diff --git a/src/renderer/components/providers/ProviderIcon.test.tsx b/src/renderer/components/providers/ProviderIcon.test.tsx index bae9327ca..d00c96dc5 100644 --- a/src/renderer/components/providers/ProviderIcon.test.tsx +++ b/src/renderer/components/providers/ProviderIcon.test.tsx @@ -2,6 +2,10 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { ProviderIcon } from "./ProviderIcon"; import "./claude"; +import "./codex"; +import "./copilot"; +import "./gemini"; +import "./grok"; describe("ProviderIcon", () => { it("uses the ACP instance id for generic fallback initials", () => { @@ -23,4 +27,32 @@ describe("ProviderIcon", () => { expect(screen.getByText("P")).toBeInTheDocument(); expect(screen.queryByText("C")).not.toBeInTheDocument(); }); + + it("uses a home profile label for the base provider icon badge", () => { + render(); + + expect(screen.getByText("W")).toBeInTheDocument(); + expect(screen.queryByText("C")).not.toBeInTheDocument(); + }); + + it("uses a home profile id when no display label is provided", () => { + render(); + + expect(screen.getByText("E")).toBeInTheDocument(); + expect(screen.queryByText("G")).not.toBeInTheDocument(); + }); + + it("strips the multiword GitHub Copilot label from a profile badge", () => { + render(); + + expect(screen.getByText("W")).toBeInTheDocument(); + expect(screen.queryByText("G")).not.toBeInTheDocument(); + }); + + it("strips the multiword Grok Build label from a profile badge", () => { + render(); + + expect(screen.getByText("W")).toBeInTheDocument(); + expect(screen.queryByText("G")).not.toBeInTheDocument(); + }); }); diff --git a/src/renderer/components/providers/ProviderIcon.tsx b/src/renderer/components/providers/ProviderIcon.tsx index 57a473ce9..b7be9202f 100644 --- a/src/renderer/components/providers/ProviderIcon.tsx +++ b/src/renderer/components/providers/ProviderIcon.tsx @@ -1,6 +1,13 @@ import type { CSSProperties, ReactNode } from "react"; -import { ACP_GENERIC_KIND_PREFIX, CLAUDE_PROFILE_KIND_PREFIX } from "@/shared/contracts"; +import { + ACP_GENERIC_KIND_PREFIX, + baseAgentKind, + isClaudeProfileKind, + isHomeProfileKind, +} from "@/shared/contracts"; +import { i18n } from "@/renderer/i18n/i18n"; import type { StatusTone } from "./statusTone"; +import { getProviderManifest } from "./providerManifest"; import { syncMaskScanPhase } from "./syncMaskScanPhase"; import { lookupProviderRegistration } from "./providerRegistry"; @@ -70,14 +77,22 @@ function fallbackInitial(label: string | undefined): string { return (raw.match(/[A-Za-z0-9]/)?.[0] ?? "?").toUpperCase(); } -function claudeProfileBadgeLabel(kind: string, fallbackLabel: string | undefined): string { - const profileId = kind.slice(CLAUDE_PROFILE_KIND_PREFIX.length); +function profileBadgeLabel(kind: string, fallbackLabel: string | undefined): string { + const profileId = kind.slice(kind.indexOf(":") + 1); + const baseKind = baseAgentKind(kind); const label = fallbackLabel?.trim(); if (!label) return profileId; - if (label === kind || label.toLowerCase().startsWith(CLAUDE_PROFILE_KIND_PREFIX)) + if (label === kind || label.toLowerCase().startsWith(`${baseKind.toLowerCase()}:`)) { return profileId; - const profileLabel = label.replace(/^claude\s+/i, "").trim(); - return profileLabel || profileId; + } + const manifestLabel = getProviderManifest(baseKind)?.label; + const providerLabels = [...(manifestLabel ? [i18n._(manifestLabel)] : []), baseKind]; + for (const providerLabel of providerLabels) { + if (label.toLowerCase().startsWith(`${providerLabel.toLowerCase()} `)) { + return label.slice(providerLabel.length).trim() || profileId; + } + } + return label; } function GenericProviderIcon(props: { label?: string; tone: StatusTone; className?: string }) { @@ -135,12 +150,12 @@ export function ProviderIcon(props: { const rendered = ( ); - if (props.kind.startsWith(CLAUDE_PROFILE_KIND_PREFIX)) { + if (isClaudeProfileKind(props.kind) || isHomeProfileKind(props.kind)) { return ( {rendered} - {fallbackInitial(claudeProfileBadgeLabel(props.kind, props.fallbackLabel))} + {fallbackInitial(profileBadgeLabel(props.kind, props.fallbackLabel))} ); diff --git a/src/renderer/components/providers/usageProviders.test.ts b/src/renderer/components/providers/usageProviders.test.ts index d07ca301b..25505a64f 100644 --- a/src/renderer/components/providers/usageProviders.test.ts +++ b/src/renderer/components/providers/usageProviders.test.ts @@ -5,6 +5,7 @@ import { isClaudeUsageProvider, pickUsageRings, resolveDisplayedProviders, + supportsBrowserLogin, usageProvidersForAgentInstances, usageRingGroups, } from "./usageProviders"; @@ -29,6 +30,24 @@ const agentInstances: AgentInstanceConfigMap = { enabled: false, config: { configDir: "~/.poracode/claude-profiles/disabled" }, }, + codexWork: { + id: "codex-work", + driver: "codex", + displayName: "Work", + config: { homeDir: "~/.poracode/codex-profiles/work" }, + }, + geminiTeam: { + id: "gemini-team", + driver: "gemini", + displayName: "Team", + config: { homeDir: "~/.poracode/gemini-profiles/team" }, + }, + grokWork: { + id: "grok-work", + driver: "grok", + displayName: "Work", + config: { homeDir: "~/.poracode/grok-profiles/work" }, + }, }; describe("usageProviders", () => { @@ -50,6 +69,32 @@ describe("usageProviders", () => { expect(providers.find((provider) => provider.id === "claude:home")?.label).toBe("Claude Home"); }); + it("adds home-isolated profile providers after their base providers", () => { + const providers = usageProvidersForAgentInstances(agentInstances); + const codexIndex = providers.findIndex((provider) => provider.id === "codex"); + const geminiIndex = providers.findIndex((provider) => provider.id === "gemini"); + + expect(providers[codexIndex + 1]).toMatchObject({ + id: "codex:codex-work", + label: "Codex Work", + }); + expect(providers[geminiIndex + 1]).toMatchObject({ + id: "gemini:gemini-team", + label: "Gemini Team", + }); + }); + + it("does not inherit base-provider browser login for isolated profiles", () => { + const profile = usageProvidersForAgentInstances(agentInstances).find( + (provider) => provider.id === "grok:grok-work", + ); + + expect(profile).toBeDefined(); + expect(profile).not.toHaveProperty("supportsBrowserLogin"); + expect(supportsBrowserLogin("grok")).toBe(true); + expect(supportsBrowserLogin("grok:grok-work")).toBe(false); + }); + it("orders, disables, and rings Claude profiles like Claude", () => { const providers = resolveDisplayedProviders( ["claude:work", "claude"], diff --git a/src/renderer/components/providers/usageProviders.ts b/src/renderer/components/providers/usageProviders.ts index 7886b3367..6ccbe9546 100644 --- a/src/renderer/components/providers/usageProviders.ts +++ b/src/renderer/components/providers/usageProviders.ts @@ -4,7 +4,10 @@ import type { UsageWindow } from "@poracode/agents-usage/types"; import { baseAgentKind, claudeProfileKind, + homeProfileKind, + isHomeProfileDriver, parseClaudeProfileInstanceConfig, + parseHomeProfileInstanceConfig, type AgentInstanceConfigMap, } from "@/shared/contracts"; @@ -127,23 +130,40 @@ export function isClaudeUsageProvider(providerId: string): boolean { return baseAgentKind(providerId) === "claude"; } -function claudeProfileUsageProviders( +function profileUsageProviders( agentInstances: AgentInstanceConfigMap | undefined, ): UsageProvider[] { if (!agentInstances) return []; const profiles: UsageProvider[] = []; for (const instance of Object.values(agentInstances)) { - if (instance.enabled === false || instance.driver !== "claude") continue; + if ( + instance.enabled === false || + (instance.driver !== "claude" && !isHomeProfileDriver(instance.driver)) + ) { + continue; + } try { - parseClaudeProfileInstanceConfig(instance.config); + if (instance.driver === "claude") { + parseClaudeProfileInstanceConfig(instance.config); + } else { + parseHomeProfileInstanceConfig(instance.config); + } } catch { continue; } - const label = instance.displayName ?? instance.id; + const baseProvider = STATIC_USAGE_PROVIDERS.find((provider) => provider.id === instance.driver); + if (!baseProvider) continue; + const profileLabel = instance.displayName ?? instance.id; + const meta = rendererMeta(instance.driver); profiles.push({ - id: claudeProfileKind(instance.id), - label: `Claude ${label}`, - ...rendererMeta("claude"), + id: + instance.driver === "claude" + ? claudeProfileKind(instance.id) + : homeProfileKind(instance.driver, instance.id), + label: `${baseProvider.label} ${profileLabel}`, + ...(meta?.sharedWindowReset ? { sharedWindowReset: true } : {}), + ...(meta?.rings ? { rings: meta.rings } : {}), + ...(meta?.ringGroups ? { ringGroups: meta.ringGroups } : {}), }); } profiles.sort((a, b) => a.label.localeCompare(b.label)); @@ -153,26 +173,24 @@ function claudeProfileUsageProviders( export function usageProvidersForAgentInstances( agentInstances: AgentInstanceConfigMap | undefined, ): UsageProvider[] { - const profiles = claudeProfileUsageProviders(agentInstances); + const profiles = profileUsageProviders(agentInstances); if (profiles.length === 0) return [...STATIC_USAGE_PROVIDERS]; const out: UsageProvider[] = []; for (const provider of STATIC_USAGE_PROVIDERS) { out.push(provider); - if (provider.id === "claude") { - out.push(...profiles); - } + out.push(...profiles.filter((profile) => baseAgentKind(profile.id) === provider.id)); } return out; } /** Providers that expose the browser-overlay login (cookie or device flow). */ export function supportsBrowserLogin(providerId: string): boolean { - return rendererMeta(providerId)?.supportsBrowserLogin === true; + return RENDERER_META[providerId]?.supportsBrowserLogin === true; } /** Providers that sign in by pasting an API key (no browser step, e.g. z.ai). */ export function supportsApiKeyLogin(providerId: string): boolean { - return rendererMeta(providerId)?.supportsApiKeyLogin === true; + return RENDERER_META[providerId]?.supportsApiKeyLogin === true; } /** Providers whose windows share one reset clock (one header countdown, no per-window resets). */ diff --git a/src/renderer/components/providers/useUsageProviderLogin.test.ts b/src/renderer/components/providers/useUsageProviderLogin.test.ts index 729efe48a..abae8fd2d 100644 --- a/src/renderer/components/providers/useUsageProviderLogin.test.ts +++ b/src/renderer/components/providers/useUsageProviderLogin.test.ts @@ -39,6 +39,16 @@ describe("useUsageProviderLogin", () => { expect(result.current.canSignIn).toBe(true); }); + it("does not offer the base provider's browser login for an auth-missing profile", () => { + useProviderUsageStore.getState().mergeSnapshot(authMissingSnapshot("grok:work")); + + const { result } = renderHook(() => useUsageProviderLogin("grok:work")); + + expect(result.current.supportsLogin).toBe(false); + expect(result.current.canSignIn).toBe(false); + expect(result.current.canSignOut).toBe(false); + }); + it("hides usage login and sign-out controls in remote sessions", () => { bridgeMock.isRemoteSession.mockReturnValue(true); useProviderUsageStore.getState().mergeSnapshot(authMissingSnapshot("grok")); diff --git a/src/renderer/locales/de/messages.po b/src/renderer/locales/de/messages.po index 55c0738d3..6da390e42 100644 --- a/src/renderer/locales/de/messages.po +++ b/src/renderer/locales/de/messages.po @@ -421,6 +421,26 @@ msgstr "{projectLocation} (deaktiviert)" msgid "{projectName} Settings" msgstr "{projectName}-Einstellungen" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {displayName} profile added." +msgstr "{providerName}-Profil {displayName} hinzugefügt." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {trimmedName} profile saved." +msgstr "{providerName}-Profil {trimmedName} gespeichert." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile home directory" +msgstr "Home-Verzeichnis des {providerName}-Profils" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile name" +msgstr "{providerName}-Profilname" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile removed." +msgstr "{providerName}-Profil entfernt." + #: src/renderer/views/MainView/parts/RightPanel/parts/NotesPanel/TodoList.tsx msgid "{remaining, plural, one {# open} other {# open}}" msgstr "{remaining, plural, one {# offen} other {# offen}}" @@ -614,6 +634,10 @@ msgstr "Aktivitätsmetrik" msgid "Add" msgstr "Hinzufügen" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Add {providerName} profile" +msgstr "{providerName}-Profil hinzufügen" + #: src/renderer/commands/registry.ts msgid "Add a local project" msgstr "Lokales Projekt hinzufügen" @@ -703,6 +727,7 @@ msgid "Add pattern" msgstr "Muster hinzufügen" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Add profile" msgstr "Profil hinzufügen" @@ -1498,6 +1523,10 @@ msgstr "Ports konnten nicht geladen werden" msgid "Cancel" msgstr "Abbrechen" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Cancel new {providerName} profile" +msgstr "Neues {providerName}-Profil abbrechen" + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Cancel new Claude profile" msgstr "Neues Claude-Profil abbrechen" @@ -1725,6 +1754,10 @@ msgstr "Chrome" msgid "Chrome MCP enabled for this thread" msgstr "Chrome-MCP für diesen Thread aktiviert" +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "Claude" +msgstr "Claude" + #. placeholder {0}: trimmedName || displayLabel #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Claude {0} profile saved." @@ -2003,6 +2036,7 @@ msgid "Code copied. " msgstr "Code kopiert." #: src/renderer/components/providers/codex/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Codex" msgstr "Codex" @@ -3219,6 +3253,7 @@ msgid "e.g. Codex GPT-5.5 fast for quick lookups, OpenCode GLM for bulk refactor msgstr "z. B. Codex GPT-5.5 fast für schnelle Recherchen, OpenCode GLM für umfangreiche Refactorings, Claude Opus für alles Kniffelige." #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "e.g. Work" msgstr "z.B. Arbeit" @@ -3899,6 +3934,7 @@ msgid "Fullscreen overlay" msgstr "Vollbild-Overlay" #: src/renderer/components/providers/gemini/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Gemini" msgstr "Gemini" @@ -3985,6 +4021,7 @@ msgid "Git status for {0}: not a Git repository" msgstr "Git-Status für {0}: kein Git-Repository" #: src/renderer/components/providers/copilot/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "GitHub Copilot" msgstr "GitHub Copilot" @@ -4058,6 +4095,7 @@ msgid "Grade {0}" msgstr "Bewertung {0}" #: src/renderer/components/providers/grok/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Grok Build" msgstr "Grok Build" @@ -4182,6 +4220,10 @@ msgstr "Verlauf" msgid "Home" msgstr "Start" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Home directory" +msgstr "Home-Verzeichnis" + #: src/mobile/views/HostFolderPicker.tsx msgid "Home folder" msgstr "Home-Ordner" @@ -5264,6 +5306,7 @@ msgstr "my-mcp-server" #: src/renderer/components/skills/SkillMarketplaceModal.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Name" msgstr "Name" @@ -5320,6 +5363,14 @@ msgstr "Neutral" msgid "New" msgstr "Neu" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile home directory" +msgstr "Home-Verzeichnis des neuen {providerName}-Profils" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile name" +msgstr "Name des neuen {providerName}-Profils" + #: src/renderer/views/ProjectSettingsOverlay/parts/ActionsSection.tsx msgid "New action command" msgstr "Neuer Aktionsbefehl" @@ -5491,6 +5542,10 @@ msgstr "Keine aktive Sitzung" msgid "No activity yet." msgstr "Noch keine Aktivität." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "No additional {providerName} profiles." +msgstr "Keine zusätzlichen {providerName}-Profile." + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "No additional Claude profiles." msgstr "Keine zusätzlichen Claude-Profile." @@ -5987,6 +6042,7 @@ msgid "Open {0}" msgstr "{0} öffnen" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Open {label}" msgstr "{label} öffnen" @@ -6648,6 +6704,7 @@ msgid "Private key" msgstr "Privater Schlüssel" #: src/mobile/settingsSections.ts +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Profile" msgstr "Profil" @@ -6657,6 +6714,7 @@ msgid "Profile stats range" msgstr "Statistikzeitraum des Profils" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Profiles" msgstr "Profile" @@ -6978,6 +7036,7 @@ msgstr "Benachrichtigungsinhalt verbergen" #: src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx #: src/renderer/views/PullRequestsView/PullRequestsView.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx #: src/renderer/views/SettingsOverlay/parts/UsageSettings.tsx msgid "Refresh" @@ -7113,6 +7172,11 @@ msgstr "{0} entfernen" msgid "Remove {0} from panel" msgstr "{0} aus dem Panel entfernen" +#. placeholder {0}: props.providerName +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Remove {0} profile" +msgstr "Profil {0} entfernen" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx msgid "Remove {pattern}" msgstr "{pattern} entfernen" @@ -7612,6 +7676,7 @@ msgstr "Laufzeitoptionen" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Speichern" @@ -7621,6 +7686,10 @@ msgstr "Speichern" msgid "Save {0} credentials." msgstr "{0}-Anmeldeinformationen speichern." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Save {providerName} profile" +msgstr "{providerName}-Profil speichern" + #: src/renderer/views/SchedulesView/ScheduleEditor.tsx msgid "Save changes" msgstr "Änderungen speichern" @@ -9113,6 +9182,10 @@ msgstr "Dadurch wird der Branch „{0}“ dauerhaft von seinem Remote gelöscht. msgid "This permanently deletes the branch \"{0}\"." msgstr "Dadurch wird der Branch „{0}“ dauerhaft gelöscht." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "This profile keeps its {providerName} account, settings, and sessions in a separate home directory." +msgstr "Dieses Profil speichert sein {providerName}-Konto, seine Einstellungen und Sitzungen in einem separaten Home-Verzeichnis." + #: src/renderer/components/skills/SkillsManager.tsx msgid "this project" msgstr "dieses Projekt" @@ -9498,6 +9571,10 @@ msgstr "{0} konnte nicht aktualisiert werden." msgid "Unable to refresh Claude profiles." msgstr "Claude-Profile können nicht aktualisiert werden." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Unable to refresh profiles." +msgstr "Profile können nicht aktualisiert werden." + #. placeholder {0}: agent.label #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Unable to remove {0} credentials." @@ -9860,6 +9937,10 @@ msgstr "Verwenden Sie Buchstaben, Zahlen, Punkte, Bindestriche oder Unterstriche msgid "Use one global folder, or nest worktrees inside each project at .poracode/worktrees." msgstr "Verwenden Sie einen globalen Ordner oder verschachteln Sie Worktrees in jedem Projekt unter .poracode/worktrees." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Use separate {providerName} accounts and settings by assigning each profile its own home directory." +msgstr "Verwenden Sie separate {providerName}-Konten und -Einstellungen, indem Sie jedem Profil ein eigenes Home-Verzeichnis zuweisen." + #: src/renderer/views/MainView/parts/AppOverlays.tsx msgid "Use Session" msgstr "Sitzung verwenden" diff --git a/src/renderer/locales/en/messages.po b/src/renderer/locales/en/messages.po index e033acf1f..a071248a0 100644 --- a/src/renderer/locales/en/messages.po +++ b/src/renderer/locales/en/messages.po @@ -421,6 +421,26 @@ msgstr "{projectLocation} (disabled)" msgid "{projectName} Settings" msgstr "{projectName} Settings" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {displayName} profile added." +msgstr "{providerName} {displayName} profile added." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {trimmedName} profile saved." +msgstr "{providerName} {trimmedName} profile saved." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile home directory" +msgstr "{providerName} profile home directory" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile name" +msgstr "{providerName} profile name" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile removed." +msgstr "{providerName} profile removed." + #: src/renderer/views/MainView/parts/RightPanel/parts/NotesPanel/TodoList.tsx msgid "{remaining, plural, one {# open} other {# open}}" msgstr "{remaining, plural, one {# open} other {# open}}" @@ -614,6 +634,10 @@ msgstr "Activity metric" msgid "Add" msgstr "Add" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Add {providerName} profile" +msgstr "Add {providerName} profile" + #: src/renderer/commands/registry.ts msgid "Add a local project" msgstr "Add a local project" @@ -703,6 +727,7 @@ msgid "Add pattern" msgstr "Add pattern" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Add profile" msgstr "Add profile" @@ -1498,6 +1523,10 @@ msgstr "Can't load ports" msgid "Cancel" msgstr "Cancel" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Cancel new {providerName} profile" +msgstr "Cancel new {providerName} profile" + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Cancel new Claude profile" msgstr "Cancel new Claude profile" @@ -1725,6 +1754,10 @@ msgstr "Chrome" msgid "Chrome MCP enabled for this thread" msgstr "Chrome MCP enabled for this thread" +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "Claude" +msgstr "Claude" + #. placeholder {0}: trimmedName || displayLabel #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Claude {0} profile saved." @@ -2003,6 +2036,7 @@ msgid "Code copied. " msgstr "Code copied. " #: src/renderer/components/providers/codex/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Codex" msgstr "Codex" @@ -3219,6 +3253,7 @@ msgid "e.g. Codex GPT-5.5 fast for quick lookups, OpenCode GLM for bulk refactor msgstr "e.g. Codex GPT-5.5 fast for quick lookups, OpenCode GLM for bulk refactors, Claude Opus for anything subtle." #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "e.g. Work" msgstr "e.g. Work" @@ -3899,6 +3934,7 @@ msgid "Fullscreen overlay" msgstr "Fullscreen overlay" #: src/renderer/components/providers/gemini/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Gemini" msgstr "Gemini" @@ -3985,6 +4021,7 @@ msgid "Git status for {0}: not a Git repository" msgstr "Git status for {0}: not a Git repository" #: src/renderer/components/providers/copilot/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "GitHub Copilot" msgstr "GitHub Copilot" @@ -4058,6 +4095,7 @@ msgid "Grade {0}" msgstr "Grade {0}" #: src/renderer/components/providers/grok/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Grok Build" msgstr "Grok Build" @@ -4182,6 +4220,10 @@ msgstr "History" msgid "Home" msgstr "Home" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Home directory" +msgstr "Home directory" + #: src/mobile/views/HostFolderPicker.tsx msgid "Home folder" msgstr "Home folder" @@ -5264,6 +5306,7 @@ msgstr "my-mcp-server" #: src/renderer/components/skills/SkillMarketplaceModal.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Name" msgstr "Name" @@ -5320,6 +5363,14 @@ msgstr "Neutral" msgid "New" msgstr "New" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile home directory" +msgstr "New {providerName} profile home directory" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile name" +msgstr "New {providerName} profile name" + #: src/renderer/views/ProjectSettingsOverlay/parts/ActionsSection.tsx msgid "New action command" msgstr "New action command" @@ -5491,6 +5542,10 @@ msgstr "No active session" msgid "No activity yet." msgstr "No activity yet." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "No additional {providerName} profiles." +msgstr "No additional {providerName} profiles." + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "No additional Claude profiles." msgstr "No additional Claude profiles." @@ -5987,6 +6042,7 @@ msgid "Open {0}" msgstr "Open {0}" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Open {label}" msgstr "Open {label}" @@ -6648,6 +6704,7 @@ msgid "Private key" msgstr "Private key" #: src/mobile/settingsSections.ts +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Profile" msgstr "Profile" @@ -6657,6 +6714,7 @@ msgid "Profile stats range" msgstr "Profile stats range" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Profiles" msgstr "Profiles" @@ -6978,6 +7036,7 @@ msgstr "Redact notification content" #: src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx #: src/renderer/views/PullRequestsView/PullRequestsView.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx #: src/renderer/views/SettingsOverlay/parts/UsageSettings.tsx msgid "Refresh" @@ -7113,6 +7172,11 @@ msgstr "Remove {0}" msgid "Remove {0} from panel" msgstr "Remove {0} from panel" +#. placeholder {0}: props.providerName +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Remove {0} profile" +msgstr "Remove {0} profile" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx msgid "Remove {pattern}" msgstr "Remove {pattern}" @@ -7612,6 +7676,7 @@ msgstr "Runtime options" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Save" @@ -7621,6 +7686,10 @@ msgstr "Save" msgid "Save {0} credentials." msgstr "Save {0} credentials." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Save {providerName} profile" +msgstr "Save {providerName} profile" + #: src/renderer/views/SchedulesView/ScheduleEditor.tsx msgid "Save changes" msgstr "Save changes" @@ -9113,6 +9182,10 @@ msgstr "This permanently deletes the branch \"{0}\" from its remote." msgid "This permanently deletes the branch \"{0}\"." msgstr "This permanently deletes the branch \"{0}\"." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "This profile keeps its {providerName} account, settings, and sessions in a separate home directory." +msgstr "This profile keeps its {providerName} account, settings, and sessions in a separate home directory." + #: src/renderer/components/skills/SkillsManager.tsx msgid "this project" msgstr "this project" @@ -9498,6 +9571,10 @@ msgstr "Unable to refresh {0}." msgid "Unable to refresh Claude profiles." msgstr "Unable to refresh Claude profiles." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Unable to refresh profiles." +msgstr "Unable to refresh profiles." + #. placeholder {0}: agent.label #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Unable to remove {0} credentials." @@ -9860,6 +9937,10 @@ msgstr "Use letters, numbers, dots, dashes, or underscores without spaces." msgid "Use one global folder, or nest worktrees inside each project at .poracode/worktrees." msgstr "Use one global folder, or nest worktrees inside each project at .poracode/worktrees." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Use separate {providerName} accounts and settings by assigning each profile its own home directory." +msgstr "Use separate {providerName} accounts and settings by assigning each profile its own home directory." + #: src/renderer/views/MainView/parts/AppOverlays.tsx msgid "Use Session" msgstr "Use Session" diff --git a/src/renderer/locales/es/messages.po b/src/renderer/locales/es/messages.po index 7d1734c49..3030500ad 100644 --- a/src/renderer/locales/es/messages.po +++ b/src/renderer/locales/es/messages.po @@ -421,6 +421,26 @@ msgstr "{projectLocation} (deshabilitado)" msgid "{projectName} Settings" msgstr "Ajustes de {projectName}" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {displayName} profile added." +msgstr "Se añadió el perfil {providerName} {displayName}." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {trimmedName} profile saved." +msgstr "Se guardó el perfil {providerName} {trimmedName}." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile home directory" +msgstr "Directorio de inicio del perfil de {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile name" +msgstr "Nombre del perfil de {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile removed." +msgstr "Se eliminó el perfil de {providerName}." + #: src/renderer/views/MainView/parts/RightPanel/parts/NotesPanel/TodoList.tsx msgid "{remaining, plural, one {# open} other {# open}}" msgstr "{remaining, plural, one {# abierto} other {# abiertos}}" @@ -614,6 +634,10 @@ msgstr "Métrica de actividad" msgid "Add" msgstr "Añadir" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Add {providerName} profile" +msgstr "Añadir perfil de {providerName}" + #: src/renderer/commands/registry.ts msgid "Add a local project" msgstr "Agregar un proyecto local" @@ -703,6 +727,7 @@ msgid "Add pattern" msgstr "Añadir patrón" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Add profile" msgstr "Agregar perfil" @@ -1498,6 +1523,10 @@ msgstr "No se pudieron cargar los puertos" msgid "Cancel" msgstr "Cancelar" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Cancel new {providerName} profile" +msgstr "Cancelar nuevo perfil de {providerName}" + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Cancel new Claude profile" msgstr "Cancelar nuevo perfil de Claude" @@ -1725,6 +1754,10 @@ msgstr "Chrome" msgid "Chrome MCP enabled for this thread" msgstr "Chrome MCP habilitado para este hilo" +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "Claude" +msgstr "Claude" + #. placeholder {0}: trimmedName || displayLabel #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Claude {0} profile saved." @@ -2003,6 +2036,7 @@ msgid "Code copied. " msgstr "Código copiado. " #: src/renderer/components/providers/codex/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Codex" msgstr "Codex" @@ -3219,6 +3253,7 @@ msgid "e.g. Codex GPT-5.5 fast for quick lookups, OpenCode GLM for bulk refactor msgstr "p. ej. Codex GPT-5.5 fast para búsquedas rápidas, OpenCode GLM para refactorizaciones masivas, Claude Opus para cualquier cosa delicada." #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "e.g. Work" msgstr "p. ej. Trabajo" @@ -3899,6 +3934,7 @@ msgid "Fullscreen overlay" msgstr "Superposición a pantalla completa" #: src/renderer/components/providers/gemini/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Gemini" msgstr "Gemini" @@ -3985,6 +4021,7 @@ msgid "Git status for {0}: not a Git repository" msgstr "Estado de Git para {0}: no es un repositorio Git" #: src/renderer/components/providers/copilot/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "GitHub Copilot" msgstr "GitHub Copilot" @@ -4058,6 +4095,7 @@ msgid "Grade {0}" msgstr "Calificación {0}" #: src/renderer/components/providers/grok/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Grok Build" msgstr "Grok Build" @@ -4182,6 +4220,10 @@ msgstr "Historial" msgid "Home" msgstr "Inicio" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Home directory" +msgstr "Directorio de inicio" + #: src/mobile/views/HostFolderPicker.tsx msgid "Home folder" msgstr "Carpeta de inicio" @@ -5264,6 +5306,7 @@ msgstr "my-mcp-server" #: src/renderer/components/skills/SkillMarketplaceModal.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Name" msgstr "Nombre" @@ -5320,6 +5363,14 @@ msgstr "Neutral" msgid "New" msgstr "Nuevo" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile home directory" +msgstr "Directorio de inicio del nuevo perfil de {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile name" +msgstr "Nombre del nuevo perfil de {providerName}" + #: src/renderer/views/ProjectSettingsOverlay/parts/ActionsSection.tsx msgid "New action command" msgstr "Nuevo comando de acción" @@ -5491,6 +5542,10 @@ msgstr "Sin sesión activa" msgid "No activity yet." msgstr "Aún no hay actividad." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "No additional {providerName} profiles." +msgstr "No hay perfiles adicionales de {providerName}." + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "No additional Claude profiles." msgstr "No hay perfiles de Claude adicionales." @@ -5987,6 +6042,7 @@ msgid "Open {0}" msgstr "Abrir {0}" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Open {label}" msgstr "Abrir {label}" @@ -6648,6 +6704,7 @@ msgid "Private key" msgstr "Clave privada" #: src/mobile/settingsSections.ts +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Profile" msgstr "Perfil" @@ -6657,6 +6714,7 @@ msgid "Profile stats range" msgstr "Rango de estadísticas del perfil" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Profiles" msgstr "Perfiles" @@ -6978,6 +7036,7 @@ msgstr "Ocultar el contenido de las notificaciones" #: src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx #: src/renderer/views/PullRequestsView/PullRequestsView.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx #: src/renderer/views/SettingsOverlay/parts/UsageSettings.tsx msgid "Refresh" @@ -7113,6 +7172,11 @@ msgstr "Eliminar {0}" msgid "Remove {0} from panel" msgstr "Eliminar {0} del panel" +#. placeholder {0}: props.providerName +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Remove {0} profile" +msgstr "Eliminar perfil {0}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx msgid "Remove {pattern}" msgstr "Eliminar {pattern}" @@ -7612,6 +7676,7 @@ msgstr "Opciones de runtime" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Guardar" @@ -7621,6 +7686,10 @@ msgstr "Guardar" msgid "Save {0} credentials." msgstr "Guardar credenciales de {0}." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Save {providerName} profile" +msgstr "Guardar perfil de {providerName}" + #: src/renderer/views/SchedulesView/ScheduleEditor.tsx msgid "Save changes" msgstr "Guardar cambios" @@ -9113,6 +9182,10 @@ msgstr "Esto elimina permanentemente la rama \"{0}\" de su remoto." msgid "This permanently deletes the branch \"{0}\"." msgstr "Esto elimina permanentemente la rama \"{0}\"." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "This profile keeps its {providerName} account, settings, and sessions in a separate home directory." +msgstr "Este perfil mantiene la cuenta, la configuración y las sesiones de {providerName} en un directorio de inicio separado." + #: src/renderer/components/skills/SkillsManager.tsx msgid "this project" msgstr "este proyecto" @@ -9498,6 +9571,10 @@ msgstr "No se puede actualizar {0}." msgid "Unable to refresh Claude profiles." msgstr "No se pueden actualizar los perfiles de Claude." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Unable to refresh profiles." +msgstr "No se pudieron actualizar los perfiles." + #. placeholder {0}: agent.label #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Unable to remove {0} credentials." @@ -9860,6 +9937,10 @@ msgstr "Usa letras, números, puntos, guiones o guiones bajos sin espacios." msgid "Use one global folder, or nest worktrees inside each project at .poracode/worktrees." msgstr "Usa una sola carpeta global o anida los worktrees dentro de cada proyecto en .poracode/worktrees." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Use separate {providerName} accounts and settings by assigning each profile its own home directory." +msgstr "Usa cuentas y configuraciones de {providerName} separadas asignando a cada perfil su propio directorio de inicio." + #: src/renderer/views/MainView/parts/AppOverlays.tsx msgid "Use Session" msgstr "Usar sesión" diff --git a/src/renderer/locales/fr/messages.po b/src/renderer/locales/fr/messages.po index 563b5cf75..abac07a93 100644 --- a/src/renderer/locales/fr/messages.po +++ b/src/renderer/locales/fr/messages.po @@ -421,6 +421,26 @@ msgstr "{projectLocation} (désactivé)" msgid "{projectName} Settings" msgstr "Paramètres {projectName}" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {displayName} profile added." +msgstr "Profil {providerName} {displayName} ajouté." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {trimmedName} profile saved." +msgstr "Profil {providerName} {trimmedName} enregistré." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile home directory" +msgstr "Répertoire personnel du profil {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile name" +msgstr "Nom du profil {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile removed." +msgstr "Profil {providerName} supprimé." + #: src/renderer/views/MainView/parts/RightPanel/parts/NotesPanel/TodoList.tsx msgid "{remaining, plural, one {# open} other {# open}}" msgstr "{remaining, plural, one {# ouvert} other {# ouverts}}" @@ -614,6 +634,10 @@ msgstr "Métrique d'activité" msgid "Add" msgstr "Ajouter" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Add {providerName} profile" +msgstr "Ajouter un profil {providerName}" + #: src/renderer/commands/registry.ts msgid "Add a local project" msgstr "Ajouter un projet local" @@ -703,6 +727,7 @@ msgid "Add pattern" msgstr "Ajouter un motif" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Add profile" msgstr "Ajouter un profil" @@ -1498,6 +1523,10 @@ msgstr "Impossible de charger les ports" msgid "Cancel" msgstr "Annuler" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Cancel new {providerName} profile" +msgstr "Annuler le nouveau profil {providerName}" + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Cancel new Claude profile" msgstr "Annuler le nouveau profil Claude" @@ -1725,6 +1754,10 @@ msgstr "Chrome" msgid "Chrome MCP enabled for this thread" msgstr "MCP Chrome activé pour ce fil de discussion" +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "Claude" +msgstr "Claude" + #. placeholder {0}: trimmedName || displayLabel #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Claude {0} profile saved." @@ -2003,6 +2036,7 @@ msgid "Code copied. " msgstr "Code copié." #: src/renderer/components/providers/codex/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Codex" msgstr "Codex" @@ -3219,6 +3253,7 @@ msgid "e.g. Codex GPT-5.5 fast for quick lookups, OpenCode GLM for bulk refactor msgstr "par ex. Codex GPT-5.5 fast pour les recherches rapides, OpenCode GLM pour les refactorisations en masse, Claude Opus pour tout ce qui est subtil." #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "e.g. Work" msgstr "par ex. Travail" @@ -3899,6 +3934,7 @@ msgid "Fullscreen overlay" msgstr "Superposition plein écran" #: src/renderer/components/providers/gemini/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Gemini" msgstr "Gemini" @@ -3985,6 +4021,7 @@ msgid "Git status for {0}: not a Git repository" msgstr "Statut Git pour {0} : pas un dépôt Git" #: src/renderer/components/providers/copilot/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "GitHub Copilot" msgstr "GitHub Copilot" @@ -4058,6 +4095,7 @@ msgid "Grade {0}" msgstr "Note {0}" #: src/renderer/components/providers/grok/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Grok Build" msgstr "Grok Build" @@ -4182,6 +4220,10 @@ msgstr "Historique" msgid "Home" msgstr "Accueil" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Home directory" +msgstr "Répertoire personnel" + #: src/mobile/views/HostFolderPicker.tsx msgid "Home folder" msgstr "Dossier personnel" @@ -5263,6 +5305,7 @@ msgstr "my-mcp-server" #: src/renderer/components/skills/SkillMarketplaceModal.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Name" msgstr "Nom" @@ -5319,6 +5362,14 @@ msgstr "Neutre" msgid "New" msgstr "Nouveau" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile home directory" +msgstr "Répertoire personnel du nouveau profil {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile name" +msgstr "Nom du nouveau profil {providerName}" + #: src/renderer/views/ProjectSettingsOverlay/parts/ActionsSection.tsx msgid "New action command" msgstr "Nouvelle commande d'action" @@ -5490,6 +5541,10 @@ msgstr "Aucune session active" msgid "No activity yet." msgstr "Aucune activité pour l'instant." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "No additional {providerName} profiles." +msgstr "Aucun profil {providerName} supplémentaire." + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "No additional Claude profiles." msgstr "Aucun profil Claude supplémentaire." @@ -5986,6 +6041,7 @@ msgid "Open {0}" msgstr "Ouvrir {0}" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Open {label}" msgstr "Ouvrir {label}" @@ -6647,6 +6703,7 @@ msgid "Private key" msgstr "Clé privée" #: src/mobile/settingsSections.ts +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Profile" msgstr "Profil" @@ -6656,6 +6713,7 @@ msgid "Profile stats range" msgstr "Plage des statistiques du profil" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Profiles" msgstr "Profils" @@ -6977,6 +7035,7 @@ msgstr "Masquer le contenu des notifications" #: src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx #: src/renderer/views/PullRequestsView/PullRequestsView.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx #: src/renderer/views/SettingsOverlay/parts/UsageSettings.tsx msgid "Refresh" @@ -7112,6 +7171,11 @@ msgstr "Supprimer {0}" msgid "Remove {0} from panel" msgstr "Supprimer {0} du panneau" +#. placeholder {0}: props.providerName +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Remove {0} profile" +msgstr "Supprimer le profil {0}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx msgid "Remove {pattern}" msgstr "Supprimer {pattern}" @@ -7611,6 +7675,7 @@ msgstr "Options d'exécution" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Enregistrer" @@ -7620,6 +7685,10 @@ msgstr "Enregistrer" msgid "Save {0} credentials." msgstr "Enregistrer les informations d'identification {0}." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Save {providerName} profile" +msgstr "Enregistrer le profil {providerName}" + #: src/renderer/views/SchedulesView/ScheduleEditor.tsx msgid "Save changes" msgstr "Enregistrer les modifications" @@ -9112,6 +9181,10 @@ msgstr "Cela supprime définitivement la branche \"{0}\" de son dépôt distant. msgid "This permanently deletes the branch \"{0}\"." msgstr "Cela supprime définitivement la branche \"{0}\"." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "This profile keeps its {providerName} account, settings, and sessions in a separate home directory." +msgstr "Ce profil conserve son compte {providerName}, ses paramètres et ses sessions dans un répertoire personnel distinct." + #: src/renderer/components/skills/SkillsManager.tsx msgid "this project" msgstr "ce projet" @@ -9497,6 +9570,10 @@ msgstr "Impossible d'actualiser {0}." msgid "Unable to refresh Claude profiles." msgstr "Impossible d'actualiser les profils Claude." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Unable to refresh profiles." +msgstr "Impossible d'actualiser les profils." + #. placeholder {0}: agent.label #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Unable to remove {0} credentials." @@ -9859,6 +9936,10 @@ msgstr "Utilisez des lettres, des chiffres, des points, des tirets ou des traits msgid "Use one global folder, or nest worktrees inside each project at .poracode/worktrees." msgstr "Utilisez un seul dossier global, ou imbriquez les arbres de travail dans chaque projet sous .poracode/worktrees." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Use separate {providerName} accounts and settings by assigning each profile its own home directory." +msgstr "Utilisez des comptes et paramètres {providerName} distincts en attribuant à chaque profil son propre répertoire personnel." + #: src/renderer/views/MainView/parts/AppOverlays.tsx msgid "Use Session" msgstr "Utiliser la session" diff --git a/src/renderer/locales/ja/messages.po b/src/renderer/locales/ja/messages.po index b1b2e6c34..0b586059e 100644 --- a/src/renderer/locales/ja/messages.po +++ b/src/renderer/locales/ja/messages.po @@ -420,6 +420,26 @@ msgstr "{projectLocation}(無効)" msgid "{projectName} Settings" msgstr "{projectName}設定" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {displayName} profile added." +msgstr "{providerName} {displayName} プロファイルを追加しました。" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {trimmedName} profile saved." +msgstr "{providerName} {trimmedName} プロファイルを保存しました。" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile home directory" +msgstr "{providerName} プロファイルのホームディレクトリ" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile name" +msgstr "{providerName} プロファイル名" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile removed." +msgstr "{providerName} プロファイルを削除しました。" + #: src/renderer/views/MainView/parts/RightPanel/parts/NotesPanel/TodoList.tsx msgid "{remaining, plural, one {# open} other {# open}}" msgstr "{remaining, plural, one {#件オープン} other {#件オープン}}" @@ -613,6 +633,10 @@ msgstr "アクティビティ指標" msgid "Add" msgstr "追加" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Add {providerName} profile" +msgstr "{providerName} プロファイルを追加" + #: src/renderer/commands/registry.ts msgid "Add a local project" msgstr "ローカルプロジェクトを追加" @@ -702,6 +726,7 @@ msgid "Add pattern" msgstr "パターンを追加する" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Add profile" msgstr "プロフィールを追加" @@ -1497,6 +1522,10 @@ msgstr "ポートを読み込めません" msgid "Cancel" msgstr "キャンセル" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Cancel new {providerName} profile" +msgstr "新しい {providerName} プロファイルをキャンセル" + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Cancel new Claude profile" msgstr "新しい Claude プロファイルをキャンセル" @@ -1724,6 +1753,10 @@ msgstr "Chrome" msgid "Chrome MCP enabled for this thread" msgstr "このスレッドに対して Chrome MCP が有効になっています" +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "Claude" +msgstr "Claude" + #. placeholder {0}: trimmedName || displayLabel #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Claude {0} profile saved." @@ -2002,6 +2035,7 @@ msgid "Code copied. " msgstr "コードがコピーされました。" #: src/renderer/components/providers/codex/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Codex" msgstr "Codex" @@ -3218,6 +3252,7 @@ msgid "e.g. Codex GPT-5.5 fast for quick lookups, OpenCode GLM for bulk refactor msgstr "例:クイックな調査には Codex GPT-5.5 fast、大量のリファクタリングには OpenCode GLM、繊細な作業には Claude Opus。" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "e.g. Work" msgstr "例: 仕事" @@ -3898,6 +3933,7 @@ msgid "Fullscreen overlay" msgstr "全画面オーバーレイ" #: src/renderer/components/providers/gemini/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Gemini" msgstr "Gemini" @@ -3984,6 +4020,7 @@ msgid "Git status for {0}: not a Git repository" msgstr "{0}の Git ステータス: Git リポジトリではありません" #: src/renderer/components/providers/copilot/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "GitHub Copilot" msgstr "GitHub Copilot" @@ -4057,6 +4094,7 @@ msgid "Grade {0}" msgstr "グレード {0}" #: src/renderer/components/providers/grok/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Grok Build" msgstr "Grok Build" @@ -4181,6 +4219,10 @@ msgstr "履歴" msgid "Home" msgstr "ホーム" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Home directory" +msgstr "ホームディレクトリ" + #: src/mobile/views/HostFolderPicker.tsx msgid "Home folder" msgstr "ホームフォルダー" @@ -5262,6 +5304,7 @@ msgstr "my-mcp-server" #: src/renderer/components/skills/SkillMarketplaceModal.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Name" msgstr "名前" @@ -5318,6 +5361,14 @@ msgstr "中立" msgid "New" msgstr "新機能" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile home directory" +msgstr "新しい {providerName} プロファイルのホームディレクトリ" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile name" +msgstr "新しい {providerName} プロファイル名" + #: src/renderer/views/ProjectSettingsOverlay/parts/ActionsSection.tsx msgid "New action command" msgstr "新しいアクションコマンド" @@ -5489,6 +5540,10 @@ msgstr "アクティブなセッションがありません" msgid "No activity yet." msgstr "まだアクティビティがありません。" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "No additional {providerName} profiles." +msgstr "追加の {providerName} プロファイルはありません。" + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "No additional Claude profiles." msgstr "追加の Claude プロファイルはありません。" @@ -5985,6 +6040,7 @@ msgid "Open {0}" msgstr "{0} を開く" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Open {label}" msgstr "{label}を開く" @@ -6646,6 +6702,7 @@ msgid "Private key" msgstr "秘密鍵" #: src/mobile/settingsSections.ts +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Profile" msgstr "プロフィール" @@ -6655,6 +6712,7 @@ msgid "Profile stats range" msgstr "プロフィール統計の期間" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Profiles" msgstr "プロファイル" @@ -6976,6 +7034,7 @@ msgstr "通知内容を伏せる" #: src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx #: src/renderer/views/PullRequestsView/PullRequestsView.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx #: src/renderer/views/SettingsOverlay/parts/UsageSettings.tsx msgid "Refresh" @@ -7111,6 +7170,11 @@ msgstr "{0}を削除" msgid "Remove {0} from panel" msgstr "{0}をパネルから削除" +#. placeholder {0}: props.providerName +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Remove {0} profile" +msgstr "{0} プロファイルを削除" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx msgid "Remove {pattern}" msgstr "{pattern}を削除" @@ -7610,6 +7674,7 @@ msgstr "実行時オプション" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "保存" @@ -7619,6 +7684,10 @@ msgstr "保存" msgid "Save {0} credentials." msgstr "{0}資格情報を保存します。" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Save {providerName} profile" +msgstr "{providerName} プロファイルを保存" + #: src/renderer/views/SchedulesView/ScheduleEditor.tsx msgid "Save changes" msgstr "変更を保存" @@ -9111,6 +9180,10 @@ msgstr "これにより、ブランチ「{0}」がリモートから完全に削 msgid "This permanently deletes the branch \"{0}\"." msgstr "これにより、ブランチ「{0}」が完全に削除されます。" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "This profile keeps its {providerName} account, settings, and sessions in a separate home directory." +msgstr "このプロファイルでは、{providerName} のアカウント、設定、セッションが別のホームディレクトリに保存されます。" + #: src/renderer/components/skills/SkillsManager.tsx msgid "this project" msgstr "このプロジェクト" @@ -9496,6 +9569,10 @@ msgstr "{0}を更新できません。" msgid "Unable to refresh Claude profiles." msgstr "Claude プロファイルを更新できません。" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Unable to refresh profiles." +msgstr "プロファイルを更新できません。" + #. placeholder {0}: agent.label #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Unable to remove {0} credentials." @@ -9858,6 +9935,10 @@ msgstr "スペースを使わず、英数字、ピリオド、ハイフン、ま msgid "Use one global folder, or nest worktrees inside each project at .poracode/worktrees." msgstr "1 つのグローバルフォルダーを使用するか、各プロジェクト内の .poracode/worktrees にワークツリーをネストします。" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Use separate {providerName} accounts and settings by assigning each profile its own home directory." +msgstr "各プロファイルに固有のホームディレクトリを割り当てて、個別の {providerName} アカウントと設定を使用します。" + #: src/renderer/views/MainView/parts/AppOverlays.tsx msgid "Use Session" msgstr "セッションを使用する" diff --git a/src/renderer/locales/ko/messages.po b/src/renderer/locales/ko/messages.po index 7e5a9fac7..f9b718eaa 100644 --- a/src/renderer/locales/ko/messages.po +++ b/src/renderer/locales/ko/messages.po @@ -421,6 +421,26 @@ msgstr "{projectLocation}(비활성화)" msgid "{projectName} Settings" msgstr "{projectName} 설정" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {displayName} profile added." +msgstr "{providerName} {displayName} 프로필을 추가했습니다." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {trimmedName} profile saved." +msgstr "{providerName} {trimmedName} 프로필을 저장했습니다." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile home directory" +msgstr "{providerName} 프로필 홈 디렉터리" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile name" +msgstr "{providerName} 프로필 이름" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile removed." +msgstr "{providerName} 프로필을 삭제했습니다." + #: src/renderer/views/MainView/parts/RightPanel/parts/NotesPanel/TodoList.tsx msgid "{remaining, plural, one {# open} other {# open}}" msgstr "{remaining, plural, one {# 열림} other {# 열림}}" @@ -614,6 +634,10 @@ msgstr "활동 지표" msgid "Add" msgstr "추가" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Add {providerName} profile" +msgstr "{providerName} 프로필 추가" + #: src/renderer/commands/registry.ts msgid "Add a local project" msgstr "로컬 프로젝트 추가" @@ -703,6 +727,7 @@ msgid "Add pattern" msgstr "패턴 추가" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Add profile" msgstr "프로필 추가" @@ -1498,6 +1523,10 @@ msgstr "포트를 불러올 수 없습니다" msgid "Cancel" msgstr "취소" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Cancel new {providerName} profile" +msgstr "새 {providerName} 프로필 취소" + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Cancel new Claude profile" msgstr "새 Claude 프로필 취소" @@ -1725,6 +1754,10 @@ msgstr "Chrome" msgid "Chrome MCP enabled for this thread" msgstr "이 스레드에 대해 Chrome MCP가 활성화되었습니다." +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "Claude" +msgstr "Claude" + #. placeholder {0}: trimmedName || displayLabel #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Claude {0} profile saved." @@ -2003,6 +2036,7 @@ msgid "Code copied. " msgstr "코드가 복사되었습니다." #: src/renderer/components/providers/codex/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Codex" msgstr "Codex" @@ -3219,6 +3253,7 @@ msgid "e.g. Codex GPT-5.5 fast for quick lookups, OpenCode GLM for bulk refactor msgstr "예: 빠른 조회에는 Codex GPT-5.5 fast, 대규모 리팩터링에는 OpenCode GLM, 미묘한 작업에는 Claude Opus." #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "e.g. Work" msgstr "예: 업무" @@ -3899,6 +3934,7 @@ msgid "Fullscreen overlay" msgstr "전체화면 오버레이" #: src/renderer/components/providers/gemini/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Gemini" msgstr "Gemini" @@ -3985,6 +4021,7 @@ msgid "Git status for {0}: not a Git repository" msgstr "{0}에 대한 Git 상태: Git 저장소가 아닙니다." #: src/renderer/components/providers/copilot/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "GitHub Copilot" msgstr "GitHub Copilot" @@ -4058,6 +4095,7 @@ msgid "Grade {0}" msgstr "등급 {0}" #: src/renderer/components/providers/grok/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Grok Build" msgstr "Grok Build" @@ -4182,6 +4220,10 @@ msgstr "기록" msgid "Home" msgstr "홈" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Home directory" +msgstr "홈 디렉터리" + #: src/mobile/views/HostFolderPicker.tsx msgid "Home folder" msgstr "홈 폴더" @@ -5264,6 +5306,7 @@ msgstr "my-mcp-server" #: src/renderer/components/skills/SkillMarketplaceModal.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Name" msgstr "이름" @@ -5320,6 +5363,14 @@ msgstr "중립" msgid "New" msgstr "신규" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile home directory" +msgstr "새 {providerName} 프로필 홈 디렉터리" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile name" +msgstr "새 {providerName} 프로필 이름" + #: src/renderer/views/ProjectSettingsOverlay/parts/ActionsSection.tsx msgid "New action command" msgstr "새로운 동작 명령" @@ -5491,6 +5542,10 @@ msgstr "활성 세션 없음" msgid "No activity yet." msgstr "아직 활동이 없습니다." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "No additional {providerName} profiles." +msgstr "추가 {providerName} 프로필이 없습니다." + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "No additional Claude profiles." msgstr "추가 Claude 프로필이 없습니다." @@ -5987,6 +6042,7 @@ msgid "Open {0}" msgstr "{0} 열기" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Open {label}" msgstr "{label} 열기" @@ -6648,6 +6704,7 @@ msgid "Private key" msgstr "개인 키" #: src/mobile/settingsSections.ts +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Profile" msgstr "프로필" @@ -6657,6 +6714,7 @@ msgid "Profile stats range" msgstr "프로필 통계 기간" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Profiles" msgstr "프로필" @@ -6978,6 +7036,7 @@ msgstr "알림 내용 가리기" #: src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx #: src/renderer/views/PullRequestsView/PullRequestsView.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx #: src/renderer/views/SettingsOverlay/parts/UsageSettings.tsx msgid "Refresh" @@ -7113,6 +7172,11 @@ msgstr "{0} 제거" msgid "Remove {0} from panel" msgstr "패널에서 {0} 제거" +#. placeholder {0}: props.providerName +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Remove {0} profile" +msgstr "{0} 프로필 삭제" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx msgid "Remove {pattern}" msgstr "{pattern} 제거" @@ -7612,6 +7676,7 @@ msgstr "런타임 옵션" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "저장" @@ -7621,6 +7686,10 @@ msgstr "저장" msgid "Save {0} credentials." msgstr "{0} 자격 증명을 저장합니다." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Save {providerName} profile" +msgstr "{providerName} 프로필 저장" + #: src/renderer/views/SchedulesView/ScheduleEditor.tsx msgid "Save changes" msgstr "변경 사항 저장" @@ -9113,6 +9182,10 @@ msgstr "이렇게 하면 원격에서 \"{0}\" 분기가 영구적으로 삭제 msgid "This permanently deletes the branch \"{0}\"." msgstr "이렇게 하면 \"{0}\" 분기가 영구적으로 삭제됩니다." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "This profile keeps its {providerName} account, settings, and sessions in a separate home directory." +msgstr "이 프로필은 {providerName} 계정, 설정 및 세션을 별도의 홈 디렉터리에 보관합니다." + #: src/renderer/components/skills/SkillsManager.tsx msgid "this project" msgstr "이 프로젝트" @@ -9498,6 +9571,10 @@ msgstr "{0}을(를) 새로 고칠 수 없습니다." msgid "Unable to refresh Claude profiles." msgstr "Claude 프로필을 새로 고칠 수 없습니다." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Unable to refresh profiles." +msgstr "프로필을 새로 고칠 수 없습니다." + #. placeholder {0}: agent.label #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Unable to remove {0} credentials." @@ -9860,6 +9937,10 @@ msgstr "공백 없이 문자, 숫자, 마침표, 대시 또는 밑줄을 사용 msgid "Use one global folder, or nest worktrees inside each project at .poracode/worktrees." msgstr "하나의 전역 폴더를 사용하거나, 각 프로젝트의 .poracode/worktrees에 작업 트리를 중첩합니다." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Use separate {providerName} accounts and settings by assigning each profile its own home directory." +msgstr "각 프로필에 고유한 홈 디렉터리를 할당하여 별도의 {providerName} 계정과 설정을 사용합니다." + #: src/renderer/views/MainView/parts/AppOverlays.tsx msgid "Use Session" msgstr "세션 사용" diff --git a/src/renderer/locales/pl/messages.po b/src/renderer/locales/pl/messages.po index df06a2dd7..dfb0d507f 100644 --- a/src/renderer/locales/pl/messages.po +++ b/src/renderer/locales/pl/messages.po @@ -421,6 +421,26 @@ msgstr "{projectLocation} (wyłączone)" msgid "{projectName} Settings" msgstr "Ustawienia: {projectName}" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {displayName} profile added." +msgstr "Dodano profil {providerName} {displayName}." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {trimmedName} profile saved." +msgstr "Zapisano profil {providerName} {trimmedName}." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile home directory" +msgstr "Katalog domowy profilu {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile name" +msgstr "Nazwa profilu {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile removed." +msgstr "Usunięto profil {providerName}." + #: src/renderer/views/MainView/parts/RightPanel/parts/NotesPanel/TodoList.tsx msgid "{remaining, plural, one {# open} other {# open}}" msgstr "{remaining, plural, one {# otwarty} few {# otwarte} many {# otwartych} other {# otwartych}}" @@ -614,6 +634,10 @@ msgstr "Metryka aktywności" msgid "Add" msgstr "Dodaj" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Add {providerName} profile" +msgstr "Dodaj profil {providerName}" + #: src/renderer/commands/registry.ts msgid "Add a local project" msgstr "Dodaj projekt lokalny" @@ -703,6 +727,7 @@ msgid "Add pattern" msgstr "Dodaj wzór" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Add profile" msgstr "Dodaj profil" @@ -1498,6 +1523,10 @@ msgstr "Nie można wczytać portów" msgid "Cancel" msgstr "Anuluj" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Cancel new {providerName} profile" +msgstr "Anuluj nowy profil {providerName}" + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Cancel new Claude profile" msgstr "Anuluj nowy profil Claude" @@ -1725,6 +1754,10 @@ msgstr "Chrome" msgid "Chrome MCP enabled for this thread" msgstr "Chrome MCP jest włączony dla tego wątku" +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "Claude" +msgstr "Claude" + #. placeholder {0}: trimmedName || displayLabel #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Claude {0} profile saved." @@ -2003,6 +2036,7 @@ msgid "Code copied. " msgstr "Kod skopiowany." #: src/renderer/components/providers/codex/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Codex" msgstr "Codex" @@ -3219,6 +3253,7 @@ msgid "e.g. Codex GPT-5.5 fast for quick lookups, OpenCode GLM for bulk refactor msgstr "np. Codex GPT-5.5 fast do szybkich wyszukiwań, OpenCode GLM do masowych refaktoryzacji, Claude Opus do wszystkiego, co subtelne." #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "e.g. Work" msgstr "np. Praca" @@ -3899,6 +3934,7 @@ msgid "Fullscreen overlay" msgstr "Nakładka pełnoekranowa" #: src/renderer/components/providers/gemini/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Gemini" msgstr "Gemini" @@ -3985,6 +4021,7 @@ msgid "Git status for {0}: not a Git repository" msgstr "Status Git dla {0}: to nie jest repozytorium Git" #: src/renderer/components/providers/copilot/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "GitHub Copilot" msgstr "GitHub Copilot" @@ -4058,6 +4095,7 @@ msgid "Grade {0}" msgstr "Ocena {0}" #: src/renderer/components/providers/grok/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Grok Build" msgstr "Grok Build" @@ -4182,6 +4220,10 @@ msgstr "Historia" msgid "Home" msgstr "Strona główna" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Home directory" +msgstr "Katalog domowy" + #: src/mobile/views/HostFolderPicker.tsx msgid "Home folder" msgstr "Folder domowy" @@ -5264,6 +5306,7 @@ msgstr "my-mcp-server" #: src/renderer/components/skills/SkillMarketplaceModal.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Name" msgstr "Nazwa" @@ -5320,6 +5363,14 @@ msgstr "Neutralny" msgid "New" msgstr "Nowość" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile home directory" +msgstr "Katalog domowy nowego profilu {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile name" +msgstr "Nazwa nowego profilu {providerName}" + #: src/renderer/views/ProjectSettingsOverlay/parts/ActionsSection.tsx msgid "New action command" msgstr "Nowe polecenie akcji" @@ -5491,6 +5542,10 @@ msgstr "Brak aktywnej sesji" msgid "No activity yet." msgstr "Brak aktywności." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "No additional {providerName} profiles." +msgstr "Brak dodatkowych profili {providerName}." + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "No additional Claude profiles." msgstr "Żadnych dodatkowych profili Claude." @@ -5987,6 +6042,7 @@ msgid "Open {0}" msgstr "Otwórz {0}" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Open {label}" msgstr "Otwórz {label}" @@ -6648,6 +6704,7 @@ msgid "Private key" msgstr "Klucz prywatny" #: src/mobile/settingsSections.ts +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Profile" msgstr "Profil" @@ -6657,6 +6714,7 @@ msgid "Profile stats range" msgstr "Zakres statystyk profilu" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Profiles" msgstr "Profile" @@ -6978,6 +7036,7 @@ msgstr "Ukryj treść powiadomień" #: src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx #: src/renderer/views/PullRequestsView/PullRequestsView.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx #: src/renderer/views/SettingsOverlay/parts/UsageSettings.tsx msgid "Refresh" @@ -7113,6 +7172,11 @@ msgstr "Usuń {0}" msgid "Remove {0} from panel" msgstr "Usuń {0} z panelu" +#. placeholder {0}: props.providerName +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Remove {0} profile" +msgstr "Usuń profil {0}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx msgid "Remove {pattern}" msgstr "Usuń {pattern}" @@ -7612,6 +7676,7 @@ msgstr "Opcje środowiska wykonawczego" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Zapisz" @@ -7621,6 +7686,10 @@ msgstr "Zapisz" msgid "Save {0} credentials." msgstr "Zapisz dane uwierzytelniające {0}." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Save {providerName} profile" +msgstr "Zapisz profil {providerName}" + #: src/renderer/views/SchedulesView/ScheduleEditor.tsx msgid "Save changes" msgstr "Zapisz zmiany" @@ -9113,6 +9182,10 @@ msgstr "Spowoduje to trwałe usunięcie gałęzi „{0}” ze zdalnego repozytor msgid "This permanently deletes the branch \"{0}\"." msgstr "Spowoduje to trwałe usunięcie gałęzi „{0}”." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "This profile keeps its {providerName} account, settings, and sessions in a separate home directory." +msgstr "Ten profil przechowuje konto, ustawienia i sesje {providerName} w osobnym katalogu domowym." + #: src/renderer/components/skills/SkillsManager.tsx msgid "this project" msgstr "ten projekt" @@ -9498,6 +9571,10 @@ msgstr "Nie można odświeżyć {0}." msgid "Unable to refresh Claude profiles." msgstr "Nie można odświeżyć profili Claude." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Unable to refresh profiles." +msgstr "Nie można odświeżyć profili." + #. placeholder {0}: agent.label #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Unable to remove {0} credentials." @@ -9860,6 +9937,10 @@ msgstr "Użyj liter, cyfr, kropek, łączników lub podkreśleń, bez spacji." msgid "Use one global folder, or nest worktrees inside each project at .poracode/worktrees." msgstr "Użyj jednego folderu globalnego lub zagnieźdź drzewa robocze wewnątrz każdego projektu w .poracode/worktrees." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Use separate {providerName} accounts and settings by assigning each profile its own home directory." +msgstr "Używaj oddzielnych kont i ustawień {providerName}, przypisując każdemu profilowi własny katalog domowy." + #: src/renderer/views/MainView/parts/AppOverlays.tsx msgid "Use Session" msgstr "Użyj sesji" diff --git a/src/renderer/locales/pt-BR/messages.po b/src/renderer/locales/pt-BR/messages.po index 45926af85..1a9a30937 100644 --- a/src/renderer/locales/pt-BR/messages.po +++ b/src/renderer/locales/pt-BR/messages.po @@ -421,6 +421,26 @@ msgstr "{projectLocation} (desativado)" msgid "{projectName} Settings" msgstr "Configurações de {projectName}" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {displayName} profile added." +msgstr "Perfil {providerName} {displayName} adicionado." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {trimmedName} profile saved." +msgstr "Perfil {providerName} {trimmedName} salvo." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile home directory" +msgstr "Diretório inicial do perfil de {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile name" +msgstr "Nome do perfil de {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile removed." +msgstr "Perfil de {providerName} removido." + #: src/renderer/views/MainView/parts/RightPanel/parts/NotesPanel/TodoList.tsx msgid "{remaining, plural, one {# open} other {# open}}" msgstr "{remaining, plural, one {# aberto} other {# abertos}}" @@ -614,6 +634,10 @@ msgstr "Métrica de atividade" msgid "Add" msgstr "Adicionar" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Add {providerName} profile" +msgstr "Adicionar perfil de {providerName}" + #: src/renderer/commands/registry.ts msgid "Add a local project" msgstr "Adicione um projeto local" @@ -703,6 +727,7 @@ msgid "Add pattern" msgstr "Adicionar padrão" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Add profile" msgstr "Adicionar perfil" @@ -1498,6 +1523,10 @@ msgstr "Não foi possível carregar as portas" msgid "Cancel" msgstr "Cancelar" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Cancel new {providerName} profile" +msgstr "Cancelar novo perfil de {providerName}" + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Cancel new Claude profile" msgstr "Cancelar novo perfil de Claude" @@ -1725,6 +1754,10 @@ msgstr "Chrome" msgid "Chrome MCP enabled for this thread" msgstr "MCP do Chrome ativado para este tópico" +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "Claude" +msgstr "Claude" + #. placeholder {0}: trimmedName || displayLabel #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Claude {0} profile saved." @@ -2003,6 +2036,7 @@ msgid "Code copied. " msgstr "Código copiado." #: src/renderer/components/providers/codex/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Codex" msgstr "Codex" @@ -3219,6 +3253,7 @@ msgid "e.g. Codex GPT-5.5 fast for quick lookups, OpenCode GLM for bulk refactor msgstr "ex.: Codex GPT-5.5 fast para consultas rápidas, OpenCode GLM para refatorações em massa, Claude Opus para qualquer coisa sutil." #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "e.g. Work" msgstr "por exemplo, Trabalho" @@ -3899,6 +3934,7 @@ msgid "Fullscreen overlay" msgstr "Sobreposição de tela cheia" #: src/renderer/components/providers/gemini/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Gemini" msgstr "Gemini" @@ -3985,6 +4021,7 @@ msgid "Git status for {0}: not a Git repository" msgstr "Status do Git para {0}: não é um repositório Git" #: src/renderer/components/providers/copilot/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "GitHub Copilot" msgstr "GitHub Copilot" @@ -4058,6 +4095,7 @@ msgid "Grade {0}" msgstr "Nota {0}" #: src/renderer/components/providers/grok/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Grok Build" msgstr "Grok Build" @@ -4182,6 +4220,10 @@ msgstr "Histórico" msgid "Home" msgstr "Página inicial" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Home directory" +msgstr "Diretório inicial" + #: src/mobile/views/HostFolderPicker.tsx msgid "Home folder" msgstr "Pasta inicial" @@ -5264,6 +5306,7 @@ msgstr "my-mcp-server" #: src/renderer/components/skills/SkillMarketplaceModal.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Name" msgstr "Nome" @@ -5320,6 +5363,14 @@ msgstr "Neutro" msgid "New" msgstr "Novo" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile home directory" +msgstr "Diretório inicial do novo perfil de {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile name" +msgstr "Nome do novo perfil de {providerName}" + #: src/renderer/views/ProjectSettingsOverlay/parts/ActionsSection.tsx msgid "New action command" msgstr "Novo comando de ação" @@ -5491,6 +5542,10 @@ msgstr "Nenhuma sessão ativa" msgid "No activity yet." msgstr "Nenhuma atividade ainda." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "No additional {providerName} profiles." +msgstr "Nenhum perfil adicional de {providerName}." + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "No additional Claude profiles." msgstr "Nenhum perfil adicional de Claude." @@ -5987,6 +6042,7 @@ msgid "Open {0}" msgstr "Abrir {0}" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Open {label}" msgstr "Abrir {label}" @@ -6648,6 +6704,7 @@ msgid "Private key" msgstr "Chave privada" #: src/mobile/settingsSections.ts +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Profile" msgstr "Perfil" @@ -6657,6 +6714,7 @@ msgid "Profile stats range" msgstr "Período das estatísticas do perfil" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Profiles" msgstr "Perfis" @@ -6978,6 +7036,7 @@ msgstr "Ocultar o conteúdo das notificações" #: src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx #: src/renderer/views/PullRequestsView/PullRequestsView.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx #: src/renderer/views/SettingsOverlay/parts/UsageSettings.tsx msgid "Refresh" @@ -7113,6 +7172,11 @@ msgstr "Remover {0}" msgid "Remove {0} from panel" msgstr "Remover {0} do painel" +#. placeholder {0}: props.providerName +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Remove {0} profile" +msgstr "Remover perfil {0}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx msgid "Remove {pattern}" msgstr "Remover {pattern}" @@ -7612,6 +7676,7 @@ msgstr "Opções de tempo de execução" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Salvar" @@ -7621,6 +7686,10 @@ msgstr "Salvar" msgid "Save {0} credentials." msgstr "Salve as credenciais {0}." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Save {providerName} profile" +msgstr "Salvar perfil de {providerName}" + #: src/renderer/views/SchedulesView/ScheduleEditor.tsx msgid "Save changes" msgstr "Salvar alterações" @@ -9113,6 +9182,10 @@ msgstr "Isso exclui permanentemente o branch \"{0}\" do remoto dele." msgid "This permanently deletes the branch \"{0}\"." msgstr "Isso exclui permanentemente o branch \"{0}\"." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "This profile keeps its {providerName} account, settings, and sessions in a separate home directory." +msgstr "Este perfil mantém a conta, as configurações e as sessões de {providerName} em um diretório inicial separado." + #: src/renderer/components/skills/SkillsManager.tsx msgid "this project" msgstr "este projeto" @@ -9498,6 +9571,10 @@ msgstr "Não foi possível atualizar {0}." msgid "Unable to refresh Claude profiles." msgstr "Não foi possível atualizar os perfis do Claude." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Unable to refresh profiles." +msgstr "Não foi possível atualizar os perfis." + #. placeholder {0}: agent.label #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Unable to remove {0} credentials." @@ -9860,6 +9937,10 @@ msgstr "Use letras, números, pontos, hifens ou sublinhados, sem espaços." msgid "Use one global folder, or nest worktrees inside each project at .poracode/worktrees." msgstr "Use uma única pasta global ou aninhe as árvores de trabalho dentro de cada projeto em .poracode/worktrees." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Use separate {providerName} accounts and settings by assigning each profile its own home directory." +msgstr "Use contas e configurações separadas de {providerName}, atribuindo a cada perfil seu próprio diretório inicial." + #: src/renderer/views/MainView/parts/AppOverlays.tsx msgid "Use Session" msgstr "Usar sessão" diff --git a/src/renderer/locales/ru/messages.po b/src/renderer/locales/ru/messages.po index 3a28ffbcf..f972f290c 100644 --- a/src/renderer/locales/ru/messages.po +++ b/src/renderer/locales/ru/messages.po @@ -421,6 +421,26 @@ msgstr "{projectLocation} (отключено)" msgid "{projectName} Settings" msgstr "Настройки {projectName}" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {displayName} profile added." +msgstr "Профиль {providerName} {displayName} добавлен." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {trimmedName} profile saved." +msgstr "Профиль {providerName} {trimmedName} сохранён." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile home directory" +msgstr "Домашний каталог профиля {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile name" +msgstr "Имя профиля {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile removed." +msgstr "Профиль {providerName} удалён." + #: src/renderer/views/MainView/parts/RightPanel/parts/NotesPanel/TodoList.tsx msgid "{remaining, plural, one {# open} other {# open}}" msgstr "{remaining, plural, one {# открыт} few {# открыто} many {# открыто} other {# открыто}}" @@ -614,6 +634,10 @@ msgstr "Метрика активности" msgid "Add" msgstr "Добавить" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Add {providerName} profile" +msgstr "Добавить профиль {providerName}" + #: src/renderer/commands/registry.ts msgid "Add a local project" msgstr "Добавить локальный проект" @@ -703,6 +727,7 @@ msgid "Add pattern" msgstr "Добавить шаблон" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Add profile" msgstr "Добавить профиль" @@ -1498,6 +1523,10 @@ msgstr "Не удалось загрузить порты" msgid "Cancel" msgstr "Отмена" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Cancel new {providerName} profile" +msgstr "Отменить создание профиля {providerName}" + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Cancel new Claude profile" msgstr "Отменить новый профиль Claude" @@ -1725,6 +1754,10 @@ msgstr "Chrome" msgid "Chrome MCP enabled for this thread" msgstr "Chrome MCP включён для этого треда" +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "Claude" +msgstr "Claude" + #. placeholder {0}: trimmedName || displayLabel #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Claude {0} profile saved." @@ -2003,6 +2036,7 @@ msgid "Code copied. " msgstr "Код скопирован. " #: src/renderer/components/providers/codex/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Codex" msgstr "Codex" @@ -3219,6 +3253,7 @@ msgid "e.g. Codex GPT-5.5 fast for quick lookups, OpenCode GLM for bulk refactor msgstr "напр., Codex GPT-5.5 fast для быстрых запросов, OpenCode GLM для массовых рефакторингов, Claude Opus для всего тонкого." #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "e.g. Work" msgstr "напр. Работа" @@ -3899,6 +3934,7 @@ msgid "Fullscreen overlay" msgstr "Полноэкранное наложение" #: src/renderer/components/providers/gemini/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Gemini" msgstr "Gemini" @@ -3985,6 +4021,7 @@ msgid "Git status for {0}: not a Git repository" msgstr "Состояние Git для {0}: не репозиторий Git" #: src/renderer/components/providers/copilot/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "GitHub Copilot" msgstr "GitHub Copilot" @@ -4058,6 +4095,7 @@ msgid "Grade {0}" msgstr "Оценка {0}" #: src/renderer/components/providers/grok/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Grok Build" msgstr "Grok Build" @@ -4182,6 +4220,10 @@ msgstr "История" msgid "Home" msgstr "Главная" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Home directory" +msgstr "Домашний каталог" + #: src/mobile/views/HostFolderPicker.tsx msgid "Home folder" msgstr "Домашняя папка" @@ -5264,6 +5306,7 @@ msgstr "my-mcp-server" #: src/renderer/components/skills/SkillMarketplaceModal.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Name" msgstr "Имя" @@ -5320,6 +5363,14 @@ msgstr "Нейтрально" msgid "New" msgstr "Новое" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile home directory" +msgstr "Домашний каталог нового профиля {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile name" +msgstr "Имя нового профиля {providerName}" + #: src/renderer/views/ProjectSettingsOverlay/parts/ActionsSection.tsx msgid "New action command" msgstr "Новая команда действия" @@ -5491,6 +5542,10 @@ msgstr "Нет активной сессии" msgid "No activity yet." msgstr "Активности пока нет." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "No additional {providerName} profiles." +msgstr "Дополнительных профилей {providerName} нет." + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "No additional Claude profiles." msgstr "Дополнительных профилей Claude нет." @@ -5987,6 +6042,7 @@ msgid "Open {0}" msgstr "Открыть {0}" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Open {label}" msgstr "Открыть {label}" @@ -6648,6 +6704,7 @@ msgid "Private key" msgstr "Закрытый ключ" #: src/mobile/settingsSections.ts +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Profile" msgstr "Профиль" @@ -6657,6 +6714,7 @@ msgid "Profile stats range" msgstr "Диапазон статистики профиля" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Profiles" msgstr "Профили" @@ -6978,6 +7036,7 @@ msgstr "Скрывать содержимое уведомлений" #: src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx #: src/renderer/views/PullRequestsView/PullRequestsView.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx #: src/renderer/views/SettingsOverlay/parts/UsageSettings.tsx msgid "Refresh" @@ -7113,6 +7172,11 @@ msgstr "Удалить {0}" msgid "Remove {0} from panel" msgstr "Удалить {0} с панели" +#. placeholder {0}: props.providerName +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Remove {0} profile" +msgstr "Удалить профиль {0}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx msgid "Remove {pattern}" msgstr "Удалить {pattern}" @@ -7612,6 +7676,7 @@ msgstr "Параметры среды выполнения" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Сохранить" @@ -7621,6 +7686,10 @@ msgstr "Сохранить" msgid "Save {0} credentials." msgstr "Сохранить учётные данные {0}." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Save {providerName} profile" +msgstr "Сохранить профиль {providerName}" + #: src/renderer/views/SchedulesView/ScheduleEditor.tsx msgid "Save changes" msgstr "Сохранить изменения" @@ -9113,6 +9182,10 @@ msgstr "Это безвозвратно удалит ветку \"{0}\" из е msgid "This permanently deletes the branch \"{0}\"." msgstr "Это безвозвратно удалит ветку \"{0}\"." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "This profile keeps its {providerName} account, settings, and sessions in a separate home directory." +msgstr "В этом профиле учётная запись, настройки и сеансы {providerName} хранятся в отдельном домашнем каталоге." + #: src/renderer/components/skills/SkillsManager.tsx msgid "this project" msgstr "этот проект" @@ -9498,6 +9571,10 @@ msgstr "Не удалось обновить {0}." msgid "Unable to refresh Claude profiles." msgstr "Не удалось обновить профили Claude." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Unable to refresh profiles." +msgstr "Не удалось обновить профили." + #. placeholder {0}: agent.label #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Unable to remove {0} credentials." @@ -9860,6 +9937,10 @@ msgstr "Используйте буквы, цифры, точки, дефисы msgid "Use one global folder, or nest worktrees inside each project at .poracode/worktrees." msgstr "Используйте одну глобальную папку или вкладывайте worktree внутрь каждого проекта в .poracode/worktrees." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Use separate {providerName} accounts and settings by assigning each profile its own home directory." +msgstr "Используйте отдельные учётные записи и настройки {providerName}, назначив каждому профилю собственный домашний каталог." + #: src/renderer/views/MainView/parts/AppOverlays.tsx msgid "Use Session" msgstr "Использовать сессию" diff --git a/src/renderer/locales/tr/messages.po b/src/renderer/locales/tr/messages.po index 888b96b26..9db05ab7c 100644 --- a/src/renderer/locales/tr/messages.po +++ b/src/renderer/locales/tr/messages.po @@ -421,6 +421,26 @@ msgstr "{projectLocation} (devre dışı)" msgid "{projectName} Settings" msgstr "{projectName} Ayarları" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {displayName} profile added." +msgstr "{providerName} {displayName} profili eklendi." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {trimmedName} profile saved." +msgstr "{providerName} {trimmedName} profili kaydedildi." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile home directory" +msgstr "{providerName} profili ana dizini" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile name" +msgstr "{providerName} profil adı" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile removed." +msgstr "{providerName} profili kaldırıldı." + #: src/renderer/views/MainView/parts/RightPanel/parts/NotesPanel/TodoList.tsx msgid "{remaining, plural, one {# open} other {# open}}" msgstr "{remaining, plural, one {# açık} other {# açık}}" @@ -614,6 +634,10 @@ msgstr "Etkinlik metriği" msgid "Add" msgstr "Ekle" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Add {providerName} profile" +msgstr "{providerName} profili ekle" + #: src/renderer/commands/registry.ts msgid "Add a local project" msgstr "Yerel bir proje ekle" @@ -703,6 +727,7 @@ msgid "Add pattern" msgstr "Desen ekle" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Add profile" msgstr "Profil ekle" @@ -1498,6 +1523,10 @@ msgstr "Portlar yüklenemedi" msgid "Cancel" msgstr "İptal" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Cancel new {providerName} profile" +msgstr "Yeni {providerName} profilini iptal et" + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Cancel new Claude profile" msgstr "Yeni Claude profilini iptal et" @@ -1725,6 +1754,10 @@ msgstr "Chrome" msgid "Chrome MCP enabled for this thread" msgstr "Bu konu için Chrome MCP etkinleştirildi" +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "Claude" +msgstr "Claude" + #. placeholder {0}: trimmedName || displayLabel #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Claude {0} profile saved." @@ -2003,6 +2036,7 @@ msgid "Code copied. " msgstr "Kod kopyalandı." #: src/renderer/components/providers/codex/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Codex" msgstr "Codex" @@ -3219,6 +3253,7 @@ msgid "e.g. Codex GPT-5.5 fast for quick lookups, OpenCode GLM for bulk refactor msgstr "örn. hızlı aramalar için Codex GPT-5.5 fast, toplu yeniden düzenlemeler için OpenCode GLM, ince işler için Claude Opus." #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "e.g. Work" msgstr "örneğin İş" @@ -3899,6 +3934,7 @@ msgid "Fullscreen overlay" msgstr "Tam ekran yer paylaşımı" #: src/renderer/components/providers/gemini/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Gemini" msgstr "Gemini" @@ -3985,6 +4021,7 @@ msgid "Git status for {0}: not a Git repository" msgstr "{0} için Git durumu: Git deposu değil" #: src/renderer/components/providers/copilot/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "GitHub Copilot" msgstr "GitHub Copilot" @@ -4058,6 +4095,7 @@ msgid "Grade {0}" msgstr "Derece {0}" #: src/renderer/components/providers/grok/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Grok Build" msgstr "Grok Build" @@ -4182,6 +4220,10 @@ msgstr "Geçmiş" msgid "Home" msgstr "Ana sayfa" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Home directory" +msgstr "Ana dizin" + #: src/mobile/views/HostFolderPicker.tsx msgid "Home folder" msgstr "Ana klasör" @@ -5264,6 +5306,7 @@ msgstr "my-mcp-server" #: src/renderer/components/skills/SkillMarketplaceModal.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Name" msgstr "Ad" @@ -5320,6 +5363,14 @@ msgstr "Nötr" msgid "New" msgstr "Yeni" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile home directory" +msgstr "Yeni {providerName} profilinin ana dizini" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile name" +msgstr "Yeni {providerName} profilinin adı" + #: src/renderer/views/ProjectSettingsOverlay/parts/ActionsSection.tsx msgid "New action command" msgstr "Yeni eylem komutu" @@ -5491,6 +5542,10 @@ msgstr "Aktif oturum yok" msgid "No activity yet." msgstr "Henüz etkinlik yok." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "No additional {providerName} profiles." +msgstr "Ek {providerName} profili yok." + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "No additional Claude profiles." msgstr "Ek Claude profili yok." @@ -5987,6 +6042,7 @@ msgid "Open {0}" msgstr "{0} aç" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Open {label}" msgstr "{label} öğesini aç" @@ -6648,6 +6704,7 @@ msgid "Private key" msgstr "Özel anahtar" #: src/mobile/settingsSections.ts +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Profile" msgstr "Profil" @@ -6657,6 +6714,7 @@ msgid "Profile stats range" msgstr "Profil istatistikleri aralığı" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Profiles" msgstr "Profiller" @@ -6978,6 +7036,7 @@ msgstr "Bildirim içeriğini gizle" #: src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx #: src/renderer/views/PullRequestsView/PullRequestsView.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx #: src/renderer/views/SettingsOverlay/parts/UsageSettings.tsx msgid "Refresh" @@ -7113,6 +7172,11 @@ msgstr "{0} öğesini kaldır" msgid "Remove {0} from panel" msgstr "{0} öğesini panelden kaldırın" +#. placeholder {0}: props.providerName +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Remove {0} profile" +msgstr "{0} profilini kaldır" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx msgid "Remove {pattern}" msgstr "{pattern} öğesini kaldır" @@ -7612,6 +7676,7 @@ msgstr "Çalışma zamanı seçenekleri" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Kaydet" @@ -7621,6 +7686,10 @@ msgstr "Kaydet" msgid "Save {0} credentials." msgstr "{0} kimlik bilgilerini kaydedin." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Save {providerName} profile" +msgstr "{providerName} profilini kaydet" + #: src/renderer/views/SchedulesView/ScheduleEditor.tsx msgid "Save changes" msgstr "Değişiklikleri kaydet" @@ -9113,6 +9182,10 @@ msgstr "Bu, \"{0}\" dalını uzak deposundan kalıcı olarak siler." msgid "This permanently deletes the branch \"{0}\"." msgstr "Bu, \"{0}\" dalını kalıcı olarak siler." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "This profile keeps its {providerName} account, settings, and sessions in a separate home directory." +msgstr "Bu profil, {providerName} hesabını, ayarlarını ve oturumlarını ayrı bir ana dizinde tutar." + #: src/renderer/components/skills/SkillsManager.tsx msgid "this project" msgstr "bu proje" @@ -9498,6 +9571,10 @@ msgstr "{0} yenilenemiyor." msgid "Unable to refresh Claude profiles." msgstr "Claude profilleri yenilenemiyor." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Unable to refresh profiles." +msgstr "Profiller yenilenemiyor." + #. placeholder {0}: agent.label #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Unable to remove {0} credentials." @@ -9860,6 +9937,10 @@ msgstr "Boşluk kullanmadan harf, rakam, nokta, kısa çizgi veya alt çizgi kul msgid "Use one global folder, or nest worktrees inside each project at .poracode/worktrees." msgstr "Tek bir genel klasör kullanın veya çalışma ağaçlarını her projenin içinde .poracode/worktrees altında iç içe yerleştirin." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Use separate {providerName} accounts and settings by assigning each profile its own home directory." +msgstr "Her profile kendi ana dizinini atayarak ayrı {providerName} hesapları ve ayarları kullanın." + #: src/renderer/views/MainView/parts/AppOverlays.tsx msgid "Use Session" msgstr "Oturumu Kullan" diff --git a/src/renderer/locales/uk/messages.po b/src/renderer/locales/uk/messages.po index b37be18c0..0a025e25e 100644 --- a/src/renderer/locales/uk/messages.po +++ b/src/renderer/locales/uk/messages.po @@ -421,6 +421,26 @@ msgstr "{projectLocation} (вимкнено)" msgid "{projectName} Settings" msgstr "Налаштування {projectName}" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {displayName} profile added." +msgstr "Профіль {providerName} {displayName} додано." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {trimmedName} profile saved." +msgstr "Профіль {providerName} {trimmedName} збережено." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile home directory" +msgstr "Домашній каталог профілю {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile name" +msgstr "Ім'я профілю {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile removed." +msgstr "Профіль {providerName} видалено." + #: src/renderer/views/MainView/parts/RightPanel/parts/NotesPanel/TodoList.tsx msgid "{remaining, plural, one {# open} other {# open}}" msgstr "{remaining, plural, one {# відкрито} few {# відкрито} many {# відкрито} other {# відкрито}}" @@ -614,6 +634,10 @@ msgstr "Метрика активності" msgid "Add" msgstr "Додати" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Add {providerName} profile" +msgstr "Додати профіль {providerName}" + #: src/renderer/commands/registry.ts msgid "Add a local project" msgstr "Додати локальний проєкт" @@ -703,6 +727,7 @@ msgid "Add pattern" msgstr "Додати шаблон" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Add profile" msgstr "Додати профіль" @@ -1498,6 +1523,10 @@ msgstr "Не вдалося завантажити порти" msgid "Cancel" msgstr "Скасувати" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Cancel new {providerName} profile" +msgstr "Скасувати створення профілю {providerName}" + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Cancel new Claude profile" msgstr "Скасувати новий профіль Claude" @@ -1725,6 +1754,10 @@ msgstr "Chrome" msgid "Chrome MCP enabled for this thread" msgstr "Chrome MCP увімкнено для цього треду" +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "Claude" +msgstr "Claude" + #. placeholder {0}: trimmedName || displayLabel #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Claude {0} profile saved." @@ -2003,6 +2036,7 @@ msgid "Code copied. " msgstr "Код скопійовано. " #: src/renderer/components/providers/codex/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Codex" msgstr "Codex" @@ -3219,6 +3253,7 @@ msgid "e.g. Codex GPT-5.5 fast for quick lookups, OpenCode GLM for bulk refactor msgstr "напр., Codex GPT-5.5 fast для швидких запитів, OpenCode GLM для масових рефакторингів, Claude Opus для всього тонкого." #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "e.g. Work" msgstr "напр. Робота" @@ -3899,6 +3934,7 @@ msgid "Fullscreen overlay" msgstr "Повноекранне накладання" #: src/renderer/components/providers/gemini/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Gemini" msgstr "Gemini" @@ -3985,6 +4021,7 @@ msgid "Git status for {0}: not a Git repository" msgstr "Стан Git для {0}: не репозиторій Git" #: src/renderer/components/providers/copilot/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "GitHub Copilot" msgstr "GitHub Copilot" @@ -4058,6 +4095,7 @@ msgid "Grade {0}" msgstr "Оцінка {0}" #: src/renderer/components/providers/grok/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Grok Build" msgstr "Grok Build" @@ -4182,6 +4220,10 @@ msgstr "Історія" msgid "Home" msgstr "Головна" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Home directory" +msgstr "Домашній каталог" + #: src/mobile/views/HostFolderPicker.tsx msgid "Home folder" msgstr "Домашня папка" @@ -5264,6 +5306,7 @@ msgstr "my-mcp-server" #: src/renderer/components/skills/SkillMarketplaceModal.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Name" msgstr "Ім'я" @@ -5320,6 +5363,14 @@ msgstr "Нейтрально" msgid "New" msgstr "Нове" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile home directory" +msgstr "Домашній каталог нового профілю {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile name" +msgstr "Ім'я нового профілю {providerName}" + #: src/renderer/views/ProjectSettingsOverlay/parts/ActionsSection.tsx msgid "New action command" msgstr "Нова команда дії" @@ -5491,6 +5542,10 @@ msgstr "Немає активної сесії" msgid "No activity yet." msgstr "Активності ще немає." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "No additional {providerName} profiles." +msgstr "Додаткових профілів {providerName} немає." + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "No additional Claude profiles." msgstr "Додаткових профілів Claude немає." @@ -5987,6 +6042,7 @@ msgid "Open {0}" msgstr "Відкрити {0}" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Open {label}" msgstr "Відкрити {label}" @@ -6648,6 +6704,7 @@ msgid "Private key" msgstr "Закритий ключ" #: src/mobile/settingsSections.ts +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Profile" msgstr "Профіль" @@ -6657,6 +6714,7 @@ msgid "Profile stats range" msgstr "Діапазон статистики профілю" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Profiles" msgstr "Профілі" @@ -6978,6 +7036,7 @@ msgstr "Приховувати вміст сповіщень" #: src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx #: src/renderer/views/PullRequestsView/PullRequestsView.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx #: src/renderer/views/SettingsOverlay/parts/UsageSettings.tsx msgid "Refresh" @@ -7113,6 +7172,11 @@ msgstr "Видалити {0}" msgid "Remove {0} from panel" msgstr "Видалити {0} з панелі" +#. placeholder {0}: props.providerName +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Remove {0} profile" +msgstr "Видалити профіль {0}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx msgid "Remove {pattern}" msgstr "Видалити {pattern}" @@ -7612,6 +7676,7 @@ msgstr "Параметри середовища виконання" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Зберегти" @@ -7621,6 +7686,10 @@ msgstr "Зберегти" msgid "Save {0} credentials." msgstr "Зберегти облікові дані {0}." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Save {providerName} profile" +msgstr "Зберегти профіль {providerName}" + #: src/renderer/views/SchedulesView/ScheduleEditor.tsx msgid "Save changes" msgstr "Зберегти зміни" @@ -9113,6 +9182,10 @@ msgstr "Це назавжди видалить гілку \"{0}\" з її від msgid "This permanently deletes the branch \"{0}\"." msgstr "Це назавжди видалить гілку \"{0}\"." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "This profile keeps its {providerName} account, settings, and sessions in a separate home directory." +msgstr "У цьому профілі обліковий запис, налаштування та сеанси {providerName} зберігаються в окремому домашньому каталозі." + #: src/renderer/components/skills/SkillsManager.tsx msgid "this project" msgstr "цей проєкт" @@ -9498,6 +9571,10 @@ msgstr "Не вдалося оновити {0}." msgid "Unable to refresh Claude profiles." msgstr "Не вдалося оновити профілі Claude." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Unable to refresh profiles." +msgstr "Не вдалося оновити профілі." + #. placeholder {0}: agent.label #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Unable to remove {0} credentials." @@ -9860,6 +9937,10 @@ msgstr "Використовуйте літери, цифри, крапки, д msgid "Use one global folder, or nest worktrees inside each project at .poracode/worktrees." msgstr "Використовуйте одну глобальну папку або вкладайте worktree всередину кожного проєкту в .poracode/worktrees." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Use separate {providerName} accounts and settings by assigning each profile its own home directory." +msgstr "Використовуйте окремі облікові записи та налаштування {providerName}, призначивши кожному профілю власний домашній каталог." + #: src/renderer/views/MainView/parts/AppOverlays.tsx msgid "Use Session" msgstr "Використати сесію" diff --git a/src/renderer/locales/vi/messages.po b/src/renderer/locales/vi/messages.po index f8392a14f..28075265c 100644 --- a/src/renderer/locales/vi/messages.po +++ b/src/renderer/locales/vi/messages.po @@ -421,6 +421,26 @@ msgstr "{projectLocation} (bị vô hiệu hóa)" msgid "{projectName} Settings" msgstr "Cài đặt {projectName}" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {displayName} profile added." +msgstr "Đã thêm hồ sơ {providerName} {displayName}." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {trimmedName} profile saved." +msgstr "Đã lưu hồ sơ {providerName} {trimmedName}." + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile home directory" +msgstr "Thư mục chính của hồ sơ {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile name" +msgstr "Tên hồ sơ {providerName}" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile removed." +msgstr "Đã xóa hồ sơ {providerName}." + #: src/renderer/views/MainView/parts/RightPanel/parts/NotesPanel/TodoList.tsx msgid "{remaining, plural, one {# open} other {# open}}" msgstr "{remaining, plural, one {# mở} other {# mở}}" @@ -614,6 +634,10 @@ msgstr "Chỉ số hoạt động" msgid "Add" msgstr "Thêm vào" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Add {providerName} profile" +msgstr "Thêm hồ sơ {providerName}" + #: src/renderer/commands/registry.ts msgid "Add a local project" msgstr "Thêm một dự án cục bộ" @@ -703,6 +727,7 @@ msgid "Add pattern" msgstr "Thêm mẫu" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Add profile" msgstr "Thêm hồ sơ" @@ -1498,6 +1523,10 @@ msgstr "Không thể tải danh sách cổng" msgid "Cancel" msgstr "Hủy bỏ" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Cancel new {providerName} profile" +msgstr "Hủy tạo hồ sơ {providerName} mới" + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Cancel new Claude profile" msgstr "Hủy tạo hồ sơ Claude mới" @@ -1725,6 +1754,10 @@ msgstr "Chrome" msgid "Chrome MCP enabled for this thread" msgstr "Đã bật Chrome MCP cho chủ đề này" +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "Claude" +msgstr "Claude" + #. placeholder {0}: trimmedName || displayLabel #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Claude {0} profile saved." @@ -2003,6 +2036,7 @@ msgid "Code copied. " msgstr "Đã sao chép mã." #: src/renderer/components/providers/codex/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Codex" msgstr "Codex" @@ -3219,6 +3253,7 @@ msgid "e.g. Codex GPT-5.5 fast for quick lookups, OpenCode GLM for bulk refactor msgstr "ví dụ: Codex GPT-5.5 fast cho các tra cứu nhanh, OpenCode GLM cho tái cấu trúc hàng loạt, Claude Opus cho những việc tinh tế." #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "e.g. Work" msgstr "ví dụ: Work" @@ -3899,6 +3934,7 @@ msgid "Fullscreen overlay" msgstr "Lớp phủ toàn màn hình" #: src/renderer/components/providers/gemini/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Gemini" msgstr "Gemini" @@ -3985,6 +4021,7 @@ msgid "Git status for {0}: not a Git repository" msgstr "Trạng thái Git cho {0}: không phải kho lưu trữ Git" #: src/renderer/components/providers/copilot/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "GitHub Copilot" msgstr "GitHub Copilot" @@ -4058,6 +4095,7 @@ msgid "Grade {0}" msgstr "Xếp hạng {0}" #: src/renderer/components/providers/grok/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Grok Build" msgstr "Grok Build" @@ -4182,6 +4220,10 @@ msgstr "Lịch sử" msgid "Home" msgstr "Trang chủ" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Home directory" +msgstr "Thư mục chính" + #: src/mobile/views/HostFolderPicker.tsx msgid "Home folder" msgstr "Thư mục chính" @@ -5264,6 +5306,7 @@ msgstr "my-mcp-server" #: src/renderer/components/skills/SkillMarketplaceModal.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Name" msgstr "Tên" @@ -5320,6 +5363,14 @@ msgstr "Trung lập" msgid "New" msgstr "Mới" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile home directory" +msgstr "Thư mục chính của hồ sơ {providerName} mới" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile name" +msgstr "Tên hồ sơ {providerName} mới" + #: src/renderer/views/ProjectSettingsOverlay/parts/ActionsSection.tsx msgid "New action command" msgstr "Lệnh hành động mới" @@ -5491,6 +5542,10 @@ msgstr "Không có phiên hoạt động" msgid "No activity yet." msgstr "Chưa có hoạt động nào." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "No additional {providerName} profiles." +msgstr "Không có hồ sơ {providerName} bổ sung." + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "No additional Claude profiles." msgstr "Không có hồ sơ Claude bổ sung." @@ -5987,6 +6042,7 @@ msgid "Open {0}" msgstr "Mở {0}" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Open {label}" msgstr "Mở {label}" @@ -6648,6 +6704,7 @@ msgid "Private key" msgstr "Khóa riêng" #: src/mobile/settingsSections.ts +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Profile" msgstr "Hồ sơ" @@ -6657,6 +6714,7 @@ msgid "Profile stats range" msgstr "Phạm vi thống kê hồ sơ" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Profiles" msgstr "Hồ sơ" @@ -6978,6 +7036,7 @@ msgstr "Ẩn nội dung thông báo" #: src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx #: src/renderer/views/PullRequestsView/PullRequestsView.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx #: src/renderer/views/SettingsOverlay/parts/UsageSettings.tsx msgid "Refresh" @@ -7113,6 +7172,11 @@ msgstr "Xóa {0}" msgid "Remove {0} from panel" msgstr "Xóa {0} khỏi bảng điều khiển" +#. placeholder {0}: props.providerName +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Remove {0} profile" +msgstr "Xóa hồ sơ {0}" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx msgid "Remove {pattern}" msgstr "Xóa {pattern}" @@ -7612,6 +7676,7 @@ msgstr "Tùy chọn thời gian chạy" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "Lưu" @@ -7621,6 +7686,10 @@ msgstr "Lưu" msgid "Save {0} credentials." msgstr "Lưu thông tin xác thực {0}." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Save {providerName} profile" +msgstr "Lưu hồ sơ {providerName}" + #: src/renderer/views/SchedulesView/ScheduleEditor.tsx msgid "Save changes" msgstr "Lưu thay đổi" @@ -9113,6 +9182,10 @@ msgstr "Việc này sẽ xóa vĩnh viễn nhánh \"{0}\" khỏi điều khiển msgid "This permanently deletes the branch \"{0}\"." msgstr "Việc này sẽ xóa vĩnh viễn nhánh \"{0}\"." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "This profile keeps its {providerName} account, settings, and sessions in a separate home directory." +msgstr "Hồ sơ này lưu tài khoản, cài đặt và phiên {providerName} trong một thư mục chính riêng." + #: src/renderer/components/skills/SkillsManager.tsx msgid "this project" msgstr "dự án này" @@ -9498,6 +9571,10 @@ msgstr "Không thể làm mới {0}." msgid "Unable to refresh Claude profiles." msgstr "Không thể làm mới hồ sơ Claude." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Unable to refresh profiles." +msgstr "Không thể làm mới hồ sơ." + #. placeholder {0}: agent.label #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Unable to remove {0} credentials." @@ -9860,6 +9937,10 @@ msgstr "Sử dụng chữ cái, chữ số, dấu chấm, dấu gạch ngang ho msgid "Use one global folder, or nest worktrees inside each project at .poracode/worktrees." msgstr "Dùng một thư mục toàn cục, hoặc lồng các cây làm việc bên trong mỗi dự án tại .poracode/worktrees." +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Use separate {providerName} accounts and settings by assigning each profile its own home directory." +msgstr "Sử dụng các tài khoản và cài đặt {providerName} riêng bằng cách gán cho mỗi hồ sơ một thư mục chính riêng." + #: src/renderer/views/MainView/parts/AppOverlays.tsx msgid "Use Session" msgstr "Sử dụng phiên" diff --git a/src/renderer/locales/zh-CN/messages.po b/src/renderer/locales/zh-CN/messages.po index d5676e332..4c6113995 100644 --- a/src/renderer/locales/zh-CN/messages.po +++ b/src/renderer/locales/zh-CN/messages.po @@ -421,6 +421,26 @@ msgstr "{projectLocation}(已禁用)" msgid "{projectName} Settings" msgstr "{projectName}设置" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {displayName} profile added." +msgstr "已添加 {providerName} {displayName} 配置文件。" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} {trimmedName} profile saved." +msgstr "已保存 {providerName} {trimmedName} 配置文件。" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile home directory" +msgstr "{providerName} 配置文件主目录" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile name" +msgstr "{providerName} 配置文件名称" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "{providerName} profile removed." +msgstr "已删除 {providerName} 配置文件。" + #: src/renderer/views/MainView/parts/RightPanel/parts/NotesPanel/TodoList.tsx msgid "{remaining, plural, one {# open} other {# open}}" msgstr "{remaining, plural, one {#打开} other {#打开}}" @@ -614,6 +634,10 @@ msgstr "活动指标" msgid "Add" msgstr "添加" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Add {providerName} profile" +msgstr "添加 {providerName} 配置文件" + #: src/renderer/commands/registry.ts msgid "Add a local project" msgstr "添加本地项目" @@ -703,6 +727,7 @@ msgid "Add pattern" msgstr "添加模式" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Add profile" msgstr "添加配置文件" @@ -1498,6 +1523,10 @@ msgstr "无法加载端口" msgid "Cancel" msgstr "取消" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Cancel new {providerName} profile" +msgstr "取消新建 {providerName} 配置文件" + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Cancel new Claude profile" msgstr "取消新的 Claude 配置文件" @@ -1725,6 +1754,10 @@ msgstr "Chrome" msgid "Chrome MCP enabled for this thread" msgstr "为此线程启用 Chrome MCP" +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +msgid "Claude" +msgstr "Claude" + #. placeholder {0}: trimmedName || displayLabel #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "Claude {0} profile saved." @@ -2003,6 +2036,7 @@ msgid "Code copied. " msgstr "代码已复制。" #: src/renderer/components/providers/codex/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Codex" msgstr "Codex" @@ -3219,6 +3253,7 @@ msgid "e.g. Codex GPT-5.5 fast for quick lookups, OpenCode GLM for bulk refactor msgstr "例如:Codex GPT-5.5 fast 用于快速查询,OpenCode GLM 用于批量重构,Claude Opus 用于任何微妙的任务。" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "e.g. Work" msgstr "例如工作" @@ -3899,6 +3934,7 @@ msgid "Fullscreen overlay" msgstr "全屏覆盖" #: src/renderer/components/providers/gemini/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Gemini" msgstr "Gemini" @@ -3985,6 +4021,7 @@ msgid "Git status for {0}: not a Git repository" msgstr "{0}的 Git 状态:不是 Git 存储库" #: src/renderer/components/providers/copilot/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "GitHub Copilot" msgstr "GitHub Copilot" @@ -4058,6 +4095,7 @@ msgid "Grade {0}" msgstr "等级 {0}" #: src/renderer/components/providers/grok/manifest.ts +#: src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts msgid "Grok Build" msgstr "Grok Build" @@ -4182,6 +4220,10 @@ msgstr "历史记录" msgid "Home" msgstr "首页" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Home directory" +msgstr "主目录" + #: src/mobile/views/HostFolderPicker.tsx msgid "Home folder" msgstr "主目录" @@ -5263,6 +5305,7 @@ msgstr "my-mcp-server" #: src/renderer/components/skills/SkillMarketplaceModal.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Name" msgstr "名称" @@ -5319,6 +5362,14 @@ msgstr "中立" msgid "New" msgstr "新增" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile home directory" +msgstr "新 {providerName} 配置文件主目录" + +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "New {providerName} profile name" +msgstr "新 {providerName} 配置文件名称" + #: src/renderer/views/ProjectSettingsOverlay/parts/ActionsSection.tsx msgid "New action command" msgstr "新动作命令" @@ -5490,6 +5541,10 @@ msgstr "没有活动会话" msgid "No activity yet." msgstr "暂无活动。" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "No additional {providerName} profiles." +msgstr "没有其他 {providerName} 配置文件。" + #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx msgid "No additional Claude profiles." msgstr "没有其他 Claude 配置文件。" @@ -5986,6 +6041,7 @@ msgid "Open {0}" msgstr "打开 {0}" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Open {label}" msgstr "打开{label}" @@ -6647,6 +6703,7 @@ msgid "Private key" msgstr "私钥" #: src/mobile/settingsSections.ts +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx msgid "Profile" msgstr "个人资料" @@ -6656,6 +6713,7 @@ msgid "Profile stats range" msgstr "个人资料统计范围" #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx msgid "Profiles" msgstr "配置文件" @@ -6977,6 +7035,7 @@ msgstr "隐藏通知内容" #: src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx #: src/renderer/views/PullRequestsView/PullRequestsView.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/RemoteServersSettings.tsx #: src/renderer/views/SettingsOverlay/parts/UsageSettings.tsx msgid "Refresh" @@ -7112,6 +7171,11 @@ msgstr "删除{0}" msgid "Remove {0} from panel" msgstr "从面板中删除{0}" +#. placeholder {0}: props.providerName +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Remove {0} profile" +msgstr "删除 {0} 配置文件" + #: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx msgid "Remove {pattern}" msgstr "删除{pattern}" @@ -7611,6 +7675,7 @@ msgstr "运行时选项" #: src/renderer/views/FileEditorOverlay/parts/FileEditorPane/parts/EditorToolbar.tsx #: src/renderer/views/ProfileOverlay/parts/EditProfileDialog.tsx #: src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Save" msgstr "保存" @@ -7620,6 +7685,10 @@ msgstr "保存" msgid "Save {0} credentials." msgstr "保存{0}凭据。" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Save {providerName} profile" +msgstr "保存 {providerName} 配置文件" + #: src/renderer/views/SchedulesView/ScheduleEditor.tsx msgid "Save changes" msgstr "保存更改" @@ -9112,6 +9181,10 @@ msgstr "这将从远程永久删除分支“{0}”。" msgid "This permanently deletes the branch \"{0}\"." msgstr "这将永久删除分支“{0}”。" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "This profile keeps its {providerName} account, settings, and sessions in a separate home directory." +msgstr "此配置文件将其 {providerName} 帐户、设置和会话保存在单独的主目录中。" + #: src/renderer/components/skills/SkillsManager.tsx msgid "this project" msgstr "此项目" @@ -9497,6 +9570,10 @@ msgstr "无法刷新{0}。" msgid "Unable to refresh Claude profiles." msgstr "无法刷新 Claude 配置文件。" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Unable to refresh profiles." +msgstr "无法刷新配置文件。" + #. placeholder {0}: agent.label #: src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx msgid "Unable to remove {0} credentials." @@ -9859,6 +9936,10 @@ msgstr "请使用字母、数字、点、连字符或下划线,且不要包含 msgid "Use one global folder, or nest worktrees inside each project at .poracode/worktrees." msgstr "使用一个全局文件夹,或将工作树嵌套在每个项目的 .poracode/worktrees 内。" +#: src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx +msgid "Use separate {providerName} accounts and settings by assigning each profile its own home directory." +msgstr "通过为每个配置文件分配独立的主目录,使用单独的 {providerName} 帐户和设置。" + #: src/renderer/views/MainView/parts/AppOverlays.tsx msgid "Use Session" msgstr "使用会话" diff --git a/src/renderer/state/sharedSettingsStore.test.ts b/src/renderer/state/sharedSettingsStore.test.ts index fbbc3759c..db0d45a04 100644 --- a/src/renderer/state/sharedSettingsStore.test.ts +++ b/src/renderer/state/sharedSettingsStore.test.ts @@ -24,6 +24,12 @@ describe("sharedSettingsStore", () => { recentModels: [], providerOrder: [], lastUsedProjectDirs: {}, + commitGenProvider: "auto", + titleGenProvider: "auto", + conflictResolverProvider: "auto", + wslCommitGenProvider: "auto", + wslTitleGenProvider: "auto", + wslConflictResolverProvider: "auto", }); }); @@ -171,4 +177,56 @@ describe("sharedSettingsStore", () => { expect(state.recentModels).toEqual([]); expect(state.providerOrder).toEqual(["claude"]); }); + + it("derives a home profile kind from its driver and removes usage settings", () => { + useSharedSettings.getState().setAgentInstance({ + id: "work", + driver: "codex", + displayName: "Work", + config: { homeDir: "~/.poracode/codex-profiles/work" }, + }); + const usage = useSharedSettings.getState().usage; + useSharedSettings.setState({ + providerConfigs: { "codex:work": { model: "gpt-5" } }, + hiddenModels: { "codex:work": ["gpt-5-mini"] }, + disabledAgents: ["codex:work"], + providerOrder: ["codex", "codex:work"], + commitGenProvider: "codex:work", + titleGenProvider: "codex:work", + conflictResolverProvider: "codex:work", + wslCommitGenProvider: "codex:work", + wslTitleGenProvider: "codex:work", + wslConflictResolverProvider: "codex:work", + usage: { + ...usage, + providerRefreshIntervals: { "codex:work": 10 }, + sidebarHiddenProviders: ["codex:work"], + disabledProviders: ["codex:work"], + providerOrder: ["codex", "codex:work"], + collapsedProviders: ["codex:work"], + selectedRingGroups: { "codex:work": "weekly" }, + }, + }); + + useSharedSettings.getState().removeAgentInstance("work"); + + const state = useSharedSettings.getState(); + expect(state.agentInstances.work).toBeUndefined(); + expect(state.providerConfigs["codex:work"]).toBeUndefined(); + expect(state.hiddenModels["codex:work"]).toBeUndefined(); + expect(state.disabledAgents).toEqual([]); + expect(state.providerOrder).toEqual(["codex"]); + expect(state.commitGenProvider).toBe("auto"); + expect(state.titleGenProvider).toBe("auto"); + expect(state.conflictResolverProvider).toBe("auto"); + expect(state.wslCommitGenProvider).toBe("auto"); + expect(state.wslTitleGenProvider).toBe("auto"); + expect(state.wslConflictResolverProvider).toBe("auto"); + expect(state.usage.providerRefreshIntervals["codex:work"]).toBeUndefined(); + expect(state.usage.sidebarHiddenProviders).toEqual([]); + expect(state.usage.disabledProviders).toEqual([]); + expect(state.usage.providerOrder).toEqual(["codex"]); + expect(state.usage.collapsedProviders).toEqual([]); + expect(state.usage.selectedRingGroups["codex:work"]).toBeUndefined(); + }); }); diff --git a/src/renderer/state/sharedSettingsStore.ts b/src/renderer/state/sharedSettingsStore.ts index 7428d0787..486e59471 100644 --- a/src/renderer/state/sharedSettingsStore.ts +++ b/src/renderer/state/sharedSettingsStore.ts @@ -597,23 +597,66 @@ export const useSharedSettings = create()((set, get) => ({ }, removeAgentInstance: (instanceId) => { const current = get().agentInstances; - if (!current[instanceId]) return; + const instance = current[instanceId]; + if (!instance) return; const { [instanceId]: _removed, ...agentInstances } = current; - const prefix = `claude:${instanceId}`; + const instanceKind = `${instance.driver}:${instanceId}`; const removeProfileKey = (values: Record) => - Object.fromEntries(Object.entries(values).filter(([key]) => key !== prefix)); + Object.fromEntries(Object.entries(values).filter(([key]) => key !== instanceKind)); + const resetRemovedProvider = (provider: string, fallback: string) => + provider === instanceKind ? fallback : provider; + const usage = get().usage; set({ agentInstances, + commitGenProvider: resetRemovedProvider( + get().commitGenProvider, + defaultSharedSettings.commitGenProvider, + ), + titleGenProvider: resetRemovedProvider( + get().titleGenProvider, + defaultSharedSettings.titleGenProvider, + ), + conflictResolverProvider: resetRemovedProvider( + get().conflictResolverProvider, + defaultSharedSettings.conflictResolverProvider, + ), + wslCommitGenProvider: resetRemovedProvider( + get().wslCommitGenProvider, + defaultSharedSettings.wslCommitGenProvider, + ), + wslTitleGenProvider: resetRemovedProvider( + get().wslTitleGenProvider, + defaultSharedSettings.wslTitleGenProvider, + ), + wslConflictResolverProvider: resetRemovedProvider( + get().wslConflictResolverProvider, + defaultSharedSettings.wslConflictResolverProvider, + ), providerConfigs: removeProfileKey(get().providerConfigs) as SharedSettings["providerConfigs"], hiddenModels: removeProfileKey(get().hiddenModels) as SharedSettings["hiddenModels"], agentSettings: removeProfileKey(get().agentSettings) as SharedSettings["agentSettings"], lastPresentationModeByAgent: removeProfileKey( get().lastPresentationModeByAgent, ) as SharedSettings["lastPresentationModeByAgent"], - disabledAgents: get().disabledAgents.filter((kind) => kind !== prefix), - favoriteModels: get().favoriteModels.filter((entry) => entry.agentKind !== prefix), - recentModels: get().recentModels.filter((entry) => entry.agentKind !== prefix), - providerOrder: get().providerOrder.filter((kind) => kind !== prefix), + disabledAgents: get().disabledAgents.filter((kind) => kind !== instanceKind), + favoriteModels: get().favoriteModels.filter((entry) => entry.agentKind !== instanceKind), + recentModels: get().recentModels.filter((entry) => entry.agentKind !== instanceKind), + providerOrder: get().providerOrder.filter((kind) => kind !== instanceKind), + usage: { + ...usage, + providerRefreshIntervals: removeProfileKey( + usage.providerRefreshIntervals, + ) as SharedSettings["usage"]["providerRefreshIntervals"], + sidebarHiddenProviders: usage.sidebarHiddenProviders.filter( + (kind) => kind !== instanceKind, + ), + disabledProviders: usage.disabledProviders.filter((kind) => kind !== instanceKind), + providerOrder: usage.providerOrder.filter((kind) => kind !== instanceKind), + collapsedProviders: usage.collapsedProviders.filter((kind) => kind !== instanceKind), + selectedRingGroups: removeProfileKey( + usage.selectedRingGroups, + ) as SharedSettings["usage"]["selectedRingGroups"], + }, }); persistSettings(selectSharedSettings(get())); }, diff --git a/src/renderer/views/SettingsOverlay/SettingsOverlay.test.tsx b/src/renderer/views/SettingsOverlay/SettingsOverlay.test.tsx index 31b40f498..88b200776 100644 --- a/src/renderer/views/SettingsOverlay/SettingsOverlay.test.tsx +++ b/src/renderer/views/SettingsOverlay/SettingsOverlay.test.tsx @@ -2,7 +2,7 @@ import { act, fireEvent, screen, within } from "@testing-library/react"; import { renderWithI18n as render } from "@/renderer/testUtils/i18n"; import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { AgentStatus, Project } from "@/shared/contracts"; +import type { AgentInstanceConfig, AgentStatus, Project } from "@/shared/contracts"; const statusesState = { agentStatuses: [] as AgentStatus[], @@ -17,6 +17,11 @@ const appState = { projects: [] as Project[], }; +const sharedSettingsState = { + disabledAgents: [] as string[], + agentInstances: {} as Record, +}; + vi.mock("@/renderer/state/agentStatusesStore", () => { const useAgentStatusesStore = ( selector: (state: { @@ -43,8 +48,8 @@ vi.mock("@/renderer/state/appStore", () => ({ })); vi.mock("@/renderer/state/sharedSettingsStore", () => ({ - useSharedSettings: (selector: (state: { disabledAgents: string[] }) => unknown) => - selector({ disabledAgents: [] }), + useSharedSettings: (selector: (state: typeof sharedSettingsState) => unknown) => + selector(sharedSettingsState), })); vi.mock("@/renderer/components/layout/PageLayout", () => ({ @@ -205,6 +210,8 @@ describe("SettingsOverlay", () => { statusesState.agentStatuses = []; statusesState.wslAgentStatuses = []; appState.projects = []; + sharedSettingsState.disabledAgents = []; + sharedSettingsState.agentInstances = {}; beginFirstLaunchDiscoveryMock.mockReset(); resetDiscoveredAgentsMock.mockReset(); refreshAgentStatusesMock.mockReset(); @@ -292,6 +299,28 @@ describe("SettingsOverlay", () => { expect(screen.getByText("Agent claude:home")).toBeInTheDocument(); }); + it("groups home profiles under their base provider with the saved display name", () => { + sharedSettingsState.agentInstances = { + work: { + id: "work", + driver: "codex", + displayName: "Work Account", + config: { homeDir: "~/.poracode/codex-profiles/work" }, + }, + }; + statusesState.agentStatuses = [ + makeStatus("codex", { label: "Codex", envKind: "posix" }), + makeStatus("codex:work", { label: "Codex Work", envKind: "posix" }), + ]; + + render( undefined} />); + fireEvent.click(screen.getByRole("button", { name: "Agents" })); + + expect(screen.getByRole("button", { name: "Work Account" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Work Account" })); + expect(screen.getByText("Agent codex:work")).toBeInTheDocument(); + }); + it("opens Agents on General and toggles closed on a second click", () => { statusesState.agentStatuses = [ makeStatus("claude", { diff --git a/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx b/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx index c3cec0fa6..2ae9aee7b 100644 --- a/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +++ b/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx @@ -23,12 +23,10 @@ import { } from "@/shared/contracts"; import { CLAUDE_EFFORT_TIERS } from "@/shared/agents/claudeEfforts"; import { readBridge } from "@/renderer/bridge"; -import { i18n } from "@/renderer/i18n/i18n"; import { Input } from "@/renderer/components/common"; import { formatEffortLabel } from "@/renderer/components/thread/threadDraftViewHelpers"; import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; -import { currentWslDistros } from "@/renderer/utils/acpRegistryAuth"; import { applyPresetEnvRows, cleanModels, @@ -47,6 +45,7 @@ import { type ModelRow, type ProfilePreset, } from "./ClaudeProfileSettingsModel"; +import { refreshProfileStatuses } from "./profileStatusRefresh"; const CLAUDE_PROFILE_BASE_MODEL_IDS = [ "claude-opus-4-8", @@ -57,15 +56,7 @@ const CLAUDE_PROFILE_BASE_MODEL_IDS = [ ]; function refreshClaudeProfile(kind?: string): void { - window.setTimeout(() => { - void readBridge() - .refreshAgentStatuses(currentWslDistros(), kind ? { agentKinds: [kind] } : undefined) - .catch((error) => - toast.danger( - error instanceof Error ? error.message : i18n._(msg`Unable to refresh Claude profiles.`), - ), - ); - }, 50); + refreshProfileStatuses(kind, msg`Unable to refresh Claude profiles.`); } // ── Effort multiselect dropdown ────────────────────────────────────────────── diff --git a/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettingsModel.ts b/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettingsModel.ts index c259391ac..991464a99 100644 --- a/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettingsModel.ts +++ b/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettingsModel.ts @@ -5,6 +5,9 @@ import type { ClaudeProfileInstanceConfig, } from "@/shared/contracts"; import { isEncryptedSecret } from "@/shared/secretFormat"; +import { slugifyProfileName } from "./ProfileSettingsModel"; + +export { slugifyProfileName, uniqueProfileId } from "./ProfileSettingsModel"; export const SAVED_SECRET_MASK = "••••••••"; @@ -113,31 +116,10 @@ export interface ModelRow { label: string; } -export function slugifyProfileName(value: string): string { - return ( - value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/gu, "-") - .replace(/^-+|-+$/gu, "") || "profile" - ); -} - export function defaultConfigDir(name: string): string { return `~/.poracode/claude-profiles/${slugifyProfileName(name)}`; } -export function uniqueProfileId(name: string, existing: Readonly>): string { - const base = slugifyProfileName(name); - let candidate = base; - let index = 2; - while (existing[candidate]) { - candidate = `${base}-${index}`; - index += 1; - } - return candidate; -} - export function shouldTreatEnvKeyAsSensitive(key: string): boolean { return SENSITIVE_KEY_RE.test(key); } diff --git a/src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.test.tsx b/src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.test.tsx new file mode 100644 index 000000000..2e18fae3e --- /dev/null +++ b/src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.test.tsx @@ -0,0 +1,171 @@ +import { fireEvent, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { msg } from "@lingui/core/macro"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentInstanceConfig } from "@/shared/contracts"; +import { renderWithI18n as render } from "@/renderer/testUtils/i18n"; + +const toastMock = vi.hoisted(() => ({ + danger: vi.fn<(message: string) => void>(), + success: vi.fn<(message: string) => void>(), +})); + +vi.mock("@heroui/react", () => ({ + Button: (props: { + children?: ReactNode; + "aria-label"?: string; + isDisabled?: boolean; + onPress?: () => void; + }) => ( + + ), + toast: toastMock, +})); + +vi.mock("@/renderer/components/common", () => ({ + Input: (props: { + "aria-label"?: string; + placeholder?: string; + value?: string; + onChange?: (event: { target: { value: string } }) => void; + }) => ( + + ), +})); + +const refreshAgentStatusesMock = vi.hoisted(() => vi.fn<() => Promise>()); + +vi.mock("@/renderer/bridge", () => ({ + readBridge: () => ({ refreshAgentStatuses: refreshAgentStatusesMock }), +})); + +vi.mock("@/renderer/utils/acpRegistryAuth", () => ({ + currentWslDistros: () => [], +})); + +const settingsState = { + agentInstances: {} as Record, + setAgentInstance: vi.fn<(instance: AgentInstanceConfig) => void>(), + removeAgentInstance: vi.fn<(id: string) => void>(), +}; +const statusState = { + removeAgentStatus: vi.fn<(kind: string) => void>(), +}; + +vi.mock("@/renderer/state/sharedSettingsStore", () => ({ + useSharedSettings: (selector: (state: typeof settingsState) => unknown) => + selector(settingsState), +})); + +vi.mock("@/renderer/state/agentStatusesStore", () => ({ + useAgentStatusesStore: (selector: (state: typeof statusState) => unknown) => + selector(statusState), +})); + +import { + HomeProfileProviderSettings, + HomeProfileSettings, + type HomeProfileProviderConfig, +} from "./HomeProfileSettings"; + +const CODEX_CONFIG: HomeProfileProviderConfig = { + driver: "codex", + providerName: msg`Codex`, +}; + +function codexProfile(overrides: Partial = {}): AgentInstanceConfig { + return { + id: "work", + driver: "codex", + displayName: "Work", + config: { homeDir: "~/.poracode/codex-profiles/work" }, + ...overrides, + }; +} + +describe("HomeProfileSettings", () => { + beforeEach(() => { + settingsState.agentInstances = {}; + settingsState.setAgentInstance.mockReset(); + settingsState.removeAgentInstance.mockReset(); + statusState.removeAgentStatus.mockReset(); + refreshAgentStatusesMock.mockReset().mockResolvedValue(undefined); + toastMock.success.mockReset(); + toastMock.danger.mockReset(); + }); + + it("adds a profile with a derived provider home directory and opens it", () => { + const onOpenProfile = vi.fn<(kind: string) => void>(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Add profile" })); + fireEvent.change(screen.getByLabelText("New Codex profile name"), { + target: { value: "Work Account" }, + }); + expect(screen.getByLabelText("New Codex profile home directory")).toHaveAttribute( + "placeholder", + "~/.poracode/codex-profiles/work-account", + ); + fireEvent.click(screen.getByRole("button", { name: "Add Codex profile" })); + + expect(settingsState.setAgentInstance).toHaveBeenCalledWith({ + id: "work-account", + driver: "codex", + displayName: "Work Account", + config: { homeDir: "~/.poracode/codex-profiles/work-account" }, + }); + expect(onOpenProfile).toHaveBeenCalledWith("codex:work-account"); + }); + + it("lists only the configured provider and removes its scoped status", () => { + settingsState.agentInstances = { + work: codexProfile(), + personal: { + id: "personal", + driver: "gemini", + displayName: "Personal Gemini", + config: { homeDir: "~/.poracode/gemini-profiles/personal" }, + }, + }; + render(); + + expect(screen.getByText("Work")).toBeInTheDocument(); + expect(screen.queryByText("Personal Gemini")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Remove Codex profile" })); + + expect(settingsState.removeAgentInstance).toHaveBeenCalledWith("work"); + expect(statusState.removeAgentStatus).toHaveBeenCalledWith("codex:work"); + }); + + it("updates a profile name and home directory", () => { + settingsState.agentInstances = { work: codexProfile() }; + render(); + + fireEvent.change(screen.getByLabelText("Codex profile name"), { + target: { value: "Company" }, + }); + fireEvent.change(screen.getByLabelText("Codex profile home directory"), { + target: { value: "~/profiles/company" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save Codex profile" })); + + expect(settingsState.setAgentInstance).toHaveBeenCalledWith({ + id: "work", + driver: "codex", + displayName: "Company", + config: { homeDir: "~/profiles/company" }, + }); + }); +}); diff --git a/src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx b/src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx new file mode 100644 index 000000000..e5b8fa2e8 --- /dev/null +++ b/src/renderer/views/SettingsOverlay/parts/HomeProfileSettings.tsx @@ -0,0 +1,366 @@ +import { useState } from "react"; +import { Button, toast } from "@heroui/react"; +import type { MessageDescriptor } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; +import { Check, ChevronRight, Plus, RefreshCw, Trash2, X } from "lucide-react"; +import { + extractHomeProfileInstanceId, + homeProfileKind, + parseHomeProfileInstanceConfig, + type AgentInstanceConfig, + type HomeProfileDriver, + type HomeProfileInstanceConfig, +} from "@/shared/contracts"; +import { Input } from "@/renderer/components/common"; +import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore"; +import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { defaultHomeProfileDir, uniqueProfileId } from "./ProfileSettingsModel"; +import { refreshProfileStatuses } from "./profileStatusRefresh"; + +export interface HomeProfileProviderConfig { + driver: HomeProfileDriver; + providerName: MessageDescriptor; +} + +function refreshHomeProfiles(kind?: string): void { + refreshProfileStatuses(kind, msg`Unable to refresh profiles.`); +} + +export function HomeProfileProviderSettings(props: { + config: HomeProfileProviderConfig; + instanceId: string; +}) { + const instance = useSharedSettings((state) => state.agentInstances?.[props.instanceId]); + if (!instance || instance.driver !== props.config.driver) return null; + let instanceConfig: HomeProfileInstanceConfig; + try { + instanceConfig = parseHomeProfileInstanceConfig(instance.config); + } catch { + return null; + } + return ( + + ); +} + +function HomeProfileEditor(props: { + providerConfig: HomeProfileProviderConfig; + instance: AgentInstanceConfig; + instanceConfig: HomeProfileInstanceConfig; +}) { + const { t, i18n: lingui } = useLingui(); + const setAgentInstance = useSharedSettings((state) => state.setAgentInstance); + const [name, setName] = useState(props.instance.displayName ?? props.instance.id); + const [homeDir, setHomeDir] = useState(props.instanceConfig.homeDir); + const providerName = lingui._(props.providerConfig.providerName); + const trimmedName = name.trim(); + const trimmedHomeDir = homeDir.trim(); + const canSave = trimmedName.length > 0 && trimmedHomeDir.length > 0; + + function save(): void { + if (!canSave) return; + setAgentInstance({ + ...props.instance, + displayName: trimmedName, + config: { homeDir: trimmedHomeDir }, + }); + refreshHomeProfiles(homeProfileKind(props.providerConfig.driver, props.instance.id)); + toast.success(t`${providerName} ${trimmedName} profile saved.`); + } + + return ( +
+
+
+

+ Profile +

+

+ + This profile keeps its {providerName} account, settings, and sessions in a separate + home directory. + +

+
+ +
+ +
+
+ + Name + + setName(event.target.value)} + /> +
+
+ + Home directory + + setHomeDir(event.target.value)} + /> +
+
+
+ ); +} + +function HomeProfileRow(props: { + providerName: string; + instance: AgentInstanceConfig; + instanceConfig: HomeProfileInstanceConfig; + onOpen: () => void; + onRemove: () => void; +}) { + const { t } = useLingui(); + const label = props.instance.displayName ?? props.instance.id; + return ( +
+ +
+ + +
+
+ ); +} + +export function HomeProfileSettings(props: { + config: HomeProfileProviderConfig; + onOpenProfile?: ((profileKind: string) => void) | undefined; +}) { + const { t, i18n: lingui } = useLingui(); + const agentInstances = useSharedSettings((state) => state.agentInstances ?? {}); + const setAgentInstance = useSharedSettings((state) => state.setAgentInstance); + const removeAgentInstance = useSharedSettings((state) => state.removeAgentInstance); + const removeAgentStatus = useAgentStatusesStore((state) => state.removeAgentStatus); + const [isAdding, setIsAdding] = useState(false); + const [newName, setNewName] = useState(""); + const [newHomeDir, setNewHomeDir] = useState(""); + const providerName = lingui._(props.config.providerName); + + const profiles: Array<{ + instance: AgentInstanceConfig; + instanceConfig: HomeProfileInstanceConfig; + }> = []; + for (const instance of Object.values(agentInstances)) { + if (instance.driver !== props.config.driver) continue; + try { + profiles.push({ + instance, + instanceConfig: parseHomeProfileInstanceConfig(instance.config), + }); + } catch { + // Ignore malformed records here; the supervisor skips them too. + } + } + profiles.sort((left, right) => + (left.instance.displayName ?? left.instance.id).localeCompare( + right.instance.displayName ?? right.instance.id, + ), + ); + + const suggestedHomeDir = defaultHomeProfileDir(props.config.driver, newName); + const canAdd = newName.trim().length > 0; + + function closeAddForm(): void { + setIsAdding(false); + setNewName(""); + setNewHomeDir(""); + } + + function addProfile(): void { + const displayName = newName.trim(); + if (!displayName) return; + const id = uniqueProfileId(displayName, agentInstances); + const homeDir = newHomeDir.trim() || suggestedHomeDir; + setAgentInstance({ + id, + driver: props.config.driver, + displayName, + config: { homeDir }, + }); + const kind = homeProfileKind(props.config.driver, id); + refreshHomeProfiles(kind); + closeAddForm(); + toast.success(t`${providerName} ${displayName} profile added.`); + props.onOpenProfile?.(kind); + } + + return ( +
+
+
+

+ Profiles +

+

+ + Use separate {providerName} accounts and settings by assigning each profile its own + home directory. + +

+
+ +
+ + {profiles.length === 0 && !isAdding ? ( +

+ No additional {providerName} profiles. +

+ ) : null} + +
+ {profiles.map(({ instance, instanceConfig }) => { + const kind = homeProfileKind(props.config.driver, instance.id); + return ( + props.onOpenProfile?.(kind)} + onRemove={() => { + removeAgentInstance(instance.id); + removeAgentStatus(kind); + refreshHomeProfiles(); + toast.success(t`${providerName} profile removed.`); + }} + /> + ); + })} + + {isAdding ? ( +
+ node?.focus()} + aria-label={t`New ${providerName} profile name`} + className="min-w-0" + placeholder={t`e.g. Work`} + value={newName} + onChange={(event) => setNewName(event.target.value)} + /> + setNewHomeDir(event.target.value)} + /> +
+ + +
+
+ ) : null} +
+ + {!isAdding ? ( + + ) : null} +
+ ); +} + +export function createHomeProfileSettingsPanel(config: HomeProfileProviderConfig) { + return function HomeProfileAgentSettingsPanel(props: { + agentKind: string; + onOpenProfile?: ((profileKind: string) => void) | undefined; + }) { + const instanceId = extractHomeProfileInstanceId(props.agentKind); + if (instanceId !== undefined) { + return ( + + ); + } + return ; + }; +} diff --git a/src/renderer/views/SettingsOverlay/parts/ProfileSettingsModel.test.ts b/src/renderer/views/SettingsOverlay/parts/ProfileSettingsModel.test.ts new file mode 100644 index 000000000..faf244dc5 --- /dev/null +++ b/src/renderer/views/SettingsOverlay/parts/ProfileSettingsModel.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { defaultHomeProfileDir, slugifyProfileName, uniqueProfileId } from "./ProfileSettingsModel"; + +describe("ProfileSettingsModel", () => { + it("builds provider-scoped home directories from profile names", () => { + expect(defaultHomeProfileDir("codex", "Work Account")).toBe( + "~/.poracode/codex-profiles/work-account", + ); + expect(defaultHomeProfileDir("gemini", "Personal")).toBe( + "~/.poracode/gemini-profiles/personal", + ); + }); + + it("normalizes empty and punctuation-only names", () => { + expect(slugifyProfileName(" !!! ")).toBe("profile"); + }); + + it("chooses the next unused profile id", () => { + expect(uniqueProfileId("Work", { work: {}, "work-2": {} })).toBe("work-3"); + }); +}); diff --git a/src/renderer/views/SettingsOverlay/parts/ProfileSettingsModel.ts b/src/renderer/views/SettingsOverlay/parts/ProfileSettingsModel.ts new file mode 100644 index 000000000..7ab9e407b --- /dev/null +++ b/src/renderer/views/SettingsOverlay/parts/ProfileSettingsModel.ts @@ -0,0 +1,26 @@ +import type { HomeProfileDriver } from "@/shared/contracts"; + +export function slugifyProfileName(value: string): string { + return ( + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, "") || "profile" + ); +} + +export function uniqueProfileId(name: string, existing: Readonly>): string { + const base = slugifyProfileName(name); + let candidate = base; + let index = 2; + while (existing[candidate]) { + candidate = `${base}-${index}`; + index += 1; + } + return candidate; +} + +export function defaultHomeProfileDir(driver: HomeProfileDriver, name: string): string { + return `~/.poracode/${driver}-profiles/${slugifyProfileName(name)}`; +} diff --git a/src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx b/src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx index ce384838a..8ce7a0ef9 100644 --- a/src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx +++ b/src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx @@ -31,7 +31,13 @@ import { } from "lucide-react"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { useLingui } from "@lingui/react/macro"; -import { baseAgentKind, isClaudeProfileKind, type AgentStatus } from "@/shared/contracts"; +import { + baseAgentKind, + extractClaudeProfileInstanceId, + extractHomeProfileInstanceId, + type AgentInstanceConfig, + type AgentStatus, +} from "@/shared/contracts"; import { useFindFocusStore } from "@/renderer/state/findFocusStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { @@ -65,8 +71,18 @@ const DESKTOP_ONLY_SECTIONS = new Set([ "about", ]); -function claudeProfileSidebarLabel(agent: AgentStatus): string { - return agent.label.replace(/^Claude\s+/iu, "").trim() || agent.label; +function profileSidebarLabel( + agent: AgentStatus, + instances: Readonly>, +): string { + const instanceId = + extractClaudeProfileInstanceId(agent.kind) ?? extractHomeProfileInstanceId(agent.kind); + const displayName = instanceId ? instances[instanceId]?.displayName?.trim() : undefined; + if (displayName) return displayName; + const baseKind = baseAgentKind(agent.kind); + return agent.label.toLowerCase().startsWith(`${baseKind.toLowerCase()} `) + ? agent.label.slice(baseKind.length).trim() || agent.label + : agent.label; } function renderAgentIcon( @@ -74,15 +90,14 @@ function renderAgentIcon( options: { disabled: boolean; className?: string; + fallbackLabel?: string; }, ) { return ( ); @@ -148,6 +163,7 @@ export function SettingsSidebar(props: { const { t } = useLingui(); const { isCollapsed, collapse, expand } = useSidebar(); const disabledAgents = useSharedSettings((s) => s.disabledAgents); + const agentInstances = useSharedSettings((s) => s.agentInstances); // Instance-scoped kinds (e.g. Claude profiles "claude:") nest under // their base agent's sidebar entry when the base itself is installed; // instance kinds without an installed base (ACP registry agents) stay @@ -470,6 +486,7 @@ export function SettingsSidebar(props: { /> {instanceAgentsFor(agent.kind).map((profile) => { const profileNeedsAttention = attentionAgentKinds.has(profile.kind); + const profileLabel = profileSidebarLabel(profile, agentInstances); return ( ) : null} } - label={profile.label} + label={profileLabel} isActive={activeSection === `agents:${profile.kind}`} onPress={() => onSectionChange(`agents:${profile.kind}`)} /> @@ -710,6 +728,7 @@ export function SettingsSidebar(props: {
{instanceAgentsFor(agent.kind).map((profile) => { const profileDisabled = disabledAgents.includes(profile.kind); + const profileLabel = profileSidebarLabel(profile, agentInstances); const profileNeedsAttention = attentionAgentKinds.has( profile.kind, ); @@ -719,8 +738,9 @@ export function SettingsSidebar(props: { icon={renderAgentIcon(profile, { disabled: profileDisabled, className: "size-3.5", + fallbackLabel: profileLabel, })} - label={claudeProfileSidebarLabel(profile)} + label={profileLabel} suffix={ profileNeedsAttention ? ( { expect(screen.queryByText("This agent is not installed.")).not.toBeInTheDocument(); }); + it("renders a home profile editor before detection has reported the profile status", () => { + sharedSettingsState.agentInstances = { + work: { + id: "work", + driver: "codex", + displayName: "Work", + config: { homeDir: "~/.poracode/codex-profiles/work" }, + }, + }; + + render(); + + expect(screen.getByText("Codex Work")).toBeInTheDocument(); + expect(screen.getByLabelText("Codex profile home directory")).toHaveValue( + "~/.poracode/codex-profiles/work", + ); + expect(screen.queryByText("This agent is not installed.")).not.toBeInTheDocument(); + }); + it("summarizes OpenCode connected providers on a single line", () => { statusesState.agentStatuses = [ makeStatus("opencode", { diff --git a/src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx b/src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx index 73a907ca7..d8ed18746 100644 --- a/src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx +++ b/src/renderer/views/SettingsOverlay/parts/SingleAgentSettings/SingleAgentSettings.tsx @@ -13,6 +13,7 @@ import { baseAgentKind, extractAcpGenericInstanceId, extractClaudeProfileInstanceId, + extractHomeProfileInstanceId, } from "@/shared/contracts"; import { runAgentInstallCommand, runAgentLoginCommand } from "@/renderer/actions/agentLoginActions"; import { useAppStore } from "@/renderer/state/appStore"; @@ -41,7 +42,6 @@ import { import { expandAgentToVisibilityProviders } from "@/renderer/components/thread/buildModelPickerControls"; import { SettingsPage } from "../SettingsForm"; import { NATIVE_AGENT_REGISTRY_ENTRIES } from "../agentRegistryNative"; -import { ClaudeProfileProviderSettings } from "../ClaudeProfileSettings"; import { AgentSettingRow } from "./parts/AgentSettingRow"; import { ModelVisibilityDropdown } from "./parts/ModelVisibilityDropdown"; import { AgentEnvironmentRow, AgentInstallEnvironmentRow } from "./parts/AgentEnvironmentRow"; @@ -61,7 +61,7 @@ export function SingleAgentSettings(props: { agentKind: string; onOpenProfile?: (profileKind: string) => void; }) { - const { t } = useLingui(); + const { t, i18n } = useLingui(); const [authValues, setAuthValues] = useState>({}); const [authPending, setAuthPending] = useState(false); const [authPendingMessage, setAuthPendingMessage] = useState(); @@ -97,9 +97,11 @@ export function SingleAgentSettings(props: { const projects = useAppStore((state) => state.projects); const wslProjectDistrosKey = buildWslProjectDistrosKey(projects); const platform = navigator.platform.toLowerCase().includes("win") ? "win32" : "posix"; - const claudeProfileInstanceId = extractClaudeProfileInstanceId(props.agentKind); - const claudeProfileInstance = useSharedSettings((s) => - claudeProfileInstanceId ? s.agentInstances[claudeProfileInstanceId] : undefined, + const profileInstanceId = + extractClaudeProfileInstanceId(props.agentKind) ?? + extractHomeProfileInstanceId(props.agentKind); + const profileInstance = useSharedSettings((s) => + profileInstanceId ? s.agentInstances[profileInstanceId] : undefined, ); const installedHere = agentStatuses.filter((a) => a.kind === props.agentKind && a.installed); const installedWsl = wslAgentStatuses.filter((a) => a.kind === props.agentKind && a.installed); @@ -108,7 +110,7 @@ export function SingleAgentSettings(props: { (entry) => entry.id === props.agentKind, ); // Provider-specific settings UI resolves by base kind so instance-scoped - // kinds (Claude profiles "claude:") render their provider's panel. + // profile kinds render their provider's panel. const providerEntry = NATIVE_AGENT_REGISTRY_ENTRIES.find( (entry) => entry.id === baseAgentKind(props.agentKind), ); @@ -212,13 +214,25 @@ export function SingleAgentSettings(props: { }, [accountResolver, wslProjectDistrosKey]); if (!agent) { - if (claudeProfileInstanceId && claudeProfileInstance?.driver === "claude") { + if ( + profileInstanceId && + profileInstance?.driver === baseAgentKind(props.agentKind) && + providerEntry?.settingsPanel + ) { + const providerName = providerEntry.profileProviderName + ? i18n._(providerEntry.profileProviderName) + : baseAgentKind(props.agentKind); return ( - + ); } @@ -681,7 +695,7 @@ export function SingleAgentSettings(props: {
diff --git a/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts b/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts index c3bf2f3da..c46dc4ca3 100644 --- a/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts +++ b/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts @@ -4,6 +4,7 @@ import type { MessageDescriptor } from "@lingui/core"; import type { AgentKind, AgentProviderMetadata, AgentStatus, Project } from "@/shared/contracts"; import { isMac, isWindows, readBridge } from "@/renderer/bridge"; import { ClaudeAgentSettingsPanel } from "./ClaudeProfileSettings"; +import { createHomeProfileSettingsPanel } from "./HomeProfileSettings"; import { OpenCodeProviderSettings } from "./OpenCodeProviderSettings"; /** @@ -41,6 +42,8 @@ export interface NativeAgentRegistryEntry { * (Claude profiles) render their base provider's panel. */ settingsPanel?: ComponentType; + /** Provider label used for instance-scoped profile pages before detection completes. */ + profileProviderName?: MessageDescriptor; /** * The provider's `settingsPanel` owns sign-in UI, so `SingleAgentSettings` * suppresses its generic auth section (e.g. OpenCode authenticates per AI @@ -56,6 +59,23 @@ export interface NativeAgentRegistryEntry { accountResolver?: (wslDistros: string[]) => Promise; } +const CodexProfileSettingsPanel = createHomeProfileSettingsPanel({ + driver: "codex", + providerName: msg`Codex`, +}); +const CopilotProfileSettingsPanel = createHomeProfileSettingsPanel({ + driver: "copilot", + providerName: msg`GitHub Copilot`, +}); +const GeminiProfileSettingsPanel = createHomeProfileSettingsPanel({ + driver: "gemini", + providerName: msg`Gemini`, +}); +const GrokProfileSettingsPanel = createHomeProfileSettingsPanel({ + driver: "grok", + providerName: msg`Grok Build`, +}); + const POSIX_MISSING_CURL_NPM_MESSAGE = "printf 'No supported installer found. Install curl or Node.js/npm first, then refresh detected agents.\\n'"; const MAC_MISSING_CURL_BREW_NPM_MESSAGE = @@ -106,6 +126,8 @@ export const NATIVE_AGENT_REGISTRY_ENTRIES: NativeAgentRegistryEntry[] = [ windows: "if (Get-Command powershell -ErrorAction SilentlyContinue) { powershell -ExecutionPolicy ByPass -c \"irm https://chatgpt.com/codex/install.ps1 | iex\" } elseif (Get-Command npm -ErrorAction SilentlyContinue) { npm install -g @openai/codex } else { Write-Host 'No supported installer found. Install Windows PowerShell or Node.js/npm first, then refresh detected agents.' }", }), + settingsPanel: CodexProfileSettingsPanel, + profileProviderName: msg`Codex`, }, { id: "claude", @@ -127,6 +149,7 @@ export const NATIVE_AGENT_REGISTRY_ENTRIES: NativeAgentRegistryEntry[] = [ "if (Get-Command irm -ErrorAction SilentlyContinue) { irm https://claude.ai/install.ps1 | iex } elseif (Get-Command curl.exe -ErrorAction SilentlyContinue) { cmd /c \"curl -fsSL https://claude.ai/install.cmd -o install.cmd && install.cmd && del install.cmd\" } elseif (Get-Command winget -ErrorAction SilentlyContinue) { winget install Anthropic.ClaudeCode } else { Write-Host 'No supported installer found. Install PowerShell Invoke-RestMethod, curl, or WinGet first, then refresh detected agents.' }", }), settingsPanel: ClaudeAgentSettingsPanel, + profileProviderName: msg`Claude`, }, { id: "opencode", @@ -168,6 +191,8 @@ export const NATIVE_AGENT_REGISTRY_ENTRIES: NativeAgentRegistryEntry[] = [ windows: "if (Get-Command irm -ErrorAction SilentlyContinue) { irm https://x.ai/cli/install.ps1 | iex } else { Write-Host 'No supported installer found. Install PowerShell Invoke-RestMethod first, then refresh detected agents.' }", }), + settingsPanel: GrokProfileSettingsPanel, + profileProviderName: msg`Grok Build`, }, { id: "factory", @@ -244,6 +269,8 @@ export const NATIVE_AGENT_REGISTRY_ENTRIES: NativeAgentRegistryEntry[] = [ "; fi", "if (Get-Command npm -ErrorAction SilentlyContinue) { npm install -g @google/gemini-cli } else { Write-Host 'No supported installer found. Install Node.js/npm first, then refresh detected agents.' }", ), + settingsPanel: GeminiProfileSettingsPanel, + profileProviderName: msg`Gemini`, }, { id: "copilot", @@ -270,6 +297,8 @@ export const NATIVE_AGENT_REGISTRY_ENTRIES: NativeAgentRegistryEntry[] = [ windows: "if (Get-Command winget -ErrorAction SilentlyContinue) { winget install GitHub.Copilot } elseif (Get-Command npm -ErrorAction SilentlyContinue) { npm install -g @github/copilot } else { Write-Host 'No supported installer found. Install WinGet or Node.js/npm first, then refresh detected agents.' }", }), + settingsPanel: CopilotProfileSettingsPanel, + profileProviderName: msg`GitHub Copilot`, }, ]; diff --git a/src/renderer/views/SettingsOverlay/parts/profileStatusRefresh.ts b/src/renderer/views/SettingsOverlay/parts/profileStatusRefresh.ts new file mode 100644 index 000000000..a403ad0fc --- /dev/null +++ b/src/renderer/views/SettingsOverlay/parts/profileStatusRefresh.ts @@ -0,0 +1,18 @@ +import { toast } from "@heroui/react"; +import type { MessageDescriptor } from "@lingui/core"; +import { readBridge } from "@/renderer/bridge"; +import { i18n } from "@/renderer/i18n/i18n"; +import { currentWslDistros } from "@/renderer/utils/acpRegistryAuth"; + +export function refreshProfileStatuses( + kind: string | undefined, + fallbackError: MessageDescriptor, +): void { + window.setTimeout(() => { + void readBridge() + .refreshAgentStatuses(currentWslDistros(), kind ? { agentKinds: [kind] } : undefined) + .catch((error) => + toast.danger(error instanceof Error ? error.message : i18n._(fallbackError)), + ); + }, 50); +} diff --git a/src/shared/contracts/agentInstance.test.ts b/src/shared/contracts/agentInstance.test.ts index 7c104fcf7..1bec16e80 100644 --- a/src/shared/contracts/agentInstance.test.ts +++ b/src/shared/contracts/agentInstance.test.ts @@ -2,15 +2,21 @@ import { describe, expect, it } from "vitest"; import { ACP_GENERIC_KIND_PREFIX, acpGenericKind, + agentKindsSharingBinary, agentInstanceConfigSchema, baseAgentKind, claudeProfileKind, extractAcpGenericInstanceId, extractClaudeProfileInstanceId, + extractHomeProfileInstanceId, + homeProfileKind, isAcpGenericKind, isClaudeProfileKind, + isHomeProfileDriver, + isHomeProfileKind, parseAcpGenericInstanceConfig, parseClaudeProfileInstanceConfig, + parseHomeProfileInstanceConfig, } from "./agentInstance"; /** @@ -107,6 +113,80 @@ describe("Claude profile instance helpers", () => { }); }); +describe("home profile instance helpers", () => { + it("parses a provider home directory", () => { + expect( + parseHomeProfileInstanceConfig({ + homeDir: "~/.poracode/codex-profiles/work", + }), + ).toEqual({ homeDir: "~/.poracode/codex-profiles/work" }); + }); + + it("rejects an empty provider home directory", () => { + expect(() => parseHomeProfileInstanceConfig({ homeDir: "" })).toThrow(Error); + }); + + it("recognizes only providers with first-class home isolation", () => { + expect(isHomeProfileDriver("codex")).toBe(true); + expect(isHomeProfileDriver("copilot")).toBe(true); + expect(isHomeProfileDriver("gemini")).toBe(true); + expect(isHomeProfileDriver("grok")).toBe(true); + expect(isHomeProfileDriver("claude")).toBe(false); + expect(isHomeProfileDriver("opencode")).toBe(false); + }); + + it("round-trips profile ids through each provider namespace", () => { + expect(homeProfileKind("codex", "work")).toBe("codex:work"); + expect(homeProfileKind("copilot", "personal")).toBe("copilot:personal"); + expect(homeProfileKind("gemini", "enterprise")).toBe("gemini:enterprise"); + expect(homeProfileKind("grok", "team")).toBe("grok:team"); + expect(isHomeProfileKind("codex:work")).toBe(true); + expect(extractHomeProfileInstanceId("codex:work")).toBe("work"); + }); + + it("rejects base and unsupported provider kinds", () => { + expect(isHomeProfileKind("codex")).toBe(false); + expect(isHomeProfileKind("codex:")).toBe(false); + expect(isHomeProfileKind("claude:work")).toBe(false); + expect(isHomeProfileKind("opencode:work")).toBe(false); + expect(extractHomeProfileInstanceId("codex")).toBeUndefined(); + }); +}); + +describe("agentKindsSharingBinary", () => { + const registeredKinds = [ + "claude", + "claude:work", + "codex", + "codex:work", + "codex:personal", + "acp-generic:custom", + ]; + + it("groups a base provider with every profile sharing its CLI", () => { + expect(agentKindsSharingBinary("codex", registeredKinds)).toEqual([ + "codex", + "codex:work", + "codex:personal", + ]); + expect(agentKindsSharingBinary("codex:work", registeredKinds)).toEqual([ + "codex", + "codex:work", + "codex:personal", + ]); + expect(agentKindsSharingBinary("claude:work", registeredKinds)).toEqual([ + "claude", + "claude:work", + ]); + }); + + it("does not group unrelated colon-scoped adapters", () => { + expect(agentKindsSharingBinary("acp-generic:custom", registeredKinds)).toEqual([ + "acp-generic:custom", + ]); + }); +}); + describe("agentInstanceConfigSchema", () => { it("validates a registered instance shape", () => { const result = agentInstanceConfigSchema.parse({ diff --git a/src/shared/contracts/agentInstance.ts b/src/shared/contracts/agentInstance.ts index d20c973f0..1b6d8271d 100644 --- a/src/shared/contracts/agentInstance.ts +++ b/src/shared/contracts/agentInstance.ts @@ -24,6 +24,9 @@ export type AgentDriverKind = z.infer; export const CLAUDE_PROFILE_KIND_PREFIX = "claude:"; +export const HOME_PROFILE_DRIVERS = ["codex", "copilot", "gemini", "grok"] as const; +export type HomeProfileDriver = (typeof HOME_PROFILE_DRIVERS)[number]; + export const agentInstanceIdSchema = z .string() .min(1) @@ -157,6 +160,42 @@ export function parseClaudeProfileInstanceConfig(value: unknown): ClaudeProfileI return claudeProfileInstanceConfigSchema.parse(value ?? {}); } +// ─── home profile driver config ────────────────────────────────────── + +export const homeProfileInstanceConfigSchema = z.object({ + /** + * Provider home directory selected for this profile. A leading "~/" is + * resolved against the target runtime environment (native home or WSL home). + */ + homeDir: z.string().min(1), +}); +export type HomeProfileInstanceConfig = z.infer; + +export function parseHomeProfileInstanceConfig(value: unknown): HomeProfileInstanceConfig { + return homeProfileInstanceConfigSchema.parse(value ?? {}); +} + +export function isHomeProfileDriver(driver: string): driver is HomeProfileDriver { + return HOME_PROFILE_DRIVERS.some((candidate) => candidate === driver); +} + +export function homeProfileKind(driver: HomeProfileDriver, instanceId: string): AgentDriverKind { + return `${driver}:${instanceId}` as AgentDriverKind; +} + +export function isHomeProfileKind(kind: string): boolean { + const separatorIndex = kind.indexOf(":"); + return ( + separatorIndex > 0 && + separatorIndex < kind.length - 1 && + isHomeProfileDriver(kind.slice(0, separatorIndex)) + ); +} + +export function extractHomeProfileInstanceId(kind: string): string | undefined { + return isHomeProfileKind(kind) ? kind.slice(kind.indexOf(":") + 1) : undefined; +} + /** * Payload for the `setClaudeProfileEnvironment` main-local IPC. The renderer * sends the full desired environment (plaintext for freshly-entered values, @@ -191,3 +230,18 @@ export function baseAgentKind(kind: string): string { const separatorIndex = kind.indexOf(":"); return separatorIndex > 0 ? kind.slice(0, separatorIndex) : kind; } + +/** + * Returns every registered adapter backed by the same provider binary. Profile + * adapters isolate account state and sessions, but share the base CLI install. + */ +export function agentKindsSharingBinary(kind: string, registeredKinds: Iterable): string[] { + const profileKind = isClaudeProfileKind(kind) || isHomeProfileKind(kind); + const binaryKind = profileKind ? baseAgentKind(kind) : kind; + return [...registeredKinds].filter( + (candidate) => + candidate === binaryKind || + ((isClaudeProfileKind(candidate) || isHomeProfileKind(candidate)) && + baseAgentKind(candidate) === binaryKind), + ); +} diff --git a/src/supervisor/agents/claude/index.ts b/src/supervisor/agents/claude/index.ts index 274b1d66a..105cfc5f4 100644 --- a/src/supervisor/agents/claude/index.ts +++ b/src/supervisor/agents/claude/index.ts @@ -25,6 +25,7 @@ import { type CreateStructuredSessionInput, type DetectProbeCtx, } from "../base"; +import { resolveAgentInstanceEnv } from "../homeProfile"; import { buildClaudeArgs, claudeExtraArgsPosition, rewriteClaudeLaunchArgsForConfig } from "./argv"; import { claudeCapabilities, claudeDetectionSpec, probeClaudeStatus } from "./detection"; import { probeClaudeCapabilities } from "./probe"; @@ -90,22 +91,6 @@ function profileEnvForLocation( return Object.keys(env).length > 0 ? env : undefined; } -/** - * Flatten an instance's `environment` map (values already decrypted by the - * supervisor's settings read) into a plain name→value map for spawning. - */ -function resolveInstanceEnv( - environment: AgentInstanceConfig["environment"], -): Record | undefined { - if (!environment) return undefined; - const resolved: Record = {}; - for (const [name, variable] of Object.entries(environment)) { - if (name.trim().length === 0) continue; - resolved[name] = variable.value; - } - return Object.keys(resolved).length > 0 ? resolved : undefined; -} - /** * Apply a profile's optional model additions / effort allow-list on top of the * built-in Claude capabilities. A no-op when neither override is set (so the @@ -162,7 +147,7 @@ function overrideProfileCapabilities( export function createClaudeProfileAdapter(instance: AgentInstanceConfig): AgentAdapter { const cfg = parseClaudeProfileInstanceConfig(instance.config); const profileLabel = instance.displayName ?? instance.id; - const customEnv = resolveInstanceEnv(instance.environment); + const customEnv = resolveAgentInstanceEnv(instance.environment); return createClaudeAdapter({ kind: claudeProfileKind(instance.id), label: `Claude ${profileLabel}`, diff --git a/src/supervisor/agents/codex/acp.ts b/src/supervisor/agents/codex/acp.ts index 576bb9bbd..a4c69d126 100644 --- a/src/supervisor/agents/codex/acp.ts +++ b/src/supervisor/agents/codex/acp.ts @@ -392,6 +392,7 @@ export class CodexStructuredSession implements StructuredSessionHandle { static async create( input: CreateStructuredSessionInput, wslExecPath?: string, + env?: Record, ): Promise { const wslNodePath = input.projectLocation.kind === "wsl" @@ -411,6 +412,7 @@ export class CodexStructuredSession implements StructuredSessionHandle { ...(input.chromeMcp !== undefined ? { chromeMcp: input.chromeMcp } : {}), ...(input.appControlsMcp !== undefined ? { appControlsMcp: input.appControlsMcp } : {}), ...(input.mcpServers !== undefined ? { mcpServers: input.mcpServers } : {}), + ...(env ? { env } : {}), }), ); const transport = new CodexStdioTransport(appServer); diff --git a/src/supervisor/agents/codex/argv.ts b/src/supervisor/agents/codex/argv.ts index d779f3b1f..e511be91b 100644 --- a/src/supervisor/agents/codex/argv.ts +++ b/src/supervisor/agents/codex/argv.ts @@ -132,6 +132,7 @@ export function buildCodexArgvFor( prompt: string, sessionRef?: SessionRef, launchOptions?: AgentLaunchOptions, + profileEnv?: Record, ): AgentArgvSpec { const binary = resolveCodexWindowsLaunchBinary(location) ?? "codex"; const userMcp = buildCodexUserMcp(launchOptions?.mcpServers ?? []); @@ -143,7 +144,8 @@ export function buildCodexArgvFor( ...buildCodexChromeMcpEnv(launchOptions?.chromeMcp), ...buildCodexAppControlsMcpEnv(launchOptions?.appControlsMcp), }; - const hasMcpEnv = Object.keys(mcpEnv).length > 0; + const env = { ...profileEnv, ...mcpEnv }; + const hasEnv = Object.keys(env).length > 0; const enableGoals = isCodexGoalsSupported(location); const baseArgsOptions: BuildCodexArgsOptions = { config, @@ -168,7 +170,7 @@ export function buildCodexArgvFor( return { binary, args, - ...(hasMcpEnv ? { env: mcpEnv } : {}), + ...(hasEnv ? { env } : {}), }; } @@ -185,7 +187,7 @@ export function buildCodexArgvFor( return { binary, args, - ...(hasMcpEnv ? { env: mcpEnv } : {}), + ...(hasEnv ? { env } : {}), }; } @@ -204,6 +206,7 @@ export function buildCodexAppServerCommand( chromeMcp?: ChromeMcpHttpConfig; appControlsMcp?: AppControlsMcpHttpConfig; mcpServers?: McpServer[]; + env?: Record; }, ): CommandSpec { const wslExecPath = options?.wslExecPath; @@ -239,7 +242,8 @@ export function buildCodexAppServerCommand( ...buildCodexChromeMcpEnv(options?.chromeMcp), ...buildCodexAppControlsMcpEnv(options?.appControlsMcp), }; - const hasMcpEnv = Object.keys(mcpEnv).length > 0; + const env = { ...options?.env, ...mcpEnv }; + const hasEnv = Object.keys(env).length > 0; const args = [ ...(isCodexGoalsSupported(location, wslExecPath) ? ["--enable", CODEX_GOALS_FEATURE_FLAG] : []), ...userMcp.args, @@ -266,7 +270,7 @@ export function buildCodexAppServerCommand( "--", "/usr/bin/env", `PATH=${pathSegments.join(":")}`, - ...(hasMcpEnv ? Object.entries(mcpEnv).map(([name, value]) => `${name}=${value}`) : []), + ...(hasEnv ? Object.entries(env).map(([name, value]) => `${name}=${value}`) : []), wslExecPath ?? "codex", ...args, ], @@ -277,7 +281,7 @@ export function buildCodexAppServerCommand( "codex", args, resolveCodexWindowsLaunchBinary(location) ?? wslExecPath, - hasMcpEnv ? mcpEnv : undefined, + hasEnv ? env : undefined, ); } diff --git a/src/supervisor/agents/codex/codex.test.ts b/src/supervisor/agents/codex/codex.test.ts index abd72bf4b..a286e59ac 100644 --- a/src/supervisor/agents/codex/codex.test.ts +++ b/src/supervisor/agents/codex/codex.test.ts @@ -1,8 +1,13 @@ import { EventEmitter } from "node:events"; +import { mkdtempSync, rmSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join, resolve as resolvePath } from "node:path"; import { PassThrough } from "node:stream"; import { describe, expect, it, vi } from "vitest"; import { + buildCodexAppServerCommand, createCodexAdapter, + createCodexProfileAdapter, deriveCodexStructuredState, detectCodexReadyForInitialPrompt, detectCodexUpdatePrompt, @@ -16,7 +21,12 @@ import { import { CodexStructuredSession } from "./acp"; import type { CodexAppServerRpcListener } from "./appServerRpc"; import type { OscNotification, OscTitle } from "@/shared/osc"; -import type { RuntimeEvent, ToolCallPayload } from "@/shared/contracts"; +import type { + ProjectLocation, + RuntimeEvent, + ThreadConfig, + ToolCallPayload, +} from "@/shared/contracts"; import { codexIntentFor } from "./plugin/intentMap"; import { mapCodexModels, @@ -29,6 +39,101 @@ import { buildCodexTurnInput } from "./acpTurn"; import { CodexStdioTransport } from "./stdioTransport"; import { CodexSubAgentRouter } from "./subAgentRouting"; import type { StructuredSessionUpdate } from "../base"; +import { resolveCodexHomeForLocation } from "./profile"; + +describe("createCodexProfileAdapter", () => { + it("scopes launch, logout, one-shot, and skills to the selected CODEX_HOME", async () => { + const homeDir = mkdtempSync(join(tmpdir(), "poracode-codex-profile-")); + const location: ProjectLocation = { kind: "posix", path: homeDir }; + const config: ThreadConfig = { + model: "gpt-5.4", + effort: "high", + mode: "agent", + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + }; + + try { + const adapter = createCodexProfileAdapter({ + id: "work", + driver: "codex", + displayName: "Work", + config: { homeDir }, + }); + + expect(adapter.kind).toBe("codex:work"); + expect(adapter.label).toBe("Codex Work"); + expect(adapter.skillSupport?.roots[0]?.globalBasePath).toBe(homeDir); + expect(adapter.buildLaunchArgv(location, config, "hello").env).toEqual({ + CODEX_HOME: homeDir, + OPENAI_API_KEY: "", + CODEX_API_KEY: "", + CODEX_ACCESS_TOKEN: "", + }); + expect(adapter.buildOneShotCommand?.("gpt-5.4", "high", undefined, location)?.env).toEqual({ + CODEX_HOME: homeDir, + OPENAI_API_KEY: "", + CODEX_API_KEY: "", + CODEX_ACCESS_TOKEN: "", + }); + await expect(adapter.buildAcpLogoutCommand?.({ envKind: "posix" })).resolves.toMatchObject({ + env: { + CODEX_HOME: homeDir, + OPENAI_API_KEY: "", + CODEX_API_KEY: "", + CODEX_ACCESS_TOKEN: "", + }, + }); + const hookExtras = await adapter.pluginLaunchExtras?.({ envKind: "posix", baseDir: homeDir }); + expect(hookExtras?.env).toBeUndefined(); + expect(hookExtras?.args).toHaveLength(2); + expect(hookExtras?.args).not.toContain("-c"); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } + }); + + it("resolves relative profile homes against the target user home", () => { + expect( + resolveCodexHomeForLocation("profiles/work", { + kind: "posix", + path: "/tmp/project", + }), + ).toBe(resolvePath(homedir(), "profiles/work")); + }); + + it("preserves profile auth isolation in native and WSL app-server commands", () => { + const env = { + CODEX_HOME: "/home/demo/.poracode/codex-profiles/work", + OPENAI_API_KEY: "", + CODEX_API_KEY: "", + CODEX_ACCESS_TOKEN: "", + }; + const native = buildCodexAppServerCommand( + { kind: "posix", path: "/home/demo/project" }, + { env }, + ); + const wsl = buildCodexAppServerCommand( + { + kind: "wsl", + distro: "Ubuntu", + linuxPath: "/home/demo/project", + uncPath: "\\\\wsl.localhost\\Ubuntu\\home\\demo\\project", + }, + { env, wslExecPath: "/usr/bin/codex" }, + ); + + expect(native.env).toEqual(env); + expect(wsl.args).toEqual( + expect.arrayContaining([ + `CODEX_HOME=${env.CODEX_HOME}`, + "OPENAI_API_KEY=", + "CODEX_API_KEY=", + "CODEX_ACCESS_TOKEN=", + ]), + ); + }); +}); describe("deriveCodexStructuredState", () => { it("maps active approval state to needs_approval", () => { diff --git a/src/supervisor/agents/codex/detection.ts b/src/supervisor/agents/codex/detection.ts index 26dd27f5c..d412f0268 100644 --- a/src/supervisor/agents/codex/detection.ts +++ b/src/supervisor/agents/codex/detection.ts @@ -1,7 +1,7 @@ import { compactAgentProviderMetadata, - type AgentAuthMethod, type AgentCapability, + type AgentTerminalAuthMethod, } from "@/shared/contracts"; import { configFileAuthProbe, @@ -295,6 +295,7 @@ async function probeCodexStatus(ctx: Parameters): AgentTerminalAuthMethod { + return env ? { ...CODEX_TERMINAL_AUTH_METHOD, env } : CODEX_TERMINAL_AUTH_METHOD; +} + export const codexDetectionSpec: DetectionSpec = { kind: "codex", label: "Codex", @@ -360,6 +368,7 @@ export const codexDetectionSpec: DetectionSpec = { ? { wslExecPath: ctx.executablePath } : {}), timeoutMs: 12_000, + ...(ctx.probeEnv ? { env: ctx.probeEnv } : {}), label: ctx.location.kind === "wsl" ? `codex:wsl:${ctx.location.distro}` @@ -371,7 +380,7 @@ export const codexDetectionSpec: DetectionSpec = { // `buildAcpLogoutCommand` to invoke `codex logout`. Mirrors Claude. return { ...(probe ? probeResultToCapabilityPartial(probe) : {}), - authMethods: [CODEX_TERMINAL_AUTH_METHOD], + authMethods: [codexTerminalAuthMethod(ctx.probeEnv)], authLogoutSupported: true, }; }, diff --git a/src/supervisor/agents/codex/index.ts b/src/supervisor/agents/codex/index.ts index 18cf36863..de49753b8 100644 --- a/src/supervisor/agents/codex/index.ts +++ b/src/supervisor/agents/codex/index.ts @@ -1,9 +1,17 @@ -import type { AgentCapability, ProjectLocation } from "@/shared/contracts"; +import { + homeProfileKind, + parseHomeProfileInstanceConfig, + type AgentCapability, + type AgentInstanceConfig, + type ProjectLocation, +} from "@/shared/contracts"; import type { OscNotification } from "@/shared/osc"; import { batchWslCommandsAsync, brailleSpinnerOscTitleHint, + buildAgentCommand, buildAgentLogoutCommand, + configFileAuthProbe, createKnownSessionRef, detectAgentInstall, detectProbeLocation, @@ -17,11 +25,11 @@ import { resolveAgentBinaryPath } from "../binaryResolver"; import { CodexStructuredSession } from "./acp"; import { buildCodexArgvFor, codexExtraArgsPosition, primeCodexGoalsSupport } from "./argv"; import { codexDefaultCapabilities, codexDetectionSpec } from "./detection"; +import { codexHomeEnvForLocation } from "./profile"; import { detectRateLimitPrompt } from "./rateLimitPrompt"; import { resolveInstallNodePath, warnIfPluginManifestMissing } from "../plugin/installerBase"; import { codexHooksFeatureFlagForSemver, - getCodexPluginPaths, installCodexPlugin, isCodexPluginInstalled, isCodexSemverSupportedForHooks, @@ -42,6 +50,7 @@ import { resolveCodexSessionWatchPaths, } from "./session"; import type { CodexRolloutMeta } from "./sessionFiles"; +import { codexAuthPath } from "./sessionFiles"; import { detectCodexReadyForInitialPrompt } from "./terminal"; export { buildCodexAppServerCommand } from "./argv"; @@ -101,22 +110,43 @@ async function resolveCodexHooksFeatureFlag(ctx: { return codexHooksFeatureFlagForSemver(probeCodexCliSemver()); } -export function createCodexAdapter(): AgentAdapter { +interface CodexAdapterOptions { + kind?: string; + label?: string; + homeDir?: string; +} + +export function createCodexProfileAdapter(instance: AgentInstanceConfig): AgentAdapter { + const config = parseHomeProfileInstanceConfig(instance.config); + const profileLabel = instance.displayName ?? instance.id; + return createCodexAdapter({ + kind: homeProfileKind("codex", instance.id), + label: `Codex ${profileLabel}`, + homeDir: config.homeDir, + }); +} + +export function createCodexAdapter(options: CodexAdapterOptions = {}): AgentAdapter { + const kind = options.kind ?? codexDetectionSpec.kind; + const label = options.label ?? codexDetectionSpec.label; + const profileEnv = (location: ProjectLocation) => + codexHomeEnvForLocation(options.homeDir, location); let capabilities: AgentCapability = codexDefaultCapabilities; let preSpawnRolloutIds = new Set(); let preSpawnStartedAt = 0; return { - kind: codexDetectionSpec.kind, - label: codexDetectionSpec.label, + kind, + label, binary: codexDetectionSpec.binary, skillSupport: { roots: [ { id: "codex", - label: codexDetectionSpec.label, + label, globalPath: ".codex/skills", builtInPath: ".system", + ...(options.homeDir ? { globalBasePath: options.homeDir } : {}), globalOverride: { env: "CODEX_HOME", path: "skills" }, }, ], @@ -160,42 +190,60 @@ export function createCodexAdapter(): AgentAdapter { return isCodexVersionSupportedForHooks(); }, isPluginInstalled(ctx) { - return isCodexPluginInstalled(ctx); + return isCodexPluginInstalled(ctx, options.homeDir); }, async installPlugin(ctx) { const node = await resolveInstallNodePath(ctx); if (!node.ok) return node; - const result = await installCodexPlugin(ctx, { resolvedNodePath: node.nodePath }); + const result = await installCodexPlugin(ctx, { + resolvedNodePath: node.nodePath, + ...(options.homeDir ? { profileHomeDir: options.homeDir } : {}), + }); if (!result.ok) return result; return { ok: true, version: result.version }; }, async uninstallPlugin(ctx) { - uninstallCodexPlugin(ctx); + await uninstallCodexPlugin(ctx, options.homeDir); }, async pluginLaunchExtras(ctx) { - const paths = getCodexPluginPaths(ctx); const hooksFeatureFlag = await resolveCodexHooksFeatureFlag(ctx); return { args: ["--enable", hooksFeatureFlag], - env: { CODEX_HOME: paths.codexHomeDir }, }; }, handleOscNotification: codexOscHint, handleOscTitle: brailleSpinnerOscTitleHint, oscHintsDeferToHookPlugin: true, async detectInstall(ctx) { - const status = await detectAgentInstall(ctx, codexDetectionSpec); - primeCodexGoalsSupport(detectProbeLocation(ctx), status.version, status.executablePath); + const location = detectProbeLocation(ctx); + const env = profileEnv(location); + const detectionSpec = env + ? { + ...codexDetectionSpec, + kind, + label, + probeEnv: env, + authProbes: [ + configFileAuthProbe((probeLocation) => + probeLocation.kind === "wsl" ? undefined : codexAuthPath(env.CODEX_HOME), + ), + ], + } + : codexDetectionSpec; + const status = await detectAgentInstall(ctx, detectionSpec); + primeCodexGoalsSupport(location, status.version, status.executablePath); capabilities = status.capabilities; return status; }, buildLaunchArgv(location: ProjectLocation, config, prompt, sessionRef, launchOptions) { + const env = profileEnv(location); + const codexHome = env?.CODEX_HOME; preSpawnStartedAt = Date.now(); if (location.kind === "wsl") { preSpawnRolloutIds = new Set(); } else { - const sessions = readCodexSessionIndexForLocation(location); - const rollouts = readCodexRolloutsForLocation(location); + const sessions = readCodexSessionIndexForLocation(location, codexHome); + const rollouts = readCodexRolloutsForLocation(location, codexHome); preSpawnRolloutIds = new Set(rollouts.map((rollout) => rollout.id)); console.log( [ @@ -206,10 +254,17 @@ export function createCodexAdapter(): AgentAdapter { ].join("\n"), ); } - return buildCodexArgvFor(location, config, prompt, sessionRef, launchOptions); + return buildCodexArgvFor(location, config, prompt, sessionRef, launchOptions, env); }, buildResumeArgv(location, config, prompt, sessionRef, launchOptions) { - return buildCodexArgvFor(location, config, prompt, sessionRef, launchOptions); + return buildCodexArgvFor( + location, + config, + prompt, + sessionRef, + launchOptions, + profileEnv(location), + ); }, extraArgsPosition: codexExtraArgsPosition, createInitialSessionRef() { @@ -225,9 +280,20 @@ export function createCodexAdapter(): AgentAdapter { return undefined; } const wslExecPath = resolveAgentBinaryPath(input.projectLocation, "codex"); - return CodexStructuredSession.create(input, wslExecPath); + return CodexStructuredSession.create(input, wslExecPath, profileEnv(input.projectLocation)); }, - buildAcpLogoutCommand: buildAgentLogoutCommand("codex", ["logout"]), + buildAcpLogoutCommand: options.homeDir + ? async (ctx) => { + const location = detectProbeLocation(ctx); + return buildAgentCommand( + location, + "codex", + ["logout"], + resolveAgentBinaryPath(location, "codex"), + profileEnv(location), + ); + } + : buildAgentLogoutCommand("codex", ["logout"]), buildDirectInput(prompt) { return [prompt, "@wait:160", "\r"]; }, @@ -240,7 +306,7 @@ export function createCodexAdapter(): AgentAdapter { }, initialSessionRefDiscoveryDelayMs: 1000, watchSessionRef(location, onChanged) { - const paths = resolveCodexSessionWatchPaths(location); + const paths = resolveCodexSessionWatchPaths(location, profileEnv(location)?.CODEX_HOME); if (paths.length === 0) return undefined; return watchSessionPaths( location, @@ -251,9 +317,10 @@ export function createCodexAdapter(): AgentAdapter { }, async discoverSessionRef(location) { try { + const codexHome = profileEnv(location)?.CODEX_HOME; const [sessions, rollouts] = await Promise.all([ - readCodexSessionIndexForLocationAsync(location), - readCodexRolloutsForLocationAsync(location), + readCodexSessionIndexForLocationAsync(location, codexHome), + readCodexRolloutsForLocationAsync(location, codexHome), ]); const newRollouts = rollouts .filter((rollout) => !preSpawnRolloutIds.has(rollout.id)) @@ -300,7 +367,7 @@ export function createCodexAdapter(): AgentAdapter { } }, defaultOneShotModel: "gpt-5.5", - buildOneShotCommand(model, effort) { + buildOneShotCommand(model, effort, _prompt, location) { // `--skip-git-repo-check` lets `codex exec` run from worktrees or other // directories not on codex's trust list. Title generation only reads // the user's prompt from stdin and emits a short string — it never @@ -310,7 +377,8 @@ export function createCodexAdapter(): AgentAdapter { args.push("-c", `model_reasoning_effort="${effort}"`); } args.push("-"); - return { command: "codex", args }; + const env = profileEnv(location ?? detectProbeLocation(undefined)); + return { command: "codex", args, ...(env ? { env } : {}) }; }, buildContextExtractionCommand(_sessionRef, _location, _model) { return undefined; diff --git a/src/supervisor/agents/codex/plugin/install.test.ts b/src/supervisor/agents/codex/plugin/install.test.ts index 7a7017719..b7aa0f90d 100644 --- a/src/supervisor/agents/codex/plugin/install.test.ts +++ b/src/supervisor/agents/codex/plugin/install.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -23,31 +23,13 @@ import { mergeCodexHooksDocument, parseCodexVersionLine, probeCodexCliSemver, + removeManagedCodexHooksDocument, + resolveCodexHooksPath, + uninstallCodexPlugin, } from "./install"; -import { buildNativeHookCommandHead } from "../../plugin/installerBase"; - -const forwardPath = "C:\\Users\\demo\\.poracode\\agent-plugins\\codex\\forward.mjs"; -const forwardPathUnix = "/home/demo/.poracode/agent-plugins/codex/forward.mjs"; - -/** - * Test helpers build a `commandHead` matching one of the two shapes - * `mergeCodexHooksDocument` accepts: WSL (` `) - * or native (``). The merger doesn't care which shape it - * gets — it just appends ` `. - */ -function wslCommandHead(fp: string): string { - return `${JSON.stringify("/home/demo/.nvm/versions/node/v22.11.0/bin/node")} ${JSON.stringify(fp)}`; -} - -function nativeCommandHead(wrapperPath: string): string { - return buildNativeHookCommandHead(wrapperPath); -} - -function commandFor(head: string, event: string): string { - return `${head} ${event}`; -} const originalPlatform = process.platform; +const originalCodexHome = process.env.CODEX_HOME; beforeEach(() => { mockExecFileSync.mockReset(); @@ -55,17 +37,37 @@ beforeEach(() => { afterEach(() => { Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; }); describe("getCodexPluginPaths", () => { - it("places Codex hooks under Poracode's private CODEX_HOME", () => { + it("stages hook runtime assets outside CODEX_HOME", () => { const baseDir = mkdtempSync(join(tmpdir(), "poracode-codex-paths-")); const paths = getCodexPluginPaths({ envKind: "posix", baseDir }); expect(paths.pluginDir).toBe(join(baseDir, "agent-plugins", "codex")); - expect(paths.codexHomeDir).toBe(join(baseDir, "agent-plugins", "codex", "home")); - expect(paths.codexHooksPath).toBe( - join(baseDir, "agent-plugins", "codex", "home", "hooks.json"), + expect(paths.forwardPath).toBe(join(baseDir, "agent-plugins", "codex", "forward.mjs")); + expect(paths.nativeWrapperPath).toBe( + join( + baseDir, + "agent-plugins", + "codex", + process.platform === "win32" ? "poracode-hook.cmd" : "poracode-hook.sh", + ), + ); + }); + + it("uses inherited CODEX_HOME for base native hooks and profile home when selected", async () => { + const inheritedHome = mkdtempSync(join(tmpdir(), "poracode-codex-inherited-")); + const profileHome = mkdtempSync(join(tmpdir(), "poracode-codex-profile-")); + process.env.CODEX_HOME = inheritedHome; + + await expect(resolveCodexHooksPath({ envKind: "posix" })).resolves.toBe( + join(inheritedHome, "hooks.json"), + ); + await expect(resolveCodexHooksPath({ envKind: "posix" }, profileHome)).resolves.toBe( + join(profileHome, "hooks.json"), ); }); }); @@ -123,102 +125,74 @@ describe("parseCodexVersionLine + isCodexSemverSupportedForHooks", () => { }); }); -describe("mergeCodexHooksDocument", () => { - it("creates only Poracode entries when hooks.json was absent (WSL shape)", () => { - const head = wslCommandHead(forwardPath); - const doc = mergeCodexHooksDocument(null, head); - expect(Object.keys(doc.hooks)).toEqual([ - "SessionStart", - "UserPromptSubmit", - "PreToolUse", - "PostToolUse", - "PermissionRequest", - "Stop", - ]); - const stop = doc.hooks.Stop as unknown[]; - expect(stop).toHaveLength(1); - const stopHook = (stop[0] as { hooks: { command: string }[] }).hooks[0]; - expect(stopHook?.command).toBe(commandFor(head, "Stop")); - }); +describe("Codex hooks document management", () => { + const commandHead = '"C:\\Users\\demo\\.poracode\\agent-plugins\\codex\\poracode-hook.cmd"'; - it("preserves user matcher groups and appends Poracode", () => { - const head = wslCommandHead(forwardPath); - const userGroup = { - matcher: "*", - hooks: [{ type: "command", command: "node user-script.js" }], - }; - const existing = { - hooks: { - Stop: [userGroup], - SessionStart: [], - }, - }; - const doc = mergeCodexHooksDocument(existing, head); - const stop = doc.hooks.Stop as unknown[]; - expect(stop).toHaveLength(2); - expect(stop[0]).toEqual(userGroup); - const lc = (stop[1] as { hooks: { command: string }[] }).hooks[0]; - expect(lc?.command).toBe(commandFor(head, "Stop")); + it("preserves user hooks and unrelated top-level fields while adding Poracode", () => { + const userGroup = { hooks: [{ type: "command", command: "node user-hook.js" }] }; + const existing = { version: 1, hooks: { Stop: [userGroup] } }; + + const merged = mergeCodexHooksDocument(existing, commandHead); + + expect(merged.version).toBe(1); + expect(merged.hooks.Stop?.[0]).toEqual(userGroup); + expect(merged.hooks.Stop?.[1]).toEqual({ + hooks: [{ type: "command", command: `${commandHead} Stop` }], + }); + expect(merged.hooks.SessionStart?.[0]).toMatchObject({ matcher: "*" }); }); - it("prunes stale Poracode groups by forward.mjs path fingerprint and replaces", () => { - const head = wslCommandHead(forwardPath); - const stale = { - hooks: [ - { - type: "command", - command: `node "C:\\old\\.poracode\\agent-plugins\\codex\\forward.mjs" Stop`, - }, - ], - }; - const existing = { hooks: { Stop: [stale] } }; - const doc = mergeCodexHooksDocument(existing, head); - const stop = doc.hooks.Stop as unknown[]; - expect(stop).toHaveLength(1); - const h = (stop[0] as { hooks: { command: string }[] }).hooks[0]; - expect(h?.command).toBe(commandFor(head, "Stop")); + it("replaces stale managed hooks without duplicating them", () => { + const first = mergeCodexHooksDocument({}, commandHead); + const second = mergeCodexHooksDocument(first, commandHead); + + expect(second).toEqual(first); + expect(second.hooks.Stop).toHaveLength(1); }); - it("prunes legacy Lightcode groups by native wrapper fingerprint", () => { - const head = nativeCommandHead( - "C:\\Users\\demo\\.poracode\\agent-plugins\\codex\\poracode-hook.cmd", - ); - const stale = { + it("removes only managed hooks and preserves user hooks in the same group", () => { + const mixedGroup = { + matcher: "*", hooks: [ - { - type: "command", - command: `"C:\\old\\.poracode\\agent-plugins\\codex\\lightcode-hook.cmd" Stop`, - }, + { type: "command", command: "node user-hook.js" }, + { type: "command", command: `${commandHead} Stop` }, ], }; - const existing = { hooks: { Stop: [stale] } }; - const doc = mergeCodexHooksDocument(existing, head); - const stop = doc.hooks.Stop as unknown[]; - expect(stop).toHaveLength(1); - const h = (stop[0] as { hooks: { command: string }[] }).hooks[0]; - expect(h?.command).toBe(commandFor(head, "Stop")); - }); + const existing = { + version: 1, + hooks: { Stop: [mixedGroup], Custom: [{ hooks: [{ command: "custom" }] }] }, + }; - it("is idempotent when re-run with the same command head", () => { - const head = wslCommandHead(forwardPathUnix); - const first = mergeCodexHooksDocument(null, head); - const second = mergeCodexHooksDocument(first, head); - expect(second).toEqual(first); - }); + const removed = removeManagedCodexHooksDocument(existing); - it("is idempotent when re-run with the same Windows forward path", () => { - const first = mergeCodexHooksDocument(null, forwardPath); - const second = mergeCodexHooksDocument(first, forwardPath); - expect(second).toEqual(first); + expect(removed.version).toBe(1); + expect(removed.hooks.Stop).toEqual([ + { matcher: "*", hooks: [{ type: "command", command: "node user-hook.js" }] }, + ]); + expect(removed.hooks.Custom).toEqual(existing.hooks.Custom); }); - it("uses matcher only for SessionStart, PreToolUse, PostToolUse", () => { - const doc = mergeCodexHooksDocument(null, forwardPath); - expect((doc.hooks.SessionStart as { matcher?: string }[])[0]).toMatchObject({ - matcher: "*", - }); - expect((doc.hooks.UserPromptSubmit as { matcher?: string }[])[0]?.matcher).toBeUndefined(); - expect((doc.hooks.PermissionRequest as { matcher?: string }[])[0]?.matcher).toBeUndefined(); - expect((doc.hooks.Stop as { matcher?: string }[])[0]?.matcher).toBeUndefined(); + it("uninstalls managed hooks from only the selected profile home", async () => { + const baseHome = mkdtempSync(join(tmpdir(), "poracode-codex-base-hooks-")); + const profileHome = mkdtempSync(join(tmpdir(), "poracode-codex-profile-hooks-")); + process.env.CODEX_HOME = baseHome; + const baseHooks = mergeCodexHooksDocument({}, commandHead); + const profileHooks = mergeCodexHooksDocument({}, commandHead); + writeFileSync(join(baseHome, "hooks.json"), JSON.stringify(baseHooks)); + writeFileSync(join(profileHome, "hooks.json"), JSON.stringify(profileHooks)); + + try { + await uninstallCodexPlugin({ envKind: "posix" }, profileHome); + + expect(JSON.parse(readFileSync(join(baseHome, "hooks.json"), "utf8"))).toEqual(baseHooks); + expect( + JSON.parse(readFileSync(join(profileHome, "hooks.json"), "utf8")) as { + hooks: Record; + }, + ).toEqual({ hooks: {} }); + } finally { + rmSync(baseHome, { force: true, recursive: true }); + rmSync(profileHome, { force: true, recursive: true }); + } }); }); diff --git a/src/supervisor/agents/codex/plugin/install.ts b/src/supervisor/agents/codex/plugin/install.ts index b53f8c23a..386737fd7 100644 --- a/src/supervisor/agents/codex/plugin/install.ts +++ b/src/supervisor/agents/codex/plugin/install.ts @@ -1,48 +1,39 @@ import { execFileSync } from "node:child_process"; -import { - copyFileSync as fsCopyFileSync, - existsSync, - mkdirSync, - readFileSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { toWslUncPath } from "@/shared/wsl"; import type { AgentEnvContext } from "../../base"; -import { buildAgentCommand, execInWsl, quotePosixShellArg } from "../../base"; +import { buildAgentCommand, resolveWslHomeDirectoryAsync } from "../../base"; import { resolveAgentBinaryPath } from "../../binaryResolver"; import { FORWARD_RUNTIME_FILE, buildNativeHookCommandHeads, + buildWslHookCommandHead, copyForwardRuntimeFile, copyPluginAssetsIfStale, createPluginSourceResolver, - ctxCacheKey, - ensureNativeStateLink, + getNativeHookWrapperFilename, getNativePluginBaseDir, getWslPluginBaseDirs, hasNativeHookWrapper, isWslPluginContext, - memoByCtx, parseExistingHooksJson, readBundledPluginVersion, readPluginManifest, - removeStagedPluginDir, stagePluginAssetsToWsl, writeHooksJsonFile, writeNativeHookWrapper, type PluginManifest, } from "../../plugin/installerBase"; +import { resolveCodexHomeFromBase } from "../profile"; import { resolveCodexNativeExecutableForWindows } from "../windowsExecutable"; export interface CodexPluginPaths { pluginDir: string; - /** Private CODEX_HOME used only for Codex processes spawned by Poracode. */ - codexHomeDir: string; - /** Path to hooks.json inside the private CODEX_HOME. */ - codexHooksPath: string; + forwardPath: string; + nativeWrapperPath?: string; version: string; } @@ -55,12 +46,6 @@ const CODEX_HOOK_EVENTS = [ "Stop", ] as const; -/** - * Match any Poracode-staged Codex hook command in hooks.json. Covers both - * the WSL shape (where `forward.mjs` is invoked directly via an absolute - * node path) and the native shape (where `poracode-hook.{sh,cmd,ps1}` is the - * entry point). - */ const PORACODE_FORWARD_RE = /agent-plugins(?:[/\\]+)codex(?:[/\\]+)(?:forward\.mjs|poracode-hook\.(?:sh|cmd|ps1))/; const MANAGED_FORWARD_RE = @@ -85,9 +70,8 @@ function computeCodexPluginPaths(ctx?: AgentEnvContext): CodexPluginPaths { if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "codex"); if (!wsl) { - return { pluginDir: "", codexHomeDir: "", codexHooksPath: "", version: "0.0.0" }; + return { pluginDir: "", forwardPath: "", version: "0.0.0" }; } - const linuxCodexHome = `${wsl.linuxBase}/home`; let version = "0.0.0"; try { version = readPluginManifest(wsl.uncBase).version; @@ -96,13 +80,11 @@ function computeCodexPluginPaths(ctx?: AgentEnvContext): CodexPluginPaths { } return { pluginDir: wsl.linuxBase, - codexHomeDir: linuxCodexHome, - codexHooksPath: `${linuxCodexHome}/hooks.json`, + forwardPath: `${wsl.linuxBase}/forward.mjs`, version, }; } const pluginDir = getNativePluginBaseDir("codex", ctx?.baseDir); - const codexHomeDir = join(pluginDir, "home"); let version = "0.0.0"; try { version = readPluginManifest(pluginDir).version; @@ -111,147 +93,145 @@ function computeCodexPluginPaths(ctx?: AgentEnvContext): CodexPluginPaths { } return { pluginDir, - codexHomeDir, - codexHooksPath: join(codexHomeDir, "hooks.json"), + forwardPath: join(pluginDir, "forward.mjs"), + nativeWrapperPath: join(pluginDir, getNativeHookWrapperFilename()), version, }; } -const codexPluginPathsMemo = memoByCtx(computeCodexPluginPaths, ctxCacheKey); - export function getCodexPluginPaths(ctx?: AgentEnvContext): CodexPluginPaths { - return codexPluginPathsMemo.call(ctx); + return computeCodexPluginPaths(ctx); } -function prunePoracodeGroups(groups: unknown): unknown[] { +function recordOrEmpty(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? { ...(value as Record) } + : {}; +} + +function pruneManagedHookGroups(groups: unknown): unknown[] { if (!Array.isArray(groups)) return []; - return groups.filter((g) => { - if (!g || typeof g !== "object") return true; - const rec = g as { hooks?: unknown }; - const hooks = rec.hooks; - if (!Array.isArray(hooks)) return true; - return !hooks.some((h) => { - if (!h || typeof h !== "object") return false; - const cmd = (h as { type?: string; command?: string }).command; - return typeof cmd === "string" && MANAGED_FORWARD_RE.test(cmd); + return groups.flatMap((group) => { + if (!group || typeof group !== "object" || Array.isArray(group)) return [group]; + const hooks = (group as { hooks?: unknown }).hooks; + if (!Array.isArray(hooks)) return [group]; + const retained = hooks.filter((hook) => { + if (!hook || typeof hook !== "object") return true; + const command = (hook as { command?: unknown }).command; + return typeof command !== "string" || !MANAGED_FORWARD_RE.test(command); }); + if (retained.length === hooks.length) return [group]; + return retained.length > 0 ? [{ ...(group as Record), hooks: retained }] : []; }); } -function commandForEvent(commandHead: string, event: string): string { - return `${commandHead} ${event}`; +function buildPoracodeHookGroup(event: string, commandHead: string): Record { + const hook = { type: "command", command: `${commandHead} ${event}` }; + return event === "SessionStart" || event === "PreToolUse" || event === "PostToolUse" + ? { matcher: "*", hooks: [hook] } + : { hooks: [hook] }; } -function buildPoracodeGroup(event: string, commandHead: string): Record { - const command = commandForEvent(commandHead, event); - const hook = { type: "command", command }; - if (event === "SessionStart" || event === "PreToolUse" || event === "PostToolUse") { - return { matcher: "*", hooks: [hook] }; - } - return { hooks: [hook] }; +export interface CodexHooksDocument extends Record { + hooks: Record; } -/** - * Merge Poracode Codex hook matcher groups into a parsed `hooks.json` - * document. `commandHead` is the entire pre-event portion of each hook - * command — for WSL it's `"" ""`; - * for native it's just `""`. Exported for unit tests. - */ export function mergeCodexHooksDocument( existingParsed: unknown, commandHead: string, -): { hooks: Record } { - let hooksRoot: Record = {}; - if ( - existingParsed && - typeof existingParsed === "object" && - "hooks" in existingParsed && - (existingParsed as { hooks: unknown }).hooks && - typeof (existingParsed as { hooks: unknown }).hooks === "object" - ) { - hooksRoot = { ...(existingParsed as { hooks: Record }).hooks }; +): CodexHooksDocument { + const document = recordOrEmpty(existingParsed); + const hooksRoot = recordOrEmpty(document.hooks); + for (const event of CODEX_HOOK_EVENTS) { + hooksRoot[event] = [ + ...pruneManagedHookGroups(hooksRoot[event]), + buildPoracodeHookGroup(event, commandHead), + ]; } + return { ...document, hooks: hooksRoot as Record }; +} +export function removeManagedCodexHooksDocument(existingParsed: unknown): CodexHooksDocument { + const document = recordOrEmpty(existingParsed); + const hooksRoot = recordOrEmpty(document.hooks); for (const event of CODEX_HOOK_EVENTS) { - const prev = hooksRoot[event]; - const pruned = prunePoracodeGroups(prev); - pruned.push(buildPoracodeGroup(event, commandHead)); - hooksRoot[event] = pruned; + if (!Array.isArray(hooksRoot[event])) continue; + const retained = pruneManagedHookGroups(hooksRoot[event]); + if (retained.length > 0) hooksRoot[event] = retained; + else delete hooksRoot[event]; } + return { ...document, hooks: hooksRoot as Record }; +} - return { hooks: hooksRoot as Record }; +interface ResolvedCodexHome { + runtimePath: string; + readablePath: string; } -const CODEX_LINK_TARGETS = [ - { name: "sessions", kind: "dir" as const }, - { name: "session_index.jsonl", kind: "file" as const }, - { name: "auth.json", kind: "file" as const }, - { name: "config.toml", kind: "file" as const }, -]; - -function seedNativeCodexHome(codexHomeDir: string): void { - mkdirSync(codexHomeDir, { recursive: true }); - const globalCodexHome = join(homedir(), ".codex"); - mkdirSync(join(globalCodexHome, "sessions"), { recursive: true }); - if (!existsSync(join(globalCodexHome, "session_index.jsonl"))) { - writeFileSync(join(globalCodexHome, "session_index.jsonl"), "", { flag: "a" }); - } - restorePrivateStateFile(codexHomeDir, globalCodexHome, "auth.json"); - restorePrivateStateFile(codexHomeDir, globalCodexHome, "config.toml"); +function resolveNativeCodexHome(rawHomeDir?: string): ResolvedCodexHome { + const nativeHome = homedir(); + const configuredHome = rawHomeDir ?? (process.env.CODEX_HOME?.trim() || undefined); + const runtimePath = resolveCodexHomeFromBase(configuredHome, nativeHome, "native"); + return { runtimePath, readablePath: runtimePath }; +} - for (const { name, kind } of CODEX_LINK_TARGETS) { - ensureNativeStateLink(join(globalCodexHome, name), join(codexHomeDir, name), kind); +function resolveWslCodexHome( + distro: string, + wslHome: string, + rawHomeDir?: string, +): ResolvedCodexHome { + const runtimePath = resolveCodexHomeFromBase(rawHomeDir, wslHome, "posix"); + return { runtimePath, readablePath: toWslUncPath(distro, runtimePath) }; +} + +async function resolveCodexHome( + ctx: AgentEnvContext | undefined, + rawHomeDir?: string, + knownWslHome?: string, +): Promise { + if (!isWslPluginContext(ctx)) return resolveNativeCodexHome(rawHomeDir); + const wslHome = knownWslHome ?? (await resolveWslHomeDirectoryAsync(ctx.wslDistro)); + if (!wslHome) { + throw new Error(`Unable to resolve the WSL home directory for ${ctx.wslDistro}.`); } + return resolveWslCodexHome(ctx.wslDistro, wslHome, rawHomeDir); } -function restorePrivateStateFile( - codexHomeDir: string, - globalCodexHome: string, - file: "auth.json" | "config.toml", -): void { - const source = join(codexHomeDir, file); - const target = join(globalCodexHome, file); - if (existsSync(target) || !existsSync(source)) return; - try { - fsCopyFileSync(source, target); - } catch { - // Best-effort recovery for Windows when file symlinks were unavailable. +export async function resolveCodexHooksPath( + ctx?: AgentEnvContext, + profileHomeDir?: string, +): Promise { + const home = await resolveCodexHome(ctx, profileHomeDir); + return join(home.readablePath, "hooks.json"); +} + +function parseHooksDocument(hooksPath: string): unknown { + const existing = parseExistingHooksJson(hooksPath); + if (existing === null && existsSync(hooksPath)) { + throw new Error(`Malformed Codex hooks file: ${hooksPath}`); } + return existing; } -async function seedWslCodexHome( - distro: string, - home: string, - linuxCodexHome: string, -): Promise { - const uncCodexHome = toWslUncPath(distro, linuxCodexHome); - mkdirSync(uncCodexHome, { recursive: true }); - const globalCodexHome = `${home}/.codex`; - const linkExists = (path: string) => - `[ -e ${quotePosixShellArg(path)} ] || [ -L ${quotePosixShellArg(path)} ]`; - // ln -s can fail on Windows-mounted filesystems (9p / DrvFs). For files, - // fall back to hardlink, then copy. Dirs only get the symlink attempt. - const linkLine = (name: string, kind: "dir" | "file") => { - const target = quotePosixShellArg(`${linuxCodexHome}/${name}`); - const source = quotePosixShellArg(`${globalCodexHome}/${name}`); - const attempts = [ - linkExists(`${linuxCodexHome}/${name}`), - `ln -s ${source} ${target}`, - ...(kind === "file" ? [`ln ${source} ${target}`, `cp ${source} ${target}`] : []), - ]; - return attempts.join(" || "); - }; - const script = [ - [ - "mkdir -p", - quotePosixShellArg(linuxCodexHome), - quotePosixShellArg(`${globalCodexHome}/sessions`), - ].join(" "), - `touch ${quotePosixShellArg(`${globalCodexHome}/session_index.jsonl`)}`, - ...CODEX_LINK_TARGETS.map(({ name, kind }) => linkLine(name, kind)), - ].join("\n"); - await execInWsl(distro, "/", "sh", ["-lc", script], { timeout: 15_000 }).catch((error) => { - console.warn(`[codex] WSL plugin install failed for distro ${distro}:`, error); +function hasManagedHookForEveryEvent(existingParsed: unknown): boolean { + const hooksRoot = recordOrEmpty(recordOrEmpty(existingParsed).hooks); + return CODEX_HOOK_EVENTS.every((event) => { + const groups = hooksRoot[event]; + return ( + Array.isArray(groups) && + groups.some((group) => { + if (!group || typeof group !== "object") return false; + const hooks = (group as { hooks?: unknown }).hooks; + return ( + Array.isArray(hooks) && + hooks.some((hook) => { + if (!hook || typeof hook !== "object") return false; + const command = (hook as { command?: unknown }).command; + return typeof command === "string" && PORACODE_FORWARD_RE.test(command); + }) + ); + }) + ); }); } @@ -333,6 +313,8 @@ export interface InstallCodexPluginOptions { * `ELECTRON_RUN_AS_NODE=1` against the bundled Electron binary. */ resolvedNodePath?: string | undefined; + /** Selected profile home. Omit for the base Codex home. */ + profileHomeDir?: string | undefined; } export async function installCodexPlugin( @@ -361,109 +343,81 @@ export async function installCodexPlugin( "WSL Codex plugin install requires a resolved node path; the adapter must call resolveNodeForDistro before installing.", }; } - return installCodexPluginWsl(ctx.wslDistro, sourceDir, manifest, options.resolvedNodePath); + return installCodexPluginWsl( + ctx, + sourceDir, + manifest, + options.resolvedNodePath, + options.profileHomeDir, + ); } - const pluginDir = getNativePluginBaseDir("codex", ctx?.baseDir); - const codexHomeDir = join(pluginDir, "home"); + const paths = getCodexPluginPaths(ctx); + const { pluginDir } = paths; mkdirSync(pluginDir, { recursive: true }); - seedNativeCodexHome(codexHomeDir); copyPluginAssetsIfStale(sourceDir, pluginDir); copyForwardRuntimeFile(pluginDir); const wrapperPath = writeNativeHookWrapper(pluginDir, { ...(options?.resolvedNodePath ? { nodePath: options.resolvedNodePath } : {}), }); - const hooksPath = join(codexHomeDir, "hooks.json"); - const existing = parseExistingHooksJson(hooksPath); - if (existing === null && existsSync(hooksPath)) { - return { ok: false, reason: "malformed private Codex hooks.json (invalid JSON)" }; - } - - // Native command shape: ` `. The wrapper sets - // ELECTRON_RUN_AS_NODE=1 and execs the bundled Electron Node on - // forward.mjs (which lives next to the wrapper). - const commandHead = buildNativeHookCommandHeads(wrapperPath).command; - try { - const merged = mergeCodexHooksDocument(existing, commandHead); + const home = await resolveCodexHome(ctx, options?.profileHomeDir); + const hooksPath = join(home.readablePath, "hooks.json"); + const merged = mergeCodexHooksDocument( + parseHooksDocument(hooksPath), + buildNativeHookCommandHeads(wrapperPath).command, + ); writeHooksJsonFile(hooksPath, merged); } catch (error) { - return { - ok: false, - reason: `failed to write private Codex hooks.json: ${ - error instanceof Error ? error.message : String(error) - }`, - }; + return { ok: false, reason: error instanceof Error ? error.message : String(error) }; } console.log( [ `[supervisor] Codex hook plugin staged v${manifest.version}`, ` pluginDir: ${pluginDir}`, - ` CODEX_HOME: ${codexHomeDir}`, ].join("\n"), ); return { ok: true, version: manifest.version, - paths: { - pluginDir, - codexHomeDir, - codexHooksPath: hooksPath, - version: manifest.version, - }, + paths: { ...paths, version: manifest.version }, }; } async function installCodexPluginWsl( - distro: string, + ctx: AgentEnvContext & { envKind: "wsl"; wslDistro: string }, sourceDir: string, manifest: PluginManifest, resolvedNodePath: string, + profileHomeDir?: string, ): Promise<{ ok: true; paths: CodexPluginPaths; version: string } | { ok: false; reason: string }> { - const staged = stagePluginAssetsToWsl(distro, sourceDir, "codex", { + const staged = stagePluginAssetsToWsl(ctx.wslDistro, sourceDir, "codex", { includeForwardRuntime: true, }); if (!staged.ok) return staged; - const linuxForward = `${staged.linuxPluginDir}/forward.mjs`; - const linuxCodexHome = `${staged.linuxPluginDir}/home`; - await seedWslCodexHome(distro, staged.deploy.home, linuxCodexHome); - const linuxHooksPath = `${linuxCodexHome}/hooks.json`; - const uncHooks = toWslUncPath(distro, linuxHooksPath); - - const existing = parseExistingHooksJson(uncHooks); - if (existing === null && existsSync(uncHooks)) { - return { - ok: false, - reason: `malformed private Codex hooks.json in wsl distro ${distro}`, - }; - } - - // WSL command shape: `"" "" `. - // /bin/sh -c never has to resolve `node` from PATH because both are - // absolute paths. - const commandHead = `${JSON.stringify(resolvedNodePath)} ${JSON.stringify(linuxForward)}`; - try { - const merged = mergeCodexHooksDocument(existing, commandHead); - writeHooksJsonFile(uncHooks, merged); + const home = await resolveCodexHome(ctx, profileHomeDir, staged.deploy.home); + const hooksPath = join(home.readablePath, "hooks.json"); + const commandHead = buildWslHookCommandHead( + resolvedNodePath, + `${staged.linuxPluginDir}/forward.mjs`, + ); + writeHooksJsonFile( + hooksPath, + mergeCodexHooksDocument(parseHooksDocument(hooksPath), commandHead), + ); } catch (error) { - return { - ok: false, - reason: `failed to write hooks.json in wsl distro ${distro}: ${ - error instanceof Error ? error.message : String(error) - }`, - }; + return { ok: false, reason: error instanceof Error ? error.message : String(error) }; } console.log( [ - `[supervisor] Codex hook plugin staged v${manifest.version} (wsl:${distro})`, + `[supervisor] Codex hook plugin staged v${manifest.version} (wsl:${ctx.wslDistro})`, ` pluginDir: ${staged.linuxPluginDir}`, - ` CODEX_HOME: ${linuxCodexHome}`, ].join("\n"), ); @@ -472,63 +426,50 @@ async function installCodexPluginWsl( version: manifest.version, paths: { pluginDir: staged.linuxPluginDir, - codexHomeDir: linuxCodexHome, - codexHooksPath: linuxHooksPath, + forwardPath: `${staged.linuxPluginDir}/forward.mjs`, version: manifest.version, }, }; } -export function isCodexPluginInstalled( +export async function isCodexPluginInstalled( ctx?: AgentEnvContext, + profileHomeDir?: string, ): Promise<{ installed: boolean; version?: string }> { + let hooksPath: string; + try { + hooksPath = await resolveCodexHooksPath(ctx, profileHomeDir); + } catch { + return { installed: false }; + } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "codex"); - if (!wsl) return Promise.resolve({ installed: false }); - return Promise.resolve(verifyCodexInstallAt(wsl.uncBase, "wsl")); + if (!wsl) return { installed: false }; + return verifyCodexInstallAt(wsl.uncBase, hooksPath, "wsl"); } - return Promise.resolve( - verifyCodexInstallAt(getNativePluginBaseDir("codex", ctx?.baseDir), "native"), - ); + return verifyCodexInstallAt(getNativePluginBaseDir("codex", ctx?.baseDir), hooksPath, "native"); } -export function uninstallCodexPlugin(ctx?: AgentEnvContext): void { - removeStagedPluginDir("codex", ctx); +export async function uninstallCodexPlugin( + ctx?: AgentEnvContext, + profileHomeDir?: string, +): Promise { + const hooksPath = await resolveCodexHooksPath(ctx, profileHomeDir); + if (!existsSync(hooksPath)) return; + writeHooksJsonFile(hooksPath, removeManagedCodexHooksDocument(parseHooksDocument(hooksPath))); } function verifyCodexInstallAt( readableDir: string, + hooksPath: string, target: "native" | "wsl", ): { installed: boolean; version?: string } { - const hooksPath = join(readableDir, "home", "hooks.json"); if (!existsSync(join(readableDir, "plugin.json"))) return { installed: false }; if (!existsSync(join(readableDir, "forward.mjs"))) return { installed: false }; if (!existsSync(join(readableDir, FORWARD_RUNTIME_FILE))) return { installed: false }; if (!hasNativeHookWrapper(readableDir, target)) return { installed: false }; - if (!existsSync(hooksPath)) return { installed: false }; + if (!hasManagedHookForEveryEvent(parseExistingHooksJson(hooksPath))) return { installed: false }; try { - const raw = readFileSync(hooksPath, "utf8"); - const doc = JSON.parse(raw) as { hooks?: Record }; - if (!doc.hooks) return { installed: false }; - let found = false; - for (const event of CODEX_HOOK_EVENTS) { - const groups = doc.hooks[event]; - if (!Array.isArray(groups)) continue; - for (const g of groups) { - if (!g || typeof g !== "object") continue; - const hooks = (g as { hooks?: unknown }).hooks; - if (!Array.isArray(hooks)) continue; - for (const h of hooks) { - if (!h || typeof h !== "object") continue; - const cmd = (h as { command?: string }).command; - if (typeof cmd === "string" && PORACODE_FORWARD_RE.test(cmd)) { - found = true; - break; - } - } - } - } - if (!found) return { installed: false }; const version = readPluginManifest(readableDir).version; return { installed: true, version }; } catch { diff --git a/src/supervisor/agents/codex/probe.ts b/src/supervisor/agents/codex/probe.ts index b205ef7f0..c18ccc65e 100644 --- a/src/supervisor/agents/codex/probe.ts +++ b/src/supervisor/agents/codex/probe.ts @@ -421,6 +421,7 @@ interface RunWithCodexAppServerOptions { wslExecPath?: string; timeoutMs?: number; label?: string; + env?: Record; } /** @@ -445,6 +446,7 @@ async function runWithCodexAppServer( const cmd = buildCodexAppServerCommand(location, { ...(options?.wslExecPath !== undefined ? { wslExecPath: options.wslExecPath } : {}), ...(wslNodePath !== undefined ? { wslNodePath } : {}), + ...(options?.env ? { env: options.env } : {}), }); const spawnCwd = resolveProbeSpawnCwd(location, cmd.cwd); @@ -539,7 +541,7 @@ export async function probeCodexAccount( */ export async function probeCodexCapabilities( location: ProjectLocation, - options?: { wslExecPath?: string; timeoutMs?: number; label?: string }, + options?: RunWithCodexAppServerOptions, ): Promise { const result = await runWithCodexAppServer(location, options, async ({ client, initResult }) => { const [modelResult, requirementsResult, skillsResult] = await Promise.all([ diff --git a/src/supervisor/agents/codex/profile.ts b/src/supervisor/agents/codex/profile.ts new file mode 100644 index 000000000..9b8766c0d --- /dev/null +++ b/src/supervisor/agents/codex/profile.ts @@ -0,0 +1,56 @@ +import { mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import posixPath from "node:path/posix"; +import type { ProjectLocation } from "@/shared/contracts"; +import { toWslUncPath } from "@/shared/wsl"; +import { resolveWslHomeDirectory } from "../base"; + +export function resolveCodexHomeFromBase( + rawHomeDir: string | undefined, + baseHome: string, + pathKind: "native" | "posix", +): string { + const trimmed = rawHomeDir?.trim(); + if (rawHomeDir !== undefined && !trimmed) throw new Error("Codex profile home is empty."); + const paths = pathKind === "posix" ? posixPath : path; + if (!trimmed) return paths.join(baseHome, ".codex"); + if (trimmed === "~" || trimmed.startsWith("~/")) { + return paths.join(baseHome, trimmed === "~" ? "" : trimmed.slice(2)); + } + return paths.isAbsolute(trimmed) ? paths.normalize(trimmed) : paths.resolve(baseHome, trimmed); +} + +export function resolveCodexHomeForLocation(rawHomeDir: string, location: ProjectLocation): string { + if (location.kind === "wsl") { + const home = resolveWslHomeDirectory(location.distro); + if (!home) { + throw new Error(`Unable to resolve the WSL home directory for ${location.distro}.`); + } + return resolveCodexHomeFromBase(rawHomeDir, home, "posix"); + } + return resolveCodexHomeFromBase(rawHomeDir, homedir(), "native"); +} + +/** Ensure Codex's custom home exists before invoking the CLI. */ +export function ensureCodexHomeForLocation(rawHomeDir: string, location: ProjectLocation): string { + const homeDir = resolveCodexHomeForLocation(rawHomeDir, location); + const fsHome = location.kind === "wsl" ? toWslUncPath(location.distro, homeDir) : homeDir; + mkdirSync(path.join(fsHome, "sessions"), { recursive: true }); + return homeDir; +} + +export function codexHomeEnvForLocation( + rawHomeDir: string | undefined, + location: ProjectLocation, +): Record | undefined { + if (!rawHomeDir) return undefined; + return { + CODEX_HOME: ensureCodexHomeForLocation(rawHomeDir, location), + // Account profiles must authenticate from their own CODEX_HOME. Empty + // values deliberately shadow host-level credentials inherited by spawn. + OPENAI_API_KEY: "", + CODEX_API_KEY: "", + CODEX_ACCESS_TOKEN: "", + }; +} diff --git a/src/supervisor/agents/codex/session.test.ts b/src/supervisor/agents/codex/session.test.ts new file mode 100644 index 000000000..e170c08b9 --- /dev/null +++ b/src/supervisor/agents/codex/session.test.ts @@ -0,0 +1,45 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { ProjectLocation } from "@/shared/contracts"; +import { + readCodexRolloutsForLocation, + readCodexSessionIndexForLocation, + resolveCodexSessionWatchPaths, +} from "./session"; + +describe("Codex profile session discovery", () => { + it("reads and watches only the selected CODEX_HOME", () => { + const homeDir = mkdtempSync(join(tmpdir(), "poracode-codex-session-profile-")); + const sessionsDir = join(homeDir, "sessions", "2026", "07", "15"); + const projectPath = join(homeDir, "project"); + const location: ProjectLocation = { kind: "posix", path: projectPath }; + const id = "019f-profile-session"; + + try { + mkdirSync(sessionsDir, { recursive: true }); + writeFileSync( + join(homeDir, "session_index.jsonl"), + `${JSON.stringify({ id, updated_at: "2026-07-15T01:02:03.000Z", thread_name: "Work" })}\n`, + ); + writeFileSync( + join(sessionsDir, `rollout-2026-07-15T01-02-03-${id}.jsonl`), + `${JSON.stringify({ + type: "session_meta", + payload: { id, cwd: projectPath, originator: "codex-tui", source: "cli" }, + })}\n`, + ); + + expect(readCodexSessionIndexForLocation(location, homeDir)).toEqual([ + { id, updatedAt: Date.parse("2026-07-15T01:02:03.000Z"), threadName: "Work" }, + ]); + expect(readCodexRolloutsForLocation(location, homeDir).map((rollout) => rollout.id)).toEqual([ + id, + ]); + expect(resolveCodexSessionWatchPaths(location, homeDir)).toEqual([join(homeDir, "sessions")]); + } finally { + rmSync(homeDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/supervisor/agents/codex/session.ts b/src/supervisor/agents/codex/session.ts index 5a4376b93..65891f803 100644 --- a/src/supervisor/agents/codex/session.ts +++ b/src/supervisor/agents/codex/session.ts @@ -66,11 +66,19 @@ export function describeCodexLocation(location: ProjectLocation): string { } } -export function readCodexSessionIndexForLocation(location: ProjectLocation) { +export function readCodexSessionIndexForLocation(location: ProjectLocation, codexHome?: string) { if (location.kind === "wsl") { return []; } + if (codexHome) { + try { + return parseCodexSessionIndex(readFileSync(join(codexHome, "session_index.jsonl"), "utf8")); + } catch { + return []; + } + } + const sessions = readCodexSessionIndex(); const privateIndexPath = join(nativePrivateCodexHome(), "session_index.jsonl"); let privateRaw: string; @@ -90,16 +98,19 @@ export function readCodexSessionIndexForLocation(location: ProjectLocation) { */ export async function readCodexSessionIndexForLocationAsync( location: ProjectLocation, + codexHome?: string, ): Promise> { if (location.kind !== "wsl") { - return readCodexSessionIndexForLocation(location); + return readCodexSessionIndexForLocation(location, codexHome); } const home = await resolveWslHomeDirectoryAsync(location.distro); const privateHome = codexPrivateHomeFrom(home); - const paths = [ - home ? `${home}/.codex/session_index.jsonl` : undefined, - privateHome ? `${privateHome}/session_index.jsonl` : undefined, - ].filter((p): p is string => Boolean(p)); + const paths = codexHome + ? [`${codexHome}/session_index.jsonl`] + : [ + home ? `${home}/.codex/session_index.jsonl` : undefined, + privateHome ? `${privateHome}/session_index.jsonl` : undefined, + ].filter((p): p is string => Boolean(p)); const reads = await Promise.all(paths.map((p) => readSessionFileText(location, p))); const parts = reads.filter((r): r is string => typeof r === "string" && r.length > 0); if (parts.length === 0) return []; @@ -129,7 +140,10 @@ export function isInteractiveCodexRollout( } } -export function readCodexRolloutsForLocation(location: ProjectLocation): CodexRolloutMeta[] { +export function readCodexRolloutsForLocation( + location: ProjectLocation, + codexHome?: string, +): CodexRolloutMeta[] { if (location.kind === "wsl") { return []; } @@ -173,7 +187,7 @@ export function readCodexRolloutsForLocation(location: ProjectLocation): CodexRo } } }; - for (const home of nativeCodexHomeCandidates()) { + for (const home of codexHome ? [codexHome] : nativeCodexHomeCandidates()) { walk(join(home, "sessions")); } return dedupeRollouts(rollouts); @@ -210,16 +224,19 @@ export async function readCodexRolloutMetaForLocationAsync( export async function readCodexRolloutsForLocationAsync( location: ProjectLocation, + codexHome?: string, ): Promise { if (location.kind !== "wsl") { - return readCodexRolloutsForLocation(location); + return readCodexRolloutsForLocation(location, codexHome); } const home = await resolveWslHomeDirectoryAsync(location.distro); const privateHome = codexPrivateHomeFrom(home); - const roots = [ - home ? `${home}/.codex/sessions` : undefined, - privateHome ? `${privateHome}/sessions` : undefined, - ].filter((r): r is string => Boolean(r)); + const roots = codexHome + ? [`${codexHome}/sessions`] + : [ + home ? `${home}/.codex/sessions` : undefined, + privateHome ? `${privateHome}/sessions` : undefined, + ].filter((r): r is string => Boolean(r)); const accept = (name: string): boolean => name.startsWith("rollout-") && name.endsWith(".jsonl"); const found = ( @@ -249,7 +266,15 @@ export async function readCodexRolloutsForLocationAsync( * paths for windows/posix; Linux paths inside the distro for WSL (consumed * by the in-distro bridge watch subscription, NOT UNC `\\wsl.localhost\…`). */ -export function resolveCodexSessionWatchPaths(location: ProjectLocation): string[] { +export function resolveCodexSessionWatchPaths( + location: ProjectLocation, + codexHome?: string, +): string[] { + if (codexHome) { + const sessions = + location.kind === "wsl" ? `${codexHome}/sessions` : join(codexHome, "sessions"); + return location.kind === "wsl" || existsSync(sessions) ? [sessions] : []; + } if (location.kind === "wsl") { const home = getCachedWslHomeDirectory(location.distro); const privateHome = wslPrivateCodexHome(location.distro); diff --git a/src/supervisor/agents/codex/sessionFiles.ts b/src/supervisor/agents/codex/sessionFiles.ts index 8b7dcaa74..3afc459f4 100644 --- a/src/supervisor/agents/codex/sessionFiles.ts +++ b/src/supervisor/agents/codex/sessionFiles.ts @@ -104,6 +104,6 @@ export function parseCodexRolloutMeta( } } -export function codexAuthPath(): string { - return join(homedir(), ".codex", "auth.json"); +export function codexAuthPath(codexHome = join(homedir(), ".codex")): string { + return join(codexHome, "auth.json"); } diff --git a/src/supervisor/agents/copilot/copilot.test.ts b/src/supervisor/agents/copilot/copilot.test.ts index 7610791d9..8daca51ab 100644 --- a/src/supervisor/agents/copilot/copilot.test.ts +++ b/src/supervisor/agents/copilot/copilot.test.ts @@ -1,18 +1,98 @@ import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import type { McpServer } from "@/shared/contracts"; +import type { McpServer, ProjectLocation } from "@/shared/contracts"; import type { OscNotification, OscShellEvent } from "@/shared/osc"; import { buildCopilotArgs } from "./argv"; import { copilotDetectionSpec } from "./detection"; import { buildCopilotMcpLaunchConfig } from "./mcp"; import { createCopilotAdapter, + createCopilotProfileAdapter, detectCopilotInvalidSessionRef, detectCopilotModelEffort, detectCopilotStatusLineModel, detectCopilotTerminalStatus, } from "./index"; +describe("createCopilotProfileAdapter", () => { + const location: ProjectLocation = { kind: "posix", path: "/repo" }; + + it("routes terminal, ACP, one-shot, context, and skills through COPILOT_HOME", async () => { + const adapter = createCopilotProfileAdapter({ + id: "work", + driver: "copilot", + displayName: "Work", + config: { homeDir: "/profiles/copilot" }, + environment: { + GH_TOKEN: { value: "profile-token", sensitive: true }, + COPILOT_HOME: { value: "/ignored" }, + }, + }); + + expect(adapter.kind).toBe("copilot:work"); + expect(adapter.label).toBe("GitHub Copilot Work"); + expect(adapter.skillSupport?.roots[0]?.globalBasePath).toBe("/profiles/copilot"); + expect(adapter.buildLaunchArgv(location, { model: "gpt-5" }, "hello").env).toMatchObject({ + COPILOT_HOME: "/profiles/copilot", + GH_TOKEN: "profile-token", + GITHUB_TOKEN: "", + GH_ENTERPRISE_TOKEN: "", + GITHUB_ENTERPRISE_TOKEN: "", + COPILOT_GITHUB_TOKEN: "", + COPILOT_API_TOKEN: "", + }); + expect(adapter.buildOneShotCommand?.("gpt-5", undefined, "title", location)?.env).toMatchObject( + { + COPILOT_HOME: "/profiles/copilot", + }, + ); + expect( + adapter.buildContextExtractionCommand?.( + { providerSessionId: "session", discoveredAt: "test" }, + location, + )?.env, + ).toMatchObject({ COPILOT_HOME: "/profiles/copilot" }); + + const nativeAuth = await adapter.buildAcpAuthCommand?.({ envKind: "posix" }); + expect(nativeAuth?.env).toMatchObject({ + COPILOT_HOME: "/profiles/copilot", + GH_TOKEN: "profile-token", + GITHUB_TOKEN: "", + GH_ENTERPRISE_TOKEN: "", + GITHUB_ENTERPRISE_TOKEN: "", + COPILOT_GITHUB_TOKEN: "", + COPILOT_API_TOKEN: "", + }); + + const wslAuth = await adapter.buildAcpAuthCommand?.({ envKind: "wsl", wslDistro: "Ubuntu" }); + const wslScript = wslAuth?.args.join(" ") ?? ""; + expect(wslScript).toContain("export COPILOT_HOME='/profiles/copilot'"); + expect(wslScript).toContain("export GH_TOKEN='profile-token'"); + expect(wslScript).toContain("export GITHUB_TOKEN=''"); + expect(wslScript).toContain("export GH_ENTERPRISE_TOKEN=''"); + expect(wslScript).toContain("export GITHUB_ENTERPRISE_TOKEN=''"); + expect(wslScript).toContain("export COPILOT_GITHUB_TOKEN=''"); + expect(wslScript).toContain("export COPILOT_API_TOKEN=''"); + expect( + createCopilotAdapter().buildOneShotCommand?.("gpt-5", undefined, "title", location)?.env, + ).toBeUndefined(); + }); + + it("resolves a relative profile home against the target user home", () => { + const adapter = createCopilotProfileAdapter({ + id: "relative", + driver: "copilot", + config: { homeDir: "profiles/copilot" }, + }); + + expect(adapter.buildLaunchArgv(location, { model: "gpt-5" }, "hello").env?.COPILOT_HOME).toBe( + join(homedir(), "profiles/copilot"), + ); + }); +}); + describe("copilotDetectionSpec", () => { it("uses Copilot CLI login for terminal authentication", () => { expect(copilotDetectionSpec.loginCommand).toBe("copilot login"); diff --git a/src/supervisor/agents/copilot/detection.ts b/src/supervisor/agents/copilot/detection.ts index 8f3a86415..2d35dd498 100644 --- a/src/supervisor/agents/copilot/detection.ts +++ b/src/supervisor/agents/copilot/detection.ts @@ -49,8 +49,9 @@ export function buildCopilotCommand( location: ProjectLocation, args: string[], wslExecPath?: string, + env?: Record, ) { - return buildAgentCommand(location, "copilot", args, wslExecPath); + return buildAgentCommand(location, "copilot", args, wslExecPath, env); } /** @@ -75,13 +76,15 @@ async function probeCopilotModelEfforts( location: ProjectLocation, executablePath: string | undefined, models: { id: string }[], + env?: Record, ): Promise<{ defaultEffort?: string; modelEfforts?: Record }> { - const spec = buildCopilotCommand(location, ["--acp", "--stdio"], executablePath); + const spec = buildCopilotCommand(location, ["--acp", "--stdio"], executablePath, env); const sessionCwd = getAgentProbeCwd(location); const spawnCwd = resolveProbeSpawnCwd(location, spec.cwd); const child = spawn(spec.command, spec.args, { ...(spawnCwd ? { cwd: spawnCwd } : {}), stdio: ["pipe", "pipe", "pipe"], + ...(spec.env ? { env: { ...process.env, ...spec.env } } : {}), shell: false, windowsHide: true, }); @@ -249,19 +252,21 @@ function withCopilotModelRates( async function probeCapabilities( location: ProjectLocation, executablePath?: string, + env?: Record, ): Promise { - const spec = buildCopilotCommand(location, ["--acp", "--stdio"], executablePath); + const spec = buildCopilotCommand(location, ["--acp", "--stdio"], executablePath, env); const sessionCwd = getAgentProbeCwd(location); const processCwd = resolveProbeSpawnCwd(location, spec.cwd); const probe = await probeAcpCapabilities(spec.command, spec.args, sessionCwd, { ...(processCwd ? { processCwd } : {}), + ...(spec.env ? { env: spec.env } : {}), timeoutMs: 15_000, label: location.kind === "wsl" ? `copilot:wsl:${location.distro}` : `copilot:${location.kind}`, }); const modelEffortProbe = probe?.models?.length && executablePath !== undefined - ? await probeCopilotModelEfforts(location, executablePath, probe.models) + ? await probeCopilotModelEfforts(location, executablePath, probe.models, env) : {}; // Merge probe approval policies with defaults (probe labels take precedence, @@ -310,6 +315,6 @@ export const copilotDetectionSpec: DetectionSpec = { authProbes: [envVarAuthProbe(["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]), ghAuthProbe], async capabilitiesProbe(ctx) { if (!ctx.executablePath) return undefined; - return probeCapabilities(ctx.location, ctx.executablePath); + return probeCapabilities(ctx.location, ctx.executablePath, ctx.probeEnv); }, }; diff --git a/src/supervisor/agents/copilot/index.ts b/src/supervisor/agents/copilot/index.ts index 7940cfce3..a9860e741 100644 --- a/src/supervisor/agents/copilot/index.ts +++ b/src/supervisor/agents/copilot/index.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; -import type { PromptSegment } from "@/shared/contracts"; +import type { AgentInstanceConfig, ProjectLocation, PromptSegment } from "@/shared/contracts"; +import { homeProfileKind, parseHomeProfileInstanceConfig } from "@/shared/contracts"; import { inlinePromptSegmentText } from "@/shared/promptContent"; import { createAcpStructuredSession } from "../acp"; import { @@ -13,8 +14,10 @@ import { type AgentAdapter, type AgentEnvContext, type CreateStructuredSessionInput, + type DetectionSpec, } from "../base"; import { resolveAgentBinaryPath } from "../binaryResolver"; +import { withTerminalAuthEnv } from "../homeProfile"; import { resolveInstallNodePath, warnIfPluginManifestMissing } from "../plugin/installerBase"; import { buildCopilotArgs } from "./argv"; import { buildCopilotCommand, copilotDefaultCapabilities, copilotDetectionSpec } from "./detection"; @@ -31,6 +34,11 @@ import { READY_RE, resolveModelId, } from "./terminal"; +import { + copilotProfileEnvForLocation, + resolveCopilotHomeForLocation, + resolveCopilotInstanceEnv, +} from "./profile"; export { detectCopilotInvalidSessionRef, @@ -48,20 +56,47 @@ warnIfPluginManifestMissing( "resources/agent-plugins/copilot/ (packaged, staged by scripts/prepare-agent-plugins.mjs).", ); -export function createCopilotAdapter(): AgentAdapter { +interface CopilotAdapterOptions { + kind?: string; + label?: string; + homeDir?: string; + customEnv?: Record; +} + +export function createCopilotProfileAdapter(instance: AgentInstanceConfig): AgentAdapter { + const config = parseHomeProfileInstanceConfig(instance.config); + const customEnv = resolveCopilotInstanceEnv(instance.environment); + return createCopilotAdapter({ + kind: homeProfileKind("copilot", instance.id), + label: `GitHub Copilot ${instance.displayName ?? instance.id}`, + homeDir: config.homeDir, + ...(customEnv ? { customEnv } : {}), + }); +} + +export function createCopilotAdapter(options: CopilotAdapterOptions = {}): AgentAdapter { let capabilities = copilotDefaultCapabilities; + const kind = options.kind ?? copilotDetectionSpec.kind; + const label = options.label ?? copilotDetectionSpec.label; + const profileEnv = (location: ProjectLocation) => + copilotProfileEnvForLocation(options.homeDir, options.customEnv, location); + const profileHome = (ctx?: AgentEnvContext) => + options.homeDir + ? resolveCopilotHomeForLocation(options.homeDir, detectProbeLocation(ctx)) + : undefined; return { - kind: copilotDetectionSpec.kind, - label: copilotDetectionSpec.label, + kind, + label, binary: copilotDetectionSpec.binary, skillSupport: { roots: [ { id: "copilot", - label: copilotDetectionSpec.label, + label, globalPath: ".copilot/skills", projectPath: ".github/skills", + ...(options.homeDir ? { globalBasePath: options.homeDir } : {}), globalOverride: { env: "COPILOT_HOME", path: "skills" }, }, { @@ -90,7 +125,34 @@ export function createCopilotAdapter(): AgentAdapter { }, spawnEnv: { wsl: { BROWSER: "/bin/true" } }, async detectInstall(ctx) { - const status = await detectAgentInstall(ctx, copilotDetectionSpec); + const location = detectProbeLocation(ctx); + const env = profileEnv(location); + const spec: DetectionSpec = options.homeDir + ? { + ...copilotDetectionSpec, + kind, + label, + ...(env ? { probeEnv: env } : {}), + authProbes: [ + async () => + [ + "COPILOT_GITHUB_TOKEN", + "COPILOT_API_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", + "GH_ENTERPRISE_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", + ].some((name) => env?.[name]?.trim()) + ? "authenticated" + : undefined, + ], + } + : copilotDetectionSpec; + const status = withTerminalAuthEnv(await detectAgentInstall(ctx, spec), env, { + id: "copilot-terminal-login", + name: "Login", + type: "terminal", + }); capabilities = status.capabilities; return status; }, @@ -104,17 +166,23 @@ export function createCopilotAdapter(): AgentAdapter { // L2 OSC parsing running for the working->idle edge. partialL1: true, async isPluginInstalled(ctx) { - return isCopilotPluginInstalled(ctx); + return isCopilotPluginInstalled(ctx, profileHome(ctx)); }, async installPlugin(ctx) { const node = await resolveInstallNodePath(ctx); if (!node.ok) return node; - const result = installCopilotPlugin(ctx, { resolvedNodePath: node.nodePath }); + const home = profileHome(ctx); + const result = installCopilotPlugin(ctx, { + resolvedNodePath: node.nodePath, + ...(home ? { globalCopilotDirOverride: home } : {}), + }); if (!result.ok) return result; return { ok: true, version: result.version }; }, async uninstallPlugin(ctx) { - uninstallCopilotPlugin(ctx); + // Remove only this adapter's hook file. Staged assets are shared by the + // base adapter and every profile and are inert without that hook file. + uninstallCopilotPlugin(ctx, profileHome(ctx), false); }, // No `pluginLaunchExtras` needed — Copilot CLI auto-loads // `${COPILOT_HOME ?? ~/.copilot}/hooks/poracode-status.json` written at @@ -122,10 +190,11 @@ export function createCopilotAdapter(): AgentAdapter { buildLaunchArgv(location, config, prompt, _sessionRef, launchOptions) { const sessionId = launchOptions?.resumeThreadId ?? randomUUID(); const mcp = writeCopilotMcpConfig(location, sessionId, launchOptions?.mcpServers ?? []); + const env = { ...(mcp?.env ?? {}), ...(profileEnv(location) ?? {}) }; return { binary: "copilot", args: buildCopilotArgs(config, prompt, sessionId, launchOptions, mcp?.argument), - ...(mcp && Object.keys(mcp.env).length > 0 ? { env: mcp.env } : {}), + ...(Object.keys(env).length > 0 ? { env } : {}), ...(mcp ? { cleanup: mcp.cleanup } : {}), sessionRef: createKnownSessionRef(sessionId), }; @@ -133,10 +202,11 @@ export function createCopilotAdapter(): AgentAdapter { buildResumeArgv(location, config, prompt, sessionRef, launchOptions) { const sessionId = launchOptions?.resumeThreadId ?? sessionRef.providerSessionId; const mcp = writeCopilotMcpConfig(location, sessionId, launchOptions?.mcpServers ?? []); + const env = { ...(mcp?.env ?? {}), ...(profileEnv(location) ?? {}) }; return { binary: "copilot", args: buildCopilotArgs(config, prompt, sessionId, launchOptions, mcp?.argument), - ...(mcp && Object.keys(mcp.env).length > 0 ? { env: mcp.env } : {}), + ...(Object.keys(env).length > 0 ? { env } : {}), ...(mcp ? { cleanup: mcp.cleanup } : {}), }; }, @@ -152,6 +222,7 @@ export function createCopilotAdapter(): AgentAdapter { input.projectLocation, args, resolveAgentBinaryPath(input.projectLocation, "copilot"), + profileEnv(input.projectLocation), ); return createAcpStructuredSession(command, input); }, @@ -161,6 +232,7 @@ export function createCopilotAdapter(): AgentAdapter { location, ["--acp", "--stdio"], resolveAgentBinaryPath(location, "copilot"), + profileEnv(location), ); }, createInitialSessionRef() { @@ -201,7 +273,7 @@ export function createCopilotAdapter(): AgentAdapter { }, syncConfigFromTerminalState: applyTerminalHintToConfig, defaultOneShotModel: "", - buildOneShotCommand(model, effort, prompt) { + buildOneShotCommand(model, effort, prompt, location) { if (!prompt) { return undefined; } @@ -214,9 +286,10 @@ export function createCopilotAdapter(): AgentAdapter { args.push("--effort", effort); } - return { command: "copilot", args, stdin: "" }; + const env = location ? profileEnv(location) : undefined; + return { command: "copilot", args, stdin: "", ...(env ? { env } : {}) }; }, - buildContextExtractionCommand(sessionRef, _location, model) { + buildContextExtractionCommand(sessionRef, location, model) { // Copilot's -p flag takes the prompt inline as an arg. // The orchestrator pipes the extraction prompt via stdin, // so we pass a brief directive via -p and let stdin carry the full prompt. @@ -230,7 +303,8 @@ export function createCopilotAdapter(): AgentAdapter { if (model) { args.push("--model", model); } - return { command: "copilot", args, stdin: "" }; + const env = profileEnv(location); + return { command: "copilot", args, stdin: "", ...(env ? { env } : {}) }; }, }; } diff --git a/src/supervisor/agents/copilot/plugin/install.ts b/src/supervisor/agents/copilot/plugin/install.ts index 340096315..c62ccfd14 100644 --- a/src/supervisor/agents/copilot/plugin/install.ts +++ b/src/supervisor/agents/copilot/plugin/install.ts @@ -290,14 +290,17 @@ function installCopilotPluginWsl( const COPILOT_VERIFY_ASSETS = ["plugin.json", "forward.mjs", FORWARD_RUNTIME_FILE] as const; -export function isCopilotPluginInstalled(ctx?: AgentEnvContext): { +export function isCopilotPluginInstalled( + ctx?: AgentEnvContext, + globalCopilotDirOverride?: string, +): { installed: boolean; version?: string; } { if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "copilot"); if (!wsl) return { installed: false }; - const copilotDir = wslGlobalCopilotDir(ctx.wslDistro); + const copilotDir = globalCopilotDirOverride ?? wslGlobalCopilotDir(ctx.wslDistro); const hookFile = copilotDir ? toWslUncPath(ctx.wslDistro, `${copilotDir}/${GLOBAL_HOOK_DIR_NAME}/${GLOBAL_HOOK_FILENAME}`) : ""; @@ -306,20 +309,31 @@ export function isCopilotPluginInstalled(ctx?: AgentEnvContext): { extraCheck: () => hookFile.length > 0 && existsSync(hookFile), }); } - const hookFile = join(nativeGlobalCopilotDir(), GLOBAL_HOOK_DIR_NAME, GLOBAL_HOOK_FILENAME); + const hookFile = join( + globalCopilotDirOverride ?? nativeGlobalCopilotDir(), + GLOBAL_HOOK_DIR_NAME, + GLOBAL_HOOK_FILENAME, + ); return verifyStagedPluginAt(getNativePluginBaseDir("copilot", ctx?.baseDir), "native", { assets: COPILOT_VERIFY_ASSETS, extraCheck: () => existsSync(hookFile), }); } -export function uninstallCopilotPlugin(ctx?: AgentEnvContext): void { +export function uninstallCopilotPlugin( + ctx?: AgentEnvContext, + globalCopilotDirOverride?: string, + removeStaged = true, +): void { const hookDir = isWslPluginContext(ctx) - ? toWslUncPath(ctx.wslDistro, `${wslGlobalCopilotDir(ctx.wslDistro)}/${GLOBAL_HOOK_DIR_NAME}`) - : join(nativeGlobalCopilotDir(), GLOBAL_HOOK_DIR_NAME); + ? toWslUncPath( + ctx.wslDistro, + `${globalCopilotDirOverride ?? wslGlobalCopilotDir(ctx.wslDistro)}/${GLOBAL_HOOK_DIR_NAME}`, + ) + : join(globalCopilotDirOverride ?? nativeGlobalCopilotDir(), GLOBAL_HOOK_DIR_NAME); removeHookFile(join(hookDir, GLOBAL_HOOK_FILENAME)); removeHookFile(join(hookDir, LEGACY_GLOBAL_HOOK_FILENAME)); - removeStagedPluginDir("copilot", ctx); + if (removeStaged) removeStagedPluginDir("copilot", ctx); } function removeHookFile(path: string): void { diff --git a/src/supervisor/agents/copilot/profile.ts b/src/supervisor/agents/copilot/profile.ts new file mode 100644 index 000000000..eaab8708a --- /dev/null +++ b/src/supervisor/agents/copilot/profile.ts @@ -0,0 +1,24 @@ +import type { ProjectLocation } from "@/shared/contracts"; +import { + homeProfileEnvForLocation, + resolveAgentInstanceEnv, + resolveHomeProfilePathForLocation, +} from "../homeProfile"; + +export const resolveCopilotHomeForLocation = resolveHomeProfilePathForLocation; +export const resolveCopilotInstanceEnv = resolveAgentInstanceEnv; + +export function copilotProfileEnvForLocation( + homeDir: string | undefined, + customEnv: Record | undefined, + location: ProjectLocation, +): Record | undefined { + return homeProfileEnvForLocation(homeDir, customEnv, location, "COPILOT_HOME", [ + "GH_TOKEN", + "GITHUB_TOKEN", + "GH_ENTERPRISE_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", + "COPILOT_GITHUB_TOKEN", + "COPILOT_API_TOKEN", + ]); +} diff --git a/src/supervisor/agents/gemini/detection.test.ts b/src/supervisor/agents/gemini/detection.test.ts index ac1ce12f0..9112d1224 100644 --- a/src/supervisor/agents/gemini/detection.test.ts +++ b/src/supervisor/agents/gemini/detection.test.ts @@ -94,6 +94,21 @@ describe("geminiDetectionSpec", () => { expect(result?.modelContextSizes).not.toHaveProperty("auto-gemini-3"); expect(result?.modelContextSizes).not.toHaveProperty("gemini-9-pro"); }); + + it("does not report a process-global API key for a home profile", async () => { + vi.stubEnv("GEMINI_API_KEY", "global-key"); + try { + await expect( + geminiDetectionSpec.statusProbe?.({ + location: { kind: "posix", path: "/repo" }, + executablePath: "/usr/local/bin/gemini", + probeEnv: { GEMINI_CLI_HOME: "/missing/profile/home" }, + }), + ).resolves.toBeUndefined(); + } finally { + vi.unstubAllEnvs(); + } + }); }); describe("parseGeminiGoogleAccountsJson", () => { diff --git a/src/supervisor/agents/gemini/detection.ts b/src/supervisor/agents/gemini/detection.ts index 79e851746..efbb1dd10 100644 --- a/src/supervisor/agents/gemini/detection.ts +++ b/src/supervisor/agents/gemini/detection.ts @@ -8,6 +8,7 @@ import { batchWslCommandsAsync, buildAgentCommand, envVarAuthProbe, + quotePosixShellArg, type AuthProbe, type DetectionSpec, } from "../base"; @@ -57,11 +58,13 @@ export const defaultGeminiCapabilities: AgentCapability = { // Gemini stores a config dir at ~/.gemini after first login; treat its // presence as authenticated even without GEMINI_API_KEY set. const configDirAuthProbe: AuthProbe = async (ctx) => { + const configuredHome = ctx.probeEnv?.GEMINI_CLI_HOME?.trim(); if (ctx.location.kind !== "wsl") { - return existsSync(join(homedir(), ".gemini")) ? "authenticated" : "unknown"; + return existsSync(join(configuredHome || homedir(), ".gemini")) ? "authenticated" : "unknown"; } + const configDir = configuredHome ? `${configuredHome}/.gemini` : "~/.gemini"; const [result] = await batchWslCommandsAsync(ctx.location.distro, [ - "test -d ~/.gemini && echo yes", + `test -d ${configuredHome ? quotePosixShellArg(configDir) : configDir} && echo yes`, ]); return result?.ok && result.stdout.trim() === "yes" ? "authenticated" : "unknown"; }; @@ -84,13 +87,22 @@ export function parseGeminiGoogleAccountsJson(raw: string): string | undefined { } async function probeGeminiMetadata(ctx: Parameters>[0]) { + const configuredHome = ctx.probeEnv?.GEMINI_CLI_HOME?.trim(); + const configuredApiKey = + ctx.probeEnv?.GEMINI_API_KEY?.trim() || ctx.probeEnv?.GOOGLE_API_KEY?.trim(); if (ctx.location.kind === "wsl") { + const configDir = configuredHome ? `${configuredHome}/.gemini` : "~/.gemini"; + const accountsPath = `${configDir}/google_accounts.json`; const [apiKeyResult, configDirResult, accountsResult] = await batchWslCommandsAsync( ctx.location.distro, [ - 'printf %s "$GEMINI_API_KEY"', - "test -d ~/.gemini && echo yes", - 'cat ~/.gemini/google_accounts.json 2>/dev/null || printf ""', + configuredApiKey + ? `printf %s ${quotePosixShellArg(configuredApiKey)}` + : ctx.probeEnv + ? 'printf ""' + : 'printf %s "$GEMINI_API_KEY"', + `test -d ${configuredHome ? quotePosixShellArg(configDir) : configDir} && echo yes`, + `cat ${configuredHome ? quotePosixShellArg(accountsPath) : accountsPath} 2>/dev/null || printf ""`, ], ); const apiKeySet = !!(apiKeyResult?.ok && apiKeyResult.stdout.trim().length > 0); @@ -107,9 +119,11 @@ async function probeGeminiMetadata(ctx: Parameters 0; - const accountsPath = join(homedir(), ".gemini", "google_accounts.json"); + const apiKeySet = ctx.probeEnv + ? Boolean(configuredApiKey) + : Boolean(process.env.GEMINI_API_KEY?.trim()); + const home = configuredHome || homedir(); + const accountsPath = join(home, ".gemini", "google_accounts.json"); let activeAccount: string | undefined; if (!apiKeySet) { try { @@ -118,7 +132,7 @@ async function probeGeminiMetadata(ctx: Parameters { + const location: ProjectLocation = { kind: "posix", path: "/repo" }; + + it("routes terminal, ACP, one-shot, context, and skills through GEMINI_CLI_HOME", async () => { + const adapter = createGeminiProfileAdapter({ + id: "work", + driver: "gemini", + displayName: "Work", + config: { homeDir: "/profiles/gemini" }, + environment: { + GEMINI_API_KEY: { value: "profile-key", sensitive: true }, + GOOGLE_CLOUD_PROJECT: { value: "profile-project" }, + GEMINI_CLI_HOME: { value: "/ignored" }, + }, + }); + + expect(adapter.kind).toBe("gemini:work"); + expect(adapter.label).toBe("Gemini Work"); + expect(adapter.skillSupport?.roots[0]?.globalBasePath).toBe("/profiles/gemini"); + expect( + adapter.buildLaunchArgv(location, { model: "gemini-2.5-pro" }, "hello").env, + ).toMatchObject({ + GEMINI_CLI_HOME: "/profiles/gemini", + GEMINI_API_KEY: "profile-key", + GOOGLE_API_KEY: "", + GOOGLE_APPLICATION_CREDENTIALS: "", + GOOGLE_CLOUD_PROJECT: "profile-project", + GOOGLE_CLOUD_LOCATION: "", + GOOGLE_GENAI_USE_VERTEXAI: "", + GOOGLE_GENAI_USE_GCA: "", + }); + expect( + adapter.buildOneShotCommand?.("gemini-2.5-flash", undefined, "title", location)?.env, + ).toMatchObject({ GEMINI_CLI_HOME: "/profiles/gemini" }); + expect( + adapter.buildContextExtractionCommand?.( + { providerSessionId: "session", discoveredAt: "test" }, + location, + )?.env, + ).toMatchObject({ GEMINI_CLI_HOME: "/profiles/gemini" }); + + const nativeAuth = await adapter.buildAcpAuthCommand?.({ envKind: "posix" }); + expect(nativeAuth?.env).toMatchObject({ + GEMINI_CLI_HOME: "/profiles/gemini", + GEMINI_API_KEY: "profile-key", + GOOGLE_API_KEY: "", + GOOGLE_APPLICATION_CREDENTIALS: "", + GOOGLE_CLOUD_PROJECT: "profile-project", + GOOGLE_CLOUD_LOCATION: "", + GOOGLE_GENAI_USE_VERTEXAI: "", + GOOGLE_GENAI_USE_GCA: "", + }); + + const wslAuth = await adapter.buildAcpAuthCommand?.({ envKind: "wsl", wslDistro: "Ubuntu" }); + const wslScript = wslAuth?.args.join(" ") ?? ""; + expect(wslScript).toContain("export GEMINI_CLI_HOME='/profiles/gemini'"); + expect(wslScript).toContain("export GEMINI_API_KEY='profile-key'"); + expect(wslScript).toContain("export GOOGLE_API_KEY=''"); + expect(wslScript).toContain("export GOOGLE_APPLICATION_CREDENTIALS=''"); + expect(wslScript).toContain("export GOOGLE_CLOUD_PROJECT='profile-project'"); + expect(wslScript).toContain("export GOOGLE_CLOUD_LOCATION=''"); + expect(wslScript).toContain("export GOOGLE_GENAI_USE_VERTEXAI=''"); + expect(wslScript).toContain("export GOOGLE_GENAI_USE_GCA=''"); + expect( + createGeminiAdapter().buildOneShotCommand?.("gemini-2.5-flash", undefined, "title", location) + ?.env, + ).toBeUndefined(); + expect(adapter.uninstallPlugin).toBeUndefined(); + expect(createGeminiAdapter().uninstallPlugin).toBeTypeOf("function"); + }); + + it("resolves a relative profile home against the target user home", () => { + const adapter = createGeminiProfileAdapter({ + id: "relative", + driver: "gemini", + config: { homeDir: "profiles/gemini" }, + }); + + expect( + adapter.buildLaunchArgv(location, { model: "gemini-2.5-pro" }, "hello").env?.GEMINI_CLI_HOME, + ).toBe(join(homedir(), "profiles/gemini")); + }); +}); + describe("detectGeminiOscTitleStatus", () => { it("detects idle from ◇ Ready title bar indicator", () => { const text = "◇ Ready (my-project)"; diff --git a/src/supervisor/agents/gemini/index.ts b/src/supervisor/agents/gemini/index.ts index c203be6f0..07537497a 100644 --- a/src/supervisor/agents/gemini/index.ts +++ b/src/supervisor/agents/gemini/index.ts @@ -1,6 +1,12 @@ import { randomUUID } from "node:crypto"; -import type { AgentCapability, ProjectLocation, PromptSegment } from "@/shared/contracts"; +import type { + AgentCapability, + AgentInstanceConfig, + ProjectLocation, + PromptSegment, +} from "@/shared/contracts"; +import { homeProfileKind, parseHomeProfileInstanceConfig } from "@/shared/contracts"; import { inlinePromptSegmentText } from "@/shared/promptContent"; import { EXTRACTION_PROMPT } from "@/supervisor/contextExtractor"; import { createAcpStructuredSession } from "../acp"; @@ -14,6 +20,7 @@ import { type AgentEnvContext, type AgentLaunchOptions, type CreateStructuredSessionInput, + type DetectionSpec, type TerminalStatusHint, } from "../base"; import { resolveAgentBinaryPath } from "../binaryResolver"; @@ -31,6 +38,7 @@ import { } from "./plugin/install"; import { detectGeminiInvalidSessionRef } from "./session"; import { detectGeminiOscTitleStatus } from "./terminal"; +import { geminiProfileEnvForLocation, resolveGeminiInstanceEnv } from "./profile"; export { detectGeminiInvalidSessionRef } from "./session"; @@ -94,20 +102,43 @@ function prepareGeminiLaunchMcpSettings( }; } -export function createGeminiAdapter(): AgentAdapter { +interface GeminiAdapterOptions { + kind?: string; + label?: string; + homeDir?: string; + customEnv?: Record; +} + +export function createGeminiProfileAdapter(instance: AgentInstanceConfig): AgentAdapter { + const config = parseHomeProfileInstanceConfig(instance.config); + const customEnv = resolveGeminiInstanceEnv(instance.environment); + return createGeminiAdapter({ + kind: homeProfileKind("gemini", instance.id), + label: `Gemini ${instance.displayName ?? instance.id}`, + homeDir: config.homeDir, + ...(customEnv ? { customEnv } : {}), + }); +} + +export function createGeminiAdapter(options: GeminiAdapterOptions = {}): AgentAdapter { let capabilities: AgentCapability = defaultGeminiCapabilities; + const kind = options.kind ?? geminiDetectionSpec.kind; + const label = options.label ?? geminiDetectionSpec.label; + const profileEnv = (location: ProjectLocation) => + geminiProfileEnvForLocation(options.homeDir, options.customEnv, location); return { - kind: geminiDetectionSpec.kind, - label: geminiDetectionSpec.label, + kind, + label, binary: geminiDetectionSpec.binary, skillSupport: { roots: [ { id: "gemini", - label: geminiDetectionSpec.label, + label, globalPath: ".gemini/skills", projectPath: ".gemini/skills", + ...(options.homeDir ? { globalBasePath: options.homeDir } : {}), globalOverride: { env: "GEMINI_CLI_HOME", path: ".gemini/skills" }, }, ], @@ -150,17 +181,47 @@ export function createGeminiAdapter(): AgentAdapter { if (!result.ok) return result; return { ok: true, version: result.version }; }, - async uninstallPlugin(ctx) { - uninstallGeminiPlugin(ctx); - }, + ...(options.homeDir + ? {} + : { + async uninstallPlugin(ctx: AgentEnvContext) { + // Gemini's staged system settings are shared by every profile. + uninstallGeminiPlugin(ctx); + }, + }), async detectInstall(ctx) { - const status = await detectAgentInstall(ctx, geminiDetectionSpec); + const location = detectProbeLocation(ctx); + const env = profileEnv(location); + const spec: DetectionSpec = options.homeDir + ? { + ...geminiDetectionSpec, + kind, + label, + ...(env ? { probeEnv: env } : {}), + authProbes: [ + async () => + ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS"].some( + (name) => env?.[name]?.trim(), + ) + ? "authenticated" + : undefined, + ...(geminiDetectionSpec.authProbes ?? []) + .slice(1) + .map( + (probe) => (probeCtx: Parameters[0]) => + probe({ ...probeCtx, ...(env ? { probeEnv: env } : {}) }), + ), + ], + } + : geminiDetectionSpec; + const status = await detectAgentInstall(ctx, spec); capabilities = status.capabilities; return status; }, buildLaunchArgv(location, config, prompt, _sessionRef, launchOptions) { const launchSettings = prepareGeminiLaunchMcpSettings(location, launchOptions); + const env = { ...(launchSettings?.env ?? {}), ...(profileEnv(location) ?? {}) }; // Pre-assign the session UUID via --session-id so we know it before // spawn. Avoids racing post-spawn discovery against one-shot `gemini -p` // calls (title gen, commit-msg, PR summary) that also create entries in @@ -170,18 +231,21 @@ export function createGeminiAdapter(): AgentAdapter { return { binary: "gemini", args, - ...(launchSettings ? { env: launchSettings.env, cleanup: launchSettings.cleanup } : {}), + ...(Object.keys(env).length > 0 ? { env } : {}), + ...(launchSettings ? { cleanup: launchSettings.cleanup } : {}), sessionRef: createKnownSessionRef(assignedId), }; }, buildResumeArgv(location, config, prompt, sessionRef, launchOptions) { const launchSettings = prepareGeminiLaunchMcpSettings(location, launchOptions); + const env = { ...(launchSettings?.env ?? {}), ...(profileEnv(location) ?? {}) }; const args = buildGeminiArgs(config, prompt, sessionRef.providerSessionId); return { binary: "gemini", args, - ...(launchSettings ? { env: launchSettings.env, cleanup: launchSettings.cleanup } : {}), + ...(Object.keys(env).length > 0 ? { env } : {}), + ...(launchSettings ? { cleanup: launchSettings.cleanup } : {}), }; }, @@ -191,7 +255,10 @@ export function createGeminiAdapter(): AgentAdapter { "gemini", ["--acp", "--skip-trust"], resolveAgentBinaryPath(input.projectLocation, "gemini"), - input.projectLocation.kind === "windows" ? { GEMINI_PTY_INFO: "child_process" } : undefined, + { + ...(input.projectLocation.kind === "windows" ? { GEMINI_PTY_INFO: "child_process" } : {}), + ...(profileEnv(input.projectLocation) ?? {}), + }, ); return createAcpStructuredSession(command, input); }, @@ -202,7 +269,10 @@ export function createGeminiAdapter(): AgentAdapter { "gemini", ["--acp", "--skip-trust"], resolveAgentBinaryPath(location, "gemini"), - location.kind === "windows" ? { GEMINI_PTY_INFO: "child_process" } : undefined, + { + ...(location.kind === "windows" ? { GEMINI_PTY_INFO: "child_process" } : {}), + ...(profileEnv(location) ?? {}), + }, ); }, @@ -234,11 +304,18 @@ export function createGeminiAdapter(): AgentAdapter { defaultOneShotModel: "gemini-2.5-flash", - buildOneShotCommand(model, _effort, prompt) { + buildOneShotCommand(model, _effort, prompt, location) { if (!prompt) return undefined; - return { command: "gemini", args: ["-p", prompt, "--model", model], stdin: "" }; + const env = location ? profileEnv(location) : undefined; + return { + command: "gemini", + args: ["-p", prompt, "--model", model], + stdin: "", + ...(env ? { env } : {}), + }; }, - buildContextExtractionCommand(sessionRef, _location, model) { + buildContextExtractionCommand(sessionRef, location, model) { + const env = profileEnv(location); return { command: "gemini", args: [ @@ -250,6 +327,7 @@ export function createGeminiAdapter(): AgentAdapter { model ?? "gemini-2.5-flash", ], stdin: "", + ...(env ? { env } : {}), }; }, }; diff --git a/src/supervisor/agents/gemini/plugin/install.test.ts b/src/supervisor/agents/gemini/plugin/install.test.ts index eff83957c..1c7dc1f86 100644 --- a/src/supervisor/agents/gemini/plugin/install.test.ts +++ b/src/supervisor/agents/gemini/plugin/install.test.ts @@ -17,6 +17,7 @@ import { syncGeminiAppControlsMcpSettings, syncGeminiSubagentMcpSettings, } from "./install"; +import { createGeminiAdapter, createGeminiProfileAdapter } from "../index"; const tempDirs: string[] = []; let savedBrowserMcpEnv: { url?: string; token?: string }; @@ -151,6 +152,23 @@ describe("installGeminiPlugin", () => { : /^(?!cmd\.exe)/, ); }); + + it("keeps shared staged assets when a profile is uninstalled", async () => { + const baseDir = makeBaseDir(); + const ctx = { envKind: "posix" as const, baseDir }; + expect(installGeminiPlugin(ctx).ok).toBe(true); + + const profile = createGeminiProfileAdapter({ + id: "work", + driver: "gemini", + config: { homeDir: "/profiles/gemini" }, + }); + await profile.uninstallPlugin?.(ctx); + expect(isGeminiPluginInstalled(ctx).installed).toBe(true); + + await createGeminiAdapter().uninstallPlugin?.(ctx); + expect(isGeminiPluginInstalled(ctx).installed).toBe(false); + }); }); const subagentCfg: SubagentMcpHttpConfig = { diff --git a/src/supervisor/agents/gemini/plugin/install.ts b/src/supervisor/agents/gemini/plugin/install.ts index adb610fe4..632fc3276 100644 --- a/src/supervisor/agents/gemini/plugin/install.ts +++ b/src/supervisor/agents/gemini/plugin/install.ts @@ -620,8 +620,8 @@ export function isGeminiPluginInstalled(ctx?: AgentEnvContext): { return verifyGeminiInstallAt(getNativePluginBaseDir("gemini", ctx?.baseDir), "native"); } -export function uninstallGeminiPlugin(ctx?: AgentEnvContext): void { - removeStagedPluginDir("gemini", ctx); +export function uninstallGeminiPlugin(ctx?: AgentEnvContext, removeStaged = true): void { + if (removeStaged) removeStagedPluginDir("gemini", ctx); } function verifyGeminiInstallAt( diff --git a/src/supervisor/agents/gemini/profile.ts b/src/supervisor/agents/gemini/profile.ts new file mode 100644 index 000000000..eec1cbdbd --- /dev/null +++ b/src/supervisor/agents/gemini/profile.ts @@ -0,0 +1,20 @@ +import type { ProjectLocation } from "@/shared/contracts"; +import { homeProfileEnvForLocation, resolveAgentInstanceEnv } from "../homeProfile"; + +export const resolveGeminiInstanceEnv = resolveAgentInstanceEnv; + +export function geminiProfileEnvForLocation( + homeDir: string | undefined, + customEnv: Record | undefined, + location: ProjectLocation, +): Record | undefined { + return homeProfileEnvForLocation(homeDir, customEnv, location, "GEMINI_CLI_HOME", [ + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_CLOUD_PROJECT", + "GOOGLE_CLOUD_LOCATION", + "GOOGLE_GENAI_USE_VERTEXAI", + "GOOGLE_GENAI_USE_GCA", + ]); +} diff --git a/src/supervisor/agents/grok/detection.ts b/src/supervisor/agents/grok/detection.ts index 996ca7914..3c9f90afd 100644 --- a/src/supervisor/agents/grok/detection.ts +++ b/src/supervisor/agents/grok/detection.ts @@ -12,6 +12,7 @@ import { batchWslCommandsAsync, buildAgentCommand, envVarAuthProbe, + quotePosixShellArg, type CapabilitiesProbeResult, type DetectionSpec, } from "../base"; @@ -56,19 +57,26 @@ export const grokDefaultCapabilities: AgentCapability = { settingDefs: [], }; -export function buildGrokCommand(location: ProjectLocation, args: string[], wslExecPath?: string) { - return buildAgentCommand(location, "grok", args, wslExecPath); +export function buildGrokCommand( + location: ProjectLocation, + args: string[], + wslExecPath?: string, + env?: Record, +) { + return buildAgentCommand(location, "grok", args, wslExecPath, env); } async function probeCapabilities( location: ProjectLocation, executablePath?: string, + env?: Record, ): Promise { - const spec = buildGrokCommand(location, ["agent", "stdio"], executablePath); + const spec = buildGrokCommand(location, ["agent", "stdio"], executablePath, env); const sessionCwd = getAgentProbeCwd(location); const processCwd = resolveProbeSpawnCwd(location, spec.cwd); const probe = await probeAcpCapabilities(spec.command, spec.args, sessionCwd, { ...(processCwd ? { processCwd } : {}), + ...(spec.env ? { env: spec.env } : {}), timeoutMs: 20_000, // grok may take a moment on first init label: location.kind === "wsl" ? `grok:wsl:${location.distro}` : `grok:${location.kind}`, // Grok returns identity (email, auth_mode, subscription_tier) in the @@ -215,15 +223,19 @@ function formatGrokAuthMode(mode: string): string { async function grokAuthFileProbe( ctx: Parameters>[0], ): Promise<"authenticated" | "unknown"> { - const check = (home: string) => { - if (existsSync(join(home, ".grok", "auth.json"))) return "authenticated"; + const configuredHome = ctx.probeEnv?.GROK_HOME?.trim(); + const check = (home: string, isGrokHome = false) => { + if (existsSync(join(home, ...(isGrokHome ? [] : [".grok"]), "auth.json"))) { + return "authenticated"; + } return "unknown"; }; if (ctx.location.kind !== "wsl") { - return check(homedir()); + return check(configuredHome || homedir(), Boolean(configuredHome)); } + const authPath = configuredHome ? `${configuredHome}/auth.json` : "~/.grok/auth.json"; const [r] = await batchWslCommandsAsync(ctx.location.distro, [ - "test -f ~/.grok/auth.json && echo yes || echo no", + `test -f ${configuredHome ? quotePosixShellArg(authPath) : authPath} && echo yes || echo no`, ]); return r?.ok && r.stdout.trim() === "yes" ? "authenticated" : "unknown"; } @@ -249,6 +261,6 @@ export const grokDetectionSpec: DetectionSpec = { authProbes: [envVarAuthProbe(["GROK_API_KEY", "XAI_API_KEY"]), grokAuthFileProbe], async capabilitiesProbe(ctx) { if (!ctx.executablePath) return undefined; - return probeCapabilities(ctx.location, ctx.executablePath); + return probeCapabilities(ctx.location, ctx.executablePath, ctx.probeEnv); }, }; diff --git a/src/supervisor/agents/grok/grok.test.ts b/src/supervisor/agents/grok/grok.test.ts index 85b6203eb..a4ef98e46 100644 --- a/src/supervisor/agents/grok/grok.test.ts +++ b/src/supervisor/agents/grok/grok.test.ts @@ -1,12 +1,71 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { McpServer, ProjectLocation, ThreadConfig } from "@/shared/contracts"; import type { OscNotification, OscTitle } from "@/shared/osc"; import { createKnownSessionRef } from "../base"; import { grokDetectionSpec } from "./detection"; -import { createGrokAdapter } from "./index"; +import { createGrokAdapter, createGrokProfileAdapter } from "./index"; + +describe("createGrokProfileAdapter", () => { + const location: ProjectLocation = { kind: "posix", path: "/repo" }; + + it("routes terminal, ACP, logout, one-shot, sessions, and skills through GROK_HOME", async () => { + const adapter = createGrokProfileAdapter({ + id: "work", + driver: "grok", + displayName: "Work", + config: { homeDir: "/profiles/grok" }, + environment: { + XAI_API_KEY: { value: "profile-key", sensitive: true }, + GROK_HOME: { value: "/ignored" }, + }, + }); + + expect(adapter.kind).toBe("grok:work"); + expect(adapter.label).toBe("Grok Build Work"); + expect(adapter.skillSupport?.roots[0]?.globalBasePath).toBe("/profiles/grok"); + expect(adapter.buildLaunchArgv(location, { model: "grok-4.5" }, "hello").env).toMatchObject({ + GROK_HOME: "/profiles/grok", + GROK_API_KEY: "", + XAI_API_KEY: "profile-key", + }); + expect( + adapter.buildOneShotCommand?.("grok-4.5", undefined, "title", location)?.env, + ).toMatchObject({ GROK_HOME: "/profiles/grok" }); + + const nativeAuth = await adapter.buildAcpAuthCommand?.({ envKind: "posix" }); + expect(nativeAuth?.env).toMatchObject({ + GROK_HOME: "/profiles/grok", + GROK_API_KEY: "", + XAI_API_KEY: "profile-key", + }); + + const auth = await adapter.buildAcpAuthCommand?.({ envKind: "wsl", wslDistro: "Ubuntu" }); + const logout = await adapter.buildAcpLogoutCommand?.({ envKind: "wsl", wslDistro: "Ubuntu" }); + const authScript = auth?.args.join(" ") ?? ""; + expect(authScript).toContain("export GROK_HOME='/profiles/grok'"); + expect(authScript).toContain("export GROK_API_KEY=''"); + expect(authScript).toContain("export XAI_API_KEY='profile-key'"); + expect(logout?.args.join(" ")).toContain("GROK_HOME='/profiles/grok'"); + expect( + createGrokAdapter().buildOneShotCommand?.("grok-4.5", undefined, "title", location)?.env, + ).toBeUndefined(); + }); + + it("resolves a relative profile home against the target user home", () => { + const adapter = createGrokProfileAdapter({ + id: "relative", + driver: "grok", + config: { homeDir: "profiles/grok" }, + }); + + expect(adapter.buildLaunchArgv(location, { model: "grok-4.5" }, "hello").env?.GROK_HOME).toBe( + join(homedir(), "profiles/grok"), + ); + }); +}); function oscTitle(text: string, code: 0 | 1 | 2 = 0): OscTitle { return { code, text }; diff --git a/src/supervisor/agents/grok/index.ts b/src/supervisor/agents/grok/index.ts index 15293e27c..098c4765e 100644 --- a/src/supervisor/agents/grok/index.ts +++ b/src/supervisor/agents/grok/index.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; -import type { PromptSegment } from "@/shared/contracts"; +import type { AgentInstanceConfig, ProjectLocation, PromptSegment } from "@/shared/contracts"; +import { homeProfileKind, parseHomeProfileInstanceConfig } from "@/shared/contracts"; import { inlinePromptSegmentText } from "@/shared/promptContent"; import { createAcpStructuredSession } from "../acp"; import { @@ -11,8 +12,10 @@ import { type AgentAdapter, type AgentEnvContext, type CreateStructuredSessionInput, + type DetectionSpec, } from "../base"; import { resolveAgentBinaryPath } from "../binaryResolver"; +import { withTerminalAuthEnv } from "../homeProfile"; import { resolveInstallNodePath, warnIfPluginManifestMissing } from "../plugin/installerBase"; import { buildGrokAcpArgs, buildGrokArgs } from "./argv"; import { buildGrokCommand, grokDefaultCapabilities, grokDetectionSpec } from "./detection"; @@ -23,11 +26,17 @@ import { uninstallGrokPlugin, } from "./plugin/install"; import { + createGrokSessionTracker, makeGrokDiscoverSessionRef, makeGrokWatchSessionRef, resolveGrokSessionArg, snapshotGrokPreSpawnSessions, } from "./sessionFiles"; +import { + grokProfileEnvForLocation, + resolveGrokHomeForLocation, + resolveGrokInstanceEnv, +} from "./profile"; const GROK_PLUGIN_VERSION = readBundledGrokPluginVersion(); @@ -39,20 +48,51 @@ warnIfPluginManifestMissing("grok", GROK_PLUGIN_VERSION); // Modes/permissions: https://docs.x.ai/build/modes-and-commands // Enterprise auth: https://docs.x.ai/build/enterprise -export function createGrokAdapter(): AgentAdapter { +interface GrokAdapterOptions { + kind?: string; + label?: string; + homeDir?: string; + customEnv?: Record; +} + +export function createGrokProfileAdapter(instance: AgentInstanceConfig): AgentAdapter { + const config = parseHomeProfileInstanceConfig(instance.config); + const customEnv = resolveGrokInstanceEnv(instance.environment); + return createGrokAdapter({ + kind: homeProfileKind("grok", instance.id), + label: `Grok Build ${instance.displayName ?? instance.id}`, + homeDir: config.homeDir, + ...(customEnv ? { customEnv } : {}), + }); +} + +export function createGrokAdapter(options: GrokAdapterOptions = {}): AgentAdapter { let capabilities = grokDefaultCapabilities; + const kind = options.kind ?? grokDetectionSpec.kind; + const label = options.label ?? grokDetectionSpec.label; + const profileEnv = (location: ProjectLocation) => + grokProfileEnvForLocation(options.homeDir, options.customEnv, location); + const profileHome = (ctx?: AgentEnvContext) => + options.homeDir + ? resolveGrokHomeForLocation(options.homeDir, detectProbeLocation(ctx)) + : undefined; + const sessionHome = options.homeDir + ? (location: ProjectLocation) => resolveGrokHomeForLocation(options.homeDir!, location) + : undefined; + const sessionTracker = createGrokSessionTracker(); return { - kind: grokDetectionSpec.kind, - label: grokDetectionSpec.label, + kind, + label, binary: grokDetectionSpec.binary, skillSupport: { roots: [ { id: "grok", - label: grokDetectionSpec.label, + label, globalPath: ".grok/skills", projectPath: ".grok/skills", + ...(options.homeDir ? { globalBasePath: options.homeDir } : {}), globalOverride: { env: "GROK_HOME", path: "skills" }, }, { @@ -73,7 +113,7 @@ export function createGrokAdapter(): AgentAdapter { projectionRoots: [ { id: "grok", - label: grokDetectionSpec.label, + label, projectPath: ".grok/skills", }, ], @@ -106,17 +146,23 @@ export function createGrokAdapter(): AgentAdapter { return true; }, async isPluginInstalled(ctx) { - return isGrokPluginInstalled(ctx); + return isGrokPluginInstalled(ctx, profileHome(ctx)); }, async installPlugin(ctx) { const node = await resolveInstallNodePath(ctx); if (!node.ok) return node; - const result = installGrokPlugin(ctx, { resolvedNodePath: node.nodePath }); + const home = profileHome(ctx); + const result = installGrokPlugin(ctx, { + resolvedNodePath: node.nodePath, + ...(home ? { globalGrokDirOverride: home } : {}), + }); if (!result.ok) return result; return { ok: true, version: result.version }; }, async uninstallPlugin(ctx) { - uninstallGrokPlugin(ctx); + // Remove only this adapter's hook file. Staged assets are shared by the + // base adapter and every profile and are inert without that hook file. + uninstallGrokPlugin(ctx, profileHome(ctx), false); }, // No `pluginLaunchExtras` env/args needed — Grok auto-loads // `~/.grok/hooks/poracode-status.json` written at install time, and @@ -126,17 +172,44 @@ export function createGrokAdapter(): AgentAdapter { }, async detectInstall(ctx) { - const status = await detectAgentInstall(ctx, grokDetectionSpec); + const location = detectProbeLocation(ctx); + const env = profileEnv(location); + const spec: DetectionSpec = options.homeDir + ? { + ...grokDetectionSpec, + kind, + label, + ...(env ? { probeEnv: env } : {}), + authProbes: [ + async () => + ["GROK_API_KEY", "XAI_API_KEY"].some((name) => env?.[name]?.trim()) + ? "authenticated" + : undefined, + ...(grokDetectionSpec.authProbes ?? []) + .slice(1) + .map( + (probe) => (probeCtx: Parameters[0]) => + probe({ ...probeCtx, ...(env ? { probeEnv: env } : {}) }), + ), + ], + } + : grokDetectionSpec; + const status = withTerminalAuthEnv(await detectAgentInstall(ctx, spec), env, { + id: "grok-terminal-login", + name: "Login", + type: "terminal", + }); capabilities = status.capabilities; return status; }, buildLaunchArgv(location, config, prompt, sessionRef, _launchOptions) { const cwd = location.kind === "wsl" ? location.linuxPath : location.path; + const home = sessionHome?.(location); // Snapshot existing session dirs so discoverSessionRef can identify the // new UUID Grok creates if the pre-assigned `-s` id is ever ignored and // the PTY ends up creating its own session. - snapshotGrokPreSpawnSessions(location, cwd); + snapshotGrokPreSpawnSessions(location, cwd, home, sessionTracker); // Resolve the session ID before spawning the PTY: resume a known one // with `-r`, otherwise pre-assign a fresh UUID with `-s` (grok 0.2.93, @@ -147,29 +220,33 @@ export function createGrokAdapter(): AgentAdapter { const known = sessionRef?.providerSessionId; const sessionId = known ?? randomUUID(); const sessionArg = known - ? resolveGrokSessionArg(location, cwd, known) + ? resolveGrokSessionArg(location, cwd, known, home) : ({ kind: "new", sessionId } as const); const args = buildGrokArgs(config, prompt, sessionArg); + const env = profileEnv(location); // Returning the id as sessionRef lets the runtime skip post-spawn // discovery on the happy path (mirrors gemini/cursor). // discoverSessionRef stays wired up as the fallback. return { binary: "grok", args, + ...(env ? { env } : {}), sessionRef: createKnownSessionRef(sessionId), }; }, buildResumeArgv(location, config, prompt, sessionRef) { const cwd = location.kind === "wsl" ? location.linuxPath : location.path; + const home = sessionHome?.(location); const known = sessionRef?.providerSessionId; const args = buildGrokArgs( config, prompt, - known ? resolveGrokSessionArg(location, cwd, known) : undefined, + known ? resolveGrokSessionArg(location, cwd, known, home) : undefined, ); - return { binary: "grok", args }; + const env = profileEnv(location); + return { binary: "grok", args, ...(env ? { env } : {}) }; }, async createStructuredSession(input: CreateStructuredSessionInput) { @@ -178,6 +255,7 @@ export function createGrokAdapter(): AgentAdapter { input.projectLocation, [...acpArgs, "agent", "stdio"], resolveAgentBinaryPath(input.projectLocation, "grok"), + profileEnv(input.projectLocation), ); return createAcpStructuredSession(command, input); }, @@ -188,12 +266,18 @@ export function createGrokAdapter(): AgentAdapter { location, ["agent", "stdio"], resolveAgentBinaryPath(location, "grok"), + profileEnv(location), ); }, async buildAcpLogoutCommand(ctx?: AgentEnvContext) { const location = detectProbeLocation(ctx); - return buildGrokCommand(location, ["logout"], resolveAgentBinaryPath(location, "grok")); + return buildGrokCommand( + location, + ["logout"], + resolveAgentBinaryPath(location, "grok"), + profileEnv(location), + ); }, createInitialSessionRef() { @@ -205,8 +289,8 @@ export function createGrokAdapter(): AgentAdapter { // stable ID). Subsequent CLI resumes then use precise `-r ` and the // Chat (ACP) + Terminal tabs share the exact same Grok session. initialSessionRefDiscoveryDelayMs: 1200, - discoverSessionRef: makeGrokDiscoverSessionRef(), - watchSessionRef: makeGrokWatchSessionRef(), + discoverSessionRef: makeGrokDiscoverSessionRef(sessionHome, sessionTracker), + watchSessionRef: makeGrokWatchSessionRef(sessionHome), buildDirectInput(prompt) { // Grok TUI may batch pasted input (especially on fresh/resumed sessions); @@ -247,13 +331,14 @@ export function createGrokAdapter(): AgentAdapter { // launch/ACP bypass in argv.ts). The default is Grok's own default model — // fast and subscription-covered, ideal for lightweight one-shots. defaultOneShotModel: "grok-composer-2.5-fast", - buildOneShotCommand(model, effort, prompt) { + buildOneShotCommand(model, effort, prompt, location) { if (!prompt) return undefined; const args = ["-p", prompt]; if (model) args.push("-m", model); if (effort) args.push("--reasoning-effort", effort); args.push("--always-approve"); - return { command: "grok", args, stdin: "" }; + const env = location ? profileEnv(location) : undefined; + return { command: "grok", args, stdin: "", ...(env ? { env } : {}) }; }, }; } diff --git a/src/supervisor/agents/grok/plugin/install.ts b/src/supervisor/agents/grok/plugin/install.ts index c8eb1ab53..dc62bcd87 100644 --- a/src/supervisor/agents/grok/plugin/install.ts +++ b/src/supervisor/agents/grok/plugin/install.ts @@ -264,14 +264,17 @@ function installGrokPluginWsl( const GROK_VERIFY_ASSETS = ["plugin.json", "forward.mjs", FORWARD_RUNTIME_FILE] as const; -export function isGrokPluginInstalled(ctx?: AgentEnvContext): { +export function isGrokPluginInstalled( + ctx?: AgentEnvContext, + globalGrokDirOverride?: string, +): { installed: boolean; version?: string; } { if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "grok"); if (!wsl) return { installed: false }; - const grokDir = wslGlobalGrokDir(ctx.wslDistro); + const grokDir = globalGrokDirOverride ?? wslGlobalGrokDir(ctx.wslDistro); const hookFile = grokDir ? toWslUncPath(ctx.wslDistro, `${grokDir}/${GLOBAL_HOOK_DIR_NAME}/${GLOBAL_HOOK_FILENAME}`) : ""; @@ -280,20 +283,31 @@ export function isGrokPluginInstalled(ctx?: AgentEnvContext): { extraCheck: () => hookFile.length > 0 && hookFileMatchesPoracode(hookFile), }); } - const hookFile = join(nativeGlobalGrokDir(), GLOBAL_HOOK_DIR_NAME, GLOBAL_HOOK_FILENAME); + const hookFile = join( + globalGrokDirOverride ?? nativeGlobalGrokDir(), + GLOBAL_HOOK_DIR_NAME, + GLOBAL_HOOK_FILENAME, + ); return verifyStagedPluginAt(getNativePluginBaseDir("grok", ctx?.baseDir), "native", { assets: GROK_VERIFY_ASSETS, extraCheck: () => hookFileMatchesPoracode(hookFile), }); } -export function uninstallGrokPlugin(ctx?: AgentEnvContext): void { +export function uninstallGrokPlugin( + ctx?: AgentEnvContext, + globalGrokDirOverride?: string, + removeStaged = true, +): void { const hookDir = isWslPluginContext(ctx) - ? toWslUncPath(ctx.wslDistro, `${wslGlobalGrokDir(ctx.wslDistro)}/${GLOBAL_HOOK_DIR_NAME}`) - : join(nativeGlobalGrokDir(), GLOBAL_HOOK_DIR_NAME); + ? toWslUncPath( + ctx.wslDistro, + `${globalGrokDirOverride ?? wslGlobalGrokDir(ctx.wslDistro)}/${GLOBAL_HOOK_DIR_NAME}`, + ) + : join(globalGrokDirOverride ?? nativeGlobalGrokDir(), GLOBAL_HOOK_DIR_NAME); removeManagedHookFile(join(hookDir, GLOBAL_HOOK_FILENAME)); removeManagedHookFile(join(hookDir, LEGACY_GLOBAL_HOOK_FILENAME)); - removeStagedPluginDir("grok", ctx); + if (removeStaged) removeStagedPluginDir("grok", ctx); } /** diff --git a/src/supervisor/agents/grok/profile.ts b/src/supervisor/agents/grok/profile.ts new file mode 100644 index 000000000..23ec32b2b --- /dev/null +++ b/src/supervisor/agents/grok/profile.ts @@ -0,0 +1,20 @@ +import type { ProjectLocation } from "@/shared/contracts"; +import { + homeProfileEnvForLocation, + resolveAgentInstanceEnv, + resolveHomeProfilePathForLocation, +} from "../homeProfile"; + +export const resolveGrokHomeForLocation = resolveHomeProfilePathForLocation; +export const resolveGrokInstanceEnv = resolveAgentInstanceEnv; + +export function grokProfileEnvForLocation( + homeDir: string | undefined, + customEnv: Record | undefined, + location: ProjectLocation, +): Record | undefined { + return homeProfileEnvForLocation(homeDir, customEnv, location, "GROK_HOME", [ + "GROK_API_KEY", + "XAI_API_KEY", + ]); +} diff --git a/src/supervisor/agents/grok/sessionFiles.test.ts b/src/supervisor/agents/grok/sessionFiles.test.ts index 061b3842f..062e1d3ec 100644 --- a/src/supervisor/agents/grok/sessionFiles.test.ts +++ b/src/supervisor/agents/grok/sessionFiles.test.ts @@ -3,7 +3,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { ProjectLocation } from "@/shared/contracts"; -import { grokSessionDirMaterialized, resolveGrokSessionArg } from "./sessionFiles"; +import { + createGrokSessionTracker, + discoverGrokSessionRef, + grokSessionDirMaterialized, + resolveGrokSessionArg, + snapshotGrokPreSpawnSessions, +} from "./sessionFiles"; const SESSION_ID = "11111111-2222-4333-8444-555555555555"; @@ -51,4 +57,44 @@ describe("grok session materialization (native)", () => { sessionId: SESSION_ID, }); }); + + it("uses an explicit profile home instead of the process-wide GROK_HOME", () => { + const profileHome = mkdtempSync(join(tmpdir(), "grok-profile-home-")); + try { + mkdirSync(join(profileHome, "sessions", encodeURIComponent(projectDir), SESSION_ID), { + recursive: true, + }); + expect(grokSessionDirMaterialized(location, projectDir, SESSION_ID, profileHome)).toBe(true); + expect(resolveGrokSessionArg(location, projectDir, SESSION_ID, profileHome)).toEqual({ + kind: "resume", + sessionId: SESSION_ID, + }); + } finally { + rmSync(profileHome, { recursive: true, force: true }); + } + }); + + it("keeps pre-spawn snapshots isolated between adapter trackers", async () => { + const profileHome = mkdtempSync(join(tmpdir(), "grok-profile-home-")); + const sessionsDir = join(profileHome, "sessions", encodeURIComponent(projectDir)); + const oldSession = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const newSession = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + const firstTracker = createGrokSessionTracker(); + const secondTracker = createGrokSessionTracker(); + try { + mkdirSync(join(sessionsDir, oldSession), { recursive: true }); + snapshotGrokPreSpawnSessions(location, projectDir, profileHome, firstTracker); + mkdirSync(join(sessionsDir, newSession), { recursive: true }); + snapshotGrokPreSpawnSessions(location, projectDir, profileHome, secondTracker); + + await expect( + discoverGrokSessionRef(location, projectDir, profileHome, firstTracker), + ).resolves.toMatchObject({ providerSessionId: newSession }); + await expect( + discoverGrokSessionRef(location, projectDir, profileHome, secondTracker), + ).resolves.toBeUndefined(); + } finally { + rmSync(profileHome, { recursive: true, force: true }); + } + }); }); diff --git a/src/supervisor/agents/grok/sessionFiles.ts b/src/supervisor/agents/grok/sessionFiles.ts index 3480996db..16a232e24 100644 --- a/src/supervisor/agents/grok/sessionFiles.ts +++ b/src/supervisor/agents/grok/sessionFiles.ts @@ -13,49 +13,60 @@ import { import type { GrokSessionArg } from "./argv"; // Honor the same GROK_HOME override the CLI (and grokCredentials.ts) support. -function getNativeGrokSessionsRoot(): string { - const grokHome = process.env["GROK_HOME"]; +function getNativeGrokSessionsRoot(grokHomeOverride?: string): string { + const grokHome = grokHomeOverride ?? process.env["GROK_HOME"]; return join( grokHome && grokHome.trim().length > 0 ? grokHome : join(homedir(), ".grok"), "sessions", ); } -// Module-level snapshot captured right before we spawn a PTY for a fresh Grok launch. -// This lets discoverSessionRef reliably identify the *new* session dir that Grok -// creates for this particular launch (instead of an older one for the same cwd). -let preSpawnSessionIds = new Set(); -let preSpawnCwdKey: string | null = null; +export interface GrokSessionTracker { + preSpawnSessionIds: Set; + preSpawnCwdKey: string | null; +} + +export function createGrokSessionTracker(): GrokSessionTracker { + return { preSpawnSessionIds: new Set(), preSpawnCwdKey: null }; +} + +const defaultSessionTracker = createGrokSessionTracker(); function encodeCwdKey(cwd: string): string { // Grok stores sessions under ~/.grok/sessions// return encodeURIComponent(cwd); } -function getGrokCwdSessionsDir(location: ProjectLocation, cwd: string): string | null { +function getGrokCwdSessionsDir( + location: ProjectLocation, + cwd: string, + grokHome?: string, +): string | null { if (location.kind === "wsl") { - const home = getCachedWslHomeDirectory(location.distro); - if (!home) return null; - return `${home}/.grok/sessions/${encodeCwdKey(cwd)}`; + const root = grokHome ?? getCachedWslHomeDirectory(location.distro); + if (!root) return null; + return `${root}${grokHome ? "" : "/.grok"}/sessions/${encodeCwdKey(cwd)}`; } - return join(getNativeGrokSessionsRoot(), encodeCwdKey(cwd)); + return join(getNativeGrokSessionsRoot(grokHome), encodeCwdKey(cwd)); } async function getGrokCwdSessionsDirAsync( location: ProjectLocation, cwd: string, + grokHome?: string, ): Promise { - if (location.kind !== "wsl") return getGrokCwdSessionsDir(location, cwd); + if (location.kind !== "wsl" || grokHome) return getGrokCwdSessionsDir(location, cwd, grokHome); const home = await resolveWslHomeDirectoryAsync(location.distro); return home ? `${home}/.grok/sessions/${encodeCwdKey(cwd)}` : null; } -function getGrokSessionsRoot(location: ProjectLocation): string | null { +function getGrokSessionsRoot(location: ProjectLocation, grokHome?: string): string | null { if (location.kind === "wsl") { + if (grokHome) return `${grokHome}/sessions`; const home = getCachedWslHomeDirectory(location.distro); return home ? `${home}/.grok/sessions` : null; } - return getNativeGrokSessionsRoot(); + return getNativeGrokSessionsRoot(grokHome); } /** @@ -66,22 +77,27 @@ function getGrokSessionsRoot(location: ProjectLocation): string | null { * Sync on native platforms only. WSL discovery uses the in-distro bridge * after launch instead of doing a direct pre-spawn query. */ -export function snapshotGrokPreSpawnSessions(location: ProjectLocation, cwd: string): void { - preSpawnSessionIds = new Set(); - preSpawnCwdKey = null; +export function snapshotGrokPreSpawnSessions( + location: ProjectLocation, + cwd: string, + grokHome?: string, + tracker: GrokSessionTracker = defaultSessionTracker, +): void { + tracker.preSpawnSessionIds = new Set(); + tracker.preSpawnCwdKey = null; if (location.kind === "wsl") return; - const dir = getGrokCwdSessionsDir(location, cwd); + const dir = getGrokCwdSessionsDir(location, cwd, grokHome); if (!dir) return; if (!existsSync(dir)) return; - preSpawnCwdKey = encodeCwdKey(cwd); + tracker.preSpawnCwdKey = encodeCwdKey(cwd); try { const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && isUuid(entry.name)) { - preSpawnSessionIds.add(entry.name); + tracker.preSpawnSessionIds.add(entry.name); } } } catch { @@ -111,11 +127,12 @@ export function grokSessionDirMaterialized( location: ProjectLocation, cwd: string, sessionId: string, + grokHome?: string, ): boolean | undefined { if (location.kind === "wsl") { - const home = getCachedWslHomeDirectory(location.distro); - if (!home) return undefined; - const linuxPath = `${home}/.grok/sessions/${encodeCwdKey(cwd)}/${sessionId}`; + const root = grokHome ?? getCachedWslHomeDirectory(location.distro); + if (!root) return undefined; + const linuxPath = `${root}${grokHome ? "" : "/.grok"}/sessions/${encodeCwdKey(cwd)}/${sessionId}`; const uncPath = `\\\\wsl.localhost\\${location.distro}${linuxPath.replaceAll("/", "\\")}`; try { return existsSync(uncPath); @@ -123,7 +140,7 @@ export function grokSessionDirMaterialized( return undefined; } } - const dir = getGrokCwdSessionsDir(location, cwd); + const dir = getGrokCwdSessionsDir(location, cwd, grokHome); if (!dir) return undefined; return existsSync(join(dir, sessionId)); } @@ -139,8 +156,9 @@ export function resolveGrokSessionArg( location: ProjectLocation, cwd: string, knownSessionId: string, + grokHome?: string, ): GrokSessionArg { - const materialized = grokSessionDirMaterialized(location, cwd, knownSessionId); + const materialized = grokSessionDirMaterialized(location, cwd, knownSessionId, grokHome); return materialized === false ? { kind: "new", sessionId: knownSessionId } : { kind: "resume", sessionId: knownSessionId }; @@ -159,8 +177,10 @@ export function resolveGrokSessionArg( export async function discoverGrokSessionRef( location: ProjectLocation, cwd: string, + grokHome?: string, + tracker: GrokSessionTracker = defaultSessionTracker, ): Promise { - const dir = await getGrokCwdSessionsDirAsync(location, cwd); + const dir = await getGrokCwdSessionsDirAsync(location, cwd, grokHome); if (!dir) return undefined; const key = encodeCwdKey(cwd); @@ -170,7 +190,7 @@ export async function discoverGrokSessionRef( const candidateNames = entries .filter((e) => e.type === "directory" && isUuid(e.name)) .map((e) => e.name) - .filter((name) => !(preSpawnCwdKey === key && preSpawnSessionIds.has(name))); + .filter((name) => !(tracker.preSpawnCwdKey === key && tracker.preSpawnSessionIds.has(name))); if (candidateNames.length === 0) return undefined; @@ -184,10 +204,13 @@ export async function discoverGrokSessionRef( return winner ? createKnownSessionRef(winner.id) : undefined; } -export function makeGrokDiscoverSessionRef() { +export function makeGrokDiscoverSessionRef( + resolveGrokHome?: (location: ProjectLocation) => string | undefined, + tracker: GrokSessionTracker = defaultSessionTracker, +) { return async (location: ProjectLocation): Promise => { const cwd = location.kind === "wsl" ? location.linuxPath : location.path; - return discoverGrokSessionRef(location, cwd); + return discoverGrokSessionRef(location, cwd, resolveGrokHome?.(location), tracker); }; } @@ -198,20 +221,26 @@ export function makeGrokDiscoverSessionRef() { * root when that subdir doesn't exist yet so the first session creation * still wakes the watcher. */ -export function resolveGrokSessionsWatchPaths(location: ProjectLocation, cwd: string): string[] { - const dir = getGrokCwdSessionsDir(location, cwd); +export function resolveGrokSessionsWatchPaths( + location: ProjectLocation, + cwd: string, + grokHome?: string, +): string[] { + const dir = getGrokCwdSessionsDir(location, cwd, grokHome); if (location.kind === "wsl") { - const root = getGrokSessionsRoot(location); + const root = getGrokSessionsRoot(location, grokHome); return [dir ?? undefined, root ?? undefined].filter((p): p is string => Boolean(p)); } if (dir && existsSync(dir)) return [dir]; - return [getNativeGrokSessionsRoot()]; + return [getNativeGrokSessionsRoot(grokHome)]; } -export function makeGrokWatchSessionRef() { +export function makeGrokWatchSessionRef( + resolveGrokHome?: (location: ProjectLocation) => string | undefined, +) { return (location: ProjectLocation, onChanged: () => void): (() => void) | undefined => { const cwd = location.kind === "wsl" ? location.linuxPath : location.path; - const paths = resolveGrokSessionsWatchPaths(location, cwd); + const paths = resolveGrokSessionsWatchPaths(location, cwd, resolveGrokHome?.(location)); if (paths.length === 0) return undefined; const label = `grok:${location.kind === "wsl" ? "wsl:" + location.distro : location.kind}`; diff --git a/src/supervisor/agents/homeProfile.ts b/src/supervisor/agents/homeProfile.ts new file mode 100644 index 000000000..ebe4f939d --- /dev/null +++ b/src/supervisor/agents/homeProfile.ts @@ -0,0 +1,80 @@ +import { homedir } from "node:os"; +import path from "node:path"; +import posixPath from "node:path/posix"; +import type { + AgentInstanceConfig, + AgentStatus, + AgentTerminalAuthMethod, + ProjectLocation, +} from "@/shared/contracts"; +import { resolveWslHomeDirectory } from "./base"; + +export function resolveNativeHomeProfilePath(rawHomeDir: string): string { + const trimmed = rawHomeDir.trim(); + if (trimmed !== "~" && !trimmed.startsWith("~/")) { + return path.isAbsolute(trimmed) || path.win32.isAbsolute(trimmed) + ? trimmed + : path.join(homedir(), trimmed); + } + const suffix = trimmed === "~" ? "" : trimmed.slice(2); + return path.join(homedir(), suffix); +} + +export function resolveHomeProfilePathForLocation( + rawHomeDir: string, + location: ProjectLocation, +): string { + if (location.kind !== "wsl") return resolveNativeHomeProfilePath(rawHomeDir); + const home = resolveWslHomeDirectory(location.distro); + if (!home) throw new Error(`Unable to resolve the WSL home directory for ${location.distro}.`); + const trimmed = rawHomeDir.trim(); + if (trimmed !== "~" && !trimmed.startsWith("~/")) { + return posixPath.isAbsolute(trimmed) ? trimmed : posixPath.join(home, trimmed); + } + const suffix = trimmed === "~" ? "" : trimmed.slice(2); + return posixPath.join(home, suffix); +} + +export function resolveAgentInstanceEnv( + environment: AgentInstanceConfig["environment"], +): Record | undefined { + if (!environment) return undefined; + const env = Object.fromEntries( + Object.entries(environment) + .filter(([name]) => name.trim().length > 0) + .map(([name, variable]) => [name, variable.value]), + ); + return Object.keys(env).length > 0 ? env : undefined; +} + +export function homeProfileEnvForLocation( + homeDir: string | undefined, + customEnv: Record | undefined, + location: ProjectLocation, + homeVariable: string, + credentialVariables: readonly string[], +): Record | undefined { + const env: Record = {}; + if (homeDir) { + for (const name of credentialVariables) env[name] = ""; + } + Object.assign(env, customEnv); + if (homeDir) env[homeVariable] = resolveHomeProfilePathForLocation(homeDir, location); + return Object.keys(env).length > 0 ? env : undefined; +} + +export function withTerminalAuthEnv( + status: AgentStatus, + env: Record | undefined, + fallbackMethod: AgentTerminalAuthMethod, +): AgentStatus { + if (!env || !status.loginCommand) return status; + const methods: NonNullable = (status.authMethods ?? []).map( + (method) => + method.type === "terminal" ? { ...method, env: { ...method.env, ...env } } : method, + ); + if (!methods.some((method) => method.type === "terminal")) { + methods.push({ ...fallbackMethod, env }); + } + return { ...status, authMethods: methods }; +} diff --git a/src/supervisor/agents/homeProfileUsage.ts b/src/supervisor/agents/homeProfileUsage.ts new file mode 100644 index 000000000..4656505cb --- /dev/null +++ b/src/supervisor/agents/homeProfileUsage.ts @@ -0,0 +1,100 @@ +import { + collectCodex, + collectCopilot, + collectGemini, + collectGrok, + type HostPort, + type OAuthToken, + type UsageSnapshot, +} from "@poracode/agents-usage"; +import { + homeProfileKind, + isHomeProfileDriver, + parseHomeProfileInstanceConfig, + type HomeProfileDriver, +} from "@/shared/contracts"; +import type { SharedSettings } from "@/shared/settings"; +import { resolveCodexToken } from "../runtime/codexCredentials"; +import { resolveCopilotToken } from "../runtime/copilotCredentials"; +import { resolveGeminiToken } from "../runtime/geminiCredentials"; +import { resolveGrokToken } from "../runtime/grokCredentials"; +import { resolveNativeHomeProfilePath } from "./homeProfile"; + +export interface HomeUsageProfile { + providerId: string; + driver: HomeProfileDriver; + homeDir: string; +} + +interface HomeUsageProfileSpec { + collect(host: HostPort): Promise; + resolveToken(homeDir: string): Promise; +} + +const HOME_USAGE_PROFILE_SPECS: Record = { + codex: { + collect: collectCodex, + resolveToken: (homeDir) => resolveCodexToken({ CODEX_HOME: homeDir }), + }, + copilot: { + collect: collectCopilot, + resolveToken: (homeDir) => resolveCopilotToken({ COPILOT_HOME: homeDir }), + }, + gemini: { + collect: collectGemini, + resolveToken: (homeDir) => resolveGeminiToken({ GEMINI_CLI_HOME: homeDir }), + }, + grok: { + collect: collectGrok, + resolveToken: (homeDir) => resolveGrokToken({ GROK_HOME: homeDir }), + }, +}; + +export function readHomeUsageProfiles(settings: SharedSettings): Map { + const profiles = new Map(); + for (const instance of Object.values(settings.agentInstances)) { + if (instance.enabled === false || !isHomeProfileDriver(instance.driver)) continue; + try { + const config = parseHomeProfileInstanceConfig(instance.config); + const providerId = homeProfileKind(instance.driver, instance.id); + profiles.set(providerId, { + providerId, + driver: instance.driver, + homeDir: resolveNativeHomeProfilePath(config.homeDir), + }); + } catch { + // Malformed profile records are ignored by the agent registry too. + } + } + return profiles; +} + +export async function collectHomeProfile( + profile: HomeUsageProfile, + host: HostPort, +): Promise { + const now = host.now(); + const spec = HOME_USAGE_PROFILE_SPECS[profile.driver]; + const scopedHost: HostPort = { + http: host.http, + now: () => host.now(), + credentials: { + getOAuthToken: () => spec.resolveToken(profile.homeDir), + getSecret: async () => undefined, + }, + ...(host.clientVersions ? { clientVersions: host.clientVersions } : {}), + ...(host.log ? { log: host.log } : {}), + }; + try { + const snapshot = await spec.collect(scopedHost); + return { ...snapshot, providerId: profile.providerId }; + } catch (error) { + return { + providerId: profile.providerId, + status: "error", + windows: [], + fetchedAt: now, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/src/supervisor/agents/registry.test.ts b/src/supervisor/agents/registry.test.ts index 9c0460b63..6ab99ba64 100644 --- a/src/supervisor/agents/registry.test.ts +++ b/src/supervisor/agents/registry.test.ts @@ -1,7 +1,7 @@ import { existsSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createAgentRegistry } from "./registry"; +import { buildAgentRegistry, createAgentRegistry } from "./registry"; import { buildUnrestrictedChildConfig } from "@/supervisor/subagentMcp/types"; const EXPECTED_BUILT_IN_ORDER = [ @@ -79,3 +79,40 @@ describe("built-in agent registry", () => { }, ); }); + +describe("profile agent registry", () => { + it("registers every supported home-isolated profile as a synthetic provider", () => { + const adapters = buildAgentRegistry( + (["codex", "copilot", "gemini", "grok"] as const).map((driver) => ({ + id: `${driver}-work`, + driver, + displayName: "Work", + config: { homeDir: `~/.poracode/${driver}-profiles/work` }, + })), + ); + + expect(adapters.map((adapter) => adapter.kind)).toEqual( + expect.arrayContaining([ + "codex:codex-work", + "copilot:copilot-work", + "gemini:gemini-work", + "grok:grok-work", + ]), + ); + }); + + it("skips malformed and disabled profiles", () => { + const adapters = buildAgentRegistry([ + { id: "bad", driver: "codex", config: {} }, + { + id: "disabled", + driver: "grok", + enabled: false, + config: { homeDir: "~/.poracode/grok-profiles/disabled" }, + }, + ]); + + expect(adapters.some((adapter) => adapter.kind === "codex:bad")).toBe(false); + expect(adapters.some((adapter) => adapter.kind === "grok:disabled")).toBe(false); + }); +}); diff --git a/src/supervisor/agents/registry.ts b/src/supervisor/agents/registry.ts index 1531fd7a0..44d0448c8 100644 --- a/src/supervisor/agents/registry.ts +++ b/src/supervisor/agents/registry.ts @@ -7,9 +7,9 @@ * omits one. Renderer metadata and native install wiring remain separate; see * .agents/docs/agent-adapters.md → "Adding a New Provider — Full Checklist". * - * For runtime-extensible ACP-speaking agents, pass `userInstances` to - * `buildAgentRegistry` — each `acp-generic` instance becomes a discrete - * adapter via `createAcpGenericAdapter`. + * Pass `userInstances` to `buildAgentRegistry` for provider profiles and + * runtime-extensible ACP agents; each enabled instance becomes a discrete + * adapter. */ import type { AgentInstanceConfig } from "@/shared/contracts"; import { createAcpGenericAdapter } from "./acp-generic"; @@ -17,14 +17,27 @@ import { createAntigravityAdapter } from "./antigravity"; import type { AgentAdapter } from "./base"; import { createClaudeAdapter, createClaudeProfileAdapter } from "./claude"; import { createCommandCodeAdapter } from "./commandcode"; -import { createCopilotAdapter } from "./copilot"; -import { createCodexAdapter } from "./codex"; +import { createCopilotAdapter, createCopilotProfileAdapter } from "./copilot"; +import { createCodexAdapter, createCodexProfileAdapter } from "./codex"; import { createCursorAdapter } from "./cursor"; import { createFactoryAdapter } from "./factory"; -import { createGeminiAdapter } from "./gemini"; -import { createGrokAdapter } from "./grok"; +import { createGeminiAdapter, createGeminiProfileAdapter } from "./gemini"; +import { createGrokAdapter, createGrokProfileAdapter } from "./grok"; import { createOpenCodeAdapter } from "./opencode"; +type ProfileAdapterFactory = { + label: string; + create(instance: AgentInstanceConfig): AgentAdapter; +}; + +const PROFILE_ADAPTER_FACTORIES: Readonly> = { + claude: { label: "Claude", create: createClaudeProfileAdapter }, + codex: { label: "Codex", create: createCodexProfileAdapter }, + copilot: { label: "Copilot", create: createCopilotProfileAdapter }, + gemini: { label: "Gemini", create: createGeminiProfileAdapter }, + grok: { label: "Grok", create: createGrokProfileAdapter }, +}; + export function createAgentRegistry(): AgentAdapter[] { return buildAgentRegistry([]); } @@ -50,21 +63,22 @@ export function buildAgentRegistry(userInstances: AgentInstanceConfig[]): AgentA const userAdapters = userInstances .filter((inst) => inst.enabled !== false && inst.driver === "acp-generic") .map((inst) => createAcpGenericAdapter(inst)); - const claudeProfileAdapters = userInstances - .filter((inst) => inst.enabled !== false && inst.driver === "claude") - .flatMap((inst) => { - try { - return [createClaudeProfileAdapter(inst)]; - } catch (error) { - console.warn( - `[agents] skipping Claude profile ${inst.id}: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - return []; - } - }); - const adapters = [...builtIns, ...claudeProfileAdapters, ...userAdapters]; + const profileAdapters = userInstances.flatMap((inst) => { + if (inst.enabled === false) return []; + const factory = PROFILE_ADAPTER_FACTORIES[inst.driver]; + if (!factory) return []; + try { + return [factory.create(inst)]; + } catch (error) { + console.warn( + `[agents] skipping ${factory.label} profile ${inst.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return []; + } + }); + const adapters = [...builtIns, ...profileAdapters, ...userAdapters]; const kinds = new Set(adapters.map((a) => a.kind)); if (kinds.size !== adapters.length) { throw new Error("Duplicate agent kind in registry"); diff --git a/src/supervisor/runtime/agentRegistryService.ts b/src/supervisor/runtime/agentRegistryService.ts index 1b3fa8cf9..e49c37279 100644 --- a/src/supervisor/runtime/agentRegistryService.ts +++ b/src/supervisor/runtime/agentRegistryService.ts @@ -17,7 +17,11 @@ import type { ResolveAgentAccountResult, RemoveAcpRegistryAgentPayload, } from "@/shared/contracts"; -import { acpGenericKind, extractAcpGenericInstanceId } from "@/shared/contracts"; +import { + acpGenericKind, + agentKindsSharingBinary, + extractAcpGenericInstanceId, +} from "@/shared/contracts"; import { verifyAcpGenericAuthentication } from "../agents/acp-generic"; import { dispatchAcpAuthenticate, @@ -135,18 +139,25 @@ export class AgentRegistryService { } } - private async refreshAffectedAgentStatus(agentKind: string): Promise { + private async refreshAffectedAgentStatuses(agentKinds: readonly string[]): Promise { try { const wslDistros = await this.agentStatusService.listWslDistros(); await this.agentStatusService.refreshAgentStatuses({ wslDistros, - scope: { agentKinds: [agentKind] }, + scope: { agentKinds: [...agentKinds] }, }); } catch (error) { - console.warn(`[supervisor] refreshAffectedAgentStatus failed for ${agentKind}`, error); + console.warn( + `[supervisor] refreshAffectedAgentStatuses failed for ${agentKinds.join(", ")}`, + error, + ); } } + private refreshAffectedAgentStatus(agentKind: string): Promise { + return this.refreshAffectedAgentStatuses([agentKind]); + } + async getAgentStatuses(payload: GetAgentStatusesPayload): Promise { this.deps.sharedSettingsCache.invalidate(); this.refreshAgentRegistryAdapters(); @@ -248,7 +259,9 @@ export class AgentRegistryService { // the new binary can land at a different prefix and the cached entry // would resolve to a stale shim. clearAgentBinaryPathCache(); - await this.refreshAffectedAgentStatus(payload.agentKind); + await this.refreshAffectedAgentStatuses( + agentKindsSharingBinary(payload.agentKind, this.deps.adapters.keys()), + ); } return result; } diff --git a/src/supervisor/runtime/codexCredentials.ts b/src/supervisor/runtime/codexCredentials.ts index da8f8d618..53a187049 100644 --- a/src/supervisor/runtime/codexCredentials.ts +++ b/src/supervisor/runtime/codexCredentials.ts @@ -31,15 +31,21 @@ export function parseCodexAuth(content: string): OAuthToken | undefined { }; } -function codexAuthFilePath(): string { - const home = process.env.CODEX_HOME?.trim(); +export interface CodexCredentialEnv { + CODEX_HOME?: string | undefined; +} + +function codexAuthFilePath(env: CodexCredentialEnv): string { + const home = env.CODEX_HOME?.trim(); return home ? join(home, "auth.json") : join(homedir(), ".codex", "auth.json"); } -export async function resolveCodexToken(): Promise { +export async function resolveCodexToken( + env: CodexCredentialEnv = process.env, +): Promise { // Read fresh every call — the access token is a short-lived JWT the Codex CLI // refreshes (~5 min); a cached Bearer would go stale and 401. - const path = codexAuthFilePath(); + const path = codexAuthFilePath(env); if (existsSync(path)) { try { const token = parseCodexAuth(readFileSync(path, "utf8")); @@ -48,7 +54,7 @@ export async function resolveCodexToken(): Promise { // fall through to the WSL fallback } } - if (process.platform === "win32") { + if (!env.CODEX_HOME?.trim() && process.platform === "win32") { const blob = await readCodexAuthFromWsl(); if (blob) { const token = parseCodexAuth(blob); diff --git a/src/supervisor/runtime/copilotCredentials.ts b/src/supervisor/runtime/copilotCredentials.ts index fe34a410d..c378698bb 100644 --- a/src/supervisor/runtime/copilotCredentials.ts +++ b/src/supervisor/runtime/copilotCredentials.ts @@ -18,16 +18,22 @@ interface CopilotConfig { lastLoggedInUser?: { host?: string; login?: string }; } -function copilotTokenFromEnv(): string | undefined { +export interface CopilotCredentialEnv { + COPILOT_HOME?: string | undefined; + COPILOT_GITHUB_TOKEN?: string | undefined; + COPILOT_API_TOKEN?: string | undefined; +} + +function copilotTokenFromEnv(env: CopilotCredentialEnv): string | undefined { for (const name of COPILOT_TOKEN_ENV_VARS) { - const value = process.env[name]?.trim(); + const value = env[name]?.trim(); if (value) return value; } return undefined; } -function copilotConfigPath(): string { - const home = process.env.COPILOT_HOME?.trim(); +function copilotConfigPath(env: CopilotCredentialEnv): string { + const home = env.COPILOT_HOME?.trim(); return home ? join(home, "config.json") : join(homedir(), ".copilot", "config.json"); } @@ -44,8 +50,8 @@ export function copilotCredentialTargetFromConfig(content: string): string | und return `copilot-cli/${host}:${login}`; } -async function resolveCopilotCliToken(): Promise { - const path = copilotConfigPath(); +async function resolveCopilotCliToken(env: CopilotCredentialEnv): Promise { + const path = copilotConfigPath(env); if (!existsSync(path)) return undefined; try { const target = copilotCredentialTargetFromConfig(readFileSync(path, "utf8")); @@ -57,14 +63,16 @@ async function resolveCopilotCliToken(): Promise { } } -export async function resolveCopilotToken(): Promise { - const fromEnv = copilotTokenFromEnv(); +export async function resolveCopilotToken( + env: CopilotCredentialEnv = process.env, +): Promise { + const fromEnv = copilotTokenFromEnv(env); if (fromEnv) return { accessToken: fromEnv }; - const fromCopilotCli = await resolveCopilotCliToken(); + const fromCopilotCli = await resolveCopilotCliToken(env); if (fromCopilotCli) return fromCopilotCli; // Signed in only inside WSL? `gh auth token` works regardless of which env // fetches with it, matching the other providers' native→WSL fallback. - if (process.platform === "win32") { + if (!env.COPILOT_HOME?.trim() && process.platform === "win32") { const wslToken = await readCopilotTokenFromWsl(); if (wslToken) return { accessToken: wslToken }; } diff --git a/src/supervisor/runtime/geminiCredentials.ts b/src/supervisor/runtime/geminiCredentials.ts index c4980e503..9072cddd4 100644 --- a/src/supervisor/runtime/geminiCredentials.ts +++ b/src/supervisor/runtime/geminiCredentials.ts @@ -53,9 +53,18 @@ export function parseGeminiCreds(content: string): OAuthToken | undefined { }; } -function geminiCredsFilePath(): string { - const home = process.env.GEMINI_HOME?.trim(); - return home ? join(home, "oauth_creds.json") : join(homedir(), ".gemini", "oauth_creds.json"); +export interface GeminiCredentialEnv { + GEMINI_CLI_HOME?: string | undefined; + GEMINI_HOME?: string | undefined; +} + +function geminiCredsFilePath(env: GeminiCredentialEnv): string { + const cliHome = env.GEMINI_CLI_HOME?.trim(); + if (cliHome) return join(cliHome, ".gemini", "oauth_creds.json"); + const legacyHome = env.GEMINI_HOME?.trim(); + return legacyHome + ? join(legacyHome, "oauth_creds.json") + : join(homedir(), ".gemini", "oauth_creds.json"); } const GEMINI_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; @@ -179,8 +188,10 @@ async function withFreshGeminiToken(token: OAuthToken): Promise { return refreshed ? { ...token, ...refreshed } : token; } -export async function resolveGeminiToken(): Promise { - const path = geminiCredsFilePath(); +export async function resolveGeminiToken( + env: GeminiCredentialEnv = process.env, +): Promise { + const path = geminiCredsFilePath(env); if (existsSync(path)) { try { const token = parseGeminiCreds(readFileSync(path, "utf8")); @@ -189,7 +200,7 @@ export async function resolveGeminiToken(): Promise { // fall through to the WSL fallback } } - if (process.platform === "win32") { + if (!env.GEMINI_CLI_HOME?.trim() && !env.GEMINI_HOME?.trim() && process.platform === "win32") { const blob = await readGeminiCredsFromWsl(); if (blob) { const token = parseGeminiCreds(blob); diff --git a/src/supervisor/runtime/grokCredentials.ts b/src/supervisor/runtime/grokCredentials.ts index 4b1a58f40..e44dfec51 100644 --- a/src/supervisor/runtime/grokCredentials.ts +++ b/src/supervisor/runtime/grokCredentials.ts @@ -40,13 +40,19 @@ export function parseGrokAuth(content: string): OAuthToken | undefined { return undefined; } -function grokAuthFilePath(): string { - const home = process.env.GROK_HOME?.trim(); +export interface GrokCredentialEnv { + GROK_HOME?: string | undefined; +} + +function grokAuthFilePath(env: GrokCredentialEnv): string { + const home = env.GROK_HOME?.trim(); return home ? join(home, "auth.json") : join(homedir(), ".grok", "auth.json"); } -export async function resolveGrokToken(): Promise { - const path = grokAuthFilePath(); +export async function resolveGrokToken( + env: GrokCredentialEnv = process.env, +): Promise { + const path = grokAuthFilePath(env); if (existsSync(path)) { try { const token = parseGrokAuth(readFileSync(path, "utf8")); @@ -55,7 +61,7 @@ export async function resolveGrokToken(): Promise { // fall through to the WSL fallback } } - if (process.platform === "win32") { + if (!env.GROK_HOME?.trim() && process.platform === "win32") { const blob = await readGrokAuthFromWsl(); if (blob) { const token = parseGrokAuth(blob); diff --git a/src/supervisor/runtime/usageCredentials.test.ts b/src/supervisor/runtime/usageCredentials.test.ts index b43d3bd5c..507ea4e82 100644 --- a/src/supervisor/runtime/usageCredentials.test.ts +++ b/src/supervisor/runtime/usageCredentials.test.ts @@ -1,14 +1,32 @@ -import { describe, expect, it } from "vitest"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { parseClaudeCredentials } from "./claudeCredentials"; import { claudeKeychainAccount, claudeKeychainServiceNames } from "./macClaudeKeychain"; -import { parseCodexAuth } from "./codexCredentials"; -import { copilotCredentialTargetFromConfig } from "./copilotCredentials"; +import { parseCodexAuth, resolveCodexToken } from "./codexCredentials"; +import { copilotCredentialTargetFromConfig, resolveCopilotToken } from "./copilotCredentials"; import { CURSOR_CLI_KEYCHAIN_ACCOUNT, CURSOR_CLI_KEYCHAIN_SERVICE, cursorUserIdFromJwt, parseCursorCliEmail, } from "./cursorCredentials"; +import { parseGeminiCreds, resolveGeminiToken } from "./geminiCredentials"; +import { parseGrokAuth, resolveGrokToken } from "./grokCredentials"; + +const tempPaths: string[] = []; + +function tempProfileDir(name: string): string { + const path = join(tmpdir(), `poracode-${name}-${process.pid}-${tempPaths.length}`); + tempPaths.push(path); + mkdirSync(path, { recursive: true }); + return path; +} + +afterEach(() => { + for (const path of tempPaths.splice(0)) rmSync(path, { force: true, recursive: true }); +}); /** Build a JWT-shaped token whose payload carries the given claims. */ function fakeJwt(claims: Record): string { @@ -136,6 +154,20 @@ describe("parseCodexAuth", () => { expect(parseCodexAuth(JSON.stringify({ OPENAI_API_KEY: "sk-..." }))).toBeUndefined(); expect(parseCodexAuth("nope")).toBeUndefined(); }); + + it("reads credentials from an explicit profile home", async () => { + const home = tempProfileDir("codex-profile"); + writeFileSync( + join(home, "auth.json"), + JSON.stringify({ tokens: { access_token: "profile-access", account_id: "profile-id" } }), + "utf8", + ); + + await expect(resolveCodexToken({ CODEX_HOME: home })).resolves.toMatchObject({ + accessToken: "profile-access", + accountId: "profile-id", + }); + }); }); describe("copilotCredentialTargetFromConfig", () => { @@ -148,4 +180,47 @@ describe("copilotCredentialTargetFromConfig", () => { ), ).toBe("copilot-cli/https://github.com:octo-dev"); }); + + it("uses explicitly scoped profile environment tokens", async () => { + await expect( + resolveCopilotToken({ + COPILOT_HOME: tempProfileDir("copilot-profile"), + COPILOT_GITHUB_TOKEN: "profile-token", + }), + ).resolves.toEqual({ accessToken: "profile-token" }); + }); +}); + +describe("profile-scoped Gemini credentials", () => { + it("reads .gemini/oauth_creds.json beneath GEMINI_CLI_HOME", async () => { + const home = tempProfileDir("gemini-profile"); + mkdirSync(join(home, ".gemini"), { recursive: true }); + writeFileSync( + join(home, ".gemini", "oauth_creds.json"), + JSON.stringify({ access_token: "profile-access", refresh_token: "profile-refresh" }), + "utf8", + ); + + expect(parseGeminiCreds("not json")).toBeUndefined(); + await expect(resolveGeminiToken({ GEMINI_CLI_HOME: home })).resolves.toMatchObject({ + accessToken: "profile-access", + refreshToken: "profile-refresh", + }); + }); +}); + +describe("profile-scoped Grok credentials", () => { + it("reads auth.json beneath GROK_HOME", async () => { + const home = tempProfileDir("grok-profile"); + writeFileSync( + join(home, "auth.json"), + JSON.stringify({ access_token: "profile-access" }), + "utf8", + ); + + expect(parseGrokAuth("not json")).toBeUndefined(); + await expect(resolveGrokToken({ GROK_HOME: home })).resolves.toEqual({ + accessToken: "profile-access", + }); + }); }); diff --git a/src/supervisor/runtime/usageService.test.ts b/src/supervisor/runtime/usageService.test.ts index e2cd11819..c8befe545 100644 --- a/src/supervisor/runtime/usageService.test.ts +++ b/src/supervisor/runtime/usageService.test.ts @@ -28,6 +28,14 @@ const CLAUDE_BODY = JSON.stringify({ seven_day: { utilization: 0.1 }, }); +const CODEX_BODY = JSON.stringify({ + plan_type: "plus", + rate_limit: { + primary_window: { used_percent: 30, window_minutes: 300 }, + secondary_window: { used_percent: 10, window_minutes: 10_080 }, + }, +}); + function makeHost(tokens: Record): HostPort { return { now: () => NOW, @@ -353,6 +361,67 @@ describe("UsageService", () => { expect(result.snapshots[0]?.windows.find((w) => w.id === "session-5h")?.usedPercent).toBe(0.4); }); + it("collects Codex profile usage from the isolated profile home", async () => { + const profileDir = join(tmpdir(), `poracode-usage-codex-profile-${process.pid}`); + cachePaths.push(profileDir); + mkdirSync(profileDir, { recursive: true }); + writeFileSync( + join(profileDir, "auth.json"), + JSON.stringify({ tokens: { access_token: "profile-token", account_id: "profile-account" } }), + "utf8", + ); + + const settingsPath = tempCachePath(); + writeFileSync( + settingsPath, + JSON.stringify({ + agentInstances: { + work: { + id: "work", + driver: "codex", + displayName: "Work", + config: { homeDir: profileDir }, + }, + }, + }), + "utf8", + ); + + let authorization: string | undefined; + let accountId: string | undefined; + const host: HostPort = { + now: () => NOW, + credentials: { + getOAuthToken: () => Promise.resolve(undefined), + getSecret: () => Promise.resolve(undefined), + }, + http: { + request: (request) => { + authorization = request.headers?.Authorization; + accountId = request.headers?.["ChatGPT-Account-Id"]; + return Promise.resolve({ status: 200, headers: {}, body: CODEX_BODY }); + }, + }, + }; + const service = new UsageService({ + emit: () => {}, + cachePath: tempCachePath(), + settingsPath, + host, + localCollectors: stubLocalCollectors(), + }); + + const result = await service.refreshProviderUsage({ providerIds: ["codex:work"] }); + + expect(authorization).toBe("Bearer profile-token"); + expect(accountId).toBe("profile-account"); + expect(result.snapshots[0]).toMatchObject({ + providerId: "codex:work", + status: "ok", + plan: "ChatGPT Plus", + }); + }); + it("does not re-poll a rate-limited provider until its Retry-After backoff clears", async () => { let now = NOW; let calls = 0; diff --git a/src/supervisor/runtime/usageService.ts b/src/supervisor/runtime/usageService.ts index c0debd6f1..b31ce5395 100644 --- a/src/supervisor/runtime/usageService.ts +++ b/src/supervisor/runtime/usageService.ts @@ -22,6 +22,11 @@ import { withClaudeEstimatedCost, type ClaudeUsageProfile, } from "../agents/claude/claudeUsageProfiles"; +import { + collectHomeProfile, + readHomeUsageProfiles, + type HomeUsageProfile, +} from "../agents/homeProfileUsage"; import { createLocalUsageCollectors, type LocalUsageCollector } from "./localUsageCollectors"; import { createNodeUsageHost } from "./usageHost"; @@ -68,6 +73,11 @@ interface UsageCacheFile { snapshots?: UsageSnapshot[]; } +interface UsageProfiles { + claude: Map; + home: Map; +} + function hasDisplayableUsage(snapshot: UsageSnapshot): boolean { return ( snapshot.windows.length > 0 || @@ -96,10 +106,10 @@ export class UsageService { this.loadCache(); } - private defaultProviderIds(): string[] { + private defaultProviderIds(profiles: UsageProfiles): string[] { const baseIds = [...(this.options.providerIds ?? DEFAULT_PROVIDER_IDS)]; if (this.options.providerIds) return baseIds; - return [...baseIds, ...this.claudeUsageProfiles().keys()]; + return [...baseIds, ...profiles.claude.keys(), ...profiles.home.keys()]; } /** Read shared settings from disk (defaults if absent). */ @@ -112,32 +122,37 @@ export class UsageService { } } - /** Read the usage policy from the shared settings file (defaults if absent). */ - private readUsageSettings(): UsageSettings { - return this.readSharedSettings().usage; - } - - private claudeUsageProfiles(): Map { - return readClaudeUsageProfiles(this.readSharedSettings()); + private usageProfiles(settings: SharedSettings): UsageProfiles { + return { + claude: readClaudeUsageProfiles(settings), + home: readHomeUsageProfiles(settings), + }; } /** A provider id this service can collect (package registry or supervisor-local). */ - private isSupported(id: string): boolean { + private isSupported(id: string, profiles: UsageProfiles): boolean { return ( - this.registry.has(id) || this.localCollectors.has(id) || this.claudeUsageProfiles().has(id) + this.registry.has(id) || + this.localCollectors.has(id) || + profiles.claude.has(id) || + profiles.home.has(id) ); } /** Default providers minus the user's per-provider opt-outs, intersected with what we support. */ - private enabledProviderIds(disabled: readonly string[]): string[] { - return this.defaultProviderIds().filter((id) => !disabled.includes(id) && this.isSupported(id)); + private enabledProviderIds(disabled: readonly string[], profiles: UsageProfiles): string[] { + return this.defaultProviderIds(profiles).filter( + (id) => !disabled.includes(id) && this.isSupported(id, profiles), + ); } private resolveIds(payload: ProviderUsagePayload): string[] { + const settings = this.readSharedSettings(); + const profiles = this.usageProfiles(settings); if (payload.providerIds?.length) { - return [...new Set(payload.providerIds)].filter((id) => this.isSupported(id)); + return [...new Set(payload.providerIds)].filter((id) => this.isSupported(id, profiles)); } - return this.enabledProviderIds(this.readUsageSettings().disabledProviders); + return this.enabledProviderIds(settings.usage.disabledProviders, profiles); } /** @@ -211,14 +226,18 @@ export class UsageService { } private async runRefresh(ids: string[]): Promise { - const claudeProfiles = this.claudeUsageProfiles(); + const settings = this.readSharedSettings(); + const profiles = this.usageProfiles(settings); + const claudeProfiles = profiles.claude; + const homeProfiles = profiles.home; const registryIds = ids.filter((id) => this.registry.has(id)); const localIds = ids.filter((id) => this.localCollectors.has(id)); const claudeProfileIds = ids.filter((id) => claudeProfiles.has(id)); + const homeProfileIds = ids.filter((id) => homeProfiles.has(id)); // The registry HTTP batch and the supervisor-local collectors are independent // of each other, so run both groups concurrently rather than waiting out the // (rate-limited, slow) HTTP batch before starting the local scans. - const [registrySnaps, localSnaps, claudeProfileSnaps] = await Promise.all([ + const [registrySnaps, localSnaps, claudeProfileSnaps, homeProfileSnaps] = await Promise.all([ this.registry.collectAll(registryIds, this.host), Promise.all(localIds.map((id) => this.collectLocal(id))), Promise.all( @@ -227,11 +246,20 @@ export class UsageService { return profile ? [collectClaudeProfile(profile, this.host)] : []; }), ), + Promise.all( + homeProfileIds.flatMap((id) => { + const profile = homeProfiles.get(id); + return profile ? [collectHomeProfile(profile, this.host)] : []; + }), + ), ]); - let snapshots = [...registrySnaps, ...localSnaps, ...claudeProfileSnaps].map((snap) => - this.preserveOnTransientFailure(snap), - ); - if (this.readUsageSettings().showEstimatedCost) { + let snapshots = [ + ...registrySnaps, + ...localSnaps, + ...claudeProfileSnaps, + ...homeProfileSnaps, + ].map((snap) => this.preserveOnTransientFailure(snap)); + if (settings.usage.showEstimatedCost) { snapshots = await this.withEstimatedCost(snapshots, claudeProfiles); } for (const snapshot of snapshots) { @@ -314,7 +342,8 @@ export class UsageService { */ startAutoRefresh(): void { if (this.autoRefreshTimer || this.stopped) return; - this.scheduleNextTick(this.nextTickDelayMs(this.readUsageSettings())); + const settings = this.readSharedSettings(); + this.scheduleNextTick(this.nextTickDelayMs(settings.usage, this.usageProfiles(settings))); } stop(): void { @@ -345,9 +374,9 @@ export class UsageService { * from the snapshot's `fetchedAt`, so one tick can refresh a fast provider * while leaving a slow one untouched. */ - private dueProviderIds(settings: UsageSettings): string[] { + private dueProviderIds(settings: UsageSettings, profiles: UsageProfiles): string[] { const now = this.host.now(); - return this.enabledProviderIds(settings.disabledProviders).filter((id) => { + return this.enabledProviderIds(settings.disabledProviders, profiles).filter((id) => { const snap = this.snapshots.get(id); if (!snap) return true; // Skip providers inside their rate-limit backoff so the auto-refresh tick @@ -363,9 +392,9 @@ export class UsageService { * are slower). Falls back to the global default when nothing is enabled, which * keeps the loop alive so re-enabling resumes without a restart. */ - private nextTickDelayMs(settings: UsageSettings): number { + private nextTickDelayMs(settings: UsageSettings, profiles: UsageProfiles): number { let min = Infinity; - for (const id of this.enabledProviderIds(settings.disabledProviders)) { + for (const id of this.enabledProviderIds(settings.disabledProviders, profiles)) { min = Math.min(min, this.effectiveIntervalMs(settings, id)); } return Number.isFinite(min) ? min : this.intervalMs(settings); @@ -378,9 +407,10 @@ export class UsageService { */ async refreshDueProviders(): Promise { if (this.stopped) return []; - const settings = this.readUsageSettings(); + const sharedSettings = this.readSharedSettings(); + const settings = sharedSettings.usage; if (!settings.autoRefresh) return []; - const ids = this.dueProviderIds(settings); + const ids = this.dueProviderIds(settings, this.usageProfiles(sharedSettings)); if (ids.length === 0) return []; try { await this.refreshProviderUsage({ providerIds: ids }); @@ -403,7 +433,8 @@ export class UsageService { await this.refreshDueProviders(); // Keep the loop alive even when auto-refresh is off so re-enabling (or an // interval change) resumes without a restart. - this.scheduleNextTick(this.nextTickDelayMs(this.readUsageSettings())); + const settings = this.readSharedSettings(); + this.scheduleNextTick(this.nextTickDelayMs(settings.usage, this.usageProfiles(settings))); } private loadCache(): void {