@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.
- 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,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
|
+
}
|