diff --git a/.oxlintrc.json b/.oxlintrc.json
index d0b480c6b59..9601fee85cc 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -114,6 +114,22 @@
"unicorn/throw-new-error": "error"
},
"overrides": [
+ {
+ "files": ["src/main/backend/**/*.ts"],
+ "rules": {
+ "no-restricted-imports": [
+ "error",
+ {
+ "patterns": [
+ {
+ "group": ["electron", "electron/*"],
+ "message": "Backend services use the typed desktop capability bridge; only the shell imports Electron."
+ }
+ ]
+ }
+ ]
+ }
+ },
{
"files": ["**/*.{js,cjs,mjs,jsx,ts,tsx,mts,cts}"],
"excludeFiles": [
@@ -151,6 +167,47 @@
]
}
},
+ {
+ "files": ["src/main/backend/**/*.ts"],
+ "excludeFiles": [
+ "**/*.{test,spec}.{js,cjs,mjs,jsx,ts,tsx,mts,cts}",
+ "**/__tests__/**",
+ "src/renderer/src/runtime/web-runtime-browser-creation-placement-test-rig.ts"
+ ],
+ "rules": {
+ "no-restricted-imports": [
+ "error",
+ {
+ "paths": [
+ {
+ "name": "@linear/sdk",
+ "allowTypeImports": true,
+ "message": "Value-importing @linear/sdk hoists its ~2.6MB CJS bundle into the eager top-level require block and defeats the lazy loader. Use `import type` plus loadLinearSdk() from src/main/linear/linear-sdk.ts."
+ },
+ {
+ "name": "ssh2",
+ "allowTypeImports": true,
+ "message": "Value-importing ssh2 hoists it into the eager main-process require block and blocks startup (#35731). Use `import type` plus loadSsh2() from src/main/ssh/ssh2-module-load.ts."
+ }
+ ],
+ "patterns": [
+ {
+ "regex": "(?i)(?:^|[/\\\\])(?:web-session-tabs-sync-test-harness|web-runtime-browser-creation-placement-test-rig)(?:\\.[cm]?[jt]sx?)?(?:[?#].*)?$",
+ "message": "This module is unit-test support. Import the production web-session owner instead."
+ },
+ {
+ "regex": "\\.fixture(?:\\.tsx?)?(?:[?#].*)?$",
+ "message": "Component fixtures are dev-only and load through the fixture explorer's import.meta.glob; importing one would ship it in a production bundle."
+ },
+ {
+ "group": ["electron", "electron/*"],
+ "message": "Backend services use the typed desktop capability bridge; only the shell imports Electron."
+ }
+ ]
+ }
+ ]
+ }
+ },
{
"files": ["src/renderer/src/**/*.{ts,tsx}"],
"rules": {
diff --git a/config/build-plugins/main-build-entry-points.ts b/config/build-plugins/main-build-entry-points.ts
index 1535e239862..d62affdbd34 100644
--- a/config/build-plugins/main-build-entry-points.ts
+++ b/config/build-plugins/main-build-entry-points.ts
@@ -5,6 +5,7 @@ import { CLI_MAIN_ENTRY_NAMES } from './cli-main-entry-names'
/** Main-build Rollup inputs: output file name (without .js) to its source entry. */
export const MAIN_BUILD_ENTRY_POINTS: Record = {
index: resolve('src/main/index.ts'),
+ 'backend-entry': resolve('src/main/backend/backend-entry.ts'),
'browser-extension-native-host-supervisor-entry': resolve(
'src/main/browser/extensions/native-messaging/browser-extension-native-host-supervisor-entry.ts'
),
diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts
index 1a031934482..c6d796ebd17 100644
--- a/config/build-plugins/plain-node-entry-guard.ts
+++ b/config/build-plugins/plain-node-entry-guard.ts
@@ -15,6 +15,7 @@ type OutputChunk = Rollup.OutputChunk
// Plain-Node processes and CLI modules cannot load Electron's API.
const PLAIN_NODE_ENTRY_NAMES = [
+ 'backend-entry',
'browser-extension-native-host-supervisor-entry',
'daemon-entry',
'parcel-watcher-process-entry',
diff --git a/config/scripts/static-analysis/process-boundaries/check-runtime-electron-ratchet.mjs b/config/scripts/static-analysis/process-boundaries/check-runtime-electron-ratchet.mjs
index 5fd2dd67e4c..cf8e42f10ba 100644
--- a/config/scripts/static-analysis/process-boundaries/check-runtime-electron-ratchet.mjs
+++ b/config/scripts/static-analysis/process-boundaries/check-runtime-electron-ratchet.mjs
@@ -23,6 +23,7 @@ const ENTRY_POINTS = [
// Whole lane directories cover new files automatically; only structured-chat files are plain-Node-safe inside runtime/.
export const STRUCTURED_CHAT_LANES = [
+ { directory: ['src', 'main', 'backend'] },
{ directory: ['src', 'main', 'native-chat'] },
{ directory: ['src', 'main', 'claude'] },
{ directory: ['src', 'main', 'codex'] },
diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json
index 0c4e66706e0..0b01e9e1eb6 100644
--- a/config/tsconfig.tc.web.json
+++ b/config/tsconfig.tc.web.json
@@ -14,6 +14,7 @@
"../src/renderer/src/**/*.tsx",
"../src/preload/api-types.ts",
"../src/preload/api/**/*",
+ "../src/preload/backend-port-client.ts",
"../src/preload/browser-client-page-renderer-requests.ts",
"../src/preload/browser-find-subscriptions.ts",
"../src/preload/close-active-tab-payload-admission.ts",
diff --git a/src/main/automations/run-usage-collection.ts b/src/main/automations/run-usage-collection.ts
index 32fe2a51471..c47aa3876fa 100644
--- a/src/main/automations/run-usage-collection.ts
+++ b/src/main/automations/run-usage-collection.ts
@@ -1,3 +1,4 @@
+import type { AutomationUsageClient } from '../../shared/backend-service-contract'
import type { Automation } from '../../shared/automations/automation-definition'
import type {
AutomationRun,
@@ -5,8 +6,6 @@ import type {
} from '../../shared/automations/history/automation-run'
import type { Store } from '../persistence/loading-store/store'
import type { AutomationRunWriter } from './automation-run-writer'
-import type { ClaudeUsageStore } from '../claude-usage/store'
-import type { CodexUsageStore } from '../codex-usage/store'
function createUnavailableAutomationUsage(
collectedAt: number,
@@ -54,8 +53,8 @@ export async function collectAutomationRunUsage({
}: {
automation: Automation | undefined
run: AutomationRun
- claudeUsage: ClaudeUsageStore | null
- codexUsage: CodexUsageStore | null
+ claudeUsage: AutomationUsageClient | null
+ codexUsage: AutomationUsageClient | null
}): Promise {
const collectedAt = Date.now()
const unavailable = (
@@ -83,23 +82,31 @@ export async function collectAutomationRunUsage({
if (!claudeUsage) {
return unavailable('claude', 'scan_failed', 'Claude usage store is unavailable.')
}
- return claudeUsage.getAutomationRunUsage({
- worktreeId: run.workspaceId,
- terminalSessionId: run.terminalSessionId,
- startedAt: run.startedAt,
- completedAt: collectedAt
- })
+ return claudeUsage
+ .getAutomationRunUsage({
+ worktreeId: run.workspaceId,
+ terminalSessionId: run.terminalSessionId,
+ startedAt: run.startedAt,
+ completedAt: collectedAt
+ })
+ .catch(() =>
+ unavailable('claude', 'scan_failed', 'Claude usage was unavailable when this run finished.')
+ )
}
if (automation.agentId === 'codex') {
if (!codexUsage) {
return unavailable('codex', 'scan_failed', 'Codex usage store is unavailable.')
}
- return codexUsage.getAutomationRunUsage({
- worktreeId: run.workspaceId,
- terminalSessionId: run.terminalSessionId,
- startedAt: run.startedAt,
- completedAt: collectedAt
- })
+ return codexUsage
+ .getAutomationRunUsage({
+ worktreeId: run.workspaceId,
+ terminalSessionId: run.terminalSessionId,
+ startedAt: run.startedAt,
+ completedAt: collectedAt
+ })
+ .catch(() =>
+ unavailable('codex', 'scan_failed', 'Codex usage was unavailable when this run finished.')
+ )
}
return unavailable(null, 'provider_unsupported', 'This agent does not report usage to Orca yet.')
}
@@ -108,8 +115,8 @@ export async function writeAutomationRunUsage(input: {
store: Store
runs: AutomationRunWriter
run: AutomationRun
- claudeUsage: ClaudeUsageStore | null
- codexUsage: CodexUsageStore | null
+ claudeUsage: AutomationUsageClient | null
+ codexUsage: AutomationUsageClient | null
}): Promise {
const { store, run } = input
const usage = await collectAutomationRunUsage({
diff --git a/src/main/automations/runtime-automation-service.ts b/src/main/automations/runtime-automation-service.ts
index 3a8f4b8d796..025b1530085 100644
--- a/src/main/automations/runtime-automation-service.ts
+++ b/src/main/automations/runtime-automation-service.ts
@@ -1,10 +1,9 @@
+import type { AutomationUsageClient } from '../../shared/backend-service-contract'
import { observeAutomationChatRun } from './runtime-chat-run-observer'
import { readAutomationRunReceipt } from './automation-run-receipt'
import { publishedLaunchTarget } from './automation-agent-run-result'
import { launchAutomationChatAgent } from './automation-chat-agent-launch'
import { tuiAgentDisplayName } from '../../shared/agent-launch/catalog/tui-agent-display-names'
-import type { ClaudeUsageStore } from '../claude-usage/store'
-import type { CodexUsageStore } from '../codex-usage/store'
import type { Store } from '../persistence/loading-store/store'
import type { AutomationRun } from '../../shared/automations/history/automation-run'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
@@ -29,8 +28,8 @@ const MAX_REMEMBERED_LAUNCHES = 256
export function createRuntimeAutomationService(input: {
store: Store
runtime: OrcaRuntimeService
- claudeUsage?: ClaudeUsageStore
- codexUsage?: CodexUsageStore
+ claudeUsage?: AutomationUsageClient
+ codexUsage?: AutomationUsageClient
/** Only server processes execute remote_host_service schedules. */
allowRemoteHostScheduling: boolean
}): AutomationService {
diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts
index 2e54dbd6db0..73fadb23e6a 100644
--- a/src/main/automations/service.test.ts
+++ b/src/main/automations/service.test.ts
@@ -68,6 +68,49 @@ describe('AutomationService', () => {
rmSync(testState.dir, { recursive: true, force: true })
})
+ it('keeps completed history when the usage backend disconnects', async () => {
+ const store = await createStore()
+ store.addRepo(makeRepo())
+ const automation = store.createAutomation({
+ name: 'Check',
+ prompt: 'Check spend',
+ agentId: 'claude',
+ projectId: 'r1',
+ workspaceMode: 'existing',
+ workspaceId: 'wt1',
+ timezone: 'UTC',
+ rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
+ dtstart: Date.now()
+ })
+ const run = store.createAutomationRun(automation, Date.now(), 'manual')
+ const service = new AutomationService(store, {
+ claudeUsage: {
+ getAutomationRunUsage: async () => {
+ throw new Error('Backend disconnected')
+ }
+ }
+ })
+ try {
+ const updated = await service.markDispatchResult({
+ runId: run.id,
+ status: 'completed',
+ workspaceId: 'wt1',
+ terminalSessionId: 'tab-1',
+ error: null
+ })
+ expect(updated).toMatchObject({
+ status: 'completed',
+ usage: { status: 'unavailable', provider: 'claude', unavailableReason: 'scan_failed' }
+ })
+ expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({
+ status: 'completed',
+ usage: { status: 'unavailable' }
+ })
+ } finally {
+ service.stop()
+ }
+ })
+
it('does not throw when retention evicts the run during usage collection', async () => {
vi.setSystemTime(new Date('2026-05-13T10:00:00'))
const store = await createStore()
diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts
index e25759b4b10..36fb9ba7d14 100644
--- a/src/main/automations/service.ts
+++ b/src/main/automations/service.ts
@@ -1,3 +1,4 @@
+import type { AutomationUsageClient } from '../../shared/backend-service-contract'
import {
AutomationDispatchCancelledError,
requestAutomationDispatch,
@@ -11,8 +12,6 @@ import {
type AutomationRun
} from '../../shared/automations/history/automation-run'
import type { Automation } from '../../shared/automations/automation-definition'
-import type { ClaudeUsageStore } from '../claude-usage/store'
-import type { CodexUsageStore } from '../codex-usage/store'
import {
captureLegacyRunTarget,
resolveAutomationRunTarget,
@@ -50,8 +49,8 @@ export class AutomationService {
private evaluating = false
private stopped = false
private dispatchGeneration = 0
- private readonly claudeUsage: ClaudeUsageStore | null
- private readonly codexUsage: CodexUsageStore | null
+ private readonly claudeUsage: AutomationUsageClient | null
+ private readonly codexUsage: AutomationUsageClient | null
private readonly allowRemoteHostScheduling: boolean
private readonly terminalLauncher: AutomationTerminalLauncher | null
private readonly chatDispatcher: AutomationLauncher | null
@@ -69,8 +68,8 @@ export class AutomationService {
store: Store,
opts: {
tickMs?: number
- claudeUsage?: ClaudeUsageStore
- codexUsage?: CodexUsageStore
+ claudeUsage?: AutomationUsageClient
+ codexUsage?: AutomationUsageClient
allowRemoteHostScheduling?: boolean
terminalLauncher?: AutomationTerminalLauncher
chatDispatcher?: AutomationLauncher
diff --git a/src/main/backend-desktop/backend-bootstrap.test.ts b/src/main/backend-desktop/backend-bootstrap.test.ts
new file mode 100644
index 00000000000..f899db4f8f8
--- /dev/null
+++ b/src/main/backend-desktop/backend-bootstrap.test.ts
@@ -0,0 +1,76 @@
+import { join } from 'node:path'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { getAppEnvironment, setAppEnvironment } from '../../shared/app-environment'
+import { BackendBootstrapSchema } from '../../shared/backend-service-contract'
+import { createDesktopBackendEnvironment } from '../backend/node-service-environment'
+import { getLogsDirectory } from '../observability/logs-directory'
+import { captureBackendBootstrap, capturedBootstrap } from './backend-bootstrap'
+
+const { getPath } = vi.hoisted(() => ({ getPath: vi.fn<(name: string) => string>() }))
+vi.mock('electron', () => ({
+ app: {
+ getPath,
+ getVersion: () => '1.2.3',
+ getAppPath: () => '/installed/orca',
+ isPackaged: true
+ }
+}))
+
+let originalEnvironment: ReturnType
+beforeEach(() => {
+ originalEnvironment = getAppEnvironment()
+ getPath.mockImplementation((name) => {
+ if (name !== 'userData') {
+ throw new Error(`Failed to get ${name} path`)
+ }
+ return '/profiles/usage-profile'
+ })
+})
+afterEach(() => setAppEnvironment(originalEnvironment))
+
+it('starts the usage environment at its authoritative profile when optional folders are unavailable', () => {
+ captureBackendBootstrap()
+ const config = BackendBootstrapSchema.parse(capturedBootstrap)
+ const host = createDesktopBackendEnvironment(config)
+ setAppEnvironment(host.environment)
+
+ expect(getAppEnvironment().getPath('userData')).toBe('/profiles/usage-profile')
+ expect(getLogsDirectory()).toBe(join('/profiles/usage-profile', 'logs'))
+ expect(() => getAppEnvironment().getPath('downloads')).toThrow(
+ 'Desktop backend path is unsupported: downloads'
+ )
+})
+
+it('fails startup preparation when the required profile is unavailable', () => {
+ getPath.mockImplementation(() => {
+ throw new Error('Failed to get userData path')
+ })
+ expect(captureBackendBootstrap).toThrow('Failed to get userData path')
+})
+
+it('accepts legacy full and minimal snapshots without a build restriction while requiring the profile', () => {
+ captureBackendBootstrap()
+ const minimal = BackendBootstrapSchema.parse(capturedBootstrap)
+ const legacy = {
+ ...minimal,
+ build: 'older-shell',
+ paths: {
+ userData: '/profiles/legacy-profile',
+ home: '/legacy/home',
+ appData: '/legacy/appData',
+ temp: '/legacy/temp',
+ downloads: '/legacy/downloads',
+ logs: '/legacy/logs',
+ exe: '/legacy/orca'
+ }
+ }
+ expect(
+ createDesktopBackendEnvironment(BackendBootstrapSchema.parse(legacy)).environment.getPath(
+ 'userData'
+ )
+ ).toBe('/profiles/legacy-profile')
+ expect(createDesktopBackendEnvironment(minimal).environment.getPath('userData')).toBe(
+ '/profiles/usage-profile'
+ )
+ expect(BackendBootstrapSchema.safeParse({ ...minimal, paths: {} }).success).toBe(false)
+})
diff --git a/src/main/backend-desktop/backend-bootstrap.ts b/src/main/backend-desktop/backend-bootstrap.ts
new file mode 100644
index 00000000000..bdba1ccc03c
--- /dev/null
+++ b/src/main/backend-desktop/backend-bootstrap.ts
@@ -0,0 +1,19 @@
+import { app } from 'electron'
+import { BACKEND_CAPABILITIES, type BackendBootstrap } from '../../shared/backend-service-contract'
+
+export let capturedBootstrap: BackendBootstrap | undefined
+
+/** Capture at the same pre-name-change phase as the former usage path initializers. */
+export function captureBackendBootstrap(): void {
+ capturedBootstrap = {
+ protocol: 1,
+ requiredCapabilities: [...BACKEND_CAPABILITIES],
+ services: ['usage'],
+ build: app.getVersion(),
+ paths: {
+ userData: app.getPath('userData')
+ },
+ appPath: app.getAppPath(),
+ isPackaged: app.isPackaged
+ }
+}
diff --git a/src/main/backend-desktop/backend-desktop-capabilities.test.ts b/src/main/backend-desktop/backend-desktop-capabilities.test.ts
new file mode 100644
index 00000000000..ea98e02f106
--- /dev/null
+++ b/src/main/backend-desktop/backend-desktop-capabilities.test.ts
@@ -0,0 +1,41 @@
+import { expect, it, vi } from 'vitest'
+import { createBackendDesktopCapabilities } from './backend-desktop-capabilities'
+
+vi.mock('../telemetry/client', () => ({ isTelemetryEnabled: vi.fn(), track: vi.fn() }))
+vi.mock('../claude-usage/transcript-file-discovery', () => ({
+ claudeProfileTranscriptDirs: vi.fn()
+}))
+
+it('loads remembered worktrees before sending the backend attribution catalog', async () => {
+ const remembered = new Map()
+ let finishLoading!: () => void
+ const loaded = new Promise((resolve) => {
+ finishLoading = resolve
+ }).then(() => {
+ remembered.set('repo-1::/ws/removed', 'removed')
+ })
+ const capability = createBackendDesktopCapabilities({
+ getRepos: () => [
+ { id: 'repo-1', path: '/src/app', displayName: 'app', badgeColor: '#000000', addedAt: 0 }
+ ],
+ getAllWorktreeMeta: () => ({}),
+ getRememberedWorktreeDisplayNames: () => remembered,
+ whenLoaded: () => loaded
+ })
+
+ const catalog = capability('usage.catalog', null)
+ finishLoading()
+
+ expect(await catalog).toEqual({
+ repoIds: ['repo-1'],
+ worktreesByRepo: [
+ [
+ 'repo-1',
+ [
+ { worktreeId: 'repo-1::/src/app', path: '/src/app', displayName: 'app' },
+ { worktreeId: 'repo-1::/ws/removed', path: '/ws/removed', displayName: 'removed' }
+ ]
+ ]
+ ]
+ })
+})
diff --git a/src/main/backend-desktop/backend-desktop-capabilities.ts b/src/main/backend-desktop/backend-desktop-capabilities.ts
new file mode 100644
index 00000000000..0d8bef80826
--- /dev/null
+++ b/src/main/backend-desktop/backend-desktop-capabilities.ts
@@ -0,0 +1,95 @@
+import { BrowserWindow, type WebContents, type WebFrameMain } from 'electron'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { agentTokenUsageSchema } from '../../shared/telemetry-agent-token-usage-schema'
+import { claudeProfileTranscriptDirs } from '../claude-usage/transcript-file-discovery'
+import { loadKnownUsageWorktreesByRepo } from '../usage/worktree-attribution/usage-worktree-metadata'
+import { isTelemetryEnabled, track } from '../telemetry/client'
+import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata'
+import { currentWorkerEntryLayout } from '../worker-threads/worker-thread-entry-path'
+import { isRendererDocumentNavigation } from '../window/renderer-document-navigation'
+
+export function trustsBackendRenderer(contents: WebContents): boolean {
+ if (
+ contents.isDestroyed() ||
+ contents.getType() !== 'window' ||
+ !BrowserWindow.fromWebContents(contents)
+ ) {
+ return false
+ }
+ const layout = currentWorkerEntryLayout(__dirname)
+ const roots = process.env.ELECTRON_RENDERER_URL
+ ? [process.env.ELECTRON_RENDERER_URL]
+ : ['index.html', 'space.html', 'popout.html'].map(
+ (name) => pathToFileURL(join(layout.moduleDir, '../renderer', name)).href
+ )
+ return roots.some((root) => isRendererDocumentNavigation(root, contents.getURL()))
+}
+
+export function createBackendDesktopCapabilities(
+ store: UsageWorktreeSource
+): (method: string, params: unknown) => Promise {
+ return async (method, params) => {
+ if (method === 'usage.catalog') {
+ await store.whenLoaded()
+ const repos = store.getRepos()
+ return {
+ repoIds: repos.map((repo) => repo.id),
+ worktreesByRepo: [...loadKnownUsageWorktreesByRepo(store, repos)]
+ }
+ }
+ if (method === 'usage.claudeProfileDirs') {
+ return claudeProfileTranscriptDirs()
+ }
+ if (method === 'usage.telemetry.enabled') {
+ return isTelemetryEnabled()
+ }
+ if (method === 'usage.telemetry.capture') {
+ return track('agent_token_usage', agentTokenUsageSchema.parse(params))
+ }
+ throw new Error('This desktop capability is not supported.')
+ }
+}
+
+export type BackendDocument = {
+ contents: WebContents
+ frame: WebFrameMain
+ token: string
+ retire(): void
+}
+
+export function trackBackendDocument(
+ documents: Map,
+ contents: WebContents,
+ frame: WebFrameMain,
+ token: string,
+ retireToken: (token: string) => void
+): BackendDocument {
+ documents.get(contents.id)?.retire()
+ const document: BackendDocument = { contents, frame, token, retire: () => {} }
+ const retire = (): void => {
+ if (documents.get(contents.id) !== document) {
+ return
+ }
+ documents.delete(contents.id)
+ retireToken(document.token)
+ contents.off('did-frame-navigate', navigation)
+ contents.off('destroyed', retire)
+ }
+ const navigation = (
+ _event: unknown,
+ _url: string,
+ _httpCode: number,
+ _httpStatus: string,
+ mainFrame: boolean
+ ): void => {
+ if (mainFrame) {
+ retire()
+ }
+ }
+ document.retire = retire
+ documents.set(contents.id, document)
+ contents.on('did-frame-navigate', navigation)
+ contents.once('destroyed', retire)
+ return document
+}
diff --git a/src/main/backend-desktop/backend-supervisor.test.ts b/src/main/backend-desktop/backend-supervisor.test.ts
new file mode 100644
index 00000000000..de7e80199d1
--- /dev/null
+++ b/src/main/backend-desktop/backend-supervisor.test.ts
@@ -0,0 +1,379 @@
+import { EventEmitter } from 'node:events'
+import { afterEach, expect, it, vi } from 'vitest'
+import {
+ BACKEND_CONNECTION_CHANGED,
+ BACKEND_PORT_DELIVERY,
+ BACKEND_PORT_REQUEST
+} from '../../shared/backend-service-contract'
+const rig = vi.hoisted(() => ({
+ fork: vi.fn(),
+ handle: vi.fn(),
+ call: vi.fn(),
+ appOn: vi.fn(),
+ appOff: vi.fn()
+}))
+vi.mock('electron', () => ({
+ app: { on: rig.appOn, off: rig.appOff },
+ BrowserWindow: { fromWebContents: () => ({}) },
+ ipcMain: { handle: rig.handle, removeHandler: vi.fn() },
+ utilityProcess: { fork: rig.fork },
+ MessageChannelMain: class {
+ port1 = {}
+ port2 = {}
+ }
+}))
+vi.mock('./backend-bootstrap', () => ({ capturedBootstrap: {} }))
+vi.mock('../../shared/backend-port-rpc', () => ({
+ BackendPortRpc: class {
+ call = rig.call
+ close() {}
+ }
+}))
+vi.mock('../backend/backend-message-port', () => ({ backendMessagePort: () => ({}) }))
+vi.mock('../crash-reporting/breadcrumbs/durable-crash-breadcrumb', () => ({
+ recordDurableCrashBreadcrumb: vi.fn()
+}))
+vi.mock('../worker-threads/worker-thread-entry-path', () => ({
+ currentWorkerEntryLayout: () => ({ moduleDir: '/' }),
+ resolveWorkerThreadEntryPath: () => 'backend-entry.js'
+}))
+vi.mock('../window/renderer-document-navigation', () => ({
+ isRendererDocumentNavigation: () => true
+}))
+vi.mock('../usage/worktree-attribution/usage-worktree-metadata', () => ({
+ loadKnownUsageWorktreesByRepo: vi.fn()
+}))
+vi.mock('../claude-usage/transcript-file-discovery', () => ({
+ claudeProfileTranscriptDirs: vi.fn()
+}))
+vi.mock('../telemetry/client', () => ({ isTelemetryEnabled: vi.fn(), track: vi.fn() }))
+import { BackendSupervisor } from './backend-supervisor'
+afterEach(() => {
+ vi.useRealTimers()
+ vi.clearAllMocks()
+})
+
+it('keeps ready-generated recovery behind owner backoff and the cap, then admits real demand', async () => {
+ vi.useFakeTimers()
+ const children: (EventEmitter & {
+ postMessage: ReturnType
+ kill: ReturnType
+ })[] = []
+ rig.fork.mockImplementation(() => {
+ const child = Object.assign(new EventEmitter(), { postMessage: vi.fn(), kill: vi.fn() })
+ children.push(child)
+ return child
+ })
+ const supervisor = new BackendSupervisor({
+ getRepos: () => [],
+ getAllWorktreeMeta: () => ({}),
+ getRememberedWorktreeDisplayNames: () => new Map(),
+ whenLoaded: async () => {}
+ })
+ const frame = { postMessage: vi.fn() }
+ const sender = Object.assign(new EventEmitter(), {
+ id: 2,
+ mainFrame: frame,
+ isDestroyed: () => false,
+ getType: () => 'window',
+ getURL: () => 'file:///index.html'
+ })
+ const request = rig.handle.mock.calls.find(([channel]) => channel === BACKEND_PORT_REQUEST)?.[1]
+ const event = { sender, senderFrame: frame }
+ const token = '11111111-1111-1111-1111-111111111111'
+ const joined = request(event, token, 'recovery')
+ children[0].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
+ await joined
+ for (let i = 0; i < 3; i++) {
+ children[i].emit('exit', 1)
+ await expect(request(event, token, 'recovery')).rejects.toThrow('Usage is unavailable')
+ expect(children).toHaveLength(i + 1)
+ if (i < 2) {
+ const delay = i === 0 ? 500 : 1000
+ await vi.advanceTimersByTimeAsync(delay - 1)
+ expect(children).toHaveLength(i + 1)
+ await vi.advanceTimersByTimeAsync(1)
+ expect(children).toHaveLength(i + 2)
+ const recovery = request(event, token, 'recovery')
+ children[i + 1].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
+ await recovery
+ }
+ }
+ await vi.advanceTimersByTimeAsync(5000)
+ expect(children).toHaveLength(3)
+ expect(frame.postMessage).toHaveBeenCalledWith(BACKEND_CONNECTION_CHANGED, {
+ connected: false,
+ unavailable: true
+ })
+ await expect(request(event, token, 'invalid')).rejects.toThrow(
+ 'Invalid backend connection request'
+ )
+ const demand = request(event, token)
+ expect(children).toHaveLength(4)
+ children[3].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
+ await demand
+ children[3].emit('exit', 1)
+ rig.call.mockResolvedValueOnce({ inputTokens: 42 })
+ const automation = supervisor.automationClient('claudeUsage').getAutomationRunUsage({
+ worktreeId: null,
+ terminalSessionId: null,
+ startedAt: null,
+ completedAt: null
+ })
+ expect(children).toHaveLength(5)
+ children[4].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
+ await expect(automation).resolves.toEqual({ inputTokens: 42 })
+ await vi.advanceTimersByTimeAsync(1000)
+ expect(children).toHaveLength(5)
+ children[4].emit('exit', 1)
+ await vi.advanceTimersByTimeAsync(499)
+ expect(children).toHaveLength(5)
+ await vi.advanceTimersByTimeAsync(1)
+ expect(children).toHaveLength(6)
+ children[5].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
+ const stopped = supervisor.stop()
+ children[5].emit('exit', 0)
+ await stopped
+})
+it('publishes terminal unavailability on the third death and preserves explicit recovery', async () => {
+ vi.useFakeTimers()
+ const children: (EventEmitter & {
+ postMessage: ReturnType
+ kill: ReturnType
+ })[] = []
+ rig.fork.mockImplementation(() => {
+ const child = Object.assign(new EventEmitter(), { postMessage: vi.fn(), kill: vi.fn() })
+ children.push(child)
+ return child
+ })
+ const supervisor = new BackendSupervisor({
+ getRepos: () => [],
+ getAllWorktreeMeta: () => ({}),
+ getRememberedWorktreeDisplayNames: () => new Map(),
+ whenLoaded: async () => {}
+ })
+ const frame = { postMessage: vi.fn() }
+ const sender = Object.assign(new EventEmitter(), {
+ id: 1,
+ mainFrame: frame,
+ isDestroyed: () => false,
+ getType: () => 'window',
+ getURL: () => 'file:///index.html'
+ })
+ const request = rig.handle.mock.calls.find(([channel]) => channel === BACKEND_PORT_REQUEST)?.[1]
+ const initial = request({ sender, senderFrame: frame }, '11111111-1111-1111-1111-111111111111')
+ children[0].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
+ await initial
+ for (let i = 0; i < 3; i++) {
+ children[i].emit('exit', 1)
+ expect(frame.postMessage).toHaveBeenLastCalledWith(BACKEND_CONNECTION_CHANGED, {
+ connected: false,
+ unavailable: i === 2
+ })
+ await vi.advanceTimersByTimeAsync(2000)
+ if (i < 2) {
+ children[i + 1].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
+ }
+ }
+ expect(children).toHaveLength(3)
+ const retry = request({ sender, senderFrame: frame }, '22222222-2222-2222-2222-222222222222')
+ children[3].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
+ await retry
+ expect(frame.postMessage).toHaveBeenCalledWith(BACKEND_CONNECTION_CHANGED, {
+ connected: true,
+ unavailable: false
+ })
+ const stopped = supervisor.stop()
+ children[3].emit('exit', 0)
+ await stopped
+})
+
+it('retires only the exited renderer document and rejects its pending port delivery', async () => {
+ const child = Object.assign(new EventEmitter(), { postMessage: vi.fn(), kill: vi.fn() })
+ rig.fork.mockReturnValue(child)
+ const supervisor = new BackendSupervisor({
+ getRepos: () => [],
+ getAllWorktreeMeta: () => ({}),
+ getRememberedWorktreeDisplayNames: () => new Map(),
+ whenLoaded: async () => {}
+ })
+ const frame = { postMessage: vi.fn() }
+ const sender = Object.assign(new EventEmitter(), {
+ id: 3,
+ mainFrame: frame,
+ isDestroyed: () => false,
+ getType: () => 'window',
+ getURL: () => 'file:///index.html'
+ })
+ const request = rig.handle.mock.calls.find(([channel]) => channel === BACKEND_PORT_REQUEST)?.[1]
+ const gone = rig.appOn.mock.calls.find(([event]) => event === 'render-process-gone')?.[1]
+ const token = '33333333-3333-3333-3333-333333333333'
+ const initial = request({ sender, senderFrame: frame }, token)
+ const rejected = expect(initial).rejects.toThrow('The document was replaced')
+ gone({}, { id: 99 })
+ expect(child.postMessage).not.toHaveBeenCalledWith({ type: 'retire', token })
+ gone({}, sender)
+ expect(child.postMessage).toHaveBeenCalledWith({ type: 'retire', token })
+ child.emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
+ await rejected
+ expect(frame.postMessage.mock.calls.some(([channel]) => channel === BACKEND_PORT_DELIVERY)).toBe(
+ false
+ )
+ await request({ sender, senderFrame: frame }, token)
+ expect(
+ child.postMessage.mock.calls.filter(([message]) => message.type === 'client')
+ ).toHaveLength(1)
+ gone({}, sender)
+ expect(
+ child.postMessage.mock.calls.filter(([message]) => message.type === 'retire')
+ ).toHaveLength(2)
+ const stopped = supervisor.stop()
+ expect(rig.appOff).toHaveBeenCalledWith('render-process-gone', gone)
+ child.emit('exit', 0)
+ await stopped
+})
+
+function startupFixture() {
+ const children: (EventEmitter & {
+ postMessage: ReturnType
+ kill: ReturnType
+ })[] = []
+ rig.fork.mockImplementation(() => {
+ const child = Object.assign(new EventEmitter(), {
+ postMessage: vi.fn(),
+ kill: vi.fn(() => false)
+ })
+ children.push(child)
+ return child
+ })
+ const supervisor = new BackendSupervisor({
+ getRepos: () => [],
+ getAllWorktreeMeta: () => ({}),
+ getRememberedWorktreeDisplayNames: () => new Map(),
+ whenLoaded: async () => {}
+ })
+ const frame = { postMessage: vi.fn() }
+ const sender = Object.assign(new EventEmitter(), {
+ id: 4,
+ mainFrame: frame,
+ isDestroyed: () => false,
+ getType: () => 'window',
+ getURL: () => 'file:///index.html'
+ })
+ const request = rig.handle.mock.calls.find(([channel]) => channel === BACKEND_PORT_REQUEST)?.[1]
+ const token = '44444444-4444-4444-4444-444444444444'
+ const read = () =>
+ supervisor.automationClient('claudeUsage').getAutomationRunUsage({
+ worktreeId: null,
+ terminalSessionId: null,
+ startedAt: null,
+ completedAt: null
+ })
+ return {
+ children,
+ supervisor,
+ frame,
+ read,
+ request: (purpose = 'demand', contents = sender, requestToken = token) =>
+ request({ sender: contents, senderFrame: contents.mainFrame }, requestToken, purpose)
+ }
+}
+
+it('retries a rejected signal-free launch on new demand without replay or false retirement', async () => {
+ vi.useFakeTimers()
+ const { children, supervisor, frame, read, request } = startupFixture()
+ const ready = { type: 'ready', protocol: 1, capabilities: ['usage'] }
+ for (let attempt = 0; attempt < 4; attempt++) {
+ const demand = expect(read()).rejects.toThrow('Usage is still unavailable')
+ const joined = expect(request('recovery')).rejects.toThrow('Usage is still unavailable')
+ await vi.advanceTimersByTimeAsync(30_000)
+ await demand
+ await joined
+ children[attempt].emit('message', ready)
+ expect(frame.postMessage).not.toHaveBeenCalledWith(BACKEND_CONNECTION_CHANGED, {
+ connected: true,
+ unavailable: false
+ })
+ await expect(request('recovery')).rejects.toThrow('Usage is still unavailable')
+ expect(children).toHaveLength(attempt + 1)
+ expect(rig.call).not.toHaveBeenCalled()
+ }
+ const retry = read()
+ const port = request()
+ children[4].emit('message', ready)
+ rig.call.mockResolvedValueOnce({ inputTokens: 42 })
+ await expect(retry).resolves.toEqual({ inputTokens: 42 })
+ await port
+ expect(rig.call).toHaveBeenCalledTimes(1)
+ const notifications = frame.postMessage.mock.calls.slice()
+ for (const child of children.slice(0, 4)) {
+ child.kill.mockClear()
+ child.emit('spawn')
+ expect(child.kill).toHaveBeenCalledTimes(1)
+ child.emit('message', ready)
+ child.emit('exit', 1)
+ }
+ expect(frame.postMessage.mock.calls).toEqual(notifications)
+ const { recordDurableCrashBreadcrumb } =
+ await import('../crash-reporting/breadcrumbs/durable-crash-breadcrumb')
+ expect(recordDurableCrashBreadcrumb).toHaveBeenLastCalledWith('backend_process_exited', {
+ pid: null,
+ incarnation: null,
+ exitCode: 1,
+ expected: true
+ })
+ // A rejection after successful readiness cannot turn that owner into a failed startup.
+ children[4].emit('error', new Error('late error'))
+ rig.call.mockResolvedValueOnce({ inputTokens: 43 })
+ await expect(read()).resolves.toEqual({ inputTokens: 43 })
+ expect(children).toHaveLength(5)
+ const stopped = supervisor.stop()
+ let quit = false
+ void stopped.then(() => {
+ quit = true
+ })
+ await Promise.resolve()
+ expect(quit).toBe(false)
+ children[4].emit('exit', 0)
+ await stopped
+ await expect(read()).rejects.toThrow('Orca is shutting down')
+ expect(children).toHaveLength(5)
+})
+
+it('rejects old requests when ownership changes across readiness instead of rerouting them', async () => {
+ const { children, supervisor, frame, read, request } = startupFixture()
+ const ready = { type: 'ready', protocol: 1, capabilities: ['usage'] }
+ children[0].emit('message', ready)
+ const oldRead = expect(read()).rejects.toThrow('Usage disconnected')
+ const oldPort = expect(request()).rejects.toThrow('The document was replaced')
+ children[0].emit('exit', 1)
+ const replacementFrame = { postMessage: vi.fn() }
+ const replacementSender = Object.assign(new EventEmitter(), {
+ id: 5,
+ mainFrame: replacementFrame,
+ isDestroyed: () => false,
+ getType: () => 'window',
+ getURL: () => 'file:///index.html'
+ })
+ const token = '55555555-5555-5555-5555-555555555555'
+ const replacement = request('demand', replacementSender, token)
+ children[1].emit('message', ready)
+ await oldRead
+ await oldPort
+ await replacement
+ expect(rig.call).not.toHaveBeenCalled()
+ expect(
+ frame.postMessage.mock.calls.filter(([channel]) => channel === BACKEND_PORT_DELIVERY)
+ ).toHaveLength(0)
+ expect(
+ replacementFrame.postMessage.mock.calls.filter(([channel]) => channel === BACKEND_PORT_DELIVERY)
+ ).toHaveLength(1)
+ expect(
+ children[1].postMessage.mock.calls.filter(([message]) => message.type === 'client')
+ ).toEqual([[{ type: 'client', token }, [{}]]])
+ const stopped = supervisor.stop()
+ children[1].emit('spawn')
+ expect(children[1].kill).toHaveBeenCalled()
+ children[1].emit('exit', 0)
+ await stopped
+})
diff --git a/src/main/backend-desktop/backend-supervisor.ts b/src/main/backend-desktop/backend-supervisor.ts
new file mode 100644
index 00000000000..4078fa1c92c
--- /dev/null
+++ b/src/main/backend-desktop/backend-supervisor.ts
@@ -0,0 +1,303 @@
+import { app, ipcMain, MessageChannelMain, utilityProcess, type WebContents } from 'electron'
+import { recordDurableCrashBreadcrumb } from '../crash-reporting/breadcrumbs/durable-crash-breadcrumb'
+import { BackendPortRpc } from '../../shared/backend-port-rpc'
+import {
+ BACKEND_CONNECTION_CHANGED,
+ BACKEND_PORT_DELIVERY,
+ BACKEND_PORT_REQUEST,
+ BACKEND_UTILITY_PROCESS_NAME,
+ type AutomationUsageClient
+} from '../../shared/backend-service-contract'
+import { backendMessagePort } from '../backend/backend-message-port'
+import {
+ currentWorkerEntryLayout,
+ resolveWorkerThreadEntryPath
+} from '../worker-threads/worker-thread-entry-path'
+import { ManagedOrcadEventWindow } from '../managed-server/lifecycle/orcad-managed-event-window'
+import { ORCAD_SHUTDOWN_DEADLINE_MS } from '../orcad/orcad-stop-deadlines'
+import {
+ createBackendDesktopCapabilities,
+ trustsBackendRenderer,
+ trackBackendDocument,
+ type BackendDocument
+} from './backend-desktop-capabilities'
+import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata'
+
+export { captureBackendBootstrap } from './backend-bootstrap'
+import { capturedBootstrap } from './backend-bootstrap'
+
+export class BackendSupervisor {
+ private child: ReturnType | null = null
+ private control: BackendPortRpc | null = null
+ private ready: Promise | null = null
+ private disposeFailedStartup?: () => void
+ private desiredRunning = true
+ private exited: Promise = Promise.resolve()
+ private restartTimer: ReturnType | undefined
+ private readonly failures = new ManagedOrcadEventWindow(10 * 60_000, Date.now)
+ private readonly documents = new Map()
+ private stopping?: Promise
+
+ private readonly retireRenderer = (_event: unknown, contents: WebContents): void =>
+ this.documents.get(contents.id)?.retire()
+
+ constructor(private readonly store: UsageWorktreeSource) {
+ if (!capturedBootstrap) {
+ throw new Error('Backend paths were not captured during preflight.')
+ }
+ app.on('render-process-gone', this.retireRenderer)
+ ipcMain.handle(
+ BACKEND_PORT_REQUEST,
+ async (event, token: unknown, purpose: unknown = 'demand') => {
+ if (
+ typeof token !== 'string' ||
+ !/^[0-9a-f-]{36}$/i.test(token) ||
+ (purpose !== 'demand' && purpose !== 'recovery')
+ ) {
+ throw new Error('Invalid backend connection request.')
+ }
+ const { sender, senderFrame } = event
+ if (!senderFrame || senderFrame !== sender.mainFrame || !trustsBackendRenderer(sender)) {
+ throw new Error('This document cannot connect to Orca.')
+ }
+ const document = trackBackendDocument(
+ this.documents,
+ sender,
+ senderFrame,
+ token,
+ (retiredToken) => this.child?.postMessage({ type: 'retire', token: retiredToken })
+ )
+ if (!this.desiredRunning) {
+ throw new Error('Orca is shutting down.')
+ }
+ const ready = purpose === 'recovery' ? this.ready : this.ensureReady(true)
+ const child = this.child
+ if (!ready) {
+ throw new Error('Usage is unavailable. Try refreshing the usage view.')
+ }
+ await ready
+ if (
+ this.documents.get(sender.id) !== document ||
+ !this.desiredRunning ||
+ !child ||
+ this.child !== child ||
+ sender.isDestroyed() ||
+ sender.mainFrame !== senderFrame
+ ) {
+ throw new Error('The document was replaced before Orca connected.')
+ }
+ const { port1, port2 } = new MessageChannelMain()
+ child.postMessage({ type: 'client', token: document.token }, [port1])
+ senderFrame.postMessage(BACKEND_PORT_DELIVERY, { token: document.token }, [port2])
+ return document.token
+ }
+ )
+ void this.ensureReady(false).catch(() => {})
+ }
+
+ automationClient(provider: 'claudeUsage' | 'codexUsage'): AutomationUsageClient {
+ return {
+ getAutomationRunUsage: async (input) => {
+ const ready = this.ensureReady(true)
+ const control = this.control
+ await ready
+ if (!this.desiredRunning || !control || this.control !== control) {
+ throw new Error('Usage disconnected. Try again after it reconnects.')
+ }
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The private usage service returns its typed automation result for this provider method.
+ return (await control.call(`${provider}:getAutomationRunUsage`, input)) as Awaited<
+ ReturnType
+ >
+ }
+ }
+ }
+
+ stop(): Promise {
+ this.stopping ??= (async () => {
+ this.desiredRunning = false
+ app.off('render-process-gone', this.retireRenderer)
+ clearTimeout(this.restartTimer)
+ this.restartTimer = undefined
+ const child = this.child
+ for (const document of this.documents.values()) {
+ document.retire()
+ }
+ ipcMain.removeHandler(BACKEND_PORT_REQUEST)
+ if (!child) {
+ return
+ }
+ const exited = this.exited
+ const deadline = setTimeout(() => {
+ if (this.child === child) {
+ child.kill()
+ }
+ }, ORCAD_SHUTDOWN_DEADLINE_MS + 1_000)
+ this.control?.close()
+ await exited
+ clearTimeout(deadline)
+ })()
+ return this.stopping
+ }
+
+ private notify(connected: boolean, unavailable = false): void {
+ for (const document of this.documents.values()) {
+ if (!document.contents.isDestroyed() && document.contents.mainFrame === document.frame) {
+ document.frame.postMessage(BACKEND_CONNECTION_CHANGED, { connected, unavailable })
+ }
+ }
+ }
+
+ private async ensureReady(explicit: boolean): Promise {
+ if (!this.desiredRunning) {
+ throw new Error('Orca is shutting down.')
+ }
+ if (explicit) {
+ this.disposeFailedStartup?.()
+ }
+ if (this.ready) {
+ return this.ready
+ }
+ if (explicit) {
+ this.failures.forget('backend')
+ clearTimeout(this.restartTimer)
+ this.restartTimer = undefined
+ }
+ if (this.failures.recent('backend').length >= 3) {
+ throw new Error('Usage could not start. Try refreshing the usage view.')
+ }
+ const { port1, port2 } = new MessageChannelMain()
+ const entry = resolveWorkerThreadEntryPath(
+ currentWorkerEntryLayout(__dirname),
+ 'backend-entry.js'
+ )
+ const child = utilityProcess.fork(entry, [], {
+ serviceName: BACKEND_UTILITY_PROCESS_NAME,
+ stdio: ['ignore', 'ignore', 'pipe']
+ })
+ this.child = child
+ this.control = new BackendPortRpc(
+ backendMessagePort(port1),
+ 'desktop',
+ createBackendDesktopCapabilities(this.store)
+ )
+ let childPid = child.pid
+ child.once('spawn', () => {
+ childPid = child.pid
+ if (this.child !== child || !this.desiredRunning) {
+ child.kill()
+ }
+ })
+ let incarnation: string | undefined
+ let settleExit!: () => void
+ this.exited = new Promise((resolve) => {
+ settleExit = resolve
+ })
+ let failed!: (error: Error) => void
+ let settled = false
+ let disposed = false
+ const ready = new Promise((resolve, reject) => {
+ failed = (error) => {
+ if (settled) {
+ return
+ }
+ settled = true
+ if (this.child === child && this.ready === ready) {
+ this.disposeFailedStartup = () => {
+ disposed = true
+ const control = this.control
+ this.child = null
+ this.control = null
+ this.ready = null
+ this.disposeFailedStartup = undefined
+ clearTimeout(startupDeadline)
+ control?.close()
+ try {
+ child.kill()
+ } catch {}
+ }
+ }
+ reject(error)
+ }
+ child.on(
+ 'message',
+ (message: {
+ type?: string
+ protocol?: number
+ capabilities?: string[]
+ incarnation?: string
+ }) => {
+ if (this.child !== child || settled || !this.desiredRunning) {
+ return
+ }
+ if (
+ message.type === 'ready' &&
+ message.protocol === 1 &&
+ message.capabilities?.includes('usage')
+ ) {
+ incarnation = message.incarnation
+ settled = true
+ clearTimeout(startupDeadline)
+ resolve()
+ this.notify(true)
+ }
+ if (message.type === 'failed') {
+ failed(new Error('Usage could not start. Try refreshing the usage view.'))
+ child.kill()
+ }
+ }
+ )
+ })
+ this.ready = ready
+ const startupDeadline = setTimeout(() => {
+ failed(new Error('Usage is still unavailable. Try refreshing the usage view.'))
+ if (this.child === child) {
+ child.kill()
+ }
+ }, ORCAD_SHUTDOWN_DEADLINE_MS * 2)
+ child.stderr?.on('data', (chunk: Buffer) =>
+ console.warn(`[backend] ${chunk.toString().slice(0, 16_384)}`)
+ )
+ child.on('error', () => {
+ failed(new Error('Usage could not start. Try refreshing the usage view.'))
+ })
+ child.once('exit', (code) => {
+ recordDurableCrashBreadcrumb('backend_process_exited', {
+ pid: childPid ?? null,
+ incarnation: incarnation ?? null,
+ exitCode: code,
+ expected: disposed || !this.desiredRunning
+ })
+ clearTimeout(startupDeadline)
+ failed(new Error('Usage disconnected. Try again after it reconnects.'))
+ if (this.child === child) {
+ this.control?.close()
+ this.control = null
+ this.child = null
+ this.ready = null
+ this.disposeFailedStartup = undefined
+ if (this.desiredRunning) {
+ this.failures.record('backend')
+ }
+ this.notify(false, this.desiredRunning && this.failures.recent('backend').length >= 3)
+ if (this.desiredRunning) {
+ if (this.failures.recent('backend').length < 3) {
+ const restartTimer = setTimeout(
+ () => {
+ if (this.restartTimer !== restartTimer) {
+ return
+ }
+ this.restartTimer = undefined
+ void this.ensureReady(false).catch(() => {})
+ },
+ 250 * 2 ** this.failures.recent('backend').length
+ )
+ this.restartTimer = restartTimer
+ }
+ }
+ }
+ settleExit()
+ })
+ child.postMessage({ type: 'bootstrap', config: capturedBootstrap }, [port2])
+ return ready
+ }
+}
diff --git a/src/main/backend/backend-entry.ts b/src/main/backend/backend-entry.ts
new file mode 100644
index 00000000000..b171b367dea
--- /dev/null
+++ b/src/main/backend/backend-entry.ts
@@ -0,0 +1,222 @@
+import { randomUUID } from 'node:crypto'
+import { join } from 'node:path'
+import { BackendBootstrapSchema, BACKEND_CAPABILITIES } from '../../shared/backend-service-contract'
+import { BackendPortRpc } from '../../shared/backend-port-rpc'
+import { setAppEnvironment } from '../../shared/app-environment'
+import { agentTokenUsageSchema } from '../../shared/telemetry-agent-token-usage-schema'
+import { backendMessagePort, type BackendNativePort } from './backend-message-port'
+import { createDesktopBackendEnvironment } from './node-service-environment'
+import { BackendServiceLifetime, activateBackendServices } from './service-composition'
+import { acquireOrcadInstanceLock } from '../orcad/orcad-instance-lock'
+import { OrcadInstanceLockError } from '../orcad/orcad-data-root-privacy'
+import { ORCAD_SHUTDOWN_DEADLINE_MS } from '../orcad/orcad-stop-deadlines'
+import { clearWindowsStdHandleInheritance } from '../startup/windows-std-handle-inheritance'
+import { installWindowsIsolatedSpawner } from '../startup/windows-isolated-spawner'
+import { dropE2eSpawnLogOutsideE2e } from '../startup/e2e-spawn-log-env'
+import { ignoreInheritedWslHostsGateState } from '../../shared/wsl-host-internal-gate'
+import type { createUsageService } from './usage-service'
+import type { UsageWorktreeCatalog } from '../usage/usage-store-host'
+
+dropE2eSpawnLogOutsideE2e()
+clearWindowsStdHandleInheritance()
+installWindowsIsolatedSpawner()
+ignoreInheritedWslHostsGateState()
+
+const parent:
+ | {
+ on(
+ event: 'message',
+ listener: (event: { data: unknown; ports: BackendNativePort[] }) => void
+ ): unknown
+ postMessage(message: unknown): void
+ }
+ | undefined = process.parentPort
+if (!parent) {
+ throw new Error('Backend must be launched by its desktop supervisor.')
+}
+const incarnation = randomUUID()
+const lifetime = new BackendServiceLifetime()
+const abort = new AbortController()
+const clients = new Map()
+let control: BackendPortRpc | undefined
+let usage: ReturnType | undefined
+let stopping: Promise | undefined
+let booting = false
+let startup: Promise | undefined
+
+function stop(): Promise {
+ stopping ??= (async () => {
+ const deadline = setTimeout(() => process.exit(1), ORCAD_SHUTDOWN_DEADLINE_MS)
+ abort.abort(new Error('Backend is stopping.'))
+ for (const client of clients.values()) {
+ client.close()
+ }
+ try {
+ await startup?.catch(() => {})
+ await lifetime.stop()
+ parent!.postMessage({ type: 'stopped', incarnation })
+ clearTimeout(deadline)
+ process.exit(0)
+ } catch {
+ console.error(`[backend ${incarnation}] Shutdown did not finish cleanly.`)
+ process.exit(1)
+ }
+ })()
+ return stopping
+}
+
+async function acquireServiceLock(userDataPath: string) {
+ const deadline = Date.now() + ORCAD_SHUTDOWN_DEADLINE_MS
+ while (!abort.signal.aborted) {
+ try {
+ return acquireOrcadInstanceLock(join(userDataPath, 'backend-service'), {
+ role: 'orcad',
+ enforcePrivateDataRoot: false,
+ onExclusivityLost: () => {
+ void stop()
+ }
+ })
+ } catch (error) {
+ if (
+ !(error instanceof OrcadInstanceLockError) ||
+ error.code !== 'orcad_instance_lock_held' ||
+ Date.now() >= deadline
+ ) {
+ throw error
+ }
+ await new Promise((resolve) => setTimeout(resolve, 250))
+ }
+ }
+ throw abort.signal.reason
+}
+
+async function bootstrap(raw: unknown, port: BackendNativePort): Promise {
+ if (booting) {
+ port.close()
+ throw new Error('Backend was already bootstrapped.')
+ }
+ booting = true
+ const config = BackendBootstrapSchema.parse(raw)
+ if (
+ config.requiredCapabilities.some(
+ (capability) => !BACKEND_CAPABILITIES.some((offered) => offered === capability)
+ )
+ ) {
+ throw new Error('Backend contract is unsupported.')
+ }
+ const host = createDesktopBackendEnvironment(config)
+ setAppEnvironment(host.environment)
+ control = new BackendPortRpc(
+ backendMessagePort(port),
+ incarnation,
+ async (method, params, signal) => {
+ if (method === 'backend.stop') {
+ void stop()
+ return null
+ }
+ if (!usage) {
+ throw new Error('Usage is still starting. Try again shortly.')
+ }
+ await usage.ready
+ return usage.invoke(method, params, true, signal)
+ },
+ () => {
+ void stop()
+ }
+ )
+ const lock = await acquireServiceLock(config.paths.userData)
+ // Release only after services and their worker writers have retired.
+ lifetime.add(({ failed }) => {
+ if (!failed) {
+ lock.release()
+ }
+ })
+ abort.signal.throwIfAborted()
+ lifetime.add(() => host.runQuitHandlers())
+ await activateBackendServices({
+ supervised: true,
+ registerCleanup: (cleanup) => lifetime.add(cleanup),
+ activate: async (registerCleanup) => {
+ const { createUsageService } = await import('./usage-service')
+ abort.signal.throwIfAborted()
+ const call = async (method: string, params?: unknown): Promise => {
+ return control!.call(method, params)
+ }
+ usage = createUsageService({
+ signal: abort.signal,
+ userDataPath: config.paths.userData,
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The private control peer returns the catalog directly from its typed Store adapter.
+ readCatalog: async () => (await call('usage.catalog')) as UsageWorktreeCatalog,
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The private control peer returns the account owner's typed string array.
+ claudeProfileDirs: async () => (await call('usage.claudeProfileDirs')) as string[],
+ telemetry: {
+ isEnabled: async () => (await call('usage.telemetry.enabled')) === true,
+ capture: async (snapshot) =>
+ (await call('usage.telemetry.capture', agentTokenUsageSchema.parse(snapshot))) === true
+ }
+ })
+ registerCleanup(() => usage!.stop())
+ await usage.ready
+ }
+ })
+ abort.signal.throwIfAborted()
+ parent!.postMessage({
+ type: 'ready',
+ protocol: 1,
+ capabilities: BACKEND_CAPABILITIES,
+ incarnation,
+ build: config.build
+ })
+}
+
+parent.on('message', (event) => {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only the owning desktop can send parent messages; every consumed field is validated below.
+ const message = event.data as { type?: unknown; config?: unknown; token?: unknown }
+ const [port, ...surplus] = event.ports
+ for (const extra of surplus) {
+ extra.close()
+ }
+ if (message?.type === 'bootstrap' && port) {
+ startup = bootstrap(message.config, port)
+ void startup.catch(() => {
+ console.error(`[backend ${incarnation}] Startup failed.`)
+ parent!.postMessage({ type: 'failed', incarnation })
+ void stop()
+ })
+ return
+ }
+ if (message?.type === 'retire' && typeof message.token === 'string') {
+ clients.get(message.token)?.close()
+ port?.close()
+ return
+ }
+ if (
+ message?.type === 'client' &&
+ typeof message.token === 'string' &&
+ port &&
+ usage &&
+ !stopping
+ ) {
+ const token = message.token
+ clients.get(token)?.close()
+ let client: BackendPortRpc
+ client = new BackendPortRpc(
+ backendMessagePort(port),
+ incarnation,
+ async (method, params, signal) => {
+ await usage!.ready
+ return usage!.invoke(method, params, false, signal)
+ },
+ () => clients.delete(token)
+ )
+ clients.set(token, client)
+ return
+ }
+ port?.close()
+})
+process.on('SIGTERM', () => {
+ void stop()
+})
+process.on('SIGINT', () => {
+ void stop()
+})
diff --git a/src/main/backend/backend-message-port.ts b/src/main/backend/backend-message-port.ts
new file mode 100644
index 00000000000..1e0d4e0ea6c
--- /dev/null
+++ b/src/main/backend/backend-message-port.ts
@@ -0,0 +1,32 @@
+import type { BackendPort } from '../../shared/backend-port-rpc'
+
+export type BackendNativePort = {
+ postMessage(message: unknown): void
+ on(event: 'message', listener: (event: { data: unknown }) => void): unknown
+ on(event: 'close', listener: () => void): unknown
+ off(event: 'message', listener: (event: { data: unknown }) => void): unknown
+ off(event: 'close', listener: () => void): unknown
+ start(): void
+ close(): void
+}
+
+export function backendMessagePort(port: BackendNativePort): BackendPort {
+ return {
+ postMessage: (message) => port.postMessage(message),
+ onMessage(listener) {
+ const receive = (event: { data: unknown }): void => listener(event.data)
+ port.on('message', receive)
+ port.start()
+ return () => {
+ port.off('message', receive)
+ }
+ },
+ onClose(listener) {
+ port.on('close', listener)
+ return () => {
+ port.off('close', listener)
+ }
+ },
+ close: () => port.close()
+ }
+}
diff --git a/src/main/backend/node-service-environment.ts b/src/main/backend/node-service-environment.ts
new file mode 100644
index 00000000000..81b93068598
--- /dev/null
+++ b/src/main/backend/node-service-environment.ts
@@ -0,0 +1,49 @@
+import type { AppEnvironment } from '../../shared/app-environment'
+import type { BackendBootstrap } from '../../shared/backend-service-contract'
+
+export function createDesktopBackendEnvironment(config: BackendBootstrap) {
+ return createNodeServiceEnvironment({
+ getPath(name) {
+ if (name !== 'userData') {
+ throw new Error(`Desktop backend path is unsupported: ${name}`)
+ }
+ return config.paths.userData
+ },
+ getAppPath: () => config.appPath,
+ getVersion: () => config.build,
+ isPackaged: () => config.isPackaged
+ })
+}
+
+export function createNodeServiceEnvironment(
+ host: Pick<
+ AppEnvironment,
+ 'getPath' | 'getAppPath' | 'getVersion' | 'isPackaged' | 'getCliLauncherPath'
+ >
+): {
+ environment: AppEnvironment
+ runQuitHandlers(): void
+} {
+ const quitHandlers: (() => void)[] = []
+ return {
+ environment: {
+ ...host,
+ onWillQuit: (handler) => quitHandlers.push(handler),
+ exit: (code = 0) => process.exit(code),
+ getAppMetrics: () => []
+ },
+ runQuitHandlers() {
+ const errors: unknown[] = []
+ for (const handler of quitHandlers.splice(0)) {
+ try {
+ handler()
+ } catch (error) {
+ errors.push(error)
+ }
+ }
+ if (errors.length) {
+ throw new AggregateError(errors, 'backend_service_quit_handlers_failed')
+ }
+ }
+ }
+}
diff --git a/src/main/backend/service-composition.ts b/src/main/backend/service-composition.ts
new file mode 100644
index 00000000000..a98433c52c2
--- /dev/null
+++ b/src/main/backend/service-composition.ts
@@ -0,0 +1,50 @@
+import { startOrcadEventLoopWatchdog } from '../orcad/orcad-event-loop-watchdog'
+
+export type BackendServiceCleanup = (state: { failed: boolean }) => void | Promise
+
+/** A service keeps its ownership fence until all accepted work and writers have retired. */
+export class BackendServiceLifetime {
+ private readonly cleanups: BackendServiceCleanup[] = []
+ private stopping?: Promise
+
+ constructor(private readonly release: () => void = () => {}) {}
+
+ add(cleanup: BackendServiceCleanup): void {
+ if (this.stopping) {
+ throw new Error('backend_service_lifetime_stopping')
+ }
+ this.cleanups.push(cleanup)
+ }
+
+ stop(): Promise {
+ this.stopping ??= Promise.resolve().then(async () => {
+ const errors: unknown[] = []
+ for (const cleanup of this.cleanups.splice(0).toReversed()) {
+ try {
+ await cleanup({ failed: errors.length > 0 })
+ } catch (error) {
+ errors.push(error)
+ }
+ }
+ if (errors.length === 1) {
+ throw errors[0]
+ }
+ if (errors.length > 1) {
+ throw new AggregateError(errors, 'backend_service_cleanup_failed')
+ }
+ this.release()
+ })
+ return this.stopping
+ }
+}
+
+/** Both hosts enter service activation here without importing an inactive service set. */
+export async function activateBackendServices(options: {
+ supervised: boolean
+ registerCleanup: (cleanup: BackendServiceCleanup) => void
+ activate: (registerCleanup: (cleanup: BackendServiceCleanup) => void) => Promise
+}): Promise {
+ const watchdog = startOrcadEventLoopWatchdog({ supervised: options.supervised })
+ options.registerCleanup(() => watchdog?.stop())
+ return options.activate(options.registerCleanup)
+}
diff --git a/src/main/backend/usage-service.test.ts b/src/main/backend/usage-service.test.ts
new file mode 100644
index 00000000000..e19368928e0
--- /dev/null
+++ b/src/main/backend/usage-service.test.ts
@@ -0,0 +1,152 @@
+import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { createUsageService } from './usage-service'
+import { createTestUsageStoreHost } from '../usage/usage-store-host-test-fixture'
+import type { ClaudeUsagePersistedState } from '../claude-usage/types'
+import type { UsageCacheSplitResult } from '../usage/cache/usage-source-cache-file'
+import {
+ scanClaudeUsageFilesViaWorker,
+ splitUsageCacheFileViaWorker,
+ stopUsageScanWorker
+} from '../usage/scan-worker/usage-scan-worker-spawn'
+vi.mock('../usage/scan-worker/usage-scan-worker-spawn', () => ({
+ scanClaudeUsageFilesViaWorker: vi.fn(),
+ scanCodexUsageFilesViaWorker: vi.fn(),
+ scanOpenCodeUsageDatabasesViaWorker: vi.fn(),
+ scanMuseUsageFilesViaWorker: vi.fn(),
+ splitUsageCacheFileViaWorker: vi.fn(),
+ stopUsageScanWorker: vi.fn(async () => {})
+}))
+let directory: string
+let services: ReturnType[]
+let releasePending: (() => void)[]
+function nextTurn(): Promise {
+ return new Promise((resolve) => setImmediate(resolve))
+}
+function reportText(state: ClaudeUsagePersistedState): string {
+ const { processedFiles: _files, ...report } = state
+ return JSON.stringify(report)
+}
+beforeEach(() => {
+ directory = mkdtempSync(join(tmpdir(), 'orca-backend-usage-'))
+ services = []
+ releasePending = []
+ vi.clearAllMocks()
+})
+afterEach(async () => {
+ releasePending.forEach((release) => release())
+ await Promise.all(services.map((service) => service.stop()))
+ rmSync(directory, { recursive: true, force: true })
+ vi.restoreAllMocks()
+})
+function historicalState(): ClaudeUsagePersistedState {
+ return {
+ schemaVersion: 6,
+ worktreeFingerprint: null,
+ processedFiles: [],
+ sessions: [],
+ dailyAggregates: [
+ {
+ day: '2026-10-09',
+ model: null,
+ projectKey: 'unscoped',
+ projectLabel: 'Unknown location',
+ repoId: null,
+ worktreeId: null,
+ turnCount: 1,
+ zeroCacheReadTurnCount: 1,
+ inputTokens: 7,
+ outputTokens: 2,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ cacheWrite1hTokens: 0
+ }
+ ],
+ scanState: {
+ enabled: false,
+ lastScanStartedAt: null,
+ lastScanCompletedAt: 1,
+ lastScanError: null
+ }
+ }
+}
+
+it('awaits loading before the first disabled cached snapshot and all historical queries', async () => {
+ const cached = historicalState()
+ writeFileSync(
+ join(directory, 'orca-claude-usage.json'),
+ `${JSON.stringify(cached)}${' '.repeat(9 * 1024 * 1024)}`
+ )
+ const pending = Promise.withResolvers()
+ releasePending.push(() => pending.resolve({ reportText: reportText(cached), migrated: false }))
+ vi.mocked(splitUsageCacheFileViaWorker).mockReturnValueOnce(pending.promise)
+ const service = createUsageService(createTestUsageStoreHost({ userDataPath: directory }))
+ services.push(service)
+ const settled = vi.fn()
+ const args = { scope: 'all', range: 'all', limit: 7, kind: 'model' }
+ const requests = [
+ 'getSnapshot',
+ 'getScanState',
+ 'getSummary',
+ 'getDaily',
+ 'getBreakdown',
+ 'getRecentSessions'
+ ].map((query) =>
+ Promise.resolve(service.invoke(`claudeUsage:${query}`, args, false)).then((value) => {
+ settled()
+ return value
+ })
+ )
+ await nextTurn()
+ expect(settled).not.toHaveBeenCalled()
+ expect(scanClaudeUsageFilesViaWorker).not.toHaveBeenCalled()
+ pending.resolve({ reportText: reportText(cached), migrated: false })
+ const [snapshot, scanState, summary, daily] = await Promise.all(requests)
+ expect(snapshot).toMatchObject({
+ scanState: { enabled: false, isScanning: false, hasAnyClaudeData: true },
+ summary: { turns: 1, inputTokens: 7 }
+ })
+ expect(scanState).toMatchObject({ enabled: false, isScanning: false, hasAnyClaudeData: true })
+ expect(summary).toMatchObject({ turns: 1, inputTokens: 7 })
+ expect(daily).toEqual([
+ { day: '2026-10-09', inputTokens: 7, outputTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0 }
+ ])
+ expect(scanClaudeUsageFilesViaWorker).not.toHaveBeenCalled()
+})
+
+it('retires the scanner before releasing ownership and preserves enabled state without a shutdown error', async () => {
+ const service = createUsageService(createTestUsageStoreHost({ userDataPath: directory }))
+ services.push(service)
+ await service.invoke('claudeUsage:setEnabled', { enabled: true }, false)
+ const scanStarted = Promise.withResolvers()
+ const scan = Promise.withResolvers()
+ vi.mocked(scanClaudeUsageFilesViaWorker).mockImplementationOnce(() => {
+ scanStarted.resolve()
+ return scan.promise
+ })
+ const refreshing = service.invoke('claudeUsage:refresh', { force: true }, false)
+ await scanStarted.promise
+ const retirement = Promise.withResolvers()
+ releasePending.push(() => {
+ scan.reject(new Error('worker retired'))
+ retirement.resolve()
+ })
+ vi.mocked(stopUsageScanWorker).mockImplementationOnce(() => retirement.promise)
+ const stopped = vi.fn()
+ const stopping = service.stop().then(stopped)
+ await nextTurn()
+ expect(stopped).not.toHaveBeenCalled()
+ await expect(
+ service.invoke('claudeUsage:setEnabled', { enabled: false }, false)
+ ).rejects.toThrow()
+ scan.reject(new Error('worker retired'))
+ retirement.resolve()
+ await Promise.all([refreshing, stopping])
+ expect(JSON.parse(readFileSync(join(directory, 'orca-claude-usage.json'), 'utf8'))).toMatchObject(
+ {
+ scanState: { enabled: true, lastScanError: null }
+ }
+ )
+})
diff --git a/src/main/backend/usage-service.ts b/src/main/backend/usage-service.ts
new file mode 100644
index 00000000000..4e841b6b758
--- /dev/null
+++ b/src/main/backend/usage-service.ts
@@ -0,0 +1,142 @@
+import { z } from 'zod'
+import { ClaudeUsageStore } from '../claude-usage/store'
+import { CodexUsageStore } from '../codex-usage/store'
+import { OpenCodeUsageStore } from '../opencode-usage/store'
+import { MuseUsageStore } from '../muse-usage/store'
+import type { UsageStoreHost } from '../usage/usage-store-host'
+import { stopUsageScanWorker } from '../usage/scan-worker/usage-scan-worker-spawn'
+import type { UsageProviderKey } from '../../shared/backend-service-contract'
+
+const rangeSchema = z.object({
+ scope: z.string().max(4096),
+ range: z.string().max(128),
+ limit: z.number().int().nonnegative().optional()
+})
+const automationSchema = z.object({
+ worktreeId: z.string().nullable(),
+ terminalSessionId: z.string().nullable(),
+ startedAt: z.number().nullable(),
+ completedAt: z.number().nullable()
+})
+type Handler = (params: unknown) => unknown
+
+type QueryStore = {
+ whenLoaded(): Promise
+ getScanState(): unknown
+ setEnabled(enabled: boolean): unknown
+ refresh(force?: boolean): unknown
+ getSnapshot(scope: Scope, range: Range, limit?: number): unknown
+ getSummary(scope: Scope, range: Range): unknown
+ getDaily(scope: Scope, range: Range): unknown
+ getBreakdown(scope: Scope, range: Range, kind: Kind): unknown
+ getRecentSessions(scope: Scope, range: Range, limit?: number): unknown
+}
+
+function registerUsageQueries(
+ handlers: Map,
+ prefix: UsageProviderKey,
+ store: QueryStore
+): void {
+ const query =
+ (fn: (scope: Scope, range: Range, limit?: number) => unknown): Handler =>
+ (raw) => {
+ const args = rangeSchema.parse(raw)
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Each registered provider uses the same public scope/range contract; the generic preserves its query signature.
+ return fn(args.scope as Scope, args.range as Range, args.limit)
+ }
+ handlers.set(`${prefix}:getScanState`, () => store.getScanState())
+ handlers.set(`${prefix}:setEnabled`, (raw) =>
+ store.setEnabled(z.object({ enabled: z.boolean() }).parse(raw).enabled)
+ )
+ handlers.set(`${prefix}:refresh`, (raw) =>
+ store.refresh(z.object({ force: z.boolean().optional() }).optional().parse(raw)?.force ?? false)
+ )
+ handlers.set(
+ `${prefix}:getSnapshot`,
+ query((scope, range, limit) => store.getSnapshot(scope, range, limit))
+ )
+ handlers.set(
+ `${prefix}:getSummary`,
+ query((scope, range) => store.getSummary(scope, range))
+ )
+ handlers.set(
+ `${prefix}:getDaily`,
+ query((scope, range) => store.getDaily(scope, range))
+ )
+ handlers.set(
+ `${prefix}:getRecentSessions`,
+ query((scope, range, limit) => store.getRecentSessions(scope, range, limit))
+ )
+ handlers.set(`${prefix}:getBreakdown`, (raw) => {
+ const args = rangeSchema.extend({ kind: z.string().max(128) }).parse(raw)
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These are the provider API arguments shared by all four registered query stores.
+ return store.getBreakdown(args.scope as Scope, args.range as Range, args.kind as Kind)
+ })
+}
+
+export function createUsageService(host: UsageStoreHost) {
+ const stores = {
+ claudeUsage: new ClaudeUsageStore(host),
+ codexUsage: new CodexUsageStore(host),
+ openCodeUsage: new OpenCodeUsageStore(host),
+ museUsage: new MuseUsageStore(host)
+ }
+ const all = Object.values(stores)
+ const handlers = new Map()
+ registerUsageQueries(handlers, 'claudeUsage', stores.claudeUsage)
+ registerUsageQueries(handlers, 'codexUsage', stores.codexUsage)
+ registerUsageQueries(handlers, 'openCodeUsage', stores.openCodeUsage)
+ registerUsageQueries(handlers, 'museUsage', stores.museUsage)
+ let accepting = true
+ const active = new Set>()
+ const stop = async (): Promise => {
+ accepting = false
+ for (const store of all) {
+ store.stopScanAdmission()
+ }
+ await ready
+ await stopUsageScanWorker()
+ await Promise.allSettled(active)
+ await Promise.all(all.map((store) => store.settleScansForShutdown()))
+ await Promise.all(all.map((store) => store.flush()))
+ }
+ const ready = Promise.all(all.map((store) => store.whenLoaded())).then(() => {})
+ const dispatch = async (
+ method: string,
+ params: unknown,
+ privileged: boolean
+ ): Promise => {
+ if (!accepting) {
+ throw new Error('Usage service is stopping. Try again after Orca reconnects.')
+ }
+ if (privileged && method === 'claudeUsage:getAutomationRunUsage') {
+ return stores.claudeUsage.getAutomationRunUsage(automationSchema.parse(params))
+ }
+ if (privileged && method === 'codexUsage:getAutomationRunUsage') {
+ return stores.codexUsage.getAutomationRunUsage(automationSchema.parse(params))
+ }
+ const handler = handlers.get(method)
+ if (!handler) {
+ throw new Error('This usage operation is not supported.')
+ }
+ return handler(params)
+ }
+ return {
+ ready,
+ invoke(
+ method: string,
+ params: unknown,
+ privileged: boolean,
+ signal?: AbortSignal
+ ): Promise {
+ const operation = ready.then(() => {
+ signal?.throwIfAborted()
+ return dispatch(method, params, privileged)
+ })
+ active.add(operation)
+ void operation.finally(() => active.delete(operation)).catch(() => {})
+ return operation
+ },
+ stop
+ }
+}
diff --git a/src/main/claude-usage/persisted-usage-test-fixture.ts b/src/main/claude-usage/persisted-usage-test-fixture.ts
index fb7ecb3bfe5..92863408410 100644
--- a/src/main/claude-usage/persisted-usage-test-fixture.ts
+++ b/src/main/claude-usage/persisted-usage-test-fixture.ts
@@ -1,7 +1,8 @@
+import { createTestUsageStoreHost } from '../usage/usage-store-host-test-fixture'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { vi, type Mock } from 'vitest'
+import { vi } from 'vitest'
import type { ClaudeUsagePersistedFile, ClaudeUsagePersistedState } from './types'
import { readClaudeUsageScanFile } from './transcript-record-parser'
import { projectClaudeUsageScanFile } from './transcript-usage-projection'
@@ -14,14 +15,14 @@ import {
import { normalizeClaudeUsageSourceFiles } from './persisted-projection-validation'
import { usageSourceCachePath } from '../usage/cache/usage-source-cache-file'
-import { ClaudeUsageStore, initClaudeUsagePath } from './store'
+import { ClaudeUsageStore } from './store'
import { listClaudeTranscriptFiles } from './transcript-file-discovery'
import {
scanClaudeUsageFilesViaWorker,
splitUsageCacheFileViaWorker
} from '../usage/scan-worker/usage-scan-worker-spawn'
-export async function createPersistedUsageTestFixture(getPath: Mock<() => string>) {
+export async function createPersistedUsageTestFixture() {
const directory = await mkdtemp(join(tmpdir(), 'orca-claude-persisted-usage-'))
const transcript = join(directory, 'session.jsonl')
const reportPath = join(directory, 'orca-claude-usage.json')
@@ -32,25 +33,25 @@ export async function createPersistedUsageTestFixture(getPath: Mock<() => string
reuse: true
}
const transcripts = [transcript]
- getPath.mockReturnValue(directory)
- initClaudeUsagePath()
vi.mocked(listClaudeTranscriptFiles).mockReset()
vi.mocked(listClaudeTranscriptFiles).mockImplementation(async () => [...transcripts].sort())
vi.mocked(splitUsageCacheFileViaWorker).mockReset()
vi.mocked(splitUsageCacheFileViaWorker).mockImplementation(splitClaudeUsageCacheFile)
vi.mocked(scanClaudeUsageFilesViaWorker).mockReset()
- vi.mocked(scanClaudeUsageFilesViaWorker).mockImplementation(async (worktrees, ref) => {
- const previous = await readClaudeUsageSourceCache(ref)
- const result = await scanClaudeUsageFiles(
- worktrees,
- previous.sources,
- undefined,
- [],
- previous.verifiedSources
- )
- await persistClaudeUsageSourceCache(ref, result.processedFiles, previous)
- return { sessions: result.sessions, dailyAggregates: result.dailyAggregates }
- })
+ vi.mocked(scanClaudeUsageFilesViaWorker).mockImplementation(
+ async (worktrees, ref, profileDirs) => {
+ const previous = await readClaudeUsageSourceCache(ref)
+ const result = await scanClaudeUsageFiles(
+ worktrees,
+ previous.sources,
+ undefined,
+ profileDirs,
+ previous.verifiedSources
+ )
+ await persistClaudeUsageSourceCache(ref, result.processedFiles, previous)
+ return { sessions: result.sessions, dailyAggregates: result.dailyAggregates }
+ }
+ )
function row(index: number, duplicate = false): string {
return `${JSON.stringify({
@@ -118,13 +119,7 @@ export async function createPersistedUsageTestFixture(getPath: Mock<() => string
project,
state,
writeResumable,
- createStore: () =>
- new ClaudeUsageStore({
- getRepos: () => [],
- getAllWorktreeMeta: () => ({}),
- getRememberedWorktreeDisplayNames: () => new Map(),
- whenLoaded: async () => {}
- }),
+ createStore: () => new ClaudeUsageStore(createTestUsageStoreHost({ userDataPath: directory })),
decodedSources: async () => {
const read = await readClaudeUsageSourceCache(sourceRef)
return normalizeClaudeUsageSourceFiles(read.sources, read.verifiedSources).processedFiles
diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts
index da663d189de..a6cda7d260b 100644
--- a/src/main/claude-usage/store.ts
+++ b/src/main/claude-usage/store.ts
@@ -1,5 +1,4 @@
import { claudeTokenSessions } from '../usage/reporting/token-usage/agent-token-usage'
-import { app } from 'electron'
import { join } from 'node:path'
import type {
ClaudeUsageBreakdownKind,
@@ -12,7 +11,7 @@ import type {
ClaudeUsageSummary
} from '../../shared/claude-usage-types'
import type { AutomationRunUsage } from '../../shared/automations/history/automation-run'
-import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata'
+import type { UsageStoreHost } from '../usage/usage-store-host'
import type { ClaudeUsagePersistedState } from './types'
import {
scanClaudeUsageFilesViaWorker,
@@ -29,9 +28,6 @@ import { parseClaudeUsageReport, serializeClaudeUsageReport } from './persisted-
// v7 stores protected column checkpoints on the worker and a separately protected report.
const SCHEMA_VERSION = 7
-// Capture after configureDevUserDataPath(), before app.setName() changes the derived userData path.
-let _claudeUsageFile: string | null = null
-
function getDefaultState(): ClaudeUsagePersistedState {
return {
schemaVersion: SCHEMA_VERSION,
@@ -63,30 +59,19 @@ function normalizePersistedState(state: ClaudeUsagePersistedState): ClaudeUsageP
}
}
-export function initClaudeUsagePath(): void {
- _claudeUsageFile = join(app.getPath('userData'), 'orca-claude-usage.json')
-}
-
-function getClaudeUsageFile(): string {
- if (!_claudeUsageFile) {
- _claudeUsageFile = join(app.getPath('userData'), 'orca-claude-usage.json')
- }
- return _claudeUsageFile
-}
-
export class ClaudeUsageStore extends UsageProviderStoreLifecycle<
'processedFiles',
ClaudeUsagePersistedState,
'hasAnyClaudeData'
> {
- constructor(store: UsageWorktreeSource) {
- super(store, {
+ constructor(host: UsageStoreHost) {
+ super(host, {
tokenUsage: {
provider: 'claude',
selectSessions: (state) => claudeTokenSessions(state.sessions)
},
logTag: '[claude-usage]',
- resolveCacheFile: getClaudeUsageFile,
+ resolveCacheFile: () => join(host.userDataPath, 'orca-claude-usage.json'),
createDefaultState: getDefaultState,
normalizeState: normalizePersistedState,
parseReport: parseClaudeUsageReport,
@@ -94,7 +79,11 @@ export class ClaudeUsageStore extends UsageProviderStoreLifecycle<
providerId: 'claude',
sourceKey: 'processedFiles',
dataPresenceKey: 'hasAnyClaudeData',
- scan: scanClaudeUsageFilesViaWorker,
+ scan: async (worktrees, sourceCache) => {
+ const profileDirs = await host.claudeProfileDirs()
+ host.signal.throwIfAborted()
+ return scanClaudeUsageFilesViaWorker(worktrees, sourceCache, profileDirs)
+ },
splitCacheFile: splitUsageCacheFileViaWorker
})
}
diff --git a/src/main/codex-usage/store-test-harness.ts b/src/main/codex-usage/store-test-harness.ts
index 2cdd3f7937a..f62f3caaee8 100644
--- a/src/main/codex-usage/store-test-harness.ts
+++ b/src/main/codex-usage/store-test-harness.ts
@@ -1,10 +1,10 @@
-import { mkdtempSync, rmSync } from 'node:fs'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, vi } from 'vitest'
-import type { Mock } from 'vitest'
+import { createTestUsageStoreHost } from '../usage/usage-store-host-test-fixture'
import { scanCodexUsageFilesViaWorker } from '../usage/scan-worker/usage-scan-worker-spawn'
-import { CodexUsageStore, initCodexUsagePath } from './store'
+import { CodexUsageStore } from './store'
import type { CodexUsagePersistedState } from './types'
export function createEmptyScanResult() {
@@ -66,39 +66,33 @@ export function createWorktreeUsageSession(worktreeId: string) {
}
export function createStoreWithState(state: Partial): CodexUsageStore {
- const store = new CodexUsageStore({
- getRepos: () => [],
- getAllWorktreeMeta: () => ({}),
- getRememberedWorktreeDisplayNames: () => new Map(),
- whenLoaded: async () => {}
- })
-
- ;(store as unknown as { state: CodexUsagePersistedState }).state = {
- schemaVersion: 1,
- worktreeFingerprint: null,
- processedFiles: [],
- sessions: [],
- dailyAggregates: [],
- scanState: {
- enabled: false,
- lastScanStartedAt: null,
- lastScanCompletedAt: null,
- lastScanError: null
- },
- ...state
- }
-
- return store
+ writeFileSync(
+ join(activeUserData, 'orca-codex-usage.json'),
+ JSON.stringify({
+ schemaVersion: 7,
+ worktreeFingerprint: null,
+ processedFiles: [],
+ sessions: [],
+ dailyAggregates: [],
+ scanState: {
+ enabled: false,
+ lastScanStartedAt: null,
+ lastScanCompletedAt: null,
+ lastScanError: null
+ },
+ ...state
+ })
+ )
+ return new CodexUsageStore(createTestUsageStoreHost({ userDataPath: activeUserData }))
}
-/** The caller owns the hoisted Electron mock. */
-export function setupCodexUsageStoreEnv(getPathMock: Mock): { tempUserData: string } {
+let activeUserData: string
+export function setupCodexUsageStoreEnv(): { tempUserData: string } {
const env = { tempUserData: '' }
beforeEach(() => {
env.tempUserData = mkdtempSync(join(tmpdir(), 'orca-codex-usage-store-'))
- getPathMock.mockReturnValue(env.tempUserData)
- initCodexUsagePath()
+ activeUserData = env.tempUserData
vi.mocked(scanCodexUsageFilesViaWorker).mockReset()
vi.mocked(scanCodexUsageFilesViaWorker).mockResolvedValue(createEmptyScanResult())
vi.useFakeTimers()
diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts
index 35e98288382..d320753a4a5 100644
--- a/src/main/codex-usage/store.ts
+++ b/src/main/codex-usage/store.ts
@@ -1,5 +1,4 @@
import { codexOpenCodeTokenSessions } from '../usage/reporting/token-usage/agent-token-usage'
-import { app } from 'electron'
import { join } from 'node:path'
import type {
CodexUsageBreakdownKind,
@@ -12,7 +11,7 @@ import type {
CodexUsageSummary
} from '../../shared/codex-usage-types'
import type { AutomationRunUsage } from '../../shared/automations/history/automation-run'
-import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata'
+import type { UsageStoreHost } from '../usage/usage-store-host'
import type { CodexUsagePersistedState } from './types'
import type { AutomationUsageLookupInput } from './codex-automation-run-attribution'
import { CODEX_USAGE_SCHEMA_VERSION, codexUsageProvider } from './codex-usage-provider'
@@ -24,8 +23,6 @@ import { splitUsageCacheFileViaWorker } from '../usage/scan-worker/usage-scan-wo
const SCHEMA_VERSION = CODEX_USAGE_SCHEMA_VERSION
-let _codexUsageFile: string | null = null
-
function getDefaultState(): CodexUsagePersistedState {
return {
schemaVersion: SCHEMA_VERSION,
@@ -63,30 +60,19 @@ export function normalizePersistedState(state: CodexUsagePersistedState): CodexU
}
}
-export function initCodexUsagePath(): void {
- _codexUsageFile = join(app.getPath('userData'), 'orca-codex-usage.json')
-}
-
-function getCodexUsageFile(): string {
- if (!_codexUsageFile) {
- _codexUsageFile = join(app.getPath('userData'), 'orca-codex-usage.json')
- }
- return _codexUsageFile
-}
-
export class CodexUsageStore extends UsageProviderStoreLifecycle<
'processedFiles',
CodexUsagePersistedState,
'hasAnyCodexData'
> {
- constructor(store: UsageWorktreeSource) {
- super(store, {
+ constructor(host: UsageStoreHost) {
+ super(host, {
tokenUsage: {
provider: 'codex',
selectSessions: (state) => codexOpenCodeTokenSessions(state.sessions)
},
logTag: '[codex-usage]',
- resolveCacheFile: getCodexUsageFile,
+ resolveCacheFile: () => join(host.userDataPath, 'orca-codex-usage.json'),
createDefaultState: getDefaultState,
normalizeState: normalizePersistedState,
sourceKey: 'processedFiles',
diff --git a/src/main/crash-reporting/process-exits/process-gone-classification.ts b/src/main/crash-reporting/process-exits/process-gone-classification.ts
index 72a8272b743..8b21c4b9beb 100644
--- a/src/main/crash-reporting/process-exits/process-gone-classification.ts
+++ b/src/main/crash-reporting/process-exits/process-gone-classification.ts
@@ -1,4 +1,5 @@
import { STT_UTILITY_PROCESS_NAME } from '../../speech/stt-worker-process'
+import { BACKEND_UTILITY_PROCESS_NAME } from '../../../shared/backend-service-contract'
export type ProcessGoneSource = 'renderer' | 'child'
export type ExpectedTeardownScope = 'none' | 'renderer-reload' | 'app-shutdown'
@@ -14,7 +15,10 @@ const RECOVERABLE_UTILITY_SERVICE_NAMES = new Set([
'video_capture.mojom.VideoCaptureService'
])
// Orca's own utility processes whose exits the app already handles and reports in place.
-const RECOVERABLE_ORCA_UTILITY_NAMES = new Set([STT_UTILITY_PROCESS_NAME])
+const RECOVERABLE_ORCA_UTILITY_NAMES = new Set([
+ STT_UTILITY_PROCESS_NAME,
+ BACKEND_UTILITY_PROCESS_NAME
+])
const RECOVERABLE_CHILD_PROCESS_REASONS = new Set(['abnormal-exit', 'crashed', 'killed'])
const NON_RECOVERABLE_RENDERER_REASONS = new Set(['integrity-failure'])
diff --git a/src/main/crash-reporting/process-exits/process-gone-recorder.test.ts b/src/main/crash-reporting/process-exits/process-gone-recorder.test.ts
index 7e18c41ce1e..110a8117cae 100644
--- a/src/main/crash-reporting/process-exits/process-gone-recorder.test.ts
+++ b/src/main/crash-reporting/process-exits/process-gone-recorder.test.ts
@@ -67,6 +67,50 @@ afterEach(() => {
})
describe('recordProcessGoneCrash', () => {
+ it('reports no application crash for managed utilities with their own failure presentation', async () => {
+ const record = vi.fn().mockResolvedValue({ id: 'report-1' })
+ const utility = (name: string, reason: string, exitCode: number) =>
+ event({
+ source: 'child',
+ processType: 'Utility',
+ reason,
+ exitCode,
+ details: { name, serviceName: 'node.mojom.NodeService', type: 'Utility' }
+ })
+
+ for (const name of ['Orca Speech', 'Orca backend']) {
+ for (const [reason, exitCode] of [
+ ['killed', 9],
+ ['crashed', 11],
+ ['oom', 1]
+ ] as const) {
+ recordProcessGoneCrash(
+ { record, attachDetails } as never,
+ utility(name, reason, exitCode),
+ new ProcessGoneDedupe(),
+ noMinidump
+ )
+ }
+ }
+ recordProcessGoneCrash(
+ { record, attachDetails } as never,
+ utility('Some Other Service', 'crashed', 11),
+ new ProcessGoneDedupe(),
+ noMinidump
+ )
+
+ await vi.waitFor(() => expect(record).toHaveBeenCalledOnce())
+ expect(record.mock.calls[0][0].details).toMatchObject({ name: 'Some Other Service' })
+ expect(getCrashBreadcrumbSnapshot()).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: 'process_gone_suppressed',
+ data: expect.objectContaining({ name: 'Orca backend', reason: 'killed', exitCode: 9 })
+ })
+ ])
+ )
+ })
+
it('allows the same renderer crash to retry after persistence fails', async () => {
const record = vi
.fn()
diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts
index e6d650ab58c..7bf4a77b0e8 100644
--- a/src/main/ipc/register-core-handlers/register-core-handlers.ts
+++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts
@@ -12,7 +12,6 @@ import type { StatsCollector } from '../../stats/collector'
import { registerFilesystemHandlers } from '../filesystem/filesystem'
import type { CommitMessageAgentEnvironmentResolvers } from '../../text-generation/commit-message-agent-environment'
import { registerFilesystemWatcherHandlers } from '../filesystem/watchers/filesystem-watcher-handlers'
-import { registerUsageProviderHandlers } from '../usage-provider-handlers'
import { registerGitHubHandlers } from '../source-control/github'
import { registerGitLabHandlers } from '../source-control/gitlab'
import { registerHostedReviewHandlers } from '../source-control/hosted-review'
@@ -85,10 +84,6 @@ import {
setTrustedClipboardRendererWebContentsId
} from '../../window/clipboard-ipc-handlers'
import { isDashboardPopoutRenderer } from '../../window/dashboard-popout-window'
-import type { ClaudeUsageStore } from '../../claude-usage/store'
-import type { CodexUsageStore } from '../../codex-usage/store'
-import type { OpenCodeUsageStore } from '../../opencode-usage/store'
-import type { MuseUsageStore } from '../../muse-usage/store'
import type { RateLimitService } from '../../rate-limits/service/rate-limit-service'
import type { CodexAccountService } from '../../codex-accounts/service'
import type { ClaudeAccountService } from '../../claude-accounts/service'
@@ -147,10 +142,6 @@ export function registerCoreHandlers(
store: Store,
runtime: OrcaRuntimeService,
stats: StatsCollector,
- claudeUsage: ClaudeUsageStore,
- codexUsage: CodexUsageStore,
- openCodeUsage: OpenCodeUsageStore,
- museUsage: MuseUsageStore,
codexAccounts: CodexAccountService,
claudeAccounts: ClaudeAccountService,
rateLimits: RateLimitService,
@@ -188,7 +179,6 @@ export function registerCoreHandlers(
})
registerCliHandlers()
registerPreflightHandlers(store)
- registerUsageProviderHandlers({ claudeUsage, codexUsage, openCodeUsage, museUsage })
registerCodexAccountHandlers(codexAccounts, () => store.getSettings())
registerAgentHookHandlers(runtime, { getPtyIdForPaneKey })
registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService)
diff --git a/src/main/ipc/usage-provider-handlers.ts b/src/main/ipc/usage-provider-handlers.ts
deleted file mode 100644
index f3d6dbe6669..00000000000
--- a/src/main/ipc/usage-provider-handlers.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { ipcMain } from 'electron'
-import type { ClaudeUsageStore } from '../claude-usage/store'
-import type { CodexUsageStore } from '../codex-usage/store'
-import type { OpenCodeUsageStore } from '../opencode-usage/store'
-import type { MuseUsageStore } from '../muse-usage/store'
-
-type UsageProviderStores = {
- claudeUsage: ClaudeUsageStore
- codexUsage: CodexUsageStore
- openCodeUsage: OpenCodeUsageStore
- museUsage: MuseUsageStore
-}
-
-type UsageProviderChannelPrefix = keyof UsageProviderStores
-
-type UsageProviderHandlerStore = {
- whenLoaded: () => Promise
- getScanState: () => unknown
- setEnabled: (enabled: boolean) => unknown
- refresh: (force?: boolean) => unknown
- getSnapshot: (scope: Scope, range: Range, limit?: number) => unknown
- getSummary: (scope: Scope, range: Range) => unknown
- getDaily: (scope: Scope, range: Range) => unknown
- getBreakdown: (scope: Scope, range: Range, kind: BreakdownKind) => unknown
- getRecentSessions: (scope: Scope, range: Range, limit?: number) => unknown
-}
-
-type UsageRangeArgs = {
- scope: Scope
- range: Range
-}
-
-function registerProviderHandlers(
- prefix: UsageProviderChannelPrefix,
- usage: UsageProviderHandlerStore
-): void {
- // Wait for worker hydration so getters cannot return persisted-state defaults.
- ipcMain.handle(`${prefix}:getScanState`, async () => {
- await usage.whenLoaded()
- return usage.getScanState()
- })
- ipcMain.handle(`${prefix}:setEnabled`, (_event, args: { enabled: boolean }) =>
- usage.setEnabled(args.enabled)
- )
- ipcMain.handle(`${prefix}:refresh`, (_event, args?: { force?: boolean }) =>
- usage.refresh(args?.force ?? false)
- )
- ipcMain.handle(
- `${prefix}:getSnapshot`,
- async (_event, args: UsageRangeArgs & { limit?: number }) => {
- await usage.whenLoaded()
- return usage.getSnapshot(args.scope, args.range, args.limit)
- }
- )
- ipcMain.handle(`${prefix}:getSummary`, (_event, args: UsageRangeArgs) =>
- usage.getSummary(args.scope, args.range)
- )
- ipcMain.handle(`${prefix}:getDaily`, (_event, args: UsageRangeArgs) =>
- usage.getDaily(args.scope, args.range)
- )
- ipcMain.handle(
- `${prefix}:getBreakdown`,
- (_event, args: UsageRangeArgs & { kind: BreakdownKind }) =>
- usage.getBreakdown(args.scope, args.range, args.kind)
- )
- ipcMain.handle(
- `${prefix}:getRecentSessions`,
- (_event, args: UsageRangeArgs & { limit?: number }) =>
- usage.getRecentSessions(args.scope, args.range, args.limit)
- )
-}
-
-export function registerUsageProviderHandlers(stores: UsageProviderStores): void {
- registerProviderHandlers('claudeUsage', stores.claudeUsage)
- registerProviderHandlers('codexUsage', stores.codexUsage)
- registerProviderHandlers('openCodeUsage', stores.openCodeUsage)
- registerProviderHandlers('museUsage', stores.museUsage)
-}
diff --git a/src/main/muse-usage/store.ts b/src/main/muse-usage/store.ts
index 1fba5514dcc..26d68b56171 100644
--- a/src/main/muse-usage/store.ts
+++ b/src/main/muse-usage/store.ts
@@ -1,4 +1,3 @@
-import { app } from 'electron'
import { join } from 'node:path'
import type {
MuseUsageBreakdownKind,
@@ -10,7 +9,7 @@ import type {
MuseUsageSnapshot,
MuseUsageSummary
} from '../../shared/muse-usage-types'
-import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata'
+import type { UsageStoreHost } from '../usage/usage-store-host'
import { UsageProviderStoreLifecycle } from '../usage/provider-lifecycle/usage-provider-store-lifecycle'
import { splitUsageCacheFileViaWorker } from '../usage/scan-worker/usage-scan-worker-spawn'
import {
@@ -27,26 +26,15 @@ import {
} from './snapshot-rollups'
import type { MuseUsageDailyAggregate, MuseUsagePersistedState, MuseUsageSession } from './types'
-let museUsageFile: string | null = null
-
-export function initMuseUsagePath(): void {
- museUsageFile = join(app.getPath('userData'), 'orca-muse-usage.json')
-}
-
-function getMuseUsageFile(): string {
- museUsageFile ??= join(app.getPath('userData'), 'orca-muse-usage.json')
- return museUsageFile
-}
-
export class MuseUsageStore extends UsageProviderStoreLifecycle<
'processedFiles',
MuseUsagePersistedState,
'hasAnyMuseData'
> {
- constructor(store: UsageWorktreeSource) {
- super(store, {
+ constructor(host: UsageStoreHost) {
+ super(host, {
logTag: '[muse-usage]',
- resolveCacheFile: getMuseUsageFile,
+ resolveCacheFile: () => join(host.userDataPath, 'orca-muse-usage.json'),
createDefaultState: getDefaultMuseUsageState,
normalizeState: normalizeMuseUsageState,
sourceKey: 'processedFiles',
diff --git a/src/main/opencode-usage/store.ts b/src/main/opencode-usage/store.ts
index 47393511a4e..6af7b270414 100644
--- a/src/main/opencode-usage/store.ts
+++ b/src/main/opencode-usage/store.ts
@@ -1,5 +1,4 @@
import { codexOpenCodeTokenSessions } from '../usage/reporting/token-usage/agent-token-usage'
-import { app } from 'electron'
import { join } from 'node:path'
import type {
OpenCodeUsageBreakdownKind,
@@ -11,7 +10,7 @@ import type {
OpenCodeUsageSnapshot,
OpenCodeUsageSummary
} from '../../shared/opencode-usage-types'
-import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata'
+import type { UsageStoreHost } from '../usage/usage-store-host'
import type {
OpenCodeUsageDailyAggregate,
OpenCodeUsagePersistedState,
@@ -32,32 +31,19 @@ import {
import { UsageProviderStoreLifecycle } from '../usage/provider-lifecycle/usage-provider-store-lifecycle'
import { splitUsageCacheFileViaWorker } from '../usage/scan-worker/usage-scan-worker-spawn'
-let _openCodeUsageFile: string | null = null
-
-export function initOpenCodeUsagePath(): void {
- _openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json')
-}
-
-function getOpenCodeUsageFile(): string {
- if (!_openCodeUsageFile) {
- _openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json')
- }
- return _openCodeUsageFile
-}
-
export class OpenCodeUsageStore extends UsageProviderStoreLifecycle<
'processedDatabases',
OpenCodeUsagePersistedState,
'hasAnyOpenCodeData'
> {
- constructor(store: UsageWorktreeSource) {
- super(store, {
+ constructor(host: UsageStoreHost) {
+ super(host, {
tokenUsage: {
provider: 'opencode',
selectSessions: (state) => codexOpenCodeTokenSessions(state.sessions)
},
logTag: '[opencode-usage]',
- resolveCacheFile: getOpenCodeUsageFile,
+ resolveCacheFile: () => join(host.userDataPath, 'orca-opencode-usage.json'),
createDefaultState: getDefaultState,
normalizeState: normalizePersistedState,
sourceKey: 'processedDatabases',
diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts
index 7a0b14d5a46..cdaf0a5631d 100644
--- a/src/main/orcad/orcad-entry.ts
+++ b/src/main/orcad/orcad-entry.ts
@@ -29,7 +29,7 @@ import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import { restoreOrcadTerminalAuthority } from './orcad-restored-terminal-authority'
import { installOrcadFeatureReadiness } from '../runtime/rpc/orcad-feature-readiness'
import { ORCA_SERVE_SUPERVISED_ENV } from '../../shared/serve-supervised-terminal-durability'
-import { startOrcadEventLoopWatchdog } from './orcad-event-loop-watchdog'
+import { activateBackendServices } from '../backend/service-composition'
import { isAgentStatusHooksEnabled } from '../../shared/agent-hook-listener/configuration/agent-status-hooks-setting'
let closeOrcadObservability = (): Promise => Promise.resolve()
@@ -44,7 +44,12 @@ export async function startOrcad(options: OrcadOptions = {}): Promise startOrcadRuntime(options, registerCleanup),
+ (registerCleanup) =>
+ activateBackendServices({
+ supervised: process.env[ORCA_SERVE_SUPERVISED_ENV] === '1',
+ registerCleanup,
+ activate: (cleanup) => activateOrcadRuntime(options, cleanup)
+ }),
async () => {
try {
runOrcadQuitHandlers()
@@ -60,16 +65,11 @@ export async function startOrcad(options: OrcadOptions = {}): Promise void
): Promise> {
const { time: timed, report: reportStartupTiming } = createOrcadStartupTiming()
- // Read before startOrcadDaemon consumes the supervised flag; only a supervised server exits on a long stall.
- const eventLoopWatchdog = startOrcadEventLoopWatchdog({
- supervised: process.env[ORCA_SERVE_SUPERVISED_ENV] === '1'
- })
- registerCleanup(() => eventLoopWatchdog?.stop())
// Overlap login-shell probing with imports and store startup.
const loginEnvironment = adoptWslHostLoginEnvironmentLogged()
const { startOrcadDaemon, stopOrcadDaemon } = await import('./orcad-daemon-supervision')
diff --git a/src/main/orcad/orcad-host-adapters.ts b/src/main/orcad/orcad-host-adapters.ts
index 8c5afd39de7..63ce0b05d38 100644
--- a/src/main/orcad/orcad-host-adapters.ts
+++ b/src/main/orcad/orcad-host-adapters.ts
@@ -3,6 +3,7 @@ import { join } from 'node:path'
import process from 'node:process'
import { ORCAD_CLI_PACKAGE_FILENAME } from '../../shared/orcad-artifacts'
import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment'
+import { createNodeServiceEnvironment } from '../backend/node-service-environment'
import { setSecretStore } from '../../shared/secret-store'
import { resolveOrcadInstallRoot, resolveOrcadPath } from './orcad-app-paths'
import { getOrcadCliLauncherPath } from './orcad-cli-launcher'
@@ -17,33 +18,15 @@ export function runOrcadQuitHandlers(): void {
}
function createNodeAppEnvironment(): AppEnvironment {
- const quitHandlers: (() => void)[] = []
- // Synchronous quit handlers must not preempt the signal handler’s awaited runtime/browser teardown.
- quitRegisteredHandlers = (): void => {
- const errors: unknown[] = []
- for (const handler of quitHandlers.splice(0)) {
- try {
- handler()
- } catch (error) {
- errors.push(error)
- }
- }
- // Failed handlers may leave writers running and keep the instance lock held.
- if (errors.length > 0) {
- throw new AggregateError(errors, 'orcad_quit_handlers_failed')
- }
- }
- return {
+ const host = createNodeServiceEnvironment({
getPath: resolveOrcadPath,
getAppPath: () => resolveOrcadInstallRoot(),
getVersion: () => process.env.ORCA_VERSION ?? '0.0.0-orcad',
- // Packaged gates production HTTPS/CLI/PATH policy; layout callers must inspect asar separately.
isPackaged: () => true,
- getCliLauncherPath: getOrcadCliLauncherPath,
- onWillQuit: (handler) => quitHandlers.push(handler),
- exit: (code = 0) => process.exit(code),
- getAppMetrics: () => []
- }
+ getCliLauncherPath: getOrcadCliLauncherPath
+ })
+ quitRegisteredHandlers = host.runQuitHandlers
+ return host.environment
}
let orcadSecretStore: OrcadSecretStore | null = null
diff --git a/src/main/orcad/orcad-runtime-lifetime.ts b/src/main/orcad/orcad-runtime-lifetime.ts
index 0da34a2cefe..283adac3bb5 100644
--- a/src/main/orcad/orcad-runtime-lifetime.ts
+++ b/src/main/orcad/orcad-runtime-lifetime.ts
@@ -1,37 +1,4 @@
-export type OrcadRuntimeCleanup = (state: { failed: boolean }) => void | Promise
-
-/** Failed teardown retains profile ownership: surviving writers cannot share a root with a new host. */
-export class OrcadRuntimeLifetime {
- private readonly cleanups: OrcadRuntimeCleanup[] = []
- private stopping?: Promise
-
- constructor(private readonly release: () => void = () => {}) {}
-
- add(cleanup: OrcadRuntimeCleanup): void {
- if (this.stopping) {
- throw new Error('orcad_runtime_lifetime_stopping')
- }
- this.cleanups.push(cleanup)
- }
-
- stop(): Promise {
- this.stopping ??= Promise.resolve().then(async () => {
- const errors: unknown[] = []
- for (const cleanup of this.cleanups.splice(0).toReversed()) {
- try {
- await cleanup({ failed: errors.length > 0 })
- } catch (error) {
- errors.push(error)
- }
- }
- if (errors.length === 1) {
- throw errors[0]
- }
- if (errors.length > 1) {
- throw new AggregateError(errors, 'orcad_runtime_cleanup_failed')
- }
- this.release()
- })
- return this.stopping
- }
-}
+export {
+ BackendServiceLifetime as OrcadRuntimeLifetime,
+ type BackendServiceCleanup as OrcadRuntimeCleanup
+} from '../backend/service-composition'
diff --git a/src/main/startup/browser-process-user-agent-ordering.test.ts b/src/main/startup/browser-process-user-agent-ordering.test.ts
index 5fde1f3358a..14887a9ed1e 100644
--- a/src/main/startup/browser-process-user-agent-ordering.test.ts
+++ b/src/main/startup/browser-process-user-agent-ordering.test.ts
@@ -1,3 +1,4 @@
+vi.mock('../backend-desktop/backend-supervisor')
import { expect, it, vi } from 'vitest'
import { runMainProcessPreflight } from './main-process-preflight'
@@ -181,9 +182,6 @@ vi.mock('../persistence/profile-state/offline-access/profile-state-active-locati
getActiveProfileStateLocation: () => ({ profileId: 'active-profile' })
}))
vi.mock('../stats/collector')
-vi.mock('../claude-usage/store')
-vi.mock('../codex-usage/store')
-vi.mock('../opencode-usage/store')
vi.mock('../browser/document-preview/doc-preview-protocol')
vi.mock('../crash-reporting/capture/crashpad-capture')
vi.mock('../crash-reporting/reports/crash-report-store')
diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts
index 9afcffc4217..399359df921 100644
--- a/src/main/startup/main-process-account-services.ts
+++ b/src/main/startup/main-process-account-services.ts
@@ -31,13 +31,7 @@ const MODEL_CATALOG_ACCOUNT_SETTINGS = [
export function initializeMainProcessAccountServices(): void {
const store = state.store
- if (
- !store ||
- !state.claudeUsage ||
- !state.codexUsage ||
- !state.openCodeUsage ||
- !state.museUsage
- ) {
+ if (!store || !state.claudeUsage || !state.codexUsage) {
throw new Error('Usage stores must be initialized before account services')
}
state.codexSessionMigration = createCodexSessionMigrationScheduler({
diff --git a/src/main/startup/main-process-observers.ts b/src/main/startup/main-process-observers.ts
index e2f383b66ce..a4a94d511fa 100644
--- a/src/main/startup/main-process-observers.ts
+++ b/src/main/startup/main-process-observers.ts
@@ -1,3 +1,4 @@
+import { BackendSupervisor } from '../backend-desktop/backend-supervisor'
import { app, powerMonitor } from 'electron'
import { join } from 'node:path'
import { AgentAwakeService } from '../power/agent-awake-service'
@@ -20,12 +21,8 @@ import { recoverPendingSkillTransactions } from '../skills/transaction-state/ski
import { initCohortClassifier } from '../telemetry/cohort-classifier'
import { initOnboardingCohortClassifier } from '../telemetry/onboarding-cohort-classifier'
import { StatsCollector } from '../stats/collector'
-import { feedAgentSessionStats } from '../stats/agent-session-stats-feed'
-import { ClaudeUsageStore } from '../claude-usage/store'
-import { CodexUsageStore } from '../codex-usage/store'
-import { OpenCodeUsageStore } from '../opencode-usage/store'
-import { MuseUsageStore } from '../muse-usage/store'
import { UsageWorktreeHistory } from '../usage/worktree-attribution/usage-worktree-history'
+import { feedAgentSessionStats } from '../stats/agent-session-stats-feed'
import { installRepoMaintenanceIdleGate } from '../workspaces/catalog/repo-maintenance-idle-gate'
import { mainProcessState as state } from './main-process-state'
import { runAfterFirstWindowShown } from './first-window-deferral'
@@ -152,8 +149,7 @@ export function initializeMainProcessObservers(): void {
join(getCanonicalUserDataPath(), 'orca-usage-worktree-history.json')
)
state.usageWorktreeHistory = usageWorktrees
- state.claudeUsage = new ClaudeUsageStore(usageWorktrees)
- state.codexUsage = new CodexUsageStore(usageWorktrees)
- state.openCodeUsage = new OpenCodeUsageStore(usageWorktrees)
- state.museUsage = new MuseUsageStore(usageWorktrees)
+ state.backend = new BackendSupervisor(usageWorktrees)
+ state.claudeUsage = state.backend.automationClient('claudeUsage')
+ state.codexUsage = state.backend.automationClient('codexUsage')
}
diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts
index 7422887c61f..a1ecab2621b 100644
--- a/src/main/startup/main-process-preflight.ts
+++ b/src/main/startup/main-process-preflight.ts
@@ -1,3 +1,4 @@
+import { captureBackendBootstrap } from '../backend-desktop/backend-supervisor'
import { antigravityHookService } from '../antigravity/hook-service'
import { getRelocatedDaemonHost } from '../daemon/executable-host/windows/daemon-host-relocation'
import { app, ipcMain, powerMonitor, session } from 'electron'
@@ -80,10 +81,6 @@ import '../orca-profiles/profile-index-store'
import { getProfileUserDataPath, initOrcaProfilePaths } from '../orca-profiles/profile-storage-paths'
import { recoverPendingProfileProjectMoves } from '../orca-profiles/project-transfer/profile-project-move-intent'
import { initStatsPath } from '../stats/collector'
-import { initClaudeUsagePath } from '../claude-usage/store'
-import { initCodexUsagePath } from '../codex-usage/store'
-import { initOpenCodeUsagePath } from '../opencode-usage/store'
-import { initMuseUsagePath } from '../muse-usage/store'
import { registerDocPreviewSchemePrivileges } from '../browser/document-preview/doc-preview-protocol'
import { PRIVILEGED_CUSTOM_SCHEMES } from './privileged-custom-schemes'
import { startCrashpadCapture } from '../crash-reporting/capture/crashpad-capture'
@@ -304,10 +301,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b
)
// Capture stats/usage paths before app naming can shift userData.
initStatsPath()
- initClaudeUsagePath()
- initCodexUsagePath()
- initOpenCodeUsagePath()
- initMuseUsagePath()
+ captureBackendBootstrap()
// Register preview privileges before ready freezes the table or webviews lose fetch/secure-origin access.
registerDocPreviewSchemePrivileges(PRIVILEGED_CUSTOM_SCHEMES)
// Install Crashpad before first renderer; earlier CHECK failures have exit-code evidence only.
diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts
index 1b5a696a187..0aec0ea1f8e 100644
--- a/src/main/startup/main-process-quit.ts
+++ b/src/main/startup/main-process-quit.ts
@@ -235,12 +235,8 @@ function installWillQuitHandler(): void {
const sshShutdown = Promise.resolve(finalSaves).then((saves) => saves.sshShutdown)
const storeFlush = Promise.resolve(finalSaves).then((saves) => saves.storeFlush)
const watcherShutdown = Promise.resolve(finalSaves).then((saves) => saves.watcherShutdown)
- // Capture queued usage writes before awaiting teardown or quit can lose the last enabled/scan snapshot.
const usageCacheFlush = Promise.all([
- state.claudeUsage?.flush(),
- state.codexUsage?.flush(),
- state.openCodeUsage?.flush(),
- state.museUsage?.flush(),
+ state.backend?.stop(),
state.usageWorktreeHistory?.flush()
]).then(() => {})
disposeDesktopBrowserBridge()
diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts
index e2dd75c66aa..ecb42038b0a 100644
--- a/src/main/startup/main-process-state.ts
+++ b/src/main/startup/main-process-state.ts
@@ -1,10 +1,8 @@
+import type { BackendSupervisor } from '../backend-desktop/backend-supervisor'
+import type { AutomationUsageClient } from '../../shared/backend-service-contract'
import { type BrowserWindow, type Tray, app } from 'electron'
import type { Store } from '../persistence/loading-store/store'
import type { StatsCollector } from '../stats/collector'
-import type { ClaudeUsageStore } from '../claude-usage/store'
-import type { CodexUsageStore } from '../codex-usage/store'
-import type { OpenCodeUsageStore } from '../opencode-usage/store'
-import type { MuseUsageStore } from '../muse-usage/store'
import type { UsageWorktreeHistory } from '../usage/worktree-attribution/usage-worktree-history'
import type { CodexAccountService } from '../codex-accounts/service'
import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service'
@@ -67,6 +65,14 @@ function createInitialProfileStateAdmission(): ProfileStateRuntimeAdmission | un
return undefined
}
+function createInitialBackendServices(): {
+ backend: BackendSupervisor | null
+ claudeUsage: AutomationUsageClient | null
+ codexUsage: AutomationUsageClient | null
+} {
+ return { backend: null, claudeUsage: null, codexUsage: null }
+}
+
/** Mutable composition-root state shared by startup, window, serve, and quit phases. */
const desktopRelayStatus: MobileRelayStatusDetail = { status: 'offline' }
@@ -80,12 +86,8 @@ export const mainProcessState = {
profileStateStartup: createInitialProfileStateStartup(),
profileStateAdmission: createInitialProfileStateAdmission(),
stats: null as StatsCollector | null,
- claudeUsage: null as ClaudeUsageStore | null,
- codexUsage: null as CodexUsageStore | null,
- openCodeUsage: null as OpenCodeUsageStore | null,
- // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: widens the null slot to the store type assigned by main-process-observers.
- museUsage: null as MuseUsageStore | null,
- // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: widens the null slot to the history assigned by main-process-observers.
+ ...createInitialBackendServices(),
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: widens the null slot assigned by main-process-observers.
usageWorktreeHistory: null as UsageWorktreeHistory | null,
codexAccounts: null as CodexAccountService | null,
codexRuntimeHome: null as CodexRuntimeHomeService | null,
diff --git a/src/main/startup/main-window-controller.ts b/src/main/startup/main-window-controller.ts
index 94643295e52..e8366b54d63 100644
--- a/src/main/startup/main-window-controller.ts
+++ b/src/main/startup/main-window-controller.ts
@@ -55,10 +55,6 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}
store: state.store,
runtime: state.runtime,
stats: state.stats,
- claudeUsage: state.claudeUsage,
- codexUsage: state.codexUsage,
- openCodeUsage: state.openCodeUsage,
- museUsage: state.museUsage,
rateLimits: state.rateLimits,
automations: state.automations,
codexAccounts: state.codexAccounts,
diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts
index 9fe6d2164e4..5e997750b2b 100644
--- a/src/main/startup/main-window-core-services.ts
+++ b/src/main/startup/main-window-core-services.ts
@@ -34,10 +34,6 @@ export function attachMainWindowCoreServices(
const store = state.store
const runtime = state.runtime
const stats = state.stats
- const claudeUsage = state.claudeUsage
- const codexUsage = state.codexUsage
- const openCodeUsage = state.openCodeUsage
- const museUsage = state.museUsage
const codexAccounts = state.codexAccounts
const claudeAccounts = state.claudeAccounts
const rateLimits = state.rateLimits
@@ -49,10 +45,6 @@ export function attachMainWindowCoreServices(
!store ||
!runtime ||
!stats ||
- !claudeUsage ||
- !codexUsage ||
- !openCodeUsage ||
- !museUsage ||
!codexAccounts ||
!claudeAccounts ||
!rateLimits ||
@@ -69,10 +61,6 @@ export function attachMainWindowCoreServices(
store,
runtime,
stats,
- claudeUsage,
- codexUsage,
- openCodeUsage,
- museUsage,
codexAccounts,
claudeAccounts,
rateLimits,
diff --git a/src/main/startup/main-window-service-readiness.ts b/src/main/startup/main-window-service-readiness.ts
index 09f7e281400..23df7466368 100644
--- a/src/main/startup/main-window-service-readiness.ts
+++ b/src/main/startup/main-window-service-readiness.ts
@@ -3,10 +3,6 @@ const MAIN_WINDOW_SERVICE_REQUIREMENTS = [
['store', 'Store must be initialized before opening the main window'],
['runtime', 'Runtime must be initialized before opening the main window'],
['stats', 'Stats must be initialized before opening the main window'],
- ['claudeUsage', 'Claude usage store must be initialized before opening the main window'],
- ['codexUsage', 'Codex usage store must be initialized before opening the main window'],
- ['openCodeUsage', 'OpenCode usage store must be initialized before opening the main window'],
- ['museUsage', 'Muse usage store must be initialized before opening the main window'],
['rateLimits', 'Rate limit service must be initialized before opening the main window'],
['automations', 'Automation service must be initialized before opening the main window'],
['codexAccounts', 'Codex account service must be initialized before opening the main window'],
diff --git a/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.test.ts b/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.test.ts
new file mode 100644
index 00000000000..caaceb5667a
--- /dev/null
+++ b/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.test.ts
@@ -0,0 +1,319 @@
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
+
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import type { UsageStoreHost, UsageTelemetryHost } from '../usage-store-host'
+import { createTestUsageStoreHost } from '../usage-store-host-test-fixture'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { UsageScanWorktreeRef } from './usage-provider-contract'
+import { UsageProviderStoreLifecycle } from './usage-provider-store-lifecycle'
+import {
+ splitUsageCacheFile,
+ type UsageCacheSplitRequest,
+ type UsageCacheSplitResult,
+ type UsageSourceCacheRef
+} from '../cache/usage-source-cache-file'
+
+type TestSource = { id: string }
+type TestSession = { id: string }
+type TestDailyAggregate = { day: string }
+type TestScanState = {
+ enabled: boolean
+ lastScanStartedAt: number | null
+ lastScanCompletedAt: number | null
+ lastScanError: string | null
+}
+type TestState = {
+ schemaVersion: number
+ worktreeFingerprint: string | null
+ processedSources: TestSource[]
+ sessions: TestSession[]
+ dailyAggregates: TestDailyAggregate[]
+ scanState: TestScanState
+}
+type TestScanResult = Pick
+type TestScan = (
+ worktrees: UsageScanWorktreeRef[],
+ sourceCache: UsageSourceCacheRef
+) => Promise
+type TestSplit = (request: UsageCacheSplitRequest) => Promise
+
+const NOW = Date.parse('2026-04-10T16:00:00.000Z')
+
+function makeState(
+ overrides: Partial> & { scanState?: Partial } = {}
+): TestState {
+ const { scanState, ...stateOverrides } = overrides
+ return {
+ schemaVersion: 1,
+ worktreeFingerprint: null,
+ processedSources: [],
+ sessions: [],
+ dailyAggregates: [],
+ ...stateOverrides,
+ scanState: {
+ enabled: false,
+ lastScanStartedAt: null,
+ lastScanCompletedAt: null,
+ lastScanError: null,
+ ...scanState
+ }
+ }
+}
+
+function emptyScanResult(): TestScanResult {
+ return { sessions: [], dailyAggregates: [] }
+}
+
+class TestUsageStore extends UsageProviderStoreLifecycle<
+ 'processedSources',
+ TestState,
+ 'hasAnyTestData'
+> {
+ constructor(
+ cacheFile: string,
+ scan: TestScan,
+ splitCacheFile: TestSplit,
+ capture: ReturnType>,
+ readCatalog?: UsageStoreHost['readCatalog'],
+ isEnabled?: UsageTelemetryHost['isEnabled'],
+ signal?: AbortSignal
+ ) {
+ super(
+ {
+ ...createTestUsageStoreHost({
+ userDataPath: dirname(cacheFile),
+ telemetryEnabled: true,
+ capture,
+ signal
+ }),
+ ...(readCatalog ? { readCatalog } : {}),
+ ...(isEnabled ? { telemetry: { isEnabled, capture } } : {})
+ },
+ {
+ tokenUsage: {
+ provider: 'claude',
+ selectSessions: (state) =>
+ state.sessions.map((session) => ({
+ providerSessionId: session.id,
+ input_tokens: 10,
+ output_tokens: 2,
+ cached_input_tokens: 3,
+ cache_write_input_tokens: 1
+ }))
+ },
+ logTag: '[test-usage]',
+ resolveCacheFile: () => cacheFile,
+ createDefaultState: makeState,
+ normalizeState: (state) => state,
+ sourceKey: 'processedSources',
+ dataPresenceKey: 'hasAnyTestData',
+ scan,
+ splitCacheFile
+ }
+ )
+ }
+
+ replaceState(state: TestState): void {
+ this.state = state
+ }
+
+ getState(): TestState {
+ return this.state
+ }
+}
+
+describe('UsageProviderStoreLifecycle', () => {
+ let tempDirectory: string
+ let stores: TestUsageStore[]
+ let scan: ReturnType>
+ let split: ReturnType>
+ let capture: ReturnType>
+
+ beforeEach(() => {
+ capture = vi.fn(async () => true)
+ tempDirectory = mkdtempSync(join(tmpdir(), 'orca-usage-lifecycle-'))
+ stores = []
+ scan = vi.fn().mockResolvedValue(emptyScanResult())
+ // Run the real cache split in-process; the worker only wraps it in a thread.
+ split = vi.fn(splitUsageCacheFile)
+ vi.spyOn(Date, 'now').mockReturnValue(NOW)
+ })
+
+ afterEach(async () => {
+ await Promise.all(stores.map((store) => store.flush()))
+ rmSync(tempDirectory, { recursive: true, force: true })
+ vi.restoreAllMocks()
+ })
+
+ it.each([100, 1000])('uses one consistent catalog per refresh at %i worktrees', async (count) => {
+ const worktrees = Array.from({ length: count }, (_, i) => ({
+ worktreeId: `repo::/work/${i}`,
+ path: `/work/${i}`,
+ displayName: `Work ${i}`
+ }))
+ const readCatalog = vi.fn().mockResolvedValue({
+ repoIds: ['repo'],
+ worktreesByRepo: [['repo', worktrees]]
+ })
+ const store = new TestUsageStore(
+ join(tempDirectory, 'catalog.json'),
+ scan,
+ split,
+ capture,
+ readCatalog
+ )
+ stores.push(store)
+ await store.whenLoaded()
+ await store.refresh(true)
+ expect(readCatalog).not.toHaveBeenCalled()
+ await store.setEnabled(true)
+ const blocked = Promise.withResolvers()
+ scan.mockReturnValueOnce(blocked.promise)
+ const first = store.refresh(true)
+ const second = store.refresh(true)
+ await vi.waitFor(() => expect(scan).toHaveBeenCalledTimes(1))
+ expect(readCatalog).toHaveBeenCalledTimes(2)
+ expect(scan.mock.calls[0][0]).toHaveLength(count)
+ expect(scan.mock.calls[0][0][0]).toMatchObject({ path: '/work/0' })
+ blocked.resolve({ sessions: [{ id: 'catalog-result' }], dailyAggregates: [] })
+ await Promise.all([first, second])
+ expect(store.getState().sessions).toEqual([{ id: 'catalog-result' }])
+ readCatalog.mockClear()
+ await store.refresh()
+ expect(readCatalog).toHaveBeenCalledTimes(1)
+ expect(scan).toHaveBeenCalledTimes(1)
+ store.stopScanAdmission()
+ readCatalog.mockClear()
+ await store.refresh(true)
+ expect(readCatalog).not.toHaveBeenCalled()
+ })
+
+ it('keeps a successful scan when the first async telemetry consent request rejects', async () => {
+ const cacheFile = join(tempDirectory, 'consent-rejection.json')
+ const consent = vi
+ .fn()
+ .mockRejectedValue(new Error('No RPC credit'))
+ const store = new TestUsageStore(cacheFile, scan, split, capture, undefined, consent)
+ stores.push(store)
+ await store.setEnabled(true)
+ scan.mockResolvedValueOnce({
+ sessions: [{ id: 'fresh' }],
+ dailyAggregates: [{ day: '2026-04-10' }]
+ })
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ await expect(store.refresh(true)).resolves.toMatchObject({
+ isScanning: false,
+ lastScanCompletedAt: NOW,
+ lastScanError: null,
+ hasAnyTestData: true
+ })
+ await store.flush()
+ expect(store.getState().sessions).toEqual([{ id: 'fresh' }])
+ expect(JSON.parse(readFileSync(cacheFile, 'utf8'))).toMatchObject({
+ sessions: [{ id: 'fresh' }],
+ dailyAggregates: [{ day: '2026-04-10' }],
+ scanState: { lastScanCompletedAt: NOW, lastScanError: null }
+ })
+ expect(consent).toHaveBeenCalledTimes(1)
+ expect(capture).not.toHaveBeenCalled()
+ })
+
+ it('stops admission without persisting a host catalog abort as a scan failure', async () => {
+ const cacheFile = join(tempDirectory, 'aborted-catalog.json')
+ const abort = new AbortController()
+ const readCatalog = vi.fn().mockResolvedValue({
+ repoIds: ['repo-1'],
+ worktreesByRepo: [
+ ['repo-1', [{ worktreeId: 'repo-1::/workspace/a', path: '/workspace/a', displayName: 'A' }]]
+ ]
+ })
+ const store = new TestUsageStore(
+ cacheFile,
+ scan,
+ split,
+ capture,
+ readCatalog,
+ undefined,
+ abort.signal
+ )
+ stores.push(store)
+ await store.setEnabled(true)
+ scan.mockResolvedValueOnce({ sessions: [{ id: 'scan-result' }], dailyAggregates: [] })
+ await store.refresh(true)
+ expect(scan.mock.calls[0]?.[0]).toEqual([
+ {
+ repoId: 'repo-1',
+ worktreeId: 'repo-1::/workspace/a',
+ path: '/workspace/a',
+ displayName: 'A'
+ }
+ ])
+ const catalog = Promise.withResolvers>>()
+ readCatalog.mockReturnValueOnce(catalog.promise)
+ const refresh = store.refresh(true)
+ abort.abort(new Error('Backend stopping'))
+ catalog.resolve({ repoIds: [], worktreesByRepo: [] })
+ await expect(refresh).rejects.toThrow('Backend stopping')
+ store.stopScanAdmission()
+ await store.refresh(true)
+ await store.flush()
+ expect(JSON.parse(readFileSync(cacheFile, 'utf8'))).toMatchObject({
+ sessions: [{ id: 'scan-result' }],
+ scanState: { enabled: true, lastScanError: null }
+ })
+ })
+
+ it.each([
+ { waitingOn: 'catalog', sessions: [{ id: 'prior' }], completedAt: 12_000 },
+ { waitingOn: 'scanner', sessions: [{ id: 'fresh' }], completedAt: NOW }
+ ])(
+ 'preserves disable while waiting on $waitingOn',
+ async ({ waitingOn, sessions, completedAt }) => {
+ const cacheFile = join(tempDirectory, `disable-${waitingOn}.json`)
+ const entered = Promise.withResolvers()
+ const release = Promise.withResolvers()
+ const readCatalog = async () => {
+ if (waitingOn === 'catalog') {
+ entered.resolve()
+ await release.promise
+ }
+ return { repoIds: [], worktreesByRepo: [] }
+ }
+ scan.mockImplementationOnce(async () => {
+ if (waitingOn === 'scanner') {
+ entered.resolve()
+ await release.promise
+ }
+ return { sessions: [{ id: 'fresh' }], dailyAggregates: [] }
+ })
+ const store = new TestUsageStore(cacheFile, scan, split, capture, readCatalog)
+ stores.push(store)
+ await store.whenLoaded()
+ store.replaceState(
+ makeState({
+ sessions: [{ id: 'prior' }],
+ scanState: { enabled: true, lastScanCompletedAt: 12_000 }
+ })
+ )
+ const refresh = store.refresh(true)
+ await entered.promise
+ await store.setEnabled(false)
+ release.resolve()
+ await expect(refresh).resolves.toMatchObject({
+ enabled: false,
+ isScanning: false,
+ lastScanCompletedAt: completedAt,
+ lastScanError: null
+ })
+ expect(store.getState().sessions).toEqual(sessions)
+ await store.flush()
+ expect(JSON.parse(readFileSync(cacheFile, 'utf8'))).toMatchObject({
+ sessions,
+ scanState: { enabled: false, lastScanCompletedAt: completedAt, lastScanError: null }
+ })
+ expect(capture).not.toHaveBeenCalled()
+ }
+ )
+})
diff --git a/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.ts b/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.ts
index 23c12df6227..9bbada658b3 100644
--- a/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.ts
+++ b/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.ts
@@ -1,19 +1,13 @@
import { readFileSync, statSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { AgentTokenUsageReporter } from '../reporting/token-usage/agent-token-usage-reporter'
-
-import { isTelemetryEnabled } from '../../telemetry/client'
import { join, parse } from 'node:path'
import {
AnalyticsSessionIdStore,
type AnalyticsSessionId
} from '../reporting/token-usage/analytics-session-id-store'
+import type { UsageStoreHost } from '../usage-store-host'
import { UsageCacheSnapshotWriter } from '../cache/usage-cache-snapshot-writer'
-import {
- loadKnownUsageWorktreesByRepo,
- type UsageWorktreeSource
-} from '../worktree-attribution/usage-worktree-metadata'
-
import {
createWorktreeRefs,
getUsageWorktreeFingerprint
@@ -40,20 +34,21 @@ export abstract class UsageProviderStoreLifecycle<
private readonly loaded: Promise
private readonly schemaVersion: number
private scanPromise: Promise | null = null
+ private stopping = false
private tokenReporter: AgentTokenUsageReporter | null = null
private analyticsSessionIds: AnalyticsSessionIdStore | null = null
private readonly writer: UsageCacheSnapshotWriter
private readonly inlineCache = new UsageInlineCachePreservation()
constructor(
- private readonly store: UsageWorktreeSource,
+ private readonly host: UsageStoreHost,
private readonly config: UsageProviderStoreLifecycleConfig
) {
this.writer = new UsageCacheSnapshotWriter(config.logTag, config.resolveCacheFile)
const defaults = config.createDefaultState()
this.schemaVersion = defaults.schemaVersion
this.state = defaults
- this.loaded = Promise.all([this.load(), store.whenLoaded()]).then(() => {})
+ this.loaded = this.load()
}
/** Resolves once persisted state is in memory; synchronous readers must wait for it first. */
@@ -82,6 +77,14 @@ export abstract class UsageProviderStoreLifecycle<
}
/** Await queued cache writes so quit does not drop the final snapshot. */
+ stopScanAdmission(): void {
+ this.stopping = true
+ }
+
+ async settleScansForShutdown(): Promise {
+ await this.scanPromise
+ }
+
async flush(): Promise {
await this.loaded
await this.inlineCache.settle()
@@ -98,17 +101,22 @@ export abstract class UsageProviderStoreLifecycle<
async refresh(force = false): Promise> {
await this.loaded
- if (!this.state.scanState.enabled) {
+ if (this.stopping || !this.state.scanState.enabled) {
return this.getScanState()
}
- const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint()
+ const catalog = await this.host.readCatalog()
+ this.host.signal.throwIfAborted()
+ const currentWorktreeFingerprint = getUsageWorktreeFingerprint(new Map(catalog.worktreesByRepo))
if (!force && this.state.scanState.lastScanCompletedAt) {
const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt
if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) {
return this.getScanState()
}
}
- await this.runScan()
+ if (this.stopping || !this.state.scanState.enabled) {
+ return this.getScanState()
+ }
+ await this.runScan(catalog)
return this.getScanState()
}
@@ -219,7 +227,9 @@ export abstract class UsageProviderStoreLifecycle<
})
}
- private async runScan(): Promise {
+ private async runScan(
+ catalog: Awaited>
+ ): Promise {
if (this.scanPromise) {
await this.scanPromise
return
@@ -231,15 +241,22 @@ export abstract class UsageProviderStoreLifecycle<
// Assign before yielding so concurrent refreshes share one scan.
this.scanPromise = (async () => {
try {
- const repos = this.store.getRepos()
- const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
+ const { repoIds, worktreesByRepo: entries } = catalog
+ this.host.signal.throwIfAborted()
+ const worktreesByRepo = new Map(entries)
const worktreeFingerprint = getUsageWorktreeFingerprint(worktreesByRepo)
- const result = await this.config.scan(createWorktreeRefs(repos, worktreesByRepo), {
- path: usageSourceCachePath(this.config.resolveCacheFile()),
- schemaVersion: this.schemaVersion,
- worktreeFingerprint,
- reuse: this.state.worktreeFingerprint === worktreeFingerprint
- })
+ const result = await this.config.scan(
+ createWorktreeRefs(
+ repoIds.map((id) => ({ id })),
+ worktreesByRepo
+ ),
+ {
+ path: usageSourceCachePath(this.config.resolveCacheFile()),
+ schemaVersion: this.schemaVersion,
+ worktreeFingerprint,
+ reuse: this.state.worktreeFingerprint === worktreeFingerprint
+ }
+ )
this.state.sessions = result.sessions
this.state.dailyAggregates = result.dailyAggregates
this.state.worktreeFingerprint = worktreeFingerprint
@@ -251,6 +268,9 @@ export abstract class UsageProviderStoreLifecycle<
await this.writeToDisk().catch(() => {})
await this.reportTokenUsage()
} catch (error) {
+ if (this.stopping || this.host.signal.aborted) {
+ return
+ }
this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error)
await this.writeToDisk().catch(() => {})
} finally {
@@ -263,16 +283,20 @@ export abstract class UsageProviderStoreLifecycle<
private async reportTokenUsage(): Promise {
const config = this.config.tokenUsage
- if (!config || !isTelemetryEnabled() || !this.state.scanState.enabled) {
+ if (!config) {
return
}
try {
+ if (!(await this.host.telemetry.isEnabled()) || !this.state.scanState.enabled) {
+ return
+ }
if (!this.tokenReporter) {
const { dir, name } = parse(this.config.resolveCacheFile())
this.tokenReporter = new AgentTokenUsageReporter(
join(dir, `${name}-token-usage.json`),
config.provider,
- (ids) => this.getAnalyticsSessionIds(ids)
+ (ids) => this.getAnalyticsSessionIds(ids),
+ this.host.telemetry
)
}
await this.tokenReporter.report(config.selectSessions(this.state))
@@ -283,11 +307,6 @@ export abstract class UsageProviderStoreLifecycle<
)
}
}
-
- private async getCurrentWorktreeFingerprint(): Promise {
- const repos = this.store.getRepos()
- return getUsageWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos))
- }
}
function isMissingFileError(error: unknown): boolean {
diff --git a/src/main/usage/reporting/token-usage/agent-token-usage-reporter.ts b/src/main/usage/reporting/token-usage/agent-token-usage-reporter.ts
index 831c979be04..b0bc656652b 100644
--- a/src/main/usage/reporting/token-usage/agent-token-usage-reporter.ts
+++ b/src/main/usage/reporting/token-usage/agent-token-usage-reporter.ts
@@ -5,7 +5,7 @@ import {
agentTokenUsageSchema,
type AgentTokenUsage
} from '../../../../shared/telemetry-agent-token-usage-schema'
-import { isTelemetryEnabled, track } from '../../../telemetry/client'
+import type { UsageTelemetryHost } from '../../usage-store-host'
import { UsageCacheSnapshotWriter } from '../../cache/usage-cache-snapshot-writer'
import type { AgentTokenSession } from './agent-token-usage'
@@ -26,7 +26,8 @@ export class AgentTokenUsageReporter {
constructor(
private readonly file: string,
private readonly provider: AgentTokenUsage['provider'],
- private readonly getSessionIds: (providerSessionIds: readonly string[]) => Promise
+ private readonly getSessionIds: (providerSessionIds: readonly string[]) => Promise,
+ private readonly telemetry: UsageTelemetryHost
) {
this.writer = new UsageCacheSnapshotWriter('[agent-token-usage]', () => file)
}
@@ -43,7 +44,7 @@ export class AgentTokenUsageReporter {
}
private async publish(sessions: AgentTokenSession[]): Promise {
- if (!isTelemetryEnabled()) {
+ if (!(await this.telemetry.isEnabled())) {
return
}
const rows = sessions.flatMap(({ providerSessionId, ...counts }) => {
@@ -51,7 +52,7 @@ export class AgentTokenUsageReporter {
return parsed.success ? [{ providerSessionId, counts: parsed.data }] : []
})
const ids = await this.getSessionIds(rows.map((row) => row.providerSessionId))
- if (!isTelemetryEnabled()) {
+ if (!(await this.telemetry.isEnabled())) {
return
}
const previous = this.snapshots ?? (await this.load())
@@ -91,7 +92,7 @@ export class AgentTokenUsageReporter {
if (this.acceptedRevisions.get(snapshot.analytics_session_id) === snapshot.revision) {
continue
}
- if (!track('agent_token_usage', snapshot)) {
+ if (!(await this.telemetry.capture(snapshot))) {
break
}
// Queue acceptance only deduplicates this process; restart must retry undelivered snapshots.
diff --git a/src/main/usage/scan-worker/usage-scan-worker-client.ts b/src/main/usage/scan-worker/usage-scan-worker-client.ts
index 80dcde2868d..26e344c0353 100644
--- a/src/main/usage/scan-worker/usage-scan-worker-client.ts
+++ b/src/main/usage/scan-worker/usage-scan-worker-client.ts
@@ -1,4 +1,4 @@
-import { WorkerThreadRequestQueue } from '../../worker-threads/worker-thread-request-queue'
+import { UsageScanRequestScheduler } from '../usage-scan-request-scheduler'
import type { WorkerThreadFactory } from '../../worker-threads/worker-thread-transport'
import type { ClaudeUsageDailyAggregate, ClaudeUsageSession } from '../../claude-usage/types'
import type { CodexUsageDailyAggregate, CodexUsageSession } from '../../codex-usage/types'
@@ -7,9 +7,7 @@ import type { MuseUsageDailyAggregate, MuseUsageSession } from '../../muse-usage
import type { UsageScanWorktreeRef } from '../provider-lifecycle/usage-provider-contract'
import type {
UsageScanWorkerProviderId,
- UsageScanWorkerRequest,
UsageScanWorkerRequestBody,
- UsageScanWorkerResponse,
UsageScanWorkerScanBody,
UsageScanWorkerValue
} from './usage-scan-worker-protocol'
@@ -18,10 +16,9 @@ import type {
UsageCacheSplitResult,
UsageSourceCacheRef
} from '../cache/usage-source-cache-file'
-import { isUsageScanWorkerProgress } from './usage-scan-worker-protocol'
import { createUsageScanWorkerTransport } from './usage-scan-worker-cache-cleanup'
-// #20940: serialize provider scans with WorkerThreadRequestQueue; LazyWorkerThreadHost owns thread lifetime and the spawn module owns layout/singleton.
+// Provider-local ordering shares one worker; LazyWorkerThreadHost owns its lifetime.
// Cold corpora can exceed any total deadline; time only gaps between progress reports. A huge rollout is one gap, so keep the window generous.
export const USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS = 10 * 60_000
@@ -39,25 +36,26 @@ type ProviderScanResult = {
/** Unavailable, timed-out or crashed workers reject; stores retain their previous projection instead of parsing on main. */
export class UsageScanWorkerClient {
- private readonly queue: WorkerThreadRequestQueue
+ private readonly queue: UsageScanRequestScheduler
constructor(options: { workerFactory: WorkerThreadFactory; log?: (message: string) => void }) {
const log = options.log ?? ((message: string) => console.warn(message))
- this.queue = new WorkerThreadRequestQueue({
+ this.queue = new UsageScanRequestScheduler({
factory: () => createUsageScanWorkerTransport(options.workerFactory),
idleTeardownMs: IDLE_TEARDOWN_MS,
maxConsecutiveDeaths: MAX_CONSECUTIVE_DEATHS,
createUnavailableError: (message) => new UsageScanWorkerUnavailableError(message),
- isProgress: isUsageScanWorkerProgress,
- describeTimeout: (timeoutMs) => `Usage scan worker reported no progress for ${timeoutMs}ms`,
- describeExit: (code) => `Usage scan worker exited with code ${code}`,
- describeCrashLoop: (lastError) => `Usage scan worker crashed repeatedly (${lastError})`,
// Missing/resource-starved workers must fail closed; main-thread scanning would reintroduce event-loop blocking.
onUnavailable: (err) =>
log(`[usage-scan] worker unavailable; usage scans will report an error. ${message(err)}`)
})
}
+ async dispose(): Promise {
+ this.queue.dispose()
+ await this.queue.retired()
+ }
+
/** sourceCache identifies provider records that stay on the worker. */
scan(body: UsageScanWorkerScanBody): Promise {
return this.dispatch(body)
@@ -79,10 +77,7 @@ export class UsageScanWorkerClient {
}
private async dispatch(body: UsageScanWorkerRequestBody): Promise {
- const response = await this.queue.dispatch(
- (id) => ({ ...body, id }),
- USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS
- )
+ const response = await this.queue.dispatch(body, USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS)
if (!response.ok) {
throw new Error(response.error)
}
diff --git a/src/main/usage/scan-worker/usage-scan-worker-spawn.ts b/src/main/usage/scan-worker/usage-scan-worker-spawn.ts
index 64fcdcd242b..c835743e19f 100644
--- a/src/main/usage/scan-worker/usage-scan-worker-spawn.ts
+++ b/src/main/usage/scan-worker/usage-scan-worker-spawn.ts
@@ -1,4 +1,3 @@
-import { claudeProfileTranscriptDirs } from '../../claude-usage/transcript-file-discovery'
import { existsSync } from 'node:fs'
import type { Worker } from 'node:worker_threads'
import { spawnWorkerThread } from '../../worker-threads/worker-thread-spawn'
@@ -49,18 +48,15 @@ function getSharedClient(): UsageScanWorkerClient {
export function scanClaudeUsageFilesViaWorker(
worktrees: UsageScanWorktreeRef[],
- sourceCache: UsageSourceCacheRef
+ sourceCache: UsageSourceCacheRef,
+ profileDirs: string[]
): Promise<{
sessions: ClaudeUsageSession[]
dailyAggregates: ClaudeUsageDailyAggregate[]
}> {
return scanClaudeUsageOnWorker(
(body) =>
- getSharedClient().scan(
- body.providerId === 'claude'
- ? { ...body, profileDirs: claudeProfileTranscriptDirs() }
- : body
- ),
+ getSharedClient().scan(body.providerId === 'claude' ? { ...body, profileDirs } : body),
worktrees,
sourceCache
)
@@ -102,3 +98,9 @@ export function splitUsageCacheFileViaWorker(
): Promise {
return getSharedClient().splitCacheFile(request)
}
+
+export async function stopUsageScanWorker(): Promise {
+ const client = sharedClient
+ sharedClient = null
+ await client?.dispose()
+}
diff --git a/src/main/usage/usage-scan-request-scheduler.test.ts b/src/main/usage/usage-scan-request-scheduler.test.ts
new file mode 100644
index 00000000000..ef765b4c73e
--- /dev/null
+++ b/src/main/usage/usage-scan-request-scheduler.test.ts
@@ -0,0 +1,274 @@
+import { EventEmitter } from 'node:events'
+import type { Worker } from 'node:worker_threads'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ UsageScanWorkerClient,
+ UsageScanWorkerUnavailableError,
+ USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS
+} from './scan-worker/usage-scan-worker-client'
+import type {
+ UsageScanWorkerProviderId,
+ UsageScanWorkerRequest,
+ UsageScanWorkerScanBody,
+ UsageScanWorkerValue
+} from './scan-worker/usage-scan-worker-protocol'
+
+class ScanWorker extends EventEmitter {
+ posted: UsageScanWorkerRequest[] = []
+ exit: Promise = Promise.resolve(1)
+ terminate = vi.fn(() => this.exit)
+ unref(): void {}
+
+ postMessage(request: UsageScanWorkerRequest): void {
+ this.posted.push(request)
+ }
+
+ complete(index: number): void {
+ const request = this.posted[index]
+ const value: UsageScanWorkerValue =
+ request.operation === 'scan'
+ ? { operation: 'scan', providerId: request.providerId, sessions: [], dailyAggregates: [] }
+ : { operation: 'splitCacheFile', reportText: 'saved report', migrated: false }
+ this.emit('message', { id: request.id, ok: true, value })
+ }
+}
+
+function body(providerId: UsageScanWorkerProviderId, population = 1): UsageScanWorkerScanBody {
+ return {
+ operation: 'scan',
+ providerId,
+ worktrees: Array.from({ length: population }, (_, i) => ({
+ repoId: 'repo',
+ worktreeId: `tree-${i}`,
+ displayName: `tree-${i}`,
+ path: `/tree-${i}`
+ })),
+ sourceCache: {
+ path: `/${providerId}-sources.json`,
+ schemaVersion: 1,
+ worktreeFingerprint: '[]',
+ reuse: true
+ }
+ }
+}
+
+const clients: UsageScanWorkerClient[] = []
+function fixture(): { client: UsageScanWorkerClient; workers: ScanWorker[] } {
+ const workers: ScanWorker[] = []
+ const client = new UsageScanWorkerClient({
+ workerFactory: () => {
+ const worker = new ScanWorker()
+ workers.push(worker)
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements every Worker member the host and transport use; no native Worker API is reached.
+ return worker as unknown as Worker
+ },
+ log: () => {}
+ })
+ clients.push(client)
+ return { client, workers }
+}
+
+beforeEach(() => vi.useFakeTimers())
+afterEach(async () => {
+ await Promise.all(clients.splice(0).map((client) => client.dispose().catch(() => {})))
+ vi.useRealTimers()
+})
+
+describe('usage scan scheduling through the production client', () => {
+ it.each([100, 1000])(
+ 'admits only four providers at %i worktrees and routes unordered replies',
+ async (population) => {
+ const { client, workers } = fixture()
+ const calls = [
+ client.scan(body('claude', population)),
+ client.scan(body('codex', population)),
+ client.scan(body('opencode', population)),
+ client.scan(body('muse', population))
+ ]
+ const worker = workers[0]
+ expect(worker.posted.map((request) => request.providerId)).toEqual([
+ 'claude',
+ 'codex',
+ 'opencode',
+ 'muse'
+ ])
+ worker.emit('message', { id: worker.posted[1].id, filesScanned: 1 })
+ for (const index of [2, 0, 3, 1]) {
+ worker.complete(index)
+ }
+ expect(await Promise.all(calls)).toEqual([
+ { operation: 'scan', providerId: 'claude', sessions: [], dailyAggregates: [] },
+ { operation: 'scan', providerId: 'codex', sessions: [], dailyAggregates: [] },
+ { operation: 'scan', providerId: 'opencode', sessions: [], dailyAggregates: [] },
+ { operation: 'scan', providerId: 'muse', sessions: [], dailyAggregates: [] }
+ ])
+ }
+ )
+
+ it('keeps same-provider FIFO and makes a split an exclusive barrier without overtaking', async () => {
+ const { client, workers } = fixture()
+ const first = client.scan(body('claude'))
+ const second = client.scan(body('claude'))
+ const codex = client.scan(body('codex'))
+ const split = client.splitCacheFile({ cacheFile: '/claude.json', sourceKey: 'processedFiles' })
+ const muse = client.scan(body('muse'))
+ const worker = workers[0]
+ expect(worker.posted.map((request) => request.providerId)).toEqual(['claude', 'codex'])
+ worker.complete(0)
+ expect(worker.posted.map((request) => request.providerId)).toEqual([
+ 'claude',
+ 'codex',
+ 'claude'
+ ])
+ worker.complete(2)
+ expect(worker.posted).toHaveLength(3)
+ worker.complete(1)
+ expect(worker.posted[3].operation).toBe('splitCacheFile')
+ expect(worker.posted).toHaveLength(4)
+ worker.complete(3)
+ expect(worker.posted[4].providerId).toBe('muse')
+ worker.complete(4)
+ await Promise.all([first, second, codex, muse])
+ expect(await split).toEqual({ reportText: 'saved report', migrated: false })
+ })
+
+ it('settles every posted call on one fault, parks only unposted work, and never replays', async () => {
+ const { client, workers } = fixture()
+ const posted = [
+ client.scan(body('claude')),
+ client.scan(body('codex')),
+ client.scan(body('muse'))
+ ].map((promise) => promise.catch((error: unknown) => error))
+ const pending = client.scan(body('claude'))
+ const old = workers[0]
+ let proveExit!: (code: number) => void
+ old.exit = new Promise((resolve) => {
+ proveExit = resolve
+ })
+ const failure = new Error('worker crashed')
+ old.emit('error', failure)
+ expect(await Promise.all(posted)).toEqual([failure, failure, failure])
+ expect(old.terminate).toHaveBeenCalledTimes(1)
+ await vi.advanceTimersByTimeAsync(USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS + 1)
+ expect(workers).toHaveLength(1)
+ proveExit(1)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(workers).toHaveLength(2)
+ expect(workers[1].posted.map((request) => request.providerId)).toEqual(['claude'])
+ old.complete(0)
+ workers[1].complete(0)
+ expect(await pending).toEqual({
+ operation: 'scan',
+ providerId: 'claude',
+ sessions: [],
+ dailyAggregates: []
+ })
+ })
+
+ it('times each dispatched ID independently, ignoring other-provider and unknown progress', async () => {
+ const { client, workers } = fixture()
+ const claude = client.scan(body('claude')).catch((error: unknown) => error)
+ const codex = client.scan(body('codex')).catch((error: unknown) => error)
+ await vi.advanceTimersByTimeAsync(USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS / 2)
+ workers[0].emit('message', { id: workers[0].posted[1].id, filesScanned: 1 })
+ workers[0].emit('message', { id: 999, filesScanned: 1 })
+ await vi.advanceTimersByTimeAsync(USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS / 2 + 1)
+ expect(await claude).toEqual(new Error('Usage scan worker reported no progress for 600000ms'))
+ expect(await codex).toEqual(new Error('Usage scan worker reported no progress for 600000ms'))
+ expect(workers[0].terminate).toHaveBeenCalledTimes(1)
+ })
+
+ it('counts a shared death once and caps consecutive worker deaths in one queued burst', async () => {
+ const { client, workers } = fixture()
+ const calls = [
+ client.scan(body('claude')),
+ client.scan(body('codex')),
+ client.scan(body('opencode')),
+ client.scan(body('muse')),
+ client.scan(body('claude')),
+ client.scan(body('claude')),
+ client.scan(body('claude'))
+ ].map((promise) => promise.catch((error: unknown) => error))
+ workers[0].emit('error', new Error('fault one'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(workers).toHaveLength(2)
+ workers[1].emit('error', new Error('fault two'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(workers).toHaveLength(3)
+ workers[2].emit('error', new Error('fault three'))
+ const results = await Promise.all(calls)
+ expect(results).toEqual([
+ new Error('fault one'),
+ new Error('fault one'),
+ new Error('fault one'),
+ new Error('fault one'),
+ new Error('fault two'),
+ new Error('fault three'),
+ new Error('Usage scan worker crashed repeatedly (fault three)')
+ ])
+ expect(workers).toHaveLength(3)
+ })
+
+ it('keeps a request-level error local while the other provider completes', async () => {
+ const { client, workers } = fixture()
+ const claude = client.scan(body('claude')).catch((error: unknown) => error)
+ const codex = client.scan(body('codex'))
+ workers[0].emit('message', { id: workers[0].posted[0].id, ok: false, error: 'scan failed' })
+ expect(await claude).toEqual(new Error('scan failed'))
+ expect(workers[0].terminate).not.toHaveBeenCalled()
+ workers[0].complete(1)
+ expect(await codex).toEqual({
+ operation: 'scan',
+ providerId: 'codex',
+ sessions: [],
+ dailyAggregates: []
+ })
+ })
+
+ it('cancels a parked wake when disposed and cannot spawn after the exact exit', async () => {
+ const { client, workers } = fixture()
+ const active = client.scan(body('claude')).catch((error: unknown) => error)
+ const pending = client.scan(body('claude')).catch((error: unknown) => error)
+ let proveExit!: (code: number) => void
+ workers[0].exit = new Promise((resolve) => {
+ proveExit = resolve
+ })
+ workers[0].emit('error', new Error('worker crashed'))
+ const disposed = client.dispose()
+ expect(await active).toEqual(new Error('worker crashed'))
+ expect(await pending).toEqual(new Error('Usage scan scheduler disposed'))
+ proveExit(1)
+ await disposed
+ await vi.advanceTimersByTimeAsync(0)
+ expect(workers).toHaveLength(1)
+ })
+
+ it('keeps failed termination fenced until actual exit and rejects strict disposal honestly', async () => {
+ const { client, workers } = fixture()
+ const first = client.scan(body('claude')).catch((error: unknown) => error)
+ const pending = client.scan(body('claude')).catch((error: unknown) => error)
+ const failure = new Error('termination failed')
+ workers[0].exit = Promise.reject(failure)
+ workers[0].emit('error', new Error('worker crashed'))
+ expect(await first).toEqual(new Error('worker crashed'))
+ expect(await pending).toBe(failure)
+ expect(await client.scan(body('codex')).catch((error: unknown) => error)).toBe(failure)
+ expect(workers).toHaveLength(1)
+ await expect(client.dispose()).rejects.toBe(failure)
+ workers[0].emit('exit', 1)
+ await client.dispose()
+ })
+
+ it('preserves unavailable classification without falling back to caller-thread scans', async () => {
+ const client = new UsageScanWorkerClient({
+ workerFactory: () => {
+ throw new Error('cannot spawn')
+ },
+ log: () => {}
+ })
+ clients.push(client)
+ await expect(client.scan(body('claude'))).rejects.toBeInstanceOf(
+ UsageScanWorkerUnavailableError
+ )
+ })
+})
diff --git a/src/main/usage/usage-scan-request-scheduler.ts b/src/main/usage/usage-scan-request-scheduler.ts
new file mode 100644
index 00000000000..8e7eca8a02e
--- /dev/null
+++ b/src/main/usage/usage-scan-request-scheduler.ts
@@ -0,0 +1,219 @@
+import { LazyWorkerThreadHost } from '../worker-threads/lazy-worker-thread-host'
+import type { WorkerThreadFactory } from '../worker-threads/worker-thread-transport'
+import {
+ isUsageScanWorkerProgress,
+ type UsageScanWorkerMessage,
+ type UsageScanWorkerRequest,
+ type UsageScanWorkerRequestBody,
+ type UsageScanWorkerResponse
+} from './scan-worker/usage-scan-worker-protocol'
+
+type ScanCall = {
+ request: UsageScanWorkerRequest
+ timeoutMs: number
+ timer: NodeJS.Timeout | null
+ resolve: (response: UsageScanWorkerResponse) => void
+ reject: (error: Error) => void
+}
+
+/** Independent providers share a worker; each provider and cache split retain exclusive ownership. */
+export class UsageScanRequestScheduler {
+ private readonly active = new Map()
+ private pending: ScanCall[] = []
+ private nextId = 1
+ private consecutiveDeaths = 0
+ private disposed = false
+ private retirementWait: Promise | null = null
+ private readonly host: LazyWorkerThreadHost
+
+ constructor(
+ private readonly options: {
+ factory: WorkerThreadFactory
+ idleTeardownMs: number
+ maxConsecutiveDeaths: number
+ createUnavailableError: (message: string) => Error
+ onUnavailable: (error: unknown) => void
+ }
+ ) {
+ this.host = new LazyWorkerThreadHost({
+ factory: options.factory,
+ idleTeardownMs: options.idleTeardownMs,
+ onMessage: (response) => this.onMessage(response),
+ onError: (error) => this.onFault(error),
+ onExit: (code) => this.onExit(code),
+ isIdle: () => this.active.size === 0 && this.pending.length === 0,
+ onUnavailable: options.onUnavailable,
+ awaitRetirement: true,
+ strictRetirement: true
+ })
+ }
+
+ dispatch(body: UsageScanWorkerRequestBody, timeoutMs: number): Promise {
+ return new Promise((resolve, reject) => {
+ if (this.disposed) {
+ reject(new Error('Usage scan scheduler disposed'))
+ return
+ }
+ if (this.active.size === 0 && this.pending.length === 0) {
+ this.consecutiveDeaths = 0
+ }
+ this.pending.push({
+ request: { ...body, id: this.nextId++ },
+ timeoutMs,
+ timer: null,
+ resolve,
+ reject
+ })
+ this.pump()
+ })
+ }
+
+ dispose(): void {
+ this.disposed = true
+ this.host.destroy()
+ const calls = [...this.active.values(), ...this.pending]
+ this.pending = []
+ for (const call of calls) {
+ this.settle(call, () => call.reject(new Error('Usage scan scheduler disposed')))
+ }
+ }
+
+ retired(): Promise {
+ return this.host.retired()
+ }
+
+ private pump(): void {
+ if (this.disposed) {
+ return
+ }
+ if (this.pending.length === 0) {
+ if (this.active.size === 0) {
+ this.host.scheduleIdleTeardown()
+ }
+ return
+ }
+ if (this.host.isRetiring) {
+ this.waitForRetirement()
+ return
+ }
+ while (this.pending.length > 0) {
+ const index = this.nextEligible()
+ if (index < 0) {
+ return
+ }
+ const worker = this.host.ensure()
+ if (!worker) {
+ this.rejectPending(this.options.createUnavailableError('worker spawn failed'))
+ return
+ }
+ const call = this.pending.splice(index, 1)[0]
+ this.active.set(call.request.id, call)
+ this.host.clearIdleTimer()
+ this.armDeadline(call)
+ try {
+ worker.postMessage(call.request)
+ } catch (error) {
+ this.onFault(error instanceof Error ? error : new Error(String(error)))
+ return
+ }
+ }
+ }
+
+ private nextEligible(): number {
+ const requests = [...this.active.values()].map((call) => call.request)
+ if (requests.some((request) => request.operation === 'splitCacheFile')) {
+ return -1
+ }
+ for (let index = 0; index < this.pending.length; index++) {
+ const request = this.pending[index].request
+ if (request.operation === 'splitCacheFile') {
+ return index === 0 && requests.length === 0 ? index : -1
+ }
+ if (!requests.some((active) => active.providerId === request.providerId)) {
+ return index
+ }
+ }
+ return -1
+ }
+
+ private waitForRetirement(): void {
+ if (this.retirementWait) {
+ return
+ }
+ this.retirementWait = this.host.retired().then(
+ () => {
+ this.retirementWait = null
+ this.pump()
+ },
+ (error: unknown) => {
+ this.retirementWait = null
+ this.rejectPending(error instanceof Error ? error : new Error(String(error)))
+ }
+ )
+ }
+
+ private armDeadline(call: ScanCall): void {
+ if (call.timer) {
+ clearTimeout(call.timer)
+ }
+ call.timer = setTimeout(() => {
+ if (this.active.get(call.request.id) === call) {
+ this.onFault(new Error(`Usage scan worker reported no progress for ${call.timeoutMs}ms`))
+ }
+ }, call.timeoutMs)
+ call.timer.unref?.()
+ }
+
+ private onMessage(response: UsageScanWorkerMessage): void {
+ const call = this.active.get(response.id)
+ if (!call) {
+ return
+ }
+ if (isUsageScanWorkerProgress(response)) {
+ this.armDeadline(call)
+ return
+ }
+ this.consecutiveDeaths = 0
+ this.settle(call, () => call.resolve(response))
+ this.pump()
+ }
+
+ private onExit(code: number): void {
+ if (code === 0 && this.active.size === 0 && this.pending.length === 0) {
+ this.host.destroy()
+ return
+ }
+ this.onFault(new Error(`Usage scan worker exited with code ${code}`))
+ }
+
+ private onFault(error: Error): void {
+ const posted = [...this.active.values()]
+ this.host.destroy()
+ for (const call of posted) {
+ this.settle(call, () => call.reject(error))
+ }
+ if (posted.length > 0 && ++this.consecutiveDeaths >= this.options.maxConsecutiveDeaths) {
+ this.consecutiveDeaths = 0
+ this.rejectPending(new Error(`Usage scan worker crashed repeatedly (${error.message})`))
+ return
+ }
+ this.pump()
+ }
+
+ private rejectPending(error: Error): void {
+ const pending = this.pending
+ this.pending = []
+ for (const call of pending) {
+ this.settle(call, () => call.reject(error))
+ }
+ }
+
+ private settle(call: ScanCall, finish: () => void): void {
+ if (call.timer) {
+ clearTimeout(call.timer)
+ call.timer = null
+ }
+ this.active.delete(call.request.id)
+ finish()
+ }
+}
diff --git a/src/main/usage/usage-store-host-test-fixture.ts b/src/main/usage/usage-store-host-test-fixture.ts
new file mode 100644
index 00000000000..e844627e12f
--- /dev/null
+++ b/src/main/usage/usage-store-host-test-fixture.ts
@@ -0,0 +1,21 @@
+import type { UsageStoreHost, UsageWorktreeCatalog } from './usage-store-host'
+
+export function createTestUsageStoreHost(options: {
+ userDataPath: string
+ catalog?: UsageWorktreeCatalog
+ profileDirs?: string[]
+ telemetryEnabled?: boolean
+ capture?: UsageStoreHost['telemetry']['capture']
+ signal?: AbortSignal
+}): UsageStoreHost {
+ return {
+ userDataPath: options.userDataPath,
+ signal: options.signal ?? new AbortController().signal,
+ readCatalog: async () => options.catalog ?? { repoIds: [], worktreesByRepo: [] },
+ claudeProfileDirs: async () => options.profileDirs ?? [],
+ telemetry: {
+ isEnabled: async () => options.telemetryEnabled ?? false,
+ capture: options.capture ?? (async () => true)
+ }
+ }
+}
diff --git a/src/main/usage/usage-store-host.ts b/src/main/usage/usage-store-host.ts
new file mode 100644
index 00000000000..b717713fa1d
--- /dev/null
+++ b/src/main/usage/usage-store-host.ts
@@ -0,0 +1,20 @@
+import type { AgentTokenUsage } from '../../shared/telemetry-agent-token-usage-schema'
+import type { UsageWorktreeRef } from './worktree-attribution/usage-worktree-metadata'
+
+export type UsageWorktreeCatalog = {
+ repoIds: string[]
+ worktreesByRepo: [string, UsageWorktreeRef[]][]
+}
+
+export type UsageTelemetryHost = {
+ isEnabled(): Promise
+ capture(snapshot: AgentTokenUsage): Promise
+}
+
+export type UsageStoreHost = {
+ signal: AbortSignal
+ userDataPath: string
+ readCatalog(): Promise
+ claudeProfileDirs(): Promise
+ telemetry: UsageTelemetryHost
+}
diff --git a/src/main/usage/worktree-attribution/usage-worktree-refs.ts b/src/main/usage/worktree-attribution/usage-worktree-refs.ts
index 0afe3afb5bf..cf7548cc656 100644
--- a/src/main/usage/worktree-attribution/usage-worktree-refs.ts
+++ b/src/main/usage/worktree-attribution/usage-worktree-refs.ts
@@ -21,7 +21,7 @@ export function getUsageWorktreeFingerprint(
}
export function createWorktreeRefs(
- repos: Repo[],
+ repos: readonly Pick[],
worktreesByRepo: Map
): UsageScanWorktreeRef[] {
const refs: UsageScanWorktreeRef[] = []
diff --git a/src/main/worker-threads/lazy-worker-thread-host.ts b/src/main/worker-threads/lazy-worker-thread-host.ts
index 1f41cf8d4a0..779d14c3acc 100644
--- a/src/main/worker-threads/lazy-worker-thread-host.ts
+++ b/src/main/worker-threads/lazy-worker-thread-host.ts
@@ -6,7 +6,7 @@ export class LazyWorkerThreadHost {
private idleTimer: NodeJS.Timeout | null = null
private cleanupListeners: (() => void) | null = null
private reportedUnavailable = false
- private retiring: Promise | null = null
+ private retiring: { completion: Promise; failure?: unknown } | null = null
constructor(
private readonly options: {
@@ -21,6 +21,8 @@ export class LazyWorkerThreadHost {
onUnavailable: (error: unknown) => void
/** Native calls can delay termination; wait for exit before spawning another worker (#24572). */
awaitRetirement?: boolean
+ /** Usage ownership fences require exact exit proof after termination fails. */
+ strictRetirement?: boolean
}
) {}
@@ -76,17 +78,42 @@ export class LazyWorkerThreadHost {
this.cleanupListeners?.()
this.cleanupListeners = null
worker.removeAllListeners()
- // A rejected terminate still ends retirement, so it cannot latch spawning off.
- const exited = worker.terminate().catch(() => undefined)
- if (this.options.awaitRetirement) {
- this.retiring = exited.then(() => {
- this.retiring = null
- })
+ if (!this.options.awaitRetirement) {
+ void worker.terminate().catch(() => {})
+ return
}
+ const retirement: { completion: Promise; failure?: unknown } = {
+ completion: Promise.resolve()
+ }
+ this.retiring = retirement
+ let proveExit!: () => void
+ const exitProof = new Promise((resolve) => {
+ proveExit = resolve
+ })
+ const onExit = (): void => {
+ worker.off('exit', onExit)
+ if (this.retiring === retirement) {
+ this.retiring = null
+ }
+ proveExit()
+ }
+ worker.on('exit', onExit)
+ const terminated = worker.terminate().then(onExit, (error: unknown) => {
+ if (this.options.strictRetirement) {
+ retirement.failure = error
+ } else {
+ onExit()
+ }
+ })
+ retirement.completion = Promise.race([exitProof, terminated])
}
- retired(): Promise {
- return this.retiring ?? Promise.resolve()
+ async retired(): Promise {
+ const retirement = this.retiring
+ await retirement?.completion
+ if (this.retiring === retirement && retirement?.failure !== undefined) {
+ throw retirement.failure
+ }
}
scheduleIdleTeardown(): void {
diff --git a/src/main/worker-threads/worker-thread-request-queue.test.ts b/src/main/worker-threads/worker-thread-request-queue.test.ts
index efc44b7dc1f..28a07dff34a 100644
--- a/src/main/worker-threads/worker-thread-request-queue.test.ts
+++ b/src/main/worker-threads/worker-thread-request-queue.test.ts
@@ -63,7 +63,10 @@ const MAX_CONSECUTIVE_DEATHS = 3
function makeQueue(
workers: FakeWorker[],
- options: { awaitRetirement?: boolean; makeWorker?: () => FakeWorker } = {}
+ options: {
+ awaitRetirement?: boolean
+ makeWorker?: () => FakeWorker
+ } = {}
): WorkerThreadRequestQueue {
return new WorkerThreadRequestQueue({
awaitRetirement: options.awaitRetirement,
@@ -434,6 +437,7 @@ describe('WorkerThreadRequestQueue', () => {
await vi.advanceTimersByTimeAsync(TIMEOUT_MS)
expect(await first).toMatchObject({ message: `timed out after ${TIMEOUT_MS}ms` })
+ await expect(queue.retired()).resolves.toBeUndefined()
const next = send(queue, 'next')
expect(workers).toHaveLength(2)
workers[1].respond()
diff --git a/src/main/worker-threads/worker-thread-request-queue.ts b/src/main/worker-threads/worker-thread-request-queue.ts
index 702e88a3a82..bcc917ac0d6 100644
--- a/src/main/worker-threads/worker-thread-request-queue.ts
+++ b/src/main/worker-threads/worker-thread-request-queue.ts
@@ -129,8 +129,12 @@ export class WorkerThreadRequestQueue<
}
}
+ retired(): Promise {
+ return this.host.retired()
+ }
+
private pump(): void {
- if (this.active || this.queue.length === 0) {
+ if (this.disposed || this.active || this.queue.length === 0) {
return
}
// A shared signal is already aborted before its remaining listeners run.
diff --git a/src/preload/api/agent-usage-api.ts b/src/preload/api/agent-usage-api.ts
index 2d11a83590a..8696c17885e 100644
--- a/src/preload/api/agent-usage-api.ts
+++ b/src/preload/api/agent-usage-api.ts
@@ -1,3 +1,7 @@
+import type {
+ BackendConnectionListener,
+ BackendConnectionPurpose
+} from '../../shared/backend-service-contract'
import type { ClaudeUsageBreakdownKind, ClaudeUsageSnapshot } from '../../shared/claude-usage-types'
import type { CodexUsageBreakdownKind, CodexUsageSnapshot } from '../../shared/codex-usage-types'
import type {
@@ -25,10 +29,14 @@ export type UsageQueryArgs = Pick<
>
export type UsageProviderApi = {
- getScanState: () => Promise
+ onConnectionChanged?: (listener: BackendConnectionListener) => () => void
+ getScanState: (purpose?: BackendConnectionPurpose) => Promise
setEnabled: (args: { enabled: boolean }) => Promise
refresh: (args?: { force?: boolean }) => Promise
- getSnapshot: (args: UsageQueryArgs & { limit?: number }) => Promise
+ getSnapshot: (
+ args: UsageQueryArgs & { limit?: number },
+ purpose?: BackendConnectionPurpose
+ ) => Promise
getSummary: (args: UsageQueryArgs) => Promise
getDaily: (args: UsageQueryArgs) => Promise
getBreakdown: (
diff --git a/src/preload/api/claude-usage-bridge.ts b/src/preload/api/claude-usage-bridge.ts
index 1b98202d88c..aee5e62425e 100644
--- a/src/preload/api/claude-usage-bridge.ts
+++ b/src/preload/api/claude-usage-bridge.ts
@@ -1,8 +1,8 @@
-import { ipcRenderer } from 'electron'
+import { backendUsageTransport } from '../backend-port-client'
import { createUsageProviderApi } from '../usage-provider-api'
import type { PreloadApi } from '../api-types'
export const claudeUsageApi = createUsageProviderApi(
- ipcRenderer,
+ backendUsageTransport,
'claudeUsage'
) satisfies PreloadApi['claudeUsage']
diff --git a/src/preload/api/codex-usage-bridge.ts b/src/preload/api/codex-usage-bridge.ts
index 2575f2bb0e9..0705d66ccb5 100644
--- a/src/preload/api/codex-usage-bridge.ts
+++ b/src/preload/api/codex-usage-bridge.ts
@@ -1,8 +1,8 @@
-import { ipcRenderer } from 'electron'
+import { backendUsageTransport } from '../backend-port-client'
import { createUsageProviderApi } from '../usage-provider-api'
import type { PreloadApi } from '../api-types'
export const codexUsageApi = createUsageProviderApi(
- ipcRenderer,
+ backendUsageTransport,
'codexUsage'
) satisfies PreloadApi['codexUsage']
diff --git a/src/preload/api/muse-usage-bridge.ts b/src/preload/api/muse-usage-bridge.ts
index 4c765eccff0..a741ddac1cb 100644
--- a/src/preload/api/muse-usage-bridge.ts
+++ b/src/preload/api/muse-usage-bridge.ts
@@ -1,8 +1,8 @@
-import { ipcRenderer } from 'electron'
+import { backendUsageTransport } from '../backend-port-client'
import { createUsageProviderApi } from '../usage-provider-api'
import type { PreloadApi } from '../api-types'
export const museUsageApi = createUsageProviderApi(
- ipcRenderer,
+ backendUsageTransport,
'museUsage'
) satisfies PreloadApi['museUsage']
diff --git a/src/preload/api/open-code-usage-bridge.ts b/src/preload/api/open-code-usage-bridge.ts
index cd3564d2b32..8b583be211d 100644
--- a/src/preload/api/open-code-usage-bridge.ts
+++ b/src/preload/api/open-code-usage-bridge.ts
@@ -1,8 +1,8 @@
-import { ipcRenderer } from 'electron'
+import { backendUsageTransport } from '../backend-port-client'
import { createUsageProviderApi } from '../usage-provider-api'
import type { PreloadApi } from '../api-types'
export const openCodeUsageApi = createUsageProviderApi(
- ipcRenderer,
+ backendUsageTransport,
'openCodeUsage'
) satisfies PreloadApi['openCodeUsage']
diff --git a/src/preload/backend-port-client.ts b/src/preload/backend-port-client.ts
new file mode 100644
index 00000000000..dae6df4e82b
--- /dev/null
+++ b/src/preload/backend-port-client.ts
@@ -0,0 +1,151 @@
+import { ipcRenderer } from 'electron'
+import { BackendPortRpc, type BackendPort } from '../shared/backend-port-rpc'
+import {
+ BACKEND_CONNECTION_CHANGED,
+ BACKEND_PORT_DELIVERY,
+ BACKEND_PORT_REQUEST,
+ type BackendConnectionPurpose,
+ type BackendConnectionStatus,
+ type BackendConnectionListener
+} from '../shared/backend-service-contract'
+
+let connection: BackendPortRpc | undefined
+let connecting: Promise | undefined
+type PortAttempt = {
+ token: string
+ purpose: BackendConnectionPurpose
+ resolve(client: BackendPortRpc): void
+ reject(error: Error): void
+}
+let pendingPort: PortAttempt | undefined
+const connectionListeners = new Set()
+let connected: boolean | undefined
+let unavailable = false
+
+function publishConnection(next: boolean): void {
+ if (next) {
+ unavailable = false
+ }
+ if (connected === next) {
+ return
+ }
+ connected = next
+ if (!next) {
+ const previous = connection
+ connection = undefined
+ connecting = undefined
+ const attempt = pendingPort
+ pendingPort = undefined
+ previous?.close()
+ attempt?.reject(new Error('Usage disconnected. Try again after it reconnects.'))
+ }
+ for (const listener of connectionListeners) {
+ listener(next)
+ }
+}
+
+ipcRenderer.on(BACKEND_PORT_DELIVERY, (event, data: { token?: string }) => {
+ const [port, ...surplus] = event.ports
+ for (const extra of surplus) {
+ extra.close()
+ }
+ if (!port || !pendingPort || pendingPort.token !== data?.token) {
+ port?.close()
+ return
+ }
+ const adapter: BackendPort = {
+ postMessage: (message) => port.postMessage(message),
+ onMessage(listener) {
+ const receive = (event: MessageEvent): void => listener(event.data)
+ port.addEventListener('message', receive)
+ port.start()
+ return () => port.removeEventListener('message', receive)
+ },
+ onClose(listener) {
+ port.addEventListener('close', listener)
+ return () => port.removeEventListener('close', listener)
+ },
+ close: () => port.close()
+ }
+ const previous = connection
+ connection = undefined
+ previous?.close()
+ const client = new BackendPortRpc(
+ adapter,
+ 'renderer',
+ async () => {
+ throw new Error('Renderer capabilities are not available on this port.')
+ },
+ () => {
+ if (connection === client) {
+ publishConnection(false)
+ }
+ }
+ )
+ connection = client
+ const attempt = pendingPort
+ pendingPort = undefined
+ attempt.resolve(client)
+ publishConnection(true)
+})
+
+function requestPort(attempt: PortAttempt): void {
+ void ipcRenderer
+ .invoke(BACKEND_PORT_REQUEST, attempt.token, attempt.purpose)
+ .catch((error: Error) => {
+ if (pendingPort === attempt) {
+ pendingPort = undefined
+ attempt.reject(error)
+ }
+ })
+}
+
+function connect(purpose: BackendConnectionPurpose = 'demand'): Promise {
+ if (connection) {
+ return Promise.resolve(connection)
+ }
+ if (connecting) {
+ if (purpose === 'demand' && pendingPort?.purpose === 'recovery') {
+ pendingPort = { ...pendingPort, token: crypto.randomUUID(), purpose }
+ requestPort(pendingPort)
+ }
+ return connecting
+ }
+ const attemptPromise = new Promise((resolve, reject) => {
+ const attempt = { token: crypto.randomUUID(), purpose, resolve, reject }
+ pendingPort = attempt
+ requestPort(attempt)
+ })
+ const result = attemptPromise.finally(() => {
+ if (connecting === result) {
+ connecting = undefined
+ }
+ })
+ connecting = result
+ return result
+}
+
+ipcRenderer.on(BACKEND_CONNECTION_CHANGED, (_event, state: BackendConnectionStatus) => {
+ publishConnection(state.connected)
+ if (!state.connected && state.unavailable && !unavailable) {
+ unavailable = true
+ for (const listener of connectionListeners) {
+ listener(false, { unavailable: true })
+ }
+ }
+})
+
+export const backendUsageTransport = {
+ onConnectionChanged(listener: BackendConnectionListener): () => void {
+ connectionListeners.add(listener)
+ listener(connected ?? false, { initial: true, unavailable })
+ return () => {
+ connectionListeners.delete(listener)
+ }
+ },
+ invoke: async (
+ method: string,
+ params?: unknown,
+ purpose?: BackendConnectionPurpose
+ ): Promise => (await connect(purpose)).call(method, params)
+}
diff --git a/src/preload/usage-provider-api.ts b/src/preload/usage-provider-api.ts
index 4a57c1bc061..3b9723a6aa0 100644
--- a/src/preload/usage-provider-api.ts
+++ b/src/preload/usage-provider-api.ts
@@ -1,4 +1,7 @@
-import type { IpcRenderer } from 'electron'
+import type {
+ BackendConnectionListener,
+ BackendConnectionPurpose
+} from '../shared/backend-service-contract'
import type { PreloadApi } from './api-types'
type UsageProviderApiKey = 'claudeUsage' | 'codexUsage' | 'openCodeUsage' | 'museUsage'
@@ -6,24 +9,37 @@ type UsageProviderApi = PreloadApi[UsageProviderApiKey]
type UsageRangeArgs = { scope: string; range: string }
export function createUsageProviderApi(
- ipc: Pick,
+ ipc: {
+ invoke(channel: string, args?: unknown, purpose?: BackendConnectionPurpose): Promise
+ onConnectionChanged?: (listener: BackendConnectionListener) => () => void
+ },
prefix: Key
): PreloadApi[Key]
export function createUsageProviderApi(
- ipc: Pick,
+ ipc: {
+ invoke(channel: string, args?: unknown, purpose?: BackendConnectionPurpose): Promise
+ onConnectionChanged?: (listener: BackendConnectionListener) => () => void
+ },
prefix: UsageProviderApiKey
): UsageProviderApi {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The prefix selects its typed service methods; only the transport boundary erases their return types.
return {
- getScanState: () => ipc.invoke(`${prefix}:getScanState`),
+ ...(ipc.onConnectionChanged ? { onConnectionChanged: ipc.onConnectionChanged } : {}),
+ getScanState: (purpose?: BackendConnectionPurpose) =>
+ purpose
+ ? ipc.invoke(`${prefix}:getScanState`, undefined, purpose)
+ : ipc.invoke(`${prefix}:getScanState`),
setEnabled: (args: { enabled: boolean }) => ipc.invoke(`${prefix}:setEnabled`, args),
refresh: (args?: { force?: boolean }) => ipc.invoke(`${prefix}:refresh`, args),
- getSnapshot: (args: UsageRangeArgs & { limit?: number }) =>
- ipc.invoke(`${prefix}:getSnapshot`, args),
+ getSnapshot: (args: UsageRangeArgs & { limit?: number }, purpose?: BackendConnectionPurpose) =>
+ purpose
+ ? ipc.invoke(`${prefix}:getSnapshot`, args, purpose)
+ : ipc.invoke(`${prefix}:getSnapshot`, args),
getSummary: (args: UsageRangeArgs) => ipc.invoke(`${prefix}:getSummary`, args),
getDaily: (args: UsageRangeArgs) => ipc.invoke(`${prefix}:getDaily`, args),
getBreakdown: (args: UsageRangeArgs & { kind: string }) =>
ipc.invoke(`${prefix}:getBreakdown`, args),
getRecentSessions: (args: UsageRangeArgs & { limit?: number }) =>
ipc.invoke(`${prefix}:getRecentSessions`, args)
- }
+ } as UsageProviderApi
}
diff --git a/src/renderer/src/components/stats/ClaudeUsagePane.tsx b/src/renderer/src/components/stats/ClaudeUsagePane.tsx
index 1cbee7e6717..346e257d0ca 100644
--- a/src/renderer/src/components/stats/ClaudeUsagePane.tsx
+++ b/src/renderer/src/components/stats/ClaudeUsagePane.tsx
@@ -1,4 +1,5 @@
-import { useEffect } from 'react'
+import { usageFailureMessage } from './usage-failure-message'
+import { useUsageObservation } from '../../store/slices/usage/use-usage-observation'
import {
Activity,
Coins,
@@ -54,6 +55,8 @@ export function ClaudeUsagePane(): React.JSX.Element {
const t = useTranslate()
const intlLocale = useIntlLocale()
const scanState = useAppStore((state) => state.claudeUsageScanState)
+ const failure = useAppStore((state) => state.claudeUsageFailure)
+ const failureMessage = usageFailureMessage(failure, t)
const summary = useAppStore((state) => state.claudeUsageSummary)
const daily = useAppStore((state) => state.claudeUsageDaily)
const modelBreakdown = useAppStore((state) => state.claudeUsageModelBreakdown)
@@ -61,16 +64,14 @@ export function ClaudeUsagePane(): React.JSX.Element {
const recentSessions = useAppStore((state) => state.claudeUsageRecentSessions)
const scope = useAppStore((state) => state.claudeUsageScope)
const range = useAppStore((state) => state.claudeUsageRange)
- const fetchClaudeUsage = useAppStore((state) => state.fetchClaudeUsage)
+ const observeClaudeUsage = useAppStore((state) => state.observeClaudeUsage)
const setClaudeUsageEnabled = useAppStore((state) => state.setClaudeUsageEnabled)
const refreshClaudeUsage = useAppStore((state) => state.refreshClaudeUsage)
const setClaudeUsageScope = useAppStore((state) => state.setClaudeUsageScope)
const setClaudeUsageRange = useAppStore((state) => state.setClaudeUsageRange)
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
- useEffect(() => {
- void fetchClaudeUsage()
- }, [fetchClaudeUsage])
+ useUsageObservation(observeClaudeUsage)
const handleSetEnabled = (enabled: boolean): void => {
recordFeatureInteraction('usage-tracking')
@@ -87,6 +88,9 @@ export function ClaudeUsagePane(): React.JSX.Element {
return (
}
@@ -107,6 +115,7 @@ export function ClaudeUsagePane(): React.JSX.Element {
return (
diff --git a/src/renderer/src/components/stats/CodexUsagePane.tsx b/src/renderer/src/components/stats/CodexUsagePane.tsx
index 12239584349..4027a1bf95c 100644
--- a/src/renderer/src/components/stats/CodexUsagePane.tsx
+++ b/src/renderer/src/components/stats/CodexUsagePane.tsx
@@ -1,4 +1,5 @@
-import { useEffect } from 'react'
+import { usageFailureMessage } from './usage-failure-message'
+import { useUsageObservation } from '../../store/slices/usage/use-usage-observation'
import { Activity, Brain, Coins, DatabaseZap, FolderKanban, Sparkles } from 'lucide-react'
import type { CodexUsageRange, CodexUsageScope } from '../../../../shared/codex-usage-types'
import { useAppStore } from '../../store'
@@ -46,6 +47,8 @@ export function CodexUsagePane(): React.JSX.Element {
const t = useTranslate()
const intlLocale = useIntlLocale()
const scanState = useAppStore((state) => state.codexUsageScanState)
+ const failure = useAppStore((state) => state.codexUsageFailure)
+ const failureMessage = usageFailureMessage(failure, t)
const summary = useAppStore((state) => state.codexUsageSummary)
const daily = useAppStore((state) => state.codexUsageDaily)
const modelBreakdown = useAppStore((state) => state.codexUsageModelBreakdown)
@@ -53,16 +56,14 @@ export function CodexUsagePane(): React.JSX.Element {
const recentSessions = useAppStore((state) => state.codexUsageRecentSessions)
const scope = useAppStore((state) => state.codexUsageScope)
const range = useAppStore((state) => state.codexUsageRange)
- const fetchCodexUsage = useAppStore((state) => state.fetchCodexUsage)
+ const observeCodexUsage = useAppStore((state) => state.observeCodexUsage)
const setCodexUsageEnabled = useAppStore((state) => state.setCodexUsageEnabled)
const refreshCodexUsage = useAppStore((state) => state.refreshCodexUsage)
const setCodexUsageScope = useAppStore((state) => state.setCodexUsageScope)
const setCodexUsageRange = useAppStore((state) => state.setCodexUsageRange)
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
- useEffect(() => {
- void fetchCodexUsage()
- }, [fetchCodexUsage])
+ useUsageObservation(observeCodexUsage)
const handleSetEnabled = (enabled: boolean): void => {
recordFeatureInteraction('usage-tracking')
@@ -79,6 +80,9 @@ export function CodexUsagePane(): React.JSX.Element {
return (
diff --git a/src/renderer/src/components/stats/MuseUsagePane.tsx b/src/renderer/src/components/stats/MuseUsagePane.tsx
index 650bcb1c086..27f78a01a34 100644
--- a/src/renderer/src/components/stats/MuseUsagePane.tsx
+++ b/src/renderer/src/components/stats/MuseUsagePane.tsx
@@ -1,4 +1,5 @@
-import { useEffect } from 'react'
+import { usageFailureMessage } from './usage-failure-message'
+import { useUsageObservation } from '../../store/slices/usage/use-usage-observation'
import { Activity, Brain, DatabaseZap, FolderKanban, Sigma, Sparkles } from 'lucide-react'
import type { MuseUsageRange, MuseUsageScope } from '../../../../shared/muse-usage-types'
import { useAppStore } from '../../store'
@@ -45,6 +46,8 @@ export function MuseUsagePane(): React.JSX.Element {
const t = useTranslate()
const intlLocale = useIntlLocale()
const scanState = useAppStore((state) => state.museUsageScanState)
+ const failure = useAppStore((state) => state.museUsageFailure)
+ const failureMessage = usageFailureMessage(failure, t)
const summary = useAppStore((state) => state.museUsageSummary)
const daily = useAppStore((state) => state.museUsageDaily)
const modelBreakdown = useAppStore((state) => state.museUsageModelBreakdown)
@@ -52,16 +55,14 @@ export function MuseUsagePane(): React.JSX.Element {
const recentSessions = useAppStore((state) => state.museUsageRecentSessions)
const scope = useAppStore((state) => state.museUsageScope)
const range = useAppStore((state) => state.museUsageRange)
- const fetchMuseUsage = useAppStore((state) => state.fetchMuseUsage)
+ const observeMuseUsage = useAppStore((state) => state.observeMuseUsage)
const setMuseUsageEnabled = useAppStore((state) => state.setMuseUsageEnabled)
const refreshMuseUsage = useAppStore((state) => state.refreshMuseUsage)
const setMuseUsageScope = useAppStore((state) => state.setMuseUsageScope)
const setMuseUsageRange = useAppStore((state) => state.setMuseUsageRange)
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
- useEffect(() => {
- void fetchMuseUsage()
- }, [fetchMuseUsage])
+ useUsageObservation(observeMuseUsage)
const handleSetEnabled = (enabled: boolean): void => {
recordFeatureInteraction('usage-tracking')
@@ -78,6 +79,9 @@ export function MuseUsagePane(): React.JSX.Element {
return (
diff --git a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx
index f9d8efa719b..755f9c62308 100644
--- a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx
+++ b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx
@@ -1,4 +1,5 @@
-import { useEffect } from 'react'
+import { usageFailureMessage } from './usage-failure-message'
+import { useUsageObservation } from '../../store/slices/usage/use-usage-observation'
import { Activity, Brain, Coins, DatabaseZap, FolderKanban, Sparkles } from 'lucide-react'
import type {
OpenCodeUsageRange,
@@ -50,6 +51,8 @@ export function OpenCodeUsagePane(): React.JSX.Element {
const t = useTranslate()
const intlLocale = useIntlLocale()
const scanState = useAppStore((state) => state.openCodeUsageScanState)
+ const failure = useAppStore((state) => state.openCodeUsageFailure)
+ const failureMessage = usageFailureMessage(failure, t)
const summary = useAppStore((state) => state.openCodeUsageSummary)
const daily = useAppStore((state) => state.openCodeUsageDaily)
const modelBreakdown = useAppStore((state) => state.openCodeUsageModelBreakdown)
@@ -57,16 +60,14 @@ export function OpenCodeUsagePane(): React.JSX.Element {
const recentSessions = useAppStore((state) => state.openCodeUsageRecentSessions)
const scope = useAppStore((state) => state.openCodeUsageScope)
const range = useAppStore((state) => state.openCodeUsageRange)
- const fetchOpenCodeUsage = useAppStore((state) => state.fetchOpenCodeUsage)
+ const observeOpenCodeUsage = useAppStore((state) => state.observeOpenCodeUsage)
const setOpenCodeUsageEnabled = useAppStore((state) => state.setOpenCodeUsageEnabled)
const refreshOpenCodeUsage = useAppStore((state) => state.refreshOpenCodeUsage)
const setOpenCodeUsageScope = useAppStore((state) => state.setOpenCodeUsageScope)
const setOpenCodeUsageRange = useAppStore((state) => state.setOpenCodeUsageRange)
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
- useEffect(() => {
- void fetchOpenCodeUsage()
- }, [fetchOpenCodeUsage])
+ useUsageObservation(observeOpenCodeUsage)
const handleSetEnabled = (enabled: boolean): void => {
recordFeatureInteraction('usage-tracking')
@@ -83,6 +84,9 @@ export function OpenCodeUsagePane(): React.JSX.Element {
return (
diff --git a/src/renderer/src/components/stats/UsageOverviewPane.tsx b/src/renderer/src/components/stats/UsageOverviewPane.tsx
index dcab63dc721..09e7a19e82d 100644
--- a/src/renderer/src/components/stats/UsageOverviewPane.tsx
+++ b/src/renderer/src/components/stats/UsageOverviewPane.tsx
@@ -1,4 +1,6 @@
-import { useEffect, useMemo } from 'react'
+import { usageFailureMessage } from './usage-failure-message'
+import { useUsageObservation } from '../../store/slices/usage/use-usage-observation'
+import { useMemo } from 'react'
import { Activity, CalendarDays, Coins, DatabaseZap, RefreshCw, Sparkles } from 'lucide-react'
import { useAppStore } from '../../store'
import { Badge } from '../ui/badge'
@@ -36,22 +38,26 @@ function formatUpdatedAt(
export function UsageOverviewPane(): React.JSX.Element {
const t = useTranslate()
const intlLocale = useIntlLocale()
+ const claudeFailure = useAppStore((state) => state.claudeUsageFailure)
const claudeScanState = useAppStore((state) => state.claudeUsageScanState)
const claudeSummary = useAppStore((state) => state.claudeUsageSummary)
const claudeDaily = useAppStore((state) => state.claudeUsageDaily)
+ const codexFailure = useAppStore((state) => state.codexUsageFailure)
const codexScanState = useAppStore((state) => state.codexUsageScanState)
const codexSummary = useAppStore((state) => state.codexUsageSummary)
const codexDaily = useAppStore((state) => state.codexUsageDaily)
+ const openCodeFailure = useAppStore((state) => state.openCodeUsageFailure)
const openCodeScanState = useAppStore((state) => state.openCodeUsageScanState)
const openCodeSummary = useAppStore((state) => state.openCodeUsageSummary)
const openCodeDaily = useAppStore((state) => state.openCodeUsageDaily)
+ const museFailure = useAppStore((state) => state.museUsageFailure)
const museScanState = useAppStore((state) => state.museUsageScanState)
const museSummary = useAppStore((state) => state.museUsageSummary)
const museDaily = useAppStore((state) => state.museUsageDaily)
- const fetchClaudeUsage = useAppStore((state) => state.fetchClaudeUsage)
- const fetchCodexUsage = useAppStore((state) => state.fetchCodexUsage)
- const fetchOpenCodeUsage = useAppStore((state) => state.fetchOpenCodeUsage)
- const fetchMuseUsage = useAppStore((state) => state.fetchMuseUsage)
+ const observeClaudeUsage = useAppStore((state) => state.observeClaudeUsage)
+ const observeCodexUsage = useAppStore((state) => state.observeCodexUsage)
+ const observeOpenCodeUsage = useAppStore((state) => state.observeOpenCodeUsage)
+ const observeMuseUsage = useAppStore((state) => state.observeMuseUsage)
const refreshClaudeUsage = useAppStore((state) => state.refreshClaudeUsage)
const refreshCodexUsage = useAppStore((state) => state.refreshCodexUsage)
const refreshOpenCodeUsage = useAppStore((state) => state.refreshOpenCodeUsage)
@@ -62,12 +68,10 @@ export function UsageOverviewPane(): React.JSX.Element {
const enableMuseUsage = useAppStore((state) => state.enableMuseUsage)
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
- useEffect(() => {
- void fetchClaudeUsage()
- void fetchCodexUsage()
- void fetchOpenCodeUsage()
- void fetchMuseUsage()
- }, [fetchClaudeUsage, fetchCodexUsage, fetchOpenCodeUsage, fetchMuseUsage])
+ useUsageObservation(observeClaudeUsage)
+ useUsageObservation(observeCodexUsage)
+ useUsageObservation(observeOpenCodeUsage)
+ useUsageObservation(observeMuseUsage)
const overview = useMemo(
() =>
@@ -114,12 +118,44 @@ export function UsageOverviewPane(): React.JSX.Element {
)
const isScanning = overview.providers.some((provider) => provider.isScanning)
+ const failures = [
+ { name: 'Claude', message: usageFailureMessage(claudeFailure, t) },
+ { name: 'Codex', message: usageFailureMessage(codexFailure, t) },
+ { name: 'OpenCode', message: usageFailureMessage(openCodeFailure, t) },
+ { name: 'Muse', message: usageFailureMessage(museFailure, t) }
+ ]
+ const failureGroups = new Map()
+ for (const { name, message } of failures) {
+ if (message) {
+ failureGroups.set(message, [...(failureGroups.get(message) ?? []), name])
+ }
+ }
+ const knownProviders = {
+ claude: claudeScanState,
+ codex: codexScanState,
+ opencode: openCodeScanState,
+ muse: museScanState
+ }
+ const allProvidersKnown = Object.values(knownProviders).every(Boolean)
+ const canRefresh =
+ overview.hasAnyEnabledProvider ||
+ failureGroups.size > 0 ||
+ Object.values(knownProviders).some((state) => !state)
+
const handleRefresh = (): void => {
void Promise.all([
- claudeScanState?.enabled ? refreshClaudeUsage() : Promise.resolve(),
- codexScanState?.enabled ? refreshCodexUsage() : Promise.resolve(),
- openCodeScanState?.enabled ? refreshOpenCodeUsage() : Promise.resolve(),
- museScanState?.enabled ? refreshMuseUsage() : Promise.resolve()
+ claudeScanState?.enabled || claudeFailure || !claudeScanState
+ ? refreshClaudeUsage()
+ : Promise.resolve(),
+ codexScanState?.enabled || codexFailure || !codexScanState
+ ? refreshCodexUsage()
+ : Promise.resolve(),
+ openCodeScanState?.enabled || openCodeFailure || !openCodeScanState
+ ? refreshOpenCodeUsage()
+ : Promise.resolve(),
+ museScanState?.enabled || museFailure || !museScanState
+ ? refreshMuseUsage()
+ : Promise.resolve()
])
}
@@ -140,6 +176,11 @@ export function UsageOverviewPane(): React.JSX.Element {
)
: ''}
+ {[...failureGroups].map(([message, names]) => (
+
+ {names.join(', ')}: {message}
+
+ ))}
@@ -147,7 +188,7 @@ export function UsageOverviewPane(): React.JSX.Element {
variant="ghost"
size="icon-xs"
onClick={handleRefresh}
- disabled={!overview.hasAnyEnabledProvider || isScanning}
+ disabled={!canRefresh || isScanning}
aria-label={t(
'auto.components.stats.UsageOverviewPane.e06d1baf5c',
'Refresh usage overview'
@@ -162,7 +203,7 @@ export function UsageOverviewPane(): React.JSX.Element {
- {!overview.hasAnyEnabledProvider ? (
+ {allProvidersKnown && !overview.hasAnyEnabledProvider ? (
@@ -183,6 +224,7 @@ export function UsageOverviewPane(): React.JSX.Element {
recordFeatureInteraction('usage-tracking')
void enableClaudeUsage()
}}
+ disabled={!claudeScanState}
>
{t('auto.components.stats.UsageOverviewPane.0ea0cae435', 'Enable Claude')}
@@ -193,6 +235,7 @@ export function UsageOverviewPane(): React.JSX.Element {
recordFeatureInteraction('usage-tracking')
void enableCodexUsage()
}}
+ disabled={!codexScanState}
>
{t('auto.components.stats.UsageOverviewPane.2f1ee2878b', 'Enable Codex')}
@@ -203,6 +246,7 @@ export function UsageOverviewPane(): React.JSX.Element {
recordFeatureInteraction('usage-tracking')
void enableOpenCodeUsage()
}}
+ disabled={!openCodeScanState}
>
{t('auto.components.stats.UsageOverviewPane.2d13e57f72', 'Enable OpenCode')}
@@ -213,13 +257,14 @@ export function UsageOverviewPane(): React.JSX.Element {
recordFeatureInteraction('usage-tracking')
void enableMuseUsage()
}}
+ disabled={!museScanState}
>
{t('auto.components.stats.UsageOverviewPane.enableMuse', 'Enable Muse')}
- ) : (
+ ) : overview.hasAnyEnabledProvider ? (
<>
- {!overview.hasAnyData ? (
+ {!overview.hasAnyData && allProvidersKnown ? (
{t(
'auto.components.stats.UsageOverviewPane.noLocalUsageYet',
'No local Claude, Codex, OpenCode, or Muse usage found yet. The overview will populate after the next agent session writes token logs.'
)}
- ) : (
+ ) : overview.hasAnyData ? (
- )}
+ ) : null}
>
- )}
+ ) : null}
@@ -268,11 +313,13 @@ export function UsageOverviewPane(): React.JSX.Element {
{t('auto.components.stats.UsageOverviewPane.33f7b043d2', 'Providers')}
- {t(
- 'auto.components.stats.UsageOverviewPane.providersEnabledWithData',
- '{{enabled}} enabled - {{withData}} with data',
- { enabled: overview.enabledProviderCount, withData: overview.dataProviderCount }
- )}
+ {allProvidersKnown
+ ? t(
+ 'auto.components.stats.UsageOverviewPane.providersEnabledWithData',
+ '{{enabled}} enabled - {{withData}} with data',
+ { enabled: overview.enabledProviderCount, withData: overview.dataProviderCount }
+ )
+ : '—'}
@@ -290,6 +337,7 @@ export function UsageOverviewPane(): React.JSX.Element {
key={provider.id}
provider={provider}
totalTokens={overview.totalTokens}
+ stateKnown={!!knownProviders[provider.id]}
onEnable={() => {
recordFeatureInteraction('usage-tracking')
if (provider.id === 'claude') {
diff --git a/src/renderer/src/components/stats/UsageTrackingPaneShell.tsx b/src/renderer/src/components/stats/UsageTrackingPaneShell.tsx
index 501c946bca3..f7f86d637e4 100644
--- a/src/renderer/src/components/stats/UsageTrackingPaneShell.tsx
+++ b/src/renderer/src/components/stats/UsageTrackingPaneShell.tsx
@@ -45,6 +45,9 @@ export function UsageFilterRadioGroup({
type UsageTrackingPaneShellProps = {
title: ReactNode
+ failure?: string | null
+ stateKnown?: boolean
+ retry?: { label: string; onRefresh: () => void | Promise }
enableLabel: string
onEnabledChange: (enabled: boolean) => void
} & (
@@ -78,9 +81,20 @@ export function UsageTrackingPaneShell(props: UsageTrackingPaneShellProps): Reac
{props.title}
{props.disabledDescription}
+ {props.failure && (
+
+ {props.failure}
+
+ )}
+ {props.failure && props.retry && (
+
void props.retry?.onRefresh()}>
+ {props.retry.label}
+
+ )}
@@ -95,6 +109,11 @@ export function UsageTrackingPaneShell(props: UsageTrackingPaneShellProps): Reac
{props.title}
{props.status}
+ {props.failure && (
+
+ {props.failure}
+
+ )}
{props.headerAction}
diff --git a/src/renderer/src/components/stats/usage-failure-message.ts b/src/renderer/src/components/stats/usage-failure-message.ts
new file mode 100644
index 00000000000..ceadfd92119
--- /dev/null
+++ b/src/renderer/src/components/stats/usage-failure-message.ts
@@ -0,0 +1,24 @@
+import type { UsageFailure } from '../../store/slices/usage/usage-provider-types'
+import type { Translate } from '@/i18n/use-translate'
+
+export function usageFailureMessage(failure: UsageFailure | null, t: Translate): string | null {
+ if (failure?.terminal) {
+ return t('usage.failure.unavailable', 'Usage is unavailable. Try Refresh to reconnect.')
+ }
+ if (failure?.setting) {
+ return t(
+ 'usage.failure.setting',
+ 'The tracking change could not be confirmed. Try changing it again.'
+ )
+ }
+ if (failure?.refresh) {
+ return t(
+ 'usage.failure.refresh',
+ 'The previous Refresh could not be completed. Try Refresh again.'
+ )
+ }
+ if (failure?.read) {
+ return t('usage.failure.read', 'Usage could not be loaded. Try Refresh again.')
+ }
+ return null
+}
diff --git a/src/renderer/src/components/stats/usage-failure-presentation.test.tsx b/src/renderer/src/components/stats/usage-failure-presentation.test.tsx
new file mode 100644
index 00000000000..4c76fcf24cb
--- /dev/null
+++ b/src/renderer/src/components/stats/usage-failure-presentation.test.tsx
@@ -0,0 +1,132 @@
+// @vitest-environment happy-dom
+import { afterEach, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { ClaudeUsagePane } from './ClaudeUsagePane'
+import { CodexUsagePane } from './CodexUsagePane'
+import { OpenCodeUsagePane } from './OpenCodeUsagePane'
+import { MuseUsagePane } from './MuseUsagePane'
+import { UsageOverviewPane } from './UsageOverviewPane'
+import type { AppState } from '../../store/types'
+import { TooltipProvider } from '../ui/tooltip'
+import { i18n } from '@/i18n/i18n'
+import es from '../../i18n/locales/es.json'
+
+// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Setup replaces this placeholder before any usage component renders.
+const fixture = vi.hoisted(() => ({ state: {} as AppState }))
+vi.mock('../../store', () => ({
+ useAppStore: (select: (state: AppState) => unknown) => select(fixture.state)
+}))
+const providers = [
+ ['claude', 'Claude', ClaudeUsagePane],
+ ['codex', 'Codex', CodexUsagePane],
+ ['openCode', 'OpenCode', OpenCodeUsagePane],
+ ['muse', 'Muse', MuseUsagePane]
+] as const
+function setup(kind: 'unknown' | 'disabled' | 'empty' | 'cached') {
+ const values: Record
= { recordFeatureInteraction: vi.fn() }
+ for (const [prefix, name] of providers) {
+ Object.assign(values, {
+ [`${prefix}UsageFailure`]: { terminal: false, read: false, refresh: true, setting: false },
+ [`${prefix}UsageScanState`]:
+ kind === 'unknown'
+ ? null
+ : {
+ enabled: kind !== 'disabled',
+ isScanning: false,
+ lastScanCompletedAt: kind === 'empty' ? null : 1,
+ lastScanError: kind === 'cached' ? 'source scan failed' : null,
+ [`hasAny${name}Data`]: false
+ },
+ [`${prefix}UsageSummary`]: null,
+ [`${prefix}UsageDaily`]: [],
+ [`${prefix}UsageScope`]: 'orca',
+ [`${prefix}UsageRange`]: '30d',
+ [`observe${name}Usage`]: () => () => {},
+ [`refresh${name}Usage`]: vi.fn(),
+ [`enable${name}Usage`]: vi.fn(),
+ [`set${name}UsageEnabled`]: vi.fn()
+ })
+ }
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only the populated usage surface selectors are exercised.
+ fixture.state = values as unknown as AppState
+}
+afterEach(async () => {
+ cleanup()
+ await i18n.changeLanguage('en')
+})
+
+it.each(providers)(
+ '%s exposes failure and Refresh in unknown, disabled, empty and cached states',
+ (_prefix, name, Pane) => {
+ for (const kind of ['unknown', 'disabled', 'empty', 'cached'] as const) {
+ setup(kind)
+ const view = render( )
+ expect(screen.getByRole('alert').textContent).toBe(
+ 'The previous Refresh could not be completed. Try Refresh again.'
+ )
+ if (kind === 'cached') {
+ expect(screen.getByText(/Updated/).textContent).toContain('source scan failed')
+ }
+ const retry = screen.getByRole('button', { name: /Refresh/ })
+ expect(retry.hasAttribute('disabled')).toBe(false)
+ fireEvent.click(retry)
+ expect(fixture.state[`refresh${name}Usage`]).toHaveBeenCalledTimes(1)
+ expect(screen.getByRole('switch').hasAttribute('disabled')).toBe(kind === 'unknown')
+ view.unmount()
+ }
+ }
+)
+
+it('deduplicates overview failure, retries unknown providers without enabling, and removes recovered warnings', () => {
+ setup('unknown')
+ const view = render(
+
+
+
+ )
+ expect(screen.getAllByRole('alert')).toHaveLength(1)
+ expect(screen.queryByText('Start tracking tokens')).toBeNull()
+ expect(screen.queryByText('0 enabled - 0 with data')).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: 'Refresh usage overview' }))
+ for (const [prefix, name] of providers) {
+ expect(fixture.state[`refresh${name}Usage`]).toHaveBeenCalledTimes(1)
+ expect(fixture.state[`enable${name}Usage`]).not.toHaveBeenCalled()
+ fixture.state[`${prefix}UsageFailure`] = null
+ }
+ view.rerender(
+
+
+
+ )
+ expect(screen.queryByRole('alert')).toBeNull()
+ setup('disabled')
+ view.rerender(
+
+
+
+ )
+ expect(screen.getByText('Start tracking tokens')).toBeTruthy()
+ expect(screen.getByText('0 enabled - 0 with data')).toBeTruthy()
+})
+
+it('renders translated failure and retry on the provider and overview', async () => {
+ i18n.addResourceBundle('es', 'translation', es)
+ await i18n.changeLanguage('es')
+ setup('unknown')
+ const view = render( )
+ expect(screen.getByRole('alert').textContent).toBe(
+ 'No se pudo completar la actualización anterior. Pulsa Actualizar de nuevo.'
+ )
+ fireEvent.click(screen.getByRole('button', { name: 'Actualizar' }))
+ expect(fixture.state.refreshClaudeUsage).toHaveBeenCalledTimes(1)
+ view.unmount()
+ render(
+
+
+
+ )
+ expect(screen.queryByText('Empezar a rastrear tokens')).toBeNull()
+ expect(screen.getByRole('alert').textContent).toContain(
+ 'No se pudo completar la actualización anterior.'
+ )
+})
diff --git a/src/renderer/src/components/stats/usage-overview-sections.tsx b/src/renderer/src/components/stats/usage-overview-sections.tsx
index 7d2be14f577..1faf1a231b6 100644
--- a/src/renderer/src/components/stats/usage-overview-sections.tsx
+++ b/src/renderer/src/components/stats/usage-overview-sections.tsx
@@ -240,11 +240,13 @@ export function DailyIntensityGrid({
export function ProviderUsageRow({
provider,
totalTokens,
- onEnable
+ onEnable,
+ stateKnown = true
}: {
provider: UsageProviderOverview
totalTokens: number
onEnable: () => void
+ stateKnown?: boolean
}): React.JSX.Element {
const t = useTranslate()
const intlLocale = useIntlLocale()
@@ -262,7 +264,7 @@ export function ProviderUsageRow({
{provider.label}
- {status}
+ {stateKnown ? status : '—'}
{provider.topModel ??
@@ -271,7 +273,7 @@ export function ProviderUsageRow({
{!provider.enabled ? (
-
+
{t('auto.components.stats.usage.overview.sections.57d1448ef8', 'Enable')}
) : null}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index e637bd705ac..83de3d8862c 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -25277,5 +25277,14 @@
"notGitRepository": "Not a valid Git repository",
"importFailed": "Repository could not be imported"
}
+ },
+ "usage": {
+ "failure": {
+ "unavailable": "Usage is unavailable. Try Refresh to reconnect.",
+ "setting": "The tracking change could not be confirmed. Try changing it again.",
+ "refresh": "The previous Refresh could not be completed. Try Refresh again.",
+ "read": "Usage could not be loaded. Try Refresh again.",
+ "retry": "Refresh"
+ }
}
}
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 3d6d54aef13..bb38e5be6ec 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -24207,5 +24207,14 @@
"dismiss": "Descartar",
"cancelDownload": "Cancelar descarga"
}
+ },
+ "usage": {
+ "failure": {
+ "unavailable": "El uso no está disponible. Pulsa Actualizar para reconectar.",
+ "setting": "No se pudo confirmar el cambio de seguimiento. Intenta cambiarlo de nuevo.",
+ "refresh": "No se pudo completar la actualización anterior. Pulsa Actualizar de nuevo.",
+ "read": "No se pudo cargar el uso. Pulsa Actualizar de nuevo.",
+ "retry": "Actualizar"
+ }
}
}
diff --git a/src/renderer/src/store/slices/usage/usage-backend-port.test.ts b/src/renderer/src/store/slices/usage/usage-backend-port.test.ts
new file mode 100644
index 00000000000..63e8af26347
--- /dev/null
+++ b/src/renderer/src/store/slices/usage/usage-backend-port.test.ts
@@ -0,0 +1,265 @@
+import { MessageChannel, type MessagePort } from 'node:worker_threads'
+import { afterEach, expect, it, vi } from 'vitest'
+import { BackendPortRpc, type BackendPort } from '../../../../../shared/backend-port-rpc'
+import { createStore } from 'zustand/vanilla'
+import { createClaudeUsageSlice } from './usage-provider-slices'
+import { createUsageProviderApi } from '../../../../../preload/usage-provider-api'
+import type { AppState } from '../../types'
+import type { ClaudeUsageScanState } from '../../../../../shared/claude-usage-types'
+import {
+ BACKEND_CONNECTION_CHANGED,
+ BACKEND_PORT_DELIVERY
+} from '../../../../../shared/backend-service-contract'
+
+const electron = vi.hoisted(() => ({
+ handlers: new Map void>(),
+ invoke: vi.fn(async (_channel: string, _token: string, _purpose?: string) => {})
+}))
+vi.mock('electron', () => ({
+ ipcRenderer: {
+ on: (channel: string, listener: (...args: unknown[]) => void) => {
+ electron.handlers.set(channel, listener)
+ },
+ invoke: electron.invoke
+ }
+}))
+
+const servers: BackendPortRpc[] = []
+const ports: MessagePort[] = []
+const detach: (() => void)[] = []
+
+function emit(channel: string, data: unknown, delivered: MessagePort[] = []): void {
+ electron.handlers.get(channel)?.({ ports: delivered }, data)
+}
+
+function deliver(invoke: (method: string, params?: unknown) => Promise): () => void {
+ const channel = new MessageChannel()
+ const added = vi.spyOn(channel.port2, 'addEventListener')
+ ports.push(channel.port1, channel.port2)
+ const adapter: BackendPort = {
+ postMessage: (message) => channel.port1.postMessage(message),
+ onMessage(listener) {
+ channel.port1.on('message', listener)
+ return () => channel.port1.off('message', listener)
+ },
+ onClose(listener) {
+ channel.port1.on('close', listener)
+ return () => channel.port1.off('close', listener)
+ },
+ close: () => channel.port1.close()
+ }
+ servers.push(new BackendPortRpc(adapter, 'backend', invoke))
+ emit(BACKEND_PORT_DELIVERY, { token: electron.invoke.mock.lastCall?.[1] }, [channel.port2])
+ const close = added.mock.calls.find(([event]) => event === 'close')?.[1]
+ return () => {
+ if (typeof close === 'function') {
+ close.call(channel.port2, new Event('close'))
+ } else {
+ close?.handleEvent(new Event('close'))
+ }
+ }
+}
+
+afterEach(() => {
+ detach.splice(0).forEach((unsubscribe) => unsubscribe())
+ emit(BACKEND_CONNECTION_CHANGED, { connected: false })
+ servers.splice(0).forEach((server) => server.close())
+ ports.splice(0).forEach((port) => port.close())
+ electron.handlers.clear()
+ electron.invoke.mockClear()
+ vi.restoreAllMocks()
+ vi.resetModules()
+ vi.unstubAllGlobals()
+})
+
+it('publishes port loss before rejection and preserves fresh work through duplicate loss', async () => {
+ const { backendUsageTransport: transport } =
+ await import('../../../../../preload/backend-port-client')
+ const events: string[] = []
+ let fresh: Promise | undefined
+ detach.push(
+ transport.onConnectionChanged((connected, detail) => {
+ if (detail?.initial || detail?.unavailable) {
+ return
+ }
+ events.push(connected ? 'ready' : 'lost')
+ if (!connected) {
+ fresh = transport.invoke('snapshot')
+ }
+ })
+ )
+ const started = Promise.withResolvers()
+ const held = Promise.withResolvers()
+ const first = transport.invoke('snapshot').catch(() => events.push('rejected'))
+ emit(BACKEND_CONNECTION_CHANGED, { connected: true })
+ const closeAgain = deliver(async () => {
+ started.resolve()
+ return held.promise
+ })
+ await started.promise
+ servers[0].close()
+ await first
+ expect(events).toEqual(['ready', 'lost', 'rejected'])
+ expect(electron.invoke).toHaveBeenCalledTimes(2)
+ emit(BACKEND_CONNECTION_CHANGED, { connected: false })
+ emit(BACKEND_CONNECTION_CHANGED, { connected: false })
+ deliver(async () => ({ inputTokens: 123 }))
+ await expect(fresh).resolves.toEqual({ inputTokens: 123 })
+ closeAgain()
+ expect(events).toEqual(['ready', 'lost', 'rejected', 'ready'])
+ held.resolve(null)
+ await expect(transport.invoke('snapshot')).resolves.toEqual({ inputTokens: 123 })
+ expect(electron.invoke).toHaveBeenCalledTimes(2)
+})
+
+it('keeps a replacement handshake when the old handshake rejects after loss', async () => {
+ const { backendUsageTransport: transport } =
+ await import('../../../../../preload/backend-port-client')
+ let fresh: Promise | undefined
+ detach.push(
+ transport.onConnectionChanged((connected, detail) => {
+ if (detail?.initial || detail?.unavailable) {
+ return
+ }
+ if (!connected) {
+ fresh = transport.invoke('snapshot')
+ }
+ })
+ )
+ const first = transport.invoke('snapshot')
+ const rejected = expect(first).rejects.toThrow('Usage disconnected')
+ emit(BACKEND_CONNECTION_CHANGED, { connected: false })
+ await rejected
+ emit(BACKEND_CONNECTION_CHANGED, { connected: false })
+ deliver(async () => ({ inputTokens: 123 }))
+ await expect(fresh).resolves.toEqual({ inputTokens: 123 })
+ expect(electron.invoke).toHaveBeenCalledTimes(2)
+})
+
+it('initializes terminal presentation without RPCs and preserves a fresh handshake through terminal escalation', async () => {
+ const { backendUsageTransport: transport } =
+ await import('../../../../../preload/backend-port-client')
+ emit(BACKEND_CONNECTION_CHANGED, { connected: false })
+ const fresh = transport.invoke('snapshot')
+ emit(BACKEND_CONNECTION_CHANGED, { connected: false, unavailable: true })
+ const statuses: unknown[] = []
+ detach.push(
+ transport.onConnectionChanged((connected, detail) => statuses.push({ connected, detail }))
+ )
+ expect(statuses).toEqual([{ connected: false, detail: { initial: true, unavailable: true } }])
+ expect(electron.invoke).toHaveBeenCalledTimes(1)
+ deliver(async () => ({ inputTokens: 123 }))
+ await expect(fresh).resolves.toEqual({ inputTokens: 123 })
+ expect(statuses.at(-1)).toEqual({ connected: true, detail: undefined })
+})
+
+it('clears terminal metadata on main ready without port delivery or mounted demand', async () => {
+ const { backendUsageTransport: transport } =
+ await import('../../../../../preload/backend-port-client')
+ emit(BACKEND_CONNECTION_CHANGED, { connected: false, unavailable: true })
+ emit(BACKEND_CONNECTION_CHANGED, { connected: true })
+ const statuses: unknown[] = []
+ detach.push(
+ transport.onConnectionChanged((connected, detail) => statuses.push({ connected, detail }))
+ )
+ expect(statuses).toEqual([{ connected: true, detail: { initial: true, unavailable: false } }])
+ expect(electron.invoke).not.toHaveBeenCalled()
+})
+
+it('promotes pending recovery once and ignores its stale delivery and rejection', async () => {
+ const { backendUsageTransport: transport } =
+ await import('../../../../../preload/backend-port-client')
+ const old = Promise.withResolvers()
+ electron.invoke.mockImplementationOnce(() => old.promise)
+ const recovery = transport.invoke('snapshot', undefined, 'recovery')
+ const oldToken = electron.invoke.mock.lastCall?.[1]
+ const demand = transport.invoke('snapshot')
+ const sharedDemand = transport.invoke('snapshot')
+ expect(electron.invoke.mock.calls.map((call) => call[2])).toEqual(['recovery', 'demand'])
+ expect(electron.invoke.mock.lastCall?.[1]).not.toBe(oldToken)
+ const stale = new MessageChannel()
+ ports.push(stale.port1, stale.port2)
+ const close = vi.spyOn(stale.port2, 'close')
+ emit(BACKEND_PORT_DELIVERY, { token: oldToken }, [stale.port2])
+ expect(close).toHaveBeenCalledOnce()
+ old.reject(new Error('Old document replaced'))
+ await Promise.resolve()
+ deliver(async () => ({ inputTokens: 123 }))
+ await expect(Promise.all([recovery, demand, sharedDemand])).resolves.toEqual([
+ { inputTokens: 123 },
+ { inputTokens: 123 },
+ { inputTokens: 123 }
+ ])
+ expect(electron.invoke).toHaveBeenCalledTimes(2)
+})
+
+it('cancels pending recovery on loss without rearming, and admits fresh post-loss demand', async () => {
+ const { backendUsageTransport: transport } =
+ await import('../../../../../preload/backend-port-client')
+ const old = Promise.withResolvers()
+ electron.invoke.mockImplementationOnce(() => old.promise)
+ const recovery = transport.invoke('snapshot', undefined, 'recovery')
+ const failed = expect(recovery).rejects.toThrow('Usage disconnected')
+ emit(BACKEND_CONNECTION_CHANGED, { connected: false })
+ await failed
+ expect(electron.invoke).toHaveBeenCalledTimes(1)
+ const fresh = transport.invoke('snapshot')
+ old.reject(new Error('Old acquisition failed'))
+ await Promise.resolve()
+ deliver(async () => 123)
+ await expect(fresh).resolves.toBe(123)
+ expect(electron.invoke.mock.calls.map((call) => call[2])).toEqual(['recovery', 'demand'])
+})
+
+it('carries mounted recovery purpose through both real usage reads without sending it to the service', async () => {
+ const { backendUsageTransport: transport } =
+ await import('../../../../../preload/backend-port-client')
+ const invoke = vi.spyOn(transport, 'invoke')
+ const scanState: ClaudeUsageScanState = {
+ enabled: true,
+ isScanning: false,
+ lastScanStartedAt: null,
+ lastScanCompletedAt: 1,
+ lastScanError: null,
+ hasAnyClaudeData: true
+ }
+ const snapshot = {
+ scanState,
+ summary: { inputTokens: 123 },
+ daily: [],
+ modelBreakdown: [],
+ projectBreakdown: [],
+ recentSessions: []
+ }
+ const received = vi.fn(async (method: string, _params?: unknown) =>
+ method.endsWith(':getSnapshot') ? snapshot : scanState
+ )
+ vi.stubGlobal('window', {
+ api: { claudeUsage: createUsageProviderApi(transport, 'claudeUsage') }
+ })
+ const store = createStore()((...args) => {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture uses only the production usage slice's public state/actions.
+ return createClaudeUsageSlice(...args) as AppState
+ })
+ detach.push(store.getState().observeClaudeUsage())
+ await vi.waitFor(() => expect(electron.invoke).toHaveBeenCalledTimes(1))
+ expect(electron.invoke.mock.lastCall?.[2]).toBe('demand')
+ deliver(received)
+ await store.getState().fetchClaudeUsage()
+ invoke.mockClear()
+ received.mockClear()
+ emit(BACKEND_CONNECTION_CHANGED, { connected: false })
+ emit(BACKEND_CONNECTION_CHANGED, { connected: true })
+ await vi.waitFor(() => expect(electron.invoke).toHaveBeenCalledTimes(2))
+ expect(electron.invoke.mock.lastCall?.[2]).toBe('recovery')
+ deliver(received)
+ await vi.waitFor(() => expect(received).toHaveBeenCalledTimes(2))
+ expect(invoke.mock.calls).toEqual([
+ ['claudeUsage:getScanState', undefined, 'recovery'],
+ ['claudeUsage:getSnapshot', { scope: 'orca', range: '30d', limit: 10 }, 'recovery']
+ ])
+ expect(received.mock.calls).toEqual([
+ ['claudeUsage:getScanState', undefined, expect.any(AbortSignal)],
+ ['claudeUsage:getSnapshot', { scope: 'orca', range: '30d', limit: 10 }, expect.any(AbortSignal)]
+ ])
+})
diff --git a/src/renderer/src/store/slices/usage/usage-provider-slice-contract.ts b/src/renderer/src/store/slices/usage/usage-provider-slice-contract.ts
new file mode 100644
index 00000000000..b524de54301
--- /dev/null
+++ b/src/renderer/src/store/slices/usage/usage-provider-slice-contract.ts
@@ -0,0 +1,101 @@
+import type {
+ BackendConnectionListener,
+ BackendConnectionPurpose
+} from '../../../../../shared/backend-service-contract'
+import type {
+ ClaudeUsageRange,
+ ClaudeUsageScope,
+ ClaudeUsageSnapshot
+} from '../../../../../shared/claude-usage-types'
+import type {
+ CodexUsageRange,
+ CodexUsageScope,
+ CodexUsageSnapshot
+} from '../../../../../shared/codex-usage-types'
+import type {
+ OpenCodeUsageRange,
+ OpenCodeUsageScope,
+ OpenCodeUsageSnapshot
+} from '../../../../../shared/opencode-usage-types'
+import type {
+ MuseUsageRange,
+ MuseUsageScope,
+ MuseUsageSnapshot
+} from '../../../../../shared/muse-usage-types'
+import type { UsageSnapshot, UsageProviderTypes, UsageFailure } from './usage-provider-types'
+
+export type UsageData> = {
+ failure: UsageFailure | null
+ scope: T['scope']
+ range: T['range']
+ scanState: T['snapshot']['scanState'] | null
+ summary: T['snapshot']['summary'] | null
+ daily: T['snapshot']['daily']
+ modelBreakdown: T['snapshot']['modelBreakdown']
+ projectBreakdown: T['snapshot']['projectBreakdown']
+ recentSessions: T['snapshot']['recentSessions']
+}
+
+export type UsageApi> = {
+ onConnectionChanged?: (listener: BackendConnectionListener) => () => void
+ getScanState: (purpose?: BackendConnectionPurpose) => Promise
+ setEnabled: (args: { enabled: boolean }) => Promise
+ refresh: (args?: { force?: boolean }) => Promise
+ getSnapshot: (
+ args: {
+ scope: T['scope']
+ range: T['range']
+ limit?: number
+ },
+ purpose?: BackendConnectionPurpose
+ ) => Promise
+}
+
+export type ProviderUsageSlice<
+ Prefix extends string,
+ Name extends string,
+ T extends UsageProviderTypes
+> = {
+ [K in keyof UsageData as `${Prefix}Usage${Capitalize}`]: UsageData[K]
+} & Record<`set${Name}UsageEnabled`, (enabled: boolean) => Promise> &
+ Record<`set${Name}UsageScope`, (scope: T['scope']) => Promise> &
+ Record<`set${Name}UsageRange`, (range: T['range']) => Promise> &
+ Record<`fetch${Name}Usage`, (opts?: { forceRefresh?: boolean }) => Promise> &
+ Record<`observe${Name}Usage`, () => () => void> &
+ Record<`enable${Name}Usage`, () => Promise> &
+ Record<`refresh${Name}Usage`, () => Promise>
+
+export type UsageProviderConfig<
+ Prefix extends string,
+ Name extends string,
+ T extends UsageProviderTypes
+> = {
+ prefix: Prefix
+ name: Name
+ initialScope: T['scope']
+ initialRange: T['range']
+ getApi: () => UsageApi
+ hasCachedData: (scanState: T['snapshot']['scanState']) => boolean
+}
+export type ClaudeUsageTypes = UsageProviderTypes<
+ ClaudeUsageScope,
+ ClaudeUsageRange,
+ ClaudeUsageSnapshot
+>
+export type CodexUsageTypes = UsageProviderTypes<
+ CodexUsageScope,
+ CodexUsageRange,
+ CodexUsageSnapshot
+>
+export type OpenCodeUsageTypes = UsageProviderTypes<
+ OpenCodeUsageScope,
+ OpenCodeUsageRange,
+ OpenCodeUsageSnapshot
+>
+
+export type MuseUsageTypes = UsageProviderTypes
+
+export type ClaudeUsageSlice = ProviderUsageSlice<'claude', 'Claude', ClaudeUsageTypes>
+export type CodexUsageSlice = ProviderUsageSlice<'codex', 'Codex', CodexUsageTypes>
+export type OpenCodeUsageSlice = ProviderUsageSlice<'openCode', 'OpenCode', OpenCodeUsageTypes>
+export type MuseUsageSlice = ProviderUsageSlice<'muse', 'Muse', MuseUsageTypes>
diff --git a/src/renderer/src/store/slices/usage/usage-provider-slice-state.ts b/src/renderer/src/store/slices/usage/usage-provider-slice-state.ts
new file mode 100644
index 00000000000..f2965441106
--- /dev/null
+++ b/src/renderer/src/store/slices/usage/usage-provider-slice-state.ts
@@ -0,0 +1,87 @@
+import type { AppState } from '../../types'
+import type { UsageSnapshot, UsageProviderTypes, UsageFailure } from './usage-provider-types'
+import type { UsageData } from './usage-provider-slice-contract'
+
+const usageDataFields = [
+ 'failure',
+ 'scope',
+ 'range',
+ 'scanState',
+ 'summary',
+ 'daily',
+ 'modelBreakdown',
+ 'projectBreakdown',
+ 'recentSessions'
+] as const satisfies readonly (keyof UsageData>)[]
+
+function usageDataKey(prefix: string, field: string): string {
+ return `${prefix}Usage${field[0].toUpperCase()}${field.slice(1)}`
+}
+
+export function readUsageData>(
+ state: AppState,
+ prefix: string
+): UsageData {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: AppState contains each configured provider field; the prefix is fixed by the slice factory.
+ const values = state as unknown as Record
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixed field list maps the configured provider keys back to their data fields.
+ return Object.fromEntries(
+ usageDataFields.map((field) => [field, values[usageDataKey(prefix, field)]])
+ ) as UsageData
+}
+
+export function createUsagePatch>(
+ prefix: string,
+ patch: Partial>
+): Partial {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixed field list maps the configured provider keys back to their data fields.
+ return Object.fromEntries(
+ usageDataFields
+ .filter((field) => field in patch)
+ .map((field) => [usageDataKey(prefix, field), patch[field]])
+ ) as Partial
+}
+
+export function createInitialUsageData>(
+ scope: T['scope'],
+ range: T['range']
+): UsageData {
+ return {
+ failure: null,
+ scope,
+ range,
+ scanState: null,
+ summary: null,
+ daily: [],
+ modelBreakdown: [],
+ projectBreakdown: [],
+ recentSessions: []
+ }
+}
+
+export function mergeUsageFailure(
+ previous: UsageFailure | null,
+ patch: Partial
+): UsageFailure | null {
+ const next = {
+ terminal: false,
+ read: false,
+ refresh: false,
+ setting: false,
+ ...previous,
+ ...patch
+ }
+ if (!next.terminal && !next.read && !next.refresh && !next.setting) {
+ return null
+ }
+ if (
+ previous &&
+ previous.terminal === next.terminal &&
+ previous.read === next.read &&
+ previous.refresh === next.refresh &&
+ previous.setting === next.setting
+ ) {
+ return previous
+ }
+ return next
+}
diff --git a/src/renderer/src/store/slices/usage/usage-provider-slice.ts b/src/renderer/src/store/slices/usage/usage-provider-slice.ts
new file mode 100644
index 00000000000..1a74f0251a8
--- /dev/null
+++ b/src/renderer/src/store/slices/usage/usage-provider-slice.ts
@@ -0,0 +1,308 @@
+import type { StateCreator } from 'zustand'
+import type {
+ UsageData,
+ UsageApi,
+ ProviderUsageSlice,
+ UsageProviderConfig
+} from './usage-provider-slice-contract'
+import type { AppState } from '../../types'
+
+import type { UsageSnapshot, UsageProviderTypes, UsageFailure } from './usage-provider-types'
+
+import {
+ mergeUsageFailure,
+ readUsageData,
+ createUsagePatch,
+ createInitialUsageData
+} from './usage-provider-slice-state'
+
+export function createUsageProviderSlice<
+ Prefix extends string,
+ Name extends string,
+ T extends UsageProviderTypes
+>(
+ config: UsageProviderConfig
+): StateCreator> {
+ return (set, get) => {
+ const update = (patch: Partial>): void =>
+ set(createUsagePatch(config.prefix, patch))
+ const read = (): UsageData => readUsageData(get(), config.prefix)
+
+ const updateFailure = (patch: Partial): void => {
+ const previous = read().failure
+ const failure = mergeUsageFailure(previous, patch)
+ if (failure !== previous) {
+ update({ failure })
+ }
+ }
+
+ const stopLoading = (): void => {
+ const state = read().scanState
+ if (state?.isScanning) {
+ update({ scanState: { ...state, isScanning: false } })
+ }
+ }
+
+ type Intent = 0 | 1 | 2
+ type Operation = {
+ intent: Intent
+ phase: 'reading' | 'refreshing' | 'projecting'
+ disconnected: boolean
+ readOnFailure: boolean
+ promise: Promise
+ }
+ let operation: Operation | null = null
+ let pending: Intent | null = null
+ const queueIntent = (intent: Intent): void => {
+ pending = pending === null || intent > pending ? intent : pending
+ }
+ const markLoading = (): void => {
+ const state = read().scanState
+ if (state?.enabled) {
+ update({ scanState: { ...state, isScanning: true } })
+ }
+ }
+ const project = async (
+ api: UsageApi,
+ current: Operation,
+ beforeRefresh: boolean
+ ): Promise => {
+ while (!current.disconnected) {
+ const { scope, range } = read()
+ const snapshot = await api.getSnapshot(
+ { scope, range, limit: 10 },
+ current.intent === 0 ? 'recovery' : undefined
+ )
+ if (current.disconnected) {
+ return
+ }
+ if (scope !== read().scope || range !== read().range) {
+ continue
+ }
+ const loading = pending === 2 || (beforeRefresh && current.intent === 2)
+ if (
+ beforeRefresh &&
+ current.intent > 0 &&
+ snapshot.scanState.lastScanCompletedAt === null &&
+ !config.hasCachedData(snapshot.scanState)
+ ) {
+ update({ scanState: { ...snapshot.scanState, isScanning: true, lastScanError: null } })
+ } else {
+ update({
+ ...snapshot,
+ scanState: loading ? { ...snapshot.scanState, isScanning: true } : snapshot.scanState
+ })
+ }
+ return snapshot.scanState
+ }
+ return undefined
+ }
+ const request = (intent: Intent, recovery = false): Promise => {
+ if (intent === 2) {
+ markLoading()
+ }
+ if (operation) {
+ if (recovery) {
+ operation.readOnFailure = true
+ if (operation.disconnected) {
+ queueIntent(0)
+ }
+ } else if (
+ operation.disconnected ||
+ intent > operation.intent ||
+ (intent > 0 && operation.phase === 'projecting')
+ ) {
+ queueIntent(intent)
+ }
+ return operation.promise
+ }
+ const current: Operation = {
+ intent,
+ phase: 'reading',
+ disconnected: false,
+ readOnFailure: false,
+ promise: Promise.resolve()
+ }
+ operation = current
+ const unsubscribe = config.getApi()?.onConnectionChanged?.((connected, detail) => {
+ if (detail?.initial || detail?.unavailable) {
+ return
+ }
+ if (!connected && operation === current) {
+ if (current.intent === 2 || pending === 2) {
+ updateFailure({ refresh: true })
+ }
+ current.disconnected = true
+ current.readOnFailure = false
+ pending = null
+ stopLoading()
+ }
+ })
+ // Publish the operation before the first port handshake can notify mounted readers.
+ current.promise = Promise.resolve()
+ .then(async () => {
+ let next: Intent | null = intent
+ while (next !== null) {
+ current.intent = next
+ current.phase = 'reading'
+ current.disconnected = false
+ current.readOnFailure = false
+ pending = null
+ try {
+ const api = config.getApi()
+ const state = await api.getScanState(next === 0 ? 'recovery' : undefined)
+ if (current.disconnected) {
+ throw new Error('Usage disconnected.')
+ }
+ if (state) {
+ update({
+ scanState:
+ (next === 2 || pending === 2) && state.enabled
+ ? { ...state, isScanning: true }
+ : state
+ })
+ if (state.enabled) {
+ const baseline = await project(api, current, next > 0)
+ if (current.disconnected) {
+ throw new Error('Usage disconnected.')
+ }
+ if (next > 0) {
+ current.phase = 'refreshing'
+ const olderFailure = read().failure
+ const refreshed = await api.refresh({ force: next === 2 })
+ if (current.disconnected) {
+ throw new Error('Usage disconnected.')
+ }
+ current.phase = 'projecting'
+ const completed = await project(api, current, false)
+ if (current.disconnected) {
+ throw new Error('Usage disconnected.')
+ }
+ if (
+ completed?.enabled &&
+ !completed.isScanning &&
+ !completed.lastScanError &&
+ ((next === 2 &&
+ refreshed &&
+ !refreshed.isScanning &&
+ !refreshed.lastScanError) ||
+ (next === 1 &&
+ olderFailure === read().failure &&
+ baseline &&
+ completed.lastScanCompletedAt !== null &&
+ (baseline.lastScanCompletedAt === null ||
+ completed.lastScanCompletedAt > baseline.lastScanCompletedAt)))
+ ) {
+ updateFailure({ refresh: false })
+ }
+ }
+ }
+ updateFailure({ read: false })
+ }
+ } catch (error) {
+ updateFailure({ read: true, ...(next === 2 ? { refresh: true } : {}) })
+ if (pending !== 2) {
+ stopLoading()
+ }
+ if (current.readOnFailure) {
+ queueIntent(0)
+ }
+ console.error(`Failed to fetch ${config.name} usage:`, error)
+ }
+ next = pending
+ }
+ if (operation === current) {
+ operation = null
+ }
+ })
+ .finally(() => {
+ unsubscribe?.()
+ if (operation === current) {
+ operation = null
+ }
+ })
+ return current.promise
+ }
+ const fetchUsage = (opts?: { forceRefresh?: boolean }): Promise =>
+ request(opts?.forceRefresh ? 2 : 1)
+ const observeUsage = (): (() => void) => {
+ const unsubscribe = config.getApi()?.onConnectionChanged?.((connected, detail) => {
+ if (detail?.initial) {
+ updateFailure({ terminal: detail.unavailable === true })
+ return
+ }
+ if (detail?.unavailable) {
+ updateFailure({ terminal: true })
+ return
+ }
+ if (connected) {
+ updateFailure({ terminal: false })
+ void request(0, true)
+ } else {
+ stopLoading()
+ }
+ })
+ void fetchUsage()
+ return () => unsubscribe?.()
+ }
+
+ const setEnabled = async (enabled: boolean): Promise => {
+ try {
+ const nextScanState = (await config.getApi().setEnabled({ enabled })) as
+ | T['snapshot']['scanState']
+ | undefined
+ if (!nextScanState) {
+ return
+ }
+ updateFailure(
+ enabled
+ ? { setting: false, read: false, terminal: false }
+ : { setting: false, read: false, terminal: false, refresh: false }
+ )
+ update({
+ scanState: enabled
+ ? {
+ ...nextScanState,
+ isScanning: true,
+ lastScanCompletedAt: null,
+ lastScanError: null
+ }
+ : nextScanState,
+ summary: null,
+ daily: [],
+ modelBreakdown: [],
+ projectBreakdown: [],
+ recentSessions: []
+ })
+ if (enabled) {
+ await fetchUsage({ forceRefresh: true })
+ }
+ } catch (error) {
+ updateFailure({ setting: true })
+ stopLoading()
+ console.error(`Failed to update ${config.name} usage setting:`, error)
+ }
+ }
+
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The configured prefix/name maps the declared data and action keys for this provider.
+ return {
+ ...createUsagePatch(
+ config.prefix,
+ createInitialUsageData(config.initialScope, config.initialRange)
+ ),
+ [`set${config.name}UsageEnabled`]: setEnabled,
+ [`set${config.name}UsageScope`]: async (scope: T['scope']) => {
+ update({ scope })
+ await fetchUsage()
+ },
+ [`set${config.name}UsageRange`]: async (range: T['range']) => {
+ update({ range })
+ await fetchUsage()
+ },
+ [`fetch${config.name}Usage`]: fetchUsage,
+ [`observe${config.name}Usage`]: observeUsage,
+ [`enable${config.name}Usage`]: () => setEnabled(true),
+ [`refresh${config.name}Usage`]: () => fetchUsage({ forceRefresh: true })
+ } as ProviderUsageSlice
+ }
+}
diff --git a/src/renderer/src/store/slices/usage/usage-provider-slices.test.ts b/src/renderer/src/store/slices/usage/usage-provider-slices.test.ts
new file mode 100644
index 00000000000..15d4db6d8c6
--- /dev/null
+++ b/src/renderer/src/store/slices/usage/usage-provider-slices.test.ts
@@ -0,0 +1,419 @@
+// @vitest-environment happy-dom
+import { afterEach, expect, it, vi } from 'vitest'
+import { act, cleanup, renderHook } from '@testing-library/react'
+import { createStore } from 'zustand/vanilla'
+import { createClaudeUsageSlice } from './usage-provider-slices'
+import { useUsageObservation } from './use-usage-observation'
+import type {
+ BackendConnectionListener,
+ BackendConnectionDetail
+} from '../../../../../shared/backend-service-contract'
+import type { AppState } from '../../types'
+import type { ClaudeUsageScanState } from '../../../../../shared/claude-usage-types'
+
+const scanState: ClaudeUsageScanState = {
+ enabled: true,
+ isScanning: false,
+ lastScanStartedAt: null,
+ lastScanCompletedAt: 1,
+ lastScanError: null,
+ hasAnyClaudeData: true
+}
+const snapshot = {
+ scanState,
+ summary: { turns: 1, inputTokens: 123 },
+ daily: [],
+ modelBreakdown: [],
+ projectBreakdown: [],
+ recentSessions: []
+}
+function deferred() {
+ let resolve!: (value: T) => void
+ let reject!: (error: Error) => void
+ const promise = new Promise((done, fail) => {
+ resolve = done
+ reject = fail
+ })
+ return { promise, resolve, reject }
+}
+function fixture() {
+ const listeners = new Set()
+ let status = { connected: false, unavailable: false }
+ const api = {
+ onConnectionChanged: (listener: BackendConnectionListener) => {
+ listeners.add(listener)
+ listener(status.connected, { initial: true, unavailable: status.unavailable })
+ return () => {
+ listeners.delete(listener)
+ }
+ },
+ setEnabled: vi.fn(async ({ enabled }: { enabled: boolean }) => ({ ...scanState, enabled })),
+ getScanState: vi.fn(async () => scanState),
+ getSnapshot: vi.fn(async (_selection: { scope: string; range: string }) => snapshot),
+ refresh: vi.fn(async (_options?: { force?: boolean }) => scanState)
+ }
+ vi.stubGlobal('api', { claudeUsage: api })
+ const store = createStore()((...args) => {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture uses only this production slice's public state/actions.
+ return createClaudeUsageSlice(...args) as AppState
+ })
+ return {
+ store,
+ api,
+ notify: (connected: boolean, detail?: BackendConnectionDetail) => {
+ status = { connected, unavailable: detail?.unavailable === true }
+ for (const listener of listeners) {
+ listener(connected, detail)
+ }
+ }
+ }
+}
+afterEach(() => {
+ cleanup()
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+})
+
+it.each([1, 10])(
+ 'hydrates only %i mounted outputs and does no hidden reconnect work',
+ async (count) => {
+ const { api, notify } = fixture()
+ const stores = Array.from({ length: count }, () =>
+ createStore()((...args) => {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only the usage slice is exercised.
+ return createClaudeUsageSlice(...args) as AppState
+ })
+ )
+ const hooks = stores.map((store) =>
+ renderHook(() => useUsageObservation(store.getState().observeClaudeUsage))
+ )
+ await act(async () => {
+ await Promise.all(stores.map((store) => store.getState().fetchClaudeUsage()))
+ })
+ api.getScanState.mockClear()
+ api.getSnapshot.mockClear()
+ api.refresh.mockClear()
+ await act(async () => {
+ notify(false)
+ notify(true)
+ })
+ expect(api.getScanState).toHaveBeenCalledTimes(count)
+ expect(api.getSnapshot).toHaveBeenCalledTimes(count)
+ expect(api.refresh).not.toHaveBeenCalled()
+ expect(stores[0].getState().claudeUsageSummary).toMatchObject({ inputTokens: 123 })
+ for (const hook of hooks) {
+ hook.unmount()
+ }
+ api.getScanState.mockClear()
+ api.getSnapshot.mockClear()
+ await act(async () => {
+ notify(false)
+ notify(true)
+ })
+ expect(api.getScanState).not.toHaveBeenCalled()
+ expect(api.getSnapshot).not.toHaveBeenCalled()
+ }
+)
+
+it.each([1, 10])('drops lost queued force after %i outputs unmount', async (count) => {
+ const { api, notify } = fixture()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ const refresh = deferred()
+ api.refresh.mockReturnValue(refresh.promise)
+ const stores = Array.from({ length: count }, () =>
+ createStore()((...args) => {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only the usage slice is exercised.
+ return createClaudeUsageSlice(...args) as AppState
+ })
+ )
+ const hooks = stores.map((store) =>
+ renderHook(() => useUsageObservation(store.getState().observeClaudeUsage))
+ )
+ await act(async () => {})
+ expect(api.refresh).toHaveBeenCalledTimes(count)
+ const forced = stores.map((store) => store.getState().refreshClaudeUsage())
+ for (const hook of hooks) {
+ hook.unmount()
+ }
+ api.getScanState.mockClear()
+ api.getSnapshot.mockClear()
+ api.refresh.mockClear()
+ await act(async () => {
+ notify(false)
+ refresh.reject(new Error('port closed'))
+ await Promise.all(forced)
+ notify(true)
+ })
+ expect(api.getScanState).not.toHaveBeenCalled()
+ expect(api.getSnapshot).not.toHaveBeenCalled()
+ expect(api.refresh).not.toHaveBeenCalled()
+ expect(stores[0].getState().claudeUsageScanState?.isScanning).toBe(false)
+})
+
+it.each([false, true])(
+ 'preserves admitted work after unmount and same-connection failure=%s',
+ async (fail) => {
+ const { api, store } = fixture()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ const refresh = deferred()
+ api.refresh.mockReturnValueOnce(refresh.promise)
+ const hook = renderHook(() => useUsageObservation(store.getState().observeClaudeUsage))
+ await act(async () => {})
+ expect(api.refresh).toHaveBeenCalledTimes(1)
+ const forced = store.getState().refreshClaudeUsage()
+ hook.unmount()
+ if (fail) {
+ refresh.reject(new Error('scan failed on the same connection'))
+ } else {
+ refresh.resolve(scanState)
+ }
+ await forced
+ expect(api.refresh.mock.calls.map(([options]) => options?.force)).toEqual([false, true])
+ expect(store.getState().claudeUsageSummary).toMatchObject({ inputTokens: 123 })
+ }
+)
+
+it('retains fresh explicit intent after loss while the old call is rejecting', async () => {
+ const { api, store, notify } = fixture()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ const refresh = deferred()
+ api.refresh.mockReturnValueOnce(refresh.promise)
+ const hook = renderHook(() => useUsageObservation(store.getState().observeClaudeUsage))
+ await act(async () => {})
+ const oldForce = store.getState().refreshClaudeUsage()
+ hook.unmount()
+ notify(false)
+ const fresh = store.getState().refreshClaudeUsage()
+ refresh.reject(new Error('old port closed'))
+ await Promise.all([oldForce, fresh])
+ expect(api.refresh.mock.calls.map(([options]) => options?.force)).toEqual([false, true])
+ expect(store.getState().claudeUsageSummary).toMatchObject({ inputTokens: 123 })
+})
+
+it('retains mounted recovery after the first state request fails', async () => {
+ const { store, api, notify } = fixture()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ api.getScanState.mockRejectedValueOnce(new Error('first connection failed'))
+ renderHook(() => useUsageObservation(store.getState().observeClaudeUsage))
+ await act(async () => {
+ await store.getState().fetchClaudeUsage()
+ })
+ expect(store.getState().claudeUsageScanState).toBeNull()
+ await act(async () => {
+ notify(true)
+ })
+ expect(store.getState().claudeUsageSummary).toMatchObject({ inputTokens: 123 })
+ expect(api.refresh).not.toHaveBeenCalled()
+})
+
+it('joins the startup-ready signal and bounds selection storms while retaining a late force', async () => {
+ const { store, api, notify } = fixture()
+ const refresh = deferred()
+ api.refresh.mockReturnValueOnce(refresh.promise)
+ renderHook(() => useUsageObservation(store.getState().observeClaudeUsage))
+ await act(async () => {
+ notify(true)
+ })
+ expect(api.getScanState).toHaveBeenCalledTimes(1)
+ const requests = Array.from({ length: 40 }, (_, i) =>
+ store.getState().setClaudeUsageRange(i === 39 ? 'all' : '7d')
+ )
+ const forced = store.getState().refreshClaudeUsage()
+ await act(async () => {
+ refresh.resolve(scanState)
+ await Promise.all([...requests, forced])
+ })
+ expect(api.refresh.mock.calls.map(([options]) => options?.force)).toEqual([false, true])
+ expect(store.getState().claudeUsageRange).toBe('all')
+ expect(api.getSnapshot.mock.calls.at(-1)?.[0]).toMatchObject({ range: 'all' })
+})
+
+it.each([false, true])(
+ 'owns queued explicit refresh across ordinary failure=%s',
+ async (failed) => {
+ const { store, api } = fixture()
+ const background = deferred()
+ const forcedState = deferred()
+ const forcedScan = deferred()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ api.getScanState.mockResolvedValueOnce(scanState).mockReturnValueOnce(forcedState.promise)
+ api.refresh.mockReturnValueOnce(background.promise).mockReturnValueOnce(forcedScan.promise)
+ const first = store.getState().fetchClaudeUsage()
+ await vi.waitFor(() => expect(api.refresh).toHaveBeenCalledTimes(1))
+ expect(store.getState().claudeUsageSummary).toMatchObject({ inputTokens: 123 })
+ expect(store.getState().claudeUsageScanState?.isScanning).toBe(false)
+
+ const forced = store.getState().refreshClaudeUsage()
+ expect(store.getState().claudeUsageScanState?.isScanning).toBe(true)
+ const busy: boolean[] = []
+ const unsubscribe = store.subscribe((state) => {
+ busy.push(state.claudeUsageScanState?.isScanning ?? false)
+ })
+ if (failed) {
+ background.reject(new Error('Catalog unavailable'))
+ } else {
+ background.resolve(scanState)
+ }
+ await vi.waitFor(() => expect(api.getScanState).toHaveBeenCalledTimes(2))
+ expect(store.getState().claudeUsageScanState?.isScanning).toBe(true)
+ expect(busy.every(Boolean)).toBe(true)
+ forcedState.resolve(scanState)
+ await vi.waitFor(() => expect(api.refresh).toHaveBeenCalledTimes(2))
+ expect(busy.length).toBeGreaterThan(0)
+ expect(busy.every(Boolean)).toBe(true)
+ expect(store.getState().claudeUsageSummary).toMatchObject({ inputTokens: 123 })
+ unsubscribe()
+ forcedScan.resolve(scanState)
+ await Promise.all([first, forced])
+ expect(store.getState().claudeUsageScanState?.isScanning).toBe(false)
+ }
+)
+
+it('shows first-load scanning without masking a reported scan on answered data', async () => {
+ const { store, api } = fixture()
+ const background = deferred()
+ api.getSnapshot.mockResolvedValueOnce({
+ ...snapshot,
+ scanState: { ...scanState, lastScanCompletedAt: null, hasAnyClaudeData: false }
+ })
+ api.refresh.mockReturnValueOnce(background.promise)
+ const first = store.getState().fetchClaudeUsage()
+ await vi.waitFor(() => expect(api.refresh).toHaveBeenCalledTimes(1))
+ expect(store.getState().claudeUsageScanState?.isScanning).toBe(true)
+ background.resolve(scanState)
+ await first
+ expect(store.getState().claudeUsageScanState?.isScanning).toBe(false)
+
+ const scanning = deferred()
+ api.getScanState.mockResolvedValueOnce({ ...scanState, isScanning: true })
+ api.getSnapshot.mockResolvedValueOnce({
+ ...snapshot,
+ scanState: { ...scanState, isScanning: true }
+ })
+ api.refresh.mockReturnValueOnce(scanning.promise)
+ const next = store.getState().fetchClaudeUsage()
+ await vi.waitFor(() => expect(api.refresh).toHaveBeenCalledTimes(2))
+ expect(store.getState().claudeUsageScanState?.isScanning).toBe(true)
+ scanning.resolve(scanState)
+ await next
+})
+
+it('does not publish an obsolete selection or swallow force after refresh has finished', async () => {
+ const { store, api } = fixture()
+ const projection = deferred()
+ api.getSnapshot.mockResolvedValueOnce(snapshot).mockReturnValueOnce(projection.promise)
+ const first = store.getState().fetchClaudeUsage()
+ await vi.waitFor(() => expect(api.getSnapshot).toHaveBeenCalledTimes(2))
+ const selected = store.getState().setClaudeUsageRange('all')
+ const forced = store.getState().refreshClaudeUsage()
+ projection.resolve({ ...snapshot, summary: { turns: 2, inputTokens: 999 } })
+ await Promise.all([first, selected, forced])
+ expect(store.getState().claudeUsageSummary).toMatchObject({ inputTokens: 123 })
+ expect(api.refresh.mock.calls.map(([options]) => options?.force)).toEqual([false, true])
+ expect(api.getSnapshot.mock.calls.at(-1)?.[0]).toMatchObject({ range: 'all' })
+})
+
+it('shows lost Refresh through read-only recovery and clears it only after a successful explicit retry', async () => {
+ const { api, store, notify } = fixture()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ const stop = store.getState().observeClaudeUsage()
+ await store.getState().fetchClaudeUsage()
+ const held = deferred()
+ api.refresh.mockReturnValueOnce(held.promise)
+ const refresh = store.getState().refreshClaudeUsage()
+ await vi.waitFor(() => expect(api.refresh).toHaveBeenCalledTimes(2))
+ notify(false)
+ notify(false, { unavailable: true })
+ held.reject(new Error('Usage disconnected'))
+ await refresh
+ expect(store.getState().claudeUsageFailure).toMatchObject({ terminal: true, refresh: true })
+ expect(store.getState().claudeUsageScanState?.isScanning).toBe(false)
+ api.refresh.mockClear()
+ notify(true)
+ await store.getState().fetchClaudeUsage()
+ expect(api.refresh).not.toHaveBeenCalled()
+ expect(store.getState().claudeUsageFailure).toMatchObject({ terminal: false, refresh: true })
+ expect(store.getState().claudeUsageSummary).toMatchObject({ inputTokens: 123 })
+ await store.getState().refreshClaudeUsage()
+ expect(api.refresh.mock.calls.map(([options]) => options?.force)).toEqual([true])
+ expect(store.getState().claudeUsageFailure).toBeNull()
+ stop()
+})
+
+it.each([
+ 'skip',
+ 'scanning',
+ 'error',
+ 'completed',
+ 'first-completion',
+ 'intermediate-scanning'
+] as const)(
+ 'resolves old Refresh failure only with a newer settled producer result: %s',
+ async (outcome) => {
+ const { api, store } = fixture()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ api.refresh.mockRejectedValueOnce(new Error('lost Refresh'))
+ await store.getState().refreshClaudeUsage()
+ expect(store.getState().claudeUsageFailure?.refresh).toBe(true)
+ const baseline = {
+ ...scanState,
+ lastScanCompletedAt: outcome === 'first-completion' ? null : 1
+ }
+ const final = {
+ ...scanState,
+ lastScanCompletedAt: outcome === 'skip' ? 1 : 2,
+ isScanning: outcome === 'scanning',
+ lastScanError: outcome === 'error' ? 'scan failed' : null
+ }
+ api.getSnapshot
+ .mockResolvedValueOnce({ ...snapshot, scanState: baseline })
+ .mockResolvedValueOnce({ ...snapshot, scanState: final })
+ api.refresh.mockResolvedValueOnce(
+ outcome === 'intermediate-scanning' ? { ...final, isScanning: true } : final
+ )
+ await store.getState().fetchClaudeUsage()
+ expect(store.getState().claudeUsageFailure?.refresh ?? false).toBe(
+ outcome !== 'completed' &&
+ outcome !== 'first-completion' &&
+ outcome !== 'intermediate-scanning'
+ )
+ }
+)
+
+it('retains unacknowledged setting writes through cache reads and never enables a disabled retry', async () => {
+ const { api, store, notify } = fixture()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ api.setEnabled.mockRejectedValueOnce(new Error('lost acknowledgement'))
+ await store.getState().setClaudeUsageEnabled(false)
+ const stop = store.getState().observeClaudeUsage()
+ await store.getState().fetchClaudeUsage()
+ expect(store.getState().claudeUsageFailure?.setting).toBe(true)
+ notify(false, { unavailable: true })
+ stop()
+ notify(true)
+ api.getScanState.mockResolvedValue({ ...scanState, enabled: false })
+ const remount = store.getState().observeClaudeUsage()
+ await store.getState().fetchClaudeUsage()
+ expect(store.getState().claudeUsageFailure).toMatchObject({ terminal: false, setting: true })
+ api.refresh.mockClear()
+ await store.getState().refreshClaudeUsage()
+ expect(api.refresh).not.toHaveBeenCalled()
+ expect(api.setEnabled).toHaveBeenCalledTimes(1)
+ await store.getState().setClaudeUsageEnabled(false)
+ expect(store.getState().claudeUsageFailure).toBeNull()
+ remount()
+})
+
+it('does not treat an unsuccessful forced command as ordinary newer-scan recovery', async () => {
+ const { api, store } = fixture()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ api.refresh.mockRejectedValueOnce(new Error('lost Refresh'))
+ await store.getState().refreshClaudeUsage()
+ api.getSnapshot.mockResolvedValueOnce(snapshot).mockResolvedValueOnce({
+ ...snapshot,
+ scanState: { ...scanState, lastScanCompletedAt: 2 }
+ })
+ api.refresh.mockResolvedValueOnce({ ...scanState, lastScanError: 'scan failed' })
+ await store.getState().refreshClaudeUsage()
+ expect(store.getState().claudeUsageFailure?.refresh).toBe(true)
+})
diff --git a/src/renderer/src/store/slices/usage/usage-provider-slices.ts b/src/renderer/src/store/slices/usage/usage-provider-slices.ts
index 164eb61df6d..5cb6c708181 100644
--- a/src/renderer/src/store/slices/usage/usage-provider-slices.ts
+++ b/src/renderer/src/store/slices/usage/usage-provider-slices.ts
@@ -1,283 +1,17 @@
-import type { StateCreator } from 'zustand'
+import { createUsageProviderSlice } from './usage-provider-slice'
import type {
- ClaudeUsageRange,
- ClaudeUsageScope,
- ClaudeUsageSnapshot
-} from '../../../../../shared/claude-usage-types'
-import type {
- CodexUsageRange,
- CodexUsageScope,
- CodexUsageSnapshot
-} from '../../../../../shared/codex-usage-types'
-import type {
- OpenCodeUsageRange,
- OpenCodeUsageScope,
- OpenCodeUsageSnapshot
-} from '../../../../../shared/opencode-usage-types'
-import type {
- MuseUsageRange,
- MuseUsageScope,
- MuseUsageSnapshot
-} from '../../../../../shared/muse-usage-types'
-import type { AppState } from '../../types'
+ ClaudeUsageTypes,
+ CodexUsageTypes,
+ OpenCodeUsageTypes,
+ MuseUsageTypes
+} from './usage-provider-slice-contract'
-type UsageSnapshot = {
- scanState: {
- enabled: boolean
- isScanning: boolean
- lastScanCompletedAt: number | null
- lastScanError: string | null
- }
- summary: object
- daily: object[]
- modelBreakdown: object[]
- projectBreakdown: object[]
- recentSessions: object[]
-}
-
-type UsageProviderTypes<
- Scope extends string,
- Range extends string,
- Snapshot extends UsageSnapshot
-> = {
- scope: Scope
- range: Range
- snapshot: Snapshot
-}
-
-type UsageData> = {
- scope: T['scope']
- range: T['range']
- scanState: T['snapshot']['scanState'] | null
- summary: T['snapshot']['summary'] | null
- daily: T['snapshot']['daily']
- modelBreakdown: T['snapshot']['modelBreakdown']
- projectBreakdown: T['snapshot']['projectBreakdown']
- recentSessions: T['snapshot']['recentSessions']
-}
-
-type UsageApi> = {
- getScanState: () => Promise
- setEnabled: (args: { enabled: boolean }) => Promise
- refresh: (args?: { force?: boolean }) => Promise
- getSnapshot: (args: {
- scope: T['scope']
- range: T['range']
- limit?: number
- }) => Promise
-}
-
-type ProviderUsageSlice<
- Prefix extends string,
- Name extends string,
- T extends UsageProviderTypes
-> = {
- [K in keyof UsageData as `${Prefix}Usage${Capitalize}`]: UsageData[K]
-} & Record<`set${Name}UsageEnabled`, (enabled: boolean) => Promise> &
- Record<`set${Name}UsageScope`, (scope: T['scope']) => Promise> &
- Record<`set${Name}UsageRange`, (range: T['range']) => Promise> &
- Record<`fetch${Name}Usage`, (opts?: { forceRefresh?: boolean }) => Promise> &
- Record<`enable${Name}Usage`, () => Promise> &
- Record<`refresh${Name}Usage`, () => Promise>
-
-type UsageProviderConfig<
- Prefix extends string,
- Name extends string,
- T extends UsageProviderTypes
-> = {
- prefix: Prefix
- name: Name
- initialScope: T['scope']
- initialRange: T['range']
- getApi: () => UsageApi
- hasCachedData: (scanState: T['snapshot']['scanState']) => boolean
-}
-
-const usageDataFields = [
- 'scope',
- 'range',
- 'scanState',
- 'summary',
- 'daily',
- 'modelBreakdown',
- 'projectBreakdown',
- 'recentSessions'
-] as const satisfies readonly (keyof UsageData>)[]
-
-function usageDataKey(prefix: string, field: string): string {
- return `${prefix}Usage${field[0].toUpperCase()}${field.slice(1)}`
-}
-
-function readUsageData>(
- state: AppState,
- prefix: string
-): UsageData {
- const values = state as unknown as Record
- return Object.fromEntries(
- usageDataFields.map((field) => [field, values[usageDataKey(prefix, field)]])
- ) as UsageData
-}
-
-function createUsagePatch>(
- prefix: string,
- patch: Partial>
-): Partial {
- return Object.fromEntries(
- usageDataFields
- .filter((field) => field in patch)
- .map((field) => [usageDataKey(prefix, field), patch[field]])
- ) as Partial
-}
-
-function createUsageProviderSlice<
- Prefix extends string,
- Name extends string,
- T extends UsageProviderTypes
->(
- config: UsageProviderConfig
-): StateCreator> {
- return (set, get) => {
- const update = (patch: Partial>): void =>
- set(createUsagePatch(config.prefix, patch))
- const read = (): UsageData => readUsageData(get(), config.prefix)
-
- const fetchUsage = async (opts?: { forceRefresh?: boolean }): Promise => {
- try {
- const api = config.getApi()
- const scanState = (await api.getScanState()) as T['snapshot']['scanState'] | undefined
- // Desktop-only usage APIs resolve undefined in paired web clients.
- if (!scanState) {
- return
- }
-
- const current = read()
- const preserveLoading =
- opts?.forceRefresh === true &&
- current.scanState?.enabled === true &&
- current.summary === null
- update({
- scanState: preserveLoading
- ? {
- ...scanState,
- isScanning: true,
- lastScanCompletedAt: null,
- lastScanError: null
- }
- : scanState
- })
- if (!scanState.enabled) {
- return
- }
-
- const selection = read()
- const snapshot = await api.getSnapshot({
- scope: selection.scope,
- range: selection.range,
- limit: 10
- })
- if (
- snapshot.scanState.lastScanCompletedAt !== null ||
- config.hasCachedData(snapshot.scanState)
- ) {
- update({
- ...snapshot,
- scanState:
- opts?.forceRefresh === true
- ? { ...snapshot.scanState, isScanning: true }
- : snapshot.scanState
- })
- } else {
- update({ scanState: { ...scanState, isScanning: true, lastScanError: null } })
- }
-
- await api.refresh({ force: opts?.forceRefresh ?? false })
- const refreshedSelection = read()
- update(
- await api.getSnapshot({
- scope: refreshedSelection.scope,
- range: refreshedSelection.range,
- limit: 10
- })
- )
- } catch (error) {
- console.error(`Failed to fetch ${config.name} usage:`, error)
- }
- }
-
- const setEnabled = async (enabled: boolean): Promise => {
- try {
- const nextScanState = (await config.getApi().setEnabled({ enabled })) as
- | T['snapshot']['scanState']
- | undefined
- if (!nextScanState) {
- return
- }
- update({
- scanState: enabled
- ? {
- ...nextScanState,
- isScanning: true,
- lastScanCompletedAt: null,
- lastScanError: null
- }
- : nextScanState,
- summary: null,
- daily: [],
- modelBreakdown: [],
- projectBreakdown: [],
- recentSessions: []
- })
- if (enabled) {
- await fetchUsage({ forceRefresh: true })
- }
- } catch (error) {
- console.error(`Failed to update ${config.name} usage setting:`, error)
- }
- }
-
- const initialData: UsageData = {
- scope: config.initialScope,
- range: config.initialRange,
- scanState: null,
- summary: null,
- daily: [],
- modelBreakdown: [],
- projectBreakdown: [],
- recentSessions: []
- }
-
- return {
- ...createUsagePatch(config.prefix, initialData),
- [`set${config.name}UsageEnabled`]: setEnabled,
- [`set${config.name}UsageScope`]: async (scope: T['scope']) => {
- update({ scope })
- await fetchUsage()
- },
- [`set${config.name}UsageRange`]: async (range: T['range']) => {
- update({ range })
- await fetchUsage()
- },
- [`fetch${config.name}Usage`]: fetchUsage,
- [`enable${config.name}Usage`]: () => setEnabled(true),
- [`refresh${config.name}Usage`]: () => fetchUsage({ forceRefresh: true })
- } as ProviderUsageSlice
- }
-}
-
-type ClaudeUsageTypes = UsageProviderTypes
-type CodexUsageTypes = UsageProviderTypes
-type OpenCodeUsageTypes = UsageProviderTypes<
- OpenCodeUsageScope,
- OpenCodeUsageRange,
- OpenCodeUsageSnapshot
->
-
-type MuseUsageTypes = UsageProviderTypes
-
-export type ClaudeUsageSlice = ProviderUsageSlice<'claude', 'Claude', ClaudeUsageTypes>
-export type CodexUsageSlice = ProviderUsageSlice<'codex', 'Codex', CodexUsageTypes>
-export type OpenCodeUsageSlice = ProviderUsageSlice<'openCode', 'OpenCode', OpenCodeUsageTypes>
-export type MuseUsageSlice = ProviderUsageSlice<'muse', 'Muse', MuseUsageTypes>
+export type {
+ ClaudeUsageSlice,
+ CodexUsageSlice,
+ OpenCodeUsageSlice,
+ MuseUsageSlice
+} from './usage-provider-slice-contract'
export const createClaudeUsageSlice = createUsageProviderSlice<
'claude',
diff --git a/src/renderer/src/store/slices/usage/usage-provider-types.ts b/src/renderer/src/store/slices/usage/usage-provider-types.ts
new file mode 100644
index 00000000000..3b45a97ddaf
--- /dev/null
+++ b/src/renderer/src/store/slices/usage/usage-provider-types.ts
@@ -0,0 +1,30 @@
+export type UsageFailure = {
+ terminal: boolean
+ read: boolean
+ refresh: boolean
+ setting: boolean
+}
+
+export type UsageSnapshot = {
+ scanState: {
+ enabled: boolean
+ isScanning: boolean
+ lastScanCompletedAt: number | null
+ lastScanError: string | null
+ }
+ summary: object
+ daily: object[]
+ modelBreakdown: object[]
+ projectBreakdown: object[]
+ recentSessions: object[]
+}
+
+export type UsageProviderTypes<
+ Scope extends string,
+ Range extends string,
+ Snapshot extends UsageSnapshot
+> = {
+ scope: Scope
+ range: Range
+ snapshot: Snapshot
+}
diff --git a/src/renderer/src/store/slices/usage/use-usage-observation.ts b/src/renderer/src/store/slices/usage/use-usage-observation.ts
new file mode 100644
index 00000000000..151b723cf36
--- /dev/null
+++ b/src/renderer/src/store/slices/usage/use-usage-observation.ts
@@ -0,0 +1,5 @@
+import { useEffect } from 'react'
+
+export function useUsageObservation(observe: () => () => void): void {
+ useEffect(() => observe(), [observe])
+}
diff --git a/src/shared/backend-port-rpc.test.ts b/src/shared/backend-port-rpc.test.ts
new file mode 100644
index 00000000000..4fa3e35546f
--- /dev/null
+++ b/src/shared/backend-port-rpc.test.ts
@@ -0,0 +1,96 @@
+import { MessageChannel, type MessagePort } from 'node:worker_threads'
+import { afterEach, expect, it, vi } from 'vitest'
+import { BackendPortRpc, type BackendPort } from './backend-port-rpc'
+
+const connections: BackendPortRpc[] = []
+const ports: MessagePort[] = []
+function adapt(port: MessagePort): BackendPort {
+ ports.push(port)
+ return {
+ postMessage: (message) => port.postMessage(message),
+ onMessage(listener) {
+ port.on('message', listener)
+ return () => {
+ port.off('message', listener)
+ }
+ },
+ onClose(listener) {
+ port.on('close', listener)
+ return () => {
+ port.off('close', listener)
+ }
+ },
+ close: () => port.close()
+ }
+}
+function pair(invoke: (method: string, params: unknown, signal: AbortSignal) => Promise) {
+ const channel = new MessageChannel()
+ const server = new BackendPortRpc(adapt(channel.port1), 'backend-1', invoke)
+ const client = new BackendPortRpc(adapt(channel.port2), 'renderer-1', async () => null)
+ connections.push(server, client)
+ return { client, server }
+}
+afterEach(() => {
+ connections.splice(0).forEach((connection) => connection.close())
+ ports.splice(0).forEach((port) => port.close())
+})
+it('transfers a report directly and rejects outstanding calls on document disconnect', async () => {
+ const started = Promise.withResolvers()
+ const release = Promise.withResolvers()
+ const { client, server } = pair(async (method, params, signal) => {
+ if (method === 'snapshot') {
+ return { summary: { inputTokens: 123 }, selection: params }
+ }
+ started.resolve(signal)
+ return release.promise
+ })
+ await expect(client.call('snapshot', { range: '30d' })).resolves.toEqual({
+ summary: { inputTokens: 123 },
+ selection: { range: '30d' }
+ })
+ const pending = client.call('refresh')
+ const rejected = expect(pending).rejects.toThrow()
+ const signal = await started.promise
+ server.close()
+ await rejected
+ expect(signal.aborted).toBe(true)
+ release.resolve(null)
+})
+it('bounds pending requests and admits the next request after consumption', async () => {
+ {
+ const release = Promise.withResolvers()
+ const { client, server } = pair(async () => {
+ await release.promise
+ return { inputTokens: 123 }
+ })
+ const calls = Array.from({ length: 32 }, () => client.call('snapshot'))
+ await expect(client.call('snapshot')).rejects.toThrow()
+ release.resolve()
+ expect(await Promise.all(calls)).toEqual(
+ Array.from({ length: 32 }, () => ({ inputTokens: 123 }))
+ )
+ await expect(client.call('snapshot')).resolves.toEqual({ inputTokens: 123 })
+ client.close()
+ server.close()
+ }
+})
+it('does not let an early acknowledgement bypass the in-flight request bound', async () => {
+ const channel = new MessageChannel()
+ const pending = Promise.withResolvers()
+ const invoked = Promise.withResolvers()
+ const disconnected = Promise.withResolvers()
+ const invoke = vi.fn(async () => {
+ invoked.resolve()
+ return pending.promise
+ })
+ connections.push(
+ new BackendPortRpc(adapt(channel.port1), 'backend-1', invoke, () => disconnected.resolve())
+ )
+ ports.push(channel.port2)
+ channel.port2.postMessage({ id: '1', method: 'refresh' })
+ await invoked.promise
+ channel.port2.postMessage({ ack: '1' })
+ await disconnected.promise
+ expect(invoke).toHaveBeenCalledTimes(1)
+ pending.resolve(null)
+})
diff --git a/src/shared/backend-port-rpc.ts b/src/shared/backend-port-rpc.ts
new file mode 100644
index 00000000000..dc73589b2c8
--- /dev/null
+++ b/src/shared/backend-port-rpc.ts
@@ -0,0 +1,153 @@
+import { BackendRequestSchema } from './backend-service-contract'
+import { RuntimeRpcEnvelopeSchema } from './runtime-rpc-envelope'
+
+export type BackendPort = {
+ postMessage(message: unknown): void
+ onMessage(listener: (message: unknown) => void): () => void
+ onClose(listener: () => void): () => void
+ close(): void
+}
+
+type Pending = { resolve(value: unknown): void; reject(error: Error): void }
+const MAX_REQUESTS = 32
+
+/** Responses retain a credit until consumed, bounding a slow document's native port queue. */
+export class BackendPortRpc {
+ private readonly pending = new Map()
+ private readonly admitted = new Map()
+ private readonly lifetime = new AbortController()
+ private nextId = 1
+ private closed = false
+ private readonly detach: (() => void)[]
+
+ constructor(
+ private readonly port: BackendPort,
+ private readonly incarnation: string,
+ private readonly invoke: (
+ method: string,
+ params: unknown,
+ signal: AbortSignal
+ ) => Promise,
+ private readonly onDisconnect: () => void = () => {}
+ ) {
+ this.detach = [
+ port.onMessage((message) => {
+ void this.receive(message).catch(() => this.close())
+ }),
+ port.onClose(() => this.close())
+ ]
+ }
+
+ call(method: string, params?: unknown): Promise {
+ if (this.closed) {
+ return Promise.reject(new Error('Orca disconnected. Try again after it reconnects.'))
+ }
+ if (this.pending.size >= MAX_REQUESTS) {
+ return Promise.reject(
+ new Error('Too many usage requests. Try again when the current requests finish.')
+ )
+ }
+ const id = String(this.nextId++)
+ return new Promise((resolve, reject) => {
+ this.pending.set(id, { resolve, reject })
+ try {
+ const request = { id, method, params }
+ this.port.postMessage(request)
+ } catch (error) {
+ this.pending.delete(id)
+ reject(error instanceof Error ? error : new Error(String(error)))
+ }
+ })
+ }
+
+ close(): void {
+ if (this.closed) {
+ return
+ }
+ this.closed = true
+ this.lifetime.abort()
+ for (const detach of this.detach) {
+ detach()
+ }
+ this.port.close()
+ for (const call of this.pending.values()) {
+ call.reject(new Error('Orca disconnected. Try again after it reconnects.'))
+ }
+ this.pending.clear()
+ this.admitted.clear()
+ this.onDisconnect()
+ }
+
+ private async receive(message: unknown): Promise {
+ if (this.closed) {
+ return
+ }
+ if (
+ message &&
+ typeof message === 'object' &&
+ 'ack' in message &&
+ typeof message.ack === 'string'
+ ) {
+ if (this.admitted.get(message.ack) !== true) {
+ this.close()
+ return
+ }
+ this.admitted.delete(message.ack)
+ return
+ }
+ const response = RuntimeRpcEnvelopeSchema.safeParse(message)
+ if (response.success && !('_keepalive' in response.data)) {
+ const reply = response.data
+ const call = this.pending.get(reply.id)
+ if (!call) {
+ return
+ }
+ this.pending.delete(reply.id)
+ this.port.postMessage({ ack: reply.id })
+ if (reply.ok) {
+ call.resolve(reply.result)
+ } else {
+ call.reject(Object.assign(new Error(reply.error.message), { code: reply.error.code }))
+ }
+ return
+ }
+ const parsed = BackendRequestSchema.safeParse(message)
+ if (
+ !parsed.success ||
+ this.admitted.size >= MAX_REQUESTS ||
+ this.admitted.has(parsed.data.id)
+ ) {
+ this.close()
+ return
+ }
+ const request = parsed.data
+ this.admitted.set(request.id, false)
+ let reply
+ try {
+ reply = {
+ id: request.id,
+ ok: true,
+ result: await this.invoke(request.method, request.params, this.lifetime.signal),
+ _meta: { runtimeId: this.incarnation }
+ }
+ } catch (error) {
+ reply = {
+ id: request.id,
+ ok: false,
+ error: {
+ code: 'runtime_error',
+ message: error instanceof Error ? error.message : 'Usage operation failed. Try again.'
+ },
+ _meta: { runtimeId: this.incarnation }
+ }
+ }
+ if (!this.closed) {
+ try {
+ this.admitted.set(request.id, true)
+ this.port.postMessage(reply)
+ } catch {
+ this.close()
+ }
+ }
+ }
+}
diff --git a/src/shared/backend-service-contract.ts b/src/shared/backend-service-contract.ts
new file mode 100644
index 00000000000..0775ed3708a
--- /dev/null
+++ b/src/shared/backend-service-contract.ts
@@ -0,0 +1,75 @@
+import { z } from 'zod'
+import type { RuntimeRpcResponse } from './runtime-rpc-envelope'
+import type { AutomationRunUsage } from './automations/history/automation-run'
+
+export const BACKEND_PORT_REQUEST = 'backend:connect'
+export const BACKEND_PORT_DELIVERY = 'backend:port'
+export const BACKEND_CONNECTION_CHANGED = 'backend:connectionChanged'
+export const BACKEND_UTILITY_PROCESS_NAME = 'Orca backend'
+
+export type BackendConnectionPurpose = 'demand' | 'recovery'
+export type BackendConnectionStatus = { connected: boolean; unavailable?: boolean }
+export type BackendConnectionDetail = { initial?: boolean; unavailable?: boolean }
+export type BackendConnectionListener = (
+ connected: boolean,
+ detail?: BackendConnectionDetail
+) => void
+
+export const UsageProviderSchema = z.enum([
+ 'claudeUsage',
+ 'codexUsage',
+ 'openCodeUsage',
+ 'museUsage'
+])
+export type UsageProviderKey = z.infer
+export const UsageActionSchema = z.enum([
+ 'getScanState',
+ 'setEnabled',
+ 'refresh',
+ 'getSnapshot',
+ 'getSummary',
+ 'getDaily',
+ 'getBreakdown',
+ 'getRecentSessions'
+])
+export type UsageAction = z.infer
+export type BackendResponse = RuntimeRpcResponse
+
+export const BackendBootstrapSchema = z
+ .object({
+ protocol: z.literal(1),
+ requiredCapabilities: z.array(z.string().max(128)).max(32),
+ services: z.tuple([z.literal('usage')]),
+ build: z.string().max(256),
+ paths: z.object({
+ userData: z.string().min(1)
+ }),
+ appPath: z.string().min(1),
+ isPackaged: z.boolean()
+ })
+ .strip()
+export type BackendBootstrap = z.infer
+export const BACKEND_CAPABILITIES = [
+ 'usage',
+ 'usage.catalog',
+ 'usage.claudeProfileDirs',
+ 'usage.telemetry'
+] as const
+
+export const BackendRequestSchema = z
+ .object({
+ id: z.string().min(1).max(128),
+ method: z.string().min(1).max(128),
+ params: z.unknown().optional()
+ })
+ .strip()
+
+export type AutomationUsageInput = {
+ worktreeId: string | null
+ terminalSessionId: string | null
+ startedAt: number | null
+ completedAt: number | null
+}
+export type AutomationUsageClient = {
+ getAutomationRunUsage(input: AutomationUsageInput): Promise
+}