@evandu/super-pi 0.1.70 → 0.1.71

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.
@@ -1,7 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { RpcChannel } from "./rpc-channel.js";
3
+ export { RpcChannel } from "./rpc-channel.js";
2
4
  import { randomUUID } from "node:crypto";
3
5
  import { existsSync } from "node:fs";
4
- import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
6
+ import { mkdir, stat } from "node:fs/promises";
7
+ import { forkSessionFile, truncateSessionFile } from "./session-repository.js";
8
+ export { forkSessionFile, truncateSessionFile } from "./session-repository.js";
5
9
  import { resolve, join } from "node:path";
6
10
  import { agentSpawnArgs, readAgents } from "./agents.js";
7
11
  import { TASK_CAPABILITY_ENV, mintCapability, releaseCapability } from "./capability.js";
@@ -10,6 +14,11 @@ import { TASK_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_EXEC_END
10
14
  import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
11
15
  import { loadConversationImages } from "./upload.js";
12
16
  import { describeToolCall, describeToolCallArgs, describeToolCallInProgress, extractPartialResultText, extractToolResultText, readQuestionCall } from "./tool-calls.js";
17
+ const traceLatency = (conversationId, phase, startedAt, extra = "") => {
18
+ if (process.env.SUPER_PI_TRACE_LATENCY !== "1")
19
+ return;
20
+ console.warn(`[super-pi latency] conversation=${conversationId} phase=${phase} elapsedMs=${Math.round(performance.now() - startedAt)}${extra ? ` ${extra}` : ""}`);
21
+ };
13
22
  /** A steer that provably never reached Pi; the caller may safely use the next turn instead. */
14
23
  export class SteerUnavailableError extends Error {
15
24
  }
@@ -33,6 +42,15 @@ export class WelcomeRuntime {
33
42
  return { source: "unavailable", models: [], levels: [], fetchedAt: new Date().toISOString(), error: "Welcome runtime has no Pi session to ask" };
34
43
  }
35
44
  }
45
+ /**
46
+ * One record off Pi's JSONL stdout. Deliberately a loose shape with optional fields: Pi emits a wide,
47
+ * open-ended family of session events, and a type that enumerated all of them would break on the next
48
+ * Pi release rather than on the next behaviour change.
49
+ *
50
+ * Exported so another consumer of `RpcChannel` - the sub-agent supervisor - can read the same records
51
+ * through the same channel instead of writing a second JSONL reader. Every field below is optional, so
52
+ * a reader must check before using one.
53
+ */
36
54
  /**
37
55
  * Reads Pi's `queue_update` payload, or returns undefined when the record is not one.
38
56
  *
@@ -113,111 +131,6 @@ export function buildExtensionUiResponse(request, answer) {
113
131
  return { type: "extension_ui_response", id: request.requestId, confirmed: answer.confirmed === true };
114
132
  return { type: "extension_ui_response", id: request.requestId, value: answer.value ?? "" };
115
133
  }
116
- /** One Pi child, its stdout reader, and the single writer to its stdin. */
117
- export class RpcChannel {
118
- child;
119
- records = [];
120
- /**
121
- * Responses a drain loop pulled off the queue before the waiter that wanted them looked.
122
- *
123
- * THE RACE THIS EXISTS TO SETTLE, and it is not a rare one - it cost this file an intermittent test
124
- * failure. One queue serves two kinds of reader: `waitFor`, which is after a specific command's answer by
125
- * id, and `next`, which is a turn's drain loop reading whatever arrives first. The loop does not know
126
- * which ids someone is waiting for, so it used to take the first record of any kind - including the answer
127
- * to a `get_entries` written moments earlier - and the waiter then waited out its whole timeout for a
128
- * response that had already been consumed and discarded.
129
- *
130
- * So the drain loop no longer decides an unmatched response is garbage: it HANDS IT BACK here, and the
131
- * waiter finds it whether it got there first or second. Nothing is dropped, and nothing is waited for
132
- * twice.
133
- */
134
- stashed = new Map();
135
- buffer = "";
136
- /** stderr is kept only to explain an early exit; a well-behaved Pi writes nothing here. */
137
- stderr = "";
138
- constructor(child) {
139
- this.child = child;
140
- child.stderr.on("data", (chunk) => { this.stderr += chunk.toString(); });
141
- child.stdout.on("data", (chunk) => {
142
- this.buffer += chunk.toString("utf8");
143
- let newline;
144
- while ((newline = this.buffer.indexOf("\n")) >= 0) {
145
- const line = this.buffer.slice(0, newline).replace(/\r$/, "");
146
- this.buffer = this.buffer.slice(newline + 1);
147
- if (!line)
148
- continue;
149
- try {
150
- this.records.push(JSON.parse(line));
151
- }
152
- catch { /* Pi stdout must be JSONL */ }
153
- }
154
- });
155
- }
156
- get exitCode() { return this.child.exitCode; }
157
- /** Writes one JSONL command. Throws rather than silently dropping: a command Pi never sees is a lie the caller would act on. */
158
- write(command) {
159
- if (this.child.killed || this.child.stdin.destroyed)
160
- throw new Error(`Pi RPC stdin is closed, so ${JSON.stringify(command)} was not sent`);
161
- this.child.stdin.write(`${JSON.stringify(command)}\n`);
162
- }
163
- /**
164
- * Returns one record to whoever is waiting for it, after a drain loop read it.
165
- *
166
- * Called for every response the loop does not consume itself. See `RpcChannel`'s note on `stashed` for why
167
- * this is not the same as discarding it - discarding it silently cost a turn its revert boundary.
168
- */
169
- stash(record) { if (record.type === "response" && typeof record.id === "string")
170
- this.stashed.set(record.id, record); }
171
- /** Removes and returns the next record, or undefined after `timeoutMs` with nothing to read. */
172
- async next(timeoutMs = 100) {
173
- const deadline = Date.now() + timeoutMs;
174
- for (;;) {
175
- if (this.records.length)
176
- return this.records.shift();
177
- if (Date.now() >= deadline)
178
- return undefined;
179
- await new Promise((resolve) => setTimeout(resolve, 10));
180
- }
181
- }
182
- /** Sends a command and waits for its response. For one command; use write()+waitFor() to overlap several. */
183
- async request(command, timeoutMs) {
184
- this.write(command);
185
- return this.waitFor(command.id, timeoutMs, command.type);
186
- }
187
- /**
188
- * Returns one response by id, from the queue if it is still there and from the stash if a drain loop got
189
- * there first. Removing it from the stash is what keeps a single answer from being handed out twice.
190
- */
191
- take(id) {
192
- const index = this.records.findIndex((record) => record.type === "response" && record.id === id);
193
- if (index >= 0)
194
- return this.records.splice(index, 1)[0];
195
- const stashed = this.stashed.get(id);
196
- if (!stashed)
197
- return undefined;
198
- this.stashed.delete(id);
199
- return stashed;
200
- }
201
- /**
202
- * Waits for the response to one command id. Every record that is not that response stays QUEUED in
203
- * order, so a caller can have three requests in flight and still read all three answers: whoever
204
- * asks first takes its own record and leaves the rest for the drain loop.
205
- */
206
- async waitFor(id, timeoutMs, what = id) {
207
- const deadline = Date.now() + timeoutMs;
208
- for (;;) {
209
- const record = this.take(id);
210
- if (record)
211
- return record;
212
- // A dead child will never answer, so waiting out the full deadline would only delay the failure.
213
- if (this.child.exitCode !== null)
214
- throw new Error(`Pi RPC exited (code ${this.child.exitCode}) while waiting for ${what}. ${this.stderr.trim()}`);
215
- if (Date.now() >= deadline)
216
- throw new Error(`Pi did not answer ${what} within ${timeoutMs}ms`);
217
- await new Promise((resolve) => setTimeout(resolve, 10));
218
- }
219
- }
220
- }
221
134
  /**
222
135
  * The commands a freshly spawned Pi must be told before it runs a conversation's turn.
223
136
  *
@@ -257,250 +170,7 @@ export function killTree(pid) {
257
170
  killer.once("error", () => resolve());
258
171
  });
259
172
  }
260
- /**
261
- * Cuts a Pi session file down to the header plus everything up to and including `boundaryEntryId`.
262
- *
263
- * ============================ WHAT THIS DESTROYS ============================
264
- * **Every entry written after the boundary is gone from disk, permanently, and there is no way to
265
- * reconstruct it from this file.** That includes not only the messages a person can see but also Pi's
266
- * `tool_call` records, `compaction` summaries, `model_change` and `thinking_level_change` markers, usage
267
- * accounting, and anything a future Pi version adds. This is the step the whole revert design is built
268
- * around (research §4.2) and the step that cannot be undone by the application - Super Pi has no undo,
269
- * and adding one was explicitly left out of scope.
270
- *
271
- * It does NOT touch the workspace. No file the agent read or wrote is read, written or reverted here.
272
- * =============================================================================
273
- *
274
- * WHY TRUNCATE RATHER THAN ASK PI. Pi's `SessionManager` is append-only by design
275
- * (`session-manager.ts:1516-1519`: "Entries cannot be modified or deleted"), and its in-session rewind -
276
- * `branch()`, `navigateTree()` - is IN-MEMORY ONLY (`:1573-1584`): `_buildIndex()` re-reads the whole
277
- * file on every `open()` and sets the leaf to the last line unconditionally (`:1103-1122`). Super Pi
278
- * spawns one Pi per turn and kills it afterwards, so a leaf pointer set in one turn is gone before the
279
- * next one starts - a "call `branch()` once" solution would look correct for a turn and then silently
280
- * forget (research §5 pitfall 2). Truncating the file is the only version of this that is a durable state
281
- * change rather than per-turn bookkeeping that can be skipped exactly once.
282
- *
283
- * WHY THE FORMAT IS ASSUMED RATHER THAN GUESSED. The shape this relies on - line 0 is the one and only
284
- * `type: "session"` header, every other line is one self-contained JSON entry, every entry's `parentId`
285
- * already appears earlier in the file, no blank lines, and a trailing newline - was measured against
286
- * real Pi by scripts/probe-revert-session-format.mjs, which also proves the `parentId` property that
287
- * makes a prefix a VALID session rather than a fragment. That probe is the reason this function may
288
- * index by line at all; every one of those claims is re-checked here before anything is written, so a
289
- * Pi that changed the format fails loudly instead of producing a file Pi cannot open.
290
- *
291
- * WHY THE WRITE IS TEMP-THEN-RENAME. Pi appends with a bare `appendFileSync`
292
- * (`session-manager.ts:1187`) and makes no atomicity guarantee of its own. Writing the truncated copy
293
- * under a different name and renaming it over the original means a reader never observes a half-written
294
- * file, and a failure before the rename leaves the original untouched.
295
- *
296
- * WHY A BACKUP IS KEPT. `.super-pi-revert-backup` is written next to the session file, deliberately not
297
- * named `*.jsonl` so Pi's own discovery (`readdirSync` filtered on that suffix,
298
- * `session-manager.ts:1881`) never lists it, and deliberately NOT surfaced anywhere as a feature:
299
- * Super Pi has no undo and does not pretend to. It exists so that an irreversible, unrecoverable-by-code
300
- * operation on a person's conversation history is at least recoverable by hand.
301
- *
302
- * EXPORTED so it can be tested against a fabricated session file in a temp directory, without Pi and
303
- * without touching anybody's real transcript.
304
- */
305
- export async function truncateSessionFile(path, boundaryEntryId, expectedSessionId, beforeBoundary = false) {
306
- const raw = await readFile(path, "utf8");
307
- // Split, then drop the empty segment a trailing newline leaves behind. Probe A measured that the file
308
- // DOES end with a newline, so `split` yields one empty tail element; keeping it would append a blank
309
- // line to the rewritten file and undo the property the probe just established.
310
- const lines = raw.split("\n");
311
- if (lines.at(-1) === "")
312
- lines.pop();
313
- if (!lines.length)
314
- throw new Error(`Pi 的会话文件是空的,拒绝回退:${path}`);
315
- const parsed = lines.map((line) => {
316
- try {
317
- return JSON.parse(line);
318
- }
319
- catch {
320
- return undefined;
321
- }
322
- });
323
- // Every claim probe A made, re-checked here because a Pi upgrade may invalidate any of them. Each
324
- // failure names the shape that was actually found, because "revert failed" alone sends the reader
325
- // looking for their own data instead of at the version mismatch that caused it.
326
- // Checked BEFORE the JSON check, so the refusal names the actual defect: a blank line reads as "this
327
- // file has a shape my line arithmetic cannot trust", which is what it is, and a bare "not valid JSON"
328
- // would send the reader looking for a corrupted file instead.
329
- const blank = lines.findIndex((line) => line.trim() === "");
330
- if (blank >= 0)
331
- throw new Error(`Pi 的会话文件第 ${blank} 行是空行,行号与 entry 号不再一一对应,拒绝回退:${path}`);
332
- const unparseable = parsed.findIndex((entry) => entry === undefined);
333
- if (unparseable >= 0)
334
- throw new Error(`Pi 的会话文件第 ${unparseable} 行不是合法 JSON,这个文件的格式已经变了,拒绝回退以免损坏它:${path}`);
335
- const headerLines = parsed.filter((entry) => entry?.type === "session");
336
- if (headerLines.length !== 1)
337
- throw new Error(`Pi 的会话文件里有 ${headerLines.length} 个 header(应为 1 个),格式已经变了,拒绝回退:${path}`);
338
- if (parsed[0]?.type !== "session")
339
- throw new Error(`Pi 的会话文件第一行不是 header(实际 type=${JSON.stringify(parsed[0]?.type ?? null)}),格式已经变了,拒绝回退:${path}`);
340
- if (parsed[0]?.id !== expectedSessionId)
341
- throw new Error(`Pi 的会话文件属于会话 ${JSON.stringify(parsed[0]?.id ?? null)},不是要回退的会话 ${JSON.stringify(expectedSessionId)}。拒绝截断别人的会话。`);
342
- if (!raw.endsWith("\n"))
343
- throw new Error(`Pi 的会话文件末尾没有换行,格式已经变了,拒绝回退:${path}`);
344
- let boundary = boundaryEntryId === "" ? 0 : parsed.findIndex((entry, index) => index > 0 && entry?.id === boundaryEntryId);
345
- if (boundary < 0)
346
- throw new Error(`在这份 Pi 会话记录里找不到要保留的那条消息(entry ${JSON.stringify(boundaryEntryId)})。这条消息可能是更早的版本写的,Super Pi 当时没有记下它对应 Pi 的哪一条。`);
347
- const expectedLastId = beforeBoundary ? parsed[boundary - 1]?.id : parsed[boundary]?.id;
348
- if (beforeBoundary)
349
- boundary -= 1;
350
- // Fail closed: a boundary of -1 here means "revert to before the FIRST entry", which would write an
351
- // empty session file. The verification below would pass vacuously (0 !== 0 is false), so the refusal
352
- // must happen here rather than be trusted to a check that cannot fire.
353
- if (boundary < 0)
354
- throw new Error(`回退点在第一条消息之前,截断会得到一个空会话文件,拒绝回退:${path}`);
355
- // The `parentId` property probe A measured, enforced at the boundary that matters: every entry still
356
- // being KEPT must point at something also being kept. A prefix that violates this is not a session,
357
- // and writing it would produce a file Pi either rejects or silently misreads.
358
- const kept = new Set();
359
- for (let index = 0; index <= boundary; index += 1) {
360
- const parent = parsed[index]?.parentId;
361
- if (parent !== undefined && parent !== null && !kept.has(parent))
362
- throw new Error(`要保留的部分不是自洽的:第 ${index} 行引用了不在保留范围内的 ${JSON.stringify(parent)},拒绝回退以免产出 Pi 打不开的会话。`);
363
- kept.add(parsed[index]?.id);
364
- }
365
- const rewritten = `${lines.slice(0, boundary + 1).join("\n")}\n`;
366
- const backupPath = `${path}.super-pi-revert-backup`;
367
- const temporaryPath = `${path}.super-pi-revert-tmp`;
368
- await writeFile(backupPath, raw, "utf8");
369
- await writeFile(temporaryPath, rewritten, "utf8");
370
- await rename(temporaryPath, path);
371
- // Re-read rather than trust the write. The claim this whole mechanism rests on is "the file's last
372
- // line is now the boundary entry", and `_buildIndex` derives the leaf from exactly that line - so if
373
- // the write did not land, nothing downstream would say so until a turn produced wrong context.
374
- const verified = (await readFile(path, "utf8")).split("\n").filter((line) => line !== "");
375
- const last = verified[verified.length - 1];
376
- let lastEntry;
377
- try {
378
- lastEntry = JSON.parse(last);
379
- }
380
- catch {
381
- lastEntry = undefined;
382
- }
383
- if (verified.length !== boundary + 1 || lastEntry?.id !== expectedLastId || boundary + 1 < 1) {
384
- throw new Error(`截断后的 Pi 会话文件没有以要保留的那条消息结尾(写到 ${verified.length} 行,期望 ${boundary + 1} 行,末行 id=${JSON.stringify(lastEntry?.id ?? null)})。原始文件仍在 ${backupPath}。`);
385
- }
386
- return { linesKept: boundary + 1, entriesDropped: lines.length - (boundary + 1) };
387
- }
388
- /**
389
- * Writes a second Pi session file that begins as an EXACT copy of one, under a different session id.
390
- *
391
- * ============================ WHAT THIS DOES TO THE ORIGINAL ============================
392
- * **Nothing.** The source file is read and never written, never truncated, never renamed. This is the
393
- * whole difference from `truncateSessionFile`, and it is the reason a fork is the safe half of this pair
394
- * of operations: a revert destroys entries permanently and keeps a hand-recovery backup, while a fork
395
- * leaves both files whole and keeps no backup because there is nothing to recover.
396
- * ========================================================================================
397
- *
398
- * WHY A COPY AND NOT PI'S `fork()`. Covered at length on `ClientCommand`'s `conversation.fork`: Pi's fork
399
- * always tears down the runtime and switches to the new session (`agent-session-runtime.ts:299-307`), and
400
- * Super Pi has no runtime to switch - it spawns one Pi per turn and kills it. The file is the only durable
401
- * thing a fork has to produce here, so the file is what gets produced.
402
- *
403
- * ============================ WHAT THIS DOES TO PI's ENTRY IDS ============================
404
- * **Nothing either, and this is a deliberate divergence from Pi's own `createBranchedSession`, which
405
- * RENUMBERS `parentId` (`:1655-1666`).** Keeping every id verbatim means every cross-reference already
406
- * inside the file stays valid, and research pitfall 6 is exactly why that matters: a Pi entry may point
407
- * at other entries by id in ways this code has never enumerated - a `compaction` record carries a
408
- * `firstKeptEntryId`, `branch_summary` carries the branch point - and renumbering ids means rewriting every
409
- * one of those references correctly, with no way to find them all. Copying the bytes cannot break a
410
- * reference because it changes nothing a reference points at. The cost is that the same entry id now
411
- * exists in two files; that is safe because `_buildIndex` builds its `byId` map per file (`:1103-1122`),
412
- * and Super Pi only ever opens one file per process.
413
- *
414
- * WHAT THE HEADER CHANGES, and it is two fields. `id` becomes the new conversation's id, because that is
415
- * what Pi's lookup matches (`findLocalSessionByExactId` reads the header, probe B) and therefore what makes
416
- * `--session-id <forked>` open THIS file rather than silently inventing an empty session with that id
417
- * (`main.ts:436-448`, measured: `Warning: No project session found with id '...'`). And `parentSession`
418
- * records the file this one came from, which is the same one-line lineage pointer Pi itself writes
419
- * (`:1675-1682`) and is not a link - the files share nothing. `cwd`, `version` and `timestamp` are copied
420
- * verbatim: `cwd` MUST be identical, because Pi's lookup requires the recorded cwd to equal the one it is
421
- * asked from, and the fork always lives in the same workspace as its source.
422
- *
423
- * THE SAME FORMAT CHECKS AS TRUNCATE, AND FOR THE SAME REASON. Probe A measured this file's shape against
424
- * real Pi (`scripts/probe-revert-session-format.mjs`); a Pi upgrade can invalidate any of those claims, so
425
- * every one is re-checked before a byte is written and a failure names the shape actually found. Here the
426
- * `parentId` property is checked across the WHOLE file rather than across a kept prefix, because the whole
427
- * file is what is being copied.
428
- *
429
- * TEMP-THEN-RENAME, because Pi appends with a bare `appendFileSync` (`:1187`) and the target must never be
430
- * observed half-written - a half-written file under the forked id is precisely the "Pi opens an empty
431
- * session and the history has vanished with no error anywhere" outcome probe B was about.
432
- *
433
- * EXPORTED so it can be tested against fabricated session files in a temp directory, without Pi.
434
- */
435
- export async function forkSessionFile(sourcePath, targetPath, expectedSessionId, targetSessionId) {
436
- const raw = await readFile(sourcePath, "utf8");
437
- const lines = raw.split("\n");
438
- if (lines.at(-1) === "")
439
- lines.pop();
440
- if (!lines.length)
441
- throw new Error(`Pi 的会话文件是空的,拒绝分叉:${sourcePath}`);
442
- const parsed = lines.map((line) => {
443
- try {
444
- return JSON.parse(line);
445
- }
446
- catch {
447
- return undefined;
448
- }
449
- });
450
- // The refusal messages name the shape actually found, and say "拒绝分叉" rather than reusing the revert
451
- // wording: the reader is looking at a fork button, and a message about 回退 would send them to look for a
452
- // control they did not press.
453
- const blank = lines.findIndex((line) => line.trim() === "");
454
- if (blank >= 0)
455
- throw new Error(`Pi 的会话文件第 ${blank} 行是空行,无法安全复制,拒绝分叉:${sourcePath}`);
456
- const unparseable = parsed.findIndex((entry) => entry === undefined);
457
- if (unparseable >= 0)
458
- throw new Error(`Pi 的会话文件第 ${unparseable} 行不是合法 JSON,这个文件的格式已经变了,拒绝分叉以免复制出一个 Pi 打不开的会话:${sourcePath}`);
459
- const headerLines = parsed.filter((entry) => entry?.type === "session");
460
- if (headerLines.length !== 1)
461
- throw new Error(`Pi 的会话文件里有 ${headerLines.length} 个 header(应为 1 个),格式已经变了,拒绝分叉:${sourcePath}`);
462
- if (parsed[0]?.type !== "session")
463
- throw new Error(`Pi 的会话文件第一行不是 header(实际 type=${JSON.stringify(parsed[0]?.type ?? null)}),格式已经变了,拒绝分叉:${sourcePath}`);
464
- // The one that matters most. Copying the wrong file would put one person's conversation into another's
465
- // session, and unlike a wrong truncation it would be written as a brand-new file rather than destroying
466
- // something - so this check is what stops a fork from being a disclosure.
467
- if (parsed[0]?.id !== expectedSessionId)
468
- throw new Error(`Pi 的会话文件属于会话 ${JSON.stringify(parsed[0]?.id ?? null)},不是要分叉的会话 ${JSON.stringify(expectedSessionId)}。拒绝复制别人的会话。`);
469
- if (!raw.endsWith("\n"))
470
- throw new Error(`Pi 的会话文件末尾没有换行,格式已经变了,拒绝分叉:${sourcePath}`);
471
- // Every kept entry must point at another kept entry. The whole file is being copied, so "kept" is all of
472
- // it - and a file that violates this is not a session Pi can open.
473
- const known = new Set();
474
- for (let index = 0; index < parsed.length; index += 1) {
475
- const parent = parsed[index]?.parentId;
476
- if (parent !== undefined && parent !== null && !known.has(parent))
477
- throw new Error(`这个 Pi 会话记录不自洽:第 ${index} 行引用了不存在的 ${JSON.stringify(parent)},拒绝分叉以免复制出一个 Pi 打不开的会话:${sourcePath}`);
478
- known.add(parsed[index]?.id);
479
- }
480
- // The header is REBUILT rather than string-patched, so the two changed fields are structural and
481
- // everything else - including any field a future Pi adds - is carried across untouched. A regex over the
482
- // first line would silently drop a field it did not recognise.
483
- const header = { ...parsed[0], id: targetSessionId, parentSession: sourcePath };
484
- const rewritten = [JSON.stringify(header), ...lines.slice(1)].join("\n") + "\n";
485
- const temporaryPath = `${targetPath}.super-pi-fork-tmp`;
486
- await writeFile(temporaryPath, rewritten, "utf8");
487
- await rename(temporaryPath, targetPath);
488
- // Re-read rather than trust the write. The claim this rests on is "a Pi spawned with the new id will
489
- // open THIS file and find the whole history in it", and nothing downstream would report a failed write -
490
- // the fork would simply appear to work and the model would start from nothing.
491
- const verified = (await readFile(targetPath, "utf8")).split("\n").filter((line) => line !== "");
492
- let verifiedHeader;
493
- try {
494
- verifiedHeader = JSON.parse(verified[0]);
495
- }
496
- catch {
497
- verifiedHeader = undefined;
498
- }
499
- if (verified.length !== lines.length || verifiedHeader?.id !== targetSessionId) {
500
- throw new Error(`写入后的 Pi 会话文件不是预期的样子(写到 ${verified.length} 行,期望 ${lines.length} 行,header id=${JSON.stringify(verifiedHeader?.id ?? null)},期望 ${JSON.stringify(targetSessionId)})。已放弃,没有创建任何会话。`);
501
- }
502
- return { entriesCopied: lines.length - 1 };
503
- }
173
+ // Session-file mutation and validation live behind this boundary; runtime only locates Pi sessions.
504
174
  /** How long one command may take before the turn is failed rather than left hanging. */
505
175
  const SESSION_COMMAND_TIMEOUT_MS = 10_000;
506
176
  /** `get_state`/`get_entries` on an idle Pi. Generous, because it only has to answer a question. */
@@ -725,7 +395,9 @@ export class PiRpcRuntime {
725
395
  [TASK_CAPABILITY_ENV]: mintCapability(conversationId),
726
396
  };
727
397
  }
728
- async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, images, memoryReview, memorySourceConversationId }) {
398
+ async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, images, memoryReview, memorySourceConversationId }) {
399
+ const latencyStartedAt = performance.now();
400
+ traceLatency(conversation.id, "answer.begin", latencyStartedAt);
729
401
  await mkdir(this.sessionDir, { recursive: true });
730
402
  // Normalize first: the picker can hand over forward slashes or a trailing separator, and `resolve`
731
403
  // turns both into one absolute native path. The directory must exist on disk before spawn - a
@@ -788,6 +460,7 @@ export class PiRpcRuntime {
788
460
  });
789
461
  const channel = new RpcChannel(child);
790
462
  this.runs.set(conversation.id, { child, channel });
463
+ traceLatency(conversation.id, "pi.spawned", latencyStartedAt, `pid=${child.pid ?? "unknown"}`);
791
464
  // Same guard as subagents.ts: a destroyed stdin throws synchronously on write, and an abort
792
465
  // listener that throws inside a listener would surface as an unhandled error instead of a cancel.
793
466
  const abort = () => {
@@ -835,16 +508,21 @@ export class PiRpcRuntime {
835
508
  }
836
509
  // Resumed Pi sessions can carry a different model than the global default.
837
510
  // Check the real get_state result as well, before any prompt reaches the model.
838
- if (this.modelValidator) {
511
+ if (this.modelValidator || onSessionFile) {
839
512
  const actual = await channel.request({ id: "policy_state", type: "get_state" }, SESSION_COMMAND_TIMEOUT_MS);
840
513
  const model = actual.data?.model;
841
- await this.modelValidator(validationId, model?.provider && model.id ? { provider: model.provider, modelId: model.id, label: model.name ?? model.id } : undefined);
514
+ await this.modelValidator?.(validationId, model?.provider && model.id ? { provider: model.provider, modelId: model.id, label: model.name ?? model.id } : undefined);
515
+ const sessionFile = actual.data?.sessionFile;
516
+ if (typeof sessionFile === "string" && sessionFile)
517
+ await onSessionFile?.(sessionFile);
842
518
  }
843
519
  const imagePayloads = this.options.uploadsDir && images?.length
844
520
  ? await loadConversationImages(this.options.uploadsDir, conversation.id, images)
845
521
  : undefined;
846
522
  channel.write({ id: "prompt", type: "prompt", message: prompt, ...(imagePayloads?.length ? { images: imagePayloads } : {}) });
523
+ traceLatency(conversation.id, "prompt.sent", latencyStartedAt);
847
524
  let settled = false;
525
+ let firstPiRecord = true;
848
526
  // Nothing may be dropped without a trace. Pi emits a wide, open-ended family of agent
849
527
  // session events (tool_call, thinking, compaction_*, auto_retry_*, ...), so an unhandled
850
528
  // record is announced once per distinct type and then tallied instead of logging per event.
@@ -875,6 +553,10 @@ export class PiRpcRuntime {
875
553
  const record = await channel.next(100);
876
554
  if (!record)
877
555
  break;
556
+ if (firstPiRecord) {
557
+ firstPiRecord = false;
558
+ traceLatency(conversation.id, "pi.first-record", latencyStartedAt, `type=${record.type}`);
559
+ }
878
560
  if (record.type === "response" && record.id === "prompt" && record.success === false)
879
561
  throw new Error(record.error ?? "Pi rejected the prompt");
880
562
  // A `steer` written while this turn was running, answered here because this loop is the only
@@ -1002,7 +684,6 @@ export class PiRpcRuntime {
1002
684
  }
1003
685
  if (record.type === "message_update") {
1004
686
  const event = record.assistantMessageEvent;
1005
- console.log(`[runtime] message_update event.type=${JSON.stringify(event?.type)}`);
1006
687
  // text_delta is what drives the visible answer, and toolcall_start/delta/end are what make a
1007
688
  // call VISIBLE WHILE IT IS BEING WRITTEN.
1008
689
  //
@@ -1031,14 +712,18 @@ export class PiRpcRuntime {
1031
712
  // between a `read`'s path and a `bash`'s command.
1032
713
  const name = toolNames.get(event.contentIndex) ?? "";
1033
714
  argumentText.set(event.contentIndex, `${argumentText.get(event.contentIndex) ?? ""}${delta}`);
1034
- const detail = describeToolCallInProgress(name, argumentText.get(event.contentIndex) ?? "");
1035
- onToolPreparing?.({ contentIndex: event.contentIndex, name, ...(delta ? { delta } : {}), ...(detail ? { detail } : {}) });
715
+ // WHAT ARRIVES IS A FRAGMENT; WHAT IS HANDED ON IS THE TEXT SO FAR. The same field on the
716
+ // caller's side receives `tool_execution_update`'s output, and Pi sends THAT already
717
+ // accumulated - so a caller storing both verbatim would have to append one kind of text and
718
+ // replace the other. Accumulating here, where the fragments already are, gives it one kind.
719
+ const text = argumentText.get(event.contentIndex) ?? "";
720
+ const detail = describeToolCallInProgress(name, text);
721
+ onToolPreparing?.({ contentIndex: event.contentIndex, name, ...(text ? { live: text } : {}), ...(detail ? { detail } : {}) });
1036
722
  }
1037
723
  else if (event?.type === "thinking_delta")
1038
724
  onThinking?.(typeof event.delta === "string" ? event.delta : "");
1039
725
  else {
1040
726
  noteUnhandled(`message_update:${JSON.stringify(event?.type ?? "(missing)")}`);
1041
- console.log(`[runtime] message_update not matched: event.type=${JSON.stringify(event?.type)}`);
1042
727
  }
1043
728
  continue;
1044
729
  }
@@ -1055,12 +740,13 @@ export class PiRpcRuntime {
1055
740
  throw new Error(`Pi extension error in ${record.extensionPath ?? "unknown path"} (${record.event ?? "unknown event"}): ${record.error ?? "unknown error"}`);
1056
741
  }
1057
742
  if (record.type === "agent_settled") {
743
+ traceLatency(conversation.id, "pi.settled", latencyStartedAt);
1058
744
  settled = true;
1059
745
  continue;
1060
746
  }
1061
747
  noteUnhandled(`type ${JSON.stringify(record.type ?? "(missing type)")}`);
1062
748
  }
1063
- if (!settled && child.exitCode !== null)
749
+ if (!settled && channel.closed)
1064
750
  throw new Error(`Pi RPC exited before settling (code ${child.exitCode}). ${channel.stderr.trim()}`);
1065
751
  }
1066
752
  for (const [key, count] of repeats)
@@ -1209,6 +895,21 @@ export class PiRpcRuntime {
1209
895
  throw new Error("这个会话还有一轮任务在跑,先停止它再回退");
1210
896
  if (!this.pi.found)
1211
897
  throw new Error(this.pi.reason);
898
+ // Normal path: this exact worker already asked Pi for get_state before its prompt was sent, so reuse
899
+ // that locator. `truncateSessionFile` below still proves the header belongs to this conversation and
900
+ // validates the requested boundary and parent chain before a byte is changed. The cache removes an
901
+ // otherwise pointless second Pi startup from every ordinary revert.
902
+ const cached = conversation.piSessionFile;
903
+ if (cached) {
904
+ const exists = await stat(cached).catch(() => undefined);
905
+ if (exists?.isFile()) {
906
+ const result = await truncateSessionFile(cached, boundaryEntryId, conversation.id, beforeBoundary);
907
+ return { sessionFile: cached, linesKept: result.linesKept, entriesDropped: result.entriesDropped };
908
+ }
909
+ }
910
+ // Compatibility fallback for conversations created before the live-worker locator existed, or a
911
+ // transcript that was moved since the last turn. This retains Pi's authoritative lookup rather than
912
+ // guessing from filenames.
1212
913
  const childCwd = resolve(cwd);
1213
914
  const info = await stat(childCwd).catch(() => undefined);
1214
915
  if (!info?.isDirectory())
@@ -1305,6 +1006,13 @@ export class PiRpcRuntime {
1305
1006
  return { entriesCopied: 0 };
1306
1007
  if (!this.pi.found)
1307
1008
  throw new Error(this.pi.reason);
1009
+ const cached = conversation.piSessionFile;
1010
+ if (cached && (await stat(cached).catch(() => undefined))?.isFile()) {
1011
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
1012
+ const target = join(this.sessionDir, `${stamp}_${forkedConversationId}.jsonl`);
1013
+ const result = await forkSessionFile(cached, target, conversation.id, forkedConversationId);
1014
+ return { sessionFile: target, entriesCopied: result.entriesCopied };
1015
+ }
1308
1016
  const childCwd = resolve(cwd);
1309
1017
  const info = await stat(childCwd).catch(() => undefined);
1310
1018
  if (!info?.isDirectory())
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Cuts a Pi session file down to the header plus everything up to and including `boundaryEntryId`.
3
+ *
4
+ * ============================ WHAT THIS DESTROYS ============================
5
+ * **Every entry written after the boundary is gone from disk, permanently, and there is no way to
6
+ * reconstruct it from this file.** That includes not only the messages a person can see but also Pi's
7
+ * `tool_call` records, `compaction` summaries, `model_change` and `thinking_level_change` markers, usage
8
+ * accounting, and anything a future Pi version adds. This is the step the whole revert design is built
9
+ * around (research §4.2) and the step that cannot be undone by the application - Super Pi has no undo,
10
+ * and adding one was explicitly left out of scope.
11
+ *
12
+ * It does NOT touch the workspace. No file the agent read or wrote is read, written or reverted here.
13
+ * =============================================================================
14
+ *
15
+ * WHY TRUNCATE RATHER THAN ASK PI. Pi's `SessionManager` is append-only by design
16
+ * (`session-manager.ts:1516-1519`: "Entries cannot be modified or deleted"), and its in-session rewind -
17
+ * `branch()`, `navigateTree()` - is IN-MEMORY ONLY (`:1573-1584`): `_buildIndex()` re-reads the whole
18
+ * file on every `open()` and sets the leaf to the last line unconditionally (`:1103-1122`). Super Pi
19
+ * spawns one Pi per turn and kills it afterwards, so a leaf pointer set in one turn is gone before the
20
+ * next one starts - a "call `branch()` once" solution would look correct for a turn and then silently
21
+ * forget (research §5 pitfall 2). Truncating the file is the only version of this that is a durable state
22
+ * change rather than per-turn bookkeeping that can be skipped exactly once.
23
+ *
24
+ * WHY THE FORMAT IS ASSUMED RATHER THAN GUESSED. The shape this relies on - line 0 is the one and only
25
+ * `type: "session"` header, every other line is one self-contained JSON entry, every entry's `parentId`
26
+ * already appears earlier in the file, no blank lines, and a trailing newline - was measured against
27
+ * real Pi by scripts/probe-revert-session-format.mjs, which also proves the `parentId` property that
28
+ * makes a prefix a VALID session rather than a fragment. That probe is the reason this function may
29
+ * index by line at all; every one of those claims is re-checked here before anything is written, so a
30
+ * Pi that changed the format fails loudly instead of producing a file Pi cannot open.
31
+ *
32
+ * WHY THE WRITE IS TEMP-THEN-RENAME. Pi appends with a bare `appendFileSync`
33
+ * (`session-manager.ts:1187`) and makes no atomicity guarantee of its own. Writing the truncated copy
34
+ * under a different name and renaming it over the original means a reader never observes a half-written
35
+ * file, and a failure before the rename leaves the original untouched.
36
+ *
37
+ * WHY A BACKUP IS KEPT. `.super-pi-revert-backup` is written next to the session file, deliberately not
38
+ * named `*.jsonl` so Pi's own discovery (`readdirSync` filtered on that suffix,
39
+ * `session-manager.ts:1881`) never lists it, and deliberately NOT surfaced anywhere as a feature:
40
+ * Super Pi has no undo and does not pretend to. It exists so that an irreversible, unrecoverable-by-code
41
+ * operation on a person's conversation history is at least recoverable by hand.
42
+ *
43
+ * EXPORTED so it can be tested against a fabricated session file in a temp directory, without Pi and
44
+ * without touching anybody's real transcript.
45
+ */
46
+ export declare function truncateSessionFile(path: string, boundaryEntryId: string, expectedSessionId: string, beforeBoundary?: boolean): Promise<{
47
+ linesKept: number;
48
+ entriesDropped: number;
49
+ }>;
50
+ /**
51
+ * Writes a second Pi session file that begins as an EXACT copy of one, under a different session id.
52
+ *
53
+ * ============================ WHAT THIS DOES TO THE ORIGINAL ============================
54
+ * **Nothing.** The source file is read and never written, never truncated, never renamed. This is the
55
+ * whole difference from `truncateSessionFile`, and it is the reason a fork is the safe half of this pair
56
+ * of operations: a revert destroys entries permanently and keeps a hand-recovery backup, while a fork
57
+ * leaves both files whole and keeps no backup because there is nothing to recover.
58
+ * ========================================================================================
59
+ *
60
+ * WHY A COPY AND NOT PI'S `fork()`. Covered at length on `ClientCommand`'s `conversation.fork`: Pi's fork
61
+ * always tears down the runtime and switches to the new session (`agent-session-runtime.ts:299-307`), and
62
+ * Super Pi has no runtime to switch - it spawns one Pi per turn and kills it. The file is the only durable
63
+ * thing a fork has to produce here, so the file is what gets produced.
64
+ *
65
+ * ============================ WHAT THIS DOES TO PI's ENTRY IDS ============================
66
+ * **Nothing either, and this is a deliberate divergence from Pi's own `createBranchedSession`, which
67
+ * RENUMBERS `parentId` (`:1655-1666`).** Keeping every id verbatim means every cross-reference already
68
+ * inside the file stays valid, and research pitfall 6 is exactly why that matters: a Pi entry may point
69
+ * at other entries by id in ways this code has never enumerated - a `compaction` record carries a
70
+ * `firstKeptEntryId`, `branch_summary` carries the branch point - and renumbering ids means rewriting every
71
+ * one of those references correctly, with no way to find them all. Copying the bytes cannot break a
72
+ * reference because it changes nothing a reference points at. The cost is that the same entry id now
73
+ * exists in two files; that is safe because `_buildIndex` builds its `byId` map per file (`:1103-1122`),
74
+ * and Super Pi only ever opens one file per process.
75
+ *
76
+ * WHAT THE HEADER CHANGES, and it is two fields. `id` becomes the new conversation's id, because that is
77
+ * what Pi's lookup matches (`findLocalSessionByExactId` reads the header, probe B) and therefore what makes
78
+ * `--session-id <forked>` open THIS file rather than silently inventing an empty session with that id
79
+ * (`main.ts:436-448`, measured: `Warning: No project session found with id '...'`). And `parentSession`
80
+ * records the file this one came from, which is the same one-line lineage pointer Pi itself writes
81
+ * (`:1675-1682`) and is not a link - the files share nothing. `cwd`, `version` and `timestamp` are copied
82
+ * verbatim: `cwd` MUST be identical, because Pi's lookup requires the recorded cwd to equal the one it is
83
+ * asked from, and the fork always lives in the same workspace as its source.
84
+ *
85
+ * THE SAME FORMAT CHECKS AS TRUNCATE, AND FOR THE SAME REASON. Probe A measured this file's shape against
86
+ * real Pi (`scripts/probe-revert-session-format.mjs`); a Pi upgrade can invalidate any of those claims, so
87
+ * every one is re-checked before a byte is written and a failure names the shape actually found. Here the
88
+ * `parentId` property is checked across the WHOLE file rather than across a kept prefix, because the whole
89
+ * file is what is being copied.
90
+ *
91
+ * TEMP-THEN-RENAME, because Pi appends with a bare `appendFileSync` (`:1187`) and the target must never be
92
+ * observed half-written - a half-written file under the forked id is precisely the "Pi opens an empty
93
+ * session and the history has vanished with no error anywhere" outcome probe B was about.
94
+ *
95
+ * EXPORTED so it can be tested against fabricated session files in a temp directory, without Pi.
96
+ */
97
+ export declare function forkSessionFile(sourcePath: string, targetPath: string, expectedSessionId: string, targetSessionId: string): Promise<{
98
+ entriesCopied: number;
99
+ }>;