diff --git a/src/main/claude-accounts/runtime-selection.ts b/src/main/claude-accounts/runtime-selection.ts index 7d51e345e8f..06f00c025c6 100644 --- a/src/main/claude-accounts/runtime-selection.ts +++ b/src/main/claude-accounts/runtime-selection.ts @@ -136,7 +136,7 @@ export function pruneInvalidClaudeRuntimeSelection( } export function getClaudeSelectionTargetForAccount( - account: ClaudeManagedAccount + account: Pick ): ClaudeAccountSelectionTarget { if (account.managedAuthRuntime === 'wsl') { return { runtime: 'wsl', wslDistro: account.wslDistro ?? null } diff --git a/src/main/runtime/construction/runtime-host-services.ts b/src/main/runtime/construction/runtime-host-services.ts index 2daaad64865..c9e76e6654e 100644 --- a/src/main/runtime/construction/runtime-host-services.ts +++ b/src/main/runtime/construction/runtime-host-services.ts @@ -272,7 +272,7 @@ export abstract class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeW protected ptyControllerInventoryGenerationByProvider = new Map() - protected readonly accounts = new RuntimeAccountController() + protected readonly accounts = new RuntimeAccountController(() => this.notifier) protected readonly mobileSpeech = new RuntimeMobileSpeechCatalog(() => this.store) diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts index fa357dd5453..46a3663b286 100644 --- a/src/main/runtime/rpc/methods/accounts.ts +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -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', diff --git a/src/main/runtime/runtime-account-controller.test.ts b/src/main/runtime/runtime-account-controller.test.ts new file mode 100644 index 00000000000..e8629c4580f --- /dev/null +++ b/src/main/runtime/runtime-account-controller.test.ts @@ -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 +} { + 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() + }) +}) diff --git a/src/main/runtime/runtime-account-controller.ts b/src/main/runtime/runtime-account-controller.ts index 5c2efc8a4d7..4213b407bda 100644 --- a/src/main/runtime/runtime-account-controller.ts +++ b/src/main/runtime/runtime-account-controller.ts @@ -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 + 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 { - 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 { + 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 { @@ -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 + ) +} diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index c1656c3d36e..0f304ccfda5 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -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 } diff --git a/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts b/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts index aa32994ca90..81a5199592f 100644 --- a/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts +++ b/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts @@ -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', diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index eea396f5986..7f60877ebc6 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -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 diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts index ee59efe1be9..6b97a23cf6f 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -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 + onSelectedElsewhere?: (callback: (change: ClaudeAccountSelectionChange) => void) => () => void } export type GrokAccountsApi = { diff --git a/src/preload/api/claude-accounts-bridge.ts b/src/preload/api/claude-accounts-bridge.ts index 2dd7d2c23b6..95da48caf8f 100644 --- a/src/preload/api/claude-accounts-bridge.ts +++ b/src/preload/api/claude-accounts-bridge.ts @@ -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'] diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index 15e789930fd..a853f8f0bca 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -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) diff --git a/src/renderer/src/hooks/ipc-events/claude-account-switch-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/claude-account-switch-ipc-bridge.ts new file mode 100644 index 00000000000..1b2c738a8c4 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/claude-account-switch-ipc-bridge.ts @@ -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 }) + }) + ) +} diff --git a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts index f07fba0c893..87e1be5bca5 100644 --- a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts +++ b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts @@ -133,7 +133,8 @@ export function createClaudeAccountsApi(): PreloadApi['claudeAccounts'] { } return callRuntimeResult( 'accounts.selectClaude', - { accountId }, + // This window's switcher moves running sessions itself; the host window must not too. + { accountId, movesRunningSessions: true }, ACCOUNT_MUTATION_TIMEOUT_MS ) } diff --git a/src/renderer/src/web/web-preload-api-rate-limits.test.ts b/src/renderer/src/web/web-preload-api-rate-limits.test.ts index e9bd0995da2..9dc88a7bb00 100644 --- a/src/renderer/src/web/web-preload-api-rate-limits.test.ts +++ b/src/renderer/src/web/web-preload-api-rate-limits.test.ts @@ -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 } }) }) diff --git a/src/shared/managed-account-types.ts b/src/shared/managed-account-types.ts index 595c777895e..3a870d6c356 100644 --- a/src/shared/managed-account-types.ts +++ b/src/shared/managed-account-types.ts @@ -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 diff --git a/src/shared/rpc-contract/accounts-params.ts b/src/shared/rpc-contract/accounts-params.ts index c6fd5025b1b..507438ea3d3 100644 --- a/src/shared/rpc-contract/accounts-params.ts +++ b/src/shared/rpc-contract/accounts-params.ts @@ -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 }) diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 992134b4f25..be03d1d8af1 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -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,