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