@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 +1 -1
- package/package.json +3 -3
- package/src/format.test.ts +90 -1
- package/src/format.ts +23 -0
- package/src/index.ts +2 -12
- package/src/spawn.test.ts +119 -0
- package/src/spawn.ts +36 -7
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.
|
|
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.
|
|
19
|
-
"@pi-archimedes/todo": "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",
|
package/src/format.test.ts
CHANGED
|
@@ -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:
|
|
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
|
|
75
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|