@evandu/super-pi 0.1.85 → 0.1.87

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.
@@ -8,8 +8,8 @@
8
8
  <link rel="alternate icon" href="/favicon.ico" />
9
9
  <link rel="apple-touch-icon" href="/favicon.svg" />
10
10
  <title>Super Pi</title>
11
- <script type="module" crossorigin src="/assets/index-B_jOwjyp.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-CNNGl4eF.css">
11
+ <script type="module" crossorigin src="/assets/index-Dh3QFthH.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-BfK8kaCv.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -838,24 +838,63 @@ export class RunService {
838
838
  // Written first, the message is in the transcript in the same frame, whether it starts a turn or
839
839
  // waits for one, and the guard below only decides WHICH. It also means the client-supplied `clientId`
840
840
  // is attached to a real message either way, so there is nothing to reconcile afterwards.
841
- const message = await this.store.addUserMessage(conversationId, trimmed, validImages, attachments, { clientId, delivery: "waiting" });
841
+ //
842
+ // BUT "WHICH" IS DECIDED BEFORE THE WRITE, not at the guard, and that ordering is the fix for a bug
843
+ // the other order cannot fix: a message that is going to start a turn has no business passing through
844
+ // 「等待回答」 on its way there. Send into an idle conversation and the reader watches a queue badge
845
+ // light up, a row appear in the dock, and both disappear a frame later - a queue that was never one,
846
+ // for a turn that had already begun. The state a message is born into is a claim about it, and a claim
847
+ // retracted in the next frame is worse than never making it.
848
+ //
849
+ // Reading the slot here and claiming it below leaves a window between them, and the window is real:
850
+ // `message.send` runs outside the socket's command queue precisely so a follow-up can overtake a
851
+ // command already in flight, so two sends CAN be inside this method at the same time. The loser wrote
852
+ // itself as an ordinary message and now finds the slot taken - its words are on screen with nobody
853
+ // going to answer them. Repairing that is what the guard below is for; it is not a rare path, it is
854
+ // what two fast sends looks like from the inside.
855
+ const busy = this.activeRuns.has(conversation.id);
856
+ const message = await this.store.addUserMessage(conversationId, trimmed, validImages, attachments, { clientId, ...(busy ? { delivery: "waiting" } : {}) });
842
857
  this.broadcaster.broadcastState();
843
858
  // Claim the run slot synchronously (no await between has() and set()) so two
844
859
  // rapid sends can never both pass the guard and start concurrent turns.
845
- if (this.activeRuns.has(conversation.id))
860
+ if (this.activeRuns.has(conversation.id)) {
861
+ if (!busy) {
862
+ // Lost the race described above. No turn has opened on this message and no model has seen it, so
863
+ // calling it waiting is a statement of fact - and it puts the words exactly where a message born
864
+ // waiting would have put them, which is the only place a person can find them.
865
+ await this.store.markMessageWaiting(conversationId, message.id);
866
+ this.broadcaster.broadcastState();
867
+ }
846
868
  return;
869
+ }
847
870
  const controller = new AbortController();
848
871
  this.activeRuns.set(conversation.id, controller);
849
872
  try {
850
- if (validImages?.length) {
851
- const catalog = await this.runtime.catalog(conversation.model);
852
- const currentModel = catalog.models.find((model) => model.provider === conversation.model?.provider && model.modelId === conversation.model?.modelId)
853
- ?? catalog.models.find((m) => m.provider === catalog.defaultModel?.provider && m.modelId === catalog.defaultModel?.modelId);
854
- if (currentModel && !currentModel.imageInput) {
855
- throw new Error(`当前选择的模型「${currentModel.label || currentModel.modelId}」不支持图片输入`);
873
+ let expanded;
874
+ try {
875
+ if (validImages?.length) {
876
+ const catalog = await this.runtime.catalog(conversation.model);
877
+ const currentModel = catalog.models.find((model) => model.provider === conversation.model?.provider && model.modelId === conversation.model?.modelId)
878
+ ?? catalog.models.find((m) => m.provider === catalog.defaultModel?.provider && m.modelId === catalog.defaultModel?.modelId);
879
+ if (currentModel && !currentModel.imageInput) {
880
+ throw new Error(`当前选择的模型「${currentModel.label || currentModel.modelId}」不支持图片输入`);
881
+ }
856
882
  }
883
+ expanded = await expandAtReferences(this.store, workspace, trimmed);
884
+ }
885
+ catch (error) {
886
+ // EVERY REFUSAL ABOVE HAPPENS BEFORE A TURN EXISTS. The words are on screen and no model has seen
887
+ // them, so failing here must not leave a bubble that nothing will ever answer: they go back to
888
+ // the queue, where they can be seen, cancelled or sent again. On the queueing path this is the
889
+ // state they were born in and nothing changed; on the idle path it is the difference between
890
+ // 「消息发出去了但没有回答」 and a message the person can still act on.
891
+ //
892
+ // The boundary is the shape of the code, not a flag: `runConversationTurns` opens the turn on its
893
+ // first line, so it is outside this try for exactly that reason. A failure from in there is a turn
894
+ // that has already consumed these words, and putting them back would send them twice.
895
+ await this.store.markMessageWaiting(conversationId, message.id).catch(() => undefined);
896
+ throw error;
857
897
  }
858
- const expanded = await expandAtReferences(this.store, workspace, trimmed);
859
898
  await this.runConversationTurns(conversation, workspace, expanded, controller, message.id, validImages);
860
899
  await this.store.setStatus(conversation.id, controller.signal.aborted ? "stopped" : "completed");
861
900
  }
@@ -1,6 +1,6 @@
1
1
  import { createReadStream, existsSync } from "node:fs";
2
2
  import { spawn } from "node:child_process";
3
- import { readdir, stat } from "node:fs/promises";
3
+ import { readdir, rm, stat } from "node:fs/promises";
4
4
  import { createServer } from "node:http";
5
5
  import { homedir, networkInterfaces } from "node:os";
6
6
  import { basename, dirname, extname, join, relative, resolve } from "node:path";
@@ -10,6 +10,7 @@ import { checkDiskVersion } from "./version.js";
10
10
  import { gitDiff, gitLog, gitStatus } from "./git.js";
11
11
  import { verifyCapability } from "./capability.js";
12
12
  import { MAX_KNOWLEDGE_REQUEST_BYTES } from "./knowledge.js";
13
+ import { MAX_TOOL_BODY_BYTES } from "./tool-registry.js";
13
14
  import { packageUpdateArgs } from "./update.js";
14
15
  const MIME_TYPES = {
15
16
  ".css": "text/css; charset=utf-8",
@@ -50,6 +51,28 @@ function toolBrowserSdk() {
50
51
  }
51
52
  },
52
53
  runAndWait: async (params = {}, options) => { const run = await api.run(params); return api.wait(run.id, options); },
54
+ // THE MODEL, ASKED ON THE TOOL'S BEHALF. One call, one answer, no client to write: Super Pi owns the
55
+ // credentials, the model choice and the ceilings, and the tool only ever sees the text. It is a
56
+ // completion, not an agent - the model that answers has no tools, no files and no session history.
57
+ // Only a tool registered with permissions.llm may call it; otherwise the server refuses.
58
+ llm: (input = {}) => request("./api/llm", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input) }),
59
+ llmGet: (callId) => request("./api/llm/" + encodeURIComponent(callId), { method: "GET" }),
60
+ llmCancel: (callId) => request("./api/llm/" + encodeURIComponent(callId), { method: "DELETE" }),
61
+ llmWait: async (callId, { intervalMs = 400, signal } = {}) => {
62
+ for (;;) {
63
+ if (signal && signal.aborted) throw new DOMException("Aborted", "AbortError");
64
+ const call = await api.llmGet(callId);
65
+ if (call.status !== "running") {
66
+ if (call.status !== "succeeded") throw new Error(call.error || "Super Pi's model call did not succeed");
67
+ return call;
68
+ }
69
+ await new Promise((resolve, reject) => {
70
+ const timer = setTimeout(resolve, Math.max(50, intervalMs));
71
+ if (signal) signal.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("Aborted", "AbortError")); }, { once: true });
72
+ });
73
+ }
74
+ },
75
+ llmText: async (input = {}, options) => { const call = await api.llm(input); return (await api.llmWait(call.id, options)).text; },
53
76
  };
54
77
  Object.freeze(api);
55
78
  Object.defineProperty(window, "SuperPiTool", { value: api, writable: false, configurable: false });
@@ -206,10 +229,35 @@ export function createHttpServer(options) {
206
229
  const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
207
230
  const remote = request.socket.remoteAddress ?? "";
208
231
  const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(JSON.stringify(body)); };
232
+ if (url.pathname === "/api/tools/export") {
233
+ // NOT EXEMPT FROM THE TOKEN CHECK ABOVE, and that is deliberate: an export hands out a tool's
234
+ // whole source, so it is a management read exactly like GET and DELETE, not a capability call.
235
+ try {
236
+ const id = url.searchParams.get("id");
237
+ if (!id) {
238
+ json(400, { error: "id required" });
239
+ return;
240
+ }
241
+ const exported = await toolRegistry.exportBundle(id);
242
+ if (!exported) {
243
+ json(404, { error: "tool not found" });
244
+ return;
245
+ }
246
+ json(200, exported);
247
+ return;
248
+ }
249
+ catch (error) {
250
+ json(400, { error: error instanceof Error ? error.message : "tool export failed" });
251
+ return;
252
+ }
253
+ }
209
254
  if (url.pathname === "/api/tools") {
210
255
  try {
211
256
  if (request.method === "GET") {
212
- json(200, { tools: await toolRegistry.list() });
257
+ // The environment state is DERIVED per request and deliberately not stored: it changes when
258
+ // the user installs Python, when a tool's venv is built, and when nothing at all happens.
259
+ const tools = await toolRegistry.list();
260
+ json(200, { tools: await Promise.all(tools.map(async (tool) => ({ ...tool, environmentState: await toolRunner.environmentState(tool) }))) });
213
261
  return;
214
262
  }
215
263
  if (request.method === "DELETE") {
@@ -219,6 +267,11 @@ export function createHttpServer(options) {
219
267
  return;
220
268
  }
221
269
  const removed = await toolRegistry.remove(id);
270
+ // The venv is machine-local state that belongs to that tool and nothing else, so it goes with
271
+ // it. Leaving it behind would mean a re-registered tool of the same slug silently inherits a
272
+ // stale environment.
273
+ if (removed)
274
+ await rm(toolRunner.envDir(id), { recursive: true, force: true });
222
275
  if (removed)
223
276
  broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
224
277
  json(removed ? 200 : 404, { ok: removed });
@@ -228,13 +281,18 @@ export function createHttpServer(options) {
228
281
  json(405, { error: "method" });
229
282
  return;
230
283
  }
231
- let raw = "";
284
+ // BYTES, NOT CHARACTERS, AND FROM THE REGISTRY'S OWN CONSTANT. The old cap counted UTF-16 code
285
+ // units at 256,000, below the registry's own 512,000-byte asset limit, so JSON escaping decided
286
+ // where the real ceiling was and an exported bundle could be refused on the way back in.
287
+ let raw = "", bytes = 0;
232
288
  for await (const chunk of request) {
233
- raw += chunk;
234
- if (raw.length > 256_000) {
289
+ const piece = chunk;
290
+ bytes += piece.length;
291
+ if (bytes > MAX_TOOL_BODY_BYTES) {
235
292
  json(413, { error: "request too large" });
236
293
  return;
237
294
  }
295
+ raw += piece.toString("utf8");
238
296
  }
239
297
  const body = JSON.parse(raw);
240
298
  if (!isAuthorized(request, accessToken)) {
@@ -252,7 +310,16 @@ export function createHttpServer(options) {
252
310
  json(400, { error: "slug, name and files are required" });
253
311
  return;
254
312
  }
255
- const tool = await toolRegistry.register({ slug: body.slug, name: body.name, description: body.description, entrypoint: body.entrypoint, script: body.script, files: body.files });
313
+ // AN IMPORTED BUNDLE KEEPS THE EXPORTER'S FINGERPRINT. It describes where the tool was written
314
+ // and verified, and overwriting it with the importer's own machine would erase the very
315
+ // difference the tool list exists to show. A tool with no fingerprint gets one measured here.
316
+ let environment = body.environment;
317
+ if (environment && !environment.fingerprint) {
318
+ const local = await toolRunner.describeLocalPython();
319
+ if (local)
320
+ environment = { ...environment, fingerprint: { python: local.python, platform: local.platform, packages: environment.requires } };
321
+ }
322
+ const tool = await toolRegistry.register({ slug: body.slug, name: body.name, description: body.description, entrypoint: body.entrypoint, script: body.script, environment, permissions: body.permissions, files: body.files });
256
323
  broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
257
324
  json(201, tool);
258
325
  return;
@@ -316,7 +383,7 @@ export function createHttpServer(options) {
316
383
  toolApi(404, { error: "python script is not registered" });
317
384
  return;
318
385
  }
319
- const run = toolRunner.start({ toolId: id, script: resolve(resolved.root, resolved.tool.script.path), cwd: resolved.root, definition: resolved.tool.script, params: body.params ?? {} });
386
+ const run = toolRunner.start({ toolId: id, script: resolve(resolved.root, resolved.tool.script.path), cwd: resolved.root, definition: resolved.tool.script, params: body.params ?? {}, environment: resolved.tool.environment });
320
387
  toolApi(202, run);
321
388
  }
322
389
  catch (error) {
@@ -363,6 +430,91 @@ export function createHttpServer(options) {
363
430
  toolApi(405, { error: "method" });
364
431
  return;
365
432
  }
433
+ // ---- THE MODEL, ON THE TOOL'S OWN BEHALF ---------------------------------------------------
434
+ // A tool asking the model a question, over the same relative API as its Python jobs, so the frame
435
+ // still never learns an id, a path or a credential. Three gates, in this order:
436
+ // 1. the opaque sandbox origin - identical to every other tool API here;
437
+ // 2. the tool must CARRY the grant (`permissions.llm`), which is the thing the review card showed
438
+ // the person before they accepted the file. A tool that quietly started asking the model
439
+ // would be spending the user's quota from inside a sandbox they were told was isolated;
440
+ // 3. the run is bound to this tool id, so one frame cannot read or cancel another's answer.
441
+ const llmStart = /^api\/llm$/.exec(resource);
442
+ const llmRun = /^api\/llm\/([^/]+)$/.exec(resource);
443
+ if (llmStart || llmRun) {
444
+ if (!toolOrigin) {
445
+ toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
446
+ return;
447
+ }
448
+ // GRANT FIRST, THEN RUNTIME. What a tool is allowed to ask is a fact about the tool and does not
449
+ // depend on whether this machine happens to have a model, so a tool with no grant gets the same
450
+ // 403 whether or not Pi is installed.
451
+ if (!listed.permissions?.llm) {
452
+ toolApi(403, { error: "这个工具没有调用模型的权限;注册或导入时它必须声明 permissions: { llm: true }。" });
453
+ return;
454
+ }
455
+ if (!context.toolLlm?.available) {
456
+ toolApi(503, { error: "这台机器上没有可用的 Pi 运行时,工具无法调用模型。" });
457
+ return;
458
+ }
459
+ if (llmStart) {
460
+ if (request.method === "OPTIONS") {
461
+ response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "POST", "access-control-allow-headers": "content-type", "access-control-max-age": "300", "vary": "Origin" }).end();
462
+ return;
463
+ }
464
+ if (request.method !== "POST") {
465
+ toolApi(405, { error: "method" });
466
+ return;
467
+ }
468
+ let raw = "";
469
+ for await (const chunk of request) {
470
+ raw += chunk;
471
+ if (raw.length > 64_000) {
472
+ toolApi(413, { error: "request too large" });
473
+ return;
474
+ }
475
+ }
476
+ try {
477
+ const body = JSON.parse(raw);
478
+ toolApi(202, context.toolLlm.start({ toolId: id, prompt: body.prompt ?? "", system: body.system, timeoutMs: body.timeoutMs }));
479
+ }
480
+ catch (error) {
481
+ toolApi(400, { error: error instanceof Error ? error.message : "model call failed" });
482
+ }
483
+ return;
484
+ }
485
+ if (request.method === "OPTIONS") {
486
+ response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "GET, DELETE", "access-control-allow-headers": "content-type", "access-control-max-age": "300", "vary": "Origin" }).end();
487
+ return;
488
+ }
489
+ let llmId;
490
+ try {
491
+ llmId = decodeURIComponent(llmRun[1]);
492
+ }
493
+ catch {
494
+ toolApi(400, { error: "Malformed tool resource" });
495
+ return;
496
+ }
497
+ if (request.method === "GET") {
498
+ const run = context.toolLlm.get(llmId, id);
499
+ if (!run) {
500
+ toolApi(404, { error: "model call not found" });
501
+ return;
502
+ }
503
+ toolApi(200, run);
504
+ return;
505
+ }
506
+ if (request.method === "DELETE") {
507
+ const run = context.toolLlm.cancel(llmId, id);
508
+ if (!run) {
509
+ toolApi(404, { error: "model call not found" });
510
+ return;
511
+ }
512
+ toolApi(200, run);
513
+ return;
514
+ }
515
+ toolApi(405, { error: "method" });
516
+ return;
517
+ }
366
518
  if (resource === "super-pi-tool.js") {
367
519
  response.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" }).end(toolBrowserSdk());
368
520
  return;
@@ -26,6 +26,7 @@ import { WsBroadcaster, WsServer } from "./ws-server.js";
26
26
  import { initRunningVersion } from "./version.js";
27
27
  import { ToolRegistry } from "./tool-registry.js";
28
28
  import { ToolRunner } from "./tool-runner.js";
29
+ import { ToolLlmService } from "./tool-llm.js";
29
30
  // ------------------------------------------------------------- Environment & paths
30
31
  const rootDir = resolve(import.meta.dirname, "../..");
31
32
  initRunningVersion(rootDir);
@@ -54,7 +55,9 @@ const toolsEndpoint = `http://${internalHost}:${port}/api/tools`;
54
55
  // ------------------------------------------------------------- Core singletons
55
56
  const store = await openStateStore(dataDir, process.env.SUPER_PI_PERSISTENCE ?? "sqlite");
56
57
  const toolRegistry = new ToolRegistry(dataDir);
57
- const toolRunner = new ToolRunner();
58
+ // The data dir is what makes a per-tool virtual environment possible: it lives at
59
+ // `<dataDir>/tool-envs/<toolId>`, beside the tools rather than inside one.
60
+ const toolRunner = new ToolRunner(dataDir);
58
61
  const initialAgentDirs = resolveAgentDirectories({
59
62
  customDirs: store.snapshot().customAgentDirs,
60
63
  rootDir,
@@ -77,6 +80,9 @@ const memory = new ActiveMemory(join(dataDir, "memories"), store, runtime);
77
80
  process.env.SUPER_PI_MEMORY_ENDPOINT = `http://${internalHost}:${port}/api/memory/tool`;
78
81
  if (runtime instanceof PiRpcRuntime)
79
82
  runtime.setMemory(memory);
83
+ // The model a mounted tool may ask. It IS Pi: `PiRpcRuntime.askOnce` spawns its own child with nothing
84
+ // attached but Pi, and a runtime without one reports itself unavailable rather than faking an answer.
85
+ const toolLlm = new ToolLlmService(runtime, rootDir);
80
86
  const knowledge = new KnowledgeBase(join(dataDir, "knowledge"), store, runtime, new VectorIndex(join(dataDir, "knowledge")));
81
87
  process.env.SUPER_PI_KNOWLEDGE_ENDPOINT = `http://${internalHost}:${port}/api/knowledge/tool`;
82
88
  if (runtime instanceof PiRpcRuntime)
@@ -169,6 +175,7 @@ const serverContext = {
169
175
  execService,
170
176
  toolRegistry,
171
177
  toolRunner,
178
+ toolLlm,
172
179
  terminalEnabled: () => terminalService.enabled,
173
180
  terminalDisabledReason: () => terminalDisabledReason(),
174
181
  };
@@ -245,6 +245,30 @@ export interface AgentRuntime {
245
245
  * Absent on the welcome runtime, which has no Pi behind it.
246
246
  */
247
247
  summarizeConversation?(conversation: Conversation, cwd: string, signal?: AbortSignal): Promise<string>;
248
+ /**
249
+ * ONE-SHOT COMPLETION, FOR A MOUNTED TOOL. A throwaway Pi child, one prompt, one answer, then gone.
250
+ *
251
+ * WHY IT IS A SEPARATE CHILD, LIKE `summarizeConversation`: a turn's child is the one reading the
252
+ * user's stream. Writing a tool's prompt at it would land in the conversation the user is watching.
253
+ *
254
+ * WHY NOTHING IS ATTACHED BUT Pi ITSELF. `--no-extensions --no-skills --no-mcp --no-context-files
255
+ * --no-prompt-templates`, the same line the knowledge reranker uses: a tool asking the model a question
256
+ * must not be able to reach the user's files, the task tools, or a session history. A model that
257
+ * spends the turn on a tool call anyway gets no answer and the call times out - the same accepted
258
+ * failure mode the reranker documents.
259
+ *
260
+ * WHY IT CHOOSES NO MODEL. Pi's own default is the user's own default, and a sandboxed frame is the
261
+ * last place that should get to pick which model spends the user's quota.
262
+ *
263
+ * Absent on a runtime with no Pi behind it, which the caller reports as a refusal rather than
264
+ * pretending a model answered.
265
+ */
266
+ askOnce?(input: {
267
+ prompt: string;
268
+ timeoutMs: number;
269
+ cwd: string;
270
+ signal?: AbortSignal;
271
+ }): Promise<string>;
248
272
  /**
249
273
  * The agent a conversation runs as, read fresh from the agent directory. Absent on a runtime with
250
274
  * no agents (the welcome runtime), where every conversation runs as Pi itself. Callers that need an
@@ -669,6 +693,16 @@ export declare class PiRpcRuntime implements AgentRuntime {
669
693
  * model; in the second the outcome is genuinely unknown and a `sending` state says so.
670
694
  */
671
695
  summarizeConversation(conversation: Conversation, cwd: string, signal?: AbortSignal): Promise<string>;
696
+ /**
697
+ * A single completion for a mounted tool. See `AgentRuntime.askOnce` for why it is its own child,
698
+ * why nothing is attached but Pi, and why it picks no model.
699
+ */
700
+ askOnce(input: {
701
+ prompt: string;
702
+ timeoutMs: number;
703
+ cwd: string;
704
+ signal?: AbortSignal;
705
+ }): Promise<string>;
672
706
  steer(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
673
707
  /**
674
708
  * The model/thinking catalog, from Pi, cached.
@@ -1360,6 +1360,51 @@ export class PiRpcRuntime {
1360
1360
  await killTree(child.pid);
1361
1361
  }
1362
1362
  }
1363
+ /**
1364
+ * A single completion for a mounted tool. See `AgentRuntime.askOnce` for why it is its own child,
1365
+ * why nothing is attached but Pi, and why it picks no model.
1366
+ */
1367
+ async askOnce(input) {
1368
+ if (!this.pi.found)
1369
+ throw new Error(this.pi.reason);
1370
+ // A throwaway session id, for the same reason the autotitle child gets one: this exchange is not
1371
+ // part of any conversation's transcript and must never be filed under one.
1372
+ const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", `tool-llm-${randomUUID()}`, "--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates"], {
1373
+ cwd: resolve(input.cwd),
1374
+ stdio: ["pipe", "pipe", "pipe"],
1375
+ shell: process.platform === "win32",
1376
+ });
1377
+ const channel = new RpcChannel(child);
1378
+ const deadline = Date.now() + input.timeoutMs;
1379
+ try {
1380
+ channel.write({ id: "tool_llm", type: "prompt", message: input.prompt });
1381
+ let settled = false, text = "";
1382
+ while (!settled && Date.now() < deadline) {
1383
+ if (input.signal?.aborted)
1384
+ throw new Error("已取消");
1385
+ const record = await channel.next(100);
1386
+ if (!record)
1387
+ continue;
1388
+ if (record.type === "message_update" && record.assistantMessageEvent?.type === "text_delta")
1389
+ text += record.assistantMessageEvent.delta ?? "";
1390
+ if (record.type === "agent_settled")
1391
+ settled = true;
1392
+ if (record.type === "response" && record.id === "tool_llm" && record.success === false)
1393
+ throw new Error(record.error ?? "Pi 拒绝了这次请求");
1394
+ if (child.exitCode !== null)
1395
+ throw new Error("模型进程意外退出");
1396
+ }
1397
+ if (!settled)
1398
+ throw new Error(`模型在 ${Math.round(input.timeoutMs / 1000)} 秒内没有返回`);
1399
+ if (!text.trim())
1400
+ throw new Error("模型没有返回任何内容");
1401
+ return text;
1402
+ }
1403
+ finally {
1404
+ if (child.exitCode === null && child.pid !== undefined)
1405
+ await killTree(child.pid);
1406
+ }
1407
+ }
1363
1408
  async steer(conversationId, message, images) {
1364
1409
  const run = this.runs.get(conversationId);
1365
1410
  if (!run || !this.canSteer(conversationId))
@@ -52,6 +52,8 @@ export interface ServerContext {
52
52
  execService: ExecService;
53
53
  toolRegistry: ToolRegistry;
54
54
  toolRunner: ToolRunner;
55
+ /** Absent only if construction failed; a runtime with no Pi reports itself through `available`. */
56
+ toolLlm?: import("./tool-llm.js").ToolLlmService;
55
57
  /**
56
58
  * Whether the right rail's terminal tab should exist, and why not when it should not.
57
59
  *
@@ -419,6 +419,25 @@ export declare class Store {
419
419
  * claim the same message.
420
420
  */
421
421
  claimWaitingMessage(conversationId: string, messageId: string): Promise<Message>;
422
+ /**
423
+ * Puts ONE message into `waiting` when it has no delivery state at all.
424
+ *
425
+ * `releaseWaitingMessage` above moves `sending` BACK to `waiting`, which is a claim being taken back.
426
+ * This is the other direction: a message that was written as an ordinary sent message and has to join
427
+ * the queue after the fact. There are exactly two reasons it happens, and both are consequences of the
428
+ * decision being made before the write rather than after it (see `enqueueAndRun`):
429
+ *
430
+ * 1. Two sends raced. The second one read "idle", wrote itself as an ordinary message, and only then
431
+ * found the run slot taken. Its words are unanswered, so it is a waiting message after all.
432
+ * 2. The preflight that runs between the write and the turn refused (a model that cannot read images,
433
+ * an @-mention that could not be expanded). Nothing reached a model, so the words are still owed an
434
+ * answer and belong in the queue where the user can see them and send them again.
435
+ *
436
+ * Both are restorations of a state that was briefly wrong, never a way to re-queue a message Pi holds:
437
+ * a `sending` message is refused here, which keeps this from becoming a back door around the one-way
438
+ * door `claimWaitingMessage` puts up.
439
+ */
440
+ markMessageWaiting(conversationId: string, messageId: string): Promise<Message | undefined>;
422
441
  /**
423
442
  * Puts ONE `sending` message back to `waiting`, for the one case that proves Pi never took it.
424
443
  *
@@ -1365,6 +1365,37 @@ export class Store {
1365
1365
  await this.persistMessages(conversation);
1366
1366
  return structuredClone(message);
1367
1367
  }
1368
+ /**
1369
+ * Puts ONE message into `waiting` when it has no delivery state at all.
1370
+ *
1371
+ * `releaseWaitingMessage` above moves `sending` BACK to `waiting`, which is a claim being taken back.
1372
+ * This is the other direction: a message that was written as an ordinary sent message and has to join
1373
+ * the queue after the fact. There are exactly two reasons it happens, and both are consequences of the
1374
+ * decision being made before the write rather than after it (see `enqueueAndRun`):
1375
+ *
1376
+ * 1. Two sends raced. The second one read "idle", wrote itself as an ordinary message, and only then
1377
+ * found the run slot taken. Its words are unanswered, so it is a waiting message after all.
1378
+ * 2. The preflight that runs between the write and the turn refused (a model that cannot read images,
1379
+ * an @-mention that could not be expanded). Nothing reached a model, so the words are still owed an
1380
+ * answer and belong in the queue where the user can see them and send them again.
1381
+ *
1382
+ * Both are restorations of a state that was briefly wrong, never a way to re-queue a message Pi holds:
1383
+ * a `sending` message is refused here, which keeps this from becoming a back door around the one-way
1384
+ * door `claimWaitingMessage` puts up.
1385
+ */
1386
+ async markMessageWaiting(conversationId, messageId) {
1387
+ const conversation = this.requireConversation(conversationId);
1388
+ const message = conversation.messages.find((item) => item.id === messageId);
1389
+ if (!message || message.role !== "user")
1390
+ return undefined;
1391
+ if (message.delivery === "sending")
1392
+ throw new Error("这条消息已经交给 Agent 了");
1393
+ if (message.delivery === "waiting")
1394
+ return structuredClone(message);
1395
+ message.delivery = "waiting";
1396
+ await this.persistMessages(conversation);
1397
+ return structuredClone(message);
1398
+ }
1368
1399
  /**
1369
1400
  * Puts ONE `sending` message back to `waiting`, for the one case that proves Pi never took it.
1370
1401
  *
@@ -0,0 +1,65 @@
1
+ /**
2
+ * A MOUNTED TOOL ASKING THE MODEL A QUESTION - bookkeeping, ceilings and the consent gate's server half.
3
+ *
4
+ * WHY A TOOL GETS THIS AT ALL. A tool author who wants a model in their tool should not be writing a
5
+ * client: no API key to store, no provider endpoint, no retry, no streaming, no cost ceiling. Pi already
6
+ * holds the credentials and the model configuration, so Super Pi owns the call and the tool asks for it
7
+ * over the same relative API it already uses for its Python jobs.
8
+ *
9
+ * WHY IT IS A COMPLETION AND NOT AN AGENT. `askOnce` spawns Pi with `--no-extensions --no-skills
10
+ * --no-mcp --no-context-files --no-prompt-templates`, so the model answering a tool's prompt has no
11
+ * tools, no files and no history. A tool that could make the agent run things would be handing a
12
+ * sandboxed iframe the user's whole machine, and the iframe is exactly the thing that must not have it.
13
+ *
14
+ * WHY EVERY CEILING IS HERE AND NOT IN THE FRAME. The caller is an opaque sandbox on someone else's
15
+ * machine. Prompt size, answer size, duration, and how many calls may be in flight at once are all
16
+ * decided by this file, because "the tool asked nicely" is not a limit.
17
+ */
18
+ /** The slice of the runtime this needs, declared structurally for the same reason `KnowledgeRuntime` is. */
19
+ export type ToolLlmRuntime = {
20
+ /** OPTIONAL, exactly as it is on `AgentRuntime`: a runtime with no Pi behind it has none. */
21
+ askOnce?(input: {
22
+ prompt: string;
23
+ timeoutMs: number;
24
+ cwd: string;
25
+ signal?: AbortSignal;
26
+ }): Promise<string>;
27
+ };
28
+ export type ToolLlmStatus = "running" | "succeeded" | "failed" | "cancelled" | "timed_out";
29
+ export type ToolLlmRun = {
30
+ id: string;
31
+ toolId: string;
32
+ status: ToolLlmStatus;
33
+ startedAt: string;
34
+ finishedAt?: string;
35
+ /** The model's answer. Bounded by MAX_TOOL_LLM_RESPONSE_BYTES, and this is the only copy. */
36
+ text: string;
37
+ promptChars: number;
38
+ elapsedMs?: number;
39
+ error?: string;
40
+ };
41
+ export type ToolLlmRequest = {
42
+ toolId: string;
43
+ prompt: string;
44
+ system?: string;
45
+ timeoutMs?: number;
46
+ };
47
+ /** 32KB of prompt is a paragraph of a book; more than that is a tool trying to move a transcript. */
48
+ export declare const MAX_TOOL_LLM_PROMPT_BYTES = 32000;
49
+ /** Answers are clipped rather than refused: a truncated answer is still an answer, and the model may be
50
+ * mid-sentence when the ceiling lands. The clip is visible in `chars` being lower than expected. */
51
+ export declare const MAX_TOOL_LLM_RESPONSE_BYTES = 64000;
52
+ export declare const MAX_TOOL_LLM_TIMEOUT_MS: number;
53
+ export declare class ToolLlmService {
54
+ private readonly runtime;
55
+ private readonly cwd;
56
+ private readonly runs;
57
+ constructor(runtime: ToolLlmRuntime | undefined, cwd: string);
58
+ /** WHY A MISSING RUNTIME IS A SENTENCE AND NOT A 500. The welcome runtime has no Pi behind it, and a
59
+ * tool asking a question there should be told the machine cannot answer it. */
60
+ get available(): boolean;
61
+ start(request: ToolLlmRequest): ToolLlmRun;
62
+ get(id: string, toolId: string): ToolLlmRun | undefined;
63
+ cancel(id: string, toolId: string): ToolLlmRun | undefined;
64
+ wait(id: string, toolId: string, signal?: AbortSignal): Promise<ToolLlmRun>;
65
+ }