@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,1470 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { resolve, join } from "node:path";
5
+ import { agentSpawnArgs, readAgents } from "./agents.js";
6
+ import { resolvePiCommand, shellQuote } from "./pi-command.js";
7
+ import { TASK_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_ROSTER_ENV, TASK_TOOL_NAME, TODO_ENDPOINT_ENV, TODO_TOOL_NAME, rosterPayload, } from "./task-tool.js";
8
+ import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
9
+ import { loadConversationImages } from "./upload.js";
10
+ import { describeToolCall, describeToolCallArgs } from "./tool-calls.js";
11
+ /** Deterministic offline runtime used by tests and local UI development. */
12
+ export class WelcomeRuntime {
13
+ async *answer({ prompt, signal }) {
14
+ const response = `\u5df2\u6536\u5230\uff1a${prompt}\n\nSuper Pi \u7684 Web \u57fa\u5ea7\u5df2\u7ecf\u8fd0\u884c\u3002\u5f53\u524d\u4f1a\u8bdd\u3001\u6d41\u5f0f\u8f93\u51fa\u3001\u505c\u6b62\u4efb\u52a1\u4e0e\u672c\u5730\u6301\u4e45\u5316\u5747\u53ef\u7528\u3002\n\nPi \u8fd0\u884c\u65f6\u9002\u914d\u5668\u5df2\u51c6\u5907\u5c31\u7eea\uff1a\u914d\u7f6e\u597d Pi \u7684\u6a21\u578b\u8ba4\u8bc1\u540e\uff0cWeb \u754c\u9762\u4f1a\u901a\u8fc7 JSONL RPC \u9a71\u52a8\u771f\u5b9e agent\u3002`;
15
+ for (const chunk of response.match(/.{1,16}/gsu) ?? []) {
16
+ if (signal.aborted)
17
+ return;
18
+ await new Promise((resolve) => setTimeout(resolve, 20));
19
+ yield chunk;
20
+ }
21
+ }
22
+ async stop() { }
23
+ /**
24
+ * No Pi, so no models. `source` is what turns the picker into a visibly disabled control instead
25
+ * of an empty list, which would read as "you have no models".
26
+ */
27
+ async catalog() {
28
+ return { source: "unavailable", models: [], levels: [], fetchedAt: new Date().toISOString(), error: "Welcome runtime has no Pi session to ask" };
29
+ }
30
+ }
31
+ /**
32
+ * Reads Pi's `queue_update` payload, or returns undefined when the record is not one.
33
+ *
34
+ * Filters to real strings rather than passing the arrays through, so a malformed snapshot cannot put
35
+ * objects into the browser's DOM or into a log line that is supposed to be readable - the same rule
36
+ * `textList` already applies to Extension UI options. Returns undefined for the wrong `type` too, so the
37
+ * drain loop can keep using it as the recogniser.
38
+ */
39
+ export function describePiQueue(record) {
40
+ if (record.type !== "queue_update")
41
+ return undefined;
42
+ return { steering: textList(record.steering) ?? [], followUp: textList(record.followUp) ?? [] };
43
+ }
44
+ const NOTIFY_TYPES = new Set(["info", "warning", "error"]);
45
+ const PLACEMENTS = new Set(["aboveEditor", "belowEditor"]);
46
+ const text = (value) => (typeof value === "string" ? value : undefined);
47
+ /** Keeps only real strings, so a malformed `options` cannot put objects into the browser's DOM. */
48
+ const textList = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : undefined;
49
+ /**
50
+ * Normalizes one `extension_ui_request` record, or returns undefined when the record is not something
51
+ * Pi's union can produce (wrong `type`, missing `id`, or a method outside the nine). Exports so the
52
+ * mapping can be tested without spawning Pi.
53
+ */
54
+ export function describeExtensionUiRequest(record) {
55
+ if (record.type !== "extension_ui_request" || typeof record.id !== "string" || record.id === "")
56
+ return undefined;
57
+ if (typeof record.method !== "string" || !EXTENSION_UI_METHODS.includes(record.method))
58
+ return undefined;
59
+ const method = record.method;
60
+ const request = { requestId: record.id, method, blocking: BLOCKING_UI_METHODS.has(method), title: text(record.title) ?? "" };
61
+ switch (method) {
62
+ case "select":
63
+ request.options = textList(record.options) ?? [];
64
+ break;
65
+ case "confirm":
66
+ request.message = text(record.message) ?? "";
67
+ break;
68
+ case "input":
69
+ request.placeholder = text(record.placeholder);
70
+ break;
71
+ case "editor":
72
+ request.prefill = text(record.prefill);
73
+ break;
74
+ // notify reuses `message`; there is no separate notifyTitle in Pi's union.
75
+ case "notify":
76
+ request.message = text(record.message) ?? "";
77
+ request.notifyType = (NOTIFY_TYPES.has(record.notifyType ?? "") ? record.notifyType : "info");
78
+ break;
79
+ case "setStatus":
80
+ request.statusKey = text(record.statusKey) ?? "";
81
+ request.statusText = text(record.statusText);
82
+ break;
83
+ case "setWidget":
84
+ request.widgetKey = text(record.widgetKey) ?? "";
85
+ request.widgetLines = textList(record.widgetLines);
86
+ request.widgetPlacement = (PLACEMENTS.has(record.widgetPlacement ?? "") ? record.widgetPlacement : undefined);
87
+ break;
88
+ case "setTitle": break;
89
+ case "set_editor_text":
90
+ request.text = text(record.text) ?? "";
91
+ break;
92
+ }
93
+ // Only Pi's blocking methods carry a deadline, and `editor` never does - which is exactly why an
94
+ // unanswered editor is allowed to wait indefinitely rather than being expired by this host.
95
+ if (request.blocking && typeof record.timeout === "number" && Number.isFinite(record.timeout) && record.timeout > 0)
96
+ request.timeoutMs = record.timeout;
97
+ return request;
98
+ }
99
+ /**
100
+ * Maps a human's answer onto the one response shape Pi's method accepts. The method decides the shape
101
+ * rather than the answer, so a client that sends `confirmed` for a `select` (or `value` for a `confirm`)
102
+ * cannot produce a response Pi would read as a missing field.
103
+ */
104
+ export function buildExtensionUiResponse(request, answer) {
105
+ if (answer.cancelled)
106
+ return { type: "extension_ui_response", id: request.requestId, cancelled: true };
107
+ if (request.method === "confirm")
108
+ return { type: "extension_ui_response", id: request.requestId, confirmed: answer.confirmed === true };
109
+ return { type: "extension_ui_response", id: request.requestId, value: answer.value ?? "" };
110
+ }
111
+ /** One Pi child, its stdout reader, and the single writer to its stdin. */
112
+ export class RpcChannel {
113
+ child;
114
+ records = [];
115
+ /**
116
+ * Responses a drain loop pulled off the queue before the waiter that wanted them looked.
117
+ *
118
+ * THE RACE THIS EXISTS TO SETTLE, and it is not a rare one - it cost this file an intermittent test
119
+ * failure. One queue serves two kinds of reader: `waitFor`, which is after a specific command's answer by
120
+ * id, and `next`, which is a turn's drain loop reading whatever arrives first. The loop does not know
121
+ * which ids someone is waiting for, so it used to take the first record of any kind - including the answer
122
+ * to a `get_entries` written moments earlier - and the waiter then waited out its whole timeout for a
123
+ * response that had already been consumed and discarded.
124
+ *
125
+ * So the drain loop no longer decides an unmatched response is garbage: it HANDS IT BACK here, and the
126
+ * waiter finds it whether it got there first or second. Nothing is dropped, and nothing is waited for
127
+ * twice.
128
+ */
129
+ stashed = new Map();
130
+ buffer = "";
131
+ /** stderr is kept only to explain an early exit; a well-behaved Pi writes nothing here. */
132
+ stderr = "";
133
+ constructor(child) {
134
+ this.child = child;
135
+ child.stderr.on("data", (chunk) => { this.stderr += chunk.toString(); });
136
+ child.stdout.on("data", (chunk) => {
137
+ this.buffer += chunk.toString("utf8");
138
+ let newline;
139
+ while ((newline = this.buffer.indexOf("\n")) >= 0) {
140
+ const line = this.buffer.slice(0, newline).replace(/\r$/, "");
141
+ this.buffer = this.buffer.slice(newline + 1);
142
+ if (!line)
143
+ continue;
144
+ try {
145
+ this.records.push(JSON.parse(line));
146
+ }
147
+ catch { /* Pi stdout must be JSONL */ }
148
+ }
149
+ });
150
+ }
151
+ get exitCode() { return this.child.exitCode; }
152
+ /** Writes one JSONL command. Throws rather than silently dropping: a command Pi never sees is a lie the caller would act on. */
153
+ write(command) {
154
+ if (this.child.killed || this.child.stdin.destroyed)
155
+ throw new Error(`Pi RPC stdin is closed, so ${JSON.stringify(command)} was not sent`);
156
+ this.child.stdin.write(`${JSON.stringify(command)}\n`);
157
+ }
158
+ /**
159
+ * Returns one record to whoever is waiting for it, after a drain loop read it.
160
+ *
161
+ * Called for every response the loop does not consume itself. See `RpcChannel`'s note on `stashed` for why
162
+ * this is not the same as discarding it - discarding it silently cost a turn its revert boundary.
163
+ */
164
+ stash(record) { if (record.type === "response" && typeof record.id === "string")
165
+ this.stashed.set(record.id, record); }
166
+ /** Removes and returns the next record, or undefined after `timeoutMs` with nothing to read. */
167
+ async next(timeoutMs = 100) {
168
+ const deadline = Date.now() + timeoutMs;
169
+ for (;;) {
170
+ if (this.records.length)
171
+ return this.records.shift();
172
+ if (Date.now() >= deadline)
173
+ return undefined;
174
+ await new Promise((resolve) => setTimeout(resolve, 10));
175
+ }
176
+ }
177
+ /** Sends a command and waits for its response. For one command; use write()+waitFor() to overlap several. */
178
+ async request(command, timeoutMs) {
179
+ this.write(command);
180
+ return this.waitFor(command.id, timeoutMs, command.type);
181
+ }
182
+ /**
183
+ * Returns one response by id, from the queue if it is still there and from the stash if a drain loop got
184
+ * there first. Removing it from the stash is what keeps a single answer from being handed out twice.
185
+ */
186
+ take(id) {
187
+ const index = this.records.findIndex((record) => record.type === "response" && record.id === id);
188
+ if (index >= 0)
189
+ return this.records.splice(index, 1)[0];
190
+ const stashed = this.stashed.get(id);
191
+ if (!stashed)
192
+ return undefined;
193
+ this.stashed.delete(id);
194
+ return stashed;
195
+ }
196
+ /**
197
+ * Waits for the response to one command id. Every record that is not that response stays QUEUED in
198
+ * order, so a caller can have three requests in flight and still read all three answers: whoever
199
+ * asks first takes its own record and leaves the rest for the drain loop.
200
+ */
201
+ async waitFor(id, timeoutMs, what = id) {
202
+ const deadline = Date.now() + timeoutMs;
203
+ for (;;) {
204
+ const record = this.take(id);
205
+ if (record)
206
+ return record;
207
+ // A dead child will never answer, so waiting out the full deadline would only delay the failure.
208
+ if (this.child.exitCode !== null)
209
+ throw new Error(`Pi RPC exited (code ${this.child.exitCode}) while waiting for ${what}. ${this.stderr.trim()}`);
210
+ if (Date.now() >= deadline)
211
+ throw new Error(`Pi did not answer ${what} within ${timeoutMs}ms`);
212
+ await new Promise((resolve) => setTimeout(resolve, 10));
213
+ }
214
+ }
215
+ }
216
+ /**
217
+ * The commands a freshly spawned Pi must be told before it runs a conversation's turn.
218
+ *
219
+ * This is the whole mechanism behind the picker: PiRpcRuntime spawns one Pi process per turn and
220
+ * kills it afterwards, so there is no long-lived session to poke. Writing these commands onto that
221
+ * process's stdin before the prompt is what makes a selection REAL - Pi answers `set_model` with
222
+ * the model it switched to, and the prompt that follows runs on it.
223
+ *
224
+ * Model BEFORE level, and the order is load-bearing: `AgentSession.setModel` re-derives the thinking
225
+ * level for the new model (agent-session.ts:2493), so applying the level first would have Pi undo it
226
+ * on the very next line.
227
+ *
228
+ * A conversation with neither selection produces NO commands, which is what keeps every
229
+ * pre-picker conversation on Pi's own defaults.
230
+ */
231
+ export function sessionCommands(conversation) {
232
+ const commands = [];
233
+ const model = conversation.model;
234
+ if (model?.provider && model.modelId)
235
+ commands.push({ id: "set_model", type: "set_model", ...{ provider: model.provider, modelId: model.modelId } });
236
+ if (conversation.thinkingLevel)
237
+ commands.push({ id: "set_thinking_level", type: "set_thinking_level", ...{ level: conversation.thinkingLevel } });
238
+ return commands;
239
+ }
240
+ /**
241
+ * Kills a whole process tree on Windows. Pi is launched through a .cmd shim (spawn with shell:true),
242
+ * so the spawned process is cmd.exe and the real Pi is its grandchild: killing the shim alone leaks
243
+ * Pi, which keeps holding its working directory and the RPC pipes. No-op on a pid that already died.
244
+ *
245
+ * Exported rather than reimplemented per spawner: a second copy is how one of the two ends up killing
246
+ * only the shim, which is the exact failure this function exists to prevent.
247
+ */
248
+ export function killTree(pid) {
249
+ return new Promise((resolve) => {
250
+ const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
251
+ killer.once("exit", () => resolve());
252
+ killer.once("error", () => resolve());
253
+ });
254
+ }
255
+ /**
256
+ * Cuts a Pi session file down to the header plus everything up to and including `boundaryEntryId`.
257
+ *
258
+ * ============================ WHAT THIS DESTROYS ============================
259
+ * **Every entry written after the boundary is gone from disk, permanently, and there is no way to
260
+ * reconstruct it from this file.** That includes not only the messages a person can see but also Pi's
261
+ * `tool_call` records, `compaction` summaries, `model_change` and `thinking_level_change` markers, usage
262
+ * accounting, and anything a future Pi version adds. This is the step the whole revert design is built
263
+ * around (research §4.2) and the step that cannot be undone by the application - Super Pi has no undo,
264
+ * and adding one was explicitly left out of scope.
265
+ *
266
+ * It does NOT touch the workspace. No file the agent read or wrote is read, written or reverted here.
267
+ * =============================================================================
268
+ *
269
+ * WHY TRUNCATE RATHER THAN ASK PI. Pi's `SessionManager` is append-only by design
270
+ * (`session-manager.ts:1516-1519`: "Entries cannot be modified or deleted"), and its in-session rewind -
271
+ * `branch()`, `navigateTree()` - is IN-MEMORY ONLY (`:1573-1584`): `_buildIndex()` re-reads the whole
272
+ * file on every `open()` and sets the leaf to the last line unconditionally (`:1103-1122`). Super Pi
273
+ * spawns one Pi per turn and kills it afterwards, so a leaf pointer set in one turn is gone before the
274
+ * next one starts - a "call `branch()` once" solution would look correct for a turn and then silently
275
+ * forget (research §5 pitfall 2). Truncating the file is the only version of this that is a durable state
276
+ * change rather than per-turn bookkeeping that can be skipped exactly once.
277
+ *
278
+ * WHY THE FORMAT IS ASSUMED RATHER THAN GUESSED. The shape this relies on - line 0 is the one and only
279
+ * `type: "session"` header, every other line is one self-contained JSON entry, every entry's `parentId`
280
+ * already appears earlier in the file, no blank lines, and a trailing newline - was measured against
281
+ * real Pi by scripts/probe-revert-session-format.mjs, which also proves the `parentId` property that
282
+ * makes a prefix a VALID session rather than a fragment. That probe is the reason this function may
283
+ * index by line at all; every one of those claims is re-checked here before anything is written, so a
284
+ * Pi that changed the format fails loudly instead of producing a file Pi cannot open.
285
+ *
286
+ * WHY THE WRITE IS TEMP-THEN-RENAME. Pi appends with a bare `appendFileSync`
287
+ * (`session-manager.ts:1187`) and makes no atomicity guarantee of its own. Writing the truncated copy
288
+ * under a different name and renaming it over the original means a reader never observes a half-written
289
+ * file, and a failure before the rename leaves the original untouched.
290
+ *
291
+ * WHY A BACKUP IS KEPT. `.super-pi-revert-backup` is written next to the session file, deliberately not
292
+ * named `*.jsonl` so Pi's own discovery (`readdirSync` filtered on that suffix,
293
+ * `session-manager.ts:1881`) never lists it, and deliberately NOT surfaced anywhere as a feature:
294
+ * Super Pi has no undo and does not pretend to. It exists so that an irreversible, unrecoverable-by-code
295
+ * operation on a person's conversation history is at least recoverable by hand.
296
+ *
297
+ * EXPORTED so it can be tested against a fabricated session file in a temp directory, without Pi and
298
+ * without touching anybody's real transcript.
299
+ */
300
+ export async function truncateSessionFile(path, boundaryEntryId, expectedSessionId) {
301
+ const raw = await readFile(path, "utf8");
302
+ // Split, then drop the empty segment a trailing newline leaves behind. Probe A measured that the file
303
+ // DOES end with a newline, so `split` yields one empty tail element; keeping it would append a blank
304
+ // line to the rewritten file and undo the property the probe just established.
305
+ const lines = raw.split("\n");
306
+ if (lines.at(-1) === "")
307
+ lines.pop();
308
+ if (!lines.length)
309
+ throw new Error(`Pi 的会话文件是空的,拒绝回退:${path}`);
310
+ const parsed = lines.map((line) => {
311
+ try {
312
+ return JSON.parse(line);
313
+ }
314
+ catch {
315
+ return undefined;
316
+ }
317
+ });
318
+ // Every claim probe A made, re-checked here because a Pi upgrade may invalidate any of them. Each
319
+ // failure names the shape that was actually found, because "revert failed" alone sends the reader
320
+ // looking for their own data instead of at the version mismatch that caused it.
321
+ // Checked BEFORE the JSON check, so the refusal names the actual defect: a blank line reads as "this
322
+ // file has a shape my line arithmetic cannot trust", which is what it is, and a bare "not valid JSON"
323
+ // would send the reader looking for a corrupted file instead.
324
+ const blank = lines.findIndex((line) => line.trim() === "");
325
+ if (blank >= 0)
326
+ throw new Error(`Pi 的会话文件第 ${blank} 行是空行,行号与 entry 号不再一一对应,拒绝回退:${path}`);
327
+ const unparseable = parsed.findIndex((entry) => entry === undefined);
328
+ if (unparseable >= 0)
329
+ throw new Error(`Pi 的会话文件第 ${unparseable} 行不是合法 JSON,这个文件的格式已经变了,拒绝回退以免损坏它:${path}`);
330
+ const headerLines = parsed.filter((entry) => entry?.type === "session");
331
+ if (headerLines.length !== 1)
332
+ throw new Error(`Pi 的会话文件里有 ${headerLines.length} 个 header(应为 1 个),格式已经变了,拒绝回退:${path}`);
333
+ if (parsed[0]?.type !== "session")
334
+ throw new Error(`Pi 的会话文件第一行不是 header(实际 type=${JSON.stringify(parsed[0]?.type ?? null)}),格式已经变了,拒绝回退:${path}`);
335
+ if (parsed[0]?.id !== expectedSessionId)
336
+ throw new Error(`Pi 的会话文件属于会话 ${JSON.stringify(parsed[0]?.id ?? null)},不是要回退的会话 ${JSON.stringify(expectedSessionId)}。拒绝截断别人的会话。`);
337
+ if (!raw.endsWith("\n"))
338
+ throw new Error(`Pi 的会话文件末尾没有换行,格式已经变了,拒绝回退:${path}`);
339
+ const boundary = parsed.findIndex((entry, index) => index > 0 && entry?.id === boundaryEntryId);
340
+ if (boundary < 0)
341
+ throw new Error(`在这份 Pi 会话记录里找不到要保留的那条消息(entry ${JSON.stringify(boundaryEntryId)})。这条消息可能是更早的版本写的,Super Pi 当时没有记下它对应 Pi 的哪一条。`);
342
+ // The `parentId` property probe A measured, enforced at the boundary that matters: every entry still
343
+ // being KEPT must point at something also being kept. A prefix that violates this is not a session,
344
+ // and writing it would produce a file Pi either rejects or silently misreads.
345
+ const kept = new Set();
346
+ for (let index = 0; index <= boundary; index += 1) {
347
+ const parent = parsed[index]?.parentId;
348
+ if (parent !== undefined && parent !== null && !kept.has(parent))
349
+ throw new Error(`要保留的部分不是自洽的:第 ${index} 行引用了不在保留范围内的 ${JSON.stringify(parent)},拒绝回退以免产出 Pi 打不开的会话。`);
350
+ kept.add(parsed[index]?.id);
351
+ }
352
+ const rewritten = `${lines.slice(0, boundary + 1).join("\n")}\n`;
353
+ const backupPath = `${path}.super-pi-revert-backup`;
354
+ const temporaryPath = `${path}.super-pi-revert-tmp`;
355
+ await writeFile(backupPath, raw, "utf8");
356
+ await writeFile(temporaryPath, rewritten, "utf8");
357
+ await rename(temporaryPath, path);
358
+ // Re-read rather than trust the write. The claim this whole mechanism rests on is "the file's last
359
+ // line is now the boundary entry", and `_buildIndex` derives the leaf from exactly that line - so if
360
+ // the write did not land, nothing downstream would say so until a turn produced wrong context.
361
+ const verified = (await readFile(path, "utf8")).split("\n").filter((line) => line !== "");
362
+ const last = verified[verified.length - 1];
363
+ let lastEntry;
364
+ try {
365
+ lastEntry = JSON.parse(last);
366
+ }
367
+ catch {
368
+ lastEntry = undefined;
369
+ }
370
+ if (verified.length !== boundary + 1 || lastEntry?.id !== boundaryEntryId) {
371
+ throw new Error(`截断后的 Pi 会话文件没有以要保留的那条消息结尾(写到 ${verified.length} 行,期望 ${boundary + 1} 行,末行 id=${JSON.stringify(lastEntry?.id ?? null)})。原始文件仍在 ${backupPath}。`);
372
+ }
373
+ return { linesKept: boundary + 1, entriesDropped: lines.length - (boundary + 1) };
374
+ }
375
+ /**
376
+ * Writes a second Pi session file that begins as an EXACT copy of one, under a different session id.
377
+ *
378
+ * ============================ WHAT THIS DOES TO THE ORIGINAL ============================
379
+ * **Nothing.** The source file is read and never written, never truncated, never renamed. This is the
380
+ * whole difference from `truncateSessionFile`, and it is the reason a fork is the safe half of this pair
381
+ * of operations: a revert destroys entries permanently and keeps a hand-recovery backup, while a fork
382
+ * leaves both files whole and keeps no backup because there is nothing to recover.
383
+ * ========================================================================================
384
+ *
385
+ * WHY A COPY AND NOT PI'S `fork()`. Covered at length on `ClientCommand`'s `conversation.fork`: Pi's fork
386
+ * always tears down the runtime and switches to the new session (`agent-session-runtime.ts:299-307`), and
387
+ * Super Pi has no runtime to switch - it spawns one Pi per turn and kills it. The file is the only durable
388
+ * thing a fork has to produce here, so the file is what gets produced.
389
+ *
390
+ * ============================ WHAT THIS DOES TO PI's ENTRY IDS ============================
391
+ * **Nothing either, and this is a deliberate divergence from Pi's own `createBranchedSession`, which
392
+ * RENUMBERS `parentId` (`:1655-1666`).** Keeping every id verbatim means every cross-reference already
393
+ * inside the file stays valid, and research pitfall 6 is exactly why that matters: a Pi entry may point
394
+ * at other entries by id in ways this code has never enumerated - a `compaction` record carries a
395
+ * `firstKeptEntryId`, `branch_summary` carries the branch point - and renumbering ids means rewriting every
396
+ * one of those references correctly, with no way to find them all. Copying the bytes cannot break a
397
+ * reference because it changes nothing a reference points at. The cost is that the same entry id now
398
+ * exists in two files; that is safe because `_buildIndex` builds its `byId` map per file (`:1103-1122`),
399
+ * and Super Pi only ever opens one file per process.
400
+ *
401
+ * WHAT THE HEADER CHANGES, and it is two fields. `id` becomes the new conversation's id, because that is
402
+ * what Pi's lookup matches (`findLocalSessionByExactId` reads the header, probe B) and therefore what makes
403
+ * `--session-id <forked>` open THIS file rather than silently inventing an empty session with that id
404
+ * (`main.ts:436-448`, measured: `Warning: No project session found with id '...'`). And `parentSession`
405
+ * records the file this one came from, which is the same one-line lineage pointer Pi itself writes
406
+ * (`:1675-1682`) and is not a link - the files share nothing. `cwd`, `version` and `timestamp` are copied
407
+ * verbatim: `cwd` MUST be identical, because Pi's lookup requires the recorded cwd to equal the one it is
408
+ * asked from, and the fork always lives in the same workspace as its source.
409
+ *
410
+ * THE SAME FORMAT CHECKS AS TRUNCATE, AND FOR THE SAME REASON. Probe A measured this file's shape against
411
+ * real Pi (`scripts/probe-revert-session-format.mjs`); a Pi upgrade can invalidate any of those claims, so
412
+ * every one is re-checked before a byte is written and a failure names the shape actually found. Here the
413
+ * `parentId` property is checked across the WHOLE file rather than across a kept prefix, because the whole
414
+ * file is what is being copied.
415
+ *
416
+ * TEMP-THEN-RENAME, because Pi appends with a bare `appendFileSync` (`:1187`) and the target must never be
417
+ * observed half-written - a half-written file under the forked id is precisely the "Pi opens an empty
418
+ * session and the history has vanished with no error anywhere" outcome probe B was about.
419
+ *
420
+ * EXPORTED so it can be tested against fabricated session files in a temp directory, without Pi.
421
+ */
422
+ export async function forkSessionFile(sourcePath, targetPath, expectedSessionId, targetSessionId) {
423
+ const raw = await readFile(sourcePath, "utf8");
424
+ const lines = raw.split("\n");
425
+ if (lines.at(-1) === "")
426
+ lines.pop();
427
+ if (!lines.length)
428
+ throw new Error(`Pi 的会话文件是空的,拒绝分叉:${sourcePath}`);
429
+ const parsed = lines.map((line) => {
430
+ try {
431
+ return JSON.parse(line);
432
+ }
433
+ catch {
434
+ return undefined;
435
+ }
436
+ });
437
+ // The refusal messages name the shape actually found, and say "拒绝分叉" rather than reusing the revert
438
+ // wording: the reader is looking at a fork button, and a message about 回退 would send them to look for a
439
+ // control they did not press.
440
+ const blank = lines.findIndex((line) => line.trim() === "");
441
+ if (blank >= 0)
442
+ throw new Error(`Pi 的会话文件第 ${blank} 行是空行,无法安全复制,拒绝分叉:${sourcePath}`);
443
+ const unparseable = parsed.findIndex((entry) => entry === undefined);
444
+ if (unparseable >= 0)
445
+ throw new Error(`Pi 的会话文件第 ${unparseable} 行不是合法 JSON,这个文件的格式已经变了,拒绝分叉以免复制出一个 Pi 打不开的会话:${sourcePath}`);
446
+ const headerLines = parsed.filter((entry) => entry?.type === "session");
447
+ if (headerLines.length !== 1)
448
+ throw new Error(`Pi 的会话文件里有 ${headerLines.length} 个 header(应为 1 个),格式已经变了,拒绝分叉:${sourcePath}`);
449
+ if (parsed[0]?.type !== "session")
450
+ throw new Error(`Pi 的会话文件第一行不是 header(实际 type=${JSON.stringify(parsed[0]?.type ?? null)}),格式已经变了,拒绝分叉:${sourcePath}`);
451
+ // The one that matters most. Copying the wrong file would put one person's conversation into another's
452
+ // session, and unlike a wrong truncation it would be written as a brand-new file rather than destroying
453
+ // something - so this check is what stops a fork from being a disclosure.
454
+ if (parsed[0]?.id !== expectedSessionId)
455
+ throw new Error(`Pi 的会话文件属于会话 ${JSON.stringify(parsed[0]?.id ?? null)},不是要分叉的会话 ${JSON.stringify(expectedSessionId)}。拒绝复制别人的会话。`);
456
+ if (!raw.endsWith("\n"))
457
+ throw new Error(`Pi 的会话文件末尾没有换行,格式已经变了,拒绝分叉:${sourcePath}`);
458
+ // Every kept entry must point at another kept entry. The whole file is being copied, so "kept" is all of
459
+ // it - and a file that violates this is not a session Pi can open.
460
+ const known = new Set();
461
+ for (let index = 0; index < parsed.length; index += 1) {
462
+ const parent = parsed[index]?.parentId;
463
+ if (parent !== undefined && parent !== null && !known.has(parent))
464
+ throw new Error(`这个 Pi 会话记录不自洽:第 ${index} 行引用了不存在的 ${JSON.stringify(parent)},拒绝分叉以免复制出一个 Pi 打不开的会话:${sourcePath}`);
465
+ known.add(parsed[index]?.id);
466
+ }
467
+ // The header is REBUILT rather than string-patched, so the two changed fields are structural and
468
+ // everything else - including any field a future Pi adds - is carried across untouched. A regex over the
469
+ // first line would silently drop a field it did not recognise.
470
+ const header = { ...parsed[0], id: targetSessionId, parentSession: sourcePath };
471
+ const rewritten = [JSON.stringify(header), ...lines.slice(1)].join("\n") + "\n";
472
+ const temporaryPath = `${targetPath}.super-pi-fork-tmp`;
473
+ await writeFile(temporaryPath, rewritten, "utf8");
474
+ await rename(temporaryPath, targetPath);
475
+ // Re-read rather than trust the write. The claim this rests on is "a Pi spawned with the new id will
476
+ // open THIS file and find the whole history in it", and nothing downstream would report a failed write -
477
+ // the fork would simply appear to work and the model would start from nothing.
478
+ const verified = (await readFile(targetPath, "utf8")).split("\n").filter((line) => line !== "");
479
+ let verifiedHeader;
480
+ try {
481
+ verifiedHeader = JSON.parse(verified[0]);
482
+ }
483
+ catch {
484
+ verifiedHeader = undefined;
485
+ }
486
+ if (verified.length !== lines.length || verifiedHeader?.id !== targetSessionId) {
487
+ throw new Error(`写入后的 Pi 会话文件不是预期的样子(写到 ${verified.length} 行,期望 ${lines.length} 行,header id=${JSON.stringify(verifiedHeader?.id ?? null)},期望 ${JSON.stringify(targetSessionId)})。已放弃,没有创建任何会话。`);
488
+ }
489
+ return { entriesCopied: lines.length - 1 };
490
+ }
491
+ /** How long one command may take before the turn is failed rather than left hanging. */
492
+ const SESSION_COMMAND_TIMEOUT_MS = 10_000;
493
+ /** `get_state`/`get_entries` on an idle Pi. Generous, because it only has to answer a question. */
494
+ const LOOKUP_COMMAND_TIMEOUT_MS = 30_000;
495
+ /**
496
+ * How long a `steer` may go unanswered before the caller is told the outcome is unknown.
497
+ *
498
+ * The same budget as any other single Pi command, for the same reason: this is one round-trip to a
499
+ * local child that is already busy running a turn. It is generous because the child is mid-turn and may
500
+ * not read stdin promptly - too short a budget would report "unknown" for steers Pi simply had not got
501
+ * to yet, which is exactly the ambiguity this whole path exists to avoid.
502
+ */
503
+ const STEER_COMMAND_TIMEOUT_MS = 10_000;
504
+ /**
505
+ * Reads Pi's `steer` disposition out of its response payload.
506
+ *
507
+ * Unknown and missing both become `queued`, which is the CONSERVATIVE default and not a convenience: a
508
+ * steer Pi could not categorise may or may not already be in the model's context, so it must keep being
509
+ * treated as "not yet delivered" until a `queue_update` says otherwise. Guessing `handled` would let the
510
+ * caller drop the message from the dock on the strength of a payload shape we did not recognise.
511
+ */
512
+ export function describeSteerDisposition(data) {
513
+ const disposition = data?.disposition;
514
+ return disposition === "handled" || disposition === "queued" ? disposition : "queued";
515
+ }
516
+ /**
517
+ * How long a catalog answer is reused. Pi's model list changes when the user edits its config, never
518
+ * on a timer, so this only trades freshness for the ~400-840ms a probe Pi costs - measured here at
519
+ * 394ms for all three commands written at once. Ten minutes is long enough that reopening a picker
520
+ * twice in a session never pays for a second spawn, and short enough that a `pi models add` shows up
521
+ * the same afternoon.
522
+ */
523
+ const CATALOG_TTL_MS = 10 * 60_000;
524
+ /** A FAILED catalog is cached for far less time, so one broken Pi cannot be retried on every keystroke of a model switch. */
525
+ const CATALOG_FAILURE_TTL_MS = 30_000;
526
+ const CATALOG_COMMAND_TIMEOUT_MS = 15_000;
527
+ /** Keys Pi's model answers by. Exported so the store and the tests agree with the runtime. */
528
+ export const catalogKey = (selection) => (selection?.provider && selection.modelId ? `${selection.provider}/${selection.modelId}` : "default");
529
+ /**
530
+ * Where the `task` extension lives, as an ABSOLUTE path.
531
+ *
532
+ * Absolute because Pi requires it: `--extension` takes the path verbatim and resolves it against Pi's
533
+ * own working directory, which is the CONVERSATION's workspace, not this repository. A relative path
534
+ * would therefore resolve differently for every conversation and fail for all of them except the one
535
+ * whose workspace happens to be this repo.
536
+ *
537
+ * `import.meta.dirname` is the one thing stable across both ways this code runs - `dist/server/` after
538
+ * `tsc -b`, `src/server/` under `tsx watch` - so two `..` segments reach the repository root either way.
539
+ *
540
+ * Returned as a FUNCTION rather than a module constant so a test can assert on the resolution without
541
+ * this module's import-time filesystem state deciding it, and so the "does it exist" check below is a
542
+ * warning on a path that is genuinely optional rather than a startup crash on a build that skipped it.
543
+ */
544
+ export function taskExtensionPath(from = import.meta.dirname) {
545
+ return resolve(from, "../../agents/super-pi-task/index.ts");
546
+ }
547
+ /** Bridges a Pi session to Super Pi through Pi's documented JSONL RPC protocol. */
548
+ export class PiRpcRuntime {
549
+ sessionDir;
550
+ agents;
551
+ options;
552
+ runs = new Map();
553
+ /**
554
+ * `steer` commands written to a child and not yet answered by Pi, keyed by the command id.
555
+ *
556
+ * A steer has to be correlated to a promise rather than fire-and-forget, because Pi's `steer` response
557
+ * carries a `disposition` that decides whether the message is already in the model's context or still
558
+ * waiting (`handled` vs `queued`, `rpc-mode.ts:416-419`). Without that answer Super Pi would have to
559
+ * guess, and both guesses are wrong: reporting a delivered steer as waiting would leave a message
560
+ * stranded in the dock forever, and reporting a waiting one as delivered would let the user cancel a
561
+ * message the model is about to act on.
562
+ */
563
+ pendingSteers = new Map();
564
+ /** Distinguishes steer command ids. Never reused, so a late response cannot settle a later steer. */
565
+ steerSeq = 0;
566
+ catalogs = new Map();
567
+ /** In-flight reads, so N clients asking at once cost ONE Pi spawn rather than N. */
568
+ pendingCatalogs = new Map();
569
+ /** Latches the "no extension on disk" warning, so it is said once rather than once per turn. */
570
+ warnedMissingExtension = false;
571
+ /**
572
+ * Agent definitions are read from disk, never cached. A file is cheap to parse, and re-reading it
573
+ * per turn is the whole point: editing an agent's prompt or tool allowlist changes the NEXT message
574
+ * with no re-selection and no restart, which a cache would silently prevent.
575
+ *
576
+ * `command` is an override for tests and for a Pi installed somewhere unusual; with none given, Pi is
577
+ * resolved through `resolvePiCommand()` (override -> PATH -> npm's global bin directory), the same
578
+ * function scripts/probe-agent-real-pi.mjs uses. Resolved ONCE here because it is a filesystem
579
+ * lookup, and a resolution that could change mid-run would make a turn's spawn line depend on when
580
+ * it was started.
581
+ */
582
+ pi;
583
+ constructor(sessionDir, command,
584
+ /**
585
+ * Where agent definitions are read from and where their prompt bodies are written. A THIRD positional
586
+ * parameter rather than part of the second one, so every existing `new PiRpcRuntime(dir, launcher)`
587
+ * call site keeps its meaning - moving the agent directory into that slot would have silently fed a
588
+ * launcher's file path in as a directory of agent definitions.
589
+ */
590
+ agents = { dir: join(sessionDir, "agents"), promptDir: join(sessionDir, "agent-prompts") },
591
+ /**
592
+ * How the `task` extension reaches this server. A FOURTH positional parameter for the same reason as
593
+ * the third: an options bag in one of the earlier slots would silently change what an existing
594
+ * `new PiRpcRuntime(dir, launcher, { dir, promptDir })` call site means.
595
+ *
596
+ * `endpoint` is this server's own listening URL, supplied rather than derived. It is the fix for
597
+ * hardcoding a port: `SUPER_PI_PORT` is honoured by `index.ts` and by every test that boots on
598
+ * `freePort()`, so an extension that dialled a fixed number would reach nothing at all. When it is
599
+ * absent the extension is simply not mounted, and the reason is logged once per turn rather than
600
+ * swallowed - a task tool that is mysteriously missing is the single hardest failure here to
601
+ * diagnose from the outside.
602
+ *
603
+ * `todoEndpoint` is the `todowrite` endpoint, supplied the same way and for the same reason, and it
604
+ * is a SEPARATE field rather than a path derived from `endpoint`: the two tools are allowed to be
605
+ * available independently (a harness that only exercises dispatch does not want a todo writer, and an
606
+ * older checkout has no todo endpoint at all), and a derived path could not tell "not configured"
607
+ * from "configured wrong". When it is absent the tool still registers and the extension refuses in
608
+ * words, which is what `refuseTodo` in core.ts is for.
609
+ */
610
+ options = {}) {
611
+ this.sessionDir = sessionDir;
612
+ this.agents = agents;
613
+ this.options = options;
614
+ this.pi = resolvePiCommand({ command });
615
+ if (!this.pi.found)
616
+ console.warn(`PiRpcRuntime: ${this.pi.reason}`);
617
+ }
618
+ /**
619
+ * The agent a conversation runs as, or undefined for Pi's own defaults.
620
+ *
621
+ * A missing file is NOT an error: a conversation that names an agent that has since been deleted
622
+ * degrades to running as Pi itself, with a warning, rather than failing every turn forever. A
623
+ * `subagent` definition is also ignored here - those are invoked BY a primary agent, never chosen
624
+ * as the conversation's own role.
625
+ */
626
+ async agentFor(conversation) {
627
+ if (!conversation.agentId)
628
+ return undefined;
629
+ const { agents, problems } = await readAgents(this.agents.dir);
630
+ const agent = agents.find((item) => item.id === conversation.agentId);
631
+ if (agent)
632
+ return agent;
633
+ console.warn(`PiRpcRuntime: conversation ${conversation.id} names agent ${JSON.stringify(conversation.agentId)}, which is not a primary agent in ${this.agents.dir}${problems.length ? ` (${problems.length} file(s) there are not usable agents)` : ""} - running as Pi itself.`);
634
+ return undefined;
635
+ }
636
+ /**
637
+ * The `--extension` argv for this turn, or `[]` when the task tool is not available.
638
+ *
639
+ * Empty in TWO different situations, and both are deliberate rather than merged:
640
+ *
641
+ * - No endpoint was configured, so there is nowhere for the extension to dispatch to. Mounting it
642
+ * anyway would give the model a tool whose every call fails with a network error.
643
+ * - The extension file is not on disk. That is a build or checkout problem, and it is warned about
644
+ * ONCE rather than every turn, because "your primary agent cannot delegate anything" is worth
645
+ * knowing and worth not repeating on every keystroke.
646
+ */
647
+ async taskExtensionArgs() {
648
+ if (!this.options.endpoint)
649
+ return [];
650
+ const path = taskExtensionPath();
651
+ if (!existsSync(path)) {
652
+ if (!this.warnedMissingExtension) {
653
+ this.warnedMissingExtension = true;
654
+ console.warn(`PiRpcRuntime: ${path} does not exist, so the primary agent cannot delegate to sub-agents. The task tool is off for every conversation until it is restored.`);
655
+ }
656
+ return [];
657
+ }
658
+ return ["--extension", path];
659
+ }
660
+ /**
661
+ * The five values the extension reads out of its own environment. See `task-tool.ts` for why they are
662
+ * written out on both sides rather than imported.
663
+ *
664
+ * The ROSTER is read here, per turn, from the same `readAgents` the picker and the supervisor use -
665
+ * never from a list written into this file. A sub-agent added while the server is running is therefore
666
+ * dispatchable on the next message with no restart, which is the same freshness rule `agentFor`
667
+ * follows for agent files and for the same reason: a human edits those files while the server runs.
668
+ *
669
+ * `depth` is `0`, hardcoded, and that is not an oversight: this is the PRIMARY agent's spawn, so it is
670
+ * definitionally at the top of the tree. `SubagentSupervisor` never passes this function, which is why
671
+ * no sub-agent process is ever given this environment at all.
672
+ *
673
+ * The TODO ENDPOINT is the one value here that is NOT per-turn derived from disk: it is this server's
674
+ * own address, so it is the same string on every turn and is read once from the constructor's options.
675
+ */
676
+ async taskExtensionEnv(conversationId) {
677
+ const { agents, problems } = await readAgents(this.agents.dir, { mode: "subagent" });
678
+ for (const problem of problems)
679
+ console.warn(`PiRpcRuntime: ${this.agents.dir}/${problem.file} is not a usable sub-agent - ${problem.message}`);
680
+ return {
681
+ [TASK_ENDPOINT_ENV]: this.options.endpoint ?? "",
682
+ [TASK_ROSTER_ENV]: rosterPayload(agents),
683
+ [TASK_CONVERSATION_ENV]: conversationId,
684
+ [TASK_DEPTH_ENV]: "0",
685
+ [TODO_ENDPOINT_ENV]: this.options.todoEndpoint ?? "",
686
+ };
687
+ }
688
+ async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolEnd, images }) {
689
+ await mkdir(this.sessionDir, { recursive: true });
690
+ // Normalize first: the picker can hand over forward slashes or a trailing separator, and `resolve`
691
+ // turns both into one absolute native path. The directory must exist on disk before spawn - a
692
+ // deleted workspace directory is a visible failure, never a reason to run somewhere else.
693
+ const childCwd = resolve(cwd);
694
+ const info = await stat(childCwd).catch(() => undefined);
695
+ if (!info?.isDirectory())
696
+ throw new Error(`Workspace directory for conversation ${conversation.id} does not exist or is not a directory: ${childCwd}. Refusing to run Pi in any other directory.`);
697
+ // The agent's flags go on the SPAWN line, not the wire, because that is the only place Pi reads
698
+ // them: its RPC command union (rpc-types.ts:20-74) has no prompt and no tools command, so there is
699
+ // nothing to send after the process is already running. Fetched before the spawn so a definition
700
+ // error fails the turn loudly instead of starting a Pi that silently ignores it.
701
+ const agent = await this.agentFor(conversation);
702
+ // Whether the `task` tool EXISTS is decided first, because that is what decides whether it belongs
703
+ // on the allowlist. Naming a tool Pi has not registered is meaningless, and doing it anyway would
704
+ // change the spawn line of a runtime that has no task tool - which is what
705
+ // `PiRpcRuntime(dir, launcher)` without an endpoint is, and what `agent-mechanism.test.mjs` asserts
706
+ // a byte-identical spawn line for.
707
+ //
708
+ // Why it has to be added at all: Pi filters its registry through `--tools` and only activates a tool
709
+ // the allowlist allows (agent-session.ts:3567-3574), so a primary agent declaring
710
+ // `tools: {"*": false, read: true}` would otherwise get an extension tool that is loaded and never
711
+ // declared - broken with no error. An agent that says `task: false` still wins: `toolFlags` treats an
712
+ // explicit false as outranking a host extra, which is what makes "this agent does not delegate" true
713
+ // rather than advisory.
714
+ const extensionArgs = await this.taskExtensionArgs();
715
+ // BOTH extension tools, or neither. Naming one while the other goes unmentioned would be a lie in a
716
+ // different direction: Pi filters its registry through `--tools`, so an agent that declares
717
+ // `tools: {"*": false, read: true}` gets whichever of the two is on this line and silently not the
718
+ // other. They also share the extension's existence - `extensionArgs` is non-empty exactly when
719
+ // `index.ts` is on disk and an endpoint was configured - so there is no case where one is mounted and
720
+ // the other is not.
721
+ const agentArgs = await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME] : [] });
722
+ // Refused here, with the reason, rather than handed to the spawn: an ENOENT from `spawn` reads as
723
+ // "the app is broken" and hides the one fact that matters, which is where it looked for Pi.
724
+ if (!this.pi.found)
725
+ throw new Error(this.pi.reason);
726
+ // npm exposes global CLIs as .cmd shims on Windows; Node requires a shell to launch those shims.
727
+ const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", conversation.id, ...extensionArgs, ...agentArgs], {
728
+ cwd: childCwd,
729
+ stdio: ["pipe", "pipe", "pipe"],
730
+ shell: process.platform === "win32",
731
+ // `env` is passed ONLY when the extension is mounted, because passing `{ ...process.env }` on
732
+ // every turn would silently replace the child's environment with whatever THIS process has at
733
+ // spawn time. Inheriting is what an omitted `env` already does, so the two paths differ in nothing
734
+ // but the four documented additions.
735
+ ...(extensionArgs.length ? { env: { ...process.env, ...await this.taskExtensionEnv(conversation.id) } } : {}),
736
+ });
737
+ const channel = new RpcChannel(child);
738
+ this.runs.set(conversation.id, { child, channel });
739
+ const abort = () => { if (!child.killed)
740
+ child.stdin.write(`${JSON.stringify({ id: "abort", type: "abort" })}\n`); };
741
+ signal.addEventListener("abort", abort, { once: true });
742
+ // A blocking request is awaited inside the drain loop, so the loop cannot observe an abort or a
743
+ // dead child on its own. Both are raced into the wait so a pending question can never outlive the run.
744
+ const ui = { seen: new Set(), aborted: new Promise((resolve) => { if (signal.aborted)
745
+ resolve(undefined);
746
+ else
747
+ signal.addEventListener("abort", () => resolve(undefined), { once: true }); }), exited: new Promise((resolve) => child.once("exit", () => resolve(undefined))) };
748
+ try {
749
+ // The leaf this turn starts from, read from Pi BEFORE anything is written this turn.
750
+ //
751
+ // Written, not awaited: Pi queues commands, so this line costs no wall-clock time - it is answered
752
+ // while the model is thinking, and collected at the end of the turn. Awaiting it here would add a
753
+ // round trip to every message in exchange for information nothing needs until the turn is over.
754
+ //
755
+ // The FIRST turn has no file and no leaf at all, which is why `leafId` may be null: Pi reports it
756
+ // that way until something has been appended, and the `since` lookup below simply reads everything.
757
+ let turnStart;
758
+ if (onUserEntry) {
759
+ channel.write({ id: "turn_head", type: "get_entries" });
760
+ // Swallowed HERE rather than awaited below: a Pi that cannot answer this has not failed the turn,
761
+ // and the alternative - propagating - would turn "this message cannot be a revert boundary" into
762
+ // "your message failed to send", which is both false and far more alarming.
763
+ turnStart = channel.waitFor("turn_head", SESSION_COMMAND_TIMEOUT_MS, "get_entries").catch((error) => {
764
+ console.warn(`PiRpcRuntime: could not read the leaf before the turn (${error instanceof Error ? error.message : "unknown error"}) - this turn's message may not become a revert boundary.`);
765
+ return undefined;
766
+ });
767
+ }
768
+ // The conversation's picker selection, applied to THIS Pi before it sees the prompt. A refusal
769
+ // is thrown, never downgraded to a warning: a run on some other model than the button says is
770
+ // exactly the decorative picker this must not be.
771
+ for (const command of sessionCommands(conversation)) {
772
+ const response = await channel.request(command, SESSION_COMMAND_TIMEOUT_MS);
773
+ if (response.success === false)
774
+ throw new Error(`Pi rejected ${command.type}: ${response.error ?? "no reason given"}`);
775
+ }
776
+ const imagePayloads = this.options.uploadsDir && images?.length
777
+ ? await loadConversationImages(this.options.uploadsDir, conversation.id, images)
778
+ : undefined;
779
+ channel.write({ id: "prompt", type: "prompt", message: prompt, ...(imagePayloads?.length ? { images: imagePayloads } : {}) });
780
+ let settled = false;
781
+ // Nothing may be dropped without a trace. Pi emits a wide, open-ended family of agent
782
+ // session events (tool_call, thinking, compaction_*, auto_retry_*, ...), so an unhandled
783
+ // record is announced once per distinct type and then tallied instead of logging per event.
784
+ const announced = new Set();
785
+ const repeats = new Map();
786
+ const noteUnhandled = (key) => {
787
+ if (announced.has(key))
788
+ repeats.set(key, (repeats.get(key) ?? 0) + 1);
789
+ else {
790
+ announced.add(key);
791
+ console.warn(`PiRpcRuntime: unhandled Pi RPC record ${key} - consumed without action.`);
792
+ }
793
+ };
794
+ while (!settled) {
795
+ for (;;) {
796
+ const record = await channel.next(100);
797
+ if (!record)
798
+ break;
799
+ // A response the loop does not itself consume is not garbage - it is the answer to a command
800
+ // somebody wrote on this channel and is waiting for (`get_entries`, and in `readCatalog` the model
801
+ // list). Handed to the stash rather than dropped, which is what stopped the loop from eating the
802
+ // per-turn entry-id read and leaving this turn's message unrecordable.
803
+ if (record.type === "response" && record.id !== "prompt") {
804
+ channel.stash(record);
805
+ continue;
806
+ }
807
+ if (record.type === "response" && record.id === "prompt" && record.success === false)
808
+ throw new Error(record.error ?? "Pi rejected the prompt");
809
+ // A `steer` written while this turn was running, answered here because this loop is the only
810
+ // thing reading the child's stdout. Settled rather than ignored: the caller is holding a
811
+ // promise on this exact id to learn whether the message is already in the model's context.
812
+ if (record.type === "response" && record.id !== "prompt") {
813
+ const pending = typeof record.id === "string" ? this.pendingSteers.get(record.id) : undefined;
814
+ if (pending) {
815
+ this.pendingSteers.delete(record.id);
816
+ if (record.success === false)
817
+ pending.reject(new Error(`Pi rejected the steer: ${record.error ?? "no reason given"}`));
818
+ else
819
+ pending.resolve(describeSteerDisposition(record.data) ?? "queued");
820
+ }
821
+ continue;
822
+ }
823
+ // Pi's own queue, pushed as a full snapshot whenever it changes. READ, not dropped: this used
824
+ // to fall through to `noteUnhandled` and be logged once as an unhandled record type, which is
825
+ // the "looks wired up but nothing happens" failure - it is the only signal that a steer was
826
+ // really picked up, and Pi emits it before the delivered message on the same stdout stream.
827
+ const piQueue = describePiQueue(record);
828
+ if (piQueue) {
829
+ try {
830
+ onPiQueue?.(piQueue);
831
+ }
832
+ catch (error) {
833
+ console.warn(`PiRpcRuntime: the pi queue handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
834
+ }
835
+ continue;
836
+ }
837
+ // THE PRIMARY AGENT'S TOOL CALLS. Consumed, not announced - the same routing the sub-agent
838
+ // supervisor already does for its child (subagents.ts:532-559), on the same two records.
839
+ //
840
+ // These two used to reach `noteUnhandled` and be logged once each per turn, which is the
841
+ // "looks wired up but nothing happens" failure twice over: the warning said the host did not
842
+ // understand them, and the host genuinely did not, so a `read` and a two-minute `bash` were the
843
+ // same silence on the transcript. Consumed here because they now carry data - which is the
844
+ // difference between routing and silencing.
845
+ //
846
+ // WHAT IS *NOT* CHANGED. Every other record type keeps its existing report, including the inner
847
+ // `message_update` events below: a `thinking_delta` still announces itself, because that is a
848
+ // documented decision about an event shape rather than an oversight this branch is here to
849
+ // repair. Only the two tool records move.
850
+ if (record.type === "tool_execution_start") {
851
+ // The `args` are READ and summarised, never carried: `describeToolCallArgs` turns an
852
+ // arbitrary object into at most six capped strings and a non-string becomes a bare type
853
+ // word, because an `edit`'s arguments routinely hold a whole file's replacement text. See
854
+ // tool-calls.ts for the argument-vs-transcript line.
855
+ const name = record.toolName ?? "unknown";
856
+ try {
857
+ onToolCall?.({ id: record.toolCallId ?? "", name, detail: describeToolCall(name, record.args), args: describeToolCallArgs(record.args) });
858
+ }
859
+ catch (error) {
860
+ console.warn(`PiRpcRuntime: the tool call handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
861
+ }
862
+ continue;
863
+ }
864
+ if (record.type === "tool_execution_end") {
865
+ // The other half of the same call, and the half that lets a block say whether it finished.
866
+ // ONLY `isError` AND `durationMs` CROSS OVER. `result` is never read - `RpcRecord` does not
867
+ // even declare it, because for a `read` it is the file's contents and `ToolCallNotice` is
868
+ // persisted into `state.json`.
869
+ //
870
+ // The handler is wrapped for the same reason the queue handler is: a listener that throws
871
+ // must cost one tool row, not the whole turn's drain loop.
872
+ try {
873
+ onToolEnd?.({
874
+ id: record.toolCallId ?? "",
875
+ name: record.toolName ?? "unknown",
876
+ status: record.isError === true ? "error" : "ok",
877
+ ...(typeof record.durationMs === "number" && Number.isFinite(record.durationMs) ? { durationMs: Math.max(0, Math.round(record.durationMs)) } : {}),
878
+ });
879
+ }
880
+ catch (error) {
881
+ console.warn(`PiRpcRuntime: the tool end handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
882
+ }
883
+ continue;
884
+ }
885
+ if (record.type === "message_update") {
886
+ const event = record.assistantMessageEvent;
887
+ // text_delta is the only inner event that drives the visible answer. The rest (thinking,
888
+ // tool calls, message_start/end) are consumed here, but still announced once each so an
889
+ // event shape this runtime ignores cannot pass unnoticed.
890
+ if (event?.type === "text_delta")
891
+ yield event.delta ?? "";
892
+ else
893
+ noteUnhandled(`message_update:${JSON.stringify(event?.type ?? "(missing)")}`);
894
+ continue;
895
+ }
896
+ if (record.type === "extension_ui_request") {
897
+ await this.handleExtensionUi(record, child, host, ui);
898
+ continue;
899
+ }
900
+ // Pi already caught this error inside the extension host, so it will never reach us as a
901
+ // thrown failure. Every answer spawns its own Pi process, so any extension_error seen while
902
+ // draining belongs to the prompt in flight - surface it as a real failure instead of
903
+ // letting the turn finish as if nothing happened.
904
+ if (record.type === "extension_error") {
905
+ console.warn(`PiRpcRuntime: extension_error path=${record.extensionPath ?? "unknown"} event=${record.event ?? "unknown"} error=${record.error ?? "unknown"}`);
906
+ throw new Error(`Pi extension error in ${record.extensionPath ?? "unknown path"} (${record.event ?? "unknown event"}): ${record.error ?? "unknown error"}`);
907
+ }
908
+ if (record.type === "agent_settled") {
909
+ settled = true;
910
+ continue;
911
+ }
912
+ noteUnhandled(`type ${JSON.stringify(record.type ?? "(missing type)")}`);
913
+ }
914
+ if (!settled && child.exitCode !== null)
915
+ throw new Error(`Pi RPC exited before settling (code ${child.exitCode}). ${channel.stderr.trim()}`);
916
+ }
917
+ for (const [key, count] of repeats)
918
+ console.warn(`PiRpcRuntime: ${count} further unhandled record(s) of ${key}.`);
919
+ // This turn's own slice of Pi's transcript, taken now because after the `finally` below the child is
920
+ // gone and `answer()` will never be able to ask again.
921
+ //
922
+ // `since` is what makes this exact rather than approximate: the entries written after the pre-turn
923
+ // leaf belong to THIS turn, and the first USER message among them is the one Super Pi just pushed.
924
+ // Counting messages instead would be wrong the moment a compaction or a `model_change` lands in the
925
+ // slice - which is exactly research §5 pitfall 6.
926
+ if (onUserEntry) {
927
+ await this.recordTurnEntryId(channel, await turnStart, conversation.id, onUserEntry);
928
+ }
929
+ }
930
+ finally {
931
+ signal.removeEventListener("abort", abort);
932
+ this.runs.delete(conversation.id);
933
+ // A steer still unanswered when the turn ends is REJECTED, not left pending. The drain loop above
934
+ // is the only reader of this child's stdout and it has stopped, so nothing would ever settle that
935
+ // promise: the caller would wait for a timeout to learn something this process already knows. The
936
+ // message itself is not lost - Pi drains steering before its loop can end, so anything unanswered
937
+ // at this point was in practice not delivered, and the caller returns it to its queue.
938
+ if (this.pendingSteers.size) {
939
+ for (const [id, pending] of [...this.pendingSteers]) {
940
+ this.pendingSteers.delete(id);
941
+ pending.reject(new Error(`Pi never answered the steer: the turn ended first.`));
942
+ }
943
+ }
944
+ // The run is not over until the child is actually gone: a process that is still tearing down
945
+ // still holds a lock on its working directory, so a caller that removes that directory the
946
+ // moment answer() returns would hit EBUSY on Windows. Kill, then wait for the exit.
947
+ //
948
+ // The 5s backstop is load-bearing and matches the two other waits in this file: the kill
949
+ // precedes the `exit` subscription, so an exit that lands between them is dropped and the promise
950
+ // never settles. Unbounded, that hangs the turn that asked for it. See readCatalog() for the
951
+ // measurement.
952
+ if (child.exitCode === null && child.pid !== undefined) {
953
+ if (!child.killed) {
954
+ if (process.platform === "win32")
955
+ await killTree(child.pid);
956
+ else
957
+ child.kill();
958
+ }
959
+ if (child.exitCode === null)
960
+ await new Promise((resolve) => { child.once("exit", resolve); setTimeout(resolve, 5000); });
961
+ }
962
+ }
963
+ }
964
+ /**
965
+ * Reads back the id of the user message this turn just created, and hands it to the caller.
966
+ *
967
+ * `head` is the already-answered pre-turn `get_entries`, whose `leafId` is what makes the second read
968
+ * exact. Its three possible outcomes are handled differently ON PURPOSE, because they mean different
969
+ * things and two of them must not produce an id:
970
+ *
971
+ * - `leafId: null` - the session was empty, so this IS the first turn and the whole entry list is this
972
+ * turn's. Safe to take the first user message from it.
973
+ * - `leafId: "..."` - read only what came after it. Safe, and the only correct answer for turn two onward.
974
+ * - no usable answer - Pi never told us where the turn began, so NOTHING in the transcript can be
975
+ * attributed to it. Reading everything and taking "the first user message" here would hand back a
976
+ * PREVIOUS turn's id, and a revert to that id would silently cut more than the user asked for. So this
977
+ * case records nothing and says why.
978
+ *
979
+ * Every failure is a warning, never a throw: the turn's answer has already streamed by this point, and
980
+ * "this message cannot be a revert boundary" is not "your message failed".
981
+ */
982
+ async recordTurnEntryId(channel, head, conversationId, onUserEntry) {
983
+ const before = head?.data;
984
+ const leaf = before?.leafId;
985
+ if (leaf !== null && typeof leaf !== "string") {
986
+ console.warn(`PiRpcRuntime: Pi did not report where turn of conversation ${conversationId} started, so its message was not recorded and cannot be used as a revert boundary.`);
987
+ return;
988
+ }
989
+ try {
990
+ const id = `turn_tail_${Math.random().toString(36).slice(2, 10)}`;
991
+ // `since` is omitted, not sent as null, on the first turn: Pi's handler tests
992
+ // `command.since !== undefined` (`rpc-mode.ts:638`), and an explicit null would be a lookup for an
993
+ // entry named "null" that fails with an error instead of reading everything.
994
+ channel.write({ id, type: "get_entries", ...(typeof leaf === "string" ? { since: leaf } : {}) });
995
+ const tail = await channel.waitFor(id, SESSION_COMMAND_TIMEOUT_MS, "get_entries");
996
+ if (tail.success === false)
997
+ throw new Error(tail.error ?? "Pi rejected get_entries");
998
+ const entries = tail.data?.entries;
999
+ if (!Array.isArray(entries))
1000
+ throw new Error("get_entries returned no entry list");
1001
+ const firstUser = entries.find((entry) => {
1002
+ const candidate = entry;
1003
+ return candidate?.type === "message" && candidate.message?.role === "user" && typeof candidate.id === "string";
1004
+ });
1005
+ if (typeof firstUser?.id !== "string") {
1006
+ console.warn(`PiRpcRuntime: the entries Pi added for this turn contain no user message, so nothing was recorded as a revert boundary for conversation ${conversationId}.`);
1007
+ return;
1008
+ }
1009
+ await onUserEntry(firstUser.id);
1010
+ }
1011
+ catch (error) {
1012
+ console.warn(`PiRpcRuntime: could not record this turn's Pi entry id for conversation ${conversationId} (${error instanceof Error ? error.message : "unknown error"}) - its message cannot be used as a revert boundary.`);
1013
+ }
1014
+ }
1015
+ /**
1016
+ * Reverts Pi's own transcript to the state right after `boundaryEntryId`.
1017
+ *
1018
+ * ============================ WHAT THIS DESTROYS ============================
1019
+ * Every Pi entry after that boundary, permanently - including tool calls, compaction records, model and
1020
+ * thinking-level markers, and usage accounting, not just the messages Super Pi shows. A copy of the file
1021
+ * as it was is left beside it as `<file>.super-pi-revert-backup`, which Pi never lists because it does
1022
+ * not end in `.jsonl`. There is no undo in the application.
1023
+ *
1024
+ * No workspace file is read, written or restored. That is the whole point of not reaching for OpenCode's
1025
+ * `snapshot/` machinery: its revert rolls the working tree back (`snapshot/index.ts:382-406`), this one
1026
+ * must not, and Super Pi has no reason to build a second git shadow repository to do damage.
1027
+ * =============================================================================
1028
+ *
1029
+ * WHY PI IS ASKED WHICH FILE TO OPEN, rather than this code scanning the session directory.
1030
+ *
1031
+ * The obvious implementation - read the directory, find the `*.jsonl` whose header id matches - is
1032
+ * exactly the one that fails silently, and probe B measured why:
1033
+ *
1034
+ * 1. When Pi cannot find the requested id it does NOT fail. It prints a warning to stderr and opens a
1035
+ * brand-new session under that same id (`main.ts:436-448`), so a wrong lookup becomes "the history
1036
+ * has vanished" with no error anywhere in the UI. Measured:
1037
+ * `Warning: No project session found with id '...'; creating a new session with that id.`
1038
+ * 2. Pi's lookup is not "match the id". With an explicit `--session-dir` it ALSO requires the session's
1039
+ * recorded `cwd` to equal the one it was asked from (`session-manager.ts:1874-1891`), and probe B
1040
+ * measured the same id resolving to two different files from two different working directories -
1041
+ * one of which did not exist on disk.
1042
+ *
1043
+ * So the file is taken from Pi's own `get_state.sessionFile` (`rpc-mode.ts:448-464`), which is the
1044
+ * lookup Pi performed for a real spawn, and then THREE things are checked before a byte is written:
1045
+ * the file exists, its header is the session header, and that header's id is the conversation being
1046
+ * reverted. A miss on any of them refuses, in words, rather than truncating something it is not sure of.
1047
+ *
1048
+ * The cost is one extra Pi spawn, which is why this is a deliberate trade rather than an oversight:
1049
+ * revert is a rare, deliberate act, and a second Pi answers in well under a second. It buys the
1050
+ * elimination of an entire class of "we truncated the wrong file and nothing said so" failures.
1051
+ */
1052
+ async revert({ conversation, cwd, boundaryEntryId }) {
1053
+ if (this.runs.has(conversation.id))
1054
+ throw new Error("这个会话还有一轮任务在跑,先停止它再回退");
1055
+ if (!this.pi.found)
1056
+ throw new Error(this.pi.reason);
1057
+ const childCwd = resolve(cwd);
1058
+ const info = await stat(childCwd).catch(() => undefined);
1059
+ if (!info?.isDirectory())
1060
+ throw new Error(`工作区目录不存在或不是目录,无法在其中找到这个会话的 Pi 记录:${childCwd}`);
1061
+ await mkdir(this.sessionDir, { recursive: true });
1062
+ // NO agent flags and NO extension on this spawn. It sends no prompt and must leave nothing behind:
1063
+ // `SessionManager._persist` only creates a file once a session holds a message
1064
+ // (`session-manager.ts:1160-1170`), so an answer to `get_state` cannot write anything. Adding the
1065
+ // task extension here would mount a tool whose every call fails, since no endpoint is wired up.
1066
+ const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", conversation.id], {
1067
+ cwd: childCwd,
1068
+ stdio: ["pipe", "pipe", "pipe"],
1069
+ shell: process.platform === "win32",
1070
+ });
1071
+ let sessionFile;
1072
+ try {
1073
+ const channel = new RpcChannel(child);
1074
+ const state = await channel.request({ id: "revert_state", type: "get_state" }, LOOKUP_COMMAND_TIMEOUT_MS);
1075
+ if (state.success === false)
1076
+ throw new Error(`Pi 没有回答会话状态:${state.error ?? "未说明原因"}`);
1077
+ const data = state.data;
1078
+ // Pi echoes the id back even when it has just INVENTED a session for it, so this catches a Pi that
1079
+ // did something else entirely rather than the ordinary "no history yet" case below.
1080
+ if (typeof data?.sessionId === "string" && data.sessionId !== conversation.id)
1081
+ throw new Error(`Pi 打开的会话是 ${JSON.stringify(data.sessionId)},不是要回退的 ${JSON.stringify(conversation.id)}。已放弃,没有改动任何文件。`);
1082
+ if (typeof data?.sessionFile === "string" && data.sessionFile)
1083
+ sessionFile = data.sessionFile;
1084
+ if (channel.stderr.trim())
1085
+ console.warn(`PiRpcRuntime: Pi said this while locating conversation ${conversation.id}: ${channel.stderr.trim()}`);
1086
+ }
1087
+ catch (error) {
1088
+ if (child.exitCode === null && child.pid !== undefined)
1089
+ await killTree(child.pid);
1090
+ await new Promise((resolve) => { child.once("exit", resolve); setTimeout(resolve, 5000); });
1091
+ throw error instanceof Error ? error : new Error(`无法向 Pi 询问会话文件:${String(error)}`);
1092
+ }
1093
+ // The lookup Pi is holding open is finished; it is killed BEFORE the file is touched, for the same
1094
+ // reason `answer()` kills before returning: a process still tearing down can still be appending.
1095
+ if (child.exitCode === null && child.pid !== undefined)
1096
+ await killTree(child.pid);
1097
+ await new Promise((resolve) => { child.once("exit", resolve); setTimeout(resolve, 5000); });
1098
+ if (!sessionFile)
1099
+ throw new Error("Pi 没有报告这个会话的记录文件,已放弃,没有改动任何文件。");
1100
+ const exists = await stat(sessionFile).catch(() => undefined);
1101
+ // Measured, not guessed: Pi answers `get_state` with the path it WOULD use even for a session it has
1102
+ // just created, and that path does not exist until a message has been sent
1103
+ // (`session-manager.ts:1160-1170`). So "reported but absent" is the ordinary reading of "this
1104
+ // conversation has no Pi history yet", and truncating nothing is the correct outcome - but it is
1105
+ // reported, because the alternative is a revert that appears to work and changes nothing.
1106
+ if (!exists?.isFile())
1107
+ throw new Error(`Pi 还没有为这个会话建立记录文件(${sessionFile}),没有可以回退的历史。`);
1108
+ const result = await truncateSessionFile(sessionFile, boundaryEntryId, conversation.id);
1109
+ return { sessionFile, linesKept: result.linesKept, entriesDropped: result.entriesDropped };
1110
+ }
1111
+ /**
1112
+ * Copies Pi's own transcript for one conversation into a second, independent file under a new id.
1113
+ *
1114
+ * THE SAME THREE STEPS AS REVERT, IN THE SAME ORDER, AND FOR THE SAME REASONS: refuse a live turn, ask Pi
1115
+ * which file it uses rather than scanning the directory (probe B measured that a directory scan resolves
1116
+ * the wrong file or a nonexistent one with no error, and that Pi invents an empty session under an id it
1117
+ * cannot find), kill that Pi BEFORE touching anything on disk, and check the file's header against the
1118
+ * conversation being forked before a byte is written. Every one of those paragraphs above still applies
1119
+ * here word for word, because it is the same machinery pointed the other way: revert cuts a file in
1120
+ * place, fork copies a file beside it.
1121
+ *
1122
+ * ============================ WHAT A FORK COPIES ============================
1123
+ * The file, byte for byte, with two header fields changed (`id`, `parentSession` - see `forkSessionFile`).
1124
+ * Entry ids are NOT renumbered, so every message recorded on the source keeps a `piEntryId` that is valid
1125
+ * in the fork too, which is what keeps REVERT working inside a forked conversation.
1126
+ * =============================================================================
1127
+ *
1128
+ * WHAT A FORK DOES NOT COPY, and both omissions are deliberate:
1129
+ *
1130
+ * - **Sub-agent runs.** `SubagentRun.conversationId` points at the conversation that dispatched it, and
1131
+ * research pitfall 7 is that truncating or duplicating a transcript leaves those records orphaned. A
1132
+ * forked conversation shows the messages but no child cards from before the fork: the children belong to
1133
+ * the conversation that started them, they are not listed anywhere once their parent has copied their
1134
+ * words, and a card in the fork that could not be stopped from the fork would be worse than no card.
1135
+ * - **Queued messages.** Same argument, and narrower: a waiting message belongs to the run in progress,
1136
+ * and a run is not copied.
1137
+ *
1138
+ * THE EMPTY-CONVERSATION CASE IS NOT AN ERROR. A conversation with no messages has no Pi file at all -
1139
+ * `SessionManager._persist` only creates one once a session holds a message (`:1160-1170`) - so there is
1140
+ * nothing to copy and the fork is a copy of `state.json` alone. That is checked BEFORE any Pi is spawned,
1141
+ * so forking a fresh conversation costs nothing and cannot fail. The converse is refused rather than
1142
+ * tolerated: messages in `state.json` with no Pi file means the two histories were ALREADY out of step,
1143
+ * and copying that into a second conversation would make the disagreement permanent and multiply it.
1144
+ */
1145
+ async fork({ conversation, cwd, forkedConversationId }) {
1146
+ if (this.runs.has(conversation.id))
1147
+ throw new Error("这个会话还有一轮任务在跑,先停止它再分叉");
1148
+ if (!conversation.messages.length)
1149
+ return { entriesCopied: 0 };
1150
+ if (!this.pi.found)
1151
+ throw new Error(this.pi.reason);
1152
+ const childCwd = resolve(cwd);
1153
+ const info = await stat(childCwd).catch(() => undefined);
1154
+ if (!info?.isDirectory())
1155
+ throw new Error(`工作区目录不存在或不是目录,无法在其中找到这个会话的 Pi 记录:${childCwd}`);
1156
+ await mkdir(this.sessionDir, { recursive: true });
1157
+ // The same lookup spawn as `revert`, and the same reason for leaving the agent flags off: this child
1158
+ // sends no prompt and must leave nothing behind.
1159
+ const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", conversation.id], {
1160
+ cwd: childCwd,
1161
+ stdio: ["pipe", "pipe", "pipe"],
1162
+ shell: process.platform === "win32",
1163
+ });
1164
+ let sessionFile;
1165
+ try {
1166
+ const channel = new RpcChannel(child);
1167
+ const state = await channel.request({ id: "fork_state", type: "get_state" }, LOOKUP_COMMAND_TIMEOUT_MS);
1168
+ if (state.success === false)
1169
+ throw new Error(`Pi 没有回答会话状态:${state.error ?? "未说明原因"}`);
1170
+ const data = state.data;
1171
+ if (typeof data?.sessionId === "string" && data.sessionId !== conversation.id)
1172
+ throw new Error(`Pi 打开的会话是 ${JSON.stringify(data.sessionId)},不是要分叉的 ${JSON.stringify(conversation.id)}。已放弃,没有复制任何文件。`);
1173
+ if (typeof data?.sessionFile === "string" && data.sessionFile)
1174
+ sessionFile = data.sessionFile;
1175
+ if (channel.stderr.trim())
1176
+ console.warn(`PiRpcRuntime: Pi said this while locating conversation ${conversation.id} for a fork: ${channel.stderr.trim()}`);
1177
+ }
1178
+ catch (error) {
1179
+ if (child.exitCode === null && child.pid !== undefined)
1180
+ await killTree(child.pid);
1181
+ await new Promise((resolve) => { child.once("exit", resolve); setTimeout(resolve, 5000); });
1182
+ throw error instanceof Error ? error : new Error(`无法向 Pi 询问会话文件:${String(error)}`);
1183
+ }
1184
+ if (child.exitCode === null && child.pid !== undefined)
1185
+ await killTree(child.pid);
1186
+ await new Promise((resolve) => { child.once("exit", resolve); setTimeout(resolve, 5000); });
1187
+ if (!sessionFile)
1188
+ throw new Error("Pi 没有报告这个会话的记录文件,已放弃,没有复制任何文件。");
1189
+ const exists = await stat(sessionFile).catch(() => undefined);
1190
+ // Reached only when `state.json` HAS messages, so "reported but absent" cannot be the ordinary
1191
+ // no-history-yet reading here - it means the two files have already diverged, and the refusal says so.
1192
+ if (!exists?.isFile())
1193
+ throw new Error(`这个会话在 Super Pi 里有 ${conversation.messages.length} 条消息,但 Pi 的记录文件不存在(${sessionFile})。两份记录已经不一致,拒绝分叉——否则新会话会带着一份对不上的历史出生。`);
1194
+ // Named after the new id, exactly the way Pi names its own (`${timestamp}_${sessionId}.jsonl`,
1195
+ // `session-manager.ts:1673`), so a directory listing sorts and reads the way Pi's would. The timestamp
1196
+ // is the fork's, not the source's: the file header keeps the SOURCE timestamp verbatim (it is history),
1197
+ // while the filename carries when this copy was made, which is what makes the ordering of a directory
1198
+ // full of forks mean anything.
1199
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
1200
+ const target = join(this.sessionDir, `${stamp}_${forkedConversationId}.jsonl`);
1201
+ const result = await forkSessionFile(sessionFile, target, conversation.id, forkedConversationId);
1202
+ return { sessionFile: target, entriesCopied: result.entriesCopied };
1203
+ }
1204
+ /**
1205
+ * Delivers one Extension UI request. One-way methods are pushed to the host and never replied to
1206
+ * (Pi holds no pending entry, so a response would resolve nothing). Blocking methods are handed to
1207
+ * the host and this call is awaited, so Pi stays blocked exactly as long as the human takes.
1208
+ */
1209
+ async handleExtensionUi(record, child, host, run) {
1210
+ const request = describeExtensionUiRequest(record);
1211
+ if (!request) {
1212
+ console.warn(`PiRpcRuntime: extension_ui_request method=${JSON.stringify(record.method ?? null)} id=${JSON.stringify(record.id ?? null)} is not one of Pi's nine Extension UI methods - consumed without reply.`);
1213
+ return;
1214
+ }
1215
+ const reply = (response) => { if (child.killed || child.stdin.destroyed)
1216
+ return; child.stdin.write(`${JSON.stringify(response)}\n`); };
1217
+ if (!request.blocking) {
1218
+ // Deliberately not awaited: these five are fire-and-forget in Pi, and awaiting would stall the
1219
+ // drain loop on a UI that already told us it wants no reply.
1220
+ try {
1221
+ host?.notify(request);
1222
+ }
1223
+ catch (error) {
1224
+ console.warn(`PiRpcRuntime: extension_ui notify for ${request.method} id=${request.requestId} threw in the host: ${error instanceof Error ? error.message : "unknown error"}`);
1225
+ }
1226
+ return;
1227
+ }
1228
+ if (run.seen.has(request.requestId)) {
1229
+ console.warn(`PiRpcRuntime: extension_ui_request method=${request.method} id=${request.requestId} arrived twice - second copy consumed without reply.`);
1230
+ return;
1231
+ }
1232
+ run.seen.add(request.requestId);
1233
+ if (!host) {
1234
+ // No human can be asked, so the honest reply is "cancelled". Auto-answering with options[0] or
1235
+ // confirmed:false would look to Pi exactly like a decision, and the user would never learn a
1236
+ // question was asked of them.
1237
+ console.warn(`PiRpcRuntime: extension_ui_request method=${request.method} id=${request.requestId} has no UI host, so nobody can answer it - cancelled rather than answering on the user's behalf.`);
1238
+ reply({ type: "extension_ui_response", id: request.requestId, cancelled: true });
1239
+ return;
1240
+ }
1241
+ const cancelled = { cancelled: true };
1242
+ let answer;
1243
+ try {
1244
+ answer = await Promise.race([host.ask(request), run.aborted.then(() => cancelled), run.exited.then(() => cancelled)]);
1245
+ }
1246
+ catch (error) {
1247
+ console.warn(`PiRpcRuntime: extension_ui_request method=${request.method} id=${request.requestId} host failed (${error instanceof Error ? error.message : "unknown error"}) - cancelled.`);
1248
+ answer = cancelled;
1249
+ }
1250
+ reply(buildExtensionUiResponse(request, answer));
1251
+ }
1252
+ async stop(conversationId) { const run = this.runs.get(conversationId); if (run && !run.child.killed)
1253
+ run.child.stdin.write(`${JSON.stringify({ id: "abort", type: "abort" })}\n`); }
1254
+ /**
1255
+ * Injects one message into the Pi process that is running right now, at its next turn boundary.
1256
+ *
1257
+ * `steer` and NOT `follow_up`, and NOT `prompt` with a `streamingBehavior`. The three are not
1258
+ * interchangeable and the difference is the whole feature:
1259
+ *
1260
+ * - `steer` is drained at `agent-loop.ts:295`, after `turn_end` and therefore after the current
1261
+ * assistant message's WHOLE tool-call batch has finished, before the next LLM request. The agent
1262
+ * stops doing what it was doing at the next boundary instead of at the end of the task. That is
1263
+ * "插队", and its granularity is a tool BATCH - a long single tool call is not interruptible,
1264
+ * because Pi offers no preemption.
1265
+ * - `follow_up` is drained at `agent-loop.ts:302`, only once the agent has stopped on its own.
1266
+ * - `prompt` would start a turn, which is the thing that must NOT happen here: this conversation
1267
+ * already has a turn in flight, and starting a second one is the two-writers-one-session bug.
1268
+ *
1269
+ * NO `set_follow_up_mode` / `set_steering_mode` IS EVER SENT, and that is a hard rule rather than a
1270
+ * default. Both go through `AgentSession.set*Mode`, which mutates `SettingsManager`'s global scope and
1271
+ * calls `save()` (`settings-manager.ts:857-871`), writing to the user's Pi `settings.json`. A host that
1272
+ * merely wanted a different queue depth would permanently rewrite the user's Pi configuration. Pi's
1273
+ * default is already `one-at-a-time` (`agent.ts:247-248`), which is the semantics this wants: one
1274
+ * queued message consumed per boundary.
1275
+ *
1276
+ * REJECTS rather than reporting success when there is no live child, and when Pi does not answer in
1277
+ * time. The caller keeps the message cancellable in the first case because it provably reached no
1278
+ * model; in the second the outcome is genuinely unknown and a `sending` state says so.
1279
+ */
1280
+ async steer(conversationId, message, images) {
1281
+ const run = this.runs.get(conversationId);
1282
+ if (!run)
1283
+ throw new Error(`会话 ${conversationId} 现在没有正在运行的 Pi 进程,无法插队;这条消息仍在队列里,可以等下一轮或取消。`);
1284
+ if (run.child.exitCode !== null || run.child.killed || run.child.stdin.destroyed)
1285
+ throw new Error(`会话 ${conversationId} 的 Pi 进程已经结束,无法插队;这条消息仍在队列里。`);
1286
+ const id = `steer_${++this.steerSeq}`;
1287
+ const imagePayloads = this.options.uploadsDir && images?.length
1288
+ ? await loadConversationImages(this.options.uploadsDir, conversationId, images)
1289
+ : undefined;
1290
+ const answer = new Promise((resolve, reject) => {
1291
+ const timer = setTimeout(() => {
1292
+ this.pendingSteers.delete(id);
1293
+ // NOT rolled back to `queued` here, and the distinction matters. The text is already in Pi's
1294
+ // stdin; Pi may well deliver it at the next boundary, and reporting it as never sent would make
1295
+ // the user see a message they can cancel - and then watch the model act on it anyway.
1296
+ reject(new Error(`Pi 在 ${STEER_COMMAND_TIMEOUT_MS}ms 内没有确认这条插队消息,它可能仍会在下一个工具调用结束时送达;请等这一轮结束再看。`));
1297
+ }, STEER_COMMAND_TIMEOUT_MS);
1298
+ timer.unref();
1299
+ this.pendingSteers.set(id, {
1300
+ resolve: (disposition) => { clearTimeout(timer); resolve(disposition); },
1301
+ reject: (error) => { clearTimeout(timer); reject(error); },
1302
+ });
1303
+ });
1304
+ try {
1305
+ run.channel.write({ id, type: "steer", message, ...(imagePayloads?.length ? { images: imagePayloads } : {}) });
1306
+ }
1307
+ catch (error) {
1308
+ // `write` throws when the pipe is already gone. Settle and unregister here rather than letting a
1309
+ // timeout report a broken stdin ten seconds later as an unexplained silence.
1310
+ const pending = this.pendingSteers.get(id);
1311
+ this.pendingSteers.delete(id);
1312
+ pending?.reject(error instanceof Error ? error : new Error(String(error)));
1313
+ }
1314
+ return answer;
1315
+ }
1316
+ /**
1317
+ * The model/thinking catalog, from Pi, cached.
1318
+ *
1319
+ * Three layers, because a picker opens far more often than Pi can be asked: a TTL cache keyed by
1320
+ * the selection (10 minutes for a good answer, 30 seconds for a failure), an in-flight map so
1321
+ * concurrent callers share one spawn, and - because each read spawns a short-lived Pi that is
1322
+ * killed as soon as it has answered - no long-lived process to keep warm.
1323
+ */
1324
+ async catalog(selection) {
1325
+ const key = catalogKey(selection);
1326
+ const cached = this.catalogs.get(key);
1327
+ const ttl = cached?.value.source === "pi" ? CATALOG_TTL_MS : CATALOG_FAILURE_TTL_MS;
1328
+ if (cached && Date.now() - cached.at < ttl)
1329
+ return cached.value;
1330
+ const inFlight = this.pendingCatalogs.get(key);
1331
+ if (inFlight)
1332
+ return inFlight;
1333
+ const pending = this.readCatalog(selection, key).finally(() => this.pendingCatalogs.delete(key));
1334
+ this.pendingCatalogs.set(key, pending);
1335
+ return pending;
1336
+ }
1337
+ /**
1338
+ * Asks one throwaway Pi for its model list, its thinking levels and its own current selection.
1339
+ *
1340
+ * The three commands are written back to back and only then awaited, so Pi works on them in
1341
+ * parallel - measured at 394ms for all three, against 714ms for the slowest one alone. A Pi
1342
+ * scoped to one conversation's model needs `set_model` FIRST and alone, because thinking levels are
1343
+ * a property of the selected model, not of Pi's default.
1344
+ */
1345
+ async readCatalog(selection, key) {
1346
+ const unavailable = (error) => {
1347
+ console.warn(`PiRpcRuntime: catalog for ${key} could not be read: ${error}`);
1348
+ return { source: "unavailable", models: [], levels: [], fetchedAt: new Date().toISOString(), error };
1349
+ };
1350
+ // Catalog probes get their OWN session directory and a fixed session id: they must never write
1351
+ // into a conversation's transcript, and a fixed id means a refresh reuses one file instead of
1352
+ // leaving a new one behind on every model switch.
1353
+ const directory = join(this.sessionDir, "catalog");
1354
+ await mkdir(directory, { recursive: true });
1355
+ // Same resolution, same refusal, one reason: a picker that cannot be filled because Pi is not
1356
+ // installed must say so in the picker's own error, not report the spawn's ENOENT as Pi's answer.
1357
+ if (!this.pi.found)
1358
+ return unavailable(this.pi.reason);
1359
+ let child;
1360
+ try {
1361
+ child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", directory, "--session-id", "catalog"], { cwd: directory, stdio: ["pipe", "pipe", "pipe"], shell: process.platform === "win32" });
1362
+ }
1363
+ catch (error) {
1364
+ return unavailable(`could not spawn Pi: ${error instanceof Error ? error.message : "unknown error"}`);
1365
+ }
1366
+ try {
1367
+ const channel = new RpcChannel(child);
1368
+ if (selection?.provider && selection.modelId) {
1369
+ const applied = await channel.request({ id: "catalog_set_model", type: "set_model", provider: selection.provider, modelId: selection.modelId }, CATALOG_COMMAND_TIMEOUT_MS);
1370
+ if (applied.success === false)
1371
+ throw new Error(applied.error ?? "Pi rejected set_model");
1372
+ }
1373
+ channel.write({ id: "catalog_models", type: "get_available_models" });
1374
+ channel.write({ id: "catalog_levels", type: "get_available_thinking_levels" });
1375
+ channel.write({ id: "catalog_state", type: "get_state" });
1376
+ const models = await channel.waitFor("catalog_models", CATALOG_COMMAND_TIMEOUT_MS, "get_available_models");
1377
+ const levels = await channel.waitFor("catalog_levels", CATALOG_COMMAND_TIMEOUT_MS, "get_available_thinking_levels");
1378
+ const state = await channel.waitFor("catalog_state", CATALOG_COMMAND_TIMEOUT_MS, "get_state");
1379
+ if (models.success === false)
1380
+ throw new Error(models.error ?? "Pi rejected get_available_models");
1381
+ if (levels.success === false)
1382
+ throw new Error(levels.error ?? "Pi rejected get_available_thinking_levels");
1383
+ const options = describeModels(models.data?.models);
1384
+ const levelNames = textList(levels.data?.levels) ?? [];
1385
+ const current = describeModel(state.data?.model);
1386
+ // Narrowed to the three fields ModelSelection declares, because a default is only ever read back
1387
+ // as a button label - the per-model capability fields belong to the list entries, not here.
1388
+ const pick = (model) => (model ? { provider: model.provider, modelId: model.modelId, label: model.label } : undefined);
1389
+ const value = {
1390
+ source: "pi",
1391
+ models: options,
1392
+ levels: levelNames,
1393
+ // Pi's own answer wins. The fallbacks only fire if get_state is missing the model, and they
1394
+ // fall back to Pi's OWN ordering rather than to anything invented here.
1395
+ defaultModel: pick(current) ?? pick(options[0]),
1396
+ defaultThinkingLevel: text(state.data?.thinkingLevel) ?? levelNames[0],
1397
+ fetchedAt: new Date().toISOString(),
1398
+ };
1399
+ if (!value.models.length)
1400
+ return unavailable(`Pi returned no usable models: ${JSON.stringify(models.data?.models)?.slice(0, 200)}`);
1401
+ this.catalogs.set(key, { at: Date.now(), value });
1402
+ return value;
1403
+ }
1404
+ catch (error) {
1405
+ const message = error instanceof Error ? error.message : "unknown error";
1406
+ const failed = unavailable(message);
1407
+ this.catalogs.set(key, { at: Date.now(), value: failed });
1408
+ return failed;
1409
+ }
1410
+ finally {
1411
+ // Same rule as answer(): the child is not gone until it is actually gone, because a process
1412
+ // still tearing down still holds a lock on this directory.
1413
+ //
1414
+ // THE 5s BACKSTOP IS NOT OPTIONAL, and the same one already appears twice in this file. The kill
1415
+ // happens first and the `exit` subscription second, so an `exit` that fires in that window is
1416
+ // emitted to nobody and dropped by the EventEmitter - `exitCode` is then updated, but the promise
1417
+ // below never settles. Without the timer that hangs the whole catalog read, so `catalog()` never
1418
+ // returns, so the `workspace.settings` handler never reaches its persist: the 保存 dialog closes
1419
+ // and the setting is silently lost, with no error frame and nothing in the server log. Observed
1420
+ // under load in scripts/workspace-settings-browser.test.mjs, which timed out at 60s with an
1421
+ // accepted command, an empty error channel and an untouched state.json.
1422
+ if (child.exitCode === null && child.pid !== undefined) {
1423
+ if (!child.killed) {
1424
+ if (process.platform === "win32")
1425
+ await killTree(child.pid);
1426
+ else
1427
+ child.kill();
1428
+ }
1429
+ if (child.exitCode === null)
1430
+ await new Promise((resolve) => { child.once("exit", resolve); setTimeout(resolve, 5000); });
1431
+ }
1432
+ }
1433
+ }
1434
+ }
1435
+ /**
1436
+ * Normalizes Pi's `Model[]`, keeping only entries that can actually be sent back as a `set_model`.
1437
+ * Exported so the mapping is testable without a Pi process.
1438
+ *
1439
+ * `imageInput` reads Pi's `input` array, which is where image support is actually declared -
1440
+ * `BaseModel.input: ("text" | "image")[]` at packages/ai/src/types.ts:1124, and Pi's own CLI prints
1441
+ * the very same test as its "images" column (cli/list-models.ts:71, `m.input.includes("image")`).
1442
+ * Copying Pi's predicate rather than inventing one is the point: if Pi ever renames the value or
1443
+ * adds a third modality, Pi's column and this badge change together.
1444
+ *
1445
+ * A MISSING OR MALFORMED `input` READS AS FALSE, and that direction is deliberate. The field's
1446
+ * failure mode is a silent one in both directions - Pi's RPC reader does no schema validation
1447
+ * (rpc-mode.ts:750-783) - so a model with no `input` at all could be a Pi too old to have the
1448
+ * concept, and answering "supports images" for it would invite a picture the model cannot see.
1449
+ * Announcing a capability that turns out to be absent costs the user a confusing turn; declining to
1450
+ * announce one that is present costs them an unlabelled model. Only the first is worth guessing on.
1451
+ */
1452
+ export function describeModel(value) {
1453
+ if (!value || typeof value !== "object")
1454
+ return undefined;
1455
+ const model = value;
1456
+ if (typeof model.provider !== "string" || model.provider === "" || typeof model.id !== "string" || model.id === "")
1457
+ return undefined;
1458
+ const label = typeof model.name === "string" && model.name !== "" ? model.name : model.id;
1459
+ // Anything that is not an array of strings is treated as absent rather than searched with
1460
+ // `.includes`, which would throw on a string. Pi's own union makes this unreachable today; the
1461
+ // check is here because this runs against a field read off the wire from a process that does no
1462
+ // validation of its own.
1463
+ const input = Array.isArray(model.input) ? model.input.filter((value) => typeof value === "string") : [];
1464
+ return { provider: model.provider, modelId: model.id, label, detail: `${model.provider}/${model.id}`, reasoning: model.reasoning === true, contextWindow: typeof model.contextWindow === "number" ? model.contextWindow : 0, imageInput: input.includes("image") };
1465
+ }
1466
+ export function describeModels(value) {
1467
+ if (!Array.isArray(value))
1468
+ return [];
1469
+ return value.map(describeModel).filter((model) => model !== undefined);
1470
+ }