@evandu/super-pi 0.1.133 → 0.1.136

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.
Files changed (63) hide show
  1. package/dist/.super-pi-check-inputs.json +2 -2
  2. package/dist/client/.super-pi-client-sources.json +6 -6
  3. package/dist/client/assets/{CodeMirrorEditor-CZZfGD0U.js → CodeMirrorEditor-CWN8JIx4.js} +2 -2
  4. package/dist/client/assets/{dist-D4x1XTQb.js → dist-0vYGgCC5.js} +1 -1
  5. package/dist/client/assets/{dist-CFPXj4ws.js → dist-B0vHKLg1.js} +1 -1
  6. package/dist/client/assets/{dist-4Pts9xPO.js → dist-BA6BeFaf.js} +1 -1
  7. package/dist/client/assets/{dist-BtTFCNVQ.js → dist-BQjtlfpY.js} +1 -1
  8. package/dist/client/assets/{dist-CUKaDTyO.js → dist-BSOBu3lQ.js} +1 -1
  9. package/dist/client/assets/{dist-CYD2SqmG.js → dist-BtNKV30Z.js} +1 -1
  10. package/dist/client/assets/{dist-DHqqpxo4.js → dist-CTXz1R9C.js} +1 -1
  11. package/dist/client/assets/{dist-BffE00vI.js → dist-CV9QN-qU.js} +1 -1
  12. package/dist/client/assets/{dist-azDN8a65.js → dist-CYLUz40X.js} +1 -1
  13. package/dist/client/assets/{dist-aL2NGji_.js → dist-CwNZuQ4i.js} +1 -1
  14. package/dist/client/assets/{dist-CApAttfj.js → dist-DCuAT0n-.js} +1 -1
  15. package/dist/client/assets/{dist-DOYWHMIq.js → dist-DErmxG4Q.js} +1 -1
  16. package/dist/client/assets/{dist-DQf5nNxa.js → dist-DHTyhm7w.js} +1 -1
  17. package/dist/client/assets/{dist-fYp8Xwrx.js → dist-DMDxxHpj.js} +1 -1
  18. package/dist/client/assets/{dist-B5ruVtUG.js → dist-Dalb5yHq.js} +1 -1
  19. package/dist/client/assets/{dist-0FH-oFco.js → dist-OKarkelj.js} +1 -1
  20. package/dist/client/assets/{dist-DbbBgSwL.js → dist-Qc0xt1y7.js} +1 -1
  21. package/dist/client/assets/{dist-CxsunljU.js → dist-WkcafMsD.js} +1 -1
  22. package/dist/client/assets/{dist-CE5BnwJa.js → dist-ZvUavnTX.js} +1 -1
  23. package/dist/client/assets/{dist-BJinarhP.js → dist-q1T0uV1c.js} +1 -1
  24. package/dist/client/assets/{dist-C2nuBCP4.js → dist-t0oHfWqq.js} +1 -1
  25. package/dist/client/assets/{dist-LFfokYJs.js → dist-yOveRczL.js} +1 -1
  26. package/dist/client/assets/{index-YEN4ysHF.js → index-oDRgSKPX.js} +37 -37
  27. package/dist/client/assets/index-r-L_XCYs.css +1 -0
  28. package/dist/client/index.html +2 -2
  29. package/dist/server/active-memory.d.ts +18 -2
  30. package/dist/server/active-memory.js +73 -8
  31. package/dist/server/application/conversation-service.js +4 -2
  32. package/dist/server/application/run-service.d.ts +4 -0
  33. package/dist/server/application/run-service.js +114 -93
  34. package/dist/server/application/scheduled-task-service.d.ts +7 -0
  35. package/dist/server/application/scheduled-task-service.js +14 -5
  36. package/dist/server/application/workspace-service.d.ts +8 -4
  37. package/dist/server/application/workspace-service.js +11 -10
  38. package/dist/server/failure-text.d.ts +18 -0
  39. package/dist/server/failure-text.js +44 -0
  40. package/dist/server/files.js +11 -1
  41. package/dist/server/http-server.js +16 -5
  42. package/dist/server/index.js +5 -2
  43. package/dist/server/knowledge-embedding-worker.d.ts +1 -0
  44. package/dist/server/knowledge-embedding-worker.js +27 -0
  45. package/dist/server/knowledge-vectors.d.ts +5 -3
  46. package/dist/server/knowledge-vectors.js +49 -47
  47. package/dist/server/knowledge.d.ts +1 -0
  48. package/dist/server/knowledge.js +6 -2
  49. package/dist/server/model-filter.d.ts +13 -3
  50. package/dist/server/model-filter.js +7 -5
  51. package/dist/server/persistence/sqlite-state-repository.d.ts +0 -1
  52. package/dist/server/persistence/sqlite-state-repository.js +37 -1
  53. package/dist/server/providers.d.ts +13 -0
  54. package/dist/server/providers.js +27 -2
  55. package/dist/server/runtime.js +46 -0
  56. package/dist/server/store.d.ts +83 -0
  57. package/dist/server/store.js +140 -14
  58. package/dist/server/subagents.js +10 -2
  59. package/dist/server/types.d.ts +10 -0
  60. package/dist/server/ws-server.d.ts +3 -0
  61. package/dist/server/ws-server.js +35 -6
  62. package/package.json +21 -2
  63. package/dist/client/assets/index-D3MEG8uH.css +0 -1
@@ -9,6 +9,13 @@ export class ActiveMemory {
9
9
  inverted = new Map();
10
10
  indexedWorkspaces = new Set();
11
11
  indexedMessages = new Set();
12
+ /**
13
+ * messageId -> which workspace's index holds it and under which terms. This is the reverse side of
14
+ * `inverted`, and it is what makes removal surgical: a revert knows exactly which messages it cut
15
+ * (the store's `plan.removedMessageIds`), so exactly those postings can be dropped - the rest of the
16
+ * workspace stays indexed instead of paying a full rebuild on the next search.
17
+ */
18
+ termsByMessage = new Map();
12
19
  notes;
13
20
  capabilities = new Map();
14
21
  reviews = new Map();
@@ -109,10 +116,14 @@ export class ActiveMemory {
109
116
  release(token) { this.capabilities.delete(token); }
110
117
  identity(token) {
111
118
  const identity = this.capabilities.get(token);
112
- if (!identity || !this.store.snapshot().workspaces.some(w => w.id === identity.workspaceId))
119
+ if (!identity || !this.hasWorkspace(identity.workspaceId))
113
120
  throw new Error("记忆授权已失效");
114
121
  return identity;
115
122
  }
123
+ hasWorkspace(workspaceId) {
124
+ const store = this.store;
125
+ return store.hasWorkspace ? store.hasWorkspace(workspaceId) : store.snapshot().workspaces.some(w => w.id === workspaceId);
126
+ }
116
127
  search(workspaceId, query, sessionId) {
117
128
  const words = query.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu)?.slice(0, 12) ?? [];
118
129
  if (!words.length)
@@ -134,7 +145,9 @@ export class ActiveMemory {
134
145
  continue;
135
146
  const value = { conversationId: conversation.id, title: conversation.title, messageId: message.id, role: message.role, content: message.content };
136
147
  this.indexedMessages.add(message.id);
137
- for (const word of message.content.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) ?? []) {
148
+ const terms = message.content.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) ?? [];
149
+ this.termsByMessage.set(message.id, { workspaceId, terms });
150
+ for (const word of terms) {
138
151
  let bucket = index.get(word);
139
152
  if (!bucket)
140
153
  index.set(word, bucket = new Map());
@@ -145,7 +158,7 @@ export class ActiveMemory {
145
158
  this.indexedWorkspaces.add(workspaceId);
146
159
  }
147
160
  /**
148
- * Drops a workspace's search index so the next search rebuilds it from the store.
161
+ * Drops exactly the given messages from the search index, leaving the rest of the workspace indexed.
149
162
  *
150
163
  * WHY THIS EXISTS. `ensureIndex` builds once per workspace and `addToIndex` only ever adds, so the
151
164
  * index outlives the messages it indexed: a revert removes messages from the store while the index
@@ -154,17 +167,54 @@ export class ActiveMemory {
154
167
  * history" after a revert every file-level assertion proved had really truncated. The store is the
155
168
  * source of truth; the index is a cache of it, and a cache that survives an event that invalidated
156
169
  * its source is wrong by definition.
170
+ *
171
+ * WHY SURGICAL. The caller (revert) knows precisely which message ids the store dropped, and this
172
+ * method trusts that list rather than re-deriving it: the ids not in `termsByMessage` were never
173
+ * indexed (no search had built the index, or a role the index skips), so a no-op for them is
174
+ * correct, and the next search - if the index was never built - builds from the post-revert store
175
+ * anyway. Dropping the whole workspace index instead would force the next search to rebuild every
176
+ * surviving conversation's entries for no reason, and the recall gap in between would answer
177
+ * searches with nothing at all.
157
178
  */
158
- invalidateSearchIndex(workspaceId) {
159
- this.indexedWorkspaces.delete(workspaceId);
160
- this.inverted.delete(workspaceId);
179
+ removeFromIndex(messageIds) {
180
+ for (const messageId of messageIds) {
181
+ const entry = this.termsByMessage.get(messageId);
182
+ if (!entry)
183
+ continue;
184
+ const terms = this.inverted.get(entry.workspaceId);
185
+ if (terms) {
186
+ for (const term of entry.terms) {
187
+ const bucket = terms.get(term);
188
+ if (!bucket)
189
+ continue;
190
+ bucket.delete(messageId);
191
+ if (!bucket.size)
192
+ terms.delete(term);
193
+ }
194
+ }
195
+ this.termsByMessage.delete(messageId);
196
+ this.indexedMessages.delete(messageId);
197
+ }
161
198
  }
162
199
  addToIndex(conversation, message) {
163
200
  if (!this.indexedWorkspaces.has(conversation.workspaceId) || (message.role !== "user" && message.role !== "assistant"))
164
201
  return;
165
202
  let terms = this.inverted.get(conversation.workspaceId);
166
203
  const value = { conversationId: conversation.id, title: conversation.title, messageId: message.id, role: message.role, content: message.content };
167
- for (const term of message.content.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) ?? []) {
204
+ const messageTerms = message.content.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) ?? [];
205
+ // A REFRESH MUST ALSO FORGET THE OLD TERMS. The buckets map a term to the message that contains
206
+ // it, and a term that only the OLD content had would keep pointing at this message with a stale
207
+ // copy - so a search for a word the model has since dropped would still hand the reader the old
208
+ // text. The terms this message had are remembered exactly so they can be dropped here.
209
+ const previous = this.termsByMessage.get(message.id);
210
+ if (previous)
211
+ for (const term of previous.terms) {
212
+ const bucket = terms.get(term);
213
+ if (bucket)
214
+ bucket.delete(message.id);
215
+ }
216
+ this.termsByMessage.set(message.id, { workspaceId: conversation.workspaceId, terms: messageTerms });
217
+ for (const term of messageTerms) {
168
218
  let bucket = terms.get(term);
169
219
  if (!bucket)
170
220
  terms.set(term, bucket = new Map());
@@ -232,7 +282,7 @@ export class ActiveMemory {
232
282
  return alreadyLocked ? work() : this.locked(work);
233
283
  }
234
284
  async manage(workspaceId, body) {
235
- if (!this.store.snapshot().workspaces.some(w => w.id === workspaceId))
285
+ if (!this.hasWorkspace(workspaceId))
236
286
  throw new Error("工作区不存在");
237
287
  if (body.action === "configure") {
238
288
  await this.configure(body.settings);
@@ -266,6 +316,21 @@ export class ActiveMemory {
266
316
  this.cancel(id); }
267
317
  async waitForIdle() { await Promise.allSettled([...this.workspaceReviews.values()]); await this.serial; }
268
318
  async completed(conversation) {
319
+ // REFRESH THIS TURN'S MESSAGES, DO NOT JUST FILL IN THE UNSEEN ONES.
320
+ //
321
+ // The index is a CACHE of the store's content, and an assistant message's content changes while
322
+ // the model is still writing it: the entry was built from the empty placeholder the turn opened
323
+ // with, so it holds the answer BEFORE the answer. "I have seen this message" is not "I have seen
324
+ // its final content" - treating the two as the same froze this turn's answer in the index, and a
325
+ // frozen answer is an answer that can never be recalled (measured by
326
+ // scripts/active-memory-index-content.test.mjs: the recall came back `undefined`).
327
+ //
328
+ // Only THIS TURN's messages are refreshed - the ones after the last user message. The rest of the
329
+ // transcript was indexed when it was final, and re-tokenizing a whole conversation on every turn
330
+ // end is exactly the cost this file is about.
331
+ const lastUserIndex = conversation.messages.map((message) => message.role).lastIndexOf("user");
332
+ for (const message of lastUserIndex >= 0 ? conversation.messages.slice(lastUserIndex + 1) : conversation.messages)
333
+ this.addToIndex(conversation, message);
269
334
  for (const message of conversation.messages)
270
335
  if (!this.indexedMessages.has(message.id))
271
336
  this.addToIndex(conversation, message);
@@ -359,8 +359,10 @@ export class ConversationService {
359
359
  // The recall index is a cache of the store's messages, and the store just lost several: left alone it
360
360
  // keeps answering the memory extension's searches with the withdrawn turns, so the model is told about
361
361
  // history the person reverted away even though the transcript, Pi's file and the memory store all
362
- // agree it is gone. Invalidate it and the next search rebuilds from what actually survived.
363
- this.memory?.invalidateSearchIndex(workspaceId);
362
+ // agree it is gone. The store's plan says exactly which message ids were dropped, so exactly those
363
+ // postings leave the index - the surviving conversations of this workspace stay indexed and the next
364
+ // search pays no rebuild.
365
+ this.memory?.removeFromIndex(applied.removedMessageIds);
364
366
  console.log(`Super Pi: reverted conversation ${conversationId} to ${applied.boundaryIndex + 1} message(s): ` +
365
367
  `${plan.removedMessageIds.length} message(s) and ${applied.doomedRunIds.length} sub-agent run(s) dropped from state.json; ` +
366
368
  `Pi's file ${outcome.sessionFile} kept ${outcome.linesKept} line(s) and lost ${outcome.entriesDropped}; ` +
@@ -61,6 +61,7 @@ export declare class RunService {
61
61
  private readonly extensionUiService;
62
62
  private agentsDir;
63
63
  private readonly workflows;
64
+ private readonly questionWaiters;
64
65
  private memory?;
65
66
  setMemory(memory: import("../active-memory.js").ActiveMemory): void;
66
67
  /**
@@ -178,6 +179,9 @@ export declare class RunService {
178
179
  answers?: QuestionAnswer[];
179
180
  cancelled?: boolean;
180
181
  }): Promise<void>;
182
+ private questionKey;
183
+ private answerPayload;
184
+ private settleQuestion;
181
185
  /**
182
186
  * THE `question` TOOL BLOCKS HERE, ON THE CALL THAT ASKED IT.
183
187
  *
@@ -6,6 +6,7 @@ import { verifyCapability } from "../capability.js";
6
6
  import { validateWorkflowPlan, workflowSummary } from "../workflow.js";
7
7
  import { ensureNameIndex } from "../file-name-index.js";
8
8
  import { formatTurnFailure } from "../turn-failure.js";
9
+ import { describeThrownWithStack } from "../failure-text.js";
9
10
  import { execFile } from "node:child_process";
10
11
  import { promisify } from "node:util";
11
12
  const execFileAsync = promisify(execFile);
@@ -84,6 +85,7 @@ export class RunService {
84
85
  extensionUiService;
85
86
  agentsDir;
86
87
  workflows;
88
+ questionWaiters = new Map();
87
89
  memory;
88
90
  setMemory(memory) { this.memory = memory; }
89
91
  /**
@@ -238,52 +240,32 @@ export class RunService {
238
240
  this.broadcaster.broadcastState();
239
241
  return row;
240
242
  });
241
- // AUTO-TRIGGER WHEN IDLE. The conversation may already be busy (running turn, taking the next
242
- // waiting message in its own follow-up loop); if so, the existing loop picks this one up and
243
- // this branch is a no-op. If idle, kick off a fresh turn so the model processes the result
244
- // without the user having to send anything - the failure case is intentionally included: the
245
- // model seeing "[后台任务 X 失败]" is part of the same "after current AI finishes, automatically
246
- // processes the result" contract, and the auto-triggered turn goes through the same
247
- // `composePrompt` path the success case does (so a helper failure surfaces as `state="error"`
248
- // prose the agent can react to).
249
- // EXACTLY ONE DELIVERY PER RESULT. The custom channel hands the result straight to Pi's own
250
- // follow-up queue, but it knows nothing about the `waiting` row queued above, and
251
- // `takeNextWaitingMessage` knows nothing about Pi's queue - so delivering through the channel
252
- // while leaving the row waiting gave the model the SAME result twice (once as the custom
253
- // message, once as the row's own prose turn). Measured by
254
- // scripts/background-result-delivery.test.mjs. So a successful custom delivery CONSUMES the row,
255
- // and a failed one - `SteerUnavailableError`, which is what oneshot mode and a not-yet-created
256
- // session throw - falls back to the queue path, the same semantics the sub-agent result path
257
- // (processWaitingMessage only) has always had.
258
- let deliveredViaCustom = false;
259
- const custom = this.runtime.sendCustomMessage;
260
- if (custom) {
261
- try {
262
- await custom.call(this.runtime, task.conversationId, {
263
- customType: "super-pi-task-result",
264
- content: body,
265
- display: "后台任务结果",
266
- details: { taskId: sessionId, source: "exec", state: exitCode === 0 ? "completed" : "error", summary: task.title },
267
- });
268
- deliveredViaCustom = true;
269
- }
270
- catch (error) {
271
- console.warn(`Super Pi: custom delivery of the exec result failed, the queue path takes over: ${error instanceof Error ? error.message : "unknown error"}`);
272
- }
273
- }
274
- if (deliveredViaCustom) {
275
- await this.store.takeWaitingMessage(task.conversationId, queued.id);
276
- }
277
- else if (!this.activeRuns.has(task.conversationId) && !this.interruptTargets.has(task.conversationId)) {
243
+ // THE RESULT WAITS IN SUPER PI'S OWN QUEUE, AND THE QUEUE IS DRAINED ONE TURN AT A TIME.
244
+ //
245
+ // It used to go to Pi's own follow-up queue, and Pi DRAINS that queue all at once when a turn
246
+ // ends - the pending messages are collected into one array and fed to the model as a single
247
+ // turn. So two background results that finished close together were handed over TOGETHER, and
248
+ // the agent answered both at once (ledger TEST-033). That path also had a second defect of its
249
+ // own: Pi's queue knows nothing about the `waiting` row queued above, so delivering through it
250
+ // while leaving the row waiting gave the model the SAME result twice (ledger TEST-011).
251
+ //
252
+ // So the result is not delivered through that channel at all. It stays a `waiting` row, and
253
+ // Super Pi's own loop takes waiting messages ONE PER TURN - which is what that loop was built
254
+ // for, and what the sub-agent result path has always done.
255
+ //
256
+ // IDLE: a turn of its own starts now, so the model processes the result without the user
257
+ // having to send anything. The failure case is included on purpose: the model seeing
258
+ // "[后台任务 X 失败]" is part of the same "after the current answer finishes, the result is
259
+ // processed automatically" contract, and the auto-triggered turn goes through the same
260
+ // `composePrompt` path the success case does.
261
+ if (!this.activeRuns.has(task.conversationId) && !this.interruptTargets.has(task.conversationId)) {
278
262
  void this.processWaitingMessage(task.conversationId, queued.id).catch((error) => {
279
263
  console.warn(`Super Pi: auto-trigger for background exec result failed: ${error instanceof Error ? error.message : "unknown error"}`);
280
264
  });
281
265
  }
282
- else {
283
- // THE CONVERSATION IS BUSY, so the run that is going answers this result itself - through
284
- // Pi's own follow-up queue, so the result does not have to wait for a turn of its own.
285
- await this.submitToPiQueue(task.conversationId, queued);
286
- }
266
+ // BUSY: the row stays in the waiting queue, and the turn that is running takes it when that
267
+ // turn ends - one message per turn, in the queue's own order. Nothing of a background result
268
+ // goes into Pi's follow-up queue any more.
287
269
  }
288
270
  catch (error) {
289
271
  console.warn(`Super Pi: exec result could not be appended as a tool message: ${error instanceof Error ? error.message : "unknown error"}`);
@@ -374,7 +356,7 @@ export class RunService {
374
356
  * other tool update uses - and the client can only get that index from the row it is already rendering.
375
357
  */
376
358
  async answerQuestion(command) {
377
- const existing = this.store.snapshot().conversations.find((item) => item.id === command.conversationId)?.messages.find((item) => item.id === command.messageId)?.toolCalls?.[command.callIndex]?.question;
359
+ const existing = this.store.question(command.conversationId, command.messageId, command.callIndex);
378
360
  if (!existing)
379
361
  throw new Error("这个工具调用不是一个问题");
380
362
  const calls = await this.store.updateToolCall(command.conversationId, command.messageId, command.callIndex, {
@@ -385,6 +367,30 @@ export class RunService {
385
367
  },
386
368
  });
387
369
  this.broadcaster.broadcast({ type: "message.tools", conversationId: command.conversationId, messageId: command.messageId, toolCalls: calls });
370
+ this.settleQuestion(command.conversationId, command.messageId, command.callIndex, calls[command.callIndex]?.question);
371
+ }
372
+ questionKey(conversationId, messageId, index) {
373
+ return `${conversationId}\0${messageId}\0${index}`;
374
+ }
375
+ answerPayload(current) {
376
+ const answers = (current.answers ?? []).map((answer) => {
377
+ const asked = current.items.find((item) => item.id === answer.id);
378
+ return { question: asked?.question, selected: answer.selected, ...(answer.note ? { note: answer.note } : {}) };
379
+ });
380
+ return { answers, summary: `用户回答:${answers.map((answer) => answer.selected.join("、")).join(";")}` };
381
+ }
382
+ settleQuestion(conversationId, messageId, index, question) {
383
+ if (!question || question.status === "pending")
384
+ return;
385
+ const key = this.questionKey(conversationId, messageId, index);
386
+ const waiter = this.questionWaiters.get(key);
387
+ if (!waiter)
388
+ return;
389
+ this.questionWaiters.delete(key);
390
+ if (question.status === "answered")
391
+ waiter.resolve(this.answerPayload(question));
392
+ else
393
+ waiter.reject(new Error("用户没有回答这个问题"));
388
394
  }
389
395
  /**
390
396
  * THE `question` TOOL BLOCKS HERE, ON THE CALL THAT ASKED IT.
@@ -414,28 +420,13 @@ export class RunService {
414
420
  //
415
421
  // So the lookup WAITS. A few hundred milliseconds of polling covers the drain, and a question that
416
422
  // genuinely has no pending call still fails - just not instantly.
417
- const findCall = () => {
418
- const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
419
- const message = conversation?.messages.slice().reverse().find((item) => item.role === "assistant");
420
- if (!message)
421
- return undefined;
422
- for (let i = (message.toolCalls?.length ?? 0) - 1; i >= 0; i -= 1) {
423
- const call = message.toolCalls?.[i];
424
- if (call?.label === "question" && call.question?.status === "pending")
425
- return { messageId: message.id, index: i };
426
- }
427
- return undefined;
428
- };
429
423
  let found;
430
424
  for (let attempt = 0; attempt < 50 && !found; attempt += 1) {
431
- found = findCall();
425
+ found = this.store.pendingQuestion(conversationId);
432
426
  if (found)
433
427
  break;
434
428
  await new Promise((resolve) => setTimeout(resolve, 100));
435
429
  }
436
- const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
437
- if (!conversation?.messages.length)
438
- throw new Error("No active assistant message");
439
430
  if (!found)
440
431
  throw new Error("这个会话里没有正在等待回答的 question 工具调用");
441
432
  const { messageId, index } = found;
@@ -445,29 +436,7 @@ export class RunService {
445
436
  // continuing from a question that was never decided). The run-level teardown (stop / abort /
446
437
  // shutdown) clears the underlying ask and the client settles it as cancelled, which lands here
447
438
  // and rejects. A question that is simply unanswered waits indefinitely.
448
- const poll = setInterval(() => {
449
- const current = this.store.snapshot().conversations.find((item) => item.id === conversationId)?.messages.find((item) => item.id === messageId)?.toolCalls?.[index]?.question;
450
- if (current?.status === "answered") {
451
- clearInterval(poll);
452
- resolve(answerPayload(current));
453
- }
454
- else if (current?.status === "cancelled") {
455
- clearInterval(poll);
456
- reject(new Error("用户没有回答这个问题"));
457
- }
458
- }, 250);
459
- // WHAT GOES BACK TO THE MODEL IS WRITTEN FOR THE MODEL. The answers pair to their questions by id,
460
- // which is what stops a person answering the second of two being recorded against the first - but an
461
- // id is a UUID, and a model handed `{answers:[{id:"671b0f0b-…", selected:["发"]}]}` has to guess at
462
- // what that means. It guessed, and reported a real answer as a cancellation. So the answer goes back
463
- // as its questions and its choices, with a sentence naming the outcome.
464
- const answerPayload = (current) => {
465
- const answers = (current.answers ?? []).map((answer) => {
466
- const asked = current.items.find((item) => item.id === answer.id);
467
- return { question: asked?.question, selected: answer.selected, ...(answer.note ? { note: answer.note } : {}) };
468
- });
469
- return { answers, summary: `用户回答:${answers.map((answer) => answer.selected.join("、")).join(";")}` };
470
- };
439
+ this.questionWaiters.set(this.questionKey(conversationId, messageId, index), { resolve, reject });
471
440
  });
472
441
  }
473
442
  /**
@@ -571,6 +540,7 @@ export class RunService {
571
540
  // Abort immediately before waiting for the conversation lane; otherwise a stop queued behind a
572
541
  // long streaming command could never reach the controller that it is meant to cancel.
573
542
  this.activeRuns.get(conversationId)?.abort();
543
+ this.broadcaster.broadcast({ type: "run.stopping", conversationId });
574
544
  await this.runtime.stop?.(conversationId);
575
545
  return this.serializeConversation(conversationId, () => this.stopRunUnsafe(conversationId));
576
546
  }
@@ -591,9 +561,9 @@ export class RunService {
591
561
  continue;
592
562
  const calls = await this.store.updateToolCall(conversationId, message.id, index, { question: { items: question.items, status: "cancelled" } });
593
563
  this.broadcaster.broadcast({ type: "message.tools", conversationId, messageId: message.id, toolCalls: calls });
564
+ this.settleQuestion(conversationId, message.id, index, calls[index]?.question);
594
565
  }
595
566
  }
596
- await this.store.setStatus(conversationId, "stopped");
597
567
  if (this.subagents.stopConversation(conversationId)) {
598
568
  console.log(`Super Pi: stopped the sub-agent runs of conversation ${conversationId}.`);
599
569
  }
@@ -603,6 +573,17 @@ export class RunService {
603
573
  if (this.workflows.stopConversation(conversationId)) {
604
574
  console.log(`Super Pi: stopped the running orchestration plans of conversation ${conversationId}.`);
605
575
  }
576
+ // AWAIT RUN SLOT RELEASE BEFORE CLAIMING "STOPPED".
577
+ // A stop previously called setStatus("stopped") immediately, while activeRuns still held the
578
+ // conversation slot and the streaming lane was still draining. This produced the contradiction:
579
+ // the UI displayed 「已停止」 and offered 「发送」, but sending a new message hit an active
580
+ // run slot and was falsely placed into 「排队中」, while the previous turn might still produce text.
581
+ await this.awaitRunSlotReleased(conversationId, false, 3_000);
582
+ if (this.activeRuns.has(conversationId)) {
583
+ console.warn(`Super Pi: run slot for conversation ${conversationId} not released after stop deadline; force-releasing.`);
584
+ this.releaseRunSlot(conversationId);
585
+ }
586
+ await this.store.setStatus(conversationId, "stopped");
606
587
  this.broadcaster.broadcastState();
607
588
  }
608
589
  /**
@@ -777,14 +758,29 @@ export class RunService {
777
758
  // long tool execution. The flag is the only way to make the flip one-shot: calling `setStatus` on
778
759
  // every delta would be a per-token status write.
779
760
  let firstSignal = true;
761
+ /**
762
+ * Promotes the turn to `running`, and ANSWERS WHETHER IT PUBLISHED A STATE SNAPSHOT.
763
+ *
764
+ * The answer matters because a state snapshot and a live event are two carriers of the same
765
+ * information: the snapshot is the whole truth (it carries the text, the thinking and the block
766
+ * boundaries), and a live `message.delta` / `message.thinking` event is an increment on top of it.
767
+ * A caller that has ALREADY put a delta into the store must therefore not also publish it as a
768
+ * live event, or the browser receives it twice and counts it twice - the reasoning the reader
769
+ * sees is longer than the reasoning the model wrote.
770
+ *
771
+ * So the rule is: WHOEVER PUTS THE DELTA IN THE STORE OWNS THE DELIVERY. The promotion publishes
772
+ * a snapshot only when it actually flips the status, and the caller asks.
773
+ */
780
774
  const promoteToRunning = async () => {
781
775
  if (!firstSignal)
782
- return;
776
+ return false;
783
777
  firstSignal = false;
784
778
  if (this.store.snapshot().conversations.find((item) => item.id === conversation.id)?.status === "waiting") {
785
779
  await this.store.setStatus(conversation.id, "running");
786
780
  this.broadcaster.broadcastState();
781
+ return true;
787
782
  }
783
+ return false;
788
784
  };
789
785
  // Live events are independent from durable checkpoints. Persisting every token made the stdout
790
786
  // reader wait for one disk transaction per token; over a remote browser connection that backlog
@@ -1152,14 +1148,27 @@ export class RunService {
1152
1148
  thinkingActive = true;
1153
1149
  this.store.setThinkingActiveLive(conversation.id, assistant.id, true);
1154
1150
  thought += delta.length;
1151
+ // THE DELTA GOES INTO THE STORE FIRST, and the promotion that follows broadcasts a state
1152
+ // snapshot that therefore CARRIES it - the text, the thinking and the block boundary the
1153
+ // browser needs to put this fragment in the right fold. That snapshot is the delta's
1154
+ // delivery, so the live event is not sent as well: sent twice, the browser appends the
1155
+ // first fragment to a copy that already has it, and the reasoning the reader sees is longer
1156
+ // than the reasoning the model wrote.
1157
+ //
1158
+ // THE OTHER ORDER HAD ITS OWN FAILURE, and it is why the snapshot is not simply left to
1159
+ // carry everything: promoting BEFORE the append publishes a snapshot WITHOUT this delta,
1160
+ // and the browser - which rebuilds its fold blocks from the snapshot's boundaries - then
1161
+ // starts a second block for the same stretch of reasoning, so one continuous train of
1162
+ // thought arrives as two fold bars and the tool call inside it falls out of the block it
1163
+ // belonged to.
1164
+ //
1165
+ // So: the snapshot carries the delta, and only the deltas the snapshot did not carry are
1166
+ // published live. `promoteToRunning` answers whether it published one.
1155
1167
  this.store.appendThinkingDeltaLive(conversation.id, assistant.id, delta, spoken, newBlock);
1156
- // THE PROMOTION COMES AFTER THE DELTA IS IN THE STORE, so the state snapshot it broadcasts
1157
- // carries the same thinking the client is about to receive as a live event. Promoting first
1158
- // broadcast a snapshot WITHOUT this delta, and the client - which prefers the live body only
1159
- // while the conversation is running - took that stale snapshot, lost the thinking block's
1160
- // boundary, and dropped a tool call out of the block it belonged to.
1161
- await promoteToRunning();
1162
- this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true, at: spoken, blockIndex: thinkingBlock });
1168
+ const promoted = await promoteToRunning();
1169
+ if (!promoted) {
1170
+ this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true, at: spoken, blockIndex: thinkingBlock });
1171
+ }
1163
1172
  scheduleCheckpoint();
1164
1173
  },
1165
1174
  // AN EMPTY DELTA THAT ONLY CARRIES THE FLAG. `thinking_end` is a fact the browser needs and not
@@ -1324,6 +1333,12 @@ export class RunService {
1324
1333
  // itself as an ordinary message and now finds the slot taken - its words are on screen with nobody
1325
1334
  // going to answer them. Repairing that is what the guard below is for; it is not a rare path, it is
1326
1335
  // what two fast sends looks like from the inside.
1336
+ // An aborted run is unwinding its slot. Wait out that brief cleanup rather than falsely
1337
+ // treating it as an active turn and sending this message to a dying follow-up queue.
1338
+ const active = this.activeRuns.get(conversation.id);
1339
+ if (active?.signal.aborted) {
1340
+ await this.awaitRunSlotReleased(conversation.id, false);
1341
+ }
1327
1342
  const busy = this.activeRuns.has(conversation.id) || this.interruptTargets.has(conversation.id);
1328
1343
  const message = await this.store.addUserMessage(conversationId, trimmed, validImages, attachments, { clientId, ...(busy ? { delivery: "waiting" } : {}) });
1329
1344
  this.broadcaster.broadcastState();
@@ -1375,6 +1390,9 @@ export class RunService {
1375
1390
  // waiting". The banner is cleared by the next `addUserMessage` so a stale reason cannot
1376
1391
  // survive a retry into the same conversation.
1377
1392
  await this.store.setFailureReason(conversationId, formatTurnFailure(error));
1393
+ // The refusal is written for the reader, but the reader is not the only one who needs to know
1394
+ // why a turn never started: the server log is where the stack survives.
1395
+ console.warn("Super Pi turn refused before it started:", describeThrownWithStack(error));
1378
1396
  // Status is restored here, not relied on the outer catch to handle: a preflight failure never
1379
1397
  // opened a turn, so the right state is the one the conversation had a moment ago (idle). The
1380
1398
  // outer catch would otherwise flip it to `failed`, which the reader would render as a turn
@@ -1424,6 +1442,9 @@ export class RunService {
1424
1442
  if (controller.signal.aborted)
1425
1443
  return;
1426
1444
  await this.store.setFailureReason(conversation.id, formatTurnFailure(error));
1445
+ // A turn that broke is the failure the reader will complain about, and the banner only carries
1446
+ // the sentence. The stack goes to the server log, where it can still be found.
1447
+ console.error("Super Pi turn failed:", describeThrownWithStack(error));
1427
1448
  throw error;
1428
1449
  }
1429
1450
  finally {
@@ -1460,8 +1481,8 @@ export class RunService {
1460
1481
  * and nothing more. Waiting out an arbitrarily long RUNNING turn would turn "send now" into "send
1461
1482
  * whenever the current answer finishes", which is what the queue is for.
1462
1483
  */
1463
- async awaitRunSlotReleased(conversationId, onlyWhileStopped = false) {
1464
- const deadline = Date.now() + 15_000;
1484
+ async awaitRunSlotReleased(conversationId, onlyWhileStopped = false, timeoutMs = 15_000) {
1485
+ const deadline = Date.now() + timeoutMs;
1465
1486
  while (this.activeRuns.has(conversationId) && Date.now() < deadline) {
1466
1487
  if (onlyWhileStopped && this.store.snapshot().conversations.find((item) => item.id === conversationId)?.status !== "stopped")
1467
1488
  return;
@@ -147,6 +147,13 @@ export declare class ScheduledTaskService {
147
147
  * one that has to use it. A failure here is the task's failure - a model that Pi no longer offers
148
148
  * cannot be silently ignored, because the run would then happen on some other model and the panel
149
149
  * would say it succeeded.
150
+ *
151
+ * AND WHEN THERE IS NO OVERRIDE, THE DEFAULTS BEHIND IT GO ON THE CONVERSATION TOO. Clearing the
152
+ * override in the panel means 「跟着工作区默认走」, and the only way to honour that on a conversation
153
+ * that already ran with the override is to PUT THE DEFAULT BACK. The conversation's model is a
154
+ * copy-at-birth value (AGENTS.md: 建会话时把默认值盖到会话字段上,之后永不回查工作区), so "do
155
+ * nothing when there is no override" keeps the WITHDRAWN override pinned for good - and the panel
156
+ * says the run succeeded on a model the user just took away.
150
157
  */
151
158
  private applyConfiguration;
152
159
  /** Writes down how the run went, and tells the panel. */
@@ -302,14 +302,23 @@ export class ScheduledTaskService {
302
302
  * one that has to use it. A failure here is the task's failure - a model that Pi no longer offers
303
303
  * cannot be silently ignored, because the run would then happen on some other model and the panel
304
304
  * would say it succeeded.
305
+ *
306
+ * AND WHEN THERE IS NO OVERRIDE, THE DEFAULTS BEHIND IT GO ON THE CONVERSATION TOO. Clearing the
307
+ * override in the panel means 「跟着工作区默认走」, and the only way to honour that on a conversation
308
+ * that already ran with the override is to PUT THE DEFAULT BACK. The conversation's model is a
309
+ * copy-at-birth value (AGENTS.md: 建会话时把默认值盖到会话字段上,之后永不回查工作区), so "do
310
+ * nothing when there is no override" keeps the WITHDRAWN override pinned for good - and the panel
311
+ * says the run succeeded on a model the user just took away.
305
312
  */
306
313
  async applyConfiguration(task, conversationId) {
307
- if (task.model) {
308
- await this.conversationService.setModel(conversationId, task.model.provider, task.model.modelId);
309
- }
310
- if (task.agentId) {
311
- await this.conversationService.setAgent(conversationId, task.agentId);
314
+ const state = this.store.snapshot();
315
+ const workspace = state.workspaces.find((item) => item.id === task.workspaceId);
316
+ const model = task.model ?? workspace?.defaultModel ?? state.defaultModel;
317
+ if (model) {
318
+ await this.conversationService.setModel(conversationId, model.provider, model.modelId);
312
319
  }
320
+ const agentId = task.agentId ?? workspace?.defaultAgentId ?? null;
321
+ await this.conversationService.setAgent(conversationId, agentId);
313
322
  }
314
323
  /** Writes down how the run went, and tells the panel. */
315
324
  async record(task, outcome) {
@@ -22,9 +22,8 @@ export declare class WorkspaceService {
22
22
  private readonly catalogService;
23
23
  private readonly runService;
24
24
  private readonly extensionUiService;
25
- private readonly uploadsDir;
26
25
  private agentsDir;
27
- constructor(store: Store, runtime: AgentRuntime, broadcaster: EventBroadcaster, catalogService: CatalogService, runService: RunService, extensionUiService: ExtensionUiService, uploadsDir: string, agentsDir: string | string[]);
26
+ constructor(store: Store, runtime: AgentRuntime, broadcaster: EventBroadcaster, catalogService: CatalogService, runService: RunService, extensionUiService: ExtensionUiService, agentsDir: string | string[]);
28
27
  setAgentsDir(dir: string | string[]): void;
29
28
  /**
30
29
  * Creates a new workspace.
@@ -35,8 +34,13 @@ export declare class WorkspaceService {
35
34
  */
36
35
  renameWorkspace(workspaceId: string, name: string): Promise<void>;
37
36
  /**
38
- * Deletes a workspace and cascades cleanup to active runs, subagent runs,
39
- * uploaded files, and stored conversations.
37
+ * Takes a workspace out of the sidebar. NOTHING IS DELETED.
38
+ *
39
+ * The conversations under it, the uploads attached to them and the workspace's own defaults all
40
+ * stay on disk: removing a workspace from the list is a soft unmount, and adding the same
41
+ * folder back brings the whole history with it. What does stop is the WORK: a turn that was still
42
+ * running cannot keep a model session alive behind a workspace the user can no longer see, and a
43
+ * pending question would wait for an answer the sidebar can no longer reach.
40
44
  */
41
45
  deleteWorkspace(workspaceId: string): Promise<void>;
42
46
  /**