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
2129// outputs keep costing money as long as they sit in the context.
2230
2331import { readFileSync } from 'fs'
32+ import { homedir } from 'os'
33+ import { join } from 'path'
2434import type { Store } from './store'
35+ import type { StateEvent } from '../shared/state'
2536import { onStopEvent , type StopEvent } from './hooks'
2637import {
2738 emptyTally ,
@@ -49,17 +60,23 @@ interface ParseResult {
4960}
5061
5162export 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 - z A - Z 0 - 9 ] / g, '-' ) ,
161+ `${ sessionId } .jsonl`
162+ )
87163}
88164
89165function parseTranscript ( path : string ) : ParseResult {
0 commit comments