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
@@ -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
  }
@@ -8,27 +8,75 @@
8
8
  * keeps calling them unchanged.
9
9
  */
10
10
  import { toolErrorDisplay } from './tool-result-text.mjs';
11
+ import {
12
+ normalizeToolTerminalStatus,
13
+ toolResultTerminalStatus,
14
+ } from '../../runtime/shared/tool-status.mjs';
11
15
 
12
16
  export const CANCELLED_RESULT_STATUS_LINE = '[status: cancelled]';
13
17
 
18
+ // Detect a shell command that RAN but exited non-zero (a process exit code)
19
+ // as opposed to a real tool-call failure (`[shell-tool-failed]`) or a
20
+ // timeout/abort. bash-tool.mjs emits `Error: [shell-run-failed] [exit code: N]`
21
+ // for a plain non-zero exit; timeout/signal cases carry `[timeout: …]`/
22
+ // `[signal: …]` instead of an `[exit code: …]` marker. Returns the numeric
23
+ // exit code (>= 0) for a command-exit, or null otherwise.
24
+ export function shellCommandExitCode(text) {
25
+ const body = String(text || '');
26
+ // Anchor to the START of the result so a success/non-shell body that merely
27
+ // QUOTES the marker mid-output is never misclassified. bash-tool emits
28
+ // `Error: [shell-run-failed] [exit code: N]` as the leading marker header.
29
+ if (!/^\s*(?:Error:\s*)?\[shell-run-failed\]/i.test(body)) return null;
30
+ // Restrict marker parsing to the header region (first line) so only the
31
+ // engine-emitted status header — not quoted command output below — counts.
32
+ const header = body.split('\n', 1)[0] || '';
33
+ // Timeout / signal / abort are NOT a plain command exit — keep them "Failed".
34
+ if (/\[timeout:|\[signal:|timed out|aborted|interrupted/i.test(header)) return null;
35
+ const m = header.match(/\[exit code:\s*(\d+)\]/i);
36
+ if (!m) return null;
37
+ const code = Number(m[1]);
38
+ return Number.isFinite(code) ? code : null;
39
+ }
40
+
41
+ // Only provider envelope metadata establishes a failed invocation. Result
42
+ // bodies can contain "Error:"/HTTP/domain/task status text without making the
43
+ // call itself fail. A recognized plain shell exit wins over provider envelope
44
+ // error flags: adapters commonly label non-zero process exits as tool errors
45
+ // even though the shell tool itself ran successfully.
46
+ export function toolCallOutcome(message, rawText) {
47
+ const exitCode = shellCommandExitCode(rawText);
48
+ if (exitCode != null) {
49
+ return { isCallError: false, isExitError: true, exitCode };
50
+ }
51
+ const isCallError = message?.isError === true || message?.toolKind === 'error';
52
+ return {
53
+ isCallError,
54
+ isExitError: false,
55
+ exitCode,
56
+ };
57
+ }
58
+
59
+ // Build the collapsed failure/exit detail string. Real tool-call/result
60
+ // failures keep the red-adjacent "Failed" wording; shell command-exits render
61
+ // as the distinct neutral "Exit" state ("Exit N" for a single exit, "Y Exit"
62
+ // grouped). A mixed group surfaces both ("1 Ok · 1 Failed · 1 Exit").
63
+ export function failureDetailText({ succeeded = 0, realErrors = 0, exitErrors = 0, exitCode } = {}) {
64
+ const parts = [];
65
+ if (succeeded > 0) parts.push(`${succeeded} Ok`);
66
+ if (realErrors > 0) parts.push(`${realErrors} Failed`);
67
+ if (exitErrors > 0) {
68
+ const solo = exitErrors === 1 && realErrors === 0 && succeeded === 0;
69
+ parts.push(solo && Number.isFinite(exitCode) ? `Exit ${exitCode}` : `${exitErrors} Exit`);
70
+ }
71
+ return parts.join(' · ');
72
+ }
73
+
14
74
  export function normalizedResultStatusToken(value) {
15
- const raw = String(value || '').trim().toLowerCase();
16
- if (!raw) return '';
17
- if (/^(running|pending|queued|in_progress|in-progress)$/.test(raw)) return 'running';
18
- if (/^(completed|complete|done|success|succeeded|ok)$/.test(raw)) return 'completed';
19
- if (/^(failed|fail|error|errored|timeout|timed_out|killed)$/.test(raw)) return 'failed';
20
- if (/^(cancelled|canceled|cancel)$/.test(raw)) return 'cancelled';
21
- return '';
75
+ return normalizeToolTerminalStatus(value);
22
76
  }
23
77
 
24
78
  export function resultTextTerminalStatus(text) {
25
- const body = String(text || '');
26
- const tagged = body.match(/<status[^>]*>([\s\S]*?)<\/status>/i)?.[1]?.trim();
27
- if (tagged) return normalizedResultStatusToken(tagged);
28
- const bracketed = body.match(/^\[status:\s*([^\]]*)\]/mi)?.[1]?.trim();
29
- if (bracketed) return normalizedResultStatusToken(bracketed);
30
- const inline = body.match(/^(?:status|state):\s*([^\s·,;]+)/mi)?.[1]?.trim();
31
- return normalizedResultStatusToken(inline);
79
+ return toolResultTerminalStatus(text);
32
80
  }
33
81
 
34
82
  export function itemHasKnownTerminalStatus(item, texts = []) {
@@ -57,16 +105,18 @@ export function withCancelledResultMarker(text, item) {
57
105
  export function groupedToolResultText(group) {
58
106
  const completed = Math.min(group.count, group.completed);
59
107
  if (group.count <= 1) return group.results.at(-1)?.text ?? '';
60
- if (group.errors > 0) {
61
- const succeeded = Math.max(0, completed - group.errors);
108
+ const exitErrors = Number(group.exitErrors || 0);
109
+ if (group.errors > 0 || exitErrors > 0) {
110
+ const realErrors = Math.max(0, Number(group.callErrors || group.errors || 0));
111
+ const succeeded = Math.max(0, completed - group.errors - exitErrors);
112
+ const exitCode = group.results.find((result) => result?.isExitError)?.exitCode;
113
+ // Command-exits carry no failure reason line; only real failures do.
62
114
  const reasons = group.results
63
- .filter((result) => result?.isError)
115
+ .filter((result) => result?.isError && !result?.isExitError)
64
116
  .map((result) => firstErrorLine(result?.text))
65
117
  .filter(Boolean);
66
118
  const uniqueReasons = [...new Set(reasons)].slice(0, 2);
67
- const base = succeeded > 0
68
- ? `${succeeded} Ok · ${group.errors} Failed`
69
- : `${group.errors} Failed`;
119
+ const base = failureDetailText({ succeeded, realErrors, exitErrors, exitCode });
70
120
  return [
71
121
  `${base}${uniqueReasons[0] ? ` · ${uniqueReasons[0]}` : ''}`,
72
122
  ...uniqueReasons.slice(1),
@@ -102,7 +152,7 @@ export function aggregateRawResult(calls) {
102
152
  return chunks.join('\n\n');
103
153
  }
104
154
 
105
- export function aggregateBucketForCategory(category) {
155
+ export function aggregateBucketForCategory(category, { agentBatch = '' } = {}) {
106
156
  // Merge consecutive tool calls of the SAME category into one aggregate card;
107
157
  // a different category opens a fresh card (no cross-category merge). The
108
158
  // bucket key is the category itself, so a run of Search calls collapses into
@@ -117,6 +167,10 @@ export function aggregateBucketForCategory(category) {
117
167
  // header still spells out both. State-changing categories (Patch/Shell/…)
118
168
  // stay separate.
119
169
  if (key === 'Read' || key === 'Search') return 'category:Read+Search';
170
+ // Agent actions are grouped only inside the provider callback that emitted
171
+ // them. This keeps a later provider batch from changing an older outbound
172
+ // card above an intervening result/transcript boundary.
173
+ if (key === 'Agent') return agentBatch ? `category:Agent:${agentBatch}` : 'category:Agent';
120
174
  return key ? `category:${key}` : 'default';
121
175
  }
122
176
 
@@ -6,11 +6,9 @@ import { applyUsageDelta } from './session-stats.mjs';
6
6
  import { pickVerb, pickDoneVerb, compactEventLabel, compactEventDetail } from './labels.mjs';
7
7
  import { toolResultText, toolErrorDisplay } from './tool-result-text.mjs';
8
8
  import { toolCallId, toolResultCallId, toolCallName, toolCallArgs } from './tool-call-fields.mjs';
9
- import { toolResultStatus, isErrorToolStatus } from './agent-envelope.mjs';
10
- import { promptDisplayText } from './queue-helpers.mjs';
11
- import { memoryCoreResultErrorText } from '../app/input-parsers.mjs';
9
+ import { promptDisplayText, STEERING_SUPPRESSED_DISPLAY } from './queue-helpers.mjs';
12
10
  import { yieldToRenderer } from './render-timing.mjs';
13
- import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, assignAggregateSummaryOrder } from './tool-result-status.mjs';
11
+ import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, assignAggregateSummaryOrder, failureDetailText, toolCallOutcome } from './tool-result-status.mjs';
14
12
 
15
13
  export function createRunTurn(bag) {
16
14
  const {
@@ -42,6 +40,9 @@ export function createRunTurn(bag) {
42
40
  reclaimed: false,
43
41
  committed: false,
44
42
  requeueEntries: Array.isArray(options.requeueOnAbort) ? options.requeueOnAbort.slice() : [],
43
+ discardExecutionPendingResumeKeys: Array.isArray(options.discardExecutionPendingResumeKeys)
44
+ ? options.discardExecutionPendingResumeKeys.slice()
45
+ : [],
45
46
  };
46
47
  set({ busy: true, lastTurn: null, spinner: { active: true, verb: pickVerb(turnIndex), startedAt, responseLength: 0, inputTokens: 0, outputTokens: 0, mode: 'requesting' } });
47
48
 
@@ -130,6 +131,15 @@ export function createRunTurn(bag) {
130
131
  };
131
132
  armWatchdog();
132
133
  let currentAssistantText = '';
134
+ // Segments sealed as their own assistant item(s) this turn via
135
+ // commitAssistantSegment (e.g. a tool preamble, then a no-newline tail
136
+ // committed by onSteerMessage before an injected steering row). Kept as an
137
+ // ordered list — NOT one concatenated string — so finalization can strip
138
+ // each committed segment out of the provider's final content individually.
139
+ // A single concatenation breaks when the provider omits an earlier segment
140
+ // (e.g. a tool preamble) from result.content: the combined prefix no longer
141
+ // matches and the tail would duplicate after the steering row.
142
+ const committedSegments = [];
133
143
  let thinkingText = '';
134
144
  let thinkingStartedAt = 0;
135
145
  let thinkingSegmentStartedAt = 0;
@@ -147,6 +157,7 @@ export function createRunTurn(bag) {
147
157
  const earlyResultBuffer = new Map();
148
158
  const aggregateCards = []; // active aggregate cards in the current consecutive tool block
149
159
  let tailAggregate = null; // most recently touched aggregate card; only the tail may absorb the next same-bucket call
160
+ let providerToolBatch = 0;
150
161
 
151
162
  // ── Deferred tool-card push (scroll/text sync) ────────────────────────────
152
163
  // A tool card used to enter the transcript the instant onToolCall fired,
@@ -163,13 +174,11 @@ export function createRunTurn(bag) {
163
174
  // cards are pushed alongside the result-bearing one before their own delay
164
175
  // elapses and would otherwise paint an empty reserved band.
165
176
  // Mirrors components/ToolExecution.jsx TOOL_PENDING_SHOW_DELAY_MS.
166
- // [jitter fix] Reserve the tool-card row immediately instead of after a
167
- // 1000ms delay. Delayed insertion made rows appear late and shove the body
168
- // vertically; pushing now (with the real 'Running' header height via
169
- // deferredDisplayReady) keeps the layout stable. The timer path is kept as a
170
- // 0ms microtask fallback for entries that are registered but not explicitly
171
- // surfaced, preserving call-order flush semantics.
172
- const TOOL_CARD_PUSH_DELAY_MS = 0;
177
+ // Keep a fast call off-screen until it either resolves or has genuinely
178
+ // been pending long enough to communicate work. This avoids a one-frame
179
+ // Running→Finished flash while preserving the deferred entry's ordered,
180
+ // result-forced materialization path.
181
+ const TOOL_CARD_PUSH_DELAY_MS = 1000;
173
182
  let deferredSeqCounter = 0;
174
183
  const deferredEntries = []; // creation-order list; each is pushed at most once
175
184
  // Push this entry AND every earlier-created still-deferred entry, in order,
@@ -328,13 +337,15 @@ export function createRunTurn(bag) {
328
337
  aggregate.ensureVisible?.();
329
338
  const errors = allCalls.filter((r) => r.isError).length;
330
339
  const callErrors = allCalls.filter((r) => r.isCallError).length;
340
+ const exitErrors = allCalls.filter((r) => r.isExitError).length;
331
341
  const completed = allCalls.filter((r) => r.resolved).length;
332
- const succeeded = completed - errors;
342
+ const succeeded = Math.max(0, completed - errors - exitErrors);
333
343
  const rawResult = aggregateRawResult(allCalls);
334
- // Merged count summary (see patchToolCardResult); failures keep
335
- // 'N Ok · N Failed'. Raw preserved for ctrl+o expansion.
336
- const displayDetail = errors > 0
337
- ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
344
+ // Merged count summary (see patchToolCardResult); real failures keep
345
+ // 'N Failed', shell command-exits render 'Exit N'/'Y Exit'. Raw
346
+ // preserved for ctrl+o expansion.
347
+ const displayDetail = errors > 0 || exitErrors > 0
348
+ ? failureDetailText({ succeeded, realErrors: callErrors, exitErrors, exitCode: allCalls.find((r) => r.isExitError)?.exitCode })
338
349
  : formatAggregateDetail(aggregateSummaries(aggregate));
339
350
  patchItem(aggregate.itemId, {
340
351
  result: displayDetail,
@@ -343,6 +354,7 @@ export function createRunTurn(bag) {
343
354
  isError: errors > 0,
344
355
  errorCount: errors,
345
356
  callErrorCount: callErrors,
357
+ exitErrorCount: exitErrors,
346
358
  count: allCalls.length,
347
359
  completedCount: allCalls.length,
348
360
  doneCategories: aggregateDoneCategories(allCalls),
@@ -463,6 +475,7 @@ export function createRunTurn(bag) {
463
475
  if (sealToolBlock) clearAggregateContinuation();
464
476
  const id = currentAssistantId || ensureAssistant(text);
465
477
  patchItem(id, { text, streaming: false });
478
+ committedSegments.push(text);
466
479
  closeAssistantSegment();
467
480
  return true;
468
481
  };
@@ -658,35 +671,33 @@ export function createRunTurn(bag) {
658
671
  if (!callRec || callRec.resolved || callRec.completedEarly) return;
659
672
  aggregate.ensureVisible?.();
660
673
  const rawText = toolResultText(message?.content);
661
- // Recover flattened memory "core" op failures (isError dropped upstream)
662
- // so they are excluded from the done count see memoryCoreResultErrorText.
663
- // Gated to Memory calls: the text-matcher's ^(error|failed) catch-all
664
- // would otherwise misflag legitimate non-memory success output.
665
- const isMemoryCall = classifyToolCategory(callRec.name, callRec.args) === 'Memory';
666
- // Same split as tool-card-results.mjs: a REAL tool-call error drives the
667
- // red ● dot; command/result failures only mark the card Failed in L2.
668
- const isCallError = message?.isError === true || message?.toolKind === 'error';
669
- const isResultError = /^\s*\[?error/i.test(rawText) || isErrorToolStatus(toolResultStatus(rawText)) || (isMemoryCall && memoryCoreResultErrorText(rawText) != null);
670
- const isError = isCallError || isResultError;
674
+ // Tool result text (including HTTP/domain failures, zero matches, task
675
+ // statuses, and shell output) is detail, not a failed invocation. Only
676
+ // the provider's isError/error-tool envelope drives failure counts/red.
677
+ const { exitCode, isExitError, isCallError } = toolCallOutcome(message, rawText);
678
+ const isError = isCallError;
671
679
  const text = isError ? toolErrorDisplay(rawText, callRec.name || 'tool') : rawText;
672
680
  callRec.summary = !isError ? summarizeToolResult(callRec.name, callRec.args, rawText, isError) : null;
673
681
  assignAggregateSummaryOrder(aggregate, callRec);
674
682
  callRec.isError = isError;
675
683
  callRec.isCallError = isCallError;
684
+ callRec.isExitError = isExitError;
685
+ callRec.exitCode = exitCode;
676
686
  callRec.resultText = text;
677
687
  callRec.completedEarly = true;
678
688
  const allCalls = [...aggregate.calls.values()];
679
689
  const completedCount = allCalls.filter((r) => r.resolved || r.completedEarly).length;
680
690
  const errors = allCalls.filter((r) => r.isError).length;
681
691
  const callErrors = allCalls.filter((r) => r.isCallError).length;
682
- const succeeded = completedCount - errors;
692
+ const exitErrors = allCalls.filter((r) => r.isExitError).length;
693
+ const succeeded = Math.max(0, completedCount - errors - exitErrors);
683
694
  const rawResult = aggregateRawResult(allCalls);
684
695
  // Collapsed detail carries the merged per-call count summary even on
685
696
  // the early-notify path; patching '' here flipped the detail row back
686
697
  // to the 'Running' placeholder between count updates (the visible
687
698
  // jitter). Failures keep 'N Failed'. Raw preserved for ctrl+o expansion.
688
- const displayDetail = errors > 0
689
- ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
699
+ const displayDetail = errors > 0 || exitErrors > 0
700
+ ? failureDetailText({ succeeded, realErrors: callErrors, exitErrors, exitCode: allCalls.find((r) => r.isExitError)?.exitCode })
690
701
  : formatAggregateDetail(aggregateSummaries(aggregate));
691
702
  const currentItem = getState().items.find((it) => it.id === card.itemId);
692
703
  const visualCompleted = Math.max(
@@ -699,6 +710,7 @@ export function createRunTurn(bag) {
699
710
  isError: errors > 0,
700
711
  errorCount: errors,
701
712
  callErrorCount: callErrors,
713
+ exitErrorCount: exitErrors,
702
714
  count: allCalls.length,
703
715
  completedCount: visualCompleted,
704
716
  };
@@ -727,15 +739,22 @@ export function createRunTurn(bag) {
727
739
  drainSteering: (_sessionId, drainOptions) => (isCurrentTurn() ? drainPendingSteering(drainOptions) : []),
728
740
  onSteerMessage: (text) => {
729
741
  if (!markTurnProgress('steer-message')) return;
742
+ // A suppressed live-completion twin is model-visible only; its
743
+ // Response card was already pushed at delivery time. Skip the
744
+ // duplicate transcript item (progress is still marked above since
745
+ // the content WAS injected into the model turn).
746
+ if (text === STEERING_SUPPRESSED_DISPLAY) return;
730
747
  // Steering can be injected after a terminal no-tool response has
731
748
  // already streamed but before runTurn finalizes. Seal the current
732
749
  // assistant segment first so the steered user turn and the next
733
750
  // assistant response do not get visually merged into one bubble.
734
751
  flushStreamBatch();
735
- if (currentAssistantId) {
736
- patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
737
- closeAssistantSegment();
738
- }
752
+ // Commit any pending assistant segment — including a streamed tail
753
+ // that never got a trailing '\n' (no row/currentAssistantId created
754
+ // yet). Using the shared segment-commit helper ensures that tail is
755
+ // materialized as an assistant item instead of being dropped when a
756
+ // steering/agent-completion injection races turn finalization.
757
+ commitAssistantSegment({ sealToolBlock: true });
739
758
  assistantText = '';
740
759
  const value = String(text || '').trim();
741
760
  if (value) {
@@ -764,6 +783,7 @@ export function createRunTurn(bag) {
764
783
  }
765
784
  const batchCalls = (calls || []).filter(Boolean);
766
785
  if (batchCalls.length === 0) return;
786
+ const agentBatch = ++providerToolBatch;
767
787
  const committedAssistantSegment = commitAssistantSegment({ sealToolBlock: true });
768
788
  if (committedAssistantSegment) {
769
789
  // Let the pre-tool assistant preamble paint and settle before the
@@ -787,11 +807,10 @@ export function createRunTurn(bag) {
787
807
  // Category drives the aggregate bucket so only same-category calls
788
808
  // merge into one card; classify first, then bucket by it.
789
809
  const category = classifyToolCategory(name, args);
790
- // Agent/bridge calls always get their own standalone card so
791
- // ToolExecution's isAgentTool/agentActionTitle path renders a
792
- // "Spawn <Agent> (model, tag)" header instead of being folded
793
- // into the "Called N agent(s)" category-aggregate card.
794
- const bucket = category === 'Agent' ? null : aggregateBucketForCategory(category);
810
+ // Agent actions aggregate only within this provider-emitted batch.
811
+ // They stay outbound category cards; asynchronous inbound Responses
812
+ // are separately tailed by the notification feed and never mix here.
813
+ const bucket = aggregateBucketForCategory(category, { agentBatch });
795
814
  const callId = toolCallId(c);
796
815
  const callKey = callId || `__tool_${toolCards.length}_${i}`;
797
816
  // The old App scan counted multi-pattern calls via
@@ -854,7 +873,7 @@ export function createRunTurn(bag) {
854
873
  ...categoryEntry,
855
874
  count: Number(prevCategory?.count || 0) + Number(categoryEntry.count || 1),
856
875
  });
857
- aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, isCallError: false, resultText: null, resolved: false, completedEarly: false });
876
+ aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, isCallError: false, isExitError: false, exitCode: null, resultText: null, resolved: false, completedEarly: false });
858
877
  touchedAggregates.add(aggregateCard);
859
878
  const card = { itemId: aggregateCard.itemId, callId: callKey, done: false, aggregate: aggregateCard };
860
879
  if (callId) {
@@ -1038,16 +1057,37 @@ export function createRunTurn(bag) {
1038
1057
  syncContextStats({ allowEstimated: true });
1039
1058
 
1040
1059
  const finalText = result?.content != null ? String(result.content) : '';
1041
- if (finalText.trim()) {
1060
+ // Strip text already sealed as its own item(s) this turn (a tool
1061
+ // preamble, then a no-newline tail committed by onSteerMessage before an
1062
+ // injected steering row) so finalization reconciles only the uncommitted
1063
+ // remainder — never re-creating a committed segment as a duplicate item
1064
+ // that also reorders after the steering row. Walk the segments IN ORDER,
1065
+ // peeling each off the front of the remaining content; skip leading
1066
+ // whitespace/newlines between segments. A segment that does not match at
1067
+ // the current position (provider omitted it from result.content, e.g. a
1068
+ // tool preamble) is left in place and the walk moves on.
1069
+ let finalRemainder = finalText;
1070
+ for (const seg of committedSegments) {
1071
+ // Compare against the whitespace-skipped remainder AND a
1072
+ // whitespace-trimmed segment: a segment sealed with its own leading
1073
+ // newline ('\nTAIL') would otherwise never match the skipped remainder
1074
+ // ('TAIL') and duplicate after the steering row.
1075
+ const skipped = finalRemainder.replace(/^\s+/, '');
1076
+ const trimmedSeg = seg ? seg.replace(/^\s+/, '') : '';
1077
+ if (trimmedSeg && skipped.startsWith(trimmedSeg)) {
1078
+ finalRemainder = skipped.slice(trimmedSeg.length);
1079
+ }
1080
+ }
1081
+ if (finalRemainder.trim()) {
1042
1082
  // The persisted transcript is written from the provider's final content,
1043
1083
  // while the live TUI row is fed by streaming deltas. If a provider/parser
1044
1084
  // misses or suppresses an early delta, keeping the streamed buffer here
1045
1085
  // leaves the final on-screen assistant row missing leading characters even
1046
1086
  // though the transcript is correct. Always reconcile the active segment to
1047
1087
  // the final provider text when it is available.
1048
- const id = currentAssistantId || ensureAssistant(finalText);
1049
- currentAssistantText = finalText;
1050
- patchItem(id, { text: finalText, streaming: false });
1088
+ const id = currentAssistantId || ensureAssistant(finalRemainder);
1089
+ currentAssistantText = finalRemainder;
1090
+ patchItem(id, { text: finalRemainder, streaming: false });
1051
1091
  } else if (currentAssistantId && (currentAssistantText.trim() || assistantText.trim())) {
1052
1092
  const streamedText = currentAssistantText || assistantText;
1053
1093
  patchItem(currentAssistantId, { text: streamedText, streaming: false });