@evandu/super-pi 0.1.133 → 0.1.135

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 (55) 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-D6awRUZO.js} +2 -2
  4. package/dist/client/assets/{dist-azDN8a65.js → dist-1jSyapdW.js} +1 -1
  5. package/dist/client/assets/{dist-CE5BnwJa.js → dist-BGgidsaF.js} +1 -1
  6. package/dist/client/assets/{dist-DOYWHMIq.js → dist-BYMYC_2I.js} +1 -1
  7. package/dist/client/assets/{dist-CYD2SqmG.js → dist-BkVaPA_q.js} +1 -1
  8. package/dist/client/assets/{dist-CxsunljU.js → dist-BoeM5xW7.js} +1 -1
  9. package/dist/client/assets/{dist-D4x1XTQb.js → dist-C46474ys.js} +1 -1
  10. package/dist/client/assets/{dist-BJinarhP.js → dist-CCkdCH6a.js} +1 -1
  11. package/dist/client/assets/{dist-DQf5nNxa.js → dist-CNuKauOq.js} +1 -1
  12. package/dist/client/assets/{dist-CApAttfj.js → dist-CubYGjAk.js} +1 -1
  13. package/dist/client/assets/{dist-0FH-oFco.js → dist-Cwb-Lb5-.js} +1 -1
  14. package/dist/client/assets/{dist-CFPXj4ws.js → dist-D0Iw5XQA.js} +1 -1
  15. package/dist/client/assets/{dist-CUKaDTyO.js → dist-DCiuVvGU.js} +1 -1
  16. package/dist/client/assets/{dist-B5ruVtUG.js → dist-DHBWUQhM.js} +1 -1
  17. package/dist/client/assets/{dist-BtTFCNVQ.js → dist-DOwrSiuY.js} +1 -1
  18. package/dist/client/assets/{dist-4Pts9xPO.js → dist-DeZsNbFx.js} +1 -1
  19. package/dist/client/assets/{dist-aL2NGji_.js → dist-Dk3e6RfY.js} +1 -1
  20. package/dist/client/assets/{dist-LFfokYJs.js → dist-DoDmoWTn.js} +1 -1
  21. package/dist/client/assets/{dist-DbbBgSwL.js → dist-WkVUFX28.js} +1 -1
  22. package/dist/client/assets/{dist-C2nuBCP4.js → dist-lZUWPQbT.js} +1 -1
  23. package/dist/client/assets/{dist-DHqqpxo4.js → dist-mPR8uuzk.js} +1 -1
  24. package/dist/client/assets/{dist-fYp8Xwrx.js → dist-spB2Q2CG.js} +1 -1
  25. package/dist/client/assets/{dist-BffE00vI.js → dist-yTTCht0J.js} +1 -1
  26. package/dist/client/assets/{index-YEN4ysHF.js → index-Dyta-HkQ.js} +37 -37
  27. package/dist/client/assets/{index-D3MEG8uH.css → index-V93Kzott.css} +1 -1
  28. package/dist/client/index.html +2 -2
  29. package/dist/server/active-memory.d.ts +1 -0
  30. package/dist/server/active-memory.js +6 -2
  31. package/dist/server/application/run-service.d.ts +4 -0
  32. package/dist/server/application/run-service.js +114 -93
  33. package/dist/server/application/workspace-service.d.ts +8 -4
  34. package/dist/server/application/workspace-service.js +11 -10
  35. package/dist/server/failure-text.d.ts +18 -0
  36. package/dist/server/failure-text.js +44 -0
  37. package/dist/server/http-server.js +16 -5
  38. package/dist/server/index.js +5 -2
  39. package/dist/server/knowledge-embedding-worker.d.ts +1 -0
  40. package/dist/server/knowledge-embedding-worker.js +27 -0
  41. package/dist/server/knowledge-vectors.d.ts +5 -3
  42. package/dist/server/knowledge-vectors.js +49 -47
  43. package/dist/server/knowledge.d.ts +1 -0
  44. package/dist/server/knowledge.js +6 -2
  45. package/dist/server/persistence/sqlite-state-repository.d.ts +0 -1
  46. package/dist/server/persistence/sqlite-state-repository.js +37 -1
  47. package/dist/server/providers.d.ts +13 -0
  48. package/dist/server/providers.js +27 -2
  49. package/dist/server/runtime.js +46 -0
  50. package/dist/server/store.d.ts +61 -0
  51. package/dist/server/store.js +112 -14
  52. package/dist/server/types.d.ts +10 -0
  53. package/dist/server/ws-server.d.ts +3 -0
  54. package/dist/server/ws-server.js +35 -6
  55. package/package.json +16 -2
@@ -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-YEN4ysHF.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-D3MEG8uH.css">
11
+ <script type="module" crossorigin src="/assets/index-Dyta-HkQ.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-V93Kzott.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -61,6 +61,7 @@ export declare class ActiveMemory {
61
61
  }): string;
62
62
  release(token: string): void;
63
63
  private identity;
64
+ private hasWorkspace;
64
65
  search(workspaceId: string, query: string, sessionId?: string): {
65
66
  conversationId: string;
66
67
  title: string;
@@ -109,10 +109,14 @@ export class ActiveMemory {
109
109
  release(token) { this.capabilities.delete(token); }
110
110
  identity(token) {
111
111
  const identity = this.capabilities.get(token);
112
- if (!identity || !this.store.snapshot().workspaces.some(w => w.id === identity.workspaceId))
112
+ if (!identity || !this.hasWorkspace(identity.workspaceId))
113
113
  throw new Error("记忆授权已失效");
114
114
  return identity;
115
115
  }
116
+ hasWorkspace(workspaceId) {
117
+ const store = this.store;
118
+ return store.hasWorkspace ? store.hasWorkspace(workspaceId) : store.snapshot().workspaces.some(w => w.id === workspaceId);
119
+ }
116
120
  search(workspaceId, query, sessionId) {
117
121
  const words = query.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu)?.slice(0, 12) ?? [];
118
122
  if (!words.length)
@@ -232,7 +236,7 @@ export class ActiveMemory {
232
236
  return alreadyLocked ? work() : this.locked(work);
233
237
  }
234
238
  async manage(workspaceId, body) {
235
- if (!this.store.snapshot().workspaces.some(w => w.id === workspaceId))
239
+ if (!this.hasWorkspace(workspaceId))
236
240
  throw new Error("工作区不存在");
237
241
  if (body.action === "configure") {
238
242
  await this.configure(body.settings);
@@ -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;
@@ -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
  /**
@@ -1,5 +1,4 @@
1
1
  import { assertModelAllowed } from "../model-filter.js";
2
- import { deleteConversationUploads } from "../upload.js";
3
2
  /**
4
3
  * Service managing workspaces: creation, rename, cascade deletion, and defaults configuration.
5
4
  */
@@ -10,16 +9,14 @@ export class WorkspaceService {
10
9
  catalogService;
11
10
  runService;
12
11
  extensionUiService;
13
- uploadsDir;
14
12
  agentsDir;
15
- constructor(store, runtime, broadcaster, catalogService, runService, extensionUiService, uploadsDir, agentsDir) {
13
+ constructor(store, runtime, broadcaster, catalogService, runService, extensionUiService, agentsDir) {
16
14
  this.store = store;
17
15
  this.runtime = runtime;
18
16
  this.broadcaster = broadcaster;
19
17
  this.catalogService = catalogService;
20
18
  this.runService = runService;
21
19
  this.extensionUiService = extensionUiService;
22
- this.uploadsDir = uploadsDir;
23
20
  this.agentsDir = agentsDir;
24
21
  }
25
22
  setAgentsDir(dir) {
@@ -41,21 +38,25 @@ export class WorkspaceService {
41
38
  this.broadcaster.broadcastState();
42
39
  }
43
40
  /**
44
- * Deletes a workspace and cascades cleanup to active runs, subagent runs,
45
- * uploaded files, and stored conversations.
41
+ * Takes a workspace out of the sidebar. NOTHING IS DELETED.
42
+ *
43
+ * The conversations under it, the uploads attached to them and the workspace's own defaults all
44
+ * stay on disk: removing a workspace from the list is a soft unmount, and adding the same
45
+ * folder back brings the whole history with it. What does stop is the WORK: a turn that was still
46
+ * running cannot keep a model session alive behind a workspace the user can no longer see, and a
47
+ * pending question would wait for an answer the sidebar can no longer reach.
46
48
  */
47
49
  async deleteWorkspace(workspaceId) {
48
50
  const snapshot = this.store.snapshot();
49
- const doomed = snapshot.conversations.filter((c) => c.workspaceId === workspaceId);
50
- for (const c of doomed) {
51
+ const affected = snapshot.conversations.filter((c) => c.workspaceId === workspaceId);
52
+ for (const c of affected) {
51
53
  this.extensionUiService.cancelPendingUi(c.id, "aborted");
52
54
  if (this.runService.isRunning(c.id)) {
53
55
  await this.runService.stopRun(c.id);
54
56
  }
55
57
  this.runService.stopConversationSubagents(c.id);
56
58
  }
57
- const doomedIds = await this.store.deleteWorkspace(workspaceId);
58
- await Promise.all(doomedIds.map((id) => deleteConversationUploads(this.uploadsDir, id)));
59
+ await this.store.deleteWorkspace(workspaceId);
59
60
  this.broadcaster.broadcastState();
60
61
  }
61
62
  /**
@@ -0,0 +1,18 @@
1
+ /**
2
+ * THE TEXT OF A FAILURE.
3
+ *
4
+ * A server failure reaches the browser as a sentence, and the reader is entitled to know what
5
+ * actually went wrong. The trouble is that what was thrown is not always an `Error`: it can be a
6
+ * string, a plain object, a rejected promise with no reason at all. The old code answered
7
+ * `command failed` in those cases - the one sentence that says nothing about anything - so a real
8
+ * cause that had reached the server was replaced by a sentence with no cause in it.
9
+ *
10
+ * This module is the one place that decides what a thrown value turns into. It never invents
11
+ * text: an `Error` gives its message, a string gives itself, an object gives its `message` or its
12
+ * JSON, and a value that carries no information at all gives an EMPTY STRING - which the client
13
+ * reads as "no reason was given", and says so out loud instead of pretending.
14
+ */
15
+ /** The failure as a person should read it. Empty when the thrown value carries no information. */
16
+ export declare function describeThrown(error: unknown): string;
17
+ /** The failure with its stack, for the log file. */
18
+ export declare function describeThrownWithStack(error: unknown): string;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * THE TEXT OF A FAILURE.
3
+ *
4
+ * A server failure reaches the browser as a sentence, and the reader is entitled to know what
5
+ * actually went wrong. The trouble is that what was thrown is not always an `Error`: it can be a
6
+ * string, a plain object, a rejected promise with no reason at all. The old code answered
7
+ * `command failed` in those cases - the one sentence that says nothing about anything - so a real
8
+ * cause that had reached the server was replaced by a sentence with no cause in it.
9
+ *
10
+ * This module is the one place that decides what a thrown value turns into. It never invents
11
+ * text: an `Error` gives its message, a string gives itself, an object gives its `message` or its
12
+ * JSON, and a value that carries no information at all gives an EMPTY STRING - which the client
13
+ * reads as "no reason was given", and says so out loud instead of pretending.
14
+ */
15
+ /** The failure as a person should read it. Empty when the thrown value carries no information. */
16
+ export function describeThrown(error) {
17
+ if (error instanceof Error)
18
+ return error.message || error.name;
19
+ if (typeof error === "string")
20
+ return error;
21
+ if (error === null || error === undefined)
22
+ return "";
23
+ if (typeof error === "object") {
24
+ const message = error.message;
25
+ if (typeof message === "string" && message.trim())
26
+ return message;
27
+ try {
28
+ const text = JSON.stringify(error);
29
+ if (text && text !== "{}")
30
+ return text;
31
+ }
32
+ catch {
33
+ // Circular or otherwise unserializable: there is nothing better to say than "no reason".
34
+ }
35
+ return "";
36
+ }
37
+ return String(error);
38
+ }
39
+ /** The failure with its stack, for the log file. */
40
+ export function describeThrownWithStack(error) {
41
+ if (error instanceof Error && error.stack)
42
+ return error.stack;
43
+ return describeThrown(error);
44
+ }
@@ -6,6 +6,7 @@ import { homedir, networkInterfaces } from "node:os";
6
6
  import { basename, dirname, extname, join, relative, resolve } from "node:path";
7
7
  import { describe as describeFilesError, fileMeta, listDirectory, MAX_FILE_BYTES, readWorkspaceFile, resolveInsideRoot, toRelative, writeWorkspaceFile, } from "./files.js";
8
8
  import { findUploadFile, MAX_UPLOAD_BODY_BYTES, saveUpload } from "./upload.js";
9
+ import { describeThrown } from "./failure-text.js";
9
10
  import { checkDiskVersion } from "./version.js";
10
11
  import { gitBlame, gitDiff, gitLog, gitMutate, gitStatus } from "./git.js";
11
12
  import { computeFileDiff } from "./file-diff.js";
@@ -282,7 +283,7 @@ export function createHttpServer(options) {
282
283
  return;
283
284
  }
284
285
  catch (error) {
285
- json(400, { error: error instanceof Error ? error.message : "tool export failed" });
286
+ json(400, { error: describeThrown(error) || "导出工具失败,没有拿到失败原因。" });
286
287
  return;
287
288
  }
288
289
  }
@@ -360,7 +361,7 @@ export function createHttpServer(options) {
360
361
  return;
361
362
  }
362
363
  catch (error) {
363
- json(400, { error: error instanceof Error ? error.message : "tool request failed" });
364
+ json(400, { error: describeThrown(error) || "工具请求失败,没有拿到失败原因。" });
364
365
  return;
365
366
  }
366
367
  }
@@ -422,7 +423,7 @@ export function createHttpServer(options) {
422
423
  toolApi(202, run);
423
424
  }
424
425
  catch (error) {
425
- toolApi(400, { error: error instanceof Error ? error.message : "tool run failed" });
426
+ toolApi(400, { error: describeThrown(error) || "工具运行失败,没有拿到失败原因。" });
426
427
  }
427
428
  return;
428
429
  }
@@ -513,7 +514,7 @@ export function createHttpServer(options) {
513
514
  toolApi(202, context.toolLlm.start({ toolId: id, prompt: body.prompt ?? "", system: body.system, timeoutMs: body.timeoutMs }));
514
515
  }
515
516
  catch (error) {
516
- toolApi(400, { error: error instanceof Error ? error.message : "model call failed" });
517
+ toolApi(400, { error: describeThrown(error) || "模型调用失败,没有拿到失败原因。" });
517
518
  }
518
519
  return;
519
520
  }
@@ -1039,6 +1040,16 @@ export function createHttpServer(options) {
1039
1040
  return;
1040
1041
  }
1041
1042
  if (request.method === "POST") {
1043
+ // A GIT WRITE IS A WRITE, AND IT GETS THE SAME GATE AS EVERY OTHER WRITE. `gitMutate` can
1044
+ // stage, DISCARD (`git restore -- <path>`, which throws away uncommitted work), commit,
1045
+ // pull and PUSH - so an endpoint that skips the address predicate hands a public caller the
1046
+ // keys to somebody's repository. The server listens on 0.0.0.0 by default and the global
1047
+ // access token is OPTIONAL (`isAuthorized` passes everyone when there is none), so this
1048
+ // predicate IS the boundary for every other write endpoint in this file (ledger TEST-042).
1049
+ if (!isLocalOrPrivateAddress(remote)) {
1050
+ json(403, { error: "Git 写操作只允许来自本机或局域网的请求。" });
1051
+ return;
1052
+ }
1042
1053
  let raw = "";
1043
1054
  for await (const chunk of request)
1044
1055
  raw += chunk;
@@ -1417,7 +1428,7 @@ export function createHttpServer(options) {
1417
1428
  response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(result));
1418
1429
  }
1419
1430
  catch (error) {
1420
- response.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: error instanceof Error ? error.message : "question failed" }));
1431
+ response.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: describeThrown(error) || "问答失败,没有拿到失败原因。" }));
1421
1432
  }
1422
1433
  return;
1423
1434
  }