mixdog 0.9.37 → 0.9.39

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 (127) hide show
  1. package/package.json +9 -4
  2. package/scripts/abort-recovery-test.mjs +43 -2
  3. package/scripts/agent-tag-reuse-smoke.mjs +146 -6
  4. package/scripts/agent-terminal-reap-test.mjs +127 -0
  5. package/scripts/agent-trace-io-test.mjs +69 -0
  6. package/scripts/code-graph-disk-hit-test.mjs +224 -0
  7. package/scripts/dispatch-persist-recovery-test.mjs +141 -0
  8. package/scripts/execution-completion-dedup-test.mjs +157 -0
  9. package/scripts/execution-pending-resume-kick-test.mjs +57 -2
  10. package/scripts/execution-resume-esc-integration-test.mjs +174 -0
  11. package/scripts/explore-bench.mjs +101 -8
  12. package/scripts/explore-prompt-policy-test.mjs +156 -11
  13. package/scripts/find-fuzzy-hidden-test.mjs +122 -0
  14. package/scripts/internal-comms-bench-test.mjs +226 -0
  15. package/scripts/internal-comms-bench.mjs +185 -58
  16. package/scripts/internal-comms-smoke.mjs +171 -23
  17. package/scripts/live-worker-smoke.mjs +38 -2
  18. package/scripts/memory-cycle-routing-test.mjs +111 -0
  19. package/scripts/memory-rule-contract-test.mjs +93 -0
  20. package/scripts/notify-completion-mirror-test.mjs +73 -0
  21. package/scripts/output-style-smoke.mjs +2 -2
  22. package/scripts/rg-runner-test.mjs +240 -0
  23. package/scripts/routing-corpus-test.mjs +349 -0
  24. package/scripts/routing-corpus.mjs +211 -32
  25. package/scripts/session-orphan-sweep-test.mjs +83 -0
  26. package/scripts/session-sweep.mjs +266 -0
  27. package/scripts/steering-drain-buckets-test.mjs +179 -0
  28. package/scripts/tool-smoke.mjs +21 -13
  29. package/scripts/tool-tui-presentation-test.mjs +202 -0
  30. package/src/agents/heavy-worker/AGENT.md +10 -7
  31. package/src/agents/reviewer/AGENT.md +6 -4
  32. package/src/agents/worker/AGENT.md +7 -5
  33. package/src/rules/agent/00-common.md +4 -4
  34. package/src/rules/agent/00-core.md +11 -14
  35. package/src/rules/agent/20-skip-protocol.md +3 -3
  36. package/src/rules/agent/30-explorer.md +56 -48
  37. package/src/rules/agent/40-cycle1-agent.md +15 -24
  38. package/src/rules/agent/41-cycle2-agent.md +33 -57
  39. package/src/rules/agent/42-cycle3-agent.md +28 -42
  40. package/src/rules/lead/01-general.md +7 -10
  41. package/src/rules/lead/lead-brief.md +11 -14
  42. package/src/rules/lead/lead-tool.md +6 -5
  43. package/src/rules/shared/01-tool.md +44 -41
  44. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +37 -1
  45. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
  46. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
  47. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
  48. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
  49. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +6 -6
  50. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
  51. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -4
  52. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
  53. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
  54. package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
  55. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
  56. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +18 -52
  57. package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
  58. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
  59. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +41 -2
  60. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/session/store.mjs +339 -63
  62. package/src/runtime/agent/orchestrator/stall-policy.mjs +37 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
  65. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
  66. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
  67. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
  68. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
  69. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
  70. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
  71. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
  72. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
  73. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
  74. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
  75. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
  76. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
  77. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
  78. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
  79. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
  80. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
  81. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
  82. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
  83. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
  84. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  85. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
  86. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
  87. package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
  88. package/src/runtime/shared/tool-primitives.mjs +4 -1
  89. package/src/runtime/shared/tool-status.mjs +27 -0
  90. package/src/runtime/shared/tool-surface.mjs +6 -3
  91. package/src/session-runtime/config-helpers.mjs +14 -0
  92. package/src/session-runtime/context-status.mjs +1 -0
  93. package/src/session-runtime/effort.mjs +6 -2
  94. package/src/session-runtime/model-recency.mjs +5 -2
  95. package/src/session-runtime/provider-models.mjs +3 -3
  96. package/src/session-runtime/runtime-core.mjs +78 -10
  97. package/src/session-runtime/tool-catalog.mjs +34 -0
  98. package/src/session-runtime/warmup-schedulers.mjs +7 -1
  99. package/src/standalone/agent-tool/notify.mjs +13 -0
  100. package/src/standalone/agent-tool.mjs +45 -69
  101. package/src/standalone/explore-tool.mjs +6 -7
  102. package/src/tui/App.jsx +31 -0
  103. package/src/tui/app/model-options.mjs +5 -3
  104. package/src/tui/app/model-picker.mjs +12 -24
  105. package/src/tui/app/transcript-window.mjs +1 -0
  106. package/src/tui/components/ToolExecution.jsx +11 -6
  107. package/src/tui/components/TranscriptItem.jsx +1 -1
  108. package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
  109. package/src/tui/components/tool-execution/text-format.mjs +10 -19
  110. package/src/tui/dist/index.mjs +533 -143
  111. package/src/tui/engine/agent-job-feed.mjs +153 -16
  112. package/src/tui/engine/agent-response-tail.mjs +68 -0
  113. package/src/tui/engine/notification-plan.mjs +16 -0
  114. package/src/tui/engine/queue-helpers.mjs +8 -0
  115. package/src/tui/engine/session-api.mjs +8 -2
  116. package/src/tui/engine/session-flow.mjs +34 -2
  117. package/src/tui/engine/tool-card-results.mjs +54 -32
  118. package/src/tui/engine/tool-result-status.mjs +75 -21
  119. package/src/tui/engine/turn.mjs +83 -43
  120. package/src/tui/engine.mjs +63 -2
  121. package/src/workflows/bench/WORKFLOW.md +25 -35
  122. package/src/workflows/default/WORKFLOW.md +38 -32
  123. package/src/workflows/solo/WORKFLOW.md +19 -22
  124. package/scripts/_jitter-fuzz.mjs +0 -44410
  125. package/scripts/_jitter-fuzz2.mjs +0 -44400
  126. package/scripts/_jitter-probe.mjs +0 -44397
  127. package/scripts/_jp2.mjs +0 -45614
@@ -21,9 +21,15 @@ import {
21
21
  import { toolErrorDisplay } from './tool-result-text.mjs';
22
22
  import {
23
23
  notificationQueueKey,
24
+ executionCardKey,
24
25
  resolveTuiRuntimeNotificationDelivery,
25
26
  } from './notification-plan.mjs';
27
+ import { shortTextFingerprint } from './queue-helpers.mjs';
26
28
  import { readImageAttachmentFromPath } from '../paste-attachments.mjs';
29
+ import {
30
+ isDeliveredCompletion,
31
+ recordDeliveredCompletion,
32
+ } from '../../runtime/agent/orchestrator/session/manager/delivered-completions.mjs';
27
33
 
28
34
  // Channel inbound images arrive as a JSON-array-of-paths meta value (stringified
29
35
  // across the notify IPC boundary). Parse defensively; a malformed value simply
@@ -48,13 +54,26 @@ export function createAgentJobFeed({
48
54
  enqueue,
49
55
  drain,
50
56
  pushUserOrSyntheticItem,
57
+ pushAsyncAgentResponse,
51
58
  makeQueueEntry,
52
59
  getPending,
53
60
  agentStatusState,
54
61
  displayedExecutionNotificationKeys,
55
62
  pushNotice,
63
+ now = () => Date.now(),
64
+ executionResumeTombstoneTtlMs = 30_000,
65
+ executionResumeTombstoneLimit = 128,
56
66
  }) {
57
67
  let executionResumeKickDeferred = false;
68
+ // Completion keys explicitly abandoned by Esc. Tombstones are per-feed
69
+ // (therefore per TUI session), short-lived, and bounded: they catch a late
70
+ // duplicate racing the abort without permanently reserving execution IDs or
71
+ // retaining completion bodies.
72
+ const discardedExecutionResumeKeys = new Map();
73
+ // Tracks whether an execution's visible response is only a bodyless preview
74
+ // or has reached its final body. A preview must not permanently suppress the
75
+ // later body, while repeated body retries remain idempotent.
76
+ const displayedExecutionResponseStates = new Map();
58
77
 
59
78
  // FIFO accumulation of model-visible bodies from completions that arrived
60
79
  // while busy (or while a pending-resume entry was already queued). A single
@@ -62,8 +81,49 @@ export function createAgentJobFeed({
62
81
  // the queue preserves every body and merges them into the resume turn.
63
82
  const executionResumeKickBodies = [];
64
83
 
65
- function kickExecutionPendingResume(body = '') {
66
- if (body) executionResumeKickBodies.push(body);
84
+ function executionResumeKey(body, completionKey = '') {
85
+ if (completionKey && typeof completionKey === 'object') {
86
+ completionKey = completionKey.executionId || completionKey.key || '';
87
+ }
88
+ const explicitKey = String(completionKey || '').trim();
89
+ if (explicitKey.startsWith('execution:') || explicitKey.startsWith('body:')) return explicitKey;
90
+ if (explicitKey) return `execution:${explicitKey}`;
91
+ const value = String(body || '').trim();
92
+ return value ? `body:${shortTextFingerprint(value)}` : '';
93
+ }
94
+
95
+ function pruneDiscardedExecutionResumeKeys() {
96
+ const nowMs = Number(now()) || Date.now();
97
+ for (const [key, expiresAt] of discardedExecutionResumeKeys) {
98
+ if (expiresAt <= nowMs) discardedExecutionResumeKeys.delete(key);
99
+ }
100
+ }
101
+
102
+ function isDiscardedExecutionResumeKey(key) {
103
+ if (!key) return false;
104
+ pruneDiscardedExecutionResumeKeys();
105
+ return discardedExecutionResumeKeys.has(key);
106
+ }
107
+
108
+ function rememberDiscardedExecutionResumeKey(key) {
109
+ if (!key) return;
110
+ pruneDiscardedExecutionResumeKeys();
111
+ const limit = Math.max(1, Number(executionResumeTombstoneLimit) || 128);
112
+ while (!discardedExecutionResumeKeys.has(key) && discardedExecutionResumeKeys.size >= limit) {
113
+ const oldest = discardedExecutionResumeKeys.keys().next().value;
114
+ if (oldest == null) break;
115
+ discardedExecutionResumeKeys.delete(oldest);
116
+ }
117
+ const ttlMs = Math.max(1, Number(executionResumeTombstoneTtlMs) || 30_000);
118
+ // Refresh insertion order so the bounded map evicts the oldest tombstone.
119
+ discardedExecutionResumeKeys.delete(key);
120
+ discardedExecutionResumeKeys.set(key, (Number(now()) || Date.now()) + ttlMs);
121
+ }
122
+
123
+ function kickExecutionPendingResume(body = '', completionKey = '') {
124
+ const key = executionResumeKey(body, completionKey);
125
+ if (body && isDiscardedExecutionResumeKey(key)) return;
126
+ if (body) executionResumeKickBodies.push({ body, key });
67
127
  if (getDisposed()) return;
68
128
  if (getState().busy) {
69
129
  executionResumeKickDeferred = true;
@@ -77,8 +137,15 @@ export function createAgentJobFeed({
77
137
  executionResumeKickDeferred = false;
78
138
  // Drain every accumulated body into ONE resume turn so no completion body
79
139
  // is lost when several deferred while busy.
80
- const resumeBody = executionResumeKickBodies.splice(0).filter(Boolean).join('\n\n');
81
- pending.push(makeQueueEntry(resumeBody, { mode: 'pending-resume', priority: 'next' }));
140
+ const resumeBodies = executionResumeKickBodies.splice(0);
141
+ const resumeBody = resumeBodies.map(({ body: value }) => value).filter(Boolean).join('\n\n');
142
+ const resumeCompletionKeys = resumeBodies.map(({ key }) => key).filter(Boolean);
143
+ pending.push(makeQueueEntry(resumeBody, {
144
+ mode: 'pending-resume',
145
+ priority: 'next',
146
+ abortDiscardOnAbort: true,
147
+ resumeCompletionKeys,
148
+ }));
82
149
  void drain();
83
150
  }
84
151
 
@@ -87,11 +154,25 @@ export function createAgentJobFeed({
87
154
  kickExecutionPendingResume();
88
155
  }
89
156
 
90
- function scheduleExecutionPendingResumeKick(body = '') {
157
+ function scheduleExecutionPendingResumeKick(body = '', completionKey = '') {
91
158
  // Carry the model-visible body directly into the pending-resume entry so
92
159
  // the resumed turn sends it, instead of relying on the session-pending
93
160
  // completion marker (dropped by askSession pre-drain).
94
- queueMicrotask(() => kickExecutionPendingResume(body));
161
+ queueMicrotask(() => kickExecutionPendingResume(body, completionKey));
162
+ }
163
+
164
+ function discardExecutionPendingResume(completionKeys = []) {
165
+ const keys = (Array.isArray(completionKeys) ? completionKeys : [completionKeys])
166
+ .map((key) => executionResumeKey('', key))
167
+ .filter(Boolean);
168
+ if (keys.length === 0) return;
169
+ for (const key of keys) rememberDiscardedExecutionResumeKey(key);
170
+ for (let i = executionResumeKickBodies.length - 1; i >= 0; i -= 1) {
171
+ if (isDiscardedExecutionResumeKey(executionResumeKickBodies[i].key)) {
172
+ executionResumeKickBodies.splice(i, 1);
173
+ }
174
+ }
175
+ executionResumeKickDeferred = executionResumeKickBodies.length > 0;
95
176
  }
96
177
 
97
178
  // Pure builder for the agent-job card patch. Split out so callers that are
@@ -117,6 +198,13 @@ export function createAgentJobFeed({
117
198
  patchItem(itemId, buildAgentJobCardPatch(itemId, text, isError));
118
199
  }
119
200
 
201
+ function refreshAgentStatus(parsed) {
202
+ if (!parsed?.taskId) return;
203
+ const status = String(parsed.status || '').toLowerCase();
204
+ const terminal = /^(completed|complete|done|success|succeeded|ok|failed|error|timeout|killed|cancelled|canceled|denied)$/.test(status);
205
+ set(agentStatusState(terminal ? { force: true } : undefined));
206
+ }
207
+
120
208
  function subscribeRuntimeNotifications() {
121
209
  if (typeof runtime.onNotification !== 'function') return null;
122
210
  return runtime.onNotification((event) => {
@@ -132,33 +220,81 @@ export function createAgentJobFeed({
132
220
  return true;
133
221
  }
134
222
  if (delivery.action === 'status-only') {
135
- if (parsed?.taskId) set(agentStatusState({ force: true }));
223
+ refreshAgentStatus(parsed);
136
224
  return true;
137
225
  }
138
226
  if (delivery.action === 'execution-ui') {
139
- const firstDelivery = !notificationKey || !displayedExecutionNotificationKeys.has(notificationKey);
140
- if (firstDelivery) {
141
- if (notificationKey) displayedExecutionNotificationKeys.add(notificationKey);
142
- pushUserOrSyntheticItem(delivery.displayText, nextId());
227
+ const cardKey = executionCardKey(event, text, parsed);
228
+ const firstDelivery = !cardKey || !displayedExecutionNotificationKeys.has(cardKey);
229
+ const executionId = String(event?.meta?.execution_id || parsed?.taskId || '').trim();
230
+ const status = String(event?.meta?.status || parsed?.status || '').toLowerCase();
231
+ const hasBody = /\n\s*\n[\s\S]*\S/.test(text);
232
+ const isFailure = /^(failed|error|timeout|killed|cancelled|canceled|denied)$/.test(status);
233
+ const successfulPreview = !hasBody && !isFailure && /^(completed|complete|done|success|succeeded|ok)$/.test(status);
234
+ const responseState = executionId ? displayedExecutionResponseStates.get(executionId) : '';
235
+ const bodyAlreadyDisplayed = responseState === 'body';
236
+ if (firstDelivery && !successfulPreview && !bodyAlreadyDisplayed) {
237
+ if (cardKey) displayedExecutionNotificationKeys.add(cardKey);
238
+ if (executionId) displayedExecutionResponseStates.set(executionId, hasBody ? 'body' : 'preview');
239
+ // Execution completions are inbound agent responses. The engine keeps
240
+ // their aggregation tail-safe (only an immediately-adjacent inbound
241
+ // response of the same preview/body phase can merge); the fallback
242
+ // preserves the standalone path for minimal/test harnesses.
243
+ (pushAsyncAgentResponse || pushUserOrSyntheticItem)(delivery.displayText, nextId(), 'injected', { responseKey: executionId });
143
244
  }
144
- if (parsed?.taskId) set(agentStatusState({ force: true }));
245
+ refreshAgentStatus(parsed);
145
246
  const resumeBody = String(delivery.modelContent || '').trim();
146
247
  if (resumeBody) {
147
- enqueue(resumeBody, {
248
+ const completionKey = executionResumeKey(resumeBody, executionId);
249
+ // Consolidated completion dedup keyed off execution_id (+ text hash):
250
+ // if this exact execution completion was already delivered — either by
251
+ // an earlier TUI enqueue here or by runtime-core's ack — do NOT enqueue
252
+ // a duplicate model-visible twin. A re-arriving completion while IDLE
253
+ // would otherwise re-enqueue and let post-turn drain() spawn a fresh
254
+ // turn. Still ack (modelVisibleDelivered) so runtime-core's
255
+ // mirror/fallback stays suppressed; the card first-delivery push and
256
+ // status refresh above already ran.
257
+ if (isDiscardedExecutionResumeKey(completionKey) || isDeliveredCompletion({ executionId, text: resumeBody })) {
258
+ if (event && typeof event === 'object') event.modelVisibleDelivered = true;
259
+ return true;
260
+ }
261
+ const enqueued = enqueue(resumeBody, {
148
262
  mode: 'task-notification',
149
263
  // Claude Code parity: live execution completions are queued as
150
264
  // task notifications so the active loop can attach them after the
151
265
  // next tool batch; no special pending-resume bypass.
152
266
  priority: 'next',
153
267
  key: notificationKey || undefined,
268
+ abortDiscardOnAbort: true,
269
+ resumeCompletionKeys: completionKey ? [completionKey] : [],
154
270
  displayText: delivery.displayText || text,
271
+ // The immediate Response card was already pushed above
272
+ // (pushUserOrSyntheticItem). Keep this queued twin model-visible
273
+ // but suppress its drain-time transcript card to avoid a duplicate.
274
+ suppressDisplay: true,
155
275
  });
276
+ // Self-sufficient TUI dedup: mark delivered right here (mark-once at
277
+ // delivery, keyed by execution_id) so a re-arriving completion is
278
+ // skipped above without depending solely on the runtime-core ack
279
+ // roundtrip — removes the split-brain asymmetry. Gated on a CONFIRMED
280
+ // enqueue: enqueue() returns false only when an identical-key
281
+ // task-notification twin is already queued (session-flow.mjs
282
+ // pendingNotificationKeys.has(key)), in which case the completion is
283
+ // already pending delivery and must not be double-recorded.
284
+ if (enqueued) recordDeliveredCompletion({ executionId, text: resumeBody });
285
+ // EXPLICIT ack to the emitting runtime: the model-visible completion
286
+ // body was injected into the active loop here, so notifyFnForSession
287
+ // must NOT also mirror it into the pending queue (double injection).
288
+ // A bare truthy return below is display/status handling only — this
289
+ // flag is the sole model-visible-delivery signal. Set for BOTH the
290
+ // real-enqueue and the enqueue===false (already-queued twin) cases:
291
+ // either way the completion is pending delivery on the TUI path, so
292
+ // runtime-core must still not mirror it.
293
+ if (event && typeof event === 'object') event.modelVisibleDelivered = true;
156
294
  }
157
295
  return true;
158
296
  }
159
- if (parsed?.taskId) {
160
- set(agentStatusState({ force: true }));
161
- }
297
+ refreshAgentStatus(parsed);
162
298
  const modelContent = String(delivery.modelContent ?? delivery.displayText ?? text).trim();
163
299
  const imagePaths = parseInboundImagePaths(event?.meta?.image_paths);
164
300
  if (!modelContent && imagePaths.length === 0) return true;
@@ -202,6 +338,7 @@ export function createAgentJobFeed({
202
338
  kickExecutionPendingResume,
203
339
  flushDeferredExecutionPendingResumeKick,
204
340
  scheduleExecutionPendingResumeKick,
341
+ discardExecutionPendingResume,
205
342
  updateAgentJobCard,
206
343
  buildAgentJobCardPatch,
207
344
  subscribeRuntimeNotifications,
@@ -0,0 +1,68 @@
1
+ // Pure, tail-only inbound-agent response aggregation. The caller decides
2
+ // whether the transcript tail is eligible; this helper never searches older
3
+ // rows, so a retry cannot mutate across a real transcript boundary.
4
+ function responseEntry(response = {}) {
5
+ return {
6
+ key: String(response.key || ''),
7
+ raw: String(response.rawResult ?? response.raw ?? '').trim(),
8
+ result: response.result,
9
+ hasBody: response.hasBody === true,
10
+ isError: response.isError === true,
11
+ };
12
+ }
13
+
14
+ export function formatAgentResponseRaw(entries = []) {
15
+ return entries
16
+ .map((entry, index) => {
17
+ const raw = String(entry?.raw || '').trim();
18
+ return raw ? `${index + 1}. agent\n${raw}` : '';
19
+ })
20
+ .filter(Boolean)
21
+ .join('\n\n');
22
+ }
23
+
24
+ function priorEntries(previous) {
25
+ if (Array.isArray(previous?.agentResponseEntries) && previous.agentResponseEntries.length) {
26
+ return previous.agentResponseEntries.map(responseEntry);
27
+ }
28
+ return [responseEntry({
29
+ key: previous?.agentResponseKey,
30
+ rawResult: previous?.rawResult ?? previous?.result,
31
+ result: previous?.result,
32
+ hasBody: previous?.agentResponseHasBody,
33
+ isError: previous?.isError,
34
+ })];
35
+ }
36
+
37
+ export function appendAgentResponseTail(previous, response, now = Date.now()) {
38
+ if (previous?.kind !== 'tool' || previous.agentDirection !== 'inbound') return null;
39
+ const next = responseEntry(response);
40
+ const entries = priorEntries(previous);
41
+ const existingIndex = next.key ? entries.findIndex((entry) => entry.key === next.key) : -1;
42
+
43
+ if (existingIndex >= 0) {
44
+ // A preview/final/retry for the same execution is one response. Replace
45
+ // that entry in-place only because this aggregate is still the transcript
46
+ // tail; never turn it into another count or a nested raw block.
47
+ entries[existingIndex] = next;
48
+ } else {
49
+ // Distinct responses can merge only when their presentation phase matches.
50
+ // A visible bodyless failure stays a separate row from a later full body.
51
+ if (previous.agentResponseHasBody !== next.hasBody) return null;
52
+ entries.push(next);
53
+ }
54
+
55
+ return {
56
+ args: response.args,
57
+ result: response.result,
58
+ rawResult: formatAgentResponseRaw(entries) || null,
59
+ isError: entries.some((entry) => entry.isError),
60
+ count: entries.length,
61
+ completedCount: entries.length,
62
+ completedAt: now,
63
+ agentResponseEntries: entries,
64
+ agentResponseKeys: entries.map((entry) => entry.key).filter(Boolean),
65
+ agentResponseHasBody: entries.every((entry) => entry.hasBody),
66
+ agentResponseAggregate: entries.length > 1,
67
+ };
68
+ }
@@ -47,6 +47,22 @@ export function notificationQueueKey(event, text, parsed) {
47
47
  return [id, type || fallbackKind, status, hasBody].filter(Boolean).join(':');
48
48
  }
49
49
 
50
+ /**
51
+ * Stable dedup key for the execution transcript CARD. Unlike notificationQueueKey
52
+ * it drops the volatile type/status dimensions so a duplicate completion re-arriving
53
+ * with a different type/status (same execution_id) cannot push a second card. The
54
+ * hasBody dimension is preserved so a bodyless preview (b0) and the real result (b1)
55
+ * remain distinct cards (intentional preview→result upgrade). Falls back to
56
+ * notificationQueueKey when no execution_id is present.
57
+ */
58
+ export function executionCardKey(event, text, parsed) {
59
+ const meta = event?.meta && typeof event.meta === 'object' ? event.meta : {};
60
+ const executionId = String(meta.execution_id || '').trim();
61
+ if (!executionId) return notificationQueueKey(event, text, parsed);
62
+ const hasBody = /\n\s*\n[\s\S]*\S/.test(String(text || '')) ? 'b1' : 'b0';
63
+ return `card:${executionId}:${hasBody}`;
64
+ }
65
+
50
66
  export function isExecutionNotification(event, text, parsed) {
51
67
  const meta = event?.meta && typeof event.meta === 'object' ? event.meta : {};
52
68
  if (meta.execution_id || meta.execution_surface) return true;
@@ -12,6 +12,14 @@ import {
12
12
 
13
13
  const QUEUE_PRIORITY = { now: 0, next: 1, later: 2 };
14
14
 
15
+ // Sentinel display text for a steering entry that is model-visible only: its
16
+ // immediate transcript ("Response …") card was already pushed at delivery time
17
+ // (live execution completion), so the mid-turn steering drain must NOT render a
18
+ // second card for it. drainPendingSteering emits this as the entry's display
19
+ // text; turn.mjs's onSteerMessage recognizes it and skips the push while the
20
+ // model still receives the entry's content.
21
+ export const STEERING_SUPPRESSED_DISPLAY = '\u0000mixdog:suppress-steer-display\u0000';
22
+
15
23
  export function queuePriorityValue(value) {
16
24
  return QUEUE_PRIORITY[String(value || 'next')] ?? QUEUE_PRIORITY.next;
17
25
  }
@@ -24,7 +24,7 @@ export function createEngineApi(bag) {
24
24
 
25
25
  export function createEngineApiA(bag) {
26
26
  const {
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,
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, discardExecutionPendingResume,
28
28
  } = bag;
29
29
  return {
30
30
  getState: () => getState(),
@@ -573,10 +573,15 @@ export function createEngineApiA(bag) {
573
573
  ? restoreState.pastedTexts
574
574
  : null;
575
575
  const requeueEntries = restoreState && !restoreState.committed && Array.isArray(restoreState.requeueEntries)
576
- ? restoreState.requeueEntries.slice()
576
+ ? restoreState.requeueEntries.filter(
577
+ (entry) => entry?.abortDiscardOnAbort !== true && entry?.mode !== 'pending-resume',
578
+ )
577
579
  : [];
578
580
  const aborted = runtime.abort('cli-react-abort');
579
581
  if (restoreState) {
582
+ if (aborted !== false && Array.isArray(restoreState.discardExecutionPendingResumeKeys)) {
583
+ discardExecutionPendingResume?.(restoreState.discardExecutionPendingResumeKeys);
584
+ }
580
585
  if ((restoreText || requeueEntries.length > 0) && aborted !== false) {
581
586
  restoreState.reclaimed = true;
582
587
  const idSet = new Set((restoreState.submittedIds || []).filter((id) => id != null));
@@ -592,6 +597,7 @@ export function createEngineApiA(bag) {
592
597
  }
593
598
  restoreState.restorable = false;
594
599
  restoreState.requeueEntries = [];
600
+ restoreState.discardExecutionPendingResumeKeys = [];
595
601
  }
596
602
  // ── Bounded manual-abort recovery ───────────────────────────────────
597
603
  // runtime.abort() above normally rejects the in-flight runtime.ask() so
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { presentErrorText } from '../../runtime/shared/err-text.mjs';
5
5
  import { createSessionStats } from './session-stats.mjs';
6
- import { queuePriorityValue, defaultQueuePriority, isQueuedEntryEditable, isQueuedEntryVisible, isSlashQueuedEntry, notificationDisplayText, sessionActivityTimestamp, promptDisplayText, mergePromptContents, mergePastedImages, mergePastedTexts, callCommitCallbacks } from './queue-helpers.mjs';
6
+ import { queuePriorityValue, defaultQueuePriority, isQueuedEntryEditable, isQueuedEntryVisible, isSlashQueuedEntry, notificationDisplayText, sessionActivityTimestamp, promptDisplayText, mergePromptContents, mergePastedImages, mergePastedTexts, callCommitCallbacks, STEERING_SUPPRESSED_DISPLAY } from './queue-helpers.mjs';
7
7
  import { appendTuiSteeringPersist, dropTuiSteeringPersist, drainTuiSteeringPersist } from './tui-steering-persist.mjs';
8
8
 
9
9
  export function createSessionFlow(bag) {
@@ -53,6 +53,13 @@ export function createSessionFlow(bag) {
53
53
  key: options.key || null,
54
54
  skipSlashCommands: options.skipSlashCommands === true,
55
55
  displayText: mode === 'task-notification' ? notificationDisplayText(displayText) : String(displayText || ''),
56
+ suppressDisplay: options.suppressDisplay === true,
57
+ // Completion resumes are consumed exactly once: Esc abandons their
58
+ // uncommitted body instead of putting it back at the queue front.
59
+ abortDiscardOnAbort: options.abortDiscardOnAbort === true,
60
+ resumeCompletionKeys: Array.isArray(options.resumeCompletionKeys)
61
+ ? options.resumeCompletionKeys.filter((key) => key != null && String(key).trim())
62
+ : [],
56
63
  steeringPersistId: options.steeringPersistId || null,
57
64
  steeringPersistRestored: options.steeringPersistRestored === true,
58
65
  };
@@ -186,9 +193,24 @@ export function createSessionFlow(bag) {
186
193
  const merged = mergePromptContents(batch);
187
194
  for (const entry of batch) {
188
195
  if (entry.mode === 'pending-resume') continue;
196
+ // Live execution completions push their own immediate Response card
197
+ // at delivery time; the queued twin is model-visible only and must
198
+ // NOT render a second transcript card here (no fall-back to content).
199
+ if (entry.suppressDisplay) continue;
189
200
  pushUserOrSyntheticItem(entry.text, entry.id, isQueuedEntryEditable(entry) ? 'user' : 'injected');
190
201
  }
191
202
  const nonEditable = batch.filter((entry) => !isQueuedEntryEditable(entry));
203
+ // A completion resume is owned by the completion that woke it. Esc
204
+ // consumes that ownership; unlike ordinary notifications it must never
205
+ // be requeued from an uncommitted turn. Keep normal task notifications
206
+ // recoverable exactly as before.
207
+ const discardOnAbort = nonEditable.filter(
208
+ (entry) => entry?.abortDiscardOnAbort === true || entry?.mode === 'pending-resume',
209
+ );
210
+ const requeueOnAbort = nonEditable.filter((entry) => !discardOnAbort.includes(entry));
211
+ const discardExecutionPendingResumeKeys = discardOnAbort.flatMap(
212
+ (entry) => Array.isArray(entry?.resumeCompletionKeys) ? entry.resumeCompletionKeys : [],
213
+ );
192
214
  const batchPastedImages = mergePastedImages(batch);
193
215
  const batchPastedTexts = mergePastedTexts(batch);
194
216
  const turnStatus = await bag.runTurn(merged, {
@@ -198,7 +220,8 @@ export function createSessionFlow(bag) {
198
220
  onCommitted: () => commitSteeringQueueEntries(batch),
199
221
  submittedIds: [...ids],
200
222
  restorable: nonEditable.length === 0,
201
- requeueOnAbort: nonEditable,
223
+ requeueOnAbort,
224
+ discardExecutionPendingResumeKeys,
202
225
  });
203
226
  if (flags.drainEpoch !== drainEpoch) return;
204
227
  // A deferred cleared-session UI sync (from a late-settling abandoned
@@ -274,6 +297,15 @@ export function createSessionFlow(bag) {
274
297
  if (batch.length === 0) break;
275
298
  for (const entry of batch) {
276
299
  const content = entry.content;
300
+ if (entry.suppressDisplay) {
301
+ // Model-visible twin of an already-rendered live completion: deliver
302
+ // content to the model but flag onSteerMessage to skip the duplicate
303
+ // transcript card (no fall-back to content-derived display text).
304
+ if (Array.isArray(content) ? content.length > 0 : String(content ?? '').trim().length > 0) {
305
+ out.push({ text: STEERING_SUPPRESSED_DISPLAY, content });
306
+ }
307
+ continue;
308
+ }
277
309
  const value = typeof content === 'string'
278
310
  ? content.trim()
279
311
  : { text: String(entry.text || '').trim(), content };