diff --git a/src/main/host/electron-runtime-desktop-surface.ts b/src/main/host/electron-runtime-desktop-surface.ts index cb9b83d8684..e0d9d13fdc6 100644 --- a/src/main/host/electron-runtime-desktop-surface.ts +++ b/src/main/host/electron-runtime-desktop-surface.ts @@ -17,6 +17,18 @@ export const electronRuntimeDesktopSurface: RuntimeDesktopSurface = { new Notification({ title, body }).show() return true }, + sendToRenderers: (channel, payload) => { + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) { + continue + } + try { + window.webContents.send(channel, payload) + } catch { + // A renderer can disappear between isDestroyed() and send(). + } + } + }, findWindowById: (id) => BrowserWindow.fromId(id), isResourceViewVisible: (sender) => isResourceWindowVisible(BrowserWindow.fromWebContents(sender)), onIpc: (channel, listener) => { diff --git a/src/main/ipc/runtime-environments/orchestration-environment-transport.ts b/src/main/ipc/runtime-environments/orchestration-environment-transport.ts new file mode 100644 index 00000000000..3722dcb06fd --- /dev/null +++ b/src/main/ipc/runtime-environments/orchestration-environment-transport.ts @@ -0,0 +1,36 @@ +import { getAppEnvironment } from '../../../shared/app-environment' +import { getPreferredPairedRuntimeConnection } from '../../../shared/remote-runtime/host-catalog/runtime-environments' +import { resolveEnvironment } from '../../../shared/remote-runtime/host-store/runtime-environment-store' +import { + pairedOrchestrationPeers, + type OrchestrationEnvironmentTransport +} from '../../runtime/orchestration/environment-transport' + +/** Dials this host's paired servers for cross-server orchestration; host-free, so desktop and orcad share it. */ +export function createOrchestrationEnvironmentTransport(): OrchestrationEnvironmentTransport { + // Read per call: the desktop settles userData casing only after app.setName. + const registryRoot = (): string => getAppEnvironment().getPath('userData') + return { + resolve: (selector) => { + const environment = resolveEnvironment(registryRoot(), selector) + const connection = getPreferredPairedRuntimeConnection(environment) + return { + environmentId: environment.id, + name: environment.name, + ...pairedOrchestrationPeers(connection.hostPublicKeyB64, connection.credential.deviceToken), + pairingRevision: environment.pairingRevision ?? environment.createdAt + } + }, + // Loaded on first dial: the routing graph is large and most hosts never place remote workers. + call: async (selector, method, params, timeoutMs, envelope, expectedPairingRevision) => + (await import('./runtime-environment-transport-routing')).callRuntimeEnvironment( + registryRoot(), + selector, + method, + params, + timeoutMs, + expectedPairingRevision, + envelope + ) + } +} diff --git a/src/main/ipc/runtime-environments/runtime-environment-diagnostics-broadcast.ts b/src/main/ipc/runtime-environments/runtime-environment-diagnostics-broadcast.ts index f3093c3bf1d..2d0c4961255 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-diagnostics-broadcast.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-diagnostics-broadcast.ts @@ -1,6 +1,6 @@ -import { BrowserWindow } from 'electron' import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../shared/remote-runtime/control/remote-runtime-shared-control-types' import { RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL } from '../../../shared/remote-runtime/host-catalog/runtime-environment-diagnostics' +import { getRuntimeDesktopSurface } from '../../runtime/runtime-desktop-surface' export type RuntimeEnvironmentDiagnosticsEvent = { environmentId: string @@ -11,14 +11,5 @@ export type RuntimeEnvironmentDiagnosticsEvent = { export function publishRuntimeEnvironmentDiagnostics( event: RuntimeEnvironmentDiagnosticsEvent ): void { - for (const window of BrowserWindow.getAllWindows()) { - if (window.isDestroyed()) { - continue - } - try { - window.webContents.send(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, event) - } catch { - // A renderer can disappear between isDestroyed() and send(). - } - } + getRuntimeDesktopSurface().sendToRenderers?.(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, event) } diff --git a/src/main/ipc/runtime-environments/runtime-environment-status-owner.ts b/src/main/ipc/runtime-environments/runtime-environment-status-owner.ts index 4f9409f9352..d306eae2f9c 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-status-owner.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-status-owner.ts @@ -1,4 +1,4 @@ -import { BrowserWindow } from 'electron' +import { getRuntimeDesktopSurface } from '../../runtime/runtime-desktop-surface' import { sendRemoteRuntimeRequest } from '../../../shared/remote-runtime/remote-runtime-client' import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../shared/protocol-version' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../shared/electron-remote-runtime-client-capabilities' @@ -140,16 +140,7 @@ export function createRuntimeEnvironmentStatusOwner( return capable && active }, publish: (snapshot) => { - for (const window of BrowserWindow.getAllWindows()) { - if (window.isDestroyed()) { - continue - } - try { - window.webContents.send(RUNTIME_HOST_STATUS_CHANNEL, snapshot) - } catch { - /* A renderer can close during publication. */ - } - } + getRuntimeDesktopSurface().sendToRenderers?.(RUNTIME_HOST_STATUS_CHANNEL, snapshot) for (const listener of statusListeners) { listener() } diff --git a/src/main/orcad/orcad-account-services.ts b/src/main/orcad/orcad-account-services.ts index 059e7b9be0d..53939d0fef9 100644 --- a/src/main/orcad/orcad-account-services.ts +++ b/src/main/orcad/orcad-account-services.ts @@ -65,7 +65,6 @@ export function createOrcadAccountServices(args: { const services = createProviderAccountServices(store, { setClaudeStatusLineListener: args.setClaudeStatusLineListener }) - // Refresh usage on demand through list/subscribe rather than starting a background poller. args.registerCleanup(() => { services.rateLimits.stop() args.setClaudeStatusLineListener(null) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 6c6d1b0df43..265422ef42e 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -189,10 +189,9 @@ async function startOrcadRuntime( const { loadAgentSessionClaimSigner } = await import('../runtime/agent-session/ownership/agent-session-claim-identity') - let runtimeRpc: InstanceType | null = null + const { createOrcadPairedEnvironmentOptions } = await import('./orcad-paired-environment-options') + const pairedEnvironments = createOrcadPairedEnvironmentOptions() const runtime = new OrcaRuntimeService(profileStore, stats, { - getPairedDeviceName: (deviceId) => - runtimeRpc?.getDeviceRegistry()?.getDevice(deviceId)?.name ?? null, // Persistent signatures let restarted runtimes adopt PTYs owned before restart. agentSessionClaimSigner: loadAgentSessionClaimSigner(runtimeUserDataPath, runtimeUserDataPath), getAdditionalAiVaultCodexHomePaths: () => [ @@ -206,6 +205,7 @@ async function startOrcadRuntime( ), // Resolve lazily because daemon swaps replace the provider after construction. getLocalProvider: () => getLocalPtyProvider(), + ...pairedEnvironments.runtimeOptions, // Read current daemon ownership; failed/degraded launch cannot claim fresh PTY persistence. canRecoverPersistentLocalPtys: () => daemonOwnsFreshPersistentPtys(), // Node hosts cannot promote into desktop windows; override the constructor’s openable default. @@ -304,7 +304,7 @@ async function startOrcadRuntime( webClientRoot: resolveOrcadWebClientRoot(options.webClientRoot), ...orcadWsPortOptions(options.port) }) - runtimeRpc = rpc + pairedEnvironments.bindRpc(rpc) runtime.setAgentSenderPeerKey(() => rpc.getE2EEPublicKey()) // Stop RPC before cleanup can tear down writers and runtime state. registerCleanup(() => rpc.stop()) diff --git a/src/main/orcad/orcad-paired-environment-options.ts b/src/main/orcad/orcad-paired-environment-options.ts new file mode 100644 index 00000000000..7070991a9ee --- /dev/null +++ b/src/main/orcad/orcad-paired-environment-options.ts @@ -0,0 +1,27 @@ +import { createOrchestrationEnvironmentTransport } from '../ipc/runtime-environments/orchestration-environment-transport' +import type { RuntimeConstructionDependencies } from '../runtime/construction/runtime-construction-dependencies' +import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' + +type PairedEnvironmentRuntimeOptions = Required< + Pick +> + +/** Runtime options for this host's pairings: the servers it dials and the devices paired to it. */ +export function createOrcadPairedEnvironmentOptions(): { + runtimeOptions: PairedEnvironmentRuntimeOptions + /** RPC owns the device registry and starts after the runtime. */ + bindRpc: (rpc: Pick) => void +} { + let rpc: Pick | null = null + return { + runtimeOptions: { + // A headless host is a Run home too; it places workers on its own paired servers (#36853). + orchestrationEnvironmentTransport: createOrchestrationEnvironmentTransport(), + getPairedDeviceName: (pairedDeviceId) => + rpc?.getDeviceRegistry()?.getDevice(pairedDeviceId)?.name ?? null + }, + bindRpc: (server) => { + rpc = server + } + } +} diff --git a/src/main/providers/accounts/provider-account-services.ts b/src/main/providers/accounts/provider-account-services.ts index e173b8f6457..4cac2c103e4 100644 --- a/src/main/providers/accounts/provider-account-services.ts +++ b/src/main/providers/accounts/provider-account-services.ts @@ -173,5 +173,7 @@ export function createProviderAccountServices( ) })) }) + // Every host runs the poll timer; ticks fetch only while a window or remote reader watches. + rateLimits.start({ fetchImmediately: false }) return { rateLimits, codexRuntimeHome, codexAccounts, claudeRuntimeAuth, claudeAccounts } } diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index 81aeff98171..9c0ba166e5f 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -129,6 +129,32 @@ describe('RateLimitService', () => { service.stop() }) + // #37256: a headless host has no window, so its only watchers are remote account streams. + it('polls without a window only while a remote reader watches', async () => { + vi.useFakeTimers() + try { + mockFreshBackgroundProviderFetches() + vi.mocked(fetchClaudeRateLimits).mockImplementation(async () => okProvider('claude', 12)) + const service = new RateLimitService() + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(15 * 60 * 1000) + expect(fetchClaudeRateLimits).not.toHaveBeenCalled() + + const release = service.addUsageViewer() + await vi.advanceTimersByTimeAsync(15 * 60 * 1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + release() + await vi.advanceTimersByTimeAsync(15 * 60 * 1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + service.stop() + } finally { + vi.useRealTimers() + } + }) + it('performs a one-shot active-window fetch when startup focus was missed', async () => { vi.useFakeTimers() try { diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts index e8677bb7b85..fc66c6160d7 100644 --- a/src/main/rate-limits/service/service-configuration.ts +++ b/src/main/rate-limits/service/service-configuration.ts @@ -85,7 +85,7 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco this.detachWindowListeners?.() this.mainWindow = mainWindow const refreshOnResume = (): void => { - void this.refreshIfWindowActive() + void this.refreshIfWatched() } // Replacing windows must also detach the old closed listener. const detachWindowListeners = (): void => { @@ -108,10 +108,25 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco mainWindow.on('restore', refreshOnResume) mainWindow.on('closed', onClosed) this.detachWindowListeners = detachWindowListeners + // Quota probes spawn CLIs/use network; defer past first paint, then show/focus refresh. + this.scheduleDeferredStartupRefresh() + } + + /** Counts a paired client's usage stream so polls run without a focused window; returns its release. */ + addUsageViewer(): () => void { + this.usageViewers += 1 + let released = false + return () => { + if (!released) { + released = true + this.usageViewers -= 1 + } + } } start(options: { fetchImmediately?: boolean } = {}): void { if (options.fetchImmediately !== false) { + this.clearDeferredStartupRefresh() void this.fetchAll() } else { this.scheduleDeferredStartupRefresh() diff --git a/src/main/rate-limits/service/service-polling.ts b/src/main/rate-limits/service/service-polling.ts index f4cc9c1f48b..9ef7bbb8061 100644 --- a/src/main/rate-limits/service/service-polling.ts +++ b/src/main/rate-limits/service/service-polling.ts @@ -42,7 +42,7 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue this.clearDeferredStartupRefresh() this.deferredStartupRefreshTimer = setTimeout(() => { this.deferredStartupRefreshTimer = null - void this.refreshIfWindowActive() + void this.refreshIfWatched() }, DEFERRED_STARTUP_ACTIVE_REFRESH_MS) } @@ -54,6 +54,10 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue } protected shouldBackgroundPoll(): boolean { + // Poll only while someone watches: a paired client's stream, or the focused desktop window. + if (this.usageViewers > 0) { + return true + } if (!this.mainWindow || this.mainWindow.isDestroyed()) { return false } @@ -174,7 +178,7 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue } } - protected async refreshIfWindowActive(): Promise { + protected async refreshIfWatched(): Promise { if (!this.shouldBackgroundPoll()) { return } diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index 59b3cc592d0..1fce546ed42 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -71,6 +71,8 @@ export abstract class RateLimitServiceState { zcode: 0 } protected mainWindow: BrowserWindow | null = null + /** Paired clients streaming usage (accounts.subscribe); headless hosts have no window to watch. */ + protected usageViewers = 0 protected detachWindowListeners: (() => void) | null = null protected isFetching = false protected fullFetchQueued = false diff --git a/src/main/runtime/construction/runtime-composition-parity.test.ts b/src/main/runtime/construction/runtime-composition-parity.test.ts index 3c724fd12f6..7cb1aad213e 100644 --- a/src/main/runtime/construction/runtime-composition-parity.test.ts +++ b/src/main/runtime/construction/runtime-composition-parity.test.ts @@ -75,11 +75,6 @@ const DECLARED_HOST_DIFFERENCES: readonly DeclaredDifference[] = [ missingOn: 'orcad', reason: 'Pairs with attestAgentHookCompatibilityAuthority.' }, - { - wiring: 'ctor:orchestrationEnvironmentTransport', - missingOn: 'orcad', - reason: 'Cross-environment orchestration dials paired environments from the desktop only.' - }, { wiring: 'ctor:skillTransactionRecovery', missingOn: 'orcad', @@ -160,6 +155,68 @@ const DECLARED_HOST_DIFFERENCES: readonly DeclaredDifference[] = [ } ] +type WindowAttachStart = { + reason: string + /** Where each headless host starts its own equivalent; omitted only when none needs one. */ + headless?: Record +} + +/** Background work started when a desktop window attaches; headless hosts never attach one (#37256). */ +const WINDOW_ATTACH_STARTS: Readonly> = { + automations: { + reason: 'The desktop binds automations to its window; headless hosts start them at launch.', + headless: { + electron: { + file: 'src/main/startup/main-process-runtime-launch.ts', + call: 'automations?.start()' + }, + orcad: { file: 'src/main/orcad/orcad-entry.ts', call: 'startOrcadAutomations(' } + } + }, + 'rateLimits.attach': { + reason: 'A focused window is one usage watcher; the shared account composition runs the poller.' + }, + initTccPromptNotice: { reason: 'macOS privacy-prompt notices render in the window.' }, + installPluginDesktopFrameServices: { reason: 'Plugin frames render inside the window.' }, + attachMainWindowServices: { + reason: 'Window IPC and renderer wiring; its starts are listed here.' + }, + hydrateLocalPtyRegistryAtBoot: { + reason: 'Headless PTY registration hydrates the same registry.', + headless: { + electron: { + file: 'src/main/startup/main-process-runtime-launch.ts', + call: 'registerHeadlessPtyRuntime(' + }, + orcad: { file: 'src/main/orcad/orcad-entry.ts', call: 'registerHeadlessPtyRuntime(' } + } + }, + scheduleMainWindowAutoUpdaterSetup: { + reason: 'Serve sets up its own updater; orcad is updated by the client that manages it.' + }, + scheduleWorktreeBaseDirectoryWatcherSync: { + reason: 'Unwired gap (follow-up): external worktree changes on a headless host push nothing.' + }, + startFolderRepoGitUpgradeWatch: { + reason: + 'Unwired gap (follow-up): git init in a headless folder project needs a manual type change.' + }, + scheduleHistoryGc: { + reason: 'Unwired gap (follow-up): headless hosts never prune history of vanished worktrees.' + }, + 'openCodeHookService.configDirGc.schedule': { + reason: 'Unwired gap (follow-up): headless hosts never prune stale OpenCode overlays.' + }, + 'openCode2HookService.configDirGc.schedule': { + reason: 'Unwired gap (follow-up): headless hosts never prune stale OpenCode overlays.' + } +} + +const WINDOW_ATTACH_SOURCES = [ + { file: 'src/main/startup/main-window-core-services.ts', fn: 'attachMainWindowCoreServices' }, + { file: 'src/main/window/attach-main-window-services.ts', fn: 'attachMainWindowServices' } +] as const + const UNWIRED_CONSTRUCTION_OPTIONS: Readonly> = {} const POSITIONAL_CONSTRUCTOR_PARAMETERS = ['stats'] as const @@ -206,6 +263,10 @@ const SPREAD_SOURCES: Record = { file: 'src/main/orcad/orcad-account-services.ts', anchor: 'runtimeDeps: {' }, + 'pairedEnvironments.runtimeOptions': { + file: 'src/main/orcad/orcad-paired-environment-options.ts', + anchor: 'runtimeOptions: {' + }, 'orcadWsPortOptions(': { file: 'src/main/orcad/orcad-ws-port-policy.ts', anchor: 'wsPort: port' } } @@ -399,6 +460,31 @@ function sourceFilesUnder(dir: string): string[] { .map((file) => join(dir, file).split(sep).join('/')) } +function functionBody(source: string, name: string): string { + const at = source.indexOf(`export function ${name}(`) + if (at === -1) { + throw new Error(`function not found: ${name}`) + } + const open = source.indexOf('(', at) + const paramsEnd = open + parenGroup(source, open).length + 1 + return braceGroup(source, source.indexOf('{', paramsEnd)) +} + +/** Calls whose final member starts, schedules, hydrates, initializes, attaches or installs something. */ +function windowAttachStarts(): Set { + const starts = new Set() + for (const { file, fn } of WINDOW_ATTACH_SOURCES) { + const body = functionBody(read(file), fn) + for (const [, callee] of body.matchAll( + /(? { 'ctor:getLocalProvider', 'ctor:onTerminalAgentStatus', 'ctor:agentSessionClaimSigner', + // #36853: without it a headless Run home refuses worker-start --on with server_required. + 'ctor:orchestrationEnvironmentTransport', + 'ctor:getPairedDeviceName', 'rpc-option:webClientRoot', 'rpc-option:wsPort', 'runtime.setAccountServices', @@ -492,6 +581,16 @@ describe('runtime composition parity (Electron vs orcad)', () => { } }) + it('accounts for every service a desktop window starts, which headless hosts never attach', () => { + expect([...windowAttachStarts()].sort()).toEqual(Object.keys(WINDOW_ATTACH_STARTS).sort()) + for (const [start, { headless }] of Object.entries(WINDOW_ATTACH_STARTS)) { + for (const [root, counterpart] of Object.entries(headless ?? {})) { + expect(read(counterpart.file), `${root} ${start}`).toContain(counterpart.call) + } + } + }) + + // #37256: one composition runs the usage poller on desktop, Electron serve and orcad alike. it('lists each advertised-but-unserved capability against what the host advertises', () => { for (const difference of DECLARED_HOST_DIFFERENCES) { expect(difference.reason, difference.wiring).not.toBe('') diff --git a/src/main/runtime/remote-runtime-request-connection.integration.test.ts b/src/main/runtime/remote-runtime-request-connection.integration.test.ts index 26eef69eb93..6b11b474746 100644 --- a/src/main/runtime/remote-runtime-request-connection.integration.test.ts +++ b/src/main/runtime/remote-runtime-request-connection.integration.test.ts @@ -562,6 +562,7 @@ describe('remote runtime request connection integration', () => { }, getAccountsSnapshot: () => ({ claude: null, codex: null }), getAccountsStreamSnapshot: () => ({ claude: null, codex: null }), + getUsageRateLimits: () => null, refreshAccountsForMobile: async () => { for (const listener of accountsListeners) { listener({ claude: null, codex: null }) diff --git a/src/main/runtime/rpc/methods/accounts.test.ts b/src/main/runtime/rpc/methods/accounts.test.ts index 1f31fb6c5db..cca7528f234 100644 --- a/src/main/runtime/rpc/methods/accounts.test.ts +++ b/src/main/runtime/rpc/methods/accounts.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import type { OrcaRuntimeService } from '../../orca-runtime' import { eraseRpcMethods, isStreamingMethod, type RpcContext } from '../core' import { ACCOUNT_METHODS } from './accounts' +import { RateLimitService } from '../../../rate-limits/service' import { createEmptyRateLimitState } from '../../../../shared/rate-limit-state-factory' import type { HostAccountSignInChannel, @@ -337,6 +338,62 @@ describe('account RPC methods', () => { }) }) +// #37256: a paired client's stream keeps a windowless host polling; the host's own renderer follows window focus. +it.each([ + { caller: { kind: 'paired-device', deviceId: 'phone-1' } as const, holds: true }, + { caller: { kind: 'desktop' } as const, holds: false } +])('holds a usage viewer for a $caller.kind stream: $holds', async ({ caller, holds }) => { + const rateLimits = new RateLimitService() + const releaseViewer = vi.fn() + const addUsageViewer = vi.spyOn(rateLimits, 'addUsageViewer').mockReturnValue(releaseViewer) + let cleanup: (() => void) | undefined + const runtime: Pick< + OrcaRuntimeService, + | 'getAccountsStreamSnapshot' + | 'getUsageRateLimits' + | 'onAccountsChanged' + | 'registerSubscriptionCleanup' + | 'cleanupSubscription' + | 'refreshAccountsForMobileSubscriber' + > = { + getAccountsStreamSnapshot: vi.fn(() => ({ + claude: { accounts: [], activeAccountId: null }, + codex: { accounts: [], activeAccountId: null }, + rateLimits: createEmptyRateLimitState() + })), + getUsageRateLimits: () => rateLimits, + onAccountsChanged: vi.fn(() => vi.fn()), + registerSubscriptionCleanup: vi.fn((_id: string, next: () => void) => { + cleanup = next + }), + cleanupSubscription: vi.fn(() => { + const release = cleanup + cleanup = undefined + release?.() + }), + refreshAccountsForMobileSubscriber: vi.fn().mockResolvedValue(undefined) + } + const subscribe = method('accounts.subscribe') + if (!isStreamingMethod(subscribe)) { + throw new Error('accounts.subscribe must be a streaming method') + } + const stream: { + handler( + params: unknown, + context: { runtime: typeof runtime; caller?: RpcContext['caller']; signal?: AbortSignal }, + emit: (frame: unknown) => void + ): Promise + } = subscribe + const controller = new AbortController() + const running = stream.handler(undefined, { runtime, caller, signal: controller.signal }, vi.fn()) + await vi.waitFor(() => expect(runtime.refreshAccountsForMobileSubscriber).toHaveBeenCalled()) + + expect(addUsageViewer).toHaveBeenCalledTimes(holds ? 1 : 0) + controller.abort() + await running + expect(releaseViewer).toHaveBeenCalledTimes(holds ? 1 : 0) +}) + it('rejects the retired Claude import even for a local socket, before touching its supplied path', async () => { const old = method('accounts.addClaudeFromConfigDir') if (isStreamingMethod(old)) { diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts index 46a3663b286..bedfa01ef37 100644 --- a/src/main/runtime/rpc/methods/accounts.ts +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -240,7 +240,7 @@ const ACCOUNT_METHOD_DEFINITIONS = [ name: 'accounts.subscribe', permission: 'workspace', params: null, - handler: async (_params, { runtime, connectionId, signal }, emit) => { + handler: async (_params, { runtime, connectionId, signal, caller }, emit) => { if (signal?.aborted) { return } @@ -248,6 +248,11 @@ const ACCOUNT_METHOD_DEFINITIONS = [ const unsubscribe = runtime.onAccountsChanged((snapshot) => { emit({ type: 'snapshot', snapshot }) }) + // A paired client watching keeps polls running without a window; the host's own renderer follows window focus. + const releaseUsageViewer = + caller?.kind === 'paired-device' + ? runtime.getUsageRateLimits()?.addUsageViewer() + : undefined // Key cleanup by socket plus sequence so sibling sockets and concurrent subscribes cannot evict each other. const seq = ++accountsSubscriptionSeq @@ -258,6 +263,7 @@ const ACCOUNT_METHOD_DEFINITIONS = [ () => { signal?.removeEventListener('abort', release) unsubscribe() + releaseUsageViewer?.() stopped.resolve() }, connectionId diff --git a/src/main/runtime/runtime-desktop-surface.ts b/src/main/runtime/runtime-desktop-surface.ts index 82eea3e33d8..1575e63a7bd 100644 --- a/src/main/runtime/runtime-desktop-surface.ts +++ b/src/main/runtime/runtime-desktop-surface.ts @@ -10,6 +10,8 @@ export type RuntimeDesktopSurface = { isAwayForMobileNotifications?(): boolean | undefined /** Show a native notification. Returns false when the host cannot, so callers can say so. */ showNotification(input: { title: string; body: string }): boolean + /** Sends to every open renderer window. Absent where there is no desktop. */ + sendToRenderers?(channel: string, payload: unknown): void /** The renderer window with this id, or null when there is no desktop. */ findWindowById(id: number): BrowserWindow | null isResourceViewVisible?(sender: WebContents): boolean diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 5042fd2d591..2c426f30528 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -22,15 +22,9 @@ import { resolveHostCodexSessionSourceHome } from '../codex/history/rollouts/cod import { agentHookRuntimeDeps } from '../agent-hooks/agent-hook-runtime-deps' import { getDaemonProvider } from '../daemon/daemon-provider-state' import type { TerminalSideEffectBatch } from '../../shared/terminal/output/terminal-side-effect-facts' - -import { resolveEnvironment } from '../../shared/remote-runtime/host-store/runtime-environment-store' import { resolveTuiAgentLaunchEnv } from '../../shared/agent-launch/choices/tui-agent-launch-defaults' -import { getPreferredPairedRuntimeConnection } from '../../shared/remote-runtime/host-catalog/runtime-environments' -import { - type OrchestrationEnvironmentTransport, - pairedOrchestrationPeers -} from '../runtime/orchestration/environment-transport' import { callRuntimeEnvironment } from '../ipc/runtime-environments/runtime-environment-transport-routing' +import { createOrchestrationEnvironmentTransport } from '../ipc/runtime-environments/orchestration-environment-transport' import { mainProcessState as state } from './main-process-state' import { suspendEphemeralVmWorkspace } from '../ephemeral-vm/workspace-suspend' import { installWslOrcadAccountShim } from './wsl-orcad-account-env-wiring' @@ -68,28 +62,6 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { if (!store || !stats) { throw new Error('Store and stats must be initialized before runtime') } - const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = { - resolve: (selector) => { - const environment = resolveEnvironment(getRuntimeEnvironmentRegistryRoot(), selector) - const connection = getPreferredPairedRuntimeConnection(environment) - return { - environmentId: environment.id, - name: environment.name, - ...pairedOrchestrationPeers(connection.hostPublicKeyB64, connection.credential.deviceToken), - pairingRevision: environment.pairingRevision ?? environment.createdAt - } - }, - call: (selector, method, params, timeoutMs, envelope, expectedPairingRevision) => - callRuntimeEnvironment( - getRuntimeEnvironmentRegistryRoot(), - selector, - method, - params, - timeoutMs, - expectedPairingRevision, - envelope - ) - } // Process-scope subscription covers desktop, headless serve and fleet status. const observedPaneIdentities = new AgentStatusObservedPaneIdentities() const runtime = new OrcaRuntimeService(store, stats, { @@ -134,7 +106,7 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { homePath, resolveTuiAgentLaunchEnv('codex', store.getSettings()) ), - orchestrationEnvironmentTransport, + orchestrationEnvironmentTransport: createOrchestrationEnvironmentTransport(), workspaceSleepPlatform: { callEnvironment: (environmentId, method, params, timeoutMs) => callRuntimeEnvironment( diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index 119f807b3ac..9b8a81c780f 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -161,6 +161,4 @@ export function attachMainWindowCoreServices( // Attach durable diagnostic pulls now, defer process launch past first paint. initTccPromptNotice(window, { deferWatchUntilReadyToShow: true }) rateLimits.attach(window) - // Quota probes spawn CLIs/use network; defer to show/focus refresh so they do not compete with first paint. - rateLimits.start({ fetchImmediately: false }) } diff --git a/src/renderer/src/components/status-bar/use-owner-usage-rate-limits.ts b/src/renderer/src/components/status-bar/use-owner-usage-rate-limits.ts index d81af775573..f07bb25e748 100644 --- a/src/renderer/src/components/status-bar/use-owner-usage-rate-limits.ts +++ b/src/renderer/src/components/status-bar/use-owner-usage-rate-limits.ts @@ -107,7 +107,7 @@ export function useOwnerUsageRateLimits(local: RateLimitState): OwnerUsageRateLi } let active = true const reopen = (): void => setStreamGeneration((value) => value + 1) - // Why: orcad does not poll on its own, so a long-lived stream would freeze the meters. + // Why: older hosts never poll for a paired stream, so a long-lived one would freeze the meters. const staleTimer = setTimeout(reopen, REMOTE_USAGE_STALE_REOPEN_MS) let reconnectTimer: ReturnType | undefined const lost = (): void => {