mixdog 0.7.1

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 (404) hide show
  1. package/.claude-plugin/marketplace.json +31 -0
  2. package/.claude-plugin/plugin.json +20 -0
  3. package/.gitattributes +34 -0
  4. package/.mcp.json +14 -0
  5. package/ARCHITECTURE.md +77 -0
  6. package/CHANGELOG.md +7 -0
  7. package/CONTRIBUTING.md +45 -0
  8. package/DATA-FLOW.md +79 -0
  9. package/LICENSE +21 -0
  10. package/README.md +389 -0
  11. package/SECURITY.md +138 -0
  12. package/UNINSTALL.md +112 -0
  13. package/agents/maintenance.md +5 -0
  14. package/agents/memory-classification.md +30 -0
  15. package/agents/scheduler-task.md +18 -0
  16. package/agents/webhook-handler.md +27 -0
  17. package/agents/worker.md +24 -0
  18. package/bin/bridge +133 -0
  19. package/bin/statusline-launcher.mjs +78 -0
  20. package/bin/statusline-lib.mjs +550 -0
  21. package/bin/statusline.mjs +607 -0
  22. package/bun.lock +802 -0
  23. package/commands/config.md +16 -0
  24. package/commands/doctor.md +13 -0
  25. package/commands/setup.md +17 -0
  26. package/defaults/cycle3-review-prompt.md +90 -0
  27. package/defaults/hidden-roles.json +65 -0
  28. package/defaults/memory-chunk-prompt.md +63 -0
  29. package/defaults/memory-promote-prompt.md +135 -0
  30. package/defaults/mixdog-config.template.json +27 -0
  31. package/defaults/user-workflow.json +8 -0
  32. package/defaults/user-workflow.md +12 -0
  33. package/hooks/hooks.json +73 -0
  34. package/hooks/lib/active-instance.cjs +77 -0
  35. package/hooks/lib/permission-evaluator.cjs +411 -0
  36. package/hooks/lib/permission-route.cjs +63 -0
  37. package/hooks/lib/permission-rules.cjs +170 -0
  38. package/hooks/lib/settings-loader.cjs +116 -0
  39. package/hooks/post-tool-use.cjs +84 -0
  40. package/hooks/pre-mcp-sandbox.cjs +158 -0
  41. package/hooks/pre-tool-subagent.cjs +253 -0
  42. package/hooks/session-start.cjs +1372 -0
  43. package/hooks/turn-timer.cjs +82 -0
  44. package/lib/claude-md-writer.cjs +386 -0
  45. package/lib/config-cjs.cjs +61 -0
  46. package/lib/hook-pipe-path.cjs +10 -0
  47. package/lib/keychain-cjs.cjs +263 -0
  48. package/lib/plugin-paths.cjs +61 -0
  49. package/lib/rules-builder.cjs +241 -0
  50. package/lib/text-utils.cjs +61 -0
  51. package/native/README.md +117 -0
  52. package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
  53. package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
  54. package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
  55. package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
  56. package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
  57. package/package.json +107 -0
  58. package/prompts/code-review.txt +16 -0
  59. package/prompts/security-audit.txt +17 -0
  60. package/rules/bridge/00-common.md +39 -0
  61. package/rules/bridge/20-skip-protocol.md +18 -0
  62. package/rules/bridge/30-explorer.md +33 -0
  63. package/rules/bridge/40-cycle1-agent.md +52 -0
  64. package/rules/bridge/41-cycle2-agent.md +62 -0
  65. package/rules/bridge/42-cycle3-agent.md +44 -0
  66. package/rules/lead/00-tool-lead.md +61 -0
  67. package/rules/lead/01-general.md +23 -0
  68. package/rules/lead/02-channels.md +49 -0
  69. package/rules/lead/03-team.md +27 -0
  70. package/rules/lead/04-workflow.md +20 -0
  71. package/rules/shared/00-language.md +14 -0
  72. package/rules/shared/01-tool.md +138 -0
  73. package/scripts/bootstrap.mjs +184 -0
  74. package/scripts/bridge-unify-smoke.mjs +308 -0
  75. package/scripts/build-runtime-linux.sh +348 -0
  76. package/scripts/build-runtime-macos.sh +217 -0
  77. package/scripts/build-runtime-windows.ps1 +242 -0
  78. package/scripts/builtin-utils-smoke.mjs +392 -0
  79. package/scripts/check-json.mjs +45 -0
  80. package/scripts/check-syntax-changed.mjs +102 -0
  81. package/scripts/check-syntax.mjs +58 -0
  82. package/scripts/code-graph-batch.test.mjs +33 -0
  83. package/scripts/config-preserve-smoke.mjs +180 -0
  84. package/scripts/doctor.mjs +484 -0
  85. package/scripts/edit-normalize-fuzz.mjs +130 -0
  86. package/scripts/edit-normalize-smoke.mjs +401 -0
  87. package/scripts/edit-operation-smoke.mjs +369 -0
  88. package/scripts/edit2-smoke.mjs +63 -0
  89. package/scripts/fuzzy-e2e.mjs +28 -0
  90. package/scripts/fuzzy-smoke.mjs +26 -0
  91. package/scripts/generate-runtime-manifest.mjs +166 -0
  92. package/scripts/guard-smoke.mjs +66 -0
  93. package/scripts/hidden-role-schema-smoke.mjs +162 -0
  94. package/scripts/hook-routing-smoke.mjs +29 -0
  95. package/scripts/inject-input.ps1 +204 -0
  96. package/scripts/io-complex-smoke.mjs +667 -0
  97. package/scripts/io-explore-bench.mjs +424 -0
  98. package/scripts/io-guardrails-smoke.mjs +205 -0
  99. package/scripts/io-mini-bench-baseline.json +11 -0
  100. package/scripts/io-mini-bench.mjs +216 -0
  101. package/scripts/io-route-harness.mjs +933 -0
  102. package/scripts/io-telemetry-report.mjs +691 -0
  103. package/scripts/mutation-bench.mjs +564 -0
  104. package/scripts/mutation-io-smoke.mjs +1081 -0
  105. package/scripts/native-patch-bridge-smoke.mjs +288 -0
  106. package/scripts/native-patch-smoke.mjs +304 -0
  107. package/scripts/patch-interior-context-smoke.mjs +49 -0
  108. package/scripts/patch-newline-utf8-smoke.mjs +157 -0
  109. package/scripts/perf-hook-smoke.mjs +71 -0
  110. package/scripts/permission-eval-smoke.mjs +426 -0
  111. package/scripts/prep-patch.mjs +53 -0
  112. package/scripts/prep-shim.mjs +96 -0
  113. package/scripts/provider-cache-smoke.mjs +687 -0
  114. package/scripts/report-runtime-health.mjs +132 -0
  115. package/scripts/run-mcp.mjs +1547 -0
  116. package/scripts/salvage-v4a-shatter.test.mjs +58 -0
  117. package/scripts/scoped-cache-io-smoke.mjs +103 -0
  118. package/scripts/shell-policy-round3-smoke.mjs +46 -0
  119. package/scripts/smoke-runtime-negative.ps1 +100 -0
  120. package/scripts/smoke-runtime-negative.sh +95 -0
  121. package/scripts/stall-policy-smoke.mjs +50 -0
  122. package/scripts/start-memory-worker.mjs +23 -0
  123. package/scripts/statusline-launcher-smoke.mjs +82 -0
  124. package/scripts/stress-atomic-write.mjs +1028 -0
  125. package/scripts/test-config-rmw-restore.mjs +122 -0
  126. package/scripts/test-fault-inject.mjs +164 -0
  127. package/scripts/test-large-file.mjs +174 -0
  128. package/scripts/tool-edge-smoke.mjs +209 -0
  129. package/scripts/uninstall.mjs +201 -0
  130. package/scripts/webhook-selfheal-smoke.mjs +29 -0
  131. package/scripts/write-overwrite-guard-smoke.mjs +56 -0
  132. package/server-main.mjs +3055 -0
  133. package/server.mjs +468 -0
  134. package/setup/config-merge.mjs +254 -0
  135. package/setup/install.mjs +120 -0
  136. package/setup/launch-core.mjs +507 -0
  137. package/setup/launch.mjs +101 -0
  138. package/setup/setup-server.mjs +3206 -0
  139. package/setup/setup.html +3693 -0
  140. package/skills/retro-skill-proposer/SKILL.md +92 -0
  141. package/skills/schedule-add/SKILL.md +77 -0
  142. package/skills/setup/SKILL.md +346 -0
  143. package/skills/webhook-add/SKILL.md +81 -0
  144. package/src/agent/bridge-stall-watchdog.mjs +337 -0
  145. package/src/agent/index.mjs +2138 -0
  146. package/src/agent/orchestrator/activity-bus.mjs +38 -0
  147. package/src/agent/orchestrator/ai-wrapped-dispatch.mjs +1010 -0
  148. package/src/agent/orchestrator/bridge-retry.mjs +220 -0
  149. package/src/agent/orchestrator/bridge-trace.mjs +583 -0
  150. package/src/agent/orchestrator/cache-mtime.mjs +58 -0
  151. package/src/agent/orchestrator/config.mjs +358 -0
  152. package/src/agent/orchestrator/context/collect.mjs +651 -0
  153. package/src/agent/orchestrator/dispatch-persist.mjs +549 -0
  154. package/src/agent/orchestrator/drain-registry.mjs +50 -0
  155. package/src/agent/orchestrator/explore-validator.mjs +8 -0
  156. package/src/agent/orchestrator/internal-roles.mjs +118 -0
  157. package/src/agent/orchestrator/internal-tools.mjs +88 -0
  158. package/src/agent/orchestrator/jobs.mjs +116 -0
  159. package/src/agent/orchestrator/mcp/client.mjs +364 -0
  160. package/src/agent/orchestrator/providers/anthropic-betas.mjs +21 -0
  161. package/src/agent/orchestrator/providers/anthropic-oauth.mjs +1745 -0
  162. package/src/agent/orchestrator/providers/anthropic.mjs +437 -0
  163. package/src/agent/orchestrator/providers/gemini.mjs +1175 -0
  164. package/src/agent/orchestrator/providers/grok-oauth.mjs +782 -0
  165. package/src/agent/orchestrator/providers/model-catalog.mjs +241 -0
  166. package/src/agent/orchestrator/providers/openai-compat.mjs +1467 -0
  167. package/src/agent/orchestrator/providers/openai-oauth-ws.mjs +1890 -0
  168. package/src/agent/orchestrator/providers/openai-oauth.mjs +1307 -0
  169. package/src/agent/orchestrator/providers/openai-ws.mjs +104 -0
  170. package/src/agent/orchestrator/providers/registry.mjs +192 -0
  171. package/src/agent/orchestrator/providers/retry-classifier.mjs +325 -0
  172. package/src/agent/orchestrator/session/abort-lookup.mjs +13 -0
  173. package/src/agent/orchestrator/session/cache/post-edit-marks.mjs +42 -0
  174. package/src/agent/orchestrator/session/cache/prefetch-cache.mjs +142 -0
  175. package/src/agent/orchestrator/session/cache/read-cache.mjs +319 -0
  176. package/src/agent/orchestrator/session/cache/scoped-cache-outcome.mjs +11 -0
  177. package/src/agent/orchestrator/session/cache/scoped-cache.mjs +361 -0
  178. package/src/agent/orchestrator/session/cache/util.mjs +49 -0
  179. package/src/agent/orchestrator/session/loop.mjs +1478 -0
  180. package/src/agent/orchestrator/session/manager.mjs +1975 -0
  181. package/src/agent/orchestrator/session/read-dedup.mjs +6 -0
  182. package/src/agent/orchestrator/session/result-classification.mjs +65 -0
  183. package/src/agent/orchestrator/session/save-session-worker.mjs +18 -0
  184. package/src/agent/orchestrator/session/store.mjs +624 -0
  185. package/src/agent/orchestrator/session/stream-watchdog.mjs +130 -0
  186. package/src/agent/orchestrator/session/tool-result-offload.mjs +166 -0
  187. package/src/agent/orchestrator/session/trim.mjs +491 -0
  188. package/src/agent/orchestrator/smart-bridge/CACHE-SHARD.md +115 -0
  189. package/src/agent/orchestrator/smart-bridge/bridge-llm.mjs +327 -0
  190. package/src/agent/orchestrator/smart-bridge/cache-obs.mjs +150 -0
  191. package/src/agent/orchestrator/smart-bridge/cache-strategy.mjs +228 -0
  192. package/src/agent/orchestrator/smart-bridge/index.mjs +215 -0
  193. package/src/agent/orchestrator/smart-bridge/profiles.mjs +37 -0
  194. package/src/agent/orchestrator/smart-bridge/registry.mjs +348 -0
  195. package/src/agent/orchestrator/smart-bridge/session-builder.mjs +116 -0
  196. package/src/agent/orchestrator/stall-policy.mjs +195 -0
  197. package/src/agent/orchestrator/tool-loop-guard.mjs +75 -0
  198. package/src/agent/orchestrator/tools/bash-policy-scan.mjs +77 -0
  199. package/src/agent/orchestrator/tools/bash-session.mjs +721 -0
  200. package/src/agent/orchestrator/tools/builtin/advisory-lock.mjs +171 -0
  201. package/src/agent/orchestrator/tools/builtin/arg-guard.mjs +455 -0
  202. package/src/agent/orchestrator/tools/builtin/atomic-write.mjs +236 -0
  203. package/src/agent/orchestrator/tools/builtin/bash-tool.mjs +480 -0
  204. package/src/agent/orchestrator/tools/builtin/binary-file.mjs +76 -0
  205. package/src/agent/orchestrator/tools/builtin/builtin-tools.mjs +256 -0
  206. package/src/agent/orchestrator/tools/builtin/cache-layers.mjs +386 -0
  207. package/src/agent/orchestrator/tools/builtin/cwd-utils.mjs +37 -0
  208. package/src/agent/orchestrator/tools/builtin/device-paths.mjs +154 -0
  209. package/src/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +292 -0
  210. package/src/agent/orchestrator/tools/builtin/diff-utils.mjs +109 -0
  211. package/src/agent/orchestrator/tools/builtin/edit-base-guard.mjs +58 -0
  212. package/src/agent/orchestrator/tools/builtin/edit-byte-plan.mjs +240 -0
  213. package/src/agent/orchestrator/tools/builtin/edit-byte-utils.mjs +113 -0
  214. package/src/agent/orchestrator/tools/builtin/edit-commit.mjs +74 -0
  215. package/src/agent/orchestrator/tools/builtin/edit-context-utils.mjs +242 -0
  216. package/src/agent/orchestrator/tools/builtin/edit-diagnostics.mjs +211 -0
  217. package/src/agent/orchestrator/tools/builtin/edit-engine.mjs +1364 -0
  218. package/src/agent/orchestrator/tools/builtin/edit-failure-context.mjs +126 -0
  219. package/src/agent/orchestrator/tools/builtin/edit-hint.mjs +141 -0
  220. package/src/agent/orchestrator/tools/builtin/edit-match-utils.mjs +194 -0
  221. package/src/agent/orchestrator/tools/builtin/edit-partial-write.mjs +60 -0
  222. package/src/agent/orchestrator/tools/builtin/edit-stale-refresh.mjs +168 -0
  223. package/src/agent/orchestrator/tools/builtin/edit-tool.mjs +173 -0
  224. package/src/agent/orchestrator/tools/builtin/edit-utf8-guard.mjs +48 -0
  225. package/src/agent/orchestrator/tools/builtin/fs-reachability.mjs +48 -0
  226. package/src/agent/orchestrator/tools/builtin/fuzzy-match.mjs +99 -0
  227. package/src/agent/orchestrator/tools/builtin/glob-walk.mjs +170 -0
  228. package/src/agent/orchestrator/tools/builtin/grep-formatting.mjs +113 -0
  229. package/src/agent/orchestrator/tools/builtin/hash-utils.mjs +6 -0
  230. package/src/agent/orchestrator/tools/builtin/list-formatting.mjs +7 -0
  231. package/src/agent/orchestrator/tools/builtin/list-tool.mjs +593 -0
  232. package/src/agent/orchestrator/tools/builtin/native-edit-runner.mjs +89 -0
  233. package/src/agent/orchestrator/tools/builtin/notebook-edit-tool.mjs +300 -0
  234. package/src/agent/orchestrator/tools/builtin/open-config-tool.mjs +26 -0
  235. package/src/agent/orchestrator/tools/builtin/path-diagnostics.mjs +152 -0
  236. package/src/agent/orchestrator/tools/builtin/path-locks.mjs +35 -0
  237. package/src/agent/orchestrator/tools/builtin/path-utils.mjs +201 -0
  238. package/src/agent/orchestrator/tools/builtin/read-args.mjs +103 -0
  239. package/src/agent/orchestrator/tools/builtin/read-batch.mjs +172 -0
  240. package/src/agent/orchestrator/tools/builtin/read-constants.mjs +40 -0
  241. package/src/agent/orchestrator/tools/builtin/read-formatting.mjs +118 -0
  242. package/src/agent/orchestrator/tools/builtin/read-image-resize.mjs +189 -0
  243. package/src/agent/orchestrator/tools/builtin/read-image.mjs +88 -0
  244. package/src/agent/orchestrator/tools/builtin/read-lines.mjs +12 -0
  245. package/src/agent/orchestrator/tools/builtin/read-mode-tool.mjs +455 -0
  246. package/src/agent/orchestrator/tools/builtin/read-open.mjs +190 -0
  247. package/src/agent/orchestrator/tools/builtin/read-range-index.mjs +271 -0
  248. package/src/agent/orchestrator/tools/builtin/read-ranges.mjs +26 -0
  249. package/src/agent/orchestrator/tools/builtin/read-single-tool.mjs +728 -0
  250. package/src/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +173 -0
  251. package/src/agent/orchestrator/tools/builtin/read-special-files.mjs +268 -0
  252. package/src/agent/orchestrator/tools/builtin/read-streaming.mjs +602 -0
  253. package/src/agent/orchestrator/tools/builtin/read-tool.mjs +530 -0
  254. package/src/agent/orchestrator/tools/builtin/read-windows.mjs +107 -0
  255. package/src/agent/orchestrator/tools/builtin/rename-tool.mjs +196 -0
  256. package/src/agent/orchestrator/tools/builtin/rg-runner.mjs +422 -0
  257. package/src/agent/orchestrator/tools/builtin/search-builders.mjs +158 -0
  258. package/src/agent/orchestrator/tools/builtin/search-tool.mjs +869 -0
  259. package/src/agent/orchestrator/tools/builtin/shell-analysis.mjs +653 -0
  260. package/src/agent/orchestrator/tools/builtin/shell-jobs.mjs +936 -0
  261. package/src/agent/orchestrator/tools/builtin/shell-output.mjs +36 -0
  262. package/src/agent/orchestrator/tools/builtin/shell-runtime.mjs +214 -0
  263. package/src/agent/orchestrator/tools/builtin/snapshot-helpers.mjs +143 -0
  264. package/src/agent/orchestrator/tools/builtin/snapshot-store.mjs +206 -0
  265. package/src/agent/orchestrator/tools/builtin/snapshot-validation.mjs +98 -0
  266. package/src/agent/orchestrator/tools/builtin/text-stats.mjs +69 -0
  267. package/src/agent/orchestrator/tools/builtin/windows-roots.mjs +23 -0
  268. package/src/agent/orchestrator/tools/builtin/write-tool.mjs +401 -0
  269. package/src/agent/orchestrator/tools/builtin.mjs +500 -0
  270. package/src/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +39 -0
  271. package/src/agent/orchestrator/tools/code-graph-tool-defs.mjs +24 -0
  272. package/src/agent/orchestrator/tools/code-graph.mjs +4095 -0
  273. package/src/agent/orchestrator/tools/cwd-tool.mjs +298 -0
  274. package/src/agent/orchestrator/tools/destructive-warning.mjs +323 -0
  275. package/src/agent/orchestrator/tools/edit-normalize.mjs +603 -0
  276. package/src/agent/orchestrator/tools/env-scrub.mjs +100 -0
  277. package/src/agent/orchestrator/tools/graph-binary-fetcher.mjs +144 -0
  278. package/src/agent/orchestrator/tools/graph-manifest.json +26 -0
  279. package/src/agent/orchestrator/tools/host-input.mjs +204 -0
  280. package/src/agent/orchestrator/tools/mutation-content-cache.mjs +67 -0
  281. package/src/agent/orchestrator/tools/mutation-planner.mjs +75 -0
  282. package/src/agent/orchestrator/tools/next-call-utils.mjs +48 -0
  283. package/src/agent/orchestrator/tools/patch-binary-fetcher.mjs +133 -0
  284. package/src/agent/orchestrator/tools/patch-manifest.json +26 -0
  285. package/src/agent/orchestrator/tools/patch-tool-defs.mjs +20 -0
  286. package/src/agent/orchestrator/tools/patch.mjs +2754 -0
  287. package/src/agent/orchestrator/tools/progress-message.mjs +118 -0
  288. package/src/agent/orchestrator/tools/result-compression.mjs +279 -0
  289. package/src/agent/orchestrator/tools/shell-command.mjs +865 -0
  290. package/src/agent/orchestrator/tools/shell-exec-policy.mjs +89 -0
  291. package/src/agent/orchestrator/tools/shell-policy-danger-target.mjs +27 -0
  292. package/src/agent/orchestrator/tools/shell-policy-imports.mjs +7 -0
  293. package/src/agent/orchestrator/tools/shell-policy.mjs +345 -0
  294. package/src/agent/orchestrator/tools/shell-snapshot.mjs +313 -0
  295. package/src/agent/orchestrator/workflow-store.mjs +93 -0
  296. package/src/agent/tool-defs.mjs +103 -0
  297. package/src/channels/backends/discord.mjs +784 -0
  298. package/src/channels/data/voice-runtime-manifest.json +138 -0
  299. package/src/channels/index.mjs +3229 -0
  300. package/src/channels/lib/cli-worker-host.mjs +12 -0
  301. package/src/channels/lib/config-lock.mjs +13 -0
  302. package/src/channels/lib/config.mjs +292 -0
  303. package/src/channels/lib/drop-trace.mjs +71 -0
  304. package/src/channels/lib/event-pipeline.mjs +81 -0
  305. package/src/channels/lib/event-queue.mjs +345 -0
  306. package/src/channels/lib/executor.mjs +168 -0
  307. package/src/channels/lib/format.mjs +188 -0
  308. package/src/channels/lib/holidays.mjs +138 -0
  309. package/src/channels/lib/hook-pipe-server.mjs +802 -0
  310. package/src/channels/lib/interaction-workflows.mjs +184 -0
  311. package/src/channels/lib/memory-client.mjs +149 -0
  312. package/src/channels/lib/output-forwarder.mjs +765 -0
  313. package/src/channels/lib/runtime-paths.mjs +479 -0
  314. package/src/channels/lib/scheduler.mjs +723 -0
  315. package/src/channels/lib/session-control.mjs +36 -0
  316. package/src/channels/lib/session-discovery.mjs +103 -0
  317. package/src/channels/lib/settings.mjs +11 -0
  318. package/src/channels/lib/state-file.mjs +68 -0
  319. package/src/channels/lib/status-snapshot.mjs +219 -0
  320. package/src/channels/lib/tool-format.mjs +140 -0
  321. package/src/channels/lib/transcript-discovery.mjs +195 -0
  322. package/src/channels/lib/voice-runtime-fetcher.mjs +734 -0
  323. package/src/channels/lib/webhook.mjs +1179 -0
  324. package/src/channels/lib/whisper-server.mjs +477 -0
  325. package/src/channels/tool-defs.mjs +170 -0
  326. package/src/daemon/host.mjs +118 -0
  327. package/src/daemon/mcp-transport.mjs +47 -0
  328. package/src/daemon/session.mjs +100 -0
  329. package/src/daemon/thin-client.mjs +71 -0
  330. package/src/daemon/transport.mjs +163 -0
  331. package/src/memory/data/runtime-manifest.json +40 -0
  332. package/src/memory/index.mjs +3305 -0
  333. package/src/memory/lib/agent-ipc.mjs +93 -0
  334. package/src/memory/lib/bridge-trace-queries.mjs +120 -0
  335. package/src/memory/lib/core-memory-store.mjs +330 -0
  336. package/src/memory/lib/embedding-provider.mjs +269 -0
  337. package/src/memory/lib/embedding-worker.mjs +323 -0
  338. package/src/memory/lib/llm-worker-host.mjs +17 -0
  339. package/src/memory/lib/memory-cycle.mjs +11 -0
  340. package/src/memory/lib/memory-cycle1.mjs +641 -0
  341. package/src/memory/lib/memory-cycle2.mjs +1284 -0
  342. package/src/memory/lib/memory-cycle3.mjs +540 -0
  343. package/src/memory/lib/memory-embed.mjs +299 -0
  344. package/src/memory/lib/memory-extraction.mjs +5 -0
  345. package/src/memory/lib/memory-maintenance-store.mjs +32 -0
  346. package/src/memory/lib/memory-ops-policy.mjs +190 -0
  347. package/src/memory/lib/memory-recall-id-patch.mjs +15 -0
  348. package/src/memory/lib/memory-recall-read-query.mjs +7 -0
  349. package/src/memory/lib/memory-recall-scope-filter.mjs +63 -0
  350. package/src/memory/lib/memory-recall-store.mjs +621 -0
  351. package/src/memory/lib/memory-retrievers.mjs +112 -0
  352. package/src/memory/lib/memory-score.mjs +71 -0
  353. package/src/memory/lib/memory-text-utils.mjs +58 -0
  354. package/src/memory/lib/memory.mjs +412 -0
  355. package/src/memory/lib/model-profile.mjs +85 -0
  356. package/src/memory/lib/pg/adapter.mjs +308 -0
  357. package/src/memory/lib/pg/process.mjs +360 -0
  358. package/src/memory/lib/pg/supervisor.mjs +396 -0
  359. package/src/memory/lib/project-id-resolver.mjs +86 -0
  360. package/src/memory/lib/runtime-fetcher.mjs +442 -0
  361. package/src/memory/lib/trace-store.mjs +728 -0
  362. package/src/memory/tool-defs.mjs +79 -0
  363. package/src/search/index.mjs +1173 -0
  364. package/src/search/lib/backends/anthropic-oauth.mjs +98 -0
  365. package/src/search/lib/backends/exa.mjs +50 -0
  366. package/src/search/lib/backends/firecrawl.mjs +61 -0
  367. package/src/search/lib/backends/gemini-api.mjs +83 -0
  368. package/src/search/lib/backends/grok-oauth.mjs +86 -0
  369. package/src/search/lib/backends/index.mjs +150 -0
  370. package/src/search/lib/backends/openai-api.mjs +144 -0
  371. package/src/search/lib/backends/openai-oauth.mjs +98 -0
  372. package/src/search/lib/backends/openai-web-search.mjs +76 -0
  373. package/src/search/lib/backends/tavily.mjs +55 -0
  374. package/src/search/lib/backends/xai-api.mjs +113 -0
  375. package/src/search/lib/cache.mjs +131 -0
  376. package/src/search/lib/config.mjs +192 -0
  377. package/src/search/lib/formatter.mjs +115 -0
  378. package/src/search/lib/provider-usage.mjs +67 -0
  379. package/src/search/lib/providers.mjs +47 -0
  380. package/src/search/lib/search-intent.mjs +109 -0
  381. package/src/search/lib/setup-handler.mjs +261 -0
  382. package/src/search/lib/state.mjs +201 -0
  383. package/src/search/lib/web-tools.mjs +1207 -0
  384. package/src/search/tool-defs.mjs +83 -0
  385. package/src/setup/defender-exclusion.mjs +183 -0
  386. package/src/shared/abort-controller.mjs +15 -0
  387. package/src/shared/atomic-file.mjs +420 -0
  388. package/src/shared/config.mjs +350 -0
  389. package/src/shared/daemon-recycle.mjs +108 -0
  390. package/src/shared/disable-claude-builtins.mjs +88 -0
  391. package/src/shared/err-text.mjs +12 -0
  392. package/src/shared/llm/cost.mjs +66 -0
  393. package/src/shared/llm/http-agent.mjs +123 -0
  394. package/src/shared/llm/index.mjs +41 -0
  395. package/src/shared/llm/pid-cleanup.mjs +27 -0
  396. package/src/shared/llm/usage-log.mjs +47 -0
  397. package/src/shared/plugin-paths.mjs +58 -0
  398. package/src/shared/schedules-store.mjs +70 -0
  399. package/src/shared/seed.mjs +119 -0
  400. package/src/shared/user-cwd.mjs +213 -0
  401. package/src/shared/user-data-guard.mjs +238 -0
  402. package/src/status/aggregator.mjs +584 -0
  403. package/src/status/server.mjs +413 -0
  404. package/tools.json +1653 -0
@@ -0,0 +1,936 @@
1
+ import { spawn } from 'child_process';
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, watch as fsWatch, writeFileSync } from 'fs';
3
+ import * as fsPromises from 'fs/promises';
4
+ import { basename, join } from 'path';
5
+ import { getPluginData } from '../../config.mjs';
6
+ import { stripAnsi } from '../shell-command.mjs';
7
+ import { normalizeOutputPath } from './path-utils.mjs';
8
+ import { scrubLoaderVars, scrubProviderSecrets } from '../env-scrub.mjs';
9
+
10
+ function sleep(ms) {
11
+ return new Promise((resolve) => setTimeout(resolve, ms));
12
+ }
13
+
14
+ // One-shot sweep of stale shell-job artefacts. Each backgrounded `bash`
15
+ // emits five files (.json/.done/.exit/.stdout.log/.stderr.log); the .done
16
+ // flag is written when the job exits, so a .done file older than
17
+ // SHELL_JOB_STALE_MS is the invariant proof its sibling files are also
18
+ // safe to remove. Active and recently-completed jobs are kept so
19
+ // `job_wait`/status readers still find them. Runs once per mcp child
20
+ // lifetime on first getShellJobsDir() call. Async dirent walk + parallel
21
+ // stat/unlink keeps the main event loop free; fire-and-forget so the
22
+ // synchronous caller receives `dir` immediately.
23
+ const SHELL_JOB_STALE_MS = 24 * 60 * 60 * 1000;
24
+ let shellJobsSwept = false;
25
+ async function sweepStaleShellJobs(dir) {
26
+ if (shellJobsSwept) return;
27
+ shellJobsSwept = true;
28
+ const cutoff = Date.now() - SHELL_JOB_STALE_MS;
29
+ let names;
30
+ try { names = await fsPromises.readdir(dir); } catch { return; }
31
+ const expired = [];
32
+ await Promise.all(names.map(async (name) => {
33
+ if (!name.endsWith('.done')) return;
34
+ const p = join(dir, name);
35
+ try {
36
+ const st = await fsPromises.stat(p);
37
+ if (st.mtimeMs < cutoff) expired.push(name.slice(0, -5));
38
+ } catch {}
39
+ }));
40
+ // Orphan reclaim: a crashed wrapper leaves <id>.json with no .done —
41
+ // forever. Invariant proofs of death (either suffices, both gated on the
42
+ // stale cutoff so a young orphan can't race its own spawn):
43
+ // a) deadline: the wrapper enforces timeoutMs, so an entry older than
44
+ // timeoutMs + grace cannot still be running — pid-reuse-proof. Only
45
+ // trusted on runtime proof: detail.timeoutEnforced:true (PS wrapper,
46
+ // unconditional) or the <id>.enforced marker the posix wrapper
47
+ // touches when its `timeout` branch actually runs.
48
+ // b) ESRCH: the recorded pid no longer exists. Alone this misses pids
49
+ // recycled by unrelated live processes, hence (a).
50
+ const ORPHAN_DEADLINE_GRACE_MS = 30 * 60_000;
51
+ const doneSet = new Set(names.filter(n => n.endsWith('.done')).map(n => n.slice(0, -5)));
52
+ await Promise.all(names.map(async (name) => {
53
+ if (!name.endsWith('.json')) return;
54
+ const jobId = name.slice(0, -5);
55
+ if (doneSet.has(jobId)) return;
56
+ const p = join(dir, name);
57
+ try {
58
+ const st = await fsPromises.stat(p);
59
+ if (st.mtimeMs >= cutoff) return;
60
+ const detail = JSON.parse(await fsPromises.readFile(p, 'utf-8'));
61
+ const tmo = Number(detail?.timeoutMs);
62
+ const enforced = detail?.timeoutEnforced === true
63
+ || existsSync(join(dir, `${jobId}.enforced`));
64
+ const deadlinePassed = enforced
65
+ && Number.isFinite(tmo) && tmo > 0
66
+ && (Date.now() - st.mtimeMs) > tmo + ORPHAN_DEADLINE_GRACE_MS;
67
+ if (!deadlinePassed) {
68
+ const pid = Number(detail?.pid);
69
+ if (Number.isFinite(pid) && pid > 0) {
70
+ try { process.kill(pid, 0); return; } // alive (or EPERM → treated dead only via ESRCH below)
71
+ catch (e) { if (e?.code !== 'ESRCH') return; }
72
+ }
73
+ }
74
+ expired.push(jobId);
75
+ } catch {}
76
+ }));
77
+ // Owner sidecar markers carry a dynamic `.owner-<pid>` suffix, so they
78
+ // can't sit in the fixed-extension list — map each expired jobId to the
79
+ // marker name(s) actually present in this listing and unlink those too.
80
+ const expiredSet = new Set(expired);
81
+ const ownerMarkers = names.filter((n) => {
82
+ const i = n.lastIndexOf('.owner-');
83
+ return i > 0 && expiredSet.has(n.slice(0, i));
84
+ });
85
+ await Promise.all([
86
+ ...expired.flatMap((jobId) =>
87
+ ['.json', '.done', '.exit', '.enforced', '.exit.cmd.sh', '.exit.cmd.ps1', '.stdout.log', '.stderr.log'].map((ext) =>
88
+ fsPromises.unlink(join(dir, jobId + ext)).catch(() => {}),
89
+ ),
90
+ ),
91
+ ...ownerMarkers.map((n) => fsPromises.unlink(join(dir, n)).catch(() => {})),
92
+ ]);
93
+ }
94
+
95
+ function getShellJobsDir() {
96
+ const dir = join(getPluginData(), 'shell-jobs');
97
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
98
+ sweepStaleShellJobs(dir);
99
+ return dir;
100
+ }
101
+
102
+ function shellJobDetailPath(jobId) { return join(getShellJobsDir(), `${jobId}.json`); }
103
+ function shellJobStdoutPath(jobId) { return join(getShellJobsDir(), `${jobId}.stdout.log`); }
104
+ function shellJobStderrPath(jobId) { return join(getShellJobsDir(), `${jobId}.stderr.log`); }
105
+ function shellJobExitPath(jobId) { return join(getShellJobsDir(), `${jobId}.exit`); }
106
+ function shellJobDonePath(jobId) { return join(getShellJobsDir(), `${jobId}.done`); }
107
+ // Runtime proof that the posix wrapper's `timeout` branch actually ran: the
108
+ // wrapper touches this marker immediately before exec'ing `timeout`, so its
109
+ // existence is never optimistic (no spawn-time probe can guarantee the
110
+ // wrapper's own env/cwd resolution). PS jobs don't need it — their wrapper
111
+ // enforces unconditionally and records detail.timeoutEnforced:true.
112
+ function shellJobEnforcedPath(jobId) { return join(getShellJobsDir(), `${jobId}.enforced`); }
113
+ // Owner sidecar marker: a zero-byte file whose NAME encodes the owning CC host
114
+ // (claude.exe) pid — `<jobId>.owner-<pid>`. It lets the statusline owner-filter
115
+ // jobs from a SINGLE directory listing (no per-job JSON read) so the filter can
116
+ // precede its per-tick scan cap — otherwise other sessions' newer jobs evict
117
+ // this session's live jobs before filtering. Swept alongside the other
118
+ // artefacts.
119
+ function shellJobOwnerPath(jobId, pid) { return join(getShellJobsDir(), `${jobId}.owner-${pid}`); }
120
+
121
+ const JOB_STATUS_PREVIEW_MAX_BYTES = 4096;
122
+ const JOB_STATUS_PREVIEW_MAX_LINES = 20;
123
+ const JOB_STATUS_PREVIEW_MAX_CHARS = 1200;
124
+
125
+ // Resolve the CC host pid (claude.exe) that owns a freshly-spawned job. In the
126
+ // shared-daemon model one daemon serves many terminals but keeps the FIRST
127
+ // spawner's env, so process.env.MIXDOG_OWNER_HOST_PID is the daemon owner's pid
128
+ // — wrong for jobs spawned by any other terminal. The per-request
129
+ // callerSession.clientHostPid (threaded from server-main through the spawn
130
+ // site) is the correct, terminal-specific pid; the env var is only the
131
+ // documented single-client fallback where the two are identical (or for
132
+ // non-MCP callers that thread nothing).
133
+ function resolveJobOwnerHostPid(clientHostPid) {
134
+ const explicit = Number(clientHostPid);
135
+ if (Number.isInteger(explicit) && explicit > 0) return explicit;
136
+ const envPid = Number(process.env.MIXDOG_OWNER_HOST_PID);
137
+ if (Number.isInteger(envPid) && envPid > 0) return envPid;
138
+ return null;
139
+ }
140
+
141
+ function writeShellJobDetail(detail) {
142
+ // Session scope stamp: every job record is tagged with the CC host pid
143
+ // that owns it (the claude.exe pid). This is the SAME physical pid the
144
+ // statusline shim passes as --client-host-pid, so the statusline can count
145
+ // only its own session's jobs by exact pid equality (no heuristic). The
146
+ // spawn sites set detail.ownerHostPid from the per-request threaded
147
+ // clientHostPid (resolveJobOwnerHostPid); this fallback only stamps the
148
+ // single-client env value when no spawn-site pid was set, and NEVER
149
+ // overwrites an existing stamp — so the field round-trips through disk on
150
+ // refresh/kill and a correct per-terminal stamp is preserved across rewrites.
151
+ if (detail && detail.ownerHostPid == null) {
152
+ const hostPid = Number(process.env.MIXDOG_OWNER_HOST_PID);
153
+ if (Number.isInteger(hostPid) && hostPid > 0) detail.ownerHostPid = hostPid;
154
+ }
155
+ writeFileSync(shellJobDetailPath(detail.jobId), JSON.stringify(detail, null, 2), 'utf-8');
156
+ // Owner sidecar: encode the resolved owner pid in the marker filename so the
157
+ // statusline can owner-filter from the directory listing alone (before its
158
+ // scan cap). Idempotent zero-byte write — safe to repeat on refresh/kill
159
+ // rewrites. Skipped when no owner is known (legacy/unattributed jobs).
160
+ if (detail && Number.isInteger(detail.ownerHostPid) && detail.ownerHostPid > 0) {
161
+ try { writeFileSync(shellJobOwnerPath(detail.jobId, detail.ownerHostPid), '', 'utf-8'); }
162
+ catch { /* best-effort marker */ }
163
+ }
164
+ }
165
+
166
+ function readShellJobDetail(jobId) {
167
+ try {
168
+ const p = shellJobDetailPath(jobId);
169
+ if (!existsSync(p)) return null;
170
+ return JSON.parse(readFileSync(p, 'utf-8'));
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ export function buildJobNotFoundMessage(jobId) {
177
+ return `Error: job not found: ${jobId}`;
178
+ }
179
+
180
+ function isPidAlive(pid) {
181
+ if (!Number.isFinite(pid) || pid <= 0) return false;
182
+ try {
183
+ process.kill(pid, 0);
184
+ return true;
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
189
+
190
+ function killProcessTree(pid, signal = 'SIGTERM') {
191
+ if (!Number.isFinite(pid) || pid <= 0) return false;
192
+ try {
193
+ if (process.platform === 'win32') {
194
+ spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { windowsHide: true, stdio: 'ignore' });
195
+ } else {
196
+ try { process.kill(-pid, signal); }
197
+ catch { process.kill(pid, signal); }
198
+ // SIGKILL escalation: a background child that ignores SIGTERM must
199
+ // not survive (foreground treeKill / persistent _killProcessTree
200
+ // already do this). After a 3s grace, force-kill the group/pid.
201
+ // unref so this backstop never holds the host process open.
202
+ if (signal === 'SIGTERM') {
203
+ const t = setTimeout(() => {
204
+ try { process.kill(-pid, 'SIGKILL'); }
205
+ catch { try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } }
206
+ }, 3000);
207
+ if (t.unref) t.unref();
208
+ }
209
+ }
210
+ return true;
211
+ } catch {
212
+ return false;
213
+ }
214
+ }
215
+
216
+ // Module-level tracking of live background-job pids so the host process
217
+ // can reap orphaned children on exit. Without this, detached `bash`
218
+ // background jobs survive host death and continue running. Mirrors the
219
+ // bash-session.mjs _installParentExitHook / _killProcessTree pattern;
220
+ // on synchronous signal/exit paths we send SIGKILL directly because the
221
+ // async grace period from killProcessTree() cannot run inside a sync
222
+ // exit handler.
223
+ const _liveJobPids = new Set();
224
+ let _shellJobsExitHookInstalled = false;
225
+ function _registerLiveJobPid(pid) {
226
+ if (Number.isFinite(pid) && pid > 0) _liveJobPids.add(pid);
227
+ }
228
+ function _unregisterLiveJobPid(pid) {
229
+ if (Number.isFinite(pid) && pid > 0) _liveJobPids.delete(pid);
230
+ }
231
+ function _sweepLiveJobsSync() {
232
+ for (const pid of _liveJobPids) {
233
+ try {
234
+ if (process.platform === 'win32') {
235
+ spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { windowsHide: true, stdio: 'ignore' });
236
+ } else {
237
+ try { process.kill(-pid, 'SIGKILL'); }
238
+ catch { try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ } }
239
+ }
240
+ } catch { /* ignore */ }
241
+ }
242
+ _liveJobPids.clear();
243
+ }
244
+ function _installShellJobsExitHook() {
245
+ if (_shellJobsExitHookInstalled) return;
246
+ _shellJobsExitHookInstalled = true;
247
+ try { process.on('exit', _sweepLiveJobsSync); } catch { /* ignore */ }
248
+ try { process.on('SIGTERM', _sweepLiveJobsSync); } catch { /* ignore */ }
249
+ try { process.on('SIGINT', _sweepLiveJobsSync); } catch { /* ignore */ }
250
+ }
251
+
252
+ function shellQuoteSingle(s) {
253
+ return `'${String(s).replace(/'/g, `'\"'\"'`)}'`;
254
+ }
255
+
256
+ function psSingleQuote(s) {
257
+ return `'${String(s).replace(/'/g, "''")}'`;
258
+ }
259
+
260
+ function powerShellEncodedCommand(command) {
261
+ return Buffer.from(String(command || ''), 'utf16le').toString('base64');
262
+ }
263
+
264
+ function isPowerShellShell(shell, shellType) {
265
+ if (shellType === 'powershell') return true;
266
+ const stem = basename(String(shell || '')).toLowerCase().replace(/\.exe$/, '');
267
+ return stem === 'pwsh' || stem === 'powershell';
268
+ }
269
+
270
+ function readTailPreviewSync(filePath, { maxBytes = JOB_STATUS_PREVIEW_MAX_BYTES, maxLines = JOB_STATUS_PREVIEW_MAX_LINES, maxChars = JOB_STATUS_PREVIEW_MAX_CHARS } = {}) {
271
+ try {
272
+ if (!filePath || !existsSync(filePath)) return null;
273
+ const st = statSync(filePath);
274
+ if (!st.isFile()) return null;
275
+ const size = st.size;
276
+ if (size <= 0) return { bytes: 0, preview: '' };
277
+ const readBytes = Math.min(size, maxBytes);
278
+ const fd = openSync(filePath, 'r');
279
+ try {
280
+ const buf = Buffer.alloc(readBytes);
281
+ readSync(fd, buf, 0, readBytes, size - readBytes);
282
+ let text = buf.toString('utf8');
283
+ if (size > readBytes) {
284
+ const nl = text.indexOf('\n');
285
+ if (nl !== -1) text = text.slice(nl + 1);
286
+ }
287
+ let lines = text.split(/\r?\n/);
288
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
289
+ let truncated = size > readBytes;
290
+ if (lines.length > maxLines) {
291
+ lines = lines.slice(-maxLines);
292
+ truncated = true;
293
+ }
294
+ let preview = lines.join('\n');
295
+ if (preview.length > maxChars) {
296
+ preview = preview.slice(preview.length - maxChars);
297
+ const nl = preview.indexOf('\n');
298
+ if (nl !== -1) preview = preview.slice(nl + 1);
299
+ truncated = true;
300
+ }
301
+ return {
302
+ bytes: size,
303
+ preview,
304
+ truncated,
305
+ };
306
+ } finally {
307
+ try { closeSync(fd); } catch { /* ignore */ }
308
+ }
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
313
+
314
+ function attachJobPreview(detail) {
315
+ if (!detail || typeof detail !== 'object') return detail;
316
+ const withPreview = { ...detail };
317
+ const stdoutInfo = readTailPreviewSync(detail.stdoutPath);
318
+ if (stdoutInfo) {
319
+ withPreview.stdoutBytes = stdoutInfo.bytes;
320
+ if (stdoutInfo.preview) withPreview.stdoutPreview = stdoutInfo.preview;
321
+ if (stdoutInfo.truncated) withPreview.stdoutPreviewTruncated = true;
322
+ }
323
+ if (detail.mergeStderr !== true) {
324
+ const stderrInfo = readTailPreviewSync(detail.stderrPath);
325
+ if (stderrInfo) {
326
+ withPreview.stderrBytes = stderrInfo.bytes;
327
+ if (stderrInfo.preview) withPreview.stderrPreview = stderrInfo.preview;
328
+ if (stderrInfo.truncated) withPreview.stderrPreviewTruncated = true;
329
+ }
330
+ }
331
+ return withPreview;
332
+ }
333
+
334
+ function summarizeJobPreviewText(text, maxChars = 160) {
335
+ if (typeof text !== 'string' || !text.trim()) return '';
336
+ const lines = text
337
+ .split(/\r?\n/)
338
+ .map((line) => stripAnsi(line).replace(/\s+/g, ' ').trim())
339
+ .filter(Boolean);
340
+ if (lines.length === 0) return '';
341
+ let summary = lines[lines.length - 1];
342
+ if (summary.length > maxChars) summary = `${summary.slice(0, maxChars - 1)}…`;
343
+ return summary;
344
+ }
345
+
346
+ function attachJobInsights(detail) {
347
+ const withPreview = attachJobPreview(detail);
348
+ if (!withPreview || typeof withPreview !== 'object') return withPreview;
349
+ let summary = '';
350
+ let summarySource = '';
351
+ if (withPreview.status === 'completed') {
352
+ summary = summarizeJobPreviewText(withPreview.stdoutPreview)
353
+ || summarizeJobPreviewText(withPreview.stderrPreview);
354
+ summarySource = summary ? (withPreview.stdoutPreview ? 'stdout' : 'stderr') : '';
355
+ } else if (withPreview.status === 'failed') {
356
+ summary = summarizeJobPreviewText(withPreview.stderrPreview)
357
+ || summarizeJobPreviewText(withPreview.stdoutPreview)
358
+ || String(withPreview.error || '').trim();
359
+ summarySource = summary ? (withPreview.stderrPreview ? 'stderr' : (withPreview.stdoutPreview ? 'stdout' : 'status')) : '';
360
+ } else if (withPreview.status === 'cancelled') {
361
+ summary = 'cancelled before completion';
362
+ summarySource = 'status';
363
+ } else if (withPreview.status === 'running') {
364
+ summary = summarizeJobPreviewText(withPreview.stdoutPreview)
365
+ || summarizeJobPreviewText(withPreview.stderrPreview);
366
+ summarySource = summary ? (withPreview.stdoutPreview ? 'stdout' : 'stderr') : '';
367
+ }
368
+ if (summary) {
369
+ withPreview.summary = summary;
370
+ withPreview.summarySource = summarySource;
371
+ }
372
+ return withPreview;
373
+ }
374
+
375
+ export async function waitForShellJob(jobId, { timeoutMs = 30_000, pollMs = 250 } = {}) {
376
+ const started = Date.now();
377
+ const deadline = started + Math.max(0, timeoutMs);
378
+ let detail = refreshShellJob(jobId);
379
+ if (!detail) return null;
380
+ while (detail && detail.status === 'running' && Date.now() < deadline) {
381
+ await sleep(Math.max(25, pollMs));
382
+ detail = refreshShellJob(jobId);
383
+ }
384
+ const withInsights = attachJobInsights(detail);
385
+ if (!withInsights) return null;
386
+ withInsights.waitedMs = Date.now() - started;
387
+ if (withInsights.status === 'running') withInsights.waitTimedOut = true;
388
+ return withInsights;
389
+ }
390
+
391
+ // Non-blocking peek at a background job (CC BashOutput analogue): refresh its
392
+ // status and return current stdout/stderr tail preview WITHOUT waiting for
393
+ // completion. Returns null if the job id is unknown.
394
+ export function peekShellJob(jobId) {
395
+ const detail = refreshShellJob(jobId);
396
+ if (!detail) return null;
397
+ return attachJobInsights(detail);
398
+ }
399
+
400
+ // Terminate a running background job (CC KillShell analogue): kill the process
401
+ // tree and mark the job failed/137. Returns null if unknown; a detail with
402
+ // killed:false if it had already finished.
403
+ export function killShellJob(jobId) {
404
+ const detail = readShellJobDetail(jobId);
405
+ if (!detail) return null;
406
+ if (detail.status !== 'running') {
407
+ return { ...detail, killed: false, note: `job already ${detail.status}` };
408
+ }
409
+ killProcessTree(detail.pid, 'SIGTERM');
410
+ detail.status = 'failed';
411
+ detail.exitCode = 137;
412
+ detail.error = 'killed by user (KillShell)';
413
+ detail.finishedAt = new Date().toISOString();
414
+ writeShellJobDetail(detail);
415
+ _unregisterLiveJobPid(detail.pid);
416
+ return { ...attachJobInsights(detail), killed: true };
417
+ }
418
+
419
+ function refreshShellJob(jobId) {
420
+ const detail = readShellJobDetail(jobId);
421
+ if (!detail) return null;
422
+ if (detail.status !== 'running') return detail;
423
+ const exitPath = shellJobExitPath(jobId);
424
+ const donePath = shellJobDonePath(jobId);
425
+ // Gate completion on donePath existence. The wrapper writes the
426
+ // exit-code file FIRST and `touch donePath` strictly AFTER, so a
427
+ // visible donePath proves the exit file is fully flushed. Reading
428
+ // exit before donePath landed produced empty-string -> NaN ->
429
+ // exitCode=null -> spurious 'failed' status for processes that
430
+ // actually exited 0.
431
+ if (existsSync(donePath)) {
432
+ let exitCode = null;
433
+ try {
434
+ const raw = readFileSync(exitPath, 'utf-8').trim();
435
+ const parsed = parseInt(raw, 10);
436
+ exitCode = Number.isFinite(parsed) ? parsed : null;
437
+ } catch { /* ignore */ }
438
+ let finishedAt = new Date().toISOString();
439
+ try {
440
+ finishedAt = new Date(statSync(donePath).mtimeMs).toISOString();
441
+ } catch { /* ignore */ }
442
+ detail.status = exitCode === 0 ? 'completed' : 'failed';
443
+ detail.exitCode = exitCode;
444
+ detail.finishedAt = finishedAt;
445
+ writeShellJobDetail(detail);
446
+ _unregisterLiveJobPid(detail.pid);
447
+ return detail;
448
+ }
449
+ const timeoutMs = Number(detail.timeoutMs || 0);
450
+ const startedAtMs = Date.parse(detail.startedAt || '');
451
+ if (timeoutMs > 0 && Number.isFinite(startedAtMs) && Date.now() - startedAtMs > timeoutMs) {
452
+ killProcessTree(detail.pid, 'SIGTERM');
453
+ detail.status = 'failed';
454
+ detail.exitCode = 124;
455
+ detail.finishedAt = new Date().toISOString();
456
+ detail.error = `timed out after ${timeoutMs} ms`;
457
+ writeShellJobDetail(detail);
458
+ _unregisterLiveJobPid(detail.pid);
459
+ return detail;
460
+ }
461
+ if (detail.pid && !isPidAlive(detail.pid)) {
462
+ detail.status = 'failed';
463
+ detail.finishedAt = new Date().toISOString();
464
+ detail.error = 'process exited without completion marker';
465
+ writeShellJobDetail(detail);
466
+ _unregisterLiveJobPid(detail.pid);
467
+ }
468
+ return detail;
469
+ }
470
+
471
+ export function startBackgroundShellJob({ command, timeoutMs, workDir, mergeStderr, spawnEnv, shell, shellArg, shellType, clientHostPid }) {
472
+ return _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr, spawnEnv, shell, shellArg, shellType, clientHostPid });
473
+ }
474
+
475
+ // In-process completion watcher. After a background `bash` job is spawned the
476
+ // Lead session has no way to learn the job finished (no polling tool is
477
+ // auto-driven), so this registers an fs.watch on the shell-jobs dir filtered
478
+ // to `<jobId>.done` plus a ~2s polling fallback (fs.watch misses on some
479
+ // network / overlay filesystems) and a hard stop at timeoutMs + grace. When
480
+ // the job completes it reads the finished detail and calls notifyFn ONCE with
481
+ // type 'shell_job_result', mirroring pushDispatchResult's option shape so the
482
+ // daemon router delivers the result to the dispatching terminal.
483
+ //
484
+ // v1 LIMITATION: in-process only. The watcher state lives in this MCP child;
485
+ // if the child dies before the job completes, no notification is replayed on
486
+ // restart (unlike dispatch-persist's recoverPending). The job's .done/.json
487
+ // files still land on disk, but nothing re-arms a watcher for them.
488
+ //
489
+ // All timers are unref()'d and fs.watch is closed on completion/stop, so the
490
+ // watcher never keeps the host process alive.
491
+ const SHELL_JOB_WATCH_POLL_MS = 2000;
492
+ const SHELL_JOB_WATCH_GRACE_MS = 5000;
493
+ // Registry of armed background-job watchers keyed by jobId. job_wait's `wait`
494
+ // and `kill` actions already hold the completed outcome, so they cancel the
495
+ // armed watcher here to prevent a double-notify when its next poll fires.
496
+ const backgroundShellJobWatchers = new Map();
497
+ // Persistent notify ctx per jobId, set at FIRST arm and surviving cancel — so a
498
+ // re-arm after a job_wait timeout can reconstruct the notify wiring without the
499
+ // caller threading the ctx back in. Deleted only in the watcher's cleanup() on
500
+ // settle (and explicitly in the kill path) so it cannot leak for entries that
501
+ // never complete.
502
+ const jobNotifyCtxByJobId = new Map();
503
+ // Live job_wait waiter count per jobId. While >0 a synchronous caller is
504
+ // consuming the outcome, so the watcher must stay cancelled; the last waiter to
505
+ // leave (count===0) owns the decision to re-arm a still-running job.
506
+ const jobWaitWaiterCountByJobId = new Map();
507
+ // Register a synchronous job_wait waiter. Paired with endShellJobWait in a
508
+ // finally so the count can't leak on throw.
509
+ export function beginShellJobWait(jobId) {
510
+ jobWaitWaiterCountByJobId.set(jobId, (jobWaitWaiterCountByJobId.get(jobId) || 0) + 1);
511
+ }
512
+ // Deregister a synchronous job_wait waiter; returns the POST-decrement count so
513
+ // the last leaver (0) can decide whether to re-arm.
514
+ export function endShellJobWait(jobId) {
515
+ const next = (jobWaitWaiterCountByJobId.get(jobId) || 0) - 1;
516
+ if (next <= 0) { jobWaitWaiterCountByJobId.delete(jobId); return 0; }
517
+ jobWaitWaiterCountByJobId.set(jobId, next);
518
+ return next;
519
+ }
520
+ // Drop the persistent notify ctx for a jobId. Called from the kill path after
521
+ // cancel so a killed-but-never-fired entry can't leak its ctx.
522
+ export function clearShellJobNotifyCtx(jobId) {
523
+ jobNotifyCtxByJobId.delete(jobId);
524
+ }
525
+ // Cancel (and unregister) an armed watcher without notifying. Idempotent: a
526
+ // no-op when no watcher is armed, and the per-watcher cancel respects the
527
+ // `settled` guard so it cannot race a real completion notify. The persistent
528
+ // notify ctx survives cancel (see jobNotifyCtxByJobId) so a re-arm can recover
529
+ // it; return value is no longer relied upon by callers.
530
+ export function cancelBackgroundShellJobWatch(jobId) {
531
+ const entry = backgroundShellJobWatchers.get(jobId);
532
+ if (!entry) return null;
533
+ if (typeof entry.cancel === 'function') entry.cancel();
534
+ return entry.notifyCtx || null;
535
+ }
536
+ // notifyCtx may be omitted on RE-ARM — it then falls back to the persistent
537
+ // ctx captured at first arm (jobNotifyCtxByJobId).
538
+ export function watchBackgroundShellJob(jobId, notifyCtx) {
539
+ const ctx = (notifyCtx && typeof notifyCtx.notifyFn === 'function')
540
+ ? notifyCtx
541
+ : (jobId ? jobNotifyCtxByJobId.get(jobId) : null);
542
+ if (!jobId || !ctx || typeof ctx.notifyFn !== 'function') {
543
+ // Mirror pushDispatchResult's no-notify-fn diagnostic rather than
544
+ // failing the spawn — the job still runs and is pollable via job_wait.
545
+ try {
546
+ process.stderr.write(`[shell-jobs] watchBackgroundShellJob: no notifyFn — completion of ${jobId} will not be pushed (reason: no-notify-fn)\n`);
547
+ } catch { /* ignore */ }
548
+ return;
549
+ }
550
+ // Idempotent arm: if a watcher is already registered for this jobId, leave
551
+ // it in place. Lets job_wait's re-arm-after-timeout path call this
552
+ // unconditionally without stacking duplicate watchers.
553
+ if (backgroundShellJobWatchers.has(jobId)) return;
554
+ // Persist the notify ctx on FIRST arm so a later re-arm can recover it even
555
+ // after cancel cleared the live watcher entry.
556
+ jobNotifyCtxByJobId.set(jobId, ctx);
557
+ let settled = false;
558
+ // Distinguishes a bare cancel (keep ctx for re-arm) from a real settle
559
+ // (fire/timeout/hard-stop → drop ctx). cleanup() reads this.
560
+ let cancelled = false;
561
+ let watcher = null;
562
+ let pollTimer = null;
563
+ let hardStopTimer = null;
564
+ const cleanup = () => {
565
+ if (watcher) { try { watcher.close(); } catch { /* ignore */ } watcher = null; }
566
+ if (pollTimer) { try { clearInterval(pollTimer); } catch { /* ignore */ } pollTimer = null; }
567
+ if (hardStopTimer) { try { clearTimeout(hardStopTimer); } catch { /* ignore */ } hardStopTimer = null; }
568
+ backgroundShellJobWatchers.delete(jobId);
569
+ // Drop the persistent ctx only on a real settle (fire/timeout/hard-stop)
570
+ // — NOT on a bare cancel, which keeps it for a possible re-arm.
571
+ if (settled && !cancelled) jobNotifyCtxByJobId.delete(jobId);
572
+ };
573
+ // Cancel without notifying — used by job_wait's wait/kill paths, which
574
+ // already hold the completed outcome. Idempotent via the `settled` guard
575
+ // so it can never race or double-fire against a real completion notify.
576
+ const cancel = () => {
577
+ if (settled) return;
578
+ settled = true;
579
+ cancelled = true;
580
+ cleanup();
581
+ };
582
+ backgroundShellJobWatchers.set(jobId, { cancel, notifyCtx: ctx });
583
+ const fire = (reason) => {
584
+ if (settled) return;
585
+ settled = true;
586
+ cleanup();
587
+ try {
588
+ const detail = attachJobInsights(refreshShellJob(jobId)) || readShellJobDetail(jobId);
589
+ if (!detail) return;
590
+ const startedAtMs = Date.parse(detail.startedAt || '');
591
+ const finishedAtMs = Date.parse(detail.finishedAt || '') || Date.now();
592
+ const elapsedMs = Number.isFinite(startedAtMs) ? Math.max(0, finishedAtMs - startedAtMs) : null;
593
+ const exitCode = (typeof detail.exitCode === 'number') ? detail.exitCode : null;
594
+ const status = detail.status || (reason === 'timeout' ? 'running' : 'unknown');
595
+ const lines = [
596
+ `[job: ${jobId}]`,
597
+ `[status: ${status}]`,
598
+ `[exit: ${exitCode === null ? 'n/a' : exitCode}]`,
599
+ elapsedMs !== null ? `[elapsed: ${elapsedMs} ms]` : null,
600
+ detail.command ? `[command: ${detail.command}]` : null,
601
+ '',
602
+ detail.summary ? `Summary: ${detail.summary}` : null,
603
+ detail.stdoutPreview ? `\n[stdout preview]\n${detail.stdoutPreview}` : null,
604
+ (detail.mergeStderr !== true && detail.stderrPreview) ? `\n[stderr preview]\n${detail.stderrPreview}` : null,
605
+ ].filter((l) => l !== null && l !== '');
606
+ const body = lines.join('\n');
607
+ const instruction = `The background bash job ${jobId} you started earlier has finished (${status}, exit ${exitCode === null ? 'n/a' : exitCode}) — review this result in your next step.`;
608
+ Promise.resolve(
609
+ ctx.notifyFn(body, {
610
+ type: 'shell_job_result',
611
+ // Daemon routing: deliver to the dispatching terminal via
612
+ // caller_session_id (owner-only in daemon; no session → drop).
613
+ caller_session_id: ctx.routingSessionId,
614
+ ...(typeof ctx.clientHostPid === 'number' && ctx.clientHostPid > 0
615
+ ? { client_host_pid: String(ctx.clientHostPid) }
616
+ : {}),
617
+ instruction,
618
+ }),
619
+ ).catch((err) => {
620
+ try { process.stderr.write(`[shell-jobs] shell_job_result notify failed: jobId=${jobId} err=${err?.message ?? String(err)}\n`); } catch { /* ignore */ }
621
+ });
622
+ } catch (err) {
623
+ try { process.stderr.write(`[shell-jobs] watchBackgroundShellJob fire failed: jobId=${jobId} err=${err?.message ?? String(err)}\n`); } catch { /* ignore */ }
624
+ }
625
+ };
626
+ const checkDone = (reason) => {
627
+ if (settled) return;
628
+ const detail = refreshShellJob(jobId);
629
+ // refreshShellJob flips status off 'running' once donePath/exit/timeout
630
+ // is observed; only fire once the job is no longer running.
631
+ if (!detail || detail.status !== 'running') fire(reason);
632
+ };
633
+ try {
634
+ const donePath = shellJobDonePath(jobId);
635
+ // Already finished between spawn and watcher arm — fire immediately.
636
+ if (existsSync(donePath)) { fire('already-done'); return; }
637
+ const dir = getShellJobsDir();
638
+ const doneName = `${jobId}.done`;
639
+ try {
640
+ watcher = fsWatch(dir, (_event, filename) => {
641
+ if (!filename) { checkDone('watch'); return; }
642
+ if (String(filename) === doneName) checkDone('watch');
643
+ });
644
+ // Don't let the FSWatcher pin the event loop — the poll fallback
645
+ // and hard-stop timer are already unref()'d, so the watcher must
646
+ // be too or the host process can't exit until the job completes.
647
+ if (watcher && typeof watcher.unref === 'function') watcher.unref();
648
+ // A watcher error (e.g. dir removed) must not crash the host; rely
649
+ // on the poll fallback instead.
650
+ if (watcher && typeof watcher.on === 'function') {
651
+ watcher.on('error', () => { try { watcher.close(); } catch { /* ignore */ } watcher = null; });
652
+ }
653
+ } catch { watcher = null; }
654
+ pollTimer = setInterval(() => checkDone('poll'), SHELL_JOB_WATCH_POLL_MS);
655
+ if (typeof pollTimer.unref === 'function') pollTimer.unref();
656
+ const startedAtMs = Date.parse(readShellJobDetail(jobId)?.startedAt || '') || Date.now();
657
+ const timeoutMs = Number(readShellJobDetail(jobId)?.timeoutMs || 0);
658
+ const hardStopMs = Math.max(0, (startedAtMs + timeoutMs + SHELL_JOB_WATCH_GRACE_MS) - Date.now());
659
+ hardStopTimer = setTimeout(() => fire('timeout'), hardStopMs);
660
+ if (typeof hardStopTimer.unref === 'function') hardStopTimer.unref();
661
+ } catch (err) {
662
+ cleanup();
663
+ try { process.stderr.write(`[shell-jobs] watchBackgroundShellJob arm failed: jobId=${jobId} err=${err?.message ?? String(err)}\n`); } catch { /* ignore */ }
664
+ }
665
+ }
666
+
667
+ // Adopt a still-running FOREGROUND child into the shell-jobs registry. Used
668
+ // by execShellCommand's auto-background transition: the foreground one-shot
669
+ // path spawned a piped child whose stdout/stderr were already being captured
670
+ // to TaskOutput spill files. When the auto-background timer fires we do NOT
671
+ // re-spawn or wrap — the child keeps running as-is — we only publish a job
672
+ // detail so job_wait/refreshShellJob can track it to completion.
673
+ //
674
+ // The caller owns the child.on('close') lifecycle wiring (writing the exit
675
+ // file FIRST, donePath AFTER) so the ordering invariant refreshShellJob()
676
+ // depends on holds for adopted jobs exactly as it does for staged wrappers.
677
+ // This function only allocates the jobId/paths and writes the initial
678
+ // 'running' detail.
679
+ export function adoptForegroundShellJob({ command, cwd, pid, timeoutMs, mergeStderr, stdoutPath, stderrPath, clientHostPid }) {
680
+ const jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
681
+ const exitPath = shellJobExitPath(jobId);
682
+ const donePath = shellJobDonePath(jobId);
683
+ const detail = {
684
+ jobId,
685
+ kind: 'bash',
686
+ status: 'running',
687
+ adopted: true,
688
+ command,
689
+ cwd,
690
+ pid,
691
+ mergeStderr: mergeStderr === true,
692
+ timeoutMs: Number(timeoutMs) || 0,
693
+ // Point the registry at the live TaskOutput spill files so
694
+ // peek/wait previews read the same bytes the foreground capture is
695
+ // still appending. mergeStderr collapses both onto stdoutPath.
696
+ stdoutPath: stdoutPath || null,
697
+ stderrPath: mergeStderr === true ? (stdoutPath || null) : (stderrPath || null),
698
+ exitPath,
699
+ donePath,
700
+ // Per-terminal session stamp: the threaded clientHostPid is the
701
+ // dispatching terminal's claude.exe pid (resolveJobOwnerHostPid falls
702
+ // back to the single-client env only when unset).
703
+ ownerHostPid: resolveJobOwnerHostPid(clientHostPid),
704
+ startedAt: new Date().toISOString(),
705
+ };
706
+ writeShellJobDetail(detail);
707
+ if (Number.isFinite(pid) && pid > 0) {
708
+ _installShellJobsExitHook();
709
+ _registerLiveJobPid(pid);
710
+ }
711
+ return { ...detail, exitPath, donePath };
712
+ }
713
+
714
+ function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr, spawnEnv, shell, shellArg, shellType, clientHostPid }) {
715
+ if (process.platform === 'win32' && isPowerShellShell(shell, shellType)) {
716
+ return startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr, spawnEnv, shell, clientHostPid });
717
+ }
718
+
719
+ // POSIX-shell wrapper path. On Windows this runs for shell:'bash' (Git
720
+ // Bash): the previous hard rejection here was a policy choice, NOT a real
721
+ // invariant — every piece of this path's plumbing is shell-neutral.
722
+ // The wrapper uses `command -v timeout`, `if ... fi`, single-quote escape
723
+ // and POSIX exit-code propagation, all of which Git Bash executes; output
724
+ // and exit-code plumbing flows through the staged script's own
725
+ // `exec > … 2> …` redirect plus `printf … > exitPath` / `touch donePath`
726
+ // (filesystem ops, not shell features); and kill goes through
727
+ // killProcessTree(), which on win32 uses `taskkill /pid /t /f` regardless
728
+ // of which shell spawned the tree. So Git Bash background jobs cancel,
729
+ // capture output, and report exit codes correctly.
730
+ const jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
731
+ const stdoutPath = shellJobStdoutPath(jobId);
732
+ const stderrPath = shellJobStderrPath(jobId);
733
+ const exitPath = shellJobExitPath(jobId);
734
+ const donePath = shellJobDonePath(jobId);
735
+ const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000));
736
+ // P2 fix: wrap with POSIX `timeout` so the kernel terminates the
737
+ // process at deadline regardless of mixdog parent state. Previously
738
+ // only the setTimeout below enforced; a mixdog restart between spawn
739
+ // and deadline would orphan the runaway. --preserve-status keeps the
740
+ // user command's exit code on success; on timeout the wrapper exits 124.
741
+ // `timeout` ships with GNU coreutils on Linux and brew coreutils on
742
+ // macOS; absent platforms fall through to the inner
743
+ // command (the parent setTimeout still calls refreshShellJob to clean up).
744
+ const userCmdQuoted = shellQuoteSingle(command);
745
+ // P2 fix: invoke the resolved shell (not bash -c) so zsh / dash /
746
+ // alternate shells run snapshot-aware commands correctly. Drop
747
+ // --preserve-status so timeout returns 124 unambiguously, making
748
+ // it trivial to distinguish a timeout (124) from a user-side
749
+ // SIGTERM exit (143).
750
+ const innerShellQ = shellQuoteSingle(shell);
751
+ const innerArgQ = shellQuoteSingle(shellArg);
752
+ // Runtime enforcement proof: the wrapper touches <jobId>.enforced right
753
+ // before exec'ing `timeout`, so the marker exists iff the timeout branch
754
+ // actually ran under the wrapper's own env/cwd. A spawn-time probe can't
755
+ // guarantee that (env is scrubbed, cwd differs) — see shellJobEnforcedPath.
756
+ const enforcedPath = shellJobEnforcedPath(jobId);
757
+ // Lifecycle ordering invariant: write the exit-code file FIRST and
758
+ // `touch donePath` strictly AFTER. refreshShellJob() gates completion
759
+ // on donePath existence and only then trusts the exit file — without
760
+ // this strict ordering, readFileSync on a partially-flushed exit file
761
+ // returned '' -> parseInt NaN -> exitCode null -> spurious 'failed'
762
+ // status for processes that actually exited 0. `rm -- "$0"` removes
763
+ // the staged wrapper .cmd.sh after donePath is published so a host
764
+ // crash before this point still leaves the file for the sweep to GC.
765
+ const wrapped = `{ if command -v timeout >/dev/null 2>&1; then touch ${shellQuoteSingle(enforcedPath)}; timeout ${timeoutSeconds} ${innerShellQ} ${innerArgQ} ${userCmdQuoted}; else ${innerShellQ} ${innerArgQ} ${userCmdQuoted}; fi; rc=$?; printf '%s' "$rc" > ${shellQuoteSingle(exitPath)}; touch ${shellQuoteSingle(donePath)}; rm -- "$0" 2>/dev/null; exit $rc; }`;
766
+ // Stage the wrapped command to a .sh and let the script open its own
767
+ // output files via `exec > … 2> …`. The parent does NOT pass file
768
+ // descriptors via stdio inheritance (`stdio: 'ignore'` for all three).
769
+ //
770
+ // Let the shell own redirects via `exec > ... 2> ...` inside the
771
+ // staged script. That keeps descriptor ownership in one process and
772
+ // avoids detached stdio inheritance surprises.
773
+ const outRedirect = mergeStderr
774
+ ? `> ${shellQuoteSingle(stdoutPath)} 2>&1`
775
+ : `> ${shellQuoteSingle(stdoutPath)} 2> ${shellQuoteSingle(stderrPath)}`;
776
+ const scriptBody = `#!/usr/bin/env bash\nexec ${outRedirect}\n${wrapped}\n`;
777
+ const wrappedTempPath = `${exitPath}.cmd.sh`;
778
+ try {
779
+ writeFileSync(wrappedTempPath, scriptBody);
780
+ } catch (e) {
781
+ return { jobId, kind: 'bash', status: 'failed', error: `failed to stage wrapped script: ${e?.message || e}` };
782
+ }
783
+ // R11: scrub loader/execution vars even though bash-tool.mjs already
784
+ // scrubs upstream — defense-in-depth at the spawn site catches future
785
+ // callers that build their own spawnEnv.
786
+ const child = spawn(shell, [wrappedTempPath], {
787
+ cwd: workDir,
788
+ env: scrubLoaderVars(scrubProviderSecrets({ ...spawnEnv })),
789
+ detached: true,
790
+ stdio: 'ignore',
791
+ windowsHide: true,
792
+ });
793
+ child.unref();
794
+ _installShellJobsExitHook();
795
+ _registerLiveJobPid(child.pid);
796
+ const detail = {
797
+ jobId,
798
+ kind: 'bash',
799
+ status: 'running',
800
+ command,
801
+ cwd: workDir,
802
+ pid: child.pid,
803
+ mergeStderr,
804
+ timeoutMs,
805
+ timeoutSeconds,
806
+ stdoutPath,
807
+ stderrPath: mergeStderr ? stdoutPath : stderrPath,
808
+ exitPath,
809
+ donePath,
810
+ // Per-terminal session stamp (see resolveJobOwnerHostPid).
811
+ ownerHostPid: resolveJobOwnerHostPid(clientHostPid),
812
+ startedAt: new Date().toISOString(),
813
+ };
814
+ writeShellJobDetail(detail);
815
+ const timer = setTimeout(() => { refreshShellJob(jobId); }, timeoutMs + 25);
816
+ if (typeof timer.unref === 'function') timer.unref();
817
+ return detail;
818
+ }
819
+
820
+ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr, spawnEnv, shell, clientHostPid }) {
821
+ const jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
822
+ const stdoutPath = shellJobStdoutPath(jobId);
823
+ const rawStderrPath = shellJobStderrPath(jobId);
824
+ const exitPath = shellJobExitPath(jobId);
825
+ const donePath = shellJobDonePath(jobId);
826
+ const wrappedTempPath = `${exitPath}.cmd.ps1`;
827
+ const encodedCommand = powerShellEncodedCommand(command);
828
+ const mergeLiteral = mergeStderr ? '$true' : '$false';
829
+ const wrapper = [
830
+ "$ErrorActionPreference = 'Continue'",
831
+ '[Console]::OutputEncoding=[System.Text.Encoding]::UTF8',
832
+ '$OutputEncoding=[System.Text.Encoding]::UTF8',
833
+ '$exe = (Get-Process -Id $PID).Path',
834
+ `$encoded = ${psSingleQuote(encodedCommand)}`,
835
+ `$stdoutPath = ${psSingleQuote(stdoutPath)}`,
836
+ `$stderrPath = ${psSingleQuote(rawStderrPath)}`,
837
+ `$exitPath = ${psSingleQuote(exitPath)}`,
838
+ `$donePath = ${psSingleQuote(donePath)}`,
839
+ `$mergeStderr = ${mergeLiteral}`,
840
+ `$timeoutMs = ${Math.max(1, Math.floor(timeoutMs || 0))}`,
841
+ '$code = 1',
842
+ 'try {',
843
+ " $argList = @('-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', $encoded)",
844
+ ' $p = Start-Process -FilePath $exe -ArgumentList $argList -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -WindowStyle Hidden -PassThru',
845
+ ' if ($timeoutMs -gt 0 -and -not $p.WaitForExit($timeoutMs)) {',
846
+ ' try { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } catch {}',
847
+ ' $code = 124',
848
+ ' } else {',
849
+ ' try { $p.WaitForExit() } catch {}',
850
+ ' $code = if ($null -ne $p.ExitCode) { [int]$p.ExitCode } else { 0 }',
851
+ ' }',
852
+ '} catch {',
853
+ ' try { Add-Content -LiteralPath $stderrPath -Value ($_ | Out-String) -Encoding utf8 } catch {}',
854
+ ' $code = 1',
855
+ '}',
856
+ 'if ($mergeStderr) {',
857
+ ' try {',
858
+ ' if (Test-Path -LiteralPath $stderrPath) {',
859
+ ' $err = Get-Content -LiteralPath $stderrPath -Raw -ErrorAction SilentlyContinue',
860
+ ' if ($err) { Add-Content -LiteralPath $stdoutPath -Value $err -Encoding utf8 }',
861
+ ' Remove-Item -LiteralPath $stderrPath -Force -ErrorAction SilentlyContinue',
862
+ ' }',
863
+ ' } catch {}',
864
+ '}',
865
+ 'try { Set-Content -LiteralPath $exitPath -Value ([string]$code) -NoNewline -Encoding ascii } catch {}',
866
+ 'try { Set-Content -LiteralPath $donePath -Value "" -NoNewline -Encoding ascii } catch {}',
867
+ 'try { Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue } catch {}',
868
+ 'exit $code',
869
+ '',
870
+ ].join('\n');
871
+ try {
872
+ writeFileSync(wrappedTempPath, wrapper, 'utf-8');
873
+ } catch (e) {
874
+ return { jobId, kind: 'bash', status: 'failed', error: `failed to stage PowerShell background script: ${e?.message || e}` };
875
+ }
876
+
877
+ const shellStem = basename(String(shell || '')).toLowerCase().replace(/\.exe$/, '');
878
+ const wrapperArgs = shellStem === 'powershell'
879
+ ? ['-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', wrappedTempPath]
880
+ : ['-NoLogo', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-File', wrappedTempPath];
881
+ // Spawn the staged wrapper directly. detached MUST be false on Windows:
882
+ // a native pwsh launched with detached:true + stdio:'ignore' exits
883
+ // immediately without running -File (verified — the detached child dies
884
+ // even while the parent stays alive; the non-detached child runs to
885
+ // completion). windowsHide:true gives CREATE_NO_WINDOW so no console
886
+ // window flashes on screen. The wrapper owns its own stdout/stderr file
887
+ // redirect (exec-equivalent Set-Content paths above), so stdio:'ignore'
888
+ // drops no output. unref() frees the host event loop; the exit hook reaps
889
+ // the process tree if the parent closes while the job is still running.
890
+ let child;
891
+ try {
892
+ child = spawn(shell, wrapperArgs, {
893
+ cwd: workDir,
894
+ env: scrubLoaderVars(scrubProviderSecrets({ ...spawnEnv })),
895
+ detached: false,
896
+ stdio: 'ignore',
897
+ windowsHide: true,
898
+ });
899
+ } catch (e) {
900
+ return { jobId, kind: 'bash', status: 'failed', error: `failed to spawn PowerShell background job: ${e?.message || e}` };
901
+ }
902
+ child.unref();
903
+ const childPid = child.pid;
904
+ if (!Number.isFinite(childPid) || childPid <= 0) {
905
+ return { jobId, kind: 'bash', status: 'failed', error: 'PowerShell background spawn returned no pid' };
906
+ }
907
+ _installShellJobsExitHook();
908
+ _registerLiveJobPid(childPid);
909
+ const detail = {
910
+ jobId,
911
+ kind: 'bash',
912
+ shellType: 'powershell',
913
+ status: 'running',
914
+ command,
915
+ cwd: workDir,
916
+ pid: childPid,
917
+ mergeStderr,
918
+ timeoutMs,
919
+ timeoutSeconds: Math.max(1, Math.ceil(timeoutMs / 1000)),
920
+ stdoutPath,
921
+ stderrPath: mergeStderr ? stdoutPath : rawStderrPath,
922
+ exitPath,
923
+ donePath,
924
+ // The PS wrapper enforces timeoutMs unconditionally in-wrapper
925
+ // (WaitForExit($timeoutMs) → Stop-Process → 124), so the deadline
926
+ // invariant always holds for PowerShell jobs.
927
+ timeoutEnforced: true,
928
+ // Per-terminal session stamp (see resolveJobOwnerHostPid).
929
+ ownerHostPid: resolveJobOwnerHostPid(clientHostPid),
930
+ startedAt: new Date().toISOString(),
931
+ };
932
+ writeShellJobDetail(detail);
933
+ const timer = setTimeout(() => { refreshShellJob(jobId); }, timeoutMs + 25);
934
+ if (typeof timer.unref === 'function') timer.unref();
935
+ return detail;
936
+ }