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') })
+})