@cat-factory/executor-harness 1.64.2 → 1.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,331 @@
1
+ import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js'
2
+ import type { HarnessCallMetric } from './pi.js'
3
+
4
+ // Claude Code's `stream-json` does NOT emit one `assistant` envelope per model call. It emits one
5
+ // per CONTENT BLOCK of a call's response — a turn that answers with text and then fires five
6
+ // parallel tool calls arrives as six envelopes, each carrying that ONE call's `usage`, with the
7
+ // `user` tool_result turns interleaved between them. Treating an envelope as a call therefore
8
+ // counted a single request once per block: a measured pr-review recorded 575 rows and 39.4M summed
9
+ // prompt tokens for ~230 real calls and ~16.3M, which is why the burn instrumentation could not be
10
+ // trusted (docs/initiatives/token-burn-instrumentation.md).
11
+ //
12
+ // This aggregator folds every envelope sharing a `message.id` back into the one call it belongs to,
13
+ // and buffers that call's tool_result turns so the reconstructed prompt chain keeps the shape the
14
+ // model was actually sent: one assistant turn holding all its blocks, then the results.
15
+
16
+ /** One model call, assembled from every stream envelope that carried a piece of it. */
17
+ export interface AggregatedClaudeCall {
18
+ model?: string
19
+ /** Every content block of the response, in arrival order. */
20
+ content: unknown[]
21
+ text: string
22
+ reasoning: string
23
+ stopReason: string | null
24
+ inputTokens: number
25
+ cacheReadTokens: number
26
+ cacheWriteTokens: number
27
+ outputTokens: number
28
+ /** The `user` turns carrying this call's tool_result blocks, in arrival order. */
29
+ toolResults: unknown[][]
30
+ /** tool_use blocks across the whole response (the run's `stats.toolCalls` term). */
31
+ toolUses: number
32
+ }
33
+
34
+ export interface ClaudeCallAggregator {
35
+ /**
36
+ * Fold one `assistant` envelope in. A new `message.id` completes the call in flight first, so
37
+ * `onCallStart` for the new call always runs after `onCall` for the previous one.
38
+ */
39
+ onAssistant(message: Record<string, unknown>): void
40
+ /** Buffer a `user` turn's content against the call in flight (dropped when none is). */
41
+ onToolResult(content: unknown[]): void
42
+ /** Complete the call still in flight, if any. Call once the stream has ended. */
43
+ flush(): void
44
+ }
45
+
46
+ interface Pending extends AggregatedClaudeCall {
47
+ id: string
48
+ }
49
+
50
+ /**
51
+ * Assemble per-call telemetry out of Claude Code's per-block stream envelopes.
52
+ *
53
+ * `onCallStart` fires when a call's FIRST envelope arrives, which is the moment the caller must
54
+ * snapshot the prompt: the history at that point is what produced the response. `onCall` fires
55
+ * once the call is complete (a different `message.id` began, or the stream ended).
56
+ *
57
+ * Usage is merged as the MAXIMUM of each bucket across the call's envelopes rather than the last
58
+ * one seen. The envelopes carry a snapshot of the same call's usage, and which of them holds the
59
+ * final output count is a CLI detail we should not depend on; a max is right whether the value is
60
+ * repeated verbatim or grows.
61
+ *
62
+ * An envelope with no `message.id` cannot be attributed, so it is treated as a call of its own —
63
+ * the pre-aggregation behaviour, kept so a CLI build (or a transcript) that omits the id degrades
64
+ * to over-counting rather than to silently merging unrelated calls.
65
+ */
66
+ export function createClaudeCallAggregator(handlers: {
67
+ onCallStart?: () => void
68
+ onCall: (call: AggregatedClaudeCall) => void
69
+ }): ClaudeCallAggregator {
70
+ let pending: Pending | undefined
71
+ let anonymous = 0
72
+
73
+ const complete = (): void => {
74
+ if (!pending) return
75
+ const { id: _id, ...call } = pending
76
+ pending = undefined
77
+ handlers.onCall(call)
78
+ }
79
+
80
+ return {
81
+ onAssistant(message) {
82
+ // `#anon-<n>` cannot collide with a real id (the API mints `msg_…`), so an envelope
83
+ // with no id keeps its own call rather than merging into whatever came before it.
84
+ const id = typeof message.id === 'string' && message.id ? message.id : `#anon-${anonymous++}`
85
+ if (pending && pending.id !== id) complete()
86
+ const content = Array.isArray(message.content) ? message.content : []
87
+ const { text, reasoning, toolUses } = claudeAssistantContent(content)
88
+ const usage = claudeCallUsage(message.usage)
89
+ const stopReason = typeof message.stop_reason === 'string' ? message.stop_reason : null
90
+ const model = typeof message.model === 'string' ? message.model : undefined
91
+ if (!pending) {
92
+ pending = {
93
+ id,
94
+ content: [],
95
+ text: '',
96
+ reasoning: '',
97
+ stopReason: null,
98
+ inputTokens: 0,
99
+ cacheReadTokens: 0,
100
+ cacheWriteTokens: 0,
101
+ outputTokens: 0,
102
+ toolResults: [],
103
+ toolUses: 0,
104
+ }
105
+ handlers.onCallStart?.()
106
+ }
107
+ pending.content.push(...content)
108
+ pending.text += text
109
+ pending.reasoning += reasoning
110
+ pending.toolUses += toolUses
111
+ pending.inputTokens = Math.max(pending.inputTokens, usage.inputTokens)
112
+ pending.cacheReadTokens = Math.max(pending.cacheReadTokens, usage.cacheReadTokens)
113
+ pending.cacheWriteTokens = Math.max(pending.cacheWriteTokens, usage.cacheWriteTokens)
114
+ pending.outputTokens = Math.max(pending.outputTokens, usage.outputTokens)
115
+ // A block-split response reports its stop reason on the envelope that carries the end of the
116
+ // message; earlier ones report none. Keep the first non-null rather than the last seen.
117
+ if (stopReason && !pending.stopReason) pending.stopReason = stopReason
118
+ if (model && !pending.model) pending.model = model
119
+ },
120
+
121
+ onToolResult(content) {
122
+ // Results can only belong to the tool_use blocks of the call in flight. Before the first
123
+ // assistant envelope there is nothing they could attach to.
124
+ if (pending) pending.toolResults.push(content)
125
+ },
126
+
127
+ flush: complete,
128
+ }
129
+ }
130
+
131
+ /** One turn of the reconstructed request transcript, in the proxy's chat-array shape. */
132
+ interface TranscriptTurn {
133
+ role: string
134
+ content: unknown
135
+ }
136
+
137
+ /** The per-call telemetry the Claude Code stream yields, assembled behind one small surface. */
138
+ export interface ClaudeStreamTelemetry {
139
+ /** Fold an `assistant` envelope in (parent-loop turns only — see {@link isSubagentEvent}). */
140
+ onAssistant(message: Record<string, unknown>): void
141
+ /** Fold a `user` turn's tool_result content in, against the call in flight. */
142
+ onToolResult(content: unknown[]): void
143
+ /** Publish the call still in flight. Idempotent; safe to call on both the clean and error path. */
144
+ flush(): void
145
+ }
146
+
147
+ /**
148
+ * Assemble ONE conversation's per-call telemetry from the CLI stream: the growing request
149
+ * transcript and the per-call token/body metrics.
150
+ *
151
+ * Owns the transcript because the two are one concern — a call's `promptText` is the transcript as
152
+ * of that call, and its turns may only be appended once the call that produced them is complete.
153
+ * `seed` is what the harness supplied and the stream therefore never shows (the system + first user
154
+ * message, or the single folded user turn), so the reconstruction never claims a system turn that
155
+ * was not sent. A subagent's conversation seeds EMPTY — its prompt was minted by the CLI and never
156
+ * crosses this stream — which is also why its first call carries `messageCount: 0` (the backend's
157
+ * `latestChainTip` skips those on purpose: there is no re-sendable chain to delta against).
158
+ * Bodies are credential-scrubbed; they can echo the leased token.
159
+ *
160
+ * Deliberately does NOT touch {@link PiRunStats}: the run's tool/output counters describe whether
161
+ * the agent ACTED at all (`agentNeverActed`), which is true of a subagent's turns whichever channel
162
+ * ends up owning their telemetry rows. The caller accumulates them off the raw stream instead.
163
+ */
164
+ export function createClaudeStreamTelemetry(opts: {
165
+ seed: TranscriptTurn[]
166
+ secrets: string[]
167
+ publish: (metric: HarnessCallMetric) => void
168
+ }): ClaudeStreamTelemetry {
169
+ const messages: TranscriptTurn[] = [...opts.seed]
170
+ let callPrompt = ''
171
+ let callMessageCount = 0
172
+
173
+ // The aggregator IS the surface: the transcript and metric work happens in its callbacks, so
174
+ // there is nothing to wrap it in.
175
+ return createClaudeCallAggregator({
176
+ // Snapshotted when a call's FIRST envelope arrives: the history at that moment is what
177
+ // produced the response, and later envelopes of the same call must not see the turns it
178
+ // went on to add.
179
+ onCallStart: () => {
180
+ callPrompt = redactBody(JSON.stringify(messages), opts.secrets)
181
+ callMessageCount = messages.length
182
+ },
183
+ onCall: (call) => {
184
+ opts.publish({
185
+ ...(call.model ? { model: call.model } : {}),
186
+ promptText: callPrompt,
187
+ messageCount: callMessageCount,
188
+ responseText: redactBody(call.text, opts.secrets),
189
+ reasoningText: redactBody(call.reasoning, opts.secrets),
190
+ inputTokens: call.inputTokens,
191
+ cacheReadTokens: call.cacheReadTokens,
192
+ cacheWriteTokens: call.cacheWriteTokens,
193
+ outputTokens: call.outputTokens,
194
+ finishReason: call.stopReason,
195
+ })
196
+ // Appended only now, so each call's prompt stays a strict prefix of the next and the
197
+ // backend's telemetry chain delta-compresses cleanly.
198
+ messages.push({ role: 'assistant', content: call.content })
199
+ for (const result of call.toolResults) messages.push({ role: 'tool', content: result })
200
+ },
201
+ })
202
+ }
203
+
204
+ /**
205
+ * The dispatch (`Agent`/`Task` tool_use) id a stream envelope is tagged with, or `undefined` for a
206
+ * parent-loop turn.
207
+ *
208
+ * Claude Code streams the turns of the subagents it dispatches onto the parent's stdout, tagged
209
+ * with the tool_use id that spawned them. Those same turns are also written to the per-session
210
+ * `subagents/*.jsonl` transcripts the watcher reads, so recording both channels counted every
211
+ * subagent call twice — and splicing them into the parent's message reconstruction produced a
212
+ * `promptText` chain that interleaves several conversations and therefore matches no real request.
213
+ *
214
+ * The id is what makes the fallback below possible: concurrent subagents interleave on one stdout,
215
+ * so it is the ONLY thing separating their conversations.
216
+ */
217
+ export function subagentDispatchId(event: Record<string, unknown>): string | undefined {
218
+ if (!isObject(event)) return undefined
219
+ const id = event.parent_tool_use_id
220
+ return typeof id === 'string' && id ? id : undefined
221
+ }
222
+
223
+ /** Whether a stream envelope describes a SUBAGENT's turn rather than the parent loop's. */
224
+ export function isSubagentEvent(event: Record<string, unknown>): boolean {
225
+ return subagentDispatchId(event) !== undefined
226
+ }
227
+
228
+ /**
229
+ * Per-call telemetry for the subagents whose turns ride the parent's stdout — the FALLBACK channel,
230
+ * used only when no `subagents/*.jsonl` watcher will run (see `startSubagentWatcher`, which is
231
+ * wired only when the CLI has an isolated config home; an `ambientAuth` run has none).
232
+ *
233
+ * Without this, filtering tagged events out of the parent's telemetry leaves a subagent-heavy run
234
+ * with its spend recorded by NEITHER channel — an under-count, which reads as a cheap run and is
235
+ * the worse failure direction than the double-count the filter exists to fix.
236
+ *
237
+ * Each dispatch id gets its OWN transcript, because concurrent subagents interleave arbitrarily on
238
+ * one stream: folding them into a single chain is exactly the defect this whole module removes,
239
+ * one level down.
240
+ */
241
+ function createSubagentStreamTelemetry(opts: {
242
+ secrets: string[]
243
+ publish: (metric: HarnessCallMetric) => void
244
+ }): {
245
+ onAssistant(dispatchId: string, message: Record<string, unknown>): void
246
+ onToolResult(dispatchId: string, content: unknown[]): void
247
+ flush(): void
248
+ } {
249
+ const perDispatch = new Map<string, ClaudeStreamTelemetry>()
250
+ const forDispatch = (dispatchId: string): ClaudeStreamTelemetry => {
251
+ let telemetry = perDispatch.get(dispatchId)
252
+ if (!telemetry) {
253
+ // Seeded EMPTY: the CLI minted this subagent's prompt and it never crossed the stream.
254
+ telemetry = createClaudeStreamTelemetry({
255
+ seed: [],
256
+ secrets: opts.secrets,
257
+ publish: opts.publish,
258
+ })
259
+ perDispatch.set(dispatchId, telemetry)
260
+ }
261
+ return telemetry
262
+ }
263
+ return {
264
+ onAssistant: (dispatchId, message) => forDispatch(dispatchId).onAssistant(message),
265
+ // Only against a dispatch already seen: a result for a subagent whose assistant turns never
266
+ // reached us has no conversation to attach to, and minting one would publish a call that is
267
+ // all tool output and no request.
268
+ onToolResult: (dispatchId, content) => perDispatch.get(dispatchId)?.onToolResult(content),
269
+ flush: () => {
270
+ for (const telemetry of perDispatch.values()) telemetry.flush()
271
+ },
272
+ }
273
+ }
274
+
275
+ /** All per-call telemetry for ONE claude-code run: the parent loop, and whoever bills the subagents. */
276
+ export interface ClaudeRunTelemetry {
277
+ /** Fold an `assistant` envelope in, routed by its dispatch tag (`undefined` ⇒ the parent loop). */
278
+ onAssistant(dispatchId: string | undefined, message: Record<string, unknown>): void
279
+ /** Fold a `user` turn's tool_result content in, against the same conversation. */
280
+ onToolResult(dispatchId: string | undefined, content: unknown[]): void
281
+ /** Publish every conversation's call in flight. Idempotent; safe on the clean and error paths. */
282
+ flush(): void
283
+ /**
284
+ * Subagent turns crossed the stream AND the watcher was the channel meant to record them — so a
285
+ * watcher that captured nothing means this run's subagent rows are simply missing.
286
+ */
287
+ expectsWatcherCalls(): boolean
288
+ }
289
+
290
+ /**
291
+ * Assemble a run's per-call telemetry, routing each envelope to the conversation it belongs to.
292
+ *
293
+ * The routing is the whole point. A subagent's turns ride the parent's stdout tagged with the
294
+ * dispatch that spawned them, and they must never join the PARENT's chain — that splice produced a
295
+ * `promptText` interleaving several conversations, matching no request that was ever sent.
296
+ *
297
+ * Who RECORDS them is a separate question, decided once per run rather than per event:
298
+ * `watcherOwnsSubagents` says a `subagents/*.jsonl` watcher will run, and it is the better source
299
+ * (it reads the settled transcript, so its usage and stop reason are final). With no watcher — an
300
+ * `ambientAuth` run has no isolated config home to watch — the tagged turns are recorded here
301
+ * instead, on per-dispatch transcripts of their own. Dropping them in that case would leave the run
302
+ * billed by neither channel, and an under-count reads as a cheap run rather than as an error.
303
+ */
304
+ export function createClaudeRunTelemetry(opts: {
305
+ seed: TranscriptTurn[]
306
+ secrets: string[]
307
+ watcherOwnsSubagents: boolean
308
+ publish: (metric: HarnessCallMetric) => void
309
+ }): ClaudeRunTelemetry {
310
+ const parent = createClaudeStreamTelemetry(opts)
311
+ const subagents = opts.watcherOwnsSubagents ? undefined : createSubagentStreamTelemetry(opts)
312
+ let sawSubagentTurn = false
313
+
314
+ return {
315
+ onAssistant(dispatchId, message) {
316
+ if (!dispatchId) return parent.onAssistant(message)
317
+ sawSubagentTurn = true
318
+ subagents?.onAssistant(dispatchId, message)
319
+ },
320
+ onToolResult(dispatchId, content) {
321
+ if (!dispatchId) return parent.onToolResult(content)
322
+ sawSubagentTurn = true
323
+ subagents?.onToolResult(dispatchId, content)
324
+ },
325
+ flush() {
326
+ parent.flush()
327
+ subagents?.flush()
328
+ },
329
+ expectsWatcherCalls: () => opts.watcherOwnsSubagents && sawSubagentTurn,
330
+ }
331
+ }
@@ -59,19 +59,27 @@ export function claudeAssistantContent(content: unknown[]): {
59
59
 
60
60
  /**
61
61
  * Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
62
- * the cumulative `result` total). `inputTokens` counts every billed input bucket (fresh
63
- * + both cache buckets); `cachedInputTokens` is the cache share, surfaced separately.
62
+ * the cumulative `result` total).
63
+ *
64
+ * Anthropic reports all three input classes SEPARATELY and `input_tokens` is already
65
+ * exclusive of both caches, so the three fields here are orthogonal and additive:
66
+ * total input = `inputTokens + cacheReadTokens + cacheWriteTokens`. Do NOT re-lump the
67
+ * reads and the writes — a cache write costs 1.25–2× base input while a read costs ~0.1×,
68
+ * so a turn that keeps invalidating the prefix and one that rides a warm cache are
69
+ * indistinguishable once they are summed.
64
70
  */
65
71
  export function claudeCallUsage(raw: unknown): {
66
72
  inputTokens: number
67
- cachedInputTokens: number
73
+ cacheReadTokens: number
74
+ cacheWriteTokens: number
68
75
  outputTokens: number
69
76
  } {
70
- if (!isObject(raw)) return { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }
71
- const cached = numberOf(raw.cache_read_input_tokens) + numberOf(raw.cache_creation_input_tokens)
77
+ if (!isObject(raw))
78
+ return { inputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 }
72
79
  return {
73
- inputTokens: numberOf(raw.input_tokens) + cached,
74
- cachedInputTokens: cached,
80
+ inputTokens: numberOf(raw.input_tokens),
81
+ cacheReadTokens: numberOf(raw.cache_read_input_tokens),
82
+ cacheWriteTokens: numberOf(raw.cache_creation_input_tokens),
75
83
  outputTokens: numberOf(raw.output_tokens),
76
84
  }
77
85
  }
package/src/inline.ts CHANGED
@@ -54,10 +54,43 @@ export async function handleInline(job: InlineJob, opts: RunOptions): Promise<In
54
54
  return {
55
55
  text: outcome.summary,
56
56
  finishReason: deriveFinishReason(outcome.callMetrics),
57
- ...(outcome.usage ? { usage: outcome.usage } : {}),
57
+ ...(outcome.usage ? { usage: inlineUsage(outcome.usage, outcome.callMetrics) } : {}),
58
58
  ...(outcome.callMetrics ? { callMetrics: outcome.callMetrics } : {}),
59
59
  }
60
60
  } finally {
61
61
  await rm(cwd, { recursive: true, force: true }).catch(() => {})
62
62
  }
63
63
  }
64
+
65
+ /**
66
+ * Split the run's coarse usage into the three orthogonal input classes an {@link InlineResult}
67
+ * carries. `outcome.usage` is the ROTATION-window weight — every billed input bucket summed —
68
+ * so the split has to come from the per-call metrics, the only channel that kept the classes
69
+ * apart. Fresh input is likewise taken from the calls rather than derived by subtraction, so a
70
+ * CLI whose per-call and cumulative counts disagree can never produce a negative class.
71
+ *
72
+ * With no per-call telemetry (an older CLI build that streams nothing) the coarse total is
73
+ * reported as fresh with both cache classes 0. That is the honest reading: nothing is KNOWN to
74
+ * have been cached, and inventing a split would be worse than admitting the channel is silent.
75
+ */
76
+ function inlineUsage(
77
+ usage: { inputTokens: number; outputTokens: number },
78
+ calls: HarnessCallMetric[] | undefined,
79
+ ): NonNullable<InlineResult['usage']> {
80
+ if (!calls?.length) {
81
+ return {
82
+ inputTokens: usage.inputTokens,
83
+ cacheReadTokens: 0,
84
+ cacheWriteTokens: 0,
85
+ outputTokens: usage.outputTokens,
86
+ }
87
+ }
88
+ const sum = (pick: (call: HarnessCallMetric) => number): number =>
89
+ calls.reduce((total, call) => total + pick(call), 0)
90
+ return {
91
+ inputTokens: sum((call) => call.inputTokens),
92
+ cacheReadTokens: sum((call) => call.cacheReadTokens),
93
+ cacheWriteTokens: sum((call) => call.cacheWriteTokens),
94
+ outputTokens: usage.outputTokens,
95
+ }
96
+ }
package/src/job.ts CHANGED
@@ -1251,7 +1251,20 @@ export interface InlineResult {
1251
1251
  text: string
1252
1252
  /** `length` when the model hit its output cap (the reviewer rejects a truncated doc). */
1253
1253
  finishReason?: 'stop' | 'length'
1254
- usage?: { inputTokens: number; outputTokens: number }
1254
+ /**
1255
+ * The job's token usage with the input side split into its three ORTHOGONAL classes:
1256
+ * `inputTokens` is FRESH input only, so the total input is
1257
+ * `inputTokens + cacheReadTokens + cacheWriteTokens`. Folded from the per-call metrics below,
1258
+ * which is the only channel that knows the split; a CLI that streamed none falls back to the
1259
+ * coarse total with both cache classes 0 — honest, since on that shape nothing is known to
1260
+ * have been cached.
1261
+ */
1262
+ usage?: {
1263
+ inputTokens: number
1264
+ cacheReadTokens: number
1265
+ cacheWriteTokens: number
1266
+ outputTokens: number
1267
+ }
1255
1268
  /** Per-model-call telemetry lifted from the CLI stream (recorded into `llm_call_metrics`). */
1256
1269
  callMetrics?: HarnessCallMetric[]
1257
1270
  /** A structured failure marks a job-level failure even on a clean HTTP exit (see JobResultBase). */
package/src/pi.ts CHANGED
@@ -509,8 +509,21 @@ export interface HarnessCallMetric {
509
509
  responseText: string
510
510
  /** The reasoning/thinking trace, as a plain string (`''` when none). */
511
511
  reasoningText: string
512
+ /**
513
+ * FRESH (uncached) input tokens: exclusive of BOTH cache classes below, so the three
514
+ * are orthogonal and additive. Every producer normalises to this — reading the already
515
+ * exclusive field where the vendor reports the classes apart (Anthropic), subtracting
516
+ * the cached share where the vendor reports an inclusive prompt count (Codex/OpenAI).
517
+ */
512
518
  inputTokens: number
513
- cachedInputTokens: number
519
+ /** Input tokens served from the vendor's prompt cache (~0.1× base input). */
520
+ cacheReadTokens: number
521
+ /**
522
+ * Input tokens written INTO the vendor's cache (1.25–2× base input — dearer than fresh),
523
+ * kept apart from the reads so a loop that keeps re-writing the prefix is distinguishable
524
+ * from one riding a warm cache. 0 where the CLI reports no separate write class.
525
+ */
526
+ cacheWriteTokens: number
514
527
  outputTokens: number
515
528
  /** The provider finish/stop reason when the CLI reports one (else null). */
516
529
  finishReason: string | null
package/src/subagents.ts CHANGED
@@ -40,9 +40,15 @@ import { publishCallMetric, type HarnessCallMetric, type TodoProgress } from './
40
40
  // `projects` root and DISCOVERS the `subagents/` dir by walking (see
41
41
  // {@link findSubagentTranscripts}).
42
42
  //
43
- // Both degrade gracefully: the CLI's subagent transcript layout is not a stable contract,
44
- // so a missing directory, an unreadable file, or an unparseable line is swallowed and the
45
- // harness falls back to today's parent-stream-only behaviour.
43
+ // Both degrade gracefully in the sense that a missing directory, an unreadable file, or an
44
+ // unparseable line is swallowed rather than failing the run the CLI's subagent transcript layout
45
+ // is not a stable contract. But note what that costs SINCE the per-call fold landed: the parent
46
+ // loop's telemetry now filters the subagent turns the CLI tags onto its stdout (they were being
47
+ // counted twice and spliced into the parent's message chain), so when this watcher is wired and
48
+ // yields nothing, the run's subagent calls are recorded by NEITHER channel. `runClaudeCode` warns
49
+ // on exactly that shape, and an `ambientAuth` run — which has no config home to watch, so no
50
+ // watcher — keeps recording them off the parent stream instead
51
+ // (`createSubagentStreamTelemetry`). Do not "simplify" that fallback away.
46
52
 
47
53
  // ---------------------------------------------------------------------------
48
54
  // Slice / progress tracking off the PARENT stream (D2.1)
@@ -230,7 +236,16 @@ export function startSubagentWatcher(root: string, opts: SubagentWatcherOptions)
230
236
  if (event.type !== 'assistant' || !isObject(event.message)) return
231
237
  const message = event.message as Record<string, unknown>
232
238
  const u = claudeCallUsage(message.usage)
233
- if (u.inputTokens === 0 && u.outputTokens === 0) return
239
+ // Every input class counts towards "did this turn report usage at all": a turn riding a
240
+ // warm cache legitimately reports 0 fresh input, and skipping it would drop precisely the
241
+ // cache-heavy calls this telemetry exists to weigh.
242
+ if (
243
+ u.inputTokens === 0 &&
244
+ u.cacheReadTokens === 0 &&
245
+ u.cacheWriteTokens === 0 &&
246
+ u.outputTokens === 0
247
+ )
248
+ return
234
249
  const content = Array.isArray(message.content) ? message.content : []
235
250
  const { text, reasoning } = claudeAssistantContent(content)
236
251
  publishCallMetric(
@@ -248,13 +263,18 @@ export function startSubagentWatcher(root: string, opts: SubagentWatcherOptions)
248
263
  responseText: redactBody(text, secrets),
249
264
  reasoningText: redactBody(reasoning, secrets),
250
265
  inputTokens: u.inputTokens,
251
- cachedInputTokens: u.cachedInputTokens,
266
+ cacheReadTokens: u.cacheReadTokens,
267
+ cacheWriteTokens: u.cacheWriteTokens,
252
268
  outputTokens: u.outputTokens,
253
269
  finishReason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
254
270
  },
255
271
  opts.onCallMetric,
256
272
  )
257
- usage.inputTokens += u.inputTokens
273
+ // The run-level `usage` is the COARSE rotation-window weight, which counts every billed
274
+ // input bucket — unlike the per-call metric above, whose `inputTokens` is fresh-only. Sum
275
+ // all three classes back together here or a cache-heavy subagent looks nearly free to the
276
+ // rotation.
277
+ usage.inputTokens += u.inputTokens + u.cacheReadTokens + u.cacheWriteTokens
258
278
  usage.outputTokens += u.outputTokens
259
279
  }
260
280