mixdog 0.9.147 → 0.9.149

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 (265) hide show
  1. package/package.json +23 -3
  2. package/scripts/build-voice-runtime-unix.sh +101 -0
  3. package/scripts/embedding-quality-probe.mjs +96 -0
  4. package/scripts/generate-voice-runtime-manifest.mjs +300 -0
  5. package/scripts/generate-voice-runtime-manifest.test.mjs +135 -0
  6. package/scripts/native-binary-arch.mjs +30 -13
  7. package/scripts/native-binary-arch.test.mjs +40 -4
  8. package/scripts/plan-voice-runtime-release.mjs +73 -0
  9. package/scripts/prepare-native-assets.mjs +50 -9
  10. package/scripts/prune-embedding-runtime.mjs +3 -3
  11. package/scripts/sync-voice-runtime-manifest.mjs +46 -0
  12. package/scripts/verify-release-assets.mjs +106 -6
  13. package/scripts/verify-voice-runtime-release.mjs +89 -0
  14. package/scripts/voice-runtime-config.json +52 -0
  15. package/src/app.mjs +42 -5
  16. package/src/headless-exec.mjs +58 -32
  17. package/src/headless-exec.test.mjs +74 -13
  18. package/src/rules/lead/01-general.md +2 -0
  19. package/src/rules/shared/10-tool-workflow.md +7 -4
  20. package/src/rules/shared/30-exploration.md +5 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +28 -9
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +6 -1
  23. package/src/runtime/agent/orchestrator/config.mjs +53 -18
  24. package/src/runtime/agent/orchestrator/mcp/cancellation.test.mjs +139 -0
  25. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +34 -5
  26. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -10
  27. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +5 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +98 -0
  29. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.test.mjs +90 -0
  30. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +67 -16
  31. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +4 -82
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +0 -5
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -121
  34. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +48 -0
  35. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.test.mjs +215 -0
  36. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +44 -48
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +54 -31
  38. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +56 -2
  39. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +34 -2
  40. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +61 -1
  41. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +6 -1
  42. package/src/runtime/agent/orchestrator/providers/gemini.mjs +61 -15
  43. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +120 -1
  44. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +9 -0
  45. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +0 -3
  46. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +187 -84
  47. package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +32 -9
  48. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +78 -33
  49. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +21 -0
  50. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +36 -36
  51. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +16 -6
  52. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +50 -213
  53. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +135 -126
  54. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +20 -11
  55. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +112 -36
  56. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +220 -50
  57. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +112 -9
  58. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -0
  59. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +130 -60
  60. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -2
  61. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +28 -10
  62. package/src/runtime/agent/orchestrator/providers/registry.mjs +122 -95
  63. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +69 -3
  64. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +4 -22
  65. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +7 -14
  66. package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +128 -0
  67. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +5 -0
  68. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +27 -48
  69. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +19 -14
  70. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -43
  71. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +5 -1
  72. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +43 -5
  73. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -2
  74. package/src/runtime/agent/orchestrator/session/manager/pending-messages-persistence.test.mjs +325 -0
  75. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +599 -195
  76. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +22 -0
  77. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +9 -2
  78. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +2 -0
  79. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  80. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +26 -16
  81. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +23 -11
  82. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +118 -3
  83. package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +332 -0
  84. package/src/runtime/agent/orchestrator/session/store.mjs +38 -1
  85. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +27 -6
  86. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -20
  87. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +291 -128
  88. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +46 -9
  89. package/src/runtime/agent/orchestrator/tools/builtin/edit-encoding-eol.test.mjs +224 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +53 -3
  91. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +255 -25
  92. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +87 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +1 -1
  94. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +132 -9
  95. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +10 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +29 -3
  97. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/list-tool-integrity.test.mjs +14 -0
  99. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +180 -31
  100. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -1
  101. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +32 -5
  102. package/src/runtime/agent/orchestrator/tools/builtin/native-search-health.test.mjs +5 -2
  103. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +10 -1
  104. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +38 -10
  105. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +12 -0
  106. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +31 -1
  107. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +11 -1
  108. package/src/runtime/agent/orchestrator/tools/builtin/read-windows.mjs +22 -4
  109. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +6 -8
  110. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +25 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +152 -133
  112. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +114 -42
  113. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +129 -15
  114. package/src/runtime/agent/orchestrator/tools/code-graph/loose-file-target.test.mjs +72 -0
  115. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +7 -8
  116. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +4 -4
  117. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +201 -19
  118. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +26 -3
  119. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +10 -1
  120. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-search-scope.test.mjs +55 -0
  121. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +115 -25
  122. package/src/runtime/agent/orchestrator/tools/code-graph/text-span-mask.test.mjs +268 -0
  123. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -3
  124. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +296 -46
  125. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.test.mjs +104 -0
  126. package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +136 -0
  127. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +127 -38
  128. package/src/runtime/agent/orchestrator/tools/patch/engine-contract-gate.test.mjs +661 -0
  129. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +350 -9
  130. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +418 -44
  131. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -3
  132. package/src/runtime/agent/orchestrator/tools/patch/patch-codec-eol.test.mjs +552 -0
  133. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +65 -15
  134. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  135. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +284 -109
  136. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +51 -21
  137. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +44 -27
  138. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +6 -1
  139. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  140. package/src/runtime/attachments/store.mjs +11 -3
  141. package/src/runtime/channels/data/voice-runtime-manifest.json +46 -70
  142. package/src/runtime/channels/lib/event-queue.mjs +51 -41
  143. package/src/runtime/channels/lib/scheduler.mjs +23 -28
  144. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -17
  145. package/src/runtime/channels/lib/voice-runtime-fetcher.test.mjs +17 -0
  146. package/src/runtime/channels/lib/webhook.mjs +39 -20
  147. package/src/runtime/channels/lib/whisper-server.mjs +46 -1
  148. package/src/runtime/channels/lib/worker-main.mjs +3 -1
  149. package/src/runtime/media/adapters/gemini-video.mjs +20 -4
  150. package/src/runtime/media/jobs.mjs +13 -0
  151. package/src/runtime/media/renditions.mjs +7 -1
  152. package/src/runtime/media/store.mjs +42 -22
  153. package/src/runtime/memory/index.mjs +4 -9
  154. package/src/runtime/memory/lib/agent-ipc.mjs +1 -6
  155. package/src/runtime/memory/lib/archive-security.test.mjs +21 -1
  156. package/src/runtime/memory/lib/core-memory-store.mjs +28 -50
  157. package/src/runtime/memory/lib/embedding-model-config.mjs +37 -1
  158. package/src/runtime/memory/lib/embedding-model-config.test.mjs +33 -0
  159. package/src/runtime/memory/lib/embedding-provider.mjs +34 -12
  160. package/src/runtime/memory/lib/embedding-worker.mjs +37 -5
  161. package/src/runtime/memory/lib/ko-morph.mjs +46 -15
  162. package/src/runtime/memory/lib/memory-action-handlers.mjs +18 -1
  163. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -18
  164. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +1 -18
  165. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +98 -45
  166. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +71 -0
  167. package/src/runtime/memory/lib/memory-cycle2-shared.test.mjs +78 -0
  168. package/src/runtime/memory/lib/memory-cycle2.mjs +37 -3
  169. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  170. package/src/runtime/memory/lib/memory-embedding-migration.test.mjs +50 -0
  171. package/src/runtime/memory/lib/memory-recall-store.mjs +9 -24
  172. package/src/runtime/memory/lib/memory.mjs +50 -12
  173. package/src/runtime/memory/lib/pg/process.mjs +1 -10
  174. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -9
  175. package/src/runtime/memory/lib/query-handlers.mjs +2 -4
  176. package/src/runtime/memory/lib/session-ingest-runtime.mjs +2 -2
  177. package/src/runtime/shared/background-tasks.mjs +15 -0
  178. package/src/runtime/shared/background-tasks.test.mjs +78 -0
  179. package/src/runtime/shared/child-guardian.mjs +2 -10
  180. package/src/runtime/shared/pid-liveness.mjs +27 -0
  181. package/src/runtime/shared/pristine-execution-contract.json +0 -1
  182. package/src/runtime/shared/runtime-root.mjs +1 -11
  183. package/src/runtime/shared/service-discovery.mjs +1 -17
  184. package/src/runtime/shared/singleton-owner.mjs +1 -11
  185. package/src/runtime/shared/staged-update.mjs +2 -12
  186. package/src/runtime/shared/tool-status.mjs +5 -1
  187. package/src/runtime/shared/tool-surface.mjs +15 -7
  188. package/src/runtime/shared/turn-snapshot-store.mjs +168 -0
  189. package/src/runtime/shared/turn-snapshot.mjs +137 -13
  190. package/src/runtime/shared/turn-worktree-snapshot.mjs +110 -18
  191. package/src/runtime/shared/webhook-session-run.mjs +16 -2
  192. package/src/runtime/web-search/lib/http-fetch.mjs +34 -4
  193. package/src/runtime/web-search/lib/state.mjs +15 -15
  194. package/src/runtime/web-search/lib/web-tools.mjs +46 -5
  195. package/src/session-runtime/context-status.mjs +4 -12
  196. package/src/session-runtime/runtime-core.mjs +20 -1
  197. package/src/session-runtime/session-lifecycle.mjs +9 -0
  198. package/src/standalone/agent-dispatch-broker.mjs +33 -2
  199. package/src/standalone/agent-tool/job-task-reconcile.mjs +78 -0
  200. package/src/standalone/agent-tool/notify.mjs +3 -1
  201. package/src/standalone/agent-tool/spawn-flow.mjs +52 -70
  202. package/src/standalone/agent-tool/tag-registry.mjs +116 -33
  203. package/src/standalone/agent-tool/tool-def.mjs +2 -2
  204. package/src/standalone/agent-tool/worker-index.mjs +8 -11
  205. package/src/standalone/agent-tool/worker-rows.mjs +4 -1
  206. package/src/standalone/agent-tool.mjs +20 -55
  207. package/src/standalone/agent-watchdog-registry.mjs +28 -4
  208. package/src/standalone/channel-client.mjs +47 -15
  209. package/src/standalone/channel-transport.mjs +44 -25
  210. package/src/standalone/daemon.mjs +31 -2
  211. package/src/standalone/hook-bus/config.mjs +25 -9
  212. package/src/standalone/hook-bus/handlers.mjs +16 -52
  213. package/src/standalone/hook-bus.mjs +29 -23
  214. package/src/standalone/memory-runtime-proxy.mjs +1 -16
  215. package/src/standalone/session-client.mjs +86 -23
  216. package/src/standalone/session-protocol.mjs +0 -2
  217. package/src/standalone/session-runtime-host-health.test.mjs +5 -4
  218. package/src/standalone/session-runtime-host.mjs +101 -6
  219. package/src/standalone/session-runtime-record.mjs +1 -5
  220. package/src/standalone/session-runtime-worker.mjs +52 -57
  221. package/src/standalone/session-service.mjs +4 -1
  222. package/src/standalone/session-transport.mjs +56 -25
  223. package/src/tui/App.jsx +40 -49
  224. package/src/tui/app/app-view.jsx +14 -21
  225. package/src/tui/app/core-memory-picker.mjs +29 -16
  226. package/src/tui/app/create-app-pickers.mjs +15 -36
  227. package/src/tui/app/extension-pickers.mjs +60 -49
  228. package/src/tui/app/maintenance-pickers.mjs +100 -57
  229. package/src/tui/app/message-selector.mjs +6 -5
  230. package/src/tui/app/model-picker.mjs +30 -13
  231. package/src/tui/app/onboarding-steps.mjs +21 -12
  232. package/src/tui/app/panel-epoch.mjs +56 -0
  233. package/src/tui/app/panel-epoch.test.mjs +1009 -0
  234. package/src/tui/app/panel-surface.mjs +98 -0
  235. package/src/tui/app/project-picker.mjs +30 -29
  236. package/src/tui/app/prompt-submit.mjs +192 -97
  237. package/src/tui/app/prompt-submit.test.mjs +206 -0
  238. package/src/tui/app/provider-setup-picker.mjs +74 -43
  239. package/src/tui/app/resume-picker.mjs +11 -8
  240. package/src/tui/app/route-pickers.mjs +45 -28
  241. package/src/tui/app/settings-picker.mjs +69 -18
  242. package/src/tui/app/shell-layout.mjs +1 -3
  243. package/src/tui/app/slash-commands.mjs +0 -2
  244. package/src/tui/app/slash-dispatch.mjs +4 -2
  245. package/src/tui/app/text-entry-policy.mjs +25 -0
  246. package/src/tui/app/theme-effort-pickers.mjs +11 -16
  247. package/src/tui/app/usage-context-panels.mjs +21 -25
  248. package/src/tui/app/use-global-key-input.mjs +3 -2
  249. package/src/tui/app/use-prompt-draft-flow.mjs +6 -18
  250. package/src/tui/app/use-prompt-handlers.mjs +3 -2
  251. package/src/tui/app/use-welcome-prompt-hint.mjs +17 -3
  252. package/src/tui/components/Picker.jsx +1 -1
  253. package/src/tui/components/TextEntryPanel.jsx +7 -2
  254. package/src/tui/dist/index.mjs +652 -561
  255. package/src/tui/lib/voice-setup.mjs +1 -2
  256. package/src/tui/session/agent-job-feed.mjs +73 -11
  257. package/src/tui/session/session-api-ext.mjs +5 -6
  258. package/src/tui/session/session-api.mjs +1 -0
  259. package/src/tui/session/tool-approval.mjs +15 -5
  260. package/src/tui/session/tui-steering-persist.mjs +33 -19
  261. package/src/tui/session/tui-steering-persist.test.mjs +104 -0
  262. package/src/tui/session-local.mjs +0 -22
  263. package/scripts/.tmp-coverage-report.mjs +0 -96
  264. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.mjs +0 -57
  265. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.test.mjs +0 -57
@@ -5,7 +5,7 @@
5
5
  // improvements over spawnSync are:
6
6
  // - native process-tree termination on timeout / abort.
7
7
  // - automatic spill to $PLUGIN_DATA/shell-output/<taskId>.* once the
8
- // in-memory buffers exceed SHELL_OUTPUT_INLINE_CAP*4 bytes. The caller
8
+ // in-memory buffers exceed SHELL_OUTPUT_INLINE_CAP bytes. The caller
9
9
  // receives an outputFilePath marker the model can FileRead later
10
10
  // instead of losing the tail past the inline cap.
11
11
  // - external AbortSignal hookup so a session-scoped abort (ESC, new
@@ -19,18 +19,24 @@ import { acquire as acquireChildSpawnSlot } from '../../../shared/child-spawn-ga
19
19
  // a static cycle — safe because neither binding is touched at module-eval
20
20
  // time, only when the respective functions actually run.
21
21
  import {
22
- adoptForegroundShellJob,
23
22
  attachShellJobResourceLease,
24
23
  killShellJob,
24
+ promoteForegroundShellJob,
25
25
  publishForegroundShellRecord,
26
26
  retireForegroundShellRecord,
27
+ trackForegroundShellJob,
27
28
  } from './builtin/shell-jobs.mjs';
29
+ import { nativeSpawnFileCaptureReady, setNativeTaskStartedAt } from './lib/native-spawn-client.mjs';
28
30
  import {
29
31
  _maybeEncodePowerShellCommand,
30
32
  extractPowerShellCommandInner,
31
33
  } from './shell-powershell.mjs';
32
34
  import { spawnShellWithRetry as _spawnShellWithRetry } from './lib/shell-spawn-retry.mjs';
33
35
  import { takeWarmShellStandby } from './lib/shell-warm-standby.mjs';
36
+ import {
37
+ probeShellDescendants,
38
+ STDIO_HELD_AFTER_EXIT_MS,
39
+ } from './lib/shell-descendants.mjs';
34
40
 
35
41
  export {
36
42
  _maybeEncodePowerShellCommand,
@@ -152,6 +158,29 @@ export function _abortReasonIsInterrupt(abortSignal) {
152
158
  return false;
153
159
  }
154
160
 
161
+ /** Host family of the shell that will ACTUALLY execute the command, resolved
162
+ * from the spawn target itself: the binary being launched and its arguments.
163
+ * Caller-supplied metadata never overrides the target — a spec claiming `cmd`
164
+ * while launching pwsh.exe is answered `powershell`, because pwsh is what
165
+ * parses the text. Returns null when the target cannot be identified; callers
166
+ * must then leave the command exactly as written rather than rewrite it on a
167
+ * guess. (A union of "plausible" families was worse than the ambiguity: it
168
+ * rewrote a valid CMD `echo literal ^&` into `echo literal ^`.) */
169
+ // Arguments are accepted for call-site convenience but never classify.
170
+ export function _shellFamilyForSpawn({ shell = '', shellArg: _shellArg = '', shellArgs: _shellArgs = null } = {}) {
171
+ const name = String(shell || '').toLowerCase().replace(/\.exe$/, '').split(/[\\/]/).pop();
172
+ if (name === 'pwsh' || name === 'powershell') return 'powershell';
173
+ if (name === 'cmd') return 'cmd';
174
+ // bash-family shells add `$'…'` / `$"…"`; sh/dash/ash/busybox do not, and
175
+ // their delimiter words keep `$` as an ordinary character.
176
+ if (/^(?:bash|zsh|ksh|ksh93|mksh)$/.test(name)) return 'bash';
177
+ if (/^(?:sh|dash|ash|busybox)$/.test(name)) return 'posix';
178
+ // Arguments never classify: `/c` looks like cmd.exe, but `{shell:'/usr/bin/env',
179
+ // shellArg:'/c'}` is not cmd — only the executable receiving the command text
180
+ // decides, and an unrecognized one means detect nothing and rewrite nothing.
181
+ return null;
182
+ }
183
+
155
184
  export async function acquireShellLeaseBounded(admission, {
156
185
  abortSignal, label, dependency = 'scoped', ownerKey = null,
157
186
  } = {}) {
@@ -226,6 +255,12 @@ export function execShellCommand({
226
255
  backgroundDeadlineMs = 0,
227
256
  admission = resourceAdmission,
228
257
  directArgv = null,
258
+ // What the shell PARSES, when that must differ from what the caller shows.
259
+ // `command` stays the display/record text (job labels, telemetry, shell-job
260
+ // rows); `execScript` is the argv payload. They diverge only when a caller
261
+ // hands the script to the shell out-of-band — see the POSIX env+eval path in
262
+ // bash-tool.mjs, which keeps the command body out of the child's argv.
263
+ execScript = null,
229
264
  }) {
230
265
  return new Promise(async (resolve) => {
231
266
  let resultResolved = false;
@@ -237,6 +272,11 @@ export function execShellCommand({
237
272
  };
238
273
  const taskId = `shell_${randomUUID().slice(0, 8)}`;
239
274
  const taskOutput = new TaskOutput(taskId);
275
+ // The command text is NEVER inspected or rewritten here: whatever the
276
+ // caller wrote reaches the shell byte for byte, trailing `&` included.
277
+ // A command that detaches work is dealt with AFTER it runs, by observing
278
+ // whether the shell's process group / tree still holds live processes
279
+ // (see the descendant probe in settle()).
240
280
  const stdoutDecoder = new ShellTextDecoder();
241
281
  const stderrDecoder = new ShellTextDecoder();
242
282
  let timedOut = false;
@@ -251,6 +291,10 @@ export function execShellCommand({
251
291
  let timer = null;
252
292
  let abortHandler = null;
253
293
  let partialOutput = false;
294
+ // Moment the shell PROCESS itself exited. The gap between it and the
295
+ // stdio close is the win32 evidence that descendants inherited (and still
296
+ // hold) this command's stdout/stderr — see lib/shell-descendants.mjs.
297
+ let rootExitAtMs = 0;
254
298
  let resourceLease = null;
255
299
  const releaseResourceLease = async () => {
256
300
  if (!resourceLease) return null;
@@ -285,7 +329,7 @@ export function execShellCommand({
285
329
  if (outputTailTimer) { clearInterval(outputTailTimer); outputTailTimer = null; }
286
330
  };
287
331
  // Auto-background transition flag. Set the moment the autoBackgroundMs
288
- // timer fires and adopts the still-running child. Once
332
+ // timer fires and promotes the still-running child. Once
289
333
  // true the normal settle()/close/exit/treeKill paths are inert for this
290
334
  // run — the call has already resolved with a 'backgrounded' result and
291
335
  // the child's lifecycle is owned by the shell-jobs registry. Mutually
@@ -293,31 +337,47 @@ export function execShellCommand({
293
337
  let autoBackgrounded = false;
294
338
  let autoBackgroundJobId = null;
295
339
  let autoBgTimer = null;
296
- // Treekill + force-settle deadline. treeKill alone leaves settle()
297
- // pending on 'close'/'exit'; on Windows a taskkill miss or a grandchild
298
- // holding stdio fds keeps the dispatch stalled until the upstream
299
- // ceiling. Covers every kill path (timeout / pre-aborted / abort /
300
- // capture-error / size-watchdog) so the hang risk does not live on
301
- // outside the timeout branch. Function declaration so callers placed
302
- // above settle()'s const definition still resolve via hoisting; the
303
- // 5 s deadline always fires after settle is constructed.
340
+ // Treekill + exit confirmation. treeKill alone leaves settle() pending on
341
+ // 'close'/'exit'; on Windows a taskkill miss or a grandchild holding stdio
342
+ // fds keeps the dispatch stalled until the upstream ceiling. Covers every
343
+ // kill path (timeout / pre-aborted / abort / capture-error /
344
+ // size-watchdog) so the hang risk does not live on outside the timeout
345
+ // branch. Function declaration so callers placed above settle()'s const
346
+ // definition still resolve via hoisting.
347
+ // A single 5 s force-settle was NOT enough: settling on that timer while
348
+ // the tree was still alive released the admission lease and the capture
349
+ // files under a running process. Each deadline now RE-issues the kill and
350
+ // re-arms; only a confirmed exit settles normally, and the bounded last
351
+ // resort settles with killUnconfirmed so cleanup follows the real exit.
352
+ const KILL_CONFIRM_INTERVAL_MS = 5000;
353
+ const KILL_CONFIRM_ATTEMPTS = 3;
354
+ let killUnconfirmed = false;
304
355
  function _treeKillForceSettle(cause) {
305
356
  killed = true;
306
357
  killCause = killCause || cause || 'runtime-guard';
358
+ let attempts = 0;
359
+ const armConfirmation = () => {
360
+ const deadline = setTimeout(() => {
361
+ if (settled || autoBackgrounded) return;
362
+ // A confirmed exit is settled by the child's own close/exit
363
+ // handlers with its real status; nothing to force here.
364
+ if (child?.exitCode != null || child?.signalCode != null) return;
365
+ attempts += 1;
366
+ if (attempts < KILL_CONFIRM_ATTEMPTS) {
367
+ treeKill(child);
368
+ armConfirmation();
369
+ return;
370
+ }
371
+ partialOutput = true;
372
+ killUnconfirmed = true;
373
+ failureReason = failureReason || 'kill unconfirmed';
374
+ settle(1, 'SIGKILL');
375
+ }, KILL_CONFIRM_INTERVAL_MS);
376
+ if (deadline.unref) deadline.unref();
377
+ };
307
378
  treeKill(child);
308
- const _killDeadline = setTimeout(() => {
309
- if (settled) return;
310
- partialOutput = true;
311
- settle(1, 'SIGKILL');
312
- }, 5000);
313
- if (_killDeadline.unref) _killDeadline.unref();
379
+ armConfirmation();
314
380
  }
315
- // Background commands (trailing `&`) intentionally detach stdio
316
- // from the parent shell, so 'close' may never fire while the
317
- // backgrounded grandchild is still alive. For those we settle
318
- // immediately on direct-child exit instead of waiting for close.
319
- const _trimmed = String(command || '').replace(/\s+$/, '');
320
- const _isBackground = /(^|[^&|])&$/.test(_trimmed);
321
381
 
322
382
  let child;
323
383
  let _onChildErrorRef = null;
@@ -366,7 +426,12 @@ export function execShellCommand({
366
426
  return;
367
427
  }
368
428
  const _useDirectArgv = Array.isArray(directArgv);
369
- const _spawnCommand = _useDirectArgv ? String(command ?? '') : _maybeEncodePowerShellCommand(command);
429
+ // Direct-exe spawns already bypass the shell, so they keep using the
430
+ // command verbatim; only the shell-parsed form honours execScript.
431
+ const _shellScript = (!_useDirectArgv && typeof execScript === 'string' && execScript)
432
+ ? execScript
433
+ : command;
434
+ const _spawnCommand = _useDirectArgv ? String(command ?? '') : _maybeEncodePowerShellCommand(_shellScript);
370
435
  const argv = _useDirectArgv
371
436
  ? [...directArgv]
372
437
  : (Array.isArray(shellArgs) && shellArgs.length > 0
@@ -395,44 +460,76 @@ export function execShellCommand({
395
460
  // full-lifetime gating concern in the note below stays true.
396
461
  let spawned = null;
397
462
  if (_standby) {
398
- spawned = _standby.spawned;
399
- } else {
400
- const _releaseSpawnSlot = await acquireChildSpawnSlot(abortSignal || null, 'process-spawn', {
401
- ownerKey: ownerSessionId,
402
- });
403
- // Gate drain can grant several waiters in one tick. Yield so CreateProcess
404
- // + AV does not freeze graph/patch/search callbacks in that same turn.
405
- await new Promise((resolve) => setImmediate(resolve));
406
- if (abortSignal?.aborted) {
407
- try { _releaseSpawnSlot(); } catch { /* idempotent */ }
408
- throw abortSignal.reason || new Error('aborted');
463
+ let tracked = null;
464
+ try {
465
+ tracked = await trackForegroundShellJob({
466
+ command,
467
+ cwd,
468
+ child: _standby.spawned.child,
469
+ jobId: _foregroundRecordId,
470
+ ownerSessionId,
471
+ clientHostPid,
472
+ });
473
+ } catch {}
474
+ if (tracked) {
475
+ spawned = _standby.spawned;
476
+ } else {
477
+ // The parked process settled or lost its native request identity.
478
+ // Do not feed it; replace it with a normally tracked spawn.
479
+ try { _standby.spawned.child.kill(); } catch {}
480
+ _standby = null;
481
+ }
409
482
  }
410
- try {
411
- spawned = await _spawnShellWithRetry({
412
- shell,
413
- argv,
414
- shellArg,
415
- cwd,
416
- spawnOptions: {
417
- env,
418
- cwd,
419
- outputLimit: SHELL_OUTPUT_DISK_CAP,
420
- rawOutput: true,
421
- // NOTE (child-spawn-gate): the full command lifetime is intentionally
422
- // NOT gated — bash/pwsh commands can run for minutes and would starve
423
- // rg/code_graph. Only the spawn window above holds a slot.
424
- // POSIX: detached gives the child its own process group so treeKill can
425
- // signal the whole group. The child is still CLI-owned because we do
426
- // not unref it after adoption. Windows detached has different console
427
- // semantics, so it stays off there.
428
- },
483
+ if (!spawned) {
484
+ const _releaseSpawnSlot = await acquireChildSpawnSlot(abortSignal || null, 'process-spawn', {
485
+ ownerKey: ownerSessionId,
429
486
  });
430
- } finally {
431
- try { _releaseSpawnSlot(); } catch { /* idempotent */ }
432
- }
487
+ // Gate drain can grant several waiters in one tick. Yield so CreateProcess
488
+ // + AV does not freeze graph/patch/search callbacks in that same turn.
489
+ await new Promise((resolve) => setImmediate(resolve));
490
+ if (abortSignal?.aborted) {
491
+ try { _releaseSpawnSlot(); } catch { /* idempotent */ }
492
+ throw abortSignal.reason || new Error('aborted');
493
+ }
494
+ // CC parity (ShellCommand.ts): give the child its OWN fds on the
495
+ // capture files so its output never depends on a reader living in this
496
+ // process. A job the caller deliberately leaves running — a task's
497
+ // server — then keeps writing after we exit; on a pipe it dies on its
498
+ // next write the moment our reader is gone.
499
+ const _capture = nativeSpawnFileCaptureReady() ? taskOutput.openDirectCapture() : null;
500
+ try {
501
+ spawned = await _spawnShellWithRetry({
502
+ shell,
503
+ argv,
504
+ shellArg,
505
+ cwd,
506
+ spawnOptions: {
507
+ env,
508
+ cwd,
509
+ outputLimit: SHELL_OUTPUT_DISK_CAP,
510
+ rawOutput: true,
511
+ jobId: _foregroundRecordId,
512
+ command,
513
+ ownerSessionId,
514
+ clientHostPid,
515
+ ...(_capture ? {
516
+ stdoutPath: taskOutput.stdoutPath,
517
+ stderrPath: taskOutput.stderrPath,
518
+ } : {}),
519
+ // NOTE (child-spawn-gate): the full command lifetime is intentionally
520
+ // NOT gated — bash/pwsh commands can run for minutes and would starve
521
+ // rg/code_graph. Only the spawn window above holds a slot.
522
+ // POSIX: detached gives the child its own process group so treeKill can
523
+ // signal the whole group. Windows detached has different console
524
+ // semantics, so it stays off there.
525
+ },
526
+ });
527
+ } finally {
528
+ try { _releaseSpawnSlot(); } catch { /* idempotent */ }
529
+ }
433
530
  }
434
531
  child = spawned.child;
435
- spawned.adoptErrorHandler(_onChildError);
532
+ spawned.attachErrorHandler(_onChildError);
436
533
  // Feed the standby only after the error handler is attached; server
437
534
  // messages cannot be processed before this synchronous block yields.
438
535
  if (_standby) _standby.feed(_spawnCommand, cwd);
@@ -440,6 +537,7 @@ export function execShellCommand({
440
537
  // readout measures from, and the point from which a still-running
441
538
  // command deserves to be visible in the shell readouts.
442
539
  _commandStartedAtMs = Date.now();
540
+ setNativeTaskStartedAt(_foregroundRecordId, _commandStartedAtMs);
443
541
  _foregroundRecordTimer = setTimeout(() => {
444
542
  _foregroundRecordTimer = null;
445
543
  if (settled || autoBackgrounded || !child?.pid) return;
@@ -486,16 +584,20 @@ export function execShellCommand({
486
584
  _treeKillForceSettle('cancellation');
487
585
  }
488
586
 
587
+ // Binary bytes are sanitized by the capture layer and the run CONTINUES.
588
+ // Killing the whole process tree on the first non-text chunk also killed
589
+ // the servers and pipelines that legitimately emit binary (git http
590
+ // protocol, VM stdout, PDF/ISO dumps) and returned zero output for work
591
+ // that had already succeeded. Runaway volume stays bounded by the inline
592
+ // cap and the SHELL_OUTPUT_DISK_CAP watchdog.
489
593
  const _stdoutData = (chunk) => {
490
594
  const text = stdoutDecoder.write(chunk);
491
595
  if (text) taskOutput.writeStdout(text);
492
- if (taskOutput.binaryOutput && !settled && !autoBackgrounded) _treeKillForceSettle('binary-output');
493
596
  if (taskOutput.writeError && !settled && !autoBackgrounded) _treeKillForceSettle('output-capture-error');
494
597
  };
495
598
  const _stderrData = (chunk) => {
496
599
  const text = stderrDecoder.write(chunk);
497
600
  if (text) taskOutput.writeStderr(text);
498
- if (taskOutput.binaryOutput && !settled && !autoBackgrounded) _treeKillForceSettle('binary-output');
499
601
  if (taskOutput.writeError && !settled && !autoBackgrounded) _treeKillForceSettle('output-capture-error');
500
602
  };
501
603
  if (child.stdout) {
@@ -505,8 +607,49 @@ export function execShellCommand({
505
607
  child.stderr.on('data', _stderrData);
506
608
  }
507
609
 
610
+ // Promotion resolves the call and hands the child's lifecycle to the
611
+ // shell-jobs registry, but the spill FDs stay owned by THIS runner and
612
+ // nothing else would ever close them — one leaked descriptor pair per
613
+ // promoted command. Released on the promoted child's own terminal event.
614
+ let _promotedCaptureReleased = false;
615
+ const _releasePromotedCapture = () => {
616
+ if (_promotedCaptureReleased) return;
617
+ if (child?.exitCode == null && child?.signalCode == null) return;
618
+ _promotedCaptureReleased = true;
619
+ try {
620
+ // An empty spill pair is garbage nothing can reference; captured
621
+ // bytes stay, because the promoted task record points at these files.
622
+ if (taskOutput.spilled && taskOutput.totalDiskBytes() === 0) taskOutput.deleteFiles();
623
+ else taskOutput.closeFds();
624
+ } catch { /* best-effort */ }
625
+ };
626
+ // Unconfirmed-kill cleanup. The result is already reported, but the tree
627
+ // may still be alive: defer closing the capture and returning the
628
+ // admission lease to the child's real exit, with a ceiling so an
629
+ // unkillable tree cannot pin the shell lane forever.
630
+ const UNCONFIRMED_KILL_CLEANUP_CEILING_MS = 60_000;
631
+ let _deferredCleanupDone = false;
632
+ const _deferCleanupToChildExit = () => {
633
+ const run = () => {
634
+ if (_deferredCleanupDone) return;
635
+ _deferredCleanupDone = true;
636
+ try { taskOutput.closeFds(); } catch { /* best-effort */ }
637
+ void releaseResourceLease();
638
+ };
639
+ try {
640
+ child.once('close', run);
641
+ child.once('exit', run);
642
+ } catch { /* child may already be gone */ }
643
+ const ceiling = setTimeout(run, UNCONFIRMED_KILL_CLEANUP_CEILING_MS);
644
+ if (ceiling.unref) ceiling.unref();
645
+ };
646
+
508
647
  settle = async (exitCode, signal) => {
509
- if (settled || autoBackgrounded) return;
648
+ if (settled) return;
649
+ if (autoBackgrounded) {
650
+ _releasePromotedCapture();
651
+ return;
652
+ }
510
653
  settled = true;
511
654
  // Off the readouts the instant the command is over, before any awaited
512
655
  // output capture — a finished command must never linger as "running".
@@ -538,18 +681,42 @@ export function execShellCommand({
538
681
  catch (err) { taskOutput.writeError = taskOutput.writeError || err; }
539
682
  if (spawnError && !stderr) stderr = String(spawnError.message || spawnError);
540
683
  // Inline-only path: nothing spilled. Nothing to clean up.
541
- // Spilled but tiny: drop the files — outputFilePath would duplicate
542
- // the inline body. Spilled and large: keep the files, caller renders
543
- // the path marker.
544
- if (
684
+ // Spilled but within the inline cap: getStdout/getStderr already
685
+ // returned the whole file, so the files would only duplicate the
686
+ // inline body — drop them. Past the cap the rendered body is head+tail
687
+ // carrying a "full output at <path>" marker, so the files MUST survive.
688
+ // The test compares captured BYTES, not rendered UTF-16 length: a CJK
689
+ // head+tail holds ~1/3 the char count of its byte size and under a
690
+ // length test would delete the very file its own marker names.
691
+ if (killUnconfirmed) {
692
+ // Never confirmed dead: deleting the capture files or handing back the
693
+ // lease now would release resources a live process still owns. The
694
+ // spilled paths therefore survive and travel with the result below.
695
+ _deferCleanupToChildExit();
696
+ } else if (
545
697
  taskOutput.spilled &&
546
- stdout.length + stderr.length <= SHELL_OUTPUT_INLINE_CAP
698
+ taskOutput.totalDiskBytes() <= SHELL_OUTPUT_INLINE_CAP
547
699
  ) {
548
700
  taskOutput.deleteFiles();
701
+ void releaseResourceLease();
549
702
  } else {
550
703
  taskOutput.closeFds();
704
+ void releaseResourceLease();
705
+ }
706
+ // The shell is gone — did it leave anything RUNNING? Answered from the
707
+ // process group / process tree the spawn layer already owns, never from
708
+ // the command text. A handle here means the caller reports a tracked
709
+ // task instead of a clean finish; null means nothing survived.
710
+ let descendants = null;
711
+ if (!killed && !timedOut && child?.pid) {
712
+ try {
713
+ descendants = await probeShellDescendants({
714
+ pid: child.pid,
715
+ stdioHeld: rootExitAtMs > 0 && (Date.now() - rootExitAtMs) >= STDIO_HELD_AFTER_EXIT_MS,
716
+ });
717
+ if (descendants) descendants.taskId = _foregroundRecordId;
718
+ } catch { descendants = null; }
551
719
  }
552
- void releaseResourceLease();
553
720
  resolveResult(
554
721
  new ExecResult({
555
722
  stdout,
@@ -568,6 +735,7 @@ export function execShellCommand({
568
735
  outputCaptureError: taskOutput.writeError,
569
736
  failurePhase,
570
737
  failureReason,
738
+ descendants,
571
739
  }),
572
740
  );
573
741
  };
@@ -583,31 +751,36 @@ export function execShellCommand({
583
751
  // 'close' only fires after stdio drains; a forked grandchild that
584
752
  // inherited stdout/stderr fds can hold them open past direct-child
585
753
  // exit and stall settle() until timeoutMs. 'exit' fires on direct
586
- // child termination regardless — give 'close' a 2 s grace then
587
- // settle anyway.
754
+ // child termination regardless — give 'close' a 3 s grace then
755
+ // settle anyway. The grace outlives the spawn server's own 2 s drain
756
+ // deadline, so the real exit status normally still arrives first.
588
757
  const _onChildExit = (code, signal) => {
589
- if (_isBackground) {
590
- setImmediate(() => settle(code == null ? 1 : code, signal));
591
- return;
592
- }
758
+ if (!rootExitAtMs) rootExitAtMs = Date.now();
593
759
  const grace = setTimeout(() => {
594
- if (settled || autoBackgrounded) return;
760
+ if (settled) return;
761
+ if (autoBackgrounded) {
762
+ // A promoted child whose 'close' never arrives (grandchild holding
763
+ // the stdio) would otherwise keep its spill descriptors forever:
764
+ // 'exit' is the only terminal event this path gets.
765
+ _releasePromotedCapture();
766
+ return;
767
+ }
595
768
  partialOutput = true;
596
769
  settle(code == null ? 1 : code, signal);
597
- }, 2000);
770
+ }, 3000);
598
771
  if (grace.unref) grace.unref();
599
772
  };
600
773
  child.once('exit', _onChildExit);
601
774
  // Auto-background transition. Two triggers
602
775
  // resolve the call immediately with a 'backgrounded' result while the
603
- // child keeps running, adopted into the shell-jobs registry but still
776
+ // child keeps running, promoted in the shell-jobs registry but still
604
777
  // owned by this CLI process:
605
778
  // 1. the autoBackgroundMs soft foreground threshold — an EARLIER
606
779
  // promotion before the timeout, and
607
780
  // 2. the foreground timeout deadline (backgroundOnTimeout) — the default
608
781
  // promote-on-timeout that replaces the old tree-kill.
609
782
  // A capped explicit foreground timeout supplies its remaining deadline to
610
- // the adopted job; otherwise adoption remains unlimited as before.
783
+ // the promoted job; otherwise background execution remains unlimited.
611
784
  // Mutually exclusive with settle() via the autoBackgrounded flag set
612
785
  // synchronously at the top before any await.
613
786
  const _autoBackground = async ({ reason = 'threshold' } = {}) => {
@@ -618,49 +791,50 @@ export function execShellCommand({
618
791
  if (child.exitCode != null || child.signalCode != null) return;
619
792
  autoBackgrounded = true;
620
793
  // The foreground capture is over; stop the local watchdogs/timers so
621
- // they cannot treeKill the now-adopted child.
794
+ // they cannot treeKill the now-promoted child.
622
795
  if (timer) { clearTimeout(timer); timer = null; }
623
796
  _clearProgressTimer();
624
797
  if (autoBgTimer) { clearTimeout(autoBgTimer); autoBgTimer = null; }
625
798
  // Keep the abort handler ATTACHED through the promotion window. A user
626
- // cancel racing in after promotion starts must still bring the adopted
799
+ // cancel racing in after promotion starts must still bring the promoted
627
800
  // child down — the handler's treeKill(child) does exactly that (settle()
628
801
  // is inert once autoBackgrounded, but the kill itself still lands, and
629
802
  // refreshShellJob then flags the job failed). We only detach on a real
630
- // settle() or on the adoption-failure fallback below.
803
+ // settle() or on the promotion-failure fallback below.
631
804
  // Every subsequent stdout/stderr chunk must hit disk — the call is
632
805
  // about to resolve and nobody will drain the in-memory buffers again.
633
806
  try { taskOutput.forceSpill(); } catch {}
634
807
  // The foreground sizeWatchdog was cleared above; the output cap now
635
- // travels with the adopted job — adoptForegroundShellJob arms a periodic
808
+ // travels with the promoted job — the shell-job watcher arms a periodic
636
809
  // refreshShellJob tick that enforces SHELL_JOB_OUTPUT_DISK_CAP against the
637
810
  // same spill files (stdoutPath/stderrPath below), killing + flagging a
638
811
  // runaway background producer even with no active task waiter.
639
812
  const stdoutPath = taskOutput.spilled ? taskOutput.stdoutPath : null;
640
813
  const stderrPath = taskOutput.spilled ? taskOutput.stderrPath : null;
641
814
  let job = null;
642
- // The adopted job publishes its own record under the real task id; drop
815
+ // The promoted job publishes its own record under the real task id; drop
643
816
  // the foreground marker first so the command is never counted twice.
644
817
  _clearForegroundRecord();
645
818
  const elapsedMs = _elapsedSinceStart();
646
- const adoptedTimeoutMs = reason === 'timeout'
819
+ const remainingBackgroundTimeoutMs = reason === 'timeout'
647
820
  ? promotedTimeoutMs
648
821
  : (backgroundDeadlineMs > 0
649
822
  ? Math.max(1, backgroundDeadlineMs - elapsedMs)
650
823
  : 0);
651
824
  try {
652
- job = await adoptForegroundShellJob({
825
+ job = await promoteForegroundShellJob({
653
826
  command,
654
827
  cwd,
655
828
  pid: child.pid,
656
- timeoutMs: adoptedTimeoutMs,
657
- // Carry the command's own start moment into the job: adoption time
829
+ jobId: _foregroundRecordId,
830
+ timeoutMs: remainingBackgroundTimeoutMs,
831
+ // Carry the command's own start moment into the job: promotion time
658
832
  // and a standby's process-creation time are both wrong.
659
833
  startedAtMs: _commandStartedAtMs,
660
834
  mergeStderr: false,
661
835
  stdoutPath,
662
836
  stderrPath,
663
- // Stamp the adopted job with the dispatching terminal's claude.exe
837
+ // Stamp the promoted job with the dispatching terminal's claude.exe
664
838
  // pid so the statusline scopes it to the owning session.
665
839
  clientHostPid,
666
840
  // …and with the dispatching SESSION, so a pooled host (desktop) can
@@ -682,7 +856,7 @@ export function execShellCommand({
682
856
  timedOut = true;
683
857
  _treeKillForceSettle('timeout');
684
858
  } else {
685
- _treeKillForceSettle('background-adoption-failed');
859
+ _treeKillForceSettle('background-promotion-failed');
686
860
  }
687
861
  return;
688
862
  }
@@ -707,12 +881,12 @@ export function execShellCommand({
707
881
  try { stderr = await taskOutput.getStderr(); }
708
882
  catch (err) { taskOutput.writeError = taskOutput.writeError || err; }
709
883
  // Re-check after the awaited capture reads: cancellation can race after
710
- // adoption commits. Never report that cancelled process as a successful
884
+ // promotion commits. Never report that cancelled process as a successful
711
885
  // still-running background task.
712
886
  // EXCEPTION: an interrupt-driven promotion starts FROM an aborted signal
713
887
  // by design (the user typed a new message), so this guard must not undo
714
888
  // the very transition it was asked to perform. A plain cancellation still
715
- // reverts adoption and kills.
889
+ // reverts promotion and kills.
716
890
  if (abortSignal && abortSignal.aborted
717
891
  && !(reason === 'interrupt' && _abortReasonIsInterrupt(abortSignal))) {
718
892
  killed = true;
@@ -739,11 +913,11 @@ export function execShellCommand({
739
913
  return;
740
914
  }
741
915
  // Completed-during-promotion race: the child
742
- // finished while adoption was committing. Report a clean COMPLETED
916
+ // finished while promotion was committing. Report a clean COMPLETED
743
917
  // result instead of backgrounded — the caller then never arms the
744
918
  // completion watcher, so no redundant task notification fires. Write
745
919
  // the exit/done files here as well: when 'close' fired before the
746
- // once('close') wiring above, nothing else would ever flip the adopted
920
+ // once('close') wiring above, nothing else would ever flip the promoted
747
921
  // job detail off 'running'.
748
922
  if (child.exitCode != null || child.signalCode != null) {
749
923
  detachAbortHandler();
@@ -765,7 +939,7 @@ export function execShellCommand({
765
939
  }));
766
940
  return;
767
941
  }
768
- // The adopted job now owns cancellation through task control. Retaining
942
+ // The promoted job now owns cancellation through task control. Retaining
769
943
  // the foreground caller's signal listener would keep the completed tool
770
944
  // frame alive and could later kill an unrelated, already-returned job.
771
945
  detachAbortHandler();
@@ -790,9 +964,9 @@ export function execShellCommand({
790
964
  outputCaptureError: taskOutput.writeError,
791
965
  backgrounded: true,
792
966
  jobId,
793
- backgroundTimeoutMs: adoptedTimeoutMs,
967
+ backgroundTimeoutMs: remainingBackgroundTimeoutMs,
794
968
  backgroundMessage: jobId
795
- ? `${_verb}; still running. Completion is automatic; do not call task read/monitor to wait or check progress. Continue independent work or end the turn. Use task read only when the user explicitly asks for current status, and task monitor only when they explicitly ask for periodic progress.`
969
+ ? `${_verb}; still running. Completion is automatic; unless periodic task reports were requested, continue independent work or end the turn. When the next step needs the result or the next report interval, call task wait instead of polling task read: it returns the moment the task settles, or hands back the current output at its ceiling so you can re-decide.`
796
970
  : `${_verb}; still running — judge from the partial output whether waiting can finish in budget, or diagnose and pursue an alternative.`,
797
971
  }),
798
972
  );
@@ -810,6 +984,12 @@ export function execShellCommand({
810
984
  detachAbortHandler();
811
985
  try { if (autoBackgroundJobId) killShellJob(autoBackgroundJobId); } catch {}
812
986
  try { treeKill(child); } catch {}
987
+ // settle() is inert from here (settled), so the cleanup it owns has to
988
+ // run in this branch: the reported result carries no spill paths, so
989
+ // the capture files are unreferenced garbage — drop them, close their
990
+ // descriptors and hand back any lease the failed handoff still holds.
991
+ try { taskOutput.deleteFiles(); } catch { /* best-effort */ }
992
+ void releaseResourceLease();
813
993
  resolveResult(new ExecResult({
814
994
  stdout: '',
815
995
  stderr: `resource cleanup failed during background promotion: ${error?.message || error}`,
@@ -831,13 +1011,12 @@ export function execShellCommand({
831
1011
  if (timeoutMs > 0) {
832
1012
  timer = setTimeout(() => {
833
1013
  // Promote-on-timeout: if the caller allows backgrounding and the child
834
- // is still running (not a trailing-`&` detach), adopt it as a tracked
835
- // background job instead of tree-killing it. Falls through to the old
836
- // kill path for disallowed/opted-out commands (backgroundOnTimeout
837
- // false) or when a terminal transition already won the race.
1014
+ // is still running, promote it as a tracked background job instead of
1015
+ // tree-killing it. Falls through to the old kill path for
1016
+ // disallowed/opted-out commands (backgroundOnTimeout false) or when a
1017
+ // terminal transition already won the race.
838
1018
  if (
839
1019
  backgroundOnTimeout &&
840
- !_isBackground &&
841
1020
  !settled &&
842
1021
  !autoBackgrounded &&
843
1022
  !killed &&
@@ -856,9 +1035,8 @@ export function execShellCommand({
856
1035
  // Live-progress heartbeat: every 2 s while the foreground command runs,
857
1036
  // emit "running Ns" so the MCP client renders live progress
858
1037
  // instead of an opaque hang. Only armed for a genuine foreground run with
859
- // a subscribed client; trailing-`&` background commands settle on exit and
860
- // never need it. Cleared on settle / auto-background (see above).
861
- if (_hasProgress && !_isBackground) {
1038
+ // a subscribed client. Cleared on settle / auto-background (see above).
1039
+ if (_hasProgress) {
862
1040
  progressTimer = setInterval(() => {
863
1041
  if (settled || autoBackgrounded) return;
864
1042
  const secs = Math.round(_elapsedSinceStart() / 1000);
@@ -869,7 +1047,7 @@ export function execShellCommand({
869
1047
 
870
1048
  // Live output tail pump (see declaration above). Emits only on change so
871
1049
  // idle commands cost one getLiveTail per second and zero downstream work.
872
- if (_hasOutputTail && !_isBackground) {
1050
+ if (_hasOutputTail) {
873
1051
  outputTailTimer = setInterval(() => {
874
1052
  if (settled || autoBackgrounded) return;
875
1053
  try {
@@ -884,12 +1062,10 @@ export function execShellCommand({
884
1062
  }
885
1063
 
886
1064
  // Arm the auto-background timer only for the genuine foreground one-shot
887
- // path: a positive threshold strictly below the hard timeout, and not a
888
- // trailing-`&` background command (those already detach + settle on exit).
1065
+ // path: a positive threshold strictly below the hard timeout.
889
1066
  if (
890
1067
  typeof autoBackgroundMs === 'number' &&
891
1068
  autoBackgroundMs > 0 &&
892
- !_isBackground &&
893
1069
  (timeoutMs <= 0 || autoBackgroundMs < timeoutMs)
894
1070
  ) {
895
1071
  autoBgTimer = setTimeout(() => { fireAutoBackground(); }, autoBackgroundMs);
@@ -904,11 +1080,10 @@ export function execShellCommand({
904
1080
  // to stop is the worse outcome. Explicit cancellation (ESC) keeps the
905
1081
  // kill. The promotion reuses the timeout path's guards verbatim so a
906
1082
  // detached / already-settled / already-exited child can never be
907
- // adopted as a job.
1083
+ // promoted as a job.
908
1084
  if (
909
1085
  _abortReasonIsInterrupt(abortSignal) &&
910
1086
  backgroundOnTimeout &&
911
- !_isBackground &&
912
1087
  !settled &&
913
1088
  !autoBackgrounded &&
914
1089
  !killed &&