@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.
@@ -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();
@@ -98,6 +98,7 @@ export type Message = {
98
98
  at: number;
99
99
  text: string;
100
100
  }[];
101
+ thinkingActive?: boolean;
101
102
  images?: MessageImage[];
102
103
  attachments?: MessageAttachments;
103
104
  agentIdentity?: {
@@ -203,6 +204,16 @@ export type ToolCallBlock = {
203
204
  */
204
205
  at?: number;
205
206
  thinkingAt?: number;
207
+ /** Index into the message's thinkingBlocks, captured when this call was first announced.
208
+ * null means outside reasoning; absence is an older record with offsets only. */
209
+ thinkingBlock?: number | null;
210
+ /** A background command keeps its dispatch position and settled output after the active task is removed. */
211
+ backgroundTask?: {
212
+ id: string;
213
+ status: "running" | "completed" | "failed";
214
+ output?: string;
215
+ exitCode?: number;
216
+ };
206
217
  question?: ToolCallQuestion;
207
218
  };
208
219
  /**
@@ -565,6 +576,22 @@ export type SubagentRun = {
565
576
  * Absent means `human`, which is what every record written before this field existed was.
566
577
  */
567
578
  dispatchedBy?: "human" | "model";
579
+ /**
580
+ * WHERE THIS RUN WAS ASKED FOR, as a position in the dispatching assistant message: the message id, and
581
+ * the index of the tool call inside its `toolCalls`.
582
+ *
583
+ * This exists because a card anchored to nothing is anchored to a CLOCK. `startedAt` is when the child
584
+ * was spawned, and the message that dispatched it goes on streaming for seconds afterwards, so sorting
585
+ * cards against messages by time dropped every card below every message still being written - which is
586
+ * why sub-agent cards appeared at the bottom of the transcript instead of at the call that asked for
587
+ * them. Ordering by call position fixes that in the only direction a reader means: later call, later
588
+ * card, regardless of which child finishes first.
589
+ *
590
+ * ABSENT IS NORMAL AND MEANS "no anchor", never "row zero": a person pressing 派子智能体 has no tool
591
+ * call, and neither does a record written before this field existed. Both render as they always did.
592
+ */
593
+ originMessageId?: string;
594
+ originCallRow?: number;
568
595
  /** Authoritative dispatch ancestry, assigned by the server. */
569
596
  parentRunId?: string;
570
597
  depth: number;
@@ -628,6 +655,11 @@ export type BackgroundTaskSummary = {
628
655
  status: "running" | "completed" | "failed";
629
656
  startedAt: string;
630
657
  resultBlockId: string;
658
+ /** The same call-position anchor a `SubagentRun` carries, and for the same reason: a background command
659
+ * asked for inside a turn belongs at that call, not wherever its output happened to land. Absent when
660
+ * the task was not started by a tool call. */
661
+ originMessageId?: string;
662
+ originCallRow?: number;
631
663
  };
632
664
  /**
633
665
  * Orchestrated plans, optional for the same reason `todos` is: a `state.json` written before this
@@ -1075,7 +1107,16 @@ export type ServerVersionInfo = {
1075
1107
  latestVersion?: string;
1076
1108
  updateAvailable?: boolean;
1077
1109
  };
1110
+ export type MountedToolSummary = {
1111
+ id: string;
1112
+ name: string;
1113
+ description?: string;
1114
+ enabled: boolean;
1115
+ };
1078
1116
  export type ServerEvent = {
1117
+ type: "tools.updated";
1118
+ tools: MountedToolSummary[];
1119
+ } | {
1079
1120
  type: "operation.result";
1080
1121
  requestId: string;
1081
1122
  ok: boolean;
@@ -1099,6 +1140,7 @@ export type ServerEvent = {
1099
1140
  delta: string;
1100
1141
  active: boolean;
1101
1142
  at?: number;
1143
+ blockIndex?: number;
1102
1144
  }
1103
1145
  /**
1104
1146
  * THE ANSWER TO `conversation.fork`, as ONE event with an `ok` discriminant rather than two events or a
@@ -44,13 +44,20 @@ export class WsBroadcaster {
44
44
  enqueueStreaming(socket, event) {
45
45
  let pending = this.pendingStreams.get(socket);
46
46
  if (!pending) {
47
- const events = new Map();
47
+ const events = [];
48
48
  pending = { events, timer: setTimeout(() => this.flushStreaming(socket), 16) };
49
49
  this.pendingStreams.set(socket, pending);
50
50
  }
51
- const key = `${event.type}\u0000${event.conversationId}\u0000${event.messageId}`;
52
- const previous = pending.events.get(key);
53
- pending.events.set(key, previous ? { ...previous, delta: previous.delta + event.delta } : event);
51
+ // Only adjacent fragments from the same stream can merge. A different kind,
52
+ // answer offset or lifecycle flag is an ordering boundary, even within 16ms.
53
+ const previous = pending.events.at(-1);
54
+ if (previous && previous.type === event.type && previous.conversationId === event.conversationId
55
+ && previous.messageId === event.messageId && (event.type !== "message.thinking"
56
+ || (previous.type === "message.thinking" && previous.at === event.at && previous.active === event.active && previous.blockIndex === event.blockIndex))) {
57
+ pending.events[pending.events.length - 1] = { ...event, delta: previous.delta + event.delta };
58
+ }
59
+ else
60
+ pending.events.push(event);
54
61
  }
55
62
  getSockets() {
56
63
  return this.sockets;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.76",
3
+ "version": "0.1.78",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  "dev:client": "vite --host 0.0.0.0",
29
29
  "dev:server": "cross-env SUPER_PI_PORT=3081 tsx watch src/server/index.ts",
30
30
  "dev:installed": "npm run build && cross-env SUPER_PI_PORT=3080 node bin/super-pi.js",
31
- "build": "tsc -b && vite build",
31
+ "build": "tsc -b && vite build && node scripts/fresh-build.mjs",
32
32
  "start": "node dist/server/index.js",
33
33
  "restart:server": "node scripts/restart-server.mjs",
34
34
  "smoke": "npm run build && node scripts/smoke.mjs",
@@ -85,6 +85,7 @@
85
85
  "scripts/model-filter.test.mjs",
86
86
  "scripts/active-memory.test.mjs",
87
87
  "scripts/tool-runner.test.mjs",
88
+ "scripts/tool-runtime-api.test.mjs",
88
89
  "scripts/extension-load.test.mjs"
89
90
  ],
90
91
  "check:browser": [
@@ -104,6 +105,7 @@
104
105
  "scripts/md-render-browser.test.mjs",
105
106
  "scripts/right-sidebar-browser.test.mjs",
106
107
  "scripts/sidebar-tools-browser.test.mjs",
108
+ "scripts/tool-runtime-browser.test.mjs",
107
109
  "scripts/session-context-menu-browser.test.mjs",
108
110
  "scripts/tool-call-blocks-browser.test.mjs",
109
111
  "scripts/files-tab-browser.test.mjs",