@evandu/super-pi 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,819 @@
1
+ import { type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { type AgentDefinition, type Conversation, type ExtensionUiAnswer, type ExtensionUiRequest, type MessageImage, type ModelCatalog, type ModelOption, type ModelSelection, type PiQueueSnapshot, type ToolCallNotice } from "./types.js";
3
+ /**
4
+ * Who answers Pi's Extension UI questions. The runtime never invents an answer: a blocking method
5
+ * either comes back from `ask`, or it is cancelled. That is the whole point of Pi's third response
6
+ * shape - `{ type: "extension_ui_response", id, cancelled: true }` means "no answer was collected",
7
+ * which Pi is entitled to fall back from, whereas a fabricated `options[0]` looks identical to a
8
+ * human choice and silently discards the question the user was supposed to see.
9
+ */
10
+ export interface ExtensionUiHost {
11
+ /** Blocking methods. Must settle only once a human answered, cancelled, or the run ended. */
12
+ ask(request: ExtensionUiRequest): Promise<ExtensionUiAnswer>;
13
+ /** One-way methods. Fire and forget: the host must not retain the request, and there is nothing to await. */
14
+ notify(request: ExtensionUiRequest): void;
15
+ }
16
+ export interface AnswerInput {
17
+ conversation: Conversation;
18
+ prompt: string;
19
+ images?: MessageImage[];
20
+ signal: AbortSignal;
21
+ /**
22
+ * The directory the child process must run in: the conversation's workspace path, resolved by the
23
+ * caller (the runtime never sees the workspace map). Required with no fallback - every tool Pi runs
24
+ * resolves its paths against this directory, so a missing one must fail loudly rather than silently
25
+ * run in the server's directory.
26
+ */
27
+ cwd: string;
28
+ /** Absent in headless use, in which case blocking requests are cancelled rather than guessed at. */
29
+ host?: ExtensionUiHost;
30
+ /**
31
+ * Called for every `queue_update` the child pushes, which is Pi reporting its own in-memory queue.
32
+ *
33
+ * Optional and observational: nothing about the delivery contract depends on it. It exists because
34
+ * `queue_update` (`agent-session.ts:204-208`) is the ONLY proof that a steer was actually picked up -
35
+ * Pi removes the entry from its queue and emits this snapshot BEFORE it emits the delivered
36
+ * `message_start` (`agent-session.ts:1116-1136`), so a host that ignored it would have no way to tell
37
+ * "queued in Pi" from "delivered to the model". It is also why a record that used to be announced as
38
+ * unhandled and dropped is now read.
39
+ */
40
+ onPiQueue?(queue: PiQueueSnapshot): void;
41
+ /**
42
+ * Called ONCE per turn with the Pi entry id of the user message that started it, or never.
43
+ *
44
+ * This is the whole of Super Pi's ability to name a position inside Pi's transcript, and the window is
45
+ * one turn long: `answer()` spawns Pi here and kills it in its `finally`, so an id not taken before the
46
+ * loop ends is not obtainable at all. It is taken from Pi's own `get_entries` (`rpc-mode.ts:636-647`),
47
+ * not by counting, because Pi writes entry types that are not messages (`model_change`,
48
+ * `thinking_level_change`, `compaction`, ...) and only Pi knows which line the user message is.
49
+ *
50
+ * Failing to produce one is NOT a turn failure - the answer has already streamed by then - so it is
51
+ * never awaited on the critical path and never thrown. A message with no entry id simply cannot be a
52
+ * revert boundary, which the revert handler reports in words.
53
+ */
54
+ onUserEntry?: (entryId: string) => void | Promise<void>;
55
+ /**
56
+ * Called once per `tool_execution_start`, with the tool's name and the bounded label naming what it was
57
+ * aimed at.
58
+ *
59
+ * ABSENT MEANS "DON'T BOTHER", and the record is still consumed rather than announced - see the drain
60
+ * loop. That is the whole point of this pair: until it existed the PRIMARY agent's tool calls were the
61
+ * one thing Pi emitted that nobody on this side looked at, so a `read` and a two-minute `bash` were the
62
+ * same silence on the transcript, and a reader had no way to tell "the agent did nothing" from "the agent
63
+ * did six things you cannot see".
64
+ *
65
+ * The same two facts the sub-agent supervisor already reports, through the same `describeToolCall`
66
+ * mapping, because the two surfaces are two windows onto one protocol and a `read` labelled by its path
67
+ * on a card but by its working directory on a call block would be worse than having neither.
68
+ */
69
+ onToolCall?(call: ToolCallNotice): void;
70
+ /**
71
+ * Called on `tool_execution_end` for the same `id`, carrying how the call ended.
72
+ *
73
+ * A SEPARATE callback for the same reason the supervisor has one: "a tool started" and "a tool finished"
74
+ * are distinguishable events, and a caller that could not tell them apart would have to invent the second
75
+ * one - which would be a row claiming an outcome Pi never reported.
76
+ *
77
+ * ONLY `isError` AND `durationMs` CROSS OVER. `result` is deliberately never read, for the reason
78
+ * `RpcRecord` does not even declare it: for a `read` it is the file's contents, and this type is the one
79
+ * place a reader would reach for it.
80
+ */
81
+ onToolEnd?(call: ToolCallNotice): void;
82
+ }
83
+ /**
84
+ * What Pi answered when a `steer` was written to it, from the `disposition` of that command's response
85
+ * (`rpc-mode.ts:416-419`).
86
+ *
87
+ * - `handled` - Pi delivered it immediately, because there was no turn to wait for. Super Pi can drop it
88
+ * from its own list right away.
89
+ * - `queued` - Pi is holding it and will take it at the next turn boundary. It stays in Super Pi's list
90
+ * as `sending` until `queue_update` reports it gone.
91
+ *
92
+ * This is a real distinction and not a detail: it is the difference between a message that is already
93
+ * in the model's context and one that is not, and the UI must not claim either without knowing which.
94
+ */
95
+ export type SteerDisposition = "handled" | "queued";
96
+ /**
97
+ * What a revert has to be told, and what it is told afterwards. Declared here rather than in `types.ts`
98
+ * because neither travels to the browser: this is the runtime's own account of a file it changed.
99
+ */
100
+ export type RevertRequest = {
101
+ /** The conversation being reverted. Its `id` is Pi's `--session-id`, and is what identifies the file. */
102
+ conversation: Conversation;
103
+ /**
104
+ * The directory the next turn would spawn Pi in - the conversation's workspace.
105
+ *
106
+ * NOT OPTIONAL and NOT DEFAULTED, because it decides WHICH FILE IS FOUND: Pi's own `findById`
107
+ * (`session-manager.ts:1874-1891`) only matches a session whose recorded `cwd` equals the one it is
108
+ * asked from, and probe B measured that: the same id opened from two different cwds resolved to two
109
+ * different files, one of which did not exist. Passing the server's own directory here would find no
110
+ * conversation at all, and passing the wrong workspace would find somebody else's.
111
+ */
112
+ cwd: string;
113
+ /** Pi's entry id of the last message that SURVIVES. Everything written after it is dropped. */
114
+ boundaryEntryId: string;
115
+ };
116
+ export type RevertOutcome = {
117
+ /** The file that was truncated, so a caller can log what was rewritten. */
118
+ sessionFile: string;
119
+ /** Lines kept, header included. The boundary line is the last of them. */
120
+ linesKept: number;
121
+ /** Entry lines dropped. Zero means the boundary was already the leaf. */
122
+ entriesDropped: number;
123
+ };
124
+ /** What a fork has to be told, and what it is told afterwards. See `RevertRequest` for why these are here
125
+ * rather than in `types.ts`: neither travels to the browser - this is the runtime's own account of a file
126
+ * it wrote. */
127
+ export type ForkRequest = {
128
+ /** The conversation being forked. Its `id` is Pi's `--session-id`, and is what identifies the file. */
129
+ conversation: Conversation;
130
+ /** The same directory the next turn would spawn Pi in - the conversation's workspace. Required for the
131
+ * same reason as `RevertRequest.cwd`: Pi's own lookup only matches a session whose recorded `cwd`
132
+ * equals the one it is asked from, so getting this wrong resolves to some other conversation's file. */
133
+ cwd: string;
134
+ /**
135
+ * The id the NEW conversation will have, minted by the store BEFORE the file is written.
136
+ *
137
+ * MINTED UP FRONT RATHER THAN BY THE RUNTIME, and the ordering is the safety property: this file's
138
+ * header `id` has to BE the new conversation's id, so the id must exist before a byte is written - but
139
+ * the conversation itself must not exist until the file is safely on disk. Minting here and creating
140
+ * after gives exactly that: a write failure leaves an unreferenced `.jsonl` (inert - Pi only ever opens
141
+ * a file by an id some conversation already claims), while the reverse order would leave a conversation
142
+ * whose model context is silently empty, which is the single worst outcome available here.
143
+ */
144
+ forkedConversationId: string;
145
+ };
146
+ export type ForkOutcome = {
147
+ /** The file that was written, so a caller can log it. Absent when the source had no Pi transcript at
148
+ * all - an empty conversation - and the fork is then a copy of `state.json` alone. */
149
+ sessionFile?: string;
150
+ /** Entry lines copied, header excluded. */
151
+ entriesCopied: number;
152
+ };
153
+ export interface AgentRuntime {
154
+ answer(input: AnswerInput): AsyncIterable<string>;
155
+ stop?(conversationId: string): Promise<void>;
156
+ /**
157
+ * Hands one message to the Pi process that is running RIGHT NOW, to be injected at its next turn
158
+ * boundary (`agent-loop.ts:295`) rather than after the whole task.
159
+ *
160
+ * Resolves with Pi's own answer, and REJECTS when there is no live child or Pi did not answer. Rejecting
161
+ * rather than silently succeeding is what lets the caller keep the message cancellable: a steer that
162
+ * never reached a Pi process provably reached no model, while a steer that was written but not
163
+ * confirmed is genuinely unknown and must not be reported as either delivered or failed.
164
+ *
165
+ * Absent on a runtime with no Pi behind it (the welcome runtime), which has nothing to steer.
166
+ */
167
+ steer?(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
168
+ /**
169
+ * The agent a conversation runs as, read fresh from the agent directory. Absent on a runtime with
170
+ * no agents (the welcome runtime), where every conversation runs as Pi itself. Callers that need an
171
+ * agent's declared model - to apply it when one is selected - go through here rather than reading the
172
+ * directory themselves, so the picker and the spawn can never disagree about which definition is live.
173
+ */
174
+ agentFor?(conversation: Pick<Conversation, "id" | "agentId">): Promise<AgentDefinition | undefined>;
175
+ /**
176
+ * The model/thinking catalog a picker lists. `selection` scopes the answer to one model's
177
+ * capabilities, because Pi's thinking levels are per model (`getSupportedThinkingLevels`,
178
+ * ai/src/models.ts:1224): a catalog taken from the default model would offer levels that the
179
+ * selected model silently clamps away.
180
+ */
181
+ catalog(selection?: ModelSelection): Promise<ModelCatalog>;
182
+ /**
183
+ * Reverts the conversation to the state right after `boundaryEntryId`, in Pi's own transcript.
184
+ *
185
+ * OPTIONAL on the interface, like `stop`, because a runtime with no Pi has no Pi transcript to cut -
186
+ * and the handler must not pretend otherwise. A runtime that cannot revert says so; it never silently
187
+ * does nothing, which would leave `state.json` and the model's context permanently disagreeing while
188
+ * the UI showed a perfectly successful revert.
189
+ */
190
+ revert?(request: RevertRequest): Promise<RevertOutcome>;
191
+ /**
192
+ * Writes a SECOND Pi transcript that starts as an exact copy of one conversation's, under a different
193
+ * session id.
194
+ *
195
+ * OPTIONAL on the interface for the same reason `revert` is: a runtime with no Pi behind it has no Pi
196
+ * transcript to copy. But the asymmetry matters - a revert that cannot happen is merely refused, whereas
197
+ * a fork that quietly produced only a `state.json` copy would hand the user a conversation that LOOKS
198
+ * complete and whose model has never heard any of it. So the absence of this method is reported as a
199
+ * refusal, exactly as `revert`'s absence is, rather than passed over.
200
+ */
201
+ fork?(request: ForkRequest): Promise<ForkOutcome>;
202
+ }
203
+ /** Deterministic offline runtime used by tests and local UI development. */
204
+ export declare class WelcomeRuntime implements AgentRuntime {
205
+ answer({ prompt, signal }: AnswerInput): AsyncIterable<string>;
206
+ stop(): Promise<void>;
207
+ /**
208
+ * No Pi, so no models. `source` is what turns the picker into a visibly disabled control instead
209
+ * of an empty list, which would read as "you have no models".
210
+ */
211
+ catalog(): Promise<ModelCatalog>;
212
+ }
213
+ /**
214
+ * One record off Pi's JSONL stdout. Deliberately a loose shape with optional fields: Pi emits a wide,
215
+ * open-ended family of session events, and a type that enumerated all of them would break on the next
216
+ * Pi release rather than on the next behaviour change.
217
+ *
218
+ * Exported so another consumer of `RpcChannel` - the sub-agent supervisor - can read the same records
219
+ * through the same channel instead of writing a second JSONL reader. Every field below is optional, so
220
+ * a reader must check before using one.
221
+ */
222
+ export type RpcRecord = {
223
+ type?: string;
224
+ id?: string;
225
+ success?: boolean;
226
+ error?: string;
227
+ /** Every `response` record's payload. Kept opaque: only the catalog reader knows these shapes. */
228
+ data?: unknown;
229
+ /**
230
+ * The inner event of a `message_update`. `message` rides on `done` - the authoritative final
231
+ * assistant message, with its text content, its stop reason and its model (pi-ai AssistantMessageEvent).
232
+ */
233
+ assistantMessageEvent?: {
234
+ type?: string;
235
+ delta?: string;
236
+ reason?: string;
237
+ message?: {
238
+ role?: string;
239
+ content?: unknown;
240
+ stopReason?: string;
241
+ model?: string;
242
+ };
243
+ };
244
+ /**
245
+ * Cumulative usage of the CURRENT assistant message, repeated on every one of that message's updates
246
+ * (modes/json-event.ts:56-59). Reading it anywhere but on `done` counts the same tokens per token.
247
+ */
248
+ usage?: {
249
+ input?: number;
250
+ output?: number;
251
+ cacheRead?: number;
252
+ cacheWrite?: number;
253
+ cost?: {
254
+ total?: number;
255
+ };
256
+ };
257
+ toolCallId?: string;
258
+ toolName?: string;
259
+ args?: unknown;
260
+ /**
261
+ * tool_execution_end payload. `result` is NOT declared: it is deliberately never read, because for a
262
+ * `read` it is the file's contents and this type is the one place a reader would reach for it. The two
263
+ * numbers below are the whole of what a human-readable tool row can honestly report.
264
+ */
265
+ isError?: boolean;
266
+ durationMs?: number;
267
+ method?: string;
268
+ title?: string;
269
+ options?: unknown;
270
+ message?: string;
271
+ placeholder?: string;
272
+ prefill?: string;
273
+ timeout?: number;
274
+ notifyType?: string;
275
+ statusKey?: string;
276
+ statusText?: string;
277
+ widgetKey?: string;
278
+ widgetLines?: unknown;
279
+ widgetPlacement?: string;
280
+ text?: string;
281
+ extensionPath?: string;
282
+ event?: string;
283
+ steering?: unknown;
284
+ followUp?: unknown;
285
+ };
286
+ /**
287
+ * Reads Pi's `queue_update` payload, or returns undefined when the record is not one.
288
+ *
289
+ * Filters to real strings rather than passing the arrays through, so a malformed snapshot cannot put
290
+ * objects into the browser's DOM or into a log line that is supposed to be readable - the same rule
291
+ * `textList` already applies to Extension UI options. Returns undefined for the wrong `type` too, so the
292
+ * drain loop can keep using it as the recogniser.
293
+ */
294
+ export declare function describePiQueue(record: RpcRecord): PiQueueSnapshot | undefined;
295
+ /** A line to write back to Pi's stdin. Mirrors Pi's RpcExtensionUIResponse union (rpc-types.ts:294-297). */
296
+ export type ExtensionUiResponse = {
297
+ type: "extension_ui_response";
298
+ id: string;
299
+ value?: string;
300
+ confirmed?: boolean;
301
+ cancelled?: true;
302
+ };
303
+ /**
304
+ * Normalizes one `extension_ui_request` record, or returns undefined when the record is not something
305
+ * Pi's union can produce (wrong `type`, missing `id`, or a method outside the nine). Exports so the
306
+ * mapping can be tested without spawning Pi.
307
+ */
308
+ export declare function describeExtensionUiRequest(record: RpcRecord): ExtensionUiRequest | undefined;
309
+ /**
310
+ * Maps a human's answer onto the one response shape Pi's method accepts. The method decides the shape
311
+ * rather than the answer, so a client that sends `confirmed` for a `select` (or `value` for a `confirm`)
312
+ * cannot produce a response Pi would read as a missing field.
313
+ */
314
+ export declare function buildExtensionUiResponse(request: ExtensionUiRequest, answer: ExtensionUiAnswer): ExtensionUiResponse;
315
+ /** One Pi child, its stdout reader, and the single writer to its stdin. */
316
+ export declare class RpcChannel {
317
+ private readonly child;
318
+ private readonly records;
319
+ /**
320
+ * Responses a drain loop pulled off the queue before the waiter that wanted them looked.
321
+ *
322
+ * THE RACE THIS EXISTS TO SETTLE, and it is not a rare one - it cost this file an intermittent test
323
+ * failure. One queue serves two kinds of reader: `waitFor`, which is after a specific command's answer by
324
+ * id, and `next`, which is a turn's drain loop reading whatever arrives first. The loop does not know
325
+ * which ids someone is waiting for, so it used to take the first record of any kind - including the answer
326
+ * to a `get_entries` written moments earlier - and the waiter then waited out its whole timeout for a
327
+ * response that had already been consumed and discarded.
328
+ *
329
+ * So the drain loop no longer decides an unmatched response is garbage: it HANDS IT BACK here, and the
330
+ * waiter finds it whether it got there first or second. Nothing is dropped, and nothing is waited for
331
+ * twice.
332
+ */
333
+ private readonly stashed;
334
+ private buffer;
335
+ /** stderr is kept only to explain an early exit; a well-behaved Pi writes nothing here. */
336
+ stderr: string;
337
+ constructor(child: ChildProcessWithoutNullStreams);
338
+ get exitCode(): number | null;
339
+ /** Writes one JSONL command. Throws rather than silently dropping: a command Pi never sees is a lie the caller would act on. */
340
+ write(command: object): void;
341
+ /**
342
+ * Returns one record to whoever is waiting for it, after a drain loop read it.
343
+ *
344
+ * Called for every response the loop does not consume itself. See `RpcChannel`'s note on `stashed` for why
345
+ * this is not the same as discarding it - discarding it silently cost a turn its revert boundary.
346
+ */
347
+ stash(record: RpcRecord): void;
348
+ /** Removes and returns the next record, or undefined after `timeoutMs` with nothing to read. */
349
+ next(timeoutMs?: number): Promise<RpcRecord | undefined>;
350
+ /** Sends a command and waits for its response. For one command; use write()+waitFor() to overlap several. */
351
+ request(command: {
352
+ id: string;
353
+ type: string;
354
+ } & Record<string, unknown>, timeoutMs: number): Promise<RpcRecord>;
355
+ /**
356
+ * Returns one response by id, from the queue if it is still there and from the stash if a drain loop got
357
+ * there first. Removing it from the stash is what keeps a single answer from being handed out twice.
358
+ */
359
+ private take;
360
+ /**
361
+ * Waits for the response to one command id. Every record that is not that response stays QUEUED in
362
+ * order, so a caller can have three requests in flight and still read all three answers: whoever
363
+ * asks first takes its own record and leaves the rest for the drain loop.
364
+ */
365
+ waitFor(id: string, timeoutMs: number, what?: string): Promise<RpcRecord>;
366
+ }
367
+ /**
368
+ * The commands a freshly spawned Pi must be told before it runs a conversation's turn.
369
+ *
370
+ * This is the whole mechanism behind the picker: PiRpcRuntime spawns one Pi process per turn and
371
+ * kills it afterwards, so there is no long-lived session to poke. Writing these commands onto that
372
+ * process's stdin before the prompt is what makes a selection REAL - Pi answers `set_model` with
373
+ * the model it switched to, and the prompt that follows runs on it.
374
+ *
375
+ * Model BEFORE level, and the order is load-bearing: `AgentSession.setModel` re-derives the thinking
376
+ * level for the new model (agent-session.ts:2493), so applying the level first would have Pi undo it
377
+ * on the very next line.
378
+ *
379
+ * A conversation with neither selection produces NO commands, which is what keeps every
380
+ * pre-picker conversation on Pi's own defaults.
381
+ */
382
+ export declare function sessionCommands(conversation: Pick<Conversation, "model" | "thinkingLevel">): {
383
+ id: string;
384
+ type: string;
385
+ }[];
386
+ /**
387
+ * Kills a whole process tree on Windows. Pi is launched through a .cmd shim (spawn with shell:true),
388
+ * so the spawned process is cmd.exe and the real Pi is its grandchild: killing the shim alone leaks
389
+ * Pi, which keeps holding its working directory and the RPC pipes. No-op on a pid that already died.
390
+ *
391
+ * Exported rather than reimplemented per spawner: a second copy is how one of the two ends up killing
392
+ * only the shim, which is the exact failure this function exists to prevent.
393
+ */
394
+ export declare function killTree(pid: number): Promise<void>;
395
+ /**
396
+ * Cuts a Pi session file down to the header plus everything up to and including `boundaryEntryId`.
397
+ *
398
+ * ============================ WHAT THIS DESTROYS ============================
399
+ * **Every entry written after the boundary is gone from disk, permanently, and there is no way to
400
+ * reconstruct it from this file.** That includes not only the messages a person can see but also Pi's
401
+ * `tool_call` records, `compaction` summaries, `model_change` and `thinking_level_change` markers, usage
402
+ * accounting, and anything a future Pi version adds. This is the step the whole revert design is built
403
+ * around (research §4.2) and the step that cannot be undone by the application - Super Pi has no undo,
404
+ * and adding one was explicitly left out of scope.
405
+ *
406
+ * It does NOT touch the workspace. No file the agent read or wrote is read, written or reverted here.
407
+ * =============================================================================
408
+ *
409
+ * WHY TRUNCATE RATHER THAN ASK PI. Pi's `SessionManager` is append-only by design
410
+ * (`session-manager.ts:1516-1519`: "Entries cannot be modified or deleted"), and its in-session rewind -
411
+ * `branch()`, `navigateTree()` - is IN-MEMORY ONLY (`:1573-1584`): `_buildIndex()` re-reads the whole
412
+ * file on every `open()` and sets the leaf to the last line unconditionally (`:1103-1122`). Super Pi
413
+ * spawns one Pi per turn and kills it afterwards, so a leaf pointer set in one turn is gone before the
414
+ * next one starts - a "call `branch()` once" solution would look correct for a turn and then silently
415
+ * forget (research §5 pitfall 2). Truncating the file is the only version of this that is a durable state
416
+ * change rather than per-turn bookkeeping that can be skipped exactly once.
417
+ *
418
+ * WHY THE FORMAT IS ASSUMED RATHER THAN GUESSED. The shape this relies on - line 0 is the one and only
419
+ * `type: "session"` header, every other line is one self-contained JSON entry, every entry's `parentId`
420
+ * already appears earlier in the file, no blank lines, and a trailing newline - was measured against
421
+ * real Pi by scripts/probe-revert-session-format.mjs, which also proves the `parentId` property that
422
+ * makes a prefix a VALID session rather than a fragment. That probe is the reason this function may
423
+ * index by line at all; every one of those claims is re-checked here before anything is written, so a
424
+ * Pi that changed the format fails loudly instead of producing a file Pi cannot open.
425
+ *
426
+ * WHY THE WRITE IS TEMP-THEN-RENAME. Pi appends with a bare `appendFileSync`
427
+ * (`session-manager.ts:1187`) and makes no atomicity guarantee of its own. Writing the truncated copy
428
+ * under a different name and renaming it over the original means a reader never observes a half-written
429
+ * file, and a failure before the rename leaves the original untouched.
430
+ *
431
+ * WHY A BACKUP IS KEPT. `.super-pi-revert-backup` is written next to the session file, deliberately not
432
+ * named `*.jsonl` so Pi's own discovery (`readdirSync` filtered on that suffix,
433
+ * `session-manager.ts:1881`) never lists it, and deliberately NOT surfaced anywhere as a feature:
434
+ * Super Pi has no undo and does not pretend to. It exists so that an irreversible, unrecoverable-by-code
435
+ * operation on a person's conversation history is at least recoverable by hand.
436
+ *
437
+ * EXPORTED so it can be tested against a fabricated session file in a temp directory, without Pi and
438
+ * without touching anybody's real transcript.
439
+ */
440
+ export declare function truncateSessionFile(path: string, boundaryEntryId: string, expectedSessionId: string): Promise<{
441
+ linesKept: number;
442
+ entriesDropped: number;
443
+ }>;
444
+ /**
445
+ * Writes a second Pi session file that begins as an EXACT copy of one, under a different session id.
446
+ *
447
+ * ============================ WHAT THIS DOES TO THE ORIGINAL ============================
448
+ * **Nothing.** The source file is read and never written, never truncated, never renamed. This is the
449
+ * whole difference from `truncateSessionFile`, and it is the reason a fork is the safe half of this pair
450
+ * of operations: a revert destroys entries permanently and keeps a hand-recovery backup, while a fork
451
+ * leaves both files whole and keeps no backup because there is nothing to recover.
452
+ * ========================================================================================
453
+ *
454
+ * WHY A COPY AND NOT PI'S `fork()`. Covered at length on `ClientCommand`'s `conversation.fork`: Pi's fork
455
+ * always tears down the runtime and switches to the new session (`agent-session-runtime.ts:299-307`), and
456
+ * Super Pi has no runtime to switch - it spawns one Pi per turn and kills it. The file is the only durable
457
+ * thing a fork has to produce here, so the file is what gets produced.
458
+ *
459
+ * ============================ WHAT THIS DOES TO PI's ENTRY IDS ============================
460
+ * **Nothing either, and this is a deliberate divergence from Pi's own `createBranchedSession`, which
461
+ * RENUMBERS `parentId` (`:1655-1666`).** Keeping every id verbatim means every cross-reference already
462
+ * inside the file stays valid, and research pitfall 6 is exactly why that matters: a Pi entry may point
463
+ * at other entries by id in ways this code has never enumerated - a `compaction` record carries a
464
+ * `firstKeptEntryId`, `branch_summary` carries the branch point - and renumbering ids means rewriting every
465
+ * one of those references correctly, with no way to find them all. Copying the bytes cannot break a
466
+ * reference because it changes nothing a reference points at. The cost is that the same entry id now
467
+ * exists in two files; that is safe because `_buildIndex` builds its `byId` map per file (`:1103-1122`),
468
+ * and Super Pi only ever opens one file per process.
469
+ *
470
+ * WHAT THE HEADER CHANGES, and it is two fields. `id` becomes the new conversation's id, because that is
471
+ * what Pi's lookup matches (`findLocalSessionByExactId` reads the header, probe B) and therefore what makes
472
+ * `--session-id <forked>` open THIS file rather than silently inventing an empty session with that id
473
+ * (`main.ts:436-448`, measured: `Warning: No project session found with id '...'`). And `parentSession`
474
+ * records the file this one came from, which is the same one-line lineage pointer Pi itself writes
475
+ * (`:1675-1682`) and is not a link - the files share nothing. `cwd`, `version` and `timestamp` are copied
476
+ * verbatim: `cwd` MUST be identical, because Pi's lookup requires the recorded cwd to equal the one it is
477
+ * asked from, and the fork always lives in the same workspace as its source.
478
+ *
479
+ * THE SAME FORMAT CHECKS AS TRUNCATE, AND FOR THE SAME REASON. Probe A measured this file's shape against
480
+ * real Pi (`scripts/probe-revert-session-format.mjs`); a Pi upgrade can invalidate any of those claims, so
481
+ * every one is re-checked before a byte is written and a failure names the shape actually found. Here the
482
+ * `parentId` property is checked across the WHOLE file rather than across a kept prefix, because the whole
483
+ * file is what is being copied.
484
+ *
485
+ * TEMP-THEN-RENAME, because Pi appends with a bare `appendFileSync` (`:1187`) and the target must never be
486
+ * observed half-written - a half-written file under the forked id is precisely the "Pi opens an empty
487
+ * session and the history has vanished with no error anywhere" outcome probe B was about.
488
+ *
489
+ * EXPORTED so it can be tested against fabricated session files in a temp directory, without Pi.
490
+ */
491
+ export declare function forkSessionFile(sourcePath: string, targetPath: string, expectedSessionId: string, targetSessionId: string): Promise<{
492
+ entriesCopied: number;
493
+ }>;
494
+ /**
495
+ * Reads Pi's `steer` disposition out of its response payload.
496
+ *
497
+ * Unknown and missing both become `queued`, which is the CONSERVATIVE default and not a convenience: a
498
+ * steer Pi could not categorise may or may not already be in the model's context, so it must keep being
499
+ * treated as "not yet delivered" until a `queue_update` says otherwise. Guessing `handled` would let the
500
+ * caller drop the message from the dock on the strength of a payload shape we did not recognise.
501
+ */
502
+ export declare function describeSteerDisposition(data: unknown): SteerDisposition | undefined;
503
+ /** Keys Pi's model answers by. Exported so the store and the tests agree with the runtime. */
504
+ export declare const catalogKey: (selection?: ModelSelection) => string;
505
+ /**
506
+ * Where the `task` extension lives, as an ABSOLUTE path.
507
+ *
508
+ * Absolute because Pi requires it: `--extension` takes the path verbatim and resolves it against Pi's
509
+ * own working directory, which is the CONVERSATION's workspace, not this repository. A relative path
510
+ * would therefore resolve differently for every conversation and fail for all of them except the one
511
+ * whose workspace happens to be this repo.
512
+ *
513
+ * `import.meta.dirname` is the one thing stable across both ways this code runs - `dist/server/` after
514
+ * `tsc -b`, `src/server/` under `tsx watch` - so two `..` segments reach the repository root either way.
515
+ *
516
+ * Returned as a FUNCTION rather than a module constant so a test can assert on the resolution without
517
+ * this module's import-time filesystem state deciding it, and so the "does it exist" check below is a
518
+ * warning on a path that is genuinely optional rather than a startup crash on a build that skipped it.
519
+ */
520
+ export declare function taskExtensionPath(from?: string): string;
521
+ /** Bridges a Pi session to Super Pi through Pi's documented JSONL RPC protocol. */
522
+ export declare class PiRpcRuntime implements AgentRuntime {
523
+ private readonly sessionDir;
524
+ /**
525
+ * Where agent definitions are read from and where their prompt bodies are written. A THIRD positional
526
+ * parameter rather than part of the second one, so every existing `new PiRpcRuntime(dir, launcher)`
527
+ * call site keeps its meaning - moving the agent directory into that slot would have silently fed a
528
+ * launcher's file path in as a directory of agent definitions.
529
+ */
530
+ private readonly agents;
531
+ /**
532
+ * How the `task` extension reaches this server. A FOURTH positional parameter for the same reason as
533
+ * the third: an options bag in one of the earlier slots would silently change what an existing
534
+ * `new PiRpcRuntime(dir, launcher, { dir, promptDir })` call site means.
535
+ *
536
+ * `endpoint` is this server's own listening URL, supplied rather than derived. It is the fix for
537
+ * hardcoding a port: `SUPER_PI_PORT` is honoured by `index.ts` and by every test that boots on
538
+ * `freePort()`, so an extension that dialled a fixed number would reach nothing at all. When it is
539
+ * absent the extension is simply not mounted, and the reason is logged once per turn rather than
540
+ * swallowed - a task tool that is mysteriously missing is the single hardest failure here to
541
+ * diagnose from the outside.
542
+ *
543
+ * `todoEndpoint` is the `todowrite` endpoint, supplied the same way and for the same reason, and it
544
+ * is a SEPARATE field rather than a path derived from `endpoint`: the two tools are allowed to be
545
+ * available independently (a harness that only exercises dispatch does not want a todo writer, and an
546
+ * older checkout has no todo endpoint at all), and a derived path could not tell "not configured"
547
+ * from "configured wrong". When it is absent the tool still registers and the extension refuses in
548
+ * words, which is what `refuseTodo` in core.ts is for.
549
+ */
550
+ private readonly options;
551
+ private readonly runs;
552
+ /**
553
+ * `steer` commands written to a child and not yet answered by Pi, keyed by the command id.
554
+ *
555
+ * A steer has to be correlated to a promise rather than fire-and-forget, because Pi's `steer` response
556
+ * carries a `disposition` that decides whether the message is already in the model's context or still
557
+ * waiting (`handled` vs `queued`, `rpc-mode.ts:416-419`). Without that answer Super Pi would have to
558
+ * guess, and both guesses are wrong: reporting a delivered steer as waiting would leave a message
559
+ * stranded in the dock forever, and reporting a waiting one as delivered would let the user cancel a
560
+ * message the model is about to act on.
561
+ */
562
+ private readonly pendingSteers;
563
+ /** Distinguishes steer command ids. Never reused, so a late response cannot settle a later steer. */
564
+ private steerSeq;
565
+ private readonly catalogs;
566
+ /** In-flight reads, so N clients asking at once cost ONE Pi spawn rather than N. */
567
+ private readonly pendingCatalogs;
568
+ /** Latches the "no extension on disk" warning, so it is said once rather than once per turn. */
569
+ private warnedMissingExtension;
570
+ /**
571
+ * Agent definitions are read from disk, never cached. A file is cheap to parse, and re-reading it
572
+ * per turn is the whole point: editing an agent's prompt or tool allowlist changes the NEXT message
573
+ * with no re-selection and no restart, which a cache would silently prevent.
574
+ *
575
+ * `command` is an override for tests and for a Pi installed somewhere unusual; with none given, Pi is
576
+ * resolved through `resolvePiCommand()` (override -> PATH -> npm's global bin directory), the same
577
+ * function scripts/probe-agent-real-pi.mjs uses. Resolved ONCE here because it is a filesystem
578
+ * lookup, and a resolution that could change mid-run would make a turn's spawn line depend on when
579
+ * it was started.
580
+ */
581
+ private readonly pi;
582
+ constructor(sessionDir: string, command?: string,
583
+ /**
584
+ * Where agent definitions are read from and where their prompt bodies are written. A THIRD positional
585
+ * parameter rather than part of the second one, so every existing `new PiRpcRuntime(dir, launcher)`
586
+ * call site keeps its meaning - moving the agent directory into that slot would have silently fed a
587
+ * launcher's file path in as a directory of agent definitions.
588
+ */
589
+ agents?: {
590
+ dir: string;
591
+ promptDir: string;
592
+ },
593
+ /**
594
+ * How the `task` extension reaches this server. A FOURTH positional parameter for the same reason as
595
+ * the third: an options bag in one of the earlier slots would silently change what an existing
596
+ * `new PiRpcRuntime(dir, launcher, { dir, promptDir })` call site means.
597
+ *
598
+ * `endpoint` is this server's own listening URL, supplied rather than derived. It is the fix for
599
+ * hardcoding a port: `SUPER_PI_PORT` is honoured by `index.ts` and by every test that boots on
600
+ * `freePort()`, so an extension that dialled a fixed number would reach nothing at all. When it is
601
+ * absent the extension is simply not mounted, and the reason is logged once per turn rather than
602
+ * swallowed - a task tool that is mysteriously missing is the single hardest failure here to
603
+ * diagnose from the outside.
604
+ *
605
+ * `todoEndpoint` is the `todowrite` endpoint, supplied the same way and for the same reason, and it
606
+ * is a SEPARATE field rather than a path derived from `endpoint`: the two tools are allowed to be
607
+ * available independently (a harness that only exercises dispatch does not want a todo writer, and an
608
+ * older checkout has no todo endpoint at all), and a derived path could not tell "not configured"
609
+ * from "configured wrong". When it is absent the tool still registers and the extension refuses in
610
+ * words, which is what `refuseTodo` in core.ts is for.
611
+ */
612
+ options?: {
613
+ endpoint?: string;
614
+ todoEndpoint?: string;
615
+ uploadsDir?: string;
616
+ });
617
+ /**
618
+ * The agent a conversation runs as, or undefined for Pi's own defaults.
619
+ *
620
+ * A missing file is NOT an error: a conversation that names an agent that has since been deleted
621
+ * degrades to running as Pi itself, with a warning, rather than failing every turn forever. A
622
+ * `subagent` definition is also ignored here - those are invoked BY a primary agent, never chosen
623
+ * as the conversation's own role.
624
+ */
625
+ agentFor(conversation: Pick<Conversation, "id" | "agentId">): Promise<AgentDefinition | undefined>;
626
+ /**
627
+ * The `--extension` argv for this turn, or `[]` when the task tool is not available.
628
+ *
629
+ * Empty in TWO different situations, and both are deliberate rather than merged:
630
+ *
631
+ * - No endpoint was configured, so there is nowhere for the extension to dispatch to. Mounting it
632
+ * anyway would give the model a tool whose every call fails with a network error.
633
+ * - The extension file is not on disk. That is a build or checkout problem, and it is warned about
634
+ * ONCE rather than every turn, because "your primary agent cannot delegate anything" is worth
635
+ * knowing and worth not repeating on every keystroke.
636
+ */
637
+ private taskExtensionArgs;
638
+ /**
639
+ * The five values the extension reads out of its own environment. See `task-tool.ts` for why they are
640
+ * written out on both sides rather than imported.
641
+ *
642
+ * The ROSTER is read here, per turn, from the same `readAgents` the picker and the supervisor use -
643
+ * never from a list written into this file. A sub-agent added while the server is running is therefore
644
+ * dispatchable on the next message with no restart, which is the same freshness rule `agentFor`
645
+ * follows for agent files and for the same reason: a human edits those files while the server runs.
646
+ *
647
+ * `depth` is `0`, hardcoded, and that is not an oversight: this is the PRIMARY agent's spawn, so it is
648
+ * definitionally at the top of the tree. `SubagentSupervisor` never passes this function, which is why
649
+ * no sub-agent process is ever given this environment at all.
650
+ *
651
+ * The TODO ENDPOINT is the one value here that is NOT per-turn derived from disk: it is this server's
652
+ * own address, so it is the same string on every turn and is read once from the constructor's options.
653
+ */
654
+ private taskExtensionEnv;
655
+ answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolEnd, images }: AnswerInput): AsyncIterable<string>;
656
+ /**
657
+ * Reads back the id of the user message this turn just created, and hands it to the caller.
658
+ *
659
+ * `head` is the already-answered pre-turn `get_entries`, whose `leafId` is what makes the second read
660
+ * exact. Its three possible outcomes are handled differently ON PURPOSE, because they mean different
661
+ * things and two of them must not produce an id:
662
+ *
663
+ * - `leafId: null` - the session was empty, so this IS the first turn and the whole entry list is this
664
+ * turn's. Safe to take the first user message from it.
665
+ * - `leafId: "..."` - read only what came after it. Safe, and the only correct answer for turn two onward.
666
+ * - no usable answer - Pi never told us where the turn began, so NOTHING in the transcript can be
667
+ * attributed to it. Reading everything and taking "the first user message" here would hand back a
668
+ * PREVIOUS turn's id, and a revert to that id would silently cut more than the user asked for. So this
669
+ * case records nothing and says why.
670
+ *
671
+ * Every failure is a warning, never a throw: the turn's answer has already streamed by this point, and
672
+ * "this message cannot be a revert boundary" is not "your message failed".
673
+ */
674
+ private recordTurnEntryId;
675
+ /**
676
+ * Reverts Pi's own transcript to the state right after `boundaryEntryId`.
677
+ *
678
+ * ============================ WHAT THIS DESTROYS ============================
679
+ * Every Pi entry after that boundary, permanently - including tool calls, compaction records, model and
680
+ * thinking-level markers, and usage accounting, not just the messages Super Pi shows. A copy of the file
681
+ * as it was is left beside it as `<file>.super-pi-revert-backup`, which Pi never lists because it does
682
+ * not end in `.jsonl`. There is no undo in the application.
683
+ *
684
+ * No workspace file is read, written or restored. That is the whole point of not reaching for OpenCode's
685
+ * `snapshot/` machinery: its revert rolls the working tree back (`snapshot/index.ts:382-406`), this one
686
+ * must not, and Super Pi has no reason to build a second git shadow repository to do damage.
687
+ * =============================================================================
688
+ *
689
+ * WHY PI IS ASKED WHICH FILE TO OPEN, rather than this code scanning the session directory.
690
+ *
691
+ * The obvious implementation - read the directory, find the `*.jsonl` whose header id matches - is
692
+ * exactly the one that fails silently, and probe B measured why:
693
+ *
694
+ * 1. When Pi cannot find the requested id it does NOT fail. It prints a warning to stderr and opens a
695
+ * brand-new session under that same id (`main.ts:436-448`), so a wrong lookup becomes "the history
696
+ * has vanished" with no error anywhere in the UI. Measured:
697
+ * `Warning: No project session found with id '...'; creating a new session with that id.`
698
+ * 2. Pi's lookup is not "match the id". With an explicit `--session-dir` it ALSO requires the session's
699
+ * recorded `cwd` to equal the one it was asked from (`session-manager.ts:1874-1891`), and probe B
700
+ * measured the same id resolving to two different files from two different working directories -
701
+ * one of which did not exist on disk.
702
+ *
703
+ * So the file is taken from Pi's own `get_state.sessionFile` (`rpc-mode.ts:448-464`), which is the
704
+ * lookup Pi performed for a real spawn, and then THREE things are checked before a byte is written:
705
+ * the file exists, its header is the session header, and that header's id is the conversation being
706
+ * reverted. A miss on any of them refuses, in words, rather than truncating something it is not sure of.
707
+ *
708
+ * The cost is one extra Pi spawn, which is why this is a deliberate trade rather than an oversight:
709
+ * revert is a rare, deliberate act, and a second Pi answers in well under a second. It buys the
710
+ * elimination of an entire class of "we truncated the wrong file and nothing said so" failures.
711
+ */
712
+ revert({ conversation, cwd, boundaryEntryId }: RevertRequest): Promise<RevertOutcome>;
713
+ /**
714
+ * Copies Pi's own transcript for one conversation into a second, independent file under a new id.
715
+ *
716
+ * THE SAME THREE STEPS AS REVERT, IN THE SAME ORDER, AND FOR THE SAME REASONS: refuse a live turn, ask Pi
717
+ * which file it uses rather than scanning the directory (probe B measured that a directory scan resolves
718
+ * the wrong file or a nonexistent one with no error, and that Pi invents an empty session under an id it
719
+ * cannot find), kill that Pi BEFORE touching anything on disk, and check the file's header against the
720
+ * conversation being forked before a byte is written. Every one of those paragraphs above still applies
721
+ * here word for word, because it is the same machinery pointed the other way: revert cuts a file in
722
+ * place, fork copies a file beside it.
723
+ *
724
+ * ============================ WHAT A FORK COPIES ============================
725
+ * The file, byte for byte, with two header fields changed (`id`, `parentSession` - see `forkSessionFile`).
726
+ * Entry ids are NOT renumbered, so every message recorded on the source keeps a `piEntryId` that is valid
727
+ * in the fork too, which is what keeps REVERT working inside a forked conversation.
728
+ * =============================================================================
729
+ *
730
+ * WHAT A FORK DOES NOT COPY, and both omissions are deliberate:
731
+ *
732
+ * - **Sub-agent runs.** `SubagentRun.conversationId` points at the conversation that dispatched it, and
733
+ * research pitfall 7 is that truncating or duplicating a transcript leaves those records orphaned. A
734
+ * forked conversation shows the messages but no child cards from before the fork: the children belong to
735
+ * the conversation that started them, they are not listed anywhere once their parent has copied their
736
+ * words, and a card in the fork that could not be stopped from the fork would be worse than no card.
737
+ * - **Queued messages.** Same argument, and narrower: a waiting message belongs to the run in progress,
738
+ * and a run is not copied.
739
+ *
740
+ * THE EMPTY-CONVERSATION CASE IS NOT AN ERROR. A conversation with no messages has no Pi file at all -
741
+ * `SessionManager._persist` only creates one once a session holds a message (`:1160-1170`) - so there is
742
+ * nothing to copy and the fork is a copy of `state.json` alone. That is checked BEFORE any Pi is spawned,
743
+ * so forking a fresh conversation costs nothing and cannot fail. The converse is refused rather than
744
+ * tolerated: messages in `state.json` with no Pi file means the two histories were ALREADY out of step,
745
+ * and copying that into a second conversation would make the disagreement permanent and multiply it.
746
+ */
747
+ fork({ conversation, cwd, forkedConversationId }: ForkRequest): Promise<ForkOutcome>;
748
+ /**
749
+ * Delivers one Extension UI request. One-way methods are pushed to the host and never replied to
750
+ * (Pi holds no pending entry, so a response would resolve nothing). Blocking methods are handed to
751
+ * the host and this call is awaited, so Pi stays blocked exactly as long as the human takes.
752
+ */
753
+ private handleExtensionUi;
754
+ stop(conversationId: string): Promise<void>;
755
+ /**
756
+ * Injects one message into the Pi process that is running right now, at its next turn boundary.
757
+ *
758
+ * `steer` and NOT `follow_up`, and NOT `prompt` with a `streamingBehavior`. The three are not
759
+ * interchangeable and the difference is the whole feature:
760
+ *
761
+ * - `steer` is drained at `agent-loop.ts:295`, after `turn_end` and therefore after the current
762
+ * assistant message's WHOLE tool-call batch has finished, before the next LLM request. The agent
763
+ * stops doing what it was doing at the next boundary instead of at the end of the task. That is
764
+ * "插队", and its granularity is a tool BATCH - a long single tool call is not interruptible,
765
+ * because Pi offers no preemption.
766
+ * - `follow_up` is drained at `agent-loop.ts:302`, only once the agent has stopped on its own.
767
+ * - `prompt` would start a turn, which is the thing that must NOT happen here: this conversation
768
+ * already has a turn in flight, and starting a second one is the two-writers-one-session bug.
769
+ *
770
+ * NO `set_follow_up_mode` / `set_steering_mode` IS EVER SENT, and that is a hard rule rather than a
771
+ * default. Both go through `AgentSession.set*Mode`, which mutates `SettingsManager`'s global scope and
772
+ * calls `save()` (`settings-manager.ts:857-871`), writing to the user's Pi `settings.json`. A host that
773
+ * merely wanted a different queue depth would permanently rewrite the user's Pi configuration. Pi's
774
+ * default is already `one-at-a-time` (`agent.ts:247-248`), which is the semantics this wants: one
775
+ * queued message consumed per boundary.
776
+ *
777
+ * REJECTS rather than reporting success when there is no live child, and when Pi does not answer in
778
+ * time. The caller keeps the message cancellable in the first case because it provably reached no
779
+ * model; in the second the outcome is genuinely unknown and a `sending` state says so.
780
+ */
781
+ steer(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
782
+ /**
783
+ * The model/thinking catalog, from Pi, cached.
784
+ *
785
+ * Three layers, because a picker opens far more often than Pi can be asked: a TTL cache keyed by
786
+ * the selection (10 minutes for a good answer, 30 seconds for a failure), an in-flight map so
787
+ * concurrent callers share one spawn, and - because each read spawns a short-lived Pi that is
788
+ * killed as soon as it has answered - no long-lived process to keep warm.
789
+ */
790
+ catalog(selection?: ModelSelection): Promise<ModelCatalog>;
791
+ /**
792
+ * Asks one throwaway Pi for its model list, its thinking levels and its own current selection.
793
+ *
794
+ * The three commands are written back to back and only then awaited, so Pi works on them in
795
+ * parallel - measured at 394ms for all three, against 714ms for the slowest one alone. A Pi
796
+ * scoped to one conversation's model needs `set_model` FIRST and alone, because thinking levels are
797
+ * a property of the selected model, not of Pi's default.
798
+ */
799
+ private readCatalog;
800
+ }
801
+ /**
802
+ * Normalizes Pi's `Model[]`, keeping only entries that can actually be sent back as a `set_model`.
803
+ * Exported so the mapping is testable without a Pi process.
804
+ *
805
+ * `imageInput` reads Pi's `input` array, which is where image support is actually declared -
806
+ * `BaseModel.input: ("text" | "image")[]` at packages/ai/src/types.ts:1124, and Pi's own CLI prints
807
+ * the very same test as its "images" column (cli/list-models.ts:71, `m.input.includes("image")`).
808
+ * Copying Pi's predicate rather than inventing one is the point: if Pi ever renames the value or
809
+ * adds a third modality, Pi's column and this badge change together.
810
+ *
811
+ * A MISSING OR MALFORMED `input` READS AS FALSE, and that direction is deliberate. The field's
812
+ * failure mode is a silent one in both directions - Pi's RPC reader does no schema validation
813
+ * (rpc-mode.ts:750-783) - so a model with no `input` at all could be a Pi too old to have the
814
+ * concept, and answering "supports images" for it would invite a picture the model cannot see.
815
+ * Announcing a capability that turns out to be absent costs the user a confusing turn; declining to
816
+ * announce one that is present costs them an unlabelled model. Only the first is worth guessing on.
817
+ */
818
+ export declare function describeModel(value: unknown): ModelOption | undefined;
819
+ export declare function describeModels(value: unknown): ModelOption[];