@evandu/super-pi 0.1.94 → 0.1.95

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,7 +8,7 @@
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-zbxFjL-O.js"></script>
11
+ <script type="module" crossorigin src="/assets/index-gjLqMWYT.js"></script>
12
12
  <link rel="stylesheet" crossorigin href="/assets/index-8olCbCbk.css">
13
13
  </head>
14
14
  <body>
@@ -116,6 +116,24 @@ export declare function toolFlags(tools: Frontmatter | undefined, options?: Tool
116
116
  * of `toolFlags` because the picker needs to say "no tools" rather than render an empty allowlist.
117
117
  */
118
118
  export declare function deniesAllTools(tools: Frontmatter | undefined): boolean;
119
+ /**
120
+ * The same translation `toolFlags` performs, expressed as the SDK's two lists rather than as argv.
121
+ *
122
+ * WHY A SECOND TRANSLATION AND NOT A PARSER. The argv form is `--tools a,b,c` or `--exclude-tools x,y`
123
+ * or `--no-tools`, and parsing it back would be reading a serialization of a decision to recover the
124
+ * decision. Worse, `--no-tools` has no SDK equivalent that means the same thing: Pi's `noTools: "all"`
125
+ * disables the built-ins while leaving extension tools on, which is NOT what an agent that declared
126
+ * `{"*": false}` asked for - that agent asked for NO tools at all, and an extension tool left enabled
127
+ * would be a tool the author explicitly refused.
128
+ *
129
+ * So the DECISION is made once, here, in the same terms `toolFlags` uses, and each consumer takes the
130
+ * form it needs. The two must agree, and `scripts/agent-config-regression.test.mjs` asserts that they
131
+ * do for every shape of `tools:` map.
132
+ */
133
+ export declare function toolSelection(tools: Frontmatter | undefined, options?: ToolFlagOptions): {
134
+ tools?: string[];
135
+ excludeTools?: string[];
136
+ };
119
137
  /** The tools an allowlist names, for display. Empty when the agent adds no allowlist of its own. */
120
138
  export declare function allowedToolNames(tools: Frontmatter | undefined): string[];
121
139
  /** `provider/modelId`, split into the two halves Pi's `set_model` and `--provider/--model` both need. */
@@ -304,6 +304,45 @@ export function deniesAllTools(tools) {
304
304
  return false;
305
305
  return tools["*"] === false && !Object.entries(tools).some(([key, on]) => key !== "*" && on === true);
306
306
  }
307
+ /**
308
+ * The same translation `toolFlags` performs, expressed as the SDK's two lists rather than as argv.
309
+ *
310
+ * WHY A SECOND TRANSLATION AND NOT A PARSER. The argv form is `--tools a,b,c` or `--exclude-tools x,y`
311
+ * or `--no-tools`, and parsing it back would be reading a serialization of a decision to recover the
312
+ * decision. Worse, `--no-tools` has no SDK equivalent that means the same thing: Pi's `noTools: "all"`
313
+ * disables the built-ins while leaving extension tools on, which is NOT what an agent that declared
314
+ * `{"*": false}` asked for - that agent asked for NO tools at all, and an extension tool left enabled
315
+ * would be a tool the author explicitly refused.
316
+ *
317
+ * So the DECISION is made once, here, in the same terms `toolFlags` uses, and each consumer takes the
318
+ * form it needs. The two must agree, and `scripts/agent-config-regression.test.mjs` asserts that they
319
+ * do for every shape of `tools:` map.
320
+ */
321
+ export function toolSelection(tools, options = {}) {
322
+ const denied = new Set((options.denyTools ?? []).filter((name) => name !== ""));
323
+ // No map at all is Pi's own default set. An inherited deny still has to become an exclusion, because
324
+ // there is no allowlist here to drop the name from.
325
+ if (!tools || typeof tools !== "object") {
326
+ return denied.size ? { excludeTools: [...denied] } : {};
327
+ }
328
+ const entries = Object.entries(tools).filter(([key]) => key !== "*");
329
+ const wildcard = tools["*"];
330
+ const explicit = new Map(entries);
331
+ const extra = (options.extraAllowedTools ?? []).filter((name) => name !== "" && explicit.get(name) !== false);
332
+ const declared = entries.filter(([, on]) => on === true).map(([name]) => name).filter((name) => !denied.has(name));
333
+ const deny = entries.filter(([, on]) => on === false).map(([name]) => name).filter((name) => !declared.includes(name));
334
+ if (wildcard === false) {
335
+ // An allowlist of nothing is an agent that talks and does not act. The SDK expresses that as an
336
+ // EMPTY allowlist - `tools: []` - which is different from omitting the option, and the difference
337
+ // matters: omitted means Pi's defaults, empty means none.
338
+ if (!declared.length)
339
+ return { tools: [] };
340
+ const allow = [...declared, ...extra.filter((name) => !declared.includes(name) && !denied.has(name))];
341
+ return { tools: allow };
342
+ }
343
+ const excluded = [...new Set([...deny, ...[...denied].filter((name) => !deny.includes(name))])];
344
+ return excluded.length ? { excludeTools: excluded } : {};
345
+ }
307
346
  /** The tools an allowlist names, for display. Empty when the agent adds no allowlist of its own. */
308
347
  export function allowedToolNames(tools) {
309
348
  if (!isFrontmatter(tools) || tools["*"] !== false)
@@ -64,6 +64,21 @@ export declare class RunService {
64
64
  setMemory(memory: import("../active-memory.js").ActiveMemory): void;
65
65
  reviewMemoryBoundary(conversationId: string): void;
66
66
  private readonly activeRuns;
67
+ /**
68
+ * The completion of the run currently holding a conversation's slot.
69
+ *
70
+ * The slot and the promise are two halves of one fact - "this conversation has a run" and "that run is
71
+ * over" - and they are claimed and released together. A waiter that needs the slot (an interrupt, a
72
+ * send-now) AWAITS the promise rather than polling the map: polling samples a flag on a 10ms clock and
73
+ * has to guess at the state around it, while the promise IS the event, fired by the run's own `finally`.
74
+ */
75
+ private readonly runCompletions;
76
+ /** A selected interrupt owns the next free run slot, even while the old child exits. */
77
+ private readonly interruptTargets;
78
+ /** Claims the run slot and arms its completion promise. Call sites release with `releaseRunSlot`. */
79
+ private claimRunSlot;
80
+ /** Releases the run slot and settles its completion promise, so a waiter wakes on the SAME instruction. */
81
+ private releaseRunSlot;
67
82
  /**
68
83
  * WHERE EACH TOOL CALL LANDED, per conversation, for as long as the turn that made it is still running.
69
84
  *
@@ -162,6 +177,15 @@ export declare class RunService {
162
177
  * Checks whether a conversation has an active turn running.
163
178
  */
164
179
  isRunning(conversationId: string): boolean;
180
+ /**
181
+ * Checks the session credential together with the application-level turn truth.
182
+ *
183
+ * `activeRuns` is set synchronously before `runtime.answer()` is entered and cleared in that same
184
+ * turn's finally block. It is therefore the authoritative answer to "is there a primary turn to act
185
+ * for?". Keeping a second set in the runtime made correctness depend on two lifecycles agreeing and
186
+ * left HTTP-only paths with no owner at all.
187
+ */
188
+ verifyPrimaryCapability(conversationId: string, capability: unknown): import("../capability.js").CapabilityVerdict;
165
189
  /**
166
190
  * Aborts all active conversation runs and sub-agents.
167
191
  */
@@ -174,6 +198,8 @@ export declare class RunService {
174
198
  waitForIdle(): Promise<void>;
175
199
  private trackDetached;
176
200
  private serializeConversation;
201
+ /** Priority begins immediately after messages already handed to Pi, in their FIFO order. */
202
+ private moveAheadOfUnclaimed;
177
203
  /**
178
204
  * Stops an active turn run for a conversation.
179
205
  */
@@ -239,13 +265,31 @@ export declare class RunService {
239
265
  steer(conversationId: string, messageId: string): Promise<void>;
240
266
  /** Starts a chosen waiting message as the next turn, only when the conversation is idle. */
241
267
  sendQueued(conversationId: string, messageId: string): Promise<void>;
268
+ /**
269
+ * Waits for the run holding a conversation's slot to release it, by AWAITING ITS COMPLETION rather
270
+ * than polling a flag.
271
+ *
272
+ * Bounded, because a wedged run must not hang the message the user just asked for: after the deadline
273
+ * the caller proceeds to `processWaitingMessage`, whose slot check then refuses in words. With the
274
+ * session path the wait is short by construction - `runtime.stop()` has already awaited Pi going idle,
275
+ * so what is left is the old run's `finally` unwinding, which is local bookkeeping.
276
+ *
277
+ * `onlyWhileStopped` is `sendQueued`'s contract: it waits out the stop-clearing window after a stop,
278
+ * and nothing more. Waiting out an arbitrarily long RUNNING turn would turn "send now" into "send
279
+ * whenever the current answer finishes", which is what the queue is for.
280
+ */
281
+ private awaitRunSlotReleased;
242
282
  /**
243
283
  * Stops the current turn and immediately gives one waiting message its own fresh turn.
244
284
  *
245
285
  * `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.
286
+ * finishes. Stopping first is what makes a long-running tool give way now.
287
+ *
288
+ * THE HAND-OFF IS AN AWAITED HAND-OFF, not a poll. `stopRun` aborts the turn and - on the session
289
+ * path - `runtime.stop()` has already awaited Pi going idle, so what remains is the old run's own
290
+ * `finally` unwinding. `awaitRunSlotReleased` waits for exactly that event (the run's completion
291
+ * promise), then the selected message opens its turn ON THE SAME SESSION: the pool's fingerprint has
292
+ * not changed, so no process is spawned and no history is replayed.
249
293
  */
250
294
  interruptQueued(conversationId: string, messageId: string): Promise<void>;
251
295
  /**
@@ -121,11 +121,7 @@ function composePrompt(message) {
121
121
  const bodyMatch = /<task_(?:result|error)>\n?([\s\S]*?)\n?<\/(?:task_result|task_error)>/.exec(content);
122
122
  const body = bodyMatch ? bodyMatch[1] : content;
123
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}`;
124
+ return `[后台任务「${summary}」${stateLabel}]\n\n${body}`;
129
125
  }
130
126
  /**
131
127
  * Service managing execution of conversation turns, message queueing,
@@ -147,6 +143,30 @@ export class RunService {
147
143
  this.memory?.review(conversation);
148
144
  }
149
145
  activeRuns = new Map();
146
+ /**
147
+ * The completion of the run currently holding a conversation's slot.
148
+ *
149
+ * The slot and the promise are two halves of one fact - "this conversation has a run" and "that run is
150
+ * over" - and they are claimed and released together. A waiter that needs the slot (an interrupt, a
151
+ * send-now) AWAITS the promise rather than polling the map: polling samples a flag on a 10ms clock and
152
+ * has to guess at the state around it, while the promise IS the event, fired by the run's own `finally`.
153
+ */
154
+ runCompletions = new Map();
155
+ /** A selected interrupt owns the next free run slot, even while the old child exits. */
156
+ interruptTargets = new Map();
157
+ /** Claims the run slot and arms its completion promise. Call sites release with `releaseRunSlot`. */
158
+ claimRunSlot(conversationId, controller) {
159
+ let resolve;
160
+ const promise = new Promise((resolvePromise) => { resolve = resolvePromise; });
161
+ this.runCompletions.set(conversationId, { promise, resolve });
162
+ this.activeRuns.set(conversationId, controller);
163
+ }
164
+ /** Releases the run slot and settles its completion promise, so a waiter wakes on the SAME instruction. */
165
+ releaseRunSlot(conversationId) {
166
+ this.activeRuns.delete(conversationId);
167
+ this.runCompletions.get(conversationId)?.resolve();
168
+ this.runCompletions.delete(conversationId);
169
+ }
150
170
  /**
151
171
  * WHERE EACH TOOL CALL LANDED, per conversation, for as long as the turn that made it is still running.
152
172
  *
@@ -245,15 +265,13 @@ export class RunService {
245
265
  // QUEUE ITEM, NOT A NOTICE. `delivery: "waiting"` is what makes the row show in the dock; without
246
266
  // it the message is a transcript entry only and `takeNextWaitingMessage` (which `runConversationTurns`
247
267
  // calls after every turn) never picks it up.
248
- const queued = await this.store.addToolMessage(task.conversationId, message, { delivery: "waiting" });
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);
268
+ const queued = await this.serializeConversation(task.conversationId, async () => {
269
+ const row = await this.store.addToolMessage(task.conversationId, message, { delivery: "waiting" });
270
+ // Pi already owns every `sending` row. Put a result ahead of unclaimed user messages.
271
+ await this.moveAheadOfUnclaimed(task.conversationId, row.id);
255
272
  this.broadcaster.broadcastState();
256
- }
273
+ return row;
274
+ });
257
275
  // AUTO-TRIGGER WHEN IDLE. The conversation may already be busy (running turn, taking the next
258
276
  // waiting message in its own follow-up loop); if so, the existing loop picks this one up and
259
277
  // this branch is a no-op. If idle, kick off a fresh turn so the model processes the result
@@ -262,7 +280,7 @@ export class RunService {
262
280
  // processes the result" contract, and the auto-triggered turn goes through the same
263
281
  // `composePrompt` path the success case does (so a helper failure surfaces as `state="error"`
264
282
  // prose the agent can react to).
265
- if (!this.activeRuns.has(task.conversationId)) {
283
+ if (!this.activeRuns.has(task.conversationId) && !this.interruptTargets.has(task.conversationId)) {
266
284
  void this.processWaitingMessage(task.conversationId, queued.id).catch((error) => {
267
285
  console.warn(`Super Pi: auto-trigger for background exec result failed: ${error instanceof Error ? error.message : "unknown error"}`);
268
286
  });
@@ -422,10 +440,22 @@ export class RunService {
422
440
  isRunning(conversationId) {
423
441
  return this.activeRuns.has(conversationId);
424
442
  }
443
+ /**
444
+ * Checks the session credential together with the application-level turn truth.
445
+ *
446
+ * `activeRuns` is set synchronously before `runtime.answer()` is entered and cleared in that same
447
+ * turn's finally block. It is therefore the authoritative answer to "is there a primary turn to act
448
+ * for?". Keeping a second set in the runtime made correctness depend on two lifecycles agreeing and
449
+ * left HTTP-only paths with no owner at all.
450
+ */
451
+ verifyPrimaryCapability(conversationId, capability) {
452
+ return verifyCapability(conversationId, capability, { running: this.isRunning(conversationId) });
453
+ }
425
454
  /**
426
455
  * Aborts all active conversation runs and sub-agents.
427
456
  */
428
457
  abortAll() {
458
+ this.interruptTargets.clear();
429
459
  this.memory?.cancelAll();
430
460
  for (const controller of this.activeRuns.values()) {
431
461
  controller.abort();
@@ -464,6 +494,12 @@ export class RunService {
464
494
  this.conversationOps.delete(conversationId); });
465
495
  return next;
466
496
  }
497
+ /** Priority begins immediately after messages already handed to Pi, in their FIFO order. */
498
+ async moveAheadOfUnclaimed(conversationId, messageId) {
499
+ const waiting = this.store.listWaitingMessages(conversationId);
500
+ const lastClaimed = waiting.reduce((last, item, index) => item.delivery === "sending" ? index : last, -1);
501
+ await this.store.reorderWaitingMessage(conversationId, messageId, lastClaimed + 1);
502
+ }
467
503
  /**
468
504
  * Stops an active turn run for a conversation.
469
505
  */
@@ -996,12 +1032,12 @@ export class RunService {
996
1032
  // itself as an ordinary message and now finds the slot taken - its words are on screen with nobody
997
1033
  // going to answer them. Repairing that is what the guard below is for; it is not a rare path, it is
998
1034
  // what two fast sends looks like from the inside.
999
- const busy = this.activeRuns.has(conversation.id);
1035
+ const busy = this.activeRuns.has(conversation.id) || this.interruptTargets.has(conversation.id);
1000
1036
  const message = await this.store.addUserMessage(conversationId, trimmed, validImages, attachments, { clientId, ...(busy ? { delivery: "waiting" } : {}) });
1001
1037
  this.broadcaster.broadcastState();
1002
1038
  // Claim the run slot synchronously (no await between has() and set()) so two
1003
1039
  // rapid sends can never both pass the guard and start concurrent turns.
1004
- if (this.activeRuns.has(conversation.id)) {
1040
+ if (this.activeRuns.has(conversation.id) || this.interruptTargets.has(conversation.id)) {
1005
1041
  if (!busy) {
1006
1042
  // Lost the race described above. No turn has opened on this message and no model has seen it, so
1007
1043
  // calling it waiting is a statement of fact - and it puts the words exactly where a message born
@@ -1012,7 +1048,7 @@ export class RunService {
1012
1048
  return;
1013
1049
  }
1014
1050
  const controller = new AbortController();
1015
- this.activeRuns.set(conversation.id, controller);
1051
+ this.claimRunSlot(conversation.id, controller);
1016
1052
  try {
1017
1053
  let expanded;
1018
1054
  try {
@@ -1095,7 +1131,7 @@ export class RunService {
1095
1131
  throw error;
1096
1132
  }
1097
1133
  finally {
1098
- this.activeRuns.delete(conversation.id);
1134
+ this.releaseRunSlot(conversation.id);
1099
1135
  this.extensionUiService.cancelPendingUi(conversation.id, "expired");
1100
1136
  this.broadcaster.broadcastState();
1101
1137
  }
@@ -1108,26 +1144,68 @@ export class RunService {
1108
1144
  }
1109
1145
  /** Starts a chosen waiting message as the next turn, only when the conversation is idle. */
1110
1146
  async sendQueued(conversationId, messageId) {
1147
+ if (this.interruptTargets.has(conversationId))
1148
+ throw new Error("打断回复正在启动,请等待所选消息开始回答");
1111
1149
  // A stop broadcasts `stopped` before the previous run has finished its finally block. Allow that
1112
1150
  // short cleanup window; a click on the newly visible button must not race the old run slot.
1151
+ await this.awaitRunSlotReleased(conversationId, true);
1152
+ await this.processWaitingMessage(conversationId, messageId);
1153
+ }
1154
+ /**
1155
+ * Waits for the run holding a conversation's slot to release it, by AWAITING ITS COMPLETION rather
1156
+ * than polling a flag.
1157
+ *
1158
+ * Bounded, because a wedged run must not hang the message the user just asked for: after the deadline
1159
+ * the caller proceeds to `processWaitingMessage`, whose slot check then refuses in words. With the
1160
+ * session path the wait is short by construction - `runtime.stop()` has already awaited Pi going idle,
1161
+ * so what is left is the old run's `finally` unwinding, which is local bookkeeping.
1162
+ *
1163
+ * `onlyWhileStopped` is `sendQueued`'s contract: it waits out the stop-clearing window after a stop,
1164
+ * and nothing more. Waiting out an arbitrarily long RUNNING turn would turn "send now" into "send
1165
+ * whenever the current answer finishes", which is what the queue is for.
1166
+ */
1167
+ async awaitRunSlotReleased(conversationId, onlyWhileStopped = false) {
1113
1168
  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));
1169
+ while (this.activeRuns.has(conversationId) && Date.now() < deadline) {
1170
+ if (onlyWhileStopped && this.store.snapshot().conversations.find((item) => item.id === conversationId)?.status !== "stopped")
1171
+ return;
1172
+ const completion = this.runCompletions.get(conversationId)?.promise;
1173
+ if (!completion)
1174
+ return;
1175
+ // The completion usually wins this race by a wide margin; the timer is what re-checks the
1176
+ // `onlyWhileStopped` condition if the run keeps going, and what enforces the deadline.
1177
+ await Promise.race([completion, new Promise((resolve) => setTimeout(resolve, Math.min(500, Math.max(1, deadline - Date.now()))))]);
1116
1178
  }
1117
- await this.processWaitingMessage(conversationId, messageId);
1118
1179
  }
1119
1180
  /**
1120
1181
  * Stops the current turn and immediately gives one waiting message its own fresh turn.
1121
1182
  *
1122
1183
  * `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.
1184
+ * finishes. Stopping first is what makes a long-running tool give way now.
1185
+ *
1186
+ * THE HAND-OFF IS AN AWAITED HAND-OFF, not a poll. `stopRun` aborts the turn and - on the session
1187
+ * path - `runtime.stop()` has already awaited Pi going idle, so what remains is the old run's own
1188
+ * `finally` unwinding. `awaitRunSlotReleased` waits for exactly that event (the run's completion
1189
+ * promise), then the selected message opens its turn ON THE SAME SESSION: the pool's fingerprint has
1190
+ * not changed, so no process is spawned and no history is replayed.
1126
1191
  */
1127
1192
  async interruptQueued(conversationId, messageId) {
1128
- if (this.activeRuns.has(conversationId))
1129
- await this.stopRun(conversationId);
1130
- await this.sendQueued(conversationId, messageId);
1193
+ if (this.interruptTargets.has(conversationId))
1194
+ throw new Error("已有一条打断回复正在启动,请稍候");
1195
+ const selected = this.store.listWaitingMessages(conversationId).find((item) => item.id === messageId);
1196
+ if (!selected || selected.delivery !== "waiting")
1197
+ throw new Error("这条消息已不在等待队列中,无法打断并回答");
1198
+ this.interruptTargets.set(conversationId, messageId);
1199
+ try {
1200
+ if (this.activeRuns.has(conversationId))
1201
+ await this.stopRun(conversationId);
1202
+ await this.awaitRunSlotReleased(conversationId);
1203
+ await this.processWaitingMessage(conversationId, messageId);
1204
+ }
1205
+ finally {
1206
+ if (this.interruptTargets.get(conversationId) === messageId)
1207
+ this.interruptTargets.delete(conversationId);
1208
+ }
1131
1209
  }
1132
1210
  /**
1133
1211
  * The lower-level "wait for run slot, open the turn on a specific waiting message, run it" sequence.
@@ -1145,59 +1223,48 @@ export class RunService {
1145
1223
  * transcript, but the prose form of the same fact is what reaches Pi.
1146
1224
  */
1147
1225
  async processWaitingMessage(conversationId, messageId) {
1148
- return this.serializeConversation(conversationId, async () => {
1226
+ // Serialize only the run claim. Holding the command lane for the entire Pi turn makes every
1227
+ // steer/reorder/cancel wait until that turn ends, which is especially visible after a background
1228
+ // result auto-starts a long follow-up. The activeRuns slot protects the turn after this lane exits.
1229
+ const controller = await this.serializeConversation(conversationId, async () => {
1230
+ const reserved = this.interruptTargets.get(conversationId);
1231
+ if (reserved && reserved !== messageId)
1232
+ throw new Error("打断回复正在启动,请等待所选消息开始回答");
1149
1233
  if (this.activeRuns.has(conversationId))
1150
1234
  throw new Error("当前轮仍在运行;停止或等待它结束后再发送这条消息");
1235
+ const message = this.store.listWaitingMessages(conversationId).find((item) => item.id === messageId);
1236
+ if (!message || message.delivery !== "waiting")
1237
+ throw new Error("等待中的消息不存在或已被处理");
1151
1238
  const controller = new AbortController();
1152
- this.activeRuns.set(conversationId, controller);
1153
- try {
1154
- const { conversation, workspace } = this.turnContext(conversationId);
1155
- const message = conversation.messages.find((item) => item.id === messageId);
1156
- if (!message)
1157
- throw new Error("消息不存在或已被撤回");
1158
- // Do not call openTurnOnSpecificMessage here. runConversationTurns opens this exact first
1159
- // message before streaming it, which creates one assistant placeholder and one only.
1160
- await this.runConversationTurns(conversation, workspace, composePrompt(message), controller, message.id, message.images);
1161
- await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "completed");
1162
- }
1163
- catch (error) {
1164
- await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "failed");
1165
- if (!controller.signal.aborted) {
1166
- await this.store.setFailureReason(conversationId, formatTurnFailure(error));
1167
- }
1168
- // A preflight failure must put the message BACK where the reader can see it. Re-adding it would
1169
- // duplicate the row that is still in the transcript, so this only re-marks the one that is
1170
- // there - which is what makes "the send failed, this row is still in the queue" a true statement.
1171
- await this.store.releaseWaitingMessage(conversationId, messageId).catch(() => undefined);
1172
- throw error;
1173
- }
1174
- finally {
1175
- this.activeRuns.delete(conversationId);
1176
- this.extensionUiService.cancelPendingUi(conversationId, "expired");
1177
- this.broadcaster.broadcastState();
1178
- }
1239
+ this.claimRunSlot(conversationId, controller);
1240
+ return controller;
1179
1241
  });
1180
- }
1181
- async steerUnsafe(conversationId, messageId) {
1182
- // MOVED TO THE FRONT FIRST, ALWAYS - AND THE MOVE IS BEST-EFFORT.
1183
- //
1184
- // The move is what makes "插到最前" mean what it says rather than leaving a flag nobody can explain.
1185
- // But the move CAN fail, and the case where it fails is a real one a person causes on purpose: two
1186
- // steers in a row. The first message is already in Pi's stdin, nothing can be placed in front of it,
1187
- // and the second click means "and this one too". Refusing it would break a capability the old
1188
- // implementation had and would leave the person with a message that cannot be sent at all until the
1189
- // turn ends - which is the thing they were trying to avoid by clicking twice.
1190
- //
1191
- // So the refusal is swallowed HERE and only here: being second in Pi's own FIFO queue is still a jump
1192
- // past everything else that is merely waiting, which is what the second click was asking for. The
1193
- // `message.reorder` command does not swallow it, because a button that says 「插到最前」 and silently
1194
- // did not move anything is the promise this refactor exists to stop making.
1195
1242
  try {
1196
- await this.store.reorderWaitingMessage(conversationId, messageId, 0);
1243
+ const { conversation, workspace } = this.turnContext(conversationId);
1244
+ const message = conversation.messages.find((item) => item.id === messageId);
1245
+ if (!message)
1246
+ throw new Error("消息不存在或已被撤回");
1247
+ // runConversationTurns opens this first message; do not create a second assistant placeholder.
1248
+ await this.runConversationTurns(conversation, workspace, composePrompt(message), controller, message.id, message.images);
1249
+ await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "completed");
1197
1250
  }
1198
- catch {
1199
- // Nothing to do: the claim below is what actually delivers it, and Pi takes steers in order.
1251
+ catch (error) {
1252
+ await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "failed");
1253
+ if (!controller.signal.aborted)
1254
+ await this.store.setFailureReason(conversationId, formatTurnFailure(error));
1255
+ throw error;
1200
1256
  }
1257
+ finally {
1258
+ this.releaseRunSlot(conversationId);
1259
+ this.extensionUiService.cancelPendingUi(conversationId, "expired");
1260
+ this.broadcaster.broadcastState();
1261
+ }
1262
+ }
1263
+ async steerUnsafe(conversationId, messageId) {
1264
+ if (this.interruptTargets.has(conversationId))
1265
+ throw new Error("打断回复正在启动,请稍后再插队");
1266
+ // Already-sent Pi steers are FIFO. Put this row directly behind them, ahead of unclaimed rows.
1267
+ await this.moveAheadOfUnclaimed(conversationId, messageId);
1201
1268
  this.broadcaster.broadcastState();
1202
1269
  if (!this.runtime.steer)
1203
1270
  return;
@@ -1231,6 +1298,8 @@ export class RunService {
1231
1298
  return this.serializeConversation(conversationId, () => this.cancelQueuedUnsafe(conversationId, messageId));
1232
1299
  }
1233
1300
  async cancelQueuedUnsafe(conversationId, messageId) {
1301
+ if (this.interruptTargets.get(conversationId) === messageId)
1302
+ throw new Error("这条消息正在打断并回答,无法撤回");
1234
1303
  await this.store.cancelWaitingMessage(conversationId, messageId);
1235
1304
  this.broadcaster.broadcastState();
1236
1305
  }
@@ -1239,6 +1308,8 @@ export class RunService {
1239
1308
  */
1240
1309
  async editQueued(conversationId, messageId, content) {
1241
1310
  return this.serializeConversation(conversationId, async () => {
1311
+ if (this.interruptTargets.get(conversationId) === messageId)
1312
+ throw new Error("这条消息正在打断并回答,无法修改");
1242
1313
  await this.store.editWaitingMessage(conversationId, messageId, content);
1243
1314
  this.broadcaster.broadcastState();
1244
1315
  });
@@ -1248,6 +1319,8 @@ export class RunService {
1248
1319
  */
1249
1320
  async reorderQueued(conversationId, messageId, toIndex) {
1250
1321
  return this.serializeConversation(conversationId, async () => {
1322
+ if (this.interruptTargets.get(conversationId) === messageId)
1323
+ throw new Error("这条消息正在打断并回答,无法移动");
1251
1324
  await this.store.reorderWaitingMessage(conversationId, messageId, toIndex);
1252
1325
  this.broadcaster.broadcastState();
1253
1326
  });
@@ -1288,7 +1361,7 @@ export class RunService {
1288
1361
  // BEFORE anything else, including before the conversation is looked up: a request that cannot prove it
1289
1362
  // came from this server's primary agent gets no answer about what exists. Everything below this line
1290
1363
  // is about shaping a dispatch; this line is about whether the caller may have one at all.
1291
- const capability = verifyCapability(conversationId, body.capability);
1364
+ const capability = this.verifyPrimaryCapability(conversationId, body.capability);
1292
1365
  if (!capability.ok)
1293
1366
  throw new Error(capability.message);
1294
1367
  if (body.depth !== undefined)
@@ -1344,14 +1417,13 @@ export class RunService {
1344
1417
  // still the 后台回传 column.
1345
1418
  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>`;
1346
1419
  try {
1347
- const queued = await this.store.addToolMessage(conversation.id, message, { delivery: "waiting" });
1348
- this.broadcaster.broadcastState();
1349
- const waiting = this.store.listWaitingMessages(conversation.id);
1350
- if (waiting.length > 1) {
1351
- await this.store.reorderWaitingMessage(conversation.id, queued.id, 0);
1420
+ const queued = await this.serializeConversation(conversation.id, async () => {
1421
+ const row = await this.store.addToolMessage(conversation.id, message, { delivery: "waiting" });
1422
+ await this.moveAheadOfUnclaimed(conversation.id, row.id);
1352
1423
  this.broadcaster.broadcastState();
1353
- }
1354
- if (!this.activeRuns.has(conversation.id)) {
1424
+ return row;
1425
+ });
1426
+ if (!this.activeRuns.has(conversation.id) && !this.interruptTargets.has(conversation.id)) {
1355
1427
  void this.processWaitingMessage(conversation.id, queued.id).catch((error) => {
1356
1428
  console.warn(`Super Pi: auto-trigger for background sub-agent result failed: ${error instanceof Error ? error.message : "unknown error"}`);
1357
1429
  });
@@ -1365,13 +1437,11 @@ export class RunService {
1365
1437
  }).catch(async (error) => {
1366
1438
  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>`;
1367
1439
  try {
1368
- const queued = await this.store.addToolMessage(conversationId, message, { delivery: "waiting" });
1369
- this.broadcaster.broadcastState();
1370
- const waiting = this.store.listWaitingMessages(conversationId);
1371
- if (waiting.length > 1) {
1372
- await this.store.reorderWaitingMessage(conversationId, queued.id, 0);
1440
+ await this.serializeConversation(conversationId, async () => {
1441
+ const queued = await this.store.addToolMessage(conversationId, message, { delivery: "waiting" });
1442
+ await this.moveAheadOfUnclaimed(conversationId, queued.id);
1373
1443
  this.broadcaster.broadcastState();
1374
- }
1444
+ });
1375
1445
  // No auto-trigger on this failure branch (the result envelope says `state="error"` and the
1376
1446
  // conversation is by construction not in a turn that asked for it). The row is visible in the
1377
1447
  // dock for the reader to send-now or cancel.
@@ -1424,7 +1494,7 @@ export class RunService {
1424
1494
  // Same rule as `dispatchTask`, and for a larger reason: this endpoint triggers a SEQUENCE of
1425
1495
  // dispatches, so a caller that cannot prove it is the primary agent would get an unattended pipeline
1426
1496
  // of sub-agents writing to the workspace rather than one delegation.
1427
- const capability = verifyCapability(conversationId, body.capability);
1497
+ const capability = this.verifyPrimaryCapability(conversationId, body.capability);
1428
1498
  if (!capability.ok)
1429
1499
  throw new Error(capability.message);
1430
1500
  const { conversation, cwd } = this.dispatchContext(conversationId);
@@ -36,27 +36,41 @@
36
36
  */
37
37
  export declare const TASK_CAPABILITY_ENV = "SUPER_PI_TASK_CAPABILITY";
38
38
  /**
39
- * Mints the capability for one turn of one conversation, and returns it for the extension's environment.
39
+ * Mints the capability for one conversation's primary session, and returns it for the extension's
40
+ * environment.
40
41
  *
41
- * CALLED AT SPAWN, so the value is decided before the child exists and cannot be changed underneath it.
42
- * A turn that never reaches the spawn mints nothing, which is correct: there is no process to hand it to.
42
+ * CALLED WHEN THE SESSION IS CREATED, so the value is decided before the session exists and cannot be
43
+ * changed underneath it. A session that is never created mints nothing, which is correct: there is
44
+ * nothing to hand it to.
43
45
  *
44
- * ROTATED EVERY TURN, and that is the property the duration would not give on its own. A token valid for
45
- * the whole server lifetime would still be a token an unrelated process could not guess - but a token
46
- * valid only while its holder is alive is a token that stops being a credential the moment the turn ends,
47
- * so there is no window in which a leaked value is still live.
46
+ * ROTATED EVERY SESSION, and that is the property the duration would not give on its own. A token valid
47
+ * for the whole server lifetime would still be a token an unrelated process could not guess - but a token
48
+ * valid only while its holder is alive is a token that stops being a credential the moment the session
49
+ * ends, so there is no window in which a leaked value is still live.
50
+ *
51
+ * WHY IT IS THE SESSION AND NOT THE TURN. The capability travels in the extension's ENVIRONMENT, and an
52
+ * extension reads its environment when it is LOADED. A session that outlives its turns loads its
53
+ * extension once, so a per-turn token would have to be re-read from an environment that cannot change -
54
+ * and the second turn of a conversation would carry the FIRST turn's token, which the server would
55
+ * (correctly) refuse. The session is therefore the unit the credential belongs to, and the server
56
+ * enforces the turn separately. The application service owns the authoritative active-turn map, and
57
+ * passes that fact to `verifyCapability` at each protected endpoint. A stolen token is therefore worth
58
+ * nothing to a caller that cannot also start a turn.
48
59
  */
49
60
  export declare function mintCapability(conversationId: string): string;
50
61
  /**
51
- * Forgets a conversation's capability, at the end of its turn.
62
+ * Forgets a conversation's capability, when its session ends.
52
63
  *
53
- * CALLED FROM `answer()`'s `finally`, so it runs on every exit - a turn that answered, a turn that threw,
54
- * and a turn whose child was killed. A capability that outlived its turn would keep working for a process
55
- * that no longer exists, which is precisely the shape of a credential with no owner.
64
+ * CALLED WHEN THE SESSION IS DESTROYED, so it runs on every exit - a session that was evicted as idle, a
65
+ * session that was thrown away by a revert, a session that was replaced because the conversation's
66
+ * configuration changed, and a session that was disposed at shutdown. A capability that outlived its
67
+ * session would keep working for a session that no longer exists, which is precisely the shape of a
68
+ * credential with no owner.
56
69
  */
57
70
  export declare function releaseCapability(conversationId: string): void;
58
71
  /** Forgets every capability. Called on shutdown, so nothing survives the process that minted it. */
59
72
  export declare function releaseAllCapabilities(): void;
73
+ /**
60
74
  /**
61
75
  * The capability currently valid for a conversation, or `undefined` when no primary turn is running.
62
76
  *
@@ -86,4 +100,6 @@ export type CapabilityVerdict = {
86
100
  * silently omitted the header would otherwise be indistinguishable from an attacker, and would keep
87
101
  * retrying forever.
88
102
  */
89
- export declare function verifyCapability(conversationId: string, presented: unknown): CapabilityVerdict;
103
+ export declare function verifyCapability(conversationId: string, presented: unknown, options?: {
104
+ running?: boolean;
105
+ }): CapabilityVerdict;