From 0a5567c2b2aaa81bc4f6fca4724e8b787e5bbcda Mon Sep 17 00:00:00 2001 From: Qichang Zheng Date: Wed, 14 Oct 2026 12:39:41 +0800 Subject: [PATCH 01/32] feat(codex): remember when a pane is kept on its account A Codex pane's launch record gains an optional `kept` flag. The stale-pane sweep skips kept records, so a kept pane is never offered the "Account switched" prompt, and registry trimming never drops a kept record. `setCodexPaneKept` refuses against a registry it could not read instead of queuing a record it never saw. Co-Authored-By: Claude Opus 5.5 --- .../codex-pane-account-registry-kept.test.ts | 115 ++++++++++++++++++ ...ex-pane-account-registry-mutations.test.ts | 20 +++ .../codex-pane-account-registry-mutations.ts | 11 +- .../codex-pane-account-registry-types.ts | 2 + src/main/codex/codex-pane-account-registry.ts | 30 ++++- .../codex/codex-stale-pane-accounts.test.ts | 21 +++- src/main/codex/codex-stale-pane-accounts.ts | 3 +- 7 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 src/main/codex/codex-pane-account-registry-kept.test.ts 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..9a14b702b071 100644 --- a/src/main/codex/codex-pane-account-registry-mutations.ts +++ b/src/main/codex/codex-pane-account-registry-mutations.ts @@ -137,16 +137,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..54530b4bb3d0 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,32 @@ export function recordCodexPaneAccount(ptyId: string, record: CodexPaneAccountRe mutations.record(ptyId, record) } +export type SetCodexPaneKeptResult = 'kept' | 'released' | 'no-record' | 'unavailable' + +/** + * Marks or clears a person's choice to keep a pane on the account it launched under. + * Why read first: the flag lives on the launch record, so an unreadable registry + * must refuse rather than queue a record it never saw. + */ +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' +} + /** * Drops a PTY's record once it exits. */ diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index ac21c3d56046..7022cd5e2109 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -13,7 +13,8 @@ import { hasRecordedManagedHostCodexPane, isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys, - recordCodexPaneAccount + recordCodexPaneAccount, + setCodexPaneKept } from './codex-pane-account-registry' import { forgetStaleCodexPanes, listStaleCodexPanes } from './codex-stale-pane-accounts' @@ -421,6 +422,24 @@ 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('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..b15ffa953744 100644 --- a/src/main/codex/codex-stale-pane-accounts.ts +++ b/src/main/codex/codex-stale-pane-accounts.ts @@ -24,7 +24,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( From d3d5d1963d08a6f8e2893a825696461f736a46f4 Mon Sep 17 00:00:00 2001 From: Qichang Zheng Date: Wed, 14 Oct 2026 12:47:48 +0800 Subject: [PATCH 02/32] feat(codex): make "Keep old account" keep the pane "Keep old account" in the Account switched prompt used to delete the pane's launch record. It now marks the pane kept, so the answer survives restarts and the record still says which account the pane runs on. Panes whose home cannot stay on an account (the shared CODEX_HOME mirror, WSL panes, records from before route provenance) keep today's behaviour: the record is dropped. The channel becomes codexAccounts:keepStalePanes and also carries the pane key, so the host can keep the pane's conversation as well. Co-Authored-By: Claude Opus 5.5 --- .../codex/codex-stale-pane-accounts.test.ts | 65 ++++++++++++++++++- src/main/codex/codex-stale-pane-accounts.ts | 35 +++++++++- src/main/ipc/codex-accounts.ts | 33 ++++++++-- src/preload/api/agent-account-api.ts | 4 +- src/preload/api/codex-accounts-bridge.ts | 4 +- .../src/components/CodexRestartChip.tsx | 15 +++-- .../components/codex-restart-chip.test.tsx | 31 +++++++-- .../TerminalPaneRuntimePortals.tsx | 2 + .../web/preload-api/web-agent-accounts-api.ts | 2 +- 9 files changed, 167 insertions(+), 24 deletions(-) diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index 7022cd5e2109..46432b6c4d35 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -16,7 +16,11 @@ import { 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 @@ -440,6 +444,65 @@ describe('listStaleCodexPanes', () => { ).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 b15ffa953744..99a96aedbd20 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 = { @@ -52,6 +55,36 @@ export function forgetStaleCodexPanes(ptyIds: readonly string[]): void { } } +/** + * True when a pane's own home can carry it on its account after the selection moves. + * Why not the shared home: it mirrors whichever account is selected, and a WSL + * pane's home is owned by the distro, so neither can stay on a kept account. + */ +export function isCodexPaneKeepable(record: CodexPaneAccountRecord): boolean { + return ( + record.selectionKey === 'host' && + (record.homeRoute === 'account-home' || record.homeRoute === 'real-home') + ) +} + +/** + * Answers the restart prompt with "Keep old account": marks keepable panes kept, + * and drops the record of every other pane exactly as before. + * Returns the pty ids that are now kept. + */ +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/codex-accounts.ts b/src/main/ipc/codex-accounts.ts index fc1ec0bcb709..98d438a7b66c 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -3,7 +3,7 @@ import type { CodexAccountAddTarget, CodexAccountService } from '../codex-accoun import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import { broadcastCodexPendingLoginUrl } from './codex-pending-login-url-broadcast' import { listRecordedCodexPaneLanes } from '../codex/codex-pane-account-registry' -import { forgetStaleCodexPanes, listStaleCodexPanes } from '../codex/codex-stale-pane-accounts' +import { keepStaleCodexPanes, listStaleCodexPanes } from '../codex/codex-stale-pane-accounts' import type { GlobalSettings } from '../../shared/global-settings-types' export function registerCodexAccountHandlers( @@ -28,11 +28,11 @@ export function registerCodexAccountHandlers( args.ptyIds.filter((ptyId): ptyId is string => typeof ptyId === 'string') ) }) - ipcMain.handle('codexAccounts:forgetStalePanes', (_event, args: { ptyIds?: unknown }) => { - if (!Array.isArray(args?.ptyIds)) { - return + ipcMain.handle('codexAccounts:keepStalePanes', (_event, args: { panes?: unknown }) => { + const panes = readKeepStalePanes(args?.panes) + if (panes.length > 0) { + keepStaleCodexPanes(panes.map((pane) => pane.ptyId)) } - forgetStaleCodexPanes(args.ptyIds.filter((ptyId): ptyId is string => typeof ptyId === 'string')) }) ipcMain.handle('codexAccounts:list', () => codexAccounts.listAccounts()) ipcMain.handle('codexAccounts:add', (_event, args?: CodexAccountAddTarget) => @@ -66,3 +66,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/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts index 5c1379324f13..9630d9f8d76f 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -38,8 +38,8 @@ 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. */ - 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 } export type ClaudeAccountsApi = { diff --git a/src/preload/api/codex-accounts-bridge.ts b/src/preload/api/codex-accounts-bridge.ts index e94b5f35b87a..b70df6e9e49d 100644 --- a/src/preload/api/codex-accounts-bridge.ts +++ b/src/preload/api/codex-accounts-bridge.ts @@ -34,6 +34,6 @@ export const codexAccountsApi = { > => ipcRenderer.invoke('codexAccounts:listStalePanes', args), listRecordedPaneLanes: (args: { ptyIds: string[] }): Promise> => ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args), - forgetStalePanes: (args: { ptyIds: string[] }): Promise => - 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/renderer/src/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx index e4109551bf3d..b5f0f2f84d50 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 { @@ -41,11 +44,13 @@ export default function CodexRestartChip({ 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) - }) + // Why: notices are renderer-only, so the answer must be persisted on the + // host or the startup sweep re-raises the answered prompt. + void window.api.codexAccounts + .keepStalePanes({ panes: [{ ptyId, ...(paneKey ? { paneKey } : {}) }] }) + .catch((err: unknown) => { + console.warn('Failed to keep Codex pane on its account:', err) + }) } return ( diff --git a/src/renderer/src/components/codex-restart-chip.test.tsx b/src/renderer/src/components/codex-restart-chip.test.tsx index d588dfdeef45..5de5f6cd675f 100644 --- a/src/renderer/src/components/codex-restart-chip.test.tsx +++ b/src/renderer/src/components/codex-restart-chip.test.tsx @@ -19,7 +19,7 @@ const PTY_ONE = 'worktree-1@@pty-1' const PTY_TWO = 'worktree-1@@pty-2' let container: HTMLDivElement -let forgetStalePanes: ReturnType +let keepStalePanes: ReturnType let root: Root function notice(previousAccountLabel: string, nextAccountLabel: string) { @@ -42,10 +42,10 @@ beforeEach(() => { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) - forgetStalePanes = vi.fn(() => Promise.resolve()) + keepStalePanes = vi.fn(() => Promise.resolve()) Object.defineProperty(window, 'api', { configurable: true, - value: { codexAccounts: { forgetStalePanes } } + value: { codexAccounts: { keepStalePanes } } }) }) @@ -141,13 +141,30 @@ describe('CodexRestartChip pane ownership', () => { }) expect(returnFocus).toHaveBeenCalledOnce() - expect(forgetStalePanes).toHaveBeenCalledExactlyOnceWith({ ptyIds: [PTY_ONE] }) + expect(keepStalePanes).toHaveBeenCalledExactlyOnceWith({ panes: [{ ptyId: PTY_ONE }] }) expect(useAppStore.getState().codexRestartNoticeByPtyId[PTY_ONE]?.dismissed).toBe(true) expect(useAppStore.getState().codexRestartNoticeByPtyId[PTY_TWO]?.dismissed).toBeUndefined() expect(firstPane.textContent).toBe('') expect(secondPane.textContent).toContain('old-two@example.com') }) + it('keeps the pane through its pane key so the host can keep its conversation too', async () => { + useAppStore.setState({ + codexRestartNoticeByPtyId: { [PTY_ONE]: notice('old@example.com', 'new@example.com') } + }) + await act(async () => { + root.render() + }) + + await act(async () => { + button(container, 'Keep old account').click() + }) + + expect(keepStalePanes).toHaveBeenCalledExactlyOnceWith({ + panes: [{ ptyId: PTY_ONE, paneKey: 'tab-1:leaf-1' }] + }) + }) + it('follows a pane replacement id without acting on the retired PTY', async () => { useAppStore.setState({ codexRestartNoticeByPtyId: { @@ -278,7 +295,7 @@ describe('CodexRestartChip pane focus', () => { expect(container.querySelector('[role="dialog"]')).toBeNull() expect(returnFocus).toHaveBeenCalledOnce() if (action !== 'restart') { - expect(forgetStalePanes).toHaveBeenCalledExactlyOnceWith({ ptyIds: [PTY_ONE] }) + expect(keepStalePanes).toHaveBeenCalledExactlyOnceWith({ panes: [{ ptyId: PTY_ONE }] }) expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({}) } } @@ -293,7 +310,7 @@ describe('CodexRestartChip pane focus', () => { }) expect(container.querySelector('[role="dialog"]')).not.toBeNull() expect(returnFocus).not.toHaveBeenCalled() - expect(forgetStalePanes).not.toHaveBeenCalled() + expect(keepStalePanes).not.toHaveBeenCalled() }) it('does not dismiss when a card text selection ends on the backdrop', async () => { @@ -306,7 +323,7 @@ describe('CodexRestartChip pane focus', () => { }) expect(container.querySelector('[role="dialog"]')).not.toBeNull() expect(returnFocus).not.toHaveBeenCalled() - expect(forgetStalePanes).not.toHaveBeenCalled() + expect(keepStalePanes).not.toHaveBeenCalled() }) it('does not dismiss on a secondary mouse button press', async () => { diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx index de9270182cbe..b56a0fecd299 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 { NewerDaemonHoldBanner } from './NewerDaemonHoldBanner' @@ -46,6 +47,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/web/preload-api/web-agent-accounts-api.ts b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts index 66ca2493f3be..c4c93302a2bb 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,6 @@ 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() + keepStalePanes: () => Promise.resolve() } } From 61ccf26de12b12cb46ef7ca8fa9334906090030b Mon Sep 17 00:00:00 2001 From: Qichang Zheng Date: Wed, 14 Oct 2026 13:01:51 +0800 Subject: [PATCH 03/32] feat(agent-status): carry the account an agent runs on Agent Status rows gain an optional runAccount facet: which Claude or Codex account a local terminal agent runs on, and whether a person kept it there. The hook server is the only writer. republishRunAccount restates a row with a new facet and nothing else: its clocks, observation and state stay as they were, it is persisted with the row, and only the renderer is told, so plugin listeners, the session recorder and paired clients see no change. Later rows of the same agent keep the facet; a different agent in the pane drops it. Hydration validates it and keeps it only for local rows. The paired-client projections (pickParsedAgentStatusPayload) never copy it. In the renderer a host row's facet is copied as is; a renderer-local row (an OSC repaint) keeps the facet the host last sent for that agent. Co-Authored-By: Claude Opus 5.5 --- ...ver-last-status-hydrate-validation.test.ts | 42 +++++ .../agent-hooks/server-run-account.test.ts | 166 ++++++++++++++++++ src/main/agent-hooks/server.ts | 5 +- .../server/server-persistence-validation.ts | 6 +- .../server/server-row-ownership.ts | 2 + .../server/server-run-account-carry.ts | 17 ++ .../agent-hooks/server/server-run-account.ts | 62 +++++++ .../server/server-status-identity.ts | 1 + .../server/server-status-update.ts | 4 +- src/main/agent-hooks/server/server-types.ts | 4 + src/main/startup/main-window-agent-status.ts | 6 +- ...in-window-structured-status-filter.test.ts | 12 ++ .../normalize-agent-status-event.ts | 12 +- .../src/store/slices/agent-status-contract.ts | 3 + .../slices/agent-status-live-entry-builder.ts | 3 + .../agent-status-live-entry-run-account.ts | 18 ++ .../slices/agent-status-run-account.test.ts | 114 ++++++++++++ src/shared/agent-run-account.test.ts | 86 +++++++++ src/shared/agent-run-account.ts | 80 +++++++++ src/shared/agent-status-ipc-payload.ts | 4 + src/shared/agent-status-types.ts | 3 + 21 files changed, 642 insertions(+), 8 deletions(-) create mode 100644 src/main/agent-hooks/server-run-account.test.ts create mode 100644 src/main/agent-hooks/server/server-run-account-carry.ts create mode 100644 src/main/agent-hooks/server/server-run-account.ts create mode 100644 src/renderer/src/store/slices/agent-status-live-entry-run-account.ts create mode 100644 src/renderer/src/store/slices/agent-status-run-account.test.ts create mode 100644 src/shared/agent-run-account.test.ts create mode 100644 src/shared/agent-run-account.ts 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..29e43b3ab510 --- /dev/null +++ b/src/main/agent-hooks/server-run-account.test.ts @@ -0,0 +1,166 @@ +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 + }) + expect(enriched).not.toHaveBeenCalled() + expect(statusChanges).not.toHaveBeenCalled() + expect(mutations).not.toHaveBeenCalled() + expect(JSON.parse(server.serialized()).entries[PANE_KEY].runAccount).toEqual(KEPT) + }) + + 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-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 e8540392075f..944e6dbc702f 100644 --- a/src/main/agent-hooks/server/server-row-ownership.ts +++ b/src/main/agent-hooks/server/server-row-ownership.ts @@ -35,6 +35,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..57c3771f1d57 --- /dev/null +++ b/src/main/agent-hooks/server/server-run-account.ts @@ -0,0 +1,62 @@ +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 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 { + /** + * Restates a local row with a new run-account facet. Only the facet changes: timestamps, + * observation and state stay as they were, and only the row, persistence and the renderer + * see it — plugin and recorder listeners get nothing because no status changed. + * `null` withdraws a facet the host published earlier. + */ + 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 a replay: the status is restated, not changed, so first-work effects must skip it. + this.onAgentStatus?.({ ...next, isReplay: true }) + return true + } + + 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 e9d98098c059..875244ab43a4 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' export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusApplication { protected readonly claudeOwedNotificationExpiry = new ClaudeOwedNotificationExpiryTimers( @@ -264,7 +265,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 8ebde5d39e23..822675b1572c 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 { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import type { AgentStatusClearIpcPayload, @@ -28,6 +29,9 @@ 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 } // `claudeRunningNonAgentTask` is persisted on purpose: it is the one child-work fact the row's diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts index 38bb380d7247..be32f3af5acc 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -44,7 +44,8 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt observation, isReplay, authorityRestartId, - structuredHost + structuredHost, + runAccount }) => { if (state.mainWindow?.isDestroyed()) { return @@ -95,7 +96,8 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt ...(promptInteractionKey ? { promptInteractionKey } : {}), ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), ...(observation ? { observation } : {}), - ...(orchestration ? { orchestration } : {}) + ...(orchestration ? { orchestration } : {}), + ...(runAccount !== undefined ? { runAccount } : {}) } state.mainWindow?.webContents.send('agentStatus:set', statusEvent) getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent) 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 da26a593d00f..67e3074c29e0 100644 --- a/src/main/startup/main-window-structured-status-filter.test.ts +++ b/src/main/startup/main-window-structured-status-filter.test.ts @@ -102,3 +102,15 @@ it("forwards the host's turn start, and nothing when the host stamped none", () expect(sent[0].event).toHaveProperty('turnStartedAt', 1) expect(sent[1].event).not.toHaveProperty('turnStartedAt') }) + +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') +}) 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/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 cd4c6da3d47f..921c63037372 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 @@ -29,6 +29,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 @@ -197,6 +198,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, @@ -243,6 +245,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..d9b0f7b80bb5 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-live-entry-run-account.ts @@ -0,0 +1,18 @@ +import type { AgentRunAccount } from '../../../../shared/agent-run-account' +import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import type { AgentStatusMetadata } from './agent-status-contract' + +/** + * The host is the only producer of the facet: a host row's value (or withdrawal) is copied as is, + * while a renderer-local row (OSC title, launch seed) 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/shared/agent-run-account.test.ts b/src/shared/agent-run-account.test.ts new file mode 100644 index 000000000000..cff22e4e2d58 --- /dev/null +++ b/src/shared/agent-run-account.test.ts @@ -0,0 +1,86 @@ +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 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' }] + ])('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(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..9764810da3b4 --- /dev/null +++ b/src/shared/agent-run-account.ts @@ -0,0 +1,80 @@ +// 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' +} + +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 + 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') + ) { + return undefined + } + return { + provider, + lane, + accountId, + mode, + ...(shellRoute !== undefined ? { shellRoute } : {}), + ...(keepUnavailable !== undefined ? { keepUnavailable } : {}) + } +} + +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 + ) +} diff --git a/src/shared/agent-status-ipc-payload.ts b/src/shared/agent-status-ipc-payload.ts index 59803d498702..51b8b7bd9eff 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, @@ -70,6 +71,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 1fff0ea17e3e..14bcf01eb614 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) ────────── From c6c3729f0caa8fe24492340219368e6fb75c22d9 Mon Sep 17 00:00:00 2001 From: Qichang Zheng Date: Wed, 14 Oct 2026 13:15:38 +0800 Subject: [PATCH 04/32] feat(agent-accounts): publish each terminal agent's account A host-side producer writes the runAccount facet onto local Claude and Codex terminal rows. It never guesses: - Codex reads the pane's existing launch record (account, kept flag, whether its home can be kept). WSL lanes publish nothing. - Claude dates a session to the account it started on: the pane's own pointer for panes started on one, otherwise the selection at the moment this runtime saw the session start. A session that was already running when it was first seen keeps the facet an earlier runtime recorded, or gets none. The statusline folder each post reports then corrects it, and a folder no saved account owns withdraws the facet. Both spawn-commit paths note the pane's lane and whether it was a fresh spawn. Statusline posts are forwarded to the producer separately from the usage feed, alias-resolved, whether or not they carry usage. Providers with no saved accounts get no facet. Co-Authored-By: Claude Opus 5.5 --- .../agent-run-account-compute.ts | 95 +++++ .../agent-run-accounts-installed.ts | 22 + .../agent-accounts/agent-run-accounts.test.ts | 377 ++++++++++++++++++ src/main/agent-accounts/agent-run-accounts.ts | 250 ++++++++++++ .../server-claude-statusline.test.ts | 31 ++ .../agent-hooks/server/server-lifecycle.ts | 4 + .../agent-hooks/server/server-run-account.ts | 29 ++ src/main/ipc/pty/ipc/spawn-commit.ts | 9 + src/main/ipc/pty/runtime/spawn-commit.ts | 9 + .../startup/main-process-account-services.ts | 7 + .../main-process-agent-run-accounts.test.ts | 100 +++++ .../main-process-agent-run-accounts.ts | 60 +++ src/shared/claude-statusline-rate-limits.ts | 22 + 13 files changed, 1015 insertions(+) create mode 100644 src/main/agent-accounts/agent-run-account-compute.ts create mode 100644 src/main/agent-accounts/agent-run-accounts-installed.ts create mode 100644 src/main/agent-accounts/agent-run-accounts.test.ts create mode 100644 src/main/agent-accounts/agent-run-accounts.ts create mode 100644 src/main/startup/main-process-agent-run-accounts.test.ts create mode 100644 src/main/startup/main-process-agent-run-accounts.ts 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..3d2c3f769640 --- /dev/null +++ b/src/main/agent-accounts/agent-run-account-compute.ts @@ -0,0 +1,95 @@ +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' }) + } +} + +function computeClaude(input: AgentRunAccountInput): AgentRunAccount | null { + const observed = input.claude + 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 kept = + input.providerSessionId !== null && + input.claudeKeptAccountId !== undefined && + input.claudeKeptAccountId === accountId + return { + provider: 'claude', + lane: 'host', + accountId, + mode: kept ? 'kept' : 'follow', + shellRoute: observed.shellRoute, + ...(input.providerSessionId === null ? { keepUnavailable: 'no-session' } : {}) + } +} 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..2934e76290fc --- /dev/null +++ b/src/main/agent-accounts/agent-run-accounts-installed.ts @@ -0,0 +1,22 @@ +// 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' + +let installed: AgentRunAccounts | undefined + +/** Installed beside the account services (desktop and `orca serve`); orcad has none. */ +export function installAgentRunAccounts(producer: AgentRunAccounts | undefined): void { + installed = producer +} + +export function getAgentRunAccounts(): AgentRunAccounts | undefined { + return installed +} + +/** Called by both spawn-commit paths; a facet failure must never fail a spawn. */ +export function noteAgentPaneLaunch(fact: PaneLaunchFact): void { + try { + installed?.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..0003648a34c7 --- /dev/null +++ b/src/main/agent-accounts/agent-run-accounts.test.ts @@ -0,0 +1,377 @@ +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 { 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' +} + +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' + } + ] + ])('%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 = { + 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 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) + } + }) + 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 { 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() + }) +}) 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..b56bb76f45ef --- /dev/null +++ b/src/main/agent-accounts/agent-run-accounts.ts @@ -0,0 +1,250 @@ +import { resolve } from 'node:path' +import { agentRunAccountsEqual, type AgentRunAccount } from '../../shared/agent-run-account' +import { normalizeAgentProviderSession } from '../../shared/agent-session-resume' +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' + +// 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' +> & { 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 +} + +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 } +} + +type ClaudePaneState = ClaudePaneObservation & { + sessionId: string | null + /** Account the pane's own pointer names, for panes started kept. */ + pointerAccountId?: string | null + lastConfigDir?: string | null +} + +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 samePath(left: string, right: string): boolean { + const normalize = (value: string): string => { + const resolved = resolve(value) + return process.platform === 'win32' ? resolved.toLowerCase() : resolved + } + return normalize(left) === normalize(right) +} + +/** + * The single producer of the run-account facet on Agent Status rows. It joins what a + * spawn knew (launch facts, the Codex launch record) with what the agent later reports + * (session ids, the Claude statusline folder) and republishes only when the facet changes. + */ +export class AgentRunAccounts { + private readonly launches = new Map() + private readonly claudePanes = 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.refresh(fact.paneKey) + } + + noteStatus(row: AgentRunAccountStatusRow): void { + const provider = runAccountProvider(row.payload.agentType) + if (provider === 'claude' && row.connectionId === null && !row.structuredHost) { + this.observeClaudeSession(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) + 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 } + }) + } + + 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 + 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: row.runAccount ?? undefined, + claudeKeptAccountId: undefined + }) + } + + private observeClaudeSession(row: AgentRunAccountStatusRow): void { + const sessionId = sessionIdOf(row) + const existing = this.claudePanes.get(row.paneKey) + if (existing && (sessionId === null || sessionId === existing.sessionId)) { + return + } + const fact = this.launches.get(row.paneKey) + 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) + setBounded(this.claudePanes, row.paneKey, { + sessionId, + 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 + }) + return + } + setBounded(this.claudePanes, row.paneKey, { + sessionId, + shellRoute: existing.shellRoute, + pointerAccountId: existing.pointerAccountId, + accountId: this.claudeStartAccount(existing.shellRoute, existing.pointerAccountId) + }) + } + + /** 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-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/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 323066631edb..0c58b8bbe48b 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -17,6 +17,9 @@ import { AgentHookServerStatusHookLifecycle } from './server-status-hook-lifecyc import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../../shared/opencode-startup-prompt' export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle { + /** 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. */ async start(options?: { env?: string @@ -88,6 +91,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-run-account.ts b/src/main/agent-hooks/server/server-run-account.ts index 57c3771f1d57..b7289811abdb 100644 --- a/src/main/agent-hooks/server/server-run-account.ts +++ b/src/main/agent-hooks/server/server-run-account.ts @@ -1,6 +1,10 @@ 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' @@ -13,6 +17,31 @@ export type AgentRunAccountRow = { } 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 run-account facet. Only the facet changes: timestamps, * observation and state stay as they were, and only the row, persistence and the renderer diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index d009b7871f5c..5311dc2f530b 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -9,6 +9,8 @@ import { codexReattachedHomeRouteField } from '../host-env/codex-home' import { rememberPaneKeyForPty } from '../pane/key-state' +import { noteAgentPaneLaunch } from '../../../agent-accounts/agent-run-accounts-installed' +import { getCodexSelectionLaneKey } from '../../../../shared/codex-selection-lane' import { resolvePaneSpawnReservation } from '../pane/spawn-reservation' import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' import { @@ -173,6 +175,13 @@ async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise store.getSettings(), + onSettingsChanged: (listener) => store.onSettingsChanged(listener), + claudeRouter: state.claudeRuntimeAuth.router + }) // Why: quota polls are background work and must not boot a stopped distro; user-driven // resets call prepareForRateLimitFetch directly. state.rateLimits.setCodexHomePathResolver(async (target) => 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..dcaa99bda075 --- /dev/null +++ b/src/main/startup/main-process-agent-run-accounts.test.ts @@ -0,0 +1,100 @@ +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 } from '../agent-hooks/server' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { AgentRunAccountSettings } from '../agent-accounts/agent-run-accounts' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import { + _internals as registryInternals, + recordCodexPaneAccount +} from '../codex/codex-pane-account-registry' +import { paneKeyPtyId, ptyPaneKey, rememberPaneKeyForPty } from '../ipc/pty/pane/key-state' +import { + getAgentRunAccounts, + installAgentRunAccounts +} 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 + } +} + +beforeEach(() => { + 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 + }) + 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() + }) +}) 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..c43acb118b9e --- /dev/null +++ b/src/main/startup/main-process-agent-run-accounts.ts @@ -0,0 +1,60 @@ +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 type { ClaudeProfileRouter } from '../claude-accounts/claude-profile-router' +import { getCodexPaneAccount } from '../codex/codex-pane-account-registry' +import { getPtyIdForPaneKey } from '../ipc/pty/pane/key-state' + +const ACCOUNT_LIST_SETTINGS = ['claudeManagedAccounts', 'codexManagedAccounts'] as const + +/** 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 +}): AgentRunAccounts { + const { server, claudeRouter } = args + 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 + }) + installAgentRunAccounts(producer) + 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)) + args.onSettingsChanged((updates) => { + if (ACCOUNT_LIST_SETTINGS.some((key) => key in updates)) { + producer.refreshAll() + } + }) + return producer +} 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 } +} From 3c34427d00b141ad3a3b35e395991cbae95f4ea4 Mon Sep 17 00:00:00 2001 From: Qichang Zheng Date: Wed, 14 Oct 2026 13:27:29 +0800 Subject: [PATCH 05/32] feat(agent-accounts): remember kept conversations Adds the only new store this feature needs: a small index of conversations a person kept on an account, keyed by provider and session id, so a kept agent keeps its account after its tab closes and whenever Orca starts it again. It holds explicit keeps only, has no cap and no expiry, and the file does not exist until the first keep. An unreadable file is reported as unavailable rather than empty, queued changes wait until it can be read, a corrupt file is set aside, and a file from another version is left alone. Keeps follow the agent: a new session in a kept Codex pane is kept too, and a new Claude session inherits its predecessor's keep only when it provably runs on the kept account. A kept start that Claude reports under a new id is adopted the same way. Removing an account releases what was kept on it, so those agents follow the selection again. A startup pass drops keeps whose transcript has been definitively gone for over a week. "Keep old account" now keeps the pane's current conversation as well. Co-Authored-By: Claude Opus 5.5 --- .../agent-account-keep-wiring.ts | 25 ++ .../agent-accounts/agent-account-keep.test.ts | 185 ++++++++++++ src/main/agent-accounts/agent-account-keep.ts | 109 +++++++ .../agent-run-account-adoption.ts | 76 +++++ .../agent-run-accounts-installed.ts | 20 +- .../agent-accounts/agent-run-accounts.test.ts | 80 ++++- src/main/agent-accounts/agent-run-accounts.ts | 65 ++++- .../kept-agent-conversations.test.ts | 174 +++++++++++ .../kept-agent-conversations.ts | 274 ++++++++++++++++++ src/main/codex/codex-pane-account-registry.ts | 15 + .../codex-accounts-keep-stale-panes.test.ts | 85 ++++++ src/main/ipc/codex-accounts.ts | 10 +- .../main-process-agent-run-accounts.test.ts | 79 ++++- .../main-process-agent-run-accounts.ts | 57 +++- 14 files changed, 1229 insertions(+), 25 deletions(-) create mode 100644 src/main/agent-accounts/agent-account-keep-wiring.ts create mode 100644 src/main/agent-accounts/agent-account-keep.test.ts create mode 100644 src/main/agent-accounts/agent-account-keep.ts create mode 100644 src/main/agent-accounts/agent-run-account-adoption.ts create mode 100644 src/main/agent-accounts/kept-agent-conversations.test.ts create mode 100644 src/main/agent-accounts/kept-agent-conversations.ts create mode 100644 src/main/ipc/codex-accounts-keep-stale-panes.test.ts 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..dd0e4189fccf --- /dev/null +++ b/src/main/agent-accounts/agent-account-keep-wiring.ts @@ -0,0 +1,25 @@ +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), + 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..6d913ceb7aa6 --- /dev/null +++ b/src/main/agent-accounts/agent-account-keep.test.ts @@ -0,0 +1,185 @@ +import { 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 type { CodexPaneAccountRecord } from '../codex/codex-pane-account-registry' +import { + keepStaleCodexPanesAndConversations, + setAgentKept, + type AgentKeepDeps +} from './agent-account-keep' + +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() } + const base: AgentKeepDeps = { + getRow: vi.fn(() => row()), + refresh: vi.fn((): AgentRunAccount => ({ ...CODEX_FACET, mode: 'kept' })), + 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 } +} + +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() + setAgentKept(d, { paneKey: PANE, kept: false }) + expect(d.setCodexPaneKept).toHaveBeenCalledWith('pty-1', false) + expect(d.kept.release).toHaveBeenCalledWith('codex', 's1') + expect(d.kept.keep).not.toHaveBeenCalled() + }) + + 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('reports an unreadable launch registry instead of keeping the conversation alone', () => { + const d = deps({ setCodexPaneKept: vi.fn(() => 'unavailable' as const) }) + expect(setAgentKept(d, { paneKey: PANE, kept: true })).toEqual({ + ok: false, + reason: 'unavailable' + }) + expect(d.kept.keep).not.toHaveBeenCalled() + }) + + 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..d117afd754ef --- /dev/null +++ b/src/main/agent-accounts/agent-account-keep.ts @@ -0,0 +1,109 @@ +import type { AgentRunAccount } 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 = + | { ok: true; runAccount: AgentRunAccount } + | { ok: false; reason: 'unknown-pane' | 'not-keepable' | 'no-session' | 'unavailable' } + +export type AgentKeepDeps = { + getRow: (paneKey: string) => AgentRunAccountRow | null + refresh: (paneKey: string) => AgentRunAccount | null + 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.ptyId !== undefined && args.ptyId !== ptyId) { + return { ok: false, reason: 'unknown-pane' } + } + 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' } +} + +/** "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-run-account-adoption.ts b/src/main/agent-accounts/agent-run-account-adoption.ts new file mode 100644 index 000000000000..21662c5f5796 --- /dev/null +++ b/src/main/agent-accounts/agent-run-account-adoption.ts @@ -0,0 +1,76 @@ +import type { AgentRunAccountProvider } from '../../shared/agent-run-account' +import type { CodexPaneAccountRecord } from '../codex/codex-pane-account-registry' +import type { KeptAgentConversations } from './kept-agent-conversations' + +export type KeptConversationLookup = Pick + +/** 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 { + if (!sessionId) { + return undefined + } + const entry = kept.get(provider, sessionId) + return entry && entry !== 'unavailable' ? entry.accountId : undefined +} + +/** + * A new Claude session in a pane inherits its predecessor's keep, but only when it provably + * runs on the kept account; one typed after a switch into a selection-following terminal + * runs elsewhere and stays unkept, so the UI never claims a keep the process ignores. + */ +export function adoptClaudeSession(args: { + kept: KeptConversationLookup + previousSessionId: string | null | undefined + sessionId: string | null + accountId: string | null | undefined + transcriptPath: string | undefined + now: number +}): void { + const { kept, sessionId, accountId } = args + if (!sessionId || accountId === undefined || !args.previousSessionId) { + return + } + if (sessionId === args.previousSessionId) { + return + } + if (keptConversationAccount(kept, 'claude', args.previousSessionId) !== accountId) { + return + } + if (keptConversationAccount(kept, 'claude', sessionId) === accountId) { + return + } + kept.keep('claude', sessionId, { + lane: 'host', + accountId, + keptAt: args.now, + ...(args.transcriptPath ? { transcriptPath: args.transcriptPath } : {}) + }) +} + +/** 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 + 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 + } + kept.keep('codex', sessionId, { + lane: 'host', + accountId: record.accountId, + keptAt: args.now, + ...(args.transcriptPath ? { transcriptPath: args.transcriptPath } : {}) + }) +} diff --git a/src/main/agent-accounts/agent-run-accounts-installed.ts b/src/main/agent-accounts/agent-run-accounts-installed.ts index 2934e76290fc..7540b8ce8a16 100644 --- a/src/main/agent-accounts/agent-run-accounts-installed.ts +++ b/src/main/agent-accounts/agent-run-accounts-installed.ts @@ -1,21 +1,31 @@ // 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' -let installed: AgentRunAccounts | undefined +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(producer: AgentRunAccounts | undefined): void { - installed = producer +export function installAgentRunAccounts(services: InstalledAgentRunAccounts | undefined): void { + installed = services } export function getAgentRunAccounts(): AgentRunAccounts | undefined { - return installed + 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?.notePaneLaunch(fact) + 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 index 0003648a34c7..b9792994b5a1 100644 --- a/src/main/agent-accounts/agent-run-accounts.test.ts +++ b/src/main/agent-accounts/agent-run-accounts.test.ts @@ -2,6 +2,7 @@ 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, @@ -194,6 +195,7 @@ function codexAccount(id: string): CodexManagedAccount { } type Harness = { + kept: Map producer: AgentRunAccounts rows: Map settings: AgentRunAccountSettings @@ -208,6 +210,7 @@ function harness(): Harness { 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')], @@ -243,7 +246,14 @@ function harness(): Harness { 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) + } + }, + now: () => 42 }) const emit: Harness['emit'] = ({ sessionId = 's1', ...overrides }) => { const previous = rows.get(PANE) @@ -258,7 +268,7 @@ function harness(): Harness { rows.set(PANE, row) producer.noteStatus(row) } - return { producer, rows, settings, records, covered, republished, emit } + return { kept, producer, rows, settings, records, covered, republished, emit } } function facet(h: Harness): AgentRunAccount | null | undefined { @@ -374,4 +384,70 @@ describe('AgentRunAccounts', () => { 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 }) + 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('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', () => { + 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' } }) + + expect(h.kept.get('codex:c1')).toMatchObject({ accountId: 'acc-a' }) + expect(h.kept.get('codex:c2')).toMatchObject({ accountId: 'acc-a' }) + }) }) diff --git a/src/main/agent-accounts/agent-run-accounts.ts b/src/main/agent-accounts/agent-run-accounts.ts index b56bb76f45ef..8c958b912a01 100644 --- a/src/main/agent-accounts/agent-run-accounts.ts +++ b/src/main/agent-accounts/agent-run-accounts.ts @@ -12,6 +12,12 @@ import { type ClaudePaneObservation, type ClaudeStatusLineReport } from './agent-run-account-compute' +import { + adoptClaudeSession, + adoptCodexSession, + 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. @@ -42,6 +48,8 @@ export type AgentRunAccountsDeps = { systemDefaultHome: () => string coveredBySystemDefault: (accountId: string) => boolean } | null + kept: KeptConversationLookup + now?: () => number } export type PaneLaunchFact = { @@ -52,6 +60,8 @@ export type PaneLaunchFact = { 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 & { @@ -106,8 +116,19 @@ export class AgentRunAccounts { noteStatus(row: AgentRunAccountStatusRow): void { const provider = runAccountProvider(row.payload.agentType) - if (provider === 'claude' && row.connectionId === null && !row.structuredHost) { - this.observeClaudeSession(row) + if (row.connectionId === null && !row.structuredHost) { + if (provider === 'claude') { + this.observeClaudeSession(row) + } else if (provider === 'codex') { + const ptyId = this.deps.getPtyIdForPaneKey(row.paneKey) + adoptCodexSession({ + kept: this.deps.kept, + record: ptyId ? this.deps.readCodexRecord(ptyId) : null, + sessionId: sessionIdOf(row), + transcriptPath: row.providerSession?.transcriptPath, + now: this.now() + }) + } } this.publish(row) } @@ -168,7 +189,10 @@ export class AgentRunAccounts { codexRecord: ptyId ? this.deps.readCodexRecord(ptyId) : null, claude: provider === 'claude' ? (this.claudePanes.get(row.paneKey) ?? null) : null, current: row.runAccount ?? undefined, - claudeKeptAccountId: undefined + claudeKeptAccountId: + provider === 'claude' + ? keptConversationAccount(this.deps.kept, 'claude', sessionIdOf(row)) + : undefined }) } @@ -187,24 +211,47 @@ export class AgentRunAccounts { const pointerAccountId = fact?.claudeShellAccount?.accountId ?? (shellRoute === 'account' ? persisted?.accountId : undefined) + // 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. + const accountId = fact?.fresh + ? this.claudeStartAccount(shellRoute, pointerAccountId) + : persisted?.accountId setBounded(this.claudePanes, row.paneKey, { sessionId, 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 + accountId }) + this.adoptClaude(row, fact?.fresh ? fact.resumedSessionId : undefined, accountId) return } + const accountId = this.claudeStartAccount(existing.shellRoute, existing.pointerAccountId) setBounded(this.claudePanes, row.paneKey, { sessionId, shellRoute: existing.shellRoute, pointerAccountId: existing.pointerAccountId, - accountId: this.claudeStartAccount(existing.shellRoute, existing.pointerAccountId) + accountId }) + this.adoptClaude(row, existing.sessionId, accountId) + } + + private adoptClaude( + row: AgentRunAccountStatusRow, + previousSessionId: string | null | undefined, + accountId: string | null | undefined + ): void { + adoptClaudeSession({ + kept: this.deps.kept, + previousSessionId, + sessionId: sessionIdOf(row), + accountId, + transcriptPath: row.providerSession?.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. */ 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..4631644deaf0 --- /dev/null +++ b/src/main/agent-accounts/kept-agent-conversations.test.ts @@ -0,0 +1,174 @@ +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('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('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('never reads or overwrites a version it does not understand', () => { + const newer = JSON.stringify({ version: 2, conversations: {} }) + writeFileSync(filePath, newer) + const index = open() + + expect(index.get('claude', 's1')).toBe('unavailable') + index.keep('claude', 's1', entry()) + expect(readFileSync(filePath, 'utf-8')).toBe(newer) + }) + + 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..df0d5354c09d --- /dev/null +++ b/src/main/agent-accounts/kept-agent-conversations.ts @@ -0,0 +1,274 @@ +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' + +/** A conversation a person kept on an account. Only explicit keeps are stored. */ +export type KeptConversation = { + lane: string + accountId: string | null + keptAt: number + transcriptPath?: string +} + +type KeptConversationsFile = { + version: 1 + conversations: Record +} + +type Mutation = + | { kind: 'keep'; key: string; entry: KeptConversation } + | { kind: 'release'; key: string; keptAt?: number } + | { kind: 'release-account'; provider: AgentRunAccountProvider; accountId: string } + +const RETRY_DELAYS_MS = [100, 1_000, 5_000, 30_000] as const +const PRUNE_MIN_AGE_MS = 7 * 24 * 60 * 60 * 1000 + +function conversationKey(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 + 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 } + : {}) + } +} + +/** '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 && /^(claude|codex):./.test(key)) { + entries.set(key, entry) + } + } + return entries +} + +/** + * Conversations a person kept on an account, by provider session id, so every start + * Orca makes of one (restore, wake, history resume) runs on that account. + * + * Why the read discipline: an unreadable file is not an empty one. Mapping it to empty + * would silently start kept conversations on whatever account is selected, so callers + * get 'unavailable' and mutations wait until the file can be read. + */ +export class KeptAgentConversations { + private entries: Map | null = null + private readonly queue: Mutation[] = [] + private needsWrite = false + private retryTimer: ReturnType | null = null + private retryAttempt = 0 + private reportedCorruption = false + + constructor(private readonly deps: { filePath: string; now?: () => number }) {} + + get( + provider: AgentRunAccountProvider, + sessionId: string + ): KeptConversation | null | 'unavailable' { + const entries = this.load() + return entries ? (entries.get(conversationKey(provider, sessionId)) ?? null) : 'unavailable' + } + + keep(provider: AgentRunAccountProvider, sessionId: string, entry: KeptConversation): void { + this.queue.push({ kind: 'keep', key: conversationKey(provider, sessionId), entry }) + this.flush() + } + + release(provider: AgentRunAccountProvider, sessionId: string): void { + this.queue.push({ kind: 'release', key: conversationKey(provider, sessionId) }) + 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 { + 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') { + return null + } + 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.scheduleRetry() + return + } + for (const mutation of this.queue.splice(0)) { + this.needsWrite = applyMutation(entries, mutation) || this.needsWrite + } + if (!this.needsWrite) { + this.clearRetry() + 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.clearRetry() + } catch (error) { + console.warn('[kept-conversations] Failed to persist kept conversations:', error) + this.scheduleRetry() + } + } + + 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 clearRetry(): void { + if (this.retryTimer) { + clearTimeout(this.retryTimer) + this.retryTimer = null + } + this.retryAttempt = 0 + } +} + +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 + ) { + 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 + } + 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/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index 54530b4bb3d0..6132017ba7cc 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -227,6 +227,21 @@ export function setCodexPaneKept(ptyId: string, kept: boolean): SetCodexPaneKept 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/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..c9d8e1214226 --- /dev/null +++ b/src/main/ipc/codex-accounts-keep-stale-panes.test.ts @@ -0,0 +1,85 @@ +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[] => []) + } +}) + +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', () => ({ + 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() + const service = { onPendingLoginUrlChanged: vi.fn() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration only subscribes to pending login URLs; 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('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 98d438a7b66c..c05b4d790458 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -5,6 +5,8 @@ import { broadcastCodexPendingLoginUrl } from './codex-pending-login-url-broadca import { listRecordedCodexPaneLanes } from '../codex/codex-pane-account-registry' import { 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, @@ -30,7 +32,13 @@ export function registerCodexAccountHandlers( }) ipcMain.handle('codexAccounts:keepStalePanes', (_event, args: { panes?: unknown }) => { const panes = readKeepStalePanes(args?.panes) - if (panes.length > 0) { + if (panes.length === 0) { + return + } + const deps = installedAgentKeepDeps() + if (deps) { + keepStaleCodexPanesAndConversations(deps, panes) + } else { keepStaleCodexPanes(panes.map((pane) => pane.ptyId)) } }) diff --git a/src/main/startup/main-process-agent-run-accounts.test.ts b/src/main/startup/main-process-agent-run-accounts.test.ts index dcaa99bda075..5fd9b886525b 100644 --- a/src/main/startup/main-process-agent-run-accounts.test.ts +++ b/src/main/startup/main-process-agent-run-accounts.test.ts @@ -5,14 +5,17 @@ import { join } from 'node:path' import { AgentHookServer } from '../agent-hooks/server' import type { GlobalSettings } from '../../shared/global-settings-types' import type { AgentRunAccountSettings } from '../agent-accounts/agent-run-accounts' -import type { CodexManagedAccount } from '../../shared/managed-account-types' +import type { ClaudeManagedAccount, CodexManagedAccount } from '../../shared/managed-account-types' import { _internals as registryInternals, - recordCodexPaneAccount + getCodexPaneAccount, + recordCodexPaneAccount, + setCodexPaneKept } from '../codex/codex-pane-account-registry' import { paneKeyPtyId, ptyPaneKey, rememberPaneKeyForPty } from '../ipc/pty/pane/key-state' import { getAgentRunAccounts, + getKeptAgentConversations, installAgentRunAccounts } from '../agent-accounts/agent-run-accounts-installed' import { startAgentRunAccounts } from './main-process-agent-run-accounts' @@ -33,6 +36,18 @@ function codexAccount(id: string): CodexManagedAccount { } } +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(() => { previousUserDataPath = process.env.ORCA_USER_DATA_PATH userDataPath = mkdtempSync(join(tmpdir(), 'orca-run-accounts-')) @@ -67,7 +82,8 @@ describe('startAgentRunAccounts', () => { server, getSettings: () => settings, onSettingsChanged: (listener) => settingsListeners.push(listener), - claudeRouter: null + claudeRouter: null, + keptFilePath: join(userDataPath, 'kept.json') }) expect(getAgentRunAccounts()).toBeDefined() rememberPaneKeyForPty('pty-1', PANE) @@ -97,4 +113,61 @@ describe('startAgentRunAccounts', () => { } 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() + }) }) diff --git a/src/main/startup/main-process-agent-run-accounts.ts b/src/main/startup/main-process-agent-run-accounts.ts index c43acb118b9e..3fd9ff078813 100644 --- a/src/main/startup/main-process-agent-run-accounts.ts +++ b/src/main/startup/main-process-agent-run-accounts.ts @@ -1,3 +1,4 @@ +import { join } from 'node:path' import type { GlobalSettings } from '../../shared/global-settings-types' import type { AgentHookServer } from '../agent-hooks/server' import { @@ -5,20 +6,57 @@ import { 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 { getCodexPaneAccount } from '../codex/codex-pane-account-registry' +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), @@ -40,9 +78,10 @@ export function startAgentRunAccounts(args: { systemDefaultHome: () => claudeRouter.systemDefaultHome(), coveredBySystemDefault: (accountId) => claudeRouter.coveredBySystemDefault(accountId) } - : null + : null, + kept }) - installAgentRunAccounts(producer) + installAgentRunAccounts({ producer, kept }) server.subscribeEnrichedStatus((row) => { try { producer.noteStatus(row) @@ -51,10 +90,18 @@ export function startAgentRunAccounts(args: { } }) server.subscribeClaudeStatusLinePanes((event) => producer.noteClaudeStatusLine(event)) + let known = accountIds(args.getSettings()) args.onSettingsChanged((updates) => { - if (ACCOUNT_LIST_SETTINGS.some((key) => key in updates)) { - producer.refreshAll() + 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 } From 9123b87d6bfc5fcb4c3407cc4e9820c5c7c6dddb Mon Sep 17 00:00:00 2001 From: Qichang Zheng Date: Wed, 14 Oct 2026 13:38:48 +0800 Subject: [PATCH 06/32] feat(codex): resume a kept conversation on its account A Codex resume can now be told which account a conversation is kept on. That account's home is resolved before any ranking, with no fallback: - a managed account must still be saved, on this host, and its home must pass the existing ownership check; an unreadable home is "temporarily unavailable", an untrusted one is refused, and the selection is never cleared because the kept account is not the selection; - System default resumes in the system home, and is refused while a custom CODEX_HOME is set rather than using the mirror, which would be a second copy of the login; - the rollout is moved into the kept home the same way Restart does; - a kept conversation whose transcript cannot be found is refused instead of starting fresh on the selection. Session History resumes of a kept conversation retarget to its account instead of the selection, moving the rollout there from a listed home when needed. Forks are new conversations and still follow the selection. Co-Authored-By: Claude Opus 5.5 --- .../agent-accounts/kept-account-refusals.ts | 31 ++++ src/main/agent-accounts/kept-codex-home.ts | 64 +++++++ .../runtime-home-kept-resume.test.ts | 151 ++++++++++++++++ .../runtime-home-service-home-routing.ts | 22 +++ .../runtime-home-service-state.ts | 1 + ...codex-ai-vault-session-resume-kept.test.ts | 111 ++++++++++++ .../codex/codex-ai-vault-session-resume.ts | 66 ++++++- .../pty/host-env/codex-resume-kept.test.ts | 52 ++++++ src/main/ipc/pty/host-env/codex-resume.ts | 8 +- src/main/ipc/pty/host-env/types.ts | 1 + .../startup/codex-session-resume-kept.test.ts | 165 ++++++++++++++++++ .../startup/codex-session-resume-launch.ts | 42 ++++- .../startup/main-process-runtime-service.ts | 5 +- src/main/startup/main-window-core-services.ts | 5 +- 14 files changed, 712 insertions(+), 12 deletions(-) create mode 100644 src/main/agent-accounts/kept-account-refusals.ts create mode 100644 src/main/agent-accounts/kept-codex-home.ts create mode 100644 src/main/codex-accounts/runtime-home-kept-resume.test.ts create mode 100644 src/main/codex/codex-ai-vault-session-resume-kept.test.ts create mode 100644 src/main/ipc/pty/host-env/codex-resume-kept.test.ts create mode 100644 src/main/startup/codex-session-resume-kept.test.ts 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..0a4d3d8930d3 --- /dev/null +++ b/src/main/agent-accounts/kept-account-refusals.ts @@ -0,0 +1,31 @@ +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: (): AgentSessionPreSpawnError => + refusal('Orca could not read which account this agent is kept on. Try again in a moment.'), + 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-codex-home.ts b/src/main/agent-accounts/kept-codex-home.ts new file mode 100644 index 000000000000..e6252b8582f3 --- /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 must resume in. Refuses rather than falling back: + * any other home would run the conversation on another account, and the System default + * mirror used under a custom CODEX_HOME would be a second copy of its 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 entry = getKeptAgentConversations()?.get('codex', sessionId) + if (entry === 'unavailable') { + throw keptAccountRefusals.indexUnavailable() + } + 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/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..4539bd7dfc5f --- /dev/null +++ b/src/main/codex-accounts/runtime-home-kept-resume.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +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 } })) + +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 cfb630dd29d9..1988661998f7 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[] { @@ -80,6 +82,26 @@ export abstract class CodexRuntimeHomeRouting extends CodexRuntimeHomeManagedHom return resolved.homePath } + /** + * The home of an account a person kept a conversation on. Refuses instead of falling back, + * and never clears the selection: the kept account is not the selected one. + */ + 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 3a4201060dca..5f5ef588033b 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 } /** Keeps window and serve AI Vault resumes behind the same refusing account-home gate. */ @@ -32,13 +37,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) { @@ -76,3 +94,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/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 3222ddf533cc..fa137a1132e4 100644 --- a/src/main/ipc/pty/host-env/types.ts +++ b/src/main/ipc/pty/host-env/types.ts @@ -58,6 +58,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/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/codex-session-resume-launch.ts b/src/main/startup/codex-session-resume-launch.ts index cd7a6619851b..c132a3e87da6 100644 --- a/src/main/startup/codex-session-resume-launch.ts +++ b/src/main/startup/codex-session-resume-launch.ts @@ -17,12 +17,16 @@ import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent- import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { mainProcessState as state } from './main-process-state' +import { keptAccountRefusals } from '../agent-accounts/kept-account-refusals' +import { codexAccountLabel, resolveKeptCodexHome } from '../agent-accounts/kept-codex-home' export async function prepareCodexSessionResumeForLaunch(args: { providerSession: AgentProviderSessionMetadata 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 = state.codexRuntimeHome const store = state.store @@ -31,6 +35,15 @@ export async function prepareCodexSessionResumeForLaunch(args: { } 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 @@ -38,7 +51,12 @@ export async function prepareCodexSessionResumeForLaunch(args: { // 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({ @@ -79,20 +97,32 @@ export async function prepareCodexSessionResumeForLaunch(args: { 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(store.getSettings(), args.keptAccount?.accountId ?? null) + ) + } return preparation.outcome === 'resume' ? { ...preparation, diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 6fa6ddb109ea..f7424af725b9 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 { prepareCodexAiVaultSessionResume } 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' @@ -118,7 +119,9 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { prepareCodexAiVaultSessionResume(args, { 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-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 From 59099def3572624e2113d92fcc3da3a692eec07d Mon Sep 17 00:00:00 2001 From: Qichang Zheng Date: Wed, 14 Oct 2026 13:48:03 +0800 Subject: [PATCH 07/32] feat(claude): per-account pointer for agents started kept A Claude agent started kept on an account gets its own which-account file, claude-profile-pointers/account-, instead of the selection's. It is keyed by account, never by pane, so moving or closing tabs cannot unpin an agent, and only people who have kept an agent ever get one. Orca never deletes these files: a missing file reads as System default. Before an account's folder is removed, its file is pointed at an empty placeholder folder, so a `claude` typed again in such a pane reaches Claude's own sign-in there and never another account's login; a failed removal points it back. Whenever the selection file is written, existing account files follow their account's route (its own folder, or System default while that covers it). Launches on a kept account reuse the selection's preparation: the same first-setup wait, capped refresh and refusals, with the account's file written before the agent starts. The env never falls back to the selection's file. Follow panes and the shell functions are unchanged. The credential persistence ratchet names the new file writer. Co-Authored-By: Claude Opus 5.5 --- ...ude-credential-persistence-ratchet.test.ts | 2 + .../claude-profile-account-pointers.test.ts | 239 ++++++++++++++++++ .../claude-profile-account-pointers.ts | 132 ++++++++++ .../claude-profile-account-route.ts | 151 +++++++++++ .../claude-accounts/claude-profile-router.ts | 6 +- .../runtime-auth-service-kept.test.ts | 52 ++++ .../claude-accounts/runtime-auth-service.ts | 13 +- src/shared/claude-shell-function.test.ts | 9 + 8 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 src/main/claude-accounts/claude-profile-account-pointers.test.ts create mode 100644 src/main/claude-accounts/claude-profile-account-pointers.ts create mode 100644 src/main/claude-accounts/claude-profile-account-route.ts create mode 100644 src/main/claude-accounts/runtime-auth-service-kept.test.ts 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..f99d10f345d5 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-account-pointers.test.ts @@ -0,0 +1,239 @@ +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 } 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) + const old = new Date(Date.now() - 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 an empty 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() + + const placeholder = join(f.dataRoot, 'claude-profile-pointers', 'removed-account-home') + expect(pointerDuringDelete).toBe(placeholder) + expect(readFileSync(f.pointer('a'), 'utf8')).toBe(placeholder) + expect(f.router.accountPointers.routeFor('a')).toBeNull() + }) + + 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..b3337dd44b6c --- /dev/null +++ b/src/main/claude-accounts/claude-profile-account-pointers.ts @@ -0,0 +1,132 @@ +import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import type { GlobalSettings } from '../../shared/global-settings-types' +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, for agents started kept on it; `claude` typed in such a + * pane reads it on every launch. Keyed by account, never by pane, so moving or closing a tab + * cannot unpin an agent. Orca never deletes these files: a missing file reads as System + * default, so removing an account points its file at an empty placeholder folder instead. + */ +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) { + this.writeContent(accountId, route) + } + } + } + + /** Before an account's folder goes, its file names an empty folder: a re-typed `claude` signs in there. */ + retire(accountId: string): void { + this.retired.add(accountId) + if (!this.listExisting().has(accountId) && isDefinitivelyAbsent(this.pointerPath(accountId))) { + return + } + this.writeContent(accountId, join(this.root, 'removed-account-home')) + } + + /** 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..4b87813b3e34 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-account-route.ts @@ -0,0 +1,151 @@ +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: the same shape as the selection's, but naming the account's own + * pointer. Never throws and never names the selection's pointer, even when the account's + * folder is missing, so a re-typed `claude` cannot reach another account. + */ +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) } + } + 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: the same first-setup wait, capped + * refresh and refusals, with the account's pointer written before the agent starts. + */ +export async function prepareClaudeLaunchForAccount( + router: AccountRouter, + accountId: string | null, + options: { refreshWaitMs?: number } = {} +): Promise { + const pointers = router.accountPointers + if (accountId !== null && 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 + } +} diff --git a/src/main/claude-accounts/claude-profile-router.ts b/src/main/claude-accounts/claude-profile-router.ts index 3a7d3f81f42d..94f8a9bd2a4c 100644 --- a/src/main/claude-accounts/claude-profile-router.ts +++ b/src/main/claude-accounts/claude-profile-router.ts @@ -39,6 +39,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 @@ -59,7 +60,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: { @@ -75,6 +77,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) @@ -169,6 +172,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..a4366e5bc33c --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-kept.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' + +const fakes = vi.hoisted(() => ({ + host: { publish: () => {}, removeAccount: vi.fn(async () => {}) }, + prepareClaudeLaunchForAccount: vi.fn(async () => ({ + configDir: '/a', + envPatch: {}, + provenance: 'profile:a' + })), + 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', () => ({ + prepareClaudeLaunchForAccount: fakes.prepareClaudeLaunchForAccount, + 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('prepares a launch on the kept account through the host router', async () => { + const auth = service() + await expect(auth.prepareForClaudeLaunchForAccount('a')).resolves.toMatchObject({ + provenance: 'profile:a' + }) + expect(fakes.prepareClaudeLaunchForAccount).toHaveBeenCalledWith(fakes.host, 'a') + }) + + 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..c8a4ca296be1 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -13,6 +13,10 @@ import { } from './claude-profile-installed-router' import type { ClaudeAccountSelectionTarget } from './runtime-selection' import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' +import { + prepareClaudeLaunchForAccount, + removeClaudeAccountFolder +} from './claude-profile-account-route' export type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' @@ -47,6 +51,13 @@ export class ClaudeRuntimeAuthService { return wsl ? wsl.router.prepareLaunch(wsl.distro) : this.router.prepareLaunch() } + /** A launch on the account a person kept the agent on; host only, as Keep is. */ + prepareForClaudeLaunchForAccount( + accountId: string | null + ): Promise { + return prepareClaudeLaunchForAccount(this.router, accountId) + } + async prepareForRateLimitFetch( target?: ClaudeAccountSelectionTarget ): Promise { @@ -117,7 +128,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/shared/claude-shell-function.test.ts b/src/shared/claude-shell-function.test.ts index e775c092dae6..dad3264df827 100644 --- a/src/shared/claude-shell-function.test.ts +++ b/src/shared/claude-shell-function.test.ts @@ -148,6 +148,15 @@ describe.each(SHELLS)('the claude function in %s', (shell) => { expect(statSync(fresh).mode & 0o777).toBe(0o700) }) + it('runs a removed account’s placeholder folder, never System default or another account', () => { + const f = fixture() + const placeholder = join(f.a, 'claude-profile-pointers', 'removed-account-home') + writeFileSync(f.pointer, placeholder) + const result = f.run(shell, injected(f.b)) + expect(result.stdout).toBe(`HOME=${placeholder} KEY=fake TWIN=${placeholder}\n`) + expect(existsSync(placeholder)).toBe(true) + }) + 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') From 920a7f49ac49fc7c67e6bab7ea455475ebd484f8 Mon Sep 17 00:00:00 2001 From: Qichang Zheng Date: Wed, 14 Oct 2026 14:12:18 +0800 Subject: [PATCH 08/32] feat(pty): start kept agents on their account in both spawn paths One synchronous resolver decides, for every Claude or Codex resume, whether the conversation was kept on an account. Both the window spawn and the runtime controller call it at the same point, so the two cannot drift: - nothing kept (and every relay pane, other agent or fresh start): the spawn takes exactly the calls it took before, with the same arguments; - kept: a Claude pane gets the account's own pointer and launch preparation, a Codex resume is prepared in the kept account's home even when the request was a restart to the selection, and the new pane's launch record is kept too; - a keep that cannot be honoured (unreadable index, another lane, an account that is no longer saved) refuses before anything starts. Both commit paths tell the run-account producer which account pointer a Claude pane started with and which conversation it resumed. Co-Authored-By: Claude Opus 5.5 --- .../agent-launch-account.test.ts | 103 +++++++++ .../agent-accounts/agent-launch-account.ts | 56 +++++ .../claude-profile-account-route.ts | 10 + .../runtime-auth-service-kept.test.ts | 14 -- .../claude-accounts/runtime-auth-service.ts | 12 +- src/main/ipc/pty-claude-kept-launch.test.ts | 207 ++++++++++++++++++ src/main/ipc/pty-codex-kept-resume.test.ts | 195 +++++++++++++++++ .../pty-kept-account-spawn.test-fixture.ts | 97 ++++++++ .../ipc/pty/host-env/agent-account-launch.ts | 96 ++++++++ src/main/ipc/pty/host-env/codex-home.ts | 8 +- src/main/ipc/pty/ipc/spawn-commit-persist.ts | 4 +- src/main/ipc/pty/ipc/spawn-commit.ts | 6 +- src/main/ipc/pty/ipc/spawn-env-codex.ts | 4 +- src/main/ipc/pty/ipc/spawn-preflight.ts | 25 ++- src/main/ipc/pty/ipc/spawn-state.ts | 4 + src/main/ipc/pty/runtime/spawn-commit.ts | 9 +- src/main/ipc/pty/runtime/spawn-preflight.ts | 33 ++- src/main/ipc/pty/runtime/spawn-state.ts | 4 + 18 files changed, 843 insertions(+), 44 deletions(-) create mode 100644 src/main/agent-accounts/agent-launch-account.test.ts create mode 100644 src/main/agent-accounts/agent-launch-account.ts create mode 100644 src/main/ipc/pty-claude-kept-launch.test.ts create mode 100644 src/main/ipc/pty-codex-kept-resume.test.ts create mode 100644 src/main/ipc/pty-kept-account-spawn.test-fixture.ts create mode 100644 src/main/ipc/pty/host-env/agent-account-launch.ts 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..3e44b7fb8691 --- /dev/null +++ b/src/main/agent-accounts/agent-launch-account.test.ts @@ -0,0 +1,103 @@ +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'): { + get: (provider: string, sessionId: string) => KeptConversation | null | 'unavailable' +} { + return { + 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/) + }) + + 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..0a73b67263c9 --- /dev/null +++ b/src/main/agent-accounts/agent-launch-account.ts @@ -0,0 +1,56 @@ +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 } + +/** + * One resolver for both spawn paths, so they cannot drift. Pure and synchronous: a spawn must + * not gain an await here. With no keep for the conversation the spawn follows the selection + * exactly as before; a keep that cannot be honoured refuses rather than falling back. + */ +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) { + return { kind: 'follow' } + } + const entry = input.kept?.get(provider, session.id) + if (entry === 'unavailable') { + return { kind: 'refuse', error: keptAccountRefusals.indexUnavailable() } + } + 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/claude-accounts/claude-profile-account-route.ts b/src/main/claude-accounts/claude-profile-account-route.ts index 4b87813b3e34..2c9f7050dc78 100644 --- a/src/main/claude-accounts/claude-profile-account-route.ts +++ b/src/main/claude-accounts/claude-profile-account-route.ts @@ -149,3 +149,13 @@ export async function removeClaudeAccountFolder( 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/runtime-auth-service-kept.test.ts b/src/main/claude-accounts/runtime-auth-service-kept.test.ts index a4366e5bc33c..db2bbf765225 100644 --- a/src/main/claude-accounts/runtime-auth-service-kept.test.ts +++ b/src/main/claude-accounts/runtime-auth-service-kept.test.ts @@ -3,11 +3,6 @@ import { getDefaultSettings } from '../../shared/constants' const fakes = vi.hoisted(() => ({ host: { publish: () => {}, removeAccount: vi.fn(async () => {}) }, - prepareClaudeLaunchForAccount: vi.fn(async () => ({ - configDir: '/a', - envPatch: {}, - provenance: 'profile:a' - })), removeClaudeAccountFolder: vi.fn(async () => {}) })) vi.mock('../../shared/app-environment', () => ({ @@ -23,7 +18,6 @@ vi.mock('./claude-profile-installed-router', () => ({ installClaudeWslProfileRouter: () => {} })) vi.mock('./claude-profile-account-route', () => ({ - prepareClaudeLaunchForAccount: fakes.prepareClaudeLaunchForAccount, removeClaudeAccountFolder: fakes.removeClaudeAccountFolder })) @@ -35,14 +29,6 @@ function service(): ClaudeRuntimeAuthService { } describe('ClaudeRuntimeAuthService for kept agents', () => { - it('prepares a launch on the kept account through the host router', async () => { - const auth = service() - await expect(auth.prepareForClaudeLaunchForAccount('a')).resolves.toMatchObject({ - provenance: 'profile:a' - }) - expect(fakes.prepareClaudeLaunchForAccount).toHaveBeenCalledWith(fakes.host, 'a') - }) - it("points kept panes away before a host account's folder is removed", async () => { const auth = service() await auth.removeAccountFolder('a', { runtime: 'host' }) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index c8a4ca296be1..b44b022ec88e 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -13,10 +13,7 @@ import { } from './claude-profile-installed-router' import type { ClaudeAccountSelectionTarget } from './runtime-selection' import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' -import { - prepareClaudeLaunchForAccount, - removeClaudeAccountFolder -} from './claude-profile-account-route' +import { removeClaudeAccountFolder } from './claude-profile-account-route' export type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' @@ -51,13 +48,6 @@ export class ClaudeRuntimeAuthService { return wsl ? wsl.router.prepareLaunch(wsl.distro) : this.router.prepareLaunch() } - /** A launch on the account a person kept the agent on; host only, as Keep is. */ - prepareForClaudeLaunchForAccount( - accountId: string | null - ): Promise { - return prepareClaudeLaunchForAccount(this.router, accountId) - } - async prepareForRateLimitFetch( target?: ClaudeAccountSelectionTarget ): Promise { 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..baa6b2602acb --- /dev/null +++ b/src/main/ipc/pty-claude-kept-launch.test.ts @@ -0,0 +1,207 @@ +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', + env: { KEEP: 'me', ORCA_PANE_KEY: `${TAB_ID}:${LEAF_ID}` }, + 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-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..febf14e8d554 --- /dev/null +++ b/src/main/ipc/pty/host-env/agent-account-launch.ts @@ -0,0 +1,96 @@ +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 } 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 } : {}) + } +} 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/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 5311dc2f530b..1e749e78a2f0 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -10,6 +10,7 @@ import { } from '../host-env/codex-home' import { rememberPaneKeyForPty } from '../pane/key-state' import { noteAgentPaneLaunch } from '../../../agent-accounts/agent-run-accounts-installed' +import { keptLaunchFact } from '../host-env/agent-account-launch' import { getCodexSelectionLaneKey } from '../../../../shared/codex-selection-lane' import { resolvePaneSpawnReservation } from '../pane/spawn-reservation' import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' @@ -179,7 +180,10 @@ async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args @@ -42,7 +43,8 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis providerSession: args.resumeProviderSession, target: ctx.codexSelectionTarget, launchEnv: ctx.baseEnv, - ...(args.replacesPtyId ? { useSelectedAccount: true } : {}) + ...(args.replacesPtyId ? { useSelectedAccount: true } : {}), + ...codexKeptAccountArg(ctx.accountLaunch) }) ctx.codexResumeLaunch = codexResumePreparation ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index cbc0f2e21d04..63b283c89cd7 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 { isWslShellName, resolveLocalWindowsTerminalRuntimeOptions @@ -204,10 +208,19 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis ctx.cwd, ctx.expectedWslDistro ) - args.env = withClaudeProfileTerminalEnv(args.env, initialSelectionTarget) - ctx.claudeAuth = - ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth - ? await ctx.deps.prepareClaudeAuth(initialSelectionTarget) - : null + ctx.accountLaunch = ctx.preAdoptedStablePane + ? { kind: 'follow' } + : resolveSpawnAccountLaunch({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + resumeProviderSession: args.resumeProviderSession, + target: initialSelectionTarget, + settings: ctx.deps.getSettings?.() + }) + args.env = withSpawnClaudeTerminalEnv(args.env, ctx.accountLaunch, initialSelectionTarget) + const claudeAuth = ctx.isClaudeLaunch + ? prepareSpawnClaudeAuth(ctx.accountLaunch, initialSelectionTarget, 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 840ae20edd91..709bbdba2c1a 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' @@ -75,6 +76,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 @@ -149,6 +152,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 288ca7db4d02..c804c2f47b9e 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -14,6 +14,7 @@ import { import { registerPty } from '../../../memory/pty-registry' import { rememberPaneKeyForPty } from '../pane/key-state' import { noteAgentPaneLaunch } from '../../../agent-accounts/agent-run-accounts-installed' +import { codexKeptAccountArg, keptLaunchFact } from '../host-env/agent-account-launch' import { getCodexSelectionLaneKey } from '../../../../shared/codex-selection-lane' import { pendingByPaneKey, @@ -222,7 +223,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) @@ -239,7 +241,10 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { noteAgentPaneLaunch({ paneKey, lane: getCodexSelectionLaneKey(ctx.codexSelectionTarget), - fresh: ctx.result.isReattach !== true + fresh: ctx.result.isReattach !== true, + ...(ctx.result.isReattach + ? {} + : keptLaunchFact(ctx.accountLaunch, args.resumeProviderSession)) }) } const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 7e9efdb64b5e..09cd5a66e2a0 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 { inheritOmpLaunchEnvironment } from '../host-env/omp-launch-environment' import { getAppEnvironment } from '../../../../shared/app-environment' import type { PtySpawnResult } from '../../../providers/pty-provider-contract' @@ -99,6 +104,15 @@ export async function prepareRuntimePtySpawn( ctx.cwd, ctx.expectedWslDistro ) + ctx.accountLaunch = ctx.preAdoptedStablePane + ? { kind: 'follow' } + : resolveSpawnAccountLaunch({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + resumeProviderSession: args.resumeProviderSession, + target: ctx.codexSelectionTarget, + settings: ctx.deps.getSettings?.() + }) const codexResumePreparation = ctx.preAdoptedStablePane ? null : ctx.deps.prepareCodexResumeHome({ @@ -106,7 +120,8 @@ export async function prepareRuntimePtySpawn( launchAgent: args.launchAgent, providerSession: args.resumeProviderSession, target: ctx.codexSelectionTarget, - launchEnv: args.env + launchEnv: args.env, + ...codexKeptAccountArg(ctx.accountLaunch) }) const codexResumeLaunch = codexResumePreparation ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) @@ -115,11 +130,15 @@ export async function prepareRuntimePtySpawn( // Why: the drop still applies here, but this controller's result has no field for // notifyResumeUnavailable — runtime/relay panes start fresh without the notice. ctx.launchCommand = codexResumeLaunch.command - args.env = withClaudeProfileTerminalEnv(args.env, ctx.codexSelectionTarget) - ctx.claudeAuth = - ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth - ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) - : null + 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) { diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 6b585e281a87..1217fa78c669 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' @@ -60,6 +61,8 @@ export type RuntimePtySpawnState = { requestedAgentTeamsPath: string | undefined selectedCodexHomePath: string | null codexResumeHomeSelected: boolean + /** Kept agents run on their account; everything else follows the selection. */ + accountLaunch: SpawnAccountLaunch skipCodexHomeEnv: boolean stripInheritedOrcaCodexHome: boolean spawnOptions: PtySpawnOptions @@ -175,6 +178,7 @@ export function createRuntimePtySpawnState( requestedAgentTeamsPath: undefined, selectedCodexHomePath: null, codexResumeHomeSelected: false, + accountLaunch: { kind: 'follow' }, skipCodexHomeEnv: false, stripInheritedOrcaCodexHome: false, spawnOptions: { cols: args.cols, rows: args.rows }, From 35dd235c4fada9eb65ed5ce361b31a9daed23f1b Mon Sep 17 00:00:00 2001 From: Qichang Zheng Date: Wed, 14 Oct 2026 14:32:56 +0800 Subject: [PATCH 09/32] feat(tabs): show an agent's account and keep it from the tab menu The tab hover card now names the account each Claude or Codex agent in the tab runs on, with one line per agent pane in split tabs, and what that means for its next start: kept on this account, kept from its next start (a Claude agent kept after it started; a `claude` typed by hand in that terminal still follows the selection), or following the selected account. The tab's right-click menu offers "Keep on " per agent pane. It is disabled, with the reason, until the agent reports its session or while CODEX_HOME is set. Releasing a Codex pane re-checks it against the selection, so a pane left behind gets the usual Account switched prompt. Both read the host-published facet; the renderer keeps only labels and copy. The keep goes through a new agentAccounts:setKept channel. Web clients never see the facet, so they offer nothing and need no shim. Co-Authored-By: Claude Opus 5.5 --- src/main/agent-accounts/agent-account-keep.ts | 6 +- src/main/ipc/agent-account-keep.test.ts | 66 ++++++ src/main/ipc/agent-account-keep.ts | 38 ++++ .../register-core-handlers.test.ts | 8 + .../register-core-handlers.ts | 2 + src/preload/api-types.ts | 2 + src/preload/api/agent-account-api.ts | 6 + src/preload/api/agent-account-keep-bridge.ts | 8 + src/preload/index.ts | 2 + .../src/components/tab-bar/SortableTab.tsx | 2 + .../tab-bar/SortableTabContextMenu.tsx | 2 + .../tab-bar/TabAgentAccountLines.test.tsx | 134 ++++++++++++ .../tab-bar/TabAgentAccountLines.tsx | 19 ++ .../tab-bar/TabAgentAccountMenuItems.test.tsx | 207 ++++++++++++++++++ .../tab-bar/TabAgentAccountMenuItems.tsx | 113 ++++++++++ .../src/components/tab-bar/TabHoverCard.tsx | 6 +- .../tab-bar/tab-agent-accounts.test.ts | 200 +++++++++++++++++ .../components/tab-bar/tab-agent-accounts.ts | 130 +++++++++++ .../tab-bar/use-tab-agent-accounts.ts | 10 + src/renderer/src/i18n/locales/en.json | 16 ++ src/shared/agent-run-account.ts | 7 + 21 files changed, 979 insertions(+), 5 deletions(-) create mode 100644 src/main/ipc/agent-account-keep.test.ts create mode 100644 src/main/ipc/agent-account-keep.ts create mode 100644 src/preload/api/agent-account-keep-bridge.ts create mode 100644 src/renderer/src/components/tab-bar/TabAgentAccountLines.test.tsx create mode 100644 src/renderer/src/components/tab-bar/TabAgentAccountLines.tsx create mode 100644 src/renderer/src/components/tab-bar/TabAgentAccountMenuItems.test.tsx create mode 100644 src/renderer/src/components/tab-bar/TabAgentAccountMenuItems.tsx create mode 100644 src/renderer/src/components/tab-bar/tab-agent-accounts.test.ts create mode 100644 src/renderer/src/components/tab-bar/tab-agent-accounts.ts create mode 100644 src/renderer/src/components/tab-bar/use-tab-agent-accounts.ts diff --git a/src/main/agent-accounts/agent-account-keep.ts b/src/main/agent-accounts/agent-account-keep.ts index d117afd754ef..111d6c166bf1 100644 --- a/src/main/agent-accounts/agent-account-keep.ts +++ b/src/main/agent-accounts/agent-account-keep.ts @@ -1,4 +1,4 @@ -import type { AgentRunAccount } from '../../shared/agent-run-account' +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 { @@ -8,9 +8,7 @@ import type { import { isCodexPaneKeepable } from '../codex/codex-stale-pane-accounts' import type { KeptAgentConversations } from './kept-agent-conversations' -export type SetAgentKeptResult = - | { ok: true; runAccount: AgentRunAccount } - | { ok: false; reason: 'unknown-pane' | 'not-keepable' | 'no-session' | 'unavailable' } +export type { SetAgentKeptResult } export type AgentKeepDeps = { getRow: (paneKey: string) => AgentRunAccountRow | 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/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index bec4937b740a..bead6ccc8c8a 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, @@ -102,6 +103,7 @@ const { registerAiVaultSearchHandlersMock: vi.fn(), registerOrcaProfileHandlersMock: vi.fn(), registerCodexAccountHandlersMock: vi.fn(), + registerAgentAccountKeepHandlersMock: vi.fn(), registerAgentHookHandlersMock: vi.fn(), registerClaudeAccountHandlersMock: vi.fn(), registerOpenCodeGoCredentialsHandlersMock: vi.fn(), @@ -333,6 +335,10 @@ vi.mock('../codex-accounts', () => ({ registerCodexAccountHandlers: registerCodexAccountHandlersMock })) +vi.mock('../agent-account-keep', () => ({ + registerAgentAccountKeepHandlers: registerAgentAccountKeepHandlersMock +})) + vi.mock('../agent-hooks', () => ({ registerAgentHookHandlers: registerAgentHookHandlersMock })) @@ -461,6 +467,7 @@ describe('registerCoreHandlers', () => { registerAiVaultHandlersMock.mockReset() registerOrcaProfileHandlersMock.mockReset() registerCodexAccountHandlersMock.mockReset() + registerAgentAccountKeepHandlersMock.mockReset() registerAgentHookHandlersMock.mockReset() registerClaudeAccountHandlersMock.mockReset() registerOpenCodeGoCredentialsHandlersMock.mockReset() @@ -563,6 +570,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 48234504d437..868150b0b038 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -61,6 +61,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' @@ -172,6 +173,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/preload/api-types.ts b/src/preload/api-types.ts index fc54b0a8b60b..50bc8d90369b 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1,5 +1,6 @@ import type { ClaudeAccountsApi, + AgentAccountKeepApi, CodexAccountsApi, CodexConfigSyncApi, CursorAccountsApi, @@ -105,6 +106,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 9630d9f8d76f..f1820d51a763 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 @@ -42,6 +43,11 @@ export type CodexAccountsApi = { 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 = { list: () => Promise add: (args?: { 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/index.ts b/src/preload/index.ts index 402c9e00eb1a..999868f9d0f5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -34,6 +34,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' @@ -134,6 +135,7 @@ const api = { localhostWorktreeLabels: localhostWorktreeLabelsApi, keybindings: keybindingsApi, codexAccounts: codexAccountsApi, + agentAccounts: agentAccountKeepApi, claudeAccounts: claudeAccountsApi, cli: cliApi, codexConfigSync: codexConfigSyncApi, diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 1c5a9df5e4c4..279741852a81 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -7,6 +7,7 @@ import { basename } from '@/lib/path' import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { Input } from '@/components/ui/input' import { TabHoverCard } from './TabHoverCard' +import { TabAgentAccountLines } from './TabAgentAccountLines' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { TerminalAgent } from '../../../../shared/terminal-agent' import type { TabDragItemData } from '../tab-group/useTabDragSplit' @@ -380,6 +381,7 @@ export default function SortableTab({ ? basename(shellForIcon) : translate('tabHoverCard.terminal', 'Terminal') } + details={} icon={ + {showTerminalViewSwitch ? ( <> diff --git a/src/renderer/src/components/tab-bar/TabAgentAccountLines.test.tsx b/src/renderer/src/components/tab-bar/TabAgentAccountLines.test.tsx new file mode 100644 index 000000000000..ba67c10ce85f --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabAgentAccountLines.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '../../store' +import { getDefaultSettings } from '../../../../shared/constants' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { ClaudeManagedAccount } from '../../../../shared/managed-account-types' +import { TabAgentAccountLines } from './TabAgentAccountLines' +import { TabHoverCard } from './TabHoverCard' +import { TAB_TOOLTIP_DELAY_MS, TabStripTooltipProvider } from './TabStripTooltipProvider' + +vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: () => null })) + +const TAB = 'tab-1' +const LEFT = '11111111-1111-4111-8111-111111111111' +const RIGHT = '22222222-2222-4222-8222-222222222222' + +function account(id: string): ClaudeManagedAccount { + return { + id, + email: `${id}@example.com`, + managedAuthPath: '/unused', + authMethod: 'subscription-oauth', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0 + } +} + +function row(leafId: string, accountId: string, mode: 'kept' | 'follow'): AgentStatusEntry { + return { + state: 'working', + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + paneKey: makePaneKey(TAB, leafId), + stateHistory: [], + connectionId: null, + runAccount: { provider: 'claude', lane: 'host', accountId, mode, shellRoute: 'account' } + } +} + +function seed(leafIds: string[]): void { + useAppStore.setState({ + settings: { + ...getDefaultSettings('/home/user'), + claudeManagedAccounts: [account('work'), account('personal')], + activeClaudeManagedAccountId: '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 + } + }, + agentStatusByPaneKey: Object.fromEntries( + [row(LEFT, 'work', 'kept'), row(RIGHT, 'personal', 'follow')] + .filter((entry) => leafIds.some((leafId) => entry.paneKey.endsWith(leafId))) + .map((entry) => [entry.paneKey, entry]) + ) + }) +} + +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() + useAppStore.setState(useAppStore.getInitialState(), true) +}) + +describe('the tab hover card account lines', () => { + it('names the account and what keeping means for a single agent', () => { + seed([LEFT]) + const { container } = render() + expect(container.textContent).toBe('work@example.com · Kept on this account') + }) + + it('lists one line per agent pane of a split tab, in layout order', () => { + seed([LEFT, RIGHT]) + const markup = render().container.textContent ?? '' + const first = markup.indexOf('1 · work@example.com · Kept on this account') + const second = markup.indexOf('2 · personal@example.com · Follows the selected account') + expect(first).toBeGreaterThan(-1) + expect(second).toBeGreaterThan(first) + }) + + it('renders nothing for a tab whose host published no account', () => { + expect(render().container.innerHTML).toBe('') + }) + + it('shows the lines under the program line once the card opens', () => { + vi.useFakeTimers() + seed([LEFT]) + render( + + } + > + + + + ) + 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..96e3c5e12551 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabAgentAccountMenuItems.test.tsx @@ -0,0 +1,207 @@ +// @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('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..beba81aef488 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabAgentAccountMenuItems.tsx @@ -0,0 +1,113 @@ +import { useState } from 'react' +import { toast } from 'sonner' +import { DropdownMenuCheckboxItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' +import { translate } from '@/i18n/i18n' +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 { + 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 + } + // Why: a released Codex pane may already sit behind the selection, so it gets today's prompt. + const ptyId = account.ptyId + 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/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..0a151880adbc --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-agent-accounts.ts @@ -0,0 +1,130 @@ +import { translate } from '@/i18n/i18n' +import { getCodexAccountDisplayLabel } from '@/lib/codex-account-display-label' +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> + settings: TabAgentAccountSettings | null | undefined +}): TabAgentAccount[] { + const { tabId, layout, rows, 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) + const row = rows[paneKey] + // Why local only: a remote host's accounts are not this machine's to name. + return row?.runAccount && !row.connectionId + ? [{ paneKey, ptyId: layout?.ptyIdsByLeafId?.[leafId], facet: row.runAccount }] + : [] + }) + 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..d05d548e1229 --- /dev/null +++ b/src/renderer/src/components/tab-bar/use-tab-agent-accounts.ts @@ -0,0 +1,10 @@ +import { useAppStore } from '../../store' +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 layout = useAppStore((state) => state.terminalLayoutsByTabId?.[tabId]) + const settings = useAppStore((state) => state.settings) + return describeTabAgentAccounts({ tabId, layout, rows: rows ?? {}, settings }) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4b68f7a9ea34..4e7c8f635ebf 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18893,6 +18893,22 @@ "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", + "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" } } }, diff --git a/src/shared/agent-run-account.ts b/src/shared/agent-run-account.ts index 9764810da3b4..4326b1f48d9b 100644 --- a/src/shared/agent-run-account.ts +++ b/src/shared/agent-run-account.ts @@ -78,3 +78,10 @@ export function agentRunAccountsEqual( left.keepUnavailable === right.keepUnavailable ) } + +/** 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 = + | { ok: true; runAccount: AgentRunAccount } + | { ok: false; reason: 'unknown-pane' | 'not-keepable' | 'no-session' | 'unavailable' } From 48aa22a5f3514398a6605bfed27bc5a4006fae37 Mon Sep 17 00:00:00 2001 From: Qichang Zheng Date: Wed, 14 Oct 2026 14:39:03 +0800 Subject: [PATCH 10/32] feat(status-bar): count terminal agents per account The Claude and Codex account switchers show "N terminal agents" on each of this host's account rows, counted from the host-published account on Agent Status rows whose tab is open. Chats publish no account, so they are not counted, and rows for another runtime's or a WSL lane's accounts show no count. The Claude switcher's footnote now says that agents kept on an account keep it too. Co-Authored-By: Claude Opus 5.5 --- .../status-bar/AccountAgentCount.tsx | 42 ++++ .../status-bar/ClaudeSwitcherMenu.tsx | 10 +- .../status-bar/CodexSwitcherMenu.tsx | 6 + .../count-terminal-agents-by-account.test.tsx | 108 +++++++++ .../count-terminal-agents-by-account.ts | 44 ++++ .../status-bar/switcher-agent-count.test.tsx | 222 ++++++++++++++++++ src/renderer/src/i18n/locales/en.json | 7 +- 7 files changed, 436 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/status-bar/AccountAgentCount.tsx create mode 100644 src/renderer/src/components/status-bar/count-terminal-agents-by-account.test.tsx create mode 100644 src/renderer/src/components/status-bar/count-terminal-agents-by-account.ts create mode 100644 src/renderer/src/components/status-bar/switcher-agent-count.test.tsx diff --git a/src/renderer/src/components/status-bar/AccountAgentCount.tsx b/src/renderer/src/components/status-bar/AccountAgentCount.tsx new file mode 100644 index 000000000000..eacdefb71668 --- /dev/null +++ b/src/renderer/src/components/status-bar/AccountAgentCount.tsx @@ -0,0 +1,42 @@ +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../../store' +import type { AgentRunAccountProvider } from '../../../../shared/agent-run-account' +import { countTerminalAgentsByAccount } from './count-terminal-agents-by-account' + +const EMPTY_ROWS = {} +const EMPTY_TABS = {} + +/** "N terminal agents" on an account row; this host's local panes only, hidden at zero. */ +export function AccountAgentCount({ + provider, + accountId, + enabled +}: { + provider: AgentRunAccountProvider + accountId: string | null + /** Off for another runtime's or a WSL lane's accounts, whose agents this host does not see. */ + enabled: boolean +}): React.JSX.Element | null { + const count = useAppStore((state) => + enabled + ? countTerminalAgentsByAccount({ + rows: state.agentStatusByPaneKey ?? EMPTY_ROWS, + tabsByWorktree: state.tabsByWorktree ?? EMPTY_TABS, + provider, + accountId + }) + : 0 + ) + if (count === 0) { + return null + } + return ( + + {count === 1 + ? translate('components.status.bar.AccountAgentCount.one', '1 terminal agent') + : translate('components.status.bar.AccountAgentCount.other', '{{value0}} terminal agents', { + value0: count + })} + + ) +} diff --git a/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx b/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx index 0e09af995cb9..3cc8d429ea6d 100644 --- a/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx +++ b/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx @@ -45,6 +45,7 @@ import { import { ProviderDetailsMenu } from './ProviderDetailsMenu' import { getClaudeAccountSyncKey } from './provider-account-sync-key' import { signInToClaudeAccount } from '@/lib/claude-account-sign-in' +import { AccountAgentCount } from './AccountAgentCount' // Exported so its account-switch/reset logic is preserved for row drill-in even // though the footer now opens the consolidated UsageRosterPanel first. @@ -340,6 +341,11 @@ export function ClaudeSwitcherMenu({
{target.label} + {target.active ? ( {translate('auto.components.status.bar.StatusBar.ff0fbe9311', 'Active')} @@ -376,8 +382,8 @@ export function ClaudeSwitcherMenu({
{translate( - 'accounts.claude.profileSwitching', - 'Switching applies to the next Claude you start in any tab. Running sessions keep their account.' + 'accounts.claude.profileSwitchingKept', + 'Switching applies to the next Claude you start in any tab. Running sessions and agents kept on an account keep their account.' )}
diff --git a/src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx b/src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx index 33818340d24c..6c9b9208cefd 100644 --- a/src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx +++ b/src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx @@ -27,6 +27,7 @@ import { } from './InlineProviderUsage' import { ProviderDetailsMenu } from './ProviderDetailsMenu' import { useCodexSwitcherController } from './use-codex-switcher-controller' +import { AccountAgentCount } from './AccountAgentCount' export function CodexSwitcherMenu({ codex, @@ -242,6 +243,11 @@ export function CodexSwitcherMenu({ {target.label} + {target.active ? ( {translate( diff --git a/src/renderer/src/components/status-bar/count-terminal-agents-by-account.test.tsx b/src/renderer/src/components/status-bar/count-terminal-agents-by-account.test.tsx new file mode 100644 index 000000000000..ea02c471d4e7 --- /dev/null +++ b/src/renderer/src/components/status-bar/count-terminal-agents-by-account.test.tsx @@ -0,0 +1,108 @@ +// @vitest-environment happy-dom +import { cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useAppStore } from '../../store' +import type { AgentRunAccount } from '../../../../shared/agent-run-account' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { AccountAgentCount } from './AccountAgentCount' +import { countTerminalAgentsByAccount } from './count-terminal-agents-by-account' + +const LEAF = '11111111-1111-4111-8111-111111111111' + +function row( + tabId: string, + runAccount: AgentRunAccount | undefined, + extra: Partial = {} +): [string, AgentStatusEntry] { + const paneKey = makePaneKey(tabId, LEAF) + return [ + paneKey, + { + state: 'working', + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + paneKey, + stateHistory: [], + connectionId: null, + ...(runAccount ? { runAccount } : {}), + ...extra + } + ] +} + +function tab(id: string): TerminalTab { + return { + id, + ptyId: null, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0, + worktreeId: 'wt' + } +} + +const CLAUDE_A = { provider: 'claude', lane: 'host', accountId: 'a', mode: 'follow' } as const +const ROWS = Object.fromEntries([ + row('t1', CLAUDE_A), + row('t2', { ...CLAUDE_A, mode: 'kept' }), + row('t3', { ...CLAUDE_A, accountId: 'b' }), + row('t4', { provider: 'codex', lane: 'host', accountId: 'a', mode: 'follow' }), + row('t5', { provider: 'codex', lane: 'host', accountId: null, mode: 'follow' }), + row('t6', { ...CLAUDE_A, lane: 'wsl:Ubuntu' }), + row('t7', CLAUDE_A, { connectionId: 'ssh-1' }), + row('closed', CLAUDE_A), + row('t8', undefined) +]) +const TABS = { wt: ['t1', 't2', 't3', 't4', 't5', 't6', 't7', 't8'].map(tab) } + +describe('countTerminalAgentsByAccount', () => { + it.each([ + ['claude', 'a', 2], + ['claude', 'b', 1], + ['codex', 'a', 1], + ['codex', null, 1], + ['claude', null, 0] + ] as const)('counts %s agents on %s: %i', (provider, accountId, expected) => { + expect( + countTerminalAgentsByAccount({ rows: ROWS, tabsByWorktree: TABS, provider, accountId }) + ).toBe(expected) + }) +}) + +describe('AccountAgentCount', () => { + beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ agentStatusByPaneKey: ROWS, tabsByWorktree: TABS }) + }) + + afterEach(() => { + cleanup() + useAppStore.setState(useAppStore.getInitialState(), true) + }) + + it('says how many terminal agents run on the account', () => { + expect( + render().container.textContent + ).toBe('2 terminal agents') + cleanup() + expect( + render().container.textContent + ).toBe('1 terminal agent') + }) + + it('shows nothing at zero or for accounts this host does not run', () => { + expect( + render().container.innerHTML + ).toBe('') + cleanup() + expect( + render().container + .innerHTML + ).toBe('') + }) +}) diff --git a/src/renderer/src/components/status-bar/count-terminal-agents-by-account.ts b/src/renderer/src/components/status-bar/count-terminal-agents-by-account.ts new file mode 100644 index 000000000000..bff6ee0a21f9 --- /dev/null +++ b/src/renderer/src/components/status-bar/count-terminal-agents-by-account.ts @@ -0,0 +1,44 @@ +import type { AgentRunAccountProvider } from '../../../../shared/agent-run-account' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { getTabIdToWorktreeId } from '../sidebar/worktree-agent-row-selectors' + +type CountCache = { + rows: Readonly> + tabsByWorktree: Readonly> + counts: Map +} + +let cache: CountCache | null = null + +function countKey(provider: AgentRunAccountProvider, accountId: string | null): string { + return `${provider}:${accountId ?? ''}` +} + +/** + * Terminal agents on this host per provider and account, counting only rows whose tab is open + * (the sidebar's own tab index). Chats publish no account, so they are never counted. + */ +export function countTerminalAgentsByAccount(args: { + rows: Readonly> + tabsByWorktree: Readonly> + provider: AgentRunAccountProvider + accountId: string | null +}): number { + if (!cache || cache.rows !== args.rows || cache.tabsByWorktree !== args.tabsByWorktree) { + const openTabs = getTabIdToWorktreeId(args.tabsByWorktree) + const counts = new Map() + for (const [paneKey, row] of Object.entries(args.rows)) { + const facet = row.runAccount + const tabId = parsePaneKey(paneKey)?.tabId + if (!facet || facet.lane !== 'host' || row.connectionId || !tabId || !openTabs.has(tabId)) { + continue + } + const key = countKey(facet.provider, facet.accountId) + counts.set(key, (counts.get(key) ?? 0) + 1) + } + cache = { rows: args.rows, tabsByWorktree: args.tabsByWorktree, counts } + } + return cache.counts.get(countKey(args.provider, args.accountId)) ?? 0 +} diff --git a/src/renderer/src/components/status-bar/switcher-agent-count.test.tsx b/src/renderer/src/components/status-bar/switcher-agent-count.test.tsx new file mode 100644 index 000000000000..69a0536f5026 --- /dev/null +++ b/src/renderer/src/components/status-bar/switcher-agent-count.test.tsx @@ -0,0 +1,222 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { getDefaultSettings } from '../../../../shared/constants' +import type { + ClaudeManagedAccount, + CodexManagedAccount +} from '../../../../shared/managed-account-types' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' +import type { AgentRunAccount } from '../../../../shared/agent-run-account' +import { makePaneKey } from '../../../../shared/stable-pane-id' + +const LEAF = '11111111-1111-4111-8111-111111111111' +let storeSettings: GlobalSettings +let activeRuntimeEnvironmentId: string | null = null + +function claudeAccount(id: string): ClaudeManagedAccount { + return { + id, + email: `${id}@example.com`, + authMethod: 'subscription-oauth', + managedAuthPath: '/unused', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } +} + +function codexAccount(id: string): CodexManagedAccount { + return { + id, + email: `${id}@example.com`, + managedHomePath: '/unused', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } +} + +function row(tabId: string, runAccount: AgentRunAccount) { + const paneKey = makePaneKey(tabId, LEAF) + return [ + paneKey, + { + state: 'working', + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + paneKey, + stateHistory: [], + connectionId: null, + runAccount + } + ] as const +} + +const ROWS = Object.fromEntries([ + row('t1', { provider: 'claude', lane: 'host', accountId: 'work', mode: 'kept' }), + row('t2', { provider: 'claude', lane: 'host', accountId: 'work', mode: 'follow' }), + row('t3', { provider: 'codex', lane: 'host', accountId: 'work', mode: 'follow' }) +]) +const TABS = { + wt: ['t1', 't2', 't3'].map((id) => ({ id, title: id, customTitle: null, color: null })) +} + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), info: vi.fn(), success: vi.fn() } })) +vi.mock('@/lib/codex-session-restart', () => ({ + markLiveCodexSessionsForRestart: vi.fn(async () => {}), + resolveCodexRestartPromptAccountLabel: () => 'System default' +})) +vi.mock('@/runtime/runtime-provider-accounts-client', () => ({ + fetchProviderAccountsSnapshot: vi.fn(async () => ({ + claude: { + accounts: [claudeAccount('work'), claudeAccount('home')], + activeAccountId: 'home', + activeAccountIdsByRuntime: { host: 'home', wsl: {} } + }, + codex: { + accounts: [codexAccount('work'), codexAccount('home')], + activeAccountId: 'home', + activeAccountIdsByRuntime: { host: 'home', wsl: {} } + }, + failedProviders: [] + })), + selectClaudeProviderAccount: vi.fn(), + selectCodexProviderAccount: vi.fn() +})) +vi.mock('@/lib/windows-terminal-capabilities', () => ({ + useWindowsTerminalCapabilities: () => ({ wslDistros: [], isLoading: false }), + getWindowsTerminalCapabilityOwnerKey: () => 'local' +})) +vi.mock('./tooltip', async (importOriginal) => ({ + ...(await importOriginal>()), + ProviderIcon: () => null, + ProviderPanel: () => null +})) +// Why: Radix portals its menu behind pointer-capture the DOM shim cannot drive. +vi.mock('@/components/ui/dropdown-menu', () => { + const passthrough = + (role?: string) => + ({ + children, + onSelect + }: { + children?: React.ReactNode + onSelect?: (event: { preventDefault: () => void }) => void + }): React.JSX.Element => + React.createElement( + 'div', + { role, onClick: onSelect ? () => onSelect({ preventDefault: () => {} }) : undefined }, + children + ) + return { + DropdownMenu: passthrough(), + DropdownMenuCheckboxItem: passthrough(), + DropdownMenuContent: passthrough(), + DropdownMenuItem: passthrough('menuitem'), + DropdownMenuLabel: passthrough(), + DropdownMenuSeparator: passthrough(), + DropdownMenuSub: passthrough(), + DropdownMenuSubContent: passthrough(), + DropdownMenuSubTrigger: passthrough(), + DropdownMenuTrigger: passthrough() + } +}) +vi.mock('../../store', () => { + const state = (): Record => ({ + settings: { ...storeSettings, activeRuntimeEnvironmentId }, + runtimeEnvironments: [], + usagePercentageDisplay: 'used', + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn(), + fetchSettings: vi.fn(async () => {}), + updateSettings: vi.fn(), + recordFeatureInteraction: vi.fn(), + refreshClaudeRateLimitsForTarget: vi.fn(), + refreshCodexRateLimitsForTarget: vi.fn(), + fetchInactiveClaudeAccountUsage: vi.fn(), + fetchInactiveCodexAccountUsage: vi.fn(async () => {}), + consumeCodexRateLimitResetCredit: vi.fn(), + rateLimits: { + inactiveClaudeAccounts: [], + inactiveCodexAccounts: [], + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: { runtime: 'host', wslDistro: null } + }, + agentStatusByPaneKey: ROWS, + tabsByWorktree: TABS + }) + const useAppStore = (selector: (value: Record) => unknown): unknown => + selector(state()) + useAppStore.getState = state + return { useAppStore } +}) + +function provider(name: 'claude' | 'codex'): ProviderRateLimits { + return { provider: name, session: null, weekly: null, status: 'ok', error: null, updatedAt: 1 } +} + +async function openClaude(): Promise { + const { ClaudeSwitcherMenu } = await import('./ClaudeSwitcherMenu') + render( + React.createElement(ClaudeSwitcherMenu, { + claude: provider('claude'), + compact: false, + iconOnly: false + }) + ) + fireEvent.click(await screen.findByText('home@example.com')) + await waitFor(() => expect(screen.getByText('work@example.com')).toBeTruthy()) +} + +async function openCodex(): Promise { + const { CodexSwitcherMenu } = await import('./StatusBar') + render( + React.createElement(CodexSwitcherMenu, { + codex: provider('codex'), + compact: false, + iconOnly: false + }) + ) + fireEvent.click(await screen.findByText('home@example.com')) + await waitFor(() => expect(screen.getByText('work@example.com')).toBeTruthy()) +} + +describe('terminal agent counts in the account switchers', () => { + beforeEach(() => { + activeRuntimeEnvironmentId = null + storeSettings = { + ...getDefaultSettings('/home/user'), + claudeManagedAccounts: [claudeAccount('work'), claudeAccount('home')], + activeClaudeManagedAccountId: 'home', + codexManagedAccounts: [codexAccount('work'), codexAccount('home')], + activeCodexManagedAccountId: 'home' + } + }) + + afterEach(() => cleanup()) + + it('counts Claude terminal agents per account and says kept agents stay put', async () => { + await openClaude() + expect(screen.getByText('2 terminal agents')).toBeTruthy() + expect( + screen.getByText( + 'Switching applies to the next Claude you start in any tab. Running sessions and agents kept on an account keep their account.' + ) + ).toBeTruthy() + }) + + it('counts Codex terminal agents per account', async () => { + await openCodex() + expect(screen.getByText('1 terminal agent')).toBeTruthy() + }) + + it("never counts this host's agents against a remote server's accounts", async () => { + activeRuntimeEnvironmentId = 'remote-1' + await openClaude() + expect(screen.queryByText('2 terminal agents')).toBeNull() + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4e7c8f635ebf..0d6d7a5fa2ae 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19197,6 +19197,10 @@ "bar": { "workspaceSpace": { "otherTopLevelItems": "Other top-level items ({{value0}})" + }, + "AccountAgentCount": { + "one": "1 terminal agent", + "other": "{{value0}} terminal agents" } } }, @@ -19798,7 +19802,8 @@ "moreSignInOptions": "More sign-in options", "copySignInLink": "Copy sign-in link instead", "signInLinkCopied": "Sign-in link copied. Open it in a private window to sign in with a different account.", - "signInLinkCopyFailed": "Could not copy the link." + "signInLinkCopyFailed": "Could not copy the link.", + "profileSwitchingKept": "Switching applies to the next Claude you start in any tab. Running sessions and agents kept on an account keep their account." }, "managedData": { "description": "Add accounts by running this command in a terminal on the Orca host. Selection applies to new explicit agent launches on that host; direct SSH relay and Windows-hosted WSL launches use their own credentials.", From 40aa087274376a03f18d789f98d04ebf74cbe187 Mon Sep 17 00:00:00 2001 From: Qichang Zheng Date: Wed, 14 Oct 2026 14:43:56 +0800 Subject: [PATCH 11/32] feat(accounts): say what removing an account does to kept agents Removing a Claude or Codex account releases the conversations kept on it, so those agents follow the selected account from their next start. The removal dialog now says so, with the number of this host's open terminal agents kept on that account, whenever there is at least one. Co-Authored-By: Claude Opus 5.5 --- .../settings/KeptAgentsRemovalNote.tsx | 48 ++++++++ .../accounts-pane-removal-dialogs.tsx | 17 +++ ...accounts-pane-removal-kept-agents.test.tsx | 116 ++++++++++++++++++ .../count-terminal-agents-by-account.ts | 16 ++- src/renderer/src/i18n/locales/en.json | 4 + 5 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/settings/KeptAgentsRemovalNote.tsx create mode 100644 src/renderer/src/components/settings/accounts-pane-removal-kept-agents.test.tsx diff --git a/src/renderer/src/components/settings/KeptAgentsRemovalNote.tsx b/src/renderer/src/components/settings/KeptAgentsRemovalNote.tsx new file mode 100644 index 000000000000..cfdbeef12638 --- /dev/null +++ b/src/renderer/src/components/settings/KeptAgentsRemovalNote.tsx @@ -0,0 +1,48 @@ +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../../store' +import type { AgentRunAccountProvider } from '../../../../shared/agent-run-account' +import { countTerminalAgentsByAccount } from '../status-bar/count-terminal-agents-by-account' + +const EMPTY_ROWS = {} +const EMPTY_TABS = {} + +/** 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 count = useAppStore((state) => + enabled + ? countTerminalAgentsByAccount({ + rows: state.agentStatusByPaneKey ?? EMPTY_ROWS, + tabsByWorktree: state.tabsByWorktree ?? EMPTY_TABS, + provider, + accountId, + keptOnly: true + }) + : 0 + ) + if (count === 0) { + return null + } + 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 } + )} +

+ ) +} 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} + ) +})) +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/store/agent-identity-row-run-accounts.ts b/src/renderer/src/store/agent-identity-row-run-accounts.ts new file mode 100644 index 000000000000..f39565a65dc0 --- /dev/null +++ b/src/renderer/src/store/agent-identity-row-run-accounts.ts @@ -0,0 +1,88 @@ +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 +} From 2ee54a5893db2174ed6c1dd959ee6ca26623e16e Mon Sep 17 00:00:00 2001 From: Qichang Zheng Date: Sat, 17 Oct 2026 20:09:27 +0800 Subject: [PATCH 30/32] fix(agent-accounts): a Claude account switch never restarts a kept agent A Claude agent kept on the outgoing account was counted as still running there, listed in the switch dialog and queued by Restart and Always restart, only to resume on its kept account again. The switch now leaves kept agents out of the count and the list, asks nothing when only kept agents remain, and a queued restart of a pane that became kept is dropped instead of relaunching it. Agents that follow the selection restart exactly as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/site/content/docs/agents/claude-code.mdx | 2 +- .../claude-detached-pane-restart.test.ts | 29 ++++- .../lib/agent-account-restart-startup.test.ts | 28 +++++ .../src/lib/agent-account-restart-startup.ts | 10 ++ .../lib/claude-session-account-move.test.ts | 116 +++++++++++++++++- .../src/lib/claude-session-account-move.ts | 6 +- .../store/agent-identity-row-run-accounts.ts | 8 ++ 7 files changed, 194 insertions(+), 5 deletions(-) diff --git a/docs/site/content/docs/agents/claude-code.mdx b/docs/site/content/docs/agents/claude-code.mdx index 509af73b9490..2ad7f814f6d9 100644 --- a/docs/site/content/docs/agents/claude-code.mdx +++ b/docs/site/content/docs/agents/claude-code.mdx @@ -22,7 +22,7 @@ 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. 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. +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 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..9af68424fd39 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,33 @@ 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('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/lib/agent-account-restart-startup.test.ts b/src/renderer/src/lib/agent-account-restart-startup.test.ts index ee12af48f395..8aa4734e0425 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,34 @@ 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('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 8ce58169c426..dd440675866a 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 { isPaneAgentKept } from '@/store/agent-identity-row-run-accounts' import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' import { resolveSshHostReportedPlatform, @@ -131,6 +132,15 @@ export function buildPaneAccountRestartStartup( agent: AccountRestartAgent, args: AccountRestartPane ): AgentAccountRestartStartup | null { + // Why: a kept Claude resumes on its kept account, so a switch restart would only relaunch it there. + if (agent === 'claude' && isClaudePaneKept(args)) { + return null + } const resume = resolveAgentAccountRestartResumeStartup(agent, args) return resume ?? (agent === 'codex' ? CODEX_ACCOUNT_RESTART_STARTUP : null) } + +function isClaudePaneKept(args: AccountRestartPane): boolean { + const paneKey = makePaneKey(args.tabId, args.leafId) + return isPaneAgentKept(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..0dd616310a34 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' } const tabId = (n: number): string => `tab-${n}` @@ -107,7 +118,17 @@ 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 + } + } + : {}) } } useAppStore.setState({ @@ -209,6 +230,16 @@ 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('skips a pane where Claude has exited to the shell', async () => { seedPanes([{ n: 1, agentType: 'claude' }]) vi.mocked(inspectRuntimeTerminalProcess).mockResolvedValue({ @@ -295,3 +326,86 @@ 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' } + ]) + 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' } + ]) + 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' } + ]) + 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 index f39565a65dc0..593ac4047e41 100644 --- a/src/renderer/src/store/agent-identity-row-run-accounts.ts +++ b/src/renderer/src/store/agent-identity-row-run-accounts.ts @@ -86,3 +86,11 @@ export function resolvePaneRunAccount( } return row && !row.connectionId ? row.runAccount : undefined } + +/** Whether the host says this local pane's agent is kept on its account. */ +export function isPaneAgentKept(paneKey: string, row: AgentStatusEntry | undefined): boolean { + return ( + resolvePaneRunAccount(paneKey, row, useIdentityRowRunAccounts.getState().byPaneKey)?.mode === + 'kept' + ) +} From 91c602ec59b7959885be8217ef30d62bd7710a93 Mon Sep 17 00:00:00 2001 From: Qichang Zheng Date: Sat, 17 Oct 2026 20:33:47 +0800 Subject: [PATCH 31/32] fix(agent-accounts): keep a kept Claude agent's facet across a restart, and out of the switch restart An idle Claude terminal that outlived an Orca restart reattaches without posting anything, so the producer had no observation of it and withdrew the kept facet it had just been hydrated with. The switch dialog then counted, listed and restarted that kept agent again. A pane this runtime has not seen report now keeps the account an earlier runtime recorded, reading only the keep afresh; a fresh spawn still starts from nothing. A conversation kept on one account but running on another (resumed by hand in a selection-following terminal) showed as following, yet its restart resumes on the kept account. The facet now says so, and the switch leaves such an agent out like a kept one. Agents that follow the selection restart exactly as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agent-run-account-compute.ts | 23 ++- .../agent-accounts/agent-run-accounts.test.ts | 170 +++++++++++++++++- src/main/agent-accounts/agent-run-accounts.ts | 5 +- .../main-process-agent-run-accounts.test.ts | 68 ++++++- .../claude-detached-pane-restart.test.ts | 18 ++ .../lib/agent-account-restart-startup.test.ts | 21 +++ .../src/lib/agent-account-restart-startup.ts | 11 +- .../lib/claude-session-account-move.test.ts | 23 ++- .../store/agent-identity-row-run-accounts.ts | 14 +- src/shared/agent-run-account.test.ts | 12 +- src/shared/agent-run-account.ts | 13 +- 11 files changed, 348 insertions(+), 30 deletions(-) diff --git a/src/main/agent-accounts/agent-run-account-compute.ts b/src/main/agent-accounts/agent-run-account-compute.ts index 3d2c3f769640..367c85f4dd46 100644 --- a/src/main/agent-accounts/agent-run-account-compute.ts +++ b/src/main/agent-accounts/agent-run-account-compute.ts @@ -68,8 +68,18 @@ function computeCodex(input: AgentRunAccountInput): AgentRunAccount | null { } } +/** 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 { - const observed = input.claude + // 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 } @@ -80,16 +90,17 @@ function computeClaude(input: AgentRunAccountInput): AgentRunAccount | null { if (accountId === undefined) { return null } - const kept = - input.providerSessionId !== null && - input.claudeKeptAccountId !== undefined && - input.claudeKeptAccountId === accountId + 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' } : {}) + ...(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.test.ts b/src/main/agent-accounts/agent-run-accounts.test.ts index 2c84788f7d35..09080e893212 100644 --- a/src/main/agent-accounts/agent-run-accounts.test.ts +++ b/src/main/agent-accounts/agent-run-accounts.test.ts @@ -36,6 +36,14 @@ const ACCOUNT_HOME_RECORD: CodexPaneAccountRecord = { 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]>([ [ @@ -153,8 +161,75 @@ describe('computeAgentRunAccount', () => { lane: 'host', accountId: 'acc-b', mode: 'follow', - shellRoute: 'selected' + 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) @@ -538,4 +613,97 @@ describe('AgentRunAccounts', () => { 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 index 286d1cac9bdc..260e6008db5e 100644 --- a/src/main/agent-accounts/agent-run-accounts.ts +++ b/src/main/agent-accounts/agent-run-accounts.ts @@ -192,6 +192,9 @@ export class AgentRunAccounts { 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, @@ -204,7 +207,7 @@ export class AgentRunAccounts { 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: row.runAccount ?? undefined, + current: startedHere ? undefined : (row.runAccount ?? undefined), claudeKeptAccountId: provider === 'claude' ? keptConversationAccount(this.deps.kept, 'claude', sessionIdOf(row)) diff --git a/src/main/startup/main-process-agent-run-accounts.test.ts b/src/main/startup/main-process-agent-run-accounts.test.ts index 5fd9b886525b..9dfc1e2ffbda 100644 --- a/src/main/startup/main-process-agent-run-accounts.test.ts +++ b/src/main/startup/main-process-agent-run-accounts.test.ts @@ -2,7 +2,8 @@ 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 } from '../agent-hooks/server' +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' @@ -16,7 +17,8 @@ import { paneKeyPtyId, ptyPaneKey, rememberPaneKeyForPty } from '../ipc/pty/pane import { getAgentRunAccounts, getKeptAgentConversations, - installAgentRunAccounts + installAgentRunAccounts, + noteAgentPaneLaunch } from '../agent-accounts/agent-run-accounts-installed' import { startAgentRunAccounts } from './main-process-agent-run-accounts' @@ -49,6 +51,7 @@ function claudeAccount(id: string): ClaudeManagedAccount { } beforeEach(() => { + hookServerInternals.resetCachesForTests() previousUserDataPath = process.env.ORCA_USER_DATA_PATH userDataPath = mkdtempSync(join(tmpdir(), 'orca-run-accounts-')) process.env.ORCA_USER_DATA_PATH = userDataPath @@ -170,4 +173,65 @@ describe('startAgentRunAccounts', () => { } 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/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 9af68424fd39..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 @@ -156,6 +156,24 @@ describe('detached Claude account-move restart', () => { }) }) + 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' } 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 8aa4734e0425..cae03aec21e2 100644 --- a/src/renderer/src/lib/agent-account-restart-startup.test.ts +++ b/src/renderer/src/lib/agent-account-restart-startup.test.ts @@ -266,6 +266,27 @@ describe('buildPaneAccountRestartStartup for Claude', () => { ) }) + 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', diff --git a/src/renderer/src/lib/agent-account-restart-startup.ts b/src/renderer/src/lib/agent-account-restart-startup.ts index dd440675866a..fc1285daf5a8 100644 --- a/src/renderer/src/lib/agent-account-restart-startup.ts +++ b/src/renderer/src/lib/agent-account-restart-startup.ts @@ -1,5 +1,5 @@ import { useAppStore } from '@/store' -import { isPaneAgentKept } from '@/store/agent-identity-row-run-accounts' +import { isPaneAgentResumePinned } from '@/store/agent-identity-row-run-accounts' import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' import { resolveSshHostReportedPlatform, @@ -132,15 +132,16 @@ export function buildPaneAccountRestartStartup( agent: AccountRestartAgent, args: AccountRestartPane ): AgentAccountRestartStartup | null { - // Why: a kept Claude resumes on its kept account, so a switch restart would only relaunch it there. - if (agent === 'claude' && isClaudePaneKept(args)) { + // 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 isClaudePaneKept(args: AccountRestartPane): boolean { +function isClaudePanePinned(args: AccountRestartPane): boolean { const paneKey = makePaneKey(args.tabId, args.leafId) - return isPaneAgentKept(paneKey, useAppStore.getState().agentStatusByPaneKey[paneKey]) + 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 0dd616310a34..4ace746bd6c7 100644 --- a/src/renderer/src/lib/claude-session-account-move.test.ts +++ b/src/renderer/src/lib/claude-session-account-move.test.ts @@ -74,7 +74,7 @@ type SeededPane = { /** 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' + runAccount?: 'kept' | 'follow' | 'kept-elsewhere' } const tabId = (n: number): string => `tab-${n}` @@ -125,7 +125,9 @@ function seedPanes(panes: SeededPane[]): void { provider: 'claude' as const, lane: 'host', accountId: 'acct-a', - mode: pane.runAccount + 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 } : {}) } } : {}) @@ -240,6 +242,15 @@ describe('findMovableClaudeSessions', () => { 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({ @@ -364,7 +375,7 @@ describe('a Claude account switch with agents kept on the outgoing account', () async (policy) => { seedPanes([ { n: 1, agentType: 'claude', runAccount: 'kept' }, - { n: 2, agentType: 'claude', runAccount: 'kept' } + { n: 2, agentType: 'claude', runAccount: 'kept-elsewhere' } ]) setPolicy(policy) @@ -379,7 +390,8 @@ describe('a Claude account switch with agents kept on the outgoing account', () seedPanes([ { n: 1, agentType: 'claude', runAccount: 'kept' }, { n: 2, agentType: 'claude', runAccount: 'follow' }, - { n: 3, agentType: 'claude' } + { n: 3, agentType: 'claude' }, + { n: 4, agentType: 'claude', runAccount: 'kept-elsewhere' } ]) setPolicy('ask') @@ -399,7 +411,8 @@ describe('a Claude account switch with agents kept on the outgoing account', () 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: 2, agentType: 'claude', runAccount: 'follow' }, + { n: 3, agentType: 'claude', runAccount: 'kept-elsewhere' } ]) setPolicy('restart') diff --git a/src/renderer/src/store/agent-identity-row-run-accounts.ts b/src/renderer/src/store/agent-identity-row-run-accounts.ts index 593ac4047e41..dd124b3173e9 100644 --- a/src/renderer/src/store/agent-identity-row-run-accounts.ts +++ b/src/renderer/src/store/agent-identity-row-run-accounts.ts @@ -87,10 +87,12 @@ export function resolvePaneRunAccount( return row && !row.connectionId ? row.runAccount : undefined } -/** Whether the host says this local pane's agent is kept on its account. */ -export function isPaneAgentKept(paneKey: string, row: AgentStatusEntry | undefined): boolean { - return ( - resolvePaneRunAccount(paneKey, row, useIdentityRowRunAccounts.getState().byPaneKey)?.mode === - 'kept' - ) +/** 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/shared/agent-run-account.test.ts b/src/shared/agent-run-account.test.ts index cff22e4e2d58..61f406232331 100644 --- a/src/shared/agent-run-account.test.ts +++ b/src/shared/agent-run-account.test.ts @@ -21,6 +21,10 @@ describe('readAgentRunAccount', () => { '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', { @@ -46,7 +50,12 @@ describe('readAgentRunAccount', () => { ['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' }] + ['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() }) @@ -62,6 +71,7 @@ describe('agentRunAccountsEqual', () => { 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) }) diff --git a/src/shared/agent-run-account.ts b/src/shared/agent-run-account.ts index cf7cf795c0a7..7d23d6292687 100644 --- a/src/shared/agent-run-account.ts +++ b/src/shared/agent-run-account.ts @@ -16,6 +16,9 @@ export type AgentRunAccount = { 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}$/ @@ -39,6 +42,7 @@ export function readAgentRunAccount(value: unknown): AgentRunAccount | 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) || @@ -48,7 +52,8 @@ export function readAgentRunAccount(value: unknown): AgentRunAccount | undefined (shellRoute !== undefined && provider !== 'claude') || (keepUnavailable !== undefined && keepUnavailable !== 'no-session' && - keepUnavailable !== 'codex-home-not-keepable') + keepUnavailable !== 'codex-home-not-keepable') || + (keptElsewhere !== undefined && (keptElsewhere !== true || provider !== 'claude')) ) { return undefined } @@ -58,7 +63,8 @@ export function readAgentRunAccount(value: unknown): AgentRunAccount | undefined accountId, mode, ...(shellRoute !== undefined ? { shellRoute } : {}), - ...(keepUnavailable !== undefined ? { keepUnavailable } : {}) + ...(keepUnavailable !== undefined ? { keepUnavailable } : {}), + ...(keptElsewhere === true ? { keptElsewhere } : {}) } } @@ -75,7 +81,8 @@ export function agentRunAccountsEqual( left.accountId === right.accountId && left.mode === right.mode && left.shellRoute === right.shellRoute && - left.keepUnavailable === right.keepUnavailable + left.keepUnavailable === right.keepUnavailable && + left.keptElsewhere === right.keptElsewhere ) } From c6fb48817c3f97f98358bbccdad8237c14fb1a12 Mon Sep 17 00:00:00 2001 From: Qichang Zheng Date: Sun, 18 Oct 2026 23:16:29 +0800 Subject: [PATCH 32/32] test(claude-accounts): compare a pointer's unchanged mtime in whole seconds A millisecond mtime set through utimes can read back as 691.999 instead of 692, so the "writes only when the route changed" check failed now and then. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../claude-accounts/claude-profile-account-pointers.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/claude-accounts/claude-profile-account-pointers.test.ts b/src/main/claude-accounts/claude-profile-account-pointers.test.ts index b0e48f32049b..27d3432f0959 100644 --- a/src/main/claude-accounts/claude-profile-account-pointers.test.ts +++ b/src/main/claude-accounts/claude-profile-account-pointers.test.ts @@ -114,7 +114,8 @@ describe('per-account Claude pointers', () => { pointers.update('a') expect(readFileSync(f.pointer('a'), 'utf8')).toBe(f.home('a')) expect(statSync(f.pointer('a')).mode & 0o777).toBe(0o600) - const old = new Date(Date.now() - 60_000) + // 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')