@pi-archimedes/subagent 2.7.3 → 2.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/subagent",
3
- "version": "2.7.3",
3
+ "version": "2.9.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.3",
19
- "@pi-archimedes/todo": "2.7.3"
18
+ "@pi-archimedes/core": "2.9.0",
19
+ "@pi-archimedes/todo": "2.9.0"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "@earendil-works/pi-ai": ">=0.1.0",
@@ -25,9 +25,9 @@
25
25
  "typebox": ">=1.1.0"
26
26
  },
27
27
  "devDependencies": {
28
- "@earendil-works/pi-ai": "^0.85.1",
29
- "@earendil-works/pi-coding-agent": "^0.85.1",
30
- "@earendil-works/pi-tui": "^0.85.1",
28
+ "@earendil-works/pi-ai": "^0.87.0",
29
+ "@earendil-works/pi-coding-agent": "^0.87.0",
30
+ "@earendil-works/pi-tui": "^0.87.0",
31
31
  "@types/node": "^22.0.0",
32
32
  "typebox": "^1.1.38",
33
33
  "typescript": "^6.0.3"
@@ -0,0 +1,292 @@
1
+ // ── Bridge-mode dispatch (dispatchViaBridge) ─────────────────────────────
2
+ //
3
+ // A fake bridge server (a `net` server on a temp socket — the pattern from
4
+ // packages/core's bridge index.test.ts end-to-end section) stands in for the
5
+ // desktop. The bridge is activated through the real env-gated session_start
6
+ // path (registerBridge), so `getBridge().active` is the real gate.
7
+
8
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import * as net from "node:net";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import * as fs from "node:fs";
14
+ import { randomUUID } from "node:crypto";
15
+ import { registerBridge } from "@pi-archimedes/core/bridge";
16
+ import { dispatchViaBridge } from "./dispatch.js";
17
+ import { resolveModel } from "./spawn.js";
18
+ import type { ExecuteOptions } from "./execute.js";
19
+ import type { AgentConfig } from "./agents.js";
20
+ import type { SubagentResult } from "./types.js";
21
+
22
+ const ENV_KEYS = [
23
+ "PI_ARCHIMEDES_BRIDGE",
24
+ "PI_ARCHIMEDES_BRIDGE_SOCKET",
25
+ "PI_ARCHIMEDES_BRIDGE_SESSION",
26
+ "PI_ARCHIMEDES_BRIDGE_SERVER_PID",
27
+ "PI_SUBAGENT_SOCKET",
28
+ ] as const;
29
+
30
+ const savedEnv: Record<string, string | undefined> = {};
31
+
32
+ function clearBridgeEnv(): void {
33
+ for (const k of ENV_KEYS) delete process.env[k];
34
+ }
35
+
36
+ /** Activate the bridge (env-gated, root-only) pointing at the given socket. */
37
+ function activateBridge(socketPath: string): void {
38
+ clearBridgeEnv();
39
+ process.env.PI_ARCHIMEDES_BRIDGE = "1";
40
+ process.env.PI_ARCHIMEDES_BRIDGE_SOCKET = socketPath;
41
+ process.env.PI_ARCHIMEDES_BRIDGE_SESSION = "test-desktop-session";
42
+ process.env.PI_ARCHIMEDES_BRIDGE_SERVER_PID = "12345";
43
+ // registerBridge evaluates isBridgeMode at session_start — fire the handler.
44
+ const onSpy = vi.fn();
45
+ registerBridge({ on: onSpy } as unknown as ExtensionAPI);
46
+ const calls = onSpy.mock.calls as Array<[string, (e: unknown, ctx: unknown) => void]>;
47
+ const startCall = calls.find((c) => c[0] === "session_start");
48
+ expect(startCall).toBeTruthy();
49
+ startCall![1](null, { mode: "rpc" });
50
+ }
51
+
52
+ function tempSocketPath(): string {
53
+ return path.join(os.tmpdir(), `dispatch-test-${randomUUID()}.sock`);
54
+ }
55
+
56
+ const AGENT: AgentConfig = {
57
+ name: "reviewer",
58
+ description: "Code reviewer",
59
+ systemPrompt: "You are a reviewer.\n",
60
+ source: "global",
61
+ filePath: "/tmp/reviewer.md",
62
+ model: "anthropic/claude-agent",
63
+ thinking: "medium",
64
+ tools: ["read", "bash"],
65
+ };
66
+
67
+ function makeOptions(overrides: Partial<ExecuteOptions> = {}): ExecuteOptions {
68
+ return {
69
+ agent: "reviewer",
70
+ agentConfig: AGENT,
71
+ task: "review this code",
72
+ model: "call-model",
73
+ activeModel: "active-model",
74
+ cwd: undefined,
75
+ signal: undefined,
76
+ onUpdate: undefined,
77
+ toolCallId: "tc-1",
78
+ ...overrides,
79
+ };
80
+ }
81
+
82
+ /** A fake bridge server: reply to every request frame with the given frame body. */
83
+ function startFakeDesktop(
84
+ sockPath: string,
85
+ reply: (msg: { id?: string; type?: string; method?: string; source?: string; toolCallId?: string; params?: unknown }) => unknown,
86
+ ): Promise<net.Server> {
87
+ const server = net.createServer((socket) => {
88
+ let buffer = "";
89
+ socket.on("data", (chunk: Buffer) => {
90
+ buffer += chunk.toString("utf-8");
91
+ const lines = buffer.split("\n");
92
+ buffer = lines.pop() ?? "";
93
+ for (const line of lines) {
94
+ const trimmed = line.trim();
95
+ if (!trimmed) continue;
96
+ try {
97
+ const msg = JSON.parse(trimmed) as { type?: string; id?: string; method?: string; source?: string; toolCallId?: string; params?: unknown };
98
+ if (msg.type === "request" && msg.id) {
99
+ const body = reply(msg);
100
+ if (body !== undefined) socket.write(JSON.stringify(body) + "\n");
101
+ }
102
+ } catch { /* malformed — ignore */ }
103
+ }
104
+ });
105
+ socket.on("error", () => { /* connection dropped */ });
106
+ });
107
+ return new Promise((resolve) => server.listen(sockPath, () => resolve(server)));
108
+ }
109
+
110
+ beforeEach(() => {
111
+ for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
112
+ clearBridgeEnv();
113
+ });
114
+
115
+ afterEach(() => {
116
+ for (const k of ENV_KEYS) {
117
+ if (savedEnv[k] === undefined) delete process.env[k];
118
+ else process.env[k] = savedEnv[k];
119
+ }
120
+ clearBridgeEnv();
121
+ });
122
+
123
+ // ── dispatchViaBridge: response mapping ───────────────────────────────────
124
+
125
+ describe("dispatchViaBridge", () => {
126
+ it("(1) maps a success response to a SubagentResult (no fork)", async () => {
127
+ const sockPath = tempSocketPath();
128
+ const frames: Array<Record<string, unknown>> = [];
129
+ const server = await startFakeDesktop(sockPath, (msg) => {
130
+ frames.push(msg);
131
+ return {
132
+ v: 1,
133
+ type: "response",
134
+ id: msg.id,
135
+ result: { output: "done", metrics: { inputTokens: 1, outputTokens: 2, cost: 0.01, durationMs: 5 } },
136
+ };
137
+ });
138
+ activateBridge(sockPath);
139
+
140
+ const outcome = await dispatchViaBridge(makeOptions(), undefined, "tc-1");
141
+
142
+ // A SubagentResult — NOT the fallback marker.
143
+ expect("fallback" in outcome).toBe(false);
144
+ const result = outcome as SubagentResult;
145
+ expect(result.exitCode).toBe(0);
146
+ expect(result.finalOutput).toBe("done");
147
+ expect(result.error).toBeUndefined();
148
+ expect(result.usage).toEqual({ input: 1, output: 2, cacheRead: 0, cacheWrite: 0, cost: 0.01, turns: 0 });
149
+ expect(result.model).toBe("anthropic/claude-agent"); // agent.model wins
150
+ // Synthesized progress (the same defensive synthesis executeSubagent does).
151
+ expect(result.progress).toBeDefined();
152
+ expect(result.progress!.status).toBe("completed");
153
+ expect(result.progress!.agent).toBe("reviewer");
154
+ expect(result.progress!.durationMs).toBe(5);
155
+ expect(result.progressSummary).toEqual({ toolCount: 0, tokens: 3, durationMs: 5 });
156
+
157
+ // The frame follows the wire contract.
158
+ const frame = frames.find((f) => f.type === "request");
159
+ expect(frame).toBeDefined();
160
+ expect(frame!.method).toBe("dispatch_subagent");
161
+ expect(frame!.source).toBe("main");
162
+ expect(frame!.toolCallId).toBe("tc-1");
163
+ const params = frame!.params as Record<string, unknown>;
164
+ expect(params.agentName).toBe("reviewer");
165
+ expect(params.task).toBe("review this code");
166
+ expect(params.systemPrompt).toBe("You are a reviewer."); // trimmed
167
+ expect(params.model).toBe("anthropic/claude-agent");
168
+ expect(params.thinking).toBe("medium");
169
+ expect(params.tools).toEqual(["read", "bash"]);
170
+ expect("cwd" in params).toBe(false); // intentionally NOT sent
171
+
172
+ server.close();
173
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
174
+ });
175
+
176
+ it("(2) a terminal error: 'cancelled' frame → a FAILED SubagentResult (NOT a fallback)", async () => {
177
+ const sockPath = tempSocketPath();
178
+ const server = await startFakeDesktop(sockPath, (msg) => {
179
+ return { v: 1, type: "response", id: msg.id, error: "cancelled" };
180
+ });
181
+ activateBridge(sockPath);
182
+
183
+ const outcome = await dispatchViaBridge(makeOptions(), undefined, "tc-1");
184
+
185
+ // The desktop answered — its outcome is authoritative, never a fallback.
186
+ expect("fallback" in outcome).toBe(false);
187
+ const result = outcome as SubagentResult;
188
+ expect(result.exitCode).toBe(1);
189
+ expect(result.error).toBe("cancelled");
190
+ expect(result.finalOutput).toBeUndefined();
191
+ expect(result.progress!.status).toBe("failed");
192
+ expect(result.progress!.error).toBe("cancelled");
193
+
194
+ server.close();
195
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
196
+ });
197
+
198
+ it("a mid-dispatch abort settles as a FAILED SubagentResult with error 'cancelled' (NOT a fallback — no fork)", async () => {
199
+ const sockPath = tempSocketPath();
200
+ const server = await startFakeDesktop(sockPath, () => {
201
+ /* accept but never respond */
202
+ });
203
+ activateBridge(sockPath);
204
+
205
+ const controller = new AbortController();
206
+ const p = dispatchViaBridge(makeOptions({ signal: controller.signal }), controller.signal, "tc-1");
207
+ await new Promise((r) => setTimeout(r, 50)); // let the socket connect (request sent)
208
+ controller.abort(); // mid-dispatch: after the request, before the response
209
+
210
+ const outcome = await p;
211
+ // A deliberate cancel must NEVER fork: a FAILED SubagentResult with the
212
+ // "cancelled" outcome (the same as the desktop's terminal `error:
213
+ // "cancelled" frame) — NOT the fallback marker.
214
+ expect("fallback" in outcome).toBe(false);
215
+ const result = outcome as SubagentResult;
216
+ expect(result.exitCode).toBe(1);
217
+ expect(result.error).toBe("cancelled");
218
+ expect(result.finalOutput).toBeUndefined();
219
+ expect(result.progress!.status).toBe("failed");
220
+ expect(result.progress!.error).toBe("cancelled");
221
+
222
+ server.close();
223
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
224
+ });
225
+
226
+ it("any other response-carrying error → a FAILED SubagentResult with the error message (NOT a fallback)", async () => {
227
+ const sockPath = tempSocketPath();
228
+ const server = await startFakeDesktop(sockPath, (msg) => {
229
+ return { v: 1, type: "response", id: msg.id, error: "worker runtime unavailable" };
230
+ });
231
+ activateBridge(sockPath);
232
+
233
+ const outcome = await dispatchViaBridge(makeOptions(), undefined, "tc-1");
234
+
235
+ expect("fallback" in outcome).toBe(false);
236
+ const result = outcome as SubagentResult;
237
+ expect(result.exitCode).toBe(1);
238
+ expect(result.error).toBe("worker runtime unavailable");
239
+ expect(result.progress!.status).toBe("failed");
240
+
241
+ server.close();
242
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
243
+ });
244
+
245
+ it("(3) an unreachable bridge (no response frame) → { fallback: true }", async () => {
246
+ // No server on this socket → ECONNREFUSED → BridgeTransportError.
247
+ activateBridge(tempSocketPath());
248
+
249
+ const outcome = await dispatchViaBridge(makeOptions(), undefined, "tc-1");
250
+ expect(outcome).toEqual({ fallback: true });
251
+ });
252
+
253
+ it("nullifies the params (model/thinking/tools/systemPrompt) for a config-less run", async () => {
254
+ const sockPath = tempSocketPath();
255
+ const frames: Array<Record<string, unknown>> = [];
256
+ const server = await startFakeDesktop(sockPath, (msg) => {
257
+ frames.push(msg);
258
+ return { v: 1, type: "response", id: msg.id, result: { output: "ok", metrics: { inputTokens: 0, outputTokens: 0, cost: 0, durationMs: 1 } } };
259
+ });
260
+ activateBridge(sockPath);
261
+
262
+ const outcome = await dispatchViaBridge(
263
+ makeOptions({ agent: undefined, agentConfig: undefined, model: undefined, activeModel: undefined }),
264
+ undefined,
265
+ "tc-1",
266
+ );
267
+ expect("fallback" in outcome).toBe(false);
268
+
269
+ const params = (frames.find((f) => f.type === "request")?.params ?? {}) as Record<string, unknown>;
270
+ expect(params.agentName).toBe("subagent");
271
+ expect(params.systemPrompt).toBeNull();
272
+ expect(params.model).toBeNull();
273
+ expect(params.thinking).toBeNull();
274
+ expect(params.tools).toBeNull();
275
+ // The result's model is the (un-nullified) resolution — undefined here.
276
+ expect((outcome as SubagentResult).model).toBeUndefined();
277
+
278
+ server.close();
279
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
280
+ });
281
+ });
282
+
283
+ // ── resolveModel precedence (shared fork + bridge) ────────────────────────
284
+
285
+ describe("resolveModel", () => {
286
+ it("(4) agent.model > call model > activeModel; all undefined → undefined", () => {
287
+ expect(resolveModel({ model: "call-model", activeModel: "active-model", agent: AGENT })).toBe("anthropic/claude-agent");
288
+ expect(resolveModel({ model: "call-model", activeModel: "active-model", agent: undefined })).toBe("call-model");
289
+ expect(resolveModel({ model: undefined, activeModel: "active-model", agent: undefined })).toBe("active-model");
290
+ expect(resolveModel({ model: undefined, activeModel: undefined, agent: undefined })).toBeUndefined();
291
+ });
292
+ });
@@ -0,0 +1,175 @@
1
+ // ── Bridge-mode dispatch (dispatch_subagent over the bridge channel) ────
2
+ //
3
+ // In bridge mode the subagent tool does NOT fork a child pi process — it
4
+ // sends a `dispatch_subagent` bridge request and the desktop (Client) runs
5
+ // the subagent session (a full ACP session on its worker runtime).
6
+ //
7
+ // Fallback rule (the BridgeTransportError distinction is the trigger):
8
+ // - NO response frame (unreachable socket / ECONNREFUSED, a mid-connection
9
+ // error, or a clean close before any response — the desktop is
10
+ // effectively gone) → { fallback: true } — the fork path keeps the
11
+ // subagent working (the main agent is still alive — its desktop
12
+ // connection died, not the agent).
13
+ // - A response-carrying `error` (the desktop answered — it is alive and
14
+ // the outcome is authoritative, incl. the terminal `error: "cancelled"`
15
+ // frame) → a FAILED SubagentResult, NEVER a fallback.
16
+ // - A DETERMINISTIC LOCAL CANCEL (the tool's AbortSignal → the channel's
17
+ // cancel() settles with a BridgeCancelledError — the desktop is NOT
18
+ // gone, the user just cancelled) → a FAILED SubagentResult with error
19
+ // "cancelled" (the same outcome as the desktop's terminal
20
+ // `error: "cancelled" frame), NEVER a fallback — a deliberate cancel
21
+ // must never fork. The cancel is detected by TYPE
22
+ // (`instanceof BridgeCancelledError`), not by the message string — the
23
+ // class is the contract between core and this package.
24
+ //
25
+ // The pre-aborted-signal + unreachable-channel corner (cancel() is a no-op
26
+ // there — the executor's early return leaves `finish` as the safe no-op — so
27
+ // the rejection WOULD map to { fallback: true }) is closed at the EXECUTE
28
+ // layer: executeSubagent short-circuits an already-aborted signal to a
29
+ // FAILED result (error "cancelled") before dispatching (the execute layer
30
+ // already has the signal; the channel-level function must not know fork
31
+ // semantics).
32
+
33
+ import { dispatch, BridgeTransportError, BridgeCancelledError } from "@pi-archimedes/core/bridge";
34
+ import { resolveModel } from "./spawn.js";
35
+ import type { ExecuteOptions } from "./execute.js";
36
+ import type { SubagentProgress, SubagentResult } from "./types.js";
37
+
38
+ export type DispatchOutcome = SubagentResult | { fallback: true };
39
+
40
+ /**
41
+ * Synthesize a FAILED SubagentResult (a task that never completed — a
42
+ * response-carrying error, a deterministic local cancel, or a pre-aborted
43
+ * signal). Shared by the bridge error path and executeSubagent's pre-abort
44
+ * short-circuit (and fork-path catch) so the "cancelled" synthesis is not
45
+ * duplicated. The progress is the same defensive synthesis executeSubagent
46
+ * uses: zeros + the real durationMs (the desktop panel is the progress
47
+ * surface in bridge mode; the TUI has none in RPC mode).
48
+ */
49
+ export function buildFailedResult(
50
+ agentName: string,
51
+ task: string,
52
+ error: string,
53
+ model: string | undefined,
54
+ durationMs: number,
55
+ ): SubagentResult {
56
+ return {
57
+ agent: agentName,
58
+ task,
59
+ exitCode: 1,
60
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
61
+ model,
62
+ finalOutput: undefined,
63
+ error,
64
+ progress: {
65
+ agent: agentName,
66
+ status: "failed",
67
+ task,
68
+ currentTool: undefined,
69
+ currentToolArgs: undefined,
70
+ currentToolStartedAt: undefined,
71
+ toolCount: 0,
72
+ inputTokens: 0,
73
+ outputTokens: 0,
74
+ tokens: 0,
75
+ cost: 0,
76
+ durationMs,
77
+ error,
78
+ output: undefined,
79
+ recentOutput: undefined,
80
+ toolCalls: undefined,
81
+ model,
82
+ },
83
+ progressSummary: { toolCount: 0, tokens: 0, durationMs },
84
+ };
85
+ }
86
+
87
+ export function dispatchViaBridge(
88
+ options: ExecuteOptions,
89
+ signal: AbortSignal | undefined,
90
+ toolCallId: string | undefined,
91
+ ): Promise<DispatchOutcome> {
92
+ const agentName = options.agent ?? "subagent";
93
+ const model = resolveModel({ model: options.model, activeModel: options.activeModel, agent: options.agentConfig });
94
+ const startTime = Date.now();
95
+
96
+ // `cwd` is intentionally NOT sent — the subagent session's cwd is the
97
+ // parent's Space folder (the wire contract; the tool's `cwd` param is
98
+ // ignored in bridge mode).
99
+ const params = {
100
+ agentName,
101
+ task: options.task,
102
+ systemPrompt: options.agentConfig?.systemPrompt?.trim() || null,
103
+ model: model ?? null,
104
+ thinking: options.agentConfig?.thinking ?? null,
105
+ tools: options.agentConfig?.tools?.length ? options.agentConfig.tools : null,
106
+ };
107
+
108
+ return dispatch(params, toolCallId, signal).then(
109
+ (res) => {
110
+ // Success → a SubagentResult with a synthesized progress (the same
111
+ // defensive synthesis executeSubagent does; the desktop's panel is the
112
+ // progress surface, so the fields are zeros + the real durationMs).
113
+ const durationMs = res.metrics.durationMs;
114
+ const progress: SubagentProgress = {
115
+ agent: agentName,
116
+ status: "completed",
117
+ task: options.task,
118
+ currentTool: undefined,
119
+ currentToolArgs: undefined,
120
+ currentToolStartedAt: undefined,
121
+ toolCount: 0,
122
+ inputTokens: 0,
123
+ outputTokens: 0,
124
+ tokens: 0,
125
+ cost: 0,
126
+ durationMs,
127
+ error: undefined,
128
+ output: res.output,
129
+ recentOutput: undefined,
130
+ toolCalls: undefined,
131
+ model,
132
+ };
133
+ return {
134
+ agent: agentName,
135
+ task: options.task,
136
+ exitCode: 0,
137
+ usage: {
138
+ input: res.metrics.inputTokens,
139
+ output: res.metrics.outputTokens,
140
+ cacheRead: 0,
141
+ cacheWrite: 0,
142
+ cost: res.metrics.cost,
143
+ turns: 0,
144
+ },
145
+ model,
146
+ finalOutput: res.output,
147
+ error: undefined,
148
+ progress,
149
+ progressSummary: {
150
+ toolCount: 0,
151
+ tokens: res.metrics.inputTokens + res.metrics.outputTokens,
152
+ durationMs,
153
+ },
154
+ };
155
+ },
156
+ (err: unknown) => {
157
+ // No response frame (the desktop is effectively gone) → the fork path
158
+ // keeps the subagent working.
159
+ if (err instanceof BridgeTransportError) {
160
+ return { fallback: true };
161
+ }
162
+ // A response-carrying error (the desktop answered — authoritative,
163
+ // incl. the terminal "cancelled" frame) or a DETERMINISTIC LOCAL CANCEL
164
+ // (the tool's AbortSignal → channel.cancel() settles with a
165
+ // BridgeCancelledError — the same outcome as the desktop's terminal
166
+ // `error: "cancelled" frame) → a FAILED SubagentResult (the shared
167
+ // failed-progress synthesis), NEVER a fallback (a deliberate cancel
168
+ // must never fork).
169
+ const isLocalCancel = err instanceof BridgeCancelledError;
170
+ const errorMessage = isLocalCancel ? "cancelled" : err instanceof Error ? err.message : String(err);
171
+ const durationMs = Date.now() - startTime;
172
+ return buildFailedResult(agentName, options.task, errorMessage, model, durationMs);
173
+ },
174
+ );
175
+ }
@@ -0,0 +1,180 @@
1
+ // ── executeSubagent bridge branch: a deliberate cancel must never fork ──
2
+ //
3
+ // The "no fork on cancel" guarantee at the execute layer: a mid-dispatch
4
+ // abort settles the bridge dispatch as a FAILED result (error "cancelled")
5
+ // and `spawnSubagent` (the fork path) is NEVER called. The bridge is
6
+ // activated through the real env-gated session_start path (registerBridge),
7
+ // so `getBridge().active` is the real gate.
8
+
9
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import * as net from "node:net";
12
+ import * as os from "node:os";
13
+ import * as path from "node:path";
14
+ import * as fs from "node:fs";
15
+ import { randomUUID } from "node:crypto";
16
+ import { registerBridge } from "@pi-archimedes/core/bridge";
17
+ import * as channel from "@pi-archimedes/core/bridge/channel";
18
+ import * as spawn from "./spawn.js";
19
+ import { executeSubagent } from "./execute.js";
20
+ import type { ExecuteOptions } from "./execute.js";
21
+ import type { AgentConfig } from "./agents.js";
22
+ import type { SubagentProgress } from "./types.js";
23
+
24
+ const ENV_KEYS = [
25
+ "PI_ARCHIMEDES_BRIDGE",
26
+ "PI_ARCHIMEDES_BRIDGE_SOCKET",
27
+ "PI_ARCHIMEDES_BRIDGE_SESSION",
28
+ "PI_ARCHIMEDES_BRIDGE_SERVER_PID",
29
+ "PI_SUBAGENT_SOCKET",
30
+ ] as const;
31
+
32
+ const savedEnv: Record<string, string | undefined> = {};
33
+
34
+ function clearBridgeEnv(): void {
35
+ for (const k of ENV_KEYS) delete process.env[k];
36
+ }
37
+
38
+ /** Activate the bridge (env-gated, root-only) pointing at the given socket. */
39
+ function activateBridge(socketPath: string): void {
40
+ clearBridgeEnv();
41
+ process.env.PI_ARCHIMEDES_BRIDGE = "1";
42
+ process.env.PI_ARCHIMEDES_BRIDGE_SOCKET = socketPath;
43
+ process.env.PI_ARCHIMEDES_BRIDGE_SESSION = "test-desktop-session";
44
+ process.env.PI_ARCHIMEDES_BRIDGE_SERVER_PID = "12345";
45
+ const onSpy = vi.fn();
46
+ registerBridge({ on: onSpy } as unknown as ExtensionAPI);
47
+ const calls = onSpy.mock.calls as Array<[string, (e: unknown, ctx: unknown) => void]>;
48
+ const startCall = calls.find((c) => c[0] === "session_start");
49
+ expect(startCall).toBeTruthy();
50
+ startCall![1](null, { mode: "rpc" });
51
+ }
52
+
53
+ function tempSocketPath(): string {
54
+ return path.join(os.tmpdir(), `execute-test-${randomUUID()}.sock`);
55
+ }
56
+
57
+ const AGENT: AgentConfig = {
58
+ name: "reviewer",
59
+ description: "Code reviewer",
60
+ systemPrompt: "You are a reviewer.\n",
61
+ source: "global",
62
+ filePath: "/tmp/reviewer.md",
63
+ model: "anthropic/claude-agent",
64
+ thinking: "medium",
65
+ tools: ["read", "bash"],
66
+ };
67
+
68
+ function makeOptions(overrides: Partial<ExecuteOptions> = {}): ExecuteOptions {
69
+ return {
70
+ agent: "reviewer",
71
+ agentConfig: AGENT,
72
+ task: "review this code",
73
+ model: "call-model",
74
+ activeModel: "active-model",
75
+ cwd: undefined,
76
+ signal: undefined,
77
+ onUpdate: undefined,
78
+ toolCallId: "tc-1",
79
+ ...overrides,
80
+ };
81
+ }
82
+
83
+ function startSilentDesktop(sockPath: string): Promise<net.Server> {
84
+ const server = net.createServer((socket) => {
85
+ /* accept but never respond */
86
+ socket.on("error", () => { /* connection dropped */ });
87
+ });
88
+ return new Promise((resolve) => server.listen(sockPath, () => resolve(server)));
89
+ }
90
+
91
+ beforeEach(() => {
92
+ for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
93
+ clearBridgeEnv();
94
+ });
95
+
96
+ afterEach(() => {
97
+ for (const k of ENV_KEYS) {
98
+ if (savedEnv[k] === undefined) delete process.env[k];
99
+ else process.env[k] = savedEnv[k];
100
+ }
101
+ clearBridgeEnv();
102
+ vi.restoreAllMocks();
103
+ });
104
+
105
+ describe("executeSubagent (bridge branch)", () => {
106
+ it("a mid-dispatch abort settles as a FAILED result and NEVER forks (spawnSubagent not called)", async () => {
107
+ const sockPath = tempSocketPath();
108
+ const server = await startSilentDesktop(sockPath);
109
+ activateBridge(sockPath);
110
+
111
+ // The fork path: if a deliberate cancel ever fell through to it, the
112
+ // spy catches the call (the implementation throws so a fall-through is
113
+ // also loud).
114
+ const spawnSpy = vi.spyOn(spawn, "spawnSubagent").mockImplementation(() => {
115
+ throw new Error("fork must not happen on a deliberate cancel");
116
+ });
117
+
118
+ const controller = new AbortController();
119
+ const p = executeSubagent(makeOptions({ signal: controller.signal }));
120
+ await new Promise((r) => setTimeout(r, 50)); // let the socket connect (request sent)
121
+ controller.abort(); // mid-dispatch: after the request, before the response
122
+
123
+ const result = await p;
124
+ // The bridge outcome is authoritative (a FAILED result) — the fork path
125
+ // (spawnSubagent) is NEVER called for a task the user just cancelled.
126
+ expect(result.exitCode).toBe(1);
127
+ expect(result.error).toBe("cancelled");
128
+ expect(result.progress!.status).toBe("failed");
129
+ expect(spawnSpy).not.toHaveBeenCalled();
130
+
131
+ spawnSpy.mockRestore();
132
+ server.close();
133
+ try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
134
+ });
135
+
136
+ it("a pre-aborted signal short-circuits to a FAILED result and NEVER forks (no 'dispatched' placeholder, no fork)", async () => {
137
+ // The corner: an UNREACHABLE channel (the executor's early return leaves
138
+ // `finish` as a safe no-op, so `handle.cancel()` is a no-op and
139
+ // `channel.request` rejects with a BridgeTransportError → { fallback: true }
140
+ // → the fork path would run for an already-cancelled task). Simulated with
141
+ // `active: true` + no socket target (the ECONNREFUSED variant is NOT the
142
+ // corner — there the async socket error loses the race to the synchronous
143
+ // cancel() and the outcome is already a deterministic "cancelled").
144
+ activateBridge(tempSocketPath());
145
+ channel.configure({ active: true, socketPath: undefined });
146
+
147
+ // The fork path: if the pre-abort corner ever fell through to it, the spy
148
+ // catches the call (the implementation throws so a fall-through is also
149
+ // loud).
150
+ const spawnSpy = vi.spyOn(spawn, "spawnSubagent").mockImplementation(() => {
151
+ throw new Error("fork must not happen for a pre-aborted signal");
152
+ });
153
+
154
+ const controller = new AbortController();
155
+ controller.abort(); // pre-aborted BEFORE dispatch
156
+
157
+ const updates: SubagentProgress[] = [];
158
+ const result = await executeSubagent(
159
+ makeOptions({ signal: controller.signal, onUpdate: (p) => updates.push(p) }),
160
+ );
161
+
162
+ // A deliberate cancel must NEVER fork — even in the unreachable-channel
163
+ // corner: a FAILED SubagentResult with the "cancelled" outcome, and the
164
+ // task does not claim it was "dispatched" (no placeholder before the
165
+ // short-circuit; the final-failure progress still updates the
166
+ // executeParallel slot from the pending placeholder to "failed").
167
+ expect(result.exitCode).toBe(1);
168
+ expect(result.error).toBe("cancelled");
169
+ expect(result.progress!.status).toBe("failed");
170
+ expect(result.progress!.error).toBe("cancelled");
171
+ expect(spawnSpy).not.toHaveBeenCalled();
172
+ // Exactly ONE update: the final-failure progress (no "dispatched"
173
+ // placeholder — a cancelled task does not claim it was dispatched).
174
+ expect(updates).toHaveLength(1);
175
+ expect(updates[0]?.status).toBe("failed");
176
+ expect(updates[0]?.error).toBe("cancelled");
177
+
178
+ spawnSpy.mockRestore();
179
+ });
180
+ });
package/src/execute.ts CHANGED
@@ -1,8 +1,10 @@
1
- import { spawnSubagent } from "./spawn.js";
1
+ import { getBridge } from "@pi-archimedes/core/bridge";
2
+ import { spawnSubagent, resolveModel } from "./spawn.js";
3
+ import { dispatchViaBridge, buildFailedResult } from "./dispatch.js";
2
4
  import { streamEvents } from "./stream.js";
3
5
  import { emitCostUpdate } from "./cost.js";
4
6
  import type { AgentConfig } from "./agents.js";
5
- import type { SubagentProgress, SubagentResult, SubagentUsage } from "./types.js";
7
+ import type { SubagentProgress, SubagentResult } from "./types.js";
6
8
 
7
9
  export interface ExecuteOptions {
8
10
  agent: string | undefined;
@@ -13,6 +15,10 @@ export interface ExecuteOptions {
13
15
  cwd: string | undefined;
14
16
  signal: AbortSignal | undefined;
15
17
  onUpdate: ((progress: SubagentProgress) => void) | undefined;
18
+ /** The subagent tool-call id (the tool's `_id`) — rides the dispatch frame
19
+ * for Client correlation; `undefined` → the frame's `toolCallId` is absent.
20
+ * In `executeParallel` all N tasks share the single subagent tool-call's id. */
21
+ toolCallId?: string;
16
22
  }
17
23
 
18
24
  /**
@@ -22,6 +28,71 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
22
28
  const agentName = options.agent ?? "subagent";
23
29
  const startTime = Date.now();
24
30
 
31
+ // Bridge mode: dispatch to the desktop (Client) instead of forking a child
32
+ // pi process. An UNREACHABLE bridge (no response frame — e.g. macOS, where
33
+ // the desktop's listener is not started, or Windows, where it is a no-op
34
+ // stub) falls back to the fork path so subagents keep working everywhere.
35
+ if (getBridge().active) {
36
+ const model = resolveModel({ model: options.model, activeModel: options.activeModel, agent: options.agentConfig });
37
+
38
+ // A signal that is ALREADY aborted before dispatch: short-circuit to a
39
+ // FAILED result (error "cancelled") — a deliberate cancel must never
40
+ // fork, even in the corner where the channel is unreachable (the
41
+ // executor's early return leaves `finish` as a safe no-op, so
42
+ // `handle.cancel()` is a no-op and the rejection would map to
43
+ // { fallback: true } → the fork path for an already-cancelled task).
44
+ // This layer (not dispatchViaBridge) is the home: the execute layer
45
+ // already has the signal; the channel-level function must not know fork
46
+ // semantics. The short-circuit is BEFORE the "dispatched" placeholder —
47
+ // a cancelled task does not claim it was dispatched — but the final
48
+ // failure progress IS emitted (the same emit the catch block below does),
49
+ // so executeParallel's progress slot updates from the pending placeholder
50
+ // to "failed" instead of a stale "Starting...".
51
+ if (options.signal?.aborted) {
52
+ const result = buildFailedResult(agentName, options.task, "cancelled", model, Date.now() - startTime);
53
+ options.onUpdate?.(result.progress!);
54
+ return result;
55
+ }
56
+
57
+ // The TUI has no surface in bridge mode (RPC mode) and the desktop panel
58
+ // is the progress surface — call onUpdate ONCE with a minimal "dispatched"
59
+ // placeholder so the pi-acp tool_call_update frames stay well-formed, then
60
+ // no further updates.
61
+ options.onUpdate?.({
62
+ agent: agentName,
63
+ status: "running",
64
+ task: options.task,
65
+ currentTool: undefined,
66
+ currentToolArgs: undefined,
67
+ currentToolStartedAt: undefined,
68
+ toolCount: 0,
69
+ inputTokens: 0,
70
+ outputTokens: 0,
71
+ tokens: 0,
72
+ cost: 0,
73
+ durationMs: 0,
74
+ error: undefined,
75
+ output: undefined,
76
+ recentOutput: undefined,
77
+ toolCalls: undefined,
78
+ model,
79
+ });
80
+ const outcome = await dispatchViaBridge(options, options.signal, options.toolCallId);
81
+ if (!("fallback" in outcome)) {
82
+ // Bridge used (success or a response-carrying error, incl. the terminal
83
+ // "cancelled" frame — the desktop is alive, its outcome is authoritative):
84
+ // return it — NO fork fallback, and NO emitCostUpdate here — the PARENT
85
+ // never pushes the subagent's usage on the PARENT's bus (the fork-delta
86
+ // semantics: only the fork path reports a child's usage, source
87
+ // `subagent:<name>`). The subagent process self-emits its own usage to
88
+ // its OWN bridge (the core self-usage emitter,
89
+ // `subagent-metrics-cost-push` Task 1) — a different bus, a different
90
+ // session; no double-count.
91
+ return outcome;
92
+ }
93
+ // { fallback: true } — unreachable bridge: fall through to the fork path.
94
+ }
95
+
25
96
  // Track previously emitted values to only emit deltas
26
97
  let lastEmittedInput = 0;
27
98
  let lastEmittedOutput = 0;
@@ -95,46 +166,13 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
95
166
  } catch (err) {
96
167
  const errorMessage = err instanceof Error ? err.message : String(err);
97
168
  const durationMs = Date.now() - startTime;
98
- const result: SubagentResult = {
99
- agent: agentName,
100
- task: options.task,
101
- exitCode: 1,
102
- usage: {
103
- input: 0,
104
- output: 0,
105
- cacheRead: 0,
106
- cacheWrite: 0,
107
- cost: 0,
108
- turns: 0,
109
- } as SubagentUsage,
110
- model: undefined,
111
- finalOutput: undefined,
112
- error: errorMessage,
113
- // Always return a valid progress object so the parallel renderer's
114
- // `details.progress[i]` stays aligned with `details.results[i]`.
115
- // Returning undefined here would be filtered out and cause index
116
- // misalignment between results and progress in the parallel view.
117
- progress: {
118
- agent: agentName,
119
- status: "failed",
120
- task: options.task,
121
- currentTool: undefined,
122
- currentToolArgs: undefined,
123
- currentToolStartedAt: undefined,
124
- toolCount: 0,
125
- inputTokens: 0,
126
- outputTokens: 0,
127
- tokens: 0,
128
- cost: 0,
129
- durationMs,
130
- error: errorMessage,
131
- output: undefined,
132
- recentOutput: undefined,
133
- toolCalls: undefined,
134
- model: undefined,
135
- },
136
- progressSummary: { toolCount: 0, tokens: 0, durationMs },
137
- };
169
+ // The shared failed-result synthesis (the same one the bridge error path
170
+ // uses — model is undefined here: the fork never ran, so no model was
171
+ // chosen). Always returns a valid progress object so the parallel
172
+ // renderer's `details.progress[i]` stays aligned with `details.results[i]`
173
+ // (returning undefined would be filtered out and cause index misalignment
174
+ // between results and progress in the parallel view).
175
+ const result = buildFailedResult(agentName, options.task, errorMessage, undefined, durationMs);
138
176
  // Emit final failure progress so executeParallel's progress slot updates
139
177
  // from the pending placeholder to "failed" (prevents stale "Starting..." display).
140
178
  options.onUpdate?.(result.progress!);
@@ -146,7 +184,7 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
146
184
  * Execute multiple subagents in parallel.
147
185
  */
148
186
  export async function executeParallel(options: {
149
- tasks: Array<{ agent: string | undefined; agentConfig: AgentConfig | undefined; task: string; model: string | undefined; activeModel: string | undefined; cwd: string | undefined }>;
187
+ tasks: Array<{ agent: string | undefined; agentConfig: AgentConfig | undefined; task: string; model: string | undefined; activeModel: string | undefined; cwd: string | undefined; toolCallId?: string }>;
150
188
  signal: AbortSignal | undefined;
151
189
  onUpdate: ((progress: SubagentProgress[]) => void) | undefined;
152
190
  }): Promise<SubagentResult[]> {
package/src/index.ts CHANGED
@@ -96,6 +96,9 @@ export function registerSubagent(pi: ExtensionAPI): void {
96
96
  model: t.model,
97
97
  activeModel: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
98
98
  cwd: t.cwd ?? undefined,
99
+ // All N tasks share the single subagent tool-call's id (the frame
100
+ // carries it for Client correlation).
101
+ toolCallId: _id,
99
102
  })),
100
103
  signal: signal ?? undefined,
101
104
  onUpdate: (progress: SubagentProgress[]) => {
@@ -163,6 +166,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
163
166
  activeModel: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
164
167
  cwd: params.cwd ?? undefined,
165
168
  signal: signal ?? undefined,
169
+ toolCallId: _id,
166
170
  onUpdate: (progress: SubagentProgress) => {
167
171
  onUpdate?.({
168
172
  content: [],
@@ -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
@@ -16,6 +16,15 @@ export interface SpawnOptions {
16
16
  agent: AgentConfig | undefined;
17
17
  }
18
18
 
19
+ /**
20
+ * Resolve the model for a subagent run: agent config model > call model >
21
+ * active (parent) model. Shared by the fork path (spawnSubagent) and the
22
+ * bridge path (dispatch) so both resolve identically.
23
+ */
24
+ export function resolveModel(options: Pick<SpawnOptions, "model" | "activeModel"> & { agent?: AgentConfig | undefined }): string | undefined {
25
+ return options.agent?.model ?? options.model ?? options.activeModel;
26
+ }
27
+
19
28
  /**
20
29
  * Resolve the pi binary path.
21
30
  *
@@ -67,17 +76,22 @@ function resolvePiBinary(): string {
67
76
  * Start a Unix socket server that bridges the child's ask tool to the parent bus.
68
77
  *
69
78
  * 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
79
+ * { type: "ask_request", requestId, questions, toolCallId } as a JSON line
71
80
  * and waits for:
72
81
  * { type: "ask_response", requestId, cancelled, results } as a JSON line
73
82
  *
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.
83
+ * We forward the request onto the bus (ASK_REQUEST, carrying toolCallId), the
84
+ * 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.
86
+ *
87
+ * When the socket closes (child exit, or the child's own 5-min timeout →
88
+ * socket.end()) we signal the bridge via ASK_CANCEL for any still-pending ask.
89
+ * We do NOT emit ASK_RESPONSE ourselves — the bridge is the sole emitter
90
+ * (no double-decrement of its refcount).
77
91
  *
78
92
  * Returns the socket path and a cleanup function.
79
93
  */
80
- function startAskSocketServer(agentName: string): { socketPath: string; cleanup: () => void } {
94
+ export function startAskSocketServer(agentName: string): { socketPath: string; cleanup: () => void } {
81
95
  // Use named pipes on Windows, Unix domain sockets elsewhere.
82
96
  // Linux socket path limit is 108 chars — keep it short.
83
97
  const id = randomUUID().slice(0, 8);
@@ -110,6 +124,8 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
110
124
 
111
125
  const server = net.createServer((socket) => {
112
126
  let buffer = "";
127
+ // requestIds seen on this socket (for the ASK_CANCEL wiring below)
128
+ const socketRequestIds = new Set<string>();
113
129
 
114
130
  socket.on("data", (chunk: Buffer) => {
115
131
  buffer += chunk.toString("utf-8");
@@ -124,6 +140,7 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
124
140
  type: string;
125
141
  requestId: string;
126
142
  questions: unknown[];
143
+ toolCallId?: string;
127
144
  };
128
145
  if (msg.type === "ask_request") {
129
146
  // Register write-back so ASK_RESPONSE handler can find this socket
@@ -134,11 +151,14 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
134
151
  // socket already closed
135
152
  }
136
153
  });
137
- // Forward to bus — ask package will show the TUI dialog
154
+ socketRequestIds.add(msg.requestId);
155
+ // Forward to bus — the ask package (or the bridge, in bridge mode)
156
+ // answers it; toolCallId rides along for Client correlation
138
157
  getBus().emit(Events.ASK_REQUEST, {
139
158
  source: `subagent:${agentName}`,
140
159
  requestId: msg.requestId,
141
160
  questions: msg.questions,
161
+ toolCallId: msg.toolCallId,
142
162
  });
143
163
  }
144
164
  } catch {
@@ -147,6 +167,19 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
147
167
  }
148
168
  });
149
169
 
170
+ // Child exit (or the child's own 5-min timeout → socket.end()) closes the
171
+ // socket: signal the bridge via ASK_CANCEL for any still-pending ask so it
172
+ // can cancel the relayed Client request. We only signal — the bridge is
173
+ // the sole ASK_RESPONSE emitter (no double-decrement of its refcount).
174
+ socket.on("close", () => {
175
+ for (const rid of socketRequestIds) {
176
+ if (pending.has(rid)) {
177
+ getBus().emit(Events.ASK_CANCEL, { requestId: rid, source: `subagent:${agentName}` });
178
+ pending.delete(rid);
179
+ }
180
+ }
181
+ });
182
+
150
183
  socket.on("error", () => { /* connection dropped */ });
151
184
  });
152
185
 
@@ -181,7 +214,7 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
181
214
  const args: string[] = ["--mode", "json", "--no-session", "-p"];
182
215
 
183
216
  // Model: agent.model > options.model > options.activeModel
184
- const model = options.agent?.model ?? options.model ?? options.activeModel;
217
+ const model = resolveModel(options);
185
218
  if (model) {
186
219
  args.push("--model", model);
187
220
  }
@@ -245,7 +278,12 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
245
278
  forceKill.unref();
246
279
  }
247
280
  };
248
- options.signal.addEventListener("abort", abortHandler, { once: true });
281
+ // A signal that is ALREADY aborted never dispatches its "abort" event (it
282
+ // fires exactly once, at abort time), so addEventListener alone would never
283
+ // kill the child — call the handler directly in that case. (kill is
284
+ // idempotent via the child.killed guard, so both branches coexist safely.)
285
+ if (options.signal.aborted) abortHandler();
286
+ else options.signal.addEventListener("abort", abortHandler, { once: true });
249
287
  child.on("exit", () => options.signal!.removeEventListener("abort", abortHandler));
250
288
  }
251
289
 
@@ -192,6 +192,42 @@ describe("streamEvents todo mirroring", () => {
192
192
  ]);
193
193
  });
194
194
 
195
+ it("stows raw args under the alias key todo_list (result has no details)", async () => {
196
+ const updates: Array<{ source: string; todos: unknown[] }> = [];
197
+ const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
198
+ updates.push(p as (typeof updates)[number]),
199
+ );
200
+
201
+ await finishWith(
202
+ [
203
+ {
204
+ type: "tool_execution_start",
205
+ toolCallId: "t-alias",
206
+ toolName: "manage_todo_list",
207
+ args: {
208
+ operation: "write",
209
+ todo_list: [{ id: 1, title: "Fix auth", status: "not-started" }],
210
+ },
211
+ },
212
+ {
213
+ type: "tool_execution_end",
214
+ toolCallId: "t-alias",
215
+ toolName: "manage_todo_list",
216
+ isError: false,
217
+ result: { content: [{ type: "text", text: "ok" }] },
218
+ },
219
+ ],
220
+ { agent: "t-alias" },
221
+ );
222
+ off();
223
+
224
+ expect(updates).toHaveLength(1);
225
+ expect(updates[0]!.source).toBe("subagent:t-alias");
226
+ expect(updates[0]!.todos).toEqual([
227
+ { content: "Fix auth", status: "pending" },
228
+ ]);
229
+ });
230
+
195
231
  it("does not crash or emit when the end event has no matching start", async () => {
196
232
  const updates: Array<{ source: string; todos: unknown[] }> = [];
197
233
  const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
package/src/stream.ts CHANGED
@@ -2,7 +2,7 @@ import { createInterface } from "node:readline";
2
2
  import type { ChildProcess } from "node:child_process";
3
3
  import type { StreamState, SubagentProgress, SubagentResult } from "./types.js";
4
4
  import { getBus, Events } from "@pi-archimedes/core/bus";
5
- import { normalizeTodoItems } from "@pi-archimedes/todo/prepare-args";
5
+ import { normalizeTodoItems, pickTodoListRaw } from "@pi-archimedes/todo/prepare-args";
6
6
  import {
7
7
  type JsonEvent,
8
8
  handleToolStart,
@@ -150,7 +150,9 @@ export function streamEvents(
150
150
  emitProgress();
151
151
  if (event.toolName === "manage_todo_list" && typeof event.toolCallId === "string") {
152
152
  const args = event.args as Record<string, unknown> | undefined;
153
- const todoList = args?.todoList;
153
+ // Raw (pre-prepareArguments) args: the child model may have used
154
+ // an alias key (todo_list/todos) — pickTodoListRaw covers it.
155
+ const todoList = pickTodoListRaw(args);
154
156
  if (Array.isArray(todoList)) {
155
157
  pendingTodoArgs.set(event.toolCallId, todoList);
156
158
  }