@evandu/super-pi 0.1.89 → 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.
@@ -98,6 +98,35 @@ async function expandAtReferences(store, workspace, content) {
98
98
  }
99
99
  return blocks.length ? `${content}\n${blocks.join("\n")}` : content;
100
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
+ }
101
130
  /**
102
131
  * Service managing execution of conversation turns, message queueing,
103
132
  * follow-ups, and sub-agent dispatching.
@@ -187,6 +216,12 @@ export class RunService {
187
216
  /**
188
217
  * Called by the exec completion handler in index.ts when a background exec session ends.
189
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.
190
225
  */
191
226
  enqueueExecResult(sessionId, exitCode, stdout, stderr) {
192
227
  const tasks = this.store.snapshot().backgroundTasks;
@@ -195,10 +230,6 @@ export class RunService {
195
230
  return;
196
231
  const tag = exitCode === 0 ? "task_result" : "task_error";
197
232
  const body = exitCode === 0 ? stdout : (stderr || `Command exited with code ${exitCode}`);
198
- // The result becomes a `role: "tool"` message in the conversation, not a user prompt for a fresh
199
- // turn. The transcript now has a 「后台回传」 column the client renders, and the model sees the
200
- // result only when something else starts a turn that includes it. Asking the model to consume a
201
- // `<task>` envelope as user prose is the "undefined behaviour" path the user reported.
202
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>`;
203
234
  void this.trackDetached((async () => {
204
235
  try {
@@ -211,8 +242,31 @@ export class RunService {
211
242
  this.broadcaster.broadcast({ type: "message.tools", conversationId: task.conversationId, messageId: task.originMessageId, toolCalls });
212
243
  }
213
244
  this.broadcaster.broadcast({ type: "exec.exit", sessionId, exitCode, stdout, stderr });
214
- 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" });
215
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
+ }
216
270
  }
217
271
  catch (error) {
218
272
  console.warn(`Super Pi: exec result could not be appended as a tool message: ${error instanceof Error ? error.message : "unknown error"}`);
@@ -870,7 +924,9 @@ export class RunService {
870
924
  const followUp = await this.store.openTurnOnMessage(conversation.id, next.id, identity);
871
925
  this.broadcaster.broadcastState();
872
926
  const current = this.turnContext(conversation.id).conversation;
873
- 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);
874
930
  }
875
931
  }
876
932
  finally {
@@ -1024,22 +1080,29 @@ export class RunService {
1024
1080
  }
1025
1081
  /** Starts a chosen waiting message as the next turn, only when the conversation is idle. */
1026
1082
  async sendQueued(conversationId, messageId) {
1027
- const waitForStop = async () => {
1028
- // A stop broadcasts `stopped` before the previous run has finished its finally block. Allow that
1029
- // short cleanup window; a click on the newly visible button must not race the old run slot.
1030
- const deadline = Date.now() + 15_000;
1031
- while (this.activeRuns.has(conversationId) && this.store.snapshot().conversations.find((item) => item.id === conversationId)?.status === "stopped" && Date.now() < deadline) {
1032
- await new Promise((resolve) => setTimeout(resolve, 10));
1033
- }
1034
- };
1035
- 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) {
1036
1103
  return this.serializeConversation(conversationId, async () => {
1037
1104
  if (this.activeRuns.has(conversationId))
1038
1105
  throw new Error("当前轮仍在运行;停止或等待它结束后再发送这条消息");
1039
- // Claim the run slot BEFORE releasing the command lane, then start the turn from the message that
1040
- // is already in the transcript. It is not re-sent through `enqueueAndRun`, which would write a
1041
- // SECOND copy of these words: the whole point of this action is that the user is sending a message
1042
- // they can already see sitting in their conversation.
1043
1106
  const controller = new AbortController();
1044
1107
  this.activeRuns.set(conversationId, controller);
1045
1108
  try {
@@ -1048,14 +1111,14 @@ export class RunService {
1048
1111
  const identity = agent ? { id: agent.id, name: agent.name } : { name: "Pi" };
1049
1112
  const opened = await this.store.openTurnOnSpecificMessage(conversationId, messageId, identity);
1050
1113
  this.broadcaster.broadcastState();
1051
- 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);
1052
1115
  await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "completed");
1053
1116
  }
1054
1117
  catch (error) {
1055
1118
  await this.store.setStatus(conversationId, "failed");
1056
- // A preflight failure must put the words BACK where the user can see them. Re-adding them would
1057
- // duplicate the message that is still in the transcript, so this only re-marks the one that is
1058
- // 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.
1059
1122
  await this.store.releaseWaitingMessage(conversationId, messageId).catch(() => undefined);
1060
1123
  throw error;
1061
1124
  }
@@ -1097,7 +1160,10 @@ export class RunService {
1097
1160
  const claimed = await this.store.claimWaitingMessage(conversationId, messageId);
1098
1161
  this.broadcaster.broadcastState();
1099
1162
  try {
1100
- 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);
1101
1167
  }
1102
1168
  catch (error) {
1103
1169
  // Back to waiting in every failure case, including the "Pi is gone" one. It is still at the front of
@@ -1223,11 +1289,24 @@ export class RunService {
1223
1289
  this.memory?.review(this.turnContext(conversationId).conversation, { task: body.prompt, result: run.output });
1224
1290
  const result = run.status === "completed" ? run.output ?? "" : `Background task failed: ${run.error ?? run.status}`;
1225
1291
  const tag = run.status === "completed" ? "task_result" : "task_error";
1226
- // Result is appended as a `role: "tool"` message, not a user-prompted turn. The transcript
1227
- // 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.
1228
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>`;
1229
1297
  try {
1230
- 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
+ }
1231
1310
  }
1232
1311
  catch (error) {
1233
1312
  console.warn(`Super Pi: sub-agent result could not be appended as a tool message: ${error instanceof Error ? error.message : "unknown error"}`);
@@ -1237,7 +1316,16 @@ export class RunService {
1237
1316
  }).catch(async (error) => {
1238
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>`;
1239
1318
  try {
1240
- 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.
1241
1329
  }
1242
1330
  catch (innerError) {
1243
1331
  console.warn(`Super Pi: sub-agent failure could not be appended as a tool message: ${innerError instanceof Error ? innerError.message : "unknown error"}`);
@@ -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
@@ -421,7 +421,11 @@ export declare class Store {
421
421
  * browsers and the array is mutated in place by the very next send.
422
422
  */
423
423
  listWaitingMessages(conversationId: string): Message[];
424
- /** 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`). */
425
429
  private waitingMessages;
426
430
  private requireWaitingMessage;
427
431
  private persistMessages;
@@ -491,12 +495,9 @@ export declare class Store {
491
495
  */
492
496
  cancelWaitingMessage(conversationId: string, messageId: string): Promise<Message | undefined>;
493
497
  /**
494
- * Rewrites the words of one waiting message.
495
- *
496
- * Only legal while nothing has read it. `waiting` means the text is still only in this transcript, so
497
- * changing it is changing the whole truth of the message - there is no second copy anywhere that could
498
- * fall out of step. `sending` means Pi already holds it, and a message the model is about to answer is
499
- * 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.
500
501
  */
501
502
  editWaitingMessage(conversationId: string, messageId: string, content: string): Promise<Message>;
502
503
  /**
@@ -358,9 +358,16 @@ function reconcileWaitingMessages(state) {
358
358
  const recovered = [];
359
359
  for (const conversation of state.conversations) {
360
360
  for (const message of conversation.messages) {
361
- // A `delivery` on anything but a user message is a broken file: no code path writes one, and
362
- // rendering a waiting state on an assistant bubble would be a claim about nothing.
363
- if (message.role !== "user") {
361
+ // A `delivery` on an ASSISTANT message is still a broken file: rendering a waiting state on an
362
+ // assistant bubble would be a claim about nothing, and nothing in the code path writes one. Tool
363
+ // messages with `delivery` are queue items now (exec / sub-agent background completions) - if they
364
+ // died in `sending`, recover them to `waiting` just like user messages, so a crashed server does
365
+ // not strand a 后台回传 whose delivery it had already accepted.
366
+ if (message.role === "assistant") {
367
+ delete message.delivery;
368
+ continue;
369
+ }
370
+ if (message.role !== "user" && message.role !== "tool") {
364
371
  delete message.delivery;
365
372
  continue;
366
373
  }
@@ -896,22 +903,26 @@ export class Store {
896
903
  return id("message");
897
904
  }
898
905
  /**
899
- * Appends one background-result message to a conversation, without starting a turn.
900
- *
901
- * Used by sub-agent dispatch completions and the exec service when they finish out of band - the
902
- * result is the prompt for a follow-up turn in the old design, which made a "后台回传" render under
903
- * the "你" bubble and asked the model to consume a tool envelope as user prose. This appends a
904
- * `role: "tool"` message that the client renders with its own column and its own block UI, so the
905
- * transcript shows the result AND nothing pretends the user said it.
906
- *
907
- * NO `turnContext` claim, NO `status: "running"`. The message sits in the transcript and a subsequent
908
- * caller can start a turn normally if it wants the model to react to the result.
906
+ * Appends one background-result message to a conversation. The render column is the same as before
907
+ * (a `role: "tool"` row, never impersonating a user bubble) - what this method now ALSO supports is
908
+ * putting the message straight into the dock by passing `delivery: "waiting"`, which is what exec and
909
+ * sub-agent background completions do once a 后台回传 is treated as a queue item with steer priority
910
+ * rather than a one-way notice the model never gets to read.
911
+ *
912
+ * NO `turnContext` claim, NO `status: "running"`. The message sits in the transcript; with a delivery
913
+ * state it also enters `waitingMessages` and `runConversationTurns` will pick it up next.
909
914
  */
910
- async addToolMessage(conversationId, content) {
915
+ async addToolMessage(conversationId, content, options) {
911
916
  const conversation = this.requireConversation(conversationId);
912
917
  if (conversation.archivedAt)
913
918
  throw new Error("无法向已归档的会话追加后台回传,请先恢复该会话");
914
- const message = { id: id("message"), role: "tool", content, createdAt: now() };
919
+ const message = {
920
+ id: id("message"),
921
+ role: "tool",
922
+ content,
923
+ createdAt: now(),
924
+ ...(options?.delivery ? { delivery: options.delivery } : {}),
925
+ };
915
926
  conversation.messages.push(message);
916
927
  conversation.updatedAt = message.createdAt;
917
928
  await this.persist();
@@ -1358,14 +1369,18 @@ export class Store {
1358
1369
  listWaitingMessages(conversationId) {
1359
1370
  return structuredClone(this.waitingMessages(this.requireConversation(conversationId)));
1360
1371
  }
1361
- /** The same list by reference, for callers that only read a field and must not pay for a clone. */
1372
+ /** The same list by reference, for callers that only read a field and must not pay for a clone.
1373
+ *
1374
+ * Includes `role: "tool"` messages now that a 后台回传 is a real queue item: same dock, same ordering,
1375
+ * same delivery state machine, same steer/claim semantics - the only thing different is the render
1376
+ * (kept as a 后台回传 block) and what the agent is told when its turn comes (see `composePrompt`). */
1362
1377
  waitingMessages(conversation) {
1363
- return conversation.messages.filter((message) => message.role === "user" && message.delivery);
1378
+ return conversation.messages.filter((message) => (message.role === "user" || message.role === "tool") && message.delivery);
1364
1379
  }
1365
1380
  requireWaitingMessage(conversationId, messageId) {
1366
1381
  const conversation = this.requireConversation(conversationId);
1367
1382
  const message = conversation.messages.find((item) => item.id === messageId);
1368
- if (!message || message.role !== "user" || !message.delivery)
1383
+ if (!message || !message.delivery || (message.role !== "user" && message.role !== "tool"))
1369
1384
  throw new Error(`等待中的消息不存在或已被处理:${messageId}`);
1370
1385
  return { conversation, message };
1371
1386
  }
@@ -1410,7 +1425,7 @@ export class Store {
1410
1425
  async markMessageWaiting(conversationId, messageId) {
1411
1426
  const conversation = this.requireConversation(conversationId);
1412
1427
  const message = conversation.messages.find((item) => item.id === messageId);
1413
- if (!message || message.role !== "user")
1428
+ if (!message || (message.role !== "user" && message.role !== "tool"))
1414
1429
  return undefined;
1415
1430
  if (message.delivery === "sending")
1416
1431
  throw new Error("这条消息已经交给 Agent 了");
@@ -1479,7 +1494,7 @@ export class Store {
1479
1494
  if (index < 0)
1480
1495
  return undefined;
1481
1496
  const message = conversation.messages[index];
1482
- if (message.role !== "user" || !message.delivery)
1497
+ if ((message.role !== "user" && message.role !== "tool") || !message.delivery)
1483
1498
  return undefined;
1484
1499
  if (message.delivery === "sending")
1485
1500
  throw new Error("这条消息已经交给 Agent 了,取消不掉(Pi 没有撤回单条队列消息的能力)");
@@ -1488,18 +1503,17 @@ export class Store {
1488
1503
  return structuredClone(removed);
1489
1504
  }
1490
1505
  /**
1491
- * Rewrites the words of one waiting message.
1492
- *
1493
- * Only legal while nothing has read it. `waiting` means the text is still only in this transcript, so
1494
- * changing it is changing the whole truth of the message - there is no second copy anywhere that could
1495
- * fall out of step. `sending` means Pi already holds it, and a message the model is about to answer is
1496
- * not one the user gets to rewrite after the fact.
1506
+ * Rewrites the words of one waiting message. User sends only - a 后台回传 carries a `<task>` envelope
1507
+ * the model parses for state, summary and body, and editing it would change a fact the agent has
1508
+ * already started reasoning from. The client dock shows no 「改字」 button for `role: "tool"` rows.
1497
1509
  */
1498
1510
  async editWaitingMessage(conversationId, messageId, content) {
1499
1511
  const trimmed = content.trim();
1500
1512
  if (!trimmed)
1501
1513
  throw new Error("消息不能为空");
1502
1514
  const { conversation, message } = this.requireWaitingMessage(conversationId, messageId);
1515
+ if (message.role === "tool")
1516
+ throw new Error("后台任务结果不能改字");
1503
1517
  if (message.delivery !== "waiting")
1504
1518
  throw new Error("这条消息已经交给 Agent 了,改不了(Pi 已经收到了原文)");
1505
1519
  message.content = trimmed;
@@ -1571,7 +1585,11 @@ export class Store {
1571
1585
  */
1572
1586
  async deliverWaitingMessage(conversationId, agentIdentity) {
1573
1587
  const conversation = this.requireConversation(conversationId);
1574
- const index = conversation.messages.findIndex((item) => item.role === "user" && item.delivery === "sending");
1588
+ // Looks for the single message Pi is currently answering. A `role: "tool"` 后台回传 that has been
1589
+ // steered or taken is on the same delivery state machine as a user message, so it is the answer
1590
+ // target too; the assistant placeholder still lands directly below it and the role/identity is read
1591
+ // from the conversation (not the message) when the turn opens.
1592
+ const index = conversation.messages.findIndex((item) => (item.role === "user" || item.role === "tool") && item.delivery === "sending");
1575
1593
  if (index < 0)
1576
1594
  return undefined;
1577
1595
  const user = conversation.messages[index];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.89",
3
+ "version": "0.1.90",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",