mixdog 0.9.95 → 0.9.96

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 (178) hide show
  1. package/LICENSES/Apache-2.0.txt +201 -0
  2. package/LICENSES/MIT.txt +56 -0
  3. package/LICENSES/codex-NOTICE.txt +6 -0
  4. package/NOTICE.md +46 -9
  5. package/package.json +12 -9
  6. package/scripts/.tmp-coverage-report.mjs +96 -0
  7. package/scripts/build-tui.mjs +17 -1
  8. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  9. package/src/defaults/mixdog-config.template.json +5 -8
  10. package/src/rules/agent/30-explorer.md +7 -4
  11. package/src/rules/shared/01-tool.md +20 -29
  12. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  13. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  14. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  16. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  17. package/src/runtime/agent/orchestrator/mcp/client.mjs +26 -11
  18. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  19. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  20. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  21. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  22. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  23. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  25. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  26. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  28. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  29. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  30. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  31. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  32. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +28 -4
  34. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +4 -33
  35. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  36. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  37. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  38. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -27
  39. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -10
  40. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +5 -0
  41. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  42. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  44. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  45. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  46. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  47. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  48. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +12 -0
  49. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -33
  50. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  51. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  53. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  54. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +118 -18
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  56. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  58. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  59. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -26
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +3 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -23
  63. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +23 -9
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  66. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  67. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  70. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  72. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +87 -15
  73. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +6 -15
  74. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  76. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  77. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +29 -1
  78. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  79. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  80. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +10 -10
  81. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -8
  82. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  83. package/src/runtime/channels/lib/config.mjs +2 -2
  84. package/src/runtime/channels/lib/inbound-handler.mjs +1 -1
  85. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  86. package/src/runtime/memory/index.mjs +5 -38
  87. package/src/runtime/memory/lib/agent-ipc.mjs +145 -81
  88. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  89. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  90. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +12 -33
  91. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  92. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  93. package/src/runtime/memory/lib/http-router.mjs +8 -0
  94. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  95. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  96. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  97. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  98. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  99. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  100. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  101. package/src/runtime/memory/tool-defs.mjs +1 -5
  102. package/src/runtime/search/tool-defs.mjs +2 -2
  103. package/src/runtime/shared/agent-route-config.mjs +119 -0
  104. package/src/runtime/shared/atomic-file.mjs +80 -4
  105. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  106. package/src/runtime/shared/child-guardian.mjs +204 -108
  107. package/src/runtime/shared/child-spawn-gate.mjs +60 -31
  108. package/src/runtime/shared/config.mjs +130 -44
  109. package/src/runtime/shared/resource-admission.mjs +146 -97
  110. package/src/runtime/shared/stream-progress.mjs +6 -0
  111. package/src/runtime/shared/tool-surface.mjs +0 -5
  112. package/src/runtime/shared/turn-snapshot.mjs +31 -5
  113. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  114. package/src/session-runtime/config-helpers.mjs +39 -37
  115. package/src/session-runtime/model-route-api.mjs +7 -5
  116. package/src/session-runtime/provider-auth-api.mjs +0 -7
  117. package/src/session-runtime/runtime-core.mjs +11 -11
  118. package/src/session-runtime/runtime-tunables.mjs +4 -4
  119. package/src/session-runtime/session-lifecycle.mjs +9 -2
  120. package/src/session-runtime/session-turn-api.mjs +41 -9
  121. package/src/session-runtime/settings-api.mjs +5 -17
  122. package/src/session-runtime/tool-defs.mjs +3 -3
  123. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  124. package/src/session-runtime/workflow.mjs +14 -24
  125. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  126. package/src/standalone/agent-tool/helpers.mjs +2 -2
  127. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  128. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  129. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  130. package/src/standalone/backend-daemon.mjs +97 -36
  131. package/src/standalone/channel-admin.mjs +12 -12
  132. package/src/standalone/channel-daemon-client.mjs +17 -1
  133. package/src/standalone/channel-daemon-transport.mjs +90 -8
  134. package/src/standalone/engine-daemon-client.mjs +380 -205
  135. package/src/standalone/engine-daemon-protocol.mjs +3 -4
  136. package/src/standalone/engine-daemon-service.mjs +378 -134
  137. package/src/standalone/engine-daemon-transport.mjs +268 -77
  138. package/src/standalone/explore-tool.mjs +187 -70
  139. package/src/standalone/fair-call-scheduler.mjs +264 -0
  140. package/src/standalone/session-protocol.mjs +188 -0
  141. package/src/tui/App.jsx +30 -17
  142. package/src/tui/app/app-view.jsx +5 -2
  143. package/src/tui/app/core-memory-picker.mjs +19 -19
  144. package/src/tui/app/doctor.mjs +5 -11
  145. package/src/tui/app/message-selector.mjs +103 -0
  146. package/src/tui/app/onboarding-steps.mjs +1 -2
  147. package/src/tui/app/project-picker.mjs +80 -56
  148. package/src/tui/app/prompt-submit.mjs +46 -41
  149. package/src/tui/app/resume-picker.mjs +2 -2
  150. package/src/tui/app/route-pickers.mjs +3 -1
  151. package/src/tui/app/settings-picker.mjs +6 -8
  152. package/src/tui/app/transcript-window.mjs +38 -0
  153. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  154. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  155. package/src/tui/app/use-prompt-hint.mjs +3 -2
  156. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  157. package/src/tui/app/use-transcript-scroll.mjs +9 -2
  158. package/src/tui/app/use-transcript-window.mjs +50 -17
  159. package/src/tui/components/PromptInput.jsx +71 -38
  160. package/src/tui/components/Spinner.jsx +1 -1
  161. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  162. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  163. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  164. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  165. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  166. package/src/tui/dist/index.mjs +920 -1592
  167. package/src/tui/engine/live-share.mjs +34 -0
  168. package/src/tui/engine/session-api-ext.mjs +1 -4
  169. package/src/tui/engine/session-api.mjs +103 -33
  170. package/src/tui/engine/session-flow.mjs +7 -1
  171. package/src/tui/engine/tool-card-results.mjs +4 -2
  172. package/src/tui/engine/tool-result-text.mjs +10 -0
  173. package/src/tui/engine/turn.mjs +9 -69
  174. package/src/tui/engine-local-session.mjs +47 -31
  175. package/src/tui/index.jsx +1 -2
  176. package/src/tui/spinner-meta.mjs +4 -4
  177. package/src/tui/spinner-verbs.mjs +51 -3
  178. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
@@ -5,13 +5,19 @@
5
5
  // pass), and unrecoverable errors throw. Behavior identical to the inline
6
6
  // try/catch it replaced.
7
7
  import { appendAgentTrace } from '../agent-trace.mjs';
8
- import { classifyError, isContextOverflowError } from '../providers/retry-classifier.mjs';
8
+ import {
9
+ classifyError,
10
+ isContextOverflowError,
11
+ isNonTerminalStreamClose,
12
+ jitterDelayMs,
13
+ } from '../providers/retry-classifier.mjs';
9
14
  import { setTimeout as sleepMs } from 'timers/promises';
10
15
  import { readStreamOutcome } from '../providers/lib/stream-outcome.mjs';
11
16
  import { resolveWorkerCompactPolicy } from './loop/compact-policy.mjs';
12
17
  import { agentContextOverflowError } from './loop/context-overflow.mjs';
13
18
  import { estimateMessagesTokensSafe } from './loop/compact-debug.mjs';
14
19
  import { isOutputLimitStopReason } from './loop/termination.mjs';
20
+ import { isVisibleStreamProgress } from '../../../shared/stream-progress.mjs';
15
21
 
16
22
  function normalizedIncompleteUsage(raw) {
17
23
  if (!raw || typeof raw !== 'object') return undefined;
@@ -45,9 +51,35 @@ function normalizedIncompleteUsage(raw) {
45
51
  // the identical request is side-effect-free, so wait out the blip and retry
46
52
  // the send at the loop level. Two attempts, 5s/15s — combined with the
47
53
  // provider envelope this covers ~50s outages before failing honestly.
48
- const TRANSPORT_RETRY_BACKOFF_MS = Object.freeze([5_000, 15_000]);
54
+ // Env-overridable (comma-separated ms) so tests can drive the ladder without
55
+ // real waits and an operator can widen the window for a flaky uplink; the
56
+ // LENGTH of the list is the retry budget, exactly like codex's
57
+ // stream_max_retries + backoff table.
58
+ const TRANSPORT_RETRY_BACKOFF_MS = Object.freeze((() => {
59
+ const raw = process.env.MIXDOG_TRANSPORT_RETRY_BACKOFF_MS;
60
+ if (typeof raw === 'string' && raw.trim()) {
61
+ const parsed = raw.split(',')
62
+ .map((value) => Number(value.trim()))
63
+ .filter((value) => Number.isFinite(value) && value >= 0);
64
+ if (parsed.length) return parsed;
65
+ }
66
+ // Three steps (~50s of outage) instead of two: the provider envelope
67
+ // already covers short blips, so this ladder exists for the long ones.
68
+ // codex allows 5 stream reconnects and cc 10 request retries.
69
+ return [5_000, 15_000, 30_000];
70
+ })());
49
71
  export const TRANSPORT_RETRY_MAX = TRANSPORT_RETRY_BACKOFF_MS.length;
50
72
 
73
+ // codex jitters every backoff by 0.9–1.1 so parallel workers that lose the
74
+ // same uplink do not resume in lockstep; our provider layers already jitter
75
+ // (10% WS / 20% shared), this brings the loop ladder in line.
76
+ const TRANSPORT_RETRY_JITTER_RATIO = 0.1;
77
+
78
+ function transportRetryWaitMs(attemptIndex) {
79
+ const base = TRANSPORT_RETRY_BACKOFF_MS[attemptIndex];
80
+ return jitterDelayMs(base, TRANSPORT_RETRY_JITTER_RATIO);
81
+ }
82
+
51
83
  export async function sendWithRecovery(ctx) {
52
84
  const {
53
85
  provider, messages, model, sendTools, tools, opts,
@@ -56,7 +88,8 @@ export async function sendWithRecovery(ctx) {
56
88
  } = ctx;
57
89
  let response;
58
90
  // Bench-only turn timing (MIXDOG_TURN_TIMING=1): one stderr line per
59
- // provider request — TTFT (first stream delta) and total stream time.
91
+ // provider request — TTFT (first visible model progress) and total stream
92
+ // time. Transport/header and response-created acknowledgements do not count.
60
93
  // Inert unless the env flag is set; used to profile harness vs model
61
94
  // latency in Terminal-Bench runs.
62
95
  const turnT0 = process.env.MIXDOG_TURN_TIMING === '1' ? Date.now() : 0;
@@ -67,7 +100,7 @@ export async function sendWithRecovery(ctx) {
67
100
  timedOpts = {
68
101
  ...(opts || {}),
69
102
  onStreamDelta: (kind) => {
70
- if (!turnFirstDelta) turnFirstDelta = Date.now();
103
+ if (!turnFirstDelta && isVisibleStreamProgress(kind)) turnFirstDelta = Date.now();
71
104
  if (prevDelta) prevDelta(kind);
72
105
  },
73
106
  };
@@ -92,6 +125,22 @@ export async function sendWithRecovery(ctx) {
92
125
  // overflow-retry branch intentionally clears opts.onToolCall, and that
93
126
  // clear must survive the restore.
94
127
  const relayWitness = { textEmitted: false, toolCallsDispatched: 0 };
128
+ // Caller-visible reconnect progress for LOOP-level replays, mirroring the
129
+ // transports' own emitReconnectProgress (openai-oauth-ws). Without it the
130
+ // 5s/15s wait rendered as a frozen turn with no explanation, while codex
131
+ // surfaces "Reconnecting... n/max" through notify_stream_error and cc
132
+ // yields a SystemAPIErrorMessage for the same situation.
133
+ const emitLoopReconnectProgress = (attempt, waitMs, classifier) => {
134
+ try {
135
+ opts?.onStageChange?.('reconnecting', {
136
+ attempt,
137
+ max: TRANSPORT_RETRY_MAX,
138
+ classifier: classifier || null,
139
+ waitMs,
140
+ message: `Reconnecting... ${attempt}/${TRANSPORT_RETRY_MAX}`,
141
+ });
142
+ } catch { /* progress reporting must never break recovery */ }
143
+ };
95
144
  const prevOnTextDelta = typeof opts?.onTextDelta === 'function' ? opts.onTextDelta : null;
96
145
  const prevOnToolCall = typeof opts?.onToolCall === 'function' ? opts.onToolCall : null;
97
146
  const witnessedOnTextDelta = prevOnTextDelta
@@ -116,7 +165,15 @@ export async function sendWithRecovery(ctx) {
116
165
  }
117
166
  try {
118
167
  try {
119
- response = await provider.send(messages, model, sendTools.length ? sendTools : undefined, timedOpts);
168
+ const admittedOpts = timedOpts?.admissionOwner
169
+ ? timedOpts
170
+ : { ...(timedOpts || {}), admissionOwner: sessionId };
171
+ response = await provider.send(
172
+ messages,
173
+ model,
174
+ sendTools.length ? sendTools : undefined,
175
+ admittedOpts,
176
+ );
120
177
  logTurnTiming('ok');
121
178
  } catch (sendErr) {
122
179
  logTurnTiming(`err:${sendErr?.code || sendErr?.name || 'unknown'}`);
@@ -220,7 +277,7 @@ export async function sendWithRecovery(ctx) {
220
277
  transportRetriesUsed < TRANSPORT_RETRY_MAX
221
278
  && await retractExposedTextForReplay()
222
279
  ) {
223
- const waitMs = TRANSPORT_RETRY_BACKOFF_MS[transportRetriesUsed];
280
+ const waitMs = transportRetryWaitMs(transportRetriesUsed);
224
281
  try {
225
282
  process.stderr.write(
226
283
  `[loop] exposed-text stall retracted (sess=${sessionId || 'unknown'} `
@@ -238,6 +295,11 @@ export async function sendWithRecovery(ctx) {
238
295
  partialContentLen: sendErr.partialContent.length,
239
296
  });
240
297
  } catch { /* best-effort */ }
298
+ emitLoopReconnectProgress(
299
+ transportRetriesUsed + 1,
300
+ waitMs,
301
+ outcome.stallObserved ? 'stream_stalled' : 'stream_closed',
302
+ );
241
303
  await sleepMs(waitMs, undefined, signal ? { signal } : undefined);
242
304
  return { action: 'retry_transport' };
243
305
  }
@@ -332,12 +394,23 @@ export async function sendWithRecovery(ctx) {
332
394
  && (
333
395
  (outcome.replaySafe === true && classifyError(sendErr) === 'transient')
334
396
  || (
335
- (classifyError(sendErr) === 'transient' || outcome.stallObserved === true)
397
+ // classifyError reports 'permanent' the moment anything
398
+ // was exposed, so an exposed disconnect could never
399
+ // reach the retraction path: name the transport
400
+ // symptoms directly (stall OR non-terminal close).
401
+ (classifyError(sendErr) === 'transient'
402
+ || outcome.stallObserved === true
403
+ // Same inversion for the truncated-stream class
404
+ // (anthropic message_start-without-message_stop,
405
+ // gemini/compat EOF): classifyError calls it
406
+ // 'transient' only while nothing was exposed.
407
+ || outcome.truncatedStream === true
408
+ || isNonTerminalStreamClose(sendErr))
336
409
  && await retractExposedTextForReplay()
337
410
  )
338
411
  )
339
412
  ) {
340
- const waitMs = TRANSPORT_RETRY_BACKOFF_MS[transportRetriesUsed];
413
+ const waitMs = transportRetryWaitMs(transportRetriesUsed);
341
414
  try {
342
415
  process.stderr.write(
343
416
  `[loop] transient send failure with no observed output (sess=${sessionId || 'unknown'} `
@@ -356,6 +429,11 @@ export async function sendWithRecovery(ctx) {
356
429
  status: sendErr?.status ?? null,
357
430
  });
358
431
  } catch { /* best-effort */ }
432
+ emitLoopReconnectProgress(
433
+ transportRetriesUsed + 1,
434
+ waitMs,
435
+ sendErr?.retryClassifier || sendErr?.midstreamClassifier || sendErr?.code || null,
436
+ );
359
437
  await sleepMs(waitMs, undefined, signal ? { signal } : undefined);
360
438
  return { action: 'retry_transport' };
361
439
  } else
@@ -511,6 +511,18 @@ export function listStoredSessionSummaries(options = {}) {
511
511
  return options.rebuildIfMissing === false ? [] : (scan() ?? indexRows ?? []);
512
512
  }
513
513
 
514
+ /** Exact, fail-closed existence check for a durable session address.
515
+ * Summary indexes and desktop metadata are presentation caches; neither may
516
+ * make a missing sessions/<id>.json record addressable again. */
517
+ export function storedSessionExists(id) {
518
+ const sessionId = String(id || '').trim();
519
+ if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) return false;
520
+ const read = readTextFile(join(dataDir(), 'sessions', `${sessionId}.json`));
521
+ if (read.state !== PROBE_PRESENT) return false;
522
+ const record = readTopLevelLifecycleRecord(read.text);
523
+ return !isLifecycleUnreadable(record) && record.id === sessionId;
524
+ }
525
+
514
526
  /** Read exactly one persisted session for a visible desktop pane. This never
515
527
  * enumerates siblings. Normal reads stay independent of runtime ownership;
516
528
  * interrupted turns conditionally use the same durable reconnect recovery as
@@ -24,7 +24,7 @@ import {
24
24
  import { isInvalidToolArgsMarker, formatInvalidToolArgsResult } from '../providers/openai-compat-stream.mjs';
25
25
  import {
26
26
  _stripMcpPrefix, _isReadTool, _isMutationTool, _isScopedCacheableTool,
27
- _isShellTool, _isOrderedGateSkippable, _intraTurnSig,
27
+ _isShellTool, _intraTurnSig,
28
28
  } from './loop/tool-classify.mjs';
29
29
  import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
30
30
  import { executeTool } from './loop/tool-exec.mjs';
@@ -103,12 +103,6 @@ export async function processToolBatch(ctx) {
103
103
  if (message?.toolCallId) _batchToolResultByCallId.set(message.toolCallId, message);
104
104
  else _batchToolResultsWithoutId.push(message);
105
105
  };
106
- // Ordered-mutation batch gate. Each apply_patch is an eager-dispatch
107
- // barrier: side effects overlap within the current segment, but no
108
- // later segment starts before the loop crosses the patch in model
109
- // order. A failed patch prevents every unstarted later side effect;
110
- // known read-only calls may still complete for diagnostics.
111
- let _orderedMutationFailed = null;
112
106
  for (let callIndex = 0; callIndex < calls.length; callIndex += 1) {
113
107
  const call = calls[callIndex];
114
108
  if (isBuiltinTool(call.name)) {
@@ -202,22 +196,6 @@ export async function processToolBatch(ctx) {
202
196
  continue;
203
197
  }
204
198
  }
205
- // Ordered-mutation skip: an earlier apply_patch in THIS batch failed,
206
- // so this later side-effect call is skipped rather than executed.
207
- // Restore a later patch's full body first so it can be re-issued.
208
- // `skipped` is deliberate: the call never started, so it is neither
209
- // a tool failure nor a cancellation. The earlier patch remains the
210
- // structural failure that drives retry/stop-hook behavior.
211
- if (_orderedMutationFailed && _isOrderedGateSkippable(call.name) && !pending.has(call.id)) {
212
- if (call?.id) restoreToolCallBodyForId(assistantTurnMsg, calls, call.id);
213
- _stageToolResultMessage({
214
- role: 'tool',
215
- content: `[prerequisite-failed] an earlier apply_patch in this same tool batch (call index ${_orderedMutationFailed.index + 1}) failed, so this later \`${call.name}\` was skipped without starting. Re-issue it after resolving the patch failure.`,
216
- toolCallId: call.id,
217
- toolKind: 'skipped',
218
- });
219
- continue;
220
- }
221
199
  if (sessionId) markSessionToolCall(sessionId, call.name, resolveToolSelfDeadlineMs(call.name, call.arguments));
222
200
  let result;
223
201
  let toolStartedAt;
@@ -279,8 +257,8 @@ export async function processToolBatch(ctx) {
279
257
  _executeOk = true;
280
258
  } else {
281
259
  // Fallback for providers that don't stream tool calls early:
282
- // start the whole remaining batch in parallel (full-parallel
283
- // policy; only apply_patch stays in this serial body).
260
+ // start the whole remaining batch in parallel, including
261
+ // apply_patch and shell.
284
262
  if (isParallelDispatchable(call.name)) {
285
263
  startEagerRun(calls, callIndex, _duplicateCallIds);
286
264
  }
@@ -512,14 +490,6 @@ export async function processToolBatch(ctx) {
512
490
  }
513
491
  if (_isMutationTool(call.name)) {
514
492
  epoch.mutation += 1;
515
- // Record the first failed ordered mutation in this batch so any
516
- // later side effect is skipped by the gate at the top of the
517
- // loop. Keyed on exec outcome (not post-processing): a mutation
518
- // whose write succeeded but post-processing threw still landed
519
- // on disk, so it must NOT block subsequent ordered patches.
520
- if ((!_executeOk || _resultKind === 'error') && !_orderedMutationFailed) {
521
- _orderedMutationFailed = { index: callIndex, callId: call.id, name: call.name };
522
- }
523
493
  }
524
494
  // Bash always clears scoped cache UNCONDITIONALLY — a mutating bash
525
495
  // that throws or fails partway can still leave stale find_symbol / grep entries.
@@ -52,11 +52,10 @@ function maybeSweepStaleOffloadDirs() {
52
52
  void sweepStaleOffloadDirs();
53
53
  }
54
54
 
55
- // Per-tool persistence limits mirror reference per-tool maxResultSizeChars
56
- // rather than a single global value: Grep persists at 20k (CC GrepTool), Glob
57
- // and list/find_* at the 50k system default (CC DEFAULT_MAX_RESULT_SIZE_CHARS;
58
- // tighter than CC Glob's 100k by design), and shell/bash_session/task at 30k
59
- // (CC BASH_MAX_OUTPUT_LENGTH default). Read/head/tail/diff stay inline
55
+ // Per-tool persistence limits are per-tool maxResultSizeChars values rather
56
+ // than a single global value: grep persists at 20k, glob and list/find_* at
57
+ // the 50k system default (deliberately tighter than the common 100k), and
58
+ // shell/bash_session/task at 30k. Read/head/tail/diff stay inline
60
59
  // (Infinity) — they are self-bound by FileRead semantics and the upstream
61
60
  // READ_MAX_SIZE_BYTES cap, so persisting to a sidecar to be re-read would be
62
61
  // circular. These values keep context-rich IO tools from turning into "read
@@ -3,7 +3,7 @@ import { getHiddenAgent } from './internal-agents.mjs';
3
3
  const SECOND_MS = 1000;
4
4
  const MIN_PROVIDER_TIMEOUT_MS = 30_000;
5
5
 
6
- const STALL_TICK_MS = 15_000;
6
+ export const STALL_TICK_MS = 15_000;
7
7
  const DEFAULT_STALL_WARN_S = 300;
8
8
  const DEFAULT_STALL_ABORT_S = 600;
9
9
  // First-byte (no-stream-delta) abort for the agent stall watchdog. A wedged
@@ -65,7 +65,7 @@ const MAX_TIMEOUT_MS = Math.max(_envMaxTimeout > 0 ? _envMaxTimeout : 600_000, D
65
65
  // JS setTimeout / PS WaitForExit(ms) are 32-bit: a delay above 2^31-1 wraps and
66
66
  // fires immediately. Hard ceiling (~24.8 days) for an uncapped explicit timeout.
67
67
  const TIMER_MAX_MS = 2_147_483_647;
68
- const IDLE_TIMEOUT_MS = 5 * 60_000;
68
+ const IDLE_TIMEOUT_MS = 2 * 60_000;
69
69
  const MAX_SESSIONS = 10;
70
70
  const STDERR_DRAIN_MS = 25;
71
71
  const STDERR_DRAIN_MAX_MS = 250;
@@ -364,7 +364,6 @@ function _spawnSession(id, initialCwd = process.cwd(), resourceLease = null) {
364
364
  childPid: proc.pid,
365
365
  childGroupPid: proc.pid,
366
366
  label: 'bash-session',
367
- protectHostMemory: true,
368
367
  });
369
368
  proc.stdout.setEncoding('utf-8');
370
369
  proc.stderr.setEncoding('utf-8');
@@ -446,6 +445,7 @@ async function _getOrCreate(sessionId, initialCwd = process.cwd(), opts = {}) {
446
445
  signal: opts.signal || null,
447
446
  label: `persistent:${id}`,
448
447
  dependency: 'detached',
448
+ ownerKey: opts.sessionId || id,
449
449
  });
450
450
  try {
451
451
  entry = _sessions.get(id);
@@ -849,7 +849,40 @@ const GUARDS = {
849
849
  // key instead of failing the call on a field the caller never set.
850
850
  function dropUndefinedArgs(args) {
851
851
  for (const key of Object.keys(args)) {
852
- if (args[key] === undefined) delete args[key];
852
+ // `null` gets the same treatment: it is the only way JSON can express
853
+ // "this optional field was not set", and failing the whole call on it
854
+ // was measured as a recurring, fully recoverable tool error.
855
+ if (args[key] === undefined || args[key] === null) delete args[key];
856
+ }
857
+ }
858
+
859
+ // string | string[] arguments arrive nested ([["a","b"]] from a batching
860
+ // wrapper), padded with null/empty entries, or empty. The intent is
861
+ // unambiguous, so flatten one level, drop the blanks, and remove a key that
862
+ // ends up empty (the tool then uses its default scope) instead of rejecting
863
+ // the call.
864
+ const STRING_LIST_ARG_KEYS = [
865
+ 'path', 'paths', 'root', 'file', 'files',
866
+ 'pattern', 'patterns', 'query', 'regex', 'needle',
867
+ 'glob', 'file_pattern', 'include', 'type', 'symbols',
868
+ ];
869
+ function normalizeStringListArgs(args) {
870
+ for (const key of STRING_LIST_ARG_KEYS) {
871
+ if (!hasOwn(args, key) || !Array.isArray(args[key])) continue;
872
+ const source = args[key];
873
+ // Flatten ONE nesting level and drop blank entries; every other entry
874
+ // type is left untouched so the per-tool guards still see (and coerce
875
+ // or reject) it — `read`'s {path,offset,limit}[] regions must survive
876
+ // this pass verbatim.
877
+ const flat = [];
878
+ for (const entry of source.flat(1)) {
879
+ if (entry === null || entry === undefined) continue;
880
+ if (typeof entry === 'string' && entry.trim().length === 0) continue;
881
+ flat.push(entry);
882
+ }
883
+ if (flat.length === source.length && flat.every((entry, idx) => entry === source[idx])) continue;
884
+ if (flat.length === 0) delete args[key];
885
+ else args[key] = flat;
853
886
  }
854
887
  }
855
888
 
@@ -863,6 +896,7 @@ export function validateBuiltinArgs(toolName, args) {
863
896
  return `Error: ${toolName} arguments must be an object (got ${describeType(args)})`;
864
897
  }
865
898
  dropUndefinedArgs(args);
899
+ normalizeStringListArgs(args);
866
900
  if (toolName === 'grep') applyGrepContextLeadPolicy(args);
867
901
  try {
868
902
  return guard(args) || null;
@@ -1,4 +1,9 @@
1
1
  import { getAbortSignalForSession } from '../../session/abort-lookup.mjs';
2
+ import { unlinkSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join as pathJoin } from 'node:path';
5
+ import { isLegitimateShellExit } from '../../session/result-classification.mjs';
6
+ import { makeToolEnvelope } from '../../session/tool-envelope.mjs';
2
7
  import { acquireShellLeaseBounded, execShellCommand, stripAnsi } from '../shell-command.mjs';
3
8
  import { wrapCommandWithSnapshot } from '../shell-snapshot.mjs';
4
9
  import { getDestructiveCommandWarning } from '../destructive-warning.mjs';
@@ -27,7 +32,9 @@ import {
27
32
  detectLongForegroundReason,
28
33
  extractShellApplyPatchInvocation,
29
34
  foregroundLongCommandHint,
35
+ hasPowerShellOnlySyntax,
30
36
  isAutobackgroundingAllowed,
37
+ planInlineScriptHoist,
31
38
  preflightPowerShellHygiene,
32
39
  shellSplitSegments,
33
40
  shellSplitPipelineSegments,
@@ -89,10 +96,12 @@ export function _trackedDriftNoteAfter(_scope, _pre) {
89
96
 
90
97
  // Search-style commands and `git diff --exit-code` use exit 1 as a SIGNAL
91
98
  // (no match / has diff), not a failure. Benign ONLY when exitCode===1, no
92
- // signal, stderr blank, AND the command is a SINGLE pipeline (no ;/&&/||, so
93
- // exit 1's origin is unambiguous — a mixed chain stays Error). Quote/comment
94
- // aware via the shared shell tokenizers, so quoted/commented `;` `|` `grep`
95
- // can never masquerade as a connector/command and hide a real failure.
99
+ // signal, stderr blank, AND the exit status provably comes from a search-style
100
+ // stage: the LAST segment of a `;`/`&&` chain (its status IS the chain's) whose
101
+ // last pipeline stage is a search head. `||` chains stay ambiguous (either
102
+ // branch can supply the status) and stay Error. Quote/comment aware via the
103
+ // shared shell tokenizers, so quoted/commented `;` `|` `grep` can never
104
+ // masquerade as a connector/command and hide a real failure.
96
105
  const _SEARCH_HEADS = new Set(['select-string', 'sls', 'grep', 'egrep', 'fgrep', 'findstr']);
97
106
  const _GIT_GLOBAL_VALUE_OPTS = new Set(['-c', '-C', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env']);
98
107
  // Command/process/subshell substitution or a backslash/backtick-escaped pipe
@@ -120,9 +129,11 @@ export function _isBenignSearchExitOne(command, exitCode, signal, stderr) {
120
129
  const text = _stripShellComment(String(command || ''));
121
130
  if (_AMBIGUOUS_SYNTAX.test(text)) return false; // subshell/subst/escaped pipe → ambiguous
122
131
  const segments = shellSplitSegments(text);
123
- if (segments.length !== 1) return false; // ;/&&/|| chain → ambiguous, stay Error
124
- const stages = shellSplitPipelineSegments(segments[0]);
125
- const last = stages[stages.length - 1] || segments[0];
132
+ if (segments.length === 0) return false;
133
+ if (segments.length > 1 && /\|\|/.test(text)) return false; // || → which branch exited 1?
134
+ const lastSegment = segments[segments.length - 1];
135
+ const stages = shellSplitPipelineSegments(lastSegment);
136
+ const last = stages[stages.length - 1] || lastSegment;
126
137
  const raw = shellTokenize(last);
127
138
  if (!raw) return false; // unbalanced quotes
128
139
  const tokens = stripShellProbeWrappers(raw);
@@ -208,6 +219,14 @@ export function formatShellToolFailure(message) {
208
219
  return `Error: [shell-tool-failed] ${text}`;
209
220
  }
210
221
 
222
+ // A legitimate non-zero exit keeps its `[exit code: N]` marker but must not be
223
+ // CLASSIFIED as a failure downstream — the marker alone would read as an error
224
+ // to classifyResultKind. The explicit-success envelope is the documented
225
+ // channel for output that looks like an error and is not one.
226
+ function _finalizeShellResult(legitExit, text) {
227
+ return legitExit ? makeToolEnvelope(text, [], { explicitSuccess: true }) : text;
228
+ }
229
+
211
230
  export function _shellFailureStatus(result, timeout) {
212
231
  // Prefer the signal reported by the process. `killed` is only a fallback
213
232
  // for platforms (notably taskkill on Windows) that close without one.
@@ -324,7 +343,7 @@ export async function executeBashTool(args, workDir, options = {}) {
324
343
  // null spec means it is genuinely not installed — surface a clear error with
325
344
  // NO silent fallback to the other shell.
326
345
  const shellKind = args.shell === 'bash' || args.shell === 'powershell' ? args.shell : 'default';
327
- const resolvedSpec = resolveShellFor(shellKind);
346
+ let resolvedSpec = resolveShellFor(shellKind);
328
347
  if (!resolvedSpec) {
329
348
  if (shellKind === 'bash') {
330
349
  return formatShellToolFailure("Git Bash not found — install Git for Windows or omit shell:'bash'.");
@@ -351,13 +370,52 @@ export async function executeBashTool(args, workDir, options = {}) {
351
370
  shellType: resolvedSpec.shellType,
352
371
  shellName: resolvedSpec.shell,
353
372
  });
354
- if (psHygiene.block) return formatShellToolFailure(psHygiene.block);
355
- command = psHygiene.command;
373
+ let shellRescueNote = '';
374
+ if (psHygiene.block) {
375
+ // Auto-rescue: the block fired ONLY because the command is written in
376
+ // bash (unix filter heads, `&&`) and it carries no PowerShell-only
377
+ // construct — running it in Git Bash is exactly what the caller meant,
378
+ // byte-for-byte. A mixed command ($env:, cmdlets, `2>$null`) or a
379
+ // PowerShell-specific violation stays a hard block, and an explicit
380
+ // shell:'powershell' is never overridden.
381
+ const bashSpec = psHygiene.bashOnly
382
+ && shellKind === 'default'
383
+ && !hasPowerShellOnlySyntax(psHygiene.original ?? command)
384
+ ? resolveShellFor('bash')
385
+ : null;
386
+ if (!bashSpec) return formatShellToolFailure(psHygiene.block);
387
+ // The MSYS rewrite targets PowerShell; bash gets the original text.
388
+ command = psHygiene.original ?? command;
389
+ resolvedSpec = bashSpec;
390
+ shellRescueNote = "note: bash-only syntax on a PowerShell host — ran it in Git Bash (pass shell:'bash' to make that explicit).";
391
+ } else {
392
+ command = psHygiene.command;
393
+ }
356
394
 
357
395
  const _execPolicyBlock = checkExecPolicyMessage(command);
358
396
  if (_execPolicyBlock) {
359
397
  return formatShellToolFailure(_execPolicyBlock);
360
398
  }
399
+ // Inline-script hoisting (sync runs only — a background job would outlive
400
+ // the temp file). The body is written verbatim and the invocation becomes a
401
+ // file run, so the host shell never has to carry the script through its
402
+ // quoting layer. planInlineScriptHoist refuses every case where file
403
+ // semantics would differ, so this is a transport change only.
404
+ let _inlineHoistPath = null;
405
+ if (!runInBackground && args.persistent !== true) {
406
+ const hoist = planInlineScriptHoist(command);
407
+ if (hoist) {
408
+ try {
409
+ const file = pathJoin(
410
+ tmpdir(),
411
+ `mixdog-inline-${process.pid}-${Date.now().toString(36)}${hoist.extension}`,
412
+ );
413
+ writeFileSync(file, hoist.body, 'utf8');
414
+ _inlineHoistPath = file;
415
+ command = hoist.replace(file.replace(/\\/g, '/'));
416
+ } catch { _inlineHoistPath = null; }
417
+ }
418
+ }
361
419
 
362
420
  // Sleep-chain auto-promotion: a leading `sleep N && …` / `Start-Sleep N; …`
363
421
  // used to be DENIED preflight (CC detectBlockedSleepPattern parity).
@@ -456,11 +514,30 @@ export async function executeBashTool(args, workDir, options = {}) {
456
514
  // the value stays a soft hint clamped below `timeout` so the hard ceiling
457
515
  // remains a separate, later bound. Never applies to run_in_background
458
516
  // (already detached) or persistent sessions (handled far above).
459
- const _autoBgEnvMs = Number(process.env.MIXDOG_SHELL_AUTO_BACKGROUND_MS);
460
- const DEFAULT_AUTO_BACKGROUND_MS = Number.isFinite(_autoBgEnvMs) && _autoBgEnvMs > 0
517
+ // Promotion threshold (Codex `yield_time_ms` / CC startBackgrounding
518
+ // analogue). Previously 0 = OFF, so every long command held the turn open
519
+ // to the full foreground timeout and, at the cap, produced a wasted wait.
520
+ // 30 s keeps ordinary builds/tests inline (measured shell p90 ~ 18 s) while
521
+ // detaching the long tail (measured: 111 calls/day above 30 s, 30 of them
522
+ // burning the entire 120 s cap) as a tracked job with partial output.
523
+ // MIXDOG_SHELL_AUTO_BACKGROUND_MS overrides; an explicit 0 disables.
524
+ const _autoBgEnvRaw = process.env.MIXDOG_SHELL_AUTO_BACKGROUND_MS;
525
+ const _autoBgEnvMs = Number(_autoBgEnvRaw);
526
+ const DEFAULT_AUTO_BACKGROUND_MS = (_autoBgEnvRaw != null && String(_autoBgEnvRaw).trim() !== ''
527
+ && Number.isFinite(_autoBgEnvMs) && _autoBgEnvMs >= 0)
461
528
  ? Math.floor(_autoBgEnvMs)
462
- : 0;
463
- const autoBackgroundMs = (runInBackground || DEFAULT_AUTO_BACKGROUND_MS <= 0)
529
+ : 30_000;
530
+ // Gate on backgroundOnTimeout rather than just `runInBackground`: it already
531
+ // encodes "promotion is available for this command" (not detached, background
532
+ // tasks enabled, command shape allows it), so the soft threshold can never
533
+ // promote something the hard timeout would refuse to.
534
+ // An EXPLICIT caller timeout is a deliberate "I intend to wait this long"
535
+ // (a long build/test/install), so the soft threshold never overrides it —
536
+ // the call stays inline until its own deadline, where backgroundOnTimeout
537
+ // still promotes instead of killing. Auto-promotion therefore only applies
538
+ // to the OMITTED-timeout default, which is exactly the case where nobody
539
+ // declared how long the command should be allowed to hold the turn.
540
+ const autoBackgroundMs = (!backgroundOnTimeout || hasExplicitTimeout || DEFAULT_AUTO_BACKGROUND_MS <= 0)
464
541
  ? 0
465
542
  : Math.min(DEFAULT_AUTO_BACKGROUND_MS, timeout);
466
543
 
@@ -507,6 +584,7 @@ export async function executeBashTool(args, workDir, options = {}) {
507
584
  abortSignal: combinedAsyncAbort.signal,
508
585
  label: String(command).replace(/\s+/g, ' ').slice(0, 120),
509
586
  dependency: 'detached',
587
+ ownerKey: options?.callerSessionId || options?.sessionId || null,
510
588
  });
511
589
  if (combinedAsyncAbort.signal?.aborted) {
512
590
  throw combinedAsyncAbort.signal.reason || new Error('shell background task cancelled before spawn');
@@ -704,7 +782,17 @@ export async function executeBashTool(args, workDir, options = {}) {
704
782
  const failureStatus = _shellFailureStatus(result, timeout);
705
783
  const { signal, exitCode, shellToolFailed } = failureStatus;
706
784
  const benignExitOne = _isBenignSearchExitOne(command, exitCode, signal, stderr);
707
- const shellRunFailed = !shellToolFailed && (!!signal || (exitCode !== 0 && exitCode !== null && !benignExitOne));
785
+ // Legitimate non-zero exit: the command printed its result and ended
786
+ // with 1 without writing a single byte to stderr (probes, reports,
787
+ // `git diff --exit-code`-shaped checks). It keeps its `[exit code: 1]`
788
+ // marker so the caller sees the code, but it is NOT framed as a tool
789
+ // error and NOT classified as a failure — measured as the dominant
790
+ // shape among non-zero exits.
791
+ const legitExit = !shellToolFailed
792
+ && !benignExitOne
793
+ && isLegitimateShellExit({ exitCode, signal, stdout, stderr });
794
+ const shellRunFailed = !shellToolFailed
795
+ && (!!signal || (exitCode !== 0 && exitCode !== null && !benignExitOne && !legitExit));
708
796
  const isReallyErrored = shellToolFailed || shellRunFailed;
709
797
  // Filter-swallow rescue: the tee file is ALWAYS consumed (deleted)
710
798
  // here; its tail is attached only when the run failed with an empty
@@ -729,8 +817,16 @@ export async function executeBashTool(args, workDir, options = {}) {
729
817
  const statusDetail = failureStatus.statusDetail;
730
818
  const statusMarker = shellToolFailed
731
819
  ? `[shell-tool-failed] ${statusDetail}`
732
- : (shellRunFailed ? `[shell-run-failed] ${statusDetail}` : '');
820
+ : (shellRunFailed ? `[shell-run-failed] ${statusDetail}` : (legitExit ? statusDetail : ''));
733
821
  const errorPrefix = isReallyErrored ? 'Error: ' : '';
822
+ // Three outcomes, three labels: a TOOL error (`[shell-tool-failed]`),
823
+ // a command that RAN AND FAILED (`Error: [shell-run-failed]`), and a
824
+ // command that RAN TO COMPLETION and merely ended non-zero. The last
825
+ // one keeps its exit code but says so in words, so neither the model
826
+ // nor the failure log treats a finished report as a failure.
827
+ const completionNote = legitExit
828
+ ? '\n[completed: the command finished and wrote nothing to stderr; the exit code is its own report, not a tool failure]'
829
+ : '';
734
830
  if (mergeStderr) {
735
831
  // Post-exit concatenation. True chunk-level interleaving would
736
832
  // require shell-level `2>&1` redirection (bash) or `*>&1`
@@ -740,7 +836,7 @@ export async function executeBashTool(args, workDir, options = {}) {
740
836
  // cross-stream interleaving. Acceptable for most diagnostic
741
837
  // outputs; flag in shell-command if exact interleaving is required.
742
838
  const merged = stdout + stderr;
743
- if (statusMarker) return _prependDestructiveWarning(command, errorPrefix + smartMiddleTruncate(`${statusMarker}\n\n${merged || '(no output)'}`) + _rescueNote + _driftNote);
839
+ if (statusMarker) return _finalizeShellResult(legitExit, _prependDestructiveWarning(command, errorPrefix + smartMiddleTruncate(`${statusMarker}${completionNote}\n\n${merged || '(no output)'}`) + _rescueNote + _driftNote));
744
840
  return _prependDestructiveWarning(command, smartMiddleTruncate(merged || '(no output)') + _driftNote);
745
841
  }
746
842
  const truncatedStdout = smartMiddleTruncate(stdout);
@@ -761,13 +857,17 @@ export async function executeBashTool(args, workDir, options = {}) {
761
857
  }
762
858
  const warningBlock = [
763
859
  wmicRewrite?.note || '',
860
+ shellRescueNote,
764
861
  ].filter(Boolean).join('\n');
765
862
  const payload = `${body}${stderrBlock}${spillBlock}${_rescueNote}${_driftNote}`;
766
- if (statusMarker) return _prependDestructiveWarning(command, _composeShellFailure(statusMarker, errorPrefix, warningBlock, payload));
863
+ if (statusMarker) return _finalizeShellResult(legitExit, _prependDestructiveWarning(command, _composeShellFailure(`${statusMarker}${completionNote}`, errorPrefix, warningBlock, payload)));
767
864
  return _prependDestructiveWarning(command, warningBlock ? `${warningBlock}\n${payload}` : payload);
768
865
  }
769
866
  finally {
770
867
  combinedBashAbort?.cleanup?.();
868
+ if (_inlineHoistPath) {
869
+ try { unlinkSync(_inlineHoistPath); } catch { /* best-effort cleanup */ }
870
+ }
771
871
  if (shellEffects.mutationMode === 'paths') {
772
872
  invalidateBuiltinResultCache(shellEffects.paths);
773
873
  markCodeGraphDirtyPaths(shellEffects.paths);