Merge remote-tracking branch 'origin/main' into brennanb2025/agent-migration-check

# Conflicts:
#	src/main/persistence.ts
This commit is contained in:
brennanb2025
2026-05-15 17:18:46 -07:00
66 changed files with 5589 additions and 260 deletions
+1 -1
View File
@@ -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",
+10 -2
View File
@@ -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,
+184
View File
@@ -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(
+159 -11
View File
@@ -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<string>()
private legacyPaneKeyAliases = new Map<string, PaneKeyAliasEntry>()
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<string, unknown>
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<string, unknown>), 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
+101
View File
@@ -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')
})
})
+114 -4
View File
@@ -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<AutomationRun> {
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<AutomationRunUsage> {
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<void> {
@@ -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'
)
}
+62
View File
@@ -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)
})
})
+126
View File
@@ -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<AutomationRunUsage> {
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) => {
+97
View File
@@ -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)
})
})
+157
View File
@@ -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<string, CodexModelPricing> = {
@@ -635,6 +644,154 @@ export class CodexUsageStore {
})
}
async getAutomationRunUsage(input: AutomationUsageLookupInput): Promise<AutomationRunUsage> {
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) => {
+9 -1
View File
@@ -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,
+2 -1
View File
@@ -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<AutomationRun> =>
service.markDispatchResult(result)
)
ipcMain.handle('automations:rendererReady', (): void => {
service.setRendererReady()
+43
View File
@@ -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)
)
}
+45 -16
View File
@@ -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(() => {})
+33 -45
View File
@@ -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<string, string> | 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
@@ -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,
+4
View File
@@ -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()
+6
View File
@@ -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)
}
+269
View File
@@ -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)
})
})
+931
View File
@@ -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<string[]> {
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<OpenCodeUsageProcessedDatabase> {
const dbStat = await stat(dbPath)
return {
path: dbPath,
mtimeMs: dbStat.mtimeMs,
size: dbStat.size
}
}
async function yieldToEventLoop(): Promise<void> {
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<string, unknown> | null {
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return value as Record<string, unknown>
}
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<string, unknown>)
: 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string> {
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<OpenCodeUsageAttributedEvent | null> {
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<string, OpenCodeUsageSession>()
const dailyByKey = new Map<string, OpenCodeUsageDailyAggregate>()
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<string, OpenCodeUsageSession>): 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<string, OpenCodeUsageSession>,
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<string, OpenCodeUsageDailyAggregate>,
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<OpenCodeUsagePersistedDatabase> {
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<string, OpenCodeUsageSession>()
const dailyByKey = new Map<string, OpenCodeUsageDailyAggregate>()
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<string, { path: string; worktreeId: string; displayName: string }[]>
): 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
}
+309
View File
@@ -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<OpenCodeUsagePersistedState>): 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> = {}): 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> = {}
): 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)
})
})
+464
View File
@@ -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, UsageWorktreeRef[]>): 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<void> | 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<OpenCodeUsageScanState> {
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<OpenCodeUsageScanState> {
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<void> {
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<OpenCodeUsageSummary> {
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<string, number>()
const byProject = new Map<string, number>()
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<OpenCodeUsageDailyPoint[]> {
await this.refresh(false)
const byDay = new Map<string, OpenCodeUsageDailyPoint>()
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<OpenCodeUsageBreakdownRow[]> {
await this.refresh(false)
const rows = new Map<string, OpenCodeUsageBreakdownRow>()
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<OpenCodeUsageSessionRow[]> {
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<string> {
const repos = this.store.getRepos()
return getWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos))
}
}
+124
View File
@@ -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
}
+561
View File
@@ -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,
+218 -70
View File
@@ -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<string, string>
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<string>()
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<string, MigrationUnsupportedPtyEntry[]>()
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<string, TerminalLayoutSnapshot> = {},
sourceForPtyId: (ptyId: string) => 'local' | 'ssh' = () => 'local'
priorLayoutsByTabId: Record<string, TerminalLayoutSnapshot> = {}
): {
session: WorkspaceSessionState
changed: boolean
leafIdByInputLeafIdByTabId: Map<string, Map<string, string>>
leafIdByPtyIdByTabId: Map<string, Map<string, string>>
migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[]
legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[]
} {
let changed = false
const leafIdByInputLeafIdByTabId = new Map<string, Map<string, string>>()
const leafIdByPtyIdByTabId = new Map<string, Map<string, string>>()
const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = []
const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = []
const terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot> = {}
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<string, string>()
const duplicatePtyIds = new Set<string>()
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<string, MigrationUnsupportedPtyEntry>()
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<string, Map<string, string>>
@@ -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<LegacyPaneKeyAliasEntry>
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<string, LegacyPaneKeyAliasEntry>()
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 ?? [],
+35
View File
@@ -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<CodexUsageSessionRow[]>
}
export type OpenCodeUsageApi = {
getScanState: () => Promise<OpenCodeUsageScanState>
setEnabled: (args: { enabled: boolean }) => Promise<OpenCodeUsageScanState>
refresh: (args?: { force?: boolean }) => Promise<OpenCodeUsageScanState>
getSummary: (args: {
scope: OpenCodeUsageScope
range: OpenCodeUsageRange
}) => Promise<OpenCodeUsageSummary>
getDaily: (args: {
scope: OpenCodeUsageScope
range: OpenCodeUsageRange
}) => Promise<OpenCodeUsageDailyPoint[]>
getBreakdown: (args: {
scope: OpenCodeUsageScope
range: OpenCodeUsageRange
kind: OpenCodeUsageBreakdownKind
}) => Promise<OpenCodeUsageBreakdownRow[]>
getRecentSessions: (args: {
scope: OpenCodeUsageScope
range: OpenCodeUsageRange
limit?: number
}) => Promise<OpenCodeUsageSessionRow[]>
}
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<DirEntry[]>
readFile: (args: {
+16
View File
@@ -2418,6 +2418,22 @@ const api = {
ipcRenderer.invoke('codexUsage:getRecentSessions', args)
},
openCodeUsage: {
getScanState: (): Promise<unknown> => ipcRenderer.invoke('openCodeUsage:getScanState'),
setEnabled: (args: { enabled: boolean }): Promise<unknown> =>
ipcRenderer.invoke('openCodeUsage:setEnabled', args),
refresh: (args?: { force?: boolean }): Promise<unknown> =>
ipcRenderer.invoke('openCodeUsage:refresh', args),
getSummary: (args: { scope: string; range: string }): Promise<unknown> =>
ipcRenderer.invoke('openCodeUsage:getSummary', args),
getDaily: (args: { scope: string; range: string }): Promise<unknown> =>
ipcRenderer.invoke('openCodeUsage:getDaily', args),
getBreakdown: (args: { scope: string; range: string; kind: string }): Promise<unknown> =>
ipcRenderer.invoke('openCodeUsage:getBreakdown', args),
getRecentSessions: (args: { scope: string; range: string; limit?: number }): Promise<unknown> =>
ipcRenderer.invoke('openCodeUsage:getRecentSessions', args)
},
runtime: {
syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise<RuntimeStatus> =>
ipcRenderer.invoke('runtime:syncWindowGraph', graph),
@@ -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({
</div>
{createError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{createError}
<div
role="alert"
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
>
{createError.help ? (
<div className="space-y-1">
<p className="font-medium">{createError.title}</p>
<p>{createError.message}</p>
<p className="text-destructive/85">{createError.help}</p>
</div>
) : (
createError.message
)}
</div>
) : null}
@@ -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({
</div>
)
}
const usageSummary = summarizeAutomationRunUsage(runs)
const usageCoverage =
usageSummary.knownRuns > 0
? `${usageSummary.knownRuns}/${runs.length} runs`
: usageSummary.unavailableRuns > 0
? 'Unavailable'
: 'No runs'
return (
<div className="flex w-full flex-col gap-4">
@@ -175,8 +188,7 @@ export function AutomationDetail({
</div>
) : null}
<div className="grid grid-cols-4 gap-6 rounded-md border border-border/50 bg-muted/30 px-4 py-3 shadow-sm">
<DetailMetric label="Run location" value={`${projectName} / ${workspaceName}`} />
<div className="grid grid-cols-6 gap-6 rounded-md border border-border/50 bg-muted/30 px-4 py-3 shadow-sm">
<DetailMetric
label="Next run"
value={
@@ -189,6 +201,12 @@ export function AutomationDetail({
label="Last run"
value={formatAutomationDateTimeWithRelative(automation.lastRunAt, now)}
/>
<DetailMetric
label="Est. spend"
value={formatAutomationCost(usageSummary.estimatedCostUsd)}
/>
<DetailMetric label="Tokens" value={formatAutomationTokens(usageSummary.totalTokens)} />
<DetailMetric label="Usage coverage" value={usageCoverage} />
<DetailMetric label="Grace" value={formatGrace(automation.missedRunGraceMinutes)} />
</div>
@@ -228,9 +246,11 @@ export function AutomationDetail({
<div className="text-sm font-medium">Run history</div>
<div className="text-xs text-muted-foreground">{runs.length} runs</div>
</div>
<div className="grid grid-cols-[minmax(10rem,1fr)_minmax(12rem,1.4fr)_minmax(6rem,auto)] gap-3 border-b border-border/50 px-3 py-1.5 text-[11px] font-medium uppercase text-muted-foreground">
<div className="grid grid-cols-[minmax(9rem,1fr)_minmax(11rem,1.2fr)_minmax(5rem,.55fr)_minmax(5rem,.55fr)_minmax(6rem,auto)] gap-3 border-b border-border/50 px-3 py-1.5 text-[11px] font-medium uppercase text-muted-foreground">
<div>Run</div>
<div>Workspace</div>
<div>Spend</div>
<div>Tokens</div>
<div>Status</div>
</div>
<div className="divide-y divide-border/50">
@@ -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 = (
<>
<div className="min-w-0">
<div>{formatAutomationDateTime(run.scheduledFor)}</div>
{run.error ? (
<div className="mt-1 truncate text-xs text-muted-foreground">{run.error}</div>
{run.error || run.usage?.status === 'unavailable' ? (
<div className="mt-1 truncate text-xs text-muted-foreground">
{run.error ?? run.usage?.unavailableMessage}
</div>
) : null}
</div>
<div
@@ -258,6 +281,28 @@ export function AutomationDetail({
>
{workspaceLabel}
</div>
<div
className={
run.usage?.status === 'known'
? 'text-sm tabular-nums'
: 'text-sm text-muted-foreground'
}
title={usageLabel}
>
{formatAutomationCost(run.usage?.estimatedCostUsd)}
</div>
<div
className={
run.usage?.status === 'known'
? 'text-sm tabular-nums'
: 'text-sm text-muted-foreground'
}
title={usageLabel}
>
{run.usage?.status === 'known'
? formatAutomationTokens(run.usage.totalTokens)
: 'n/a'}
</div>
<div className="flex justify-start">
<Badge variant={getAutomationRunStatusVariant(run.status)}>
{getAutomationRunStatusLabel(run.status)}
@@ -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 (
<ContextMenu key={automation.id}>
<ContextMenuTrigger asChild>
@@ -774,6 +790,7 @@ export default function AutomationsPage(): React.JSX.Element {
)}`
: 'Paused'}
</span>
<span className="text-xs text-muted-foreground">{usageText}</span>
</button>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
@@ -121,6 +121,7 @@ export function ExternalAutomationManagers({
<Badge variant={job.enabled ? 'secondary' : 'outline'}>
{job.enabled ? 'Active' : 'Paused'}
</Badge>
<Badge variant="outline">Usage unsupported</Badge>
</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{job.schedule} · next {formatExternalDate(job.nextRunAt, now)}
@@ -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>): 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')
})
})
@@ -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}`
}
@@ -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<typeof getDiffCommentPopoverTop>[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()
})
})
@@ -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
}
@@ -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,
@@ -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
@@ -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)
@@ -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)
@@ -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
}
@@ -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
}
@@ -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
@@ -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
},
@@ -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<OpenCodeUsageRange, string> = {
'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 (
<div className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<h3 className="text-sm font-semibold text-foreground">OpenCode Usage Tracking</h3>
<p className="text-sm text-muted-foreground">
Reads local OpenCode usage logs to show token, model, and session stats.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={false}
aria-label="Enable OpenCode usage analytics"
onClick={() => void setOpenCodeUsageEnabled(true)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors"
>
<span className="pointer-events-none block size-3.5 translate-x-0.5 rounded-full bg-background shadow-sm transition-transform" />
</button>
</div>
</div>
)
}
if (!summary && (scanState.isScanning || scanState.lastScanCompletedAt === null)) {
return (
<ClaudeUsageLoadingState
title="OpenCode Usage Tracking"
summaryCardCount={6}
summaryGridClassName="md:grid-cols-3"
/>
)
}
const hasAnyData = summary?.hasAnyOpenCodeData ?? scanState.hasAnyOpenCodeData
return (
<div className="space-y-4 rounded-lg border border-border/60 bg-card/30 p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-foreground">OpenCode Usage Tracking</h3>
<p className="mt-1 text-xs text-muted-foreground">
{formatUpdatedAt(scanState.lastScanCompletedAt)}
{scanState.lastScanError ? ` • Last scan error: ${scanState.lastScanError}` : ''}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 self-start">
<DropdownMenu>
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-xs" aria-label="OpenCode usage options">
<SlidersHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Filters
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel>Scope</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={scope}
onValueChange={(value) => void setOpenCodeUsageScope(value as OpenCodeUsageScope)}
>
{SCOPE_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>Range</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={range}
onValueChange={(value) => void setOpenCodeUsageRange(value as OpenCodeUsageRange)}
>
{RANGE_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option} value={option}>
{RANGE_LABELS[option]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => void refreshOpenCodeUsage()}
disabled={scanState.isScanning}
aria-label="Refresh OpenCode usage"
>
<RefreshCw className={`size-3.5 ${scanState.isScanning ? 'animate-spin' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Refresh
</TooltipContent>
</Tooltip>
</TooltipProvider>
<button
type="button"
role="switch"
aria-checked={true}
aria-label="Enable OpenCode usage analytics"
onClick={() => void setOpenCodeUsageEnabled(false)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors"
>
<span className="pointer-events-none block size-3.5 translate-x-4 rounded-full bg-background shadow-sm transition-transform" />
</button>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{SCOPE_OPTIONS.find((option) => option.value === scope)?.label} • {RANGE_LABELS[range]}
</p>
</div>
{!hasAnyData ? (
<div className="rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-6 text-sm text-muted-foreground">
No local OpenCode usage found yet for this scope.
</div>
) : (
<>
<div className="grid gap-3 md:grid-cols-3">
<StatCard
label="Input tokens"
value={formatTokens(summary?.inputTokens ?? 0)}
icon={<Sparkles className="size-4" />}
/>
<StatCard
label="Output tokens"
value={formatTokens(summary?.outputTokens ?? 0)}
icon={<Activity className="size-4" />}
/>
<StatCard
label="Cached input"
value={formatTokens(summary?.cachedInputTokens ?? 0)}
icon={<DatabaseZap className="size-4" />}
/>
<StatCard
label="Reasoning output"
value={formatTokens(summary?.reasoningOutputTokens ?? 0)}
icon={<Brain className="size-4" />}
/>
<StatCard
label="Sessions / Events"
value={`${(summary?.sessions ?? 0).toLocaleString()} / ${(summary?.events ?? 0).toLocaleString()}`}
icon={<FolderKanban className="size-4" />}
/>
<StatCard
label="Recorded cost"
value={formatCost(summary?.estimatedCostUsd ?? null)}
icon={<Coins className="size-4" />}
/>
</div>
<p className="px-1 text-xs text-muted-foreground">
Cost comes from the local OpenCode database when the assistant message recorded one.
</p>
<CodexUsageDailyChart daily={daily} />
<div className="grid gap-4 xl:grid-cols-2">
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">By model</h4>
<p className="text-xs text-muted-foreground">
Top model: {summary?.topModel ?? 'n/a'}
</p>
</div>
<div className="space-y-3">
{modelBreakdown.slice(0, 5).map((row) => (
<div key={row.key} className="space-y-1">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="truncate text-foreground">{row.label}</span>
<span className="shrink-0 text-muted-foreground">
{formatTokens(row.totalTokens)}
</span>
</div>
<div className="text-xs text-muted-foreground">
{row.sessions} sessions • {row.events} events
{row.estimatedCostUsd !== null
? ` • ${formatCost(row.estimatedCostUsd)}`
: ''}
</div>
</div>
))}
</div>
</section>
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">By project</h4>
<p className="text-xs text-muted-foreground">
Top project: {summary?.topProject ?? 'n/a'}
</p>
</div>
<div className="space-y-3">
{projectBreakdown.slice(0, 5).map((row) => (
<div key={row.key} className="space-y-1">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="truncate text-foreground">{row.label}</span>
<span className="shrink-0 text-muted-foreground">
{formatTokens(row.totalTokens)}
</span>
</div>
<div className="text-xs text-muted-foreground">
{row.sessions} sessions • {row.events} events
</div>
</div>
))}
</div>
</section>
</div>
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">Recent sessions</h4>
<p className="text-xs text-muted-foreground">
Most recent local OpenCode sessions in this scope.
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-border/60 text-left text-xs text-muted-foreground">
<th className="px-2 py-2 font-medium">Last active</th>
<th className="px-2 py-2 font-medium">Project</th>
<th className="px-2 py-2 font-medium">Model</th>
<th className="px-2 py-2 font-medium">Events</th>
<th className="px-2 py-2 font-medium">Input</th>
<th className="px-2 py-2 font-medium">Output</th>
<th className="px-2 py-2 font-medium">Total</th>
</tr>
</thead>
<tbody>
{recentSessions.map((row) => (
<tr key={row.sessionId} className="border-b border-border/40 last:border-b-0">
<td className="px-2 py-2 text-muted-foreground">
{formatSessionTime(row.lastActiveAt)}
</td>
<td className="px-2 py-2 text-foreground">{row.projectLabel}</td>
<td className="px-2 py-2 text-muted-foreground">{row.model ?? 'Unknown'}</td>
<td className="px-2 py-2 text-muted-foreground">{row.events}</td>
<td className="px-2 py-2 text-muted-foreground">
{formatTokens(row.inputTokens)}
</td>
<td className="px-2 py-2 text-muted-foreground">
{formatTokens(row.outputTokens)}
</td>
<td className="px-2 py-2 text-muted-foreground">
{formatTokens(row.totalTokens)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</>
)}
</div>
)
}
+67 -25
View File
@@ -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 <BarChart3 className="size-3.5 text-muted-foreground" />
}
return <AgentIcon agent={tab} size={14} />
}
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<UsageTab>('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 {
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-foreground">Usage Analytics</h3>
<div
role="group"
aria-label="Usage analytics provider"
className="inline-flex w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground"
>
{(['overview', 'claude', 'codex'] as const).map((tab) => (
<button
key={tab}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
aria-pressed={activeUsageTab === tab}
onClick={() => setActiveUsageTab(tab)}
className={cn(
'inline-flex h-8 items-center justify-center rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all',
activeUsageTab === tab
? 'bg-background text-foreground shadow-sm dark:border-input dark:bg-input/30'
: 'text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground'
)}
variant="outline"
size="sm"
data-testid="usage-provider-select"
aria-label={`Usage analytics provider: ${activeUsageOption.label}`}
className="min-w-36 justify-between"
>
{tab === 'overview' ? 'Overview' : tab === 'claude' ? 'Claude' : 'Codex'}
</button>
))}
</div>
<span className="flex min-w-0 items-center gap-2">
<UsageAnalyticsOptionIcon tab={activeUsageOption.id} />
<span className="truncate">{activeUsageOption.label}</span>
</span>
<ChevronDown className="ml-1 size-3.5 text-muted-foreground" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{USAGE_ANALYTICS_OPTIONS.map((option) => (
<DropdownMenuItem key={option.id} onSelect={() => setActiveUsageTab(option.id)}>
<span className="flex min-w-0 items-center gap-2">
<UsageAnalyticsOptionIcon tab={option.id} />
<span className="truncate">{option.label}</span>
</span>
<Check
className={`ml-auto size-3.5 ${
activeUsageTab === option.id ? 'opacity-100' : 'opacity-0'
}`}
aria-hidden
/>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Why: the Stats section lives inside the scroll-tracked settings page. Keeping only the
@@ -138,8 +178,10 @@ export function StatsPane(): React.JSX.Element {
<UsageOverviewPane />
) : activeUsageTab === 'claude' ? (
<ClaudeUsagePane />
) : (
) : activeUsageTab === 'codex' ? (
<CodexUsagePane />
) : (
<OpenCodeUsagePane />
)}
</div>
</div>
@@ -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({
<div>
<h4 className="text-sm font-semibold text-foreground">Daily intensity</h4>
<p className="text-xs text-muted-foreground">
Recent combined Claude and Codex token activity.
Recent combined Claude, Codex, and OpenCode token activity.
</p>
</div>
{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 {
<Button variant="secondary" size="sm" onClick={() => void enableCodexUsage()}>
Enable Codex
</Button>
<Button variant="outline" size="sm" onClick={() => void enableOpenCodeUsage()}>
Enable OpenCode
</Button>
</div>
</div>
</div>
@@ -364,8 +392,8 @@ export function UsageOverviewPane(): React.JSX.Element {
{!overview.hasAnyData ? (
<div className="mt-4 rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-5 text-sm text-muted-foreground">
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.
</div>
) : (
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
@@ -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()
}
}}
/>
@@ -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)
@@ -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<string, Omit<UsageOverviewDailyPoint, 'intensity'>>()
@@ -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
+13 -8
View File
@@ -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<string | null>(null)
const [createError, setCreateError] = useState<WorkspaceCreateErrorDisplay | null>(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)
}
@@ -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')
})
})
@@ -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
}
+2
View File
@@ -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<AppState>()((...a) => ({
...createWorkspaceSpaceSlice(...a),
...createClaudeUsageSlice(...a),
...createCodexUsageSlice(...a),
...createOpenCodeUsageSlice(...a),
...createBrowserSlice(...a),
...createRateLimitSlice(...a),
...createSshSlice(...a),
@@ -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),
@@ -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<void>
setOpenCodeUsageScope: (scope: OpenCodeUsageScope) => Promise<void>
setOpenCodeUsageRange: (range: OpenCodeUsageRange) => Promise<void>
fetchOpenCodeUsage: (opts?: { forceRefresh?: boolean }) => Promise<void>
enableOpenCodeUsage: () => Promise<void>
refreshOpenCodeUsage: () => Promise<void>
}
export const createOpenCodeUsageSlice: StateCreator<AppState, [], [], OpenCodeUsageSlice> = (
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<OpenCodeUsageSummary>,
window.api.openCodeUsage.getDaily({
scope: openCodeUsageScope,
range: openCodeUsageRange
}) as Promise<OpenCodeUsageDailyPoint[]>,
window.api.openCodeUsage.getBreakdown({
scope: openCodeUsageScope,
range: openCodeUsageRange,
kind: 'model'
}) as Promise<OpenCodeUsageBreakdownRow[]>,
window.api.openCodeUsage.getBreakdown({
scope: openCodeUsageScope,
range: openCodeUsageRange,
kind: 'project'
}) as Promise<OpenCodeUsageBreakdownRow[]>,
window.api.openCodeUsage.getRecentSessions({
scope: openCodeUsageScope,
range: openCodeUsageRange,
limit: 10
}) as Promise<OpenCodeUsageSessionRow[]>
])
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 })
}
})
@@ -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),
@@ -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),
@@ -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),
@@ -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([])
}
}
+2
View File
@@ -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 &
+32
View File
@@ -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
}
+1
View File
@@ -322,6 +322,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
sshTargets: [],
sshRemotePtyLeases: [],
migrationUnsupportedPtyEntries: [],
legacyPaneKeyAliasEntries: [],
automations: [],
automationRuns: [],
onboarding: getDefaultOnboardingState()
+64
View File
@@ -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
}
+17 -1
View File
@@ -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()
})
})
+22
View File
@@ -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 }
}
+8
View File
@@ -1804,6 +1804,13 @@ export type PersistedTrustedOrcaHookRepo = {
export type PersistedTrustedOrcaHooks = Record<string, PersistedTrustedOrcaHookRepo>
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
+17 -5
View File
@@ -19,17 +19,29 @@ test.describe('usage overview', () => {
.poll(async () => getStoreState<string>(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'
)
})
})