Skip to content

Commit a433e34

Browse files
committed
json-mode-cost-meter (squashed)
1 parent 53a88a8 commit a433e34

3 files changed

Lines changed: 186 additions & 5 deletions

File tree

src/main/cost-tracker.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2+
3+
// Controlled fs mock: readFileSync returns a JSONL transcript fixture
4+
// which we override per-test. existsSync is unused by parseTranscript
5+
// (it just lets readFileSync throw), so leaving the real one in place
6+
// is fine.
7+
const readFileSyncMock = vi.fn((..._args: unknown[]) => '')
8+
9+
vi.mock('fs', async () => {
10+
const actual = await vi.importActual<typeof import('fs')>('fs')
11+
return {
12+
...actual,
13+
readFileSync: (...args: unknown[]) => readFileSyncMock(...args)
14+
}
15+
})
16+
17+
vi.mock('electron', () => ({
18+
app: { getPath: () => '/tmp', setPath: () => {}, isPackaged: false }
19+
}))
20+
21+
import { Store } from './store'
22+
import { CostTracker } from './cost-tracker'
23+
24+
describe('CostTracker — JSON-mode wiring', () => {
25+
beforeEach(() => {
26+
readFileSyncMock.mockReset()
27+
readFileSyncMock.mockReturnValue('')
28+
})
29+
30+
afterEach(() => {
31+
vi.clearAllMocks()
32+
})
33+
34+
it('reparses + dispatches costs/usageUpdated when a json-claude turn completes', () => {
35+
const store = new Store()
36+
const tracker = new CostTracker(store)
37+
tracker.start()
38+
39+
const sessionId = 'sess-cost-1'
40+
41+
// Start with an empty transcript so the sessionStarted reparse is
42+
// a no-op (gated by the empty-byModel check). This isolates the
43+
// assertion to the busyChanged path.
44+
readFileSyncMock.mockReturnValue('')
45+
store.dispatch({
46+
type: 'jsonClaude/sessionStarted',
47+
payload: { sessionId, worktreePath: '/tmp/wt' }
48+
})
49+
expect(store.getSnapshot().state.costs.byTerminal[sessionId]).toBeUndefined()
50+
51+
// claude has now finished a turn and flushed its session jsonl.
52+
// The parser only needs type=assistant, message.model,
53+
// message.usage, message.content.
54+
const transcript =
55+
JSON.stringify({
56+
type: 'assistant',
57+
message: {
58+
model: 'claude-sonnet-4-5',
59+
usage: { input_tokens: 100, output_tokens: 50 },
60+
content: [{ type: 'text', text: 'hi there' }]
61+
}
62+
}) + '\n'
63+
readFileSyncMock.mockReturnValue(transcript)
64+
65+
// The result-event boundary in JsonClaudeManager dispatches
66+
// busyChanged false. CostTracker subscribes to that.
67+
store.dispatch({
68+
type: 'jsonClaude/busyChanged',
69+
payload: { sessionId, busy: false }
70+
})
71+
72+
tracker.stop()
73+
74+
const usage = store.getSnapshot().state.costs.byTerminal[sessionId]
75+
expect(usage).toBeDefined()
76+
expect(usage.byModel['claude-sonnet-4-5']?.input).toBe(100)
77+
expect(usage.byModel['claude-sonnet-4-5']?.output).toBe(50)
78+
expect(usage.sessionId).toBe(sessionId)
79+
})
80+
81+
it('skips the dispatch when the transcript has no parseable assistant turn', () => {
82+
// Resume-from-disk before claude has flushed: parseTranscript
83+
// returns empty data. Dispatching it would wipe whatever the slice
84+
// already had hydrated, so we gate on byModel having entries.
85+
readFileSyncMock.mockReturnValue('')
86+
87+
const store = new Store()
88+
const tracker = new CostTracker(store)
89+
tracker.start()
90+
91+
const sessionId = 'sess-cost-2'
92+
store.dispatch({
93+
type: 'jsonClaude/sessionStarted',
94+
payload: { sessionId, worktreePath: '/tmp/wt' }
95+
})
96+
// sessionStarted itself triggers a reparse on resume; that
97+
// empty-transcript run must not have dispatched usageUpdated.
98+
99+
tracker.stop()
100+
101+
expect(store.getSnapshot().state.costs.byTerminal[sessionId]).toBeUndefined()
102+
})
103+
})

src/main/cost-tracker.ts

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@
1010
// assistant turn), so the simpler full-reparse beats incremental
1111
// tailing + state juggling.
1212
//
13+
// JSON-mode tabs don't fire Claude's Stop hook (we scrub the
14+
// HARNESS_TERMINAL_ID env var so user-scope hooks stay inert under
15+
// `claude -p`). Instead, we subscribe to the store and trigger the same
16+
// reparse on `jsonClaude/busyChanged` transitions to false (the boundary
17+
// JsonClaudeManager dispatches when claude emits a `result` event), and
18+
// on `jsonClaude/sessionStarted` so reopened tabs rehydrate from the
19+
// on-disk JSONL even if the costs slice doesn't persist their entry.
20+
//
1321
// Per-block token counts aren't in the Anthropic usage field, so we
1422
// estimate the $ split by char-length proportion within each turn:
1523
// the turn's known output-rate cost is divided across this message's
@@ -21,7 +29,10 @@
2129
// outputs keep costing money as long as they sit in the context.
2230

2331
import { readFileSync } from 'fs'
32+
import { homedir } from 'os'
33+
import { join } from 'path'
2434
import type { Store } from './store'
35+
import type { StateEvent } from '../shared/state'
2536
import { onStopEvent, type StopEvent } from './hooks'
2637
import {
2738
emptyTally,
@@ -49,17 +60,23 @@ interface ParseResult {
4960
}
5061

5162
export class CostTracker {
52-
private unsubscribe: (() => void) | null = null
63+
private unsubscribeHook: (() => void) | null = null
64+
private unsubscribeStore: (() => void) | null = null
5365

5466
constructor(private store: Store) {}
5567

5668
start(): void {
57-
this.unsubscribe = onStopEvent((ev) => this.handleStop(ev))
69+
this.unsubscribeHook = onStopEvent((ev) => this.handleStop(ev))
70+
this.unsubscribeStore = this.store.subscribe((event) =>
71+
this.handleStoreEvent(event)
72+
)
5873
}
5974

6075
stop(): void {
61-
this.unsubscribe?.()
62-
this.unsubscribe = null
76+
this.unsubscribeHook?.()
77+
this.unsubscribeHook = null
78+
this.unsubscribeStore?.()
79+
this.unsubscribeStore = null
6380
}
6481

6582
private handleStop(ev: StopEvent): void {
@@ -84,6 +101,65 @@ export class CostTracker {
84101
)
85102
}
86103
}
104+
105+
private handleStoreEvent(event: StateEvent): void {
106+
if (
107+
event.type === 'jsonClaude/busyChanged' &&
108+
event.payload.busy === false
109+
) {
110+
this.recordJsonModeTurnComplete(event.payload.sessionId)
111+
return
112+
}
113+
if (event.type === 'jsonClaude/sessionStarted') {
114+
this.recordJsonModeTurnComplete(event.payload.sessionId)
115+
}
116+
}
117+
118+
/** Reparse the JSON-mode session's on-disk JSONL and dispatch fresh
119+
* cost totals. Skips the dispatch when parsing yields no model data
120+
* so an early reparse (resume from disk before claude has flushed)
121+
* doesn't wipe an existing hydrated entry. The next reparse picks
122+
* things up. */
123+
private recordJsonModeTurnComplete(sessionId: string): void {
124+
const session =
125+
this.store.getSnapshot().state.jsonClaude.sessions[sessionId]
126+
if (!session) return
127+
const transcriptPath = jsonClaudeTranscriptPath(
128+
session.worktreePath,
129+
sessionId
130+
)
131+
try {
132+
const parsed = parseTranscript(transcriptPath)
133+
if (Object.keys(parsed.byModel).length === 0) return
134+
const usage: SessionUsage = {
135+
sessionId,
136+
transcriptPath,
137+
byModel: parsed.byModel,
138+
breakdown: parsed.breakdown,
139+
currentModel: parsed.currentModel,
140+
updatedAt: Date.now()
141+
}
142+
this.store.dispatch({
143+
type: 'costs/usageUpdated',
144+
payload: { terminalId: sessionId, usage }
145+
})
146+
} catch (err) {
147+
log(
148+
'cost-tracker',
149+
`failed to ingest json-claude ${transcriptPath}: ${err instanceof Error ? err.message : err}`
150+
)
151+
}
152+
}
153+
}
154+
155+
function jsonClaudeTranscriptPath(worktreePath: string, sessionId: string): string {
156+
return join(
157+
homedir(),
158+
'.claude',
159+
'projects',
160+
worktreePath.replace(/[^a-zA-Z0-9]/g, '-'),
161+
`${sessionId}.jsonl`
162+
)
87163
}
88164

89165
function parseTranscript(path: string): ParseResult {

src/renderer/components/CostPanel.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,9 @@ export function CostPanel({ worktreePath }: CostPanelProps): JSX.Element | null
101101
if (tree) {
102102
for (const leaf of getLeaves(tree)) {
103103
for (const tab of leaf.tabs) {
104-
if (tab.type === 'agent') terminalIds.add(tab.id)
104+
if (tab.type === 'agent' || tab.type === 'json-claude') {
105+
terminalIds.add(tab.id)
106+
}
105107
}
106108
}
107109
}

0 commit comments

Comments
 (0)