mixdog 0.9.38 → 0.9.40

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 (124) 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 +176 -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 +316 -2
  25. package/scripts/tool-smoke.mjs +21 -13
  26. package/scripts/tool-tui-presentation-test.mjs +202 -0
  27. package/scripts/tui-transcript-perf-test.mjs +279 -0
  28. package/src/agents/heavy-worker/AGENT.md +10 -7
  29. package/src/agents/reviewer/AGENT.md +6 -4
  30. package/src/agents/worker/AGENT.md +7 -5
  31. package/src/rules/agent/00-common.md +4 -4
  32. package/src/rules/agent/00-core.md +11 -14
  33. package/src/rules/agent/20-skip-protocol.md +3 -3
  34. package/src/rules/agent/30-explorer.md +50 -60
  35. package/src/rules/agent/40-cycle1-agent.md +15 -24
  36. package/src/rules/agent/41-cycle2-agent.md +33 -57
  37. package/src/rules/agent/42-cycle3-agent.md +28 -42
  38. package/src/rules/lead/01-general.md +7 -10
  39. package/src/rules/lead/lead-brief.md +11 -14
  40. package/src/rules/lead/lead-tool.md +6 -5
  41. package/src/rules/shared/01-tool.md +44 -45
  42. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +44 -1
  43. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
  44. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
  46. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
  47. package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
  48. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
  49. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -0
  50. package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
  51. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
  52. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +67 -2
  53. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  54. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +232 -43
  56. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
  57. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +27 -13
  59. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
  60. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
  62. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
  63. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
  65. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
  66. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
  67. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
  68. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
  69. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
  70. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
  72. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
  73. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
  74. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
  75. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
  76. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
  77. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
  78. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
  79. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  80. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
  81. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
  82. package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
  83. package/src/runtime/shared/buffered-appender.mjs +13 -2
  84. package/src/runtime/shared/tool-primitives.mjs +4 -1
  85. package/src/runtime/shared/tool-status.mjs +27 -0
  86. package/src/runtime/shared/tool-surface.mjs +6 -3
  87. package/src/session-runtime/config-helpers.mjs +14 -0
  88. package/src/session-runtime/context-status.mjs +1 -0
  89. package/src/session-runtime/effort.mjs +6 -2
  90. package/src/session-runtime/lifecycle-api.mjs +4 -0
  91. package/src/session-runtime/model-recency.mjs +5 -2
  92. package/src/session-runtime/runtime-core.mjs +36 -2
  93. package/src/session-runtime/session-turn-api.mjs +12 -0
  94. package/src/session-runtime/tool-catalog.mjs +34 -0
  95. package/src/standalone/agent-tool/notify.mjs +13 -0
  96. package/src/standalone/agent-tool.mjs +53 -70
  97. package/src/standalone/explore-tool.mjs +6 -7
  98. package/src/tui/App.jsx +33 -1
  99. package/src/tui/app/model-options.mjs +5 -3
  100. package/src/tui/app/model-picker.mjs +12 -24
  101. package/src/tui/app/transcript-window.mjs +10 -10
  102. package/src/tui/app/use-transcript-window.mjs +38 -56
  103. package/src/tui/components/ToolExecution.jsx +11 -6
  104. package/src/tui/components/TranscriptItem.jsx +1 -1
  105. package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
  106. package/src/tui/components/tool-execution/text-format.mjs +10 -19
  107. package/src/tui/dist/index.mjs +866 -300
  108. package/src/tui/engine/agent-job-feed.mjs +216 -19
  109. package/src/tui/engine/agent-response-tail.mjs +68 -0
  110. package/src/tui/engine/notification-plan.mjs +16 -0
  111. package/src/tui/engine/session-api.mjs +14 -2
  112. package/src/tui/engine/session-flow.mjs +22 -2
  113. package/src/tui/engine/tool-card-results.mjs +64 -37
  114. package/src/tui/engine/tool-result-status.mjs +75 -21
  115. package/src/tui/engine/turn.mjs +172 -77
  116. package/src/tui/engine.mjs +199 -39
  117. package/src/tui/index.jsx +2 -2
  118. package/src/workflows/bench/WORKFLOW.md +25 -35
  119. package/src/workflows/default/WORKFLOW.md +38 -32
  120. package/src/workflows/solo/WORKFLOW.md +19 -22
  121. package/scripts/_jitter-fuzz.mjs +0 -44410
  122. package/scripts/_jitter-fuzz2.mjs +0 -44400
  123. package/scripts/_jitter-probe.mjs +0 -44397
  124. package/scripts/_jp2.mjs +0 -45614
@@ -245,6 +245,7 @@ function toolHasDisplayResultForRows(item) {
245
245
 
246
246
  function toolExpandedRawTextForRows(item, rawRt) {
247
247
  if (item?.aggregate) return rawRt;
248
+ if (item?.agentResponseAggregate) return rawRt;
248
249
  const hasDisplayResult = toolHasDisplayResultForRows(item);
249
250
  if (hasDisplayResult) return toolDisplayedResultTextForRows(item);
250
251
  return stripLeadingStatusMarkerFromTextForRows(rawRt || '');
@@ -831,7 +832,7 @@ export function buildTranscriptRowIndexIncremental(items, {
831
832
  suppressMeasuredRowHeights = false,
832
833
  measuredRowsVersion = 0,
833
834
  cacheRef = null,
834
- prefixSig = null,
835
+ prefixRevision = null,
835
836
  } = {}) {
836
837
  const allItems = Array.isArray(items) ? items : [];
837
838
  const tail = trailingStreamingItem(allItems);
@@ -849,11 +850,10 @@ export function buildTranscriptRowIndexIncremental(items, {
849
850
  }
850
851
  const prefixLen = allItems.length - 1;
851
852
  const cache = holder.current;
852
- // Prefix identity is carried by `prefixSig` (a hash of every prefix item's
853
- // WeakMap sigPart, computed once in the hook). String equality catches a
854
- // same-length MIDDLE-item replacement (tool-card patch swaps a middle object
855
- // fresh fragment different sig), which a last-item ref check would miss.
856
- // Fast path: same prefix length + tail id + prefixSig, and columns/expanded/
853
+ // Prefix identity is carried by the engine's monotonic structure revision.
854
+ // Every settled-item mutation, including same-length middle tool-card patches,
855
+ // bumps it without requiring a render-time walk over the transcript.
856
+ // Fast path: same prefix length + tail id + revision, and columns/expanded/
857
857
  // suppress/version all match. Only the tail row can differ → recompute + append.
858
858
  if (cache
859
859
  && cache.columns === columns
@@ -861,10 +861,10 @@ export function buildTranscriptRowIndexIncremental(items, {
861
861
  && cache.suppress === suppressMeasuredRowHeights
862
862
  && cache.version === measuredRowsVersion
863
863
  && cache.prefixLen === prefixLen
864
- && prefixSig != null
865
- && cache.prefixSig === prefixSig
864
+ && prefixRevision != null
865
+ && cache.prefixRevision === prefixRevision
866
866
  && cache.tailId === tail.id) {
867
- // prefixSig matches → prefix rows provably unchanged; NO prefix walk / no
867
+ // revision matches → prefix rows provably unchanged; NO prefix walk / no
868
868
  // re-estimation. Only the tail row can differ, recompute it.
869
869
  {
870
870
  const tailMeasured = suppressMeasuredRowHeights
@@ -893,7 +893,7 @@ export function buildTranscriptRowIndexIncremental(items, {
893
893
  suppress: suppressMeasuredRowHeights,
894
894
  version: measuredRowsVersion,
895
895
  prefixLen,
896
- prefixSig,
896
+ prefixRevision,
897
897
  tailId: tail.id,
898
898
  prefixRowsArr: full.rows.slice(0, prefixLen),
899
899
  prefixPrefixRows: full.prefixRows.slice(0, prefixLen + 1),
@@ -18,7 +18,6 @@ import {
18
18
  hasStreamingRowStateToPrune,
19
19
  transcriptItemVariantKey,
20
20
  buildTranscriptRowIndexIncremental,
21
- transcriptStructureSignature,
22
21
  estimateTranscriptItemRowsCached,
23
22
  setStreamingBottomPinned,
24
23
  transcriptRenderWindow,
@@ -32,7 +31,9 @@ import {
32
31
  import { shouldSuppressFullyFailedToolItem } from '../transcript-tool-failures.mjs';
33
32
 
34
33
  export function useTranscriptWindow({
35
- items,
34
+ items: settledItems,
35
+ structureRevision,
36
+ streamingTail,
36
37
  themeEpoch,
37
38
  frameColumns,
38
39
  toolOutputExpanded,
@@ -129,42 +130,12 @@ export function useTranscriptWindow({
129
130
  return fn;
130
131
  }, []);
131
132
 
132
- // Key the heavy O(n) row-index + windowing memos on a STRUCTURE signature
133
- // instead of the `items` array identity. The engine swaps `items`
134
- // for a new array on every streaming flush (~8ms) while only the final
135
- // assistant item's text grows; depending on array identity re-ran both memos
136
- // each delta frame and visibly throttled the stream. The signature changes
137
- // only when transcript structure or the streaming item's estimated height
138
- // changes, so steady per-character growth keeps both memos warm.
139
- //
140
- // The signature itself is memoized on `items` identity (+columns/
141
- // expanded) so re-renders that DO NOT touch items — drag motion, scroll,
142
- // input typing — skip the O(n) signature walk entirely. During streaming the
143
- // engine hands us a fresh `items` array each flush, so this memo
144
- // recomputes and still tracks the streaming item's height correctly.
145
- // Split the structure signature into prefix (all items except the trailing
146
- // streaming item) + tail. On a streaming flush only the tail item object is
147
- // replaced; every prefix item keeps identity, so the prefix sig is O(1)-
148
- const streamingTailItem = useMemo(() => {
149
- const last = (items || []).length > 0 ? items[items.length - 1] : null;
150
- return last && last.kind === 'assistant' && last.streaming ? last : null;
151
- }, [items]);
152
- const prefixLen = streamingTailItem ? (items || []).length - 1 : (items || []).length;
153
- // Key on `items` identity (engine swaps the array every flush — INCLUDING a
154
- // tool-card patch that replaces a MIDDLE item object), so a same-length
155
- // middle-item replacement is caught (new object → fresh WeakMap fragment →
156
- // different prefixSig string). The walk itself is cheap: transcript-
157
- // StructureSignature reads a WeakMap sigPart per item (no re-estimation for
158
- // unchanged objects) and joins — O(n) map lookups, not O(n) measurement. The
159
- // row-index memo stays keyed on the prefixSig STRING, so a tail-only flush
160
- // (identical prefix objects → identical string) still skips the row rebuild.
161
- const prefixSig = useMemo(
162
- () => transcriptStructureSignature(
163
- streamingTailItem ? (items || []).slice(0, prefixLen) : (items || []),
164
- frameColumns, toolOutputExpanded,
165
- ),
166
- [items, streamingTailItem, prefixLen, frameColumns, toolOutputExpanded],
167
- );
133
+ // The settled array no longer changes during streaming. Geometry is keyed by
134
+ // the engine revision plus the live tail's resolved height, so same-height
135
+ // text flushes do not copy/walk the settled prefix or rerun heavy memos.
136
+ const streamingTailItem = streamingTail?.kind === 'assistant' && streamingTail.streaming
137
+ ? streamingTail
138
+ : null;
168
139
  const scrolledUpRowsForPin = Math.max(0, Number(scrollTargetRef.current) || 0);
169
140
  const transcriptPinnedForStreaming = followingRef.current
170
141
  || scrolledUpRowsForPin <= transcriptBottomSlackRows;
@@ -173,13 +144,18 @@ export function useTranscriptWindow({
173
144
  // applies while bottom-pinned (right geometry on first commit, no judder) and
174
145
  // every tail-resolving path reads the same height (no sig/geometry divergence).
175
146
  setStreamingBottomPinned(transcriptPinnedForStreaming);
176
- const tailSig = streamingTailItem
177
- ? `a${streamingTailItem.id}:${estimateTranscriptItemRowsCached(streamingTailItem, frameColumns, toolOutputExpanded)}`
178
- : '_';
179
- const transcriptStructureSig = `${prefixSig}#${tailSig}`;
180
- const transcriptStreamingActive = (items || []).some(
181
- (item) => item?.kind === 'assistant' && item?.streaming,
147
+ const tailRows = streamingTailItem
148
+ ? estimateTranscriptItemRowsCached(streamingTailItem, frameColumns, toolOutputExpanded)
149
+ : 0;
150
+ const tailSig = streamingTailItem ? `${streamingTailItem.id}:${tailRows}` : '_';
151
+ const revision = Math.max(0, Number(structureRevision) || 0);
152
+ const transcriptItems = useMemo(
153
+ () => streamingTailItem ? [...(settledItems || []), streamingTailItem] : (settledItems || []),
154
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- tail text at the same resolved height is injected into the bounded visible slice below
155
+ [settledItems, revision, streamingTailItem?.id, tailRows],
182
156
  );
157
+ const transcriptStructureSig = `${revision}#${tailSig}`;
158
+ const transcriptStreamingActive = !!streamingTailItem;
183
159
  const scrolledUpForStreamingMeasure = scrolledUpRowsForPin > transcriptBottomSlackRows;
184
160
  const prevEstimateGeometry = transcriptGeomRef.current?.suppressMeasuredRowHeights === true;
185
161
  const hasStreamingReadingAnchor = !!transcriptAnchorRef.current
@@ -199,15 +175,15 @@ export function useTranscriptWindow({
199
175
  // rebuild for the settled prefix. All those invalidators are folded into the
200
176
  // memo deps below (sig captures item/column/expanded structure; the rest are
201
177
  // listed explicitly), so the memo only recomputes when one of them changes.
202
- const transcriptRowIndex = useMemo(() => buildTranscriptRowIndexIncremental(items, {
178
+ const transcriptRowIndex = useMemo(() => buildTranscriptRowIndexIncremental(transcriptItems, {
203
179
  columns: frameColumns,
204
180
  toolOutputExpanded,
205
181
  suppressMeasuredRowHeights,
206
182
  measuredRowsVersion,
207
183
  cacheRef: incrementalRowIndexCacheRef,
208
- prefixSig,
209
- // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: prefixSig/tailSig capture the relevant item changes (raw `items` dropped so a tail-only flush never re-enters the builder via array identity); measuredRowsVersion folds in app-level measured height corrections
210
- }), [prefixSig, tailSig, measuredRowsVersion, suppressMeasuredRowHeights]);
184
+ prefixRevision: revision,
185
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- revision/tail height capture structural geometry; measuredRowsVersion folds in measured corrections
186
+ }), [revision, tailSig, frameColumns, toolOutputExpanded, measuredRowsVersion, suppressMeasuredRowHeights]);
211
187
  // ── Same-frame anchor lock ───────────────────────────────────────────────
212
188
  // While the user reads older transcript (anchor captured, not dirty), resolve
213
189
  // the scroll offset that keeps the anchored viewport-top row fixed for THIS
@@ -243,7 +219,7 @@ export function useTranscriptWindow({
243
219
  if (anchorLockActive) {
244
220
  const locked = resolveAnchorScrollOffset({
245
221
  anchor: transcriptAnchorRef.current,
246
- items: items,
222
+ items: transcriptItems,
247
223
  curPrefix: curPrefixForLock,
248
224
  totalRows: lockTotalRows,
249
225
  viewRows: lockViewRows,
@@ -288,7 +264,7 @@ export function useTranscriptWindow({
288
264
  const captured = { id: anchorItem.id, offset: Math.max(0, prevTopRow - (prevPrefix[idx] || 0)) };
289
265
  const locked = resolveAnchorScrollOffset({
290
266
  anchor: captured,
291
- items: items,
267
+ items: transcriptItems,
292
268
  curPrefix: curPrefixForLock,
293
269
  totalRows: lockTotalRows,
294
270
  viewRows: lockViewRows,
@@ -343,7 +319,7 @@ export function useTranscriptWindow({
343
319
  const captured = { id: anchorItem.id, offset: Math.max(0, prevTopRow - (prevPrefix[idx] || 0)) };
344
320
  const locked = resolveAnchorScrollOffset({
345
321
  anchor: captured,
346
- items: items,
322
+ items: transcriptItems,
347
323
  curPrefix: curPrefixForLock,
348
324
  totalRows: lockTotalRows,
349
325
  viewRows: lockViewRows,
@@ -376,7 +352,7 @@ export function useTranscriptWindow({
376
352
  // measuredRowsVersion bumps, the row index absorbs the growth (idEntry.rows ==
377
353
  // the new measured height) and the live estimate matches it, so delta → 0.
378
354
  if (scrolledUp && !followingRef.current) {
379
- const growth = streamingTailMountedGrowth(items, frameColumns, toolOutputExpanded);
355
+ const growth = streamingTailMountedGrowth(transcriptItems, frameColumns, toolOutputExpanded);
380
356
  // Only compensate when the tail is actually mounted in the rendered slice
381
357
  // (viewport + overscan). Off-slice it is represented by a row-index-sized
382
358
  // bottom spacer that does NOT physically grow this frame, so shifting the
@@ -394,7 +370,7 @@ export function useTranscriptWindow({
394
370
  }
395
371
  }
396
372
  }
397
- const transcriptWindow = useMemo(() => transcriptRenderWindow(items, {
373
+ const transcriptWindow = useMemo(() => transcriptRenderWindow(transcriptItems, {
398
374
  scrollOffset: renderScrollOffset,
399
375
  viewportHeight: transcriptContentHeight,
400
376
  columns: frameColumns,
@@ -451,7 +427,7 @@ export function useTranscriptWindow({
451
427
  prefixRows: transcriptRowIndex?.prefixRows || null,
452
428
  totalRows: Math.max(0, Number(transcriptWindow.totalRows) || 0),
453
429
  viewRows: Math.max(1, Number(transcriptContentHeight) || 1),
454
- items: items || null,
430
+ items: transcriptItems || null,
455
431
  // The offset THIS frame actually rendered with. The same-frame anchor
456
432
  // CAPTURE for a missing/dirty anchor (above) reads this from the PREVIOUS
457
433
  // frame to reconstruct the exact top-edge row that was on screen, so the
@@ -469,10 +445,16 @@ export function useTranscriptWindow({
469
445
  // height changes. Re-slice the live `items` over the memo's stable
470
446
  // [startIndex, endIndex) bounds so the on-screen text is always current
471
447
  // while the expensive indexing/windowing stays warm.
472
- const transcriptVisibleItems = (items || []).slice(
448
+ const transcriptVisibleItems = (transcriptItems || []).slice(
473
449
  transcriptWindow.startIndex,
474
450
  transcriptWindow.endIndex,
475
451
  );
452
+ if (streamingTailItem && transcriptVisibleItems.length > 0) {
453
+ const last = transcriptVisibleItems.length - 1;
454
+ if (transcriptVisibleItems[last]?.id === streamingTailItem.id) {
455
+ transcriptVisibleItems[last] = streamingTailItem;
456
+ }
457
+ }
476
458
  // The bottom meta band is spinner-only, so nothing is pulled out of the
477
459
  // transcript for it. A finished turn's done row (turndone/statusdone) renders
478
460
  // inline in scrollback like any other item — no filtering, no double-paint.
@@ -703,7 +685,7 @@ export function useTranscriptWindow({
703
685
  // move the item's prefix start and are absorbed the same way. No deltas, no
704
686
  // fallback, no drift.
705
687
  const viewRows = Math.max(1, Number(transcriptContentHeight) || 1);
706
- const itemList = items || [];
688
+ const itemList = transcriptItems || [];
707
689
  let anchor = transcriptAnchorRef.current;
708
690
  // (Re)capture the anchor from the current viewport-top edge when missing or
709
691
  // invalidated by a manual scroll. anchorRow = absolute row at the top edge.
@@ -86,7 +86,7 @@ function statusCopy(name, label, count, doneCount, pending, isError, args = {})
86
86
  // dropping the pad just normalizes the spacing.
87
87
  return formatToolActionHeader(name, args, { pending, count });
88
88
  }
89
- export function ToolExecution({ name, args, result, rawResult, isError, errorCount, callErrorCount, expanded, columns = 80, attached = false, count = 1, completedCount = 0, startedAt = 0, completedAt = 0, aggregate = false, categories = {}, doneCategories = null, headerFinalized = true, deferredDisplayReady = false }) {
89
+ export function ToolExecution({ name, args, result, rawResult, isError, errorCount, callErrorCount, exitErrorCount, expanded, columns = 80, attached = false, count = 1, completedCount = 0, startedAt = 0, completedAt = 0, aggregate = false, categories = {}, doneCategories = null, headerFinalized = true, deferredDisplayReady = false, agentResponseAggregate = false }) {
90
90
  const rowWidth = Math.max(1, Number(columns || 80));
91
91
  const groupCount = Math.max(1, Number(count || 1));
92
92
  const doneCount = Math.max(0, Math.min(groupCount, Number(completedCount || (result == null ? 0 : groupCount))));
@@ -138,6 +138,9 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
138
138
  // paint the dot red. Fall back to 0 (never `isError`) when the engine did not
139
139
  // supply a call-error count so a result failure can't leak into the dot.
140
140
  const callFailedCount = clampFailureCount(callErrorCount, groupCount, false);
141
+ // Shell command-exits (ran, non-zero exit). Counted separately so the dot
142
+ // paints the neutral warning "Exit" color instead of red or green success.
143
+ const exitFailedCount = clampFailureCount(exitErrorCount, groupCount, false);
141
144
  const displayGroupCount = groupCount;
142
145
  const displayCategories = normalizeCountMap(categories || {});
143
146
  // In the DONE state, count only successful calls: error-terminated calls are
@@ -212,7 +215,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
212
215
  const aggregateTerminalStatus = pending
213
216
  ? 'running'
214
217
  : (resultTerminalStatus(rt) || (isError || failedCount > 0 ? 'failed' : 'completed'));
215
- const dotColor = toolStatusColor({ pending, groupCount, callFailedCount, terminalStatus: aggregateTerminalStatus });
218
+ const dotColor = toolStatusColor({ pending, groupCount, callFailedCount, exitFailedCount, terminalStatus: aggregateTerminalStatus });
216
219
  const dotText = pending && !blinkOn ? ' ' : TURN_MARKER;
217
220
  const gutter = 2;
218
221
  const showHeaderExpandHint = hasRawResult;
@@ -310,7 +313,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
310
313
  const firstResultLine = hasDisplayResult ? String(lines[0] ?? '') : '';
311
314
  const firstResultLineClipped = hasDisplayBody && stringWidth(firstResultLine) > maxResultChars;
312
315
  const hasHiddenDetail = !pending && hasDisplayBody && (totalLines > 1 || firstResultLineClipped || Boolean(resultSummary));
313
- const shellStatus = isShellSurface ? shellDisplayStatus({ pending, failedCount, isError, result: displayedResultText }) : '';
316
+ const shellStatus = isShellSurface ? shellDisplayStatus({ pending, failedCount, exitFailedCount, isError, result: displayedResultText }) : '';
314
317
  const shellElapsed = isShellSurface ? (shellResultElapsed(displayedResultText) || elapsed) : '';
315
318
  const backgroundElapsed = backgroundMeta
316
319
  ? backgroundTaskElapsed(backgroundMeta, elapsed)
@@ -384,7 +387,9 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
384
387
  const showRawResult = expanded && (hasDisplayBody || hasRawResult)
385
388
  && (!isBackgroundMetadataResult || hasRawResult);
386
389
  const detailLines = showRawResult
387
- ? (hasDisplayBody ? lines : (rawRt ? stripLeadingStatusMarkerLines(rawRt.split('\n')) : []))
390
+ ? (agentResponseAggregate && hasRawResult
391
+ ? stripLeadingStatusMarkerLines(rawRt.split('\n'))
392
+ : (hasDisplayBody ? lines : (rawRt ? stripLeadingStatusMarkerLines(rawRt.split('\n')) : [])))
388
393
  : (collapsedDetail ? [collapsedDetail] : []);
389
394
  const isPendingPlaceholderDetail = !showRawResult && Boolean(pendingDetailPlaceholder);
390
395
  const detailColor = isPendingPlaceholderDetail ? theme.subtle : theme.text;
@@ -426,7 +431,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
426
431
  const keepAgentDetail = (isError || agentFailureText) && !(agentHeaderFailure && !agentSurfaceBrief);
427
432
  visibleDetailLines = keepAgentDetail ? [agentDetailLine] : [];
428
433
  }
429
- const finalStatusColor = toolStatusColor({ pending, groupCount, callFailedCount, terminalStatus });
434
+ const finalStatusColor = toolStatusColor({ pending, groupCount, callFailedCount, exitFailedCount, terminalStatus });
430
435
  const dotColor = finalStatusColor;
431
436
  // Agent surface cards use directional markers: `←` for requests going OUT
432
437
  // (spawn/send/etc.) and `→` for the response coming back IN. Background
@@ -446,7 +451,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
446
451
  const markerText = isDirectionalMarker ? `${markerGlyph} ` : markerGlyph;
447
452
  const dotText = pending && !blinkOn ? ' ' : markerText;
448
453
  let labelText;
449
- if (isAgentResponse) labelText = agentResponseTitle(parsedArgs);
454
+ if (isAgentResponse) labelText = agentResponseTitle(parsedArgs, displayGroupCount);
450
455
  else if (isBackgroundResponse) labelText = backgroundTaskResultTitle(normalizedName, backgroundMeta || parsedArgs);
451
456
  else if (isBackgroundMetadataResult) labelText = backgroundTaskActionTitle(normalizedName, backgroundMeta);
452
457
  else if (isShellSurface) labelText = shellHeader(shellStatus, displayGroupCount);
@@ -77,7 +77,7 @@ export const Item = React.memo(function Item({ item, prevKind, columns, toolOutp
77
77
  node = <ToolHookDenialCard item={item} columns={columns} />;
78
78
  break;
79
79
  }
80
- node = <ToolExecution name={item.name} args={item.args} result={item.result} rawResult={item.rawResult} isError={item.isError} errorCount={item.errorCount} callErrorCount={item.callErrorCount} expanded={toolOutputExpanded || item.expanded} columns={columns} attached={false} count={item.count} completedCount={item.completedCount} startedAt={item.startedAt} completedAt={item.completedAt} aggregate={item.aggregate} categories={item.categories} doneCategories={item.doneCategories} headerFinalized={item.headerFinalized} deferredDisplayReady={item.deferredDisplayReady} />;
80
+ node = <ToolExecution name={item.name} args={item.args} result={item.result} rawResult={item.rawResult} isError={item.isError} errorCount={item.errorCount} callErrorCount={item.callErrorCount} exitErrorCount={item.exitErrorCount} expanded={toolOutputExpanded || item.expanded} columns={columns} attached={false} count={item.count} completedCount={item.completedCount} startedAt={item.startedAt} completedAt={item.completedAt} aggregate={item.aggregate} categories={item.categories} doneCategories={item.doneCategories} headerFinalized={item.headerFinalized} deferredDisplayReady={item.deferredDisplayReady} agentResponseAggregate={item.agentResponseAggregate} />;
81
81
  break;
82
82
  }
83
83
  case 'notice':
@@ -21,11 +21,18 @@ export function isShellTool(normalizedName, label = '') {
21
21
  return n === 'shell' || n === 'bash' || n === 'bash_session' || n === 'shell_command' || n === 'job_wait' || l === 'run';
22
22
  }
23
23
 
24
- export function shellDisplayStatus({ pending = false, failedCount = 0, isError = false, result = '' } = {}) {
24
+ export function shellDisplayStatus({ pending = false, failedCount = 0, exitFailedCount = 0, isError = false, result = '' } = {}) {
25
25
  const status = shellResultStatus(result);
26
26
  if (pending || /^(running|pending|queued)$/.test(status)) return 'running';
27
27
  if (/^cancel/.test(status)) return 'cancelled';
28
- if (/^(failed|error|killed|timeout)$/.test(status) || isError || failedCount > 0) return 'failed';
28
+ if (/^(failed|error|killed|timeout)$/.test(status)) return 'failed';
29
+ // A command that RAN but exited non-zero is a command-exit, not a real
30
+ // failure: render the neutral "Exit" state unless there is ALSO a real
31
+ // tool-call/result failure in the group.
32
+ const realFailed = Math.max(0, Number(failedCount) - Number(exitFailedCount));
33
+ if (realFailed > 0) return 'failed';
34
+ if (Number(exitFailedCount) > 0) return 'exit';
35
+ if (isError || failedCount > 0) return 'failed';
29
36
  return 'completed';
30
37
  }
31
38
 
@@ -98,7 +105,9 @@ export function joinActionAgent(action, agent) {
98
105
  return agent ? `${action} ${agent}` : action;
99
106
  }
100
107
 
101
- export function agentResponseTitle(args) {
108
+ export function agentResponseTitle(args, count = 1) {
109
+ const total = Math.max(1, Number(count) || 1);
110
+ if (total > 1) return `Responses ${total} agents`;
102
111
  const name = titleizeAgentName(args?.agent || args?.subagent_type || args?.name || '');
103
112
  // The agent + model identify the responder; the response summary itself
104
113
  // is hidden in the collapsed card (ctrl+o expand still shows the full body).
@@ -392,14 +401,19 @@ export function clampFailureCount(errorCount, groupCount, isError) {
392
401
  // cancelled -> theme.warning
393
402
  // The RED/orange failure color is driven ONLY by real tool-call errors
394
403
  // (`callFailedCount` — backend isError / error toolKind), NOT by command/result
395
- // failures like a shell non-zero exit or a `[status: failed]` result. Those
396
- // keep the card's L2 detail showing "Failed" but leave the dot on the success
397
- // color. `terminalStatus` is still consulted so a cancelled card stays warning.
398
- export function toolStatusColor({ pending, groupCount, callFailedCount = 0, terminalStatus = '' }) {
404
+ // failures like a `[status: failed]` result. A shell command-exit
405
+ // (`exitFailedCount`) is its own distinct neutral state: warning color, never
406
+ // red. `terminalStatus` is still consulted so a cancelled card stays warning.
407
+ export function toolStatusColor({ pending, groupCount, callFailedCount = 0, exitFailedCount = 0, terminalStatus = '' }) {
399
408
  if (pending) return theme.text;
400
409
  const status = normalizeTerminalStatus(terminalStatus);
401
410
  if (status === 'cancelled') return theme.warning;
402
- if (callFailedCount <= 0) return theme.success;
403
- if (groupCount > 1 && callFailedCount < groupCount) return theme.mixdogOrange || theme.warning;
404
- return theme.error;
411
+ if (status === 'denied') return theme.warning;
412
+ if (callFailedCount > 0) {
413
+ if (groupCount > 1 && callFailedCount < groupCount) return theme.mixdogOrange || theme.warning;
414
+ return theme.error;
415
+ }
416
+ // Command-exit(s) with no real tool-call failure: distinct warning state.
417
+ if (exitFailedCount > 0) return theme.warning;
418
+ return theme.success;
405
419
  }
@@ -8,6 +8,10 @@ import { displayWidth } from '../../display-width.mjs';
8
8
  import { theme } from '../../theme.mjs';
9
9
  import { formatElapsed } from '../../time-format.mjs';
10
10
  import stripAnsi from 'strip-ansi';
11
+ import {
12
+ normalizeToolTerminalStatus,
13
+ toolResultTerminalStatus,
14
+ } from '../../../runtime/shared/tool-status.mjs';
11
15
 
12
16
  export const MIN_RESULT_LINE_CHARS = 24;
13
17
  // Hard cap for the collapsed result detail row (the second line under the ⎿
@@ -108,37 +112,24 @@ export function shellResultStatus(value) {
108
112
  }
109
113
 
110
114
  export function normalizeTerminalStatus(value) {
111
- const raw = String(value || '').trim().toLowerCase();
112
- if (!raw) return '';
113
- if (/^(running|pending|queued|in_progress|in-progress)$/.test(raw)) return 'running';
114
- if (/^(completed|complete|done|success|succeeded|ok)$/.test(raw)) return 'completed';
115
- if (/^(failed|fail|error|errored|timeout|timed_out|killed)$/.test(raw)) return 'failed';
116
- if (/^(cancelled|canceled|cancel)$/.test(raw)) return 'cancelled';
117
- return '';
115
+ return normalizeToolTerminalStatus(value);
118
116
  }
119
117
 
120
118
  export function displayTerminalStatus(value) {
119
+ // 'exit' is a shell-only pseudo-status (command RAN but exited non-zero); it
120
+ // is intentionally NOT a normalized terminal status so it never colors red.
121
+ if (String(value || '').trim().toLowerCase() === 'exit') return 'Exit';
121
122
  const status = normalizeTerminalStatus(value);
122
123
  if (status === 'running') return 'Running';
123
124
  if (status === 'completed') return 'Finished';
124
125
  if (status === 'failed') return 'Failed';
125
126
  if (status === 'cancelled') return 'Cancelled';
127
+ if (status === 'denied') return 'Denied';
126
128
  return '';
127
129
  }
128
130
 
129
131
  export function resultTerminalStatus(value) {
130
- const text = String(value || '');
131
- const tagged = text.match(/<status[^>]*>([\s\S]*?)<\/status>/i)?.[1]?.trim();
132
- if (tagged) return normalizeTerminalStatus(tagged);
133
- const bracketed = text.match(/^\[status:\s*([^\]]*)\]/mi)?.[1]?.trim();
134
- if (bracketed) return normalizeTerminalStatus(bracketed);
135
- // Loose inline `status: x` / `state: x` matches are a last-resort fallback —
136
- // prefer the engine-controlled `<status>` tag or `[status: …]` marker above.
137
- // A loose match can false-positive on prose that happens to start with
138
- // "status:" (rare, but shellResultStatus below already owns real shell
139
- // output parsing; this fallback stays narrow and unchanged in behavior).
140
- const inline = text.match(/^(?:status|state):\s*([^\s·,;]+)/mi)?.[1]?.trim();
141
- return normalizeTerminalStatus(inline);
132
+ return toolResultTerminalStatus(value);
142
133
  }
143
134
 
144
135
  const LEADING_STATUS_MARKER_LINE_RE = /^\[status:\s*[^\]]*\]\s*$/i;