@evandu/super-pi 0.1.84 → 0.1.86

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 (40) hide show
  1. package/agents/super-pi-knowledge/index.ts +40 -0
  2. package/agents/super-pi-task/index.ts +2 -2
  3. package/assets/knowledge-model/README.md +37 -0
  4. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/config.json +31 -0
  5. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/onnx/model_quantized.onnx +0 -0
  6. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/special_tokens_map.json +7 -0
  7. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer.json +21278 -0
  8. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer_config.json +15 -0
  9. package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/vocab.txt +21128 -0
  10. package/dist/.super-pi-check-inputs.json +4 -0
  11. package/dist/client/.super-pi-client-sources.json +10 -7
  12. package/dist/client/assets/index-B3QVoYJY.css +1 -0
  13. package/dist/client/assets/index-C_giUkvT.js +72 -0
  14. package/dist/client/index.html +2 -2
  15. package/dist/server/application/run-service.d.ts +50 -11
  16. package/dist/server/application/run-service.js +206 -77
  17. package/dist/server/command-handler.js +16 -4
  18. package/dist/server/http-server.js +218 -8
  19. package/dist/server/index.js +15 -1
  20. package/dist/server/knowledge-vectors.d.ts +133 -0
  21. package/dist/server/knowledge-vectors.js +302 -0
  22. package/dist/server/knowledge.d.ts +263 -0
  23. package/dist/server/knowledge.js +743 -0
  24. package/dist/server/persistence/app-state-repository.d.ts +25 -1
  25. package/dist/server/persistence/sqlite-state-repository.js +47 -4
  26. package/dist/server/runtime.d.ts +82 -1
  27. package/dist/server/runtime.js +128 -7
  28. package/dist/server/server-context.d.ts +3 -0
  29. package/dist/server/store.d.ts +153 -68
  30. package/dist/server/store.js +405 -187
  31. package/dist/server/tool-llm.d.ts +65 -0
  32. package/dist/server/tool-llm.js +139 -0
  33. package/dist/server/tool-registry.d.ts +65 -0
  34. package/dist/server/tool-registry.js +129 -6
  35. package/dist/server/tool-runner.d.ts +70 -0
  36. package/dist/server/tool-runner.js +210 -10
  37. package/dist/server/types.d.ts +97 -65
  38. package/package.json +166 -155
  39. package/dist/client/assets/index-8Pf3MG2d.js +0 -72
  40. package/dist/client/assets/index-Jnj15dhT.css +0 -1
@@ -1,23 +1,163 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
4
  import { randomUUID } from "node:crypto";
5
+ import { join } from "node:path";
3
6
  const MAX_LOG_BYTES = 512_000;
4
7
  const MAX_TIMEOUT_MS = 10 * 60_000;
8
+ /** The environment build gets its OWN budget: a cold `pip install` must not eat the tool's timeout. */
9
+ const MAX_ENV_TIMEOUT_MS = 10 * 60_000;
10
+ const isWindows = process.platform === "win32";
5
11
  /** Runs a registered Python entrypoint without a shell. It intentionally accepts no command string. */
6
12
  export class ToolRunner {
13
+ dataDir;
7
14
  runs = new Map();
15
+ /** Optional so the existing unit tests can build a runner with no data dir; a tool that needs no venv never touches it. */
16
+ constructor(dataDir) {
17
+ this.dataDir = dataDir;
18
+ }
19
+ /**
20
+ * THE RESOLUTION ORDER, IN ONE PLACE, AND IT IS AN ORDER ON PURPOSE.
21
+ *
22
+ * 1. the tool's own venv - a tool's dependencies belong to that tool and to nothing else;
23
+ * 2. `SUPER_PI_PYTHON_COMMAND` - the user's explicit choice, already honoured before this existed;
24
+ * 3. the system interpreter - today's behaviour;
25
+ * 4. nothing usable, which is REPORTED as a sentence rather than discovered as an ENOENT.
26
+ *
27
+ * Bundling an interpreter later is a fourth rung on this chain, which is why nothing in a tool's
28
+ * files has to know about it.
29
+ */
30
+ resolvePython(toolId, needsVenv) {
31
+ if (needsVenv && this.dataDir) {
32
+ const envPython = this.envPython(toolId);
33
+ if (existsSync(envPython))
34
+ return { path: envPython, kind: "tool-env" };
35
+ }
36
+ const override = process.env.SUPER_PI_PYTHON_COMMAND?.trim();
37
+ if (override)
38
+ return { path: override, kind: "override" };
39
+ return { path: isWindows ? "python.exe" : "python3", kind: "system" };
40
+ }
41
+ /** The venv lives BESIDE the tools, not inside one: export walks the tool directory, and the mount route serves everything under it. */
42
+ envDir(toolId) { return join(this.dataDir ?? ".", "tool-envs", toolId); }
43
+ envPython(toolId) { return join(this.envDir(toolId), ".venv", isWindows ? "Scripts" : "bin", isWindows ? "python.exe" : "python"); }
44
+ /** MEASURE this machine's Python. One spawn; the answer is what every fingerprint is compared to. */
45
+ async describeLocalPython() {
46
+ const choice = this.resolvePython("", false);
47
+ const probe = await runCapture(choice.path, ["-I", "-c", "import json,platform,sys;print(json.dumps({'python':sys.version.split()[0],'platform':platform.system().lower()+'-'+platform.machine()}))"], 10_000);
48
+ if (!probe.ok)
49
+ return undefined;
50
+ try {
51
+ const parsed = JSON.parse(probe.stdout);
52
+ if (typeof parsed?.python !== "string")
53
+ return undefined;
54
+ return { ...choice, python: parsed.python, platform: parsed.platform };
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ }
60
+ async environmentState(tool) {
61
+ const environment = tool.environment;
62
+ const requires = environment?.requires ?? [];
63
+ const wants = requires.length > 0 || Boolean(environment?.requiresPython);
64
+ const local = await this.describeLocalPython();
65
+ const base = { python: local?.python, platform: local?.platform, requires };
66
+ if (!wants)
67
+ return { ...base, state: "zero-deps" };
68
+ if (!local)
69
+ return { ...base, state: "needs-python", detail: "这台机器上找不到可用的 Python 解释器。" };
70
+ if (environment?.requiresPython && !satisfiesRange(local.python ?? "", environment.requiresPython))
71
+ return { ...base, state: "needs-python", detail: `这个工具需要 Python ${environment.requiresPython},本机是 ${local.python}。` };
72
+ if (requires.length > 0 && this.dataDir && !existsSync(this.envPython(tool.id)))
73
+ return { ...base, state: "needs-env", detail: "首次运行这个工具时会自动安装这些依赖。" };
74
+ const known = environment?.fingerprint;
75
+ if (known?.python && known.platform && (majorMinor(known.python) !== majorMinor(local.python ?? "") || known.platform !== local.platform))
76
+ return { ...base, state: "differs", detail: `导出方是 Python ${known.python} / ${known.platform},本机是 ${local.python} / ${local.platform}。` };
77
+ return { ...base, state: "ready" };
78
+ }
79
+ /**
80
+ * Starts a run. It RETURNS SYNCHRONOUSLY - so bad parameters still throw at the call site, exactly
81
+ * as before - while the interpreter and, if the tool needs one, its virtual environment are prepared
82
+ * in the background. The run is already `running` at that point and the build's output lands in the
83
+ * run's own `logs`, which is why the tool's existing dialog shows the progress without knowing
84
+ * anything about environments.
85
+ */
8
86
  start(input) {
9
87
  validateParams(input.definition.parameters ?? {}, input.params);
10
- const python = process.env.SUPER_PI_PYTHON_COMMAND?.trim() || (process.platform === "win32" ? "python.exe" : "python3");
11
88
  const id = `toolrun_${randomUUID()}`;
12
89
  const run = { id, toolId: input.toolId, status: "running", startedAt: new Date().toISOString(), logs: "" };
13
- // -I ignores user Python configuration and user site packages. No shell means params never become code.
14
- const child = spawn(python, ["-I", "-u", input.script], { cwd: input.cwd, shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"], env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, TEMP: process.env.TEMP, TMP: process.env.TMP, PYTHONIOENCODING: "utf-8", PYTHONNOUSERSITE: "1", SUPER_PI_TOOL_ID: input.toolId } });
15
- const active = { run, child, stdout: "", cancelled: false, timer: setTimeout(() => this.finish(id, "timed_out", `脚本超过 ${boundedTimeout(input.definition.timeoutMs)}ms 时限`), boundedTimeout(input.definition.timeoutMs)) };
16
- active.timer.unref();
90
+ const timer = setTimeout(() => this.finish(id, "timed_out", `准备运行环境超过 ${MAX_ENV_TIMEOUT_MS}ms 时限`), MAX_ENV_TIMEOUT_MS);
91
+ timer.unref();
92
+ const active = { run, stdout: "", cancelled: false, timer };
17
93
  this.runs.set(id, active);
18
- const log = (data) => { active.run.logs = clipped(`${active.run.logs}${data.toString("utf8")}`); };
94
+ void this.prepare(id, active, input);
95
+ return copy(run);
96
+ }
97
+ async prepare(id, active, input) {
98
+ try {
99
+ const requires = input.environment?.requires ?? [];
100
+ let python = this.resolvePython(input.toolId, false);
101
+ if (requires.length > 0) {
102
+ const built = await this.ensureEnvironment(input.toolId, python, requires, active);
103
+ if (!built.ok) {
104
+ this.finish(id, "failed", built.error);
105
+ return;
106
+ }
107
+ python = { path: this.envPython(input.toolId), kind: "tool-env" };
108
+ }
109
+ if (active.cancelled)
110
+ return;
111
+ if (active.timer)
112
+ clearTimeout(active.timer);
113
+ const scriptTimer = setTimeout(() => this.finish(id, "timed_out", `脚本超过 ${boundedTimeout(input.definition.timeoutMs)}ms 时限`), boundedTimeout(input.definition.timeoutMs));
114
+ scriptTimer.unref();
115
+ active.timer = scriptTimer;
116
+ this.launch(id, active, python, input);
117
+ }
118
+ catch (error) {
119
+ this.finish(id, "failed", error instanceof Error ? error.message : "tool environment could not be prepared");
120
+ }
121
+ }
122
+ /**
123
+ * THE VENV IS PYTHON'S OWN, ON PURPOSE. `python -m venv` finds its site-packages through the
124
+ * pyvenv.cfg next to the interpreter, which is why it survives the `-I` a tool runs under - a
125
+ * PYTHONPATH-based vendor directory does not (verified: `-I` implies `-E`, so PYTHONPATH is
126
+ * ignored, and dropping `-I` would give up the isolation that keeps a hostile PYTHON* environment
127
+ * from influencing a shared tool's script).
128
+ */
129
+ async ensureEnvironment(toolId, base, requires, active) {
130
+ if (!this.dataDir)
131
+ return { ok: false, error: "这个工具需要依赖,但没有可用的数据目录来存放它的虚拟环境。" };
132
+ const envDir = this.envDir(toolId), envPython = this.envPython(toolId);
133
+ const recorded = await readFile(join(envDir, "installed.json"), "utf8").then((text) => { try {
134
+ return JSON.parse(text);
135
+ }
136
+ catch {
137
+ return undefined;
138
+ } }).catch(() => undefined);
139
+ if (existsSync(envPython) && Array.isArray(recorded?.requires) && recorded.requires.length === requires.length && recorded.requires.every((item, index) => item === requires[index]))
140
+ return { ok: true };
141
+ await rm(envDir, { recursive: true, force: true });
142
+ await mkdir(envDir, { recursive: true });
143
+ const requirements = join(envDir, "requirements.txt");
144
+ await writeFile(requirements, `${requires.join("\n")}\n`, "utf8");
145
+ logLine(active, `正在准备 Python 虚拟环境(${requires.join("、")})…\n`);
146
+ const created = await runCapture(base.path, ["-m", "venv", join(envDir, ".venv")], MAX_ENV_TIMEOUT_MS, (text) => logLine(active, text));
147
+ if (!created.ok)
148
+ return { ok: false, error: `创建 Python 虚拟环境失败:${created.error || `解释器 ${base.path} 不可用`}` };
149
+ const installed = await runCapture(envPython, ["-m", "pip", "install", "--disable-pip-version-check", "--no-input", "-r", requirements], MAX_ENV_TIMEOUT_MS, (text) => logLine(active, text));
150
+ if (!installed.ok)
151
+ return { ok: false, error: `安装工具依赖失败:${installed.error || `pip 退出码 ${installed.code}`}(依赖:${requires.join("、")})` };
152
+ await writeFile(join(envDir, "installed.json"), JSON.stringify({ requires, python: base.path, builtAt: new Date().toISOString() }, null, 2), "utf8");
153
+ return { ok: true };
154
+ }
155
+ launch(id, active, python, input) {
156
+ // -I ignores user Python configuration and user site packages. No shell means params never become code.
157
+ const child = spawn(python.path, ["-I", "-u", input.script], { cwd: input.cwd, shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"], env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, TEMP: process.env.TEMP, TMP: process.env.TMP, PYTHONIOENCODING: "utf-8", PYTHONNOUSERSITE: "1", SUPER_PI_TOOL_ID: input.toolId } });
158
+ active.child = child;
19
159
  child.stdout.on("data", (data) => { active.stdout = clipped(`${active.stdout}${data.toString("utf8")}`); });
20
- child.stderr.on("data", log);
160
+ child.stderr.on("data", (data) => logLine(active, data.toString("utf8")));
21
161
  child.once("error", (error) => this.finish(id, "failed", error.message));
22
162
  child.once("close", (code) => {
23
163
  if (active.run.status !== "running")
@@ -38,7 +178,6 @@ export class ToolRunner {
38
178
  }
39
179
  });
40
180
  child.stdin.end(JSON.stringify(input.params));
41
- return copy(run);
42
181
  }
43
182
  get(id, toolId) { const active = this.runs.get(id); return active?.run.toolId === toolId ? copy(active.run) : undefined; }
44
183
  cancel(id, toolId) { const active = this.runs.get(id); if (!active || active.run.toolId !== toolId)
@@ -47,17 +186,78 @@ export class ToolRunner {
47
186
  this.finish(id, "cancelled");
48
187
  } return copy(active.run); }
49
188
  finish(id, status, error) { const active = this.runs.get(id); if (!active || active.run.status !== "running")
50
- return; clearTimeout(active.timer); if (status !== "succeeded" && active.stdout)
189
+ return; if (active.timer)
190
+ clearTimeout(active.timer); if (status !== "succeeded" && active.stdout)
51
191
  active.run.logs = clipped(`${active.run.logs}${active.stdout}`); active.run.status = status; active.run.finishedAt = new Date().toISOString(); if (error)
52
- active.run.error = error; if (!active.child.killed) {
192
+ active.run.error = error; if (active.child && !active.child.killed) {
53
193
  try {
54
194
  active.child.kill();
55
195
  }
56
196
  catch { }
57
197
  } }
58
198
  }
199
+ /**
200
+ * Runs a Python process with NO SHELL - the same rule as a tool run, because what is being installed
201
+ * is code too. There is no command string anywhere on this path, only an argv.
202
+ */
203
+ function runCapture(python, args, timeoutMs, onOutput) {
204
+ return new Promise((resolve) => {
205
+ let stdout = "", stderr = "", settled = false;
206
+ const child = spawn(python, args, { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, TEMP: process.env.TEMP, TMP: process.env.TMP, PYTHONIOENCODING: "utf-8", PYTHONNOUSERSITE: "1" } });
207
+ const timer = setTimeout(() => { if (settled)
208
+ return; settled = true; try {
209
+ child.kill();
210
+ }
211
+ catch { } resolve({ ok: false, code: null, stdout, error: `timed out after ${timeoutMs}ms` }); }, timeoutMs);
212
+ timer.unref();
213
+ child.stdout.on("data", (data) => { const text = data.toString("utf8"); stdout += text; onOutput?.(text); });
214
+ child.stderr.on("data", (data) => { const text = data.toString("utf8"); stderr += text; onOutput?.(text); });
215
+ child.once("error", (error) => { if (settled)
216
+ return; settled = true; clearTimeout(timer); resolve({ ok: false, code: null, stdout, error: error.message }); });
217
+ child.once("close", (code) => { if (settled)
218
+ return; settled = true; clearTimeout(timer); if (code === 0)
219
+ resolve({ ok: true, stdout, code });
220
+ else
221
+ resolve({ ok: false, code, stdout, error: stderr.split("\n").filter((line) => line.trim()).slice(-4).join("\n") }); });
222
+ });
223
+ }
224
+ /**
225
+ * A SMALL, DELIBERATELY SMALL PEP 440 READER: `>=`, `>`, `<=`, `<`, `==`, comma-separated. Anything it
226
+ * cannot parse counts as NOT satisfied, because a version check that guesses is worse than one that
227
+ * refuses. A tool needing something exotic declares no range at all and relies on the venv, not here.
228
+ */
229
+ export function satisfiesRange(version, range) {
230
+ const actual = version.split(".").map((part) => Number.parseInt(part, 10));
231
+ if (actual.some((part) => Number.isNaN(part)))
232
+ return false;
233
+ for (const term of range.split(",")) {
234
+ const match = /^(>=|<=|==|>|<)?\s*(\d+(?:\.\d+)*)$/.exec(term.trim());
235
+ if (!match)
236
+ return false;
237
+ const comparison = compareVersions(actual, match[2].split(".").map((part) => Number.parseInt(part, 10)));
238
+ const operator = match[1] ?? "==";
239
+ if (operator === ">=" && comparison < 0)
240
+ return false;
241
+ if (operator === ">" && comparison <= 0)
242
+ return false;
243
+ if (operator === "<=" && comparison > 0)
244
+ return false;
245
+ if (operator === "<" && comparison >= 0)
246
+ return false;
247
+ if (operator === "==" && comparison !== 0)
248
+ return false;
249
+ }
250
+ return true;
251
+ }
252
+ function compareVersions(left, right) { for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
253
+ const a = left[index] ?? 0, b = right[index] ?? 0;
254
+ if (a !== b)
255
+ return a < b ? -1 : 1;
256
+ } return 0; }
257
+ function majorMinor(version) { return version.split(".").slice(0, 2).join("."); }
59
258
  function boundedTimeout(value) { return Math.max(1_000, Math.min(MAX_TIMEOUT_MS, Number.isFinite(value) ? Math.floor(value) : 60_000)); }
60
259
  function clipped(text) { return text.length > MAX_LOG_BYTES ? text.slice(-MAX_LOG_BYTES) : text; }
260
+ function logLine(active, text) { active.run.logs = clipped(`${active.run.logs}${text}`); }
61
261
  function copy(run) { return { ...run, result: run.result ? { ...run.result } : undefined }; }
62
262
  export function validateParams(schema, params) {
63
263
  if (!params || Array.isArray(params) || typeof params !== "object")
@@ -105,7 +105,39 @@ export type Message = {
105
105
  id?: string;
106
106
  name: string;
107
107
  };
108
+ delivery?: MessageDelivery;
108
109
  };
110
+ /**
111
+ * WHERE A USER MESSAGE IS IN ITS OWN DELIVERY, or `undefined` for every message that is not waiting.
112
+ *
113
+ * A user message is written into the transcript the instant it is sent, BEFORE any turn has started,
114
+ * and it stays in that exact position until the agent has answered it. What changes meanwhile is this
115
+ * field and nothing else - see `docs/QUEUE-UX.md` for why the message must never move.
116
+ *
117
+ * ABSENT means "not waiting": a message the agent is answering right now, one it already answered, one
118
+ * withdrawn, and every `assistant` / `tool` message. Only `role: "user"` ever carries it.
119
+ *
120
+ * - `waiting` - said, not yet handed to the agent. CANCELLED freely, EDITED freely, and REORDERED
121
+ * freely. This is where essentially every waiting message lives, because a message is only given to
122
+ * Pi at the moment it is about to be delivered.
123
+ * - `sending` - written to a live Pi child's stdin as a `steer` and not yet confirmed delivered. NOT
124
+ * cancellable and NOT editable: Pi has no single-item retract (its only queue command is
125
+ * `clear_queue`, which clears everything), so a UI offering either would be a promise the transport
126
+ * cannot keep. It returns to `waiting` exactly when we can prove Pi never delivered it - the turn
127
+ * ended - and stays here when Pi simply has not answered yet.
128
+ *
129
+ * WHY A FIELD AND NOT A SEPARATE LIST. The waiting list used to be its own array of content copies
130
+ * (`QueuedMessage`), and that is what made two bugs structural rather than accidental: a queued entry
131
+ * was not a message, so promoting one to a turn had to CREATE a message - which is why a steered
132
+ * message reached Pi and the model and never reached the transcript at all - and "delivered" could only
133
+ * be expressed by DELETING a row, so every delivery was an opportunity to lose the user's words. A
134
+ * message with a state has an identity to transition and nothing to delete.
135
+ *
136
+ * ORDER IS THE QUEUE. There is no `priority` flag and no second source of truth: the waiting messages
137
+ * are the trailing `role: "user"` messages of the array, and "put this one first" is a move within
138
+ * that block.
139
+ */
140
+ export type MessageDelivery = "waiting" | "sending";
109
141
  /**
110
142
  * A piece of context attached to a user message that is shown as its OWN block under the bubble, rather
111
143
  * than flattened into the prompt. Today: a skill the person picked from the slash menu - the model gets
@@ -691,7 +723,6 @@ export type AppState = {
691
723
  workspaces: Workspace[];
692
724
  conversations: Conversation[];
693
725
  subagentRuns: SubagentRun[];
694
- queuedMessages: QueuedMessage[];
695
726
  backgroundTasks: BackgroundTaskSummary[];
696
727
  todosByConversation?: Record<string, TodoItem[]>;
697
728
  workflows?: WorkflowRun[];
@@ -703,63 +734,21 @@ export type TodoItem = {
703
734
  status: "pending" | "in_progress" | "completed";
704
735
  priority?: "high" | "medium" | "low";
705
736
  };
706
- /**
707
- * One message the user sent WHILE THE AGENT WAS WORKING, held by Super Pi until a turn boundary can
708
- * carry it.
709
- *
710
- * WHY THE SERVER OWNS THIS LIST AT ALL. Pi's own queues live in the Pi process's memory
711
- * (`agent-session.ts:394/396`) and Super Pi kills that process unconditionally at the end of every
712
- * turn (`runtime.ts:539-545`), while Pi never writes a queued message to the session JSONL. A list kept
713
- * in Pi is therefore guaranteed to be destroyed, with no trace, on every turn boundary. So Pi is used
714
- * only as a delivery pipe - one steer per turn, handed over at the moment it is allowed to be
715
- * delivered - and the waiting list is this one.
716
- *
717
- * THE ID IS OURS AND IT IS THE POINT. Pi matches a queued message back to its queue entry by
718
- * `indexOf` on the message TEXT (`agent-session.ts:1123/1129`), so two queued messages with identical
719
- * text are indistinguishable to it and one of them is left behind in its queue forever. Every entry
720
- * here therefore carries a Super Pi id that Pi never sees, and cancel/steer address THAT.
721
- *
722
- * `state` is the whole lifecycle, and `sending` is the only irreversible step:
723
- *
724
- * - `queued` - in the list, not yet given to Pi. CANCELLED freely; this is where essentially every
725
- * message lives, because a message is only handed to Pi when it is about to be delivered.
726
- * - `sending` - written to Pi's stdin as a `steer` and not yet confirmed delivered. NOT cancellable:
727
- * Pi has no single-item retract (its only queue command is `clear_queue`, which clears
728
- * everything), so claiming otherwise in the UI would be a lie. It goes back to `queued`
729
- * exactly when we can prove Pi never delivered it - the turn ended - and stays here when
730
- * Pi simply has not answered yet.
731
- *
732
- * A delivered or cancelled message is REMOVED rather than kept with a terminal state: a queue that
733
- * retains sent messages needs a pruning policy and a way to read it, and a stale row in this list is
734
- * worse than no row.
735
- */
736
- export type QueuedMessage = {
737
- /** `q_<uuid>`. Super Pi's own identity for this message, and the only thing cancel/steer name. */
738
- id: string;
739
- /**
740
- * The conversation whose next turn will carry this. Deliberately a CONVERSATION id and never a
741
- * `sub_*` run id: sub-agent children run in their own Pi processes under their own supervisor
742
- * (`subagents.ts`), they have their own stop and their own lifecycle, and a queue entry keyed by one
743
- * would be claimed and delivered by the wrong owner.
744
- */
745
- conversationId: string;
746
- content: string;
747
- state: "queued" | "sending";
748
- /** Chosen for priority delivery. Survives a Pi turn boundary and a server restart. */
749
- priority?: boolean;
750
- createdAt: string;
751
- images?: MessageImage[];
752
- attachments?: MessageAttachments;
753
- };
754
737
  /**
755
738
  * What Pi reports about ITS OWN queue, forwarded to the browser as it arrives.
756
739
  *
757
740
  * `steering` and `followUp` are Pi's in-memory arrays as of this event (`agent-session.ts:204-208`).
758
- * They are NOT the queue the user sees: the authoritative list is `AppState.queuedMessages`, and this
759
- * is Pi's own view of the at most one steer that is currently in flight inside the child process. It
760
- * is forwarded because it is the only signal that proves a steer was really picked up, and because a
761
- * `followUp` array that is ever non-empty means something outside this server sent a `follow_up` -
762
- * worth being able to see rather than infer.
741
+ * They are NOT what the user sees: the authoritative list is the trailing `delivery`-bearing messages
742
+ * of the conversation itself (`MessageDelivery`), and this is Pi's own view of the at most one steer
743
+ * that is currently in flight inside the child process. It is forwarded because it is the only signal
744
+ * that proves a steer was really picked up, and because a `followUp` array that is ever non-empty means
745
+ * something outside this server sent a `follow_up` - worth being able to see rather than infer.
746
+ *
747
+ * It no longer decides anything. A delivery used to be inferred from a DROP in `steering.length` and
748
+ * applied by DELETING the oldest `sending` row, which is how a steered message could reach the model
749
+ * and never reach the transcript. Delivery is now announced directly, by the `message_start` Pi emits
750
+ * when it hands the text to the agent loop, and applied by moving a state on a message that was already
751
+ * on screen the whole time.
763
752
  */
764
753
  export type PiQueueSnapshot = {
765
754
  steering: string[];
@@ -989,6 +978,14 @@ export type ClientCommandBody =
989
978
  * one gets a turn. The browser never picks, which is what makes the guard un-bypassable - the old
990
979
  * arrangement had the browser decide by disabling itself, and a second WebSocket client (or a stale
991
980
  * tab) walked straight past it into two Pi processes writing one session.
981
+ *
982
+ * `clientId` is how the browser's copy and the server's copy become THE SAME MESSAGE instead of two
983
+ * that have to be matched up afterwards. The browser mints the id, renders the message with it in the
984
+ * same frame it pressed send, and sends it here; the store uses it as the message id when it is well
985
+ * formed and not already taken. So when the broadcast arrives there is nothing to reconcile - no
986
+ * content matching, no `knownIds` set, no window in which the same sentence exists twice. It used to
987
+ * be matched by `content === content.trim()`, which cannot tell two identical messages apart and made
988
+ * the tests assert ordinals instead of messages. Omitted means "server, mint one".
992
989
  */
993
990
  | {
994
991
  type: "message.send";
@@ -996,6 +993,7 @@ export type ClientCommandBody =
996
993
  content: string;
997
994
  images?: MessageImage[];
998
995
  attachments?: MessageAttachments;
996
+ clientId?: string;
999
997
  }
1000
998
  /**
1001
999
  * Revert the conversation to the state right AFTER one message: every message after it is removed from
@@ -1018,21 +1016,49 @@ export type ClientCommandBody =
1018
1016
  withdraw?: boolean;
1019
1017
  }
1020
1018
  /**
1021
- * Drops ONE queued message, by Super Pi's id.
1019
+ * Withdraws ONE waiting message, by its message id.
1022
1020
  *
1023
- * Addressed by `queueId` rather than by text or by index for the reason `QueuedMessage.id` exists at
1024
- * all: two queued messages can be the same string, and a cancel that matched on text would drop
1025
- * whichever one sorted first. Refused for an entry already in `sending` - Pi holds that text now and
1026
- * cannot be told to forget one item - and an unknown or already-removed id is a no-op rather than an
1027
- * error, exactly as `subagent.stop` treats a run that has already settled.
1021
+ * Addressed by `messageId` rather than by text or by index because two waiting messages can be the
1022
+ * same string. Refused for a message already in `sending` - Pi holds that text now and cannot be told
1023
+ * to forget one item - and an unknown or already-removed id is a no-op rather than an error, exactly
1024
+ * as `subagent.stop` treats a run that has already settled.
1028
1025
  */
1029
1026
  | {
1030
1027
  type: "message.cancel";
1031
1028
  conversationId: string;
1032
- queueId: string;
1029
+ messageId: string;
1033
1030
  }
1034
1031
  /**
1035
- * Promotes one queued message to a `steer`: Pi takes it at the next turn boundary instead of waiting
1032
+ * Rewrites the text of ONE waiting message that has not been given to Pi yet.
1033
+ *
1034
+ * The one mutation the old waiting list could not express: a row there was a content copy in an array
1035
+ * with no way to say "the user changed their mind about these words", so changing your mind meant
1036
+ * cancelling and retyping. Now the message itself is edited, in place, and it stays exactly where it
1037
+ * is. Refused for `sending`, for the same reason `cancel` is.
1038
+ */
1039
+ | {
1040
+ type: "message.edit";
1041
+ conversationId: string;
1042
+ messageId: string;
1043
+ content: string;
1044
+ }
1045
+ /**
1046
+ * Moves ONE waiting message to a new position among the waiting messages.
1047
+ *
1048
+ * This is the whole of what the old `priority` boolean meant, expressed as a position instead of a
1049
+ * flag. `toIndex` is counted over the WAITING messages only (`0` is the next one the agent will
1050
+ * start), so moving something to the front does not depend on how many answered messages sit above
1051
+ * it, and moving it back is the same command rather than a separate "un-prioritise" that would be a
1052
+ * second source of truth for one idea.
1053
+ */
1054
+ | {
1055
+ type: "message.reorder";
1056
+ conversationId: string;
1057
+ messageId: string;
1058
+ toIndex: number;
1059
+ }
1060
+ /**
1061
+ * Promotes one waiting message to a `steer`: Pi takes it at the next turn boundary instead of waiting
1036
1062
  * for the whole task to finish.
1037
1063
  *
1038
1064
  * The timing is Pi's and cannot be finer: `agent-loop.ts:295` polls steering after `turn_end`, which
@@ -1040,17 +1066,23 @@ export type ClientCommandBody =
1040
1066
  * this means "after the tools currently running finish", and during a long single tool call there is
1041
1067
  * nothing to interrupt - Pi offers no preemption. The UI says so in words rather than implying
1042
1068
  * instant delivery.
1069
+ *
1070
+ * What this USED TO DO that it no longer does: it also set a `priority` flag, and on failure it fell
1071
+ * back to setting that flag, so "make this the next one" and "hand this to Pi right now" were one
1072
+ * button and one code path. They are still one button - both move the message to the front of the
1073
+ * waiting block - but the flag is gone, so the fallback is now the same move rather than a second
1074
+ * mechanism that had to be kept in agreement with the first.
1043
1075
  */
1044
1076
  | {
1045
1077
  type: "message.steer";
1046
1078
  conversationId: string;
1047
- queueId: string;
1079
+ messageId: string;
1048
1080
  }
1049
- /** Starts this saved queued message as the next turn, only after the current turn has ended. */
1081
+ /** Starts this waiting message as the next turn, only after the current turn has ended. */
1050
1082
  | {
1051
1083
  type: "message.sendQueued";
1052
1084
  conversationId: string;
1053
- queueId: string;
1085
+ messageId: string;
1054
1086
  } | {
1055
1087
  type: "run.stop";
1056
1088
  conversationId: string;