@evandu/super-pi 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -2
- package/agents/coder.md +10 -0
- package/agents/critic.md +20 -0
- package/agents/patcher.md +23 -0
- package/agents/planner.md +18 -0
- package/agents/reviewer.md +13 -0
- package/agents/scout.md +23 -0
- package/agents/super-pi-task/core.ts +485 -0
- package/agents/super-pi-task/index.ts +141 -0
- package/agents/verifier.md +21 -0
- package/bin/super-pi.js +17 -0
- package/dist/client/assets/index-BMQsEdio.js +24 -0
- package/dist/client/assets/index-CsbZzuMy.css +1 -0
- package/dist/client/index.html +14 -0
- package/dist/extension.d.ts +15 -0
- package/dist/extension.js +111 -0
- package/dist/server/agents.d.ts +177 -0
- package/dist/server/agents.js +484 -0
- package/dist/server/application/catalog-service.d.ts +44 -0
- package/dist/server/application/catalog-service.js +94 -0
- package/dist/server/application/conversation-service.d.ts +73 -0
- package/dist/server/application/conversation-service.js +202 -0
- package/dist/server/application/extension-ui-service.d.ts +28 -0
- package/dist/server/application/extension-ui-service.js +73 -0
- package/dist/server/application/run-service.d.ts +115 -0
- package/dist/server/application/run-service.js +341 -0
- package/dist/server/application/workspace-service.d.ts +45 -0
- package/dist/server/application/workspace-service.js +106 -0
- package/dist/server/command-handler.d.ts +22 -0
- package/dist/server/command-handler.js +137 -0
- package/dist/server/files.d.ts +202 -0
- package/dist/server/files.js +629 -0
- package/dist/server/http-server.d.ts +20 -0
- package/dist/server/http-server.js +313 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +103 -0
- package/dist/server/pi-command.d.ts +65 -0
- package/dist/server/pi-command.js +183 -0
- package/dist/server/runtime.d.ts +819 -0
- package/dist/server/runtime.js +1470 -0
- package/dist/server/server-context.d.ts +46 -0
- package/dist/server/server-context.js +1 -0
- package/dist/server/store.d.ts +424 -0
- package/dist/server/store.js +1121 -0
- package/dist/server/subagent-runs.d.ts +142 -0
- package/dist/server/subagent-runs.js +315 -0
- package/dist/server/subagents.d.ts +194 -0
- package/dist/server/subagents.js +542 -0
- package/dist/server/task-tool.d.ts +196 -0
- package/dist/server/task-tool.js +285 -0
- package/dist/server/tool-calls.d.ts +62 -0
- package/dist/server/tool-calls.js +141 -0
- package/dist/server/types.d.ts +920 -0
- package/dist/server/types.js +11 -0
- package/dist/server/upload.d.ts +37 -0
- package/dist/server/upload.js +169 -0
- package/dist/server/ws-server.d.ts +45 -0
- package/dist/server/ws-server.js +116 -0
- 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
|
+
}
|