@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,142 @@
1
+ import type { ExtensionUiHost } from "./runtime.js";
2
+ import { type SubagentSupervisor } from "./subagents.js";
3
+ import type { Store } from "./store.js";
4
+ import type { ModelSelection, ServerEvent, SubagentRun } from "./types.js";
5
+ /**
6
+ * Making one sub-agent run VISIBLE and RECOVERABLE, per
7
+ * [docs/SUBAGENT-DESIGN.md](../../docs/SUBAGENT-DESIGN.md) §5-§6.
8
+ *
9
+ * `subagents.ts` spawns and drains; this file remembers and publishes. The split is the same one Part 1
10
+ * drew: a spawner that also wrote records would have to be tested against a store to prove it streams,
11
+ * and a store that also spawned children could not be tested without one. Everything here is about the
12
+ * two obligations the design names and nothing else:
13
+ *
14
+ * 1. **A record before the child.** Written as `running` BEFORE `run()` is called, so a server that dies
15
+ * mid-run leaves an orphan - and `Store.open` fails that orphan with a reason on the next start.
16
+ * 2. **A snapshot on every delta.** `subagent.event` carries the run as it stands, not just at the end,
17
+ * because streaming a sub-agent is the entire reason Super Pi spawns its own instead of shipping Pi's
18
+ * extension (design §2, reason 1).
19
+ *
20
+ * What deliberately does NOT go into either is the child's transcript. See `SubagentRun`.
21
+ */
22
+ /**
23
+ * The model-visible output cap, 50 KB - design doc §6's `PER_TASK_OUTPUT_CAP`, taken from the reference
24
+ * implementation (`index.ts:36`) rather than invented here.
25
+ *
26
+ * APPLIED TO THE STORED COPY ONLY. A human looking at a sub-agent is debugging it, and trimming its
27
+ * answer to fit somebody else's context window is the wrong trade for them; the browser gets every byte
28
+ * through `subagent.event`, and `outputTruncated` is what tells a reader the stored copy is less than
29
+ * what was said. The parent model is handed the capped copy, because that is the window it actually has.
30
+ */
31
+ export declare const PER_TASK_OUTPUT_CAP: number;
32
+ /** What a caller supplies to dispatch one run. The workspace directory is required, like `AnswerInput.cwd`. */
33
+ export type SubagentDispatch = {
34
+ id?: string;
35
+ conversationId: string;
36
+ agentId: string;
37
+ task: string;
38
+ /**
39
+ * Who decided to run this. Defaults to "human" when omitted, which is right for every caller that
40
+ * predates the task tool; the `/api/subagent/dispatch` endpoint - reachable only from the task tool -
41
+ * passes "model" explicitly.
42
+ */
43
+ dispatchedBy?: "human" | "model";
44
+ parentRunId?: string;
45
+ depth?: number;
46
+ loopId?: string;
47
+ /** Optional orchestration label. The dispatcher treats labels as opaque data. */
48
+ role?: string;
49
+ /** The dispatching conversation's workspace, resolved by the caller. Never defaulted to the server's own directory. */
50
+ cwd: string;
51
+ model?: ModelSelection;
52
+ /**
53
+ * Tools the dispatching agent denied, inherited by the child. Carried all the way down to
54
+ * `agentSpawnArgs` for the same reason it exists on `SubagentRunInput`: a parent's deny is inherited,
55
+ * its allowlist is not.
56
+ */
57
+ denyTools?: readonly string[];
58
+ /** Cancels this run. Also cancelled by `abortAll`, so a caller may pass either or both. */
59
+ signal?: AbortSignal;
60
+ };
61
+ /** What the recorder needs from the server around it. Injected, so this file holds no globals of its own. */
62
+ export type SubagentRunHooks = {
63
+ store: Store;
64
+ /**
65
+ * Publishes one event to whoever is listening. Kept as a plain function rather than a socket set so
66
+ * this file can be tested with an array, and so a second consumer (a future HTTP probe, say) can be
67
+ * attached without changing the recorder.
68
+ */
69
+ broadcast: (event: ServerEvent) => void;
70
+ /**
71
+ * Builds the host that answers a CHILD's questions, keyed by the run as well as the conversation
72
+ * (design §4). Optional because a headless caller has nobody to ask; a child that asks in that case is
73
+ * cancelled, never guessed at.
74
+ */
75
+ host?: (conversationId: string, runId: string) => ExtensionUiHost;
76
+ };
77
+ export declare class SubagentRunRecorder {
78
+ private readonly supervisor;
79
+ private readonly hooks;
80
+ /**
81
+ * One entry per in-flight run, so `abortAll` reaches children the caller cannot name and `stop` can
82
+ * reach one the caller CAN.
83
+ *
84
+ * The conversation id is carried alongside the controller rather than looked up from the supervisor's
85
+ * live view, for two reasons that are really one: the supervisor's entry is deleted the moment the
86
+ * run settles (so it is absent exactly when a stop is most likely to be sent - the card reacting to
87
+ * a late click), and `run.stop` needs the CONVERSATION a run belongs to, which only the recorder
88
+ * knows before the supervisor is asked.
89
+ */
90
+ private readonly controllers;
91
+ constructor(supervisor: SubagentSupervisor, hooks: SubagentRunHooks);
92
+ /** Every child still running, optionally narrowed to one conversation. The supervisor owns the pids. */
93
+ liveRuns(conversationId?: string): {
94
+ id: string;
95
+ conversationId: string;
96
+ pid: number | undefined;
97
+ }[];
98
+ /**
99
+ * Stops ONE run, and says whether there was anything to stop.
100
+ *
101
+ * THIS IS THE BUTTON THE 32B GAP LEFT OPEN. `run.stop` used to abort the conversation's own Pi and
102
+ * nothing else, so a sub-agent the user could see on screen kept running - and kept holding the
103
+ * workspace directory - after they pressed stop. The abort here is the SAME signal the supervisor
104
+ * turns into an `abort` write followed by `killTree` on Windows, so stopping one really does reach
105
+ * the grandchild Pi rather than only the `.cmd` shim.
106
+ *
107
+ * `false` for an unknown or already-settled id is deliberate, not a swallowed failure: the click
108
+ * that produces it raced the run's own terminal `subagent.event`, and an `error` reply for that
109
+ * would put a failure banner on top of a card that says it finished normally.
110
+ */
111
+ stop(runId: string): boolean;
112
+ /**
113
+ * Stops every run in flight for one conversation, and returns how many were signalled.
114
+ *
115
+ * What `run.stop` on a conversation now does to the children it started, so the conversation's own
116
+ * stop button and a sub-agent card's stop button end the same way. Same reason for the return value
117
+ * as `stop`: it is a count the caller can log, not an error.
118
+ */
119
+ stopConversation(conversationId: string): number;
120
+ /**
121
+ * Aborts every run in flight.
122
+ *
123
+ * Called on shutdown, and the reason it is worth having: a child left running when the server exits
124
+ * keeps holding its workspace directory, and Windows will then refuse to delete it. Startup
125
+ * reconciliation cleans up the RECORD of such a run; this stops the PROCESS from being one.
126
+ */
127
+ abortAll(): void;
128
+ /**
129
+ * Runs one sub-agent, recording it from before the spawn to after the settle, and returns the record
130
+ * as it ended.
131
+ *
132
+ * ALWAYS RESOLVES, including for a dispatch that never started - an unknown agent, a deleted workspace,
133
+ * a Pi that is not installed. By the time `run()` is called the `running` record is already written, so
134
+ * the honest account of "this never ran" is a record that says exactly why, next to the runs that did.
135
+ * Rejecting instead would leave that row claiming to be running until the next restart, which is the
136
+ * orphan this whole file is about.
137
+ *
138
+ * A caller that wants the child's answer rather than a verdict reads `status` and `output` off the
139
+ * returned record; it does not pattern-match an error message to find out whether work happened.
140
+ */
141
+ dispatch(input: SubagentDispatch): Promise<SubagentRun>;
142
+ }
@@ -0,0 +1,315 @@
1
+ import { SubagentCancelled, newSubagentRunId } from "./subagents.js";
2
+ /**
3
+ * Making one sub-agent run VISIBLE and RECOVERABLE, per
4
+ * [docs/SUBAGENT-DESIGN.md](../../docs/SUBAGENT-DESIGN.md) §5-§6.
5
+ *
6
+ * `subagents.ts` spawns and drains; this file remembers and publishes. The split is the same one Part 1
7
+ * drew: a spawner that also wrote records would have to be tested against a store to prove it streams,
8
+ * and a store that also spawned children could not be tested without one. Everything here is about the
9
+ * two obligations the design names and nothing else:
10
+ *
11
+ * 1. **A record before the child.** Written as `running` BEFORE `run()` is called, so a server that dies
12
+ * mid-run leaves an orphan - and `Store.open` fails that orphan with a reason on the next start.
13
+ * 2. **A snapshot on every delta.** `subagent.event` carries the run as it stands, not just at the end,
14
+ * because streaming a sub-agent is the entire reason Super Pi spawns its own instead of shipping Pi's
15
+ * extension (design §2, reason 1).
16
+ *
17
+ * What deliberately does NOT go into either is the child's transcript. See `SubagentRun`.
18
+ */
19
+ /**
20
+ * The model-visible output cap, 50 KB - design doc §6's `PER_TASK_OUTPUT_CAP`, taken from the reference
21
+ * implementation (`index.ts:36`) rather than invented here.
22
+ *
23
+ * APPLIED TO THE STORED COPY ONLY. A human looking at a sub-agent is debugging it, and trimming its
24
+ * answer to fit somebody else's context window is the wrong trade for them; the browser gets every byte
25
+ * through `subagent.event`, and `outputTruncated` is what tells a reader the stored copy is less than
26
+ * what was said. The parent model is handed the capped copy, because that is the window it actually has.
27
+ */
28
+ export const PER_TASK_OUTPUT_CAP = 50 * 1024;
29
+ export class SubagentRunRecorder {
30
+ supervisor;
31
+ hooks;
32
+ /**
33
+ * One entry per in-flight run, so `abortAll` reaches children the caller cannot name and `stop` can
34
+ * reach one the caller CAN.
35
+ *
36
+ * The conversation id is carried alongside the controller rather than looked up from the supervisor's
37
+ * live view, for two reasons that are really one: the supervisor's entry is deleted the moment the
38
+ * run settles (so it is absent exactly when a stop is most likely to be sent - the card reacting to
39
+ * a late click), and `run.stop` needs the CONVERSATION a run belongs to, which only the recorder
40
+ * knows before the supervisor is asked.
41
+ */
42
+ controllers = new Map();
43
+ constructor(supervisor, hooks) {
44
+ this.supervisor = supervisor;
45
+ this.hooks = hooks;
46
+ }
47
+ /** Every child still running, optionally narrowed to one conversation. The supervisor owns the pids. */
48
+ liveRuns(conversationId) {
49
+ return this.supervisor.liveRuns(conversationId);
50
+ }
51
+ /**
52
+ * Stops ONE run, and says whether there was anything to stop.
53
+ *
54
+ * THIS IS THE BUTTON THE 32B GAP LEFT OPEN. `run.stop` used to abort the conversation's own Pi and
55
+ * nothing else, so a sub-agent the user could see on screen kept running - and kept holding the
56
+ * workspace directory - after they pressed stop. The abort here is the SAME signal the supervisor
57
+ * turns into an `abort` write followed by `killTree` on Windows, so stopping one really does reach
58
+ * the grandchild Pi rather than only the `.cmd` shim.
59
+ *
60
+ * `false` for an unknown or already-settled id is deliberate, not a swallowed failure: the click
61
+ * that produces it raced the run's own terminal `subagent.event`, and an `error` reply for that
62
+ * would put a failure banner on top of a card that says it finished normally.
63
+ */
64
+ stop(runId) {
65
+ const entry = this.controllers.get(runId);
66
+ if (!entry)
67
+ return false;
68
+ entry.controller.abort();
69
+ return true;
70
+ }
71
+ /**
72
+ * Stops every run in flight for one conversation, and returns how many were signalled.
73
+ *
74
+ * What `run.stop` on a conversation now does to the children it started, so the conversation's own
75
+ * stop button and a sub-agent card's stop button end the same way. Same reason for the return value
76
+ * as `stop`: it is a count the caller can log, not an error.
77
+ */
78
+ stopConversation(conversationId) {
79
+ let stopped = 0;
80
+ for (const entry of this.controllers.values())
81
+ if (entry.conversationId === conversationId) {
82
+ entry.controller.abort();
83
+ stopped += 1;
84
+ }
85
+ return stopped;
86
+ }
87
+ /**
88
+ * Aborts every run in flight.
89
+ *
90
+ * Called on shutdown, and the reason it is worth having: a child left running when the server exits
91
+ * keeps holding its workspace directory, and Windows will then refuse to delete it. Startup
92
+ * reconciliation cleans up the RECORD of such a run; this stops the PROCESS from being one.
93
+ */
94
+ abortAll() {
95
+ for (const entry of this.controllers.values())
96
+ entry.controller.abort();
97
+ }
98
+ /**
99
+ * Runs one sub-agent, recording it from before the spawn to after the settle, and returns the record
100
+ * as it ended.
101
+ *
102
+ * ALWAYS RESOLVES, including for a dispatch that never started - an unknown agent, a deleted workspace,
103
+ * a Pi that is not installed. By the time `run()` is called the `running` record is already written, so
104
+ * the honest account of "this never ran" is a record that says exactly why, next to the runs that did.
105
+ * Rejecting instead would leave that row claiming to be running until the next restart, which is the
106
+ * orphan this whole file is about.
107
+ *
108
+ * A caller that wants the child's answer rather than a verdict reads `status` and `output` off the
109
+ * returned record; it does not pattern-match an error message to find out whether work happened.
110
+ */
111
+ async dispatch(input) {
112
+ // `dispatchedBy` defaults to "human" because every caller that existed before the task tool was a
113
+ // person pressing the button, and the model's own calls are the exception that must be marked.
114
+ const { conversationId, agentId, task, cwd, model, denyTools, dispatchedBy = "human" } = input;
115
+ // Generated HERE, not by `run()`, because the record has to exist first - see this file's header.
116
+ const id = input.id ?? newSubagentRunId();
117
+ const started = { id, conversationId, agentId, task, dispatchedBy, parentRunId: input.parentRunId, depth: input.depth ?? 0, loopId: input.loopId, role: input.role, status: "running", startedAt: new Date().toISOString(), sessionId: id };
118
+ await this.hooks.store.beginSubagentRun(started);
119
+ // The recorder's own copy, held separately from the store's. Mutating the object `beginSubagentRun`
120
+ // was handed would reach into the store's state behind its back, and every snapshot it hands out
121
+ // would then move under its reader.
122
+ const record = { ...started };
123
+ /** The child's deltas so far. Persisted on every one, exactly as a conversation's assistant text is. */
124
+ let streamed = "";
125
+ /**
126
+ * Tools the child has started, oldest first. Accumulated here rather than taken from the record,
127
+ * for the same reason `streamed` is: the recorder's copy is what a snapshot is built from, so
128
+ * mutating the store's object would move it under every reader.
129
+ */
130
+ let toolCalls = [];
131
+ /**
132
+ * Pi's tool-call id → the row it became, so a `tool_execution_end` can find the call it belongs to.
133
+ *
134
+ * Held here rather than on the record, because `SubagentToolCall` deliberately carries no id: card
135
+ * rows are ordered, not addressable, and an id on a persisted row would be an identity nothing
136
+ * outside Pi can resolve. The mapping only has to survive the run, which is exactly its scope.
137
+ */
138
+ const toolRowById = new Map();
139
+ /**
140
+ * What the child was doing last, or undefined while it has done nothing observable yet.
141
+ *
142
+ * The gap this exists for is a card that FREEZES: a run that spends its whole life inside one long
143
+ * tool call emits no `text_delta`, so a browser watching only the text sees nothing change and
144
+ * cannot tell a working child from a dead one. `at` is what lets the card say how stale the news is.
145
+ */
146
+ let activity;
147
+ /**
148
+ * The phase the child last reached. Starts at `dispatch`, which covers every refusal before the spawn
149
+ * and is what a card should say about a run whose child never existed. See `SubagentFailure`.
150
+ */
151
+ let stage = "dispatch";
152
+ const publish = () => {
153
+ record.output = streamed;
154
+ record.toolCalls = toolCalls;
155
+ record.activity = activity;
156
+ // WRITE BEFORE ANNOUNCE, and this is the whole defect. The order of the two statements below is
157
+ // load-bearing; swapping them back reintroduces a card that loses text it already streamed.
158
+ //
159
+ // The hook a caller installs announces the event and THEN broadcasts `state` (`index.ts`), so both
160
+ // frames leave here in that order whatever this function does internally. Announcing first made the
161
+ // `state` frame a snapshot of a store this delta had not reached yet - the write below had not even
162
+ // been CALLED. The browser REPLACES on `state` (`App.tsx`), so a card that had just been handed the
163
+ // right text had it taken back one frame later.
164
+ //
165
+ // Why some cards lived anyway and this one did not: a child that keeps talking recovers by accident,
166
+ // because its NEXT delta publishes a `state` once the write queue has drained. A child that says one
167
+ // thing and goes quiet never publishes again, so nothing restores it - and it is not only that child's
168
+ // card. Every later `state` broadcast from any command, including ones about an unrelated
169
+ // conversation, reads that same stale record.
170
+ //
171
+ // The write is still not awaited, and still must not be: `onTextDelta` is synchronous, and a drain loop
172
+ // that flushed to disk per token would throttle the child. Reordering buys the correctness anyway,
173
+ // because `Store.setSubagentRun` applies its patch BEFORE its first `await` - the same adjacency
174
+ // `claimQueuedMessage` relies on to make claiming atomic. `Store.snapshot` clones in-memory state
175
+ // rather than the file, so the frame is correct whether or not the flush behind it has landed yet.
176
+ const persisted = this.hooks.store.setSubagentRun(id, { output: streamed, toolCalls, activity });
177
+ this.hooks.broadcast({ type: "subagent.event", run: structuredClone(record) });
178
+ // Written through on every delta, NOT left until the run settles. `state` is broadcast from other
179
+ // commands while this run is in flight, and a browser that reloads mid-run reads the record from
180
+ // there: a store that only saw the final answer would show an empty card and never fill it in until
181
+ // the child finished. Never rejected into the void, because an unhandled rejection would take the
182
+ // server down over a write that the next delta is about to make irrelevant.
183
+ void persisted.catch((error) => {
184
+ console.warn(`Super Pi: recording a delta of sub-agent run ${id} failed: ${error instanceof Error ? error.message : "unknown error"}`);
185
+ });
186
+ };
187
+ const settle = async (changes) => {
188
+ Object.assign(record, changes);
189
+ // PERSIST FIRST, ANNOUNCE SECOND. The order is load-bearing and was previously reversed.
190
+ //
191
+ // Broadcasting the terminal frame before the write lands tells the browser a run has finished
192
+ // while `state.json` still says `running`. Measured, the gap is 0.36-8.19ms - long enough that a
193
+ // refresh in that window reloads a card that spins forever, and the startup reconciliation that
194
+ // would fix it only runs on a SERVER restart, not a page refresh. The same principle as
195
+ // `Store.open` persisting its reconciliation immediately: nothing is announced before it is on
196
+ // disk. The write is serialized, so the broadcast is a few ms later than it used to be. That is
197
+ // the correct trade: never announce state that a reload would contradict.
198
+ const settled = await this.hooks.store.setSubagentRun(id, changes);
199
+ this.hooks.broadcast({ type: "subagent.event", run: structuredClone(record) });
200
+ return settled;
201
+ };
202
+ /**
203
+ * Wall-clock from the `running` row to now, in whole milliseconds.
204
+ *
205
+ * This project's clock, measured across the row the recorder itself wrote, so it covers the spawn
206
+ * and the drain rather than only the model's thinking. The point of recording it at all is a
207
+ * measurement: a real sub-agent reading three files finished in 2.5s, which is far too short for
208
+ * anyone to have watched it, so without a duration left behind the transcript cannot even say the
209
+ * run happened.
210
+ */
211
+ const elapsedMs = () => Math.max(0, Date.now() - new Date(started.startedAt).getTime());
212
+ // One controller for both abort sources: the caller's signal and `abortAll` are the same button from
213
+ // the child's point of view, and `run()` takes exactly one.
214
+ const controller = new AbortController();
215
+ this.controllers.set(id, { controller, conversationId });
216
+ const onCallerAbort = () => controller.abort();
217
+ input.signal?.addEventListener("abort", onCallerAbort, { once: true });
218
+ try {
219
+ const result = await this.supervisor.run({
220
+ id,
221
+ conversationId,
222
+ agentId,
223
+ task,
224
+ cwd,
225
+ model,
226
+ denyTools,
227
+ signal: controller.signal,
228
+ // Asked for BEFORE the run, because the host's key needs the run id and only this file decided it.
229
+ host: this.hooks.host?.(conversationId, id),
230
+ onTextDelta: (delta) => {
231
+ streamed += delta;
232
+ publish();
233
+ },
234
+ // Appended, never replaced. `detail` is the DERIVED label `describeToolCall` made from the
235
+ // child's arguments - one bounded line naming the file, pattern or command - and never the
236
+ // arguments themselves. `status` stays absent until an `onToolEnd` fills it in, because Pi
237
+ // announces a tool with `tool_execution_start` and a row with no outcome is exactly how a card
238
+ // says "this one is still running".
239
+ onToolCall: (call) => {
240
+ toolRowById.set(call.id, toolCalls.length);
241
+ toolCalls = [...toolCalls, { label: call.name, ...(call.detail === undefined ? {} : { detail: call.detail }) }];
242
+ activity = { kind: "tool", label: call.name, at: new Date().toISOString() };
243
+ publish();
244
+ },
245
+ // The end of a call, matched back to its row by Pi's own id. A `tool_execution_end` with no
246
+ // matching start is DROPPED rather than appended: it would otherwise become a row for a call the
247
+ // card never announced, which reads as a tool the child used that it did not.
248
+ onToolEnd: (call) => {
249
+ const row = toolRowById.get(call.id);
250
+ if (row === undefined)
251
+ return;
252
+ activity = { kind: "thinking", at: new Date().toISOString() };
253
+ toolCalls = toolCalls.map((item, index) => index === row
254
+ ? { ...item, status: call.status, ...(call.durationMs === undefined ? {} : { durationMs: call.durationMs }) }
255
+ : item);
256
+ publish();
257
+ },
258
+ // Thinking, not tool: the child is alive and composing, and saying so is the only thing that
259
+ // distinguishes a reasoning model mid-turn from a hung one.
260
+ onThinking: () => {
261
+ activity = { kind: "thinking", at: new Date().toISOString() };
262
+ publish();
263
+ },
264
+ onStage: (next) => { stage = next; },
265
+ });
266
+ // `run()` returns the authoritative last assistant message, which is what the record means by
267
+ // `output`; the streamed copy is only a fallback for a child that never sent a `done`.
268
+ const output = result.finalText || streamed;
269
+ const truncated = output.length > PER_TASK_OUTPUT_CAP;
270
+ return await settle({
271
+ status: "completed",
272
+ output: truncated ? output.slice(0, PER_TASK_OUTPUT_CAP) : output,
273
+ // `undefined` rather than `false`: the key is absent on an untruncated run, so it serializes
274
+ // identically to a record written before this field existed.
275
+ outputTruncated: truncated || undefined,
276
+ toolCalls,
277
+ usage: result.usage,
278
+ sessionId: result.sessionId,
279
+ durationMs: elapsedMs(),
280
+ // CLEARED rather than left describing the child's last moment: a finished card that still says
281
+ // 正在调用 read is making a claim about a process that no longer exists.
282
+ activity: undefined,
283
+ failure: undefined,
284
+ finishedAt: new Date().toISOString(),
285
+ });
286
+ }
287
+ catch (error) {
288
+ const stopped = error instanceof SubagentCancelled;
289
+ return await settle({
290
+ status: stopped ? "stopped" : "failed",
291
+ // Cleared rather than left as the partial stream: `output` is defined as the last assistant
292
+ // message, and a run that produced none has none. What the child managed to say is not lost - it
293
+ // was already broadcast - and `error` says why the run ended without one.
294
+ output: undefined,
295
+ outputTruncated: undefined,
296
+ // KEPT, unlike `output`: which tools the child reached for before it stopped is a true fact about
297
+ // the run and is usually the whole diagnosis. They are labels and derived targets, never
298
+ // arguments or results, so §5's rule about the child's turn not entering `state.json` still holds.
299
+ toolCalls,
300
+ // WHERE, not only WHAT. `error` says what went wrong; this says which phase the child was in when
301
+ // it did, so a card can answer 「卡在哪」 - and a run stopped by a person is not given one, because
302
+ // nobody failed: 「已停止」 is a complete and true account.
303
+ ...(stopped ? {} : { failure: { stage } }),
304
+ durationMs: elapsedMs(),
305
+ activity: undefined,
306
+ error: stopped ? `Run ${id} was stopped before it settled.` : error instanceof Error ? error.message : "unknown error",
307
+ finishedAt: new Date().toISOString(),
308
+ });
309
+ }
310
+ finally {
311
+ input.signal?.removeEventListener("abort", onCallerAbort);
312
+ this.controllers.delete(id);
313
+ }
314
+ }
315
+ }
@@ -0,0 +1,194 @@
1
+ import { type ExtensionUiHost } from "./runtime.js";
2
+ import { describeToolCall, TOOL_DETAIL_CAP } from "./tool-calls.js";
3
+ import { type ModelSelection, type SubagentFailure, type SubagentUsage } from "./types.js";
4
+ /** Re-exported from `types.js` where it is declared: usage is a PERSISTED shape, not a spawn detail. */
5
+ export type { SubagentUsage };
6
+ /**
7
+ * The tool-call derivation now lives in `tool-calls.js`, because the primary agent's runtime needs the
8
+ * identical mapping and `subagents.ts` already imports `runtime.ts` - so the shared definition has to sit
9
+ * below both of them.
10
+ *
11
+ * RE-EXPORTED rather than moved silently. `scripts/agent-vis-records.test.mjs` imports both of these from
12
+ * this module, and a card that quietly lost its only derivation would still typecheck. Keeping the names
13
+ * exported here means that test keeps pointing at the one definition that also serves a call block.
14
+ */
15
+ export { describeToolCall, TOOL_DETAIL_CAP };
16
+ /**
17
+ * The run id every dispatch gets unless a caller supplies one: `sub_<uuid>`, and Pi's `--session-id`
18
+ * alongside it. Exported so a caller that must know the id BEFORE the spawn - the recorder, which has to
19
+ * write a `running` row before the child exists - generates exactly the id this would have generated.
20
+ */
21
+ export declare const newSubagentRunId: () => string;
22
+ /**
23
+ * One tool the child started, and later ended.
24
+ *
25
+ * `detail` is a derived label, not the arguments - see `describeToolCall` in `tool-calls.js`, which is
26
+ * where that mapping now lives and why. `status` and `durationMs` arrive on the separate `onToolEnd`
27
+ * callback, because they are facts about the END of a call and a caller that cannot tell the two apart
28
+ * would have to guess at a half-filled row.
29
+ */
30
+ export type SubagentToolCall = {
31
+ id: string;
32
+ name: string;
33
+ detail?: string;
34
+ status?: "ok" | "error";
35
+ durationMs?: number;
36
+ };
37
+ /**
38
+ * One completed run.
39
+ *
40
+ * `sessionId` is Pi's own session id for the child, which today is the run id because that is what
41
+ * `--session-id` was given. They are separate fields because they are separate facts: the run is
42
+ * Super Pi's record, the session is the file Pi wrote, and a future caller (records in step 2) will
43
+ * want to report one after the other has been changed.
44
+ */
45
+ export type SubagentResult = {
46
+ id: string;
47
+ sessionId: string;
48
+ finalText: string;
49
+ usage: SubagentUsage;
50
+ };
51
+ export type SubagentRunInput = {
52
+ /** The conversation that dispatched this run. Recorded on the child so a later reader can attribute it. */
53
+ conversationId: string;
54
+ /** The `agents/*.md` file's base name, which must be a `mode: subagent` definition. */
55
+ agentId: string;
56
+ /** What the child is asked to do. Sent over RPC stdin, never on the command line - see `run()`. */
57
+ task: string;
58
+ /**
59
+ * The directory the child must run in: the dispatching conversation's workspace, resolved by the
60
+ * caller. Required with no default for the same reason `AnswerInput.cwd` is: every tool the child runs
61
+ * resolves its paths against this directory, so an absent one would silently run the sub-agent in the
62
+ * server's own directory instead of failing.
63
+ */
64
+ cwd: string;
65
+ /** The parent conversation's current model, used when the definition names none. See `run()`. */
66
+ model?: ModelSelection;
67
+ /**
68
+ * Tools the DISPATCHING agent has denied, removed from this child's allowlist.
69
+ *
70
+ * The parent's denies are inherited and its allowlist is NOT, which is exactly what OpenCode's
71
+ * `deriveSubagentSessionPermission` does and why: "parent agent restrictions only govern that agent;
72
+ * the subagent's own permissions determine its capabilities" (subagent-permissions.ts:11-12). A
73
+ * primary agent that took `bash` away from itself has said something about its own turns; a child
74
+ * whose own author gave it `bash` for verification is not thereby unsafe.
75
+ *
76
+ * Applied to the child's `agents/*.md` map by `agentSpawnArgs`, never by editing the definition: two
77
+ * runs of the same agent under different parents must be able to differ.
78
+ */
79
+ denyTools?: readonly string[];
80
+ /**
81
+ * The id this run answers to. Omitted means `newSubagentRunId()`.
82
+ *
83
+ * Supplied by a caller that must know the id before the child exists - the recorder persists a
84
+ * `running` row first precisely so a crashed server leaves a reconcilable orphan, which is impossible
85
+ * if the id only appears after `run()` returns. Validated rather than trusted, because the same string
86
+ * becomes Pi's `--session-id` and is the key startup reconciliation looks orphans up by: a run
87
+ * recorded under an id nothing else knows can never be found, let alone cleaned up.
88
+ */
89
+ id?: string;
90
+ /**
91
+ * Who answers the child's Extension UI questions.
92
+ *
93
+ * Absent means nobody can be asked, and a blocking question is CANCELLED - Pi's own third response
94
+ * shape, meaning "no answer was collected". Auto-answering with `options[0]` would look to Pi exactly
95
+ * like a decision the user made, and the user would never learn a question was asked of them.
96
+ */
97
+ host?: ExtensionUiHost;
98
+ /** Aborting writes Pi an `abort` and then kills the whole process tree; `run()` rejects with `SubagentCancelled`. */
99
+ signal?: AbortSignal;
100
+ /** Called once per `text_delta`, in order, with the raw delta. Never the accumulated text. */
101
+ onTextDelta?: (delta: string) => void;
102
+ /** Called on `tool_execution_start`, with the name and the derived target label. */
103
+ onToolCall?: (call: SubagentToolCall) => void;
104
+ /**
105
+ * Called on `tool_execution_end` for the same `id`, carrying how the call ended.
106
+ *
107
+ * A SEPARATE callback rather than a second `onToolCall`, so "a tool started" and "a tool finished"
108
+ * stay distinguishable: a row with no status is a call still in flight, which is the whole reason the
109
+ * card can show that a child is inside a long tool right now. Nothing here is derived from the
110
+ * RESULT - only `isError` and Pi's own `durationMs` come through, because a result can be a file.
111
+ */
112
+ onToolEnd?: (call: SubagentToolCall) => void;
113
+ /** Called on every `thinking_delta`: the child is composing, and emitting no output while it does. */
114
+ onThinking?: () => void;
115
+ /**
116
+ * Called when the run moves to a new phase, for a caller that has to report where it died.
117
+ *
118
+ * Driven by the drain loop meeting each record family rather than by a timer, so the phase can only
119
+ * ever be one the supervisor actually observed. `dispatch` is the implicit starting value and is
120
+ * never announced - a run that fails before the prompt is written never called it, and the caller's
121
+ * default is the honest answer rather than a special case.
122
+ */
123
+ onStage?: (stage: SubagentFailure["stage"]) => void;
124
+ };
125
+ /**
126
+ * A run that ended because the caller aborted it.
127
+ *
128
+ * A named error rather than a message a caller has to pattern-match, because "the user pressed stop" and
129
+ * "the child failed" are different outcomes for whoever reports this run later, and collapsing them into
130
+ * one rejected promise would force that decision back out of string matching.
131
+ */
132
+ export declare class SubagentCancelled extends Error {
133
+ readonly runId: string;
134
+ constructor(runId: string);
135
+ }
136
+ /** Spawns sub-agent children Pi and drains their JSONL RPC streams. */
137
+ export declare class SubagentSupervisor {
138
+ private readonly sessionDir;
139
+ private readonly agents;
140
+ private readonly pi;
141
+ /** Live children by run id, so a caller can name the process it means. Cleared in the run's `finally`. */
142
+ private readonly children;
143
+ /**
144
+ * `command` and `agents` are the same two override points `PiRpcRuntime` takes, and for the same
145
+ * reasons: a test needs to launch a fake peer, and a Pi installed somewhere unusual needs to be
146
+ * reachable. `Pi` is resolved once, because a resolution that could change mid-run would make one
147
+ * child's spawn line depend on when it was started.
148
+ */
149
+ constructor(sessionDir: string, command?: string, agents?: {
150
+ dir: string;
151
+ promptDir: string;
152
+ });
153
+ /**
154
+ * Every child this supervisor still owns, optionally narrowed to one conversation.
155
+ *
156
+ * A live view rather than a returned handle, because the caller cannot have the id before the run:
157
+ * `run()` returns the RESULT, and a stop button needs the pid while the promise is still pending.
158
+ * Deleted as soon as the child is gone, so a caller that polls it sees the truth rather than a
159
+ * remembered one.
160
+ */
161
+ liveRuns(conversationId?: string): {
162
+ id: string;
163
+ conversationId: string;
164
+ pid: number | undefined;
165
+ }[];
166
+ /**
167
+ * Runs one sub-agent to completion and returns what it said.
168
+ *
169
+ * The three spawn-time decisions, all of which the design fixes rather than leaves open:
170
+ *
171
+ * - `--mode rpc`, NOT the reference implementation's `--mode json -p`. In print mode a child that
172
+ * wants to ask something has nowhere to ask and hangs; in RPC mode its questions arrive as
173
+ * `extension_ui_request` records on the channel Super Pi already owns. That is the whole reason this
174
+ * supervisor exists instead of shipping Pi's extension.
175
+ * - `--session-dir` shared by every child, `--session-id` unique per run, so Pi owns the directory and
176
+ * Super Pi never has to read a transcript it could only misread.
177
+ * - `agentSpawnArgs` verbatim, so a sub-agent's tool allowlist and prompt go through the one proven
178
+ * translation rather than a second hand-rolled one.
179
+ *
180
+ * The TASK goes over RPC stdin, never on argv. The reference passes it as a positional argument, which
181
+ * under `shell: true` on Windows is cmd.exe's to parse: an ampersand or a pipe in a model's own words
182
+ * becomes a command. `{ type: "prompt" }` is exactly how the primary agent is already prompted, so
183
+ * this costs nothing and removes the whole class of bug.
184
+ */
185
+ run(input: SubagentRunInput): Promise<SubagentResult>;
186
+ /**
187
+ * The definition this run will use, or the reason there is none.
188
+ *
189
+ * Read with `mode: "subagent"`, so a primary agent is not found by id at all rather than found and
190
+ * then judged: a sub-agent definition is the same `agents/*.md` file with a different `mode`, and this
191
+ * is the filter that keeps the two rosters from ever meeting.
192
+ */
193
+ private subagentDefinition;
194
+ }