@evandu/super-pi 0.1.70 → 0.1.72

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.
@@ -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
+ }>;
@@ -0,0 +1,245 @@
1
+ import { readFile, rename, writeFile } from "node:fs/promises";
2
+ /**
3
+ * Cuts a Pi session file down to the header plus everything up to and including `boundaryEntryId`.
4
+ *
5
+ * ============================ WHAT THIS DESTROYS ============================
6
+ * **Every entry written after the boundary is gone from disk, permanently, and there is no way to
7
+ * reconstruct it from this file.** That includes not only the messages a person can see but also Pi's
8
+ * `tool_call` records, `compaction` summaries, `model_change` and `thinking_level_change` markers, usage
9
+ * accounting, and anything a future Pi version adds. This is the step the whole revert design is built
10
+ * around (research §4.2) and the step that cannot be undone by the application - Super Pi has no undo,
11
+ * and adding one was explicitly left out of scope.
12
+ *
13
+ * It does NOT touch the workspace. No file the agent read or wrote is read, written or reverted here.
14
+ * =============================================================================
15
+ *
16
+ * WHY TRUNCATE RATHER THAN ASK PI. Pi's `SessionManager` is append-only by design
17
+ * (`session-manager.ts:1516-1519`: "Entries cannot be modified or deleted"), and its in-session rewind -
18
+ * `branch()`, `navigateTree()` - is IN-MEMORY ONLY (`:1573-1584`): `_buildIndex()` re-reads the whole
19
+ * file on every `open()` and sets the leaf to the last line unconditionally (`:1103-1122`). Super Pi
20
+ * spawns one Pi per turn and kills it afterwards, so a leaf pointer set in one turn is gone before the
21
+ * next one starts - a "call `branch()` once" solution would look correct for a turn and then silently
22
+ * forget (research §5 pitfall 2). Truncating the file is the only version of this that is a durable state
23
+ * change rather than per-turn bookkeeping that can be skipped exactly once.
24
+ *
25
+ * WHY THE FORMAT IS ASSUMED RATHER THAN GUESSED. The shape this relies on - line 0 is the one and only
26
+ * `type: "session"` header, every other line is one self-contained JSON entry, every entry's `parentId`
27
+ * already appears earlier in the file, no blank lines, and a trailing newline - was measured against
28
+ * real Pi by scripts/probe-revert-session-format.mjs, which also proves the `parentId` property that
29
+ * makes a prefix a VALID session rather than a fragment. That probe is the reason this function may
30
+ * index by line at all; every one of those claims is re-checked here before anything is written, so a
31
+ * Pi that changed the format fails loudly instead of producing a file Pi cannot open.
32
+ *
33
+ * WHY THE WRITE IS TEMP-THEN-RENAME. Pi appends with a bare `appendFileSync`
34
+ * (`session-manager.ts:1187`) and makes no atomicity guarantee of its own. Writing the truncated copy
35
+ * under a different name and renaming it over the original means a reader never observes a half-written
36
+ * file, and a failure before the rename leaves the original untouched.
37
+ *
38
+ * WHY A BACKUP IS KEPT. `.super-pi-revert-backup` is written next to the session file, deliberately not
39
+ * named `*.jsonl` so Pi's own discovery (`readdirSync` filtered on that suffix,
40
+ * `session-manager.ts:1881`) never lists it, and deliberately NOT surfaced anywhere as a feature:
41
+ * Super Pi has no undo and does not pretend to. It exists so that an irreversible, unrecoverable-by-code
42
+ * operation on a person's conversation history is at least recoverable by hand.
43
+ *
44
+ * EXPORTED so it can be tested against a fabricated session file in a temp directory, without Pi and
45
+ * without touching anybody's real transcript.
46
+ */
47
+ export async function truncateSessionFile(path, boundaryEntryId, expectedSessionId, beforeBoundary = false) {
48
+ const raw = await readFile(path, "utf8");
49
+ // Split, then drop the empty segment a trailing newline leaves behind. Probe A measured that the file
50
+ // DOES end with a newline, so `split` yields one empty tail element; keeping it would append a blank
51
+ // line to the rewritten file and undo the property the probe just established.
52
+ const lines = raw.split("\n");
53
+ if (lines.at(-1) === "")
54
+ lines.pop();
55
+ if (!lines.length)
56
+ throw new Error(`Pi 的会话文件是空的,拒绝回退:${path}`);
57
+ const parsed = lines.map((line) => {
58
+ try {
59
+ return JSON.parse(line);
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ });
65
+ // Every claim probe A made, re-checked here because a Pi upgrade may invalidate any of them. Each
66
+ // failure names the shape that was actually found, because "revert failed" alone sends the reader
67
+ // looking for their own data instead of at the version mismatch that caused it.
68
+ // Checked BEFORE the JSON check, so the refusal names the actual defect: a blank line reads as "this
69
+ // file has a shape my line arithmetic cannot trust", which is what it is, and a bare "not valid JSON"
70
+ // would send the reader looking for a corrupted file instead.
71
+ const blank = lines.findIndex((line) => line.trim() === "");
72
+ if (blank >= 0)
73
+ throw new Error(`Pi 的会话文件第 ${blank} 行是空行,行号与 entry 号不再一一对应,拒绝回退:${path}`);
74
+ const unparseable = parsed.findIndex((entry) => entry === undefined);
75
+ if (unparseable >= 0)
76
+ throw new Error(`Pi 的会话文件第 ${unparseable} 行不是合法 JSON,这个文件的格式已经变了,拒绝回退以免损坏它:${path}`);
77
+ const headerLines = parsed.filter((entry) => entry?.type === "session");
78
+ if (headerLines.length !== 1)
79
+ throw new Error(`Pi 的会话文件里有 ${headerLines.length} 个 header(应为 1 个),格式已经变了,拒绝回退:${path}`);
80
+ if (parsed[0]?.type !== "session")
81
+ throw new Error(`Pi 的会话文件第一行不是 header(实际 type=${JSON.stringify(parsed[0]?.type ?? null)}),格式已经变了,拒绝回退:${path}`);
82
+ if (parsed[0]?.id !== expectedSessionId)
83
+ throw new Error(`Pi 的会话文件属于会话 ${JSON.stringify(parsed[0]?.id ?? null)},不是要回退的会话 ${JSON.stringify(expectedSessionId)}。拒绝截断别人的会话。`);
84
+ if (!raw.endsWith("\n"))
85
+ throw new Error(`Pi 的会话文件末尾没有换行,格式已经变了,拒绝回退:${path}`);
86
+ let boundary = boundaryEntryId === "" ? 0 : parsed.findIndex((entry, index) => index > 0 && entry?.id === boundaryEntryId);
87
+ if (boundary < 0)
88
+ throw new Error(`在这份 Pi 会话记录里找不到要保留的那条消息(entry ${JSON.stringify(boundaryEntryId)})。这条消息可能是更早的版本写的,Super Pi 当时没有记下它对应 Pi 的哪一条。`);
89
+ const expectedLastId = beforeBoundary ? parsed[boundary - 1]?.id : parsed[boundary]?.id;
90
+ if (beforeBoundary)
91
+ boundary -= 1;
92
+ // Fail closed: a boundary of -1 here means "revert to before the FIRST entry", which would write an
93
+ // empty session file. The verification below would pass vacuously (0 !== 0 is false), so the refusal
94
+ // must happen here rather than be trusted to a check that cannot fire.
95
+ if (boundary < 0)
96
+ throw new Error(`回退点在第一条消息之前,截断会得到一个空会话文件,拒绝回退:${path}`);
97
+ // The `parentId` property probe A measured, enforced at the boundary that matters: every entry still
98
+ // being KEPT must point at something also being kept. A prefix that violates this is not a session,
99
+ // and writing it would produce a file Pi either rejects or silently misreads.
100
+ const kept = new Set();
101
+ for (let index = 0; index <= boundary; index += 1) {
102
+ const parent = parsed[index]?.parentId;
103
+ if (parent !== undefined && parent !== null && !kept.has(parent))
104
+ throw new Error(`要保留的部分不是自洽的:第 ${index} 行引用了不在保留范围内的 ${JSON.stringify(parent)},拒绝回退以免产出 Pi 打不开的会话。`);
105
+ kept.add(parsed[index]?.id);
106
+ }
107
+ const rewritten = `${lines.slice(0, boundary + 1).join("\n")}\n`;
108
+ const backupPath = `${path}.super-pi-revert-backup`;
109
+ const temporaryPath = `${path}.super-pi-revert-tmp`;
110
+ await writeFile(backupPath, raw, "utf8");
111
+ await writeFile(temporaryPath, rewritten, "utf8");
112
+ await rename(temporaryPath, path);
113
+ // Re-read rather than trust the write. The claim this whole mechanism rests on is "the file's last
114
+ // line is now the boundary entry", and `_buildIndex` derives the leaf from exactly that line - so if
115
+ // the write did not land, nothing downstream would say so until a turn produced wrong context.
116
+ const verified = (await readFile(path, "utf8")).split("\n").filter((line) => line !== "");
117
+ const last = verified[verified.length - 1];
118
+ let lastEntry;
119
+ try {
120
+ lastEntry = JSON.parse(last);
121
+ }
122
+ catch {
123
+ lastEntry = undefined;
124
+ }
125
+ if (verified.length !== boundary + 1 || lastEntry?.id !== expectedLastId || boundary + 1 < 1) {
126
+ throw new Error(`截断后的 Pi 会话文件没有以要保留的那条消息结尾(写到 ${verified.length} 行,期望 ${boundary + 1} 行,末行 id=${JSON.stringify(lastEntry?.id ?? null)})。原始文件仍在 ${backupPath}。`);
127
+ }
128
+ return { linesKept: boundary + 1, entriesDropped: lines.length - (boundary + 1) };
129
+ }
130
+ /**
131
+ * Writes a second Pi session file that begins as an EXACT copy of one, under a different session id.
132
+ *
133
+ * ============================ WHAT THIS DOES TO THE ORIGINAL ============================
134
+ * **Nothing.** The source file is read and never written, never truncated, never renamed. This is the
135
+ * whole difference from `truncateSessionFile`, and it is the reason a fork is the safe half of this pair
136
+ * of operations: a revert destroys entries permanently and keeps a hand-recovery backup, while a fork
137
+ * leaves both files whole and keeps no backup because there is nothing to recover.
138
+ * ========================================================================================
139
+ *
140
+ * WHY A COPY AND NOT PI'S `fork()`. Covered at length on `ClientCommand`'s `conversation.fork`: Pi's fork
141
+ * always tears down the runtime and switches to the new session (`agent-session-runtime.ts:299-307`), and
142
+ * Super Pi has no runtime to switch - it spawns one Pi per turn and kills it. The file is the only durable
143
+ * thing a fork has to produce here, so the file is what gets produced.
144
+ *
145
+ * ============================ WHAT THIS DOES TO PI's ENTRY IDS ============================
146
+ * **Nothing either, and this is a deliberate divergence from Pi's own `createBranchedSession`, which
147
+ * RENUMBERS `parentId` (`:1655-1666`).** Keeping every id verbatim means every cross-reference already
148
+ * inside the file stays valid, and research pitfall 6 is exactly why that matters: a Pi entry may point
149
+ * at other entries by id in ways this code has never enumerated - a `compaction` record carries a
150
+ * `firstKeptEntryId`, `branch_summary` carries the branch point - and renumbering ids means rewriting every
151
+ * one of those references correctly, with no way to find them all. Copying the bytes cannot break a
152
+ * reference because it changes nothing a reference points at. The cost is that the same entry id now
153
+ * exists in two files; that is safe because `_buildIndex` builds its `byId` map per file (`:1103-1122`),
154
+ * and Super Pi only ever opens one file per process.
155
+ *
156
+ * WHAT THE HEADER CHANGES, and it is two fields. `id` becomes the new conversation's id, because that is
157
+ * what Pi's lookup matches (`findLocalSessionByExactId` reads the header, probe B) and therefore what makes
158
+ * `--session-id <forked>` open THIS file rather than silently inventing an empty session with that id
159
+ * (`main.ts:436-448`, measured: `Warning: No project session found with id '...'`). And `parentSession`
160
+ * records the file this one came from, which is the same one-line lineage pointer Pi itself writes
161
+ * (`:1675-1682`) and is not a link - the files share nothing. `cwd`, `version` and `timestamp` are copied
162
+ * verbatim: `cwd` MUST be identical, because Pi's lookup requires the recorded cwd to equal the one it is
163
+ * asked from, and the fork always lives in the same workspace as its source.
164
+ *
165
+ * THE SAME FORMAT CHECKS AS TRUNCATE, AND FOR THE SAME REASON. Probe A measured this file's shape against
166
+ * real Pi (`scripts/probe-revert-session-format.mjs`); a Pi upgrade can invalidate any of those claims, so
167
+ * every one is re-checked before a byte is written and a failure names the shape actually found. Here the
168
+ * `parentId` property is checked across the WHOLE file rather than across a kept prefix, because the whole
169
+ * file is what is being copied.
170
+ *
171
+ * TEMP-THEN-RENAME, because Pi appends with a bare `appendFileSync` (`:1187`) and the target must never be
172
+ * observed half-written - a half-written file under the forked id is precisely the "Pi opens an empty
173
+ * session and the history has vanished with no error anywhere" outcome probe B was about.
174
+ *
175
+ * EXPORTED so it can be tested against fabricated session files in a temp directory, without Pi.
176
+ */
177
+ export async function forkSessionFile(sourcePath, targetPath, expectedSessionId, targetSessionId) {
178
+ const raw = await readFile(sourcePath, "utf8");
179
+ const lines = raw.split("\n");
180
+ if (lines.at(-1) === "")
181
+ lines.pop();
182
+ if (!lines.length)
183
+ throw new Error(`Pi 的会话文件是空的,拒绝分叉:${sourcePath}`);
184
+ const parsed = lines.map((line) => {
185
+ try {
186
+ return JSON.parse(line);
187
+ }
188
+ catch {
189
+ return undefined;
190
+ }
191
+ });
192
+ // The refusal messages name the shape actually found, and say "拒绝分叉" rather than reusing the revert
193
+ // wording: the reader is looking at a fork button, and a message about 回退 would send them to look for a
194
+ // control they did not press.
195
+ const blank = lines.findIndex((line) => line.trim() === "");
196
+ if (blank >= 0)
197
+ throw new Error(`Pi 的会话文件第 ${blank} 行是空行,无法安全复制,拒绝分叉:${sourcePath}`);
198
+ const unparseable = parsed.findIndex((entry) => entry === undefined);
199
+ if (unparseable >= 0)
200
+ throw new Error(`Pi 的会话文件第 ${unparseable} 行不是合法 JSON,这个文件的格式已经变了,拒绝分叉以免复制出一个 Pi 打不开的会话:${sourcePath}`);
201
+ const headerLines = parsed.filter((entry) => entry?.type === "session");
202
+ if (headerLines.length !== 1)
203
+ throw new Error(`Pi 的会话文件里有 ${headerLines.length} 个 header(应为 1 个),格式已经变了,拒绝分叉:${sourcePath}`);
204
+ if (parsed[0]?.type !== "session")
205
+ throw new Error(`Pi 的会话文件第一行不是 header(实际 type=${JSON.stringify(parsed[0]?.type ?? null)}),格式已经变了,拒绝分叉:${sourcePath}`);
206
+ // The one that matters most. Copying the wrong file would put one person's conversation into another's
207
+ // session, and unlike a wrong truncation it would be written as a brand-new file rather than destroying
208
+ // something - so this check is what stops a fork from being a disclosure.
209
+ if (parsed[0]?.id !== expectedSessionId)
210
+ throw new Error(`Pi 的会话文件属于会话 ${JSON.stringify(parsed[0]?.id ?? null)},不是要分叉的会话 ${JSON.stringify(expectedSessionId)}。拒绝复制别人的会话。`);
211
+ if (!raw.endsWith("\n"))
212
+ throw new Error(`Pi 的会话文件末尾没有换行,格式已经变了,拒绝分叉:${sourcePath}`);
213
+ // Every kept entry must point at another kept entry. The whole file is being copied, so "kept" is all of
214
+ // it - and a file that violates this is not a session Pi can open.
215
+ const known = new Set();
216
+ for (let index = 0; index < parsed.length; index += 1) {
217
+ const parent = parsed[index]?.parentId;
218
+ if (parent !== undefined && parent !== null && !known.has(parent))
219
+ throw new Error(`这个 Pi 会话记录不自洽:第 ${index} 行引用了不存在的 ${JSON.stringify(parent)},拒绝分叉以免复制出一个 Pi 打不开的会话:${sourcePath}`);
220
+ known.add(parsed[index]?.id);
221
+ }
222
+ // The header is REBUILT rather than string-patched, so the two changed fields are structural and
223
+ // everything else - including any field a future Pi adds - is carried across untouched. A regex over the
224
+ // first line would silently drop a field it did not recognise.
225
+ const header = { ...parsed[0], id: targetSessionId, parentSession: sourcePath };
226
+ const rewritten = [JSON.stringify(header), ...lines.slice(1)].join("\n") + "\n";
227
+ const temporaryPath = `${targetPath}.super-pi-fork-tmp`;
228
+ await writeFile(temporaryPath, rewritten, "utf8");
229
+ await rename(temporaryPath, targetPath);
230
+ // Re-read rather than trust the write. The claim this rests on is "a Pi spawned with the new id will
231
+ // open THIS file and find the whole history in it", and nothing downstream would report a failed write -
232
+ // the fork would simply appear to work and the model would start from nothing.
233
+ const verified = (await readFile(targetPath, "utf8")).split("\n").filter((line) => line !== "");
234
+ let verifiedHeader;
235
+ try {
236
+ verifiedHeader = JSON.parse(verified[0]);
237
+ }
238
+ catch {
239
+ verifiedHeader = undefined;
240
+ }
241
+ if (verified.length !== lines.length || verifiedHeader?.id !== targetSessionId) {
242
+ throw new Error(`写入后的 Pi 会话文件不是预期的样子(写到 ${verified.length} 行,期望 ${lines.length} 行,header id=${JSON.stringify(verifiedHeader?.id ?? null)},期望 ${JSON.stringify(targetSessionId)})。已放弃,没有创建任何会话。`);
243
+ }
244
+ return { entriesCopied: lines.length - 1 };
245
+ }
@@ -223,6 +223,13 @@ export declare class Store {
223
223
  thinkingLevel?: string;
224
224
  }): Promise<Conversation>;
225
225
  beginAssistantMessage(conversationId: string, agentIdentity?: Message["agentIdentity"]): Promise<Message>;
226
+ /**
227
+ * Applies a streamed delta to the authoritative in-memory transcript without touching disk.
228
+ *
229
+ * The runtime must be able to drain Pi and publish the token before a disk transaction finishes.
230
+ * `checkpointMessage` below makes the same object durable on a bounded cadence and at turn boundaries.
231
+ */
232
+ appendAssistantDeltaLive(conversationId: string, messageId: string, delta: string): void;
226
233
  appendAssistantDelta(conversationId: string, messageId: string, delta: string): Promise<void>;
227
234
  /**
228
235
  * THE MODEL'S REASONING, APPENDED TO THE MESSAGE IT BELONGS TO.
@@ -234,7 +241,10 @@ export declare class Store {
234
241
  * to go back to. Persisting here rather than only in the browser is the point of its living on the
235
242
  * message: a reasoning stream that vanishes on reload was never a record of anything.
236
243
  */
244
+ appendThinkingDeltaLive(conversationId: string, messageId: string, delta: string, at?: number): void;
237
245
  appendThinkingDelta(conversationId: string, messageId: string, delta: string): Promise<void>;
246
+ /** Persists the message exactly as it stands now, including any live text, thinking and tool updates. */
247
+ checkpointMessage(conversationId: string, messageId: string): Promise<void>;
238
248
  /**
239
249
  * Appends one tool call to the message it belongs to, and returns the message's whole `toolCalls`
240
250
  * array so the caller can broadcast it as one snapshot.
@@ -308,6 +318,8 @@ export declare class Store {
308
318
  * one: it means the caller and the store disagree about the conversation, and guessing is worse.
309
319
  */
310
320
  setMessagePiEntryId(conversationId: string, messageId: string, piEntryId: string): Promise<void>;
321
+ /** Cache a locator Pi itself resolved while serving this conversation; never trust it as authorization. */
322
+ setConversationPiSessionFile(conversationId: string, path: string): Promise<void>;
311
323
  /**
312
324
  * Reverts the conversation to the state right after `messageId`, in `state.json` only.
313
325
  *
@@ -879,14 +879,23 @@ export class Store {
879
879
  return structuredClone(conversation);
880
880
  }
881
881
  async beginAssistantMessage(conversationId, agentIdentity) { const conversation = this.requireConversation(conversationId); const message = { id: id("message"), role: "assistant", content: "", createdAt: now(), ...(agentIdentity ? { agentIdentity: structuredClone(agentIdentity) } : {}) }; conversation.messages.push(message); await this.persist(); return structuredClone(message); }
882
- async appendAssistantDelta(conversationId, messageId, delta) {
882
+ /**
883
+ * Applies a streamed delta to the authoritative in-memory transcript without touching disk.
884
+ *
885
+ * The runtime must be able to drain Pi and publish the token before a disk transaction finishes.
886
+ * `checkpointMessage` below makes the same object durable on a bounded cadence and at turn boundaries.
887
+ */
888
+ appendAssistantDeltaLive(conversationId, messageId, delta) {
883
889
  const conversation = this.requireConversation(conversationId);
884
890
  const message = conversation.messages.find((item) => item.id === messageId);
885
891
  if (!message)
886
892
  throw new Error("Assistant message not found");
887
893
  message.content += delta;
888
894
  conversation.updatedAt = now();
889
- await this.persistTransaction({ kind: "message.replace", conversationId, message: structuredClone(message), updatedAt: conversation.updatedAt });
895
+ }
896
+ async appendAssistantDelta(conversationId, messageId, delta) {
897
+ this.appendAssistantDeltaLive(conversationId, messageId, delta);
898
+ await this.checkpointMessage(conversationId, messageId);
890
899
  }
891
900
  /**
892
901
  * THE MODEL'S REASONING, APPENDED TO THE MESSAGE IT BELONGS TO.
@@ -898,13 +907,30 @@ export class Store {
898
907
  * to go back to. Persisting here rather than only in the browser is the point of its living on the
899
908
  * message: a reasoning stream that vanishes on reload was never a record of anything.
900
909
  */
901
- async appendThinkingDelta(conversationId, messageId, delta) {
910
+ appendThinkingDeltaLive(conversationId, messageId, delta, at) {
902
911
  const conversation = this.requireConversation(conversationId);
903
912
  const message = conversation.messages.find((item) => item.id === messageId);
904
913
  if (!message)
905
914
  throw new Error("Assistant message not found");
906
915
  message.thinking = `${message.thinking ?? ""}${delta}`;
916
+ const blocks = (message.thinkingBlocks ??= []);
917
+ const last = blocks.at(-1);
918
+ if (last && last.at === (at ?? last.at))
919
+ last.text += delta;
920
+ else
921
+ blocks.push({ at: at ?? message.content.length, text: delta });
907
922
  conversation.updatedAt = now();
923
+ }
924
+ async appendThinkingDelta(conversationId, messageId, delta) {
925
+ this.appendThinkingDeltaLive(conversationId, messageId, delta);
926
+ await this.checkpointMessage(conversationId, messageId);
927
+ }
928
+ /** Persists the message exactly as it stands now, including any live text, thinking and tool updates. */
929
+ async checkpointMessage(conversationId, messageId) {
930
+ const conversation = this.requireConversation(conversationId);
931
+ const message = conversation.messages.find((item) => item.id === messageId);
932
+ if (!message)
933
+ throw new Error("Assistant message not found");
908
934
  await this.persistTransaction({ kind: "message.replace", conversationId, message: structuredClone(message), updatedAt: conversation.updatedAt });
909
935
  }
910
936
  /**
@@ -996,7 +1022,7 @@ export class Store {
996
1022
  // full output a reader watched grow; the persisted `result` is a bounded 8KB tail of the same
997
1023
  // stream. Deleting live would replace the watched text with a possibly-different 8KB slice, and a
998
1024
  // finished tool block that vanishes the moment the call ends is a block that taught the reader
999
- // nothing. The client renders `result` when present, falling back to `live` for the full preview.
1025
+ // nothing. The client prefers `live` when both are present, falling back to `result`.
1000
1026
  conversation.updatedAt = now();
1001
1027
  await this.persist();
1002
1028
  return structuredClone(calls);
@@ -1036,8 +1062,14 @@ export class Store {
1036
1062
  merged.set(arg.key, arg);
1037
1063
  call.args = [...merged.values()].slice(0, TOOL_ARG_CAP);
1038
1064
  }
1065
+ // THE PREVIEW IS "THE TEXT SO FAR", SO IT REPLACES. Every writer sends the whole text: the runtime
1066
+ // accumulates the model's argument fragments before this is called, and Pi sends
1067
+ // `tool_execution_update.partialResult` already accumulated. Appending here would concatenate two
1068
+ // "so far"s - the reader would watch the output print itself twice. The clear at the preparing ->
1069
+ // running handoff (`live: ""`) is the same statement, once: the arguments that were on screen while
1070
+ // the call was being written are gone by the time it runs.
1039
1071
  if (patch.live !== undefined)
1040
- call.live = (call.live ?? "") + patch.live;
1072
+ call.live = patch.live;
1041
1073
  // The question rides on the call rather than beside it, so this is the only write it needs.
1042
1074
  if (patch.question !== undefined)
1043
1075
  call.question = patch.question;
@@ -1067,6 +1099,14 @@ export class Store {
1067
1099
  message.piEntryId = piEntryId;
1068
1100
  await this.persist();
1069
1101
  }
1102
+ /** Cache a locator Pi itself resolved while serving this conversation; never trust it as authorization. */
1103
+ async setConversationPiSessionFile(conversationId, path) {
1104
+ const conversation = this.requireConversation(conversationId);
1105
+ if (conversation.piSessionFile === path)
1106
+ return;
1107
+ conversation.piSessionFile = path;
1108
+ await this.persistMetadata(conversationId);
1109
+ }
1070
1110
  /**
1071
1111
  * Reverts the conversation to the state right after `messageId`, in `state.json` only.
1072
1112
  *
@@ -4,7 +4,8 @@ import { mkdir, stat } from "node:fs/promises";
4
4
  import { join, resolve } from "node:path";
5
5
  import { agentSpawnArgs, readAgents } from "./agents.js";
6
6
  import { resolvePiCommand, shellQuote } from "./pi-command.js";
7
- import { buildExtensionUiResponse, describeExtensionUiRequest, killTree, RpcChannel, } from "./runtime.js";
7
+ import { buildExtensionUiResponse, describeExtensionUiRequest, killTree, } from "./runtime.js";
8
+ import { RpcChannel } from "./rpc-channel.js";
8
9
  import { describeToolCall, TOOL_DETAIL_CAP } from "./tool-calls.js";
9
10
  /**
10
11
  * The tool-call derivation now lives in `tool-calls.js`, because the primary agent's runtime needs the
@@ -497,7 +498,7 @@ export class SubagentSupervisor {
497
498
  noteUnhandled(`type ${JSON.stringify(record.type ?? "(missing type)")}`);
498
499
  }
499
500
  // A dead child will never settle, so waiting out another poll would only delay the failure.
500
- if (!settled && child.exitCode !== null)
501
+ if (!settled && channel.closed)
501
502
  throw new Error(`sub-agent ${agentId} (${id}) exited before settling (code ${child.exitCode}). ${channel.stderr.trim()}`);
502
503
  }
503
504
  for (const [key, count] of repeats)
@@ -0,0 +1,35 @@
1
+ export type MountedTool = {
2
+ id: string;
3
+ slug: string;
4
+ name: string;
5
+ description?: string;
6
+ workspaceId: string;
7
+ entrypoint: string;
8
+ source: "user" | "builtin";
9
+ enabled: boolean;
10
+ createdAt: string;
11
+ updatedAt: string;
12
+ };
13
+ export declare class ToolRegistry {
14
+ private readonly dataDir;
15
+ private readonly file;
16
+ private items;
17
+ private loaded;
18
+ constructor(dataDir: string);
19
+ private load;
20
+ private save;
21
+ list(): Promise<MountedTool[]>;
22
+ register(input: {
23
+ workspaceId: string;
24
+ slug: string;
25
+ name: string;
26
+ description?: string;
27
+ entrypoint: string;
28
+ }, workspaceRoot: string): Promise<MountedTool>;
29
+ remove(id: string): Promise<boolean>;
30
+ setEnabled(id: string, enabled: boolean): Promise<MountedTool | undefined>;
31
+ resolve(id: string, workspaceRoot: string, resource?: string): Promise<{
32
+ tool: MountedTool;
33
+ file: string;
34
+ } | undefined>;
35
+ }
@@ -0,0 +1,74 @@
1
+ import { mkdir, readFile, rename, writeFile, access, stat } from "node:fs/promises";
2
+ import { dirname, join, relative, resolve, sep } from "node:path";
3
+ const MAX_TOOLS = 128;
4
+ const MAX_SLUG = 64;
5
+ const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
6
+ export class ToolRegistry {
7
+ dataDir;
8
+ file;
9
+ items = [];
10
+ loaded = false;
11
+ constructor(dataDir) {
12
+ this.dataDir = dataDir;
13
+ this.file = join(dataDir, "tools.json");
14
+ }
15
+ async load() {
16
+ if (this.loaded)
17
+ return;
18
+ this.loaded = true;
19
+ try {
20
+ const parsed = JSON.parse(await readFile(this.file, "utf8"));
21
+ if (Array.isArray(parsed))
22
+ this.items = parsed.filter(isTool);
23
+ }
24
+ catch {
25
+ this.items = [];
26
+ }
27
+ }
28
+ async save() {
29
+ await mkdir(this.dataDir, { recursive: true });
30
+ const tmp = `${this.file}.${process.pid}.tmp`;
31
+ await writeFile(tmp, JSON.stringify(this.items, null, 2), "utf8");
32
+ await rename(tmp, this.file);
33
+ }
34
+ async list() { await this.load(); return this.items.map((x) => ({ ...x })); }
35
+ async register(input, workspaceRoot) {
36
+ await this.load();
37
+ const slug = input.slug.trim().toLowerCase();
38
+ if (!SLUG.test(slug))
39
+ throw new Error("slug 只能包含小写字母、数字和连字符");
40
+ if (!input.name.trim() || input.name.length > 120)
41
+ throw new Error("工具名称无效");
42
+ const entrypoint = safeRelative(input.entrypoint);
43
+ const root = resolve(workspaceRoot);
44
+ const file = resolve(root, entrypoint);
45
+ if (!isInside(root, file))
46
+ throw new Error("entrypoint 必须位于工作区内");
47
+ const info = await stat(file).catch(() => undefined);
48
+ if (!info?.isFile())
49
+ throw new Error("entrypoint 文件不存在");
50
+ if (!entrypoint.endsWith(".html"))
51
+ throw new Error("entrypoint 必须是 HTML 文件");
52
+ if (this.items.some((x) => x.workspaceId === input.workspaceId && x.slug === slug))
53
+ throw new Error("工具 slug 已存在");
54
+ if (this.items.length >= MAX_TOOLS)
55
+ throw new Error("工具数量已达到上限");
56
+ const now = new Date().toISOString();
57
+ const tool = { id: `tool_${crypto.randomUUID()}`, slug, name: input.name.trim(), description: input.description?.trim().slice(0, 500), workspaceId: input.workspaceId, entrypoint, source: "user", enabled: true, createdAt: now, updatedAt: now };
58
+ this.items.push(tool);
59
+ await this.save();
60
+ return { ...tool };
61
+ }
62
+ async remove(id) { await this.load(); const before = this.items.length; this.items = this.items.filter((x) => x.id !== id); if (this.items.length !== before)
63
+ await this.save(); return before !== this.items.length; }
64
+ async setEnabled(id, enabled) { await this.load(); const item = this.items.find((x) => x.id === id); if (!item)
65
+ return undefined; item.enabled = enabled; item.updatedAt = new Date().toISOString(); await this.save(); return { ...item }; }
66
+ async resolve(id, workspaceRoot, resource = "") { await this.load(); const tool = this.items.find((x) => x.id === id && x.enabled); if (!tool)
67
+ return undefined; const root = resolve(workspaceRoot); const entrypoint = resolve(root, tool.entrypoint); if (!isInside(root, entrypoint))
68
+ return undefined; const file = resource ? resolve(dirname(entrypoint), safeRelative(resource)) : entrypoint; if (!isInside(dirname(entrypoint), file))
69
+ throw new Error("工具资源必须位于工具目录内"); await access(file).catch(() => { throw new Error("工具文件不存在"); }); return { tool: { ...tool }, file }; }
70
+ }
71
+ function safeRelative(value) { const p = value.replace(/\\/g, "/").trim(); if (!p || p.startsWith("/") || p.includes("\0") || p.split("/").includes(".."))
72
+ throw new Error("路径必须是工作区内的相对路径"); return p; }
73
+ function isInside(root, target) { const r = relative(root, target); return r === "" || (!r.startsWith(".." + sep) && r !== ".." && !r.includes(":")); }
74
+ function isTool(value) { const x = value; return !!x && typeof x === "object" && typeof x.id === "string" && typeof x.slug === "string" && typeof x.workspaceId === "string" && typeof x.entrypoint === "string" && (x.source === "user" || x.source === "builtin"); }