@evandu/super-pi 0.1.88 → 0.1.89

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.
@@ -24,5 +24,5 @@
24
24
  "src/client/types.ts": "d84b66d06691e450fbf53c76881344d718a4c34ccfb972d6d98d0576a4279f4f",
25
25
  "src/client/vite-env.d.ts": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5"
26
26
  },
27
- "recordedAt": "2026-10-19T06:13:57.734Z"
27
+ "recordedAt": "2026-10-19T08:04:40.664Z"
28
28
  }
@@ -7,6 +7,66 @@ import { validateWorkflowPlan, workflowSummary } from "../workflow.js";
7
7
  import { execFile } from "node:child_process";
8
8
  import { promisify } from "node:util";
9
9
  const execFileAsync = promisify(execFile);
10
+ /**
11
+ * Translates a thrown error from the turn loop into a sentence a person reads.
12
+ *
13
+ * The turn loop throws for very different reasons: a model-provider rate limit, a 5xx that Pi's RPC
14
+ * layer chose to surface as a child exit, an OOM that took the whole process down, a path that
15
+ * resolved to a directory the workspace contract forbids, an `imageInput: false` mismatch. The
16
+ * throw message carries the technical fact; what the reader needs is the action it implies - retry,
17
+ * change model, check the key, change workspace - and that is the level this function writes at.
18
+ *
19
+ * The reason the shape is match-on-substring rather than match-on-error-class is that the upstream
20
+ * error is whatever Pi wrote to its own log: a string, not an exception class, and the host has
21
+ * nothing to pattern-match against other than the message itself. The literals below are
22
+ * deliberately permissive on the LLM side ("quota", "rate limit", "usage limit", "too many requests",
23
+ * "5-hour", "5h") because the underlying model APIs name this limit in several ways, and the
24
+ * reader cares about the bucket, not the exact wording.
25
+ */
26
+ function formatTurnFailure(error) {
27
+ if (controllerAborted(error))
28
+ return "本轮任务已停止。";
29
+ const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
30
+ const lower = raw.toLowerCase();
31
+ // PROVIDER QUOTA / RATE LIMIT - the only reason the user is in this branch is because Pi was
32
+ // answering them a moment ago and now the model will not let it. The action is "wait or change
33
+ // model"; the wording has to make that obvious from the banner alone, without a tooltip.
34
+ if (/quota|rate limit|usage limit|too many requests|5-hour|5h|resource_exhausted|resourc exhaust|hit your limit|usage cap|usage restrict/.test(lower)) {
35
+ return "模型额度已用完(5 小时限额或类似上限),Pi 已停止回答。可以等待重置后重试,或在会话里换一个模型再发。";
36
+ }
37
+ // RPC CHANNEL DEAD - Pi died without producing a `settled` event. Anything in the turn is now
38
+ // unanswerable, but the deltas the user already saw are still on screen, so the message has to
39
+ // say "we noticed and stopped" rather than "your input was lost".
40
+ if (lower.includes("pi rpc channel closed") || lower.includes("agent rpc") || lower.includes("exited before settling")) {
41
+ return "Pi 进程异常退出,未完成本轮回答。已停止运行,可以直接重发任务继续。";
42
+ }
43
+ // NO PI - the launch path failed (binary missing, executable bit, Windows shim). The reader's
44
+ // best move is a `which pi` / reinstall, and the banner should point there.
45
+ if (lower.includes("could not find") && lower.includes("pi") || lower.includes("pi command") || lower.startsWith("pi ")) {
46
+ return `无法启动 Pi:${raw || "未知原因"}。请确认 Pi 已正确安装并可在命令行运行。`;
47
+ }
48
+ // WORKSPACE PATH - turn refused on directory the contract forbids. Refusal happens before
49
+ // anything is on screen, so the banner is also the only place the reason lives.
50
+ if (lower.includes("workspace directory") || lower.includes("does not exist or is not a directory")) {
51
+ return `工作区目录无效:${raw}`;
52
+ }
53
+ // IMAGE MISMATCH - explicit by the caller, no need to translate.
54
+ if (lower.includes("不支持图片输入")) {
55
+ return raw;
56
+ }
57
+ // GENERIC - the literal message is more useful than a paraphrase, but it has to be the LAST
58
+ // branch and the message has to be non-empty (a thrown `new Error()` is empty and renders
59
+ // nothing, which is exactly the silence this whole change exists to break).
60
+ return raw || "Pi 未能完成本轮回答,已标记为失败。可以重发任务继续。";
61
+ }
62
+ function controllerAborted(error) {
63
+ if (!error)
64
+ return false;
65
+ if (error instanceof Error && error.name === "AbortError")
66
+ return true;
67
+ const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "";
68
+ return /abort|cancel|aborted/i.test(msg);
69
+ }
10
70
  /** Expands composer references into explicit context for the model. */
11
71
  async function expandAtReferences(store, workspace, content) {
12
72
  const refs = [...content.matchAll(/@(worktree|session):([^\s]+)/gi)];
@@ -893,13 +953,61 @@ export class RunService {
893
953
  // first line, so it is outside this try for exactly that reason. A failure from in there is a turn
894
954
  // that has already consumed these words, and putting them back would send them twice.
895
955
  await this.store.markMessageWaiting(conversationId, message.id).catch(() => undefined);
956
+ // The reason for the refusal is also worth showing - a banner above the transcript is the only
957
+ // place the user can read it from, because the message itself was rolled back to the queue and
958
+ // the banner is the difference between "the message is waiting" and "I know why it is
959
+ // waiting". The banner is cleared by the next `addUserMessage` so a stale reason cannot
960
+ // survive a retry into the same conversation.
961
+ await this.store.setFailureReason(conversationId, formatTurnFailure(error));
962
+ // Status is restored here, not relied on the outer catch to handle: a preflight failure never
963
+ // opened a turn, so the right state is the one the conversation had a moment ago (idle). The
964
+ // outer catch would otherwise flip it to `failed`, which the reader would render as a turn
965
+ // that ran and broke - the opposite of what actually happened.
966
+ await this.store.setStatus(conversationId, "idle");
967
+ // Tag the error so the outer catch knows to stay out of the way: no status flip, no second
968
+ // setFailureReason (the one above is the only one the reader should see). A free-standing
969
+ // property on the Error object is enough - it is checked with `in`, not by class, and the
970
+ // throw immediately below carries it through the propagation.
971
+ error.__superPiPreflight = true;
972
+ this.broadcaster.broadcastState();
896
973
  throw error;
897
974
  }
898
975
  await this.runConversationTurns(conversation, workspace, expanded, controller, message.id, validImages);
899
976
  await this.store.setStatus(conversation.id, controller.signal.aborted ? "stopped" : "completed");
900
977
  }
901
978
  catch (error) {
979
+ // A failing turn needs to be READ AS A FAILING TURN, not just stamped "failed" in a corner no one
980
+ // watches. The status flip is necessary but not sufficient: a turn that was answering the user a
981
+ // moment ago, now reporting 「failed」 with no reason, looks identical to a turn that the user
982
+ // stopped themselves, and the reader has no way to tell which one it is. The only honest move
983
+ // is to put the reason in front of them - `Conversation.error` is the banner above the
984
+ // transcript that the failure state was never reaching.
985
+ //
986
+ // The reason is taken from the error itself, with the most common upstream cases translated to
987
+ // the wording a person reads. The literal `error.message` would say "Pi RPC channel closed
988
+ // before producing a record (code 3)" - that is true and useless. The translate here is what
989
+ // the model-provider documentation actually says and what the reader can act on (retry, change
990
+ // model, check the key), and it matches the banner the reader sees elsewhere in the app.
991
+ //
992
+ // NOT EVERY CATCH PATH IS A FAILURE the reader should see. A stop signalled through the
993
+ // controller reaches the catch too (the abort propagates as a throw), and a stop is not a
994
+ // failure - it is what the user asked for, and painting it red would be both wrong and a
995
+ // regression from the pre-change behavior of these very lines. The reason is recorded only
996
+ // when the controller was NOT aborted.
997
+ //
998
+ // Preflight refusals - bad model choice, unresolvable `@`-reference - carry the same shape but
999
+ // never opened a turn, so flipping status to `failed` would be a lie: the previous state was
1000
+ // `idle` (or whatever the conversation was before the send) and that is what the conversation
1001
+ // is now. The preflight branch above already wrote the reason and set status back to `idle`,
1002
+ // so this catch only has to stay out of the way. The marker is set on the error there and
1003
+ // checked here.
1004
+ if (error?.__superPiPreflight) {
1005
+ throw error;
1006
+ }
902
1007
  await this.store.setStatus(conversation.id, "failed");
1008
+ if (!controller.signal.aborted) {
1009
+ await this.store.setFailureReason(conversation.id, formatTurnFailure(error));
1010
+ }
903
1011
  throw error;
904
1012
  }
905
1013
  finally {
@@ -96,7 +96,20 @@ export declare class RpcChannel {
96
96
  write(command: object): void;
97
97
  /** Returns a response to its waiter after the drain loop encountered it. */
98
98
  stash(record: RpcRecord): void;
99
- /** Removes and returns the next record, or undefined after `timeoutMs` with nothing to read. */
99
+ /**
100
+ * Removes and returns the next record, or returns `undefined` after `timeoutMs` with nothing to read.
101
+ *
102
+ * THROWS once the child is gone, rather than returning undefined. The old contract was "undefined on
103
+ * EOF" and it had a specific shape of failure it could not see: Pi dying without ever emitting
104
+ * `agent_settled` (a 4xx/5xx from the model provider is the obvious one, an OOM kill another) used
105
+ * to look exactly like "Pi had nothing to say", so the host's drain loop kept polling until the
106
+ * generator returned and the turn was reported as completed - a quota-exhausted turn presented to
107
+ * the reader as 「已完成」 with no error and no message, which is the exact silence this threw exit
108
+ * exists to break. The thrown `Error` carries the exit reason and a `code: "CHANNEL_CLOSED"`
109
+ * property the runtime layer can pattern-match on when it wants to decide between "this turn
110
+ * failed before settling" and any other drain-loop failure; a host that does not match it still
111
+ * sees a real error with a real reason.
112
+ */
100
113
  next(timeoutMs?: number): Promise<RpcRecord | undefined>;
101
114
  /** Sends a command and waits for its response. For one command; use write()+waitFor() to overlap several. */
102
115
  request(command: {
@@ -74,13 +74,31 @@ export class RpcChannel {
74
74
  this.wake();
75
75
  }
76
76
  }
77
- /** Removes and returns the next record, or undefined after `timeoutMs` with nothing to read. */
77
+ /**
78
+ * Removes and returns the next record, or returns `undefined` after `timeoutMs` with nothing to read.
79
+ *
80
+ * THROWS once the child is gone, rather than returning undefined. The old contract was "undefined on
81
+ * EOF" and it had a specific shape of failure it could not see: Pi dying without ever emitting
82
+ * `agent_settled` (a 4xx/5xx from the model provider is the obvious one, an OOM kill another) used
83
+ * to look exactly like "Pi had nothing to say", so the host's drain loop kept polling until the
84
+ * generator returned and the turn was reported as completed - a quota-exhausted turn presented to
85
+ * the reader as 「已完成」 with no error and no message, which is the exact silence this threw exit
86
+ * exists to break. The thrown `Error` carries the exit reason and a `code: "CHANNEL_CLOSED"`
87
+ * property the runtime layer can pattern-match on when it wants to decide between "this turn
88
+ * failed before settling" and any other drain-loop failure; a host that does not match it still
89
+ * sees a real error with a real reason.
90
+ */
78
91
  async next(timeoutMs = 100) {
79
92
  const deadline = performance.now() + timeoutMs;
80
93
  for (;;) {
81
94
  if (this.records.length)
82
95
  return this.records.shift();
83
- if (this.exited || this.child.exitCode !== null || performance.now() >= deadline)
96
+ if (this.exited || this.child.exitCode !== null) {
97
+ const err = new Error(`Pi RPC channel closed before producing a record (${this.exitReason || "no reason recorded"}). ${this.stderr.trim()}`);
98
+ err.code = "CHANNEL_CLOSED";
99
+ throw err;
100
+ }
101
+ if (performance.now() >= deadline)
84
102
  return undefined;
85
103
  await this.changed(deadline - performance.now());
86
104
  }
@@ -365,6 +365,22 @@ export declare class Store {
365
365
  */
366
366
  clearPreparingToolCalls(conversationId: string, messageId: string, rows: readonly number[]): Promise<ToolCallBlock[]>;
367
367
  setStatus(conversationId: string, status: Conversation["status"]): Promise<void>;
368
+ /**
369
+ * Records WHY a turn failed in a way the reader can see. `Conversation.error` is rendered as a red
370
+ * banner above the transcript (`App.tsx:conversation-error`), and it is the only field that survives
371
+ * a status flip and a navigation away and back - so it is the only place a "Pi died without saying
372
+ * anything" failure becomes visible to a person.
373
+ *
374
+ * Settled through `persistMetadata` rather than `persistState` so the message and tool deltas the
375
+ * turn had already written are not re-validated against a partial read; a previous build took the
376
+ * heavier path and an early failure mid-turn left the transcript rolled back to the last good
377
+ * checkpoint, hiding exactly the deltas the reader would have wanted to see.
378
+ *
379
+ * Set to `undefined` to clear, which the next `addUserMessage` does anyway - so callers only need
380
+ * this when they want the banner without a follow-up message (the turn did fail and the user is
381
+ * going to act on it without sending another prompt first).
382
+ */
383
+ setFailureReason(conversationId: string, reason: string | undefined): Promise<void>;
368
384
  /** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
369
385
  planRevert(conversationId: string, messageId: string, withdraw?: boolean): RevertPlan;
370
386
  /**
@@ -1252,6 +1252,30 @@ export class Store {
1252
1252
  return structuredClone(calls);
1253
1253
  }
1254
1254
  async setStatus(conversationId, status) { const conversation = this.requireConversation(conversationId); conversation.status = status; conversation.updatedAt = now(); await this.persistMetadata(conversationId); }
1255
+ /**
1256
+ * Records WHY a turn failed in a way the reader can see. `Conversation.error` is rendered as a red
1257
+ * banner above the transcript (`App.tsx:conversation-error`), and it is the only field that survives
1258
+ * a status flip and a navigation away and back - so it is the only place a "Pi died without saying
1259
+ * anything" failure becomes visible to a person.
1260
+ *
1261
+ * Settled through `persistMetadata` rather than `persistState` so the message and tool deltas the
1262
+ * turn had already written are not re-validated against a partial read; a previous build took the
1263
+ * heavier path and an early failure mid-turn left the transcript rolled back to the last good
1264
+ * checkpoint, hiding exactly the deltas the reader would have wanted to see.
1265
+ *
1266
+ * Set to `undefined` to clear, which the next `addUserMessage` does anyway - so callers only need
1267
+ * this when they want the banner without a follow-up message (the turn did fail and the user is
1268
+ * going to act on it without sending another prompt first).
1269
+ */
1270
+ async setFailureReason(conversationId, reason) {
1271
+ const conversation = this.requireConversation(conversationId);
1272
+ if (reason === undefined)
1273
+ delete conversation.error;
1274
+ else
1275
+ conversation.error = reason;
1276
+ conversation.updatedAt = now();
1277
+ await this.persistMetadata(conversationId);
1278
+ }
1255
1279
  /** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
1256
1280
  planRevert(conversationId, messageId, withdraw = false) { return planRevert(this.state, conversationId, messageId, withdraw); }
1257
1281
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.88",
3
+ "version": "0.1.89",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",