@evandu/super-pi 0.1.132 → 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 (69) hide show
  1. package/dist/.super-pi-check-inputs.json +4 -0
  2. package/dist/client/.super-pi-client-sources.json +8 -8
  3. package/dist/client/assets/{CodeMirrorEditor-ktyL2PcX.js → CodeMirrorEditor-D6awRUZO.js} +2 -2
  4. package/dist/client/assets/{dist--igDAzRS.js → dist-1jSyapdW.js} +1 -1
  5. package/dist/client/assets/{dist-CPLCQDqy.js → dist-BGgidsaF.js} +1 -1
  6. package/dist/client/assets/{dist-DNZMXYNq.js → dist-BYMYC_2I.js} +1 -1
  7. package/dist/client/assets/{dist-vsMBLoxV.js → dist-BkVaPA_q.js} +1 -1
  8. package/dist/client/assets/{dist-B8OZ-n_C.js → dist-BoeM5xW7.js} +1 -1
  9. package/dist/client/assets/{dist-D_Zdnhm8.js → dist-C46474ys.js} +1 -1
  10. package/dist/client/assets/{dist-Cu75NyQ0.js → dist-CCkdCH6a.js} +1 -1
  11. package/dist/client/assets/{dist-BJi5HmH4.js → dist-CNuKauOq.js} +1 -1
  12. package/dist/client/assets/{dist-BAJCrbnX.js → dist-CubYGjAk.js} +1 -1
  13. package/dist/client/assets/{dist-8pdzh6h1.js → dist-Cwb-Lb5-.js} +1 -1
  14. package/dist/client/assets/{dist-BRjGckmQ.js → dist-D0Iw5XQA.js} +1 -1
  15. package/dist/client/assets/{dist-BjZUYxLd.js → dist-DCiuVvGU.js} +1 -1
  16. package/dist/client/assets/{dist-Hj_dWgPr.js → dist-DHBWUQhM.js} +1 -1
  17. package/dist/client/assets/{dist-CoszOk5k.js → dist-DOwrSiuY.js} +1 -1
  18. package/dist/client/assets/{dist-Bg9zLfex.js → dist-DeZsNbFx.js} +1 -1
  19. package/dist/client/assets/{dist-C6iAHGvb.js → dist-Dk3e6RfY.js} +1 -1
  20. package/dist/client/assets/{dist-Bv6P2Rcp.js → dist-DoDmoWTn.js} +1 -1
  21. package/dist/client/assets/{dist-CgmISAMr.js → dist-WkVUFX28.js} +1 -1
  22. package/dist/client/assets/{dist-DvHghuGd.js → dist-lZUWPQbT.js} +1 -1
  23. package/dist/client/assets/{dist-Zhdkfvng.js → dist-mPR8uuzk.js} +1 -1
  24. package/dist/client/assets/{dist-60usx__o.js → dist-spB2Q2CG.js} +1 -1
  25. package/dist/client/assets/{dist-CMscy2Nn.js → dist-yTTCht0J.js} +1 -1
  26. package/dist/client/assets/index-Dyta-HkQ.js +100 -0
  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/agents.js +21 -1
  32. package/dist/server/application/feishu-service.d.ts +57 -10
  33. package/dist/server/application/feishu-service.js +118 -24
  34. package/dist/server/application/run-service.d.ts +4 -0
  35. package/dist/server/application/run-service.js +123 -88
  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/feishu-cli.d.ts +18 -2
  41. package/dist/server/feishu-cli.js +46 -12
  42. package/dist/server/feishu-config.d.ts +68 -0
  43. package/dist/server/feishu-config.js +84 -0
  44. package/dist/server/feishu.d.ts +187 -0
  45. package/dist/server/feishu.js +695 -0
  46. package/dist/server/http-server.js +36 -5
  47. package/dist/server/index.js +5 -2
  48. package/dist/server/knowledge-embedding-worker.d.ts +1 -0
  49. package/dist/server/knowledge-embedding-worker.js +27 -0
  50. package/dist/server/knowledge-vectors.d.ts +5 -3
  51. package/dist/server/knowledge-vectors.js +49 -47
  52. package/dist/server/knowledge.d.ts +1 -0
  53. package/dist/server/knowledge.js +6 -2
  54. package/dist/server/persistence/sqlite-state-repository.d.ts +0 -1
  55. package/dist/server/persistence/sqlite-state-repository.js +55 -1
  56. package/dist/server/pi-session-events.js +10 -2
  57. package/dist/server/pi-session-pool.d.ts +33 -1
  58. package/dist/server/pi-session-pool.js +20 -2
  59. package/dist/server/providers.d.ts +13 -0
  60. package/dist/server/providers.js +27 -2
  61. package/dist/server/runtime.d.ts +26 -1
  62. package/dist/server/runtime.js +98 -9
  63. package/dist/server/store.d.ts +61 -0
  64. package/dist/server/store.js +112 -14
  65. package/dist/server/types.d.ts +10 -0
  66. package/dist/server/ws-server.d.ts +3 -0
  67. package/dist/server/ws-server.js +35 -6
  68. package/package.json +25 -2
  69. package/dist/client/assets/index-ChyemF2I.js +0 -100
@@ -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
@@ -1132,9 +1128,17 @@ export class RunService {
1132
1128
  // `active: true` on every delta is not a field Pi sent - it is the conclusion Super Pi draws from
1133
1129
  // having received one. Pi announces the END of reasoning with `thinking_end` and puts nothing on
1134
1130
  // the delta, so these two halves together are the whole lifecycle.
1135
- onThinking: (delta) => {
1131
+ onThinking: async (delta) => {
1136
1132
  if (!delta)
1137
1133
  return;
1134
+ // THINKING IS THE MODEL PRODUCING SOMETHING, SO THE TURN IS RUNNING. The contract above
1135
+ // says a turn moves from `waiting` to `running` once the model has produced ANY signal -
1136
+ // "regardless of shape" - and a thinking delta is the same signal as a text delta: it is Pi
1137
+ // yielding data back. Only two shapes promoted the turn (text and a tool-call notice), so a
1138
+ // model that thinks before it answers sat in `waiting` for the whole of its reasoning: the
1139
+ // panel said 「等待 LLM 返回」 while the model was plainly working, and - because the stop
1140
+ // button appears while a turn is in flight - the reader had no way to stop it for exactly
1141
+ // as long as the thinking lasted.
1138
1142
  const newBlock = thinkingAnswerAt !== spoken || thinkingInterrupted;
1139
1143
  if (newBlock) {
1140
1144
  thinkingBlock++;
@@ -1144,8 +1148,27 @@ export class RunService {
1144
1148
  thinkingActive = true;
1145
1149
  this.store.setThinkingActiveLive(conversation.id, assistant.id, true);
1146
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.
1147
1167
  this.store.appendThinkingDeltaLive(conversation.id, assistant.id, delta, spoken, newBlock);
1148
- 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
+ }
1149
1172
  scheduleCheckpoint();
1150
1173
  },
1151
1174
  // AN EMPTY DELTA THAT ONLY CARRIES THE FLAG. `thinking_end` is a fact the browser needs and not
@@ -1310,6 +1333,12 @@ export class RunService {
1310
1333
  // itself as an ordinary message and now finds the slot taken - its words are on screen with nobody
1311
1334
  // going to answer them. Repairing that is what the guard below is for; it is not a rare path, it is
1312
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
+ }
1313
1342
  const busy = this.activeRuns.has(conversation.id) || this.interruptTargets.has(conversation.id);
1314
1343
  const message = await this.store.addUserMessage(conversationId, trimmed, validImages, attachments, { clientId, ...(busy ? { delivery: "waiting" } : {}) });
1315
1344
  this.broadcaster.broadcastState();
@@ -1361,6 +1390,9 @@ export class RunService {
1361
1390
  // waiting". The banner is cleared by the next `addUserMessage` so a stale reason cannot
1362
1391
  // survive a retry into the same conversation.
1363
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));
1364
1396
  // Status is restored here, not relied on the outer catch to handle: a preflight failure never
1365
1397
  // opened a turn, so the right state is the one the conversation had a moment ago (idle). The
1366
1398
  // outer catch would otherwise flip it to `failed`, which the reader would render as a turn
@@ -1410,6 +1442,9 @@ export class RunService {
1410
1442
  if (controller.signal.aborted)
1411
1443
  return;
1412
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));
1413
1448
  throw error;
1414
1449
  }
1415
1450
  finally {
@@ -1446,8 +1481,8 @@ export class RunService {
1446
1481
  * and nothing more. Waiting out an arbitrarily long RUNNING turn would turn "send now" into "send
1447
1482
  * whenever the current answer finishes", which is what the queue is for.
1448
1483
  */
1449
- async awaitRunSlotReleased(conversationId, onlyWhileStopped = false) {
1450
- const deadline = Date.now() + 15_000;
1484
+ async awaitRunSlotReleased(conversationId, onlyWhileStopped = false, timeoutMs = 15_000) {
1485
+ const deadline = Date.now() + timeoutMs;
1451
1486
  while (this.activeRuns.has(conversationId) && Date.now() < deadline) {
1452
1487
  if (onlyWhileStopped && this.store.snapshot().conversations.find((item) => item.id === conversationId)?.status !== "stopped")
1453
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
+ }
@@ -64,6 +64,16 @@ export type FeishuCliStatus = {
64
64
  identity?: FeishuIdentity;
65
65
  /** Feishu's own "what to do about it", when the CLI had something to say. */
66
66
  hint?: string;
67
+ /** The CLI's own summary of the situation, when it has one. */
68
+ note?: string;
69
+ /** The state of each identity, as the CLI words it: ready, missing, verify_failed, ... */
70
+ botStatus?: string;
71
+ userStatus?: string;
72
+ /** What the CLI says about each identity, in its own words. */
73
+ userMessage?: string;
74
+ botMessage?: string;
75
+ /** Whether the check actually reached Feishu, rather than only reading the local credential. */
76
+ verified?: boolean;
67
77
  };
68
78
  /** The outcome of a CLI call, in the shape this product speaks in. */
69
79
  export declare class FeishuCliError extends Error {
@@ -139,8 +149,14 @@ export declare class FeishuCli {
139
149
  /**
140
150
  * WHAT THE CLI KNOWS ABOUT ITSELF.
141
151
  *
142
- * `auth status` is the one command that answers without touching the network unless asked to
143
- * verify, so it is what the panel polls.
152
+ * THE VERIFY IS ON, and that is the whole point of asking the CLI at all. Without `--verify` the
153
+ * CLI only reads its local credential and reports the application as "ready" — which is what it
154
+ * said on a machine whose App Secret had expired, and which would have left the panel claiming
155
+ * everything was fine while every document call failed. With `--verify` the CLI asks Feishu, and
156
+ * the answer distinguishes the states a person can act on: `ready`, `missing`, `verify_failed`.
157
+ *
158
+ * When the verification cannot reach Feishu at all (no network), the plain status is read
159
+ * instead, and `verified: false` says so rather than pretending.
144
160
  */
145
161
  status(): Promise<FeishuCliStatus>;
146
162
  /**
@@ -198,38 +198,72 @@ export class FeishuCli {
198
198
  /**
199
199
  * WHAT THE CLI KNOWS ABOUT ITSELF.
200
200
  *
201
- * `auth status` is the one command that answers without touching the network unless asked to
202
- * verify, so it is what the panel polls.
201
+ * THE VERIFY IS ON, and that is the whole point of asking the CLI at all. Without `--verify` the
202
+ * CLI only reads its local credential and reports the application as "ready" — which is what it
203
+ * said on a machine whose App Secret had expired, and which would have left the panel claiming
204
+ * everything was fine while every document call failed. With `--verify` the CLI asks Feishu, and
205
+ * the answer distinguishes the states a person can act on: `ready`, `missing`, `verify_failed`.
206
+ *
207
+ * When the verification cannot reach Feishu at all (no network), the plain status is read
208
+ * instead, and `verified: false` says so rather than pretending.
203
209
  */
204
210
  async status() {
205
211
  const version = await this.version();
206
212
  if (!version)
207
213
  return { installed: false, userReady: false, botReady: false };
214
+ const read = async (args) => this.run(args, { timeoutMs: 30_000 });
208
215
  let envelope;
216
+ let verified = true;
209
217
  try {
210
- envelope = await this.run(["auth", "status", "--json"], { timeoutMs: 30_000 });
218
+ envelope = await read(["auth", "status", "--verify", "--json"]);
219
+ // The CLI answers a failed verification with an error envelope. That is not the same as a
220
+ // credential that Feishu refused - it means the question was never answered - so the local
221
+ // status is read instead and the panel is told that Feishu was never asked.
222
+ if (envelope.ok === false) {
223
+ envelope = await read(["auth", "status", "--json"]);
224
+ verified = false;
225
+ }
211
226
  }
212
227
  catch (error) {
213
- return {
214
- installed: true,
215
- version,
216
- userReady: false,
217
- botReady: false,
218
- ...(error instanceof FeishuCliError ? { hint: error.message } : {}),
219
- };
228
+ try {
229
+ envelope = await read(["auth", "status", "--json"]);
230
+ verified = false;
231
+ }
232
+ catch {
233
+ return {
234
+ installed: true,
235
+ version,
236
+ userReady: false,
237
+ botReady: false,
238
+ ...(error instanceof FeishuCliError ? { hint: error.message } : {}),
239
+ };
240
+ }
220
241
  }
221
242
  const identities = pick(envelope.data ?? envelope, "identities");
222
243
  const user = identities?.user;
223
244
  const bot = identities?.bot;
224
245
  const identity = asString(envelope.identity) ?? pick(envelope.data ?? envelope, "identity");
246
+ const appId = asString(pick(envelope.data ?? envelope, "appId", "app_id"));
247
+ const note = asString(pick(envelope.data ?? envelope, "note"));
248
+ // THE HINT OF THE IDENTITY THAT IS FAILING. Both identities carry a hint, and the wrong one
249
+ // sends the person to the wrong command: the application's hint says "log in" when the real
250
+ // problem is that the credential was refused, and the user's hint says "check the credential"
251
+ // when all that is missing is a login.
252
+ const failing = bot?.available !== true ? bot : user;
225
253
  return {
226
254
  installed: true,
227
255
  version,
228
- ...(asString(pick(envelope.data ?? envelope, "appId", "app_id")) ? { appId: asString(pick(envelope.data ?? envelope, "appId", "app_id")) } : {}),
256
+ ...(appId ? { appId } : {}),
229
257
  userReady: user?.available === true,
230
258
  botReady: bot?.available === true,
231
259
  ...(identity === "user" || identity === "bot" ? { identity } : {}),
232
- ...(asString(user?.hint) ?? asString(bot?.hint) ? { hint: (asString(user?.hint) ?? asString(bot?.hint)) } : {}),
260
+ ...(asString(user?.status) ? { userStatus: asString(user?.status) } : {}),
261
+ ...(asString(bot?.status) ? { botStatus: asString(bot?.status) } : {}),
262
+ ...(asString(user?.message) ? { userMessage: asString(user?.message) } : {}),
263
+ ...(asString(bot?.message) ? { botMessage: asString(bot?.message) } : {}),
264
+ ...(asString(failing?.hint) ? { hint: asString(failing?.hint) } : {}),
265
+ ...(note ? { note } : {}),
266
+ verified,
233
267
  };
234
268
  }
235
269
  /**