@evandu/super-pi 0.1.41 → 0.1.42

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-Dihs8Ezc.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-DTd4g0h5.css">
11
+ <script type="module" crossorigin src="/assets/index-DlKKZuzf.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-C6BogHc-.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -314,6 +314,11 @@ export class RunService {
314
314
  // an order that never happened. The runtime calls `onToolCall` synchronously between yields, so a
315
315
  // counter advanced once per yielded delta is the real position of the call in the answer.
316
316
  let spoken = 0;
317
+ // AND HOW MUCH REASONING HAD BEEN STREAMED, for the same reason and from the same place. The store's
318
+ // `message.thinking` is only updated at the end of the turn (in `finally`), so it cannot be read here.
319
+ // The running total is what lets a call made during reasoning anchor inside the thinking block rather than
320
+ // after it, which is the order the events had.
321
+ let thought = 0;
317
322
  /**
318
323
  * ONE COUNTER FOR EVERY ROW, AND THE ONLY WAY A ROW NUMBER IS EVER MADE.
319
324
  *
@@ -364,6 +369,7 @@ export class RunService {
364
369
  ...(call.detail ? { detail: call.detail } : { detail: "正在生成参数…" }),
365
370
  preparing: true,
366
371
  at,
372
+ thinkingAt: thought,
367
373
  }));
368
374
  preparingRows.set(call.contentIndex, { row, name: call.name });
369
375
  };
@@ -395,7 +401,7 @@ export class RunService {
395
401
  const row = rows++;
396
402
  if (call.id)
397
403
  rowByToolCallId.set(call.id, row);
398
- enqueueTools("a tool call", () => this.store.appendToolCall(conversation.id, assistant.id, { label: call.name, detail: call.detail, args: call.args, at, ...(call.question ? { question: call.question } : {}) }));
404
+ enqueueTools("a tool call", () => this.store.appendToolCall(conversation.id, assistant.id, { label: call.name, detail: call.detail, args: call.args, at, thinkingAt: thought, ...(call.question ? { question: call.question } : {}) }));
399
405
  };
400
406
  const onToolUpdate = (call) => {
401
407
  const index = call.id ? rowByToolCallId.get(call.id) : undefined;
@@ -425,6 +431,7 @@ export class RunService {
425
431
  // reached nobody, live or afterwards. Both halves now happen: the browser draws the delta as it
426
432
  // arrives, and the message keeps it so the finished turn has something to go back to.
427
433
  onThinking: (delta) => {
434
+ thought += delta.length;
428
435
  this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true });
429
436
  void this.store.appendThinkingDelta(conversation.id, assistant.id, delta);
430
437
  },
@@ -630,36 +637,38 @@ export class RunService {
630
637
  const result = run.status === "completed" ? run.output ?? "" : `Background task failed: ${run.error ?? run.status}`;
631
638
  const tag = run.status === "completed" ? "task_result" : "task_error";
632
639
  const message = `<task id="${run.id}" state="${run.status === "completed" ? "completed" : "error"}">\n<summary>Background task ${run.status === "completed" ? "completed" : "failed"}: ${body.description?.trim() || subagentType}</summary>\n<${tag}>\n${result}\n</${tag}>\n</task>`;
633
- // Background results are durable follow-up material. Keep them visible in the queue until the
634
- // user sends the next prompt; injecting a result into an idle conversation starts an unexpected
635
- // model turn and makes the result disappear before the Queue Dock can render it.
636
- try {
637
- if (this.activeRuns.has(conversation.id))
638
- await this.enqueueAndRun(conversation.id, message);
639
- else
640
- await this.store.enqueueMessage(conversation.id, message);
641
- }
642
- catch (error) {
640
+ // A BACKGROUND RESULT IS A PROMPT, AND DELIVERING IT IS NOT TRIGGERING IT.
641
+ //
642
+ // This used to park it in the queue until the reader sent the next prompt, which is the hole the
643
+ // queue exists to avoid: nothing owned it. `takeNextQueuedMessage` is only ever called from
644
+ // inside a turn, so a result arriving at an IDLE conversation sat in the dock forever - and when
645
+ // the reader tried to steer it, the runtime refused with 「会话 X 现在没有正在运行的 Pi 进程」,
646
+ // because of course there was no run: the message had been waiting for one nobody would start.
647
+ //
648
+ // `enqueueAndRun` is the owner this needed. It claims the run slot synchronously, so it starts a
649
+ // turn when the conversation is idle and enqueues when one is running - and the turn it starts
650
+ // drains whatever follows. One call, and no queued message is ever left without a scheduled reader.
651
+ // HANDED OFF, NOT AWAITED. `enqueueAndRun` resolves when the whole turn it started has FINISHED,
652
+ // and this handler owns the `backgroundTasks` row - so awaiting it here kept that row on screen
653
+ // for the entire delivered answer, a finished task wearing its own 「running」 state. The row is
654
+ // removed as soon as the result has a home, which is what its lifecycle means.
655
+ const started = this.enqueueAndRun(conversation.id, message);
656
+ await this.store.removeBackgroundTask(run.id);
657
+ this.broadcaster.broadcastState();
658
+ await started.catch(async (error) => {
659
+ // The turn could not start, so the result has nowhere to be read from. Put it back in the queue,
660
+ // where it is at least visible and cancellable, rather than dropping it on the floor.
643
661
  await this.store.enqueueMessage(conversation.id, message);
644
- console.warn(`Super Pi: background result could not start a turn: ${error instanceof Error ? error.message : "unknown error"}`);
645
- }
646
- finally {
647
- await this.store.removeBackgroundTask(run.id);
648
662
  this.broadcaster.broadcastState();
649
- }
663
+ console.warn(`Super Pi: background result could not start a turn: ${error instanceof Error ? error.message : "unknown error"}`);
664
+ });
650
665
  }).catch(async (error) => {
651
666
  const message = `<task id="${backgroundId}" state="error">\n<summary>Background task failed: ${body.description?.trim() || subagentType}</summary>\n<task_error>Background task failed before completion: ${error instanceof Error ? error.message : "unknown error"}</task_error>\n</task>`;
652
- try {
653
- if (this.activeRuns.has(conversationId))
654
- await this.enqueueAndRun(conversationId, message);
655
- else
656
- await this.store.enqueueMessage(conversationId, message);
657
- }
658
- catch {
659
- await this.store.enqueueMessage(conversationId, message);
660
- }
667
+ // The same rule as the task result above, and the same shape: hand it off, then drop the row.
668
+ const started = this.enqueueAndRun(conversationId, message);
661
669
  await this.store.removeBackgroundTask(backgroundId);
662
670
  this.broadcaster.broadcastState();
671
+ await started.catch(async () => { await this.store.enqueueMessage(conversationId, message); this.broadcaster.broadcastState(); });
663
672
  console.warn(`Super Pi: background sub-agent dispatch failed: ${error instanceof Error ? error.message : "unknown error"}`);
664
673
  }));
665
674
  return {
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
4
3
  import { existsSync } from "node:fs";
4
+ import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
5
5
  import { resolve, join } from "node:path";
6
6
  import { agentSpawnArgs, readAgents } from "./agents.js";
7
7
  import { TASK_CAPABILITY_ENV, mintCapability, releaseCapability } from "./capability.js";
@@ -779,7 +779,6 @@ export class PiRpcRuntime {
779
779
  } : {}) } } : {}),
780
780
  });
781
781
  const channel = new RpcChannel(child);
782
- this.runs.set(conversation.id, { child, channel });
783
782
  // Same guard as subagents.ts: a destroyed stdin throws synchronously on write, and an abort
784
783
  // listener that throws inside a listener would surface as an unhandled error instead of a cancel.
785
784
  const abort = () => {
@@ -994,6 +993,7 @@ export class PiRpcRuntime {
994
993
  }
995
994
  if (record.type === "message_update") {
996
995
  const event = record.assistantMessageEvent;
996
+ console.log(`[runtime] message_update event.type=${JSON.stringify(event?.type)}`);
997
997
  // text_delta is what drives the visible answer, and toolcall_start/delta/end are what make a
998
998
  // call VISIBLE WHILE IT IS BEING WRITTEN.
999
999
  //
@@ -1027,8 +1027,10 @@ export class PiRpcRuntime {
1027
1027
  }
1028
1028
  else if (event?.type === "thinking_delta")
1029
1029
  onThinking?.(typeof event.delta === "string" ? event.delta : "");
1030
- else
1030
+ else {
1031
1031
  noteUnhandled(`message_update:${JSON.stringify(event?.type ?? "(missing)")}`);
1032
+ console.log(`[runtime] message_update not matched: event.type=${JSON.stringify(event?.type)}`);
1033
+ }
1032
1034
  continue;
1033
1035
  }
1034
1036
  if (record.type === "extension_ui_request") {
@@ -913,7 +913,22 @@ export class Store {
913
913
  // halves: the call arrived, and the message already holds every character streamed before it. Without
914
914
  // it the browser can only render the blocks as one list beside the text, so a turn that read a file,
915
915
  // said something, then ran a test would show all three in the wrong order.
916
- calls.push(structuredClone(call.at === undefined ? { ...call, at: message.content.length } : call));
916
+ //
917
+ // AND WHERE IN THE REASONING, for the same reason and from the same place. `thinking` is the other
918
+ // stream a turn has, and a call made while the model was still thinking has to be placeable inside it -
919
+ // otherwise the transcript can only say "it reasoned, and then it called a tool", which is the one order
920
+ // a `hmm → read → hmm → grep → answer` turn never had. Stamped as the thinking length BEFORE this call,
921
+ // so a call that started before any reasoning anchors at 0 and renders first.
922
+ //
923
+ // The thinking read here is the message's own field, written synchronously as each delta arrives - not
924
+ // a count of how much a queued disk write had managed to flush. That distinction is the same one `at`
925
+ // was stamped for.
926
+ const placed = {
927
+ ...call,
928
+ ...(call.at === undefined ? { at: message.content.length } : {}),
929
+ ...(call.thinkingAt === undefined ? { thinkingAt: (message.thinking ?? "").length } : {}),
930
+ };
931
+ calls.push(structuredClone(placed));
917
932
  conversation.updatedAt = now();
918
933
  await this.persist();
919
934
  return structuredClone(calls);
@@ -167,7 +167,23 @@ export type ToolCallBlock = {
167
167
  * the only thing on screen during a long parameter stream - an 'edit' block carries a whole file's
168
168
  * replacement text, and without this the screen is blank until the last token arrives. */
169
169
  preparing?: boolean;
170
+ /**
171
+ * WHERE IN THE ANSWER the call happened, as a character offset into `content` - and WHERE IN THE
172
+ * REASONING, as an offset into `thinking`.
173
+ *
174
+ * A TURN HAS TWO STREAMS, and a call belongs to a position in each. `at` places it among the words the
175
+ * agent spoke; `thinkingAt` places it inside the reasoning it did before speaking, which is what lets a
176
+ * call that happened mid-thought render INSIDE the thinking block rather than beside it. Without the
177
+ * second offset a tool call can only be stacked after the reasoning, which reads as "the model thought,
178
+ * and then, separately, called a tool" - the order the events did not have.
179
+ *
180
+ * OPTIONAL, and absent means "this call's place in the reasoning was never recorded" - a message written
181
+ * before the field existed. A reader that guesses a position for such a call would be re-ordering a turn
182
+ * on no evidence, so it must fall back to `at` alone, which is exactly what those blocks were written to
183
+ * be read by.
184
+ */
170
185
  at?: number;
186
+ thinkingAt?: number;
171
187
  question?: ToolCallQuestion;
172
188
  };
173
189
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.41",
3
+ "version": "0.1.42",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",