@evandu/super-pi 0.0.0-stage → 0.1.1

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 (59) hide show
  1. package/README.md +79 -2
  2. package/agents/coder.md +10 -0
  3. package/agents/critic.md +20 -0
  4. package/agents/patcher.md +23 -0
  5. package/agents/planner.md +18 -0
  6. package/agents/reviewer.md +13 -0
  7. package/agents/scout.md +23 -0
  8. package/agents/super-pi-task/core.ts +485 -0
  9. package/agents/super-pi-task/index.ts +141 -0
  10. package/agents/verifier.md +21 -0
  11. package/bin/super-pi.js +17 -0
  12. package/dist/client/assets/index-BMQsEdio.js +24 -0
  13. package/dist/client/assets/index-CsbZzuMy.css +1 -0
  14. package/dist/client/index.html +14 -0
  15. package/dist/extension.d.ts +15 -0
  16. package/dist/extension.js +111 -0
  17. package/dist/server/agents.d.ts +177 -0
  18. package/dist/server/agents.js +484 -0
  19. package/dist/server/application/catalog-service.d.ts +44 -0
  20. package/dist/server/application/catalog-service.js +94 -0
  21. package/dist/server/application/conversation-service.d.ts +73 -0
  22. package/dist/server/application/conversation-service.js +202 -0
  23. package/dist/server/application/extension-ui-service.d.ts +28 -0
  24. package/dist/server/application/extension-ui-service.js +73 -0
  25. package/dist/server/application/run-service.d.ts +115 -0
  26. package/dist/server/application/run-service.js +341 -0
  27. package/dist/server/application/workspace-service.d.ts +45 -0
  28. package/dist/server/application/workspace-service.js +106 -0
  29. package/dist/server/command-handler.d.ts +22 -0
  30. package/dist/server/command-handler.js +137 -0
  31. package/dist/server/files.d.ts +202 -0
  32. package/dist/server/files.js +629 -0
  33. package/dist/server/http-server.d.ts +20 -0
  34. package/dist/server/http-server.js +313 -0
  35. package/dist/server/index.d.ts +1 -0
  36. package/dist/server/index.js +103 -0
  37. package/dist/server/pi-command.d.ts +65 -0
  38. package/dist/server/pi-command.js +183 -0
  39. package/dist/server/runtime.d.ts +819 -0
  40. package/dist/server/runtime.js +1470 -0
  41. package/dist/server/server-context.d.ts +46 -0
  42. package/dist/server/server-context.js +1 -0
  43. package/dist/server/store.d.ts +424 -0
  44. package/dist/server/store.js +1121 -0
  45. package/dist/server/subagent-runs.d.ts +142 -0
  46. package/dist/server/subagent-runs.js +315 -0
  47. package/dist/server/subagents.d.ts +194 -0
  48. package/dist/server/subagents.js +542 -0
  49. package/dist/server/task-tool.d.ts +196 -0
  50. package/dist/server/task-tool.js +285 -0
  51. package/dist/server/tool-calls.d.ts +62 -0
  52. package/dist/server/tool-calls.js +141 -0
  53. package/dist/server/types.d.ts +920 -0
  54. package/dist/server/types.js +11 -0
  55. package/dist/server/upload.d.ts +37 -0
  56. package/dist/server/upload.js +169 -0
  57. package/dist/server/ws-server.d.ts +45 -0
  58. package/dist/server/ws-server.js +116 -0
  59. package/package.json +115 -4
@@ -0,0 +1,542 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdir, stat } from "node:fs/promises";
4
+ import { join, resolve } from "node:path";
5
+ import { agentSpawnArgs, readAgents } from "./agents.js";
6
+ import { resolvePiCommand, shellQuote } from "./pi-command.js";
7
+ import { buildExtensionUiResponse, describeExtensionUiRequest, killTree, RpcChannel, } from "./runtime.js";
8
+ import { describeToolCall, TOOL_DETAIL_CAP } from "./tool-calls.js";
9
+ /**
10
+ * The tool-call derivation now lives in `tool-calls.js`, because the primary agent's runtime needs the
11
+ * identical mapping and `subagents.ts` already imports `runtime.ts` - so the shared definition has to sit
12
+ * below both of them.
13
+ *
14
+ * RE-EXPORTED rather than moved silently. `scripts/agent-vis-records.test.mjs` imports both of these from
15
+ * this module, and a card that quietly lost its only derivation would still typecheck. Keeping the names
16
+ * exported here means that test keeps pointing at the one definition that also serves a call block.
17
+ */
18
+ export { describeToolCall, TOOL_DETAIL_CAP };
19
+ /**
20
+ * Spawning and draining ONE child Pi, per [docs/SUBAGENT-DESIGN.md](../docs/SUBAGENT-DESIGN.md) §4.
21
+ * No records, no events, no store: this file's whole job is that a `mode: subagent` definition becomes a
22
+ * real OS process whose text comes back as a stream and a final answer. What the browser does with that
23
+ * is a later step, and keeping it out is why `run()` returns a value instead of emitting one.
24
+ *
25
+ * The mechanism is the primary agent's, reused rather than reinvented: the same `readAgents` /
26
+ * `agentSpawnArgs`, the same `resolvePiCommand` / `shellQuote` resolution, the same `RpcChannel` and
27
+ * JSONL drain loop, the same `killTree`. A sub-agent is the same thing one layer down.
28
+ */
29
+ /** How long the drain loop waits for a record before re-checking the child and the caller's signal. */
30
+ const DRAIN_POLL_MS = 100;
31
+ /**
32
+ * The run id every dispatch gets unless a caller supplies one: `sub_<uuid>`, and Pi's `--session-id`
33
+ * alongside it. Exported so a caller that must know the id BEFORE the spawn - the recorder, which has to
34
+ * write a `running` row before the child exists - generates exactly the id this would have generated.
35
+ */
36
+ export const newSubagentRunId = () => `sub_${randomUUID()}`;
37
+ /** Token/cost usage for a run that produced no finished message. */
38
+ const NO_USAGE = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
39
+ /**
40
+ * Pi's session records that ARE part of one turn's lifecycle, so this supervisor has nothing to do with
41
+ * them, and so announcing them once per distinct type and then tallying the rest turns an ordinary run
42
+ * into a thousand warnings.
43
+ *
44
+ * THE CRITERION IS "WE KNOW EXACTLY WHAT THIS IS", not "it is noisy" and not "we are bored of it". Each
45
+ * name below is a member of Pi's own published `AgentSessionEvent` union, and every one of them arrives on
46
+ * every single run: `session.subscribe((event) => output(toJsonEvent(event)))` forwards the union
47
+ * unfiltered (modes/rpc/rpc-mode.ts:355-356), so a host that acts on a handful of them and treats the rest
48
+ * as silence is the only possible design here. Membership is therefore a CLAIM about Pi's protocol, and
49
+ * `scripts/subagent-noise.test.mjs` is what keeps the claim honest in both directions: a fake Pi emitting
50
+ * this whole set must produce no warning, and a fake Pi emitting a type that is NOT in it must still
51
+ * produce one. That second half is the point - the day Pi adds a record here, it lands in the tally
52
+ * instead of vanishing (the failure mode R2 §4.3/P1-1 records: a record dropped silently makes a protocol
53
+ * change look like it worked).
54
+ *
55
+ * What is deliberately NOT in this set, even though Pi documents all of it: `queue_update`,
56
+ * `compaction_start` / `compaction_end`, `auto_retry_start` / `auto_retry_end`, `summarization_retry_*`,
57
+ * `bash_execution_update`, `session_info_changed`, `thinking_level_changed`. Those are known too, but
58
+ * this supervisor has no answer for them - a compacted or retried run is a run whose answer a caller
59
+ * should distrust, and R2 P1-1 names `queue_update` as exactly the record a future feature must consume
60
+ * rather than have quietly swallowed. "We know what it is" is the necessary condition for exemption here;
61
+ * it is not sufficient, and these keep announcing until something actually handles them.
62
+ */
63
+ const KNOWN_LIFECYCLE_RECORDS = new Set([
64
+ // Pi's own framing of a run, one pair per child (packages/agent/src/types.ts:518-519). The bookends
65
+ // carry no information this supervisor can use, and `agent_end` carries every message in the transcript,
66
+ // which by contract Super Pi never reads - see `run()`'s "Pi owns the directory".
67
+ "agent_start",
68
+ "agent_end",
69
+ // The turn and message boundaries AROUND the records this loop does act on. `turn_end` is where Pi
70
+ // finishes a turn and `message_start` / `message_end` bracket every message; the loop needs none of
71
+ // them because it tracks the assistant message through the inner `start` / `done` events instead
72
+ // (modes/json-event.ts:40-44 says the same: "message_start provides the initial message, deltas build
73
+ // it, and message_end provides the final authoritative message").
74
+ "turn_start",
75
+ "turn_end",
76
+ "message_start",
77
+ "message_end",
78
+ // The rest of ONE tool call's lifecycle. `tool_execution_start` is handled and reported as the tool
79
+ // line; these two are the same call's progress and result, carrying `partialResult` / `result` payloads
80
+ // that `SubagentToolCall` deliberately drops because they can be large and can hold file contents. One
81
+ // line per call is the whole contract, so two more records per call is pure repetition.
82
+ "tool_execution_update",
83
+ "tool_execution_end",
84
+ // Pi telling us it appended an entry to the transcript it owns. Same contract as `agent_end`: Super Pi
85
+ // never reads Pi's transcript, so there is nothing to do with an entry-level receipt.
86
+ "entry_appended",
87
+ ]);
88
+ /**
89
+ * The inner events of a `message_update` that are part of an assistant message's construction, for the
90
+ * same reason and under the same criterion as `KNOWN_LIFECYCLE_RECORDS` - every name is a member of Pi's
91
+ * published `AssistantMessageEvent` union (packages/ai/src/types.ts:788-804) minus `partial`, and each one
92
+ * fires on every run. The union's members the loop DOES consume are `start`, `text_delta`, `done` and
93
+ * `error`; these are the rest.
94
+ */
95
+ const KNOWN_LIFECYCLE_EVENTS = new Set([
96
+ // The boundaries of the very `text_delta` stream this loop accumulates. Real Pi emits a `text_start` /
97
+ // `text_end` pair around EVERY text block, so a run with one paragraph per turn emits two of these per
98
+ // turn for output the loop already has.
99
+ "text_start",
100
+ "text_end",
101
+ // The model's reasoning stream, as a whole lifecycle rather than just its `thinking_delta` - one real
102
+ // run logged 1449 of them, which is one per token of thought. Nothing here is a bug or a protocol
103
+ // surprise; this supervisor's contract is `finalText`, not the model's private thinking.
104
+ // ROADMAP NOTE: if thinking ever becomes part of a sub-agent card, this is where it is plumbed in -
105
+ // accumulate the deltas and render them - and THAT change should move these three out of this set
106
+ // deliberately, at the same time as it teaches the UI to show them. Until then they are known and
107
+ // deliberately unused, which is different from unknown.
108
+ "thinking_start",
109
+ "thinking_delta",
110
+ "thinking_end",
111
+ // A tool call being assembled inside the message. The tool LINE this supervisor reports comes from the
112
+ // outer `tool_execution_start`, which carries the authoritative `toolCallId` and `toolName`; these three
113
+ // are the incremental JSON of arguments the loop deliberately does not keep.
114
+ "toolcall_start",
115
+ "toolcall_delta",
116
+ "toolcall_end",
117
+ ]);
118
+ /**
119
+ * A run that ended because the caller aborted it.
120
+ *
121
+ * A named error rather than a message a caller has to pattern-match, because "the user pressed stop" and
122
+ * "the child failed" are different outcomes for whoever reports this run later, and collapsing them into
123
+ * one rejected promise would force that decision back out of string matching.
124
+ */
125
+ export class SubagentCancelled extends Error {
126
+ runId;
127
+ constructor(runId) {
128
+ super(`sub-agent run ${runId} was cancelled before it settled`);
129
+ this.runId = runId;
130
+ this.name = "SubagentCancelled";
131
+ }
132
+ }
133
+ /**
134
+ * The text of one assistant message: its FIRST text block, exactly as the reference implementation reads
135
+ * it (examples/extensions/subagent/index.ts:170-180). A message whose only content is a tool call has no
136
+ * text, so "" is a real answer here rather than a failure.
137
+ */
138
+ const textOf = (part) => part !== null && typeof part === "object" && part.type === "text" && typeof part.text === "string"
139
+ ? part.text
140
+ : undefined;
141
+ const assistantText = (message) => {
142
+ const content = message?.content;
143
+ if (!Array.isArray(content))
144
+ return "";
145
+ for (const part of content) {
146
+ const text = textOf(part);
147
+ if (text !== undefined)
148
+ return text;
149
+ }
150
+ return "";
151
+ };
152
+ /**
153
+ * Adds one finished message's usage into the run total.
154
+ *
155
+ * Read ONLY on `done`, never on a delta: Pi repeats the same per-message usage on every update of that
156
+ * message (modes/json-event.ts:56), so summing per delta would count a message's tokens once per token
157
+ * in it. `cost` is `usage.cost.total`, the provider's own number rather than a re-derivation of it.
158
+ */
159
+ const addUsage = (total, usage) => {
160
+ total.input += usage?.input ?? 0;
161
+ total.output += usage?.output ?? 0;
162
+ total.cacheRead += usage?.cacheRead ?? 0;
163
+ total.cacheWrite += usage?.cacheWrite ?? 0;
164
+ total.cost += usage?.cost?.total ?? 0;
165
+ total.turns += 1;
166
+ };
167
+ /** Spawns sub-agent children Pi and drains their JSONL RPC streams. */
168
+ export class SubagentSupervisor {
169
+ sessionDir;
170
+ agents;
171
+ pi;
172
+ /** Live children by run id, so a caller can name the process it means. Cleared in the run's `finally`. */
173
+ children = new Map();
174
+ /**
175
+ * `command` and `agents` are the same two override points `PiRpcRuntime` takes, and for the same
176
+ * reasons: a test needs to launch a fake peer, and a Pi installed somewhere unusual needs to be
177
+ * reachable. `Pi` is resolved once, because a resolution that could change mid-run would make one
178
+ * child's spawn line depend on when it was started.
179
+ */
180
+ constructor(sessionDir, command, agents = { dir: join(sessionDir, "agents"), promptDir: join(sessionDir, "agent-prompts") }) {
181
+ this.sessionDir = sessionDir;
182
+ this.agents = agents;
183
+ this.pi = resolvePiCommand({ command });
184
+ if (!this.pi.found)
185
+ console.warn(`SubagentSupervisor: ${this.pi.reason}`);
186
+ }
187
+ /**
188
+ * Every child this supervisor still owns, optionally narrowed to one conversation.
189
+ *
190
+ * A live view rather than a returned handle, because the caller cannot have the id before the run:
191
+ * `run()` returns the RESULT, and a stop button needs the pid while the promise is still pending.
192
+ * Deleted as soon as the child is gone, so a caller that polls it sees the truth rather than a
193
+ * remembered one.
194
+ */
195
+ liveRuns(conversationId) {
196
+ return [...this.children].map(([id, run]) => ({ id, conversationId: run.conversationId, pid: run.child.pid }));
197
+ }
198
+ /**
199
+ * Runs one sub-agent to completion and returns what it said.
200
+ *
201
+ * The three spawn-time decisions, all of which the design fixes rather than leaves open:
202
+ *
203
+ * - `--mode rpc`, NOT the reference implementation's `--mode json -p`. In print mode a child that
204
+ * wants to ask something has nowhere to ask and hangs; in RPC mode its questions arrive as
205
+ * `extension_ui_request` records on the channel Super Pi already owns. That is the whole reason this
206
+ * supervisor exists instead of shipping Pi's extension.
207
+ * - `--session-dir` shared by every child, `--session-id` unique per run, so Pi owns the directory and
208
+ * Super Pi never has to read a transcript it could only misread.
209
+ * - `agentSpawnArgs` verbatim, so a sub-agent's tool allowlist and prompt go through the one proven
210
+ * translation rather than a second hand-rolled one.
211
+ *
212
+ * The TASK goes over RPC stdin, never on argv. The reference passes it as a positional argument, which
213
+ * under `shell: true` on Windows is cmd.exe's to parse: an ampersand or a pipe in a model's own words
214
+ * becomes a command. `{ type: "prompt" }` is exactly how the primary agent is already prompted, so
215
+ * this costs nothing and removes the whole class of bug.
216
+ */
217
+ async run(input) {
218
+ const { conversationId, agentId, task, cwd, model, host, signal, onTextDelta, onToolCall, onToolEnd, onThinking, onStage } = input;
219
+ // Refused here, with the reason, rather than handed to the spawn: an ENOENT from `spawn` reads as
220
+ // "the app is broken" and hides the one fact that matters, which is where it looked for Pi.
221
+ if (!this.pi.found)
222
+ throw new Error(this.pi.reason);
223
+ if (!task.trim())
224
+ throw new Error(`sub-agent ${JSON.stringify(agentId)} was dispatched with an empty task`);
225
+ // The caller's id, or the one this would have made. Refused rather than sanitised: a mangled id
226
+ // would still spawn, still write a Pi transcript, and still be unreconcilable at the next startup.
227
+ const supplied = input.id;
228
+ if (supplied !== undefined && !/^sub_[A-Za-z0-9_-]{1,120}$/.test(supplied))
229
+ throw new Error(`A caller-supplied sub-agent run id must look like ${newSubagentRunId()}; got ${JSON.stringify(supplied)}.`);
230
+ const id = supplied ?? newSubagentRunId();
231
+ // Normalized first, and required to be a real directory: a deleted workspace is a visible failure,
232
+ // never a reason to run the child somewhere else.
233
+ const childCwd = resolve(cwd);
234
+ const info = await stat(childCwd).catch(() => undefined);
235
+ if (!info?.isDirectory())
236
+ throw new Error(`Workspace directory for conversation ${conversationId} does not exist or is not a directory: ${childCwd}. Refusing to run sub-agent ${JSON.stringify(agentId)} in any other directory.`);
237
+ // Fetched before the spawn, so a bad definition fails the run loudly instead of starting a Pi that
238
+ // silently ignores it - and so a PRIMARY agent cannot be dispatched as a sub-agent by accident.
239
+ const agent = await this.subagentDefinition(agentId);
240
+ // The child's own `tools:` map, minus whatever the dispatching agent denied. Never the parent's
241
+ // ALLOWLIST - see `SubagentRunInput.denyTools` for why that asymmetry is the reference's too.
242
+ const agentArgs = await agentSpawnArgs(this.agents.promptDir, agent, { denyTools: input.denyTools ?? [] });
243
+ const sessionDirectory = join(this.sessionDir, "subagents");
244
+ await mkdir(sessionDirectory, { recursive: true });
245
+ const args = ["--mode", "rpc", "--session-dir", sessionDirectory, "--session-id", id, ...agentArgs];
246
+ // Model inheritance: the definition wins, otherwise the parent's current model, otherwise Pi's own
247
+ // default. "Omit the model and get what the parent is running on" is the default worth having, and it
248
+ // is a spawn-time flag because there is no RPC command to change it later.
249
+ const chosen = agent.model ?? (model?.provider && model.modelId ? { provider: model.provider, modelId: model.modelId } : undefined);
250
+ if (chosen)
251
+ args.push("--provider", chosen.provider, "--model", chosen.modelId);
252
+ if (agent.thinkingLevel)
253
+ args.push("--thinking", agent.thinkingLevel);
254
+ // npm exposes global CLIs as .cmd shims on Windows; Node requires a shell to launch those shims.
255
+ const child = spawn(shellQuote(this.pi.command), args, { cwd: childCwd, stdio: ["pipe", "pipe", "pipe"], shell: process.platform === "win32" });
256
+ this.children.set(id, { conversationId, child });
257
+ const channel = new RpcChannel(child);
258
+ // Pi is asked to stop first and killed only if that is not enough, so a child that can unwind its own
259
+ // tools gets to. `channel.write` is deliberately not used: it throws on a closed pipe, and an abort
260
+ // handler that throws inside a listener would surface as an unhandled rejection instead of a cancel.
261
+ const abort = () => {
262
+ if (child.exitCode === null && !child.stdin.destroyed)
263
+ child.stdin.write(`${JSON.stringify({ id: "abort", type: "abort" })}\n`);
264
+ };
265
+ signal?.addEventListener("abort", abort, { once: true });
266
+ /** Every delta of the run, for a child that streams text but never sends a `done`. */
267
+ let streamed = "";
268
+ /** The current assistant message's text, reset by `start` and replaced by `done`. */
269
+ let current = "";
270
+ /** The last assistant message's text, which is the run's output. */
271
+ let finalText = "";
272
+ const usage = { ...NO_USAGE };
273
+ /** Set rather than thrown so a child's own failure is reported AFTER its output has been drained. */
274
+ let failed;
275
+ /**
276
+ * Which phase the child is in, for a caller that has to say where a run died.
277
+ *
278
+ * Changed ONLY at a record the drain loop actually consumed, so `onStage` cannot report a phase that
279
+ * never happened - which is what keeps a failure card's 「卡在哪」 an observation rather than a guess.
280
+ * Starts at `dispatch`, which covers every refusal above the spawn: an unknown agent, a deleted
281
+ * workspace, a Pi that is not installed. Those throw before a child exists, and "it never got as
282
+ * far as being asked anything" is the accurate thing to say about all of them.
283
+ */
284
+ let stage = "dispatch";
285
+ const enter = (next) => {
286
+ if (stage === next)
287
+ return;
288
+ stage = next;
289
+ onStage?.(next);
290
+ };
291
+ try {
292
+ channel.write({ id: "prompt", type: "prompt", message: task });
293
+ enter("prompt");
294
+ let settled = false;
295
+ // Nothing may be dropped without a trace. Pi emits a wide, open-ended family of session events, so
296
+ // an unhandled record is announced once per distinct type and then tallied instead of logged per event.
297
+ // "Unhandled" means "not in KNOWN_LIFECYCLE_RECORDS / KNOWN_LIFECYCLE_EVENTS and not consumed above",
298
+ // i.e. genuinely unrecognised - NOT merely frequent. The exemption sets are the whole reason an
299
+ // ordinary run is quiet while a protocol change is still loud; keep that ordering when editing them.
300
+ const announced = new Set();
301
+ const repeats = new Map();
302
+ const noteUnhandled = (key) => {
303
+ if (announced.has(key))
304
+ repeats.set(key, (repeats.get(key) ?? 0) + 1);
305
+ else {
306
+ announced.add(key);
307
+ console.warn(`SubagentSupervisor: unhandled child record ${key} - consumed without action.`);
308
+ }
309
+ };
310
+ // A child asking a question is exactly why RPC mode was chosen, and `host` is the other half of
311
+ // that choice: with one, the question reaches a human and Pi stays blocked for as long as they take.
312
+ // The host speaks the same protocol the primary agent's does (`describeExtensionUiRequest` /
313
+ // `buildExtensionUiResponse` are shared with `PiRpcRuntime`), so there is exactly one definition of
314
+ // "what Pi's nine methods mean" in the codebase rather than one per spawner.
315
+ //
316
+ // The reply always carries the record's OWN `requestId`, never the host's key for it: the host may
317
+ // scope that key per run or per conversation so two questions cannot settle each other, and leaking
318
+ // that prefix back into the child's protocol would make Pi wait forever on an id it never issued.
319
+ const ui = {
320
+ seen: new Set(),
321
+ aborted: new Promise((resolve) => { if (signal?.aborted)
322
+ resolve(undefined);
323
+ else
324
+ signal?.addEventListener("abort", () => resolve(undefined), { once: true }); }),
325
+ exited: new Promise((resolve) => child.once("exit", () => resolve(undefined))),
326
+ };
327
+ const handleExtensionUi = async (record) => {
328
+ const request = describeExtensionUiRequest(record);
329
+ if (!request) {
330
+ console.warn(`SubagentSupervisor: sub-agent ${agentId} (${id}) sent extension_ui_request method=${JSON.stringify(record.method ?? null)} id=${JSON.stringify(record.id ?? null)}, which is not one of Pi's nine Extension UI methods - consumed without reply.`);
331
+ return;
332
+ }
333
+ // `channel.write` is deliberately not used for the reply, for the same reason it is not used for
334
+ // the abort: it throws on a closed pipe, and a throw from inside a host callback would surface as
335
+ // an unhandled rejection rather than as the failure it actually is.
336
+ const reply = (response) => { if (child.exitCode === null && !child.stdin.destroyed)
337
+ child.stdin.write(`${JSON.stringify(response)}\n`); };
338
+ // The five one-way methods carry no question and Pi holds no pending entry for them, so a response
339
+ // would resolve nothing. Pushed to the host un-awaited; a host that throws is announced, not fatal.
340
+ if (!request.blocking) {
341
+ try {
342
+ host?.notify(request);
343
+ }
344
+ catch (error) {
345
+ console.warn(`SubagentSupervisor: extension_ui notify for ${request.method} id=${request.requestId} in sub-agent ${agentId} (${id}) threw in the host: ${error instanceof Error ? error.message : "unknown error"}`);
346
+ }
347
+ return;
348
+ }
349
+ // Pi parks one pending entry per request id, so a duplicate id is the same question twice. Answering
350
+ // it twice would write two responses into one slot.
351
+ if (ui.seen.has(request.requestId)) {
352
+ console.warn(`SubagentSupervisor: sub-agent ${agentId} (${id}) sent extension_ui_request ${request.method} id=${request.requestId} twice - second copy consumed without reply.`);
353
+ return;
354
+ }
355
+ ui.seen.add(request.requestId);
356
+ if (!host) {
357
+ // No human can be asked, so the honest reply is "cancelled". Auto-answering with options[0] or
358
+ // confirmed:false would look to Pi exactly like a decision, and the user would never learn a
359
+ // question was asked of them.
360
+ console.warn(`SubagentSupervisor: sub-agent ${agentId} (${id}) used ${request.method} id=${request.requestId}, and this run has no UI host to answer it - cancelled rather than answering on a human's behalf.`);
361
+ reply({ type: "extension_ui_response", id: request.requestId, cancelled: true });
362
+ return;
363
+ }
364
+ // Raced against abort and exit, because this `await` parks the drain loop and the loop cannot see
365
+ // either of them on its own: a question outliving its run would leave the child blocked forever on
366
+ // a reply that can no longer be written.
367
+ const cancelled = { cancelled: true };
368
+ let answer;
369
+ try {
370
+ answer = await Promise.race([host.ask(request), ui.aborted.then(() => cancelled), ui.exited.then(() => cancelled)]);
371
+ }
372
+ catch (error) {
373
+ console.warn(`SubagentSupervisor: sub-agent ${agentId} (${id}) got no answer for ${request.method} id=${request.requestId} (host failed: ${error instanceof Error ? error.message : "unknown error"}) - cancelled.`);
374
+ answer = cancelled;
375
+ }
376
+ reply(buildExtensionUiResponse(request, answer));
377
+ };
378
+ while (!settled) {
379
+ // Checked between polls rather than only on the abort event: a child that ignores `abort` must
380
+ // not be able to hold the run open, and the kill in `finally` is what actually ends it.
381
+ if (signal?.aborted)
382
+ throw new SubagentCancelled(id);
383
+ for (;;) {
384
+ const record = await channel.next(DRAIN_POLL_MS);
385
+ if (!record)
386
+ break;
387
+ if (record.type === "response" && record.id === "prompt" && record.success === false)
388
+ throw new Error(`Pi rejected the sub-agent task: ${record.error ?? "no reason given"}`);
389
+ if (record.type === "message_update") {
390
+ const event = record.assistantMessageEvent;
391
+ // `text_delta` is the only inner event that is output. `start` and `done` are the boundaries
392
+ // that make "the last assistant message" knowable at all: without them a child that produced
393
+ // three messages would report all three concatenated as one answer.
394
+ if (event?.type === "text_delta") {
395
+ const delta = event.delta ?? "";
396
+ streamed += delta;
397
+ current += delta;
398
+ enter("turn");
399
+ onTextDelta?.(delta);
400
+ }
401
+ else if (event?.type === "start") {
402
+ current = "";
403
+ enter("turn");
404
+ }
405
+ else if (event?.type === "done") {
406
+ // `done` carries the AUTHORITATIVE message, so its text wins over what the deltas added up
407
+ // to; the accumulated text stays as the fallback for a child that streams without a `done`.
408
+ current = assistantText(event.message) || current;
409
+ finalText = current;
410
+ addUsage(usage, record.usage);
411
+ enter("turn");
412
+ const reason = event.message?.stopReason ?? event.reason;
413
+ if (reason === "error" || reason === "aborted")
414
+ failed ??= `sub-agent ${agentId} (${id}) stopped with reason ${reason}: ${assistantText(event.message) || "no output"}`;
415
+ }
416
+ else if (event?.type === "error") {
417
+ failed ??= `sub-agent ${agentId} (${id}) reported ${event.reason ?? "an error"}: ${assistantText(event.message) || "no detail from Pi"}`;
418
+ }
419
+ else if (event?.type === "thinking_delta") {
420
+ // CONSUMED, not discarded. A reasoning model spends the first seconds of every turn here,
421
+ // emitting no text at all, so this record is the only evidence the child is alive and
422
+ // working during exactly the window a card otherwise looks frozen. See `onThinking`.
423
+ enter("turn");
424
+ onThinking?.();
425
+ continue;
426
+ }
427
+ else if (KNOWN_LIFECYCLE_EVENTS.has(event?.type ?? "")) {
428
+ // Known, and this supervisor has no use for it - see the set's own comment for why each name
429
+ // qualifies. Deliberately NOT a blanket ignore: anything outside the set still falls through
430
+ // to `noteUnhandled`, which is what catches Pi adding an event shape.
431
+ }
432
+ else {
433
+ noteUnhandled(`message_update:${JSON.stringify(event?.type ?? "(missing)")}`);
434
+ }
435
+ continue;
436
+ }
437
+ if (record.type === "tool_execution_start") {
438
+ // The `args` on this record are exactly what used to be thrown away with the record itself.
439
+ // They are read, not copied: `describeToolCall` turns them into one bounded label and the
440
+ // object itself goes no further. See that function for the whole argument-vs-transcript line.
441
+ enter("tool");
442
+ onToolCall?.({ id: record.toolCallId ?? "", name: record.toolName ?? "unknown", detail: describeToolCall(record.toolName ?? "unknown", record.args) });
443
+ continue;
444
+ }
445
+ if (record.type === "tool_execution_end") {
446
+ // THE OTHER HALF OF THE SAME CALL, and the half that makes a row mean something. Until Pi
447
+ // started sending this, a `read` row could only ever say that a `read` happened: not whether
448
+ // it found the file, not how long a slow call had been running, and - for a card whose child
449
+ // is still working - not whether the row was even finished yet.
450
+ //
451
+ // ONLY `isError` AND `durationMs` CROSS OVER. `result` is deliberately not read: for a `read`
452
+ // it is the file's contents, and a card row is persisted in a file that is serialized on
453
+ // every single broadcast. `durationMs` is Pi's own number off a monotonic clock, so it is
454
+ // the one outcome fact available for EVERY tool rather than only for result shapes this code
455
+ // would have to guess at.
456
+ enter("turn");
457
+ onToolEnd?.({
458
+ id: record.toolCallId ?? "",
459
+ name: record.toolName ?? "unknown",
460
+ status: record.isError === true ? "error" : "ok",
461
+ ...(typeof record.durationMs === "number" && Number.isFinite(record.durationMs) ? { durationMs: Math.max(0, Math.round(record.durationMs)) } : {}),
462
+ });
463
+ continue;
464
+ }
465
+ if (record.type === "extension_ui_request") {
466
+ await handleExtensionUi(record);
467
+ continue;
468
+ }
469
+ // Pi caught this inside its own extension host, so it never becomes a thrown failure there. Every
470
+ // run is a fresh process, so any extension_error seen while draining belongs to the task in
471
+ // flight - and a sub-agent whose extension failed has not really done the job.
472
+ if (record.type === "extension_error")
473
+ throw new Error(`Pi extension error in ${record.extensionPath ?? "unknown path"} (${record.event ?? "unknown event"}): ${record.error ?? "unknown error"}`);
474
+ if (record.type === "agent_settled") {
475
+ enter("settle");
476
+ settled = true;
477
+ continue;
478
+ }
479
+ // Known lifecycle, no action needed - see the set's own comment. Anything NOT in the set lands on
480
+ // `noteUnhandled` below, so a record type this build has never heard of is still announced.
481
+ if (KNOWN_LIFECYCLE_RECORDS.has(record.type ?? ""))
482
+ continue;
483
+ noteUnhandled(`type ${JSON.stringify(record.type ?? "(missing type)")}`);
484
+ }
485
+ // A dead child will never settle, so waiting out another poll would only delay the failure.
486
+ if (!settled && child.exitCode !== null)
487
+ throw new Error(`sub-agent ${agentId} (${id}) exited before settling (code ${child.exitCode}). ${channel.stderr.trim()}`);
488
+ }
489
+ for (const [key, count] of repeats)
490
+ console.warn(`SubagentSupervisor: ${count} further unhandled child record(s) of ${key}.`);
491
+ // Checked again after the loop, because the poll above can consume the very record `agent_settled`
492
+ // emits and the abort at the same time: a run the user stopped must not report success just because
493
+ // Pi happened to finish while it was being cancelled.
494
+ if (signal?.aborted)
495
+ throw new SubagentCancelled(id);
496
+ if (failed)
497
+ throw new Error(failed);
498
+ // No `done` at all is a child that streamed and vanished; the deltas are then the best answer there
499
+ // is, and returning them beats returning "" for work that demonstrably happened.
500
+ return { id, sessionId: id, finalText: finalText || streamed, usage };
501
+ }
502
+ finally {
503
+ signal?.removeEventListener("abort", abort);
504
+ this.children.delete(id);
505
+ // The run is not over until the child is actually gone: a process still tearing down still holds a
506
+ // lock on its working directory, so a caller that removes that directory the moment run() settles
507
+ // would hit EBUSY on Windows.
508
+ if (child.exitCode === null && child.pid !== undefined) {
509
+ if (!child.killed) {
510
+ // The TREE, never the shim: Super Pi spawns pi.cmd through a .cmd shim with shell:true, so the
511
+ // direct child is cmd.exe and the real Pi is its grandchild. Killing only the shim leaks a Pi
512
+ // that keeps holding the workspace directory, which is the failure this exact call already had
513
+ // to be written for on the primary agent's side.
514
+ if (process.platform === "win32")
515
+ await killTree(child.pid);
516
+ else
517
+ child.kill();
518
+ }
519
+ if (child.exitCode === null) {
520
+ // The 5s backstop matches the primary agent's side (runtime.ts answer() / readCatalog()): the
521
+ // kill precedes this subscription, so an exit landing in between is dropped and the promise
522
+ // would never settle - hanging the sub-agent run instead of ending it.
523
+ await new Promise((resolve) => { child.once("exit", resolve); setTimeout(resolve, 5000); });
524
+ }
525
+ }
526
+ }
527
+ }
528
+ /**
529
+ * The definition this run will use, or the reason there is none.
530
+ *
531
+ * Read with `mode: "subagent"`, so a primary agent is not found by id at all rather than found and
532
+ * then judged: a sub-agent definition is the same `agents/*.md` file with a different `mode`, and this
533
+ * is the filter that keeps the two rosters from ever meeting.
534
+ */
535
+ async subagentDefinition(agentId) {
536
+ const { agents, problems } = await readAgents(this.agents.dir, { mode: "subagent" });
537
+ const agent = agents.find((item) => item.id === agentId);
538
+ if (agent)
539
+ return agent;
540
+ throw new Error(`sub-agent ${JSON.stringify(agentId)} is not a subagent definition in ${this.agents.dir}${problems.length ? ` (${problems.length} file(s) there are not usable agents)` : ""}. A sub-agent is an agents/*.md file with \`mode: subagent\`; a primary agent is chosen by a human and is deliberately not dispatchable as one.`);
541
+ }
542
+ }