mixdog 0.9.95 → 0.9.97

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 (282) 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/README.md +0 -17
  6. package/package.json +16 -12
  7. package/scripts/.tmp-coverage-report.mjs +96 -0
  8. package/scripts/build-tui.mjs +17 -1
  9. package/scripts/{channel-daemon-stub.mjs → daemon-stub.mjs} +18 -12
  10. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  11. package/src/defaults/mixdog-config.template.json +5 -8
  12. package/src/defaults/skills/setup/SKILL.md +1 -1
  13. package/src/lib/keychain-cjs.cjs +1 -1
  14. package/src/lib/mixdog-debug.cjs +2 -3
  15. package/src/rules/agent/30-explorer.md +7 -4
  16. package/src/rules/lead/01-general.md +1 -2
  17. package/src/rules/lead/lead-tool.md +0 -3
  18. package/src/rules/shared/01-tool.md +21 -29
  19. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  20. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  23. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -13
  24. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  25. package/src/runtime/agent/orchestrator/mcp/client.mjs +104 -41
  26. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  27. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  29. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  30. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  31. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  32. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  33. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  34. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  35. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  37. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +3 -6
  39. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  41. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  42. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  43. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +36 -10
  44. package/src/runtime/agent/orchestrator/session/compact.mjs +1 -1
  45. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +22 -33
  46. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  48. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  49. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +21 -29
  50. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +7 -10
  51. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +45 -25
  52. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -3
  53. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  54. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +12 -6
  55. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  56. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  57. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +38 -2
  58. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  59. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  60. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  61. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  62. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +10 -3
  63. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
  64. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +71 -42
  65. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +4 -33
  66. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  67. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  70. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +116 -26
  71. package/src/runtime/agent/orchestrator/tools/builtin/binary-file.mjs +92 -19
  72. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  73. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  74. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +2 -2
  76. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  77. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +12 -26
  78. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +5 -5
  79. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +83 -33
  80. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +42 -45
  81. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +27 -9
  82. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  84. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  85. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  86. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  87. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  88. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  89. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  90. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +142 -26
  91. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +12 -1
  92. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +47 -19
  93. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -2
  94. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +1 -2
  95. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  96. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  97. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +94 -20
  99. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  100. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  101. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +12 -11
  102. package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
  103. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +63 -9
  104. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  105. package/src/runtime/channels/lib/config.mjs +34 -34
  106. package/src/runtime/channels/lib/inbound-handler.mjs +8 -8
  107. package/src/runtime/channels/lib/interaction-handlers.mjs +6 -6
  108. package/src/runtime/channels/lib/memory-client.mjs +31 -157
  109. package/src/runtime/channels/lib/output-forwarder.mjs +16 -16
  110. package/src/runtime/channels/lib/owned-runtime.mjs +60 -60
  111. package/src/runtime/channels/lib/owner-heartbeat.mjs +1 -1
  112. package/src/runtime/channels/lib/{backend-dispatch.mjs → provider-dispatch.mjs} +6 -6
  113. package/src/runtime/channels/lib/runtime-paths.mjs +4 -30
  114. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  115. package/src/runtime/channels/lib/status-snapshot.mjs +3 -3
  116. package/src/runtime/channels/lib/tool-dispatch.mjs +4 -4
  117. package/src/runtime/channels/lib/worker-bootstrap.mjs +1 -1
  118. package/src/runtime/channels/lib/worker-ipc.mjs +7 -7
  119. package/src/runtime/channels/lib/worker-main.mjs +38 -38
  120. package/src/runtime/channels/{backends → providers}/discord-access.mjs +1 -1
  121. package/src/runtime/channels/{backends → providers}/discord-gateway.mjs +1 -1
  122. package/src/runtime/channels/{backends → providers}/discord.mjs +3 -3
  123. package/src/runtime/channels/{backends → providers}/telegram.mjs +12 -12
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/memory/index.mjs +76 -248
  126. package/src/runtime/memory/lib/agent-ipc.mjs +146 -81
  127. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  128. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  129. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +13 -34
  130. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  131. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  132. package/src/runtime/memory/lib/http-router.mjs +9 -6
  133. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  134. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  135. package/src/runtime/memory/lib/memory-config-flags.mjs +1 -1
  136. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  137. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  138. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  139. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +4 -1
  140. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  141. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +5 -0
  142. package/src/runtime/memory/lib/memory-log.mjs +1 -1
  143. package/src/runtime/memory/lib/memory-process-lock.mjs +5 -60
  144. package/src/runtime/memory/lib/memory.mjs +1 -1
  145. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  146. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  147. package/src/runtime/memory/tool-defs.mjs +1 -5
  148. package/src/runtime/search/index.mjs +2 -2
  149. package/src/runtime/search/tool-defs.mjs +2 -2
  150. package/src/runtime/shared/abort-race.mjs +70 -0
  151. package/src/runtime/shared/agent-route-config.mjs +119 -0
  152. package/src/runtime/shared/atomic-file.mjs +80 -4
  153. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  154. package/src/runtime/shared/child-guardian.mjs +204 -108
  155. package/src/runtime/shared/child-spawn-gate.mjs +139 -72
  156. package/src/runtime/shared/config.mjs +120 -47
  157. package/src/runtime/shared/llm/index.mjs +1 -1
  158. package/src/runtime/shared/owner-fair-gate.mjs +134 -0
  159. package/src/runtime/shared/resource-admission.mjs +150 -100
  160. package/src/runtime/shared/stream-progress.mjs +6 -0
  161. package/src/runtime/shared/tool-execution-owner.mjs +12 -0
  162. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  163. package/src/runtime/shared/tool-surface.mjs +91 -27
  164. package/src/runtime/shared/tool-workload-gates.mjs +29 -0
  165. package/src/runtime/shared/turn-snapshot.mjs +47 -6
  166. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  167. package/src/session-runtime/channel-config-api.mjs +5 -5
  168. package/src/session-runtime/config-helpers.mjs +39 -37
  169. package/src/session-runtime/config-lifecycle.mjs +27 -27
  170. package/src/session-runtime/lifecycle-api.mjs +22 -3
  171. package/src/session-runtime/memory-runtime-prewarm.mjs +15 -0
  172. package/src/session-runtime/model-route-api.mjs +7 -5
  173. package/src/session-runtime/prewarm.mjs +1 -1
  174. package/src/session-runtime/provider-auth-api.mjs +0 -7
  175. package/src/session-runtime/provider-models.mjs +1 -1
  176. package/src/session-runtime/remote-control.mjs +6 -7
  177. package/src/session-runtime/runtime-core.mjs +63 -39
  178. package/src/session-runtime/runtime-tunables.mjs +4 -4
  179. package/src/session-runtime/self-update.mjs +1 -1
  180. package/src/session-runtime/session-lifecycle.mjs +45 -17
  181. package/src/session-runtime/session-title.mjs +1 -1
  182. package/src/session-runtime/session-turn-api.mjs +96 -24
  183. package/src/session-runtime/settings-api.mjs +12 -24
  184. package/src/session-runtime/tool-defs.mjs +3 -3
  185. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  186. package/src/session-runtime/workflow.mjs +14 -24
  187. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  188. package/src/standalone/agent-tool/helpers.mjs +2 -2
  189. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  190. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  191. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  192. package/src/standalone/channel-admin.mjs +53 -64
  193. package/src/standalone/{channel-daemon-client.mjs → channel-client.mjs} +33 -19
  194. package/src/standalone/{channel-daemon-transport.mjs → channel-transport.mjs} +127 -46
  195. package/src/standalone/channel-worker.mjs +124 -647
  196. package/src/standalone/{backend-daemon.mjs → daemon.mjs} +270 -154
  197. package/src/standalone/explore-tool.mjs +187 -70
  198. package/src/standalone/fair-call-scheduler.mjs +264 -0
  199. package/src/standalone/memory-runtime-proxy.mjs +58 -1
  200. package/src/standalone/session-client.mjs +1134 -0
  201. package/src/standalone/session-protocol.mjs +192 -0
  202. package/src/standalone/session-runtime-pool.mjs +393 -0
  203. package/src/standalone/session-runtime-record.mjs +20 -0
  204. package/src/standalone/session-runtime-worker.mjs +203 -0
  205. package/src/standalone/{engine-daemon-service.mjs → session-service.mjs} +481 -224
  206. package/src/standalone/{engine-daemon-transport.mjs → session-transport.mjs} +390 -121
  207. package/src/standalone/session-wire.mjs +55 -0
  208. package/src/tui/App.jsx +34 -21
  209. package/src/tui/app/app-format.mjs +1 -1
  210. package/src/tui/app/app-view.jsx +5 -2
  211. package/src/tui/app/channel-pickers.mjs +41 -41
  212. package/src/tui/app/core-memory-picker.mjs +19 -19
  213. package/src/tui/app/doctor.mjs +5 -11
  214. package/src/tui/app/input-parsers.mjs +2 -2
  215. package/src/tui/app/message-selector.mjs +103 -0
  216. package/src/tui/app/onboarding-steps.mjs +3 -4
  217. package/src/tui/app/project-picker.mjs +80 -56
  218. package/src/tui/app/prompt-submit.mjs +48 -43
  219. package/src/tui/app/resume-picker.mjs +2 -2
  220. package/src/tui/app/route-pickers.mjs +3 -1
  221. package/src/tui/app/settings-picker.mjs +31 -33
  222. package/src/tui/app/transcript-window.mjs +38 -0
  223. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  224. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  225. package/src/tui/app/use-prompt-hint.mjs +3 -2
  226. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  227. package/src/tui/app/use-transcript-scroll.mjs +10 -3
  228. package/src/tui/app/use-transcript-window.mjs +50 -17
  229. package/src/tui/components/PromptInput.jsx +71 -38
  230. package/src/tui/components/Spinner.jsx +1 -1
  231. package/src/tui/components/ToolExecution.jsx +1 -1
  232. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  233. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  234. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  235. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  236. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  237. package/src/tui/components/tool-execution/surface-detail.mjs +1 -1
  238. package/src/tui/dist/index.mjs +1031 -1676
  239. package/src/tui/hooks/useSession.mjs +16 -0
  240. package/src/tui/index.jsx +12 -13
  241. package/src/tui/{engine → session}/agent-envelope.mjs +3 -3
  242. package/src/tui/{engine → session}/agent-job-feed.mjs +4 -4
  243. package/src/tui/{engine → session}/boot-profile.mjs +2 -2
  244. package/src/tui/{engine → session}/context-state.mjs +3 -3
  245. package/src/tui/{engine → session}/labels.mjs +2 -2
  246. package/src/tui/{engine → session}/live-share.mjs +38 -4
  247. package/src/tui/{engine → session}/notice-text.mjs +2 -2
  248. package/src/tui/{engine → session}/notification-plan.mjs +3 -3
  249. package/src/tui/session/oauth-flows.mjs +140 -0
  250. package/src/tui/{engine → session}/prompt-history.mjs +3 -3
  251. package/src/tui/{engine → session}/queue-helpers.mjs +2 -2
  252. package/src/tui/{engine → session}/render-timing.mjs +2 -2
  253. package/src/tui/{engine → session}/session-api-ext.mjs +30 -20
  254. package/src/tui/{engine → session}/session-api.mjs +110 -93
  255. package/src/tui/{engine → session}/session-flow.mjs +8 -2
  256. package/src/tui/{engine → session}/session-stats.mjs +3 -3
  257. package/src/tui/{engine → session}/tool-approval.mjs +3 -3
  258. package/src/tui/{engine → session}/tool-call-fields.mjs +2 -2
  259. package/src/tui/{engine → session}/tool-card-results.mjs +8 -6
  260. package/src/tui/{engine → session}/tool-result-status.mjs +5 -5
  261. package/src/tui/{engine → session}/tool-result-text.mjs +13 -3
  262. package/src/tui/{engine → session}/transcript-spill.mjs +34 -34
  263. package/src/tui/{engine → session}/turn.mjs +38 -176
  264. package/src/tui/{engine-local-session.mjs → session-local.mjs} +103 -103
  265. package/src/tui/session.mjs +22 -0
  266. package/src/tui/spinner-meta.mjs +4 -4
  267. package/src/tui/spinner-verbs.mjs +51 -3
  268. package/src/ui/statusline-segments.mjs +1 -1
  269. package/src/ui/statusline.mjs +1 -1
  270. package/vendor/ink/build/components/App.js +10 -1
  271. package/src/session-runtime/memory-daemon-probe.mjs +0 -61
  272. package/src/standalone/engine-daemon-client.mjs +0 -894
  273. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
  274. package/src/standalone/engine-daemon-protocol.mjs +0 -33
  275. package/src/tui/engine/turn-watchdog.mjs +0 -92
  276. package/src/tui/engine.mjs +0 -22
  277. package/src/tui/hooks/useEngine.mjs +0 -16
  278. /package/src/runtime/agent/orchestrator/session/compact/{engine.mjs → runner.mjs} +0 -0
  279. /package/src/runtime/channels/{backends → providers}/discord-attachments.mjs +0 -0
  280. /package/src/tui/{engine → session}/agent-response-tail.mjs +0 -0
  281. /package/src/tui/{engine → session}/frame-batched-store.mjs +0 -0
  282. /package/src/tui/{engine → session}/tui-steering-persist.mjs +0 -0
@@ -345,6 +345,8 @@ function normalizedRow(row, heartbeatAt = 0) {
345
345
  preview: cleanText(row.preview),
346
346
  generation: typeof row.generation === 'number' ? row.generation : 0,
347
347
  implicitBashSessionId: row.implicitBashSessionId || null,
348
+ storageMtimeMs: positiveNumber(row.storageMtimeMs, 0),
349
+ storageSize: positiveNumber(row.storageSize, 0),
348
350
  detachedReason: row.detachedReason || null,
349
351
  };
350
352
  }
@@ -412,10 +414,15 @@ function sessionHeartbeatMtimes() {
412
414
  * Returns `null` when the directory itself is NOT enumerable (unreadable
413
415
  * stat / readdir): that is not "no sessions", and the caller must keep the
414
416
  * authority it already has (the index rows) instead of publishing an empty
415
- * catalog. `indexRowsById` carries those rows so a single UNREADABLE session
416
- * file also retains its last known row instead of vanishing.
417
+ * catalog. `indexRowsById` carries those rows so unchanged files can reuse the
418
+ * durable summary and a single UNREADABLE file retains its last known row
419
+ * instead of vanishing.
417
420
  */
418
- function scanSessionFiles(heartbeatMtimes = sessionHeartbeatMtimes(), indexRowsById = new Map()) {
421
+ function scanSessionFiles(
422
+ heartbeatMtimes = sessionHeartbeatMtimes(),
423
+ indexRowsById = new Map(),
424
+ { indexMtimeMs = 0, forceRead = false } = {},
425
+ ) {
419
426
  const directory = join(dataDir(), 'sessions');
420
427
  const dirProbe = probePath(directory);
421
428
  if (dirProbe.state === PROBE_ABSENT) return [];
@@ -426,13 +433,37 @@ function scanSessionFiles(heartbeatMtimes = sessionHeartbeatMtimes(), indexRowsB
426
433
  for (const filename of entries) {
427
434
  if (!filename.endsWith('.json')) continue;
428
435
  const storageId = filename.slice(0, -5);
429
- const read = readTextFile(join(directory, filename));
436
+ const path = join(directory, filename);
437
+ const retained = indexRowsById.get(storageId);
438
+ const probe = probePath(path);
439
+ // Provably gone: the row legitimately disappears with the file.
440
+ if (probe.state === PROBE_ABSENT) continue;
441
+ if (probe.state !== PROBE_PRESENT) {
442
+ if (retained) rows.push(retained);
443
+ continue;
444
+ }
445
+ if (!forceRead && retained) {
446
+ const exactFingerprint = retained.storageMtimeMs > 0
447
+ && retained.storageSize > 0
448
+ && retained.storageMtimeMs === probe.mtimeMs
449
+ && retained.storageSize === probe.size;
450
+ // Older v2 rows predate per-file fingerprints. Their global index
451
+ // timestamp remains a one-release compatibility fallback; every
452
+ // later save/rebuild stamps the exact (mtimeMs,size) pair.
453
+ const legacyUnchanged = retained.storageMtimeMs === 0
454
+ && indexMtimeMs > 0
455
+ && (probe.mtimeMs || 0) <= indexMtimeMs;
456
+ if (exactFingerprint || legacyUnchanged) {
457
+ rows.push(retained);
458
+ continue;
459
+ }
460
+ }
461
+ const read = readTextFile(path);
430
462
  // Provably gone: the row legitimately disappears with the file.
431
463
  if (read.state === PROBE_ABSENT) continue;
432
464
  if (read.state !== PROBE_PRESENT) {
433
465
  // Exists and could not be read (EACCES/EIO): retain the last known
434
466
  // row rather than let a transient IO error delete it from view.
435
- const retained = indexRowsById.get(storageId);
436
467
  if (retained) rows.push(retained);
437
468
  continue;
438
469
  }
@@ -440,36 +471,16 @@ function scanSessionFiles(heartbeatMtimes = sessionHeartbeatMtimes(), indexRowsB
440
471
  // record or a foreign identity is not this file's session.
441
472
  const record = readTopLevelLifecycleRecord(read.text);
442
473
  if (isLifecycleUnreadable(record) || record.id !== storageId) continue;
443
- const row = rowFromSession(record.doc, heartbeatMtimes.get(record.id) || 0);
474
+ const summary = rowFromSession(record.doc, heartbeatMtimes.get(record.id) || 0);
475
+ const row = summary
476
+ ? { ...summary, storageMtimeMs: probe.mtimeMs, storageSize: probe.size }
477
+ : null;
444
478
  if (row) rows.push(row);
445
479
  }
446
480
  return leadRowsWithAgentHeartbeat(rows).sort((left, right) =>
447
481
  (right.lastUsedAt || right.updatedAt || 0) - (left.lastUsedAt || left.updatedAt || 0));
448
482
  }
449
483
 
450
- function sessionFilesAreNewerThan(indexPath) {
451
- const directory = join(dataDir(), 'sessions');
452
- if (probePath(directory).state !== PROBE_PRESENT) return false;
453
- const indexProbe = probePath(indexPath);
454
- // Only a PROVABLY absent index makes the files newer by definition. An
455
- // index that exists but cannot be stat'ed (EACCES/EIO) is still the
456
- // authority we failed to consult — claiming "files are newer" there would
457
- // replace the whole catalog on the strength of an IO error.
458
- if (indexProbe.state === PROBE_ABSENT) return true;
459
- if (indexProbe.state !== PROBE_PRESENT) return false;
460
- const indexMtime = indexProbe.mtimeMs || 0;
461
- let entries;
462
- try { entries = readdirSync(directory); } catch { return false; }
463
- for (const filename of entries) {
464
- if (!filename.endsWith('.json')) continue;
465
- // Absent (raced unlink) and unreadable both prove nothing about
466
- // freshness: only an observed newer mtime does.
467
- const probe = probePath(join(directory, filename));
468
- if (probe.state === PROBE_PRESENT && (probe.mtimeMs || 0) > indexMtime) return true;
469
- }
470
- return false;
471
- }
472
-
473
484
  export function listStoredSessionSummaries(options = {}) {
474
485
  const heartbeatMtimes = sessionHeartbeatMtimes();
475
486
  const indexPath = join(dataDir(), 'session-summaries.json');
@@ -478,6 +489,7 @@ export function listStoredSessionSummaries(options = {}) {
478
489
  // an individual session file cannot be read.
479
490
  const indexRowsById = new Map();
480
491
  let indexRows = null;
492
+ let indexMtimeMs = 0;
481
493
  const indexRead = readTextFile(indexPath);
482
494
  if (indexRead.state === PROBE_PRESENT) {
483
495
  try {
@@ -490,25 +502,42 @@ export function listStoredSessionSummaries(options = {}) {
490
502
  indexRows = leadRowsWithAgentHeartbeat(normalizedRows)
491
503
  .sort((left, right) =>
492
504
  (right.lastUsedAt || right.updatedAt || 0) - (left.lastUsedAt || left.updatedAt || 0));
505
+ const indexProbe = probePath(indexPath);
506
+ if (indexProbe.state === PROBE_PRESENT) indexMtimeMs = indexProbe.mtimeMs || 0;
493
507
  }
494
508
  } catch { /* malformed sidecar: the files below are the authority */ }
495
509
  }
496
510
  // An index that EXISTS but is unreadable (EACCES/EIO) is neither missing
497
511
  // nor empty: the scan below still runs, and when IT cannot enumerate
498
512
  // either, the catalog reports nothing rather than inventing an empty truth.
499
- const scan = () => scanSessionFiles(heartbeatMtimes, indexRowsById);
500
- if (options.refreshFromStorage === true) return scan() ?? indexRows ?? [];
513
+ const scan = (forceRead = false) => scanSessionFiles(
514
+ heartbeatMtimes,
515
+ indexRowsById,
516
+ { indexMtimeMs, forceRead },
517
+ );
518
+ if (options.refreshFromStorage === true) return scan(true) ?? indexRows ?? [];
501
519
  if (indexRows) {
502
- // A crashed/non-blocking sidecar writer can leave a valid but old,
503
- // non-empty index indefinitely. Detect only newer session JSONs
504
- // (heartbeat churn does not count), then recover read-only from the
505
- // authoritative files without trying to rewrite the stuck index.
506
- if (options.rebuildIfMissing !== false && sessionFilesAreNewerThan(indexPath)) {
507
- return scan() ?? indexRows;
508
- }
509
- if (indexRows.length > 0 || options.rebuildIfMissing === false) return indexRows;
520
+ if (options.rebuildIfMissing === false) return indexRows;
521
+ // Enumerate identities to detect deletions, stat known files, and parse
522
+ // only rows newer than the index. Cold-start cost therefore scales with
523
+ // changed transcripts rather than total transcript bytes.
524
+ return scan(false) ?? indexRows;
510
525
  }
511
- return options.rebuildIfMissing === false ? [] : (scan() ?? indexRows ?? []);
526
+ // Missing/malformed index is the sole cold path that must rebuild every
527
+ // summary from canonical session bytes.
528
+ return options.rebuildIfMissing === false ? [] : (scan(true) ?? indexRows ?? []);
529
+ }
530
+
531
+ /** Exact, fail-closed existence check for a durable session address.
532
+ * Summary indexes and desktop metadata are presentation caches; neither may
533
+ * make a missing sessions/<id>.json record addressable again. */
534
+ export function storedSessionExists(id) {
535
+ const sessionId = String(id || '').trim();
536
+ if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) return false;
537
+ const read = readTextFile(join(dataDir(), 'sessions', `${sessionId}.json`));
538
+ if (read.state !== PROBE_PRESENT) return false;
539
+ const record = readTopLevelLifecycleRecord(read.text);
540
+ return !isLifecycleUnreadable(record) && record.id === sessionId;
512
541
  }
513
542
 
514
543
  /** Read exactly one persisted session for a visible desktop pane. This never
@@ -555,7 +584,7 @@ export async function readStoredSessionTranscript(id, options = {}) {
555
584
  restoreTranscriptItems,
556
585
  sessionContextSnapshotProjection,
557
586
  } = await import(
558
- '../../../../tui/engine/session-api-ext.mjs'
587
+ '../../../../tui/session/session-api-ext.mjs'
559
588
  );
560
589
  let preparedContextProjection = null;
561
590
  try {
@@ -712,7 +741,7 @@ export async function createStoredSessionLiveViewer(id, options = {}) {
712
741
  clearStreamingTail: () => commit({ streamingTail: null }),
713
742
  };
714
743
  const { createLiveShare, liveSharePipePath } = await import(
715
- '../../../../tui/engine/live-share.mjs'
744
+ '../../../../tui/session/live-share.mjs'
716
745
  );
717
746
  const share = createLiveShare({
718
747
  ownerSessionId: () => '',
@@ -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,9 @@ 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
+ // dispatch the whole remaining batch. The eager dispatcher
261
+ // keeps independent calls parallel while making shell wait
262
+ // for earlier apply_patch calls.
284
263
  if (isParallelDispatchable(call.name)) {
285
264
  startEagerRun(calls, callIndex, _duplicateCallIds);
286
265
  }
@@ -512,14 +491,6 @@ export async function processToolBatch(ctx) {
512
491
  }
513
492
  if (_isMutationTool(call.name)) {
514
493
  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
494
  }
524
495
  // Bash always clears scoped cache UNCONDITIONALLY — a mutating bash
525
496
  // 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).
@@ -448,19 +506,24 @@ export async function executeBashTool(args, workDir, options = {}) {
448
506
  { backgroundTasksDisabled: _bgTasksDisabled },
449
507
  );
450
508
  if (longForegroundHint) return formatShellToolFailure(longForegroundHint);
451
- // Auto-background threshold. Reference-CLI parity: sync commands run to
452
- // their timeout without any default auto-promotion, so the default is 0
453
- // (disabled) for ALL callers. It is an explicit opt-in only: set
454
- // MIXDOG_SHELL_AUTO_BACKGROUND_MS (positive ms) to re-enable detaching a
455
- // still-running foreground one-shot into a tracked shell-job. When enabled,
456
- // the value stays a soft hint clamped below `timeout` so the hard ceiling
457
- // remains a separate, later bound. Never applies to run_in_background
458
- // (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
509
+ // Main-agent blocking budget. A timeout is the command's total deadline,
510
+ // not permission to hold the conversation open for that whole duration:
511
+ // after 15 s a still-running command becomes a tracked background task and
512
+ // completion is pushed to the owner. Explicit timeouts keep their remaining
513
+ // deadline after promotion. Never applies to run_in_background (already
514
+ // detached), persistent sessions, or commands barred from backgrounding.
515
+ // MIXDOG_SHELL_AUTO_BACKGROUND_MS overrides; an explicit 0 disables.
516
+ const _autoBgEnvRaw = process.env.MIXDOG_SHELL_AUTO_BACKGROUND_MS;
517
+ const _autoBgEnvMs = Number(_autoBgEnvRaw);
518
+ const DEFAULT_AUTO_BACKGROUND_MS = (_autoBgEnvRaw != null && String(_autoBgEnvRaw).trim() !== ''
519
+ && Number.isFinite(_autoBgEnvMs) && _autoBgEnvMs >= 0)
461
520
  ? Math.floor(_autoBgEnvMs)
462
- : 0;
463
- const autoBackgroundMs = (runInBackground || DEFAULT_AUTO_BACKGROUND_MS <= 0)
521
+ : 15_000;
522
+ // Gate on backgroundOnTimeout rather than just `runInBackground`: it already
523
+ // encodes "promotion is available for this command" (not detached, background
524
+ // tasks enabled, command shape allows it), so the soft threshold can never
525
+ // promote something the hard timeout would refuse to.
526
+ const autoBackgroundMs = (!backgroundOnTimeout || DEFAULT_AUTO_BACKGROUND_MS <= 0)
464
527
  ? 0
465
528
  : Math.min(DEFAULT_AUTO_BACKGROUND_MS, timeout);
466
529
 
@@ -507,6 +570,7 @@ export async function executeBashTool(args, workDir, options = {}) {
507
570
  abortSignal: combinedAsyncAbort.signal,
508
571
  label: String(command).replace(/\s+/g, ' ').slice(0, 120),
509
572
  dependency: 'detached',
573
+ ownerKey: options?.callerSessionId || options?.sessionId || null,
510
574
  });
511
575
  if (combinedAsyncAbort.signal?.aborted) {
512
576
  throw combinedAsyncAbort.signal.reason || new Error('shell background task cancelled before spawn');
@@ -634,6 +698,10 @@ export async function executeBashTool(args, workDir, options = {}) {
634
698
  // tracked background job (unlimited) instead of killing it.
635
699
  backgroundOnTimeout,
636
700
  promotedTimeoutMs,
701
+ // Soft/interrupt promotion happens before the foreground cap.
702
+ // Preserve an explicit total deadline by passing it separately;
703
+ // omitted-timeout promotions retain the existing unlimited job.
704
+ backgroundDeadlineMs: hasExplicitTimeout ? totalTimeout : 0,
637
705
  // Threaded so an auto-backgrounded foreground job is stamped with
638
706
  // the dispatching terminal's claude.exe pid (per-terminal scope).
639
707
  clientHostPid: options?.clientHostPid,
@@ -704,7 +772,17 @@ export async function executeBashTool(args, workDir, options = {}) {
704
772
  const failureStatus = _shellFailureStatus(result, timeout);
705
773
  const { signal, exitCode, shellToolFailed } = failureStatus;
706
774
  const benignExitOne = _isBenignSearchExitOne(command, exitCode, signal, stderr);
707
- const shellRunFailed = !shellToolFailed && (!!signal || (exitCode !== 0 && exitCode !== null && !benignExitOne));
775
+ // Legitimate non-zero exit: the command printed its result and ended
776
+ // with 1 without writing a single byte to stderr (probes, reports,
777
+ // `git diff --exit-code`-shaped checks). It keeps its `[exit code: 1]`
778
+ // marker so the caller sees the code, but it is NOT framed as a tool
779
+ // error and NOT classified as a failure — measured as the dominant
780
+ // shape among non-zero exits.
781
+ const legitExit = !shellToolFailed
782
+ && !benignExitOne
783
+ && isLegitimateShellExit({ exitCode, signal, stdout, stderr });
784
+ const shellRunFailed = !shellToolFailed
785
+ && (!!signal || (exitCode !== 0 && exitCode !== null && !benignExitOne && !legitExit));
708
786
  const isReallyErrored = shellToolFailed || shellRunFailed;
709
787
  // Filter-swallow rescue: the tee file is ALWAYS consumed (deleted)
710
788
  // here; its tail is attached only when the run failed with an empty
@@ -729,8 +807,16 @@ export async function executeBashTool(args, workDir, options = {}) {
729
807
  const statusDetail = failureStatus.statusDetail;
730
808
  const statusMarker = shellToolFailed
731
809
  ? `[shell-tool-failed] ${statusDetail}`
732
- : (shellRunFailed ? `[shell-run-failed] ${statusDetail}` : '');
810
+ : (shellRunFailed ? `[shell-run-failed] ${statusDetail}` : (legitExit ? statusDetail : ''));
733
811
  const errorPrefix = isReallyErrored ? 'Error: ' : '';
812
+ // Three outcomes, three labels: a TOOL error (`[shell-tool-failed]`),
813
+ // a command that RAN AND FAILED (`Error: [shell-run-failed]`), and a
814
+ // command that RAN TO COMPLETION and merely ended non-zero. The last
815
+ // one keeps its exit code but says so in words, so neither the model
816
+ // nor the failure log treats a finished report as a failure.
817
+ const completionNote = legitExit
818
+ ? '\n[completed: the command finished and wrote nothing to stderr; the exit code is its own report, not a tool failure]'
819
+ : '';
734
820
  if (mergeStderr) {
735
821
  // Post-exit concatenation. True chunk-level interleaving would
736
822
  // require shell-level `2>&1` redirection (bash) or `*>&1`
@@ -740,7 +826,7 @@ export async function executeBashTool(args, workDir, options = {}) {
740
826
  // cross-stream interleaving. Acceptable for most diagnostic
741
827
  // outputs; flag in shell-command if exact interleaving is required.
742
828
  const merged = stdout + stderr;
743
- if (statusMarker) return _prependDestructiveWarning(command, errorPrefix + smartMiddleTruncate(`${statusMarker}\n\n${merged || '(no output)'}`) + _rescueNote + _driftNote);
829
+ if (statusMarker) return _finalizeShellResult(legitExit, _prependDestructiveWarning(command, errorPrefix + smartMiddleTruncate(`${statusMarker}${completionNote}\n\n${merged || '(no output)'}`) + _rescueNote + _driftNote));
744
830
  return _prependDestructiveWarning(command, smartMiddleTruncate(merged || '(no output)') + _driftNote);
745
831
  }
746
832
  const truncatedStdout = smartMiddleTruncate(stdout);
@@ -761,13 +847,17 @@ export async function executeBashTool(args, workDir, options = {}) {
761
847
  }
762
848
  const warningBlock = [
763
849
  wmicRewrite?.note || '',
850
+ shellRescueNote,
764
851
  ].filter(Boolean).join('\n');
765
852
  const payload = `${body}${stderrBlock}${spillBlock}${_rescueNote}${_driftNote}`;
766
- if (statusMarker) return _prependDestructiveWarning(command, _composeShellFailure(statusMarker, errorPrefix, warningBlock, payload));
853
+ if (statusMarker) return _finalizeShellResult(legitExit, _prependDestructiveWarning(command, _composeShellFailure(`${statusMarker}${completionNote}`, errorPrefix, warningBlock, payload)));
767
854
  return _prependDestructiveWarning(command, warningBlock ? `${warningBlock}\n${payload}` : payload);
768
855
  }
769
856
  finally {
770
857
  combinedBashAbort?.cleanup?.();
858
+ if (_inlineHoistPath) {
859
+ try { unlinkSync(_inlineHoistPath); } catch { /* best-effort cleanup */ }
860
+ }
771
861
  if (shellEffects.mutationMode === 'paths') {
772
862
  invalidateBuiltinResultCache(shellEffects.paths);
773
863
  markCodeGraphDirtyPaths(shellEffects.paths);