diff --git a/docs/site/content/docs/agents/claude-code.mdx b/docs/site/content/docs/agents/claude-code.mdx index a0d733abd4a6..2ad7f814f6d9 100644 --- a/docs/site/content/docs/agents/claude-code.mdx +++ b/docs/site/content/docs/agents/claude-code.mdx @@ -22,6 +22,8 @@ Orca reads your local `~/.claude` usage state and shows current usage plus rate- Orca supports multiple Claude accounts and can swap between them in one click, matching the Codex flow. Switching accounts works even with live Claude sessions running — Orca holds the in-progress switch behind a guard so you don't trigger overlapping auth refreshes. See [Hot-swap Codex accounts](/docs/agents/codex-hot-swap) — the Claude flow is identical in shape. +You can also keep one Claude agent on its account from its tab's right-click menu, so switching moves the others but not it: when Orca offers to restart Claude sessions still running on the old account, it leaves kept agents out. Orca then starts that conversation on the kept account whenever it restores, wakes or resumes it. If you keep an agent after it started, a `claude` you type again in that same terminal still follows the selected account; the tab's hover card says "Kept on this account from its next start" until Orca starts it again. + ## Subagents and teams Background subagents and Agent Teams teammates can show as expandable child rows under the lead agent in the worktree agent list and [Agent Dashboard](/docs/model/agents-sessions#agent-dashboard). Selecting a child focuses the lead terminal. diff --git a/docs/site/content/docs/agents/codex-hot-swap.mdx b/docs/site/content/docs/agents/codex-hot-swap.mdx index 2c00f0cff1a2..a11f5b64b1a5 100644 --- a/docs/site/content/docs/agents/codex-hot-swap.mdx +++ b/docs/site/content/docs/agents/codex-hot-swap.mdx @@ -15,6 +15,14 @@ Running multiple Codex accounts to maximize tokens is common. Orca lets you hot- Click the Codex chip in the status bar to open the account switcher. Pick an account; any new Codex session launched after that uses it. Sessions already running keep their original account until restarted. +## Keep an agent on its account + +Hover a Codex or Claude tab to see which account each agent in it runs on. To keep one agent on its account while you switch the others, right-click its tab and check **Keep on** followed by the account's name (a split tab has one item per agent). Answering the **Account switched** prompt with **Keep old account** does the same. + +A kept agent stays on its account when you switch accounts, and every start Orca makes of its conversation — after a restart of Orca, when the agent wakes, or when you resume it from Session History — runs on that account. Agents you don't keep follow the selected account exactly as before. The account switcher shows how many terminal agents run on each account. + +Uncheck the item to let the agent follow the selected account again; this also works for an agent Orca refused to start on its kept account. Removing an account releases the agents kept on it. A `claude` you type in a Claude terminal kept on a removed account does not run; open a new terminal instead. + ## System default The **System default** row is your current host Codex login under `~/.codex`. Managed accounts (added in Orca) do not rewrite that login; they run in isolated homes. Select System default when you want launches to match a terminal `codex` outside Orca. @@ -29,6 +37,7 @@ For managed Codex accounts, Orca mirrors settings from your real `~/.codex/confi - Existing Codex processes keep their current account until restart. - Usage readouts in the status bar follow the currently-active account. - The restart chip preserves the active account at the time of restart. +- Keep is not offered for a Codex agent started while `CODEX_HOME` was set, or for terminals in WSL or on a remote host. ## Claude Code accounts diff --git a/src/main/agent-accounts/agent-account-keep-wiring.ts b/src/main/agent-accounts/agent-account-keep-wiring.ts new file mode 100644 index 000000000000..5e6b2d151588 --- /dev/null +++ b/src/main/agent-accounts/agent-account-keep-wiring.ts @@ -0,0 +1,26 @@ +import { agentHookServer } from '../agent-hooks/server' +import { getCodexPaneAccount, setCodexPaneKept } from '../codex/codex-pane-account-registry' +import { keepStaleCodexPanes } from '../codex/codex-stale-pane-accounts' +import { getPtyIdForPaneKey, ptyPaneKey } from '../ipc/pty/pane/key-state' +import type { AgentKeepDeps } from './agent-account-keep' +import { getAgentRunAccounts, getKeptAgentConversations } from './agent-run-accounts-installed' + +/** Null where the producer is not installed (orcad): there, keeping is not offered. */ +export function installedAgentKeepDeps(): AgentKeepDeps | null { + const producer = getAgentRunAccounts() + const kept = getKeptAgentConversations() + if (!producer || !kept) { + return null + } + return { + getRow: (paneKey) => agentHookServer.getRunAccountRow(paneKey), + refresh: (paneKey) => producer.refresh(paneKey), + withdraw: (paneKey) => producer.withdraw(paneKey), + kept, + getPtyIdForPaneKey, + getPaneKeyForPty: (ptyId) => ptyPaneKey.get(ptyId), + readCodexRecord: getCodexPaneAccount, + setCodexPaneKept, + keepStaleCodexPanes + } +} diff --git a/src/main/agent-accounts/agent-account-keep.test.ts b/src/main/agent-accounts/agent-account-keep.test.ts new file mode 100644 index 000000000000..5b86722e11e8 --- /dev/null +++ b/src/main/agent-accounts/agent-account-keep.test.ts @@ -0,0 +1,359 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentRunAccount } from '../../shared/agent-run-account' +import type { AgentRunAccountRow } from '../agent-hooks/server/server-run-account' +import { + _internals as codexRegistryInternals, + getCodexPaneAccount, + recordCodexPaneAccount, + setCodexPaneKept, + type CodexPaneAccountRecord +} from '../codex/codex-pane-account-registry' +import { + keepStaleCodexPanesAndConversations, + setAgentKept, + type AgentKeepDeps +} from './agent-account-keep' +import { resolveAgentLaunchAccount } from './agent-launch-account' +import { KeptAgentConversations } from './kept-agent-conversations' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' +const OTHER_PANE = 'tab-2:22222222-2222-4222-8222-222222222222' + +const CODEX_FACET: AgentRunAccount = { + provider: 'codex', + lane: 'host', + accountId: 'acc-a', + mode: 'follow' +} +const CLAUDE_FACET: AgentRunAccount = { + provider: 'claude', + lane: 'host', + accountId: 'acc-b', + mode: 'follow', + shellRoute: 'selected' +} + +function row(overrides: Partial = {}): AgentRunAccountRow { + return { + paneKey: PANE, + agentType: 'codex', + providerSession: { key: 'session_id', id: 's1', transcriptPath: '/t/s1.jsonl' }, + runAccount: CODEX_FACET, + connectionId: null, + ...overrides + } +} + +function deps(overrides: Partial = {}) { + const record: CodexPaneAccountRecord = { + selectionKey: 'host', + accountId: 'acc-a', + homeRoute: 'account-home' + } + const kept = { keep: vi.fn(), release: vi.fn(), acceptsChanges: vi.fn(() => true) } + const base: AgentKeepDeps = { + getRow: vi.fn(() => row()), + refresh: vi.fn((): AgentRunAccount => ({ ...CODEX_FACET, mode: 'kept' })), + withdraw: vi.fn(), + kept, + getPtyIdForPaneKey: vi.fn((paneKey: string) => (paneKey === PANE ? 'pty-1' : undefined)), + getPaneKeyForPty: vi.fn((ptyId: string) => (ptyId === 'pty-1' ? PANE : undefined)), + readCodexRecord: vi.fn(() => record), + setCodexPaneKept: vi.fn(() => 'kept' as const), + keepStaleCodexPanes: vi.fn((ptyIds: readonly string[]) => [...ptyIds]), + now: () => 7, + ...overrides + } + return { ...base, kept } +} + +const dirs: string[] = [] +afterEach(() => { + vi.unstubAllEnvs() + codexRegistryInternals.resetCache() + dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })) +}) + +describe('setAgentKept', () => { + it('keeps a Codex pane and its conversation on the account it launched under', () => { + const d = deps() + expect(setAgentKept(d, { paneKey: PANE, ptyId: 'pty-1', kept: true })).toEqual({ + ok: true, + runAccount: { ...CODEX_FACET, mode: 'kept' } + }) + expect(d.setCodexPaneKept).toHaveBeenCalledWith('pty-1', true) + expect(d.kept.keep).toHaveBeenCalledWith('codex', 's1', { + lane: 'host', + accountId: 'acc-a', + keptAt: 7, + transcriptPath: '/t/s1.jsonl' + }) + expect(d.refresh).toHaveBeenCalledWith(PANE) + }) + + it('releases a Codex pane and its conversation', () => { + const d = deps({ refresh: vi.fn(() => CODEX_FACET) }) + expect(setAgentKept(d, { paneKey: PANE, kept: false })).toEqual({ + ok: true, + runAccount: CODEX_FACET + }) + expect(d.setCodexPaneKept).toHaveBeenCalledWith('pty-1', false) + expect(d.kept.release).toHaveBeenCalledWith('codex', 's1') + expect(d.kept.keep).not.toHaveBeenCalled() + expect(d.withdraw).not.toHaveBeenCalled() + }) + + it('un-keeps a refused kept Codex resume, which has no terminal, so it can resume again', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-unkeep-')) + dirs.push(dir) + const index = new KeptAgentConversations({ filePath: join(dir, 'kept.json') }) + index.keep('codex', 's1', { lane: 'host', accountId: 'acc-a', keptAt: 1 }) + const keptFacet: AgentRunAccount = { ...CODEX_FACET, mode: 'kept' } + const d = deps({ + getRow: vi.fn(() => row({ runAccount: keptFacet })), + getPtyIdForPaneKey: vi.fn(() => undefined), + readCodexRecord: vi.fn(() => null), + // With no launch record the producer can only restate the stored facet. + refresh: vi.fn(() => keptFacet) + }) + + expect( + setAgentKept({ ...d, kept: index }, { paneKey: PANE, ptyId: 'pty-gone', kept: false }) + ).toEqual({ + ok: true, + runAccount: null + }) + expect(d.setCodexPaneKept).not.toHaveBeenCalled() + expect(d.withdraw).toHaveBeenCalledWith(PANE) + expect( + resolveAgentLaunchAccount({ + connectionId: null, + launchAgent: 'codex', + resumeProviderSession: { key: 'session_id', id: 's1' }, + lane: 'host', + kept: index, + settings: { claudeManagedAccounts: [], codexManagedAccounts: [] } + }) + ).toEqual({ kind: 'follow' }) + }) + + it('reports a released Claude agent no runtime is observing as released, not failed', () => { + const d = deps({ + getRow: vi.fn(() => + row({ agentType: 'claude', runAccount: { ...CLAUDE_FACET, mode: 'kept' } }) + ), + getPtyIdForPaneKey: vi.fn(() => undefined), + refresh: vi.fn(() => null) + }) + expect(setAgentKept(d, { paneKey: PANE, kept: false })).toEqual({ ok: true, runAccount: null }) + expect(d.kept.release).toHaveBeenCalledWith('claude', 's1') + }) + + it('refuses a Codex pane whose home cannot be kept, without touching anything', () => { + const d = deps({ + readCodexRecord: vi.fn(() => ({ + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home' as const + })) + }) + expect(setAgentKept(d, { paneKey: PANE, kept: true })).toEqual({ + ok: false, + reason: 'not-keepable' + }) + expect(d.setCodexPaneKept).not.toHaveBeenCalled() + expect(d.kept.keep).not.toHaveBeenCalled() + }) + + it('refuses to keep while a newer Orca owns the list, touching nothing', () => { + const d = deps() + d.kept.acceptsChanges.mockReturnValue(false) + expect(setAgentKept(d, { paneKey: PANE, kept: true })).toEqual({ + ok: false, + reason: 'read-only' + }) + expect(d.setCodexPaneKept).not.toHaveBeenCalled() + expect(d.kept.keep).not.toHaveBeenCalled() + }) + + it.each([ + [ + 'a Claude conversation', + { getRow: vi.fn(() => row({ agentType: 'claude', runAccount: CLAUDE_FACET })) } + ], + [ + 'a Codex conversation with no launch record', + { setCodexPaneKept: vi.fn(() => 'no-record' as const) } + ] + ])('refuses to un-keep %s while a newer Orca owns the list, touching nothing', (_l, over) => { + const d = deps(over) + d.kept.acceptsChanges.mockReturnValue(false) + expect(setAgentKept(d, { paneKey: PANE, kept: false })).toEqual({ + ok: false, + reason: 'read-only' + }) + expect(d.kept.release).not.toHaveBeenCalled() + expect(d.withdraw).not.toHaveBeenCalled() + }) + + it('un-keeps a Codex pane kept in its launch registry while a newer Orca owns the list', () => { + const launched: CodexPaneAccountRecord = { + selectionKey: 'host', + accountId: 'acc-a', + homeRoute: 'account-home' + } + let record: CodexPaneAccountRecord = { ...launched, kept: true } + const d = deps({ + getRow: vi.fn(() => row({ runAccount: { ...CODEX_FACET, mode: 'kept' } })), + readCodexRecord: vi.fn(() => record), + setCodexPaneKept: vi.fn((_ptyId: string, kept: boolean) => { + record = kept ? { ...launched, kept: true } : launched + return kept ? ('kept' as const) : ('released' as const) + }), + refresh: vi.fn((): AgentRunAccount => ({ + ...CODEX_FACET, + mode: record.kept ? 'kept' : 'follow' + })) + }) + d.kept.acceptsChanges.mockReturnValue(false) + expect(setAgentKept(d, { paneKey: PANE, kept: false })).toEqual({ + ok: true, + runAccount: CODEX_FACET + }) + expect(record.kept).toBeUndefined() + expect(d.kept.release).not.toHaveBeenCalled() + expect(d.withdraw).not.toHaveBeenCalled() + }) + + it.each([ + ['keeping', true, 'keep'], + ['un-keeping', false, 'release'] + ] as const)( + 'reports an unreadable launch registry when %s, leaving the list alone', + (_l, kept, op) => { + const d = deps({ setCodexPaneKept: vi.fn(() => 'unavailable' as const) }) + expect(setAgentKept(d, { paneKey: PANE, kept })).toEqual({ + ok: false, + reason: 'unavailable' + }) + expect(d.kept[op]).not.toHaveBeenCalled() + expect(d.withdraw).not.toHaveBeenCalled() + } + ) + + it.each([[true], [false]])( + 'un-keeping against an unreadable real launch registry leaves the keep in place (list writable: %s)', + (listWritable) => { + const userData = mkdtempSync(join(tmpdir(), 'orca-unkeep-registry-')) + dirs.push(userData) + vi.stubEnv('ORCA_USER_DATA_PATH', userData) + codexRegistryInternals.resetCache() + const registryFile = join(userData, 'codex-pane-accounts.json') + recordCodexPaneAccount('pty-1', { + selectionKey: 'host', + accountId: 'acc-a', + homeRoute: 'account-home' + }) + expect(setCodexPaneKept('pty-1', true)).toBe('kept') + const persisted = readFileSync(registryFile, 'utf-8') + rmSync(registryFile) + // Why: a directory in the file's place fails the read with EISDIR, not absence. + mkdirSync(registryFile) + codexRegistryInternals.resetCache() + const d = deps({ + getRow: vi.fn(() => row({ runAccount: { ...CODEX_FACET, mode: 'kept' } })), + readCodexRecord: getCodexPaneAccount, + setCodexPaneKept + }) + d.kept.acceptsChanges.mockReturnValue(listWritable) + + expect(setAgentKept(d, { paneKey: PANE, kept: false })).toEqual({ + ok: false, + reason: 'unavailable' + }) + expect(d.kept.release).not.toHaveBeenCalled() + expect(d.withdraw).not.toHaveBeenCalled() + rmSync(registryFile, { recursive: true }) + writeFileSync(registryFile, persisted) + expect(getCodexPaneAccount('pty-1')).toMatchObject({ kept: true }) + } + ) + + it('refuses a terminal that is not the one the request names', () => { + const d = deps() + expect(setAgentKept(d, { paneKey: PANE, ptyId: 'pty-9', kept: true })).toEqual({ + ok: false, + reason: 'unknown-pane' + }) + expect(d.setCodexPaneKept).not.toHaveBeenCalled() + }) + + it.each([ + ['a relay row', row({ connectionId: 'ssh-1' })], + ['a row with no account facet', row({ runAccount: undefined })], + ['no row', null] + ])('refuses %s', (_label, current) => { + const d = deps({ getRow: vi.fn(() => current) }) + expect(setAgentKept(d, { paneKey: PANE, kept: true })).toEqual({ + ok: false, + reason: 'unknown-pane' + }) + }) + + it('keeps a Claude conversation on the account its row says it runs on', () => { + const d = deps({ + getRow: vi.fn(() => row({ agentType: 'claude', runAccount: CLAUDE_FACET })), + refresh: vi.fn(() => ({ ...CLAUDE_FACET, mode: 'kept' as const })) + }) + expect(setAgentKept(d, { paneKey: PANE, kept: true })).toMatchObject({ ok: true }) + expect(d.kept.keep).toHaveBeenCalledWith('claude', 's1', { + lane: 'host', + accountId: 'acc-b', + keptAt: 7, + transcriptPath: '/t/s1.jsonl' + }) + expect(d.setCodexPaneKept).not.toHaveBeenCalled() + }) + + it('waits for a Claude agent to report its session before keeping it', () => { + const d = deps({ + getRow: vi.fn(() => + row({ agentType: 'claude', runAccount: CLAUDE_FACET, providerSession: undefined }) + ) + }) + expect(setAgentKept(d, { paneKey: PANE, kept: true })).toEqual({ + ok: false, + reason: 'no-session' + }) + expect(d.kept.keep).not.toHaveBeenCalled() + }) +}) + +describe('keepStaleCodexPanesAndConversations', () => { + it('keeps the conversation of each pane the registry kept, found by pty when needed', () => { + const d = deps({ keepStaleCodexPanes: vi.fn(() => ['pty-1']) }) + keepStaleCodexPanesAndConversations(d, [ + // A stale pane key from the renderer still resolves through the pty. + { ptyId: 'pty-1', paneKey: OTHER_PANE }, + { ptyId: 'pty-2' } + ]) + expect(d.keepStaleCodexPanes).toHaveBeenCalledWith(['pty-1', 'pty-2']) + expect(d.getRow).toHaveBeenCalledWith(PANE) + expect(d.kept.keep).toHaveBeenCalledTimes(1) + expect(d.kept.keep).toHaveBeenCalledWith( + 'codex', + 's1', + expect.objectContaining({ accountId: 'acc-a' }) + ) + expect(d.refresh).toHaveBeenCalledWith(PANE) + }) + + it('keeps no conversation for a pane the registry could not keep', () => { + const d = deps({ keepStaleCodexPanes: vi.fn(() => []) }) + keepStaleCodexPanesAndConversations(d, [{ ptyId: 'pty-1', paneKey: PANE }]) + expect(d.kept.keep).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/agent-accounts/agent-account-keep.ts b/src/main/agent-accounts/agent-account-keep.ts new file mode 100644 index 000000000000..669440ecdc2f --- /dev/null +++ b/src/main/agent-accounts/agent-account-keep.ts @@ -0,0 +1,144 @@ +import type { AgentRunAccount, SetAgentKeptResult } from '../../shared/agent-run-account' +import { normalizeAgentProviderSession } from '../../shared/agent-session-resume' +import type { AgentRunAccountRow } from '../agent-hooks/server/server-run-account' +import type { + CodexPaneAccountRecord, + SetCodexPaneKeptResult +} from '../codex/codex-pane-account-registry' +import { isCodexPaneKeepable } from '../codex/codex-stale-pane-accounts' +import type { KeptAgentConversations } from './kept-agent-conversations' + +export type { SetAgentKeptResult } + +export type AgentKeepDeps = { + getRow: (paneKey: string) => AgentRunAccountRow | null + refresh: (paneKey: string) => AgentRunAccount | null + withdraw: (paneKey: string) => void + kept: Pick + getPtyIdForPaneKey: (paneKey: string) => string | undefined + getPaneKeyForPty: (ptyId: string) => string | undefined + readCodexRecord: (ptyId: string) => CodexPaneAccountRecord | null + setCodexPaneKept: (ptyId: string, kept: boolean) => SetCodexPaneKeptResult + keepStaleCodexPanes: (ptyIds: readonly string[]) => string[] + now?: () => number +} + +function keepConversation( + deps: AgentKeepDeps, + row: AgentRunAccountRow, + provider: 'claude' | 'codex', + accountId: string | null, + kept: boolean +): void { + const session = normalizeAgentProviderSession(row.providerSession) + if (!session) { + return + } + if (!kept) { + deps.kept.release(provider, session.id) + return + } + deps.kept.keep(provider, session.id, { + lane: 'host', + accountId, + keptAt: (deps.now ?? Date.now)(), + ...(session.transcriptPath ? { transcriptPath: session.transcriptPath } : {}) + }) +} + +/** Keeps or releases one local terminal agent on the account its row says it runs on. */ +export function setAgentKept( + deps: AgentKeepDeps, + args: { paneKey: string; ptyId?: string; kept: boolean } +): SetAgentKeptResult { + const row = deps.getRow(args.paneKey) + const facet = row?.runAccount + if (!row || !facet || row.connectionId !== null) { + return { ok: false, reason: 'unknown-pane' } + } + const ptyId = deps.getPtyIdForPaneKey(row.paneKey) + if (!args.kept) { + return releaseAgent(deps, row, facet, ptyId) + } + if (args.ptyId !== undefined && args.ptyId !== ptyId) { + return { ok: false, reason: 'unknown-pane' } + } + if (!deps.kept.acceptsChanges()) { + return { ok: false, reason: 'read-only' } + } + if (facet.provider === 'codex') { + const record = ptyId ? deps.readCodexRecord(ptyId) : null + if (!ptyId || !record) { + return { ok: false, reason: 'unknown-pane' } + } + if (!isCodexPaneKeepable(record)) { + return { ok: false, reason: 'not-keepable' } + } + const result = deps.setCodexPaneKept(ptyId, args.kept) + if (result === 'unavailable' || result === 'no-record') { + return { ok: false, reason: result === 'unavailable' ? 'unavailable' : 'unknown-pane' } + } + keepConversation(deps, row, 'codex', record.accountId, args.kept) + } else { + if (!normalizeAgentProviderSession(row.providerSession)) { + return { ok: false, reason: 'no-session' } + } + keepConversation(deps, row, 'claude', facet.accountId, args.kept) + } + const runAccount = deps.refresh(row.paneKey) + return runAccount ? { ok: true, runAccount } : { ok: false, reason: 'unknown-pane' } +} + +/** Needs no live terminal: a refused kept resume has none, and un-keeping is its way out. */ +function releaseAgent( + deps: AgentKeepDeps, + row: AgentRunAccountRow, + facet: AgentRunAccount, + ptyId: string | undefined +): SetAgentKeptResult { + // Why: an unreadable registry is not "no record"; only the release's own answer can tell them apart. + const codexRelease = + facet.provider === 'codex' && ptyId ? deps.setCodexPaneKept(ptyId, false) : 'no-record' + if (codexRelease === 'unavailable') { + return { ok: false, reason: 'unavailable' } + } + const listAcceptsChanges = deps.kept.acceptsChanges() + // Why: a newer Orca's list drops a release; only a Codex pane's own launch record can still let go. + if (!listAcceptsChanges && codexRelease === 'no-record') { + return { ok: false, reason: 'read-only' } + } + if (listAcceptsChanges) { + keepConversation(deps, row, facet.provider, facet.accountId, false) + } + const runAccount = deps.refresh(row.paneKey) + if (runAccount?.mode === 'follow') { + return { ok: true, runAccount } + } + // Why: with no process to observe, the stored facet still says kept; nothing honours it now. + deps.withdraw(row.paneKey) + return { ok: true, runAccount: null } +} + +/** "Keep old account": keeps each pane that can be kept, and its current conversation. */ +export function keepStaleCodexPanesAndConversations( + deps: AgentKeepDeps, + panes: readonly { ptyId: string; paneKey?: string }[] +): void { + const keptPtyIds = new Set(deps.keepStaleCodexPanes(panes.map((pane) => pane.ptyId))) + for (const pane of panes) { + if (!keptPtyIds.has(pane.ptyId)) { + continue + } + const paneKey = + pane.paneKey && deps.getPtyIdForPaneKey(pane.paneKey) === pane.ptyId + ? pane.paneKey + : deps.getPaneKeyForPty(pane.ptyId) + const row = paneKey ? deps.getRow(paneKey) : null + const record = deps.readCodexRecord(pane.ptyId) + if (!row || !record || row.agentType !== 'codex') { + continue + } + keepConversation(deps, row, 'codex', record.accountId, true) + deps.refresh(row.paneKey) + } +} diff --git a/src/main/agent-accounts/agent-launch-account.test.ts b/src/main/agent-accounts/agent-launch-account.test.ts new file mode 100644 index 000000000000..c25e0f6598c6 --- /dev/null +++ b/src/main/agent-accounts/agent-launch-account.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import type { ClaudeManagedAccount, CodexManagedAccount } from '../../shared/managed-account-types' +import { resolveAgentLaunchAccount } from './agent-launch-account' +import type { KeptConversation } from './kept-agent-conversations' + +function claudeAccount(id: string): ClaudeManagedAccount { + return { + id, + email: `${id}@example.com`, + managedAuthPath: '/unused', + authMethod: 'subscription-oauth', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +function codexAccount(id: string): CodexManagedAccount { + return { + id, + email: `${id}@example.com`, + managedHomePath: '/unused', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +const SETTINGS = { + claudeManagedAccounts: [claudeAccount('claude-a')], + codexManagedAccounts: [codexAccount('codex-a')] +} + +function keptIndex(entries: Record | 'unavailable'): { + filePath: string + get: (provider: string, sessionId: string) => KeptConversation | null | 'unavailable' +} { + return { + filePath: '/data/agent-kept-conversations.json', + get: (provider, sessionId) => + entries === 'unavailable' ? 'unavailable' : (entries[`${provider}:${sessionId}`] ?? null) + } +} + +function resolve(overrides: Partial[0]> = {}) { + return resolveAgentLaunchAccount({ + connectionId: null, + launchAgent: 'claude', + resumeProviderSession: { key: 'session_id', id: 's1' }, + lane: 'host', + kept: keptIndex({ 'claude:s1': { lane: 'host', accountId: 'claude-a', keptAt: 1 } }), + settings: SETTINGS, + ...overrides + }) +} + +describe('resolveAgentLaunchAccount', () => { + it('binds a kept conversation to its account', () => { + expect(resolve()).toEqual({ + kind: 'bound', + provider: 'claude', + accountId: 'claude-a', + mode: 'kept' + }) + expect( + resolve({ + launchAgent: 'codex', + kept: keptIndex({ 'codex:s1': { lane: 'host', accountId: null, keptAt: 1 } }) + }) + ).toEqual({ kind: 'bound', provider: 'codex', accountId: null, mode: 'kept' }) + }) + + it.each([ + ['a conversation nobody kept', { kept: keptIndex({}) }], + ['a relay pane', { connectionId: 'ssh-1' }], + ['another agent', { launchAgent: 'gemini' as const }], + ['a fresh start', { resumeProviderSession: undefined }], + ['a host with no index', { kept: undefined }], + [ + 'the same session kept for another agent', + { + launchAgent: 'codex' as const, + kept: keptIndex({ 'claude:s1': { lane: 'host', accountId: null, keptAt: 1 } }) + } + ] + ])('follows the selection for %s', (_label, overrides) => { + expect(resolve(overrides)).toEqual({ kind: 'follow' }) + }) + + it('refuses while the index cannot be read, instead of following the selection', () => { + const result = resolve({ kept: keptIndex('unavailable') }) + expect(result.kind).toBe('refuse') + expect(result.kind === 'refuse' && result.error.message).toMatch(/could not read/) + // Says which file, and the way out when it never becomes readable. + expect(result.kind === 'refuse' && result.error.message).toContain( + '/data/agent-kept-conversations.json' + ) + }) + + it('refuses a conversation kept on another lane', () => { + const result = resolve({ lane: 'wsl:Ubuntu' }) + expect(result.kind === 'refuse' && result.error.message).toMatch(/another environment/) + }) + + it('refuses a conversation kept on an account that is no longer saved', () => { + const result = resolve({ settings: { ...SETTINGS, claudeManagedAccounts: [] } }) + expect(result.kind === 'refuse' && result.error.message).toMatch(/no longer saved/) + }) +}) diff --git a/src/main/agent-accounts/agent-launch-account.ts b/src/main/agent-accounts/agent-launch-account.ts new file mode 100644 index 000000000000..cf073d41be7c --- /dev/null +++ b/src/main/agent-accounts/agent-launch-account.ts @@ -0,0 +1,57 @@ +import type { AgentRunAccountProvider } from '../../shared/agent-run-account' +import { + normalizeAgentProviderSession, + type AgentProviderSessionMetadata +} from '../../shared/agent-session-resume' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { TuiAgent } from '../../shared/tui-agent' +import type { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { keptAccountRefusals } from './kept-account-refusals' +import type { KeptAgentConversations } from './kept-agent-conversations' + +/** Which account a spawn runs on. Later steps (placement, explicit requests) add kinds here. */ +export type AgentLaunchAccount = + | { kind: 'follow' } + | { kind: 'bound'; provider: AgentRunAccountProvider; accountId: string | null; mode: 'kept' } + | { kind: 'refuse'; error: AgentSessionPreSpawnError } + +/** Both spawn paths' resolver; synchronous so a follow spawn gains no await, and a keep it cannot honour refuses. */ +export function resolveAgentLaunchAccount(input: { + connectionId: string | null | undefined + launchAgent: TuiAgent | undefined + resumeProviderSession: AgentProviderSessionMetadata | undefined + /** The pane's selection lane ('host' | 'wsl:'). */ + lane: string + kept: Pick | undefined + settings: Pick | undefined +}): AgentLaunchAccount { + const provider = input.launchAgent + const session = normalizeAgentProviderSession(input.resumeProviderSession) + if ( + input.connectionId || + (provider !== 'claude' && provider !== 'codex') || + !session || + !input.kept + ) { + return { kind: 'follow' } + } + const entry = input.kept.get(provider, session.id) + if (entry === 'unavailable') { + return { kind: 'refuse', error: keptAccountRefusals.indexUnavailable(input.kept.filePath) } + } + if (!entry) { + return { kind: 'follow' } + } + if (entry.lane !== input.lane) { + return { kind: 'refuse', error: keptAccountRefusals.laneMismatch() } + } + const accounts = + provider === 'claude' + ? input.settings?.claudeManagedAccounts + : input.settings?.codexManagedAccounts + // Why refuse: removal releases its keeps, so a dangling one means that release failed. + if (entry.accountId !== null && !accounts?.some((account) => account.id === entry.accountId)) { + return { kind: 'refuse', error: keptAccountRefusals.accountRemoved() } + } + return { kind: 'bound', provider, accountId: entry.accountId, mode: 'kept' } +} diff --git a/src/main/agent-accounts/agent-run-account-adoption.ts b/src/main/agent-accounts/agent-run-account-adoption.ts new file mode 100644 index 000000000000..996a161ca505 --- /dev/null +++ b/src/main/agent-accounts/agent-run-account-adoption.ts @@ -0,0 +1,115 @@ +import type { AgentRunAccountProvider } from '../../shared/agent-run-account' +import type { CodexPaneAccountRecord } from '../codex/codex-pane-account-registry' +import { + keptConversationKey, + type KeptAgentConversations, + type KeptConversation +} from './kept-agent-conversations' + +export type KeptConversationLookup = Pick + +function keptEntry( + kept: KeptConversationLookup, + provider: AgentRunAccountProvider, + sessionId: string | null | undefined +): KeptConversation | null { + const entry = sessionId ? kept.get(provider, sessionId) : null + return entry && entry !== 'unavailable' ? entry : null +} + +/** The account a conversation is kept on; undefined = not kept, or the index cannot say. */ +export function keptConversationAccount( + kept: KeptConversationLookup, + provider: AgentRunAccountProvider, + sessionId: string | null +): string | null | undefined { + return keptEntry(kept, provider, sessionId)?.accountId +} + +/** Carries a predecessor's keep to the session after it in the same agent, on the same account. */ +function carryKeep(args: { + kept: KeptConversationLookup + provider: AgentRunAccountProvider + previousSessionId: string | null | undefined + sessionId: string | null + accountId: string | null | undefined + transcriptPath: string | undefined + now: number +}): void { + const { kept, provider, sessionId, accountId, previousSessionId } = args + if (!sessionId || accountId === undefined || !previousSessionId) { + return + } + const previous = + sessionId === previousSessionId ? null : keptEntry(kept, provider, previousSessionId) + if (!previous || previous.accountId !== accountId) { + return + } + const current = kept.get(provider, sessionId) + if (current === 'unavailable' || current?.accountId === accountId) { + return + } + kept.keep(provider, sessionId, { + lane: 'host', + accountId, + keptAt: args.now, + ...(args.transcriptPath ? { transcriptPath: args.transcriptPath } : {}), + // Why the root: un-keeping any session of the agent then releases all of them. + keptVia: previous.keptVia ?? keptConversationKey(provider, previousSessionId) + }) +} + +/** A Claude session carries its predecessor's keep only when it provably runs on that account. */ +export function adoptClaudeSession(args: { + kept: KeptConversationLookup + previousSessionId: string | null | undefined + sessionId: string | null + accountId: string | null | undefined + transcriptPath: string | undefined + now: number +}): void { + carryKeep({ ...args, provider: 'claude' }) +} + +/** A carried Claude keep the statusline proves runs on another account is dropped; a person's own never is. */ +export function dropMisplacedClaudeKeep( + kept: KeptConversationLookup, + sessionId: string | null, + accountId: string | null +): void { + const entry = keptEntry(kept, 'claude', sessionId) + if (sessionId && entry?.keptVia && entry.accountId !== accountId) { + kept.releaseCarried('claude', sessionId, entry.keptAt) + } +} + +/** A kept Codex pane's home is fixed at spawn, so every session it reports runs there. */ +export function adoptCodexSession(args: { + kept: KeptConversationLookup + record: CodexPaneAccountRecord | null + previousSessionId: string | null | undefined + sessionId: string | null + transcriptPath: string | undefined + now: number +}): void { + const { kept, record, sessionId } = args + if (!record?.kept || record.selectionKey !== 'host' || !sessionId) { + return + } + const current = kept.get('codex', sessionId) + if (current === 'unavailable' || current?.accountId === record.accountId) { + return + } + const previous = keptEntry(kept, 'codex', args.previousSessionId) + const keptVia = + previous && args.previousSessionId && previous.accountId === record.accountId + ? (previous.keptVia ?? keptConversationKey('codex', args.previousSessionId)) + : undefined + kept.keep('codex', sessionId, { + lane: 'host', + accountId: record.accountId, + keptAt: args.now, + ...(args.transcriptPath ? { transcriptPath: args.transcriptPath } : {}), + ...(keptVia ? { keptVia } : {}) + }) +} diff --git a/src/main/agent-accounts/agent-run-account-compute.ts b/src/main/agent-accounts/agent-run-account-compute.ts new file mode 100644 index 000000000000..367c85f4dd46 --- /dev/null +++ b/src/main/agent-accounts/agent-run-account-compute.ts @@ -0,0 +1,106 @@ +import type { AgentRunAccount, AgentRunAccountProvider } from '../../shared/agent-run-account' +import type { CodexPaneAccountRecord } from '../codex/codex-pane-account-registry' +import { isCodexPaneKeepable } from '../codex/codex-stale-pane-accounts' + +/** The account a statusline folder belongs to; 'unknown-folder' = no account owns it. */ +export type ClaudeStatusLineReport = { accountId: string | null } | 'unknown-folder' + +/** What this runtime knows about the Claude session in one pane. */ +export type ClaudePaneObservation = { + /** Account the session runs on; undefined = this runtime never saw it start. */ + accountId: string | null | undefined + /** Latest statusline report since the session started. */ + statusLine?: ClaudeStatusLineReport + shellRoute: 'selected' | 'account' +} + +export type AgentRunAccountInput = { + agentType: string | undefined + connectionId: string | null + structured: boolean + providerSessionId: string | null + /** The provider has at least one saved account, so naming an account means something. */ + hasManagedAccounts: boolean + /** Lane the pane launched from, when this runtime knows it. */ + lane: string | undefined + codexRecord: CodexPaneAccountRecord | null + claude: ClaudePaneObservation | null + /** The row's current facet, carried from an earlier runtime when nothing newer is known. */ + current: AgentRunAccount | undefined + /** Account a person kept this Claude conversation on; undefined = not kept. */ + claudeKeptAccountId: string | null | undefined +} + +export function runAccountProvider(agentType: string | undefined): AgentRunAccountProvider | null { + return agentType === 'claude' || agentType === 'codex' ? agentType : null +} + +/** The facet a local terminal agent's row should carry; null = none. Never guesses. */ +export function computeAgentRunAccount(input: AgentRunAccountInput): AgentRunAccount | null { + const provider = runAccountProvider(input.agentType) + if ( + !provider || + input.connectionId !== null || + input.structured || + !input.hasManagedAccounts || + (input.lane !== undefined && input.lane !== 'host') + ) { + return null + } + return provider === 'codex' ? computeCodex(input) : computeClaude(input) +} + +function computeCodex(input: AgentRunAccountInput): AgentRunAccount | null { + const record = input.codexRecord + if (!record) { + // Why: until this runtime can read the pane's launch record, the persisted facet stands. + return input.current?.provider === 'codex' ? input.current : null + } + if (record.selectionKey !== 'host') { + return null + } + return { + provider: 'codex', + lane: 'host', + accountId: record.accountId, + mode: record.kept ? 'kept' : 'follow', + ...(isCodexPaneKeepable(record) ? {} : { keepUnavailable: 'codex-home-not-keepable' }) + } +} + +/** What the facet an earlier runtime left on the row says about its Claude session. */ +function persistedObservation(current: AgentRunAccount | undefined): ClaudePaneObservation | null { + return current?.provider === 'claude' + ? { accountId: current.accountId, shellRoute: current.shellRoute ?? 'selected' } + : null +} + +function computeClaude(input: AgentRunAccountInput): AgentRunAccount | null { + // Why: a pane this runtime has not seen report, such as an idle terminal that outlived a restart, + // keeps the account an earlier runtime recorded for it. Only the keep is read afresh, so one + // made or released since still shows. + const observed = input.claude ?? persistedObservation(input.current) + if (!observed) { + return null + } + if (observed.statusLine === 'unknown-folder') { + return null + } + const accountId = observed.statusLine ? observed.statusLine.accountId : observed.accountId + if (accountId === undefined) { + return null + } + const keptOn = input.providerSessionId !== null ? input.claudeKeptAccountId : undefined + const kept = keptOn !== undefined && keptOn === accountId + return { + provider: 'claude', + lane: 'host', + accountId, + mode: kept ? 'kept' : 'follow', + shellRoute: observed.shellRoute, + ...(input.providerSessionId === null ? { keepUnavailable: 'no-session' } : {}), + // Why: a person's own keep stays on its account when the agent ends up on another, and a + // restart resumes the conversation there, wherever the selection points. + ...(keptOn !== undefined && !kept ? { keptElsewhere: true as const } : {}) + } +} diff --git a/src/main/agent-accounts/agent-run-accounts-installed.ts b/src/main/agent-accounts/agent-run-accounts-installed.ts new file mode 100644 index 000000000000..7540b8ce8a16 --- /dev/null +++ b/src/main/agent-accounts/agent-run-accounts-installed.ts @@ -0,0 +1,32 @@ +// Why type-only: spawn paths import this module, and the producer's graph must not load in workers. +import type { AgentRunAccounts, PaneLaunchFact } from './agent-run-accounts' +import type { KeptAgentConversations } from './kept-agent-conversations' + +export type InstalledAgentRunAccounts = { + producer: AgentRunAccounts + kept: KeptAgentConversations +} + +let installed: InstalledAgentRunAccounts | undefined + +/** Installed beside the account services (desktop and `orca serve`); orcad has none. */ +export function installAgentRunAccounts(services: InstalledAgentRunAccounts | undefined): void { + installed = services +} + +export function getAgentRunAccounts(): AgentRunAccounts | undefined { + return installed?.producer +} + +export function getKeptAgentConversations(): KeptAgentConversations | undefined { + return installed?.kept +} + +/** Called by both spawn-commit paths; a facet failure must never fail a spawn. */ +export function noteAgentPaneLaunch(fact: PaneLaunchFact): void { + try { + installed?.producer.notePaneLaunch(fact) + } catch (error) { + console.warn('[agent-run-accounts] Could not note a pane launch:', error) + } +} diff --git a/src/main/agent-accounts/agent-run-accounts.test.ts b/src/main/agent-accounts/agent-run-accounts.test.ts new file mode 100644 index 000000000000..09080e893212 --- /dev/null +++ b/src/main/agent-accounts/agent-run-accounts.test.ts @@ -0,0 +1,709 @@ +import { describe, expect, it } from 'vitest' +import type { AgentRunAccount } from '../../shared/agent-run-account' +import type { CodexPaneAccountRecord } from '../codex/codex-pane-account-registry' +import type { ClaudeManagedAccount, CodexManagedAccount } from '../../shared/managed-account-types' +import type { KeptConversation } from './kept-agent-conversations' +import { computeAgentRunAccount, type AgentRunAccountInput } from './agent-run-account-compute' +import { + AgentRunAccounts, + type AgentRunAccountSettings, + type AgentRunAccountStatusRow +} from './agent-run-accounts' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' +const DATA = '/data' +const SYSTEM_HOME = '/home/user/.claude' + +function input(overrides: Partial): AgentRunAccountInput { + return { + agentType: 'codex', + connectionId: null, + structured: false, + providerSessionId: 's1', + hasManagedAccounts: true, + lane: 'host', + codexRecord: null, + claude: null, + current: undefined, + claudeKeptAccountId: undefined, + ...overrides + } +} + +const ACCOUNT_HOME_RECORD: CodexPaneAccountRecord = { + selectionKey: 'host', + accountId: 'acc-a', + homeRoute: 'account-home' +} + +const CLAUDE_FOLLOW_A: AgentRunAccount = { + provider: 'claude', + lane: 'host', + accountId: 'acc-a', + mode: 'follow', + shellRoute: 'account' +} + +describe('computeAgentRunAccount', () => { + it.each<[string, Partial, AgentRunAccount | null]>([ + [ + 'a Codex pane that follows', + { codexRecord: ACCOUNT_HOME_RECORD }, + { provider: 'codex', lane: 'host', accountId: 'acc-a', mode: 'follow' } + ], + [ + 'a kept Codex pane', + { codexRecord: { ...ACCOUNT_HOME_RECORD, kept: true } }, + { provider: 'codex', lane: 'host', accountId: 'acc-a', mode: 'kept' } + ], + [ + 'a Codex pane on the shared home', + { codexRecord: { selectionKey: 'host', accountId: null, homeRoute: 'shared-home' } }, + { + provider: 'codex', + lane: 'host', + accountId: null, + mode: 'follow', + keepUnavailable: 'codex-home-not-keepable' + } + ], + [ + 'a WSL Codex record', + { codexRecord: { ...ACCOUNT_HOME_RECORD, selectionKey: 'wsl:U' } }, + null + ], + ['a Codex pane with no record and no facet', {}, null], + ['a relay row', { connectionId: 'ssh-1', codexRecord: ACCOUNT_HOME_RECORD }, null], + ['a structured row', { structured: true, codexRecord: ACCOUNT_HOME_RECORD }, null], + [ + 'a provider with no saved accounts', + { hasManagedAccounts: false, codexRecord: ACCOUNT_HOME_RECORD }, + null + ], + ['a pane launched on a WSL lane', { lane: 'wsl:U', codexRecord: ACCOUNT_HOME_RECORD }, null], + ['another agent', { agentType: 'gemini', codexRecord: ACCOUNT_HOME_RECORD }, null], + [ + 'a Claude session this runtime saw start', + { agentType: 'claude', claude: { accountId: 'acc-a', shellRoute: 'selected' } }, + { + provider: 'claude', + lane: 'host', + accountId: 'acc-a', + mode: 'follow', + shellRoute: 'selected' + } + ], + [ + 'a Claude session nobody saw start', + { agentType: 'claude', claude: { accountId: undefined, shellRoute: 'selected' } }, + null + ], + [ + 'a Claude statusline from a folder no account owns', + { + agentType: 'claude', + claude: { accountId: 'acc-a', statusLine: 'unknown-folder', shellRoute: 'selected' } + }, + null + ], + [ + 'a Claude statusline correcting the start account', + { + agentType: 'claude', + claude: { + accountId: 'acc-a', + statusLine: { accountId: 'acc-b' }, + shellRoute: 'selected' + } + }, + { + provider: 'claude', + lane: 'host', + accountId: 'acc-b', + mode: 'follow', + shellRoute: 'selected' + } + ], + [ + 'a Claude pane with no session yet', + { + agentType: 'claude', + providerSessionId: null, + claude: { accountId: 'acc-a', shellRoute: 'account' } + }, + { + provider: 'claude', + lane: 'host', + accountId: 'acc-a', + mode: 'follow', + shellRoute: 'account', + keepUnavailable: 'no-session' + } + ], + [ + 'a kept Claude conversation on its account', + { + agentType: 'claude', + claude: { accountId: 'acc-a', shellRoute: 'account' }, + claudeKeptAccountId: 'acc-a' + }, + { provider: 'claude', lane: 'host', accountId: 'acc-a', mode: 'kept', shellRoute: 'account' } + ], + [ + 'a Claude conversation kept elsewhere than it runs', + { + agentType: 'claude', + claude: { accountId: 'acc-b', shellRoute: 'selected' }, + claudeKeptAccountId: 'acc-a' + }, + { + provider: 'claude', + lane: 'host', + accountId: 'acc-b', + mode: 'follow', + shellRoute: 'selected', + keptElsewhere: true + } + ], + [ + 'a kept System default Claude conversation that runs on an account', + { + agentType: 'claude', + claude: { accountId: 'acc-b', shellRoute: 'selected' }, + claudeKeptAccountId: null + }, + { + provider: 'claude', + lane: 'host', + accountId: 'acc-b', + mode: 'follow', + shellRoute: 'selected', + keptElsewhere: true + } + ], + [ + 'a Claude pane with no session yet, which has no keep to name', + { + agentType: 'claude', + providerSessionId: null, + claude: { accountId: 'acc-b', shellRoute: 'selected' }, + claudeKeptAccountId: 'acc-a' + }, + { + provider: 'claude', + lane: 'host', + accountId: 'acc-b', + mode: 'follow', + shellRoute: 'selected', + keepUnavailable: 'no-session' + } + ], + [ + 'a Claude pane this runtime has not seen, with the facet an earlier runtime recorded', + { agentType: 'claude', current: CLAUDE_FOLLOW_A }, + CLAUDE_FOLLOW_A + ], + [ + 'a Claude pane this runtime has not seen, whose conversation is kept on its account', + { agentType: 'claude', current: CLAUDE_FOLLOW_A, claudeKeptAccountId: 'acc-a' }, + { ...CLAUDE_FOLLOW_A, mode: 'kept' } + ], + [ + 'a Claude pane this runtime has not seen, whose keep was released since it was recorded', + { agentType: 'claude', current: { ...CLAUDE_FOLLOW_A, mode: 'kept' } }, + CLAUDE_FOLLOW_A + ], + [ + 'a Claude pane this runtime has not seen, whose conversation is now kept elsewhere', + { agentType: 'claude', current: CLAUDE_FOLLOW_A, claudeKeptAccountId: 'acc-b' }, + { ...CLAUDE_FOLLOW_A, keptElsewhere: true } + ], + [ + 'a Claude pane this runtime has not seen, with no facet recorded', + { agentType: 'claude' }, + null + ], + [ + "a Claude pane this runtime has not seen, whose recorded facet is Codex's", + { + agentType: 'claude', + current: { provider: 'codex', lane: 'host', accountId: 'acc-a', mode: 'kept' } + }, + null + ] + ])('%s', (_label, overrides, expected) => { + expect(computeAgentRunAccount(input(overrides))).toEqual(expected) + }) + + it('keeps a persisted Codex facet until the launch record can be read', () => { + const current: AgentRunAccount = { + provider: 'codex', + lane: 'host', + accountId: 'acc-a', + mode: 'kept' + } + expect(computeAgentRunAccount(input({ current }))).toEqual(current) + }) +}) + +function claudeAccount(id: string): ClaudeManagedAccount { + return { + id, + email: `${id}@example.com`, + managedAuthPath: `${DATA}/claude-accounts/${id}`, + authMethod: 'subscription-oauth', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } +} + +function codexAccount(id: string): CodexManagedAccount { + return { + id, + email: `${id}@example.com`, + managedHomePath: `${DATA}/codex-accounts/${id}/home`, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } +} + +type Harness = { + kept: Map + producer: AgentRunAccounts + rows: Map + settings: AgentRunAccountSettings + records: Map + covered: Set + republished: (AgentRunAccount | null)[] + emit: (row: Partial & { sessionId?: string | null }) => void +} + +function harness(): Harness { + const rows = new Map() + const records = new Map() + const covered = new Set() + const republished: (AgentRunAccount | null)[] = [] + const kept = new Map() + const settings: AgentRunAccountSettings = { + claudeManagedAccounts: [claudeAccount('acc-a'), claudeAccount('acc-b')], + codexManagedAccounts: [codexAccount('acc-a')], + activeClaudeManagedAccountId: 'acc-a', + activeClaudeManagedAccountIdsByRuntime: undefined + } + const producer = new AgentRunAccounts({ + republish: (paneKey, runAccount) => { + const row = rows.get(paneKey) + if (row) { + rows.set(paneKey, { ...row, runAccount }) + } + republished.push(runAccount) + return true + }, + getRow: (paneKey) => { + const row = rows.get(paneKey) + return row + ? { + paneKey, + agentType: row.payload.agentType, + providerSession: row.providerSession, + runAccount: row.runAccount ?? undefined, + connectionId: row.connectionId + } + : null + }, + listRows: () => [...rows.values()], + getSettings: () => settings, + getPtyIdForPaneKey: (paneKey) => (paneKey === PANE ? 'pty-1' : undefined), + readCodexRecord: (ptyId) => records.get(ptyId) ?? null, + claudeFolders: { + accountHome: (id) => `${DATA}/claude-profiles/${id}/home`, + systemDefaultHome: () => SYSTEM_HOME, + coveredBySystemDefault: (id) => covered.has(id) + }, + kept: { + get: (provider, sessionId) => kept.get(`${provider}:${sessionId}`) ?? null, + keep: (provider, sessionId, entry) => { + kept.set(`${provider}:${sessionId}`, entry) + }, + releaseCarried: (provider, sessionId, keptAt) => { + if (kept.get(`${provider}:${sessionId}`)?.keptAt === keptAt) { + kept.delete(`${provider}:${sessionId}`) + } + } + }, + now: () => 42 + }) + const emit: Harness['emit'] = ({ sessionId = 's1', ...overrides }) => { + const previous = rows.get(PANE) + const row: AgentRunAccountStatusRow = { + paneKey: PANE, + connectionId: null, + payload: { agentType: 'claude' }, + ...(sessionId ? { providerSession: { key: 'session_id', id: sessionId } } : {}), + ...(previous?.runAccount !== undefined ? { runAccount: previous.runAccount } : {}), + ...overrides + } + rows.set(PANE, row) + producer.noteStatus(row) + } + return { kept, producer, rows, settings, records, covered, republished, emit } +} + +function facet(h: Harness): AgentRunAccount | null | undefined { + return h.rows.get(PANE)?.runAccount +} + +describe('AgentRunAccounts', () => { + it('dates a Claude session to the selection when this runtime saw it start', () => { + const h = harness() + h.producer.notePaneLaunch({ paneKey: PANE, lane: 'host', fresh: true }) + h.emit({}) + expect(facet(h)).toMatchObject({ accountId: 'acc-a', shellRoute: 'selected', mode: 'follow' }) + + // A switch moves the next start, not the running session. + h.settings.activeClaudeManagedAccountId = 'acc-b' + h.emit({}) + expect(facet(h)).toMatchObject({ accountId: 'acc-a' }) + + h.emit({ sessionId: 's2' }) + expect(facet(h)).toMatchObject({ accountId: 'acc-b' }) + }) + + it('claims nothing for a session that was already running when it was first seen', () => { + const h = harness() + h.producer.notePaneLaunch({ paneKey: PANE, lane: 'host', fresh: false }) + h.emit({}) + expect(facet(h)).toBeUndefined() + expect(h.republished).toEqual([]) + + h.producer.noteClaudeStatusLine({ + paneKey: PANE, + configDir: `${DATA}/claude-profiles/acc-b/home` + }) + expect(facet(h)).toMatchObject({ accountId: 'acc-b' }) + }) + + it('keeps the facet a previous runtime recorded for the same session', () => { + const h = harness() + const persisted: AgentRunAccount = { + provider: 'claude', + lane: 'host', + accountId: 'acc-b', + mode: 'follow', + shellRoute: 'selected' + } + h.emit({ runAccount: persisted }) + expect(facet(h)).toEqual(persisted) + expect(h.republished).toEqual([]) + }) + + it('withdraws the facet when the statusline names a folder no account owns', () => { + const h = harness() + h.producer.notePaneLaunch({ paneKey: PANE, lane: 'host', fresh: true }) + h.emit({}) + h.producer.noteClaudeStatusLine({ paneKey: PANE, configDir: '/somewhere/else' }) + expect(facet(h)).toBeNull() + }) + + it('maps System default to the start account only while that account is covered', () => { + const h = harness() + h.producer.notePaneLaunch({ paneKey: PANE, lane: 'host', fresh: true }) + h.emit({}) + h.producer.noteClaudeStatusLine({ paneKey: PANE, configDir: null }) + expect(facet(h)).toMatchObject({ accountId: null }) + + h.covered.add('acc-a') + h.producer.noteClaudeStatusLine({ paneKey: PANE, configDir: SYSTEM_HOME }) + expect(facet(h)).toMatchObject({ accountId: 'acc-a' }) + }) + + it('reads a pane started on an account pointer from that pointer, not the selection', () => { + const h = harness() + h.producer.notePaneLaunch({ + paneKey: PANE, + lane: 'host', + fresh: true, + claudeShellAccount: { accountId: 'acc-b' } + }) + h.emit({}) + expect(facet(h)).toMatchObject({ accountId: 'acc-b', shellRoute: 'account' }) + h.emit({ sessionId: 's2' }) + expect(facet(h)).toMatchObject({ accountId: 'acc-b', shellRoute: 'account' }) + }) + + it('publishes nothing for a Claude pane launched on a WSL lane', () => { + const h = harness() + h.producer.notePaneLaunch({ paneKey: PANE, lane: 'wsl:Ubuntu', fresh: true }) + h.emit({}) + expect(facet(h)).toBeUndefined() + }) + + it('follows the Codex launch record, including a later keep', () => { + const h = harness() + h.records.set('pty-1', ACCOUNT_HOME_RECORD) + h.emit({ payload: { agentType: 'codex' } }) + expect(facet(h)).toEqual({ + provider: 'codex', + lane: 'host', + accountId: 'acc-a', + mode: 'follow' + }) + + h.records.set('pty-1', { ...ACCOUNT_HOME_RECORD, kept: true }) + expect(h.producer.refresh(PANE)).toMatchObject({ mode: 'kept' }) + expect(facet(h)).toMatchObject({ mode: 'kept' }) + }) + + it('withdraws every facet once a provider has no saved accounts left', () => { + const h = harness() + h.records.set('pty-1', ACCOUNT_HOME_RECORD) + h.emit({ payload: { agentType: 'codex' } }) + h.settings.codexManagedAccounts = [] + h.producer.refreshAll() + expect(facet(h)).toBeNull() + }) + + it('carries a keep to the next session when it runs on the kept account', () => { + const h = harness() + h.producer.notePaneLaunch({ paneKey: PANE, lane: 'host', fresh: true }) + h.emit({}) + h.kept.set('claude:s1', { lane: 'host', accountId: 'acc-a', keptAt: 1 }) + h.emit({}) + expect(facet(h)).toMatchObject({ mode: 'kept', accountId: 'acc-a' }) + + h.emit({ sessionId: 's2' }) + expect(h.kept.get('claude:s2')).toEqual({ + lane: 'host', + accountId: 'acc-a', + keptAt: 42, + keptVia: 'claude:s1' + }) + expect(facet(h)).toMatchObject({ mode: 'kept', accountId: 'acc-a' }) + }) + + it('never carries a keep to a session that runs on another account', () => { + const h = harness() + h.producer.notePaneLaunch({ paneKey: PANE, lane: 'host', fresh: true }) + h.emit({}) + h.kept.set('claude:s1', { lane: 'host', accountId: 'acc-a', keptAt: 1 }) + + // A `claude` typed after a switch in a selection-following terminal runs on the selection. + h.settings.activeClaudeManagedAccountId = 'acc-b' + h.emit({ sessionId: 's2' }) + + expect(h.kept.has('claude:s2')).toBe(false) + expect(facet(h)).toMatchObject({ mode: 'follow', accountId: 'acc-b' }) + }) + + it('carries a keep through /clear after a switch, inside the same Claude process', () => { + const h = harness() + const process = { pid: 4242, platform: 'linux', startTime: 'boot-1' } as const + const presence = { agentPresence: { agent: 'claude', process } } as const + h.producer.notePaneLaunch({ paneKey: PANE, lane: 'host', fresh: true }) + h.emit({ ...presence }) + h.kept.set('claude:s1', { lane: 'host', accountId: 'acc-a', keptAt: 1 }) + h.settings.activeClaudeManagedAccountId = 'acc-b' + + h.emit({ sessionId: 's2', ...presence }) + + expect(h.kept.get('claude:s2')).toMatchObject({ accountId: 'acc-a', keptVia: 'claude:s1' }) + expect(facet(h)).toMatchObject({ mode: 'kept', accountId: 'acc-a' }) + }) + + it('carries a keep through /clear once the statusline proves the kept account', () => { + const h = harness() + h.producer.notePaneLaunch({ paneKey: PANE, lane: 'host', fresh: true }) + h.emit({}) + h.kept.set('claude:s1', { lane: 'host', accountId: 'acc-a', keptAt: 1 }) + h.settings.activeClaudeManagedAccountId = 'acc-b' + // No process identity: s2 is dated to the selection until its folder is known. + h.emit({ sessionId: 's2' }) + expect(h.kept.has('claude:s2')).toBe(false) + + h.producer.noteClaudeStatusLine({ + paneKey: PANE, + configDir: `${DATA}/claude-profiles/acc-a/home` + }) + + expect(h.kept.get('claude:s2')).toMatchObject({ accountId: 'acc-a', keptVia: 'claude:s1' }) + expect(facet(h)).toMatchObject({ mode: 'kept', accountId: 'acc-a' }) + }) + + it('drops a carried keep the statusline proves runs on another account', () => { + const h = harness() + const old = { + agentPresence: { agent: 'claude', process: { pid: 7, platform: 'linux', startTime: 'a' } } + } as const + h.producer.notePaneLaunch({ paneKey: PANE, lane: 'host', fresh: true }) + h.emit({ ...old }) + h.kept.set('claude:s1', { lane: 'host', accountId: 'acc-a', keptAt: 1 }) + h.settings.activeClaudeManagedAccountId = 'acc-b' + // A new claude whose hooks still name the old, unended owner looks like the same process. + h.emit({ sessionId: 's2', ...old }) + expect(h.kept.get('claude:s2')).toMatchObject({ accountId: 'acc-a' }) + + h.producer.noteClaudeStatusLine({ + paneKey: PANE, + configDir: `${DATA}/claude-profiles/acc-b/home` + }) + + expect(h.kept.has('claude:s2')).toBe(false) + expect(h.kept.has('claude:s1')).toBe(true) + expect(facet(h)).toMatchObject({ mode: 'follow', accountId: 'acc-b' }) + }) + + it('never carries a keep to a new Claude process typed after a switch', () => { + const h = harness() + h.producer.notePaneLaunch({ paneKey: PANE, lane: 'host', fresh: true }) + h.emit({ + agentPresence: { agent: 'claude', process: { pid: 7, platform: 'linux', startTime: 'a' } } + }) + h.kept.set('claude:s1', { lane: 'host', accountId: 'acc-a', keptAt: 1 }) + h.settings.activeClaudeManagedAccountId = 'acc-b' + + h.emit({ + sessionId: 's2', + agentPresence: { agent: 'claude', process: { pid: 8, platform: 'linux', startTime: 'b' } } + }) + + expect(h.kept.has('claude:s2')).toBe(false) + expect(facet(h)).toMatchObject({ mode: 'follow', accountId: 'acc-b' }) + }) + + it('never carries a Claude keep to another agent in the same pane', () => { + const h = harness() + h.producer.notePaneLaunch({ paneKey: PANE, lane: 'host', fresh: true }) + h.emit({}) + h.kept.set('claude:s1', { lane: 'host', accountId: 'acc-a', keptAt: 1 }) + h.records.set('pty-1', ACCOUNT_HOME_RECORD) + + h.emit({ sessionId: 's9', payload: { agentType: 'codex' } }) + + expect([...h.kept.keys()]).toEqual(['claude:s1']) + }) + + it('keeps the renamed session of a kept start', () => { + const h = harness() + h.kept.set('claude:resumed', { lane: 'host', accountId: 'acc-b', keptAt: 1 }) + h.producer.notePaneLaunch({ + paneKey: PANE, + lane: 'host', + fresh: true, + claudeShellAccount: { accountId: 'acc-b' }, + resumedSessionId: 'resumed' + }) + + h.emit({ sessionId: 'renamed' }) + + expect(h.kept.get('claude:renamed')).toMatchObject({ accountId: 'acc-b' }) + expect(facet(h)).toMatchObject({ mode: 'kept', accountId: 'acc-b', shellRoute: 'account' }) + }) + + it('keeps every session a kept Codex pane reports, each traced to the first', () => { + const h = harness() + h.records.set('pty-1', { ...ACCOUNT_HOME_RECORD, kept: true }) + h.emit({ sessionId: 'c1', payload: { agentType: 'codex' } }) + h.emit({ sessionId: 'c2', payload: { agentType: 'codex' } }) + h.emit({ sessionId: 'c3', payload: { agentType: 'codex' } }) + + expect(h.kept.get('codex:c1')).toMatchObject({ accountId: 'acc-a' }) + expect(h.kept.get('codex:c1')).not.toHaveProperty('keptVia') + expect(h.kept.get('codex:c2')).toMatchObject({ accountId: 'acc-a', keptVia: 'codex:c1' }) + expect(h.kept.get('codex:c3')).toMatchObject({ accountId: 'acc-a', keptVia: 'codex:c1' }) + }) + + it('marks a conversation kept on another account than the one it now runs on', () => { + const h = harness() + h.producer.notePaneLaunch({ paneKey: PANE, lane: 'host', fresh: true }) + h.emit({}) + h.kept.set('claude:s1', { lane: 'host', accountId: 'acc-a', keptAt: 1 }) + h.emit({}) + expect(facet(h)).toMatchObject({ mode: 'kept', accountId: 'acc-a' }) + expect(facet(h)).not.toHaveProperty('keptElsewhere') + + // A person resumes it by hand in a selection-following terminal, and the folder proves it. + h.producer.noteClaudeStatusLine({ + paneKey: PANE, + configDir: `${DATA}/claude-profiles/acc-b/home` + }) + expect(facet(h)).toMatchObject({ mode: 'follow', accountId: 'acc-b', keptElsewhere: true }) + // Why: only a carried keep is dropped for running elsewhere; a person's own stays. + expect(h.kept.get('claude:s1')).toMatchObject({ accountId: 'acc-a' }) + + h.kept.delete('claude:s1') + h.producer.refresh(PANE) + expect(facet(h)).toMatchObject({ mode: 'follow', accountId: 'acc-b' }) + expect(facet(h)).not.toHaveProperty('keptElsewhere') + }) +}) + +describe('AgentRunAccounts for a Claude terminal that outlived a restart', () => { + const keptFacet: AgentRunAccount = { + provider: 'claude', + lane: 'host', + accountId: 'acc-a', + mode: 'kept', + shellRoute: 'selected' + } + + // The host hydrated the row with the facet it persisted; the idle agent has posted nothing since. + function hydrated(runAccount: AgentRunAccount = keptFacet): Harness { + const h = harness() + h.kept.set('claude:s1', { lane: 'host', accountId: 'acc-a', keptAt: 1 }) + h.rows.set(PANE, { + paneKey: PANE, + connectionId: null, + payload: { agentType: 'claude' }, + providerSession: { key: 'session_id', id: 's1' }, + runAccount + }) + return h + } + + it('keeps its kept facet when the terminal reattaches', () => { + const h = hydrated() + h.producer.notePaneLaunch({ paneKey: PANE, lane: 'host', fresh: false }) + expect(facet(h)).toEqual(keptFacet) + expect(h.republished).toEqual([]) + }) + + it('keeps its kept facet when an account is added or removed', () => { + const h = hydrated() + h.producer.refreshAll() + expect(facet(h)).toEqual(keptFacet) + expect(h.republished).toEqual([]) + }) + + it('keeps its kept facet through the status that follows', () => { + const h = hydrated() + h.producer.notePaneLaunch({ paneKey: PANE, lane: 'host', fresh: false }) + h.emit({}) + expect(facet(h)).toEqual(keptFacet) + expect(h.republished).toEqual([]) + }) + + it('still shows a keep made, or released, after the restart', () => { + const h = hydrated({ ...keptFacet, mode: 'follow' }) + h.kept.delete('claude:s1') + h.producer.notePaneLaunch({ paneKey: PANE, lane: 'host', fresh: false }) + expect(facet(h)).toMatchObject({ mode: 'follow' }) + + h.kept.set('claude:s1', { lane: 'host', accountId: 'acc-a', keptAt: 2 }) + expect(h.producer.refresh(PANE)).toMatchObject({ mode: 'kept' }) + expect(facet(h)).toMatchObject({ mode: 'kept' }) + + h.kept.delete('claude:s1') + expect(h.producer.refresh(PANE)).toMatchObject({ mode: 'follow' }) + expect(facet(h)).toMatchObject({ mode: 'follow' }) + }) + + it('withdraws what an earlier process left when the pane is spawned fresh', () => { + const h = hydrated() + h.producer.notePaneLaunch({ paneKey: PANE, lane: 'host', fresh: true }) + expect(facet(h)).toBeNull() + h.producer.refreshAll() + expect(facet(h)).toBeNull() + }) +}) diff --git a/src/main/agent-accounts/agent-run-accounts.ts b/src/main/agent-accounts/agent-run-accounts.ts new file mode 100644 index 000000000000..260e6008db5e --- /dev/null +++ b/src/main/agent-accounts/agent-run-accounts.ts @@ -0,0 +1,338 @@ +import { agentRunAccountsEqual, type AgentRunAccount } from '../../shared/agent-run-account' +import { isSameAgentProcess, type AgentProcessIdentity } from '../../shared/agent-process-presence' +import { normalizeAgentProviderSession } from '../../shared/agent-session-resume' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { EnrichedAgentHookEventPayload } from '../agent-hooks/server' +import type { AgentRunAccountRow } from '../agent-hooks/server/server-run-account' +import type { CodexPaneAccountRecord } from '../codex/codex-pane-account-registry' +import { getSelectedClaudeAccountIdForTarget } from '../claude-accounts/runtime-selection' +import { + computeAgentRunAccount, + runAccountProvider, + type ClaudePaneObservation, + type ClaudeStatusLineReport +} from './agent-run-account-compute' +import { + adoptClaudeSession, + adoptCodexSession, + dropMisplacedClaudeKeep, + keptConversationAccount, + type KeptConversationLookup +} from './agent-run-account-adoption' + +// Why: launch facts only bridge a spawn to its first row; losing the oldest one only +// delays that pane's facet until it reports a new session. +const MAX_TRACKED_PANES = 2048 + +export type AgentRunAccountSettings = Pick< + GlobalSettings, + | 'claudeManagedAccounts' + | 'codexManagedAccounts' + | 'activeClaudeManagedAccountId' + | 'activeClaudeManagedAccountIdsByRuntime' +> + +export type AgentRunAccountStatusRow = Pick< + EnrichedAgentHookEventPayload, + 'paneKey' | 'connectionId' | 'providerSession' | 'runAccount' | 'structuredHost' | 'agentPresence' +> & { payload: { agentType?: string } } + +export type AgentRunAccountsDeps = { + republish: (paneKey: string, runAccount: AgentRunAccount | null) => boolean + getRow: (paneKey: string) => AgentRunAccountRow | null + listRows: () => readonly AgentRunAccountStatusRow[] + getSettings: () => AgentRunAccountSettings + getPtyIdForPaneKey: (paneKey: string) => string | undefined + readCodexRecord: (ptyId: string) => CodexPaneAccountRecord | null + claudeFolders: { + accountHome: (accountId: string) => string + systemDefaultHome: () => string + coveredBySystemDefault: (accountId: string) => boolean + } | null + kept: KeptConversationLookup + now?: () => number +} + +export type PaneLaunchFact = { + paneKey: string + /** Selection lane the spawn resolved ('host' | 'wsl:'). */ + lane: string + /** False for a reattach, which never re-resolves accounts. */ + fresh: boolean + /** Set when the pane was started with a per-account Claude pointer (kept start). */ + claudeShellAccount?: { accountId: string | null } + /** The provider session a kept start resumed; Claude may report it under a new id. */ + resumedSessionId?: string +} + +type ClaudePaneState = ClaudePaneObservation & { + sessionId: string | null + /** The pane's session before this one, whose keep this one may carry on. */ + previousSessionId?: string | null + /** Account the pane's own pointer names, for panes started kept. */ + pointerAccountId?: string | null + lastConfigDir?: string | null + /** The Claude process the pane's hooks named while this session ran. */ + process?: AgentProcessIdentity +} + +function setBounded(map: Map, key: string, value: V): void { + map.delete(key) + map.set(key, value) + if (map.size > MAX_TRACKED_PANES) { + const oldest = map.keys().next().value + if (typeof oldest === 'string') { + map.delete(oldest) + } + } +} + +function sessionIdOf(row: Pick): string | null { + return normalizeAgentProviderSession(row.providerSession)?.id ?? null +} + +function liveProcess(row: AgentRunAccountStatusRow): AgentProcessIdentity | undefined { + return row.agentPresence?.ended ? undefined : row.agentPresence?.process +} + +/** The account a pane's Claude runs on as far as this runtime can tell; undefined = unknown. */ +function runningAccount(state: ClaudePaneObservation): string | null | undefined { + if (state.statusLine === 'unknown-folder') { + return undefined + } + return state.statusLine ? state.statusLine.accountId : state.accountId +} + +function samePath(left: string, right: string): boolean { + return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right) +} + +/** The single producer of the run-account facet: joins launch facts with what agents report. */ +export class AgentRunAccounts { + private readonly launches = new Map() + private readonly claudePanes = new Map() + private readonly codexSessions = new Map() + + constructor(private readonly deps: AgentRunAccountsDeps) {} + + notePaneLaunch(fact: PaneLaunchFact): void { + setBounded(this.launches, fact.paneKey, fact) + if (fact.fresh) { + // Why: a fresh spawn starts a new process, so nothing observed for the old one applies. + this.claudePanes.delete(fact.paneKey) + this.codexSessions.delete(fact.paneKey) + } + this.refresh(fact.paneKey) + } + + noteStatus(row: AgentRunAccountStatusRow): void { + const provider = runAccountProvider(row.payload.agentType) + if (row.connectionId === null && !row.structuredHost) { + if (provider === 'claude') { + this.observeClaudeSession(row) + } else if (provider === 'codex') { + this.observeCodexSession(row) + } + } + this.publish(row) + } + + noteClaudeStatusLine(event: { paneKey: string; configDir: string | null }): void { + const state = this.claudePanes.get(event.paneKey) + if (!state || state.lastConfigDir === event.configDir) { + return + } + state.lastConfigDir = event.configDir + state.statusLine = this.accountForClaudeConfigDir(event.configDir, state.accountId) + if (state.statusLine !== 'unknown-folder') { + // Why: the folder is proof, so a session dated to the selection may still carry a keep, or lose one. + dropMisplacedClaudeKeep(this.deps.kept, state.sessionId, state.statusLine.accountId) + const row = this.deps.getRow(event.paneKey) + const session = normalizeAgentProviderSession(row?.providerSession) + this.adoptClaude(state, session?.id === state.sessionId ? session.transcriptPath : undefined) + } + this.refresh(event.paneKey) + } + + /** Recomputes one pane's facet from its stored row. */ + refresh(paneKey: string): AgentRunAccount | null { + const row = this.deps.getRow(paneKey) + if (!row) { + return null + } + return this.publish({ + paneKey: row.paneKey, + connectionId: row.connectionId, + providerSession: row.providerSession, + runAccount: row.runAccount, + payload: { agentType: row.agentType } + }) + } + + withdraw(paneKey: string): void { + this.deps.republish(paneKey, null) + } + + refreshAll(): void { + for (const row of this.deps.listRows()) { + this.publish(row) + } + } + + private publish(row: AgentRunAccountStatusRow): AgentRunAccount | null { + const next = this.compute(row) + if (!agentRunAccountsEqual(row.runAccount, next)) { + this.deps.republish(row.paneKey, next) + } + return next + } + + private compute(row: AgentRunAccountStatusRow): AgentRunAccount | null { + const provider = runAccountProvider(row.payload.agentType) + const settings = this.deps.getSettings() + const ptyId = provider === 'codex' ? this.deps.getPtyIdForPaneKey(row.paneKey) : undefined + // Why: a Claude spawned fresh here is a new process, so the facet its predecessor left on the + // row says nothing about it; a reattach or an untouched pane still carries what was recorded. + const startedHere = provider === 'claude' && this.launches.get(row.paneKey)?.fresh === true + return computeAgentRunAccount({ + agentType: row.payload.agentType, + connectionId: row.connectionId, + structured: Boolean(row.structuredHost), + providerSessionId: sessionIdOf(row), + hasManagedAccounts: + provider === 'codex' + ? settings.codexManagedAccounts.length > 0 + : settings.claudeManagedAccounts.length > 0, + lane: this.launches.get(row.paneKey)?.lane, + codexRecord: ptyId ? this.deps.readCodexRecord(ptyId) : null, + claude: provider === 'claude' ? (this.claudePanes.get(row.paneKey) ?? null) : null, + current: startedHere ? undefined : (row.runAccount ?? undefined), + claudeKeptAccountId: + provider === 'claude' + ? keptConversationAccount(this.deps.kept, 'claude', sessionIdOf(row)) + : undefined + }) + } + + private observeCodexSession(row: AgentRunAccountStatusRow): void { + const sessionId = sessionIdOf(row) + const ptyId = this.deps.getPtyIdForPaneKey(row.paneKey) + const fact = this.launches.get(row.paneKey) + adoptCodexSession({ + kept: this.deps.kept, + record: ptyId ? this.deps.readCodexRecord(ptyId) : null, + previousSessionId: + this.codexSessions.get(row.paneKey) ?? (fact?.fresh ? fact.resumedSessionId : undefined), + sessionId, + transcriptPath: row.providerSession?.transcriptPath, + now: this.now() + }) + if (sessionId) { + setBounded(this.codexSessions, row.paneKey, sessionId) + } + } + + private observeClaudeSession(row: AgentRunAccountStatusRow): void { + const sessionId = sessionIdOf(row) + const process = liveProcess(row) + const existing = this.claudePanes.get(row.paneKey) + if (existing && (sessionId === null || sessionId === existing.sessionId)) { + existing.process ??= process + return + } + const fact = this.launches.get(row.paneKey) + let next: ClaudePaneState + if (!existing) { + const persisted = row.runAccount?.provider === 'claude' ? row.runAccount : undefined + const shellRoute = fact?.claudeShellAccount + ? 'account' + : (persisted?.shellRoute ?? 'selected') + const pointerAccountId = + fact?.claudeShellAccount?.accountId ?? + (shellRoute === 'account' ? persisted?.accountId : undefined) + next = { + sessionId, + previousSessionId: fact?.fresh ? fact.resumedSessionId : undefined, + shellRoute, + pointerAccountId, + // Why: only a session this runtime saw start may be dated to the selection now; + // one that was already running keeps what an earlier runtime recorded, or nothing. + accountId: fact?.fresh + ? this.claudeStartAccount(shellRoute, pointerAccountId) + : persisted?.accountId, + process + } + } else if (existing.process && process && isSameAgentProcess(existing.process, process)) { + // Why: /clear and /resume change sessions inside one process, whose folder was fixed at its start. + next = { ...existing, sessionId, previousSessionId: existing.sessionId } + } else { + next = { + sessionId, + previousSessionId: existing.sessionId, + shellRoute: existing.shellRoute, + pointerAccountId: existing.pointerAccountId, + accountId: this.claudeStartAccount(existing.shellRoute, existing.pointerAccountId), + process + } + } + setBounded(this.claudePanes, row.paneKey, next) + this.adoptClaude(next, row.providerSession?.transcriptPath) + } + + private adoptClaude(state: ClaudePaneState, transcriptPath: string | undefined): void { + adoptClaudeSession({ + kept: this.deps.kept, + previousSessionId: state.previousSessionId, + sessionId: state.sessionId, + accountId: runningAccount(state), + transcriptPath, + now: this.now() + }) + } + + private now(): number { + return (this.deps.now ?? Date.now)() + } + + /** The account a `claude` started now in this pane reads: its own pointer, or the selection. */ + private claudeStartAccount( + shellRoute: 'selected' | 'account', + pointerAccountId: string | null | undefined + ): string | null | undefined { + if (shellRoute === 'account') { + return pointerAccountId + } + return getSelectedClaudeAccountIdForTarget(this.deps.getSettings(), { runtime: 'host' }) + } + + private accountForClaudeConfigDir( + configDir: string | null, + startAccountId: string | null | undefined + ): ClaudeStatusLineReport { + const folders = this.deps.claudeFolders + if (!folders) { + return 'unknown-folder' + } + try { + if (!configDir || samePath(configDir, folders.systemDefaultHome())) { + // Why: an account saved before per-account folders runs on System default's login. + return { + accountId: + startAccountId && folders.coveredBySystemDefault(startAccountId) ? startAccountId : null + } + } + for (const account of this.deps.getSettings().claudeManagedAccounts) { + if ( + account.managedAuthRuntime !== 'wsl' && + samePath(configDir, folders.accountHome(account.id)) + ) { + return { accountId: account.id } + } + } + } catch (error) { + console.warn('[agent-run-accounts] Could not map a Claude config folder:', error) + } + return 'unknown-folder' + } +} diff --git a/src/main/agent-accounts/kept-account-refusals.ts b/src/main/agent-accounts/kept-account-refusals.ts new file mode 100644 index 000000000000..2772f86fdfc4 --- /dev/null +++ b/src/main/agent-accounts/kept-account-refusals.ts @@ -0,0 +1,33 @@ +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' + +// Why typed: a terminal shows the message through its spawn error, and nothing falls back. +function refusal(message: string): AgentSessionPreSpawnError { + return new AgentSessionPreSpawnError(new Error(message)) +} + +export const keptAccountRefusals = { + indexUnavailable: (filePath: string): AgentSessionPreSpawnError => + refusal( + `Orca could not read which account this agent is kept on (${filePath}). Make sure Orca can read that file, or move it aside to start agents on the selected account.` + ), + laneMismatch: (): AgentSessionPreSpawnError => + refusal( + 'This agent is kept on an account from another environment (host or WSL). Open it there, or un-keep it from the tab menu.' + ), + accountRemoved: (): AgentSessionPreSpawnError => + refusal( + 'This agent is kept on an account that is no longer saved in Orca. Un-keep it from the tab menu to start it on the selected account.' + ), + codexHomeUntrusted: (label: string): AgentSessionPreSpawnError => + refusal( + `This agent is kept on ${label}, but Orca could not verify that account's Codex folder. Sign in to it again in Settings, or un-keep the agent.` + ), + codexSystemDefaultWithCustomHome: (): AgentSessionPreSpawnError => + refusal( + 'This agent is kept on System default, which Orca cannot start while CODEX_HOME is set. Unset CODEX_HOME, or un-keep the agent.' + ), + codexConversationMissing: (label: string): AgentSessionPreSpawnError => + refusal( + `This agent is kept on ${label}, but its conversation was not found. Un-keep it to start a new session.` + ) +} diff --git a/src/main/agent-accounts/kept-agent-conversations.test.ts b/src/main/agent-accounts/kept-agent-conversations.test.ts new file mode 100644 index 000000000000..0388aaf8e0de --- /dev/null +++ b/src/main/agent-accounts/kept-agent-conversations.test.ts @@ -0,0 +1,259 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { KeptAgentConversations, type KeptConversation } from './kept-agent-conversations' + +const DAY_MS = 24 * 60 * 60 * 1000 +const NOW = 1_800_000_000_000 + +let dir: string +let filePath: string + +function entry(overrides: Partial = {}): KeptConversation { + return { lane: 'host', accountId: 'acc-a', keptAt: NOW, ...overrides } +} + +function open(): KeptAgentConversations { + return new KeptAgentConversations({ filePath, now: () => NOW }) +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-kept-conversations-')) + filePath = join(dir, 'agent-kept-conversations.json') +}) + +afterEach(() => { + vi.useRealTimers() + rmSync(dir, { recursive: true, force: true }) +}) + +describe('KeptAgentConversations', () => { + it('creates no file until the first keep, then survives a reload', () => { + const index = open() + expect(index.get('claude', 's1')).toBeNull() + expect(existsSync(filePath)).toBe(false) + + index.keep('claude', 's1', entry({ transcriptPath: '/t/s1.jsonl' })) + index.keep('codex', 's2', entry({ accountId: null })) + + const reloaded = open() + expect(reloaded.get('claude', 's1')).toEqual(entry({ transcriptPath: '/t/s1.jsonl' })) + expect(reloaded.get('codex', 's2')).toEqual(entry({ accountId: null })) + expect(reloaded.get('codex', 's1')).toBeNull() + expect(statSync(filePath).mode & 0o777).toBe(0o600) + }) + + it('releases one conversation, or every conversation kept on a removed account', () => { + const index = open() + index.keep('claude', 's1', entry()) + index.keep('claude', 's2', entry()) + index.keep('claude', 's3', entry({ accountId: 'acc-b' })) + index.keep('codex', 's4', entry()) + + index.release('claude', 's1') + expect(index.releaseAccount('claude', 'acc-a')).toBe(1) + + const reloaded = open() + expect(reloaded.get('claude', 's2')).toBeNull() + expect(reloaded.get('claude', 's3')).toEqual(entry({ accountId: 'acc-b' })) + expect(reloaded.get('codex', 's4')).toEqual(entry()) + }) + + it('un-keeping any session of an agent releases every session that carried its keep', () => { + const index = open() + index.keep('codex', 'c1', entry()) + index.keep('codex', 'c2', entry({ keptVia: 'codex:c1' })) + index.keep('codex', 'c3', entry({ keptVia: 'codex:c1' })) + index.keep('codex', 'other', entry()) + + index.release('codex', 'c3') + + const reloaded = open() + expect(reloaded.get('codex', 'c1')).toBeNull() + expect(reloaded.get('codex', 'c2')).toBeNull() + expect(reloaded.get('codex', 'c3')).toBeNull() + expect(reloaded.get('codex', 'other')).toEqual(entry()) + }) + + it('drops one carried keep only while it is the one that was carried', () => { + const index = open() + index.keep('claude', 's2', entry({ keptAt: 5, keptVia: 'claude:s1' })) + index.releaseCarried('claude', 's2', 4) + expect(index.get('claude', 's2')).toEqual(entry({ keptAt: 5, keptVia: 'claude:s1' })) + + index.releaseCarried('claude', 's2', 5) + expect(open().get('claude', 's2')).toBeNull() + }) + + it('reads back where a keep was carried from, and ignores a malformed origin', () => { + writeFileSync( + filePath, + JSON.stringify({ + version: 1, + conversations: { + 'claude:s2': entry({ keptVia: 'claude:s1' }), + 'claude:s3': { ...entry(), keptVia: 'not a key' } + } + }) + ) + const index = open() + expect(index.get('claude', 's2')).toEqual(entry({ keptVia: 'claude:s1' })) + expect(index.get('claude', 's3')).toEqual(entry()) + }) + + it('reads a newer version as nothing kept, and never rewrites it', () => { + const newer = JSON.stringify({ version: 2, conversations: { 'claude:s1': entry() } }) + writeFileSync(filePath, newer) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const index = open() + + // Nothing is refused: conversations follow the selection as before anything was kept. + expect(index.get('claude', 's1')).toBeNull() + expect(index.acceptsChanges()).toBe(false) + index.keep('claude', 's2', entry()) + index.release('claude', 's1') + + expect(readFileSync(filePath, 'utf-8')).toBe(newer) + expect(readdirSync(dir)).toEqual(['agent-kept-conversations.json']) + expect(warn).toHaveBeenCalledTimes(1) + warn.mockRestore() + }) + + it('reports an unreadable file as unavailable, uncached, and merges a queued keep later', async () => { + vi.useFakeTimers() + const seed = open() + seed.keep('claude', 'earlier', entry()) + const persisted = readFileSync(filePath, 'utf-8') + rmSync(filePath) + // Why: a directory in the file's place fails the read with EISDIR, not absence. + mkdirSync(filePath) + + const index = open() + expect(index.get('claude', 'earlier')).toBe('unavailable') + index.keep('claude', 'queued', entry({ accountId: 'acc-b' })) + expect(statSync(filePath).isDirectory()).toBe(true) + + rmSync(filePath, { recursive: true }) + writeFileSync(filePath, persisted) + expect(index.get('claude', 'earlier')).toEqual(entry()) + await vi.advanceTimersByTimeAsync(100) + + const reloaded = open() + expect(reloaded.get('claude', 'earlier')).toEqual(entry()) + expect(reloaded.get('claude', 'queued')).toEqual(entry({ accountId: 'acc-b' })) + }) + + it('shows a keep and a release queued while the file was unreadable to the first read after', () => { + vi.useFakeTimers() + const seed = open() + seed.keep('claude', 'earlier', entry()) + const persisted = readFileSync(filePath, 'utf-8') + rmSync(filePath) + mkdirSync(filePath) + + const index = open() + expect(index.get('claude', 'earlier')).toBe('unavailable') + index.keep('claude', 'queued', entry({ accountId: 'acc-b' })) + index.release('claude', 'earlier') + + rmSync(filePath, { recursive: true }) + writeFileSync(filePath, persisted) + // Why no timer advance: the retry has not fired, so only this read can apply the queue. + expect(index.get('claude', 'queued')).toEqual(entry({ accountId: 'acc-b' })) + expect(index.get('claude', 'earlier')).toBeNull() + }) + + it('persists a mutation queued past every retry once a read finds the file readable', async () => { + vi.useFakeTimers() + mkdirSync(filePath) + + const index = open() + index.keep('claude', 'queued', entry()) + await vi.advanceTimersByTimeAsync(100 + 1_000 + 5_000 + 30_000) + rmSync(filePath, { recursive: true }) + await vi.advanceTimersByTimeAsync(60_000) + expect(existsSync(filePath)).toBe(false) + + expect(index.get('claude', 'queued')).toEqual(entry()) + expect(open().get('claude', 'queued')).toEqual(entry()) + }) + + it('sets a corrupt file aside and starts empty', () => { + writeFileSync(filePath, '{ not json') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + expect(open().get('claude', 's1')).toBeNull() + + expect(readdirSync(dir).some((name) => name.includes('.corrupt-'))).toBe(true) + expect(warn).toHaveBeenCalledTimes(1) + warn.mockRestore() + }) + + it('drops malformed entries and keeps their valid siblings', () => { + writeFileSync( + filePath, + JSON.stringify({ + version: 1, + conversations: { + 'claude:good': entry(), + 'claude:bad-id': { ...entry(), accountId: '../x' }, + 'claude:bad-lane': { ...entry(), lane: 'Ubuntu' }, + 'gemini:other': entry() + } + }) + ) + const index = open() + expect(index.get('claude', 'good')).toEqual(entry()) + expect(index.get('claude', 'bad-id')).toBeNull() + expect(index.get('claude', 'bad-lane')).toBeNull() + }) + + it('keeps every kept conversation, however many there are', () => { + const index = open() + for (let n = 0; n < 5_000; n += 1) { + index.keep('codex', `s${n}`, entry({ keptAt: n })) + } + const reloaded = open() + expect(reloaded.get('codex', 's0')).toEqual(entry({ keptAt: 0 })) + expect(reloaded.get('codex', 's4999')).toEqual(entry({ keptAt: 4999 })) + }) + + it('prunes only old keeps whose transcript is definitively gone', async () => { + const live = join(dir, 'live.jsonl') + writeFileSync(live, '') + const loop = join(dir, 'loop.jsonl') + // Why: a self-referencing link fails stat with ELOOP, which is not proof of absence. + symlinkSync(loop, loop) + const old = NOW - 8 * DAY_MS + const index = open() + index.keep('claude', 'gone-old', entry({ keptAt: old, transcriptPath: join(dir, 'gone') })) + index.keep( + 'claude', + 'gone-new', + entry({ keptAt: NOW - DAY_MS, transcriptPath: join(dir, 'x') }) + ) + index.keep('claude', 'live-old', entry({ keptAt: old, transcriptPath: live })) + index.keep('claude', 'loop-old', entry({ keptAt: old, transcriptPath: loop })) + index.keep('codex', 'no-path', entry({ keptAt: old })) + + await index.pruneUnreachable() + + const reloaded = open() + expect(reloaded.get('claude', 'gone-old')).toBeNull() + expect(reloaded.get('claude', 'gone-new')).not.toBeNull() + expect(reloaded.get('claude', 'live-old')).not.toBeNull() + expect(reloaded.get('claude', 'loop-old')).not.toBeNull() + expect(reloaded.get('codex', 'no-path')).not.toBeNull() + }) +}) diff --git a/src/main/agent-accounts/kept-agent-conversations.ts b/src/main/agent-accounts/kept-agent-conversations.ts new file mode 100644 index 000000000000..f7ce374aee74 --- /dev/null +++ b/src/main/agent-accounts/kept-agent-conversations.ts @@ -0,0 +1,300 @@ +import { mkdirSync, readFileSync, renameSync } from 'node:fs' +import { stat } from 'node:fs/promises' +import { dirname } from 'node:path' +import { isAgentRunAccountId, type AgentRunAccountProvider } from '../../shared/agent-run-account' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { PersistRetryBackoff } from '../persist-retry-backoff' + +/** A conversation kept on an account, by a person or carried on from one they kept. */ +export type KeptConversation = { + lane: string + accountId: string | null + keptAt: number + transcriptPath?: string + /** Key of the person's keep this one carried on from; absent on the person's own keep. */ + keptVia?: string +} + +type KeptConversationsFile = { + version: 1 + conversations: Record +} + +type Mutation = + | { kind: 'keep'; key: string; entry: KeptConversation } + | { kind: 'release'; key: string; keptAt?: number } + | { kind: 'release-lineage'; key: string } + | { kind: 'release-account'; provider: AgentRunAccountProvider; accountId: string } + +const PRUNE_MIN_AGE_MS = 7 * 24 * 60 * 60 * 1000 + +const CONVERSATION_KEY = /^(claude|codex):./ + +export function keptConversationKey(provider: AgentRunAccountProvider, sessionId: string): string { + return `${provider}:${sessionId}` +} + +function readEntry(value: unknown): KeptConversation | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const lane = 'lane' in value ? value.lane : undefined + const accountId = 'accountId' in value ? value.accountId : undefined + const keptAt = 'keptAt' in value ? value.keptAt : undefined + const transcriptPath = 'transcriptPath' in value ? value.transcriptPath : undefined + const keptVia = 'keptVia' in value ? value.keptVia : undefined + if ( + (lane !== 'host' && !(typeof lane === 'string' && /^wsl:.{1,256}$/.test(lane))) || + (accountId !== null && !isAgentRunAccountId(accountId)) || + typeof keptAt !== 'number' || + !Number.isFinite(keptAt) + ) { + return null + } + return { + lane, + accountId, + keptAt, + ...(typeof transcriptPath === 'string' && transcriptPath.length <= 4096 + ? { transcriptPath } + : {}), + ...(typeof keptVia === 'string' && keptVia.length <= 600 && CONVERSATION_KEY.test(keptVia) + ? { keptVia } + : {}) + } +} + +/** 'unsupported' = another build's version, which this one must neither read nor overwrite. */ +function parseFile(raw: string): Map | 'corrupt' | 'unsupported' { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return 'corrupt' + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return 'corrupt' + } + if (!('version' in parsed) || parsed.version !== 1) { + return 'unsupported' + } + const conversations = 'conversations' in parsed ? parsed.conversations : undefined + if (!conversations || typeof conversations !== 'object' || Array.isArray(conversations)) { + return 'corrupt' + } + const entries = new Map() + for (const [key, value] of Object.entries(conversations)) { + const entry = readEntry(value) + if (entry && CONVERSATION_KEY.test(key)) { + entries.set(key, entry) + } + } + return entries +} + +/** Conversations kept on an account, by session id; an unreadable file is 'unavailable', never empty. */ +export class KeptAgentConversations { + private entries: Map | null = null + private readonly queue: Mutation[] = [] + private needsWrite = false + private readonly retry = new PersistRetryBackoff(() => this.flush()) + private reportedCorruption = false + /** A newer build's file: read as empty and never written, so neither build loses the other's keeps. */ + private readOnly = false + + constructor(private readonly deps: { filePath: string; now?: () => number }) {} + + get filePath(): string { + return this.deps.filePath + } + + /** False while the file belongs to a newer Orca, whose keeps this build must not rewrite. */ + acceptsChanges(): boolean { + this.load() + return !this.readOnly + } + + get( + provider: AgentRunAccountProvider, + sessionId: string + ): KeptConversation | null | 'unavailable' { + const entries = this.load() + // Why: a mutation queued while the file was unreadable must show in the first read after. + if (entries && this.queue.length > 0) { + this.flush() + } + return entries ? (entries.get(keptConversationKey(provider, sessionId)) ?? null) : 'unavailable' + } + + keep(provider: AgentRunAccountProvider, sessionId: string, entry: KeptConversation): void { + this.queue.push({ kind: 'keep', key: keptConversationKey(provider, sessionId), entry }) + this.flush() + } + + /** Releases a kept conversation and every session that carried its keep on. */ + release(provider: AgentRunAccountProvider, sessionId: string): void { + this.queue.push({ kind: 'release-lineage', key: keptConversationKey(provider, sessionId) }) + this.flush() + } + + /** Releases one carried-on keep, unless it was kept again since `keptAt`. */ + releaseCarried(provider: AgentRunAccountProvider, sessionId: string, keptAt: number): void { + this.queue.push({ kind: 'release', key: keptConversationKey(provider, sessionId), keptAt }) + this.flush() + } + + /** Releases every conversation kept on an account; returns how many were released now. */ + releaseAccount(provider: AgentRunAccountProvider, accountId: string): number { + const before = this.entries?.size ?? 0 + this.queue.push({ kind: 'release-account', provider, accountId }) + this.flush() + return this.entries && this.queue.length === 0 ? before - this.entries.size : 0 + } + + /** Drops keeps whose transcript is definitively gone, since they can no longer resume. */ + async pruneUnreachable(): Promise { + const entries = this.load() + if (!entries) { + return + } + const cutoff = (this.deps.now ?? Date.now)() - PRUNE_MIN_AGE_MS + for (const [key, entry] of entries) { + if (!entry.transcriptPath || entry.keptAt >= cutoff) { + continue + } + try { + // Why one at a time: this runs at startup, where a burst of stats would compete with launch. + await stat(entry.transcriptPath) + } catch (error) { + if (isDefinitiveAbsence(error)) { + // Why keptAt: a keep made while this stat was pending is newer and must stay. + this.queue.push({ kind: 'release', key, keptAt: entry.keptAt }) + } + } + } + if (this.queue.length > 0) { + this.flush() + } + } + + private load(): Map | null { + if (this.entries) { + return this.entries + } + let raw: string + try { + raw = readFileSync(this.deps.filePath, 'utf-8') + } catch (error) { + if (!isDefinitiveAbsence(error)) { + return null + } + this.entries = new Map() + return this.entries + } + const parsed = parseFile(raw) + if (parsed === 'unsupported') { + console.warn( + `[kept-conversations] ${this.deps.filePath} is from a newer Orca; keeps are read-only` + ) + this.readOnly = true + this.entries = new Map() + return this.entries + } + if (parsed === 'corrupt') { + return this.setAsideUnreadableFile() + } + this.entries = parsed + return parsed + } + + // Why rename, not overwrite: the bytes stay on disk for anyone who needs them back. + private setAsideUnreadableFile(): Map | null { + try { + renameSync(this.deps.filePath, `${this.deps.filePath}.corrupt-${Date.now()}`) + } catch (error) { + if (!isDefinitiveAbsence(error)) { + return null + } + } + if (!this.reportedCorruption) { + this.reportedCorruption = true + console.warn('[kept-conversations] Set aside an unreadable kept-conversations file') + } + this.entries = new Map() + return this.entries + } + + private flush(): void { + const entries = this.load() + if (!entries) { + this.retry.schedule() + return + } + if (this.readOnly) { + this.queue.length = 0 + return + } + for (const mutation of this.queue.splice(0)) { + this.needsWrite = applyMutation(entries, mutation) || this.needsWrite + } + if (!this.needsWrite) { + this.retry.clear() + return + } + try { + mkdirSync(dirname(this.deps.filePath), { recursive: true }) + const file: KeptConversationsFile = { + version: 1, + conversations: Object.fromEntries(entries) + } + writeFileAtomically(this.deps.filePath, `${JSON.stringify(file)}\n`, { mode: 0o600 }) + this.needsWrite = false + this.retry.clear() + } catch (error) { + console.warn('[kept-conversations] Failed to persist kept conversations:', error) + this.retry.schedule() + } + } +} + +function applyMutation(entries: Map, mutation: Mutation): boolean { + if (mutation.kind === 'keep') { + const existing = entries.get(mutation.key) + if ( + existing?.lane === mutation.entry.lane && + existing.accountId === mutation.entry.accountId && + existing.transcriptPath === mutation.entry.transcriptPath && + existing.keptVia === mutation.entry.keptVia + ) { + return false + } + entries.set(mutation.key, mutation.entry) + return true + } + if (mutation.kind === 'release') { + const existing = entries.get(mutation.key) + return mutation.keptAt === undefined || existing?.keptAt === mutation.keptAt + ? entries.delete(mutation.key) + : false + } + if (mutation.kind === 'release-lineage') { + const root = entries.get(mutation.key)?.keptVia ?? mutation.key + let changed = entries.delete(mutation.key) + for (const [key, entry] of entries) { + if (key === root || entry.keptVia === root) { + entries.delete(key) + changed = true + } + } + return changed + } + let changed = false + for (const [key, entry] of entries) { + if (key.startsWith(`${mutation.provider}:`) && entry.accountId === mutation.accountId) { + entries.delete(key) + changed = true + } + } + return changed +} diff --git a/src/main/agent-accounts/kept-codex-home.ts b/src/main/agent-accounts/kept-codex-home.ts new file mode 100644 index 000000000000..abdf4cafbe38 --- /dev/null +++ b/src/main/agent-accounts/kept-codex-home.ts @@ -0,0 +1,64 @@ +import type { GlobalSettings } from '../../shared/global-settings-types' +import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' +import { keptAccountRefusals } from './kept-account-refusals' +import { getKeptAgentConversations } from './agent-run-accounts-installed' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' + +export type KeptCodexHomeRuntime = { + resolveHostAccountCodexHomePathForKeptResume: (accountId: string) => string +} + +export function codexAccountLabel( + settings: Pick | undefined, + accountId: string | null +): string { + if (accountId === null) { + return 'System default' + } + return ( + settings?.codexManagedAccounts.find((account) => account.id === accountId)?.email ?? + 'a removed account' + ) +} + +/** The CODEX_HOME a kept conversation resumes in; refuses rather than run elsewhere or copy credentials. */ +export function resolveKeptCodexHome(args: { + accountId: string | null + runtimeHome: KeptCodexHomeRuntime + systemCodexHomePath: string + launchEnv?: NodeJS.ProcessEnv +}): string { + if (args.accountId !== null) { + return args.runtimeHome.resolveHostAccountCodexHomePathForKeptResume(args.accountId) + } + if (hasCustomCodexHomeOverrideForLaunch(args.launchEnv)) { + throw keptAccountRefusals.codexSystemDefaultWithCustomHome() + } + return args.systemCodexHomePath +} + +/** Session History's view of a kept Codex conversation: its home, or undefined when not kept. */ +export function resolveKeptCodexHomeForSession( + sessionId: string, + runtimeHome: KeptCodexHomeRuntime | null | undefined +): string | undefined { + const kept = getKeptAgentConversations() + if (!kept) { + return undefined + } + const entry = kept.get('codex', sessionId) + if (entry === 'unavailable') { + throw keptAccountRefusals.indexUnavailable(kept.filePath) + } + if (!entry) { + return undefined + } + if (entry.lane !== 'host' || !runtimeHome) { + throw keptAccountRefusals.laneMismatch() + } + return resolveKeptCodexHome({ + accountId: entry.accountId, + runtimeHome, + systemCodexHomePath: getSystemCodexHomePath() + }) +} diff --git a/src/main/agent-hooks/server-claude-statusline.test.ts b/src/main/agent-hooks/server-claude-statusline.test.ts index b40edb397d22..6fdbd1ae8222 100644 --- a/src/main/agent-hooks/server-claude-statusline.test.ts +++ b/src/main/agent-hooks/server-claude-statusline.test.ts @@ -74,4 +74,35 @@ describe('AgentHookServer /statusline/claude', () => { expect(events).toEqual([]) }) + + it('tells pane listeners which folder a pane reports from, alias-resolved, usage or not', async () => { + const physical = 'tab-old:11111111-1111-4111-8111-111111111111' + const stable = 'tab-new:22222222-2222-4222-8222-222222222222' + server.transferPaneAuthority(physical, stable, 'pty-1') + const panes: { paneKey: string; configDir: string | null }[] = [] + const usage: ClaudeStatusLineRateLimits[] = [] + server.subscribeClaudeStatusLinePanes((event) => panes.push(event)) + server.setClaudeStatusLineListener((event) => usage.push(event)) + + const withUsage = new URLSearchParams({ + paneKey: physical, + configDir: '/home/dev/managed', + payload: JSON.stringify({ rate_limits: { five_hour: { used_percentage: 1 } } }) + }).toString() + const withoutUsage = new URLSearchParams({ + paneKey: stable, + configDir: '', + payload: JSON.stringify({ context_window: { used_percentage: 8 } }) + }).toString() + await expect(post(withUsage)).resolves.toMatchObject({ status: 204 }) + await expect(post(withoutUsage)).resolves.toMatchObject({ status: 204 }) + await expect(post('paneKey=not-a-pane-key&payload={}')).resolves.toMatchObject({ status: 204 }) + + expect(panes).toEqual([ + { paneKey: stable, configDir: '/home/dev/managed' }, + { paneKey: stable, configDir: null } + ]) + expect(usage).toHaveLength(1) + expect(usage[0]).not.toHaveProperty('paneKey') + }) }) diff --git a/src/main/agent-hooks/server-last-status-hydrate-validation.test.ts b/src/main/agent-hooks/server-last-status-hydrate-validation.test.ts index 3ad27b64a386..11b20339e096 100644 --- a/src/main/agent-hooks/server-last-status-hydrate-validation.test.ts +++ b/src/main/agent-hooks/server-last-status-hydrate-validation.test.ts @@ -435,6 +435,48 @@ describe('Last-status persistence', () => { } }) + it('hydrates a valid run-account facet and drops an invalid or relay one', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + const receivedAt = recentTs() + const row = (paneKey: string, tabId: string, extra: Record) => ({ + paneKey, + tabId, + receivedAt, + stateStartedAt: receivedAt, + payload: { state: 'done', prompt: 'kept', agentType: 'codex' }, + ...extra + }) + const facet = { provider: 'codex', lane: 'host', accountId: 'account-a', mode: 'kept' } + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + [PANE]: row(PANE, 'tab-1', { runAccount: facet }), + [GOOD_PANE]: row(GOOD_PANE, 'tab-good', { runAccount: { ...facet, mode: 'pinned' } }), + [OLD_PANE]: row(OLD_PANE, 'tab-old', { runAccount: { ...facet, accountId: '../x' } }), + [FRESH_PANE]: row(FRESH_PANE, 'tab-fresh', { + connectionId: 'ssh-1', + runAccount: facet + }) + } + }), + 'utf8' + ) + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const byPane = new Map(server.getStatusSnapshot().map((entry) => [entry.paneKey, entry])) + expect(byPane.get(PANE)?.runAccount).toEqual(facet) + for (const paneKey of [GOOD_PANE, OLD_PANE, FRESH_PANE]) { + expect(byPane.get(paneKey)).toBeDefined() + expect(byPane.get(paneKey)).not.toHaveProperty('runAccount') + } + } finally { + server.stop() + } + }) + it('stop() flushes pending debounced writes synchronously', async () => { const server = new AgentHookServer() await server.start({ diff --git a/src/main/agent-hooks/server-run-account.test.ts b/src/main/agent-hooks/server-run-account.test.ts new file mode 100644 index 000000000000..ab94e070c98e --- /dev/null +++ b/src/main/agent-hooks/server-run-account.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import type { AgentRunAccount } from '../../shared/agent-run-account' +import { selectRuntimeHookAgentRowForPane } from '../runtime/runtime-mobile-agent-status-projection' + +const PANE_KEY = 'tab-run:33333333-3333-4333-8333-333333333333' +const MOVED_PANE_KEY = 'tab-moved:44444444-4444-4444-8444-444444444444' + +const KEPT: AgentRunAccount = { + provider: 'codex', + lane: 'host', + accountId: 'account-a', + mode: 'kept' +} + +function ingest(server: AgentHookServer, overrides: Record = {}): void { + server.ingestTerminalStatus({ + paneKey: PANE_KEY, + tabId: 'tab-run', + worktreeId: 'worktree', + connectionId: null, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' }, + ...overrides + }) +} + +class ProbeServer extends AgentHookServer { + persistRequests = 0 + protected override scheduleStatusPersist(): void { + this.persistRequests += 1 + super.scheduleStatusPersist() + } + serialized(): string { + return this.serializeStatusFile() + } +} + +function row(server: AgentHookServer, paneKey = PANE_KEY) { + return server.getStatusSnapshot().find((entry) => entry.paneKey === paneKey) +} + +describe('run-account facet on status rows', () => { + it('restates only the facet: same clocks, persisted, renderer only', () => { + const server = new ProbeServer() + ingest(server) + const before = row(server)! + const renderer = vi.fn() + server.setListener(renderer) + renderer.mockClear() + const enriched = vi.fn() + const statusChanges = vi.fn() + const mutations = vi.fn() + server.subscribeEnrichedStatus(enriched) + server.subscribeStatusChanges(statusChanges) + server.subscribeStatusRowMutations(mutations) + const persistRequestsBefore = server.persistRequests + + expect(server.republishRunAccount(PANE_KEY, KEPT)).toBe(true) + + const after = row(server)! + expect(after.runAccount).toEqual(KEPT) + expect(after.receivedAt).toBe(before.receivedAt) + expect(after.stateStartedAt).toBe(before.stateStartedAt) + expect(after.state).toBe('working') + expect(server.persistRequests - persistRequestsBefore).toBe(1) + expect(renderer).toHaveBeenCalledTimes(1) + expect(renderer.mock.calls[0]![0]).toMatchObject({ + paneKey: PANE_KEY, + runAccount: KEPT, + receivedAt: before.receivedAt, + stateStartedAt: before.stateStartedAt, + isReplay: true, + runAccountOnly: true + }) + expect(enriched).not.toHaveBeenCalled() + expect(statusChanges).not.toHaveBeenCalled() + expect(mutations).not.toHaveBeenCalled() + expect(JSON.parse(server.serialized()).entries[PANE_KEY].runAccount).toEqual(KEPT) + expect(JSON.parse(server.serialized()).entries[PANE_KEY]).not.toHaveProperty('runAccountOnly') + }) + + it('is a no-op for an equal facet, an unknown pane and a relay row', () => { + const server = new AgentHookServer() + ingest(server) + expect(server.republishRunAccount(PANE_KEY, KEPT)).toBe(true) + const renderer = vi.fn() + server.setListener(renderer) + renderer.mockClear() + + expect(server.republishRunAccount(PANE_KEY, { ...KEPT })).toBe(false) + expect(server.republishRunAccount('tab-none:55555555-5555-4555-8555-555555555555', KEPT)).toBe( + false + ) + ingest(server, { paneKey: MOVED_PANE_KEY, tabId: 'tab-moved', connectionId: 'ssh-1' }) + renderer.mockClear() + expect(server.republishRunAccount(MOVED_PANE_KEY, KEPT)).toBe(false) + expect(renderer).not.toHaveBeenCalled() + }) + + it('withdraws a facet with an explicit null the renderer can see', () => { + const server = new AgentHookServer() + ingest(server) + server.republishRunAccount(PANE_KEY, KEPT) + + expect(server.republishRunAccount(PANE_KEY, null)).toBe(true) + expect(row(server)?.runAccount).toBeNull() + expect(server.getRunAccountRow(PANE_KEY)?.runAccount).toBeUndefined() + }) + + it('keeps the facet across later rows of the same agent', () => { + const server = new AgentHookServer() + ingest(server) + server.republishRunAccount(PANE_KEY, KEPT) + + ingest(server, { payload: { state: 'done', prompt: 'ship it', agentType: 'codex' } }) + server.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-run', + worktreeId: 'worktree', + providerSession: { key: 'session_id', id: 'session-1' }, + payload: { state: 'working', prompt: 'next', agentType: 'codex' } + }, + null + ) + + expect(row(server)).toMatchObject({ state: 'working', runAccount: KEPT }) + }) + + it('drops the facet when another agent takes the pane', () => { + const server = new AgentHookServer() + ingest(server) + server.republishRunAccount(PANE_KEY, KEPT) + ingest(server, { payload: { state: 'done', prompt: 'ship it', agentType: 'codex' } }) + expect(row(server)?.runAccount).toEqual(KEPT) + + ingest(server, { payload: { state: 'working', prompt: 'hi', agentType: 'claude' } }) + + expect(row(server)?.agentType).toBe('claude') + expect(row(server)).not.toHaveProperty('runAccount') + }) + + it('moves with the row when the pane moves to another tab', () => { + const server = new AgentHookServer() + ingest(server) + server.republishRunAccount(PANE_KEY, KEPT) + + server.transferPaneAuthority(PANE_KEY, MOVED_PANE_KEY, 'pty-1') + + expect(row(server, MOVED_PANE_KEY)?.runAccount).toEqual(KEPT) + expect(server.getRunAccountRow(PANE_KEY)).toMatchObject({ + paneKey: MOVED_PANE_KEY, + runAccount: KEPT + }) + }) + + it('reaches the host snapshot but not the paired-client projection', () => { + const server = new AgentHookServer() + ingest(server) + server.republishRunAccount(PANE_KEY, KEPT) + + const snapshot = server.getStatusSnapshot() + expect(snapshot[0]?.runAccount).toEqual(KEPT) + const projected = selectRuntimeHookAgentRowForPane(snapshot) + expect(projected.live?.payload.state).toBe('working') + expect(JSON.stringify(projected)).not.toContain('account-a') + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 5c88dfd62d8c..45568fc91082 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -4,7 +4,7 @@ import { normalizeHookPayload } from '../../shared/agent-hook-listener' import { parseFormEncodedBody } from '../../shared/agent-hook-listener/request-body' import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import type { AgentHookSource } from '../../shared/agent-hook-relay' -import { AgentHookServerAgentPresence } from './server/server-agent-presence' +import { AgentHookServerRunAccount } from './server/server-run-account' import { isValidPaneKey } from './server/server-status-identity' export type { @@ -17,6 +17,7 @@ export type { EnrichedAgentHookEventPayload } from './server/server-types' export type { AgentHookSource } +export type { AgentRunAccountRow } from './server/server-run-account' export { CLOSED_AGENT_STATUS_TAB_IDS_MAX, CLOSED_AGENT_STATUS_PANE_KEYS_MAX, @@ -26,7 +27,7 @@ export { export { isValidPaneKey } /** Public composition seam for the loopback hook listener and relay status adapter. */ -export class AgentHookServer extends AgentHookServerAgentPresence {} +export class AgentHookServer extends AgentHookServerRunAccount {} export const agentHookServer = new AgentHookServer() diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 95835546c825..d26998f4a7cd 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -28,6 +28,9 @@ type HookServerStartOptions = { export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle { private readonly startup = new AgentHookStartup() + /** Which account folder a pane's Claude reports from; the run-account layer listens. */ + protected noteClaudeStatusLinePane(_body: unknown): void {} + /** Start the loopback listener after hydration and spool replay have settled. */ start(options?: HookServerStartOptions): Promise { return this.startup.start((isCurrent) => this.startListener(options, isCurrent)) @@ -118,6 +121,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook if (statusLineEvent) { this.onClaudeStatusLine?.(statusLineEvent) } + this.noteClaudeStatusLinePane(body) res.writeHead(204) res.end() return diff --git a/src/main/agent-hooks/server/server-persistence-validation.ts b/src/main/agent-hooks/server/server-persistence-validation.ts index c4913e29c0e7..7880679d1d7d 100644 --- a/src/main/agent-hooks/server/server-persistence-validation.ts +++ b/src/main/agent-hooks/server/server-persistence-validation.ts @@ -2,6 +2,7 @@ import { readAgentProcessPresence } from '../../../shared/agent-process-presence import { createHash } from 'node:crypto' import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume' +import { readAgentRunAccount } from '../../../shared/agent-run-account' import { normalizeAgentStatusPayload, type AgentMainAgentStatus, @@ -151,6 +152,8 @@ export function sanitizeHydratedEntry( ? record.compactTrigger : undefined const turnStartedAt = record.turnStartedAt + // Why local only: a facet describes this host's accounts and never rides a relay row. + const runAccount = connectionId === null ? readAgentRunAccount(record.runAccount) : undefined return { paneKey, agentPresence: readAgentProcessPresence(record.agentPresence), @@ -176,7 +179,8 @@ export function sanitizeHydratedEntry( stateStartedAt, ...(typeof turnStartedAt === 'number' && Number.isFinite(turnStartedAt) && turnStartedAt > 0 ? { turnStartedAt } - : {}) + : {}), + ...(runAccount ? { runAccount } : {}) } } diff --git a/src/main/agent-hooks/server/server-row-ownership.ts b/src/main/agent-hooks/server/server-row-ownership.ts index a1836e5777f5..3b1540d3a45f 100644 --- a/src/main/agent-hooks/server/server-row-ownership.ts +++ b/src/main/agent-hooks/server/server-row-ownership.ts @@ -30,6 +30,8 @@ function semanticRow(row: EnrichedAgentHookEventPayload): Record +): Pick { + if (incoming.runAccount !== undefined) { + return { runAccount: incoming.runAccount } + } + return previous?.runAccount !== undefined && + previous.payload.agentType === incoming.payload.agentType && + previous.connectionId === incoming.connectionId + ? { runAccount: previous.runAccount } + : {} +} diff --git a/src/main/agent-hooks/server/server-run-account.ts b/src/main/agent-hooks/server/server-run-account.ts new file mode 100644 index 000000000000..0c83c7b8a6b8 --- /dev/null +++ b/src/main/agent-hooks/server/server-run-account.ts @@ -0,0 +1,121 @@ +import { agentRunAccountsEqual, type AgentRunAccount } from '../../../shared/agent-run-account' +import type { AgentProviderSessionMetadata } from '../../../shared/agent-session-resume' +import type { AgentType } from '../../../shared/agent-status-types' +import { + parseClaudeStatusLinePane, + type ClaudeStatusLinePane +} from '../../../shared/claude-statusline-rate-limits' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { AgentHookServerAgentPresence } from './server-agent-presence' + +export type AgentRunAccountRow = { + paneKey: string + agentType: AgentType | undefined + providerSession: AgentProviderSessionMetadata | undefined + runAccount: AgentRunAccount | undefined + connectionId: string | null +} + +export abstract class AgentHookServerRunAccount extends AgentHookServerAgentPresence { + private readonly statusLinePaneListeners = new Set<(event: ClaudeStatusLinePane) => void>() + + /** Statusline posts name the folder the pane's Claude runs in, with or without usage. */ + subscribeClaudeStatusLinePanes(listener: (event: ClaudeStatusLinePane) => void): () => void { + this.statusLinePaneListeners.add(listener) + return () => { + this.statusLinePaneListeners.delete(listener) + } + } + + protected override noteClaudeStatusLinePane(body: unknown): void { + const event = this.statusLinePaneListeners.size > 0 ? parseClaudeStatusLinePane(body) : null + if (!event) { + return + } + const resolved = { ...event, paneKey: this.resolvePaneKeyAlias(event.paneKey) } + for (const listener of this.statusLinePaneListeners) { + try { + listener(resolved) + } catch (error) { + console.error('[agent-hooks] statusline pane listener threw', error) + } + } + } + + /** Restates a local row with a new facet (`null` withdraws it); nothing but the facet changes. */ + republishRunAccount(paneKey: string, runAccount: AgentRunAccount | null): boolean { + const resolved = this.resolvePaneKeyAlias(paneKey) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type. + const previous = this.state.lastStatusByPaneKey.get(resolved) as + | EnrichedAgentHookEventPayload + | undefined + if (!previous || previous.connectionId !== null || previous.structuredHost) { + return false + } + if (agentRunAccountsEqual(previous.runAccount, runAccount)) { + return false + } + const next: EnrichedAgentHookEventPayload = { ...previous, runAccount } + if (!this.writeLegacyStatusRow(next)) { + return false + } + this.commitStatusRowMutation(previous, next) + this.scheduleStatusPersist() + // Why flagged: the status did not change, so no title, bell, telemetry or first-work effect may run. + this.onAgentStatus?.({ ...next, isReplay: true, runAccountOnly: true }) + return true + } + + /** A retirement that keeps the pane's resume identity clears the renderer's row first; restate + * the facet the kept identity row still carries, or a kept agent whose terminal died could no + * longer be un-kept from the tab menu — the way out its refused resume points to. */ + override reconcileEndedProcessForPaneKeys( + paneKeys: Iterable, + options?: Parameters[1] + ): number { + const requested = [...paneKeys] + const rowsBefore = new Map( + requested.map((paneKey) => { + const resolved = this.resolvePaneKeyAlias(paneKey) + return [resolved, this.state.lastStatusByPaneKey.get(resolved)] as const + }) + ) + const cleared = super.reconcileEndedProcessForPaneKeys(requested, options) + for (const [paneKey, before] of rowsBefore) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type. + const retained = this.state.lastStatusByPaneKey.get(paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if ( + retained && + retained !== before && + retained.providerSessionOnly === true && + retained.runAccount !== undefined && + retained.connectionId === null && + !retained.structuredHost + ) { + // Why flagged like republishRunAccount: only the facet is news; no status changed. + this.onAgentStatus?.({ ...retained, isReplay: true, runAccountOnly: true }) + } + } + return cleared + } + + getRunAccountRow(paneKey: string): AgentRunAccountRow | null { + const resolved = this.resolvePaneKeyAlias(paneKey) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type. + const row = this.state.lastStatusByPaneKey.get(resolved) as + | EnrichedAgentHookEventPayload + | undefined + if (!row || row.structuredHost) { + return null + } + return { + paneKey: resolved, + agentType: row.payload.agentType, + providerSession: row.providerSession, + runAccount: row.runAccount ?? undefined, + connectionId: row.connectionId + } + } +} diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts index be824aaf09ba..7c5e4ffb1c95 100644 --- a/src/main/agent-hooks/server/server-status-identity.ts +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -68,6 +68,7 @@ export function toAgentStatusIpcPayload( ...(entry.observation ? { observation: entry.observation } : {}), ...(entry.structuredHost ? { structuredHost: entry.structuredHost } : {}), ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}), + ...(entry.runAccount !== undefined ? { runAccount: entry.runAccount } : {}), ...entry.payload } } diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index c80a70dfe1a6..4ec4000929ae 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -21,6 +21,7 @@ import { import { isStaleGrokTurnEnd } from './server-grok-status-rules' import { resolveCancelVerdictLatch } from './server-cancel-verdict-latch' import { AgentHookServerStatusApplication } from './server-status-application' +import { carriedRunAccount } from './server-run-account-carry' import { claimsPaneAfterExit } from './agent-exit-evidence' export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusApplication { @@ -270,7 +271,8 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA : { ...unpairedPayload, claudeRunningNonAgentTask: runningNonAgentTask } const enriched = { ...this.attachStatusTiming(pairedPayload, now, observedAt), - observation: this.stampObservation(pairedPayload, origin, observedAt ?? now) + observation: this.stampObservation(pairedPayload, origin, observedAt ?? now), + ...carriedRunAccount(previous, pairedPayload) } if ( typeof enriched.payload.turnCompletedAt === 'number' && diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index 66324e3cfd40..b6c5d479d97a 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -1,4 +1,5 @@ import type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits' +import type { AgentRunAccount } from '../../../shared/agent-run-account' import type { PersistedAgentExitEvidence } from './agent-exit-evidence' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import type { @@ -29,6 +30,11 @@ export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { restoredUnconfirmed?: true /** User-hidden resume identity retained solely for destructive liveness checks. */ retainedForLiveness?: true + /** The account a local agent runs on; only the host's run-account producer writes it. + * `null` = a facet the host published was withdrawn. */ + runAccount?: AgentRunAccount | null + /** Only on a facet restatement handed to the window listener; never stored. */ + runAccountOnly?: true } // `claudeRunningNonAgentTask` is persisted on purpose: it is the one child-work fact the row's diff --git a/src/main/ai-vault/ai-vault-local-scan-progress.ts b/src/main/ai-vault/ai-vault-local-scan-progress.ts index 034a16303db3..a84a9cc164a6 100644 --- a/src/main/ai-vault/ai-vault-local-scan-progress.ts +++ b/src/main/ai-vault/ai-vault-local-scan-progress.ts @@ -59,9 +59,7 @@ export function startLocalAiVaultScanProgress( ): LocalAiVaultScanProgressHandle { const previous = latest.get(scanKey) const fallback = - previous?.stage === 'finished' - ? previous - : (currentScans.get(scanKey)?.fallback ?? null) + previous?.stage === 'finished' ? previous : (currentScans.get(scanKey)?.fallback ?? null) let current: AiVaultScanProgress = { scanId, stage: 'finding', diff --git a/src/main/ai-vault/ai-vault-scan-coordinator.test.ts b/src/main/ai-vault/ai-vault-scan-coordinator.test.ts index 209b4055f2c6..61392a5e700d 100644 --- a/src/main/ai-vault/ai-vault-scan-coordinator.test.ts +++ b/src/main/ai-vault/ai-vault-scan-coordinator.test.ts @@ -44,7 +44,10 @@ describe('AiVaultScanCoordinator', () => { }) }) await Promise.resolve() - expect(localAiVaultScanProgress('scope')).toMatchObject({ stage: 'finding', startedAt: 2_000 }) + expect(localAiVaultScanProgress('scope')).toMatchObject({ + stage: 'finding', + startedAt: 2_000 + }) expect(stages).not.toContain(null) finishPreparation?.(EMPTY_RESULT) await expect(Promise.all([first, replacement])).resolves.toEqual([EMPTY_RESULT, EMPTY_RESULT]) diff --git a/src/main/ai-vault/ai-vault-scan-progress-flow.test.ts b/src/main/ai-vault/ai-vault-scan-progress-flow.test.ts index 729bdc1717eb..8f40cb10b6f3 100644 --- a/src/main/ai-vault/ai-vault-scan-progress-flow.test.ts +++ b/src/main/ai-vault/ai-vault-scan-progress-flow.test.ts @@ -139,7 +139,10 @@ describe('Session History scan progress, scanner child to window', () => { // A failed refresh puts back the last finished scan rather than leaving the key empty. replacement.fail() - expect(localAiVaultScanProgress('scope')).toMatchObject({ scanId: 'earlier', stage: 'finished' }) + expect(localAiVaultScanProgress('scope')).toMatchObject({ + scanId: 'earlier', + stage: 'finished' + }) }) it('settles unfinished work when an interactive timeout retires the shared scanner', async () => { diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index 799f208224fa..954a235ff339 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -306,7 +306,10 @@ export async function forEachSessionFile( options.signal?.throwIfAborted() // Why: a gate refusal means the scan could not run, not that the tree is // empty — swallowing it would misreport a stalled distro as "no transcript". - if (error instanceof WslTranscriptFsError || (options.strictErrors && !isMissingSidecarError(error))) { + if ( + error instanceof WslTranscriptFsError || + (options.strictErrors && !isMissingSidecarError(error)) + ) { throw error } return diff --git a/src/main/ai-vault/session-scanner-scope-walk.test.ts b/src/main/ai-vault/session-scanner-scope-walk.test.ts index 90852c4b9243..81e3f730a978 100644 --- a/src/main/ai-vault/session-scanner-scope-walk.test.ts +++ b/src/main/ai-vault/session-scanner-scope-walk.test.ts @@ -220,7 +220,10 @@ it('uses a cold parsed latest Codex cwd after its unlisted row is evicted', asyn const scan = () => scanAiVaultSessions({ ...roots, platform: 'linux', limit: 1, scopePaths: ['/repo'] }) expect((await scan()).sessions.some((row) => row.filePath === path)).toBe(false) - const parsed = await parseAgentSessionFileCached({ agent: 'codex', file, codexHome: null }, 'linux') + const parsed = await parseAgentSessionFileCached( + { agent: 'codex', file, codexHome: null }, + 'linux' + ) expect(rows.get(path)?.session?.cwd).toBe('/repo') for (let index = 0; index <= MAX_UNLISTED_ENTRIES; index++) { storeSessionParseCacheEntry(`search-${index}`, { diff --git a/src/main/ai-vault/session-scanner-zcode-sources.ts b/src/main/ai-vault/session-scanner-zcode-sources.ts index a980aa5aab46..b39f99229ae6 100644 --- a/src/main/ai-vault/session-scanner-zcode-sources.ts +++ b/src/main/ai-vault/session-scanner-zcode-sources.ts @@ -34,7 +34,12 @@ export function zcodeDiscoveries( list(), scopeWalksAgentFiles('zcode', options.scopePaths) ? list(options.scopePaths) : undefined ]) - return { agent: 'zcode' as const, rootDir: dbPath, files, ...(scopeFiles ? { scopeFiles } : {}) } + return { + agent: 'zcode' as const, + rootDir: dbPath, + files, + ...(scopeFiles ? { scopeFiles } : {}) + } }) } diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index af415f5eddac..f6441d33f774 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -1,4 +1,8 @@ -import type { AiVaultListResult, AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' +import type { + AiVaultListResult, + AiVaultScanIssue, + AiVaultSession +} from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { withSpan } from '../observability/tracer' import { sessionSortTime } from './session-scanner-accumulator' diff --git a/src/main/ai-vault/session-scope-cwd.ts b/src/main/ai-vault/session-scope-cwd.ts index b8ae68b4dd7e..fad3287dc145 100644 --- a/src/main/ai-vault/session-scope-cwd.ts +++ b/src/main/ai-vault/session-scope-cwd.ts @@ -40,8 +40,7 @@ export const AI_VAULT_SCOPE_CWD_SOURCES: Record - record.type === 'session_meta' ? asRecord(record.payload)?.cwd : undefined, + cwdOf: (record) => (record.type === 'session_meta' ? asRecord(record.payload)?.cwd : undefined), // Its first record carries the base instructions (about 20 KB): one read, not two. firstReadBytes: 64 * 1024 }, @@ -102,7 +101,9 @@ export async function readTranscriptHeadCwd( const { cwdOf } = source let cwd: string | null = null const first = source.firstReadBytes ?? HEAD_READ_BYTES - for (const length of first < HEAD_WIDENED_READ_BYTES ? [first, HEAD_WIDENED_READ_BYTES] : [first]) { + for (const length of first < HEAD_WIDENED_READ_BYTES + ? [first, HEAD_WIDENED_READ_BYTES] + : [first]) { let head: Buffer try { head = await readTranscriptSlice(file.path, 0, length, 'scan', signal) diff --git a/src/main/antigravity/antigravity-acp-runtime-probe.ts b/src/main/antigravity/antigravity-acp-runtime-probe.ts index f8baff94aa8f..1b913e9751da 100644 --- a/src/main/antigravity/antigravity-acp-runtime-probe.ts +++ b/src/main/antigravity/antigravity-acp-runtime-probe.ts @@ -6,9 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { createAcpAgentConnection, type AcpAgentConnection } from '../acp/acp-agent-connection' import type { InitializeResponse } from '../acp/generated/acp-protocol.generated' -import { - runAgentModelCatalogSession -} from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner' +import { runAgentModelCatalogSession } from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner' import { ANTIGRAVITY_ACP_ENV, scrubAntigravityAcpEnvironment } from './antigravity-acp-environment' const PROBE_TIMEOUT_MS = 90_000 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 f9b0fe9c8f62..4727b338d545 100644 --- a/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts +++ b/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts @@ -39,6 +39,8 @@ const fsAllowances: Record = { 'claude-accounts/claude-profile-paths.ts': ['mkdirSync', 'writeFileAtomically'], // The which-account file. 'claude-accounts/claude-profile-router.ts': ['mkdirSync', 'rmSync', 'writeFileAtomically'], + // One which-account file per account, for agents kept on it; it names a folder, never a login. + 'claude-accounts/claude-profile-account-pointers.ts': ['mkdirSync', 'writeFileAtomically'], // Removing an account deletes its folder, links first. 'claude-accounts/claude-account-folder.ts': ['rmSync', 'rmdirSync', 'unlinkSync'], 'claude-accounts/claude-profile-prompt-history.ts': [ diff --git a/src/main/claude-accounts/claude-profile-account-pointers.test.ts b/src/main/claude-accounts/claude-profile-account-pointers.test.ts new file mode 100644 index 000000000000..27d3432f0959 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-account-pointers.test.ts @@ -0,0 +1,272 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ClaudeManagedAccount } from '../../shared/managed-account-types' +import type { ClaudeProfileSetupReport } from './claude-profile-setup' +import { claudeProfileMarkerPath, type ClaudeProfileDescriptor } from './claude-profile-paths' +import { + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, + CLAUDE_REMOVED_ACCOUNT_POINTER +} from '../../shared/claude-profile-routing' +import { ClaudeProfileRouter, type ClaudeProfileRouterSettings } from './claude-profile-router' +import { + claudeTerminalEnvForAccount, + prepareClaudeLaunchForAccount, + removeClaudeAccountFolder +} from './claude-profile-account-route' + +vi.mock('../startup/login-shell-environment', () => ({ + resolveLoginShellEnvironment: () => Promise.resolve({}) +})) +// Why: removal deletes macOS Keychain items; the test must never reach the real Keychain. +vi.mock('../macos-keychain/generic-password', () => ({ + execSecurityCommand: async () => { + throw new Error('The specified item could not be found in the keychain.') + } +})) + +const roots: string[] = [] +afterEach(() => { + roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) +}) + +function account(id: string): ClaudeManagedAccount { + return { + id, + email: `${id}@example.com`, + authMethod: 'subscription-oauth', + managedAuthPath: '/unused-legacy', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'claude-account-pointers-')) + roots.push(root) + const userHome = join(root, 'personal') + const dataRoot = join(root, 'data') + mkdirSync(join(userHome, '.claude'), { recursive: true }) + const settings: ClaudeProfileRouterSettings = { + claudeManagedAccounts: [account('a'), account('b'), account('c')], + activeClaudeManagedAccountId: 'b', + activeClaudeManagedAccountIdsByRuntime: undefined, + agentStatusHooksEnabled: false, + disabledTuiAgents: [] + } + const setup: { outcome: ClaudeProfileSetupReport['outcome'] } = { outcome: 'prepared' } + const runSetup = async ({ profile }: { profile: ClaudeProfileDescriptor }) => { + mkdirSync(profile.home, { recursive: true }) + if (setup.outcome === 'prepared') { + writeFileSync(claudeProfileMarkerPath(profile), '{}') + } + return { outcome: setup.outcome, warnings: [], surfaces: {} } satisfies ClaudeProfileSetupReport + } + const router = new ClaudeProfileRouter({ + getSettings: () => settings, + dataRoot, + userHome, + env: {}, + runSetup, + refreshWaitMs: 50 + }) + const home = (id: string) => join(dataRoot, 'claude-profiles', id, 'home') + const pointer = (id: string) => join(dataRoot, 'claude-profile-pointers', `account-${id}`) + return { root, userHome, dataRoot, settings, router, home, pointer, setup } +} + +function signIn(stateDir: string, email: string): void { + mkdirSync(stateDir, { recursive: true }) + const stateFile = join(stateDir, '.claude.json') + const previous = existsSync(stateFile) ? statSync(stateFile).mtimeMs : 0 + writeFileSync(stateFile, JSON.stringify({ oauthAccount: { emailAddress: email } })) + const mtime = new Date(Math.max(Date.now(), previous + 2_000)) + utimesSync(stateFile, mtime, mtime) +} + +describe('per-account Claude pointers', () => { + it('routes an account to its own folder, or to System default while that covers it', () => { + const f = fixture() + signIn(f.home('a'), 'a@example.com') + signIn(f.userHome, 'b@example.com') + const pointers = f.router.accountPointers + + expect(pointers.routeFor('a')).toBe(f.home('a')) + expect(pointers.routeFor('b')).toBe('') + expect(pointers.routeFor('gone')).toBeNull() + }) + + it('writes only when the route changed', () => { + const f = fixture() + signIn(f.home('a'), 'a@example.com') + const pointers = f.router.accountPointers + pointers.update('a') + expect(readFileSync(f.pointer('a'), 'utf8')).toBe(f.home('a')) + expect(statSync(f.pointer('a')).mode & 0o777).toBe(0o600) + // Why whole seconds: a millisecond mtime can read back as 691.999 instead of 692. + const old = new Date(Math.floor(Date.now() / 1000) * 1000 - 60_000) + utimesSync(f.pointer('a'), old, old) + + pointers.update('a') + + expect(statSync(f.pointer('a')).mtimeMs).toBe(old.getTime()) + }) + + it('follows an existing file when coverage changes, on the next selection write', () => { + const f = fixture() + signIn(f.userHome, 'a@example.com') + f.router.accountPointers.update('a') + expect(readFileSync(f.pointer('a'), 'utf8')).toBe('') + + signIn(f.home('a'), 'a@example.com') + f.router.publish() + + expect(readFileSync(f.pointer('a'), 'utf8')).toBe(f.home('a')) + }) + + it('creates nothing for people who never kept an agent', async () => { + const f = fixture() + f.router.publish() + await f.router.prepareLaunch() + f.router.terminalEnv() + expect(existsSync(join(f.dataRoot, 'claude-profile-pointers'))).toBe(false) + }) + + it("points a removed account's file at no folder before deleting, and never deletes it", async () => { + const f = fixture() + signIn(f.home('a'), 'a@example.com') + f.router.accountPointers.update('a') + const removeAccount = vi.spyOn(f.router, 'removeAccount') + let pointerDuringDelete = '' + removeAccount.mockImplementation(async () => { + pointerDuringDelete = readFileSync(f.pointer('a'), 'utf8') + }) + + await removeClaudeAccountFolder(f.router, 'a') + // A write racing the removal must not point the file back at the folder. + f.router.publish() + f.settings.claudeManagedAccounts = [] + f.router.publish() + + expect(pointerDuringDelete).toBe(CLAUDE_REMOVED_ACCOUNT_POINTER) + expect(readFileSync(f.pointer('a'), 'utf8')).toBe(CLAUDE_REMOVED_ACCOUNT_POINTER) + expect(f.router.accountPointers.routeFor('a')).toBeNull() + }) + + it('refuses a kept launch on an account while its folder is being removed', async () => { + const f = fixture() + signIn(f.home('a'), 'a@example.com') + f.router.accountPointers.update('a') + let launchDuringDelete: Promise = Promise.resolve() + let envDuringDelete: unknown + vi.spyOn(f.router, 'removeAccount').mockImplementation(async () => { + // The account is still saved here: settings drop it only after the folder is gone. + envDuringDelete = claudeTerminalEnvForAccount(f.router, 'a') + rmSync(join(f.dataRoot, 'claude-profiles', 'a'), { recursive: true, force: true }) + launchDuringDelete = prepareClaudeLaunchForAccount(f.router, 'a') + }) + + await removeClaudeAccountFolder(f.router, 'a') + + await expect(launchDuringDelete).rejects.toThrow(/no longer saved/) + expect(envDuringDelete).toEqual({ ORCA_CLAUDE_PROFILE_POINTER: f.pointer('a') }) + expect(existsSync(join(f.dataRoot, 'claude-profiles', 'a'))).toBe(false) + expect(readFileSync(f.pointer('a'), 'utf8')).toBe(CLAUDE_REMOVED_ACCOUNT_POINTER) + }) + + it("starts the selection even when a kept account's file cannot be rewritten", async () => { + const f = fixture() + mkdirSync(f.pointer('a'), { recursive: true }) + signIn(f.home('b'), 'b@example.com') + + await expect(f.router.prepareLaunch()).resolves.toMatchObject({ configDir: f.home('b') }) + expect(readFileSync(f.router.pointerPath, 'utf8')).toBe(f.home('b')) + }) + + it('points the file back at the account when its removal fails', async () => { + const f = fixture() + signIn(f.home('a'), 'a@example.com') + f.router.accountPointers.update('a') + vi.spyOn(f.router, 'removeAccount').mockRejectedValue(new Error('folder in use')) + + await expect(removeClaudeAccountFolder(f.router, 'a')).rejects.toThrow('folder in use') + + expect(readFileSync(f.pointer('a'), 'utf8')).toBe(f.home('a')) + }) + + it("never hands a kept pane the selection's pointer, even with a missing folder", () => { + const f = fixture() + expect(claudeTerminalEnvForAccount(f.router, 'a')).toEqual({ + ORCA_CLAUDE_PROFILE_POINTER: f.pointer('a') + }) + expect(claudeTerminalEnvForAccount(f.router, null)).toEqual({ + ORCA_CLAUDE_PROFILE_POINTER: join(f.dataRoot, 'claude-profile-pointers', 'system-default') + }) + expect(claudeTerminalEnvForAccount(f.router, '../x')).toEqual({ + ORCA_CLAUDE_PROFILE_POINTER: join(f.dataRoot, 'claude-profile-pointers', 'system-default') + }) + }) + + it('injects the kept account with its twin once its folder exists', () => { + const f = fixture() + signIn(f.home('a'), 'a@example.com') + expect(claudeTerminalEnvForAccount(f.router, 'a')).toEqual({ + ORCA_CLAUDE_PROFILE_POINTER: f.pointer('a'), + CLAUDE_CONFIG_DIR: f.home('a'), + ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.home('a') + }) + }) +}) + +describe('prepareClaudeLaunchForAccount', () => { + it('launches on the kept account while another one is selected', async () => { + const f = fixture() + signIn(f.home('a'), 'a@example.com') + + await expect(prepareClaudeLaunchForAccount(f.router, 'a')).resolves.toEqual({ + configDir: f.home('a'), + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: { + ORCA_CLAUDE_PROFILE_POINTER: f.pointer('a'), + CLAUDE_CONFIG_DIR: f.home('a'), + ORCA_CLAUDE_INJECTED_CONFIG_DIR: f.home('a') + }, + provenance: 'profile:a' + }) + expect(readFileSync(f.pointer('a'), 'utf8')).toBe(f.home('a')) + expect(existsSync(f.router.pointerPath)).toBe(false) + }) + + it('launches System default with its own file and leaves the user value alone', async () => { + const f = fixture() + await expect(prepareClaudeLaunchForAccount(f.router, null)).resolves.toMatchObject({ + configDir: resolve(f.userHome, '.claude'), + envPatch: { + ORCA_CLAUDE_PROFILE_POINTER: join(f.dataRoot, 'claude-profile-pointers', 'system-default') + }, + provenance: 'system' + }) + }) + + it('refuses when the first setup fails, or the account is no longer saved', async () => { + const f = fixture() + f.setup.outcome = 'refused' + await expect(prepareClaudeLaunchForAccount(f.router, 'a')).rejects.toThrow( + CLAUDE_PROFILE_SETUP_FAILED_MESSAGE + ) + await expect(prepareClaudeLaunchForAccount(f.router, 'gone')).rejects.toThrow(/no longer saved/) + }) +}) diff --git a/src/main/claude-accounts/claude-profile-account-pointers.ts b/src/main/claude-accounts/claude-profile-account-pointers.ts new file mode 100644 index 000000000000..8d7051c67905 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-account-pointers.ts @@ -0,0 +1,138 @@ +import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { CLAUDE_REMOVED_ACCOUNT_POINTER } from '../../shared/claude-profile-routing' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { findClaudeAccount } from './runtime-selection' + +function isDefinitivelyAbsent(path: string): boolean { + try { + lstatSync(path) + return false + } catch (error) { + return isDefinitiveAbsence(error) + } +} + +const ACCOUNT_PREFIX = 'account-' +const SYSTEM_DEFAULT_FILE = 'system-default' +const ACCOUNT_ID = /^[a-zA-Z0-9_-]+$/ + +type AccountFolders = { + accountHome: (accountId: string) => string + coveredBySystemDefault: (accountId: string) => boolean +} + +/** One which-account file per account, read by `claude` in panes started kept on it; never deleted, since a missing file reads as System default. */ +export class ClaudeAccountPointers { + readonly root: string + /** Account ids with a file on disk; read once, so people who never kept anything pay nothing. */ + private existing: Set | null = null + /** Accounts being removed: nothing may point their files back at a folder that is going away. */ + private readonly retired = new Set() + + constructor( + readonly dataRoot: string, + private readonly folders: AccountFolders, + private readonly getSettings: () => Pick + ) { + this.root = join(dataRoot, 'claude-profile-pointers') + } + + pointerPath(accountId: string | null): string { + if (accountId === null) { + return join(this.root, SYSTEM_DEFAULT_FILE) + } + if (!ACCOUNT_ID.test(accountId)) { + throw new Error('Invalid Claude account id') + } + return join(this.root, `${ACCOUNT_PREFIX}${accountId}`) + } + + /** The folder an account's launches run in; '' when System default covers it, null when not saved. */ + routeFor(accountId: string): string | null { + if (!findClaudeAccount(this.getSettings(), accountId)) { + return null + } + return this.folders.coveredBySystemDefault(accountId) ? '' : this.folders.accountHome(accountId) + } + + /** Writes the account's current route; a removed account's file keeps its placeholder. */ + update(accountId: string | null): void { + if (accountId !== null && this.retired.has(accountId)) { + return + } + const route = accountId === null ? '' : this.routeFor(accountId) + if (route === null) { + return + } + this.writeContent(accountId, route) + } + + /** Called whenever the selection file is written, so a route change reaches kept panes too. */ + refreshExisting(): void { + for (const accountId of this.listExisting()) { + const route = this.retired.has(accountId) ? null : this.routeFor(accountId) + if (route === null) { + continue + } + try { + this.writeContent(accountId, route) + } catch (error) { + // Why: one kept account's file must never fail the selection's own launches. + console.warn('[claude-profile] Could not refresh a kept account pointer:', error) + } + } + } + + isRetired(accountId: string): boolean { + return this.retired.has(accountId) + } + + /** Before an account's folder goes, its file names no folder, so a re-typed `claude` starts nothing. */ + retire(accountId: string): void { + this.retired.add(accountId) + if (!this.listExisting().has(accountId) && isDefinitivelyAbsent(this.pointerPath(accountId))) { + return + } + this.writeContent(accountId, CLAUDE_REMOVED_ACCOUNT_POINTER) + } + + /** A removal that failed leaves the account saved, so its kept agents point at it again. */ + restore(accountId: string): void { + if (this.retired.delete(accountId) && this.listExisting().has(accountId)) { + this.update(accountId) + } + } + + private listExisting(): Set { + if (!this.existing) { + let names: string[] = [] + try { + names = readdirSync(this.root) + } catch { + names = [] + } + this.existing = new Set( + names + .filter((name) => name.startsWith(ACCOUNT_PREFIX)) + .map((name) => name.slice(ACCOUNT_PREFIX.length)) + .filter((id) => ACCOUNT_ID.test(id)) + ) + } + return this.existing + } + + private writeContent(accountId: string | null, content: string): void { + const path = this.pointerPath(accountId) + // Why compare first: launches re-sync these, and most find them unchanged. + if (!existsSync(path) || readFileSync(path, 'utf8') !== content) { + mkdirSync(this.root, { recursive: true, mode: 0o700 }) + writeFileAtomically(path, content, { mode: 0o600 }) + } + if (accountId !== null) { + this.listExisting().add(accountId) + } + } +} diff --git a/src/main/claude-accounts/claude-profile-account-route.ts b/src/main/claude-accounts/claude-profile-account-route.ts new file mode 100644 index 000000000000..d4980e75493d --- /dev/null +++ b/src/main/claude-accounts/claude-profile-account-route.ts @@ -0,0 +1,161 @@ +import { existsSync } from 'node:fs' +import { + CLAUDE_INJECTED_CONFIG_DIR_ENV, + CLAUDE_PROFILE_POINTER_ENV, + CLAUDE_USER_CONFIG_DIR_ENV +} from '../../shared/claude-profile-routing' +import { keptAccountRefusals } from '../agent-accounts/kept-account-refusals' +import { claudeProfileMissing, claudeProfileSetupFailed } from './claude-profile-launch-errors' +import { claudeProfileMarkerPath, describeClaudeProfile } from './claude-profile-paths' +import { getClaudeProfileRouter, isDirectory } from './claude-profile-installed-router' +import type { ClaudeProfileRouter } from './claude-profile-router' +import type { ClaudeEnvPatch } from './environment' +import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' + +const REFRESH_WAIT_MS = 5_000 + +type AccountRouter = Pick< + ClaudeProfileRouter, + | 'accountPointers' + | 'envReady' + | 'accountLogin' + | 'prepareAccount' + | 'removeAccount' + | 'systemDefaultHome' + | 'userConfigDir' +> + +/** A kept pane's spawn env, naming its account's own pointer; never throws, never names the selection's. */ +export function claudeTerminalEnvForAccount( + router: AccountRouter, + accountId: string | null +): ClaudeEnvPatch { + const pointers = router.accountPointers + let pointer: string + try { + pointer = pointers.pointerPath(accountId) + } catch { + // Why: an id that names no file still must not fall back to the selection. + return { [CLAUDE_PROFILE_POINTER_ENV]: pointers.pointerPath(null) } + } + if (accountId !== null && pointers.isRetired(accountId)) { + return { [CLAUDE_PROFILE_POINTER_ENV]: pointer } + } + try { + pointers.update(accountId) + const home = accountId === null ? '' : pointers.routeFor(accountId) + if (!home || !isDirectory(home)) { + return { [CLAUDE_PROFILE_POINTER_ENV]: pointer } + } + const env = { + [CLAUDE_PROFILE_POINTER_ENV]: pointer, + CLAUDE_CONFIG_DIR: home, + [CLAUDE_INJECTED_CONFIG_DIR_ENV]: home + } + const userConfigDir = router.userConfigDir() + return userConfigDir ? { ...env, [CLAUDE_USER_CONFIG_DIR_ENV]: userConfigDir } : env + } catch { + return { [CLAUDE_PROFILE_POINTER_ENV]: pointer } + } +} + +/** A pane started kept on an account: its env names that account's own pointer, never the selection's. */ +export function withClaudeKeptAccountTerminalEnv | undefined>( + env: Env, + accountId: string | null +): Env | Record { + const router = getClaudeProfileRouter() + return router ? { ...env, ...claudeTerminalEnvForAccount(router, accountId) } : env +} + +/** The selection's launch preparation for a given account, with that account's pointer written first. */ +export async function prepareClaudeLaunchForAccount( + router: AccountRouter, + accountId: string | null, + options: { refreshWaitMs?: number } = {} +): Promise { + const pointers = router.accountPointers + // Why retired too: setup would recreate the folder its removal is deleting. + if ( + accountId !== null && + (pointers.isRetired(accountId) || pointers.routeFor(accountId) === null) + ) { + throw keptAccountRefusals.accountRemoved() + } + // Why: whether System default covers an account depends on the login shell's env. + if (accountId === null || !router.accountLogin(accountId)) { + await router.envReady + } + pointers.update(accountId) + const pointer = pointers.pointerPath(accountId) + const home = accountId === null ? '' : pointers.routeFor(accountId) + if (!home || accountId === null) { + return { + configDir: router.systemDefaultHome(), + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: { [CLAUDE_PROFILE_POINTER_ENV]: pointer }, + provenance: 'system' + } + } + const profile = describeClaudeProfile(pointers.dataRoot, accountId, { + executionHostId: 'local', + runtime: 'host' + }) + if (!existsSync(claudeProfileMarkerPath(profile))) { + const prepared = await router.prepareAccount(accountId).then( + () => true, + () => false + ) + if (!prepared) { + throw claudeProfileSetupFailed() + } + } else { + // Why capped and never thrown: a set-up folder runs on its last refresh rather than not at all. + const refresh = router.prepareAccount(accountId).catch((error: unknown) => { + console.warn('[claude-profile] Account refresh failed:', error) + }) + const waitMs = options.refreshWaitMs ?? REFRESH_WAIT_MS + await Promise.race([refresh, new Promise((resolve) => setTimeout(resolve, waitMs).unref())]) + } + if (!isDirectory(home)) { + throw claudeProfileMissing() + } + return { + configDir: home, + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: { + [CLAUDE_PROFILE_POINTER_ENV]: pointer, + CLAUDE_CONFIG_DIR: home, + [CLAUDE_INJECTED_CONFIG_DIR_ENV]: home + }, + provenance: `profile:${accountId}` + } +} + +/** Removes an account's folder with its kept panes' file already pointing away from it. */ +export async function removeClaudeAccountFolder( + router: AccountRouter, + accountId: string +): Promise { + router.accountPointers.retire(accountId) + try { + await router.removeAccount(accountId) + } catch (error) { + router.accountPointers.restore(accountId) + throw error + } +} + +/** A kept launch on this host's router; refuses where none is installed rather than falling back. */ +export function prepareInstalledClaudeLaunchForAccount( + accountId: string | null +): Promise { + const router = getClaudeProfileRouter() + return router + ? prepareClaudeLaunchForAccount(router, accountId) + : Promise.reject(keptAccountRefusals.accountRemoved()) +} diff --git a/src/main/claude-accounts/claude-profile-router.ts b/src/main/claude-accounts/claude-profile-router.ts index d190e1fc8f3b..ab5ee4bbf3d7 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -40,6 +40,7 @@ import { type ClaudeFolderLogin } from './claude-account-folder' import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' +import { ClaudeAccountPointers } from './claude-profile-account-pointers' const REFRESH_WAIT_MS = 5_000 @@ -60,7 +61,8 @@ export class ClaudeProfileRouter { readonly pointerPath: string private readonly setups = new Map>() private env: NodeJS.ProcessEnv - private readonly envReady: Promise + readonly envReady: Promise + readonly accountPointers: ClaudeAccountPointers private envResolved: boolean constructor( private readonly args: { @@ -76,6 +78,7 @@ export class ClaudeProfileRouter { } ) { this.pointerPath = join(args.dataRoot, 'claude-profiles', 'selected-host') + this.accountPointers = new ClaudeAccountPointers(args.dataRoot, this, args.getSettings) this.env = args.env ?? process.env // Why the login shell's: a Dock launch lacks the CLAUDE_CONFIG_DIR an rc exports. Chats share it. this.envResolved = Boolean(args.env) @@ -170,6 +173,7 @@ export class ClaudeProfileRouter { mkdirSync(dirname(this.pointerPath), { recursive: true, mode: 0o700 }) writeFileAtomically(this.pointerPath, home, { mode: 0o600 }) } + this.accountPointers.refreshExisting() return true } diff --git a/src/main/claude-accounts/runtime-auth-service-kept.test.ts b/src/main/claude-accounts/runtime-auth-service-kept.test.ts new file mode 100644 index 000000000000..db2bbf765225 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-kept.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' + +const fakes = vi.hoisted(() => ({ + host: { publish: () => {}, removeAccount: vi.fn(async () => {}) }, + removeClaudeAccountFolder: vi.fn(async () => {}) +})) +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getPath: () => '/data/orca' }) +})) +vi.mock('./claude-profile-router', () => ({ + ClaudeProfileRouter: function ClaudeProfileRouter() { + return fakes.host + } +})) +vi.mock('./claude-profile-installed-router', () => ({ + installClaudeProfileRouter: () => {}, + installClaudeWslProfileRouter: () => {} +})) +vi.mock('./claude-profile-account-route', () => ({ + removeClaudeAccountFolder: fakes.removeClaudeAccountFolder +})) + +import { ClaudeRuntimeAuthService } from './runtime-auth-service' + +function service(): ClaudeRuntimeAuthService { + const settings = getDefaultSettings('/home/user') + return new ClaudeRuntimeAuthService({ getSettings: () => settings }) +} + +describe('ClaudeRuntimeAuthService for kept agents', () => { + it("points kept panes away before a host account's folder is removed", async () => { + const auth = service() + await auth.removeAccountFolder('a', { runtime: 'host' }) + expect(fakes.removeClaudeAccountFolder).toHaveBeenCalledWith(fakes.host, 'a') + expect(fakes.host.removeAccount).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index d84fe4bbdbaf..b44b022ec88e 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -13,6 +13,7 @@ import { } from './claude-profile-installed-router' import type { ClaudeAccountSelectionTarget } from './runtime-selection' import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' +import { removeClaudeAccountFolder } from './claude-profile-account-route' export type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' @@ -117,7 +118,7 @@ export class ClaudeRuntimeAuthService { ): Promise { const wsl = this.wslRouteFor(target) if (!wsl) { - await this.router.removeAccount(accountId) + await removeClaudeAccountFolder(this.router, accountId) return } // Why never thrown: a deleted distro must not block removing its accounts. diff --git a/src/main/codex-accounts/runtime-home-kept-resume.test.ts b/src/main/codex-accounts/runtime-home-kept-resume.test.ts new file mode 100644 index 000000000000..3d1fc0da2db1 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-kept-resume.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { realpathSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import type * as NodeOs from 'node:os' +import { join } from 'node:path' +import type { CodexRuntimeHomeService } from './runtime-home-service' +import { createSettings } from './runtime-home-settings-test-fixtures' +import { + createCodexAccountRecord, + createCodexAuthJson, + createManagedAuth, + createStore, + getSystemCodexAuthPath, + setupRuntimeHomeTest, + teardownRuntimeHomeTest, + testState +} from './runtime-home-service-test-harness' +import { isAgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' + +const lstatFaults = vi.hoisted(() => { + const held = new Set() + return { + held, + consume(target: unknown): void { + if (typeof target !== 'string' || !held.has(target)) { + return + } + const error: NodeJS.ErrnoException = new Error(`EPERM: operation not permitted, lstat`) + error.code = 'EPERM' + throw error + } + } +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the wrapper forwards the same arguments to the real lstatSync. + const original = actual.lstatSync as (...args: unknown[]) => unknown + const patched: Record = { + ...actual, + lstatSync: Object.assign((...args: unknown[]): unknown => { + lstatFaults.consume(args[0]) + return original(...args) + }, original) + } + return { ...patched, default: patched } +}) + +vi.mock('electron', () => ({ app: { getPath: () => testState.userDataDir } })) +beforeEach(() => installFakeAppEnvironment({ getPath: () => testState.userDataDir })) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: () => testState.fakeHomeDir } +}) + +// Selection is A; the conversation was kept on B. +async function createService(): Promise<{ + service: CodexRuntimeHomeService + store: ReturnType + keptHome: string +}> { + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const selectedHome = createManagedAuth( + testState.userDataDir, + 'account-a', + createCodexAuthJson('a@example.com', 'acct-a', 'refresh-a') + ) + const keptHome = createManagedAuth( + testState.userDataDir, + 'account-b', + createCodexAuthJson('b@example.com', 'acct-b', 'refresh-b') + ) + const store = createStore( + createSettings({ + realHomeRoutable: true, + codexManagedAccounts: [ + createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', selectedHome), + createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', keptHome) + ], + activeCodexManagedAccountId: 'account-a', + activeCodexManagedAccountIdsByRuntime: { host: 'account-a', wsl: {} } + }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the harness store implements every Store method this service reads. + return { service: new CodexRuntimeHomeService(store as never), store, keptHome } +} + +describe('CodexRuntimeHomeService.resolveHostAccountCodexHomePathForKeptResume', () => { + beforeEach(() => { + lstatFaults.held.clear() + setupRuntimeHomeTest() + }) + + afterEach(() => { + lstatFaults.held.clear() + teardownRuntimeHomeTest() + }) + + it("resolves the kept account's own home, not the selection's", async () => { + const { service, keptHome } = await createService() + expect(service.resolveHostAccountCodexHomePathForKeptResume('account-b')).toBe(keptHome) + }) + + it('reports an unreadable kept home as temporarily unavailable and changes nothing', async () => { + const { service, store, keptHome } = await createService() + const { ManagedCodexHomeTemporarilyUnavailableError } = + await import('./host-codex-managed-home-ownership') + lstatFaults.held.add(join(realpathSync(keptHome), '.orca-managed-home')) + + expect(() => service.resolveHostAccountCodexHomePathForKeptResume('account-b')).toThrow( + ManagedCodexHomeTemporarilyUnavailableError + ) + expect(store.updateSettings).not.toHaveBeenCalled() + }) + + it('refuses an untrusted kept home without clearing any selection', async () => { + const { service, store, keptHome } = await createService() + writeFileSync(join(keptHome, '.orca-managed-home'), 'someone-else\n', 'utf-8') + + let thrown: unknown + try { + service.resolveHostAccountCodexHomePathForKeptResume('account-b') + } catch (error) { + thrown = error + } + expect(isAgentSessionPreSpawnError(thrown)).toBe(true) + expect(String(thrown)).toContain('b@example.com') + expect(store.updateSettings).not.toHaveBeenCalled() + expect(store.getSettings().activeCodexManagedAccountId).toBe('account-a') + }) + + it('never clears the selection, even when the kept account is the selected one', async () => { + const { service, store } = await createService() + const selectedHome = store.getSettings().codexManagedAccounts[0]!.managedHomePath + writeFileSync(join(selectedHome, '.orca-managed-home'), 'someone-else\n', 'utf-8') + + expect(() => service.resolveHostAccountCodexHomePathForKeptResume('account-a')).toThrow( + /a@example\.com/ + ) + expect(store.updateSettings).not.toHaveBeenCalled() + expect(store.getSettings().activeCodexManagedAccountId).toBe('account-a') + }) + + it('refuses an account that is no longer saved', async () => { + const { service } = await createService() + expect(() => service.resolveHostAccountCodexHomePathForKeptResume('account-gone')).toThrow( + /no longer saved/ + ) + }) +}) diff --git a/src/main/codex-accounts/runtime-home-service-home-routing.ts b/src/main/codex-accounts/runtime-home-service-home-routing.ts index c0b98ee9aafa..29453a256728 100644 --- a/src/main/codex-accounts/runtime-home-service-home-routing.ts +++ b/src/main/codex-accounts/runtime-home-service-home-routing.ts @@ -25,6 +25,8 @@ import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-con import type { CodexManagedAccount } from '../../shared/managed-account-types' import type { CodexRateLimitHomeResolution } from './runtime-home-service-types' import { CodexRuntimeHomeManagedHome } from './runtime-home-service-managed-home' +import { keptAccountRefusals } from '../agent-accounts/kept-account-refusals' +import { codexAccountLabel } from '../agent-accounts/kept-codex-home' export abstract class CodexRuntimeHomeRouting extends CodexRuntimeHomeManagedHome { getHostCodexHomePathsForSessionDiscovery(): string[] { @@ -81,6 +83,23 @@ export abstract class CodexRuntimeHomeRouting extends CodexRuntimeHomeManagedHom return resolved.homePath } + /** A kept conversation's account home; refuses rather than fall back, and never touches the selection. */ + resolveHostAccountCodexHomePathForKeptResume(accountId: string): string { + const settings = this.store.getSettings() + const account = settings.codexManagedAccounts.find((candidate) => candidate.id === accountId) + if (!account || this.getWslManagedHomePath(account)) { + throw keptAccountRefusals.accountRemoved() + } + const resolved = this.resolveSelfContainedManagedHome(account) + if (resolved.kind === 'indeterminate') { + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + if (resolved.kind === 'untrusted') { + throw keptAccountRefusals.codexHomeUntrusted(codexAccountLabel(settings, accountId)) + } + return resolved.homePath + } + /** Trust-gates host previews without changing WSL routing or durable account state. */ resolveCodexManagedAccountHomeForInactiveFetch( account: CodexManagedAccount diff --git a/src/main/codex-accounts/runtime-home-service-state.ts b/src/main/codex-accounts/runtime-home-service-state.ts index eecda28f6c6e..53bfe7788d4a 100644 --- a/src/main/codex-accounts/runtime-home-service-state.ts +++ b/src/main/codex-accounts/runtime-home-service-state.ts @@ -88,6 +88,7 @@ export abstract class CodexRuntimeHomeState { abstract getHostCodexHomePathsForSessionDiscovery(): string[] abstract getSelectedHostAccountCodexHomePath(): string | null abstract resolveSelectedHostAccountCodexHomePathForResume(): string | null + abstract resolveHostAccountCodexHomePathForKeptResume(accountId: string): string abstract resolveCodexManagedAccountHomeForInactiveFetch( account: CodexManagedAccount ): { kind: 'ready'; homePath: string } | { kind: 'skip' } diff --git a/src/main/codex/codex-ai-vault-session-resume-kept.test.ts b/src/main/codex/codex-ai-vault-session-resume-kept.test.ts new file mode 100644 index 000000000000..427ec4467ff1 --- /dev/null +++ b/src/main/codex/codex-ai-vault-session-resume-kept.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, linkSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { prepareCodexAiVaultSessionResume } from './codex-ai-vault-session-resume' + +const ROLLOUT = join('sessions', '2026', '10', '14', 'rollout-2026-10-14T10-00-00-abc.jsonl') + +describe('Session History resume of a kept Codex conversation', () => { + let root: string + let homeA: string + let homeB: string + let homeC: string + let preparePinnedLaunchHome: ReturnType Promise>> + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-ai-vault-kept-')) + vi.stubEnv('ORCA_USER_DATA_PATH', join(root, 'user-data')) + homeA = join(root, 'codex-accounts', 'account-a', 'home') + homeB = join(root, 'codex-accounts', 'account-b', 'home') + homeC = join(root, 'codex-accounts', 'account-c', 'home') + mkdirSync(dirname(join(homeA, ROLLOUT)), { recursive: true }) + mkdirSync(dirname(join(homeB, ROLLOUT)), { recursive: true }) + mkdirSync(homeC, { recursive: true }) + writeFileSync(join(homeA, ROLLOUT), '{"type":"session_meta"}\n') + linkSync(join(homeA, ROLLOUT), join(homeB, ROLLOUT)) + preparePinnedLaunchHome = vi.fn(async () => undefined) + }) + + afterEach(() => { + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) + }) + + function prepare(options: { + keptHome: () => string | undefined + selectedHome?: string + fork?: boolean + discoveryHomes?: string[] + }) { + const resolveKeptCodexHome = vi.fn(options.keptHome) + const result = prepareCodexAiVaultSessionResume( + { + agent: 'codex', + sessionId: 'abc', + filePath: join(homeA, ROLLOUT), + codexHome: homeA, + executionHostId: 'local', + ...(options.fork ? { fork: true } : {}) + }, + { + runtimeHome: { + isHostSystemDefaultRealHomeSelected: () => false, + resolveSelectedHostAccountCodexHomePathForResume: () => options.selectedHome ?? homeB, + getHostCodexHomePathsForSessionDiscovery: () => + options.discoveryHomes ?? [homeA, homeB, homeC] + }, + systemCodexHomePath: join(root, 'system-codex-home'), + preparePinnedLaunchHome, + resolveKeptCodexHome + } + ) + return { result, resolveKeptCodexHome } + } + + it('stays on the kept account instead of following the selection', async () => { + await expect(prepare({ keptHome: () => homeA }).result).resolves.toEqual({ + useRealCodexHome: false + }) + }) + + it('retargets to the kept account when the listing named another alias', async () => { + await expect(prepare({ keptHome: () => homeB, selectedHome: homeA }).result).resolves.toEqual({ + useRealCodexHome: false, + substituteCodexHome: homeB + }) + }) + + it('moves the conversation into a kept home that does not hold it yet', async () => { + await expect(prepare({ keptHome: () => homeC }).result).resolves.toEqual({ + useRealCodexHome: false, + substituteCodexHome: homeC + }) + expect(existsSync(join(homeC, ROLLOUT))).toBe(true) + expect(preparePinnedLaunchHome).toHaveBeenCalledWith(homeC) + }) + + it('refuses to copy from a home this host does not list', async () => { + await expect( + prepare({ keptHome: () => homeC, discoveryHomes: [homeB] }).result + ).rejects.toThrow(/could not move this conversation/) + expect(existsSync(join(homeC, ROLLOUT))).toBe(false) + }) + + it('refuses when the keep cannot be read', async () => { + const refusal = new Error('Orca could not read which account this agent is kept on.') + await expect( + prepare({ + keptHome: () => { + throw refusal + } + }).result + ).rejects.toBe(refusal) + }) + + it('leaves a fork, which is a new conversation, to the selection', async () => { + const { result, resolveKeptCodexHome } = prepare({ keptHome: () => homeC, fork: true }) + await expect(result).resolves.toEqual({ useRealCodexHome: false, substituteCodexHome: homeB }) + expect(resolveKeptCodexHome).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/codex/codex-ai-vault-session-resume.ts b/src/main/codex/codex-ai-vault-session-resume.ts index 908587829943..3b396c98c77f 100644 --- a/src/main/codex/codex-ai-vault-session-resume.ts +++ b/src/main/codex/codex-ai-vault-session-resume.ts @@ -7,7 +7,10 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { parseWslUncPath } from '../../shared/wsl-paths' import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service' import { getSystemCodexHomePath } from './codex-home-paths' -import { prepareLegacySharedCodexSessionResume } from './codex-legacy-session-resume' +import { + prepareCodexAccountRestartResume, + prepareLegacySharedCodexSessionResume +} from './codex-legacy-session-resume' import { resolveTrustedCodexSessionResumeHome, trustedCodexResumeHomes @@ -25,6 +28,8 @@ type CodexAiVaultResumeOptions = { systemCodexHomePath: string | undefined /** Readies a home a pane will pin as CODEX_HOME instead of the selected account's. */ preparePinnedLaunchHome: (home: string) => Promise + /** The home a kept conversation must resume in; undefined when nobody kept it. Throws to refuse. */ + resolveKeptCodexHome?: (sessionId: string) => string | undefined } export type NativeCodexResumePreparation = ( @@ -50,13 +55,26 @@ export async function prepareCodexAiVaultSessionResume( args: AiVaultPrepareSessionResumeArgs, options: CodexAiVaultResumeOptions ): Promise { + // Why: a kept conversation retargets to its own account, never to the selection. + const keptHome = + !args.fork && args.sessionId && args.executionHostId === LOCAL_EXECUTION_HOST_ID + ? options.resolveKeptCodexHome?.(args.sessionId) + : undefined + const keptOnSystemDefault = keptHome !== undefined && sameHome(keptHome, getSystemCodexHomePath()) const result = await prepareLegacySharedCodexSessionResume(args, { isHostSystemDefaultRealHomeSelected: () => - options.runtimeHome?.isHostSystemDefaultRealHomeSelected() === true, + keptHome !== undefined + ? keptOnSystemDefault + : options.runtimeHome?.isHostSystemDefaultRealHomeSelected() === true, getSelectedHostAccountCodexHomePath: () => - options.runtimeHome?.resolveSelectedHostAccountCodexHomePathForResume() ?? null, + keptHome !== undefined + ? keptHome + : (options.runtimeHome?.resolveSelectedHostAccountCodexHomePathForResume() ?? null), systemCodexHomePath: options.systemCodexHomePath }) + if (keptHome !== undefined) { + return retargetToKeptHome(args, result, { keptHome, keptOnSystemDefault }, options) + } // Why: a fork carries no provider session, so the pane spawn never readies the home a resume // would; when it keeps the row's own home, that home needs the same hooks here. if (args.fork && !result.useRealCodexHome && !result.substituteCodexHome) { @@ -94,3 +112,45 @@ async function readyForkLaunchHome( await options.preparePinnedLaunchHome(home) } } + +/** Moves a kept conversation into its account's home when the vault listed it elsewhere. */ +async function retargetToKeptHome( + args: AiVaultPrepareSessionResumeArgs, + result: AiVaultPrepareSessionResumeResult, + kept: { keptHome: string; keptOnSystemDefault: boolean }, + options: CodexAiVaultResumeOptions +): Promise { + const systemHome = getSystemCodexHomePath() + const resumeHome = result.useRealCodexHome + ? systemHome + : (result.substituteCodexHome ?? args.codexHome) + if (resumeHome && sameHome(resumeHome, kept.keptHome)) { + return result + } + // Why: only a home this host lists may be a copy source, as for any resume it pins. + const sourceHome = options.runtimeHome + ? resolveTrustedCodexSessionResumeHome({ + transcriptPath: args.filePath, + trustedCodexHomes: trustedCodexResumeHomes(options.runtimeHome, systemHome) + }) + : null + if (!sourceHome) { + throw new Error( + 'Orca could not move this conversation to the Codex account it is kept on. Retry resume.' + ) + } + await prepareCodexAccountRestartResume({ + sourceHome, + transcriptPath: args.filePath, + targetHome: kept.keptHome, + systemCodexHomePath: systemHome + }) + await options.preparePinnedLaunchHome(kept.keptHome) + return kept.keptOnSystemDefault + ? { useRealCodexHome: true } + : { useRealCodexHome: false, substituteCodexHome: kept.keptHome } +} + +function sameHome(left: string, right: string): boolean { + return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right) +} diff --git a/src/main/codex/codex-pane-account-registry-kept.test.ts b/src/main/codex/codex-pane-account-registry-kept.test.ts new file mode 100644 index 000000000000..d69054a4854f --- /dev/null +++ b/src/main/codex/codex-pane-account-registry-kept.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + _internals, + getCodexPaneAccount, + recordCodexPaneAccount, + setCodexPaneKept +} from './codex-pane-account-registry' + +let userDataPath: string +let previousUserDataPath: string | undefined + +function registryPath(): string { + return join(userDataPath, 'codex-pane-accounts.json') +} + +beforeEach(() => { + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + userDataPath = mkdtempSync(join(tmpdir(), 'orca-codex-pane-kept-')) + process.env.ORCA_USER_DATA_PATH = userDataPath + _internals.resetCache() +}) + +afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + _internals.resetCache() +}) + +describe('kept Codex pane records', () => { + it('keeps a pane across a process restart and releases it again', () => { + recordCodexPaneAccount('pty-1', { + selectionKey: 'host', + accountId: 'account-a', + homeRoute: 'account-home' + }) + + expect(setCodexPaneKept('pty-1', true)).toBe('kept') + _internals.resetCache() + expect(getCodexPaneAccount('pty-1')).toEqual({ + selectionKey: 'host', + accountId: 'account-a', + homeRoute: 'account-home', + kept: true + }) + + expect(setCodexPaneKept('pty-1', false)).toBe('released') + _internals.resetCache() + expect(getCodexPaneAccount('pty-1')).toEqual({ + selectionKey: 'host', + accountId: 'account-a', + homeRoute: 'account-home' + }) + }) + + it.each([['yes'], [1], [false], [null]])('reads kept=%j as a pane that follows', (kept) => { + writeFileSync( + registryPath(), + JSON.stringify({ + version: 2, + panes: { 'pty-1': { selectionKey: 'host', accountId: 'account-a', kept } } + }) + ) + _internals.resetCache() + + expect(getCodexPaneAccount('pty-1')).toEqual({ selectionKey: 'host', accountId: 'account-a' }) + }) + + it('reports a pane with no launch record instead of inventing one', () => { + expect(setCodexPaneKept('pty-unknown', true)).toBe('no-record') + expect(getCodexPaneAccount('pty-unknown')).toBeNull() + }) + + it('refuses to keep against a registry it could not read and queues nothing', () => { + recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: 'account-a' }) + const persisted = readFileSync(registryPath(), 'utf-8') + rmSync(registryPath()) + // Why: a directory in the file's place fails the read with EISDIR, not absence. + mkdirSync(registryPath()) + _internals.resetCache() + + expect(setCodexPaneKept('pty-1', true)).toBe('unavailable') + expect(statSync(registryPath()).isDirectory()).toBe(true) + + rmSync(registryPath(), { recursive: true }) + writeFileSync(registryPath(), persisted) + expect(getCodexPaneAccount('pty-1')).toEqual({ selectionKey: 'host', accountId: 'account-a' }) + }) + + it('never trims a kept record, even when it is the oldest one', () => { + recordCodexPaneAccount('pty-kept', { selectionKey: 'host', accountId: 'account-a' }) + expect(setCodexPaneKept('pty-kept', true)).toBe('kept') + recordCodexPaneAccount('pty-old', { selectionKey: 'host', accountId: 'account-a' }) + for (let index = 0; index < 2000; index += 1) { + recordCodexPaneAccount(`pty-${index}`, { selectionKey: 'host', accountId: 'account-b' }) + } + _internals.resetCache() + + expect(getCodexPaneAccount('pty-kept')).toEqual({ + selectionKey: 'host', + accountId: 'account-a', + kept: true + }) + // Why: the oldest record nobody kept is the one that ages out. + expect(getCodexPaneAccount('pty-old')).toBeNull() + expect(getCodexPaneAccount('pty-0')).not.toBeNull() + expect(getCodexPaneAccount('pty-1999')).not.toBeNull() + }) +}) diff --git a/src/main/codex/codex-pane-account-registry-mutations.test.ts b/src/main/codex/codex-pane-account-registry-mutations.test.ts index e7a09ad7a82f..a4632ead35ea 100644 --- a/src/main/codex/codex-pane-account-registry-mutations.test.ts +++ b/src/main/codex/codex-pane-account-registry-mutations.test.ts @@ -81,6 +81,26 @@ describe('CodexPaneAccountRegistryMutations', () => { expect(vi.getTimerCount()).toBe(0) }) + it('writes when only the kept flag changes', () => { + let persisted = JSON.stringify({ version: 2, panes: { existing: EXISTING } }) + const write = vi.fn((registry: CodexPaneAccountRegistryFile) => { + persisted = JSON.stringify(registry) + return true + }) + const mutations = new CodexPaneAccountRegistryMutations({ + read: () => parseRegistry(persisted), + write + }) + + mutations.record('existing', { ...EXISTING, kept: true }) + expect(write).toHaveBeenCalledTimes(1) + expect(parseRegistry(persisted).panes.existing).toEqual({ ...EXISTING, kept: true }) + + mutations.record('existing', EXISTING) + expect(write).toHaveBeenCalledTimes(2) + expect(parseRegistry(persisted).panes.existing).toEqual(EXISTING) + }) + it('retries a failed reconciliation write without a pending pane mutation', async () => { const registry = parseRegistry(JSON.stringify({ version: 2, panes: { existing: EXISTING } })) const write = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true) diff --git a/src/main/codex/codex-pane-account-registry-mutations.ts b/src/main/codex/codex-pane-account-registry-mutations.ts index 61de741d45f6..e6d9359b450a 100644 --- a/src/main/codex/codex-pane-account-registry-mutations.ts +++ b/src/main/codex/codex-pane-account-registry-mutations.ts @@ -1,3 +1,4 @@ +import { PersistRetryBackoff } from '../persist-retry-backoff' import type { CodexPaneAccountRecord, CodexPaneAccountRegistryFile @@ -5,7 +6,6 @@ import type { // Why: bounds both crash-leaked disk records and mutations retained while the file is unavailable. const MAX_TRACKED_PANES = 2000 -const RETRY_DELAYS_MS = [100, 1_000, 5_000, 30_000] as const type MutationStoreDependencies = { read: () => CodexPaneAccountRegistryFile | null @@ -14,8 +14,7 @@ type MutationStoreDependencies = { export class CodexPaneAccountRegistryMutations { private readonly pending = new Map() - private retryTimer: ReturnType | null = null - private retryAttempt = 0 + private readonly retry = new PersistRetryBackoff(() => this.flush()) private needsPersistence = false constructor(private readonly dependencies: MutationStoreDependencies) {} @@ -35,12 +34,12 @@ export class CodexPaneAccountRegistryMutations { flush(): void { if (this.pending.size === 0 && !this.needsPersistence) { - this.clearRetryState() + this.retry.clear() return } const registry = this.dependencies.read() if (!registry) { - this.scheduleRetry() + this.retry.schedule() return } let changed = false @@ -50,12 +49,12 @@ export class CodexPaneAccountRegistryMutations { trimRegistry(registry) if ((changed || this.needsPersistence) && !this.dependencies.write(registry)) { this.needsPersistence = true - this.scheduleRetry() + this.retry.schedule() return } this.needsPersistence = false this.pending.clear() - this.clearRetryState() + this.retry.clear() } getPendingRegistry(): CodexPaneAccountRegistryFile | null { @@ -75,38 +74,18 @@ export class CodexPaneAccountRegistryMutations { } if (!this.dependencies.write(registry)) { this.needsPersistence = true - this.scheduleRetry() + this.retry.schedule() return } this.needsPersistence = false this.pending.clear() - this.clearRetryState() + this.retry.clear() } reset(): void { this.needsPersistence = false this.pending.clear() - this.clearRetryState() - } - - private scheduleRetry(): void { - if (this.retryTimer || this.retryAttempt >= RETRY_DELAYS_MS.length) { - return - } - this.retryTimer = setTimeout(() => { - this.retryTimer = null - this.flush() - }, RETRY_DELAYS_MS[this.retryAttempt]) - this.retryAttempt += 1 - this.retryTimer.unref?.() - } - - private clearRetryState(): void { - if (this.retryTimer) { - clearTimeout(this.retryTimer) - this.retryTimer = null - } - this.retryAttempt = 0 + this.retry.clear() } } @@ -137,16 +116,19 @@ function recordsEqual( return ( left?.selectionKey === right.selectionKey && left.accountId === right.accountId && - left.homeRoute === right.homeRoute + left.homeRoute === right.homeRoute && + left.kept === right.kept ) } function trimRegistry(registry: CodexPaneAccountRegistryFile): void { - const trackedPtyIds = Object.keys(registry.panes) - if (trackedPtyIds.length <= MAX_TRACKED_PANES) { + // Why: kept panes are a person's explicit choice and never age out; the cap + // still bounds the records nobody chose to keep. + const trimmablePtyIds = Object.keys(registry.panes).filter((id) => !registry.panes[id]?.kept) + if (trimmablePtyIds.length <= MAX_TRACKED_PANES) { return } - for (const staleId of trackedPtyIds.slice(0, trackedPtyIds.length - MAX_TRACKED_PANES)) { + for (const staleId of trimmablePtyIds.slice(0, trimmablePtyIds.length - MAX_TRACKED_PANES)) { delete registry.panes[staleId] } } diff --git a/src/main/codex/codex-pane-account-registry-types.ts b/src/main/codex/codex-pane-account-registry-types.ts index 65666532a878..95b8c81fa0ce 100644 --- a/src/main/codex/codex-pane-account-registry-types.ts +++ b/src/main/codex/codex-pane-account-registry-types.ts @@ -7,6 +7,8 @@ export type CodexPaneAccountRecord = { accountId: string | null /** Absent only on records written before route provenance was introduced. */ homeRoute?: CodexPaneHomeRoute + /** A person kept this pane on accountId; the restart prompt never offers to move it. */ + kept?: true } export type CodexPaneAccountRegistryFile = { diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index 55eac65e21cd..1e7a9dc5e931 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -129,7 +129,9 @@ function parseRegistry(parsed: unknown): CodexPaneAccountRegistryFile { empty.panes[ptyId] = { selectionKey: record.selectionKey, accountId: record.accountId, - ...(homeRoute ? { homeRoute } : {}) + ...(homeRoute ? { homeRoute } : {}), + // Why: only a literal true keeps a pane; anything else reads as follow. + ...(record.kept === true ? { kept: true } : {}) } } } @@ -199,6 +201,43 @@ export function recordCodexPaneAccount(ptyId: string, record: CodexPaneAccountRe mutations.record(ptyId, record) } +export type SetCodexPaneKeptResult = 'kept' | 'released' | 'no-record' | 'unavailable' + +/** Marks or clears a pane's keep; reads first, since a flag must never be queued onto an unseen record. */ +export function setCodexPaneKept(ptyId: string, kept: boolean): SetCodexPaneKeptResult { + mutations.flush() + const registry = readRegistryOrNull() + if (!registry) { + return 'unavailable' + } + const record = registry.panes[ptyId] + if (!record) { + return 'no-record' + } + mutations.record(ptyId, { + selectionKey: record.selectionKey, + accountId: record.accountId, + ...(record.homeRoute ? { homeRoute: record.homeRoute } : {}), + ...(kept ? { kept: true } : {}) + }) + return kept ? 'kept' : 'released' +} + +/** After an account is removed, its kept panes follow the selection again; returns their ids. */ +export function releaseKeptCodexPanesForAccount(accountId: string): string[] { + const released: string[] = [] + for (const [ptyId, record] of Object.entries(readRegistry().panes)) { + if ( + record.kept && + record.accountId === accountId && + setCodexPaneKept(ptyId, false) === 'released' + ) { + released.push(ptyId) + } + } + return released +} + /** * Drops a PTY's record once it exits. */ diff --git a/src/main/codex/codex-session-resume-launch-preparation.ts b/src/main/codex/codex-session-resume-launch-preparation.ts index 8936b53f92b5..181429c7bf71 100644 --- a/src/main/codex/codex-session-resume-launch-preparation.ts +++ b/src/main/codex/codex-session-resume-launch-preparation.ts @@ -18,6 +18,8 @@ import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-hom import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service' import type { GlobalSettings } from '../../shared/global-settings-types' +import { keptAccountRefusals } from '../agent-accounts/kept-account-refusals' +import { codexAccountLabel, resolveKeptCodexHome } from '../agent-accounts/kept-codex-home' /** Automatic Codex resume prep against a host's runtime-home service; the desktop and orcad share it. */ export function createCodexSessionResumeLaunchPreparation(deps: { @@ -58,6 +60,8 @@ export function createCodexSessionResumeLaunchPreparation(deps: { target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv useSelectedAccount?: boolean + /** A person kept this conversation on an account: resume there or refuse. */ + keptAccount?: { accountId: string | null } }): Promise { const runtimeHome = deps.getRuntimeHome() if (args.target.runtime === 'wsl' || !runtimeHome || !deps.getSettings()) { @@ -65,6 +69,15 @@ export function createCodexSessionResumeLaunchPreparation(deps: { } const systemHomePath = getSystemCodexHomePath() const trustedHomes = trustedCodexResumeHomes(runtimeHome, systemHomePath) + // Why before ranking and with no fallback: a kept conversation runs on its account or not at all. + const keptHome = args.keptAccount + ? resolveKeptCodexHome({ + accountId: args.keptAccount.accountId, + runtimeHome, + systemCodexHomePath: systemHomePath, + launchEnv: args.launchEnv + }) + : undefined // Why: resolved eagerly, once, before any ranking or provenance match. The // marker read used to be deferred into the ranking thunk so a // provenance-present resume never paid for it, but that optimisation let an @@ -72,7 +85,12 @@ export function createCodexSessionResumeLaunchPreparation(deps: { // branch simply omits the account from `trustedHomes` and another account's // readable alias wins. A throw here refuses the whole resume instead // (#STA-4422). - const selectedAccountCodexHome = runtimeHome.resolveSelectedHostAccountCodexHomePathForResume() + const selectedAccountCodexHome = + keptHome === undefined + ? runtimeHome.resolveSelectedHostAccountCodexHomePathForResume() + : args.keptAccount?.accountId + ? keptHome + : null // Why: a `fresh` outcome must skip migration and hook repair entirely — there is // no verified origin home to prepare, so the PTY layer drops the resume argv (#10793). const preparation = await prepareCodexSessionResume({ @@ -113,20 +131,32 @@ export function createCodexSessionResumeLaunchPreparation(deps: { error ) } - const resumeHome = args.useSelectedAccount + const resumeHome = keptHome ? await prepareCodexAccountRestartResume({ sourceHome: sessionSource.homePath, transcriptPath: sessionSource.transcriptPath, - targetHome: selectedAccountCodexHome ?? systemHomePath, + targetHome: keptHome, systemCodexHomePath: systemHomePath }) - : migrated.useRealCodexHome - ? systemHomePath - : sessionSource.homePath + : args.useSelectedAccount + ? await prepareCodexAccountRestartResume({ + sourceHome: sessionSource.homePath, + transcriptPath: sessionSource.transcriptPath, + targetHome: selectedAccountCodexHome ?? systemHomePath, + systemCodexHomePath: systemHomePath + }) + : migrated.useRealCodexHome + ? systemHomePath + : sessionSource.homePath await prepareCodexPinnedLaunchHome(resumeHome, systemHomePath) return resumeHome } }) + if (keptHome && preparation.outcome === 'fresh') { + throw keptAccountRefusals.codexConversationMissing( + codexAccountLabel(deps.getSettings(), args.keptAccount?.accountId ?? null) + ) + } return preparation.outcome === 'resume' ? { ...preparation, diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index ac21c3d56046..46432b6c4d35 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -13,9 +13,14 @@ import { hasRecordedManagedHostCodexPane, isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys, - recordCodexPaneAccount + recordCodexPaneAccount, + setCodexPaneKept } from './codex-pane-account-registry' -import { forgetStaleCodexPanes, listStaleCodexPanes } from './codex-stale-pane-accounts' +import { + forgetStaleCodexPanes, + keepStaleCodexPanes, + listStaleCodexPanes +} from './codex-stale-pane-accounts' let userDataPath: string let previousUserDataPath: string | undefined @@ -421,6 +426,83 @@ describe('listStaleCodexPanes', () => { ]) }) + it('never reports a pane a person kept on its account', () => { + recordCodexPaneAccount('pty-kept', { selectionKey: 'host', accountId: 'account-a' }) + recordCodexPaneAccount('pty-follow', { selectionKey: 'host', accountId: 'account-a' }) + expect(setCodexPaneKept('pty-kept', true)).toBe('kept') + + expect( + listStaleCodexPanes({ + ptyIds: ['pty-kept', 'pty-follow'], + settings: settingsWithSelection('account-b') + }) + ).toEqual([{ ptyId: 'pty-follow', launchAccountId: 'account-a', activeAccountId: 'account-b' }]) + + expect(setCodexPaneKept('pty-kept', false)).toBe('released') + expect( + listStaleCodexPanes({ ptyIds: ['pty-kept'], settings: settingsWithSelection('account-b') }) + ).toEqual([{ ptyId: 'pty-kept', launchAccountId: 'account-a', activeAccountId: 'account-b' }]) + }) + + it('keeps panes whose own home carries the account and drops the rest as before', () => { + recordCodexPaneAccount('pty-account', { + selectionKey: 'host', + accountId: 'account-a', + homeRoute: 'account-home' + }) + recordCodexPaneAccount('pty-real', { + selectionKey: 'host', + accountId: null, + homeRoute: 'real-home' + }) + recordCodexPaneAccount('pty-shared', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home' + }) + recordCodexPaneAccount('pty-legacy', { selectionKey: 'host', accountId: 'account-a' }) + recordCodexPaneAccount('pty-wsl-home', { + selectionKey: 'wsl:Ubuntu', + accountId: 'account-a', + homeRoute: 'wsl-home' + }) + recordCodexPaneAccount('pty-wsl-account', { + selectionKey: 'wsl:Ubuntu', + accountId: 'account-a', + homeRoute: 'account-home' + }) + + expect( + keepStaleCodexPanes([ + 'pty-account', + 'pty-real', + 'pty-shared', + 'pty-legacy', + 'pty-wsl-home', + 'pty-wsl-account', + 'pty-unrecorded' + ]) + ).toEqual(['pty-account', 'pty-real']) + _internals.resetCache() + + expect(getCodexPaneAccount('pty-account')).toEqual({ + selectionKey: 'host', + accountId: 'account-a', + homeRoute: 'account-home', + kept: true + }) + expect(getCodexPaneAccount('pty-real')).toEqual({ + selectionKey: 'host', + accountId: null, + homeRoute: 'real-home', + kept: true + }) + for (const ptyId of ['pty-shared', 'pty-legacy', 'pty-wsl-home', 'pty-wsl-account']) { + expect(getCodexPaneAccount(ptyId)).toBeNull() + } + expect(getCodexPaneAccount('pty-unrecorded')).toBeNull() + }) + it('compares a WSL pane against its own distro selection', () => { recordCodexPaneAccount('pty-1', { selectionKey: 'wsl:Ubuntu', accountId: 'account-a' }) recordCodexPaneAccount('pty-2', { selectionKey: 'wsl:Debian', accountId: 'account-c' }) diff --git a/src/main/codex/codex-stale-pane-accounts.ts b/src/main/codex/codex-stale-pane-accounts.ts index 99a9cdf422ab..b2a4d2310bc8 100644 --- a/src/main/codex/codex-stale-pane-accounts.ts +++ b/src/main/codex/codex-stale-pane-accounts.ts @@ -2,7 +2,10 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { getSelectedCodexAccountIdForTarget } from '../codex-accounts/runtime-selection' import { forgetCodexPaneAccount, - listRecordedCodexPaneAccounts + getCodexPaneAccount, + listRecordedCodexPaneAccounts, + setCodexPaneKept, + type CodexPaneAccountRecord } from './codex-pane-account-registry' export type StaleCodexPane = { @@ -24,7 +27,8 @@ export function listStaleCodexPanes(args: { const records = listRecordedCodexPaneAccounts(args.ptyIds) for (const ptyId of args.ptyIds) { const record = records.get(ptyId) - if (!record) { + // Why: a kept pane stays on its account by choice, so it is never stale. + if (!record || record.kept) { continue } const activeAccountId = getSelectedCodexAccountIdForTarget( @@ -51,6 +55,28 @@ export function forgetStaleCodexPanes(ptyIds: readonly string[]): void { } } +/** Only a pane's own home can stay on its account; the shared home mirrors the selection, WSL's the distro's. */ +export function isCodexPaneKeepable(record: CodexPaneAccountRecord): boolean { + return ( + record.selectionKey === 'host' && + (record.homeRoute === 'account-home' || record.homeRoute === 'real-home') + ) +} + +/** "Keep old account": keeps each keepable pane and forgets the rest as before; returns the kept ids. */ +export function keepStaleCodexPanes(ptyIds: readonly string[]): string[] { + const kept: string[] = [] + for (const ptyId of ptyIds) { + const record = getCodexPaneAccount(ptyId) + if (record && isCodexPaneKeepable(record) && setCodexPaneKept(ptyId, true) === 'kept') { + kept.push(ptyId) + continue + } + forgetStaleCodexPanes([ptyId]) + } + return kept +} + function parseSelectionLaneKey(selectionKey: string): { runtime: 'host' | 'wsl' wslDistro: string | null diff --git a/src/main/ipc/agent-account-keep.test.ts b/src/main/ipc/agent-account-keep.test.ts new file mode 100644 index 000000000000..8032df4311bb --- /dev/null +++ b/src/main/ipc/agent-account-keep.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => { + const deps: { current: object | null } = { current: null } + return { + handlers: new Map unknown>(), + deps, + setAgentKept: vi.fn((_deps: unknown, _request: unknown) => ({ ok: true })) + } +}) + +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => { + state.handlers.set(channel, handler) + } + } +})) +vi.mock('../agent-accounts/agent-account-keep-wiring', () => ({ + installedAgentKeepDeps: () => state.deps.current +})) +vi.mock('../agent-accounts/agent-account-keep', () => ({ setAgentKept: state.setAgentKept })) + +import { readSetAgentKeptRequest, registerAgentAccountKeepHandlers } from './agent-account-keep' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +function setKept(args: unknown): unknown { + return state.handlers.get('agentAccounts:setKept')!({}, args) +} + +beforeEach(() => { + state.handlers.clear() + state.deps.current = { installed: true } + state.setAgentKept.mockClear() + registerAgentAccountKeepHandlers() +}) + +describe('agentAccounts:setKept', () => { + it('passes a well-formed request to the host keep', () => { + expect(setKept({ paneKey: PANE, ptyId: 'pty-1', kept: true })).toEqual({ ok: true }) + expect(state.setAgentKept).toHaveBeenCalledWith(state.deps.current, { + paneKey: PANE, + ptyId: 'pty-1', + kept: true + }) + }) + + it('refuses where no producer runs', () => { + state.deps.current = null + expect(setKept({ paneKey: PANE, kept: false })).toEqual({ ok: false, reason: 'unknown-pane' }) + expect(state.setAgentKept).not.toHaveBeenCalled() + }) +}) + +describe('readSetAgentKeptRequest', () => { + it.each([ + ['no object', null], + ['a malformed pane key', { paneKey: 'pane', kept: true }], + ['an oversized pty id', { paneKey: PANE, ptyId: 'x'.repeat(513), kept: true }], + ['a non-string pty id', { paneKey: PANE, ptyId: 7, kept: true }], + ['a missing kept flag', { paneKey: PANE }] + ])('rejects %s', (_label, value) => { + expect(readSetAgentKeptRequest(value)).toBeNull() + }) +}) diff --git a/src/main/ipc/agent-account-keep.ts b/src/main/ipc/agent-account-keep.ts new file mode 100644 index 000000000000..37aae042eea7 --- /dev/null +++ b/src/main/ipc/agent-account-keep.ts @@ -0,0 +1,38 @@ +import { ipcMain } from 'electron' +import type { SetAgentKeptRequest, SetAgentKeptResult } from '../../shared/agent-run-account' +import { parsePaneKey } from '../../shared/stable-pane-id' +import { setAgentKept } from '../agent-accounts/agent-account-keep' +import { installedAgentKeepDeps } from '../agent-accounts/agent-account-keep-wiring' + +const MAX_ID_LENGTH = 512 + +export function readSetAgentKeptRequest(value: unknown): SetAgentKeptRequest | null { + if (!value || typeof value !== 'object') { + return null + } + const paneKey = 'paneKey' in value ? value.paneKey : undefined + const ptyId = 'ptyId' in value ? value.ptyId : undefined + const kept = 'kept' in value ? value.kept : undefined + if ( + typeof paneKey !== 'string' || + paneKey.length > MAX_ID_LENGTH || + parsePaneKey(paneKey) === null || + (ptyId !== undefined && (typeof ptyId !== 'string' || ptyId.length > MAX_ID_LENGTH)) || + typeof kept !== 'boolean' + ) { + return null + } + return { paneKey, kept, ...(ptyId !== undefined ? { ptyId } : {}) } +} + +/** Keep or release a local terminal agent from the tab menu. */ +export function registerAgentAccountKeepHandlers(): void { + ipcMain.handle('agentAccounts:setKept', (_event, args: unknown): SetAgentKeptResult => { + const request = readSetAgentKeptRequest(args) + const deps = installedAgentKeepDeps() + if (!request || !deps) { + return { ok: false, reason: 'unknown-pane' } + } + return setAgentKept(deps, request) + }) +} diff --git a/src/main/ipc/codex-accounts-keep-stale-panes.test.ts b/src/main/ipc/codex-accounts-keep-stale-panes.test.ts new file mode 100644 index 000000000000..e46215bcc449 --- /dev/null +++ b/src/main/ipc/codex-accounts-keep-stale-panes.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CodexAccountService } from '../codex-accounts/service' + +const state = vi.hoisted(() => { + const deps: { current: object | null } = { current: null } + return { + handlers: new Map unknown>(), + deps, + keepWithConversations: vi.fn(), + keepRegistryOnly: vi.fn((_ptyIds: readonly string[]): string[] => []), + forget: vi.fn() + } +}) + +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => { + state.handlers.set(channel, handler) + } + } +})) +vi.mock('../agent-accounts/agent-account-keep-wiring', () => ({ + installedAgentKeepDeps: () => state.deps.current +})) +vi.mock('../agent-accounts/agent-account-keep', () => ({ + keepStaleCodexPanesAndConversations: state.keepWithConversations +})) +vi.mock('../codex/codex-stale-pane-accounts', () => ({ + forgetStaleCodexPanes: state.forget, + keepStaleCodexPanes: state.keepRegistryOnly, + listStaleCodexPanes: vi.fn(() => []) +})) + +import { readKeepStalePanes, registerCodexAccountHandlers } from './codex-accounts' + +function keepStalePanes(args: unknown): unknown { + return state.handlers.get('codexAccounts:keepStalePanes')!({}, args) +} + +beforeEach(() => { + state.handlers.clear() + state.deps.current = null + state.keepWithConversations.mockReset() + state.keepRegistryOnly.mockReset() + state.forget.mockReset() + const service = { onPendingLoginUrlChanged: vi.fn(), setWslLoginUrlOpener: vi.fn() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration only subscribes to pending login URLs and sets the WSL link opener; no other service method runs here. + registerCodexAccountHandlers(service as unknown as CodexAccountService) +}) + +describe('codexAccounts:keepStalePanes', () => { + it('keeps panes and their conversations through the host producer when it runs', () => { + state.deps.current = { marker: true } + keepStalePanes({ panes: [{ ptyId: 'pty-1', paneKey: 'tab:leaf' }] }) + expect(state.keepWithConversations).toHaveBeenCalledWith(state.deps.current, [ + { ptyId: 'pty-1', paneKey: 'tab:leaf' } + ]) + expect(state.keepRegistryOnly).not.toHaveBeenCalled() + }) + + it('keeps the panes alone where no producer runs', () => { + keepStalePanes({ panes: [{ ptyId: 'pty-1' }] }) + expect(state.keepRegistryOnly).toHaveBeenCalledWith(['pty-1']) + expect(state.keepWithConversations).not.toHaveBeenCalled() + }) + + it('ignores a request with nothing valid in it', () => { + keepStalePanes({ ptyIds: ['pty-1'] }) + expect(state.keepRegistryOnly).not.toHaveBeenCalled() + expect(state.keepWithConversations).not.toHaveBeenCalled() + }) +}) + +describe('codexAccounts:forgetStalePanes', () => { + it('forgets a dismissed pane without keeping it or its conversation', () => { + state.deps.current = { marker: true } + state.handlers.get('codexAccounts:forgetStalePanes')!({}, { ptyIds: ['pty-1', 7] }) + expect(state.forget).toHaveBeenCalledWith(['pty-1']) + expect(state.keepWithConversations).not.toHaveBeenCalled() + expect(state.keepRegistryOnly).not.toHaveBeenCalled() + }) +}) + +describe('readKeepStalePanes', () => { + it('keeps well-formed panes and drops the rest', () => { + expect( + readKeepStalePanes([ + { ptyId: 'pty-1', paneKey: 'tab:leaf' }, + { ptyId: 'pty-2', paneKey: 7 }, + { ptyId: '' }, + { ptyId: 'x'.repeat(513) }, + null, + 'pty-3' + ]) + ).toEqual([{ ptyId: 'pty-1', paneKey: 'tab:leaf' }, { ptyId: 'pty-2' }]) + expect(readKeepStalePanes(undefined)).toEqual([]) + }) +}) diff --git a/src/main/ipc/codex-accounts.ts b/src/main/ipc/codex-accounts.ts index 10014baf4b37..d222e1d358a8 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -4,8 +4,14 @@ import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-sele import { broadcastCodexPendingLoginUrl } from './codex-pending-login-url-broadcast' import { openAccountSignInLink } from './account-sign-in-link-opener' import { listRecordedCodexPaneLanes } from '../codex/codex-pane-account-registry' -import { forgetStaleCodexPanes, listStaleCodexPanes } from '../codex/codex-stale-pane-accounts' +import { + forgetStaleCodexPanes, + keepStaleCodexPanes, + listStaleCodexPanes +} from '../codex/codex-stale-pane-accounts' import type { GlobalSettings } from '../../shared/global-settings-types' +import { keepStaleCodexPanesAndConversations } from '../agent-accounts/agent-account-keep' +import { installedAgentKeepDeps } from '../agent-accounts/agent-account-keep-wiring' export function registerCodexAccountHandlers( codexAccounts: CodexAccountService, @@ -35,6 +41,18 @@ export function registerCodexAccountHandlers( } forgetStaleCodexPanes(args.ptyIds.filter((ptyId): ptyId is string => typeof ptyId === 'string')) }) + ipcMain.handle('codexAccounts:keepStalePanes', (_event, args: { panes?: unknown }) => { + const panes = readKeepStalePanes(args?.panes) + if (panes.length === 0) { + return + } + const deps = installedAgentKeepDeps() + if (deps) { + keepStaleCodexPanesAndConversations(deps, panes) + } else { + keepStaleCodexPanes(panes.map((pane) => pane.ptyId)) + } + }) ipcMain.handle('codexAccounts:list', () => codexAccounts.listAccounts()) ipcMain.handle('codexAccounts:add', (_event, args?: CodexAccountAddTarget) => codexAccounts.addAccount(args) @@ -68,3 +86,26 @@ export function registerCodexAccountHandlers( } ) } + +export type KeepStaleCodexPane = { ptyId: string; paneKey?: string } + +export function readKeepStalePanes(value: unknown): KeepStaleCodexPane[] { + if (!Array.isArray(value)) { + return [] + } + const panes: KeepStaleCodexPane[] = [] + for (const entry of value) { + if (!entry || typeof entry !== 'object') { + continue + } + const ptyId = 'ptyId' in entry ? entry.ptyId : undefined + const paneKey = 'paneKey' in entry ? entry.paneKey : undefined + if (typeof ptyId === 'string' && ptyId.length > 0 && ptyId.length <= 512) { + panes.push({ + ptyId, + ...(typeof paneKey === 'string' && paneKey.length <= 512 ? { paneKey } : {}) + }) + } + } + return panes +} diff --git a/src/main/ipc/pty-claude-kept-launch.test.ts b/src/main/ipc/pty-claude-kept-launch.test.ts new file mode 100644 index 000000000000..868358578889 --- /dev/null +++ b/src/main/ipc/pty-claude-kept-launch.test.ts @@ -0,0 +1,209 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { installAgentRunAccounts } from '../agent-accounts/agent-run-accounts-installed' +import { + fakeRuntime, + installFakeLocalProvider, + installKeptIndex, + registerKeptAccountHandlers +} from './pty-kept-account-spawn.test-fixture' +import { + prepareInstalledClaudeLaunchForAccount, + withClaudeKeptAccountTerminalEnv +} from '../claude-accounts/claude-profile-account-route' +import { withClaudeProfileTerminalEnv } from '../claude-accounts/claude-profile-installed-router' + +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()) +) + +// Why: the route's own behaviour has its own suite; here only which route a spawn takes matters. +vi.mock('../claude-accounts/claude-profile-account-route', () => ({ + withClaudeKeptAccountTerminalEnv: vi.fn( + (env: Record | undefined, accountId: string | null) => ({ + ...env, + ORCA_CLAUDE_PROFILE_POINTER: `/data/claude-profile-pointers/account-${accountId}` + }) + ), + prepareInstalledClaudeLaunchForAccount: vi.fn(async (accountId: string | null) => ({ + configDir: `/data/claude-profiles/${accountId}/home`, + envPatch: { + ORCA_CLAUDE_PROFILE_POINTER: `/data/claude-profile-pointers/account-${accountId}`, + CLAUDE_CONFIG_DIR: `/data/claude-profiles/${accountId}/home`, + ORCA_CLAUDE_INJECTED_CONFIG_DIR: `/data/claude-profiles/${accountId}/home` + }, + provenance: `profile:${accountId}` + })) +})) +vi.mock('../claude-accounts/claude-profile-installed-router', async (importOriginal) => ({ + ...(await importOriginal()), + withClaudeProfileTerminalEnv: vi.fn((env: Record | undefined) => ({ + ...env, + ORCA_CLAUDE_PROFILE_POINTER: '/data/claude-profiles/selected-host' + })) +})) + +const RESUME = { key: 'session_id' as const, id: 's1' } +const TAB_ID = '55555555-5555-4555-8555-555555555555' +const LEAF_ID = '66666666-6666-4666-8666-666666666666' +const SETTINGS = { + activeClaudeManagedAccountId: 'account-b', + claudeManagedAccounts: [{ id: 'account-a' }, { id: 'account-b' }], + codexManagedAccounts: [] +} +const SELECTED_AUTH = { + configDir: '/data/claude-profiles/account-b/home', + envPatch: { ORCA_CLAUDE_PROFILE_POINTER: '/data/claude-profiles/selected-host' }, + provenance: 'profile:account-b' +} + +describe('a Claude resume in both spawn paths', () => { + const { handlers, mainWindow } = setupPtyIpcSuite() + afterEach(() => { + installAgentRunAccounts(undefined) + vi.mocked(withClaudeKeptAccountTerminalEnv).mockClear() + vi.mocked(prepareInstalledClaudeLaunchForAccount).mockClear() + vi.mocked(withClaudeProfileTerminalEnv).mockClear() + }) + + const lanes = { + window: async (prepareClaudeAuth: unknown) => { + handlers.clear() + registerKeptAccountHandlers({ mainWindow, settings: SETTINGS, prepareClaudeAuth }) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'claude --resume s1', + launchAgent: 'claude', + tabId: TAB_ID, + leafId: LEAF_ID, + env: { KEEP: 'me' }, + resumeProviderSession: RESUME + }) + }, + host: async (prepareClaudeAuth: unknown) => { + const runtime = fakeRuntime() + handlers.clear() + registerKeptAccountHandlers({ + mainWindow, + runtime: runtime.runtime, + settings: SETTINGS, + prepareClaudeAuth + }) + await runtime.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-runtime', + command: 'claude --resume s1', + launchAgent: 'claude', + tabId: TAB_ID, + leafId: LEAF_ID, + env: { KEEP: 'me' }, + resumeProviderSession: RESUME + }) + } + } + + it.each(['window', 'host'] as const)( + 'starts a kept conversation on its own account pointer: %s lane', + async (lane) => { + const notePaneLaunch = installKeptIndex({ + 'claude:s1': { lane: 'host', accountId: 'account-a', keptAt: 1 } + }) + const spawn = installFakeLocalProvider('pty-claude') + const prepareClaudeAuth = vi.fn(async () => SELECTED_AUTH) + + await lanes[lane](prepareClaudeAuth) + + const env = spawn.mock.calls[0]![0].env ?? {} + expect(env.ORCA_CLAUDE_PROFILE_POINTER).toBe( + '/data/claude-profile-pointers/account-account-a' + ) + expect(env.CLAUDE_CONFIG_DIR).toBe('/data/claude-profiles/account-a/home') + expect(env.KEEP).toBe('me') + expect(prepareInstalledClaudeLaunchForAccount).toHaveBeenCalledWith('account-a') + expect(prepareClaudeAuth).not.toHaveBeenCalled() + expect(withClaudeProfileTerminalEnv).not.toHaveBeenCalled() + expect(notePaneLaunch).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: `${TAB_ID}:${LEAF_ID}`, + claudeShellAccount: { accountId: 'account-a' }, + resumedSessionId: 's1', + fresh: true + }) + ) + } + ) + + it.each(['window', 'host'] as const)( + 'leaves a conversation nobody kept on the selection, with the same calls: %s lane', + async (lane) => { + installKeptIndex({}) + const spawn = installFakeLocalProvider('pty-claude') + const prepareClaudeAuth = vi.fn(async () => SELECTED_AUTH) + + await lanes[lane](prepareClaudeAuth) + + expect(withClaudeProfileTerminalEnv).toHaveBeenCalledWith( + expect.objectContaining({ KEEP: 'me' }), + { runtime: 'host' } + ) + expect(prepareClaudeAuth).toHaveBeenCalledWith({ runtime: 'host' }) + expect(withClaudeKeptAccountTerminalEnv).not.toHaveBeenCalled() + expect(prepareInstalledClaudeLaunchForAccount).not.toHaveBeenCalled() + expect(spawn.mock.calls[0]![0].env?.ORCA_CLAUDE_PROFILE_POINTER).toBe( + '/data/claude-profiles/selected-host' + ) + } + ) + + it.each(['window', 'host'] as const)( + 'refuses before spawning while the index cannot be read: %s lane', + async (lane) => { + installKeptIndex('unavailable') + const spawn = installFakeLocalProvider('pty-claude') + await expect(lanes[lane](vi.fn(async () => SELECTED_AUTH))).rejects.toThrow(/could not read/) + expect(spawn).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/ipc/pty-codex-kept-resume.test.ts b/src/main/ipc/pty-codex-kept-resume.test.ts new file mode 100644 index 000000000000..cf24881277ab --- /dev/null +++ b/src/main/ipc/pty-codex-kept-resume.test.ts @@ -0,0 +1,195 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { readFileSyncMock, recordCodexPaneAccountMock } from './pty-ipc-mock-registry' +import { TEST_CODEX_AUTH_JSON, TEST_MANAGED_ROOT } from './pty-ipc-test-constants' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { installAgentRunAccounts } from '../agent-accounts/agent-run-accounts-installed' +import { + fakeRuntime, + installFakeLocalProvider, + installKeptIndex, + registerKeptAccountHandlers +} from './pty-kept-account-spawn.test-fixture' + +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()) +) + +const KEPT_HOME = join(TEST_MANAGED_ROOT, 'kept', 'home') +const SELECTED_HOME = join(TEST_MANAGED_ROOT, 'selected', 'home') +const ROLLOUT = join(KEPT_HOME, 'sessions', '2026', '10', '14', 'rollout-a.jsonl') + +const SETTINGS = { + activeCodexManagedAccountId: 'account-b', + codexManagedAccounts: [ + { id: 'account-a', managedHomePath: KEPT_HOME }, + { id: 'account-b', managedHomePath: SELECTED_HOME } + ], + claudeManagedAccounts: [] +} + +const RESUME = { + key: 'session_id' as const, + id: 'session-a', + transcriptPath: ROLLOUT +} + +const KEPT_ON_A = { 'codex:session-a': { lane: 'host', accountId: 'account-a', keptAt: 1 } } + +describe('a kept Codex conversation in both spawn paths', () => { + const { handlers, mainWindow } = setupPtyIpcSuite() + afterEach(() => installAgentRunAccounts(undefined)) + + function register(runtime?: unknown) { + const prepareCodexSessionResume = vi.fn(async (_args: Record) => ({ + outcome: 'resume' as const, + codexHomePath: KEPT_HOME + })) + handlers.clear() + registerKeptAccountHandlers({ + mainWindow, + runtime, + settings: SETTINGS, + getSelectedCodexHomePath: vi.fn(() => SELECTED_HOME), + prepareCodexSessionResume + }) + readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) + return prepareCodexSessionResume + } + + it('resumes on the kept account in the window path and keeps the pane', async () => { + installKeptIndex(KEPT_ON_A) + const spawn = installFakeLocalProvider('pty-kept') + const prepare = register() + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'codex resume session-a', + launchAgent: 'codex', + // A restart request would otherwise move it to the selection. + replacesPtyId: 'pty-old', + resumeProviderSession: RESUME + }) + + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ keptAccount: { accountId: 'account-a' } }) + ) + expect(prepare.mock.calls[0]![0]).not.toHaveProperty('useSelectedAccount') + expect(recordCodexPaneAccountMock.mock.calls).toEqual([ + [ + 'pty-kept', + { selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home', kept: true } + ] + ]) + expect(spawn).toHaveBeenCalledOnce() + }) + + it('resumes on the kept account in the runtime path and keeps the pane', async () => { + installKeptIndex(KEPT_ON_A) + installFakeLocalProvider('pty-runtime-kept') + const runtime = fakeRuntime() + const prepare = register(runtime.runtime) + + await runtime.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-runtime', + command: 'codex resume session-a', + launchAgent: 'codex', + resumeProviderSession: RESUME + }) + + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ keptAccount: { accountId: 'account-a' } }) + ) + expect(recordCodexPaneAccountMock.mock.calls).toEqual([ + [ + 'pty-runtime-kept', + { selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home', kept: true } + ] + ]) + }) + + it('leaves a conversation nobody kept exactly as before', async () => { + installKeptIndex({}) + installFakeLocalProvider('pty-follow') + const prepare = register() + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'codex resume session-a', + launchAgent: 'codex', + replacesPtyId: 'pty-old', + resumeProviderSession: RESUME + }) + + expect(prepare.mock.calls[0]![0]).toEqual({ + providerSession: RESUME, + target: { runtime: 'host' }, + launchEnv: undefined, + useSelectedAccount: true + }) + expect(recordCodexPaneAccountMock.mock.calls[0]![1]).not.toHaveProperty('kept') + }) + + it('refuses before spawning when the keep cannot be honoured', async () => { + installKeptIndex({ + 'codex:session-a': { lane: 'host', accountId: 'account-gone', keptAt: 1 } + }) + const spawn = installFakeLocalProvider('pty-refused') + register() + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'codex resume session-a', + launchAgent: 'codex', + resumeProviderSession: RESUME + }) + ).rejects.toThrow(/no longer saved/) + expect(spawn).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index 4eba8d0b1641..c011d56f7795 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -41,6 +41,7 @@ export const setMigrationUnsupportedPtyMock: Mock = vi.fn() export const clearMigrationUnsupportedPtyMock: Mock = vi.fn() export const clearMigrationUnsupportedPtysForPaneKeyMock: Mock = vi.fn() export const clearPaneKeyAliasesForPtyMock: Mock = vi.fn() +export const recordLaunchResumeIdentityMock: Mock = vi.fn() export const recordCodexPaneAccountMock: Mock = vi.fn() export const forgetCodexPaneAccountMock: Mock = vi.fn() export const getCodexPaneAccountMock: Mock = vi.fn() @@ -134,7 +135,9 @@ export const agentHookServerModuleMock = () => ({ buildPtyEnv: buildAgentHookEnvMock, clearPaneState: clearAgentHookPaneStateMock, registerPaneKeyAlias: registerPaneKeyAliasMock, - clearPaneKeyAliasesForPty: clearPaneKeyAliasesForPtyMock + clearPaneKeyAliasesForPty: clearPaneKeyAliasesForPtyMock, + // Why: a resume spawn seeds its provider session into the hook store at commit. + recordLaunchResumeIdentity: recordLaunchResumeIdentityMock } }) diff --git a/src/main/ipc/pty-kept-account-spawn.test-fixture.ts b/src/main/ipc/pty-kept-account-spawn.test-fixture.ts new file mode 100644 index 000000000000..8106d71d97ba --- /dev/null +++ b/src/main/ipc/pty-kept-account-spawn.test-fixture.ts @@ -0,0 +1,97 @@ +// Test-only: the fakes both kept-account spawn suites share, so their casts live in one place. +import { vi } from 'vitest' +import type { Mock } from 'vitest' +import { registerPtyHandlers } from './pty/register-handlers' +import { setLocalPtyProvider } from './pty/provider/registry' +import { installAgentRunAccounts } from '../agent-accounts/agent-run-accounts-installed' +import type { AgentRunAccounts } from '../agent-accounts/agent-run-accounts' +import type { + KeptAgentConversations, + KeptConversation +} from '../agent-accounts/kept-agent-conversations' + +export type SpawnOptions = { env?: Record } + +/** Installs a kept-conversations index (and a producer that only records launches). */ +export function installKeptIndex(entries: Record | 'unavailable'): Mock { + const notePaneLaunch = vi.fn() + const kept: Pick = { + get: (provider, sessionId) => + entries === 'unavailable' ? 'unavailable' : (entries[`${provider}:${sessionId}`] ?? null) + } + installAgentRunAccounts({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: spawns call only notePaneLaunch on the producer. + producer: { notePaneLaunch } as unknown as AgentRunAccounts, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: spawns call only get on the index. + kept: kept as KeptAgentConversations + }) + return notePaneLaunch +} + +export function installFakeLocalProvider( + id: string +): Mock<(options: SpawnOptions) => Promise<{ id: string }>> { + const spawn = vi.fn(async (_options: SpawnOptions) => ({ id })) + const provider = { + spawn, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a spawn reaches only the provider members defined above. + setLocalPtyProvider(provider as never) + return spawn +} + +/** A runtime that only takes the controller registerPtyHandlers installs. */ +export function fakeRuntime(): { + runtime: unknown + spawn: (args: Record) => Promise<{ id: string }> +} { + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + return { + runtime, + spawn: (args) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registerPtyHandlers hands the runtime its pty controller, whose spawn takes these args. + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + spawn(spawnArgs: Record): Promise<{ id: string }> + } + return controller.spawn(args) + } + } +} + +export function registerKeptAccountHandlers(args: { + mainWindow: unknown + runtime?: unknown + settings: Record + getSelectedCodexHomePath?: () => string + prepareClaudeAuth?: unknown + prepareCodexSessionResume?: unknown +}): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers read only the window, runtime, settings and launch preparation these fakes define. + const handlerArgs = [ + args.mainWindow, + args.runtime, + args.getSelectedCodexHomePath, + () => args.settings, + args.prepareClaudeAuth, + undefined, + args.prepareCodexSessionResume + ? { prepareCodexSessionResume: args.prepareCodexSessionResume } + : undefined + ] as unknown as Parameters + registerPtyHandlers(...handlerArgs) +} diff --git a/src/main/ipc/pty/host-env/agent-account-launch.ts b/src/main/ipc/pty/host-env/agent-account-launch.ts new file mode 100644 index 000000000000..2b01f838905a --- /dev/null +++ b/src/main/ipc/pty/host-env/agent-account-launch.ts @@ -0,0 +1,118 @@ +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import { normalizeAgentProviderSession } from '../../../../shared/agent-session-resume' +import { getCodexSelectionLaneKey } from '../../../../shared/codex-selection-lane' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import { + resolveAgentLaunchAccount, + type AgentLaunchAccount +} from '../../../agent-accounts/agent-launch-account' +import { + getKeptAgentConversations, + noteAgentPaneLaunch +} from '../../../agent-accounts/agent-run-accounts-installed' +import type { PaneLaunchFact } from '../../../agent-accounts/agent-run-accounts' +import { withClaudeProfileTerminalEnv } from '../../../claude-accounts/claude-profile-installed-router' +import { + prepareInstalledClaudeLaunchForAccount, + withClaudeKeptAccountTerminalEnv +} from '../../../claude-accounts/claude-profile-account-route' +import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import type { PrepareClaudeAuth } from './types' + +export type SpawnAccountLaunch = Exclude + +/** The kept account a spawn must run on, or follow; throws the refusal before anything starts. */ +export function resolveSpawnAccountLaunch(args: { + connectionId: string | null | undefined + launchAgent: TuiAgent | undefined + resumeProviderSession: AgentProviderSessionMetadata | undefined + target: CodexAccountSelectionTarget + settings: Pick | undefined +}): SpawnAccountLaunch { + const account = resolveAgentLaunchAccount({ + ...args, + lane: getCodexSelectionLaneKey(args.target), + kept: getKeptAgentConversations() + }) + if (account.kind === 'refuse') { + throw account.error + } + return account +} + +function keptClaudeAccount(account: SpawnAccountLaunch): { accountId: string | null } | null { + return account.kind === 'bound' && account.provider === 'claude' + ? { accountId: account.accountId } + : null +} + +/** A kept Claude pane names its account's own pointer; everything else is unchanged. */ +export function withSpawnClaudeTerminalEnv | undefined>( + env: Env, + account: SpawnAccountLaunch, + target: CodexAccountSelectionTarget +): Env | Record { + const kept = keptClaudeAccount(account) + return kept + ? withClaudeKeptAccountTerminalEnv(env, kept.accountId) + : withClaudeProfileTerminalEnv(env, target) +} + +/** Null exactly when the selection path would not prepare auth, so a follow spawn awaits as before. */ +export function prepareSpawnClaudeAuth( + account: SpawnAccountLaunch, + target: CodexAccountSelectionTarget, + prepareClaudeAuth: PrepareClaudeAuth | undefined +): Promise | null { + const kept = keptClaudeAccount(account) + if (kept) { + return prepareInstalledClaudeLaunchForAccount(kept.accountId) + } + return prepareClaudeAuth ? prepareClaudeAuth(target) : null +} + +export function codexKeptAccountArg( + account: SpawnAccountLaunch | undefined +): { keptAccount: { accountId: string | null } } | Record { + return account?.kind === 'bound' && account.provider === 'codex' + ? { keptAccount: { accountId: account.accountId } } + : {} +} + +/** What the run-account producer needs to know about a kept start. */ +export function keptLaunchFact( + account: SpawnAccountLaunch | undefined, + resumeProviderSession: AgentProviderSessionMetadata | undefined +): Pick { + if (account?.kind !== 'bound') { + return {} + } + const resumedSessionId = normalizeAgentProviderSession(resumeProviderSession)?.id + return { + ...(account.provider === 'claude' + ? { claudeShellAccount: { accountId: account.accountId } } + : {}), + ...(resumedSessionId ? { resumedSessionId } : {}) + } +} + +/** Both spawn-commit paths: tells the run-account producer what this pane now runs. */ +export function noteSpawnAccountLaunch(args: { + paneKey: string | null | undefined + target: CodexAccountSelectionTarget + isReattach: boolean | undefined + account: SpawnAccountLaunch + resumeProviderSession: AgentProviderSessionMetadata | undefined +}): void { + if (!args.paneKey) { + return + } + noteAgentPaneLaunch({ + paneKey: args.paneKey, + lane: getCodexSelectionLaneKey(args.target), + fresh: args.isReattach !== true, + ...(args.isReattach ? {} : keptLaunchFact(args.account, args.resumeProviderSession)) + }) +} diff --git a/src/main/ipc/pty/host-env/codex-home.ts b/src/main/ipc/pty/host-env/codex-home.ts index 3b79c0e7876e..9b7b5e1a157e 100644 --- a/src/main/ipc/pty/host-env/codex-home.ts +++ b/src/main/ipc/pty/host-env/codex-home.ts @@ -12,6 +12,7 @@ import { type CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' import { resolveCodexPaneLaunchAccount } from '../../../codex/codex-pane-launch-account' +import { isCodexPaneKeepable } from '../../../codex/codex-stale-pane-accounts' import { getSystemCodexHomePath } from '../../../codex/codex-home-paths' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../../../pty/codex-home-wsl-env' import { isWslShellName } from '../../../../shared/local-windows-terminal-runtime' @@ -214,6 +215,8 @@ export function recordCodexPaneAccountForSpawn(args: { launchCodexHomePath: string | null target: CodexAccountSelectionTarget settings: GlobalSettings | undefined + /** The spawn resumed a conversation a person kept; its pane stays kept too. */ + kept?: boolean }): void { if (!args.ptyId || !args.isDaemonHostSpawn || args.isReattach) { return @@ -231,7 +234,10 @@ export function recordCodexPaneAccountForSpawn(args: { forgetCodexPaneAccount(args.ptyId) return } - recordCodexPaneAccount(args.ptyId, record) + recordCodexPaneAccount( + args.ptyId, + args.kept && isCodexPaneKeepable(record) ? { ...record, kept: true } : record + ) } export function snapshotCodexPaneHomeRoutes( diff --git a/src/main/ipc/pty/host-env/codex-resume-kept.test.ts b/src/main/ipc/pty/host-env/codex-resume-kept.test.ts new file mode 100644 index 000000000000..8984027916f7 --- /dev/null +++ b/src/main/ipc/pty/host-env/codex-resume-kept.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import { prepareCodexResumeHome } from './codex-resume' + +const SESSION = { key: 'session_id' as const, id: 'abc' } + +describe('prepareCodexResumeHome for a kept conversation', () => { + it('asks for the kept account and drops the selected-account restart target', async () => { + const prepare = vi.fn(async () => null) + prepareCodexResumeHome(prepare, { + launchAgent: 'codex', + providerSession: SESSION, + target: { runtime: 'host' }, + useSelectedAccount: true, + keptAccount: { accountId: 'account-a' } + }) + expect(prepare).toHaveBeenCalledWith({ + providerSession: SESSION, + target: { runtime: 'host' }, + launchEnv: undefined, + keptAccount: { accountId: 'account-a' } + }) + }) + + it('passes a System default keep through as null', () => { + const prepare = vi.fn(async () => null) + prepareCodexResumeHome(prepare, { + launchAgent: 'codex', + providerSession: SESSION, + target: { runtime: 'host' }, + keptAccount: { accountId: null } + }) + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ keptAccount: { accountId: null } }) + ) + }) + + it('keeps the selected-account restart target for a conversation nobody kept', () => { + const prepare = vi.fn(async () => null) + prepareCodexResumeHome(prepare, { + launchAgent: 'codex', + providerSession: SESSION, + target: { runtime: 'host' }, + useSelectedAccount: true + }) + expect(prepare).toHaveBeenCalledWith({ + providerSession: SESSION, + target: { runtime: 'host' }, + launchEnv: undefined, + useSelectedAccount: true + }) + }) +}) diff --git a/src/main/ipc/pty/host-env/codex-resume.ts b/src/main/ipc/pty/host-env/codex-resume.ts index 463660e00d7a..1f2e8352af4c 100644 --- a/src/main/ipc/pty/host-env/codex-resume.ts +++ b/src/main/ipc/pty/host-env/codex-resume.ts @@ -31,6 +31,8 @@ export type PrepareCodexResumeHomeArgs = { target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv useSelectedAccount?: boolean + /** A person kept this conversation on an account; wins over useSelectedAccount. */ + keptAccount?: { accountId: string | null } } export function prepareCodexResumeHome( @@ -50,7 +52,11 @@ export function prepareCodexResumeHome( providerSession, target: args.target, launchEnv: args.launchEnv, - ...(args.useSelectedAccount ? { useSelectedAccount: true } : {}) + ...(args.keptAccount + ? { keptAccount: args.keptAccount } + : args.useSelectedAccount + ? { useSelectedAccount: true } + : {}) }) } } diff --git a/src/main/ipc/pty/host-env/types.ts b/src/main/ipc/pty/host-env/types.ts index d7564184e49d..c9be98047dd1 100644 --- a/src/main/ipc/pty/host-env/types.ts +++ b/src/main/ipc/pty/host-env/types.ts @@ -59,6 +59,7 @@ export type PrepareCodexSessionResume = (args: { target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv useSelectedAccount?: boolean + keptAccount?: { accountId: string | null } }) => Promise export type CodexHomePtySpawnedLifecycleArgs = { diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index 03b7ef9e0bd8..485a8ad3d918 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -15,6 +15,7 @@ import { resolveCommittedPtySize, type PtyGrid } from '../delivery/attached-pty- import { discardUnpersistedPtySpawn } from '../pane/spawn-registration' import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' import type { PtyIpcSpawnState } from './spawn-state' +import { codexKeptAccountArg } from '../host-env/agent-account-launch' import { parseTerminalPanePlacement } from '../../../../shared/terminal-pane-placement' export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise { @@ -96,7 +97,8 @@ export function publishPtyIpcSpawnCommit(ctx: PtyIpcSpawnState, committedSize: P pinnedByResume: ctx.codexResumeHomeSelected, launchCodexHomePath: ctx.selectedCodexHomePath, target: ctx.codexSelectionTarget, - settings: ctx.deps.getSettings?.() + settings: ctx.deps.getSettings?.(), + kept: codexKeptAccountArg(ctx.accountLaunch).keptAccount !== undefined }) ptyOwnership.set(ctx.result.id, LOCAL_EXECUTION_HOST_ID) if (ctx.result.incarnationId) { diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 2b5c078164cb..19515fbc8569 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -9,6 +9,7 @@ import { codexReattachedHomeRouteField } from '../host-env/codex-home' import { rememberPaneKeyForPty } from '../pane/key-state' +import { noteSpawnAccountLaunch } from '../host-env/agent-account-launch' import { resolvePaneSpawnReservation } from '../pane/spawn-reservation' import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' import { seedResumeLaunchIdentityFromSpawn } from '../pane/resume-launch-identity' @@ -152,6 +153,14 @@ async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args @@ -25,6 +26,7 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis callerNamedSession: args.sessionId !== undefined, mintedSessionId: ctx.isMintedSessionId, resumeOnSelectedAccount: Boolean(args.replacesPtyId), + ...codexKeptAccountArg(ctx.accountLaunch), emptySessionIdSkipsHostEnv: false, requestedAgentTeamsPath: ctx.requestedAgentTeamsPath, provider: ctx.provider, diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index f791190a074c..1055586cbc0a 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -1,4 +1,8 @@ -import { withClaudeProfileTerminalEnv } from '../../../claude-accounts/claude-profile-installed-router' +import { + prepareSpawnClaudeAuth, + resolveSpawnAccountLaunch, + withSpawnClaudeTerminalEnv +} from '../host-env/agent-account-launch' import { mintPtySessionId } from '../../../daemon/pty-session-id' import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { @@ -108,10 +112,23 @@ 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 + ctx.accountLaunch = ctx.preAdoptedStablePane + ? { kind: 'follow' } + : resolveSpawnAccountLaunch({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + resumeProviderSession: args.resumeProviderSession, + target: ctx.codexSelectionTarget, + settings: ctx.deps.getSettings?.() + }) + args.env = withSpawnClaudeTerminalEnv(args.env, ctx.accountLaunch, ctx.codexSelectionTarget) + const claudeAuth = ctx.isClaudeLaunch + ? prepareSpawnClaudeAuth( + ctx.accountLaunch, + ctx.codexSelectionTarget, + ctx.deps.prepareClaudeAuth + ) + : null + ctx.claudeAuth = claudeAuth ? await claudeAuth : null ctx.spawnTiming.mark('auth') } diff --git a/src/main/ipc/pty/ipc/spawn-state.ts b/src/main/ipc/pty/ipc/spawn-state.ts index 6b24c4d24b45..62d36087c2d0 100644 --- a/src/main/ipc/pty/ipc/spawn-state.ts +++ b/src/main/ipc/pty/ipc/spawn-state.ts @@ -10,6 +10,7 @@ import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runt import type { PtySpawnTiming } from '../../pty-spawn-timing' import { createPtySpawnTiming } from '../../pty-spawn-timing' import { noCodexResumeLaunch, type CodexResumeLaunch } from '../host-env/codex-resume' +import type { SpawnAccountLaunch } from '../host-env/agent-account-launch' import type { StablePaneOwner } from '../pane/stable-owner' import type { PaneSpawnReservation } from '../pane/spawn-reservation' import type { ReplacedPaneOwner } from '../pane/pane-owner-replacement' @@ -80,6 +81,8 @@ export type PtyIpcSpawnState = { nativeWindowsConptySpawn: boolean codexSelectionTarget: CodexAccountSelectionTarget codexResumeLaunch: CodexResumeLaunch + /** Kept agents run on their account; everything else follows the selection. */ + accountLaunch: SpawnAccountLaunch launchCommand: string | undefined env: Record | undefined selectedCodexHomePath: string | null @@ -156,6 +159,7 @@ export function createPtyIpcSpawnState( nativeWindowsConptySpawn: false, codexSelectionTarget: { runtime: 'host' }, codexResumeLaunch: noCodexResumeLaunch(undefined), + accountLaunch: { kind: 'follow' }, launchCommand: undefined, env: undefined, selectedCodexHomePath: null, diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index b72c9b2a79b4..189ead412698 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -13,6 +13,7 @@ import { } from '../host-env/codex-home' import { registerPty } from '../../../memory/pty-registry' import { rememberPaneKeyForPty } from '../pane/key-state' +import { codexKeptAccountArg, noteSpawnAccountLaunch } from '../host-env/agent-account-launch' import { pendingByPaneKey, pendingPtyIdBySerializerGeneration, @@ -221,7 +222,8 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { pinnedByResume: ctx.codexResumeHomeSelected, launchCodexHomePath: ctx.selectedCodexHomePath, target: ctx.codexSelectionTarget, - settings: ctx.deps.getSettings?.() + settings: ctx.deps.getSettings?.(), + kept: codexKeptAccountArg(ctx.accountLaunch).keptAccount !== undefined }) // Why: runtime-controller creates (headless serve, CLI, splits) adopt surviving daemon sessions too; without this seed their records stay blank. seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result) @@ -234,6 +236,13 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { } // Why: runtime-owned CLI PTYs bypass the renderer pty:spawn handler; record paneKey here too since hook titles and cache cleanup need this reverse lookup. const paneKey = rememberPaneKeyForPty(ctx.result.id, ctx.env?.ORCA_PANE_KEY) + noteSpawnAccountLaunch({ + paneKey, + target: ctx.codexSelectionTarget, + isReattach: ctx.result.isReattach, + account: ctx.accountLaunch, + resumeProviderSession: args.resumeProviderSession + }) const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined const inheritRendererReadiness = ctx.result.isReattach === true && diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 39dc59e3afba..5718cdfe2bc1 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -1,4 +1,9 @@ -import { withClaudeProfileTerminalEnv } from '../../../claude-accounts/claude-profile-installed-router' +import { + codexKeptAccountArg, + prepareSpawnClaudeAuth, + resolveSpawnAccountLaunch, + withSpawnClaudeTerminalEnv +} from '../host-env/agent-account-launch' import type { PtySpawnResult } from '../../../providers/pty-provider-contract' import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' @@ -76,11 +81,24 @@ 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 + ctx.accountLaunch = ctx.preAdoptedStablePane + ? { kind: 'follow' } + : resolveSpawnAccountLaunch({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + resumeProviderSession: args.resumeProviderSession, + target: ctx.codexSelectionTarget, + settings: ctx.deps.getSettings?.() + }) + args.env = withSpawnClaudeTerminalEnv(args.env, ctx.accountLaunch, ctx.codexSelectionTarget) + const claudeAuth = ctx.isClaudeLaunch + ? prepareSpawnClaudeAuth( + ctx.accountLaunch, + ctx.codexSelectionTarget, + ctx.deps.prepareClaudeAuth + ) + : null + ctx.claudeAuth = claudeAuth ? await claudeAuth : null ctx.shouldPersistHostSessionBinding = args.persistHostSessionBinding === true if (ctx.shouldPersistHostSessionBinding) { @@ -141,6 +159,7 @@ export async function prepareRuntimePtySpawn( callerNamedSession: ctx.callerRequestedSessionId !== undefined, mintedSessionId: ctx.requestedSessionId === undefined, resumeOnSelectedAccount: false, + ...codexKeptAccountArg(ctx.accountLaunch), emptySessionIdSkipsHostEnv: true, requestedAgentTeamsPath: ctx.requestedAgentTeamsPath, provider: ctx.provider, diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 57a3ef2394a1..95d4af8fa456 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -8,6 +8,7 @@ import type { import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service' +import type { SpawnAccountLaunch } from '../host-env/agent-account-launch' import type { StablePaneOwner } from '../pane/stable-owner' import type { PaneSpawnReservation } from '../pane/spawn-reservation' import type { AdoptStablePaneResult } from '../ipc/spawn-types' @@ -61,6 +62,8 @@ export type RuntimePtySpawnState = { selectedCodexHomePath: string | null codexResumeHomeSelected: boolean codexResumeArgvDropped: boolean + /** Kept agents run on their account; everything else follows the selection. */ + accountLaunch: SpawnAccountLaunch skipCodexHomeEnv: boolean stripInheritedOrcaCodexHome: boolean spawnOptions: PtySpawnOptions @@ -177,6 +180,7 @@ export function createRuntimePtySpawnState( selectedCodexHomePath: null, codexResumeHomeSelected: false, codexResumeArgvDropped: false, + accountLaunch: { kind: 'follow' }, skipCodexHomeEnv: false, stripInheritedOrcaCodexHome: false, spawnOptions: { cols: args.cols, rows: args.rows }, diff --git a/src/main/ipc/pty/spawn-host-env.ts b/src/main/ipc/pty/spawn-host-env.ts index b7d83ca344cb..27c77397dc34 100644 --- a/src/main/ipc/pty/spawn-host-env.ts +++ b/src/main/ipc/pty/spawn-host-env.ts @@ -84,6 +84,8 @@ type PtySpawnHostEnvArgs = { mintedSessionId: boolean /** An account-switch restart resumes onto the selected account; only the window has one. */ resumeOnSelectedAccount: boolean + /** A person kept this conversation on an account; its resume goes there, over the selection. */ + keptAccount?: { accountId: string | null } /** TEMPORARY (row 7 removes it): the host builds no host env for an empty id; the window refuses it. */ emptySessionIdSkipsHostEnv: boolean requestedAgentTeamsPath: string | undefined @@ -106,7 +108,8 @@ export async function resolvePtySpawnHostEnv(args: PtySpawnHostEnvArgs): Promise target, // Why: a host runtime skips resume prep for a launch whose env names a client-sent CODEX_HOME. launchEnv: args.env, - ...(args.resumeOnSelectedAccount ? { useSelectedAccount: true } : {}) + ...(args.resumeOnSelectedAccount ? { useSelectedAccount: true } : {}), + ...(args.keptAccount ? { keptAccount: args.keptAccount } : {}) }) const codexResumeLaunch = codexResumePreparation ? await deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) 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 547bb0125f07..b187f40b4e00 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 @@ -33,6 +33,7 @@ const { registerAiVaultSearchHandlersMock, registerOrcaProfileHandlersMock, registerCodexAccountHandlersMock, + registerAgentAccountKeepHandlersMock, registerAgentHookHandlersMock, registerClaudeAccountHandlersMock, registerOpenCodeGoCredentialsHandlersMock, @@ -104,6 +105,7 @@ const { registerAiVaultSearchHandlersMock: vi.fn(), registerOrcaProfileHandlersMock: vi.fn(), registerCodexAccountHandlersMock: vi.fn(), + registerAgentAccountKeepHandlersMock: vi.fn(), registerAgentHookHandlersMock: vi.fn(), registerClaudeAccountHandlersMock: vi.fn(), registerOpenCodeGoCredentialsHandlersMock: vi.fn(), @@ -345,6 +347,10 @@ vi.mock('../codex-accounts', () => ({ registerCodexAccountHandlers: registerCodexAccountHandlersMock })) +vi.mock('../agent-account-keep', () => ({ + registerAgentAccountKeepHandlers: registerAgentAccountKeepHandlersMock +})) + vi.mock('../agent-hooks', () => ({ registerAgentHookHandlers: registerAgentHookHandlersMock })) @@ -477,6 +483,7 @@ describe('registerCoreHandlers', () => { registerAiVaultHandlersMock.mockReset() registerOrcaProfileHandlersMock.mockReset() registerCodexAccountHandlersMock.mockReset() + registerAgentAccountKeepHandlersMock.mockReset() registerAgentHookHandlersMock.mockReset() registerClaudeAccountHandlersMock.mockReset() registerOpenCodeGoCredentialsHandlersMock.mockReset() @@ -581,6 +588,7 @@ describe('registerCoreHandlers', () => { codexAccounts, expect.any(Function) ) + expect(registerAgentAccountKeepHandlersMock).toHaveBeenCalledOnce() expect(registerAgentHookHandlersMock).toHaveBeenCalledWith(runtime, { getPtyIdForPaneKey: expect.any(Function) }) 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 a39b797fdf45..3718bb6b8376 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -64,6 +64,7 @@ import { registerSpeechHandlers } from '../speech' import { registerTerminalRenderDesyncEvidenceHandler } from '../terminal-render-desync-evidence' import { registerOrcaProfileHandlers } from '../orca-profiles' import { registerCodexAccountHandlers } from '../codex-accounts' +import { registerAgentAccountKeepHandlers } from '../agent-account-keep' import { registerAgentHookHandlers } from '../agent-hooks' import { registerCodexConfigSyncHandlers } from '../codex-config-sync' import { getPtyIdForPaneKey } from '../pty/pane/key-state' @@ -175,6 +176,7 @@ export function registerCoreHandlers( registerPreflightHandlers() registerUsageProviderHandlers({ claudeUsage, codexUsage, openCodeUsage, museUsage }) registerCodexAccountHandlers(codexAccounts, () => store.getSettings()) + registerAgentAccountKeepHandlers() registerAgentHookHandlers(runtime, { getPtyIdForPaneKey }) registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService) registerClaudeAccountHandlers(claudeAccounts) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 0e028ae2c2ab..48f7780c9207 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -36,10 +36,7 @@ export type StructuredAgentSessionMutationContext = { publishChildWork: (sessionId: string, evidence: AgentChildWorkEvidence[]) => void /** What the live child holds changed with no row (`publishGenerationEnded`). */ publishWorkChanged: (sessionId: string) => void - captureChildWorkStops: ( - sessionId: string, - handleIds: readonly string[] - ) => CapturedChildWorkStops + captureChildWorkStops: (sessionId: string, handleIds: readonly string[]) => CapturedChildWorkStops /** Some of the session's running child work has no record. */ childWorkOpen: (sessionId: string) => boolean childWorkIncomplete: (sessionId: string) => boolean diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts index f6e4705ee683..9a90800184ac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts @@ -53,13 +53,15 @@ export function unfinishedStructuredAgentSessionWorkWasInterrupted( ): boolean { const currentSnapshot = journal.snapshot() if ( - journal.submissions?.().some( - (send) => - heldSends?.has(send.clientMessageId) && - send.fence === exit?.ownerFence && - !isQueuedAgentJournalSubmission(send) && - (send.dispatchState === 'pending' || send.dispatchState === 'unknown') - ) || + journal + .submissions?.() + .some( + (send) => + heldSends?.has(send.clientMessageId) && + send.fence === exit?.ownerFence && + !isQueuedAgentJournalSubmission(send) && + (send.dispatchState === 'pending' || send.dispatchState === 'unknown') + ) || currentSnapshot.items.some(isInProgressStructuredAgentSessionItem) ) { return true diff --git a/src/main/persist-retry-backoff.ts b/src/main/persist-retry-backoff.ts new file mode 100644 index 000000000000..e8705d7cc374 --- /dev/null +++ b/src/main/persist-retry-backoff.ts @@ -0,0 +1,29 @@ +const RETRY_DELAYS_MS = [100, 1_000, 5_000, 30_000] as const + +/** Retries a failed persist on a short backoff; after the last delay it waits for the next mutation. */ +export class PersistRetryBackoff { + private timer: ReturnType | null = null + private attempt = 0 + + constructor(private readonly retry: () => void) {} + + schedule(): void { + if (this.timer || this.attempt >= RETRY_DELAYS_MS.length) { + return + } + this.timer = setTimeout(() => { + this.timer = null + this.retry() + }, RETRY_DELAYS_MS[this.attempt]) + this.attempt += 1 + this.timer.unref?.() + } + + clear(): void { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + this.attempt = 0 + } +} diff --git a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts index 9f66a0509327..2541f004e00a 100644 --- a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -121,7 +121,9 @@ describe('a live Claude chat whose send could not be written', () => { expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') ) const snapshot = await host.journalSnapshot(SESSION) - expect(snapshot.items.some((item) => item.body.kind === 'status' && item.body.failure)).toBe(false) + expect(snapshot.items.some((item) => item.body.kind === 'status' && item.body.failure)).toBe( + false + ) expect( snapshot.items.some((item) => item.body.kind === 'turn' && item.body.state === 'completed') ).toBe(true) diff --git a/src/main/runtime/session-tabs/structured-session-create-support.ts b/src/main/runtime/session-tabs/structured-session-create-support.ts index 2220e7499a07..e7078175b42b 100644 --- a/src/main/runtime/session-tabs/structured-session-create-support.ts +++ b/src/main/runtime/session-tabs/structured-session-create-support.ts @@ -161,7 +161,8 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte selfSessionId: input.envelope.sessionId }) } - const structuredAgentCliSourceEvidence = input.structuredAgentCliSourceEvidence ?? + const structuredAgentCliSourceEvidence = + input.structuredAgentCliSourceEvidence ?? (input.resumeFrom?.cliSource ? captureStructuredAgentCliSource(() => this.getAgentProviderSessionRows()) : undefined) @@ -241,7 +242,9 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte return committedReplay } const resumeFrom = input.resumeFrom - const workspaceCwd = resumeFrom ? (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path : '' + const workspaceCwd = resumeFrom + ? (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path + : '' const selectedAccountHome = await resolveAccountHome({ launchEnv, location }) // Adopting pins the account home to wherever the conversation actually lives, which is not // necessarily the one a fresh create would pick: Codex resolves its rollout under @@ -254,10 +257,13 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte agent: input.agent, transcriptImport, providerSessionId: resumeFrom.providerSessionId, - resolveTranscriptPath: () => structuredAgentCliSourcePathFromEvidence({ - agent: input.agent, resumeFrom, workspaceId: location.workspaceId, - evidence: input.structuredAgentCliSourceEvidence - }), + resolveTranscriptPath: () => + structuredAgentCliSourcePathFromEvidence({ + agent: input.agent, + resumeFrom, + workspaceId: location.workspaceId, + evidence: input.structuredAgentCliSourceEvidence + }), prepareCodexResume: this.prepareAiVaultSessionResumeFn ?? undefined, selectedAccountHome, launchEnv: await resolveHostStructuredAgentLaunchEnvironment(input.agent, settings), diff --git a/src/main/startup/codex-session-resume-kept.test.ts b/src/main/startup/codex-session-resume-kept.test.ts new file mode 100644 index 000000000000..bcd4dc32bb67 --- /dev/null +++ b/src/main/startup/codex-session-resume-kept.test.ts @@ -0,0 +1,165 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { VerifiedCodexResumeSource } from '../codex/codex-session-resume-preparation' +import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' + +const mocks = vi.hoisted(() => ({ + systemHomePath: '', + sharedHomePath: '', + prepareCodexSessionResume: vi.fn(), + resolveSelected: vi.fn((): string | null => null), + resolveKept: vi.fn((_accountId: string): string => '') +})) + +vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp/orca-user-data') } })) +vi.mock('../codex/hook-service', () => ({ + codexHookService: { + installForLaunchPrep: vi.fn(async () => {}), + refreshRuntimeUserHooksForLaunchPrep: vi.fn(async () => {}) + } +})) +vi.mock('../codex/codex-hook-reconcile', () => ({ + reconcileCodexHooksForLaunch: vi.fn(async () => {}) +})) +vi.mock('../agent-hooks/managed-agent-hook-controls', () => ({ + isAgentStatusHooksEnabledForAgent: () => false +})) +vi.mock('../codex/codex-home-paths', async (importOriginal) => ({ + ...(await importOriginal()), + getSystemCodexHomePath: () => mocks.systemHomePath, + getOrcaManagedCodexHomePath: () => mocks.sharedHomePath +})) +vi.mock('../codex/codex-session-resume-preparation', () => ({ + prepareCodexSessionResume: mocks.prepareCodexSessionResume +})) +vi.mock('../codex/codex-legacy-session-resume', async (importOriginal) => ({ + ...(await importOriginal()), + prepareLegacySharedCodexSessionResume: vi.fn(async () => ({ useRealCodexHome: false })) +})) +vi.mock('./main-process-state', () => ({ + mainProcessState: { + codexRuntimeHome: { + isHostSystemDefaultRealHomeSelected: () => false, + getHostCodexHomePathsForSessionDiscovery: () => [], + resolveSelectedHostAccountCodexHomePathForResume: mocks.resolveSelected, + resolveHostAccountCodexHomePathForKeptResume: mocks.resolveKept + }, + store: { + getSettings: () => ({ + codexManagedAccounts: [{ id: 'account-a', email: 'a@example.com' }] + }) + } + } +})) + +import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' + +const ROLLOUT = join('sessions', '2026', '10', '14', 'rollout-2026-10-14T10-00-00-abc.jsonl') + +describe('resuming a kept Codex conversation', () => { + let root: string + let keptHome: string + let selectedHome: string + let rankedHome: string | null | undefined + + beforeEach(() => { + vi.clearAllMocks() + root = mkdtempSync(join(tmpdir(), 'orca-kept-resume-')) + // Why: the move writes its audit record under userData, which must be a temp folder here. + vi.stubEnv('ORCA_USER_DATA_PATH', join(root, 'user-data')) + keptHome = join(root, 'codex-accounts', 'account-a', 'home') + selectedHome = join(root, 'codex-accounts', 'account-b', 'home') + mocks.systemHomePath = join(root, 'system', '.codex') + mocks.sharedHomePath = join(root, 'codex-runtime-home', 'home') + for (const home of [keptHome, selectedHome, mocks.systemHomePath, mocks.sharedHomePath]) { + mkdirSync(home, { recursive: true }) + } + // The rollout lives in the selected account's home; the conversation is kept on A. + mkdirSync(dirname(join(selectedHome, ROLLOUT)), { recursive: true }) + writeFileSync(join(selectedHome, ROLLOUT), '{"type":"session_meta"}\n') + mocks.resolveSelected.mockReturnValue(selectedHome) + mocks.resolveKept.mockReturnValue(keptHome) + rankedHome = undefined + mocks.prepareCodexSessionResume.mockImplementation( + async (args: { + getSelectedAccountCodexHome: () => string | null + resolveVerifiedResumeHome: (source: VerifiedCodexResumeSource) => Promise + }) => { + rankedHome = args.getSelectedAccountCodexHome() + return { + outcome: 'resume' as const, + codexHomePath: await args.resolveVerifiedResumeHome({ + homePath: selectedHome, + transcriptPath: join(selectedHome, ROLLOUT) + }) + } + } + ) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) + }) + + function resume( + keptAccount: { accountId: string | null }, + launchEnv?: NodeJS.ProcessEnv + ): ReturnType { + return prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: 'abc' }, + target: { runtime: 'host' }, + keptAccount, + ...(launchEnv ? { launchEnv } : {}) + }) + } + + it('moves the conversation into the kept home and never consults the selection', async () => { + await expect(resume({ accountId: 'account-a' })).resolves.toMatchObject({ + outcome: 'resume', + codexHomePath: keptHome + }) + expect(existsSync(join(keptHome, ROLLOUT))).toBe(true) + expect(mocks.resolveKept).toHaveBeenCalledWith('account-a') + expect(mocks.resolveSelected).not.toHaveBeenCalled() + expect(rankedHome).toBe(keptHome) + }) + + it('resumes a conversation kept on System default in the system home', async () => { + await expect(resume({ accountId: null })).resolves.toMatchObject({ + codexHomePath: mocks.systemHomePath + }) + expect(mocks.resolveKept).not.toHaveBeenCalled() + }) + + it('refuses System default while a custom CODEX_HOME is set, never using the mirror', async () => { + await expect(resume({ accountId: null }, { CODEX_HOME: join(root, 'custom') })).rejects.toThrow( + /CODEX_HOME is set/ + ) + expect(mocks.prepareCodexSessionResume).not.toHaveBeenCalled() + }) + + it('refuses while the kept home is unreadable instead of falling back', async () => { + mocks.resolveKept.mockImplementation(() => { + throw new ManagedCodexHomeTemporarilyUnavailableError() + }) + await expect(resume({ accountId: 'account-a' })).rejects.toBeInstanceOf( + ManagedCodexHomeTemporarilyUnavailableError + ) + expect(mocks.prepareCodexSessionResume).not.toHaveBeenCalled() + }) + + it('refuses a kept conversation whose transcript was not found', async () => { + mocks.prepareCodexSessionResume.mockResolvedValue({ + outcome: 'fresh', + claimedCodexProvenance: true + }) + await expect(resume({ accountId: 'account-a' })).rejects.toThrow( + /kept on a@example\.com, but its conversation was not found/ + ) + }) +}) diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 380a0cf7fcb7..3fe44a2abd80 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -18,6 +18,7 @@ import { prewarmStructuredAgentModelCatalogs } from '../runtime/structured-agent import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { createAgentModelCatalogSettingsExpiry } from '../native-chat/agent-model-catalog/agent-model-catalog-account-expiry' import { mainProcessState as state } from './main-process-state' +import { startAgentRunAccounts } from './main-process-agent-run-accounts' // Settings that change which account or binary a new chat's agent runs under. const MODEL_CATALOG_ACCOUNT_SETTINGS = [ @@ -67,6 +68,12 @@ export function initializeMainProcessAccountServices(): void { // Why: migrate historical shared-home sessions after startup; compatibility // launches re-arm the non-destructive pass for new rollouts (#4444, #8612, #12480). state.codexSessionMigration.scheduleInitialRun() + startAgentRunAccounts({ + server: agentHookServer, + getSettings: () => store.getSettings(), + onSettingsChanged: (listener) => store.onSettingsChanged(listener), + claudeRouter: services.claudeRuntimeAuth.router + }) const expireModelCatalogs = createAgentModelCatalogSettingsExpiry( agentModelCatalogStore, store.getSettings() diff --git a/src/main/startup/main-process-agent-run-accounts.test.ts b/src/main/startup/main-process-agent-run-accounts.test.ts new file mode 100644 index 000000000000..9dfc1e2ffbda --- /dev/null +++ b/src/main/startup/main-process-agent-run-accounts.test.ts @@ -0,0 +1,237 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AgentHookServer, _internals as hookServerInternals } from '../agent-hooks/server' +import { buildBody, postHookEvent } from '../agent-hooks/server.test-fixtures' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { AgentRunAccountSettings } from '../agent-accounts/agent-run-accounts' +import type { ClaudeManagedAccount, CodexManagedAccount } from '../../shared/managed-account-types' +import { + _internals as registryInternals, + getCodexPaneAccount, + recordCodexPaneAccount, + setCodexPaneKept +} from '../codex/codex-pane-account-registry' +import { paneKeyPtyId, ptyPaneKey, rememberPaneKeyForPty } from '../ipc/pty/pane/key-state' +import { + getAgentRunAccounts, + getKeptAgentConversations, + installAgentRunAccounts, + noteAgentPaneLaunch +} from '../agent-accounts/agent-run-accounts-installed' +import { startAgentRunAccounts } from './main-process-agent-run-accounts' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +let userDataPath: string +let previousUserDataPath: string | undefined + +function codexAccount(id: string): CodexManagedAccount { + return { + id, + email: `${id}@example.com`, + managedHomePath: `/data/codex-accounts/${id}/home`, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } +} + +function claudeAccount(id: string): ClaudeManagedAccount { + return { + id, + email: `${id}@example.com`, + managedAuthPath: `/data/claude-accounts/${id}`, + authMethod: 'subscription-oauth', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } +} + +beforeEach(() => { + hookServerInternals.resetCachesForTests() + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + userDataPath = mkdtempSync(join(tmpdir(), 'orca-run-accounts-')) + process.env.ORCA_USER_DATA_PATH = userDataPath + registryInternals.resetCache() +}) + +afterEach(() => { + installAgentRunAccounts(undefined) + ptyPaneKey.clear() + paneKeyPtyId.clear() + rmSync(userDataPath, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + registryInternals.resetCache() +}) + +describe('startAgentRunAccounts', () => { + it('publishes a terminal agent account onto its status row and reacts to account changes', () => { + const server = new AgentHookServer() + let settings: AgentRunAccountSettings = { + codexManagedAccounts: [codexAccount('acc-a')], + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: undefined + } + const settingsListeners: ((updates: Partial) => void)[] = [] + startAgentRunAccounts({ + server, + getSettings: () => settings, + onSettingsChanged: (listener) => settingsListeners.push(listener), + claudeRouter: null, + keptFilePath: join(userDataPath, 'kept.json') + }) + expect(getAgentRunAccounts()).toBeDefined() + rememberPaneKeyForPty('pty-1', PANE) + recordCodexPaneAccount('pty-1', { + selectionKey: 'host', + accountId: 'acc-a', + homeRoute: 'account-home' + }) + + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt', + connectionId: null, + payload: { state: 'working', prompt: 'go', agentType: 'codex' } + }) + expect(server.getRunAccountRow(PANE)?.runAccount).toEqual({ + provider: 'codex', + lane: 'host', + accountId: 'acc-a', + mode: 'follow' + }) + + settings = { ...settings, codexManagedAccounts: [] } + for (const listener of settingsListeners) { + listener({ codexManagedAccounts: [] }) + } + expect(server.getRunAccountRow(PANE)?.runAccount).toBeUndefined() + }) + + it('releases what was kept on a removed account and leaves other accounts alone', () => { + const server = new AgentHookServer() + let settings: AgentRunAccountSettings = { + codexManagedAccounts: [codexAccount('acc-a'), codexAccount('acc-b')], + claudeManagedAccounts: [claudeAccount('acc-a')], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: undefined + } + const settingsListeners: ((updates: Partial) => void)[] = [] + startAgentRunAccounts({ + server, + getSettings: () => settings, + onSettingsChanged: (listener) => settingsListeners.push(listener), + claudeRouter: null, + keptFilePath: join(userDataPath, 'kept.json') + }) + const kept = getKeptAgentConversations()! + const record = { selectionKey: 'host', homeRoute: 'account-home' } as const + rememberPaneKeyForPty('pty-1', PANE) + recordCodexPaneAccount('pty-1', { ...record, accountId: 'acc-a' }) + recordCodexPaneAccount('pty-2', { ...record, accountId: 'acc-b' }) + setCodexPaneKept('pty-1', true) + setCodexPaneKept('pty-2', true) + kept.keep('codex', 'c1', { lane: 'host', accountId: 'acc-a', keptAt: 1 }) + kept.keep('codex', 'c2', { lane: 'host', accountId: 'acc-b', keptAt: 1 }) + kept.keep('claude', 'k1', { lane: 'host', accountId: 'acc-a', keptAt: 1 }) + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt', + connectionId: null, + payload: { state: 'working', prompt: 'go', agentType: 'codex' } + }) + expect(server.getRunAccountRow(PANE)?.runAccount).toMatchObject({ mode: 'kept' }) + + settings = { ...settings, codexManagedAccounts: [codexAccount('acc-b')] } + for (const listener of settingsListeners) { + listener({ codexManagedAccounts: settings.codexManagedAccounts }) + } + + expect(kept.get('codex', 'c1')).toBeNull() + expect(kept.get('codex', 'c2')).toMatchObject({ accountId: 'acc-b' }) + expect(kept.get('claude', 'k1')).toMatchObject({ accountId: 'acc-a' }) + expect(getCodexPaneAccount('pty-1')).not.toHaveProperty('kept') + expect(getCodexPaneAccount('pty-2')).toMatchObject({ kept: true }) + expect(server.getRunAccountRow(PANE)?.runAccount).toMatchObject({ + accountId: 'acc-a', + mode: 'follow' + }) + + settings = { ...settings, claudeManagedAccounts: [] } + for (const listener of settingsListeners) { + listener({ claudeManagedAccounts: [] }) + } + expect(kept.get('claude', 'k1')).toBeNull() + }) + + // The daemon keeps a terminal alive across an Orca restart, and the hydrated row carries the + // facet the last run persisted, while the idle agent posts nothing that would re-derive it. + it('keeps a kept Claude agent kept when its terminal reattaches after a restart', async () => { + const first = new AgentHookServer() + await first.start({ env: 'production', userDataPath }) + await postHookEvent( + first, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'go', session_id: 's1' }) + ) + const keptFacet = { + provider: 'claude', + lane: 'host', + accountId: 'acc-a', + mode: 'kept', + shellRoute: 'selected' + } as const + expect(first.republishRunAccount(PANE, keptFacet)).toBe(true) + first.flushStatusPersistSync() + first.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + // Positive control: the restarted host did hydrate the facet. + expect(server.getRunAccountRow(PANE)?.runAccount).toEqual(keptFacet) + const settings: AgentRunAccountSettings = { + codexManagedAccounts: [], + claudeManagedAccounts: [claudeAccount('acc-a'), claudeAccount('acc-b')], + activeClaudeManagedAccountId: 'acc-a', + activeClaudeManagedAccountIdsByRuntime: undefined + } + const settingsListeners: ((updates: Partial) => void)[] = [] + startAgentRunAccounts({ + server, + getSettings: () => settings, + onSettingsChanged: (listener) => settingsListeners.push(listener), + claudeRouter: null, + keptFilePath: join(userDataPath, 'kept.json') + }) + getKeptAgentConversations()!.keep('claude', 's1', { + lane: 'host', + accountId: 'acc-a', + keptAt: 1 + }) + + noteAgentPaneLaunch({ paneKey: PANE, lane: 'host', fresh: false }) + expect(server.getRunAccountRow(PANE)?.runAccount).toEqual(keptFacet) + + // Adding, removing or re-authenticating an account refreshes every pane. + for (const listener of settingsListeners) { + listener({ claudeManagedAccounts: settings.claudeManagedAccounts }) + } + expect(server.getRunAccountRow(PANE)?.runAccount).toEqual(keptFacet) + + await postHookEvent(server, buildBody({ hook_event_name: 'Stop', session_id: 's1' })) + expect(server.getRunAccountRow(PANE)?.runAccount).toEqual(keptFacet) + } finally { + server.stop() + } + }) +}) diff --git a/src/main/startup/main-process-agent-run-accounts.ts b/src/main/startup/main-process-agent-run-accounts.ts new file mode 100644 index 000000000000..3fd9ff078813 --- /dev/null +++ b/src/main/startup/main-process-agent-run-accounts.ts @@ -0,0 +1,107 @@ +import { join } from 'node:path' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { AgentHookServer } from '../agent-hooks/server' +import { + AgentRunAccounts, + type AgentRunAccountSettings +} from '../agent-accounts/agent-run-accounts' +import { installAgentRunAccounts } from '../agent-accounts/agent-run-accounts-installed' +import { KeptAgentConversations } from '../agent-accounts/kept-agent-conversations' +import type { ClaudeProfileRouter } from '../claude-accounts/claude-profile-router' +import { getOrcaUserDataPath } from '../codex/codex-home-paths' +import { + getCodexPaneAccount, + releaseKeptCodexPanesForAccount +} from '../codex/codex-pane-account-registry' +import { getPtyIdForPaneKey } from '../ipc/pty/pane/key-state' + +const ACCOUNT_LIST_SETTINGS = ['claudeManagedAccounts', 'codexManagedAccounts'] as const + +type AccountIds = { claude: Set; codex: Set } + +function accountIds(settings: AgentRunAccountSettings): AccountIds { + return { + claude: new Set(settings.claudeManagedAccounts.map((account) => account.id)), + codex: new Set(settings.codexManagedAccounts.map((account) => account.id)) + } +} + +/** A removed account releases what was kept on it: kept agents follow the selection again. */ +function releaseRemovedAccounts( + before: AccountIds, + after: AccountIds, + kept: KeptAgentConversations +): void { + for (const id of before.claude) { + if (!after.claude.has(id)) { + kept.releaseAccount('claude', id) + } + } + for (const id of before.codex) { + if (!after.codex.has(id)) { + kept.releaseAccount('codex', id) + releaseKeptCodexPanesForAccount(id) + } + } +} + +/** Creates the run-account producer and feeds it status rows, statusline folders and account changes. */ +export function startAgentRunAccounts(args: { + server: AgentHookServer + getSettings: () => AgentRunAccountSettings + onSettingsChanged: (listener: (updates: Partial) => void) => void + claudeRouter: ClaudeProfileRouter | null + keptFilePath?: string +}): AgentRunAccounts { + const { server, claudeRouter } = args + const kept = new KeptAgentConversations({ + filePath: args.keptFilePath ?? join(getOrcaUserDataPath(), 'agent-kept-conversations.json') + }) + const producer = new AgentRunAccounts({ + republish: (paneKey, runAccount) => server.republishRunAccount(paneKey, runAccount), + getRow: (paneKey) => server.getRunAccountRow(paneKey), + listRows: () => + server.getStatusSnapshot().map((row) => ({ + paneKey: row.paneKey, + connectionId: row.connectionId, + providerSession: row.providerSession, + runAccount: row.runAccount, + structuredHost: row.structuredHost, + payload: { agentType: row.agentType } + })), + getSettings: args.getSettings, + getPtyIdForPaneKey, + readCodexRecord: getCodexPaneAccount, + claudeFolders: claudeRouter + ? { + accountHome: (accountId) => claudeRouter.accountHome(accountId), + systemDefaultHome: () => claudeRouter.systemDefaultHome(), + coveredBySystemDefault: (accountId) => claudeRouter.coveredBySystemDefault(accountId) + } + : null, + kept + }) + installAgentRunAccounts({ producer, kept }) + server.subscribeEnrichedStatus((row) => { + try { + producer.noteStatus(row) + } catch (error) { + console.warn('[agent-run-accounts] Could not publish an agent account:', error) + } + }) + server.subscribeClaudeStatusLinePanes((event) => producer.noteClaudeStatusLine(event)) + let known = accountIds(args.getSettings()) + args.onSettingsChanged((updates) => { + if (!ACCOUNT_LIST_SETTINGS.some((key) => key in updates)) { + return + } + const next = accountIds(args.getSettings()) + releaseRemovedAccounts(known, next, kept) + known = next + producer.refreshAll() + }) + void kept.pruneUnreachable().catch((error: unknown) => { + console.warn('[kept-conversations] Could not prune kept conversations:', error) + }) + return producer +} diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 213ff778511d..78adf1ea6caa 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -14,6 +14,7 @@ import { browserManager } from '../browser/browser-manager' import { loadAgentSessionClaimSigner } from '../runtime/agent-session-claim-identity' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { codexAiVaultSessionResumePreparation } from '../codex/codex-ai-vault-session-resume' +import { resolveKeptCodexHomeForSession } from '../agent-accounts/kept-codex-home' import { prepareCodexPinnedLaunchHome } from './codex-session-resume-launch' import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' import { agentHookRuntimeDeps } from '../agent-hooks/agent-hook-runtime-deps' @@ -119,7 +120,9 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { prepareAiVaultSessionResume: codexAiVaultSessionResumePreparation(() => ({ runtimeHome: state.codexRuntimeHome, systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()), - preparePinnedLaunchHome: (home) => prepareCodexPinnedLaunchHome(home) + preparePinnedLaunchHome: (home) => prepareCodexPinnedLaunchHome(home), + resolveKeptCodexHome: (sessionId) => + resolveKeptCodexHomeForSession(sessionId, state.codexRuntimeHome) })), ...codexStructuredLaunchHomeResolvers, prepareCodexCatalogProbeHome: (homePath) => diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts index 8925c5975497..8793e69f6f24 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -30,7 +30,9 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt observation, isReplay, authorityRestartId, - structuredHost + structuredHost, + runAccount, + runAccountOnly }) => { if (state.mainWindow?.isDestroyed()) { return @@ -54,6 +56,9 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt stateStartedAt, ...(providerSession ? { providerSession } : {}), ...(observation ? { observation } : {}), + // Why: an identity row is where a kept agent's facet lives once its terminal is gone, + // and the tab menu un-keeps it from there. + ...(runAccount !== undefined ? { runAccount } : {}), providerSessionOnly: true }) return @@ -80,10 +85,14 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt ...(observation ? { observation } : {}), ...(orchestration ? { orchestration } : {}), // Why: reconnect replays re-send cached rows; the renderer must not treat them as fresh completions. - ...(isReplay === true ? { isReplay: true } : {}) + ...(isReplay === true ? { isReplay: true } : {}), + ...(runAccount !== undefined ? { runAccount } : {}) } state.mainWindow?.webContents.send('agentStatus:set', statusEvent) getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent) + if (runAccountOnly) { + return + } options.onRecordAgentState(payload.agentType ?? 'unknown', payload.state) } ) diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index c350a4e573f0..25005dd43cc1 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -5,6 +5,7 @@ import { initTccPromptNotice } from '../macos-tcc-prompt-notice' import { resolveUpdateInstallMode } from '../updater' import { mainProcessState as state } from './main-process-state' import { prepareCodexAiVaultSessionResume } from '../codex/codex-ai-vault-session-resume' +import { resolveKeptCodexHomeForSession } from '../agent-accounts/kept-codex-home' import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' import { preserveAgentAuthBeforeRestart } from '../agent-auth-restart-preservation' import { preserveStateBeforeUpdateRestart } from '../update-restart-preservation' @@ -89,7 +90,9 @@ export function attachMainWindowCoreServices( prepareCodexAiVaultSessionResume(args, { runtimeHome: codexRuntimeHome, systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()), - preparePinnedLaunchHome: (home) => prepareCodexPinnedLaunchHome(home) + preparePinnedLaunchHome: (home) => prepareCodexPinnedLaunchHome(home), + resolveKeptCodexHome: (sessionId) => + resolveKeptCodexHomeForSession(sessionId, codexRuntimeHome) }), onBeforeRelaunch: async () => { state.isQuitting = true diff --git a/src/main/startup/main-window-kept-identity-facet.test.ts b/src/main/startup/main-window-kept-identity-facet.test.ts new file mode 100644 index 000000000000..337b3e25a52d --- /dev/null +++ b/src/main/startup/main-window-kept-identity-facet.test.ts @@ -0,0 +1,173 @@ +// A kept agent whose terminal died while Orca was closed: the restored-row sweep retires its row to +// an identity row and clears the pane. Its refused resume tells the person to un-keep it from the +// tab menu, which only lists panes whose facet reached the renderer — so after the clear, the +// window must still hear the facet the identity row carries, and every later change to it. + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentRunAccount } from '../../shared/agent-run-account' +import type * as HookServerModule from '../agent-hooks/server' +import { makePaneKey } from '../../shared/stable-pane-id' + +vi.mock('electron', () => ({ + app: { getPath: () => '', on: vi.fn(), isReady: () => true } +})) +vi.mock('../agent-hooks/server', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, agentHookServer: new actual.AgentHookServer() } +}) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({ + setMigrationUnsupportedPtyListener: vi.fn() +})) +vi.mock('../window/dashboard-popout-window', () => ({ + getDashboardPopoutWindow: () => null +})) +vi.mock('./synthetic-title-runtime', () => ({ + stopSyntheticTitleSpinnerTimer: vi.fn() +})) + +import { agentHookServer } from '../agent-hooks/server' +import { retireRestoredStatusesWithDeadTerminals } from '../agent-hooks/restored-status-dead-terminal-sweep' +import { installMainWindowAgentStatusListeners } from './main-window-agent-status' +import { mainProcessState } from './main-process-state' + +const KEPT_PANE = makePaneKey('tab-kept', '11111111-1111-4111-8111-111111111111') +const PLAIN_PANE = makePaneKey('tab-plain', '22222222-2222-4222-8222-222222222222') +const KEPT: AgentRunAccount = { + provider: 'codex', + lane: 'host', + accountId: 'account-a', + mode: 'kept' +} + +type Sent = { channel: string; event: Record } +const sent: Sent[] = [] +let dir = '' + +/** Start the server over the status file an Orca that closed mid-turn leaves behind. */ +async function restartMidTurn(): Promise { + const now = Date.now() + const persisted = (paneKey: string, runAccount?: AgentRunAccount) => ({ + paneKey, + tabId: paneKey.split(':')[0], + worktreeId: 'wt-1', + connectionId: null, + providerSession: { key: 'session_id', id: `resume-${paneKey}` }, + payload: { state: 'working', prompt: 'review the PR', agentType: 'codex' }, + receivedAt: now, + stateStartedAt: now, + ...(runAccount ? { runAccount } : {}) + }) + mkdirSync(join(dir, 'agent-hooks'), { recursive: true }) + writeFileSync( + join(dir, 'agent-hooks', 'last-status.json'), + JSON.stringify({ + version: 2, + entries: { [KEPT_PANE]: persisted(KEPT_PANE, KEPT), [PLAIN_PANE]: persisted(PLAIN_PANE) }, + authorityCommitments: {} + }) + ) + await agentHookServer.start({ env: 'production', userDataPath: dir }) +} + +function sweepDeadTerminals(): Promise { + return retireRestoredStatusesWithDeadTerminals({ + server: agentHookServer, + probePtyLiveness: async () => false, + isLocalExecutionHost: () => true, + getBoundPtyIdForPaneKey: () => undefined, + getPersistedPtyIdForPaneKey: (paneKey) => `pty-${paneKey}` + }) +} + +function forPane(paneKey: string): string[] { + return sent + .filter((entry) => entry.event.paneKey === paneKey) + .map((entry) => + entry.channel === 'agentStatus:clear' + ? 'clear' + : `${entry.event.providerSessionOnly ? 'identity' : 'status'}:${JSON.stringify( + entry.event.runAccount + )}` + ) +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-kept-identity-facet-')) + sent.length = 0 + mainProcessState.runtime = null + mainProcessState.mainWindow = { + isDestroyed: () => false, + webContents: { + send: (channel: string, event: Record) => sent.push({ channel, event }) + } + } as unknown as typeof mainProcessState.mainWindow +}) + +afterEach(() => { + agentHookServer.setListener(null) + agentHookServer.setPaneStatusClearListener(null) + agentHookServer.stop() + rmSync(dir, { recursive: true, force: true }) +}) + +describe('a kept agent whose terminal died while Orca was closed', () => { + it('keeps its facet in the window after the sweep clears the pane, through un-keep', async () => { + await restartMidTurn() + installMainWindowAgentStatusListeners({ + window: mainProcessState.mainWindow!, + onRecordAgentState: vi.fn() + }) + sent.length = 0 + + expect(await sweepDeadTerminals()).toBe(2) + + expect(forPane(KEPT_PANE)).toEqual(['clear', `identity:${JSON.stringify(KEPT)}`]) + // Upstream unchanged for a row with no facet: the clear alone. + expect(forPane(PLAIN_PANE)).toEqual(['clear']) + + sent.length = 0 + // What un-keeping a refused resume does when nothing observes the agent any more. + expect(agentHookServer.republishRunAccount(KEPT_PANE, null)).toBe(true) + expect(forPane(KEPT_PANE)).toEqual(['identity:null']) + }) + + it('replays the identity row with its facet to a window opened after the sweep', async () => { + await restartMidTurn() + await sweepDeadTerminals() + + installMainWindowAgentStatusListeners({ + window: mainProcessState.mainWindow!, + onRecordAgentState: vi.fn() + }) + + expect(forPane(KEPT_PANE)).toEqual([`identity:${JSON.stringify(KEPT)}`]) + expect(agentHookServer.getStatusSnapshotForPane(KEPT_PANE)[0]).toMatchObject({ + providerSessionOnly: true, + runAccount: KEPT + }) + }) + + // The same identity-row path carries a fresh resume's facet before its first turn reports. + it("forwards the facet the producer publishes on a resume launch's identity row", async () => { + await restartMidTurn() + installMainWindowAgentStatusListeners({ + window: mainProcessState.mainWindow!, + onRecordAgentState: vi.fn() + }) + sent.length = 0 + + agentHookServer.recordLaunchResumeIdentity({ + paneKey: KEPT_PANE, + worktreeId: 'wt-1', + agentType: 'codex', + providerSession: { key: 'session_id', id: 'resumed-session' }, + launchedAt: Date.now() + 1_000 + }) + expect(agentHookServer.republishRunAccount(KEPT_PANE, KEPT)).toBe(true) + + expect(forPane(KEPT_PANE)).toEqual(['identity:undefined', `identity:${JSON.stringify(KEPT)}`]) + }) +}) diff --git a/src/main/startup/main-window-structured-status-filter.test.ts b/src/main/startup/main-window-structured-status-filter.test.ts index 7475179ff710..51f05897f2d5 100644 --- a/src/main/startup/main-window-structured-status-filter.test.ts +++ b/src/main/startup/main-window-structured-status-filter.test.ts @@ -10,6 +10,7 @@ import type { EnrichedAgentHookEventPayload } from '../agent-hooks/server' const hooks = vi.hoisted(() => ({ listener: null as ((payload: EnrichedAgentHookEventPayload) => void) | null })) +const record = vi.fn() vi.mock('electron', () => ({ app: { getPath: () => '', on: vi.fn(), isReady: () => true } @@ -62,9 +63,10 @@ beforeEach(() => { send: (channel: string, event: { paneKey: string }) => sent.push({ channel, event }) } } as unknown as typeof mainProcessState.mainWindow + record.mockReset() installMainWindowAgentStatusListeners({ window: mainProcessState.mainWindow!, - onRecordAgentState: vi.fn() + onRecordAgentState: record }) }) @@ -108,3 +110,35 @@ it('marks replayed rows so the renderer can skip their completion notification', expect(sent[0].event).not.toHaveProperty('isReplay') expect(sent[1].event).toHaveProperty('isReplay', true) }) + +it('forwards the host account facet, including a withdrawal, and nothing when absent', () => { + const facet = { provider: 'codex', lane: 'host', accountId: 'a', mode: 'kept' } as const + hooks.listener!(statusPayload({ paneKey: 'kept', runAccount: facet })) + hooks.listener!(statusPayload({ paneKey: 'withdrawn', runAccount: null })) + hooks.listener!(statusPayload({ paneKey: 'plain' })) + + const byPane = new Map(sent.map((entry) => [entry.event.paneKey, entry.event])) + expect(byPane.get('kept')).toMatchObject({ runAccount: facet }) + expect(byPane.get('withdrawn')).toMatchObject({ runAccount: null }) + expect(byPane.get('plain')).not.toHaveProperty('runAccount') +}) + +// Titles and first-work renames hang off the hook subscription, which a facet restatement never +// reaches (server-run-account.test.ts); this listener must still skip telemetry and stay a replay. +it('forwards a facet-only restatement as a replay without recording telemetry', () => { + const facet = { provider: 'codex', lane: 'host', accountId: 'a', mode: 'kept' } as const + const waiting = { state: 'waiting', prompt: '', agentType: 'codex' } as const + hooks.listener!(statusPayload({ payload: waiting, runAccount: facet, isReplay: true })) + expect(record).toHaveBeenCalledOnce() + record.mockClear() + sent.length = 0 + + hooks.listener!( + statusPayload({ payload: waiting, runAccount: facet, isReplay: true, runAccountOnly: true }) + ) + + expect(sent.map((entry) => entry.event)).toEqual([ + expect.objectContaining({ paneKey: 'pane-1', runAccount: facet, isReplay: true }) + ]) + expect(record).not.toHaveBeenCalled() +}) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 31cfafbdde6f..7483b1222b7d 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1,5 +1,6 @@ import type { ClaudeAccountsApi, + AgentAccountKeepApi, CodexAccountsApi, CodexConfigSyncApi, CursorAccountsApi, @@ -106,6 +107,7 @@ export type PreloadApi = { localhostWorktreeLabels: LocalhostWorktreeLabelsApi keybindings: KeybindingsApi codexAccounts: CodexAccountsApi + agentAccounts: AgentAccountKeepApi claudeAccounts: ClaudeAccountsApi cli: CliApi codexConfigSync: CodexConfigSyncApi diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts index 5c1379324f13..7f2c3b5085e7 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -1,4 +1,5 @@ import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' +import type { SetAgentKeptRequest, SetAgentKeptResult } from '../../shared/agent-run-account' import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState @@ -38,8 +39,15 @@ export type CodexAccountsApi = { > /** The selection lane each PTY launched from, keyed by pty id; unrecorded panes are absent. */ listRecordedPaneLanes: (args: { ptyIds: string[] }) => Promise> - /** Drops launch records so a dismissed prompt stays dismissed across restarts. */ + /** Dismissing the prompt: forgets each pane's launch record, so the prompt stays answered. */ forgetStalePanes: (args: { ptyIds: string[] }) => Promise + /** "Keep old account": keeps each pane on its launch account, so the prompt stays answered. */ + keepStalePanes: (args: { panes: { ptyId: string; paneKey?: string }[] }) => Promise +} + +/** Keeping a local terminal agent on the account it runs on. */ +export type AgentAccountKeepApi = { + setKept: (args: SetAgentKeptRequest) => Promise } export type ClaudeAccountsApi = { diff --git a/src/preload/api/agent-account-keep-bridge.ts b/src/preload/api/agent-account-keep-bridge.ts new file mode 100644 index 000000000000..b667466a5013 --- /dev/null +++ b/src/preload/api/agent-account-keep-bridge.ts @@ -0,0 +1,8 @@ +import { ipcRenderer } from 'electron' +import type { SetAgentKeptRequest, SetAgentKeptResult } from '../../shared/agent-run-account' +import type { PreloadApi } from '../api-types' + +export const agentAccountKeepApi = { + setKept: (args: SetAgentKeptRequest): Promise => + ipcRenderer.invoke('agentAccounts:setKept', args) +} satisfies PreloadApi['agentAccounts'] diff --git a/src/preload/api/codex-accounts-bridge.ts b/src/preload/api/codex-accounts-bridge.ts index e94b5f35b87a..e107a755363e 100644 --- a/src/preload/api/codex-accounts-bridge.ts +++ b/src/preload/api/codex-accounts-bridge.ts @@ -35,5 +35,7 @@ export const codexAccountsApi = { listRecordedPaneLanes: (args: { ptyIds: string[] }): Promise> => ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args), forgetStalePanes: (args: { ptyIds: string[] }): Promise => - ipcRenderer.invoke('codexAccounts:forgetStalePanes', args) + ipcRenderer.invoke('codexAccounts:forgetStalePanes', args), + keepStalePanes: (args: { panes: { ptyId: string; paneKey?: string }[] }): Promise => + ipcRenderer.invoke('codexAccounts:keepStalePanes', args) } satisfies PreloadApi['codexAccounts'] diff --git a/src/preload/index.ts b/src/preload/index.ts index c395e6ab1239..dc006951cc2e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -35,6 +35,7 @@ import { agentAwakeApi } from './api/agent-awake-bridge' import { localhostWorktreeLabelsApi } from './api/localhost-worktree-labels-bridge' import { keybindingsApi } from './api/keybindings-bridge' import { codexAccountsApi } from './api/codex-accounts-bridge' +import { agentAccountKeepApi } from './api/agent-account-keep-bridge' import { claudeAccountsApi } from './api/claude-accounts-bridge' import { cliApi } from './api/cli-bridge' import { codexConfigSyncApi } from './api/codex-config-sync-bridge' @@ -136,6 +137,7 @@ const api = { localhostWorktreeLabels: localhostWorktreeLabelsApi, keybindings: keybindingsApi, codexAccounts: codexAccountsApi, + agentAccounts: agentAccountKeepApi, claudeAccounts: claudeAccountsApi, cli: cliApi, codexConfigSync: codexConfigSyncApi, diff --git a/src/renderer/src/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx index 7b3f44e4266f..cd9e9668090f 100644 --- a/src/renderer/src/components/CodexRestartChip.tsx +++ b/src/renderer/src/components/CodexRestartChip.tsx @@ -18,11 +18,14 @@ type RestartNotice = { export default function CodexRestartChip({ isVisible = true, ptyId, + paneKey, onReturnFocus, shouldFocus = false }: { isVisible?: boolean ptyId: string + /** Lets the host keep this pane's conversation, not just its terminal. */ + paneKey?: string onReturnFocus: () => void shouldFocus?: boolean }): React.JSX.Element | null { @@ -38,11 +41,22 @@ export default function CodexRestartChip({ onReturnFocus() } + // Why: notices are renderer-only, so each answer is persisted on the host or + // the startup sweep re-raises the answered prompt. + const handleKeep = (): void => { + useAppStore.getState().dismissCodexRestartNotices([ptyId]) + onReturnFocus() + void window.api.codexAccounts + .keepStalePanes({ panes: [{ ptyId, ...(paneKey ? { paneKey } : {}) }] }) + .catch((err: unknown) => { + console.warn('Failed to keep Codex pane on its account:', err) + }) + } + + // Why not a keep: Escape and a backdrop click are too easy to send by accident to pin a conversation. const handleDismiss = (): void => { useAppStore.getState().dismissCodexRestartNotices([ptyId]) onReturnFocus() - // Why: notices are renderer-only, so the persisted launch record must be - // cleared for this pane or the startup sweep re-raises its answered prompt. void window.api.codexAccounts.forgetStalePanes({ ptyIds: [ptyId] }).catch((err: unknown) => { console.warn('Failed to forget dismissed Codex pane account:', err) }) @@ -55,6 +69,7 @@ export default function CodexRestartChip({ restartNotice={restartNotice} shouldFocus={shouldFocus} onDismiss={handleDismiss} + onKeep={handleKeep} onRestart={handleRestart} /> ) @@ -66,6 +81,7 @@ function LoudRestartOverlay({ restartNotice, shouldFocus, onDismiss, + onKeep, onRestart }: { isVisible: boolean @@ -73,6 +89,7 @@ function LoudRestartOverlay({ restartNotice: RestartNotice shouldFocus: boolean onDismiss: () => void + onKeep: () => void onRestart: () => void }): React.JSX.Element { const titleId = useId() @@ -154,7 +171,7 @@ function LoudRestartOverlay({ )}
-
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index 087619b327c7..280164f55855 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -246,7 +246,10 @@ export function useAiVaultSessionRefresh( ) { setTerminalScopeKey(scanKey) if (!isAiVaultScanCancellation(err)) { - setFailure({ scopeKey: scanKey, cause: err instanceof Error ? err.message : String(err) }) + setFailure({ + scopeKey: scanKey, + cause: err instanceof Error ? err.message : String(err) + }) } } } finally { diff --git a/src/renderer/src/components/settings/KeptAgentsRemovalNote.tsx b/src/renderer/src/components/settings/KeptAgentsRemovalNote.tsx new file mode 100644 index 000000000000..c957699161b7 --- /dev/null +++ b/src/renderer/src/components/settings/KeptAgentsRemovalNote.tsx @@ -0,0 +1,67 @@ +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../../store' +import { useIdentityRowRunAccounts } from '../../store/agent-identity-row-run-accounts' +import type { AgentRunAccountProvider } from '../../../../shared/agent-run-account' +import { countTerminalAgentsByAccount } from '../status-bar/count-terminal-agents-by-account' + +const EMPTY_ROWS = {} + +function noteText(provider: AgentRunAccountProvider, count: number): string { + // Why Claude differs: its open terminals read the removed account's file, which starts nothing. + if (provider === 'claude') { + return count === 1 + ? translate( + 'components.settings.accountsPaneRemoval.keptClaudeAgentsOne', + '1 terminal agent is kept on this account. After removal, Orca starts it on the selected account, but claude typed in its open terminal will not run.' + ) + : translate( + 'components.settings.accountsPaneRemoval.keptClaudeAgentsOther', + '{{value0}} terminal agents are kept on this account. After removal, Orca starts them on the selected account, but claude typed in their open terminals will not run.', + { value0: count } + ) + } + return count === 1 + ? translate( + 'components.settings.accountsPaneRemoval.keptAgentsOne', + '1 terminal agent is kept on this account. After removal it follows the selected account from its next start.' + ) + : translate( + 'components.settings.accountsPaneRemoval.keptAgentsOther', + '{{value0}} terminal agents are kept on this account. After removal they follow the selected account from their next start.', + { value0: count } + ) +} + +/** Says what removal does to agents kept on the account: they follow the selection again. */ +export function KeptAgentsRemovalNote({ + provider, + accountId, + enabled +}: { + provider: AgentRunAccountProvider + accountId: string + /** Off for another runtime's or a WSL account, whose agents this host does not see. */ + enabled: boolean +}): React.JSX.Element | null { + const identityRows = useIdentityRowRunAccounts((state) => state.byPaneKey) + const count = useAppStore((state) => + enabled + ? countTerminalAgentsByAccount({ + rows: state.agentStatusByPaneKey ?? EMPTY_ROWS, + identityRows, + tabsByWorktree: state.tabsByWorktree, + provider, + accountId, + keptOnly: true + }) + : 0 + ) + if (count === 0) { + return null + } + return ( +

+ {noteText(provider, count)} +

+ ) +} diff --git a/src/renderer/src/components/settings/NativeChatProvidersSetting.tsx b/src/renderer/src/components/settings/NativeChatProvidersSetting.tsx index e83a421ea7d4..fb7b1d02ab95 100644 --- a/src/renderer/src/components/settings/NativeChatProvidersSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatProvidersSetting.tsx @@ -101,10 +101,7 @@ function ProviderDetail(props: { } return (
- +
) } diff --git a/src/renderer/src/components/settings/accounts-pane-removal-dialogs.tsx b/src/renderer/src/components/settings/accounts-pane-removal-dialogs.tsx index 984071b1b46c..503686396bcd 100644 --- a/src/renderer/src/components/settings/accounts-pane-removal-dialogs.tsx +++ b/src/renderer/src/components/settings/accounts-pane-removal-dialogs.tsx @@ -13,6 +13,7 @@ import { DialogTitle } from '../ui/dialog' import type { AccountsPaneSectionModel, RemoveAccountTarget } from './accounts-pane-types' +import { KeptAgentsRemovalNote } from './KeptAgentsRemovalNote' export function renderAccountsRemovalDialogs( model: AccountsPaneSectionModel, @@ -26,6 +27,8 @@ export function renderAccountsRemovalDialogs( setRemoveCodexTarget, accountOwner } = model + const localHostAccount = (target: RemoveAccountTarget): boolean => + accountOwner.kind === 'local' && target.runtime.runtime === 'host' return ( <> + {removeCodexTarget ? ( + + ) : null} + + + ) + fireEvent.pointerMove(screen.getByRole('button', { name: 'tab' }), { pointerType: 'mouse' }) + act(() => { + vi.advanceTimersByTime(TAB_TOOLTIP_DELAY_MS) + }) + + const card = document.querySelector('[data-tab-hover-card="true"]') + const program = card?.querySelector('[data-tab-hover-card-program]') + const accounts = card?.querySelector('[data-tab-hover-card-accounts]') + expect(accounts?.textContent).toContain('work@example.com · Kept on this account') + expect( + program && accounts + ? program.compareDocumentPosition(accounts) & Node.DOCUMENT_POSITION_FOLLOWING + : 0 + ).toBeTruthy() + }) +}) diff --git a/src/renderer/src/components/tab-bar/TabAgentAccountLines.tsx b/src/renderer/src/components/tab-bar/TabAgentAccountLines.tsx new file mode 100644 index 000000000000..aacd1a8d0d8e --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabAgentAccountLines.tsx @@ -0,0 +1,19 @@ +import { useTabAgentAccounts } from './use-tab-agent-accounts' + +/** The hover card's account lines: one per agent pane, in layout order. */ +export function TabAgentAccountLines({ tabId }: { tabId: string }): React.JSX.Element | null { + const accounts = useTabAgentAccounts(tabId) + if (accounts.length === 0) { + return null + } + return ( +
+ {accounts.map((account) => ( +
+ {accounts.length > 1 ? `${account.agentLabel} · ` : ''} + {account.accountLabel} · {account.note} +
+ ))} +
+ ) +} diff --git a/src/renderer/src/components/tab-bar/TabAgentAccountMenuItems.test.tsx b/src/renderer/src/components/tab-bar/TabAgentAccountMenuItems.test.tsx new file mode 100644 index 000000000000..65ab1184f5f6 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabAgentAccountMenuItems.test.tsx @@ -0,0 +1,227 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { useAppStore } from '../../store' +import { getDefaultSettings } from '../../../../shared/constants' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { AgentRunAccount } from '../../../../shared/agent-run-account' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { CodexManagedAccount } from '../../../../shared/managed-account-types' +import { markRestoredStaleCodexSessionsForRestart } from '@/lib/codex-session-restart' +import { TabAgentAccountMenuItems } from './TabAgentAccountMenuItems' + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenuSeparator: () =>
, + DropdownMenuCheckboxItem: ({ + children, + checked, + disabled, + onCheckedChange + }: { + children?: ReactNode + checked?: boolean + disabled?: boolean + onCheckedChange?: (checked: boolean) => void + }) => ( + + ) +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('@/lib/codex-session-restart', () => ({ + markRestoredStaleCodexSessionsForRestart: vi.fn(async () => []) +})) + +const TAB = 'tab-1' +const LEFT = '11111111-1111-4111-8111-111111111111' +const RIGHT = '22222222-2222-4222-8222-222222222222' + +function codexAccount(id: string): CodexManagedAccount { + return { + id, + email: `${id}@example.com`, + managedHomePath: '/unused', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +function row(leafId: string, runAccount: AgentRunAccount): [string, AgentStatusEntry] { + const paneKey = makePaneKey(TAB, leafId) + return [ + paneKey, + { + state: 'working', + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + paneKey, + stateHistory: [], + connectionId: null, + runAccount + } + ] +} + +const setKept = vi.fn() + +function seed(rows: [string, AgentStatusEntry][]): void { + const leafIds = rows.map(([paneKey]) => paneKey.slice(TAB.length + 1)) + useAppStore.setState({ + settings: { + ...getDefaultSettings('/home/user'), + codexManagedAccounts: [codexAccount('work'), codexAccount('personal')], + activeCodexManagedAccountId: 'work' + }, + terminalLayoutsByTabId: { + [TAB]: { + root: + leafIds.length === 1 + ? { type: 'leaf', leafId: leafIds[0]! } + : { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: leafIds[0]! }, + second: { type: 'leaf', leafId: leafIds[1]! } + }, + activeLeafId: leafIds[0]!, + expandedLeafId: null, + ptyIdsByLeafId: Object.fromEntries(leafIds.map((leafId) => [leafId, `pty-${leafId}`])) + } + }, + agentStatusByPaneKey: Object.fromEntries(rows) + }) +} + +const CODEX = { provider: 'codex', lane: 'host' } as const + +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + setKept.mockReset() + vi.mocked(toast.error).mockReset() + vi.mocked(markRestoredStaleCodexSessionsForRestart).mockClear() + Object.defineProperty(window, 'api', { + configurable: true, + value: { agentAccounts: { setKept } } + }) +}) + +afterEach(() => { + cleanup() + useAppStore.setState(useAppStore.getInitialState(), true) +}) + +describe('Keep on in the tab menu', () => { + it('offers one checkbox per agent pane, checked when kept', () => { + seed([ + row(LEFT, { ...CODEX, accountId: 'work', mode: 'kept' }), + row(RIGHT, { ...CODEX, accountId: 'personal', mode: 'follow' }) + ]) + render() + + const items = screen.getAllByRole('menuitemcheckbox') + expect(items.map((item) => [item.textContent, item.getAttribute('aria-checked')])).toEqual([ + ['Keep Codex 1 on work@example.com', 'true'], + ['Keep Codex 2 on personal@example.com', 'false'] + ]) + }) + + it('renders nothing for a tab with no published account', () => { + const { container } = render() + expect(container.innerHTML).toBe('') + }) + + it.each([ + ['no-session', 'Available once the agent reports its session'], + ['codex-home-not-keepable', 'Not available while CODEX_HOME is set'] + ] as const)('explains why Keep is off: %s', (keepUnavailable, reason) => { + seed([row(LEFT, { ...CODEX, accountId: 'work', mode: 'follow', keepUnavailable })]) + render() + const item = screen.getByRole('menuitemcheckbox') + expect(item.textContent).toContain(reason) + expect(item).toHaveProperty('disabled', true) + }) + + it('keeps the pane through the host and disables the item until it answers', async () => { + let answer: (value: unknown) => void = () => {} + setKept.mockReturnValue(new Promise((resolve) => (answer = resolve))) + seed([row(LEFT, { ...CODEX, accountId: 'work', mode: 'follow' })]) + render() + + fireEvent.click(screen.getByRole('menuitemcheckbox')) + + expect(setKept).toHaveBeenCalledWith({ + paneKey: makePaneKey(TAB, LEFT), + ptyId: `pty-${LEFT}`, + kept: true + }) + expect(screen.getByRole('menuitemcheckbox')).toHaveProperty('disabled', true) + await act(async () => + answer({ ok: true, runAccount: { ...CODEX, accountId: 'work', mode: 'kept' } }) + ) + expect(screen.getByRole('menuitemcheckbox')).toHaveProperty('disabled', false) + expect(markRestoredStaleCodexSessionsForRestart).not.toHaveBeenCalled() + }) + + it("re-checks a released Codex pane against the selection, so it gets today's prompt", async () => { + setKept.mockResolvedValue({ + ok: true, + runAccount: { ...CODEX, accountId: 'personal', mode: 'follow' } + }) + seed([row(LEFT, { ...CODEX, accountId: 'personal', mode: 'kept' })]) + render() + + await act(async () => { + fireEvent.click(screen.getByRole('menuitemcheckbox')) + }) + + expect(setKept).toHaveBeenCalledWith(expect.objectContaining({ kept: false })) + expect(markRestoredStaleCodexSessionsForRestart).toHaveBeenCalledWith({ + ptyIds: [`pty-${LEFT}`] + }) + }) + + it("answers a Codex pane's account-switched prompt once the pane is kept", async () => { + setKept.mockResolvedValue({ + ok: true, + runAccount: { ...CODEX, accountId: 'work', mode: 'kept' } + }) + seed([row(LEFT, { ...CODEX, accountId: 'work', mode: 'follow' })]) + useAppStore + .getState() + .markCodexRestartNotices([ + { ptyId: `pty-${LEFT}`, previousAccountLabel: 'work', nextAccountLabel: 'personal' } + ]) + render() + + await act(async () => { + fireEvent.click(screen.getByRole('menuitemcheckbox')) + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId[`pty-${LEFT}`]?.dismissed).toBe(true) + }) + + it('says why the host refused', async () => { + setKept.mockResolvedValue({ ok: false, reason: 'unavailable' }) + seed([row(LEFT, { ...CODEX, accountId: 'work', mode: 'follow' })]) + render() + + await act(async () => { + fireEvent.click(screen.getByRole('menuitemcheckbox')) + }) + + expect(toast.error).toHaveBeenCalledWith( + 'Orca could not save this right now. Try again in a moment.' + ) + }) +}) diff --git a/src/renderer/src/components/tab-bar/TabAgentAccountMenuItems.tsx b/src/renderer/src/components/tab-bar/TabAgentAccountMenuItems.tsx new file mode 100644 index 000000000000..5145da89e52c --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabAgentAccountMenuItems.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react' +import { toast } from 'sonner' +import { DropdownMenuCheckboxItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../../store' +import type { SetAgentKeptResult } from '../../../../shared/agent-run-account' +import type { TabAgentAccount } from './tab-agent-accounts' +import { useTabAgentAccounts } from './use-tab-agent-accounts' + +function keepLabel(account: TabAgentAccount, split: boolean): string { + return split + ? translate( + 'components.tab.bar.TabAgentAccountMenuItems.keepAgentOn', + 'Keep {{value0}} on {{value1}}', + { value0: account.agentLabel, value1: account.accountLabel } + ) + : translate('components.tab.bar.TabAgentAccountMenuItems.keepOn', 'Keep on {{value0}}', { + value0: account.accountLabel + }) +} + +function disabledReason(account: TabAgentAccount): string | null { + if (account.keepDisabledReason === 'no-session') { + return translate( + 'components.tab.bar.TabAgentAccountMenuItems.noSession', + 'Available once the agent reports its session' + ) + } + if (account.keepDisabledReason === 'codex-home-not-keepable') { + return translate( + 'components.tab.bar.TabAgentAccountMenuItems.customCodexHome', + 'Not available while CODEX_HOME is set' + ) + } + return null +} + +function failureMessage(reason: Extract['reason']): string { + if (reason === 'read-only') { + return translate( + 'components.tab.bar.TabAgentAccountMenuItems.readOnly', + 'A newer version of Orca saved the list of kept agents, so this version cannot change it.' + ) + } + return reason === 'unavailable' + ? translate( + 'components.tab.bar.TabAgentAccountMenuItems.unavailable', + 'Orca could not save this right now. Try again in a moment.' + ) + : translate( + 'components.tab.bar.TabAgentAccountMenuItems.failed', + 'This agent cannot be kept on its account right now.' + ) +} + +/** "Keep on ", one per agent pane whose host published its account. */ +export function TabAgentAccountMenuItems({ tabId }: { tabId: string }): React.JSX.Element | null { + const accounts = useTabAgentAccounts(tabId) + const [pending, setPending] = useState>(() => new Set()) + if (accounts.length === 0) { + return null + } + + const setKept = (account: TabAgentAccount, kept: boolean): void => { + // Why disable at once: a slow host round trip must not let a second click race the first. + setPending((current) => new Set(current).add(account.paneKey)) + void window.api.agentAccounts + .setKept({ + paneKey: account.paneKey, + ...(account.ptyId ? { ptyId: account.ptyId } : {}), + kept + }) + .then((result) => { + if (!result.ok) { + toast.error(failureMessage(result.reason)) + return + } + const ptyId = account.ptyId + if (kept && account.provider === 'codex' && ptyId) { + // Why: a kept pane is answered; its "Restart on " would now restart on the kept one. + useAppStore.getState().dismissCodexRestartNotices([ptyId]) + } + // Why: a released Codex pane may already sit behind the selection, so it gets today's prompt. + if (!kept && account.provider === 'codex' && ptyId) { + // Why lazy: the restart scan's module graph is only needed after a release. + void import('@/lib/codex-session-restart').then( + ({ markRestoredStaleCodexSessionsForRestart }) => + markRestoredStaleCodexSessionsForRestart({ ptyIds: [ptyId] }) + ) + } + }) + .catch(() => toast.error(failureMessage('unavailable'))) + .finally(() => + setPending((current) => { + const next = new Set(current) + next.delete(account.paneKey) + return next + }) + ) + } + + return ( + <> + + {accounts.map((account) => { + const reason = disabledReason(account) + return ( + setKept(account, checked)} + data-tab-agent-account-keep={account.paneKey} + > + + {keepLabel(account, accounts.length > 1)} + {reason ? {reason} : null} + + + ) + })} + + ) +} diff --git a/src/renderer/src/components/tab-bar/TabHoverCard.tsx b/src/renderer/src/components/tab-bar/TabHoverCard.tsx index b8949d696c21..c08feaf05c75 100644 --- a/src/renderer/src/components/tab-bar/TabHoverCard.tsx +++ b/src/renderer/src/components/tab-bar/TabHoverCard.tsx @@ -11,13 +11,16 @@ export function TabHoverCard({ title, icon, programName, - description + description, + details }: { children: ReactNode title: string icon: ReactNode programName: string description?: string + /** Rendered under the program line; mounted only while the card is open. */ + details?: ReactNode }): React.JSX.Element { const placement = useContext(TabCardPlacementContext) const content = useRef(null) @@ -129,6 +132,7 @@ export function TabHoverCard({ {programName} + {details} 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..6d648df16bf9 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TerminalTabHoverCard.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from 'react' +import { getAgentLabel } from '@/lib/agent-catalog' +import { basename } from '@/lib/path' +import { translate } from '@/i18n/i18n' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TerminalAgent } from '../../../../shared/terminal-agent' +import { TabHoverCard } from './TabHoverCard' +import { TabAgentAccountLines } from './TabAgentAccountLines' +import { TerminalTabLeadingIcon } from './TerminalTabLeadingIcon' +import type { TerminalTabActivityStatus } from './terminal-tab-activity-status' + +/** A terminal tab's hover card: what it runs, its live icon, and each agent pane's account. */ +export function TerminalTabHoverCard({ + children, + tabId, + title, + agent, + shell, + activityStatus +}: { + children: ReactNode + tabId: string + title: string + agent: TerminalAgent | null + shell: TerminalTab['shellOverride'] + activityStatus: TerminalTabActivityStatus +}): React.JSX.Element { + return ( + } + icon={ + + } + > + {children} + + ) +} diff --git a/src/renderer/src/components/tab-bar/tab-agent-accounts.test.ts b/src/renderer/src/components/tab-bar/tab-agent-accounts.test.ts new file mode 100644 index 000000000000..4e2801477a50 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-agent-accounts.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest' +import type { AgentRunAccount } from '../../../../shared/agent-run-account' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { + ClaudeManagedAccount, + CodexManagedAccount +} from '../../../../shared/managed-account-types' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import { getAgentLabel } from '@/lib/agent-catalog' +import { describeTabAgentAccounts, type TabAgentAccountSettings } from './tab-agent-accounts' + +const TAB = 'tab-1' +const LEFT = '11111111-1111-4111-8111-111111111111' +const RIGHT = '22222222-2222-4222-8222-222222222222' + +function claudeAccount(id: string): ClaudeManagedAccount { + return { + id, + email: `${id}@example.com`, + managedAuthPath: '/unused', + authMethod: 'subscription-oauth', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +function codexAccount(id: string, email = `${id}@example.com`): CodexManagedAccount { + return { + id, + email, + managedHomePath: '/unused', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +const SETTINGS: TabAgentAccountSettings = { + claudeManagedAccounts: [claudeAccount('work'), claudeAccount('personal')], + codexManagedAccounts: [codexAccount('work')], + activeClaudeManagedAccountId: 'work', + activeClaudeManagedAccountIdsByRuntime: undefined, + activeCodexManagedAccountId: 'work', + activeCodexManagedAccountIdsByRuntime: undefined +} + +function layout(leafIds: string[]): TerminalLayoutSnapshot { + const root = + leafIds.length === 1 + ? { type: 'leaf' as const, leafId: leafIds[0]! } + : { + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: leafIds[0]! }, + second: { type: 'leaf' as const, leafId: leafIds[1]! } + } + return { + root, + activeLeafId: leafIds[0]!, + expandedLeafId: null, + ptyIdsByLeafId: Object.fromEntries(leafIds.map((leafId) => [leafId, `pty-${leafId}`])) + } +} + +function row(leafId: string, runAccount: AgentRunAccount, extra: Partial = {}) { + const paneKey = makePaneKey(TAB, leafId) + const entry: AgentStatusEntry = { + state: 'working', + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + paneKey, + stateHistory: [], + connectionId: null, + runAccount, + ...extra + } + return { [paneKey]: entry } +} + +function describeOne(facet: AgentRunAccount, settings = SETTINGS) { + return describeTabAgentAccounts({ + tabId: TAB, + layout: layout([LEFT]), + rows: row(LEFT, facet), + settings + })[0] +} + +const CLAUDE = { provider: 'claude', lane: 'host' } as const +const CODEX = { provider: 'codex', lane: 'host' } as const + +describe('describeTabAgentAccounts', () => { + it.each<[string, AgentRunAccount, string, string]>([ + [ + 'a kept Codex agent', + { ...CODEX, accountId: 'work', mode: 'kept' }, + 'work@example.com', + 'Kept on this account' + ], + [ + 'a Claude agent started kept', + { ...CLAUDE, accountId: 'personal', mode: 'kept', shellRoute: 'account' }, + 'personal@example.com', + 'Kept on this account' + ], + [ + 'a Claude agent kept after it started', + { ...CLAUDE, accountId: 'personal', mode: 'kept', shellRoute: 'selected' }, + 'personal@example.com', + 'Kept on this account from its next start' + ], + [ + 'an agent on the selected account', + { ...CLAUDE, accountId: 'work', mode: 'follow', shellRoute: 'selected' }, + 'work@example.com', + 'Follows the selected account' + ], + [ + 'an agent left behind on another account', + { ...CLAUDE, accountId: 'personal', mode: 'follow', shellRoute: 'selected' }, + 'personal@example.com', + 'Follows the selected account from its next start' + ], + [ + 'an agent started on its own pointer, then released', + { ...CLAUDE, accountId: 'work', mode: 'follow', shellRoute: 'account' }, + 'work@example.com', + 'Follows the selected account from its next start' + ], + [ + 'an agent on System default', + { ...CODEX, accountId: null, mode: 'follow' }, + 'System default', + 'Follows the selected account from its next start' + ], + [ + 'an agent kept on an account that was removed', + { ...CODEX, accountId: 'gone', mode: 'kept' }, + 'Removed account', + 'Kept on this account' + ] + ])('labels %s', (_label, facet, accountLabel, note) => { + expect(describeOne(facet)).toMatchObject({ accountLabel, note, kept: facet.mode === 'kept' }) + }) + + it('tells same-email Codex accounts apart the way the switcher does', () => { + const settings = { + ...SETTINGS, + codexManagedAccounts: [ + { ...codexAccount('one', 'same@example.com'), workspaceLabel: 'Team' }, + { ...codexAccount('two', 'same@example.com'), workspaceLabel: 'Personal' } + ] + } + expect( + describeOne({ ...CODEX, accountId: 'one', mode: 'follow' }, settings)?.accountLabel + ).toBe('same@example.com (Team)') + }) + + it('lists split panes in layout order with numbered agents, and carries why Keep is off', () => { + const accounts = describeTabAgentAccounts({ + tabId: TAB, + layout: layout([LEFT, RIGHT]), + rows: { + ...row(RIGHT, { ...CLAUDE, accountId: 'work', mode: 'follow', shellRoute: 'selected' }), + ...row(LEFT, { + ...CLAUDE, + accountId: 'personal', + mode: 'follow', + shellRoute: 'selected', + keepUnavailable: 'no-session' + }) + }, + settings: SETTINGS + }) + const claude = getAgentLabel('claude') + expect(accounts.map((account) => [account.agentLabel, account.ptyId])).toEqual([ + [`${claude} 1`, `pty-${LEFT}`], + [`${claude} 2`, `pty-${RIGHT}`] + ]) + expect(accounts[0]?.keepDisabledReason).toBe('no-session') + expect(accounts[1]).not.toHaveProperty('keepDisabledReason') + }) + + it('ignores relay rows, rows without a facet and panes outside the layout', () => { + expect( + describeTabAgentAccounts({ + tabId: TAB, + layout: layout([LEFT]), + rows: { + ...row(LEFT, { ...CODEX, accountId: 'work', mode: 'follow' }, { connectionId: 'ssh-1' }), + ...row(RIGHT, { ...CODEX, accountId: 'work', mode: 'follow' }) + }, + settings: SETTINGS + }) + ).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-agent-accounts.ts b/src/renderer/src/components/tab-bar/tab-agent-accounts.ts new file mode 100644 index 000000000000..94ac84b37c08 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-agent-accounts.ts @@ -0,0 +1,135 @@ +import { translate } from '@/i18n/i18n' +import { getCodexAccountDisplayLabel } from '@/lib/codex-account-display-label' +import { + resolvePaneRunAccount, + type IdentityRowRunAccounts +} from '@/store/agent-identity-row-run-accounts' +import type { AgentRunAccount, AgentRunAccountProvider } from '../../../../shared/agent-run-account' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import { + collectLayoutLeafIdsInOrder, + resolveRootlessTerminalLayoutLeafId +} from '../../../../shared/workspace-layout/terminal-pane-tree' + +export type TabAgentAccountSettings = Pick< + GlobalSettings, + | 'claudeManagedAccounts' + | 'codexManagedAccounts' + | 'activeClaudeManagedAccountId' + | 'activeClaudeManagedAccountIdsByRuntime' + | 'activeCodexManagedAccountId' + | 'activeCodexManagedAccountIdsByRuntime' +> + +export type TabAgentAccount = { + paneKey: string + ptyId: string | undefined + provider: AgentRunAccountProvider + /** "Claude", or "Claude 2" when a split tab runs more than one agent. */ + agentLabel: string + accountLabel: string + note: string + kept: boolean + keepDisabledReason?: NonNullable +} + +// Why the catalog's own keys, not the catalog: its module builds every agent entry on import. +function providerLabel(provider: AgentRunAccountProvider): string { + return provider === 'claude' + ? translate('auto.lib.agent.catalog.0708ed89f1', 'Claude') + : translate('auto.lib.agent.catalog.760bc6883d', 'Codex') +} + +function selectedAccountId( + settings: TabAgentAccountSettings, + provider: AgentRunAccountProvider +): string | null { + return provider === 'claude' + ? (settings.activeClaudeManagedAccountIdsByRuntime?.host ?? + settings.activeClaudeManagedAccountId ?? + null) + : (settings.activeCodexManagedAccountIdsByRuntime?.host ?? + settings.activeCodexManagedAccountId ?? + null) +} + +function accountLabel(settings: TabAgentAccountSettings, facet: AgentRunAccount): string { + if (facet.accountId === null) { + return translate('components.tab.bar.tabAgentAccounts.systemDefault', 'System default') + } + if (facet.provider === 'claude') { + const account = settings.claudeManagedAccounts.find((entry) => entry.id === facet.accountId) + if (account) { + return account.email + } + } else { + const account = settings.codexManagedAccounts.find((entry) => entry.id === facet.accountId) + if (account) { + return getCodexAccountDisplayLabel(account, settings.codexManagedAccounts) + } + } + return translate('components.tab.bar.tabAgentAccounts.removedAccount', 'Removed account') +} + +/** Presentation only: the host decides kept/follow; this says what that means for the next start. */ +function accountNote(settings: TabAgentAccountSettings, facet: AgentRunAccount): string { + if (facet.mode === 'kept') { + return facet.provider === 'claude' && facet.shellRoute !== 'account' + ? translate( + 'components.tab.bar.tabAgentAccounts.keptFromNextStart', + 'Kept on this account from its next start' + ) + : translate('components.tab.bar.tabAgentAccounts.kept', 'Kept on this account') + } + return facet.accountId === selectedAccountId(settings, facet.provider) && + facet.shellRoute !== 'account' + ? translate('components.tab.bar.tabAgentAccounts.follows', 'Follows the selected account') + : translate( + 'components.tab.bar.tabAgentAccounts.followsFromNextStart', + 'Follows the selected account from its next start' + ) +} + +/** One entry per agent pane whose host published its account, in layout order. */ +export function describeTabAgentAccounts(args: { + tabId: string + layout: TerminalLayoutSnapshot | undefined + rows: Readonly> + /** Facets on the host's identity-only rows, which no status entry carries. */ + identityRows?: IdentityRowRunAccounts + settings: TabAgentAccountSettings | null | undefined +}): TabAgentAccount[] { + const { tabId, layout, rows, identityRows, settings } = args + if (!settings) { + return [] + } + const ordered = collectLayoutLeafIdsInOrder(layout?.root) + const rootless = layout && !layout.root ? resolveRootlessTerminalLayoutLeafId(layout) : null + const leafIds = ordered.length > 0 ? ordered : rootless ? [rootless] : [] + const panes = leafIds.flatMap((leafId) => { + const paneKey = makePaneKey(tabId, leafId) + // Why resolved: a dead terminal's kept agent survives only as an identity row, and its + // refused resume sends the person here to un-keep it. Local panes only, either way. + const facet = resolvePaneRunAccount(paneKey, rows[paneKey], identityRows) + return facet ? [{ paneKey, ptyId: layout?.ptyIdsByLeafId?.[leafId], facet }] : [] + }) + const seen = new Map() + return panes.map(({ paneKey, ptyId, facet }) => { + const index = (seen.get(facet.provider) ?? 0) + 1 + seen.set(facet.provider, index) + const label = providerLabel(facet.provider) + return { + paneKey, + ptyId, + provider: facet.provider, + agentLabel: panes.length > 1 ? `${label} ${index}` : label, + accountLabel: accountLabel(settings, facet), + note: accountNote(settings, facet), + kept: facet.mode === 'kept', + ...(facet.keepUnavailable ? { keepDisabledReason: facet.keepUnavailable } : {}) + } + }) +} diff --git a/src/renderer/src/components/tab-bar/use-tab-agent-accounts.ts b/src/renderer/src/components/tab-bar/use-tab-agent-accounts.ts new file mode 100644 index 000000000000..61a94263cac0 --- /dev/null +++ b/src/renderer/src/components/tab-bar/use-tab-agent-accounts.ts @@ -0,0 +1,12 @@ +import { useAppStore } from '../../store' +import { useIdentityRowRunAccounts } from '../../store/agent-identity-row-run-accounts' +import { describeTabAgentAccounts, type TabAgentAccount } from './tab-agent-accounts' + +/** Mounted only while a hover card or tab menu is open, so closed tabs pay nothing for it. */ +export function useTabAgentAccounts(tabId: string): TabAgentAccount[] { + const rows = useAppStore((state) => state.agentStatusByPaneKey) + const identityRows = useIdentityRowRunAccounts((state) => state.byPaneKey) + const layout = useAppStore((state) => state.terminalLayoutsByTabId?.[tabId]) + const settings = useAppStore((state) => state.settings) + return describeTabAgentAccounts({ tabId, layout, rows: rows ?? {}, identityRows, settings }) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx index 7128cdbeb912..62ca06c98e69 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx @@ -1,6 +1,7 @@ import { createPortal } from 'react-dom' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import CodexRestartChip from '../CodexRestartChip' +import { makePaneKey } from '../../../../shared/stable-pane-id' import { CodexSharedServerBanner } from './CodexSharedServerBanner' import { ClaudeOldTerminalBanner } from './ClaudeOldTerminalBanner' import { ClaudeDiffPanelNoticeBanner } from './ClaudeDiffPanelNoticeBanner' @@ -47,6 +48,7 @@ export function TerminalPaneCodexRestartPortals({ key={`codex-restart-${pane.id}-${ptyId}`} isVisible={isVisible} ptyId={ptyId} + paneKey={makePaneKey(tabId, pane.leafId)} onReturnFocus={() => { managerRef.current?.setActivePane(pane.id, { focus: false }) focusTerminalTabSurface(tabId, pane.leafId) diff --git a/src/renderer/src/components/terminal-pane/claude-detached-pane-restart.test.ts b/src/renderer/src/components/terminal-pane/claude-detached-pane-restart.test.ts index 8dadc4f24894..6128ce58a2b6 100644 --- a/src/renderer/src/components/terminal-pane/claude-detached-pane-restart.test.ts +++ b/src/renderer/src/components/terminal-pane/claude-detached-pane-restart.test.ts @@ -14,7 +14,7 @@ const NEW_PTY = 'wt1@@new' const SESSION_ID = '01a006a6-1d07-70a1-bad5-f9110d5845c0' function seedClaudePane( - overrides: Partial> = {} + overrides: Partial> = {} ): void { const paneKey = makePaneKey('tab-1', LEAF_ID) useAppStore.setState({ @@ -141,6 +141,51 @@ describe('detached Claude account-move restart', () => { } }) + it('drops a queued restart of an agent kept on its account instead of relaunching it', async () => { + seedClaudePane({ + runAccount: { provider: 'claude', lane: 'host', accountId: 'acct-a', mode: 'kept' } + }) + useAppStore.getState().queuePaneAccountRestarts([OLD_PTY], 'claude') + + await sweepUnclaimedCodexPaneRestarts() + + expect(window.api.pty.spawn).not.toHaveBeenCalled() + expect(useAppStore.getState().pendingPaneAccountRestarts).toEqual({}) + expect(useAppStore.getState().terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({ + [LEAF_ID]: OLD_PTY + }) + }) + + it('drops a queued restart of an agent whose conversation is kept on another account', async () => { + seedClaudePane({ + runAccount: { + provider: 'claude', + lane: 'host', + accountId: 'acct-b', + mode: 'follow', + keptElsewhere: true + } + }) + useAppStore.getState().queuePaneAccountRestarts([OLD_PTY], 'claude') + + await sweepUnclaimedCodexPaneRestarts() + + expect(window.api.pty.spawn).not.toHaveBeenCalled() + expect(useAppStore.getState().pendingPaneAccountRestarts).toEqual({}) + }) + + it('still relaunches an agent that follows the selected account', async () => { + seedClaudePane({ + runAccount: { provider: 'claude', lane: 'host', accountId: 'acct-a', mode: 'follow' } + }) + useAppStore.getState().queuePaneAccountRestarts([OLD_PTY], 'claude') + + await sweepUnclaimedCodexPaneRestarts() + + expect(window.api.pty.spawn).toHaveBeenCalledTimes(1) + expect(useAppStore.getState().pendingPaneAccountRestarts).toEqual({}) + }) + it('skips a pane whose session can no longer be resumed instead of starting a fresh Claude', async () => { seedClaudePane({ providerSession: undefined }) useAppStore.getState().queuePaneAccountRestarts([OLD_PTY], 'claude') diff --git a/src/renderer/src/hooks/ipc-events/agent-status-identity-row-run-account.test.tsx b/src/renderer/src/hooks/ipc-events/agent-status-identity-row-run-account.test.tsx new file mode 100644 index 000000000000..1b5973da3d9f --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-identity-row-run-account.test.tsx @@ -0,0 +1,260 @@ +// @vitest-environment happy-dom +// A kept agent whose terminal died while Orca was closed reaches this renderer as its restored row, +// then the host's pane clear, then the identity row the host kept — facet included. Its refused +// resume says "un-keep it from the tab menu", so that menu must still offer it after the clear. +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { useIdentityRowRunAccounts } from '@/store/agent-identity-row-run-accounts' +import { TabAgentAccountMenuItems } from '@/components/tab-bar/TabAgentAccountMenuItems' +import { getDefaultSettings } from '../../../../shared/constants' +import type { AgentRunAccount } from '../../../../shared/agent-run-account' +import type { + AgentStatusClearIpcPayload, + AgentStatusIpcPayload +} from '../../../../shared/agent-status-types' +import type { CodexManagedAccount } from '../../../../shared/managed-account-types' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { registerAgentStatusIpcBridge, type AgentStatusIpcBridge } from './agent-status-ipc-bridge' + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenuSeparator: () =>
, + DropdownMenuCheckboxItem: ({ + children, + checked, + onCheckedChange + }: { + children?: ReactNode + checked?: boolean + onCheckedChange?: (checked: boolean) => void + }) => ( + + ) +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('@/lib/codex-session-restart', () => ({ + markRestoredStaleCodexSessionsForRestart: vi.fn(async () => []) +})) +vi.mock('../agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification: vi.fn(), + syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn() +})) + +const TAB = 'tab-1' +const LEAF = '11111111-1111-4111-8111-111111111111' +const PANE = makePaneKey(TAB, LEAF) +const WORKTREE = 'repo-1::/wt-1' +const STARTED = 1_700_000_000_000 +const KEPT: AgentRunAccount = { provider: 'codex', lane: 'host', accountId: 'work', mode: 'kept' } + +function codexAccount(id: string): CodexManagedAccount { + return { + id, + email: `${id}@example.com`, + managedHomePath: '/unused', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +function hostRow(overrides: Partial = {}): AgentStatusIpcPayload { + return { + paneKey: PANE, + tabId: TAB, + worktreeId: WORKTREE, + connectionId: null, + receivedAt: STARTED, + stateStartedAt: STARTED, + state: 'working', + prompt: 'review the PR', + agentType: 'codex', + providerSession: { key: 'session_id', id: 'resume-1' }, + ...overrides + } +} + +const setKept = vi.fn() +let onSetListener: (data: AgentStatusIpcPayload) => void = () => {} +let onClear: (data: AgentStatusClearIpcPayload) => void = () => {} +let snapshot: AgentStatusIpcPayload[] = [] +let bridge: AgentStatusIpcBridge | null = null +const unsubs: (() => void)[] = [] + +/** Delivers one host row and lets the bridge's live burst window flush it. */ +async function onSet(data: AgentStatusIpcPayload): Promise { + await act(async () => { + onSetListener(data) + await new Promise((resolve) => setTimeout(resolve, 40)) + }) +} + +function startBridge(): void { + bridge = registerAgentStatusIpcBridge(unsubs) +} + +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + useIdentityRowRunAccounts.setState({ byPaneKey: {} }, true) + useAppStore.setState({ + workspaceSessionReady: true, + settings: { + ...getDefaultSettings('/home/user'), + codexManagedAccounts: [codexAccount('work'), codexAccount('personal')], + activeCodexManagedAccountId: 'personal' + }, + tabsByWorktree: { + [WORKTREE]: [ + { + id: TAB, + ptyId: 'pty-1', + worktreeId: WORKTREE, + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + }, + terminalLayoutsByTabId: { + [TAB]: { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: 'pty-1' } + } + } + }) + setKept.mockReset() + snapshot = [] + Object.defineProperty(window, 'api', { + configurable: true, + value: { + agentAccounts: { setKept }, + agentStatus: { + onSet: (callback: typeof onSetListener) => { + onSetListener = callback + return () => {} + }, + onClear: (callback: typeof onClear) => { + onClear = callback + return () => {} + }, + getSnapshot: async () => snapshot + } + } + }) +}) + +afterEach(() => { + bridge?.disposeAsyncState() + bridge?.unsubscribeStore() + bridge = null + unsubs.splice(0).forEach((unsubscribe) => unsubscribe()) + cleanup() + useAppStore.setState(useAppStore.getInitialState(), true) + useIdentityRowRunAccounts.setState({ byPaneKey: {} }, true) +}) + +function keepItems(): [string | null, string | null][] { + return screen + .queryAllByRole('menuitemcheckbox') + .map((item) => [item.textContent, item.getAttribute('aria-checked')]) +} + +describe('a kept agent retired to its identity row', () => { + it('stays un-keepable from the tab menu after the host clears its restored row', async () => { + startBridge() + render() + await onSet(hostRow({ restoredUnconfirmed: true, runAccount: KEPT })) + expect(keepItems()).toEqual([['Keep on work@example.com', 'true']]) + + // The dead-terminal sweep: the pane stops claiming work, then the identity row is restated. + act(() => onClear({ paneKey: PANE })) + await onSet(hostRow({ providerSessionOnly: true, runAccount: KEPT })) + + expect(useAppStore.getState().agentStatusByPaneKey[PANE]).toBeUndefined() + expect(keepItems()).toEqual([['Keep on work@example.com', 'true']]) + + fireEvent.click(screen.getByRole('menuitemcheckbox')) + expect(setKept).toHaveBeenCalledWith({ paneKey: PANE, ptyId: 'pty-1', kept: false }) + + // The host withdraws a facet nothing observes any more once it is released. + await onSet(hostRow({ providerSessionOnly: true, runAccount: null })) + expect(keepItems()).toEqual([]) + }) + + it('reads the facet from the startup snapshot of a window opened after the sweep', async () => { + snapshot = [hostRow({ providerSessionOnly: true, runAccount: KEPT })] + startBridge() + render() + + await vi.waitFor(() => expect(keepItems()).toEqual([['Keep on work@example.com', 'true']])) + expect(useAppStore.getState().agentStatusByPaneKey[PANE]).toBeUndefined() + }) + + it('follows the host past a completed row the clear leaves in place', async () => { + startBridge() + render() + await onSet(hostRow({ state: 'done', runAccount: KEPT })) + act(() => onClear({ paneKey: PANE })) + await onSet(hostRow({ state: 'done', providerSessionOnly: true, runAccount: KEPT })) + expect(useAppStore.getState().agentStatusByPaneKey[PANE]?.runAccount).toEqual(KEPT) + + await onSet(hostRow({ state: 'done', providerSessionOnly: true, runAccount: null })) + + // The completed row still says kept; the host's identity row no longer does. + expect(keepItems()).toEqual([]) + }) + + it('forgets the facet when the host clears the pane for good', async () => { + startBridge() + render() + await onSet(hostRow({ providerSessionOnly: true, runAccount: KEPT })) + expect(keepItems()).toEqual([['Keep on work@example.com', 'true']]) + + act(() => onClear({ paneKey: PANE })) + + expect(keepItems()).toEqual([]) + }) + + it("gives the pane back to the resumed agent's own row", async () => { + startBridge() + render() + await onSet(hostRow({ providerSessionOnly: true, runAccount: KEPT })) + expect(keepItems()).toEqual([['Keep on work@example.com', 'true']]) + + const follow: AgentRunAccount = { ...KEPT, accountId: 'personal', mode: 'follow' } + await onSet(hostRow({ receivedAt: STARTED + 1, runAccount: follow })) + + expect(useIdentityRowRunAccounts.getState().byPaneKey).toEqual({}) + expect(keepItems()).toEqual([['Keep on personal@example.com', 'false']]) + }) + + it('never names an account from a remote identity row, nor a stale one', async () => { + startBridge() + render() + await onSet(hostRow({ providerSessionOnly: true, runAccount: KEPT })) + await onSet(hostRow({ receivedAt: STARTED - 1, providerSessionOnly: true, runAccount: null })) + expect(keepItems()).toEqual([['Keep on work@example.com', 'true']]) + + await onSet( + hostRow({ + receivedAt: STARTED + 1, + connectionId: 'ssh-1', + providerSessionOnly: true, + runAccount: KEPT + }) + ) + expect(keepItems()).toEqual([]) + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/agent-status-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/agent-status-ipc-bridge.ts index 4a43b0c44791..8962e7df429b 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-ipc-bridge.ts @@ -2,6 +2,9 @@ import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-type import { syncAgentHookCompletionNotificationsForStoreUpdate } from '../agent-hook-completion-notifications' import { registerAgentStatusListeners } from './agent-status-listeners' import { useAppStore } from '../../store' +import { noteHostRowRunAccount } from '@/store/agent-identity-row-run-accounts' +import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' +import { isAgentStatusForRecentlyClosedTab } from './agent-status-routing' import { createAgentStatusPaneRoutingIndex, resolvePaneKeyFromRoutingIndex @@ -101,11 +104,20 @@ export function registerAgentStatusIpcBridge(unsubs: (() => void)[]): AgentStatu } } - const applyAgentStatus = createAgentStatusEventApplicator({ + const applyStatusEntry = createAgentStatusEventApplicator({ pendingAgentStatusEvents, transientClearWatermarkByConnectionId, enqueuePendingAgentStatus }) + // Why ahead of the applicator's gates: an identity row never becomes a status entry, yet once a + // kept agent's terminal is gone its facet is the only account the tab menu can un-keep it from. + const applyAgentStatus: typeof applyStatusEntry = (data, options) => { + const store = options?.batch?.transaction.getState() ?? useAppStore.getState() + if (store.workspaceSessionReady && !isAgentStatusForRecentlyClosedTab(store, data.paneKey)) { + noteHostRowRunAccount(resolveAgentPaneAuthorityKey(data.paneKey), data) + } + return applyStatusEntry(data, options) + } const startupSnapshot = registerAgentStatusStartupSnapshot() let snapshotRequestedForReadyWindow = false let snapshotRequestId = 0 diff --git a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts index 62d9f9c3f036..1e173144d281 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts @@ -8,6 +8,8 @@ import { rollbackLegacyWorkerTerminalSurfaceInStore } from '../legacy-worker-terminal-recovery-event' import { useAppStore } from '../../store' +import { forgetIdentityRowRunAccount } from '@/store/agent-identity-row-run-accounts' +import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' import { resolvePaneKey } from './agent-status-routing' import type { PendingAgentStatusEvent } from './agent-status-bridge-types' @@ -81,6 +83,7 @@ export function registerAgentStatusListeners(args: { pendingAgentStatusEvents.splice(index, 1) } } + forgetIdentityRowRunAccount(resolveAgentPaneAuthorityKey(data.paneKey)) const store = useAppStore.getState() if ( data.statusUnavailable !== true && diff --git a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts index ee9504659ce6..72b16e83a7c6 100644 --- a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts +++ b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts @@ -1,4 +1,5 @@ import type { AgentStatusMetadata } from '../../store/slices/agent-status-contract' +import { readAgentRunAccount } from '../../../../shared/agent-run-account' import { normalizeAgentStatusPayload, type AgentStatusIpcPayload, @@ -31,12 +32,19 @@ export function normalizeAgentStatusMetadata( data: AgentStatusIpcPayload, authorityRestartId?: string ): AgentStatusMetadata | undefined { - if (!data.providerSession && !data.launchToken && !authorityRestartId) { + const runAccount = data.runAccount === null ? null : readAgentRunAccount(data.runAccount) + if ( + !data.providerSession && + !data.launchToken && + !authorityRestartId && + runAccount === undefined + ) { return undefined } return { ...(authorityRestartId ? { authorityRestartId } : {}), ...(data.providerSession ? { providerSession: data.providerSession } : {}), - ...(data.launchToken ? { launchToken: data.launchToken } : {}) + ...(data.launchToken ? { launchToken: data.launchToken } : {}), + ...(runAccount !== undefined ? { runAccount } : {}) } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4009acd80dde..676a75511cb0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19041,6 +19041,23 @@ "copied": "Tab ID copied", "failed": "Could not copy tab ID", "copy": "Copy Tab ID" + }, + "TabAgentAccountMenuItems": { + "keepAgentOn": "Keep {{value0}} on {{value1}}", + "keepOn": "Keep on {{value0}}", + "noSession": "Available once the agent reports its session", + "customCodexHome": "Not available while CODEX_HOME is set", + "readOnly": "A newer version of Orca saved the list of kept agents, so this version cannot change it.", + "unavailable": "Orca could not save this right now. Try again in a moment.", + "failed": "This agent cannot be kept on its account right now." + }, + "tabAgentAccounts": { + "systemDefault": "System default", + "removedAccount": "Removed account", + "keptFromNextStart": "Kept on this account from its next start", + "kept": "Kept on this account", + "follows": "Follows the selected account", + "followsFromNextStart": "Follows the selected account from its next start" } } }, @@ -19336,6 +19353,10 @@ "bar": { "workspaceSpace": { "otherTopLevelItems": "Other top-level items ({{value0}})" + }, + "AccountAgentCount": { + "one": "1 terminal agent", + "other": "{{value0}} terminal agents" } } }, @@ -19390,6 +19411,12 @@ "shellEnvSearchVariables": "environment variables", "shellEnvSearchPath": "PATH", "shellEnvSearchProxy": "proxy" + }, + "accountsPaneRemoval": { + "keptAgentsOne": "1 terminal agent is kept on this account. After removal it follows the selected account from its next start.", + "keptAgentsOther": "{{value0}} terminal agents are kept on this account. After removal they follow the selected account from their next start.", + "keptClaudeAgentsOne": "1 terminal agent is kept on this account. After removal, Orca starts it on the selected account, but claude typed in its open terminal will not run.", + "keptClaudeAgentsOther": "{{value0}} terminal agents are kept on this account. After removal, Orca starts them on the selected account, but claude typed in their open terminals will not run." } }, "agentChildRow": { diff --git a/src/renderer/src/lib/agent-account-restart-startup.test.ts b/src/renderer/src/lib/agent-account-restart-startup.test.ts index ee12af48f395..cae03aec21e2 100644 --- a/src/renderer/src/lib/agent-account-restart-startup.test.ts +++ b/src/renderer/src/lib/agent-account-restart-startup.test.ts @@ -251,6 +251,55 @@ describe('buildPaneAccountRestartStartup for Claude', () => { expect(buildPaneAccountRestartStartup('claude', pane)).toBeNull() }) + it('skips a Claude kept on its account, whose own resume stays on that account', () => { + seedAgentStatus({ + agentType: 'claude', + state: 'done', + providerSession: { key: 'session_id', id: SESSION_ID }, + runAccount: { provider: 'claude', lane: 'host', accountId: 'acct-a', mode: 'kept' } + }) + + expect(buildPaneAccountRestartStartup('claude', pane)).toBeNull() + // Why: other restarts still resume it, and its spawn starts it on the kept account. + expect(resolveAgentAccountRestartResumeStartup('claude', pane)?.resumeProviderSession?.id).toBe( + SESSION_ID + ) + }) + + it('skips a Claude whose conversation is kept on another account than it runs on', () => { + seedAgentStatus({ + agentType: 'claude', + state: 'done', + providerSession: { key: 'session_id', id: SESSION_ID }, + runAccount: { + provider: 'claude', + lane: 'host', + accountId: 'acct-b', + mode: 'follow', + keptElsewhere: true + } + }) + + // Why: its own resume starts on the account it is kept on, never on the one a switch picked. + expect(buildPaneAccountRestartStartup('claude', pane)).toBeNull() + expect(resolveAgentAccountRestartResumeStartup('claude', pane)?.resumeProviderSession?.id).toBe( + SESSION_ID + ) + }) + + it('still resumes a Claude that follows the selected account', () => { + seedAgentStatus({ + agentType: 'claude', + state: 'done', + providerSession: { key: 'session_id', id: SESSION_ID }, + runAccount: { provider: 'claude', lane: 'host', accountId: 'acct-a', mode: 'follow' } + }) + + expect(buildPaneAccountRestartStartup('claude', pane)?.resumeProviderSession?.id).toBe( + SESSION_ID + ) + }) + it('never resumes a Codex session as Claude', () => { seedAgentStatus({ agentType: 'codex', diff --git a/src/renderer/src/lib/agent-account-restart-startup.ts b/src/renderer/src/lib/agent-account-restart-startup.ts index 2d080f0e7b59..402924a92e26 100644 --- a/src/renderer/src/lib/agent-account-restart-startup.ts +++ b/src/renderer/src/lib/agent-account-restart-startup.ts @@ -1,4 +1,5 @@ import { useAppStore } from '@/store' +import { isPaneAgentResumePinned } from '@/store/agent-identity-row-run-accounts' import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' import { resolveSshHostReportedPlatform, @@ -129,6 +130,16 @@ export function buildPaneAccountRestartStartup( agent: AccountRestartAgent, args: AccountRestartPane ): AgentAccountRestartStartup | null { + // Why: a kept conversation resumes on its kept account, so a switch restart would only + // relaunch it there, and never on the account the switch moved to. + if (agent === 'claude' && isClaudePanePinned(args)) { + return null + } const resume = resolveAgentAccountRestartResumeStartup(agent, args) return resume ?? (agent === 'codex' ? CODEX_ACCOUNT_RESTART_STARTUP : null) } + +function isClaudePanePinned(args: AccountRestartPane): boolean { + const paneKey = makePaneKey(args.tabId, args.leafId) + return isPaneAgentResumePinned(paneKey, useAppStore.getState().agentStatusByPaneKey[paneKey]) +} diff --git a/src/renderer/src/lib/claude-session-account-move.test.ts b/src/renderer/src/lib/claude-session-account-move.test.ts index 3e12b573d36d..4ace746bd6c7 100644 --- a/src/renderer/src/lib/claude-session-account-move.test.ts +++ b/src/renderer/src/lib/claude-session-account-move.test.ts @@ -18,8 +18,17 @@ import { isClaudePaneMidTurn, resolveClaudeSessionAccountId } from './claude-session-account-move' +import { + answerClaudeAccountSwitchPrompt, + getClaudeAccountSwitchPrompt, + handleRunningClaudeSessionsAfterSwitch, + resetClaudeAccountSwitchSessionsForTests +} from './claude-account-switch-sessions' const laneByPtyId: Record = {} +const toastSuccess = vi.hoisted(() => vi.fn()) + +vi.mock('sonner', () => ({ toast: { success: toastSuccess } })) vi.mock('@/runtime/runtime-terminal-inspection', () => ({ inspectRuntimeTerminalProcess: vi.fn(), @@ -64,6 +73,8 @@ type SeededPane = { sessionId?: string | null /** Folder Claude writes this session's transcript under; null leaves it unreported. */ configDir?: string | null + /** The account facet the host published for this agent; absent publishes none. */ + runAccount?: 'kept' | 'follow' | 'kept-elsewhere' } const tabId = (n: number): string => `tab-${n}` @@ -107,7 +118,19 @@ function seedPanes(panes: SeededPane[]): void { stateStartedAt: 1, stateHistory: [], agentType: pane.agentType, - ...(pane.sessionId === null ? {} : { providerSession: providerSession(pane) }) + ...(pane.sessionId === null ? {} : { providerSession: providerSession(pane) }), + ...(pane.runAccount + ? { + runAccount: { + provider: 'claude' as const, + lane: 'host', + accountId: 'acct-a', + mode: pane.runAccount === 'kept' ? ('kept' as const) : ('follow' as const), + // Why: the conversation is kept on another account than this agent runs on. + ...(pane.runAccount === 'kept-elsewhere' ? { keptElsewhere: true as const } : {}) + } + } + : {}) } } useAppStore.setState({ @@ -209,6 +232,25 @@ describe('findMovableClaudeSessions', () => { expect(await movable()).toEqual([]) }) + it('leaves out an agent kept on its account, and still offers agents that follow', async () => { + seedPanes([ + { n: 1, agentType: 'claude', runAccount: 'kept' }, + { n: 2, agentType: 'claude', runAccount: 'follow' }, + { n: 3, agentType: 'claude' } + ]) + + expect(await movable()).toEqual([ptyId(2), ptyId(3)]) + }) + + it('leaves out an agent whose conversation is kept on another account than it runs on', async () => { + seedPanes([ + { n: 1, agentType: 'claude', runAccount: 'kept-elsewhere' }, + { n: 2, agentType: 'claude', runAccount: 'follow' } + ]) + + expect(await movable()).toEqual([ptyId(2)]) + }) + it('skips a pane where Claude has exited to the shell', async () => { seedPanes([{ n: 1, agentType: 'claude' }]) vi.mocked(inspectRuntimeTerminalProcess).mockResolvedValue({ @@ -295,3 +337,88 @@ describe('claudeLaunchAccountId', () => { expect(claudeLaunchAccountId({ accounts: [covered, own] }, null)).toBeNull() }) }) + +describe('a Claude account switch with agents kept on the outgoing account', () => { + const summary = (id: string) => ({ + id, + email: `${id}@example.com`, + authMethod: 'subscription-oauth' as const, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }) + const roster = { accounts: [summary('acct-a'), summary('acct-b')] } + const switchToB = () => + handleRunningClaudeSessionsAfterSwitch({ + owner: LOCAL, + target: HOST, + before: { state: roster, accountId: 'acct-a' }, + after: { state: roster, accountId: 'acct-b' } + }) + const setPolicy = (policy: 'ask' | 'restart'): void => { + const settings = useAppStore.getState().settings! + useAppStore.setState({ settings: { ...settings, claudeAccountSwitchRunningSessions: policy } }) + } + + beforeEach(() => { + toastSuccess.mockClear() + resetClaudeAccountSwitchSessionsForTests() + vi.mocked(inspectRuntimeTerminalProcess).mockResolvedValue({ + foregroundProcess: 'claude', + hasChildProcesses: true + }) + vi.mocked(confirmRuntimeTerminalForegroundProcess).mockResolvedValue('claude') + }) + + it.each(['ask', 'restart'] as const)( + 'neither asks nor restarts when only kept agents run there: %s', + async (policy) => { + seedPanes([ + { n: 1, agentType: 'claude', runAccount: 'kept' }, + { n: 2, agentType: 'claude', runAccount: 'kept-elsewhere' } + ]) + setPolicy(policy) + + expect(await switchToB()).toBe('show-confirmation') + expect(getClaudeAccountSwitchPrompt()).toBeNull() + expect(useAppStore.getState().pendingPaneAccountRestarts).toEqual({}) + expect(toastSuccess).not.toHaveBeenCalled() + } + ) + + it('counts and restarts only the sessions that are not kept', async () => { + seedPanes([ + { n: 1, agentType: 'claude', runAccount: 'kept' }, + { n: 2, agentType: 'claude', runAccount: 'follow' }, + { n: 3, agentType: 'claude' }, + { n: 4, agentType: 'claude', runAccount: 'kept-elsewhere' } + ]) + setPolicy('ask') + + expect(await switchToB()).toBe('handled') + expect(getClaudeAccountSwitchPrompt()?.sessions.map((session) => session.ptyId)).toEqual([ + ptyId(2), + ptyId(3) + ]) + answerClaudeAccountSwitchPrompt('restart') + + expect(useAppStore.getState().pendingPaneAccountRestarts).toEqual({ + [ptyId(2)]: 'claude', + [ptyId(3)]: 'claude' + }) + }) + + it('always restart moves only the sessions that are not kept', async () => { + seedPanes([ + { n: 1, agentType: 'claude', runAccount: 'kept' }, + { n: 2, agentType: 'claude', runAccount: 'follow' }, + { n: 3, agentType: 'claude', runAccount: 'kept-elsewhere' } + ]) + setPolicy('restart') + + expect(await switchToB()).toBe('handled') + expect(getClaudeAccountSwitchPrompt()).toBeNull() + expect(useAppStore.getState().pendingPaneAccountRestarts).toEqual({ [ptyId(2)]: 'claude' }) + expect(toastSuccess).toHaveBeenCalledWith('Restarting 1 Claude session on acct-b@example.com') + }) +}) diff --git a/src/renderer/src/lib/claude-session-account-move.ts b/src/renderer/src/lib/claude-session-account-move.ts index 36c5ac1cfca9..d9a750f7371f 100644 --- a/src/renderer/src/lib/claude-session-account-move.ts +++ b/src/renderer/src/lib/claude-session-account-move.ts @@ -23,7 +23,7 @@ import type { ClaudeRateLimitAccountsState } from '../../../shared/managed-account-types' import type { TerminalTab } from '../../../shared/terminal-tab-types' -import { resolveAgentAccountRestartResumeStartup } from './agent-account-restart-startup' +import { buildPaneAccountRestartStartup } from './agent-account-restart-startup' import { getRendererAppPlatform } from './renderer-app-platform' import { getCodexAccountSwitchLaneMatcher, @@ -192,7 +192,9 @@ export async function findMovableClaudeSessions(args: { if (pane.accountId !== args.fromAccountId) { continue } - const resumeStartup = resolveAgentAccountRestartResumeStartup('claude', { + // Why the restart's own startup: a pane it would skip, such as an agent kept on its account, + // is not offered either. + const resumeStartup = buildPaneAccountRestartStartup('claude', { tabId: pane.tab.id, leafId: pane.leafId, worktreeId: pane.worktreeId, diff --git a/src/renderer/src/store/agent-identity-row-run-accounts.ts b/src/renderer/src/store/agent-identity-row-run-accounts.ts new file mode 100644 index 000000000000..dd124b3173e9 --- /dev/null +++ b/src/renderer/src/store/agent-identity-row-run-accounts.ts @@ -0,0 +1,98 @@ +import { create } from 'zustand' +import { + agentRunAccountsEqual, + readAgentRunAccount, + type AgentRunAccount +} from '../../../shared/agent-run-account' +import type { AgentStatusEntry, AgentStatusIpcPayload } from '../../../shared/agent-status-types' + +type IdentityRowRunAccount = { + /** `null`: the host's identity row names no account (none, withdrawn, or not a local pane). */ + runAccount: AgentRunAccount | null + receivedAt: number +} + +export type IdentityRowRunAccounts = Readonly> + +type IdentityRowRunAccountState = { byPaneKey: IdentityRowRunAccounts } + +// Why a standalone store: an identity row (`providerSessionOnly`) never becomes a status entry — +// every one but Pi's is dropped — yet once a kept agent's terminal is gone it is the only row +// carrying the agent's account, and un-keeping that agent from the tab menu has to see it. +export const useIdentityRowRunAccounts = create()(() => ({ + byPaneKey: {} +})) + +const EMPTY: IdentityRowRunAccounts = {} + +function setEntry(paneKey: string, entry: IdentityRowRunAccount | undefined): void { + useIdentityRowRunAccounts.setState((state) => { + const next = { ...state.byPaneKey } + if (entry) { + next[paneKey] = entry + } else { + delete next[paneKey] + } + return { byPaneKey: next } + }) +} + +/** Mirrors which kind of row the host holds for a pane: an identity row records its facet, and a + * status row that is not older takes the pane back, so its own facet is the one shown. */ +export function noteHostRowRunAccount( + paneKey: string, + data: Pick & { + receivedAt: number + } +): void { + const current = useIdentityRowRunAccounts.getState().byPaneKey[paneKey] + if (current && data.receivedAt < current.receivedAt) { + return + } + if (!data.providerSessionOnly) { + if (current) { + setEntry(paneKey, undefined) + } + return + } + // Why local only: a remote host's accounts are not this machine's to name. + const runAccount = data.connectionId ? null : (readAgentRunAccount(data.runAccount) ?? null) + if ( + current && + current.receivedAt === data.receivedAt && + agentRunAccountsEqual(current.runAccount, runAccount) + ) { + return + } + setEntry(paneKey, { runAccount, receivedAt: data.receivedAt }) +} + +/** The host cleared the pane's row; a retirement that keeps its identity restates the facet after. */ +export function forgetIdentityRowRunAccount(paneKey: string): void { + if (useIdentityRowRunAccounts.getState().byPaneKey[paneKey]) { + setEntry(paneKey, undefined) + } +} + +/** The facet of the host's current row for a local pane: its identity row's when it holds one. */ +export function resolvePaneRunAccount( + paneKey: string, + row: AgentStatusEntry | undefined, + identityRows: IdentityRowRunAccounts = EMPTY +): AgentRunAccount | undefined { + const identity = identityRows[paneKey] + if (identity) { + return identity.runAccount ?? undefined + } + return row && !row.connectionId ? row.runAccount : undefined +} + +/** Whether the host says a restart would resume this local pane's agent on a kept account, not + * the selection: it is kept on the account it runs on, or its conversation is kept on another. */ +export function isPaneAgentResumePinned( + paneKey: string, + row: AgentStatusEntry | undefined +): boolean { + const facet = resolvePaneRunAccount(paneKey, row, useIdentityRowRunAccounts.getState().byPaneKey) + return facet?.mode === 'kept' || facet?.keptElsewhere === true +} diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index cd066a63a99c..8899d08ffaa5 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -15,6 +15,7 @@ import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { AgentRunAccount } from '../../../../shared/agent-run-account' export type RetainedAgentEntry = { entry: AgentStatusEntry @@ -109,6 +110,8 @@ export type AgentStatusMetadata = { launchConfig?: SleepingAgentLaunchConfig launchToken?: string terminalResumeEligible?: false + /** Host-published account facet; `null` = the host withdrew it, absent = not a host row. */ + runAccount?: AgentRunAccount | null } export type AgentStatusUpdate = { diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index 56629b964524..34f252854838 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -30,6 +30,7 @@ import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pan import { mergeCurrentOrchestrationContext } from './agent-status-orchestration-context' import { deriveAgentStatusLiveFacts } from './agent-status-live-facts' import { liveEntryChildFields } from './agent-status-live-entry-children' +import { resolveLiveEntryRunAccount } from './agent-status-live-entry-run-account' export type AgentStatusLiveEntryBuild = { entry: AgentStatusEntry @@ -202,6 +203,7 @@ export function buildAgentStatusLiveEntry( matchedRegistryLaunchConfig ?? matchedSleepingLaunchConfig const mainAgent = resolveAgentStatusLiveEntryMainAgent(existing, payload, identity.agentType) + const runAccount = resolveLiveEntryRunAccount(existing, metadata, identity.agentType) const entry: AgentStatusEntry = { state: payload.state, workingMode: payload.workingMode, @@ -248,6 +250,7 @@ export function buildAgentStatusLiveEntry( ...liveEntryChildFields(existing, payload), ...(mainAgent ? { mainAgent } : {}), ...(providerSession ? { providerSession } : {}), + ...(runAccount ? { runAccount } : {}), ...(metadata?.terminalResumeEligible === false ? { terminalResumeEligible: false as const } : {}), diff --git a/src/renderer/src/store/slices/agent-status-live-entry-run-account.ts b/src/renderer/src/store/slices/agent-status-live-entry-run-account.ts new file mode 100644 index 000000000000..44e7981daab8 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-live-entry-run-account.ts @@ -0,0 +1,15 @@ +import type { AgentRunAccount } from '../../../../shared/agent-run-account' +import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import type { AgentStatusMetadata } from './agent-status-contract' + +/** Only the host writes the facet; a renderer-local row keeps what the host last said for that agent. */ +export function resolveLiveEntryRunAccount( + existing: AgentStatusEntry | undefined, + metadata: AgentStatusMetadata | undefined, + agentType: AgentType | undefined +): AgentRunAccount | undefined { + if (metadata?.runAccount !== undefined) { + return metadata.runAccount ?? undefined + } + return existing && existing.agentType === agentType ? existing.runAccount : undefined +} diff --git a/src/renderer/src/store/slices/agent-status-run-account.test.ts b/src/renderer/src/store/slices/agent-status-run-account.test.ts new file mode 100644 index 000000000000..4aa3623719c3 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-run-account.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { createTestStore } from './store-test-helpers' +import { normalizeAgentStatusMetadata } from '../../hooks/ipc-events/normalize-agent-status-event' +import type { AgentRunAccount } from '../../../../shared/agent-run-account' +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' +const KEPT: AgentRunAccount = { + provider: 'codex', + lane: 'host', + accountId: 'account-a', + mode: 'kept' +} + +function hostRow(overrides: Partial): AgentStatusIpcPayload { + return { + paneKey: PANE, + connectionId: null, + receivedAt: 1_000, + stateStartedAt: 1_000, + state: 'working', + prompt: 'go', + agentType: 'codex', + ...overrides + } +} + +describe("the host's run-account facet on a renderer status entry", () => { + it('lands verbatim from a host row and is absent when the host never sent one', () => { + const store = createTestStore() + store + .getState() + .setAgentStatus( + PANE, + { state: 'working', prompt: 'go', agentType: 'codex' }, + undefined, + { updatedAt: 1_000, stateStartedAt: 1_000 }, + undefined, + normalizeAgentStatusMetadata(hostRow({})) + ) + expect(store.getState().agentStatusByPaneKey[PANE]).not.toHaveProperty('runAccount') + + store + .getState() + .setAgentStatus( + PANE, + { state: 'working', prompt: 'go', agentType: 'codex' }, + undefined, + { updatedAt: 2_000, stateStartedAt: 1_000 }, + undefined, + normalizeAgentStatusMetadata(hostRow({ receivedAt: 2_000, runAccount: KEPT })) + ) + expect(store.getState().agentStatusByPaneKey[PANE]?.runAccount).toEqual(KEPT) + }) + + it('survives a renderer-local row of the same agent and clears on a host withdrawal', () => { + const store = createTestStore() + store + .getState() + .setAgentStatus( + PANE, + { state: 'working', prompt: 'go', agentType: 'codex' }, + undefined, + { updatedAt: 1_000, stateStartedAt: 1_000 }, + undefined, + normalizeAgentStatusMetadata(hostRow({ runAccount: KEPT })) + ) + // An OSC repaint parsed in the renderer carries no host metadata. + store.getState().setAgentStatus(PANE, { + state: 'working', + prompt: 'go', + agentType: 'codex', + toolName: 'Bash' + }) + expect(store.getState().agentStatusByPaneKey[PANE]?.runAccount).toEqual(KEPT) + + store + .getState() + .setAgentStatus( + PANE, + { state: 'working', prompt: 'go', agentType: 'codex' }, + undefined, + { updatedAt: Date.now() + 1_000, stateStartedAt: 1_000 }, + undefined, + normalizeAgentStatusMetadata(hostRow({ runAccount: null })) + ) + expect(store.getState().agentStatusByPaneKey[PANE]).not.toHaveProperty('runAccount') + }) + + it('drops the facet when another agent takes the pane', () => { + const store = createTestStore() + store + .getState() + .setAgentStatus( + PANE, + { state: 'done', prompt: 'go', agentType: 'codex' }, + undefined, + { updatedAt: 1_000, stateStartedAt: 1_000 }, + undefined, + normalizeAgentStatusMetadata(hostRow({ state: 'done', runAccount: KEPT })) + ) + store.getState().setAgentStatus(PANE, { state: 'working', prompt: 'hi', agentType: 'claude' }) + + expect(store.getState().agentStatusByPaneKey[PANE]?.agentType).toBe('claude') + expect(store.getState().agentStatusByPaneKey[PANE]).not.toHaveProperty('runAccount') + }) + + it('ignores a malformed facet from the wire', () => { + const metadata = normalizeAgentStatusMetadata( + hostRow({ runAccount: { ...KEPT, accountId: '../x' } }) + ) + expect(metadata).toBeUndefined() + }) +}) diff --git a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts index 66ca2493f3be..b0037b6db075 100644 --- a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts +++ b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts @@ -180,6 +180,7 @@ export function createCodexAccountsApi(): PreloadApi['codexAccounts'] { // Why empty rather than absent: the same host owns both records, so a web // client has no recorded lane to offer and every pane falls to derivation. listRecordedPaneLanes: () => Promise.resolve({}), - forgetStalePanes: () => Promise.resolve() + forgetStalePanes: () => Promise.resolve(), + keepStalePanes: () => Promise.resolve() } } diff --git a/src/shared/agent-run-account.test.ts b/src/shared/agent-run-account.test.ts new file mode 100644 index 000000000000..61f406232331 --- /dev/null +++ b/src/shared/agent-run-account.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { + agentRunAccountsEqual, + readAgentRunAccount, + type AgentRunAccount +} from './agent-run-account' +import { pickParsedAgentStatusPayload, type AgentStatusIpcPayload } from './agent-status-types' + +const CLAUDE: AgentRunAccount = { + provider: 'claude', + lane: 'host', + accountId: 'account-a', + mode: 'kept', + shellRoute: 'account' +} + +describe('readAgentRunAccount', () => { + it.each([ + ['a kept Claude facet', CLAUDE], + [ + 'a System default Codex facet', + { provider: 'codex', lane: 'host', accountId: null, mode: 'follow' } + ], + [ + 'a Claude facet whose conversation is kept on another account', + { ...CLAUDE, mode: 'follow', keptElsewhere: true } + ], + [ + 'a facet Keep cannot be offered for', + { + provider: 'codex', + lane: 'wsl:Ubuntu', + accountId: 'b', + mode: 'follow', + keepUnavailable: 'codex-home-not-keepable' + } + ] + ])('accepts %s', (_label, value) => { + expect(readAgentRunAccount(value)).toEqual(value) + }) + + it.each([ + ['null', null], + ['an array', [CLAUDE]], + ['an unknown provider', { ...CLAUDE, provider: 'gemini' }], + ['an unknown mode', { ...CLAUDE, mode: 'placed' }], + ['a path-like id', { ...CLAUDE, accountId: '../home' }], + ['a numeric id', { ...CLAUDE, accountId: 7 }], + ['an undefined id', { ...CLAUDE, accountId: undefined }], + ['a bare distro lane', { ...CLAUDE, lane: 'Ubuntu' }], + ['an unknown shell route', { ...CLAUDE, shellRoute: 'pane' }], + ['a shell route on Codex', { ...CLAUDE, provider: 'codex' }], + ['an unknown keep reason', { ...CLAUDE, keepUnavailable: 'later' }], + ['a kept-elsewhere flag that is not true', { ...CLAUDE, keptElsewhere: 'yes' }], + [ + 'a kept-elsewhere flag on Codex', + { provider: 'codex', lane: 'host', accountId: 'b', mode: 'follow', keptElsewhere: true } + ] + ])('rejects %s', (_label, value) => { + expect(readAgentRunAccount(value)).toBeUndefined() + }) + + it('drops unknown fields instead of carrying them', () => { + expect(readAgentRunAccount({ ...CLAUDE, email: 'someone@example.com' })).toEqual(CLAUDE) + }) +}) + +describe('agentRunAccountsEqual', () => { + it('compares every field and treats null and absent as no facet', () => { + expect(agentRunAccountsEqual(CLAUDE, { ...CLAUDE })).toBe(true) + expect(agentRunAccountsEqual(CLAUDE, { ...CLAUDE, mode: 'follow' })).toBe(false) + expect(agentRunAccountsEqual(CLAUDE, { ...CLAUDE, shellRoute: 'selected' })).toBe(false) + expect(agentRunAccountsEqual(CLAUDE, { ...CLAUDE, keepUnavailable: 'no-session' })).toBe(false) + expect(agentRunAccountsEqual(CLAUDE, { ...CLAUDE, keptElsewhere: true })).toBe(false) + expect(agentRunAccountsEqual(null, undefined)).toBe(true) + expect(agentRunAccountsEqual(CLAUDE, null)).toBe(false) + }) +}) + +describe('client-visible projections', () => { + it('never copy the facet into the parsed status payload', () => { + const row: AgentStatusIpcPayload = { + paneKey: 'tab:leaf', + connectionId: null, + receivedAt: 1, + stateStartedAt: 1, + state: 'working', + prompt: 'p', + agentType: 'claude', + runAccount: CLAUDE + } + // Positive control: the host-side row does carry it. + expect(row.runAccount).toEqual(CLAUDE) + expect(pickParsedAgentStatusPayload(row)).not.toHaveProperty('runAccount') + }) +}) diff --git a/src/shared/agent-run-account.ts b/src/shared/agent-run-account.ts new file mode 100644 index 000000000000..7d23d6292687 --- /dev/null +++ b/src/shared/agent-run-account.ts @@ -0,0 +1,99 @@ +// The account a local terminal agent runs on, published by the execution host on its status row. + +export type AgentRunAccountProvider = 'claude' | 'codex' + +/** 'kept' = a person kept this agent on its account; 'follow' = it moves with the selection. */ +export type AgentRunAccountMode = 'follow' | 'kept' + +export type AgentRunAccount = { + provider: AgentRunAccountProvider + /** Selection lane key ('host' | 'wsl:'). */ + lane: string + /** Managed account id; null = System default. */ + accountId: string | null + mode: AgentRunAccountMode + /** Claude only: which pointer a `claude` typed in this terminal reads. */ + shellRoute?: 'selected' | 'account' + /** Why Keep cannot be offered for this agent right now. */ + keepUnavailable?: 'no-session' | 'codex-home-not-keepable' + /** Claude only: the conversation is kept on another account than the one this agent runs on, + * so a restart resumes it there instead of on the selection. */ + keptElsewhere?: true +} + +const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ + +export function isAgentRunAccountId(value: unknown): value is string { + return typeof value === 'string' && ACCOUNT_ID_PATTERN.test(value) +} + +function isLaneKey(value: unknown): value is string { + return value === 'host' || (typeof value === 'string' && /^wsl:.{1,256}$/.test(value)) +} + +/** Strict reader for persisted or received facets: anything unexpected is no facet. */ +export function readAgentRunAccount(value: unknown): AgentRunAccount | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined + } + const provider = 'provider' in value ? value.provider : undefined + const lane = 'lane' in value ? value.lane : undefined + const accountId = 'accountId' in value ? value.accountId : undefined + const mode = 'mode' in value ? value.mode : undefined + const shellRoute = 'shellRoute' in value ? value.shellRoute : undefined + const keepUnavailable = 'keepUnavailable' in value ? value.keepUnavailable : undefined + const keptElsewhere = 'keptElsewhere' in value ? value.keptElsewhere : undefined + if ( + (provider !== 'claude' && provider !== 'codex') || + !isLaneKey(lane) || + (accountId !== null && !isAgentRunAccountId(accountId)) || + (mode !== 'follow' && mode !== 'kept') || + (shellRoute !== undefined && shellRoute !== 'selected' && shellRoute !== 'account') || + (shellRoute !== undefined && provider !== 'claude') || + (keepUnavailable !== undefined && + keepUnavailable !== 'no-session' && + keepUnavailable !== 'codex-home-not-keepable') || + (keptElsewhere !== undefined && (keptElsewhere !== true || provider !== 'claude')) + ) { + return undefined + } + return { + provider, + lane, + accountId, + mode, + ...(shellRoute !== undefined ? { shellRoute } : {}), + ...(keepUnavailable !== undefined ? { keepUnavailable } : {}), + ...(keptElsewhere === true ? { keptElsewhere } : {}) + } +} + +export function agentRunAccountsEqual( + left: AgentRunAccount | null | undefined, + right: AgentRunAccount | null | undefined +): boolean { + if (!left || !right) { + return !left && !right + } + return ( + left.provider === right.provider && + left.lane === right.lane && + left.accountId === right.accountId && + left.mode === right.mode && + left.shellRoute === right.shellRoute && + left.keepUnavailable === right.keepUnavailable && + left.keptElsewhere === right.keptElsewhere + ) +} + +/** Renderer -> host: keep (or release) one local terminal agent on the account it runs on. */ +export type SetAgentKeptRequest = { paneKey: string; ptyId?: string; kept: boolean } + +export type SetAgentKeptResult = + /** `runAccount` null = released with no live process to report on, so the facet was withdrawn. */ + | { ok: true; runAccount: AgentRunAccount | null } + | { + ok: false + /** 'read-only' = a newer Orca owns the list of kept agents. */ + reason: 'unknown-pane' | 'not-keepable' | 'no-session' | 'unavailable' | 'read-only' + } diff --git a/src/shared/agent-status-ipc-payload.ts b/src/shared/agent-status-ipc-payload.ts index 0a896566dea0..22db5eb8b847 100644 --- a/src/shared/agent-status-ipc-payload.ts +++ b/src/shared/agent-status-ipc-payload.ts @@ -5,6 +5,7 @@ import type { StructuredHostStatus } from './agent-hook-listener/listener-event' import type { AgentProviderSessionMetadata } from './agent-session-resume' +import type { AgentRunAccount } from './agent-run-account' import type { WithAgentStatusObservation } from './agent-status-observation' import type { AgentStatusExecutionId, @@ -72,6 +73,9 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { /** Present on rows the structured session host projects; `owned` keeps them fresh past the * staleness window because the host still runs the provider child. */ structuredHost?: StructuredHostStatus + /** The account a local agent runs on, from the host; `null` withdraws an earlier facet. + * Not part of ParsedAgentStatusPayload, so paired-client projections never carry it. */ + runAccount?: AgentRunAccount | null } & WithAgentStatusObservation /** Identity used by UI-only cleanup to evict exactly the status it cleared. diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 88c744aedda6..3ee89e2efec3 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -3,6 +3,7 @@ // a narrow interrupt fallback synthesizes a final `done` when an agent misses its cancellation hook. import type { AgentProviderSessionMetadata } from './agent-session-resume' +import type { AgentRunAccount } from './agent-run-account' import type { AgentMainAgentStatus } from './main-agent-status' import type { AgentStateHistoryEntry } from './agent-state-history' import { isAgentTurnOutcome } from './agent-turn-outcome' @@ -163,6 +164,8 @@ export type AgentStatusEntry = { * the transition may have been missed while no receiver was up, so freshness gates * treat the row as stale immediately. Cleared by any accepted live event. */ restoredUnconfirmed?: boolean + /** The account a local agent runs on, as its execution host published it. */ + runAccount?: AgentRunAccount } & AgentStatusRowFacets // ─── Agent status payload shape (what hook receivers send via IPC) ────────── diff --git a/src/shared/claude-profile-routing.ts b/src/shared/claude-profile-routing.ts index 6b1294f236f7..c0123e5afee3 100644 --- a/src/shared/claude-profile-routing.ts +++ b/src/shared/claude-profile-routing.ts @@ -7,6 +7,9 @@ export const CLAUDE_INJECTED_CONFIG_DIR_ENV = 'ORCA_CLAUDE_INJECTED_CONFIG_DIR' /** The user's own CLAUDE_CONFIG_DIR an injected value replaced; System default restores it. */ export const CLAUDE_USER_CONFIG_DIR_ENV = 'ORCA_CLAUDE_USER_CONFIG_DIR' +/** A removed account's pointer content: not a folder, so the `claude` function starts nothing. */ +export const CLAUDE_REMOVED_ACCOUNT_POINTER = 'removed-account' + export const CLAUDE_PROFILE_MISSING_MESSAGE = "The selected Claude account's folder is missing. Sign in to it again or choose another account." diff --git a/src/shared/claude-shell-function.test.ts b/src/shared/claude-shell-function.test.ts index 31d5aa06e475..2f3c19c50fdb 100644 --- a/src/shared/claude-shell-function.test.ts +++ b/src/shared/claude-shell-function.test.ts @@ -16,6 +16,7 @@ import { getFishClaudeShellFunction, getPowerShellClaudeShellFunction } from './claude-shell-function' +import { CLAUDE_REMOVED_ACCOUNT_POINTER } from './claude-profile-routing' // Why filtered: CI images lack zsh and fish; a missing shell skips rather than exits 127. const POSIX_SHELLS = ['/bin/bash', '/bin/zsh'].filter((shell) => existsSync(shell)) const FISH = ['/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/usr/bin/fish'].find(existsSync) @@ -148,6 +149,15 @@ describe.each(SHELLS)('the claude function in %s', (shell) => { expect(statSync(fresh).mode & 0o777).toBe(0o700) }) + it('starts nothing for a removed account, never System default or another account', () => { + const f = fixture() + writeFileSync(f.pointer, CLAUDE_REMOVED_ACCOUNT_POINTER) + const result = f.run(shell, injected(f.b)) + expect(result.status).toBe(1) + expect(result.stdout).toBe('') + expect(existsSync(join(f.a, '..', CLAUDE_REMOVED_ACCOUNT_POINTER))).toBe(false) + }) + it('still runs Claude only in the selected folder when that folder cannot be created', () => { const f = fixture() const blocked = join(f.a, 'file', 'home') diff --git a/src/shared/claude-statusline-rate-limits.ts b/src/shared/claude-statusline-rate-limits.ts index 4484809d5313..cb1c82ab91e2 100644 --- a/src/shared/claude-statusline-rate-limits.ts +++ b/src/shared/claude-statusline-rate-limits.ts @@ -2,6 +2,8 @@ // turn — piggybacked on Messages API responses, so reading it costs no usage-endpoint // budget (the endpoint 429s under Orca's polling; see rate-limits/service.ts). +import { parsePaneKey } from './stable-pane-id' + export const CLAUDE_STATUSLINE_PATHNAME = '/statusline/claude' // Why: the statusline ticks ~3x/sec while streaming and the service drops same-value posts @@ -86,3 +88,23 @@ export function parseClaudeStatusLineBody(body: unknown): ClaudeStatusLineRateLi const configDir = typeof fields.configDir === 'string' ? fields.configDir.trim() : '' return { configDir: configDir || null, fiveHour, sevenDay } } + +export type ClaudeStatusLinePane = { + paneKey: string + /** CLAUDE_CONFIG_DIR of the reporting session; null for system-default sessions. */ + configDir: string | null +} + +/** Which folder a pane's Claude reports from; independent of whether the post carries usage. */ +export function parseClaudeStatusLinePane(body: unknown): ClaudeStatusLinePane | null { + if (typeof body !== 'object' || body === null) { + return null + } + const paneKey = 'paneKey' in body && typeof body.paneKey === 'string' ? body.paneKey.trim() : '' + if (paneKey.length > 256 || parsePaneKey(paneKey) === null) { + return null + } + const configDir = + 'configDir' in body && typeof body.configDir === 'string' ? body.configDir.trim() : '' + return { paneKey, configDir: configDir || null } +} diff --git a/tests/e2e/agent-account-keep-claude.spec.ts b/tests/e2e/agent-account-keep-claude.spec.ts new file mode 100644 index 000000000000..6ea61b27da80 --- /dev/null +++ b/tests/e2e/agent-account-keep-claude.spec.ts @@ -0,0 +1,164 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { emitClaudeHookStatus, readHookEndpoint } from './helpers/agent-hook-endpoint' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + waitForActivePaneHookDescriptor, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' + +const probeDir = path.join(process.cwd(), 'tests', 'e2e', 'fixtures', 'claude-account-probe') +const SESSION_ID = 'e2e-kept-claude-session' + +// Why a `claude` on PATH: Orca's shell function only wraps a real executable, and the probe +// prints which account folder and pointer file a launch reads instead of starting anything. +test.use({ + launchEnv: { PATH: [probeDir, process.env.PATH ?? ''].filter(Boolean).join(path.delimiter) } +}) + +type SeededClaudeAccount = { + id: string + email: string + managedAuthPath: string + authMethod: 'subscription-oauth' + createdAt: number + updatedAt: number + lastAuthenticatedAt: number +} + +/** An account folder that already finished setup and holds a (fake) login of its own. */ +function seedClaudeAccount(userData: string, id: string, email: string): SeededClaudeAccount { + const accountDir = path.join(userData, 'claude-profiles', id) + mkdirSync(path.join(accountDir, 'home'), { recursive: true }) + writeFileSync( + path.join(accountDir, 'profile.json'), + `${JSON.stringify({ version: 1, accountId: id, runtime: 'host' })}\n` + ) + writeFileSync( + path.join(accountDir, 'home', '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: email } }) + ) + return { + id, + email, + managedAuthPath: path.join(userData, 'claude-accounts', id), + authMethod: 'subscription-oauth', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } +} + +async function selectClaudeAccount(page: Page, accountId: string): Promise { + await page.evaluate(async (accountId) => { + await window.__store!.getState().updateSettings({ + activeClaudeManagedAccountId: accountId, + activeClaudeManagedAccountIdsByRuntime: { host: accountId, wsl: {} } + }) + }, accountId) +} + +async function openTerminalTab(page: Page): Promise<{ tabId: string; ptyId: string }> { + const tabId = await page.evaluate(() => { + const state = window.__store!.getState() + const tab = state.createTab(state.activeWorktreeId!) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal', state.activeWorktreeId ?? null) + return tab.id + }) + await waitForActiveTerminalManager(page) + return { tabId, ptyId: await waitForActivePanePtyId(page) } +} + +test.describe('Keeping a Claude agent on its account', () => { + test.skip(process.platform === 'win32', 'The probe standing in for claude is a POSIX script') + + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + }) + + test('a kept conversation resumes on its account while another is selected', async ({ + electronApp, + orcaPage + }) => { + const userData = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const work = seedClaudeAccount(userData, 'e2e-keep-work', 'work@example.com') + const personal = seedClaudeAccount(userData, 'e2e-keep-personal', 'personal@example.com') + await orcaPage.evaluate( + async ({ work, personal }) => { + await window.__store!.getState().updateSettings({ + claudeManagedAccounts: [work, personal] + }) + }, + { work, personal } + ) + await selectClaudeAccount(orcaPage, work.id) + + // The agent starts on the selected account and reports its session. + const agentPane = await openTerminalTab(orcaPage) + const descriptor = await waitForActivePaneHookDescriptor(orcaPage) + await execInTerminal(orcaPage, agentPane.ptyId, 'claude') + await waitForTerminalOutput(orcaPage, `CLAUDE_ACCOUNT_PROBE ${work.id} selected-host`) + await emitClaudeHookStatus(await readHookEndpoint(electronApp), { + ...descriptor, + prompt: 'Keep this on work', + sessionId: SESSION_ID + }) + await expect + .poll(() => + orcaPage.evaluate( + (paneKey) => window.__store!.getState().agentStatusByPaneKey[paneKey]?.runAccount ?? null, + descriptor.paneKey + ) + ) + .toMatchObject({ provider: 'claude', accountId: work.id, mode: 'follow' }) + + const tab = orcaPage.locator(`[data-testid="sortable-tab"][data-tab-id="${agentPane.tabId}"]`) + await tab.click({ button: 'right' }) + await orcaPage.getByRole('menuitemcheckbox', { name: 'Keep on work@example.com' }).click() + await expect + .poll(() => + orcaPage.evaluate( + (paneKey) => window.__store!.getState().agentStatusByPaneKey[paneKey]?.runAccount ?? null, + descriptor.paneKey + ) + ) + .toMatchObject({ accountId: work.id, mode: 'kept' }) + + await selectClaudeAccount(orcaPage, personal.id) + + // Resuming the kept conversation starts it on its account, reading that account's own pointer. + await orcaPage.evaluate((sessionId) => { + const state = window.__store!.getState() + const worktreeId = state.activeWorktreeId! + const resumeTab = state.createTab(worktreeId, undefined, undefined, { + launchAgent: 'claude', + pendingStartup: { + command: `claude --resume ${sessionId}`, + launchAgent: 'claude', + resumeProviderSession: { key: 'session_id', id: sessionId } + } + }) + state.setActiveTab(resumeTab.id) + state.setActiveTabType('terminal', worktreeId) + }, SESSION_ID) + // Why longer: like a selected-account start, a kept start first waits (capped) on its folder's refresh. + await waitForTerminalOutput( + orcaPage, + `CLAUDE_ACCOUNT_PROBE ${work.id} account-${work.id}`, + 30_000 + ) + + // Every other terminal still follows the selection. + const plainPane = await openTerminalTab(orcaPage) + await execInTerminal(orcaPage, plainPane.ptyId, 'claude') + await waitForTerminalOutput(orcaPage, `CLAUDE_ACCOUNT_PROBE ${personal.id} selected-host`) + }) +}) diff --git a/tests/e2e/agent-account-keep-codex.spec.ts b/tests/e2e/agent-account-keep-codex.spec.ts new file mode 100644 index 000000000000..da1fbeadd65d --- /dev/null +++ b/tests/e2e/agent-account-keep-codex.spec.ts @@ -0,0 +1,118 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { emitCodexHookStatus, readHookEndpoint } from './helpers/agent-hook-endpoint' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + waitForActivePaneHookDescriptor, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +type SeededCodexAccount = { + id: string + email: string + managedHomePath: string + createdAt: number + updatedAt: number + lastAuthenticatedAt: number +} + +function seedCodexAccount(userData: string, id: string, email: string): SeededCodexAccount { + const managedHomePath = path.join(userData, 'codex-accounts', id, 'home') + mkdirSync(managedHomePath, { recursive: true }) + writeFileSync(path.join(managedHomePath, '.orca-managed-home'), `${id}\n`) + writeFileSync( + path.join(managedHomePath, 'auth.json'), + JSON.stringify({ OPENAI_API_KEY: 'e2e-placeholder' }) + ) + return { id, email, managedHomePath, createdAt: 1, updatedAt: 1, lastAuthenticatedAt: 1 } +} + +async function openTerminalTab(page: Page): Promise<{ tabId: string; ptyId: string }> { + const tabId = await page.evaluate(() => { + const state = window.__store!.getState() + const tab = state.createTab(state.activeWorktreeId!) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal', state.activeWorktreeId ?? null) + return tab.id + }) + await waitForActiveTerminalManager(page) + return { tabId, ptyId: await waitForActivePanePtyId(page) } +} + +test.describe('Keeping a Codex agent on its account', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + }) + + test('a kept pane is not asked to restart when the selection moves', async ({ + electronApp, + orcaPage + }) => { + const userData = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const work = seedCodexAccount(userData, 'e2e-keep-work', 'work@example.com') + const personal = seedCodexAccount(userData, 'e2e-keep-personal', 'personal@example.com') + await orcaPage.evaluate( + async ({ work, personal }) => { + await window.__store!.getState().updateSettings({ + codexManagedAccounts: [work, personal], + activeCodexManagedAccountId: work.id, + activeCodexManagedAccountIdsByRuntime: { host: work.id, wsl: {} } + }) + }, + { work, personal } + ) + + const keptPane = await openTerminalTab(orcaPage) + const descriptor = await waitForActivePaneHookDescriptor(orcaPage) + const controlPane = await openTerminalTab(orcaPage) + + // The host publishes the account once the pane's agent reports in. + await emitCodexHookStatus(await readHookEndpoint(electronApp), { + ...descriptor, + state: 'working', + prompt: 'Keep this on work', + sessionId: 'e2e-kept-codex-session' + }) + await expect + .poll(() => + orcaPage.evaluate( + (paneKey) => window.__store!.getState().agentStatusByPaneKey[paneKey]?.runAccount ?? null, + descriptor.paneKey + ) + ) + .toMatchObject({ provider: 'codex', accountId: work.id, mode: 'follow' }) + + const tab = orcaPage.locator(`[data-testid="sortable-tab"][data-tab-id="${keptPane.tabId}"]`) + await tab.click({ button: 'right' }) + const keep = orcaPage.getByRole('menuitemcheckbox', { name: 'Keep on work@example.com' }) + await expect(keep).toHaveAttribute('aria-checked', 'false') + await keep.click() + await expect + .poll(() => + orcaPage.evaluate( + (paneKey) => window.__store!.getState().agentStatusByPaneKey[paneKey]?.runAccount ?? null, + descriptor.paneKey + ) + ) + .toMatchObject({ accountId: work.id, mode: 'kept' }) + + await orcaPage.evaluate(async (personalId) => { + await window.__store!.getState().updateSettings({ + activeCodexManagedAccountId: personalId, + activeCodexManagedAccountIdsByRuntime: { host: personalId, wsl: {} } + }) + }, personal.id) + + const stalePtyIds = await orcaPage.evaluate( + async (ptyIds) => + (await window.api.codexAccounts.listStalePanes({ ptyIds })).map((pane) => pane.ptyId), + [keptPane.ptyId, controlPane.ptyId] + ) + expect(stalePtyIds).toEqual([controlPane.ptyId]) + }) +}) diff --git a/tests/e2e/fixtures/claude-account-probe/claude b/tests/e2e/fixtures/claude-account-probe/claude new file mode 100755 index 000000000000..7afa1c32054c --- /dev/null +++ b/tests/e2e/fixtures/claude-account-probe/claude @@ -0,0 +1,11 @@ +#!/bin/sh +# Stands in for `claude`: prints which account folder and pointer file this launch reads. +account=none +if [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then + account=$(basename "$(dirname "$CLAUDE_CONFIG_DIR")") +fi +pointer=none +if [ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" ]; then + pointer=$(basename "$ORCA_CLAUDE_PROFILE_POINTER") +fi +printf 'CLAUDE_ACCOUNT_PROBE %s %s\n' "$account" "$pointer" diff --git a/tests/e2e/helpers/agent-hook-endpoint.ts b/tests/e2e/helpers/agent-hook-endpoint.ts index f112329dbc16..fc426745854c 100644 --- a/tests/e2e/helpers/agent-hook-endpoint.ts +++ b/tests/e2e/helpers/agent-hook-endpoint.ts @@ -96,6 +96,35 @@ export async function emitCodexHookStatus( } } +export async function emitClaudeHookStatus( + endpoint: AgentHookEndpoint, + status: { paneKey: string; worktreeId: string; prompt: string; sessionId: string } +): Promise { + const [tabId] = status.paneKey.split(':') + const response = await fetch(`http://127.0.0.1:${endpoint.port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': endpoint.token + }, + body: JSON.stringify({ + paneKey: status.paneKey, + tabId, + worktreeId: status.worktreeId, + env: endpoint.env, + version: endpoint.version, + payload: { + hook_event_name: 'UserPromptSubmit', + prompt: status.prompt, + session_id: status.sessionId + } + }) + }) + if (response.status !== 204) { + throw new Error(`Claude hook POST returned ${response.status}`) + } +} + export async function emitGrokHookPayload( endpoint: AgentHookEndpoint, event: {