diff --git a/docs/site/content/docs/agents/claude-code.mdx b/docs/site/content/docs/agents/claude-code.mdx index a0d733abd4a6..59300baa8603 100644 --- a/docs/site/content/docs/agents/claude-code.mdx +++ b/docs/site/content/docs/agents/claude-code.mdx @@ -22,6 +22,8 @@ Orca reads your local `~/.claude` usage state and shows current usage plus rate- Orca supports multiple Claude accounts and can swap between them in one click, matching the Codex flow. Switching accounts works even with live Claude sessions running — Orca holds the in-progress switch behind a guard so you don't trigger overlapping auth refreshes. See [Hot-swap Codex accounts](/docs/agents/codex-hot-swap) — the Claude flow is identical in shape. +A Claude session that is already running keeps the account it started on. After a switch, the Claude account menu shows how many sessions are still on the old account; **Move Sessions** reopens each one's conversation on the selected account (`claude --resume`) once it finishes its turn and holds no unsent text. **Settings → Accounts → Claude → Move idle sessions to the selected account** does that on its own for sessions idle for a minute in a tab you are not looking at. It is off by default. + ## Subagents and teams Background subagents and Agent Teams teammates can show as expandable child rows under the lead agent in the worktree agent list and [Agent Dashboard](/docs/model/agents-sessions#agent-dashboard). Selecting a child focuses the lead terminal. diff --git a/src/main/claude-accounts/claude-account-selection.ts b/src/main/claude-accounts/claude-account-selection.ts index b26010420781..62c9c120c9f8 100644 --- a/src/main/claude-accounts/claude-account-selection.ts +++ b/src/main/claude-accounts/claude-account-selection.ts @@ -80,6 +80,9 @@ export class ClaudeAccountSelection { activeAccountId: selection.host, activeAccountIdsByRuntime: selection, ...(systemDefault ? { systemDefaultEmail: systemDefault.email } : {}), + ...(systemDefault?.organizationUuid + ? { systemDefaultOrganizationUuid: systemDefault.organizationUuid } + : {}), ...(systemDefault && this.runtimeAuth.router.copiedLoginIntoSystemDefault() ? { systemDefaultMayBeCopied: true } : {}) diff --git a/src/main/claude-accounts/claude-account-service.test.ts b/src/main/claude-accounts/claude-account-service.test.ts index b442d90f90b4..0f8beca4817e 100644 --- a/src/main/claude-accounts/claude-account-service.test.ts +++ b/src/main/claude-accounts/claude-account-service.test.ts @@ -144,10 +144,13 @@ describe('ClaudeAccountService', () => { mkdirSync(join(f.root, 'personal')) writeFileSync( join(f.root, 'personal', '.claude.json'), - JSON.stringify({ oauthAccount: { emailAddress: 'me@example.test' } }) + JSON.stringify({ + oauthAccount: { emailAddress: 'me@example.test', organizationUuid: 'org-personal' } + }) ) expect(f.service.listAccounts()).toMatchObject({ - systemDefaultEmail: 'me@example.test' + systemDefaultEmail: 'me@example.test', + systemDefaultOrganizationUuid: 'org-personal' }) expect(f.service.listAccounts()).not.toHaveProperty('userClaudeConfigDir') expect(f.service.listAccounts().systemDefaultMayBeCopied).toBeUndefined() diff --git a/src/renderer/src/app-shell/AppBackgroundServices.tsx b/src/renderer/src/app-shell/AppBackgroundServices.tsx index 6aac734b06ce..a29b884c8684 100644 --- a/src/renderer/src/app-shell/AppBackgroundServices.tsx +++ b/src/renderer/src/app-shell/AppBackgroundServices.tsx @@ -1,6 +1,7 @@ import { Suspense } from 'react' import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { AgentHibernationGate } from '../components/AgentHibernationGate' +import { ClaudeSessionMoveGate } from '../components/ClaudeSessionMoveGate' import { AiVaultTabTitleSyncGate } from '../components/AiVaultTabTitleSyncGate' import RetainedAgentsSyncGate from '../components/dashboard/RetainedAgentsSyncGate' import { WorkspacePortScanner } from '../components/ports/WorkspacePortScanner' @@ -37,6 +38,7 @@ export function AppBackgroundServices(): React.JSX.Element { ) : null} + {/* Why here and not in the chat pane: a backgrounded chat has no mounted pane, and that is exactly the completion the user needs the dot for. */} diff --git a/src/renderer/src/components/ClaudeSessionMoveGate.tsx b/src/renderer/src/components/ClaudeSessionMoveGate.tsx new file mode 100644 index 000000000000..08da9176b230 --- /dev/null +++ b/src/renderer/src/components/ClaudeSessionMoveGate.tsx @@ -0,0 +1,8 @@ +import { useEffect } from 'react' +import { installClaudeSessionMoveCoordinator } from '@/lib/claude-session-move-coordinator' + +/** Moves Claude sessions to the selected account once they can go, as the move setting says. */ +export function ClaudeSessionMoveGate(): null { + useEffect(() => installClaudeSessionMoveCoordinator(), []) + return null +} diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 5ee7c7f116eb..6eb5f03c584c 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -15,8 +15,8 @@ import { getProviderAccountsOwnerKey, watchProviderAccounts } from '@/runtime/runtime-provider-accounts-client' +import { getAccountsClaudeSearchEntries } from './accounts-claude-search' import { - getAccountsClaudeSearchEntries, getAccountsCodexSearchEntries, getAccountsGeminiSearchEntries, getAccountsCursorSearchEntries, diff --git a/src/renderer/src/components/settings/accounts-claude-search.ts b/src/renderer/src/components/settings/accounts-claude-search.ts new file mode 100644 index 000000000000..1625250af309 --- /dev/null +++ b/src/renderer/src/components/settings/accounts-claude-search.ts @@ -0,0 +1,40 @@ +import { translate } from '@/i18n/i18n' +import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { translateSearchKeyword } from './settings-search-keywords' + +export const getAccountsClaudeSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('auto.components.settings.accounts.search.75682e1b62', 'Claude Accounts'), + description: translate( + 'auto.components.settings.accounts.search.dd75a73991', + 'Optional account switching for Claude while preserving shared chat context.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.accounts.search.e14049e1a8', 'claude'), + ...translateSearchKeyword('auto.components.settings.accounts.search.06662af91e', 'account'), + ...translateSearchKeyword('auto.components.settings.accounts.search.5b3f18ef4a', 'switch'), + ...translateSearchKeyword('auto.components.settings.accounts.search.8b06729e0f', 'active'), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.86edc96bc9', + 'status bar' + ), + ...translateSearchKeyword('auto.components.settings.accounts.search.c759741d77', 'quota'), + ...translateSearchKeyword('auto.components.settings.accounts.search.f2d666a886', 'optional') + ] + }, + { + title: translate( + 'accounts.claude.moveIdleSessions', + 'Move idle sessions to the selected account' + ), + description: translate( + 'accounts.claude.moveIdleSessionsDescription', + 'After you switch accounts, a Claude session still on the old one reopens its conversation on the selected account once it has sat idle for a minute in a tab you are not looking at.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.accounts.search.e14049e1a8', 'claude'), + ...translateSearchKeyword('accounts.claude.keyword.move', 'move'), + ...translateSearchKeyword('accounts.claude.keyword.resume', 'resume') + ] + } +]) diff --git a/src/renderer/src/components/settings/accounts-pane-claude-section.tsx b/src/renderer/src/components/settings/accounts-pane-claude-section.tsx index 69ae293d79d2..5dc8e59c4cb9 100644 --- a/src/renderer/src/components/settings/accounts-pane-claude-section.tsx +++ b/src/renderer/src/components/settings/accounts-pane-claude-section.tsx @@ -7,6 +7,7 @@ import { ButtonGroup } from '../ui/button-group' import { Label } from '../ui/label' import { ClaudeIcon } from '../status-bar/icons' import { SearchableSetting } from './SearchableSetting' +import { renderClaudeSessionMoveSetting } from './accounts-pane-claude-session-moves' import { getProviderAccountRuntime, providerAccountIsActiveInView @@ -364,6 +365,7 @@ export function renderClaudeAccountsSection(model: AccountsPaneSectionModel): Re )} + {renderClaudeSessionMoveSetting(model)} ) } diff --git a/src/renderer/src/components/settings/accounts-pane-claude-session-moves.test.tsx b/src/renderer/src/components/settings/accounts-pane-claude-session-moves.test.tsx new file mode 100644 index 000000000000..881636f461c5 --- /dev/null +++ b/src/renderer/src/components/settings/accounts-pane-claude-session-moves.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import type { ClaudeRateLimitAccountsState } from '../../../../shared/managed-account-types' +import { renderClaudeSessionMoveSetting } from './accounts-pane-claude-session-moves' + +const NAME = 'Move idle sessions to the selected account' + +function accounts(count: number): ClaudeRateLimitAccountsState { + return { + accounts: Array.from({ length: count }, (_, index) => ({ + id: `acct-${index}`, + email: `${index}@example.test`, + authMethod: 'subscription-oauth' as const, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + })), + activeAccountId: null + } +} + +function renderSetting(overrides: { remote?: boolean; count?: number; on?: boolean } = {}) { + const updateSettings = vi.fn() + const element = renderClaudeSessionMoveSetting({ + claudeAccounts: accounts(overrides.count ?? 1), + isRemoteAccountScope: overrides.remote ?? false, + settings: { ...getDefaultSettings('/tmp'), claudeMoveIdleSessions: overrides.on === true }, + updateSettings + }) + render(<>{element}) + return updateSettings +} + +describe('Claude session move setting', () => { + afterEach(cleanup) + + it('turns automatic moves on', () => { + const updateSettings = renderSetting() + + fireEvent.click(screen.getByRole('switch', { name: NAME })) + + expect(updateSettings).toHaveBeenCalledWith({ claudeMoveIdleSessions: true }) + }) + + it('shows the saved choice', () => { + renderSetting({ on: true }) + + expect(screen.getByRole('switch', { name: NAME }).getAttribute('aria-checked')).toBe('true') + }) + + it("is offered only for this machine's accounts, with an account to switch to", () => { + renderSetting({ remote: true }) + expect(screen.queryByRole('switch', { name: NAME })).toBeNull() + cleanup() + renderSetting({ count: 0 }) + expect(screen.queryByRole('switch', { name: NAME })).toBeNull() + }) +}) diff --git a/src/renderer/src/components/settings/accounts-pane-claude-session-moves.tsx b/src/renderer/src/components/settings/accounts-pane-claude-session-moves.tsx new file mode 100644 index 000000000000..724493248c58 --- /dev/null +++ b/src/renderer/src/components/settings/accounts-pane-claude-session-moves.tsx @@ -0,0 +1,43 @@ +import { translate } from '@/i18n/i18n' +import { Label } from '../ui/label' +import { Switch } from '../ui/switch' +import { SearchableSetting } from './SearchableSetting' +import type { AccountsPaneSectionModel } from './accounts-pane-types' + +export function renderClaudeSessionMoveSetting( + model: Pick< + AccountsPaneSectionModel, + 'claudeAccounts' | 'isRemoteAccountScope' | 'settings' | 'updateSettings' + > +): React.JSX.Element | null { + // Why this machine only: a move restarts panes running here, so it follows this machine's setting. + if (model.isRemoteAccountScope || model.claudeAccounts.accounts.length === 0) { + return null + } + const title = translate( + 'accounts.claude.moveIdleSessions', + 'Move idle sessions to the selected account' + ) + const description = translate( + 'accounts.claude.moveIdleSessionsDescription', + 'After you switch accounts, a Claude session still on the old one reopens its conversation on the selected account once it has sat idle for a minute in a tab you are not looking at.' + ) + return ( + +
+ +

{description}

+
+ model.updateSettings({ claudeMoveIdleSessions: enabled })} + /> +
+ ) +} diff --git a/src/renderer/src/components/settings/accounts-search.test.ts b/src/renderer/src/components/settings/accounts-search.test.ts index 4931510b1b92..ca38dc4d8954 100644 --- a/src/renderer/src/components/settings/accounts-search.test.ts +++ b/src/renderer/src/components/settings/accounts-search.test.ts @@ -15,6 +15,7 @@ vi.mock('./settings-search-keywords', () => ({ translateSearchKeyword: (_key: string, fallback: string) => [fallback] })) +import { getAccountsClaudeSearchEntries } from './accounts-claude-search' import { getAccountsMiniMaxSearchEntries, getAccountsOpencodeSearchEntries, @@ -80,3 +81,13 @@ describe('getAccountsOpencodeSearchEntries', () => { ) }) }) + +describe('getAccountsClaudeSearchEntries', () => { + it('finds the setting that moves idle sessions by its own words', () => { + expect( + getAccountsClaudeSearchEntries().some((entry) => + entry.title.toLowerCase().includes('move idle sessions') + ) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 948f258323f3..c31998ead112 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -2,6 +2,7 @@ import type { SettingsSearchEntry } from './settings-search' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { getAccountsClaudeSearchEntries } from './accounts-claude-search' export const getAccountsLocationSearchEntries = createLocalizedCatalog(() => [ { @@ -22,28 +23,6 @@ export const getAccountsLocationSearchEntries = createLocalizedCatalog(() => [ } ]) -export const getAccountsClaudeSearchEntries = createLocalizedCatalog(() => [ - { - title: translate('auto.components.settings.accounts.search.75682e1b62', 'Claude Accounts'), - description: translate( - 'auto.components.settings.accounts.search.dd75a73991', - 'Optional account switching for Claude while preserving shared chat context.' - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.accounts.search.e14049e1a8', 'claude'), - ...translateSearchKeyword('auto.components.settings.accounts.search.06662af91e', 'account'), - ...translateSearchKeyword('auto.components.settings.accounts.search.5b3f18ef4a', 'switch'), - ...translateSearchKeyword('auto.components.settings.accounts.search.8b06729e0f', 'active'), - ...translateSearchKeyword( - 'auto.components.settings.accounts.search.86edc96bc9', - 'status bar' - ), - ...translateSearchKeyword('auto.components.settings.accounts.search.c759741d77', 'quota'), - ...translateSearchKeyword('auto.components.settings.accounts.search.f2d666a886', 'optional') - ] - } -]) - export const getAccountsCodexSearchEntries = createLocalizedCatalog(() => [ { title: translate('auto.components.settings.accounts.search.17c5d244eb', 'Codex Accounts'), diff --git a/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx b/src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx index 0e09af995cb9..9991e754881a 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 { ClaudeSessionMovePrompt } from './claude-session-move-prompt' // Exported so its account-switch/reset logic is preserved for row drill-in even // though the footer now opens the consolidated UsageRosterPanel first. @@ -382,6 +383,7 @@ export function ClaudeSwitcherMenu({ ) : null} + {hasActiveRuntimeEnvironment ? null : } { diff --git a/src/renderer/src/components/status-bar/claude-session-move-prompt.test.tsx b/src/renderer/src/components/status-bar/claude-session-move-prompt.test.tsx new file mode 100644 index 000000000000..1c7d57d8d8dd --- /dev/null +++ b/src/renderer/src/components/status-bar/claude-session-move-prompt.test.tsx @@ -0,0 +1,44 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ClaudeSessionMovePrompt } from './claude-session-move-prompt' + +const onOldAccount = vi.hoisted(() => ({ count: 0 })) +const moveClaudeSessionsToSelectedAccount = vi.hoisted(() => vi.fn(async () => {})) +vi.mock('@/lib/claude-session-move-coordinator', () => ({ + refreshClaudeMoveAccounts: vi.fn(async () => {}), + listClaudeSessionsOnOtherLogin: () => + Array.from({ length: onOldAccount.count }, (_, index) => ({ paneKey: `pane-${index}` })), + moveClaudeSessionsToSelectedAccount +})) + +describe('ClaudeSessionMovePrompt', () => { + beforeEach(() => { + moveClaudeSessionsToSelectedAccount.mockClear() + }) + afterEach(cleanup) + + it('stays out of the way while every session runs on the selected account', async () => { + onOldAccount.count = 0 + const { container } = render() + + expect(container.textContent).toBe('') + }) + + it('says how many sessions are still on the old account and moves them', async () => { + onOldAccount.count = 2 + render() + + expect(await screen.findByText('2 Claude sessions are still on the old account.')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Move 2 Sessions' })) + + expect(moveClaudeSessionsToSelectedAccount).toHaveBeenCalledOnce() + }) + + it('words a single session as one', async () => { + onOldAccount.count = 1 + render() + + expect(await screen.findByRole('button', { name: 'Move Session' })).toBeTruthy() + }) +}) diff --git a/src/renderer/src/components/status-bar/claude-session-move-prompt.tsx b/src/renderer/src/components/status-bar/claude-session-move-prompt.tsx new file mode 100644 index 000000000000..51085c7e466c --- /dev/null +++ b/src/renderer/src/components/status-bar/claude-session-move-prompt.tsx @@ -0,0 +1,71 @@ +import React, { useEffect, useState } from 'react' +import { DropdownMenuSeparator } from '@/components/ui/dropdown-menu' +import { translate } from '@/i18n/i18n' +import { + listClaudeSessionsOnOtherLogin, + moveClaudeSessionsToSelectedAccount, + refreshClaudeMoveAccounts +} from '@/lib/claude-session-move-coordinator' +import { claudeMoveAccountsKey } from '@/lib/claude-session-move' +import { useAppStore } from '../../store' + +/** Offers to move Claude sessions still on the old account, the way Codex offers a restart. */ +export function ClaudeSessionMovePrompt(): React.JSX.Element | null { + const accountsKey = useAppStore((s) => claudeMoveAccountsKey(s.settings)) + const [accountsRead, setAccountsRead] = useState(0) + useEffect(() => { + let current = true + void refreshClaudeMoveAccounts().then(() => { + if (current) { + setAccountsRead((count) => count + 1) + } + }) + return () => { + current = false + } + }, [accountsKey]) + const sessionCount = useAppStore((s) => + accountsRead > 0 ? listClaudeSessionsOnOtherLogin(s).length : 0 + ) + + if (sessionCount === 0) { + return null + } + + return ( + <> + +
+
+ {sessionCount === 1 + ? translate( + 'accounts.claude.moveSessionsOnOldAccountOne', + '1 Claude session is still on the old account.' + ) + : translate( + 'accounts.claude.moveSessionsOnOldAccountMany', + '{{value0}} Claude sessions are still on the old account.', + { value0: sessionCount } + )} + + {translate( + 'accounts.claude.moveSessionsHint', + 'Each one reopens its conversation on the selected account once it finishes its turn and holds no unsent text.' + )} + +
+ +
+ + ) +} diff --git a/src/renderer/src/components/terminal-pane/pane-account-move-restarts.ts b/src/renderer/src/components/terminal-pane/pane-account-move-restarts.ts new file mode 100644 index 000000000000..06332cdfb53e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pane-account-move-restarts.ts @@ -0,0 +1,32 @@ +import type { PtyConnectionDeps } from './pty-connection-types' + +type PaneRestartStartup = NonNullable +type PaneRestart = (startup: PaneRestartStartup) => void + +// Why a registry of mounted panes, not a store queue: only a mounted pane restarts in place, and an +// account move must not join the Codex restart queue, whose detached path relaunches a pane as Codex. +const restartByPtyId = new Map() + +export function registerPaneAccountMoveRestart(ptyId: string, restart: PaneRestart): () => void { + restartByPtyId.set(ptyId, restart) + return () => { + if (restartByPtyId.get(ptyId) === restart) { + restartByPtyId.delete(ptyId) + } + } +} + +/** Whether a mounted pane owns this PTY, so a move can restart it in place. */ +export function hasPaneAccountMoveRestart(ptyId: string): boolean { + return restartByPtyId.has(ptyId) +} + +/** Restarts the mounted pane that owns this PTY; false when no mounted pane does. */ +export function restartPaneForAccountMove(ptyId: string, startup: PaneRestartStartup): boolean { + const restart = restartByPtyId.get(ptyId) + if (!restart) { + return false + } + restart(startup) + return true +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-process-exit-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-process-exit-actions.ts index c825c25d20a1..9ffb7d4d2f84 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-process-exit-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-process-exit-actions.ts @@ -5,6 +5,7 @@ import { makePaneKey } from '../../../../shared/stable-pane-id' import { connectPanePty } from './pty-connection' import { bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides' import { releasePaneTransportForRestart } from './pane-restart-transport-handoff' +import { registerPaneAccountMoveRestart } from './pane-account-move-restarts' import { clearPaneTerminalError } from './terminal-error-accumulation' import { resolveTerminalProcessExitRestartStartup } from './terminal-process-exit-restart' import type { PaneProcessExit, PtyConnectionDeps } from './pty-connection-types' @@ -242,6 +243,26 @@ export function useTerminalPaneProcessExitActions(controller: TerminalPaneCloseC pendingCodexPaneRestartIds ]) + // Why: a Claude account move restarts only mounted panes, so a pane in an unmounted tab moves + // once it mounts. + useEffect(() => { + const manager = managerRef.current + if (!manager) { + return + } + const unregister = manager.getPanes().flatMap((pane) => { + const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() + return ptyId + ? [ + registerPaneAccountMoveRestart(ptyId, (startup) => + handleRestartCodexPane(pane.id, startup) + ) + ] + : [] + }) + return () => unregister.forEach((dispose) => dispose()) + }, [handleRestartCodexPane, managerRef, panePtyLayoutBindings, paneTransportsRef]) + return { handleRestartExitedPane, handleCloseExitedPane } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8cf5446f99cb..01ae72a591c5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19905,6 +19905,13 @@ "added": "Account added. Select it to use it for the next Claude you start.", "nextLaunch": "{{value0}} → {{value1}}. The next Claude you start uses it. Running sessions keep their account.", "profileSwitching": "Switching applies to the next Claude you start in any tab. Running sessions keep their account.", + "moveSessionsOnOldAccountOne": "1 Claude session is still on the old account.", + "moveSessionsOnOldAccountMany": "{{value0}} Claude sessions are still on the old account.", + "moveSessionsHint": "Each one reopens its conversation on the selected account once it finishes its turn and holds no unsent text.", + "moveSessionsButtonOne": "Move Session", + "moveSessionsButtonMany": "Move {{value0}} Sessions", + "moveIdleSessions": "Move idle sessions to the selected account", + "moveIdleSessionsDescription": "After you switch accounts, a Claude session still on the old one reopens its conversation on the selected account once it has sat idle for a minute in a tab you are not looking at.", "systemDefaultNamed": "System default: {{value0}}", "systemDefaultIsSaved": "System default is signed in as {{value0}}, which is also a saved account. An earlier Orca version may have copied that login there. If it is not your own login, select System default and run claude /login.", "systemDefaultIsSavedDismiss": "Dismiss", @@ -19916,7 +19923,11 @@ "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.", + "keyword": { + "move": "move", + "resume": "resume" + } }, "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.", diff --git a/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts b/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts index 0971402e8726..34fb927c122f 100644 --- a/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts +++ b/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts @@ -5,7 +5,10 @@ import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/termin import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { lastInputBlocksHibernation } from './agent-hibernation-input-guard' import { isLiveResumeAnchorForCompletedAgent } from './live-resume-anchor-record' -import { agentTurnEndedUncleanly } from '../../../shared/agent-main-agent-verdict' +import { + agentMainAgentVerdict, + agentTurnEndedUncleanly +} from '../../../shared/agent-main-agent-verdict' import type { AgentHibernationPlannerSnapshot } from './agent-hibernation-planner-snapshot' export type EligiblePane = { @@ -53,6 +56,13 @@ const hasUnsettledOrUnknownDispatch = ({ orchestration }: AgentStatusEntry): boo ? !['completed', 'failed', 'circuit_broken'].includes(orchestration.dispatchStatus ?? '') : false +// Why only these two: a failed turn (a usage limit ends one) or a Stop leaves the agent idle at its +// prompt, so a restart loses nothing; the other unclean endings may still have work in flight. +const turnEndedOnErrorOrStop = (entry: AgentStatusEntry): boolean => { + const verdict = agentMainAgentVerdict(entry) + return verdict === 'failure' || verdict === 'cancellation' +} + export function getEligiblePane(args: { entry: AgentStatusEntry tab: TerminalTab @@ -66,6 +76,8 @@ export function getEligiblePane(args: { mobileLockedPtyIds: Set now: number idleMs: number + /** A restart that brings the turn back, such as an account move, may take a failed or stopped one. */ + allowEndedTurn?: boolean }): EligiblePane | null { const { entry, @@ -90,7 +102,7 @@ export function getEligiblePane(args: { ) if ( entry.state !== 'done' || - agentTurnEndedUncleanly(entry) || + (agentTurnEndedUncleanly(entry) && !(args.allowEndedTurn && turnEndedOnErrorOrStop(entry))) || Boolean(entry.subagents?.length) || hasUnsettledOrUnknownDispatch(entry) || (sleepingRecord && !hasOnlyLiveResumeAnchor) diff --git a/src/renderer/src/lib/claude-session-move-coordinator.test.ts b/src/renderer/src/lib/claude-session-move-coordinator.test.ts new file mode 100644 index 000000000000..f49c771648ef --- /dev/null +++ b/src/renderer/src/lib/claude-session-move-coordinator.test.ts @@ -0,0 +1,625 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import type { ClaudeRateLimitAccountsState } from '../../../shared/managed-account-types' +import { getDefaultSettings } from '../../../shared/constants' +import { useAppStore } from '@/store' +import { + installClaudeSessionMoveCoordinator, + listClaudeSessionsOnOtherLogin, + moveClaudeSessionsToSelectedAccount, + resetClaudeSessionMoveForTests +} from './claude-session-move-coordinator' + +const restartPaneForAccountMove = vi.hoisted(() => vi.fn((): boolean => true)) +const hasPaneAccountMoveRestart = vi.hoisted(() => vi.fn(() => true)) +type Inspection = { + foregroundProcess: string | null + hasChildProcesses: boolean + childProcessEvidence?: 'children' | 'no-children' | 'unverifiable' + foregroundProcessEvidence?: { + verdict: 'unverifiable' + reason: string + authorityGeneration: string + observationEpoch: number + capturedAgeMs: number + ptyId: string + ptyIncarnationId: string + } +} +const foreground = vi.hoisted(() => ({ process: 'claude' })) +const inspect = vi.hoisted(() => + vi.fn(async (): Promise => ({ + foregroundProcess: foreground.process, + hasChildProcesses: true + })) +) +const confirm = vi.hoisted(() => vi.fn(async (): Promise => 'claude')) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + inspectRuntimeTerminalProcess: inspect, + confirmRuntimeTerminalForegroundProcess: confirm +})) +type MoveStartup = { command: string; startupCommandDelivery: 'shell-ready'; launchAgent: 'claude' } +const buildClaudeSessionMoveStartup = vi.hoisted(() => + vi.fn((): MoveStartup | null => ({ + command: 'claude --resume session-1', + startupCommandDelivery: 'shell-ready', + launchAgent: 'claude' + })) +) +vi.mock('@/components/terminal-pane/pane-account-move-restarts', () => ({ + restartPaneForAccountMove, + hasPaneAccountMoveRestart +})) +vi.mock('./codex-account-restart-startup', () => ({ buildClaudeSessionMoveStartup })) +vi.mock('@/lib/local-preflight-context', () => ({ + getLocalProjectExecutionRuntimeContext: () => undefined +})) + +const NOW = 50_000_000 +const LEAF = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = `tab-1:${LEAF}` +const ON_A = '/Users/me/orca/claude-profiles/acct-a/home/projects/-repo/session-1.jsonl' +const ON_B = '/Users/me/orca/claude-profiles/acct-b/home/projects/-repo/session-1.jsonl' + +let roster: ClaudeRateLimitAccountsState +const list = vi.fn(async (): Promise => roster) + +function selected(accountId: string): ClaudeRateLimitAccountsState { + return { + accounts: [ + { + id: 'acct-a', + email: 'a@example.test', + authMethod: 'subscription-oauth', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'acct-b', + email: 'b@example.test', + authMethod: 'subscription-oauth', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeAccountId: accountId, + activeAccountIdsByRuntime: { host: accountId, wsl: {} } + } +} + +function seed( + row: Partial, + settings: { claudeMoveIdleSessions?: boolean } = {} +): void { + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), ...settings }, + tabsByWorktree: { + 'wt-1': [ + { + id: 'tab-1', + ptyId: null, + worktreeId: 'wt-1', + title: 'Claude', + launchAgent: 'claude', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: 'pty-1' } + } + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + sleepingAgentSessionsByPaneKey: {}, + lastTerminalInputAtByPaneKey: {}, + agentStatusByPaneKey: { + [PANE_KEY]: { + state: 'done', + prompt: 'ship it', + updatedAt: NOW - 120_000, + stateStartedAt: NOW - 120_000, + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + agentType: 'claude', + providerSession: { key: 'session_id', id: 'session-1', transcriptPath: ON_A }, + stateHistory: [], + ...row + } + } + }) +} + +let ptyCount = 1 + +// The pane comes back on a new PTY, the way a restart that connected rebinds its leaf. +function rebindPane(): boolean { + ptyCount += 1 + const ptyId = `pty-${ptyCount}` + useAppStore.setState((s) => ({ + terminalLayoutsByTabId: { + 'tab-1': { ...s.terminalLayoutsByTabId['tab-1']!, ptyIdsByLeafId: { [LEAF]: ptyId } } + }, + ptyIdsByTabId: { 'tab-1': [ptyId] } + })) + return true +} + +function setRow(row: Partial): void { + const current = useAppStore.getState().agentStatusByPaneKey[PANE_KEY] + if (!current) { + throw new Error('No Claude row to update') + } + useAppStore.setState({ + agentStatusByPaneKey: { + [PANE_KEY]: { ...current, stateStartedAt: Date.now() - 120_000, ...row } + } + }) +} + +function switchTo(accountId: string): void { + roster = selected(accountId) + useAppStore.setState((s) => ({ + settings: s.settings ? { ...s.settings, activeClaudeManagedAccountId: accountId } : s.settings + })) +} + +async function settle(): Promise { + await vi.advanceTimersByTimeAsync(1_000) +} + +describe('Claude session moves', () => { + let uninstall: () => void = () => {} + + beforeEach(() => { + vi.useFakeTimers({ now: NOW }) + vi.stubGlobal('window', { ...globalThis.window, api: { claudeAccounts: { list } } }) + roster = selected('acct-b') + restartPaneForAccountMove.mockClear() + restartPaneForAccountMove.mockImplementation(rebindPane) + ptyCount = 1 + hasPaneAccountMoveRestart.mockClear() + inspect.mockClear() + confirm.mockClear() + list.mockClear() + foreground.process = 'claude' + buildClaudeSessionMoveStartup.mockClear() + resetClaudeSessionMoveForTests() + }) + + afterEach(() => { + uninstall() + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('reopens a finished session on the selected account when you ask', async () => { + seed({}) + + await moveClaudeSessionsToSelectedAccount() + + expect(restartPaneForAccountMove).toHaveBeenCalledWith( + 'pty-1', + expect.objectContaining({ command: 'claude --resume session-1' }) + ) + // The menu stops counting it, though its row still names the old account until it reports. + expect(listClaudeSessionsOnOtherLogin(useAppStore.getState())).toEqual([]) + }) + + it('moves a busy session once its turn ends', async () => { + seed({ state: 'working' }) + uninstall = installClaudeSessionMoveCoordinator() + await moveClaudeSessionsToSelectedAccount() + expect(restartPaneForAccountMove).not.toHaveBeenCalled() + + setRow({ state: 'done' }) + await settle() + + expect(restartPaneForAccountMove).toHaveBeenCalledOnce() + }) + + it('drops the move when the account switches back before the turn ends', async () => { + seed({ state: 'working' }) + uninstall = installClaudeSessionMoveCoordinator() + await moveClaudeSessionsToSelectedAccount() + + switchTo('acct-a') + await settle() + // Why switch again: the earlier answer was about a switch that was undone, so it must not + // carry over to a later one. + switchTo('acct-b') + await settle() + setRow({ state: 'done' }) + await settle() + + expect(restartPaneForAccountMove).not.toHaveBeenCalled() + }) + + it('moves idle background sessions on its own only when the setting is on', async () => { + roster = selected('acct-a') + seed({}) + uninstall = installClaudeSessionMoveCoordinator() + switchTo('acct-b') + await settle() + expect(restartPaneForAccountMove).not.toHaveBeenCalled() + + useAppStore.setState((s) => ({ + settings: s.settings ? { ...s.settings, claudeMoveIdleSessions: true } : s.settings + })) + await settle() + + expect(restartPaneForAccountMove).toHaveBeenCalledOnce() + }) + + it('moves a session once, even if it still reads as the old account', async () => { + seed({}, { claudeMoveIdleSessions: true }) + uninstall = installClaudeSessionMoveCoordinator() + await moveClaudeSessionsToSelectedAccount() + setRow({}) + await settle() + + expect(restartPaneForAccountMove).toHaveBeenCalledOnce() + }) + + it('offers the session again when its restart never connected and the pane kept the old PTY', async () => { + restartPaneForAccountMove.mockImplementationOnce(() => true) + seed({}) + + await moveClaudeSessionsToSelectedAccount() + + expect(listClaudeSessionsOnOtherLogin(useAppStore.getState())).toHaveLength(1) + }) + + it('leaves a session it cannot resume', async () => { + buildClaudeSessionMoveStartup.mockReturnValueOnce(null) + seed({}) + + await moveClaudeSessionsToSelectedAccount() + + expect(restartPaneForAccountMove).not.toHaveBeenCalled() + expect(listClaudeSessionsOnOtherLogin(useAppStore.getState())).toEqual([]) + }) + + it('leaves a pane where Claude is no longer what runs, such as tmux or the shell', async () => { + foreground.process = 'tmux' + seed({}) + + await moveClaudeSessionsToSelectedAccount() + + expect(restartPaneForAccountMove).not.toHaveBeenCalled() + }) + + it('moves again once the session has reported from the account it moved to', async () => { + seed({}, { claudeMoveIdleSessions: true }) + uninstall = installClaudeSessionMoveCoordinator() + await moveClaudeSessionsToSelectedAccount() + setRow({ providerSession: { key: 'session_id', id: 'session-1', transcriptPath: ON_B } }) + switchTo('acct-a') + await settle() + setRow({ providerSession: { key: 'session_id', id: 'session-1', transcriptPath: ON_A } }) + switchTo('acct-b') + await settle() + + expect(restartPaneForAccountMove).toHaveBeenCalledTimes(3) + }) + + it('does not act on an old account list when reading the new one fails, and tries again', async () => { + roster = selected('acct-a') + seed({}, { claudeMoveIdleSessions: true }) + uninstall = installClaudeSessionMoveCoordinator() + await moveClaudeSessionsToSelectedAccount() + list + .mockRejectedValueOnce(new Error('main is busy')) + .mockRejectedValueOnce(new Error('still busy')) + switchTo('acct-b') + await settle() + expect(restartPaneForAccountMove).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(16_000) + + expect(restartPaneForAccountMove).toHaveBeenCalledOnce() + }) + + it('keeps the account list from the latest switch when replies arrive out of order', async () => { + roster = selected('acct-a') + seed({ providerSession: { key: 'session_id', id: 'session-1', transcriptPath: ON_A } }) + uninstall = installClaudeSessionMoveCoordinator() + let answerSlowly: (state: ClaudeRateLimitAccountsState) => void = () => {} + list.mockImplementationOnce( + () => new Promise((resolve) => (answerSlowly = resolve)) + ) + switchTo('acct-b') + switchTo('acct-a') + await settle() + answerSlowly(selected('acct-b')) + await settle() + + expect(listClaudeSessionsOnOtherLogin(useAppStore.getState())).toEqual([]) + }) + + it('retries a session you asked to move when its pane was not ready', async () => { + restartPaneForAccountMove.mockReturnValueOnce(false) + seed({}) + uninstall = installClaudeSessionMoveCoordinator() + await moveClaudeSessionsToSelectedAccount() + + await vi.advanceTimersByTimeAsync(16_000) + + expect(restartPaneForAccountMove).toHaveBeenCalledTimes(2) + }) + + it('keeps text you start typing while it checks the pane', async () => { + let answer: (inspection: Inspection) => void = () => {} + inspect.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve))) + seed({}) + const moving = moveClaudeSessionsToSelectedAccount() + await vi.advanceTimersByTimeAsync(0) + useAppStore.setState({ lastTerminalInputAtByPaneKey: { [PANE_KEY]: Date.now() } }) + answer({ foregroundProcess: 'claude', hasChildProcesses: true }) + await moving + + expect(restartPaneForAccountMove).not.toHaveBeenCalled() + }) + + it('tries again after the pane could not be checked', async () => { + inspect.mockRejectedValueOnce(new Error('pty is busy')) + seed({}) + uninstall = installClaudeSessionMoveCoordinator() + await moveClaudeSessionsToSelectedAccount() + expect(restartPaneForAccountMove).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(16_000) + + expect(restartPaneForAccountMove).toHaveBeenCalledOnce() + }) + + it('keeps your Move request when the account list could not be read', async () => { + seed({}) + uninstall = installClaudeSessionMoveCoordinator() + list.mockRejectedValueOnce(new Error('main is busy')) + await moveClaudeSessionsToSelectedAccount() + expect(restartPaneForAccountMove).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(16_000) + + expect(restartPaneForAccountMove).toHaveBeenCalledOnce() + }) + + it('drops the move when the account switches back while it checks the pane', async () => { + let answer: (inspection: Inspection) => void = () => {} + inspect.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve))) + seed({}) + uninstall = installClaudeSessionMoveCoordinator() + const moving = moveClaudeSessionsToSelectedAccount() + await vi.advanceTimersByTimeAsync(0) + switchTo('acct-a') + await settle() + answer({ foregroundProcess: 'claude', hasChildProcesses: true }) + await moving + + expect(restartPaneForAccountMove).not.toHaveBeenCalled() + }) + + it('stops an automatic move you turn off while it checks the pane', async () => { + let answer: (inspection: Inspection) => void = () => {} + inspect.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve))) + roster = selected('acct-a') + seed({}, { claudeMoveIdleSessions: true }) + uninstall = installClaudeSessionMoveCoordinator() + switchTo('acct-b') + await settle() + useAppStore.setState((s) => ({ + settings: s.settings ? { ...s.settings, claudeMoveIdleSessions: false } : s.settings + })) + answer({ foregroundProcess: 'claude', hasChildProcesses: true }) + await settle() + + expect(restartPaneForAccountMove).not.toHaveBeenCalled() + }) + + it("tries again when the pane's processes could not be read", async () => { + inspect.mockResolvedValueOnce({ + foregroundProcess: 'zsh', + hasChildProcesses: false, + childProcessEvidence: 'unverifiable' + }) + seed({}) + uninstall = installClaudeSessionMoveCoordinator() + await moveClaudeSessionsToSelectedAccount() + expect(restartPaneForAccountMove).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(16_000) + + expect(restartPaneForAccountMove).toHaveBeenCalledOnce() + }) + + it('does not use the old account list while a new one is being read', async () => { + let answer: (inspection: Inspection) => void = () => {} + inspect.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve))) + seed({}) + uninstall = installClaudeSessionMoveCoordinator() + const moving = moveClaudeSessionsToSelectedAccount() + await vi.advanceTimersByTimeAsync(0) + list.mockImplementationOnce(() => new Promise(() => {})) + switchTo('acct-a') + answer({ foregroundProcess: 'claude', hasChildProcesses: true }) + await moving + + expect(restartPaneForAccountMove).not.toHaveBeenCalled() + }) + + it('tries again when the foreground could not be verified, rather than giving up on the session', async () => { + inspect.mockResolvedValueOnce({ + foregroundProcess: 'zsh', + hasChildProcesses: false, + foregroundProcessEvidence: { + verdict: 'unverifiable', + reason: 'process_table_unavailable', + authorityGeneration: 'gen-1', + observationEpoch: 1, + capturedAgeMs: 0, + ptyId: 'pty-1', + ptyIncarnationId: 'inc-1' + } + }) + seed({}) + uninstall = installClaudeSessionMoveCoordinator() + await moveClaudeSessionsToSelectedAccount() + expect(restartPaneForAccountMove).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(16_000) + + expect(restartPaneForAccountMove).toHaveBeenCalledOnce() + }) + + it('does not trust a cached claude name the pane could not verify', async () => { + inspect.mockResolvedValueOnce({ + foregroundProcess: 'claude', + hasChildProcesses: true, + foregroundProcessEvidence: { + verdict: 'unverifiable', + reason: 'process_table_unavailable', + authorityGeneration: 'gen-1', + observationEpoch: 1, + capturedAgeMs: 0, + ptyId: 'pty-1', + ptyIncarnationId: 'inc-1' + } + }) + seed({}) + + await moveClaudeSessionsToSelectedAccount() + + expect(restartPaneForAccountMove).not.toHaveBeenCalled() + }) + + it('moves a session whose pane reads as the shell for a moment while Claude still runs', async () => { + foreground.process = 'zsh' + seed({}) + + await moveClaudeSessionsToSelectedAccount() + + expect(restartPaneForAccountMove).toHaveBeenCalledOnce() + }) + + it('moves a Claude the fresh read finds running under node', async () => { + inspect.mockResolvedValueOnce({ foregroundProcess: 'zsh', hasChildProcesses: false }) + confirm.mockResolvedValueOnce('node') + seed({}) + + await moveClaudeSessionsToSelectedAccount() + + expect(restartPaneForAccountMove).toHaveBeenCalledOnce() + }) + + it('leaves the pane when the fresh read finds another agent there', async () => { + foreground.process = 'zsh' + confirm.mockResolvedValueOnce('codex') + seed({}) + + await moveClaudeSessionsToSelectedAccount() + + expect(restartPaneForAccountMove).not.toHaveBeenCalled() + }) + + it('leaves the pane once a fresh read confirms the shell', async () => { + foreground.process = 'zsh' + confirm.mockResolvedValueOnce('zsh') + seed({}) + + await moveClaudeSessionsToSelectedAccount() + + expect(restartPaneForAccountMove).not.toHaveBeenCalled() + expect(listClaudeSessionsOnOtherLogin(useAppStore.getState())).toEqual([]) + }) + + it('tries again when a fresh read of a shell reading cannot tell', async () => { + foreground.process = 'zsh' + confirm.mockResolvedValueOnce(null) + seed({}) + uninstall = installClaudeSessionMoveCoordinator() + await moveClaudeSessionsToSelectedAccount() + expect(restartPaneForAccountMove).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(16_000) + + expect(restartPaneForAccountMove).toHaveBeenCalledOnce() + }) + + it('does not read the processes of a pane that is not mounted', async () => { + hasPaneAccountMoveRestart.mockReturnValueOnce(false) + seed({}) + + await moveClaudeSessionsToSelectedAccount() + + expect(inspect).not.toHaveBeenCalled() + }) + + it('does not read the accounts again when an unrelated setting is saved', async () => { + seed({}, { claudeMoveIdleSessions: true }) + uninstall = installClaudeSessionMoveCoordinator() + await settle() + list.mockClear() + + // A save stores the whole settings object main returns, so every field comes back new. + useAppStore.setState((s) => ({ + settings: s.settings + ? structuredClone({ ...s.settings, terminalInactivePaneOpacity: 0.5 }) + : s.settings + })) + await settle() + + expect(list).not.toHaveBeenCalled() + }) + + it('reads the accounts again when an account signs in again', async () => { + const account = (updatedAt: number) => ({ + id: 'acct-a', + email: 'a@example.test', + managedAuthPath: '', + authMethod: 'subscription-oauth' as const, + createdAt: 1, + updatedAt, + lastAuthenticatedAt: updatedAt + }) + seed({}, { claudeMoveIdleSessions: true }) + useAppStore.setState((s) => ({ + settings: s.settings ? { ...s.settings, claudeManagedAccounts: [account(1)] } : s.settings + })) + uninstall = installClaudeSessionMoveCoordinator() + await settle() + list.mockClear() + + useAppStore.setState((s) => ({ + settings: s.settings ? { ...s.settings, claudeManagedAccounts: [account(2)] } : s.settings + })) + await settle() + + expect(list).toHaveBeenCalledOnce() + }) + + it("reads the accounts again when only this machine's selection changes", async () => { + seed({}, { claudeMoveIdleSessions: true }) + uninstall = installClaudeSessionMoveCoordinator() + await settle() + list.mockClear() + + useAppStore.setState((s) => ({ + settings: s.settings + ? { ...s.settings, activeClaudeManagedAccountIdsByRuntime: { host: 'acct-a', wsl: {} } } + : s.settings + })) + await settle() + + expect(list).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/lib/claude-session-move-coordinator.ts b/src/renderer/src/lib/claude-session-move-coordinator.ts new file mode 100644 index 000000000000..883c6052f862 --- /dev/null +++ b/src/renderer/src/lib/claude-session-move-coordinator.ts @@ -0,0 +1,301 @@ +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { + hasPaneAccountMoveRestart, + restartPaneForAccountMove +} from '@/components/terminal-pane/pane-account-move-restarts' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { ClaudeRateLimitAccountsState } from '../../../shared/managed-account-types' +import { MIN_AGENT_HIBERNATION_IDLE_MS } from './agent-hibernation-planner' +import { + getHibernationBoundaryResolvedAtByPaneKey, + getHibernationPtyBindingFirstSeenAtByPaneKey +} from './agent-hibernation-pane-age' +import { buildClaudeSessionMoveStartup } from './codex-account-restart-startup' +import { + claudeMoveAccountsKey, + findClaudeSessionsOnOtherLogin, + isClaudeSessionMovableNow, + readClaudeInPane, + type ClaudeSessionMoveGates, + type ClaudeSessionOnOtherLogin +} from './claude-session-move' +import { + getForegroundTerminalTabIds, + getForegroundTerminalTabLastSeenAtById +} from './foreground-terminal-tabs' +import { getAllDrivers } from './pane-manager/mobile-driver-state' +import { mergePendingTerminalInputActivity } from './terminal-input-activity-coalescing' +import { getRendererAppPlatform } from './renderer-app-platform' +import { getExecutionHostIdForWorktree } from './worktree-runtime-owner' + +// Why hibernation's shortest idle time: a move nobody asked for restarts the pane, so it waits as +// long as the gentlest sleep would before touching it. +const AUTOMATIC_MOVE_IDLE_MS = MIN_AGENT_HIBERNATION_IDLE_MS +const SETTLE_MS = 500 +const RECHECK_MS = 15_000 + +/** Sessions you asked to move that were still busy, by pane. */ +const approvedPaneKeys = new Set() +/** Panes already moved, or left alone, toward a login; a new report from the session lifts it. */ +const settledMoves = new Map< + string, + { transcriptPath: string; targetLogin: string; replacedPtyId?: string } +>() +let accounts: ClaudeRateLimitAccountsState | null = null +let accountsRead = 0 +/** You pressed Move Sessions before the account list could be read. */ +let moveRequested = false +let wakeRetry: (() => void) | null = null + +export function resetClaudeSessionMoveForTests(): void { + approvedPaneKeys.clear() + settledMoves.clear() + accounts = null + moveRequested = false +} + +// Why this machine only: a pane on WSL, SSH or a remote server runs on another host's selection. +function isOnThisHost(state: AppState, worktreeId: string): boolean { + if (getExecutionHostIdForWorktree(state, worktreeId) !== LOCAL_EXECUTION_HOST_ID) { + return false + } + const runtime = getLocalProjectExecutionRuntimeContext(state, worktreeId) + return !(runtime?.status === 'resolved' && runtime.runtime.kind === 'wsl') +} + +/** Re-reads this host's Claude accounts, which name the selected login. */ +export async function refreshClaudeMoveAccounts(): Promise { + const read = ++accountsRead + // Why drop the old list first: a switch is what usually asks for a read, so until the new one + // arrives the old list names the account we just left. + accounts = null + // Why forget on failure: the old list may name the account we just switched away from. + const next = await window.api.claudeAccounts.list().catch(() => null) + // Why only the latest: replies to a quick A, B, A can arrive out of order. + if (read === accountsRead) { + accounts = next + } +} + +function isSettled(session: ClaudeSessionOnOtherLogin): boolean { + const settled = settledMoves.get(session.paneKey) + // Why the replaced PTY: a restart whose connect never ran leaves the pane on it, unmoved. + return ( + settled?.transcriptPath === session.transcriptPath && + settled.targetLogin === session.targetLogin && + settled.replacedPtyId !== session.ptyId + ) +} + +function settle(session: ClaudeSessionOnOtherLogin, replacedPtyId?: string): void { + settledMoves.set(session.paneKey, { + transcriptPath: session.transcriptPath, + targetLogin: session.targetLogin, + replacedPtyId + }) + approvedPaneKeys.delete(session.paneKey) +} + +/** Claude sessions on this machine still to move to the selected login. */ +export function listClaudeSessionsOnOtherLogin(state: AppState): ClaudeSessionOnOtherLogin[] { + // Why drop settled ones: a moved session reads as the old login until it reports again, and one + // that cannot be resumed has nothing for the button to do. + return accounts + ? findClaudeSessionsOnOtherLogin(state, accounts, (worktreeId) => + isOnThisHost(state, worktreeId) + ).filter((session) => !isSettled(session)) + : [] +} + +/** Moves every session on another login, each one as soon as it can go without losing anything. */ +export async function moveClaudeSessionsToSelectedAccount(): Promise { + moveRequested = true + await refreshClaudeMoveAccounts() + approveRequestedMoves() + // Why wake the retry: a pane that cannot go yet may free up with no store change to notice it. + if ((await movePanes()) > 0 || moveRequested) { + wakeRetry?.() + } +} + +// Why wait for the list: without it there is no telling which sessions the request covers. +function approveRequestedMoves(): void { + if (!moveRequested || !accounts) { + return + } + for (const session of listClaudeSessionsOnOtherLogin(useAppStore.getState())) { + approvedPaneKeys.add(session.paneKey) + } + moveRequested = false +} + +function gatesFor(approved: boolean): ClaudeSessionMoveGates { + const shared = { + now: Date.now(), + mobileLockedPtyIds: new Set( + [...getAllDrivers()].filter(([, driver]) => driver.kind === 'mobile').map(([ptyId]) => ptyId) + ), + ptyBindingFirstSeenAtByPaneKey: getHibernationPtyBindingFirstSeenAtByPaneKey(), + boundaryResolvedAtByPaneKey: getHibernationBoundaryResolvedAtByPaneKey() + } + // Why: a session you asked to move goes at once, even on screen; one moved on its own leaves the + // tab you are looking at alone and waits for the idle time. + return approved + ? { ...shared, idleMs: 0, foregroundTabIds: new Set(), foregroundLastSeenAtByTabId: {} } + : { + ...shared, + idleMs: AUTOMATIC_MOVE_IDLE_MS, + foregroundTabIds: new Set(getForegroundTerminalTabIds()), + foregroundLastSeenAtByTabId: getForegroundTerminalTabLastSeenAtById() + } +} + +// Why: input stamps are coalesced, so unsent text must include the not-yet-flushed keystroke. +function readMoveView(state: AppState): AppState { + return { + ...state, + lastTerminalInputAtByPaneKey: mergePendingTerminalInputActivity( + state.lastTerminalInputAtByPaneKey + ) + } +} + +// Why read everything fresh: across an await the account, the setting, the pane or the session can +// all have changed, and any of them can make the move wrong. +function canMoveNow(session: ClaudeSessionOnOtherLogin): boolean { + const view = readMoveView(useAppStore.getState()) + const approved = approvedPaneKeys.has(session.paneKey) + if (!accounts || (!approved && view.settings?.claudeMoveIdleSessions !== true)) { + return false + } + const now = findClaudeSessionsOnOtherLogin(view, accounts, (worktreeId) => + isOnThisHost(view, worktreeId) + ).find((candidate) => candidate.paneKey === session.paneKey) + // Why mounted: only a mounted pane restarts in place, so reading a parked one's processes is waste. + return ( + hasPaneAccountMoveRestart(session.ptyId) && + now?.ptyId === session.ptyId && + now.transcriptPath === session.transcriptPath && + now.targetLogin === session.targetLogin && + isClaudeSessionMovableNow(view, now, gatesFor(approved)) + ) +} + +/** Moves what can go now and returns how many sessions still wait their turn. */ +async function movePanes(): Promise { + if (!accounts) { + return 0 + } + const state = useAppStore.getState() + const sessions = findClaudeSessionsOnOtherLogin(readMoveView(state), accounts, (worktreeId) => + isOnThisHost(state, worktreeId) + ) + const currentPaneKeys = new Set(sessions.map((session) => session.paneKey)) + for (const paneKey of approvedPaneKeys) { + // Why: the account switched back, or the pane closed, so the answer no longer applies. + if (!currentPaneKeys.has(paneKey)) { + approvedPaneKeys.delete(paneKey) + } + } + const automatic = state.settings?.claudeMoveIdleSessions === true + let waiting = 0 + for (const session of sessions) { + const approved = approvedPaneKeys.has(session.paneKey) + if (isSettled(session) || (!approved && !automatic)) { + continue + } + if (!canMoveNow(session)) { + waiting += 1 + continue + } + const tab = state.tabsByWorktree[session.worktreeId]?.find( + (candidate) => candidate.id === session.tabId + ) + // Why ask the process: the pane may now run tmux or the shell, and restarting it there would + // cut off a Claude that lives on outside the pane, or start one nobody runs any more. + const inspection = await inspectRuntimeTerminalProcess(session.ptyId).catch(() => null) + const running = await readClaudeInPane( + session.ptyId, + inspection, + tab?.launchAgent, + getRendererAppPlatform() + ) + // Why retry: a check that failed, or could not see the process, says nothing about the pane. + if (running === null) { + waiting += 1 + continue + } + if (!running) { + settle(session) + continue + } + // Why check again: you may have typed, switched back, or turned moves off while we asked. + if (!canMoveNow(session)) { + waiting += 1 + continue + } + const startup = buildClaudeSessionMoveStartup({ + tabId: session.tabId, + leafId: session.leafId, + worktreeId: session.worktreeId, + shellOverride: tab?.shellOverride + }) + // Why settle either way: a session that cannot be resumed is never restarted blank, and one + // that comes back still reading as the old login must not restart again. + if (!startup) { + settle(session) + } else if (restartPaneForAccountMove(session.ptyId, startup)) { + settle(session, session.ptyId) + } else { + waiting += 1 + } + } + return waiting +} + +export function installClaudeSessionMoveCoordinator(): () => void { + let timer: ReturnType | undefined + const schedule = (delayMs: number): void => { + clearTimeout(timer) + timer = setTimeout(() => void run(), delayMs) + } + const run = async (): Promise => { + if (!accounts) { + await refreshClaudeMoveAccounts() + } + approveRequestedMoves() + const pendingWork = + moveRequested || + approvedPaneKeys.size > 0 || + useAppStore.getState().settings?.claudeMoveIdleSessions === true + // Why recheck while any wait: idle time runs out, and a failed accounts read clears, without a + // store change to wake us. + if ((await movePanes()) > 0 || (!accounts && pendingWork)) { + schedule(RECHECK_MS) + } + } + wakeRetry = () => schedule(RECHECK_MS) + const unsubscribe = useAppStore.subscribe((state, previous) => { + const settings = state.settings + const before = previous.settings + if (settings !== before && claudeMoveAccountsKey(settings) !== claudeMoveAccountsKey(before)) { + void refreshClaudeMoveAccounts().then(() => schedule(SETTLE_MS)) + return + } + const changed = + settings?.claudeMoveIdleSessions !== before?.claudeMoveIdleSessions || + state.agentStatusByPaneKey !== previous.agentStatusByPaneKey || + state.terminalLayoutsByTabId !== previous.terminalLayoutsByTabId + if (changed && (approvedPaneKeys.size > 0 || settings?.claudeMoveIdleSessions === true)) { + schedule(SETTLE_MS) + } + }) + return () => { + unsubscribe() + clearTimeout(timer) + wakeRetry = null + } +} diff --git a/src/renderer/src/lib/claude-session-move.test.ts b/src/renderer/src/lib/claude-session-move.test.ts new file mode 100644 index 000000000000..06cae383fe3d --- /dev/null +++ b/src/renderer/src/lib/claude-session-move.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import type { + ClaudeManagedAccountSummary, + ClaudeRateLimitAccountsState +} from '../../../shared/managed-account-types' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import { + findClaudeSessionsOnOtherLogin, + isClaudeRunningInPane, + isClaudeSessionMovableNow, + isUnseenPaneInspection, + type ClaudeSessionMoveGates, + type ClaudeSessionMoveView +} from './claude-session-move' + +const NOW = 5_000_000 +const LEAF = '11111111-1111-4111-8111-111111111111' +const PROFILES = '/Users/me/Library/Application Support/orca/claude-profiles' + +function account(id: string, email: string): ClaudeManagedAccountSummary { + return { + id, + email, + authMethod: 'subscription-oauth', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } +} + +function accounts( + activeAccountId: string | null, + systemDefaultEmail = 'me@example.test' +): ClaudeRateLimitAccountsState { + return { + accounts: [account('acct-a', 'a@example.test'), account('acct-b', 'b@example.test')], + activeAccountId, + activeAccountIdsByRuntime: { host: activeAccountId, wsl: {} }, + systemDefaultEmail + } +} + +function tab(): TerminalTab { + return { + id: 'tab-1', + ptyId: null, + worktreeId: 'wt-1', + title: 'Claude', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function claudeRow( + transcriptPath: string | undefined, + overrides: Partial = {} +): AgentStatusEntry { + return { + state: 'done', + prompt: 'ship it', + updatedAt: NOW - 1_000, + stateStartedAt: NOW - 1_000, + paneKey: `tab-1:${LEAF}`, + tabId: 'tab-1', + worktreeId: 'wt-1', + agentType: 'claude', + providerSession: { + key: 'session_id', + id: 'session-1', + ...(transcriptPath ? { transcriptPath } : {}) + }, + stateHistory: [], + ...overrides + } +} + +function view( + row: AgentStatusEntry, + extra: Partial = {} +): ClaudeSessionMoveView { + return { + tabsByWorktree: { 'wt-1': [tab()] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: 'pty-1' } + } + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + agentStatusByPaneKey: { [row.paneKey]: row }, + sleepingAgentSessionsByPaneKey: {}, + lastTerminalInputAtByPaneKey: {}, + ...extra + } +} + +const onThisHost = (): boolean => true +const onA = `${PROFILES}/acct-a/home/projects/-repo/session-1.jsonl` +const onB = `${PROFILES}/acct-b/home/projects/-repo/session-1.jsonl` +const onSystemDefault = '/Users/me/.claude/projects/-repo/session-1.jsonl' + +function gates(overrides: Partial = {}): ClaudeSessionMoveGates { + return { + now: NOW, + idleMs: 0, + foregroundTabIds: new Set(), + foregroundLastSeenAtByTabId: {}, + mobileLockedPtyIds: new Set(), + ptyBindingFirstSeenAtByPaneKey: {}, + boundaryResolvedAtByPaneKey: {}, + ...overrides + } +} + +describe('findClaudeSessionsOnOtherLogin', () => { + it('finds a session still on the account it started on after a switch', () => { + expect( + findClaudeSessionsOnOtherLogin(view(claudeRow(onA)), accounts('acct-b'), onThisHost) + ).toEqual([ + { + paneKey: `tab-1:${LEAF}`, + tabId: 'tab-1', + leafId: LEAF, + ptyId: 'pty-1', + worktreeId: 'wt-1', + transcriptPath: onA, + targetLogin: 'b@example.test/' + } + ]) + }) + + it('leaves a session that already runs on the selected login', () => { + expect( + findClaudeSessionsOnOtherLogin(view(claudeRow(onB)), accounts('acct-b'), onThisHost) + ).toEqual([]) + }) + + it('moves a System default session when another login is selected', () => { + expect( + findClaudeSessionsOnOtherLogin( + view(claudeRow(onSystemDefault)), + accounts('acct-a'), + onThisHost + ) + ).toHaveLength(1) + }) + + it('counts an account that holds the System default login as already in use there', () => { + // Why: Orca runs an account whose login is System default's from the System default folder. + expect( + findClaudeSessionsOnOtherLogin( + view(claudeRow(onSystemDefault)), + accounts('acct-a', 'A@example.test'), + onThisHost + ) + ).toEqual([]) + }) + + it('offers a session whose account was removed, since it cannot be on the selected login', () => { + const removed = `${PROFILES}/acct-removed/home/projects/-repo/session-1.jsonl` + expect( + findClaudeSessionsOnOtherLogin(view(claudeRow(removed)), accounts('acct-b'), onThisHost) + ).toHaveLength(1) + }) + + it('tells two logins on one email apart by their organization', () => { + const teams: ClaudeRateLimitAccountsState = { + ...accounts('acct-a'), + accounts: [{ ...account('acct-a', 'me@example.test'), organizationUuid: 'org-team' }], + systemDefaultEmail: 'me@example.test', + systemDefaultOrganizationUuid: 'org-personal' + } + expect( + findClaudeSessionsOnOtherLogin(view(claudeRow(onSystemDefault)), teams, onThisHost) + ).toHaveLength(1) + expect( + findClaudeSessionsOnOtherLogin( + view(claudeRow(onSystemDefault)), + { ...teams, systemDefaultOrganizationUuid: 'org-team' }, + onThisHost + ) + ).toEqual([]) + }) + + it('skips what it cannot place', () => { + const unplaced = [claudeRow(onA, { agentType: 'codex' }), claudeRow(undefined)] + for (const row of unplaced) { + expect(findClaudeSessionsOnOtherLogin(view(row), accounts('acct-b'), onThisHost)).toEqual([]) + } + expect( + findClaudeSessionsOnOtherLogin( + view(claudeRow(onSystemDefault)), + accounts('acct-b', ''), + onThisHost + ) + ).toEqual([]) + expect( + findClaudeSessionsOnOtherLogin(view(claudeRow(onA)), accounts('acct-b'), () => false) + ).toEqual([]) + }) +}) + +describe('isClaudeSessionMovableNow', () => { + function movable( + row: AgentStatusEntry, + extra: Partial = {}, + gateOverrides: Partial = {} + ): boolean { + const sessionView = view(row, extra) + const [session] = findClaudeSessionsOnOtherLogin(sessionView, accounts('acct-b'), onThisHost) + return isClaudeSessionMovableNow(sessionView, session, gates(gateOverrides)) + } + + it('moves a finished session', () => { + expect(movable(claudeRow(onA))).toBe(true) + }) + + it('waits while the session works or asks you something', () => { + expect(movable(claudeRow(onA, { state: 'working' }))).toBe(false) + expect(movable(claudeRow(onA, { state: 'waiting' }))).toBe(false) + }) + + it('waits while subagents still run', () => { + expect( + movable(claudeRow(onA, { subagents: [{ id: 'child-1', state: 'working', startedAt: NOW }] })) + ).toBe(false) + }) + + it('keeps text you typed but did not send', () => { + expect( + movable(claudeRow(onA), { lastTerminalInputAtByPaneKey: { [`tab-1:${LEAF}`]: NOW - 10 } }) + ).toBe(false) + }) + + it('moves a session whose turn died on an error, which is how a usage limit ends one', () => { + expect( + movable( + claudeRow(onA, { + mainAgent: { state: 'done', stateStartedAt: NOW - 1_000, outcome: 'failure' } + }) + ) + ).toBe(true) + }) + + it('waits for the idle time, and leaves the tab on screen when told to', () => { + expect(movable(claudeRow(onA), {}, { idleMs: 60_000 })).toBe(false) + expect(movable(claudeRow(onA), {}, { foregroundTabIds: new Set(['tab-1']) })).toBe(false) + }) +}) + +describe('isClaudeRunningInPane', () => { + const inspection = (foregroundProcess: string | null, hasChildProcesses = true) => ({ + foregroundProcess, + hasChildProcesses + }) + + it('knows Claude by its process', () => { + expect(isClaudeRunningInPane(inspection('claude'), undefined)).toBe(true) + }) + + it('knows a Claude this tab launched that runs under node, as an npm install does', () => { + expect(isClaudeRunningInPane(inspection('node'), 'claude')).toBe(true) + expect(isClaudeRunningInPane(inspection('node'), undefined)).toBe(false) + }) + + it('leaves another agent alone, even in a tab that launched Claude', () => { + expect(isClaudeRunningInPane(inspection('codex'), 'claude')).toBe(false) + }) + + it('leaves tmux, the shell, or another program alone', () => { + for (const name of ['tmux', 'zsh', 'vim']) { + expect(isClaudeRunningInPane(inspection(name), 'claude')).toBe(false) + } + expect(isClaudeRunningInPane(null, 'claude')).toBe(false) + }) +}) + +describe('isUnseenPaneInspection', () => { + const unfenced = { + verdict: 'unverifiable' as const, + reason: 'process_table_unavailable', + authorityGeneration: 'gen-1', + observationEpoch: 1, + capturedAgeMs: 0, + ptyId: 'pty-1', + ptyIncarnationId: 'inc-1' + } + + it('reads a Windows answer by its child-process evidence, since Windows never fences the foreground', () => { + expect( + isUnseenPaneInspection( + { + foregroundProcess: 'claude', + hasChildProcesses: true, + childProcessEvidence: 'children', + foregroundProcessEvidence: { + verdict: 'unverifiable', + reason: 'windows_ssh_foreground_unavailable', + authorityGeneration: 'gen-1', + observationEpoch: 1, + capturedAgeMs: 0, + ptyId: 'pty-1', + ptyIncarnationId: 'inc-1' + } + }, + 'win32' + ) + ).toBe(false) + }) + + it('counts an unverified foreground as unseen elsewhere, even when the cached name says claude', () => { + expect( + isUnseenPaneInspection( + { + foregroundProcess: 'claude', + hasChildProcesses: true, + foregroundProcessEvidence: unfenced + }, + 'darwin' + ) + ).toBe(true) + }) + + it('counts an answer that could not read the process table as unseen', () => { + expect( + isUnseenPaneInspection( + { + foregroundProcess: 'zsh', + hasChildProcesses: false, + childProcessEvidence: 'unverifiable' + }, + 'darwin' + ) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/claude-session-move.ts b/src/renderer/src/lib/claude-session-move.ts new file mode 100644 index 000000000000..6608c053e421 --- /dev/null +++ b/src/renderer/src/lib/claude-session-move.ts @@ -0,0 +1,249 @@ +import type { AppState } from '@/store' +import { + confirmRuntimeTerminalForegroundProcess, + type RuntimeTerminalProcessInspection +} from '@/runtime/runtime-terminal-inspection' +import { + isAgentForegroundWrapperProcess, + recognizeAgentProcess +} from '../../../shared/agent-process-recognition' +import { claudeAccountIdFromTranscriptPath } from '../../../shared/claude-transcript-account' +import type { ClaudeRateLimitAccountsState } from '../../../shared/managed-account-types' +import { isRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' +import { isShellProcess } from '../../../shared/shell-process-detection' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { isClientOnlyUnverifiableInspection } from '../../../shared/terminal-process-inspection' +import type { TuiAgent } from '../../../shared/tui-agent' +import { getEligiblePane, toRuntimePtyId } from './agent-hibernation-pane-eligibility' + +export type ClaudeSessionMoveView = Pick< + AppState, + | 'tabsByWorktree' + | 'terminalLayoutsByTabId' + | 'ptyIdsByTabId' + | 'agentStatusByPaneKey' + | 'sleepingAgentSessionsByPaneKey' + | 'lastTerminalInputAtByPaneKey' +> + +export type ClaudeSessionOnOtherLogin = { + paneKey: string + tabId: string + leafId: string + ptyId: string + worktreeId: string + /** Where the session reports its transcript; it changes once a moved session reports again. */ + transcriptPath: string + /** The selected login, email and organization, so a move is recorded against where it went. */ + targetLogin: string +} + +export type ClaudeSessionMoveGates = { + now: number + idleMs: number + foregroundTabIds: ReadonlySet + foregroundLastSeenAtByTabId: Record + mobileLockedPtyIds: Set + ptyBindingFirstSeenAtByPaneKey: Record + boundaryResolvedAtByPaneKey: Record +} + +/** What names the selected login; a settings save hands back every field as a new object. */ +export function claudeMoveAccountsKey(settings: AppState['settings']): string { + return JSON.stringify([ + settings?.activeClaudeManagedAccountId, + settings?.activeClaudeManagedAccountIdsByRuntime?.host, + settings?.claudeManagedAccounts?.map((account) => [account.id, account.updatedAt]) + ]) +} + +type ClaudeLogin = { email: string; organizationUuid?: string | null } + +// Why the login and not the folder: usage limits follow the login, and two folders can hold the +// same one (an account whose login is System default's runs from the System default folder). +function isSameLogin(a: ClaudeLogin, b: ClaudeLogin): boolean { + return ( + a.email.toLowerCase() === b.email.toLowerCase() && + (!a.organizationUuid || !b.organizationUuid || a.organizationUuid === b.organizationUuid) + ) +} + +function loginOf( + accounts: ClaudeRateLimitAccountsState, + accountId: string | null +): ClaudeLogin | null { + if (accountId === null) { + return accounts.systemDefaultEmail + ? { + email: accounts.systemDefaultEmail, + organizationUuid: accounts.systemDefaultOrganizationUuid + } + : null + } + const account = accounts.accounts.find((candidate) => candidate.id === accountId) + return account ? { email: account.email, organizationUuid: account.organizationUuid } : null +} + +/** Claude sessions on this host still running on a login other than the selected one. */ +export function findClaudeSessionsOnOtherLogin( + view: ClaudeSessionMoveView, + accounts: ClaudeRateLimitAccountsState, + isOnThisHost: (worktreeId: string) => boolean +): ClaudeSessionOnOtherLogin[] { + const selectedAccountId = accounts.activeAccountIdsByRuntime + ? accounts.activeAccountIdsByRuntime.host + : accounts.activeAccountId + const selected = loginOf(accounts, selectedAccountId) + if (!selected) { + return [] + } + const sessions: ClaudeSessionOnOtherLogin[] = [] + for (const [worktreeId, tabs] of Object.entries(view.tabsByWorktree)) { + if (!isOnThisHost(worktreeId)) { + continue + } + for (const tab of tabs) { + const ptyIdsByLeafId = view.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {} + for (const [leafId, ptyId] of Object.entries(ptyIdsByLeafId)) { + const paneKey = makePaneKey(tab.id, leafId) + const entry = view.agentStatusByPaneKey[paneKey] + const providerSession = entry?.providerSession + if ( + entry?.agentType !== 'claude' || + !providerSession?.transcriptPath || + !ptyId || + isRemoteRuntimePtyId(ptyId) + ) { + continue + } + const accountId = claudeAccountIdFromTranscriptPath(providerSession.transcriptPath) + const login = loginOf(accounts, accountId) + // Why a removed account counts: no saved login is served from its folder any more, so the + // session cannot be on the selected one. + const removed = accountId !== null && !login + if (!removed && (!login || isSameLogin(login, selected))) { + continue + } + sessions.push({ + paneKey, + tabId: tab.id, + leafId, + ptyId, + worktreeId, + transcriptPath: providerSession.transcriptPath, + targetLogin: `${selected.email.toLowerCase()}/${selected.organizationUuid ?? ''}` + }) + } + } + } + return sessions +} + +/** + * Whether Claude is what runs in the pane, much as Codex's restart prompt tests it: Claude by name, + * or an agent wrapper such as node in a tab that launched Claude (an npm install runs it under node). + * tmux, the shell, another agent or another program means a restart would cut off something else. + */ +export function isClaudeRunningInPane( + inspection: Pick< + RuntimeTerminalProcessInspection, + 'foregroundProcess' | 'hasChildProcesses' + > | null, + launchAgent: TuiAgent | undefined +): boolean { + if (!inspection || isClientOnlyUnverifiableInspection(inspection)) { + return false + } + const { foregroundProcess, hasChildProcesses } = inspection + if (recognizeAgentProcess(foregroundProcess)?.agent === 'claude') { + return true + } + if (launchAgent !== 'claude' || foregroundProcess === null || !hasChildProcesses) { + return false + } + // Why not another agent: restarting a Codex someone started here as Claude would end it. + return ( + !isShellProcess(foregroundProcess) && + recognizeAgentProcess(foregroundProcess) === null && + isAgentForegroundWrapperProcess(foregroundProcess) + ) +} + +/** + * Whether the inspection could not see this machine's pane processes, so it says nothing either + * way, even when a cached name says claude. Windows marks the foreground evidence unverifiable on + * every local answer, so there it goes by the child-process evidence, as agent completion does. + */ +export function isUnseenPaneInspection( + inspection: RuntimeTerminalProcessInspection, + platform: NodeJS.Platform +): boolean { + return ( + isClientOnlyUnverifiableInspection(inspection) || + inspection.childProcessEvidence === 'unverifiable' || + (platform !== 'win32' && inspection.foregroundProcessEvidence?.verdict === 'unverifiable') + ) +} + +/** + * Whether Claude runs in the pane, or null when its processes could not be seen. A shell reading in + * a tab that launched Claude is read again fresh, as Codex's restart does: the cached read can flap + * to the shell for a live agent (#11064). + */ +export async function readClaudeInPane( + ptyId: string, + inspection: RuntimeTerminalProcessInspection | null, + launchAgent: TuiAgent | undefined, + platform: NodeJS.Platform +): Promise { + if (!inspection || isUnseenPaneInspection(inspection, platform)) { + return null + } + const { foregroundProcess } = inspection + if ( + launchAgent !== 'claude' || + foregroundProcess === null || + !isShellProcess(foregroundProcess) + ) { + return isClaudeRunningInPane(inspection, launchAgent) + } + const confirmed = await confirmRuntimeTerminalForegroundProcess(ptyId) + // Why children: a foreground other than the shell runs under it, as the daemon's own read says. + return confirmed === null + ? null + : isClaudeRunningInPane({ foregroundProcess: confirmed, hasChildProcesses: true }, launchAgent) +} + +/** Whether restarting the session now loses nothing: hibernation's own test for a pane it may stop. */ +export function isClaudeSessionMovableNow( + view: ClaudeSessionMoveView, + session: ClaudeSessionOnOtherLogin, + gates: ClaudeSessionMoveGates +): boolean { + if (gates.foregroundTabIds.has(session.tabId)) { + return false + } + const entry = view.agentStatusByPaneKey[session.paneKey] + const tab = view.tabsByWorktree[session.worktreeId]?.find( + (candidate) => candidate.id === session.tabId + ) + if (!entry || !tab) { + return false + } + const eligible = getEligiblePane({ + entry, + tab, + layout: view.terminalLayoutsByTabId[session.tabId], + livePtyIds: new Set((view.ptyIdsByTabId[session.tabId] ?? []).map(toRuntimePtyId)), + sleepingAgentSessionsByPaneKey: view.sleepingAgentSessionsByPaneKey, + lastTerminalInputAtByPaneKey: view.lastTerminalInputAtByPaneKey, + foregroundTerminalLastSeenAtByTabId: gates.foregroundLastSeenAtByTabId, + ptyBindingFirstSeenAtByPaneKey: gates.ptyBindingFirstSeenAtByPaneKey, + boundaryResolvedAtByPaneKey: gates.boundaryResolvedAtByPaneKey, + mobileLockedPtyIds: gates.mobileLockedPtyIds, + now: gates.now, + idleMs: gates.idleMs, + allowEndedTurn: true + }) + return eligible !== null +} diff --git a/src/renderer/src/lib/codex-account-restart-startup.test.ts b/src/renderer/src/lib/codex-account-restart-startup.test.ts index dd0a7ffb78ac..7652828f5d0b 100644 --- a/src/renderer/src/lib/codex-account-restart-startup.test.ts +++ b/src/renderer/src/lib/codex-account-restart-startup.test.ts @@ -4,7 +4,10 @@ import { useAppStore } from '@/store' import { makePaneKey } from '../../../shared/stable-pane-id' import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' import type * as localPreflightContext from '@/lib/local-preflight-context' -import { buildCodexAccountRestartStartup } from './codex-account-restart-startup' +import { + buildClaudeSessionMoveStartup, + buildCodexAccountRestartStartup +} from './codex-account-restart-startup' let projectRuntimeContext: ProjectExecutionRuntimeResolution | undefined @@ -158,3 +161,62 @@ describe('buildCodexAccountRestartStartup', () => { expect(build().resumeProviderSession).toBeUndefined() }) }) + +describe('buildClaudeSessionMoveStartup', () => { + const buildClaude = (): ReturnType => + buildClaudeSessionMoveStartup({ tabId: TAB_ID, leafId: LEAF_ID, worktreeId: WORKTREE_ID }) + + beforeEach(() => { + seedAgentStatus(null) + projectRuntimeContext = undefined + }) + + it('reopens the same Claude conversation through the shell, where claude picks the selected account', () => { + seedAgentStatus({ + agentType: 'claude', + state: 'done', + providerSession: { key: 'session_id', id: SESSION_ID } + }) + + const startup = buildClaude() + + expect(startup?.command).toContain('--resume') + expect(startup?.command).toContain(SESSION_ID) + expect(startup).toMatchObject({ + launchAgent: 'claude', + startupCommandDelivery: 'shell-ready', + resumeProviderSession: { id: SESSION_ID } + }) + }) + + it('gives up rather than start a blank Claude that would lose the conversation', () => { + expect(buildClaude()).toBeNull() + seedAgentStatus({ + agentType: 'codex', + state: 'done', + providerSession: { key: 'session_id', id: SESSION_ID } + }) + expect(buildClaude()).toBeNull() + }) + + it('leaves a WSL pane alone', () => { + seedAgentStatus({ + agentType: 'claude', + state: 'done', + providerSession: { key: 'session_id', id: SESSION_ID } + }) + projectRuntimeContext = { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'project-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'repo-1:wsl:Ubuntu' + } + } + + expect(buildClaude()).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/codex-account-restart-startup.ts b/src/renderer/src/lib/codex-account-restart-startup.ts index 48cbba4d15d0..9ab3b824f289 100644 --- a/src/renderer/src/lib/codex-account-restart-startup.ts +++ b/src/renderer/src/lib/codex-account-restart-startup.ts @@ -21,47 +21,67 @@ import type { } from '../../../shared/agent-session-resume' import { CODEX_ACCOUNT_RESTART_STARTUP } from './codex-session-restart' -export type CodexAccountRestartStartup = { +type AccountRestartAgent = 'codex' | 'claude' + +export type AccountRestartStartup = { command: string startupCommandDelivery: 'shell-ready' - launchAgent: 'codex' + launchAgent: A env?: Record launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata } -// Capture the session before restart clears the old pane’s status. -export function buildCodexAccountRestartStartup(args: { +export type CodexAccountRestartStartup = AccountRestartStartup<'codex'> + +type AccountRestartPane = { tabId: string leafId: string worktreeId: string shellOverride?: string -}): CodexAccountRestartStartup { +} + +// Capture the session before restart clears the old pane’s status. +export function buildCodexAccountRestartStartup( + args: AccountRestartPane +): CodexAccountRestartStartup { + return buildAccountRestartResume('codex', args) ?? CODEX_ACCOUNT_RESTART_STARTUP +} + +/** Reopens a Claude pane's conversation; null when it can't, since a blank restart would lose it. */ +export function buildClaudeSessionMoveStartup( + args: AccountRestartPane +): AccountRestartStartup<'claude'> | null { + return buildAccountRestartResume('claude', args) +} + +function buildAccountRestartResume( + agent: A, + args: AccountRestartPane +): AccountRestartStartup | null { const state = useAppStore.getState() const paneKey = makePaneKey(args.tabId, args.leafId) const entry = state.agentStatusByPaneKey[paneKey] const sleeping = state.sleepingAgentSessionsByPaneKey[paneKey] const agentType = entry?.agentType ?? sleeping?.agent - if (agentType !== 'codex') { - return CODEX_ACCOUNT_RESTART_STARTUP + if (agentType !== agent) { + return null } const providerSession = normalizeAgentProviderSession(entry?.providerSession) ?? - normalizeAgentProviderSession( - sleeping?.agent === 'codex' ? sleeping.providerSession : undefined - ) + normalizeAgentProviderSession(sleeping?.agent === agent ? sleeping.providerSession : undefined) if (!providerSession) { - return CODEX_ACCOUNT_RESTART_STARTUP + return null } const projectRuntime = getLocalProjectExecutionRuntimeContext(state, args.worktreeId) if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { - return CODEX_ACCOUNT_RESTART_STARTUP + return null } const worktree = state.getKnownWorktreeById(args.worktreeId) const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null const launchConfig = (entry ? state.getAgentLaunchConfigForStatusEntry(entry) : undefined) ?? - (sleeping?.agent === 'codex' ? sleeping.launchConfig : undefined) + (sleeping?.agent === agent ? sleeping.launchConfig : undefined) const executionHostId = getExecutionHostIdForWorktree(state, args.worktreeId) const resumeTarget = resolveAgentResumeLaunchTarget({ projectRuntime, @@ -77,17 +97,17 @@ export function buildCodexAccountRestartStartup(args: { ) }) const startupPlan = buildAgentResumeStartupPlan({ - agent: 'codex', + agent, providerSession, cmdOverrides: state.settings?.agentCmdOverrides ?? {}, agentArgs: launchConfig !== undefined ? launchConfig.agentArgs - : resolveTuiAgentLaunchArgs('codex', state.settings?.agentDefaultArgs), + : resolveTuiAgentLaunchArgs(agent, state.settings?.agentDefaultArgs), agentEnv: launchConfig !== undefined ? launchConfig.agentEnv - : resolveTuiAgentLaunchEnv('codex', state.settings?.agentDefaultEnv), + : resolveTuiAgentLaunchEnv(agent, state.settings?.agentDefaultEnv), ...(launchConfig?.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}), ...(launchConfig?.ompResumeFilePath ? { ompResumeFilePath: launchConfig.ompResumeFilePath } @@ -96,10 +116,11 @@ export function buildCodexAccountRestartStartup(args: { shell: resumeTarget.shell }) if (!startupPlan) { - return CODEX_ACCOUNT_RESTART_STARTUP + return null } return { - ...CODEX_ACCOUNT_RESTART_STARTUP, + startupCommandDelivery: 'shell-ready', + launchAgent: agent, command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, diff --git a/src/shared/claude-account-global-settings.ts b/src/shared/claude-account-global-settings.ts new file mode 100644 index 000000000000..b841d09b0c30 --- /dev/null +++ b/src/shared/claude-account-global-settings.ts @@ -0,0 +1,14 @@ +import type { + ClaudeManagedAccount, + ClaudeManagedAccountRuntimeSelection +} from './managed-account-types' + +/** The Claude account settings, kept apart so the global settings type stays under its size cap. */ +export type ClaudeAccountGlobalSettings = { + /** Why: persist only per-account auth (not a CLAUDE_CONFIG_DIR swap) so switching accounts doesn't fork Claude's shared chat/session context. */ + claudeManagedAccounts: ClaudeManagedAccount[] + activeClaudeManagedAccountId: string | null + activeClaudeManagedAccountIdsByRuntime?: ClaudeManagedAccountRuntimeSelection + /** After an account switch, reopen idle Claude sessions on the selected account. Off by default. */ + claudeMoveIdleSessions?: boolean +} diff --git a/src/shared/claude-transcript-account.test.ts b/src/shared/claude-transcript-account.test.ts new file mode 100644 index 000000000000..6af980a47207 --- /dev/null +++ b/src/shared/claude-transcript-account.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { claudeAccountIdFromTranscriptPath } from './claude-transcript-account' + +describe('claudeAccountIdFromTranscriptPath', () => { + it('reads the managed account whose folder holds the transcript', () => { + expect( + claudeAccountIdFromTranscriptPath( + '/Users/me/Library/Application Support/orca/claude-profiles/acct-1/home/projects/-repo/s.jsonl' + ) + ).toBe('acct-1') + }) + + it('reads a Windows transcript path', () => { + expect( + claudeAccountIdFromTranscriptPath( + 'C:\\Users\\me\\AppData\\Roaming\\orca\\claude-profiles\\acct-2\\home\\projects\\-repo\\s.jsonl' + ) + ).toBe('acct-2') + }) + + it("reads the user's own folder as System default", () => { + expect(claudeAccountIdFromTranscriptPath('/Users/me/.claude/projects/-repo/s.jsonl')).toBeNull() + }) + + it('does not take a project that merely mentions claude-profiles for an account', () => { + expect( + claudeAccountIdFromTranscriptPath( + '/Users/me/.claude/projects/-Users-me-claude-profiles-acct-3-home/s.jsonl' + ) + ).toBeNull() + }) +}) diff --git a/src/shared/claude-transcript-account.ts b/src/shared/claude-transcript-account.ts new file mode 100644 index 000000000000..0c11b592137c --- /dev/null +++ b/src/shared/claude-transcript-account.ts @@ -0,0 +1,18 @@ +/** + * The managed account whose folder holds a Claude transcript, or null for the user's own folder + * (System default). Each account runs with CLAUDE_CONFIG_DIR at `/claude-profiles//home`, + * and Claude writes its transcripts under that folder's `projects/`. + */ +export function claudeAccountIdFromTranscriptPath(transcriptPath: string): string | null { + const segments = transcriptPath.split(/[\\/]/) + for (let index = 0; index + 3 < segments.length; index += 1) { + if ( + segments[index] === 'claude-profiles' && + segments[index + 2] === 'home' && + segments[index + 3] === 'projects' + ) { + return segments[index + 1] || null + } + } + return null +} diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 9a71e4342ac6..bbf3bd5ff078 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -184,6 +184,7 @@ export function buildDefaultSettings(args: { activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} }, claudeManagedAccounts: [], activeClaudeManagedAccountId: null, + claudeMoveIdleSessions: false, terminalScopeHistoryByWorktree: true, terminalHiddenViewParking: true, // C1 kill switches — runtime reads stay `!== false` so older persisted diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 5794026f9fc4..dd41063c25a5 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -1,5 +1,6 @@ import type { NativeChatGlobalSettings } from './native-chat-appearance-settings' import type { WorktreeDeletionPreferences as DeletePrefs } from './worktree-deletion-preferences' +import type { ClaudeAccountGlobalSettings } from './claude-account-global-settings' import type { ExecutionHostId } from './execution-host' import type { OrcaWorkspaceLayout } from './orca-workspace-layout' import type { GitHubProjectSettings } from './github/project-types' @@ -20,8 +21,6 @@ import type { ComputerAwakeMode } from './computer-awake-mode' import type { CommitMessageAiSettings } from './commit-message-ai-types' import type { HostSettingOverrides } from './host-setting-overrides' import type { - ClaudeManagedAccount, - ClaudeManagedAccountRuntimeSelection, CodexManagedAccount, CodexManagedAccountRuntimeSelection } from './managed-account-types' @@ -50,7 +49,7 @@ export type { WorktreeVisibilityDefaults } from './repo-types' /** MiniMax account region used to select the quota endpoint. */ export type MiniMaxEndpoint = 'overseas' | 'cn' -type GlobalSettingsBase = NativeChatGlobalSettings & DeletePrefs +type GlobalSettingsBase = NativeChatGlobalSettings & DeletePrefs & ClaudeAccountGlobalSettings export type GlobalSettings = GlobalSettingsBase & { workspaceDir: string @@ -322,10 +321,6 @@ export type GlobalSettings = GlobalSettingsBase & { codexManagedAccounts: CodexManagedAccount[] activeCodexManagedAccountId: string | null activeCodexManagedAccountIdsByRuntime?: CodexManagedAccountRuntimeSelection - /** Why: persist only per-account auth (not a CLAUDE_CONFIG_DIR swap) so switching accounts doesn't fork Claude's shared chat/session context. */ - claudeManagedAccounts: ClaudeManagedAccount[] - activeClaudeManagedAccountId: string | null - activeClaudeManagedAccountIdsByRuntime?: ClaudeManagedAccountRuntimeSelection /** Dismissed the "System default may hold a copied login" notice in Settings > Accounts. */ claudeCopiedSystemDefaultNoticeDismissed?: boolean /** Per-worktree shell history so ArrowUp doesn't surface other worktrees' commands (a HISTFILE for diff --git a/src/shared/managed-account-types.ts b/src/shared/managed-account-types.ts index 6f9cc642ce7e..fd1528f1ae5e 100644 --- a/src/shared/managed-account-types.ts +++ b/src/shared/managed-account-types.ts @@ -120,6 +120,8 @@ export type ClaudeRateLimitAccountsState = { activeAccountIdsByRuntime?: ClaudeManagedAccountRuntimeSelection /** The login System default holds, when the host can read it and accounts exist. */ systemDefaultEmail?: string + /** That login's organization, so one email in two organizations stays two logins. */ + systemDefaultOrganizationUuid?: string /** An older Orca's copy-based switching once wrote an account's login into System default. */ systemDefaultMayBeCopied?: boolean } diff --git a/tests/e2e/claude-session-move.spec.ts b/tests/e2e/claude-session-move.spec.ts new file mode 100644 index 000000000000..b333725735a6 --- /dev/null +++ b/tests/e2e/claude-session-move.spec.ts @@ -0,0 +1,109 @@ +import { expect, test } from './helpers/orca-app' +import { + configureGoldenStubAgent, + getGoldenStubAgentLaunchEnv, + launchGoldenStubAgentFromNewTab +} from './helpers/golden-stub-agent' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActivePanePtyId, waitForTerminalOutput } from './helpers/terminal' + +const SESSION_ID = 'e2e-claude-move-session' + +test.use({ launchEnv: getGoldenStubAgentLaunchEnv() }) + +test('moves a Claude session still on the old account to the selected one', async ({ + orcaPage +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + // A stand-in for Claude, launched the way Orca launches Claude, so the pane really runs it. + await configureGoldenStubAgent(orcaPage, { agent: 'claude' }) + await launchGoldenStubAgentFromNewTab(orcaPage, /^Claude(?:\s|$)/i) + const ptyId = await waitForActivePanePtyId(orcaPage) + + // Two saved accounts with B selected, the way a switch leaves them. + await orcaPage.evaluate(async () => { + const account = (id: string, email: string) => ({ + id, + email, + managedAuthPath: '', + managedAuthRuntime: 'host' as const, + wslDistro: null, + authMethod: 'subscription-oauth' as const, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }) + const settings = await window.api.settings.set({ + claudeManagedAccounts: [ + account('acct-a', 'a@example.test'), + account('acct-b', 'b@example.test') + ], + activeClaudeManagedAccountId: 'acct-b', + activeClaudeManagedAccountIdsByRuntime: { host: 'acct-b', wsl: {} } + }) + window.__store?.setState({ settings }) + }) + + // The open pane plays a Claude session that started on A and sits idle at its prompt. + await orcaPage.evaluate( + ({ ptyId, sessionId }) => { + const store = window.__store + if (!store) { + throw new Error('No store') + } + const state = store.getState() + const tabId = Object.entries(state.ptyIdsByTabId).find(([, ids]) => ids?.includes(ptyId))?.[0] + const ptyIdsByLeafId = tabId ? state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId : null + const leafId = Object.entries(ptyIdsByLeafId ?? {}).find(([, id]) => id === ptyId)?.[0] + const tab = Object.values(state.tabsByWorktree) + .flat() + .find((candidate) => candidate.id === tabId) + if (!tabId || !leafId || !tab) { + throw new Error('Active pane is not bound to a tab') + } + const paneKey = `${tabId}:${leafId}` + const idleSince = Date.now() - 120_000 + store.setState({ + agentStatusByPaneKey: { + ...state.agentStatusByPaneKey, + [paneKey]: { + paneKey, + tabId, + worktreeId: tab.worktreeId, + state: 'done', + prompt: 'ship it', + updatedAt: idleSince, + stateStartedAt: idleSince, + stateHistory: [], + agentType: 'claude', + providerSession: { + key: 'session_id', + id: sessionId, + transcriptPath: `/e2e/claude-profiles/acct-a/home/projects/-e2e/${sessionId}.jsonl` + } + } + } + }) + }, + { ptyId, sessionId: SESSION_ID } + ) + + // The Claude switcher opens from its row in the status bar's usage panel. + await orcaPage.getByRole('button', { name: 'Usage', exact: true }).click() + await orcaPage + .getByRole('menuitem', { name: /Claude/ }) + .first() + .click() + await expect(orcaPage.getByText('1 Claude session is still on the old account.')).toBeVisible() + await orcaPage.screenshot({ path: testInfo.outputPath('claude-session-move-prompt.png') }) + await orcaPage.getByRole('button', { name: 'Move Session', exact: true }).click() + + // The pane comes back in a new process that resumes the same conversation, and the menu stops + // counting it. + await waitForTerminalOutput(orcaPage, "'--resume'", 20_000) + await expect.poll(() => waitForActivePanePtyId(orcaPage), { timeout: 20_000 }).not.toBe(ptyId) + await expect(orcaPage.getByText('1 Claude session is still on the old account.')).toHaveCount(0) + await orcaPage.screenshot({ path: testInfo.outputPath('claude-session-moved.png') }) +})