@evandu/super-pi 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +79 -2
  2. package/agents/coder.md +10 -0
  3. package/agents/critic.md +20 -0
  4. package/agents/patcher.md +23 -0
  5. package/agents/planner.md +18 -0
  6. package/agents/reviewer.md +13 -0
  7. package/agents/scout.md +23 -0
  8. package/agents/super-pi-task/core.ts +485 -0
  9. package/agents/super-pi-task/index.ts +141 -0
  10. package/agents/verifier.md +21 -0
  11. package/bin/super-pi.js +17 -0
  12. package/dist/client/assets/index-BMQsEdio.js +24 -0
  13. package/dist/client/assets/index-CsbZzuMy.css +1 -0
  14. package/dist/client/index.html +14 -0
  15. package/dist/extension.d.ts +15 -0
  16. package/dist/extension.js +111 -0
  17. package/dist/server/agents.d.ts +177 -0
  18. package/dist/server/agents.js +484 -0
  19. package/dist/server/application/catalog-service.d.ts +44 -0
  20. package/dist/server/application/catalog-service.js +94 -0
  21. package/dist/server/application/conversation-service.d.ts +73 -0
  22. package/dist/server/application/conversation-service.js +202 -0
  23. package/dist/server/application/extension-ui-service.d.ts +28 -0
  24. package/dist/server/application/extension-ui-service.js +73 -0
  25. package/dist/server/application/run-service.d.ts +115 -0
  26. package/dist/server/application/run-service.js +341 -0
  27. package/dist/server/application/workspace-service.d.ts +45 -0
  28. package/dist/server/application/workspace-service.js +106 -0
  29. package/dist/server/command-handler.d.ts +22 -0
  30. package/dist/server/command-handler.js +137 -0
  31. package/dist/server/files.d.ts +202 -0
  32. package/dist/server/files.js +629 -0
  33. package/dist/server/http-server.d.ts +20 -0
  34. package/dist/server/http-server.js +313 -0
  35. package/dist/server/index.d.ts +1 -0
  36. package/dist/server/index.js +103 -0
  37. package/dist/server/pi-command.d.ts +65 -0
  38. package/dist/server/pi-command.js +183 -0
  39. package/dist/server/runtime.d.ts +819 -0
  40. package/dist/server/runtime.js +1470 -0
  41. package/dist/server/server-context.d.ts +46 -0
  42. package/dist/server/server-context.js +1 -0
  43. package/dist/server/store.d.ts +424 -0
  44. package/dist/server/store.js +1121 -0
  45. package/dist/server/subagent-runs.d.ts +142 -0
  46. package/dist/server/subagent-runs.js +315 -0
  47. package/dist/server/subagents.d.ts +194 -0
  48. package/dist/server/subagents.js +542 -0
  49. package/dist/server/task-tool.d.ts +196 -0
  50. package/dist/server/task-tool.js +285 -0
  51. package/dist/server/tool-calls.d.ts +62 -0
  52. package/dist/server/tool-calls.js +141 -0
  53. package/dist/server/types.d.ts +920 -0
  54. package/dist/server/types.js +11 -0
  55. package/dist/server/upload.d.ts +37 -0
  56. package/dist/server/upload.js +169 -0
  57. package/dist/server/ws-server.d.ts +45 -0
  58. package/dist/server/ws-server.js +116 -0
  59. package/package.json +115 -4
@@ -0,0 +1,46 @@
1
+ import type { Store } from "./store.js";
2
+ import type { AgentRuntime } from "./runtime.js";
3
+ import type { SubagentRunRecorder } from "./subagent-runs.js";
4
+ import type { ServerEvent } from "./types.js";
5
+ import type { WorkspaceService } from "./application/workspace-service.js";
6
+ import type { ConversationService } from "./application/conversation-service.js";
7
+ import type { RunService } from "./application/run-service.js";
8
+ import type { ExtensionUiService } from "./application/extension-ui-service.js";
9
+ import type { CatalogService } from "./application/catalog-service.js";
10
+ /**
11
+ * Common filesystem paths used across the server.
12
+ */
13
+ export interface ServerPaths {
14
+ dataDir: string;
15
+ rootDir: string;
16
+ agentsDir: string;
17
+ agentPromptDir?: string;
18
+ uploadsDir: string;
19
+ clientDir: string;
20
+ }
21
+ /**
22
+ * EventBroadcaster abstracts the WebSocket transport's broadcast mechanism
23
+ * so application services do not directly couple to WebSocket connections.
24
+ */
25
+ export interface EventBroadcaster {
26
+ /** Broadcast a server event to all connected clients. */
27
+ broadcast(event: ServerEvent): void;
28
+ /** Broadcast the current state snapshot to all connected clients. */
29
+ broadcastState(): void;
30
+ }
31
+ /**
32
+ * Central server context containing infrastructure dependencies and application services.
33
+ */
34
+ export interface ServerContext {
35
+ paths: ServerPaths;
36
+ store: Store;
37
+ runtime: AgentRuntime;
38
+ subagents: SubagentRunRecorder;
39
+ broadcaster: EventBroadcaster;
40
+ accessToken?: string;
41
+ workspaceService: WorkspaceService;
42
+ conversationService: ConversationService;
43
+ runService: RunService;
44
+ extensionUiService: ExtensionUiService;
45
+ catalogService: CatalogService;
46
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,424 @@
1
+ import type { AppState, Conversation, Message, MessageImage, ModelSelection, QueuedMessage, SubagentRun, SubagentRunUpdate, TodoItem, ToolCallBlock, Workspace } from "./types.js";
2
+ /** Mints the id a forked conversation will have, BEFORE its Pi transcript exists.
3
+ *
4
+ * Exported because the ordering demands it and no other arrangement provides it. Pi's copied file's header
5
+ * `id` has to BE the new conversation's id (it is what `findLocalSessionByExactId` matches), so the id has
6
+ * to be decided before the file is written; and the conversation must not exist until the file is safely on
7
+ * disk, or a failed write would leave a conversation whose model context is silently empty. The runtime is
8
+ * therefore handed the id and the store is handed the same one back. See `ForkRequest.forkedConversationId`. */
9
+ export declare function newConversationId(): string;
10
+ /**
11
+ * How many SETTLED sub-agent runs `state.json` keeps, so the file cannot grow without bound.
12
+ *
13
+ * WHY A BOUND IS NEEDED AT ALL. `beginSubagentRun` unshifts and nothing ever removes a run, so a long
14
+ * session that dispatches sub-agents - which is exactly what this feature encourages - would otherwise
15
+ * accumulate records forever. The file is not a log on disk that costs nothing to grow: it is rewritten
16
+ * in full on EVERY text delta of EVERY running child (see `SubagentRunRecorder.publish`), cloned into
17
+ * the browser on every `state` broadcast, and read at startup. A run record is bounded but not small -
18
+ * `output` is the last assistant message, up to the 50 KB cap of §6, plus the task text and one label
19
+ * per tool call - so an unbounded array makes every one of those operations slower for everybody.
20
+ *
21
+ * WHY 100. It has to be big enough that a person can scroll back through a working day without a card
22
+ * going missing, and small enough that the worst case stays in the range a JSON file and a browser
23
+ * snapshot handle comfortably. At 100 settled runs, and a deliberately pessimistic 50 KB per output,
24
+ * the array is ~5 MB; at 1000 it would be ~50 MB, which is enough to be felt as the app hanging on
25
+ * every broadcast. 100 is roughly a day's worth of sub-agent work for a person using this as a tool
26
+ * rather than as a load generator, and a run older than that is still recoverable in full through Pi -
27
+ * the record carries `sessionId`, and `switch_session` or `export_html` reads the real transcript. So
28
+ * what the bound costs is a scrollback affordance, not history.
29
+ *
30
+ * Exported so a test can assert the bound against the same number the store uses rather than
31
+ * re-deriving it.
32
+ */
33
+ export declare const SUBAGENT_RUN_RETENTION = 100;
34
+ /**
35
+ * Everything a revert decided, before anything is changed.
36
+ *
37
+ * TWO CALLERS NEED IT, and they must agree exactly: the server has to know which sub-agent children to
38
+ * STOP before it cuts the history out from under them, and the store has to know which records to delete
39
+ * afterwards. Two independently written answers to "what does this revert remove" would eventually
40
+ * disagree, and the shape of that disagreement is the worst one available - a stopped child whose record
41
+ * survives, or a deleted record for a child that is still running and still streaming into a store it is
42
+ * no longer in. So both read this one function, which touches nothing.
43
+ */
44
+ export type RevertPlan = {
45
+ /** Index of the message that SURVIVES. Everything from `boundaryIndex + 1` is removed. */
46
+ boundaryIndex: number;
47
+ /** Pi's entry id of the surviving message - the line Pi's file is cut at. */
48
+ boundaryEntryId: string;
49
+ /** The messages that go away, oldest first. */
50
+ removedMessageIds: string[];
51
+ /**
52
+ * Sub-agent runs dispatched by a turn that is being removed, so their records point at a transcript
53
+ * that no longer exists (research §5 pitfall 7). Runs from surviving turns are deliberately NOT here:
54
+ * `trimSubagentRuns` refuses to drop a live conversation's settled runs (`store.ts:183-195`), so a
55
+ * revert has to drop these explicitly or they become permanent orphans.
56
+ */
57
+ doomedRunIds: string[];
58
+ };
59
+ export declare class Store {
60
+ private readonly filePath;
61
+ private state;
62
+ private constructor();
63
+ static open(filePath: string): Promise<Store>;
64
+ snapshot(): AppState;
65
+ createWorkspace(name: string, path?: string): Promise<Workspace>;
66
+ renameWorkspace(workspaceId: string, name: string): Promise<Workspace>;
67
+ /**
68
+ * Replaces the workspace's two defaults. `undefined` DELETES the key rather than storing Pi's
69
+ * default as this app's state - the same convention as setConversationThinking, and for the same
70
+ * reason: "follow Pi" must be the absence of a value, not a frozen copy of whatever Pi's default
71
+ * happened to be when the dialog was saved. Only conversations created AFTER this call ever see
72
+ * the new values; existing ones are not migrated, by design.
73
+ */
74
+ /**
75
+ * Replaces the workspace's defaults. `model`/`thinkingLevel` are ALWAYS sent whole (null clears one of
76
+ * them); `agentId` is tri-state, which is why it is not part of that pair: a string sets it, `null`
77
+ * clears it, and `undefined` leaves it alone. Bundling a tri-state field into an "absent means delete"
78
+ * pair would make any caller that only wanted to change the model silently drop the default agent.
79
+ *
80
+ * ONE persist for all three, deliberately. Two writes would publish an intermediate state - the pair
81
+ * saved but not the agent - and the window between them is observable: a client that reopens the
82
+ * settings dialog after seeing the first write on disk would mount from a broadcast that has not been
83
+ * sent yet, and its draft would then be frozen at the stale value. One write, one broadcast, no window.
84
+ */
85
+ setWorkspaceDefaults(workspaceId: string, defaults: {
86
+ model?: ModelSelection;
87
+ thinkingLevel?: string;
88
+ agentId?: string | null;
89
+ }): Promise<Workspace>;
90
+ deleteWorkspace(workspaceId: string): Promise<string[]>;
91
+ createConversation(workspaceId: string): Promise<Conversation>;
92
+ /**
93
+ * Copies a conversation into a NEW one, under an id the caller has already minted.
94
+ *
95
+ * THE ID IS A PARAMETER, and that is the whole reason this method does not simply mint one: the caller had
96
+ * to write Pi's copied transcript under that id first (see `ForkRequest.forkedConversationId`), so by the
97
+ * time this runs the id is already the one on disk. Minting here as well would produce a conversation whose
98
+ * Pi file belongs to a different, nonexistent conversation - the exact "Pi opens an empty session and the
99
+ * history has vanished with no error anywhere" failure probe B was about.
100
+ *
101
+ * WHAT IS COPIED, FIELD BY FIELD AND NEVER BY A SPREAD, for the same reason `createConversation` writes out
102
+ * three fields individually: the type declares exactly these, so a spread would quietly start persisting
103
+ * whatever the next person adds to `Conversation`. The workspace, the model, the thinking level and the
104
+ * agent are all copied, because a fork that came back on a different model or with no role would not be
105
+ * the conversation the user forked.
106
+ *
107
+ * `title` is copied VERBATIM, unchanged. Not a reason, not a suffix: the fork IS this conversation, and
108
+ * inventing a name is a product decision the request did not delegate. Renaming it is one click and always
109
+ * available, which is better than a label nobody chose.
110
+ *
111
+ * MESSAGES ARE COPIED WITH FRESH `id`s AND THE ORIGINAL `piEntryId`s. Both halves matter:
112
+ *
113
+ * - Fresh ids, because `Message.id` is this store's own handle (`beginAssistantMessage`,
114
+ * `appendToolCall` and `appendAssistantDelta` all find a message by it) and a forked conversation that
115
+ * answered to its source's ids would be one object wearing two names. Fork the same conversation twice
116
+ * and the two copies are distinct records.
117
+ * - The ORIGINAL `piEntryId`, because that is the whole reason the fork is a fork. Pi's copied file is
118
+ * byte-identical apart from two header fields (see `forkSessionFile`), so the source's entry ids are
119
+ * present and valid in the fork - and a copied message keeps the ability to be a REVERT boundary in the
120
+ * conversation it was copied into. Dropping the ids would produce a fork whose history cannot be
121
+ * reverted at all, which is a silent loss of a feature rather than an honest absence.
122
+ *
123
+ * `status` is `"idle"` unconditionally, even when the source is running, because the caller refuses a
124
+ * running source outright (`runtime.fork`) and a conversation that has never run a turn must not be born
125
+ * claiming to be in one. `error` is NOT copied: it describes a run, and this conversation has had none.
126
+ *
127
+ * NOT COPIED, and both omissions are deliberate and both are the same judgement - they belong to a RUN,
128
+ * and no run was copied: `subagentRuns` are filtered by `conversationId`, so a run dispatched by the source
129
+ * stays with the source; a run dispatched by the FORK will be filed under the fork's own id and work as
130
+ * usual. `queuedMessages` likewise, for the same reason (research pitfall 7): a message waiting for a turn
131
+ * that was never copied must not be re-sent into a different conversation.
132
+ *
133
+ * POSITION IS NOT CHOSEN HERE. `unshift` plus the client rendering the sidebar by `updatedAt`
134
+ * (`byRecency`) puts a fork at the TOP of its workspace group, because `updatedAt` is now - which is the
135
+ * same position a newly created conversation takes, and the same reason.
136
+ */
137
+ forkConversation(sourceConversationId: string, forkedConversationId: string): Promise<Conversation>;
138
+ /**
139
+ * Archive is a marker, not a move: the conversation keeps its id, workspace, messages and position.
140
+ * `updatedAt` is deliberately NOT touched, because it drives the sidebar's live timestamp and recency -
141
+ * bumping it would teleport the row to the top of the list the instant it was archived.
142
+ *
143
+ * A running conversation is rejected rather than archived. An archived conversation cannot be opened,
144
+ * so allowing one would leave a run streaming into a pane the user can no longer reach, along with any
145
+ * Extension UI question it is blocked on. Refusing keeps the rule honest and leaves stop-then-archive
146
+ * to the user, which is a visible choice instead of a silent one.
147
+ */
148
+ archiveConversation(conversationId: string): Promise<void>;
149
+ /** Restoring deletes the key rather than setting it to null, so a fully restored conversation serializes
150
+ * byte-identically to one that was never archived. `delete` is legal here because the field is optional. */
151
+ unarchiveConversation(conversationId: string): Promise<void>;
152
+ /**
153
+ * Rename edits the title and NOTHING else. In particular `updatedAt` is deliberately not touched,
154
+ * for exactly the reason archive is: `updatedAt` is the sidebar's recency key, so bumping it would
155
+ * teleport the row to the top of the live list because someone retitled it. Renaming is not activity.
156
+ *
157
+ * There is no uniqueness check, and there is no status check on purpose. `createWorkspace` does not
158
+ * reject a duplicate name either, so rejecting one here would make the two paths disagree about the
159
+ * same question; two rows reading "notes" is a cosmetic problem the user can see and fix. And a
160
+ * RUNNING conversation stays renameable: the title is display-only, the run is unaffected, and
161
+ * refusing would mean a conversation could be permanently un-renameable while the agent works.
162
+ *
163
+ * Archived conversations are renameable too - archiving folds a row away, it does not delete it, and
164
+ * the Archived group renders the same name.
165
+ */
166
+ renameConversation(conversationId: string, name: string): Promise<Conversation>;
167
+ addUserMessage(conversationId: string, content: string, images?: MessageImage[]): Promise<Message>;
168
+ /**
169
+ * Records the model a conversation runs on. `updatedAt` is deliberately NOT touched, for the same
170
+ * reason rename does not touch it: it is the sidebar's recency key, and switching model is not
171
+ * activity in the conversation. No status check either - changing the model of a RUNNING
172
+ * conversation is allowed, because the selection is read at the START of the next turn, so the
173
+ * turn in flight is unaffected and refusing would strand a conversation on a model the user can
174
+ * no longer change until it settles.
175
+ */
176
+ setConversationModel(conversationId: string, model: ModelSelection): Promise<Conversation>;
177
+ /** Restoring Pi's own default deletes the key rather than storing Pi's default as this app's state. */
178
+ setConversationThinking(conversationId: string, level: string | undefined): Promise<Conversation>;
179
+ /**
180
+ * Records the agent a conversation runs as. Same three rules as `setConversationModel`: `undefined`
181
+ * DELETES the key rather than freezing "no agent" as a value, `updatedAt` is untouched because
182
+ * changing role is not activity in the conversation, and no status check - the agent is read at the
183
+ * START of the next turn, so the turn in flight is unaffected.
184
+ */
185
+ setConversationAgent(conversationId: string, agentId: string | undefined): Promise<Conversation>;
186
+ beginAssistantMessage(conversationId: string): Promise<Message>;
187
+ appendAssistantDelta(conversationId: string, messageId: string, delta: string): Promise<void>;
188
+ /**
189
+ * Appends one tool call to the message it belongs to, and returns the message's whole `toolCalls`
190
+ * array so the caller can broadcast it as one snapshot.
191
+ *
192
+ * WHY AN APPEND AND NOT AN UPDATE, and why the returned array is the whole truth. A turn's calls arrive
193
+ * one at a time and each one REWRITES a field on a row it has already broadcast - `status` and
194
+ * `durationMs` land on `tool_execution_end`, minutes or seconds after the row was published by
195
+ * `tool_execution_start`. So the only thing that can be cheap and correct is: the store owns the array,
196
+ * the caller sends the array. Handing back the array rather than the one row is what stops the two from
197
+ * drifting - the caller has nothing to merge, so there is no merge to get wrong, and the event it sends
198
+ * is the same shape `state` carries on the next broadcast.
199
+ *
200
+ * PERSISTED ON EVERY CALL, for the same reason `appendAssistantDelta` is: a block that was on screen
201
+ * when the browser reloaded has to come back, and a reload that showed a `read` but not the `bash` that
202
+ * followed it would be worse than showing neither.
203
+ *
204
+ * `toolCalls` is CREATED LAZILY, so a message whose turn used no tools stores no empty array - which is
205
+ * what makes a `state.json` written before call blocks existed read as "this turn used no tools"
206
+ * instead of as a field it does not understand.
207
+ */
208
+ appendToolCall(conversationId: string, messageId: string, call: ToolCallBlock): Promise<ToolCallBlock[]>;
209
+ /**
210
+ * Fills in the outcome of the call at `index`, or returns the array UNCHANGED when there is no such
211
+ * index.
212
+ *
213
+ * ADDRESSED BY POSITION rather than by Pi's `toolCallId`, and that is the whole reason the caller has
214
+ * to hold a map. `toolCallId` is an identity only Pi can resolve - it is never persisted on
215
+ * `ToolCallBlock` - so the caller resolves each end back to the row it started, which is exactly what
216
+ * `SubagentRunRecorder` does for a card.
217
+ *
218
+ * A MISMATCH IS NOT AN ERROR. `tool_execution_end` with no matching start, or one that arrives after the
219
+ * turn ended, must not fail the turn: the alternative is a store method that throws inside a drain loop,
220
+ * and the reader would lose the whole turn's transcript over one unresolvable id. Returning the array
221
+ * unchanged keeps the caller broadcasting what it actually knows.
222
+ *
223
+ * Only `status` and `durationMs` are written, and only when they are present on the incoming block - a
224
+ * second end for a row that already has an outcome must not erase it with an absent field.
225
+ */
226
+ settleToolCall(conversationId: string, messageId: string, index: number, outcome: {
227
+ status?: "ok" | "error";
228
+ durationMs?: number;
229
+ }): Promise<ToolCallBlock[]>;
230
+ setStatus(conversationId: string, status: Conversation["status"]): Promise<void>;
231
+ /** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
232
+ planRevert(conversationId: string, messageId: string): RevertPlan;
233
+ /**
234
+ * Records which Pi entry a message became, as reported by Pi while it was still running.
235
+ *
236
+ * Only ever set, never cleared: a message that already has an id keeps the one Pi gave it for that
237
+ * turn, and a second report for the same message is ignored rather than allowed to move the boundary -
238
+ * a boundary that moved under a user who is looking at it is exactly the silent data loss revert must not
239
+ * be able to cause. An unknown message id is refused for the same reason `appendAssistantDelta` refuses
240
+ * one: it means the caller and the store disagree about the conversation, and guessing is worse.
241
+ */
242
+ setMessagePiEntryId(conversationId: string, messageId: string, piEntryId: string): Promise<void>;
243
+ /**
244
+ * Reverts the conversation to the state right after `messageId`, in `state.json` only.
245
+ *
246
+ * SCOPE, DELIBERATELY NARROW: this touches `messages` and `subagentRuns` and nothing else. It does not
247
+ * touch the workspace (that is not this process's to touch), it does not create or modify a conversation
248
+ * (the user's hard requirement: still the same session, same id, same row in the sidebar), and it does
249
+ * not record a "revert happened" marker - there is no undo to restore one for.
250
+ *
251
+ * NOT `updatedAt`. For the same reason rename and archive leave it alone: it is the sidebar's recency key,
252
+ * so bumping it would teleport the conversation to the top of the list as a side effect of editing its
253
+ * history. What a person did to a transcript is not activity IN the conversation, and the timestamps of
254
+ * the surviving messages are untouched, so the pane's own ordering does not move either.
255
+ *
256
+ * `planRevert` has already run by the time this is called (the server reads it to stop children), so the
257
+ * plan is recomputed here rather than passed in - two callers cannot disagree about what a message id
258
+ * means, and the recomputation is a pure scan of a small array.
259
+ */
260
+ revertMessages(conversationId: string, messageId: string): Promise<RevertPlan>;
261
+ private requireConversation;
262
+ /**
263
+ * This conversation's waiting list, oldest first.
264
+ *
265
+ * Cloned like every other value this store hands out, because the caller broadcasts it straight to
266
+ * browsers and the array is mutated in place by the very next enqueue.
267
+ */
268
+ listQueuedMessages(conversationId: string): QueuedMessage[];
269
+ /**
270
+ * Adds one message to the waiting list and returns it.
271
+ *
272
+ * The id is minted here and is the entry's identity for its whole life. Pi matches its own queue by
273
+ * message TEXT (`agent-session.ts:1123`), so two identical messages are one entry to Pi and two here;
274
+ * `cancel` and `steer` name the id, which is the only reason "cancel the second of two identical
275
+ * messages" is expressible at all. `crypto.randomUUID`, like every other id in this store.
276
+ */
277
+ enqueueMessage(conversationId: string, content: string, images?: MessageImage[]): Promise<QueuedMessage>;
278
+ /**
279
+ * Removes one waiting message. Returns it, or `undefined` when it is already gone.
280
+ *
281
+ * "Already gone" is a no-op rather than an error, and the reason is the same as `SubagentRun.stop`:
282
+ * the click that sent this was almost always triggered by a dock row the browser has not been told
283
+ * about yet, so an id that has already been delivered or cancelled is the ordinary case and must not
284
+ * surface as a failure.
285
+ *
286
+ * A `sending` entry REFUSES, because the text is already in a live Pi child's stdin and Pi has no way
287
+ * to forget one item (its only queue command is `clear_queue`, which clears every one of them,
288
+ * including follow-ups that have nothing to do with this click). Removing it from this list would not
289
+ * stop Pi from delivering it - it would make the model act on a message the user believed they had
290
+ * withdrawn.
291
+ */
292
+ cancelQueuedMessage(conversationId: string, queueId: string): Promise<QueuedMessage | undefined>;
293
+ /**
294
+ * Claims one waiting message for delivery to the live Pi process: `queued` becomes `sending`, and the
295
+ * entry STAYS in the list until Pi reports it delivered.
296
+ *
297
+ * The read and the write are adjacent statements with no `await` between them, which is what makes
298
+ * the claim atomic here. This store has no mutex, and it does not need one: JavaScript runs this
299
+ * synchronous run-to-completion, so a second caller arriving in the same tick observes the write above
300
+ * and cannot claim the same entry. Anything that needed to await between reading and writing would
301
+ * need real serialization instead.
302
+ *
303
+ * Throws for an unknown id and for one that is already `sending`, because both mean the caller is
304
+ * acting on a state the store has already moved past - and the second one means a second steer of the
305
+ * same message would put the text into Pi twice.
306
+ */
307
+ claimQueuedMessage(conversationId: string, queueId: string): Promise<QueuedMessage>;
308
+ /**
309
+ * Puts a `sending` entry back into the queue.
310
+ *
311
+ * The one legal use, and it is narrow on purpose: this is called ONLY when we can prove the message
312
+ * never reached a model - the turn ended while Pi still held it, or the write to Pi's stdin failed
313
+ * before it left. Pi drains steering before its loop can end and reports the removal before the
314
+ * delivered message, so a message still `sending` when the child is gone provably was not delivered,
315
+ * and returning it is a fact rather than a guess. It is deliberately NOT used for "Pi has not answered
316
+ * yet" (see `steer` in `index.ts`), where the outcome is genuinely unknown and the honest state is
317
+ * still `sending`.
318
+ */
319
+ releaseQueuedMessage(queueId: string): Promise<void>;
320
+ /**
321
+ * Removes the OLDEST `count` messages this conversation has in `sending`, and returns them.
322
+ *
323
+ * Driven by a COUNT rather than by matching text, and that is the whole point. Pi's `queue_update`
324
+ * reports only the strings it still holds, and Pi itself identifies which of its entries a delivered
325
+ * message came from with `indexOf` on that text - so with two identical steered messages, "which one
326
+ * was delivered" is exactly the question Pi cannot answer. It does not need to be answered: Pi's
327
+ * steering queue is FIFO and Super Pi only ever steers from the front of this list, so a drop in
328
+ * `steering.length` from N to N-k means Super Pi's oldest k `sending` entries were delivered, in
329
+ * order, whatever their text was.
330
+ */
331
+ dropSendingMessages(conversationId: string, count: number): Promise<QueuedMessage[]>;
332
+ /**
333
+ * Takes the oldest still-`queued` message of this conversation OUT of the list and returns it, for the
334
+ * next turn to send as its prompt.
335
+ *
336
+ * Removing it here, rather than marking it and removing it after the turn, is what bounds the loop:
337
+ * every pass through the follow-up loop strictly shrinks the list, so the loop cannot run forever
338
+ * even if a turn succeeds without consuming anything. And removing it BEFORE the spawn is what makes a
339
+ * crash honest - a server that dies mid-turn has already taken the message out of the file, and
340
+ * `open` does not put it back, because it may well have been delivered.
341
+ */
342
+ takeNextQueuedMessage(conversationId: string): Promise<QueuedMessage | undefined>;
343
+ /**
344
+ * Returns every `sending` message of this conversation to the queue, for the end of a turn.
345
+ *
346
+ * See `releaseQueuedMessage` for why this is a fact and not a guess, and `reconcileQueuedMessages` for
347
+ * why the same treatment is right after a crash. Called both when a turn ends and when the user stops
348
+ * it: in the stop case the child is killed mid-turn and a steer it was still holding was never
349
+ * delivered either, so putting it back is what lets the user decide what to do with it instead of
350
+ * silently dropping a message they typed.
351
+ */
352
+ releaseSendingMessages(conversationId: string): Promise<void>;
353
+ /**
354
+ * Records a sub-agent run as `running`, BEFORE the child is spawned.
355
+ *
356
+ * The ordering is the whole point of this method existing separately from the spawn. A record created
357
+ * after the child starts cannot survive the child failing to start, and one created only at the end
358
+ * leaves nothing behind when the SERVER dies - which is the orphan startup reconciliation exists to
359
+ * clean up. Writing the claim first is what makes that orphan possible, and therefore recoverable.
360
+ *
361
+ * `unshift`, like `createConversation`: the newest run is the one a reader is looking for.
362
+ *
363
+ * Trimmed HERE rather than at read time, because the array in memory and the file on disk have to
364
+ * be the same thing - a store that kept evicted rows until the next unrelated write would put them
365
+ * back on disk, and one that trimmed on read would show a browser a roster the file still holds.
366
+ */
367
+ beginSubagentRun(run: SubagentRun): Promise<SubagentRun>;
368
+ setBackgroundTask(summary: AppState["backgroundTasks"][number]): Promise<void>;
369
+ removeBackgroundTask(id: string): Promise<void>;
370
+ /**
371
+ * Applies the fields a run's life can change, and nothing else.
372
+ *
373
+ * `Object.assign` is safe HERE in a way it is not in `setConversationModel`, and the difference is the
374
+ * patch's own type: `SubagentRunUpdate` is a named list of six already-narrowed fields, so there is no
375
+ * wider object for a spread to smuggle extra keys in from. `undefined` means "no such value" and is
376
+ * dropped by JSON.stringify, which is how `output`/`error`/`outputTruncated` come to be absent rather
377
+ * than stored as an empty or null one.
378
+ *
379
+ * PERSISTED ON EVERY CALL, exactly as `appendAssistantDelta` is: the record a reader sees in a
380
+ * `state` broadcast and the record on disk are then never different, and a run that is in flight is
381
+ * as durable as the conversation it belongs to.
382
+ */
383
+ setSubagentRun(runId: string, changes: SubagentRunUpdate): Promise<SubagentRun>;
384
+ /**
385
+ * Replaces the model's task list, and persists it.
386
+ *
387
+ * WHY IT IS A FILE AND NOT PI'S MEMORY, stated once here because it is the whole justification: Pi is
388
+ * killed unconditionally at the end of every turn (`runtime.ts`) and never writes a tool result it was
389
+ * given by an extension anywhere durable except the session JSONL, so a list held only in the child
390
+ * would be gone before a person could look at it - and the sidebar would empty itself at every turn
391
+ * boundary. The list is therefore part of `state.json` and rides the existing broadcast, which is also
392
+ * why the browser needs no channel of its own: it already repaints from `state` on every text delta.
393
+ *
394
+ * THE PARAMETER IS RE-NORMALIZED HERE, not trusted because the endpoint normalized it. Those are two
395
+ * different things: `/api/todos` validates a request that crossed a socket, and this validates a value
396
+ * handed to `setTodos` by any future caller inside this process. A method whose own contract is "call me
397
+ * with anything" is the only version of this that cannot be broken by the next call site.
398
+ *
399
+ * AN EMPTY LIST IS STORED AS ABSENCE, not as `[]`. `normalizeTodos` returns `[]` for a list the model
400
+ * emptied deliberately, and the two must not be distinguishable: the browser renders `todos: []` and
401
+ * `todos: undefined` as the same empty state on purpose, and persisting `[]` for a store that has never
402
+ * seen the tool would make a fresh install claim the model had considered a plan and found none. So the
403
+ * key is deleted, which also keeps `state.json` free of a field no build before this one wrote.
404
+ */
405
+ setTodos(raw: unknown): Promise<{
406
+ todos: TodoItem[];
407
+ dropped: number;
408
+ }>;
409
+ /**
410
+ * Writes the whole state atomically: a temp file, then a rename over the real one.
411
+ *
412
+ * SERIALIZED, because two conversations can genuinely run at once (see workspace-cwd.test.mjs), and
413
+ * this method was not safe for that: every write used the SAME `${filePath}.tmp`, so two overlapping
414
+ * persists raced and the loser's rename failed with ENOENT on a file the winner had already moved.
415
+ * A unique name per write would hide that ENOENT while leaving the real bug in place - whichever rename
416
+ * landed last would win, and a rename of an OLDER snapshot last would silently discard a newer one.
417
+ * Queueing keeps both properties: one writer at a time, and last write wins with the newest state.
418
+ *
419
+ * A rejected write must not poison the queue, or one failure would reject every later persist as well.
420
+ */
421
+ private persistQueue;
422
+ private persist;
423
+ }
424
+ export declare function defaultStorePath(dataDir: string): string;