@pi-archimedes/subagent 2.10.4 → 2.11.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.
Files changed (46) hide show
  1. package/README.md +1 -1
  2. package/package.json +10 -10
  3. package/src/agent-panel.mcp.test.ts +76 -0
  4. package/src/agent-panel.tools.test.ts +162 -0
  5. package/src/agent-panel.ts +37 -8
  6. package/src/agent-panel.width-audit.test.ts +258 -0
  7. package/src/agents-parse.test.ts +252 -0
  8. package/src/agents.ts +72 -5
  9. package/src/compact.test.ts +136 -57
  10. package/src/compact.ts +57 -37
  11. package/src/dispatch.test.ts +73 -1
  12. package/src/dispatch.ts +20 -2
  13. package/src/execute.test.ts +9 -1
  14. package/src/execute.ts +26 -29
  15. package/src/expanded.test.ts +61 -0
  16. package/src/expanded.ts +96 -80
  17. package/src/format.test.ts +71 -29
  18. package/src/format.ts +71 -28
  19. package/src/frontmatter-io.test.ts +62 -0
  20. package/src/frontmatter-io.ts +16 -2
  21. package/src/handlers.test.ts +21 -3
  22. package/src/handlers.ts +22 -3
  23. package/src/index.output-schema.test.ts +317 -0
  24. package/src/index.test.ts +486 -0
  25. package/src/index.ts +168 -8
  26. package/src/render.ts +46 -36
  27. package/src/row-block.test.ts +195 -0
  28. package/src/row-block.ts +186 -0
  29. package/src/run-outcome.test.ts +71 -0
  30. package/src/run-outcome.ts +84 -0
  31. package/src/spawn.test.ts +204 -19
  32. package/src/spawn.ts +49 -17
  33. package/src/stream.test.ts +215 -17
  34. package/src/stream.ts +47 -9
  35. package/src/thinking-validation.test.ts +96 -0
  36. package/src/thinking-validation.ts +78 -0
  37. package/src/tool-declarations.test.ts +58 -0
  38. package/src/tool-row.width-audit.test.ts +720 -0
  39. package/src/tool-schema.ts +85 -0
  40. package/src/tool-validation.test.ts +321 -0
  41. package/src/tool-validation.ts +262 -0
  42. package/src/types.ts +86 -0
  43. package/src/usage.test.ts +95 -0
  44. package/src/usage.ts +87 -0
  45. package/src/cost.test.ts +0 -62
  46. package/src/cost.ts +0 -8
package/src/spawn.ts CHANGED
@@ -4,7 +4,9 @@ import * as net from "node:net";
4
4
  import * as os from "node:os";
5
5
  import * as path from "node:path";
6
6
  import { randomUUID } from "node:crypto";
7
- import { getBus, Events } from "@pi-archimedes/core/bus";
7
+ import type { EventBus } from "@earendil-works/pi-coding-agent";
8
+ import { Events, emitOn } from "@pi-archimedes/core/events";
9
+ import { resolveThinkingLevel } from "./thinking-validation.js";
8
10
  import type { AgentConfig } from "./agents.js";
9
11
 
10
12
  export interface SpawnOptions {
@@ -14,6 +16,8 @@ export interface SpawnOptions {
14
16
  cwd: string | undefined;
15
17
  signal: AbortSignal | undefined;
16
18
  agent: AgentConfig | undefined;
19
+ /** The parent's host handle (`pi.events`) — see `startAskSocketServer`. */
20
+ events: EventBus;
17
21
  }
18
22
 
19
23
  /**
@@ -73,25 +77,32 @@ function resolvePiBinary(): string {
73
77
  }
74
78
 
75
79
  /**
76
- * Start a Unix socket server that bridges the child's ask tool to the parent bus.
80
+ * Start a Unix socket server that bridges the child's ask tool to the parent's
81
+ * event handle.
77
82
  *
78
83
  * The child's ask tool (in packages/ask) connects to PI_SUBAGENT_SOCKET and sends:
79
84
  * { type: "ask_request", requestId, questions, toolCallId } as a JSON line
80
85
  * and waits for:
81
86
  * { type: "ask_response", requestId, cancelled, results } as a JSON line
82
87
  *
83
- * We forward the request onto the bus (ASK_REQUEST, carrying toolCallId), the
88
+ * We forward the request onto the handle (ASK_REQUEST, carrying toolCallId), the
84
89
  * ask package (or the bridge, in bridge mode) answers it, then emits
85
- * ASK_RESPONSE on the bus, and we write it back to the socket connection.
90
+ * ASK_RESPONSE on the same handle, and we write it back to the socket connection.
86
91
  *
87
92
  * When the socket closes (child exit, or the child's own 5-min timeout →
88
93
  * socket.end()) we signal the bridge via ASK_CANCEL for any still-pending ask.
89
94
  * We do NOT emit ASK_RESPONSE ourselves — the bridge is the sole emitter
90
95
  * (no double-decrement of its refcount).
91
96
  *
97
+ * @param events the parent's `pi.events`, injected: this is a plain function, not
98
+ * an extension, so the handle has to come from the caller that has
99
+ * one. The subscription it opens is closed by the returned
100
+ * `cleanup()` — a per-spawn subscription with a per-spawn
101
+ * teardown, which is why it cannot accumulate across runs.
102
+ *
92
103
  * Returns the socket path and a cleanup function.
93
104
  */
94
- export function startAskSocketServer(agentName: string): { socketPath: string; cleanup: () => void } {
105
+ export function startAskSocketServer(agentName: string, events: EventBus): { socketPath: string; cleanup: () => void } {
95
106
  // Use named pipes on Windows, Unix domain sockets elsewhere.
96
107
  // Linux socket path limit is 108 chars — keep it short.
97
108
  const id = randomUUID().slice(0, 8);
@@ -103,8 +114,8 @@ export function startAskSocketServer(agentName: string): { socketPath: string; c
103
114
  // Map of pending ask requests: requestId → write-back callback
104
115
  const pending = new Map<string, (response: unknown) => void>();
105
116
 
106
- // Listen for ASK_RESPONSE from the bus and route back to the waiting socket conn
107
- const unsubResponse = getBus().on(Events.ASK_RESPONSE, (payload: unknown) => {
117
+ // Listen for ASK_RESPONSE on the handle and route back to the waiting socket conn
118
+ const unsubResponse = events.on(Events.ASK_RESPONSE, (payload: unknown) => {
108
119
  const data = payload as {
109
120
  requestId: string;
110
121
  cancelled: boolean;
@@ -152,9 +163,9 @@ export function startAskSocketServer(agentName: string): { socketPath: string; c
152
163
  }
153
164
  });
154
165
  socketRequestIds.add(msg.requestId);
155
- // Forward to bus — the ask package (or the bridge, in bridge mode)
166
+ // Forward onto the handle — the ask package (or the bridge, in bridge mode)
156
167
  // answers it; toolCallId rides along for Client correlation
157
- getBus().emit(Events.ASK_REQUEST, {
168
+ emitOn(events, Events.ASK_REQUEST, {
158
169
  source: `subagent:${agentName}`,
159
170
  requestId: msg.requestId,
160
171
  questions: msg.questions,
@@ -174,7 +185,7 @@ export function startAskSocketServer(agentName: string): { socketPath: string; c
174
185
  socket.on("close", () => {
175
186
  for (const rid of socketRequestIds) {
176
187
  if (pending.has(rid)) {
177
- getBus().emit(Events.ASK_CANCEL, { requestId: rid, source: `subagent:${agentName}` });
188
+ emitOn(events, Events.ASK_CANCEL, { requestId: rid, source: `subagent:${agentName}` });
178
189
  pending.delete(rid);
179
190
  }
180
191
  }
@@ -208,7 +219,7 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
208
219
  const agentName = options.agent?.name ?? "general";
209
220
 
210
221
  // Start ask bridge socket before spawning so the env var is ready
211
- const { socketPath, cleanup: cleanupSocket } = startAskSocketServer(agentName);
222
+ const { socketPath, cleanup: cleanupSocket } = startAskSocketServer(agentName, options.events);
212
223
 
213
224
  // Build CLI args
214
225
  const args: string[] = ["--mode", "json", "--no-session", "-p"];
@@ -219,14 +230,35 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
219
230
  args.push("--model", model);
220
231
  }
221
232
 
222
- // Thinking level from agent config
223
- if (options.agent?.thinking) {
224
- args.push("--thinking", options.agent.thinking);
233
+ // Thinking level from agent config. An unknown level is NOT passed on: pi
234
+ // warns and drops the flag itself, which leaves the child on the default
235
+ // level with the explanation in a stderr nobody reads for a successful run.
236
+ // `index.ts` reports the same drop where the user can see it.
237
+ const thinking = resolveThinkingLevel(options.agent?.thinking);
238
+ if (thinking.level !== undefined) {
239
+ args.push("--thinking", thinking.level);
225
240
  }
226
241
 
227
- // Tool allowlist from agent config
228
- if (options.agent?.tools && options.agent.tools.length > 0) {
229
- args.push("--tools", options.agent.tools.join(","));
242
+ // Tool allowlist from agent config.
243
+ //
244
+ // pi 1.0.4 changed what a non-empty `--tools` does to MCP tools. It no longer
245
+ // filters them: unless the list is empty or names an `mcp__` entry, they stay
246
+ // REGISTERED — reachable through codemode and `tool_search` — even though they
247
+ // are not *declared* to the model (`agent-session.js`: `_allowlistFiltersMcp`
248
+ // false → `_isAllowedTool` keeps them, `_isActivatable` withholds the
249
+ // declaration unless `tool_search` can load them). So the child's tool box is
250
+ // wider than the three names in the config, which is the hole ADR 0028 closed:
251
+ // `--no-mcp` stops the servers connecting at all, so there is nothing to reach.
252
+ // A config that wants the servers asks with `mcp: true` — verified against a
253
+ // live 1.2.0 child: with `--tools read` it sees exactly `read` either way, so
254
+ // the opt-in's observable effect today is registration, not a declared list
255
+ // (see ADR 0028's verification note for the open follow-up).
256
+ const tools = options.agent?.tools ?? [];
257
+ if (tools.length > 0) {
258
+ args.push("--tools", tools.join(","));
259
+ if (options.agent?.mcp !== true) {
260
+ args.push("--no-mcp");
261
+ }
230
262
  }
231
263
 
232
264
  // Always exclude the subagent tool itself to prevent infinite recursion
@@ -1,12 +1,39 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { PassThrough } from "node:stream";
3
3
  import type { ChildProcess } from "node:child_process";
4
- import { describe, expect, it } from "vitest";
5
- import { getBus, Events } from "@pi-archimedes/core/bus";
4
+ import { describe, expect, it, beforeEach } from "vitest";
5
+ import { Events } from "@pi-archimedes/core/events";
6
6
  import { streamEvents, type StreamCallbacks } from "./stream.js";
7
7
 
8
8
  type FakeChild = ChildProcess & { stdout: PassThrough; stderr: PassThrough };
9
9
 
10
+ /** Stands in for the host's `createEventBus()` — the emit/on/unsubscribe contract `pi.events` implements. */
11
+ function fakeBus() {
12
+ const listeners = new Map<string, Array<(data: unknown) => void>>();
13
+ return {
14
+ emit(channel: string, data: unknown): void {
15
+ for (const handler of [...(listeners.get(channel) ?? [])]) handler(data);
16
+ },
17
+ on(channel: string, handler: (data: unknown) => void): () => void {
18
+ const subs = listeners.get(channel) ?? [];
19
+ subs.push(handler);
20
+ listeners.set(channel, subs);
21
+ return () => {
22
+ const current = listeners.get(channel) ?? [];
23
+ const idx = current.indexOf(handler);
24
+ if (idx !== -1) current.splice(idx, 1);
25
+ };
26
+ },
27
+ };
28
+ }
29
+
30
+ /** Fresh handle per test: the emitter is handed this, the assertions listen on it. */
31
+ let bus: ReturnType<typeof fakeBus>;
32
+
33
+ beforeEach(() => {
34
+ bus = fakeBus();
35
+ });
36
+
10
37
  function fakeChild(): FakeChild {
11
38
  const child = new EventEmitter() as FakeChild;
12
39
  Object.assign(child, {
@@ -19,10 +46,10 @@ function fakeChild(): FakeChild {
19
46
 
20
47
  async function finishWith(
21
48
  events: Array<Record<string, unknown>>,
22
- callbacks: StreamCallbacks = {},
49
+ callbacks: Partial<StreamCallbacks> = {},
23
50
  ) {
24
51
  const child = fakeChild();
25
- const result = streamEvents(child, callbacks);
52
+ const result = streamEvents(child, { events: bus, ...callbacks } as StreamCallbacks);
26
53
  for (const event of events) {
27
54
  child.stdout.write(`${JSON.stringify(event)}\n`);
28
55
  }
@@ -65,10 +92,10 @@ describe("streamEvents todo mirroring", () => {
65
92
  it("mirrors the child-accepted state from result.details.todos", async () => {
66
93
  const updates: Array<{ source: string; todos: unknown[] }> = [];
67
94
  const clears: Array<{ source: string }> = [];
68
- const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
95
+ const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
69
96
  updates.push(p as (typeof updates)[number]),
70
97
  );
71
- const offClear = getBus().on(Events.TODOS_CLEAR, (p: unknown) =>
98
+ const offClear = bus.on(Events.TODOS_CLEAR, (p: unknown) =>
72
99
  clears.push(p as { source: string }),
73
100
  );
74
101
 
@@ -99,15 +126,16 @@ describe("streamEvents todo mirroring", () => {
99
126
  expect(updates[0]!.todos).toEqual([
100
127
  { content: "Fix auth", status: "pending" },
101
128
  ]);
102
- // Bus re-drains queued events (emitted while unsubscribed) to the next
103
- // subscriber, so stale "subagent:general" clears from earlier tests may
104
- // be present — assert on the per-agent source instead of the raw count.
129
+ // Each test owns its handle now, so `clears` can only hold this test's
130
+ // emit — but the assertion stays source-scoped, because that is the
131
+ // contract a reader needs to see: the clear names the child it belongs to.
132
+ expect(clears.filter((c) => c.source === "subagent:t-acc")).toHaveLength(1);
105
133
  expect(clears.filter((c) => c.source === "subagent:t-acc")).toHaveLength(1);
106
134
  });
107
135
 
108
136
  it("mirrors nothing when the tool returned a validation rejection (result.isError)", async () => {
109
137
  const updates: Array<{ source: string; todos: unknown[] }> = [];
110
- const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
138
+ const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
111
139
  updates.push(p as (typeof updates)[number]),
112
140
  );
113
141
 
@@ -139,7 +167,7 @@ describe("streamEvents todo mirroring", () => {
139
167
 
140
168
  it("mirrors nothing when the harness reported an error (event.isError)", async () => {
141
169
  const updates: Array<{ source: string; todos: unknown[] }> = [];
142
- const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
170
+ const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
143
171
  updates.push(p as (typeof updates)[number]),
144
172
  );
145
173
 
@@ -166,7 +194,7 @@ describe("streamEvents todo mirroring", () => {
166
194
 
167
195
  it("falls back to normalized raw args when the accepted result has no details", async () => {
168
196
  const updates: Array<{ source: string; todos: unknown[] }> = [];
169
- const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
197
+ const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
170
198
  updates.push(p as (typeof updates)[number]),
171
199
  );
172
200
 
@@ -194,7 +222,7 @@ describe("streamEvents todo mirroring", () => {
194
222
 
195
223
  it("stows raw args under the alias key todo_list (result has no details)", async () => {
196
224
  const updates: Array<{ source: string; todos: unknown[] }> = [];
197
- const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
225
+ const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
198
226
  updates.push(p as (typeof updates)[number]),
199
227
  );
200
228
 
@@ -230,7 +258,7 @@ describe("streamEvents todo mirroring", () => {
230
258
 
231
259
  it("does not crash or emit when the end event has no matching start", async () => {
232
260
  const updates: Array<{ source: string; todos: unknown[] }> = [];
233
- const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
261
+ const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
234
262
  updates.push(p as (typeof updates)[number]),
235
263
  );
236
264
 
@@ -254,7 +282,7 @@ describe("streamEvents todo mirroring", () => {
254
282
 
255
283
  it("does not emit for non-todo tools with todoList-shaped args", async () => {
256
284
  const updates: Array<{ source: string; todos: unknown[] }> = [];
257
- const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
285
+ const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
258
286
  updates.push(p as (typeof updates)[number]),
259
287
  );
260
288
 
@@ -293,10 +321,10 @@ describe("streamEvents todo mirroring", () => {
293
321
  it("uses a unique per-child source when the child provides a session id", async () => {
294
322
  const updates: Array<{ source: string; todos: unknown[] }> = [];
295
323
  const clears: Array<{ source: string }> = [];
296
- const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
324
+ const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
297
325
  updates.push(p as (typeof updates)[number]),
298
326
  );
299
- const offClear = getBus().on(Events.TODOS_CLEAR, (p: unknown) =>
327
+ const offClear = bus.on(Events.TODOS_CLEAR, (p: unknown) =>
300
328
  clears.push(p as { source: string }),
301
329
  );
302
330
 
@@ -345,3 +373,173 @@ describe("streamEvents todo mirroring", () => {
345
373
  });
346
374
  });
347
375
 
376
+
377
+ // ── Run outcomes (CONTEXT.md: *Run outcome*; ADR 0028) ──────────────────────
378
+ //
379
+ // pi 1.2.0 made print mode exit 1 when the final response hit the output token
380
+ // limit, and text-mode truncation is reported that way — so an exit code alone
381
+ // can no longer tell a useful-but-cut-off answer from a crash. The child's own
382
+ // `stopReason` and 1.1.0's `agent_settled.aborted` are what carry the
383
+ // distinction; `agent_end` fires for a cancelled run too and says nothing about
384
+ // either. Cost has to survive all three outcomes.
385
+
386
+ const USAGE = {
387
+ input: 100,
388
+ output: 40,
389
+ cacheRead: 7,
390
+ cacheWrite: 2,
391
+ totalTokens: 149,
392
+ cost: { input: 0.001, output: 0.002, cacheRead: 0, cacheWrite: 0, total: 0.003 },
393
+ };
394
+
395
+ function assistantMessage(stopReason: string): Record<string, unknown> {
396
+ return {
397
+ type: "message_end",
398
+ message: {
399
+ role: "assistant",
400
+ content: [{ type: "text", text: "partial answer" }],
401
+ model: "anthropic/claude-opus-4.5",
402
+ stopReason,
403
+ usage: USAGE,
404
+ },
405
+ };
406
+ }
407
+
408
+ async function finishChild(
409
+ events: Array<Record<string, unknown>>,
410
+ close: [code: number | null, signal?: NodeJS.Signals | null],
411
+ stderr = "",
412
+ ) {
413
+ const child = fakeChild();
414
+ const result = streamEvents(child, { agent: "reviewer", task: "t", events: bus });
415
+ if (stderr) child.stderr.write(stderr);
416
+ for (const event of events) {
417
+ child.stdout.write(`${JSON.stringify(event)}\n`);
418
+ }
419
+ child.emit("close", close[0], close[1] ?? null);
420
+ return result;
421
+ }
422
+
423
+ describe("streamEvents run outcomes", () => {
424
+ it("a clean finish is `completed`", async () => {
425
+ const result = await finishChild(
426
+ [
427
+ assistantMessage("stop"),
428
+ { type: "agent_end", messages: [] },
429
+ { type: "agent_settled", aborted: false },
430
+ ],
431
+ [0],
432
+ );
433
+ expect(result.outcome).toBe("completed");
434
+ expect(result.error).toBeUndefined();
435
+ });
436
+
437
+ it("exit 1 with stopReason `length` is `truncated`, not a failure, and keeps the cost", async () => {
438
+ const result = await finishChild(
439
+ [
440
+ assistantMessage("length"),
441
+ { type: "agent_end", messages: [] },
442
+ { type: "agent_settled", aborted: false },
443
+ ],
444
+ [1],
445
+ "Response was truncated: the model reached its output token limit.\n",
446
+ );
447
+ expect(result.outcome).toBe("truncated");
448
+ expect(result.error).toBeUndefined();
449
+ expect(result.usage.cost).toBeCloseTo(0.003);
450
+ expect(result.usage.input).toBe(100);
451
+ expect(result.usage.output).toBe(40);
452
+ });
453
+
454
+ it("`--mode json` exits 0 for the same truncation, and it is still `truncated`", async () => {
455
+ // Verified on 1.2.0: the exit-1 mapping lives in print mode's *text* branch,
456
+ // so a json child reports truncation only through stopReason.
457
+ const result = await finishChild(
458
+ [assistantMessage("length"), { type: "agent_settled", aborted: false }],
459
+ [0],
460
+ );
461
+ expect(result.outcome).toBe("truncated");
462
+ expect(result.error).toBeUndefined();
463
+ });
464
+
465
+ it("`agent_settled.aborted: true` is `aborted`, whatever the exit code says", async () => {
466
+ // The child's print mode exits 143 when it is SIGTERM'd on the way out.
467
+ const result = await finishChild(
468
+ [assistantMessage("stop"), { type: "agent_settled", aborted: true }],
469
+ [143],
470
+ );
471
+ expect(result.outcome).toBe("aborted");
472
+ expect(result.error).toBeUndefined();
473
+ expect(result.usage.cost).toBeCloseTo(0.003);
474
+ });
475
+
476
+ it("a child killed by the abort signal before it settled is `aborted`", async () => {
477
+ const result = await finishChild([assistantMessage("stop")], [null, "SIGTERM"]);
478
+ expect(result.outcome).toBe("aborted");
479
+ expect(result.error).toBeUndefined();
480
+ });
481
+
482
+ it("a real SIGTERM'd child exits 143 with no signal and no settle event, and is still `aborted`", async () => {
483
+ // The shape a live 1.2.0 child actually produces when the parent's abort
484
+ // handler SIGTERMs it: print mode catches the signal and `process.exit(143)`,
485
+ // so there is no `signal`, and the process dies before it can emit
486
+ // `agent_settled`. Whatever it had printed to stderr (here, an unrelated
487
+ // provider warning) must not be promoted to an error.
488
+ const result = await finishChild([assistantMessage("stop")], [143], "LiteLLM discovery: warning");
489
+ expect(result.outcome).toBe("aborted");
490
+ expect(result.error).toBeUndefined();
491
+ });
492
+
493
+ it("an assistant message aborted at the provider is `aborted` even with no settle event", async () => {
494
+ const result = await finishChild([assistantMessage("aborted")], [0]);
495
+ expect(result.outcome).toBe("aborted");
496
+ });
497
+
498
+ it("a pre/during-run error stays a failure: no outcome, error from stderr", async () => {
499
+ const result = await finishChild(
500
+ [assistantMessage("error"), { type: "agent_settled", aborted: false }],
501
+ [1],
502
+ "401 unauthorized",
503
+ );
504
+ expect(result.outcome).toBeUndefined();
505
+ expect(result.error).toContain("401");
506
+ });
507
+
508
+ it("a nonzero exit with no run behind it stays a failure", async () => {
509
+ const result = await finishChild([], [2], "unknown flag --nope");
510
+ expect(result.outcome).toBeUndefined();
511
+ expect(result.error).toContain("unknown flag");
512
+ });
513
+
514
+ it("a failed run always says why, even on a clean exit with empty stderr", async () => {
515
+ // The shape a json child produces when the provider errors mid-run: the last
516
+ // assistant message ends `stopReason: "error"`, the exit code stays 0 (the
517
+ // code mapping is print mode's text branch) and stderr is empty. Without a
518
+ // synthesised explanation this is indistinguishable from a successful run.
519
+ const result = await finishChild(
520
+ [assistantMessage("error"), { type: "agent_settled", aborted: false }],
521
+ [0],
522
+ );
523
+ expect(result.outcome).toBeUndefined();
524
+ expect(result.error).toMatch(/stopReason|failed/i);
525
+ });
526
+
527
+ it("a crash on an unexpected signal is a failure, not a cancellation", async () => {
528
+ const result = await finishChild([assistantMessage("stop")], [null, "SIGSEGV"]);
529
+ expect(result.outcome).toBeUndefined();
530
+ expect(result.error).toBeTruthy();
531
+ });
532
+
533
+ it("the final progress carries the outcome, so the live view can show it", async () => {
534
+ const seen: Array<string | undefined> = [];
535
+ const result = await finishChild(
536
+ [assistantMessage("length"), { type: "agent_settled", aborted: false }],
537
+ [0],
538
+ ).then((r) => {
539
+ seen.push(r.progress?.outcome);
540
+ return r;
541
+ });
542
+ expect(seen).toEqual(["truncated"]);
543
+ expect(result.progress?.status).toBe("completed");
544
+ });
545
+ });
package/src/stream.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import type { ChildProcess } from "node:child_process";
3
+ import type { EventBus } from "@earendil-works/pi-coding-agent";
3
4
  import type { StreamState, SubagentProgress, SubagentResult } from "./types.js";
4
- import { getBus, Events } from "@pi-archimedes/core/bus";
5
+ import { Events, emitOn } from "@pi-archimedes/core/events";
5
6
  import { normalizeTodoItems, pickTodoListRaw } from "@pi-archimedes/todo/prepare-args";
6
7
  import {
7
8
  type JsonEvent,
@@ -11,11 +12,19 @@ import {
11
12
  handleMessageEnd,
12
13
  handleAgentEnd,
13
14
  } from "./handlers.js";
15
+ import { decideRunOutcome } from "./run-outcome.js";
14
16
 
15
17
  export interface StreamCallbacks {
16
18
  agent?: string;
17
19
  task?: string;
18
20
  onProgress?: (progress: SubagentProgress) => void;
21
+ /**
22
+ * The parent's host handle (`pi.events`), injected. `streamEvents` is a plain
23
+ * function over a child process, so it has no `pi` of its own; the child's
24
+ * todo state is announced on the handle the caller is holding, which is the
25
+ * same one the todo package subscribed on in this process.
26
+ */
27
+ events: EventBus;
19
28
  }
20
29
 
21
30
  /**
@@ -27,7 +36,7 @@ export interface StreamCallbacks {
27
36
  */
28
37
  export function streamEvents(
29
38
  child: ChildProcess,
30
- callbacks: StreamCallbacks = {},
39
+ callbacks: StreamCallbacks,
31
40
  ): Promise<SubagentResult> {
32
41
  return new Promise((resolve, reject) => {
33
42
  const startTime = Date.now();
@@ -175,7 +184,7 @@ export function streamEvents(
175
184
  const detailsTodos = (result?.details as Record<string, unknown> | undefined)?.todos;
176
185
  const todos = normalizeTodoItems(Array.isArray(detailsTodos) ? detailsTodos : stowed);
177
186
  if (todos) {
178
- getBus().emit(Events.TODOS_UPDATE, { source: subagentSource, todos });
187
+ emitOn(callbacks.events, Events.TODOS_UPDATE, { source: subagentSource, todos });
179
188
  }
180
189
  }
181
190
  }
@@ -198,21 +207,48 @@ export function streamEvents(
198
207
  handleAgentEnd(state, event);
199
208
  break;
200
209
  }
210
+ case "agent_settled": {
211
+ // pi 1.1.0: the child's own statement that its run is over, and
212
+ // whether it was cancelled. `agent_end` fires for a cancelled run too
213
+ // (and can fire while a continuation is still pending), so this — not
214
+ // `agent_end` — is what an outcome is read from.
215
+ if (event.aborted === true) state.settledAborted = true;
216
+ break;
217
+ }
201
218
  // Ignore: agent_start, message_start, message_update, turn_end, tool_execution_update
202
219
  }
203
220
  });
204
221
 
205
222
  // Handle process exit
206
- child.on("close", (code) => {
223
+ child.on("close", (code, signal) => {
207
224
  clearStartupTimer();
208
225
  clearInterval(heartbeat);
209
226
  const durationMs = Date.now() - startTime;
210
227
  const exitCode = code ?? 1;
211
228
 
212
- // Surface stderr as error if the process failed and we have no other error
213
- if (exitCode !== 0 && !error) {
229
+ const outcome = decideRunOutcome({
230
+ settledAborted: state.settledAborted === true,
231
+ stopReason: state.lastStopReason,
232
+ // The raw code, not `exitCode`: `null` is the difference between "it was
233
+ // killed" and "it failed", and the outcome table reads that difference.
234
+ exitCode: code,
235
+ signal: signal ?? undefined,
236
+ });
237
+
238
+ // stderr explains a failure and nothing else. A truncated run's stderr is
239
+ // pi's own "Response was truncated" notice and an aborted one's is noise —
240
+ // promoting either to `error` would report a real answer as a broken one.
241
+ if (outcome === "failed" && !error) {
214
242
  const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
215
243
  if (stderr) error = stderr;
244
+ else if (code === null) error = `subagent terminated by ${signal ?? "a signal"}`;
245
+ // A json child that errored leaves nothing behind to read: the exit code
246
+ // stays 0 (the stopReason→code mapping is print mode's text branch) and
247
+ // stderr is clean, which used to make a failed run byte-identical to a
248
+ // successful one at this boundary. A failed run always says why.
249
+ else if (state.lastStopReason === "error")
250
+ error = 'subagent failed: the child\u2019s last response ended with stopReason "error"';
251
+ else error = `subagent failed with exit code ${code ?? "null"}`;
216
252
  }
217
253
 
218
254
  const result: SubagentResult = {
@@ -220,6 +256,7 @@ export function streamEvents(
220
256
  task: callbacks.task ?? "",
221
257
  ...(state.childSessionId ? { childSessionId: state.childSessionId } : {}),
222
258
  exitCode,
259
+ ...(outcome === "failed" ? {} : { outcome }),
223
260
  model: state.model,
224
261
  usage: {
225
262
  input: state.totalInput,
@@ -233,7 +270,8 @@ export function streamEvents(
233
270
  error,
234
271
  progress: {
235
272
  ...buildProgress(),
236
- status: exitCode === 0 ? "completed" : "failed",
273
+ status: outcome === "failed" ? "failed" : "completed",
274
+ ...(outcome === "failed" ? {} : { outcome }),
237
275
  durationMs,
238
276
  },
239
277
  progressSummary: {
@@ -245,8 +283,8 @@ export function streamEvents(
245
283
 
246
284
  callbacks.onProgress?.(result.progress!);
247
285
 
248
- // Clear subagent todos from the bus on exit
249
- getBus().emit(Events.TODOS_CLEAR, {
286
+ // Clear subagent todos from the handle on exit
287
+ emitOn(callbacks.events, Events.TODOS_CLEAR, {
250
288
  source: subagentSource,
251
289
  });
252
290