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,2138 @@
1
+ import { initProviders, refreshProviderCatalogsOnStartup, refreshCatalogs } from './orchestrator/providers/registry.mjs';
2
+ import { runWithCwdOverride, pwd } from '../shared/user-cwd.mjs';
3
+ import { askSession, listSessions, closeSession, updateSessionStatus, updateSessionStage, getSessionRuntime, SessionClosedError, setSmartBridge, forEachSessionRuntime, hideSessionFromList, getSession, enqueuePendingMessage } from './orchestrator/session/manager.mjs';
4
+ import { publishHeartbeat as publishSessionHeartbeat } from './orchestrator/session/store.mjs';
5
+ import { StreamStalledAbortError, startWatchdog as startStreamWatchdog } from './orchestrator/session/stream-watchdog.mjs';
6
+ import { startBridgeStallWatchdog } from './bridge-stall-watchdog.mjs';
7
+ import { loadConfig, getPluginData, listPresets, getDefaultPreset, resolveRuntimeSpec } from './orchestrator/config.mjs';
8
+ import { connectMcpServers, disconnectAll } from './orchestrator/mcp/client.mjs';
9
+ import { setInternalToolsProvider, awaitBootReady } from './orchestrator/internal-tools.mjs';
10
+ import { listWorkflows, getWorkflow, seedDefaults } from './orchestrator/workflow-store.mjs';
11
+ import { prepareBridgeSession } from './orchestrator/smart-bridge/session-builder.mjs';
12
+ import { normalizeInputPath } from './orchestrator/tools/builtin.mjs';
13
+ import { prewarmCodeGraph, prewarmCodeGraphSymbols } from './orchestrator/tools/code-graph.mjs';
14
+ import { runWithDispatchRetry } from './orchestrator/bridge-retry.mjs';
15
+ import { ensureDataSeeds } from '../shared/seed.mjs';
16
+ import { errText } from '../shared/err-text.mjs';
17
+ import { isCurrentDaemonDoomed } from '../shared/daemon-recycle.mjs';
18
+ import { writeFileSync, readFileSync, existsSync, watch, watchFile, unwatchFile } from 'fs';
19
+ import { readFile } from 'fs/promises';
20
+ import { execFileSync } from 'child_process';
21
+ import { addPending, removePending, setPendingResult } from './orchestrator/dispatch-persist.mjs';
22
+ import { join, resolve, isAbsolute } from 'path';
23
+
24
+ // --- user-workflow.json loader ---
25
+ // The plugin already persists user role -> preset mapping in
26
+ // <plugin-data>/user-workflow.json
27
+ // Smart Bridge consumes this directly instead of introducing a duplicate
28
+ // config key. fs.watch keeps Smart Bridge in sync when the user edits roles.
29
+
30
+ /**
31
+ * @typedef {Object} RoleConfig
32
+ * @property {string} name - unique role identifier
33
+ * @property {string} preset - preset name from agent-config presets
34
+ * @property {'read'|'read-write'|'mcp'|'full'} permission - tool permission category
35
+ * @property {string|null} desc_path - relative to CLAUDE_PLUGIN_ROOT
36
+ */
37
+
38
+ const VALID_PERMISSIONS = new Set(['read', 'read-write', 'mcp', 'full']);
39
+ const SILENT_BRIDGE_CANCEL_REASONS = new Set([
40
+ 'manual',
41
+ 'request-abort',
42
+ 'request-aborted',
43
+ 'retry-replaced',
44
+ 'idle-sweep',
45
+ ]);
46
+
47
+ function shouldEmitBridgeCancellation(reason) {
48
+ if (!reason) return true;
49
+ return !SILENT_BRIDGE_CANCEL_REASONS.has(String(reason));
50
+ }
51
+
52
+ // Sanitize cwd before passing it to a child-process spawn. On Windows,
53
+ // node:child_process spawn() with a non-ASCII cwd often fails with ENOENT
54
+ // because of code-page / UTF-8 mismatches in the inherited environment
55
+ // (worker invocations with non-ASCII OneDrive paths such as a localized
56
+ // Desktop directory saw zero tool activity
57
+ // before this guard). Falls back to process.cwd() when the cwd contains
58
+ // non-ASCII; the caller (case 'bridge') is expected to surface the
59
+ // originally-requested path inside the prompt body so absolute paths in
60
+ // tool calls still hit the right tree.
61
+ function _safeCwdForSpawn(rawCwd) {
62
+ if (!rawCwd) return rawCwd;
63
+ if (process.platform === 'win32' && /[^\x20-\x7e]/.test(String(rawCwd))) {
64
+ try { return process.cwd(); } catch { return rawCwd; }
65
+ }
66
+ return rawCwd;
67
+ }
68
+
69
+ function applyRoleDefaults(raw) {
70
+ const permission = raw.permission ?? 'full';
71
+ const desc_path = typeof raw.desc_path === 'string' ? raw.desc_path : null;
72
+
73
+ return {
74
+ name: raw.name,
75
+ preset: raw.preset,
76
+ permission,
77
+ desc_path,
78
+ };
79
+ }
80
+
81
+ function validateRoleConfig(role) {
82
+ if (!role.name || typeof role.name !== 'string')
83
+ throw new Error(`[user-workflow] role entry missing "name"`);
84
+ if (!role.preset || typeof role.preset !== 'string')
85
+ throw new Error(`[user-workflow] role "${role.name}" missing "preset"`);
86
+ if (!VALID_PERMISSIONS.has(role.permission))
87
+ throw new Error(`[user-workflow] role "${role.name}": invalid permission "${role.permission}" (expected: ${[...VALID_PERMISSIONS].join(", ")})`);
88
+ }
89
+
90
+ /** @type {Map<string, RoleConfig>} */
91
+ let _roleConfigCache = new Map();
92
+
93
+ function loadResolvedRoles() {
94
+ const path = join(getPluginData(), 'user-workflow.json');
95
+ const map = new Map();
96
+ if (!existsSync(path)) return map;
97
+ try {
98
+ const data = JSON.parse(readFileSync(path, 'utf8'));
99
+ if (Array.isArray(data?.roles)) {
100
+ for (const raw of data.roles) {
101
+ if (!raw?.name || !raw?.preset) continue;
102
+ const resolved = applyRoleDefaults(raw);
103
+ validateRoleConfig(resolved);
104
+ map.set(resolved.name, resolved);
105
+ }
106
+ }
107
+ } catch (e) {
108
+ process.stderr.write(`[user-workflow] load error: ${e.message}\n`);
109
+ }
110
+ return map;
111
+ }
112
+
113
+ function loadUserWorkflowRoles() {
114
+ _roleConfigCache = loadResolvedRoles();
115
+ const out = {};
116
+ for (const [name, cfg] of _roleConfigCache) out[name] = cfg.preset;
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * Get the fully-resolved RoleConfig for a given role name.
122
+ * @param {string} roleName
123
+ * @returns {RoleConfig|null}
124
+ */
125
+ export function getRoleConfig(roleName) {
126
+ return _roleConfigCache.get(roleName) ?? null;
127
+ }
128
+
129
+ let _userWorkflowWatcher = null;
130
+ function watchUserWorkflow(onChange) {
131
+ if (_userWorkflowWatcher) return;
132
+ const dir = getPluginData();
133
+ if (process.platform === 'linux') {
134
+ // linux/WSL: fs.watch on a directory is unreliable/unsupported.
135
+ // Use fs.watchFile polling on the specific file instead.
136
+ try {
137
+ const fp = join(dir, 'user-workflow.json');
138
+ watchFile(fp, { persistent: false, interval: 2000 }, () => {
139
+ try { onChange(loadUserWorkflowRoles()); } catch {}
140
+ });
141
+ _userWorkflowWatcher = { close: () => unwatchFile(fp) };
142
+ } catch (e) {
143
+ process.stderr.write(`[user-workflow-watch] watchFile failed on ${dir}: ${e?.message}\n`);
144
+ }
145
+ } else {
146
+ // win32: reliable; darwin: flat watch on single dir is fine.
147
+ try {
148
+ _userWorkflowWatcher = watch(dir, { persistent: false }, (_event, filename) => {
149
+ if (filename === 'user-workflow.json') {
150
+ try { onChange(loadUserWorkflowRoles()); } catch {}
151
+ }
152
+ });
153
+ } catch (e) {
154
+ // fs.watch can fail on some platforms — log and continue; watcher is non-critical.
155
+ process.stderr.write(`[user-workflow-watch] fs.watch failed on ${dir}: ${e?.message}\n`);
156
+ }
157
+ }
158
+ }
159
+
160
+ let _agentConfigWatcher = null;
161
+ let _agentConfigReloadTimer = null;
162
+ let _agentConfigReloadRunning = false;
163
+ let _agentConfigReloadQueued = false;
164
+ let _lastExternalServersKey = '';
165
+
166
+ function stableJson(value) {
167
+ if (!value || typeof value !== 'object') return JSON.stringify(value);
168
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
169
+ const entries = Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`);
170
+ return `{${entries.join(',')}}`;
171
+ }
172
+
173
+ function externalMcpServersFromConfig(config) {
174
+ const rawServers = (config?.mcpServers && typeof config.mcpServers === 'object') ? config.mcpServers : {};
175
+ const externalServers = {};
176
+ for (const [name, cfg] of Object.entries(rawServers)) {
177
+ if (name === 'mixdog' || name === 'trib-plugin') continue;
178
+ externalServers[name] = cfg;
179
+ }
180
+ return externalServers;
181
+ }
182
+
183
+ function scheduleAgentConfigReload(reason = 'change') {
184
+ clearTimeout(_agentConfigReloadTimer);
185
+ _agentConfigReloadTimer = setTimeout(() => {
186
+ reloadAgentConfig(reason).catch((err) => {
187
+ process.stderr.write(`[agent-config-watch] reload failed: ${err?.message || String(err)}\n`);
188
+ });
189
+ }, 250);
190
+ _agentConfigReloadTimer.unref?.();
191
+ }
192
+
193
+ async function reloadAgentConfig(reason = 'change') {
194
+ if (_agentConfigReloadRunning) {
195
+ _agentConfigReloadQueued = true;
196
+ return;
197
+ }
198
+ _agentConfigReloadRunning = true;
199
+ try {
200
+ const config = loadConfig();
201
+ await initProviders(config.providers);
202
+
203
+ try {
204
+ const { getSmartBridge } = await import('./orchestrator/smart-bridge/index.mjs');
205
+ getSmartBridge().updatePresets(config.presets || []);
206
+ } catch {}
207
+
208
+ const externalServers = externalMcpServersFromConfig(config);
209
+ const externalKey = stableJson(externalServers);
210
+ if (_lastExternalServersKey !== externalKey) {
211
+ try {
212
+ await disconnectAll();
213
+ if (Object.keys(externalServers).length > 0) await connectMcpServers(externalServers);
214
+ process.stderr.write(`[agent-config-watch] external MCP servers refreshed (${Object.keys(externalServers).length})\n`);
215
+ } catch (err) {
216
+ process.stderr.write(`[agent-config-watch] external MCP refresh failed: ${err?.message || String(err)}\n`);
217
+ } finally {
218
+ _lastExternalServersKey = externalKey;
219
+ }
220
+ }
221
+
222
+ refreshCatalogs();
223
+ process.stderr.write(`[agent-config-watch] runtime refreshed (${reason})\n`);
224
+ } finally {
225
+ _agentConfigReloadRunning = false;
226
+ if (_agentConfigReloadQueued) {
227
+ _agentConfigReloadQueued = false;
228
+ scheduleAgentConfigReload('queued');
229
+ }
230
+ }
231
+ }
232
+
233
+ function watchAgentConfig() {
234
+ if (_agentConfigWatcher) return;
235
+ const dir = getPluginData();
236
+ const file = join(dir, 'mixdog-config.json');
237
+ if (process.platform === 'linux') {
238
+ try {
239
+ watchFile(file, { persistent: false, interval: 2000 }, () => scheduleAgentConfigReload('mixdog-config.json'));
240
+ _agentConfigWatcher = { close: () => unwatchFile(file) };
241
+ } catch (e) {
242
+ process.stderr.write(`[agent-config-watch] watchFile failed on ${file}: ${e?.message}\n`);
243
+ }
244
+ } else {
245
+ try {
246
+ _agentConfigWatcher = watch(dir, { persistent: false }, (_event, filename) => {
247
+ if (filename === 'mixdog-config.json') scheduleAgentConfigReload('mixdog-config.json');
248
+ });
249
+ } catch (e) {
250
+ process.stderr.write(`[agent-config-watch] fs.watch failed on ${dir}: ${e?.message}\n`);
251
+ }
252
+ }
253
+ }
254
+
255
+ function buildInstructions() {
256
+ const lines = [];
257
+
258
+ try {
259
+ const workflows = listWorkflows();
260
+ lines.push('');
261
+ if (workflows.length > 0) {
262
+ lines.push('Available workflows:');
263
+ for (const w of workflows) {
264
+ lines.push(`- ${w.name}: ${w.description}`);
265
+ }
266
+ } else {
267
+ lines.push('No custom workflows configured.');
268
+ }
269
+ } catch {
270
+ lines.push('');
271
+ lines.push('No custom workflows configured.');
272
+ }
273
+
274
+ return lines.join('\n');
275
+ }
276
+
277
+ // Seed default workflows into user data dir if none exist yet.
278
+ seedDefaults();
279
+
280
+ // jobId → current activeSession.id registry. Populated by detached bridge
281
+ // workers so bridge type=close can reach the *latest* session after a retry
282
+ // replaced the original. Also maintains a reverse map (sessionId → jobId)
283
+ // so bridge type=close can resolve any session id — original or replacement —
284
+ // back to the job and close its current active session. Both maps are
285
+ // cleared when the job finishes (finally block).
286
+ // Map<jobId, sessionId> + Map<sessionId, jobId>
287
+ const _jobSessionRegistry = new Map();
288
+ const _sessionJobRegistry = new Map(); // reverse: sessionId → jobId
289
+
290
+ // tag → sessionId registry for the unified `bridge` tool. A `tag` is a stable,
291
+ // human-named handle for a detached worker session so the Lead can `bridge
292
+ // type=send|close tag=<tag>` without tracking the raw sess_ id. Unlike the
293
+ // job registries (which are torn down in the worker finally block to free
294
+ // memory), tag entries persist until the session is explicitly closed OR a
295
+ // completed/errored session is reaped from listSessions — this is what makes
296
+ // detached workers RESUMABLE: askSession replays session.messages from the
297
+ // {id}.json transcript on disk, so a `send` to a still-live (idle) session
298
+ // continues the conversation. On retry the worker swaps activeSession.id; the
299
+ // spawn handler updates this map in lockstep with _jobSessionRegistry so the
300
+ // tag keeps pointing at the live replacement (history of the prior attempt is
301
+ // not carried — see retry-swap note in the bridge spawn branch).
302
+ // Map<tag, sessionId>
303
+ const _tagSessionRegistry = new Map();
304
+ // Map<tag, role> — in-memory, EXPLICIT caller tags only (omitted tag → auto
305
+ // ${role}${n} is NOT recorded). Set at bind-time after session creation succeeds;
306
+ // survives terminal reap (tag→session binding is dropped on reap; role mapping
307
+ // stays so a cold `bridge type=send` can respawn without passing role). Cleared
308
+ // on `bridge type=close` (live session or cold tag with only a role mapping).
309
+ const _tagRoleRegistry = new Map();
310
+ // Map<tag, cwd> — the resolved workerCwd captured at spawn-bind time, kept
311
+ // alongside the tag→role map so a COLD `bridge type=send` respawn restores the
312
+ // original spawn's working directory. Without it a cold respawn falls back to
313
+ // the daemon's launch dir (the plugin cache), and the worker's relative-path
314
+ // reads resolve to the deployed copy instead of the live working tree.
315
+ const _tagCwdRegistry = new Map();
316
+ // Per-role auto-tag counter for spawns that omit an explicit tag.
317
+ // Map<role, number>
318
+ const _roleTagCounters = new Map();
319
+
320
+ // Per-session terminal-reap timer handle. A completed/errored bridge worker
321
+ // schedules a 1h reap (hide-from-list + close/tombstone + tag reclaim) in its finally block;
322
+ // the handle is stored here keyed by sessionId so a later `bridge type=send`
323
+ // resume can CANCEL or RESCHEDULE it (see _cancelBridgeReap /
324
+ // _scheduleBridgeReap). Without this, a send to a just-completed session could
325
+ // be reaped mid-resume — hidden and its tag deleted — leaving the resumed
326
+ // session unaddressable by tag.
327
+ // Map<sessionId, ReturnType<typeof setTimeout>>
328
+ const _sessionReapTimers = new Map();
329
+
330
+ // Resolve a tag (or a raw sess_ id) to a live sessionId. Returns null when the
331
+ // tag is unknown OR maps to a session that no longer exists / was closed.
332
+ function _resolveBridgeTag(tagOrId) {
333
+ if (typeof tagOrId !== 'string' || !tagOrId) return null;
334
+ // Raw session id passes through (still validated against the store below).
335
+ let sessionId = _tagSessionRegistry.get(tagOrId) || (tagOrId.startsWith('sess_') ? tagOrId : null);
336
+ if (!sessionId) return null;
337
+ let session = null;
338
+ try { session = getSession(sessionId); } catch { session = null; }
339
+ if (!session || session.closed === true) return null;
340
+ return sessionId;
341
+ }
342
+
343
+ // Allocate a unique tag for a spawn. Explicit tag must not already map to a
344
+ // LIVE session (no silent overwrite); a stale tag (closed/missing session) is
345
+ // reclaimed. Returns { tag } or { error }.
346
+ function _allocateBridgeTag(requestedTag, role) {
347
+ if (typeof requestedTag === 'string' && requestedTag.trim()) {
348
+ const tag = requestedTag.trim();
349
+ if (_resolveBridgeTag(tag)) {
350
+ return { error: `tag "${tag}" already maps to a live session — close it first or use a different tag` };
351
+ }
352
+ return { tag };
353
+ }
354
+ // Auto-tag: ${role}${n} per-role counter, skipping any live collisions.
355
+ let n = (_roleTagCounters.get(role) || 0) + 1;
356
+ let tag = `${role}${n}`;
357
+ while (_resolveBridgeTag(tag)) {
358
+ n += 1;
359
+ tag = `${role}${n}`;
360
+ }
361
+ _roleTagCounters.set(role, n);
362
+ return { tag };
363
+ }
364
+
365
+ // Reverse-lookup the tag bound to a sessionId (for list output). Linear scan;
366
+ // the registry is small (one live worker per entry).
367
+ function _tagForSessionId(sessionId) {
368
+ for (const [tag, sid] of _tagSessionRegistry.entries()) {
369
+ if (sid === sessionId) return tag;
370
+ }
371
+ return null;
372
+ }
373
+
374
+ // Validate that a raw `sess_...` id maps to a LIVE (on-disk, non-tombstoned)
375
+ // session. A reaped/closed session leaves a tombstone (closed===true) or is
376
+ // gone entirely; treating such an id as resolved would launch an async
377
+ // askSession against a dead session (#5). Used to gate the raw-sess_ fallback
378
+ // in both the cold-respawn precheck and the send branch.
379
+ function _isLiveSession(sessionId) {
380
+ if (!sessionId) return false;
381
+ try {
382
+ const s = getSession(sessionId);
383
+ return !!(s && s.closed !== true);
384
+ } catch { return false; }
385
+ }
386
+
387
+ // Schedule the 1h (3600s) terminal reap for a completed/errored session: hide
388
+ // it from listSessions() and reclaim its tag. The 1h window keeps a finished
389
+ // bridge worker resumable for same-task reuse — a follow-up `bridge type=send`
390
+ // to the same tag continues the SAME session (transcript preserved, no
391
+ // from-scratch re-discovery) and lands within the 1h prompt-cache window of
392
+ // every provider we use, so the resume is cheap. The timer handle is recorded in
393
+ // _sessionReapTimers so a resume (`bridge type=send`) can cancel it. Any
394
+ // previously-pending reap for the same session is cancelled first so the
395
+ // window is not double-scheduled. `.unref()` keeps the timer from holding the
396
+ // process open (mirrors a detached lifecycle hook).
397
+ function _scheduleBridgeReap(sessionId) {
398
+ if (!sessionId) return;
399
+ _cancelBridgeReap(sessionId);
400
+ const handle = setTimeout(() => {
401
+ _sessionReapTimers.delete(sessionId);
402
+ try { hideSessionFromList(sessionId); } catch { /* ignore */ }
403
+ // #3: also CLOSE/tombstone the persisted session JSON. hideSessionFromList
404
+ // only flips an in-memory listHidden flag (lost on restart) and the
405
+ // statusline aggregator reads the on-disk JSON directly — it treats any
406
+ // non-closed bridge worker as an idle worker and keeps rendering the
407
+ // reaped session until the 24h store sweep. Closing plants closed===true
408
+ // (status='closed') so the aggregator's `s.closed === true` filter drops
409
+ // it immediately. This is the terminal reap, so the worker is no longer
410
+ // resumable — closing is the correct lifecycle end.
411
+ try { closeSession(sessionId, 'terminal-reap'); } catch { /* ignore */ }
412
+ // Reclaim the tag once the terminal session is reaped from the list — the
413
+ // worker is no longer resumable, so free the name.
414
+ try {
415
+ const _t = _tagForSessionId(sessionId);
416
+ if (_t) _tagSessionRegistry.delete(_t);
417
+ } catch { /* ignore */ }
418
+ }, 3_600_000);
419
+ try { handle.unref?.(); } catch { /* ignore */ }
420
+ _sessionReapTimers.set(sessionId, handle);
421
+ }
422
+
423
+ // Cancel a pending terminal reap for a session (called on resume so an
424
+ // in-flight/just-resumed worker is not hidden + tag-reclaimed mid-use).
425
+ // Returns true if a timer was actually cleared.
426
+ function _cancelBridgeReap(sessionId) {
427
+ if (!sessionId) return false;
428
+ const handle = _sessionReapTimers.get(sessionId);
429
+ if (!handle) return false;
430
+ try { clearTimeout(handle); } catch { /* ignore */ }
431
+ _sessionReapTimers.delete(sessionId);
432
+ return true;
433
+ }
434
+
435
+ // --- Shared session-control helpers (folded from the removed standalone
436
+ // list / close session handlers (now bridge type=list / bridge type=close);
437
+ // the unified `bridge` handler calls these so the runtime stage/staleness
438
+ // derivation stays single-sourced).
439
+
440
+ // Build the list-sessions view with runtime stage + staleness. Optional
441
+ // role/status filters and a brief flag mirror the legacy tool's shape, plus a
442
+ // `tag` field resolved from the tag registry.
443
+ function _bridgeListSessions(opts = {}) {
444
+ const sessions = listSessions();
445
+ if (sessions.length === 0) return 'No active sessions.';
446
+ const now = Date.now();
447
+ const brief = opts.brief !== false;
448
+ const includeClosed = opts.includeClosed === true;
449
+ const roleFilter = typeof opts.role === 'string' && opts.role ? opts.role : null;
450
+ const statusFilter = typeof opts.status === 'string' && opts.status ? opts.status : null;
451
+ let filtered = includeClosed ? sessions : sessions.filter((s) => s.closed !== true);
452
+ if (filtered.length === 0) return 'No active sessions.';
453
+ const rows = filtered.map((s) => {
454
+ const runtime = getSessionRuntime(s.id);
455
+ // No runtime entry → session has no in-flight work; stage derives from
456
+ // persisted status ('running' is only set by long-running callers; idle
457
+ // otherwise). Single derivation, no legacy fallback path.
458
+ const persistedStatus = s.status || 'idle';
459
+ // Tombstones override status/stage. The persisted `status` field may still
460
+ // be 'running' because close aborts rather than touches it; `closed: true`
461
+ // is the authoritative signal.
462
+ const isClosed = s.closed === true;
463
+ const status = isClosed ? 'closed' : persistedStatus;
464
+ // Persisted status is the authoritative terminal signal — runtime stage may
465
+ // linger as 'streaming'/'tool_running' if the runtime entry missed the
466
+ // terminal transition. Trust persistedStatus when terminal; only use
467
+ // runtime stage while status is still 'running'.
468
+ const persistedTerminal = persistedStatus === 'idle' || persistedStatus === 'error';
469
+ const stage = isClosed
470
+ ? 'closed'
471
+ : (persistedTerminal ? persistedStatus : (runtime?.stage || 'connecting'));
472
+ const lastStreamDeltaAt = runtime?.lastStreamDeltaAt
473
+ ? new Date(runtime.lastStreamDeltaAt).toISOString()
474
+ : null;
475
+ const staleSeconds = runtime?.lastStreamDeltaAt
476
+ ? Math.floor((now - runtime.lastStreamDeltaAt) / 1000)
477
+ : null;
478
+ // windowTokens: provider-normalized context footprint at the most-recent
479
+ // call — the prompt tokens the model actually saw last turn, comparable
480
+ // across providers. Anthropic's input_tokens excludes cache, so
481
+ // lastContextTokens folds cache_read back in; openai/grok/gemini already
482
+ // include it. Far more honest than the lifetime-cumulative totalInputTokens
483
+ // (which mixes per-provider cache conventions). Falls back to
484
+ // lastInputTokens for sessions persisted before lastContextTokens existed.
485
+ const windowTokens = Number(s.lastContextTokens ?? s.lastInputTokens) || 0;
486
+ const base = {
487
+ id: s.id,
488
+ tag: _tagForSessionId(s.id),
489
+ role: s.role || null,
490
+ provider: s.provider,
491
+ model: s.model,
492
+ messages: s.messages.length,
493
+ tools: s.tools.length,
494
+ windowTokens,
495
+ windowCap: Number(s.contextWindow) || null,
496
+ cumulativeInputTokens: s.totalInputTokens,
497
+ cumulativeOutputTokens: s.totalOutputTokens,
498
+ scope: s.scopeKey || null,
499
+ status,
500
+ lastStatus: persistedStatus,
501
+ createdAt: new Date(s.createdAt).toISOString(),
502
+ updatedAt: s.updatedAt ? new Date(s.updatedAt).toISOString() : null,
503
+ stage,
504
+ lastStreamDeltaAt,
505
+ staleSeconds,
506
+ lastToolCall: runtime?.lastToolCall || null,
507
+ };
508
+ if (!brief) {
509
+ base.toolNames = Array.isArray(s.tools) ? s.tools.map((t) => t?.name).filter(Boolean) : [];
510
+ }
511
+ return base;
512
+ });
513
+ const out = rows.filter((r) =>
514
+ (!roleFilter || r.role === roleFilter) &&
515
+ (!statusFilter || r.status === statusFilter));
516
+ if (out.length === 0) return 'No active sessions.';
517
+ if (brief) {
518
+ // Brief (default): one compact line per session for token efficiency.
519
+ // Null/empty fields are omitted; callers needing full detail pass
520
+ // brief:false to get the unchanged JSON object array below.
521
+ return out.map((r) => {
522
+ const label = r.tag || String(r.id).split('_').pop();
523
+ const parts = [label];
524
+ if (r.role) parts.push(`role=${r.role}`);
525
+ if (r.model) parts.push(`model=${r.model}`);
526
+ if (r.status) parts.push(`status=${r.status}`);
527
+ if (r.stage) parts.push(`stage=${r.stage}`);
528
+ if (r.staleSeconds) parts.push(`stale=${r.staleSeconds}s`);
529
+ parts.push(`msgs=${r.messages}`);
530
+ const windowK = Math.round(r.windowTokens / 1000);
531
+ if (windowK) parts.push(`window=${windowK}k`);
532
+ if (r.lastToolCall) parts.push(`lastTool=${r.lastToolCall}`);
533
+ return parts.join(' ');
534
+ }).join('\n');
535
+ }
536
+ return out;
537
+ }
538
+
539
+ // Close a session by raw id (fire-and-forget). Resolves any retry-replacement
540
+ // session via the job registries and plants a cancellation tombstone. Returns
541
+ // the close ack shape. Folded verbatim from the removed standalone close
542
+ // handler (now bridge type=close).
543
+ function _bridgeCloseSession(sessionId) {
544
+ // Fire-and-forget: plant tombstone, abort in-flight controller, defer
545
+ // cleanup. We don't wait for the abort to unwind — callers get an immediate
546
+ // ack and unknown IDs return the same shape for simplicity.
547
+ //
548
+ // Retry-session forwarding: if the supplied sessionId was the initial session
549
+ // for a detached bridge job since replaced by a retry, the registry maps
550
+ // jobId→currentSessionId. We close the current (possibly newer) replacement
551
+ // too so the worker is actually stopped. If no retry replacement exists the
552
+ // current==requested and both closeSession calls are idempotent no-ops.
553
+ closeSession(sessionId, 'manual');
554
+ const owningJobId = _sessionJobRegistry.get(sessionId);
555
+ if (owningJobId != null) {
556
+ // Plant a job-level cancellation tombstone so the dispatch-retry loop stops
557
+ // between attempts. closeSession() only kills the currently-active session;
558
+ // without this flag, a manual close landing during STALL_RETRY_BACKOFF_MS
559
+ // would still let the retry path create a fresh activeSession after backoff.
560
+ _jobCancelledTombstones.add(owningJobId);
561
+ const currentId = _jobSessionRegistry.get(owningJobId);
562
+ if (currentId && currentId !== sessionId) {
563
+ try { closeSession(currentId, 'manual'); } catch {}
564
+ }
565
+ }
566
+ return { ok: true, sessionId };
567
+ }
568
+
569
+ // Job-level cancellation tombstones. bridge type=close plants the owning jobId so
570
+ // the dispatch-retry path stops between attempts even when the manual close
571
+ // races the stall-retry backoff (the OLD session is closed, but runWithDispatchRetry
572
+ // is still asleep on STALL_RETRY_BACKOFF_MS and would otherwise unconditionally
573
+ // spin a fresh activeSession on the next attempt). Cleared by the worker's
574
+ // finally / .catch block when the job tears down.
575
+ const _jobCancelledTombstones = new Set();
576
+
577
+ // Seed plugin-owned scaffolding files (memory-config.json, etc.) so
578
+ // first-time installs land with the Pool B surface populated and the Config
579
+ // UI has real paths to edit.
580
+ ensureDataSeeds(getPluginData());
581
+
582
+ const INSTRUCTIONS = buildInstructions();
583
+
584
+ // --- Prompt store (file-backed, shared with bin/bridge CLI) ---
585
+ const _promptStorePath = join(getPluginData(), 'prompt-store.json');
586
+ let _promptSeq = 0;
587
+
588
+ function _psLoad() {
589
+ try {
590
+ return JSON.parse(readFileSync(_promptStorePath, 'utf-8'));
591
+ } catch {
592
+ return {};
593
+ }
594
+ }
595
+
596
+ function _psSave(store) {
597
+ writeFileSync(_promptStorePath, JSON.stringify(store) + '\n', 'utf-8');
598
+ }
599
+
600
+ const _promptStore = {
601
+ get(key) {
602
+ return _psLoad()[key] ?? null;
603
+ },
604
+ set(key, val) {
605
+ const store = _psLoad();
606
+ store[key] = val;
607
+ _psSave(store);
608
+ },
609
+ delete(key) {
610
+ const store = _psLoad();
611
+ delete store[key];
612
+ _psSave(store);
613
+ },
614
+ };
615
+
616
+ // --- Helpers ---
617
+
618
+ // Worker reply protocol: every bridge response is wrapped in
619
+ // <final-answer>...</final-answer> by instruction (rules/bridge/00-common.md).
620
+ // Extract the wrapped content; if the tag is missing return raw text.
621
+ function extractFinalAnswer(text) {
622
+ if (typeof text !== 'string' || !text) return text;
623
+ const m = text.match(/<final-answer>([\s\S]*?)<\/final-answer>/);
624
+ if (m) return m[1].trim();
625
+ return text;
626
+ }
627
+
628
+ function ok(data) {
629
+ return { content: [{ type: 'text', text: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }] };
630
+ }
631
+
632
+ function fail(err) {
633
+ const msg = errText(err);
634
+ return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
635
+ }
636
+
637
+ // Format token counts in Claude Code style: <1000 as-is, >=1000 as "9.9k".
638
+ function fmtTokens(n) {
639
+ if (typeof n !== 'number') return String(n ?? '?');
640
+ if (n < 1000) return String(n);
641
+ return `${(n / 1000).toFixed(1)}k`;
642
+ }
643
+
644
+ // --- bridge case helpers ---
645
+ // These are ordered helpers extracted from the `case 'bridge'` handler.
646
+ // They are side-effecting (emit notifications, mutate registries, wire
647
+ // abort listeners, call into the session manager) but take all collaborators
648
+ // as arguments — so call-site behavior is byte-identical to the inlined form.
649
+
650
+ // Input resolution: prompt | file | ref → { prompt } or { error }.
651
+ // `bridgeCwd` is the bridge worker's resolved working directory
652
+ // (args.cwd > callerCwd). When supplied, a relative `args.file` is
653
+ // resolved against it rather than the agent process cwd — previously
654
+ // `readFile(args.file)` would silently read from the agent's process
655
+ // cwd, producing the wrong contents (or ENOENT) for bridge callers
656
+ // whose effective workspace differs from the host process.
657
+ async function _resolveBridgePrompt(args, bridgeCwd = null) {
658
+ let prompt = args.prompt;
659
+ // Invariant: each input slot counts only when it carries
660
+ // non-whitespace content. filter(Boolean) historically accepted
661
+ // trim-empty strings ("\n", " "), producing a downstream
662
+ // "# Task\n<whitespace>" block and silent empty-<final-answer>
663
+ // responses from the worker.
664
+ const _isMeaningful = (x) => typeof x === 'string' ? x.trim().length > 0 : Boolean(x);
665
+ const _inputCount = [args.prompt, args.file, args.ref].filter(_isMeaningful).length;
666
+ if (_inputCount > 1) return { error: 'bridge: provide exactly one of prompt|ref|file' };
667
+ if (!_isMeaningful(prompt) && _isMeaningful(args.file)) {
668
+ try {
669
+ const _filePath = (bridgeCwd && !isAbsolute(args.file))
670
+ ? resolve(bridgeCwd, args.file)
671
+ : args.file;
672
+ prompt = await readFile(_filePath, 'utf-8');
673
+ } catch (e) {
674
+ return { error: `Cannot read file: ${e.message}` };
675
+ }
676
+ }
677
+ if (!_isMeaningful(prompt) && _isMeaningful(args.ref)) {
678
+ prompt = _promptStore.get(args.ref);
679
+ if (!prompt) return { error: `ref "${args.ref}" not found in prompt store` };
680
+ _promptStore.delete(args.ref);
681
+ }
682
+ if (!_isMeaningful(prompt)) return { error: 'prompt must contain non-whitespace content (or provide a non-empty file/ref)' };
683
+ return { prompt };
684
+ }
685
+
686
+ // Role/preset lookup: bench escape hatch (provider+model) OR
687
+ // user-workflow.json role→preset mapping.
688
+ function _resolveBridgePreset(args, config) {
689
+ if (args.provider && args.model) {
690
+ const preset = {
691
+ id: '__bench__',
692
+ name: '__BENCH__',
693
+ type: 'bridge',
694
+ provider: String(args.provider),
695
+ model: String(args.model),
696
+ effort: args.effort ? String(args.effort) : undefined,
697
+ fast: args.fast === true,
698
+ tools: args.tools ? String(args.tools) : 'full',
699
+ };
700
+ const promptPrefix = args.systemPrompt ? String(args.systemPrompt) + '\n\n' : null;
701
+ return { preset, presetName: preset.name, _roleConfig: null, promptPrefix };
702
+ }
703
+ // Bench escape hatch, model-only form: a model override with no provider.
704
+ // Resolve the provider from the loaded config presets by exact model match
705
+ // so the override is honored instead of silently falling through to the
706
+ // role default. Errors out (rather than guessing) when no preset matches.
707
+ if (args.model && !args.provider) {
708
+ const _matches = config.presets?.filter((x) => x.model === String(args.model)) ?? [];
709
+ if (_matches.length === 0) {
710
+ return { error: `model "${args.model}" did not match any preset in mixdog-config.json — pass provider explicitly or use a preset` };
711
+ }
712
+ const _providers = [...new Set(_matches.map((x) => String(x.provider)))];
713
+ if (_providers.length > 1) {
714
+ return { error: `model "${args.model}" is ambiguous — it maps to multiple providers (${_providers.join(', ')}) in mixdog-config.json; pass provider explicitly` };
715
+ }
716
+ const _matched = _matches[0];
717
+ const preset = {
718
+ id: '__bench__',
719
+ name: '__BENCH__',
720
+ type: 'bridge',
721
+ provider: String(_matched.provider),
722
+ model: String(args.model),
723
+ effort: args.effort ? String(args.effort) : undefined,
724
+ fast: args.fast === true,
725
+ tools: args.tools ? String(args.tools) : 'full',
726
+ };
727
+ const promptPrefix = args.systemPrompt ? String(args.systemPrompt) + '\n\n' : null;
728
+ return { preset, presetName: preset.name, _roleConfig: null, promptPrefix };
729
+ }
730
+ // Load role→preset mapping from user-workflow.json. Role primitives only —
731
+ // no suffix variants, exact match required.
732
+ // Route through loadUserWorkflowRoles() so validateRoleConfig runs on
733
+ // every entry and _roleConfigCache is populated with full RoleConfig objects
734
+ // (including permission). getRoleConfig() then returns the validated record.
735
+ loadUserWorkflowRoles();
736
+ const _roleConfig = getRoleConfig(args.role);
737
+ const presetName = args.preset || _roleConfig?.preset;
738
+ if (!presetName) return { error: `role "${args.role}" not found in user-workflow.json (and no preset override given)` };
739
+ const preset = config.presets?.find((x) => x.id === presetName || x.name === presetName);
740
+ if (!preset) return { error: `preset "${presetName}" (mapped from role "${args.role}") not found in mixdog-config.json` };
741
+ return { preset, presetName, _roleConfig, promptPrefix: null };
742
+ }
743
+
744
+ // cwd resolution: explicit args.cwd > callerCwd > null; then spawn-safe variant.
745
+ function _buildBridgeCwds(args, callerCwd) {
746
+ const _rawBridgeCwd = args.cwd ? normalizeInputPath(args.cwd) : (callerCwd || null);
747
+ const _safeBridgeCwd = _safeCwdForSpawn(_rawBridgeCwd);
748
+ return { _rawBridgeCwd, _safeBridgeCwd };
749
+ }
750
+
751
+ // cwd rewrite: prepend authoritative-cwd note (when silent-swapped) and the
752
+ // invariant [effective-cwd] header. Idempotent against retry paths.
753
+ function _applyBridgeCwdHeaders(prompt, rawCwd, safeCwd) {
754
+ if (rawCwd && safeCwd !== rawCwd) {
755
+ prompt = `# Working directory note\nThe authoritative working directory is \`${rawCwd}\`. Use absolute paths starting with this prefix in every tool call. The host process is running from \`${safeCwd}\` because the original cwd contains characters that node's child_process spawn cannot reliably handle on this platform.\n\n${prompt}`;
756
+ }
757
+ // Invariant header: tell the worker its effective cwd as data so that
758
+ // bare `~` in brief text cannot be re-expanded against a container HOME.
759
+ // Omit entirely when cwd is null (no workspace context).
760
+ // Idempotent: skip if the brief already carries the header (retry paths).
761
+ if (rawCwd && !String(prompt).startsWith('[effective-cwd]')) {
762
+ prompt = `[effective-cwd] ${rawCwd}\n\n${prompt}`;
763
+ }
764
+ return prompt;
765
+ }
766
+
767
+ // R3 Gap 3: clamp forwarded permissionMode against the parent Lead session
768
+ // so a bridge worker can never become MORE permissive than its caller.
769
+ // Rank: plan(0) < default(1) < acceptEdits(2) < bypassPermissions(3).
770
+ // Unknown/missing parent ⇒ treat as 'default'. Unknown requested ⇒ pass
771
+ // through unranked (validator already enums the accepted set upstream).
772
+ function _clampBridgePermissionMode(requested, parentPermissionMode, role) {
773
+ const _permRank = { plan: 0, default: 1, acceptEdits: 2, bypassPermissions: 3 };
774
+ const _parentRanked = Object.prototype.hasOwnProperty.call(_permRank, parentPermissionMode);
775
+ const _parentRank = _parentRanked ? _permRank[parentPermissionMode] : _permRank.default;
776
+ let _permissionMode = requested;
777
+ if (requested && Object.prototype.hasOwnProperty.call(_permRank, requested)) {
778
+ const _reqRank = _permRank[requested];
779
+ if (_reqRank > _parentRank) {
780
+ const _clampedTo = _parentRanked ? parentPermissionMode : 'default';
781
+ process.stderr.write(
782
+ `[bridge-perm-clamp] role=${role} requested=${requested} parent=${parentPermissionMode || 'default'} clamped=${_clampedTo}\n`
783
+ );
784
+ _permissionMode = _clampedTo;
785
+ }
786
+ }
787
+ return _permissionMode;
788
+ }
789
+
790
+ // Fire-and-forget code-graph prewarm so the first find_symbol in this
791
+ // bridge dispatch hits a warm cache (cold _buildCodeGraph is the 90s
792
+ // outlier in PG bridge_calls telemetry). Same pattern as warmupCatalogs.
793
+ // Prewarm only when prefetch signals symbol-graph use. files-only
794
+ // prefetch embeds file bodies in the prompt and never fires
795
+ // find_symbol — prewarming the graph for that path is wasted CPU.
796
+ // callers / references are the true graph-touch signals.
797
+ function _runBridgePrewarm(workerCwd, prefetch) {
798
+ const _prewarmCallers = Array.isArray(prefetch?.callers) ? prefetch.callers : [];
799
+ const _prewarmRefs = Array.isArray(prefetch?.references) ? prefetch.references : [];
800
+ const _prewarmSymbols = [..._prewarmCallers, ..._prewarmRefs].filter(Boolean);
801
+ if (_prewarmSymbols.length > 0) {
802
+ // Symbol-aware prewarm populates lazy per-symbol candidate cache
803
+ // so the worker's first find_symbol on these names skips the
804
+ // O(N) node scan in addition to skipping the graph cold build.
805
+ try { prewarmCodeGraphSymbols(workerCwd, _prewarmSymbols); }
806
+ catch (e) { process.stderr.write(`[bridge] prewarmCodeGraphSymbols failed: ${e?.message}\n`); }
807
+ } else if (prefetch && (prefetch.callers || prefetch.references)) {
808
+ // Only fall back to a full graph prewarm when prefetch signals
809
+ // symbol-graph use (callers/references fields present). Files-only
810
+ // or otherwise empty prefetch never fires find_symbol, so skip.
811
+ try { prewarmCodeGraph(workerCwd); }
812
+ catch (e) { process.stderr.write(`[bridge] prewarmCodeGraph failed: ${e?.message}\n`); }
813
+ }
814
+ }
815
+
816
+ // --- opt-in git-worktree isolation for bridge workers ---
817
+ // When a spawn passes isolation:'worktree' and the resolved workerCwd lives
818
+ // inside a git repo, the worker runs in a dedicated `git worktree` + branch so
819
+ // parallel workers can edit the same files without stepping on each other's
820
+ // working tree. On any git failure we fall back to the plain workerCwd (a warn
821
+ // to stderr) so isolation is strictly best-effort and never blocks a spawn.
822
+
823
+ // Sanitize a tag/jobId fragment into a shell-safe, git-ref-safe token. Only
824
+ // [A-Za-z0-9_-] survive; everything else is dropped. Empty result → null so
825
+ // callers fall back to the jobId. Never interpolated into a shell (execFile),
826
+ // but sanitized anyway to keep the branch ref well-formed.
827
+ function _sanitizeWorktreeName(raw) {
828
+ if (raw == null) return null;
829
+ const cleaned = String(raw).replace(/[^A-Za-z0-9_-]/g, '');
830
+ return cleaned.length > 0 ? cleaned : null;
831
+ }
832
+
833
+ // Resolve the git repo root for a cwd (null when not a git repo / git absent).
834
+ function _gitRepoRoot(cwd) {
835
+ try {
836
+ const out = execFileSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], {
837
+ encoding: 'utf8',
838
+ stdio: ['ignore', 'pipe', 'ignore'],
839
+ // Bound a hung git so it can't block the daemon event loop for ALL
840
+ // sessions; on timeout execFileSync throws and the catch falls back.
841
+ timeout: 10000,
842
+ maxBuffer: 8 * 1024 * 1024,
843
+ windowsHide: true,
844
+ });
845
+ const root = String(out).trim();
846
+ return root.length > 0 ? root : null;
847
+ } catch {
848
+ return null;
849
+ }
850
+ }
851
+
852
+ // Attempt to create an isolated worktree for a worker. Returns the new worktree
853
+ // path on success, or null to signal "use plain workerCwd". Best-effort: any
854
+ // failure warns to stderr and returns null.
855
+ // Returns { worktreePath, branch } on success; null on fallback.
856
+ function _setupWorkerWorktree({ workerCwd, jobId, tag }) {
857
+ const repoRoot = _gitRepoRoot(workerCwd);
858
+ if (!repoRoot) {
859
+ try { process.stderr.write(`[bridge] isolation=worktree requested but ${workerCwd} is not in a git repo — using plain cwd\n`); } catch {}
860
+ return null;
861
+ }
862
+ const safeName = _sanitizeWorktreeName(tag) || _sanitizeWorktreeName(jobId) || _sanitizeWorktreeName(`job_${Date.now()}`);
863
+ const pluginData = process.env.CLAUDE_PLUGIN_DATA || getPluginData();
864
+ const worktreePath = join(pluginData, 'worktrees', _sanitizeWorktreeName(jobId) || safeName);
865
+ const branch = `bridge/${safeName}`;
866
+ try {
867
+ execFileSync('git', ['-C', repoRoot, 'worktree', 'add', worktreePath, '-b', branch], {
868
+ encoding: 'utf8',
869
+ stdio: ['ignore', 'pipe', 'pipe'],
870
+ // Bound a hung git so it can't block the daemon event loop for ALL
871
+ // sessions; on timeout execFileSync throws and the catch falls back.
872
+ timeout: 10000,
873
+ maxBuffer: 8 * 1024 * 1024,
874
+ windowsHide: true,
875
+ });
876
+ return { worktreePath, branch, repoRoot };
877
+ } catch (e) {
878
+ try { process.stderr.write(`[bridge] git worktree add failed (job=${jobId}) — falling back to plain cwd: ${e?.message ?? e}\n`); } catch {}
879
+ return null;
880
+ }
881
+ }
882
+
883
+ // Tear down a worker's isolated worktree in the IIFE finally block. If the
884
+ // worktree is clean it is removed and its branch deleted. If dirty, it is kept
885
+ // (no auto-merge) and { kept:true, path, changedFiles } is returned so the
886
+ // completion emit can surface the leftover path + changed-file count.
887
+ function _teardownWorkerWorktree({ worktreePath, branch, repoRoot }) {
888
+ let dirty = false;
889
+ let changedFiles = 0;
890
+ try {
891
+ const out = execFileSync('git', ['-C', worktreePath, 'status', '--porcelain'], {
892
+ encoding: 'utf8',
893
+ stdio: ['ignore', 'pipe', 'ignore'],
894
+ // Bound a hung git so it can't block the daemon event loop for ALL
895
+ // sessions; on timeout execFileSync throws and the catch falls back.
896
+ timeout: 10000,
897
+ maxBuffer: 8 * 1024 * 1024,
898
+ windowsHide: true,
899
+ });
900
+ const lines = String(out).split('\n').filter((l) => l.trim().length > 0);
901
+ changedFiles = lines.length;
902
+ dirty = changedFiles > 0;
903
+ } catch (e) {
904
+ // If status can't be read, treat as dirty (keep) — never destroy unknown state.
905
+ try { process.stderr.write(`[bridge] worktree status failed (${worktreePath}) — keeping worktree: ${e?.message ?? e}\n`); } catch {}
906
+ return { kept: true, path: worktreePath, changedFiles: 0 };
907
+ }
908
+ if (dirty) {
909
+ return { kept: true, path: worktreePath, changedFiles };
910
+ }
911
+ // Clean — remove the worktree and delete its branch.
912
+ try {
913
+ execFileSync('git', ['-C', repoRoot, 'worktree', 'remove', '--force', worktreePath], {
914
+ encoding: 'utf8',
915
+ stdio: ['ignore', 'pipe', 'pipe'],
916
+ // Bound a hung git so it can't block the daemon event loop for ALL
917
+ // sessions; on timeout execFileSync throws and the catch falls back.
918
+ timeout: 10000,
919
+ maxBuffer: 8 * 1024 * 1024,
920
+ windowsHide: true,
921
+ });
922
+ } catch (e) {
923
+ try { process.stderr.write(`[bridge] worktree remove failed (${worktreePath}): ${e?.message ?? e}\n`); } catch {}
924
+ }
925
+ try {
926
+ execFileSync('git', ['-C', repoRoot, 'branch', '-D', branch], {
927
+ encoding: 'utf8',
928
+ stdio: ['ignore', 'pipe', 'pipe'],
929
+ // Bound a hung git so it can't block the daemon event loop for ALL
930
+ // sessions; on timeout execFileSync throws and the catch falls back.
931
+ timeout: 10000,
932
+ maxBuffer: 8 * 1024 * 1024,
933
+ windowsHide: true,
934
+ });
935
+ } catch (e) {
936
+ try { process.stderr.write(`[bridge] branch delete failed (${branch}): ${e?.message ?? e}\n`); } catch {}
937
+ }
938
+ return { kept: false, path: worktreePath, changedFiles: 0 };
939
+ }
940
+
941
+ // Short model tag for bridge worker lifecycle notifications.
942
+ // Strip the redundant `claude-` vendor prefix; other providers
943
+ // (gpt-*, etc.) pass through unchanged. Falls back to empty on
944
+ // missing model so callers never throw.
945
+ function _bridgeModelTag(preset) {
946
+ try {
947
+ const raw = preset.model;
948
+ if (!raw || typeof raw !== 'string') return '';
949
+ const stripped = raw.startsWith('claude-') ? raw.slice('claude-'.length) : raw;
950
+ return stripped ? `[${stripped}] ` : '';
951
+ } catch { return ''; }
952
+ }
953
+
954
+ // Brief size soft-warn helper: cap is ≤150 words per rules/lead/00-tool-lead.md.
955
+ function _bridgeBriefWordCount(prompt) {
956
+ return prompt ? String(prompt).trim().split(/\s+/).filter(Boolean).length : 0;
957
+ }
958
+
959
+ // Logging-only listener so operators can correlate the MCP request
960
+ // abort with a still-running detached worker. Returns the detach fn so the
961
+ // IIFE finally block can remove it on normal completion (previously
962
+ // leaked until GC). Detached bridge workers do NOT close the session on
963
+ // MCP request abort — by contract they outlive the originating request.
964
+ function _wireRequestAbortLog(requestSignal, { sessionId, role, jobId }) {
965
+ if (!requestSignal) return () => {};
966
+ const onRequestAbort = () => {
967
+ try {
968
+ process.stderr.write(
969
+ `[bridge] MCP request aborted — detached worker continues: session=${sessionId} role=${role} job=${jobId}\n`,
970
+ );
971
+ // Intentionally NOT calling closeSession() here — detached bridge
972
+ // workers survive the MCP request lifecycle.
973
+ } catch (e) { process.stderr.write(`[bridge] onRequestAbort write failed: ${e?.message}\n`); }
974
+ };
975
+ if (requestSignal.aborted) {
976
+ queueMicrotask(onRequestAbort);
977
+ return () => {};
978
+ }
979
+ try {
980
+ requestSignal.addEventListener('abort', onRequestAbort, { once: true });
981
+ return () => {
982
+ try { requestSignal.removeEventListener('abort', onRequestAbort); } catch { /* ignore */ }
983
+ };
984
+ } catch (e) {
985
+ process.stderr.write(`[bridge] addEventListener abort failed: ${e?.message}\n`);
986
+ return () => {};
987
+ }
988
+ }
989
+
990
+ // Render: empty-content diagnostic. Builds the telemetry shape, emits the
991
+ // stderr breadcrumb, and returns the user-facing diagnostic string. Pure
992
+ // w.r.t. the dispatch loop: only stderr side-effect, no state mutation.
993
+ function _renderEmptyContentDiagnostic(result, activeSession) {
994
+ // Telemetry: why did the bridge result lack content?
995
+ const shape = {
996
+ resultType: typeof result,
997
+ contentType: typeof result?.content,
998
+ contentLen: typeof result?.content === 'string' ? result.content.length : null,
999
+ hasToolCalls: Array.isArray(result?.toolCalls) ? result.toolCalls.length : null,
1000
+ stopReason: result?.stopReason ?? result?.stop_reason ?? null,
1001
+ midstreamRetries: result?.__midstreamRetries ?? null,
1002
+ toolCallsTotal: typeof result?.toolCallsTotal === 'number' ? result.toolCallsTotal : null,
1003
+ outputTokens: typeof result?.lastUsage?.outputTokens === 'number'
1004
+ ? result.lastUsage.outputTokens
1005
+ : (typeof result?.usage?.outputTokens === 'number' ? result.usage.outputTokens : null),
1006
+ model: activeSession?.model ?? null,
1007
+ // Provider content-block classification (anthropic*.mjs):
1008
+ // distinguishes thinking-only stalls (reasoning emitted, no
1009
+ // text/tool_use) from true silent empty turns. null when
1010
+ // the provider doesn't expose these fields.
1011
+ hasThinkingContent: typeof result?.hasThinkingContent === 'boolean'
1012
+ ? result.hasThinkingContent
1013
+ : null,
1014
+ contentBlockTypes: Array.isArray(result?.contentBlockTypes)
1015
+ ? result.contentBlockTypes.slice()
1016
+ : null,
1017
+ keys: result && typeof result === 'object' ? Object.keys(result) : null,
1018
+ };
1019
+ try { process.stderr.write(`[bridge] empty-content fallback for sessionId=${activeSession?.id ?? 'unknown'} shape=${JSON.stringify(shape)}\n`); } catch {}
1020
+ // Empty-content protocol guard: emit diagnostic instead of
1021
+ // silent empty so Lead always sees actionable signal.
1022
+ // Reports iterations + cumulative toolCallsTotal (not just
1023
+ // final-turn toolCalls which is 0 when synthesis stalls) +
1024
+ // stopReason. Work landed via tool calls survives even when
1025
+ // the final synthesis turn returned empty content; this
1026
+ // message helps Lead distinguish "work landed, synthesis
1027
+ // missing" from "nothing happened".
1028
+ const _emptyIterations = result?.iterations ?? 0;
1029
+ const _emptyToolCallsTotal = result?.toolCallsTotal ?? shape.hasToolCalls ?? 0;
1030
+ const _emptyFinalToolCalls = shape.hasToolCalls ?? 0;
1031
+ const _emptyStopReason = shape.stopReason ?? 'unknown';
1032
+ const _emptyOutTokens = shape.outputTokens;
1033
+ const _emptyModel = shape.model ?? 'unknown';
1034
+ const _outTokPart = typeof _emptyOutTokens === 'number'
1035
+ ? `${_emptyOutTokens} output token(s)`
1036
+ : 'output tokens unknown';
1037
+ // Thinking-only stall vs. true empty disambiguator: when the
1038
+ // provider classifies content blocks (anthropic*.mjs) we can
1039
+ // tell Lead whether the model spent the turn on reasoning
1040
+ // (hasThinkingContent=true, blockTypes contains 'thinking')
1041
+ // versus producing nothing at all. Older providers leave
1042
+ // these null — fall back to "unknown" so Lead still knows
1043
+ // the classification wasn't available.
1044
+ const _emptyHasThinking = shape.hasThinkingContent;
1045
+ const _emptyBlockTypes = shape.contentBlockTypes;
1046
+ const _thinkingPart = _emptyHasThinking === null
1047
+ ? 'hasThinkingContent=unknown'
1048
+ : `hasThinkingContent=${_emptyHasThinking}`;
1049
+ const _blockTypesPart = _emptyBlockTypes === null
1050
+ ? 'contentBlockTypes=unknown'
1051
+ : `contentBlockTypes=[${_emptyBlockTypes.join(',') || 'none'}]`;
1052
+ const _classifyHint = _emptyHasThinking === true
1053
+ ? ' — thinking-only stall (model emitted reasoning but no text/tool_use); likely max_tokens or budget cutoff mid-synthesis'
1054
+ : (_emptyHasThinking === false && Array.isArray(_emptyBlockTypes) && _emptyBlockTypes.length === 0
1055
+ ? ' — true empty turn (no content blocks emitted at all)'
1056
+ : '');
1057
+ return `(empty final synthesis: bridge worker [model=${_emptyModel}] completed ${_emptyIterations} iteration(s) with ${_emptyToolCallsTotal} cumulative tool call(s); final turn had ${_emptyFinalToolCalls} tool call(s), 0 content, ${_outTokPart}. stopReason=${_emptyStopReason}. ${_thinkingPart} ${_blockTypesPart}${_classifyHint}. Tool-side work (read/edit/write/apply_patch) may have landed — if toolCallsTotal>0 check git diff or trace store; if toolCallsTotal=0 and outputTokens=0 nothing happened. Possible causes: provider returned text-empty terminal turn (thinking-only block, pause_turn, max_tokens), worker skipped <final-answer> tag emission, or contract-nudge cap reached. Re-dispatch or inspect landed work.)`;
1058
+ }
1059
+
1060
+ // Empty <final-answer> guard: extractFinalAnswer returns '' when the
1061
+ // model emitted `<final-answer></final-answer>` (or wrapped only
1062
+ // whitespace). Lead would otherwise receive a bare `[role]` header
1063
+ // and have no signal about what the worker produced. Surface the
1064
+ // raw content (truncated) with an explicit warning so the Lead can
1065
+ // see the actual model output and decide next steps.
1066
+ function _resolveFinalAnswer(content) {
1067
+ let _extractedAnswer = extractFinalAnswer(content);
1068
+ if (typeof _extractedAnswer === 'string' && _extractedAnswer.trim().length === 0) {
1069
+ const _rawSample = typeof content === 'string' ? content.slice(0, 500) : '';
1070
+ _extractedAnswer = _rawSample
1071
+ ? `(warning: empty <final-answer> tag; raw response: ${_rawSample})`
1072
+ : '(warning: worker produced no content)';
1073
+ }
1074
+ return _extractedAnswer;
1075
+ }
1076
+
1077
+ // Unified emit-with-delivery-flag helper for the bridge dispatch IIFE.
1078
+ // Matches the original four-site pattern: `emit → set delivered → catch
1079
+ // emit error → log to stderr (best-effort)`. Returns true on emit success,
1080
+ // false on failure; callers OR-in the result so an earlier success cannot
1081
+ // be flipped to false by a later branch's emit failure (the original code
1082
+ // only ever set `_delivered = true`, never back to false).
1083
+ function _bridgeDispatchMeta(jobId, { error = false, instruction } = {}) {
1084
+ return {
1085
+ type: 'dispatch_result',
1086
+ dispatch_id: jobId,
1087
+ tool: 'bridge',
1088
+ error: String(!!error),
1089
+ instruction: instruction || `The bridge dispatch you started earlier (${jobId}) has returned — use this answer in your next step.`,
1090
+ };
1091
+ }
1092
+
1093
+ async function _bridgeEmitDelivered(emit, message, { pathLabel, jobId, sessionId, role, meta }) {
1094
+ try {
1095
+ await Promise.resolve(emit(message, meta || _bridgeDispatchMeta(jobId)));
1096
+ return true;
1097
+ } catch (emitErr) {
1098
+ try {
1099
+ process.stderr.write(`[bridge] emit failed (${pathLabel}): job=${jobId} session=${sessionId} role=${role} ${emitErr instanceof Error ? emitErr.message : String(emitErr)}\n`);
1100
+ } catch {}
1101
+ return false;
1102
+ }
1103
+ }
1104
+
1105
+ // --- Tool definitions ---
1106
+ //
1107
+ // Tool array lives in ./tool-defs.mjs (pure data, zero side effects) so
1108
+ // it can be imported without dragging the full agent module graph.
1109
+ // Re-aliased here as `TOOLS` to keep existing references and the
1110
+ // `TOOL_DEFS` export below resolving unchanged.
1111
+ import { TOOL_DEFS as TOOLS } from './tool-defs.mjs';
1112
+
1113
+ // ── Module exports (for unified server) ──────────────────────────────
1114
+
1115
+ export { TOOLS as TOOL_DEFS };
1116
+ export { INSTRUCTIONS as instructions };
1117
+
1118
+ export async function init() {
1119
+ const config = loadConfig();
1120
+ // External MCP servers only. Plugin's own tools are injected in-process
1121
+ // via the context from server.mjs; a self-ref entry would self-spawn or
1122
+ // partially loop back through HTTP, so strip on ingress.
1123
+ const externalServers = externalMcpServersFromConfig(config);
1124
+ // Run independent init steps in parallel: provider registry + external
1125
+ // MCP server connections. Both are network-bound and have no shared
1126
+ // dependency, so serialising them was wasted boot latency.
1127
+ await Promise.all([
1128
+ initProviders(config.providers),
1129
+ Object.keys(externalServers).length > 0 ? connectMcpServers(externalServers) : Promise.resolve(),
1130
+ ]);
1131
+ _lastExternalServersKey = stableJson(externalServers);
1132
+ // Force-refresh each provider's /models catalog in the background so models
1133
+ // released since the last run are picked up on every MCP start (the old
1134
+ // warmupCatalogs respected the 24h provider TTL and no-op'd on a fresh
1135
+ // cache). LiteLLM pricing/context metadata is intentionally left on its own
1136
+ // 24h TTL — not force-refreshed here.
1137
+ setImmediate(() => refreshProviderCatalogsOnStartup());
1138
+ seedDefaults();
1139
+ startStreamWatchdog(forEachSessionRuntime);
1140
+ // Smart Bridge — unified router + cache strategy + profile system.
1141
+ // User-role preset mapping comes from user-workflow.json (existing source
1142
+ // of truth). Preset catalog (provider/model/effort) comes from config.presets.
1143
+ try {
1144
+ const { initSmartBridge, getSmartBridge, setRoleResolver } = await import('./orchestrator/smart-bridge/index.mjs');
1145
+ const userRoles = loadUserWorkflowRoles();
1146
+ const presets = config.presets || [];
1147
+ // Inject the role resolver so SmartBridge.resolveSync() can read role
1148
+ // configs without lazy-importing this module (avoids the circular
1149
+ // require that the old router.mjs had).
1150
+ setRoleResolver(getRoleConfig);
1151
+ const sb = initSmartBridge({ userRoles, presets });
1152
+ // Inject into session manager so createSession() can resolve profiles
1153
+ // synchronously (no lazy-import race).
1154
+ setSmartBridge(sb);
1155
+ // Keep Smart Bridge in sync with user-workflow.json edits.
1156
+ watchUserWorkflow((nextRoles) => {
1157
+ try { getSmartBridge().updateUserRoles(nextRoles); } catch {}
1158
+ });
1159
+ } catch (e) {
1160
+ process.stderr.write(`[smart-bridge] init skipped: ${e.message}\n`);
1161
+ }
1162
+ watchAgentConfig();
1163
+ }
1164
+
1165
+ /**
1166
+ * Handle a tool call from the unified server.
1167
+ * @param {string} name - tool name
1168
+ * @param {object} args - tool arguments
1169
+ * @param {{ notifyFn?: (text: string) => void, elicitFn?: (opts: object) => Promise<object> }} [opts]
1170
+ */
1171
+ export async function handleToolCall(name, args, opts = {}) {
1172
+ const _baseNotifyFn = typeof opts.notifyFn === 'function' ? opts.notifyFn : null;
1173
+ // Daemon routing: tag every bridge notification with the dispatching MCP
1174
+ // session id so the daemon router delivers detached-worker results back to
1175
+ // THIS terminal instead of the Lead connection. No-op outside the daemon
1176
+ // (routingSessionId undefined → meta unchanged → router falls back to Lead).
1177
+ const _routingSessionId = typeof opts.routingSessionId === 'string' && opts.routingSessionId ? opts.routingSessionId : null;
1178
+ const _clientHostPid = Number(opts.clientHostPid) || null;
1179
+ const notifyFn = _baseNotifyFn
1180
+ ? (text, extraMeta) => {
1181
+ const patch = {};
1182
+ if (_routingSessionId) patch.caller_session_id = _routingSessionId;
1183
+ if (_clientHostPid > 0) patch.client_host_pid = String(_clientHostPid);
1184
+ const merged = { ...(extraMeta || {}), ...patch };
1185
+ // CC channel schema requires meta to be Record<string,string> (channelNotification.ts);
1186
+ // coerce every value so a non-string (boolean/number) can't fail zod and silently drop the notify.
1187
+ const meta = {};
1188
+ for (const [k, v] of Object.entries(merged)) {
1189
+ if (v === undefined || v === null) continue;
1190
+ // silent_to_agent is an internal routing flag the daemon router and
1191
+ // agentNotify consume (=== true) BEFORE the CC zod boundary; keep it
1192
+ // boolean so those checks fire. It never reaches CC — silent notifies
1193
+ // are dropped or Discord-forwarded pre-CC.
1194
+ meta[k] = k === 'silent_to_agent' ? (v === true || v === 'true') : String(v);
1195
+ }
1196
+ return _baseNotifyFn(text, Object.keys(meta).length ? meta : undefined);
1197
+ }
1198
+ : null;
1199
+ const requestSignal = opts.requestSignal instanceof AbortSignal ? opts.requestSignal : null;
1200
+ const callerSessionId = typeof opts.callerSessionId === 'string' && opts.callerSessionId ? opts.callerSessionId : null;
1201
+ const callerCwd = typeof opts.callerCwd === 'string' && opts.callerCwd ? opts.callerCwd : null;
1202
+ await awaitBootReady(2000);
1203
+ // Idempotent fallback — server.mjs populates the registry at boot via
1204
+ // loadModule('agent').then(...), but if eager init failed (missing deps,
1205
+ // file error), the first tool call still restores it here. Re-registration
1206
+ // is safe: setInternalToolsProvider replaces the executor/tools refs.
1207
+ if (typeof opts.toolExecutor === 'function' && Array.isArray(opts.internalTools)) {
1208
+ setInternalToolsProvider({
1209
+ executor: opts.toolExecutor,
1210
+ tools: opts.internalTools,
1211
+ });
1212
+ }
1213
+
1214
+ try {
1215
+ switch (name) {
1216
+ case 'list_models': {
1217
+ const cfg = loadConfig();
1218
+ const presets = listPresets(cfg);
1219
+ const current = getDefaultPreset(cfg);
1220
+ if (presets.length === 0) return ok('No presets configured.');
1221
+ const currentLabel = current ? `${current.model}${current.effort ? ' · ' + current.effort : ''}${current.fast ? ' · fast' : ''}` : 'none';
1222
+ // list_models is read-only; default-preset changes go through
1223
+ // mixdog-config.json or the config UI, not interactive elicit.
1224
+ const lines = presets.map((p, i) => {
1225
+ const parts = [p.name, p.model];
1226
+ if (p.effort) parts.push(p.effort);
1227
+ if (p.fast) parts.push('fast');
1228
+ const mark = current && p.name === current.name ? ' ← active' : '';
1229
+ return `[${i}] ${parts.join(' · ')}${mark}`;
1230
+ });
1231
+ return ok({ current: currentLabel, presets: lines });
1232
+ }
1233
+
1234
+ case 'get_workflows': {
1235
+ const workflows = listWorkflows();
1236
+ return ok({ workflows });
1237
+ }
1238
+
1239
+ case 'get_workflow': {
1240
+ if (!args.name) return fail('name is required');
1241
+ const workflow = getWorkflow(args.name);
1242
+ if (!workflow) return fail('workflow not found');
1243
+ return ok(workflow);
1244
+ }
1245
+
1246
+ case 'set_prompt': {
1247
+ let content = args.content;
1248
+ if (!content && args.file) {
1249
+ try {
1250
+ content = await readFile(args.file, 'utf-8');
1251
+ } catch (e) {
1252
+ return fail(`Cannot read file: ${e.message}`);
1253
+ }
1254
+ }
1255
+ if (!content) return fail('content or file is required');
1256
+ const key = args.key || `p${++_promptSeq}`;
1257
+ _promptStore.set(key, content);
1258
+ return ok(`Stored as '${key}' (${content.length} chars)`);
1259
+ }
1260
+
1261
+ case 'bridge': {
1262
+ // Unified worker session control. `type` selects the action; default
1263
+ // 'spawn' preserves the legacy detached-dispatch behavior so callers
1264
+ // that pass only role+prompt (channels/index.mjs, webhook.mjs) keep
1265
+ // working unchanged.
1266
+ let bridgeType = typeof args.type === 'string' && args.type ? args.type : 'spawn';
1267
+ // True when a cold `send` was flipped to `spawn` (fresh session, no
1268
+ // transcript carry-over). Surfaced as `respawned: true` on the spawn
1269
+ // immediate-return payload; live `send` resumes use `respawned: false`.
1270
+ let _bridgeColdRespawn = false;
1271
+
1272
+ // Cold-tag respawn: a `type=send` whose tag no longer resolves to a
1273
+ // live session (reaped after the 1h window, or never spawned) but
1274
+ // whose role is known (args.role or the session-scoped tag→role map)
1275
+ // should respawn FRESH rather than error — the prompt cache is cold
1276
+ // anyway, so a new session is the correct outcome. Flip to the spawn
1277
+ // path (which re-allocates args.tag + args.role and folds args.message
1278
+ // into the prompt) and carry the requested tag forward so the new
1279
+ // session registers under the same name. Without a recoverable role we
1280
+ // leave bridgeType as 'send' and let the send handler emit its clear
1281
+ // "include a role to respawn" error.
1282
+ if (bridgeType === 'send') {
1283
+ const _sendTarget = args.tag || args.sessionId;
1284
+ const _sendTagKey = (typeof args.tag === 'string' && args.tag.trim())
1285
+ ? args.tag.trim()
1286
+ : (typeof _sendTarget === 'string' && !_sendTarget.startsWith('sess_') ? _sendTarget.trim() : null);
1287
+ const _recoveredRole = args.role
1288
+ || (_sendTagKey ? _tagRoleRegistry.get(_sendTagKey) : null)
1289
+ || null;
1290
+ // A raw `sess_...` id only counts as resolved when it maps to a LIVE
1291
+ // session — a reaped/closed id must fall through to the respawn
1292
+ // branch (role recoverable) rather than being launched against a dead
1293
+ // session (#5).
1294
+ const _resolved = _sendTarget
1295
+ ? (_resolveBridgeTag(_sendTarget) || (typeof _sendTarget === 'string' && _sendTarget.startsWith('sess_') && _isLiveSession(_sendTarget) ? _sendTarget : null))
1296
+ : null;
1297
+ if (_sendTarget && !_resolved && _recoveredRole) {
1298
+ if (args.tag == null && typeof _sendTarget === 'string' && !_sendTarget.startsWith('sess_')) {
1299
+ args = { ...args, tag: _sendTarget };
1300
+ }
1301
+ if (!args.role) args = { ...args, role: _recoveredRole };
1302
+ // Restore the original spawn's cwd so a cold respawn does NOT fall
1303
+ // back to the daemon launch dir (plugin cache). Explicit args.cwd
1304
+ // still wins; this only fills the gap a reaped session left behind.
1305
+ if (args.cwd == null && _sendTagKey) {
1306
+ const _recoveredCwd = _tagCwdRegistry.get(_sendTagKey);
1307
+ if (_recoveredCwd) args = { ...args, cwd: _recoveredCwd };
1308
+ }
1309
+ bridgeType = 'spawn';
1310
+ _bridgeColdRespawn = true;
1311
+ }
1312
+ }
1313
+
1314
+ if (bridgeType === 'list') {
1315
+ return ok(_bridgeListSessions({
1316
+ role: args.role,
1317
+ status: args.status,
1318
+ brief: args.brief,
1319
+ includeClosed: args.includeClosed,
1320
+ }));
1321
+ }
1322
+
1323
+ if (bridgeType === 'close') {
1324
+ const target = args.tag || args.sessionId;
1325
+ if (!target) return fail('bridge close: tag (or sess_ id) is required');
1326
+ const sessionId = _resolveBridgeTag(target) || (target.startsWith('sess_') ? target : null);
1327
+ if (!sessionId) {
1328
+ const _coldTag = (typeof args.tag === 'string' && args.tag.trim())
1329
+ ? args.tag.trim()
1330
+ : (typeof target === 'string' && !target.startsWith('sess_') ? String(target).trim() : null);
1331
+ if (_coldTag && _tagRoleRegistry.has(_coldTag)) {
1332
+ try { _tagSessionRegistry.delete(_coldTag); } catch {}
1333
+ try { _tagRoleRegistry.delete(_coldTag); } catch {}
1334
+ try { _tagCwdRegistry.delete(_coldTag); } catch {}
1335
+ return ok({ closed: true, forgotten: true, tag: _coldTag, sessionId: null });
1336
+ }
1337
+ return fail(`bridge close: tag "${target}" does not map to a live session`);
1338
+ }
1339
+ const res = _bridgeCloseSession(sessionId);
1340
+ // Cancel any pending terminal reap — the session is being closed
1341
+ // explicitly now, so the deferred hide/tag-reclaim timer is moot.
1342
+ _cancelBridgeReap(sessionId);
1343
+ // Drop the tag binding so the name can be reused for a fresh spawn.
1344
+ const tag = _tagForSessionId(sessionId) || (args.tag && _tagSessionRegistry.get(args.tag) === sessionId ? args.tag : null);
1345
+ if (tag) {
1346
+ try { _tagSessionRegistry.delete(tag); } catch {}
1347
+ try { _tagRoleRegistry.delete(tag); } catch {}
1348
+ try { _tagCwdRegistry.delete(tag); } catch {}
1349
+ }
1350
+ return ok({ ...res, tag: tag || null });
1351
+ }
1352
+
1353
+ if (bridgeType === 'send') {
1354
+ const target = args.tag || args.sessionId;
1355
+ if (!target) return fail('bridge send: tag (or sess_ id) is required');
1356
+ const message = args.message || args.prompt;
1357
+ if (!message) return fail('bridge send: message is required');
1358
+ // A raw `sess_...` id resolves only if it points at a LIVE session;
1359
+ // a reaped/closed id is treated as unresolved so it cannot launch an
1360
+ // async askSession against a dead session (#5). With no role to
1361
+ // respawn (the role case was already flipped to spawn above), this
1362
+ // falls into the clear "include a role to respawn" error below.
1363
+ const sessionId = _resolveBridgeTag(target) || (target.startsWith('sess_') && _isLiveSession(target) ? target : null);
1364
+ // A cold tag carrying a role was already flipped to the spawn path
1365
+ // above, so reaching here means no role was supplied — guide the
1366
+ // caller to respawn fresh.
1367
+ if (!sessionId) return fail(`bridge send: tag "${target}" not found (may have been reaped after 1h idle) — include a role to respawn fresh`);
1368
+ // Busy worker → QUEUE, don't reject (Claude Code pendingMessages
1369
+ // pattern). The tag is published BEFORE the worker enters askSession
1370
+ // (spawn) and re-published on each retry rebind, so a `send` can land
1371
+ // while the startup / retry turn is still in flight. Rather than
1372
+ // making it the first user message (jumping ahead of the original
1373
+ // prompt) or rejecting it, enqueue it onto the per-session pending
1374
+ // queue; askSession drains the queue after the in-flight turn and
1375
+ // runs queued messages — in order — as follow-up turns. This both
1376
+ // removes the startup race (queued send runs AFTER the original
1377
+ // prompt) and lets a mid-turn send land without a retry. We treat a
1378
+ // live, un-aborted controller OR a non-terminal status as "busy".
1379
+ {
1380
+ const _rt = getSessionRuntime(sessionId);
1381
+ const _inFlight = !!(_rt && _rt.controller && _rt.controller.signal && !_rt.controller.signal.aborted);
1382
+ let _sess = null;
1383
+ try { _sess = getSession(sessionId); } catch { _sess = null; }
1384
+ const _activeStates = new Set(['connecting', 'requesting', 'streaming', 'tool_running', 'running', 'cancelling']);
1385
+ // #2 race close: prefer the RUNTIME terminal stage over a stale
1386
+ // persisted 'running'. Spawn keeps persisted status 'running' until
1387
+ // AFTER the completion emit (updateSessionStatus(idle) runs only
1388
+ // after the await _bridgeEmitDelivered, OUTSIDE askSession). A send
1389
+ // landing in that post-askSession / pre-idle gap would see persisted
1390
+ // 'running' and wrongly ENQUEUE onto a queue nothing drains (the
1391
+ // turn already returned past askSession's drain point) — stranded.
1392
+ // The runtime stage flips to a terminal value (done/idle/error) the
1393
+ // instant the turn completes (markSessionDone), so when a runtime
1394
+ // entry exists it is authoritative for in-flight detection: enqueue
1395
+ // ONLY when the runtime stage is genuinely active. Fall back to the
1396
+ // persisted status only when there is no runtime entry at all (a
1397
+ // truly live ask always creates one via markSessionAskStart).
1398
+ const _runtimeStage = _rt?.stage || null;
1399
+ let _busy;
1400
+ if (_inFlight) {
1401
+ _busy = true;
1402
+ } else if (_runtimeStage) {
1403
+ _busy = _activeStates.has(_runtimeStage);
1404
+ } else {
1405
+ const _status = _sess?.status || null;
1406
+ _busy = !!(_status && _activeStates.has(_status));
1407
+ }
1408
+ if (_busy) {
1409
+ const _depth = enqueuePendingMessage(sessionId, message);
1410
+ return ok({
1411
+ queued: true,
1412
+ tag: _tagForSessionId(sessionId) || (typeof args.tag === 'string' ? args.tag : null) || null,
1413
+ sessionId,
1414
+ queueDepth: _depth,
1415
+ respawned: false,
1416
+ });
1417
+ }
1418
+ }
1419
+ // Resume the conversation — askSession replays session.messages from
1420
+ // the {id}.json transcript, so a follow-up continues where the
1421
+ // detached worker left off. Like spawn, a detached resume does NOT
1422
+ // bail on an already-aborted request signal — it runs to completion and
1423
+ // delivers via notifyFn (abort is logging-only, wired below).
1424
+ // Cancel the pending 1h terminal reap (scheduled when the spawn's
1425
+ // initial turn completed) so this just-resumed / in-flight session is
1426
+ // not hidden + tag-reclaimed mid-resume. Re-armed after the resumed
1427
+ // turn settles below.
1428
+ _cancelBridgeReap(sessionId);
1429
+ // Keep the tag bound to the live session across the resume — if the
1430
+ // reap had already raced ahead and dropped the binding, restore it so
1431
+ // the session stays addressable by tag afterward.
1432
+ const _liveTag = (typeof args.tag === 'string' && args.tag.trim()) ? args.tag.trim() : _tagForSessionId(sessionId);
1433
+ if (_liveTag) { try { _tagSessionRegistry.set(_liveTag, sessionId); } catch {} }
1434
+ // Detached resume — mirror the spawn dispatch path. The guard, the
1435
+ // reap-cancel and the tag rebind above all run synchronously BEFORE
1436
+ // we hand off; from here we generate a jobId and return IMMEDIATELY
1437
+ // ({ jobId, sessionId, tag, detached:true }) without blocking on
1438
+ // askSession. The resume runs inside the async IIFE below and its
1439
+ // reply is delivered via notifyFn using the SAME shape spawn's
1440
+ // completion emit uses (`${modelTag}[${role}] ${answer}`), so the
1441
+ // Lead + channel see it like a SPAWN-OK dispatch result instead of a
1442
+ // synchronous return value.
1443
+ const sendJobId = `bridge_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
1444
+ const _sendSession = (() => { try { return getSession(sessionId); } catch { return null; } })();
1445
+ const sendRole = _sendSession?.role || 'worker';
1446
+ // Prefer the live bridge tag for completion labels; fall back through
1447
+ // the persisted session tag, then role only if the tag is missing.
1448
+ const sendTag = _liveTag || _tagForSessionId(sessionId) || _sendSession?.bridgeTag || sendRole;
1449
+ const sendModelTag = _bridgeModelTag({ model: _sendSession?.model });
1450
+ const emit = notifyFn || (() => {});
1451
+ addPending(process.env.CLAUDE_PLUGIN_DATA, sendJobId, 'bridge', [sendTag || sendRole], _routingSessionId, _clientHostPid);
1452
+ // Detached resume MUST outlive the originating MCP request — mirror
1453
+ // the spawn dispatch contract: do NOT close/kill the session on
1454
+ // request abort (#1). A request-abort that tore down the resumed
1455
+ // session here used to flip a long resume to silently-dropped /
1456
+ // cancelled the moment the MCP request lifecycle ended. Only a
1457
+ // logging listener is installed; operators stop the work explicitly
1458
+ // via bridge type=close.
1459
+ const _detachSendAbortLog = _wireRequestAbortLog(requestSignal, {
1460
+ sessionId,
1461
+ role: sendRole,
1462
+ jobId: sendJobId,
1463
+ });
1464
+ (async () => {
1465
+ let _delivered = false;
1466
+ try {
1467
+ const result = await askSession(sessionId, message, args.context || null);
1468
+ let content;
1469
+ if (result && typeof result.content === 'string' && result.content.length > 0) {
1470
+ content = result.content;
1471
+ } else {
1472
+ content = _renderEmptyContentDiagnostic(result, _sendSession);
1473
+ }
1474
+ const _extractedAnswer = _resolveFinalAnswer(content);
1475
+ try {
1476
+ const _tail = setPendingResult(process.env.CLAUDE_PLUGIN_DATA, sendJobId, 'bridge', [sendTag || sendRole], content, false, _routingSessionId, _clientHostPid);
1477
+ if (_tail && typeof _tail.then === 'function') await _tail;
1478
+ } catch (e) { try { process.stderr.write(`[bridge] setPendingResult (send success) failed: job=${sendJobId} role=${sendRole} ${e?.message ?? e}\n`); } catch {} }
1479
+ _delivered = (await _bridgeEmitDelivered(
1480
+ emit,
1481
+ `${sendModelTag}[${sendTag}] ${_extractedAnswer}`,
1482
+ { pathLabel: 'send success path', jobId: sendJobId, sessionId, role: sendRole },
1483
+ )) || _delivered;
1484
+ } catch (err) {
1485
+ if (err instanceof SessionClosedError) {
1486
+ // Preserve SessionClosedError handling — emit a cancellation
1487
+ // notice only for reasons that warrant one (mirrors spawn).
1488
+ // Label by tag (bridgeTag||role), matching the spawn cancel
1489
+ // path (#4).
1490
+ let reason = err.reason || null;
1491
+ if (!reason && typeof err.message === 'string') {
1492
+ const m = err.message.match(/reason=([\w-]+)/);
1493
+ if (m) reason = m[1];
1494
+ }
1495
+ if (shouldEmitBridgeCancellation(reason)) {
1496
+ _delivered = (await _bridgeEmitDelivered(
1497
+ emit,
1498
+ reason ? `${sendTag} cancelled (reason=${reason})` : `${sendTag} cancelled`,
1499
+ { pathLabel: 'send cancel path', jobId: sendJobId, sessionId, role: sendRole, meta: _bridgeDispatchMeta(sendJobId, { error: true }) },
1500
+ )) || _delivered;
1501
+ } else {
1502
+ _delivered = true;
1503
+ }
1504
+ } else {
1505
+ const _errBody = `${sendTag} error: ${errText(err)}`;
1506
+ try {
1507
+ const _tail = setPendingResult(process.env.CLAUDE_PLUGIN_DATA, sendJobId, 'bridge', [sendTag || sendRole], _errBody, true, _routingSessionId, _clientHostPid);
1508
+ if (_tail && typeof _tail.then === 'function') await _tail;
1509
+ } catch (e) { try { process.stderr.write(`[bridge] setPendingResult (send error) failed: job=${sendJobId} role=${sendRole} ${e?.message ?? e}\n`); } catch {} }
1510
+ // Error label by tag (bridgeTag||role) to match the spawn error
1511
+ // path (#4).
1512
+ _delivered = (await _bridgeEmitDelivered(
1513
+ emit,
1514
+ _errBody,
1515
+ { pathLabel: 'send error path', jobId: sendJobId, sessionId, role: sendRole, meta: _bridgeDispatchMeta(sendJobId, { error: true }) },
1516
+ )) || _delivered;
1517
+ }
1518
+ } finally {
1519
+ try { _detachSendAbortLog(); } catch { /* ignore */ }
1520
+ if (_delivered) {
1521
+ try { removePending(process.env.CLAUDE_PLUGIN_DATA, sendJobId); } catch {}
1522
+ } else {
1523
+ try { process.stderr.write(`[bridge] keeping pending record (send emit not delivered): job=${sendJobId} session=${sessionId} role=${sendRole}\n`); } catch {}
1524
+ }
1525
+ // Re-arm the deferred reap on BOTH success and failure (#2 fix):
1526
+ // send() cancelled the pending reap before resuming, so a failed
1527
+ // resumed turn would otherwise leave the idle/error session never
1528
+ // terminally reaped. _scheduleBridgeReap cancels any prior timer
1529
+ // so this is idempotent. Keep the tag bound across the resume.
1530
+ // The detached resume outlives the request (no close-on-abort),
1531
+ // so always re-arm.
1532
+ {
1533
+ if (_liveTag) { try { _tagSessionRegistry.set(_liveTag, sessionId); } catch {} }
1534
+ try { _scheduleBridgeReap(sessionId); } catch { /* ignore */ }
1535
+ }
1536
+ }
1537
+ })().catch(async (err) => {
1538
+ try { process.stderr.write(`[bridge] detached send runner unhandled: session=${sessionId} role=${sendRole} job=${sendJobId} ${err instanceof Error ? (err.stack || err.message) : String(err)}\n`); } catch {}
1539
+ const _crashDelivered = await _bridgeEmitDelivered(
1540
+ emit,
1541
+ `${sendTag} crash: ${errText(err)}`,
1542
+ { pathLabel: 'send crash path', jobId: sendJobId, sessionId, role: sendRole, meta: _bridgeDispatchMeta(sendJobId, { error: true }) },
1543
+ );
1544
+ if (_crashDelivered) {
1545
+ try { removePending(process.env.CLAUDE_PLUGIN_DATA, sendJobId); } catch {}
1546
+ } else {
1547
+ try { process.stderr.write(`[bridge] keeping pending record (send crash emit not delivered): job=${sendJobId} session=${sessionId} role=${sendRole}\n`); } catch {}
1548
+ }
1549
+ });
1550
+
1551
+ return ok({
1552
+ jobId: sendJobId,
1553
+ sessionId,
1554
+ tag: _tagForSessionId(sessionId) || _liveTag || null,
1555
+ detached: true,
1556
+ respawned: false,
1557
+ });
1558
+ }
1559
+
1560
+ if (bridgeType !== 'spawn') {
1561
+ return fail(`bridge: unknown type "${bridgeType}" (expected spawn|send|close|list)`);
1562
+ }
1563
+
1564
+ // --- spawn (default): dispatch a detached worker ---
1565
+ // Enforce exactly-one-of: prompt | file | ref. Schema is the first
1566
+ // gate; _resolveBridgePrompt is the second defence for clients that
1567
+ // bypass JSON Schema validation. `message` is the unified alias for
1568
+ // `prompt` on spawn — fold it in before resolution. Resolve the bridge
1569
+ // worker's cwd BEFORE reading args.file so a relative path is resolved
1570
+ // against the worker's effective workspace (args.cwd > callerCwd), not
1571
+ // the agent host process cwd.
1572
+ if (!args.role) return fail('role is required');
1573
+ // dev-sync recycle barrier: if dev-sync has flagged THIS daemon
1574
+ // (server_pid) for a forced restart, the in-band kill is delayed and
1575
+ // the daemon keeps serving for the kill-delay + respawn window.
1576
+ // Spawning here would bind the worker to about-to-die, stale
1577
+ // code/schema daemon (the stale-schema-after-restart incident).
1578
+ // Fail fast with a retryable message so the caller's next call
1579
+ // reconnects to the fresh daemon instead.
1580
+ if (isCurrentDaemonDoomed()) {
1581
+ return fail('bridge spawn deferred: this daemon (server_pid) is being recycled by dev-sync — retry; the next call reconnects to the fresh daemon');
1582
+ }
1583
+ if (args.message != null && args.prompt == null) args = { ...args, prompt: args.message };
1584
+ // Allocate the tag up front so a duplicate-live-tag request fails fast
1585
+ // before any session is created.
1586
+ const _tagAlloc = _allocateBridgeTag(args.tag, args.role);
1587
+ if (_tagAlloc.error) return fail(_tagAlloc.error);
1588
+ const bridgeTag = _tagAlloc.tag;
1589
+ const { _rawBridgeCwd, _safeBridgeCwd } = _buildBridgeCwds(args, callerCwd);
1590
+ const _promptResolution = await _resolveBridgePrompt(args, _rawBridgeCwd);
1591
+ if (_promptResolution.error) return fail(_promptResolution.error);
1592
+ let prompt = _promptResolution.prompt;
1593
+
1594
+ // Bench escape hatch — when both provider and model are supplied,
1595
+ // build an ad-hoc preset on the fly and bypass mixdog-config.json.
1596
+ // Role still drives BP2 catalog scoping (unknown role names fall back
1597
+ // to the legacy all-in-one catalog inside loadScopedRoleCatalog).
1598
+ // systemPrompt is appended as a prefix to prompt so the same dispatch
1599
+ // path can carry caller-injected guidance without changing the
1600
+ // session-builder schema.
1601
+ const config = loadConfig();
1602
+ const _presetResolution = _resolveBridgePreset(args, config);
1603
+ if (_presetResolution.error) return fail(_presetResolution.error);
1604
+ const preset = _presetResolution.preset;
1605
+ const presetName = _presetResolution.presetName;
1606
+ const _roleConfig = _presetResolution._roleConfig;
1607
+ if (_presetResolution.promptPrefix) {
1608
+ prompt = _presetResolution.promptPrefix + prompt;
1609
+ }
1610
+
1611
+ const role = args.role;
1612
+ const effectiveLane = 'bridge';
1613
+ const runtimeSpec = resolveRuntimeSpec(preset, {
1614
+ lane: effectiveLane,
1615
+ agentId: role,
1616
+ });
1617
+
1618
+ // Stateless ephemeral session — created fresh per call (v0.6.97+).
1619
+ // No pool, no resume, no reset. Provider-level prefix cache still
1620
+ // hits because cache is content-keyed, not session-keyed. Shared
1621
+ // with the Smart Bridge path via session-builder so role/preset
1622
+ // telemetry stays bit-identical in bridge-trace.jsonl.
1623
+ // P1 fix: cwd silent-swap surfacing. When _safeCwdForSpawn substitutes
1624
+ // process.cwd() for a non-ASCII Windows cwd, the agent has no way to
1625
+ // know the authoritative working directory and may resolve relative
1626
+ // paths into the wrong tree. Inject a header into the prompt so the
1627
+ // worker uses absolute paths under the originally-requested cwd.
1628
+ // R3 Gap 3: clamp forwarded permissionMode against the parent Lead session
1629
+ // so a bridge worker can never become MORE permissive than its caller.
1630
+ const _requestedPermissionMode = args.permission_mode || args.permissionMode || undefined;
1631
+ let _parentPermissionMode = null;
1632
+ if (callerSessionId) {
1633
+ try { _parentPermissionMode = getSession(callerSessionId)?.permissionMode || null; }
1634
+ catch (e) {
1635
+ // Fail closed: an unreadable parent must clamp the worker to the most
1636
+ // restrictive mode ('plan'), not silently pass as a default parent.
1637
+ try { process.stderr.write(`[bridge] parent permission lookup failed for ${callerSessionId}: ${e?.message ?? e}\n`); } catch {}
1638
+ _parentPermissionMode = 'plan';
1639
+ }
1640
+ }
1641
+ const _permissionMode = _clampBridgePermissionMode(_requestedPermissionMode, _parentPermissionMode, args.role);
1642
+ prompt = _applyBridgeCwdHeaders(prompt, _rawBridgeCwd, _safeBridgeCwd);
1643
+ const { session, effectiveCwd } = prepareBridgeSession({
1644
+ role,
1645
+ presetName,
1646
+ preset,
1647
+ runtimeSpec,
1648
+ cwd: _safeBridgeCwd,
1649
+ sourceType: 'lead',
1650
+ sourceName: role,
1651
+ parentSessionId: callerSessionId,
1652
+ permissionMode: _permissionMode,
1653
+ cacheKeyOverride: args.cacheKey || undefined,
1654
+ permission: _roleConfig?.permission || undefined,
1655
+ // Persist the bridge tag on the session JSON (sync save inside
1656
+ // createSession lands it BEFORE the heartbeat publish below) so the
1657
+ // forked statusline process + aggregator can read s.bridgeTag.
1658
+ bridgeTag,
1659
+ clientHostPid: _clientHostPid || undefined,
1660
+ });
1661
+
1662
+ // workerCwd precedence: explicit Lead intent (effectiveCwd) > the
1663
+ // dispatching session's cwd (callerCwd) > the ambient override/user cwd
1664
+ // (pwd()). callerCwd sits ahead of pwd() so a worker never silently
1665
+ // inherits the daemon launch dir (plugin cache) when no explicit cwd was
1666
+ // given — pwd() in the daemon resolves to process.cwd() == plugin root.
1667
+ let workerCwd = effectiveCwd || callerCwd || pwd();
1668
+
1669
+ const jobId = `bridge_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
1670
+ // Opt-in git-worktree isolation (args.isolation === 'worktree'): run the
1671
+ // worker in a dedicated worktree + branch so parallel workers can edit
1672
+ // the same files without clobbering each other's working tree. Resolved
1673
+ // BEFORE prewarm so the code-graph cache warms against the worktree path.
1674
+ // Best-effort — any git failure falls back to the plain workerCwd. The
1675
+ // handle is captured for teardown in the IIFE finally block.
1676
+ // Pre-isolation cwd: the real workerCwd before any worktree swap. The
1677
+ // worktree path is per-jobId ephemeral and deleted on clean teardown,
1678
+ // so a cold tag-respawn must restore THIS (the real repo cwd), not the
1679
+ // swapped worktree dir which would no longer exist.
1680
+ const _preIsolationWorkerCwd = workerCwd;
1681
+ let _workerWorktree = null;
1682
+ if (args.isolation === 'worktree') {
1683
+ _workerWorktree = _setupWorkerWorktree({ workerCwd, jobId, tag: bridgeTag });
1684
+ if (_workerWorktree) workerCwd = _workerWorktree.worktreePath;
1685
+ }
1686
+ _runBridgePrewarm(workerCwd, args.prefetch);
1687
+ // Brief size soft-warn: cap is ≤150 words per subsystem per rules/lead/00-tool-lead.md.
1688
+ const _briefWordCount = _bridgeBriefWordCount(prompt);
1689
+ if (_briefWordCount > 150) {
1690
+ process.stderr.write(
1691
+ `[brief-size-warn] brief is ${_briefWordCount} words (cap=150) — split into multiple roles\n`
1692
+ );
1693
+ }
1694
+ const modelLabel = preset.model || preset.name;
1695
+ const emit = notifyFn || (() => {});
1696
+ // Persist the in-flight bridge job so a child crash/restart can surface
1697
+ // a loss notification via recoverPending() on next bootstrap instead of
1698
+ // leaving the session permanently stuck at 'running'.
1699
+ addPending(process.env.CLAUDE_PLUGIN_DATA, jobId, 'bridge', [role], _routingSessionId, _clientHostPid);
1700
+ // Synchronous .hb write at dispatch entry (BEFORE the async IIFE) so
1701
+ // the status aggregator surfaces this session immediately. Without
1702
+ // this, the first heartbeat had to wait for the IIFE to spawn and the
1703
+ // first stream delta to land — short / cached bridge-worker calls
1704
+ // (recall / search / explore via dispatchAiWrapped) often completed
1705
+ // before any .hb existed, making them invisible on the statusline.
1706
+ // publishHeartbeat is atomic (tmp + rename) and ≤5s self-throttled,
1707
+ // so the IIFE's own first markSessionStreamDelta naturally collapses
1708
+ // into the same throttle window — no double-write.
1709
+ try { publishSessionHeartbeat(session.id); } catch (e) { process.stderr.write(`[bridge] publishSessionHeartbeat failed: ${e?.message}\n`); }
1710
+ // Public `bridge` is intentionally detached: we return immediately and
1711
+ // keep the session alive until it completes (or is explicitly closed).
1712
+ // Tying requestSignal to session lifetime caused long reviewer runs to
1713
+ // flip to `role cancelled` when the MCP request lifecycle ended before
1714
+ // the detached worker finished. Detached mode therefore does NOT wire
1715
+ // request abort into closeSession(); operators can still stop the work
1716
+ // explicitly through bridge type=close.
1717
+ //
1718
+ // Logging-only listener so operators can correlate the MCP request
1719
+ // abort with a still-running detached worker. Tracked locally so the
1720
+ // IIFE finally block can detach it on normal completion (previously
1721
+ // leaked until GC).
1722
+ const _detachRequestAbortLog = _wireRequestAbortLog(requestSignal, {
1723
+ sessionId: session.id,
1724
+ role,
1725
+ jobId,
1726
+ });
1727
+ const modelTag = _bridgeModelTag(preset);
1728
+
1729
+ // Detached bridge workers do NOT close the session on MCP request
1730
+ // abort — by contract they outlive the originating request. The
1731
+ // logging listener installed above is the only request-lifecycle
1732
+ // tie-in; no closeSession-on-abort wire-up is installed here.
1733
+
1734
+ // Track the active session id across retry attempts (may be replaced
1735
+ // on each retry by a fresh session — the outer `session` binding is
1736
+ // the first attempt; activeSession is updated per attempt and hoisted
1737
+ // outside the IIFE so the .catch() handler can reference it).
1738
+ let activeSession = session;
1739
+ // Track all session IDs created across retry attempts so the finally
1740
+ // block can clean up every _sessionJobRegistry entry, not just the last.
1741
+ const _allRetrySessionIds = new Set([session.id]);
1742
+ // Register initial mapping so bridge type=close can resolve the job's current
1743
+ // session before the IIFE has had a chance to update it.
1744
+ _jobSessionRegistry.set(jobId, activeSession.id);
1745
+ _sessionJobRegistry.set(activeSession.id, jobId);
1746
+ // Bind the tag → current sessionId so `bridge type=send|close tag=<tag>`
1747
+ // can reach this worker. Kept in lockstep with _jobSessionRegistry on
1748
+ // retry-swap (below) so the tag stays stable across retry attempts.
1749
+ _tagSessionRegistry.set(bridgeTag, activeSession.id);
1750
+ if (typeof args.tag === 'string' && args.tag.trim()) {
1751
+ try { _tagRoleRegistry.set(bridgeTag, role); } catch { /* ignore */ }
1752
+ // Capture the resolved cwd so a later cold respawn restores it. Use
1753
+ // the PRE-isolation cwd: a worktree path is per-jobId ephemeral and
1754
+ // removed on clean teardown, so a tag-respawn must start from the
1755
+ // real repo cwd, not a dir that may no longer exist.
1756
+ try { if (_preIsolationWorkerCwd) _tagCwdRegistry.set(bridgeTag, _preIsolationWorkerCwd); } catch { /* ignore */ }
1757
+ }
1758
+ // Close the send busy-guard's startup race: the tag is now resolvable
1759
+ // but the session status is not written 'running' until inside the async
1760
+ // IIFE below (and the runtime entry isn't created until askSession). A
1761
+ // `send` landing in that gap would otherwise see no controller + no
1762
+ // active status and wrongly become the first askSession turn. Mark the
1763
+ // runtime stage 'connecting' synchronously so the guard's active-status
1764
+ // set already catches this window. (in-memory + immediate, unlike the
1765
+ // worker-thread-deferred updateSessionStatus disk write).
1766
+ try { updateSessionStage(activeSession.id, 'connecting'); } catch { /* ignore */ }
1767
+ // Wrap the detached async IIFE in runWithCwdOverride so all builtin tool
1768
+ // calls inside this worker's async context resolve paths against workerCwd
1769
+ // via pwd() — no manual callerCwd propagation through nested calls needed.
1770
+ (async () => runWithCwdOverride(workerCwd, async () => {
1771
+ const t0 = Date.now();
1772
+ let errorMessage = null;
1773
+ let result = null;
1774
+ let lastIteration = 0;
1775
+ let stallWatch = { stop() {}, fired() { return false; } };
1776
+ let _delivered = false;
1777
+ // Worktree-isolation teardown result, computed once on the success
1778
+ // path (so a dirty leftover can be appended to the completion emit)
1779
+ // and otherwise in the finally block. Guarded so it only runs once.
1780
+ let _worktreeTornDown = false;
1781
+ let _worktreeTeardown = null;
1782
+ const _runWorktreeTeardown = () => {
1783
+ if (_worktreeTornDown || !_workerWorktree) return _worktreeTeardown;
1784
+ _worktreeTornDown = true;
1785
+ try { _worktreeTeardown = _teardownWorkerWorktree(_workerWorktree); }
1786
+ catch (e) { try { process.stderr.write(`[bridge] worktree teardown failed: job=${jobId} ${e?.message ?? e}\n`); } catch {} }
1787
+ return _worktreeTeardown;
1788
+ };
1789
+ try {
1790
+ await updateSessionStatus(activeSession.id, 'running');
1791
+ // Bridge Start — silent_to_agent so the "started" lifecycle ping
1792
+ // surfaces on Discord / user terminal but does NOT land in the Lead
1793
+ // agent's context (redundant since Lead already knows it just
1794
+ // dispatched). Done / Error emissions below stay non-silent so the
1795
+ // Lead receives the result / failure.
1796
+ // Best-effort: a synchronous notifyFn throw on this non-critical
1797
+ // lifecycle ping must not abort the bridge work (matches the
1798
+ // completion / error emit pattern wrapped via _bridgeEmitDelivered).
1799
+ try { emit(`${modelTag}${role} started`, { silent_to_agent: true }); } catch (emitErr) {
1800
+ try { process.stderr.write(`[bridge] emit failed (started path): job=${jobId} session=${activeSession.id} role=${role} ${emitErr instanceof Error ? emitErr.message : String(emitErr)}\n`); } catch {}
1801
+ }
1802
+ // Per-session stall watchdog — complements the orchestrator's
1803
+ // stream-watchdog (which fires at 300s/600s on raw stream silence).
1804
+ // This one catches the bridge-specific case where the lead is
1805
+ // waiting on a `worker finished` notification that never arrives:
1806
+ // if the SSE stream is quiet beyond STALL_TIMEOUT_S (default 600s)
1807
+ // and the session isn't in `tool_running`, emit via notifyFn and
1808
+ // abort so the outer catch renders a normal error footer.
1809
+ //
1810
+ // The watchdog is one-shot (self-stops after firing). For
1811
+ // stall-class auto-retry we re-arm a fresh watchdog on each
1812
+ // attempt below, and expose the *current* watchdog to the
1813
+ // retry wrapper via `isStallAbort` so the race-rejector path
1814
+ // (SessionClosedError 'aborted during call' with no closeReason)
1815
+ // is still classified as a stall.
1816
+ const _startStallWatch = () => startBridgeStallWatchdog({
1817
+ sessionId: activeSession.id,
1818
+ // Retry replaces activeSession; the watchdog's flush/hide path
1819
+ // must target the *current* session id, not the original one
1820
+ // captured at construction.
1821
+ getSessionId: () => activeSession.id,
1822
+ getRuntime: () => getSessionRuntime(activeSession.id),
1823
+ getIteration: () => lastIteration,
1824
+ abort: (reason) => {
1825
+ const rt = getSessionRuntime(activeSession.id);
1826
+ rt?.controller?.abort?.(reason);
1827
+ try { closeSession(activeSession.id, String(reason?.message || reason || 'stall-watchdog')); } catch {}
1828
+ },
1829
+ notify: emit,
1830
+ modelTag,
1831
+ role,
1832
+ });
1833
+ stallWatch = _startStallWatch();
1834
+ ({ result } = await runWithDispatchRetry({
1835
+ role,
1836
+ jobId,
1837
+ startAttempt: 0,
1838
+ // Detached bridge worker survives the MCP request lifecycle by
1839
+ // contract; passing requestSignal would let the retry wrapper
1840
+ // abort at attempt boundaries when the originating request ends.
1841
+ externalSignal: null,
1842
+ // Race-rejector path in manager.mjs:1232 surfaces a generic
1843
+ // SessionClosedError('aborted during call', reason=null) — the
1844
+ // structured BridgeStallAbortError marker is lost. Expose the
1845
+ // current watchdog's fired() so runWithDispatchRetry can still
1846
+ // classify that surface as a retryable stall.
1847
+ isStallAbort: () => { try { return stallWatch.fired(); } catch { return false; } },
1848
+ runFn: async (attempt) => {
1849
+ if (attempt > 0) {
1850
+ // Job-level cancellation tombstone check — bridge type=close
1851
+ // may have planted this while runWithDispatchRetry was
1852
+ // sleeping on STALL_RETRY_BACKOFF_MS. If set, surface a
1853
+ // SessionClosedError(reason=manual) so the outer catch
1854
+ // takes the silent-cancel path instead of spinning a
1855
+ // fresh retry session against a job the user already
1856
+ // killed.
1857
+ if (_jobCancelledTombstones.has(jobId)) {
1858
+ throw new SessionClosedError(activeSession.id, 'cancelled before retry (reason=manual)', 'manual');
1859
+ }
1860
+ // Fresh session for retry — no message-history carry-over.
1861
+ try { closeSession(activeSession.id, 'retry-replaced'); } catch {}
1862
+ const retryBuilt = prepareBridgeSession({
1863
+ role,
1864
+ presetName,
1865
+ preset,
1866
+ runtimeSpec,
1867
+ cwd: _safeBridgeCwd,
1868
+ sourceType: 'lead',
1869
+ sourceName: role,
1870
+ parentSessionId: callerSessionId,
1871
+ permissionMode: _permissionMode,
1872
+ cacheKeyOverride: args.cacheKey || undefined,
1873
+ permission: _roleConfig?.permission || undefined,
1874
+ // Carry the stable bridge tag onto the replacement session
1875
+ // JSON so statusline/aggregator keep reading it post-swap.
1876
+ bridgeTag,
1877
+ clientHostPid: _clientHostPid || undefined,
1878
+ });
1879
+ activeSession = retryBuilt.session;
1880
+ _allRetrySessionIds.add(activeSession.id);
1881
+ // Keep jobId→sessionId registry current so bridge type=close
1882
+ // (called by the Lead with the original sessionId, which
1883
+ // bridge type=close resolves via _sessionJobRegistry) can
1884
+ // forward the close to the replacement worker.
1885
+ _jobSessionRegistry.set(jobId, activeSession.id);
1886
+ _sessionJobRegistry.set(activeSession.id, jobId);
1887
+ // Keep the tag pointing at the live replacement so a
1888
+ // concurrent `bridge type=send tag=<tag>` resumes the current
1889
+ // retry session, not the discarded one. The tag is stable
1890
+ // across the swap; only the underlying sessionId changes.
1891
+ _tagSessionRegistry.set(bridgeTag, activeSession.id);
1892
+ // Same startup-race guard as the initial bind: the rebound
1893
+ // tag now resolves to the fresh retry session, but its status
1894
+ // isn't 'running' until the updateSessionStatus below. Mark
1895
+ // the runtime stage 'connecting' synchronously so a `send`
1896
+ // arriving in this rebind→running gap is rejected by the
1897
+ // busy-guard instead of becoming the first askSession turn.
1898
+ try { updateSessionStage(activeSession.id, 'connecting'); } catch { /* ignore */ }
1899
+ await updateSessionStatus(activeSession.id, 'running');
1900
+ // Best-effort: a synchronous notifyFn throw on this non-critical
1901
+ // retry-attempt ping must not abort the bridge work (matches the
1902
+ // completion / error emit pattern wrapped via _bridgeEmitDelivered).
1903
+ try { emit(`${modelTag}${role} retry attempt=${attempt}`, { silent_to_agent: true }); } catch (emitErr) {
1904
+ try { process.stderr.write(`[bridge] emit failed (retry path): job=${jobId} session=${activeSession.id} role=${role} attempt=${attempt} ${emitErr instanceof Error ? emitErr.message : String(emitErr)}\n`); } catch {}
1905
+ }
1906
+ // Re-arm the stall watchdog for the retry attempt. The
1907
+ // previous watchdog is one-shot — it cleared its interval
1908
+ // when it fired (or stopped via _api_call_with_interrupt's
1909
+ // abort), and stays in the fired() state forever. Without
1910
+ // re-arming, a second stall on the retry attempt would go
1911
+ // undetected and the retry would hang against the bare
1912
+ // provider timeout instead of being aborted again.
1913
+ try { stallWatch.stop(); } catch { /* ignore */ }
1914
+ stallWatch = _startStallWatch();
1915
+ // Reset the iteration tracker so stall messages on this
1916
+ // retry attempt report the current attempt's progress.
1917
+ // Otherwise the askSession callback (iteration >
1918
+ // lastIteration) would silently skip early iterations
1919
+ // whose number is <= the previous attempt's final value.
1920
+ lastIteration = 0;
1921
+ }
1922
+ return askSession(activeSession.id, prompt, args.context || null, (iteration, _calls) => {
1923
+ if (typeof iteration === 'number' && iteration > lastIteration) lastIteration = iteration;
1924
+ }, workerCwd, (args.prefetch && typeof args.prefetch === 'object') ? args.prefetch : undefined);
1925
+ },
1926
+ }));
1927
+ // Footer (model/ctx/cache/out/loops/elapsed) dropped per user spec
1928
+ // — caller (Lead) wants core content only. Per-turn usage stats are
1929
+ // still available via session telemetry / bridge type=list.
1930
+ let content;
1931
+ if (result && typeof result.content === 'string' && result.content.length > 0) {
1932
+ content = result.content;
1933
+ } else {
1934
+ content = _renderEmptyContentDiagnostic(result, activeSession);
1935
+ }
1936
+ const _extractedAnswer = _resolveFinalAnswer(content);
1937
+ // Persist the full result BODY before emit so a torn-down
1938
+ // transport / exit-255 between emit and disk-flush can't drop the
1939
+ // answer — recoverPending replays this content instead of the
1940
+ // generic Aborted boilerplate. Await the disk-flush tail; a
1941
+ // persist failure is best-effort and must NOT block the emit.
1942
+ try {
1943
+ const _tail = setPendingResult(process.env.CLAUDE_PLUGIN_DATA, jobId, 'bridge', [role], content, false, _routingSessionId, _clientHostPid);
1944
+ if (_tail && typeof _tail.then === 'function') await _tail;
1945
+ } catch (e) { try { process.stderr.write(`[bridge] setPendingResult (success) failed: job=${jobId} role=${role} ${e?.message ?? e}\n`); } catch {} }
1946
+ // Tear down the isolation worktree before the completion emit so a
1947
+ // dirty (un-merged) leftover can be surfaced to the Lead. No
1948
+ // auto-merge: a dirty worktree is kept and its path + changed-file
1949
+ // count appended to the emit.
1950
+ const _wtResult = _runWorktreeTeardown();
1951
+ const _wtNote = (_wtResult && _wtResult.kept)
1952
+ ? `\n\n⚠ isolation worktree kept (dirty, ${_wtResult.changedFiles} changed file${_wtResult.changedFiles === 1 ? '' : 's'}): ${_wtResult.path} — no auto-merge`
1953
+ : '';
1954
+ _delivered = (await _bridgeEmitDelivered(
1955
+ emit,
1956
+ `${modelTag}[${bridgeTag || role}] ${_extractedAnswer}${_wtNote}`,
1957
+ { pathLabel: 'success path', jobId, sessionId: activeSession.id, role },
1958
+ )) || _delivered;
1959
+ await updateSessionStatus(activeSession.id, 'idle');
1960
+ } catch (err) {
1961
+ errorMessage = errText(err);
1962
+ if (stallWatch.fired()) {
1963
+ // The stall watchdog already attempted a user-facing message
1964
+ // before aborting; whatever error bubbled up here (likely a
1965
+ // SessionClosedError or provider-side abort surface) is just
1966
+ // the unwind. Mark the session as errored and fall through.
1967
+ // Only treat the pending record as delivered when the
1968
+ // watchdog's notify actually landed (delivered()) — an async
1969
+ // notify rejection routes to the fallback addPending and the
1970
+ // pending record must stay replayable on next boot. Older
1971
+ // builds shipping a watchdog without delivered() degrade
1972
+ // safely: treat absence as "delivered" so behaviour matches
1973
+ // the prior contract.
1974
+ const _watchdogDelivered = typeof stallWatch.delivered === 'function'
1975
+ ? (() => { try { return !!stallWatch.delivered(); } catch { return true; } })()
1976
+ : true;
1977
+ if (_watchdogDelivered) _delivered = true;
1978
+ await updateSessionStatus(activeSession.id, 'error');
1979
+ } else if (err instanceof SessionClosedError) {
1980
+ // Prefer the structured enum on the error; fall back to
1981
+ // regex-parsing the message for older call paths that might
1982
+ // have constructed the error without the third arg.
1983
+ let reason = err.reason || null;
1984
+ if (!reason && typeof err.message === 'string') {
1985
+ const m = err.message.match(/reason=([\w-]+)/);
1986
+ if (m) reason = m[1];
1987
+ }
1988
+ if (shouldEmitBridgeCancellation(reason)) {
1989
+ _delivered = (await _bridgeEmitDelivered(
1990
+ emit,
1991
+ reason ? `${bridgeTag || role} cancelled (reason=${reason})` : `${bridgeTag || role} cancelled`,
1992
+ { pathLabel: 'cancel path', jobId, sessionId: activeSession.id, role, meta: _bridgeDispatchMeta(jobId, { error: true }) },
1993
+ )) || _delivered;
1994
+ } else {
1995
+ // Intentionally-silent cancel (manual / request-abort /
1996
+ // retry-replaced / idle-sweep): no notification by design.
1997
+ // Mark delivered so the finally block clears the pending
1998
+ // record — otherwise the next bootstrap would resurface a
1999
+ // bogus "loss notification" for a user-requested cancel.
2000
+ _delivered = true;
2001
+ }
2002
+ // Stop stall watchdog synchronously on close so the timer can't
2003
+ // fire a spurious notify after the session is already closed.
2004
+ try { stallWatch.stop(); } catch { /* ignore */ }
2005
+ // Cancellation isn't an error; flip to idle so the next sweep
2006
+ // pass can reclaim the file instead of leaving a 'running'
2007
+ // zombie until the 24h tombstone window expires.
2008
+ await updateSessionStatus(activeSession.id, 'idle');
2009
+ } else if (err instanceof StreamStalledAbortError) {
2010
+ const info = err.info || {};
2011
+ const header = `⚠ stream stalled — ${info.staleSeconds}s no delta (stage: ${info.stage || 'unknown'})`;
2012
+ // Persist the rendered error BODY before emit so a crash after
2013
+ // the error is computed replays the real error, not boilerplate.
2014
+ try {
2015
+ const _tail = setPendingResult(process.env.CLAUDE_PLUGIN_DATA, jobId, 'bridge', [role], `${role} error: ${header}`, true, _routingSessionId, _clientHostPid);
2016
+ if (_tail && typeof _tail.then === 'function') await _tail;
2017
+ } catch (e) { try { process.stderr.write(`[bridge] setPendingResult (stall) failed: job=${jobId} role=${role} ${e?.message ?? e}\n`); } catch {} }
2018
+ _delivered = (await _bridgeEmitDelivered(
2019
+ emit,
2020
+ `${bridgeTag || role} error: ${header}`,
2021
+ { pathLabel: 'stall path', jobId, sessionId: activeSession.id, role, meta: _bridgeDispatchMeta(jobId, { error: true }) },
2022
+ )) || _delivered;
2023
+ await updateSessionStatus(activeSession.id, 'error');
2024
+ } else {
2025
+ // Persist the error BODY before emit so a crash after the error
2026
+ // is computed replays the real error, not Aborted boilerplate.
2027
+ try {
2028
+ const _tail = setPendingResult(process.env.CLAUDE_PLUGIN_DATA, jobId, 'bridge', [role], `${role} error: ${errorMessage}`, true, _routingSessionId, _clientHostPid);
2029
+ if (_tail && typeof _tail.then === 'function') await _tail;
2030
+ } catch (e) { try { process.stderr.write(`[bridge] setPendingResult (error) failed: job=${jobId} role=${role} ${e?.message ?? e}\n`); } catch {} }
2031
+ _delivered = (await _bridgeEmitDelivered(
2032
+ emit,
2033
+ `${bridgeTag || role} error: ${errorMessage}`,
2034
+ { pathLabel: 'error path', jobId, sessionId: activeSession.id, role, meta: _bridgeDispatchMeta(jobId, { error: true }) },
2035
+ )) || _delivered;
2036
+ await updateSessionStatus(activeSession.id, 'error');
2037
+ }
2038
+ } finally {
2039
+ // Ensure the isolation worktree is torn down on every exit path
2040
+ // (error / cancel / stall). Idempotent: a no-op when the success
2041
+ // path already ran it. A dirty worktree is kept (logged in the
2042
+ // teardown helper); only a clean one is removed + branch deleted.
2043
+ try { _runWorktreeTeardown(); } catch { /* ignore */ }
2044
+ // Do NOT stop stallWatch here unconditionally — stopping it before
2045
+ // the 1h terminal-reap window prevents terminal-stale sessions
2046
+ // from ever being hidden. Instead, schedule a deferred hide so
2047
+ // listSessions() stops returning the completed bridge session after
2048
+ // 1h. stallWatch.stop() is kept for the explicit close path only
2049
+ // (handled inside the watchdog itself on abort).
2050
+ // Schedule via _scheduleBridgeReap so the timer handle is recorded
2051
+ // per-session in _sessionReapTimers — a later `bridge type=send`
2052
+ // resume can then cancel/reschedule it (see send branch) instead of
2053
+ // being reaped (hidden + tag-deleted) mid-resume.
2054
+ try { _scheduleBridgeReap(activeSession.id); } catch { /* ignore */ }
2055
+ // Detach request-abort logging listener — IIFE has settled, the
2056
+ // MCP request has nothing left to log against. Harmless if abort
2057
+ // already fired (listener was registered { once: true }).
2058
+ try { _detachRequestAbortLog(); } catch { /* ignore */ }
2059
+ // Remove persist record only after emit confirmed delivered.
2060
+ // If emit failed (channel down, owner restart), leave the pending
2061
+ // record intact so the supervisor can resurface or retry the job.
2062
+ if (_delivered) {
2063
+ try { removePending(process.env.CLAUDE_PLUGIN_DATA, jobId); } catch {}
2064
+ } else {
2065
+ try { process.stderr.write(`[bridge] keeping pending record (emit not delivered): job=${jobId} session=${activeSession.id} role=${role}\n`); } catch {}
2066
+ }
2067
+ try { _jobSessionRegistry.delete(jobId); } catch {}
2068
+ // Clean up all session IDs registered across retry attempts.
2069
+ try { for (const sid of _allRetrySessionIds) _sessionJobRegistry.delete(sid); } catch {}
2070
+ try { _jobCancelledTombstones.delete(jobId); } catch {}
2071
+ }
2072
+ }))().catch(async (err) => {
2073
+ // Order: emit FIRST, then remove pending only if emit succeeded.
2074
+ // Previously this handler removed the pending record before
2075
+ // attempting the crash emit — if the emit then failed (channel
2076
+ // down, owner restart) the loss notification was silently
2077
+ // dropped and the next bootstrap had no record to resurface.
2078
+ // Keep genuine emit-failure replayable by keeping the pending
2079
+ // record intact when emit throws.
2080
+ const msg = err instanceof Error ? (err.stack || err.message) : String(err);
2081
+ const crashSessionId = activeSession?.id ?? session.id;
2082
+ try {
2083
+ process.stderr.write(`[bridge] detached runner unhandled: session=${crashSessionId} role=${role} job=${jobId} ${msg}\n`);
2084
+ } catch {}
2085
+ // Best-effort isolation-worktree teardown for the crash path (the
2086
+ // inner finally may not have run if the override wrapper threw).
2087
+ // Idempotent at the git level: removing an already-removed worktree
2088
+ // fails harmlessly and is logged by the helper.
2089
+ try { if (_workerWorktree) _teardownWorkerWorktree(_workerWorktree); } catch { /* ignore */ }
2090
+ // Notify via emit so the crash surfaces to the Lead / channel instead
2091
+ // of silently disappearing. Uses the same channel as normal error
2092
+ // emissions so the Lead can act on it.
2093
+ const _crashDelivered = await _bridgeEmitDelivered(
2094
+ emit,
2095
+ `${bridgeTag || role} crash: ${errText(err)}`,
2096
+ { pathLabel: 'crash path', jobId, sessionId: crashSessionId, role, meta: _bridgeDispatchMeta(jobId, { error: true }) },
2097
+ );
2098
+ if (_crashDelivered) {
2099
+ try { removePending(process.env.CLAUDE_PLUGIN_DATA, jobId); } catch {}
2100
+ } else {
2101
+ try { process.stderr.write(`[bridge] keeping pending record (crash emit not delivered): job=${jobId} session=${crashSessionId} role=${role}\n`); } catch {}
2102
+ }
2103
+ try { _jobSessionRegistry.delete(jobId); } catch {}
2104
+ try { for (const sid of _allRetrySessionIds) _sessionJobRegistry.delete(sid); } catch {}
2105
+ try { _tagSessionRegistry.delete(bridgeTag); } catch {}
2106
+ try { for (const sid of _allRetrySessionIds) _cancelBridgeReap(sid); } catch {}
2107
+ try { _jobCancelledTombstones.delete(jobId); } catch {}
2108
+ try { await updateSessionStatus(crashSessionId, 'error'); } catch {}
2109
+ try { closeSession(crashSessionId, 'runner-crash'); } catch {}
2110
+ });
2111
+
2112
+ return ok({
2113
+ jobId,
2114
+ sessionId: activeSession.id,
2115
+ tag: bridgeTag,
2116
+ role,
2117
+ model: modelLabel,
2118
+ detached: true,
2119
+ respawned: !!_bridgeColdRespawn,
2120
+ // Surface brief size to dispatcher (Lead) so over-cap briefs are
2121
+ // visible without needing to inspect bridge stderr.
2122
+ ...(_briefWordCount > 0 ? { briefWords: _briefWordCount } : {}),
2123
+ ...(_briefWordCount > 150
2124
+ ? { briefWarning: `brief is ${_briefWordCount} words (cap=150)` }
2125
+ : {}),
2126
+ });
2127
+ }
2128
+
2129
+ default:
2130
+ return fail(`Unknown tool: ${name}`);
2131
+ }
2132
+ } catch (err) {
2133
+ return fail(err);
2134
+ }
2135
+ }
2136
+
2137
+ export async function start() { /* noop — standalone mode uses main() */ }
2138
+ export async function stop() { await disconnectAll(); }