fix(accounts): restart running Claude sessions when the account is switched from the phone (#36933)

A switch over runtime RPC (the phone's Accounts screen) changed the
selection but never reached the window's running-session handling, which
only the status bar and Settings called after their own IPC switch. The
RPC path now reports the lane and outgoing/incoming account to the main
window, which runs the same handler. The web client handles its own
switches and opts out.
This commit is contained in:
Kelvin Amoaba
2026-11-01 08:00:08 +00:00
committed by GitHub
parent b4c93baee8
commit 92fc8bc26d
17 changed files with 160 additions and 13 deletions
@@ -136,7 +136,7 @@ export function pruneInvalidClaudeRuntimeSelection(
}
export function getClaudeSelectionTargetForAccount(
account: ClaudeManagedAccount
account: Pick<ClaudeManagedAccount, 'managedAuthRuntime' | 'wslDistro'>
): ClaudeAccountSelectionTarget {
if (account.managedAuthRuntime === 'wsl') {
return { runtime: 'wsl', wslDistro: account.wslDistro ?? null }
@@ -272,7 +272,7 @@ export abstract class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeW
protected ptyControllerInventoryGenerationByProvider = new Map<string, number>()
protected readonly accounts = new RuntimeAccountController()
protected readonly accounts = new RuntimeAccountController(() => this.notifier)
protected readonly mobileSpeech = new RuntimeMobileSpeechCatalog(() => this.store)
+6 -2
View File
@@ -16,6 +16,7 @@ import {
ListAccountsParams,
RemoveAccountParams,
SelectAccountParams,
SelectClaudeAccountParams,
SelectCodexAccountForTargetParams,
HostAccountSignInStartParams,
HostAccountSignInWaitParams,
@@ -142,8 +143,11 @@ const ACCOUNT_METHOD_DEFINITIONS = [
defineMethod({
name: 'accounts.selectClaude',
permission: 'accounts-admin',
params: SelectAccountParams,
handler: async (params, { runtime }) => runtime.selectClaudeAccount(params.accountId)
params: SelectClaudeAccountParams,
handler: async (params, { runtime }) =>
runtime.selectClaudeAccount(params.accountId, {
movesRunningSessions: params.movesRunningSessions
})
}),
defineMethod({
name: 'accounts.selectCodex',
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest'
import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types'
import { RuntimeAccountController, type RuntimeAccountServices } from './runtime-account-controller'
// The saved lane key may differ in case from the account's distro, as older selections do.
function claudeState(wslAccountId: string | null): ClaudeRateLimitAccountsState {
return {
activeAccountId: null,
activeAccountIdsByRuntime: { host: null, wsl: { ubuntu: wslAccountId } },
accounts: [
{
id: 'wsl-b',
email: 'b@example.com',
managedAuthRuntime: 'wsl',
wslDistro: 'Ubuntu',
authMethod: 'subscription-oauth',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
]
}
}
function controllerWithSwitch(): {
accounts: RuntimeAccountController
onClaudeSelected: ReturnType<typeof vi.fn>
} {
const onClaudeSelected = vi.fn()
const accounts = new RuntimeAccountController(() => ({
claudeAccountSelected: onClaudeSelected
}))
const claudeAccounts = {
listAccounts: () => claudeState(null),
selectAccount: async (accountId: string | null) => claudeState(accountId)
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: selectClaude reads only these two methods.
accounts.setServices({ claudeAccounts } as unknown as RuntimeAccountServices)
return { accounts, onClaudeSelected }
}
describe('RuntimeAccountController.selectClaude', () => {
it("tells the window about a phone switch, on the selected account's lane", async () => {
const { accounts, onClaudeSelected } = controllerWithSwitch()
await accounts.selectClaude('wsl-b')
expect(onClaudeSelected).toHaveBeenCalledWith({
target: { runtime: 'wsl', wslDistro: 'Ubuntu' },
before: { state: claudeState(null), accountId: null },
after: { state: claudeState('wsl-b'), accountId: 'wsl-b' }
})
})
it('leaves a web client switch to the web client, which moves its own sessions', async () => {
const { accounts, onClaudeSelected } = controllerWithSwitch()
await accounts.selectClaude('wsl-b', { movesRunningSessions: true })
expect(onClaudeSelected).not.toHaveBeenCalled()
})
})
+44 -3
View File
@@ -23,13 +23,18 @@ import type {
import type { CodexRateLimitResetOutcome, RateLimitState } from '../../shared/rate-limit-types'
import type { CodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope'
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection'
import {
getClaudeSelectionTargetForAccount,
getSelectedClaudeAccountIdForTarget,
type ClaudeAccountSelectionTarget
} from '../claude-accounts/runtime-selection'
import {
hostAccountsManagedByDesktopMessage,
type HostAccountLaunchEnv
} from '../../shared/host-account-launch-env'
import type { HostClaudeProfileRefresh } from './host-claude-profile-refresh'
import { HostAccountLaunchEnvState } from './host-account-launch-env-state'
import type { RuntimeNotifier } from './runtime-notifier-contract'
export type RuntimeAccountServices = {
claudeAccounts: ClaudeAccountService
@@ -57,11 +62,15 @@ export type CodexRateLimitResetRpcResult = {
}
)
type ClaudeSelectionNotifier = Pick<RuntimeNotifier, 'claudeAccountSelected'>
export class RuntimeAccountController {
private services: RuntimeAccountServices | null = null
private commitMessageAgentEnvironment: CommitMessageAgentEnvironmentResolvers | null = null
private readonly hostAccounts = new HostAccountLaunchEnvState(() => this.services !== null)
constructor(private readonly getNotifier: () => ClaudeSelectionNotifier | null = () => null) {}
setServices(services: RuntimeAccountServices): void {
this.services = services
}
@@ -195,8 +204,27 @@ export class RuntimeAccountController {
])
}
selectClaude(accountId: string | null): Promise<ClaudeRateLimitAccountsState> {
return this.requireServices().claudeAccounts.selectAccount(accountId)
/** Desktop switches go through IPC and handle their own sessions; this path tells the window. */
async selectClaude(
accountId: string | null,
options: { movesRunningSessions?: true } = {}
): Promise<ClaudeRateLimitAccountsState> {
const { claudeAccounts } = this.requireServices()
if (options.movesRunningSessions) {
return claudeAccounts.selectAccount(accountId)
}
const before = claudeAccounts.listAccounts()
const after = await claudeAccounts.selectAccount(accountId)
const account = before.accounts.find((candidate) => candidate.id === accountId)
const target: ClaudeAccountSelectionTarget = account
? getClaudeSelectionTargetForAccount(account)
: { runtime: 'host' }
this.getNotifier()?.claudeAccountSelected?.({
target,
before: { state: before, accountId: selectedClaudeAccountId(before, target) },
after: { state: after, accountId: selectedClaudeAccountId(after, target) }
})
return after
}
selectCodex(accountId: string | null): Promise<CodexRateLimitAccountsState> {
@@ -303,3 +331,16 @@ export class RuntimeAccountController {
return this.services
}
}
function selectedClaudeAccountId(
state: ClaudeRateLimitAccountsState,
target: ClaudeAccountSelectionTarget
): string | null {
return getSelectedClaudeAccountIdForTarget(
{
activeClaudeManagedAccountId: state.activeAccountId,
activeClaudeManagedAccountIdsByRuntime: state.activeAccountIdsByRuntime
},
target
)
}
@@ -2,6 +2,7 @@ import type {
RuntimeEditorTabReveal,
RuntimeFileOpenPosition
} from '../../shared/runtime-file-contracts'
import type { ClaudeAccountSelectionChange } from '../../shared/managed-account-types'
import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict'
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
import type { PaneLaunchRecipe } from '../../shared/terminal-attachment-launch-result'
@@ -180,4 +181,5 @@ export type RuntimeNotifier = {
browserDriverChanged?(browserPageId: string, driver: RuntimeBrowserDriverState): void
browserRemoteViewersChanged?(browserPageId: string, hasRemoteViewers: boolean): void
clientHostedBrowserRowsChanged?(event: ClientHostedBrowserRowsEvent): void
claudeAccountSelected?(change: ClaudeAccountSelectionChange): void
}
@@ -621,7 +621,7 @@ describe('OrcaRuntimeRpcServer', () => {
error: expect.objectContaining({ code: 'forbidden' })
})
)
expect(mocks.selectClaudeAccount).toHaveBeenCalledWith('claude-account')
expect(mocks.selectClaudeAccount).toHaveBeenCalledWith('claude-account', {})
expect(mocks.selectCodexAccount).toHaveBeenCalledWith(null)
expect(mocks.consumeCodexRateLimitResetCredit).toHaveBeenCalledWith(
'11111111-1111-4111-8111-111111111111',
+3 -1
View File
@@ -227,7 +227,9 @@ export function registerRuntimeWindowLifecycle(
send('runtime:browserDriverChanged', { browserPageId, driver }),
browserRemoteViewersChanged: (browserPageId, hasRemoteViewers) =>
send('runtime:browserRemoteViewersChanged', { browserPageId, hasRemoteViewers }),
clientHostedBrowserRowsChanged: (event) => send('runtime:clientHostedBrowserRowsChanged', event)
clientHostedBrowserRowsChanged: (event) =>
send('runtime:clientHostedBrowserRowsChanged', event),
claudeAccountSelected: (change) => send('claudeAccounts:selectedElsewhere', change)
})
// Switch point: the window reads its sidebar from main's feed and re-reads on each notice.
const detachSidebarInputsNotices = ORCA_SIDEBAR_INPUTS_IS_MAIN
+2
View File
@@ -1,4 +1,5 @@
import type {
ClaudeAccountSelectionChange,
ClaudeRateLimitAccountsState,
CodexRateLimitAccountsState
} from '../../shared/managed-account-types'
@@ -62,6 +63,7 @@ export type ClaudeAccountsApi = {
runtime?: 'host' | 'wsl'
wslDistro?: string | null
}) => Promise<ClaudeRateLimitAccountsState>
onSelectedElsewhere?: (callback: (change: ClaudeAccountSelectionChange) => void) => () => void
}
export type GrokAccountsApi = {
+8 -1
View File
@@ -1,4 +1,5 @@
import { ipcRenderer } from 'electron'
import type { ClaudeAccountSelectionChange } from '../../shared/managed-account-types'
import type { PreloadApi } from '../api-types'
export const claudeAccountsApi = {
@@ -16,5 +17,11 @@ export const claudeAccountsApi = {
accountId: string | null
runtime?: 'host' | 'wsl'
wslDistro?: string | null
}) => ipcRenderer.invoke('claudeAccounts:select', args)
}) => ipcRenderer.invoke('claudeAccounts:select', args),
onSelectedElsewhere: (callback: (change: ClaudeAccountSelectionChange) => void) => {
const listener = (_event: Electron.IpcRendererEvent, change: ClaudeAccountSelectionChange) =>
callback(change)
ipcRenderer.on('claudeAccounts:selectedElsewhere', listener)
return () => ipcRenderer.removeListener('claudeAccounts:selectedElsewhere', listener)
}
} satisfies PreloadApi['claudeAccounts']
@@ -24,6 +24,7 @@ import { registerPreflightWslRetryIpcBridge } from './preflight-wsl-retry-ipc-br
import { registerOsMarkdownFileOpenBridge } from './os-markdown-file-open-bridge'
import { registerProjectCatalogIpcBridge } from './project-catalog-ipc-bridge'
import { registerRateLimitIpcBridge } from './rate-limit-ipc-bridge'
import { registerClaudeAccountSwitchIpcBridge } from './claude-account-switch-ipc-bridge'
import { registerRuntimeClientIpcBridge } from './runtime-client-ipc-bridge'
import { registerSessionTabIpcBridge } from './session-tab-ipc-bridge'
import { registerSettingsAndSidebarIpcBridge } from './settings-sidebar-ipc-bridge'
@@ -136,6 +137,7 @@ export function installAppLifetimeIpcEvents(): () => void {
registerBrowserTabActivateIpcBridge(unsubs)
registerTabLifecycleIpcBridge(unsubs)
registerRateLimitIpcBridge(unsubs)
registerClaudeAccountSwitchIpcBridge(unsubs)
const stopDirectSshStateBridge = registerDirectSshStateIpcBridge(unsubs)
registerZoomIpcBridge(unsubs)
const agentStatusBridge = registerAgentStatusIpcBridge(unsubs)
@@ -0,0 +1,13 @@
import { handleRunningClaudeSessionsAfterSwitch } from '@/lib/claude-account-switch-sessions'
export function registerClaudeAccountSwitchIpcBridge(unsubs: (() => void)[]): void {
const subscribe = window.api.claudeAccounts?.onSelectedElsewhere
if (!subscribe) {
return
}
unsubs.push(
subscribe((change) => {
void handleRunningClaudeSessionsAfterSwitch({ owner: { kind: 'local' }, ...change })
})
)
}
@@ -133,7 +133,8 @@ export function createClaudeAccountsApi(): PreloadApi['claudeAccounts'] {
}
return callRuntimeResult<ClaudeRateLimitAccountsState>(
'accounts.selectClaude',
{ accountId },
// This window's switcher moves running sessions itself; the host window must not too.
{ accountId, movesRunningSessions: true },
ACCOUNT_MUTATION_TIMEOUT_MS
)
}
@@ -222,7 +222,7 @@ describe('web rate limits and accounts preload API', () => {
await expect(api.claudeAccounts.select({ accountId: 'account-b' })).resolves.toEqual(selected)
expect(calls).toContainEqual({
method: 'accounts.selectClaude',
params: { accountId: 'account-b' }
params: { accountId: 'account-b', movesRunningSessions: true }
})
})
+9
View File
@@ -1,3 +1,5 @@
import type { CodexAccountSelectionTarget } from './codex-selection-lane'
export type ManagedDataAccountProvider = 'opencode' | 'devin'
export type ManagedDataAccountSummary = {
@@ -128,6 +130,13 @@ export type ClaudeRateLimitAccountsState = {
systemDefaultMayBeCopied?: boolean
}
/** A Claude switch made over runtime RPC, e.g. from the phone. */
export type ClaudeAccountSelectionChange = {
target: CodexAccountSelectionTarget
before: { state: ClaudeRateLimitAccountsState; accountId: string | null }
after: { state: ClaudeRateLimitAccountsState; accountId: string | null }
}
export type ClaudeManagedAccountRuntimeSelection = {
host: string | null
wsl: Record<string, string | null>
@@ -37,6 +37,11 @@ export const SelectAccountParams = z.object({
.transform((v) => (v === null ? null : v))
})
export const SelectClaudeAccountParams = SelectAccountParams.extend({
// The web client moves running sessions in its own window; older hosts strip this.
movesRunningSessions: z.literal(true).optional()
})
export const SelectCodexAccountForTargetParams = SelectAccountParams.extend({
target: CodexSelectionTarget
})
+2 -1
View File
@@ -59,6 +59,7 @@ import {
RemoveAccountParams,
RemoveDataAccountParams,
SelectAccountParams,
SelectClaudeAccountParams,
SelectCodexAccountForTargetParams,
SelectDataAccountParams
} from './accounts-params'
@@ -746,7 +747,7 @@ export const RPC_PARAMS_BY_METHOD = {
'accounts.removeClaude': RemoveAccountParams,
'accounts.removeCodex': RemoveAccountParams,
'accounts.removeData': RemoveDataAccountParams,
'accounts.selectClaude': SelectAccountParams,
'accounts.selectClaude': SelectClaudeAccountParams,
'accounts.selectCodex': SelectAccountParams,
'accounts.selectCodexForTarget': SelectCodexAccountForTargetParams,
'accounts.selectData': SelectDataAccountParams,