@cat-factory/executor-harness 1.50.10 → 1.50.14

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.
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path';
5
5
  import { claudeAssistantContent, claudeCallUsage, isObject, numberOf, redactBody, } from './claude-stream.js';
6
6
  import { killChildProcess, spawnDetached } from './process.js';
7
7
  import { redact, secretsToRedact } from './redact.js';
8
- import { createSliceTracker, startSubagentWatcher } from './subagents.js';
8
+ import { createSliceTracker, pickProgress, startSubagentWatcher } from './subagents.js';
9
9
  import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js';
10
10
  import { retainSessionTranscripts } from './transcript-retention.js';
11
11
  /**
@@ -219,17 +219,19 @@ export async function runClaudeCode(opts) {
219
219
  { role: 'user', content: opts.userPrompt },
220
220
  ];
221
221
  const calls = [];
222
- // ADR 0026 D2.1: derive slice progress from the parent stream's `Task` dispatches +
223
- // their terminal tool_results (both DO appear here — only a subagent's intermediate
224
- // turns don't). A real parent TodoWrite plan, when the agent writes one, wins; the
225
- // slice-derived progress is the fallback for the parallel-subagent shape that writes no
226
- // parent plan (the pr-reviewer failure this fixes).
222
+ // ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from TWO reconciled
223
+ // sources. The parent's `Task` dispatches + their terminal tool_results DO appear on this
224
+ // stream (only a subagent's intermediate turns don't), so `sliceTracker` derives per-slice
225
+ // progress for the parallel-subagent shape; a parent `TodoWrite` plan (the sequential
226
+ // shape) is tracked in `lastTodo`. `pickProgress` picks whichever is further along on each
227
+ // update, so neither masks the other — the pr-reviewer prompt writes its todo plan ONCE
228
+ // and never marks it done, which used to gate the slice signal off and pin progress at 0%.
227
229
  const sliceTracker = createSliceTracker();
228
- let sawTodoPlan = false;
229
- const emitSliceProgress = () => {
230
- if (sawTodoPlan || !opts.onProgress)
230
+ let lastTodo;
231
+ const emitProgress = () => {
232
+ if (!opts.onProgress)
231
233
  return;
232
- const progress = sliceTracker.progress();
234
+ const progress = pickProgress(lastTodo, sliceTracker.progress());
233
235
  if (progress)
234
236
  opts.onProgress(progress);
235
237
  };
@@ -242,19 +244,14 @@ export async function runClaudeCode(opts) {
242
244
  stats.assistantChars += text.length;
243
245
  stats.toolCalls += toolUses;
244
246
  for (const block of content) {
245
- if (isObject(block) &&
246
- block.type === 'tool_use' &&
247
- block.name === 'TodoWrite' &&
248
- opts.onProgress) {
247
+ if (isObject(block) && block.type === 'tool_use' && block.name === 'TodoWrite') {
249
248
  const progress = todosToProgress(block.input?.todos);
250
- if (progress) {
251
- sawTodoPlan = true;
252
- opts.onProgress(progress);
253
- }
249
+ if (progress)
250
+ lastTodo = progress;
254
251
  }
255
252
  }
256
253
  sliceTracker.onAssistant(content);
257
- emitSliceProgress();
254
+ emitProgress();
258
255
  // Record this call BEFORE appending its turn: the prompt is the history that
259
256
  // produced this response. The append-only array keeps each call's prompt a strict
260
257
  // prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
@@ -277,7 +274,7 @@ export async function runClaudeCode(opts) {
277
274
  const content = event.message.content;
278
275
  if (Array.isArray(content)) {
279
276
  sliceTracker.onUser(content);
280
- emitSliceProgress();
277
+ emitProgress();
281
278
  messages.push({ role: 'tool', content });
282
279
  }
283
280
  }
@@ -337,13 +334,16 @@ export async function runClaudeCode(opts) {
337
334
  }
338
335
  : { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken }),
339
336
  };
340
- // ADR 0026 D2.1/D3: while the run is live, tail the CLI's `subagents/*.jsonl`
341
- // transcripts (under the isolated config home) so a parallel-subagent review keeps the
342
- // inactivity heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible
343
- // token spend is lifted into the run's telemetry. Ambient mode has no isolated home to
344
- // watch. Best-effort a missing/renamed transcript layout just yields no extra signal.
337
+ // ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
338
+ // subagent `*.jsonl` transcripts so a parallel-subagent review keeps the inactivity
339
+ // heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible token spend is
340
+ // lifted into the run's telemetry. The CLI writes them per-session under
341
+ // `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/*.jsonl`, so we watch the
342
+ // `projects` tree and let the watcher discover the `subagents/` dir (the session uuid isn't
343
+ // known up front). Ambient mode has no isolated home to watch. Best-effort — a
344
+ // missing/renamed transcript layout just yields no extra signal.
345
345
  const subagents = configHome
346
- ? startSubagentWatcher(join(configHome, 'subagents'), {
346
+ ? startSubagentWatcher(join(configHome, 'projects'), {
347
347
  ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
348
348
  secrets,
349
349
  model: opts.model,
@@ -384,9 +384,10 @@ export async function runClaudeCode(opts) {
384
384
  // is the terminal `result` event's cumulative, which covers ONLY the parent loop — the
385
385
  // ADR 0026 incident is itself the proof: a heavily subagent-parallelised review reported
386
386
  // ~0 tokens, i.e. the parent stream (and its `result` total) never included the subagent
387
- // spend. The subagent tokens live exclusively in the `subagents/*.jsonl` transcripts (a
388
- // directory distinct from the parent's `projects/` session transcript), which the watcher
389
- // reads and nothing else does so neither `calls` nor `usage` can already contain them.
387
+ // spend. The subagent tokens live exclusively in the per-session `subagents/*.jsonl`
388
+ // transcripts, which the watcher reads and nothing else does; it deliberately EXCLUDES the
389
+ // sibling parent session transcript (whose usage `result` already totals), so neither
390
+ // `calls` nor `usage` can already contain the subagent spend.
390
391
  const mergedUsage = usage || subUsage.inputTokens || subUsage.outputTokens
391
392
  ? {
392
393
  inputTokens: (usage?.inputTokens ?? 0) + subUsage.inputTokens,
package/dist/job.js CHANGED
@@ -647,45 +647,82 @@ export function parseAgentJob(input) {
647
647
  // preview dispatch to send dummy values it has no reason to supply. Every other mode still
648
648
  // requires them (throws when missing/empty), exactly as before.
649
649
  const agentField = (value, path) => mode === 'preview' ? (typeof value === 'string' ? value : '') : str(value, path);
650
+ // Parse each field, then hand the pieces to `assembleAgentJob` for the (large) object literal —
651
+ // the parse/assemble split keeps both within the cyclomatic-complexity budget. Behaviour is
652
+ // byte-identical (the literal + host validation moved verbatim).
653
+ const job = assembleAgentJob(o, mode, agentField, {
654
+ output: parseAgentOutputSpec(o.output),
655
+ pr: parseAgentPrSpec(o.pr),
656
+ infra: parseAgentInfraSpec(o.infra),
657
+ peerRepos: parsePeerRepos(o.peerRepos),
658
+ referenceRepos: parseReferenceRepos(o.referenceRepos),
659
+ referenceBranches: parseReferenceBranches(o.referenceBranches),
660
+ bootstrap: parseAgentBootstrapSpec(o.bootstrap),
661
+ contextFiles: parseContextFiles(o.contextFiles),
662
+ packageRegistries: parsePackageRegistries(o.packageRegistries),
663
+ skill: parseSkillSpec(o.skill),
664
+ testSecrets: parseTestSecrets(o.testSecrets),
665
+ guardLimits: parseGuardLimits(o.guardLimits),
666
+ validation: parseValidationSpec(o.validation),
667
+ reviewPrNumber: posInt(o.reviewPrNumber),
668
+ });
669
+ assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl');
670
+ if (job.githubApiBase)
671
+ assertAllowedHost(job.githubApiBase, 'githubApiBase');
672
+ // Bootstrap pushes the result to a SEPARATE target repo, so its clone URL must be an
673
+ // allowed GitHub host too (the installation token is sent to it on the force-push).
674
+ if (job.bootstrap)
675
+ assertAllowedHost(job.bootstrap.target.cloneUrl, 'bootstrap.target.cloneUrl');
676
+ // Each peer repo's clone URL receives the installation token on clone/push, so it must be
677
+ // an allowed GitHub host too — a body-supplied peer pointing at an attacker host would
678
+ // exfiltrate the token exactly like a rogue primary clone URL.
679
+ for (const [i, peer] of (job.peerRepos ?? []).entries()) {
680
+ assertAllowedHost(peer.repo.cloneUrl, `peerRepos[${i}].repo.cloneUrl`);
681
+ }
682
+ // Each reference repo's clone URL receives the installation/PAT token on clone (read-only,
683
+ // never pushed), so it must be an allowed host too — a body-supplied reference pointing at an
684
+ // attacker host would exfiltrate the token exactly like a rogue peer clone URL.
685
+ for (const [i, ref] of (job.referenceRepos ?? []).entries()) {
686
+ assertAllowedHost(ref.repo.cloneUrl, `referenceRepos[${i}].repo.cloneUrl`);
687
+ }
688
+ return job;
689
+ }
690
+ /** Parse the optional structured-output spec (`{ kind, shapeHint?, repair?, failOnUnusableFinal? }`). */
691
+ function parseAgentOutputSpec(raw) {
692
+ if (typeof raw !== 'object' || raw === null)
693
+ return undefined;
694
+ const so = raw;
695
+ const kind = so.kind === 'structured' ? 'structured' : 'prose';
696
+ const spec = { kind };
697
+ if (typeof so.shapeHint === 'string')
698
+ spec.shapeHint = so.shapeHint;
699
+ // Carry an explicit `repair: false` through — the handler defaults to repair-on
700
+ // when absent, so dropping `false` would silently re-enable the repair call for a
701
+ // kind that opted out (it keys off `output.repair === false`).
702
+ if (typeof so.repair === 'boolean')
703
+ spec.repair = so.repair;
704
+ // Carry the opt-in truncation gate through (document producers set it); dropping
705
+ // it would silently re-enable laundering a cut-off reply into a half-baked doc.
706
+ if (so.failOnUnusableFinal === true)
707
+ spec.failOnUnusableFinal = true;
708
+ return spec;
709
+ }
710
+ /** Parse the optional PR spec (`{ title, body }`). */
711
+ function parseAgentPrSpec(raw) {
712
+ if (typeof raw !== 'object' || raw === null)
713
+ return undefined;
714
+ const p = raw;
715
+ return { title: str(p.title, 'pr.title'), body: typeof p.body === 'string' ? p.body : '' };
716
+ }
717
+ /**
718
+ * Assemble the {@link AgentJob} object from the request `o` + the pre-parsed {@link
719
+ * ParsedAgentJobParts}. Extracted from {@link parseAgentJob} so the large conditional-spread
720
+ * literal doesn't blow the complexity budget; behaviour is byte-identical (spread order preserved).
721
+ */
722
+ function assembleAgentJob(o, mode, agentField, parts) {
723
+ const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skill, testSecrets, guardLimits, validation, reviewPrNumber, } = parts;
650
724
  const repo = (o.repo ?? {});
651
- const output = typeof o.output === 'object' && o.output !== null
652
- ? (() => {
653
- const so = o.output;
654
- const kind = so.kind === 'structured' ? 'structured' : 'prose';
655
- const spec = { kind };
656
- if (typeof so.shapeHint === 'string')
657
- spec.shapeHint = so.shapeHint;
658
- // Carry an explicit `repair: false` through — the handler defaults to repair-on
659
- // when absent, so dropping `false` would silently re-enable the repair call for a
660
- // kind that opted out (it keys off `output.repair === false`).
661
- if (typeof so.repair === 'boolean')
662
- spec.repair = so.repair;
663
- // Carry the opt-in truncation gate through (document producers set it); dropping
664
- // it would silently re-enable laundering a cut-off reply into a half-baked doc.
665
- if (so.failOnUnusableFinal === true)
666
- spec.failOnUnusableFinal = true;
667
- return spec;
668
- })()
669
- : undefined;
670
- const pr = typeof o.pr === 'object' && o.pr !== null
671
- ? (() => {
672
- const p = o.pr;
673
- return { title: str(p.title, 'pr.title'), body: typeof p.body === 'string' ? p.body : '' };
674
- })()
675
- : undefined;
676
- const infra = parseAgentInfraSpec(o.infra);
677
- const peerRepos = parsePeerRepos(o.peerRepos);
678
- const referenceRepos = parseReferenceRepos(o.referenceRepos);
679
- const referenceBranches = parseReferenceBranches(o.referenceBranches);
680
- const bootstrap = parseAgentBootstrapSpec(o.bootstrap);
681
- const contextFiles = parseContextFiles(o.contextFiles);
682
- const packageRegistries = parsePackageRegistries(o.packageRegistries);
683
- const skill = parseSkillSpec(o.skill);
684
- const testSecrets = parseTestSecrets(o.testSecrets);
685
- const guardLimits = parseGuardLimits(o.guardLimits);
686
- const validation = parseValidationSpec(o.validation);
687
- const reviewPrNumber = posInt(o.reviewPrNumber);
688
- const job = {
725
+ return {
689
726
  jobId: str(o.jobId, 'jobId'),
690
727
  mode,
691
728
  systemPrompt: agentField(o.systemPrompt, 'systemPrompt'),
@@ -723,24 +760,4 @@ export function parseAgentJob(input) {
723
760
  ...(guardLimits ? { guardLimits } : {}),
724
761
  ...(validation ? { validation } : {}),
725
762
  };
726
- assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl');
727
- if (job.githubApiBase)
728
- assertAllowedHost(job.githubApiBase, 'githubApiBase');
729
- // Bootstrap pushes the result to a SEPARATE target repo, so its clone URL must be an
730
- // allowed GitHub host too (the installation token is sent to it on the force-push).
731
- if (job.bootstrap)
732
- assertAllowedHost(job.bootstrap.target.cloneUrl, 'bootstrap.target.cloneUrl');
733
- // Each peer repo's clone URL receives the installation token on clone/push, so it must be
734
- // an allowed GitHub host too — a body-supplied peer pointing at an attacker host would
735
- // exfiltrate the token exactly like a rogue primary clone URL.
736
- for (const [i, peer] of (job.peerRepos ?? []).entries()) {
737
- assertAllowedHost(peer.repo.cloneUrl, `peerRepos[${i}].repo.cloneUrl`);
738
- }
739
- // Each reference repo's clone URL receives the installation/PAT token on clone (read-only,
740
- // never pushed), so it must be an allowed host too — a body-supplied reference pointing at an
741
- // attacker host would exfiltrate the token exactly like a rogue peer clone URL.
742
- for (const [i, ref] of (job.referenceRepos ?? []).entries()) {
743
- assertAllowedHost(ref.repo.cloneUrl, `referenceRepos[${i}].repo.cloneUrl`);
744
- }
745
- return job;
746
763
  }
package/dist/subagents.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readdir, stat } from 'node:fs/promises';
2
2
  import { createReadStream } from 'node:fs';
3
- import { join } from 'node:path';
3
+ import { basename, join } from 'node:path';
4
4
  import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js';
5
5
  export function createSliceTracker() {
6
6
  // Insertion-ordered so the progress `items` render in dispatch order.
@@ -54,21 +54,84 @@ export function createSliceTracker() {
54
54
  },
55
55
  };
56
56
  }
57
+ /**
58
+ * Reconcile the two redundant views of the same slice work into the one to surface
59
+ * (ADR 0027 Defect B). A pr-reviewer run has BOTH a parent `TodoWrite` plan (the slices,
60
+ * written once at grouping time) and the {@link SliceTracker}'s `Task`-dispatch view. The
61
+ * sequential shape advances the todo plan; the parallel-subagent shape advances ONLY the
62
+ * slice tracker (the CLI writes the plan once and never marks it done, while the parallel
63
+ * `Task`s report in-flight/complete). Neither alone covers both shapes, and gating the
64
+ * slice tracker OFF whenever a todo plan exists (the old behaviour) pinned parallel runs
65
+ * at 0%. So prefer whichever view is further along: more `completed`, then more
66
+ * `inProgress` (an all-pending todo plan must not beat live in-flight slices), then more
67
+ * `total` (the richer view — the todo plan can carry an extra "aggregate" entry), else the
68
+ * todo plan. Pure + total; returns whichever single input is present when only one is.
69
+ */
70
+ export function pickProgress(todo, slice) {
71
+ if (!todo)
72
+ return slice;
73
+ if (!slice)
74
+ return todo;
75
+ if (slice.completed !== todo.completed)
76
+ return slice.completed > todo.completed ? slice : todo;
77
+ if (slice.inProgress !== todo.inProgress)
78
+ return slice.inProgress > todo.inProgress ? slice : todo;
79
+ if (slice.total !== todo.total)
80
+ return slice.total > todo.total ? slice : todo;
81
+ return todo;
82
+ }
57
83
  // ---------------------------------------------------------------------------
58
84
  // Subagent transcript watcher (heartbeat + usage) (D3)
59
85
  // ---------------------------------------------------------------------------
60
86
  /** Default poll cadence for the transcript directory; well under the git timeout margin. */
61
87
  const DEFAULT_POLL_MS = 3_000;
62
88
  /**
63
- * Start watching `dir` (the CLI's `<configHome>/subagents`) for `*.jsonl` transcripts,
64
- * tailing each file by byte offset. New content feeds `onActivity` (heartbeat) and each
65
- * assistant turn carrying usage is lifted into a {@link HarnessCallMetric} + summed into
66
- * the cumulative usage. Best-effort throughout: the directory may not exist yet (created
67
- * lazily by the CLI), a file may be mid-write, and the line/usage shape may change across
68
- * CLI versions every such case is swallowed so the watcher can only ever ADD signal,
69
- * never break the run.
89
+ * Recursively collect every `*.jsonl` file that lives inside a `subagents/` directory
90
+ * anywhere under `root` (the CLI's `<configHome>/projects` tree). The Claude CLI writes
91
+ * each parallel `Task` subagent's transcript to
92
+ * `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/agent-*.jsonl`; the
93
+ * session-uuid dir isn't known before the CLI mints it, so we DISCOVER the `subagents/`
94
+ * dir by walking rather than guessing its path (ADR 0027 Defect A). Files NOT under a
95
+ * `subagents/` dir — critically the parent's own `<session-uuid>.jsonl` session transcript,
96
+ * whose per-turn usage the terminal `result` event already totals — are deliberately
97
+ * excluded: reading them would double-count the parent. `root` itself counts as inside a
98
+ * `subagents/` dir when its own basename is `subagents` (so passing the leaf dir works too).
99
+ * Best-effort: an unreadable directory is skipped, never thrown.
100
+ */
101
+ async function findSubagentTranscripts(root) {
102
+ const out = [];
103
+ const walk = async (dir, inSubagents) => {
104
+ let entries;
105
+ try {
106
+ entries = await readdir(dir, { withFileTypes: true });
107
+ }
108
+ catch {
109
+ return; // dir not created yet (or vanished) — try again next tick
110
+ }
111
+ for (const entry of entries) {
112
+ const full = join(dir, entry.name);
113
+ if (entry.isDirectory()) {
114
+ await walk(full, inSubagents || entry.name === 'subagents');
115
+ }
116
+ else if (inSubagents && entry.isFile() && entry.name.endsWith('.jsonl')) {
117
+ out.push(full);
118
+ }
119
+ }
120
+ };
121
+ await walk(root, basename(root) === 'subagents');
122
+ return out;
123
+ }
124
+ /**
125
+ * Start watching `root` (the CLI's `<configHome>/projects` tree) for subagent `*.jsonl`
126
+ * transcripts — any file under a `subagents/` directory beneath it (see
127
+ * {@link findSubagentTranscripts}) — tailing each file by byte offset. New content feeds
128
+ * `onActivity` (heartbeat) and each assistant turn carrying usage is lifted into a
129
+ * {@link HarnessCallMetric} + summed into the cumulative usage. Best-effort throughout: the
130
+ * tree may not exist yet (created lazily by the CLI), a file may be mid-write, and the
131
+ * line/usage shape may change across CLI versions — every such case is swallowed so the
132
+ * watcher can only ever ADD signal, never break the run.
70
133
  */
71
- export function startSubagentWatcher(dir, opts) {
134
+ export function startSubagentWatcher(root, opts) {
72
135
  const secrets = opts.secrets ?? [];
73
136
  const offsets = new Map();
74
137
  const calls = [];
@@ -151,16 +214,8 @@ export function startSubagentWatcher(dir, opts) {
151
214
  return;
152
215
  polling = true;
153
216
  try {
154
- let entries;
155
- try {
156
- entries = (await readdir(dir)).filter((n) => n.endsWith('.jsonl'));
157
- }
158
- catch {
159
- return; // dir not created yet (or vanished) — try again next tick
160
- }
161
217
  let grew = false;
162
- for (const name of entries) {
163
- const path = join(dir, name);
218
+ for (const path of await findSubagentTranscripts(root)) {
164
219
  let size;
165
220
  try {
166
221
  size = (await stat(path)).size;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.50.10",
3
+ "version": "1.50.14",
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",
@@ -26,8 +26,8 @@
26
26
  "hono": "^4.12.30",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/server": "0.140.4",
30
- "@cat-factory/spend": "0.12.69"
29
+ "@cat-factory/server": "0.141.3",
30
+ "@cat-factory/spend": "0.12.73"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
@@ -13,7 +13,7 @@ import type { Logger } from './logger.js'
13
13
  import type { HarnessCallMetric, PiRunOutcome, PiRunStats, TodoProgress } from './pi.js'
14
14
  import { killChildProcess, spawnDetached } from './process.js'
15
15
  import { redact, secretsToRedact } from './redact.js'
16
- import { createSliceTracker, startSubagentWatcher } from './subagents.js'
16
+ import { createSliceTracker, pickProgress, startSubagentWatcher } from './subagents.js'
17
17
  import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js'
18
18
  import { retainSessionTranscripts } from './transcript-retention.js'
19
19
 
@@ -325,16 +325,18 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
325
325
  ]
326
326
  const calls: HarnessCallMetric[] = []
327
327
 
328
- // ADR 0026 D2.1: derive slice progress from the parent stream's `Task` dispatches +
329
- // their terminal tool_results (both DO appear here — only a subagent's intermediate
330
- // turns don't). A real parent TodoWrite plan, when the agent writes one, wins; the
331
- // slice-derived progress is the fallback for the parallel-subagent shape that writes no
332
- // parent plan (the pr-reviewer failure this fixes).
328
+ // ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from TWO reconciled
329
+ // sources. The parent's `Task` dispatches + their terminal tool_results DO appear on this
330
+ // stream (only a subagent's intermediate turns don't), so `sliceTracker` derives per-slice
331
+ // progress for the parallel-subagent shape; a parent `TodoWrite` plan (the sequential
332
+ // shape) is tracked in `lastTodo`. `pickProgress` picks whichever is further along on each
333
+ // update, so neither masks the other — the pr-reviewer prompt writes its todo plan ONCE
334
+ // and never marks it done, which used to gate the slice signal off and pin progress at 0%.
333
335
  const sliceTracker = createSliceTracker()
334
- let sawTodoPlan = false
335
- const emitSliceProgress = (): void => {
336
- if (sawTodoPlan || !opts.onProgress) return
337
- const progress = sliceTracker.progress()
336
+ let lastTodo: TodoProgress | undefined
337
+ const emitProgress = (): void => {
338
+ if (!opts.onProgress) return
339
+ const progress = pickProgress(lastTodo, sliceTracker.progress())
338
340
  if (progress) opts.onProgress(progress)
339
341
  }
340
342
 
@@ -347,21 +349,13 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
347
349
  stats.assistantChars += text.length
348
350
  stats.toolCalls += toolUses
349
351
  for (const block of content) {
350
- if (
351
- isObject(block) &&
352
- block.type === 'tool_use' &&
353
- block.name === 'TodoWrite' &&
354
- opts.onProgress
355
- ) {
352
+ if (isObject(block) && block.type === 'tool_use' && block.name === 'TodoWrite') {
356
353
  const progress = todosToProgress((block.input as Record<string, unknown>)?.todos)
357
- if (progress) {
358
- sawTodoPlan = true
359
- opts.onProgress(progress)
360
- }
354
+ if (progress) lastTodo = progress
361
355
  }
362
356
  }
363
357
  sliceTracker.onAssistant(content)
364
- emitSliceProgress()
358
+ emitProgress()
365
359
  // Record this call BEFORE appending its turn: the prompt is the history that
366
360
  // produced this response. The append-only array keeps each call's prompt a strict
367
361
  // prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
@@ -383,7 +377,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
383
377
  const content = (event.message as Record<string, unknown>).content
384
378
  if (Array.isArray(content)) {
385
379
  sliceTracker.onUser(content)
386
- emitSliceProgress()
380
+ emitProgress()
387
381
  messages.push({ role: 'tool', content })
388
382
  }
389
383
  } else if (type === 'result') {
@@ -446,13 +440,16 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
446
440
  : { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken! }),
447
441
  }
448
442
 
449
- // ADR 0026 D2.1/D3: while the run is live, tail the CLI's `subagents/*.jsonl`
450
- // transcripts (under the isolated config home) so a parallel-subagent review keeps the
451
- // inactivity heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible
452
- // token spend is lifted into the run's telemetry. Ambient mode has no isolated home to
453
- // watch. Best-effort a missing/renamed transcript layout just yields no extra signal.
443
+ // ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
444
+ // subagent `*.jsonl` transcripts so a parallel-subagent review keeps the inactivity
445
+ // heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible token spend is
446
+ // lifted into the run's telemetry. The CLI writes them per-session under
447
+ // `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/*.jsonl`, so we watch the
448
+ // `projects` tree and let the watcher discover the `subagents/` dir (the session uuid isn't
449
+ // known up front). Ambient mode has no isolated home to watch. Best-effort — a
450
+ // missing/renamed transcript layout just yields no extra signal.
454
451
  const subagents = configHome
455
- ? startSubagentWatcher(join(configHome, 'subagents'), {
452
+ ? startSubagentWatcher(join(configHome, 'projects'), {
456
453
  ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
457
454
  secrets,
458
455
  model: opts.model,
@@ -502,9 +499,10 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
502
499
  // is the terminal `result` event's cumulative, which covers ONLY the parent loop — the
503
500
  // ADR 0026 incident is itself the proof: a heavily subagent-parallelised review reported
504
501
  // ~0 tokens, i.e. the parent stream (and its `result` total) never included the subagent
505
- // spend. The subagent tokens live exclusively in the `subagents/*.jsonl` transcripts (a
506
- // directory distinct from the parent's `projects/` session transcript), which the watcher
507
- // reads and nothing else does so neither `calls` nor `usage` can already contain them.
502
+ // spend. The subagent tokens live exclusively in the per-session `subagents/*.jsonl`
503
+ // transcripts, which the watcher reads and nothing else does; it deliberately EXCLUDES the
504
+ // sibling parent session transcript (whose usage `result` already totals), so neither
505
+ // `calls` nor `usage` can already contain the subagent spend.
508
506
  const mergedUsage =
509
507
  usage || subUsage.inputTokens || subUsage.outputTokens
510
508
  ? {
package/src/job.ts CHANGED
@@ -1250,44 +1250,116 @@ export function parseAgentJob(input: unknown): AgentJob {
1250
1250
  // requires them (throws when missing/empty), exactly as before.
1251
1251
  const agentField = (value: unknown, path: string): string =>
1252
1252
  mode === 'preview' ? (typeof value === 'string' ? value : '') : str(value, path)
1253
+ // Parse each field, then hand the pieces to `assembleAgentJob` for the (large) object literal —
1254
+ // the parse/assemble split keeps both within the cyclomatic-complexity budget. Behaviour is
1255
+ // byte-identical (the literal + host validation moved verbatim).
1256
+ const job = assembleAgentJob(o, mode, agentField, {
1257
+ output: parseAgentOutputSpec(o.output),
1258
+ pr: parseAgentPrSpec(o.pr),
1259
+ infra: parseAgentInfraSpec(o.infra),
1260
+ peerRepos: parsePeerRepos(o.peerRepos),
1261
+ referenceRepos: parseReferenceRepos(o.referenceRepos),
1262
+ referenceBranches: parseReferenceBranches(o.referenceBranches),
1263
+ bootstrap: parseAgentBootstrapSpec(o.bootstrap),
1264
+ contextFiles: parseContextFiles(o.contextFiles),
1265
+ packageRegistries: parsePackageRegistries(o.packageRegistries),
1266
+ skill: parseSkillSpec(o.skill),
1267
+ testSecrets: parseTestSecrets(o.testSecrets),
1268
+ guardLimits: parseGuardLimits(o.guardLimits),
1269
+ validation: parseValidationSpec(o.validation),
1270
+ reviewPrNumber: posInt(o.reviewPrNumber),
1271
+ })
1272
+ assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl')
1273
+ if (job.githubApiBase) assertAllowedHost(job.githubApiBase, 'githubApiBase')
1274
+ // Bootstrap pushes the result to a SEPARATE target repo, so its clone URL must be an
1275
+ // allowed GitHub host too (the installation token is sent to it on the force-push).
1276
+ if (job.bootstrap) assertAllowedHost(job.bootstrap.target.cloneUrl, 'bootstrap.target.cloneUrl')
1277
+ // Each peer repo's clone URL receives the installation token on clone/push, so it must be
1278
+ // an allowed GitHub host too — a body-supplied peer pointing at an attacker host would
1279
+ // exfiltrate the token exactly like a rogue primary clone URL.
1280
+ for (const [i, peer] of (job.peerRepos ?? []).entries()) {
1281
+ assertAllowedHost(peer.repo.cloneUrl, `peerRepos[${i}].repo.cloneUrl`)
1282
+ }
1283
+ // Each reference repo's clone URL receives the installation/PAT token on clone (read-only,
1284
+ // never pushed), so it must be an allowed host too — a body-supplied reference pointing at an
1285
+ // attacker host would exfiltrate the token exactly like a rogue peer clone URL.
1286
+ for (const [i, ref] of (job.referenceRepos ?? []).entries()) {
1287
+ assertAllowedHost(ref.repo.cloneUrl, `referenceRepos[${i}].repo.cloneUrl`)
1288
+ }
1289
+ return job
1290
+ }
1291
+
1292
+ /** The pre-parsed field bundle {@link parseAgentJob} hands to {@link assembleAgentJob}. */
1293
+ interface ParsedAgentJobParts {
1294
+ output: AgentOutputSpec | undefined
1295
+ pr: { title: string; body: string } | undefined
1296
+ infra: ReturnType<typeof parseAgentInfraSpec>
1297
+ peerRepos: ReturnType<typeof parsePeerRepos>
1298
+ referenceRepos: ReturnType<typeof parseReferenceRepos>
1299
+ referenceBranches: ReturnType<typeof parseReferenceBranches>
1300
+ bootstrap: ReturnType<typeof parseAgentBootstrapSpec>
1301
+ contextFiles: ReturnType<typeof parseContextFiles>
1302
+ packageRegistries: ReturnType<typeof parsePackageRegistries>
1303
+ skill: ReturnType<typeof parseSkillSpec>
1304
+ testSecrets: ReturnType<typeof parseTestSecrets>
1305
+ guardLimits: ReturnType<typeof parseGuardLimits>
1306
+ validation: ReturnType<typeof parseValidationSpec>
1307
+ reviewPrNumber: number | undefined
1308
+ }
1309
+
1310
+ /** Parse the optional structured-output spec (`{ kind, shapeHint?, repair?, failOnUnusableFinal? }`). */
1311
+ function parseAgentOutputSpec(raw: unknown): AgentOutputSpec | undefined {
1312
+ if (typeof raw !== 'object' || raw === null) return undefined
1313
+ const so = raw as Record<string, unknown>
1314
+ const kind = so.kind === 'structured' ? 'structured' : 'prose'
1315
+ const spec: AgentOutputSpec = { kind }
1316
+ if (typeof so.shapeHint === 'string') spec.shapeHint = so.shapeHint
1317
+ // Carry an explicit `repair: false` through — the handler defaults to repair-on
1318
+ // when absent, so dropping `false` would silently re-enable the repair call for a
1319
+ // kind that opted out (it keys off `output.repair === false`).
1320
+ if (typeof so.repair === 'boolean') spec.repair = so.repair
1321
+ // Carry the opt-in truncation gate through (document producers set it); dropping
1322
+ // it would silently re-enable laundering a cut-off reply into a half-baked doc.
1323
+ if (so.failOnUnusableFinal === true) spec.failOnUnusableFinal = true
1324
+ return spec
1325
+ }
1326
+
1327
+ /** Parse the optional PR spec (`{ title, body }`). */
1328
+ function parseAgentPrSpec(raw: unknown): { title: string; body: string } | undefined {
1329
+ if (typeof raw !== 'object' || raw === null) return undefined
1330
+ const p = raw as Record<string, unknown>
1331
+ return { title: str(p.title, 'pr.title'), body: typeof p.body === 'string' ? p.body : '' }
1332
+ }
1333
+
1334
+ /**
1335
+ * Assemble the {@link AgentJob} object from the request `o` + the pre-parsed {@link
1336
+ * ParsedAgentJobParts}. Extracted from {@link parseAgentJob} so the large conditional-spread
1337
+ * literal doesn't blow the complexity budget; behaviour is byte-identical (spread order preserved).
1338
+ */
1339
+ function assembleAgentJob(
1340
+ o: Record<string, unknown>,
1341
+ mode: AgentJob['mode'],
1342
+ agentField: (value: unknown, path: string) => string,
1343
+ parts: ParsedAgentJobParts,
1344
+ ): AgentJob {
1345
+ const {
1346
+ output,
1347
+ pr,
1348
+ infra,
1349
+ peerRepos,
1350
+ referenceRepos,
1351
+ referenceBranches,
1352
+ bootstrap,
1353
+ contextFiles,
1354
+ packageRegistries,
1355
+ skill,
1356
+ testSecrets,
1357
+ guardLimits,
1358
+ validation,
1359
+ reviewPrNumber,
1360
+ } = parts
1253
1361
  const repo = (o.repo ?? {}) as Record<string, unknown>
1254
- const output =
1255
- typeof o.output === 'object' && o.output !== null
1256
- ? (() => {
1257
- const so = o.output as Record<string, unknown>
1258
- const kind = so.kind === 'structured' ? 'structured' : 'prose'
1259
- const spec: AgentOutputSpec = { kind }
1260
- if (typeof so.shapeHint === 'string') spec.shapeHint = so.shapeHint
1261
- // Carry an explicit `repair: false` through — the handler defaults to repair-on
1262
- // when absent, so dropping `false` would silently re-enable the repair call for a
1263
- // kind that opted out (it keys off `output.repair === false`).
1264
- if (typeof so.repair === 'boolean') spec.repair = so.repair
1265
- // Carry the opt-in truncation gate through (document producers set it); dropping
1266
- // it would silently re-enable laundering a cut-off reply into a half-baked doc.
1267
- if (so.failOnUnusableFinal === true) spec.failOnUnusableFinal = true
1268
- return spec
1269
- })()
1270
- : undefined
1271
- const pr =
1272
- typeof o.pr === 'object' && o.pr !== null
1273
- ? (() => {
1274
- const p = o.pr as Record<string, unknown>
1275
- return { title: str(p.title, 'pr.title'), body: typeof p.body === 'string' ? p.body : '' }
1276
- })()
1277
- : undefined
1278
- const infra = parseAgentInfraSpec(o.infra)
1279
- const peerRepos = parsePeerRepos(o.peerRepos)
1280
- const referenceRepos = parseReferenceRepos(o.referenceRepos)
1281
- const referenceBranches = parseReferenceBranches(o.referenceBranches)
1282
- const bootstrap = parseAgentBootstrapSpec(o.bootstrap)
1283
- const contextFiles = parseContextFiles(o.contextFiles)
1284
- const packageRegistries = parsePackageRegistries(o.packageRegistries)
1285
- const skill = parseSkillSpec(o.skill)
1286
- const testSecrets = parseTestSecrets(o.testSecrets)
1287
- const guardLimits = parseGuardLimits(o.guardLimits)
1288
- const validation = parseValidationSpec(o.validation)
1289
- const reviewPrNumber = posInt(o.reviewPrNumber)
1290
- const job: AgentJob = {
1362
+ return {
1291
1363
  jobId: str(o.jobId, 'jobId'),
1292
1364
  mode,
1293
1365
  systemPrompt: agentField(o.systemPrompt, 'systemPrompt'),
@@ -1325,22 +1397,4 @@ export function parseAgentJob(input: unknown): AgentJob {
1325
1397
  ...(guardLimits ? { guardLimits } : {}),
1326
1398
  ...(validation ? { validation } : {}),
1327
1399
  }
1328
- assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl')
1329
- if (job.githubApiBase) assertAllowedHost(job.githubApiBase, 'githubApiBase')
1330
- // Bootstrap pushes the result to a SEPARATE target repo, so its clone URL must be an
1331
- // allowed GitHub host too (the installation token is sent to it on the force-push).
1332
- if (job.bootstrap) assertAllowedHost(job.bootstrap.target.cloneUrl, 'bootstrap.target.cloneUrl')
1333
- // Each peer repo's clone URL receives the installation token on clone/push, so it must be
1334
- // an allowed GitHub host too — a body-supplied peer pointing at an attacker host would
1335
- // exfiltrate the token exactly like a rogue primary clone URL.
1336
- for (const [i, peer] of (job.peerRepos ?? []).entries()) {
1337
- assertAllowedHost(peer.repo.cloneUrl, `peerRepos[${i}].repo.cloneUrl`)
1338
- }
1339
- // Each reference repo's clone URL receives the installation/PAT token on clone (read-only,
1340
- // never pushed), so it must be an allowed host too — a body-supplied reference pointing at an
1341
- // attacker host would exfiltrate the token exactly like a rogue peer clone URL.
1342
- for (const [i, ref] of (job.referenceRepos ?? []).entries()) {
1343
- assertAllowedHost(ref.repo.cloneUrl, `referenceRepos[${i}].repo.cloneUrl`)
1344
- }
1345
- return job
1346
1400
  }
package/src/subagents.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import { readdir, stat } from 'node:fs/promises'
2
- import { createReadStream } from 'node:fs'
3
- import { join } from 'node:path'
2
+ import { createReadStream, type Dirent } from 'node:fs'
3
+ import { basename, join } from 'node:path'
4
4
  import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js'
5
5
  import type { Logger } from './logger.js'
6
6
  import type { HarnessCallMetric, TodoProgress } from './pi.js'
7
7
 
8
- // ADR 0026 D2.1 + D3. When the Claude Code CLI reviews a large PR it fans the work
9
- // out across parallel `Task` subagents. Two things then go dark to the harness, which
10
- // only reads the PARENT process's stream-json stdout:
8
+ // ADR 0026 D2.1 + D3, corrected by ADR 0027. When the Claude Code CLI reviews a large PR
9
+ // it fans the work out across parallel `Task` subagents. Two things then go dark to the
10
+ // harness, which only reads the PARENT process's stream-json stdout:
11
11
  //
12
12
  // - the parent stream falls quiet for the whole (potentially 15+ minute) parallel
13
13
  // review, so the inactivity heartbeat freezes and a healthy run looks wedged (P3);
@@ -21,11 +21,19 @@ import type { HarnessCallMetric, TodoProgress } from './pi.js'
21
21
  // - {@link createSliceTracker} derives the slice plan + per-slice progress from the
22
22
  // PARENT stream alone — the `Task` tool_use dispatch and its terminal tool_result
23
23
  // DO appear there (only the subagent's intermediate turns don't), so slices/progress
24
- // need no file watching (D2.1);
24
+ // need no file watching (D2.1). {@link pickProgress} reconciles it with any parent
25
+ // TodoWrite plan (ADR 0027 Defect B);
25
26
  // - {@link startSubagentWatcher} tails the `subagents/*.jsonl` transcripts for the
26
27
  // heartbeat (any new bytes ⇒ `onActivity`) and sums each subagent turn's usage into
27
28
  // the run's telemetry (D3).
28
29
  //
30
+ // The CLI does NOT write those transcripts to `<configHome>/subagents` (the location ADR
31
+ // 0026 assumed, which never exists — ADR 0027 Defect A). It writes them PER SESSION under
32
+ // `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/agent-*.jsonl`, and the
33
+ // session-uuid dir isn't known before the CLI mints it — so the watcher is pointed at the
34
+ // `projects` root and DISCOVERS the `subagents/` dir by walking (see
35
+ // {@link findSubagentTranscripts}).
36
+ //
29
37
  // Both degrade gracefully: the CLI's subagent transcript layout is not a stable contract,
30
38
  // so a missing directory, an unreadable file, or an unparseable line is swallowed and the
31
39
  // harness falls back to today's parent-stream-only behaviour.
@@ -52,8 +60,10 @@ export interface SliceTracker {
52
60
  hasSlices(): boolean
53
61
  /**
54
62
  * Progress derived from the dispatched subagents (completed / in-flight / total),
55
- * or undefined when none have been dispatched. Used ONLY as a fallback when the
56
- * agent never wrote a parent TodoWrite plan a real todo list, when present, wins.
63
+ * or undefined when none have been dispatched. Reconciled with any parent TodoWrite
64
+ * plan by {@link pickProgress} it is NOT gated off by the presence of a todo plan
65
+ * (that gate was ADR 0027 Defect B: the pr-reviewer prompt writes the plan ONCE at
66
+ * grouping time and never marks it done, which used to permanently mask this signal).
57
67
  */
58
68
  progress(): TodoProgress | undefined
59
69
  }
@@ -106,6 +116,31 @@ export function createSliceTracker(): SliceTracker {
106
116
  }
107
117
  }
108
118
 
119
+ /**
120
+ * Reconcile the two redundant views of the same slice work into the one to surface
121
+ * (ADR 0027 Defect B). A pr-reviewer run has BOTH a parent `TodoWrite` plan (the slices,
122
+ * written once at grouping time) and the {@link SliceTracker}'s `Task`-dispatch view. The
123
+ * sequential shape advances the todo plan; the parallel-subagent shape advances ONLY the
124
+ * slice tracker (the CLI writes the plan once and never marks it done, while the parallel
125
+ * `Task`s report in-flight/complete). Neither alone covers both shapes, and gating the
126
+ * slice tracker OFF whenever a todo plan exists (the old behaviour) pinned parallel runs
127
+ * at 0%. So prefer whichever view is further along: more `completed`, then more
128
+ * `inProgress` (an all-pending todo plan must not beat live in-flight slices), then more
129
+ * `total` (the richer view — the todo plan can carry an extra "aggregate" entry), else the
130
+ * todo plan. Pure + total; returns whichever single input is present when only one is.
131
+ */
132
+ export function pickProgress(
133
+ todo: TodoProgress | undefined,
134
+ slice: TodoProgress | undefined,
135
+ ): TodoProgress | undefined {
136
+ if (!todo) return slice
137
+ if (!slice) return todo
138
+ if (slice.completed !== todo.completed) return slice.completed > todo.completed ? slice : todo
139
+ if (slice.inProgress !== todo.inProgress) return slice.inProgress > todo.inProgress ? slice : todo
140
+ if (slice.total !== todo.total) return slice.total > todo.total ? slice : todo
141
+ return todo
142
+ }
143
+
109
144
  // ---------------------------------------------------------------------------
110
145
  // Subagent transcript watcher (heartbeat + usage) (D3)
111
146
  // ---------------------------------------------------------------------------
@@ -135,15 +170,51 @@ export interface SubagentWatcher {
135
170
  }
136
171
 
137
172
  /**
138
- * Start watching `dir` (the CLI's `<configHome>/subagents`) for `*.jsonl` transcripts,
139
- * tailing each file by byte offset. New content feeds `onActivity` (heartbeat) and each
140
- * assistant turn carrying usage is lifted into a {@link HarnessCallMetric} + summed into
141
- * the cumulative usage. Best-effort throughout: the directory may not exist yet (created
142
- * lazily by the CLI), a file may be mid-write, and the line/usage shape may change across
143
- * CLI versions every such case is swallowed so the watcher can only ever ADD signal,
144
- * never break the run.
173
+ * Recursively collect every `*.jsonl` file that lives inside a `subagents/` directory
174
+ * anywhere under `root` (the CLI's `<configHome>/projects` tree). The Claude CLI writes
175
+ * each parallel `Task` subagent's transcript to
176
+ * `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/agent-*.jsonl`; the
177
+ * session-uuid dir isn't known before the CLI mints it, so we DISCOVER the `subagents/`
178
+ * dir by walking rather than guessing its path (ADR 0027 Defect A). Files NOT under a
179
+ * `subagents/` dir — critically the parent's own `<session-uuid>.jsonl` session transcript,
180
+ * whose per-turn usage the terminal `result` event already totals — are deliberately
181
+ * excluded: reading them would double-count the parent. `root` itself counts as inside a
182
+ * `subagents/` dir when its own basename is `subagents` (so passing the leaf dir works too).
183
+ * Best-effort: an unreadable directory is skipped, never thrown.
184
+ */
185
+ async function findSubagentTranscripts(root: string): Promise<string[]> {
186
+ const out: string[] = []
187
+ const walk = async (dir: string, inSubagents: boolean): Promise<void> => {
188
+ let entries: Dirent[]
189
+ try {
190
+ entries = await readdir(dir, { withFileTypes: true })
191
+ } catch {
192
+ return // dir not created yet (or vanished) — try again next tick
193
+ }
194
+ for (const entry of entries) {
195
+ const full = join(dir, entry.name)
196
+ if (entry.isDirectory()) {
197
+ await walk(full, inSubagents || entry.name === 'subagents')
198
+ } else if (inSubagents && entry.isFile() && entry.name.endsWith('.jsonl')) {
199
+ out.push(full)
200
+ }
201
+ }
202
+ }
203
+ await walk(root, basename(root) === 'subagents')
204
+ return out
205
+ }
206
+
207
+ /**
208
+ * Start watching `root` (the CLI's `<configHome>/projects` tree) for subagent `*.jsonl`
209
+ * transcripts — any file under a `subagents/` directory beneath it (see
210
+ * {@link findSubagentTranscripts}) — tailing each file by byte offset. New content feeds
211
+ * `onActivity` (heartbeat) and each assistant turn carrying usage is lifted into a
212
+ * {@link HarnessCallMetric} + summed into the cumulative usage. Best-effort throughout: the
213
+ * tree may not exist yet (created lazily by the CLI), a file may be mid-write, and the
214
+ * line/usage shape may change across CLI versions — every such case is swallowed so the
215
+ * watcher can only ever ADD signal, never break the run.
145
216
  */
146
- export function startSubagentWatcher(dir: string, opts: SubagentWatcherOptions): SubagentWatcher {
217
+ export function startSubagentWatcher(root: string, opts: SubagentWatcherOptions): SubagentWatcher {
147
218
  const secrets = opts.secrets ?? []
148
219
  const offsets = new Map<string, number>()
149
220
  const calls: HarnessCallMetric[] = []
@@ -225,15 +296,8 @@ export function startSubagentWatcher(dir: string, opts: SubagentWatcherOptions):
225
296
  if (polling) return
226
297
  polling = true
227
298
  try {
228
- let entries: string[]
229
- try {
230
- entries = (await readdir(dir)).filter((n) => n.endsWith('.jsonl'))
231
- } catch {
232
- return // dir not created yet (or vanished) — try again next tick
233
- }
234
299
  let grew = false
235
- for (const name of entries) {
236
- const path = join(dir, name)
300
+ for (const path of await findSubagentTranscripts(root)) {
237
301
  let size: number
238
302
  try {
239
303
  size = (await stat(path)).size