@cat-factory/executor-harness 1.64.2 → 1.64.4

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.
@@ -2,7 +2,8 @@ import { spawn } from 'node:child_process';
2
2
  import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
- import { claudeAssistantContent, claudeCallUsage, isObject, numberOf, redactBody, } from './claude-stream.js';
5
+ import { claudeAssistantContent, isObject, numberOf, redactBody } from './claude-stream.js';
6
+ import { createClaudeRunTelemetry, subagentDispatchId } from './claude-call-aggregator.js';
6
7
  import { createCallMetricPublisher, publishCallMetric, } from './pi.js';
7
8
  import { ProgressGuard } from './progress-guard.js';
8
9
  import { killChildProcess, spawnDetached } from './process.js';
@@ -214,30 +215,39 @@ export async function runClaudeCode(opts) {
214
215
  });
215
216
  }
216
217
  // Reconstruct the full per-call request/response bodies for telemetry from the
217
- // stream. `--output-format stream-json --verbose` emits each turn as a near-verbatim
218
- // Anthropic Messages envelope, so `assistant` events carry the complete response
219
- // (text + tool_use blocks + usage), and `user` events carry the tool_result blocks
220
- // fed back — together the growing prompt transcript. We seed it with the inputs the
221
- // harness supplies (they never appear in the stream): the system + first user message
222
- // when the prompt rides argv, or a single folded user turn when it doesn't so the
223
- // reconstruction never shows a system turn that was never sent. Bodies are
224
- // credential-scrubbed (they can echo the leased token).
218
+ // stream. `--output-format stream-json --verbose` emits a near-verbatim Anthropic
219
+ // Messages envelope per response CONTENT BLOCK (not per call), so the aggregator below
220
+ // folds the envelopes sharing a `message.id` back into one call and buffers that call's
221
+ // `user` tool_result turns — together the growing prompt transcript, in the shape the
222
+ // model was actually sent. We seed it with the inputs the harness supplies (they never
223
+ // appear in the stream): the system + first user message when the prompt rides argv, or
224
+ // a single folded user turn when it doesn't — so the reconstruction never shows a system
225
+ // turn that was never sent. Bodies are credential-scrubbed (they can echo the leased token).
225
226
  const secrets = opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [];
226
- const messages = folded
227
- ? [{ role: 'user', content: prompt }]
228
- : [
229
- { role: 'system', content: opts.systemPrompt },
230
- { role: 'user', content: opts.userPrompt },
231
- ];
232
227
  const calls = [];
233
228
  // Streams each call as the CLI yields it, EXCEPT one whose tokens `attributeCumulativeUsage`
234
229
  // may still rewrite below (a published call must be final — see the publisher).
235
230
  const publisher = createCallMetricPublisher(calls, opts.onCallMetric);
231
+ // `watcherOwnsSubagents` tracks the `startSubagentWatcher` wiring below: it is started only when
232
+ // the CLI has an isolated config home to watch, which an `ambientAuth` run does not have. The
233
+ // telemetry routes the CLI's tagged subagent turns accordingly — see `createClaudeRunTelemetry`.
234
+ const telemetry = createClaudeRunTelemetry({
235
+ seed: folded
236
+ ? [{ role: 'user', content: prompt }]
237
+ : [
238
+ { role: 'system', content: opts.systemPrompt },
239
+ { role: 'user', content: opts.userPrompt },
240
+ ],
241
+ secrets,
242
+ watcherOwnsSubagents: !opts.ambientAuth,
243
+ publish: (metric) => publisher.publish(metric),
244
+ });
236
245
  // ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from the two views the run
237
246
  // produces of the SAME slicing. The parent's subagent dispatches + their terminal tool_results
238
- // DO appear on this stream (only a subagent's intermediate turns don't), so `sliceTracker`
239
- // knows which slices are in flight and which have returned; the parent's own plan (tracked by
240
- // `planTracker` + `lastTodo`) is the only place a not-yet-dispatched slice is named at all.
247
+ // appear on this stream (as do the subagents' own intermediate turns, tagged with the dispatch
248
+ // that spawned them see `isSubagentEvent`), so `sliceTracker` knows which slices are in flight
249
+ // and which have returned; the parent's own plan (tracked by `planTracker` + `lastTodo`) is the
250
+ // only place a not-yet-dispatched slice is named at all.
241
251
  //
242
252
  // The plan arrives in one of two tool vocabularies depending on the bundled CLI build:
243
253
  // `TodoWrite` (whole-list snapshots, tracked in `lastTodo`) or the incremental
@@ -295,12 +305,19 @@ export async function runClaudeCode(opts) {
295
305
  };
296
306
  const onEvent = (event, meta) => {
297
307
  const type = event.type;
308
+ // A subagent's turns ride the parent's stdout tagged with the dispatch that spawned them;
309
+ // `telemetry` routes them off the parent's chain (and decides who bills them). Progress, slice
310
+ // tracking, the guard and `stats` below deliberately see EVERY event: a subagent grinding on
311
+ // errors should trip the guard exactly as the parent would, and whether the agent acted at all
312
+ // does not depend on which channel billed it.
313
+ const dispatchId = subagentDispatchId(event);
298
314
  if (type === 'assistant' && isObject(event.message)) {
299
315
  const message = event.message;
300
316
  const content = Array.isArray(message.content) ? message.content : [];
301
- const { text, reasoning, toolUses } = claudeAssistantContent(content);
317
+ const { text, toolUses } = claudeAssistantContent(content);
302
318
  stats.assistantChars += text.length;
303
319
  stats.toolCalls += toolUses;
320
+ telemetry.onAssistant(dispatchId, message);
304
321
  for (const block of content) {
305
322
  if (!isObject(block) || block.type !== 'tool_use')
306
323
  continue;
@@ -318,22 +335,6 @@ export async function runClaudeCode(opts) {
318
335
  sliceTracker.onAssistant(content);
319
336
  planTracker.onAssistant(content);
320
337
  emitProgress();
321
- // Record this call BEFORE appending its turn: the prompt is the history that
322
- // produced this response. The append-only array keeps each call's prompt a strict
323
- // prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
324
- const u = claudeCallUsage(message.usage);
325
- publisher.publish({
326
- ...(typeof message.model === 'string' ? { model: message.model } : {}),
327
- promptText: redactBody(JSON.stringify(messages), secrets),
328
- messageCount: messages.length,
329
- responseText: redactBody(text, secrets),
330
- reasoningText: redactBody(reasoning, secrets),
331
- inputTokens: u.inputTokens,
332
- cachedInputTokens: u.cachedInputTokens,
333
- outputTokens: u.outputTokens,
334
- finishReason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
335
- });
336
- messages.push({ role: 'assistant', content });
337
338
  }
338
339
  else if (type === 'user' && isObject(event.message)) {
339
340
  // tool_result blocks the harness fed back to the model — part of the next prompt.
@@ -346,7 +347,7 @@ export async function runClaudeCode(opts) {
346
347
  // would kill nothing and only convert a clean exit into a spurious failure.
347
348
  if (!meta?.final)
348
349
  feedGuard(content);
349
- messages.push({ role: 'tool', content });
350
+ telemetry.onToolResult(dispatchId, content);
350
351
  }
351
352
  }
352
353
  else if (type === 'result') {
@@ -432,6 +433,8 @@ export async function runClaudeCode(opts) {
432
433
  ...appendArgs,
433
434
  ],
434
435
  }, prompt, { ...opts, signal: runSignal }, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
436
+ // The stream has ended, so the last call has no successor envelope to complete it.
437
+ telemetry.flush();
435
438
  return await assembleClaudeOutcome({
436
439
  summary,
437
440
  stats,
@@ -440,9 +443,17 @@ export async function runClaudeCode(opts) {
440
443
  publisher,
441
444
  usage,
442
445
  subagents,
446
+ expectSubagentCalls: telemetry.expectsWatcherCalls(),
447
+ ...(opts.log ? { log: opts.log } : {}),
443
448
  });
444
449
  }
445
450
  catch (err) {
451
+ // The stream ended abnormally (guard trip, watchdog kill, CLI crash). Complete the call in
452
+ // flight anyway, and release whatever the publisher was withholding: a killed run never
453
+ // returns an outcome, so the live channel is the ONLY record of what it spent, and dropping
454
+ // its last turn is what the streaming exists to avoid.
455
+ telemetry.flush();
456
+ publisher.flush();
446
457
  // A tripped no-progress guard aborted the CLI; streamCli rejects with its generic abort
447
458
  // message, so replace it with the guard's actionable diagnostic — carrying the stderr tail
448
459
  // it attached, since that is usually the only evidence of what the CLI was doing when it was
@@ -498,6 +509,10 @@ function buildClaudeEnv(opts, configHome) {
498
509
  * terminal `result` event's cumulative) covers ONLY the parent loop, and the subagent tokens live
499
510
  * exclusively in the per-session `subagents/*.jsonl` transcripts the watcher reads. Extracted from
500
511
  * {@link runClaudeCode} verbatim to keep its cyclomatic complexity down.
512
+ *
513
+ * The invariant is about the aggregate `usage` only. `calls` was NEVER disjoint from the watcher's
514
+ * on its own: the CLI streams a subagent's turns onto the parent's stdout as well, so the parent
515
+ * loop's telemetry must filter them (`subagentDispatchId`) for this concatenation to hold.
501
516
  */
502
517
  async function assembleClaudeOutcome(args) {
503
518
  const { summary, stats, stderrTail, calls, publisher, usage, subagents } = args;
@@ -513,6 +528,10 @@ async function assembleClaudeOutcome(args) {
513
528
  await subagents?.stop();
514
529
  const subUsage = subagents?.usage() ?? { inputTokens: 0, outputTokens: 0 };
515
530
  const subCalls = subagents?.calls() ?? [];
531
+ if (args.expectSubagentCalls && !subCalls.length) {
532
+ args.log?.warn('subagent turns were streamed but the transcript watcher captured no calls; their token ' +
533
+ 'spend is missing from this run’s telemetry (check the CLI’s subagents/*.jsonl layout)');
534
+ }
516
535
  const mergedCalls = [...calls, ...subCalls];
517
536
  const mergedUsage = usage || subUsage.inputTokens || subUsage.outputTokens
518
537
  ? {
@@ -0,0 +1,229 @@
1
+ import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js';
2
+ /**
3
+ * Assemble per-call telemetry out of Claude Code's per-block stream envelopes.
4
+ *
5
+ * `onCallStart` fires when a call's FIRST envelope arrives, which is the moment the caller must
6
+ * snapshot the prompt: the history at that point is what produced the response. `onCall` fires
7
+ * once the call is complete (a different `message.id` began, or the stream ended).
8
+ *
9
+ * Usage is merged as the MAXIMUM of each bucket across the call's envelopes rather than the last
10
+ * one seen. The envelopes carry a snapshot of the same call's usage, and which of them holds the
11
+ * final output count is a CLI detail we should not depend on; a max is right whether the value is
12
+ * repeated verbatim or grows.
13
+ *
14
+ * An envelope with no `message.id` cannot be attributed, so it is treated as a call of its own —
15
+ * the pre-aggregation behaviour, kept so a CLI build (or a transcript) that omits the id degrades
16
+ * to over-counting rather than to silently merging unrelated calls.
17
+ */
18
+ export function createClaudeCallAggregator(handlers) {
19
+ let pending;
20
+ let anonymous = 0;
21
+ const complete = () => {
22
+ if (!pending)
23
+ return;
24
+ const { id: _id, ...call } = pending;
25
+ pending = undefined;
26
+ handlers.onCall(call);
27
+ };
28
+ return {
29
+ onAssistant(message) {
30
+ // `#anon-<n>` cannot collide with a real id (the API mints `msg_…`), so an envelope
31
+ // with no id keeps its own call rather than merging into whatever came before it.
32
+ const id = typeof message.id === 'string' && message.id ? message.id : `#anon-${anonymous++}`;
33
+ if (pending && pending.id !== id)
34
+ complete();
35
+ const content = Array.isArray(message.content) ? message.content : [];
36
+ const { text, reasoning, toolUses } = claudeAssistantContent(content);
37
+ const usage = claudeCallUsage(message.usage);
38
+ const stopReason = typeof message.stop_reason === 'string' ? message.stop_reason : null;
39
+ const model = typeof message.model === 'string' ? message.model : undefined;
40
+ if (!pending) {
41
+ pending = {
42
+ id,
43
+ content: [],
44
+ text: '',
45
+ reasoning: '',
46
+ stopReason: null,
47
+ inputTokens: 0,
48
+ cachedInputTokens: 0,
49
+ outputTokens: 0,
50
+ toolResults: [],
51
+ toolUses: 0,
52
+ };
53
+ handlers.onCallStart?.();
54
+ }
55
+ pending.content.push(...content);
56
+ pending.text += text;
57
+ pending.reasoning += reasoning;
58
+ pending.toolUses += toolUses;
59
+ pending.inputTokens = Math.max(pending.inputTokens, usage.inputTokens);
60
+ pending.cachedInputTokens = Math.max(pending.cachedInputTokens, usage.cachedInputTokens);
61
+ pending.outputTokens = Math.max(pending.outputTokens, usage.outputTokens);
62
+ // A block-split response reports its stop reason on the envelope that carries the end of the
63
+ // message; earlier ones report none. Keep the first non-null rather than the last seen.
64
+ if (stopReason && !pending.stopReason)
65
+ pending.stopReason = stopReason;
66
+ if (model && !pending.model)
67
+ pending.model = model;
68
+ },
69
+ onToolResult(content) {
70
+ // Results can only belong to the tool_use blocks of the call in flight. Before the first
71
+ // assistant envelope there is nothing they could attach to.
72
+ if (pending)
73
+ pending.toolResults.push(content);
74
+ },
75
+ flush: complete,
76
+ };
77
+ }
78
+ /**
79
+ * Assemble ONE conversation's per-call telemetry from the CLI stream: the growing request
80
+ * transcript and the per-call token/body metrics.
81
+ *
82
+ * Owns the transcript because the two are one concern — a call's `promptText` is the transcript as
83
+ * of that call, and its turns may only be appended once the call that produced them is complete.
84
+ * `seed` is what the harness supplied and the stream therefore never shows (the system + first user
85
+ * message, or the single folded user turn), so the reconstruction never claims a system turn that
86
+ * was not sent. A subagent's conversation seeds EMPTY — its prompt was minted by the CLI and never
87
+ * crosses this stream — which is also why its first call carries `messageCount: 0` (the backend's
88
+ * `latestChainTip` skips those on purpose: there is no re-sendable chain to delta against).
89
+ * Bodies are credential-scrubbed; they can echo the leased token.
90
+ *
91
+ * Deliberately does NOT touch {@link PiRunStats}: the run's tool/output counters describe whether
92
+ * the agent ACTED at all (`agentNeverActed`), which is true of a subagent's turns whichever channel
93
+ * ends up owning their telemetry rows. The caller accumulates them off the raw stream instead.
94
+ */
95
+ export function createClaudeStreamTelemetry(opts) {
96
+ const messages = [...opts.seed];
97
+ let callPrompt = '';
98
+ let callMessageCount = 0;
99
+ // The aggregator IS the surface: the transcript and metric work happens in its callbacks, so
100
+ // there is nothing to wrap it in.
101
+ return createClaudeCallAggregator({
102
+ // Snapshotted when a call's FIRST envelope arrives: the history at that moment is what
103
+ // produced the response, and later envelopes of the same call must not see the turns it
104
+ // went on to add.
105
+ onCallStart: () => {
106
+ callPrompt = redactBody(JSON.stringify(messages), opts.secrets);
107
+ callMessageCount = messages.length;
108
+ },
109
+ onCall: (call) => {
110
+ opts.publish({
111
+ ...(call.model ? { model: call.model } : {}),
112
+ promptText: callPrompt,
113
+ messageCount: callMessageCount,
114
+ responseText: redactBody(call.text, opts.secrets),
115
+ reasoningText: redactBody(call.reasoning, opts.secrets),
116
+ inputTokens: call.inputTokens,
117
+ cachedInputTokens: call.cachedInputTokens,
118
+ outputTokens: call.outputTokens,
119
+ finishReason: call.stopReason,
120
+ });
121
+ // Appended only now, so each call's prompt stays a strict prefix of the next and the
122
+ // backend's telemetry chain delta-compresses cleanly.
123
+ messages.push({ role: 'assistant', content: call.content });
124
+ for (const result of call.toolResults)
125
+ messages.push({ role: 'tool', content: result });
126
+ },
127
+ });
128
+ }
129
+ /**
130
+ * The dispatch (`Agent`/`Task` tool_use) id a stream envelope is tagged with, or `undefined` for a
131
+ * parent-loop turn.
132
+ *
133
+ * Claude Code streams the turns of the subagents it dispatches onto the parent's stdout, tagged
134
+ * with the tool_use id that spawned them. Those same turns are also written to the per-session
135
+ * `subagents/*.jsonl` transcripts the watcher reads, so recording both channels counted every
136
+ * subagent call twice — and splicing them into the parent's message reconstruction produced a
137
+ * `promptText` chain that interleaves several conversations and therefore matches no real request.
138
+ *
139
+ * The id is what makes the fallback below possible: concurrent subagents interleave on one stdout,
140
+ * so it is the ONLY thing separating their conversations.
141
+ */
142
+ export function subagentDispatchId(event) {
143
+ if (!isObject(event))
144
+ return undefined;
145
+ const id = event.parent_tool_use_id;
146
+ return typeof id === 'string' && id ? id : undefined;
147
+ }
148
+ /** Whether a stream envelope describes a SUBAGENT's turn rather than the parent loop's. */
149
+ export function isSubagentEvent(event) {
150
+ return subagentDispatchId(event) !== undefined;
151
+ }
152
+ /**
153
+ * Per-call telemetry for the subagents whose turns ride the parent's stdout — the FALLBACK channel,
154
+ * used only when no `subagents/*.jsonl` watcher will run (see `startSubagentWatcher`, which is
155
+ * wired only when the CLI has an isolated config home; an `ambientAuth` run has none).
156
+ *
157
+ * Without this, filtering tagged events out of the parent's telemetry leaves a subagent-heavy run
158
+ * with its spend recorded by NEITHER channel — an under-count, which reads as a cheap run and is
159
+ * the worse failure direction than the double-count the filter exists to fix.
160
+ *
161
+ * Each dispatch id gets its OWN transcript, because concurrent subagents interleave arbitrarily on
162
+ * one stream: folding them into a single chain is exactly the defect this whole module removes,
163
+ * one level down.
164
+ */
165
+ function createSubagentStreamTelemetry(opts) {
166
+ const perDispatch = new Map();
167
+ const forDispatch = (dispatchId) => {
168
+ let telemetry = perDispatch.get(dispatchId);
169
+ if (!telemetry) {
170
+ // Seeded EMPTY: the CLI minted this subagent's prompt and it never crossed the stream.
171
+ telemetry = createClaudeStreamTelemetry({
172
+ seed: [],
173
+ secrets: opts.secrets,
174
+ publish: opts.publish,
175
+ });
176
+ perDispatch.set(dispatchId, telemetry);
177
+ }
178
+ return telemetry;
179
+ };
180
+ return {
181
+ onAssistant: (dispatchId, message) => forDispatch(dispatchId).onAssistant(message),
182
+ // Only against a dispatch already seen: a result for a subagent whose assistant turns never
183
+ // reached us has no conversation to attach to, and minting one would publish a call that is
184
+ // all tool output and no request.
185
+ onToolResult: (dispatchId, content) => perDispatch.get(dispatchId)?.onToolResult(content),
186
+ flush: () => {
187
+ for (const telemetry of perDispatch.values())
188
+ telemetry.flush();
189
+ },
190
+ };
191
+ }
192
+ /**
193
+ * Assemble a run's per-call telemetry, routing each envelope to the conversation it belongs to.
194
+ *
195
+ * The routing is the whole point. A subagent's turns ride the parent's stdout tagged with the
196
+ * dispatch that spawned them, and they must never join the PARENT's chain — that splice produced a
197
+ * `promptText` interleaving several conversations, matching no request that was ever sent.
198
+ *
199
+ * Who RECORDS them is a separate question, decided once per run rather than per event:
200
+ * `watcherOwnsSubagents` says a `subagents/*.jsonl` watcher will run, and it is the better source
201
+ * (it reads the settled transcript, so its usage and stop reason are final). With no watcher — an
202
+ * `ambientAuth` run has no isolated config home to watch — the tagged turns are recorded here
203
+ * instead, on per-dispatch transcripts of their own. Dropping them in that case would leave the run
204
+ * billed by neither channel, and an under-count reads as a cheap run rather than as an error.
205
+ */
206
+ export function createClaudeRunTelemetry(opts) {
207
+ const parent = createClaudeStreamTelemetry(opts);
208
+ const subagents = opts.watcherOwnsSubagents ? undefined : createSubagentStreamTelemetry(opts);
209
+ let sawSubagentTurn = false;
210
+ return {
211
+ onAssistant(dispatchId, message) {
212
+ if (!dispatchId)
213
+ return parent.onAssistant(message);
214
+ sawSubagentTurn = true;
215
+ subagents?.onAssistant(dispatchId, message);
216
+ },
217
+ onToolResult(dispatchId, content) {
218
+ if (!dispatchId)
219
+ return parent.onToolResult(content);
220
+ sawSubagentTurn = true;
221
+ subagents?.onToolResult(dispatchId, content);
222
+ },
223
+ flush() {
224
+ parent.flush();
225
+ subagents?.flush();
226
+ },
227
+ expectsWatcherCalls: () => opts.watcherOwnsSubagents && sawSubagentTurn,
228
+ };
229
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.64.2",
3
+ "version": "1.64.4",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,7 +27,7 @@
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
29
  "@cat-factory/kernel": "0.171.0",
30
- "@cat-factory/server": "0.162.0",
30
+ "@cat-factory/server": "0.162.1",
31
31
  "@cat-factory/spend": "0.12.100"
32
32
  },
33
33
  "scripts": {
@@ -2,13 +2,8 @@ import { spawn } from 'node:child_process'
2
2
  import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { dirname, join } from 'node:path'
5
- import {
6
- claudeAssistantContent,
7
- claudeCallUsage,
8
- isObject,
9
- numberOf,
10
- redactBody,
11
- } from './claude-stream.js'
5
+ import { claudeAssistantContent, isObject, numberOf, redactBody } from './claude-stream.js'
6
+ import { createClaudeRunTelemetry, subagentDispatchId } from './claude-call-aggregator.js'
12
7
  import type { Logger } from './logger.js'
13
8
  import {
14
9
  createCallMetricPublisher,
@@ -359,31 +354,40 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
359
354
  }
360
355
 
361
356
  // Reconstruct the full per-call request/response bodies for telemetry from the
362
- // stream. `--output-format stream-json --verbose` emits each turn as a near-verbatim
363
- // Anthropic Messages envelope, so `assistant` events carry the complete response
364
- // (text + tool_use blocks + usage), and `user` events carry the tool_result blocks
365
- // fed back — together the growing prompt transcript. We seed it with the inputs the
366
- // harness supplies (they never appear in the stream): the system + first user message
367
- // when the prompt rides argv, or a single folded user turn when it doesn't so the
368
- // reconstruction never shows a system turn that was never sent. Bodies are
369
- // credential-scrubbed (they can echo the leased token).
357
+ // stream. `--output-format stream-json --verbose` emits a near-verbatim Anthropic
358
+ // Messages envelope per response CONTENT BLOCK (not per call), so the aggregator below
359
+ // folds the envelopes sharing a `message.id` back into one call and buffers that call's
360
+ // `user` tool_result turns — together the growing prompt transcript, in the shape the
361
+ // model was actually sent. We seed it with the inputs the harness supplies (they never
362
+ // appear in the stream): the system + first user message when the prompt rides argv, or
363
+ // a single folded user turn when it doesn't — so the reconstruction never shows a system
364
+ // turn that was never sent. Bodies are credential-scrubbed (they can echo the leased token).
370
365
  const secrets = opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : []
371
- const messages: Array<{ role: string; content: unknown }> = folded
372
- ? [{ role: 'user', content: prompt }]
373
- : [
374
- { role: 'system', content: opts.systemPrompt },
375
- { role: 'user', content: opts.userPrompt },
376
- ]
377
366
  const calls: HarnessCallMetric[] = []
378
367
  // Streams each call as the CLI yields it, EXCEPT one whose tokens `attributeCumulativeUsage`
379
368
  // may still rewrite below (a published call must be final — see the publisher).
380
369
  const publisher = createCallMetricPublisher(calls, opts.onCallMetric)
370
+ // `watcherOwnsSubagents` tracks the `startSubagentWatcher` wiring below: it is started only when
371
+ // the CLI has an isolated config home to watch, which an `ambientAuth` run does not have. The
372
+ // telemetry routes the CLI's tagged subagent turns accordingly — see `createClaudeRunTelemetry`.
373
+ const telemetry = createClaudeRunTelemetry({
374
+ seed: folded
375
+ ? [{ role: 'user', content: prompt }]
376
+ : [
377
+ { role: 'system', content: opts.systemPrompt },
378
+ { role: 'user', content: opts.userPrompt },
379
+ ],
380
+ secrets,
381
+ watcherOwnsSubagents: !opts.ambientAuth,
382
+ publish: (metric) => publisher.publish(metric),
383
+ })
381
384
 
382
385
  // ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from the two views the run
383
386
  // produces of the SAME slicing. The parent's subagent dispatches + their terminal tool_results
384
- // DO appear on this stream (only a subagent's intermediate turns don't), so `sliceTracker`
385
- // knows which slices are in flight and which have returned; the parent's own plan (tracked by
386
- // `planTracker` + `lastTodo`) is the only place a not-yet-dispatched slice is named at all.
387
+ // appear on this stream (as do the subagents' own intermediate turns, tagged with the dispatch
388
+ // that spawned them see `isSubagentEvent`), so `sliceTracker` knows which slices are in flight
389
+ // and which have returned; the parent's own plan (tracked by `planTracker` + `lastTodo`) is the
390
+ // only place a not-yet-dispatched slice is named at all.
387
391
  //
388
392
  // The plan arrives in one of two tool vocabularies depending on the bundled CLI build:
389
393
  // `TodoWrite` (whole-list snapshots, tracked in `lastTodo`) or the incremental
@@ -441,12 +445,19 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
441
445
 
442
446
  const onEvent = (event: Record<string, unknown>, meta?: { final?: boolean }): void => {
443
447
  const type = event.type
448
+ // A subagent's turns ride the parent's stdout tagged with the dispatch that spawned them;
449
+ // `telemetry` routes them off the parent's chain (and decides who bills them). Progress, slice
450
+ // tracking, the guard and `stats` below deliberately see EVERY event: a subagent grinding on
451
+ // errors should trip the guard exactly as the parent would, and whether the agent acted at all
452
+ // does not depend on which channel billed it.
453
+ const dispatchId = subagentDispatchId(event)
444
454
  if (type === 'assistant' && isObject(event.message)) {
445
455
  const message = event.message as Record<string, unknown>
446
456
  const content = Array.isArray(message.content) ? message.content : []
447
- const { text, reasoning, toolUses } = claudeAssistantContent(content)
457
+ const { text, toolUses } = claudeAssistantContent(content)
448
458
  stats.assistantChars += text.length
449
459
  stats.toolCalls += toolUses
460
+ telemetry.onAssistant(dispatchId, message)
450
461
  for (const block of content) {
451
462
  if (!isObject(block) || block.type !== 'tool_use') continue
452
463
  // Remember each call's name against its id so the guard can pair it with the
@@ -462,22 +473,6 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
462
473
  sliceTracker.onAssistant(content)
463
474
  planTracker.onAssistant(content)
464
475
  emitProgress()
465
- // Record this call BEFORE appending its turn: the prompt is the history that
466
- // produced this response. The append-only array keeps each call's prompt a strict
467
- // prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
468
- const u = claudeCallUsage(message.usage)
469
- publisher.publish({
470
- ...(typeof message.model === 'string' ? { model: message.model } : {}),
471
- promptText: redactBody(JSON.stringify(messages), secrets),
472
- messageCount: messages.length,
473
- responseText: redactBody(text, secrets),
474
- reasoningText: redactBody(reasoning, secrets),
475
- inputTokens: u.inputTokens,
476
- cachedInputTokens: u.cachedInputTokens,
477
- outputTokens: u.outputTokens,
478
- finishReason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
479
- })
480
- messages.push({ role: 'assistant', content })
481
476
  } else if (type === 'user' && isObject(event.message)) {
482
477
  // tool_result blocks the harness fed back to the model — part of the next prompt.
483
478
  const content = (event.message as Record<string, unknown>).content
@@ -488,7 +483,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
488
483
  // Not on the at-close flush: the CLI has already exited, so tripping the guard there
489
484
  // would kill nothing and only convert a clean exit into a spurious failure.
490
485
  if (!meta?.final) feedGuard(content)
491
- messages.push({ role: 'tool', content })
486
+ telemetry.onToolResult(dispatchId, content)
492
487
  }
493
488
  } else if (type === 'result') {
494
489
  if (typeof event.result === 'string') summary = event.result
@@ -587,6 +582,8 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
587
582
  onEvent,
588
583
  )
589
584
 
585
+ // The stream has ended, so the last call has no successor envelope to complete it.
586
+ telemetry.flush()
590
587
  return await assembleClaudeOutcome({
591
588
  summary,
592
589
  stats,
@@ -595,8 +592,16 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
595
592
  publisher,
596
593
  usage,
597
594
  subagents,
595
+ expectSubagentCalls: telemetry.expectsWatcherCalls(),
596
+ ...(opts.log ? { log: opts.log } : {}),
598
597
  })
599
598
  } catch (err) {
599
+ // The stream ended abnormally (guard trip, watchdog kill, CLI crash). Complete the call in
600
+ // flight anyway, and release whatever the publisher was withholding: a killed run never
601
+ // returns an outcome, so the live channel is the ONLY record of what it spent, and dropping
602
+ // its last turn is what the streaming exists to avoid.
603
+ telemetry.flush()
604
+ publisher.flush()
600
605
  // A tripped no-progress guard aborted the CLI; streamCli rejects with its generic abort
601
606
  // message, so replace it with the guard's actionable diagnostic — carrying the stderr tail
602
607
  // it attached, since that is usually the only evidence of what the CLI was doing when it was
@@ -655,6 +660,10 @@ function buildClaudeEnv(
655
660
  * terminal `result` event's cumulative) covers ONLY the parent loop, and the subagent tokens live
656
661
  * exclusively in the per-session `subagents/*.jsonl` transcripts the watcher reads. Extracted from
657
662
  * {@link runClaudeCode} verbatim to keep its cyclomatic complexity down.
663
+ *
664
+ * The invariant is about the aggregate `usage` only. `calls` was NEVER disjoint from the watcher's
665
+ * on its own: the CLI streams a subagent's turns onto the parent's stdout as well, so the parent
666
+ * loop's telemetry must filter them (`subagentDispatchId`) for this concatenation to hold.
658
667
  */
659
668
  async function assembleClaudeOutcome(args: {
660
669
  summary: string
@@ -665,6 +674,14 @@ async function assembleClaudeOutcome(args: {
665
674
  publisher: CallMetricPublisher
666
675
  usage: { inputTokens: number; outputTokens: number } | undefined
667
676
  subagents: ReturnType<typeof startSubagentWatcher> | undefined
677
+ /**
678
+ * The parent stream carried subagent turns AND the watcher was the channel meant to record them.
679
+ * A watcher that then yields nothing means the run lost its subagent rows entirely — the CLI's
680
+ * transcript layout is not a stable contract (ADR 0027 Defect A moved it once already), so say
681
+ * so rather than under-reporting the spend in silence.
682
+ */
683
+ expectSubagentCalls: boolean
684
+ log?: Logger
668
685
  }): Promise<PiRunOutcome> {
669
686
  const { summary, stats, stderrTail, calls, publisher, usage, subagents } = args
670
687
  // The parent's cumulative-usage fallback applies to the PARENT calls only (before the
@@ -679,6 +696,12 @@ async function assembleClaudeOutcome(args: {
679
696
  await subagents?.stop()
680
697
  const subUsage = subagents?.usage() ?? { inputTokens: 0, outputTokens: 0 }
681
698
  const subCalls = subagents?.calls() ?? []
699
+ if (args.expectSubagentCalls && !subCalls.length) {
700
+ args.log?.warn(
701
+ 'subagent turns were streamed but the transcript watcher captured no calls; their token ' +
702
+ 'spend is missing from this run’s telemetry (check the CLI’s subagents/*.jsonl layout)',
703
+ )
704
+ }
682
705
  const mergedCalls = [...calls, ...subCalls]
683
706
  const mergedUsage =
684
707
  usage || subUsage.inputTokens || subUsage.outputTokens
@@ -0,0 +1,327 @@
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
+ cachedInputTokens: number
26
+ outputTokens: number
27
+ /** The `user` turns carrying this call's tool_result blocks, in arrival order. */
28
+ toolResults: unknown[][]
29
+ /** tool_use blocks across the whole response (the run's `stats.toolCalls` term). */
30
+ toolUses: number
31
+ }
32
+
33
+ export interface ClaudeCallAggregator {
34
+ /**
35
+ * Fold one `assistant` envelope in. A new `message.id` completes the call in flight first, so
36
+ * `onCallStart` for the new call always runs after `onCall` for the previous one.
37
+ */
38
+ onAssistant(message: Record<string, unknown>): void
39
+ /** Buffer a `user` turn's content against the call in flight (dropped when none is). */
40
+ onToolResult(content: unknown[]): void
41
+ /** Complete the call still in flight, if any. Call once the stream has ended. */
42
+ flush(): void
43
+ }
44
+
45
+ interface Pending extends AggregatedClaudeCall {
46
+ id: string
47
+ }
48
+
49
+ /**
50
+ * Assemble per-call telemetry out of Claude Code's per-block stream envelopes.
51
+ *
52
+ * `onCallStart` fires when a call's FIRST envelope arrives, which is the moment the caller must
53
+ * snapshot the prompt: the history at that point is what produced the response. `onCall` fires
54
+ * once the call is complete (a different `message.id` began, or the stream ended).
55
+ *
56
+ * Usage is merged as the MAXIMUM of each bucket across the call's envelopes rather than the last
57
+ * one seen. The envelopes carry a snapshot of the same call's usage, and which of them holds the
58
+ * final output count is a CLI detail we should not depend on; a max is right whether the value is
59
+ * repeated verbatim or grows.
60
+ *
61
+ * An envelope with no `message.id` cannot be attributed, so it is treated as a call of its own —
62
+ * the pre-aggregation behaviour, kept so a CLI build (or a transcript) that omits the id degrades
63
+ * to over-counting rather than to silently merging unrelated calls.
64
+ */
65
+ export function createClaudeCallAggregator(handlers: {
66
+ onCallStart?: () => void
67
+ onCall: (call: AggregatedClaudeCall) => void
68
+ }): ClaudeCallAggregator {
69
+ let pending: Pending | undefined
70
+ let anonymous = 0
71
+
72
+ const complete = (): void => {
73
+ if (!pending) return
74
+ const { id: _id, ...call } = pending
75
+ pending = undefined
76
+ handlers.onCall(call)
77
+ }
78
+
79
+ return {
80
+ onAssistant(message) {
81
+ // `#anon-<n>` cannot collide with a real id (the API mints `msg_…`), so an envelope
82
+ // with no id keeps its own call rather than merging into whatever came before it.
83
+ const id = typeof message.id === 'string' && message.id ? message.id : `#anon-${anonymous++}`
84
+ if (pending && pending.id !== id) complete()
85
+ const content = Array.isArray(message.content) ? message.content : []
86
+ const { text, reasoning, toolUses } = claudeAssistantContent(content)
87
+ const usage = claudeCallUsage(message.usage)
88
+ const stopReason = typeof message.stop_reason === 'string' ? message.stop_reason : null
89
+ const model = typeof message.model === 'string' ? message.model : undefined
90
+ if (!pending) {
91
+ pending = {
92
+ id,
93
+ content: [],
94
+ text: '',
95
+ reasoning: '',
96
+ stopReason: null,
97
+ inputTokens: 0,
98
+ cachedInputTokens: 0,
99
+ outputTokens: 0,
100
+ toolResults: [],
101
+ toolUses: 0,
102
+ }
103
+ handlers.onCallStart?.()
104
+ }
105
+ pending.content.push(...content)
106
+ pending.text += text
107
+ pending.reasoning += reasoning
108
+ pending.toolUses += toolUses
109
+ pending.inputTokens = Math.max(pending.inputTokens, usage.inputTokens)
110
+ pending.cachedInputTokens = Math.max(pending.cachedInputTokens, usage.cachedInputTokens)
111
+ pending.outputTokens = Math.max(pending.outputTokens, usage.outputTokens)
112
+ // A block-split response reports its stop reason on the envelope that carries the end of the
113
+ // message; earlier ones report none. Keep the first non-null rather than the last seen.
114
+ if (stopReason && !pending.stopReason) pending.stopReason = stopReason
115
+ if (model && !pending.model) pending.model = model
116
+ },
117
+
118
+ onToolResult(content) {
119
+ // Results can only belong to the tool_use blocks of the call in flight. Before the first
120
+ // assistant envelope there is nothing they could attach to.
121
+ if (pending) pending.toolResults.push(content)
122
+ },
123
+
124
+ flush: complete,
125
+ }
126
+ }
127
+
128
+ /** One turn of the reconstructed request transcript, in the proxy's chat-array shape. */
129
+ interface TranscriptTurn {
130
+ role: string
131
+ content: unknown
132
+ }
133
+
134
+ /** The per-call telemetry the Claude Code stream yields, assembled behind one small surface. */
135
+ export interface ClaudeStreamTelemetry {
136
+ /** Fold an `assistant` envelope in (parent-loop turns only — see {@link isSubagentEvent}). */
137
+ onAssistant(message: Record<string, unknown>): void
138
+ /** Fold a `user` turn's tool_result content in, against the call in flight. */
139
+ onToolResult(content: unknown[]): void
140
+ /** Publish the call still in flight. Idempotent; safe to call on both the clean and error path. */
141
+ flush(): void
142
+ }
143
+
144
+ /**
145
+ * Assemble ONE conversation's per-call telemetry from the CLI stream: the growing request
146
+ * transcript and the per-call token/body metrics.
147
+ *
148
+ * Owns the transcript because the two are one concern — a call's `promptText` is the transcript as
149
+ * of that call, and its turns may only be appended once the call that produced them is complete.
150
+ * `seed` is what the harness supplied and the stream therefore never shows (the system + first user
151
+ * message, or the single folded user turn), so the reconstruction never claims a system turn that
152
+ * was not sent. A subagent's conversation seeds EMPTY — its prompt was minted by the CLI and never
153
+ * crosses this stream — which is also why its first call carries `messageCount: 0` (the backend's
154
+ * `latestChainTip` skips those on purpose: there is no re-sendable chain to delta against).
155
+ * Bodies are credential-scrubbed; they can echo the leased token.
156
+ *
157
+ * Deliberately does NOT touch {@link PiRunStats}: the run's tool/output counters describe whether
158
+ * the agent ACTED at all (`agentNeverActed`), which is true of a subagent's turns whichever channel
159
+ * ends up owning their telemetry rows. The caller accumulates them off the raw stream instead.
160
+ */
161
+ export function createClaudeStreamTelemetry(opts: {
162
+ seed: TranscriptTurn[]
163
+ secrets: string[]
164
+ publish: (metric: HarnessCallMetric) => void
165
+ }): ClaudeStreamTelemetry {
166
+ const messages: TranscriptTurn[] = [...opts.seed]
167
+ let callPrompt = ''
168
+ let callMessageCount = 0
169
+
170
+ // The aggregator IS the surface: the transcript and metric work happens in its callbacks, so
171
+ // there is nothing to wrap it in.
172
+ return createClaudeCallAggregator({
173
+ // Snapshotted when a call's FIRST envelope arrives: the history at that moment is what
174
+ // produced the response, and later envelopes of the same call must not see the turns it
175
+ // went on to add.
176
+ onCallStart: () => {
177
+ callPrompt = redactBody(JSON.stringify(messages), opts.secrets)
178
+ callMessageCount = messages.length
179
+ },
180
+ onCall: (call) => {
181
+ opts.publish({
182
+ ...(call.model ? { model: call.model } : {}),
183
+ promptText: callPrompt,
184
+ messageCount: callMessageCount,
185
+ responseText: redactBody(call.text, opts.secrets),
186
+ reasoningText: redactBody(call.reasoning, opts.secrets),
187
+ inputTokens: call.inputTokens,
188
+ cachedInputTokens: call.cachedInputTokens,
189
+ outputTokens: call.outputTokens,
190
+ finishReason: call.stopReason,
191
+ })
192
+ // Appended only now, so each call's prompt stays a strict prefix of the next and the
193
+ // backend's telemetry chain delta-compresses cleanly.
194
+ messages.push({ role: 'assistant', content: call.content })
195
+ for (const result of call.toolResults) messages.push({ role: 'tool', content: result })
196
+ },
197
+ })
198
+ }
199
+
200
+ /**
201
+ * The dispatch (`Agent`/`Task` tool_use) id a stream envelope is tagged with, or `undefined` for a
202
+ * parent-loop turn.
203
+ *
204
+ * Claude Code streams the turns of the subagents it dispatches onto the parent's stdout, tagged
205
+ * with the tool_use id that spawned them. Those same turns are also written to the per-session
206
+ * `subagents/*.jsonl` transcripts the watcher reads, so recording both channels counted every
207
+ * subagent call twice — and splicing them into the parent's message reconstruction produced a
208
+ * `promptText` chain that interleaves several conversations and therefore matches no real request.
209
+ *
210
+ * The id is what makes the fallback below possible: concurrent subagents interleave on one stdout,
211
+ * so it is the ONLY thing separating their conversations.
212
+ */
213
+ export function subagentDispatchId(event: Record<string, unknown>): string | undefined {
214
+ if (!isObject(event)) return undefined
215
+ const id = event.parent_tool_use_id
216
+ return typeof id === 'string' && id ? id : undefined
217
+ }
218
+
219
+ /** Whether a stream envelope describes a SUBAGENT's turn rather than the parent loop's. */
220
+ export function isSubagentEvent(event: Record<string, unknown>): boolean {
221
+ return subagentDispatchId(event) !== undefined
222
+ }
223
+
224
+ /**
225
+ * Per-call telemetry for the subagents whose turns ride the parent's stdout — the FALLBACK channel,
226
+ * used only when no `subagents/*.jsonl` watcher will run (see `startSubagentWatcher`, which is
227
+ * wired only when the CLI has an isolated config home; an `ambientAuth` run has none).
228
+ *
229
+ * Without this, filtering tagged events out of the parent's telemetry leaves a subagent-heavy run
230
+ * with its spend recorded by NEITHER channel — an under-count, which reads as a cheap run and is
231
+ * the worse failure direction than the double-count the filter exists to fix.
232
+ *
233
+ * Each dispatch id gets its OWN transcript, because concurrent subagents interleave arbitrarily on
234
+ * one stream: folding them into a single chain is exactly the defect this whole module removes,
235
+ * one level down.
236
+ */
237
+ function createSubagentStreamTelemetry(opts: {
238
+ secrets: string[]
239
+ publish: (metric: HarnessCallMetric) => void
240
+ }): {
241
+ onAssistant(dispatchId: string, message: Record<string, unknown>): void
242
+ onToolResult(dispatchId: string, content: unknown[]): void
243
+ flush(): void
244
+ } {
245
+ const perDispatch = new Map<string, ClaudeStreamTelemetry>()
246
+ const forDispatch = (dispatchId: string): ClaudeStreamTelemetry => {
247
+ let telemetry = perDispatch.get(dispatchId)
248
+ if (!telemetry) {
249
+ // Seeded EMPTY: the CLI minted this subagent's prompt and it never crossed the stream.
250
+ telemetry = createClaudeStreamTelemetry({
251
+ seed: [],
252
+ secrets: opts.secrets,
253
+ publish: opts.publish,
254
+ })
255
+ perDispatch.set(dispatchId, telemetry)
256
+ }
257
+ return telemetry
258
+ }
259
+ return {
260
+ onAssistant: (dispatchId, message) => forDispatch(dispatchId).onAssistant(message),
261
+ // Only against a dispatch already seen: a result for a subagent whose assistant turns never
262
+ // reached us has no conversation to attach to, and minting one would publish a call that is
263
+ // all tool output and no request.
264
+ onToolResult: (dispatchId, content) => perDispatch.get(dispatchId)?.onToolResult(content),
265
+ flush: () => {
266
+ for (const telemetry of perDispatch.values()) telemetry.flush()
267
+ },
268
+ }
269
+ }
270
+
271
+ /** All per-call telemetry for ONE claude-code run: the parent loop, and whoever bills the subagents. */
272
+ export interface ClaudeRunTelemetry {
273
+ /** Fold an `assistant` envelope in, routed by its dispatch tag (`undefined` ⇒ the parent loop). */
274
+ onAssistant(dispatchId: string | undefined, message: Record<string, unknown>): void
275
+ /** Fold a `user` turn's tool_result content in, against the same conversation. */
276
+ onToolResult(dispatchId: string | undefined, content: unknown[]): void
277
+ /** Publish every conversation's call in flight. Idempotent; safe on the clean and error paths. */
278
+ flush(): void
279
+ /**
280
+ * Subagent turns crossed the stream AND the watcher was the channel meant to record them — so a
281
+ * watcher that captured nothing means this run's subagent rows are simply missing.
282
+ */
283
+ expectsWatcherCalls(): boolean
284
+ }
285
+
286
+ /**
287
+ * Assemble a run's per-call telemetry, routing each envelope to the conversation it belongs to.
288
+ *
289
+ * The routing is the whole point. A subagent's turns ride the parent's stdout tagged with the
290
+ * dispatch that spawned them, and they must never join the PARENT's chain — that splice produced a
291
+ * `promptText` interleaving several conversations, matching no request that was ever sent.
292
+ *
293
+ * Who RECORDS them is a separate question, decided once per run rather than per event:
294
+ * `watcherOwnsSubagents` says a `subagents/*.jsonl` watcher will run, and it is the better source
295
+ * (it reads the settled transcript, so its usage and stop reason are final). With no watcher — an
296
+ * `ambientAuth` run has no isolated config home to watch — the tagged turns are recorded here
297
+ * instead, on per-dispatch transcripts of their own. Dropping them in that case would leave the run
298
+ * billed by neither channel, and an under-count reads as a cheap run rather than as an error.
299
+ */
300
+ export function createClaudeRunTelemetry(opts: {
301
+ seed: TranscriptTurn[]
302
+ secrets: string[]
303
+ watcherOwnsSubagents: boolean
304
+ publish: (metric: HarnessCallMetric) => void
305
+ }): ClaudeRunTelemetry {
306
+ const parent = createClaudeStreamTelemetry(opts)
307
+ const subagents = opts.watcherOwnsSubagents ? undefined : createSubagentStreamTelemetry(opts)
308
+ let sawSubagentTurn = false
309
+
310
+ return {
311
+ onAssistant(dispatchId, message) {
312
+ if (!dispatchId) return parent.onAssistant(message)
313
+ sawSubagentTurn = true
314
+ subagents?.onAssistant(dispatchId, message)
315
+ },
316
+ onToolResult(dispatchId, content) {
317
+ if (!dispatchId) return parent.onToolResult(content)
318
+ sawSubagentTurn = true
319
+ subagents?.onToolResult(dispatchId, content)
320
+ },
321
+ flush() {
322
+ parent.flush()
323
+ subagents?.flush()
324
+ },
325
+ expectsWatcherCalls: () => opts.watcherOwnsSubagents && sawSubagentTurn,
326
+ }
327
+ }
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)