mixdog 0.9.35 → 0.9.37

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 (105) hide show
  1. package/package.json +3 -2
  2. package/scripts/abort-recovery-test.mjs +118 -0
  3. package/scripts/compact-smoke.mjs +7 -7
  4. package/scripts/explore-bench-tmp.mjs +19 -0
  5. package/scripts/explore-bench.mjs +36 -6
  6. package/scripts/explore-prompt-policy-test.mjs +27 -0
  7. package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
  8. package/scripts/openai-ws-early-settle-test.mjs +176 -0
  9. package/scripts/output-style-smoke.mjs +17 -17
  10. package/scripts/provider-toolcall-test.mjs +333 -14
  11. package/scripts/recall-bench-cases.json +3 -3
  12. package/scripts/recall-bench.mjs +1 -1
  13. package/scripts/recall-quality-cases.json +4 -4
  14. package/scripts/recall-usecase-cases.json +2 -2
  15. package/scripts/session-bench.mjs +13 -13
  16. package/scripts/steering-drain-buckets-test.mjs +72 -11
  17. package/scripts/submit-commandbusy-race-test.mjs +114 -0
  18. package/scripts/tool-smoke.mjs +83 -10
  19. package/src/app.mjs +2 -1
  20. package/src/defaults/cycle3-review-prompt.md +9 -0
  21. package/src/defaults/skills/setup/SKILL.md +93 -293
  22. package/src/lib/rules-builder.cjs +3 -2
  23. package/src/output-styles/default.md +2 -2
  24. package/src/output-styles/extreme-minimal.md +1 -1
  25. package/src/output-styles/minimal.md +1 -1
  26. package/src/output-styles/simple.md +2 -3
  27. package/src/rules/agent/30-explorer.md +15 -7
  28. package/src/rules/lead/01-general.md +4 -0
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
  30. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/mcp/client.mjs +7 -7
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  33. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  34. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
  35. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
  36. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
  37. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
  40. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
  41. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
  42. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +4 -2
  43. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
  44. package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
  45. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
  46. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
  47. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
  48. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
  49. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
  50. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
  51. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -17
  52. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
  53. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
  54. package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
  55. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
  56. package/src/runtime/agent/orchestrator/stall-policy.mjs +18 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
  58. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
  59. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
  61. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
  62. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
  63. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
  64. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
  65. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
  66. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
  68. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  69. package/src/runtime/channels/lib/worker-main.mjs +5 -0
  70. package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
  71. package/src/runtime/memory/lib/ko-morph.mjs +1 -1
  72. package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
  73. package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
  74. package/src/runtime/memory/lib/recall-format.mjs +3 -3
  75. package/src/runtime/shared/child-spawn-gate.mjs +15 -0
  76. package/src/runtime/shared/config.mjs +98 -12
  77. package/src/runtime/shared/process-listener-headroom.mjs +12 -0
  78. package/src/session-runtime/cwd-plugins.mjs +6 -3
  79. package/src/session-runtime/model-route-api.mjs +50 -2
  80. package/src/session-runtime/provider-auth-api.mjs +8 -1
  81. package/src/session-runtime/resource-api.mjs +22 -0
  82. package/src/session-runtime/runtime-core.mjs +13 -2
  83. package/src/session-runtime/settings-api.mjs +11 -2
  84. package/src/standalone/agent-tool.mjs +15 -9
  85. package/src/standalone/explore-tool.mjs +4 -1
  86. package/src/tui/App.jsx +6 -5
  87. package/src/tui/app/transcript-window.mjs +60 -10
  88. package/src/tui/app/use-transcript-window.mjs +2 -1
  89. package/src/tui/components/ContextPanel.jsx +4 -1
  90. package/src/tui/components/ToolExecution.jsx +15 -12
  91. package/src/tui/components/TranscriptItem.jsx +1 -1
  92. package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
  93. package/src/tui/dist/index.mjs +473 -155
  94. package/src/tui/engine/agent-job-feed.mjs +26 -6
  95. package/src/tui/engine/context-state.mjs +13 -4
  96. package/src/tui/engine/notification-plan.mjs +3 -3
  97. package/src/tui/engine/render-timing.mjs +104 -8
  98. package/src/tui/engine/session-api.mjs +58 -3
  99. package/src/tui/engine/session-flow.mjs +78 -28
  100. package/src/tui/engine/tool-card-results.mjs +28 -13
  101. package/src/tui/engine/turn.mjs +232 -156
  102. package/src/tui/engine.mjs +17 -8
  103. package/src/tui/index.jsx +10 -1
  104. package/src/workflows/default/WORKFLOW.md +8 -4
  105. package/src/workflows/solo/WORKFLOW.md +8 -4
@@ -26,6 +26,7 @@ export function createRunTurn(bag) {
26
26
  // it is stale and skip all shared-getState() writes (busy, flags.activePromptRestore,
27
27
  // turndone, drain kick). Neutralizes the stale unwind without touching the mutex.
28
28
  const turnEpoch = ++flags.leadTurnEpoch;
29
+ const isCurrentTurn = () => !flags.disposed && flags.leadTurnEpoch === turnEpoch;
29
30
  const inputBaseline = getState().stats.inputTokens;
30
31
  const outputBaseline = getState().stats.outputTokens;
31
32
  const submittedIds = Array.isArray(options.submittedIds) ? options.submittedIds : [];
@@ -48,50 +49,86 @@ export function createRunTurn(bag) {
48
49
  let currentAssistantId = null;
49
50
 
50
51
  tuiDebug(`runTurn start turn=${turnIndex} pending=${pending.length} timeoutMs=${LEAD_TURN_TIMEOUT_MS}`);
51
- // ── Wall-clock watchdog ───────────────────────────────────────────────
52
- // If this turn never unwinds (provider call stuck), trip after the cap:
53
- // abort the in-flight run via the existing interrupt path (runtime.abort,
54
- // the same one Esc uses), which rejects the pending runtime.ask() with a
55
- // SessionClosedError so the normal cancelled-turn teardown runs. If even
56
- // that unwind is starved, forceReleaseTurn() in the timer hard-clears busy
57
- // and kicks the drain so input is never permanently dead.
52
+ // ── Idle watchdog ─────────────────────────────────────────────────────
53
+ // If this turn stops making observable progress (provider call stuck),
54
+ // trip after the cap: abort the in-flight run via the existing interrupt
55
+ // path (runtime.abort, the same one Esc uses), which rejects the pending
56
+ // runtime.ask() with a SessionClosedError so the normal cancelled-turn
57
+ // teardown runs. Keep this progress-based rather than total wall-clock:
58
+ // long multi-tool turns can legitimately exceed 5m while still producing
59
+ // model/tool events.
58
60
  let watchdogTripped = false;
59
61
  let watchdogTimer = null;
60
62
  let watchdogGraceTimer = null;
63
+ let lastProgressAt = startedAt;
64
+ let lastProgressLabel = 'start';
61
65
  const clearWatchdog = () => {
62
66
  if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
63
67
  if (watchdogGraceTimer) { clearTimeout(watchdogGraceTimer); watchdogGraceTimer = null; }
64
68
  };
65
- watchdogTimer = setTimeout(() => {
66
- if (flags.disposed) return;
67
- watchdogTripped = true;
68
- const elapsed = Date.now() - startedAt;
69
- tuiDebug(`runTurn WATCHDOG TRIP turn=${turnIndex} elapsedMs=${elapsed} — aborting stuck turn`);
70
- pushNotice(`Turn timed out after ${Math.round(elapsed / 1000)}s — aborting stuck request. Input is available again.`, 'warn', { transcript: true });
71
- try { runtime.abort('cli-react-abort-watchdog'); } catch {}
72
- // Belt-and-suspenders: if runtime.abort() did not reject runtime.ask()
73
- // (unwind starved), hard-release the turn after a short grace so the
74
- // React store is never left with busy=true and no drain in flight.
75
- watchdogGraceTimer = setTimeout(() => {
76
- if (flags.disposed) return;
77
- if (!getState().busy) return;
78
- if (flags.leadTurnEpoch !== turnEpoch) return; // a newer turn already owns the store
79
- tuiDebug(`runTurn WATCHDOG FORCE-RELEASE turn=${turnIndex} — abort unwind starved`);
80
- // Bump the epoch FIRST so this (still-stuck) turn's later finally becomes
81
- // a no-op for shared getState() and cannot corrupt the turn we hand off to.
82
- flags.leadTurnEpoch++;
83
- set({ busy: false, spinner: null, thinking: null, lastTurn: null });
84
- flags.activePromptRestore = null;
85
- if (flags.draining) flags.draining = false;
86
- if (pending.length > 0) void drain();
87
- // busy→false here bypasses both the normal turn-end flush and the
88
- // drain-finally flush, so a deferred completion kick would never re-arm.
89
- // Fire it explicitly (idempotent: guarded by deferred flag + !busy).
90
- flushDeferredExecutionPendingResumeKick();
91
- }, 5_000);
92
- watchdogGraceTimer.unref?.();
93
- }, LEAD_TURN_TIMEOUT_MS);
94
- watchdogTimer.unref?.();
69
+ const armWatchdog = () => {
70
+ if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
71
+ if (watchdogTripped) return;
72
+ const remaining = Math.max(1, LEAD_TURN_TIMEOUT_MS - Math.max(0, Date.now() - lastProgressAt));
73
+ watchdogTimer = setTimeout(() => {
74
+ if (!isCurrentTurn()) return;
75
+ if (watchdogTripped) return;
76
+ const idleMs = Date.now() - lastProgressAt;
77
+ if (idleMs < LEAD_TURN_TIMEOUT_MS) {
78
+ armWatchdog();
79
+ return;
80
+ }
81
+ watchdogTripped = true;
82
+ if (_batchTimer !== null) {
83
+ clearTimeout(_batchTimer);
84
+ _batchTimer = null;
85
+ }
86
+ _pendingTextFlush = false;
87
+ _pendingThinkFlush = false;
88
+ _pendingThinkingLastEndedAt = 0;
89
+ const elapsed = Date.now() - startedAt;
90
+ tuiDebug(`runTurn WATCHDOG TRIP turn=${turnIndex} elapsedMs=${elapsed} idleMs=${idleMs} lastProgress=${lastProgressLabel} — aborting stuck turn`);
91
+ pushNotice(`Turn timed out after ${Math.round(idleMs / 1000)}s idle aborting stuck request. Input will be released shortly if abort does not unwind.`, 'warn', { transcript: true });
92
+ try { runtime.abort('cli-react-abort-watchdog'); } catch {}
93
+ // Belt-and-suspenders: if runtime.abort() did not reject runtime.ask()
94
+ // (unwind starved), hard-release the turn after a short grace so the
95
+ // React store is never left with busy=true and no drain in flight.
96
+ watchdogGraceTimer = setTimeout(() => {
97
+ if (flags.disposed) return;
98
+ if (!getState().busy) return;
99
+ if (flags.leadTurnEpoch !== turnEpoch) return; // a newer turn already owns the store
100
+ tuiDebug(`runTurn WATCHDOG FORCE-RELEASE turn=${turnIndex} — abort unwind starved`);
101
+ denyAllToolApprovals('turn timed out');
102
+ clearActiveToolSummary();
103
+ flushToolResults([], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true, cancelled: true });
104
+ finalizeToolHeaders();
105
+ clearDeferredTimers();
106
+ flags.flushDeferredBeforeImmediatePush = null;
107
+ // Bump the epoch FIRST so this (still-stuck) turn's later finally becomes
108
+ // a no-op for shared getState() and cannot corrupt the turn we hand off to.
109
+ flags.leadTurnEpoch++;
110
+ set({ busy: false, spinner: null, thinking: null, lastTurn: null });
111
+ flags.activePromptRestore = null;
112
+ if (flags.draining) flags.draining = false;
113
+ if (pending.length > 0) void drain();
114
+ // busy→false here bypasses both the normal turn-end flush and the
115
+ // drain-finally flush, so a deferred completion kick would never re-arm.
116
+ // Fire it explicitly (idempotent: guarded by deferred flag + !busy).
117
+ flushDeferredExecutionPendingResumeKick();
118
+ }, 5_000);
119
+ watchdogGraceTimer.unref?.();
120
+ }, remaining);
121
+ watchdogTimer.unref?.();
122
+ };
123
+ const markTurnProgress = (label) => {
124
+ if (!isCurrentTurn()) return false;
125
+ if (watchdogTripped) return;
126
+ lastProgressAt = Date.now();
127
+ lastProgressLabel = String(label || 'progress');
128
+ armWatchdog();
129
+ return true;
130
+ };
131
+ armWatchdog();
95
132
  let currentAssistantText = '';
96
133
  let thinkingText = '';
97
134
  let thinkingStartedAt = 0;
@@ -139,8 +176,9 @@ export function createRunTurn(bag) {
139
176
  // so transcript order always matches call order even when a later card's
140
177
  // result/timer fires before an earlier one's. Commit the collected cards in
141
178
  // ONE state update: emitting one pushItem() per deferred card made a tool
142
- // batch climb into view one row/card at a time ("툭툭" upward row jitter).
179
+ // batch climb into view one row/card at a time (stepwise upward row jitter).
143
180
  const flushDeferredUpTo = (entry) => {
181
+ if (!isCurrentTurn()) return;
144
182
  if (!entry) return;
145
183
  const specs = collectDeferredUpTo(entry);
146
184
  if (!specs.length) return;
@@ -177,7 +215,7 @@ export function createRunTurn(bag) {
177
215
  deferredEntries.push(entry);
178
216
  entry.timer = setTimeout(() => {
179
217
  entry.timer = null;
180
- if (flags.disposed) return;
218
+ if (!isCurrentTurn()) return;
181
219
  flushDeferredUpTo(entry);
182
220
  }, TOOL_CARD_PUSH_DELAY_MS);
183
221
  entry.timer.unref?.();
@@ -205,7 +243,7 @@ export function createRunTurn(bag) {
205
243
  deferredEntries.push(entry);
206
244
  entry.timer = setTimeout(() => {
207
245
  entry.timer = null;
208
- if (flags.disposed) return;
246
+ if (!isCurrentTurn()) return;
209
247
  flushDeferredUpTo(entry);
210
248
  }, TOOL_CARD_PUSH_DELAY_MS);
211
249
  entry.timer.unref?.();
@@ -235,6 +273,7 @@ export function createRunTurn(bag) {
235
273
  // Append pre-built items (deferred cards + turndone) in ONE set(). None are
236
274
  // 'user' kind, so no promptHistory rebuild is needed.
237
275
  const appendItemsBatch = (newItems, extra = {}) => {
276
+ if (!isCurrentTurn()) return;
238
277
  if (!newItems || !newItems.length) { set(extra); return; }
239
278
  const base = getState().items.length;
240
279
  const items = [...getState().items, ...newItems];
@@ -288,6 +327,7 @@ export function createRunTurn(bag) {
288
327
  if (allCalls.length === 0) continue;
289
328
  aggregate.ensureVisible?.();
290
329
  const errors = allCalls.filter((r) => r.isError).length;
330
+ const callErrors = allCalls.filter((r) => r.isCallError).length;
291
331
  const completed = allCalls.filter((r) => r.resolved).length;
292
332
  const succeeded = completed - errors;
293
333
  const rawResult = aggregateRawResult(allCalls);
@@ -301,6 +341,8 @@ export function createRunTurn(bag) {
301
341
  text: displayDetail,
302
342
  rawResult: rawResult || null,
303
343
  isError: errors > 0,
344
+ errorCount: errors,
345
+ callErrorCount: callErrors,
304
346
  count: allCalls.length,
305
347
  completedCount: allCalls.length,
306
348
  doneCategories: aggregateDoneCategories(allCalls),
@@ -481,6 +523,12 @@ export function createRunTurn(bag) {
481
523
  clearTimeout(_batchTimer);
482
524
  _batchTimer = null;
483
525
  }
526
+ if (!isCurrentTurn()) {
527
+ _pendingTextFlush = false;
528
+ _pendingThinkFlush = false;
529
+ _pendingThinkingLastEndedAt = 0;
530
+ return;
531
+ }
484
532
  if (_pendingTextFlush) {
485
533
  _pendingTextFlush = false;
486
534
  // Show only COMPLETED lines while streaming. The in-progress trailing
@@ -615,16 +663,22 @@ export function createRunTurn(bag) {
615
663
  // Gated to Memory calls: the text-matcher's ^(error|failed) catch-all
616
664
  // would otherwise misflag legitimate non-memory success output.
617
665
  const isMemoryCall = classifyToolCategory(callRec.name, callRec.args) === 'Memory';
618
- const isError = message?.isError === true || message?.toolKind === 'error' || /^\s*\[?error/i.test(rawText) || isErrorToolStatus(toolResultStatus(rawText)) || (isMemoryCall && memoryCoreResultErrorText(rawText) != null);
666
+ // Same split as tool-card-results.mjs: a REAL tool-call error drives the
667
+ // red ● dot; command/result failures only mark the card Failed in L2.
668
+ const isCallError = message?.isError === true || message?.toolKind === 'error';
669
+ const isResultError = /^\s*\[?error/i.test(rawText) || isErrorToolStatus(toolResultStatus(rawText)) || (isMemoryCall && memoryCoreResultErrorText(rawText) != null);
670
+ const isError = isCallError || isResultError;
619
671
  const text = isError ? toolErrorDisplay(rawText, callRec.name || 'tool') : rawText;
620
672
  callRec.summary = !isError ? summarizeToolResult(callRec.name, callRec.args, rawText, isError) : null;
621
673
  assignAggregateSummaryOrder(aggregate, callRec);
622
674
  callRec.isError = isError;
675
+ callRec.isCallError = isCallError;
623
676
  callRec.resultText = text;
624
677
  callRec.completedEarly = true;
625
678
  const allCalls = [...aggregate.calls.values()];
626
679
  const completedCount = allCalls.filter((r) => r.resolved || r.completedEarly).length;
627
680
  const errors = allCalls.filter((r) => r.isError).length;
681
+ const callErrors = allCalls.filter((r) => r.isCallError).length;
628
682
  const succeeded = completedCount - errors;
629
683
  const rawResult = aggregateRawResult(allCalls);
630
684
  // Collapsed detail carries the merged per-call count summary even on
@@ -644,6 +698,7 @@ export function createRunTurn(bag) {
644
698
  text: displayDetail,
645
699
  isError: errors > 0,
646
700
  errorCount: errors,
701
+ callErrorCount: callErrors,
647
702
  count: allCalls.length,
648
703
  completedCount: visualCompleted,
649
704
  };
@@ -669,8 +724,9 @@ export function createRunTurn(bag) {
669
724
 
670
725
  try {
671
726
  const { result, session } = await runtime.ask(userText, {
672
- drainSteering: () => drainPendingSteering(),
727
+ drainSteering: (_sessionId, drainOptions) => (isCurrentTurn() ? drainPendingSteering(drainOptions) : []),
673
728
  onSteerMessage: (text) => {
729
+ if (!markTurnProgress('steer-message')) return;
674
730
  // Steering can be injected after a terminal no-tool response has
675
731
  // already streamed but before runTurn finalizes. Seal the current
676
732
  // assistant segment first so the steered user turn and the next
@@ -692,6 +748,7 @@ export function createRunTurn(bag) {
692
748
  }
693
749
  },
694
750
  onToolCall: async (_iter, calls) => {
751
+ if (!markTurnProgress('tool-call')) return;
695
752
  markPromptCommitted();
696
753
  // Always flush any buffered mid-turn assistant text before the tool
697
754
  // card appears. Without this, when neither a thinking panel nor a
@@ -709,11 +766,13 @@ export function createRunTurn(bag) {
709
766
  if (batchCalls.length === 0) return;
710
767
  const committedAssistantSegment = commitAssistantSegment({ sealToolBlock: true });
711
768
  if (committedAssistantSegment) {
712
- // Let the pre-tool assistant preamble paint as its own frame before
713
- // the tool card reserves/pushes rows. When both enter the transcript
714
- // in the same render, the bottom-pinned viewport can appear to jump
715
- // upward by the combined height ("preamble + tool card" at once).
716
- await yieldToRenderer();
769
+ // Let the pre-tool assistant preamble paint and settle before the
770
+ // tool card reserves/pushes rows. The first frame emits the sealed
771
+ // preamble; the second gives measured-height harvest a chance to
772
+ // publish any Markdown/streaming→final correction. If the settle
773
+ // frame is unnecessary, yieldToRenderer's fallback releases quickly.
774
+ await yieldToRenderer({ frames: 2 });
775
+ if (!isCurrentTurn()) return;
717
776
  }
718
777
 
719
778
  const touchedAggregates = new Set();
@@ -795,7 +854,7 @@ export function createRunTurn(bag) {
795
854
  ...categoryEntry,
796
855
  count: Number(prevCategory?.count || 0) + Number(categoryEntry.count || 1),
797
856
  });
798
- aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, resultText: null, resolved: false, completedEarly: false });
857
+ aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, isCallError: false, resultText: null, resolved: false, completedEarly: false });
799
858
  touchedAggregates.add(aggregateCard);
800
859
  const card = { itemId: aggregateCard.itemId, callId: callKey, done: false, aggregate: aggregateCard };
801
860
  if (callId) {
@@ -831,6 +890,7 @@ export function createRunTurn(bag) {
831
890
  await yieldToRenderer();
832
891
  },
833
892
  onToolResult: (message) => {
893
+ if (!markTurnProgress('tool-result')) return;
834
894
  const callId = toolResultCallId(message);
835
895
  if (callId && !cardByCallId.has(callId) && !resultsDone.has(callId)) {
836
896
  earlyResultBuffer.set(callId, message);
@@ -839,12 +899,16 @@ export function createRunTurn(bag) {
839
899
  deliverToolResultMessage(message);
840
900
  },
841
901
  onToolApproval: async (request) => {
902
+ if (!markTurnProgress('tool-approval')) return { approved: false, reason: 'turn no longer active' };
842
903
  markPromptCommitted();
843
904
  flushStreamBatch();
844
905
  if (getState().spinner) set({ spinner: { ...getState().spinner, mode: 'tool-approval' } });
845
- return await requestToolApproval(request);
906
+ const approval = await requestToolApproval(request);
907
+ if (!isCurrentTurn()) return { approved: false, reason: 'turn no longer active' };
908
+ return approval;
846
909
  },
847
910
  onCompactEvent: (event) => {
911
+ if (!markTurnProgress('compact-event')) return;
848
912
  flushStreamBatch();
849
913
  // Non-tool transcript item — same block-boundary rule as the
850
914
  // steered user item above: seal any live aggregate first so a
@@ -859,6 +923,7 @@ export function createRunTurn(bag) {
859
923
  });
860
924
  },
861
925
  onStageChange: async (stage) => {
926
+ if (!markTurnProgress(`stage:${String(stage || '')}`)) return;
862
927
  if (!getState().spinner) return;
863
928
  const value = String(stage || '');
864
929
  if (value === 'compacting') {
@@ -892,6 +957,7 @@ export function createRunTurn(bag) {
892
957
  onTextDelta: (chunk) => {
893
958
  const textChunk = String(chunk ?? '');
894
959
  if (!textChunk) return;
960
+ if (!markTurnProgress('text-delta')) return;
895
961
  markPromptCommitted();
896
962
  const thinkingLastEndedAt = closeThinkingSegment();
897
963
  // Drop any queued think-flush too: it would otherwise re-publish
@@ -918,6 +984,7 @@ export function createRunTurn(bag) {
918
984
  // streaming path is unaffected.
919
985
  const full = String(text ?? '');
920
986
  if (!full.trim()) return;
987
+ if (!markTurnProgress('assistant-text')) return;
921
988
  // If the streaming path already produced text for THIS segment,
922
989
  // onTextDelta owns the render — content is the same accumulated text
923
990
  // (or a superset), so skip to avoid double-printing the preamble.
@@ -935,7 +1002,11 @@ export function createRunTurn(bag) {
935
1002
  flushStreamBatch();
936
1003
  },
937
1004
  onReasoningDelta: (chunk) => {
938
- if (String(chunk ?? '')) markPromptCommitted();
1005
+ if (!isCurrentTurn() || watchdogTripped) return;
1006
+ if (String(chunk ?? '')) {
1007
+ if (!markTurnProgress('reasoning-delta')) return;
1008
+ markPromptCommitted();
1009
+ }
939
1010
  startThinkingSegment();
940
1011
  thinkingText += String(chunk ?? '');
941
1012
  // Accumulate reasoning text; fire at most one render per STREAM_BATCH_INTERVAL_MS.
@@ -943,6 +1014,7 @@ export function createRunTurn(bag) {
943
1014
  scheduleStreamFlush();
944
1015
  },
945
1016
  onUsageDelta: (delta) => {
1017
+ if (!markTurnProgress('usage-delta')) return;
946
1018
  applyUsageDelta(getState().stats, delta);
947
1019
  syncContextStats({ allowEstimated: true });
948
1020
  const currentTurnInput = Math.max(0, getState().stats.inputTokens - inputBaseline);
@@ -954,130 +1026,134 @@ export function createRunTurn(bag) {
954
1026
  }
955
1027
  },
956
1028
  });
957
- askResult = result;
958
- markPromptCommitted();
1029
+ if (!isCurrentTurn()) {
1030
+ cancelled = true;
1031
+ } else {
1032
+ askResult = result;
1033
+ markPromptCommitted();
959
1034
 
960
- flushToolResults(session?.messages || [], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true });
961
- finalizeToolHeaders();
962
- flushStreamBatch(); // force-flush any batched streaming text before finalization writes
963
- syncContextStats({ allowEstimated: true });
1035
+ flushToolResults(session?.messages || [], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true });
1036
+ finalizeToolHeaders();
1037
+ flushStreamBatch(); // force-flush any batched streaming text before finalization writes
1038
+ syncContextStats({ allowEstimated: true });
964
1039
 
965
- const finalText = result?.content != null ? String(result.content) : '';
966
- if (finalText.trim()) {
967
- // The persisted transcript is written from the provider's final content,
968
- // while the live TUI row is fed by streaming deltas. If a provider/parser
969
- // misses or suppresses an early delta, keeping the streamed buffer here
970
- // leaves the final on-screen assistant row missing leading characters even
971
- // though the transcript is correct. Always reconcile the active segment to
972
- // the final provider text when it is available.
973
- const id = currentAssistantId || ensureAssistant(finalText);
974
- currentAssistantText = finalText;
975
- patchItem(id, { text: finalText, streaming: false });
976
- } else if (currentAssistantId && (currentAssistantText.trim() || assistantText.trim())) {
977
- const streamedText = currentAssistantText || assistantText;
978
- patchItem(currentAssistantId, { text: streamedText, streaming: false });
1040
+ const finalText = result?.content != null ? String(result.content) : '';
1041
+ if (finalText.trim()) {
1042
+ // The persisted transcript is written from the provider's final content,
1043
+ // while the live TUI row is fed by streaming deltas. If a provider/parser
1044
+ // misses or suppresses an early delta, keeping the streamed buffer here
1045
+ // leaves the final on-screen assistant row missing leading characters even
1046
+ // though the transcript is correct. Always reconcile the active segment to
1047
+ // the final provider text when it is available.
1048
+ const id = currentAssistantId || ensureAssistant(finalText);
1049
+ currentAssistantText = finalText;
1050
+ patchItem(id, { text: finalText, streaming: false });
1051
+ } else if (currentAssistantId && (currentAssistantText.trim() || assistantText.trim())) {
1052
+ const streamedText = currentAssistantText || assistantText;
1053
+ patchItem(currentAssistantId, { text: streamedText, streaming: false });
1054
+ }
1055
+ turnFinishedNormally = true;
979
1056
  }
980
- turnFinishedNormally = true;
981
1057
  } catch (error) {
982
- flushStreamBatch(); // ensure any batched text lands before the error notice
983
- if (error?.name === 'SessionClosedError') {
1058
+ const staleCatch = !isCurrentTurn();
1059
+ if (staleCatch) {
984
1060
  cancelled = true;
985
- if (assistantText.trim() && currentAssistantId) {
986
- patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
987
- }
988
- // Finalize pending tool cards so they don't stay "Running..." forever
989
- // after cancellation. Without this, the spinner vanishes and TurnDone
990
- // shows "cancelled", but in-flight tool cards remain in a perpetual
991
- // pending/blinking getState() because the normal finalize path (line 992)
992
- // was skipped when the error interrupted the try block.
993
- flushToolResults([], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true, cancelled: true });
994
- finalizeToolHeaders();
995
1061
  } else {
996
- finalizeToolHeaders();
997
- pushNotice(toolErrorDisplay(error, 'turn'), 'error');
1062
+ flushStreamBatch(); // ensure any batched text lands before the error notice
1063
+ if (error?.name === 'SessionClosedError') {
1064
+ cancelled = true;
1065
+ if (assistantText.trim() && currentAssistantId) {
1066
+ patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
1067
+ }
1068
+ // Finalize pending tool cards so they don't stay "Running..." forever
1069
+ // after cancellation. Without this, the spinner vanishes and TurnDone
1070
+ // shows "cancelled", but in-flight tool cards remain in a perpetual
1071
+ // pending/blinking getState() because the normal finalize path (line 992)
1072
+ // was skipped when the error interrupted the try block.
1073
+ flushToolResults([], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true, cancelled: true });
1074
+ finalizeToolHeaders();
1075
+ } else {
1076
+ finalizeToolHeaders();
1077
+ pushNotice(toolErrorDisplay(error, 'turn'), 'error');
1078
+ }
998
1079
  }
999
1080
  } finally {
1000
- denyAllToolApprovals(cancelled ? 'turn cancelled' : 'turn finished');
1001
- // Turn is unwinding normally (or via abort) cancel the wall-clock
1002
- // watchdog + its force-release grace so they never fire on a live turn.
1081
+ const isStaleUnwind = !isCurrentTurn();
1082
+ if (!isStaleUnwind) denyAllToolApprovals(cancelled ? 'turn cancelled' : 'turn finished');
1083
+ // Turn is unwinding normally (or via abort) cancel the idle watchdog and
1084
+ // its force-release grace so they never fire on a live turn.
1003
1085
  clearWatchdog();
1004
1086
  // If the watchdog force-release already fired, a NEWER turn now owns the
1005
1087
  // shared store (busy, flags.activePromptRestore, turndone, drain). This stale
1006
- // unwind must NOT write shared getState() or it corrupts that turn. It still
1007
- // runs its own turn-local teardown (approvals, deferred cards) below.
1008
- const isStaleUnwind = flags.leadTurnEpoch !== turnEpoch;
1009
- // Flush any still-deferred tool cards into the transcript and cancel their
1010
- // pending push timers so nothing fires (or leaks) after the turn ends. The
1011
- // finalize path above already patches results onto visible cards; this just
1012
- // guarantees every registered card is materialized before the turn closes.
1013
- // Collect (don't emit) the still-deferred cards so the turn-close flush and
1014
- // the turndone item append in ONE set() below instead of one render bounce
1015
- // per row. Order/ids are preserved (creation order, then turndone last).
1088
+ // unwind must NOT write shared getState() or it corrupts that turn.
1016
1089
  let closingItems = [];
1017
1090
  if (deferredEntries.length) {
1018
- const last = deferredEntries[deferredEntries.length - 1];
1019
- closingItems = collectDeferredUpTo(last);
1091
+ if (!isStaleUnwind) {
1092
+ // Flush any still-deferred tool cards into the transcript and cancel
1093
+ // their pending push timers so nothing fires (or leaks) after the turn
1094
+ // ends. The finalize path above already patches results onto visible
1095
+ // cards; this just guarantees every registered card is materialized
1096
+ // before the turn closes. Collect (don't emit) the still-deferred cards
1097
+ // so the turn-close flush and the turndone item append in ONE set()
1098
+ // below instead of one render bounce per row. Order/ids are preserved
1099
+ // (creation order, then turndone last).
1100
+ const last = deferredEntries[deferredEntries.length - 1];
1101
+ closingItems = collectDeferredUpTo(last);
1102
+ }
1020
1103
  clearDeferredTimers();
1021
1104
  }
1022
- flags.flushDeferredBeforeImmediatePush = null;
1023
- const producedTranscriptItem =
1024
- getState().items.length + closingItems.length > itemsAtTurnStart;
1025
- const reclaimed = cancelled && flags.activePromptRestore?.reclaimed === true;
1026
- if (!isStaleUnwind) flags.activePromptRestore = null;
1105
+ if (!isStaleUnwind) flags.flushDeferredBeforeImmediatePush = null;
1027
1106
  closeThinkingSegment();
1028
- const elapsedMs = Date.now() - startedAt;
1029
- const thinkingElapsedMs = thinkingStartedAt ? accumulatedThinkingMs : 0;
1030
- // responseLength is engine-local now (not pushed per-token), so compute the
1031
- // fallback from the live accumulator instead of the possibly-stale
1032
- // getState().spinner.responseLength. Final-only / non-streaming turns never
1033
- // accumulate `assistantText` (only currentAssistantText is set at the
1034
- // finalize reconcile above), so take the larger of the two text sources so
1035
- // a no-usage turn still estimates tokens from the final content.
1036
- const finalAssistantLen = Math.max(assistantText.length, currentAssistantText.length);
1037
- const finalResponseLength = finalAssistantLen + thinkingText.length;
1038
- const finalOutputTokens = Math.max(0, Number(getState().spinner?.outputTokens || 0), Math.round(finalResponseLength / 4));
1039
- const turnStatus = cancelled ? 'cancelled' : 'done';
1040
- const resultContent = askResult?.content != null ? String(askResult.content).trim() : '';
1041
- const assistantOutput = (currentAssistantText || assistantText || '').trim();
1042
- // Suppress only true pending-resume no-ops: no transcript items added and no model output; cancelled/error turns and any visible turn stay marked.
1043
- const isNoOpTurn = turnFinishedNormally
1044
- && !cancelled
1045
- && toolCards.length === 0
1046
- && !resultContent
1047
- && !assistantOutput
1048
- && !producedTranscriptItem;
1049
1107
  if (isStaleUnwind) {
1050
- // Preserve prior behavior: a stale unwind still materializes its own
1051
- // deferred cards (turn-local teardown) but writes no other shared getState().
1052
- if (closingItems.length) appendItemsBatch(closingItems);
1053
- tuiDebug(`runTurn STALE UNWIND turn=${turnIndex} — force-released; skipping shared-getState() writes`);
1054
- // A stale unwind settles busy→false (via the watchdog release) without
1055
- // the normal turn-end flush, so re-arm any deferred completion kick here
1056
- // too (idempotent: no-ops unless a kick is deferred and nothing is busy).
1057
- flushDeferredExecutionPendingResumeKick();
1108
+ tuiDebug(`runTurn STALE UNWIND turn=${turnIndex} force-released; skipping shared UI/state writes`);
1058
1109
  } else {
1059
- if (!isNoOpTurn) {
1060
- getState().stats.turns = (getState().stats.turns || 0) + 1;
1061
- }
1062
- // Pin the post-think summary into the transcript right after this turn's
1063
- // output so it scrolls up with the answer and stays in the scrollback,
1064
- // in scrollback. (Previously TurnDone rendered only in the
1065
- // bottom-fixed live-status slot and vanished on the next turn.)
1066
- if (!reclaimed && !isNoOpTurn) {
1067
- closingItems.push({ kind: 'turndone', id: nextId(), elapsedMs, status: turnStatus, outputTokens: finalOutputTokens, thinkingElapsedMs, verb: pickDoneVerb(turnIndex) });
1068
- }
1069
- // Deferred cards + turndone + status all land in ONE set() (one commit).
1070
- appendItemsBatch(closingItems, {
1071
- busy: false,
1072
- spinner: null,
1073
- thinking: null,
1074
- lastTurn: null,
1075
- stats: { ...getState().stats },
1076
- ...routeState(),
1077
- toolMode: runtime.toolMode,
1078
- ...agentStatusState({ force: true }),
1079
- });
1080
- flushDeferredExecutionPendingResumeKick();
1110
+ const producedTranscriptItem =
1111
+ getState().items.length + closingItems.length > itemsAtTurnStart;
1112
+ const reclaimed = cancelled && flags.activePromptRestore?.reclaimed === true;
1113
+ flags.activePromptRestore = null;
1114
+ const elapsedMs = Date.now() - startedAt;
1115
+ const thinkingElapsedMs = thinkingStartedAt ? accumulatedThinkingMs : 0;
1116
+ // responseLength is engine-local now (not pushed per-token), so compute the
1117
+ // fallback from the live accumulator instead of the possibly-stale
1118
+ // getState().spinner.responseLength. Final-only / non-streaming turns never
1119
+ // accumulate `assistantText` (only currentAssistantText is set at the
1120
+ // finalize reconcile above), so take the larger of the two text sources so
1121
+ // a no-usage turn still estimates tokens from the final content.
1122
+ const finalAssistantLen = Math.max(assistantText.length, currentAssistantText.length);
1123
+ const finalResponseLength = finalAssistantLen + thinkingText.length;
1124
+ const finalOutputTokens = Math.max(0, Number(getState().spinner?.outputTokens || 0), Math.round(finalResponseLength / 4));
1125
+ const turnStatus = cancelled ? 'cancelled' : 'done';
1126
+ const resultContent = askResult?.content != null ? String(askResult.content).trim() : '';
1127
+ const assistantOutput = (currentAssistantText || assistantText || '').trim();
1128
+ // Suppress only true pending-resume no-ops: no transcript items added and no model output; cancelled/error turns and any visible turn stay marked.
1129
+ const isNoOpTurn = turnFinishedNormally
1130
+ && !cancelled
1131
+ && toolCards.length === 0
1132
+ && !resultContent
1133
+ && !assistantOutput
1134
+ && !producedTranscriptItem;
1135
+ if (!isNoOpTurn) {
1136
+ getState().stats.turns = (getState().stats.turns || 0) + 1;
1137
+ }
1138
+ // Pin the post-think summary into the transcript right after this turn's
1139
+ // output so it scrolls up with the answer and stays in the scrollback,
1140
+ // in scrollback. (Previously TurnDone rendered only in the
1141
+ // bottom-fixed live-status slot and vanished on the next turn.)
1142
+ if (!reclaimed && !isNoOpTurn) {
1143
+ closingItems.push({ kind: 'turndone', id: nextId(), elapsedMs, status: turnStatus, outputTokens: finalOutputTokens, thinkingElapsedMs, verb: pickDoneVerb(turnIndex) });
1144
+ }
1145
+ // Deferred cards + turndone + status all land in ONE set() (one commit).
1146
+ appendItemsBatch(closingItems, {
1147
+ busy: false,
1148
+ spinner: null,
1149
+ thinking: null,
1150
+ lastTurn: null,
1151
+ stats: { ...getState().stats },
1152
+ ...routeState(),
1153
+ toolMode: runtime.toolMode,
1154
+ ...agentStatusState({ force: true }),
1155
+ });
1156
+ flushDeferredExecutionPendingResumeKick();
1081
1157
  }
1082
1158
  }
1083
1159
  // Shared UI getState(): a stale unwind must not wipe a newer turn's live