mixdog 0.9.36 → 0.9.38

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 (115) 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/dispatch-persist-recovery-test.mjs +141 -0
  5. package/scripts/explore-bench-tmp.mjs +19 -0
  6. package/scripts/explore-bench.mjs +101 -14
  7. package/scripts/explore-prompt-policy-test.mjs +31 -0
  8. package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
  9. package/scripts/notify-completion-mirror-test.mjs +73 -0
  10. package/scripts/openai-ws-early-settle-test.mjs +176 -0
  11. package/scripts/output-style-smoke.mjs +17 -17
  12. package/scripts/provider-toolcall-test.mjs +333 -14
  13. package/scripts/recall-bench-cases.json +3 -3
  14. package/scripts/recall-bench.mjs +1 -1
  15. package/scripts/recall-quality-cases.json +4 -4
  16. package/scripts/recall-usecase-cases.json +2 -2
  17. package/scripts/session-bench.mjs +13 -13
  18. package/scripts/session-sweep.mjs +266 -0
  19. package/scripts/steering-drain-buckets-test.mjs +72 -11
  20. package/scripts/submit-commandbusy-race-test.mjs +114 -0
  21. package/scripts/tool-smoke.mjs +83 -10
  22. package/src/app.mjs +2 -1
  23. package/src/defaults/cycle3-review-prompt.md +9 -0
  24. package/src/defaults/skills/setup/SKILL.md +93 -293
  25. package/src/lib/rules-builder.cjs +3 -2
  26. package/src/output-styles/default.md +2 -2
  27. package/src/output-styles/extreme-minimal.md +1 -1
  28. package/src/output-styles/minimal.md +1 -1
  29. package/src/output-styles/simple.md +2 -3
  30. package/src/rules/agent/30-explorer.md +40 -14
  31. package/src/rules/lead/01-general.md +4 -0
  32. package/src/rules/shared/01-tool.md +7 -3
  33. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
  34. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
  36. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  38. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  39. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
  42. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
  43. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
  44. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
  46. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
  47. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -2
  48. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
  49. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
  50. package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
  51. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
  52. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
  53. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
  54. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
  55. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
  56. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
  57. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +30 -55
  58. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
  59. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
  60. package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
  61. package/src/runtime/agent/orchestrator/session/store.mjs +116 -21
  62. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
  63. package/src/runtime/agent/orchestrator/stall-policy.mjs +55 -0
  64. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
  65. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -9
  66. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
  67. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
  68. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
  70. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
  72. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
  73. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
  74. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
  75. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  76. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  77. package/src/runtime/channels/lib/worker-main.mjs +5 -0
  78. package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
  79. package/src/runtime/memory/lib/ko-morph.mjs +1 -1
  80. package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
  81. package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
  82. package/src/runtime/memory/lib/recall-format.mjs +3 -3
  83. package/src/runtime/shared/child-spawn-gate.mjs +15 -0
  84. package/src/runtime/shared/config.mjs +98 -12
  85. package/src/runtime/shared/process-listener-headroom.mjs +12 -0
  86. package/src/session-runtime/cwd-plugins.mjs +6 -3
  87. package/src/session-runtime/model-route-api.mjs +50 -2
  88. package/src/session-runtime/provider-auth-api.mjs +8 -1
  89. package/src/session-runtime/provider-models.mjs +3 -3
  90. package/src/session-runtime/resource-api.mjs +22 -0
  91. package/src/session-runtime/runtime-core.mjs +56 -10
  92. package/src/session-runtime/settings-api.mjs +11 -2
  93. package/src/session-runtime/warmup-schedulers.mjs +7 -1
  94. package/src/standalone/agent-tool.mjs +15 -9
  95. package/src/standalone/explore-tool.mjs +4 -1
  96. package/src/tui/App.jsx +6 -5
  97. package/src/tui/app/transcript-window.mjs +60 -10
  98. package/src/tui/app/use-transcript-window.mjs +2 -1
  99. package/src/tui/components/ContextPanel.jsx +4 -1
  100. package/src/tui/components/ToolExecution.jsx +15 -12
  101. package/src/tui/components/TranscriptItem.jsx +1 -1
  102. package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
  103. package/src/tui/dist/index.mjs +482 -151
  104. package/src/tui/engine/agent-job-feed.mjs +36 -6
  105. package/src/tui/engine/notification-plan.mjs +3 -3
  106. package/src/tui/engine/queue-helpers.mjs +8 -0
  107. package/src/tui/engine/render-timing.mjs +104 -8
  108. package/src/tui/engine/session-api.mjs +58 -3
  109. package/src/tui/engine/session-flow.mjs +93 -29
  110. package/src/tui/engine/tool-card-results.mjs +28 -13
  111. package/src/tui/engine/turn.mjs +238 -157
  112. package/src/tui/engine.mjs +17 -8
  113. package/src/tui/index.jsx +10 -1
  114. package/src/workflows/default/WORKFLOW.md +8 -4
  115. package/src/workflows/solo/WORKFLOW.md +8 -4
@@ -7,7 +7,7 @@ import { pickVerb, pickDoneVerb, compactEventLabel, compactEventDetail } from '.
7
7
  import { toolResultText, toolErrorDisplay } from './tool-result-text.mjs';
8
8
  import { toolCallId, toolResultCallId, toolCallName, toolCallArgs } from './tool-call-fields.mjs';
9
9
  import { toolResultStatus, isErrorToolStatus } from './agent-envelope.mjs';
10
- import { promptDisplayText } from './queue-helpers.mjs';
10
+ import { promptDisplayText, STEERING_SUPPRESSED_DISPLAY } from './queue-helpers.mjs';
11
11
  import { memoryCoreResultErrorText } from '../app/input-parsers.mjs';
12
12
  import { yieldToRenderer } from './render-timing.mjs';
13
13
  import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, assignAggregateSummaryOrder } from './tool-result-status.mjs';
@@ -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,14 @@ 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;
730
+ // A suppressed live-completion twin is model-visible only; its
731
+ // Response card was already pushed at delivery time. Skip the
732
+ // duplicate transcript item (progress is still marked above since
733
+ // the content WAS injected into the model turn).
734
+ if (text === STEERING_SUPPRESSED_DISPLAY) return;
674
735
  // Steering can be injected after a terminal no-tool response has
675
736
  // already streamed but before runTurn finalizes. Seal the current
676
737
  // assistant segment first so the steered user turn and the next
@@ -692,6 +753,7 @@ export function createRunTurn(bag) {
692
753
  }
693
754
  },
694
755
  onToolCall: async (_iter, calls) => {
756
+ if (!markTurnProgress('tool-call')) return;
695
757
  markPromptCommitted();
696
758
  // Always flush any buffered mid-turn assistant text before the tool
697
759
  // card appears. Without this, when neither a thinking panel nor a
@@ -709,11 +771,13 @@ export function createRunTurn(bag) {
709
771
  if (batchCalls.length === 0) return;
710
772
  const committedAssistantSegment = commitAssistantSegment({ sealToolBlock: true });
711
773
  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();
774
+ // Let the pre-tool assistant preamble paint and settle before the
775
+ // tool card reserves/pushes rows. The first frame emits the sealed
776
+ // preamble; the second gives measured-height harvest a chance to
777
+ // publish any Markdown/streaming→final correction. If the settle
778
+ // frame is unnecessary, yieldToRenderer's fallback releases quickly.
779
+ await yieldToRenderer({ frames: 2 });
780
+ if (!isCurrentTurn()) return;
717
781
  }
718
782
 
719
783
  const touchedAggregates = new Set();
@@ -795,7 +859,7 @@ export function createRunTurn(bag) {
795
859
  ...categoryEntry,
796
860
  count: Number(prevCategory?.count || 0) + Number(categoryEntry.count || 1),
797
861
  });
798
- aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, resultText: null, resolved: false, completedEarly: false });
862
+ aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, isCallError: false, resultText: null, resolved: false, completedEarly: false });
799
863
  touchedAggregates.add(aggregateCard);
800
864
  const card = { itemId: aggregateCard.itemId, callId: callKey, done: false, aggregate: aggregateCard };
801
865
  if (callId) {
@@ -831,6 +895,7 @@ export function createRunTurn(bag) {
831
895
  await yieldToRenderer();
832
896
  },
833
897
  onToolResult: (message) => {
898
+ if (!markTurnProgress('tool-result')) return;
834
899
  const callId = toolResultCallId(message);
835
900
  if (callId && !cardByCallId.has(callId) && !resultsDone.has(callId)) {
836
901
  earlyResultBuffer.set(callId, message);
@@ -839,12 +904,16 @@ export function createRunTurn(bag) {
839
904
  deliverToolResultMessage(message);
840
905
  },
841
906
  onToolApproval: async (request) => {
907
+ if (!markTurnProgress('tool-approval')) return { approved: false, reason: 'turn no longer active' };
842
908
  markPromptCommitted();
843
909
  flushStreamBatch();
844
910
  if (getState().spinner) set({ spinner: { ...getState().spinner, mode: 'tool-approval' } });
845
- return await requestToolApproval(request);
911
+ const approval = await requestToolApproval(request);
912
+ if (!isCurrentTurn()) return { approved: false, reason: 'turn no longer active' };
913
+ return approval;
846
914
  },
847
915
  onCompactEvent: (event) => {
916
+ if (!markTurnProgress('compact-event')) return;
848
917
  flushStreamBatch();
849
918
  // Non-tool transcript item — same block-boundary rule as the
850
919
  // steered user item above: seal any live aggregate first so a
@@ -859,6 +928,7 @@ export function createRunTurn(bag) {
859
928
  });
860
929
  },
861
930
  onStageChange: async (stage) => {
931
+ if (!markTurnProgress(`stage:${String(stage || '')}`)) return;
862
932
  if (!getState().spinner) return;
863
933
  const value = String(stage || '');
864
934
  if (value === 'compacting') {
@@ -892,6 +962,7 @@ export function createRunTurn(bag) {
892
962
  onTextDelta: (chunk) => {
893
963
  const textChunk = String(chunk ?? '');
894
964
  if (!textChunk) return;
965
+ if (!markTurnProgress('text-delta')) return;
895
966
  markPromptCommitted();
896
967
  const thinkingLastEndedAt = closeThinkingSegment();
897
968
  // Drop any queued think-flush too: it would otherwise re-publish
@@ -918,6 +989,7 @@ export function createRunTurn(bag) {
918
989
  // streaming path is unaffected.
919
990
  const full = String(text ?? '');
920
991
  if (!full.trim()) return;
992
+ if (!markTurnProgress('assistant-text')) return;
921
993
  // If the streaming path already produced text for THIS segment,
922
994
  // onTextDelta owns the render — content is the same accumulated text
923
995
  // (or a superset), so skip to avoid double-printing the preamble.
@@ -935,7 +1007,11 @@ export function createRunTurn(bag) {
935
1007
  flushStreamBatch();
936
1008
  },
937
1009
  onReasoningDelta: (chunk) => {
938
- if (String(chunk ?? '')) markPromptCommitted();
1010
+ if (!isCurrentTurn() || watchdogTripped) return;
1011
+ if (String(chunk ?? '')) {
1012
+ if (!markTurnProgress('reasoning-delta')) return;
1013
+ markPromptCommitted();
1014
+ }
939
1015
  startThinkingSegment();
940
1016
  thinkingText += String(chunk ?? '');
941
1017
  // Accumulate reasoning text; fire at most one render per STREAM_BATCH_INTERVAL_MS.
@@ -943,6 +1019,7 @@ export function createRunTurn(bag) {
943
1019
  scheduleStreamFlush();
944
1020
  },
945
1021
  onUsageDelta: (delta) => {
1022
+ if (!markTurnProgress('usage-delta')) return;
946
1023
  applyUsageDelta(getState().stats, delta);
947
1024
  syncContextStats({ allowEstimated: true });
948
1025
  const currentTurnInput = Math.max(0, getState().stats.inputTokens - inputBaseline);
@@ -954,130 +1031,134 @@ export function createRunTurn(bag) {
954
1031
  }
955
1032
  },
956
1033
  });
957
- askResult = result;
958
- markPromptCommitted();
1034
+ if (!isCurrentTurn()) {
1035
+ cancelled = true;
1036
+ } else {
1037
+ askResult = result;
1038
+ markPromptCommitted();
959
1039
 
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 });
1040
+ flushToolResults(session?.messages || [], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true });
1041
+ finalizeToolHeaders();
1042
+ flushStreamBatch(); // force-flush any batched streaming text before finalization writes
1043
+ syncContextStats({ allowEstimated: true });
964
1044
 
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 });
1045
+ const finalText = result?.content != null ? String(result.content) : '';
1046
+ if (finalText.trim()) {
1047
+ // The persisted transcript is written from the provider's final content,
1048
+ // while the live TUI row is fed by streaming deltas. If a provider/parser
1049
+ // misses or suppresses an early delta, keeping the streamed buffer here
1050
+ // leaves the final on-screen assistant row missing leading characters even
1051
+ // though the transcript is correct. Always reconcile the active segment to
1052
+ // the final provider text when it is available.
1053
+ const id = currentAssistantId || ensureAssistant(finalText);
1054
+ currentAssistantText = finalText;
1055
+ patchItem(id, { text: finalText, streaming: false });
1056
+ } else if (currentAssistantId && (currentAssistantText.trim() || assistantText.trim())) {
1057
+ const streamedText = currentAssistantText || assistantText;
1058
+ patchItem(currentAssistantId, { text: streamedText, streaming: false });
1059
+ }
1060
+ turnFinishedNormally = true;
979
1061
  }
980
- turnFinishedNormally = true;
981
1062
  } catch (error) {
982
- flushStreamBatch(); // ensure any batched text lands before the error notice
983
- if (error?.name === 'SessionClosedError') {
1063
+ const staleCatch = !isCurrentTurn();
1064
+ if (staleCatch) {
984
1065
  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
1066
  } else {
996
- finalizeToolHeaders();
997
- pushNotice(toolErrorDisplay(error, 'turn'), 'error');
1067
+ flushStreamBatch(); // ensure any batched text lands before the error notice
1068
+ if (error?.name === 'SessionClosedError') {
1069
+ cancelled = true;
1070
+ if (assistantText.trim() && currentAssistantId) {
1071
+ patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
1072
+ }
1073
+ // Finalize pending tool cards so they don't stay "Running..." forever
1074
+ // after cancellation. Without this, the spinner vanishes and TurnDone
1075
+ // shows "cancelled", but in-flight tool cards remain in a perpetual
1076
+ // pending/blinking getState() because the normal finalize path (line 992)
1077
+ // was skipped when the error interrupted the try block.
1078
+ flushToolResults([], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true, cancelled: true });
1079
+ finalizeToolHeaders();
1080
+ } else {
1081
+ finalizeToolHeaders();
1082
+ pushNotice(toolErrorDisplay(error, 'turn'), 'error');
1083
+ }
998
1084
  }
999
1085
  } 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.
1086
+ const isStaleUnwind = !isCurrentTurn();
1087
+ if (!isStaleUnwind) denyAllToolApprovals(cancelled ? 'turn cancelled' : 'turn finished');
1088
+ // Turn is unwinding normally (or via abort) cancel the idle watchdog and
1089
+ // its force-release grace so they never fire on a live turn.
1003
1090
  clearWatchdog();
1004
1091
  // If the watchdog force-release already fired, a NEWER turn now owns the
1005
1092
  // 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).
1093
+ // unwind must NOT write shared getState() or it corrupts that turn.
1016
1094
  let closingItems = [];
1017
1095
  if (deferredEntries.length) {
1018
- const last = deferredEntries[deferredEntries.length - 1];
1019
- closingItems = collectDeferredUpTo(last);
1096
+ if (!isStaleUnwind) {
1097
+ // Flush any still-deferred tool cards into the transcript and cancel
1098
+ // their pending push timers so nothing fires (or leaks) after the turn
1099
+ // ends. The finalize path above already patches results onto visible
1100
+ // cards; this just guarantees every registered card is materialized
1101
+ // before the turn closes. Collect (don't emit) the still-deferred cards
1102
+ // so the turn-close flush and the turndone item append in ONE set()
1103
+ // below instead of one render bounce per row. Order/ids are preserved
1104
+ // (creation order, then turndone last).
1105
+ const last = deferredEntries[deferredEntries.length - 1];
1106
+ closingItems = collectDeferredUpTo(last);
1107
+ }
1020
1108
  clearDeferredTimers();
1021
1109
  }
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;
1110
+ if (!isStaleUnwind) flags.flushDeferredBeforeImmediatePush = null;
1027
1111
  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
1112
  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();
1113
+ tuiDebug(`runTurn STALE UNWIND turn=${turnIndex} force-released; skipping shared UI/state writes`);
1058
1114
  } 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();
1115
+ const producedTranscriptItem =
1116
+ getState().items.length + closingItems.length > itemsAtTurnStart;
1117
+ const reclaimed = cancelled && flags.activePromptRestore?.reclaimed === true;
1118
+ flags.activePromptRestore = null;
1119
+ const elapsedMs = Date.now() - startedAt;
1120
+ const thinkingElapsedMs = thinkingStartedAt ? accumulatedThinkingMs : 0;
1121
+ // responseLength is engine-local now (not pushed per-token), so compute the
1122
+ // fallback from the live accumulator instead of the possibly-stale
1123
+ // getState().spinner.responseLength. Final-only / non-streaming turns never
1124
+ // accumulate `assistantText` (only currentAssistantText is set at the
1125
+ // finalize reconcile above), so take the larger of the two text sources so
1126
+ // a no-usage turn still estimates tokens from the final content.
1127
+ const finalAssistantLen = Math.max(assistantText.length, currentAssistantText.length);
1128
+ const finalResponseLength = finalAssistantLen + thinkingText.length;
1129
+ const finalOutputTokens = Math.max(0, Number(getState().spinner?.outputTokens || 0), Math.round(finalResponseLength / 4));
1130
+ const turnStatus = cancelled ? 'cancelled' : 'done';
1131
+ const resultContent = askResult?.content != null ? String(askResult.content).trim() : '';
1132
+ const assistantOutput = (currentAssistantText || assistantText || '').trim();
1133
+ // Suppress only true pending-resume no-ops: no transcript items added and no model output; cancelled/error turns and any visible turn stay marked.
1134
+ const isNoOpTurn = turnFinishedNormally
1135
+ && !cancelled
1136
+ && toolCards.length === 0
1137
+ && !resultContent
1138
+ && !assistantOutput
1139
+ && !producedTranscriptItem;
1140
+ if (!isNoOpTurn) {
1141
+ getState().stats.turns = (getState().stats.turns || 0) + 1;
1142
+ }
1143
+ // Pin the post-think summary into the transcript right after this turn's
1144
+ // output so it scrolls up with the answer and stays in the scrollback,
1145
+ // in scrollback. (Previously TurnDone rendered only in the
1146
+ // bottom-fixed live-status slot and vanished on the next turn.)
1147
+ if (!reclaimed && !isNoOpTurn) {
1148
+ closingItems.push({ kind: 'turndone', id: nextId(), elapsedMs, status: turnStatus, outputTokens: finalOutputTokens, thinkingElapsedMs, verb: pickDoneVerb(turnIndex) });
1149
+ }
1150
+ // Deferred cards + turndone + status all land in ONE set() (one commit).
1151
+ appendItemsBatch(closingItems, {
1152
+ busy: false,
1153
+ spinner: null,
1154
+ thinking: null,
1155
+ lastTurn: null,
1156
+ stats: { ...getState().stats },
1157
+ ...routeState(),
1158
+ toolMode: runtime.toolMode,
1159
+ ...agentStatusState({ force: true }),
1160
+ });
1161
+ flushDeferredExecutionPendingResumeKick();
1081
1162
  }
1082
1163
  }
1083
1164
  // Shared UI getState(): a stale unwind must not wipe a newer turn's live