@pi-archimedes/subagent 2.11.0 → 2.11.2

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.
package/README.md CHANGED
@@ -44,7 +44,7 @@ The `agent` field is optional — omitting it is not a built-in default agent, a
44
44
  }
45
45
  ```
46
46
 
47
- **The dispatch waits.** A call — single or parallel — blocks until every task completes and returns the combined results, each optionally carrying the child's `childSessionId`. There is an `async` field in the schema; the implementation ignores it, so don't plan on fire-and-forget: batch the work into `tasks` and let the call block on all of it. In parallel mode each result section carries the child's final output beneath its metrics line.
47
+ **The dispatch waits.** A call — single or parallel — blocks until every task completes and returns the combined results, each optionally carrying the child's `childSessionId`. There is no async mode: an `async` parameter was deleted in #114 because the schema declared it and the implementation ignored it, and a parameter the model is told about but nothing reads is a false promise. Implementing fire-and-forget is deferred to #113; until then, batch the work into `tasks` and let the call block on all of it. In parallel mode each result section carries the child's final output beneath its metrics line.
48
48
 
49
49
  ### Model and thinking resolution
50
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/subagent",
3
- "version": "2.11.0",
3
+ "version": "2.11.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -15,8 +15,8 @@
15
15
  ],
16
16
  "main": "./src/index.ts",
17
17
  "dependencies": {
18
- "@pi-archimedes/core": "2.11.0",
19
- "@pi-archimedes/todo": "2.11.0"
18
+ "@pi-archimedes/core": "2.11.2",
19
+ "@pi-archimedes/todo": "2.11.2"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "@earendil-works/pi-ai": ">=1.2.0",
@@ -1,7 +1,8 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { buildExpandedText } from "./expanded.js";
3
- import type { RowSegment } from "./row-block.js";
4
- import type { SubagentResult } from "./types.js";
2
+ import { stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { buildExpandedText, buildProgressExpandedText, renderExpanded, renderProgressExpanded } from "./expanded.js";
4
+ import { RowBlock, type RowSegment, RESET } from "./row-block.js";
5
+ import type { RunOutcome, SubagentProgress, SubagentResult } from "./types.js";
5
6
 
6
7
  // The expanded row is what the user reads after `ctrl+o`; the compact row is
7
8
  // what they see while it runs. Both have to carry the *Run outcome* vocabulary
@@ -59,3 +60,413 @@ describe("expanded view: run outcomes", () => {
59
60
  expect(out).toContain("401 unauthorized");
60
61
  });
61
62
  });
63
+
64
+ // ── A multi-line error, read in full (issue #112) ───────────────────────────
65
+
66
+ /**
67
+ * What a failed child actually reports: a command line, a compiler diagnostic, a
68
+ * stack frame, and the exit status on the last line. The last line is the one
69
+ * whoever reads it wants (`rc=1` says what happened), and it is exactly what the
70
+ * row cut threw away — so every assertion below keys off it.
71
+ */
72
+ const MULTI_LINE_ERROR = [
73
+ "Command failed: pnpm -r exec -- tsc --noEmit",
74
+ "packages/subagent/src/index.ts:42:7 - error TS2345: Argument of type 'string' is not assignable",
75
+ " at runChild (/home/daniel/Coding/Javascript/pi-archimedes/packages/subagent/src/spawn.ts:88:12)",
76
+ "error: subagent exited with rc=1",
77
+ ].join("\n");
78
+ const ERROR_LINES = MULTI_LINE_ERROR.split("\n");
79
+ /** Everything after the head — the part that moved from the row into the block. */
80
+ const TAIL_ERROR_LINES = ERROR_LINES.slice(1);
81
+ const FIRST_ERROR_LINE = ERROR_LINES[0]!;
82
+ const LAST_ERROR_LINE = ERROR_LINES[ERROR_LINES.length - 1]!;
83
+
84
+ /** `theme.fg` for real SGR bytes, so the rendered lines carry an ANSI state. */
85
+ const ERROR_SGR = "\x1b[31m";
86
+ const sgrTheme = {
87
+ fg: (token: string, text: string) =>
88
+ `${token === "error" ? ERROR_SGR : "\x1b[2m"}${text}\x1b[0m`,
89
+ bold: (t: string) => `\x1b[1m${t}\x1b[0m`,
90
+ };
91
+
92
+ /** A `RowBlock` that remembers the segments it was last given, without faking the row layer. */
93
+ class CapturingRowBlock extends RowBlock {
94
+ /** Named apart from `RowBlock`'s own private field of the same name. */
95
+ lastSegments: RowSegment[] | undefined = undefined;
96
+ /** A builder that handed its content over `setText` instead would land here. */
97
+ lastSetText: string | undefined = undefined;
98
+
99
+ override setSegments(segments: RowSegment[]): void {
100
+ this.lastSegments = segments;
101
+ super.setSegments(segments);
102
+ }
103
+
104
+ override setText(text: string): void {
105
+ this.lastSetText = text;
106
+ super.setText(text);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * What the builder handed the row layer — asserted rather than assumed. Hooking
112
+ * `setText` and checking it here means a builder that switched to `setText`
113
+ * fails as a readable assertion instead of a `TypeError` on `lastSegments!`.
114
+ */
115
+ function captured(rows: CapturingRowBlock): RowSegment[] {
116
+ expect(rows.lastSetText, "the builder handed its content over setText, so nothing below reads it")
117
+ .toBeUndefined();
118
+ expect(rows.lastSegments, "setSegments was never called, so nothing below reads the view").toBeDefined();
119
+ return rows.lastSegments!;
120
+ }
121
+
122
+ /** Strip this file's fake `theme.fg` markers, line by line — a block carries one per line. */
123
+ const plainLines = (text: string): string[] => text.split("\n").map((l) => l.replace(/^\[[a-z]+\]/, ""));
124
+
125
+ const content = (line: string): string => stripTerminalSequences(line).replace(/\s+$/, "");
126
+
127
+ function makeProgress(overrides: Partial<SubagentProgress> = {}): SubagentProgress {
128
+ return {
129
+ agent: "reviewer",
130
+ status: "failed",
131
+ task: "review the diff",
132
+ currentTool: undefined,
133
+ currentToolArgs: undefined,
134
+ currentToolStartedAt: undefined,
135
+ toolCount: 2,
136
+ inputTokens: 10,
137
+ outputTokens: 5,
138
+ tokens: 15,
139
+ cost: 0.01,
140
+ durationMs: 1000,
141
+ error: MULTI_LINE_ERROR,
142
+ model: "anthropic/claude-opus-4.5",
143
+ output: undefined,
144
+ recentOutput: undefined,
145
+ toolCalls: undefined,
146
+ ...overrides,
147
+ };
148
+ }
149
+
150
+ /** A failed run: an error and no outcome, which is what `stream.ts` reports. */
151
+ function failedResult(overrides: Partial<SubagentResult> = {}): SubagentResult {
152
+ const result = makeResult({ exitCode: 1, error: MULTI_LINE_ERROR, ...overrides });
153
+ // A failed run claims no outcome — `stream.ts` leaves the field off entirely.
154
+ if (!("outcome" in overrides)) delete result.outcome;
155
+ return result;
156
+ }
157
+
158
+ describe("expanded view: a multi-line error is readable in full (issue #112)", () => {
159
+ /**
160
+ * The guarantee, asserted the same way for all three builders: the error's last
161
+ * line is present, and a `block` carries it. `block` is this repo's mechanism
162
+ * for "read this in full" (`row-block.ts`; issue #112), so the assertion is on
163
+ * the segment kind rather than on the joined text — a change that pushes the
164
+ * error back through `row()` fails here even though the joined text would still
165
+ * contain the line.
166
+ *
167
+ * Order and grouping are asserted too, because a builder that emitted one block
168
+ * per line, or emitted the tail bottom-up, would satisfy the checks above: the
169
+ * tail is exactly one block and its lines are in the order the child wrote them.
170
+ */
171
+ const revealsTheWholeError = (segments: RowSegment[]): void => {
172
+ expect(drawnText(segments)).toContain(LAST_ERROR_LINE);
173
+ expect(
174
+ segments.filter((s) => s.text.includes(LAST_ERROR_LINE)).map((s) => s.kind),
175
+ "the error's last line is inside a row again",
176
+ ).toEqual(["block"]);
177
+ const heads = segments
178
+ .map((s, i) => ({ s, i }))
179
+ .filter(({ s }) => s.kind === "row" && s.text.includes(FIRST_ERROR_LINE));
180
+ expect(heads, "the error's first line is no longer the row it was").toHaveLength(1);
181
+ const tails = segments.filter(
182
+ (s) => s.kind === "block" && TAIL_ERROR_LINES.some((line) => s.text.includes(line)),
183
+ );
184
+ // One block, not one per line: `RowBlock` renders a block by wrapping it, so
185
+ // N blocks for N lines would draw the tail the same at a wide frame and hide
186
+ // the difference at a narrow one (where each block re-wraps on its own).
187
+ expect(tails, "the error's tail is not carried by exactly one block").toHaveLength(1);
188
+ const [tail] = tails;
189
+ expect(
190
+ tail === undefined ? [] : plainLines(tail.text),
191
+ "the error's tail block does not carry its lines in order",
192
+ ).toEqual(TAIL_ERROR_LINES);
193
+ expect(
194
+ segments.indexOf(tail!),
195
+ "the tail block was emitted before the row head, so the error reads bottom-up",
196
+ ).toBeGreaterThan(heads[0]!.i);
197
+ };
198
+
199
+ it("shows every line of a finished run's error, the last one as a block", () => {
200
+ const segments = buildExpandedText(failedResult(), undefined, theme);
201
+ revealsTheWholeError(segments);
202
+ // The marker convention survives: the first line is still the outcome row,
203
+ // with the ✗ error marker in front of it. Pinned exactly, because that row is
204
+ // what the collapsed view draws too — only the tail moves into a block.
205
+ const heads = segments.filter((s) => s.kind === "row" && s.text.includes("✗ "));
206
+ expect(heads).toEqual([{ kind: "row", text: "[error]✗ " + FIRST_ERROR_LINE }]);
207
+ });
208
+
209
+ it("shows every line of a streaming run's error, the last one as a block", () => {
210
+ const rows = new CapturingRowBlock();
211
+ renderProgressExpanded(rows, makeProgress({ status: "failed" }), theme);
212
+ revealsTheWholeError(captured(rows));
213
+ });
214
+
215
+ it("shows every line of a streaming parallel run's error, the last one as a block", () => {
216
+ revealsTheWholeError(buildProgressExpandedText(makeProgress({ status: "failed" }), theme));
217
+ });
218
+
219
+ // Both streaming builders choose this branch on `status === "completed" ||
220
+ // outcome`, and still pass `progress.error` down, where `outcomeSegments` reads
221
+ // `error` before `outcome` — mirroring `buildOutcomeLine` (`format.ts:101`).
222
+ // `stream.ts` only ever sets `error` for a failed run today, but `progress` is
223
+ // also data read back out of a persisted session, and the branch's own contract
224
+ // is "state or outcome, plus whatever error came with it" — so the error has to
225
+ // be revealed here too. Until now it was the one place the finished view's fix
226
+ // had not reached: same run, full error in `buildExpandedText`, one line here.
227
+ it("shows every line of a *completed* streaming run's error, not just a failed one's", () => {
228
+ const rows = new CapturingRowBlock();
229
+ renderProgressExpanded(rows, makeProgress({ status: "completed" }), theme);
230
+ revealsTheWholeError(captured(rows));
231
+ });
232
+
233
+ it("shows every line of a *completed* parallel streaming run's error", () => {
234
+ revealsTheWholeError(buildProgressExpandedText(makeProgress({ status: "completed" }), theme));
235
+ });
236
+
237
+ it("reveals the tail for a still-running progress that already carries an outcome", () => {
238
+ // The `|| progress.outcome` half of the same condition.
239
+ const rows = new CapturingRowBlock();
240
+ renderProgressExpanded(rows, makeProgress({ status: "running", outcome: "truncated" }), theme);
241
+ revealsTheWholeError(captured(rows));
242
+ });
243
+
244
+ it("draws the whole error on screen at a narrow width as well as a wide one", () => {
245
+ // Through the real row component, so this is about what lands on screen and
246
+ // not about the intermediate segments (the style of the width audit).
247
+ const [headLine, ...carriedLines] = MULTI_LINE_ERROR.split("\n");
248
+ for (const width of [200, 120, 80, 40]) {
249
+ const rows = new CapturingRowBlock();
250
+ renderExpanded(rows, failedResult(), undefined, sgrTheme);
251
+ const rendered = rows.render(width);
252
+ const joined = rendered.map(content).join("").replace(/\s+/g, "");
253
+ // Every line the block carries survives the wrap, whole. (The first line
254
+ // stays a row — cut at the frame like every other row, which is why it is
255
+ // not in this list.)
256
+ for (const line of carriedLines) {
257
+ // A wrapped line may break a long line across rows, so compare with the
258
+ // whitespace removed, exactly like the width audit does.
259
+ expect(joined, `line lost at ${width}: ${JSON.stringify(rendered.map(content))}`)
260
+ .toContain(line.replace(/\s+/g, ""));
261
+ }
262
+ // The last line — the one the old cut discarded outright — is on a row of
263
+ // its own rather than smeared across a wrap boundary.
264
+ expect(
265
+ rendered.map(content).some((l) => l.includes(carriedLines[carriedLines.length - 1]!)),
266
+ `last error line missing at ${width}: ${JSON.stringify(rendered.map(content))}`,
267
+ ).toBe(true);
268
+ // And the head is still there, cut the way a row cuts, marker and all.
269
+ expect(rendered.map(content)[0]).toContain("reviewer");
270
+ expect(
271
+ rendered.map(content).some((l) => l.includes("✗ " + headLine!.slice(0, 10))),
272
+ `error head missing at ${width}: ${JSON.stringify(rendered.map(content))}`,
273
+ ).toBe(true);
274
+ for (const line of rendered) {
275
+ expect(visibleWidth(line), `row over the frame at ${width}`).toBeLessThanOrEqual(width);
276
+ }
277
+ }
278
+ });
279
+
280
+ it("closes the error colour on every line it drew, so colour does not bleed", () => {
281
+ // Wide enough that nothing wraps: every logical line is one rendered line, so
282
+ // each one has to carry its own complete SGR pair. Handing `block` the whole
283
+ // `theme.fg("error", "✗ " + error)` string instead would open one span over
284
+ // every line and land the single reset on the last one only — the padding of
285
+ // the earlier lines then sits inside an open span.
286
+ const rows = new CapturingRowBlock();
287
+ renderExpanded(rows, failedResult(), undefined, sgrTheme);
288
+ const drawn = rows.render(200);
289
+ const errorLines = drawn.filter((l) => l.includes(ERROR_SGR));
290
+ expect(errorLines.length).toBe(MULTI_LINE_ERROR.split("\n").length);
291
+ for (const line of errorLines) {
292
+ expect(line.startsWith(ERROR_SGR), `line does not open its own span: ${JSON.stringify(line)}`)
293
+ .toBe(true);
294
+ expect(
295
+ line.trimEnd().endsWith(RESET),
296
+ `line leaves its span open across the padding: ${JSON.stringify(line)}`,
297
+ ).toBe(true);
298
+ }
299
+ });
300
+
301
+ it("leaves a single-line error exactly as it was rendered before", () => {
302
+ // Byte-identical: only the multi-line case changes, so every existing
303
+ // assertion about the outcome row keeps its meaning.
304
+ const failed = makeResult({ exitCode: 1, error: "401 unauthorized" });
305
+ delete failed.outcome;
306
+ const segments = buildExpandedText(failed, undefined, theme);
307
+ const outcome = segments.filter((s) => s.text.includes("401 unauthorized"));
308
+ expect(outcome).toEqual([{ kind: "row", text: "[error]✗ 401 unauthorized" }]);
309
+ // Counted as segments, because a segment's text is already multi-line: the
310
+ // claim is "the error reached the row layer exactly once", which a duplicate
311
+ // (the same line in a row *and* in a block) would fail.
312
+ expect(segments.filter((s) => s.text.includes("401"))).toHaveLength(1);
313
+ });
314
+
315
+ it("draws no blank row for an error that merely ends in a newline", () => {
316
+ // A trailing newline is not a line of content, and opening a block for it
317
+ // would add a blank row to the bottom of every failed run.
318
+ const failed = makeResult({ exitCode: 1, error: "401 unauthorized\n" });
319
+ delete failed.outcome;
320
+ const segments = buildExpandedText(failed, undefined, theme);
321
+ expect(segments.filter((s) => s.kind === "block" && s.text.trim() === "")).toEqual([]);
322
+ // And through the real row component, because the segment layer cannot see
323
+ // the failure this guards against: an emitted `row("")` is invisible in the
324
+ // list above yet `RowBlock` would draw it as a blank bottom row.
325
+ const rows = new CapturingRowBlock();
326
+ renderExpanded(rows, failed, undefined, theme);
327
+ const drawn = rows.render(200).map(content);
328
+ expect(drawn.filter((l) => l.includes("401"))).toHaveLength(1);
329
+ expect(
330
+ drawn[drawn.length - 1],
331
+ `a blank row was drawn under the outcome: ${JSON.stringify(drawn)}`,
332
+ ).toContain("401 unauthorized");
333
+ });
334
+
335
+ it("still lets the error win over the outcome, even when the error is multi-line", () => {
336
+ // Pinned as-is, not decided here: `outcomeSegments` reads `data.error`
337
+ // before the outcome — the same precedence `buildOutcomeLine` gives it — so an
338
+ // error and an outcome together report the error. Both halves of
339
+ // the text have to survive that precedence now that the error is a block.
340
+ const out = drawnText(
341
+ buildExpandedText(failedResult({ outcome: "truncated", finalOutput: "partial" }), undefined, theme),
342
+ );
343
+ expect(out).toContain(FIRST_ERROR_LINE);
344
+ expect(out).toContain(LAST_ERROR_LINE);
345
+ expect(out).not.toMatch(/Truncated/);
346
+ });
347
+
348
+ // The collapsed half of this asymmetry is pinned where that code lives, not here: `compact.test.ts` ("hands over three row segments carrying whole fields, so newlines reach the row cut") and `tool-row.width-audit.test.ts` ("keeps a multiline task or error to three rows per agent (collapsed)").
349
+
350
+ describe("a child that wrote CRLF line endings (a Windows child)", () => {
351
+ // `stream.ts` reads stderr as `Buffer.concat(...).toString("utf-8").trim()` —
352
+ // no newline normalisation — so `\r\n` arrives intact from a Windows child.
353
+ // `pi-tui`'s `wrapTextWithAnsi` splits on `/\r\n|\r|\n/` but `truncateRow`
354
+ // splits on `"\n"`, so a `\r` surviving the split rides inside a `row` and
355
+ // gets drawn: pi-tui does not strip it, and a terminal interpreting a bare CR
356
+ // rewinds the cursor to column 0 and paints the row over itself.
357
+ const CRLF_ERROR = ERROR_LINES.join("\r\n");
358
+
359
+ it("keeps every carriage return out of the segments and off the screen", () => {
360
+ const builders: Array<[string, () => RowSegment[]]> = [
361
+ ["buildExpandedText", () => buildExpandedText(failedResult({ error: CRLF_ERROR }), undefined, theme)],
362
+ ["renderProgressExpanded", () => {
363
+ const rows = new CapturingRowBlock();
364
+ renderProgressExpanded(rows, makeProgress({ status: "completed", error: CRLF_ERROR }), theme);
365
+ return captured(rows);
366
+ }],
367
+ ["buildProgressExpandedText", () => buildProgressExpandedText(makeProgress({ status: "failed", error: CRLF_ERROR }), theme)],
368
+ ];
369
+ for (const [name, build] of builders) {
370
+ const segments = build();
371
+ expect(
372
+ segments.filter((s) => s.text.includes("\r")).map((s) => s.text),
373
+ `${name} left a carriage return inside a segment`,
374
+ ).toEqual([]);
375
+ }
376
+ // Asserted on the RAW rendered lines: `content()` trims trailing whitespace,
377
+ // so it would swallow the very byte this is about.
378
+ for (const width of [200, 80]) {
379
+ const rows = new CapturingRowBlock();
380
+ renderExpanded(rows, failedResult({ error: CRLF_ERROR }), undefined, sgrTheme);
381
+ const rendered = rows.render(width);
382
+ for (const line of rendered) {
383
+ expect(JSON.stringify(line), `a bare CR was drawn at width ${width}`).not.toContain("\\r");
384
+ }
385
+ // Nothing was lost by the split either — every line still reaches the frame.
386
+ const joined = rendered.map(content).join("").replace(/\s+/g, "");
387
+ for (const line of ERROR_LINES) {
388
+ expect(joined, `line lost at ${width}: ${JSON.stringify(rendered.map(content))}`)
389
+ .toContain(line.replace(/\s+/g, ""));
390
+ }
391
+ }
392
+ });
393
+ });
394
+ });
395
+
396
+ // ── The fallback each builder keeps (the legacy-session shape) ──────────────
397
+
398
+ describe("expanded view: a run with nothing to report falls back the way its own builder did", () => {
399
+ // `outcome` is optional (`types.ts:38,70,134`) and only populated from 8cf0d12
400
+ // (#103) on, so both shapes below reach the empty case: a session JSON persisted
401
+ // before it renders the finished view with `status:"completed"` and no outcome,
402
+ // and a progress that reports a terminal state and no outcome renders the two
403
+ // streaming views that way. Each site's fallback is load-bearing there, and they
404
+ // are *not* the same fallback: the finished view says `✗ Failed`, the two
405
+ // streaming views say `✓ Done`. Relabelling a completed run failed is how a
406
+ // useful answer gets thrown away by whoever reads it.
407
+ const saysDoneAndNeverFailed = (text: string): void => {
408
+ expect(text).toContain("✓ Done");
409
+ expect(text).not.toMatch(/Failed/);
410
+ };
411
+
412
+ it("says Done for a finished result that predates the outcome field", () => {
413
+ const legacy = makeResult();
414
+ delete legacy.outcome;
415
+ saysDoneAndNeverFailed(drawnText(buildExpandedText(legacy, undefined, theme)));
416
+ });
417
+
418
+ it("says Done for streaming progress that predates the outcome field", () => {
419
+ const rows = new CapturingRowBlock();
420
+ renderProgressExpanded(rows, makeProgress({ status: "completed", error: undefined }), theme);
421
+ saysDoneAndNeverFailed(drawnText(captured(rows)));
422
+ });
423
+
424
+ it("says Done for parallel streaming progress that predates the outcome field", () => {
425
+ saysDoneAndNeverFailed(
426
+ drawnText(buildProgressExpandedText(makeProgress({ status: "completed", error: undefined }), theme)),
427
+ );
428
+ });
429
+
430
+ it("still names a truncated or aborted streaming run by its outcome, not as failed", () => {
431
+ // The same sites, one step on from the legacy shape: a real outcome has to win
432
+ // over the caller's fallback, and `✓ Done` must not flatten it either.
433
+ for (const outcome of ["truncated", "aborted"] as const) {
434
+ const expected = outcome === "truncated" ? /truncated/i : /aborted/i;
435
+ const rows = new CapturingRowBlock();
436
+ renderProgressExpanded(rows, makeProgress({ status: "completed", outcome, error: undefined }), theme);
437
+ const streaming = drawnText(captured(rows));
438
+ expect(streaming, `renderProgressExpanded flattened ${outcome}`).toMatch(expected);
439
+ expect(streaming).not.toMatch(/Failed|✓ Done/);
440
+ const parallel = drawnText(
441
+ buildProgressExpandedText(makeProgress({ status: "completed", outcome, error: undefined }), theme),
442
+ );
443
+ expect(parallel, `buildProgressExpandedText flattened ${outcome}`).toMatch(expected);
444
+ expect(parallel).not.toMatch(/Failed|✓ Done/);
445
+ }
446
+ });
447
+
448
+ it("falls back to Failed on the finished view when a result reports nothing at all", () => {
449
+ // One door onto the finished view's bottom row: a result with neither an
450
+ // outcome nor an error and a nonzero exit — `buildExpandedText`'s own `else`,
451
+ // which keeps saying Failed because such a run has no success to claim.
452
+ const nothing = makeResult({ exitCode: 1 });
453
+ delete nothing.outcome;
454
+ const out = drawnText(buildExpandedText(nothing, undefined, theme));
455
+ expect(out).toContain("✗ Failed");
456
+ expect(out).not.toMatch(/✓ Done/);
457
+ });
458
+
459
+ it("falls back to Failed — not Done — for an outcome this build cannot name", () => {
460
+ // The other door, and the one that only exists now the fallback is the
461
+ // caller's: `outcome` set to something `buildOutcomeLine` has no case for (a
462
+ // session written by a newer build, or a widened *Run outcome* axis) leaves it
463
+ // returning `undefined`, and the segment `buildExpandedText` passes in is what
464
+ // gets drawn. It stays Failed — a run whose ending this code cannot read is
465
+ // not a success this code may claim — which is the byte this site rendered
466
+ // before the fallback moved, so moving it changed nothing here.
467
+ const unknown = makeResult({ exitCode: 1, outcome: "detached" as RunOutcome });
468
+ const out = drawnText(buildExpandedText(unknown, undefined, theme));
469
+ expect(out).toContain("✗ Failed");
470
+ expect(out).not.toMatch(/✓ Done/);
471
+ });
472
+ });
package/src/expanded.ts CHANGED
@@ -6,12 +6,18 @@ import { renderToolCallLine } from "@pi-archimedes/core/tool-render";
6
6
  type Theme = { fg: (token: string, text: string) => string; bold: (text: string) => string };
7
7
 
8
8
  // The expanded view mixes two kinds of line and says which is which (see
9
- // `row-block.ts`): a **row** (label, activity, outcome) stays one line and fills
10
- // the frame; a **block** (the stats line, the task text, the tool-call history,
11
- // the output) keeps wrapping, because the expanded view is where all of it is
12
- // meant to be readable (ADR 0020). The stats line is a block and not a row
13
- // because nothing else repeats its numbers: cut at the frame, its tail — tokens,
14
- // duration, cost — would simply be gone on a narrow terminal.
9
+ // `row-block.ts`): a **row** (label, activity, the first line of an outcome) stays
10
+ // one line and fills the frame; a **block** (the stats line, the task text, the
11
+ // tool-call history, the output, the lines of an error after the first) keeps
12
+ // wrapping, because the expanded view is where all of it is meant to be readable
13
+ // (issue #112; its counterpart — the collapsed view cutting at the first line — is
14
+ // pinned in `tool-row.width-audit.test.ts`). The stats line is a block and not a
15
+ // row so the view reads on its own, without the reader collapsing back to the row
16
+ // to get the numbers back. They do live elsewhere too — `compact.ts` builds the
17
+ // identical `buildStatsLine`, `formatParallelResults` puts tools / tokens /
18
+ // seconds in the model-facing content, and `usage` reaches pi's `/session` — but
19
+ // every row is cut at the frame, so the tail of the stats (tokens, duration,
20
+ // cost) is exactly what a narrow terminal would drop from this view.
15
21
 
16
22
  // ── Helpers ─────────────────────────────────────────────────────────────────
17
23
 
@@ -27,18 +33,74 @@ function formatToolCall(call: SubagentToolCall | string, theme: Theme): string {
27
33
  return renderToolCallLine(status, call.name, suffix, theme);
28
34
  }
29
35
 
30
- // The bottom row of a finished view. The *Run outcome* wins over the done/failed
36
+ // The bottom of an expanded view. The *Run outcome* wins over the done/failed
31
37
  // pair: a truncated answer is not a failed run, and labelling it "✗ Failed" is
32
38
  // how a useful answer gets thrown away by whoever reads it.
33
- function outcomeRow(
39
+ //
40
+ // An error is often stderr or a thrown Error's message — the second kind never
41
+ // touched a stream (`dispatch.ts`'s `buildFailedResult` carries `err.message`, and
42
+ // `stream.ts` synthesises strings like "subagent failed with exit code N"). Either
43
+ // way it arrives with newlines in it, and as one `row` the view could only ever
44
+ // show its first line: `truncateRow` treats a newline as a hard boundary (see
45
+ // `row-block.ts`), which is right for the collapsed view (the three-rows-per-agent
46
+ // shape is pinned in `tool-row.width-audit.test.ts`) and wrong here: the expanded
47
+ // view is where everything is meant to be readable (issue #112), and a stack
48
+ // trace's last line is usually the informative one. So the first line stays the
49
+ // row it was, and the lines after it are handed over as one `block` — this repo's
50
+ // existing mechanism for "read this in full".
51
+ //
52
+ // The raw error is split *before* anything is coloured, and every line is
53
+ // coloured on its own. `theme.fg("error", "✗ " + error)` wraps the whole
54
+ // multi-line string in one span whose reset lands on the last line only, so
55
+ // every earlier line would be drawn — padding included — inside an open span.
56
+ // Measured against `wrapTextWithAnsi`: it re-opens the active codes at the start
57
+ // of a continuation line but never invents a reset at the end of one.
58
+ function outcomeSegments(
34
59
  data: { outcome?: RunOutcome | undefined; error: string | undefined },
35
60
  theme: Theme,
36
- fallback: { failed: string },
37
- ): string {
38
- const line = buildOutcomeLine({ outcome: data.outcome, error: data.error }, theme);
39
- if (line) return line;
40
- if (data.error) return theme.fg("error", "✗ " + data.error);
41
- return theme.fg("error", "✗ " + fallback.failed);
61
+ // The segment to draw when there is nothing to report, rendered by the caller
62
+ // rather than decided here, because the five call sites do not agree on it — and
63
+ // they disagree per *branch*, not per builder: the finished view's single call
64
+ // says `✗ Failed` (an `outcome` this build cannot name is not a success this
65
+ // build may claim), while each streaming builder passes `✓ Done` from its
66
+ // completed branch and `✗ Failed` from its failed one. So "nothing to report"
67
+ // means something different at each site, and the `✓ Done` ones are why the
68
+ // answer cannot be baked in here: `outcome` is optional on `SubagentProgress`
69
+ // (`types.ts:38`; `SubagentResult` at `:70`, `SubagentRunReport` at `:134`), so a
70
+ // run can report a terminal status with no outcome at all — every session
71
+ // persisted before #103 / 8cf0d12 added the field — and a helper-wide
72
+ // `✗ Failed` there would relabel a finished run a broken one.
73
+ fallback: RowSegment,
74
+ ): RowSegment[] {
75
+ // Same precedence as `buildOutcomeLine` (`format.ts:101`): `error` is read first,
76
+ // so a run that carries both reports the error and not the outcome. Pinned in
77
+ // `expanded.test.ts` rather than re-decided here. It is also why neither streaming
78
+ // branch may assume it has no error in hand: each opens on a completed *status* or
79
+ // on any reported outcome, and either can arrive together with a multi-line one,
80
+ // so a completed progress's error gets the same head-row-then-block treatment here
81
+ // that the failed branch gives its own (#112).
82
+ if (data.error) {
83
+ // `/\r\n|\r|\n/`, not `"\n"`: `stream.ts` reads stderr as
84
+ // `Buffer.concat(...).toString("utf-8").trim()` with no newline
85
+ // normalisation, so a Windows child's `\r\n` arrives intact. pi-tui's
86
+ // `wrapTextWithAnsi` splits on all three endings but `truncateRow` splits on
87
+ // `"\n"`, so a `\r` left inside a row would be *drawn*, and a terminal that
88
+ // honours a bare CR repaints the row from column 0.
89
+ const [head, ...tail] = data.error.split(/\r\n|\r|\n/);
90
+ const segments: RowSegment[] = [row(theme.fg("error", "✗ " + head))];
91
+ // A tail of nothing but blanks gets no block: a synthesised error, or any
92
+ // producer that does not `trim()` its stderr, can end in a newline, and
93
+ // opening a block for that would add a blank row under every failed run.
94
+ if (tail.some((line) => line.trim() !== "")) {
95
+ segments.push(block(tail.map((line) => (line ? theme.fg("error", line) : "")).join("\n")));
96
+ }
97
+ return segments;
98
+ }
99
+ const line = buildOutcomeLine({ outcome: data.outcome, error: undefined }, theme);
100
+ // The outcome vocabulary stays `buildOutcomeLine`'s and this helper still owns
101
+ // the row decision; only "nothing to report" hands back the segment the caller
102
+ // rendered for itself.
103
+ return [line === undefined ? fallback : row(line)];
42
104
  }
43
105
 
44
106
  // ── Expanded completed result ───────────────────────────────────────────────
@@ -65,9 +127,9 @@ export function buildExpandedText(
65
127
  }, theme);
66
128
  const statsParts = [modelLabel, statsLine].filter(Boolean);
67
129
  if (statsParts.length > 0) {
68
- // A block, not a row: this is the only place the run's numbers appear, and
69
- // the expanded view is where they are meant to be readable however narrow
70
- // the frame is (ADR 0020).
130
+ // A block, not a row: the expanded view has to read on its own, so the
131
+ // numbers cannot depend on the reader collapsing the row to get them back
132
+ // however narrow the frame is.
71
133
  lines.push(block(statsParts.join(" ")));
72
134
  }
73
135
 
@@ -90,6 +152,11 @@ export function buildExpandedText(
90
152
  const outputText = progress?.output ?? result.finalOutput;
91
153
  if (outputText) {
92
154
  lines.push(row(""));
155
+ // A block, uncapped: ADR 0020 is the precedent that this package does not cap
156
+ // output where the reader has no other source for it. Read it precisely — ADR
157
+ // 0020 governs the *tool-result content* the model reads, not this view — and
158
+ // the same reasoning carries over: the answer shown here is the child's, and
159
+ // nothing else spells it out for whoever is reading the transcript.
93
160
  lines.push(block(outputText));
94
161
  }
95
162
 
@@ -97,7 +164,20 @@ export function buildExpandedText(
97
164
  if (result.error || result.outcome || result.exitCode === 0) {
98
165
  lines.push(row(""));
99
166
  if (result.outcome || result.error) {
100
- lines.push(row(outcomeRow({ outcome: result.outcome, error: result.error }, theme, { failed: "Failed" })));
167
+ // The fallback covers exactly one case here: an `outcome` this build cannot
168
+ // name, which leaves `buildOutcomeLine` with nothing to return. It is
169
+ // Failed, because a run whose ending this code cannot read is not a success
170
+ // this code may claim. The never-settled legacy shape — no outcome, no
171
+ // error, a non-zero exit — is not this fallback's: the `else` below prints
172
+ // it. The streaming builders' completed branch passes ✓ Done instead (see
173
+ // `outcomeSegments`' third parameter for why it is not decided here).
174
+ lines.push(
175
+ ...outcomeSegments(
176
+ { outcome: result.outcome, error: result.error },
177
+ theme,
178
+ row(theme.fg("error", "✗ Failed")),
179
+ ),
180
+ );
101
181
  } else {
102
182
  lines.push(row(theme.fg("success", "✓ Done")));
103
183
  }
@@ -170,14 +250,25 @@ export function renderProgressExpanded(
170
250
  lines.push(row(renderToolCallLine("running", progress.currentTool, suffix, theme)));
171
251
  }
172
252
 
173
- // Status at bottom
253
+ // Status at bottom. Which fallback each branch hands the helper, and why the
254
+ // helper is not the one to decide it, is written once above `outcomeSegments`.
174
255
  if (progress.status === "completed" || progress.outcome) {
175
256
  lines.push(row(""));
176
- const outcomeLine = buildOutcomeLine({ outcome: progress.outcome, error: progress.error }, theme);
177
- lines.push(row(outcomeLine ?? theme.fg("success", "✓ Done")));
257
+ // `✓ Done`: this branch's agent reached a terminal state, so a run with nothing
258
+ // to name its outcome by is finished work here — the ✗ below would bury it.
259
+ lines.push(...outcomeSegments(
260
+ { outcome: progress.outcome, error: progress.error },
261
+ theme,
262
+ row(theme.fg("success", "✓ Done")),
263
+ ));
178
264
  } else if (progress.status === "failed") {
179
265
  lines.push(row(""));
180
- lines.push(row(outcomeRow({ outcome: undefined, error: progress.error }, theme, { failed: "Failed" })));
266
+ // The only branch here that may call a run broken, so it passes `✗ Failed`.
267
+ lines.push(...outcomeSegments(
268
+ { outcome: undefined, error: progress.error },
269
+ theme,
270
+ row(theme.fg("error", "✗ Failed")),
271
+ ));
181
272
  }
182
273
 
183
274
  rows.setSegments(lines);
@@ -240,14 +331,27 @@ export function buildProgressExpandedText(
240
331
  lines.push(row(line));
241
332
  }
242
333
 
243
- // Status at bottom
334
+ // Status at bottom — the same pair of branches `renderProgressExpanded` draws for
335
+ // the single running agent, here as one block of the parallel view. What each
336
+ // branch passes, and why the helper cannot decide it, is written once above
337
+ // `outcomeSegments`.
244
338
  if (progress.status === "completed" || progress.outcome) {
245
339
  lines.push(row(""));
246
- const outcomeLine = buildOutcomeLine({ outcome: progress.outcome, error: progress.error }, theme);
247
- lines.push(row(outcomeLine ?? theme.fg("success", "✓ Done")));
340
+ // `✓ Done`, and never the failed branch's segment: this block claims its agent
341
+ // finished, and a batch read as failed is a batch whose answers get discarded.
342
+ lines.push(...outcomeSegments(
343
+ { outcome: progress.outcome, error: progress.error },
344
+ theme,
345
+ row(theme.fg("success", "✓ Done")),
346
+ ));
248
347
  } else if (progress.status === "failed") {
249
348
  lines.push(row(""));
250
- lines.push(row(outcomeRow({ outcome: undefined, error: progress.error }, theme, { failed: "Failed" })));
349
+ // `✗ Failed`: this branch got a broken run, the only one allowed to say so.
350
+ lines.push(...outcomeSegments(
351
+ { outcome: undefined, error: progress.error },
352
+ theme,
353
+ row(theme.fg("error", "✗ Failed")),
354
+ ));
251
355
  }
252
356
 
253
357
  return lines;
package/src/index.test.ts CHANGED
@@ -484,3 +484,83 @@ describe("dispatch: cost is reported natively, not on the bus", () => {
484
484
  expect(busEmits.filter(([channel]) => channel === Events.COST_UPDATE)).toEqual([]);
485
485
  });
486
486
  });
487
+
488
+ // The shape handed to the executor, pinned as data.
489
+ //
490
+ // `index.ts` maps each incoming task into the object `executeParallel` receives
491
+ // (and builds the single equivalent for `executeSubagent`). That map is the only
492
+ // place a per-task `cwd` or `model` reaches the child, and it had no guard: the
493
+ // tests above drive both branches through the mocked executors and assert the
494
+ // outcome, never the argument, so deleting a forwarded key stayed green
495
+ // everywhere. This is the behavioural replacement for the source-text scanner
496
+ // deleted in the same branch: it pins what is actually handed over by running the
497
+ // tool, instead of parsing `index.ts` and risking a red run on an unrelated edit.
498
+ // It is a narrower claim than the scanner's — it says nothing about whether the
499
+ // executor later reads a key it is given — see the header of
500
+ // `tool-schema.test.ts`, which states the residue that leaves uncovered.
501
+ describe("dispatch: the object forwarded to the executor", () => {
502
+ it("forwards the full per-task shape to executeParallel", async () => {
503
+ discoverAgents.mockReturnValue([agentConfig()]);
504
+ await subagent(register()).execute(
505
+ "c",
506
+ { tasks: [{ agent: "reviewer", task: "a", model: "anthropic/claude-opus-4.5", cwd: "/tmp/work" }] },
507
+ undefined,
508
+ undefined,
509
+ makeCtx(),
510
+ );
511
+
512
+ expect(executeParallel).toHaveBeenCalledTimes(1);
513
+ const calls = executeParallel.mock.calls as unknown as Array<[{ tasks: Array<Record<string, unknown>> }]>;
514
+ const forwarded = calls[0]![0].tasks[0]!;
515
+ // Exact and sorted, so it fails in both directions: dropping a forward
516
+ // (`cwd`, `model`, …) silently loses a documented per-task option, and
517
+ // adding one changes the executor's input contract.
518
+ expect(Object.keys(forwarded).sort()).toEqual([
519
+ "activeModel",
520
+ "agent",
521
+ "agentConfig",
522
+ "cwd",
523
+ "model",
524
+ "task",
525
+ "toolCallId",
526
+ ]);
527
+ // The key set alone would still pass with the values swapped or dropped to
528
+ // undefined, so the two the #114 discussion cares about are pinned by value.
529
+ expect(forwarded.cwd).toBe("/tmp/work");
530
+ expect(forwarded.model).toBe("anthropic/claude-opus-4.5");
531
+ expect(forwarded.task).toBe("a");
532
+ expect(forwarded.toolCallId).toBe("c");
533
+ });
534
+
535
+ it("forwards the same per-task shape to executeSubagent on the single-run path", async () => {
536
+ discoverAgents.mockReturnValue([agentConfig()]);
537
+ await subagent(register()).execute(
538
+ "c",
539
+ { agent: "reviewer", task: "t", model: "anthropic/claude-opus-4.5", cwd: "/tmp/work" },
540
+ undefined,
541
+ undefined,
542
+ makeCtx(),
543
+ );
544
+
545
+ expect(executeSubagent).toHaveBeenCalledTimes(1);
546
+ const calls = executeSubagent.mock.calls as unknown as Array<[Record<string, unknown>]>;
547
+ const forwarded = calls[0]![0];
548
+ // The single path takes its arguments flat rather than nested under `tasks`,
549
+ // so its key set differs from the parallel item's by exactly that: no
550
+ // nesting, and `signal` / `events` / `onUpdate` carried per run.
551
+ expect(Object.keys(forwarded).sort()).toEqual([
552
+ "activeModel",
553
+ "agent",
554
+ "agentConfig",
555
+ "cwd",
556
+ "events",
557
+ "model",
558
+ "onUpdate",
559
+ "signal",
560
+ "task",
561
+ "toolCallId",
562
+ ]);
563
+ expect(forwarded.cwd).toBe("/tmp/work");
564
+ expect(forwarded.model).toBe("anthropic/claude-opus-4.5");
565
+ });
566
+ });
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Text, type TUI } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
- import { SUBAGENT_PARAMS_SCHEMA, SUBAGENT_OUTPUT_SCHEMA, subagentStructuredContent } from "./tool-schema.js";
4
+ import { SUBAGENT_PARAMS_SCHEMA, SUBAGENT_OUTPUT_SCHEMA, subagentStructuredContent, type SubagentParams } from "./tool-schema.js";
5
5
  import { executeSubagent, executeParallel } from "./execute.js";
6
6
  import { formatParallelResults, formatSingleResult } from "./format.js";
7
7
  // agent-manager.js lazy-loaded below to keep subagent tool registration fast
@@ -71,14 +71,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
71
71
 
72
72
  async execute(
73
73
  _id: string,
74
- params: {
75
- agent?: string;
76
- task?: string;
77
- tasks?: Array<{ agent?: string; task: string; count?: number; model?: string; cwd?: string }>;
78
- model?: string;
79
- cwd?: string;
80
- async?: boolean;
81
- },
74
+ params: SubagentParams,
82
75
  signal: AbortSignal | undefined,
83
76
  onUpdate: ((update: SubagentToolResult) => void) | undefined,
84
77
  ctx: ExtensionContext,
@@ -14,12 +14,11 @@
14
14
  */
15
15
 
16
16
  import { describe, it, expect } from "vitest";
17
- import { Text, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
18
- import { RowBlock, block, row, truncateRow } from "./row-block.js";
17
+ import { Box, Text, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
18
+ import { RowBlock, block, row, truncateRow, RESET } from "./row-block.js";
19
19
 
20
- const dim = (t: string) => `\x1b[2m${t}\x1b[0m`;
21
- const bold = (t: string) => `\x1b[1m${t}\x1b[0m`;
22
- const RESET = "\x1b[0m";
20
+ const dim = (t: string) => `\x1b[2m${t}${RESET}`;
21
+ const bold = (t: string) => `\x1b[1m${t}${RESET}`;
23
22
 
24
23
  const drawn = (line: string) => visibleWidth(stripTerminalSequences(line).replace(/\s+$/, ""));
25
24
 
@@ -93,6 +92,42 @@ describe("truncateRow", () => {
93
92
  }
94
93
  });
95
94
 
95
+ it("never emits a full SGR reset (\\x1b[0m) that would kill a container Box background", () => {
96
+ // Both when cut on width and when cut on a newline
97
+ const cutOnWidth = truncateRow(dim(LONG), 40);
98
+ expect(cutOnWidth.includes("\x1b[0m")).toBe(false);
99
+ expect(cutOnWidth.includes("\x1b[m")).toBe(false);
100
+
101
+ const cutOnNewline = truncateRow("general: Check status\nsecond line", 80);
102
+ expect(cutOnNewline.includes("\x1b[0m")).toBe(false);
103
+ expect(cutOnNewline.includes("\x1b[m")).toBe(false);
104
+
105
+ // Embedded \x1b[0m is safely converted to style reset
106
+ const embedded = truncateRow("\x1b[31merror\x1b[0m: details", 80);
107
+ expect(embedded.includes("\x1b[0m")).toBe(false);
108
+ expect(embedded.includes("\x1b[m")).toBe(false);
109
+ });
110
+
111
+ it("preserves an enclosing Box background across the entire row including padding", () => {
112
+ const bgFn = (t: string) => `\x1b[48;2;33;34;44m${t}\x1b[49m`;
113
+ const box = new Box(1, 1, bgFn);
114
+ const rows = new RowBlock();
115
+ rows.setSegments([
116
+ row("general: Check the status of PR #23 on danielcherubini/archimedes:\nsomething else"),
117
+ ]);
118
+ box.addChild(rows);
119
+ const lines = box.render(100);
120
+ // Line 1 is the content row
121
+ const contentLine = lines[1]!;
122
+ expect(contentLine).toBeDefined();
123
+ // Background escape was applied at the start and closed at the end
124
+ expect(contentLine.startsWith("\x1b[48;2;33;34;44m")).toBe(true);
125
+ expect(contentLine.endsWith("\x1b[49m")).toBe(true);
126
+ // No full reset in the middle that would kill the background before the padding
127
+ expect(contentLine.includes("\x1b[0m")).toBe(false);
128
+ expect(contentLine.includes("\x1b[m")).toBe(false);
129
+ });
130
+
96
131
  it("survives widths with no room for an ellipsis", () => {
97
132
  expect(truncateRow(LONG, 3)).toBe("...");
98
133
  expect(truncateRow(LONG, 2)).toBe("..");
@@ -192,4 +227,18 @@ describe("RowBlock", () => {
192
227
  const lines = rows.render(60);
193
228
  for (const line of lines) expect(padTo(line, 60)).toBe(line);
194
229
  });
230
+
231
+ it("sanitizes embedded \\x1b[0m in block segments to RESET when rendered and padded", () => {
232
+ const rows = new RowBlock();
233
+ rows.setSegments([block("\x1b[31merror message\x1b[0m: line one\nsecond line with \x1b[32msuccess\x1b[m details")]);
234
+ const lines = rows.render(80);
235
+ expect(lines.length).toBe(2);
236
+ for (const line of lines) {
237
+ expect(line.includes("\x1b[0m")).toBe(false);
238
+ expect(line.includes("\x1b[m")).toBe(false);
239
+ expect(visibleWidth(line)).toBe(80);
240
+ }
241
+ expect(lines[0]!).toContain(`\x1b[31merror message${RESET}: line one`);
242
+ expect(lines[1]!).toContain(`second line with \x1b[32msuccess${RESET} details`);
243
+ });
195
244
  });
package/src/row-block.ts CHANGED
@@ -27,8 +27,28 @@ import { Text, sliceByColumn, visibleWidth, wrapTextWithAnsi } from "@earendil-w
27
27
  /** The marker appended to a row that was cut. Plain text, so it inherits colour. */
28
28
  export const ROW_ELLIPSIS = "...";
29
29
 
30
- /** SGR reset, appended after a cut so the row's padding is not painted. */
31
- const RESET = "\x1b[0m";
30
+ /**
31
+ * SGR reset for styled rows: resets foreground color and font styles (bold,
32
+ * dim, italic, underline, reverse video, strikethrough), but never resets the
33
+ * background color (SGR 49).
34
+ *
35
+ * Inside pi's tool execution component, rows are rendered inside a Box that
36
+ * applies a panel background (e.g. toolSuccessBg / toolPendingBg) across the
37
+ * entire row: `theme.bg(token, paddedLine)`. A full SGR reset (`\x1b[0m` or
38
+ * `\x1b[m`) cancels the Box-painted background mid-line, causing all subsequent
39
+ * padding spaces and trailing content to fall through to the terminal's default
40
+ * background (appearing as an unsightly mid-grey or mismatched bar after
41
+ * truncated text or early newlines).
42
+ */
43
+ export const RESET = "\x1b[22;23;24;27;29;39m";
44
+
45
+ /**
46
+ * Replace any full SGR resets (\x1b[0m or \x1b[m) with a style-and-foreground reset
47
+ * so the line never kills an enclosing container's background color mid-line.
48
+ */
49
+ export function sanitizeRowResets(line: string): string {
50
+ return line.replace(/\x1b\[0?m/g, RESET);
51
+ }
32
52
 
33
53
  /** How `Text` renders a tab; kept identical so short rows stay byte-identical. */
34
54
  const TAB_WIDTH = 3;
@@ -82,13 +102,16 @@ export function truncateRow(line: string, width: number): string {
82
102
  if (width <= 0) return "";
83
103
  const nlIdx = line.indexOf("\n");
84
104
  const firstLine = nlIdx === -1 ? line : line.slice(0, nlIdx);
85
- if (nlIdx === -1 && visibleWidth(firstLine) <= width) return firstLine;
105
+ if (nlIdx === -1 && visibleWidth(firstLine) <= width) {
106
+ return sanitizeRowResets(firstLine);
107
+ }
86
108
 
87
109
  const budget = width - ROW_ELLIPSIS.length;
88
110
  // No room for both content and dots: show as many dots as the frame holds, so
89
111
  // the row still reads as "cut" rather than as "the whole task".
90
112
  if (budget <= 0) return sliceByColumn(ROW_ELLIPSIS, 0, width, true);
91
- return sliceByColumn(firstLine, 0, budget, true) + ROW_ELLIPSIS + RESET;
113
+ const sliced = sliceByColumn(firstLine, 0, budget, true);
114
+ return sanitizeRowResets(sliced + ROW_ELLIPSIS) + RESET;
92
115
  }
93
116
 
94
117
  /** Pad a rendered line out to the frame, the way `Text` does. */
@@ -175,7 +198,7 @@ export class RowBlock extends Text {
175
198
  const text = segment.text.replace(/\t/g, " ".repeat(TAB_WIDTH));
176
199
  if (segment.kind === "block") {
177
200
  for (const wrapped of wrapTextWithAnsi(text, width)) {
178
- lines.push(padToWidth(wrapped, width));
201
+ lines.push(padToWidth(sanitizeRowResets(wrapped), width));
179
202
  }
180
203
  } else {
181
204
  lines.push(padToWidth(truncateRow(text, width), width));
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Issue #114 — the parameter set `subagent` declares is the whole contract, so
3
+ * every name in it must be a name `execute` actually reads.
4
+ *
5
+ * #114 deleted a declared `async` boolean ("Run asynchronously
6
+ * (fire-and-forget)") that nothing read: a model that passed `async: true` got a
7
+ * fully synchronous run with no warning. That is a false statement in a
8
+ * model-facing contract, and worse than a missing feature, because the contract
9
+ * *is* what the model believes about the tool — it is what gets rendered into
10
+ * the tool list, what the model plans around, what a wrong answer gets blamed
11
+ * on. Deleting the claim is the fix; implementing fire-and-forget is out of
12
+ * scope and blocked on #113 (reaping subagent children across session
13
+ * replacement).
14
+ *
15
+ * The review that followed found a SECOND instance of the same defect class the
16
+ * issue never named: `count?: number` on the parallel task item, read nowhere.
17
+ * It differed from `async` in one instructive way: it lived only in the
18
+ * hand-written `params` type on `execute` in `index.ts`, never in `TaskItem`, so
19
+ * the JSON Schema the model sees never advertised it and a guard that enumerates
20
+ * the schema alone could not have caught it. The root cause of both was one
21
+ * contract stated twice — once for the model, once for TypeScript — with nothing
22
+ * keeping the two in step. So the fix is structural rather than another
23
+ * assertion: `SubagentParams` in `tool-schema.ts` is
24
+ * `Static<typeof SUBAGENT_PARAMS_SCHEMA>` and `execute` takes that, which is the
25
+ * `ask` / `sudo` precedent. There is no second list left to drift on its own, so
26
+ * the only low-friction way to introduce a `count` is to say so at one of the
27
+ * two surfaces a reviewer reads: the schema, or `execute`'s signature.
28
+ *
29
+ * What is guarded where, stated honestly:
30
+ *
31
+ * - the schema's declared sets are pinned below, exactly and enumerated from
32
+ * the schema object rather than restated;
33
+ * - there is no second TS-side list left for a test to police. `tsc` does
34
+ * reject a params type that *contradicts* the schema — `model?: number` at
35
+ * the params position fails `registerTool`'s `execute` with TS2322 — but
36
+ * that check is neither new nor the derivation's doing: `registerTool`
37
+ * infers `TParams` from `parameters` and declares `execute`'s `params` as
38
+ * `Static<TParams>` (`pi-coding-agent` `dist/core/extensions/types.d.ts:514`),
39
+ * so the same TS2322 fires with or without `SubagentParams` (measured against
40
+ * the pre-derivation literal, and it failed there too).
41
+ * - what `tsc` never caught, and still does not, is the phantom itself. An
42
+ * extra *optional* member only widens the params type, and pi declares
43
+ * `execute` as a method, so method parameters are bivariant: `count?: number`
44
+ * in the old literal, in `TaskItem` inside that literal, and spliced on as
45
+ * `params: SubagentParams & { count?: number }` all compile (each measured).
46
+ * So the derivation removes the *place* a phantom hid — it does not add a
47
+ * compiler check that would catch one. Writing a phantom now takes a
48
+ * deliberate `& { … }` at the params position, an edit a reviewer sees, where
49
+ * before it was the default way to state the contract.
50
+ * - the residual gap, stated against what this branch removed rather than
51
+ * against the compiler. The ~370-line source-text scanner deleted in
52
+ * `ca1a328` DID catch a declared-and-unread task field: it parsed
53
+ * `index.ts` as text, derived the task fields the parallel branch reads
54
+ * (`readTaskFields()`) and compared them against
55
+ * `declaredTaskItemParams()`, pinning both directions — declared and not
56
+ * read, and read and not declared. So type erasure was never what defeated
57
+ * that guard: it read the file, and a test over source text does see what
58
+ * the body reads. It was deleted for its false-positive cost, not because it
59
+ * did not work — it could redden on an unrelated `index.ts` edit (Greptile's
60
+ * P2 on PR #143), and that brittleness is why the maintainer took the
61
+ * schema-shared-type option instead. Losing it has a real price: on the
62
+ * task-item surface a schema field that `execute` declares but ignores is
63
+ * once again undetectable here, which is exactly how `async` survived. The
64
+ * cheap, non-brittle replacement is a BEHAVIOURAL pin, not a second parser:
65
+ * `index.test.ts` ("the object forwarded to the executor") now asserts the
66
+ * exact key set handed to `executeParallel` / `executeSubagent`, so dropping
67
+ * a forwarded task field — or adding one nobody declared — fails a test. Its
68
+ * limit, stated rather than sold: it pins the shape handed over, so a schema
69
+ * field that `execute` never forwards at all is invisible to it, and it
70
+ * cannot prove the executor reads a key it is handed. That residue is the
71
+ * phantom class, and it is genuinely uncovered again. What still covers it is
72
+ * the two pins below, a schema small enough to read in one screen — five
73
+ * top-level parameters, four task fields — and review.
74
+ *
75
+ * If a name reappears here it has to arrive together with the behaviour that
76
+ * honours it (#113 first, for `async`) and a consumer that reads it; until then
77
+ * the honest description is the one that does not mention it.
78
+ */
79
+
80
+ import { describe, it, expect } from "vitest";
81
+ import { Value } from "typebox/value";
82
+ import { SUBAGENT_PARAMS_SCHEMA } from "./tool-schema.js";
83
+
84
+ /** Read a schema node's declared property names, failing loudly if it has none. */
85
+ function propertyNames(node: unknown, what: string): string[] {
86
+ const properties = (node as { properties?: Record<string, unknown> }).properties;
87
+ if (!properties) throw new Error(`${what} declares no properties`);
88
+ return Object.keys(properties);
89
+ }
90
+
91
+ /** The top-level parameters `subagent` declares. Read from the schema, never restated. */
92
+ function declaredParams(): string[] {
93
+ return propertyNames(SUBAGENT_PARAMS_SCHEMA, "SUBAGENT_PARAMS_SCHEMA");
94
+ }
95
+
96
+ /**
97
+ * The properties declared for ONE entry of the `tasks` array — `TaskItem`.
98
+ * Reached through the schema rather than exported from `tool-schema.ts`, so the
99
+ * guard depends on the contract the model actually receives and the module
100
+ * keeps its current public surface (`TaskItem` is deliberately unexported).
101
+ */
102
+ function declaredTaskItemParams(): string[] {
103
+ const tasks = (SUBAGENT_PARAMS_SCHEMA as { properties?: Record<string, unknown> }).properties?.tasks;
104
+ if (!tasks) throw new Error("SUBAGENT_PARAMS_SCHEMA declares no `tasks` property");
105
+ const items = (tasks as { items?: unknown }).items;
106
+ if (!items) throw new Error("`tasks` is not an array schema — no item schema to enumerate");
107
+ return propertyNames(items, "The `tasks` item schema (TaskItem)");
108
+ }
109
+
110
+ describe("SUBAGENT_PARAMS_SCHEMA — declared parameter set (issue #114)", () => {
111
+ it("pins the five declared parameters", () => {
112
+ // Exact and sorted, so it fails in BOTH directions: adding a name the pin
113
+ // does not know (`async` would) and deleting one of the five. Read from the
114
+ // schema object, never restated by hand. And because `SubagentParams` is
115
+ // derived, adding a name here is also adding it to what `execute` is handed,
116
+ // so this pin is where a new name has to be justified. What it does NOT
117
+ // check is whether anything reads the name — that cross-surface claim
118
+ // belonged to the scanner described in the header and no longer exists, so
119
+ // the name says only what the body proves.
120
+ expect([...declaredParams()].sort()).toEqual(["agent", "cwd", "model", "task", "tasks"]);
121
+ });
122
+ });
123
+
124
+ describe("TaskItem — declared parallel task item (issue #114, second instance: `count`)", () => {
125
+ it("pins the four declared task-item properties", () => {
126
+ // `count` was the proof this surface needs its own pin: a phantom can be
127
+ // added to the task item while the top-level set above stays green. Like the
128
+ // pin above this one enumerates the SCHEMA only — with the scanner gone
129
+ // nothing here compares the four against what the parallel branch reads, so
130
+ // the name claims the enumeration and no more.
131
+ expect([...declaredTaskItemParams()].sort()).toEqual(["agent", "cwd", "model", "task"]);
132
+ });
133
+ });
134
+
135
+ // Legacy-caller compatibility check — a real behavioural guarantee, so it is
136
+ // pinned rather than assumed. TypeBox `Type.Object` does not set
137
+ // `additionalProperties: false`, so deleting `async` — and deleting `count` from
138
+ // the TS type, which the schema never advertised anyway — stops promising the
139
+ // option without turning an existing caller's payload into a validation error: a
140
+ // caller still passing `async: true`, or a `count` on a task, keeps validating, it
141
+ // just no longer gets a promise about it. That is what makes both deletions safe
142
+ // to land in a patch release, and it is the one thing here that fails on its own
143
+ // if anyone ever tightens the schema with `additionalProperties: false`.
144
+ describe("SUBAGENT_PARAMS_SCHEMA — legacy callers passing the deleted fields still validate", () => {
145
+ it("accepts a payload carrying `async`", () => {
146
+ expect(Value.Check(SUBAGENT_PARAMS_SCHEMA as never, { task: "x", async: true })).toBe(true);
147
+ });
148
+
149
+ it("accepts a task item carrying `count`", () => {
150
+ expect(
151
+ Value.Check(SUBAGENT_PARAMS_SCHEMA as never, { tasks: [{ task: "x", count: 1 }] }),
152
+ ).toBe(true);
153
+ });
154
+ });
@@ -1,4 +1,4 @@
1
- import { Type } from "typebox";
1
+ import { Type, type Static } from "typebox";
2
2
  import type { SubagentResult, SubagentRunReport } from "./types.js";
3
3
 
4
4
  // ── JSON Schema for tool parameters (TypeBox) ──────────────────────────────
@@ -25,14 +25,31 @@ export const SUBAGENT_PARAMS_SCHEMA = Type.Object({
25
25
  model: Type.Optional(Type.String({
26
26
  description: "Model override for the subagent",
27
27
  })),
28
- async: Type.Optional(Type.Boolean({
29
- description: "Run asynchronously (fire-and-forget)",
30
- })),
31
28
  cwd: Type.Optional(Type.String({
32
29
  description: "Working directory for the subagent",
33
30
  })),
34
31
  });
35
32
 
33
+ /**
34
+ * What `execute` is handed, derived from the schema rather than written out —
35
+ * the `ask` / `sudo` precedent (`AskParams`, `SudoExecInput`).
36
+ *
37
+ * #114's two phantoms were only possible because one contract was stated twice,
38
+ * independently: `async` in this schema, `count?: number` in the hand-written
39
+ * `params` type in `index.ts`. Nothing kept the two in step, and only one of them
40
+ * is what the model sees. Stating the contract once removes the second list: the
41
+ * schema is now the only *default* source of a name in `execute`'s declared
42
+ * params type — see the caveat below, which is the whole difference between
43
+ * "only default" and "only".
44
+ *
45
+ * That is a structural fix, not a check. It does not stop anyone reading a
46
+ * declared field, and it does not make a phantom a compile error — `execute` is
47
+ * declared as a method, so bivariant parameters accept a widening `& { … }` at
48
+ * the params position. See the header of `tool-schema.test.ts` for what is
49
+ * enforced where, measured.
50
+ */
51
+ export type SubagentParams = Static<typeof SUBAGENT_PARAMS_SCHEMA>;
52
+
36
53
  // ── JSON Schema for `structuredContent` ────────────────────────────────
37
54
  //
38
55
  // One report per run: what ran, how it ended, what it cost, what it answered.