|
1 | 1 | import * as path from 'node:path'; |
2 | 2 | import { homedir } from 'node:os'; |
| 3 | +import { readFileSync, existsSync } from 'node:fs'; |
3 | 4 |
|
4 | 5 | import { spawnProcess } from '../../../../process/spawn.js'; |
5 | 6 | import { buildClaudeExecCommand } from './commands.js'; |
6 | 7 | import { metadata } from '../metadata.js'; |
7 | 8 | import { expandHomeDir } from '../../../../../shared/utils/index.js'; |
8 | 9 | import { ENV } from '../config.js'; |
9 | | -import { createTelemetryCapture } from '../../../../../shared/telemetry/index.js'; |
10 | 10 | import { debug } from '../../../../../shared/logging/logger.js'; |
11 | 11 | import type { ParsedTelemetry } from '../../../core/types.js'; |
12 | 12 | import { |
13 | 13 | formatThinking, |
14 | 14 | formatCommand, |
15 | 15 | formatResult, |
16 | 16 | formatStatus, |
17 | | - formatDuration, |
18 | | - formatCost, |
19 | | - formatTokens, |
20 | | - addMarker, |
21 | | - SYMBOL_BULLET, |
22 | 17 | } from '../../../../../shared/formatters/outputMarkers.js'; |
23 | 18 |
|
| 19 | +/** |
| 20 | + * Get Claude session file path using claudeConfigDir |
| 21 | + */ |
| 22 | +function getSessionPath(sessionId: string, workingDir: string, claudeConfigDir: string): string { |
| 23 | + const slug = workingDir.replace(/\//g, '-'); |
| 24 | + return path.join(claudeConfigDir, 'projects', slug, `${sessionId}.jsonl`); |
| 25 | +} |
| 26 | + |
| 27 | +/** |
| 28 | + * Read telemetry from Claude session file (last assistant message with usage) |
| 29 | + */ |
| 30 | +function readSessionTelemetry(sessionPath: string): ParsedTelemetry | null { |
| 31 | + if (!existsSync(sessionPath)) { |
| 32 | + debug('[SESSION-READER] File not found: %s', sessionPath); |
| 33 | + return null; |
| 34 | + } |
| 35 | + |
| 36 | + try { |
| 37 | + const content = readFileSync(sessionPath, 'utf-8'); |
| 38 | + const lines = content.trim().split('\n').filter(Boolean); |
| 39 | + |
| 40 | + for (let i = lines.length - 1; i >= 0; i--) { |
| 41 | + try { |
| 42 | + const entry = JSON.parse(lines[i]); |
| 43 | + if (entry.type === 'assistant' && entry.message?.usage) { |
| 44 | + const u = entry.message.usage; |
| 45 | + const input = u.input_tokens || 0; |
| 46 | + const output = u.output_tokens || 0; |
| 47 | + const cacheCreation = u.cache_creation_input_tokens || 0; |
| 48 | + const cacheRead = u.cache_read_input_tokens || 0; |
| 49 | + |
| 50 | + const totalContext = input + cacheCreation + cacheRead; |
| 51 | + |
| 52 | + debug('[SESSION-READER] input=%d, output=%d, cache_creation=%d, cache_read=%d, TOTAL=%d', |
| 53 | + input, output, cacheCreation, cacheRead, totalContext); |
| 54 | + |
| 55 | + return { |
| 56 | + tokensIn: totalContext, |
| 57 | + tokensOut: output, |
| 58 | + cached: cacheCreation + cacheRead, |
| 59 | + cacheCreationTokens: cacheCreation, |
| 60 | + cacheReadTokens: cacheRead, |
| 61 | + }; |
| 62 | + } |
| 63 | + } catch { /* skip malformed */ } |
| 64 | + } |
| 65 | + return null; |
| 66 | + } catch (err) { |
| 67 | + debug('[SESSION-READER] Error: %s', err); |
| 68 | + return null; |
| 69 | + } |
| 70 | +} |
| 71 | + |
24 | 72 | export interface RunClaudeOptions { |
25 | 73 | prompt: string; |
26 | 74 | workingDir: string; |
@@ -181,68 +229,42 @@ export async function runClaude(options: RunClaudeOptions): Promise<RunClaudeRes |
181 | 229 |
|
182 | 230 | const { command, args } = buildClaudeExecCommand({ workingDir, resumeSessionId, model }); |
183 | 231 |
|
184 | | - // Create telemetry capture instance |
185 | | - const telemetryCapture = createTelemetryCapture('claude', model, prompt, workingDir); |
186 | | - |
187 | | - // Track JSON error events (Claude may exit 0 even on errors) |
| 232 | + // Track state |
188 | 233 | let capturedError: string | null = null; |
189 | | - let sessionIdCaptured = false; |
| 234 | + let capturedSessionId: string | null = null; |
190 | 235 | let stdoutBuffer = ''; |
191 | 236 |
|
192 | 237 | const handleStreamLine = (line: string): void => { |
193 | 238 | if (!line.trim()) return; |
194 | 239 |
|
195 | | - // Capture telemetry data |
196 | | - telemetryCapture.captureFromStreamJson(line); |
197 | | - |
198 | | - // Check for error events (Claude may exit 0 even on errors like invalid model) |
199 | 240 | try { |
200 | 241 | const json = JSON.parse(line); |
201 | 242 |
|
202 | | - // Capture session ID from first event that contains it |
203 | | - if (!sessionIdCaptured && json.session_id && onSessionId) { |
204 | | - sessionIdCaptured = true; |
205 | | - onSessionId(json.session_id); |
| 243 | + // Capture session ID from first event |
| 244 | + if (!capturedSessionId && json.session_id) { |
| 245 | + capturedSessionId = json.session_id; |
| 246 | + onSessionId?.(json.session_id); |
206 | 247 | } |
207 | 248 |
|
208 | | - // Check for error in result type |
| 249 | + // Check for errors |
209 | 250 | if (json.type === 'result' && json.is_error && json.result && !capturedError) { |
210 | 251 | capturedError = json.result; |
211 | 252 | } |
212 | | - // Check for error in assistant message |
213 | 253 | if (json.type === 'assistant' && json.error && !capturedError) { |
214 | | - const messageText = json.message?.content?.[0]?.text; |
215 | | - capturedError = messageText || json.error; |
| 254 | + capturedError = json.message?.content?.[0]?.text || json.error; |
216 | 255 | } |
217 | | - } catch { |
218 | | - // Ignore parse errors |
219 | | - } |
220 | 256 |
|
221 | | - // Emit telemetry event if captured and callback provided |
222 | | - if (onTelemetry) { |
223 | | - const captured = telemetryCapture.getCaptured(); |
224 | | - if (captured && captured.tokens) { |
225 | | - // Per Anthropic docs: total_input = input_tokens + cache_read + cache_creation |
226 | | - // See: https://platform.claude.com/docs/en/build-with-claude/prompt-caching#tracking-cache-performance |
227 | | - const totalIn = (captured.tokens.input ?? 0) + (captured.tokens.cached ?? 0); |
228 | | - |
229 | | - debug('[TELEMETRY:2.5-RUNNER] [CLAUDE] Emitting telemetry via onTelemetry callback'); |
230 | | - debug('[TELEMETRY:2.5-RUNNER] [CLAUDE] CAPTURED: input=%d, output=%d, cached=%s', |
231 | | - captured.tokens.input ?? 0, |
232 | | - captured.tokens.output ?? 0, |
233 | | - captured.tokens.cached ?? 'none'); |
234 | | - debug('[TELEMETRY:2.5-RUNNER] [CLAUDE] TOTAL CONTEXT: %d (input + cached), output=%d', |
235 | | - totalIn, |
236 | | - captured.tokens.output ?? 0); |
237 | | - |
238 | | - onTelemetry({ |
239 | | - tokensIn: totalIn, |
240 | | - tokensOut: captured.tokens.output ?? 0, |
241 | | - cached: captured.tokens.cached, |
242 | | - cost: captured.cost, |
243 | | - duration: captured.duration, |
244 | | - }); |
| 257 | + // Result event = trigger to read telemetry from session file |
| 258 | + if (json.type === 'result' && onTelemetry && capturedSessionId) { |
| 259 | + const sessionPath = getSessionPath(capturedSessionId, workingDir, claudeConfigDir); |
| 260 | + debug('[SESSION-READER] Reading telemetry from: %s', sessionPath); |
| 261 | + const telemetry = readSessionTelemetry(sessionPath); |
| 262 | + if (telemetry) { |
| 263 | + onTelemetry(telemetry); |
| 264 | + } |
245 | 265 | } |
| 266 | + } catch { |
| 267 | + // Ignore parse errors |
246 | 268 | } |
247 | 269 |
|
248 | 270 | const formatted = formatStreamJsonLine(line); |
@@ -351,9 +373,6 @@ export async function runClaude(options: RunClaudeOptions): Promise<RunClaudeRes |
351 | 373 | throw new Error(errorMessage); |
352 | 374 | } |
353 | 375 |
|
354 | | - // Log captured telemetry |
355 | | - telemetryCapture.logCapturedTelemetry(result.exitCode); |
356 | | - |
357 | 376 | return { |
358 | 377 | stdout: result.stdout, |
359 | 378 | stderr: result.stderr, |
|
0 commit comments