@evandu/super-pi 0.1.77 → 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-DiG980gH.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) => {
@@ -173,9 +173,12 @@ export function createHttpServer(options) {
173
173
  return createServer(async (request, response) => {
174
174
  // Internal tools use a narrow per-run capability, never the global management token.
175
175
  const requestPath = request.url?.split("?")[0] ?? "";
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.
178
- if (requestPath !== "/api/memory/tool" && requestPath !== "/api/tools" && !requestPath.startsWith("/tools/") && !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)) {
179
182
  response.writeHead(401, { "www-authenticate": "Bearer" }).end("Authentication required");
180
183
  return;
181
184
  }
@@ -194,7 +197,10 @@ export function createHttpServer(options) {
194
197
  json(400, { error: "id required" });
195
198
  return;
196
199
  }
197
- 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 });
198
204
  return;
199
205
  }
200
206
  if (request.method !== "POST") {
@@ -236,9 +242,17 @@ export function createHttpServer(options) {
236
242
  }
237
243
  }
238
244
  if (url.pathname.startsWith("/tools/")) {
239
- const parts = url.pathname.slice("/tools/".length).split("/");
240
- const id = decodeURIComponent(parts.shift() ?? "");
241
- const resource = parts.filter(Boolean).map(decodeURIComponent).join("/");
245
+ let id;
246
+ let resource;
247
+ try {
248
+ const parts = url.pathname.slice("/tools/".length).split("/");
249
+ id = decodeURIComponent(parts.shift() ?? "");
250
+ resource = parts.filter(Boolean).map(decodeURIComponent).join("/");
251
+ }
252
+ catch {
253
+ response.writeHead(400).end("Malformed tool resource");
254
+ return;
255
+ }
242
256
  const listed = (await toolRegistry.list()).find((x) => x.id === id && x.enabled);
243
257
  if (!listed) {
244
258
  response.writeHead(404).end("Tool not found");
@@ -259,7 +273,7 @@ export function createHttpServer(options) {
259
273
  return;
260
274
  }
261
275
  if (request.method === "OPTIONS") {
262
- response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "POST", "access-control-allow-headers": "content-type", "vary": "Origin" }).end();
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();
263
277
  return;
264
278
  }
265
279
  if (request.method !== "POST") {
@@ -296,10 +310,17 @@ export function createHttpServer(options) {
296
310
  return;
297
311
  }
298
312
  if (request.method === "OPTIONS") {
299
- response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "GET, DELETE", "vary": "Origin" }).end();
313
+ response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "GET, DELETE", "access-control-max-age": "300", "vary": "Origin" }).end();
314
+ return;
315
+ }
316
+ let runId;
317
+ try {
318
+ runId = decodeURIComponent(runMatch[1]);
319
+ }
320
+ catch {
321
+ toolApi(400, { error: "Malformed tool resource" });
300
322
  return;
301
323
  }
302
- const runId = decodeURIComponent(runMatch[1]);
303
324
  if (request.method === "GET") {
304
325
  const run = toolRunner.get(runId, id);
305
326
  if (!run) {
@@ -331,7 +352,11 @@ export function createHttpServer(options) {
331
352
  response.writeHead(404).end("Tool not found");
332
353
  return;
333
354
  }
334
- 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'" });
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 });
335
360
  createReadStream(resolved.file).pipe(response);
336
361
  return;
337
362
  }
@@ -568,7 +593,7 @@ export function createHttpServer(options) {
568
593
  json(400, { error: "invalid JSON" });
569
594
  return;
570
595
  }
571
- const { cmd, cwd, conversationId, capability } = body;
596
+ const { cmd, cwd, conversationId, capability, toolCallId } = body;
572
597
  if (!cmd || !cwd || !conversationId) {
573
598
  json(400, { error: "cmd, cwd and conversationId are required" });
574
599
  return;
@@ -591,6 +616,10 @@ export function createHttpServer(options) {
591
616
  // Set up the background task record BEFORE spawning, so the UI shows it immediately
592
617
  const { randomUUID } = await import("node:crypto");
593
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);
594
623
  await store.setBackgroundTask({
595
624
  id: sessionId,
596
625
  conversationId,
@@ -599,13 +628,22 @@ export function createHttpServer(options) {
599
628
  status: "running",
600
629
  startedAt: new Date().toISOString(),
601
630
  resultBlockId: sessionId,
631
+ ...(origin ? origin : {}),
602
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
+ }
603
637
  broadcaster.broadcastState();
604
638
  try {
605
639
  execService.spawn(sessionId, cmd, cwd);
606
640
  json(202, { sessionId, status: "running" });
607
641
  }
608
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
+ }
609
647
  await store.removeBackgroundTask(sessionId);
610
648
  broadcaster.broadcastState();
611
649
  json(500, { error: error instanceof Error ? error.message : "spawn failed" });
@@ -100,8 +100,11 @@ export interface AnswerInput {
100
100
  * replacement text, and one that shows the call arriving.
101
101
  */
102
102
  onToolPreparing?(call: ToolCallPreparing): void;
103
- /** Called while the primary model is emitting reasoning, so the UI can show live progress. */
103
+ /** Called while the primary model is emitting reasoning, so the UI can show live progress. A call means
104
+ * the model is STILL THINKING - there is no separate flag, because Pi does not send one. */
104
105
  onThinking?(delta: string): void;
106
+ /** Called when the reasoning phase ends - the only signal that it has. */
107
+ onThinkingEnd?(): void;
105
108
  }
106
109
  /**
107
110
  * What Pi answered when a `steer` was written to it, from the `disposition` of that command's response
@@ -492,7 +495,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
492
495
  * own address, so it is the same string on every turn and is read once from the constructor's options.
493
496
  */
494
497
  private taskExtensionEnv;
495
- answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, images, memoryReview, memorySourceConversationId }: AnswerInput): AsyncIterable<string>;
498
+ answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, onThinkingEnd, images, memoryReview, memorySourceConversationId }: AnswerInput): AsyncIterable<string>;
496
499
  /**
497
500
  * Reads back the id of the user message this turn just created, and hands it to the caller.
498
501
  *
@@ -398,7 +398,7 @@ export class PiRpcRuntime {
398
398
  [TASK_CAPABILITY_ENV]: capability,
399
399
  };
400
400
  }
401
- async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, images, memoryReview, memorySourceConversationId }) {
401
+ async *answer({ conversation, prompt, signal, cwd, host, onPiQueue, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, onThinkingEnd, images, memoryReview, memorySourceConversationId }) {
402
402
  const latencyStartedAt = performance.now();
403
403
  traceLatency(conversation.id, "answer.begin", latencyStartedAt);
404
404
  await mkdir(this.sessionDir, { recursive: true });
@@ -723,8 +723,14 @@ export class PiRpcRuntime {
723
723
  const detail = describeToolCallInProgress(name, text);
724
724
  onToolPreparing?.({ contentIndex: event.contentIndex, name, ...(text ? { live: text } : {}), ...(detail ? { detail } : {}) });
725
725
  }
726
+ // Pi's reasoning lifecycle is three events, not one flag: `thinking_delta` means the model is
727
+ // STILL THINKING RIGHT NOW, and `thinking_end` is the only thing that says it stopped. There is
728
+ // deliberately no `active` field read here - every `thinking_delta` in Pi's wire shape carries
729
+ // `{ type, contentIndex, delta, partial }` and nothing else, so a delta is itself the evidence.
726
730
  else if (event?.type === "thinking_delta")
727
731
  onThinking?.(typeof event.delta === "string" ? event.delta : "");
732
+ else if (event?.type === "thinking_end")
733
+ onThinkingEnd?.();
728
734
  else {
729
735
  noteUnhandled(`message_update:${JSON.stringify(event?.type ?? "(missing)")}`);
730
736
  }
@@ -241,7 +241,8 @@ export declare class Store {
241
241
  * to go back to. Persisting here rather than only in the browser is the point of its living on the
242
242
  * message: a reasoning stream that vanishes on reload was never a record of anything.
243
243
  */
244
- appendThinkingDeltaLive(conversationId: string, messageId: string, delta: string, at?: number): void;
244
+ appendThinkingDeltaLive(conversationId: string, messageId: string, delta: string, at?: number, newBlock?: boolean): void;
245
+ setThinkingActiveLive(conversationId: string, messageId: string, active: boolean): void;
245
246
  appendThinkingDelta(conversationId: string, messageId: string, delta: string): Promise<void>;
246
247
  /** Persists the message exactly as it stands now, including any live text, thinking and tool updates. */
247
248
  checkpointMessage(conversationId: string, messageId: string): Promise<void>;
@@ -304,6 +305,7 @@ export declare class Store {
304
305
  live?: string;
305
306
  preparing?: boolean;
306
307
  question?: ToolCallQuestion;
308
+ backgroundTask?: ToolCallBlock["backgroundTask"];
307
309
  }): Promise<ToolCallBlock[]>;
308
310
  setStatus(conversationId: string, status: Conversation["status"]): Promise<void>;
309
311
  /** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
@@ -907,7 +907,7 @@ export class Store {
907
907
  * to go back to. Persisting here rather than only in the browser is the point of its living on the
908
908
  * message: a reasoning stream that vanishes on reload was never a record of anything.
909
909
  */
910
- appendThinkingDeltaLive(conversationId, messageId, delta, at) {
910
+ appendThinkingDeltaLive(conversationId, messageId, delta, at, newBlock = false) {
911
911
  const conversation = this.requireConversation(conversationId);
912
912
  const message = conversation.messages.find((item) => item.id === messageId);
913
913
  if (!message)
@@ -915,12 +915,17 @@ export class Store {
915
915
  message.thinking = `${message.thinking ?? ""}${delta}`;
916
916
  const blocks = (message.thinkingBlocks ??= []);
917
917
  const last = blocks.at(-1);
918
- if (last && last.at === (at ?? last.at))
918
+ if (!newBlock && last && last.at === (at ?? last.at))
919
919
  last.text += delta;
920
920
  else
921
921
  blocks.push({ at: at ?? message.content.length, text: delta });
922
922
  conversation.updatedAt = now();
923
923
  }
924
+ setThinkingActiveLive(conversationId, messageId, active) {
925
+ const message = this.requireConversation(conversationId).messages.find((item) => item.id === messageId);
926
+ if (message)
927
+ message.thinkingActive = active;
928
+ }
924
929
  async appendThinkingDelta(conversationId, messageId, delta) {
925
930
  this.appendThinkingDeltaLive(conversationId, messageId, delta);
926
931
  await this.checkpointMessage(conversationId, messageId);
@@ -1050,6 +1055,8 @@ export class Store {
1050
1055
  // whatever the latest update happened to carry loses every key the update left out, which on the very
1051
1056
  // first update is all of them. Merging can only ever add, so a sparse update can no longer erase what
1052
1057
  // an earlier one already reported - and the cap is re-applied here rather than trusted from outside.
1058
+ if (patch.backgroundTask !== undefined)
1059
+ call.backgroundTask = structuredClone(patch.backgroundTask);
1053
1060
  if (patch.label !== undefined)
1054
1061
  call.label = patch.label;
1055
1062
  if (patch.detail !== undefined)
@@ -41,6 +41,14 @@ export type SubagentDispatch = {
41
41
  * passes "model" explicitly.
42
42
  */
43
43
  dispatchedBy?: "human" | "model";
44
+ /**
45
+ * Where in the dispatching message this run was asked for, resolved by the caller from the tool call's
46
+ * id. Copied straight onto the record, because a card's position is a fact about its BIRTH - it is
47
+ * settled here and no update has to supply it, so the card cannot drift while the child streams.
48
+ * Omitted means "no call asked for it" (a person pressing the button), which is not row zero and never is.
49
+ */
50
+ originMessageId?: string;
51
+ originCallRow?: number;
44
52
  parentRunId?: string;
45
53
  depth?: number;
46
54
  loopId?: string;
@@ -124,7 +124,7 @@ export class SubagentRunRecorder {
124
124
  const { conversationId, agentId, task, cwd, model, denyTools, dispatchedBy = "human" } = input;
125
125
  // Generated HERE, not by `run()`, because the record has to exist first - see this file's header.
126
126
  const id = input.id ?? newSubagentRunId();
127
- const started = { id, conversationId, agentId, task, dispatchedBy, parentRunId: input.parentRunId, depth: input.depth ?? 0, loopId: input.loopId, role: input.role, status: "running", startedAt: new Date().toISOString(), sessionId: id };
127
+ const started = { id, conversationId, agentId, task, dispatchedBy, parentRunId: input.parentRunId, depth: input.depth ?? 0, loopId: input.loopId, role: input.role, originMessageId: input.originMessageId, originCallRow: input.originCallRow, status: "running", startedAt: new Date().toISOString(), sessionId: id };
128
128
  await this.hooks.store.beginSubagentRun(started);
129
129
  // The recorder's own copy, held separately from the store's. Mutating the object `beginSubagentRun`
130
130
  // was handed would reach into the store's state behind its back, and every snapshot it hands out
@@ -19,7 +19,7 @@ export class ToolRunner {
19
19
  child.stdout.on("data", (data) => { active.stdout = clipped(`${active.stdout}${data.toString("utf8")}`); });
20
20
  child.stderr.on("data", log);
21
21
  child.once("error", (error) => this.finish(id, "failed", error.message));
22
- child.once("exit", (code) => {
22
+ child.once("close", (code) => {
23
23
  if (active.run.status !== "running")
24
24
  return;
25
25
  if (code !== 0) {
@@ -34,7 +34,6 @@ export class ToolRunner {
34
34
  this.finish(id, "succeeded");
35
35
  }
36
36
  catch (error) {
37
- active.run.logs = clipped(`${active.run.logs}${active.stdout}`);
38
37
  this.finish(id, "failed", `脚本没有输出一个 JSON 对象:${error instanceof Error ? error.message : "invalid output"}`);
39
38
  }
40
39
  });
@@ -48,7 +47,8 @@ export class ToolRunner {
48
47
  this.finish(id, "cancelled");
49
48
  } return copy(active.run); }
50
49
  finish(id, status, error) { const active = this.runs.get(id); if (!active || active.run.status !== "running")
51
- return; clearTimeout(active.timer); active.run.status = status; active.run.finishedAt = new Date().toISOString(); if (error)
50
+ return; clearTimeout(active.timer); if (status !== "succeeded" && active.stdout)
51
+ active.run.logs = clipped(`${active.run.logs}${active.stdout}`); active.run.status = status; active.run.finishedAt = new Date().toISOString(); if (error)
52
52
  active.run.error = error; if (!active.child.killed) {
53
53
  try {
54
54
  active.child.kill();