mixdog 0.9.38 → 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 (114) 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/execution-completion-dedup-test.mjs +157 -0
  8. package/scripts/execution-pending-resume-kick-test.mjs +57 -2
  9. package/scripts/execution-resume-esc-integration-test.mjs +174 -0
  10. package/scripts/explore-bench.mjs +38 -2
  11. package/scripts/explore-prompt-policy-test.mjs +152 -11
  12. package/scripts/find-fuzzy-hidden-test.mjs +122 -0
  13. package/scripts/internal-comms-bench-test.mjs +226 -0
  14. package/scripts/internal-comms-bench.mjs +185 -58
  15. package/scripts/internal-comms-smoke.mjs +171 -23
  16. package/scripts/live-worker-smoke.mjs +38 -2
  17. package/scripts/memory-cycle-routing-test.mjs +111 -0
  18. package/scripts/memory-rule-contract-test.mjs +93 -0
  19. package/scripts/output-style-smoke.mjs +2 -2
  20. package/scripts/rg-runner-test.mjs +240 -0
  21. package/scripts/routing-corpus-test.mjs +349 -0
  22. package/scripts/routing-corpus.mjs +211 -32
  23. package/scripts/session-orphan-sweep-test.mjs +83 -0
  24. package/scripts/steering-drain-buckets-test.mjs +179 -0
  25. package/scripts/tool-smoke.mjs +21 -13
  26. package/scripts/tool-tui-presentation-test.mjs +202 -0
  27. package/src/agents/heavy-worker/AGENT.md +10 -7
  28. package/src/agents/reviewer/AGENT.md +6 -4
  29. package/src/agents/worker/AGENT.md +7 -5
  30. package/src/rules/agent/00-common.md +4 -4
  31. package/src/rules/agent/00-core.md +11 -14
  32. package/src/rules/agent/20-skip-protocol.md +3 -3
  33. package/src/rules/agent/30-explorer.md +50 -60
  34. package/src/rules/agent/40-cycle1-agent.md +15 -24
  35. package/src/rules/agent/41-cycle2-agent.md +33 -57
  36. package/src/rules/agent/42-cycle3-agent.md +28 -42
  37. package/src/rules/lead/01-general.md +7 -10
  38. package/src/rules/lead/lead-brief.md +11 -14
  39. package/src/rules/lead/lead-tool.md +6 -5
  40. package/src/rules/shared/01-tool.md +44 -45
  41. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +37 -1
  42. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
  43. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
  44. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
  45. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
  46. package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
  47. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
  48. package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
  49. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
  50. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +41 -2
  51. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/session/store.mjs +223 -42
  53. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
  54. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
  55. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
  56. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
  57. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
  58. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
  62. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
  63. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
  65. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
  66. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
  67. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
  68. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
  69. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
  71. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
  72. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
  73. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
  74. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  75. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
  76. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
  77. package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
  78. package/src/runtime/shared/tool-primitives.mjs +4 -1
  79. package/src/runtime/shared/tool-status.mjs +27 -0
  80. package/src/runtime/shared/tool-surface.mjs +6 -3
  81. package/src/session-runtime/config-helpers.mjs +14 -0
  82. package/src/session-runtime/context-status.mjs +1 -0
  83. package/src/session-runtime/effort.mjs +6 -2
  84. package/src/session-runtime/model-recency.mjs +5 -2
  85. package/src/session-runtime/runtime-core.mjs +35 -2
  86. package/src/session-runtime/tool-catalog.mjs +34 -0
  87. package/src/standalone/agent-tool/notify.mjs +13 -0
  88. package/src/standalone/agent-tool.mjs +45 -69
  89. package/src/standalone/explore-tool.mjs +6 -7
  90. package/src/tui/App.jsx +31 -0
  91. package/src/tui/app/model-options.mjs +5 -3
  92. package/src/tui/app/model-picker.mjs +12 -24
  93. package/src/tui/app/transcript-window.mjs +1 -0
  94. package/src/tui/components/ToolExecution.jsx +11 -6
  95. package/src/tui/components/TranscriptItem.jsx +1 -1
  96. package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
  97. package/src/tui/components/tool-execution/text-format.mjs +10 -19
  98. package/src/tui/dist/index.mjs +517 -142
  99. package/src/tui/engine/agent-job-feed.mjs +144 -17
  100. package/src/tui/engine/agent-response-tail.mjs +68 -0
  101. package/src/tui/engine/notification-plan.mjs +16 -0
  102. package/src/tui/engine/session-api.mjs +8 -2
  103. package/src/tui/engine/session-flow.mjs +19 -1
  104. package/src/tui/engine/tool-card-results.mjs +54 -32
  105. package/src/tui/engine/tool-result-status.mjs +75 -21
  106. package/src/tui/engine/turn.mjs +77 -42
  107. package/src/tui/engine.mjs +63 -2
  108. package/src/workflows/bench/WORKFLOW.md +25 -35
  109. package/src/workflows/default/WORKFLOW.md +38 -32
  110. package/src/workflows/solo/WORKFLOW.md +19 -22
  111. package/scripts/_jitter-fuzz.mjs +0 -44410
  112. package/scripts/_jitter-fuzz2.mjs +0 -44400
  113. package/scripts/_jitter-probe.mjs +0 -44397
  114. 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,43 +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,
155
271
  // The immediate Response card was already pushed above
156
272
  // (pushUserOrSyntheticItem). Keep this queued twin model-visible
157
273
  // but suppress its drain-time transcript card to avoid a duplicate.
158
274
  suppressDisplay: true,
159
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 });
160
285
  // EXPLICIT ack to the emitting runtime: the model-visible completion
161
286
  // body was injected into the active loop here, so notifyFnForSession
162
287
  // must NOT also mirror it into the pending queue (double injection).
163
288
  // A bare truthy return below is display/status handling only — this
164
- // flag is the sole model-visible-delivery signal.
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.
165
293
  if (event && typeof event === 'object') event.modelVisibleDelivered = true;
166
294
  }
167
295
  return true;
168
296
  }
169
- if (parsed?.taskId) {
170
- set(agentStatusState({ force: true }));
171
- }
297
+ refreshAgentStatus(parsed);
172
298
  const modelContent = String(delivery.modelContent ?? delivery.displayText ?? text).trim();
173
299
  const imagePaths = parseInboundImagePaths(event?.meta?.image_paths);
174
300
  if (!modelContent && imagePaths.length === 0) return true;
@@ -212,6 +338,7 @@ export function createAgentJobFeed({
212
338
  kickExecutionPendingResume,
213
339
  flushDeferredExecutionPendingResumeKick,
214
340
  scheduleExecutionPendingResumeKick,
341
+ discardExecutionPendingResume,
215
342
  updateAgentJobCard,
216
343
  buildAgentJobCardPatch,
217
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;
@@ -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
@@ -54,6 +54,12 @@ export function createSessionFlow(bag) {
54
54
  skipSlashCommands: options.skipSlashCommands === true,
55
55
  displayText: mode === 'task-notification' ? notificationDisplayText(displayText) : String(displayText || ''),
56
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
+ : [],
57
63
  steeringPersistId: options.steeringPersistId || null,
58
64
  steeringPersistRestored: options.steeringPersistRestored === true,
59
65
  };
@@ -194,6 +200,17 @@ export function createSessionFlow(bag) {
194
200
  pushUserOrSyntheticItem(entry.text, entry.id, isQueuedEntryEditable(entry) ? 'user' : 'injected');
195
201
  }
196
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
+ );
197
214
  const batchPastedImages = mergePastedImages(batch);
198
215
  const batchPastedTexts = mergePastedTexts(batch);
199
216
  const turnStatus = await bag.runTurn(merged, {
@@ -203,7 +220,8 @@ export function createSessionFlow(bag) {
203
220
  onCommitted: () => commitSteeringQueueEntries(batch),
204
221
  submittedIds: [...ids],
205
222
  restorable: nonEditable.length === 0,
206
- requeueOnAbort: nonEditable,
223
+ requeueOnAbort,
224
+ discardExecutionPendingResumeKeys,
207
225
  });
208
226
  if (flags.drainEpoch !== drainEpoch) return;
209
227
  // A deferred cleared-session UI sync (from a late-settling abandoned
@@ -10,17 +10,18 @@
10
10
  * the factory argument (getters/callbacks) — never stale snapshots. Every body
11
11
  * is the original engine.mjs logic verbatim.
12
12
  */
13
- import { summarizeToolResult, aggregateDoneCategories, classifyToolCategory } from '../../runtime/shared/tool-surface.mjs';
13
+ import { summarizeToolResult, aggregateDoneCategories } from '../../runtime/shared/tool-surface.mjs';
14
14
  import { toolResultText, toolErrorDisplay, toolGroupedDisplayFallback } from './tool-result-text.mjs';
15
15
  import { toolResultCallId } from './tool-call-fields.mjs';
16
- import { memoryCoreResultErrorText } from '../app/input-parsers.mjs';
17
- import { parseAgentJob, toolResultStatus, isErrorToolStatus } from './agent-envelope.mjs';
16
+ import { parseAgentJob } from './agent-envelope.mjs';
18
17
  import {
19
18
  withCancelledResultMarker,
20
19
  groupedToolResultText,
21
20
  aggregateRawResult,
22
21
  aggregateSummaries,
23
22
  assignAggregateSummaryOrder,
23
+ failureDetailText,
24
+ toolCallOutcome,
24
25
  } from './tool-result-status.mjs';
25
26
  import { formatAggregateDetail } from '../../runtime/shared/tool-surface.mjs';
26
27
  import { carryTranscriptMeasuredRowsCache } from '../app/transcript-window.mjs';
@@ -34,6 +35,17 @@ export function createToolCardResults({
34
35
  buildAgentJobCardPatch,
35
36
  agentStatusState,
36
37
  }) {
38
+ // A finalized/failed non-aggregate card must never carry an empty body:
39
+ // an empty-body error card is classified fully-failed-with-no-body upstream
40
+ // (transcript-tool-failures) and null-renders (card disappears). Stamp a
41
+ // minimal non-empty fallback, preferring meaningful text, then Exit N, then
42
+ // a bare Failed status.
43
+ function finalizedErrorFallbackBody(body, text, exitCode) {
44
+ if (String(body || '').trim()) return body;
45
+ if (String(text || '').trim()) return text;
46
+ if (exitCode != null) return `Exit ${exitCode}`;
47
+ return 'Failed';
48
+ }
37
49
  function patchToolItem(id, patch) {
38
50
  const prev = getState().items.find((it) => it.id === id);
39
51
  const ok = patchItem(id, patch);
@@ -60,22 +72,11 @@ export function createToolCardResults({
60
72
  // Aggregate card handling — collect semantic summaries per call
61
73
  const aggregate = card.aggregate;
62
74
  const callRec = aggregate && callId ? aggregate.calls.get(callId) : null;
63
- // Backend "core" memory-op failures are flattened to plain text (isError
64
- // dropped upstream see memoryCoreResultErrorText); recover that signal so
65
- // failed memory writes are excluded from the done count. Gated to Memory
66
- // calls: the text-matcher's ^(error|failed) catch-all would otherwise
67
- // misflag legitimate non-memory success output.
68
- const isMemoryCall = classifyToolCategory(callRec?.name || card?.name || '', callRec?.args || {}) === 'Memory';
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;
75
+ // Only a provider-marked invocation failure contributes to failure count,
76
+ // red state, or Failed aggregate copy. Tool-reported HTTP/domain/status
77
+ // outcomes remain successful calls with their raw/semantic result detail.
78
+ const { exitCode, isExitError, isCallError } = toolCallOutcome(message, rawText);
79
+ const isError = isCallError;
79
80
  const text = isError ? toolErrorDisplay(rawText, card?.name || 'tool') : rawText;
80
81
 
81
82
  if (aggregate && card.itemId === aggregate.itemId) {
@@ -89,19 +90,22 @@ export function createToolCardResults({
89
90
  assignAggregateSummaryOrder(aggregate, callRec);
90
91
  callRec.isError = isError;
91
92
  callRec.isCallError = isCallError;
93
+ callRec.isExitError = isExitError;
94
+ callRec.exitCode = exitCode;
92
95
  callRec.resultText = text;
93
96
  callRec.resolved = true;
94
97
  const allCalls = [...aggregate.calls.values()];
95
98
  const completed = allCalls.filter((r) => r.resolved).length;
96
99
  const errors = allCalls.filter((r) => r.isError).length;
97
100
  const callErrors = allCalls.filter((r) => r.isCallError).length;
101
+ const exitErrors = allCalls.filter((r) => r.isExitError).length;
98
102
  // Collapsed detail carries the merged per-call count summary
99
103
  // ("512 lines, 6 matches, 3 files") so the finished card answers "how
100
104
  // much" without ctrl+o. Failures keep a bare 'N Ok · N Failed' status so
101
105
  // an error stays visible while collapsed.
102
- const succeeded = completed - errors;
103
- const detailText = errors > 0
104
- ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
106
+ const succeeded = Math.max(0, completed - errors - exitErrors);
107
+ const detailText = errors > 0 || exitErrors > 0
108
+ ? failureDetailText({ succeeded, realErrors: callErrors, exitErrors, exitCode: allCalls.find((r) => r.isExitError)?.exitCode })
105
109
  : formatAggregateDetail(aggregateSummaries(aggregate));
106
110
  const currentItem = getState().items.find((it) => it.id === card.itemId);
107
111
  const earlyCompleted = allCalls.filter((r) => r.resolved || r.completedEarly).length;
@@ -116,6 +120,7 @@ export function createToolCardResults({
116
120
  isError: errors > 0,
117
121
  errorCount: errors,
118
122
  callErrorCount: callErrors,
123
+ exitErrorCount: exitErrors,
119
124
  count: allCalls.length,
120
125
  completedCount: visualCompleted,
121
126
  doneCategories: aggregateDoneCategories(allCalls),
@@ -127,20 +132,25 @@ export function createToolCardResults({
127
132
  }
128
133
 
129
134
  // Non-aggregate (legacy agent-job cards, etc.)
130
- const group = toolGroups.get(card.itemId) || { count: 1, completed: 0, errors: 0, callErrors: 0, results: [] };
135
+ const group = toolGroups.get(card.itemId) || { count: 1, completed: 0, errors: 0, callErrors: 0, exitErrors: 0, results: [] };
131
136
  group.completed = Math.min(group.count, group.completed + 1);
132
137
  group.errors += isError ? 1 : 0;
133
138
  group.callErrors = (group.callErrors || 0) + (isCallError ? 1 : 0);
134
- group.results.push({ text, isError });
139
+ group.exitErrors = (group.exitErrors || 0) + (isExitError ? 1 : 0);
140
+ group.results.push({ text, isError, isExitError, exitCode });
135
141
  toolGroups.set(card.itemId, group);
136
142
  const resultText = groupedToolResultText(group);
137
- const displayResult = toolGroupedDisplayFallback(resultText, text, rawText);
143
+ let displayResult = toolGroupedDisplayFallback(resultText, text, rawText);
144
+ if (group.errors > 0 && !String(displayResult || '').trim()) {
145
+ displayResult = finalizedErrorFallbackBody(displayResult, text, exitCode);
146
+ }
138
147
  const patch = {
139
148
  result: displayResult,
140
149
  text: displayResult,
141
150
  isError: group.errors > 0,
142
151
  errorCount: group.errors,
143
152
  callErrorCount: group.callErrors || 0,
153
+ exitErrorCount: group.exitErrors || 0,
144
154
  count: group.count,
145
155
  completedCount: group.completed,
146
156
  completedAt: Date.now(),
@@ -159,6 +169,11 @@ export function createToolCardResults({
159
169
  // The agent fields win (final display) while patch keeps the completion
160
170
  // metadata (count/completedCount/completedAt/rawResult) for expand.
161
171
  Object.assign(patch, buildAgentJobCardPatch(card.itemId, rawText, isError));
172
+ // Re-apply the empty-body guard: buildAgentJobCardPatch may overwrite the
173
+ // stamped fallback with an empty agent-job body, letting the card vanish.
174
+ if (group.errors > 0 && !String(patch.result || '').trim()) {
175
+ patch.result = patch.text = finalizedErrorFallbackBody(patch.result, text, exitCode);
176
+ }
162
177
  }
163
178
  patchToolItem(card.itemId, patch);
164
179
  card.done = true;
@@ -223,12 +238,14 @@ export function createToolCardResults({
223
238
  const totalCompleted = completed;
224
239
  const errors = allCalls.filter((r) => r.isError).length;
225
240
  const callErrors = allCalls.filter((r) => r.isCallError).length;
226
- const succeeded = completed - errors;
241
+ const exitErrors = allCalls.filter((r) => r.isExitError).length;
242
+ const succeeded = Math.max(0, completed - errors - exitErrors);
227
243
  const rawResult = aggregateRawResult(allCalls);
228
- // Collapsed detail carries the merged per-call count summary; failures
229
- // keep a bare 'N Ok · N Failed' status. Raw is kept for ctrl+o.
230
- let displayDetail = errors > 0
231
- ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
244
+ // Collapsed detail carries the merged per-call count summary; real
245
+ // failures keep 'N Failed', shell command-exits show 'Exit N'/'Y Exit'.
246
+ // Raw is kept for ctrl+o.
247
+ let displayDetail = errors > 0 || exitErrors > 0
248
+ ? failureDetailText({ succeeded, realErrors: callErrors, exitErrors, exitCode: allCalls.find((r) => r.isExitError)?.exitCode })
232
249
  : formatAggregateDetail(aggregateSummaries(aggregate));
233
250
  if (cancelled) {
234
251
  // Cancelled aggregates MUST keep the [status: cancelled] marker on the
@@ -244,6 +261,7 @@ export function createToolCardResults({
244
261
  isError: errors > 0,
245
262
  errorCount: errors,
246
263
  callErrorCount: callErrors,
264
+ exitErrorCount: exitErrors,
247
265
  count: allCalls.length,
248
266
  completedCount: totalCompleted,
249
267
  doneCategories: aggregateDoneCategories(allCalls),
@@ -257,15 +275,19 @@ export function createToolCardResults({
257
275
  continue;
258
276
  }
259
277
  // Non-aggregate finalize
260
- const group = toolGroups.get(card.itemId) || { count: 1, completed: 0, errors: 0, results: [] };
278
+ const group = toolGroups.get(card.itemId) || { count: 1, completed: 0, errors: 0, exitErrors: 0, results: [] };
261
279
  group.completed = Math.min(group.count, group.completed + 1);
262
280
  toolGroups.set(card.itemId, group);
263
281
  let resultText = groupedToolResultText(group);
282
+ if (group.errors > 0 && !String(resultText || '').trim()) {
283
+ const exitRec = (group.results || []).find((r) => r && r.isExitError);
284
+ resultText = finalizedErrorFallbackBody(resultText, exitRec?.text, exitRec?.exitCode);
285
+ }
264
286
  if (cancelled) {
265
287
  const currentItem = getState().items.find((it) => it.id === card.itemId);
266
288
  resultText = withCancelledResultMarker(resultText, currentItem);
267
289
  }
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() });
290
+ patchToolItem(card.itemId, { result: resultText, text: resultText, isError: group.errors > 0, errorCount: group.errors, callErrorCount: group.callErrors || 0, exitErrorCount: group.exitErrors || 0, count: group.count, completedCount: group.completed, completedAt: Date.now() });
269
291
  card.done = true;
270
292
  if (card.callId) done.add(card.callId);
271
293
  }