diff --git a/package.json b/package.json index bd2cfa65bee..43f1355ea99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.2-rc.8", + "version": "1.4.2-rc.9", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/src/cli/index.ts b/src/cli/index.ts index 00a96e9043f..d80789c4772 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -47,11 +47,19 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath) const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code') const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment') + // Why: pass `null` (not `undefined`) when remote selection is suppressed + // so the RuntimeClient default parameter does not re-activate the + // ORCA_PAIRING_CODE / ORCA_ENVIRONMENT env-var fallback for commands + // that must run locally (environment / serve). const client = new RuntimeClient( undefined, undefined, - typeof pairingCode === 'string' ? pairingCode : undefined, - typeof environmentSelector === 'string' ? environmentSelector : undefined + typeof pairingCode === 'string' ? pairingCode : ignoreRemoteSelection ? null : undefined, + typeof environmentSelector === 'string' + ? environmentSelector + : ignoreRemoteSelection + ? null + : undefined ) await dispatch(parsed.commandPath, { flags: parsed.flags, diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index dbeaa3e5520..3cb87f33ecf 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -290,6 +290,50 @@ describe('AgentHookServer listener replay', () => { } }) + it('maps registered legacy numeric HTTP pane keys to stable pane keys', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + server.registerPaneKeyAlias('tab-1:0', PANE) + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody( + { + hook_event_name: 'UserPromptSubmit', + prompt: 'legacy pane' + }, + { paneKey: 'tab-1:0' } + ) + ) + }) + expect(response.status).toBe(204) + + const listener = vi.fn() + server.setListener(listener) + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: expect.objectContaining({ + state: 'working', + prompt: 'legacy pane', + agentType: 'claude' + }) + }) + ) + } finally { + server.stop() + } + }) + it('tracks hook posts with an empty paneKey before dropping them', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) @@ -2127,6 +2171,112 @@ describe('Last-status persistence', () => { } }) + it('hydrates registered legacy numeric pane keys as stable pane status entries', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + 'tab-1:0': { + paneKey: 'tab-1:0', + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + receivedAt: recentTs(), + stateStartedAt: recentTs(-1000), + payload: { state: 'working', prompt: 'legacy cached', agentType: 'claude' } + } + } + }), + 'utf8' + ) + const server = new AgentHookServer() + server.registerPaneKeyAlias('tab-1:0', PANE) + await server.start({ + env: 'production', + userDataPath + }) + try { + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + state: 'working', + prompt: 'legacy cached', + agentType: 'claude' + }) + ]) + } finally { + server.stop() + } + }) + + it('clears hydrated stable statuses when their persisted legacy alias PTY is cleared', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + 'tab-1:0': { + paneKey: 'tab-1:0', + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + receivedAt: recentTs(), + stateStartedAt: recentTs(-1000), + payload: { state: 'working', prompt: 'legacy cached', agentType: 'claude' } + } + } + }), + 'utf8' + ) + const server = new AgentHookServer() + const statusListener = vi.fn() + server.registerPaneKeyAlias('tab-1:0', PANE, 'pty-1') + server.subscribeStatusChanges(statusListener) + await server.start({ + env: 'production', + userDataPath + }) + try { + expect(server.getStatusSnapshot()).toHaveLength(1) + + server.clearPaneKeyAliasesForPty('pty-1') + + expect(server.getStatusSnapshot()).toEqual([]) + expect(statusListener).toHaveBeenCalledWith([]) + } finally { + server.stop() + } + }) + + it('does not clear a stable status when alias cleanup no longer owns that pane', () => { + const server = new AgentHookServer() + server.registerPaneKeyAlias('tab-1:0', PANE, 'old-pty') + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', agentType: 'claude' } + }, + 'conn-1' + ) + + server.clearPaneKeyAliasesForPty('old-pty', { shouldClearStablePaneKey: () => false }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + agentType: 'claude' + }) + ]) + }) + it('drops a hydrate entry whose tabId disagrees with the paneKey prefix', async () => { mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) writeFileSync( @@ -2318,6 +2468,40 @@ describe('AgentHookServer ingestRemote', () => { expect(server.getStatusSnapshot()).toEqual([]) }) + it('maps registered legacy numeric relay pane keys to stable pane keys', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.registerPaneKeyAlias('tab-1:0', PANE) + server.setListener(listener) + server.ingestRemote( + { paneKey: 'tab-1:0', tabId: 'tab-1', worktreeId: 'wt-1', payload }, + 'conn-1' + ) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: 'conn-1', + payload + }) + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + state: 'working', + prompt: 'p' + }) + ]) + }) + it('drops remote relay envelopes whose tabId disagrees with the paneKey tab', () => { const server = new AgentHookServer() const payload = parseAgentStatusPayload( diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 072874998e7..7fe76457992 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -39,7 +39,8 @@ import { type AgentStatusState, normalizeAgentStatusPayload } from '../../shared/agent-status-types' -import { parsePaneKey } from '../../shared/stable-pane-id' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import type { LegacyPaneKeyAliasEntry } from '../../shared/types' export type { AgentHookSource } @@ -63,6 +64,12 @@ export type AgentHookStatusChangeEntry = { } type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void +type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void +type PaneKeyAliasEntry = { + stablePaneKey: string + ptyId: string | null + updatedAt: number +} // Why: name of the on-disk cache that survives Orca restart. Lives next to // the endpoint file in userData/agent-hooks/ so all hook-server-owned cross- @@ -215,6 +222,8 @@ export class AgentHookServer { // Why: hydrated last-status rows are useful UI continuity, but they are not // evidence of live agent work in this main-process runtime. private runtimeObservedStatusPaneKeys = new Set() + private legacyPaneKeyAliases = new Map() + private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null // Why: full path to the on-disk last-status cache. Set in start() from // userDataPath. Null when the server runs without a userDataPath (e.g. // tests that skip the userDataPath option) — in that case, persistence is @@ -305,6 +314,121 @@ export class AgentHookServer { } } + setPaneKeyAliasPersistenceListener(listener: PaneKeyAliasPersistenceListener | null): void { + this.paneKeyAliasPersistenceListener = listener + } + + private getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] { + return Array.from(this.legacyPaneKeyAliases.entries()).flatMap(([legacyPaneKey, entry]) => + entry.ptyId + ? [ + { + ptyId: entry.ptyId, + legacyPaneKey, + stablePaneKey: entry.stablePaneKey, + updatedAt: entry.updatedAt + } + ] + : [] + ) + } + + private notifyPaneKeyAliasPersistenceListener(): void { + this.paneKeyAliasPersistenceListener?.(this.getPersistedPaneKeyAliases()) + } + + registerPaneKeyAlias( + legacyPaneKey: string, + stablePaneKey: string, + ptyId?: string, + updatedAt = Date.now(), + options?: { overwriteExisting?: boolean } + ): void { + const legacy = parseLegacyNumericPaneKey(legacyPaneKey) + const stable = parsePaneKey(stablePaneKey) + if (!legacy || !stable || legacy.tabId !== stable.tabId) { + return + } + const existing = this.legacyPaneKeyAliases.get(legacy.paneKey) + if (existing && options?.overwriteExisting === false) { + return + } + const normalizedPtyId = + typeof ptyId === 'string' && ptyId.trim().length > 0 ? ptyId.trim() : existing?.ptyId + const normalizedUpdatedAt = + Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : (existing?.updatedAt ?? Date.now()) + if ( + existing && + existing.stablePaneKey === stablePaneKey && + existing.ptyId === (normalizedPtyId ?? null) && + existing.updatedAt === normalizedUpdatedAt + ) { + return + } + this.legacyPaneKeyAliases.set(legacy.paneKey, { + stablePaneKey, + ptyId: normalizedPtyId ?? null, + updatedAt: normalizedUpdatedAt + }) + if (normalizedPtyId) { + this.notifyPaneKeyAliasPersistenceListener() + } + } + + clearPaneKeyAliasesForPty( + ptyId: string, + options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean } + ): void { + let aliasChanged = false + let statusChanged = false + for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { + if (entry.ptyId === ptyId) { + this.legacyPaneKeyAliases.delete(legacyPaneKey) + clearPaneCacheState(this.state, legacyPaneKey) + const shouldClearStablePaneKey = + options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true + if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) { + statusChanged = true + } + if (shouldClearStablePaneKey) { + // Why: after hydrate, legacy rows are stored under the stable key. If + // this PTY is later proven dead before ptyPaneKey is rebuilt, alias + // cleanup is the only path that can evict that retained status. + clearPaneCacheState(this.state, entry.stablePaneKey) + this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey) + } + aliasChanged = true + } + } + if (aliasChanged) { + this.notifyPaneKeyAliasPersistenceListener() + } + if (statusChanged) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + } + + private resolvePaneKeyAlias(paneKey: string): string { + return this.legacyPaneKeyAliases.get(paneKey)?.stablePaneKey ?? paneKey + } + + private normalizeHookBodyPaneKeyAlias(body: unknown): unknown { + if (typeof body !== 'object' || body === null) { + return body + } + const record = body as Record + const rawPaneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' + const stablePaneKey = this.legacyPaneKeyAliases.get(rawPaneKey)?.stablePaneKey + if (!stablePaneKey) { + return body + } + // Why: pre-migration live shells keep posting their immutable numeric + // ORCA_PANE_KEY. The reattach path proves the UUID leaf once, then this + // bridge lets hook caches and renderer state use only the stable key. + return { ...record, paneKey: stablePaneKey } + } + /** Ingest a payload that arrived over the relay JSON-RPC channel rather * than the local HTTP server. `connectionId` is the SshChannelMultiplexer * identity Orca holds (the wire envelope carries connectionId: null and @@ -341,7 +465,7 @@ export class AgentHookServer { // Why: match the listener's HTTP path — `normalizeHookPayload` trims and // length-caps paneKey before caching, so the cache key here must follow // the same rule or remote-vs-local events for the same pane would diverge. - const paneKey = envelope.paneKey.trim() + const paneKey = this.resolvePaneKeyAlias(envelope.paneKey.trim()) const parsedPaneKey = parsePaneKey(paneKey) if (paneKey.length === 0) { track('agent_hook_unattributed', { reason: 'empty_pane_key' }) @@ -460,7 +584,12 @@ export class AgentHookServer { } trackEmptyPaneKeyHook(body) - const normalized = normalizeHookPayload(this.state, source, body, this.env) + const normalized = normalizeHookPayload( + this.state, + source, + this.normalizeHookBodyPaneKeyAlias(body), + this.env + ) if (normalized) { const enriched = this.attachStatusTiming(normalized) this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) @@ -527,6 +656,7 @@ export class AgentHookServer { this.lastStatusFilePath = null this.lastWrittenJson = null this.runtimeObservedStatusPaneKeys.clear() + this.legacyPaneKeyAliases.clear() clearAllListenerCaches(this.state) this.notifyStatusChangeListeners() } @@ -539,24 +669,37 @@ export class AgentHookServer { * lands. clearPaneState (which wipes all three caches) is the right shape * only for PTY-teardown. */ dropStatusEntry(paneKey: string): void { - if (!this.state.lastStatusByPaneKey.has(paneKey)) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + if (!this.state.lastStatusByPaneKey.has(resolvedPaneKey)) { return } - this.state.lastStatusByPaneKey.delete(paneKey) - this.runtimeObservedStatusPaneKeys.delete(paneKey) + this.state.lastStatusByPaneKey.delete(resolvedPaneKey) + this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) this.scheduleStatusPersist() this.notifyStatusChangeListeners() } clearPaneState(paneKey: string): void { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) // Why: only schedule a write when we actually evicted a status entry — // dropping prompt/tool caches for a pane that never produced a hook // event does not change the on-disk file, and skipping the write avoids // re-stat'ing on every dead-pane teardown. - const hadStatus = this.state.lastStatusByPaneKey.has(paneKey) - clearPaneCacheState(this.state, paneKey) + const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey) + clearPaneCacheState(this.state, resolvedPaneKey) + let clearedAlias = false + for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) { + if (stablePaneKey.stablePaneKey === resolvedPaneKey) { + this.legacyPaneKeyAliases.delete(legacyPaneKey) + clearPaneCacheState(this.state, legacyPaneKey) + clearedAlias = true + } + } + if (clearedAlias) { + this.notifyPaneKeyAliasPersistenceListener() + } if (hadStatus) { - this.runtimeObservedStatusPaneKeys.delete(paneKey) + this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) this.scheduleStatusPersist() this.notifyStatusChangeListeners() } @@ -655,9 +798,14 @@ export class AgentHookServer { // tick. const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS for (const [paneKey, rawEntry] of Object.entries(entries)) { - const entry = sanitizeHydratedEntry(paneKey, rawEntry) + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + const rawResolvedEntry = + resolvedPaneKey === paneKey || typeof rawEntry !== 'object' || rawEntry === null + ? rawEntry + : { ...(rawEntry as Record), paneKey: resolvedPaneKey } + const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry) if (entry && entry.receivedAt >= ttlCutoff) { - this.state.lastStatusByPaneKey.set(paneKey, entry) + this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) hydrated += 1 } else { dropped += 1 diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts index ef016d486ca..acfc596339a 100644 --- a/src/main/automations/service.test.ts +++ b/src/main/automations/service.test.ts @@ -88,4 +88,105 @@ describe('AutomationService', () => { new Date('2026-05-14T09:00:00').getTime() ) }) + + it('attaches provider usage when a completed run can be attributed', async () => { + vi.setSystemTime(new Date('2026-05-13T10:00:00')) + const store = await createStore() + store.addRepo(makeRepo()) + const automation = store.createAutomation({ + name: 'Costed check', + prompt: 'Check spend', + agentId: 'claude', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + const run = store.createAutomationRun(automation, Date.now(), 'manual') + store.updateAutomationRun({ + runId: run.id, + status: 'dispatched', + workspaceId: 'wt1', + terminalSessionId: 'tab-1', + error: null + }) + const usage = { + status: 'known' as const, + provider: 'claude' as const, + model: 'claude-sonnet-4-6', + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 25, + cacheWriteTokens: 10, + reasoningOutputTokens: null, + totalTokens: 185, + estimatedCostUsd: 0.001, + estimatedCostSource: 'api_equivalent' as const, + providerSessionId: 'provider-session-1', + attribution: 'provider_session_time_window' as const, + collectedAt: Date.now(), + unavailableReason: null, + unavailableMessage: null + } + const getAutomationRunUsage = vi.fn().mockResolvedValue(usage) + const service = new AutomationService(store, { + tickMs: 60_000, + claudeUsage: { getAutomationRunUsage } as never + }) + + const updated = await service.markDispatchResult({ + runId: run.id, + status: 'completed', + workspaceId: 'wt1', + terminalSessionId: 'tab-1', + error: null + }) + + expect(updated.usage).toEqual(usage) + expect(getAutomationRunUsage).toHaveBeenCalledWith({ + worktreeId: 'wt1', + terminalSessionId: 'tab-1', + startedAt: expect.any(Number), + completedAt: expect.any(Number) + }) + }) + + it('records unsupported usage cleanly for completed agents without local usage stores', async () => { + vi.setSystemTime(new Date('2026-05-13T10:00:00')) + const store = await createStore() + store.addRepo(makeRepo()) + const automation = store.createAutomation({ + name: 'Gemini check', + prompt: 'Check spend', + agentId: 'gemini', + projectId: 'r1', + workspaceMode: 'existing', + workspaceId: 'wt1', + timezone: 'UTC', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: new Date('2026-05-13T00:00:00Z').getTime() + }) + const run = store.createAutomationRun(automation, Date.now(), 'manual') + store.updateAutomationRun({ + runId: run.id, + status: 'dispatched', + workspaceId: 'wt1', + terminalSessionId: 'tab-1', + error: null + }) + const service = new AutomationService(store, { tickMs: 60_000 }) + + const updated = await service.markDispatchResult({ + runId: run.id, + status: 'completed', + workspaceId: 'wt1', + terminalSessionId: 'tab-1', + error: null + }) + + expect(updated.usage?.status).toBe('unavailable') + expect(updated.usage?.unavailableReason).toBe('provider_unsupported') + }) }) diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index ac3111f725b..888fa9d580d 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -4,8 +4,12 @@ import type { Automation, AutomationDispatchRequest, AutomationDispatchResult, - AutomationRun + AutomationRun, + AutomationRunStatus, + AutomationRunUsage } from '../../shared/automations-types' +import type { ClaudeUsageStore } from '../claude-usage/store' +import type { CodexUsageStore } from '../codex-usage/store' const DEFAULT_TICK_MS = 60 * 1000 @@ -16,10 +20,17 @@ export class AutomationService { private webContents: WebContents | null = null private rendererReady = false private evaluating = false + private readonly claudeUsage: ClaudeUsageStore | null + private readonly codexUsage: CodexUsageStore | null - constructor(store: Store, opts: { tickMs?: number } = {}) { + constructor( + store: Store, + opts: { tickMs?: number; claudeUsage?: ClaudeUsageStore; codexUsage?: CodexUsageStore } = {} + ) { this.store = store this.tickMs = opts.tickMs ?? DEFAULT_TICK_MS + this.claudeUsage = opts.claudeUsage ?? null + this.codexUsage = opts.codexUsage ?? null } setWebContents(webContents: WebContents | null): void { @@ -62,8 +73,97 @@ export class AutomationService { return run } - markDispatchResult(result: AutomationDispatchResult): AutomationRun { - return this.store.updateAutomationRun(result) + async markDispatchResult(result: AutomationDispatchResult): Promise { + const run = this.store.updateAutomationRun(result) + if (!isFinalRunStatus(run.status)) { + return run + } + const usage = await this.collectRunUsage(run) + return this.store.updateAutomationRun({ + runId: run.id, + status: run.status, + workspaceId: run.workspaceId, + terminalSessionId: run.terminalSessionId, + usage, + error: run.error + }) + } + + private async collectRunUsage(run: AutomationRun): Promise { + const automation = this.store.listAutomations().find((entry) => entry.id === run.automationId) + const collectedAt = Date.now() + const unavailable = ( + provider: AutomationRunUsage['provider'], + unavailableReason: AutomationRunUsage['unavailableReason'], + unavailableMessage: string + ): AutomationRunUsage => ({ + status: 'unavailable', + provider, + model: null, + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + reasoningOutputTokens: null, + totalTokens: null, + estimatedCostUsd: null, + estimatedCostSource: null, + providerSessionId: null, + attribution: null, + collectedAt, + unavailableReason, + unavailableMessage + }) + + if (!automation || run.status !== 'completed') { + return unavailable( + automation?.agentId === 'codex' + ? 'codex' + : automation?.agentId === 'claude' + ? 'claude' + : null, + 'run_not_finished', + 'Usage is only collected for completed automation runs.' + ) + } + if (automation.executionTargetType === 'ssh') { + return unavailable( + automation.agentId === 'codex' + ? 'codex' + : automation.agentId === 'claude' + ? 'claude' + : null, + 'remote_usage_unavailable', + 'Remote automation usage is not available from local usage logs.' + ) + } + if (automation.agentId === 'claude') { + if (!this.claudeUsage) { + return unavailable('claude', 'scan_failed', 'Claude usage store is unavailable.') + } + return this.claudeUsage.getAutomationRunUsage({ + worktreeId: run.workspaceId, + terminalSessionId: run.terminalSessionId, + startedAt: run.startedAt, + completedAt: collectedAt + }) + } + if (automation.agentId === 'codex') { + if (!this.codexUsage) { + return unavailable('codex', 'scan_failed', 'Codex usage store is unavailable.') + } + return this.codexUsage.getAutomationRunUsage({ + worktreeId: run.workspaceId, + terminalSessionId: run.terminalSessionId, + startedAt: run.startedAt, + completedAt: collectedAt + }) + } + return unavailable( + null, + 'provider_unsupported', + 'This agent does not report usage to Orca yet.' + ) } private async evaluateDueRuns(): Promise { @@ -128,3 +228,13 @@ export class AutomationService { webContents.send('automations:dispatchRequested', payload) } } + +function isFinalRunStatus(status: AutomationRunStatus): boolean { + return ( + status === 'completed' || + status === 'dispatch_failed' || + status === 'skipped_missed' || + status === 'skipped_unavailable' || + status === 'skipped_needs_interactive_auth' + ) +} diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts index 2ae6a09990c..2baabbd6e98 100644 --- a/src/main/claude-usage/store.test.ts +++ b/src/main/claude-usage/store.test.ts @@ -257,4 +257,66 @@ describe('ClaudeUsageStore', () => { expect(summary.estimatedCostUsd).toBeCloseTo(8.07) }) + + it('returns automation usage for a single matching worktree session', async () => { + const worktreeId = 'repo-1::/workspace/repo-a' + const store = createStoreWithState({ + scanState: { + enabled: true, + lastScanStartedAt: 1, + lastScanCompletedAt: 2, + lastScanError: null + }, + sessions: [ + { + sessionId: 'session-1', + firstTimestamp: '2026-04-09T15:00:00.000Z', + lastTimestamp: '2026-04-09T15:05:00.000Z', + model: 'claude-sonnet-4-6', + lastCwd: '/workspace/repo-a', + lastGitBranch: 'feature/a', + primaryWorktreeId: worktreeId, + primaryRepoId: 'repo-1', + turnCount: 1, + totalInputTokens: 1000, + totalOutputTokens: 500, + totalCacheReadTokens: 200, + totalCacheWriteTokens: 100, + locationBreakdown: [ + { + locationKey: `worktree:${worktreeId}`, + projectLabel: 'Repo A', + repoId: 'repo-1', + worktreeId, + turnCount: 1, + inputTokens: 1000, + outputTokens: 500, + cacheReadTokens: 200, + cacheWriteTokens: 100 + } + ] + } + ] + }) + ;(store as unknown as { refresh: typeof store.refresh }).refresh = vi.fn().mockResolvedValue({ + enabled: true, + isScanning: false, + lastScanStartedAt: 1, + lastScanCompletedAt: 2, + lastScanError: null, + hasAnyClaudeData: true + }) + + const usage = await store.getAutomationRunUsage({ + worktreeId, + terminalSessionId: 'tab-1', + startedAt: new Date('2026-04-09T14:59:00.000Z').getTime(), + completedAt: new Date('2026-04-09T15:06:00.000Z').getTime() + }) + + expect(usage.status).toBe('known') + expect(usage.providerSessionId).toBe('session-1') + expect(usage.totalTokens).toBe(1800) + expect(usage.estimatedCostUsd).toBeCloseTo(0.010935) + }) }) diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index dbc2add004f..cadb45e84b5 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -12,6 +12,7 @@ import type { ClaudeUsageSessionRow, ClaudeUsageSummary } from '../../shared/claude-usage-types' +import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { ClaudeUsagePersistedState } from './types' @@ -19,6 +20,7 @@ import { createWorktreeRefs, getSessionProjectLabel, scanClaudeUsageFiles } from const SCHEMA_VERSION = 3 const STALE_MS = 5 * 60_000 +const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 // Why: capture the path after configureDevUserDataPath() but before app.setName() // mutates Electron's derived userData location, matching the persistence/store pattern. @@ -36,6 +38,13 @@ type ClaudeModelPricing = { cacheWriteAboveThreshold?: number } +type AutomationUsageLookupInput = { + worktreeId: string | null + terminalSessionId: string | null + startedAt: number | null + completedAt: number | null +} + const LONG_CONTEXT_THRESHOLD_TOKENS = 200_000 const SONNET_LONG_CONTEXT_PRICING = { thresholdTokens: LONG_CONTEXT_THRESHOLD_TOKENS, @@ -622,6 +631,123 @@ export class ClaudeUsageStore { }) } + async getAutomationRunUsage(input: AutomationUsageLookupInput): Promise { + const collectedAt = Date.now() + const unavailable = ( + unavailableReason: AutomationRunUsage['unavailableReason'], + unavailableMessage: string + ): AutomationRunUsage => ({ + status: 'unavailable', + provider: 'claude', + model: null, + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + reasoningOutputTokens: null, + totalTokens: null, + estimatedCostUsd: null, + estimatedCostSource: null, + providerSessionId: null, + attribution: null, + collectedAt, + unavailableReason, + unavailableMessage + }) + + if (!this.state.scanState.enabled) { + return unavailable('usage_not_enabled', 'Claude usage tracking is not enabled.') + } + if (!input.worktreeId || !input.startedAt || !input.completedAt) { + return unavailable('no_matching_session', 'Run session metadata is incomplete.') + } + + const scanState = await this.refresh(true) + if (scanState.lastScanError) { + return unavailable('scan_failed', scanState.lastScanError) + } + + const windowStart = input.startedAt - AUTOMATION_ATTRIBUTION_WINDOW_MS + const windowEnd = input.completedAt + AUTOMATION_ATTRIBUTION_WINDOW_MS + const candidates = this.state.sessions.filter((session) => { + const first = new Date(session.firstTimestamp).getTime() + const last = new Date(session.lastTimestamp).getTime() + if (!Number.isFinite(first) || !Number.isFinite(last)) { + return false + } + if (session.sessionId === input.terminalSessionId) { + return true + } + if (first < windowStart || first > windowEnd || last > windowEnd) { + return false + } + return session.locationBreakdown.some((entry) => entry.worktreeId === input.worktreeId) + }) + + if (candidates.length === 0) { + return unavailable('no_matching_session', 'No Claude usage session matched this run.') + } + if (candidates.length > 1) { + return unavailable( + 'ambiguous_session', + 'Multiple Claude usage sessions matched this run window.' + ) + } + + const session = candidates[0] + const scopedLocations = session.locationBreakdown.filter( + (entry) => entry.worktreeId === input.worktreeId + ) + const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown + const totals = locations.reduce( + (acc, entry) => { + acc.turns += entry.turnCount + acc.inputTokens += entry.inputTokens + acc.outputTokens += entry.outputTokens + acc.cacheReadTokens += entry.cacheReadTokens + acc.cacheWriteTokens += entry.cacheWriteTokens + return acc + }, + { + turns: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0 + } + ) + const estimatedCostUsd = estimateCostUsd( + session.model, + totals.inputTokens, + totals.outputTokens, + totals.cacheReadTokens, + totals.cacheWriteTokens + ) + + return { + status: 'known', + provider: 'claude', + model: session.model, + inputTokens: totals.inputTokens, + outputTokens: totals.outputTokens, + cacheReadTokens: totals.cacheReadTokens, + cacheWriteTokens: totals.cacheWriteTokens, + reasoningOutputTokens: null, + totalTokens: + totals.inputTokens + totals.outputTokens + totals.cacheReadTokens + totals.cacheWriteTokens, + estimatedCostUsd, + estimatedCostSource: estimatedCostUsd === null ? null : 'api_equivalent', + providerSessionId: session.sessionId, + // Why: Orca terminal tab ids and Claude usage session ids are different + // systems today, so attribution is intentionally limited to one local + // provider session in the run's worktree/time window. + attribution: 'provider_session_time_window', + collectedAt, + unavailableReason: null, + unavailableMessage: null + } + } + private getFilteredDaily(scope: ClaudeUsageScope, range: ClaudeUsageRange) { const cutoff = getRangeCutoff(range) return this.state.dailyAggregates.filter((entry) => { diff --git a/src/main/codex-usage/store.test.ts b/src/main/codex-usage/store.test.ts index 00686de5971..5012b7db68e 100644 --- a/src/main/codex-usage/store.test.ts +++ b/src/main/codex-usage/store.test.ts @@ -661,4 +661,101 @@ describe('CodexUsageStore', () => { } }) }) + + it('returns automation usage for a single matching worktree session', async () => { + const worktreeId = 'repo-1::/workspace/repo' + const store = createStoreWithState({ + scanState: { + enabled: true, + lastScanStartedAt: 1, + lastScanCompletedAt: 2, + lastScanError: null + }, + sessions: [ + { + sessionId: 'session-1', + firstTimestamp: '2026-04-10T15:00:00.000Z', + lastTimestamp: '2026-04-10T15:05:00.000Z', + primaryModel: 'gpt-5', + hasMixedModels: false, + primaryProjectLabel: 'Repo', + hasMixedLocations: false, + primaryWorktreeId: worktreeId, + primaryRepoId: 'repo-1', + eventCount: 1, + totalInputTokens: 1000, + totalCachedInputTokens: 400, + totalOutputTokens: 250, + totalReasoningOutputTokens: 100, + totalTokens: 1250, + hasInferredPricing: false, + locationBreakdown: [ + { + locationKey: `worktree:${worktreeId}`, + projectLabel: 'Repo', + repoId: 'repo-1', + worktreeId, + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1250, + hasInferredPricing: false + } + ], + modelBreakdown: [ + { + modelKey: 'gpt-5', + modelLabel: 'gpt-5', + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1250, + hasInferredPricing: false + } + ], + locationModelBreakdown: [ + { + locationKey: `worktree:${worktreeId}`, + modelKey: 'gpt-5', + modelLabel: 'gpt-5', + repoId: 'repo-1', + worktreeId, + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1250, + hasInferredPricing: false + } + ] + } + ] + }) + ;(store as unknown as { refresh: typeof store.refresh }).refresh = vi.fn().mockResolvedValue({ + enabled: true, + isScanning: false, + lastScanStartedAt: 1, + lastScanCompletedAt: 2, + lastScanError: null, + hasAnyCodexData: true + }) + + const usage = await store.getAutomationRunUsage({ + worktreeId, + terminalSessionId: 'tab-1', + startedAt: new Date('2026-04-10T14:59:00.000Z').getTime(), + completedAt: new Date('2026-04-10T15:06:00.000Z').getTime() + }) + + expect(usage.status).toBe('known') + expect(usage.providerSessionId).toBe('session-1') + expect(usage.cacheReadTokens).toBe(400) + expect(usage.reasoningOutputTokens).toBe(100) + expect(usage.estimatedCostUsd).toBeCloseTo(0.0033) + }) }) diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index 8699e4f49d1..cfd1f2908fc 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -12,6 +12,7 @@ import type { CodexUsageSessionRow, CodexUsageSummary } from '../../shared/codex-usage-types' +import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { CodexUsagePersistedState } from './types' @@ -19,6 +20,7 @@ import { createWorktreeRefs, scanCodexUsageFiles } from './scanner' const SCHEMA_VERSION = 3 const STALE_MS = 5 * 60_000 +const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 let _codexUsageFile: string | null = null @@ -32,6 +34,13 @@ type CodexModelPricing = { outputTiers?: TieredPrice[] } +type AutomationUsageLookupInput = { + worktreeId: string | null + terminalSessionId: string | null + startedAt: number | null + completedAt: number | null +} + const LONG_CONTEXT_THRESHOLD_TOKENS = 272_000 const MODEL_PRICING: Record = { @@ -635,6 +644,154 @@ export class CodexUsageStore { }) } + async getAutomationRunUsage(input: AutomationUsageLookupInput): Promise { + const collectedAt = Date.now() + const unavailable = ( + unavailableReason: AutomationRunUsage['unavailableReason'], + unavailableMessage: string + ): AutomationRunUsage => ({ + status: 'unavailable', + provider: 'codex', + model: null, + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + reasoningOutputTokens: null, + totalTokens: null, + estimatedCostUsd: null, + estimatedCostSource: null, + providerSessionId: null, + attribution: null, + collectedAt, + unavailableReason, + unavailableMessage + }) + + if (!this.state.scanState.enabled) { + return unavailable('usage_not_enabled', 'Codex usage tracking is not enabled.') + } + if (!input.worktreeId || !input.startedAt || !input.completedAt) { + return unavailable('no_matching_session', 'Run session metadata is incomplete.') + } + + const scanState = await this.refresh(true) + if (scanState.lastScanError) { + return unavailable('scan_failed', scanState.lastScanError) + } + + const windowStart = input.startedAt - AUTOMATION_ATTRIBUTION_WINDOW_MS + const windowEnd = input.completedAt + AUTOMATION_ATTRIBUTION_WINDOW_MS + const candidates = this.state.sessions.filter((session) => { + const first = new Date(session.firstTimestamp).getTime() + const last = new Date(session.lastTimestamp).getTime() + if (!Number.isFinite(first) || !Number.isFinite(last)) { + return false + } + if (session.sessionId === input.terminalSessionId) { + return true + } + if (first < windowStart || first > windowEnd || last > windowEnd) { + return false + } + return session.locationBreakdown.some((entry) => entry.worktreeId === input.worktreeId) + }) + + if (candidates.length === 0) { + return unavailable('no_matching_session', 'No Codex usage session matched this run.') + } + if (candidates.length > 1) { + return unavailable( + 'ambiguous_session', + 'Multiple Codex usage sessions matched this run window.' + ) + } + + const session = candidates[0] + const scopedLocations = session.locationBreakdown.filter( + (entry) => entry.worktreeId === input.worktreeId + ) + const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown + const totals = locations.reduce( + (acc, entry) => { + acc.events += entry.eventCount + acc.inputTokens += entry.inputTokens + acc.cachedInputTokens += entry.cachedInputTokens + acc.outputTokens += entry.outputTokens + acc.reasoningOutputTokens += entry.reasoningOutputTokens + acc.totalTokens += entry.totalTokens + return acc + }, + { + events: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0 + } + ) + const scopedModelRows = session.locationModelBreakdown.filter( + (entry) => entry.worktreeId === input.worktreeId + ) + const modelRows = scopedModelRows.length > 0 ? scopedModelRows : session.modelBreakdown + const modelLabels = [...new Set(modelRows.map((entry) => entry.modelLabel))] + let estimatedCostUsd = 0 + let hasKnownCost = false + if (scopedModelRows.length > 0) { + for (const modelRow of scopedModelRows) { + const cost = estimateCostUsd( + modelRow.modelKey, + modelRow.inputTokens, + modelRow.cachedInputTokens, + modelRow.outputTokens + ) + if (cost !== null) { + hasKnownCost = true + estimatedCostUsd += cost + } + } + } else if (!session.hasMixedModels) { + const cost = estimateCostUsd( + session.primaryModel, + totals.inputTokens, + totals.cachedInputTokens, + totals.outputTokens + ) + if (cost !== null) { + hasKnownCost = true + estimatedCostUsd += cost + } + } + + return { + status: 'known', + provider: 'codex', + model: + modelLabels.length === 1 + ? modelLabels[0] + : session.hasMixedModels + ? 'Mixed models' + : session.primaryModel, + inputTokens: totals.inputTokens, + outputTokens: totals.outputTokens, + cacheReadTokens: totals.cachedInputTokens, + cacheWriteTokens: null, + reasoningOutputTokens: totals.reasoningOutputTokens, + totalTokens: totals.totalTokens, + estimatedCostUsd: hasKnownCost ? estimatedCostUsd : null, + estimatedCostSource: hasKnownCost ? 'api_equivalent' : null, + providerSessionId: session.sessionId, + // Why: Orca terminal tab ids and Codex usage session ids are different + // systems today, so attribution is intentionally limited to one local + // provider session in the run's worktree/time window. + attribution: 'provider_session_time_window', + collectedAt, + unavailableReason: null, + unavailableMessage: null + } + } + private getFilteredDaily(scope: CodexUsageScope, range: CodexUsageRange) { const cutoff = getRangeCutoff(range) return this.state.dailyAggregates.filter((entry) => { diff --git a/src/main/index.ts b/src/main/index.ts index af7af86db47..8179af9a443 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -13,6 +13,7 @@ import { Store, initDataPath } from './persistence' import { StatsCollector, initStatsPath } from './stats/collector' import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store' +import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store' import { killAllPty } from './ipc/pty' import { initDaemonPtyProvider, disconnectDaemon } from './daemon/daemon-init' import { closeAllWatchers } from './ipc/filesystem-watcher' @@ -78,6 +79,7 @@ let store: Store | null = null let stats: StatsCollector | null = null let claudeUsage: ClaudeUsageStore | null = null let codexUsage: CodexUsageStore | null = null +let openCodeUsage: OpenCodeUsageStore | null = null let codexAccounts: CodexAccountService | null = null let codexRuntimeHome: CodexRuntimeHomeService | null = null let claudeAccounts: ClaudeAccountService | null = null @@ -190,6 +192,7 @@ if (hasSingleInstanceLock) { initStatsPath() initClaudeUsagePath() initCodexUsagePath() + initOpenCodeUsagePath() enableMainProcessGpuFeatures() } @@ -209,6 +212,9 @@ function openMainWindow(): BrowserWindow { if (!codexUsage) { throw new Error('Codex usage store must be initialized before opening the main window') } + if (!openCodeUsage) { + throw new Error('OpenCode usage store must be initialized before opening the main window') + } if (!rateLimits) { throw new Error('Rate limit service must be initialized before opening the main window') } @@ -272,6 +278,7 @@ function openMainWindow(): BrowserWindow { stats, claudeUsage, codexUsage, + openCodeUsage, codexAccounts, claudeAccounts, rateLimits, @@ -664,6 +671,7 @@ app.whenReady().then(async () => { stats = new StatsCollector() claudeUsage = new ClaudeUsageStore(store) codexUsage = new CodexUsageStore(store) + openCodeUsage = new OpenCodeUsageStore(store) rateLimits = new RateLimitService() codexRuntimeHome = new CodexRuntimeHomeService(store) codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome) @@ -692,7 +700,7 @@ app.whenReady().then(async () => { // and defeat the teardown helper's prefix sweep (design §4.3 wire-up). getLocalProvider: () => getLocalPtyProvider() }) - automations = new AutomationService(store) + automations = new AutomationService(store, { claudeUsage, codexUsage }) runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) disposeFeatureWallFirstAgentTour = registerFeatureWallFirstAgentTour({ stats, diff --git a/src/main/ipc/automations.ts b/src/main/ipc/automations.ts index f5c762d265c..4e1b6094f02 100644 --- a/src/main/ipc/automations.ts +++ b/src/main/ipc/automations.ts @@ -49,7 +49,8 @@ export function registerAutomationHandlers(store: Store, service: AutomationServ ) ipcMain.handle( 'automations:markDispatchResult', - (_event, result: AutomationDispatchResult): AutomationRun => service.markDispatchResult(result) + (_event, result: AutomationDispatchResult): Promise => + service.markDispatchResult(result) ) ipcMain.handle('automations:rendererReady', (): void => { service.setRendererReady() diff --git a/src/main/ipc/opencode-usage.ts b/src/main/ipc/opencode-usage.ts new file mode 100644 index 00000000000..180403f370b --- /dev/null +++ b/src/main/ipc/opencode-usage.ts @@ -0,0 +1,43 @@ +import { ipcMain } from 'electron' +import type { OpenCodeUsageStore } from '../opencode-usage/store' +import type { + OpenCodeUsageBreakdownKind, + OpenCodeUsageRange, + OpenCodeUsageScope +} from '../../shared/opencode-usage-types' + +export function registerOpenCodeUsageHandlers(openCodeUsage: OpenCodeUsageStore): void { + ipcMain.handle('openCodeUsage:getScanState', () => openCodeUsage.getScanState()) + ipcMain.handle('openCodeUsage:setEnabled', (_event, args: { enabled: boolean }) => + openCodeUsage.setEnabled(args.enabled) + ) + ipcMain.handle('openCodeUsage:refresh', (_event, args?: { force?: boolean }) => + openCodeUsage.refresh(args?.force ?? false) + ) + ipcMain.handle( + 'openCodeUsage:getSummary', + (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange }) => + openCodeUsage.getSummary(args.scope, args.range) + ) + ipcMain.handle( + 'openCodeUsage:getDaily', + (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange }) => + openCodeUsage.getDaily(args.scope, args.range) + ) + ipcMain.handle( + 'openCodeUsage:getBreakdown', + ( + _event, + args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + kind: OpenCodeUsageBreakdownKind + } + ) => openCodeUsage.getBreakdown(args.scope, args.range, args.kind) + ) + ipcMain.handle( + 'openCodeUsage:getRecentSessions', + (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange; limit?: number }) => + openCodeUsage.getRecentSessions(args.scope, args.range, args.limit) + ) +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 2cd81d45df2..b2c29bff0e3 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -21,6 +21,7 @@ const { openCodeClearPtyMock, buildAgentHookEnvMock, clearAgentHookPaneStateMock, + registerPaneKeyAliasMock, piBuildPtyEnvMock, piClearPtyMock, isPwshAvailableMock, @@ -30,7 +31,8 @@ const { unregisterPtyMock, setMigrationUnsupportedPtyMock, clearMigrationUnsupportedPtyMock, - clearMigrationUnsupportedPtysForPaneKeyMock + clearMigrationUnsupportedPtysForPaneKeyMock, + clearPaneKeyAliasesForPtyMock } = vi.hoisted(() => ({ handleMock: vi.fn(), onMock: vi.fn(), @@ -50,6 +52,7 @@ const { openCodeClearPtyMock: vi.fn(), buildAgentHookEnvMock: vi.fn(), clearAgentHookPaneStateMock: vi.fn(), + registerPaneKeyAliasMock: vi.fn(), piBuildPtyEnvMock: vi.fn(), piClearPtyMock: vi.fn(), trackMock: vi.fn(), @@ -58,7 +61,8 @@ const { unregisterPtyMock: vi.fn(), setMigrationUnsupportedPtyMock: vi.fn(), clearMigrationUnsupportedPtyMock: vi.fn(), - clearMigrationUnsupportedPtysForPaneKeyMock: vi.fn() + clearMigrationUnsupportedPtysForPaneKeyMock: vi.fn(), + clearPaneKeyAliasesForPtyMock: vi.fn() })) vi.mock('electron', () => ({ @@ -101,7 +105,9 @@ vi.mock('../opencode/hook-service', () => ({ vi.mock('../agent-hooks/server', () => ({ agentHookServer: { buildPtyEnv: buildAgentHookEnvMock, - clearPaneState: clearAgentHookPaneStateMock + clearPaneState: clearAgentHookPaneStateMock, + registerPaneKeyAlias: registerPaneKeyAliasMock, + clearPaneKeyAliasesForPty: clearPaneKeyAliasesForPtyMock } })) @@ -206,6 +212,7 @@ describe('registerPtyHandlers', () => { openCodeClearPtyMock.mockReset() buildAgentHookEnvMock.mockReset() clearAgentHookPaneStateMock.mockReset() + registerPaneKeyAliasMock.mockReset() piBuildPtyEnvMock.mockReset() piClearPtyMock.mockReset() isPwshAvailableMock.mockReset() @@ -216,6 +223,7 @@ describe('registerPtyHandlers', () => { setMigrationUnsupportedPtyMock.mockReset() clearMigrationUnsupportedPtyMock.mockReset() clearMigrationUnsupportedPtysForPaneKeyMock.mockReset() + clearPaneKeyAliasesForPtyMock.mockReset() mainWindow.webContents.on.mockReset() mainWindow.webContents.send.mockReset() @@ -2326,9 +2334,10 @@ describe('registerPtyHandlers', () => { } }) - it('registers only validated stable pane keys in the local PTY memory registry', async () => { + it('upgrades legacy numeric pane keys when the spawn metadata proves the stable leaf', async () => { registerPtyHandlers(mainWindow as never) const leafId = '11111111-1111-4111-8111-111111111111' + const stablePaneKey = makePaneKey('tab-1', leafId) await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, @@ -2340,22 +2349,17 @@ describe('registerPtyHandlers', () => { expect(registerPtyMock).toHaveBeenLastCalledWith( expect.objectContaining({ - paneKey: null + paneKey: stablePaneKey }) ) - expect(setMigrationUnsupportedPtyMock).toHaveBeenCalledWith( - expect.objectContaining({ - ptyId: expect.any(String), - worktreeId: 'wt-1', - tabId: 'tab-1', - leafId, - paneKey: makePaneKey('tab-1', leafId), - reason: 'legacy-numeric-pane-key', - source: 'local' - }) + expect(registerPaneKeyAliasMock).toHaveBeenCalledWith( + 'tab-1:0', + stablePaneKey, + expect.any(String) ) + expect(clearMigrationUnsupportedPtysForPaneKeyMock).toHaveBeenCalledWith(stablePaneKey) + expect(setMigrationUnsupportedPtyMock).not.toHaveBeenCalled() - const stablePaneKey = makePaneKey('tab-1', leafId) await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, @@ -2422,6 +2426,31 @@ describe('registerPtyHandlers', () => { expect(clearAgentHookPaneStateMock).toHaveBeenCalledWith(stablePaneKey) }) + it('does not let restart-era alias cleanup clear a newer pane-key owner', async () => { + registerPtyHandlers(mainWindow as never) + const leafId = '11111111-1111-4111-8111-111111111111' + const stablePaneKey = makePaneKey('tab-1', leafId) + + const current = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId, + env: { ORCA_PANE_KEY: stablePaneKey } + })) as { id: string } + + expect(getPtyIdForPaneKey(stablePaneKey)).toBe(current.id) + clearPaneKeyAliasesForPtyMock.mockClear() + + clearProviderPtyState('old-pty-without-forward-pane-key') + + const cleanupOptions = clearPaneKeyAliasesForPtyMock.mock.calls.find( + ([ptyId]) => ptyId === 'old-pty-without-forward-pane-key' + )?.[1] + expect(cleanupOptions?.shouldClearStablePaneKey(stablePaneKey)).toBe(false) + }) + it('prefers args.env.SHELL and normalizes the child env after fallback', async () => { const originalShell = process.env.SHELL const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index a0d4cc4c26e..58fdcc55876 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -38,11 +38,15 @@ import { } from '../../shared/telemetry-events' import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay' import { readShellStartupEnvVar } from '../pty/shell-startup-env' -import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { + isTerminalLeafId, + makePaneKey, + parseLegacyNumericPaneKey, + parsePaneKey +} from '../../shared/stable-pane-id' import { clearMigrationUnsupportedPty, - clearMigrationUnsupportedPtysForPaneKey, - setMigrationUnsupportedPty + clearMigrationUnsupportedPtysForPaneKey } from '../agent-hooks/migration-unsupported-pty-state' // ─── Provider Registry ────────────────────────────────────────────── @@ -119,28 +123,6 @@ function isValidPaneKey(paneKey: unknown): paneKey is string { return parseValidPaneKey(paneKey) !== null } -function parseLegacyNumericPaneKey( - paneKey: unknown -): { tabId: string; numericPaneId: string } | null { - if (typeof paneKey !== 'string' || paneKey.length > 256) { - return null - } - const trimmed = paneKey.trim() - const delimiter = trimmed.indexOf(':') - if ( - delimiter <= 0 || - delimiter !== trimmed.lastIndexOf(':') || - delimiter === trimmed.length - 1 - ) { - return null - } - const numericPaneId = trimmed.slice(delimiter + 1) - if (!/^\d+$/.test(numericPaneId)) { - return null - } - return { tabId: trimmed.slice(0, delimiter), numericPaneId } -} - function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string | null { const normalizedPaneKey = typeof paneKey === 'string' ? paneKey.trim() : '' if (!isValidPaneKey(normalizedPaneKey)) { @@ -462,19 +444,31 @@ export function clearProviderPtyState(id: string): void { openCodeHookService.clearPty(id) piTitlebarExtensionService.clearPty(id) ptySizes.delete(id) + const paneKey = ptyPaneKey.get(id) + const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false // Why: drop the memory-collector registration so a dead PTY does not keep // trying to resolve its (now-dead) pid on every snapshot. Safe no-op for // PTYs that were never registered (SSH-owned). unregisterPty(id) clearMigrationUnsupportedPty(id) + agentHookServer.clearPaneKeyAliasesForPty(id, { + shouldClearStablePaneKey: (stablePaneKey) => { + // Why: when this PTY never rebuilt ptyPaneKey after restart, alias + // ownership is our only proof. Once a newer PTY owns the same stable + // paneKey, alias teardown must not erase that newer status. + const stablePaneOwner = paneKeyPtyId.get(stablePaneKey) + if (stablePaneOwner && stablePaneOwner !== id) { + return false + } + return !paneKey || (stillOwnsPaneKey && stablePaneKey === paneKey) + } + }) rendererSerializerByPtyId.delete(id) // Why: the hook server's per-paneKey caches (lastPrompt / lastTool) would // otherwise accumulate entries for dead panes over the process lifetime. // Use the spawn-time paneKey mapping since the server has no other way to // correlate a ptyId back to its paneKey. - const paneKey = ptyPaneKey.get(id) if (paneKey) { - const stillOwnsPaneKey = paneKeyPtyId.get(paneKey) === id if (stillOwnsPaneKey) { agentHookServer.clearPaneState(paneKey) paneKeyPtyId.delete(paneKey) @@ -1151,15 +1145,16 @@ export function registerPtyHandlers( isTerminalLeafId(args.leafId) ? makePaneKey(args.tabId, args.leafId) : null + const stablePaneKey = verifiedPaneKey ?? migrationUnsupportedPaneKey const baseEnv = baseEnvWithAuth - ? { ...baseEnvWithAuth, ...(verifiedPaneKey ? { ORCA_PANE_KEY: verifiedPaneKey } : {}) } + ? { ...baseEnvWithAuth, ...(stablePaneKey ? { ORCA_PANE_KEY: stablePaneKey } : {}) } : undefined - if (baseEnv && !verifiedPaneKey) { + if (baseEnv && !stablePaneKey) { // Why: ORCA_PANE_KEY crosses into shells and hook registries. Only the // key proven to match this spawn's tab+leaf may leave the IPC boundary. delete baseEnv.ORCA_PANE_KEY } - const validatedPaneKey = verifiedPaneKey + const validatedPaneKey = stablePaneKey const validatedLeafId = verifiedLeafId ?? metadataLeafId let env: Record | undefined = baseEnv const preAllocatedHandle = @@ -1445,24 +1440,17 @@ export function registerPtyHandlers( const rememberedPaneKey = validatedPaneKey ? rememberPaneKeyForPty(result.id, validatedPaneKey) : null - if (validatedPaneKey) { + if (legacySpawnPaneKey && migrationUnsupportedPaneKey) { + agentHookServer.registerPaneKeyAlias( + legacySpawnPaneKey.paneKey, + migrationUnsupportedPaneKey, + result.id + ) + clearMigrationUnsupportedPtysForPaneKey(migrationUnsupportedPaneKey) + } else if (validatedPaneKey) { if (!result.isReattach) { clearMigrationUnsupportedPtysForPaneKey(validatedPaneKey) } - } else if (migrationUnsupportedPaneKey && legacySpawnPaneKey) { - // Why: old live PTYs can still carry `${tabId}:${numericPaneId}` in - // ORCA_PANE_KEY. Only surface a migration row when this spawn also - // proves the owning UUID leaf through the renderer IPC metadata. - setMigrationUnsupportedPty({ - ptyId: result.id, - ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), - tabId: legacySpawnPaneKey.tabId, - leafId: args.leafId, - paneKey: migrationUnsupportedPaneKey, - reason: 'legacy-numeric-pane-key', - source: args.connectionId ? 'ssh' : 'local', - updatedAt: Date.now() - }) } // Why: register local PTYs (connectionId falsy) with the memory // collector so it can walk each PTY's process subtree and attribute diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index f81db6fddf8..82e700e592b 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -7,6 +7,7 @@ const { registerPreflightHandlersMock, registerClaudeUsageHandlersMock, registerCodexUsageHandlersMock, + registerOpenCodeUsageHandlersMock, registerGitHubHandlersMock, registerFeedbackHandlersMock, registerStatsHandlersMock, @@ -48,6 +49,7 @@ const { registerPreflightHandlersMock: vi.fn(), registerClaudeUsageHandlersMock: vi.fn(), registerCodexUsageHandlersMock: vi.fn(), + registerOpenCodeUsageHandlersMock: vi.fn(), registerGitHubHandlersMock: vi.fn(), registerFeedbackHandlersMock: vi.fn(), registerStatsHandlersMock: vi.fn(), @@ -110,6 +112,10 @@ vi.mock('./codex-usage', () => ({ registerCodexUsageHandlers: registerCodexUsageHandlersMock })) +vi.mock('./opencode-usage', () => ({ + registerOpenCodeUsageHandlers: registerOpenCodeUsageHandlersMock +})) + vi.mock('./github', () => ({ registerGitHubHandlers: registerGitHubHandlersMock })) @@ -245,6 +251,7 @@ describe('registerCoreHandlers', () => { registerPreflightHandlersMock.mockReset() registerClaudeUsageHandlersMock.mockReset() registerCodexUsageHandlersMock.mockReset() + registerOpenCodeUsageHandlersMock.mockReset() registerGitHubHandlersMock.mockReset() registerFeedbackHandlersMock.mockReset() registerStatsHandlersMock.mockReset() @@ -288,6 +295,7 @@ describe('registerCoreHandlers', () => { const stats = { marker: 'stats' } const claudeUsage = { marker: 'claudeUsage' } const codexUsage = { marker: 'codexUsage' } + const openCodeUsage = { marker: 'openCodeUsage' } const codexAccounts = { marker: 'codexAccounts' } const claudeAccounts = { marker: 'claudeAccounts' } const rateLimits = { marker: 'rateLimits' } @@ -298,6 +306,7 @@ describe('registerCoreHandlers', () => { stats as never, claudeUsage as never, codexUsage as never, + openCodeUsage as never, codexAccounts as never, claudeAccounts as never, rateLimits as never @@ -305,6 +314,7 @@ describe('registerCoreHandlers', () => { expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage) expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage) + expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage) expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts) expect(registerAgentHookHandlersMock).toHaveBeenCalled() expect(registerPetHandlersMock).toHaveBeenCalled() @@ -348,6 +358,7 @@ describe('registerCoreHandlers', () => { const stats2 = { marker: 'stats2' } const claudeUsage2 = { marker: 'claudeUsage2' } const codexUsage2 = { marker: 'codexUsage2' } + const openCodeUsage2 = { marker: 'openCodeUsage2' } const codexAccounts2 = { marker: 'codexAccounts2' } const claudeAccounts2 = { marker: 'claudeAccounts2' } const rateLimits2 = { marker: 'rateLimits2' } @@ -358,6 +369,7 @@ describe('registerCoreHandlers', () => { stats2 as never, claudeUsage2 as never, codexUsage2 as never, + openCodeUsage2 as never, codexAccounts2 as never, claudeAccounts2 as never, rateLimits2 as never, diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index f3e5bcce61a..8d2c6a50642 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -11,6 +11,7 @@ import { import { registerFilesystemWatcherHandlers } from './filesystem-watcher' import { registerClaudeUsageHandlers } from './claude-usage' import { registerCodexUsageHandlers } from './codex-usage' +import { registerOpenCodeUsageHandlers } from './opencode-usage' import { registerGitHubHandlers } from './github' import { registerGitLabHandlers } from './gitlab' import { registerHostedReviewHandlers } from './hosted-review' @@ -50,6 +51,7 @@ import { } from '../window/attach-main-window-services' import type { ClaudeUsageStore } from '../claude-usage/store' import type { CodexUsageStore } from '../codex-usage/store' +import type { OpenCodeUsageStore } from '../opencode-usage/store' import type { RateLimitService } from '../rate-limits/service' import type { CodexAccountService } from '../codex-accounts/service' import type { ClaudeAccountService } from '../claude-accounts/service' @@ -64,6 +66,7 @@ export function registerCoreHandlers( stats: StatsCollector, claudeUsage: ClaudeUsageStore, codexUsage: CodexUsageStore, + openCodeUsage: OpenCodeUsageStore, codexAccounts: CodexAccountService, claudeAccounts: ClaudeAccountService, rateLimits: RateLimitService, @@ -88,6 +91,7 @@ export function registerCoreHandlers( registerPreflightHandlers() registerClaudeUsageHandlers(claudeUsage) registerCodexUsageHandlers(codexUsage) + registerOpenCodeUsageHandlers(openCodeUsage) registerCodexAccountHandlers(codexAccounts) registerAgentHookHandlers() registerAgentTrustHandlers() diff --git a/src/main/linear/issues.ts b/src/main/linear/issues.ts index afc59809973..cafff393e8f 100644 --- a/src/main/linear/issues.ts +++ b/src/main/linear/issues.ts @@ -100,6 +100,12 @@ export async function searchIssues( } }) ) + // Why: searchIssues returns Linear's relevance ranking. Re-sorting by + // updatedAt would discard relevance order for single-workspace results, + // diverging from Linear's web UI and pre-PR behavior. + if (entries.length === 1) { + return results.flat().slice(0, limit) + } return sortAndLimitIssues(results.flat(), limit) } diff --git a/src/main/opencode-usage/scanner.test.ts b/src/main/opencode-usage/scanner.test.ts new file mode 100644 index 00000000000..625effd4ce6 --- /dev/null +++ b/src/main/opencode-usage/scanner.test.ts @@ -0,0 +1,269 @@ +import Database from 'better-sqlite3' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { parseOpenCodeUsageDatabase, parseOpenCodeUsageRow } from './scanner' + +const WORKTREE = '/workspace/repo' + +let tempDirs: string[] = [] + +function createTempDb(): { db: Database.Database; path: string } { + const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-usage-')) + tempDirs.push(dir) + const path = join(dir, 'opencode.db') + return { db: new Database(path), path } +} + +function worktrees() { + return [ + { + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + path: WORKTREE, + displayName: 'Repo', + canonicalPath: WORKTREE + } + ] +} + +describe('parseOpenCodeUsageRow', () => { + it('reads assistant message tokens, cost, model, cwd, and timestamp', () => { + const parsed = parseOpenCodeUsageRow({ + id: 'message-1', + session_id: 'session-1', + time_created: 1_777_777_700_000, + time_updated: null, + directory: null, + title: null, + worktree: null, + session_model: null, + data: JSON.stringify({ + providerID: 'anthropic', + modelID: 'claude-sonnet-4-5', + path: { cwd: `${WORKTREE}/packages/app` }, + cost: 0.0123, + tokens: { + input: 1000, + output: 250, + reasoning: 100, + total: 1350, + cache: { read: 400, write: 25 } + }, + time: { + completed: 1_777_777_800_000 + } + }) + }) + + expect(parsed).toEqual({ + sessionId: 'session-1', + timestamp: new Date(1_777_777_800_000).toISOString(), + cwd: `${WORKTREE}/packages/app`, + model: 'anthropic/claude-sonnet-4-5', + estimatedCostUsd: 0.0123, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350 + }) + }) +}) + +describe('parseOpenCodeUsageDatabase', () => { + afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs = [] + }) + + it('uses materialized session token totals when the OpenCode DB has them', async () => { + const { db, path } = createTempDb() + db.exec(` + CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT); + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT, + directory TEXT, + title TEXT, + model TEXT, + cost REAL, + tokens_input INTEGER, + tokens_output INTEGER, + tokens_reasoning INTEGER, + tokens_cache_read INTEGER, + time_created INTEGER, + time_updated INTEGER + ); + `) + db.prepare('INSERT INTO project (id, worktree) VALUES (?, ?)').run('project-1', WORKTREE) + db.prepare( + `INSERT INTO session ( + id, project_id, directory, title, model, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, + time_created, time_updated + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + 'session-1', + 'project-1', + `${WORKTREE}/packages/app`, + 'Build feature', + JSON.stringify({ providerID: 'anthropic', id: 'claude-sonnet-4-5' }), + 0.06, + 1000, + 500, + 100, + 250, + 1_777_777_700_000, + 1_777_777_800_000 + ) + db.close() + + const parsed = await parseOpenCodeUsageDatabase(path, worktrees()) + + expect(parsed.sessions).toHaveLength(1) + expect(parsed.sessions[0]).toMatchObject({ + sessionId: 'session-1', + primaryModel: 'anthropic/claude-sonnet-4-5', + primaryProjectLabel: 'Repo', + eventCount: 1, + totalInputTokens: 1000, + totalCachedInputTokens: 250, + totalOutputTokens: 500, + totalReasoningOutputTokens: 100, + totalTokens: 1600, + estimatedCostUsd: 0.06 + }) + expect(parsed.dailyAggregates).toEqual([ + expect.objectContaining({ + projectLabel: 'Repo', + inputTokens: 1000, + cachedInputTokens: 250, + outputTokens: 500, + reasoningOutputTokens: 100, + totalTokens: 1600, + estimatedCostUsd: 0.06 + }) + ]) + }) + + it('supports session_message tables without a type column', async () => { + const { db, path } = createTempDb() + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + directory TEXT, + title TEXT, + time_created INTEGER, + time_updated INTEGER + ); + CREATE TABLE session_message ( + id TEXT PRIMARY KEY, + session_id TEXT, + time_created INTEGER, + time_updated INTEGER, + data TEXT + ); + `) + db.prepare( + 'INSERT INTO session (id, directory, title, time_created, time_updated) VALUES (?, ?, ?, ?, ?)' + ).run('session-1', `${WORKTREE}/tools`, 'Legacy session', 1_777_777_700_000, 1_777_777_800_000) + db.prepare( + 'INSERT INTO session_message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)' + ).run( + 'message-1', + 'session-1', + 1_777_777_700_000, + 1_777_777_800_000, + JSON.stringify({ + providerID: 'openai', + modelID: 'gpt-5.5', + cost: 0.03, + tokens: { + input: 800, + output: 200, + reasoning: 50, + cache: { read: 100, write: 0 } + } + }) + ) + db.close() + + const parsed = await parseOpenCodeUsageDatabase(path, worktrees()) + + expect(parsed.sessions[0]).toMatchObject({ + primaryModel: 'openai/gpt-5.5', + primaryProjectLabel: 'Repo', + totalTokens: 1050, + estimatedCostUsd: 0.03 + }) + }) + + it('prefers session_message rows over legacy message rows to avoid double counting', async () => { + const { db, path } = createTempDb() + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + directory TEXT, + title TEXT, + time_created INTEGER, + time_updated INTEGER + ); + CREATE TABLE session_message ( + id TEXT PRIMARY KEY, + session_id TEXT, + type TEXT, + time_created INTEGER, + time_updated INTEGER, + data TEXT + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT, + time_created INTEGER, + time_updated INTEGER, + data TEXT + ); + `) + db.prepare( + 'INSERT INTO session (id, directory, title, time_created, time_updated) VALUES (?, ?, ?, ?, ?)' + ).run('session-1', WORKTREE, 'Mixed schema session', 1_777_777_700_000, 1_777_777_800_000) + db.prepare( + 'INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)' + ).run( + 'session-message-1', + 'session-1', + 'assistant', + 1_777_777_700_000, + 1_777_777_800_000, + JSON.stringify({ + providerID: 'openai', + modelID: 'gpt-5.5', + tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 10, write: 0 } } + }) + ) + db.prepare( + 'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)' + ).run( + 'legacy-message-1', + 'session-1', + 1_777_777_700_000, + 1_777_777_800_000, + JSON.stringify({ + role: 'assistant', + providerID: 'openai', + modelID: 'gpt-5.5', + tokens: { input: 1000, output: 500, reasoning: 0, cache: { read: 100, write: 0 } } + }) + ) + db.close() + + const parsed = await parseOpenCodeUsageDatabase(path, worktrees()) + + expect(parsed.sessions[0]?.totalTokens).toBe(120) + expect(parsed.sessions[0]?.eventCount).toBe(1) + }) +}) diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts new file mode 100644 index 00000000000..3066d707e7b --- /dev/null +++ b/src/main/opencode-usage/scanner.ts @@ -0,0 +1,931 @@ +/* eslint-disable max-lines -- Why: OpenCode usage analytics need to normalize multiple local DB schema generations, attribute worktrees, and build persisted projections in one auditable pipeline. */ +import Database from 'better-sqlite3' +import { existsSync } from 'fs' +import { readdir, realpath, stat } from 'fs/promises' +import { homedir } from 'os' +import { isAbsolute, join, posix, win32 } from 'path' +import type { Repo } from '../../shared/types' +import { areWorktreePathsEqual } from '../ipc/worktree-logic' +import type { + OpenCodeUsageAttributedEvent, + OpenCodeUsageDailyAggregate, + OpenCodeUsageLocationBreakdown, + OpenCodeUsageLocationModelBreakdown, + OpenCodeUsageModelBreakdown, + OpenCodeUsageParsedEvent, + OpenCodeUsagePersistedDatabase, + OpenCodeUsageProcessedDatabase, + OpenCodeUsageSession +} from './types' + +export type OpenCodeUsageWorktreeRef = { + repoId: string + worktreeId: string + path: string + displayName: string +} + +type OpenCodeUsageRow = { + id: string + session_id: string + time_created: number + time_updated: number | null + data: string + directory: string | null + title: string | null + worktree: string | null + session_model: string | null +} + +type OpenCodeSessionUsageRow = { + id: string + session_id: string + time_created: number + time_updated: number | null + directory: string | null + title: string | null + worktree: string | null + session_model: string | null + cost: number + tokens_input: number + tokens_output: number + tokens_reasoning: number + tokens_cache_read: number +} + +const YIELD_EVERY_DATABASES = 2 + +function ensureNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +function normalizeComparablePath(pathValue: string, platform = process.platform): string { + const normalized = pathValue.replace(/\\/g, '/') + return platform === 'win32' || looksLikeWindowsPath(pathValue) + ? normalized.toLowerCase() + : normalized +} + +function normalizeFsPath(pathValue: string, platform = process.platform): string { + if (platform === 'win32' || looksLikeWindowsPath(pathValue)) { + return win32.normalize(win32.resolve(pathValue)) + } + return posix.normalize(posix.resolve(pathValue)) +} + +function looksLikeWindowsPath(pathValue: string): boolean { + return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') +} + +function getXdgDataHome(): string { + if (process.env.XDG_DATA_HOME?.trim()) { + return process.env.XDG_DATA_HOME.trim() + } + if (process.platform === 'win32') { + return process.env.LOCALAPPDATA || process.env.APPDATA || join(homedir(), 'AppData', 'Local') + } + return join(homedir(), '.local', 'share') +} + +function getOpenCodeDataDirectory(): string { + return join(getXdgDataHome(), 'opencode') +} + +function getOpenCodeDatabasePathFromEnv(): string | null { + const raw = process.env.OPENCODE_DB?.trim() + if (!raw) { + return null + } + if (raw === ':memory:') { + return null + } + return isAbsolute(raw) ? raw : join(getOpenCodeDataDirectory(), raw) +} + +export async function listOpenCodeDatabases(): Promise { + const envPath = getOpenCodeDatabasePathFromEnv() + if (envPath) { + return existsSync(envPath) ? [envPath] : [] + } + + try { + const entries = await readdir(getOpenCodeDataDirectory(), { withFileTypes: true }) + return entries + .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) + .map((entry) => join(getOpenCodeDataDirectory(), entry.name)) + .sort() + } catch { + return [] + } +} + +export async function getProcessedDatabaseInfo( + dbPath: string +): Promise { + const dbStat = await stat(dbPath) + return { + path: dbPath, + mtimeMs: dbStat.mtimeMs, + size: dbStat.size + } +} + +async function yieldToEventLoop(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +function tableExists(db: Database.Database, tableName: string): boolean { + const row = db + .prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(tableName) as { found?: number } | undefined + return row?.found === 1 +} + +function columnExists(db: Database.Database, tableName: string, columnName: string): boolean { + const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[] + return rows.some((row) => row.name === columnName) +} + +function getProjectJoin(db: Database.Database): string { + return tableExists(db, 'project') && columnExists(db, 'session', 'project_id') + ? 'LEFT JOIN project p ON p.id = s.project_id' + : 'LEFT JOIN (SELECT NULL AS id, NULL AS worktree) p ON 1 = 0' +} + +function getSessionModelSelect(db: Database.Database): string { + return columnExists(db, 'session', 'model') ? 's.model AS session_model' : 'NULL AS session_model' +} + +function getAssistantSessionMessageCount(db: Database.Database): number { + if (!tableExists(db, 'session_message')) { + return 0 + } + const assistantPredicate = columnExists(db, 'session_message', 'type') + ? "type = 'assistant' AND json_extract(data, '$.tokens.input') IS NOT NULL" + : "json_extract(data, '$.tokens.input') IS NOT NULL" + const row = db + .prepare(`SELECT COUNT(*) AS count FROM session_message WHERE ${assistantPredicate}`) + .get() as { count?: number } | undefined + return row?.count ?? 0 +} + +function canReadSessionUsageRows(db: Database.Database): boolean { + if (!tableExists(db, 'session')) { + return false + } + return ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'].every( + (columnName) => columnExists(db, 'session', columnName) + ) +} + +function getSessionUsageRowCount(db: Database.Database): number { + if (!canReadSessionUsageRows(db)) { + return 0 + } + const row = db + .prepare( + `SELECT COUNT(*) AS count + FROM session + WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read > 0` + ) + .get() as { count?: number } | undefined + return row?.count ?? 0 +} + +function selectSessionUsageRows(db: Database.Database): OpenCodeUsageRow[] { + const projectJoin = getProjectJoin(db) + const sessionModelSelect = getSessionModelSelect(db) + const rows = db + .prepare( + `SELECT s.id, s.id AS session_id, s.time_created, s.time_updated, + s.directory, s.title, p.worktree, ${sessionModelSelect}, + s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read + FROM session s + ${projectJoin} + WHERE s.tokens_input + s.tokens_output + s.tokens_reasoning + s.tokens_cache_read > 0 + ORDER BY s.time_created, s.id` + ) + .all() as OpenCodeSessionUsageRow[] + + return rows.map((row) => ({ + id: row.id, + session_id: row.session_id, + time_created: row.time_created, + time_updated: row.time_updated, + directory: row.directory, + title: row.title, + worktree: row.worktree, + session_model: row.session_model, + data: JSON.stringify({ + cost: row.cost, + tokens: { + input: row.tokens_input, + output: row.tokens_output, + reasoning: row.tokens_reasoning, + total: row.tokens_input + row.tokens_output + row.tokens_reasoning, + cache: { + read: row.tokens_cache_read, + write: 0 + } + } + }) + })) +} + +function selectUsageRows(db: Database.Database): OpenCodeUsageRow[] { + if (!tableExists(db, 'session')) { + return [] + } + + // Why: newer OpenCode DBs maintain session-level token/cost totals. Reading + // one aggregate row per session is faster than parsing every message blob. + if (getSessionUsageRowCount(db) > 0) { + return selectSessionUsageRows(db) + } + + const projectJoin = getProjectJoin(db) + const sessionModelSelect = getSessionModelSelect(db) + + if (getAssistantSessionMessageCount(db) > 0) { + const assistantPredicate = columnExists(db, 'session_message', 'type') + ? "sm.type = 'assistant'" + : "json_extract(sm.data, '$.tokens.input') IS NOT NULL" + return db + .prepare( + `SELECT sm.id, sm.session_id, sm.time_created, sm.time_updated, sm.data, + s.directory, s.title, p.worktree, ${sessionModelSelect} + FROM session_message sm + JOIN session s ON s.id = sm.session_id + ${projectJoin} + WHERE ${assistantPredicate} + ORDER BY sm.time_created, sm.id` + ) + .all() as OpenCodeUsageRow[] + } + + if (!tableExists(db, 'message')) { + return [] + } + + return db + .prepare( + `SELECT m.id, m.session_id, m.time_created, m.time_updated, m.data, + s.directory, s.title, p.worktree, ${sessionModelSelect} + FROM message m + JOIN session s ON s.id = m.session_id + ${projectJoin} + WHERE json_extract(m.data, '$.role') = 'assistant' + ORDER BY m.time_created, m.id` + ) + .all() as OpenCodeUsageRow[] +} + +function parseJsonObject(value: unknown): Record | null { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Record + } + if (typeof value !== 'string') { + return null + } + try { + const parsed = JSON.parse(value) as unknown + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : null + } catch { + return null + } +} + +function extractString(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +function extractModelLabel(data: Record, sessionModel: unknown): string | null { + const directModel = extractString(data.modelID) ?? extractString(data.modelId) + const directProvider = extractString(data.providerID) ?? extractString(data.providerId) + if (directModel) { + return directProvider ? `${directProvider}/${directModel}` : directModel + } + + const modelObject = parseJsonObject(data.model) ?? parseJsonObject(sessionModel) + if (!modelObject) { + return null + } + const modelID = extractString(modelObject.modelID) ?? extractString(modelObject.id) + const providerID = extractString(modelObject.providerID) + if (!modelID) { + return null + } + return providerID ? `${providerID}/${modelID}` : modelID +} + +function extractCwd(data: Record, row: OpenCodeUsageRow): string | null { + const pathData = parseJsonObject(data.path) + return ( + extractString(pathData?.cwd) ?? + extractString(row.directory) ?? + extractString(row.worktree) ?? + null + ) +} + +function normalizeMillis(value: unknown): number | null { + const numeric = ensureNumber(value) + if (numeric <= 0) { + return null + } + return numeric < 10_000_000_000 ? numeric * 1000 : numeric +} + +function extractTimestamp(data: Record, row: OpenCodeUsageRow): string | null { + const timeData = parseJsonObject(data.time) + const millis = + normalizeMillis(timeData?.completed) ?? + normalizeMillis(timeData?.created) ?? + normalizeMillis(row.time_updated) ?? + normalizeMillis(row.time_created) + return millis ? new Date(millis).toISOString() : null +} + +export function parseOpenCodeUsageRow(row: OpenCodeUsageRow): OpenCodeUsageParsedEvent | null { + const data = parseJsonObject(row.data) + if (!data) { + return null + } + + const tokens = parseJsonObject(data.tokens) + if (!tokens) { + return null + } + const cache = parseJsonObject(tokens.cache) + const inputTokens = ensureNumber(tokens.input) + const outputTokens = ensureNumber(tokens.output) + const reasoningOutputTokens = ensureNumber(tokens.reasoning) + const cachedInputTokens = Math.min(ensureNumber(cache?.read), inputTokens) + const totalTokens = + ensureNumber(tokens.total) > 0 + ? ensureNumber(tokens.total) + : inputTokens + outputTokens + reasoningOutputTokens + + if (inputTokens + outputTokens + reasoningOutputTokens + cachedInputTokens + totalTokens <= 0) { + return null + } + + const timestamp = extractTimestamp(data, row) + if (!timestamp) { + return null + } + + return { + sessionId: row.session_id, + timestamp, + cwd: extractCwd(data, row), + model: extractModelLabel(data, row.session_model), + estimatedCostUsd: ensureNumber(data.cost) > 0 ? ensureNumber(data.cost) : null, + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + totalTokens + } +} + +function getDefaultProjectLabel(cwd: string | null): string { + if (!cwd) { + return 'Unknown location' + } + const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean) + if (parts.length >= 2) { + return parts.slice(-2).join('/') + } + return parts.at(-1) ?? cwd +} + +function localDayFromTimestamp(timestamp: string): string | null { + const parsed = new Date(timestamp) + if (Number.isNaN(parsed.getTime())) { + return null + } + const year = parsed.getFullYear() + const month = String(parsed.getMonth() + 1).padStart(2, '0') + const day = String(parsed.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function isContainingPath(candidatePath: string, targetPath: string): boolean { + const useWin32 = looksLikeWindowsPath(candidatePath) || looksLikeWindowsPath(targetPath) + const relativePath = useWin32 + ? win32.relative(candidatePath, targetPath) + : posix.relative(candidatePath, targetPath) + if (!relativePath) { + return true + } + const isAbsoluteRelative = useWin32 + ? win32.isAbsolute(relativePath) + : posix.isAbsolute(relativePath) + return !isAbsoluteRelative && !relativePath.startsWith('..') && relativePath !== '.' +} + +async function buildWorktreesWithCanonicalPaths( + worktrees: OpenCodeUsageWorktreeRef[] +): Promise<(OpenCodeUsageWorktreeRef & { canonicalPath: string })[]> { + const canonicalized = await Promise.all( + worktrees.map(async (worktree) => ({ + ...worktree, + canonicalPath: await canonicalizePath(worktree.path) + })) + ) + return canonicalized.sort((left, right) => right.canonicalPath.length - left.canonicalPath.length) +} + +async function canonicalizePath(pathValue: string): Promise { + try { + return normalizeFsPath(await realpath(pathValue)) + } catch { + return normalizeFsPath(pathValue) + } +} + +function findContainingWorktree( + cwd: string, + worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[] +): OpenCodeUsageWorktreeRef | null { + const normalizedCwd = normalizeFsPath(cwd) + for (const worktree of worktrees) { + if (areWorktreePathsEqual(worktree.canonicalPath, normalizedCwd)) { + return worktree + } + if (isContainingPath(worktree.canonicalPath, normalizedCwd)) { + return worktree + } + } + return null +} + +export async function attributeOpenCodeUsageEvent( + event: OpenCodeUsageParsedEvent, + worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[] +): Promise { + const day = localDayFromTimestamp(event.timestamp) + if (!day) { + return null + } + + let repoId: string | null = null + let worktreeId: string | null = null + let projectKey = 'unscoped' + let projectLabel = getDefaultProjectLabel(event.cwd) + + if (event.cwd) { + const worktree = findContainingWorktree(event.cwd, worktrees) + if (worktree) { + repoId = worktree.repoId + worktreeId = worktree.worktreeId + projectKey = `worktree:${worktree.worktreeId}` + projectLabel = worktree.displayName + } else { + projectKey = `cwd:${normalizeComparablePath(event.cwd)}` + } + } + + return { + ...event, + day, + projectKey, + projectLabel, + repoId, + worktreeId + } +} + +function addCost(left: number | null, right: number | null): number | null { + if (left === null && right === null) { + return null + } + return (left ?? 0) + (right ?? 0) +} + +function createEmptySession(event: OpenCodeUsageAttributedEvent): OpenCodeUsageSession { + return { + sessionId: event.sessionId, + firstTimestamp: event.timestamp, + lastTimestamp: event.timestamp, + primaryModel: event.model, + hasMixedModels: false, + primaryProjectLabel: event.projectLabel, + hasMixedLocations: false, + primaryWorktreeId: event.worktreeId, + primaryRepoId: event.repoId, + eventCount: 0, + totalInputTokens: 0, + totalCachedInputTokens: 0, + totalOutputTokens: 0, + totalReasoningOutputTokens: 0, + totalTokens: 0, + estimatedCostUsd: null, + locationBreakdown: [], + modelBreakdown: [], + locationModelBreakdown: [] + } +} + +function createEmptyDailyAggregate( + event: OpenCodeUsageAttributedEvent +): OpenCodeUsageDailyAggregate { + return { + day: event.day, + model: event.model, + projectKey: event.projectKey, + projectLabel: event.projectLabel, + repoId: event.repoId, + worktreeId: event.worktreeId, + eventCount: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + estimatedCostUsd: null + } +} + +function mergeLocationBreakdown( + target: OpenCodeUsageLocationBreakdown[], + event: OpenCodeUsageAttributedEvent +): void { + const existing = target.find((entry) => entry.locationKey === event.projectKey) ?? null + if (existing) { + existing.eventCount++ + existing.inputTokens += event.inputTokens + existing.cachedInputTokens += event.cachedInputTokens + existing.outputTokens += event.outputTokens + existing.reasoningOutputTokens += event.reasoningOutputTokens + existing.totalTokens += event.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd) + return + } + + target.push({ + locationKey: event.projectKey, + projectLabel: event.projectLabel, + repoId: event.repoId, + worktreeId: event.worktreeId, + eventCount: 1, + inputTokens: event.inputTokens, + cachedInputTokens: event.cachedInputTokens, + outputTokens: event.outputTokens, + reasoningOutputTokens: event.reasoningOutputTokens, + totalTokens: event.totalTokens, + estimatedCostUsd: event.estimatedCostUsd + }) +} + +function mergeModelBreakdown( + target: OpenCodeUsageModelBreakdown[], + event: OpenCodeUsageAttributedEvent +): void { + const key = event.model ?? 'unknown' + const existing = target.find((entry) => entry.modelKey === key) ?? null + if (existing) { + existing.eventCount++ + existing.inputTokens += event.inputTokens + existing.cachedInputTokens += event.cachedInputTokens + existing.outputTokens += event.outputTokens + existing.reasoningOutputTokens += event.reasoningOutputTokens + existing.totalTokens += event.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd) + return + } + + target.push({ + modelKey: key, + modelLabel: event.model ?? 'Unknown model', + eventCount: 1, + inputTokens: event.inputTokens, + cachedInputTokens: event.cachedInputTokens, + outputTokens: event.outputTokens, + reasoningOutputTokens: event.reasoningOutputTokens, + totalTokens: event.totalTokens, + estimatedCostUsd: event.estimatedCostUsd + }) +} + +function mergeLocationModelBreakdown( + target: OpenCodeUsageLocationModelBreakdown[], + event: OpenCodeUsageAttributedEvent +): void { + const modelKey = event.model ?? 'unknown' + const existing = + target.find((entry) => entry.locationKey === event.projectKey && entry.modelKey === modelKey) ?? + null + if (existing) { + existing.eventCount++ + existing.inputTokens += event.inputTokens + existing.cachedInputTokens += event.cachedInputTokens + existing.outputTokens += event.outputTokens + existing.reasoningOutputTokens += event.reasoningOutputTokens + existing.totalTokens += event.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd) + return + } + + target.push({ + locationKey: event.projectKey, + modelKey, + modelLabel: event.model ?? 'Unknown model', + repoId: event.repoId, + worktreeId: event.worktreeId, + eventCount: 1, + inputTokens: event.inputTokens, + cachedInputTokens: event.cachedInputTokens, + outputTokens: event.outputTokens, + reasoningOutputTokens: event.reasoningOutputTokens, + totalTokens: event.totalTokens, + estimatedCostUsd: event.estimatedCostUsd + }) +} + +function aggregateOpenCodeUsage(events: OpenCodeUsageAttributedEvent[]): { + sessions: OpenCodeUsageSession[] + dailyAggregates: OpenCodeUsageDailyAggregate[] +} { + const sessionsById = new Map() + const dailyByKey = new Map() + + for (const event of events) { + const session = sessionsById.get(event.sessionId) ?? createEmptySession(event) + if (!sessionsById.has(event.sessionId)) { + sessionsById.set(event.sessionId, session) + } + if (event.timestamp < session.firstTimestamp) { + session.firstTimestamp = event.timestamp + } + if (event.timestamp >= session.lastTimestamp) { + session.lastTimestamp = event.timestamp + } + session.eventCount++ + session.totalInputTokens += event.inputTokens + session.totalCachedInputTokens += event.cachedInputTokens + session.totalOutputTokens += event.outputTokens + session.totalReasoningOutputTokens += event.reasoningOutputTokens + session.totalTokens += event.totalTokens + session.estimatedCostUsd = addCost(session.estimatedCostUsd, event.estimatedCostUsd) + mergeLocationBreakdown(session.locationBreakdown, event) + mergeModelBreakdown(session.modelBreakdown, event) + mergeLocationModelBreakdown(session.locationModelBreakdown, event) + + const dailyKey = [event.day, event.model ?? 'unknown', event.projectKey].join('::') + const daily = dailyByKey.get(dailyKey) ?? createEmptyDailyAggregate(event) + if (!dailyByKey.has(dailyKey)) { + dailyByKey.set(dailyKey, daily) + } + daily.eventCount++ + daily.inputTokens += event.inputTokens + daily.cachedInputTokens += event.cachedInputTokens + daily.outputTokens += event.outputTokens + daily.reasoningOutputTokens += event.reasoningOutputTokens + daily.totalTokens += event.totalTokens + daily.estimatedCostUsd = addCost(daily.estimatedCostUsd, event.estimatedCostUsd) + } + + return { + sessions: finalizeSessions(sessionsById), + dailyAggregates: [...dailyByKey.values()].sort((left, right) => + left.day === right.day + ? left.projectLabel.localeCompare(right.projectLabel) + : left.day.localeCompare(right.day) + ) + } +} + +function finalizeSessions(sessionsById: Map): OpenCodeUsageSession[] { + for (const session of sessionsById.values()) { + session.locationBreakdown.sort((left, right) => right.totalTokens - left.totalTokens) + session.modelBreakdown.sort((left, right) => right.totalTokens - left.totalTokens) + const primaryLocation = session.locationBreakdown[0] ?? null + const primaryModel = session.modelBreakdown[0] ?? null + session.primaryProjectLabel = + session.locationBreakdown.length <= 1 + ? (primaryLocation?.projectLabel ?? 'Unknown location') + : 'Multiple locations' + session.hasMixedLocations = session.locationBreakdown.length > 1 + session.primaryWorktreeId = primaryLocation?.worktreeId ?? null + session.primaryRepoId = primaryLocation?.repoId ?? null + session.primaryModel = + session.modelBreakdown.length <= 1 ? (primaryModel?.modelLabel ?? null) : 'Mixed models' + session.hasMixedModels = session.modelBreakdown.length > 1 + } + + return [...sessionsById.values()].sort((left, right) => + right.lastTimestamp.localeCompare(left.lastTimestamp) + ) +} + +function mergeSessions( + target: Map, + sessions: OpenCodeUsageSession[] +): void { + for (const session of sessions) { + const existing = target.get(session.sessionId) + if (!existing) { + target.set(session.sessionId, structuredClone(session)) + continue + } + + existing.firstTimestamp = + session.firstTimestamp < existing.firstTimestamp + ? session.firstTimestamp + : existing.firstTimestamp + existing.lastTimestamp = + session.lastTimestamp > existing.lastTimestamp + ? session.lastTimestamp + : existing.lastTimestamp + existing.eventCount += session.eventCount + existing.totalInputTokens += session.totalInputTokens + existing.totalCachedInputTokens += session.totalCachedInputTokens + existing.totalOutputTokens += session.totalOutputTokens + existing.totalReasoningOutputTokens += session.totalReasoningOutputTokens + existing.totalTokens += session.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, session.estimatedCostUsd) + + for (const location of session.locationBreakdown) { + const existingLocation = + existing.locationBreakdown.find((entry) => entry.locationKey === location.locationKey) ?? + null + if (existingLocation) { + existingLocation.eventCount += location.eventCount + existingLocation.inputTokens += location.inputTokens + existingLocation.cachedInputTokens += location.cachedInputTokens + existingLocation.outputTokens += location.outputTokens + existingLocation.reasoningOutputTokens += location.reasoningOutputTokens + existingLocation.totalTokens += location.totalTokens + existingLocation.estimatedCostUsd = addCost( + existingLocation.estimatedCostUsd, + location.estimatedCostUsd + ) + } else { + existing.locationBreakdown.push({ ...location }) + } + } + + for (const model of session.modelBreakdown) { + const existingModel = + existing.modelBreakdown.find((entry) => entry.modelKey === model.modelKey) ?? null + if (existingModel) { + existingModel.eventCount += model.eventCount + existingModel.inputTokens += model.inputTokens + existingModel.cachedInputTokens += model.cachedInputTokens + existingModel.outputTokens += model.outputTokens + existingModel.reasoningOutputTokens += model.reasoningOutputTokens + existingModel.totalTokens += model.totalTokens + existingModel.estimatedCostUsd = addCost( + existingModel.estimatedCostUsd, + model.estimatedCostUsd + ) + } else { + existing.modelBreakdown.push({ ...model }) + } + } + + for (const locationModel of session.locationModelBreakdown) { + const existingLocationModel = + existing.locationModelBreakdown.find( + (entry) => + entry.locationKey === locationModel.locationKey && + entry.modelKey === locationModel.modelKey + ) ?? null + if (existingLocationModel) { + existingLocationModel.eventCount += locationModel.eventCount + existingLocationModel.inputTokens += locationModel.inputTokens + existingLocationModel.cachedInputTokens += locationModel.cachedInputTokens + existingLocationModel.outputTokens += locationModel.outputTokens + existingLocationModel.reasoningOutputTokens += locationModel.reasoningOutputTokens + existingLocationModel.totalTokens += locationModel.totalTokens + existingLocationModel.estimatedCostUsd = addCost( + existingLocationModel.estimatedCostUsd, + locationModel.estimatedCostUsd + ) + } else { + existing.locationModelBreakdown.push({ ...locationModel }) + } + } + } +} + +function mergeDailyAggregates( + target: Map, + dailyAggregates: OpenCodeUsageDailyAggregate[] +): void { + for (const aggregate of dailyAggregates) { + const key = [aggregate.day, aggregate.model ?? 'unknown', aggregate.projectKey].join('::') + const existing = target.get(key) + if (!existing) { + target.set(key, { ...aggregate }) + continue + } + existing.eventCount += aggregate.eventCount + existing.inputTokens += aggregate.inputTokens + existing.cachedInputTokens += aggregate.cachedInputTokens + existing.outputTokens += aggregate.outputTokens + existing.reasoningOutputTokens += aggregate.reasoningOutputTokens + existing.totalTokens += aggregate.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, aggregate.estimatedCostUsd) + } +} + +export async function parseOpenCodeUsageDatabase( + dbPath: string, + worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[] +): Promise { + const processedDatabase = await getProcessedDatabaseInfo(dbPath) + const db = new Database(dbPath, { readonly: true, fileMustExist: true }) + try { + db.pragma('query_only = ON') + const events: OpenCodeUsageAttributedEvent[] = [] + for (const row of selectUsageRows(db)) { + const parsed = parseOpenCodeUsageRow(row) + if (!parsed) { + continue + } + const attributed = await attributeOpenCodeUsageEvent(parsed, worktrees) + if (attributed) { + events.push(attributed) + } + } + return { + ...processedDatabase, + ...aggregateOpenCodeUsage(events) + } + } finally { + db.close() + } +} + +export async function scanOpenCodeUsageDatabases( + worktrees: OpenCodeUsageWorktreeRef[], + previousProcessedDatabases: OpenCodeUsagePersistedDatabase[] +): Promise<{ + processedDatabases: OpenCodeUsagePersistedDatabase[] + sessions: OpenCodeUsageSession[] + dailyAggregates: OpenCodeUsageDailyAggregate[] +}> { + const dbPaths = await listOpenCodeDatabases() + const previousByPath = new Map( + previousProcessedDatabases.map((database) => [database.path, database]) + ) + const processedDatabases: OpenCodeUsagePersistedDatabase[] = [] + const worktreesWithCanonicalPaths = await buildWorktreesWithCanonicalPaths(worktrees) + const sessionsById = new Map() + const dailyByKey = new Map() + + for (const [index, dbPath] of dbPaths.entries()) { + const databaseInfo = await getProcessedDatabaseInfo(dbPath) + const previous = previousByPath.get(dbPath) + const canReuse = + previous && previous.mtimeMs === databaseInfo.mtimeMs && previous.size === databaseInfo.size + const processed = canReuse + ? previous + : await parseOpenCodeUsageDatabase(dbPath, worktreesWithCanonicalPaths) + + processedDatabases.push(processed) + mergeSessions(sessionsById, processed.sessions) + mergeDailyAggregates(dailyByKey, processed.dailyAggregates) + + if ((index + 1) % YIELD_EVERY_DATABASES === 0) { + await yieldToEventLoop() + } + } + + return { + processedDatabases, + sessions: finalizeSessions(sessionsById), + dailyAggregates: [...dailyByKey.values()].sort((left, right) => + left.day === right.day + ? left.projectLabel.localeCompare(right.projectLabel) + : left.day.localeCompare(right.day) + ) + } +} + +export function createWorktreeRefs( + repos: Repo[], + worktreesByRepo: Map +): OpenCodeUsageWorktreeRef[] { + const refs: OpenCodeUsageWorktreeRef[] = [] + for (const repo of repos) { + for (const worktree of worktreesByRepo.get(repo.id) ?? []) { + refs.push({ + repoId: repo.id, + worktreeId: worktree.worktreeId, + path: worktree.path, + displayName: worktree.displayName + }) + } + } + return refs +} diff --git a/src/main/opencode-usage/store.test.ts b/src/main/opencode-usage/store.test.ts new file mode 100644 index 00000000000..bca001390c2 --- /dev/null +++ b/src/main/opencode-usage/store.test.ts @@ -0,0 +1,309 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + OpenCodeUsageDailyAggregate, + OpenCodeUsagePersistedState, + OpenCodeUsageSession +} from './types' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/tmp/orca-test-userdata') +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +import { OpenCodeUsageStore, normalizePersistedState } from './store' + +function getDefaultState(): OpenCodeUsagePersistedState { + return { + schemaVersion: 1, + worktreeFingerprint: null, + processedDatabases: [], + sessions: [], + dailyAggregates: [], + scanState: { + enabled: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null + } + } +} + +function createStoreWithState(state: Partial): OpenCodeUsageStore { + const store = new OpenCodeUsageStore({ + getRepos: () => [], + getWorktreeMeta: () => undefined + } as never) + + ;(store as unknown as { state: OpenCodeUsagePersistedState }).state = { + ...getDefaultState(), + ...state + } + + return store +} + +function makeSession(overrides: Partial = {}): OpenCodeUsageSession { + const worktreeId = overrides.primaryWorktreeId ?? 'repo-1::/workspace/repo' + const repoId = overrides.primaryRepoId ?? 'repo-1' + const projectLabel = overrides.primaryProjectLabel ?? 'Repo' + const model = overrides.primaryModel ?? 'anthropic/claude-sonnet-4-5' + return { + sessionId: 'session-1', + firstTimestamp: '2026-04-09T10:00:00.000Z', + lastTimestamp: '2026-04-09T10:10:00.000Z', + primaryModel: model, + hasMixedModels: false, + primaryProjectLabel: projectLabel, + hasMixedLocations: false, + primaryWorktreeId: worktreeId, + primaryRepoId: repoId, + eventCount: 1, + totalInputTokens: 1000, + totalCachedInputTokens: 400, + totalOutputTokens: 250, + totalReasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05, + locationBreakdown: [ + { + locationKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo', + projectLabel, + repoId, + worktreeId, + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05 + } + ], + modelBreakdown: [ + { + modelKey: model ?? 'unknown', + modelLabel: model ?? 'Unknown model', + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05 + } + ], + locationModelBreakdown: [ + { + locationKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo', + modelKey: model ?? 'unknown', + modelLabel: model ?? 'Unknown model', + repoId, + worktreeId, + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05 + } + ], + ...overrides + } +} + +function makeDaily( + overrides: Partial = {} +): OpenCodeUsageDailyAggregate { + const worktreeId = overrides.worktreeId ?? 'repo-1::/workspace/repo' + return { + day: '2026-04-09', + model: 'anthropic/claude-sonnet-4-5', + projectKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo', + projectLabel: worktreeId ? 'Repo' : 'outside/repo', + repoId: worktreeId ? 'repo-1' : null, + worktreeId, + eventCount: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350, + estimatedCostUsd: 0.05, + ...overrides + } +} + +describe('OpenCodeUsageStore', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-10T12:00:00.000-04:00')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('reports no data for Orca scope when only non-Orca OpenCode usage exists', async () => { + const store = createStoreWithState({ + sessions: [ + makeSession({ + primaryProjectLabel: 'outside/repo', + primaryWorktreeId: null, + primaryRepoId: null + }) + ], + dailyAggregates: [ + makeDaily({ + projectKey: 'cwd:/outside/repo', + projectLabel: 'outside/repo', + repoId: null, + worktreeId: null + }) + ] + }) + + const summary = await store.getSummary('orca', '30d') + + expect(summary.hasAnyOpenCodeData).toBe(false) + expect(summary.sessions).toBe(0) + expect(summary.events).toBe(0) + }) + + it('uses recorded OpenCode costs and token totals without model pricing inference', async () => { + const store = createStoreWithState({ + sessions: [ + makeSession({ sessionId: 'session-1' }), + makeSession({ + sessionId: 'session-2', + primaryModel: 'openai/gpt-5.5', + totalTokens: 2000, + estimatedCostUsd: null, + modelBreakdown: [ + { + modelKey: 'openai/gpt-5.5', + modelLabel: 'openai/gpt-5.5', + eventCount: 1, + inputTokens: 1500, + cachedInputTokens: 200, + outputTokens: 500, + reasoningOutputTokens: 0, + totalTokens: 2000, + estimatedCostUsd: null + } + ] + }) + ], + dailyAggregates: [ + makeDaily(), + makeDaily({ + model: 'openai/gpt-5.5', + eventCount: 2, + inputTokens: 1500, + cachedInputTokens: 200, + outputTokens: 500, + reasoningOutputTokens: 0, + totalTokens: 2000, + estimatedCostUsd: null + }) + ] + }) + + const summary = await store.getSummary('orca', '30d') + const daily = await store.getDaily('orca', '30d') + const breakdown = await store.getBreakdown('orca', '30d', 'model') + + expect(summary).toMatchObject({ + sessions: 2, + events: 3, + inputTokens: 2500, + cachedInputTokens: 600, + outputTokens: 750, + reasoningOutputTokens: 100, + totalTokens: 3350, + estimatedCostUsd: 0.05, + topModel: 'openai/gpt-5.5', + topProject: 'Repo', + hasAnyOpenCodeData: true + }) + expect(daily).toEqual([ + { + day: '2026-04-09', + inputTokens: 2500, + cachedInputTokens: 600, + outputTokens: 750, + reasoningOutputTokens: 100, + totalTokens: 3350 + } + ]) + expect(breakdown.find((row) => row.key === 'openai/gpt-5.5')).toMatchObject({ + sessions: 1, + estimatedCostUsd: null + }) + }) + + it('returns recent sessions with OpenCode event and token fields', async () => { + const store = createStoreWithState({ + sessions: [makeSession()], + dailyAggregates: [makeDaily()] + }) + + const sessions = await store.getRecentSessions('orca', '30d', 5) + + expect(sessions).toEqual([ + { + sessionId: 'session-1', + lastActiveAt: '2026-04-09T10:10:00.000Z', + durationMinutes: 10, + projectLabel: 'Repo', + model: 'anthropic/claude-sonnet-4-5', + events: 1, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + reasoningOutputTokens: 100, + totalTokens: 1350 + } + ]) + }) + + it('normalizes persisted OpenCode state by schema version', () => { + expect( + normalizePersistedState({ + ...getDefaultState(), + schemaVersion: 0, + processedDatabases: [ + { + path: '/tmp/opencode.db', + mtimeMs: 1, + size: 2, + sessions: [makeSession()], + dailyAggregates: [makeDaily()] + } + ], + sessions: [makeSession()], + dailyAggregates: [makeDaily()] + }) + ).toEqual(getDefaultState()) + + expect( + normalizePersistedState({ + ...getDefaultState(), + processedDatabases: [ + { + path: '/tmp/opencode.db', + mtimeMs: 1, + size: 2, + sessions: [], + dailyAggregates: [] + } + ] + }).processedDatabases + ).toHaveLength(1) + }) +}) diff --git a/src/main/opencode-usage/store.ts b/src/main/opencode-usage/store.ts new file mode 100644 index 00000000000..1fd7d50830c --- /dev/null +++ b/src/main/opencode-usage/store.ts @@ -0,0 +1,464 @@ +/* eslint-disable max-lines -- Why: this store owns OpenCode analytics persistence, scan policy, and renderer query semantics. Keeping range/scope queries next to scan persistence prevents UI totals from drifting from the SQLite projection. */ +import { app } from 'electron' +import { dirname, join } from 'path' +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs' +import type { + OpenCodeUsageBreakdownKind, + OpenCodeUsageBreakdownRow, + OpenCodeUsageDailyPoint, + OpenCodeUsageRange, + OpenCodeUsageScanState, + OpenCodeUsageScope, + OpenCodeUsageSessionRow, + OpenCodeUsageSummary +} from '../../shared/opencode-usage-types' +import type { Store } from '../persistence' +import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' +import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types' +import { createWorktreeRefs, scanOpenCodeUsageDatabases } from './scanner' + +const SCHEMA_VERSION = 1 +const STALE_MS = 5 * 60_000 + +let _openCodeUsageFile: string | null = null + +function getDefaultState(): OpenCodeUsagePersistedState { + return { + schemaVersion: SCHEMA_VERSION, + worktreeFingerprint: null, + processedDatabases: [], + sessions: [], + dailyAggregates: [], + scanState: { + enabled: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null + } + } +} + +export function normalizePersistedState( + state: OpenCodeUsagePersistedState +): OpenCodeUsagePersistedState { + if (state.schemaVersion !== SCHEMA_VERSION) { + return getDefaultState() + } + return { + ...state, + processedDatabases: (state.processedDatabases ?? []).map((database) => ({ + ...database, + sessions: (database.sessions ?? []).map(normalizeSessionCost), + dailyAggregates: (database.dailyAggregates ?? []).map(normalizeDailyAggregateCost) + })), + sessions: state.sessions.map(normalizeSessionCost), + dailyAggregates: state.dailyAggregates.map(normalizeDailyAggregateCost) + } +} + +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 +} + +function getRangeCutoff(range: OpenCodeUsageRange): string | null { + if (range === 'all') { + return null + } + const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 + const now = new Date() + now.setHours(0, 0, 0, 0) + now.setDate(now.getDate() - (days - 1)) + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function getLocalDay(timestamp: string): string | null { + const parsed = new Date(timestamp) + if (Number.isNaN(parsed.getTime())) { + return null + } + const year = parsed.getFullYear() + const month = String(parsed.getMonth() + 1).padStart(2, '0') + const day = String(parsed.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function getWorktreeFingerprint(worktreesByRepo: Map): string { + const rows = [...worktreesByRepo.entries()] + .flatMap(([repoId, worktrees]) => + worktrees.map((worktree) => + JSON.stringify({ + repoId, + worktreeId: worktree.worktreeId, + path: worktree.path, + displayName: worktree.displayName + }) + ) + ) + .sort() + return JSON.stringify(rows) +} + +function addCost(left: number | null, right: number | null): number | null { + if (left === null && right === null) { + return null + } + return (left ?? 0) + (right ?? 0) +} + +function normalizeDailyAggregateCost( + entry: OpenCodeUsageDailyAggregate +): OpenCodeUsageDailyAggregate { + return { + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + } +} + +function normalizeSessionCost( + session: OpenCodeUsagePersistedState['sessions'][number] +): OpenCodeUsagePersistedState['sessions'][number] { + return { + ...session, + estimatedCostUsd: session.estimatedCostUsd ?? null, + locationBreakdown: (session.locationBreakdown ?? []).map((entry) => ({ + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + })), + modelBreakdown: (session.modelBreakdown ?? []).map((entry) => ({ + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + })), + locationModelBreakdown: (session.locationModelBreakdown ?? []).map((entry) => ({ + ...entry, + estimatedCostUsd: entry.estimatedCostUsd ?? null + })) + } +} + +export class OpenCodeUsageStore { + private state: OpenCodeUsagePersistedState + private readonly store: Store + private scanPromise: Promise | null = null + + constructor(store: Store) { + this.store = store + this.state = this.load() + } + + private load(): OpenCodeUsagePersistedState { + try { + const usageFile = getOpenCodeUsageFile() + if (!existsSync(usageFile)) { + return getDefaultState() + } + const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as OpenCodeUsagePersistedState + return normalizePersistedState({ + ...getDefaultState(), + ...parsed, + scanState: { + ...getDefaultState().scanState, + ...parsed.scanState + } + }) + } catch (error) { + console.error('[opencode-usage] Failed to load persisted state, starting fresh:', error) + return getDefaultState() + } + } + + private writeToDisk(): void { + const usageFile = getOpenCodeUsageFile() + const dir = dirname(usageFile) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8') + renameSync(tmpFile, usageFile) + } + + async setEnabled(enabled: boolean): Promise { + this.state.scanState.enabled = enabled + this.writeToDisk() + return this.getScanState() + } + + getScanState(): OpenCodeUsageScanState { + return { + ...this.state.scanState, + isScanning: this.scanPromise !== null, + hasAnyOpenCodeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 + } + } + + async refresh(force = false): Promise { + if (!this.state.scanState.enabled) { + return this.getScanState() + } + const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() + 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() + return this.getScanState() + } + + private async runScan(): Promise { + if (this.scanPromise) { + await this.scanPromise + return + } + + this.state.scanState.lastScanStartedAt = Date.now() + this.state.scanState.lastScanError = null + this.writeToDisk() + + this.scanPromise = (async () => { + try { + const repos = this.store.getRepos() + const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) + const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) + const result = await scanOpenCodeUsageDatabases( + createWorktreeRefs(repos, worktreesByRepo), + this.state.worktreeFingerprint === worktreeFingerprint + ? this.state.processedDatabases + : [] + ) + this.state.processedDatabases = result.processedDatabases + this.state.sessions = result.sessions + this.state.dailyAggregates = result.dailyAggregates + this.state.worktreeFingerprint = worktreeFingerprint + this.state.scanState.lastScanCompletedAt = Date.now() + this.state.scanState.lastScanError = null + this.writeToDisk() + } catch (error) { + this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) + this.writeToDisk() + } finally { + this.scanPromise = null + } + })() + + await this.scanPromise + } + + async getSummary( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange + ): Promise { + await this.refresh(false) + const filteredDaily = this.getFilteredDaily(scope, range) + const filteredSessions = this.getFilteredSessions(scope, range) + + let inputTokens = 0 + let cachedInputTokens = 0 + let outputTokens = 0 + let reasoningOutputTokens = 0 + let totalTokens = 0 + let events = 0 + let estimatedCostUsd: number | null = null + const byModel = new Map() + const byProject = new Map() + + for (const row of filteredDaily) { + inputTokens += row.inputTokens + cachedInputTokens += row.cachedInputTokens + outputTokens += row.outputTokens + reasoningOutputTokens += row.reasoningOutputTokens + totalTokens += row.totalTokens + events += row.eventCount + estimatedCostUsd = addCost(estimatedCostUsd, row.estimatedCostUsd) + byModel.set( + row.model ?? 'Unknown model', + (byModel.get(row.model ?? 'Unknown model') ?? 0) + row.totalTokens + ) + byProject.set(row.projectLabel, (byProject.get(row.projectLabel) ?? 0) + row.totalTokens) + } + + const topModel = + [...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null + const topProject = + [...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null + + return { + scope, + range, + sessions: filteredSessions.length, + events, + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + totalTokens, + estimatedCostUsd, + topModel, + topProject, + hasAnyOpenCodeData: filteredSessions.length > 0 || filteredDaily.length > 0 + } + } + + async getDaily( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange + ): Promise { + await this.refresh(false) + const byDay = new Map() + for (const row of this.getFilteredDaily(scope, range)) { + const existing = byDay.get(row.day) ?? { + day: row.day, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0 + } + existing.inputTokens += row.inputTokens + existing.cachedInputTokens += row.cachedInputTokens + existing.outputTokens += row.outputTokens + existing.reasoningOutputTokens += row.reasoningOutputTokens + existing.totalTokens += row.totalTokens + byDay.set(row.day, existing) + } + return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day)) + } + + async getBreakdown( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange, + kind: OpenCodeUsageBreakdownKind + ): Promise { + await this.refresh(false) + const rows = new Map() + const filteredDaily = this.getFilteredDaily(scope, range) + const filteredSessions = this.getFilteredSessions(scope, range) + + for (const daily of filteredDaily) { + const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey + const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel + const existing = rows.get(key) ?? { + key, + label, + sessions: 0, + events: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + estimatedCostUsd: null + } + existing.events += daily.eventCount + existing.inputTokens += daily.inputTokens + existing.cachedInputTokens += daily.cachedInputTokens + existing.outputTokens += daily.outputTokens + existing.reasoningOutputTokens += daily.reasoningOutputTokens + existing.totalTokens += daily.totalTokens + existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, daily.estimatedCostUsd) + rows.set(key, existing) + } + + if (kind === 'model') { + for (const session of filteredSessions) { + for (const entry of session.modelBreakdown) { + const row = rows.get(entry.modelKey) + if (row) { + row.sessions++ + } + } + } + } else { + for (const session of filteredSessions) { + for (const entry of session.locationBreakdown) { + const row = rows.get(entry.locationKey) + if (row) { + row.sessions++ + } + } + } + } + + return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens) + } + + async getRecentSessions( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange, + limit = 10 + ): Promise { + await this.refresh(false) + return this.getFilteredSessions(scope, range) + .slice(0, limit) + .map( + (session): OpenCodeUsageSessionRow => ({ + sessionId: session.sessionId, + lastActiveAt: session.lastTimestamp, + durationMinutes: Math.max( + 0, + Math.round( + (new Date(session.lastTimestamp).getTime() - + new Date(session.firstTimestamp).getTime()) / + 60_000 + ) + ), + projectLabel: session.primaryProjectLabel, + model: session.primaryModel, + events: session.eventCount, + inputTokens: session.totalInputTokens, + cachedInputTokens: session.totalCachedInputTokens, + outputTokens: session.totalOutputTokens, + reasoningOutputTokens: session.totalReasoningOutputTokens, + totalTokens: session.totalTokens + }) + ) + } + + private getFilteredDaily( + scope: OpenCodeUsageScope, + range: OpenCodeUsageRange + ): OpenCodeUsageDailyAggregate[] { + const cutoff = getRangeCutoff(range) + return this.state.dailyAggregates.filter((row) => { + if (scope === 'orca' && !row.worktreeId) { + return false + } + if (cutoff && row.day < cutoff) { + return false + } + return true + }) + } + + private getFilteredSessions(scope: OpenCodeUsageScope, range: OpenCodeUsageRange) { + const cutoff = getRangeCutoff(range) + return this.state.sessions.filter((session) => { + if (scope === 'orca' && !session.primaryWorktreeId) { + return false + } + if (cutoff) { + const day = getLocalDay(session.lastTimestamp) + if (!day || day < cutoff) { + return false + } + } + return true + }) + } + + private async getCurrentWorktreeFingerprint(): Promise { + const repos = this.store.getRepos() + return getWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos)) + } +} diff --git a/src/main/opencode-usage/types.ts b/src/main/opencode-usage/types.ts new file mode 100644 index 00000000000..ed7f3d2143c --- /dev/null +++ b/src/main/opencode-usage/types.ts @@ -0,0 +1,124 @@ +export type OpenCodeUsageProcessedDatabase = { + path: string + mtimeMs: number + size: number +} + +export type OpenCodeUsageLocationBreakdown = { + locationKey: string + projectLabel: string + repoId: string | null + worktreeId: string | null + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null +} + +export type OpenCodeUsageModelBreakdown = { + modelKey: string + modelLabel: string + estimatedCostUsd: number | null + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} + +export type OpenCodeUsageLocationModelBreakdown = { + locationKey: string + modelKey: string + modelLabel: string + repoId: string | null + worktreeId: string | null + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null +} + +export type OpenCodeUsageSession = { + sessionId: string + firstTimestamp: string + lastTimestamp: string + primaryModel: string | null + hasMixedModels: boolean + primaryProjectLabel: string + hasMixedLocations: boolean + primaryWorktreeId: string | null + primaryRepoId: string | null + eventCount: number + totalInputTokens: number + totalCachedInputTokens: number + totalOutputTokens: number + totalReasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null + locationBreakdown: OpenCodeUsageLocationBreakdown[] + modelBreakdown: OpenCodeUsageModelBreakdown[] + locationModelBreakdown: OpenCodeUsageLocationModelBreakdown[] +} + +export type OpenCodeUsageDailyAggregate = { + day: string + model: string | null + projectKey: string + projectLabel: string + repoId: string | null + worktreeId: string | null + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null +} + +export type OpenCodeUsagePersistedDatabase = OpenCodeUsageProcessedDatabase & { + sessions: OpenCodeUsageSession[] + dailyAggregates: OpenCodeUsageDailyAggregate[] +} + +export type OpenCodeUsagePersistedState = { + schemaVersion: number + worktreeFingerprint: string | null + processedDatabases: OpenCodeUsagePersistedDatabase[] + sessions: OpenCodeUsageSession[] + dailyAggregates: OpenCodeUsageDailyAggregate[] + scanState: { + enabled: boolean + lastScanStartedAt: number | null + lastScanCompletedAt: number | null + lastScanError: string | null + } +} + +export type OpenCodeUsageParsedEvent = { + sessionId: string + timestamp: string + model: string | null + cwd: string | null + estimatedCostUsd: number | null + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} + +export type OpenCodeUsageAttributedEvent = OpenCodeUsageParsedEvent & { + day: string + projectKey: string + projectLabel: string + repoId: string | null + worktreeId: string | null +} diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 07e5868dc74..e91e6b5d7a4 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -1370,6 +1370,567 @@ describe('Store', () => { expect(store.getSshRemotePtyLeases('ssh-1')[0].leafId).toBe(leafId) }) + it('hydrates legacy numeric agent status cache through the pane identity migration', async () => { + const agentHooksDir = join(testState.dir, 'agent-hooks') + mkdirSync(agentHooksDir, { recursive: true }) + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'local-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'local-pty' } + } + } + } + }) + writeFileSync( + join(agentHooksDir, 'last-status.json'), + JSON.stringify({ + version: 2, + entries: { + 'tab1:1': { + paneKey: 'tab1:1', + tabId: 'tab1', + worktreeId: 'wt1', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now() - 1000, + payload: { state: 'working', prompt: 'legacy numeric prompt', agentType: 'claude' } + } + } + }), + 'utf-8' + ) + + const store = await createStore() + const { agentHookServer } = await import('./agent-hooks/server') + await agentHookServer.start({ env: 'production', userDataPath: testState.dir }) + try { + const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 + const leafId = layout.root?.type === 'leaf' ? layout.root.leafId : null + if (leafId === null) { + throw new Error('Expected remapped leaf id') + } + const stablePaneKey = makePaneKey('tab1', leafId) + expect(agentHookServer.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: stablePaneKey, + tabId: 'tab1', + worktreeId: 'wt1', + state: 'working', + prompt: 'legacy numeric prompt', + agentType: 'claude' + }) + ]) + } finally { + agentHookServer.stop() + } + }) + + it('hydrates split-pane legacy numeric agent status rows onto the matching remapped leaves', async () => { + const agentHooksDir = join(testState.dir, 'agent-hooks') + mkdirSync(agentHooksDir, { recursive: true }) + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'local-pty-1' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: 'pane:1' }, + second: { type: 'leaf', leafId: 'pane:2' }, + sizes: [50, 50] + }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'local-pty-1', 'pane:2': 'local-pty-2' } + } + } + } + }) + const now = Date.now() + writeFileSync( + join(agentHooksDir, 'last-status.json'), + JSON.stringify({ + version: 2, + entries: { + 'tab1:1': { + paneKey: 'tab1:1', + tabId: 'tab1', + worktreeId: 'wt1', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 2000, + payload: { state: 'working', prompt: 'left legacy prompt', agentType: 'claude' } + }, + 'tab1:2': { + paneKey: 'tab1:2', + tabId: 'tab1', + worktreeId: 'wt1', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 1000, + payload: { state: 'blocked', prompt: 'right legacy prompt', agentType: 'codex' } + } + } + }), + 'utf-8' + ) + + const store = await createStore() + const { agentHookServer } = await import('./agent-hooks/server') + await agentHookServer.start({ env: 'production', userDataPath: testState.dir }) + try { + const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 + const firstLeafId = + layout.root?.type === 'split' && layout.root.first.type === 'leaf' + ? layout.root.first.leafId + : null + const secondLeafId = + layout.root?.type === 'split' && layout.root.second.type === 'leaf' + ? layout.root.second.leafId + : null + if (firstLeafId === null || secondLeafId === null) { + throw new Error('Expected remapped split leaves') + } + const byPaneKey = new Map( + agentHookServer.getStatusSnapshot().map((entry) => [entry.paneKey, entry]) + ) + + expect(byPaneKey.get(makePaneKey('tab1', firstLeafId))).toEqual( + expect.objectContaining({ + state: 'working', + prompt: 'left legacy prompt', + agentType: 'claude' + }) + ) + expect(byPaneKey.get(makePaneKey('tab1', secondLeafId))).toEqual( + expect.objectContaining({ + state: 'blocked', + prompt: 'right legacy prompt', + agentType: 'codex' + }) + ) + } finally { + agentHookServer.stop() + } + }) + + it('hydrates split-pane legacy status rows even when PTY leaf bindings are absent', async () => { + const agentHooksDir = join(testState.dir, 'agent-hooks') + mkdirSync(agentHooksDir, { recursive: true }) + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'local-pty-1' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'pane:1' }, + second: { type: 'leaf', leafId: 'pane:2' }, + sizes: [50, 50] + }, + activeLeafId: 'pane:2', + expandedLeafId: null + } + } + } + }) + const now = Date.now() + writeFileSync( + join(agentHooksDir, 'last-status.json'), + JSON.stringify({ + version: 2, + entries: { + 'tab1:1': { + paneKey: 'tab1:1', + tabId: 'tab1', + worktreeId: 'wt1', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 2000, + payload: { state: 'working', prompt: 'left no binding', agentType: 'claude' } + }, + 'tab1:2': { + paneKey: 'tab1:2', + tabId: 'tab1', + worktreeId: 'wt1', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 1000, + payload: { state: 'blocked', prompt: 'right no binding', agentType: 'codex' } + } + } + }), + 'utf-8' + ) + + const store = await createStore() + const { agentHookServer } = await import('./agent-hooks/server') + await agentHookServer.start({ env: 'production', userDataPath: testState.dir }) + try { + const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 + const firstLeafId = + layout.root?.type === 'split' && layout.root.first.type === 'leaf' + ? layout.root.first.leafId + : null + const secondLeafId = + layout.root?.type === 'split' && layout.root.second.type === 'leaf' + ? layout.root.second.leafId + : null + if (firstLeafId === null || secondLeafId === null) { + throw new Error('Expected remapped split leaves') + } + const byPaneKey = new Map( + agentHookServer.getStatusSnapshot().map((entry) => [entry.paneKey, entry]) + ) + + expect(byPaneKey.get(makePaneKey('tab1', firstLeafId))).toEqual( + expect.objectContaining({ + state: 'working', + prompt: 'left no binding', + agentType: 'claude' + }) + ) + expect(byPaneKey.get(makePaneKey('tab1', secondLeafId))).toEqual( + expect.objectContaining({ + state: 'blocked', + prompt: 'right no binding', + agentType: 'codex' + }) + ) + } finally { + agentHookServer.stop() + } + }) + + it('persists legacy pane-key aliases after the layout has been normalized', async () => { + const agentHooksDir = join(testState.dir, 'agent-hooks') + mkdirSync(agentHooksDir, { recursive: true }) + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'local-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null, + ptyIdsByLeafId: { 'pane:1': 'local-pty' } + } + } + } + }) + + const firstStore = await createStore() + const root = firstStore.getWorkspaceSession().terminalLayoutsByTabId.tab1.root + const stableLeafId = root?.type === 'leaf' ? root.leafId : null + if (stableLeafId === null) { + throw new Error('Expected remapped leaf id') + } + const stablePaneKey = makePaneKey('tab1', stableLeafId) + firstStore.flush() + + expect(readDataFile()).toEqual( + expect.objectContaining({ + legacyPaneKeyAliasEntries: [ + expect.objectContaining({ + ptyId: 'local-pty', + legacyPaneKey: 'tab1:1', + stablePaneKey + }) + ] + }) + ) + + const now = Date.now() + writeFileSync( + join(agentHooksDir, 'last-status.json'), + JSON.stringify({ + version: 2, + entries: { + 'tab1:1': { + paneKey: 'tab1:1', + tabId: 'tab1', + worktreeId: 'wt1', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 1000, + payload: { state: 'working', prompt: 'post-normalize legacy prompt' } + } + } + }), + 'utf-8' + ) + + await createStore() + const { agentHookServer } = await import('./agent-hooks/server') + await agentHookServer.start({ env: 'production', userDataPath: testState.dir }) + try { + expect(agentHookServer.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: stablePaneKey, + state: 'working', + prompt: 'post-normalize legacy prompt' + }) + ]) + } finally { + agentHookServer.stop() + } + }) + + it('persists fallback aliases when a legacy split layout has no PTY leaf bindings', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'local-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'pane:1' }, + second: { type: 'leaf', leafId: 'pane:2' }, + sizes: [50, 50] + }, + activeLeafId: 'pane:2', + expandedLeafId: null + } + } + } + }) + + const store = await createStore() + const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1 + const firstLeafId = + layout.root?.type === 'split' && layout.root.first.type === 'leaf' + ? layout.root.first.leafId + : null + const secondLeafId = + layout.root?.type === 'split' && layout.root.second.type === 'leaf' + ? layout.root.second.leafId + : null + if ( + !firstLeafId || + !secondLeafId || + !isTerminalLeafId(firstLeafId) || + !isTerminalLeafId(secondLeafId) + ) { + throw new Error('Expected remapped split leaf ids') + } + const activePaneKey = makePaneKey('tab1', secondLeafId) + const firstPaneKey = makePaneKey('tab1', firstLeafId) + const secondPaneKey = makePaneKey('tab1', secondLeafId) + store.flush() + + expect(readDataFile()).toEqual( + expect.objectContaining({ + legacyPaneKeyAliasEntries: expect.arrayContaining([ + expect.objectContaining({ + ptyId: 'local-pty', + legacyPaneKey: 'tab1:0', + stablePaneKey: activePaneKey + }), + expect.objectContaining({ + ptyId: 'local-pty', + legacyPaneKey: 'tab1:1', + stablePaneKey: firstPaneKey + }), + expect.objectContaining({ + ptyId: 'local-pty', + legacyPaneKey: 'tab1:2', + stablePaneKey: secondPaneKey + }) + ]) + }) + ) + }) + + it('converts unambiguous dev migration rows into persisted aliases', async () => { + const stablePaneKey = makePaneKey('tab1', TEST_LEAF_1) + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: { + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab1', + tabsByWorktree: { + wt1: [ + { + id: 'tab1', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: 'local-pty' + } + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'local-pty' } + } + } + }, + migrationUnsupportedPtyEntries: [ + { + ptyId: 'local-pty', + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + paneKey: stablePaneKey, + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 123 + } + ] + }) + + const store = await createStore() + store.flush() + + expect(readDataFile()).toEqual( + expect.objectContaining({ + migrationUnsupportedPtyEntries: [], + legacyPaneKeyAliasEntries: expect.arrayContaining([ + expect.objectContaining({ + ptyId: 'local-pty', + legacyPaneKey: 'tab1:0', + stablePaneKey + }), + expect.objectContaining({ + ptyId: 'local-pty', + legacyPaneKey: 'tab1:1', + stablePaneKey + }) + ]) + }) + ) + }) + it('remaps legacy SSH lease leaf ids by PTY when the layout is already normalized', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 75186b8c99a..3c155fc8bd1 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -37,6 +37,7 @@ import type { OnboardingChecklistState, OnboardingOutcome, OnboardingState, + LegacyPaneKeyAliasEntry, TerminalPaneLayoutNode, TerminalLayoutSnapshot, TerminalTab, @@ -57,11 +58,17 @@ import { ONBOARDING_FINAL_STEP } from '../shared/constants' import { parseWorkspaceSession } from '../shared/workspace-session-schema' -import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../shared/stable-pane-id' +import { + isTerminalLeafId, + makePaneKey, + parseLegacyNumericPaneKey, + parsePaneKey +} from '../shared/stable-pane-id' import { setMigrationUnsupportedPty, setMigrationUnsupportedPtyPersistenceListener } from './agent-hooks/migration-unsupported-pty-state' +import { agentHookServer } from './agent-hooks/server' import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session-terminal-buffers' import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history' import { getRepoIdFromWorktreeId } from '../shared/worktree-id' @@ -413,64 +420,135 @@ function findWorktreeIdForTab(session: WorkspaceSessionState, tabId: string): st return undefined } +type PaneIdentityMigrationEntries = { + migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] + legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] +} + function collectMigrationUnsupportedPtyEntries(args: { session: WorkspaceSessionState tabId: string inputLayout: TerminalLayoutSnapshot normalizedLayout: TerminalLayoutSnapshot leafIdByInputLeafId: Map - sourceForPtyId: (ptyId: string) => 'local' | 'ssh' -}): MigrationUnsupportedPtyEntry[] { - const entries: MigrationUnsupportedPtyEntry[] = [] +}): PaneIdentityMigrationEntries { const worktreeId = findWorktreeIdForTab(args.session, args.tabId) const tab = worktreeId ? args.session.tabsByWorktree?.[worktreeId]?.find((entry) => entry.id === args.tabId) : undefined - const pushEntry = (ptyId: string, leafId: string): void => { + const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] + const registeredLegacyPaneKeys = new Set() + const hasLeafPtyBindings = Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}).length > 0 + const fallbackPtyId = + !hasLeafPtyBindings && typeof tab?.ptyId === 'string' ? tab.ptyId : undefined + const registerLegacyAlias = (inputLeafId: string, leafId: string, ptyId?: string): boolean => { if (!isTerminalLeafId(leafId)) { - return + return false } let paneKey: string try { paneKey = makePaneKey(args.tabId, leafId) } catch { - return + return false } - entries.push({ - ptyId, - ...(worktreeId ? { worktreeId } : {}), - tabId: args.tabId, - leafId, - paneKey, - reason: 'legacy-numeric-pane-key', - source: args.sourceForPtyId(ptyId), - updatedAt: Date.now() - }) + const numeric = /^(?:pane:)?(\d+)$/.exec(inputLeafId)?.[1] + if (!numeric) { + return false + } + // Why: persisted PaneManager ids are 1-based. A zero-based alias in split + // layouts would make tab:1 ambiguous and can route the first pane to the second. + const legacyPaneKey = `${args.tabId}:${numeric}` + agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, ptyId) + registeredLegacyPaneKeys.add(legacyPaneKey) + if (ptyId) { + legacyPaneKeyAliasEntries.push({ + ptyId, + legacyPaneKey, + stablePaneKey: paneKey, + updatedAt: Date.now() + }) + return true + } + return false } - for (const [inputLeafId, ptyId] of Object.entries(args.inputLayout.ptyIdsByLeafId ?? {})) { + const inputLeafIds = new Set([ + ...collectLayoutLeafIdsInOrder(args.inputLayout.root), + ...Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}) + ]) + for (const inputLeafId of inputLeafIds) { if (isTerminalLeafId(inputLeafId)) { continue } const leafId = args.leafIdByInputLeafId.get(inputLeafId) if (leafId) { - pushEntry(ptyId, leafId) + registerLegacyAlias( + inputLeafId, + leafId, + args.inputLayout.ptyIdsByLeafId?.[inputLeafId] ?? fallbackPtyId + ) } } - if ( - entries.length === 0 && - tab?.ptyId && - Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}).length === 0 - ) { + if (tab?.ptyId && !hasLeafPtyBindings) { const fallbackLeafId = args.normalizedLayout.activeLeafId ?? firstLayoutLeafId(args.normalizedLayout.root) - if (fallbackLeafId) { - // Why: older single-pane sessions can reattach from tab.ptyId only, with - // no leaf binding to migrate. Their live shell env can still hold the - // legacy pane key, so surface the restart-required row for that PTY. - pushEntry(tab.ptyId, fallbackLeafId) + if (fallbackLeafId && isTerminalLeafId(fallbackLeafId)) { + const paneKey = makePaneKey(args.tabId, fallbackLeafId) + for (const legacyPaneKey of [`${args.tabId}:0`, `${args.tabId}:1`]) { + if (registeredLegacyPaneKeys.has(legacyPaneKey)) { + continue + } + agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, tab.ptyId) + legacyPaneKeyAliasEntries.push({ + ptyId: tab.ptyId, + legacyPaneKey, + stablePaneKey: paneKey, + updatedAt: Date.now() + }) + } } } - return entries + // Why: legacy numeric pane keys are now bridged by aliases instead of + // persisted as restart-required rows. Existing saved rows are pruned during + // normalizePersistedPaneIdentityState. + return { migrationUnsupportedEntries: [], legacyPaneKeyAliasEntries } +} + +function legacyMigrationUnsupportedRowsToAliasEntries( + entries: MigrationUnsupportedPtyEntry[] +): LegacyPaneKeyAliasEntry[] { + const normalizedEntries = normalizeMigrationUnsupportedPtyEntries(entries).filter( + (entry) => entry.tabId && entry.paneKey && parsePaneKey(entry.paneKey) + ) + const entriesByTabId = new Map() + for (const entry of normalizedEntries) { + const tabId = entry.tabId + if (!tabId) { + continue + } + entriesByTabId.set(tabId, [...(entriesByTabId.get(tabId) ?? []), entry]) + } + const aliasEntries: LegacyPaneKeyAliasEntry[] = [] + for (const [tabId, tabEntries] of entriesByTabId) { + if (tabEntries.length !== 1) { + continue + } + const [entry] = tabEntries + if (!entry.paneKey) { + continue + } + // Why: pre-stable dev/RC migration rows did not store the old numeric + // key. Only synthesize the single-pane aliases when the row is unambiguous + // for its tab; split rows need layout-derived aliases instead of a guess. + for (const legacyPaneKey of [`${tabId}:0`, `${tabId}:1`]) { + aliasEntries.push({ + ptyId: entry.ptyId, + legacyPaneKey, + stablePaneKey: entry.paneKey, + updatedAt: entry.updatedAt + }) + } + } + return aliasEntries } function normalizeTerminalLayoutSnapshotForPersistence( @@ -596,19 +674,20 @@ function normalizeTerminalLayoutSnapshotForPersistence( function normalizeWorkspaceSessionPaneIdentities( session: WorkspaceSessionState, - priorLayoutsByTabId: Record = {}, - sourceForPtyId: (ptyId: string) => 'local' | 'ssh' = () => 'local' + priorLayoutsByTabId: Record = {} ): { session: WorkspaceSessionState changed: boolean leafIdByInputLeafIdByTabId: Map> leafIdByPtyIdByTabId: Map> migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] + legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] } { let changed = false const leafIdByInputLeafIdByTabId = new Map>() const leafIdByPtyIdByTabId = new Map>() const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = [] + const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] const terminalLayoutsByTabId: Record = {} for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) { const normalized = normalizeTerminalLayoutSnapshotForPersistence( @@ -617,16 +696,15 @@ function normalizeWorkspaceSessionPaneIdentities( ) terminalLayoutsByTabId[tabId] = normalized.snapshot leafIdByInputLeafIdByTabId.set(tabId, normalized.leafIdByInputLeafId) - migrationUnsupportedEntries.push( - ...collectMigrationUnsupportedPtyEntries({ - session, - tabId, - inputLayout: layout, - normalizedLayout: normalized.snapshot, - leafIdByInputLeafId: normalized.leafIdByInputLeafId, - sourceForPtyId - }) - ) + const migrationEntries = collectMigrationUnsupportedPtyEntries({ + session, + tabId, + inputLayout: layout, + normalizedLayout: normalized.snapshot, + leafIdByInputLeafId: normalized.leafIdByInputLeafId + }) + migrationUnsupportedEntries.push(...migrationEntries.migrationUnsupportedEntries) + legacyPaneKeyAliasEntries.push(...migrationEntries.legacyPaneKeyAliasEntries) const leafIdByPtyId = new Map() const duplicatePtyIds = new Set() for (const [leafId, ptyId] of Object.entries(normalized.snapshot.ptyIdsByLeafId ?? {})) { @@ -648,7 +726,8 @@ function normalizeWorkspaceSessionPaneIdentities( changed, leafIdByInputLeafIdByTabId, leafIdByPtyIdByTabId, - migrationUnsupportedEntries + migrationUnsupportedEntries, + legacyPaneKeyAliasEntries } } @@ -686,21 +765,19 @@ function normalizePersistedPaneIdentityState(state: PersistedState): { state: PersistedState changed: boolean migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] + legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] } { - const sshPtyIds = new Set((state.sshRemotePtyLeases ?? []).map((lease) => lease.ptyId)) - const normalizedSession = normalizeWorkspaceSessionPaneIdentities( - state.workspaceSession, - {}, - (ptyId) => (sshPtyIds.has(ptyId) ? 'ssh' : 'local') - ) + const normalizedSession = normalizeWorkspaceSessionPaneIdentities(state.workspaceSession, {}) const remappedLeases = remapSshRemotePtyLeaseLeafIds( state.sshRemotePtyLeases ?? [], normalizedSession.leafIdByInputLeafIdByTabId, normalizedSession.leafIdByPtyIdByTabId ) - const mergedMigrationUnsupportedEntries = mergeMigrationUnsupportedPtyEntries([ - ...(state.migrationUnsupportedPtyEntries ?? []), - ...normalizedSession.migrationUnsupportedEntries + const mergedMigrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = [] + const mergedLegacyPaneKeyAliasEntries = mergeLegacyPaneKeyAliasEntries([ + ...normalizeLegacyPaneKeyAliasEntries(state.legacyPaneKeyAliasEntries), + ...legacyMigrationUnsupportedRowsToAliasEntries(state.migrationUnsupportedPtyEntries ?? []), + ...normalizedSession.legacyPaneKeyAliasEntries ]) const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys( state.ui?.acknowledgedAgentsByPaneKey, @@ -710,16 +787,22 @@ function normalizePersistedPaneIdentityState(state: PersistedState): { state.migrationUnsupportedPtyEntries ?? [], mergedMigrationUnsupportedEntries ) + const legacyAliasesChanged = !legacyPaneKeyAliasEntriesEqual( + state.legacyPaneKeyAliasEntries ?? [], + mergedLegacyPaneKeyAliasEntries + ) if ( !normalizedSession.changed && !remappedLeases.changed && !migrationUnsupportedChanged && + !legacyAliasesChanged && !remappedAcknowledgements.changed ) { return { state, changed: false, - migrationUnsupportedEntries: mergedMigrationUnsupportedEntries + migrationUnsupportedEntries: mergedMigrationUnsupportedEntries, + legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries } } return { @@ -728,6 +811,7 @@ function normalizePersistedPaneIdentityState(state: PersistedState): { workspaceSession: normalizedSession.session, sshRemotePtyLeases: remappedLeases.leases, migrationUnsupportedPtyEntries: mergedMigrationUnsupportedEntries, + legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries, ...(remappedAcknowledgements.changed ? { ui: { @@ -738,23 +822,11 @@ function normalizePersistedPaneIdentityState(state: PersistedState): { : {}) }, changed: true, - migrationUnsupportedEntries: mergedMigrationUnsupportedEntries + migrationUnsupportedEntries: mergedMigrationUnsupportedEntries, + legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries } } -function mergeMigrationUnsupportedPtyEntries( - entries: MigrationUnsupportedPtyEntry[] -): MigrationUnsupportedPtyEntry[] { - const byPtyId = new Map() - for (const entry of entries) { - const existing = byPtyId.get(entry.ptyId) - if (!existing || existing.updatedAt <= entry.updatedAt) { - byPtyId.set(entry.ptyId, entry) - } - } - return [...byPtyId.values()] -} - function remapAcknowledgedAgentPaneKeys( acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey'], leafIdByInputLeafIdByTabId: Map> @@ -827,6 +899,57 @@ function normalizeMigrationUnsupportedPtyEntries(value: unknown): MigrationUnsup }) } +function normalizeLegacyPaneKeyAliasEntries(value: unknown): LegacyPaneKeyAliasEntry[] { + if (!Array.isArray(value)) { + return [] + } + return value.filter((entry): entry is LegacyPaneKeyAliasEntry => { + if (!entry || typeof entry !== 'object') { + return false + } + const candidate = entry as Partial + if ( + typeof candidate.ptyId !== 'string' || + candidate.ptyId.trim().length === 0 || + typeof candidate.legacyPaneKey !== 'string' || + typeof candidate.stablePaneKey !== 'string' || + !Number.isFinite(candidate.updatedAt) + ) { + return false + } + const legacy = parseLegacyNumericPaneKey(candidate.legacyPaneKey) + const stable = parsePaneKey(candidate.stablePaneKey) + return Boolean(legacy && stable && legacy.tabId === stable.tabId) + }) +} + +function mergeLegacyPaneKeyAliasEntries( + entries: LegacyPaneKeyAliasEntry[] +): LegacyPaneKeyAliasEntry[] { + const byLegacyPaneKey = new Map() + for (const entry of normalizeLegacyPaneKeyAliasEntries(entries)) { + const existing = byLegacyPaneKey.get(entry.legacyPaneKey) + if (!existing || existing.updatedAt <= entry.updatedAt) { + byLegacyPaneKey.set(entry.legacyPaneKey, entry) + } + } + return [...byLegacyPaneKey.values()] +} + +function legacyPaneKeyAliasEntriesEqual( + left: LegacyPaneKeyAliasEntry[], + right: LegacyPaneKeyAliasEntry[] +): boolean { + if (left.length !== right.length) { + return false + } + const rightByLegacyPaneKey = new Map(right.map((entry) => [entry.legacyPaneKey, entry])) + return left.every((entry) => { + const other = rightByLegacyPaneKey.get(entry.legacyPaneKey) + return other ? JSON.stringify(entry) === JSON.stringify(other) : false + }) +} + function migrationUnsupportedEntriesEqual( left: MigrationUnsupportedPtyEntry[], right: MigrationUnsupportedPtyEntry[] @@ -881,10 +1004,23 @@ export class Store { for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) } + for (const entry of normalized.legacyPaneKeyAliasEntries) { + agentHookServer.registerPaneKeyAlias( + entry.legacyPaneKey, + entry.stablePaneKey, + entry.ptyId, + entry.updatedAt, + { overwriteExisting: false } + ) + } setMigrationUnsupportedPtyPersistenceListener((entries) => { this.state.migrationUnsupportedPtyEntries = entries this.scheduleSave() }) + agentHookServer.setPaneKeyAliasPersistenceListener((entries) => { + this.state.legacyPaneKeyAliasEntries = entries + this.scheduleSave() + }) if (normalized.changed) { // Why: upgraded sessions may contain legacy pane:1 leaves. Rewrite them at // the main persistence boundary so older renderer writes cannot revive them. @@ -1164,6 +1300,9 @@ export class Store { migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( parsed.migrationUnsupportedPtyEntries ), + legacyPaneKeyAliasEntries: normalizeLegacyPaneKeyAliasEntries( + parsed.legacyPaneKeyAliasEntries + ), automations: Array.isArray(parsed.automations) ? parsed.automations : [], automationRuns: Array.isArray(parsed.automationRuns) ? parsed.automationRuns : [], onboarding: (() => { @@ -1722,6 +1861,7 @@ export class Store { sessionKind: 'terminal', chatSessionId: null, terminalSessionId: null, + usage: null, error: null, startedAt: null, dispatchedAt: null, @@ -1744,6 +1884,7 @@ export class Store { status: result.status, workspaceId: result.workspaceId ?? current.workspaceId, terminalSessionId: result.terminalSessionId ?? current.terminalSessionId, + usage: Object.hasOwn(result, 'usage') ? (result.usage ?? null) : (current.usage ?? null), error: result.error ?? null, startedAt: current.startedAt ?? now, dispatchedAt: result.status === 'dispatched' ? now : current.dispatchedAt @@ -1938,11 +2079,9 @@ export class Store { // the durable binding and re-open the orphan window. Merge in any // existing bindings whenever the incoming snapshot's binding is empty. const prior = this.state.workspaceSession - const sshPtyIds = new Set((this.state.sshRemotePtyLeases ?? []).map((lease) => lease.ptyId)) const normalized = normalizeWorkspaceSessionPaneIdentities( session, - prior?.terminalLayoutsByTabId, - (ptyId) => (sshPtyIds.has(ptyId) ? 'ssh' : 'local') + prior?.terminalLayoutsByTabId ) for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) @@ -1957,6 +2096,15 @@ export class Store { acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements } } + for (const entry of normalized.legacyPaneKeyAliasEntries) { + agentHookServer.registerPaneKeyAlias( + entry.legacyPaneKey, + entry.stablePaneKey, + entry.ptyId, + entry.updatedAt, + { overwriteExisting: false } + ) + } session = normalized.session const remappedLeases = remapSshRemotePtyLeaseLeafIds( this.state.sshRemotePtyLeases ?? [], diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index df7aaa2b59c..0fdce54eb6b 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -211,6 +211,16 @@ import type { CodexUsageSessionRow, CodexUsageSummary } from '../shared/codex-usage-types' +import type { + OpenCodeUsageBreakdownKind, + OpenCodeUsageBreakdownRow, + OpenCodeUsageDailyPoint, + OpenCodeUsageRange, + OpenCodeUsageScanState, + OpenCodeUsageScope, + OpenCodeUsageSessionRow, + OpenCodeUsageSummary +} from '../shared/opencode-usage-types' import type { TelemetryConsentState } from '../shared/telemetry-consent-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' import type { @@ -449,6 +459,30 @@ export type CodexUsageApi = { }) => Promise } +export type OpenCodeUsageApi = { + getScanState: () => Promise + setEnabled: (args: { enabled: boolean }) => Promise + refresh: (args?: { force?: boolean }) => Promise + getSummary: (args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + }) => Promise + getDaily: (args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + }) => Promise + getBreakdown: (args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + kind: OpenCodeUsageBreakdownKind + }) => Promise + getRecentSessions: (args: { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + limit?: number + }) => Promise +} + export type AppApi = { /** Returns a URL base for feature-wall assets. In dev this is Vite /@fs; * in packaged builds this is file:// resources. Renderer appends filenames. */ @@ -1162,6 +1196,7 @@ export type PreloadApi = { memory: MemoryApi claudeUsage: ClaudeUsageApi codexUsage: CodexUsageApi + openCodeUsage: OpenCodeUsageApi fs: { readDir: (args: { dirPath: string; connectionId?: string }) => Promise readFile: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index e7330fd1b9c..f6348d1b7b6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2418,6 +2418,22 @@ const api = { ipcRenderer.invoke('codexUsage:getRecentSessions', args) }, + openCodeUsage: { + getScanState: (): Promise => ipcRenderer.invoke('openCodeUsage:getScanState'), + setEnabled: (args: { enabled: boolean }): Promise => + ipcRenderer.invoke('openCodeUsage:setEnabled', args), + refresh: (args?: { force?: boolean }): Promise => + ipcRenderer.invoke('openCodeUsage:refresh', args), + getSummary: (args: { scope: string; range: string }): Promise => + ipcRenderer.invoke('openCodeUsage:getSummary', args), + getDaily: (args: { scope: string; range: string }): Promise => + ipcRenderer.invoke('openCodeUsage:getDaily', args), + getBreakdown: (args: { scope: string; range: string; kind: string }): Promise => + ipcRenderer.invoke('openCodeUsage:getBreakdown', args), + getRecentSessions: (args: { scope: string; range: string; limit?: number }): Promise => + ipcRenderer.invoke('openCodeUsage:getRecentSessions', args) + }, + runtime: { syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise => ipcRenderer.invoke('runtime:syncWindowGraph', graph), diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 6c8ba28f167..05b6fd7a591 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -28,6 +28,7 @@ import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPreset import SmartWorkspaceNameField, { type SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' +import type { WorkspaceCreateErrorDisplay } from '@/lib/workspace-create-error-format' const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') @@ -65,7 +66,7 @@ type NewWorkspaceComposerCardProps = { onSetupDecisionChange: (value: 'run' | 'skip') => void shouldWaitForSetupCheck: boolean resolvedSetupDecision: 'run' | 'skip' | null - createError: string | null + createError: WorkspaceCreateErrorDisplay | null canUseSparseCheckout: boolean sparsePresets: SparsePreset[] sparseSelectedPresetId: string | null @@ -549,8 +550,19 @@ export default function NewWorkspaceComposerCard({ {createError ? ( -
- {createError} +
+ {createError.help ? ( +
+

{createError.title}

+

{createError.message}

+

{createError.help}

+
+ ) : ( + createError.message + )}
) : null} diff --git a/src/renderer/src/components/automations/AutomationDetail.tsx b/src/renderer/src/components/automations/AutomationDetail.tsx index 562f7ea5634..fe9228c4434 100644 --- a/src/renderer/src/components/automations/AutomationDetail.tsx +++ b/src/renderer/src/components/automations/AutomationDetail.tsx @@ -13,6 +13,12 @@ import { getAutomationRunStatusLabel, getAutomationRunStatusVariant } from './automation-page-parts' +import { + formatAutomationCost, + formatAutomationTokens, + getAutomationUsageStatusLabel, + summarizeAutomationRunUsage +} from './automation-usage-model' type AutomationDetailProps = { automation: Automation | null @@ -129,6 +135,13 @@ export function AutomationDetail({
) } + const usageSummary = summarizeAutomationRunUsage(runs) + const usageCoverage = + usageSummary.knownRuns > 0 + ? `${usageSummary.knownRuns}/${runs.length} runs` + : usageSummary.unavailableRuns > 0 + ? 'Unavailable' + : 'No runs' return (
@@ -175,8 +188,7 @@ export function AutomationDetail({
) : null} -
- +
+ + +
@@ -228,9 +246,11 @@ export function AutomationDetail({
Run history
{runs.length} runs
-
+
Run
Workspace
+
Spend
+
Tokens
Status
@@ -240,13 +260,16 @@ export function AutomationDetail({ ? (runWorktree?.displayName ?? 'Missing workspace') : 'Not launched' const rowClassName = - 'grid grid-cols-[minmax(10rem,1fr)_minmax(12rem,1.4fr)_minmax(6rem,auto)] items-center gap-3 px-3 py-2 text-left text-sm outline-none transition-colors' + 'grid grid-cols-[minmax(9rem,1fr)_minmax(11rem,1.2fr)_minmax(5rem,.55fr)_minmax(5rem,.55fr)_minmax(6rem,auto)] items-center gap-3 px-3 py-2 text-left text-sm outline-none transition-colors' + const usageLabel = getAutomationUsageStatusLabel(run.usage) const rowContent = ( <>
{formatAutomationDateTime(run.scheduledFor)}
- {run.error ? ( -
{run.error}
+ {run.error || run.usage?.status === 'unavailable' ? ( +
+ {run.error ?? run.usage?.unavailableMessage} +
) : null}
{workspaceLabel}
+
+ {formatAutomationCost(run.usage?.estimatedCostUsd)} +
+
+ {run.usage?.status === 'known' + ? formatAutomationTokens(run.usage.totalTokens) + : 'n/a'} +
{getAutomationRunStatusLabel(run.status)} diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 0027ee7d9eb..3d9cbc5371f 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -37,6 +37,11 @@ import type { import type { Worktree } from '../../../../shared/types' import { buildAutomationRrule, parseAutomationRrule } from '../../../../shared/automation-schedules' import { formatAutomationDateTimeWithRelative } from './automation-page-parts' +import { + formatAutomationCost, + formatAutomationTokens, + summarizeAutomationRunUsage +} from './automation-usage-model' import { AutomationDetail } from './AutomationDetail' import { AutomationEditorDialog, type AutomationDraft } from './AutomationEditorDialog' import { ExternalAutomationManagers } from './ExternalAutomationManagers' @@ -739,6 +744,17 @@ export default function AutomationsPage(): React.JSX.Element { automation.workspaceMode === 'new_per_run' ? 'New workspace each run' : (automationWorktree?.displayName ?? 'Missing workspace') + const usageSummary = summarizeAutomationRunUsage( + runs.filter((run) => run.automationId === automation.id) + ) + const usageText = + usageSummary.knownRuns > 0 + ? `${formatAutomationCost( + usageSummary.estimatedCostUsd + )} est. · ${formatAutomationTokens(usageSummary.totalTokens)} tokens` + : usageSummary.unavailableRuns > 0 + ? 'Usage unavailable' + : 'No run usage yet' return ( @@ -774,6 +790,7 @@ export default function AutomationsPage(): React.JSX.Element { )}` : 'Paused'} + {usageText} diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx index 865422a30cb..81d23b91c7d 100644 --- a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx @@ -121,6 +121,7 @@ export function ExternalAutomationManagers({ {job.enabled ? 'Active' : 'Paused'} + Usage unsupported
{job.schedule} · next {formatExternalDate(job.nextRunAt, now)} diff --git a/src/renderer/src/components/automations/automation-usage-model.test.ts b/src/renderer/src/components/automations/automation-usage-model.test.ts new file mode 100644 index 00000000000..1e5facd27cc --- /dev/null +++ b/src/renderer/src/components/automations/automation-usage-model.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import type { AutomationRun } from '../../../../shared/automations-types' +import { + formatAutomationCost, + formatAutomationTokens, + summarizeAutomationRunUsage +} from './automation-usage-model' + +function makeRun(overrides: Partial): AutomationRun { + return { + id: 'run-1', + automationId: 'automation-1', + title: 'Run 1', + scheduledFor: 1, + status: 'completed', + trigger: 'manual', + workspaceId: 'wt1', + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: 'tab-1', + usage: null, + error: null, + startedAt: 1, + dispatchedAt: 1, + createdAt: 1, + ...overrides + } +} + +describe('automation usage model', () => { + it('rolls known run usage up while preserving unavailable coverage', () => { + const summary = summarizeAutomationRunUsage([ + makeRun({ + usage: { + status: 'known', + provider: 'codex', + model: 'gpt-5.4', + inputTokens: 1000, + outputTokens: 300, + cacheReadTokens: 400, + cacheWriteTokens: null, + reasoningOutputTokens: 50, + totalTokens: 1350, + estimatedCostUsd: 0.0042, + estimatedCostSource: 'api_equivalent', + providerSessionId: 'session-1', + attribution: 'provider_session_time_window', + collectedAt: 1, + unavailableReason: null, + unavailableMessage: null + } + }), + makeRun({ + id: 'run-2', + usage: { + status: 'unavailable', + provider: 'claude', + model: null, + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + reasoningOutputTokens: null, + totalTokens: null, + estimatedCostUsd: null, + estimatedCostSource: null, + providerSessionId: null, + attribution: null, + collectedAt: 2, + unavailableReason: 'no_matching_session', + unavailableMessage: 'No matching session' + } + }) + ]) + + expect(summary).toEqual({ + knownRuns: 1, + unavailableRuns: 1, + inputTokens: 1000, + outputTokens: 300, + cacheTokens: 400, + reasoningOutputTokens: 50, + totalTokens: 1350, + estimatedCostUsd: 0.0042 + }) + }) + + it('formats compact token and cost labels', () => { + expect(formatAutomationTokens(1250)).toBe('1.3k') + expect(formatAutomationTokens(1_250_000)).toBe('1.3M') + expect(formatAutomationCost(null)).toBe('n/a') + expect(formatAutomationCost(0.0042)).toBe('$0.0042') + expect(formatAutomationCost(1.234)).toBe('$1.23') + }) +}) diff --git a/src/renderer/src/components/automations/automation-usage-model.ts b/src/renderer/src/components/automations/automation-usage-model.ts new file mode 100644 index 00000000000..fcc43e0703e --- /dev/null +++ b/src/renderer/src/components/automations/automation-usage-model.ts @@ -0,0 +1,93 @@ +import type { AutomationRun, AutomationRunUsage } from '../../../../shared/automations-types' + +export type AutomationUsageSummary = { + knownRuns: number + unavailableRuns: number + inputTokens: number + outputTokens: number + cacheTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null +} + +export function summarizeAutomationRunUsage( + runs: readonly AutomationRun[] +): AutomationUsageSummary { + let knownRuns = 0 + let unavailableRuns = 0 + let inputTokens = 0 + let outputTokens = 0 + let cacheTokens = 0 + let reasoningOutputTokens = 0 + let totalTokens = 0 + let estimatedCostUsd = 0 + let hasKnownCost = false + + for (const run of runs) { + const usage = run.usage + if (!usage) { + unavailableRuns++ + continue + } + if (usage.status !== 'known') { + unavailableRuns++ + continue + } + knownRuns++ + inputTokens += usage.inputTokens ?? 0 + outputTokens += usage.outputTokens ?? 0 + cacheTokens += (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0) + reasoningOutputTokens += usage.reasoningOutputTokens ?? 0 + totalTokens += usage.totalTokens ?? 0 + if (usage.estimatedCostUsd !== null) { + estimatedCostUsd += usage.estimatedCostUsd + hasKnownCost = true + } + } + + return { + knownRuns, + unavailableRuns, + inputTokens, + outputTokens, + cacheTokens, + reasoningOutputTokens, + totalTokens, + estimatedCostUsd: hasKnownCost ? estimatedCostUsd : null + } +} + +export function formatAutomationTokens(value: number | null | undefined): string { + if (!value) { + return '0' + } + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M` + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}k` + } + return value.toLocaleString() +} + +export function formatAutomationCost(value: number | null | undefined): string { + if (value === null || value === undefined) { + return 'n/a' + } + if (value > 0 && value < 0.01) { + return `$${value.toFixed(4)}` + } + return `$${value.toFixed(2)}` +} + +export function getAutomationUsageStatusLabel( + usage: AutomationRunUsage | null | undefined +): string { + if (!usage || usage.status === 'unavailable') { + return usage?.unavailableMessage ?? 'Usage unavailable' + } + const cost = formatAutomationCost(usage.estimatedCostUsd) + const tokens = formatAutomationTokens(usage.totalTokens) + return `${tokens} tokens · ${cost}` +} diff --git a/src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts b/src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts new file mode 100644 index 00000000000..94b44652e81 --- /dev/null +++ b/src/renderer/src/components/diff-comments/diff-comment-popover-position.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import type { editor as monacoEditor } from 'monaco-editor' +import { getDiffCommentPopoverTop } from './diff-comment-popover-position' + +function makeEditor({ + lineCount = 10, + scrollTop = 15, + topForLine = (lineNumber: number) => lineNumber * 20 +}: { + lineCount?: number + scrollTop?: number + topForLine?: (lineNumber: number) => number +} = {}): Parameters[0] { + return { + getModel: () => ({ getLineCount: () => lineCount }) as monacoEditor.ITextModel, + getScrollTop: () => scrollTop, + getTopForLineNumber: topForLine + } +} + +describe('getDiffCommentPopoverTop', () => { + it('positions the popover below the anchor line', () => { + const top = getDiffCommentPopoverTop(makeEditor(), 3, 20) + + expect(top).toBe(65) + }) + + it('uses a fallback line height when Monaco does not return a positive number', () => { + const top = getDiffCommentPopoverTop(makeEditor(), 3, 0) + + expect(top).toBe(64) + }) + + it('returns null when the editor has no model', () => { + const editor = { + ...makeEditor(), + getModel: () => null + } + + expect(getDiffCommentPopoverTop(editor, 3, 20)).toBeNull() + }) + + it('returns null for out-of-range line numbers', () => { + expect(getDiffCommentPopoverTop(makeEditor({ lineCount: 2 }), 3, 20)).toBeNull() + expect(getDiffCommentPopoverTop(makeEditor({ lineCount: 2 }), 0, 20)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/diff-comments/diff-comment-popover-position.ts b/src/renderer/src/components/diff-comments/diff-comment-popover-position.ts new file mode 100644 index 00000000000..fa2f7c9e545 --- /dev/null +++ b/src/renderer/src/components/diff-comments/diff-comment-popover-position.ts @@ -0,0 +1,25 @@ +import type { editor as monacoEditor } from 'monaco-editor' + +type DiffCommentPopoverEditor = Pick< + monacoEditor.ICodeEditor, + 'getModel' | 'getScrollTop' | 'getTopForLineNumber' +> + +const FALLBACK_LINE_HEIGHT_PX = 19 + +export function getDiffCommentPopoverTop( + editor: DiffCommentPopoverEditor, + lineNumber: number, + lineHeight: unknown +): number | null { + const model = editor.getModel() + if (!model) { + return null + } + if (lineNumber < 1 || lineNumber > model.getLineCount()) { + return null + } + const resolvedLineHeight = + typeof lineHeight === 'number' && lineHeight > 0 ? lineHeight : FALLBACK_LINE_HEIGHT_PX + return editor.getTopForLineNumber(lineNumber) - editor.getScrollTop() + resolvedLineHeight +} diff --git a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx index a05fcaacbb0..f1f5d8d7911 100644 --- a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx +++ b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx @@ -4,6 +4,7 @@ import type { editor as monacoEditor, IDisposable } from 'monaco-editor' import { createRoot, type Root } from 'react-dom/client' import type { DiffComment } from '../../../../shared/types' import { DiffCommentCard } from './DiffCommentCard' +import { getDiffCommentPopoverTop } from './diff-comment-popover-position' // Why: Monaco glyph-margin *decorations* don't expose click events in a way // that lets us show a polished popover anchored to a line. So instead we own a @@ -203,7 +204,10 @@ export function useDiffCommentDecorator({ } const startLine = Math.min(currentDrag.startLine, currentDrag.endLine) const lineNumber = Math.max(currentDrag.startLine, currentDrag.endLine) - const top = editor.getTopForLineNumber(lineNumber) - editor.getScrollTop() + const top = getDiffCommentPopoverTop(editor, lineNumber, getLineHeight()) + if (top == null) { + return + } onAddCommentClickRef.current({ lineNumber, startLine: startLine === lineNumber ? undefined : startLine, diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index b444d713d54..ac5a3587d30 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -16,23 +16,10 @@ import { getRuntimeGitBranchDiff, getRuntimeGitDiff } from '@/runtime/runtime-gi import '@/lib/monaco-setup' import { Button } from '@/components/ui/button' import type { OpenFile } from '@/store/slices/editor' -import type { GitBranchChangeEntry, GitDiffResult, GitStatusEntry } from '../../../../shared/types' +import type { GitBranchChangeEntry, GitDiffResult } from '../../../../shared/types' import { DiffSectionItem } from './DiffSectionItem' import { getCombinedUncommittedEntries } from './combined-diff-entries' - -type DiffSection = { - key: string - path: string - status: string - area?: GitStatusEntry['area'] - oldPath?: string - originalContent: string - modifiedContent: string - collapsed: boolean - loading: boolean - dirty: boolean - diffResult: GitDiffResult | null -} +import type { DiffSection } from './diff-section-types' type CachedCombinedDiffViewState = { entrySignature: string diff --git a/src/renderer/src/components/editor/DiffSectionItem.tsx b/src/renderer/src/components/editor/DiffSectionItem.tsx index f30e6266691..e2b12eb65c6 100644 --- a/src/renderer/src/components/editor/DiffSectionItem.tsx +++ b/src/renderer/src/components/editor/DiffSectionItem.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: this component owns diff rendering, image previews, comment popovers, and expansion state as one synchronized editor row. */ import { lazy, useCallback, @@ -19,27 +20,15 @@ import { computeEditorFontSize } from '@/lib/editor-font-zoom' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { useDiffCommentDecorator } from '../diff-comments/useDiffCommentDecorator' import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover' +import { getDiffCommentPopoverTop } from '../diff-comments/diff-comment-popover-position' import { applyDiffEditorLineNumberOptions } from './diff-editor-line-number-options' import { computeLineStats } from './diff-line-stats' -import type { DiffComment, GitDiffResult } from '../../../../shared/types' +import type { DiffSection } from './diff-section-types' +import type { DiffComment } from '../../../../shared/types' import { isDiffComment } from '@/lib/diff-comment-compat' const ImageDiffViewer = lazy(() => import('./ImageDiffViewer')) -type DiffSection = { - key: string - path: string - status: string - area?: 'staged' | 'unstaged' | 'untracked' - oldPath?: string - originalContent: string - modifiedContent: string - collapsed: boolean - loading: boolean - dirty: boolean - diffResult: GitDiffResult | null -} - export function DiffSectionItem({ section, index, @@ -161,8 +150,15 @@ export function DiffSectionItem({ return } const update = (): void => { - const top = - modifiedEditor.getTopForLineNumber(popover.lineNumber) - modifiedEditor.getScrollTop() + const top = getDiffCommentPopoverTop( + modifiedEditor, + popover.lineNumber, + modifiedEditor.getOption(monaco.editor.EditorOption.lineHeight) + ) + if (top == null) { + setPopover(null) + return + } setPopover((prev) => (prev ? { ...prev, top } : prev)) } const scrollSub = modifiedEditor.onDidScrollChange(update) diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx index c25f22235c0..4c3ff0d521e 100644 --- a/src/renderer/src/components/editor/DiffViewer.tsx +++ b/src/renderer/src/components/editor/DiffViewer.tsx @@ -3,12 +3,13 @@ import { DiffEditor, type DiffOnMount } from '@monaco-editor/react' import type { editor } from 'monaco-editor' import { useAppStore } from '@/store' import { diffViewStateCache, setWithLRU } from '@/lib/scroll-cache' -import '@/lib/monaco-setup' +import { monaco } from '@/lib/monaco-setup' import { computeEditorFontSize } from '@/lib/editor-font-zoom' import { useContextualCopySetup } from './useContextualCopySetup' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { useDiffCommentDecorator } from '../diff-comments/useDiffCommentDecorator' import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover' +import { getDiffCommentPopoverTop } from '../diff-comments/diff-comment-popover-position' import { applyDiffEditorLineNumberOptions } from './diff-editor-line-number-options' import type { DiffComment } from '../../../../shared/types' import { isDiffComment } from '@/lib/diff-comment-compat' @@ -129,8 +130,15 @@ export default function DiffViewer({ return } const update = (): void => { - const top = - modifiedEditor.getTopForLineNumber(popover.lineNumber) - modifiedEditor.getScrollTop() + const top = getDiffCommentPopoverTop( + modifiedEditor, + popover.lineNumber, + modifiedEditor.getOption(monaco.editor.EditorOption.lineHeight) + ) + if (top == null) { + setPopover(null) + return + } setPopover((prev) => (prev ? { ...prev, top } : prev)) } const scrollSub = modifiedEditor.onDidScrollChange(update) diff --git a/src/renderer/src/components/editor/diff-section-types.ts b/src/renderer/src/components/editor/diff-section-types.ts new file mode 100644 index 00000000000..d74266c0b27 --- /dev/null +++ b/src/renderer/src/components/editor/diff-section-types.ts @@ -0,0 +1,15 @@ +import type { GitDiffResult, GitStatusEntry } from '../../../../shared/types' + +export type DiffSection = { + key: string + path: string + status: string + area?: GitStatusEntry['area'] + oldPath?: string + originalContent: string + modifiedContent: string + collapsed: boolean + loading: boolean + dirty: boolean + diffResult: GitDiffResult | null +} diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts index c1bbd9851e1..19aafc228d6 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts @@ -232,7 +232,16 @@ export function createRichMarkdownKeyHandler( return false } + const currentFilteredSlashCommands = ctx.filteredSlashCommandsRef.current + if (!event.metaKey && !event.ctrlKey && !event.altKey && event.key.length === 1) { + // Why: when the current query has no matching commands the slash menu UI + // is already hidden; intercepting further keystrokes would silently + // swallow them. Fall through so ProseMirror inserts the character and + // syncSlashMenu refreshes the query from the document. + if (currentFilteredSlashCommands.length === 0) { + return false + } event.preventDefault() ctx.setSlashMenu((menu) => (menu ? { ...menu, query: `${menu.query}${event.key}` } : menu)) ctx.setSelectedCommandIndex(0) @@ -246,7 +255,6 @@ export function createRichMarkdownKeyHandler( return true } - const currentFilteredSlashCommands = ctx.filteredSlashCommandsRef.current if (currentFilteredSlashCommands.length === 0) { return false } diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 1e05fb0ab0b..f94822f20dd 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -491,8 +491,16 @@ function SourceControlInner(): React.JSX.Element { const linkedGitHubPR = activeWorktree?.linkedPR ?? null const linkedGitLabMR = activeWorktree?.linkedGitLabMR ?? null + // Why: when activeRepo.connectionId is truthy, neither the SourceControl + // effect below nor WorktreeCard.tsx fetches hostedReview for this branch, + // so hostedReviewEntry would stay undefined forever and would permanently + // block Publish Branch on SSH-backed worktrees with a linkedPR/linkedGitLabMR + // and no upstream. Skip the loading state for those repos so the publish + // gate doesn't latch. const isHostedReviewStateLoading = - (linkedGitHubPR !== null || linkedGitLabMR !== null) && hostedReviewEntry === undefined + !activeRepo?.connectionId && + (linkedGitHubPR !== null || linkedGitLabMR !== null) && + hostedReviewEntry === undefined useEffect(() => { if (!isBranchVisible || !activeRepo || isFolder || !branchName || branchName === 'HEAD') { return diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 2938353c754..050eb72bb44 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -570,7 +570,7 @@ function Settings(): React.JSX.Element { { id: 'stats', title: 'Stats & Usage', - description: 'Orca stats plus Claude and Codex usage analytics.', + description: 'Orca stats plus Claude, Codex, and OpenCode usage analytics.', icon: BarChart3, searchEntries: STATS_PANE_SEARCH_ENTRIES }, diff --git a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx new file mode 100644 index 00000000000..a8718b62b1c --- /dev/null +++ b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx @@ -0,0 +1,371 @@ +import { useEffect } from 'react' +import { + Activity, + Brain, + Coins, + DatabaseZap, + FolderKanban, + RefreshCw, + SlidersHorizontal, + Sparkles +} from 'lucide-react' +import type { + OpenCodeUsageRange, + OpenCodeUsageScope +} from '../../../../shared/opencode-usage-types' +import { useAppStore } from '../../store' +import { Button } from '../ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '../ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' +import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState' +import { CodexUsageDailyChart } from './CodexUsageDailyChart' +import { StatCard } from './StatCard' + +const RANGE_OPTIONS: OpenCodeUsageRange[] = ['7d', '30d', '90d', 'all'] +const SCOPE_OPTIONS: { value: OpenCodeUsageScope; label: string }[] = [ + { value: 'orca', label: 'Orca worktrees only' }, + { value: 'all', label: 'All local OpenCode usage' } +] +const RANGE_LABELS: Record = { + '7d': 'Last 7 days', + '30d': 'Last 30 days', + '90d': 'Last 90 days', + all: 'All time' +} + +function formatTokens(value: number): string { + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}M` + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(1)}k` + } + return value.toLocaleString() +} + +function formatCost(value: number | null): string { + if (value === null) { + return 'n/a' + } + return value < 0.01 ? `$${value.toFixed(4)}` : `$${value.toFixed(2)}` +} + +function formatUpdatedAt(timestamp: number | null): string { + if (!timestamp) { + return 'Not scanned yet' + } + return `Updated ${new Date(timestamp).toLocaleString()}` +} + +function formatSessionTime(timestamp: string): string { + const parsed = new Date(timestamp) + if (Number.isNaN(parsed.getTime())) { + return timestamp + } + return parsed.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }) +} + +export function OpenCodeUsagePane(): React.JSX.Element { + const scanState = useAppStore((state) => state.openCodeUsageScanState) + const summary = useAppStore((state) => state.openCodeUsageSummary) + const daily = useAppStore((state) => state.openCodeUsageDaily) + const modelBreakdown = useAppStore((state) => state.openCodeUsageModelBreakdown) + const projectBreakdown = useAppStore((state) => state.openCodeUsageProjectBreakdown) + 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 setOpenCodeUsageEnabled = useAppStore((state) => state.setOpenCodeUsageEnabled) + const refreshOpenCodeUsage = useAppStore((state) => state.refreshOpenCodeUsage) + const setOpenCodeUsageScope = useAppStore((state) => state.setOpenCodeUsageScope) + const setOpenCodeUsageRange = useAppStore((state) => state.setOpenCodeUsageRange) + + useEffect(() => { + void fetchOpenCodeUsage() + }, [fetchOpenCodeUsage]) + + if (!scanState?.enabled) { + return ( +
+
+
+

OpenCode Usage Tracking

+

+ Reads local OpenCode usage logs to show token, model, and session stats. +

+
+ +
+
+ ) + } + + if (!summary && (scanState.isScanning || scanState.lastScanCompletedAt === null)) { + return ( + + ) + } + + const hasAnyData = summary?.hasAnyOpenCodeData ?? scanState.hasAnyOpenCodeData + + return ( +
+
+
+

OpenCode Usage Tracking

+

+ {formatUpdatedAt(scanState.lastScanCompletedAt)} + {scanState.lastScanError ? ` • Last scan error: ${scanState.lastScanError}` : ''} +

+
+
+ + + + + + + + + + Filters + + + + + Scope + void setOpenCodeUsageScope(value as OpenCodeUsageScope)} + > + {SCOPE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + Range + void setOpenCodeUsageRange(value as OpenCodeUsageRange)} + > + {RANGE_OPTIONS.map((option) => ( + + {RANGE_LABELS[option]} + + ))} + + + + + + + + + + Refresh + + + + +
+
+ +
+

+ {SCOPE_OPTIONS.find((option) => option.value === scope)?.label} • {RANGE_LABELS[range]} +

+
+ + {!hasAnyData ? ( +
+ No local OpenCode usage found yet for this scope. +
+ ) : ( + <> +
+ } + /> + } + /> + } + /> + } + /> + } + /> + } + /> +
+

+ Cost comes from the local OpenCode database when the assistant message recorded one. +

+ + + +
+
+
+

By model

+

+ Top model: {summary?.topModel ?? 'n/a'} +

+
+
+ {modelBreakdown.slice(0, 5).map((row) => ( +
+
+ {row.label} + + {formatTokens(row.totalTokens)} + +
+
+ {row.sessions} sessions • {row.events} events + {row.estimatedCostUsd !== null + ? ` • ${formatCost(row.estimatedCostUsd)}` + : ''} +
+
+ ))} +
+
+ +
+
+

By project

+

+ Top project: {summary?.topProject ?? 'n/a'} +

+
+
+ {projectBreakdown.slice(0, 5).map((row) => ( +
+
+ {row.label} + + {formatTokens(row.totalTokens)} + +
+
+ {row.sessions} sessions • {row.events} events +
+
+ ))} +
+
+
+ +
+
+

Recent sessions

+

+ Most recent local OpenCode sessions in this scope. +

+
+
+ + + + + + + + + + + + + + {recentSessions.map((row) => ( + + + + + + + + + + ))} + +
Last activeProjectModelEventsInputOutputTotal
+ {formatSessionTime(row.lastActiveAt)} + {row.projectLabel}{row.model ?? 'Unknown'}{row.events} + {formatTokens(row.inputTokens)} + + {formatTokens(row.outputTokens)} + + {formatTokens(row.totalTokens)} +
+
+
+ + )} +
+ ) +} diff --git a/src/renderer/src/components/stats/StatsPane.tsx b/src/renderer/src/components/stats/StatsPane.tsx index 266b18a0e9a..80e9e2862f4 100644 --- a/src/renderer/src/components/stats/StatsPane.tsx +++ b/src/renderer/src/components/stats/StatsPane.tsx @@ -1,18 +1,26 @@ import { useEffect, useState } from 'react' -import { Bot, Clock, GitPullRequest } from 'lucide-react' +import { BarChart3, Bot, Check, ChevronDown, Clock, GitPullRequest } from 'lucide-react' import { useAppStore } from '../../store' import { StatCard } from './StatCard' import { ClaudeUsagePane } from './ClaudeUsagePane' import { CodexUsagePane } from './CodexUsagePane' +import { OpenCodeUsagePane } from './OpenCodeUsagePane' import { UsageOverviewPane } from './UsageOverviewPane' import type { SettingsSearchEntry } from '../settings/settings-search' -import { cn } from '@/lib/utils' +import { Button } from '../ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '../ui/dropdown-menu' +import { AgentIcon } from '@/lib/agent-catalog' export const STATS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ { title: 'Stats & Usage', description: - 'Orca stats plus combined Claude and Codex usage analytics, tokens, cache, models, and sessions.', + 'Orca stats plus combined Claude, Codex, and OpenCode usage analytics, tokens, cache, models, and sessions.', keywords: [ 'stats', 'usage', @@ -23,6 +31,7 @@ export const STATS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ 'tracking', 'claude', 'codex', + 'opencode', 'tokens', 'cache' ] @@ -57,10 +66,29 @@ function formatTrackingSince(timestamp: number | null): string { return `Tracking since ${date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}` } +type UsageTab = 'overview' | 'claude' | 'codex' | 'opencode' + +const USAGE_ANALYTICS_OPTIONS = [ + { id: 'overview', label: 'Overview' }, + { id: 'claude', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + { id: 'opencode', label: 'OpenCode' } +] as const satisfies readonly { id: UsageTab; label: string }[] + +function UsageAnalyticsOptionIcon({ tab }: { tab: UsageTab }): React.JSX.Element { + if (tab === 'overview') { + return + } + return +} + export function StatsPane(): React.JSX.Element { const summary = useAppStore((s) => s.statsSummary) const fetchStatsSummary = useAppStore((s) => s.fetchStatsSummary) - const [activeUsageTab, setActiveUsageTab] = useState<'overview' | 'claude' | 'codex'>('overview') + const [activeUsageTab, setActiveUsageTab] = useState('overview') + const activeUsageOption = + USAGE_ANALYTICS_OPTIONS.find((option) => option.id === activeUsageTab) ?? + USAGE_ANALYTICS_OPTIONS[0] useEffect(() => { void fetchStatsSummary() @@ -106,28 +134,40 @@ export function StatsPane(): React.JSX.Element {

Usage Analytics

-
- {(['overview', 'claude', 'codex'] as const).map((tab) => ( - - ))} -
+ + + {activeUsageOption.label} + + + + + + {USAGE_ANALYTICS_OPTIONS.map((option) => ( + setActiveUsageTab(option.id)}> + + + {option.label} + + + + ))} + +
{/* Why: the Stats section lives inside the scroll-tracked settings page. Keeping only the @@ -138,8 +178,10 @@ export function StatsPane(): React.JSX.Element { ) : activeUsageTab === 'claude' ? ( - ) : ( + ) : activeUsageTab === 'codex' ? ( + ) : ( + )}
diff --git a/src/renderer/src/components/stats/UsageOverviewPane.tsx b/src/renderer/src/components/stats/UsageOverviewPane.tsx index 202fcdf09d4..f0f7c820975 100644 --- a/src/renderer/src/components/stats/UsageOverviewPane.tsx +++ b/src/renderer/src/components/stats/UsageOverviewPane.tsx @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: the overview keeps its small display components beside the + provider fetch wiring so the combined usage surface stays easy to audit. */ import { useEffect, useMemo } from 'react' import { Activity, @@ -143,7 +145,7 @@ function DailyIntensityGrid({

Daily intensity

- Recent combined Claude and Codex token activity. + Recent combined Claude, Codex, and OpenCode token activity.

{bestDay && bestDay.totalTokens > 0 ? ( @@ -248,17 +250,24 @@ export function UsageOverviewPane(): React.JSX.Element { const codexScanState = useAppStore((state) => state.codexUsageScanState) const codexSummary = useAppStore((state) => state.codexUsageSummary) const codexDaily = useAppStore((state) => state.codexUsageDaily) + const openCodeScanState = useAppStore((state) => state.openCodeUsageScanState) + const openCodeSummary = useAppStore((state) => state.openCodeUsageSummary) + const openCodeDaily = useAppStore((state) => state.openCodeUsageDaily) const fetchClaudeUsage = useAppStore((state) => state.fetchClaudeUsage) const fetchCodexUsage = useAppStore((state) => state.fetchCodexUsage) + const fetchOpenCodeUsage = useAppStore((state) => state.fetchOpenCodeUsage) const refreshClaudeUsage = useAppStore((state) => state.refreshClaudeUsage) const refreshCodexUsage = useAppStore((state) => state.refreshCodexUsage) + const refreshOpenCodeUsage = useAppStore((state) => state.refreshOpenCodeUsage) const enableClaudeUsage = useAppStore((state) => state.enableClaudeUsage) const enableCodexUsage = useAppStore((state) => state.enableCodexUsage) + const enableOpenCodeUsage = useAppStore((state) => state.enableOpenCodeUsage) useEffect(() => { void fetchClaudeUsage() void fetchCodexUsage() - }, [fetchClaudeUsage, fetchCodexUsage]) + void fetchOpenCodeUsage() + }, [fetchClaudeUsage, fetchCodexUsage, fetchOpenCodeUsage]) const overview = useMemo( () => @@ -272,9 +281,24 @@ export function UsageOverviewPane(): React.JSX.Element { scanState: codexScanState, summary: codexSummary, daily: codexDaily + }, + opencode: { + scanState: openCodeScanState, + summary: openCodeSummary, + daily: openCodeDaily } }), - [claudeDaily, claudeScanState, claudeSummary, codexDaily, codexScanState, codexSummary] + [ + claudeDaily, + claudeScanState, + claudeSummary, + codexDaily, + codexScanState, + codexSummary, + openCodeDaily, + openCodeScanState, + openCodeSummary + ] ) const recentDays = useMemo( () => getRecentUsageDays(overview.daily, RECENT_DAY_COUNT), @@ -285,7 +309,8 @@ export function UsageOverviewPane(): React.JSX.Element { const handleRefresh = (): void => { void Promise.all([ claudeScanState?.enabled ? refreshClaudeUsage() : Promise.resolve(), - codexScanState?.enabled ? refreshCodexUsage() : Promise.resolve() + codexScanState?.enabled ? refreshCodexUsage() : Promise.resolve(), + openCodeScanState?.enabled ? refreshOpenCodeUsage() : Promise.resolve() ]) } @@ -334,6 +359,9 @@ export function UsageOverviewPane(): React.JSX.Element { +
@@ -364,8 +392,8 @@ export function UsageOverviewPane(): React.JSX.Element { {!overview.hasAnyData ? (
- No local Claude or Codex usage found yet. The overview will populate after the next - agent session writes token logs. + No local Claude, Codex, or OpenCode usage found yet. The overview will populate + after the next agent session writes token logs.
) : (
@@ -399,8 +427,10 @@ export function UsageOverviewPane(): React.JSX.Element { onEnable={() => { if (provider.id === 'claude') { void enableClaudeUsage() - } else { + } else if (provider.id === 'codex') { void enableCodexUsage() + } else { + void enableOpenCodeUsage() } }} /> diff --git a/src/renderer/src/components/stats/usage-overview-model.test.ts b/src/renderer/src/components/stats/usage-overview-model.test.ts index 490bcc1b8f4..8fa9354d898 100644 --- a/src/renderer/src/components/stats/usage-overview-model.test.ts +++ b/src/renderer/src/components/stats/usage-overview-model.test.ts @@ -9,6 +9,11 @@ import type { CodexUsageScanState, CodexUsageSummary } from '../../../../shared/codex-usage-types' +import type { + OpenCodeUsageDailyPoint, + OpenCodeUsageScanState, + OpenCodeUsageSummary +} from '../../../../shared/opencode-usage-types' import { buildUsageOverview, formatUsageCost, @@ -38,8 +43,19 @@ function enabledCodexScanState(): CodexUsageScanState { } } +function enabledOpenCodeScanState(): OpenCodeUsageScanState { + return { + enabled: true, + isScanning: false, + lastScanStartedAt: 500, + lastScanCompletedAt: 600, + lastScanError: null, + hasAnyOpenCodeData: true + } +} + describe('usage overview model', () => { - it('combines Claude and Codex totals without double-counting Codex cached input', () => { + it('combines provider totals without double-counting cached input', () => { const claudeSummary: ClaudeUsageSummary = { scope: 'orca', range: '30d', @@ -71,6 +87,21 @@ describe('usage overview model', () => { topProject: 'orca-secondary', hasAnyCodexData: true } + const openCodeSummary: OpenCodeUsageSummary = { + scope: 'orca', + range: '30d', + sessions: 1, + events: 2, + inputTokens: 1_000, + cachedInputTokens: 250, + outputTokens: 500, + reasoningOutputTokens: 100, + totalTokens: 1_600, + estimatedCostUsd: 0.03, + topModel: 'anthropic/claude-sonnet-4-5', + topProject: 'orca-third', + hasAnyOpenCodeData: true + } const claudeDaily: ClaudeUsageDailyPoint[] = [ { day: '2026-05-13', @@ -105,6 +136,16 @@ describe('usage overview model', () => { totalTokens: 1_200 } ] + const openCodeDaily: OpenCodeUsageDailyPoint[] = [ + { + day: '2026-05-15', + inputTokens: 1_000, + cachedInputTokens: 250, + outputTokens: 500, + reasoningOutputTokens: 100, + totalTokens: 1_600 + } + ] const overview = buildUsageOverview({ claude: { @@ -116,24 +157,30 @@ describe('usage overview model', () => { scanState: enabledCodexScanState(), summary: codexSummary, daily: codexDaily + }, + opencode: { + scanState: enabledOpenCodeScanState(), + summary: openCodeSummary, + daily: openCodeDaily } }) - expect(overview.totalTokens).toBe(9_200) - expect(overview.newInputTokens).toBe(2_200) - expect(overview.cacheTokens).toBe(5_300) - expect(overview.outputTokens).toBe(1_700) - expect(overview.reasoningTokens).toBe(300) - expect(overview.sessions).toBe(3) - expect(overview.activityCount).toBe(7) + expect(overview.totalTokens).toBe(10_800) + expect(overview.newInputTokens).toBe(2_950) + expect(overview.cacheTokens).toBe(5_550) + expect(overview.outputTokens).toBe(2_200) + expect(overview.reasoningTokens).toBe(400) + expect(overview.sessions).toBe(4) + expect(overview.activityCount).toBe(9) expect(overview.activeDays).toBe(3) - expect(overview.estimatedCostUsd).toBeCloseTo(0.06) - expect(overview.cacheShare).toBeCloseTo(5_300 / 7_500) + expect(overview.estimatedCostUsd).toBeCloseTo(0.09) + expect(overview.cacheShare).toBeCloseTo(5_550 / 8_500) expect(overview.bestDay).toMatchObject({ day: '2026-05-14', totalTokens: 4_500, claudeTokens: 2_500, codexTokens: 2_000, + openCodeTokens: 0, intensity: 4 }) expect(overview.providers.find((provider) => provider.id === 'codex')).toMatchObject({ @@ -141,6 +188,11 @@ describe('usage overview model', () => { cacheTokens: 800, totalTokens: 3_200 }) + expect(overview.providers.find((provider) => provider.id === 'opencode')).toMatchObject({ + newInputTokens: 750, + cacheTokens: 250, + totalTokens: 1_600 + }) }) it('pads recent usage days with zero-token cells', () => { @@ -151,6 +203,7 @@ describe('usage overview model', () => { totalTokens: 4_500, claudeTokens: 2_500, codexTokens: 2_000, + openCodeTokens: 0, intensity: 4 } ], @@ -164,6 +217,7 @@ describe('usage overview model', () => { totalTokens: 0, claudeTokens: 0, codexTokens: 0, + openCodeTokens: 0, intensity: 0 }, { @@ -171,6 +225,7 @@ describe('usage overview model', () => { totalTokens: 4_500, claudeTokens: 2_500, codexTokens: 2_000, + openCodeTokens: 0, intensity: 4 }, { @@ -178,6 +233,7 @@ describe('usage overview model', () => { totalTokens: 0, claudeTokens: 0, codexTokens: 0, + openCodeTokens: 0, intensity: 0 } ]) @@ -186,7 +242,8 @@ describe('usage overview model', () => { it('reports disabled providers as an empty overview', () => { const overview = buildUsageOverview({ claude: { scanState: null, summary: null, daily: [] }, - codex: { scanState: null, summary: null, daily: [] } + codex: { scanState: null, summary: null, daily: [] }, + opencode: { scanState: null, summary: null, daily: [] } }) expect(overview.hasAnyEnabledProvider).toBe(false) diff --git a/src/renderer/src/components/stats/usage-overview-model.ts b/src/renderer/src/components/stats/usage-overview-model.ts index 47104aec739..ba7be85d717 100644 --- a/src/renderer/src/components/stats/usage-overview-model.ts +++ b/src/renderer/src/components/stats/usage-overview-model.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: provider normalization, totals, and heatmap aggregation share + one tested model so the overview UI cannot drift from the math. */ import type { ClaudeUsageDailyPoint, ClaudeUsageScanState, @@ -8,8 +10,13 @@ import type { CodexUsageScanState, CodexUsageSummary } from '../../../../shared/codex-usage-types' +import type { + OpenCodeUsageDailyPoint, + OpenCodeUsageScanState, + OpenCodeUsageSummary +} from '../../../../shared/opencode-usage-types' -export type UsageProviderId = 'claude' | 'codex' +export type UsageProviderId = 'claude' | 'codex' | 'opencode' export type UsageProviderOverview = { id: UsageProviderId @@ -38,6 +45,7 @@ export type UsageOverviewDailyPoint = { totalTokens: number claudeTokens: number codexTokens: number + openCodeTokens: number intensity: 0 | 1 | 2 | 3 | 4 } @@ -74,6 +82,11 @@ export type UsageOverviewInput = { summary: CodexUsageSummary | null daily: CodexUsageDailyPoint[] } + opencode: { + scanState: OpenCodeUsageScanState | null + summary: OpenCodeUsageSummary | null + daily: OpenCodeUsageDailyPoint[] + } } function getClaudeDailyTotal(entry: ClaudeUsageDailyPoint): number { @@ -87,6 +100,13 @@ function getCodexNewInputTokens(summary: CodexUsageSummary | null): number { return Math.max(summary.inputTokens - summary.cachedInputTokens, 0) } +function getOpenCodeNewInputTokens(summary: OpenCodeUsageSummary | null): number { + if (!summary) { + return 0 + } + return Math.max(summary.inputTokens - summary.cachedInputTokens, 0) +} + function getIntensity(totalTokens: number, maxTokens: number): 0 | 1 | 2 | 3 | 4 { if (totalTokens <= 0 || maxTokens <= 0) { return 0 @@ -169,6 +189,34 @@ function createCodexProvider(input: UsageOverviewInput['codex']): UsageProviderO } } +function createOpenCodeProvider(input: UsageOverviewInput['opencode']): UsageProviderOverview { + const summary = input.summary + const dailyActiveDays = input.daily + .filter((entry) => entry.totalTokens > 0) + .map((entry) => entry.day) + return { + id: 'opencode', + label: 'OpenCode', + enabled: input.scanState?.enabled ?? false, + isScanning: input.scanState?.isScanning ?? false, + hasData: summary?.hasAnyOpenCodeData ?? input.scanState?.hasAnyOpenCodeData ?? false, + lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null, + lastScanError: input.scanState?.lastScanError ?? null, + sessions: summary?.sessions ?? 0, + activityLabel: 'events', + activityCount: summary?.events ?? 0, + totalTokens: summary?.totalTokens ?? 0, + newInputTokens: getOpenCodeNewInputTokens(summary), + outputTokens: summary?.outputTokens ?? 0, + cacheTokens: summary?.cachedInputTokens ?? 0, + reasoningTokens: summary?.reasoningOutputTokens ?? 0, + estimatedCostUsd: summary?.estimatedCostUsd ?? null, + topModel: summary?.topModel ?? null, + topProject: summary?.topProject ?? null, + activeDays: countActiveDays(dailyActiveDays) + } +} + function buildDailyOverview(input: UsageOverviewInput): UsageOverviewDailyPoint[] { const byDay = new Map>() @@ -177,7 +225,8 @@ function buildDailyOverview(input: UsageOverviewInput): UsageOverviewDailyPoint[ day: entry.day, totalTokens: 0, claudeTokens: 0, - codexTokens: 0 + codexTokens: 0, + openCodeTokens: 0 } const total = getClaudeDailyTotal(entry) current.totalTokens += total @@ -190,13 +239,27 @@ function buildDailyOverview(input: UsageOverviewInput): UsageOverviewDailyPoint[ day: entry.day, totalTokens: 0, claudeTokens: 0, - codexTokens: 0 + codexTokens: 0, + openCodeTokens: 0 } current.totalTokens += entry.totalTokens current.codexTokens += entry.totalTokens byDay.set(entry.day, current) } + for (const entry of input.opencode.daily) { + const current = byDay.get(entry.day) ?? { + day: entry.day, + totalTokens: 0, + claudeTokens: 0, + codexTokens: 0, + openCodeTokens: 0 + } + current.totalTokens += entry.totalTokens + current.openCodeTokens += entry.totalTokens + byDay.set(entry.day, current) + } + const maxTokens = Math.max(0, ...[...byDay.values()].map((entry) => entry.totalTokens)) return [...byDay.values()] .sort((left, right) => left.day.localeCompare(right.day)) @@ -234,6 +297,7 @@ export function getRecentUsageDays( totalTokens: 0, claudeTokens: 0, codexTokens: 0, + openCodeTokens: 0, intensity: 0 } ) @@ -242,7 +306,11 @@ export function getRecentUsageDays( } export function buildUsageOverview(input: UsageOverviewInput): UsageOverviewModel { - const providers = [createClaudeProvider(input.claude), createCodexProvider(input.codex)] + const providers = [ + createClaudeProvider(input.claude), + createCodexProvider(input.codex), + createOpenCodeProvider(input.opencode) + ] const daily = buildDailyOverview(input) const bestDay = daily.length === 0 diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 3ff303c7bb5..a401775b61b 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -61,6 +61,11 @@ import { readRuntimeIssueCommand, type HookCheckResult } from '@/runtime/runtime-hooks-client' +import { + formatWorkspaceCreateError, + getWorkspaceCreateErrorToastMessage, + type WorkspaceCreateErrorDisplay +} from '@/lib/workspace-create-error-format' export type UseComposerStateOptions = { initialRepoId?: string @@ -167,7 +172,7 @@ export type ComposerCardProps = { onSetupDecisionChange: (value: 'run' | 'skip') => void shouldWaitForSetupCheck: boolean resolvedSetupDecision: 'run' | 'skip' | null - createError: string | null + createError: WorkspaceCreateErrorDisplay | null canUseSparseCheckout: boolean /** Saved presets for the currently-selected repo. Empty array when no * presets exist or when the repo is remote. */ @@ -375,7 +380,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [hasLoadedIssueCommand, setHasLoadedIssueCommand] = useState(false) const [setupDecision, setSetupDecision] = useState<'run' | 'skip' | null>(null) const [creating, setCreating] = useState(false) - const [createError, setCreateError] = useState(null) + const [createError, setCreateError] = useState(null) const [advancedOpen, setAdvancedOpen] = useState( persistDraft ? Boolean((newWorkspaceDraft?.note ?? '').trim()) : false ) @@ -1664,9 +1669,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } onCreated?.() } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to create worktree.' - setCreateError(message) - toast.error(message) + const formattedError = formatWorkspaceCreateError(error) + setCreateError(formattedError) + toast.error(getWorkspaceCreateErrorToastMessage(formattedError)) } finally { setCreating(false) } @@ -1896,9 +1901,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } onCreated?.() } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to create worktree.' - setCreateError(message) - toast.error(message) + const formattedError = formatWorkspaceCreateError(error) + setCreateError(formattedError) + toast.error(getWorkspaceCreateErrorToastMessage(formattedError)) } finally { setCreating(false) } diff --git a/src/renderer/src/lib/workspace-create-error-format.test.ts b/src/renderer/src/lib/workspace-create-error-format.test.ts new file mode 100644 index 00000000000..4f8fc7a591a --- /dev/null +++ b/src/renderer/src/lib/workspace-create-error-format.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { + formatWorkspaceCreateError, + getWorkspaceCreateErrorToastMessage +} from './workspace-create-error-format' + +describe('formatWorkspaceCreateError', () => { + it('returns guidance for missing default base ref failures', () => { + const error = new Error( + 'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.' + ) + + const formatted = formatWorkspaceCreateError(error) + + expect(formatted).toEqual({ + title: 'No base branch found', + message: 'Orca could not resolve a usable base ref for this workspace.', + help: 'Create an initial commit (for example on main), or select an existing branch in Create From, then try again.' + }) + expect(getWorkspaceCreateErrorToastMessage(formatted)).toBe('No base branch found') + }) + + it('matches missing base ref failures case-insensitively', () => { + const formatted = formatWorkspaceCreateError( + new Error('COULD NOT RESOLVE A DEFAULT BASE REF from remote provider') + ) + + expect(formatted.title).toBe('No base branch found') + expect(formatted.help).toBeDefined() + }) + + it('passes unknown errors through unchanged', () => { + const formatted = formatWorkspaceCreateError(new Error('fatal: not a git repository')) + + expect(formatted).toEqual({ + title: 'fatal: not a git repository', + message: 'fatal: not a git repository' + }) + expect(getWorkspaceCreateErrorToastMessage(formatted)).toBe('fatal: not a git repository') + }) +}) diff --git a/src/renderer/src/lib/workspace-create-error-format.ts b/src/renderer/src/lib/workspace-create-error-format.ts new file mode 100644 index 00000000000..98c427d4510 --- /dev/null +++ b/src/renderer/src/lib/workspace-create-error-format.ts @@ -0,0 +1,28 @@ +export type WorkspaceCreateErrorDisplay = { + title: string + message: string + help?: string +} + +const MISSING_BASE_REF_ANCHOR = 'could not resolve a default base ref' + +export function formatWorkspaceCreateError(error: unknown): WorkspaceCreateErrorDisplay { + const message = error instanceof Error ? error.message : 'Failed to create worktree.' + + if (message.toLowerCase().includes(MISSING_BASE_REF_ANCHOR)) { + return { + title: 'No base branch found', + message: 'Orca could not resolve a usable base ref for this workspace.', + help: 'Create an initial commit (for example on main), or select an existing branch in Create From, then try again.' + } + } + + return { + title: message, + message + } +} + +export function getWorkspaceCreateErrorToastMessage(error: WorkspaceCreateErrorDisplay): string { + return error.help ? error.title : error.message +} diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 3337e94403a..3d91c806575 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -16,6 +16,7 @@ import { createMemorySlice } from './slices/memory' import { createWorkspaceSpaceSlice } from './slices/workspace-space' import { createClaudeUsageSlice } from './slices/claude-usage' import { createCodexUsageSlice } from './slices/codex-usage' +import { createOpenCodeUsageSlice } from './slices/opencode-usage' import { createBrowserSlice } from './slices/browser' import { createRateLimitSlice } from './slices/rate-limits' import { createSshSlice } from './slices/ssh' @@ -45,6 +46,7 @@ export const useAppStore = create()((...a) => ({ ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), + ...createOpenCodeUsageSlice(...a), ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index 7f7018d7231..8b19aeb58f4 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -81,6 +81,22 @@ const mockApi = { getDaily: vi.fn().mockResolvedValue([]), getBreakdown: vi.fn().mockResolvedValue([]), getRecentSessions: vi.fn().mockResolvedValue([]) + }, + openCodeUsage: { + getScanState: vi.fn().mockResolvedValue({ + enabled: false, + isScanning: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null, + hasAnyOpenCodeData: false + }), + setEnabled: vi.fn().mockResolvedValue({}), + refresh: vi.fn().mockResolvedValue({}), + getSummary: vi.fn().mockResolvedValue(null), + getDaily: vi.fn().mockResolvedValue([]), + getBreakdown: vi.fn().mockResolvedValue([]), + getRecentSessions: vi.fn().mockResolvedValue([]) } } @@ -103,6 +119,7 @@ import { createMemorySlice } from './memory' import { createWorkspaceSpaceSlice } from './workspace-space' import { createClaudeUsageSlice } from './claude-usage' import { createCodexUsageSlice } from './codex-usage' +import { createOpenCodeUsageSlice } from './opencode-usage' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' @@ -131,6 +148,7 @@ function createTestStore() { ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), + ...createOpenCodeUsageSlice(...a), ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), diff --git a/src/renderer/src/store/slices/opencode-usage.ts b/src/renderer/src/store/slices/opencode-usage.ts new file mode 100644 index 00000000000..b388053bc3a --- /dev/null +++ b/src/renderer/src/store/slices/opencode-usage.ts @@ -0,0 +1,154 @@ +import type { StateCreator } from 'zustand' +import type { + OpenCodeUsageBreakdownRow, + OpenCodeUsageDailyPoint, + OpenCodeUsageRange, + OpenCodeUsageScanState, + OpenCodeUsageScope, + OpenCodeUsageSessionRow, + OpenCodeUsageSummary +} from '../../../../shared/opencode-usage-types' +import type { AppState } from '../types' + +export type OpenCodeUsageSlice = { + openCodeUsageScope: OpenCodeUsageScope + openCodeUsageRange: OpenCodeUsageRange + openCodeUsageScanState: OpenCodeUsageScanState | null + openCodeUsageSummary: OpenCodeUsageSummary | null + openCodeUsageDaily: OpenCodeUsageDailyPoint[] + openCodeUsageModelBreakdown: OpenCodeUsageBreakdownRow[] + openCodeUsageProjectBreakdown: OpenCodeUsageBreakdownRow[] + openCodeUsageRecentSessions: OpenCodeUsageSessionRow[] + setOpenCodeUsageEnabled: (enabled: boolean) => Promise + setOpenCodeUsageScope: (scope: OpenCodeUsageScope) => Promise + setOpenCodeUsageRange: (range: OpenCodeUsageRange) => Promise + fetchOpenCodeUsage: (opts?: { forceRefresh?: boolean }) => Promise + enableOpenCodeUsage: () => Promise + refreshOpenCodeUsage: () => Promise +} + +export const createOpenCodeUsageSlice: StateCreator = ( + set, + get +) => ({ + openCodeUsageScope: 'orca', + openCodeUsageRange: '30d', + openCodeUsageScanState: null, + openCodeUsageSummary: null, + openCodeUsageDaily: [], + openCodeUsageModelBreakdown: [], + openCodeUsageProjectBreakdown: [], + openCodeUsageRecentSessions: [], + + setOpenCodeUsageEnabled: async (enabled) => { + try { + const nextScanState = (await window.api.openCodeUsage.setEnabled({ + enabled + })) as OpenCodeUsageScanState + set({ + openCodeUsageScanState: enabled + ? { + ...nextScanState, + isScanning: true, + lastScanCompletedAt: null, + lastScanError: null + } + : nextScanState, + openCodeUsageSummary: null, + openCodeUsageDaily: [], + openCodeUsageModelBreakdown: [], + openCodeUsageProjectBreakdown: [], + openCodeUsageRecentSessions: [] + }) + if (enabled) { + await get().fetchOpenCodeUsage({ forceRefresh: true }) + } + } catch (error) { + console.error('Failed to update OpenCode usage setting:', error) + } + }, + + setOpenCodeUsageScope: async (scope) => { + set({ openCodeUsageScope: scope }) + await get().fetchOpenCodeUsage() + }, + + setOpenCodeUsageRange: async (range) => { + set({ openCodeUsageRange: range }) + await get().fetchOpenCodeUsage() + }, + + fetchOpenCodeUsage: async (opts) => { + try { + const scanState = (await window.api.openCodeUsage.getScanState()) as OpenCodeUsageScanState + const currentScanState = get().openCodeUsageScanState + const shouldPreserveLoadingState = + opts?.forceRefresh === true && + currentScanState?.enabled === true && + get().openCodeUsageSummary === null + set({ + openCodeUsageScanState: shouldPreserveLoadingState + ? { + ...scanState, + isScanning: true, + lastScanCompletedAt: null, + lastScanError: null + } + : scanState + }) + if (!scanState.enabled) { + return + } + + const nextScanState = (await window.api.openCodeUsage.refresh({ + force: opts?.forceRefresh ?? false + })) as OpenCodeUsageScanState + const { openCodeUsageScope, openCodeUsageRange } = get() + + const [summary, daily, modelBreakdown, projectBreakdown, recentSessions] = await Promise.all([ + window.api.openCodeUsage.getSummary({ + scope: openCodeUsageScope, + range: openCodeUsageRange + }) as Promise, + window.api.openCodeUsage.getDaily({ + scope: openCodeUsageScope, + range: openCodeUsageRange + }) as Promise, + window.api.openCodeUsage.getBreakdown({ + scope: openCodeUsageScope, + range: openCodeUsageRange, + kind: 'model' + }) as Promise, + window.api.openCodeUsage.getBreakdown({ + scope: openCodeUsageScope, + range: openCodeUsageRange, + kind: 'project' + }) as Promise, + window.api.openCodeUsage.getRecentSessions({ + scope: openCodeUsageScope, + range: openCodeUsageRange, + limit: 10 + }) as Promise + ]) + + set({ + openCodeUsageScanState: nextScanState, + openCodeUsageSummary: summary, + openCodeUsageDaily: daily, + openCodeUsageModelBreakdown: modelBreakdown, + openCodeUsageProjectBreakdown: projectBreakdown, + openCodeUsageRecentSessions: recentSessions + }) + } catch (error) { + console.error('Failed to fetch OpenCode usage:', error) + } + }, + + enableOpenCodeUsage: async () => { + await get().setOpenCodeUsageEnabled(true) + }, + + refreshOpenCodeUsage: async () => { + await get().fetchOpenCodeUsage({ forceRefresh: true }) + } +}) diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index bff2a4ff2ae..70524a3c070 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -84,6 +84,22 @@ const mockApi = { getDaily: vi.fn().mockResolvedValue([]), getBreakdown: vi.fn().mockResolvedValue([]), getRecentSessions: vi.fn().mockResolvedValue([]) + }, + openCodeUsage: { + getScanState: vi.fn().mockResolvedValue({ + enabled: false, + isScanning: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null, + hasAnyOpenCodeData: false + }), + setEnabled: vi.fn().mockResolvedValue({}), + refresh: vi.fn().mockResolvedValue({}), + getSummary: vi.fn().mockResolvedValue(null), + getDaily: vi.fn().mockResolvedValue([]), + getBreakdown: vi.fn().mockResolvedValue([]), + getRecentSessions: vi.fn().mockResolvedValue([]) } } @@ -106,6 +122,7 @@ import { createMemorySlice } from './memory' import { createWorkspaceSpaceSlice } from './workspace-space' import { createClaudeUsageSlice } from './claude-usage' import { createCodexUsageSlice } from './codex-usage' +import { createOpenCodeUsageSlice } from './opencode-usage' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' @@ -134,6 +151,7 @@ function createTestStore() { ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), + ...createOpenCodeUsageSlice(...a), ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index ab8b589ce24..e154a25224c 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -24,6 +24,7 @@ import { createMemorySlice } from './memory' import { createWorkspaceSpaceSlice } from './workspace-space' import { createClaudeUsageSlice } from './claude-usage' import { createCodexUsageSlice } from './codex-usage' +import { createOpenCodeUsageSlice } from './opencode-usage' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' @@ -60,6 +61,7 @@ export function createTestStore() { ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), + ...createOpenCodeUsageSlice(...a), ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index a68f34b135f..8f19e24692a 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -78,6 +78,22 @@ const mockApi = { getDaily: vi.fn().mockResolvedValue([]), getBreakdown: vi.fn().mockResolvedValue([]), getRecentSessions: vi.fn().mockResolvedValue([]) + }, + openCodeUsage: { + getScanState: vi.fn().mockResolvedValue({ + enabled: false, + isScanning: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null, + hasAnyOpenCodeData: false + }), + setEnabled: vi.fn().mockResolvedValue({}), + refresh: vi.fn().mockResolvedValue({}), + getSummary: vi.fn().mockResolvedValue(null), + getDaily: vi.fn().mockResolvedValue([]), + getBreakdown: vi.fn().mockResolvedValue([]), + getRecentSessions: vi.fn().mockResolvedValue([]) } } @@ -100,6 +116,7 @@ import { createMemorySlice } from './memory' import { createWorkspaceSpaceSlice } from './workspace-space' import { createClaudeUsageSlice } from './claude-usage' import { createCodexUsageSlice } from './codex-usage' +import { createOpenCodeUsageSlice } from './opencode-usage' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' @@ -130,6 +147,7 @@ function createTestStore() { ...createWorkspaceSpaceSlice(...a), ...createClaudeUsageSlice(...a), ...createCodexUsageSlice(...a), + ...createOpenCodeUsageSlice(...a), ...createBrowserSlice(...a), ...createRateLimitSlice(...a), ...createSshSlice(...a), diff --git a/src/renderer/src/store/slices/terminals-hydration.test.ts b/src/renderer/src/store/slices/terminals-hydration.test.ts index 7be0c90bb17..ace73b30b77 100644 --- a/src/renderer/src/store/slices/terminals-hydration.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration.test.ts @@ -69,6 +69,22 @@ const mockApi = { getDaily: vi.fn().mockResolvedValue([]), getBreakdown: vi.fn().mockResolvedValue([]), getRecentSessions: vi.fn().mockResolvedValue([]) + }, + openCodeUsage: { + getScanState: vi.fn().mockResolvedValue({ + enabled: false, + isScanning: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null, + hasAnyOpenCodeData: false + }), + setEnabled: vi.fn().mockResolvedValue({}), + refresh: vi.fn().mockResolvedValue({}), + getSummary: vi.fn().mockResolvedValue(null), + getDaily: vi.fn().mockResolvedValue([]), + getBreakdown: vi.fn().mockResolvedValue([]), + getRecentSessions: vi.fn().mockResolvedValue([]) } } diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index ec246109a53..bed69db258c 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -14,6 +14,7 @@ import type { MemorySlice } from './slices/memory' import type { WorkspaceSpaceSlice } from './slices/workspace-space' import type { ClaudeUsageSlice } from './slices/claude-usage' import type { CodexUsageSlice } from './slices/codex-usage' +import type { OpenCodeUsageSlice } from './slices/opencode-usage' import type { BrowserSlice } from './slices/browser' import type { RateLimitSlice } from './slices/rate-limits' import type { SshSlice } from './slices/ssh' @@ -40,6 +41,7 @@ export type AppState = RepoSlice & WorkspaceSpaceSlice & ClaudeUsageSlice & CodexUsageSlice & + OpenCodeUsageSlice & BrowserSlice & RateLimitSlice & SshSlice & diff --git a/src/shared/automations-types.ts b/src/shared/automations-types.ts index fcbc5d32dba..a9e4883ecbb 100644 --- a/src/shared/automations-types.ts +++ b/src/shared/automations-types.ts @@ -16,6 +16,36 @@ export type AutomationRunStatus = export type AutomationRunTrigger = 'scheduled' | 'manual' export type AutomationSchedulePreset = 'hourly' | 'daily' | 'weekdays' | 'weekly' +export type AutomationRunUsageProvider = 'claude' | 'codex' +export type AutomationRunUsageStatus = 'known' | 'unavailable' +export type AutomationRunUsageAttribution = 'provider_session_time_window' +export type AutomationRunUsageUnavailableReason = + | 'run_not_finished' + | 'provider_unsupported' + | 'remote_usage_unavailable' + | 'usage_not_enabled' + | 'scan_failed' + | 'no_matching_session' + | 'ambiguous_session' + +export type AutomationRunUsage = { + status: AutomationRunUsageStatus + provider: AutomationRunUsageProvider | null + model: string | null + inputTokens: number | null + outputTokens: number | null + cacheReadTokens: number | null + cacheWriteTokens: number | null + reasoningOutputTokens: number | null + totalTokens: number | null + estimatedCostUsd: number | null + estimatedCostSource: 'api_equivalent' | null + providerSessionId: string | null + attribution: AutomationRunUsageAttribution | null + collectedAt: number + unavailableReason: AutomationRunUsageUnavailableReason | null + unavailableMessage: string | null +} export type Automation = { id: string @@ -52,6 +82,7 @@ export type AutomationRun = { sessionKind: 'terminal' chatSessionId: string | null terminalSessionId: string | null + usage: AutomationRunUsage | null error: string | null startedAt: number | null dispatchedAt: number | null @@ -101,6 +132,7 @@ export type AutomationDispatchResult = { status: AutomationRunStatus workspaceId?: string | null terminalSessionId?: string | null + usage?: AutomationRunUsage | null error?: string | null } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index fbc518ccf16..d451336ecef 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -322,6 +322,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState { sshTargets: [], sshRemotePtyLeases: [], migrationUnsupportedPtyEntries: [], + legacyPaneKeyAliasEntries: [], automations: [], automationRuns: [], onboarding: getDefaultOnboardingState() diff --git a/src/shared/opencode-usage-types.ts b/src/shared/opencode-usage-types.ts new file mode 100644 index 00000000000..d583fd0a931 --- /dev/null +++ b/src/shared/opencode-usage-types.ts @@ -0,0 +1,64 @@ +export type OpenCodeUsageScope = 'orca' | 'all' +export type OpenCodeUsageRange = '7d' | '30d' | '90d' | 'all' +export type OpenCodeUsageBreakdownKind = 'model' | 'project' + +export type OpenCodeUsageScanState = { + enabled: boolean + isScanning: boolean + lastScanStartedAt: number | null + lastScanCompletedAt: number | null + lastScanError: string | null + hasAnyOpenCodeData: boolean +} + +export type OpenCodeUsageSummary = { + scope: OpenCodeUsageScope + range: OpenCodeUsageRange + sessions: number + events: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null + topModel: string | null + topProject: string | null + hasAnyOpenCodeData: boolean +} + +export type OpenCodeUsageDailyPoint = { + day: string + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} + +export type OpenCodeUsageBreakdownRow = { + key: string + label: string + sessions: number + events: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null +} + +export type OpenCodeUsageSessionRow = { + sessionId: string + lastActiveAt: string + durationMinutes: number + projectLabel: string + model: string | null + events: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} diff --git a/src/shared/stable-pane-id.test.ts b/src/shared/stable-pane-id.test.ts index a380cfacb27..9c77a95539b 100644 --- a/src/shared/stable-pane-id.test.ts +++ b/src/shared/stable-pane-id.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest' -import { isStablePaneId, isTerminalLeafId, makePaneKey, parsePaneKey } from './stable-pane-id' +import { + isStablePaneId, + isTerminalLeafId, + makePaneKey, + parseLegacyNumericPaneKey, + parsePaneKey +} from './stable-pane-id' const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -39,4 +45,14 @@ describe('stable pane ids', () => { expect(parsePaneKey(`:${LEAF_ID}`)).toBeNull() expect(parsePaneKey('tab-1:')).toBeNull() }) + + it('parses legacy numeric pane keys only for migration aliases', () => { + expect(parseLegacyNumericPaneKey(' tab-1:12 ')).toEqual({ + tabId: 'tab-1', + numericPaneId: '12', + paneKey: 'tab-1:12' + }) + expect(parseLegacyNumericPaneKey(`tab-1:${LEAF_ID}`)).toBeNull() + expect(parseLegacyNumericPaneKey('tab:1:12')).toBeNull() + }) }) diff --git a/src/shared/stable-pane-id.ts b/src/shared/stable-pane-id.ts index 75da3a6a232..a68117f3d74 100644 --- a/src/shared/stable-pane-id.ts +++ b/src/shared/stable-pane-id.ts @@ -43,3 +43,25 @@ export function parsePaneKey( } return { tabId, leafId, stablePaneId: leafId } } + +export function parseLegacyNumericPaneKey( + paneKey: unknown +): { tabId: string; numericPaneId: string; paneKey: string } | null { + if (typeof paneKey !== 'string' || paneKey.length > 256) { + return null + } + const trimmed = paneKey.trim() + const delimiter = trimmed.indexOf(':') + if ( + delimiter <= 0 || + delimiter !== trimmed.lastIndexOf(':') || + delimiter === trimmed.length - 1 + ) { + return null + } + const numericPaneId = trimmed.slice(delimiter + 1) + if (!/^\d+$/.test(numericPaneId)) { + return null + } + return { tabId: trimmed.slice(0, delimiter), numericPaneId, paneKey: trimmed } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 688b137daf5..b85faf388a1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1804,6 +1804,13 @@ export type PersistedTrustedOrcaHookRepo = { export type PersistedTrustedOrcaHooks = Record +export type LegacyPaneKeyAliasEntry = { + ptyId: string + legacyPaneKey: string + stablePaneKey: string + updatedAt: number +} + // ─── Persistence shape ────────────────────────────────────────────── export type PersistedState = { schemaVersion: number @@ -1822,6 +1829,7 @@ export type PersistedState = { sshTargets: SshTarget[] sshRemotePtyLeases: SshRemotePtyLease[] migrationUnsupportedPtyEntries: MigrationUnsupportedPtyEntry[] + legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] automations: Automation[] automationRuns: AutomationRun[] onboarding: OnboardingState diff --git a/tests/e2e/usage-overview.spec.ts b/tests/e2e/usage-overview.spec.ts index 07559cbb535..6759a1a875c 100644 --- a/tests/e2e/usage-overview.spec.ts +++ b/tests/e2e/usage-overview.spec.ts @@ -19,17 +19,29 @@ test.describe('usage overview', () => { .poll(async () => getStoreState(orcaPage, 'activeView'), { timeout: 5_000 }) .toBe('settings') await expect(orcaPage.getByRole('heading', { name: 'Usage Analytics' })).toBeVisible() - const providerTabs = orcaPage.getByRole('group', { name: 'Usage analytics provider' }) - await expect( - providerTabs.getByRole('button', { name: 'Overview', exact: true }) - ).toHaveAttribute('aria-pressed', 'true') + const providerDropdown = orcaPage.getByTestId('usage-provider-select') + await expect(providerDropdown).toHaveAttribute( + 'aria-label', + 'Usage analytics provider: Overview' + ) await expect(orcaPage.getByTestId('usage-overview-pane')).toBeVisible() await expect(orcaPage.getByRole('heading', { name: 'Usage Overview' })).toBeVisible() await expect(orcaPage.getByRole('heading', { name: 'Providers' })).toBeVisible() await expect(orcaPage.getByRole('button', { name: 'Enable Claude' })).toBeVisible() await expect(orcaPage.getByRole('button', { name: 'Enable Codex' })).toBeVisible() + await expect(orcaPage.getByRole('button', { name: 'Enable OpenCode' })).toBeVisible() - await providerTabs.getByRole('button', { name: 'Codex', exact: true }).click() + await providerDropdown.click() + await orcaPage.getByRole('menuitem', { name: 'Codex', exact: true }).click() await expect(orcaPage.getByRole('heading', { name: 'Codex Usage Tracking' })).toBeVisible() + await expect(providerDropdown).toHaveAttribute('aria-label', 'Usage analytics provider: Codex') + + await providerDropdown.click() + await orcaPage.getByRole('menuitem', { name: 'OpenCode', exact: true }).click() + await expect(orcaPage.getByRole('heading', { name: 'OpenCode Usage Tracking' })).toBeVisible() + await expect(providerDropdown).toHaveAttribute( + 'aria-label', + 'Usage analytics provider: OpenCode' + ) }) })