diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 3a4d3b11ca81..2a97dd5563bf 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -24,6 +24,7 @@ "../src/preload/runtime-environment-subscriptions.ts", "../src/preload/usage-provider-api.ts", "../src/shared/**/*", + "../src/main/claude-accounts/project-claude-account-resolution.ts", "../src/packages/process-host/src/__fixtures__/**/*", "../src/main/gitlab/mappers.ts", "../src/main/runtime/sidebar-inputs/sidebar-clock.ts", diff --git a/mobile/src/transport/rpc-acceptance-policies.test.ts b/mobile/src/transport/rpc-acceptance-policies.test.ts index 2f2abc90e166..0e0dea501822 100644 --- a/mobile/src/transport/rpc-acceptance-policies.test.ts +++ b/mobile/src/transport/rpc-acceptance-policies.test.ts @@ -4,6 +4,7 @@ import { isMethodNotFoundRefusal, isStreamingOpenerReply, requireRpcResultOrThrowCodedError, + requireRpcResultOrThrowMessage, rpcObjectResultOrNull } from './rpc-acceptance-policies' @@ -166,3 +167,18 @@ describe('isStreamingOpenerReply', () => { expect(isStreamingOpenerReply(response)).toBe(false) }) }) + +describe('requireRpcResultOrThrowMessage', () => { + it('shows a pinned Claude refusal without its renderer marker', () => { + const refusal: RpcResponse = { + id: 'rpc-1', + ok: false, + error: { + code: 'runtime_error', + message: 'Account a@b.c is in use. [claude_pinned:host-sessions email=a%40b.c terminals=1]' + }, + _meta: meta + } + expect(() => requireRpcResultOrThrowMessage(refusal)).toThrow(/^Account a@b\.c is in use\.$/) + }) +}) diff --git a/mobile/src/transport/rpc-acceptance-policies.ts b/mobile/src/transport/rpc-acceptance-policies.ts index 89e72f45468c..9631f91ee5c9 100644 --- a/mobile/src/transport/rpc-acceptance-policies.ts +++ b/mobile/src/transport/rpc-acceptance-policies.ts @@ -1,4 +1,5 @@ import type { RpcResponse, RpcSuccess } from './types' +import { stripClaudePinnedLaunchMarker } from '../../../src/shared/claude/claude-pinned-launch-error' // Named acceptance policies for RPC replies. Call sites used to hand-roll these // predicates and did not agree with each other; each policy here preserves one @@ -35,7 +36,7 @@ export function isStreamingOpenerReply( /** New-tab errors historically show the host message without its diagnostic code. */ export function requireRpcResultOrThrowMessage(response: RpcResponse): unknown { if (!response.ok) { - throw new Error(response.error.message) + throw new Error(stripClaudePinnedLaunchMarker(response.error.message)) } return response.result } diff --git a/src/cli/cli-error-claude-pinned.test.ts b/src/cli/cli-error-claude-pinned.test.ts new file mode 100644 index 000000000000..f29588dac153 --- /dev/null +++ b/src/cli/cli-error-claude-pinned.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest' +import { formatCliError } from './cli-error' +import { RuntimeRpcFailureError } from './runtime-client' + +it('prints a pinned Claude refusal without the renderer marker', () => { + const error = new RuntimeRpcFailureError({ + id: 'request-1', + ok: false, + error: { + code: 'runtime_error', + message: 'That Claude account no longer exists. [claude_pinned:account-missing]' + }, + _meta: { runtimeId: 'runtime-1' } + }) + const output = formatCliError(error) + expect(output).toContain('That Claude account no longer exists.') + expect(output).not.toContain('claude_pinned') +}) diff --git a/src/cli/cli-error.ts b/src/cli/cli-error.ts index e76dcf4d71eb..5925154afd41 100644 --- a/src/cli/cli-error.ts +++ b/src/cli/cli-error.ts @@ -4,6 +4,7 @@ import { stripAutomationOwnerConflictCode } from '../shared/automation-owner-conflict' import { automationOwnerConflictRecovery } from './automation-owner-conflict-recovery' +import { stripClaudePinnedLaunchMarker } from '../shared/claude/claude-pinned-launch-error' import { worktreeSelectorRecovery } from './worktree-selector-recovery' import { selectorAmbiguityRecovery } from './selector-ambiguity-recovery' import type { RuntimeRpcFailure } from './runtime-client' @@ -64,7 +65,9 @@ function errorCode(error: unknown): string | undefined { } export function formatCliError(error: unknown, context: CliErrorContext = {}): string { - const message = error instanceof Error ? error.message : String(error) + const message = stripClaudePinnedLaunchMarker( + error instanceof Error ? error.message : String(error) + ) const selector = contextRecovery(errorCode(error), context) if (selector) { return formatMessageWithNextSteps( diff --git a/src/cli/handlers/claude-launch-account-flag.ts b/src/cli/handlers/claude-launch-account-flag.ts new file mode 100644 index 000000000000..047e6b229d20 --- /dev/null +++ b/src/cli/handlers/claude-launch-account-flag.ts @@ -0,0 +1,38 @@ +import { getOptionalStringFlag } from '../flags' +import { RuntimeClientError, type RuntimeClient } from '../runtime-client' +import type { RuntimeStatus } from '../../shared/runtime-types' +import { CLAUDE_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY } from '../../shared/account-runtime-capabilities' + +/** + * `--account `: the managed Claude account a launch runs on, resolved by the host. + * + * Why the capability check: an older host strips the unknown field and would quietly start the + * agent on its active account — exactly what the caller asked not to happen. + */ +export async function readClaudeLaunchAccountFlag( + flags: Map, + client: RuntimeClient, + agent: string | undefined +): Promise { + if (!flags.has('account')) { + return undefined + } + const account = getOptionalStringFlag(flags, 'account')?.trim() + if (!account) { + throw new RuntimeClientError( + 'invalid_argument', + '--account requires a Claude account id or email.' + ) + } + if (agent !== 'claude') { + throw new RuntimeClientError('invalid_argument', '--account requires --agent claude.') + } + const status = await client.call('status.get') + if (!status.result.capabilities?.includes(CLAUDE_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY)) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'The connected Orca runtime does not support --account for Claude launches. Update or restart Orca and try again.' + ) + } + return account +} diff --git a/src/cli/handlers/orchestration-worker-account-cli.test.ts b/src/cli/handlers/orchestration-worker-account-cli.test.ts new file mode 100644 index 000000000000..7cef2d40babe --- /dev/null +++ b/src/cli/handlers/orchestration-worker-account-cli.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.fn() + +vi.mock('../format', () => ({ printResult: vi.fn() })) +vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() })) + +import { ORCHESTRATION_HANDLERS } from './orchestration' +import { CLAUDE_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY } from '../../shared/account-runtime-capabilities' + +describe('orchestration worker-start --account', () => { + beforeEach(() => { + callMock.mockReset() + process.exitCode = undefined + }) + + const invokeWorkerStart = (flags: Map) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: worker-start reads only flags, client.call, cwd and json. + ORCHESTRATION_HANDLERS['orchestration worker-start']({ + flags, + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + it('forwards --account to a host that resolves Claude launch accounts', async () => { + callMock + .mockResolvedValueOnce({ + result: { capabilities: [CLAUDE_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY] } + }) + .mockResolvedValueOnce({ + result: { runId: 'run_1', taskId: 'task_1', dispatchId: 'ctx_1', state: 'ready' } + }) + + await invokeWorkerStart( + new Map([ + ['task', 'task_1'], + ['agent', 'claude'], + ['account', 'pinned@example.com'], + ['from', 'term_coord'] + ]) + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'status.get') + expect(callMock).toHaveBeenNthCalledWith( + 2, + 'orchestration.workerStart', + expect.objectContaining({ agent: 'claude', account: 'pinned@example.com' }) + ) + }) + + it('fails before worker-start when --account would be stripped or misapplied', async () => { + callMock.mockResolvedValueOnce({ result: { capabilities: [] } }) + const flags = (agent: string) => + new Map([ + ['task', 'task_1'], + ['agent', agent], + ['account', 'acct-b'], + ['from', 'term_coord'] + ]) + + await expect(invokeWorkerStart(flags('claude'))).rejects.toMatchObject({ + code: 'incompatible_runtime' + }) + await expect(invokeWorkerStart(flags('codex'))).rejects.toThrow( + '--account requires --agent claude.' + ) + expect(callMock).toHaveBeenCalledTimes(1) + }) + + it('never sends an account field when --account is absent', async () => { + callMock.mockResolvedValue({ + result: { runId: 'run_1', taskId: 'task_1', dispatchId: 'ctx_1', state: 'ready' } + }) + await invokeWorkerStart( + new Map([ + ['task', 'task_1'], + ['agent', 'claude'], + ['from', 'term_coord'] + ]) + ) + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock.mock.calls[0]?.[1]).not.toHaveProperty('account') + }) + + it('refuses --account with --terminal before calling any host', async () => { + await expect( + invokeWorkerStart( + new Map([ + ['task', 'task_1'], + ['terminal', 'term_worker'], + ['account', 'acct-b'], + ['from', 'term_coord'] + ]) + ) + ).rejects.toMatchObject({ + code: 'invalid_argument', + message: + '--account cannot be combined with --terminal; the reused terminal keeps its own account.' + }) + expect(callMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/cli/handlers/orchestration/worker-launch-handler.ts b/src/cli/handlers/orchestration/worker-launch-handler.ts index b6e4d92799c2..0f59975afa45 100644 --- a/src/cli/handlers/orchestration/worker-launch-handler.ts +++ b/src/cli/handlers/orchestration/worker-launch-handler.ts @@ -10,6 +10,7 @@ import { isDevCliInvocation } from './runtime-compatibility' import { resolveCoordinatorTerminalHandle } from './terminal-identity' import { formatWorkerStart } from './worker-output' import { renderResolvedOrchestrationCommand } from '../../orchestration-mutation-recovery' +import { readClaudeLaunchAccountFlag } from '../claude-launch-account-flag' export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record = { 'orchestration worker-start': async ({ flags, client, cwd, json }) => { @@ -28,6 +29,17 @@ export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record ) } } + if (flags.has('account') && flags.has('terminal')) { + throw new RuntimeClientError( + 'invalid_argument', + '--account cannot be combined with --terminal; the reused terminal keeps its own account.' + ) + } + const account = await readClaudeLaunchAccountFlag( + flags, + client, + getOptionalStringFlag(flags, 'agent') + ) const task = getOptionalStringFlag(flags, 'task') const spec = getOptionalStringFlag(flags, 'spec') const taskTitle = getOptionalStringFlag(flags, 'task-title') @@ -62,6 +74,7 @@ export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record agent: getOptionalStringFlag(flags, 'agent'), model, effort, + ...(account ? { account } : {}), terminal: getOptionalStringFlag(flags, 'terminal'), retryOf: getOptionalStringFlag(flags, 'retry-of'), timeoutMs: getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms'), diff --git a/src/cli/handlers/worktree-create-agent-launch.ts b/src/cli/handlers/worktree-create-agent-launch.ts index 88787199ce6c..bbd1539fe411 100644 --- a/src/cli/handlers/worktree-create-agent-launch.ts +++ b/src/cli/handlers/worktree-create-agent-launch.ts @@ -39,12 +39,21 @@ export async function createWorktreeWithAgent(args: { flags: Map create: Record launch: CliAgentLaunch + /** `--account`: only the create's startup terminal can run Claude on a non-active account. */ + claudeAccount?: string json: boolean rawArgs?: readonly string[] }): Promise { - const { client, flags, create, launch, json, rawArgs } = args - const capabilities = await getAgentLaunchCapabilities(client) + const { client, flags, create, launch, claudeAccount, json, rawArgs } = args + if (claudeAccount && (launch.operationId || launch.surface || hasLaunchModelFlags(flags))) { + throw new RuntimeClientError( + 'invalid_argument', + '--account cannot be combined with --model, --effort, --surface or --retry-launch.' + ) + } + const capabilities = claudeAccount ? undefined : await getAgentLaunchCapabilities(client) if ( + claudeAccount || !capabilities?.includes(AGENT_LAUNCH_LOCAL_PRESENTATION_RUNTIME_CAPABILITY) || (launch.prompt && !capabilities.includes(AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY)) ) { @@ -67,7 +76,8 @@ export async function createWorktreeWithAgent(args: { ...create, startupAgent: launch.agent, startupPrompt: launch.prompt?.text ?? '', - launchSource: 'cli' + launchSource: 'cli', + ...(claudeAccount ? { startupClaudeAccount: claudeAccount } : {}) } const created = await withSetupDecisionRecovery( withCliAgentLaunchRecovery( diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index f47b0d5e8f25..a544ac25a03e 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -30,6 +30,7 @@ import type { TuiAgent } from '../../shared/tui-agent' import { parseCliAgent, readCliAgentLaunch } from '../agent-launch-request' import { createWorktreeWithAgent } from './worktree-create-agent-launch' import { printLineageSummary } from './worktree-lineage-summary' +import { readClaudeLaunchAccountFlag } from './claude-launch-account-flag' import { projectWorktreePsTerminalVerdict } from '../worktree-ps-terminal-verdict' import { assertWorkspaceTargetFlagsCompatible, @@ -150,10 +151,11 @@ export const WORKTREE_HANDLERS: Record = { const explicitParentWorktree = explicitParent.parentWorktree const explicitParentWorkspace = explicitParent.parentWorkspace const startupAgent = getOptionalStartupAgent(flags) - // Read before any request, so a bad prompt, model or effort creates nothing. + // Read before any request, so a bad prompt, model, effort or account creates nothing. const agentLaunch = startupAgent ? readCliAgentLaunch(flags, startupAgent, { createsWorktree: true }) : undefined + const claudeAccount = await readClaudeLaunchAccountFlag(flags, client, startupAgent) const setupDecision = getOptionalSetupDecision(flags) const noParent = flags.get('no-parent') === true const envParentWorkspace = @@ -213,7 +215,15 @@ export const WORKTREE_HANDLERS: Record = { cliProvenanceRequest: callerTerminalHandle ? { callerTerminalHandle } : {} } if (agentLaunch) { - await createWorktreeWithAgent({ client, flags, create, launch: agentLaunch, json, rawArgs }) + await createWorktreeWithAgent({ + client, + flags, + create, + launch: agentLaunch, + claudeAccount, + json, + rawArgs + }) return } const result = await withSetupDecisionRecovery( diff --git a/src/cli/index-worktree-create-claude-account.test.ts b/src/cli/index-worktree-create-claude-account.test.ts new file mode 100644 index 000000000000..64eb495419fc --- /dev/null +++ b/src/cli/index-worktree-create-claude-account.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest' +import { CLAUDE_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY } from '../shared/account-runtime-capabilities' + +const { + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock +} = vi.hoisted(() => ({ + callMock: vi.fn(), + runtimeClientConstructorMock: vi.fn(), + serveOrcaAppMock: vi.fn(), + getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'), + addEnvironmentFromPairingCodeMock: vi.fn(), + listEnvironmentsMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('./runtime-client', async () => { + const { createRuntimeClientModuleMock } = await import('./index-test-harness.js') + return createRuntimeClientModuleMock({ + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock + }) +}) + +vi.mock('./runtime/environments', () => ({ + addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock, + listEnvironments: listEnvironmentsMock, + removeEnvironment: vi.fn(), + resolveEnvironment: vi.fn() +})) + +vi.mock('child_process', async () => { + const { createChildProcessModuleMock } = await import('./index-test-harness.js') + return createChildProcessModuleMock(spawnMock) +}) + +import { main } from './index' +import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures' +import { useWorktreeAwarenessEnvironment } from './index-test-harness' + +describe('orca worktree create --account', () => { + useWorktreeAwarenessEnvironment({ + callMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock + }) + + const createArgs = ['worktree', 'create', '--repo', 'id:repo-1', '--name', 'pinned', '--json'] + + it('sends the account selector to a host that advertises support', async () => { + queueFixtures( + callMock, + okFixture('req_status', { capabilities: [CLAUDE_LAUNCH_ACCOUNT_RUNTIME_CAPABILITY] }), + worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]), + okFixture('req_create', { + worktree: buildWorktree('/tmp/repo/pinned', 'pinned', 'abc', 'repo-1') + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main([...createArgs, '--agent', 'claude', '--account', 'pinned@example.com'], '/tmp/repo') + + expect(callMock).toHaveBeenNthCalledWith(1, 'status.get') + expect(callMock).toHaveBeenNthCalledWith( + 3, + 'worktree.create', + expect.objectContaining({ + startupAgent: 'claude', + startupPrompt: '', + startupClaudeAccount: 'pinned@example.com' + }) + ) + }) + + it('refuses an older host instead of launching on its active account', async () => { + queueFixtures(callMock, okFixture('req_status', { capabilities: [] })) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main([...createArgs, '--agent', 'claude', '--account', 'acct-b'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(callMock).not.toHaveBeenCalledWith('worktree.create', expect.anything()) + }) + + it('requires --agent claude', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main([...createArgs, '--agent', 'codex', '--account', 'acct-b'], '/tmp/repo') + + expect(process.exitCode).toBe(1) + expect(callMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index bed7f0043ead..509878cba3a6 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -6,6 +6,7 @@ import { SERVE_COMMAND_SPECS } from './serve' import { REPO_COMMAND_SPECS } from './repo' import { TERMINAL_SEND_COMMAND_SPEC } from './terminal-send' import { TERMINAL_CLOSE_COMMAND_SPEC } from './terminal-close' +import { WORKTREE_CREATE_COMMAND_SPEC } from './worktree-create' import { TERMINAL_SET_PANE_TITLE_COMMAND_SPEC } from './terminal-set-pane-title' export const CORE_COMMAND_SPECS: CommandSpec[] = [ @@ -63,72 +64,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ ], examples: ['orca worktree current', 'orca worktree current --json'] }, - { - path: ['worktree', 'create'], - repeatableFlags: ['reference'], - summary: 'Create a new Orca-managed worktree', - usage: - 'orca worktree create --name [--repo |--project [--host ]|--project-host-setup ] [--agent ] [--prompt ] [--model ] [--effort ] [--surface chat|terminal] [--setup run|skip|inherit] [--base-branch ] [--reference ...] [--reference-site ] [--issue ] [--pr ] [--linear-issue ] [--gitlab-issue ] [--gitlab-mr ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--retry-launch ] [--json]', - allowedFlags: [ - ...GLOBAL_FLAGS, - 'repo', - 'project', - 'host', - 'project-host-setup', - 'name', - 'agent', - 'prompt', - 'model', - 'effort', - 'surface', - 'retry-launch', - 'base-branch', - 'reference', - 'reference-site', - 'issue', - 'pr', - 'linear-issue', - 'gitlab-issue', - 'gitlab-mr', - 'comment', - 'setup', - 'parent-worktree', - 'no-parent', - 'run-hooks', - 'activate' - ], - notes: [ - 'Repeat --reference to attach full issue or review URLs, or Jira issue keys, before setup or agent startup. Use --reference-site to choose the Jira site for keys. Other providers require full URLs. Cannot combine with legacy link flags.', - 'This creates a new checkout. For a fresh agent in an existing worktree, use `orca agent launch` instead.', - 'By default, Orca records the new worktree as a child of the caller context when it can infer one from the Orca terminal or current directory.', - 'If --repo is omitted, Orca infers the repo from the current Orca-managed worktree.', - 'Use --project to create on a ready project host setup without spelling the backing repo id. --project takes the project id or the display name from `orca project list`.', - '--host is only needed when the project is set up on more than one host; with a single setup Orca uses it. When it is ambiguous the error names every host the project is set up on.', - '--host takes local, ssh:, runtime:, or the bare host name `orca host list` prints for any of them.', - 'For related work, use the inferred parent or pass --parent-worktree active, folder:, or worktree: to make the relationship explicit. Worktree ids are the full :: values returned by `orca worktree list --json`.', - 'Use --no-parent when the new worktree should be independent of the current context.', - '--no-parent only affects Orca lineage; omit --base-branch to use the repo default base, or pass the default base ref explicitly for independent top-level work.', - 'By default this creates the worktree and its first terminal without switching the active Orca view.', - 'Pass --agent to start an agent with the worktree, as a chat or a terminal per your Chat UI setting, exactly as a new agent tab does; --prompt sends initial work to it. --model and --effort pick its model and reasoning effort, and --surface chat|terminal picks chat or terminal for this launch instead of your setting.', - 'After a lost agent launch reply, re-issue the same command from the same directory on the same host with --retry-launch from the error. Never retry as a fresh launch; older hosts require inspecting the workspace first.', - 'With --agent --json, result.launch says what started: launch.outcome.kind (structured chat or terminal), launch.outcome.handle, and launch.receipt.detail for why. An Orca host older than this returns the terminal agent handle in result.agentTerminalHandle instead.', - 'Repo-defined setup hooks follow the repository setup policy; pass --setup run to force them.', - 'Pass --activate when the CLI caller intentionally wants to reveal the new worktree in the app.', - 'Passing --run-hooks is kept as a legacy alias for --setup run and reveals the worktree.', - 'Use --pr for GitHub pull requests; --gitlab-issue and --gitlab-mr write separate GitLab links. GitLab URLs must match the stored source project or remote; they cannot select a foreign project.' - ], - examples: [ - 'orca worktree create --name agent-task --agent codex --prompt "hi" --json', - 'orca worktree create --name design --no-parent --agent codex --model gpt-6-astra --effort high --prompt "Write the design doc" --json', - 'orca worktree create --repo id: --name related-task --json', - 'orca worktree create --project github:stablyai/orca --host runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3 --name benchmark --json', - 'orca worktree create --repo id: --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue --json', - 'orca worktree create --repo id: --name agent-task --agent codex --prompt "hi" --json', - 'orca worktree create --repo id: --name folder-child --parent-worktree folder: --json', - 'orca worktree create --repo id: --name related-task --parent-worktree active --json', - 'orca worktree create --repo id: --name independent-task --no-parent --json' - ] - }, + WORKTREE_CREATE_COMMAND_SPEC, WORKTREE_SET_COMMAND_SPEC, { path: ['worktree', 'rm'], diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index 9123009f9de0..1bfd58b2e8f9 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -5,7 +5,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ path: ['orchestration', 'worker-start'], summary: 'Start one supervised worker on the Run home or a connected Orca server', usage: - 'orca orchestration worker-start (--task | --spec ) [--on ] [--worktree ] (--agent | --terminal ) [--task-title ] [--deps ] [--parent ] [--model ] [--effort ] [--name ] [--repo ] [--base-branch ] [--display-name ] [--comment ] [--setup ] [--retry-of ] [--timeout-ms ] [--run ] [--from ] [--retry-request ] [--json]', + 'orca orchestration worker-start (--task | --spec ) [--on ] [--worktree ] (--agent | --terminal ) [--task-title ] [--deps ] [--parent ] [--model ] [--effort ] [--account ] [--name ] [--repo ] [--base-branch ] [--display-name ] [--comment ] [--setup ] [--retry-of ] [--timeout-ms ] [--run ] [--from ] [--retry-request ] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'task', @@ -24,6 +24,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ 'agent', 'model', 'effort', + 'account', 'terminal', 'retry-of', 'timeout-ms', @@ -37,6 +38,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ 'When reusing --terminal, pass --worktree for that terminal; current means the coordinator worktree.', '--agent takes an Orca agent id enabled on the worker server, such as claude, codex, cursor, antigravity, muse, zcode, opencode, or opencode2.', '--model supports Claude, Codex, Cursor, Antigravity, and Muse opaque provider model ids; --effort requires --model. OMP accepts --model only; --effort is unsupported. Neither can combine with --terminal. OpenCode model selection requires an existing worktree, a verified execution-host CLI, and an available model; effort is unsupported. Other agents, including zcode, launch with the model from their own config.', + '--account starts a Claude worker on that managed account from `orca account list` without switching the host account. Claude only, this host only (no --on), always a terminal worker, and not with --terminal.', 'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.', 'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.', "How the worker runs follows the user's own setting for new agent tabs; there is no flag for it and no caller needs to ask. A dispatch the setting cannot apply to still starts, so the placement, agent, and launch options passed here are always the ones honoured.", diff --git a/src/cli/specs/worktree-create.ts b/src/cli/specs/worktree-create.ts new file mode 100644 index 000000000000..b06454a05224 --- /dev/null +++ b/src/cli/specs/worktree-create.ts @@ -0,0 +1,71 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +export const WORKTREE_CREATE_COMMAND_SPEC: CommandSpec = { + path: ['worktree', 'create'], + repeatableFlags: ['reference'], + summary: 'Create a new Orca-managed worktree', + usage: + 'orca worktree create --name [--repo |--project [--host ]|--project-host-setup ] [--agent ] [--account ] [--prompt ] [--model ] [--effort ] [--surface chat|terminal] [--setup run|skip|inherit] [--base-branch ] [--reference ...] [--reference-site ] [--issue ] [--pr ] [--linear-issue ] [--gitlab-issue ] [--gitlab-mr ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--retry-launch ] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'repo', + 'project', + 'host', + 'project-host-setup', + 'name', + 'agent', + 'account', + 'prompt', + 'model', + 'effort', + 'surface', + 'retry-launch', + 'base-branch', + 'reference', + 'reference-site', + 'issue', + 'pr', + 'linear-issue', + 'gitlab-issue', + 'gitlab-mr', + 'comment', + 'setup', + 'parent-worktree', + 'no-parent', + 'run-hooks', + 'activate' + ], + notes: [ + 'Repeat --reference to attach full issue or review URLs, or Jira issue keys, before setup or agent startup. Use --reference-site to choose the Jira site for keys. Other providers require full URLs. Cannot combine with legacy link flags.', + 'This creates a new checkout. For a fresh agent in an existing worktree, use `orca agent launch` instead.', + 'By default, Orca records the new worktree as a child of the caller context when it can infer one from the Orca terminal or current directory.', + 'If --repo is omitted, Orca infers the repo from the current Orca-managed worktree.', + 'Use --project to create on a ready project host setup without spelling the backing repo id. --project takes the project id or the display name from `orca project list`.', + '--host is only needed when the project is set up on more than one host; with a single setup Orca uses it. When it is ambiguous the error names every host the project is set up on.', + '--host takes local, ssh:, runtime:, or the bare host name `orca host list` prints for any of them.', + 'For related work, use the inferred parent or pass --parent-worktree active, folder:, or worktree: to make the relationship explicit. Worktree ids are the full :: values returned by `orca worktree list --json`.', + 'Use --no-parent when the new worktree should be independent of the current context.', + '--no-parent only affects Orca lineage; omit --base-branch to use the repo default base, or pass the default base ref explicitly for independent top-level work.', + 'By default this creates the worktree and its first terminal without switching the active Orca view.', + 'Pass --agent to start an agent with the worktree, as a chat or a terminal per your Chat UI setting, exactly as a new agent tab does; --prompt sends initial work to it. --model and --effort pick its model and reasoning effort, and --surface chat|terminal picks chat or terminal for this launch instead of your setting.', + '--account (with --agent claude) starts Claude in a terminal on that managed account from `orca account list` without switching the host account. Local host workspaces only; cannot be combined with --model, --effort, --surface or --retry-launch.', + 'After a lost agent launch reply, re-issue the same command from the same directory on the same host with --retry-launch from the error. Never retry as a fresh launch; older hosts require inspecting the workspace first.', + 'With --agent --json, result.launch says what started: launch.outcome.kind (structured chat or terminal), launch.outcome.handle, and launch.receipt.detail for why. An Orca host older than this returns the terminal agent handle in result.agentTerminalHandle instead.', + 'Repo-defined setup hooks follow the repository setup policy; pass --setup run to force them.', + 'Pass --activate when the CLI caller intentionally wants to reveal the new worktree in the app.', + 'Passing --run-hooks is kept as a legacy alias for --setup run and reveals the worktree.', + 'Use --pr for GitHub pull requests; --gitlab-issue and --gitlab-mr write separate GitLab links. GitLab URLs must match the stored source project or remote; they cannot select a foreign project.' + ], + examples: [ + 'orca worktree create --name agent-task --agent codex --prompt "hi" --json', + 'orca worktree create --name design --no-parent --agent codex --model gpt-6-astra --effort high --prompt "Write the design doc" --json', + 'orca worktree create --repo id: --name related-task --json', + 'orca worktree create --project github:stablyai/orca --host runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3 --name benchmark --json', + 'orca worktree create --repo id: --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue --json', + 'orca worktree create --repo id: --name agent-task --agent codex --prompt "hi" --json', + 'orca worktree create --repo id: --name folder-child --parent-worktree folder: --json', + 'orca worktree create --repo id: --name related-task --parent-worktree active --json', + 'orca worktree create --repo id: --name independent-task --no-parent --json' + ] +} diff --git a/src/main/agent-launch/agent-launch-execution.ts b/src/main/agent-launch/agent-launch-execution.ts index 5880cb232855..8fa70f05bf91 100644 --- a/src/main/agent-launch/agent-launch-execution.ts +++ b/src/main/agent-launch/agent-launch-execution.ts @@ -10,7 +10,7 @@ import type { AgentLaunchResult, AgentLaunchTarget } from '../../shared/agent-launch-intent' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { AgentLaunchModeHostRuntime } from './agent-launch-project-claude-account' import type { readAgentLaunchModeSettings, AgentLaunchModeReceipt, @@ -30,8 +30,7 @@ import type { export type AgentLaunchPublishedSurface = AgentLaunchResult type AgentLaunchExecutionBase = { - runtime: Pick & - Parameters[0] + runtime: AgentLaunchModeHostRuntime & Parameters[0] vocabulary?: AgentLaunchModeVocabulary /** False when the calling client cannot show the agent's chat; absent for the host's own callers. */ callerRendersStructured?: boolean diff --git a/src/main/agent-launch/agent-launch-executor-claude-account.test.ts b/src/main/agent-launch/agent-launch-executor-claude-account.test.ts new file mode 100644 index 000000000000..e5d208982f44 --- /dev/null +++ b/src/main/agent-launch/agent-launch-executor-claude-account.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { harness } from './agent-launch-executor.test-fixture' + +describe('a launch into a project with a saved Claude account', () => { + const PINNED_REPO = { + id: 'repo-1', + agentAccounts: { claude: { mode: 'account', accountId: 'acct-b' } } + } + const EXISTING = { kind: 'existing', worktree: 'repo-1::/repo-1' } as const + + it('downgrades a structured Claude launch to a terminal the spawn pins', async () => { + const h = harness({ repos: [PINNED_REPO] }) + const result = await h.run({ agent: 'claude', target: EXISTING }) + expect(h.calls).toEqual(['createSupport', 'createTerminalAgent']) + expect(result.receipt).toMatchObject({ + mode: 'terminal', + preferred: 'structured', + reason: 'pinned_claude_account' + }) + expect(result.receipt.detail).toContain('saved account') + }) + + it("keeps the host's own reason when the workspace could never pin", async () => { + const h = harness({ + repos: [PINNED_REPO], + createSupport: { supported: false, reason: 'remote' } + }) + const result = await h.run({ agent: 'claude', target: EXISTING }) + expect(result.receipt).toMatchObject({ reason: 'remote_execution_host' }) + }) + + it('leaves Codex and an unpinned project structured', async () => { + const codex = harness({ repos: [PINNED_REPO] }) + expect((await codex.run({ agent: 'codex', target: EXISTING })).receipt.mode).toBe('structured') + const ask = harness({ repos: [{ id: 'repo-1', agentAccounts: { claude: { mode: 'ask' } } }] }) + expect((await ask.run({ agent: 'claude', target: EXISTING })).receipt.mode).toBe('structured') + }) +}) diff --git a/src/main/agent-launch/agent-launch-executor.test-fixture.ts b/src/main/agent-launch/agent-launch-executor.test-fixture.ts new file mode 100644 index 000000000000..7a4f9b983e40 --- /dev/null +++ b/src/main/agent-launch/agent-launch-executor.test-fixture.ts @@ -0,0 +1,125 @@ +import { vi } from 'vitest' +import { executeAgentLaunch, type AgentLaunchExecution } from './agent-launch-executor' +import type { AgentLaunchIntent } from '../../shared/agent-launch-intent' + +/** A stub host for the executor, recording each runtime and factory call it receives. */ +export const STRUCTURED_PREFERENCE = { + experimentalNativeChat: true +} + +export function harness(options: { + settings?: Record | null + createSupport?: { supported: boolean; reason?: 'agent' | 'remote' | 'wsl' } + createSupportThrows?: boolean + structuredCreateError?: Error + terminalCreateError?: Error + deliveredMessageId?: string | null + terminalPromptDelivered?: boolean + repos?: { id: string; agentAccounts?: unknown }[] + /** Whether the surface reports that its typed line took the offered prompt. */ + lineCarriesPrompt?: boolean + worktreeCreateError?: Error + onStructuredCreate?: () => void + onStructuredPromptDelivery?: () => void + onSurfacePublished?: AgentLaunchExecution['onSurfacePublished'] + /** A worktree-only factory, as orchestration workers and `worktree.create` pass. */ + worktreeOnlyFactory?: boolean +}) { + const calls: string[] = [] + const carried = (startupPrompt: string | undefined) => + startupPrompt && (options.lineCarriesPrompt ?? true) ? { promptRodeLaunchCommand: true } : {} + const createWorktree = vi.fn( + async (args: { + create: Record + startupAgent: string | undefined + promisedAgentChat?: boolean + startupPrompt?: string + onWorkspaceCreated?: (worktreeId: string) => void + }) => { + calls.push(`createWorktree(startupAgent=${String(args.startupAgent)})`) + args.onWorkspaceCreated?.('wt-new') + if (options.worktreeCreateError) { + throw options.worktreeCreateError + } + return { + worktreeId: 'wt-new', + workspacePath: '/repo/task', + connectionId: null, + startupTerminalHandle: args.startupAgent ? 'term_agent_first' : undefined, + ...carried(args.startupPrompt) + } + } + ) + const createFolderWorkspace = vi.fn(async (_args: { create: Record }) => { + calls.push('createFolderWorkspace') + return { worktreeId: 'folder:fw-new', workspacePath: '/repo/task', connectionId: null } + }) + const createRecoveryShell = vi.fn(async (_worktreeId: string) => { + calls.push('createRecoveryShell') + }) + const getStructuredAgentSessionCreateSupport = vi.fn(async () => { + calls.push('createSupport') + if (options.createSupportThrows) { + throw new Error('host unreachable') + } + return options.createSupport ?? { supported: true } + }) + const createStructuredSession = vi.fn(async () => { + calls.push('createStructuredSession') + options.onStructuredCreate?.() + if (options.structuredCreateError) { + throw options.structuredCreateError + } + return { sessionId: 'sess-1', handle: 'handle_structured', fence: 4 } + }) + const createTerminalAgent = vi.fn(async (args: { startupPrompt?: string }) => { + calls.push('createTerminalAgent') + if (options.terminalCreateError) { + throw options.terminalCreateError + } + return { handle: 'term_1', ...carried(args.startupPrompt) } + }) + const deliverStructuredPrompt = vi.fn(async () => { + calls.push('deliverStructuredPrompt') + options.onStructuredPromptDelivery?.() + return options.deliveredMessageId === undefined ? 'msg-1' : options.deliveredMessageId + }) + const deliverTerminalPrompt = vi.fn(async () => { + calls.push('deliverTerminalPrompt') + return options.terminalPromptDelivered ?? true + }) + const runtime = { + requireStore: () => ({ + getSettings: () => (options.settings === undefined ? STRUCTURED_PREFERENCE : options.settings) + }), + getStructuredAgentSessionCreateSupport, + listRepos: () => options.repos ?? [] + } + return { + calls, + runtime, + createWorktree, + createFolderWorkspace, + createRecoveryShell, + createStructuredSession, + createTerminalAgent, + deliverStructuredPrompt, + deliverTerminalPrompt, + run: (intent: AgentLaunchIntent) => + executeAgentLaunch({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the runtime methods the executor reaches, and each test asserts the calls made, so an omitted method throws rather than reading a wrong value. + runtime: runtime as unknown as AgentLaunchExecution['runtime'], + intent, + surfaces: { + createStructuredSession, + createTerminalAgent, + deliverStructuredPrompt, + deliverTerminalPrompt + }, + workspaces: options.worktreeOnlyFactory + ? { createWorktree, createRecoveryShell } + : { createWorktree, createFolderWorkspace, createRecoveryShell }, + ...(options.onSurfacePublished ? { onSurfacePublished: options.onSurfacePublished } : {}) + }) + } +} diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index 95d723452954..613b8cef60db 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -8,132 +8,13 @@ * workspace exists. */ -import { describe, expect, it, vi } from 'vitest' -import { executeAgentLaunch, type AgentLaunchExecution } from './agent-launch-executor' +import { describe, expect, it } from 'vitest' +import { harness, STRUCTURED_PREFERENCE } from './agent-launch-executor.test-fixture' import { AgentLaunchStructuredSessionRefusedError } from './agent-launch-surface-factories' import type { AgentLaunchIntent, AgentLaunchResult } from '../../shared/agent-launch-intent' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' import { readCreatedWorkspaceLaunch } from './agent-launch-created-workspace' -const STRUCTURED_PREFERENCE = { - experimentalNativeChat: true -} - -function harness(options: { - settings?: Record | null - createSupport?: { supported: boolean; reason?: 'agent' | 'remote' | 'wsl' } - createSupportThrows?: boolean - structuredCreateError?: Error - terminalCreateError?: Error - deliveredMessageId?: string | null - terminalPromptDelivered?: boolean - /** Whether the surface reports that its typed line took the offered prompt. */ - lineCarriesPrompt?: boolean - worktreeCreateError?: Error - onStructuredCreate?: () => void - onStructuredPromptDelivery?: () => void - onSurfacePublished?: AgentLaunchExecution['onSurfacePublished'] - /** A worktree-only factory, as orchestration workers and `worktree.create` pass. */ - worktreeOnlyFactory?: boolean -}) { - const calls: string[] = [] - const carried = (startupPrompt: string | undefined) => - startupPrompt && (options.lineCarriesPrompt ?? true) ? { promptRodeLaunchCommand: true } : {} - const createWorktree = vi.fn( - async (args: { - create: Record - startupAgent: string | undefined - promisedAgentChat?: boolean - startupPrompt?: string - onWorkspaceCreated?: (worktreeId: string) => void - }) => { - calls.push(`createWorktree(startupAgent=${String(args.startupAgent)})`) - args.onWorkspaceCreated?.('wt-new') - if (options.worktreeCreateError) { - throw options.worktreeCreateError - } - return { - worktreeId: 'wt-new', - workspacePath: '/repo/task', - connectionId: null, - startupTerminalHandle: args.startupAgent ? 'term_agent_first' : undefined, - ...carried(args.startupPrompt) - } - } - ) - const createFolderWorkspace = vi.fn(async (_args: { create: Record }) => { - calls.push('createFolderWorkspace') - return { worktreeId: 'folder:fw-new', workspacePath: '/repo/task', connectionId: null } - }) - const createRecoveryShell = vi.fn(async (_worktreeId: string) => { - calls.push('createRecoveryShell') - }) - const getStructuredAgentSessionCreateSupport = vi.fn(async () => { - calls.push('createSupport') - if (options.createSupportThrows) { - throw new Error('host unreachable') - } - return options.createSupport ?? { supported: true } - }) - const createStructuredSession = vi.fn(async () => { - calls.push('createStructuredSession') - options.onStructuredCreate?.() - if (options.structuredCreateError) { - throw options.structuredCreateError - } - return { sessionId: 'sess-1', handle: 'handle_structured', fence: 4 } - }) - const createTerminalAgent = vi.fn(async (args: { startupPrompt?: string }) => { - calls.push('createTerminalAgent') - if (options.terminalCreateError) { - throw options.terminalCreateError - } - return { handle: 'term_1', ...carried(args.startupPrompt) } - }) - const deliverStructuredPrompt = vi.fn(async () => { - calls.push('deliverStructuredPrompt') - options.onStructuredPromptDelivery?.() - return options.deliveredMessageId === undefined ? 'msg-1' : options.deliveredMessageId - }) - const deliverTerminalPrompt = vi.fn(async () => { - calls.push('deliverTerminalPrompt') - return options.terminalPromptDelivered ?? true - }) - const runtime = { - requireStore: () => ({ - getSettings: () => (options.settings === undefined ? STRUCTURED_PREFERENCE : options.settings) - }), - getStructuredAgentSessionCreateSupport - } - return { - calls, - runtime, - createWorktree, - createFolderWorkspace, - createRecoveryShell, - createStructuredSession, - createTerminalAgent, - deliverStructuredPrompt, - deliverTerminalPrompt, - run: (intent: AgentLaunchIntent) => - executeAgentLaunch({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the two runtime methods the executor reaches, and each test asserts the calls made, so an omitted method throws rather than reading a wrong value. - runtime: runtime as unknown as AgentLaunchExecution['runtime'], - intent, - surfaces: { - createStructuredSession, - createTerminalAgent, - deliverStructuredPrompt, - deliverTerminalPrompt - }, - workspaces: options.worktreeOnlyFactory - ? { createWorktree, createRecoveryShell } - : { createWorktree, createFolderWorkspace, createRecoveryShell }, - ...(options.onSurfacePublished ? { onSurfacePublished: options.onSurfacePublished } : {}) - }) - } -} - const CREATE_INTENT: AgentLaunchIntent = { agent: 'claude', target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task' } } diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index aeb6c3208e49..c465f7c37f12 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -36,6 +36,11 @@ import { import type { TuiAgent } from '../../shared/tui-agent' import type { WorkspaceLaunchKind } from '../../shared/workspace-launch-kind' import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { + PROJECT_CLAUDE_ACCOUNT_DETAIL, + projectClaudeAccountForLaunch, + type AgentLaunchModeHostRuntime +} from './agent-launch-project-claude-account' import type { AgentLaunchSurface } from '../../shared/agent-launch-surface' import { formatAgentTypeLabel } from '../../shared/agent-type-label' import { AgentLaunchSurfaceUnavailableError } from './agent-launch-surface-refusal' @@ -77,6 +82,8 @@ export type AgentLaunchModePlacement = { * resolved — never accepted from a caller, which would let one route around this decision. * Absent means the kind was never established, and is not read as any particular kind. */ workspaceKind?: WorkspaceLaunchKind + /** `--account`: a structured session has no way to run on a non-active account yet. */ + claudeAccount?: string /** A requested start directory. It belongs here, unlike `model` or `effort`, because a structured * session has no way to apply one — it runs in its workspace — so honouring it and honouring the * chat preference are mutually exclusive rather than merely awkward. Read against @@ -103,7 +110,9 @@ const DOWNGRADE_DETAIL: Record = { structured_support_unknown: 'the execution host has not established structured session support', wsl_execution_runtime: 'this workspace runs under WSL', codex_on_windows: 'Codex has no structured session on Windows', - structured_unsupported_on_host: 'the execution host cannot create one here' + structured_unsupported_on_host: 'the execution host cannot create one here', + pinned_claude_account: + '--account runs Claude on a non-active account, which only a terminal can do' } const BLOCKER_REASON: Record = { @@ -167,6 +176,9 @@ export function decideAgentLaunchMode(args: { if (placement.on) { return blocked('remote_execution_host') } + if (placement.claudeAccount) { + return blocked('pinned_claude_account') + } const cwdRelation = launchCwdRelation(placement) const support = resolveStructuredNativeChatSupport({ agent, @@ -211,7 +223,7 @@ export function decideAgentLaunchMode(args: { * reject an explicit chat; a materialized workspace retains its terminal fallback. */ export async function resolveAgentLaunchModeOnHost( - runtime: Pick, + runtime: AgentLaunchModeHostRuntime, receipt: AgentLaunchModeReceipt, worktreeId: string | undefined, agent: TuiAgent | undefined, @@ -233,12 +245,21 @@ export async function resolveAgentLaunchModeOnHost( launchCwdRelation(context) === 'custom' ) } - return downgradeAgentLaunchModeForHost( + const settled = downgradeAgentLaunchModeForHost( receipt, await readStructuredCreateSupport(runtime, worktreeId, agent), vocabulary, context ? { ...context, agent } : undefined ) + // Why after the host verdict: an SSH or WSL workspace, which never pins, keeps its own reason. + if (settled.mode !== 'structured' || !projectClaudeAccountForLaunch(runtime, worktreeId, agent)) { + return settled + } + const requested = settled.reason === 'requested' + // Why refused: an explicit chat request must not quietly become a terminal before anything opens. + return requested && context?.phase === 'before-effects' + ? unavailableLaunchMode('pinned_claude_account', vocabulary, true, { ...context, agent }) + : downgraded('pinned_claude_account', vocabulary, requested, PROJECT_CLAUDE_ACCOUNT_DETAIL) } /** A host that cannot answer has not proved it can create one, so the launch stays a PTY agent. */ @@ -335,9 +356,10 @@ function unavailableLaunchMode( function downgraded( reason: AgentLaunchDowngradeReason, vocabulary: AgentLaunchModeVocabulary, - requested = false + requested = false, + detail?: string ): AgentLaunchModeReceipt { - const why = vocabulary.detailOverrides?.[reason] ?? DOWNGRADE_DETAIL[reason] + const why = detail ?? vocabulary.detailOverrides?.[reason] ?? DOWNGRADE_DETAIL[reason] return { mode: 'terminal', preferred: 'structured', diff --git a/src/main/agent-launch/agent-launch-project-claude-account.ts b/src/main/agent-launch/agent-launch-project-claude-account.ts new file mode 100644 index 000000000000..42c8411a3ad2 --- /dev/null +++ b/src/main/agent-launch/agent-launch-project-claude-account.ts @@ -0,0 +1,31 @@ +import type { TuiAgent } from '../../shared/tui-agent' +import { resolveProjectClaudeAccount } from '../claude-accounts/project-claude-account-resolution' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' + +export type AgentLaunchModeHostRuntime = Pick< + OrcaRuntimeService, + 'getStructuredAgentSessionCreateSupport' +> & + Partial> + +export const PROJECT_CLAUDE_ACCOUNT_DETAIL = + 'this project runs Claude on its saved account, which only a terminal can do' + +/** + * The saved account a Claude launch in this workspace pins its terminal spawn to; a structured + * session has no way to run on it, so a host-side launch downgrades the way the renderer's plan does. + */ +export function projectClaudeAccountForLaunch( + runtime: Partial>, + worktreeId: string, + agent: TuiAgent | undefined +): string | undefined { + if (agent !== 'claude') { + return undefined + } + const repos = runtime.listRepos?.() ?? [] + return resolveProjectClaudeAccount({ + getRepo: (repoId) => repos.find((repo) => repo.id === repoId), + worktreeId + }) +} diff --git a/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts b/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts index da5d6ada1453..3be345cb14e9 100644 --- a/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts +++ b/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts @@ -69,7 +69,9 @@ const fsAllowances: Record = { // The BROWSER stand-in a hidden sign-in hands its link through; holds no login. 'claude-accounts/claude-sign-in-browser.ts': ['rm', 'writeFile'], // The guest helper answers the host on stdout. - 'claude-accounts/claude-profile-wsl-entry.ts': ['write'] + 'claude-accounts/claude-profile-wsl-entry.ts': ['write'], + // Which PTY runs which `--account` account, for labels: ids only, never a login. + 'claude-accounts/claude-pinned-pty-registry.ts': ['mkdirSync', 'write', 'writeFileAtomically'] } const mutation = /^(?:write(?:File|Json|Sync|v|.*Credentials|.*Keychain)?$|write(?:File|Json)|appendFile|copyFile|cp(?:Sync)?$|rename(?:Sync)?$|unlink(?:Sync)?$|rm(?:dir)?(?:Sync)?$|truncate(?:Sync)?$|createWriteStream|symlink(?:Sync)?$|link(?:Sync)?$|chmod(?:Sync)?$|mkdir(?:Sync)?$|mkdtemp(?:Sync)?$|open(?:Sync)?$|delete.*Keychain|refreshClaudeOauth)/ diff --git a/src/main/claude-accounts/claude-launch-account.test.ts b/src/main/claude-accounts/claude-launch-account.test.ts new file mode 100644 index 000000000000..b5c268c3ff0d --- /dev/null +++ b/src/main/claude-accounts/claude-launch-account.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import type { ClaudeManagedAccountSummary } from '../../shared/managed-account-types' +import { ClaudeLaunchAccountError, resolveClaudeLaunchAccount } from './claude-launch-account' + +function summary( + id: string, + email: string, + overrides: Partial = {} +): ClaudeManagedAccountSummary { + return { + id, + email, + managedAuthRuntime: 'host', + wslDistro: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1, + ...overrides + } +} + +describe('resolveClaudeLaunchAccount', () => { + const state = { + accounts: [ + summary('acct-a', 'Alice@Example.com'), + summary('acct-b', 'bob@example.com'), + summary('acct-b2', 'bob@example.com'), + summary('acct-wsl', 'wsl@example.com', { managedAuthRuntime: 'wsl', wslDistro: 'Ubuntu' }) + ], + activeAccountId: 'acct-a' + } + + it('resolves an exact id and reports whether it is the host account', () => { + expect(resolveClaudeLaunchAccount(state, 'acct-a')).toEqual({ + accountId: 'acct-a', + email: 'Alice@Example.com', + isActiveOnHost: true + }) + expect(resolveClaudeLaunchAccount(state, 'acct-b2').isActiveOnHost).toBe(false) + }) + + it('matches emails case-insensitively', () => { + expect(resolveClaudeLaunchAccount(state, ' alice@example.COM ').accountId).toBe('acct-a') + }) + + it('lists the candidate ids when an email is ambiguous', () => { + expect(() => resolveClaudeLaunchAccount(state, 'bob@example.com')).toThrow( + /acct-b, acct-b2.*--account / + ) + }) + + it('points unknown selectors at orca account list', () => { + expect(() => resolveClaudeLaunchAccount(state, 'nobody@example.com')).toThrow( + ClaudeLaunchAccountError + ) + expect(() => resolveClaudeLaunchAccount(state, 'nobody@example.com')).toThrow( + 'orca account list' + ) + }) + + it('refuses WSL accounts and empty selectors', () => { + expect(() => resolveClaudeLaunchAccount(state, 'acct-wsl')).toThrow(/host accounts only/) + expect(() => resolveClaudeLaunchAccount(state, ' ')).toThrow(/requires a Claude account/) + }) +}) diff --git a/src/main/claude-accounts/claude-launch-account.ts b/src/main/claude-accounts/claude-launch-account.ts new file mode 100644 index 000000000000..26a42021d25d --- /dev/null +++ b/src/main/claude-accounts/claude-launch-account.ts @@ -0,0 +1,59 @@ +import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' + +export type ClaudeLaunchAccountResolution = { + accountId: string + email: string + /** The host's selected account; a launch on it takes the normal (unpinned) path. */ + isActiveOnHost: boolean +} + +export class ClaudeLaunchAccountError extends Error { + constructor(message: string) { + super(message) + this.name = 'ClaudeLaunchAccountError' + } +} + +/** + * Resolves `--account ` against this host's managed Claude accounts. + * + * An exact id wins over an email so an id can always disambiguate; emails compare + * case-insensitively because providers normalize them and users type them either way. + */ +export function resolveClaudeLaunchAccount( + state: Pick, + selector: string +): ClaudeLaunchAccountResolution { + const requested = selector.trim() + if (!requested) { + throw new ClaudeLaunchAccountError('--account requires a Claude account id or email.') + } + const byId = state.accounts.find((account) => account.id === requested) + const normalizedEmail = requested.toLowerCase() + const matches = byId + ? [byId] + : state.accounts.filter((account) => account.email.trim().toLowerCase() === normalizedEmail) + if (matches.length === 0) { + throw new ClaudeLaunchAccountError( + `No managed Claude account matches "${requested}". Run \`orca account list\` to see the account ids and emails on this host.` + ) + } + if (matches.length > 1) { + throw new ClaudeLaunchAccountError( + `More than one managed Claude account uses ${requested} (${matches.map((account) => account.id).join(', ')}). Pass --account instead.` + ) + } + const account = matches[0] + if (account.managedAuthRuntime === 'wsl') { + // Why: a WSL account's credentials live inside the distro; the pinned launch only knows how + // to hand a host config dir to a host Claude. + throw new ClaudeLaunchAccountError( + `Claude account ${account.email} (${account.id}) is a WSL account; --account supports host accounts only.` + ) + } + return { + accountId: account.id, + email: account.email, + isActiveOnHost: state.activeAccountId === account.id + } +} diff --git a/src/main/claude-accounts/claude-pinned-account-launch.ts b/src/main/claude-accounts/claude-pinned-account-launch.ts new file mode 100644 index 000000000000..b526ff71f978 --- /dev/null +++ b/src/main/claude-accounts/claude-pinned-account-launch.ts @@ -0,0 +1,47 @@ +import { existsSync } from 'node:fs' +import { CLAUDE_INJECTED_CONFIG_DIR_ENV } from '../../shared/claude-profile-routing' +import { claudeProfileSetupFailed } from './claude-profile-launch-errors' +import { claudeProfileMarkerPath, describeClaudeProfile } from './claude-profile-paths' +import type { ClaudeProfileRouter } from './claude-profile-router' +import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' + +/** + * A `--account` launch on an account that is not the selected one: Claude runs from that account's + * folder with no pointer, so a later switch never moves it. While the account has no login of its + * own, System default runs it, as it would the selected account. + */ +export async function prepareClaudePinnedAccountLaunch( + router: ClaudeProfileRouter, + dataRoot: string, + accountId: string +): Promise { + // Why wait only without the account's own login: whether System default covers it depends on the login shell's env. + if (!router.accountLogin(accountId)) { + await router.envSettled() + } + const pinned = { + runtime: 'host' as const, + wslDistro: null, + wslLinuxConfigDir: null, + pinnedAccountId: accountId, + provenance: `profile:${accountId}:pinned` + } + if (router.coveredBySystemDefault(accountId)) { + // Why no CLAUDE_CONFIG_DIR: naming System default's folder moves Claude to another Keychain item. + return { ...pinned, configDir: router.systemDefaultHome(), envPatch: {} } + } + const profile = describeClaudeProfile(dataRoot, accountId, { + executionHostId: 'local', + runtime: 'host' + }) + if (!existsSync(claudeProfileMarkerPath(profile))) { + await router.prepareAccount(accountId).catch(() => { + throw claudeProfileSetupFailed() + }) + } + return { + ...pinned, + configDir: profile.home, + envPatch: { CLAUDE_CONFIG_DIR: profile.home, [CLAUDE_INJECTED_CONFIG_DIR_ENV]: profile.home } + } +} diff --git a/src/main/claude-accounts/claude-pinned-pty-registry.test.ts b/src/main/claude-accounts/claude-pinned-pty-registry.test.ts new file mode 100644 index 000000000000..3a224e242576 --- /dev/null +++ b/src/main/claude-accounts/claude-pinned-pty-registry.test.ts @@ -0,0 +1,62 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + _internals, + attachClaudePinnedPtyPersistence, + confirmSeededPinnedClaudePtys, + createClaudePinnedPtyFilePersistence, + getPinnedClaudeAccountIdForPty, + hasSeededUnconfirmedPinnedClaudePtys, + markPinnedClaudePtyExited, + markPinnedClaudePtySpawned, + readClaudePinnedPtyRegistryFile, + seedPinnedClaudePtysFromPersistence +} from './claude-pinned-pty-registry' + +describe('Claude pinned PTY registry', () => { + afterEach(() => { + _internals.reset() + }) + + it('names a pinned PTY until it exits, persisting each change', () => { + const write = vi.fn() + attachClaudePinnedPtyPersistence({ write }) + + markPinnedClaudePtySpawned('pty-1', 'acct-1') + expect(getPinnedClaudeAccountIdForPty('pty-1')).toBe('acct-1') + expect(write).toHaveBeenLastCalledWith({ 'pty-1': 'acct-1' }) + + markPinnedClaudePtyExited('pty-1') + expect(getPinnedClaudeAccountIdForPty('pty-1')).toBeUndefined() + expect(write).toHaveBeenLastCalledWith({}) + + markPinnedClaudePtyExited('unpinned-pty') + expect(write).toHaveBeenCalledTimes(2) + }) + + it('keeps restored PTYs the daemon still runs and drops the rest', () => { + seedPinnedClaudePtysFromPersistence({ alive: 'acct-1', dead: 'acct-2' }) + expect(hasSeededUnconfirmedPinnedClaudePtys()).toBe(true) + + confirmSeededPinnedClaudePtys(['alive']) + + expect(getPinnedClaudeAccountIdForPty('alive')).toBe('acct-1') + expect(getPinnedClaudeAccountIdForPty('dead')).toBeUndefined() + expect(hasSeededUnconfirmedPinnedClaudePtys()).toBe(false) + }) + + it('round-trips the registry file and ignores malformed entries', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-pinned-pty-')) + try { + const filePath = join(dir, 'claude-pinned-pane-accounts.json') + createClaudePinnedPtyFilePersistence(filePath).write({ 'pty-1': 'acct-1', 'pty-2': '' }) + + expect(readClaudePinnedPtyRegistryFile(filePath)).toEqual({ 'pty-1': 'acct-1' }) + expect(readClaudePinnedPtyRegistryFile(join(dir, 'missing.json'))).toEqual({}) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/claude-accounts/claude-pinned-pty-registry.ts b/src/main/claude-accounts/claude-pinned-pty-registry.ts new file mode 100644 index 000000000000..3f8716dcc250 --- /dev/null +++ b/src/main/claude-accounts/claude-pinned-pty-registry.ts @@ -0,0 +1,113 @@ +import { mkdirSync, readFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' + +/** + * Which `--account` Claude PTYs run on which managed account, for the tab and status labels. + * Persisted so a pane that survives a restart keeps naming its account before it reattaches. + */ +const accountIdByPtyId = new Map() +// Why: restored at startup but not yet confirmed against the daemon, which drops the dead ones. +const seededUnconfirmedPtyIds = new Set() + +export type ClaudePinnedPtyPersistence = { + write(entries: Record): void +} + +let persistence: ClaudePinnedPtyPersistence | null = null + +export function attachClaudePinnedPtyPersistence(target: ClaudePinnedPtyPersistence | null): void { + persistence = target +} + +export function getPinnedClaudeAccountIdForPty(ptyId: string): string | undefined { + return accountIdByPtyId.get(ptyId) +} + +export function markPinnedClaudePtySpawned(ptyId: string, accountId: string): void { + accountIdByPtyId.set(ptyId, accountId) + seededUnconfirmedPtyIds.delete(ptyId) + persist() +} + +/** A no-op for unpinned PTYs, so every exit can call it. */ +export function markPinnedClaudePtyExited(ptyId: string): void { + seededUnconfirmedPtyIds.delete(ptyId) + if (accountIdByPtyId.delete(ptyId)) { + persist() + } +} + +export function seedPinnedClaudePtysFromPersistence(entries: Record): void { + for (const [ptyId, accountId] of Object.entries(entries)) { + accountIdByPtyId.set(ptyId, accountId) + seededUnconfirmedPtyIds.add(ptyId) + } +} + +export function hasSeededUnconfirmedPinnedClaudePtys(): boolean { + return seededUnconfirmedPtyIds.size > 0 +} + +/** Drops seeded ids the daemon no longer knows. */ +export function confirmSeededPinnedClaudePtys(aliveSessionIds: readonly string[]): void { + const alive = new Set(aliveSessionIds) + let dropped = false + for (const ptyId of seededUnconfirmedPtyIds) { + if (!alive.has(ptyId) && accountIdByPtyId.delete(ptyId)) { + dropped = true + } + } + seededUnconfirmedPtyIds.clear() + if (dropped) { + persist() + } +} + +function persist(): void { + try { + persistence?.write(Object.fromEntries(accountIdByPtyId)) + } catch (error) { + // Why: the record only keeps labels across a restart; losing it must never fail a spawn or an exit. + console.warn('[claude-pinned-pty] Failed to persist pinned Claude PTYs:', error) + } +} + +export function readClaudePinnedPtyRegistryFile(filePath: string): Record { + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(filePath, 'utf-8')) + } catch { + return {} + } + const panes = parsed && typeof parsed === 'object' && 'panes' in parsed ? parsed.panes : undefined + if (!panes || typeof panes !== 'object' || Array.isArray(panes)) { + return {} + } + const entries: Record = {} + for (const [ptyId, accountId] of Object.entries(panes)) { + if (typeof accountId === 'string' && accountId) { + entries[ptyId] = accountId + } + } + return entries +} + +export function createClaudePinnedPtyFilePersistence(filePath: string): ClaudePinnedPtyPersistence { + return { + write(entries) { + mkdirSync(dirname(filePath), { recursive: true }) + writeFileAtomically(filePath, `${JSON.stringify({ version: 1, panes: entries })}\n`, { + mode: 0o600 + }) + } + } +} + +export const _internals = { + reset(): void { + accountIdByPtyId.clear() + seededUnconfirmedPtyIds.clear() + persistence = null + } +} diff --git a/src/main/claude-accounts/claude-profile-router.test.ts b/src/main/claude-accounts/claude-profile-router.test.ts index 2615d946d11a..a1916c9de470 100644 --- a/src/main/claude-accounts/claude-profile-router.test.ts +++ b/src/main/claude-accounts/claude-profile-router.test.ts @@ -28,6 +28,7 @@ import { } from '../../shared/claude-profile-routing' import { getPosixClaudeShellFunction } from '../../shared/claude-shell-function' import { ClaudeProfileRouter, type ClaudeProfileRouterSettings } from './claude-profile-router' +import { prepareClaudePinnedAccountLaunch } from './claude-pinned-account-launch' import { claudeProfileHistoryDirs, installClaudeProfileRouter @@ -184,6 +185,33 @@ describe('ClaudeProfileRouter', () => { expect(f.router.coveredBySystemDefault('a')).toBe(false) }) + it('pins an --account launch to System default only while that holds the account’s login', async () => { + const f = fixture() + signIn(f.userHome, 'b@example.test') + await expect(prepareClaudePinnedAccountLaunch(f.router, f.dataRoot, 'b')).resolves.toEqual({ + configDir: join(f.userHome, '.claude'), + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: {}, + pinnedAccountId: 'b', + provenance: 'profile:b:pinned' + }) + + signIn(f.home('b'), 'b@example.test') + const ownFolderLaunch = prepareClaudePinnedAccountLaunch(f.router, f.dataRoot, 'b') + await vi.waitFor(() => expect(f.setup.calls).toBe(1)) + f.setup.settle() + await expect(ownFolderLaunch).resolves.toMatchObject({ + configDir: f.home('b'), + envPatch: { + CLAUDE_CONFIG_DIR: f.home('b'), + ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.home('b') + }, + pinnedAccountId: 'b' + }) + }) + it('tells whether a terminal opened before routing runs another account than the selected one', () => { const f = fixture() expect(f.router.systemDefaultRunsAnotherAccount()).toBe(true) diff --git a/src/main/claude-accounts/claude-profile-router.ts b/src/main/claude-accounts/claude-profile-router.ts index d190e1fc8f3b..cfdba534a036 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -215,6 +215,11 @@ export class ClaudeProfileRouter { return this.describe(accountId).home } + /** Settles once the login shell's env, which decides System default, has arrived. */ + envSettled(): Promise { + return this.envReady + } + /** Creates and sets up an account's folder for sign-in; the login itself is Claude's. */ async prepareAccount(accountId: string): Promise { const profile = this.describe(accountId) diff --git a/src/main/claude-accounts/claude-pty-account-registries-startup.ts b/src/main/claude-accounts/claude-pty-account-registries-startup.ts new file mode 100644 index 000000000000..76befc19cb16 --- /dev/null +++ b/src/main/claude-accounts/claude-pty-account-registries-startup.ts @@ -0,0 +1,14 @@ +import { join } from 'node:path' +import { + attachClaudePinnedPtyPersistence, + createClaudePinnedPtyFilePersistence, + readClaudePinnedPtyRegistryFile, + seedPinnedClaudePtysFromPersistence +} from './claude-pinned-pty-registry' + +/** Restores which surviving `--account` PTYs run which account, so their labels outlive a restart. */ +export function seedClaudePinnedPtyRegistry(userDataPath: string): void { + const registryPath = join(userDataPath, 'claude-pinned-pane-accounts.json') + seedPinnedClaudePtysFromPersistence(readClaudePinnedPtyRegistryFile(registryPath)) + attachClaudePinnedPtyPersistence(createClaudePinnedPtyFilePersistence(registryPath)) +} diff --git a/src/main/claude-accounts/environment.ts b/src/main/claude-accounts/environment.ts index b8d5da39077f..8a19b0c620cf 100644 --- a/src/main/claude-accounts/environment.ts +++ b/src/main/claude-accounts/environment.ts @@ -17,6 +17,8 @@ export type ClaudeEnvPatch = { [CLAUDE_INJECTED_CONFIG_DIR_ENV]?: string [CLAUDE_USER_CONFIG_DIR_ENV]?: string CLAUDE_CONFIG_DIR?: string + /** Claude Code 2.1.220+ hashes this, not CLAUDE_CONFIG_DIR, for its scoped Keychain item. */ + CLAUDE_SECURESTORAGE_CONFIG_DIR?: string ANTHROPIC_CUSTOM_HEADERS?: string } @@ -64,6 +66,9 @@ export function applyClaudeEnvPatch( if (patch.CLAUDE_CONFIG_DIR) { baseEnv.CLAUDE_CONFIG_DIR = patch.CLAUDE_CONFIG_DIR } + if (patch.CLAUDE_SECURESTORAGE_CONFIG_DIR) { + baseEnv.CLAUDE_SECURESTORAGE_CONFIG_DIR = patch.CLAUDE_SECURESTORAGE_CONFIG_DIR + } if (patch.ANTHROPIC_CUSTOM_HEADERS !== undefined) { baseEnv.ANTHROPIC_CUSTOM_HEADERS = patch.ANTHROPIC_CUSTOM_HEADERS } diff --git a/src/main/claude-accounts/pinned-claude-pane-account.test.ts b/src/main/claude-accounts/pinned-claude-pane-account.test.ts new file mode 100644 index 000000000000..966ba1c7b0c9 --- /dev/null +++ b/src/main/claude-accounts/pinned-claude-pane-account.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + _internals, + markPinnedClaudePtySpawned, + seedPinnedClaudePtysFromPersistence +} from './claude-pinned-pty-registry' +import { pinnedClaudeAccountIdForPane, withPinnedClaudeAccount } from './pinned-claude-pane-account' + +const LEAF = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = `tab-1:${LEAF}` + +function lookup(options: { livePtyId?: string; persistedPtyId?: string }) { + return { + getLivePtyIdForPaneKey: (paneKey: string) => + paneKey === PANE_KEY ? options.livePtyId : undefined, + getPersistedTerminalLayouts: () => + options.persistedPtyId + ? { 'tab-1': { ptyIdsByLeafId: { [LEAF]: options.persistedPtyId } } } + : {} + } +} + +describe('pinnedClaudeAccountIdForPane', () => { + afterEach(() => { + _internals.reset() + }) + + it('stamps the account of the live PTY bound to the pane', () => { + markPinnedClaudePtySpawned('pty-1', 'acct-b') + expect( + withPinnedClaudeAccount( + { paneKey: PANE_KEY, connectionId: null }, + lookup({ livePtyId: 'pty-1' }) + ) + ).toEqual({ + paneKey: PANE_KEY, + connectionId: null, + claudeAccountId: 'acct-b' + }) + }) + + it('resolves a restored pane through its persisted PTY before it reattaches', () => { + // Restart: the registry is seeded from claude-pinned-pane-accounts.json and nothing is bound yet. + seedPinnedClaudePtysFromPersistence({ 'wt@@pty-1': 'acct-b' }) + expect( + pinnedClaudeAccountIdForPane( + { paneKey: PANE_KEY, connectionId: null }, + lookup({ persistedPtyId: 'wt@@pty-1' }) + ) + ).toBe('acct-b') + }) + + it('leaves unpinned and SSH panes unstamped', () => { + markPinnedClaudePtySpawned('pty-1', 'acct-b') + expect( + withPinnedClaudeAccount( + { paneKey: PANE_KEY, connectionId: null }, + lookup({ livePtyId: 'pty-2', persistedPtyId: 'pty-1' }) + ) + ).toEqual({ paneKey: PANE_KEY, connectionId: null }) + expect( + pinnedClaudeAccountIdForPane( + { paneKey: PANE_KEY, connectionId: 'ssh-1' }, + lookup({ livePtyId: 'pty-1' }) + ) + ).toBeUndefined() + }) +}) diff --git a/src/main/claude-accounts/pinned-claude-pane-account.ts b/src/main/claude-accounts/pinned-claude-pane-account.ts new file mode 100644 index 000000000000..f11d8117334f --- /dev/null +++ b/src/main/claude-accounts/pinned-claude-pane-account.ts @@ -0,0 +1,40 @@ +import { parsePaneKey } from '../../shared/stable-pane-id' +import { getPinnedClaudeAccountIdForPty } from './claude-pinned-pty-registry' + +type PersistedTerminalLayouts = Record< + string, + { ptyIdsByLeafId?: Record } | undefined +> + +export type PinnedClaudePaneLookup = { + getLivePtyIdForPaneKey: (paneKey: string) => string | undefined + getPersistedTerminalLayouts: () => PersistedTerminalLayouts | undefined +} + +/** The managed account the pane's Claude PTY is pinned to; undefined for unpinned, SSH and unknown panes. */ +export function pinnedClaudeAccountIdForPane( + row: { paneKey: string; connectionId?: string | null }, + lookup: PinnedClaudePaneLookup +): string | undefined { + if (row.connectionId) { + return undefined + } + const livePtyId = lookup.getLivePtyIdForPaneKey(row.paneKey) + if (livePtyId) { + return getPinnedClaudeAccountIdForPty(livePtyId) + } + // Why: after a restart the renderer pulls the status snapshot before a restored pane reattaches, + // so only the persisted layout can name the surviving PTY at that point. + const pane = parsePaneKey(row.paneKey) + const persistedPtyId = pane + ? lookup.getPersistedTerminalLayouts()?.[pane.tabId]?.ptyIdsByLeafId?.[pane.leafId] + : undefined + return persistedPtyId ? getPinnedClaudeAccountIdForPty(persistedPtyId) : undefined +} + +export function withPinnedClaudeAccount< + T extends { paneKey: string; connectionId?: string | null } +>(row: T, lookup: PinnedClaudePaneLookup): T & { claudeAccountId?: string } { + const claudeAccountId = pinnedClaudeAccountIdForPane(row, lookup) + return claudeAccountId ? { ...row, claudeAccountId } : row +} diff --git a/src/main/claude-accounts/project-claude-account-resolution.test.ts b/src/main/claude-accounts/project-claude-account-resolution.test.ts new file mode 100644 index 000000000000..845ec003fac9 --- /dev/null +++ b/src/main/claude-accounts/project-claude-account-resolution.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { resolveProjectClaudeAccount } from './project-claude-account-resolution' +import { ACTIVE_CLAUDE_ACCOUNT } from '../../shared/claude/project-claude-account-preference' +import { getRepoMainWorktreeId } from '../../shared/worktree/id' +import type { Repo } from '../../shared/repo-types' + +const worktreeId = getRepoMainWorktreeId({ id: 'repo-1', path: '/w/app' }) +const repo = (agentAccounts?: Repo['agentAccounts']): Repo | undefined => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolver only reads agentAccounts. + ({ id: 'repo-1', agentAccounts }) as Repo + +describe('resolveProjectClaudeAccount', () => { + it('prefers explicit, then launch config, then the saved project account', () => { + const getRepo = () => repo({ claude: { mode: 'account', accountId: 'saved' } }) + expect(resolveProjectClaudeAccount({ getRepo, worktreeId, explicitAccountId: 'cli' })).toBe( + 'cli' + ) + expect( + resolveProjectClaudeAccount({ getRepo, worktreeId, launchConfigAccountId: 'resume' }) + ).toBe('resume') + expect(resolveProjectClaudeAccount({ getRepo, worktreeId })).toBe('saved') + }) + + it('keeps the active sentinel unpinned even when the project has an account', () => { + const getRepo = () => repo({ claude: { mode: 'account', accountId: 'saved' } }) + expect( + resolveProjectClaudeAccount({ + getRepo, + worktreeId, + launchConfigAccountId: ACTIVE_CLAUDE_ACCOUNT + }) + ).toBeUndefined() + }) + + it('keeps an explicit active sentinel off the project account', () => { + const getRepo = () => repo({ claude: { mode: 'account', accountId: 'saved' } }) + expect( + resolveProjectClaudeAccount({ getRepo, worktreeId, explicitAccountId: ACTIVE_CLAUDE_ACCOUNT }) + ).toBeUndefined() + }) + + it('applies only an explicit account to a WSL terminal', () => { + const getRepo = () => repo({ claude: { mode: 'account', accountId: 'saved' } }) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + expect(resolveProjectClaudeAccount({ getRepo, worktreeId, target })).toBeUndefined() + expect( + resolveProjectClaudeAccount({ getRepo, worktreeId, target, launchConfigAccountId: 'resume' }) + ).toBeUndefined() + expect( + resolveProjectClaudeAccount({ getRepo, worktreeId, target, explicitAccountId: 'cli' }) + ).toBe('cli') + }) + + it('returns nothing for ask, no preference, or unknown repos', () => { + expect( + resolveProjectClaudeAccount({ getRepo: () => repo({ claude: { mode: 'ask' } }), worktreeId }) + ).toBeUndefined() + expect(resolveProjectClaudeAccount({ getRepo: () => repo(), worktreeId })).toBeUndefined() + expect(resolveProjectClaudeAccount({ getRepo: () => undefined, worktreeId })).toBeUndefined() + expect( + resolveProjectClaudeAccount({ getRepo: () => repo(), worktreeId: undefined }) + ).toBeUndefined() + }) +}) diff --git a/src/main/claude-accounts/project-claude-account-resolution.ts b/src/main/claude-accounts/project-claude-account-resolution.ts new file mode 100644 index 000000000000..40967727eecc --- /dev/null +++ b/src/main/claude-accounts/project-claude-account-resolution.ts @@ -0,0 +1,40 @@ +import type { Repo } from '../../shared/repo-types' +import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import { + ACTIVE_CLAUDE_ACCOUNT, + isPinnableClaudeAccountId +} from '../../shared/claude/project-claude-account-preference' + +export function resolveProjectClaudeAccount(input: { + getRepo: (repoId: string) => Repo | undefined + worktreeId?: string + launchConfigAccountId?: string + explicitAccountId?: string + /** The terminal's account target; absent means the host. */ + target?: { runtime?: 'host' | 'wsl' } +}): string | undefined { + if (input.explicitAccountId === ACTIVE_CLAUDE_ACCOUNT) { + return undefined + } + if (isPinnableClaudeAccountId(input.explicitAccountId)) { + return input.explicitAccountId + } + // Why: pinned launches are host-only, so a WSL terminal ignores recorded and saved defaults + // and launches unpinned instead of refusing; an explicit `--account` still refuses there. + if (input.target?.runtime === 'wsl') { + return undefined + } + // Why: a launch config records the launch's own choice (incl. "active this time"); it outranks the project default on resume. + if (input.launchConfigAccountId === ACTIVE_CLAUDE_ACCOUNT) { + return undefined + } + if (isPinnableClaudeAccountId(input.launchConfigAccountId)) { + return input.launchConfigAccountId + } + if (!input.worktreeId) { + return undefined + } + const repoId = getRepoIdFromWorktreeId(input.worktreeId) + const preference = repoId ? input.getRepo(repoId)?.agentAccounts?.claude : undefined + return preference?.mode === 'account' ? preference.accountId : undefined +} diff --git a/src/main/claude-accounts/runtime-auth-service-account-launch.test.ts b/src/main/claude-accounts/runtime-auth-service-account-launch.test.ts new file mode 100644 index 000000000000..1d842c8c83e9 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-account-launch.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' + +const fakes = vi.hoisted(() => ({ + prepareClaudePinnedAccountLaunch: vi.fn( + async (_router: unknown, _dataRoot: string, accountId: string) => ({ + provenance: `profile:${accountId}:pinned` + }) + ), + host: { + prepareLaunch: vi.fn(async () => ({ provenance: 'profile:acct-a' })), + coveredBySystemDefault: vi.fn(() => true), + publish: () => {} + } +})) +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getPath: () => '/data' }) +})) +vi.mock('../wsl', () => ({ getDefaultWslDistro: () => null, getWslHome: () => null })) +vi.mock('./claude-profile-router', () => ({ + ClaudeProfileRouter: function ClaudeProfileRouter() { + return fakes.host + } +})) +vi.mock('./claude-pinned-account-launch', () => ({ + prepareClaudePinnedAccountLaunch: fakes.prepareClaudePinnedAccountLaunch +})) +vi.mock('./claude-profile-installed-router', () => ({ + installClaudeProfileRouter: () => {}, + installClaudeWslProfileRouter: () => {} +})) + +import { ClaudeRuntimeAuthService } from './runtime-auth-service' + +function account(id: string, overrides: Partial = {}): ClaudeManagedAccount { + return { + id, + email: `${id}@example.test`, + managedAuthPath: '', + managedAuthRuntime: 'host', + authMethod: 'subscription-oauth', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1, + ...overrides + } +} + +function service(): ClaudeRuntimeAuthService { + const settings: GlobalSettings = { + ...getDefaultSettings('/home/user'), + claudeManagedAccounts: [ + account('acct-a'), + account('acct-b'), + account('acct-w', { managedAuthRuntime: 'wsl' }) + ], + activeClaudeManagedAccountId: 'acct-a' + } + return new ClaudeRuntimeAuthService({ getSettings: () => settings }) +} + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('ClaudeRuntimeAuthService --account launch', () => { + it('runs an account that is not the selected one on its own pinned preparation', async () => { + const auth = await service().prepareForClaudeLaunch(undefined, { accountId: 'acct-b' }) + + expect(fakes.prepareClaudePinnedAccountLaunch).toHaveBeenCalledWith( + fakes.host, + '/data', + 'acct-b' + ) + expect(auth).toEqual({ provenance: 'profile:acct-b:pinned' }) + }) + + it('sends the selected account down the normal launch path', async () => { + const auth = await service().prepareForClaudeLaunch(undefined, { accountId: 'acct-a' }) + + expect(auth).toEqual({ provenance: 'profile:acct-a' }) + expect(fakes.prepareClaudePinnedAccountLaunch).not.toHaveBeenCalled() + }) + + it('names the selected account when System default holds its login', async () => { + fakes.host.prepareLaunch.mockResolvedValueOnce({ provenance: 'system' }) + + const auth = await service().prepareForClaudeLaunch(undefined, { accountId: 'acct-a' }) + + expect(auth).toEqual({ provenance: 'profile:acct-a' }) + + fakes.host.prepareLaunch.mockResolvedValueOnce({ provenance: 'system' }) + fakes.host.coveredBySystemDefault.mockReturnValueOnce(false) + await expect( + service().prepareForClaudeLaunch(undefined, { accountId: 'acct-a' }) + ).resolves.toEqual({ provenance: 'system' }) + }) + + it('refuses a missing account and a WSL account before touching any folder', async () => { + const auth = service() + await expect(auth.prepareForClaudeLaunch(undefined, { accountId: 'gone' })).rejects.toThrow( + '[claude_pinned:account-missing]' + ) + await expect(auth.prepareForClaudeLaunch(undefined, { accountId: 'acct-w' })).rejects.toThrow( + '[claude_pinned:unsupported-host]' + ) + expect(fakes.prepareClaudePinnedAccountLaunch).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index d84fe4bbdbaf..7bba2869ca41 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -11,10 +11,21 @@ import { installClaudeProfileRouter, installClaudeWslProfileRouter } from './claude-profile-installed-router' -import type { ClaudeAccountSelectionTarget } from './runtime-selection' -import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' +import { + getSelectedClaudeAccountIdForTarget, + type ClaudeAccountSelectionTarget +} from './runtime-selection' +import type { + ClaudeLaunchAuthOptions, + ClaudeRuntimeAuthPreparation +} from './runtime-auth/runtime-auth-types' +import { claudePinnedLaunchError } from '../../shared/claude/claude-pinned-launch-error' +import { prepareClaudePinnedAccountLaunch } from './claude-pinned-account-launch' -export type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' +export type { + ClaudeLaunchAuthOptions, + ClaudeRuntimeAuthPreparation +} from './runtime-auth/runtime-auth-types' /** `configDir` is the CLAUDE_CONFIG_DIR value; `readPath` is where this host reads it (UNC for WSL). */ export type ClaudeAccountFolder = { configDir: string; readPath: string } @@ -41,12 +52,48 @@ export class ClaudeRuntimeAuthService { } prepareForClaudeLaunch( - target?: ClaudeAccountSelectionTarget + target?: ClaudeAccountSelectionTarget, + options?: ClaudeLaunchAuthOptions ): Promise { + if (options?.accountId) { + return this.prepareRequestedAccountLaunch(options.accountId, target) + } const wsl = this.wslRouteFor(target) return wsl ? wsl.router.prepareLaunch(wsl.distro) : this.router.prepareLaunch() } + /** + * A `--account` launch: Claude runs straight from that account's own folder, with no pointer, so + * a later switch of the selected account never moves it. The selected account takes the normal path. + */ + private async prepareRequestedAccountLaunch( + accountId: string, + target?: ClaudeAccountSelectionTarget + ): Promise { + const settings = this.store.getSettings() + const account = settings.claudeManagedAccounts.find((entry) => entry.id === accountId) + if (!account) { + throw claudePinnedLaunchError( + 'account-missing', + 'That Claude account no longer exists. Run `orca account list` and retry.' + ) + } + if (this.wslRouteFor(target) || account.managedAuthRuntime === 'wsl') { + throw claudePinnedLaunchError( + 'unsupported-host', + 'Claude --account launches are not supported for WSL terminals yet.' + ) + } + if (getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' }) === accountId) { + const preparation = await this.router.prepareLaunch() + // Why rechecked: the selection can move while prepareLaunch waits for the login shell's env. + return preparation.provenance === 'system' && this.router.coveredBySystemDefault(accountId) + ? { ...preparation, provenance: `profile:${accountId}` } + : preparation + } + return prepareClaudePinnedAccountLaunch(this.router, this.dataRoot, accountId) + } + async prepareForRateLimitFetch( target?: ClaudeAccountSelectionTarget ): Promise { diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts index 73c2a04a50f5..01384de72e1e 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-types.ts @@ -6,7 +6,14 @@ export type ClaudeRuntimeAuthPreparation = { wslDistro?: string | null wslLinuxConfigDir?: string | null envPatch: ClaudeEnvPatch + /** Set only by a `--account` launch on a host account that is not the selected one. */ + pinnedAccountId?: string provenance: string /** Usage only: why the selected account cannot be read; usage polling must not throw for it. */ usageError?: string } + +export type ClaudeLaunchAuthOptions = { + /** A managed host account the launch must run on, whether or not it is the selected one. */ + accountId?: string +} diff --git a/src/main/daemon/daemon-provider-init.ts b/src/main/daemon/daemon-provider-init.ts index 90da101f1220..820f84ed9771 100644 --- a/src/main/daemon/daemon-provider-init.ts +++ b/src/main/daemon/daemon-provider-init.ts @@ -1,5 +1,9 @@ import { getLocalPtyProvider } from '../ipc/pty/provider/registry' import { rebindLocalProviderListeners } from '../ipc/pty/provider/listener-lifecycle' +import { + confirmSeededPinnedClaudePtys, + hasSeededUnconfirmedPinnedClaudePtys +} from '../claude-accounts/claude-pinned-pty-registry' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics' import { checkDaemonHealth } from './daemon-health' import { collectPinnedDaemonVersions, pruneOldDaemonHosts } from './daemon-host-relocation' @@ -186,6 +190,7 @@ export async function initDaemonPtyProvider( if (process.platform === 'darwin' && newSpawner.getHandle()?.adopted) { void reportDaemonAdoption(runtimeDir, info.socketPath, info.tokenPath, newAdapter) } + await reconcileSeededPinnedClaudePtys(routedAdapter) } // Why off the init path: this is measurement of an adopted daemon (#17696), and neither its probes nor their failure may delay or fail startup. @@ -212,3 +217,27 @@ async function reportDaemonAdoption( // Best-effort measurement only. } } + +// Why: drop restored `--account` labels only for sessions the daemon confirmed dead; a failed listing keeps them. +async function reconcileSeededPinnedClaudePtys(provider: DaemonProvider): Promise { + if (!hasSeededUnconfirmedPinnedClaudePtys()) { + return + } + try { + const adapters = + provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider + ? provider.getAllAdapters() + : [provider] + const results = await Promise.allSettled(adapters.map((entry) => entry.listSessions())) + if (results.some((result) => result.status === 'rejected')) { + return + } + confirmSeededPinnedClaudePtys( + results.flatMap((result) => + result.status === 'fulfilled' ? result.value.map((session) => session.sessionId) : [] + ) + ) + } catch (error) { + console.warn('[daemon] Failed to reconcile restored pinned Claude PTYs:', error) + } +} diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index fe488b087c55..952159773062 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -152,6 +152,33 @@ describe('agentStatus:getSnapshot IPC', () => { expect(handler!({})).toEqual(snapshot) }) + it("stamps a restored pane's pinned Claude account from the pinned PTY registry", async () => { + const row = { + paneKey: PANE_KEY, + connectionId: null, + state: 'done', + prompt: '', + agentType: 'claude', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000 + } + getStatusSnapshot.mockReturnValue([row]) + const registry = await import('../claude-accounts/claude-pinned-pty-registry') + registry.seedPinnedClaudePtysFromPersistence({ 'wt@@pty-1': 'acct-b' }) + const { registerAgentHookHandlers } = await import('./agent-hooks') + // The renderer has not reattached the pane yet; only the persisted layout names its PTY. + registerAgentHookHandlers(undefined, { + getPtyIdForPaneKey: () => undefined, + getPersistedTerminalLayouts: () => ({ + 'tab-1': { ptyIdsByLeafId: { '11111111-1111-4111-8111-111111111111': 'wt@@pty-1' } } + }) + }) + + expect(handleHandlers.get('agentStatus:getSnapshot')!({})).toEqual([ + { ...row, claudeAccountId: 'acct-b' } + ]) + }) + // The half-migration seam: until PR 2 retires the renderer's own feed bridge, main must not // publish structured rows to the renderer at all — one pane key, one writer. it('omits structured rows the renderer feed bridge still owns', async () => { diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index e6fecfc06ee0..423d206ec0de 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -14,9 +14,14 @@ import { enrichAgentStatusIpcPayload, type AgentStatusRuntimeEnrichment } from './agent-status-ipc-boundary' +import { + withPinnedClaudeAccount, + type PinnedClaudePaneLookup +} from '../claude-accounts/pinned-claude-pane-account' type AgentHookHandlerDependencies = { getPtyIdForPaneKey?: (paneKey: string) => string | undefined + getPersistedTerminalLayouts?: PinnedClaudePaneLookup['getPersistedTerminalLayouts'] } // Why: install/remove are intentionally not exposed to the renderer. Orca @@ -46,6 +51,10 @@ export function registerAgentHookHandlers( runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey) }) }) + const pinnedPaneLookup: PinnedClaudePaneLookup = { + getLivePtyIdForPaneKey: (paneKey) => dependencies.getPtyIdForPaneKey?.(paneKey), + getPersistedTerminalLayouts: () => dependencies.getPersistedTerminalLayouts?.() + } ipcMain.handle('agentStatus:getSnapshot', (): AgentStatusIpcPayload[] => { // Why: the renderer pulls this after workspace hydration, so startup cannot // lose replayed statuses while its local store is still empty. Match the @@ -55,7 +64,9 @@ export function registerAgentHookHandlers( .getStatusSnapshot() // Same rule as the live push: the renderer's feed bridge owns structured rows for now. .filter((entry) => entry.structuredHost === undefined) - .map((entry) => enrichAgentStatusIpcPayload(entry, runtime)) + .map((entry) => + withPinnedClaudeAccount(enrichAgentStatusIpcPayload(entry, runtime), pinnedPaneLookup) + ) ) }) ipcMain.handle('agentStatus:inferInterrupt', (_event, request: unknown): boolean => { diff --git a/src/main/ipc/pty-runtime-claude-account-spawn.test.ts b/src/main/ipc/pty-runtime-claude-account-spawn.test.ts new file mode 100644 index 000000000000..788ac2efe6f9 --- /dev/null +++ b/src/main/ipc/pty-runtime-claude-account-spawn.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, it, vi } from 'vitest' +import { spawnMock } from './pty-ipc-mock-registry' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { registerPtyHandlers } from './pty/register-handlers' +import { + _internals as pinnedRegistryInternals, + getPinnedClaudeAccountIdForPty +} from '../claude-accounts/claude-pinned-pty-registry' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' +import type { Store } from '../persistence' +import { + ACTIVE_CLAUDE_ACCOUNT, + type ProjectClaudeAccountPreference +} from '../../shared/claude/project-claude-account-preference' +import { terminalCreateClaudeAccountIdField } from '../runtime/runtime-agent-launch-resolution' +import { resolveRuntimeSpawnClaudeAccount } from './pty/runtime/spawn-claude-account' +import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './pty/runtime/spawn-state' +import type { PtyRuntimeControllerDeps } from './pty/runtime/controller-deps' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +type RuntimeSpawnArgs = { + cols: number + rows: number + worktreeId?: string + command?: string + launchAgent?: 'claude' | 'codex' + connectionId?: string | null + env?: Record + claudeAccountId?: string +} +type RuntimeSpawnController = { spawn(args: RuntimeSpawnArgs): Promise<{ id: string }> } + +const ACTIVE_PREPARATION: ClaudeRuntimeAuthPreparation = { + configDir: '/home/user/.claude', + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: {}, + provenance: 'profile:acct-a' +} + +const PINNED_DIR = '/userData/claude-profiles/acct-b/home' +const PINNED_PREPARATION: ClaudeRuntimeAuthPreparation = { + configDir: PINNED_DIR, + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: { CLAUDE_CONFIG_DIR: PINNED_DIR, ORCA_CLAUDE_INJECTED_CONFIG_DIR: PINNED_DIR }, + pinnedAccountId: 'acct-b', + provenance: 'profile:acct-b:pinned' +} + +function storeWithRepo(claude: ProjectClaudeAccountPreference): Store { + const repo = { id: 'wt-1', agentAccounts: { claude } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolveRuntimeSpawnClaudeAccount only reads getRepo; the rest of Store belongs to unrelated persistence callers this test never exercises. + return { getRepo: (repoId: string) => (repoId === 'wt-1' ? repo : undefined) } as unknown as Store +} + +function runtimeCtx(input: { + args: Partial + deps?: { store?: Store } +}): ReturnType { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolveRuntimeSpawnClaudeAccount only reads deps.store; the rest of PtyRuntimeControllerDeps belongs to later spawn stages this test never runs. + const deps = { store: input.deps?.store } as unknown as PtyRuntimeControllerDeps + return createRuntimePtySpawnState(deps, { cols: 80, rows: 24, ...input.args }) +} + +describe('runtime PTY spawn pinned to a Claude account', () => { + const { mainWindow } = setupPtyIpcSuite() + + function registerRuntimeController( + prepareClaudeAuth: (...args: unknown[]) => Promise, + store?: Store + ): RuntimeSpawnController { + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value: RuntimeSpawnController) => { + controller = value + }), + preAllocateHandleForPty: vi.fn(() => 'term_pinned'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + registerPtyHandlers( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the suite's mock window stands in for the BrowserWindow these handlers only send to. + mainWindow as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration reads only the runtime members stubbed above. + runtime as never, + undefined, + undefined, + prepareClaudeAuth, + store + ) + if (!controller) { + throw new Error('runtime PTY controller was not registered') + } + return controller + } + + function lastSpawnEnv(): Record { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every spawn in this suite passes a string env map. + return spawnMock.mock.calls.at(-1)![2].env as Record + } + + function cleanup(): void { + pinnedRegistryInternals.reset() + } + + it('leaves a launch without --account byte-for-byte unchanged', async () => { + const prepareClaudeAuth = vi.fn(async (..._args: unknown[]) => ACTIVE_PREPARATION) + const controller = registerRuntimeController(prepareClaudeAuth) + const args = { cols: 80, rows: 24, worktreeId: 'wt-1', command: 'claude', env: { KEEP: '1' } } + + await controller.spawn(args) + const plainEnv = lastSpawnEnv() + const plainSpawnOptions = { ...spawnMock.mock.calls.at(-1)![2], env: undefined } + // Why: `--account` naming the host's active account resolves to the same preparation, so any + // difference between these two spawns would be the new branch leaking into the old path. + const activePinned = await controller.spawn({ ...args, claudeAccountId: 'acct-a' }) + const activePinnedEnv = lastSpawnEnv() + try { + expect(prepareClaudeAuth.mock.calls[0]).toHaveLength(1) + expect(prepareClaudeAuth.mock.calls[1]?.[1]).toEqual({ accountId: 'acct-a' }) + expect(plainEnv).not.toHaveProperty('CLAUDE_CONFIG_DIR') + expect(plainEnv.KEEP).toBe('1') + const withoutPerSpawnIds = (env: Record) => + Object.fromEntries( + Object.entries(env).filter( + ([key]) => !/^ORCA_(PANE_KEY|TERMINAL_HANDLE|PTY_ID)/.test(key) + ) + ) + expect(withoutPerSpawnIds(activePinnedEnv)).toEqual(withoutPerSpawnIds(plainEnv)) + expect({ ...spawnMock.mock.calls.at(-1)![2], env: undefined }).toEqual(plainSpawnOptions) + expect(getPinnedClaudeAccountIdForPty(activePinned.id)).toBeUndefined() + } finally { + cleanup() + } + }) + + it("spawns a pinned Claude in the account's folder and labels its PTY", async () => { + const prepareClaudeAuth = vi.fn(async () => PINNED_PREPARATION) + const controller = registerRuntimeController(prepareClaudeAuth) + + const spawned = await controller.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-1', + command: 'claude', + claudeAccountId: 'acct-b' + }) + try { + const env = lastSpawnEnv() + expect(env.CLAUDE_CONFIG_DIR).toBe(PINNED_DIR) + expect(env.ORCA_CLAUDE_INJECTED_CONFIG_DIR).toBe(PINNED_DIR) + expect(prepareClaudeAuth).toHaveBeenCalledWith({ runtime: 'host' }, { accountId: 'acct-b' }) + expect(getPinnedClaudeAccountIdForPty(spawned.id)).toBe('acct-b') + } finally { + cleanup() + } + }) + + it('treats a gated setup launch as Claude when the agent id says so', async () => { + const prepareClaudeAuth = vi.fn(async () => PINNED_PREPARATION) + const controller = registerRuntimeController(prepareClaudeAuth) + await controller.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-1', + command: `bash -lc 'eval "$ORCA_SETUP_GATE"'`, + launchAgent: 'claude', + claudeAccountId: 'acct-b' + }) + try { + expect(prepareClaudeAuth).toHaveBeenCalledTimes(1) + expect(lastSpawnEnv().CLAUDE_CONFIG_DIR).toBe(PINNED_DIR) + } finally { + cleanup() + } + }) + + it('refuses non-Claude and SSH launches before preparing any credentials', async () => { + const prepareClaudeAuth = vi.fn(async () => PINNED_PREPARATION) + const controller = registerRuntimeController(prepareClaudeAuth) + + await expect( + controller.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-1', + command: 'codex', + claudeAccountId: 'acct-b' + }) + ).rejects.toThrow('--account applies only to Claude launches.') + await expect( + controller.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-1', + command: 'claude', + connectionId: 'ssh-1', + claudeAccountId: 'acct-b' + }) + ).rejects.toThrow() + expect(prepareClaudeAuth).not.toHaveBeenCalled() + }) + + it('refuses to launch when the prepared account is not the requested one', async () => { + const prepareClaudeAuth = vi.fn(async () => ({ + ...ACTIVE_PREPARATION, + provenance: 'profile:acct-c' + })) + const controller = registerRuntimeController(prepareClaudeAuth) + const spawnsBefore = spawnMock.mock.calls.length + await expect( + controller.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-1', + command: 'claude', + claudeAccountId: 'acct-b' + }) + ).rejects.toThrow(/could not prepare the requested Claude account/) + expect(spawnMock.mock.calls.length).toBe(spawnsBefore) + }) +}) + +describe('resolveRuntimeSpawnClaudeAccount: project account fallback', () => { + const worktreeId = 'wt-1' + + it('falls back to the project account when no --account is given', () => { + const ctx = runtimeCtx({ + args: { command: 'claude', launchAgent: 'claude', worktreeId }, + deps: { store: storeWithRepo({ mode: 'account', accountId: 'acct-1' }) } + }) + expect(resolveRuntimeSpawnClaudeAccount(ctx)).toBe('acct-1') + }) + + it('keeps SSH and non-Claude runtime spawns unpinned by project preference', () => { + const store = storeWithRepo({ mode: 'account', accountId: 'acct-1' }) + const sshCtx = runtimeCtx({ + args: { command: 'claude', launchAgent: 'claude', worktreeId, connectionId: 'ssh-1' }, + deps: { store } + }) + const nonClaudeCtx = runtimeCtx({ + args: { command: 'zsh', worktreeId }, + deps: { store } + }) + + expect(() => resolveRuntimeSpawnClaudeAccount(sshCtx)).not.toThrow() + expect(resolveRuntimeSpawnClaudeAccount(sshCtx)).toBeUndefined() + expect(() => resolveRuntimeSpawnClaudeAccount(nonClaudeCtx)).not.toThrow() + expect(resolveRuntimeSpawnClaudeAccount(nonClaudeCtx)).toBeUndefined() + }) + + it('honors the launch config account over the project default, including "active this time"', () => { + const store = storeWithRepo({ mode: 'account', accountId: 'acct-1' }) + const resolveFor = (launchConfigClaudeAccountId: string) => + resolveRuntimeSpawnClaudeAccount( + runtimeCtx({ + args: { + command: 'claude --resume s-1', + launchAgent: 'claude', + worktreeId, + ...terminalCreateClaudeAccountIdField({ + launchConfig: { claudeAccountId: launchConfigClaudeAccountId } + }) + }, + deps: { store } + }) + ) + + expect(resolveFor(ACTIVE_CLAUDE_ACCOUNT)).toBeUndefined() + expect(resolveFor('acct-2')).toBe('acct-2') + }) +}) diff --git a/src/main/ipc/pty/claude-pinned-spawn.test.ts b/src/main/ipc/pty/claude-pinned-spawn.test.ts new file mode 100644 index 000000000000..18c4d16ff4f8 --- /dev/null +++ b/src/main/ipc/pty/claude-pinned-spawn.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClaudeRuntimeAuthPreparation } from '../../claude-accounts/runtime-auth-service' + +const mocks = vi.hoisted(() => ({ + markPinnedClaudePtySpawned: vi.fn() +})) + +vi.mock('../../claude-accounts/claude-pinned-pty-registry', () => ({ + markPinnedClaudePtySpawned: mocks.markPinnedClaudePtySpawned +})) + +import { + isFreshClaudeLaunch, + preparePinnableClaudeAuth, + markClaudePtySpawnedForAuth +} from './claude-pinned-spawn' + +beforeEach(() => { + vi.clearAllMocks() +}) + +function makeAuth( + overrides: Partial = {} +): ClaudeRuntimeAuthPreparation { + return { + configDir: '/tmp/claude', + envPatch: {}, + provenance: 'profile:acct-1', + ...overrides + } +} + +describe('isFreshClaudeLaunch', () => { + it('recognizes a launch by its command', () => { + expect(isFreshClaudeLaunch({ preAdoptedStablePane: false, command: 'claude' }, undefined)).toBe( + true + ) + }) + + it('rejects an adopted stable pane or SSH connection', () => { + expect(isFreshClaudeLaunch({ preAdoptedStablePane: true, command: 'claude' }, undefined)).toBe( + false + ) + expect( + isFreshClaudeLaunch( + { preAdoptedStablePane: false, connectionId: 'ssh-1', command: 'claude' }, + undefined + ) + ).toBe(false) + }) + + it('trusts the launch agent for a pinned launch without a matching command', () => { + expect( + isFreshClaudeLaunch({ preAdoptedStablePane: false, launchAgent: 'claude' }, 'acct-1') + ).toBe(true) + }) + + it('does not trust the launch agent without a pinned account or the trust option', () => { + expect( + isFreshClaudeLaunch({ preAdoptedStablePane: false, launchAgent: 'claude' }, undefined) + ).toBe(false) + }) + + it('trusts the launch agent when options.trustLaunchAgent is set, even without a pinned account', () => { + expect( + isFreshClaudeLaunch({ preAdoptedStablePane: false, launchAgent: 'claude' }, undefined, { + trustLaunchAgent: true + }) + ).toBe(true) + }) +}) + +describe('preparePinnableClaudeAuth', () => { + it('returns null when there is no prepare function and no pinned account', async () => { + await expect(preparePinnableClaudeAuth(undefined, {}, undefined)).resolves.toBeNull() + }) + + it('throws when a pinned launch has no prepare function', async () => { + await expect(preparePinnableClaudeAuth(undefined, {}, 'acct-1')).rejects.toThrow( + 'This Orca runtime cannot prepare Claude accounts for pinned launches.' + ) + }) + + it('calls prepare without options when there is no pinned account', async () => { + const auth = makeAuth() + const prepare = vi.fn().mockResolvedValue(auth) + await expect(preparePinnableClaudeAuth(prepare, {}, undefined)).resolves.toBe(auth) + expect(prepare).toHaveBeenCalledWith({}) + }) + + it('accepts a pinned-provenance result for the requested account', async () => { + const auth = makeAuth({ provenance: 'profile:acct-1:pinned', pinnedAccountId: 'acct-1' }) + const prepare = vi.fn().mockResolvedValue(auth) + await expect(preparePinnableClaudeAuth(prepare, {}, 'acct-1')).resolves.toBe(auth) + expect(prepare).toHaveBeenCalledWith({}, { accountId: 'acct-1' }) + }) + + it('rejects a result whose provenance is for a different account', async () => { + const prepare = vi.fn().mockResolvedValue(makeAuth({ provenance: 'profile:acct-2' })) + await expect(preparePinnableClaudeAuth(prepare, {}, 'acct-1')).rejects.toThrow( + 'Orca could not prepare the requested Claude account for this launch. Check `orca account list` and retry.' + ) + }) +}) + +describe('markClaudePtySpawnedForAuth', () => { + it('labels a pinned PTY with its account and leaves an unpinned one alone', () => { + markClaudePtySpawnedForAuth('pty-1', makeAuth({ pinnedAccountId: 'acct-1' })) + markClaudePtySpawnedForAuth('pty-2', makeAuth()) + markClaudePtySpawnedForAuth('pty-3', null) + expect(mocks.markPinnedClaudePtySpawned).toHaveBeenCalledTimes(1) + expect(mocks.markPinnedClaudePtySpawned).toHaveBeenCalledWith('pty-1', 'acct-1') + }) +}) diff --git a/src/main/ipc/pty/claude-pinned-spawn.ts b/src/main/ipc/pty/claude-pinned-spawn.ts new file mode 100644 index 000000000000..2e6cf38431d0 --- /dev/null +++ b/src/main/ipc/pty/claude-pinned-spawn.ts @@ -0,0 +1,66 @@ +import { isClaudeLaunchCommand } from './host-env/fresh-spawn-routing' +import { claudePinnedLaunchError } from '../../../shared/claude/claude-pinned-launch-error' +import { markPinnedClaudePtySpawned } from '../../claude-accounts/claude-pinned-pty-registry' +import type { PrepareClaudeAuth } from './host-env/types' +import type { ClaudeRuntimeAuthPreparation } from '../../claude-accounts/runtime-auth-service' +import type { ClaudeAccountSelectionTarget } from '../../claude-accounts/runtime-selection' + +type ClaudeLaunchShape = { + preAdoptedStablePane: boolean + connectionId?: string | null + command?: string + launchAgent?: string +} + +export function isFreshClaudeLaunch( + launch: ClaudeLaunchShape, + pinnedAccountId: string | undefined, + options?: { trustLaunchAgent?: boolean } +): boolean { + if (launch.preAdoptedStablePane || launch.connectionId) { + return false + } + // Why: a wait-for-setup launch types a setup gate, not `claude`; for a pinned launch the agent id is the proof. + return ( + isClaudeLaunchCommand(launch.command) || + ((Boolean(pinnedAccountId) || Boolean(options?.trustLaunchAgent)) && + launch.launchAgent === 'claude') + ) +} + +export async function preparePinnableClaudeAuth( + prepare: PrepareClaudeAuth | undefined, + target: ClaudeAccountSelectionTarget, + pinnedAccountId: string | undefined +): Promise { + if (!prepare) { + if (pinnedAccountId) { + throw new Error('This Orca runtime cannot prepare Claude accounts for pinned launches.') + } + return null + } + if (!pinnedAccountId) { + return prepare(target) + } + const claudeAuth = await prepare(target, { accountId: pinnedAccountId }) + // Why: the selected account's path (`profile:`) also honours the request; no other may run. + if ( + claudeAuth.provenance !== `profile:${pinnedAccountId}:pinned` && + claudeAuth.provenance !== `profile:${pinnedAccountId}` + ) { + throw claudePinnedLaunchError( + 'provenance', + 'Orca could not prepare the requested Claude account for this launch. Check `orca account list` and retry.' + ) + } + return claudeAuth +} + +export function markClaudePtySpawnedForAuth( + ptyId: string, + claudeAuth: ClaudeRuntimeAuthPreparation | null +): void { + if (claudeAuth?.pinnedAccountId) { + markPinnedClaudePtySpawned(ptyId, claudeAuth.pinnedAccountId) + } +} diff --git a/src/main/ipc/pty/host-env/types.ts b/src/main/ipc/pty/host-env/types.ts index d7564184e49d..84edb0d358bd 100644 --- a/src/main/ipc/pty/host-env/types.ts +++ b/src/main/ipc/pty/host-env/types.ts @@ -1,7 +1,10 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' import type { NetworkProxySettings } from '../../../../shared/network-proxy' -import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service' +import type { + ClaudeLaunchAuthOptions, + ClaudeRuntimeAuthPreparation +} from '../../../claude-accounts/runtime-auth-service' import type { ClaudeAccountSelectionTarget } from '../../../claude-accounts/runtime-selection' import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' @@ -78,5 +81,6 @@ export function allocatePtyLifecycleSequence(): number { } export type PrepareClaudeAuth = ( - target?: ClaudeAccountSelectionTarget + target?: ClaudeAccountSelectionTarget, + options?: ClaudeLaunchAuthOptions ) => Promise diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index cf4572c6c257..2eb9cd39f449 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -1,5 +1,6 @@ import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { agentHookServer } from '../../../agent-hooks/server' +import { markClaudePtySpawnedForAuth } from '../claude-pinned-spawn' import { registerPty } from '../../../memory/pty-registry' import type { PtySpawnResult } from '../../../providers/pty-provider-contract' import { clearMigrationUnsupportedPtysForPaneKey } from '../../../agent-hooks/migration-unsupported-pty-state' @@ -31,6 +32,9 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args + if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) { + markClaudePtySpawnedForAuth(ctx.result.id, ctx.claudeAuth) + } admitPtySpawnOwnership(ctx.deps.runtime, ctx.result) if (ctx.nativeWindowsConptySpawn) { markNativeWindowsConptyPty(ctx.result.id) diff --git a/src/main/ipc/pty/ipc/spawn-preflight-claude-account.test.ts b/src/main/ipc/pty/ipc/spawn-preflight-claude-account.test.ts new file mode 100644 index 000000000000..0ca580c70f8d --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-preflight-claude-account.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service' +import { installClaudeProfileRouter } from '../../../claude-accounts/claude-profile-installed-router' +import type { ClaudeProfileRouter } from '../../../claude-accounts/claude-profile-router' +import { CLAUDE_PROFILE_POINTER_ENV } from '../../../../shared/claude-profile-routing' +import { preparePtyIpcSpawnPreflight } from './spawn-preflight' +import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './spawn-state' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp', isPackaged: false } })) + +const WORKTREE_ID = 'repo-1::/work/repo-1' + +const PINNED_PREPARATION: ClaudeRuntimeAuthPreparation = { + configDir: '/profiles/acct-1/home', + envPatch: { CLAUDE_CONFIG_DIR: '/profiles/acct-1/home' }, + provenance: 'profile:acct-1:pinned', + pinnedAccountId: 'acct-1' +} + +function buildPreflightCtx(input: { + args: Partial + prepareClaudeAuth: PtySpawnIpcDeps['prepareClaudeAuth'] +}): PtyIpcSpawnState { + const repo = { id: 'repo-1', agentAccounts: { claude: { mode: 'account', accountId: 'acct-1' } } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: preflight and env assembly only read the members stubbed here; the rest belong to later spawn stages this test never runs. + const deps = { + prepareClaudeAuth: input.prepareClaudeAuth, + store: { getRepo: (repoId: string) => (repoId === 'repo-1' ? repo : undefined) }, + transitionSpawnHiddenRendererPtyDeliveryState: vi.fn(), + assertWorkspaceAcceptsNewPty: () => undefined, + assertFolderWorkspacePtyPathUsable: vi.fn(), + resolvePtySpawnStartupCwd: (_worktreeId: string | undefined, cwd: string | undefined) => cwd, + localStartupCwdDirectoryExists: () => true, + prepareCodexResumeHome: () => null, + noCodexResumeLaunch: (command: string | undefined) => ({ command }), + stripSequencedStartupResumeArgv: (env: Record | undefined) => env + } as unknown as PtySpawnIpcDeps + return createPtyIpcSpawnState(deps, { + cols: 80, + rows: 24, + worktreeId: WORKTREE_ID, + ...input.args + }) +} + +describe('renderer pty spawn preflight: project Claude account', () => { + afterEach(() => { + installClaudeProfileRouter(undefined) + }) + + it('pins a fresh Claude launch to the project account', async () => { + const prepareClaudeAuth = vi.fn(async () => PINNED_PREPARATION) + const ctx = buildPreflightCtx({ + args: { command: 'claude', launchAgent: 'claude' }, + prepareClaudeAuth + }) + + await preparePtyIpcSpawnPreflight(ctx) + + expect(prepareClaudeAuth).toHaveBeenCalledWith(expect.anything(), { accountId: 'acct-1' }) + expect(ctx.claudeAuth?.pinnedAccountId).toBe('acct-1') + }) + + it('leaves a non-Claude spawn unpinned', async () => { + const prepareClaudeAuth = vi.fn(async () => PINNED_PREPARATION) + const ctx = buildPreflightCtx({ args: { command: 'zsh' }, prepareClaudeAuth }) + + await preparePtyIpcSpawnPreflight(ctx) + + expect(ctx.claudeAuth).toBeNull() + expect(prepareClaudeAuth).not.toHaveBeenCalled() + }) + + it("keeps the selected account's pointer off a pinned pane but not off an unpinned one", async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: terminal env assembly only calls terminalEnv on the installed router. + installClaudeProfileRouter({ + terminalEnv: () => ({ [CLAUDE_PROFILE_POINTER_ENV]: '/profiles/selected-host' }) + } as unknown as ClaudeProfileRouter) + const prepareClaudeAuth = vi.fn(async () => PINNED_PREPARATION) + const pinned = buildPreflightCtx({ + args: { command: 'claude', launchAgent: 'claude' }, + prepareClaudeAuth + }) + const unpinned = buildPreflightCtx({ args: { command: 'zsh' }, prepareClaudeAuth }) + + await preparePtyIpcSpawnPreflight(pinned) + await preparePtyIpcSpawnPreflight(unpinned) + + expect(pinned.args.env?.[CLAUDE_PROFILE_POINTER_ENV]).toBeUndefined() + expect(unpinned.args.env?.[CLAUDE_PROFILE_POINTER_ENV]).toBe('/profiles/selected-host') + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index 73697f812a74..c2ffa32605dd 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -2,10 +2,11 @@ import { withClaudeProfileTerminalEnv } from '../../../claude-accounts/claude-pr import { mintPtySessionId } from '../../../daemon/pty-session-id' import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { - isClaudeLaunchCommand, recoverFreshSpawnProviderRouting, routesFreshSpawnsToLocalProvider } from '../host-env/fresh-spawn-routing' +import { resolveProjectClaudeAccount } from '../../../claude-accounts/project-claude-account-resolution' +import { isFreshClaudeLaunch, preparePinnableClaudeAuth } from '../claude-pinned-spawn' import { getProvider } from '../provider/registry' import { resolvePtySpawnStartupTarget } from '../spawn-startup-target' import type { PtyIpcSpawnState } from './spawn-state' @@ -70,7 +71,6 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis } } } - ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && isClaudeLaunchCommand(args.command) // Why: daemon host-env setup needs a stable id BEFORE provider.spawn so buildPtyHostEnv hooks/Pi cleanup can run; daemon still honors opts.sessionId ?? mint(). // Note: sessionId is STABLE across daemon restarts by design — do NOT simplify to a fresh UUID per spawn; that orphans reconnectable state. // Why: only clear ids minted in THIS request on failure — a caller-supplied args.sessionId may name an existing PTY we must not clobber. @@ -94,10 +94,31 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis ctx.expectedWslDistro = target.expectedWslDistro ctx.codexSelectionTarget = target.codexSelectionTarget ctx.spawnTiming.mark('preflight') - args.env = withClaudeProfileTerminalEnv(args.env, ctx.codexSelectionTarget) - ctx.claudeAuth = - ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth - ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) - : null + const pinnedClaudeAccountId = + ctx.preAdoptedStablePane || args.connectionId + ? undefined + : resolveProjectClaudeAccount({ + // Why optional: as in the push-target hook, a partial Store must not fail every spawn. + getRepo: (repoId) => ctx.deps.store?.getRepo?.(repoId), + worktreeId: args.worktreeId, + // Why unchecked: launchConfig is untrusted IPC JSON; the resolver validates the id itself. + launchConfigAccountId: args.launchConfig?.claudeAccountId, + target: ctx.codexSelectionTarget + }) + ctx.isClaudeLaunch = isFreshClaudeLaunch( + { ...args, preAdoptedStablePane: Boolean(ctx.preAdoptedStablePane) }, + pinnedClaudeAccountId + ) + ctx.claudeAuth = ctx.isClaudeLaunch + ? await preparePinnableClaudeAuth( + ctx.deps.prepareClaudeAuth, + ctx.codexSelectionTarget, + pinnedClaudeAccountId + ) + : null + // Why not pinned: the routed pointer would send a pinned pane's `claude` back to the selected account. + if (!ctx.claudeAuth?.pinnedAccountId) { + args.env = withClaudeProfileTerminalEnv(args.env, ctx.codexSelectionTarget) + } ctx.spawnTiming.mark('auth') } diff --git a/src/main/ipc/pty/provider/state-cleanup.ts b/src/main/ipc/pty/provider/state-cleanup.ts index df43c618084e..11ff491e6409 100644 --- a/src/main/ipc/pty/provider/state-cleanup.ts +++ b/src/main/ipc/pty/provider/state-cleanup.ts @@ -1,6 +1,7 @@ import { advertisedUrlWatcher } from '../../../ports/advertised-url-watcher' import { unregisterPty } from '../../../memory/pty-registry' import { forgetCodexPaneAccount } from '../../../codex/codex-pane-account-registry' +import { markPinnedClaudePtyExited } from '../../../claude-accounts/claude-pinned-pty-registry' import { openCodeHookService } from '../../../opencode/hook-service' import { piTitlebarExtensionService } from '../../../pi/titlebar-extension-service' import { agentHookServer } from '../../../agent-hooks/server' @@ -40,8 +41,9 @@ export function clearProviderPtyState( agentSessionOwners.release(id) // Why: the launch-account record outlives the app, so only a real teardown // may drop it — a disconnect that can reconnect is not a death, and a reused - // id must never inherit a dead pane's Codex account. + // id must never inherit a dead pane's Codex or pinned Claude account. forgetCodexPaneAccount(id) + markPinnedClaudePtyExited(id) } // Why: OpenCode and Pi both allocate PTY-scoped runtime state outside the // node-pty process table. Centralizing provider cleanup avoids drift where a diff --git a/src/main/ipc/pty/runtime/spawn-claude-account.ts b/src/main/ipc/pty/runtime/spawn-claude-account.ts new file mode 100644 index 000000000000..048ddce713f6 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-claude-account.ts @@ -0,0 +1,88 @@ +import { withClaudeProfileTerminalEnv } from '../../../claude-accounts/claude-profile-installed-router' +import { isClaudeLaunchCommand } from '../host-env/fresh-spawn-routing' +import { + isFreshClaudeLaunch, + preparePinnableClaudeAuth, + markClaudePtySpawnedForAuth +} from '../claude-pinned-spawn' +import { resolveProjectClaudeAccount } from '../../../claude-accounts/project-claude-account-resolution' +import { isPinnableClaudeAccountId } from '../../../../shared/claude/project-claude-account-preference' +import type { RuntimePtySpawnState } from './spawn-state' + +/** + * The `--account` a fresh runtime spawn must run on, refused where a pinned launch cannot run. + * With no explicit `--account`, falls back to the launch config's recorded choice, then the + * project's saved default; that fallback never throws, since it never applies to SSH, WSL or + * non-Claude launches. Call after `ctx.codexSelectionTarget` is set. + */ +export function resolveRuntimeSpawnClaudeAccount(ctx: RuntimePtySpawnState): string | undefined { + if (ctx.preAdoptedStablePane) { + return undefined + } + const explicit = ctx.args.claudeAccountId + // Why: the active sentinel is a soft choice, never an account to pin. + if (explicit && isPinnableClaudeAccountId(explicit)) { + if (ctx.args.connectionId) { + throw new Error('Claude --account launches are not supported for SSH workspaces.') + } + if (!isClaudeLaunchCommand(ctx.args.command) && ctx.args.launchAgent !== 'claude') { + throw new Error('--account applies only to Claude launches.') + } + return explicit + } + if ( + ctx.args.connectionId || + !isFreshClaudeLaunch({ ...ctx.args, preAdoptedStablePane: false }, undefined, { + trustLaunchAgent: true + }) + ) { + return undefined + } + return resolveProjectClaudeAccount({ + getRepo: (repoId) => ctx.deps.store?.getRepo?.(repoId), + worktreeId: ctx.args.worktreeId, + launchConfigAccountId: ctx.args.launchConfigClaudeAccountId, + target: ctx.codexSelectionTarget + }) +} + +export function isRuntimeClaudeLaunch( + ctx: RuntimePtySpawnState, + pinnedAccountId: string | undefined +): boolean { + return isFreshClaudeLaunch( + { ...ctx.args, preAdoptedStablePane: Boolean(ctx.preAdoptedStablePane) }, + pinnedAccountId + ) +} + +export async function prepareRuntimeSpawnClaudeAuth( + ctx: RuntimePtySpawnState, + pinnedAccountId: string | undefined +): Promise { + if (!ctx.isClaudeLaunch) { + if (pinnedAccountId) { + throw new Error('This Orca runtime cannot prepare Claude accounts for --account launches.') + } + return null + } + return preparePinnableClaudeAuth( + ctx.deps.prepareClaudeAuth, + ctx.codexSelectionTarget, + pinnedAccountId + ) +} + +export function markRuntimeClaudePtySpawned( + ptyId: string, + claudeAuth: RuntimePtySpawnState['claudeAuth'] +): void { + markClaudePtySpawnedForAuth(ptyId, claudeAuth) +} + +/** Skips pinned panes: the routed pointer would send their `claude` back to the selected account. */ +export function applyRuntimeClaudeProfileTerminalEnv(ctx: RuntimePtySpawnState): void { + if (!ctx.claudeAuth?.pinnedAccountId) { + ctx.args.env = withClaudeProfileTerminalEnv(ctx.args.env, ctx.codexSelectionTarget) + } +} diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index bed44e4cec6b..2351970871c5 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -16,6 +16,7 @@ import { recordCodexPaneAccountForSpawn, codexReattachedHomeRouteField } from '../host-env/codex-home' +import { markRuntimeClaudePtySpawned } from './spawn-claude-account' import { registerPty } from '../../../memory/pty-registry' import { rememberPaneKeyForPty } from '../pane/key-state' import { @@ -125,6 +126,9 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { agentSessionEnsure: ctx.result.agentSessionEnsure } } + if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) { + markRuntimeClaudePtySpawned(ctx.result.id, ctx.claudeAuth) + } reportRuntimeResumeOutcome(ctx) if (ctx.hostSessionBinding) { try { diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index a6c253eb29ae..5911d1fad346 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -1,11 +1,9 @@ -import { withClaudeProfileTerminalEnv } from '../../../claude-accounts/claude-profile-installed-router' import type { PtySpawnResult } from '../../../providers/pty-provider-contract' import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import { getProvider } from '../provider/registry' import { - isClaudeLaunchCommand, recoverFreshSpawnProviderRouting, routesFreshSpawnsToLocalProvider } from '../host-env/fresh-spawn-routing' @@ -15,6 +13,12 @@ import { } from '../../../daemon/pty-session-id' import { resolvePathEnvKey } from '../../../pty/windows-environment-path' import type { RuntimePtySpawnState } from './spawn-state' +import { + applyRuntimeClaudeProfileTerminalEnv, + isRuntimeClaudeLaunch, + prepareRuntimeSpawnClaudeAuth, + resolveRuntimeSpawnClaudeAccount +} from './spawn-claude-account' import { resolvePtySpawnStartupTarget } from '../spawn-startup-target' import { resolvePtySpawnHostEnv } from '../spawn-host-env' import { ptySpawnPaneFromIds, stampPtySpawnPaneEnv } from '../spawn-pane-env' @@ -42,7 +46,6 @@ export async function prepareRuntimePtySpawn( if (freshSpawnRecovery) { await freshSpawnRecovery } - ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && isClaudeLaunchCommand(args.command) ctx.isDaemonHostSpawn = !(ctx.provider instanceof LocalPtyProvider) && !routesFreshSpawnsToLocalProvider(ctx.provider) ctx.callerRequestedSessionId = args.sessionId?.trim() @@ -81,11 +84,10 @@ export async function prepareRuntimePtySpawn( ctx.daemonShellOverride = ctx.terminalRuntimeOptions.shellOverride ctx.expectedWslDistro = target.expectedWslDistro ctx.codexSelectionTarget = target.codexSelectionTarget - args.env = withClaudeProfileTerminalEnv(args.env, ctx.codexSelectionTarget) - ctx.claudeAuth = - ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth - ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) - : null + const pinnedClaudeAccountId = resolveRuntimeSpawnClaudeAccount(ctx) + ctx.isClaudeLaunch = isRuntimeClaudeLaunch(ctx, pinnedClaudeAccountId) + ctx.claudeAuth = await prepareRuntimeSpawnClaudeAuth(ctx, pinnedClaudeAccountId) + applyRuntimeClaudeProfileTerminalEnv(ctx) ctx.shouldPersistHostSessionBinding = args.persistHostSessionBinding === true if (ctx.shouldPersistHostSessionBinding) { diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 2f968cbcab6d..8877b035163e 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -115,6 +115,10 @@ export type RuntimePtySpawnArgs = { } env?: Record envToDelete?: string[] + /** `--account`: the managed host Claude account this launch must run on. */ + claudeAccountId?: string + /** The launch config's recorded account (or ACTIVE_CLAUDE_ACCOUNT); the project default applies below it. */ + launchConfigClaudeAccountId?: string resumeProviderSession?: AgentProviderSessionMetadata connectionId?: string | null worktreeId?: string diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index d581c7055bb4..08ded14fcc8f 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -583,7 +583,8 @@ describe('registerCoreHandlers', () => { expect.any(Function) ) expect(registerAgentHookHandlersMock).toHaveBeenCalledWith(runtime, { - getPtyIdForPaneKey: expect.any(Function) + getPtyIdForPaneKey: expect.any(Function), + getPersistedTerminalLayouts: expect.any(Function) }) expect(registerCodexConfigSyncHandlersMock).toHaveBeenCalledWith( codexAccounts.runtimeHomeService diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index 2694ef932750..870b44c66a51 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -185,7 +185,10 @@ export function registerCoreHandlers( registerPreflightHandlers() registerUsageProviderHandlers({ claudeUsage, codexUsage, openCodeUsage, museUsage }) registerCodexAccountHandlers(codexAccounts, () => store.getSettings()) - registerAgentHookHandlers(runtime, { getPtyIdForPaneKey }) + registerAgentHookHandlers(runtime, { + getPtyIdForPaneKey, + getPersistedTerminalLayouts: () => store.getWorkspaceSession().terminalLayoutsByTabId + }) registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService) registerClaudeAccountHandlers(claudeAccounts) registerOpenCodeGoCredentialsHandlers(rateLimits) diff --git a/src/main/ipc/repos/repo-update-handler.ts b/src/main/ipc/repos/repo-update-handler.ts index ba95477f644b..57e198394dcc 100644 --- a/src/main/ipc/repos/repo-update-handler.ts +++ b/src/main/ipc/repos/repo-update-handler.ts @@ -10,6 +10,8 @@ import { normalizeRepoBadgeColor } from '../../../shared/repo-badge-color' import { sanitizeRepoIcon } from '../../../shared/repo-icon' import { normalizeGhAccountBinding } from '../../../shared/github/account-binding' import type { GhAccountBinding } from '../../../shared/github/account-binding' +import { normalizeRepoAgentAccounts } from '../../../shared/claude/project-claude-account-preference' +import type { RepoAgentAccounts } from '../../../shared/claude/project-claude-account-preference' import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai' import { normalizeCustomWorktreeVisibilitySources, @@ -62,6 +64,7 @@ export function registerRepoUpdateHandler(mainWindow: BrowserWindow, store: Stor | Repo['externalWorktreeDiscoverySuppressedAt'] | null ghAccount?: GhAccountBinding | null + agentAccounts?: RepoAgentAccounts | null } } ): Promise => { @@ -101,6 +104,18 @@ export function registerRepoUpdateHandler(mainWindow: BrowserWindow, store: Stor } } } + if ('agentAccounts' in updates) { + if (updates.agentAccounts == null) { + updates.agentAccounts = null + } else { + const normalized = normalizeRepoAgentAccounts(updates.agentAccounts) + if (!normalized) { + delete updates.agentAccounts + } else { + updates.agentAccounts = normalized + } + } + } // Why: worktree materialization calls .trim() per entry, so strip non-string[] at the boundary to avoid a silent throw later. if ('symlinkPaths' in updates && updates.symlinkPaths !== undefined) { const v = updates.symlinkPaths as unknown diff --git a/src/main/persistence-update-repo.test.ts b/src/main/persistence-update-repo.test.ts index f21fd2a44a9a..3d9ea7bc4a18 100644 --- a/src/main/persistence-update-repo.test.ts +++ b/src/main/persistence-update-repo.test.ts @@ -679,6 +679,37 @@ describe('Store', () => { expect(cleared!.ghAccount).toBeUndefined() }) + it('updateRepo persists, reloads, and clears agentAccounts', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + + const updated = store.updateRepo( + { id: 'r1' }, + { + agentAccounts: { claude: { mode: 'account', accountId: ' acct-1 ' } } + } + ) + expect(updated!.agentAccounts).toEqual({ claude: { mode: 'account', accountId: 'acct-1' } }) + + store.flush() + const reloaded = await createStore() + expect(reloaded.getRepo('r1')!.agentAccounts).toEqual({ + claude: { mode: 'account', accountId: 'acct-1' } + }) + + const malformed = reloaded.updateRepo( + { id: 'r1' }, + { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: exercising a malformed value on purpose; the persistence layer must drop it rather than coerce it. + agentAccounts: { claude: { mode: 'bogus' } } as never + } + ) + expect(malformed!.agentAccounts).toEqual({ claude: { mode: 'account', accountId: 'acct-1' } }) + + const cleared = reloaded.updateRepo({ id: 'r1' }, { agentAccounts: null }) + expect(cleared!.agentAccounts).toBeUndefined() + }) + it('updateRepo persists fork sync mode across reloads', async () => { const store = await createStore() store.addRepo(makeRepo()) diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index 084dec0b5ccb..448c1ca2f109 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -3,6 +3,7 @@ import type { ProjectHostSetup, ProjectHostSetupUpdateArgs } from '../../../shar import type { Repo } from '../../../shared/repo-types' import type { RepoHostTarget } from '../../../shared/repo-host-identity' import type { GhAccountBinding } from '../../../shared/github/account-binding' +import type { RepoAgentAccounts } from '../../../shared/claude/project-claude-account-preference' import { removeRepoFromHostWorkspaceSessions, removeRepoFromWorkspaceSession @@ -188,6 +189,7 @@ export class RepoLifecycleOperations { sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null ghAccount?: GhAccountBinding | null + agentAccounts?: RepoAgentAccounts | null } ): Repo | null { return getRepoUpdateOperations(this).updateRepo(target, updates) diff --git a/src/main/persistence/tracking-repos/repo-hydration.ts b/src/main/persistence/tracking-repos/repo-hydration.ts index 110c47ec5f1c..a932732b4aff 100644 --- a/src/main/persistence/tracking-repos/repo-hydration.ts +++ b/src/main/persistence/tracking-repos/repo-hydration.ts @@ -4,6 +4,7 @@ import { getDefaultRepoHookSettings } from '../../../shared/constants' import { isFolderRepo } from '../../../shared/repo-kind' import { sanitizeRepoIcon } from '../../../shared/repo-icon' import { normalizeGhAccountBinding } from '../../../shared/github/account-binding' +import { normalizeRepoAgentAccounts } from '../../../shared/claude/project-claude-account-preference' import { normalizeJiraProjectReference } from '../../../shared/jira-project-reference' import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai' import { @@ -38,6 +39,7 @@ export function hydrateRepo(repo: Repo, gitUsernameCache: ReadonlyMap > & { ghAccount?: GhAccountBinding | null + agentAccounts?: RepoAgentAccounts | null } >(updates: T): T { const sanitized = { ...updates } @@ -161,6 +164,14 @@ export function sanitizeRepoUpdatesForPersistence< sanitized.ghAccount = ghAccount } } + if ('agentAccounts' in sanitized && sanitized.agentAccounts != null) { + const agentAccounts = normalizeRepoAgentAccounts(sanitized.agentAccounts) + if (!agentAccounts) { + delete sanitized.agentAccounts + } else { + sanitized.agentAccounts = agentAccounts + } + } if ('customWorktreeVisibilitySources' in sanitized) { const sources = normalizeCustomWorktreeVisibilitySources( sanitized.customWorktreeVisibilitySources diff --git a/src/main/persistence/tracking-repos/repo-update-operations.ts b/src/main/persistence/tracking-repos/repo-update-operations.ts index 25d4eff9d707..f650cb298fc8 100644 --- a/src/main/persistence/tracking-repos/repo-update-operations.ts +++ b/src/main/persistence/tracking-repos/repo-update-operations.ts @@ -7,6 +7,7 @@ import { isLegacyRepoForExternalWorktreeVisibility } from '../../../shared/exter import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai' import { normalizeWorktreeVisibilitySourcePreferences } from '../../../shared/worktree/visibility-sources' import type { GhAccountBinding } from '../../../shared/github/account-binding' +import type { RepoAgentAccounts } from '../../../shared/claude/project-claude-account-preference' import { invalidateGhAccountTokenCache } from '../../github/gh-account-token' import { sanitizeRepoUpdatesForPersistence } from './repo-sanitization' import { isFolderRepo } from '../../../shared/repo-kind' @@ -129,6 +130,7 @@ export class RepoUpdatePersistenceOperations { sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null ghAccount?: GhAccountBinding | null + agentAccounts?: RepoAgentAccounts | null } ): Repo | null { const repo = findRepoRowForHostScopedWrite( @@ -202,6 +204,10 @@ export class RepoUpdatePersistenceOperations { delete repo.ghAccount delete sanitizedUpdates.ghAccount } + if ('agentAccounts' in sanitizedUpdates && sanitizedUpdates.agentAccounts == null) { + delete repo.agentAccounts + delete sanitizedUpdates.agentAccounts + } if ('worktreeBasePath' in sanitizedUpdates && sanitizedUpdates.worktreeBasePath === undefined) { delete repo.worktreeBasePath delete sanitizedUpdates.worktreeBasePath diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts index e7909a140130..00ebf9e22602 100644 --- a/src/main/runtime/orca-runtime-agent-session-operation.test.ts +++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts @@ -218,6 +218,35 @@ describe('agent-session create operation ledger', () => { expect(createTerminal).toHaveBeenCalledOnce() }) + it('refuses a changed Claude account under the same create operation', async () => { + const runtime = createRuntime() + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) + const id = operationId() + await runtime.createAgentSession(request(id, { agent: 'claude', claudeAccountId: 'acct-1' })) + await expect( + runtime.createAgentSession(request(id, { agent: 'claude', claudeAccountId: 'acct-2' })) + ).rejects.toThrow('agent_session_operation_conflict') + expect(createTerminal).toHaveBeenCalledOnce() + }) + + it('keeps a Claude account off a non-Claude launch config on create and resume', async () => { + const runtime = createRuntime() + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) + await runtime.createAgentSession(request(operationId(), { claudeAccountId: 'acct-1' })) + await runtime.ensureAgentSession({ + kind: 'explicit', + worktree: 'id:worktree-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider-session-1' }, + claudeAccountId: 'acct-1' + }) + expect(createTerminal).toHaveBeenCalledTimes(2) + for (const call of createTerminal.mock.calls) { + expect(call[1]?.launchConfig?.claudeAccountId).toBeUndefined() + expect(call[1]?.launchClaudeAccountId).toBeUndefined() + } + }) + it('selects legacy before spawn or ledger state for an old daemon', async () => { const provider = { supportsAgentSessionClaims: vi.fn(() => false), diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 4c42117fb9e5..ab84a60080d0 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -80,7 +80,8 @@ export abstract class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithG request.placement?.tabId ?? null, request.placement?.leafId ?? null, request.viewMode ?? null, - ...(request.terminalKittyKeyboardProtocol === true ? ['kitty-keyboard'] : []) + ...(request.terminalKittyKeyboardProtocol === true ? ['kitty-keyboard'] : []), + ...(request.claudeAccountId ? ['claude-account', request.claudeAccountId] : []) ]) ) .digest('base64url') @@ -142,7 +143,8 @@ export abstract class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithG request.placement?.tabId ?? null, request.placement?.leafId ?? null, request.viewMode ?? null, - ...(request.terminalKittyKeyboardProtocol === true ? ['kitty-keyboard'] : []) + ...(request.terminalKittyKeyboardProtocol === true ? ['kitty-keyboard'] : []), + ...(request.claudeAccountId ? ['claude-account', request.claudeAccountId] : []) ]) ) .digest('base64url') @@ -184,6 +186,9 @@ export abstract class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithG // Why outside both fingerprints: the desktop re-prepares it per attempt; the first spawn wins. accountEnv: request.accountEnv, terminalImageViewerSupported: caller.terminalImageViewerSupported, + ...(request.claudeAccountId && request.agent === 'claude' + ? { launchClaudeAccountId: request.claudeAccountId } + : {}), terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol, cwd: startupCwd, presentation: request.presentation ?? 'background', diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index 600b5cb965a2..51779b3c6794 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -20,7 +20,8 @@ export { export { copySleepingAgentLaunchConfig, inferCapturedClaudeAgentTeamsMode, - mergeTerminalEnvDeletionKeys + mergeTerminalEnvDeletionKeys, + terminalCreateClaudeAccountIdField } from './runtime-agent-launch-resolution' export { buildClaudeAgentTeamsLaunchPlan } from './claude-agent-teams-shim-env' export { diff --git a/src/main/runtime/orca-runtime-create-terminal-desktop.ts b/src/main/runtime/orca-runtime-create-terminal-desktop.ts index 774b5f8acd17..27e320110284 100644 --- a/src/main/runtime/orca-runtime-create-terminal-desktop.ts +++ b/src/main/runtime/orca-runtime-create-terminal-desktop.ts @@ -9,6 +9,10 @@ export async function createDesktopTerminal( opts: dependencies.TerminalCreateOptions, presentation: RuntimeTerminalPresentation | undefined ): Promise { + if (opts.claudeAccountId) { + // Why: only the background lane reaches the PTY spawn that honours the pinned account. + throw new Error('A Claude --account launch requires a workspace selector.') + } // A worktree-less create has no execution host to resolve the shell on. if (opts.shellOverride) { throw new Error( diff --git a/src/main/runtime/orca-runtime-tests/claude-account-agent-session-lanes.spec.ts b/src/main/runtime/orca-runtime-tests/claude-account-agent-session-lanes.spec.ts new file mode 100644 index 000000000000..3499c4804df3 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/claude-account-agent-session-lanes.spec.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import { TEST_WORKTREE_ID, store } from '../orca-runtime-test-fixtures.spec' +import { ACTIVE_CLAUDE_ACCOUNT } from '../../../shared/claude/project-claude-account-preference' +import { resolveRuntimeSpawnClaudeAccount } from '../../ipc/pty/runtime/spawn-claude-account' +import type { RuntimePtySpawnState } from '../../ipc/pty/runtime/spawn-state' +import type { RuntimePtyController } from '../runtime-pty-controller-contract' + +type PtySpawnOptions = Parameters>[0] + +const PROJECT_ACCOUNT = 'acct-project' + +function pinnedProjectStore() { + const repos = store.getRepos().map((repo) => ({ + ...repo, + agentAccounts: { claude: { mode: 'account' as const, accountId: PROJECT_ACCOUNT } } + })) + return { + ...store, + getRepos: () => repos, + getRepo: (id: string) => repos.find((repo) => repo.id === id), + getSettings: () => ({ + ...store.getSettings(), + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {} + }) + } +} + +function spawningRuntime() { + const runtimeStore = pinnedProjectStore() + const spawn = vi.fn(async (options: PtySpawnOptions) => ({ + id: `pty-${spawn.mock.calls.length}`, + ...(options.agentSessionEnsure + ? { + agentSessionEnsure: { + disposition: 'created' as const, + owner: { + ...options.agentSessionEnsure, + generation: 'generation-1', + phase: 'live' as const, + ptyId: `pty-${spawn.mock.calls.length}` + } + } + } + : {}) + })) + const runtime = new OrcaRuntimeService(runtimeStore) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + Object.assign(runtime, { markWorkspaceTrustedForAgent: vi.fn(async () => {}) }) + // Runs the spawn lane's own account resolution on exactly what the runtime handed the PTY. + const spawnAccount = (call: number): string | undefined => { + const ctx = { + args: spawn.mock.calls[call]![0], + deps: { store: runtimeStore }, + preAdoptedStablePane: null + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolveRuntimeSpawnClaudeAccount reads only args, deps.store and preAdoptedStablePane. + return resolveRuntimeSpawnClaudeAccount(ctx as unknown as RuntimePtySpawnState) + } + return { runtime, spawn, spawnAccount } +} + +describe('Claude account on host agent-session launches', () => { + it.each([ + [ACTIVE_CLAUDE_ACCOUNT, undefined], + ['acct-2', 'acct-2'], + [undefined, PROJECT_ACCOUNT] + ])( + 'spawns a paired create and resume carrying %s on account %s', + async (claudeAccountId, expected) => { + const { runtime, spawn, spawnAccount } = spawningRuntime() + await runtime.createAgentSession( + { + clientOperationId: `${Date.now()}-${'cd'.repeat(16)}`, + worktree: `id:${TEST_WORKTREE_ID}`, + agent: 'claude', + prompt: '', + presentation: 'background', + ...(claudeAccountId ? { claudeAccountId } : {}) + }, + { clientId: 'paired-1', clientKind: 'runtime' } + ) + await runtime.ensureAgentSession({ + kind: 'explicit', + worktree: `id:${TEST_WORKTREE_ID}`, + agent: 'claude', + providerSession: { key: 'session_id', id: 'provider-session-1' }, + ...(claudeAccountId ? { claudeAccountId } : {}) + }) + expect(spawn).toHaveBeenCalledTimes(2) + expect(spawnAccount(0)).toBe(expected) + expect(spawnAccount(1)).toBe(expected) + } + ) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index dfe144bed4e5..5c88c5dc9c19 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -45,6 +45,7 @@ await import('./orca-runtime-tests/agent-status-and-waits-part-02.spec') await import('./orca-runtime-tests/agent-status-and-waits-part-03.spec') await import('./orca-runtime-tests/terminal-creation-and-readiness.spec') await import('./orca-runtime-tests/terminal-creation-and-readiness-part-02.spec') +await import('./orca-runtime-tests/claude-account-agent-session-lanes.spec') await import('./orca-runtime-tests/terminal-creation-and-readiness-part-03.spec') await import('./orca-runtime-tests/terminal-workspace-id-env.spec') await import('./orca-runtime-tests/terminal-creation-and-readiness-part-04.spec') diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts index f3e35ec244a0..b345e0398797 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts @@ -42,6 +42,8 @@ type WorkerStartModePlacement = { worktree?: string model?: string effort?: string + /** Read: `--account` is only honoured by a terminal worker. */ + account?: string } export function decideWorkerStartMode(args: { @@ -49,14 +51,18 @@ export function decideWorkerStartMode(args: { settings: AgentLaunchModeSettings | null | undefined }): WorkerStartModeReceipt { return decideAgentLaunchMode({ - placement: args.params, + placement: { + ...args.params, + ...(args.params.account ? { claudeAccount: args.params.account } : {}) + }, settings: args.settings, vocabulary: WORKER_START_VOCABULARY }) } export async function resolveWorkerStartModeOnHost( - runtime: Pick, + runtime: Pick & + Partial>, mode: WorkerStartModeReceipt, worktreeId: string | undefined, agent: TuiAgent | undefined diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index 1ea7cf7e9850..030587770262 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -34,6 +34,7 @@ import { tearDownFailedWorkerStart } from './failed-worker-start-teardown' import { releaseRetriedWorkerTerminal } from './retried-worker-terminal-release' import { requireWorkerAuthority, type WorkerEffect } from './worker-topology' import { prepareLocalWorkerStart } from './worker-start-validation' +import { assertClaudeAccountWorktreeIsLocal } from './worker-claude-account-placement' import { deliverAndSettleWorkerStartReadiness } from './worker-start-readiness-settlement' import type { WorkerStartMutation } from './worker-start-mutation' @@ -85,7 +86,7 @@ export async function startLocalWorker(args: { ) } - const { agent, launch } = prepareLocalWorkerStart({ + const { agent, launch, claudeAccount } = prepareLocalWorkerStart({ params: launchParams, createsWorktree, runtime, @@ -123,6 +124,7 @@ export async function startLocalWorker(args: { resolvedWorktreeId: resolvedWorktree?.id }) } + await assertClaudeAccountWorktreeIsLocal(runtime, claudeAccount && resolvedWorktree?.id) const hostMode = resolveWorkerStartModeOnHost(runtime, args.mode, resolvedWorktree?.id, agent) let mode = chatWorkerMode(await hostMode, chat) @@ -190,6 +192,7 @@ export async function startLocalWorker(args: { mode, agent, launchPreferences: launch.preferences, + ...(claudeAccount ? { claudeAccountId: claudeAccount.accountId } : {}), effects, onStage: (stage) => { failedStage = stage diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-claude-account-placement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-claude-account-placement.ts new file mode 100644 index 000000000000..517da942d185 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-claude-account-placement.ts @@ -0,0 +1,23 @@ +import { OrchestrationError } from '../../../../orchestration/orchestration-error' + +/** + * Refused before the dispatch row exists, so a remote placement never shows up as a failed start. + * No worktree id means no `--account` or a worktree this start creates, which is checked there. + */ +export async function assertClaudeAccountWorktreeIsLocal( + runtime: { + showTerminalWorkspaceLaunchScope(selector: string): Promise<{ connectionId?: string | null }> + }, + worktreeId: string | undefined +): Promise { + if (!worktreeId) { + return + } + const scope = await runtime.showTerminalWorkspaceLaunchScope(`id:${worktreeId}`) + if (scope.connectionId) { + throw new OrchestrationError( + 'invalid_argument', + '--account runs Claude on this host only and cannot start a worker in an SSH workspace.' + ) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-claude-account.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-claude-account.test.ts new file mode 100644 index 000000000000..6d2ea51bb95a --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-claude-account.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ClaudeManagedAccountSummary } from '../../../../../../shared/managed-account-types' +import { WorkerStartParams } from '../../../../../../shared/rpc-contract/orchestration-worker-start-params' +import { createWorkerLaunchReceipt, withWorkerLaunchAccount } from './worker-launch-preferences' +import { prepareLocalWorkerStart } from './worker-start-validation' +import { decideWorkerStartMode } from '../../orchestration-worker-start-mode' +import { assertClaudeAccountWorktreeIsLocal } from './worker-claude-account-placement' +import { decideAgentLaunchMode } from '../../../../../agent-launch/agent-launch-mode' + +function account(id: string, email: string): ClaudeManagedAccountSummary { + return { + id, + email, + managedAuthRuntime: 'host', + wslDistro: null, + authMethod: 'subscription-oauth', + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } +} + +function createRuntime() { + return { + validateOrchestrationAgentLauncher: vi.fn(), + getAccountsSnapshot: vi.fn(() => ({ + claude: { + accounts: [ + account('acct-a', 'active@example.com'), + account('acct-b', 'pinned@example.com') + ], + activeAccountId: 'acct-a' + } + })) + } +} + +function prepare(params: Record) { + return prepareLocalWorkerStart({ + params: WorkerStartParams.parse({ task: 'task_1', from: 'term_coord', ...params }), + createsWorktree: false, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: prepareLocalWorkerStart reads only the account roster the fake runtime provides. + runtime: createRuntime() as never + }) +} + +describe('worker-start --account', () => { + it('rejects --account beside --terminal at the schema', () => { + const parsed = WorkerStartParams.safeParse({ + task: 'task_1', + from: 'term_coord', + terminal: 'term_worker', + account: 'acct-b' + }) + expect(parsed.success).toBe(false) + expect(parsed.error?.issues.map((issue) => issue.message)).toContain( + '--account applies to a new Claude terminal and cannot combine with --terminal.' + ) + }) + + it('resolves an email to the pinned account and records it in the launch receipt', () => { + const { claudeAccount, launch } = prepare({ agent: 'claude', account: 'PINNED@example.com' }) + + expect(claudeAccount).toEqual({ + accountId: 'acct-b', + email: 'pinned@example.com', + isActiveOnHost: false + }) + const expected = { id: 'acct-b', email: 'pinned@example.com', mode: 'pinned' } + expect(launch.receipt.requested.account).toEqual(expected) + expect(launch.receipt.effective?.account).toEqual(expected) + }) + + it('reports the active host account as an unpinned launch', () => { + expect( + prepare({ agent: 'claude', account: 'acct-a' }).launch.receipt.requested.account?.mode + ).toBe('active') + }) + + it('rejects non-Claude agents and unknown accounts before any dispatch exists', () => { + expect(() => prepare({ agent: 'codex', account: 'acct-b' })).toThrow( + '--account applies only to --agent claude.' + ) + expect(() => prepare({ agent: 'claude', account: 'nobody@example.com' })).toThrow( + /orca account list/ + ) + }) + + it('leaves receipts without --account exactly as before', () => { + const receipt = createWorkerLaunchReceipt({ agent: 'claude' }) + expect(withWorkerLaunchAccount(receipt, undefined)).toBe(receipt) + expect(prepare({ agent: 'claude' }).launch.receipt).toEqual(receipt) + expect(prepare({ agent: 'claude' }).claudeAccount).toBeUndefined() + }) + + it('downgrades a structured default to a terminal worker', () => { + const settings = { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + experimentalStructuredNativeChat: true + } + expect(decideWorkerStartMode({ params: { agent: 'claude' }, settings }).mode).toBe('structured') + const mode = decideWorkerStartMode({ params: { agent: 'claude', account: 'acct-b' }, settings }) + expect(mode).toMatchObject({ + mode: 'terminal', + preferred: 'structured', + reason: 'pinned_claude_account' + }) + expect(mode.detail).toContain('--account') + }) + + it('refuses an explicit chat request instead of starting it on another account', () => { + expect(() => + decideAgentLaunchMode({ + placement: { agent: 'claude', claudeAccount: 'acct-b' }, + settings: null, + surface: 'chat' + }) + ).toThrow(/can't open as a chat/) + }) + + it('refuses an SSH workspace placement', async () => { + await expect( + assertClaudeAccountWorktreeIsLocal( + { showTerminalWorkspaceLaunchScope: vi.fn(async () => ({ connectionId: 'ssh-1' })) }, + 'wt-1' + ) + ).rejects.toThrow(/SSH workspace/) + await expect( + assertClaudeAccountWorktreeIsLocal( + { showTerminalWorkspaceLaunchScope: vi.fn(async () => ({ connectionId: null })) }, + 'wt-1' + ) + ).resolves.toBeUndefined() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts index c56437dd75e0..03549faed01e 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts @@ -4,10 +4,18 @@ import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../. import type { TuiAgent } from '../../../../../../shared/tui-agent' import { OrchestrationError } from '../../../../orchestration/orchestration-error' +/** `--account`: which managed Claude account ran, and whether that needed a pinned launch. */ +export type OrchestrationWorkerLaunchAccount = { + id: string + email: string + mode: 'pinned' | 'active' +} + export type OrchestrationWorkerLaunchSelection = { agent: TuiAgent | null model: string | null effort: string | null + account?: OrchestrationWorkerLaunchAccount } export type OrchestrationWorkerLaunchReceipt = { @@ -28,6 +36,19 @@ export function createWorkerLaunchReceipt(args: { return { requested: selection, effective: { ...selection } } } +export function withWorkerLaunchAccount( + receipt: OrchestrationWorkerLaunchReceipt, + account: OrchestrationWorkerLaunchAccount | undefined +): OrchestrationWorkerLaunchReceipt { + if (!account) { + return receipt + } + return { + requested: { ...receipt.requested, account }, + effective: receipt.effective ? { ...receipt.effective, account } : null + } +} + export function createPendingWorkerLaunchReceipt(args: { agent: TuiAgent | null model?: string diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts index b11b3e5d1938..74c599cbdb0b 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts @@ -63,6 +63,8 @@ type WorkerAgentPlacementArgs = { mode: WorkerStartModeReceipt agent: TuiAgent | undefined launchPreferences: AgentLaunchPreferences | undefined + /** `--account`: the managed Claude account a terminal worker runs on. */ + claudeAccountId?: string effects: WorkerEffect[] /** Attributes a throw to the step that was running, the way the caller's own stages do. */ onStage: (stage: string) => void @@ -151,6 +153,7 @@ async function launchWorkerAgent( promisedAgentChat, onWorkspaceCreated, ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), + ...(args.claudeAccountId ? { claudeAccountId: args.claudeAccountId } : {}), effects: args.effects }) return { @@ -222,6 +225,7 @@ function workerSurfaceFactory( worktreeId, agent, ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), + ...(args.claudeAccountId ? { claudeAccountId: args.claudeAccountId } : {}), taskId: args.taskId, effects: args.effects }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts index b9e39abb859d..982c1d66c54f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts @@ -6,8 +6,13 @@ import type { FederationAttachStartInput } from '../federation/federation-start- import { assertWorkerLaunchPreferencesCreateTerminal, createWorkerLaunchReceipt, - resolveWorkerLaunchPreferences + resolveWorkerLaunchPreferences, + withWorkerLaunchAccount } from './worker-launch-preferences' +import { + resolveClaudeLaunchAccount, + type ClaudeLaunchAccountResolution +} from '../../../../../claude-accounts/claude-launch-account' import type { WorkerStartInput } from './worker-start-schema' type WorkerStartLaunch = ReturnType @@ -53,7 +58,11 @@ export function prepareLocalWorkerStart(args: { createsWorktree: boolean runtime: OrcaRuntimeService openCodeModelLaunchSupported?: boolean -}): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } { +}): { + agent: TuiAgent | undefined + launch: WorkerStartLaunch + claudeAccount: ClaudeLaunchAccountResolution | undefined +} { const { params, createsWorktree, runtime } = args assertWorkerLaunchPreferencesCreateTerminal(params) if (params.terminal && params.agent) { @@ -68,6 +77,9 @@ export function prepareLocalWorkerStart(args: { '--terminal cannot combine with new-worktree creation.' ) } + if (params.account && params.agent !== 'claude') { + throw new OrchestrationError('invalid_argument', '--account applies only to --agent claude.') + } if (createsWorktree && !params.name) { throw new OrchestrationError('invalid_argument', 'New worktrees require --name.') } @@ -77,7 +89,7 @@ export function prepareLocalWorkerStart(args: { 'Creation and setup options apply only to new-child or new-top-level worktrees.' ) } - return resolveWorkerStartAgent({ + const started = resolveWorkerStartAgent({ runtime, openCodeModelLaunchSupported: args.openCodeModelLaunchSupported, createsWorktree, @@ -87,6 +99,38 @@ export function prepareLocalWorkerStart(args: { effort: params.effort, missingAgentMessage: 'A configured --agent is required when worker-start creates a terminal.' }) + const claudeAccount = params.account + ? resolveWorkerClaudeAccount(runtime, params.account) + : undefined + return { + ...started, + launch: { + ...started.launch, + receipt: withWorkerLaunchAccount( + started.launch.receipt, + claudeAccount && { + id: claudeAccount.accountId, + email: claudeAccount.email, + mode: claudeAccount.isActiveOnHost ? 'active' : 'pinned' + } + ) + }, + claudeAccount + } +} + +function resolveWorkerClaudeAccount( + runtime: OrcaRuntimeService, + selector: string +): ClaudeLaunchAccountResolution { + try { + return resolveClaudeLaunchAccount(runtime.getAccountsSnapshot().claude, selector) + } catch (error) { + throw new OrchestrationError( + 'invalid_argument', + error instanceof Error ? error.message : String(error) + ) + } } export function prepareFederationAttachmentWorkerStart(args: { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts index a11eb4b4c001..ae365e5ff1ec 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts @@ -60,6 +60,7 @@ export async function createExistingWorktreeWorkerTerminal(args: { worktreeId: string agent: TuiAgent launchPreferences?: AgentLaunchPreferences + claudeAccountId?: string taskId: string effects: WorkerEffect[] }): Promise<{ handle: string; warning?: string }> { @@ -70,6 +71,7 @@ export async function createExistingWorktreeWorkerTerminal(args: { startupAgent: args.agent, launchSource: 'orchestration', ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), + ...(args.claudeAccountId ? { claudeAccountId: args.claudeAccountId } : {}), title: `worker-${args.taskId}`, // Why: dispatching a worker is background work; it must not pull the sidebar // to the worker's workspace while the user is reading somewhere else. diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts index 32e1ae2ed4f3..173f8b9ef240 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts @@ -41,6 +41,8 @@ export async function createWorkerWorktree(args: { promisedAgentChat?: boolean onWorkspaceCreated?: (worktreeId: string) => void launchPreferences?: AgentLaunchPreferences + /** `--account`: resolved on this host; the created worktree's agent terminal runs on it. */ + claudeAccountId?: string effects: WorkerEffect[] }): Promise<{ worktree: Awaited> @@ -69,7 +71,8 @@ export async function createWorkerWorktree(args: { ? { startupAgent: args.agent, startupLaunchSource: 'orchestration', - ...(args.launchPreferences ? { startupLaunchPreferences: args.launchPreferences } : {}) + ...(args.launchPreferences ? { startupLaunchPreferences: args.launchPreferences } : {}), + ...(args.claudeAccountId ? { startupClaudeAccount: args.claudeAccountId } : {}) } : {}), activate: false, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts index 64d2b14e1bdd..cf8711e142b2 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts @@ -57,6 +57,12 @@ export const ORCHESTRATION_WORKER_START_METHODS = [ params, settings: readWorkerStartModeSettings(runtime) }) + if (params.on && params.account) { + throw new OrchestrationError( + 'invalid_argument', + "--account runs Claude on this host's accounts and cannot combine with --on." + ) + } if (params.on && params.terminal?.startsWith(ORCA_SESSION_ADDRESS_PREFIX)) { // Refused here, before any remote call: an Orca session ID names a chat only on its own host. throw new OrchestrationError( diff --git a/src/main/runtime/rpc/methods/repo-agent-accounts.test.ts b/src/main/runtime/rpc/methods/repo-agent-accounts.test.ts new file mode 100644 index 000000000000..20702ca40d10 --- /dev/null +++ b/src/main/runtime/rpc/methods/repo-agent-accounts.test.ts @@ -0,0 +1,70 @@ +import '../unused-default-rpc-methods.test-fixture' +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { REPO_METHODS } from './repo' +import { WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +function repoDispatcher( + runtime: Pick & Partial +) { + return new RpcDispatcher({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These RPC cases supply every runtime method their handlers and envelope access. + runtime: runtime as OrcaRuntimeService, + methods: REPO_METHODS + }) +} + +describe('repo.update agentAccounts', () => { + it('persists normalized agentAccounts and clear sentinels, and omits malformed input', async () => { + const updateRepo = vi.fn().mockResolvedValue({ + id: 'repo-1', + path: '/srv/repo', + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0, + agentAccounts: { claude: { mode: 'account', accountId: 'acct-1' } } + }) + const dispatcher = repoDispatcher({ getRuntimeId: () => 'test-runtime', updateRepo }) + + const response = await dispatcher.dispatch( + makeRequest('repo.update', { + repo: 'repo-1', + updates: { agentAccounts: { claude: { mode: 'account', accountId: ' acct-1 ' } } } + }), + { clientCapabilities: [WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY] } + ) + + expect(updateRepo).toHaveBeenCalledWith('repo-1', { + agentAccounts: { claude: { mode: 'account', accountId: 'acct-1' } } + }) + expect(response).toMatchObject({ + ok: true, + result: { + repo: { id: 'repo-1', agentAccounts: { claude: { mode: 'account', accountId: 'acct-1' } } } + } + }) + + await dispatcher.dispatch( + makeRequest('repo.update', { + repo: 'repo-1', + updates: { agentAccounts: null } + }) + ) + expect(updateRepo).toHaveBeenLastCalledWith('repo-1', { agentAccounts: null }) + + await dispatcher.dispatch( + makeRequest('repo.update', { + repo: 'repo-1', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: exercising malformed transport input on purpose; the schema must drop it rather than coerce it. + updates: { agentAccounts: { claude: { mode: 'bogus' } } as never } + }) + ) + expect(updateRepo).toHaveBeenLastCalledWith('repo-1', {}) + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree-create-args.ts b/src/main/runtime/rpc/methods/worktree-create-args.ts index dd29fbc834d1..6013666cf08e 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.ts @@ -103,6 +103,9 @@ export function buildManagedWorktreeCreateArgs( : undefined, ...(params.startupAgent ? { startupAgent: params.startupAgent } : {}), ...(params.startupPrompt !== undefined ? { startupPrompt: params.startupPrompt } : {}), + ...(params.startupClaudeAccount !== undefined + ? { startupClaudeAccount: params.startupClaudeAccount } + : {}), ...(params.launchSource ? { startupLaunchSource: params.launchSource } : {}), startupDraft: params.startupDraft, lineage: { diff --git a/src/main/runtime/rpc/methods/worktree-create-claude-account-args.test.ts b/src/main/runtime/rpc/methods/worktree-create-claude-account-args.test.ts new file mode 100644 index 000000000000..80dd74852f4a --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-create-claude-account-args.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { WorktreeCreate } from '../../../../shared/rpc-contract/worktree-create-params' +import { buildManagedWorktreeCreateArgs } from './worktree-create-args' + +describe('worktree.create startupClaudeAccount', () => { + it('requires a Claude startup agent', () => { + const parsed = WorktreeCreate.safeParse({ + repo: 'id:repo-1', + startupAgent: 'codex', + startupClaudeAccount: 'acct-b' + }) + expect(parsed.success).toBe(false) + expect(parsed.error?.issues.map((issue) => issue.message)).toContain( + 'startupClaudeAccount requires startupAgent claude' + ) + }) + + it('maps the selector to the runtime create only when present', () => { + const pinned = WorktreeCreate.parse({ + repo: 'id:repo-1', + startupAgent: 'claude', + startupClaudeAccount: 'pinned@example.com' + }) + expect(buildManagedWorktreeCreateArgs(pinned, {})).toMatchObject({ + startupAgent: 'claude', + startupClaudeAccount: 'pinned@example.com' + }) + const plain = WorktreeCreate.parse({ repo: 'id:repo-1', startupAgent: 'claude' }) + expect(buildManagedWorktreeCreateArgs(plain, {})).not.toHaveProperty('startupClaudeAccount') + }) +}) diff --git a/src/main/runtime/runtime-agent-launch-resolution.ts b/src/main/runtime/runtime-agent-launch-resolution.ts index 2f5e2c6b7349..74c5e2283d6c 100644 --- a/src/main/runtime/runtime-agent-launch-resolution.ts +++ b/src/main/runtime/runtime-agent-launch-resolution.ts @@ -11,6 +11,34 @@ import { } from '../../shared/agent-launch-runtime' import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' +import { isLaunchConfigClaudeAccountId } from '../../shared/claude/project-claude-account-preference' + +/** + * Account fields a background terminal create forwards to the PTY spawn: an explicit `--account`, + * else the launch config's recorded choice, which the spawn resolves below any explicit pin. + */ +export function terminalCreateClaudeAccountIdField(launchOpts: { + claudeAccountId?: string + launchClaudeAccountId?: string + launchConfig?: Pick +}): { claudeAccountId?: string; launchConfigClaudeAccountId?: string } { + if (launchOpts.claudeAccountId) { + return { claudeAccountId: launchOpts.claudeAccountId } + } + const recorded = launchOpts.launchConfig?.claudeAccountId ?? launchOpts.launchClaudeAccountId + return isLaunchConfigClaudeAccountId(recorded) ? { launchConfigClaudeAccountId: recorded } : {} +} + +/** Records the launch's Claude account on its config, so a resume replays it as a saved choice. */ +export function withRecordedClaudeAccount( + config: SleepingAgentLaunchConfig | undefined, + launchOpts: { claudeAccountId?: string; launchClaudeAccountId?: string; launchAgent?: TuiAgent } +): SleepingAgentLaunchConfig | undefined { + const accountId = launchOpts.claudeAccountId ?? launchOpts.launchClaudeAccountId + return config && accountId && launchOpts.launchAgent === 'claude' + ? { ...config, claudeAccountId: accountId } + : config +} export function mergeTerminalEnvDeletionKeys( first: readonly string[] | undefined, @@ -44,7 +72,8 @@ export function copySleepingAgentLaunchConfig( ...(config.agentCommand ? { agentCommand: config.agentCommand } : {}), agentArgs: config.agentArgs, agentEnv: { ...config.agentEnv }, - ...(config.ompResumeFilePath ? { ompResumeFilePath: config.ompResumeFilePath } : {}) + ...(config.ompResumeFilePath ? { ompResumeFilePath: config.ompResumeFilePath } : {}), + ...(config.claudeAccountId ? { claudeAccountId: config.claudeAccountId } : {}) } } diff --git a/src/main/runtime/runtime-folder-worktree-create.ts b/src/main/runtime/runtime-folder-worktree-create.ts index 8c7a51095383..19ea61e9e9a5 100644 --- a/src/main/runtime/runtime-folder-worktree-create.ts +++ b/src/main/runtime/runtime-folder-worktree-create.ts @@ -52,6 +52,8 @@ type RuntimeFolderWorktreeCreateDeps = { export async function createRuntimeFolderWorktree(args: { request: RuntimeManagedWorktreeCreateArgs + /** Resolved `--account`; only the startup agent terminal runs on it. */ + startupClaudeAccountId?: string repo: Repo startup?: WorktreeStartupLaunch startupFollowup?: WorktreeStartupFollowup @@ -154,6 +156,7 @@ export async function createRuntimeFolderWorktree(args: { env: args.startup.env, ...(args.startup.launchConfig ? { launchConfig: args.startup.launchConfig } : {}), ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), + ...(args.startupClaudeAccountId ? { claudeAccountId: args.startupClaudeAccountId } : {}), ...(launchRuntime instanceof Error ? { startFailure: launchRuntime } : launchRuntime @@ -193,9 +196,10 @@ export async function createRuntimeFolderWorktree(args: { worktree.id, undefined, // Switch point: a start main could not make stays main's to show, never the window's. + // Why the account check: the renderer's fallback launch cannot honour --account; it would use the active one. ORCA_LAYOUT_OWNER_IS_MAIN ? undefined - : args.startup && !didSpawnStartup + : args.startup && !didSpawnStartup && !args.startupClaudeAccountId ? args.startup : undefined, ...(args.promisedAgentChat && !args.startup ? ([true] as const) : []) diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts index 53b68cb74039..7a37d17755a6 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts @@ -241,3 +241,38 @@ describe('startRuntimeLocalWorktreeTerminals default shell seeding', () => { ) }) }) + +describe('startRuntimeLocalWorktreeTerminals --account', () => { + it('pins only the startup terminal and never hands a failed launch to the renderer', async () => { + const { createTerminal, ports } = createPorts() + createTerminal.mockRejectedValueOnce(new Error('keychain locked')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const result = await startRuntimeLocalWorktreeTerminals({ + request: { repoSelector: `id:${repo.id}`, name: worktree.displayName, activate: true }, + startupClaudeAccountId: 'acct-b', + repo, + worktree, + createdWithAgent: 'claude', + startup: { command: 'claude' }, + ports + }) + warn.mockRestore() + + expect(createTerminal.mock.calls[0]?.[1]).toMatchObject({ + claudeAccountId: 'acct-b', + launchAgent: 'claude' + }) + expect(result.warning).toContain('keychain locked') + expect(ports.activate).toHaveBeenCalledWith( + repo.id, + worktree.id, + undefined, + undefined, + undefined + ) + }) + + it('does not add an account to launches without one', async () => { + expect(await startupTerminalOptions()).not.toHaveProperty('claudeAccountId') + }) +}) diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.ts index de2b4d786aad..acd29b58f7ff 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.ts @@ -58,6 +58,8 @@ export type RuntimeLocalWorktreeTerminalStartupResult = { export async function startRuntimeLocalWorktreeTerminals(args: { request: RuntimeManagedWorktreeCreateArgs + /** Resolved `--account`; only the startup agent terminal runs on it. */ + startupClaudeAccountId?: string repo: Repo worktree: Worktree setup?: CreateWorktreeResult['setup'] @@ -115,6 +117,7 @@ export async function startRuntimeLocalWorktreeTerminals(args: { env: sequencedStartup.env, ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), + ...(args.startupClaudeAccountId ? { claudeAccountId: args.startupClaudeAccountId } : {}), ...(launchRuntime instanceof Error ? { startFailure: launchRuntime } : launchRuntime @@ -150,7 +153,10 @@ export async function startRuntimeLocalWorktreeTerminals(args: { const provisionInBackground = ports.provisionInBackground?.() === true // Why: a failed agent start hands the whole create to the window, which seeds the agent's tab // first and lays setup and default tabs around it; main provisioning first would leave it no tab. - const agentStartFailed = Boolean(sequencedStartup && ports.canSpawn && !didSpawnStartup) + // An --account start is never handed to the window, whose launch would use the active account. + const agentStartFailed = + Boolean(sequencedStartup && ports.canSpawn && !didSpawnStartup) && + !args.startupClaudeAccountId // Switch point: main provisions whether or not a window is open; no window takes over. if (!ORCA_LAYOUT_OWNER_IS_MAIN && agentStartFailed && !provisionInBackground) { ports.activate(repo.id, worktree.id, startup, setup, defaultTabs) @@ -179,7 +185,12 @@ export async function startRuntimeLocalWorktreeTerminals(args: { repo.id, worktree.id, // Switch point: a start main could not make stays main's to show, never the window's. - ORCA_LAYOUT_OWNER_IS_MAIN ? undefined : startup && !didSpawnStartup ? startup : undefined, + // Why the account check: the renderer's fallback launch cannot honour --account. + ORCA_LAYOUT_OWNER_IS_MAIN || args.startupClaudeAccountId + ? undefined + : startup && !didSpawnStartup + ? startup + : undefined, ORCA_LAYOUT_OWNER_IS_MAIN ? undefined : failedSetup, undefined, ...(args.promisedAgentChat && !startup ? ([true] as const) : []) diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index 77eb0e3b6f89..ad7969beca20 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -64,6 +64,8 @@ export type RuntimeManagedWorktreeCreateArgs = { startupAgent?: TuiAgent terminalImageViewerSupported?: boolean startupLaunchPreferences?: AgentLaunchPreferences + /** `--account ` for a `startupAgent: 'claude'` launch; resolved before any create. */ + startupClaudeAccount?: string startupPrompt?: string /** Main-internal: set by a caller that delivers an uncarried `startupPrompt` itself, so the text * rides only a typed line that can carry it; reports whether it did. */ diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 0cc430c9b0fe..422e9c9d0935 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -72,6 +72,10 @@ export type RuntimePtyController = { startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] env?: Record envToDelete?: string[] + /** `--account`: the managed host Claude account this launch must run on. */ + claudeAccountId?: string + /** The launch config's recorded account (or ACTIVE_CLAUDE_ACCOUNT); the project default applies below it. */ + launchConfigClaudeAccountId?: string resumeProviderSession?: AgentProviderSessionMetadata telemetry?: WorktreeStartupLaunch['telemetry'] connectionId?: string | null diff --git a/src/main/runtime/runtime-repository-settings-controller.ts b/src/main/runtime/runtime-repository-settings-controller.ts index 29593d7fb51b..e463467da498 100644 --- a/src/main/runtime/runtime-repository-settings-controller.ts +++ b/src/main/runtime/runtime-repository-settings-controller.ts @@ -3,6 +3,7 @@ import { commitProvenRepoKindChange } from '../folder-repo-git-conversion' import type { Repo } from '../../shared/repo-types' import type { RuntimeRepoSelector } from './runtime-repo-selector' import type { GhAccountBinding } from '../../shared/github/account-binding' +import type { RepoAgentAccounts } from '../../shared/claude/project-claude-account-preference' import { invalidateAuthorizedRootsCache } from '../ipc/registered-worktree-roots-cache' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import type { RuntimeStore } from './runtime-store-contract' @@ -52,6 +53,8 @@ type RepositoryUpdates = Partial< externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null /** Only `null` clears; `omitUndefined` drops a stripped (undefined) field so it never unbinds. */ ghAccount?: GhAccountBinding | null + /** Only null clears. */ + agentAccounts?: RepoAgentAccounts | null } function omitUndefined>(value: T): Partial { diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index d4e452fec724..2f0568bb9957 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -88,6 +88,14 @@ export type TerminalCreateOptions = { * `agentArgs`, so not part of `callerSuppliedLaunch`. */ accountEnv?: Record launchPreferences?: AgentLaunchPreferences + /** + * `--account`: a managed host Claude account id this launch must run on. Background-lane only, + * and deliberately not part of `callerSuppliedLaunch`: it picks credentials, not the launch. + */ + claudeAccountId?: string + /** The launch's own Claude account choice, the active sentinel included: read and recorded like + * a saved launch config's, so it yields to an explicit `claudeAccountId`. */ + launchClaudeAccountId?: string terminalKittyKeyboardProtocol?: boolean terminalImageViewerSupported?: boolean terminalColorQueryReplies?: TerminalOscColorQueryReplyColors diff --git a/src/main/runtime/runtime-worktree-startup-claude-account.test.ts b/src/main/runtime/runtime-worktree-startup-claude-account.test.ts new file mode 100644 index 000000000000..1e9f92130ce4 --- /dev/null +++ b/src/main/runtime/runtime-worktree-startup-claude-account.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveWorktreeStartupClaudeAccount } from './runtime-worktree-startup-claude-account' + +const accounts = { + accounts: [ + { + id: 'acct-b', + email: 'pinned@example.com', + managedAuthRuntime: 'host' as const, + wslDistro: null, + authMethod: 'subscription-oauth' as const, + organizationUuid: null, + organizationName: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeAccountId: null +} + +function resolve(overrides: Partial[0]>) { + return resolveWorktreeStartupClaudeAccount({ + request: { startupAgent: 'claude', startupClaudeAccount: 'pinned@example.com' }, + createRouteKind: 'local', + canSpawn: true, + listClaudeAccounts: () => accounts, + ...overrides + }) +} + +describe('resolveWorktreeStartupClaudeAccount', () => { + it('resolves the selector for a local Claude startup agent', () => { + expect(resolve({})).toBe('acct-b') + }) + + it('is a no-op without --account and never lists accounts', () => { + const listClaudeAccounts = vi.fn(() => accounts) + expect( + resolve({ request: { startupAgent: 'claude' }, createRouteKind: 'ssh', listClaudeAccounts }) + ).toBeUndefined() + expect(listClaudeAccounts).not.toHaveBeenCalled() + }) + + it('refuses every case the startup terminal could not honour', () => { + expect(() => + resolve({ request: { startupAgent: 'codex', startupClaudeAccount: 'acct-b' } }) + ).toThrow('--account requires --agent claude.') + expect(() => resolve({ createRouteKind: 'ssh' })).toThrow(/not supported for SSH/) + expect(() => resolve({ createRouteKind: 'runtime' })).toThrow(/connected-server/) + expect(() => resolve({ canSpawn: false })).toThrow(/cannot start Claude --account/) + expect(() => + resolve({ request: { startupAgent: 'claude', startupClaudeAccount: 'nobody@example.com' } }) + ).toThrow(/orca account list/) + }) +}) diff --git a/src/main/runtime/runtime-worktree-startup-claude-account.ts b/src/main/runtime/runtime-worktree-startup-claude-account.ts new file mode 100644 index 000000000000..5d315ae0aea7 --- /dev/null +++ b/src/main/runtime/runtime-worktree-startup-claude-account.ts @@ -0,0 +1,38 @@ +import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' +import { resolveClaudeLaunchAccount } from '../claude-accounts/claude-launch-account' +import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' + +/** + * Resolves and validates a create's `--account` before anything is created on disk. + * + * Every refusal happens here because the startup terminal is spawned after the checkout exists, + * and a failure there is only a warning on an otherwise successful create — a worktree whose + * agent silently never started, or worse, started on the host's active account. + */ +export function resolveWorktreeStartupClaudeAccount(args: { + request: Pick< + RuntimeManagedWorktreeCreateArgs, + 'startupClaudeAccount' | 'startupAgent' | 'startup' + > + createRouteKind: 'local' | 'ssh' | 'runtime' + canSpawn: boolean + listClaudeAccounts: () => ClaudeRateLimitAccountsState +}): string | undefined { + const selector = args.request.startupClaudeAccount + if (selector === undefined) { + return undefined + } + if (args.request.startupAgent !== 'claude' || args.request.startup) { + throw new Error('--account requires --agent claude.') + } + if (args.createRouteKind !== 'local') { + throw new Error( + 'Claude --account launches run on this Orca host only; they are not supported for SSH or connected-server workspaces.' + ) + } + if (!args.canSpawn) { + // Why: without a runtime PTY lane the renderer would launch the agent on the active account. + throw new Error('This Orca runtime cannot start Claude --account terminals.') + } + return resolveClaudeLaunchAccount(args.listClaudeAccounts(), selector).accountId +} diff --git a/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts b/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts index cd2748e1cc31..4fd852a7500f 100644 --- a/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts +++ b/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts @@ -144,6 +144,7 @@ export abstract class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends O : resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), ompResumeFilePath: request.ompResumeFilePath, + claudeAccountId: request.agent === 'claude' ? request.claudeAccountId : undefined, sessionOptions: this.toAgentSessionOptions(request.launchPreferences), sessionOptionsOverrideAgentArgs: Boolean(request.launchPreferences), platform, diff --git a/src/main/runtime/terminal/launch/agent-teams-launch-plan.test.ts b/src/main/runtime/terminal/launch/agent-teams-launch-plan.test.ts new file mode 100644 index 000000000000..34c6160a0bf6 --- /dev/null +++ b/src/main/runtime/terminal/launch/agent-teams-launch-plan.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-agent-teams-plan-test' } })) + +const { buildRuntimeAgentTeamsLaunchPlan } = await import('./agent-teams-launch-plan') + +describe('buildRuntimeAgentTeamsLaunchPlan', () => { + it('keeps an --account leader on in-process teammates instead of host-account panes', async () => { + const createTeamEnv = vi.fn(() => ({ ORCA_AGENT_TEAMS_TEAM_ID: 'team-1' })) + + const result = await buildRuntimeAgentTeamsLaunchPlan({ + launch: { command: 'claude', claudeAccountId: 'acct-b' }, + claudeAgentTeamsMode: 'native-panes-shim', + baseEnv: { ORCA_AGENT_TEAMS_SHIM_BIN: '/usr/local/bin/orca' }, + adoptedBeforeLaunch: false, + createTeamEnv + }) + + expect(createTeamEnv).not.toHaveBeenCalled() + expect(result.plan?.command).toContain('--teammate-mode in-process') + expect(result.plan?.env).toEqual({ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' }) + }) +}) diff --git a/src/main/runtime/terminal/launch/agent-teams-launch-plan.ts b/src/main/runtime/terminal/launch/agent-teams-launch-plan.ts index abb29be8a141..93cb56eebb0b 100644 --- a/src/main/runtime/terminal/launch/agent-teams-launch-plan.ts +++ b/src/main/runtime/terminal/launch/agent-teams-launch-plan.ts @@ -6,12 +6,21 @@ import { } from '../../../../shared/claude-agent-teams-tmux-compat' import type { TerminalCreateOptions } from '../../runtime-terminal-contracts' import { buildClaudeAgentTeamsLaunchPlan } from '../../claude-agent-teams-shim-env' -import { inferCapturedClaudeAgentTeamsMode } from '../../runtime-agent-launch-resolution' +import { + inferCapturedClaudeAgentTeamsMode, + withRecordedClaudeAccount +} from '../../runtime-agent-launch-resolution' -export async function buildRuntimeAgentTeamsLaunchPlan(args: { - launchConfig: TerminalCreateOptions['launchConfig'] - command?: string - claudeAgentTeamsSourceCommand?: string +export async function buildRuntimeAgentTeamsLaunchPlan(input: { + launch: Pick< + TerminalCreateOptions, + | 'launchConfig' + | 'command' + | 'claudeAgentTeamsSourceCommand' + | 'claudeAccountId' + | 'launchClaudeAccountId' + | 'launchAgent' + > claudeAgentTeamsMode?: ClaudeAgentTeamsMode baseEnv: Record adoptedBeforeLaunch: @@ -24,13 +33,26 @@ export async function buildRuntimeAgentTeamsLaunchPlan(args: { sequencedStartupCommand?: string effectiveLaunchConfig: TerminalCreateOptions['launchConfig'] }> { + const { launch } = input + // Why named, never spread: a spread reads every launch getter, before the caller has captured its spawn state. + const args = { + ...input, + launchConfig: launch.launchConfig, + command: launch.command, + claudeAgentTeamsSourceCommand: launch.claudeAgentTeamsSourceCommand, + claudeAccountId: launch.claudeAccountId + } const sourceCommand = args.claudeAgentTeamsSourceCommand?.trim() || args.command?.trim() || undefined - const mode = inferCapturedClaudeAgentTeamsMode( + const capturedMode = inferCapturedClaudeAgentTeamsMode( args.launchConfig, sourceCommand, args.claudeAgentTeamsMode ) + // Why: native-pane teammates are separate terminals launched on the host's active account; an + // `--account` leader keeps its teammates in-process, on its own pinned credentials. + const mode = + args.claudeAccountId && capturedMode === 'native-panes-shim' ? 'in-process' : capturedMode const plan = args.adoptedBeforeLaunch ? undefined : await buildClaudeAgentTeamsLaunchPlan({ @@ -55,5 +77,9 @@ export async function buildRuntimeAgentTeamsLaunchPlan(args: { agentEnv: { ...args.launchConfig.agentEnv, ...plan.env } } : args.launchConfig - return { plan, sequencedStartupCommand, effectiveLaunchConfig } + return { + plan, + sequencedStartupCommand, + effectiveLaunchConfig: withRecordedClaudeAccount(effectiveLaunchConfig, launch) + } } diff --git a/src/main/runtime/terminal/launch/terminal-create.ts b/src/main/runtime/terminal/launch/terminal-create.ts index a6da016e51e3..1ebb42698e7e 100644 --- a/src/main/runtime/terminal/launch/terminal-create.ts +++ b/src/main/runtime/terminal/launch/terminal-create.ts @@ -76,9 +76,7 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi let sequencedStartupCommand: string | undefined try { const agentTeams = await buildRuntimeAgentTeamsLaunchPlan({ - launchConfig: launchOpts.launchConfig, - command: launchOpts.command, - claudeAgentTeamsSourceCommand: launchOpts.claudeAgentTeamsSourceCommand, + launch: launchOpts, claudeAgentTeamsMode: this.store?.getSettings?.().claudeAgentTeamsMode, baseEnv: { ...process.env, ...baseEnv }, adoptedBeforeLaunch: null, @@ -130,6 +128,7 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi launchOpts.envToDelete, agentTeamsPlan?.envToDelete ), + ...dependencies.terminalCreateClaudeAccountIdField(launchOpts), resumeProviderSession: launchOpts.resumeProviderSession, telemetry: launchOpts.telemetry, connectionId: workspace.connectionId, diff --git a/src/main/runtime/workspaces/startup/workspace-terminal-provisioning.ts b/src/main/runtime/workspaces/startup/workspace-terminal-provisioning.ts index 8276dd0fcdfe..22147ccbf411 100644 --- a/src/main/runtime/workspaces/startup/workspace-terminal-provisioning.ts +++ b/src/main/runtime/workspaces/startup/workspace-terminal-provisioning.ts @@ -7,6 +7,7 @@ import type { WorktreeStartupReadinessHost } from '../../runtime-worktree-startu import { prefetchWorktreeCreateBase } from '../../../worktree-create-base-prefetch' import { prepareWorktreeCreateForRepo } from '../../../worktree-create-preparation' import { getWorktreeCreatePrefetchGitOptions } from '../../../project-runtime-git-options' +import { resolveWorktreeStartupClaudeAccount } from '../../runtime-worktree-startup-claude-account' import type { Worktree } from '../../../../shared/worktree/types' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import type { LayoutApplyReply } from '../../../../shared/workspace-layout/workspace-layout-command-types' @@ -78,6 +79,18 @@ export abstract class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends } } + protected resolveStartupClaudeAccount( + request: Parameters[0]['request'], + createRouteKind: Parameters[0]['createRouteKind'] + ): string | undefined { + return resolveWorktreeStartupClaudeAccount({ + request, + createRouteKind, + canSpawn: Boolean(this.ptyController?.spawn), + listClaudeAccounts: () => this.getAccountsSnapshot().claude + }) + } + private getLayoutOwnerTerminalProvisioningHost( createdWorktree?: Worktree ): WorktreeTerminalProvisioningHost { diff --git a/src/main/runtime/workspaces/workspace-creation.ts b/src/main/runtime/workspaces/workspace-creation.ts index eb53a9947e5e..9c7fa523be78 100644 --- a/src/main/runtime/workspaces/workspace-creation.ts +++ b/src/main/runtime/workspaces/workspace-creation.ts @@ -110,10 +110,12 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi ) // Match IPC routing: executionHostId-only SSH repos must not create locally. const createRoute = resolveWorktreeCreateRoute(repo) + const startupClaudeAccountId = this.resolveStartupClaudeAccount(args, createRoute.kind) if (isFolderRepo(repo)) { // A folder workspace is a registration, not a filesystem create, so it is host-agnostic. return createRuntimeFolderWorktree({ request: args, + startupClaudeAccountId, repo, startup: effectiveStartup, startupFollowup: effectiveStartupFollowup, @@ -221,6 +223,7 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi startupTerminalPtyId } = await startRuntimeLocalWorktreeTerminals({ request: args, + startupClaudeAccountId, repo, worktree, setup, diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index bfcbb91749a6..32469432b5c7 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -28,6 +28,7 @@ import { setDefaultWslDistroOverride } from '../git/runner' import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' +import { seedClaudePinnedPtyRegistry } from '../claude-accounts/claude-pty-account-registries-startup' import { applyAppIcon } from '../app-icon' import { shouldSuppressDevEducation, @@ -267,6 +268,7 @@ export async function initializeReadyFoundation(): Promise { } } }) + seedClaudePinnedPtyRegistry(app.getPath('userData')) applyAppIcon(store.getSettings().appIcon) if (shouldSuppressDevEducation({ isDev: !app.isPackaged })) { suppressDevEducationForStore(store) diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index c8cdda561d10..4d9c9586d38e 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -150,7 +150,7 @@ async function launchServeMode( runtime, prepareCodexRuntimeHomeForLaunch, () => state.store!.getSettings(), - (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target), + (target, options) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target, options), state.store!, prepareCodexSessionResumeForLaunch, { onCodexHomePtySpawned: handleCodexHomePtySpawned, onPtyExit: handlePtyExit } diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 8acbe497b1d1..988185f52d3c 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -89,7 +89,8 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { ...agentHookRuntimeDeps(agentHookServer, observedPaneIdentities, () => state.store?.getSettings() ), - prepareClaudeAuth: (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target), + prepareClaudeAuth: (target, options) => + state.claudeRuntimeAuth!.prepareForClaudeLaunch(target, options), agentSessionClaimSigner: loadAgentSessionClaimSigner( getProfileUserDataPath(), getProfileUserDataPath() diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts index cbb314dbe926..c40902b48381 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -5,6 +5,16 @@ import { getDashboardPopoutWindow } from '../window/dashboard-popout-window' import { stopSyntheticTitleSpinnerTimer } from './synthetic-title-runtime' import { mainProcessState as state } from './main-process-state' import { pickHostTurnEnd } from '../../shared/agent-status-types' +import { getPtyIdForPaneKey } from '../ipc/pty/pane/key-state' +import { + pinnedClaudeAccountIdForPane, + type PinnedClaudePaneLookup +} from '../claude-accounts/pinned-claude-pane-account' + +const pinnedPaneLookup: PinnedClaudePaneLookup = { + getLivePtyIdForPaneKey: getPtyIdForPaneKey, + getPersistedTerminalLayouts: () => state.store?.getWorkspaceSession().terminalLayoutsByTabId +} export type MainWindowAgentStatusOptions = { window: BrowserWindow @@ -66,6 +76,10 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt const runtime = state.runtime const orchestration = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey) const terminalHandle = runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey) + const claudeAccountId = pinnedClaudeAccountIdForPane( + { paneKey, connectionId }, + pinnedPaneLookup + ) const statusEvent = { ...(authorityRestartId && isReplay !== true ? { authorityRestartId } : {}), ...payload, @@ -85,6 +99,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), ...(observation ? { observation } : {}), ...(orchestration ? { orchestration } : {}), + ...(claudeAccountId ? { claudeAccountId } : {}), ...(programStatus ? { programStatus } : {}), ...(programStatusOnly === true ? { programStatusOnly: true } : {}), // Why: reconnect replays re-send cached rows; the renderer must not treat them as fresh completions. diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index c350a4e573f0..603cda2d8e1e 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -120,7 +120,7 @@ export function attachMainWindowCoreServices( store, runtime, prepareCodexRuntimeHomeForLaunch, - (target) => claudeRuntimeAuth.prepareForClaudeLaunch(target), + (target, options) => claudeRuntimeAuth.prepareForClaudeLaunch(target, options), { prepareCodexSessionResume: prepareCodexSessionResumeForLaunch, awaitLocalPtyStartup: () => state.localPtyStartupReady, diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 4992a5b6d4dd..3d3089ccdf77 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -16,6 +16,7 @@ import { registerPtyHandlers } from '../ipc/pty/register-handlers' import type { CodexHomePtySpawnedLifecycleArgs, GetSelectedCodexHomePath, + PrepareClaudeAuth, PrepareCodexSessionResume } from '../ipc/pty/host-env/types' import { registerDaemonManagementHandlers } from '../ipc/pty-management' @@ -28,9 +29,7 @@ import { scheduleHistoryGc } from '../terminal-history-gc' import { openCodeHookService, openCode2HookService } from '../opencode/hook-service' import { listLiveDaemonPtyIds } from '../daemon/daemon-provider-state' import { hydrateLocalPtyRegistryAtBoot } from '../memory/hydrate-local-pty-registry' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { getKnownWorktreeIdsForHistoryGc } from './history-gc-worktree-ids' -import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { scheduleWorktreeBaseDirectoryWatcherSync, setWorktreeBaseDirectoryWatcherSyncContext @@ -54,9 +53,7 @@ export function attachMainWindowServices( store: Store, runtime: OrcaRuntimeService, getSelectedCodexHomePath?: GetSelectedCodexHomePath, - prepareClaudeAuth?: ( - target?: ClaudeAccountSelectionTarget - ) => Promise, + prepareClaudeAuth?: PrepareClaudeAuth, options?: { prepareCodexSessionResume?: PrepareCodexSessionResume awaitLocalPtyStartup?: () => Promise diff --git a/src/preload/api/repository-api.ts b/src/preload/api/repository-api.ts index 5a4003e12e55..0b56341262fd 100644 --- a/src/preload/api/repository-api.ts +++ b/src/preload/api/repository-api.ts @@ -1,5 +1,6 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { GhAccountBinding } from '../../shared/github/account-binding' +import type { RepoAgentAccounts } from '../../shared/claude/project-claude-account-preference' import type { NestedRepoScanResult, ProjectGroup, @@ -67,6 +68,7 @@ export type RepositoryApi = { sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null ghAccount?: GhAccountBinding | null + agentAccounts?: RepoAgentAccounts | null } }) => Promise pickFolder: () => Promise diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ca067990ad8c..0ca7f6a0a467 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -10,6 +10,7 @@ import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge' import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog' import RunningTerminalCloseDialog from './components/terminal-pane/RunningTerminalCloseDialog' import WorktreeBaseFallbackDialog from './components/WorktreeBaseFallbackDialog' +import ClaudeAccountPromptDialog from './components/claude-account-prompt/ClaudeAccountPromptDialog' import ClaudeAccountSwitchDialog from './components/ClaudeAccountSwitchDialog' import { useUnreadDockBadge } from './hooks/useUnreadDockBadge' import { AppBackgroundServices } from './app-shell/AppBackgroundServices' @@ -115,6 +116,7 @@ function App(): React.JSX.Element { + diff --git a/src/renderer/src/components/claude-account-prompt/ClaudeAccountPromptDialog.test.tsx b/src/renderer/src/components/claude-account-prompt/ClaudeAccountPromptDialog.test.tsx new file mode 100644 index 000000000000..d16b557c1b5f --- /dev/null +++ b/src/renderer/src/components/claude-account-prompt/ClaudeAccountPromptDialog.test.tsx @@ -0,0 +1,202 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { ClaudeManagedAccountSummary } from '../../../../shared/managed-account-types' +import { useAppStore } from '@/store' +import ClaudeAccountPromptDialog from './ClaudeAccountPromptDialog' +import { resetClaudeAccountPromptForTests } from './claude-account-prompt-state' +import { chooseClaudeLaunchAccount } from './choose-claude-launch-account' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' + +// Why: Radix Select needs pointer/layout APIs happy-dom lacks; a native select keeps the value testable. +vi.mock('@/components/ui/select', () => ({ + Select: ({ + value, + onValueChange, + children + }: { + value: string + onValueChange: (value: string) => void + children: React.ReactNode + }) => ( + + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => ( + + ) +})) + +const initialState = useAppStore.getInitialState() +let root: Root | null = null + +function account(id: string, organizationName?: string): ClaudeManagedAccountSummary { + return { + id, + email: `${id}@example.com`, + organizationName, + authMethod: 'subscription-oauth', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Orca', + badgeColor: '#000', + addedAt: 0, + agentAccounts: { claude: { mode: 'ask' } } +} + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the prompt only reads askClaudeAccountPerProject. +const settings = {} as GlobalSettings + +async function startPrompt(updateRepo = vi.fn(), surface: React.ReactNode = null) { + const choice = chooseClaudeLaunchAccount({ + repo, + settings, + accounts: [account('work', 'Acme'), account('personal')], + activeAccountId: 'personal', + updateRepo + }) + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render( + <> + {surface} + + + ) + }) + return { choice, updateRepo } +} + +function getButton(label: string): HTMLButtonElement { + const button = [...document.body.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label + ) + if (!button) { + throw new Error(`Button not found: ${label}`) + } + return button +} + +function getSelect(): HTMLSelectElement { + const select = document.body.querySelector('select') + if (!select) { + throw new Error('Select not found') + } + return select +} + +describe('ClaudeAccountPromptDialog', () => { + beforeEach(() => { + useAppStore.setState( + { ...initialState, activeModal: 'none', setContextualToursBlockingSurfaceVisible: vi.fn() }, + true + ) + }) + + afterEach(async () => { + await act(async () => { + root?.unmount() + }) + root = null + document.body.innerHTML = '' + resetClaudeAccountPromptForTests() + useAppStore.setState(initialState, true) + }) + + it('pre-selects the active account and cancels', async () => { + const { choice, updateRepo } = await startPrompt() + + expect(document.body.textContent).toContain('Choose a Claude account') + expect(document.body.textContent).toContain('Orca') + expect(document.body.textContent).toContain('work@example.com · Acme') + expect(getSelect().value).toBe('personal') + + await act(async () => { + getButton('Cancel').click() + }) + + await expect(choice).resolves.toEqual({ kind: 'cancelled' }) + expect(updateRepo).not.toHaveBeenCalled() + }) + + it('remembers the pick for the project when Remember is checked', async () => { + const { choice, updateRepo } = await startPrompt() + + await act(async () => { + const select = getSelect() + select.value = 'work' + select.dispatchEvent(new Event('change', { bubbles: true })) + }) + await act(async () => { + getButton('Start').click() + }) + + await expect(choice).resolves.toEqual({ kind: 'default' }) + expect(updateRepo).toHaveBeenCalledWith('repo-1', { + agentAccounts: { claude: { mode: 'account', accountId: 'work' } } + }) + }) + + it('uses the pick for this launch only when Remember is unchecked', async () => { + const { choice, updateRepo } = await startPrompt() + + await act(async () => { + document.body.querySelector('[role="checkbox"]')?.click() + }) + await act(async () => { + getButton('Start').click() + }) + + await expect(choice).resolves.toEqual({ kind: 'account', accountId: 'personal' }) + expect(updateRepo).not.toHaveBeenCalled() + }) + + it('opens over the New Workspace composer that is awaiting it', async () => { + useAppStore.setState({ activeModal: 'new-workspace-composer' }) + const onComposerOpenChange = vi.fn() + const composer = ( + + + New workspace + + + ) + const { choice } = await startPrompt(vi.fn(), composer) + + expect(document.body.textContent).toContain('Choose a Claude account') + await act(async () => { + document.body.querySelector('[role="checkbox"]')?.click() + }) + await act(async () => { + getButton('Start').click() + }) + + await expect(choice).resolves.toEqual({ kind: 'account', accountId: 'personal' }) + expect(onComposerOpenChange).not.toHaveBeenCalled() + expect(useAppStore.getState().activeModal).toBe('new-workspace-composer') + }) + + it('stays hidden behind an unrelated modal', async () => { + useAppStore.setState({ activeModal: 'confirm-orca-yaml-hooks' }) + await startPrompt() + + expect(document.body.textContent).not.toContain('Choose a Claude account') + }) +}) diff --git a/src/renderer/src/components/claude-account-prompt/ClaudeAccountPromptDialog.tsx b/src/renderer/src/components/claude-account-prompt/ClaudeAccountPromptDialog.tsx new file mode 100644 index 000000000000..b3fa30283641 --- /dev/null +++ b/src/renderer/src/components/claude-account-prompt/ClaudeAccountPromptDialog.tsx @@ -0,0 +1,157 @@ +import { useEffect, useId, useState, useSyncExternalStore } from 'react' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { claudeAccountLabel } from '../settings/repository-claude-account' +import { + answerClaudeAccountPrompt, + getClaudeAccountPromptRequest, + subscribeClaudeAccountPrompt, + type ClaudeAccountPromptRequest +} from './claude-account-prompt-state' + +function initialAccountId(request: ClaudeAccountPromptRequest | null): string { + if (!request) { + return '' + } + const active = request.accounts.find((account) => account.id === request.activeAccountId) + return (active ?? request.accounts[0])?.id ?? '' +} + +export default function ClaudeAccountPromptDialog(): React.JSX.Element { + const request = useSyncExternalStore( + subscribeClaudeAccountPrompt, + getClaudeAccountPromptRequest, + getClaudeAccountPromptRequest + ) + const activeModal = useAppStore((state) => state.activeModal) + const setContextualToursBlockingSurfaceVisible = useAppStore( + (state) => state.setContextualToursBlockingSurfaceVisible + ) + const [accountId, setAccountId] = useState(() => initialAccountId(request)) + const [remember, setRemember] = useState(true) + // Why: keeps the last request rendered while the dialog animates closed. + const [displayed, setDisplayed] = useState(request) + const selectLabelId = useId() + const rememberId = useId() + // Why: New Workspace awaits this prompt while its composer modal stays open, so it layers on top + // like the composer's other nested dialogs instead of waiting for a modal that never closes. + const open = + request !== null && (activeModal === 'none' || activeModal === 'new-workspace-composer') + + if (request && request !== displayed) { + setDisplayed(request) + setAccountId(initialAccountId(request)) + setRemember(true) + } + + useEffect(() => { + setContextualToursBlockingSurfaceVisible(open) + return () => setContextualToursBlockingSurfaceVisible(false) + }, [open, setContextualToursBlockingSurfaceVisible]) + + return ( + { + if (!isOpen) { + answerClaudeAccountPrompt({ kind: 'cancelled' }) + } + }} + > + + + + {translate( + 'auto.components.ClaudeAccountPromptDialog.title', + 'Choose a Claude account' + )} + + + {translate( + 'auto.components.ClaudeAccountPromptDialog.description', + 'Pick the Claude account to start in {{value0}}.', + { value0: displayed?.projectName ?? '' } + )} + + +
+
+ + {translate('auto.components.ClaudeAccountPromptDialog.accountLabel', 'Account')} + + +
+
+
+ setRemember(checked === true)} + /> + +
+

+ {translate( + 'auto.components.ClaudeAccountPromptDialog.rememberHint', + 'You can change this in Settings → Repository' + )} +

+
+
+ + + + +
+
+ ) +} diff --git a/src/renderer/src/components/claude-account-prompt/choose-claude-launch-account.test.ts b/src/renderer/src/components/claude-account-prompt/choose-claude-launch-account.test.ts new file mode 100644 index 000000000000..eb5247a70412 --- /dev/null +++ b/src/renderer/src/components/claude-account-prompt/choose-claude-launch-account.test.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { ClaudeManagedAccountSummary } from '../../../../shared/managed-account-types' +import { useAppStore } from '@/store' +import { toast } from 'sonner' +import { + chooseClaudeLaunchAccountForWorkspace, + launchWithClaudeAccountChoice, + shouldPromptForClaudeAccount +} from './choose-claude-launch-account' +import { claudeAccountPinningUnsupportedReasonInState } from '../settings/repository-claude-account' + +vi.mock('@/lib/renderer-app-platform', () => ({ getRendererAppPlatform: () => 'win32' })) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('@/runtime/runtime-provider-accounts-client', () => ({ + fetchProviderAccountsSnapshot: vi.fn(async () => { + throw new Error('offline') + }), + hasRemoteProviderAccountOwner: () => false +})) + +function account(id: string): ClaudeManagedAccountSummary { + return { + id, + email: `${id}@example.com`, + authMethod: 'subscription-oauth', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +function repo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 0, + ...overrides + } +} + +const twoAccounts = [account('a'), account('b')] + +describe('shouldPromptForClaudeAccount', () => { + it('does not prompt without a preference while the setting is off', () => { + expect( + shouldPromptForClaudeAccount({ + repo: repo(), + settings: { askClaudeAccountPerProject: false }, + accounts: twoAccounts + }) + ).toBe(false) + expect( + shouldPromptForClaudeAccount({ + repo: repo(), + settings: { askClaudeAccountPerProject: true }, + accounts: twoAccounts + }) + ).toBe(true) + }) + + it('prompts for an ask preference with two or more accounts', () => { + const askRepo = repo({ agentAccounts: { claude: { mode: 'ask' } } }) + expect( + shouldPromptForClaudeAccount({ repo: askRepo, settings: {}, accounts: twoAccounts }) + ).toBe(true) + expect( + shouldPromptForClaudeAccount({ repo: askRepo, settings: {}, accounts: [account('a')] }) + ).toBe(false) + }) + + it('never prompts for SSH repos or a saved account', () => { + const settings = { askClaudeAccountPerProject: true } + expect( + shouldPromptForClaudeAccount({ + repo: repo({ connectionId: 'ssh-1', agentAccounts: { claude: { mode: 'ask' } } }), + settings, + accounts: twoAccounts + }) + ).toBe(false) + expect( + shouldPromptForClaudeAccount({ + repo: repo({ agentAccounts: { claude: { mode: 'account', accountId: 'a' } } }), + settings, + accounts: twoAccounts + }) + ).toBe(false) + }) +}) + +describe('WSL projects', () => { + const initialState = useAppStore.getInitialState() + afterEach(() => useAppStore.setState(initialState, true)) + + function wslState(): ReturnType { + const askRepo = repo({ path: 'C:\\repo', agentAccounts: { claude: { mode: 'ask' } } }) + useAppStore.setState({ + repos: [askRepo], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the prompt toggle and the Windows runtime default are read on this path. + settings: { + askClaudeAccountPerProject: true, + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + } as GlobalSettings + }) + return useAppStore.getState() + } + + it('reports WSL as unsupported for pinning, matching the launch runtime', () => { + const state = wslState() + expect(claudeAccountPinningUnsupportedReasonInState(state, state.repos[0]!)).toBe('wsl') + }) + + it('launches without prompting', () => { + wslState() + const launch = vi.fn() + + launchWithClaudeAccountChoice('claude', { repoId: 'repo-1' }, launch) + + expect(launch).toHaveBeenCalledWith(undefined, false) + }) +}) + +describe('ask projects when the account list cannot load', () => { + const initialState = useAppStore.getInitialState() + afterEach(() => useAppStore.setState(initialState, true)) + + it('cancels the launch and explains why instead of starting on the active account', async () => { + useAppStore.setState({ + repos: [repo({ agentAccounts: { claude: { mode: 'ask' } } })], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the prompt toggle is read before the account fetch. + settings: { askClaudeAccountPerProject: false } as GlobalSettings + }) + + await expect(chooseClaudeLaunchAccountForWorkspace({ repoId: 'repo-1' })).resolves.toEqual({ + kind: 'cancelled' + }) + expect(toast.error).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/claude-account-prompt/choose-claude-launch-account.ts b/src/renderer/src/components/claude-account-prompt/choose-claude-launch-account.ts new file mode 100644 index 000000000000..204b51d0944a --- /dev/null +++ b/src/renderer/src/components/claude-account-prompt/choose-claude-launch-account.ts @@ -0,0 +1,184 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { ClaudeManagedAccountSummary } from '../../../../shared/managed-account-types' +import type { RepoAgentAccounts } from '../../../../shared/claude/project-claude-account-preference' +import type { TuiAgent } from '../../../../shared/tui-agent' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { + claudeAccountsTargetForRepo, + findLaunchRepo, + resolveLaunchClaudeAccountId +} from '@/lib/claude-launch-account' +import { fetchProviderAccountsSnapshot } from '@/runtime/runtime-provider-accounts-client' +import { providerAccountMatchesView } from '../settings/provider-account-visibility' +import { requestClaudeAccountPrompt } from './claude-account-prompt-state' +import { claudeAccountPinningUnsupportedReasonInState } from '../settings/repository-claude-account' + +export type ClaudeLaunchAccountChoice = + | { kind: 'default' } + | { kind: 'account'; accountId: string } + | { kind: 'cancelled' } + +type PromptEligibilityInput = { + repo: Repo | undefined + settings: Pick | null | undefined +} + +function claudeAccountPromptMayApply( + repo: Repo | undefined, + settings: PromptEligibilityInput['settings'] +): repo is Repo { + if (!repo || repo.connectionId) { + return false + } + const preference = repo.agentAccounts?.claude + if (preference) { + return preference.mode === 'ask' + } + return settings?.askClaudeAccountPerProject === true +} + +type PromptEligibilityState = Parameters[0] + +// Why: a project that cannot pin (SSH, WSL) must not offer a choice its launch would refuse. +function claudeAccountPromptMayApplyInState( + state: PromptEligibilityState, + repo: Repo | undefined +): repo is Repo { + return ( + claudeAccountPromptMayApply(repo, state.settings) && + claudeAccountPinningUnsupportedReasonInState(state, repo) === null + ) +} + +export function shouldPromptForClaudeAccount( + input: PromptEligibilityInput & { accounts: ClaudeManagedAccountSummary[] } +): boolean { + return claudeAccountPromptMayApply(input.repo, input.settings) && input.accounts.length >= 2 +} + +export async function chooseClaudeLaunchAccount(input: { + repo: Repo | undefined + settings: GlobalSettings + accounts: ClaudeManagedAccountSummary[] + activeAccountId: string | null + updateRepo: (repoId: string, u: { agentAccounts: RepoAgentAccounts }) => unknown +}): Promise { + const { repo } = input + if (!repo || !shouldPromptForClaudeAccount(input)) { + return { kind: 'default' } + } + const answer = await requestClaudeAccountPrompt({ + projectName: repo.displayName, + accounts: input.accounts, + activeAccountId: input.activeAccountId + }) + if (answer.kind === 'cancelled') { + return answer + } + if (!answer.remember) { + return { kind: 'account', accountId: answer.accountId } + } + try { + const saved = await Promise.resolve( + input.updateRepo(repo.id, { + agentAccounts: { claude: { mode: 'account', accountId: answer.accountId } } + }) + ) + // Why: if saving failed, still honor the pick for this launch. + return saved === false ? { kind: 'account', accountId: answer.accountId } : { kind: 'default' } + } catch { + return { kind: 'account', accountId: answer.accountId } + } +} + +// Why: this project asks for an account, so starting on the active one unasked could bill the wrong account. +function cancelForUnavailableAccounts(): ClaudeLaunchAccountChoice { + toast.error( + translate( + 'auto.components.ClaudeAccountPromptDialog.accountsUnavailable', + "Couldn't load your Claude accounts, so Claude wasn't started. Try again, or choose this project's account in Settings → Repository." + ) + ) + return { kind: 'cancelled' } +} + +export async function chooseClaudeLaunchAccountForWorkspace(workspace: { + repoId?: string + worktreeId?: string +}): Promise { + const state = useAppStore.getState() + const repo = findLaunchRepo(state, workspace) + const { settings } = state + if (!settings || !claudeAccountPromptMayApplyInState(state, repo)) { + return { kind: 'default' } + } + const accountsTarget = claudeAccountsTargetForRepo(repo) + let accounts: ClaudeManagedAccountSummary[] + let activeAccountId: string | null + try { + const snapshot = await fetchProviderAccountsSnapshot(accountsTarget) + if (snapshot.failedProviders?.includes('claude')) { + return cancelForUnavailableAccounts() + } + const visibility = { + remoteOwner: accountsTarget.kind === 'environment', + ownerPlatform: null + } + accounts = snapshot.claude.accounts.filter((account) => + providerAccountMatchesView(account, { runtime: 'host' }, visibility) + ) + activeAccountId = + snapshot.claude.activeAccountIdsByRuntime?.host ?? snapshot.claude.activeAccountId + } catch { + return cancelForUnavailableAccounts() + } + const hostId = getRepoExecutionHostId(repo) + return chooseClaudeLaunchAccount({ + repo, + settings, + accounts, + activeAccountId, + updateRepo: (repoId, updates) => useAppStore.getState().updateRepo(repoId, updates, { hostId }) + }) +} + +// Why: a remembered pick lands in the store, so resolve it there rather than from a caller's repo snapshot. +export async function chooseClaudeLaunchAccountIdForRepo( + repoId: string +): Promise { + const choice = await chooseClaudeLaunchAccountForWorkspace({ repoId }) + if (choice.kind === 'cancelled') { + return null + } + return choice.kind === 'account' + ? choice.accountId + : resolveLaunchClaudeAccountId(findLaunchRepo(useAppStore.getState(), { repoId })) +} + +// Why: launch synchronously when no prompt can apply so menu-close focus handoffs keep today's timing. +export function launchWithClaudeAccountChoice( + agent: TuiAgent, + target: { repoId?: string; worktreeId?: string; afterDeferredLaunch?: () => void }, + launch: (claudeAccountId: string | undefined, deferred: boolean) => void +): void { + const state = useAppStore.getState() + if ( + agent !== 'claude' || + !claudeAccountPromptMayApplyInState(state, findLaunchRepo(state, target)) + ) { + launch(undefined, false) + return + } + void (async () => { + const choice = await chooseClaudeLaunchAccountForWorkspace(target) + if (choice.kind === 'cancelled') { + return + } + launch(choice.kind === 'account' ? choice.accountId : undefined, true) + target.afterDeferredLaunch?.() + })() +} diff --git a/src/renderer/src/components/claude-account-prompt/claude-account-prompt-state.ts b/src/renderer/src/components/claude-account-prompt/claude-account-prompt-state.ts new file mode 100644 index 000000000000..2a7e3e6ac215 --- /dev/null +++ b/src/renderer/src/components/claude-account-prompt/claude-account-prompt-state.ts @@ -0,0 +1,55 @@ +import type { ClaudeManagedAccountSummary } from '../../../../shared/managed-account-types' + +export type ClaudeAccountPromptAnswer = + | { kind: 'start'; accountId: string; remember: boolean } + | { kind: 'cancelled' } + +export type ClaudeAccountPromptRequest = { + projectName: string + accounts: ClaudeManagedAccountSummary[] + activeAccountId: string | null + resolve: (answer: ClaudeAccountPromptAnswer) => void +} + +const requests: ClaudeAccountPromptRequest[] = [] +const listeners = new Set<() => void>() + +function notifyListeners(): void { + for (const listener of listeners) { + listener() + } +} + +export function requestClaudeAccountPrompt( + request: Omit +): Promise { + return new Promise((resolve) => { + requests.push({ ...request, resolve }) + notifyListeners() + }) +} + +export function answerClaudeAccountPrompt(answer: ClaudeAccountPromptAnswer): void { + const request = requests.shift() + if (!request) { + return + } + request.resolve(answer) + notifyListeners() +} + +export function getClaudeAccountPromptRequest(): ClaudeAccountPromptRequest | null { + return requests[0] ?? null +} + +export function subscribeClaudeAccountPrompt(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function resetClaudeAccountPromptForTests(): void { + for (const request of requests.splice(0)) { + request.resolve({ kind: 'cancelled' }) + } + notifyListeners() +} diff --git a/src/renderer/src/components/settings/ClaudeAskPerProjectSetting.tsx b/src/renderer/src/components/settings/ClaudeAskPerProjectSetting.tsx new file mode 100644 index 000000000000..6f6b91ebf811 --- /dev/null +++ b/src/renderer/src/components/settings/ClaudeAskPerProjectSetting.tsx @@ -0,0 +1,57 @@ +import { translate } from '@/i18n/i18n' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { Label } from '../ui/label' +import { Switch } from '../ui/switch' +import { SearchableSetting } from './SearchableSetting' +import { getAskClaudeAccountPerProjectSearchKeywords } from './accounts-claude-search' + +export function ClaudeAskPerProjectSetting({ + settings, + updateSettings +}: { + settings: Pick + updateSettings: (updates: Partial) => void +}): React.JSX.Element { + return ( + +
+
+ +

+ {translate( + 'auto.components.settings.AccountsPane.askClaudeAccountPerProjectDescription', + 'When on, projects without a saved Claude account ask before starting Claude.' + )} +

+
+ { + updateSettings({ askClaudeAccountPerProject: checked }) + }} + /> +
+
+ ) +} diff --git a/src/renderer/src/components/settings/RepositoryClaudeAccountSection.test.tsx b/src/renderer/src/components/settings/RepositoryClaudeAccountSection.test.tsx new file mode 100644 index 000000000000..e72b10849f6f --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryClaudeAccountSection.test.tsx @@ -0,0 +1,271 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { RepoAgentAccounts } from '../../../../shared/claude/project-claude-account-preference' +import type { ClaudeManagedAccountSummary } from '../../../../shared/managed-account-types' +import type { ProviderAccountsSnapshot } from '@/runtime/runtime-provider-accounts-client' +import { RepositoryClaudeAccountSection } from './RepositoryClaudeAccountSection' + +const { fetchSnapshotMock, storeState } = vi.hoisted(() => { + const repos: Repo[] = [] + return { + fetchSnapshotMock: vi.fn(), + storeState: { + settings: null, + repos, + settingsSearchQuery: '' + } + } +}) + +vi.mock('../../store', () => { + const useAppStore = (selector: (state: typeof storeState) => unknown) => selector(storeState) + useAppStore.getState = () => storeState + return { useAppStore } +}) + +vi.mock('@/lib/repo-runtime-owner', () => ({ + runtimeTargetForRepoOwner: () => ({ kind: 'local' }) +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: vi.fn() +})) + +vi.mock('@/runtime/runtime-provider-accounts-client', () => ({ + fetchProviderAccountsSnapshot: fetchSnapshotMock +})) + +// Why: Radix Select needs pointer/layout APIs happy-dom lacks; a native select keeps value + disabled testable. +vi.mock('../ui/select', () => ({ + Select: ({ + value, + onValueChange, + disabled, + children + }: { + value: string + onValueChange: (value: string) => void + disabled?: boolean + children: React.ReactNode + }) => ( + + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectItem: ({ + value, + disabled, + children + }: { + value: string + disabled?: boolean + children: React.ReactNode + }) => ( + + ) +})) + +const DEFAULT_VALUE = '__default__' +const ASK_VALUE = '__ask__' + +const BASE_REPO: Repo = { + id: 'repo-1', + path: '/home/user/project', + displayName: 'My Project', + badgeColor: '#000000', + addedAt: 0 +} + +const ACCOUNT_A: ClaudeManagedAccountSummary = { + id: 'account-a', + email: 'a@x.com', + authMethod: 'subscription-oauth', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 +} + +const ACCOUNT_B: ClaudeManagedAccountSummary = { + id: 'account-b', + email: 'b@y.com', + authMethod: 'subscription-oauth', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 +} + +function snapshot(accounts: ClaudeManagedAccountSummary[]): ProviderAccountsSnapshot { + return { + claude: { accounts, activeAccountId: null, activeAccountIdsByRuntime: { host: null, wsl: {} } }, + codex: { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + }, + rateLimits: null + } +} + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + fetchSnapshotMock.mockReset() + storeState.repos = [] +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() +}) + +type UpdateRepo = (repoId: string, updates: { agentAccounts?: RepoAgentAccounts | null }) => unknown + +/** updateRepo that echoes the preference back through the store, like a current-version host. */ +function echoingUpdateRepo(): ReturnType> { + return vi.fn(async (repoId, updates) => { + storeState.repos = storeState.repos.map((entry) => + entry.id === repoId ? { ...entry, agentAccounts: updates.agentAccounts ?? undefined } : entry + ) + return true + }) +} + +async function render(repo: Repo, updateRepo: UpdateRepo = echoingUpdateRepo()): Promise { + storeState.repos = [repo] + await act(async () => { + root.render( + React.createElement(RepositoryClaudeAccountSection, { + repo, + updateRepo, + forceVisible: true + }) + ) + }) + // Flush the account-snapshot load kicked off by the mount effect. + await act(async () => {}) +} + +function getSelect(): HTMLSelectElement { + const select = container.querySelector('select') + if (!select) { + throw new Error('account select not found') + } + return select +} + +function findOption(text: string): HTMLOptionElement { + const option = Array.from(container.querySelectorAll('option')).find((entry) => + entry.textContent?.includes(text) + ) + if (!option) { + throw new Error(`option containing "${text}" not found`) + } + return option +} + +async function choose(value: string): Promise { + await act(async () => { + const select = getSelect() + select.value = value + select.dispatchEvent(new Event('change', { bubbles: true })) + }) +} + +describe('RepositoryClaudeAccountSection', () => { + it('starts on Default and lists host accounts', async () => { + fetchSnapshotMock.mockResolvedValue(snapshot([ACCOUNT_A, ACCOUNT_B])) + + await render(BASE_REPO) + + expect(getSelect().value).toBe(DEFAULT_VALUE) + expect(findOption('Default')).toBeTruthy() + expect(findOption('Ask every time')).toBeTruthy() + expect(findOption('a@x.com')).toBeTruthy() + expect(findOption('b@y.com')).toBeTruthy() + }) + + it('saves an account and clears back to Default', async () => { + fetchSnapshotMock.mockResolvedValue(snapshot([ACCOUNT_A, ACCOUNT_B])) + const updateRepo = echoingUpdateRepo() + + await render(BASE_REPO, updateRepo) + await choose('account-a') + + expect(updateRepo).toHaveBeenCalledWith('repo-1', { + agentAccounts: { claude: { mode: 'account', accountId: 'account-a' } } + }) + + // The store echoed the save back; re-render with the updated repo like the real parent would. + const afterSelect = storeState.repos.find((entry) => entry.id === 'repo-1') + if (!afterSelect) { + throw new Error('repo not found after select') + } + await act(async () => { + root.render( + React.createElement(RepositoryClaudeAccountSection, { + repo: afterSelect, + updateRepo, + forceVisible: true + }) + ) + }) + await choose(DEFAULT_VALUE) + + expect(updateRepo).toHaveBeenLastCalledWith('repo-1', { agentAccounts: null }) + }) + + it('saves Ask every time', async () => { + fetchSnapshotMock.mockResolvedValue(snapshot([ACCOUNT_A])) + const updateRepo = echoingUpdateRepo() + + await render(BASE_REPO, updateRepo) + await choose(ASK_VALUE) + + expect(updateRepo).toHaveBeenCalledWith('repo-1', { + agentAccounts: { claude: { mode: 'ask' } } + }) + }) + + it('shows a removed saved account as unavailable', async () => { + fetchSnapshotMock.mockResolvedValue(snapshot([ACCOUNT_A])) + + await render({ + ...BASE_REPO, + agentAccounts: { claude: { mode: 'account', accountId: 'account-missing' } } + }) + + expect(getSelect().value).toBe('account-missing') + const removed = findOption('(unavailable)') + expect(removed.textContent).toBe('Removed account (unavailable)') + expect(removed.disabled).toBe(true) + }) + + it('is disabled with a note for SSH projects', async () => { + fetchSnapshotMock.mockResolvedValue(snapshot([ACCOUNT_A])) + + await render({ ...BASE_REPO, connectionId: 'ssh-1' }) + + expect(getSelect().disabled).toBe(true) + expect(fetchSnapshotMock).not.toHaveBeenCalled() + expect(container.textContent).toContain("aren't supported for this project's host yet") + }) +}) diff --git a/src/renderer/src/components/settings/RepositoryClaudeAccountSection.tsx b/src/renderer/src/components/settings/RepositoryClaudeAccountSection.tsx new file mode 100644 index 000000000000..0600cad4587c --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryClaudeAccountSection.tsx @@ -0,0 +1,246 @@ +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import type { Repo } from '../../../../shared/repo-types' +import type { ClaudeManagedAccountSummary } from '../../../../shared/managed-account-types' +import type { RepoAgentAccounts } from '../../../../shared/claude/project-claude-account-preference' +import { repoAgentAccountsEqual } from '../../../../shared/claude/project-claude-account-preference' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { SearchableSetting } from './SearchableSetting' +import { useAppStore } from '../../store' +import { claudeAccountsTargetForRepo } from '@/lib/claude-launch-account' +import { fetchProviderAccountsSnapshot } from '@/runtime/runtime-provider-accounts-client' +import { providerAccountMatchesView } from './provider-account-visibility' +import { translate } from '@/i18n/i18n' +import { searchKeywords } from './settings-search-keywords' +import { + ASK_VALUE, + DEFAULT_VALUE, + buildClaudeAccountOptions, + claudeAccountPinningUnsupportedReasonInState +} from './repository-claude-account' + +type RepositoryClaudeAccountSectionProps = { + repo: Repo + updateRepo: (repoId: string, updates: { agentAccounts?: RepoAgentAccounts | null }) => unknown + forceVisible?: boolean +} + +export function RepositoryClaudeAccountSection({ + repo, + updateRepo, + forceVisible +}: RepositoryClaudeAccountSectionProps): React.JSX.Element | null { + const [accounts, setAccounts] = useState([]) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [notEnforced, setNotEnforced] = useState(false) + const loadGenerationRef = useRef(0) + const selectLabelId = useId() + + const unsupportedReason = useAppStore((state) => + claudeAccountPinningUnsupportedReasonInState(state, repo) + ) + + const ownerTarget = claudeAccountsTargetForRepo(repo) + // Why keyed on the environment id: the converter returns a fresh object each render. + const ownerEnvironmentId = ownerTarget.kind === 'environment' ? ownerTarget.environmentId : null + const runtimeTarget = useMemo( + () => + ownerEnvironmentId + ? { kind: 'environment' as const, environmentId: ownerEnvironmentId } + : { kind: 'local' as const }, + [ownerEnvironmentId] + ) + const accountVisibilityOptions = useMemo( + () => ({ remoteOwner: runtimeTarget.kind === 'environment', ownerPlatform: null }), + [runtimeTarget] + ) + + const loadAccounts = useCallback(async () => { + if (unsupportedReason) { + return + } + const generation = ++loadGenerationRef.current + setLoading(true) + setError(null) + try { + const snapshot = await fetchProviderAccountsSnapshot(runtimeTarget) + if (generation !== loadGenerationRef.current) { + return + } + setAccounts( + snapshot.claude.accounts.filter((account) => + providerAccountMatchesView(account, { runtime: 'host' }, accountVisibilityOptions) + ) + ) + } catch (err) { + if (generation !== loadGenerationRef.current) { + return + } + setError( + err instanceof Error + ? err.message + : translate( + 'auto.components.settings.RepositoryClaudeAccountSection.loadFailed', + 'Could not load Claude accounts.' + ) + ) + setAccounts([]) + } finally { + if (generation === loadGenerationRef.current) { + setLoading(false) + } + } + }, [unsupportedReason, runtimeTarget, accountVisibilityOptions]) + + useEffect(() => { + void loadAccounts() + }, [loadAccounts]) + + const saved = repo.agentAccounts?.claude + const options = buildClaudeAccountOptions({ accounts, saved }) + const selectedValue = !saved ? DEFAULT_VALUE : saved.mode === 'ask' ? ASK_VALUE : saved.accountId + + const applyPreference = async (requested: RepoAgentAccounts | null): Promise => { + if (saving) { + return + } + setSaving(true) + setError(null) + setNotEnforced(false) + try { + const result = await Promise.resolve(updateRepo(repo.id, { agentAccounts: requested })) + if (result === false) { + setError( + translate( + 'auto.components.settings.RepositoryClaudeAccountSection.saveFailed', + 'Could not save the Claude account preference.' + ) + ) + return + } + const echoed = useAppStore + .getState() + .repos.find((entry) => entry.id === repo.id)?.agentAccounts + if (!repoAgentAccountsEqual(requested, echoed ?? null)) { + setNotEnforced(true) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setSaving(false) + } + } + + return ( + +
+
+ {translate( + 'auto.components.settings.RepositoryClaudeAccountSection.title', + 'Claude Account' + )} +
+

+ {translate( + 'auto.components.settings.RepositoryClaudeAccountSection.description', + 'Choose which Claude account starts in this project.' + )} +

+
+ +
+ + {translate( + 'auto.components.settings.RepositoryClaudeAccountSection.selectLabel', + 'Account' + )} + + +
+ + {unsupportedReason ? ( +

+ {translate( + 'auto.components.settings.RepositoryClaudeAccountSection.unsupported', + "Per-project Claude accounts aren't supported for this project's host yet." + )} +

+ ) : null} + {notEnforced ? ( +

+ {translate( + 'auto.components.settings.RepositoryClaudeAccountSection.notEnforced', + 'Saved, but not enforced by this runtime (mixed-version host).' + )} +

+ ) : null} + {error ?

{error}

: null} +
+ ) +} diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index 9927b7143548..c166e848d0ef 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -1,4 +1,5 @@ import type { GhAccountBinding } from '../../../../shared/github/account-binding' +import type { RepoAgentAccounts } from '../../../../shared/claude/project-claude-account-preference' import { useCallback, useRef, useState } from 'react' import type { OrcaHooks, RepoHookSettings } from '../../../../shared/orca-yaml-hook-types' import type { Project, ProjectUpdateArgs } from '../../../../shared/project-types' @@ -21,26 +22,24 @@ import { useAppStore } from '@/store/index' import { getRepositoryIconSectionId } from './repository-settings-targets' import { RepositoryIconPicker } from './repository/identity/RepositoryIconPicker' import { getRepositoryPaneSearchEntries } from './repository-search' -import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection' import { RepoSettingsDraftInput } from './RepositorySettingsDraftInput' -import { RepositoryForkSyncSection } from './repository/identity/RepositoryForkSyncSection' -import { RepositoryGitHubAccountSection } from './RepositoryGitHubAccountSection' +import { RepositoryProjectSettingsSections } from './RepositoryProjectSettingsSections' +import { RepositoryClaudeAccountSection } from './RepositoryClaudeAccountSection' import { translate } from '@/i18n/i18n' -import { RepositoryWindowsRuntimeSection } from './RepositoryWindowsRuntimeSection' import { matchesRepositoryIdentitySearch } from './repository/identity/repository-identity-search' -import { RepositoryWorktreeDefaultsSection } from './RepositoryWorktreeDefaultsSection' import { RepositoryJiraProjectSection } from './RepositoryJiraProjectSection' import { getProjectRuntimeSessionSummary } from './repository-runtime-session-summary' import { getRepoOwnerWorktreeVisibilityDefaults } from '../../store/worktree-visibility-defaults-by-host' export { getRepositoryPaneSearchEntries } -type RepositoryPaneRepoUpdate = Omit< +export type RepositoryPaneRepoUpdate = Omit< Partial, - 'sourceControlAi' | 'externalWorktreeVisibility' | 'ghAccount' + 'sourceControlAi' | 'externalWorktreeVisibility' | 'ghAccount' | 'agentAccounts' > & { sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null ghAccount?: GhAccountBinding | null + agentAccounts?: RepoAgentAccounts | null } const EMPTY_WSL_DISTROS: string[] = [] @@ -314,58 +313,39 @@ export function RepositoryPane({ /> {!isFolder ? ( - <> - - - - - - - - - - fetchWorktrees(repoId, { - executionHostId: selectedHostId, - requireAuthoritative: true - }) - } - forceVisible={forceFullPaneForRepoMatch} - /> - - ) : null} + + fetchWorktrees(repoId, { + executionHostId: selectedHostId, + requireAuthoritative: true + }) + } + /> + ) : ( + + )} ) : null, hooksSection, diff --git a/src/renderer/src/components/settings/RepositoryProjectSettingsSections.tsx b/src/renderer/src/components/settings/RepositoryProjectSettingsSections.tsx new file mode 100644 index 000000000000..31f7c177c904 --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryProjectSettingsSections.tsx @@ -0,0 +1,116 @@ +import type React from 'react' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { Project, ProjectUpdateArgs } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { SettingsSearchEntry } from './settings-search' +import type { ProjectRuntimeSessionSummary } from './repository-runtime-session-summary' +import type { RepositoryPaneRepoUpdate } from './RepositoryPane' +import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection' +import { RepositoryWindowsRuntimeSection } from './RepositoryWindowsRuntimeSection' +import { RepositoryForkSyncSection } from './repository/identity/RepositoryForkSyncSection' +import { RepositoryGitHubAccountSection } from './RepositoryGitHubAccountSection' +import { RepositoryClaudeAccountSection } from './RepositoryClaudeAccountSection' +import { RepositoryWorktreeDefaultsSection } from './RepositoryWorktreeDefaultsSection' + +type RepositoryProjectSettingsSectionsProps = { + repo: Repo + selectedProjectSetupId?: string + settingsSelectionKey?: string + settingsEntryRepoIds?: ReadonlySet + forceVisible: boolean + searchQuery: string + hostSetupEntries: SettingsSearchEntry[] + project: Project | null + settings: GlobalSettings | null + repoOwnerSettings: GlobalSettings | null + isLocalWindowsProject: boolean + wslAvailable: boolean + wslDistros: string[] + wslCapabilitiesLoading: boolean + runtimeSessionSummary?: ProjectRuntimeSessionSummary + updateProject?: ( + projectId: string, + updates: ProjectUpdateArgs['updates'] + ) => void | Promise + projectRuntimeEntries: SettingsSearchEntry[] + updateSelectedRepo: (repoId: string, updates: RepositoryPaneRepoUpdate) => void | Promise + refreshWorktrees: (repoId: string) => void | Promise +} + +/** The project-scoped settings sections shown for non-folder repos, below Identity. */ +export function RepositoryProjectSettingsSections({ + repo, + selectedProjectSetupId, + settingsSelectionKey, + settingsEntryRepoIds, + forceVisible, + searchQuery, + hostSetupEntries, + project, + settings, + repoOwnerSettings, + isLocalWindowsProject, + wslAvailable, + wslDistros, + wslCapabilitiesLoading, + runtimeSessionSummary, + updateProject, + projectRuntimeEntries, + updateSelectedRepo, + refreshWorktrees +}: RepositoryProjectSettingsSectionsProps): React.JSX.Element { + return ( + <> + + + + + + + + + + + + + ) +} diff --git a/src/renderer/src/components/settings/accounts-claude-search.ts b/src/renderer/src/components/settings/accounts-claude-search.ts index 4015850fae72..2fd52914f56d 100644 --- a/src/renderer/src/components/settings/accounts-claude-search.ts +++ b/src/renderer/src/components/settings/accounts-claude-search.ts @@ -1,6 +1,51 @@ +import type { SettingsSearchEntry } from './settings-search' import { translate } from '@/i18n/i18n' -import { createLocalizedCatalog } from '@/i18n/localized-catalog' import { translateSearchKeyword } from './settings-search-keywords' +import { createLocalizedCatalog } from '@/i18n/localized-catalog' + +const getAskClaudeAccountPerProjectSearchEntry = createLocalizedCatalog( + (): SettingsSearchEntry => ({ + title: translate( + 'auto.components.settings.AccountsPane.askClaudeAccountPerProjectTitle', + 'Ask which Claude account to use for each project' + ), + description: translate( + 'auto.components.settings.AccountsPane.askClaudeAccountPerProjectDescription', + 'When on, projects without a saved Claude account ask before starting Claude.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.accounts.search.e14049e1a8', 'claude'), + ...translateSearchKeyword('auto.components.settings.accounts.search.06662af91e', 'account'), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.askPerProjectAccounts', + 'accounts' + ), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.askPerProjectProject', + 'project' + ), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.askPerProjectPerProject', + 'per project' + ), + ...translateSearchKeyword('auto.components.settings.accounts.search.askPerProjectAsk', 'ask'), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.askPerProjectAskWhich', + 'ask which' + ), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.askPerProjectPrompt', + 'prompt' + ) + ] + }) +) + +// Why: the toggle's own SearchableSetting needs the identical keyword set so a +// query that opens this pane section also keeps the toggle itself visible. +export function getAskClaudeAccountPerProjectSearchKeywords(): string[] { + return getAskClaudeAccountPerProjectSearchEntry().keywords ?? [] +} export const getAccountsClaudeSearchEntries = createLocalizedCatalog(() => [ { @@ -22,6 +67,7 @@ export const getAccountsClaudeSearchEntries = createLocalizedCatalog(() => [ ...translateSearchKeyword('auto.components.settings.accounts.search.f2d666a886', 'optional') ] }, + getAskClaudeAccountPerProjectSearchEntry(), { title: translate( 'accounts.claude.switchSessions.settingTitle', diff --git a/src/renderer/src/components/settings/accounts-pane-claude-ask-per-project.test.tsx b/src/renderer/src/components/settings/accounts-pane-claude-ask-per-project.test.tsx new file mode 100644 index 000000000000..a97f39bcc0b1 --- /dev/null +++ b/src/renderer/src/components/settings/accounts-pane-claude-ask-per-project.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import { i18n } from '../../i18n/i18n' +import { useAppStore } from '../../store' +import { renderClaudeAccountsSection } from './accounts-pane-claude-section' +import type { AccountsPaneSectionModel } from './accounts-pane-types' + +function buildModel(updateSettings: AccountsPaneSectionModel['updateSettings']) { + const accountRuntime = { runtime: 'host' as const, wslDistro: null, label: 'This device' } + const model: AccountsPaneSectionModel = { + updateSettings, + searchQuery: '', + recordFeatureInteraction: vi.fn(), + wslSupportedPlatform: false, + wslAvailable: false, + wslDistros: [], + localAccountRuntime: accountRuntime, + localAccountRuntimeSentenceLabel: 'this device', + remoteServerName: null, + accountOwner: { kind: 'local' }, + codexAccounts: { accounts: [], activeAccountId: null }, + codexAction: 'idle', + visibleCodexAccounts: [], + systemCodexActive: true, + systemCodexNeedsSignIn: false, + systemCodexMissingSignIn: false, + systemCodexIdentity: undefined, + activeCodexAuthWarning: null, + activeCodexAccountId: null, + codexConfigSync: null, + codexConfigSyncWarning: null, + codexRateLimits: null, + codexRateLimitTarget: { runtime: 'host', wslDistro: null }, + setRemoveCodexTarget: vi.fn(), + runCodexAccountAction: vi.fn(async () => {}), + recordOpenCodeSettingEdit: vi.fn(), + accountRuntime, + accountRuntimeSentenceLabel: 'this device', + accountRuntimeUnavailable: false, + accountVisibilityOptions: { remoteOwner: false, ownerPlatform: 'darwin' }, + claudeAccounts: { accounts: [], activeAccountId: null }, + claudeAction: 'idle', + isRemoteAccountScope: false, + remoteAccountScopeNotice: null, + runClaudeAccountAction: vi.fn(async () => {}), + setRemoveClaudeTarget: vi.fn(), + settings: getDefaultSettings('/tmp'), + systemClaudeActive: true, + visibleClaudeAccounts: [], + wslCapabilitiesLoading: false + } + return model +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('Claude accounts section ask-per-project setting', () => { + it('saves the toggle through updateSettings', async () => { + await i18n.changeLanguage('en') + useAppStore.setState({ settingsSearchQuery: '', runtimeEnvironments: [] }) + const updateSettings = vi.fn() + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + await act(async () => { + root.render(renderClaudeAccountsSection(buildModel(updateSettings))) + }) + + const switchButton = container.querySelector( + '#accounts-claude-ask-per-project button[role="switch"]' + ) + if (!switchButton) { + throw new Error('Ask-per-project switch was not rendered') + } + await act(async () => { + switchButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(updateSettings).toHaveBeenCalledWith({ askClaudeAccountPerProject: true }) + root.unmount() + }) +}) diff --git a/src/renderer/src/components/settings/accounts-pane-claude-section.tsx b/src/renderer/src/components/settings/accounts-pane-claude-section.tsx index eca8e0d20efc..137510e4c0f8 100644 --- a/src/renderer/src/components/settings/accounts-pane-claude-section.tsx +++ b/src/renderer/src/components/settings/accounts-pane-claude-section.tsx @@ -7,6 +7,7 @@ import { ButtonGroup } from '../ui/button-group' import { Label } from '../ui/label' import { ClaudeIcon } from '../status-bar/icons' import { SearchableSetting } from './SearchableSetting' +import { ClaudeAskPerProjectSetting } from './ClaudeAskPerProjectSetting' import { ClaudeRunningSessionSwitchSetting } from './claude-running-session-switch-setting' import { getProviderAccountRuntime, @@ -386,6 +387,7 @@ export function renderClaudeAccountsSection(model: AccountsPaneSectionModel): Re {isRemoteAccountScope ? null : ( )} + ) } diff --git a/src/renderer/src/components/settings/accounts-search.test.ts b/src/renderer/src/components/settings/accounts-search.test.ts index 4931510b1b92..0aaff28f7b06 100644 --- a/src/renderer/src/components/settings/accounts-search.test.ts +++ b/src/renderer/src/components/settings/accounts-search.test.ts @@ -15,11 +15,16 @@ vi.mock('./settings-search-keywords', () => ({ translateSearchKeyword: (_key: string, fallback: string) => [fallback] })) +import { matchesSettingsSearch } from './settings-search' import { getAccountsMiniMaxSearchEntries, getAccountsOpencodeSearchEntries, getAccountsPaneSearchEntries } from './accounts-search' +import { + getAccountsClaudeSearchEntries, + getAskClaudeAccountPerProjectSearchKeywords +} from './accounts-claude-search' describe('getAccountsPaneSearchEntries', () => { it('keeps Antigravity and GLM plan settings discoverable in pane order', () => { @@ -65,6 +70,26 @@ describe('getAccountsMiniMaxSearchEntries', () => { }) }) +describe('getAccountsClaudeSearchEntries ask-per-project entry', () => { + it('stays visible under the same query that opens the Claude section', () => { + const toggleEntry = getAccountsClaudeSearchEntries().find( + (entry) => entry.title === 'Ask which Claude account to use for each project' + ) + expect(toggleEntry).toBeDefined() + + // Why: "Accounts" matches the sibling "Claude Accounts" entry and used to + // hide this toggle because its own keywords lacked the plural form. + expect(matchesSettingsSearch('Accounts', toggleEntry!)).toBe(true) + expect(matchesSettingsSearch('ask which', toggleEntry!)).toBe(true) + }) + + it('exposes the same keywords to the toggle SearchableSetting', () => { + expect(getAskClaudeAccountPerProjectSearchKeywords()).toEqual( + expect.arrayContaining(['accounts', 'ask which', 'per project', 'prompt']) + ) + }) +}) + describe('getAccountsOpencodeSearchEntries', () => { it('tells search to paste the full Cookie header including the console session', () => { const cookieEntry = getAccountsOpencodeSearchEntries().find( diff --git a/src/renderer/src/components/settings/repository-claude-account.ts b/src/renderer/src/components/settings/repository-claude-account.ts new file mode 100644 index 000000000000..c1c6993e1d37 --- /dev/null +++ b/src/renderer/src/components/settings/repository-claude-account.ts @@ -0,0 +1,88 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { ClaudeManagedAccountSummary } from '../../../../shared/managed-account-types' +import type { ProjectClaudeAccountPreference } from '../../../../shared/claude/project-claude-account-preference' +import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' +import { translate } from '@/i18n/i18n' +import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' + +export const DEFAULT_VALUE = '__default__' +export const ASK_VALUE = '__ask__' + +export type ClaudeAccountOption = { + value: string + label: string + disabled?: boolean +} + +export function claudeAccountLabel(account: ClaudeManagedAccountSummary): string { + return account.organizationName ? `${account.email} · ${account.organizationName}` : account.email +} + +export function buildClaudeAccountOptions(input: { + accounts: ClaudeManagedAccountSummary[] + saved?: ProjectClaudeAccountPreference +}): ClaudeAccountOption[] { + const options: ClaudeAccountOption[] = [ + { + value: DEFAULT_VALUE, + label: translate('auto.components.settings.RepositoryClaudeAccountSection.default', 'Default') + }, + { + value: ASK_VALUE, + label: translate( + 'auto.components.settings.RepositoryClaudeAccountSection.ask', + 'Ask every time' + ) + }, + ...input.accounts.map((account) => ({ + value: account.id, + label: claudeAccountLabel(account) + })) + ] + + const saved = input.saved + if ( + saved?.mode === 'account' && + !input.accounts.some((account) => account.id === saved.accountId) + ) { + options.push({ + value: saved.accountId, + label: translate( + 'auto.components.settings.RepositoryClaudeAccountSection.removedAccountOption', + 'Removed account (unavailable)' + ), + disabled: true + }) + } + + return options +} + +function targetsWsl(projectRuntime: ProjectExecutionRuntimeResolution | undefined): boolean { + return projectRuntime?.status === 'repair-required' + ? projectRuntime.repair.preferredRuntime.kind === 'wsl' + : projectRuntime?.runtime.kind === 'wsl' +} + +// Why: SSH repos spawn Claude on a remote host that never sees the local managed-account store, +// and pinned launches support host accounts only, so a WSL-runtime project cannot pin either. +export function claudeAccountPinningUnsupportedReason( + repo: Repo, + projectRuntime?: ProjectExecutionRuntimeResolution +): 'ssh' | 'wsl' | null { + if (repo.connectionId) { + return 'ssh' + } + return targetsWsl(projectRuntime) ? 'wsl' : null +} + +/** Reads the project runtime the launch path resolves, so WSL projects match their spawns. */ +export function claudeAccountPinningUnsupportedReasonInState( + state: Parameters[0], + repo: Repo +): 'ssh' | 'wsl' | null { + return claudeAccountPinningUnsupportedReason( + repo, + getLocalRepoProjectExecutionRuntimeContext(state, repo.id) + ) +} diff --git a/src/renderer/src/components/settings/repository-search-claude-account.test.ts b/src/renderer/src/components/settings/repository-search-claude-account.test.ts new file mode 100644 index 000000000000..01a1fb21b27a --- /dev/null +++ b/src/renderer/src/components/settings/repository-search-claude-account.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { getRepositoryPaneSearchEntries } from './repository-search' + +const baseRepo: Repo = { + id: 'repo-1', + path: '/work/notes', + displayName: 'Notes', + badgeColor: '#000', + addedAt: 0 +} + +function hasClaudeAccountEntry(repo: Repo): boolean { + return getRepositoryPaneSearchEntries(repo).some((entry) => entry.title === 'Claude Account') +} + +describe('repository search: Claude Account', () => { + it('lists the section for folder projects as well as git projects', () => { + expect(hasClaudeAccountEntry(baseRepo)).toBe(true) + expect(hasClaudeAccountEntry({ ...baseRepo, kind: 'folder' })).toBe(true) + }) +}) diff --git a/src/renderer/src/components/settings/repository-search.ts b/src/renderer/src/components/settings/repository-search.ts index ae7112e2edf3..0f0fd75c4166 100644 --- a/src/renderer/src/components/settings/repository-search.ts +++ b/src/renderer/src/components/settings/repository-search.ts @@ -129,6 +129,31 @@ export function getRepositoryPaneSearchEntries( } ] : []), + { + title: translate( + 'auto.components.settings.repository.search.claudeAccount', + 'Claude Account' + ), + description: translate( + 'auto.components.settings.repository.search.claudeAccountDescription', + 'Choose which Claude account starts in this project.' + ), + keywords: [ + repo.displayName, + ...translateSearchKeyword( + 'auto.components.settings.repository.search.claudeAccountKeyword', + 'claude account' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.claudeAccountsKeyword', + 'claude accounts' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.accountsKeyword', + 'accounts' + ) + ] + }, ...(repo.upstream && !isFolder ? [ { diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index f4466344e35d..3e23408c8383 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -2,7 +2,12 @@ import React, { useCallback, useRef } from 'react' import { toast } from 'sonner' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' -import { captureNativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { launchWithClaudeAccountChoice } from '../claude-account-prompt/choose-claude-launch-account' +import { + captureNativeChatComposerFocusIntent, + type NativeChatComposerFocusIntent +} from '@/components/native-chat/use-native-chat-composer-reveal-focus' import { newAgentPromptOutcome } from '@/lib/new-agent-prompt-outcome' import { activeAgentNotesSendFailureMessage } from '@/lib/active-agent-note-send-result' import { Settings as SettingsIcon } from 'lucide-react' @@ -49,6 +54,8 @@ export type QuickLaunchAgentMenuItemsProps = { disabled?: boolean } +type NewTabLaunchableRow = Exclude + const ROW_NAVIGATION_KEYS = new Set(['ArrowLeft', 'ArrowUp', 'ArrowDown', 'Home', 'End']) const PLACEHOLDER_WIDTHS = ['w-3/5', 'w-1/2', 'w-2/3', 'w-2/5', 'w-3/4', 'w-1/3', 'w-4/5', 'w-1/4'] @@ -68,23 +75,29 @@ export function useTabAgentMenuLaunch({ onPromptHandedOff, disabled, onFocusHostTerminal, - onFocusChat + onFocusChat, + afterDeferredLaunch }: QuickLaunchAgentMenuItemsProps & { onFocusHostTerminal?: () => void /** A New Chat opened at once: its composer takes the focus the menu gives back. */ onFocusChat?: (tabId: string) => void + /** Runs after a launch the Claude account prompt delayed past the menu's close. */ + afterDeferredLaunch?: () => void }) { - const runLaunch = useCallback( - (row: NewTabAgentMenuRow) => { - if (disabled || row.kind === 'chat-unavailable') { - return - } + const launchOnAccount = useCallback( + ( + row: NewTabLaunchableRow, + focusIntent: NativeChatComposerFocusIntent, + claudeAccountId: string | undefined, + deferred: boolean + ) => { const { agent } = row const entry = getCatalogEntry(agent) const label = entry?.label ?? agent const result = launchAgentInNewTab({ requestId: newAgentLaunchRequestId(), - focusIntent: captureNativeChatComposerFocusIntent(), + focusIntent, + ...(claudeAccountId ? { claudeAccountId } : {}), ...(row.kind === 'chat' || row.kind === 'cli' ? { requestedSurface: row.kind === 'chat' ? ('chat' as const) : ('terminal' as const), @@ -139,7 +152,12 @@ export function useTabAgentMenuLaunch({ onFocusHostTerminal?.() return } - onFocusTerminal(result.surface.tabId) + // Why: after the account prompt the menu has closed, so a queued focus runs only if flushed. + if (deferred && !afterDeferredLaunch) { + focusTerminalTabSurface(result.surface.tabId) + } else { + onFocusTerminal(result.surface.tabId) + } }, [ worktreeId, @@ -150,13 +168,27 @@ export function useTabAgentMenuLaunch({ launchSource, onPromptDelivered, onPromptHandedOff, - disabled, onFocusHostTerminal, - onFocusChat + onFocusChat, + afterDeferredLaunch ] ) - return runLaunch + return useCallback( + (row: NewTabAgentMenuRow) => { + if (disabled || row.kind === 'chat-unavailable') { + return + } + // Why captured before the account prompt: the prompt itself takes focus. + const focusIntent = captureNativeChatComposerFocusIntent() + launchWithClaudeAccountChoice( + row.agent, + { worktreeId, afterDeferredLaunch }, + (claudeAccountId, deferred) => launchOnAccount(row, focusIntent, claudeAccountId, deferred) + ) + }, + [disabled, worktreeId, afterDeferredLaunch, launchOnAccount] + ) } function QuickLaunchAgentMenuItemsInner(props: QuickLaunchAgentMenuItemsProps): React.JSX.Element { diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 94f5684ac01b..34db1ad982f2 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -2,11 +2,9 @@ import { useCallback, useEffect, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' import { X, Minimize2, Pin } from 'lucide-react' import { stripLeadingAgentTitleDecoration } from '../../../../shared/agent-title-decoration' -import { getAgentLabel } from '@/lib/agent-catalog' -import { basename } from '@/lib/path' import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { Input } from '@/components/ui/input' -import { TabHoverCard } from './TabHoverCard' +import { TerminalTabHoverCard } from './TerminalTabHoverCard' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { TerminalAgent } from '../../../../shared/terminal-agent' import type { TabDragItemData } from '../tab-group/useTabDragSplit' @@ -377,15 +375,11 @@ export default function SortableTab({ {isEditing || menuOpen ? ( tabRoot ) : ( - : undefined} > {tabRoot} - + )} diff --git a/src/renderer/src/components/tab-bar/TabHoverCard.tsx b/src/renderer/src/components/tab-bar/TabHoverCard.tsx index 0068112123da..9d71e9ff2e92 100644 --- a/src/renderer/src/components/tab-bar/TabHoverCard.tsx +++ b/src/renderer/src/components/tab-bar/TabHoverCard.tsx @@ -7,7 +7,8 @@ export function TabHoverCard({ icon, programName, description, - alert + alert, + onOpenChange }: { children: ReactNode title: string @@ -16,10 +17,11 @@ export function TabHoverCard({ description?: string /** Controls below the program row; the card then stays open under the pointer. */ alert?: ReactNode + onOpenChange?: (open: boolean) => void }): React.JSX.Element { const interactive = alert !== undefined && alert !== null return ( - + { diff --git a/src/renderer/src/components/tab-bar/TerminalTabHoverCard.tsx b/src/renderer/src/components/tab-bar/TerminalTabHoverCard.tsx new file mode 100644 index 000000000000..344467bded51 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TerminalTabHoverCard.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from 'react' +import { translate } from '@/i18n/i18n' +import { getAgentLabel } from '@/lib/agent-catalog' +import { basename } from '@/lib/path' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TerminalAgent } from '../../../../shared/terminal-agent' +import { TabHoverCard } from './TabHoverCard' +import { useClaudeTabAccountLabel } from './use-claude-tab-account-label' + +/** A terminal tab's hover card: what runs in it, and the Claude account a pinned agent runs on. */ +export function TerminalTabHoverCard({ + tab, + title, + agent, + shell, + icon, + alert, + children +}: { + tab: Pick + title: string + agent: TerminalAgent | null + shell: string | null | undefined + icon: ReactNode + alert?: ReactNode + children: ReactNode +}): React.JSX.Element { + const { label: accountLabel, onTooltipOpenChange } = useClaudeTabAccountLabel(tab) + const programName = agent + ? [getAgentLabel(agent), accountLabel].filter(Boolean).join(' · ') + : shell + ? basename(shell) + : translate('tabHoverCard.terminal', 'Terminal') + return ( + + {children} + + ) +} diff --git a/src/renderer/src/components/tab-bar/new-tab-menu-terminal-focus.ts b/src/renderer/src/components/tab-bar/new-tab-menu-terminal-focus.ts new file mode 100644 index 000000000000..cfcf516d47f7 --- /dev/null +++ b/src/renderer/src/components/tab-bar/new-tab-menu-terminal-focus.ts @@ -0,0 +1,29 @@ +import type { MutableRefObject } from 'react' +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { useAppStore } from '../../store' + +const NEW_TAB_MENU_TERMINAL_FOCUS_RETRY_MS = 50 + +export function focusNewActiveTerminalWhenReady( + retryRef: MutableRefObject, + previousActiveTabId: string | null, + expiresAt: number, + now: number +): void { + const state = useAppStore.getState() + if ( + (state.activeTabType === 'terminal' || state.activeTabType === 'simulator') && + state.activeTabId && + state.activeTabId !== previousActiveTabId + ) { + focusTerminalTabSurface(state.activeTabId) + return + } + if (now >= expiresAt) { + return + } + retryRef.current = window.setTimeout(() => { + retryRef.current = null + focusNewActiveTerminalWhenReady(retryRef, previousActiveTabId, expiresAt, Date.now()) + }, NEW_TAB_MENU_TERMINAL_FOCUS_RETRY_MS) +} diff --git a/src/renderer/src/components/tab-bar/use-claude-tab-account-label.ts b/src/renderer/src/components/tab-bar/use-claude-tab-account-label.ts new file mode 100644 index 000000000000..2e64ffe8f9b5 --- /dev/null +++ b/src/renderer/src/components/tab-bar/use-claude-tab-account-label.ts @@ -0,0 +1,59 @@ +import { useCallback, useRef, useState } from 'react' +import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { ClaudeManagedAccountSummary } from '../../../../shared/managed-account-types' +import { claudeTabAccountLabel } from '@/lib/claude-tab-account-label' +import { claudeAccountsTargetForRepo, findLaunchRepo } from '@/lib/claude-launch-account' +import { fetchProviderAccountsSnapshot } from '@/runtime/runtime-provider-accounts-client' +import { claudeAccountPinningUnsupportedReasonInState } from '@/components/settings/repository-claude-account' +import { useAppStore } from '../../store' + +/** + * The pinned Claude account's email for a terminal tab's hover card, or null when the tab shows + * no label (unpinned, sentinel, unknown id, or an SSH or WSL repo). The account roster is fetched lazily + * on the first hover, not on every render, since the tab strip re-renders often. + */ +export function useClaudeTabAccountLabel(tab: Pick): { + label: string | null + onTooltipOpenChange: (open: boolean) => void +} { + const paneKey = useAppStore((s) => { + const leafId = s.terminalLayoutsByTabId?.[tab.id]?.activeLeafId + return leafId && isTerminalLeafId(leafId) ? makePaneKey(tab.id, leafId) : null + }) + const statusAccountId = useAppStore((s) => + paneKey ? s.agentStatusByPaneKey?.[paneKey]?.claudeAccountId : undefined + ) + const launchConfig = useAppStore((s) => + paneKey ? s.agentLaunchConfigByPaneKey?.[paneKey]?.launchConfig : undefined + ) + const repo = useAppStore((s) => findLaunchRepo(s, { worktreeId: tab.worktreeId })) + const pinningUnsupported = useAppStore((s) => + repo ? claudeAccountPinningUnsupportedReasonInState(s, repo) !== null : false + ) + + const [accounts, setAccounts] = useState([]) + const fetchedRef = useRef(false) + + const onTooltipOpenChange = useCallback( + (open: boolean) => { + if (!open || fetchedRef.current) { + return + } + fetchedRef.current = true + fetchProviderAccountsSnapshot(claudeAccountsTargetForRepo(repo)) + .then((snapshot) => setAccounts(snapshot.claude.accounts)) + .catch(() => { + fetchedRef.current = false + }) + }, + [repo] + ) + + return { + label: claudeTabAccountLabel({ statusAccountId, launchConfig }, accounts, { + pinningUnsupported + }), + onTooltipOpenChange + } +} diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts index 6fcb2b14006a..e97b9e7dbe1a 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts @@ -235,7 +235,8 @@ export function useTabBarCreateMenuController({ onFocusTerminal: queueTerminalTabFocusAfterNewTabMenuClose, onFocusHostTerminal: queueNewActiveTerminalFocusAfterNewTabMenuClose, onFocusChat: (tabId) => - queueFocusAfterNewTabMenuClose(() => requestNativeChatComposerFocus(tabId), true) + queueFocusAfterNewTabMenuClose(() => requestNativeChatComposerFocus(tabId), true), + afterDeferredLaunch: () => runPendingNewTabMenuFocusAfterClose() }) const launchAgentFromNewTabEntry = (option: TabAgentLaunchOption): void => { if (option.row) { diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts index 3b0b87814122..93f1a3f81c8c 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts @@ -23,6 +23,7 @@ import { shouldOfferDaemonRestart, stripSshReconnectOwnedErrorLines } from './TerminalErrorToast' +import { claudePinnedLaunchError } from '../../../../shared/claude/claude-pinned-launch-error' beforeEach(() => { environmentMocks.resolveFooter.mockReset() @@ -435,6 +436,25 @@ describe('TerminalErrorToast environment footer', () => { ) }) + it('shows the friendly message and a "Start on active account" button for a tagged account-missing refusal', () => { + const onStartOnActiveAccount = vi.fn() + const error = claudePinnedLaunchError('account-missing', 'refused').message + const view = render( + React.createElement(TerminalErrorToast, { + error, + onDismiss: vi.fn(), + onStartOnActiveAccount + }) + ) + + expect(view.container.textContent).toContain( + 'The saved Claude account for this project is no longer signed in.' + ) + expect(view.container.querySelector('a')).toBeNull() + fireEvent.click(view.getByRole('button', { name: 'Start on active account' })) + expect(onStartOnActiveAccount).toHaveBeenCalledTimes(1) + }) + // #30486: Retry was the only action, so a pane whose owner stays unverifiable looped on it. it('offers a new terminal beside Retry and leaves this pane alone', () => { const onRetry = vi.fn().mockResolvedValue(false) diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx index 24eb75794a8b..c0a79bde2a00 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx @@ -10,6 +10,7 @@ import { hasClientEnvironmentFooter, stripClientEnvironmentFooter } from '../../../../shared/client-environment-info' +import { describeClaudePinnedLaunchError } from './claude-pinned-launch-error-copy' import { localizeTerminalSpawnHints, withoutTerminalSpawnIssueRequest @@ -91,6 +92,9 @@ export function shouldOfferDaemonRestart(error: string): boolean { } export function isExplainedTerminalError(error: string): boolean { + if (describeClaudePinnedLaunchError(error)) { + return true + } return error .split('\n') .some( @@ -154,6 +158,10 @@ function isSshHostStateTerminalError(error: string): boolean { /** Swaps raw daemon-boundary codes for copy a user can act on. */ export function humanizeTerminalError(error: string): string { + const pinnedLaunch = describeClaudePinnedLaunchError(error) + if (pinnedLaunch) { + return pinnedLaunch.message + } if (isSshSignInRequiredTerminalError(error)) { return describeSshSignInRequiredTerminalError(error) } @@ -224,6 +232,7 @@ export function TerminalErrorToast({ onDismiss, onRestartDaemon, onRetry, + onStartOnActiveAccount, onOpenNewTerminal }: { error: string @@ -232,6 +241,7 @@ export function TerminalErrorToast({ onDismiss: () => void onRestartDaemon?: () => void onRetry?: () => Promise + onStartOnActiveAccount?: () => void /** Leaves this pane and its saved session untouched. */ onOpenNewTerminal?: () => void }): React.JSX.Element { @@ -239,6 +249,10 @@ export function TerminalErrorToast({ // Why: the client's OS and shell describe neither the host nor its shell, and the renderer knows neither. const showClientEnvironment = paneOnClient && !ssh const paneOwnerUnverified = isPaneOwnerRecoveryError(error) + const pinnedLaunch = describeClaudePinnedLaunchError(error) + const showStartOnActiveAccount = Boolean( + pinnedLaunch?.offerActiveAccount && onStartOnActiveAccount + ) const showDaemonRestart = !ssh && onRestartDaemon && shouldOfferDaemonRestart(error) // Restart cannot recover a session after its owning daemon exits. const showIssueLink = @@ -396,6 +410,14 @@ export function TerminalErrorToast({ : translate('auto.components.terminal.pane.TerminalErrorToast.retry', 'Retry')} ) : null} + {showStartOnActiveAccount ? ( + + ) : null} {/* Why: an owner can stay unverifiable, so Retry alone can loop forever. */} {paneOwnerUnverified && onOpenNewTerminal ? (