mixdog 0.9.36 → 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 (103) 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/providers/anthropic-oauth.mjs +6 -6
  32. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  33. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
  35. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
  36. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
  39. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
  40. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
  41. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +4 -2
  42. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
  43. package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
  44. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
  45. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
  46. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
  47. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
  48. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
  49. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
  50. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -17
  51. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
  52. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
  53. package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
  54. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
  55. package/src/runtime/agent/orchestrator/stall-policy.mjs +18 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
  57. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
  58. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
  59. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
  60. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
  62. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
  63. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
  64. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
  65. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
  67. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  68. package/src/runtime/channels/lib/worker-main.mjs +5 -0
  69. package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
  70. package/src/runtime/memory/lib/ko-morph.mjs +1 -1
  71. package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
  72. package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
  73. package/src/runtime/memory/lib/recall-format.mjs +3 -3
  74. package/src/runtime/shared/child-spawn-gate.mjs +15 -0
  75. package/src/runtime/shared/config.mjs +98 -12
  76. package/src/runtime/shared/process-listener-headroom.mjs +12 -0
  77. package/src/session-runtime/cwd-plugins.mjs +6 -3
  78. package/src/session-runtime/model-route-api.mjs +50 -2
  79. package/src/session-runtime/provider-auth-api.mjs +8 -1
  80. package/src/session-runtime/resource-api.mjs +22 -0
  81. package/src/session-runtime/runtime-core.mjs +13 -2
  82. package/src/session-runtime/settings-api.mjs +11 -2
  83. package/src/standalone/agent-tool.mjs +15 -9
  84. package/src/standalone/explore-tool.mjs +4 -1
  85. package/src/tui/App.jsx +6 -5
  86. package/src/tui/app/transcript-window.mjs +60 -10
  87. package/src/tui/app/use-transcript-window.mjs +2 -1
  88. package/src/tui/components/ContextPanel.jsx +4 -1
  89. package/src/tui/components/ToolExecution.jsx +15 -12
  90. package/src/tui/components/TranscriptItem.jsx +1 -1
  91. package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
  92. package/src/tui/dist/index.mjs +467 -151
  93. package/src/tui/engine/agent-job-feed.mjs +26 -6
  94. package/src/tui/engine/notification-plan.mjs +3 -3
  95. package/src/tui/engine/render-timing.mjs +104 -8
  96. package/src/tui/engine/session-api.mjs +58 -3
  97. package/src/tui/engine/session-flow.mjs +78 -28
  98. package/src/tui/engine/tool-card-results.mjs +28 -13
  99. package/src/tui/engine/turn.mjs +232 -156
  100. package/src/tui/engine.mjs +17 -8
  101. package/src/tui/index.jsx +10 -1
  102. package/src/workflows/default/WORKFLOW.md +8 -4
  103. package/src/workflows/solo/WORKFLOW.md +8 -4
@@ -94,18 +94,27 @@ export function createAgentJobFeed({
94
94
  queueMicrotask(() => kickExecutionPendingResume(body));
95
95
  }
96
96
 
97
- function updateAgentJobCard(itemId, text, isError = false) {
97
+ // Pure builder for the agent-job card patch. Split out so callers that are
98
+ // already patching the same card in the same tick (see tool-card-results
99
+ // non-aggregate path) can MERGE these fields into their single patchItem
100
+ // instead of issuing a second set() — collapsing the L1/L2 double-update
101
+ // jitter into one visible item update.
102
+ function buildAgentJobCardPatch(itemId, text, isError = false) {
98
103
  const parsed = parseAgentJob(text);
99
104
  const current = getState().items.find((it) => it.id === itemId);
100
105
  const rawDisplayText = agentJobResultText(text, parsed) || String(text ?? '').trim();
101
106
  const displayText = isError ? toolErrorDisplay(rawDisplayText, 'agent') : rawDisplayText;
102
- patchItem(itemId, {
107
+ return {
103
108
  result: displayText,
104
109
  text: displayText,
105
110
  isError,
106
111
  errorCount: isError ? 1 : 0,
107
112
  ...(parsed ? { args: agentArgsWithResultMetadata(current?.args, parsed) } : {}),
108
- });
113
+ };
114
+ }
115
+
116
+ function updateAgentJobCard(itemId, text, isError = false) {
117
+ patchItem(itemId, buildAgentJobCardPatch(itemId, text, isError));
109
118
  }
110
119
 
111
120
  function subscribeRuntimeNotifications() {
@@ -119,7 +128,7 @@ export function createAgentJobFeed({
119
128
  const delivery = resolveTuiRuntimeNotificationDelivery(event, text);
120
129
  if (delivery.action === 'ignore') return;
121
130
  if (delivery.action === 'notice') {
122
- pushNotice?.(delivery.displayText, delivery.tone || 'info', { transcript: true });
131
+ pushNotice?.(delivery.displayText, delivery.tone || 'info', { transcript: delivery.transcript === true });
123
132
  return true;
124
133
  }
125
134
  if (delivery.action === 'status-only') {
@@ -135,7 +144,15 @@ export function createAgentJobFeed({
135
144
  if (parsed?.taskId) set(agentStatusState({ force: true }));
136
145
  const resumeBody = String(delivery.modelContent || '').trim();
137
146
  if (resumeBody) {
138
- scheduleExecutionPendingResumeKick(resumeBody);
147
+ enqueue(resumeBody, {
148
+ mode: 'task-notification',
149
+ // Claude Code parity: live execution completions are queued as
150
+ // task notifications so the active loop can attach them after the
151
+ // next tool batch; no special pending-resume bypass.
152
+ priority: 'next',
153
+ key: notificationKey || undefined,
154
+ displayText: delivery.displayText || text,
155
+ });
139
156
  }
140
157
  return true;
141
158
  }
@@ -147,7 +164,9 @@ export function createAgentJobFeed({
147
164
  if (!modelContent && imagePaths.length === 0) return true;
148
165
  const enqueueOpts = {
149
166
  mode: 'task-notification',
150
- priority: 'next',
167
+ // Claude Code parity: task/schedule notifications are lower-priority
168
+ // queue items and drain between turns, behind direct user input.
169
+ priority: 'later',
151
170
  key: notificationKey || undefined,
152
171
  displayText: delivery.displayText || text,
153
172
  };
@@ -184,6 +203,7 @@ export function createAgentJobFeed({
184
203
  flushDeferredExecutionPendingResumeKick,
185
204
  scheduleExecutionPendingResumeKick,
186
205
  updateAgentJobCard,
206
+ buildAgentJobCardPatch,
187
207
  subscribeRuntimeNotifications,
188
208
  };
189
209
  }
@@ -61,14 +61,14 @@ export function resolveTuiRuntimeNotificationDelivery(event, text) {
61
61
  if (!trimmed) return { action: 'ignore' };
62
62
  const parsed = parseAgentJob(trimmed);
63
63
  const meta = event?.meta && typeof event.meta === 'object' ? event.meta : {};
64
- // UI-only notices (e.g. boot auto-update outcome): render as a transcript
65
- // notice, never enqueue anything model-visible.
64
+ // UI-only notices (e.g. boot auto-update outcome): render as a transient
65
+ // notice, never enqueue anything model-visible or transcript-persistent.
66
66
  if (meta.kind === 'update-notice') {
67
67
  // Wording lives here (the notice surface), not in the emitting runtime:
68
68
  // the emitter only supplies meta.version; the sentence is composed here.
69
69
  const ver = String(meta.version || '').trim();
70
70
  const displayText = ver ? `mixdog v${ver} ready — restart to apply.` : trimmed;
71
- return { action: 'notice', displayText, tone: meta.tone === 'warn' ? 'warn' : 'info' };
71
+ return { action: 'notice', displayText, tone: meta.tone === 'warn' ? 'warn' : 'info', transcript: false };
72
72
  }
73
73
  if (!isExecutionNotification(event, trimmed, parsed)) {
74
74
  return { action: 'enqueue', displayText: trimmed, modelContent: trimmed };
@@ -1,17 +1,113 @@
1
1
  /**
2
2
  * src/tui/engine/render-timing.mjs - render-throttle timing helper.
3
3
  *
4
- * Extracted from engine.mjs (no behavior change).
4
+ * Extracted from engine.mjs (no behavior change originally).
5
5
  *
6
- * Ink renders through a maxFps throttle (120fps in index.jsx, ≈8.3ms). A plain
6
+ * Ink renders through a maxFps throttle (60fps in index.jsx, ≈16.7ms). A plain
7
7
  * setImmediate only yields to the event loop; if Ink already painted within the
8
8
  * current throttle window, the next paint may still be queued and our following
9
- * transcript mutation can coalesce into the same visible frame. Wait just past
10
- * one render window when we intentionally split transcript commits for visual
11
- * stability (preamble frame tool-card frame).
9
+ * transcript mutation can coalesce into the same visible frame. When we
10
+ * intentionally split transcript commits for visual stability (preamble frame →
11
+ * tool-card frame), we must wait for the split-off commit to actually paint.
12
+ *
13
+ * A fixed 12ms wait was below the 60fps frame budget, so the tool card could
14
+ * land in the same frame as the preamble and the bottom-pinned viewport jumped.
15
+ * Instead we wait for the next real Ink render frame: index.jsx routes its
16
+ * onRender hook through notifyRenderFrame(), which resolves any pending yields.
17
+ * The fixed timeout below is only a fallback ceiling for when no renderer ack is
18
+ * wired (or the renderer is idle) so a yield can never hang the turn.
12
19
  */
13
- export const RENDER_THROTTLE_FLUSH_MS = 12;
20
+ export const RENDER_ACK_FALLBACK_MS = 32;
21
+ export const RENDER_ACK_HANG_GUARD_MS = 250;
22
+ export const RENDER_SETTLE_IDLE_MS = 64;
23
+
24
+ // Back-compat alias: previously the fixed wait duration, now the fallback only.
25
+ export const RENDER_THROTTLE_FLUSH_MS = RENDER_ACK_FALLBACK_MS;
26
+
27
+ let pendingRenderAcks = [];
28
+ let renderAckSeq = 0;
29
+
30
+ /**
31
+ * Called by the Ink onRender hook once per painted frame. The sequence is
32
+ * allocated synchronously at onRender time, BEFORE the deferred post-write ack,
33
+ * so a yield that starts after onRender but before setImmediate delivery can
34
+ * identify and ignore that stale/previous-frame ack.
35
+ */
36
+ export const scheduleRenderFrameAck = () => {
37
+ const seq = ++renderAckSeq;
38
+ setImmediate(() => notifyRenderFrame(seq));
39
+ };
40
+
41
+ export const notifyRenderFrame = (seq = ++renderAckSeq) => {
42
+ if (pendingRenderAcks.length === 0) return;
43
+ const acks = pendingRenderAcks;
44
+ pendingRenderAcks = [];
45
+ for (const ack of acks) ack(seq);
46
+ };
14
47
 
15
- export const yieldToRenderer = () => new Promise((resolve) => {
16
- setTimeout(resolve, RENDER_THROTTLE_FLUSH_MS);
48
+ export const yieldToRenderer = ({ frames = 1 } = {}) => new Promise((resolve) => {
49
+ const minSeq = renderAckSeq;
50
+ let remainingFrames = Math.max(1, Math.floor(Number(frames) || 1));
51
+ let settled = false;
52
+ let sawRealFrame = false;
53
+ let timer = null;
54
+ const finish = () => {
55
+ if (settled) return;
56
+ settled = true;
57
+ if (timer) clearTimeout(timer);
58
+ const idx = pendingRenderAcks.indexOf(onFrame);
59
+ if (idx !== -1) pendingRenderAcks.splice(idx, 1);
60
+ resolve();
61
+ };
62
+ const onTimeout = () => {
63
+ if (settled) return;
64
+ // A render has already reached onRender and scheduled its deferred
65
+ // post-write ack, but the event loop may run expired timers before the
66
+ // check-phase setImmediate. Do NOT let the first-frame hang guard beat that
67
+ // queued real-frame ack; keep waiting for it so frames:2 cannot collapse to
68
+ // zero/one actual paints under a slow preamble render.
69
+ if (!sawRealFrame && renderAckSeq > minSeq) {
70
+ // Yield exactly one check phase: a legitimate queued notifyRenderFrame(seq)
71
+ // was scheduled before this timeout callback and should run first. If it is
72
+ // lost/skipped, this follow-up finishes the hang guard instead of
73
+ // re-arming forever.
74
+ setImmediate(() => {
75
+ if (!settled && !sawRealFrame) finish();
76
+ });
77
+ return;
78
+ }
79
+ finish();
80
+ };
81
+ const armWait = () => {
82
+ if (settled) return;
83
+ if (!pendingRenderAcks.includes(onFrame)) pendingRenderAcks.push(onFrame);
84
+ if (timer) clearTimeout(timer);
85
+ // Before the FIRST real frame, the timer is only a long hang guard for
86
+ // no-ack/no-render paths. After at least one frame painted, the timer becomes
87
+ // a short "settle idle" fallback: if no follow-up measurement/correction
88
+ // render arrives within roughly four 60fps frames, treat the preamble as
89
+ // stable instead of forcing a visible 250ms pause before the tool card.
90
+ timer = setTimeout(onTimeout, sawRealFrame ? RENDER_SETTLE_IDLE_MS : RENDER_ACK_HANG_GUARD_MS);
91
+ };
92
+ const onFrame = (seq = 0) => {
93
+ if (settled) return;
94
+ if (seq <= minSeq) {
95
+ if (!pendingRenderAcks.includes(onFrame)) pendingRenderAcks.push(onFrame);
96
+ return;
97
+ }
98
+ sawRealFrame = true;
99
+ if (timer) {
100
+ clearTimeout(timer);
101
+ timer = null;
102
+ }
103
+ const idx = pendingRenderAcks.indexOf(onFrame);
104
+ if (idx !== -1) pendingRenderAcks.splice(idx, 1);
105
+ remainingFrames -= 1;
106
+ if (remainingFrames <= 0) finish();
107
+ else armWait();
108
+ };
109
+ // Count real render acks as frames. The pre-first-frame timeout is a hang
110
+ // guard, while the post-first-frame timeout is an idle-settle fallback so a
111
+ // missing second frame does not add a quarter-second tool-card delay.
112
+ armWait();
17
113
  });
@@ -9,13 +9,22 @@ import { buildDoctorReport } from '../app/doctor.mjs';
9
9
  import { recomputePromptHistory } from './prompt-history.mjs';
10
10
  import { buildMergedPromptHistory, loadPromptHistory } from '../prompt-history-store.mjs';
11
11
 
12
+ // Upper bound on how long a manual Esc abort may leave the store busy while the
13
+ // in-flight runtime.ask() unwinds. runtime.abort() normally rejects the pending
14
+ // ask within a tick; this is the belt-and-suspenders window before the bounded
15
+ // recovery timer hard-releases busy so Esc can never wedge input forever.
16
+ const MANUAL_ABORT_RECOVERY_MS = (() => {
17
+ const v = Number(process.env.MIXDOG_MANUAL_ABORT_RECOVERY_MS);
18
+ return Number.isFinite(v) && v > 0 ? v : 4000;
19
+ })();
20
+
12
21
  export function createEngineApi(bag) {
13
22
  return { ...createEngineApiA(bag), ...createEngineApiB(bag) };
14
23
  }
15
24
 
16
25
  export function createEngineApiA(bag) {
17
26
  const {
18
- runtime, nextId, flags, pending, listeners, getState, set, pushItem, patchItem, replaceItems, pushNotice, autoClearState, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, updateAgentJobCard, requeueEntriesFront, enqueue, autoClearBeforeSubmit, restoreQueued, resetStatsAndSyncContext,
27
+ runtime, nextId, flags, pending, listeners, getState, set, pushItem, patchItem, replaceItems, pushNotice, autoClearState, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, updateAgentJobCard, requeueEntriesFront, enqueue, autoClearBeforeSubmit, restoreQueued, resetStatsAndSyncContext, drain, flushDeferredExecutionPendingResumeKick,
19
28
  } = bag;
20
29
  return {
21
30
  getState: () => getState(),
@@ -44,12 +53,23 @@ export function createEngineApiA(bag) {
44
53
  enqueue(text, queueOptions);
45
54
  return true;
46
55
  }
47
- if (getState().commandBusy) return false;
56
+ // Any in-flight session command (clear/setModel/newSession/resume/...)
57
+ // holds commandBusy. Previously the prompt was dropped here and only the
58
+ // prompt-history side effect survived. Queue it instead: drain bails while
59
+ // commandBusy, and the central release hook re-kicks drain once the
60
+ // command settles, so the prompt runs afterwards rather than vanishing.
61
+ if (getState().commandBusy) {
62
+ enqueue(text, queueOptions);
63
+ return true;
64
+ }
48
65
  if (getState().busy) {
49
66
  enqueue(text, queueOptions);
50
67
  return true;
51
68
  }
52
- void autoClearBeforeSubmit().then(() => enqueue(text, queueOptions));
69
+ // If autoClearBeforeSubmit rejects (e.g. compaction timeout throws), the
70
+ // prompt must still be queued — swallow the rejection so enqueue always
71
+ // runs and the submit is never silently lost.
72
+ void autoClearBeforeSubmit().catch(() => {}).then(() => enqueue(text, queueOptions));
53
73
  return true;
54
74
  },
55
75
  restoreQueued,
@@ -573,6 +593,41 @@ export function createEngineApiA(bag) {
573
593
  restoreState.restorable = false;
574
594
  restoreState.requeueEntries = [];
575
595
  }
596
+ // ── Bounded manual-abort recovery ───────────────────────────────────
597
+ // runtime.abort() above normally rejects the in-flight runtime.ask() so
598
+ // the turn's own finally clears busy within a tick. If that unwind is
599
+ // starved — e.g. a provider abort that never settles after a post-tool
600
+ // fetch stall — busy would stay true until the far-larger turn watchdog
601
+ // trips, wedging the TUI with dead input. Arm a short grace timer that
602
+ // hard-releases busy exactly like the watchdog force-release, but ONLY
603
+ // when this same turn is still the active owner (epoch unchanged) AND
604
+ // still busy — so a normal cancellation that settles in time is never
605
+ // masked and a newer turn's store is never corrupted.
606
+ const abortEpoch = flags.leadTurnEpoch;
607
+ const recoveryMs = Number(flags.manualAbortRecoveryMs) > 0
608
+ ? Number(flags.manualAbortRecoveryMs)
609
+ : MANUAL_ABORT_RECOVERY_MS;
610
+ const recoveryTimer = setTimeout(() => {
611
+ if (flags.disposed) return;
612
+ if (!getState().busy) return; // normal abort settled
613
+ if (flags.leadTurnEpoch !== abortEpoch) return; // a newer turn owns the store
614
+ // Bump the epoch FIRST so the still-stuck turn's eventual finally becomes
615
+ // a no-op for shared getState() writes and cannot corrupt the handoff.
616
+ flags.leadTurnEpoch = (Number(flags.leadTurnEpoch) || 0) + 1;
617
+ set({ busy: false, spinner: null, thinking: null, lastTurn: null });
618
+ flags.activePromptRestore = null;
619
+ // Abandon the drain loop that is still awaiting the stuck turn before
620
+ // releasing the drain lock. Its eventual finally observes the stale
621
+ // epoch and cannot clear a newer drain's ownership or continue work.
622
+ flags.drainEpoch = (Number(flags.drainEpoch) || 0) + 1;
623
+ if (flags.draining) flags.draining = false;
624
+ pushNotice('Interrupt did not settle — input restored.', 'warn', { transcript: true });
625
+ if (pending.length > 0 && typeof drain === 'function') void drain();
626
+ // busy→false here bypasses the normal turn-end + drain-finally flushes,
627
+ // so re-arm any deferred completion kick explicitly (idempotent).
628
+ if (typeof flushDeferredExecutionPendingResumeKick === 'function') flushDeferredExecutionPendingResumeKick();
629
+ }, recoveryMs);
630
+ recoveryTimer.unref?.();
576
631
  return { aborted, restoreText, pastedImages: restorePastedImages, discardPastedImages, pastedTexts: restorePastedTexts, discardPastedTexts };
577
632
  },
578
633
  };
@@ -60,9 +60,6 @@ export function createSessionFlow(bag) {
60
60
 
61
61
  function removeQueuedEntries(entries) {
62
62
  const ids = new Set(entries.map((entry) => entry.id));
63
- const keys = entries.map((entry) => entry.key).filter(Boolean);
64
- for (const key of keys) pendingNotificationKeys.delete(key);
65
- for (const key of keys) displayedExecutionNotificationKeys.delete(key);
66
63
  const queued = getState().queued.filter((q) => !ids.has(q.id));
67
64
  if (queued.length !== getState().queued.length) set({ queued });
68
65
  }
@@ -76,8 +73,9 @@ export function createSessionFlow(bag) {
76
73
  displayText: entry.displayText || (entry.mode === 'task-notification' ? notificationDisplayText(entry.text) : String(entry.text || '')),
77
74
  };
78
75
  if (next.mode === 'task-notification' && next.key) {
79
- if (pendingNotificationKeys.has(next.key)) continue;
80
- pendingNotificationKeys.add(next.key);
76
+ const duplicateQueued = pending.some((entry) => entry?.mode === 'task-notification' && entry?.key === next.key);
77
+ if (duplicateQueued) continue;
78
+ if (!pendingNotificationKeys.has(next.key)) pendingNotificationKeys.add(next.key);
81
79
  }
82
80
  restored.push(next);
83
81
  }
@@ -120,17 +118,67 @@ export function createSessionFlow(bag) {
120
118
  return batch;
121
119
  }
122
120
 
121
+ function scheduleBlockedDrainRetry() {
122
+ if (pending.length === 0) return;
123
+ if (flags.blockedDrainRetryTimer) return;
124
+ const timer = setTimeout(() => {
125
+ flags.blockedDrainRetryTimer = null;
126
+ if (pending.length > 0) void drain();
127
+ }, 50);
128
+ if (typeof timer.unref === 'function') timer.unref();
129
+ flags.blockedDrainRetryTimer = timer;
130
+ }
131
+
132
+ function clearBlockedDrainRetry() {
133
+ if (!flags.blockedDrainRetryTimer) return;
134
+ clearTimeout(flags.blockedDrainRetryTimer);
135
+ flags.blockedDrainRetryTimer = null;
136
+ }
137
+
138
+ function hasModelDrainablePending() {
139
+ return pending.some((entry) => !isSlashQueuedEntry(entry));
140
+ }
141
+
123
142
  async function drain() {
124
143
  if (flags.draining) return;
125
- if (flags.autoClearRunning) return;
144
+ // Bail while any session command holds commandBusy (auto-clear implies it,
145
+ // but so do setModel/newSession/resume/etc). Running a turn concurrently
146
+ // with a command that swaps or reroutes the live session is a race; the
147
+ // commandBusy-release hook re-kicks drain once the command finishes.
148
+ if (flags.autoClearRunning || getState().commandBusy) {
149
+ scheduleBlockedDrainRetry();
150
+ return;
151
+ }
152
+ // Claude Code parity: a queued prompt/notification can arrive while a
153
+ // provider turn is already in flight (scheduled message, webhook, agent
154
+ // completion, or user input), but the unified queue only runs BETWEEN
155
+ // turns. Do NOT start a second Lead runTurn from the post-turn drain in
156
+ // that window: the active runtime.ask owns the session mutex/transcript.
157
+ // Anything pending is kicked again by runTurn.finally once busy flips
158
+ // false. Starting a parallel run here is what tangles turn order and can
159
+ // abort/interleave the active turn.
160
+ if (getState().busy) {
161
+ tuiDebug(`busy-queue drain deferred while active pending=${pending.length}`);
162
+ return;
163
+ }
164
+ clearBlockedDrainRetry();
165
+ const drainEpoch = (Number(flags.drainEpoch) || 0) + 1;
166
+ flags.drainEpoch = drainEpoch;
126
167
  flags.draining = true;
127
168
  let firstBatch = true;
128
169
  try {
129
170
  while (pending.length > 0) {
171
+ if (flags.drainEpoch !== drainEpoch) return;
130
172
  // Drain one priority/mode bucket at a time (unified command queue):
131
173
  // unified command queue semantics: prompt steering stays editable and
132
174
  // task notifications stay non-editable but model-visible.
133
- const batch = dequeueQueueBatch('later', { limit: firstBatch ? 1 : Infinity });
175
+ const batch = dequeueQueueBatch('later', {
176
+ limit: firstBatch ? 1 : Infinity,
177
+ // Slash commands must run through the TUI command dispatcher, not be
178
+ // delivered to the model as plain text. Claude Code's queueProcessor
179
+ // similarly handles slash entries outside the queued_command drain.
180
+ predicate: (entry) => !isSlashQueuedEntry(entry),
181
+ });
134
182
  firstBatch = false;
135
183
  if (batch.length === 0) break;
136
184
  tuiDebug(`busy-queue drain batch=${batch.length} remaining=${pending.length}`);
@@ -152,6 +200,7 @@ export function createSessionFlow(bag) {
152
200
  restorable: nonEditable.length === 0,
153
201
  requeueOnAbort: nonEditable,
154
202
  });
203
+ if (flags.drainEpoch !== drainEpoch) return;
155
204
  // A deferred cleared-session UI sync (from a late-settling abandoned
156
205
  // compacting clear) applies here now that this turn has settled.
157
206
  flushDeferredClearedSessionUi();
@@ -180,10 +229,12 @@ export function createSessionFlow(bag) {
180
229
  if (turnStatus === 'cancelled' && pending.length === 0) break;
181
230
  }
182
231
  } finally {
183
- flags.draining = false;
184
- flushDeferredClearedSessionUi();
185
- if (pending.length > 0) void drain();
186
- else flushDeferredExecutionPendingResumeKick();
232
+ if (flags.drainEpoch === drainEpoch) {
233
+ flags.draining = false;
234
+ flushDeferredClearedSessionUi();
235
+ if (hasModelDrainablePending()) void drain();
236
+ else flushDeferredExecutionPendingResumeKick();
237
+ }
187
238
  }
188
239
  }
189
240
  function enqueue(text, options = {}) {
@@ -202,25 +253,24 @@ export function createSessionFlow(bag) {
202
253
  return true;
203
254
  }
204
255
 
205
- function drainPendingSteering() {
206
- // Mid-turn steering drain:
207
- // Injects queued user prompts (steering) plus non-editable internal entries
208
- // into the CURRENT provider pre-send window so the user can redirect a turn
209
- // that is already running. Slash commands are still excluded: they must run
210
- // through the normal command processor after the turn, not be sent as plain
211
- // text. Consumed entries are spliced out of `pending` here, so the post-turn
212
- // drain() loop will not re-execute them.
213
- //
214
- // dequeueQueueBatch drains ONE priority/mode bucket per call, capped at a
215
- // max priority. A concurrent user steering prompt (`next`) and a task
216
- // notification (`later`) sit in different buckets, so a single `next`-capped
217
- // dequeue would leave the notification pending and the post-turn drain()
218
- // loop would spawn an unintended follow-up turn/reply. Loop up to `later`
219
- // so EVERY non-slash bucket is consumed by the current turn.
220
- const predicate = (entry) => !isSlashQueuedEntry(entry);
256
+ function drainPendingSteering(_sessionIdOrOptions = null, maybeOptions = null) {
257
+ const options = maybeOptions && typeof maybeOptions === 'object'
258
+ ? maybeOptions
259
+ : (_sessionIdOrOptions && typeof _sessionIdOrOptions === 'object' ? _sessionIdOrOptions : {});
260
+ const maxPriority = options.maxPriority || 'next';
261
+ // Claude Code parity: mid-chain drain converts queued prompt/task
262
+ // notification entries into model-visible "queued_command" style steering
263
+ // only at provider continuation boundaries. Slash commands stay queued for
264
+ // the post-turn command processor. `later` notifications (scheduled tasks)
265
+ // are skipped unless the runtime explicitly asks for a later flush.
266
+ const predicate = (entry) => {
267
+ if (isSlashQueuedEntry(entry)) return false;
268
+ const mode = entry?.mode || 'prompt';
269
+ return mode === 'prompt' || mode === 'task-notification';
270
+ };
221
271
  const out = [];
222
272
  for (;;) {
223
- const batch = dequeueQueueBatch('later', { predicate });
273
+ const batch = dequeueQueueBatch(maxPriority, { predicate });
224
274
  if (batch.length === 0) break;
225
275
  for (const entry of batch) {
226
276
  const content = entry.content;
@@ -14,7 +14,7 @@ import { summarizeToolResult, aggregateDoneCategories, classifyToolCategory } fr
14
14
  import { toolResultText, toolErrorDisplay, toolGroupedDisplayFallback } from './tool-result-text.mjs';
15
15
  import { toolResultCallId } from './tool-call-fields.mjs';
16
16
  import { memoryCoreResultErrorText } from '../app/input-parsers.mjs';
17
- import { parseAgentJob, agentArgsWithResultMetadata, toolResultStatus, isErrorToolStatus } from './agent-envelope.mjs';
17
+ import { parseAgentJob, toolResultStatus, isErrorToolStatus } from './agent-envelope.mjs';
18
18
  import {
19
19
  withCancelledResultMarker,
20
20
  groupedToolResultText,
@@ -31,6 +31,7 @@ export function createToolCardResults({
31
31
  patchItem,
32
32
  markToolCallDone,
33
33
  updateAgentJobCard,
34
+ buildAgentJobCardPatch,
34
35
  agentStatusState,
35
36
  }) {
36
37
  function patchToolItem(id, patch) {
@@ -65,7 +66,16 @@ export function createToolCardResults({
65
66
  // calls: the text-matcher's ^(error|failed) catch-all would otherwise
66
67
  // misflag legitimate non-memory success output.
67
68
  const isMemoryCall = classifyToolCategory(callRec?.name || card?.name || '', callRec?.args || {}) === 'Memory';
68
- const isError = message?.isError === true || message?.toolKind === 'error' || /^\s*\[?error/i.test(rawText) || isErrorToolStatus(toolResultStatus(rawText)) || (isMemoryCall && memoryCoreResultErrorText(rawText) != null);
69
+ // Split the failure signal into two:
70
+ // isCallError — a REAL tool-call failure (backend isError / error
71
+ // toolKind). ONLY this paints the ● dot red.
72
+ // isResultError — a command/result failure (shell exit code, [error…]
73
+ // text, failed status text, flattened core memory-op
74
+ // failure). These still mark the card Failed in the L2
75
+ // detail but must NOT turn the dot red.
76
+ const isCallError = message?.isError === true || message?.toolKind === 'error';
77
+ const isResultError = /^\s*\[?error/i.test(rawText) || isErrorToolStatus(toolResultStatus(rawText)) || (isMemoryCall && memoryCoreResultErrorText(rawText) != null);
78
+ const isError = isCallError || isResultError;
69
79
  const text = isError ? toolErrorDisplay(rawText, card?.name || 'tool') : rawText;
70
80
 
71
81
  if (aggregate && card.itemId === aggregate.itemId) {
@@ -78,11 +88,13 @@ export function createToolCardResults({
78
88
  callRec.summary = !isError ? summarizeToolResult(callRec.name, callRec.args, rawText, isError) : null;
79
89
  assignAggregateSummaryOrder(aggregate, callRec);
80
90
  callRec.isError = isError;
91
+ callRec.isCallError = isCallError;
81
92
  callRec.resultText = text;
82
93
  callRec.resolved = true;
83
94
  const allCalls = [...aggregate.calls.values()];
84
95
  const completed = allCalls.filter((r) => r.resolved).length;
85
96
  const errors = allCalls.filter((r) => r.isError).length;
97
+ const callErrors = allCalls.filter((r) => r.isCallError).length;
86
98
  // Collapsed detail carries the merged per-call count summary
87
99
  // ("512 lines, 6 matches, 3 files") so the finished card answers "how
88
100
  // much" without ctrl+o. Failures keep a bare 'N Ok · N Failed' status so
@@ -103,6 +115,7 @@ export function createToolCardResults({
103
115
  rawResult: rawResult || null,
104
116
  isError: errors > 0,
105
117
  errorCount: errors,
118
+ callErrorCount: callErrors,
106
119
  count: allCalls.length,
107
120
  completedCount: visualCompleted,
108
121
  doneCategories: aggregateDoneCategories(allCalls),
@@ -114,9 +127,10 @@ export function createToolCardResults({
114
127
  }
115
128
 
116
129
  // Non-aggregate (legacy agent-job cards, etc.)
117
- const group = toolGroups.get(card.itemId) || { count: 1, completed: 0, errors: 0, results: [] };
130
+ const group = toolGroups.get(card.itemId) || { count: 1, completed: 0, errors: 0, callErrors: 0, results: [] };
118
131
  group.completed = Math.min(group.count, group.completed + 1);
119
132
  group.errors += isError ? 1 : 0;
133
+ group.callErrors = (group.callErrors || 0) + (isCallError ? 1 : 0);
120
134
  group.results.push({ text, isError });
121
135
  toolGroups.set(card.itemId, group);
122
136
  const resultText = groupedToolResultText(group);
@@ -126,6 +140,7 @@ export function createToolCardResults({
126
140
  text: displayResult,
127
141
  isError: group.errors > 0,
128
142
  errorCount: group.errors,
143
+ callErrorCount: group.callErrors || 0,
129
144
  count: group.count,
130
145
  completedCount: group.completed,
131
146
  completedAt: Date.now(),
@@ -135,19 +150,17 @@ export function createToolCardResults({
135
150
  if (body) patch.rawResult = text || rawText;
136
151
  const parsedAgent = parseAgentJob(rawText);
137
152
  if (parsedAgent) {
138
- patch.args = agentArgsWithResultMetadata(getState().items.find((it) => it.id === card.itemId)?.args, parsedAgent);
139
153
  set(agentStatusState({ force: true }));
140
154
  }
155
+ // Coalesce the agent-job card refresh (result/text/isError/errorCount/
156
+ // args) into THIS patch instead of a second updateAgentJobCard() call.
157
+ // The two calls previously wrote the same card back-to-back with
158
+ // different result/text strings, producing the visible L1/L2 flash.
159
+ // The agent fields win (final display) while patch keeps the completion
160
+ // metadata (count/completedCount/completedAt/rawResult) for expand.
161
+ Object.assign(patch, buildAgentJobCardPatch(card.itemId, rawText, isError));
141
162
  }
142
163
  patchToolItem(card.itemId, patch);
143
- if (group.count <= 1) {
144
- const beforeAgent = getState().items.find((it) => it.id === card.itemId);
145
- updateAgentJobCard(card.itemId, rawText, isError);
146
- const afterAgent = getState().items.find((it) => it.id === card.itemId);
147
- if (afterAgent && beforeAgent && afterAgent !== beforeAgent) {
148
- carryTranscriptMeasuredRowsCache(beforeAgent, afterAgent);
149
- }
150
- }
151
164
  card.done = true;
152
165
  if (callId) done.add(callId);
153
166
  return true;
@@ -209,6 +222,7 @@ export function createToolCardResults({
209
222
  const completed = allCalls.filter((r) => r.resolved).length;
210
223
  const totalCompleted = completed;
211
224
  const errors = allCalls.filter((r) => r.isError).length;
225
+ const callErrors = allCalls.filter((r) => r.isCallError).length;
212
226
  const succeeded = completed - errors;
213
227
  const rawResult = aggregateRawResult(allCalls);
214
228
  // Collapsed detail carries the merged per-call count summary; failures
@@ -229,6 +243,7 @@ export function createToolCardResults({
229
243
  rawResult: rawResult || null,
230
244
  isError: errors > 0,
231
245
  errorCount: errors,
246
+ callErrorCount: callErrors,
232
247
  count: allCalls.length,
233
248
  completedCount: totalCompleted,
234
249
  doneCategories: aggregateDoneCategories(allCalls),
@@ -250,7 +265,7 @@ export function createToolCardResults({
250
265
  const currentItem = getState().items.find((it) => it.id === card.itemId);
251
266
  resultText = withCancelledResultMarker(resultText, currentItem);
252
267
  }
253
- patchToolItem(card.itemId, { result: resultText, text: resultText, isError: group.errors > 0, errorCount: group.errors, count: group.count, completedCount: group.completed, completedAt: Date.now() });
268
+ patchToolItem(card.itemId, { result: resultText, text: resultText, isError: group.errors > 0, errorCount: group.errors, callErrorCount: group.callErrors || 0, count: group.count, completedCount: group.completed, completedAt: Date.now() });
254
269
  card.done = true;
255
270
  if (card.callId) done.add(card.callId);
256
271
  }