@evandu/super-pi 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +79 -2
  2. package/agents/coder.md +10 -0
  3. package/agents/critic.md +20 -0
  4. package/agents/patcher.md +23 -0
  5. package/agents/planner.md +18 -0
  6. package/agents/reviewer.md +13 -0
  7. package/agents/scout.md +23 -0
  8. package/agents/super-pi-task/core.ts +485 -0
  9. package/agents/super-pi-task/index.ts +141 -0
  10. package/agents/verifier.md +21 -0
  11. package/bin/super-pi.js +17 -0
  12. package/dist/client/assets/index-BMQsEdio.js +24 -0
  13. package/dist/client/assets/index-CsbZzuMy.css +1 -0
  14. package/dist/client/index.html +14 -0
  15. package/dist/extension.d.ts +15 -0
  16. package/dist/extension.js +111 -0
  17. package/dist/server/agents.d.ts +177 -0
  18. package/dist/server/agents.js +484 -0
  19. package/dist/server/application/catalog-service.d.ts +44 -0
  20. package/dist/server/application/catalog-service.js +94 -0
  21. package/dist/server/application/conversation-service.d.ts +73 -0
  22. package/dist/server/application/conversation-service.js +202 -0
  23. package/dist/server/application/extension-ui-service.d.ts +28 -0
  24. package/dist/server/application/extension-ui-service.js +73 -0
  25. package/dist/server/application/run-service.d.ts +115 -0
  26. package/dist/server/application/run-service.js +341 -0
  27. package/dist/server/application/workspace-service.d.ts +45 -0
  28. package/dist/server/application/workspace-service.js +106 -0
  29. package/dist/server/command-handler.d.ts +22 -0
  30. package/dist/server/command-handler.js +137 -0
  31. package/dist/server/files.d.ts +202 -0
  32. package/dist/server/files.js +629 -0
  33. package/dist/server/http-server.d.ts +20 -0
  34. package/dist/server/http-server.js +313 -0
  35. package/dist/server/index.d.ts +1 -0
  36. package/dist/server/index.js +103 -0
  37. package/dist/server/pi-command.d.ts +65 -0
  38. package/dist/server/pi-command.js +183 -0
  39. package/dist/server/runtime.d.ts +819 -0
  40. package/dist/server/runtime.js +1470 -0
  41. package/dist/server/server-context.d.ts +46 -0
  42. package/dist/server/server-context.js +1 -0
  43. package/dist/server/store.d.ts +424 -0
  44. package/dist/server/store.js +1121 -0
  45. package/dist/server/subagent-runs.d.ts +142 -0
  46. package/dist/server/subagent-runs.js +315 -0
  47. package/dist/server/subagents.d.ts +194 -0
  48. package/dist/server/subagents.js +542 -0
  49. package/dist/server/task-tool.d.ts +196 -0
  50. package/dist/server/task-tool.js +285 -0
  51. package/dist/server/tool-calls.d.ts +62 -0
  52. package/dist/server/tool-calls.js +141 -0
  53. package/dist/server/types.d.ts +920 -0
  54. package/dist/server/types.js +11 -0
  55. package/dist/server/upload.d.ts +37 -0
  56. package/dist/server/upload.js +169 -0
  57. package/dist/server/ws-server.d.ts +45 -0
  58. package/dist/server/ws-server.js +116 -0
  59. package/package.json +115 -4
@@ -0,0 +1,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);