Skip to content

Commit 7ae5a3f

Browse files
authored
feat: cost/token tracking for Claude and Codex sessions (#20)
* feat(cost-tracking): add log adapters for Claude and Codex JSONL parsing Introduces TokenUsage type, ZERO_USAGE constant, LogAdapter interface, formatTokens/formatCost helpers, and createClaudeAdapter/createCodexAdapter factories with 32 passing tests. Co-Authored-By: Rooty * feat(cost-tracking): add CostTracker for JSONL log file tailing Discovers and tails agent log files via WSL polling, parsing token usage through LogAdapter and pushing cost:update IPC to the renderer. - Discovery phase: polls candidate dirs every 2s for up to 30s - Tailing phase: polls bound file every 3s, handles partial lines and truncation - 12 tests covering bind/unbind/destroy, discovery, tailing, partial lines, truncation, and IPC guard Co-Authored-By: Rooty * feat(store): add sessionUsage state to sessions slice Adds per-session token/cost tracking (inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, totalCostUsd) with setSessionUsage action and cleanup on removeSession. Co-Authored-By: Rooty * feat(cost-tracking): wire renderer — bind/unbind, listener, cost badge - TerminalPane: bind cost tracking after PTY spawn, unbind on session dispose - App.tsx: listen for cost:update IPC events, unbind on all close paths - PaneTopbar: display cost/token badge with hover tooltip breakdown Co-Authored-By: Rooty * fix(cost): convert Windows paths to WSL format before log discovery bindSession receives Windows paths from renderer (e.g., E:\H\LocalAI). Convert to WSL paths via toWslPath before building Claude log directory slug. Also store projectPath separately from cwd for Claude's project-indexed log structure. Co-Authored-By: Rooty * fix(cost): update CodexAdapter for real JSONL format Codex JSONL uses payload.info.total_token_usage (not payload directly). Model comes from turn_context events, not token_count events. First token_count has info: null (just rate limits) — now skipped. Added gpt-5.3/5.4 to pricing map. Co-Authored-By: Rooty * fix(ux): show session agent override in PaneTopbar, not just project default When a session is opened with a specific agent (e.g., Codex on a project whose default is Claude), the topbar now shows the actual agent name instead of always showing the project default. Co-Authored-By: Rooty * fix(cost): normalize Codex inputTokens to exclude cached tokens Codex input_tokens includes cached_input_tokens as a subset, unlike Claude where input_tokens already excludes cache reads. Badge was showing inflated totals (e.g. 12k for a simple message). Now both adapters report non-cached input only — badge shows meaningful count. Co-Authored-By: Rooty * fix(cost): Claude adapter — compute cost, fix double-count, show cache writes Three fixes for Claude cost tracking: - Compute cost from model pricing (Claude JSONL has no costUSD field) with proper cache rates (writes 1.25×, reads 0.1×) - Skip streaming partials (stop_reason: null) to prevent double-counting - Badge now shows inputTokens + cacheWriteTokens + outputTokens so first-turn cache writes (real processing) are visible, not hidden Also adds Zap icon and accent color to cost badge for visibility. Co-Authored-By: Rooty * fix(cost): badge shows total processed tokens, consistent with cost Token count now includes all types (input + cache read + cache write + output) so it tracks proportionally with cost. No more mismatch where $0.18 sits next to "87 tokens". Co-Authored-By: Rooty --------- Co-authored-by: Wintersta7e <Wintersta7e@users.noreply.github.com>
1 parent e2ca9b9 commit 7ae5a3f

12 files changed

Lines changed: 1680 additions & 4 deletions

File tree

src/main/cost-tracker.test.ts

Lines changed: 468 additions & 0 deletions
Large diffs are not rendered by default.

src/main/cost-tracker.ts

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
/**
2+
* CostTracker — watches agent JSONL log files and pushes usage updates.
3+
*
4+
* For each bound PTY session, the tracker discovers the agent's log file
5+
* (by polling candidate directories), then tails it every 3 seconds,
6+
* parsing token-usage data via the matching LogAdapter and forwarding
7+
* cumulative totals to the renderer over IPC.
8+
*/
9+
import type { BrowserWindow } from 'electron'
10+
import { execFile } from 'child_process'
11+
import { toWslPath } from './wsl-utils'
12+
import { createLogger } from './logger'
13+
import type { LogAdapter, TokenUsage } from './log-adapters'
14+
import { ZERO_USAGE } from './log-adapters'
15+
16+
const log = createLogger('cost-tracker')
17+
18+
// ── Constants ───────────────────────────────────────────────────────
19+
20+
/** How often to poll for the log file during discovery (ms). */
21+
const DISCOVERY_INTERVAL_MS = 2000
22+
23+
/** How long to keep looking for a log file before giving up (ms). */
24+
const DISCOVERY_TIMEOUT_MS = 30_000
25+
26+
/** How often to poll the log file for new content once bound (ms). */
27+
const TAIL_INTERVAL_MS = 3000
28+
29+
/** Timeout for individual WSL exec calls (ms). */
30+
const WSL_TIMEOUT_MS = 5000
31+
32+
// ── Types ───────────────────────────────────────────────────────────
33+
34+
interface BoundSession {
35+
sessionId: string
36+
adapter: LogAdapter
37+
projectPath: string
38+
cwd: string
39+
spawnAt: number
40+
discoveryStartedAt: number
41+
filePath: string | null
42+
offset: number
43+
partialLine: string
44+
usage: TokenUsage
45+
pollTimer: ReturnType<typeof setTimeout> | null
46+
}
47+
48+
export interface CostTracker {
49+
bindSession(
50+
sessionId: string,
51+
opts: {
52+
agent: string
53+
projectPath: string
54+
cwd: string
55+
spawnAt: number
56+
},
57+
): void
58+
unbindSession(sessionId: string): void
59+
destroy(): void
60+
}
61+
62+
// ── Helpers ─────────────────────────────────────────────────────────
63+
64+
/** Single-quote a path for safe use inside bash -lc commands. */
65+
function sq(s: string): string {
66+
return "'" + s.replace(/'/g, "'\\''") + "'"
67+
}
68+
69+
/**
70+
* Run a command inside WSL bash and return stdout.
71+
* Resolves with stdout on success; rejects on error.
72+
*/
73+
function wslExec(cmd: string): Promise<string> {
74+
return new Promise<string>((resolve, reject) => {
75+
execFile('wsl.exe', ['bash', '-lc', cmd], { timeout: WSL_TIMEOUT_MS }, (err, stdout) => {
76+
if (err) reject(err)
77+
else resolve(stdout)
78+
})
79+
})
80+
}
81+
82+
// ── Factory ─────────────────────────────────────────────────────────
83+
84+
export function createCostTracker(mainWindow: BrowserWindow, adapters: LogAdapter[]): CostTracker {
85+
const sessions = new Map<string, BoundSession>()
86+
87+
// ── Discovery ───────────────────────────────────────────────────
88+
89+
function startDiscovery(session: BoundSession): void {
90+
const dirs = session.adapter.getLogDirs(session.projectPath)
91+
const pattern = session.adapter.getFilePattern()
92+
93+
function discoveryPoll(): void {
94+
// Session may have been unbound while we were waiting
95+
if (!sessions.has(session.sessionId)) return
96+
97+
// Timeout guard — uses the session-level start time so re-entries
98+
// from tryMatchCandidates share the same deadline
99+
if (Date.now() - session.discoveryStartedAt > DISCOVERY_TIMEOUT_MS) {
100+
log.warn('Discovery timed out for session', {
101+
sessionId: session.sessionId,
102+
dirs,
103+
pattern,
104+
})
105+
return
106+
}
107+
108+
// Expand ~ in each dir and search for matching files modified after spawnAt
109+
const findParts = dirs.map(
110+
(d) =>
111+
`find ${sq(d.replace('~', '$HOME'))} -name ${sq(pattern)} -newermt @${Math.floor((session.spawnAt - 2000) / 1000)} 2>/dev/null`,
112+
)
113+
const findCmd = findParts.join('; ')
114+
115+
wslExec(findCmd)
116+
.then((stdout) => {
117+
if (!sessions.has(session.sessionId)) return
118+
119+
const candidates = stdout
120+
.split('\n')
121+
.map((l) => l.trim())
122+
.filter(Boolean)
123+
124+
if (candidates.length === 0) {
125+
// Nothing yet — schedule next discovery poll
126+
session.pollTimer = setTimeout(discoveryPoll, DISCOVERY_INTERVAL_MS)
127+
return
128+
}
129+
130+
// Try each candidate: read first 3 lines and check matchSession
131+
return tryMatchCandidates(session, candidates, 0)
132+
})
133+
.catch((err) => {
134+
if (!sessions.has(session.sessionId)) return
135+
log.debug('Discovery find failed (will retry)', {
136+
sessionId: session.sessionId,
137+
err: String(err),
138+
})
139+
session.pollTimer = setTimeout(discoveryPoll, DISCOVERY_INTERVAL_MS)
140+
})
141+
}
142+
143+
// Start first discovery poll
144+
session.pollTimer = setTimeout(discoveryPoll, DISCOVERY_INTERVAL_MS)
145+
}
146+
147+
function tryMatchCandidates(
148+
session: BoundSession,
149+
candidates: string[],
150+
index: number,
151+
): Promise<void> {
152+
if (!sessions.has(session.sessionId)) return Promise.resolve()
153+
if (index >= candidates.length) {
154+
// No match in this batch — schedule another discovery poll
155+
session.pollTimer = setTimeout(() => {
156+
startDiscovery(session)
157+
}, DISCOVERY_INTERVAL_MS)
158+
return Promise.resolve()
159+
}
160+
161+
const candidate = candidates[index]
162+
if (!candidate) {
163+
return tryMatchCandidates(session, candidates, index + 1)
164+
}
165+
166+
return wslExec(`head -n 3 ${sq(candidate)}`)
167+
.then((headOutput): Promise<void> | void => {
168+
if (!sessions.has(session.sessionId)) return
169+
170+
const firstLines = headOutput.split('\n').filter(Boolean)
171+
if (session.adapter.matchSession(firstLines, session.cwd, session.spawnAt)) {
172+
// Match found — bind and start tailing
173+
session.filePath = candidate
174+
log.info('Discovered log file for session', {
175+
sessionId: session.sessionId,
176+
filePath: candidate,
177+
})
178+
startTailing(session)
179+
} else {
180+
// Try next candidate
181+
return tryMatchCandidates(session, candidates, index + 1)
182+
}
183+
})
184+
.catch(() => {
185+
if (!sessions.has(session.sessionId)) return
186+
// head failed for this candidate — try next
187+
return tryMatchCandidates(session, candidates, index + 1)
188+
})
189+
}
190+
191+
// ── Tailing ─────────────────────────────────────────────────────
192+
193+
function startTailing(session: BoundSession): void {
194+
function tailPoll(): void {
195+
if (!sessions.has(session.sessionId)) return
196+
if (!session.filePath) return
197+
198+
// Get file size and new content in one call.
199+
// tail -c +N is 1-indexed: +1 reads from byte 0, so we use offset+1.
200+
const cmd = `stat -c %s ${sq(session.filePath)} && tail -c +${session.offset + 1} ${sq(session.filePath)}`
201+
202+
wslExec(cmd)
203+
.then((stdout) => {
204+
if (!sessions.has(session.sessionId)) return
205+
206+
// First line is the file size from stat, rest is content
207+
const newlineIdx = stdout.indexOf('\n')
208+
if (newlineIdx === -1) {
209+
session.pollTimer = setTimeout(tailPoll, TAIL_INTERVAL_MS)
210+
return
211+
}
212+
213+
const statLine = stdout.slice(0, newlineIdx).trim()
214+
const fileSize = parseInt(statLine, 10)
215+
const content = stdout.slice(newlineIdx + 1)
216+
217+
// Truncation detection: file shrank since last read
218+
if (!isNaN(fileSize) && fileSize < session.offset) {
219+
log.debug('File truncated, resetting offset', {
220+
sessionId: session.sessionId,
221+
oldOffset: session.offset,
222+
newSize: fileSize,
223+
})
224+
session.offset = 0
225+
session.partialLine = ''
226+
// Re-poll immediately to read from the start
227+
session.pollTimer = setTimeout(tailPoll, 0)
228+
return
229+
}
230+
231+
// No new content
232+
if (!content) {
233+
session.pollTimer = setTimeout(tailPoll, TAIL_INTERVAL_MS)
234+
return
235+
}
236+
237+
// Update offset by actual bytes read
238+
const contentBytes = Buffer.byteLength(content, 'utf8')
239+
session.offset += contentBytes
240+
241+
// Split on newlines, keeping the last incomplete part in partialLine
242+
const text = session.partialLine + content
243+
const lines = text.split('\n')
244+
session.partialLine = lines.pop() ?? ''
245+
246+
let usageChanged = false
247+
for (const line of lines) {
248+
const trimmed = line.trim()
249+
if (!trimmed) continue
250+
251+
const result = session.adapter.parseUsage(trimmed, session.usage)
252+
if (result !== null) {
253+
session.usage = result
254+
usageChanged = true
255+
}
256+
}
257+
258+
if (usageChanged && !mainWindow.isDestroyed()) {
259+
mainWindow.webContents.send('cost:update', {
260+
sessionId: session.sessionId,
261+
usage: { ...session.usage },
262+
})
263+
}
264+
265+
session.pollTimer = setTimeout(tailPoll, TAIL_INTERVAL_MS)
266+
})
267+
.catch((err) => {
268+
if (!sessions.has(session.sessionId)) return
269+
log.debug('Tail poll failed (will retry)', {
270+
sessionId: session.sessionId,
271+
err: String(err),
272+
})
273+
session.pollTimer = setTimeout(tailPoll, TAIL_INTERVAL_MS)
274+
})
275+
}
276+
277+
session.pollTimer = setTimeout(tailPoll, TAIL_INTERVAL_MS)
278+
}
279+
280+
// ── Public API ──────────────────────────────────────────────────
281+
282+
function bindSession(
283+
sessionId: string,
284+
opts: {
285+
agent: string
286+
projectPath: string
287+
cwd: string
288+
spawnAt: number
289+
},
290+
): void {
291+
// Find matching adapter
292+
const adapter = adapters.find((a) => a.agent === opts.agent)
293+
if (!adapter) {
294+
log.debug('No adapter for agent, skipping cost tracking', { agent: opts.agent })
295+
return
296+
}
297+
298+
// Don't re-bind if already tracking
299+
if (sessions.has(sessionId)) {
300+
log.debug('Session already bound, skipping', { sessionId })
301+
return
302+
}
303+
304+
// Convert Windows paths to WSL format for log directory lookup
305+
const wslCwd = toWslPath(opts.cwd)
306+
const wslProjectPath = toWslPath(opts.projectPath)
307+
308+
const session: BoundSession = {
309+
sessionId,
310+
adapter,
311+
projectPath: wslProjectPath,
312+
cwd: wslCwd,
313+
spawnAt: opts.spawnAt,
314+
discoveryStartedAt: Date.now(),
315+
filePath: null,
316+
offset: 0,
317+
partialLine: '',
318+
usage: { ...ZERO_USAGE },
319+
pollTimer: null,
320+
}
321+
322+
sessions.set(sessionId, session)
323+
log.info('Bound session for cost tracking', {
324+
sessionId,
325+
agent: opts.agent,
326+
cwd: opts.cwd,
327+
})
328+
329+
startDiscovery(session)
330+
}
331+
332+
function unbindSession(sessionId: string): void {
333+
const session = sessions.get(sessionId)
334+
if (!session) return
335+
336+
if (session.pollTimer !== null) {
337+
clearTimeout(session.pollTimer)
338+
session.pollTimer = null
339+
}
340+
341+
log.info('Unbound session from cost tracking', {
342+
sessionId,
343+
usage: session.usage,
344+
})
345+
346+
sessions.delete(sessionId)
347+
}
348+
349+
function destroy(): void {
350+
for (const session of sessions.values()) {
351+
if (session.pollTimer !== null) {
352+
clearTimeout(session.pollTimer)
353+
session.pollTimer = null
354+
}
355+
}
356+
sessions.clear()
357+
log.info('CostTracker destroyed')
358+
}
359+
360+
return { bindSession, unbindSession, destroy }
361+
}

0 commit comments

Comments
 (0)