@cat-factory/executor-harness 1.78.0 → 1.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -384,7 +384,7 @@ export async function runClaudeCode(opts) {
384
384
  // either/or; the plan then MERGES with the dispatch view (`mergeProgress`) rather than
385
385
  // competing with it — picking the further-along view collapsed the list to the dispatched
386
386
  // slices alone the moment the first subagent returned. See ./progress.ts.
387
- const sliceTracker = createSliceTracker();
387
+ const sliceTracker = createSliceTracker(secrets);
388
388
  const planTracker = createTaskPlanTracker();
389
389
  let lastTodo;
390
390
  const emitProgress = () => {
@@ -394,6 +394,17 @@ export async function runClaudeCode(opts) {
394
394
  if (progress)
395
395
  opts.onProgress(progress);
396
396
  };
397
+ // Publish the per-slice reviews the tracker has captured. Separate from `emitProgress` because
398
+ // the two answer different questions and have different lifetimes: progress is a disposable
399
+ // count the UI renders, while these carry the slices' actual review WORK and are persisted so a
400
+ // run that dies before its aggregation can be resumed from them.
401
+ const emitSliceReviews = () => {
402
+ if (!opts.onSliceReviews)
403
+ return;
404
+ const reviews = sliceTracker.sliceReviews();
405
+ if (reviews.length > 0)
406
+ opts.onSliceReviews(reviews);
407
+ };
397
408
  // No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
398
409
  // absent on this path until now. Claude Code reports a tool CALL (its name) on the `assistant`
399
410
  // turn and that call's RESULT (`is_error`) on the following `user` turn, so correlate them by
@@ -471,6 +482,9 @@ export async function runClaudeCode(opts) {
471
482
  sliceTracker.onUser(content);
472
483
  planTracker.onUser(content);
473
484
  emitProgress();
485
+ // A slice's report lands on exactly this turn, so publish here: waiting for the next
486
+ // progress tick would risk the job dying with the report captured but never surfaced.
487
+ emitSliceReviews();
474
488
  // Not on the at-close flush: the CLI has already exited, so tripping the guard there
475
489
  // would kill nothing and only convert a clean exit into a spurious failure.
476
490
  if (!meta?.final)
@@ -49,6 +49,29 @@ export function claudeAssistantContent(content) {
49
49
  }
50
50
  return { text, reasoning, toolUses };
51
51
  }
52
+ /**
53
+ * The text a `tool_result` block carries. The CLI writes it either as a bare string or as an
54
+ * array of content blocks (the shape a subagent's terminal report arrives in), so both are read
55
+ * here rather than at each call site. Non-text blocks (an image a tool returned) contribute
56
+ * nothing. Returns '' when the block carries no readable text.
57
+ *
58
+ * This is what makes a parallel subagent's work observable to the harness at all: the parent
59
+ * stream shows a subagent's dispatch and its terminal `tool_result` and nothing in between, so
60
+ * this text is the ONLY place its findings surface outside its own untailed transcript.
61
+ */
62
+ export function claudeToolResultText(block) {
63
+ const content = block.content;
64
+ if (typeof content === 'string')
65
+ return content;
66
+ if (!Array.isArray(content))
67
+ return '';
68
+ let text = '';
69
+ for (const part of content) {
70
+ if (isObject(part) && part.type === 'text' && typeof part.text === 'string')
71
+ text += part.text;
72
+ }
73
+ return text;
74
+ }
52
75
  /**
53
76
  * Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
54
77
  * the cumulative `result` total).
@@ -182,6 +182,10 @@ export async function runAgentInWorkspace(spec, opts = {}) {
182
182
  expectsEdits: spec.expectsEdits ?? true,
183
183
  onActivity: opts.onActivity,
184
184
  onProgress: opts.onProgress,
185
+ // Per-slice review capture, so a parallel review's finished slices are persisted as they
186
+ // land rather than only in the terminal output. Only the subscription runners fan work out
187
+ // across subagents, so this is the only path that can produce it.
188
+ onSliceReviews: opts.onSliceReviews,
185
189
  // Stream this run's per-call telemetry to the job's live drain. The subscription
186
190
  // harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
187
191
  // proxy as they happen), so this is the only path that needs the hook.
package/dist/runner.js CHANGED
@@ -220,6 +220,9 @@ export class JobRegistry {
220
220
  onValidationReport: (report) => {
221
221
  entry.validationReport = report;
222
222
  },
223
+ onSliceReviews: (reviews) => {
224
+ entry.sliceReviews = reviews;
225
+ },
223
226
  onReproductionProof: (report) => {
224
227
  entry.reproductionReport = report;
225
228
  },
package/dist/subagents.js CHANGED
@@ -1,9 +1,62 @@
1
1
  import { readdir, stat } from 'node:fs/promises';
2
2
  import { createReadStream } from 'node:fs';
3
3
  import { basename, join } from 'node:path';
4
- import { claudeAssistantContent, claudeCallUsage, isObject, redactBody, SUBAGENT_TOOL_NAMES, } from './claude-stream.js';
4
+ import { claudeAssistantContent, claudeCallUsage, claudeToolResultText, isObject, redactBody, SUBAGENT_TOOL_NAMES, } from './claude-stream.js';
5
5
  import { publishCallMetric } from './pi.js';
6
- export function createSliceTracker() {
6
+ // ADR 0026 D2.1 + D3, corrected by ADR 0027. When the Claude Code CLI reviews a large PR
7
+ // it fans the work out across parallel `Task` subagents. Two things then go dark to the
8
+ // harness, which only reads the PARENT process's stream-json stdout:
9
+ //
10
+ // - the parent stream falls quiet for the whole (potentially 15+ minute) parallel
11
+ // review, so the inactivity heartbeat freezes and a healthy run looks wedged (P3);
12
+ // - every subagent's token spend is written to a SEPARATE `subagents/*.jsonl`
13
+ // transcript under the CLI's config home and never reaches the parent stream, so
14
+ // the run's telemetry reports ~0 tokens while hundreds of thousands are spent (P3).
15
+ //
16
+ // This module closes both without disabling the (context-bounding, ADR-0023-wanted)
17
+ // subagent parallelism:
18
+ //
19
+ // - {@link createSliceTracker} derives the slice plan + per-slice progress from the
20
+ // PARENT stream alone — the subagent-dispatch tool_use and its terminal tool_result
21
+ // DO appear there (only the subagent's intermediate turns don't), so slices/progress
22
+ // need no file watching (D2.1). `pickProgress` (./progress.ts) reconciles it with the
23
+ // parent's own plan (ADR 0027 Defect B);
24
+ // - {@link startSubagentWatcher} tails the `subagents/*.jsonl` transcripts for the
25
+ // heartbeat (any new bytes ⇒ `onActivity`) and sums each subagent turn's usage into
26
+ // the run's telemetry (D3).
27
+ //
28
+ // The CLI does NOT write those transcripts to `<configHome>/subagents` (the location ADR
29
+ // 0026 assumed, which never exists — ADR 0027 Defect A). It writes them PER SESSION under
30
+ // `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/agent-*.jsonl`, and the
31
+ // session-uuid dir isn't known before the CLI mints it — so the watcher is pointed at the
32
+ // `projects` root and DISCOVERS the `subagents/` dir by walking (see
33
+ // {@link findSubagentTranscripts}).
34
+ //
35
+ // Both degrade gracefully in the sense that a missing directory, an unreadable file, or an
36
+ // unparseable line is swallowed rather than failing the run — the CLI's subagent transcript layout
37
+ // is not a stable contract. But note what that costs SINCE the per-call fold landed: the parent
38
+ // loop's telemetry now filters the subagent turns the CLI tags onto its stdout (they were being
39
+ // counted twice and spliced into the parent's message chain), so when this watcher is wired and
40
+ // yields nothing, the run's subagent calls are recorded by NEITHER channel. `runClaudeCode` warns
41
+ // on exactly that shape, and an `ambientAuth` run — which has no config home to watch, so no
42
+ // watcher — keeps recording them off the parent stream instead
43
+ // (`createSubagentStreamTelemetry`). Do not "simplify" that fallback away.
44
+ // ---------------------------------------------------------------------------
45
+ // Slice / progress tracking off the PARENT stream (D2.1)
46
+ // ---------------------------------------------------------------------------
47
+ /**
48
+ * How much of one slice's terminal report is kept. A slice review is prose (findings for a handful
49
+ * of files), not a transcript, so this is far above a real report while still bounding what a
50
+ * runaway subagent can push onto the step — the reports ride the job view on every poll and are
51
+ * persisted on the run.
52
+ */
53
+ export const SLICE_REPORT_MAX_CHARS = 24_000;
54
+ /**
55
+ * @param secrets Leased-credential strings scrubbed from every captured report. A subagent can
56
+ * echo a token it saw in the checkout, and these reports are persisted on the run, so they are
57
+ * redacted on the way in rather than trusting each consumer to do it.
58
+ */
59
+ export function createSliceTracker(secrets = []) {
7
60
  // Insertion-ordered so the progress `items` render in dispatch order.
8
61
  const slices = new Map();
9
62
  return {
@@ -33,10 +86,27 @@ export function createSliceTracker() {
33
86
  continue;
34
87
  const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined;
35
88
  const slice = id ? slices.get(id) : undefined;
36
- if (slice)
37
- slice.done = true;
89
+ if (!slice)
90
+ continue;
91
+ slice.done = true;
92
+ // The report is captured here or nowhere: this `tool_result` is the only place the
93
+ // subagent's findings appear on the parent stream, and the next poll may be the last one
94
+ // this job ever answers.
95
+ const report = redactBody(claudeToolResultText(block), secrets).trim();
96
+ if (report)
97
+ slice.report = report.slice(0, SLICE_REPORT_MAX_CHARS);
38
98
  }
39
99
  },
100
+ sliceReviews() {
101
+ return [...slices.values()].map((s) => ({
102
+ label: s.description,
103
+ status: (s.done ? 'completed' : 'in_progress'),
104
+ // A slice that finished but whose result carried no readable text is reported as
105
+ // completed with a null report rather than being dropped: a resume must still know it
106
+ // does not need re-reviewing, and silently omitting it would send it round again.
107
+ report: s.report ?? null,
108
+ }));
109
+ },
40
110
  hasSlices() {
41
111
  return slices.size > 0;
42
112
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.78.0",
3
+ "version": "1.80.0",
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,9 +26,9 @@
26
26
  "hono": "^4.12.32",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/kernel": "0.193.0",
30
- "@cat-factory/spend": "0.12.123",
31
- "@cat-factory/server": "0.178.2"
29
+ "@cat-factory/server": "0.183.0",
30
+ "@cat-factory/kernel": "0.197.0",
31
+ "@cat-factory/spend": "0.12.127"
32
32
  },
33
33
  "scripts": {
34
34
  "build": "tsc -p tsconfig.json",
@@ -26,7 +26,7 @@ import { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js'
26
26
  import { killChildProcess, spawnDetached } from './process.js'
27
27
  import { describeProcessExit } from './process-exit.js'
28
28
  import { redact, registerKnownSecrets, secretsToRedact } from './redact.js'
29
- import { createSliceTracker, startSubagentWatcher } from './subagents.js'
29
+ import { createSliceTracker, startSubagentWatcher, type SliceReview } from './subagents.js'
30
30
  import {
31
31
  createTaskPlanTracker,
32
32
  mergeProgress,
@@ -123,6 +123,13 @@ export interface SubscriptionRunOptions {
123
123
  onActivity?: () => void
124
124
  /** Called with the latest subtask counts each time the CLI updates its todo/plan list. */
125
125
  onProgress?: (progress: TodoProgress) => void
126
+ /**
127
+ * Called with the FULL set of per-slice reviews each time one lands, so the backend can persist
128
+ * a parallel review's completed work as it happens instead of only from the terminal result.
129
+ * A whole value rather than a delta: the set only grows and losing a finished slice's report to
130
+ * a dropped poll would defeat the point (see `SliceTracker.sliceReviews`).
131
+ */
132
+ onSliceReviews?: (reviews: SliceReview[]) => void
126
133
  /**
127
134
  * Called with each per-call telemetry row as the CLI stream yields it, so the backend can
128
135
  * record the run's model calls WHILE it runs instead of only from its terminal result. The
@@ -542,7 +549,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
542
549
  // either/or; the plan then MERGES with the dispatch view (`mergeProgress`) rather than
543
550
  // competing with it — picking the further-along view collapsed the list to the dispatched
544
551
  // slices alone the moment the first subagent returned. See ./progress.ts.
545
- const sliceTracker = createSliceTracker()
552
+ const sliceTracker = createSliceTracker(secrets)
546
553
  const planTracker = createTaskPlanTracker()
547
554
  let lastTodo: TodoProgress | undefined
548
555
  const emitProgress = (): void => {
@@ -553,6 +560,15 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
553
560
  )
554
561
  if (progress) opts.onProgress(progress)
555
562
  }
563
+ // Publish the per-slice reviews the tracker has captured. Separate from `emitProgress` because
564
+ // the two answer different questions and have different lifetimes: progress is a disposable
565
+ // count the UI renders, while these carry the slices' actual review WORK and are persisted so a
566
+ // run that dies before its aggregation can be resumed from them.
567
+ const emitSliceReviews = (): void => {
568
+ if (!opts.onSliceReviews) return
569
+ const reviews = sliceTracker.sliceReviews()
570
+ if (reviews.length > 0) opts.onSliceReviews(reviews)
571
+ }
556
572
 
557
573
  // No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
558
574
  // absent on this path until now. Claude Code reports a tool CALL (its name) on the `assistant`
@@ -626,6 +642,9 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
626
642
  sliceTracker.onUser(content)
627
643
  planTracker.onUser(content)
628
644
  emitProgress()
645
+ // A slice's report lands on exactly this turn, so publish here: waiting for the next
646
+ // progress tick would risk the job dying with the report captured but never surfaced.
647
+ emitSliceReviews()
629
648
  // Not on the at-close flush: the CLI has already exited, so tripping the guard there
630
649
  // would kill nothing and only convert a clean exit into a spurious failure.
631
650
  if (!meta?.final) feedGuard(content)
@@ -57,6 +57,27 @@ export function claudeAssistantContent(content: unknown[]): {
57
57
  return { text, reasoning, toolUses }
58
58
  }
59
59
 
60
+ /**
61
+ * The text a `tool_result` block carries. The CLI writes it either as a bare string or as an
62
+ * array of content blocks (the shape a subagent's terminal report arrives in), so both are read
63
+ * here rather than at each call site. Non-text blocks (an image a tool returned) contribute
64
+ * nothing. Returns '' when the block carries no readable text.
65
+ *
66
+ * This is what makes a parallel subagent's work observable to the harness at all: the parent
67
+ * stream shows a subagent's dispatch and its terminal `tool_result` and nothing in between, so
68
+ * this text is the ONLY place its findings surface outside its own untailed transcript.
69
+ */
70
+ export function claudeToolResultText(block: Record<string, unknown>): string {
71
+ const content = block.content
72
+ if (typeof content === 'string') return content
73
+ if (!Array.isArray(content)) return ''
74
+ let text = ''
75
+ for (const part of content) {
76
+ if (isObject(part) && part.type === 'text' && typeof part.text === 'string') text += part.text
77
+ }
78
+ return text
79
+ }
80
+
60
81
  /**
61
82
  * Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
62
83
  * the cumulative `result` total).
@@ -325,6 +325,10 @@ export async function runAgentInWorkspace(
325
325
  expectsEdits: spec.expectsEdits ?? true,
326
326
  onActivity: opts.onActivity,
327
327
  onProgress: opts.onProgress,
328
+ // Per-slice review capture, so a parallel review's finished slices are persisted as they
329
+ // land rather than only in the terminal output. Only the subscription runners fan work out
330
+ // across subagents, so this is the only path that can produce it.
331
+ onSliceReviews: opts.onSliceReviews,
328
332
  // Stream this run's per-call telemetry to the job's live drain. The subscription
329
333
  // harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
330
334
  // proxy as they happen), so this is the only path that needs the hook.
package/src/runner.ts CHANGED
@@ -2,6 +2,7 @@ import { redactSecrets } from './redact.js'
2
2
  import type { FollowUpLine } from './follow-ups.js'
3
3
  import type { ValidationReport } from './validation-checks.js'
4
4
  import type { ReproductionReport } from './reproduction-proof.js'
5
+ import type { SliceReview } from './subagents.js'
5
6
  import type { HarnessCallMetric, TodoProgress, ToolSpan } from './pi.js'
6
7
  import { log, type Logger } from './logger.js'
7
8
  import {
@@ -47,6 +48,14 @@ export interface RunOptions {
47
48
  * attempt is final, and the loop republishes a whole new one — with a fresh `at` — per round.
48
49
  */
49
50
  onReproductionProof?: (report: ReproductionReport) => void
51
+ /**
52
+ * Receives the full set of per-slice reviews a parallel review has captured, republished each
53
+ * time a slice's subagent returns. Latest-wins (NOT a drain buffer), for the same reason as
54
+ * {@link onValidationReport} but with more at stake: these carry the slices' actual review work,
55
+ * and a review whose aggregation never finishes is recoverable ONLY from what the backend
56
+ * already persisted. Absent for a job that dispatched no subagents.
57
+ */
58
+ onSliceReviews?: (reviews: SliceReview[]) => void
50
59
  /**
51
60
  * Receives each per-call telemetry row the moment the agent's CLI stream yields it, so a
52
61
  * run's model calls reach `llm_call_metrics` WHILE it runs rather than only in its terminal
@@ -203,6 +212,18 @@ export interface JobView<TResult extends JobResultBase = JobResultBase> {
203
212
  * that carried no reproduction declaration.
204
213
  */
205
214
  reproductionReport?: ReproductionReport
215
+ /**
216
+ * The per-slice reviews captured so far on a parallel (subagent-fanned) review — each slice's
217
+ * label, whether its subagent returned, and its verbatim report. A whole-value latest publish
218
+ * like {@link validationReport}, not drain-on-read.
219
+ *
220
+ * This is the durable half of a PR review. The reviewer returns `slices`/`findings` only in its
221
+ * TERMINAL structured output, so before this existed a review killed mid-run (or one whose
222
+ * aggregation pass wedged) lost every finished slice and could only be re-run from zero. The
223
+ * backend persists these onto the step as they arrive, which is what a manual resume re-aggregates
224
+ * from. Absent for a job that dispatched no subagents.
225
+ */
226
+ sliceReviews?: SliceReview[]
206
227
  }
207
228
 
208
229
  interface JobEntry<TResult extends JobResultBase> extends JobView<TResult> {
@@ -473,6 +494,9 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
473
494
  onValidationReport: (report) => {
474
495
  entry.validationReport = report
475
496
  },
497
+ onSliceReviews: (reviews) => {
498
+ entry.sliceReviews = reviews
499
+ },
476
500
  onReproductionProof: (report) => {
477
501
  entry.reproductionReport = report
478
502
  },
package/src/subagents.ts CHANGED
@@ -4,6 +4,7 @@ import { basename, join } from 'node:path'
4
4
  import {
5
5
  claudeAssistantContent,
6
6
  claudeCallUsage,
7
+ claudeToolResultText,
7
8
  isObject,
8
9
  redactBody,
9
10
  SUBAGENT_TOOL_NAMES,
@@ -54,20 +55,52 @@ import { publishCallMetric, type HarnessCallMetric, type TodoProgress } from './
54
55
  // Slice / progress tracking off the PARENT stream (D2.1)
55
56
  // ---------------------------------------------------------------------------
56
57
 
58
+ /**
59
+ * How much of one slice's terminal report is kept. A slice review is prose (findings for a handful
60
+ * of files), not a transcript, so this is far above a real report while still bounding what a
61
+ * runaway subagent can push onto the step — the reports ride the job view on every poll and are
62
+ * persisted on the run.
63
+ */
64
+ export const SLICE_REPORT_MAX_CHARS = 24_000
65
+
57
66
  interface TrackedSlice {
58
67
  /** The dispatch's tool_use id, used to pair the terminal tool_result. */
59
68
  toolUseId: string
60
69
  /** The subagent's description (`Review <slice> slice`), rendered as the progress label. */
61
70
  description: string
62
71
  done: boolean
72
+ /**
73
+ * The subagent's verbatim terminal report, captured from the paired `tool_result`; undefined
74
+ * until it lands. This is the slice's actual review work — the reason a stuck aggregation no
75
+ * longer costs the whole run (see `prReviewSliceReviewSchema`). Truncated to
76
+ * {@link SLICE_REPORT_MAX_CHARS} and scrubbed of leased credentials before it leaves here.
77
+ */
78
+ report?: string
79
+ }
80
+
81
+ /** One slice's live review, as published on the job view. Mirrors `prReviewSliceReviewSchema`. */
82
+ export interface SliceReview {
83
+ label: string
84
+ status: 'in_progress' | 'completed'
85
+ report?: string | null
63
86
  }
64
87
 
65
88
  /** Tracks parallel subagents seen on the parent stream to derive slice progress. */
66
89
  export interface SliceTracker {
67
90
  /** Feed an `assistant` message's content blocks: registers any subagent dispatches. */
68
91
  onAssistant(content: unknown[]): void
69
- /** Feed a `user` message's content blocks: marks the paired subagent(s) complete. */
92
+ /**
93
+ * Feed a `user` message's content blocks: marks the paired subagent(s) complete AND captures
94
+ * each one's terminal report (see {@link SliceTracker.sliceReviews}).
95
+ */
70
96
  onUser(content: unknown[]): void
97
+ /**
98
+ * Every dispatched slice with its status and captured report, in dispatch order — the durable
99
+ * half of this tracker. Published as a whole value on each poll (NOT drain-on-read): the set
100
+ * only grows, and a dropped poll response must never permanently lose a finished slice's
101
+ * review, which is the entire point of capturing it. Empty when nothing was dispatched.
102
+ */
103
+ sliceReviews(): SliceReview[]
71
104
  /** Whether any `Task` subagent has been dispatched (⇒ this run parallelised). */
72
105
  hasSlices(): boolean
73
106
  /**
@@ -80,7 +113,12 @@ export interface SliceTracker {
80
113
  progress(): TodoProgress | undefined
81
114
  }
82
115
 
83
- export function createSliceTracker(): SliceTracker {
116
+ /**
117
+ * @param secrets Leased-credential strings scrubbed from every captured report. A subagent can
118
+ * echo a token it saw in the checkout, and these reports are persisted on the run, so they are
119
+ * redacted on the way in rather than trusting each consumer to do it.
120
+ */
121
+ export function createSliceTracker(secrets: string[] = []): SliceTracker {
84
122
  // Insertion-ordered so the progress `items` render in dispatch order.
85
123
  const slices = new Map<string, TrackedSlice>()
86
124
 
@@ -106,9 +144,25 @@ export function createSliceTracker(): SliceTracker {
106
144
  if (!isObject(block) || block.type !== 'tool_result') continue
107
145
  const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined
108
146
  const slice = id ? slices.get(id) : undefined
109
- if (slice) slice.done = true
147
+ if (!slice) continue
148
+ slice.done = true
149
+ // The report is captured here or nowhere: this `tool_result` is the only place the
150
+ // subagent's findings appear on the parent stream, and the next poll may be the last one
151
+ // this job ever answers.
152
+ const report = redactBody(claudeToolResultText(block), secrets).trim()
153
+ if (report) slice.report = report.slice(0, SLICE_REPORT_MAX_CHARS)
110
154
  }
111
155
  },
156
+ sliceReviews() {
157
+ return [...slices.values()].map((s) => ({
158
+ label: s.description,
159
+ status: (s.done ? 'completed' : 'in_progress') as 'completed' | 'in_progress',
160
+ // A slice that finished but whose result carried no readable text is reported as
161
+ // completed with a null report rather than being dropped: a resume must still know it
162
+ // does not need re-reviewing, and silently omitting it would send it round again.
163
+ report: s.report ?? null,
164
+ }))
165
+ },
112
166
  hasSlices() {
113
167
  return slices.size > 0
114
168
  },