@cat-factory/executor-harness 1.50.12 → 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/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.12",
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,7 +26,7 @@
26
26
  "hono": "^4.12.30",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/server": "0.141.2",
29
+ "@cat-factory/server": "0.141.3",
30
30
  "@cat-factory/spend": "0.12.73"
31
31
  },
32
32
  "scripts": {
@@ -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/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