@evandu/super-pi 0.1.81 → 0.1.83

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.
@@ -232,9 +232,22 @@ export function taskExtensionPath(from = import.meta.dirname) {
232
232
  * The tool's name as far as the stream has revealed it so far, or an empty string while the model is
233
233
  * still on the first token. Deliberately NOT a placeholder: a block labelled 「工具」 tells the reader
234
234
  * nothing they cannot already see, whereas one labelled 'edit' one second early is the whole point.
235
+ *
236
+ * THE EVENT IS READ BEFORE `partial`, and that order is the whole fix. This used to look only at
237
+ * `partial` - the half-built assistant message - which is where the Anthropic and pi-messages converters
238
+ * put the toolCall block. Real Pi does NOT send it: a live RPC probe against Pi itself returns
239
+ * `toolcall_start {"contentIndex":1,"id":"call_…","toolName":"read","hasPartial":false}`, the name on the
240
+ * event and no `partial` at all. So this returned "" for every call the user ever made, every placeholder
241
+ * row was filed under an empty name, and the name-based lookup that pairs a call with the row the reader
242
+ * is already looking at could never match - see `onToolCall` in application/run-service.ts, which then
243
+ * fell back to recency and put the calls on each other's rows. Reading `toolName` first is what makes the
244
+ * pairing work at all; `partial` stays as the fallback for a Pi that only fills the message.
235
245
  */
236
- function toolNameOf(partial) {
237
- const content = partial?.content;
246
+ function toolNameOf(event) {
247
+ const direct = event?.toolName;
248
+ if (typeof direct === "string" && direct)
249
+ return direct;
250
+ const content = event?.partial?.content;
238
251
  const block = Array.isArray(content) ? content.find((item) => item?.type === "toolCall") : undefined;
239
252
  return typeof block?.name === "string" ? block.name : "";
240
253
  }
@@ -705,12 +718,13 @@ export class PiRpcRuntime {
705
718
  // A fresh index means a fresh call, so anything the PREVIOUS call at this index wrote is
706
719
  // dropped here rather than carried into this one's first fragment.
707
720
  argumentText.delete(event.contentIndex);
708
- toolNames.set(event.contentIndex, toolNameOf(event.partial));
709
- onToolPreparing?.({ contentIndex: event.contentIndex, name: toolNameOf(event.partial) });
721
+ const name = toolNameOf(event);
722
+ toolNames.set(event.contentIndex, name);
723
+ onToolPreparing?.({ contentIndex: event.contentIndex, name });
710
724
  }
711
725
  else if (event?.type === "toolcall_delta" && typeof event.contentIndex === "number") {
712
726
  const delta = typeof event.delta === "string" ? event.delta : "";
713
- // The NAME IS CARRIED FORWARD because the line naming the call needs it: `toolcall_start`
727
+ // THE NAME IS CARRIED FORWARD because the line naming the call needs it: `toolcall_start`
714
728
  // is the only event that carries one, and by the second fragment the mapping has to choose
715
729
  // between a `read`'s path and a `bash`'s command.
716
730
  const name = toolNames.get(event.contentIndex) ?? "";
@@ -723,6 +737,36 @@ export class PiRpcRuntime {
723
737
  const detail = describeToolCallInProgress(name, text);
724
738
  onToolPreparing?.({ contentIndex: event.contentIndex, name, ...(text ? { live: text } : {}), ...(detail ? { detail } : {}) });
725
739
  }
740
+ // `toolcall_end` IS THE ARGUMENTS, FINISHED - and this branch is what stops a call that Pi
741
+ // announced but never ran from rendering as a call with no parameters at all.
742
+ //
743
+ // Until `tool_execution_start`, a row's parameters exist only as the JSON text streaming past
744
+ // in `live`, which the transcript deliberately does not show as parameters. So a call the turn
745
+ // cut off mid-flight - an abort, a cancelled turn, an upstream error - reached the reader as
746
+ // 「正在生成参数…」 above 「(这次调用没有可显示的参数)」, the two halves of which are the same fact: the
747
+ // parameters were known and were not shown. Pi puts the completed object on this event, so the
748
+ // row gets its real parameters from here instead.
749
+ //
750
+ // Reduced exactly as everywhere else (`describeToolCallArgs`), so this is the same bounded
751
+ // derivation and not the arguments themselves, and the raw object stops at this line.
752
+ else if (event?.type === "toolcall_end" && typeof event.contentIndex === "number") {
753
+ const toolCall = event.toolCall;
754
+ const name = typeof toolCall?.name === "string" && toolCall.name ? toolCall.name : (toolNames.get(event.contentIndex) ?? "");
755
+ // The name this event carries is the authoritative one, so a row filed under an empty name
756
+ // by a start that carried none can still be paired with its execution.
757
+ if (name)
758
+ toolNames.set(event.contentIndex, name);
759
+ const args = describeToolCallArgs(toolCall?.arguments);
760
+ const text = argumentText.get(event.contentIndex) ?? "";
761
+ // `detail` GOES OUT ONLY WHEN NOTHING HAS DERIVED ONE. The delta path reads the same target
762
+ // out of the same text a fragment earlier, so re-sending it would be a patch that changes
763
+ // nothing - and a patch that changes nothing still publishes a frame, which is one more
764
+ // `message.tools` broadcast per call for no reader-visible change. The one case it earns its
765
+ // place in is a call whose fragments never became readable, where this is the last moment
766
+ // the parameters are available to name the target from.
767
+ const detail = describeToolCall(name, toolCall?.arguments);
768
+ onToolPreparing?.({ contentIndex: event.contentIndex, name, final: true, ...(args ? { args } : {}), ...(!describeToolCallInProgress(name, text) && detail ? { detail } : {}) });
769
+ }
726
770
  // Pi's reasoning lifecycle is three events, not one flag: `thinking_delta` means the model is
727
771
  // STILL THINKING RIGHT NOW, and `thinking_end` is the only thing that says it stopped. There is
728
772
  // deliberately no `active` field read here - every `thinking_delta` in Pi's wire shape carries
@@ -307,6 +307,30 @@ export declare class Store {
307
307
  question?: ToolCallQuestion;
308
308
  backgroundTask?: ToolCallBlock["backgroundTask"];
309
309
  }): Promise<ToolCallBlock[]>;
310
+ /**
311
+ * STOPS SEVERAL ROWS FROM CLAIMING THE MODEL IS STILL WRITING THEM, in one write.
312
+ *
313
+ * A row is created by `toolcall_start` and filled in by `tool_execution_start`, so a call the turn cut
314
+ * off between the two - an abort, a cancelled turn, an upstream error - kept `preparing` for the rest of
315
+ * time. A finished turn showing 「生成参数中…」 is the same expensive lie as a settled tool still saying
316
+ * 「正在生成参数…」: it says the agent is working on something it has already stopped doing, and it is
317
+ * what a reader sees on every such row for the rest of the conversation.
318
+ *
319
+ * WHAT IS *NOT* WRITTEN HERE, and the omissions are the point. No `status`: Pi reported no outcome for
320
+ * these calls, and `status` is the field that says an outcome was observed - writing `"error"` would
321
+ * claim the tool failed, which is a different fact from "it never ran". No `result`, for the same
322
+ * reason. `preparing: false` alone says exactly what is true, which is that the arguments stopped
323
+ * arriving.
324
+ *
325
+ * `live` IS CLEARED, because during preparation that field holds the ARGUMENT TEXT and the transcript
326
+ * would otherwise print a raw JSON fragment under a call that never ran. The parameters themselves are
327
+ * not lost: `args` carries them from `toolcall_end`, and `detail` has named the target since the first
328
+ * readable fragment.
329
+ *
330
+ * Addressed by position like every other row write, and one that is not there is skipped rather than
331
+ * failing the turn - the same tolerance `settleToolCall` has for an id it cannot resolve.
332
+ */
333
+ clearPreparingToolCalls(conversationId: string, messageId: string, rows: readonly number[]): Promise<ToolCallBlock[]>;
310
334
  setStatus(conversationId: string, status: Conversation["status"]): Promise<void>;
311
335
  /** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
312
336
  planRevert(conversationId: string, messageId: string, withdraw?: boolean): RevertPlan;
@@ -1084,6 +1084,52 @@ export class Store {
1084
1084
  await this.persist();
1085
1085
  return structuredClone(calls);
1086
1086
  }
1087
+ /**
1088
+ * STOPS SEVERAL ROWS FROM CLAIMING THE MODEL IS STILL WRITING THEM, in one write.
1089
+ *
1090
+ * A row is created by `toolcall_start` and filled in by `tool_execution_start`, so a call the turn cut
1091
+ * off between the two - an abort, a cancelled turn, an upstream error - kept `preparing` for the rest of
1092
+ * time. A finished turn showing 「生成参数中…」 is the same expensive lie as a settled tool still saying
1093
+ * 「正在生成参数…」: it says the agent is working on something it has already stopped doing, and it is
1094
+ * what a reader sees on every such row for the rest of the conversation.
1095
+ *
1096
+ * WHAT IS *NOT* WRITTEN HERE, and the omissions are the point. No `status`: Pi reported no outcome for
1097
+ * these calls, and `status` is the field that says an outcome was observed - writing `"error"` would
1098
+ * claim the tool failed, which is a different fact from "it never ran". No `result`, for the same
1099
+ * reason. `preparing: false` alone says exactly what is true, which is that the arguments stopped
1100
+ * arriving.
1101
+ *
1102
+ * `live` IS CLEARED, because during preparation that field holds the ARGUMENT TEXT and the transcript
1103
+ * would otherwise print a raw JSON fragment under a call that never ran. The parameters themselves are
1104
+ * not lost: `args` carries them from `toolcall_end`, and `detail` has named the target since the first
1105
+ * readable fragment.
1106
+ *
1107
+ * Addressed by position like every other row write, and one that is not there is skipped rather than
1108
+ * failing the turn - the same tolerance `settleToolCall` has for an id it cannot resolve.
1109
+ */
1110
+ async clearPreparingToolCalls(conversationId, messageId, rows) {
1111
+ const conversation = this.requireConversation(conversationId);
1112
+ const message = conversation.messages.find((item) => item.id === messageId);
1113
+ if (!message)
1114
+ throw new Error("Assistant message not found");
1115
+ const calls = message.toolCalls;
1116
+ if (!calls)
1117
+ return [];
1118
+ let touched = false;
1119
+ for (const index of rows) {
1120
+ const call = calls[index];
1121
+ if (!call || call.preparing !== true)
1122
+ continue;
1123
+ call.preparing = false;
1124
+ call.live = "";
1125
+ touched = true;
1126
+ }
1127
+ if (touched) {
1128
+ conversation.updatedAt = now();
1129
+ await this.persist();
1130
+ }
1131
+ return structuredClone(calls);
1132
+ }
1087
1133
  async setStatus(conversationId, status) { const conversation = this.requireConversation(conversationId); conversation.status = status; conversation.updatedAt = now(); await this.persistMetadata(conversationId); }
1088
1134
  /** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
1089
1135
  planRevert(conversationId, messageId, withdraw = false) { return planRevert(this.state, conversationId, messageId, withdraw); }
@@ -20,9 +20,8 @@
20
20
  *
21
21
  * So the guards here are deliberately redundant, and each one closes a hole the others do not:
22
22
  *
23
- * 1. OPT-IN, OFF BY DEFAULT. `SUPER_PI_TERMINAL` must be set to `1`/`true`/`on` before a single shell
24
- * is spawned. The default install has no terminal at all - the tab is not even rendered. Enabling
25
- * it is a deliberate act by someone who knows what it does.
23
+ * 1. AVAILABLE BY DEFAULT. The terminal is a built-in local capability. Network scope, workspace
24
+ * resolution, and bounded sessions still protect every command.
26
25
  * 2. NETWORK SCOPE. Even enabled, a command is refused unless the WebSocket arrived from loopback or
27
26
  * an RFC 1918 / link-local / ULA address (`isLocalOrPrivateAddress`, the same predicate the file
28
27
  * endpoints use). A request that arrives from a routed or proxied address is refused by name.
@@ -48,7 +47,6 @@
48
47
  *
49
48
  * `node-pty` allocates a real pseudo-terminal (ConPTY on Windows, `forkpty` elsewhere), which is the
50
49
  * only way to get them. The cost is a native module, and it is paid only by operators who set
51
- * `SUPER_PI_TERMINAL=1` - see `loadPty` below, which is reached lazily and never at import time.
52
50
  */
53
51
  import type { ServerEvent, Workspace } from "./types.js";
54
52
  /** One shell per browser tab, and a hard ceiling so reconnects cannot accumulate processes. */
@@ -99,11 +97,11 @@ export type TerminalEvent = {
99
97
  reason?: string;
100
98
  };
101
99
  /**
102
- * Reads the opt-in flag. Anything other than an explicit truthy string leaves the terminal OFF.
100
+ * The terminal is built in; node-pty availability is checked when a session opens.
103
101
  */
104
102
  export declare function isTerminalEnabled(env?: NodeJS.ProcessEnv): boolean;
105
103
  /** The sentence a browser shows when the terminal is missing. Says WHICH of the three gates closed. */
106
- export declare function terminalDisabledReason(env?: NodeJS.ProcessEnv): string;
104
+ export declare function terminalDisabledReason(_env?: NodeJS.ProcessEnv): string;
107
105
  export declare class TerminalService {
108
106
  private readonly sessions;
109
107
  private readonly workspaceFor;
@@ -20,9 +20,8 @@
20
20
  *
21
21
  * So the guards here are deliberately redundant, and each one closes a hole the others do not:
22
22
  *
23
- * 1. OPT-IN, OFF BY DEFAULT. `SUPER_PI_TERMINAL` must be set to `1`/`true`/`on` before a single shell
24
- * is spawned. The default install has no terminal at all - the tab is not even rendered. Enabling
25
- * it is a deliberate act by someone who knows what it does.
23
+ * 1. AVAILABLE BY DEFAULT. The terminal is a built-in local capability. Network scope, workspace
24
+ * resolution, and bounded sessions still protect every command.
26
25
  * 2. NETWORK SCOPE. Even enabled, a command is refused unless the WebSocket arrived from loopback or
27
26
  * an RFC 1918 / link-local / ULA address (`isLocalOrPrivateAddress`, the same predicate the file
28
27
  * endpoints use). A request that arrives from a routed or proxied address is refused by name.
@@ -48,7 +47,6 @@
48
47
  *
49
48
  * `node-pty` allocates a real pseudo-terminal (ConPTY on Windows, `forkpty` elsewhere), which is the
50
49
  * only way to get them. The cost is a native module, and it is paid only by operators who set
51
- * `SUPER_PI_TERMINAL=1` - see `loadPty` below, which is reached lazily and never at import time.
52
50
  */
53
51
  import { createRequire } from "node:module";
54
52
  import { existsSync } from "node:fs";
@@ -60,8 +58,8 @@ let ptyLoadFailure;
60
58
  *
61
59
  * `import("node-pty")` would be evaluated by the bundler/compiler as a real dynamic import of a package
62
60
  * the project does not depend on by default, and its rejection is a load-time failure of the WHOLE
63
- * server rather than of the terminal. The operator who never sets `SUPER_PI_TERMINAL` should not be
64
- * able to tell that node-pty exists.
61
+ * server rather than of the terminal. A server can start without node-pty and report a useful error only
62
+ * when the terminal is used.
65
63
  *
66
64
  * `createRequire` sidesteps the module-resolution question entirely: this file is compiled to ESM by
67
65
  * `tsconfig.server.json`, `node-pty` is CommonJS, and `require` is the one call that loads CJS from ESM
@@ -166,18 +164,16 @@ const MAX_CHUNK_BYTES = 64 * 1024;
166
164
  /** Hard per-session backlog. Beyond this the session is considered runaway and is killed. */
167
165
  const MAX_BACKLOG_BYTES = 4 * 1024 * 1024;
168
166
  /**
169
- * Reads the opt-in flag. Anything other than an explicit truthy string leaves the terminal OFF.
167
+ * The terminal is built in; node-pty availability is checked when a session opens.
170
168
  */
171
169
  export function isTerminalEnabled(env = process.env) {
172
- const raw = env.SUPER_PI_TERMINAL?.trim().toLowerCase();
173
- return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
170
+ void env;
171
+ return true;
174
172
  }
175
173
  /** The sentence a browser shows when the terminal is missing. Says WHICH of the three gates closed. */
176
- export function terminalDisabledReason(env = process.env) {
177
- if (!isTerminalEnabled(env))
178
- return "终端未启用。设置 SUPER_PI_TERMINAL=1 后重启即可使用。";
174
+ export function terminalDisabledReason(_env = process.env) {
179
175
  if (ptyLoadFailure)
180
- return `node-pty 未能加载(${ptyLoadFailure})。请重新安装依赖,或确认 node-pty 版本不低于 ${MINIMUM_PTY_VERSION}。`;
176
+ return "node-pty unavailable: " + ptyLoadFailure;
181
177
  return "";
182
178
  }
183
179
  export class TerminalService {
@@ -262,12 +262,27 @@ export type ToolCallNotice = {
262
262
  * field with two meanings would make the caller store one kind of text by appending and the other by
263
263
  * replacing - which is a flicker and a duplication at once. Pi sends argument fragments raw; accumulating
264
264
  * them is the runtime's job, where the fragments already are.
265
+ *
266
+ * `args` arrives ONLY at `toolcall_end`, where Pi hands over the finished argument object. Nothing before
267
+ * that point can produce it: `live` is raw JSON text, and a half-written document is not one. It is what
268
+ * gives a row its parameters before `tool_execution_start` announces the call, so a call the turn cut off
269
+ * mid-flight shows what it was going to do instead of 「(这次调用没有可显示的参数)」. Bounded by the same
270
+ * `describeToolCallArgs` derivation as every other arguments field, so it adds no new way for raw arguments
271
+ * to reach `state.json`.
272
+ *
273
+ * `final` says these are the FINISHED arguments, and it exists so that one cannot create a row. Pi emits
274
+ * `toolcall_end` whenever the argument document closes, which on a live probe is before the call runs -
275
+ * but a turn can also close a document after the call has already been announced and settled, and a
276
+ * notice that arrives then describes a row that is already on screen. A caller that treated it as an
277
+ * announcement would append a second, empty block for a call the reader is already looking at.
265
278
  */
266
279
  export interface ToolCallPreparing {
267
280
  contentIndex: number;
268
281
  name: string;
269
282
  live?: string;
270
283
  detail?: string;
284
+ args?: ToolCallArg[];
285
+ final?: boolean;
271
286
  }
272
287
  /**
273
288
  * `archivedAt` is the whole archive mechanism: a state MARKER, not a move. The conversation object never
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Return npm's install arguments for the package that is actually running.
3
+ *
4
+ * Pi can keep Super Pi in its own npm prefix, separate from the user's normal
5
+ * global npm prefix. A bare `npm install -g` therefore may update a different
6
+ * copy while this process keeps serving the old one. Installed package roots
7
+ * contain `node_modules/@evandu/super-pi`; walking back to the prefix makes the
8
+ * update target unambiguous. Source checkouts retain the global fallback.
9
+ */
10
+ export declare function packageUpdateArgs(rootDir: string): {
11
+ args: string[];
12
+ prefix?: string;
13
+ };
@@ -0,0 +1,20 @@
1
+ import { sep } from "node:path";
2
+ /**
3
+ * Return npm's install arguments for the package that is actually running.
4
+ *
5
+ * Pi can keep Super Pi in its own npm prefix, separate from the user's normal
6
+ * global npm prefix. A bare `npm install -g` therefore may update a different
7
+ * copy while this process keeps serving the old one. Installed package roots
8
+ * contain `node_modules/@evandu/super-pi`; walking back to the prefix makes the
9
+ * update target unambiguous. Source checkouts retain the global fallback.
10
+ */
11
+ export function packageUpdateArgs(rootDir) {
12
+ const marker = `${sep}node_modules${sep}`;
13
+ const lower = rootDir.toLowerCase();
14
+ const index = lower.lastIndexOf(marker);
15
+ if (index >= 0) {
16
+ const prefix = rootDir.slice(0, index);
17
+ return { prefix, args: ["install", "--prefix", prefix, "@evandu/super-pi"] };
18
+ }
19
+ return { args: ["install", "-g", "@evandu/super-pi"] };
20
+ }