From 53b1fb8dc14574b5d58c3528f91ef982c1fca930 Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:51:42 +0200 Subject: [PATCH 01/14] feat(claude-accounts): move running Claude sessions to the selected account After an account switch, a running Claude kept the account it started on (#19900). The Claude account menu now says how many sessions are still on the old account and offers Move Sessions, the way Codex offers Restart Sessions. Each one reopens its conversation with claude --resume once it is idle, so nothing it was doing, and nothing typed but not sent, is lost. A new setting, off by default, does that on its own for sessions idle for a minute in a tab you are not looking at. A session's account is read from where its transcript lives, and compared by login, so an account served by the System default login, or two folders holding one login, never cause a move. Safe to move is hibernation's own test for a pane it may stop, which this lets take a turn that ended on an error or a Stop: that is how a usage limit ends one. Moves restart only mounted panes and never join the Codex restart queue. --- docs/site/content/docs/agents/claude-code.mdx | 2 + .../src/app-shell/AppBackgroundServices.tsx | 2 + .../src/components/ClaudeSessionMoveGate.tsx | 8 + .../settings/accounts-claude-search.ts | 27 ++ .../settings/accounts-pane-claude-section.tsx | 2 + ...ccounts-pane-claude-session-moves.test.tsx | 60 +++++ .../accounts-pane-claude-session-moves.tsx | 43 ++++ .../components/settings/accounts-search.ts | 25 +- .../status-bar/ClaudeSwitcherMenu.tsx | 2 + .../claude-session-move-prompt.test.tsx | 44 ++++ .../status-bar/claude-session-move-prompt.tsx | 70 ++++++ .../pane-account-move-restarts.ts | 27 ++ .../use-terminal-pane-process-exit-actions.ts | 21 ++ src/renderer/src/i18n/locales/en.json | 13 +- .../lib/agent-hibernation-pane-eligibility.ts | 16 +- .../claude-session-move-coordinator.test.ts | 226 +++++++++++++++++ .../lib/claude-session-move-coordinator.ts | 192 +++++++++++++++ .../src/lib/claude-session-move.test.ts | 230 ++++++++++++++++++ src/renderer/src/lib/claude-session-move.ts | 147 +++++++++++ .../lib/codex-account-restart-startup.test.ts | 64 ++++- .../src/lib/codex-account-restart-startup.ts | 57 +++-- src/shared/claude-account-global-settings.ts | 13 + src/shared/claude-transcript-account.test.ts | 32 +++ src/shared/claude-transcript-account.ts | 18 ++ src/shared/default-global-settings.ts | 1 + src/shared/global-settings-types.ts | 8 +- 26 files changed, 1300 insertions(+), 50 deletions(-) create mode 100644 src/renderer/src/components/ClaudeSessionMoveGate.tsx create mode 100644 src/renderer/src/components/settings/accounts-claude-search.ts create mode 100644 src/renderer/src/components/settings/accounts-pane-claude-session-moves.test.tsx create mode 100644 src/renderer/src/components/settings/accounts-pane-claude-session-moves.tsx create mode 100644 src/renderer/src/components/status-bar/claude-session-move-prompt.test.tsx create mode 100644 src/renderer/src/components/status-bar/claude-session-move-prompt.tsx create mode 100644 src/renderer/src/components/terminal-pane/pane-account-move-restarts.ts create mode 100644 src/renderer/src/lib/claude-session-move-coordinator.test.ts create mode 100644 src/renderer/src/lib/claude-session-move-coordinator.ts create mode 100644 src/renderer/src/lib/claude-session-move.test.ts create mode 100644 src/renderer/src/lib/claude-session-move.ts create mode 100644 src/shared/claude-account-global-settings.ts create mode 100644 src/shared/claude-transcript-account.test.ts create mode 100644 src/shared/claude-transcript-account.ts diff --git a/docs/site/content/docs/agents/claude-code.mdx b/docs/site/content/docs/agents/claude-code.mdx index a0d733abd4a6..12f75e5b2dbe 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, keeping anything you typed but did not send. **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/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/accounts-claude-search.ts b/src/renderer/src/components/settings/accounts-claude-search.ts new file mode 100644 index 000000000000..c24af1841531 --- /dev/null +++ b/src/renderer/src/components/settings/accounts-claude-search.ts @@ -0,0 +1,27 @@ +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'), + ...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.ts b/src/renderer/src/components/settings/accounts-search.ts index 948f258323f3..ce7465d012b3 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -2,6 +2,9 @@ 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 { getAccountsClaudeSearchEntries } export const getAccountsLocationSearchEntries = createLocalizedCatalog(() => [ { @@ -22,28 +25,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..263b04b85ffa --- /dev/null +++ b/src/renderer/src/components/status-bar/claude-session-move-prompt.tsx @@ -0,0 +1,70 @@ +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 { 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 selection = useAppStore((s) => s.settings?.activeClaudeManagedAccountIdsByRuntime) + const [accountsRead, setAccountsRead] = useState(0) + useEffect(() => { + let current = true + void refreshClaudeMoveAccounts().then(() => { + if (current) { + setAccountsRead((count) => count + 1) + } + }) + return () => { + current = false + } + }, [selection]) + 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.' + )} + +
+ +
+ + ) +} 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..5d4a57200847 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pane-account-move-restarts.ts @@ -0,0 +1,27 @@ +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) + } + } +} + +/** 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..1d22ea3a7863 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.", + "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..081a594c08bb --- /dev/null +++ b/src/renderer/src/lib/claude-session-move-coordinator.test.ts @@ -0,0 +1,226 @@ +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, + moveClaudeSessionsToSelectedAccount, + resetClaudeSessionMoveForTests +} from './claude-session-move-coordinator' + +const restartPaneForAccountMove = vi.hoisted(() => vi.fn(() => true)) +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 +})) +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' + +let roster: ClaudeRateLimitAccountsState +const list = vi.fn(async () => 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', + 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 + } + } + }) +} + +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() + 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' }) + ) + }) + + 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('leaves a session it cannot resume', async () => { + buildClaudeSessionMoveStartup.mockReturnValueOnce(null) + seed({}) + + await moveClaudeSessionsToSelectedAccount() + + expect(restartPaneForAccountMove).not.toHaveBeenCalled() + }) +}) 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..21ea887b7791 --- /dev/null +++ b/src/renderer/src/lib/claude-session-move-coordinator.ts @@ -0,0 +1,192 @@ +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { restartPaneForAccountMove } from '@/components/terminal-pane/pane-account-move-restarts' +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 { + findClaudeSessionsOnOtherLogin, + isClaudeSessionMovableNow, + 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 { 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() +/** `session->login` pairs already moved, or given up on because they cannot be resumed. */ +const settledMoves = new Set() +let accounts: ClaudeRateLimitAccountsState | null = null + +export function resetClaudeSessionMoveForTests(): void { + approvedPaneKeys.clear() + settledMoves.clear() + accounts = null +} + +// 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 { + accounts = await window.api.claudeAccounts.list().catch(() => accounts) +} + +/** Claude sessions on this machine still running on a login other than the selected one. */ +export function listClaudeSessionsOnOtherLogin(state: AppState): ClaudeSessionOnOtherLogin[] { + return accounts + ? findClaudeSessionsOnOtherLogin(state, accounts, (worktreeId) => + isOnThisHost(state, worktreeId) + ) + : [] +} + +/** Moves every session on another login, each one as soon as it can go without losing anything. */ +export async function moveClaudeSessionsToSelectedAccount(): Promise { + await refreshClaudeMoveAccounts() + for (const session of listClaudeSessionsOnOtherLogin(useAppStore.getState())) { + approvedPaneKeys.add(session.paneKey) + } + movePanes() +} + +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() + } +} + +/** Moves what can go now and returns how many sessions still wait their turn. */ +function movePanes(): number { + if (!accounts) { + return 0 + } + const state = useAppStore.getState() + // Why: input stamps are coalesced, so unsent text must include the not-yet-flushed keystroke. + const view = { + ...state, + lastTerminalInputAtByPaneKey: mergePendingTerminalInputActivity( + state.lastTerminalInputAtByPaneKey + ) + } + const sessions = findClaudeSessionsOnOtherLogin(view, accounts, (worktreeId) => + isOnThisHost(state, worktreeId) + ) + const stalePaneKeys = 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 (!stalePaneKeys.has(paneKey)) { + approvedPaneKeys.delete(paneKey) + } + } + const automatic = state.settings?.claudeMoveIdleSessions === true + let waiting = 0 + for (const session of sessions) { + const moveKey = `${session.sessionId}->${session.targetLogin}` + const approved = approvedPaneKeys.has(session.paneKey) + if (settledMoves.has(moveKey) || (!approved && !automatic)) { + continue + } + if (!isClaudeSessionMovableNow(view, session, gatesFor(approved))) { + waiting += 1 + continue + } + const startup = buildClaudeSessionMoveStartup({ + tabId: session.tabId, + leafId: session.leafId, + worktreeId: session.worktreeId, + shellOverride: state.tabsByWorktree[session.worktreeId]?.find( + (tab) => tab.id === session.tabId + )?.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 || restartPaneForAccountMove(session.ptyId, startup)) { + settledMoves.add(moveKey) + approvedPaneKeys.delete(session.paneKey) + } 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() + } + // Why recheck while any wait: idle time runs out without a store change to wake us. + if (movePanes() > 0) { + schedule(RECHECK_MS) + } + } + const unsubscribe = useAppStore.subscribe((state, previous) => { + const settings = state.settings + const before = previous.settings + if ( + settings?.activeClaudeManagedAccountId !== before?.activeClaudeManagedAccountId || + settings?.activeClaudeManagedAccountIdsByRuntime !== + before?.activeClaudeManagedAccountIdsByRuntime || + settings?.claudeManagedAccounts !== before?.claudeManagedAccounts + ) { + 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) + } +} 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..44a60a6c37a4 --- /dev/null +++ b/src/renderer/src/lib/claude-session-move.test.ts @@ -0,0 +1,230 @@ +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, + isClaudeSessionMovableNow, + 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', + sessionId: 'session-1', + 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('skips what it cannot place', () => { + const unplaced = [ + claudeRow(onA, { agentType: 'codex' }), + claudeRow(undefined), + claudeRow(`${PROFILES}/acct-removed/home/projects/-repo/session-1.jsonl`) + ] + 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) + }) +}) 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..8b8360fea86f --- /dev/null +++ b/src/renderer/src/lib/claude-session-move.ts @@ -0,0 +1,147 @@ +import type { AppState } from '@/store' +import { claudeAccountIdFromTranscriptPath } from '../../../shared/claude-transcript-account' +import type { ClaudeRateLimitAccountsState } from '../../../shared/managed-account-types' +import { isRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' +import { makePaneKey } from '../../../shared/stable-pane-id' +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 + sessionId: string + /** The selected account's login, 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 +} + +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 } : 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 login = loginOf( + accounts, + claudeAccountIdFromTranscriptPath(providerSession.transcriptPath) + ) + if (!login || isSameLogin(login, selected)) { + continue + } + sessions.push({ + paneKey, + tabId: tab.id, + leafId, + ptyId, + worktreeId, + sessionId: providerSession.id, + targetLogin: selected.email + }) + } + } + } + return sessions +} + +/** 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..362a310980eb --- /dev/null +++ b/src/shared/claude-account-global-settings.ts @@ -0,0 +1,13 @@ +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 = { + 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..65ba2b923357 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 @@ -323,9 +322,6 @@ export type GlobalSettings = GlobalSettingsBase & { 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 From e22ee286bf9dd1ea8cb68d380c78fb16a751f88d Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:57:41 +0200 Subject: [PATCH 02/14] test(claude-accounts): drive a session move end to end, and stop counting moved sessions The e2e spec seeds two accounts with B selected and a Claude session that started on A, opens the Claude menu from the usage panel, moves the session, and checks that the pane relaunches with claude --resume and that the menu no longer counts it. A moved session reads as the old account until it reports again, so the menu now leaves settled sessions out. --- .../claude-session-move-coordinator.test.ts | 3 + .../lib/claude-session-move-coordinator.ts | 11 ++- tests/e2e/claude-session-move.spec.ts | 99 +++++++++++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/claude-session-move.spec.ts diff --git a/src/renderer/src/lib/claude-session-move-coordinator.test.ts b/src/renderer/src/lib/claude-session-move-coordinator.test.ts index 081a594c08bb..9edadca8bb8a 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.test.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.test.ts @@ -5,6 +5,7 @@ import { getDefaultSettings } from '../../../shared/constants' import { useAppStore } from '@/store' import { installClaudeSessionMoveCoordinator, + listClaudeSessionsOnOtherLogin, moveClaudeSessionsToSelectedAccount, resetClaudeSessionMoveForTests } from './claude-session-move-coordinator' @@ -158,6 +159,8 @@ describe('Claude session moves', () => { '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 () => { diff --git a/src/renderer/src/lib/claude-session-move-coordinator.ts b/src/renderer/src/lib/claude-session-move-coordinator.ts index 21ea887b7791..dafe1bb13d92 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.ts @@ -56,12 +56,17 @@ export async function refreshClaudeMoveAccounts(): Promise { accounts = await window.api.claudeAccounts.list().catch(() => accounts) } -/** Claude sessions on this machine still running on a login other than the selected one. */ +const moveKeyOf = (session: ClaudeSessionOnOtherLogin): string => + `${session.sessionId}->${session.targetLogin}` + +/** 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) => !settledMoves.has(moveKeyOf(session))) : [] } @@ -121,7 +126,7 @@ function movePanes(): number { const automatic = state.settings?.claudeMoveIdleSessions === true let waiting = 0 for (const session of sessions) { - const moveKey = `${session.sessionId}->${session.targetLogin}` + const moveKey = moveKeyOf(session) const approved = approvedPaneKeys.has(session.paneKey) if (settledMoves.has(moveKey) || (!approved && !automatic)) { continue diff --git a/tests/e2e/claude-session-move.spec.ts b/tests/e2e/claude-session-move.spec.ts new file mode 100644 index 000000000000..022cb8eba4ac --- /dev/null +++ b/tests/e2e/claude-session-move.spec.ts @@ -0,0 +1,99 @@ +import { expect, test } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActivePanePtyId, waitForTerminalOutput } from './helpers/terminal' + +const SESSION_ID = 'e2e-claude-move-session' + +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) + 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') }) +}) From da726b80d3486e0174c5ac9d31bb5be7b8272d87 Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:11:44 +0200 Subject: [PATCH 03/14] fix(claude-accounts): move only a pane that still runs Claude, and tell logins apart by organization - Ask the pane's process before a restart, so a Claude inside tmux, or a pane back at the shell, is left alone instead of having its outer terminal restarted. - Report System default's organization with its email, so one email in two organizations stays two logins. - Hold a moved session only until it reports again, so A to B to A to B moves each time. - Forget the account list when reading it fails, and try again, instead of acting on the old one. - Retry a session you asked to move when its pane was not ready, with no store change needed. - Give the setting its own search entry. --- .../claude-account-selection.ts | 3 + .../claude-account-service.test.ts | 7 ++- .../settings/accounts-claude-search.ts | 15 ++++- .../settings/accounts-search.test.ts | 11 ++++ .../claude-session-move-coordinator.test.ts | 62 ++++++++++++++++++- .../lib/claude-session-move-coordinator.ts | 62 ++++++++++++++----- .../src/lib/claude-session-move.test.ts | 22 ++++++- src/renderer/src/lib/claude-session-move.ts | 14 ++++- src/shared/managed-account-types.ts | 2 + 9 files changed, 176 insertions(+), 22 deletions(-) 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/components/settings/accounts-claude-search.ts b/src/renderer/src/components/settings/accounts-claude-search.ts index c24af1841531..1625250af309 100644 --- a/src/renderer/src/components/settings/accounts-claude-search.ts +++ b/src/renderer/src/components/settings/accounts-claude-search.ts @@ -19,7 +19,20 @@ export const getAccountsClaudeSearchEntries = createLocalizedCatalog(() => [ 'status bar' ), ...translateSearchKeyword('auto.components.settings.accounts.search.c759741d77', 'quota'), - ...translateSearchKeyword('auto.components.settings.accounts.search.f2d666a886', 'optional'), + ...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-search.test.ts b/src/renderer/src/components/settings/accounts-search.test.ts index 4931510b1b92..e9e00c7c5462 100644 --- a/src/renderer/src/components/settings/accounts-search.test.ts +++ b/src/renderer/src/components/settings/accounts-search.test.ts @@ -16,6 +16,7 @@ vi.mock('./settings-search-keywords', () => ({ })) import { + getAccountsClaudeSearchEntries, getAccountsMiniMaxSearchEntries, getAccountsOpencodeSearchEntries, getAccountsPaneSearchEntries @@ -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/lib/claude-session-move-coordinator.test.ts b/src/renderer/src/lib/claude-session-move-coordinator.test.ts index 9edadca8bb8a..fd4801559c28 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.test.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.test.ts @@ -11,6 +11,13 @@ import { } from './claude-session-move-coordinator' const restartPaneForAccountMove = vi.hoisted(() => vi.fn(() => true)) +const foreground = vi.hoisted(() => ({ process: 'claude' })) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + inspectRuntimeTerminalProcess: vi.fn(async () => ({ + foregroundProcess: foreground.process, + hasChildProcesses: true + })) +})) type MoveStartup = { command: string; startupCommandDelivery: 'shell-ready'; launchAgent: 'claude' } const buildClaudeSessionMoveStartup = vi.hoisted(() => vi.fn((): MoveStartup | null => ({ @@ -31,9 +38,10 @@ 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 () => roster) +const list = vi.fn(async (): Promise => roster) function selected(accountId: string): ClaudeRateLimitAccountsState { return { @@ -140,6 +148,7 @@ describe('Claude session moves', () => { vi.stubGlobal('window', { ...globalThis.window, api: { claudeAccounts: { list } } }) roster = selected('acct-b') restartPaneForAccountMove.mockClear() + foreground.process = 'claude' buildClaudeSessionMoveStartup.mockClear() resetClaudeSessionMoveForTests() }) @@ -226,4 +235,55 @@ describe('Claude session moves', () => { expect(restartPaneForAccountMove).not.toHaveBeenCalled() }) + + 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('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) + }) }) diff --git a/src/renderer/src/lib/claude-session-move-coordinator.ts b/src/renderer/src/lib/claude-session-move-coordinator.ts index dafe1bb13d92..ce2b3c11ac7c 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.ts @@ -1,6 +1,8 @@ import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { restartPaneForAccountMove } from '@/components/terminal-pane/pane-account-move-restarts' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' +import { recognizeAgentProcess } from '../../../shared/agent-process-recognition' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { ClaudeRateLimitAccountsState } from '../../../shared/managed-account-types' @@ -32,9 +34,10 @@ const RECHECK_MS = 15_000 /** Sessions you asked to move that were still busy, by pane. */ const approvedPaneKeys = new Set() -/** `session->login` pairs already moved, or given up on because they cannot be resumed. */ -const settledMoves = new Set() +/** Panes already moved, or left alone, toward a login; a new report from the session lifts it. */ +const settledMoves = new Map() let accounts: ClaudeRateLimitAccountsState | null = null +let wakeRetry: (() => void) | null = null export function resetClaudeSessionMoveForTests(): void { approvedPaneKeys.clear() @@ -53,11 +56,32 @@ function isOnThisHost(state: AppState, worktreeId: string): boolean { /** Re-reads this host's Claude accounts, which name the selected login. */ export async function refreshClaudeMoveAccounts(): Promise { - accounts = await window.api.claudeAccounts.list().catch(() => accounts) + // Why forget on failure: the old list may name the account we just switched away from. + accounts = await window.api.claudeAccounts.list().catch(() => null) } -const moveKeyOf = (session: ClaudeSessionOnOtherLogin): string => - `${session.sessionId}->${session.targetLogin}` +function isSettled(session: ClaudeSessionOnOtherLogin): boolean { + const settled = settledMoves.get(session.paneKey) + return ( + settled?.transcriptPath === session.transcriptPath && + settled.targetLogin === session.targetLogin + ) +} + +function settle(session: ClaudeSessionOnOtherLogin): void { + settledMoves.set(session.paneKey, { + transcriptPath: session.transcriptPath, + targetLogin: session.targetLogin + }) + approvedPaneKeys.delete(session.paneKey) +} + +// 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. +async function isRunningClaude(ptyId: string): Promise { + const inspection = await inspectRuntimeTerminalProcess(ptyId).catch(() => null) + return recognizeAgentProcess(inspection?.foregroundProcess)?.agent === 'claude' +} /** Claude sessions on this machine still to move to the selected login. */ export function listClaudeSessionsOnOtherLogin(state: AppState): ClaudeSessionOnOtherLogin[] { @@ -66,7 +90,7 @@ export function listClaudeSessionsOnOtherLogin(state: AppState): ClaudeSessionOn return accounts ? findClaudeSessionsOnOtherLogin(state, accounts, (worktreeId) => isOnThisHost(state, worktreeId) - ).filter((session) => !settledMoves.has(moveKeyOf(session))) + ).filter((session) => !isSettled(session)) : [] } @@ -76,7 +100,10 @@ export async function moveClaudeSessionsToSelectedAccount(): Promise { for (const session of listClaudeSessionsOnOtherLogin(useAppStore.getState())) { approvedPaneKeys.add(session.paneKey) } - movePanes() + // Why wake the retry: a pane that cannot go yet may free up with no store change to notice it. + if ((await movePanes()) > 0) { + wakeRetry?.() + } } function gatesFor(approved: boolean): ClaudeSessionMoveGates { @@ -101,7 +128,7 @@ function gatesFor(approved: boolean): ClaudeSessionMoveGates { } /** Moves what can go now and returns how many sessions still wait their turn. */ -function movePanes(): number { +async function movePanes(): Promise { if (!accounts) { return 0 } @@ -126,15 +153,18 @@ function movePanes(): number { const automatic = state.settings?.claudeMoveIdleSessions === true let waiting = 0 for (const session of sessions) { - const moveKey = moveKeyOf(session) const approved = approvedPaneKeys.has(session.paneKey) - if (settledMoves.has(moveKey) || (!approved && !automatic)) { + if (isSettled(session) || (!approved && !automatic)) { continue } if (!isClaudeSessionMovableNow(view, session, gatesFor(approved))) { waiting += 1 continue } + if (!(await isRunningClaude(session.ptyId))) { + settle(session) + continue + } const startup = buildClaudeSessionMoveStartup({ tabId: session.tabId, leafId: session.leafId, @@ -146,8 +176,7 @@ function movePanes(): number { // 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 || restartPaneForAccountMove(session.ptyId, startup)) { - settledMoves.add(moveKey) - approvedPaneKeys.delete(session.paneKey) + settle(session) } else { waiting += 1 } @@ -165,11 +194,15 @@ export function installClaudeSessionMoveCoordinator(): () => void { if (!accounts) { await refreshClaudeMoveAccounts() } - // Why recheck while any wait: idle time runs out without a store change to wake us. - if (movePanes() > 0) { + const pendingWork = + 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 @@ -193,5 +226,6 @@ export function installClaudeSessionMoveCoordinator(): () => void { 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 index 44a60a6c37a4..b26e00478132 100644 --- a/src/renderer/src/lib/claude-session-move.test.ts +++ b/src/renderer/src/lib/claude-session-move.test.ts @@ -127,7 +127,8 @@ describe('findClaudeSessionsOnOtherLogin', () => { ptyId: 'pty-1', worktreeId: 'wt-1', sessionId: 'session-1', - targetLogin: 'b@example.test' + transcriptPath: onA, + targetLogin: 'b@example.test/' } ]) }) @@ -159,6 +160,25 @@ describe('findClaudeSessionsOnOtherLogin', () => { ).toEqual([]) }) + 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' }), diff --git a/src/renderer/src/lib/claude-session-move.ts b/src/renderer/src/lib/claude-session-move.ts index 8b8360fea86f..341f1bfd0ac5 100644 --- a/src/renderer/src/lib/claude-session-move.ts +++ b/src/renderer/src/lib/claude-session-move.ts @@ -22,7 +22,9 @@ export type ClaudeSessionOnOtherLogin = { ptyId: string worktreeId: string sessionId: string - /** The selected account's login, so a move is recorded against where it went. */ + /** 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 } @@ -52,7 +54,12 @@ function loginOf( accountId: string | null ): ClaudeLogin | null { if (accountId === null) { - return accounts.systemDefaultEmail ? { email: accounts.systemDefaultEmail } : 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 @@ -104,7 +111,8 @@ export function findClaudeSessionsOnOtherLogin( ptyId, worktreeId, sessionId: providerSession.id, - targetLogin: selected.email + transcriptPath: providerSession.transcriptPath, + targetLogin: `${selected.email.toLowerCase()}/${selected.organizationUuid ?? ''}` }) } } 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 } From 0204303409a0b2106cac8a9a6cda38348685c1e9 Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:14:11 +0200 Subject: [PATCH 04/14] fix(claude-accounts): recognize Claude in a pane the way Codex's restart prompt does A Claude installed through npm runs under node, so a name-only check skipped it. A pane now counts as running Claude when its process is Claude, or when the tab launched Claude and an agent wrapper runs there; tmux, the shell or another program still means leave it alone. The e2e spec launches Orca's stub agent as Claude, so it no longer needs a real claude. --- .../lib/claude-session-move-coordinator.ts | 21 +++++------ .../src/lib/claude-session-move.test.ts | 24 ++++++++++++ src/renderer/src/lib/claude-session-move.ts | 37 +++++++++++++++++++ tests/e2e/claude-session-move.spec.ts | 10 +++++ 4 files changed, 80 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/lib/claude-session-move-coordinator.ts b/src/renderer/src/lib/claude-session-move-coordinator.ts index ce2b3c11ac7c..4043d142c031 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.ts @@ -2,7 +2,6 @@ import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { restartPaneForAccountMove } from '@/components/terminal-pane/pane-account-move-restarts' import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' -import { recognizeAgentProcess } from '../../../shared/agent-process-recognition' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { ClaudeRateLimitAccountsState } from '../../../shared/managed-account-types' @@ -14,6 +13,7 @@ import { import { buildClaudeSessionMoveStartup } from './codex-account-restart-startup' import { findClaudeSessionsOnOtherLogin, + isClaudeRunningInPane, isClaudeSessionMovableNow, type ClaudeSessionMoveGates, type ClaudeSessionOnOtherLogin @@ -76,13 +76,6 @@ function settle(session: ClaudeSessionOnOtherLogin): void { approvedPaneKeys.delete(session.paneKey) } -// 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. -async function isRunningClaude(ptyId: string): Promise { - const inspection = await inspectRuntimeTerminalProcess(ptyId).catch(() => null) - return recognizeAgentProcess(inspection?.foregroundProcess)?.agent === 'claude' -} - /** 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 @@ -161,7 +154,13 @@ async function movePanes(): Promise { waiting += 1 continue } - if (!(await isRunningClaude(session.ptyId))) { + 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) + if (!isClaudeRunningInPane(inspection, tab?.launchAgent)) { settle(session) continue } @@ -169,9 +168,7 @@ async function movePanes(): Promise { tabId: session.tabId, leafId: session.leafId, worktreeId: session.worktreeId, - shellOverride: state.tabsByWorktree[session.worktreeId]?.find( - (tab) => tab.id === session.tabId - )?.shellOverride + 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. diff --git a/src/renderer/src/lib/claude-session-move.test.ts b/src/renderer/src/lib/claude-session-move.test.ts index b26e00478132..b8aaf0b25cc2 100644 --- a/src/renderer/src/lib/claude-session-move.test.ts +++ b/src/renderer/src/lib/claude-session-move.test.ts @@ -7,6 +7,7 @@ import type { import type { TerminalTab } from '../../../shared/terminal-tab-types' import { findClaudeSessionsOnOtherLogin, + isClaudeRunningInPane, isClaudeSessionMovableNow, type ClaudeSessionMoveGates, type ClaudeSessionMoveView @@ -248,3 +249,26 @@ describe('isClaudeSessionMovableNow', () => { 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 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) + }) +}) diff --git a/src/renderer/src/lib/claude-session-move.ts b/src/renderer/src/lib/claude-session-move.ts index 341f1bfd0ac5..c09a8f829a77 100644 --- a/src/renderer/src/lib/claude-session-move.ts +++ b/src/renderer/src/lib/claude-session-move.ts @@ -1,8 +1,16 @@ import type { AppState } from '@/store' +import 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< @@ -120,6 +128,35 @@ export function findClaudeSessionsOnOtherLogin( return sessions } +/** + * Whether Claude is what runs in the pane, the test Codex's restart prompt uses: 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 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 + } + return ( + !isShellProcess(foregroundProcess) && + (recognizeAgentProcess(foregroundProcess) !== null || + isAgentForegroundWrapperProcess(foregroundProcess)) + ) +} + /** Whether restarting the session now loses nothing: hibernation's own test for a pane it may stop. */ export function isClaudeSessionMovableNow( view: ClaudeSessionMoveView, diff --git a/tests/e2e/claude-session-move.spec.ts b/tests/e2e/claude-session-move.spec.ts index 022cb8eba4ac..b333725735a6 100644 --- a/tests/e2e/claude-session-move.spec.ts +++ b/tests/e2e/claude-session-move.spec.ts @@ -1,15 +1,25 @@ 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. From 7e62b8010909e6afbb6deb8370774227148e35b7 Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:28:08 +0200 Subject: [PATCH 05/14] fix(claude-accounts): offer sessions on a removed account, and keep only the latest account list - A session whose account was removed is offered a move: no saved login is served from its folder any more, so it cannot be on the selected one. - When account-list replies to a quick A, B, A arrive out of order, only the latest one is kept. - The menu's hint says unsent text holds a move too. - The Claude accounts comment moved with its field, and AccountsPane imports the Claude search catalog directly. --- .../src/components/settings/AccountsPane.tsx | 2 +- .../components/settings/accounts-search.test.ts | 2 +- .../src/components/settings/accounts-search.ts | 2 -- .../status-bar/claude-session-move-prompt.tsx | 2 +- src/renderer/src/i18n/locales/en.json | 2 +- .../lib/claude-session-move-coordinator.test.ts | 17 +++++++++++++++++ .../src/lib/claude-session-move-coordinator.ts | 8 +++++++- .../src/lib/claude-session-move.test.ts | 13 ++++++++----- src/renderer/src/lib/claude-session-move.ts | 11 ++++++----- src/shared/claude-account-global-settings.ts | 1 + src/shared/global-settings-types.ts | 1 - 11 files changed, 43 insertions(+), 18 deletions(-) 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-search.test.ts b/src/renderer/src/components/settings/accounts-search.test.ts index e9e00c7c5462..ca38dc4d8954 100644 --- a/src/renderer/src/components/settings/accounts-search.test.ts +++ b/src/renderer/src/components/settings/accounts-search.test.ts @@ -15,8 +15,8 @@ vi.mock('./settings-search-keywords', () => ({ translateSearchKeyword: (_key: string, fallback: string) => [fallback] })) +import { getAccountsClaudeSearchEntries } from './accounts-claude-search' import { - getAccountsClaudeSearchEntries, getAccountsMiniMaxSearchEntries, getAccountsOpencodeSearchEntries, getAccountsPaneSearchEntries diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index ce7465d012b3..c31998ead112 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -4,8 +4,6 @@ import { translateSearchKeyword } from './settings-search-keywords' import { createLocalizedCatalog } from '@/i18n/localized-catalog' import { getAccountsClaudeSearchEntries } from './accounts-claude-search' -export { getAccountsClaudeSearchEntries } - export const getAccountsLocationSearchEntries = createLocalizedCatalog(() => [ { title: translate('auto.components.settings.accounts.search.d09fb5ca92', 'Account Location'), 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 index 263b04b85ffa..055fc0448616 100644 --- a/src/renderer/src/components/status-bar/claude-session-move-prompt.tsx +++ b/src/renderer/src/components/status-bar/claude-session-move-prompt.tsx @@ -49,7 +49,7 @@ export function ClaudeSessionMovePrompt(): React.JSX.Element | null { {translate( 'accounts.claude.moveSessionsHint', - 'Each one reopens its conversation on the selected account once it finishes its turn.' + '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/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1d22ea3a7863..01ae72a591c5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19907,7 +19907,7 @@ "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.", + "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", diff --git a/src/renderer/src/lib/claude-session-move-coordinator.test.ts b/src/renderer/src/lib/claude-session-move-coordinator.test.ts index fd4801559c28..cfaf2bc2b84f 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.test.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.test.ts @@ -276,6 +276,23 @@ describe('Claude session moves', () => { 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({}) diff --git a/src/renderer/src/lib/claude-session-move-coordinator.ts b/src/renderer/src/lib/claude-session-move-coordinator.ts index 4043d142c031..58dd5dcd3f47 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.ts @@ -37,6 +37,7 @@ 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() let accounts: ClaudeRateLimitAccountsState | null = null +let accountsRead = 0 let wakeRetry: (() => void) | null = null export function resetClaudeSessionMoveForTests(): void { @@ -56,8 +57,13 @@ function isOnThisHost(state: AppState, worktreeId: string): boolean { /** Re-reads this host's Claude accounts, which name the selected login. */ export async function refreshClaudeMoveAccounts(): Promise { + const read = ++accountsRead // Why forget on failure: the old list may name the account we just switched away from. - accounts = await window.api.claudeAccounts.list().catch(() => null) + 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 { diff --git a/src/renderer/src/lib/claude-session-move.test.ts b/src/renderer/src/lib/claude-session-move.test.ts index b8aaf0b25cc2..6e0f18ff04f4 100644 --- a/src/renderer/src/lib/claude-session-move.test.ts +++ b/src/renderer/src/lib/claude-session-move.test.ts @@ -161,6 +161,13 @@ describe('findClaudeSessionsOnOtherLogin', () => { ).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'), @@ -181,11 +188,7 @@ describe('findClaudeSessionsOnOtherLogin', () => { }) it('skips what it cannot place', () => { - const unplaced = [ - claudeRow(onA, { agentType: 'codex' }), - claudeRow(undefined), - claudeRow(`${PROFILES}/acct-removed/home/projects/-repo/session-1.jsonl`) - ] + const unplaced = [claudeRow(onA, { agentType: 'codex' }), claudeRow(undefined)] for (const row of unplaced) { expect(findClaudeSessionsOnOtherLogin(view(row), accounts('acct-b'), onThisHost)).toEqual([]) } diff --git a/src/renderer/src/lib/claude-session-move.ts b/src/renderer/src/lib/claude-session-move.ts index c09a8f829a77..0d05af6a28b8 100644 --- a/src/renderer/src/lib/claude-session-move.ts +++ b/src/renderer/src/lib/claude-session-move.ts @@ -105,11 +105,12 @@ export function findClaudeSessionsOnOtherLogin( ) { continue } - const login = loginOf( - accounts, - claudeAccountIdFromTranscriptPath(providerSession.transcriptPath) - ) - if (!login || isSameLogin(login, selected)) { + 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({ diff --git a/src/shared/claude-account-global-settings.ts b/src/shared/claude-account-global-settings.ts index 362a310980eb..b841d09b0c30 100644 --- a/src/shared/claude-account-global-settings.ts +++ b/src/shared/claude-account-global-settings.ts @@ -5,6 +5,7 @@ import type { /** 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 diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 65ba2b923357..dd41063c25a5 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -321,7 +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. */ /** 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 From 71b849d519cea9496fe7983c702552626fabe294 Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:34:29 +0200 Subject: [PATCH 06/14] fix(claude-accounts): check a session again after asking its pane, and keep retrying - After the process check comes back, the session is checked again on fresh state, so text typed or a turn started meanwhile still holds the move. - A process check that failed, or could not see the process, is retried instead of giving up. - Move Sessions pressed while the account list cannot be read is kept, and acted on once it can. --- .../claude-session-move-coordinator.test.ts | 45 ++++++++++- .../lib/claude-session-move-coordinator.ts | 76 +++++++++++++++---- 2 files changed, 104 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/lib/claude-session-move-coordinator.test.ts b/src/renderer/src/lib/claude-session-move-coordinator.test.ts index cfaf2bc2b84f..01aff6fd1e70 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.test.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.test.ts @@ -11,13 +11,15 @@ import { } from './claude-session-move-coordinator' const restartPaneForAccountMove = vi.hoisted(() => vi.fn(() => true)) +type Inspection = { foregroundProcess: string | null; hasChildProcesses: boolean } const foreground = vi.hoisted(() => ({ process: 'claude' })) -vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - inspectRuntimeTerminalProcess: vi.fn(async () => ({ +const inspect = vi.hoisted(() => + vi.fn(async (): Promise => ({ foregroundProcess: foreground.process, hasChildProcesses: true })) -})) +) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ inspectRuntimeTerminalProcess: inspect })) type MoveStartup = { command: string; startupCommandDelivery: 'shell-ready'; launchAgent: 'claude' } const buildClaudeSessionMoveStartup = vi.hoisted(() => vi.fn((): MoveStartup | null => ({ @@ -303,4 +305,41 @@ describe('Claude session moves', () => { 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() + }) }) diff --git a/src/renderer/src/lib/claude-session-move-coordinator.ts b/src/renderer/src/lib/claude-session-move-coordinator.ts index 58dd5dcd3f47..5255294d96ae 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.ts @@ -2,6 +2,7 @@ import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { restartPaneForAccountMove } from '@/components/terminal-pane/pane-account-move-restarts' import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' +import { isClientOnlyUnverifiableInspection } from '../../../shared/terminal-process-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' @@ -38,12 +39,15 @@ const approvedPaneKeys = new Set() const settledMoves = new Map() 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. @@ -95,16 +99,26 @@ export function listClaudeSessionsOnOtherLogin(state: AppState): ClaudeSessionOn /** 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() - for (const session of listClaudeSessionsOnOtherLogin(useAppStore.getState())) { - approvedPaneKeys.add(session.paneKey) - } + 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) { + 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(), @@ -127,19 +141,40 @@ function gatesFor(approved: boolean): ClaudeSessionMoveGates { } /** 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() - // Why: input stamps are coalesced, so unsent text must include the not-yet-flushed keystroke. - const view = { +// 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 ) } - const sessions = findClaudeSessionsOnOtherLogin(view, accounts, (worktreeId) => +} + +function canMoveNow( + current: ClaudeRateLimitAccountsState, + session: ClaudeSessionOnOtherLogin, + approved: boolean +): boolean { + const view = readMoveView(useAppStore.getState()) + const now = findClaudeSessionsOnOtherLogin(view, current, (worktreeId) => + isOnThisHost(view, worktreeId) + ).find((candidate) => candidate.paneKey === session.paneKey) + return ( + now?.ptyId === session.ptyId && + now.transcriptPath === session.transcriptPath && + now.targetLogin === session.targetLogin && + isClaudeSessionMovableNow(view, now, gatesFor(approved)) + ) +} + +async function movePanes(): Promise { + const current = accounts + if (!current) { + return 0 + } + const state = useAppStore.getState() + const sessions = findClaudeSessionsOnOtherLogin(readMoveView(state), current, (worktreeId) => isOnThisHost(state, worktreeId) ) const stalePaneKeys = new Set(sessions.map((session) => session.paneKey)) @@ -156,7 +191,7 @@ async function movePanes(): Promise { if (isSettled(session) || (!approved && !automatic)) { continue } - if (!isClaudeSessionMovableNow(view, session, gatesFor(approved))) { + if (!canMoveNow(current, session, approved)) { waiting += 1 continue } @@ -166,10 +201,20 @@ async function movePanes(): Promise { // 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) + // Why retry: a check that failed, or could not see the process, says nothing about the pane. + if (!inspection || isClientOnlyUnverifiableInspection(inspection)) { + waiting += 1 + continue + } if (!isClaudeRunningInPane(inspection, tab?.launchAgent)) { settle(session) continue } + // Why check again: you may have typed, or a new turn may have started, while we asked. + if (!canMoveNow(current, session, approved)) { + waiting += 1 + continue + } const startup = buildClaudeSessionMoveStartup({ tabId: session.tabId, leafId: session.leafId, @@ -197,8 +242,11 @@ export function installClaudeSessionMoveCoordinator(): () => void { if (!accounts) { await refreshClaudeMoveAccounts() } + approveRequestedMoves() const pendingWork = - approvedPaneKeys.size > 0 || useAppStore.getState().settings?.claudeMoveIdleSessions === true + 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)) { From 096f792909cb9616bbf675eb63717dfdefe4ccc3 Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:39:23 +0200 Subject: [PATCH 07/14] fix(claude-accounts): re-check with the latest account and setting, and retry when a pane's processes are unreadable After the process check, the session is re-checked against the newest account list and the current setting, so switching back or turning moves off while it waits stops the move. An inspection whose evidence says the process table could not be read is retried, like a failed one. --- .../claude-session-move-coordinator.test.ts | 54 ++++++++++++++++++- .../lib/claude-session-move-coordinator.ts | 29 +++++----- src/renderer/src/lib/claude-session-move.ts | 9 ++++ 3 files changed, 77 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/lib/claude-session-move-coordinator.test.ts b/src/renderer/src/lib/claude-session-move-coordinator.test.ts index 01aff6fd1e70..eefceeaebfbc 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.test.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.test.ts @@ -11,7 +11,11 @@ import { } from './claude-session-move-coordinator' const restartPaneForAccountMove = vi.hoisted(() => vi.fn(() => true)) -type Inspection = { foregroundProcess: string | null; hasChildProcesses: boolean } +type Inspection = { + foregroundProcess: string | null + hasChildProcesses: boolean + childProcessEvidence?: 'children' | 'no-children' | 'unverifiable' +} const foreground = vi.hoisted(() => ({ process: 'claude' })) const inspect = vi.hoisted(() => vi.fn(async (): Promise => ({ @@ -342,4 +346,52 @@ describe('Claude session moves', () => { 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() + }) }) diff --git a/src/renderer/src/lib/claude-session-move-coordinator.ts b/src/renderer/src/lib/claude-session-move-coordinator.ts index 5255294d96ae..503a398326b0 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.ts @@ -2,7 +2,6 @@ import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { restartPaneForAccountMove } from '@/components/terminal-pane/pane-account-move-restarts' import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' -import { isClientOnlyUnverifiableInspection } from '../../../shared/terminal-process-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' @@ -16,6 +15,7 @@ import { findClaudeSessionsOnOtherLogin, isClaudeRunningInPane, isClaudeSessionMovableNow, + isUnseenPaneInspection, type ClaudeSessionMoveGates, type ClaudeSessionOnOtherLogin } from './claude-session-move' @@ -151,13 +151,15 @@ function readMoveView(state: AppState): AppState { } } -function canMoveNow( - current: ClaudeRateLimitAccountsState, - session: ClaudeSessionOnOtherLogin, - approved: boolean -): boolean { +// 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 now = findClaudeSessionsOnOtherLogin(view, current, (worktreeId) => + 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) return ( @@ -169,12 +171,11 @@ function canMoveNow( } async function movePanes(): Promise { - const current = accounts - if (!current) { + if (!accounts) { return 0 } const state = useAppStore.getState() - const sessions = findClaudeSessionsOnOtherLogin(readMoveView(state), current, (worktreeId) => + const sessions = findClaudeSessionsOnOtherLogin(readMoveView(state), accounts, (worktreeId) => isOnThisHost(state, worktreeId) ) const stalePaneKeys = new Set(sessions.map((session) => session.paneKey)) @@ -191,7 +192,7 @@ async function movePanes(): Promise { if (isSettled(session) || (!approved && !automatic)) { continue } - if (!canMoveNow(current, session, approved)) { + if (!canMoveNow(session)) { waiting += 1 continue } @@ -202,7 +203,7 @@ async function movePanes(): Promise { // 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) // Why retry: a check that failed, or could not see the process, says nothing about the pane. - if (!inspection || isClientOnlyUnverifiableInspection(inspection)) { + if (!inspection || isUnseenPaneInspection(inspection)) { waiting += 1 continue } @@ -210,8 +211,8 @@ async function movePanes(): Promise { settle(session) continue } - // Why check again: you may have typed, or a new turn may have started, while we asked. - if (!canMoveNow(current, session, approved)) { + // Why check again: you may have typed, switched back, or turned moves off while we asked. + if (!canMoveNow(session)) { waiting += 1 continue } diff --git a/src/renderer/src/lib/claude-session-move.ts b/src/renderer/src/lib/claude-session-move.ts index 0d05af6a28b8..e51b01e16d24 100644 --- a/src/renderer/src/lib/claude-session-move.ts +++ b/src/renderer/src/lib/claude-session-move.ts @@ -158,6 +158,15 @@ export function isClaudeRunningInPane( ) } +/** Whether the inspection could not see the pane's processes, so it says nothing either way. */ +export function isUnseenPaneInspection(inspection: RuntimeTerminalProcessInspection): boolean { + return ( + isClientOnlyUnverifiableInspection(inspection) || + inspection.childProcessEvidence === 'unverifiable' || + inspection.foregroundProcessEvidence?.verdict === 'unverifiable' + ) +} + /** Whether restarting the session now loses nothing: hibernation's own test for a pane it may stop. */ export function isClaudeSessionMovableNow( view: ClaudeSessionMoveView, From 5e6a5c52598b676aaac9a977ab6ef712365994f7 Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:43:52 +0200 Subject: [PATCH 08/14] fix(claude-accounts): drop the old account list when a read starts, and read Windows panes by child evidence A switch is what usually asks for a new account list, so the old one is dropped as the read starts and nothing moves on it meanwhile. A local pane's inspection is judged by its child-process evidence, as agent completion does: Windows marks the foreground evidence unverifiable on every local answer, which blocked every move there. --- .../claude-session-move-coordinator.test.ts | 15 +++++++++ .../lib/claude-session-move-coordinator.ts | 3 ++ .../src/lib/claude-session-move.test.ts | 32 +++++++++++++++++++ src/renderer/src/lib/claude-session-move.ts | 9 ++++-- 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/lib/claude-session-move-coordinator.test.ts b/src/renderer/src/lib/claude-session-move-coordinator.test.ts index eefceeaebfbc..e26f7287fccc 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.test.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.test.ts @@ -394,4 +394,19 @@ describe('Claude session moves', () => { 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() + }) }) diff --git a/src/renderer/src/lib/claude-session-move-coordinator.ts b/src/renderer/src/lib/claude-session-move-coordinator.ts index 503a398326b0..b68cc7e8b31c 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.ts @@ -62,6 +62,9 @@ function isOnThisHost(state: AppState, worktreeId: string): boolean { /** 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. diff --git a/src/renderer/src/lib/claude-session-move.test.ts b/src/renderer/src/lib/claude-session-move.test.ts index 6e0f18ff04f4..e5b3b14a7a56 100644 --- a/src/renderer/src/lib/claude-session-move.test.ts +++ b/src/renderer/src/lib/claude-session-move.test.ts @@ -9,6 +9,7 @@ import { findClaudeSessionsOnOtherLogin, isClaudeRunningInPane, isClaudeSessionMovableNow, + isUnseenPaneInspection, type ClaudeSessionMoveGates, type ClaudeSessionMoveView } from './claude-session-move' @@ -275,3 +276,34 @@ describe('isClaudeRunningInPane', () => { expect(isClaudeRunningInPane(null, 'claude')).toBe(false) }) }) + +describe('isUnseenPaneInspection', () => { + 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' + } + }) + ).toBe(false) + }) + + it('counts an answer that could not read the process table as unseen', () => { + expect( + isUnseenPaneInspection({ + foregroundProcess: 'zsh', + hasChildProcesses: false, + childProcessEvidence: 'unverifiable' + }) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/claude-session-move.ts b/src/renderer/src/lib/claude-session-move.ts index e51b01e16d24..a1db34ae1b01 100644 --- a/src/renderer/src/lib/claude-session-move.ts +++ b/src/renderer/src/lib/claude-session-move.ts @@ -158,12 +158,15 @@ export function isClaudeRunningInPane( ) } -/** Whether the inspection could not see the pane's processes, so it says nothing either way. */ +/** + * Whether the inspection could not see the pane's processes, so it says nothing either way. Goes + * by the child-process evidence, as agent completion does for a local pane: the foreground + * evidence fences remote answers, and Windows marks it unverifiable on every local one. + */ export function isUnseenPaneInspection(inspection: RuntimeTerminalProcessInspection): boolean { return ( isClientOnlyUnverifiableInspection(inspection) || - inspection.childProcessEvidence === 'unverifiable' || - inspection.foregroundProcessEvidence?.verdict === 'unverifiable' + inspection.childProcessEvidence === 'unverifiable' ) } From b2500e6f8b7714d484116746c6ae1f0b86107b5f Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:48:03 +0200 Subject: [PATCH 09/14] fix(claude-accounts): give up on a session only when its pane clearly no longer runs Claude A process-table read that failed can leave only the foreground evidence marked unverifiable. A pane that does not read as Claude is now settled only on a verifiable answer, and retried otherwise; a real Claude still moves, on Windows too. --- .../claude-session-move-coordinator.test.ts | 33 +++++++++++++++++++ .../lib/claude-session-move-coordinator.ts | 6 ++++ 2 files changed, 39 insertions(+) diff --git a/src/renderer/src/lib/claude-session-move-coordinator.test.ts b/src/renderer/src/lib/claude-session-move-coordinator.test.ts index e26f7287fccc..fc0ce7d8141b 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.test.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.test.ts @@ -15,6 +15,15 @@ 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(() => @@ -409,4 +418,28 @@ describe('Claude session moves', () => { 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() + }) }) diff --git a/src/renderer/src/lib/claude-session-move-coordinator.ts b/src/renderer/src/lib/claude-session-move-coordinator.ts index b68cc7e8b31c..fc58eb85f7b7 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.ts @@ -211,6 +211,12 @@ async function movePanes(): Promise { continue } if (!isClaudeRunningInPane(inspection, tab?.launchAgent)) { + // Why give up only on a clear answer: a foreground nobody could verify may be a failed read, + // not a pane that stopped running Claude. + if (inspection.foregroundProcessEvidence?.verdict === 'unverifiable') { + waiting += 1 + continue + } settle(session) continue } From bcc31347c468c40fba26e28cdcbd27259ba5cd5d Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:53:32 +0200 Subject: [PATCH 10/14] fix(claude-accounts): do not trust a pane's cached Claude name when its process read failed Off Windows, an inspection whose foreground evidence is unverifiable now counts as unseen, so a cached claude name from a failed read cannot restart a pane Claude already left; the session is retried instead. Windows marks that evidence unverifiable on every local answer, so there the child-process evidence decides, as before. --- .../claude-session-move-coordinator.test.ts | 21 ++++++ .../lib/claude-session-move-coordinator.ts | 9 +-- .../src/lib/claude-session-move.test.ts | 67 +++++++++++++------ src/renderer/src/lib/claude-session-move.ts | 14 ++-- 4 files changed, 80 insertions(+), 31 deletions(-) diff --git a/src/renderer/src/lib/claude-session-move-coordinator.test.ts b/src/renderer/src/lib/claude-session-move-coordinator.test.ts index fc0ce7d8141b..f40e9de3ca6a 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.test.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.test.ts @@ -442,4 +442,25 @@ describe('Claude session moves', () => { 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() + }) }) diff --git a/src/renderer/src/lib/claude-session-move-coordinator.ts b/src/renderer/src/lib/claude-session-move-coordinator.ts index fc58eb85f7b7..f2d64281d67a 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.ts @@ -25,6 +25,7 @@ import { } 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 @@ -206,17 +207,11 @@ async function movePanes(): Promise { // 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) // Why retry: a check that failed, or could not see the process, says nothing about the pane. - if (!inspection || isUnseenPaneInspection(inspection)) { + if (!inspection || isUnseenPaneInspection(inspection, getRendererAppPlatform())) { waiting += 1 continue } if (!isClaudeRunningInPane(inspection, tab?.launchAgent)) { - // Why give up only on a clear answer: a foreground nobody could verify may be a failed read, - // not a pane that stopped running Claude. - if (inspection.foregroundProcessEvidence?.verdict === 'unverifiable') { - waiting += 1 - continue - } settle(session) continue } diff --git a/src/renderer/src/lib/claude-session-move.test.ts b/src/renderer/src/lib/claude-session-move.test.ts index e5b3b14a7a56..f48d5b625621 100644 --- a/src/renderer/src/lib/claude-session-move.test.ts +++ b/src/renderer/src/lib/claude-session-move.test.ts @@ -278,32 +278,61 @@ describe('isClaudeRunningInPane', () => { }) 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' - } - }) + 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' - }) + 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 index a1db34ae1b01..38e3fd7eed26 100644 --- a/src/renderer/src/lib/claude-session-move.ts +++ b/src/renderer/src/lib/claude-session-move.ts @@ -159,14 +159,18 @@ export function isClaudeRunningInPane( } /** - * Whether the inspection could not see the pane's processes, so it says nothing either way. Goes - * by the child-process evidence, as agent completion does for a local pane: the foreground - * evidence fences remote answers, and Windows marks it unverifiable on every local one. + * 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): boolean { +export function isUnseenPaneInspection( + inspection: RuntimeTerminalProcessInspection, + platform: NodeJS.Platform +): boolean { return ( isClientOnlyUnverifiableInspection(inspection) || - inspection.childProcessEvidence === 'unverifiable' + inspection.childProcessEvidence === 'unverifiable' || + (platform !== 'win32' && inspection.foregroundProcessEvidence?.verdict === 'unverifiable') ) } From af6639bcf6a232749230297de24f110616b0d6be Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:20:33 +0200 Subject: [PATCH 11/14] fix(claude-accounts): read a shell reading again before leaving a Claude pane The cached foreground read can flap to the shell for a live agent (#11064), so a shell in a tab that launched Claude now gets the fresh scan Codex's restart uses; a scan that cannot tell is retried. Panes that are not mounted are skipped before any process read, and a settings save no longer re-reads the accounts unless the selection or the account list changed. --- docs/site/content/docs/agents/claude-code.mdx | 2 +- .../status-bar/claude-session-move-prompt.tsx | 5 +- .../pane-account-move-restarts.ts | 5 + .../claude-session-move-coordinator.test.ts | 119 +++++++++++++++++- .../lib/claude-session-move-coordinator.ts | 34 ++--- .../src/lib/claude-session-move.test.ts | 1 - src/renderer/src/lib/claude-session-move.ts | 44 ++++++- 7 files changed, 187 insertions(+), 23 deletions(-) diff --git a/docs/site/content/docs/agents/claude-code.mdx b/docs/site/content/docs/agents/claude-code.mdx index 12f75e5b2dbe..59300baa8603 100644 --- a/docs/site/content/docs/agents/claude-code.mdx +++ b/docs/site/content/docs/agents/claude-code.mdx @@ -22,7 +22,7 @@ Orca reads your local `~/.claude` usage state and shows current usage plus rate- Orca supports multiple Claude accounts and can swap between them in one click, matching the Codex flow. Switching accounts works even with live Claude sessions running — Orca holds the in-progress switch behind a guard so you don't trigger overlapping auth refreshes. See [Hot-swap Codex accounts](/docs/agents/codex-hot-swap) — the Claude flow is identical in shape. -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, keeping anything you typed but did not send. **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. +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 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 index 055fc0448616..51085c7e466c 100644 --- a/src/renderer/src/components/status-bar/claude-session-move-prompt.tsx +++ b/src/renderer/src/components/status-bar/claude-session-move-prompt.tsx @@ -6,11 +6,12 @@ import { 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 selection = useAppStore((s) => s.settings?.activeClaudeManagedAccountIdsByRuntime) + const accountsKey = useAppStore((s) => claudeMoveAccountsKey(s.settings)) const [accountsRead, setAccountsRead] = useState(0) useEffect(() => { let current = true @@ -22,7 +23,7 @@ export function ClaudeSessionMovePrompt(): React.JSX.Element | null { return () => { current = false } - }, [selection]) + }, [accountsKey]) const sessionCount = useAppStore((s) => accountsRead > 0 ? listClaudeSessionsOnOtherLogin(s).length : 0 ) 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 index 5d4a57200847..06332cdfb53e 100644 --- a/src/renderer/src/components/terminal-pane/pane-account-move-restarts.ts +++ b/src/renderer/src/components/terminal-pane/pane-account-move-restarts.ts @@ -16,6 +16,11 @@ export function registerPaneAccountMoveRestart(ptyId: string, restart: PaneResta } } +/** 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) diff --git a/src/renderer/src/lib/claude-session-move-coordinator.test.ts b/src/renderer/src/lib/claude-session-move-coordinator.test.ts index f40e9de3ca6a..e217d1134f35 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.test.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.test.ts @@ -11,6 +11,7 @@ import { } from './claude-session-move-coordinator' const restartPaneForAccountMove = vi.hoisted(() => vi.fn(() => true)) +const hasPaneAccountMoveRestart = vi.hoisted(() => vi.fn(() => true)) type Inspection = { foregroundProcess: string | null hasChildProcesses: boolean @@ -32,7 +33,11 @@ const inspect = vi.hoisted(() => hasChildProcesses: true })) ) -vi.mock('@/runtime/runtime-terminal-inspection', () => ({ inspectRuntimeTerminalProcess: inspect })) +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 => ({ @@ -42,7 +47,8 @@ const buildClaudeSessionMoveStartup = vi.hoisted(() => })) ) vi.mock('@/components/terminal-pane/pane-account-move-restarts', () => ({ - restartPaneForAccountMove + restartPaneForAccountMove, + hasPaneAccountMoveRestart })) vi.mock('./codex-account-restart-startup', () => ({ buildClaudeSessionMoveStartup })) vi.mock('@/lib/local-preflight-context', () => ({ @@ -96,6 +102,7 @@ function seed( ptyId: null, worktreeId: 'wt-1', title: 'Claude', + launchAgent: 'claude', customTitle: null, color: null, sortOrder: 0, @@ -163,6 +170,10 @@ describe('Claude session moves', () => { vi.stubGlobal('window', { ...globalThis.window, api: { claudeAccounts: { list } } }) roster = selected('acct-b') restartPaneForAccountMove.mockClear() + hasPaneAccountMoveRestart.mockClear() + inspect.mockClear() + confirm.mockClear() + list.mockClear() foreground.process = 'claude' buildClaudeSessionMoveStartup.mockClear() resetClaudeSessionMoveForTests() @@ -463,4 +474,108 @@ describe('Claude session moves', () => { 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('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 () => { + seed({}, { claudeMoveIdleSessions: true }) + uninstall = installClaudeSessionMoveCoordinator() + await settle() + list.mockClear() + + useAppStore.setState((s) => ({ + settings: s.settings + ? { + ...s.settings, + claudeManagedAccounts: [ + { + id: 'acct-a', + email: 'a@example.test', + managedAuthPath: '', + authMethod: 'subscription-oauth', + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 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 index f2d64281d67a..b0201ba22311 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.ts @@ -1,6 +1,9 @@ import { useAppStore } from '@/store' import type { AppState } from '@/store/types' -import { restartPaneForAccountMove } from '@/components/terminal-pane/pane-account-move-restarts' +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' @@ -12,10 +15,10 @@ import { } from './agent-hibernation-pane-age' import { buildClaudeSessionMoveStartup } from './codex-account-restart-startup' import { + claudeMoveAccountsKey, findClaudeSessionsOnOtherLogin, - isClaudeRunningInPane, isClaudeSessionMovableNow, - isUnseenPaneInspection, + readClaudeInPane, type ClaudeSessionMoveGates, type ClaudeSessionOnOtherLogin } from './claude-session-move' @@ -144,7 +147,6 @@ function gatesFor(approved: boolean): ClaudeSessionMoveGates { } } -/** Moves what can go now and returns how many sessions still wait their turn. */ // Why: input stamps are coalesced, so unsent text must include the not-yet-flushed keystroke. function readMoveView(state: AppState): AppState { return { @@ -166,7 +168,9 @@ function canMoveNow(session: ClaudeSessionOnOtherLogin): boolean { 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 && @@ -174,6 +178,7 @@ function canMoveNow(session: ClaudeSessionOnOtherLogin): boolean { ) } +/** Moves what can go now and returns how many sessions still wait their turn. */ async function movePanes(): Promise { if (!accounts) { return 0 @@ -182,10 +187,10 @@ async function movePanes(): Promise { const sessions = findClaudeSessionsOnOtherLogin(readMoveView(state), accounts, (worktreeId) => isOnThisHost(state, worktreeId) ) - const stalePaneKeys = new Set(sessions.map((session) => session.paneKey)) + 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 (!stalePaneKeys.has(paneKey)) { + if (!currentPaneKeys.has(paneKey)) { approvedPaneKeys.delete(paneKey) } } @@ -206,12 +211,18 @@ async function movePanes(): Promise { // 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 (!inspection || isUnseenPaneInspection(inspection, getRendererAppPlatform())) { + if (running === null) { waiting += 1 continue } - if (!isClaudeRunningInPane(inspection, tab?.launchAgent)) { + if (!running) { settle(session) continue } @@ -262,12 +273,7 @@ export function installClaudeSessionMoveCoordinator(): () => void { const unsubscribe = useAppStore.subscribe((state, previous) => { const settings = state.settings const before = previous.settings - if ( - settings?.activeClaudeManagedAccountId !== before?.activeClaudeManagedAccountId || - settings?.activeClaudeManagedAccountIdsByRuntime !== - before?.activeClaudeManagedAccountIdsByRuntime || - settings?.claudeManagedAccounts !== before?.claudeManagedAccounts - ) { + if (settings !== before && claudeMoveAccountsKey(settings) !== claudeMoveAccountsKey(before)) { void refreshClaudeMoveAccounts().then(() => schedule(SETTLE_MS)) return } diff --git a/src/renderer/src/lib/claude-session-move.test.ts b/src/renderer/src/lib/claude-session-move.test.ts index f48d5b625621..ec27dc80ff13 100644 --- a/src/renderer/src/lib/claude-session-move.test.ts +++ b/src/renderer/src/lib/claude-session-move.test.ts @@ -128,7 +128,6 @@ describe('findClaudeSessionsOnOtherLogin', () => { leafId: LEAF, ptyId: 'pty-1', worktreeId: 'wt-1', - sessionId: 'session-1', transcriptPath: onA, targetLogin: 'b@example.test/' } diff --git a/src/renderer/src/lib/claude-session-move.ts b/src/renderer/src/lib/claude-session-move.ts index 38e3fd7eed26..7d7ce13ec952 100644 --- a/src/renderer/src/lib/claude-session-move.ts +++ b/src/renderer/src/lib/claude-session-move.ts @@ -1,5 +1,8 @@ import type { AppState } from '@/store' -import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' +import { + confirmRuntimeTerminalForegroundProcess, + type RuntimeTerminalProcessInspection +} from '@/runtime/runtime-terminal-inspection' import { isAgentForegroundWrapperProcess, recognizeAgentProcess @@ -29,7 +32,6 @@ export type ClaudeSessionOnOtherLogin = { leafId: string ptyId: string worktreeId: string - sessionId: 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. */ @@ -46,6 +48,15 @@ export type ClaudeSessionMoveGates = { 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 @@ -119,7 +130,6 @@ export function findClaudeSessionsOnOtherLogin( leafId, ptyId, worktreeId, - sessionId: providerSession.id, transcriptPath: providerSession.transcriptPath, targetLogin: `${selected.email.toLowerCase()}/${selected.organizationUuid ?? ''}` }) @@ -174,6 +184,34 @@ export function isUnseenPaneInspection( ) } +/** + * 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) + return confirmed === null + ? null + : isClaudeRunningInPane({ ...inspection, foregroundProcess: confirmed }, launchAgent) +} + /** Whether restarting the session now loses nothing: hibernation's own test for a pane it may stop. */ export function isClaudeSessionMovableNow( view: ClaudeSessionMoveView, From 64e318465f6aeb9b53d198d22cf081ea3119cfe7 Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:36:42 +0200 Subject: [PATCH 12/14] fix(claude-accounts): count a confirmed non-shell foreground as the shell's child A shell reading carries no children, so a Claude the fresh read finds running under node was taken for something else and left on the old account. --- .../claude-session-move-coordinator.test.ts | 39 +++++++++++-------- src/renderer/src/lib/claude-session-move.ts | 3 +- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/lib/claude-session-move-coordinator.test.ts b/src/renderer/src/lib/claude-session-move-coordinator.test.ts index e217d1134f35..dad7f06cbd45 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.test.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.test.ts @@ -484,6 +484,16 @@ describe('Claude session moves', () => { 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 once a fresh read confirms the shell', async () => { foreground.process = 'zsh' confirm.mockResolvedValueOnce('zsh') @@ -535,28 +545,25 @@ describe('Claude session moves', () => { }) 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: [ - { - id: 'acct-a', - email: 'a@example.test', - managedAuthPath: '', - authMethod: 'subscription-oauth', - createdAt: 1, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ] - } - : s.settings + settings: s.settings ? { ...s.settings, claudeManagedAccounts: [account(2)] } : s.settings })) await settle() diff --git a/src/renderer/src/lib/claude-session-move.ts b/src/renderer/src/lib/claude-session-move.ts index 7d7ce13ec952..4fe597291d7f 100644 --- a/src/renderer/src/lib/claude-session-move.ts +++ b/src/renderer/src/lib/claude-session-move.ts @@ -207,9 +207,10 @@ export async function readClaudeInPane( 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({ ...inspection, foregroundProcess: confirmed }, launchAgent) + : isClaudeRunningInPane({ foregroundProcess: confirmed, hasChildProcesses: true }, launchAgent) } /** Whether restarting the session now loses nothing: hibernation's own test for a pane it may stop. */ From 49ab0102e91292bc71736cea2a6bb6dca76c1975 Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:39:58 +0200 Subject: [PATCH 13/14] fix(claude-accounts): leave a pane where another agent runs, even in a Claude tab A Codex someone started in a tab that launched Claude was taken for Claude and would have been restarted as claude --resume. --- .../src/lib/claude-session-move-coordinator.test.ts | 10 ++++++++++ src/renderer/src/lib/claude-session-move.test.ts | 4 ++++ src/renderer/src/lib/claude-session-move.ts | 11 ++++++----- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/lib/claude-session-move-coordinator.test.ts b/src/renderer/src/lib/claude-session-move-coordinator.test.ts index dad7f06cbd45..3b16e51cc4aa 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.test.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.test.ts @@ -494,6 +494,16 @@ describe('Claude session moves', () => { 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') diff --git a/src/renderer/src/lib/claude-session-move.test.ts b/src/renderer/src/lib/claude-session-move.test.ts index ec27dc80ff13..06cae383fe3d 100644 --- a/src/renderer/src/lib/claude-session-move.test.ts +++ b/src/renderer/src/lib/claude-session-move.test.ts @@ -268,6 +268,10 @@ describe('isClaudeRunningInPane', () => { 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) diff --git a/src/renderer/src/lib/claude-session-move.ts b/src/renderer/src/lib/claude-session-move.ts index 4fe597291d7f..6608c053e421 100644 --- a/src/renderer/src/lib/claude-session-move.ts +++ b/src/renderer/src/lib/claude-session-move.ts @@ -140,9 +140,9 @@ export function findClaudeSessionsOnOtherLogin( } /** - * Whether Claude is what runs in the pane, the test Codex's restart prompt uses: 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 or another program means a restart would cut off something else. + * 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< @@ -161,10 +161,11 @@ export function isClaudeRunningInPane( 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)) + recognizeAgentProcess(foregroundProcess) === null && + isAgentForegroundWrapperProcess(foregroundProcess) ) } From cfc5ecb30b8dd8857419512463a918b3999763fd Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:44:31 +0200 Subject: [PATCH 14/14] fix(claude-accounts): offer a session again when its move never connected A move restarts the pane and is recorded at once, but a pane that unmounts before the new PTY connects keeps the old one. The record now names the PTY it replaced, so a pane still on it reads as not moved. --- .../claude-session-move-coordinator.test.ts | 29 ++++++++++++++++++- .../lib/claude-session-move-coordinator.ts | 18 ++++++++---- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/lib/claude-session-move-coordinator.test.ts b/src/renderer/src/lib/claude-session-move-coordinator.test.ts index 3b16e51cc4aa..f49c771648ef 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.test.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.test.ts @@ -10,7 +10,7 @@ import { resetClaudeSessionMoveForTests } from './claude-session-move-coordinator' -const restartPaneForAccountMove = vi.hoisted(() => vi.fn(() => true)) +const restartPaneForAccountMove = vi.hoisted(() => vi.fn((): boolean => true)) const hasPaneAccountMoveRestart = vi.hoisted(() => vi.fn(() => true)) type Inspection = { foregroundProcess: string | null @@ -139,6 +139,21 @@ function seed( }) } +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) { @@ -170,6 +185,8 @@ describe('Claude session moves', () => { 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() @@ -253,6 +270,15 @@ describe('Claude session moves', () => { 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({}) @@ -260,6 +286,7 @@ describe('Claude session moves', () => { 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 () => { diff --git a/src/renderer/src/lib/claude-session-move-coordinator.ts b/src/renderer/src/lib/claude-session-move-coordinator.ts index b0201ba22311..883c6052f862 100644 --- a/src/renderer/src/lib/claude-session-move-coordinator.ts +++ b/src/renderer/src/lib/claude-session-move-coordinator.ts @@ -40,7 +40,10 @@ 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() +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. */ @@ -79,16 +82,19 @@ export async function refreshClaudeMoveAccounts(): Promise { 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.targetLogin === session.targetLogin && + settled.replacedPtyId !== session.ptyId ) } -function settle(session: ClaudeSessionOnOtherLogin): void { +function settle(session: ClaudeSessionOnOtherLogin, replacedPtyId?: string): void { settledMoves.set(session.paneKey, { transcriptPath: session.transcriptPath, - targetLogin: session.targetLogin + targetLogin: session.targetLogin, + replacedPtyId }) approvedPaneKeys.delete(session.paneKey) } @@ -239,8 +245,10 @@ async function movePanes(): Promise { }) // 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 || restartPaneForAccountMove(session.ptyId, startup)) { + if (!startup) { settle(session) + } else if (restartPaneForAccountMove(session.ptyId, startup)) { + settle(session, session.ptyId) } else { waiting += 1 }