@evandu/super-pi 0.1.88 → 0.1.90

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-Bpy8UeU1.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-CC-Q5gNQ.css">
11
+ <script type="module" crossorigin src="/assets/index-p1BjYE_T.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-DgQRMp3j.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -99,6 +99,12 @@ export declare class RunService {
99
99
  /**
100
100
  * Called by the exec completion handler in index.ts when a background exec session ends.
101
101
  * Injects the result into the conversation and cleans up the background task record.
102
+ *
103
+ * The result is now a REAL queue item, not a one-way notice the model never gets to read: it lands
104
+ * with `delivery: "waiting"` (so the dock shows it), at the front of the waiting set (so any pending
105
+ * user sends wait behind it), and the conversation auto-starts a turn for the agent to handle it
106
+ * when nothing else is busy. The render stays as a 后台回传 block throughout - in dock and in
107
+ * transcript - so a `role: "user"` is never impersonated.
102
108
  */
103
109
  enqueueExecResult(sessionId: string, exitCode: number, stdout: string, stderr: string): void;
104
110
  /** Per-conversation command lane. All queue mutations and run claims use the same lane, so
@@ -233,6 +239,18 @@ export declare class RunService {
233
239
  steer(conversationId: string, messageId: string): Promise<void>;
234
240
  /** Starts a chosen waiting message as the next turn, only when the conversation is idle. */
235
241
  sendQueued(conversationId: string, messageId: string): Promise<void>;
242
+ /**
243
+ * The lower-level "wait for run slot, open the turn on a specific waiting message, run it" sequence.
244
+ *
245
+ * Called from two places: the user-facing `sendQueued` (which adds a stop-clearing grace window
246
+ * before this) and the auto-trigger paths in `enqueueExecResult` and `dispatchTask`'s background
247
+ * branch (which only call it when `!this.activeRuns.has(conversationId)`). The two callers differ
248
+ * only in who arrived first; the open-turn-and-run contract is the same.
249
+ *
250
+ * `composePrompt` is what makes the `role: "tool"` path real - the `<task>` envelope stays in the
251
+ * transcript, but the prose form of the same fact is what reaches Pi.
252
+ */
253
+ private processWaitingMessage;
236
254
  private steerUnsafe;
237
255
  /**
238
256
  * Withdraws a waiting message.
@@ -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)];
@@ -38,6 +98,35 @@ async function expandAtReferences(store, workspace, content) {
38
98
  }
39
99
  return blocks.length ? `${content}\n${blocks.join("\n")}` : content;
40
100
  }
101
+ /**
102
+ * The text the model is handed when a waiting message becomes its turn.
103
+ *
104
+ * `role: "user"` is the user's own words - pass through verbatim. `role: "tool"` is a 后台回传 block
105
+ * whose `message.content` is the `<task>` envelope the transcript and dock render from; handing the raw
106
+ * XML to Pi asks it to parse the format itself, which is the "未定义行为" path that 0.1.66 worked around
107
+ * by simply not triggering a turn. We DO trigger a turn now (queue semantics), so we synthesise prose
108
+ * the model can read like any other user message.
109
+ *
110
+ * The envelope and the prose are NOT redundant: the envelope is what the reader sees in the transcript
111
+ * column, what they cancel or reorder; the prose is what leaves the server for Pi.
112
+ */
113
+ function composePrompt(message) {
114
+ if (message.role !== "tool")
115
+ return message.content;
116
+ const content = message.content;
117
+ const summaryMatch = /<summary>([\s\S]*?)<\/summary>/.exec(content);
118
+ const summary = summaryMatch ? summaryMatch[1].trim() : "后台任务";
119
+ const stateMatch = /<task id="[^"]+" state="([^"]+)"/.exec(content);
120
+ const state = stateMatch ? stateMatch[1] : "completed";
121
+ const bodyMatch = /<task_(?:result|error)>\n?([\s\S]*?)\n?<\/(?:task_result|task_error)>/.exec(content);
122
+ const body = bodyMatch ? bodyMatch[1] : content;
123
+ const stateLabel = state === "completed" ? "已完成" : state === "error" ? "失败" : state;
124
+ // The marker `@bgdelivery:` is at the end so the visible prose is what the model sees (it can ignore
125
+ // a trailing `@`-token, which is what its other tools do too). The fake-pi fixture recognises the
126
+ // marker and settles immediately so test paths through this turn do not hang on the BLOCKING
127
+ // extension-UI default. A real model treats the marker as part of the user's prose.
128
+ return `[后台任务「${summary}」${stateLabel}]\n\n${body}\n\n@bgdelivery:${state}`;
129
+ }
41
130
  /**
42
131
  * Service managing execution of conversation turns, message queueing,
43
132
  * follow-ups, and sub-agent dispatching.
@@ -127,6 +216,12 @@ export class RunService {
127
216
  /**
128
217
  * Called by the exec completion handler in index.ts when a background exec session ends.
129
218
  * Injects the result into the conversation and cleans up the background task record.
219
+ *
220
+ * The result is now a REAL queue item, not a one-way notice the model never gets to read: it lands
221
+ * with `delivery: "waiting"` (so the dock shows it), at the front of the waiting set (so any pending
222
+ * user sends wait behind it), and the conversation auto-starts a turn for the agent to handle it
223
+ * when nothing else is busy. The render stays as a 后台回传 block throughout - in dock and in
224
+ * transcript - so a `role: "user"` is never impersonated.
130
225
  */
131
226
  enqueueExecResult(sessionId, exitCode, stdout, stderr) {
132
227
  const tasks = this.store.snapshot().backgroundTasks;
@@ -135,10 +230,6 @@ export class RunService {
135
230
  return;
136
231
  const tag = exitCode === 0 ? "task_result" : "task_error";
137
232
  const body = exitCode === 0 ? stdout : (stderr || `Command exited with code ${exitCode}`);
138
- // The result becomes a `role: "tool"` message in the conversation, not a user prompt for a fresh
139
- // turn. The transcript now has a 「后台回传」 column the client renders, and the model sees the
140
- // result only when something else starts a turn that includes it. Asking the model to consume a
141
- // `<task>` envelope as user prose is the "undefined behaviour" path the user reported.
142
233
  const message = `<task id="${sessionId}" state="${exitCode === 0 ? "completed" : "error"}" kind="exec">\n<summary>${task.title}</summary>\n<${tag}>\n${body}</${tag}>\n</task>`;
143
234
  void this.trackDetached((async () => {
144
235
  try {
@@ -151,8 +242,31 @@ export class RunService {
151
242
  this.broadcaster.broadcast({ type: "message.tools", conversationId: task.conversationId, messageId: task.originMessageId, toolCalls });
152
243
  }
153
244
  this.broadcaster.broadcast({ type: "exec.exit", sessionId, exitCode, stdout, stderr });
154
- await this.store.addToolMessage(task.conversationId, message);
245
+ // QUEUE ITEM, NOT A NOTICE. `delivery: "waiting"` is what makes the row show in the dock; without
246
+ // it the message is a transcript entry only and `takeNextWaitingMessage` (which `runConversationTurns`
247
+ // calls after every turn) never picks it up.
248
+ const queued = await this.store.addToolMessage(task.conversationId, message, { delivery: "waiting" });
155
249
  this.broadcaster.broadcastState();
250
+ // STEER PRIORITY. If anything else is already waiting, jump this one to the front of the queue
251
+ // so a 后台回传 cuts ahead of user sends - matching "按插队逻辑来处理".
252
+ const waiting = this.store.listWaitingMessages(task.conversationId);
253
+ if (waiting.length > 1) {
254
+ await this.store.reorderWaitingMessage(task.conversationId, queued.id, 0);
255
+ this.broadcaster.broadcastState();
256
+ }
257
+ // AUTO-TRIGGER WHEN IDLE. The conversation may already be busy (running turn, taking the next
258
+ // waiting message in its own follow-up loop); if so, the existing loop picks this one up and
259
+ // this branch is a no-op. If idle, kick off a fresh turn so the model processes the result
260
+ // without the user having to send anything - the failure case is intentionally included: the
261
+ // model seeing "[后台任务 X 失败]" is part of the same "after current AI finishes, automatically
262
+ // processes the result" contract, and the auto-triggered turn goes through the same
263
+ // `composePrompt` path the success case does (so a helper failure surfaces as `state="error"`
264
+ // prose the agent can react to).
265
+ if (!this.activeRuns.has(task.conversationId)) {
266
+ void this.processWaitingMessage(task.conversationId, queued.id).catch((error) => {
267
+ console.warn(`Super Pi: auto-trigger for background exec result failed: ${error instanceof Error ? error.message : "unknown error"}`);
268
+ });
269
+ }
156
270
  }
157
271
  catch (error) {
158
272
  console.warn(`Super Pi: exec result could not be appended as a tool message: ${error instanceof Error ? error.message : "unknown error"}`);
@@ -810,7 +924,9 @@ export class RunService {
810
924
  const followUp = await this.store.openTurnOnMessage(conversation.id, next.id, identity);
811
925
  this.broadcaster.broadcastState();
812
926
  const current = this.turnContext(conversation.id).conversation;
813
- await this.streamTurn(current, workspace, next.content, followUp.assistant, controller, followUp.user.id, next.images, identity);
927
+ // `composePrompt` synthesises the prose for a 后台回传 (the `<task>` envelope is what the transcript
928
+ // column reads from; the prose is what leaves the server for Pi). For user messages it is a no-op.
929
+ await this.streamTurn(current, workspace, composePrompt(next), followUp.assistant, controller, followUp.user.id, next.images, identity);
814
930
  }
815
931
  }
816
932
  finally {
@@ -893,13 +1009,61 @@ export class RunService {
893
1009
  // first line, so it is outside this try for exactly that reason. A failure from in there is a turn
894
1010
  // that has already consumed these words, and putting them back would send them twice.
895
1011
  await this.store.markMessageWaiting(conversationId, message.id).catch(() => undefined);
1012
+ // The reason for the refusal is also worth showing - a banner above the transcript is the only
1013
+ // place the user can read it from, because the message itself was rolled back to the queue and
1014
+ // the banner is the difference between "the message is waiting" and "I know why it is
1015
+ // waiting". The banner is cleared by the next `addUserMessage` so a stale reason cannot
1016
+ // survive a retry into the same conversation.
1017
+ await this.store.setFailureReason(conversationId, formatTurnFailure(error));
1018
+ // Status is restored here, not relied on the outer catch to handle: a preflight failure never
1019
+ // opened a turn, so the right state is the one the conversation had a moment ago (idle). The
1020
+ // outer catch would otherwise flip it to `failed`, which the reader would render as a turn
1021
+ // that ran and broke - the opposite of what actually happened.
1022
+ await this.store.setStatus(conversationId, "idle");
1023
+ // Tag the error so the outer catch knows to stay out of the way: no status flip, no second
1024
+ // setFailureReason (the one above is the only one the reader should see). A free-standing
1025
+ // property on the Error object is enough - it is checked with `in`, not by class, and the
1026
+ // throw immediately below carries it through the propagation.
1027
+ error.__superPiPreflight = true;
1028
+ this.broadcaster.broadcastState();
896
1029
  throw error;
897
1030
  }
898
1031
  await this.runConversationTurns(conversation, workspace, expanded, controller, message.id, validImages);
899
1032
  await this.store.setStatus(conversation.id, controller.signal.aborted ? "stopped" : "completed");
900
1033
  }
901
1034
  catch (error) {
1035
+ // A failing turn needs to be READ AS A FAILING TURN, not just stamped "failed" in a corner no one
1036
+ // watches. The status flip is necessary but not sufficient: a turn that was answering the user a
1037
+ // moment ago, now reporting 「failed」 with no reason, looks identical to a turn that the user
1038
+ // stopped themselves, and the reader has no way to tell which one it is. The only honest move
1039
+ // is to put the reason in front of them - `Conversation.error` is the banner above the
1040
+ // transcript that the failure state was never reaching.
1041
+ //
1042
+ // The reason is taken from the error itself, with the most common upstream cases translated to
1043
+ // the wording a person reads. The literal `error.message` would say "Pi RPC channel closed
1044
+ // before producing a record (code 3)" - that is true and useless. The translate here is what
1045
+ // the model-provider documentation actually says and what the reader can act on (retry, change
1046
+ // model, check the key), and it matches the banner the reader sees elsewhere in the app.
1047
+ //
1048
+ // NOT EVERY CATCH PATH IS A FAILURE the reader should see. A stop signalled through the
1049
+ // controller reaches the catch too (the abort propagates as a throw), and a stop is not a
1050
+ // failure - it is what the user asked for, and painting it red would be both wrong and a
1051
+ // regression from the pre-change behavior of these very lines. The reason is recorded only
1052
+ // when the controller was NOT aborted.
1053
+ //
1054
+ // Preflight refusals - bad model choice, unresolvable `@`-reference - carry the same shape but
1055
+ // never opened a turn, so flipping status to `failed` would be a lie: the previous state was
1056
+ // `idle` (or whatever the conversation was before the send) and that is what the conversation
1057
+ // is now. The preflight branch above already wrote the reason and set status back to `idle`,
1058
+ // so this catch only has to stay out of the way. The marker is set on the error there and
1059
+ // checked here.
1060
+ if (error?.__superPiPreflight) {
1061
+ throw error;
1062
+ }
902
1063
  await this.store.setStatus(conversation.id, "failed");
1064
+ if (!controller.signal.aborted) {
1065
+ await this.store.setFailureReason(conversation.id, formatTurnFailure(error));
1066
+ }
903
1067
  throw error;
904
1068
  }
905
1069
  finally {
@@ -916,22 +1080,29 @@ export class RunService {
916
1080
  }
917
1081
  /** Starts a chosen waiting message as the next turn, only when the conversation is idle. */
918
1082
  async sendQueued(conversationId, messageId) {
919
- const waitForStop = async () => {
920
- // A stop broadcasts `stopped` before the previous run has finished its finally block. Allow that
921
- // short cleanup window; a click on the newly visible button must not race the old run slot.
922
- const deadline = Date.now() + 15_000;
923
- while (this.activeRuns.has(conversationId) && this.store.snapshot().conversations.find((item) => item.id === conversationId)?.status === "stopped" && Date.now() < deadline) {
924
- await new Promise((resolve) => setTimeout(resolve, 10));
925
- }
926
- };
927
- await waitForStop();
1083
+ // A stop broadcasts `stopped` before the previous run has finished its finally block. Allow that
1084
+ // short cleanup window; a click on the newly visible button must not race the old run slot.
1085
+ const deadline = Date.now() + 15_000;
1086
+ while (this.activeRuns.has(conversationId) && this.store.snapshot().conversations.find((item) => item.id === conversationId)?.status === "stopped" && Date.now() < deadline) {
1087
+ await new Promise((resolve) => setTimeout(resolve, 10));
1088
+ }
1089
+ await this.processWaitingMessage(conversationId, messageId);
1090
+ }
1091
+ /**
1092
+ * The lower-level "wait for run slot, open the turn on a specific waiting message, run it" sequence.
1093
+ *
1094
+ * Called from two places: the user-facing `sendQueued` (which adds a stop-clearing grace window
1095
+ * before this) and the auto-trigger paths in `enqueueExecResult` and `dispatchTask`'s background
1096
+ * branch (which only call it when `!this.activeRuns.has(conversationId)`). The two callers differ
1097
+ * only in who arrived first; the open-turn-and-run contract is the same.
1098
+ *
1099
+ * `composePrompt` is what makes the `role: "tool"` path real - the `<task>` envelope stays in the
1100
+ * transcript, but the prose form of the same fact is what reaches Pi.
1101
+ */
1102
+ async processWaitingMessage(conversationId, messageId) {
928
1103
  return this.serializeConversation(conversationId, async () => {
929
1104
  if (this.activeRuns.has(conversationId))
930
1105
  throw new Error("当前轮仍在运行;停止或等待它结束后再发送这条消息");
931
- // Claim the run slot BEFORE releasing the command lane, then start the turn from the message that
932
- // is already in the transcript. It is not re-sent through `enqueueAndRun`, which would write a
933
- // SECOND copy of these words: the whole point of this action is that the user is sending a message
934
- // they can already see sitting in their conversation.
935
1106
  const controller = new AbortController();
936
1107
  this.activeRuns.set(conversationId, controller);
937
1108
  try {
@@ -940,14 +1111,14 @@ export class RunService {
940
1111
  const identity = agent ? { id: agent.id, name: agent.name } : { name: "Pi" };
941
1112
  const opened = await this.store.openTurnOnSpecificMessage(conversationId, messageId, identity);
942
1113
  this.broadcaster.broadcastState();
943
- await this.runConversationTurns(conversation, workspace, opened.user.content, controller, opened.user.id, opened.user.images);
1114
+ await this.runConversationTurns(conversation, workspace, composePrompt(opened.user), controller, opened.user.id, opened.user.images);
944
1115
  await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "completed");
945
1116
  }
946
1117
  catch (error) {
947
1118
  await this.store.setStatus(conversationId, "failed");
948
- // A preflight failure must put the words BACK where the user can see them. Re-adding them would
949
- // duplicate the message that is still in the transcript, so this only re-marks the one that is
950
- // there - which is what makes "the send failed, your message is still waiting" a true statement.
1119
+ // A preflight failure must put the message BACK where the reader can see it. Re-adding it would
1120
+ // duplicate the row that is still in the transcript, so this only re-marks the one that is
1121
+ // there - which is what makes "the send failed, this row is still in the queue" a true statement.
951
1122
  await this.store.releaseWaitingMessage(conversationId, messageId).catch(() => undefined);
952
1123
  throw error;
953
1124
  }
@@ -989,7 +1160,10 @@ export class RunService {
989
1160
  const claimed = await this.store.claimWaitingMessage(conversationId, messageId);
990
1161
  this.broadcaster.broadcastState();
991
1162
  try {
992
- await this.runtime.steer(conversationId, claimed.content, claimed.images);
1163
+ // `composePrompt` turns a 后台回传 into the prose the model can read as a user message; for a
1164
+ // user waiting message it is the no-op identity. `claimed.images` is always undefined for a
1165
+ // `role: "tool"` row, and that is correct - 后台回传 carries no images today.
1166
+ await this.runtime.steer(conversationId, composePrompt(claimed), claimed.images);
993
1167
  }
994
1168
  catch (error) {
995
1169
  // Back to waiting in every failure case, including the "Pi is gone" one. It is still at the front of
@@ -1115,11 +1289,24 @@ export class RunService {
1115
1289
  this.memory?.review(this.turnContext(conversationId).conversation, { task: body.prompt, result: run.output });
1116
1290
  const result = run.status === "completed" ? run.output ?? "" : `Background task failed: ${run.error ?? run.status}`;
1117
1291
  const tag = run.status === "completed" ? "task_result" : "task_error";
1118
- // Result is appended as a `role: "tool"` message, not a user-prompted turn. The transcript
1119
- // shows the result in its own column; the model only sees it on a later turn.
1292
+ // A 后台回传 that the agent will actually get to read: same `<task>` envelope the transcript and
1293
+ // dock render from, but with `delivery: "waiting"` so it lives in the queue, jumps to the
1294
+ // front, and auto-triggers a turn when the conversation is idle. The render side is unchanged -
1295
+ // still the 后台回传 column.
1120
1296
  const message = `<task id="${run.id}" state="${run.status === "completed" ? "completed" : "error"}" kind="subagent">\n<summary>Background task ${run.status === "completed" ? "completed" : "failed"}: ${body.description?.trim() || subagentType}</summary>\n<${tag}>\n${result}</${tag}>\n</task>`;
1121
1297
  try {
1122
- await this.store.addToolMessage(conversation.id, message);
1298
+ const queued = await this.store.addToolMessage(conversation.id, message, { delivery: "waiting" });
1299
+ this.broadcaster.broadcastState();
1300
+ const waiting = this.store.listWaitingMessages(conversation.id);
1301
+ if (waiting.length > 1) {
1302
+ await this.store.reorderWaitingMessage(conversation.id, queued.id, 0);
1303
+ this.broadcaster.broadcastState();
1304
+ }
1305
+ if (!this.activeRuns.has(conversation.id)) {
1306
+ void this.processWaitingMessage(conversation.id, queued.id).catch((error) => {
1307
+ console.warn(`Super Pi: auto-trigger for background sub-agent result failed: ${error instanceof Error ? error.message : "unknown error"}`);
1308
+ });
1309
+ }
1123
1310
  }
1124
1311
  catch (error) {
1125
1312
  console.warn(`Super Pi: sub-agent result could not be appended as a tool message: ${error instanceof Error ? error.message : "unknown error"}`);
@@ -1129,7 +1316,16 @@ export class RunService {
1129
1316
  }).catch(async (error) => {
1130
1317
  const message = `<task id="${backgroundId}" state="error" kind="subagent">\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>`;
1131
1318
  try {
1132
- await this.store.addToolMessage(conversationId, message);
1319
+ const queued = await this.store.addToolMessage(conversationId, message, { delivery: "waiting" });
1320
+ this.broadcaster.broadcastState();
1321
+ const waiting = this.store.listWaitingMessages(conversationId);
1322
+ if (waiting.length > 1) {
1323
+ await this.store.reorderWaitingMessage(conversationId, queued.id, 0);
1324
+ this.broadcaster.broadcastState();
1325
+ }
1326
+ // No auto-trigger on this failure branch (the result envelope says `state="error"` and the
1327
+ // conversation is by construction not in a turn that asked for it). The row is visible in the
1328
+ // dock for the reader to send-now or cancel.
1133
1329
  }
1134
1330
  catch (innerError) {
1135
1331
  console.warn(`Super Pi: sub-agent failure could not be appended as a tool message: ${innerError instanceof Error ? innerError.message : "unknown error"}`);
@@ -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
  }
@@ -1,7 +1,7 @@
1
1
  import type { AppStateRepository } from "./persistence/app-state-repository.js";
2
2
  import { defaultJsonStatePath } from "./persistence/json-state-repository.js";
3
3
  import type { WorkflowRun } from "./workflow.js";
4
- import type { AppState, Conversation, Message, MessageImage, ModelSelection, SubagentRun, SubagentRunUpdate, TodoItem, ToolCallQuestion, ToolCallArg, ToolCallBlock, ToolResultSummary, Workspace } from "./types.js";
4
+ import type { AppState, Conversation, Message, MessageDelivery, MessageImage, ModelSelection, SubagentRun, SubagentRunUpdate, TodoItem, ToolCallQuestion, ToolCallArg, ToolCallBlock, ToolResultSummary, Workspace } from "./types.js";
5
5
  /** Mints the id a forked conversation will have, BEFORE its Pi transcript exists.
6
6
  *
7
7
  * Exported because the ordering demands it and no other arrangement provides it. Pi's copied file's header
@@ -220,18 +220,18 @@ export declare class Store {
220
220
  */
221
221
  private claimClientMessageId;
222
222
  /**
223
- * Appends one background-result message to a conversation, without starting a turn.
223
+ * Appends one background-result message to a conversation. The render column is the same as before
224
+ * (a `role: "tool"` row, never impersonating a user bubble) - what this method now ALSO supports is
225
+ * putting the message straight into the dock by passing `delivery: "waiting"`, which is what exec and
226
+ * sub-agent background completions do once a 后台回传 is treated as a queue item with steer priority
227
+ * rather than a one-way notice the model never gets to read.
224
228
  *
225
- * Used by sub-agent dispatch completions and the exec service when they finish out of band - the
226
- * result is the prompt for a follow-up turn in the old design, which made a "后台回传" render under
227
- * the "你" bubble and asked the model to consume a tool envelope as user prose. This appends a
228
- * `role: "tool"` message that the client renders with its own column and its own block UI, so the
229
- * transcript shows the result AND nothing pretends the user said it.
230
- *
231
- * NO `turnContext` claim, NO `status: "running"`. The message sits in the transcript and a subsequent
232
- * caller can start a turn normally if it wants the model to react to the result.
229
+ * NO `turnContext` claim, NO `status: "running"`. The message sits in the transcript; with a delivery
230
+ * state it also enters `waitingMessages` and `runConversationTurns` will pick it up next.
233
231
  */
234
- addToolMessage(conversationId: string, content: string): Promise<Message>;
232
+ addToolMessage(conversationId: string, content: string, options?: {
233
+ delivery?: MessageDelivery;
234
+ }): Promise<Message>;
235
235
  /**
236
236
  * Records the model a conversation runs on. `updatedAt` is deliberately NOT touched, for the same
237
237
  * reason rename does not touch it: it is the sidebar's recency key, and switching model is not
@@ -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
  /**
@@ -405,7 +421,11 @@ export declare class Store {
405
421
  * browsers and the array is mutated in place by the very next send.
406
422
  */
407
423
  listWaitingMessages(conversationId: string): Message[];
408
- /** The same list by reference, for callers that only read a field and must not pay for a clone. */
424
+ /** The same list by reference, for callers that only read a field and must not pay for a clone.
425
+ *
426
+ * Includes `role: "tool"` messages now that a 后台回传 is a real queue item: same dock, same ordering,
427
+ * same delivery state machine, same steer/claim semantics - the only thing different is the render
428
+ * (kept as a 后台回传 block) and what the agent is told when its turn comes (see `composePrompt`). */
409
429
  private waitingMessages;
410
430
  private requireWaitingMessage;
411
431
  private persistMessages;
@@ -475,12 +495,9 @@ export declare class Store {
475
495
  */
476
496
  cancelWaitingMessage(conversationId: string, messageId: string): Promise<Message | undefined>;
477
497
  /**
478
- * Rewrites the words of one waiting message.
479
- *
480
- * Only legal while nothing has read it. `waiting` means the text is still only in this transcript, so
481
- * changing it is changing the whole truth of the message - there is no second copy anywhere that could
482
- * fall out of step. `sending` means Pi already holds it, and a message the model is about to answer is
483
- * not one the user gets to rewrite after the fact.
498
+ * Rewrites the words of one waiting message. User sends only - a 后台回传 carries a `<task>` envelope
499
+ * the model parses for state, summary and body, and editing it would change a fact the agent has
500
+ * already started reasoning from. The client dock shows no 「改字」 button for `role: "tool"` rows.
484
501
  */
485
502
  editWaitingMessage(conversationId: string, messageId: string, content: string): Promise<Message>;
486
503
  /**