@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,920 @@
|
|
|
1
|
+
export type RunStatus = "idle" | "running" | "completed" | "stopped" | "failed";
|
|
2
|
+
export type MessageRole = "user" | "assistant" | "tool" | "system";
|
|
3
|
+
/**
|
|
4
|
+
* One message in `state.json`.
|
|
5
|
+
*
|
|
6
|
+
* `piEntryId` is Pi's own id for the entry this message became, recorded while Pi was still alive.
|
|
7
|
+
*
|
|
8
|
+
* WHY A SUPER Pi MESSAGE NEEDS A PI ID AT ALL. The two histories are separate files in separate formats
|
|
9
|
+
* - `state.json` here, Pi's session JSONL in the session directory - and they share nothing but the
|
|
10
|
+
* conversation id (research §3.2). A revert has to cut BOTH at the same place, and Pi's file can only be
|
|
11
|
+
* cut at one of its own entry ids: `SessionManager` refuses to delete entries (`:1516-1519`), and the
|
|
12
|
+
* research' pitfall 6 rules out guessing a line number, because Pi writes far more entry types than
|
|
13
|
+
* messages and a "the Nth user message" boundary would slice a compaction record in half.
|
|
14
|
+
*
|
|
15
|
+
* So the id is captured per turn, from Pi's own `get_entries` (`rpc-mode.ts:636-647`), in the only window
|
|
16
|
+
* where it is available: Super Pi spawns one Pi per turn and kills it in a `finally`
|
|
17
|
+
* (`runtime.ts:429-435`), so the answer has to be taken before that. A message recorded before this
|
|
18
|
+
* field existed has no id, and is therefore not a valid revert boundary - which the handler says out
|
|
19
|
+
* loud rather than reverting to a guessed line.
|
|
20
|
+
*
|
|
21
|
+
* Optional on purpose: a `state.json` written before revert existed has no such key, and absent reads as
|
|
22
|
+
* "Pi never told us", needing no migration.
|
|
23
|
+
*
|
|
24
|
+
* `toolCalls` is the OTHER optional field, and it lives ON THE MESSAGE rather than in a separate top-level
|
|
25
|
+
* array. That is a claim about where a call happens: Pi executes a tool call after the assistant message
|
|
26
|
+
* that requested it and before the next one, so the calls belong to the turn the message belongs to and to
|
|
27
|
+
* nothing else. Attaching them there makes the transcript's ordering free - a block renders under its own
|
|
28
|
+
* message, and a reload rebuilds it from the same `state` snapshot the text came from, with no second source
|
|
29
|
+
* of truth to fall out of sync.
|
|
30
|
+
*
|
|
31
|
+
* A FOLLOW-UP ROUND GETS ITS OWN, because each one calls `beginAssistantMessage` and therefore opens a
|
|
32
|
+
* fresh message: the chain in `index.ts` sends a queued message as a new prompt, and Pi's calls for that
|
|
33
|
+
* prompt cannot be filed under the answer to the previous one.
|
|
34
|
+
*
|
|
35
|
+
* Also optional, so a `state.json` written before either field existed reads as "this turn used no tools",
|
|
36
|
+
* which is what such a turn genuinely is.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* One image attached to a message or queue item.
|
|
40
|
+
* Stored as a reference (file ID + metadata), NEVER base64 bytes in state.json,
|
|
41
|
+
* to prevent massive state bloat during streaming broadcast.
|
|
42
|
+
*/
|
|
43
|
+
export type MessageImage = {
|
|
44
|
+
id: string;
|
|
45
|
+
name?: string;
|
|
46
|
+
mimeType: string;
|
|
47
|
+
bytes: number;
|
|
48
|
+
width?: number;
|
|
49
|
+
height?: number;
|
|
50
|
+
};
|
|
51
|
+
export type Message = {
|
|
52
|
+
id: string;
|
|
53
|
+
role: MessageRole;
|
|
54
|
+
content: string;
|
|
55
|
+
createdAt: string;
|
|
56
|
+
piEntryId?: string;
|
|
57
|
+
toolCalls?: ToolCallBlock[];
|
|
58
|
+
images?: MessageImage[];
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* ONE PARAMETER OF A TOOL CALL, as a call block lists it, and the boundary that keeps Pi's arguments out
|
|
62
|
+
* of `state.json`.
|
|
63
|
+
*
|
|
64
|
+
* `value` is DERIVED, never the raw argument: a string is whitespace-collapsed and hard-capped, and
|
|
65
|
+
* anything that is not a string, a number or a boolean becomes a bare type word (`(对象)`,
|
|
66
|
+
* `(数组 3 项)`). That second half is the load-bearing part - an `edit`'s arguments routinely carry a
|
|
67
|
+
* replacement block for a whole file, so rendering such a value even truncated would put file contents
|
|
68
|
+
* into a field that is persisted in `state.json` and broadcast to every browser. See `describeToolCallArgs`.
|
|
69
|
+
*/
|
|
70
|
+
export type ToolCallArg = {
|
|
71
|
+
key: string;
|
|
72
|
+
value: string;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* ONE TOOL CALL THE PRIMARY AGENT MADE, as a block in the transcript.
|
|
76
|
+
*
|
|
77
|
+
* SAME FOUR FACTS AS `SubagentToolCall`, and deliberately the same granularity: `label` is the tool name,
|
|
78
|
+
* `detail` is the one bounded line naming what it was aimed at (the file it read, the pattern it searched
|
|
79
|
+
* for, the command it ran), `status` and `durationMs` come from Pi's own `tool_execution_end`. A call block
|
|
80
|
+
* that showed MORE than a sub-agent card row would answer a question the brief does not ask and would make
|
|
81
|
+
* the two surfaces disagree about what a tool call is.
|
|
82
|
+
*
|
|
83
|
+
* `args` is the ONE addition, and it is what makes a block worth expanding: a card row answers "what was
|
|
84
|
+
* this aimed at", while a block also answers "what else was it given" - a `bash` call's timeout, a
|
|
85
|
+
* `read`'s offset. Bounded to six entries of capped text by `TOOL_ARG_CAP`, so this stays a summary
|
|
86
|
+
* rather than becoming the transcript.
|
|
87
|
+
*
|
|
88
|
+
* TWO THINGS ARE NOT HERE AND NEVER WILL BE. `result` on `tool_execution_end` is, for a `read`, the file's
|
|
89
|
+
* contents; and Pi's `toolCallId` is an identity only Pi can resolve, which is why the record is addressed
|
|
90
|
+
* by position (the caller holds a `toolCallId` → row map for the length of a turn, exactly as
|
|
91
|
+
* `SubagentRunRecorder` does) rather than by a field persisted forever.
|
|
92
|
+
*
|
|
93
|
+
* `status` is OPTIONAL, not defaulted to `"ok"`, for the same reason it is on `SubagentToolCall`: a default
|
|
94
|
+
* would claim an outcome nobody observed. Absent means the call is still in flight, which is the state a
|
|
95
|
+
* block is in for most of a long tool call.
|
|
96
|
+
*/
|
|
97
|
+
export type ToolCallBlock = {
|
|
98
|
+
label: string;
|
|
99
|
+
detail?: string;
|
|
100
|
+
status?: "ok" | "error";
|
|
101
|
+
durationMs?: number;
|
|
102
|
+
args?: ToolCallArg[];
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* WHAT THE RUNTIME HANDS A CALLER WHEN PI ANNOUNCES OR FINISHES A TOOL CALL.
|
|
106
|
+
*
|
|
107
|
+
* Mirrors `SubagentToolCall` and is declared here rather than in `runtime.ts` because `runtime.ts` cannot
|
|
108
|
+
* import `subagents.ts` (that module imports the runtime), so a type living beside the supervisor would be
|
|
109
|
+
* unreachable from the only file that produces the primary agent's calls. It carries Pi's `id` because the
|
|
110
|
+
* CALLER needs it to match an end back to its start - the persisted `ToolCallBlock` deliberately does not.
|
|
111
|
+
*/
|
|
112
|
+
export type ToolCallNotice = {
|
|
113
|
+
id: string;
|
|
114
|
+
name: string;
|
|
115
|
+
detail?: string;
|
|
116
|
+
args?: ToolCallArg[];
|
|
117
|
+
status?: "ok" | "error";
|
|
118
|
+
durationMs?: number;
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* `archivedAt` is the whole archive mechanism: a state MARKER, not a move. The conversation object never
|
|
122
|
+
* changes workspace or position, so archiving and restoring are a stamp and a clear of one optional field.
|
|
123
|
+
*
|
|
124
|
+
* It is optional on purpose. A `state.json` written before archiving existed simply has no such key, so
|
|
125
|
+
* `undefined` means "not archived" and old state loads as live with no migration step and no data rewrite.
|
|
126
|
+
* An ISO-8601 string also carries when the conversation was archived, which the sidebar shows in place of
|
|
127
|
+
* the live timestamp.
|
|
128
|
+
*/
|
|
129
|
+
/**
|
|
130
|
+
* The model a conversation runs on. Provider and id are ONE optional field, never two, because Pi's
|
|
131
|
+
* `set_model` command takes both (rpc-mode.ts:470) and a half-updated pair - an id without its
|
|
132
|
+
* provider, or a provider whose id no longer exists - is a selection Pi rejects at the start of the
|
|
133
|
+
* next turn. Nesting them makes that state unrepresentable.
|
|
134
|
+
*
|
|
135
|
+
* `label` is the display name Pi itself reported, kept so a conversation opened without a catalog
|
|
136
|
+
* fetch still renders something a human recognises instead of a bare model id.
|
|
137
|
+
*/
|
|
138
|
+
export type ModelSelection = {
|
|
139
|
+
provider: string;
|
|
140
|
+
modelId: string;
|
|
141
|
+
label: string;
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* One entry of Pi's `get_available_models` answer, narrowed to what a picker needs. Pi's `Model`
|
|
145
|
+
* objects also carry baseUrl, cost tables and compat maps (17KB for the 28 models this build sees);
|
|
146
|
+
* keeping them would put provider URLs in the browser's state for no gain. `detail` is
|
|
147
|
+
* `provider/modelId`, the pair Pi needs back in `set_model`, shown so two same-named models from
|
|
148
|
+
* different providers are distinguishable.
|
|
149
|
+
*
|
|
150
|
+
* `imageInput` is derived from Pi's own `input` array rather than carried raw, so the browser holds
|
|
151
|
+
* one boolean instead of a modality list it would have to interpret. It is CATALOG state, never
|
|
152
|
+
* conversation state: a copy frozen onto a conversation would be wrong the moment Pi changed a
|
|
153
|
+
* model, and nothing reads one - see the note on `setConversationModel`.
|
|
154
|
+
*/
|
|
155
|
+
export type ModelOption = ModelSelection & {
|
|
156
|
+
detail: string;
|
|
157
|
+
reasoning: boolean;
|
|
158
|
+
contextWindow: number;
|
|
159
|
+
imageInput: boolean;
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* What the picker lists, read from Pi and cached by the runtime.
|
|
163
|
+
*
|
|
164
|
+
* `source` is "unavailable" when Pi could not be asked, which the UI renders as a disabled control
|
|
165
|
+
* rather than an empty list: an empty list is indistinguishable from "you have no models", and the
|
|
166
|
+
* honest answer is "the list could not be read". `fetchedAt` travels with the value so a stale
|
|
167
|
+
* cache is visible rather than silent.
|
|
168
|
+
*/
|
|
169
|
+
export type ModelCatalog = {
|
|
170
|
+
source: "pi" | "unavailable";
|
|
171
|
+
models: ModelOption[];
|
|
172
|
+
/** Exactly what `get_available_thinking_levels` returned, in Pi's own order. Never hardcoded. */
|
|
173
|
+
levels: string[];
|
|
174
|
+
defaultModel?: ModelSelection;
|
|
175
|
+
defaultThinkingLevel?: string;
|
|
176
|
+
fetchedAt: string;
|
|
177
|
+
error?: string;
|
|
178
|
+
};
|
|
179
|
+
export type Conversation = {
|
|
180
|
+
id: string;
|
|
181
|
+
title: string;
|
|
182
|
+
workspaceId: string;
|
|
183
|
+
updatedAt: string;
|
|
184
|
+
status: RunStatus;
|
|
185
|
+
messages: Message[];
|
|
186
|
+
archivedAt?: string;
|
|
187
|
+
/**
|
|
188
|
+
* Why this conversation is not `running`, in WORDS A PERSON READS - the same role `SubagentRun.error`
|
|
189
|
+
* plays for a card, one level up.
|
|
190
|
+
*
|
|
191
|
+
* Set by startup reconciliation, and ONLY by it: a conversation the previous server left `running` is
|
|
192
|
+
* failed here with this reason, and `addUserMessage` deletes the key the moment the user retries, so
|
|
193
|
+
* the field can never describe a run other than the one that most recently failed to finish. A value
|
|
194
|
+
* that outlived its run would be worse than no value at all, because the reader would take it for the
|
|
195
|
+
* current turn's outcome.
|
|
196
|
+
*
|
|
197
|
+
* Prose rather than a code because this is the ONE string from state.json that a human is expected to
|
|
198
|
+
* read on purpose; the chat pane renders it verbatim under the header. An internal error code here
|
|
199
|
+
* would answer "what happened" with something the reader has to go and decode.
|
|
200
|
+
*/
|
|
201
|
+
error?: string;
|
|
202
|
+
/** Absent means "Pi's own default", which is why a conversation created before the picker existed still works. */
|
|
203
|
+
model?: ModelSelection;
|
|
204
|
+
thinkingLevel?: string;
|
|
205
|
+
/**
|
|
206
|
+
* The agent whose role, tool allowlist and prompt apply to this conversation's turns. Absent means
|
|
207
|
+
* "Pi's own defaults", exactly as for `model` - so a state.json written before agents existed needs
|
|
208
|
+
* no migration and reads as a conversation with no role.
|
|
209
|
+
*
|
|
210
|
+
* It holds only the ID, never a copy of the agent. The definition is re-read from disk on every turn,
|
|
211
|
+
* so editing an agent's prompt or tools takes effect on the next message with no re-selection, and a
|
|
212
|
+
* deleted agent degrades to "no role" instead of leaving a stale snapshot in state.json.
|
|
213
|
+
*/
|
|
214
|
+
agentId?: string;
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* One parsed agent definition, as read from an `agents/*.md` file. `prompt` is the file body and is the
|
|
218
|
+
* system prompt; the frontmatter fields are borrowed from OpenCode's agent format.
|
|
219
|
+
*
|
|
220
|
+
* `toolFlags` is the TRANSLATED Pi command line, not the raw map: `tools: {"*": false, read: true}`
|
|
221
|
+
* becomes `["--tools", "read"]`. Keeping the translation next to the definition means the runtime
|
|
222
|
+
* cannot pick a different one, and a test can assert the exact flags without spawning Pi.
|
|
223
|
+
*/
|
|
224
|
+
export type AgentDefinition = {
|
|
225
|
+
/** The file's base name. Stable, human-chosen, and the id a conversation stores. */
|
|
226
|
+
id: string;
|
|
227
|
+
name: string;
|
|
228
|
+
mode: "primary" | "subagent";
|
|
229
|
+
description: string;
|
|
230
|
+
color?: string;
|
|
231
|
+
hidden: boolean;
|
|
232
|
+
/** `provider/modelId` from frontmatter, split into the pair Pi's `set_model` requires. */
|
|
233
|
+
model?: {
|
|
234
|
+
provider: string;
|
|
235
|
+
modelId: string;
|
|
236
|
+
};
|
|
237
|
+
thinkingLevel?: string;
|
|
238
|
+
/** `append` adds the body to Pi's baseline prompt; `replace` makes it the whole system prompt. */
|
|
239
|
+
promptMode: "append" | "replace";
|
|
240
|
+
toolFlags: string[];
|
|
241
|
+
taskPolicy?: Record<string, unknown>;
|
|
242
|
+
/** For display only. Empty means "Pi's own default tool set" or "every tool denied". */
|
|
243
|
+
toolNames: string[];
|
|
244
|
+
/**
|
|
245
|
+
* The `tools:` frontmatter MAP, kept alongside the flags it was translated into.
|
|
246
|
+
*
|
|
247
|
+
* `toolFlags` is the answer to "what does Pi get"; this is the question behind it. Keeping the source
|
|
248
|
+
* next to the translation is what lets a caller's constraint be applied to an agent that is about to be
|
|
249
|
+
* spawned - a child whose allowlist must lose the tools its parent denied
|
|
250
|
+
* (`agentSpawnArgs(..., { denyTools })`) cannot be expressed by editing a flag list that has already
|
|
251
|
+
* been flattened into two argv strings. Kept optional so a hand-built definition in a test does not
|
|
252
|
+
* have to invent one.
|
|
253
|
+
*/
|
|
254
|
+
tools?: Record<string, unknown>;
|
|
255
|
+
prompt: string;
|
|
256
|
+
};
|
|
257
|
+
/**
|
|
258
|
+
* What the picker receives. The prompt body is deliberately NOT sent: it is a whole system prompt,
|
|
259
|
+
* the browser has no use for it, and state.json plus the agent catalog stay small.
|
|
260
|
+
*/
|
|
261
|
+
export type AgentSummary = Omit<AgentDefinition, "prompt" | "toolFlags" | "tools"> & {
|
|
262
|
+
toolSummary: string;
|
|
263
|
+
};
|
|
264
|
+
export type AgentCatalog = {
|
|
265
|
+
source: "directory" | "unavailable";
|
|
266
|
+
agents: AgentSummary[];
|
|
267
|
+
/** Files that exist but are not usable agents, so a typo is visible instead of silently absent. */
|
|
268
|
+
problems: {
|
|
269
|
+
file: string;
|
|
270
|
+
message: string;
|
|
271
|
+
}[];
|
|
272
|
+
directory: string;
|
|
273
|
+
fetchedAt: string;
|
|
274
|
+
error?: string;
|
|
275
|
+
};
|
|
276
|
+
/**
|
|
277
|
+
* Workspace-level DEFAULTS for conversations not yet created. Both fields are optional for the same
|
|
278
|
+
* reason Conversation's are: a state.json written before workspace settings existed has no such keys,
|
|
279
|
+
* and absent means "Pi's own default" with no migration step.
|
|
280
|
+
*
|
|
281
|
+
* These are copied INTO a conversation at creation time (store.createConversation) and never read
|
|
282
|
+
* afterwards. That copy is the whole "default vs override" semantics: the session picker's value is
|
|
283
|
+
* the conversation's own field, an existing conversation is therefore immune to later workspace
|
|
284
|
+
* edits, and deleting the workspace default cannot reach back into sessions already born.
|
|
285
|
+
*/
|
|
286
|
+
export type Workspace = {
|
|
287
|
+
id: string;
|
|
288
|
+
name: string;
|
|
289
|
+
path: string;
|
|
290
|
+
updatedAt: string;
|
|
291
|
+
defaultModel?: ModelSelection;
|
|
292
|
+
defaultThinkingLevel?: string;
|
|
293
|
+
defaultAgentId?: string;
|
|
294
|
+
};
|
|
295
|
+
/**
|
|
296
|
+
* Usage accumulated across one sub-agent run. `turns` is the number of finished assistant messages,
|
|
297
|
+
* not a token total: it is what tells a human whether a sub-agent did one pass or twenty.
|
|
298
|
+
*
|
|
299
|
+
* Declared HERE rather than in `subagents.ts` because this is a persisted shape - it is copied into
|
|
300
|
+
* `state.json` and back out - and a type that lives next to the code that produced it cannot be
|
|
301
|
+
* checked by the one that consumes it.
|
|
302
|
+
*/
|
|
303
|
+
export type SubagentUsage = {
|
|
304
|
+
input: number;
|
|
305
|
+
output: number;
|
|
306
|
+
cacheRead: number;
|
|
307
|
+
cacheWrite: number;
|
|
308
|
+
cost: number;
|
|
309
|
+
turns: number;
|
|
310
|
+
};
|
|
311
|
+
/**
|
|
312
|
+
* ONE TOOL CALL, as the card shows it, and the boundary that keeps a child's turn out of `state.json`.
|
|
313
|
+
*
|
|
314
|
+
* `detail` is a SHORT LABEL of what the call was aimed at - the file it read, the pattern it searched
|
|
315
|
+
* for, the command it ran - and it is the only part of the child's arguments Super Pi keeps. That is a
|
|
316
|
+
* deliberate line, not an oversight, and it is drawn in three places at once:
|
|
317
|
+
*
|
|
318
|
+
* 1. **It is derived, never copied.** `describeToolCall` reads named argument fields and renders ONE
|
|
319
|
+
* capped line; the argument object itself is dropped. A `read` whose result is a whole file still
|
|
320
|
+
* contributes nothing but its path.
|
|
321
|
+
* 2. **It is bounded.** 120 characters, whitespace collapsed, no newlines - so the label cannot grow
|
|
322
|
+
* into a transcript, and a `state.json` serialized on every broadcast stays small by construction
|
|
323
|
+
* rather than by hope.
|
|
324
|
+
* 3. **The RESULT never lands here at all.** `tool_execution_end` carries `result`, which for a `read`
|
|
325
|
+
* is the file's contents; only its `isError` and its `durationMs` are kept, because both are
|
|
326
|
+
* numbers rather than text. This is the guard `scripts/agent-vis-records.test.mjs` pins down.
|
|
327
|
+
*
|
|
328
|
+
* `status` is OPTIONAL rather than defaulted to `"ok"` for a reason that is now historical: when this
|
|
329
|
+
* type was declared, the protocol surface announced tools and never reported how they ended, so a
|
|
330
|
+
* default would have claimed an outcome nobody observed. Pi does send `tool_execution_end`, the
|
|
331
|
+
* supervisor consumes it, and a call that has not ended yet still has no `status` - which is exactly
|
|
332
|
+
* what makes an in-flight row distinguishable from a finished one.
|
|
333
|
+
*
|
|
334
|
+
* `durationMs` is Pi's own measurement off a monotonic clock, not this project's clock, and it is the
|
|
335
|
+
* one outcome number that is true for EVERY tool rather than only the shapes this code recognises.
|
|
336
|
+
*/
|
|
337
|
+
export type SubagentToolCall = {
|
|
338
|
+
label: string;
|
|
339
|
+
status?: "ok" | "error";
|
|
340
|
+
detail?: string;
|
|
341
|
+
durationMs?: number;
|
|
342
|
+
};
|
|
343
|
+
/**
|
|
344
|
+
* WHAT THE CHILD WAS DOING AT THE MOMENT THE BROWSER LAST HEARD FROM IT.
|
|
345
|
+
*
|
|
346
|
+
* A run that spends its whole life inside one long tool call emits no `text_delta`, so without this the
|
|
347
|
+
* card freezes mid-run and a frozen card is indistinguishable from a dead one. `kind` is a WORD rather
|
|
348
|
+
* than a colour, because "is it still working?" has to be answerable by someone who cannot see the
|
|
349
|
+
* green tint. `at` lets the card say how stale this is, which is the difference between "working" and
|
|
350
|
+
* "stopped answering without saying so".
|
|
351
|
+
*
|
|
352
|
+
* Optional on purpose, and cleared rather than left stale at the settle: on a finished run the last
|
|
353
|
+
* thing the child did is history, and a card that kept claiming to be 正在调用 read after the run
|
|
354
|
+
* ended would be lying.
|
|
355
|
+
*/
|
|
356
|
+
export type SubagentActivity = {
|
|
357
|
+
kind: "thinking" | "tool";
|
|
358
|
+
label?: string;
|
|
359
|
+
at: string;
|
|
360
|
+
};
|
|
361
|
+
/**
|
|
362
|
+
* WHICH PHASE A RUN DIED IN, so a failure card answers 「卡在哪」 and not only 「错了」.
|
|
363
|
+
*
|
|
364
|
+
* `error` already says what went wrong and is the older, better field; this says where. A model that
|
|
365
|
+
* cannot open its output file and one that crashed two tool calls in are both 「失败」 without it, and
|
|
366
|
+
* those have completely different fixes. Five phases, because those are the five the supervisor can
|
|
367
|
+
* actually observe - it changes this value as the drain loop meets each record family, so it cannot
|
|
368
|
+
* claim a phase it never saw.
|
|
369
|
+
*
|
|
370
|
+
* Absent means "the failure was not observed while the run was in flight" - an orphan reconciled at the
|
|
371
|
+
* next startup, for instance, whose process was gone before anything could report a phase. Inventing
|
|
372
|
+
* one there would be the exact lie this type exists to prevent.
|
|
373
|
+
*/
|
|
374
|
+
export type SubagentFailure = {
|
|
375
|
+
stage: "dispatch" | "prompt" | "turn" | "tool" | "settle";
|
|
376
|
+
};
|
|
377
|
+
/**
|
|
378
|
+
* `SubagentRun`'s lifecycle, which is NOT `RunStatus`: there is no "idle", because a run exists only
|
|
379
|
+
* between its dispatch and its settle. `stopped` is separate from `failed` for the same reason
|
|
380
|
+
* `RunStatus` keeps them apart - "the user pressed stop" and "the child broke" are different facts
|
|
381
|
+
* for whoever reads the record later, and collapsing them forces that decision back out of message
|
|
382
|
+
* matching. A fourth value is deliberately absent: a run cannot be cancelled while it has not started.
|
|
383
|
+
*/
|
|
384
|
+
export type SubagentRunStatus = "running" | "completed" | "failed" | "stopped";
|
|
385
|
+
/**
|
|
386
|
+
* One sub-agent run, exactly as [docs/SUBAGENT-DESIGN.md](../../docs/SUBAGENT-DESIGN.md) §5 fixes it.
|
|
387
|
+
*
|
|
388
|
+
* THE RECORD, NEVER THE TRANSCRIPT. `state.json` is serialized on every broadcast and cloned into the
|
|
389
|
+
* browser on every snapshot, so a sub-agent's full conversation - arbitrarily long, and belonging to a
|
|
390
|
+
* different process with its own context window - must not go in here. What goes in is the LAST
|
|
391
|
+
* ASSISTANT MESSAGE (`output`), which is also what the parent model is told, and nothing else about the
|
|
392
|
+
* child's turn. Pi keeps the real transcript in its own `--session-dir`, and `sessionId` is the handle
|
|
393
|
+
* that gets it back (`switch_session`, or `export_html`): Super Pi records where the child's words are
|
|
394
|
+
* rather than copying them.
|
|
395
|
+
*
|
|
396
|
+
* `outputTruncated` exists because `output` may be cut: the 50 KB cap in §6 applies to the MODEL's copy
|
|
397
|
+
* only, since a human reading a sub-agent is debugging it and does not want it trimmed to fit somebody
|
|
398
|
+
* else's context window. So the browser gets the whole thing through `subagent.event` while the flag
|
|
399
|
+
* says the stored copy is less.
|
|
400
|
+
*/
|
|
401
|
+
export type SubagentRun = {
|
|
402
|
+
/** `sub_<uuid>`. Also Pi's `--session-id`, which is why the two are one value today. */
|
|
403
|
+
id: string;
|
|
404
|
+
/** The conversation that dispatched this run, so its card can live under the right message. */
|
|
405
|
+
conversationId: string;
|
|
406
|
+
/** The `agents/*.md` file's base name - the `mode: subagent` definition that was run. */
|
|
407
|
+
agentId: string;
|
|
408
|
+
/** What the child was asked to do, as Super Pi sent it. The dispatcher's own text, not the child's reply. */
|
|
409
|
+
task: string;
|
|
410
|
+
/**
|
|
411
|
+
* Who decided to run this: a person pressed the dispatch button, or the primary agent called the
|
|
412
|
+
* `task` tool on its own. The two are indistinguishable on the record otherwise - same agent, same
|
|
413
|
+
* task, same card - so the browser would show a model's own decision as if a person had asked for it.
|
|
414
|
+
* Absent means `human`, which is what every record written before this field existed was.
|
|
415
|
+
*/
|
|
416
|
+
dispatchedBy?: "human" | "model";
|
|
417
|
+
/** Authoritative dispatch ancestry, assigned by the server. */
|
|
418
|
+
parentRunId?: string;
|
|
419
|
+
depth: number;
|
|
420
|
+
loopId?: string;
|
|
421
|
+
/** Optional orchestration label; interpretation belongs to the workflow, not the dispatcher. */
|
|
422
|
+
role?: string;
|
|
423
|
+
status: SubagentRunStatus;
|
|
424
|
+
startedAt: string;
|
|
425
|
+
finishedAt?: string;
|
|
426
|
+
/** The last assistant message. Absent while running is only for a run that has produced none yet. */
|
|
427
|
+
output?: string;
|
|
428
|
+
/**
|
|
429
|
+
* Every tool the child started, oldest first, appended as the run streams. Same rule as `output`:
|
|
430
|
+
* short labels go here and to the card, the child's full turn stays in Pi's session directory.
|
|
431
|
+
*
|
|
432
|
+
* Optional on purpose. It was added to the design AFTER part 2 shipped, so a `state.json` written
|
|
433
|
+
* by an older build has no such key and an absent one reads as "this child used no tools", which is
|
|
434
|
+
* what a run with none genuinely means.
|
|
435
|
+
*/
|
|
436
|
+
toolCalls?: SubagentToolCall[];
|
|
437
|
+
/** True when `output` was cut for the model's benefit; the browser still saw all of it. */
|
|
438
|
+
outputTruncated?: boolean;
|
|
439
|
+
usage?: SubagentUsage;
|
|
440
|
+
error?: string;
|
|
441
|
+
/**
|
|
442
|
+
* How long the run took, measured by THIS server between writing the `running` row and settling it.
|
|
443
|
+
*
|
|
444
|
+
* Added because of a measurement, not a preference: a real sub-agent reading three files finished in
|
|
445
|
+
* 2.5 seconds, so a user glancing at the transcript afterwards could not tell whether the card had
|
|
446
|
+
* ever been anything but 已完成. A run that leaves a DURATION behind turns "I never saw it run" into
|
|
447
|
+
* "it ran for 2.5s", which is a fact, and it is the fact that makes the invisible run observable
|
|
448
|
+
* after the fact rather than only during a window nobody was looking at.
|
|
449
|
+
*
|
|
450
|
+
* Written at the settle only. While the card is live the elapsed time is the browser's own arithmetic
|
|
451
|
+
* on `startedAt`, so a client clock that disagrees with this one cannot change what was recorded.
|
|
452
|
+
*/
|
|
453
|
+
durationMs?: number;
|
|
454
|
+
/** What the child was doing last. Live only: absent once the run has settled. */
|
|
455
|
+
activity?: SubagentActivity;
|
|
456
|
+
/** Which phase it died in. Absent on a run that finished normally, and on an unreconciled orphan. */
|
|
457
|
+
failure?: SubagentFailure;
|
|
458
|
+
/** Pi's own session id for the child. Its transcript lives there and is never parsed by Super Pi. */
|
|
459
|
+
sessionId: string;
|
|
460
|
+
};
|
|
461
|
+
/**
|
|
462
|
+
* What may change on a run that has already been recorded.
|
|
463
|
+
*
|
|
464
|
+
* A named list rather than `Partial<SubagentRun>` because the fields a run must NEVER change are the
|
|
465
|
+
* point: `id` is the key startup reconciliation looks orphans up by, and `conversationId`/`agentId`/
|
|
466
|
+
* `task`/`startedAt` are the facts the run was DISPATCHED with. A patch that could rewrite any of them
|
|
467
|
+
* could produce a record whose id matches nothing that ran - unreconcilable, and unattributable.
|
|
468
|
+
* `sessionId` is the one identity field allowed to move, and only because a run's id and Pi's session id
|
|
469
|
+
* happen to coincide today rather than by definition.
|
|
470
|
+
*/
|
|
471
|
+
export type SubagentRunUpdate = Partial<Pick<SubagentRun, "status" | "output" | "outputTruncated" | "toolCalls" | "usage" | "error" | "finishedAt" | "sessionId" | "durationMs" | "activity" | "failure">>;
|
|
472
|
+
export type BackgroundTaskSummary = {
|
|
473
|
+
id: string;
|
|
474
|
+
title: string;
|
|
475
|
+
agentId: string;
|
|
476
|
+
status: "running" | "completed" | "failed";
|
|
477
|
+
startedAt: string;
|
|
478
|
+
resultBlockId: string;
|
|
479
|
+
};
|
|
480
|
+
export type AppState = {
|
|
481
|
+
workspaces: Workspace[];
|
|
482
|
+
conversations: Conversation[];
|
|
483
|
+
subagentRuns: SubagentRun[];
|
|
484
|
+
queuedMessages: QueuedMessage[];
|
|
485
|
+
backgroundTasks: BackgroundTaskSummary[];
|
|
486
|
+
todos?: TodoItem[];
|
|
487
|
+
};
|
|
488
|
+
export type TodoItem = {
|
|
489
|
+
id?: string;
|
|
490
|
+
content: string;
|
|
491
|
+
status: "pending" | "in_progress" | "completed";
|
|
492
|
+
priority?: "high" | "medium" | "low";
|
|
493
|
+
};
|
|
494
|
+
/**
|
|
495
|
+
* One message the user sent WHILE THE AGENT WAS WORKING, held by Super Pi until a turn boundary can
|
|
496
|
+
* carry it.
|
|
497
|
+
*
|
|
498
|
+
* WHY THE SERVER OWNS THIS LIST AT ALL. Pi's own queues live in the Pi process's memory
|
|
499
|
+
* (`agent-session.ts:394/396`) and Super Pi kills that process unconditionally at the end of every
|
|
500
|
+
* turn (`runtime.ts:539-545`), while Pi never writes a queued message to the session JSONL. A list kept
|
|
501
|
+
* in Pi is therefore guaranteed to be destroyed, with no trace, on every turn boundary. So Pi is used
|
|
502
|
+
* only as a delivery pipe - one steer per turn, handed over at the moment it is allowed to be
|
|
503
|
+
* delivered - and the waiting list is this one.
|
|
504
|
+
*
|
|
505
|
+
* THE ID IS OURS AND IT IS THE POINT. Pi matches a queued message back to its queue entry by
|
|
506
|
+
* `indexOf` on the message TEXT (`agent-session.ts:1123/1129`), so two queued messages with identical
|
|
507
|
+
* text are indistinguishable to it and one of them is left behind in its queue forever. Every entry
|
|
508
|
+
* here therefore carries a Super Pi id that Pi never sees, and cancel/steer address THAT.
|
|
509
|
+
*
|
|
510
|
+
* `state` is the whole lifecycle, and `sending` is the only irreversible step:
|
|
511
|
+
*
|
|
512
|
+
* - `queued` - in the list, not yet given to Pi. CANCELLED freely; this is where essentially every
|
|
513
|
+
* message lives, because a message is only handed to Pi when it is about to be delivered.
|
|
514
|
+
* - `sending` - written to Pi's stdin as a `steer` and not yet confirmed delivered. NOT cancellable:
|
|
515
|
+
* Pi has no single-item retract (its only queue command is `clear_queue`, which clears
|
|
516
|
+
* everything), so claiming otherwise in the UI would be a lie. It goes back to `queued`
|
|
517
|
+
* exactly when we can prove Pi never delivered it - the turn ended - and stays here when
|
|
518
|
+
* Pi simply has not answered yet.
|
|
519
|
+
*
|
|
520
|
+
* A delivered or cancelled message is REMOVED rather than kept with a terminal state: a queue that
|
|
521
|
+
* retains sent messages needs a pruning policy and a way to read it, and a stale row in this list is
|
|
522
|
+
* worse than no row.
|
|
523
|
+
*/
|
|
524
|
+
export type QueuedMessage = {
|
|
525
|
+
/** `q_<uuid>`. Super Pi's own identity for this message, and the only thing cancel/steer name. */
|
|
526
|
+
id: string;
|
|
527
|
+
/**
|
|
528
|
+
* The conversation whose next turn will carry this. Deliberately a CONVERSATION id and never a
|
|
529
|
+
* `sub_*` run id: sub-agent children run in their own Pi processes under their own supervisor
|
|
530
|
+
* (`subagents.ts`), they have their own stop and their own lifecycle, and a queue entry keyed by one
|
|
531
|
+
* would be claimed and delivered by the wrong owner.
|
|
532
|
+
*/
|
|
533
|
+
conversationId: string;
|
|
534
|
+
content: string;
|
|
535
|
+
state: "queued" | "sending";
|
|
536
|
+
createdAt: string;
|
|
537
|
+
images?: MessageImage[];
|
|
538
|
+
};
|
|
539
|
+
/**
|
|
540
|
+
* What Pi reports about ITS OWN queue, forwarded to the browser as it arrives.
|
|
541
|
+
*
|
|
542
|
+
* `steering` and `followUp` are Pi's in-memory arrays as of this event (`agent-session.ts:204-208`).
|
|
543
|
+
* They are NOT the queue the user sees: the authoritative list is `AppState.queuedMessages`, and this
|
|
544
|
+
* is Pi's own view of the at most one steer that is currently in flight inside the child process. It
|
|
545
|
+
* is forwarded because it is the only signal that proves a steer was really picked up, and because a
|
|
546
|
+
* `followUp` array that is ever non-empty means something outside this server sent a `follow_up` -
|
|
547
|
+
* worth being able to see rather than infer.
|
|
548
|
+
*/
|
|
549
|
+
export type PiQueueSnapshot = {
|
|
550
|
+
steering: string[];
|
|
551
|
+
followUp: string[];
|
|
552
|
+
};
|
|
553
|
+
/**
|
|
554
|
+
* The nine methods Pi's `RpcExtensionUIRequest` union defines (rpc-types.ts:252-287), split by whether
|
|
555
|
+
* Pi waits for a reply. `blocking` is the only field the transport needs in order to decide, and it
|
|
556
|
+
* is derived here rather than at each call site so the two halves can never drift apart.
|
|
557
|
+
*/
|
|
558
|
+
export declare const EXTENSION_UI_METHODS: readonly ["select", "confirm", "input", "editor", "notify", "setStatus", "setWidget", "setTitle", "set_editor_text"];
|
|
559
|
+
export type ExtensionUiMethod = (typeof EXTENSION_UI_METHODS)[number];
|
|
560
|
+
/** Pi registers exactly these four in `pendingExtensionRequests`; the other five are fire-and-forget. */
|
|
561
|
+
export declare const BLOCKING_UI_METHODS: ReadonlySet<string>;
|
|
562
|
+
export type ExtensionUiNotifyType = "info" | "warning" | "error";
|
|
563
|
+
export type ExtensionUiPlacement = "aboveEditor" | "belowEditor";
|
|
564
|
+
/** One normalized `extension_ui_request`. The method-specific fields are only present for methods that send them. */
|
|
565
|
+
export type ExtensionUiRequest = {
|
|
566
|
+
requestId: string;
|
|
567
|
+
method: ExtensionUiMethod;
|
|
568
|
+
/** True when Pi is blocked until the host answers. False means "display it, never ask the user to reply". */
|
|
569
|
+
blocking: boolean;
|
|
570
|
+
/** Pi omits `title` on notify/setStatus/setWidget/set_editor_text, so this is "" for those. */
|
|
571
|
+
title: string;
|
|
572
|
+
options?: string[];
|
|
573
|
+
/** confirm body, and the notify body. */
|
|
574
|
+
message?: string;
|
|
575
|
+
placeholder?: string;
|
|
576
|
+
prefill?: string;
|
|
577
|
+
/** Pi's own deadline in ms. `editor` never has one, which is why an unanswered editor waits forever. */
|
|
578
|
+
timeoutMs?: number;
|
|
579
|
+
notifyType?: ExtensionUiNotifyType;
|
|
580
|
+
statusKey?: string;
|
|
581
|
+
statusText?: string;
|
|
582
|
+
widgetKey?: string;
|
|
583
|
+
widgetLines?: string[];
|
|
584
|
+
widgetPlacement?: ExtensionUiPlacement;
|
|
585
|
+
text?: string;
|
|
586
|
+
};
|
|
587
|
+
export type ExtensionUiCloseReason = "answered" | "cancelled" | "timeout" | "aborted" | "expired";
|
|
588
|
+
/** The answer a human gave. `cancelled` is Pi's third response shape: no answer was collected. */
|
|
589
|
+
export type ExtensionUiAnswer = {
|
|
590
|
+
value?: string;
|
|
591
|
+
confirmed?: boolean;
|
|
592
|
+
cancelled?: true;
|
|
593
|
+
};
|
|
594
|
+
export type ClientCommand = {
|
|
595
|
+
type: "workspace.create";
|
|
596
|
+
name: string;
|
|
597
|
+
path?: string;
|
|
598
|
+
} | {
|
|
599
|
+
type: "workspace.rename";
|
|
600
|
+
workspaceId: string;
|
|
601
|
+
name: string;
|
|
602
|
+
} | {
|
|
603
|
+
type: "workspace.delete";
|
|
604
|
+
workspaceId: string;
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Replaces BOTH workspace defaults in one shot, because the settings dialog saves them together.
|
|
608
|
+
* `null` clears a default back to "Pi's own" - a default the user removed must not persist as a
|
|
609
|
+
* value, or a new conversation would keep inheriting a choice the dialog no longer shows.
|
|
610
|
+
*/
|
|
611
|
+
| {
|
|
612
|
+
type: "workspace.settings";
|
|
613
|
+
workspaceId: string;
|
|
614
|
+
model: {
|
|
615
|
+
provider: string;
|
|
616
|
+
modelId: string;
|
|
617
|
+
} | null;
|
|
618
|
+
thinkingLevel: string | null;
|
|
619
|
+
agentId?: string | null;
|
|
620
|
+
} | {
|
|
621
|
+
type: "conversation.create";
|
|
622
|
+
workspaceId: string;
|
|
623
|
+
} | {
|
|
624
|
+
type: "conversation.select";
|
|
625
|
+
conversationId: string;
|
|
626
|
+
} | {
|
|
627
|
+
type: "conversation.archive";
|
|
628
|
+
conversationId: string;
|
|
629
|
+
} | {
|
|
630
|
+
type: "conversation.unarchive";
|
|
631
|
+
conversationId: string;
|
|
632
|
+
} | {
|
|
633
|
+
type: "conversation.rename";
|
|
634
|
+
conversationId: string;
|
|
635
|
+
name: string;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* FORKS A WHOLE SESSION: a second conversation that starts from this one's complete history, in Pi's own
|
|
639
|
+
* transcript as well as in `state.json`.
|
|
640
|
+
*
|
|
641
|
+
* WHOLE SESSION, NOT A MESSAGE. Pi's own `fork` takes an `entryId` and branches from one message
|
|
642
|
+
* (`rpc-types.ts:62-66`, `agent-session-runtime.ts:279-287`); this takes no message id because the thing
|
|
643
|
+
* being asked for is a copy of the conversation as it stands, to be taken in another direction.
|
|
644
|
+
*
|
|
645
|
+
* WHY PI'S OWN `fork` IS NOT USED, which is the whole reason this command exists rather than a passthrough.
|
|
646
|
+
* Three separate reasons, and the first is not negotiable:
|
|
647
|
+
*
|
|
648
|
+
* 1. It TEARS THE RUNTIME DOWN AND SWITCHES TO THE NEW SESSION (`agent-session-runtime.ts:299-307`:
|
|
649
|
+
* `teardownCurrent("fork", ...)` then `apply(createRuntime(...))`). Super Pi's "runtime" is one short-lived
|
|
650
|
+
* Pi per turn (`runtime.ts:840` spawns, `:429-435` kills), so there is no runtime to hand across; there is
|
|
651
|
+
* only a turn. Anything reached for here would have to be undone immediately.
|
|
652
|
+
* 2. It requires a long-lived child to fork FROM, which Super Pi deliberately does not have. Every piece of
|
|
653
|
+
* Super Pi's session state is a durable file, and the process that used it is gone by the next turn.
|
|
654
|
+
* 3. Even given a live child, its result is only reachable through that child's runtime - the new session's
|
|
655
|
+
* file is a side effect of the swap, not something a caller receives. Super Pi needs the file written
|
|
656
|
+
* BEFORE the conversation exists, and needs to be told if it failed.
|
|
657
|
+
*
|
|
658
|
+
* So the fork is performed the same way a revert is: by asking Pi which file it uses for this id
|
|
659
|
+
* (`get_state.sessionFile`), checking that file belongs to the conversation being forked, and then writing a
|
|
660
|
+
* second file. See `runtime.ts` `fork()`.
|
|
661
|
+
*/
|
|
662
|
+
| {
|
|
663
|
+
type: "conversation.fork";
|
|
664
|
+
conversationId: string;
|
|
665
|
+
}
|
|
666
|
+
/** Chooses the model for a conversation. Both fields are required because Pi's set_model needs both. */
|
|
667
|
+
| {
|
|
668
|
+
type: "conversation.model";
|
|
669
|
+
conversationId: string;
|
|
670
|
+
provider: string;
|
|
671
|
+
modelId: string;
|
|
672
|
+
} | {
|
|
673
|
+
type: "conversation.thinking";
|
|
674
|
+
conversationId: string;
|
|
675
|
+
level: string;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Applies an agent to a conversation. `agentId: null` clears it back to Pi's own defaults, the same
|
|
679
|
+
* "absence means no value" rule the model and thinking selections follow.
|
|
680
|
+
*
|
|
681
|
+
* An agent that specifies a model or a thinking level does NOT set them here - it cannot, without
|
|
682
|
+
* knowing what the user has already chosen. It resolves them in the handler, where a user override
|
|
683
|
+
* that already exists is left alone, which is what makes the user's pick win over the agent's.
|
|
684
|
+
*/
|
|
685
|
+
| {
|
|
686
|
+
type: "conversation.agent";
|
|
687
|
+
conversationId: string;
|
|
688
|
+
agentId: string | null;
|
|
689
|
+
}
|
|
690
|
+
/** Asks for the model/thinking catalog. Answered by `model.catalog`, never by an `error`. */
|
|
691
|
+
| {
|
|
692
|
+
type: "catalog.fetch";
|
|
693
|
+
}
|
|
694
|
+
/** Asks for the agent catalog read from disk. Answered by `agent.catalog`. */
|
|
695
|
+
| {
|
|
696
|
+
type: "agent.catalog.fetch";
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Sends a message to a conversation, and THAT IS ALL IT CLAIMS.
|
|
700
|
+
*
|
|
701
|
+
* Whether the message starts a turn or joins the waiting queue is the SERVER's decision, made from
|
|
702
|
+
* the run guard in `index.ts`: a conversation with a turn in flight gets the message queued, an idle
|
|
703
|
+
* one gets a turn. The browser never picks, which is what makes the guard un-bypassable - the old
|
|
704
|
+
* arrangement had the browser decide by disabling itself, and a second WebSocket client (or a stale
|
|
705
|
+
* tab) walked straight past it into two Pi processes writing one session.
|
|
706
|
+
*/
|
|
707
|
+
| {
|
|
708
|
+
type: "message.send";
|
|
709
|
+
conversationId: string;
|
|
710
|
+
content: string;
|
|
711
|
+
images?: MessageImage[];
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Revert the conversation to the state right AFTER one message: every message after it is removed from
|
|
715
|
+
* BOTH histories, and no code file is touched.
|
|
716
|
+
*
|
|
717
|
+
* `messageId` names the message that SURVIVES. That is the same boundary OpenCode's revert draws
|
|
718
|
+
* (`DELETE FROM session_message WHERE seq > boundary.seq`, `packages/core/src/session/projector.ts:427-432`)
|
|
719
|
+
* and it is why this command takes no "how many messages" and no session id: the boundary is a thing the
|
|
720
|
+
* user can see and point at, and everything after it goes.
|
|
721
|
+
*
|
|
722
|
+
* No new conversation is created, ever. That is the user's hard requirement, and the reason it is worth
|
|
723
|
+
* stating here rather than only in the handler: Pi's own `fork()` cannot honour it (`fork()` always
|
|
724
|
+
* tears the runtime down and opens a new session file, `agent-session-runtime.ts:299-307`), so any
|
|
725
|
+
* implementation that reached for fork would break the promise without any type complaining.
|
|
726
|
+
*/
|
|
727
|
+
| {
|
|
728
|
+
type: "message.revert";
|
|
729
|
+
conversationId: string;
|
|
730
|
+
messageId: string;
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Drops ONE queued message, by Super Pi's id.
|
|
734
|
+
*
|
|
735
|
+
* Addressed by `queueId` rather than by text or by index for the reason `QueuedMessage.id` exists at
|
|
736
|
+
* all: two queued messages can be the same string, and a cancel that matched on text would drop
|
|
737
|
+
* whichever one sorted first. Refused for an entry already in `sending` - Pi holds that text now and
|
|
738
|
+
* cannot be told to forget one item - and an unknown or already-removed id is a no-op rather than an
|
|
739
|
+
* error, exactly as `subagent.stop` treats a run that has already settled.
|
|
740
|
+
*/
|
|
741
|
+
| {
|
|
742
|
+
type: "message.cancel";
|
|
743
|
+
conversationId: string;
|
|
744
|
+
queueId: string;
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Promotes one queued message to a `steer`: Pi takes it at the next turn boundary instead of waiting
|
|
748
|
+
* for the whole task to finish.
|
|
749
|
+
*
|
|
750
|
+
* The timing is Pi's and cannot be finer: `agent-loop.ts:295` polls steering after `turn_end`, which
|
|
751
|
+
* is after the WHOLE tool-call batch of one assistant message has finished (`agent-loop.ts:270`). So
|
|
752
|
+
* this means "after the tools currently running finish", and during a long single tool call there is
|
|
753
|
+
* nothing to interrupt - Pi offers no preemption. The UI says so in words rather than implying
|
|
754
|
+
* instant delivery.
|
|
755
|
+
*/
|
|
756
|
+
| {
|
|
757
|
+
type: "message.steer";
|
|
758
|
+
conversationId: string;
|
|
759
|
+
queueId: string;
|
|
760
|
+
} | {
|
|
761
|
+
type: "run.stop";
|
|
762
|
+
conversationId: string;
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* A HUMAN dispatches a sub-agent, with no model in the loop (design doc §3, entry 1).
|
|
766
|
+
*
|
|
767
|
+
* The three fields are the whole contract and none of them is optional: `conversationId` decides
|
|
768
|
+
* whose workspace the child runs in and whose transcript its card appears under, `agentId` must
|
|
769
|
+
* name a `mode: subagent` definition (the handler resolves it that way, so a primary agent is
|
|
770
|
+
* refused rather than quietly run as one), and `task` is what the child is asked to do. `task` has
|
|
771
|
+
* no maximum here because the cap in design §6 applies to what a MODEL is handed back, not to what
|
|
772
|
+
* a human may type; `SubagentSupervisor.run` refuses an empty one.
|
|
773
|
+
*/
|
|
774
|
+
| {
|
|
775
|
+
type: "subagent.run";
|
|
776
|
+
conversationId: string;
|
|
777
|
+
agentId: string;
|
|
778
|
+
task: string;
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Stops ONE sub-agent run, by id rather than by conversation.
|
|
782
|
+
*
|
|
783
|
+
* Id-keyed because a conversation may have several runs in flight at once and "stop" that kills
|
|
784
|
+
* every child of a conversation is a different, larger claim than the button on one card makes. A
|
|
785
|
+
* run id that is unknown, or already settled, is a no-op rather than an error: the card that sent
|
|
786
|
+
* this has usually just been told the run ended, and a stop that arrives late must not look like
|
|
787
|
+
* a failure.
|
|
788
|
+
*/
|
|
789
|
+
| {
|
|
790
|
+
type: "subagent.stop";
|
|
791
|
+
runId: string;
|
|
792
|
+
}
|
|
793
|
+
/** Asks for the `mode: subagent` half of the agent directory. Answered by `subagent.catalog`. */
|
|
794
|
+
| {
|
|
795
|
+
type: "subagent.catalog.fetch";
|
|
796
|
+
} | ({
|
|
797
|
+
type: "extension.ui.answer";
|
|
798
|
+
conversationId: string;
|
|
799
|
+
requestId: string;
|
|
800
|
+
} & ExtensionUiAnswer);
|
|
801
|
+
export type ServerEvent = {
|
|
802
|
+
type: "state";
|
|
803
|
+
state: AppState;
|
|
804
|
+
} | {
|
|
805
|
+
type: "message.delta";
|
|
806
|
+
conversationId: string;
|
|
807
|
+
messageId: string;
|
|
808
|
+
delta: string;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* THE ANSWER TO `conversation.fork`, as ONE event with an `ok` discriminant rather than two events or a
|
|
812
|
+
* bare `error`.
|
|
813
|
+
*
|
|
814
|
+
* WHY THIS EXISTS RATHER THAN REUSING `error`. The generic `error` event carries a message and nothing
|
|
815
|
+
* else, and the client's handler turns one into a banner over the whole pane. A fork that failed is a
|
|
816
|
+
* fact about ONE row of the sidebar, and the reader needs to know WHICH row: 「分叉失败:…」 with no
|
|
817
|
+
* conversation named leaves the user looking at a button that did nothing, which is the exact failure
|
|
818
|
+
* the requirement names. Two consequences, both load-bearing:
|
|
819
|
+
*
|
|
820
|
+
* - `conversationId` is on BOTH arms, so the browser can clear that row's pending state and re-enable
|
|
821
|
+
* exactly the button that was pressed. Nothing else in the protocol can be correlated to the press.
|
|
822
|
+
* - The failure arm is a SENTENCE (`message`), produced by the same code that produced the refusal, so
|
|
823
|
+
* the reason the server refused is the reason shown. A boolean would be a dead click.
|
|
824
|
+
*
|
|
825
|
+
* It is ALSO why the handler catches instead of letting a fork throw: a thrown command becomes a
|
|
826
|
+
* broadcast `error` to every connected browser, which would blame an unrelated pane in another tab.
|
|
827
|
+
*
|
|
828
|
+
* The success arm reports `messagesCopied` because "the fork happened" and "the fork happened completely"
|
|
829
|
+
* are different claims, and a whole-session fork of a conversation whose Pi transcript could not be
|
|
830
|
+
* located is a copy of the visible half only.
|
|
831
|
+
*/
|
|
832
|
+
| ({
|
|
833
|
+
type: "conversation.fork.result";
|
|
834
|
+
conversationId: string;
|
|
835
|
+
} & ({
|
|
836
|
+
ok: true;
|
|
837
|
+
forkedId: string;
|
|
838
|
+
title: string;
|
|
839
|
+
messagesCopied: number;
|
|
840
|
+
piSessionFile?: string;
|
|
841
|
+
} | {
|
|
842
|
+
ok: false;
|
|
843
|
+
message: string;
|
|
844
|
+
}))
|
|
845
|
+
/**
|
|
846
|
+
* Every tool call made while filling one message, as a whole SNAPSHOT.
|
|
847
|
+
*
|
|
848
|
+
* A snapshot for the same reason `subagent.event` is one and `message.delta` is a delta: `state` carries
|
|
849
|
+
* the same array, so a reload or a reconnect rebuilds every block from the file, and a browser that missed
|
|
850
|
+
* an event must not be left holding a half-built row. The difference from `message.delta` is that this one
|
|
851
|
+
* CANNOT be a delta - the fields change on a row rather than append to a string, and "apply this patch"
|
|
852
|
+
* is an instruction the client has to be trusted to apply correctly.
|
|
853
|
+
*
|
|
854
|
+
* Sent on `tool_execution_start` and again on `tool_execution_end`, so a block appears the moment a call
|
|
855
|
+
* begins rather than when the whole turn ends. That is what makes a long tool call visible while it is
|
|
856
|
+
* running - and a call that starts and never ends leaves a row with no `status`, which is exactly how a
|
|
857
|
+
* block says "still in flight" instead of claiming an outcome nobody reported.
|
|
858
|
+
*/
|
|
859
|
+
| {
|
|
860
|
+
type: "message.tools";
|
|
861
|
+
conversationId: string;
|
|
862
|
+
messageId: string;
|
|
863
|
+
toolCalls: ToolCallBlock[];
|
|
864
|
+
} | {
|
|
865
|
+
type: "error";
|
|
866
|
+
message: string;
|
|
867
|
+
} | {
|
|
868
|
+
type: "extension.ui";
|
|
869
|
+
conversationId: string;
|
|
870
|
+
request: ExtensionUiRequest;
|
|
871
|
+
} | {
|
|
872
|
+
type: "extension.ui.closed";
|
|
873
|
+
conversationId: string;
|
|
874
|
+
requestId: string;
|
|
875
|
+
reason: ExtensionUiCloseReason;
|
|
876
|
+
} | {
|
|
877
|
+
type: "model.catalog";
|
|
878
|
+
catalog: ModelCatalog;
|
|
879
|
+
} | {
|
|
880
|
+
type: "agent.catalog";
|
|
881
|
+
catalog: AgentCatalog;
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* The `mode: subagent` half of the same directory, as its OWN event rather than an extra key on
|
|
885
|
+
* `agent.catalog`.
|
|
886
|
+
*
|
|
887
|
+
* Split because the two rosters are kept apart everywhere else - `readAgents` filters by mode, the
|
|
888
|
+
* primary picker never sees a sub-agent and vice versa - and folding them into one payload would
|
|
889
|
+
* hand the browser a list it must then filter correctly, which is precisely the invariant that has
|
|
890
|
+
* to hold. Two catalogs, each already filtered at the source, cannot leak across.
|
|
891
|
+
*/
|
|
892
|
+
| {
|
|
893
|
+
type: "subagent.catalog";
|
|
894
|
+
catalog: AgentCatalog;
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* The current SNAPSHOT of one sub-agent run, sent on every text delta rather than once at the end.
|
|
898
|
+
*
|
|
899
|
+
* A snapshot rather than a delta for the same reason `state` is one: a card that assembles itself
|
|
900
|
+
* from a missed event is a card that can be wrong, and this event has to survive a reconnect race and
|
|
901
|
+
* an out-of-order drop. The price is that each one serializes the run so far, which is exactly why
|
|
902
|
+
* `SubagentRun` holds the last assistant message and not the transcript.
|
|
903
|
+
*/
|
|
904
|
+
| {
|
|
905
|
+
type: "subagent.event";
|
|
906
|
+
run: SubagentRun;
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Pi's own queue snapshot, forwarded the moment `queue_update` arrives on the child's stdout.
|
|
910
|
+
*
|
|
911
|
+
* A new event rather than another key on `state` because it is a different thing with a different
|
|
912
|
+
* lifetime: `state` carries the durable waiting list, this carries what the live child process is
|
|
913
|
+
* holding right now and changes many times within a single turn. Keeping them apart is also what stops
|
|
914
|
+
* the two from being confused for one source of truth - the browser renders the dock from `state` and
|
|
915
|
+
* only annotates it with this.
|
|
916
|
+
*/
|
|
917
|
+
| ({
|
|
918
|
+
type: "pi.queue";
|
|
919
|
+
conversationId: string;
|
|
920
|
+
} & PiQueueSnapshot);
|