@pi-archimedes/subagent 2.7.2 → 2.8.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.
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.
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.
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.7.2",
3
+ "version": "2.8.0",
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.7.2",
19
- "@pi-archimedes/todo": "2.7.2"
18
+ "@pi-archimedes/core": "2.8.0",
19
+ "@pi-archimedes/todo": "2.8.0"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "@earendil-works/pi-ai": ">=0.1.0",
@@ -1,5 +1,21 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { truncLine } from "./format.js";
2
+ import { truncLine, formatParallelResults } from "./format.js";
3
+ import type { SubagentResult } from "./types.js";
4
+
5
+ function makeResult(overrides: Partial<SubagentResult> = {}): SubagentResult {
6
+ return {
7
+ agent: "test-agent",
8
+ task: "do something",
9
+ exitCode: 0,
10
+ usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: 0.01, turns: 3 },
11
+ model: "gpt-4",
12
+ finalOutput: undefined,
13
+ error: undefined,
14
+ progress: undefined,
15
+ progressSummary: { toolCount: 5, tokens: 150, durationMs: 3000 },
16
+ ...overrides,
17
+ };
18
+ }
3
19
 
4
20
  // ── truncLine ───────────────────────────────────────────────────────────────
5
21
 
@@ -28,3 +44,76 @@ describe("truncLine", () => {
28
44
  expect(truncLine("\nhello", 10)).toBe("...");
29
45
  });
30
46
  });
47
+
48
+ describe("formatParallelResults", () => {
49
+ it("renders header + body per task with exactly one blank line between sections", () => {
50
+ const r1 = makeResult({
51
+ agent: "researcher",
52
+ progressSummary: { toolCount: 31, tokens: 12345, durationMs: 153000 },
53
+ finalOutput: "Found the bug.\n", // trailing newline: pins trimEnd
54
+ });
55
+ const r2 = makeResult({
56
+ agent: "reviewer",
57
+ progressSummary: { toolCount: 4, tokens: 2000, durationMs: 12000 },
58
+ finalOutput: "Looks good.",
59
+ });
60
+ expect(formatParallelResults([r1, r2])).toBe(
61
+ "✓ researcher 31 tools · 12k tok · 153s\nFound the bug.\n\n✓ reviewer 4 tools · 2k tok · 12s\nLooks good.",
62
+ );
63
+ });
64
+
65
+ it("rounds tokens and duration with Math.round", () => {
66
+ const r = makeResult({
67
+ progressSummary: { toolCount: 1, tokens: 12500, durationMs: 154000 },
68
+ finalOutput: "x",
69
+ });
70
+ expect(formatParallelResults([r])).toBe("✓ test-agent 1 tools · 13k tok · 154s\nx");
71
+ });
72
+
73
+ it("renders header only when progressSummary is absent (synthetic case)", () => {
74
+ const r = makeResult({ progressSummary: undefined });
75
+ expect(formatParallelResults([r])).toBe("✓ test-agent\ncompleted");
76
+ });
77
+
78
+ it("uses the ✗ glyph for non-zero exit codes", () => {
79
+ const r = makeResult({ exitCode: 1, finalOutput: "done" });
80
+ expect(formatParallelResults([r])).toBe("✗ test-agent 5 tools · 0k tok · 3s\ndone");
81
+ });
82
+
83
+ it("falls back to 'completed' when there is no finalOutput and no error", () => {
84
+ const r = makeResult();
85
+ expect(formatParallelResults([r])).toBe("✓ test-agent 5 tools · 0k tok · 3s\ncompleted");
86
+ });
87
+
88
+ it("uses the error text as the body when there is no finalOutput", () => {
89
+ const r = makeResult({ exitCode: 1, error: "spawn failed" });
90
+ expect(formatParallelResults([r])).toBe("✗ test-agent 5 tools · 0k tok · 3s\nspawn failed");
91
+ });
92
+
93
+ it("prefers finalOutput over error when both are set", () => {
94
+ const r = makeResult({ finalOutput: "out", error: "boom" });
95
+ expect(formatParallelResults([r])).toBe("✓ test-agent 5 tools · 0k tok · 3s\nout");
96
+ });
97
+
98
+ it("keeps internal newlines in a multi-line body and trims only the tail", () => {
99
+ const r1 = makeResult({
100
+ agent: "researcher",
101
+ finalOutput: "line one\nline two\n",
102
+ });
103
+ const r2 = makeResult({
104
+ agent: "reviewer",
105
+ finalOutput: "second",
106
+ });
107
+ expect(formatParallelResults([r1, r2])).toBe(
108
+ "✓ researcher 5 tools · 0k tok · 3s\nline one\nline two\n\n✓ reviewer 5 tools · 0k tok · 3s\nsecond",
109
+ );
110
+ });
111
+
112
+ it("keeps duplicate agent names distinguishable by position and body", () => {
113
+ const a = makeResult({ agent: "researcher", finalOutput: "first" });
114
+ const b = makeResult({ agent: "researcher", finalOutput: "second" });
115
+ expect(formatParallelResults([a, b])).toBe(
116
+ "✓ researcher 5 tools · 0k tok · 3s\nfirst\n\n✓ researcher 5 tools · 0k tok · 3s\nsecond",
117
+ );
118
+ });
119
+ });
package/src/format.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { SubagentResult } from "./types.js";
2
+
1
3
  export function formatTokens(n: number): string {
2
4
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
3
5
  if (n >= 1_000) return Math.round(n / 1_000) + "k";
@@ -41,6 +43,27 @@ export function truncLine(text: string, maxLen: number): string {
41
43
  return text.slice(0, maxLen - 3) + "...";
42
44
  }
43
45
 
46
+ /**
47
+ * Renders one section per parallel result. The header line is intentionally
48
+ * byte-identical to the pre-#55 metrics summary (a contract, not an
49
+ * accident); the body mirrors the single-mode fallback chain
50
+ * (finalOutput ?? error ?? "completed"), `trimEnd()`-ed so sections stay
51
+ * separated by exactly one blank line, and is deliberately untruncated by
52
+ * design (see docs/decisions/0020-subagent-parallel-full-output.md).
53
+ */
54
+ export function formatParallelResults(results: SubagentResult[]): string {
55
+ const sections = results.map((r) => {
56
+ const status = r.exitCode === 0 ? "✓" : "✗";
57
+ const summary = r.progressSummary
58
+ ? `${r.progressSummary.toolCount} tools · ${Math.round(r.progressSummary.tokens / 1000)}k tok · ${Math.round(r.progressSummary.durationMs / 1000)}s`
59
+ : "";
60
+ const header = `${status} ${r.agent}${summary ? " " + summary : ""}`;
61
+ const body = (r.finalOutput ?? r.error ?? "completed").trimEnd();
62
+ return `${header}\n${body}`;
63
+ });
64
+ return sections.join("\n\n");
65
+ }
66
+
44
67
  export interface StatsData {
45
68
  turns: number | undefined;
46
69
  toolCount: number | undefined;
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import { Text, type TUI } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
  import { SUBAGENT_PARAMS_SCHEMA } from "./tool-schema.js";
5
5
  import { executeSubagent, executeParallel } from "./execute.js";
6
+ import { formatParallelResults } from "./format.js";
6
7
  // agent-manager.js lazy-loaded below to keep subagent tool registration fast
7
8
  import { renderSubagentResult } from "./render.js";
8
9
  import { OVERLAY_CHROME } from "@pi-archimedes/core/overlay";
@@ -110,7 +111,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
110
111
  });
111
112
 
112
113
  return {
113
- content: [{ type: "text", text: formatResultsSummary(results) }],
114
+ content: [{ type: "text", text: formatParallelResults(results) }],
114
115
  details: {
115
116
  mode: "parallel",
116
117
  results,
@@ -281,17 +282,6 @@ function formatProgressSummary(progress: SubagentProgress[]): string {
281
282
  return lines.join("\n");
282
283
  }
283
284
 
284
- function formatResultsSummary(results: SubagentResult[]): string {
285
- const lines = results.map((r) => {
286
- const status = r.exitCode === 0 ? "✓" : "✗";
287
- const summary = r.progressSummary
288
- ? `${r.progressSummary.toolCount} tools · ${Math.round(r.progressSummary.tokens / 1000)}k tok · ${Math.round(r.progressSummary.durationMs / 1000)}s`
289
- : "";
290
- return `${status} ${r.agent}${summary ? " " + summary : ""}`;
291
- });
292
- return lines.join("\n");
293
- }
294
-
295
285
  // ── Command registration ────────────────────────────────────────────────────
296
286
 
297
287
  export function registerAgentsCommand(pi: ExtensionAPI): void {
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import * as net from "node:net";
3
+ import { getBus, Events } from "@pi-archimedes/core/bus";
4
+ import { startAskSocketServer } from "./spawn.js";
5
+
6
+ // startAskSocketServer is tested directly (not via spawnSubagent, which would
7
+ // spawn a real `pi` process). The real bus is used — subscriptions are
8
+ // unsubscribed in afterEach so they don't leak into other test files in the
9
+ // same worker (the bus is a globalThis singleton).
10
+
11
+ const cleanups: Array<() => void> = [];
12
+ const unsubs: Array<() => void> = [];
13
+
14
+ afterEach(() => {
15
+ unsubs.splice(0).forEach((u) => u());
16
+ cleanups.splice(0).forEach((c) => c());
17
+ });
18
+
19
+ function track(fn: () => void): void {
20
+ cleanups.push(fn);
21
+ }
22
+
23
+ function connectClient(socketPath: string): Promise<net.Socket> {
24
+ return new Promise((resolve, reject) => {
25
+ const client = net.connect(socketPath, () => resolve(client));
26
+ client.on("error", (err) => reject(err));
27
+ });
28
+ }
29
+
30
+ function writeAskRequest(client: net.Socket, requestId: string, toolCallId?: string): void {
31
+ client.write(
32
+ JSON.stringify({
33
+ type: "ask_request",
34
+ requestId,
35
+ toolCallId,
36
+ questions: [{ id: "q1", question: "Which?", options: [{ label: "A" }] }],
37
+ }) + "\n",
38
+ );
39
+ }
40
+
41
+ describe("startAskSocketServer", () => {
42
+ it("a child ask_request carrying toolCallId → the bus ASK_REQUEST carries the same toolCallId", async () => {
43
+ const { socketPath, cleanup } = startAskSocketServer("test-agent");
44
+ track(cleanup);
45
+ await new Promise((r) => setTimeout(r, 50)); // let the server listen
46
+
47
+ const requests: unknown[] = [];
48
+ unsubs.push(getBus().on(Events.ASK_REQUEST, (p) => requests.push(p)));
49
+
50
+ const client = await connectClient(socketPath);
51
+ writeAskRequest(client, "req-1", "tool-9");
52
+
53
+ await vi.waitFor(() => expect(requests.length).toBe(1));
54
+ const req = requests[0] as { source: string; requestId: string; toolCallId?: string; questions: unknown[] };
55
+ expect(req.source).toBe("subagent:test-agent");
56
+ expect(req.requestId).toBe("req-1");
57
+ expect(req.toolCallId).toBe("tool-9");
58
+ expect(req.questions).toEqual([{ id: "q1", question: "Which?", options: [{ label: "A" }] }]);
59
+
60
+ // Deliver the response (drains pending) before closing so no ASK_CANCEL
61
+ // leaks onto the global bus queue for the next test
62
+ getBus().emit(Events.ASK_RESPONSE, {
63
+ requestId: "req-1",
64
+ cancelled: false,
65
+ results: [{ id: "q1", selectedOptions: ["A"] }],
66
+ });
67
+ await new Promise((r) => setTimeout(r, 50));
68
+ client.destroy();
69
+ await new Promise((r) => setTimeout(r, 50)); // close handler ran (no-op)
70
+ });
71
+
72
+ it("closing the child socket → ASK_CANCEL for the pending requestId + source", async () => {
73
+ const { socketPath, cleanup } = startAskSocketServer("test-agent");
74
+ track(cleanup);
75
+ await new Promise((r) => setTimeout(r, 50)); // let the server listen
76
+
77
+ const cancels: unknown[] = [];
78
+ unsubs.push(getBus().on(Events.ASK_CANCEL, (p) => cancels.push(p)));
79
+ const requests: unknown[] = [];
80
+ unsubs.push(getBus().on(Events.ASK_REQUEST, (p) => requests.push(p)));
81
+
82
+ const client = await connectClient(socketPath);
83
+ writeAskRequest(client, "req-2");
84
+
85
+ // Wait until the request is registered (pending) before closing
86
+ await vi.waitFor(() => expect(requests.length).toBe(1));
87
+ client.destroy();
88
+
89
+ await vi.waitFor(() => expect(cancels.length).toBe(1));
90
+ expect(cancels[0]).toEqual({ requestId: "req-2", source: "subagent:test-agent" });
91
+ });
92
+
93
+ it("no ASK_CANCEL once the response has already been delivered (pending drained)", async () => {
94
+ const { socketPath, cleanup } = startAskSocketServer("test-agent");
95
+ track(cleanup);
96
+ await new Promise((r) => setTimeout(r, 50)); // let the server listen
97
+
98
+ const cancels: unknown[] = [];
99
+ unsubs.push(getBus().on(Events.ASK_CANCEL, (p) => cancels.push(p)));
100
+ const requests: unknown[] = [];
101
+ unsubs.push(getBus().on(Events.ASK_REQUEST, (p) => requests.push(p)));
102
+
103
+ const client = await connectClient(socketPath);
104
+ writeAskRequest(client, "req-3");
105
+ await vi.waitFor(() => expect(requests.length).toBe(1));
106
+
107
+ // Deliver the response (drains pending) before the socket closes
108
+ getBus().emit(Events.ASK_RESPONSE, {
109
+ requestId: "req-3",
110
+ cancelled: false,
111
+ results: [{ id: "q1", selectedOptions: ["A"] }],
112
+ });
113
+ await new Promise((r) => setTimeout(r, 50));
114
+ client.destroy();
115
+ await new Promise((r) => setTimeout(r, 50)); // close handler ran
116
+
117
+ expect(cancels.length).toBe(0);
118
+ });
119
+ });
package/src/spawn.ts CHANGED
@@ -67,17 +67,22 @@ function resolvePiBinary(): string {
67
67
  * Start a Unix socket server that bridges the child's ask tool to the parent bus.
68
68
  *
69
69
  * The child's ask tool (in packages/ask) connects to PI_SUBAGENT_SOCKET and sends:
70
- * { type: "ask_request", requestId, questions } as a JSON line
70
+ * { type: "ask_request", requestId, questions, toolCallId } as a JSON line
71
71
  * and waits for:
72
72
  * { type: "ask_response", requestId, cancelled, results } as a JSON line
73
73
  *
74
- * We forward the request onto the bus (ASK_REQUEST), the ask package shows the
75
- * parent TUI dialog, then emits ASK_RESPONSE on the bus, and we write it back
76
- * to the socket connection.
74
+ * We forward the request onto the bus (ASK_REQUEST, carrying toolCallId), the
75
+ * ask package (or the bridge, in bridge mode) answers it, then emits
76
+ * ASK_RESPONSE on the bus, and we write it back to the socket connection.
77
+ *
78
+ * When the socket closes (child exit, or the child's own 5-min timeout →
79
+ * socket.end()) we signal the bridge via ASK_CANCEL for any still-pending ask.
80
+ * We do NOT emit ASK_RESPONSE ourselves — the bridge is the sole emitter
81
+ * (no double-decrement of its refcount).
77
82
  *
78
83
  * Returns the socket path and a cleanup function.
79
84
  */
80
- function startAskSocketServer(agentName: string): { socketPath: string; cleanup: () => void } {
85
+ export function startAskSocketServer(agentName: string): { socketPath: string; cleanup: () => void } {
81
86
  // Use named pipes on Windows, Unix domain sockets elsewhere.
82
87
  // Linux socket path limit is 108 chars — keep it short.
83
88
  const id = randomUUID().slice(0, 8);
@@ -110,6 +115,8 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
110
115
 
111
116
  const server = net.createServer((socket) => {
112
117
  let buffer = "";
118
+ // requestIds seen on this socket (for the ASK_CANCEL wiring below)
119
+ const socketRequestIds = new Set<string>();
113
120
 
114
121
  socket.on("data", (chunk: Buffer) => {
115
122
  buffer += chunk.toString("utf-8");
@@ -124,6 +131,7 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
124
131
  type: string;
125
132
  requestId: string;
126
133
  questions: unknown[];
134
+ toolCallId?: string;
127
135
  };
128
136
  if (msg.type === "ask_request") {
129
137
  // Register write-back so ASK_RESPONSE handler can find this socket
@@ -134,11 +142,14 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
134
142
  // socket already closed
135
143
  }
136
144
  });
137
- // Forward to bus — ask package will show the TUI dialog
145
+ socketRequestIds.add(msg.requestId);
146
+ // Forward to bus — the ask package (or the bridge, in bridge mode)
147
+ // answers it; toolCallId rides along for Client correlation
138
148
  getBus().emit(Events.ASK_REQUEST, {
139
149
  source: `subagent:${agentName}`,
140
150
  requestId: msg.requestId,
141
151
  questions: msg.questions,
152
+ toolCallId: msg.toolCallId,
142
153
  });
143
154
  }
144
155
  } catch {
@@ -147,6 +158,19 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
147
158
  }
148
159
  });
149
160
 
161
+ // Child exit (or the child's own 5-min timeout → socket.end()) closes the
162
+ // socket: signal the bridge via ASK_CANCEL for any still-pending ask so it
163
+ // can cancel the relayed Client request. We only signal — the bridge is
164
+ // the sole ASK_RESPONSE emitter (no double-decrement of its refcount).
165
+ socket.on("close", () => {
166
+ for (const rid of socketRequestIds) {
167
+ if (pending.has(rid)) {
168
+ getBus().emit(Events.ASK_CANCEL, { requestId: rid, source: `subagent:${agentName}` });
169
+ pending.delete(rid);
170
+ }
171
+ }
172
+ });
173
+
150
174
  socket.on("error", () => { /* connection dropped */ });
151
175
  });
152
176
 
@@ -245,7 +269,12 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
245
269
  forceKill.unref();
246
270
  }
247
271
  };
248
- options.signal.addEventListener("abort", abortHandler, { once: true });
272
+ // A signal that is ALREADY aborted never dispatches its "abort" event (it
273
+ // fires exactly once, at abort time), so addEventListener alone would never
274
+ // kill the child — call the handler directly in that case. (kill is
275
+ // idempotent via the child.killed guard, so both branches coexist safely.)
276
+ if (options.signal.aborted) abortHandler();
277
+ else options.signal.addEventListener("abort", abortHandler, { once: true });
249
278
  child.on("exit", () => options.signal!.removeEventListener("abort", abortHandler));
250
279
  }
251
280