@evandu/super-pi 0.1.76 → 0.1.78

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-CHRkbLTs.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-CKdlMXJt.css">
11
+ <script type="module" crossorigin src="/assets/index-DCtDhAuw.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-C7wCG7Jn.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -17,6 +17,18 @@ export interface SubagentDispatchRequestBody {
17
17
  role?: string;
18
18
  /** The per-turn dispatch capability, presented by the primary agent's extension. See `capability.ts`. */
19
19
  capability?: string;
20
+ /**
21
+ * Pi's id for the tool call that asked for this dispatch, which the extension already receives as
22
+ * `execute`'s first argument and previously discarded. The server resolves it to a position in the
23
+ * dispatching message so the card renders AT THE CALL rather than at a point in time.
24
+ *
25
+ * UNVERIFIED AND THEREFORE INERT: it is a claim about which row a card belongs in, not a credential,
26
+ * so it is checked against nothing. An id that resolves to no call is simply no anchor. That is
27
+ * deliberate - this field crosses a boundary a model can reach, and a sub-agent inventing an id to park
28
+ * its card inside somebody else's tool call would be a way to make a run look like it belongs to work it
29
+ * never did. A position is not a permission, and it grants none.
30
+ */
31
+ toolCallId?: string;
20
32
  }
21
33
  /**
22
34
  * A whole orchestrated plan, as it arrives over the wire.
@@ -32,6 +44,9 @@ export interface WorkflowRunRequestBody {
32
44
  steps?: unknown;
33
45
  /** The per-turn dispatch capability, presented by the primary agent's extension. See `capability.ts`. */
34
46
  capability?: string;
47
+ /** Pi's id for the tool call that asked for this, and resolved the same inert way as a sub-agent
48
+ * dispatch's - a position in the transcript, never a permission. */
49
+ toolCallId?: string;
35
50
  }
36
51
  /**
37
52
  * Service managing execution of conversation turns, message queueing,
@@ -49,6 +64,36 @@ export declare class RunService {
49
64
  setMemory(memory: import("../active-memory.js").ActiveMemory): void;
50
65
  reviewMemoryBoundary(conversationId: string): void;
51
66
  private readonly activeRuns;
67
+ /**
68
+ * WHERE EACH TOOL CALL LANDED, per conversation, for as long as the turn that made it is still running.
69
+ *
70
+ * Keyed by Pi's own `toolCallId` - the id the model sees, which Pi also hands to the tool as `execute`'s
71
+ * first argument. That is the only handle shared by the two halves of a dispatch: the server learns the
72
+ * call exists while streaming the turn, and learns it was a sub-agent only when the child, running in a
73
+ * SEPARATE process, posts back over HTTP. `rowByToolCallId` already maps the same key to a row, but it is
74
+ * a local of `streamTurn` and the dispatch arrives in `dispatchTask` - a different closure, reached by an
75
+ * HTTP request. Without a bridge between them the server could only stamp a card with a start time, and
76
+ * a card sorted by start time lands BELOW every message still streaming after the call that asked for it.
77
+ *
78
+ * The outer map is per conversation rather than global so two conversations cannot read each other's
79
+ * positions, and it is dropped when the turn ends because that is when the fact stops being true: a
80
+ * dispatch that arrives after its turn has finished gets no anchor, which renders as it always did.
81
+ */
82
+ private readonly toolCallLanes;
83
+ private readonly toolCallWaiters;
84
+ private readonly toolCallSites;
85
+ /** Records that Pi's `toolCallId` became row `row` of `messageId`. */
86
+ private noteToolCallSite;
87
+ /**
88
+ * The call a dispatch belongs to, as the two fields a card carries. Absent - rather than row zero - when
89
+ * there is no call to point at: a person pressing 派子智能体 has no tool call, a dispatch from an older
90
+ * extension has no id, and a dispatch that landed after its turn ended has outlived the map.
91
+ */
92
+ originForToolCall(conversationId: string, toolCallId: string | undefined): {
93
+ originMessageId: string;
94
+ originCallRow: number;
95
+ } | undefined;
96
+ waitForToolCall(conversationId: string, toolCallId: string | undefined): Promise<ReturnType<RunService["originForToolCall"]>>;
52
97
  private execService;
53
98
  setExecService(execService: import("../exec-service.js").ExecService): void;
54
99
  /**
@@ -58,6 +58,70 @@ export class RunService {
58
58
  this.memory?.review(conversation);
59
59
  }
60
60
  activeRuns = new Map();
61
+ /**
62
+ * WHERE EACH TOOL CALL LANDED, per conversation, for as long as the turn that made it is still running.
63
+ *
64
+ * Keyed by Pi's own `toolCallId` - the id the model sees, which Pi also hands to the tool as `execute`'s
65
+ * first argument. That is the only handle shared by the two halves of a dispatch: the server learns the
66
+ * call exists while streaming the turn, and learns it was a sub-agent only when the child, running in a
67
+ * SEPARATE process, posts back over HTTP. `rowByToolCallId` already maps the same key to a row, but it is
68
+ * a local of `streamTurn` and the dispatch arrives in `dispatchTask` - a different closure, reached by an
69
+ * HTTP request. Without a bridge between them the server could only stamp a card with a start time, and
70
+ * a card sorted by start time lands BELOW every message still streaming after the call that asked for it.
71
+ *
72
+ * The outer map is per conversation rather than global so two conversations cannot read each other's
73
+ * positions, and it is dropped when the turn ends because that is when the fact stops being true: a
74
+ * dispatch that arrives after its turn has finished gets no anchor, which renders as it always did.
75
+ */
76
+ toolCallLanes = new Map();
77
+ toolCallWaiters = new Map();
78
+ toolCallSites = new Map();
79
+ /** Records that Pi's `toolCallId` became row `row` of `messageId`. */
80
+ noteToolCallSite(conversationId, toolCallId, messageId, row) {
81
+ if (!toolCallId)
82
+ return;
83
+ let sites = this.toolCallSites.get(conversationId);
84
+ if (!sites) {
85
+ sites = new Map();
86
+ this.toolCallSites.set(conversationId, sites);
87
+ }
88
+ sites.set(toolCallId, { messageId, row });
89
+ for (const wake of this.toolCallWaiters.get(`${conversationId}:${toolCallId}`) ?? [])
90
+ wake();
91
+ }
92
+ /**
93
+ * The call a dispatch belongs to, as the two fields a card carries. Absent - rather than row zero - when
94
+ * there is no call to point at: a person pressing 派子智能体 has no tool call, a dispatch from an older
95
+ * extension has no id, and a dispatch that landed after its turn ended has outlived the map.
96
+ */
97
+ originForToolCall(conversationId, toolCallId) {
98
+ if (!toolCallId)
99
+ return undefined;
100
+ const site = this.toolCallSites.get(conversationId)?.get(toolCallId);
101
+ return site ? { originMessageId: site.messageId, originCallRow: site.row } : undefined;
102
+ }
103
+ async waitForToolCall(conversationId, toolCallId) {
104
+ const origin = this.originForToolCall(conversationId, toolCallId);
105
+ if (origin) {
106
+ await this.toolCallLanes.get(conversationId);
107
+ return origin;
108
+ }
109
+ if (!toolCallId || !this.activeRuns.has(conversationId))
110
+ return undefined;
111
+ // The tool POST and Pi's stdout use independent transports. HTTP may win even
112
+ // though Pi emitted tool_execution_start first; wait for that event, not a clock position.
113
+ const key = `${conversationId}:${toolCallId}`;
114
+ await new Promise(resolve => {
115
+ const waiters = this.toolCallWaiters.get(key) ?? new Set();
116
+ const wake = () => { clearTimeout(timer); waiters.delete(wake); if (!waiters.size)
117
+ this.toolCallWaiters.delete(key); resolve(); };
118
+ const timer = setTimeout(wake, 2000);
119
+ waiters.add(wake);
120
+ this.toolCallWaiters.set(key, waiters);
121
+ });
122
+ await this.toolCallLanes.get(conversationId);
123
+ return this.originForToolCall(conversationId, toolCallId);
124
+ }
61
125
  execService;
62
126
  setExecService(execService) { this.execService = execService; }
63
127
  /**
@@ -78,6 +142,15 @@ export class RunService {
78
142
  const message = `<task id="${sessionId}" state="${exitCode === 0 ? "completed" : "error"}" kind="exec">\n<summary>${task.title}</summary>\n<${tag}>\n${body}</${tag}>\n</task>`;
79
143
  void this.trackDetached((async () => {
80
144
  try {
145
+ // Keep the dispatch card at its call after the running-task list is cleaned up.
146
+ // The result message below keeps its separate arrival position in the transcript.
147
+ if (task.originMessageId !== undefined && task.originCallRow !== undefined
148
+ && this.store.snapshot().conversations.find(conversation => conversation.id === task.conversationId)
149
+ ?.messages.find(message => message.id === task.originMessageId)?.toolCalls?.[task.originCallRow]) {
150
+ const toolCalls = await this.store.updateToolCall(task.conversationId, task.originMessageId, task.originCallRow, { backgroundTask: { id: sessionId, status: exitCode === 0 ? "completed" : "failed", output: (stdout + stderr).slice(-512_000), exitCode } });
151
+ this.broadcaster.broadcast({ type: "message.tools", conversationId: task.conversationId, messageId: task.originMessageId, toolCalls });
152
+ }
153
+ this.broadcaster.broadcast({ type: "exec.exit", sessionId, exitCode, stdout, stderr });
81
154
  await this.store.addToolMessage(task.conversationId, message);
82
155
  this.broadcaster.broadcastState();
83
156
  }
@@ -367,6 +440,7 @@ export class RunService {
367
440
  }).catch((error) => {
368
441
  console.warn(`Super Pi: recording ${what} failed: ${error instanceof Error ? error.message : "unknown error"}`);
369
442
  });
443
+ this.toolCallLanes.set(conversation.id, pendingTools);
370
444
  };
371
445
  // HOW MUCH OF THE ANSWER HAD BEEN SPOKEN WHEN EACH CALL WAS ANNOUNCED, counted HERE rather than read
372
446
  // from the store when the write runs.
@@ -383,6 +457,18 @@ export class RunService {
383
457
  // The running total is what lets a call made during reasoning anchor inside the thinking block rather than
384
458
  // after it, which is the order the events had.
385
459
  let thought = 0;
460
+ let thinkingActive = false;
461
+ let thinkingBlock = -1;
462
+ let thinkingAnswerAt = -1;
463
+ let thinkingInterrupted = false;
464
+ const currentThinkingBlock = () => {
465
+ if (thinkingActive)
466
+ return thinkingBlock;
467
+ // An outside call separates two thoughts even if no answer text was emitted.
468
+ if (thinkingBlock >= 0)
469
+ thinkingInterrupted = true;
470
+ return null;
471
+ };
386
472
  /**
387
473
  * ONE COUNTER FOR EVERY ROW, AND THE ONLY WAY A ROW NUMBER IS EVER MADE.
388
474
  *
@@ -432,6 +518,7 @@ export class RunService {
432
518
  // this one was not, and the JSON persistence backend's slower writes widen the window enough for
433
519
  // the browser test to catch the difference between capturing and reading.
434
520
  const thinkingAt = thought;
521
+ const callThinkingBlock = currentThinkingBlock();
435
522
  // The block says WHICH TOOL and WHAT IT IS AIMED AT as soon as either is known. 「工具」 as the name
436
523
  // and 「正在生成参数…」 as the only line below it told a reader nothing for the seconds a large `edit`
437
524
  // spends writing its replacement text - and by the time the file path was on screen, the block looked
@@ -443,6 +530,7 @@ export class RunService {
443
530
  preparing: true,
444
531
  at,
445
532
  thinkingAt,
533
+ thinkingBlock: callThinkingBlock,
446
534
  }));
447
535
  preparingRows.set(call.contentIndex, { row, name: call.name });
448
536
  };
@@ -466,18 +554,23 @@ export class RunService {
466
554
  // The id is bound to the row it was FILLED IN, not to a row of the caller's own counting. Every
467
555
  // later update and the outcome resolve through this binding, so a wrong number here is a block
468
556
  // that never settles and one that belongs to a different call.
469
- if (call.id)
557
+ if (call.id) {
470
558
  rowByToolCallId.set(call.id, row);
559
+ this.noteToolCallSite(conversation.id, call.id, assistant.id, row);
560
+ }
471
561
  enqueueTools("a tool call", () => this.store.updateToolCall(conversation.id, assistant.id, row, { label: call.name, detail: call.detail, args: call.args, live: "", preparing: false, ...(call.question ? { question: call.question } : {}) }));
472
562
  return;
473
563
  }
474
564
  const row = rows++;
475
- if (call.id)
565
+ if (call.id) {
476
566
  rowByToolCallId.set(call.id, row);
567
+ this.noteToolCallSite(conversation.id, call.id, assistant.id, row);
568
+ }
477
569
  // Captured for the same reason as the preparing path above: the queued append must anchor the call
478
570
  // where it was MADE, not where the queue happened to be when it drained.
479
571
  const thinkingAt = thought;
480
- enqueueTools("a tool call", () => this.store.appendToolCall(conversation.id, assistant.id, { label: call.name, detail: call.detail, args: call.args, at, thinkingAt, ...(call.question ? { question: call.question } : {}) }));
572
+ const callThinkingBlock = currentThinkingBlock();
573
+ enqueueTools("a tool call", () => this.store.appendToolCall(conversation.id, assistant.id, { label: call.name, detail: call.detail, args: call.args, at, thinkingAt, thinkingBlock: callThinkingBlock, ...(call.question ? { question: call.question } : {}) }));
481
574
  };
482
575
  const onToolUpdate = (call) => {
483
576
  const index = call.id ? rowByToolCallId.get(call.id) : undefined;
@@ -508,12 +601,36 @@ export class RunService {
508
601
  // BROADCAST AND KEEP. It used to only broadcast, into an event no one handled - so the reasoning
509
602
  // reached nobody, live or afterwards. Both halves now happen: the browser draws the delta as it
510
603
  // arrives, and the message keeps it so the finished turn has something to go back to.
604
+ //
605
+ // `active: true` on every delta is not a field Pi sent - it is the conclusion Super Pi draws from
606
+ // having received one. Pi announces the END of reasoning with `thinking_end` and puts nothing on
607
+ // the delta, so these two halves together are the whole lifecycle.
511
608
  onThinking: (delta) => {
609
+ if (!delta)
610
+ return;
611
+ const newBlock = thinkingAnswerAt !== spoken || thinkingInterrupted;
612
+ if (newBlock) {
613
+ thinkingBlock++;
614
+ thinkingAnswerAt = spoken;
615
+ }
616
+ thinkingInterrupted = false;
617
+ thinkingActive = true;
618
+ this.store.setThinkingActiveLive(conversation.id, assistant.id, true);
512
619
  thought += delta.length;
513
- this.store.appendThinkingDeltaLive(conversation.id, assistant.id, delta, spoken);
514
- this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true, at: spoken });
620
+ this.store.appendThinkingDeltaLive(conversation.id, assistant.id, delta, spoken, newBlock);
621
+ this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true, at: spoken, blockIndex: thinkingBlock });
515
622
  scheduleCheckpoint();
516
623
  },
624
+ // AN EMPTY DELTA THAT ONLY CARRIES THE FLAG. `thinking_end` is a fact the browser needs and not
625
+ // text, so it rides as a delta of length zero and the client is required to leave `thinking` and
626
+ // `thinkingBlocks` alone when it sees one. Without that rule this event appends an EMPTY fold
627
+ // block to every turn whose second reasoning run starts after the answer has already begun,
628
+ // because that block's `at` then differs from the previous one's.
629
+ onThinkingEnd: () => {
630
+ thinkingActive = false;
631
+ this.store.setThinkingActiveLive(conversation.id, assistant.id, false);
632
+ this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta: "", active: false });
633
+ },
517
634
  images,
518
635
  })) {
519
636
  this.store.appendAssistantDeltaLive(conversation.id, assistant.id, delta);
@@ -529,8 +646,20 @@ export class RunService {
529
646
  finally {
530
647
  // A failed or cancelled producer still owns live text and a checkpoint timer.
531
648
  // Drain the lane before the caller changes status or permits another turn.
649
+ this.store.setThinkingActiveLive(conversation.id, assistant.id, false);
650
+ checkpointPending = true;
532
651
  checkpointNow();
533
652
  await pendingTools;
653
+ // The positions are facts ABOUT THIS TURN and stop being true when it ends, so they go with it.
654
+ // A dispatch whose POST arrives after this line gets no anchor and renders where it always did -
655
+ // which is the honest outcome, because by then the call that asked for it may no longer be on screen.
656
+ this.toolCallSites.delete(conversation.id);
657
+ this.toolCallLanes.delete(conversation.id);
658
+ for (const [key, waiters] of this.toolCallWaiters) {
659
+ if (key.startsWith(`${conversation.id}:`))
660
+ for (const wake of waiters)
661
+ wake();
662
+ }
534
663
  }
535
664
  if (!controller.signal.aborted) {
536
665
  const completed = this.store.snapshot().conversations.find(c => c.id === conversation.id);
@@ -741,6 +870,9 @@ export class RunService {
741
870
  if (parent)
742
871
  checkSubagentDepth(1);
743
872
  const backgroundId = body.background === true ? newSubagentRunId() : undefined;
873
+ // WHERE THE CARD GOES. Resolved once, here, and written onto the record at creation - the run is born
874
+ // knowing where it was asked for, so no later update has to supply it and a card cannot drift.
875
+ const origin = await this.waitForToolCall(conversationId, body.toolCallId);
744
876
  const dispatch = this.subagents.dispatch({
745
877
  ...(backgroundId ? { id: backgroundId } : {}),
746
878
  conversationId: conversation.id,
@@ -749,6 +881,7 @@ export class RunService {
749
881
  cwd,
750
882
  model: conversation.model,
751
883
  dispatchedBy: "model",
884
+ ...(origin ? origin : {}),
752
885
  ...(parent ? { parentRunId: parent.id } : {}),
753
886
  depth: authoritativeDepth,
754
887
  ...(body.loopId ? { loopId: body.loopId } : {}),
@@ -764,6 +897,7 @@ export class RunService {
764
897
  status: "running",
765
898
  startedAt: new Date().toISOString(),
766
899
  resultBlockId: backgroundId,
900
+ ...(origin ? origin : {}),
767
901
  });
768
902
  this.broadcaster.broadcastState();
769
903
  void this.trackDetached(dispatch.then(async (run) => {
@@ -18,6 +18,41 @@ const MIME_TYPES = {
18
18
  ".ico": "image/x-icon",
19
19
  ".png": "image/png",
20
20
  };
21
+ /**
22
+ * The only browser API a mounted tool needs. It is served beneath the tool's own
23
+ * mount, so every request stays relative to that mount and the server can derive
24
+ * the tool identity without exposing a registry id, a workspace, or a credential.
25
+ */
26
+ function toolBrowserSdk() {
27
+ return String.raw `(() => {
28
+ const request = async (path, init) => {
29
+ const response = await fetch(path, init);
30
+ let body;
31
+ try { body = await response.json(); } catch { body = { error: "Super Pi returned an unreadable response" }; }
32
+ if (!response.ok) throw new Error(body && body.error ? body.error : "Tool request failed (HTTP " + response.status + ")");
33
+ return body;
34
+ };
35
+ const api = {
36
+ run: (params = {}) => request("./api/run", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ params }) }),
37
+ get: (runId) => request("./api/runs/" + encodeURIComponent(runId), { method: "GET" }),
38
+ cancel: (runId) => request("./api/runs/" + encodeURIComponent(runId), { method: "DELETE" }),
39
+ wait: async (runId, { intervalMs = 250, signal } = {}) => {
40
+ for (;;) {
41
+ if (signal && signal.aborted) throw new DOMException("Aborted", "AbortError");
42
+ const run = await api.get(runId);
43
+ if (run.status !== "running") return run;
44
+ await new Promise((resolve, reject) => {
45
+ const timer = setTimeout(resolve, Math.max(50, intervalMs));
46
+ if (signal) signal.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("Aborted", "AbortError")); }, { once: true });
47
+ });
48
+ }
49
+ },
50
+ runAndWait: async (params = {}, options) => { const run = await api.run(params); return api.wait(run.id, options); },
51
+ };
52
+ Object.freeze(api);
53
+ Object.defineProperty(window, "SuperPiTool", { value: api, writable: false, configurable: false });
54
+ })();`;
55
+ }
21
56
  /**
22
57
  * Checks if the remote address is a local loopback interface.
23
58
  */
@@ -138,17 +173,18 @@ export function createHttpServer(options) {
138
173
  return createServer(async (request, response) => {
139
174
  // Internal tools use a narrow per-run capability, never the global management token.
140
175
  const requestPath = request.url?.split("?")[0] ?? "";
141
- const opaqueToolApi = requestPath.startsWith("/api/tools/") && request.headers.origin === "null";
142
- // Tool registration is checked after its JSON body is parsed: an agent proves its turn capability
143
- // there, while an interactive browser uses the ordinary server access token.
144
- if (requestPath !== "/api/memory/tool" && requestPath !== "/api/tools" && !opaqueToolApi && !isAuthorized(request, accessToken)) {
176
+ // Tool REGISTRATION is checked after its JSON body is parsed: an agent proves its turn capability
177
+ // there, while an interactive browser uses the ordinary server access token. Reading and DELETING
178
+ // the registry have no such second channel, so they are NOT exempted here and fall through to the
179
+ // ordinary token check below - exempting the whole path is what let any local process list and
180
+ // delete a person's mounted tools on a server that had `SUPER_PI_ACCESS_TOKEN` set.
181
+ if (requestPath !== "/api/memory/tool" && (requestPath !== "/api/tools" || request.method !== "POST") && !requestPath.startsWith("/tools/") && !isAuthorized(request, accessToken)) {
145
182
  response.writeHead(401, { "www-authenticate": "Bearer" }).end("Authentication required");
146
183
  return;
147
184
  }
148
185
  const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
149
186
  const remote = request.socket.remoteAddress ?? "";
150
187
  const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(JSON.stringify(body)); };
151
- const toolJson = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "access-control-allow-origin": "null", "vary": "Origin" }); response.end(JSON.stringify(body)); };
152
188
  if (url.pathname === "/api/tools") {
153
189
  try {
154
190
  if (request.method === "GET") {
@@ -161,7 +197,10 @@ export function createHttpServer(options) {
161
197
  json(400, { error: "id required" });
162
198
  return;
163
199
  }
164
- json((await toolRegistry.remove(id)) ? 200 : 404, { ok: true });
200
+ const removed = await toolRegistry.remove(id);
201
+ if (removed)
202
+ broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
203
+ json(removed ? 200 : 404, { ok: removed });
165
204
  return;
166
205
  }
167
206
  if (request.method !== "POST") {
@@ -203,97 +242,128 @@ export function createHttpServer(options) {
203
242
  }
204
243
  }
205
244
  if (url.pathname.startsWith("/tools/")) {
206
- const parts = url.pathname.slice("/tools/".length).split("/");
207
- const id = decodeURIComponent(parts.shift() ?? "");
208
- const resource = parts.filter(Boolean).map(decodeURIComponent).join("/");
209
- const listed = (await toolRegistry.list()).find((x) => x.id === id && x.enabled);
210
- if (!listed) {
211
- response.writeHead(404).end("Tool not found");
212
- return;
213
- }
245
+ let id;
246
+ let resource;
214
247
  try {
215
- const resolved = await toolRegistry.resolve(id, resource);
216
- if (!resolved) {
217
- response.writeHead(404).end("Tool not found");
218
- return;
219
- }
220
- response.writeHead(200, { "content-type": MIME_TYPES[extname(resolved.file)] ?? "application/octet-stream", "cache-control": "no-store", "content-security-policy": "default-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'" });
221
- createReadStream(resolved.file).pipe(response);
222
- return;
248
+ const parts = url.pathname.slice("/tools/".length).split("/");
249
+ id = decodeURIComponent(parts.shift() ?? "");
250
+ resource = parts.filter(Boolean).map(decodeURIComponent).join("/");
223
251
  }
224
252
  catch {
225
- response.writeHead(404).end("Tool file not found");
226
- return;
227
- }
228
- }
229
- if (url.pathname.startsWith("/api/tools/") && url.pathname.endsWith("/run")) {
230
- const id = decodeURIComponent(url.pathname.slice("/api/tools/".length, -"/run".length));
231
- if (!opaqueToolApi) {
232
- toolJson(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
253
+ response.writeHead(400).end("Malformed tool resource");
233
254
  return;
234
255
  }
235
- if (request.method === "OPTIONS") {
236
- response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "POST", "access-control-allow-headers": "content-type", "vary": "Origin" }).end();
256
+ const listed = (await toolRegistry.list()).find((x) => x.id === id && x.enabled);
257
+ if (!listed) {
258
+ response.writeHead(404).end("Tool not found");
237
259
  return;
238
260
  }
239
- if (request.method !== "POST") {
240
- toolJson(405, { error: "method" });
261
+ // A mounted tool never learns its random registry id. Its API is instead addressed relative
262
+ // to its own mount (`./api/run`), and this route supplies that identity from the URL. Besides
263
+ // making a generated tool usable on its first render, this binds status and cancellation to the
264
+ // same tool that created a run: an opaque frame for tool A cannot inspect tool B's run ids.
265
+ const toolOrigin = request.headers.origin === "null";
266
+ const toolApi = (status, body) => {
267
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "access-control-allow-origin": "null", "vary": "Origin" });
268
+ response.end(JSON.stringify(body));
269
+ };
270
+ if (resource === "api/run") {
271
+ if (!toolOrigin) {
272
+ toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
273
+ return;
274
+ }
275
+ if (request.method === "OPTIONS") {
276
+ 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();
277
+ return;
278
+ }
279
+ if (request.method !== "POST") {
280
+ toolApi(405, { error: "method" });
281
+ return;
282
+ }
283
+ let raw = "";
284
+ for await (const chunk of request) {
285
+ raw += chunk;
286
+ if (raw.length > 64_000) {
287
+ toolApi(413, { error: "request too large" });
288
+ return;
289
+ }
290
+ }
291
+ try {
292
+ const body = JSON.parse(raw);
293
+ const resolved = await toolRegistry.resolve(id);
294
+ if (!resolved?.tool.script) {
295
+ toolApi(404, { error: "python script is not registered" });
296
+ return;
297
+ }
298
+ const run = toolRunner.start({ toolId: id, script: resolve(resolved.root, resolved.tool.script.path), cwd: resolved.root, definition: resolved.tool.script, params: body.params ?? {} });
299
+ toolApi(202, run);
300
+ }
301
+ catch (error) {
302
+ toolApi(400, { error: error instanceof Error ? error.message : "tool run failed" });
303
+ }
241
304
  return;
242
305
  }
243
- let raw = "";
244
- for await (const chunk of request) {
245
- raw += chunk;
246
- if (raw.length > 64_000) {
247
- json(413, { error: "request too large" });
306
+ const runMatch = /^api\/runs\/([^/]+)$/.exec(resource);
307
+ if (runMatch) {
308
+ if (!toolOrigin) {
309
+ toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
248
310
  return;
249
311
  }
250
- }
251
- try {
252
- const body = JSON.parse(raw);
253
- const resolved = await toolRegistry.resolve(id);
254
- if (!resolved?.tool.script) {
255
- toolJson(404, { error: "python script is not registered" });
312
+ if (request.method === "OPTIONS") {
313
+ response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "GET, DELETE", "access-control-max-age": "300", "vary": "Origin" }).end();
256
314
  return;
257
315
  }
258
- const run = toolRunner.start({ toolId: id, script: resolve(resolved.root, resolved.tool.script.path), cwd: resolved.root, definition: resolved.tool.script, params: body.params ?? {} });
259
- toolJson(202, run);
260
- }
261
- catch (error) {
262
- toolJson(400, { error: error instanceof Error ? error.message : "tool run failed" });
263
- }
264
- return;
265
- }
266
- if (url.pathname.startsWith("/api/tools/runs/")) {
267
- const parts = url.pathname.slice("/api/tools/runs/".length).split("/");
268
- const runId = decodeURIComponent(parts[0] ?? "");
269
- if (!opaqueToolApi) {
270
- toolJson(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
316
+ let runId;
317
+ try {
318
+ runId = decodeURIComponent(runMatch[1]);
319
+ }
320
+ catch {
321
+ toolApi(400, { error: "Malformed tool resource" });
322
+ return;
323
+ }
324
+ if (request.method === "GET") {
325
+ const run = toolRunner.get(runId, id);
326
+ if (!run) {
327
+ toolApi(404, { error: "run not found" });
328
+ return;
329
+ }
330
+ toolApi(200, run);
331
+ return;
332
+ }
333
+ if (request.method === "DELETE") {
334
+ const run = toolRunner.cancel(runId, id);
335
+ if (!run) {
336
+ toolApi(404, { error: "run not found" });
337
+ return;
338
+ }
339
+ toolApi(200, run);
340
+ return;
341
+ }
342
+ toolApi(405, { error: "method" });
271
343
  return;
272
344
  }
273
- if (request.method === "OPTIONS") {
274
- response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "GET, DELETE", "vary": "Origin" }).end();
345
+ if (resource === "super-pi-tool.js") {
346
+ response.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" }).end(toolBrowserSdk());
275
347
  return;
276
348
  }
277
- if (request.method === "GET") {
278
- const run = [...(await toolRegistry.list())].map((tool) => toolRunner.get(runId, tool.id)).find(Boolean);
279
- if (!run) {
280
- toolJson(404, { error: "run not found" });
349
+ try {
350
+ const resolved = await toolRegistry.resolve(id, resource);
351
+ if (!resolved) {
352
+ response.writeHead(404).end("Tool not found");
281
353
  return;
282
354
  }
283
- toolJson(200, run);
355
+ // Mounted tools run in an opaque sandbox origin (the iframe deliberately omits
356
+ // `allow-same-origin`). In that context CSP's `'self'` is the opaque origin and
357
+ // does not authorize a request back to this server, even for a relative URL.
358
+ const policy = "default-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'";
359
+ response.writeHead(200, { "content-type": MIME_TYPES[extname(resolved.file)] ?? "application/octet-stream", "cache-control": "no-store", "content-security-policy": policy });
360
+ createReadStream(resolved.file).pipe(response);
284
361
  return;
285
362
  }
286
- if (request.method === "DELETE") {
287
- const run = [...(await toolRegistry.list())].map((tool) => toolRunner.cancel(runId, tool.id)).find(Boolean);
288
- if (!run) {
289
- toolJson(404, { error: "run not found" });
290
- return;
291
- }
292
- toolJson(200, run);
363
+ catch {
364
+ response.writeHead(404).end("Tool file not found");
293
365
  return;
294
366
  }
295
- toolJson(405, { error: "method" });
296
- return;
297
367
  }
298
368
  /**
299
369
  * THE TERMINAL IS NOT AN API ENDPOINT, and the routing above deliberately cannot reach one.
@@ -523,7 +593,7 @@ export function createHttpServer(options) {
523
593
  json(400, { error: "invalid JSON" });
524
594
  return;
525
595
  }
526
- const { cmd, cwd, conversationId, capability } = body;
596
+ const { cmd, cwd, conversationId, capability, toolCallId } = body;
527
597
  if (!cmd || !cwd || !conversationId) {
528
598
  json(400, { error: "cmd, cwd and conversationId are required" });
529
599
  return;
@@ -546,6 +616,10 @@ export function createHttpServer(options) {
546
616
  // Set up the background task record BEFORE spawning, so the UI shows it immediately
547
617
  const { randomUUID } = await import("node:crypto");
548
618
  const sessionId = `exec_${randomUUID()}`;
619
+ // The same anchor a sub-agent card carries, for the same reason: a background command asked for
620
+ // inside a turn belongs at that call. Resolved AFTER the capability check above, because a position
621
+ // is worth reading only from a caller already inside the turn.
622
+ const origin = await runService.waitForToolCall(conversationId, toolCallId);
549
623
  await store.setBackgroundTask({
550
624
  id: sessionId,
551
625
  conversationId,
@@ -554,13 +628,22 @@ export function createHttpServer(options) {
554
628
  status: "running",
555
629
  startedAt: new Date().toISOString(),
556
630
  resultBlockId: sessionId,
631
+ ...(origin ? origin : {}),
557
632
  });
633
+ if (origin) {
634
+ const toolCalls = await store.updateToolCall(conversationId, origin.originMessageId, origin.originCallRow, { backgroundTask: { id: sessionId, status: "running" } });
635
+ broadcaster.broadcast({ type: "message.tools", conversationId, messageId: origin.originMessageId, toolCalls });
636
+ }
558
637
  broadcaster.broadcastState();
559
638
  try {
560
639
  execService.spawn(sessionId, cmd, cwd);
561
640
  json(202, { sessionId, status: "running" });
562
641
  }
563
642
  catch (error) {
643
+ if (origin) {
644
+ const toolCalls = await store.updateToolCall(conversationId, origin.originMessageId, origin.originCallRow, { backgroundTask: { id: sessionId, status: "failed", output: error instanceof Error ? error.message : "spawn failed" } });
645
+ broadcaster.broadcast({ type: "message.tools", conversationId, messageId: origin.originMessageId, toolCalls });
646
+ }
564
647
  await store.removeBackgroundTask(sessionId);
565
648
  broadcaster.broadcastState();
566
649
  json(500, { error: error instanceof Error ? error.message : "spawn failed" });