@evandu/super-pi 0.1.138 → 0.1.140

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 (72) hide show
  1. package/agents/super-pi-knowledge/index.ts +11 -13
  2. package/agents/super-pi-memory/index.ts +11 -5
  3. package/agents/super-pi-task/core.ts +418 -1
  4. package/agents/super-pi-task/index.ts +115 -0
  5. package/bin/super-pi.js +7 -11
  6. package/dist/.super-pi-check-inputs.json +2 -2
  7. package/dist/client/.super-pi-client-sources.json +7 -6
  8. package/dist/client/assets/{CodeMirrorEditor-CVImD0nh.js → CodeMirrorEditor-DvZeOLny.js} +2 -2
  9. package/dist/client/assets/{dist-CiEW7LQW.js → dist-3BuyVG3H.js} +1 -1
  10. package/dist/client/assets/{dist-faxwvvOU.js → dist-BCUfH4nP.js} +1 -1
  11. package/dist/client/assets/{dist-yONALz0V.js → dist-BIIMtU9W.js} +1 -1
  12. package/dist/client/assets/{dist-CKI1xVeE.js → dist-C-QmRwbd.js} +1 -1
  13. package/dist/client/assets/{dist-4uPxkRdw.js → dist-CTkzLBdb.js} +1 -1
  14. package/dist/client/assets/{dist-CUACXe1Y.js → dist-C_VuSpx8.js} +1 -1
  15. package/dist/client/assets/{dist-BwQM8_Mk.js → dist-CaUsvSz1.js} +1 -1
  16. package/dist/client/assets/{dist-Clf26yld.js → dist-Cb1gxS57.js} +1 -1
  17. package/dist/client/assets/{dist-YB4GJD3u.js → dist-CqDSeoUk.js} +1 -1
  18. package/dist/client/assets/{dist-DozjvPAZ.js → dist-D7yfnKNC.js} +1 -1
  19. package/dist/client/assets/{dist-Cocw5XJ_.js → dist-DA8yW4lK.js} +1 -1
  20. package/dist/client/assets/{dist-amySbADh.js → dist-DAcS4Kj8.js} +1 -1
  21. package/dist/client/assets/{dist-B75OT6A9.js → dist-DN0M4DAX.js} +1 -1
  22. package/dist/client/assets/{dist-At94VK1P.js → dist-DRgWZqFH.js} +1 -1
  23. package/dist/client/assets/{dist-BIgRwYsI.js → dist-DdfW8Gjv.js} +1 -1
  24. package/dist/client/assets/{dist-DhEa5hHR.js → dist-DlQ8PMvv.js} +1 -1
  25. package/dist/client/assets/{dist-quQ6u6Rw.js → dist-Dvca0LeR.js} +1 -1
  26. package/dist/client/assets/{dist-BqblSnWL.js → dist-HJdP1yxN.js} +1 -1
  27. package/dist/client/assets/{dist-pnGHl3nq.js → dist-gBAr9yXF.js} +1 -1
  28. package/dist/client/assets/{dist-1H_gIENV.js → dist-gyzurUDR.js} +1 -1
  29. package/dist/client/assets/{dist-DSCjkO-y.js → dist-qG53hT1b.js} +1 -1
  30. package/dist/client/assets/{dist-CNP-Q5Gs.js → dist-r4Kq1ABA.js} +1 -1
  31. package/dist/client/assets/{index-CNoiRdJC.js → index-CNbh1fL2.js} +14 -14
  32. package/dist/client/assets/index-DCSFD4VV.css +1 -0
  33. package/dist/client/index.html +2 -2
  34. package/dist/server/application/conversation-service.d.ts +13 -4
  35. package/dist/server/application/conversation-service.js +112 -61
  36. package/dist/server/application/run-service.d.ts +13 -0
  37. package/dist/server/application/run-service.js +255 -69
  38. package/dist/server/application/scheduled-task-service.js +8 -1
  39. package/dist/server/application/session-orchestrator-service.d.ts +113 -0
  40. package/dist/server/application/session-orchestrator-service.js +234 -0
  41. package/dist/server/application/workspace-service.js +16 -1
  42. package/dist/server/feishu-cli.js +15 -4
  43. package/dist/server/file-index.d.ts +30 -0
  44. package/dist/server/file-index.js +49 -4
  45. package/dist/server/files.d.ts +21 -0
  46. package/dist/server/files.js +38 -0
  47. package/dist/server/http-server.js +1580 -1402
  48. package/dist/server/index.js +23 -6
  49. package/dist/server/logging.js +56 -3
  50. package/dist/server/pi-session-pool.d.ts +2 -0
  51. package/dist/server/pi-session-pool.js +1 -0
  52. package/dist/server/pi-session.d.ts +118 -0
  53. package/dist/server/pi-session.js +132 -10
  54. package/dist/server/runtime.d.ts +48 -0
  55. package/dist/server/runtime.js +189 -86
  56. package/dist/server/server-context.d.ts +2 -0
  57. package/dist/server/session-ledger.d.ts +60 -0
  58. package/dist/server/session-ledger.js +76 -0
  59. package/dist/server/store.d.ts +44 -1
  60. package/dist/server/store.js +114 -12
  61. package/dist/server/subagent-runs.d.ts +31 -1
  62. package/dist/server/subagent-runs.js +48 -20
  63. package/dist/server/subagents.js +7 -0
  64. package/dist/server/task-tool.d.ts +18 -0
  65. package/dist/server/task-tool.js +85 -0
  66. package/dist/server/tool-runner.js +13 -4
  67. package/dist/server/types.d.ts +50 -0
  68. package/dist/server/ws-server.d.ts +86 -6
  69. package/dist/server/ws-server.js +291 -51
  70. package/package.json +16 -2
  71. package/scripts/diagnose.mjs +161 -58
  72. package/dist/client/assets/index-FxTrUapG.css +0 -1
@@ -7,6 +7,8 @@ import { validateWorkflowPlan, workflowSummary } from "../workflow.js";
7
7
  import { ensureNameIndex } from "../file-name-index.js";
8
8
  import { formatTurnFailure } from "../turn-failure.js";
9
9
  import { describeThrownWithStack } from "../failure-text.js";
10
+ import { toWorkspaceRelative } from "../files.js";
11
+ import { refreshWorkspaceIndex } from "../file-index.js";
10
12
  import { execFile } from "node:child_process";
11
13
  import { promisify } from "node:util";
12
14
  const execFileAsync = promisify(execFile);
@@ -86,6 +88,8 @@ export class RunService {
86
88
  agentsDir;
87
89
  workflows;
88
90
  questionWaiters = new Map();
91
+ /** A stop can race a late question callback; remember the stopped turn so it cannot park forever. */
92
+ cancelledQuestionConversations = new Set();
89
93
  memory;
90
94
  setMemory(memory) { this.memory = memory; }
91
95
  /**
@@ -111,6 +115,21 @@ export class RunService {
111
115
  this.memory?.review(conversation);
112
116
  }
113
117
  activeRuns = new Map();
118
+ historyCutClaims = new Map();
119
+ holdHistoryCut(conversationId) {
120
+ this.historyCutClaims.set(conversationId, (this.historyCutClaims.get(conversationId) ?? 0) + 1);
121
+ }
122
+ releaseHistoryCut(conversationId) {
123
+ const count = this.historyCutClaims.get(conversationId) ?? 0;
124
+ if (count <= 1)
125
+ this.historyCutClaims.delete(conversationId);
126
+ else
127
+ this.historyCutClaims.set(conversationId, count - 1);
128
+ }
129
+ assertHistoryAvailable(conversationId) {
130
+ if (this.historyCutClaims.has(conversationId))
131
+ throw new Error("正在撤回上一条消息,Pi 的历史还没截断完成,请等撤回结束后再发送。");
132
+ }
114
133
  /**
115
134
  * The completion of the run currently holding a conversation's slot.
116
135
  *
@@ -130,7 +149,14 @@ export class RunService {
130
149
  this.activeRuns.set(conversationId, controller);
131
150
  }
132
151
  /** Releases the run slot and settles its completion promise, so a waiter wakes on the SAME instruction. */
133
- releaseRunSlot(conversationId) {
152
+ releaseRunSlot(conversationId, expectedController) {
153
+ // Ownership matters even on cleanup: a delayed finally MUST NOT delete a newer turn's slot or
154
+ // resolve its completion promise. Only the run that claimed the slot may release it.
155
+ const current = this.activeRuns.get(conversationId);
156
+ if (expectedController && current !== expectedController)
157
+ return;
158
+ if (!current)
159
+ return;
134
160
  this.activeRuns.delete(conversationId);
135
161
  this.runCompletions.get(conversationId)?.resolve();
136
162
  this.runCompletions.delete(conversationId);
@@ -294,6 +320,7 @@ export class RunService {
294
320
  }
295
321
  catch { /* ignore if already exited */ }
296
322
  }
323
+ this.subagents.stop(taskId);
297
324
  if (task && task.originMessageId !== undefined && task.originCallRow !== undefined) {
298
325
  const conv = this.store.snapshot().conversations.find((c) => c.id === task.conversationId);
299
326
  const msg = conv?.messages.find((m) => m.id === task.originMessageId);
@@ -367,6 +394,7 @@ export class RunService {
367
394
  },
368
395
  });
369
396
  this.broadcaster.broadcast({ type: "message.tools", conversationId: command.conversationId, messageId: command.messageId, toolCalls: calls });
397
+ this.broadcaster.broadcastState();
370
398
  this.settleQuestion(command.conversationId, command.messageId, command.callIndex, calls[command.callIndex]?.question);
371
399
  }
372
400
  questionKey(conversationId, messageId, index) {
@@ -392,6 +420,15 @@ export class RunService {
392
420
  else
393
421
  waiter.reject(new Error("用户没有回答这个问题"));
394
422
  }
423
+ cancelQuestionWaiters(conversationId) {
424
+ const reason = new Error("用户已停止当前回合,问题未回答");
425
+ for (const [key, waiter] of [...this.questionWaiters]) {
426
+ if (!key.startsWith(`${conversationId}\0`))
427
+ continue;
428
+ this.questionWaiters.delete(key);
429
+ waiter.reject(reason);
430
+ }
431
+ }
395
432
  /**
396
433
  * THE `question` TOOL BLOCKS HERE, ON THE CALL THAT ASKED IT.
397
434
  *
@@ -409,6 +446,11 @@ export class RunService {
409
446
  * backfilled with `options[0]`.
410
447
  */
411
448
  async askQuestion(conversationId) {
449
+ // The HTTP callback belongs to the run that admitted it. A stop followed by a new turn clears
450
+ // the conversation's cancellation marker, but must never let an old polling ask bind its row.
451
+ const owner = this.activeRuns.get(conversationId);
452
+ const cancelled = () => this.cancelledQuestionConversations.has(conversationId)
453
+ || owner?.signal.aborted === true || this.activeRuns.get(conversationId) !== owner;
412
454
  // THE ROW ARRIVES AFTER THE REQUEST, AND THAT IS A RACE, NOT A BUG IN THE CALLER.
413
455
  //
414
456
  // The extension lives INSIDE the Pi process. When Pi starts running the tool it writes
@@ -422,6 +464,8 @@ export class RunService {
422
464
  // genuinely has no pending call still fails - just not instantly.
423
465
  let found;
424
466
  for (let attempt = 0; attempt < 50 && !found; attempt += 1) {
467
+ if (cancelled())
468
+ throw new Error("用户已停止当前回合,问题未回答");
425
469
  found = this.store.pendingQuestion(conversationId);
426
470
  if (found)
427
471
  break;
@@ -429,6 +473,8 @@ export class RunService {
429
473
  }
430
474
  if (!found)
431
475
  throw new Error("这个会话里没有正在等待回答的 question 工具调用");
476
+ if (cancelled())
477
+ throw new Error("用户已停止当前回合,问题未回答");
432
478
  const { messageId, index } = found;
433
479
  return new Promise((resolve, reject) => {
434
480
  // No timeout. The promise resolves when the question is answered, rejects when it is cancelled
@@ -436,6 +482,10 @@ export class RunService {
436
482
  // continuing from a question that was never decided). The run-level teardown (stop / abort /
437
483
  // shutdown) clears the underlying ask and the client settles it as cancelled, which lands here
438
484
  // and rejects. A question that is simply unanswered waits indefinitely.
485
+ if (cancelled()) {
486
+ reject(new Error("用户已停止当前回合,问题未回答"));
487
+ return;
488
+ }
439
489
  this.questionWaiters.set(this.questionKey(conversationId, messageId, index), { resolve, reject });
440
490
  });
441
491
  }
@@ -473,6 +523,11 @@ export class RunService {
473
523
  isRunning(conversationId) {
474
524
  return this.activeRuns.has(conversationId);
475
525
  }
526
+ /** Returns true while a stop has been requested and the old producer is still unwinding. */
527
+ isStopping(conversationId) {
528
+ // Use the same identity as the slot; a separate conversation-keyed flag can leak across owners.
529
+ return this.activeRuns.get(conversationId)?.signal.aborted === true;
530
+ }
476
531
  /**
477
532
  * Checks the session credential together with the application-level turn truth.
478
533
  *
@@ -482,7 +537,8 @@ export class RunService {
482
537
  * left HTTP-only paths with no owner at all.
483
538
  */
484
539
  verifyPrimaryCapability(conversationId, capability) {
485
- return verifyCapability(conversationId, capability, { running: this.isRunning(conversationId) });
540
+ const controller = this.activeRuns.get(conversationId);
541
+ return verifyCapability(conversationId, capability, { running: Boolean(controller && !controller.signal.aborted) });
486
542
  }
487
543
  /**
488
544
  * Aborts all active conversation runs and sub-agents.
@@ -539,50 +595,85 @@ export class RunService {
539
595
  async stopRun(conversationId) {
540
596
  // Abort immediately before waiting for the conversation lane; otherwise a stop queued behind a
541
597
  // long streaming command could never reach the controller that it is meant to cancel.
542
- this.activeRuns.get(conversationId)?.abort();
598
+ const controller = this.activeRuns.get(conversationId);
599
+ controller?.abort();
543
600
  this.broadcaster.broadcast({ type: "run.stopping", conversationId });
544
- await this.runtime.stop?.(conversationId);
545
- return this.serializeConversation(conversationId, () => this.stopRunUnsafe(conversationId));
601
+ // A synchronous task tool can be waiting for its child HTTP response. Stop children and plans
602
+ // before awaiting the parent's idle promise, otherwise parent and child wait on each other until
603
+ // the SDK's five-second backstop. Do this before any await so a stale stop cannot reach new work.
604
+ this.subagents.stopConversation(conversationId);
605
+ this.workflows.stopConversation(conversationId);
606
+ // Cancel human-facing waits BEFORE asking Pi to become idle. A session can be blocked inside an
607
+ // extension UI bridge or the question tool; Pi's idle promise cannot settle until that wait is
608
+ // released, so cancelling after `runtime.stop()` turns a click into the full idle backstop.
609
+ await this.cancelPendingRunInteractions(conversationId);
610
+ // Persisting cancelled question rows yields to other commands. The old turn can finish and a
611
+ // new one can claim its slot during that await; runtime.stop looks up the CURRENT SDK turn by id,
612
+ // so calling it unconditionally here would abort the new turn on behalf of the old stop click.
613
+ if (this.activeRuns.get(conversationId) === controller)
614
+ await this.runtime.stop?.(conversationId);
615
+ return this.serializeConversation(conversationId, () => this.stopRunUnsafe(conversationId, controller));
546
616
  }
547
- async stopRunUnsafe(conversationId) {
617
+ /** Releases waits that can prevent Pi's abort from reaching its idle state. Idempotent by design. */
618
+ async cancelPendingRunInteractions(conversationId) {
619
+ this.cancelledQuestionConversations.add(conversationId);
620
+ this.cancelQuestionWaiters(conversationId);
548
621
  this.memory?.cancel(conversationId);
549
622
  this.extensionUiService.cancelPendingUi(conversationId, "aborted");
623
+ // Read only this conversation. The old full-state snapshot cloned every historical transcript before
624
+ // the stop could even settle its pending questions.
625
+ await Promise.all(this.store.pendingQuestions(conversationId).map(async (pending) => {
626
+ try {
627
+ const calls = await this.store.updateToolCall(conversationId, pending.messageId, pending.index, { question: { items: pending.question.items, status: "cancelled" } });
628
+ this.broadcaster.broadcast({ type: "message.tools", conversationId, messageId: pending.messageId, toolCalls: calls });
629
+ this.broadcaster.broadcastState();
630
+ this.settleQuestion(conversationId, pending.messageId, pending.index, calls[pending.index]?.question);
631
+ }
632
+ catch (error) {
633
+ console.warn(`Super Pi: could not cancel pending question for ${conversationId}: ${error instanceof Error ? error.message : String(error)}`);
634
+ }
635
+ }));
636
+ }
637
+ async stopRunUnsafe(conversationId, expectedController) {
638
+ // A newer turn may have claimed this conversation while the old SDK session was draining. Its
639
+ // pending question belongs to the new turn and must never be cancelled by the stale stop.
640
+ // The first cancellation happens at stop arrival, before waiting for Pi idle. Keep this call here
641
+ // as an idempotent safety net for callers that enter the serialized cleanup directly. If a new run
642
+ // has claimed the slot while the old stop was waiting, this cleanup belongs to the old controller and
643
+ // must not cancel the new run's questions or stamp its conversation stopped.
644
+ const currentAtEntry = this.activeRuns.get(conversationId);
645
+ // A stop captured no controller (for example, the old run released just before the click) must
646
+ // never cancel a newer run that appeared while its command was in flight. The no-active case is
647
+ // still allowed through for cleanup of a pending question fixture/HTTP ask with no slot owner.
648
+ if (!expectedController && currentAtEntry)
649
+ return;
650
+ if (expectedController && currentAtEntry && currentAtEntry !== expectedController)
651
+ return;
652
+ await this.cancelPendingRunInteractions(conversationId);
550
653
  // THE `question` TOOL IS A PENDING ASK THE STOP MUST SETTLE, not a client-only state. Its row only
551
654
  // ever leaves `pending` through the user's `question.answer` command, so a stop used to leave the
552
655
  // ask waiting forever: the extension's HTTP handler never returned, the ask's 250ms full-snapshot
553
656
  // poll never cleared, and the card stayed interactive on a dead turn (measured by
554
657
  // scripts/question-stop.test.mjs). The same settlement the Extension UI channel gets from
555
658
  // `cancelPendingUi` above - answered `cancelled`, never a default - applied to the question rows.
556
- const stopped = this.store.snapshot().conversations.find((item) => item.id === conversationId);
557
- for (const message of stopped?.messages ?? []) {
558
- for (let index = 0; index < (message.toolCalls?.length ?? 0); index += 1) {
559
- const question = message.toolCalls?.[index]?.question;
560
- if (question?.status !== "pending")
561
- continue;
562
- const calls = await this.store.updateToolCall(conversationId, message.id, index, { question: { items: question.items, status: "cancelled" } });
563
- this.broadcaster.broadcast({ type: "message.tools", conversationId, messageId: message.id, toolCalls: calls });
564
- this.settleQuestion(conversationId, message.id, index, calls[index]?.question);
565
- }
566
- }
567
- if (this.subagents.stopConversation(conversationId)) {
568
- console.log(`Super Pi: stopped the sub-agent runs of conversation ${conversationId}.`);
569
- }
570
- // Plans too, and separately: a plan's own loop holds the signal that reaches its current child, so
571
- // stopping only the runs would let it dispatch the NEXT step a moment later. Reported on its own line
572
- // because a person who pressed stop wants to know a plan was still marching, not just a card.
573
- if (this.workflows.stopConversation(conversationId)) {
574
- console.log(`Super Pi: stopped the running orchestration plans of conversation ${conversationId}.`);
575
- }
576
659
  // AWAIT RUN SLOT RELEASE BEFORE CLAIMING "STOPPED".
577
660
  // A stop previously called setStatus("stopped") immediately, while activeRuns still held the
578
661
  // conversation slot and the streaming lane was still draining. This produced the contradiction:
579
662
  // the UI displayed 「已停止」 and offered 「发送」, but sending a new message hit an active
580
663
  // run slot and was falsely placed into 「排队中」, while the previous turn might still produce text.
581
664
  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);
665
+ const current = this.activeRuns.get(conversationId);
666
+ if (current && expectedController && current === expectedController) {
667
+ // The deadline is a bound on this command, not proof that Pi stopped. Force-releasing here lets
668
+ // a new prompt reuse an SDK session that is still running and lets the old turn's final writes
669
+ // race the new one. Keep the slot until its owner's finally releases it; a later completion will
670
+ // broadcast the truthful stopped state.
671
+ throw new Error("停止超时:上一轮仍未退出,当前回合尚未退出。可以重试停止或检查运行时状态。");
585
672
  }
673
+ // A newer turn may have started after the aborted turn released its slot but before this cleanup
674
+ // acquired the conversation lane. Never stamp that new turn as stopped.
675
+ if (this.activeRuns.get(conversationId) && this.activeRuns.get(conversationId) !== expectedController)
676
+ return;
586
677
  await this.store.setStatus(conversationId, "stopped");
587
678
  this.broadcaster.broadcastState();
588
679
  }
@@ -650,11 +741,20 @@ export class RunService {
650
741
  // which is a later answer than the one the message is waiting for.
651
742
  if (!this.activeRuns.has(conversationId))
652
743
  return;
744
+ // A stop has already been requested. The old producer still owns the slot until its `finally`,
745
+ // but handing a follow-up to Pi here would let the dying turn consume it after the user pressed
746
+ // stop. Keep the row in Super Pi's waiting queue; the next turn will pick it up once the slot is
747
+ // genuinely released.
748
+ if (this.isStopping(conversationId))
749
+ return;
653
750
  // AN INTERRUPT IS ALREADY STARTING. It aborts the run and empties Pi's queues, so a message handed
654
751
  // to Pi now would be taken back a moment later. It waits in the list for the next run, exactly as it
655
752
  // did before.
656
753
  if (this.interruptTargets.has(conversationId))
657
754
  return;
755
+ // The previous run may have taken this row while its persistence promise was still resolving.
756
+ if (!this.store.listWaitingMessages(conversationId).some(item => item.id === message.id && item.delivery === "waiting"))
757
+ return;
658
758
  // A MESSAGE THAT IS A COMMAND IS NOT QUEUED. Pi expands a skill command or a prompt template before
659
759
  // it queues the text (`agent-session.ts:2221-2222`), and it REFUSES an extension command outright.
660
760
  // An expanded text is not the text Super Pi has, so the delivery could not be resolved back to its
@@ -816,8 +916,11 @@ export class RunService {
816
916
  // honest - a client that saw the new tool row in the same frame also saw the running
817
917
  // status, instead of flashing back to a still-spinning waiting mark for the next paint.
818
918
  await promoteToRunning();
919
+ const hadPendingQuestion = toolCalls.some(call => call.question?.status === "pending");
819
920
  toolCalls = await work();
820
921
  this.broadcaster.broadcast({ type: "message.tools", conversationId: conversation.id, messageId: assistant.id, toolCalls });
922
+ if (hadPendingQuestion !== toolCalls.some(call => call.question?.status === "pending"))
923
+ this.broadcaster.broadcastState();
821
924
  }).catch((error) => {
822
925
  console.warn(`Super Pi: recording ${what} failed: ${error instanceof Error ? error.message : "unknown error"}`);
823
926
  });
@@ -842,6 +945,7 @@ export class RunService {
842
945
  let thinkingBlock = -1;
843
946
  let thinkingAnswerAt = -1;
844
947
  let thinkingInterrupted = false;
948
+ let answerAfterThinking = false;
845
949
  const currentThinkingBlock = () => {
846
950
  if (thinkingActive)
847
951
  return thinkingBlock;
@@ -922,7 +1026,6 @@ export class RunService {
922
1026
  // this one was not, and the JSON persistence backend's slower writes widen the window enough for
923
1027
  // the browser test to catch the difference between capturing and reading.
924
1028
  const thinkingAt = thought;
925
- const callThinkingBlock = currentThinkingBlock();
926
1029
  // The block says WHICH TOOL and WHAT IT IS AIMED AT as soon as either is known. 「工具」 as the name
927
1030
  // and 「正在生成参数…」 as the only line below it told a reader nothing for the seconds a large `edit`
928
1031
  // spends writing its replacement text - and by the time the file path was on screen, the block looked
@@ -934,9 +1037,16 @@ export class RunService {
934
1037
  preparing: true,
935
1038
  at,
936
1039
  thinkingAt,
937
- thinkingBlock: callThinkingBlock,
938
1040
  }));
939
- preparingRows.set(call.contentIndex, { row, name: call.name });
1041
+ // `thinkingAt` GOES WITH THE PLACEHOLDER. It is how much reasoning had been written when the
1042
+ // model announced this call, and the real call needs it to work out whether the reasoning kept
1043
+ // going afterwards - which is the fact that says whether the call belongs inside the reasoning
1044
+ // fold or in the answer (see `onToolCall`).
1045
+ // THE BLOCK INDEX GOES WITH THE PLACEHOLDER TOO, and it is captured HERE - at the moment the
1046
+ // model announced the call - because that is the only moment the block it belongs to is the
1047
+ // OPEN one. By the time the call runs, the reasoning may have ended and a LATER block may have
1048
+ // opened, and the index of the block the call belongs to is no longer the current one.
1049
+ preparingRows.set(call.contentIndex, { row, name: call.name, thinkingAt, thinkingActive, thinkingBlock: thinkingActive ? thinkingBlock : null });
940
1050
  };
941
1051
  /**
942
1052
  * THE AGENT IS ABOUT TO WRITE A FILE.
@@ -962,16 +1072,29 @@ export class RunService {
962
1072
  const tracker = this.fileChanges;
963
1073
  if (!tracker)
964
1074
  return;
965
- tracker.record(workspace.id, path, { root: workspace.path, conversationId: conversation.id });
966
- void this.fileIndexes?.get(workspace.id)?.refresh(path);
1075
+ // THE PATH AS THE WORKSPACE SPELLS IT, TRANSLATED ONCE.
1076
+ //
1077
+ // This path comes from the MODEL - it is the `path` argument of `write`/`edit` - and a model
1078
+ // that has been told where the workspace is writes an ABSOLUTE path: `/home/me/project/src/a.ts`
1079
+ // is the same file as `src/a.ts`. Every consumer below keys its rows by the workspace's own
1080
+ // spelling - the change tracker, the file index and the name index - so the translation happens
1081
+ // once, here, and all three get the same answer.
1082
+ //
1083
+ // WITHOUT IT the three disagreed: the tracker resolved the path against the workspace and got
1084
+ // it right, while the name index stored the absolute path with its leading slash stripped
1085
+ // (`home/me/project/src/a.ts`) - an entry that matched by name and then pointed at a file that
1086
+ // does not exist.
1087
+ const relative = toWorkspaceRelative(workspace.path, path);
1088
+ tracker.record(workspace.id, relative, { root: workspace.path, conversationId: conversation.id });
1089
+ refreshWorkspaceIndex(this.fileIndexes, workspace.id, relative);
967
1090
  // The name index learns about the write as well, so the file the agent just created is
968
1091
  // findable by name straight away rather than after the next background scan. It goes through
969
1092
  // the SAME `ensureNameIndex` the HTTP layer uses, so there is one index per workspace rather
970
1093
  // than one per layer.
971
1094
  if (this.nameIndexes)
972
- ensureNameIndex(this.nameIndexes, workspace.id, workspace.path).upsert(path);
1095
+ ensureNameIndex(this.nameIndexes, workspace.id, workspace.path).upsert(relative);
973
1096
  if (toolCallId)
974
- fileWritePaths.set(toolCallId, path);
1097
+ fileWritePaths.set(toolCallId, relative);
975
1098
  this.broadcastFileChanges(workspace.id);
976
1099
  };
977
1100
  const onToolCall = (call) => {
@@ -1006,7 +1129,13 @@ export class RunService {
1006
1129
  rowByToolCallId.set(call.id, row);
1007
1130
  this.noteToolCallSite(conversation.id, call.id, assistant.id, row);
1008
1131
  }
1009
- enqueueTools("a tool call", () => this.store.updateToolCall(conversation.id, assistant.id, row, { label: call.name, detail: call.detail, args: call.args, live: "", preparing: false, ...(call.question ? { question: call.question } : {}) }));
1132
+ // The offset says whether the reasoning grew after this call was initiated. The captured
1133
+ // `thinkingActive` flag covers the final-moment case, unless answer text later appeared after
1134
+ // thinking_end. That exclusion keeps after-body questions out of the previous reasoning fold.
1135
+ const callThinkingBlock = prepared[1].thinkingAt < thought || (prepared[1].thinkingActive && !answerAfterThinking) ? prepared[1].thinkingBlock : null;
1136
+ // `at` is corrected to the execution position; `thinkingAt` stays at initiation so calls
1137
+ // inside one reasoning block do not all collapse onto the final reasoning offset.
1138
+ enqueueTools("a tool call", () => this.store.updateToolCall(conversation.id, assistant.id, row, { label: call.name, detail: call.detail, args: call.args, live: "", preparing: false, at, thinkingBlock: callThinkingBlock, ...(call.question ? { question: call.question } : {}) }));
1010
1139
  return;
1011
1140
  }
1012
1141
  const row = rows++;
@@ -1016,6 +1145,12 @@ export class RunService {
1016
1145
  }
1017
1146
  // Captured for the same reason as the preparing path above: the queued append must anchor the call
1018
1147
  // where it was MADE, not where the queue happened to be when it drained.
1148
+ //
1149
+ // THIS IS THE PATH WITH NO PLACEHOLDER - Pi never announced a `toolcall_start` for this call,
1150
+ // so there is no earlier moment to compare offsets against, and the reasoning offset is taken
1151
+ // here, at the announcement. The block is decided by the same question as everywhere else: was
1152
+ // the reasoning still growing after the call was announced? Here the answer is read from
1153
+ // `thinkingActive`, because a call with no placeholder carries no earlier offset to compare.
1019
1154
  const thinkingAt = thought;
1020
1155
  const callThinkingBlock = currentThinkingBlock();
1021
1156
  enqueueTools("a tool call", () => this.store.appendToolCall(conversation.id, assistant.id, { label: call.name, detail: call.detail, args: call.args, at, thinkingAt, thinkingBlock: callThinkingBlock, ...(call.question ? { question: call.question } : {}) }));
@@ -1034,7 +1169,7 @@ export class RunService {
1034
1169
  const writtenPath = call.id ? fileWritePaths.get(call.id) : undefined;
1035
1170
  if (writtenPath) {
1036
1171
  this.fileChanges?.confirm(workspace.id, writtenPath, workspace.path);
1037
- void this.fileIndexes?.get(workspace.id)?.refresh(writtenPath);
1172
+ refreshWorkspaceIndex(this.fileIndexes, workspace.id, writtenPath);
1038
1173
  if (this.nameIndexes)
1039
1174
  ensureNameIndex(this.nameIndexes, workspace.id, workspace.path).upsert(writtenPath);
1040
1175
  if (call.id)
@@ -1097,6 +1232,7 @@ export class RunService {
1097
1232
  thought = 0;
1098
1233
  thinkingActive = false;
1099
1234
  thinkingInterrupted = true;
1235
+ answerAfterThinking = false;
1100
1236
  rows = 0;
1101
1237
  rowByToolCallId.clear();
1102
1238
  preparingRows.clear();
@@ -1143,6 +1279,7 @@ export class RunService {
1143
1279
  if (newBlock) {
1144
1280
  thinkingBlock++;
1145
1281
  thinkingAnswerAt = spoken;
1282
+ answerAfterThinking = false;
1146
1283
  }
1147
1284
  thinkingInterrupted = false;
1148
1285
  thinkingActive = true;
@@ -1197,6 +1334,8 @@ export class RunService {
1197
1334
  // answering rather than hanging on auth / quota / network. `promoteToRunning` is a no-op
1198
1335
  // after the first signal so this stays a one-shot status write, not a per-token one.
1199
1336
  await promoteToRunning();
1337
+ if (!thinkingActive && thinkingBlock >= 0)
1338
+ answerAfterThinking = true;
1200
1339
  this.store.appendAssistantDeltaLive(conversation.id, assistant.id, delta);
1201
1340
  spoken += delta.length;
1202
1341
  // Tool events are structural transcript boundaries. Publish any boundary Pi announced before
@@ -1284,10 +1423,34 @@ export class RunService {
1284
1423
  await this.store.releaseSendingMessages(conversation.id);
1285
1424
  }
1286
1425
  }
1426
+ /** Keep admission owned through final persistence and recheck the durable queue before releasing. */
1427
+ async runUntilDrained(conversation, workspace, prompt, controller, messageId, images) {
1428
+ for (;;) {
1429
+ if (!controller.signal.aborted)
1430
+ await this.runConversationTurns(conversation, workspace, prompt, controller, messageId, images);
1431
+ await this.store.setStatus(conversation.id, controller.signal.aborted ? "stopped" : "completed");
1432
+ if (controller.signal.aborted)
1433
+ return;
1434
+ // No await between the last empty observation and releasing the slot.
1435
+ // A send admitted during setStatus must be answered here, even though Pi's previous turn ended.
1436
+ const next = this.store.listWaitingMessages(conversation.id).find(item => item.delivery === "waiting");
1437
+ if (!next) {
1438
+ // Release on the same instruction turn as the empty observation. Returning to an awaiting
1439
+ // caller first would create another microtask window for a send to enter the old run's queue.
1440
+ this.releaseRunSlot(conversation.id, controller);
1441
+ return;
1442
+ }
1443
+ conversation = this.turnContext(conversation.id).conversation;
1444
+ prompt = composePrompt(next);
1445
+ messageId = next.id;
1446
+ images = next.images;
1447
+ }
1448
+ }
1287
1449
  /**
1288
1450
  * Enqueues or starts execution for a user message.
1289
1451
  */
1290
1452
  async enqueueAndRun(conversationId, content, images, attachments, clientId) {
1453
+ this.assertHistoryAvailable(conversationId);
1291
1454
  /**
1292
1455
  * A CLIENT ID THIS CONVERSATION ALREADY HAS IS A RESEND, NOT A SECOND MESSAGE.
1293
1456
  *
@@ -1327,40 +1490,51 @@ export class RunService {
1327
1490
  // for a turn that had already begun. The state a message is born into is a claim about it, and a claim
1328
1491
  // retracted in the next frame is worse than never making it.
1329
1492
  //
1330
- // Reading the slot here and claiming it below leaves a window between them, and the window is real:
1331
- // `message.send` runs outside the socket's command queue precisely so a follow-up can overtake a
1332
- // command already in flight, so two sends CAN be inside this method at the same time. The loser wrote
1333
- // itself as an ordinary message and now finds the slot taken - its words are on screen with nobody
1334
- // going to answer them. Repairing that is what the guard below is for; it is not a rare path, it is
1335
- // what two fast sends looks like from the inside.
1336
1493
  // An aborted run is unwinding its slot. Wait out that brief cleanup rather than falsely
1337
1494
  // treating it as an active turn and sending this message to a dying follow-up queue.
1338
1495
  const active = this.activeRuns.get(conversation.id);
1339
1496
  if (active?.signal.aborted) {
1340
1497
  await this.awaitRunSlotReleased(conversation.id, false);
1341
1498
  }
1499
+ this.assertHistoryAvailable(conversationId);
1342
1500
  const busy = this.activeRuns.has(conversation.id) || this.interruptTargets.has(conversation.id);
1343
- const message = await this.store.addUserMessage(conversationId, trimmed, validImages, attachments, { clientId, ...(busy ? { delivery: "waiting" } : {}) });
1501
+ // Reserve BEFORE the first persistence await. Revert/stop must see this admitted send even while
1502
+ // its message write is pending; otherwise a cut can start while this send goes on to launch Pi.
1503
+ const reservedController = busy ? undefined : new AbortController();
1504
+ if (reservedController) {
1505
+ this.cancelledQuestionConversations.delete(conversation.id);
1506
+ this.claimRunSlot(conversation.id, reservedController);
1507
+ }
1508
+ let message;
1509
+ try {
1510
+ message = await this.store.addUserMessage(conversationId, trimmed, validImages, attachments, { clientId, ...(busy ? { delivery: "waiting" } : {}) });
1511
+ }
1512
+ catch (error) {
1513
+ if (reservedController)
1514
+ this.releaseRunSlot(conversation.id, reservedController);
1515
+ throw error;
1516
+ }
1344
1517
  this.broadcaster.broadcastState();
1345
- // Claim the run slot synchronously (no await between has() and set()) so two
1346
- // rapid sends can never both pass the guard and start concurrent turns.
1347
- if (this.activeRuns.has(conversation.id) || this.interruptTargets.has(conversation.id)) {
1348
- if (!busy) {
1349
- // Lost the race described above. No turn has opened on this message and no model has seen it, so
1350
- // calling it waiting is a statement of fact - and it puts the words exactly where a message born
1351
- // waiting would have put them, which is the only place a person can find them.
1352
- await this.store.markMessageWaiting(conversationId, message.id);
1353
- this.broadcaster.broadcastState();
1354
- }
1518
+ if (!reservedController) {
1355
1519
  // The turn that is running answers this message itself, through Pi's own follow-up queue, rather
1356
1520
  // than by a turn of its own once the running one is over. The message stays in the waiting list
1357
1521
  // either way, so it is still the user's to withdraw - Pi is holding a copy, not the only copy.
1358
- await this.submitToPiQueue(conversationId, message);
1522
+ const waiting = this.store.listWaitingMessages(conversationId).find(item => item.id === message.id && item.delivery === "waiting");
1523
+ if (!waiting)
1524
+ return; // The owner already opened/delivered it while this write was pending.
1525
+ if (!this.activeRuns.has(conversationId) && !this.interruptTargets.has(conversationId)) {
1526
+ await this.processWaitingMessage(conversationId, message.id);
1527
+ }
1528
+ else
1529
+ await this.submitToPiQueue(conversationId, message);
1359
1530
  return;
1360
1531
  }
1361
- const controller = new AbortController();
1362
- this.claimRunSlot(conversation.id, controller);
1532
+ const controller = reservedController;
1363
1533
  try {
1534
+ if (controller.signal.aborted) {
1535
+ await this.store.setStatus(conversation.id, "stopped");
1536
+ return;
1537
+ }
1364
1538
  let expanded;
1365
1539
  try {
1366
1540
  if (validImages?.length) {
@@ -1406,8 +1580,7 @@ export class RunService {
1406
1580
  this.broadcaster.broadcastState();
1407
1581
  throw error;
1408
1582
  }
1409
- await this.runConversationTurns(conversation, workspace, expanded, controller, message.id, validImages);
1410
- await this.store.setStatus(conversation.id, controller.signal.aborted ? "stopped" : "completed");
1583
+ await this.runUntilDrained(conversation, workspace, expanded, controller, message.id, validImages);
1411
1584
  }
1412
1585
  catch (error) {
1413
1586
  // A failing turn needs to be READ AS A FAILING TURN, not just stamped "failed" in a corner no one
@@ -1448,8 +1621,11 @@ export class RunService {
1448
1621
  throw error;
1449
1622
  }
1450
1623
  finally {
1451
- this.releaseRunSlot(conversation.id);
1452
- this.extensionUiService.cancelPendingUi(conversation.id, "expired");
1624
+ this.releaseRunSlot(conversation.id, controller);
1625
+ // An old turn can unwind after stop has admitted a new one. Do not cancel the new turn's
1626
+ // extension dialog merely because this turn's finally block ran late.
1627
+ if (!this.activeRuns.has(conversation.id))
1628
+ this.extensionUiService.cancelPendingUi(conversation.id, "expired");
1453
1629
  this.broadcaster.broadcastState();
1454
1630
  }
1455
1631
  }
@@ -1484,7 +1660,7 @@ export class RunService {
1484
1660
  async awaitRunSlotReleased(conversationId, onlyWhileStopped = false, timeoutMs = 15_000) {
1485
1661
  const deadline = Date.now() + timeoutMs;
1486
1662
  while (this.activeRuns.has(conversationId) && Date.now() < deadline) {
1487
- if (onlyWhileStopped && this.store.snapshot().conversations.find((item) => item.id === conversationId)?.status !== "stopped")
1663
+ if (onlyWhileStopped && this.store.conversationStatus(conversationId) !== "stopped")
1488
1664
  return;
1489
1665
  const completion = this.runCompletions.get(conversationId)?.promise;
1490
1666
  if (!completion)
@@ -1507,6 +1683,7 @@ export class RunService {
1507
1683
  * not changed, so no process is spawned and no history is replayed.
1508
1684
  */
1509
1685
  async interruptQueued(conversationId, messageId) {
1686
+ this.assertHistoryAvailable(conversationId);
1510
1687
  if (this.interruptTargets.has(conversationId))
1511
1688
  throw new Error("已有一条打断回复正在启动,请稍候");
1512
1689
  const selected = this.store.listWaitingMessages(conversationId).find((item) => item.id === messageId);
@@ -1552,6 +1729,7 @@ export class RunService {
1552
1729
  // steer/reorder/cancel wait until that turn ends, which is especially visible after a background
1553
1730
  // result auto-starts a long follow-up. The activeRuns slot protects the turn after this lane exits.
1554
1731
  const controller = await this.serializeConversation(conversationId, async () => {
1732
+ this.assertHistoryAvailable(conversationId);
1555
1733
  const reserved = this.interruptTargets.get(conversationId);
1556
1734
  if (reserved && reserved !== messageId)
1557
1735
  throw new Error("打断回复正在启动,请等待所选消息开始回答");
@@ -1561,6 +1739,7 @@ export class RunService {
1561
1739
  if (!message || message.delivery !== "waiting")
1562
1740
  throw new Error("等待中的消息不存在或已被处理");
1563
1741
  const controller = new AbortController();
1742
+ this.cancelledQuestionConversations.delete(conversationId);
1564
1743
  this.claimRunSlot(conversationId, controller);
1565
1744
  return controller;
1566
1745
  });
@@ -1570,8 +1749,7 @@ export class RunService {
1570
1749
  if (!message)
1571
1750
  throw new Error("消息不存在或已被撤回");
1572
1751
  // runConversationTurns opens this first message; do not create a second assistant placeholder.
1573
- await this.runConversationTurns(conversation, workspace, composePrompt(message), controller, message.id, message.images);
1574
- await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "completed");
1752
+ await this.runUntilDrained(conversation, workspace, composePrompt(message), controller, message.id, message.images);
1575
1753
  }
1576
1754
  catch (error) {
1577
1755
  await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "failed");
@@ -1581,8 +1759,9 @@ export class RunService {
1581
1759
  throw error;
1582
1760
  }
1583
1761
  finally {
1584
- this.releaseRunSlot(conversationId);
1585
- this.extensionUiService.cancelPendingUi(conversationId, "expired");
1762
+ this.releaseRunSlot(conversationId, controller);
1763
+ if (!this.activeRuns.has(conversationId))
1764
+ this.extensionUiService.cancelPendingUi(conversationId, "expired");
1586
1765
  this.broadcaster.broadcastState();
1587
1766
  }
1588
1767
  }
@@ -1642,6 +1821,7 @@ export class RunService {
1642
1821
  return this.serializeConversation(conversationId, async () => {
1643
1822
  if (this.interruptTargets.get(conversationId) === messageId)
1644
1823
  throw new Error("这条消息正在打断并回答,无法修改");
1824
+ await this.clearPiQueues(conversationId);
1645
1825
  await this.store.editWaitingMessage(conversationId, messageId, content);
1646
1826
  this.broadcaster.broadcastState();
1647
1827
  });
@@ -1653,6 +1833,7 @@ export class RunService {
1653
1833
  return this.serializeConversation(conversationId, async () => {
1654
1834
  if (this.interruptTargets.get(conversationId) === messageId)
1655
1835
  throw new Error("这条消息正在打断并回答,无法移动");
1836
+ await this.clearPiQueues(conversationId);
1656
1837
  await this.store.reorderWaitingMessage(conversationId, messageId, toIndex);
1657
1838
  this.broadcaster.broadcastState();
1658
1839
  });
@@ -1698,6 +1879,7 @@ export class RunService {
1698
1879
  throw new Error(capability.message);
1699
1880
  if (body.depth !== undefined)
1700
1881
  checkSubagentDepth(body.depth);
1882
+ const signal = this.activeRuns.get(conversationId)?.signal;
1701
1883
  const { conversation, cwd } = this.dispatchContext(conversationId);
1702
1884
  const parent = body.parentRunId ? this.store.snapshot().subagentRuns.find((run) => run.id === body.parentRunId) : undefined;
1703
1885
  if (body.parentRunId && (!parent || parent.conversationId !== conversation.id)) {
@@ -1725,6 +1907,9 @@ export class RunService {
1725
1907
  ...(body.loopId ? { loopId: body.loopId } : {}),
1726
1908
  ...(body.role ? { role: body.role } : {}),
1727
1909
  denyTools: parentDeniedTools(await this.runtime.agentFor?.(conversation)),
1910
+ // Bind work that arrives during teardown to its original turn, including aborts that happen
1911
+ // while tool anchoring, agent lookup or the recorder's initial persistence is still pending.
1912
+ signal,
1728
1913
  });
1729
1914
  if (body.background === true) {
1730
1915
  await this.store.setBackgroundTask({
@@ -1755,7 +1940,7 @@ export class RunService {
1755
1940
  this.broadcaster.broadcastState();
1756
1941
  return row;
1757
1942
  });
1758
- if (!this.activeRuns.has(conversation.id) && !this.interruptTargets.has(conversation.id)) {
1943
+ if (!signal?.aborted && run.status !== "stopped" && !this.activeRuns.has(conversation.id) && !this.interruptTargets.has(conversation.id)) {
1759
1944
  void this.processWaitingMessage(conversation.id, queued.id).catch((error) => {
1760
1945
  console.warn(`Super Pi: auto-trigger for background sub-agent result failed: ${error instanceof Error ? error.message : "unknown error"}`);
1761
1946
  });
@@ -1830,6 +2015,7 @@ export class RunService {
1830
2015
  if (!capability.ok)
1831
2016
  throw new Error(capability.message);
1832
2017
  const { conversation, cwd } = this.dispatchContext(conversationId);
2018
+ const signal = this.activeRuns.get(conversationId)?.signal;
1833
2019
  // Validated against the roster THIS CALL reads from disk, for the same freshness reason the task tool
1834
2020
  // builds its roster per turn: a sub-agent added while the server is running is dispatchable on the next
1835
2021
  // message with no restart, because a person edits `agents/*.md` while the server runs.
@@ -1841,7 +2027,7 @@ export class RunService {
1841
2027
  conversationId: conversation.id,
1842
2028
  cwd,
1843
2029
  plan: validated.plan,
1844
- signal: this.activeRuns.get(conversation.id)?.signal,
2030
+ signal,
1845
2031
  ...(conversation.model ? { model: conversation.model } : {}),
1846
2032
  // The same inheritance a single dispatch applies: a parent's DENY reaches its children, its
1847
2033
  // allowlist does not. Every step gets the primary's denials, exactly as one dispatch would.