@evandu/super-pi 0.1.89 → 0.1.91

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-DYjBTgl4.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-BcHIMxNF.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -37,9 +37,35 @@ export class ConversationService {
37
37
  const workspace = this.store.snapshot().workspaces.find(item => item.id === workspaceId);
38
38
  if (!workspace)
39
39
  throw new Error("Workspace not found");
40
- const defaults = workspace.defaultAgentId
41
- ? await this.resolveAgentConfiguration({ id: workspaceId, model: workspace.defaultModel, thinkingLevel: workspace.defaultThinkingLevel }, workspace.defaultAgentId, workspaceId)
42
- : undefined;
40
+ // Workspace defaults are copied at birth, but Pi's model list can change after the
41
+ // setting was saved (for example when a model is removed from Pi's config). Do not
42
+ // stamp a dead selection onto every new conversation: the scoped catalog probe would
43
+ // fail and leave the model/thinking controls unavailable. A missing or invalid default
44
+ // means "follow Pi's current default" for this newly-created conversation.
45
+ let model = workspace.defaultModel;
46
+ let thinkingLevel = workspace.defaultThinkingLevel;
47
+ const catalog = model || thinkingLevel ? await this.runtime.catalog() : undefined;
48
+ if (catalog?.source === "pi" && model) {
49
+ const option = catalog.models.find(item => item.provider === model?.provider && item.modelId === model?.modelId);
50
+ if (!option) {
51
+ model = undefined;
52
+ thinkingLevel = undefined;
53
+ }
54
+ else {
55
+ model = { provider: option.provider, modelId: option.modelId, label: option.label };
56
+ if (thinkingLevel) {
57
+ const scoped = await this.runtime.catalog(model);
58
+ if (scoped.source === "pi" && !scoped.levels.includes(thinkingLevel))
59
+ thinkingLevel = undefined;
60
+ }
61
+ }
62
+ }
63
+ const resolved = workspace.defaultAgentId
64
+ ? await this.resolveAgentConfiguration({ id: workspaceId, model, thinkingLevel }, workspace.defaultAgentId, workspaceId)
65
+ : { model, thinkingLevel };
66
+ // `null` is intentional here: Store uses it to remove a workspace value that it
67
+ // would otherwise have copied before applying the resolved configuration.
68
+ const defaults = { ...resolved, model: resolved.model ?? null, thinkingLevel: resolved.thinkingLevel ?? null };
43
69
  const created = await this.store.createConversation(workspaceId, defaults);
44
70
  this.broadcaster.broadcastState();
45
71
  return created.id;
@@ -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,27 @@ 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
+ * Stops the current turn and immediately gives one waiting message its own fresh turn.
244
+ *
245
+ * `steer` cannot provide this meaning: Pi only reads its steering queue after the current tool batch
246
+ * finishes. Stopping first is what makes a long-running tool give way now. `sendQueued` already owns
247
+ * the hand-off window after a stop, so it waits until the old run has released its slot before opening
248
+ * the selected message. That preserves the one-Pi-per-conversation invariant.
249
+ */
250
+ interruptQueued(conversationId: string, messageId: string): Promise<void>;
251
+ /**
252
+ * The lower-level "wait for run slot, open the turn on a specific waiting message, run it" sequence.
253
+ *
254
+ * Called from two places: the user-facing `sendQueued` (which adds a stop-clearing grace window
255
+ * before this) and the auto-trigger paths in `enqueueExecResult` and `dispatchTask`'s background
256
+ * branch (which only call it when `!this.activeRuns.has(conversationId)`). The two callers differ
257
+ * only in who arrived first; the open-turn-and-run contract is the same.
258
+ *
259
+ * `composePrompt` is what makes the `role: "tool"` path real - the `<task>` envelope stays in the
260
+ * transcript, but the prose form of the same fact is what reaches Pi.
261
+ */
262
+ private processWaitingMessage;
236
263
  private steerUnsafe;
237
264
  /**
238
265
  * 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"}`);
@@ -488,6 +542,22 @@ export class RunService {
488
542
  let assistant = assistantMessage;
489
543
  let toolCalls = [];
490
544
  let pendingTools = Promise.resolve();
545
+ // A TURN MOVES FROM `waiting` TO `running` ONCE THE MODEL HAS PRODUCED ANY SIGNAL: a text delta
546
+ // OR a tool-call notice. The `running` state means "we have data flowing", and that is true the
547
+ // moment Pi yields anything back, regardless of shape. Without this, a pure tool-call turn would
548
+ // stay in `waiting` until the very end and the reader would see a stuck turn for the whole of a
549
+ // long tool execution. The flag is the only way to make the flip one-shot: calling `setStatus` on
550
+ // every delta would be a per-token status write.
551
+ let firstSignal = true;
552
+ const promoteToRunning = async () => {
553
+ if (!firstSignal)
554
+ return;
555
+ firstSignal = false;
556
+ if (this.store.snapshot().conversations.find((item) => item.id === conversation.id)?.status === "waiting") {
557
+ await this.store.setStatus(conversation.id, "running");
558
+ this.broadcaster.broadcastState();
559
+ }
560
+ };
491
561
  // Live events are independent from durable checkpoints. Persisting every token made the stdout
492
562
  // reader wait for one disk transaction per token; over a remote browser connection that backlog
493
563
  // looked exactly like a slow model. Keep the in-memory transcript current, publish immediately, and
@@ -516,6 +586,12 @@ export class RunService {
516
586
  };
517
587
  const enqueueTools = (what, work) => {
518
588
  pendingTools = pendingTools.then(async () => {
589
+ // A TOOL CALL IS A SIGNAL THE MODEL IS ALIVE. Promote `waiting` → `running` on the same
590
+ // path as text deltas: a tool row appearing on screen is the same "we have data" event
591
+ // the status needs to reflect. Doing it BEFORE the tool write keeps the order
592
+ // honest - a client that saw the new tool row in the same frame also saw the running
593
+ // status, instead of flashing back to a still-spinning waiting mark for the next paint.
594
+ await promoteToRunning();
519
595
  toolCalls = await work();
520
596
  this.broadcaster.broadcast({ type: "message.tools", conversationId: conversation.id, messageId: assistant.id, toolCalls });
521
597
  }).catch((error) => {
@@ -799,6 +875,12 @@ export class RunService {
799
875
  // publish a new message underneath it. One `await` at the top covers both, and costs nothing:
800
876
  // the lane is empty most of the time.
801
877
  await pendingTools;
878
+ // `waiting` → `running` ON THE FIRST DELTA, before the live write. The conversation was
879
+ // opened in `waiting` (see `openTurn` / `openTurnOnSpecificMessage` / `openTurnOnMessage`),
880
+ // and the first response token is the observable fact that says the LLM is actually
881
+ // answering rather than hanging on auth / quota / network. `promoteToRunning` is a no-op
882
+ // after the first signal so this stays a one-shot status write, not a per-token one.
883
+ await promoteToRunning();
802
884
  this.store.appendAssistantDeltaLive(conversation.id, assistant.id, delta);
803
885
  spoken += delta.length;
804
886
  // Tool events are structural transcript boundaries. Publish any boundary Pi announced before
@@ -870,7 +952,9 @@ export class RunService {
870
952
  const followUp = await this.store.openTurnOnMessage(conversation.id, next.id, identity);
871
953
  this.broadcaster.broadcastState();
872
954
  const current = this.turnContext(conversation.id).conversation;
873
- await this.streamTurn(current, workspace, next.content, followUp.assistant, controller, followUp.user.id, next.images, identity);
955
+ // `composePrompt` synthesises the prose for a 后台回传 (the `<task>` envelope is what the transcript
956
+ // column reads from; the prose is what leaves the server for Pi). For user messages it is a no-op.
957
+ await this.streamTurn(current, workspace, composePrompt(next), followUp.assistant, controller, followUp.user.id, next.images, identity);
874
958
  }
875
959
  }
876
960
  finally {
@@ -1024,22 +1108,42 @@ export class RunService {
1024
1108
  }
1025
1109
  /** Starts a chosen waiting message as the next turn, only when the conversation is idle. */
1026
1110
  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();
1111
+ // A stop broadcasts `stopped` before the previous run has finished its finally block. Allow that
1112
+ // short cleanup window; a click on the newly visible button must not race the old run slot.
1113
+ const deadline = Date.now() + 15_000;
1114
+ while (this.activeRuns.has(conversationId) && this.store.snapshot().conversations.find((item) => item.id === conversationId)?.status === "stopped" && Date.now() < deadline) {
1115
+ await new Promise((resolve) => setTimeout(resolve, 10));
1116
+ }
1117
+ await this.processWaitingMessage(conversationId, messageId);
1118
+ }
1119
+ /**
1120
+ * Stops the current turn and immediately gives one waiting message its own fresh turn.
1121
+ *
1122
+ * `steer` cannot provide this meaning: Pi only reads its steering queue after the current tool batch
1123
+ * finishes. Stopping first is what makes a long-running tool give way now. `sendQueued` already owns
1124
+ * the hand-off window after a stop, so it waits until the old run has released its slot before opening
1125
+ * the selected message. That preserves the one-Pi-per-conversation invariant.
1126
+ */
1127
+ async interruptQueued(conversationId, messageId) {
1128
+ if (this.activeRuns.has(conversationId))
1129
+ await this.stopRun(conversationId);
1130
+ await this.sendQueued(conversationId, messageId);
1131
+ }
1132
+ /**
1133
+ * The lower-level "wait for run slot, open the turn on a specific waiting message, run it" sequence.
1134
+ *
1135
+ * Called from two places: the user-facing `sendQueued` (which adds a stop-clearing grace window
1136
+ * before this) and the auto-trigger paths in `enqueueExecResult` and `dispatchTask`'s background
1137
+ * branch (which only call it when `!this.activeRuns.has(conversationId)`). The two callers differ
1138
+ * only in who arrived first; the open-turn-and-run contract is the same.
1139
+ *
1140
+ * `composePrompt` is what makes the `role: "tool"` path real - the `<task>` envelope stays in the
1141
+ * transcript, but the prose form of the same fact is what reaches Pi.
1142
+ */
1143
+ async processWaitingMessage(conversationId, messageId) {
1036
1144
  return this.serializeConversation(conversationId, async () => {
1037
1145
  if (this.activeRuns.has(conversationId))
1038
1146
  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
1147
  const controller = new AbortController();
1044
1148
  this.activeRuns.set(conversationId, controller);
1045
1149
  try {
@@ -1048,14 +1152,14 @@ export class RunService {
1048
1152
  const identity = agent ? { id: agent.id, name: agent.name } : { name: "Pi" };
1049
1153
  const opened = await this.store.openTurnOnSpecificMessage(conversationId, messageId, identity);
1050
1154
  this.broadcaster.broadcastState();
1051
- await this.runConversationTurns(conversation, workspace, opened.user.content, controller, opened.user.id, opened.user.images);
1155
+ await this.runConversationTurns(conversation, workspace, composePrompt(opened.user), controller, opened.user.id, opened.user.images);
1052
1156
  await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "completed");
1053
1157
  }
1054
1158
  catch (error) {
1055
1159
  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.
1160
+ // A preflight failure must put the message BACK where the reader can see it. Re-adding it would
1161
+ // duplicate the row that is still in the transcript, so this only re-marks the one that is
1162
+ // there - which is what makes "the send failed, this row is still in the queue" a true statement.
1059
1163
  await this.store.releaseWaitingMessage(conversationId, messageId).catch(() => undefined);
1060
1164
  throw error;
1061
1165
  }
@@ -1097,7 +1201,10 @@ export class RunService {
1097
1201
  const claimed = await this.store.claimWaitingMessage(conversationId, messageId);
1098
1202
  this.broadcaster.broadcastState();
1099
1203
  try {
1100
- await this.runtime.steer(conversationId, claimed.content, claimed.images);
1204
+ // `composePrompt` turns a 后台回传 into the prose the model can read as a user message; for a
1205
+ // user waiting message it is the no-op identity. `claimed.images` is always undefined for a
1206
+ // `role: "tool"` row, and that is correct - 后台回传 carries no images today.
1207
+ await this.runtime.steer(conversationId, composePrompt(claimed), claimed.images);
1101
1208
  }
1102
1209
  catch (error) {
1103
1210
  // Back to waiting in every failure case, including the "Pi is gone" one. It is still at the front of
@@ -1223,11 +1330,24 @@ export class RunService {
1223
1330
  this.memory?.review(this.turnContext(conversationId).conversation, { task: body.prompt, result: run.output });
1224
1331
  const result = run.status === "completed" ? run.output ?? "" : `Background task failed: ${run.error ?? run.status}`;
1225
1332
  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.
1333
+ // A 后台回传 that the agent will actually get to read: same `<task>` envelope the transcript and
1334
+ // dock render from, but with `delivery: "waiting"` so it lives in the queue, jumps to the
1335
+ // front, and auto-triggers a turn when the conversation is idle. The render side is unchanged -
1336
+ // still the 后台回传 column.
1228
1337
  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
1338
  try {
1230
- await this.store.addToolMessage(conversation.id, message);
1339
+ const queued = await this.store.addToolMessage(conversation.id, message, { delivery: "waiting" });
1340
+ this.broadcaster.broadcastState();
1341
+ const waiting = this.store.listWaitingMessages(conversation.id);
1342
+ if (waiting.length > 1) {
1343
+ await this.store.reorderWaitingMessage(conversation.id, queued.id, 0);
1344
+ this.broadcaster.broadcastState();
1345
+ }
1346
+ if (!this.activeRuns.has(conversation.id)) {
1347
+ void this.processWaitingMessage(conversation.id, queued.id).catch((error) => {
1348
+ console.warn(`Super Pi: auto-trigger for background sub-agent result failed: ${error instanceof Error ? error.message : "unknown error"}`);
1349
+ });
1350
+ }
1231
1351
  }
1232
1352
  catch (error) {
1233
1353
  console.warn(`Super Pi: sub-agent result could not be appended as a tool message: ${error instanceof Error ? error.message : "unknown error"}`);
@@ -1237,7 +1357,16 @@ export class RunService {
1237
1357
  }).catch(async (error) => {
1238
1358
  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
1359
  try {
1240
- await this.store.addToolMessage(conversationId, message);
1360
+ const queued = await this.store.addToolMessage(conversationId, message, { delivery: "waiting" });
1361
+ this.broadcaster.broadcastState();
1362
+ const waiting = this.store.listWaitingMessages(conversationId);
1363
+ if (waiting.length > 1) {
1364
+ await this.store.reorderWaitingMessage(conversationId, queued.id, 0);
1365
+ this.broadcaster.broadcastState();
1366
+ }
1367
+ // No auto-trigger on this failure branch (the result envelope says `state="error"` and the
1368
+ // conversation is by construction not in a turn that asked for it). The row is visible in the
1369
+ // dock for the reader to send-now or cancel.
1241
1370
  }
1242
1371
  catch (innerError) {
1243
1372
  console.warn(`Super Pi: sub-agent failure could not be appended as a tool message: ${innerError instanceof Error ? innerError.message : "unknown error"}`);
@@ -169,6 +169,10 @@ export class CommandHandler {
169
169
  await this.runService.steer(command.conversationId, command.messageId);
170
170
  return;
171
171
  }
172
+ if (command.type === "message.interrupt") {
173
+ await this.runService.interruptQueued(command.conversationId, command.messageId);
174
+ return;
175
+ }
172
176
  if (command.type === "message.sendQueued") {
173
177
  await this.runService.sendQueued(command.conversationId, command.messageId);
174
178
  return;
@@ -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
@@ -107,8 +107,8 @@ export declare class Store {
107
107
  deleteWorkspace(workspaceId: string): Promise<string[]>;
108
108
  createConversation(workspaceId: string, configuration?: {
109
109
  agentId?: string;
110
- model?: ModelSelection;
111
- thinkingLevel?: string;
110
+ model?: ModelSelection | null;
111
+ thinkingLevel?: string | null;
112
112
  }): Promise<Conversation>;
113
113
  /**
114
114
  * Copies a conversation into a NEW one, under an id the caller has already minted.
@@ -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.
224
- *
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.
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.
230
228
  *
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
  /**
@@ -548,9 +549,10 @@ export declare class Store {
548
549
  * question that is still waiting - a transcript lying about its own order. Splicing at
549
550
  * `indexOf(user) + 1` keeps every waiting message below the reply that belongs to the one above it.
550
551
  *
551
- * The side effects - title, `updatedAt`, `status: "running"`, clearing a stale failure - all describe a
552
+ * The side effects - title, `updatedAt`, `status: "waiting"`, clearing a stale failure - all describe a
552
553
  * TURN rather than a message, which is why `addUserMessage` skips them for a message that is only
553
- * waiting. They belong here, at the moment the turn genuinely begins.
554
+ * waiting. They belong here, at the moment the turn genuinely begins - the model still has not
555
+ * returned a token, and that is the shape the row carries until `streamTurn` sees the first delta.
554
556
  */
555
557
  openTurnOnMessage(conversationId: string, messageId: string, agentIdentity?: Message["agentIdentity"]): Promise<{
556
558
  user: Message;