@evandu/super-pi 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,1121 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { normalizeTodos } from "./task-tool.js";
|
|
4
|
+
function id(prefix) { return `${prefix}_${crypto.randomUUID()}`; }
|
|
5
|
+
/** Mints the id a forked conversation will have, BEFORE its Pi transcript exists.
|
|
6
|
+
*
|
|
7
|
+
* Exported because the ordering demands it and no other arrangement provides it. Pi's copied file's header
|
|
8
|
+
* `id` has to BE the new conversation's id (it is what `findLocalSessionByExactId` matches), so the id has
|
|
9
|
+
* to be decided before the file is written; and the conversation must not exist until the file is safely on
|
|
10
|
+
* disk, or a failed write would leave a conversation whose model context is silently empty. The runtime is
|
|
11
|
+
* therefore handed the id and the store is handed the same one back. See `ForkRequest.forkedConversationId`. */
|
|
12
|
+
export function newConversationId() { return id("conversation"); }
|
|
13
|
+
function now() { return new Date().toISOString(); }
|
|
14
|
+
const DEFAULT_CONVERSATION_TITLE = "\u65b0\u4f1a\u8bdd";
|
|
15
|
+
/** Whether a value survives a `new Date()` round-trip. `createdAt` is typed string, but state.json only
|
|
16
|
+
* has to be JSON to be read back: an old build, an import, or a hand edit owes TypeScript nothing. */
|
|
17
|
+
function parseableTimestamp(value) {
|
|
18
|
+
return typeof value === "string" && value.length > 0 && !Number.isNaN(new Date(value).getTime());
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The server's own guard, so a poisoned message can only be rendered safe ONCE, at the boundary where
|
|
22
|
+
* outside data enters: `Store.open` reads state.json blind (`JSON.parse ... as AppState`), so a message
|
|
23
|
+
* whose `createdAt` is missing or unparseable would otherwise flow into every snapshot and every
|
|
24
|
+
* broadcast untouched. Why the store and not the client alone: the client's `time()` guard makes one bad
|
|
25
|
+
* stamp render without crashing, but by then the invalid value has already propagated through state
|
|
26
|
+
* broadcasts and persisted copies of it keep resurfacing on every restart. Normalizing here - anchored on
|
|
27
|
+
* the conversation's own updatedAt, falling back to the load instant - keeps the on-disk file untouched
|
|
28
|
+
* (the next normal persist rewrites it clean) while every snapshot handed out satisfies the declared
|
|
29
|
+
* type from this point on. The conversation's `updatedAt` was deliberately NOT audited: it feeds string
|
|
30
|
+
* sorting and a `relativeTime` that already degrades, so a bad one loses at most a sidebar label, and the
|
|
31
|
+
* type stays required so callers cannot learn to omit it.
|
|
32
|
+
*
|
|
33
|
+
* Never rebuilds conversation objects: the normalization is field-deep, so old state files load with no
|
|
34
|
+
* migration step, exactly like archivedAt and the agent/model keys do.
|
|
35
|
+
*/
|
|
36
|
+
function normalizeMessageTimestamps(state) {
|
|
37
|
+
const fallback = now();
|
|
38
|
+
for (const conversation of state.conversations ?? []) {
|
|
39
|
+
const anchor = parseableTimestamp(conversation.updatedAt) ? conversation.updatedAt : fallback;
|
|
40
|
+
for (const message of conversation.messages ?? []) {
|
|
41
|
+
if (parseableTimestamp(message?.createdAt))
|
|
42
|
+
continue;
|
|
43
|
+
if (message)
|
|
44
|
+
message.createdAt = anchor;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return state;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Why a run that was still `running` when the file was read is failed rather than left alone.
|
|
51
|
+
*
|
|
52
|
+
* Chinese because it lands in the run's `error`, which the card shows to a person; the surrounding
|
|
53
|
+
* `console.warn` is the developer-facing half of the same fact. It names the cause rather than just the
|
|
54
|
+
* verdict, because "failed" on its own leaves the reader guessing whether the child crashed, the run
|
|
55
|
+
* was cut short, or the server simply went away - and those have different fixes.
|
|
56
|
+
*/
|
|
57
|
+
const ORPHANED_RUN_ERROR = "服务器上次退出时这个子进程仍在运行;进程已随服务器消失,本次运行被标记为失败。";
|
|
58
|
+
/**
|
|
59
|
+
* Startup reconciliation: every run the previous process left `running` is an orphan.
|
|
60
|
+
*
|
|
61
|
+
* The reasoning is the same one that applies to any work claimed as in-flight by a process that no
|
|
62
|
+
* longer exists - the record is a CLAIM, and the claim cannot still be true when nothing is running.
|
|
63
|
+
* `status` is the only thing that can be wrong about such a record, so it is the only thing changed;
|
|
64
|
+
* `output`, `usage` and `sessionId` were written before the crash and are still true. `finishedAt` is
|
|
65
|
+
* the load instant rather than a guess at when the child died, because the file records no death time
|
|
66
|
+
* and inventing one would be a worse lie than an honest "we noticed this now".
|
|
67
|
+
*
|
|
68
|
+
* Mutated in place, like `normalizeMessageTimestamps`: the on-disk file is rewritten by the single
|
|
69
|
+
* persist `open` performs when this returns a non-empty list, and a state file written before sub-agent
|
|
70
|
+
* runs existed simply gains the key with no migration step.
|
|
71
|
+
*
|
|
72
|
+
* Returns the orphans so the caller can announce each one - a run that failed while nobody was watching
|
|
73
|
+
* is invisible otherwise, and the user who dispatched it deserves to learn what happened to it.
|
|
74
|
+
*/
|
|
75
|
+
function reconcileSubagentRuns(state) {
|
|
76
|
+
// `Array.isArray` rather than `?? []`: `state.json` is read blind, so a hand edit or an older build
|
|
77
|
+
// can leave anything here, and a truthy non-array would then make every iteration below throw on the
|
|
78
|
+
// first unrelated key of an unrelated failure.
|
|
79
|
+
const runs = Array.isArray(state.subagentRuns) ? state.subagentRuns : [];
|
|
80
|
+
state.subagentRuns = runs;
|
|
81
|
+
const orphaned = [];
|
|
82
|
+
for (const run of runs) {
|
|
83
|
+
if (!run || run.status !== "running")
|
|
84
|
+
continue;
|
|
85
|
+
run.status = "failed";
|
|
86
|
+
run.error = ORPHANED_RUN_ERROR;
|
|
87
|
+
run.finishedAt = now();
|
|
88
|
+
orphaned.push(run);
|
|
89
|
+
}
|
|
90
|
+
return orphaned;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Why a conversation the previous process left `running` is failed, in the same spirit and for the same
|
|
94
|
+
* audience as `ORPHANED_RUN_ERROR`: this lands in a field the chat pane renders verbatim, so it names the
|
|
95
|
+
* cause, states the verdict, and says what to do next. "failed" alone would leave the reader unable to
|
|
96
|
+
* tell a crash from a stop, and unable to tell whether their work was lost or is still worth resuming.
|
|
97
|
+
*/
|
|
98
|
+
const ORPHANED_CONVERSATION_ERROR = "服务器上次退出时这次任务还在运行,没有跑完就被中断了,已标记为失败。可以直接重新发送任务继续。";
|
|
99
|
+
/**
|
|
100
|
+
* Startup reconciliation for ORDINARY conversations - the same treatment `reconcileSubagentRuns` gives a
|
|
101
|
+
* child's run, on the same reasoning: `status: "running"` is a CLAIM that some process was working on this,
|
|
102
|
+
* and the claim cannot still be true when the process that made it no longer exists. Without this, a server
|
|
103
|
+
* killed mid-turn leaves a conversation that spins in the UI forever, never settles, and cannot be closed.
|
|
104
|
+
*
|
|
105
|
+
* ARCHIVED CONVERSATIONS ARE RECONCILED TOO, and that is a decision rather than an oversight.
|
|
106
|
+
* `archiveConversation` refuses a running conversation, so an archived one carrying `running` is only
|
|
107
|
+
* reachable from an older build, a hand edit, or a file copied between installs - which makes it rare, not
|
|
108
|
+
* impossible. Skipping them would be strictly worse than reconciling them: the row would sit quiet in the
|
|
109
|
+
* Archived group until someone pressed restore, at which point the SAME unkillable spinner reappears on a
|
|
110
|
+
* conversation the user had every reason to believe was closed. Reconciling costs one status write and one
|
|
111
|
+
* reason field on a row nobody is looking at, and it removes a landmine rather than arming one.
|
|
112
|
+
*
|
|
113
|
+
* `updatedAt` is deliberately NOT touched, and this is the one place where copying the sub-agent sweep
|
|
114
|
+
* field-for-field would be wrong: `SubagentRun.finishedAt` is a field that EXISTS to be stamped with the
|
|
115
|
+
* moment an orphan was noticed, while a conversation has no such field, and `updatedAt` is the opposite of
|
|
116
|
+
* one - it is the sidebar's recency key. Bumping it would teleport every interrupted conversation to the
|
|
117
|
+
* top of the list at once, so repairing the file would reorder the UI as a side effect of a repair the user
|
|
118
|
+
* did not ask for. `archiveConversation` and `renameConversation` both decline to touch it for the same
|
|
119
|
+
* reason. Repair is not activity.
|
|
120
|
+
*
|
|
121
|
+
* `messages` is untouched for the same reason `output`/`usage` are untouched over there: everything in them
|
|
122
|
+
* was written before the crash and is still true. Only the claim, and the reason the claim is now false, are
|
|
123
|
+
* wrong.
|
|
124
|
+
*
|
|
125
|
+
* Mutated in place and persisted by `open`, exactly like the sub-agent sweep, so a state.json written before
|
|
126
|
+
* conversations were reconciled simply gains the reason with no migration step, and a record repaired on one
|
|
127
|
+
* boot is not repaired again on the next.
|
|
128
|
+
*/
|
|
129
|
+
function reconcileConversations(state) {
|
|
130
|
+
// `Array.isArray` rather than `?? []`, for precisely the reason `reconcileSubagentRuns` does it: state.json
|
|
131
|
+
// is read blind, and a truthy non-array left by a hand edit would make every iteration below throw on the
|
|
132
|
+
// first unrelated key of an unrelated failure.
|
|
133
|
+
const conversations = Array.isArray(state.conversations) ? state.conversations : [];
|
|
134
|
+
state.conversations = conversations;
|
|
135
|
+
const orphaned = [];
|
|
136
|
+
for (const conversation of conversations) {
|
|
137
|
+
if (!conversation || conversation.status !== "running")
|
|
138
|
+
continue;
|
|
139
|
+
conversation.status = "failed";
|
|
140
|
+
conversation.error = ORPHANED_CONVERSATION_ERROR;
|
|
141
|
+
orphaned.push(conversation);
|
|
142
|
+
}
|
|
143
|
+
return orphaned;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* How many SETTLED sub-agent runs `state.json` keeps, so the file cannot grow without bound.
|
|
147
|
+
*
|
|
148
|
+
* WHY A BOUND IS NEEDED AT ALL. `beginSubagentRun` unshifts and nothing ever removes a run, so a long
|
|
149
|
+
* session that dispatches sub-agents - which is exactly what this feature encourages - would otherwise
|
|
150
|
+
* accumulate records forever. The file is not a log on disk that costs nothing to grow: it is rewritten
|
|
151
|
+
* in full on EVERY text delta of EVERY running child (see `SubagentRunRecorder.publish`), cloned into
|
|
152
|
+
* the browser on every `state` broadcast, and read at startup. A run record is bounded but not small -
|
|
153
|
+
* `output` is the last assistant message, up to the 50 KB cap of §6, plus the task text and one label
|
|
154
|
+
* per tool call - so an unbounded array makes every one of those operations slower for everybody.
|
|
155
|
+
*
|
|
156
|
+
* WHY 100. It has to be big enough that a person can scroll back through a working day without a card
|
|
157
|
+
* going missing, and small enough that the worst case stays in the range a JSON file and a browser
|
|
158
|
+
* snapshot handle comfortably. At 100 settled runs, and a deliberately pessimistic 50 KB per output,
|
|
159
|
+
* the array is ~5 MB; at 1000 it would be ~50 MB, which is enough to be felt as the app hanging on
|
|
160
|
+
* every broadcast. 100 is roughly a day's worth of sub-agent work for a person using this as a tool
|
|
161
|
+
* rather than as a load generator, and a run older than that is still recoverable in full through Pi -
|
|
162
|
+
* the record carries `sessionId`, and `switch_session` or `export_html` reads the real transcript. So
|
|
163
|
+
* what the bound costs is a scrollback affordance, not history.
|
|
164
|
+
*
|
|
165
|
+
* Exported so a test can assert the bound against the same number the store uses rather than
|
|
166
|
+
* re-deriving it.
|
|
167
|
+
*/
|
|
168
|
+
export const SUBAGENT_RUN_RETENTION = 100;
|
|
169
|
+
/**
|
|
170
|
+
* Evicts settled runs down to `SUBAGENT_RUN_RETENTION`, newest first.
|
|
171
|
+
*
|
|
172
|
+
* TWO RUNS ARE NEVER EVICTED, and both rules exist because eviction is the one operation here that can
|
|
173
|
+
* destroy evidence:
|
|
174
|
+
*
|
|
175
|
+
* 1. Anything still `running`. That row is the CLAIM that startup reconciliation looks for; removing it
|
|
176
|
+
* would silently forget a child that is still out there, which is the exact orphan this whole feature
|
|
177
|
+
* was built to make recoverable.
|
|
178
|
+
* 2. Runs of a conversation that still exists and is not archived, until there is nothing else left to
|
|
179
|
+
* drop. The alternatives go first because they are the ones nobody is looking at: the conversation
|
|
180
|
+
* was archived or deleted, so there is no transcript for the card to sit under and no `state` the
|
|
181
|
+
* user is reading. That is why this is not a plain "keep the newest N" slice - the newest N would
|
|
182
|
+
* evict an active conversation's run in favour of an archived one's.
|
|
183
|
+
*
|
|
184
|
+
* Mutated in place, like the reconciliation above, and never throws: an `archivedAt` that is not a
|
|
185
|
+
* string counts as archived rather than as a reason to keep the row.
|
|
186
|
+
*/
|
|
187
|
+
function trimSubagentRuns(runs, conversations) {
|
|
188
|
+
const settled = runs.filter((run) => run?.status !== "running");
|
|
189
|
+
// Over the bound by at most the number of live runs, so one pass is enough: everything older than
|
|
190
|
+
// `keep` settled rows is dropped, and live runs were never in that slice to begin with.
|
|
191
|
+
const excess = settled.length - SUBAGENT_RUN_RETENTION;
|
|
192
|
+
if (excess <= 0)
|
|
193
|
+
return;
|
|
194
|
+
const live = new Set(conversations.filter((conversation) => conversation && typeof conversation.archivedAt !== "string").map((conversation) => conversation.id));
|
|
195
|
+
const droppableOldestFirst = [...settled].reverse();
|
|
196
|
+
const doomed = new Set();
|
|
197
|
+
for (const run of droppableOldestFirst)
|
|
198
|
+
if (!live.has(run.conversationId))
|
|
199
|
+
doomed.add(run.id);
|
|
200
|
+
for (const run of droppableOldestFirst) {
|
|
201
|
+
if (doomed.size >= excess)
|
|
202
|
+
break;
|
|
203
|
+
if (!doomed.has(run.id))
|
|
204
|
+
doomed.add(run.id);
|
|
205
|
+
}
|
|
206
|
+
runs.splice(0, runs.length, ...runs.filter((run) => !doomed.has(run.id)));
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Startup reconciliation for the follow-up queue: an entry the previous process left `sending` goes
|
|
210
|
+
* back to `queued`.
|
|
211
|
+
*
|
|
212
|
+
* `sending` is one claim only: "this text has been written to a live Pi child's stdin as a `steer` and
|
|
213
|
+
* Pi has not confirmed delivering it". The claim dies with the process that made it, exactly like the
|
|
214
|
+
* `running` claims above - the Pi that would have delivered it was a child of the server that is no
|
|
215
|
+
* longer running, so it can never confirm anything now.
|
|
216
|
+
*
|
|
217
|
+
* RETURNING IT TO THE QUEUE, RATHER THAN DROPPING IT OR LEAVING IT SENDING, is the only honest third
|
|
218
|
+
* option:
|
|
219
|
+
*
|
|
220
|
+
* - Dropping it would lose a message the user is looking at in their dock, silently and permanently.
|
|
221
|
+
* - Leaving it `sending` would show a message as "already handed to the agent, cannot be cancelled"
|
|
222
|
+
* that in fact never reached any model and can be cancelled - a UI claiming an irreversible fact
|
|
223
|
+
* about a reversible state.
|
|
224
|
+
* - Re-queueing is TRUE: Pi drains steering before its loop can end (`agent-loop.ts:295`), and it
|
|
225
|
+
* emits `queue_update` with the item removed before it emits the delivered `message_start`
|
|
226
|
+
* (`agent-session.ts:1116-1136`). A delivered steer is therefore already gone from this list before
|
|
227
|
+
* `agent_settled` arrives, so anything still `sending` at the end of a turn provably was not
|
|
228
|
+
* delivered. Re-queued, the user sees it again and can steer it into the next turn.
|
|
229
|
+
*
|
|
230
|
+
* Mutated in place and persisted by `open`, so a queue written by a build that predates it, or by a
|
|
231
|
+
* hand edit, is repaired on the first boot rather than read as permanently stuck.
|
|
232
|
+
*
|
|
233
|
+
* `changed` is reported separately from `recovered` because they are different repairs with different
|
|
234
|
+
* consequences: a `recovered` row is a message the user can see and therefore needs announcing, while a
|
|
235
|
+
* DROPPED row is a broken file that has to be rewritten or it is re-dropped on every single boot. Neither
|
|
236
|
+
* is worth a persist on its own account - `open` persists once, for all the sweeps together.
|
|
237
|
+
*/
|
|
238
|
+
function reconcileQueuedMessages(state) {
|
|
239
|
+
// `Array.isArray` rather than `?? []`, for the same reason the two sweeps above do it: state.json is
|
|
240
|
+
// read blind, and a truthy non-array left by a hand edit would make every call below throw.
|
|
241
|
+
const queue = Array.isArray(state.queuedMessages) ? state.queuedMessages : [];
|
|
242
|
+
const missing = !Array.isArray(state.queuedMessages);
|
|
243
|
+
// An entry that is not a usable QueuedMessage is dropped rather than repaired: every field is
|
|
244
|
+
// load-bearing (the id is what cancel/steer name, the content is what Pi is handed), so a partial
|
|
245
|
+
// entry has no safe default and keeping it would put `undefined` text on the wire.
|
|
246
|
+
const usable = queue.filter((item) => Boolean(item) && typeof item?.id === "string" && typeof item?.conversationId === "string" && typeof item?.content === "string" && (item.state === "queued" || item.state === "sending"));
|
|
247
|
+
state.queuedMessages = usable;
|
|
248
|
+
const recovered = [];
|
|
249
|
+
for (const message of usable) {
|
|
250
|
+
if (message.state !== "sending")
|
|
251
|
+
continue;
|
|
252
|
+
message.state = "queued";
|
|
253
|
+
recovered.push(message);
|
|
254
|
+
}
|
|
255
|
+
return { recovered, changed: missing || usable.length !== queue.length };
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Which messages and which sub-agent runs a revert to `messageId` removes.
|
|
259
|
+
*
|
|
260
|
+
* THE BOUNDARY IS AN ARRAY INDEX, NEVER A TIMESTAMP. `store.ts` stamps both halves of a turn with
|
|
261
|
+
* `now()` at millisecond precision, so a user message and the assistant reply after it very often share
|
|
262
|
+
* one timestamp - the same reason the browser sorts messages with an explicit `seq` tiebreak
|
|
263
|
+
* (`App.tsx:870`). Sorting by time and cutting there would cut mid-turn at random. `messages` is append
|
|
264
|
+
* only and nothing ever reorders it, so its order IS the conversation's order.
|
|
265
|
+
*
|
|
266
|
+
* WHICH RUNS ARE DOOMED is decided by `startedAt` against the first removed message's `createdAt`, and
|
|
267
|
+
* that comparison is safe for the same reason the index is: a child can only be dispatched AFTER the user
|
|
268
|
+
* message of its turn exists, and both stamps come from this one process's clock. A run at or after the
|
|
269
|
+
* first removed message's stamp was therefore dispatched by a removed turn; one before it belonged to a
|
|
270
|
+
* turn that survives.
|
|
271
|
+
*
|
|
272
|
+
* Throws rather than returning an empty plan, because every caller would otherwise have to invent its
|
|
273
|
+
* own message for "that message is not in this conversation" - and a revert that silently did nothing is
|
|
274
|
+
* the failure this whole feature exists to avoid.
|
|
275
|
+
*/
|
|
276
|
+
function planRevert(state, conversationId, messageId) {
|
|
277
|
+
const conversation = state.conversations.find((item) => item?.id === conversationId);
|
|
278
|
+
if (!conversation)
|
|
279
|
+
throw new Error("会话不存在");
|
|
280
|
+
const boundaryIndex = conversation.messages.findIndex((item) => item?.id === messageId);
|
|
281
|
+
if (boundaryIndex < 0)
|
|
282
|
+
throw new Error("要回退的那条消息不在这个会话里");
|
|
283
|
+
const boundary = conversation.messages[boundaryIndex];
|
|
284
|
+
if (!boundary?.piEntryId)
|
|
285
|
+
throw new Error("这条消息没有对应的 Pi 记录,不能作为回退位置。请在它之后的任意一条消息上回退。");
|
|
286
|
+
const removed = conversation.messages.slice(boundaryIndex + 1);
|
|
287
|
+
// Unparseable stamps are compared as 0, i.e. "older than everything", which keeps a corrupted run
|
|
288
|
+
// record ALIVE rather than deleting a child that may still be the user's most recent work. Deleting on a
|
|
289
|
+
// guess is the irreversible direction; keeping a stale record costs one card that startup reconciliation
|
|
290
|
+
// already knows how to explain.
|
|
291
|
+
const firstRemovedAt = removed.length ? Date.parse(removed[0].createdAt) || 0 : Number.POSITIVE_INFINITY;
|
|
292
|
+
const doomedRunIds = state.subagentRuns
|
|
293
|
+
.filter((run) => run?.conversationId === conversationId && (Date.parse(run.startedAt) || 0) >= firstRemovedAt)
|
|
294
|
+
.map((run) => run.id);
|
|
295
|
+
return { boundaryIndex, boundaryEntryId: boundary.piEntryId, removedMessageIds: removed.map((item) => item.id), doomedRunIds };
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Brings `state.todos` back to a shape the browser can render, on boot.
|
|
299
|
+
*
|
|
300
|
+
* THE SAME REASON `reconcileQueuedMessages` EXISTS, and the same three cases, but nothing here RECOVERS
|
|
301
|
+
* anything: there is no "still running" claim in a todo list to contradict, because the list is not a
|
|
302
|
+
* record of a running thing - it is a record of what a model last said it was doing. So there is nothing to
|
|
303
|
+
* reconcile against a fact and nothing to announce; the sweep exists purely because `state.json` is read
|
|
304
|
+
* blind by `JSON.parse(...) as AppState` and owes nothing to the declared type.
|
|
305
|
+
*
|
|
306
|
+
* What it does buy, and it is not nothing:
|
|
307
|
+
*
|
|
308
|
+
* - A hand-edited or half-written `todos` (an object, a string, a list holding `null`) is replaced by the
|
|
309
|
+
* empty list rather than reaching the sidebar, where `todos.map` would throw inside React's render and
|
|
310
|
+
* unmount the whole tree. That is the `.shell` gone / zero messages / one solid #0d111b frame measured in
|
|
311
|
+
* `scripts/message-timestamps.test.mjs` for `createdAt`, one field over.
|
|
312
|
+
* - A `status` the stylesheet has no rule for is coerced to `pending`, so a typo can never render as the
|
|
313
|
+
* completed style by falling through every `===` comparison.
|
|
314
|
+
* - A missing `id` is filled in, which is what stops two identical lines of text sharing one React key.
|
|
315
|
+
*
|
|
316
|
+
* `changed` is reported so `open` persists the repair. Unlike the two sweeps beside it, this one has
|
|
317
|
+
* nothing to warn about on stdout - a repaired todo list is a fixed file, not a fact the user was shown -
|
|
318
|
+
* so it is the quietest of the three and deliberately so.
|
|
319
|
+
*/
|
|
320
|
+
function reconcileTodos(state) {
|
|
321
|
+
// Compared as JSON, before and after, rather than with `changed = missing || ...`: the sweep's whole job
|
|
322
|
+
// is to make the stored shape match `normalizeTodos`, and that is precisely a "did the bytes move"
|
|
323
|
+
// question. A boolean assembled from a handful of special cases would go stale the moment a rule is
|
|
324
|
+
// added to the validator - the sweep would keep repairing the file and stop saying so.
|
|
325
|
+
const fingerprint = (value) => (value === undefined ? "absent" : JSON.stringify(value) ?? "unserializable");
|
|
326
|
+
const before = fingerprint(state.todos);
|
|
327
|
+
const { todos } = normalizeTodos(state.todos);
|
|
328
|
+
// A key that was present but empty is REMOVED, for the reason `setTodos` deletes it rather than storing
|
|
329
|
+
// `[]`: absence and emptiness mean the same thing to every reader here, and a store that has never run a
|
|
330
|
+
// model should not carry a field claiming the model considered a plan and found none.
|
|
331
|
+
if (todos.length)
|
|
332
|
+
state.todos = todos;
|
|
333
|
+
else
|
|
334
|
+
delete state.todos;
|
|
335
|
+
return { changed: before !== fingerprint(state.todos) };
|
|
336
|
+
}
|
|
337
|
+
export class Store {
|
|
338
|
+
filePath;
|
|
339
|
+
state;
|
|
340
|
+
constructor(filePath, state) {
|
|
341
|
+
this.filePath = filePath;
|
|
342
|
+
this.state = state;
|
|
343
|
+
}
|
|
344
|
+
static async open(filePath) {
|
|
345
|
+
try {
|
|
346
|
+
const state = JSON.parse(await readFile(filePath, "utf8"));
|
|
347
|
+
if (!Array.isArray(state.backgroundTasks))
|
|
348
|
+
state.backgroundTasks = [];
|
|
349
|
+
// Every sweep runs before the store is constructed, so a single persist below publishes them together.
|
|
350
|
+
// Reconciling them in separate writes would put a file on disk in which one kind of orphan was fixed
|
|
351
|
+
// and the other was not - a half-repaired state that still needs a second restart to finish.
|
|
352
|
+
const orphaned = reconcileSubagentRuns(state);
|
|
353
|
+
const interrupted = reconcileConversations(state);
|
|
354
|
+
const requeued = reconcileQueuedMessages(state);
|
|
355
|
+
const todos = reconcileTodos(state);
|
|
356
|
+
const store = new Store(filePath, normalizeMessageTimestamps(state));
|
|
357
|
+
// Persisted here, in `open`, rather than left for the first unrelated write: a reconciled record that
|
|
358
|
+
// is only rewritten by a later mutation would still read `running` to anything that opened the file
|
|
359
|
+
// in the meantime, and would be reconciled a SECOND time on the next restart.
|
|
360
|
+
if (orphaned.length || interrupted.length || requeued.recovered.length || requeued.changed || todos.changed) {
|
|
361
|
+
for (const run of orphaned)
|
|
362
|
+
console.warn(`Super Pi: sub-agent run ${run.id} was still marked running when the last server stopped - it is now failed, because no child process survived it.`);
|
|
363
|
+
// Announced for the same reason: a conversation that failed while nobody was watching is invisible
|
|
364
|
+
// otherwise, and the user who was watching a spinner when the server died deserves to learn that
|
|
365
|
+
// their run is over rather than guessing from the fact that it stopped moving.
|
|
366
|
+
for (const conversation of interrupted)
|
|
367
|
+
console.warn(`Super Pi: conversation ${conversation.id} was still marked running when the last server stopped - it is now failed, because no turn was in flight any more.`);
|
|
368
|
+
for (const message of requeued.recovered)
|
|
369
|
+
console.warn(`Super Pi: queued message ${message.id} of conversation ${message.conversationId} was still marked sending when the last server stopped - it is back in the queue and cancellable again, because the Pi that would have delivered it is gone.`);
|
|
370
|
+
await store.persist();
|
|
371
|
+
}
|
|
372
|
+
return store;
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT")
|
|
376
|
+
throw error;
|
|
377
|
+
const createdAt = now();
|
|
378
|
+
return new Store(filePath, { workspaces: [{ id: "workspace_default", name: "Super Pi", path: process.cwd(), updatedAt: createdAt }], conversations: [], subagentRuns: [], queuedMessages: [], backgroundTasks: [] });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
snapshot() { return structuredClone(this.state); }
|
|
382
|
+
async createWorkspace(name, path) {
|
|
383
|
+
const cleanName = name.trim();
|
|
384
|
+
if (!cleanName || cleanName.length > 80)
|
|
385
|
+
throw new Error("Workspace name must be 1-80 characters");
|
|
386
|
+
const cleanPath = path?.trim() || process.cwd();
|
|
387
|
+
if (cleanPath.length > 500)
|
|
388
|
+
throw new Error("Workspace path is too long");
|
|
389
|
+
const workspace = { id: id("workspace"), name: cleanName, path: cleanPath, updatedAt: now() };
|
|
390
|
+
this.state.workspaces.push(workspace);
|
|
391
|
+
await this.persist();
|
|
392
|
+
return structuredClone(workspace);
|
|
393
|
+
}
|
|
394
|
+
async renameWorkspace(workspaceId, name) {
|
|
395
|
+
const cleanName = name.trim();
|
|
396
|
+
if (!cleanName || cleanName.length > 80)
|
|
397
|
+
throw new Error("Workspace name must be 1-80 characters");
|
|
398
|
+
const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
|
|
399
|
+
if (!workspace)
|
|
400
|
+
throw new Error("Workspace not found");
|
|
401
|
+
workspace.name = cleanName;
|
|
402
|
+
workspace.updatedAt = now();
|
|
403
|
+
await this.persist();
|
|
404
|
+
return structuredClone(workspace);
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Replaces the workspace's two defaults. `undefined` DELETES the key rather than storing Pi's
|
|
408
|
+
* default as this app's state - the same convention as setConversationThinking, and for the same
|
|
409
|
+
* reason: "follow Pi" must be the absence of a value, not a frozen copy of whatever Pi's default
|
|
410
|
+
* happened to be when the dialog was saved. Only conversations created AFTER this call ever see
|
|
411
|
+
* the new values; existing ones are not migrated, by design.
|
|
412
|
+
*/
|
|
413
|
+
/**
|
|
414
|
+
* Replaces the workspace's defaults. `model`/`thinkingLevel` are ALWAYS sent whole (null clears one of
|
|
415
|
+
* them); `agentId` is tri-state, which is why it is not part of that pair: a string sets it, `null`
|
|
416
|
+
* clears it, and `undefined` leaves it alone. Bundling a tri-state field into an "absent means delete"
|
|
417
|
+
* pair would make any caller that only wanted to change the model silently drop the default agent.
|
|
418
|
+
*
|
|
419
|
+
* ONE persist for all three, deliberately. Two writes would publish an intermediate state - the pair
|
|
420
|
+
* saved but not the agent - and the window between them is observable: a client that reopens the
|
|
421
|
+
* settings dialog after seeing the first write on disk would mount from a broadcast that has not been
|
|
422
|
+
* sent yet, and its draft would then be frozen at the stale value. One write, one broadcast, no window.
|
|
423
|
+
*/
|
|
424
|
+
async setWorkspaceDefaults(workspaceId, defaults) {
|
|
425
|
+
const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
|
|
426
|
+
if (!workspace)
|
|
427
|
+
throw new Error("Workspace not found");
|
|
428
|
+
if (defaults.model === undefined)
|
|
429
|
+
delete workspace.defaultModel;
|
|
430
|
+
else
|
|
431
|
+
workspace.defaultModel = { provider: defaults.model.provider, modelId: defaults.model.modelId, label: defaults.model.label };
|
|
432
|
+
if (defaults.thinkingLevel === undefined)
|
|
433
|
+
delete workspace.defaultThinkingLevel;
|
|
434
|
+
else
|
|
435
|
+
workspace.defaultThinkingLevel = defaults.thinkingLevel;
|
|
436
|
+
if (defaults.agentId === undefined) { /* absent: leave the default agent exactly as it is */ }
|
|
437
|
+
else if (defaults.agentId === null)
|
|
438
|
+
delete workspace.defaultAgentId;
|
|
439
|
+
else
|
|
440
|
+
workspace.defaultAgentId = defaults.agentId;
|
|
441
|
+
workspace.updatedAt = now();
|
|
442
|
+
await this.persist();
|
|
443
|
+
return structuredClone(workspace);
|
|
444
|
+
}
|
|
445
|
+
async deleteWorkspace(workspaceId) {
|
|
446
|
+
if (!this.state.workspaces.some((item) => item.id === workspaceId))
|
|
447
|
+
throw new Error("Workspace not found");
|
|
448
|
+
if (this.state.workspaces.length <= 1)
|
|
449
|
+
throw new Error("Cannot delete the last workspace");
|
|
450
|
+
// Cascade: workspaces, conversations and their waiting queues live in one state.json, so this single
|
|
451
|
+
// atomic persist removes all three together. The queue has to follow: a waiting message is keyed by
|
|
452
|
+
// conversation id, so an entry left behind would name a conversation that no longer exists and could
|
|
453
|
+
// never be delivered or cancelled - invisible to the browser AND undeletable from the UI.
|
|
454
|
+
const doomed = new Set(this.state.conversations.filter((item) => item.workspaceId === workspaceId).map((item) => item.id));
|
|
455
|
+
this.state.workspaces = this.state.workspaces.filter((item) => item.id !== workspaceId);
|
|
456
|
+
this.state.conversations = this.state.conversations.filter((item) => item.workspaceId !== workspaceId);
|
|
457
|
+
this.state.queuedMessages = this.state.queuedMessages.filter((item) => !doomed.has(item.conversationId));
|
|
458
|
+
await this.persist();
|
|
459
|
+
return [...doomed];
|
|
460
|
+
}
|
|
461
|
+
async createConversation(workspaceId) {
|
|
462
|
+
const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
|
|
463
|
+
if (!workspace)
|
|
464
|
+
throw new Error("Workspace not found");
|
|
465
|
+
const conversation = { id: id("conversation"), title: DEFAULT_CONVERSATION_TITLE, workspaceId, updatedAt: now(), status: "idle", messages: [] };
|
|
466
|
+
// INHERITANCE, AS A COPY AT BIRTH. The workspace's defaults are stamped onto the new conversation
|
|
467
|
+
// here and never consulted again: the session picker edits the conversation's own fields, so an
|
|
468
|
+
// existing conversation is untouched by later workspace settings, and a workspace edit never
|
|
469
|
+
// rewrites history. Field by field, never a spread, for the same reason setConversationModel
|
|
470
|
+
// narrows: the type declares three fields, so three fields are written.
|
|
471
|
+
if (workspace.defaultModel)
|
|
472
|
+
conversation.model = { provider: workspace.defaultModel.provider, modelId: workspace.defaultModel.modelId, label: workspace.defaultModel.label };
|
|
473
|
+
if (workspace.defaultThinkingLevel)
|
|
474
|
+
conversation.thinkingLevel = workspace.defaultThinkingLevel;
|
|
475
|
+
if (workspace.defaultAgentId)
|
|
476
|
+
conversation.agentId = workspace.defaultAgentId;
|
|
477
|
+
this.state.conversations.unshift(conversation);
|
|
478
|
+
await this.persist();
|
|
479
|
+
return structuredClone(conversation);
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Copies a conversation into a NEW one, under an id the caller has already minted.
|
|
483
|
+
*
|
|
484
|
+
* THE ID IS A PARAMETER, and that is the whole reason this method does not simply mint one: the caller had
|
|
485
|
+
* to write Pi's copied transcript under that id first (see `ForkRequest.forkedConversationId`), so by the
|
|
486
|
+
* time this runs the id is already the one on disk. Minting here as well would produce a conversation whose
|
|
487
|
+
* Pi file belongs to a different, nonexistent conversation - the exact "Pi opens an empty session and the
|
|
488
|
+
* history has vanished with no error anywhere" failure probe B was about.
|
|
489
|
+
*
|
|
490
|
+
* WHAT IS COPIED, FIELD BY FIELD AND NEVER BY A SPREAD, for the same reason `createConversation` writes out
|
|
491
|
+
* three fields individually: the type declares exactly these, so a spread would quietly start persisting
|
|
492
|
+
* whatever the next person adds to `Conversation`. The workspace, the model, the thinking level and the
|
|
493
|
+
* agent are all copied, because a fork that came back on a different model or with no role would not be
|
|
494
|
+
* the conversation the user forked.
|
|
495
|
+
*
|
|
496
|
+
* `title` is copied VERBATIM, unchanged. Not a reason, not a suffix: the fork IS this conversation, and
|
|
497
|
+
* inventing a name is a product decision the request did not delegate. Renaming it is one click and always
|
|
498
|
+
* available, which is better than a label nobody chose.
|
|
499
|
+
*
|
|
500
|
+
* MESSAGES ARE COPIED WITH FRESH `id`s AND THE ORIGINAL `piEntryId`s. Both halves matter:
|
|
501
|
+
*
|
|
502
|
+
* - Fresh ids, because `Message.id` is this store's own handle (`beginAssistantMessage`,
|
|
503
|
+
* `appendToolCall` and `appendAssistantDelta` all find a message by it) and a forked conversation that
|
|
504
|
+
* answered to its source's ids would be one object wearing two names. Fork the same conversation twice
|
|
505
|
+
* and the two copies are distinct records.
|
|
506
|
+
* - The ORIGINAL `piEntryId`, because that is the whole reason the fork is a fork. Pi's copied file is
|
|
507
|
+
* byte-identical apart from two header fields (see `forkSessionFile`), so the source's entry ids are
|
|
508
|
+
* present and valid in the fork - and a copied message keeps the ability to be a REVERT boundary in the
|
|
509
|
+
* conversation it was copied into. Dropping the ids would produce a fork whose history cannot be
|
|
510
|
+
* reverted at all, which is a silent loss of a feature rather than an honest absence.
|
|
511
|
+
*
|
|
512
|
+
* `status` is `"idle"` unconditionally, even when the source is running, because the caller refuses a
|
|
513
|
+
* running source outright (`runtime.fork`) and a conversation that has never run a turn must not be born
|
|
514
|
+
* claiming to be in one. `error` is NOT copied: it describes a run, and this conversation has had none.
|
|
515
|
+
*
|
|
516
|
+
* NOT COPIED, and both omissions are deliberate and both are the same judgement - they belong to a RUN,
|
|
517
|
+
* and no run was copied: `subagentRuns` are filtered by `conversationId`, so a run dispatched by the source
|
|
518
|
+
* stays with the source; a run dispatched by the FORK will be filed under the fork's own id and work as
|
|
519
|
+
* usual. `queuedMessages` likewise, for the same reason (research pitfall 7): a message waiting for a turn
|
|
520
|
+
* that was never copied must not be re-sent into a different conversation.
|
|
521
|
+
*
|
|
522
|
+
* POSITION IS NOT CHOSEN HERE. `unshift` plus the client rendering the sidebar by `updatedAt`
|
|
523
|
+
* (`byRecency`) puts a fork at the TOP of its workspace group, because `updatedAt` is now - which is the
|
|
524
|
+
* same position a newly created conversation takes, and the same reason.
|
|
525
|
+
*/
|
|
526
|
+
async forkConversation(sourceConversationId, forkedConversationId) {
|
|
527
|
+
const source = this.requireConversation(sourceConversationId);
|
|
528
|
+
if (source.id === forkedConversationId)
|
|
529
|
+
throw new Error("Cannot fork a conversation into itself");
|
|
530
|
+
const conversation = {
|
|
531
|
+
id: forkedConversationId,
|
|
532
|
+
title: source.title,
|
|
533
|
+
workspaceId: source.workspaceId,
|
|
534
|
+
updatedAt: now(),
|
|
535
|
+
status: "idle",
|
|
536
|
+
messages: structuredClone(source.messages).map((message) => ({ ...message, id: id("message") })),
|
|
537
|
+
};
|
|
538
|
+
if (source.model)
|
|
539
|
+
conversation.model = { provider: source.model.provider, modelId: source.model.modelId, label: source.model.label };
|
|
540
|
+
if (source.thinkingLevel)
|
|
541
|
+
conversation.thinkingLevel = source.thinkingLevel;
|
|
542
|
+
if (source.agentId)
|
|
543
|
+
conversation.agentId = source.agentId;
|
|
544
|
+
this.state.conversations.unshift(conversation);
|
|
545
|
+
await this.persist();
|
|
546
|
+
return structuredClone(conversation);
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Archive is a marker, not a move: the conversation keeps its id, workspace, messages and position.
|
|
550
|
+
* `updatedAt` is deliberately NOT touched, because it drives the sidebar's live timestamp and recency -
|
|
551
|
+
* bumping it would teleport the row to the top of the list the instant it was archived.
|
|
552
|
+
*
|
|
553
|
+
* A running conversation is rejected rather than archived. An archived conversation cannot be opened,
|
|
554
|
+
* so allowing one would leave a run streaming into a pane the user can no longer reach, along with any
|
|
555
|
+
* Extension UI question it is blocked on. Refusing keeps the rule honest and leaves stop-then-archive
|
|
556
|
+
* to the user, which is a visible choice instead of a silent one.
|
|
557
|
+
*/
|
|
558
|
+
async archiveConversation(conversationId) {
|
|
559
|
+
const conversation = this.requireConversation(conversationId);
|
|
560
|
+
if (conversation.archivedAt)
|
|
561
|
+
return;
|
|
562
|
+
if (conversation.status === "running")
|
|
563
|
+
throw new Error("运行中的会话无法归档,请先停止运行");
|
|
564
|
+
conversation.archivedAt = now();
|
|
565
|
+
await this.persist();
|
|
566
|
+
}
|
|
567
|
+
/** Restoring deletes the key rather than setting it to null, so a fully restored conversation serializes
|
|
568
|
+
* byte-identically to one that was never archived. `delete` is legal here because the field is optional. */
|
|
569
|
+
async unarchiveConversation(conversationId) {
|
|
570
|
+
const conversation = this.requireConversation(conversationId);
|
|
571
|
+
if (!conversation.archivedAt)
|
|
572
|
+
return;
|
|
573
|
+
delete conversation.archivedAt;
|
|
574
|
+
await this.persist();
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Rename edits the title and NOTHING else. In particular `updatedAt` is deliberately not touched,
|
|
578
|
+
* for exactly the reason archive is: `updatedAt` is the sidebar's recency key, so bumping it would
|
|
579
|
+
* teleport the row to the top of the live list because someone retitled it. Renaming is not activity.
|
|
580
|
+
*
|
|
581
|
+
* There is no uniqueness check, and there is no status check on purpose. `createWorkspace` does not
|
|
582
|
+
* reject a duplicate name either, so rejecting one here would make the two paths disagree about the
|
|
583
|
+
* same question; two rows reading "notes" is a cosmetic problem the user can see and fix. And a
|
|
584
|
+
* RUNNING conversation stays renameable: the title is display-only, the run is unaffected, and
|
|
585
|
+
* refusing would mean a conversation could be permanently un-renameable while the agent works.
|
|
586
|
+
*
|
|
587
|
+
* Archived conversations are renameable too - archiving folds a row away, it does not delete it, and
|
|
588
|
+
* the Archived group renders the same name.
|
|
589
|
+
*/
|
|
590
|
+
async renameConversation(conversationId, name) {
|
|
591
|
+
const cleanName = name.trim();
|
|
592
|
+
if (!cleanName || cleanName.length > 80)
|
|
593
|
+
throw new Error("Conversation name must be 1-80 characters");
|
|
594
|
+
const conversation = this.requireConversation(conversationId);
|
|
595
|
+
conversation.title = cleanName;
|
|
596
|
+
await this.persist();
|
|
597
|
+
return structuredClone(conversation);
|
|
598
|
+
}
|
|
599
|
+
async addUserMessage(conversationId, content, images) {
|
|
600
|
+
const conversation = this.requireConversation(conversationId);
|
|
601
|
+
// The browser cannot reach an archived conversation, so this guard exists for any other caller -
|
|
602
|
+
// a direct WebSocket client, or a future code path. Archiving is the promise that a conversation is
|
|
603
|
+
// closed, and a closed conversation must not accept a new turn.
|
|
604
|
+
if (conversation.archivedAt)
|
|
605
|
+
throw new Error("无法向已归档的会话发送消息,请先恢复该会话");
|
|
606
|
+
const message = { id: id("message"), role: "user", content, createdAt: now() };
|
|
607
|
+
if (images && images.length > 0)
|
|
608
|
+
message.images = structuredClone(images);
|
|
609
|
+
conversation.messages.push(message);
|
|
610
|
+
const firstUser = conversation.messages.find((item) => item.role === "user");
|
|
611
|
+
const autoTitle = firstUser?.content.trim()
|
|
612
|
+
? firstUser.content.slice(0, 42)
|
|
613
|
+
: (firstUser?.images?.length ? "【图片】" : DEFAULT_CONVERSATION_TITLE);
|
|
614
|
+
conversation.title = autoTitle || DEFAULT_CONVERSATION_TITLE;
|
|
615
|
+
conversation.updatedAt = message.createdAt;
|
|
616
|
+
conversation.status = "running";
|
|
617
|
+
// The reason this conversation was failed - startup reconciliation's note that a previous run was cut
|
|
618
|
+
// short - describes THAT run, and this line is the moment it stops being the current one. Deleting the
|
|
619
|
+
// key rather than overwriting it keeps the file byte-identical to a conversation that was never
|
|
620
|
+
// reconciled, and it is deleted here rather than at settle because the reason is wrong from the instant
|
|
621
|
+
// the new turn starts: leaving it up would show "was interrupted, marked failed" above a conversation
|
|
622
|
+
// that is visibly working again.
|
|
623
|
+
delete conversation.error;
|
|
624
|
+
await this.persist();
|
|
625
|
+
return structuredClone(message);
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Records the model a conversation runs on. `updatedAt` is deliberately NOT touched, for the same
|
|
629
|
+
* reason rename does not touch it: it is the sidebar's recency key, and switching model is not
|
|
630
|
+
* activity in the conversation. No status check either - changing the model of a RUNNING
|
|
631
|
+
* conversation is allowed, because the selection is read at the START of the next turn, so the
|
|
632
|
+
* turn in flight is unaffected and refusing would strand a conversation on a model the user can
|
|
633
|
+
* no longer change until it settles.
|
|
634
|
+
*/
|
|
635
|
+
async setConversationModel(conversationId, model) {
|
|
636
|
+
const conversation = this.requireConversation(conversationId);
|
|
637
|
+
// Built field by field, NEVER `{ ...model }`. A spread copies whatever it is handed, and the
|
|
638
|
+
// caller in index.ts has a full `ModelOption` in hand (Pi's `detail`, `reasoning`,
|
|
639
|
+
// `contextWindow` and `imageInput`), so a spread persisted every one of them into a field this
|
|
640
|
+
// type declares as three: the declared shape would be a lie about the bytes on disk, and anything
|
|
641
|
+
// reasoning from the type would be reasoning about a file that does not have that shape.
|
|
642
|
+
//
|
|
643
|
+
// Pi's capability metadata belongs to the CATALOG, which re-reads it from Pi on every fetch and
|
|
644
|
+
// therefore cannot go stale. A copy frozen into a conversation would be wrong the moment Pi
|
|
645
|
+
// changed a model, and nothing anywhere reads it: `sessionCommands` sends only provider and
|
|
646
|
+
// modelId, and the client renders each option's detail from the catalog list rather than from
|
|
647
|
+
// the stored selection. Narrowing is free and makes the type true.
|
|
648
|
+
//
|
|
649
|
+
// The field count is deliberately not written down here. It changed when `imageInput` landed, and
|
|
650
|
+
// a comment naming the old number would have kept saying six while the object carried seven -
|
|
651
|
+
// which is the exact failure this whole note is about, one level up. scripts/session-picker.test.mjs
|
|
652
|
+
// pins the real key set instead, and re-breaks whenever a field is added.
|
|
653
|
+
conversation.model = { provider: model.provider, modelId: model.modelId, label: model.label };
|
|
654
|
+
await this.persist();
|
|
655
|
+
return structuredClone(conversation);
|
|
656
|
+
}
|
|
657
|
+
/** Restoring Pi's own default deletes the key rather than storing Pi's default as this app's state. */
|
|
658
|
+
async setConversationThinking(conversationId, level) {
|
|
659
|
+
const conversation = this.requireConversation(conversationId);
|
|
660
|
+
if (level === undefined)
|
|
661
|
+
delete conversation.thinkingLevel;
|
|
662
|
+
else
|
|
663
|
+
conversation.thinkingLevel = level;
|
|
664
|
+
await this.persist();
|
|
665
|
+
return structuredClone(conversation);
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Records the agent a conversation runs as. Same three rules as `setConversationModel`: `undefined`
|
|
669
|
+
* DELETES the key rather than freezing "no agent" as a value, `updatedAt` is untouched because
|
|
670
|
+
* changing role is not activity in the conversation, and no status check - the agent is read at the
|
|
671
|
+
* START of the next turn, so the turn in flight is unaffected.
|
|
672
|
+
*/
|
|
673
|
+
async setConversationAgent(conversationId, agentId) {
|
|
674
|
+
const conversation = this.requireConversation(conversationId);
|
|
675
|
+
if (agentId === undefined)
|
|
676
|
+
delete conversation.agentId;
|
|
677
|
+
else
|
|
678
|
+
conversation.agentId = agentId;
|
|
679
|
+
await this.persist();
|
|
680
|
+
return structuredClone(conversation);
|
|
681
|
+
}
|
|
682
|
+
async beginAssistantMessage(conversationId) { const conversation = this.requireConversation(conversationId); const message = { id: id("message"), role: "assistant", content: "", createdAt: now() }; conversation.messages.push(message); await this.persist(); return structuredClone(message); }
|
|
683
|
+
async appendAssistantDelta(conversationId, messageId, delta) { const conversation = this.requireConversation(conversationId); const message = conversation.messages.find((item) => item.id === messageId); if (!message)
|
|
684
|
+
throw new Error("Assistant message not found"); message.content += delta; conversation.updatedAt = now(); await this.persist(); }
|
|
685
|
+
/**
|
|
686
|
+
* Appends one tool call to the message it belongs to, and returns the message's whole `toolCalls`
|
|
687
|
+
* array so the caller can broadcast it as one snapshot.
|
|
688
|
+
*
|
|
689
|
+
* WHY AN APPEND AND NOT AN UPDATE, and why the returned array is the whole truth. A turn's calls arrive
|
|
690
|
+
* one at a time and each one REWRITES a field on a row it has already broadcast - `status` and
|
|
691
|
+
* `durationMs` land on `tool_execution_end`, minutes or seconds after the row was published by
|
|
692
|
+
* `tool_execution_start`. So the only thing that can be cheap and correct is: the store owns the array,
|
|
693
|
+
* the caller sends the array. Handing back the array rather than the one row is what stops the two from
|
|
694
|
+
* drifting - the caller has nothing to merge, so there is no merge to get wrong, and the event it sends
|
|
695
|
+
* is the same shape `state` carries on the next broadcast.
|
|
696
|
+
*
|
|
697
|
+
* PERSISTED ON EVERY CALL, for the same reason `appendAssistantDelta` is: a block that was on screen
|
|
698
|
+
* when the browser reloaded has to come back, and a reload that showed a `read` but not the `bash` that
|
|
699
|
+
* followed it would be worse than showing neither.
|
|
700
|
+
*
|
|
701
|
+
* `toolCalls` is CREATED LAZILY, so a message whose turn used no tools stores no empty array - which is
|
|
702
|
+
* what makes a `state.json` written before call blocks existed read as "this turn used no tools"
|
|
703
|
+
* instead of as a field it does not understand.
|
|
704
|
+
*/
|
|
705
|
+
async appendToolCall(conversationId, messageId, call) {
|
|
706
|
+
const conversation = this.requireConversation(conversationId);
|
|
707
|
+
const message = conversation.messages.find((item) => item.id === messageId);
|
|
708
|
+
if (!message)
|
|
709
|
+
throw new Error("Assistant message not found");
|
|
710
|
+
const calls = (message.toolCalls ??= []);
|
|
711
|
+
calls.push(structuredClone(call));
|
|
712
|
+
conversation.updatedAt = now();
|
|
713
|
+
await this.persist();
|
|
714
|
+
return structuredClone(calls);
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Fills in the outcome of the call at `index`, or returns the array UNCHANGED when there is no such
|
|
718
|
+
* index.
|
|
719
|
+
*
|
|
720
|
+
* ADDRESSED BY POSITION rather than by Pi's `toolCallId`, and that is the whole reason the caller has
|
|
721
|
+
* to hold a map. `toolCallId` is an identity only Pi can resolve - it is never persisted on
|
|
722
|
+
* `ToolCallBlock` - so the caller resolves each end back to the row it started, which is exactly what
|
|
723
|
+
* `SubagentRunRecorder` does for a card.
|
|
724
|
+
*
|
|
725
|
+
* A MISMATCH IS NOT AN ERROR. `tool_execution_end` with no matching start, or one that arrives after the
|
|
726
|
+
* turn ended, must not fail the turn: the alternative is a store method that throws inside a drain loop,
|
|
727
|
+
* and the reader would lose the whole turn's transcript over one unresolvable id. Returning the array
|
|
728
|
+
* unchanged keeps the caller broadcasting what it actually knows.
|
|
729
|
+
*
|
|
730
|
+
* Only `status` and `durationMs` are written, and only when they are present on the incoming block - a
|
|
731
|
+
* second end for a row that already has an outcome must not erase it with an absent field.
|
|
732
|
+
*/
|
|
733
|
+
async settleToolCall(conversationId, messageId, index, outcome) {
|
|
734
|
+
const conversation = this.requireConversation(conversationId);
|
|
735
|
+
const message = conversation.messages.find((item) => item.id === messageId);
|
|
736
|
+
if (!message)
|
|
737
|
+
throw new Error("Assistant message not found");
|
|
738
|
+
const calls = message.toolCalls;
|
|
739
|
+
const call = calls?.[index];
|
|
740
|
+
if (!calls || !call)
|
|
741
|
+
return structuredClone(calls ?? []);
|
|
742
|
+
if (outcome.status !== undefined)
|
|
743
|
+
call.status = outcome.status;
|
|
744
|
+
if (outcome.durationMs !== undefined)
|
|
745
|
+
call.durationMs = outcome.durationMs;
|
|
746
|
+
conversation.updatedAt = now();
|
|
747
|
+
await this.persist();
|
|
748
|
+
return structuredClone(calls);
|
|
749
|
+
}
|
|
750
|
+
async setStatus(conversationId, status) { const conversation = this.requireConversation(conversationId); conversation.status = status; conversation.updatedAt = now(); await this.persist(); }
|
|
751
|
+
/** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
|
|
752
|
+
planRevert(conversationId, messageId) { return planRevert(this.state, conversationId, messageId); }
|
|
753
|
+
/**
|
|
754
|
+
* Records which Pi entry a message became, as reported by Pi while it was still running.
|
|
755
|
+
*
|
|
756
|
+
* Only ever set, never cleared: a message that already has an id keeps the one Pi gave it for that
|
|
757
|
+
* turn, and a second report for the same message is ignored rather than allowed to move the boundary -
|
|
758
|
+
* a boundary that moved under a user who is looking at it is exactly the silent data loss revert must not
|
|
759
|
+
* be able to cause. An unknown message id is refused for the same reason `appendAssistantDelta` refuses
|
|
760
|
+
* one: it means the caller and the store disagree about the conversation, and guessing is worse.
|
|
761
|
+
*/
|
|
762
|
+
async setMessagePiEntryId(conversationId, messageId, piEntryId) {
|
|
763
|
+
const conversation = this.requireConversation(conversationId);
|
|
764
|
+
const message = conversation.messages.find((item) => item.id === messageId);
|
|
765
|
+
if (!message)
|
|
766
|
+
throw new Error(`Message not found: ${messageId}`);
|
|
767
|
+
if (message.piEntryId)
|
|
768
|
+
return;
|
|
769
|
+
message.piEntryId = piEntryId;
|
|
770
|
+
await this.persist();
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Reverts the conversation to the state right after `messageId`, in `state.json` only.
|
|
774
|
+
*
|
|
775
|
+
* SCOPE, DELIBERATELY NARROW: this touches `messages` and `subagentRuns` and nothing else. It does not
|
|
776
|
+
* touch the workspace (that is not this process's to touch), it does not create or modify a conversation
|
|
777
|
+
* (the user's hard requirement: still the same session, same id, same row in the sidebar), and it does
|
|
778
|
+
* not record a "revert happened" marker - there is no undo to restore one for.
|
|
779
|
+
*
|
|
780
|
+
* NOT `updatedAt`. For the same reason rename and archive leave it alone: it is the sidebar's recency key,
|
|
781
|
+
* so bumping it would teleport the conversation to the top of the list as a side effect of editing its
|
|
782
|
+
* history. What a person did to a transcript is not activity IN the conversation, and the timestamps of
|
|
783
|
+
* the surviving messages are untouched, so the pane's own ordering does not move either.
|
|
784
|
+
*
|
|
785
|
+
* `planRevert` has already run by the time this is called (the server reads it to stop children), so the
|
|
786
|
+
* plan is recomputed here rather than passed in - two callers cannot disagree about what a message id
|
|
787
|
+
* means, and the recomputation is a pure scan of a small array.
|
|
788
|
+
*/
|
|
789
|
+
async revertMessages(conversationId, messageId) {
|
|
790
|
+
const plan = planRevert(this.state, conversationId, messageId);
|
|
791
|
+
const conversation = this.requireConversation(conversationId);
|
|
792
|
+
conversation.messages.splice(plan.boundaryIndex + 1);
|
|
793
|
+
if (plan.doomedRunIds.length) {
|
|
794
|
+
const doomed = new Set(plan.doomedRunIds);
|
|
795
|
+
this.state.subagentRuns = this.state.subagentRuns.filter((run) => !doomed.has(run.id));
|
|
796
|
+
}
|
|
797
|
+
await this.persist();
|
|
798
|
+
return plan;
|
|
799
|
+
}
|
|
800
|
+
requireConversation(conversationId) { const conversation = this.state.conversations.find((item) => item.id === conversationId); if (!conversation)
|
|
801
|
+
throw new Error("Conversation not found"); return conversation; }
|
|
802
|
+
// ------------------------------------------------------------- Follow-up queue
|
|
803
|
+
// The durable waiting list for messages the user sent while the agent was working. Owned HERE and not
|
|
804
|
+
// by Pi, because Pi's own queue is process memory that Super Pi destroys at the end of every turn -
|
|
805
|
+
// see `QueuedMessage`. Every method persists on every call, for the reason
|
|
806
|
+
// `setSubagentRun` does: what a browser reads and what the file holds must never differ, and a
|
|
807
|
+
// message waiting for a turn that has not started is exactly as durable as the conversation.
|
|
808
|
+
/**
|
|
809
|
+
* This conversation's waiting list, oldest first.
|
|
810
|
+
*
|
|
811
|
+
* Cloned like every other value this store hands out, because the caller broadcasts it straight to
|
|
812
|
+
* browsers and the array is mutated in place by the very next enqueue.
|
|
813
|
+
*/
|
|
814
|
+
listQueuedMessages(conversationId) {
|
|
815
|
+
return structuredClone(this.state.queuedMessages.filter((item) => item.conversationId === conversationId));
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Adds one message to the waiting list and returns it.
|
|
819
|
+
*
|
|
820
|
+
* The id is minted here and is the entry's identity for its whole life. Pi matches its own queue by
|
|
821
|
+
* message TEXT (`agent-session.ts:1123`), so two identical messages are one entry to Pi and two here;
|
|
822
|
+
* `cancel` and `steer` name the id, which is the only reason "cancel the second of two identical
|
|
823
|
+
* messages" is expressible at all. `crypto.randomUUID`, like every other id in this store.
|
|
824
|
+
*/
|
|
825
|
+
async enqueueMessage(conversationId, content, images) {
|
|
826
|
+
this.requireConversation(conversationId);
|
|
827
|
+
if (this.state.conversations.find((item) => item.id === conversationId)?.archivedAt)
|
|
828
|
+
throw new Error("无法向已归档的会话排队消息,请先恢复该会话");
|
|
829
|
+
const message = { id: id("q"), conversationId, content, state: "queued", createdAt: now() };
|
|
830
|
+
if (images && images.length > 0)
|
|
831
|
+
message.images = structuredClone(images);
|
|
832
|
+
this.state.queuedMessages.push(message);
|
|
833
|
+
await this.persist();
|
|
834
|
+
return structuredClone(message);
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Removes one waiting message. Returns it, or `undefined` when it is already gone.
|
|
838
|
+
*
|
|
839
|
+
* "Already gone" is a no-op rather than an error, and the reason is the same as `SubagentRun.stop`:
|
|
840
|
+
* the click that sent this was almost always triggered by a dock row the browser has not been told
|
|
841
|
+
* about yet, so an id that has already been delivered or cancelled is the ordinary case and must not
|
|
842
|
+
* surface as a failure.
|
|
843
|
+
*
|
|
844
|
+
* A `sending` entry REFUSES, because the text is already in a live Pi child's stdin and Pi has no way
|
|
845
|
+
* to forget one item (its only queue command is `clear_queue`, which clears every one of them,
|
|
846
|
+
* including follow-ups that have nothing to do with this click). Removing it from this list would not
|
|
847
|
+
* stop Pi from delivering it - it would make the model act on a message the user believed they had
|
|
848
|
+
* withdrawn.
|
|
849
|
+
*/
|
|
850
|
+
async cancelQueuedMessage(conversationId, queueId) {
|
|
851
|
+
const index = this.state.queuedMessages.findIndex((item) => item.id === queueId && item.conversationId === conversationId);
|
|
852
|
+
if (index < 0)
|
|
853
|
+
return undefined;
|
|
854
|
+
if (this.state.queuedMessages[index].state === "sending")
|
|
855
|
+
throw new Error("这条消息已经交给 Agent 了,取消不掉(Pi 没有撤回单条队列消息的能力)");
|
|
856
|
+
const [removed] = this.state.queuedMessages.splice(index, 1);
|
|
857
|
+
await this.persist();
|
|
858
|
+
return structuredClone(removed);
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Claims one waiting message for delivery to the live Pi process: `queued` becomes `sending`, and the
|
|
862
|
+
* entry STAYS in the list until Pi reports it delivered.
|
|
863
|
+
*
|
|
864
|
+
* The read and the write are adjacent statements with no `await` between them, which is what makes
|
|
865
|
+
* the claim atomic here. This store has no mutex, and it does not need one: JavaScript runs this
|
|
866
|
+
* synchronous run-to-completion, so a second caller arriving in the same tick observes the write above
|
|
867
|
+
* and cannot claim the same entry. Anything that needed to await between reading and writing would
|
|
868
|
+
* need real serialization instead.
|
|
869
|
+
*
|
|
870
|
+
* Throws for an unknown id and for one that is already `sending`, because both mean the caller is
|
|
871
|
+
* acting on a state the store has already moved past - and the second one means a second steer of the
|
|
872
|
+
* same message would put the text into Pi twice.
|
|
873
|
+
*/
|
|
874
|
+
async claimQueuedMessage(conversationId, queueId) {
|
|
875
|
+
const message = this.state.queuedMessages.find((item) => item.id === queueId && item.conversationId === conversationId);
|
|
876
|
+
if (!message)
|
|
877
|
+
throw new Error(`排队消息不存在或已不在队列中:${queueId}`);
|
|
878
|
+
if (message.state === "sending")
|
|
879
|
+
throw new Error(`排队消息已经交给 Agent 了:${queueId}`);
|
|
880
|
+
message.state = "sending";
|
|
881
|
+
await this.persist();
|
|
882
|
+
return structuredClone(message);
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Puts a `sending` entry back into the queue.
|
|
886
|
+
*
|
|
887
|
+
* The one legal use, and it is narrow on purpose: this is called ONLY when we can prove the message
|
|
888
|
+
* never reached a model - the turn ended while Pi still held it, or the write to Pi's stdin failed
|
|
889
|
+
* before it left. Pi drains steering before its loop can end and reports the removal before the
|
|
890
|
+
* delivered message, so a message still `sending` when the child is gone provably was not delivered,
|
|
891
|
+
* and returning it is a fact rather than a guess. It is deliberately NOT used for "Pi has not answered
|
|
892
|
+
* yet" (see `steer` in `index.ts`), where the outcome is genuinely unknown and the honest state is
|
|
893
|
+
* still `sending`.
|
|
894
|
+
*/
|
|
895
|
+
async releaseQueuedMessage(queueId) {
|
|
896
|
+
const message = this.state.queuedMessages.find((item) => item.id === queueId);
|
|
897
|
+
if (!message || message.state !== "sending")
|
|
898
|
+
return;
|
|
899
|
+
message.state = "queued";
|
|
900
|
+
await this.persist();
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Removes the OLDEST `count` messages this conversation has in `sending`, and returns them.
|
|
904
|
+
*
|
|
905
|
+
* Driven by a COUNT rather than by matching text, and that is the whole point. Pi's `queue_update`
|
|
906
|
+
* reports only the strings it still holds, and Pi itself identifies which of its entries a delivered
|
|
907
|
+
* message came from with `indexOf` on that text - so with two identical steered messages, "which one
|
|
908
|
+
* was delivered" is exactly the question Pi cannot answer. It does not need to be answered: Pi's
|
|
909
|
+
* steering queue is FIFO and Super Pi only ever steers from the front of this list, so a drop in
|
|
910
|
+
* `steering.length` from N to N-k means Super Pi's oldest k `sending` entries were delivered, in
|
|
911
|
+
* order, whatever their text was.
|
|
912
|
+
*/
|
|
913
|
+
async dropSendingMessages(conversationId, count) {
|
|
914
|
+
if (count <= 0)
|
|
915
|
+
return [];
|
|
916
|
+
const sending = this.state.queuedMessages.filter((item) => item.conversationId === conversationId && item.state === "sending");
|
|
917
|
+
// Never more than are actually in `sending`: a snapshot that arrives out of order, or a Pi that
|
|
918
|
+
// clears its own queue, must not be able to remove entries that were never delivered.
|
|
919
|
+
const doomed = sending.slice(0, Math.min(count, sending.length)).map((item) => item.id);
|
|
920
|
+
if (!doomed.length)
|
|
921
|
+
return [];
|
|
922
|
+
this.state.queuedMessages = this.state.queuedMessages.filter((item) => !doomed.includes(item.id));
|
|
923
|
+
await this.persist();
|
|
924
|
+
return structuredClone(sending.slice(0, doomed.length));
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Takes the oldest still-`queued` message of this conversation OUT of the list and returns it, for the
|
|
928
|
+
* next turn to send as its prompt.
|
|
929
|
+
*
|
|
930
|
+
* Removing it here, rather than marking it and removing it after the turn, is what bounds the loop:
|
|
931
|
+
* every pass through the follow-up loop strictly shrinks the list, so the loop cannot run forever
|
|
932
|
+
* even if a turn succeeds without consuming anything. And removing it BEFORE the spawn is what makes a
|
|
933
|
+
* crash honest - a server that dies mid-turn has already taken the message out of the file, and
|
|
934
|
+
* `open` does not put it back, because it may well have been delivered.
|
|
935
|
+
*/
|
|
936
|
+
async takeNextQueuedMessage(conversationId) {
|
|
937
|
+
const index = this.state.queuedMessages.findIndex((item) => item.conversationId === conversationId && item.state === "queued");
|
|
938
|
+
if (index < 0)
|
|
939
|
+
return undefined;
|
|
940
|
+
const [taken] = this.state.queuedMessages.splice(index, 1);
|
|
941
|
+
await this.persist();
|
|
942
|
+
return structuredClone(taken);
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Returns every `sending` message of this conversation to the queue, for the end of a turn.
|
|
946
|
+
*
|
|
947
|
+
* See `releaseQueuedMessage` for why this is a fact and not a guess, and `reconcileQueuedMessages` for
|
|
948
|
+
* why the same treatment is right after a crash. Called both when a turn ends and when the user stops
|
|
949
|
+
* it: in the stop case the child is killed mid-turn and a steer it was still holding was never
|
|
950
|
+
* delivered either, so putting it back is what lets the user decide what to do with it instead of
|
|
951
|
+
* silently dropping a message they typed.
|
|
952
|
+
*/
|
|
953
|
+
async releaseSendingMessages(conversationId) {
|
|
954
|
+
let changed = false;
|
|
955
|
+
for (const message of this.state.queuedMessages) {
|
|
956
|
+
if (message.conversationId !== conversationId || message.state !== "sending")
|
|
957
|
+
continue;
|
|
958
|
+
message.state = "queued";
|
|
959
|
+
changed = true;
|
|
960
|
+
}
|
|
961
|
+
if (changed)
|
|
962
|
+
await this.persist();
|
|
963
|
+
}
|
|
964
|
+
// ------------------------------------------------------------- Sub-agent runs
|
|
965
|
+
// Records, not transcripts: what lands here is the dispatch, the last assistant message and the
|
|
966
|
+
// usage - see `SubagentRun` for why the child's own conversation deliberately does not.
|
|
967
|
+
/**
|
|
968
|
+
* Records a sub-agent run as `running`, BEFORE the child is spawned.
|
|
969
|
+
*
|
|
970
|
+
* The ordering is the whole point of this method existing separately from the spawn. A record created
|
|
971
|
+
* after the child starts cannot survive the child failing to start, and one created only at the end
|
|
972
|
+
* leaves nothing behind when the SERVER dies - which is the orphan startup reconciliation exists to
|
|
973
|
+
* clean up. Writing the claim first is what makes that orphan possible, and therefore recoverable.
|
|
974
|
+
*
|
|
975
|
+
* `unshift`, like `createConversation`: the newest run is the one a reader is looking for.
|
|
976
|
+
*
|
|
977
|
+
* Trimmed HERE rather than at read time, because the array in memory and the file on disk have to
|
|
978
|
+
* be the same thing - a store that kept evicted rows until the next unrelated write would put them
|
|
979
|
+
* back on disk, and one that trimmed on read would show a browser a roster the file still holds.
|
|
980
|
+
*/
|
|
981
|
+
async beginSubagentRun(run) {
|
|
982
|
+
if (this.state.subagentRuns.some((item) => item.id === run.id))
|
|
983
|
+
throw new Error(`Sub-agent run already recorded: ${run.id}`);
|
|
984
|
+
this.state.subagentRuns.unshift(run);
|
|
985
|
+
trimSubagentRuns(this.state.subagentRuns, this.state.conversations);
|
|
986
|
+
await this.persist();
|
|
987
|
+
// A clone, like every other return here: the caller must not hold a reference to the object this
|
|
988
|
+
// store mutates in place on the next delta, or a run's "snapshot" would change under it.
|
|
989
|
+
return structuredClone(run);
|
|
990
|
+
}
|
|
991
|
+
async setBackgroundTask(summary) {
|
|
992
|
+
const existing = this.state.backgroundTasks.find((item) => item.id === summary.id);
|
|
993
|
+
if (existing)
|
|
994
|
+
Object.assign(existing, summary);
|
|
995
|
+
else
|
|
996
|
+
this.state.backgroundTasks.unshift(summary);
|
|
997
|
+
await this.persist();
|
|
998
|
+
}
|
|
999
|
+
async removeBackgroundTask(id) {
|
|
1000
|
+
this.state.backgroundTasks = this.state.backgroundTasks.filter((item) => item.id !== id);
|
|
1001
|
+
await this.persist();
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Applies the fields a run's life can change, and nothing else.
|
|
1005
|
+
*
|
|
1006
|
+
* `Object.assign` is safe HERE in a way it is not in `setConversationModel`, and the difference is the
|
|
1007
|
+
* patch's own type: `SubagentRunUpdate` is a named list of six already-narrowed fields, so there is no
|
|
1008
|
+
* wider object for a spread to smuggle extra keys in from. `undefined` means "no such value" and is
|
|
1009
|
+
* dropped by JSON.stringify, which is how `output`/`error`/`outputTruncated` come to be absent rather
|
|
1010
|
+
* than stored as an empty or null one.
|
|
1011
|
+
*
|
|
1012
|
+
* PERSISTED ON EVERY CALL, exactly as `appendAssistantDelta` is: the record a reader sees in a
|
|
1013
|
+
* `state` broadcast and the record on disk are then never different, and a run that is in flight is
|
|
1014
|
+
* as durable as the conversation it belongs to.
|
|
1015
|
+
*/
|
|
1016
|
+
async setSubagentRun(runId, changes) {
|
|
1017
|
+
const run = this.state.subagentRuns.find((item) => item.id === runId);
|
|
1018
|
+
if (!run)
|
|
1019
|
+
throw new Error(`Sub-agent run not found: ${runId}`);
|
|
1020
|
+
Object.assign(run, changes);
|
|
1021
|
+
await this.persist();
|
|
1022
|
+
return structuredClone(run);
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Replaces the model's task list, and persists it.
|
|
1026
|
+
*
|
|
1027
|
+
* WHY IT IS A FILE AND NOT PI'S MEMORY, stated once here because it is the whole justification: Pi is
|
|
1028
|
+
* killed unconditionally at the end of every turn (`runtime.ts`) and never writes a tool result it was
|
|
1029
|
+
* given by an extension anywhere durable except the session JSONL, so a list held only in the child
|
|
1030
|
+
* would be gone before a person could look at it - and the sidebar would empty itself at every turn
|
|
1031
|
+
* boundary. The list is therefore part of `state.json` and rides the existing broadcast, which is also
|
|
1032
|
+
* why the browser needs no channel of its own: it already repaints from `state` on every text delta.
|
|
1033
|
+
*
|
|
1034
|
+
* THE PARAMETER IS RE-NORMALIZED HERE, not trusted because the endpoint normalized it. Those are two
|
|
1035
|
+
* different things: `/api/todos` validates a request that crossed a socket, and this validates a value
|
|
1036
|
+
* handed to `setTodos` by any future caller inside this process. A method whose own contract is "call me
|
|
1037
|
+
* with anything" is the only version of this that cannot be broken by the next call site.
|
|
1038
|
+
*
|
|
1039
|
+
* AN EMPTY LIST IS STORED AS ABSENCE, not as `[]`. `normalizeTodos` returns `[]` for a list the model
|
|
1040
|
+
* emptied deliberately, and the two must not be distinguishable: the browser renders `todos: []` and
|
|
1041
|
+
* `todos: undefined` as the same empty state on purpose, and persisting `[]` for a store that has never
|
|
1042
|
+
* seen the tool would make a fresh install claim the model had considered a plan and found none. So the
|
|
1043
|
+
* key is deleted, which also keeps `state.json` free of a field no build before this one wrote.
|
|
1044
|
+
*/
|
|
1045
|
+
async setTodos(raw) {
|
|
1046
|
+
const { todos, dropped } = normalizeTodos(raw);
|
|
1047
|
+
if (todos.length)
|
|
1048
|
+
this.state.todos = todos;
|
|
1049
|
+
else
|
|
1050
|
+
delete this.state.todos;
|
|
1051
|
+
await this.persist();
|
|
1052
|
+
return { todos: structuredClone(todos), dropped };
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Writes the whole state atomically: a temp file, then a rename over the real one.
|
|
1056
|
+
*
|
|
1057
|
+
* SERIALIZED, because two conversations can genuinely run at once (see workspace-cwd.test.mjs), and
|
|
1058
|
+
* this method was not safe for that: every write used the SAME `${filePath}.tmp`, so two overlapping
|
|
1059
|
+
* persists raced and the loser's rename failed with ENOENT on a file the winner had already moved.
|
|
1060
|
+
* A unique name per write would hide that ENOENT while leaving the real bug in place - whichever rename
|
|
1061
|
+
* landed last would win, and a rename of an OLDER snapshot last would silently discard a newer one.
|
|
1062
|
+
* Queueing keeps both properties: one writer at a time, and last write wins with the newest state.
|
|
1063
|
+
*
|
|
1064
|
+
* A rejected write must not poison the queue, or one failure would reject every later persist as well.
|
|
1065
|
+
*/
|
|
1066
|
+
persistQueue = Promise.resolve();
|
|
1067
|
+
async persist() {
|
|
1068
|
+
const write = this.persistQueue.then(async () => {
|
|
1069
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
1070
|
+
const temporaryPath = `${this.filePath}.tmp`;
|
|
1071
|
+
await writeFile(temporaryPath, `${JSON.stringify(this.state, null, 2)}\n`, "utf8");
|
|
1072
|
+
await replaceFile(temporaryPath, this.filePath);
|
|
1073
|
+
});
|
|
1074
|
+
this.persistQueue = write.catch(() => { });
|
|
1075
|
+
await write;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* How long `replaceFile` keeps retrying a rename that lost a race with a reader. Two seconds is two
|
|
1080
|
+
* orders of magnitude past the measured sub-50ms release, so it exists to absorb a loaded machine and a
|
|
1081
|
+
* virus scanner mid-scan, not to wait out a lock that will never clear.
|
|
1082
|
+
*/
|
|
1083
|
+
const RENAME_RETRY_MS = 2_000;
|
|
1084
|
+
const RENAME_RETRY_STEP_MS = 5;
|
|
1085
|
+
/**
|
|
1086
|
+
* Renames `from` over `to`, retrying the WINDOWS SHARING VIOLATION.
|
|
1087
|
+
*
|
|
1088
|
+
* WHY THIS EXISTS, because the failure it fixes was a real defect and not a test artefact. On Windows a
|
|
1089
|
+
* rename onto a file that ANY handle has open fails with EPERM/EACCES: the destination must be held with
|
|
1090
|
+
* FILE_SHARE_DELETE, and a plain reader does not do that. Node's own `readFile` is such a reader, and so
|
|
1091
|
+
* is every indexer, virus scanner and sync client on the machine. So `persist()` threw EPERM, the caller's
|
|
1092
|
+
* mutation stayed only in memory, and the save was LOST - the settings dialog had already closed on send,
|
|
1093
|
+
* so the person who pressed 保存 was told nothing and kept a default that had never been saved.
|
|
1094
|
+
* scripts/workspace-settings-browser.test.mjs reproduced it at roughly 1 save in 5, and every occurrence
|
|
1095
|
+
* read as "the save never arrived" because the refusal travelled to the browser as an error event the
|
|
1096
|
+
* suite did not read, so a refused write and a slow write were indistinguishable for a whole day.
|
|
1097
|
+
*
|
|
1098
|
+
* MEASURED, so the bound is honest rather than hopeful: renaming onto a held-open `state.json` fails on
|
|
1099
|
+
* the first attempt 40 times out of 40 and clears within one 5ms tick of the reader letting go (worst
|
|
1100
|
+
* case 11 attempts). The lock lasts exactly as long as somebody is READING, which is why a bounded
|
|
1101
|
+
* retry is the fix and not a longer sleep: the transient case is short, and a genuinely stuck file is
|
|
1102
|
+
* not a sharing violation. A file that stays contended past the bound still throws, which is the honest
|
|
1103
|
+
* outcome - the store reports a failed write instead of pretending it saved.
|
|
1104
|
+
*/
|
|
1105
|
+
async function replaceFile(from, to) {
|
|
1106
|
+
const deadline = Date.now() + RENAME_RETRY_MS;
|
|
1107
|
+
for (;;) {
|
|
1108
|
+
try {
|
|
1109
|
+
await rename(from, to);
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
catch (error) {
|
|
1113
|
+
const code = error.code;
|
|
1114
|
+
const contended = code === "EPERM" || code === "EACCES" || code === "EBUSY";
|
|
1115
|
+
if (!contended || Date.now() >= deadline)
|
|
1116
|
+
throw error;
|
|
1117
|
+
await new Promise((resolve) => setTimeout(resolve, RENAME_RETRY_STEP_MS));
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
export function defaultStorePath(dataDir) { return join(dataDir, "state.json"); }
|