@evandu/super-pi 0.0.0-stage → 0.1.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 +79 -2
- package/agents/coder.md +10 -0
- package/agents/critic.md +20 -0
- package/agents/patcher.md +23 -0
- package/agents/planner.md +18 -0
- package/agents/reviewer.md +13 -0
- package/agents/scout.md +23 -0
- package/agents/super-pi-task/core.ts +485 -0
- package/agents/super-pi-task/index.ts +141 -0
- package/agents/verifier.md +21 -0
- package/bin/super-pi.js +17 -0
- package/dist/client/assets/index-BMQsEdio.js +24 -0
- package/dist/client/assets/index-CsbZzuMy.css +1 -0
- package/dist/client/index.html +14 -0
- package/dist/extension.d.ts +15 -0
- package/dist/extension.js +111 -0
- package/dist/server/agents.d.ts +177 -0
- package/dist/server/agents.js +484 -0
- package/dist/server/application/catalog-service.d.ts +44 -0
- package/dist/server/application/catalog-service.js +94 -0
- package/dist/server/application/conversation-service.d.ts +73 -0
- package/dist/server/application/conversation-service.js +202 -0
- package/dist/server/application/extension-ui-service.d.ts +28 -0
- package/dist/server/application/extension-ui-service.js +73 -0
- package/dist/server/application/run-service.d.ts +115 -0
- package/dist/server/application/run-service.js +341 -0
- package/dist/server/application/workspace-service.d.ts +45 -0
- package/dist/server/application/workspace-service.js +106 -0
- package/dist/server/command-handler.d.ts +22 -0
- package/dist/server/command-handler.js +137 -0
- package/dist/server/files.d.ts +202 -0
- package/dist/server/files.js +629 -0
- package/dist/server/http-server.d.ts +20 -0
- package/dist/server/http-server.js +313 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +103 -0
- package/dist/server/pi-command.d.ts +65 -0
- package/dist/server/pi-command.js +183 -0
- package/dist/server/runtime.d.ts +819 -0
- package/dist/server/runtime.js +1470 -0
- package/dist/server/server-context.d.ts +46 -0
- package/dist/server/server-context.js +1 -0
- package/dist/server/store.d.ts +424 -0
- package/dist/server/store.js +1121 -0
- package/dist/server/subagent-runs.d.ts +142 -0
- package/dist/server/subagent-runs.js +315 -0
- package/dist/server/subagents.d.ts +194 -0
- package/dist/server/subagents.js +542 -0
- package/dist/server/task-tool.d.ts +196 -0
- package/dist/server/task-tool.js +285 -0
- package/dist/server/tool-calls.d.ts +62 -0
- package/dist/server/tool-calls.js +141 -0
- package/dist/server/types.d.ts +920 -0
- package/dist/server/types.js +11 -0
- package/dist/server/upload.d.ts +37 -0
- package/dist/server/upload.js +169 -0
- package/dist/server/ws-server.d.ts +45 -0
- package/dist/server/ws-server.js +116 -0
- 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
|
+
}
|