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,1975 @@
1
+ import { createRequire } from 'module';
2
+ import { fileURLToPath } from 'url';
3
+ import { randomBytes, createHash } from 'crypto';
4
+ import { existsSync } from 'fs';
5
+ import { join, resolve as pathResolve } from 'path';
6
+ import { homedir } from 'os';
7
+ import { getProvider, providerInputExcludesCache } from '../providers/registry.mjs';
8
+ import { agentLoop } from './loop.mjs';
9
+ import { getMcpTools } from '../mcp/client.mjs';
10
+ import { getInternalTools, executeInternalTool } from '../internal-tools.mjs';
11
+ import { BUILTIN_TOOLS, executeBuiltinTool } from '../tools/builtin.mjs';
12
+ import { PATCH_TOOL_DEFS } from '../tools/patch-tool-defs.mjs';
13
+ import { CODE_GRAPH_TOOL_DEFS } from '../tools/code-graph-tool-defs.mjs';
14
+ import { executeCodeGraphTool } from '../tools/code-graph.mjs';
15
+ import { closeBashSession } from '../tools/bash-session.mjs';
16
+ import { collectSkillsCached, buildSkillToolDefs, loadAgentTemplate, loadRoleTemplate, composeSystemPrompt, collectProjectMd } from '../context/collect.mjs';
17
+ import { saveSession, saveSessionAsync, loadSession, deleteSession, listStoredSessions, getStoredSessionsRaw, sweepStaleSessions, markSessionClosed, publishHeartbeat, deleteHeartbeat, setLiveSession } from './store.mjs';
18
+ import { clearReadDedupSession, tryPrefetchCached, setPrefetchCached, invalidatePrefetchCache } from './read-dedup.mjs';
19
+ import { clearOffloadSession } from './tool-result-offload.mjs';
20
+ import { classifyResultKind } from './result-classification.mjs';
21
+ import { createAbortController } from '../../../shared/abort-controller.mjs';
22
+ import { logLlmCall } from '../../../shared/llm/usage-log.mjs';
23
+ import { resolvePluginData, DEFAULT_PLUGIN, DEFAULT_MARKETPLACE } from '../../../shared/plugin-paths.mjs';
24
+ import { traceBridgeTool, appendBridgeTrace } from '../bridge-trace.mjs';
25
+ import { isHiddenRole } from '../internal-roles.mjs';
26
+ import { runWithCwdOverride, pwd } from '../../../shared/user-cwd.mjs';
27
+ import { maxMtimeRecursive } from '../cache-mtime.mjs';
28
+ // Phase B: Pool B Tier 2 content builder (common rules only).
29
+ // Loaded once per process via createRequire so the CJS module reaches us.
30
+ const _require = createRequire(import.meta.url);
31
+ const _rulesBuilder = (() => {
32
+ const candidates = [
33
+ process.env.CLAUDE_PLUGIN_ROOT && join(process.env.CLAUDE_PLUGIN_ROOT, 'lib', 'rules-builder.cjs'),
34
+ ].filter(Boolean);
35
+ for (const p of candidates) {
36
+ try { return _require(p); } catch { /* fall through */ }
37
+ }
38
+ // Fallback: walk up from this file's location to find lib/rules-builder.cjs.
39
+ try { return _require('../../../../lib/rules-builder.cjs'); } catch { return null; }
40
+ })();
41
+
42
+ // bridgeRules is the bridge shared prefix (shared rules + bridge common rules +
43
+ // user agent configs). It's rebuilt from disk
44
+ // by rules-builder.cjs on every call; since createSession fires on every
45
+ // Pool B/C bridge turn, that's a lot of redundant readFileSync + concat.
46
+ // BP1/BP3 cache — invalidated by source file mtime, not a timer.
47
+ // Cheap: O(sentinel-count) stat calls on each bridge turn, no I/O otherwise.
48
+ // BP1 cache — single shared entry. buildBridgeInjectionContent is
49
+ // role-agnostic (true cross-role common), so every bridge role reuses the
50
+ // same prefix bytes.
51
+ let _bridgeRulesCache = null;
52
+ let _bridgeRulesMtime = 0;
53
+ function _buildBridgeRules() {
54
+ if (!_rulesBuilder || typeof _rulesBuilder.buildBridgeInjectionContent !== 'function') return '';
55
+ const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT
56
+ || join(homedir(), '.claude', 'plugins', 'marketplaces', DEFAULT_MARKETPLACE, 'external_plugins', DEFAULT_PLUGIN);
57
+ const DATA_DIR = resolvePluginData();
58
+ const RULES_DIR = join(PLUGIN_ROOT, 'rules');
59
+ const mtime = maxMtimeRecursive([
60
+ join(RULES_DIR, 'shared'),
61
+ join(RULES_DIR, 'bridge'),
62
+ join(DATA_DIR, 'roles'),
63
+ join(DATA_DIR, 'mixdog-config.json'),
64
+ ]);
65
+ if (_bridgeRulesCache !== null && mtime <= _bridgeRulesMtime) {
66
+ return _bridgeRulesCache;
67
+ }
68
+ try {
69
+ const built = _rulesBuilder.buildBridgeInjectionContent({ PLUGIN_ROOT, DATA_DIR });
70
+ _bridgeRulesCache = built;
71
+ _bridgeRulesMtime = mtime;
72
+ return built;
73
+ } catch (e) {
74
+ throw new Error(`[session] bridge common rules build failed: ${e.message}`);
75
+ }
76
+ }
77
+
78
+ // BP3 role-specific cache — keyed by role. webhook / schedule / hidden
79
+ // retrieval roles each have their own scoped instruction set; other roles
80
+ // return ''.
81
+ const _roleSpecificCache = new Map(); // role → { value, mtime }
82
+ function _buildRoleSpecific(currentRole) {
83
+ if (!_rulesBuilder || typeof _rulesBuilder.buildBridgeRoleSpecificContent !== 'function') return '';
84
+ if (!currentRole) return '';
85
+ const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT
86
+ || join(homedir(), '.claude', 'plugins', 'marketplaces', DEFAULT_MARKETPLACE, 'external_plugins', DEFAULT_PLUGIN);
87
+ const DATA_DIR = resolvePluginData();
88
+ const RULES_DIR = join(PLUGIN_ROOT, 'rules');
89
+ const mtime = maxMtimeRecursive([
90
+ join(RULES_DIR, 'shared'),
91
+ join(DATA_DIR, 'mixdog-config.json'),
92
+ join(DATA_DIR, 'webhooks'),
93
+ join(DATA_DIR, 'schedules'),
94
+ ]);
95
+ const entry = _roleSpecificCache.get(currentRole);
96
+ if (entry && mtime <= entry.mtime) {
97
+ return entry.value;
98
+ }
99
+ try {
100
+ const built = _rulesBuilder.buildBridgeRoleSpecificContent({ PLUGIN_ROOT, DATA_DIR, currentRole });
101
+ _roleSpecificCache.set(currentRole, { mtime, value: built });
102
+ return built;
103
+ } catch (e) {
104
+ throw new Error(`[session] role-specific rules build failed (role: ${currentRole}): ${e.message}`);
105
+ }
106
+ }
107
+
108
+ // Smart Bridge is optional — injected via setSmartBridge() during plugin init
109
+ // so session creation never depends on a circular import. If never injected,
110
+ // createSession simply falls back to classic preset-only behavior.
111
+ let _smartBridgeApi = null;
112
+ let _smartBridgeWarned = false;
113
+
114
+ /**
115
+ * Inject the Smart Bridge singleton. Called once by agent/index.mjs init()
116
+ * after initSmartBridge(). Safe to call multiple times — later calls
117
+ * replace the previous reference.
118
+ */
119
+ export function setSmartBridge(api) {
120
+ _smartBridgeApi = api || null;
121
+ }
122
+
123
+ function getSmartBridgeSync() {
124
+ return _smartBridgeApi;
125
+ }
126
+
127
+ /**
128
+ * Thrown when a session is closed while a call is in-flight. Callers (bridge
129
+ * handler, CLI) should render this as "cancelled" rather than a hard error.
130
+ */
131
+ export class SessionClosedError extends Error {
132
+ constructor(sessionId, reason, closeReason) {
133
+ super(reason ? `Session "${sessionId}" closed: ${reason}` : `Session "${sessionId}" closed`);
134
+ this.name = 'SessionClosedError';
135
+ this.sessionId = sessionId;
136
+ this.cancelled = true;
137
+ // closeReason is the diagnostic enum (request-abort / manual /
138
+ // idle-sweep / runner-crash). Kept separate from `reason` (the free
139
+ // -form message) so consumers can branch on it without regex parsing.
140
+ this.reason = closeReason || null;
141
+ }
142
+ }
143
+ const HEARTBEAT_THROTTLE_MS = 60_000; // 60s
144
+
145
+ // Merge externally-connected MCP tools with the plugin's in-process tools
146
+ // (registered by agent's toolExecutor bridge). Internal tools are exposed
147
+ // under their bare names — no mcp__ prefix, since the dispatcher in
148
+ // server.mjs handles them directly without a transport.
149
+ // Sorted deterministically by name — protects BP_1 hash stability from
150
+ // listTools() ordering churn. Anthropic / OpenAI / Gemini all hash the
151
+ // tools array verbatim, so any reorder rewrites the prefix.
152
+ // No cache: getMcpTools() and getInternalTools() are O(n) in-memory reads;
153
+ // the sort overhead on ~30 tools is negligible.
154
+ function _getMcpTools() {
155
+ const mcp = getMcpTools() || [];
156
+ const internalRaw = getInternalTools() || [];
157
+ const internal = internalRaw.map(t => ({
158
+ name: t.name,
159
+ description: typeof t.description === 'string' ? t.description : '',
160
+ inputSchema: t.inputSchema || { type: 'object', properties: {} },
161
+ // Keep annotations so the permission filter / role invariants can
162
+ // tell read-only from write-capable internal tools, and so
163
+ // bridgeHidden can be read during deny filtering.
164
+ annotations: t.annotations || {},
165
+ }));
166
+ return [...mcp, ...internal].sort((a, b) => {
167
+ const an = a?.name || '';
168
+ const bn = b?.name || '';
169
+ return an < bn ? -1 : an > bn ? 1 : 0;
170
+ });
171
+ }
172
+
173
+ // Phase D-2 — profile.tools resolution.
174
+ //
175
+ // `toolSpec` may be:
176
+ // • Array<string> (profile.tools) — toolset ids like "tools:filesystem",
177
+ // "tools:git", "tools:mcp", "tools:search",
178
+ // "tools:readonly", or the literal "full"
179
+ // • 'full' / 'readonly' / 'mcp' — legacy preset.tools strings
180
+ // • null / undefined — same as 'full' (historical default)
181
+ //
182
+ // Array form is the Phase B/D target: each profile declares its tool surface
183
+ // explicitly, BP_1 hash differs across profiles with different tool subsets
184
+ // (by design — sub-task profile cannot see bash; worker-full can), and
185
+ // adding a new toolset id here is a localised change.
186
+ //
187
+ // Unified-shard policy — the session's tool array normally never narrows
188
+ // with permission or role. Bridge sessions share the same schema so BP_1
189
+ // stays bit-identical and the provider-side cache shard is shared
190
+ // workspace-wide. Rare specialist roles may pass schemaAllowedTools from a
191
+ // declarative hidden-role toolSchemaProfile to keep their first-turn routing
192
+ // surface intentionally tiny; runtime permission guards in loop.mjs remain
193
+ // the fail-safe either way.
194
+
195
+ const SESSION_ROUTE_TOOL_ORDER = [
196
+ 'code_graph',
197
+ 'glob',
198
+ 'list',
199
+ 'grep',
200
+ 'read',
201
+ 'edit',
202
+ 'write',
203
+ 'apply_patch',
204
+ 'bash',
205
+ 'job_wait',
206
+ ];
207
+ const SESSION_ROUTE_TOOL_RANK = new Map(SESSION_ROUTE_TOOL_ORDER.map((name, index) => [name, index]));
208
+ const FILESYSTEM_TOOL_NAMES = new Set([
209
+ 'code_graph',
210
+ 'glob',
211
+ 'list',
212
+ 'grep',
213
+ 'read',
214
+ 'edit',
215
+ 'write',
216
+ 'apply_patch',
217
+ ]);
218
+ const READONLY_TOOL_NAMES = new Set([
219
+ 'code_graph',
220
+ 'glob',
221
+ 'list',
222
+ 'grep',
223
+ 'read',
224
+ ]);
225
+
226
+ function orderSessionTools(tools) {
227
+ return tools.map((tool, index) => ({ tool, index }))
228
+ .sort((a, b) => {
229
+ const ar = SESSION_ROUTE_TOOL_RANK.get(a.tool?.name) ?? 10_000;
230
+ const br = SESSION_ROUTE_TOOL_RANK.get(b.tool?.name) ?? 10_000;
231
+ if (ar !== br) return ar - br;
232
+ return a.index - b.index;
233
+ })
234
+ .map((entry) => entry.tool);
235
+ }
236
+
237
+ const ALL_BUILTIN_SESSION_TOOLS = orderSessionTools(_dedupByName([
238
+ ...BUILTIN_TOOLS,
239
+ ...PATCH_TOOL_DEFS,
240
+ ...CODE_GRAPH_TOOL_DEFS,
241
+ ]));
242
+
243
+ function resolveSessionTools(toolSpec, skills, { ownerIsBridge = false } = {}) {
244
+ const mcp = _getMcpTools();
245
+ // Bridge sessions freeze the 3 skill meta-tools into the schema
246
+ // unconditionally — concrete skill resolution is cwd-scoped at tool-call
247
+ // time (loop.mjs), so the schema bytes stay bit-identical across roles /
248
+ // cwds and the provider cache shard does not fragment.
249
+ const skillTools = buildSkillToolDefs(skills, { ownerIsBridge });
250
+ return _computeBaseTools(toolSpec, mcp, skillTools);
251
+ }
252
+
253
+ // Dedup by name, first occurrence wins. BUILTIN_TOOLS is passed in ahead
254
+ // of the MCP-registered internal tools so plugin-side definitions take
255
+ // precedence when both surfaces declare the same name (e.g. read / grep / glob).
256
+ // Without this merge, Anthropic rejected the request with
257
+ // "tools: Tool names must be unique" and the orchestrator burned up to
258
+ // 20 iterations retrying before the final answer landed.
259
+ function _dedupByName(tools) {
260
+ const seen = new Map();
261
+ for (const t of tools) {
262
+ const n = t?.name;
263
+ if (!n || seen.has(n)) continue;
264
+ seen.set(n, t);
265
+ }
266
+ return [...seen.values()];
267
+ }
268
+
269
+ // Bridge visibility is declared per-tool via annotations.bridgeHidden.
270
+ // Tools with bridgeHidden:true are stripped from bridge sessions at schema
271
+ // build time (see deny filtering below). No code-level name list needed.
272
+
273
+ function _computeBaseTools(toolSpec, mcp, skillTools) {
274
+ if (Array.isArray(toolSpec)) {
275
+ if (toolSpec.length === 0) {
276
+ // Explicit "no tools" — skill meta tools still travel so the model
277
+ // can at least discover and invoke skills if that is the one
278
+ // dynamic surface the profile retains.
279
+ return _dedupByName([...skillTools]);
280
+ }
281
+ if (toolSpec.includes('full')) {
282
+ return _dedupByName([...ALL_BUILTIN_SESSION_TOOLS, ...mcp, ...skillTools]);
283
+ }
284
+ const byName = new Map();
285
+ const add = (tool) => { if (tool?.name && !byName.has(tool.name)) byName.set(tool.name, tool); };
286
+ const addMany = (arr) => { for (const t of arr) add(t); };
287
+ for (const tagRaw of toolSpec) {
288
+ const tag = String(tagRaw || '').trim();
289
+ switch (tag) {
290
+ case 'tools:filesystem':
291
+ addMany(ALL_BUILTIN_SESSION_TOOLS.filter(t => FILESYSTEM_TOOL_NAMES.has(t.name)));
292
+ break;
293
+ case 'tools:readonly':
294
+ addMany(ALL_BUILTIN_SESSION_TOOLS.filter(t => READONLY_TOOL_NAMES.has(t.name)));
295
+ break;
296
+ case 'tools:bash':
297
+ case 'tools:git':
298
+ case 'tools:analysis':
299
+ // Three aliases for the same surface — `bash` is the only
300
+ // shell-class tool. `tools:git` / `tools:analysis` exist so
301
+ // profile authors can name the intent (git workflows / data
302
+ // analysis) without inventing new toolset ids.
303
+ addMany(ALL_BUILTIN_SESSION_TOOLS.filter(t => t.name === 'bash'));
304
+ break;
305
+ case 'tools:mcp':
306
+ addMany(mcp);
307
+ break;
308
+ case 'tools:search':
309
+ // Name-pattern match: picks up `search` and any future tool
310
+ // whose name contains `search`. `recall` and `explore` deliberately do NOT match
311
+ // — they need `tools:mcp` (full mcp surface) or their own
312
+ // toolset id if a role wants targeted retrieval. Public bridge
313
+ // roles never reach the wrapper bodies regardless: see the
314
+ // isBlockedPublicWrapperCall guard in session/loop.mjs.
315
+ addMany(mcp.filter(t => /search/i.test(t?.name || '')));
316
+ break;
317
+ default:
318
+ process.stderr.write(`[session] unknown toolset id "${tag}" (profile.tools); skipping\n`);
319
+ }
320
+ }
321
+ return _dedupByName([...byName.values(), ...skillTools]);
322
+ }
323
+
324
+ switch (toolSpec) {
325
+ case 'mcp':
326
+ return _dedupByName([...mcp, ...skillTools]);
327
+ case 'readonly': {
328
+ const readTools = ALL_BUILTIN_SESSION_TOOLS.filter(t => READONLY_TOOL_NAMES.has(t.name));
329
+ return _dedupByName([...readTools, ...mcp, ...skillTools]);
330
+ }
331
+ case 'full':
332
+ default:
333
+ return _dedupByName([...ALL_BUILTIN_SESSION_TOOLS, ...mcp, ...skillTools]);
334
+ }
335
+ }
336
+
337
+ function permissionFromToolSpec(toolSpec) {
338
+ if (toolSpec === 'readonly') return 'read';
339
+ if (toolSpec === 'mcp') return 'mcp';
340
+ if (Array.isArray(toolSpec)) {
341
+ const tags = new Set(toolSpec.map(t => String(t || '').trim()));
342
+ const hasWriteOrShell = tags.has('full')
343
+ || tags.has('tools:filesystem')
344
+ || tags.has('tools:bash')
345
+ || tags.has('tools:git')
346
+ || tags.has('tools:analysis');
347
+ if (tags.has('tools:readonly') && !hasWriteOrShell) return 'read';
348
+ }
349
+ return null;
350
+ }
351
+
352
+ let nextId = Date.now();
353
+ // Known context windows for the current-generation models this plugin
354
+ // routes to. Anything not listed falls through to guessContextWindow() —
355
+ // local llama/mistral/phi default to 8192, everything else 128000. Keep
356
+ // this map trimmed to live models; older generations slow down reads
357
+ // without buying anything.
358
+ const CONTEXT_WINDOWS = {
359
+ // OpenAI GPT-5.x family
360
+ 'gpt-5.5': 1000000,
361
+ 'gpt-5.4-mini': 1000000,
362
+ 'gpt-5.4-nano': 1000000,
363
+ // Anthropic Claude 4.x
364
+ 'claude-opus-4-8': 1000000,
365
+ 'claude-opus-4-7': 1000000,
366
+ 'claude-sonnet-4-6': 1000000,
367
+ 'claude-haiku-4-5-20251001': 200000,
368
+ // Google Gemini 3.x
369
+ 'gemini-3.1-pro': 1000000,
370
+ 'gemini-3-pro': 1000000,
371
+ 'gemini-3.5-flash': 1000000,
372
+ 'gemini-3-flash': 1000000,
373
+ };
374
+ function guessContextWindow(model) {
375
+ if (CONTEXT_WINDOWS[model])
376
+ return CONTEXT_WINDOWS[model];
377
+ if (model.includes('llama') || model.includes('mistral') || model.includes('phi'))
378
+ return 8192;
379
+ return 128000;
380
+ }
381
+ // Provider-scoped unified cache key. Goal: all orchestrator-internal
382
+ // dispatches (bridge/maintenance/mcp/scheduler/webhook) targeting the
383
+ // same provider land in a single server-side cache shard, so the
384
+ // shared prefix (tools + system + pool system prompt) is reused
385
+ // regardless of role. Per-role / per-session differentiation lives in
386
+ // the message tail, which is naturally separated by content hashing.
387
+ const PROVIDER_ALIAS = {
388
+ 'openai-oauth': 'codex', // ChatGPT subscription (Codex backend)
389
+ 'anthropic-oauth': 'claude', // Claude Max subscription
390
+ 'openai': 'openai',
391
+ 'anthropic': 'anthropic',
392
+ 'gemini': 'gemini',
393
+ 'deepseek': 'deepseek',
394
+ 'xai': 'xai',
395
+ };
396
+ function providerCacheKey(provider, override) {
397
+ if (override) return String(override);
398
+ if (!provider) return 'mixdog-default';
399
+ return `mixdog-${PROVIDER_ALIAS[provider] || provider}`;
400
+ }
401
+
402
+ // ── Prefetch permission guard ─────────────────────────────────────────────────
403
+ // Mirrors _checkWorkerPermission in loop.mjs for tool calls that originate
404
+ // in the prefetch path (outside the agent loop). Returns an error string if
405
+ // blocked, or null if allowed.
406
+ const _permEvalForPrefetch = (() => {
407
+ const _req = createRequire(import.meta.url);
408
+ try {
409
+ const { dirname: _pdir, resolve: _pres } = _req('path');
410
+ const _hooksLib = _pres(_pdir(fileURLToPath(import.meta.url)), '../../../../hooks/lib/permission-evaluator.cjs');
411
+ return _req(_hooksLib).evaluatePermission;
412
+ } catch { return null; }
413
+ })();
414
+ function _guardedPrefetchTool(toolName, toolArgs, session) {
415
+ if (!_permEvalForPrefetch) return null;
416
+ // Same baseline as _checkWorkerPermission: when no explicit mode is
417
+ // attached to the session, run the evaluator under 'default' so the
418
+ // bypass-proof hard-deny patterns still apply during prefetch dispatch.
419
+ const permissionMode = session?.permissionMode || 'default';
420
+ const projectDir = session?.cwd || undefined;
421
+ const userCwd = session?.cwd || undefined;
422
+ const MCP_PFX = 'mcp__plugin_mixdog_mixdog__';
423
+ const fullName = toolName.startsWith(MCP_PFX) || toolName.startsWith('mcp__') ? toolName : `${MCP_PFX}${toolName}`;
424
+ try {
425
+ const { decision, reason } = _permEvalForPrefetch({ toolName: fullName, toolInput: toolArgs || {}, permissionMode, projectDir, userCwd });
426
+ if (decision === 'deny' || decision === 'ask') {
427
+ return `Error: prefetch tool "${toolName}" blocked (decision=${decision}): ${reason}`;
428
+ }
429
+ } catch (e) {
430
+ process.stderr.write(`[prefetch-guard] evaluator error: ${e?.message}\n`);
431
+ }
432
+ return null;
433
+ }
434
+
435
+ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
436
+ if (!explicitPrefetch || typeof explicitPrefetch !== 'object') return null;
437
+ if (session?.owner !== 'bridge') return null;
438
+ const parts = [];
439
+ const failed = [];
440
+ const totalEntries = [];
441
+ // files[] — string entries use the default head excerpt; object entries
442
+ // {path, n?, full?} let the caller widen the window or pull the full file
443
+ // so worker doesn't have to re-read deep ranges of an already-prefetched
444
+ // file (a recurring iter burner observed in baseline session telemetry).
445
+ const _rawFilesIn = Array.isArray(explicitPrefetch.files) ? explicitPrefetch.files : [];
446
+ const _readOptsByFile = new Map();
447
+ const files = [];
448
+ const _seenFiles = new Set();
449
+ const _addPrefetchFile = (file, opts = null) => {
450
+ if (typeof file !== 'string' || !file) return;
451
+ if (!_seenFiles.has(file)) {
452
+ _seenFiles.add(file);
453
+ files.push(file);
454
+ }
455
+ if (!opts || Object.keys(opts).length === 0) return;
456
+ const prev = _readOptsByFile.get(file) || {};
457
+ const merged = { ...prev };
458
+ if (opts.mode === 'full') {
459
+ merged.mode = 'full';
460
+ delete merged.n;
461
+ } else if (merged.mode !== 'full' && Number.isFinite(opts.n) && opts.n > 0) {
462
+ merged.n = Math.max(Number(merged.n) || 0, opts.n);
463
+ }
464
+ if (Object.keys(merged).length > 0) _readOptsByFile.set(file, merged);
465
+ };
466
+ for (const entry of _rawFilesIn) {
467
+ if (typeof entry === 'string' && entry) {
468
+ _addPrefetchFile(entry);
469
+ } else if (entry && typeof entry === 'object' && typeof entry.path === 'string' && entry.path) {
470
+ const opts = {};
471
+ if (entry.full === true) opts.mode = 'full';
472
+ else if (Number.isFinite(entry.n) && entry.n > 0) opts.n = entry.n;
473
+ _addPrefetchFile(entry.path, opts);
474
+ }
475
+ }
476
+ if (files.length > 0) {
477
+ const _pfGuard = _guardedPrefetchTool('read', { path: files }, session);
478
+ if (_pfGuard) {
479
+ process.stderr.write(`[bridge-prefetch] files read blocked: ${_pfGuard}\n`);
480
+ failed.push(...files);
481
+ totalEntries.push(...files);
482
+ } else {
483
+ totalEntries.push(...files);
484
+ // R20: per-file prefetch cache (cross-dispatch, process-local).
485
+ // Try each file from cache first; batch misses into one disk read.
486
+ const { resolve: _pfResolve, isAbsolute: _pfIsAbs, normalize: _pfNorm } = await import('path');
487
+ const _pfCwd = session.cwd || null;
488
+ function _pfAbsPath(f) {
489
+ const abs = _pfIsAbs(f) ? f : _pfResolve(_pfCwd || process.cwd(), f);
490
+ return _pfNorm(abs);
491
+ }
492
+ const fileHits = []; // { file, abs, content } — satisfied from cache
493
+ const fileMisses = []; // { file, abs } — need disk read
494
+ for (const f of files) {
495
+ const abs = _pfAbsPath(f);
496
+ // Skip the cross-dispatch cache when the caller asked for a
497
+ // non-default window (custom n or full-file). Cache key is the
498
+ // path alone, so a default-window cache hit would silently feed
499
+ // the wrong slice back to the next caller.
500
+ const hit = _readOptsByFile.has(f) ? null : tryPrefetchCached(abs);
501
+ if (hit) {
502
+ fileHits.push({ file: f, abs, content: hit.content });
503
+ } else {
504
+ fileMisses.push({ file: f, abs });
505
+ }
506
+ }
507
+ // Disk read for misses (single batch call).
508
+ const missFiles = fileMisses.map(m => m.file);
509
+ const missResults = {}; // file → content string
510
+ if (missFiles.length > 0) {
511
+ // Read each miss file individually so we can cache per-file.
512
+ // The files list is small (typically 2-5), so N awaits is fine.
513
+ await Promise.all(missFiles.map(async (f) => {
514
+ const opts = _readOptsByFile.get(f) || {};
515
+ const readArgs = { path: f };
516
+ if (opts.mode === 'full') {
517
+ readArgs.mode = 'full';
518
+ } else {
519
+ readArgs.mode = 'head';
520
+ readArgs.n = Number.isFinite(opts.n) ? opts.n : 120;
521
+ }
522
+ const out = await executeInternalTool('read', readArgs).catch((e) => {
523
+ process.stderr.write(`[bridge-prefetch] file read failed (${f}): ${e && e.message || e}\n`);
524
+ return null;
525
+ });
526
+ if (out !== null) {
527
+ missResults[f] = String(out);
528
+ }
529
+ }));
530
+ // Cache successful miss results.
531
+ for (const { file, abs } of fileMisses) {
532
+ const content = missResults[file];
533
+ if (content && classifyResultKind(content) !== 'error') {
534
+ // Only cache default-window reads; custom-window results
535
+ // would poison the shared cross-dispatch cache.
536
+ if (!_readOptsByFile.has(file)) setPrefetchCached(abs, content);
537
+ } else if (content === undefined || classifyResultKind(content) === 'error') {
538
+ failed.push(file);
539
+ }
540
+ }
541
+ }
542
+ // Assemble combined output preserving original file order.
543
+ const readParts = [];
544
+ const hitByFile = new Map(fileHits.map((h) => [h.file, h]));
545
+ for (const f of files) {
546
+ const hitEntry = hitByFile.get(f);
547
+ if (hitEntry) {
548
+ readParts.push(hitEntry.content);
549
+ continue;
550
+ }
551
+ const content = missResults[f];
552
+ if (content && classifyResultKind(content) !== 'error') {
553
+ readParts.push(content);
554
+ }
555
+ // else: already pushed to failed above
556
+ }
557
+ if (readParts.length > 0) {
558
+ parts.push(`### prefetch files\nread ${readParts.length}\n\n${readParts.join('\n\n')}`);
559
+ }
560
+ // Log hit/miss counters so dispatch telemetry shows prefetch effectiveness.
561
+ process.stderr.write(
562
+ `[prefetch] files=${files.length} cached=${fileHits.length} miss=${fileMisses.length} failed=${failed.length}\n`
563
+ );
564
+ // Attach stats to session so post-hoc analyzers (inspect-session.mjs)
565
+ // can see prefetch effectiveness without parsing stderr logs.
566
+ if (session && typeof session === 'object') {
567
+ if (!session.prefetchStats) session.prefetchStats = { files: 0, cached: 0, miss: 0, failed: 0 };
568
+ session.prefetchStats.files += files.length;
569
+ session.prefetchStats.cached += fileHits.length;
570
+ session.prefetchStats.miss += fileMisses.length;
571
+ session.prefetchStats.failed += failed.length;
572
+ }
573
+ }
574
+ }
575
+ // callers[]
576
+ const callers = Array.isArray(explicitPrefetch.callers) ? explicitPrefetch.callers.filter(c => c && typeof c.symbol === 'string') : [];
577
+ {
578
+ const callerTasks = callers.map(({ symbol, file }) => {
579
+ const cgArgs = { mode: 'callers', symbol };
580
+ if (file) cgArgs.file = file;
581
+ if (session?.cwd) cgArgs.cwd = session.cwd;
582
+ totalEntries.push(symbol);
583
+ const blocked = _guardedPrefetchTool('code_graph', cgArgs, session);
584
+ if (blocked) {
585
+ process.stderr.write(`[bridge-prefetch] callers(${symbol}) blocked: ${blocked}\n`);
586
+ return Promise.resolve({ symbol, out: null, blocked: true });
587
+ }
588
+ return executeCodeGraphTool('code_graph', cgArgs, session?.cwd)
589
+ .then(out => ({ symbol, out }))
590
+ .catch(e => {
591
+ process.stderr.write(`[bridge-prefetch] callers(${symbol}) failed: ${e && e.message || e}\n`);
592
+ return { symbol, out: null };
593
+ });
594
+ });
595
+ const callerResults = await Promise.allSettled(callerTasks);
596
+ for (const r of callerResults) {
597
+ const { symbol, out, blocked } = r.status === 'fulfilled' ? r.value : { symbol: '?', out: null };
598
+ if (blocked) { failed.push(symbol); continue; }
599
+ if (out && classifyResultKind(String(out)) !== 'error') {
600
+ parts.push(`### prefetch callers ${symbol}\n${out}`);
601
+ } else {
602
+ failed.push(symbol);
603
+ }
604
+ }
605
+ }
606
+ // references[]
607
+ const references = Array.isArray(explicitPrefetch.references) ? explicitPrefetch.references.filter(r => r && typeof r.symbol === 'string') : [];
608
+ {
609
+ const refTasks = references.map(({ symbol, file }) => {
610
+ const cgArgs = { mode: 'references', symbol };
611
+ if (file) cgArgs.file = file;
612
+ if (session?.cwd) cgArgs.cwd = session.cwd;
613
+ totalEntries.push(symbol);
614
+ const blocked = _guardedPrefetchTool('code_graph', cgArgs, session);
615
+ if (blocked) {
616
+ process.stderr.write(`[bridge-prefetch] references(${symbol}) blocked: ${blocked}\n`);
617
+ return Promise.resolve({ symbol, out: null, blocked: true });
618
+ }
619
+ return executeCodeGraphTool('code_graph', cgArgs, session?.cwd)
620
+ .then(out => ({ symbol, out }))
621
+ .catch(e => {
622
+ process.stderr.write(`[bridge-prefetch] references(${symbol}) failed: ${e && e.message || e}\n`);
623
+ return { symbol, out: null };
624
+ });
625
+ });
626
+ const refResults = await Promise.allSettled(refTasks);
627
+ for (const r of refResults) {
628
+ const { symbol, out, blocked } = r.status === 'fulfilled' ? r.value : { symbol: '?', out: null };
629
+ if (blocked) { failed.push(symbol); continue; }
630
+ if (out && classifyResultKind(String(out)) !== 'error') {
631
+ parts.push(`### prefetch references ${symbol}\n${out}`);
632
+ } else {
633
+ failed.push(symbol);
634
+ }
635
+ }
636
+ }
637
+ if (session && typeof session === 'object' && (callers.length > 0 || references.length > 0)) {
638
+ if (!session.prefetchStats) session.prefetchStats = { files: 0, cached: 0, miss: 0, failed: 0, callers: 0, references: 0 };
639
+ session.prefetchStats.callers = (session.prefetchStats.callers || 0) + callers.length;
640
+ session.prefetchStats.references = (session.prefetchStats.references || 0) + references.length;
641
+ }
642
+ if (parts.length === 0) {
643
+ // All entries failed but Lead presence must still be signalled — emit
644
+ // warn-only so the gate logic can distinguish "prefetch was requested"
645
+ // from "no prefetch at all".
646
+ if (totalEntries.length > 0 && failed.length > 0) {
647
+ return `<prefetch-warn>${failed.length} of ${totalEntries.length} prefetch entries failed: ${[...new Set(failed)].join(', ')}</prefetch-warn>`;
648
+ }
649
+ return null;
650
+ }
651
+ const warnLine = failed.length > 0
652
+ ? `<prefetch-warn>${failed.length} of ${totalEntries.length} prefetch entries failed: ${[...new Set(failed)].join(', ')}</prefetch-warn>\n`
653
+ : '';
654
+ return `${warnLine}<prefetch>\n${parts.join('\n\n')}\n</prefetch>`;
655
+ }
656
+
657
+ // --- bridge spawn (createSession) ---
658
+ // opts can pass either a `preset` object (from config.presets) or raw provider/model.
659
+ // Preset shape: { name, provider, model, effort?, fast?, tools? }
660
+ //
661
+ // Smart Bridge integration:
662
+ // opts.taskType / opts.role / opts.profileId — enables profile-aware routing.
663
+ // Rule-based SmartRouter resolves these synchronously; the resolved
664
+ // profile controls context filtering (skip.skills/memory/etc) and cache
665
+ // strategy. If no rule matches, falls back to classic preset behavior.
666
+ // opts.profile — pre-resolved profile (bypasses router; used by async
667
+ // callers who already ran SmartBridge.resolve()).
668
+ // opts.providerCacheOpts — pre-resolved cache options merged into ask() sendOpts.
669
+ export function createSession(opts) {
670
+ const presetObj = opts.preset && typeof opts.preset === 'object' ? opts.preset : null;
671
+
672
+ // --- Smart Bridge profile resolution (best-effort, sync) ---
673
+ let profile = opts.profile || null;
674
+ let providerCacheOpts = opts.providerCacheOpts || null;
675
+ if (!profile && (opts.taskType || opts.role || opts.profileId)) {
676
+ const smartBridge = getSmartBridgeSync();
677
+ if (smartBridge) {
678
+ try {
679
+ const resolved = smartBridge.resolveSync({
680
+ taskType: opts.taskType,
681
+ role: opts.role,
682
+ profileId: opts.profileId,
683
+ preset: presetObj?.name || (typeof opts.preset === 'string' ? opts.preset : null),
684
+ provider: opts.provider || presetObj?.provider,
685
+ });
686
+ if (resolved) {
687
+ profile = resolved.profile;
688
+ providerCacheOpts = resolved.providerCacheOpts;
689
+ }
690
+ } catch (e) {
691
+ // Smart Bridge error — log once, fall back to classic behavior.
692
+ if (!_smartBridgeWarned) {
693
+ _smartBridgeWarned = true;
694
+ process.stderr.write(`[session] smart bridge resolve failed: ${e.message}\n`);
695
+ }
696
+ }
697
+ }
698
+ }
699
+
700
+ const providerName = opts.provider || presetObj?.provider
701
+ || (profile?.preferredProviders?.[0]);
702
+ const modelName = opts.model || presetObj?.model;
703
+ // opts.tools (caller-supplied) wins over presetObj.tools — caller
704
+ // intent ('tools:readonly' from Pool C, etc.) must override the
705
+ // preset's default 'full'. Previous priority let HAIKU's tools='full'
706
+ // shadow Pool C's explicit readonly request, leaking write tools and
707
+ // bash into a read-only agent.
708
+ const toolPreset = opts.tools || presetObj?.tools || (typeof opts.preset === 'string' ? opts.preset : null) || 'full';
709
+ const effort = presetObj?.effort || opts.effort || null;
710
+ const fast = presetObj?.fast === true || opts.fast === true;
711
+ if (!providerName)
712
+ throw new Error('createSession: provider is required');
713
+ if (!modelName)
714
+ throw new Error('createSession: model is required');
715
+ const provider = getProvider(providerName);
716
+ if (!provider)
717
+ throw new Error(`Provider "${providerName}" not found or not enabled`);
718
+ const id = `sess_${process.pid}_${nextId++}_${Date.now()}_${randomBytes(16).toString('hex')}`;
719
+ const messages = [];
720
+ const agentTemplate = opts.agent ? loadAgentTemplate(opts.agent, opts.cwd) : null;
721
+ const skills = collectSkillsCached(opts.cwd);
722
+
723
+ // Bridge shared prefix (bit-identical across roles). Hidden roles reuse the
724
+ // same shared bridge rules so the cache shard stays stable across bridge
725
+ // callers. User-defined data (DATA_DIR roles/schedules/webhooks) is baked
726
+ // into BP1 as a single fixed-value monolithic block so every role shares
727
+ // one cache shard. A user edit invalidates BP1 once and the new prefix
728
+ // re-warms across all roles together.
729
+ const bridgeRulesRole = opts.role || profile?.taskType || null;
730
+ const bridgeRules = opts.skipBridgeRules ? '' : _buildBridgeRules();
731
+ const roleSpecific = opts.skipBridgeRules ? '' : _buildRoleSpecific(bridgeRulesRole);
732
+ // Project MD (cwd-based, Tier 3 slot).
733
+ const projectContext = collectProjectMd(opts.cwd);
734
+
735
+ // Role template (Phase B §4 — UI-managed). Reads <DATA_DIR>/roles/<role>.md
736
+ // and parses frontmatter (description, permission). The template is
737
+ // injected into the Tier 3 system-reminder so role differences never
738
+ // touch the BP_2 cache prefix.
739
+ const resolvedRole = opts.role || profile?.taskType || null;
740
+ const dataDir = process.env.CLAUDE_PLUGIN_DATA;
741
+ const roleTemplate = resolvedRole && dataDir
742
+ ? loadRoleTemplate(resolvedRole, dataDir)
743
+ : null;
744
+
745
+ // Bridge sessions must not inherit role/profile/preset tool narrowing: Pool
746
+ // B and Pool C share one bit-identical tool schema for BP_1/BP_2 cache
747
+ // reuse, and permission differences are enforced only at call time. Raw
748
+ // non-bridge callers keep the historical profile.tools / preset.tools
749
+ // behaviour.
750
+ const toolSpec = opts.owner === 'bridge'
751
+ ? 'full'
752
+ : (Array.isArray(profile?.tools) ? profile.tools : toolPreset);
753
+
754
+ // Prompt permission is metadata only. Preset tool restrictions must NOT
755
+ // enter the prompt, or they split the shared bridge cache tail; they map
756
+ // to toolPermission below and are enforced only at call time.
757
+ const permission = opts.permission
758
+ || roleTemplate?.permission
759
+ || null;
760
+ const toolPermission = opts.permission
761
+ || profile?.permission
762
+ || roleTemplate?.permission
763
+ || permissionFromToolSpec(toolPreset)
764
+ || null;
765
+ let toolsForRouting = resolveSessionTools(toolSpec, skills, { ownerIsBridge: opts.owner === 'bridge' });
766
+ // Fail-closed permission intersection: when a role declares an explicit
767
+ // permission (from user-workflow.json or the role template), intersect the
768
+ // resolved tool list with the permission's allow/deny lists. If the
769
+ // intersection produces an empty set the permission config is broken —
770
+ // fail closed (zero tools) rather than silently falling back to the full
771
+ // preset, which would grant the role more surface than declared.
772
+ if (toolPermission && typeof toolPermission === 'object') {
773
+ const allowSet = Array.isArray(toolPermission.allow) && toolPermission.allow.length > 0
774
+ ? new Set(toolPermission.allow.map(n => String(n).toLowerCase()))
775
+ : null;
776
+ const denySet = Array.isArray(toolPermission.deny) && toolPermission.deny.length > 0
777
+ ? new Set(toolPermission.deny.map(n => String(n).toLowerCase()))
778
+ : null;
779
+ if (allowSet || denySet) {
780
+ const filtered = toolsForRouting.filter(t => {
781
+ const name = String(t?.name || '').toLowerCase();
782
+ if (denySet && denySet.has(name)) return false;
783
+ if (allowSet && !allowSet.has(name)) return false;
784
+ return true;
785
+ });
786
+ // Fail-closed: an empty intersection means the permission config is
787
+ // misconfigured — do not silently fall back to the full preset.
788
+ toolsForRouting = filtered;
789
+ if (filtered.length === 0) {
790
+ process.stderr.write(`[session] WARN: role permission intersection produced 0 tools — failing closed (role=${opts.role || 'unknown'})
791
+ `);
792
+ }
793
+ }
794
+ }
795
+
796
+ const { baseRules, roleCatalog, sessionMarker, volatileTail } = composeSystemPrompt({
797
+ userPrompt: opts.systemPrompt,
798
+ bridgeRules: bridgeRules || undefined,
799
+ roleSpecific: roleSpecific || undefined,
800
+ agentTemplate: agentTemplate || undefined,
801
+ roleTemplate: roleTemplate || undefined,
802
+ hasSkills: skills.length > 0,
803
+ profile: profile || undefined,
804
+ role: resolvedRole,
805
+ skipRoleReminder: opts.skipRoleReminder || false,
806
+ permission,
807
+ taskBrief: opts.taskBrief || null,
808
+ projectContext: projectContext || null,
809
+ tools: toolsForRouting,
810
+ bashIsPersistent: opts.owner === 'bridge' && toolsForRouting.some(t => t?.name === 'bash'),
811
+ // Effective cwd rides in tier3Reminder so explore-like tools know
812
+ // their search root without needing to shove "Override cwd:" into
813
+ // the user message body (that used to fragment the shard prefix).
814
+ cwd: opts.cwd || null,
815
+ // BP2 catalog policy — explicit-cache providers see the unified
816
+ // all-roles catalog; implicit-prefix-hash providers keep self-only.
817
+ provider: providerName || null,
818
+ });
819
+ // 4-BP layout (see composeSystemPrompt docs):
820
+ // system block #1 = baseRules — BP1 (1h) shared across ALL roles
821
+ // system block #2 = roleCatalog — BP2 (1h) scoped role catalog + project
822
+ // first <system-reminder> user = sessionMarker — BP3 (1h) role-specific task body
823
+ // second <system-reminder> user = volatileTail — rides near BP4 (5m)
824
+ // Anthropic multi-block system pins each block with cache_control.
825
+ // OpenAI gets a stable provider cache key/session prefix. Gemini relies
826
+ // on implicit prompt caching only, so hits are observed, not treated as a
827
+ // guaranteed warm shard.
828
+ if (baseRules) {
829
+ messages.push({ role: 'system', content: baseRules });
830
+ }
831
+ if (roleCatalog) {
832
+ messages.push({ role: 'system', content: roleCatalog });
833
+ }
834
+ if (sessionMarker) {
835
+ messages.push({ role: 'user', content: `<system-reminder>\n${sessionMarker}\n</system-reminder>` });
836
+ messages.push({ role: 'assistant', content: 'Session context noted.' });
837
+ }
838
+ if (volatileTail) {
839
+ messages.push({ role: 'user', content: `<system-reminder>\n${volatileTail}\n</system-reminder>` });
840
+ messages.push({ role: 'assistant', content: 'Understood.' });
841
+ }
842
+ if (opts.files?.length) {
843
+ const fileContext = opts.files
844
+ .map(f => `### ${f.path}\n\`\`\`\n${f.content}\n\`\`\``)
845
+ .join('\n\n');
846
+ messages.push({ role: 'user', content: `Reference files:\n\n${fileContext}` });
847
+ messages.push({ role: 'assistant', content: 'Understood. I have the files in context.' });
848
+ }
849
+ let tools = toolsForRouting;
850
+
851
+ // Schema filtering applied after schema build:
852
+ // - opts.schemaAllowedTools : declarative hidden-role schema profile
853
+ // allowlist for tiny specialist roles where one-shot tool routing
854
+ // beats the shared-schema cache win.
855
+ // - opts.disallowedTools : per-call caller override (Anthropic
856
+ // BuiltInAgentDefinition pattern)
857
+ // - annotations.bridgeHidden : declarative per-tool flag (tools.json
858
+ // and internal tool defs). Pool A (Lead) still sees all tools.
859
+ //
860
+ const hasCallerAllow = Array.isArray(opts.schemaAllowedTools);
861
+ const callerAllow = hasCallerAllow ? opts.schemaAllowedTools.map(n => String(n).toLowerCase()) : [];
862
+ if (hasCallerAllow) {
863
+ const allowSet = new Set(callerAllow);
864
+ const before = tools.length;
865
+ tools = tools.filter(t => allowSet.has(String(t?.name || '').toLowerCase()));
866
+ if (tools.length !== before) {
867
+ process.stderr.write(`[session] schemaAllowedTools=${callerAllow.join(',')} kept ${tools.length}/${before} tools\n`);
868
+ }
869
+ }
870
+ const callerDeny = Array.isArray(opts.disallowedTools) ? opts.disallowedTools.map(n => String(n)) : [];
871
+ if (callerDeny.length) {
872
+ const denySet = new Set(callerDeny);
873
+ const before = tools.length;
874
+ tools = tools.filter(t => !denySet.has(String(t?.name || '').toLowerCase()));
875
+ if (tools.length !== before) {
876
+ process.stderr.write(`[session] disallowedTools=${callerDeny.join(',')} stripped ${before - tools.length} tools\n`);
877
+ }
878
+ }
879
+ if (opts.owner === 'bridge') {
880
+ const before = tools.length;
881
+ tools = tools.filter(t => !t?.annotations?.bridgeHidden);
882
+ if (tools.length !== before) {
883
+ process.stderr.write(`[session] bridgeHidden stripped ${before - tools.length} tools\n`);
884
+ }
885
+ }
886
+
887
+ // Bridge tool canonicalization: keep route-sensitive tools in policy order
888
+ // while preserving deterministic MCP/skill order for BP1 shard stability.
889
+ if (opts.owner === 'bridge') {
890
+ tools = orderSessionTools(tools);
891
+ }
892
+
893
+ // Unified-shard policy — no broad role-specific schema filter. Keep
894
+ // bridge schemas shared unless a hidden-role schema profile explicitly
895
+ // passes schemaAllowedTools for a small specialist; broad role
896
+ // whitelists would fragment the cache shard.
897
+ if (resolvedRole) {
898
+ process.stderr.write(`[session] role=${resolvedRole} permission=${permission || 'full'} toolPermission=${toolPermission || 'full'} tools=${tools.length}\n`);
899
+ }
900
+ const session = {
901
+ id,
902
+ provider: providerName,
903
+ model: modelName,
904
+ messages,
905
+ contextWindow: guessContextWindow(modelName),
906
+ tools,
907
+ preset: toolPreset,
908
+ presetName: presetObj?.name || null,
909
+ effort,
910
+ fast,
911
+ agent: opts.agent,
912
+ owner: opts.owner || 'user',
913
+ mcpPid: process.pid,
914
+ scopeKey: opts.scopeKey || null,
915
+ lane: opts.lane || 'bridge',
916
+ cwd: opts.cwd,
917
+ createdAt: Date.now(),
918
+ updatedAt: Date.now(),
919
+ lastHeartbeatAt: null,
920
+ totalInputTokens: 0,
921
+ totalOutputTokens: 0,
922
+ // Refreshed on each completed ask() — surfaced by bridge type=list for
923
+ // debugging + consumed by store.mjs's idle-sweep to reclaim stalled
924
+ // bridge sessions past RUNNING_STALL_MS.
925
+ lastUsedAt: Date.now(),
926
+ tokensCumulative: 0,
927
+ role: opts.role || null,
928
+ taskType: opts.taskType || null,
929
+ maxLoopIterations: Number.isFinite(opts.maxLoopIterations) ? opts.maxLoopIterations : null,
930
+ // Bridge tag (auto worker{n} on spawn) persisted so the forked status
931
+ // process (statusline) + aggregator can read it from the session JSON.
932
+ // In-process send/close still resolve via _tagSessionRegistry.
933
+ bridgeTag: opts.bridgeTag || null,
934
+ // Prompt permission is separate from runtime toolPermission so preset
935
+ // restrictions do not fragment the bridge cache prefix.
936
+ permission: permission || null,
937
+ toolPermission: toolPermission || null,
938
+ // Origin tag written into every bridge-trace usage row so analytics
939
+ // can slice by (sourceType, sourceName) — e.g. maintenance/cycle1,
940
+ // scheduler/daily-standup, webhook/github-push, lead/worker.
941
+ sourceType: opts.sourceType || null,
942
+ sourceName: opts.sourceName || null,
943
+ // Provider-scoped unified cache key — one shard per provider,
944
+ // shared across all roles / sources (bridge/maintenance/mcp/
945
+ // scheduler/webhook). Role or source-specific context must be
946
+ // injected into the message tail, not the shared prefix.
947
+ promptCacheKey: providerCacheKey(presetObj?.provider || opts.provider, opts.cacheKeyOverride),
948
+ // Bridge shell continuity: when a bridge session explicitly opts into
949
+ // persistent shell state (`bash` with `persistent:true`, or direct
950
+ // `bash_session`), the minted bash_session id is stored here so later
951
+ // opted-in `bash` calls can reuse the same shell state.
952
+ implicitBashSessionId: null,
953
+ // Tracks every persistent bash session id minted during this
954
+ // orchestrator session so closeSession can kill them all, not just
955
+ // the most recently recorded one.
956
+ allBashSessionIds: [],
957
+ // Smart Bridge metadata — optional. Applied on every ask() to merge
958
+ // profile-driven cache settings into provider sendOpts.
959
+ profileId: profile?.id || null,
960
+ permissionMode: opts.permissionMode ?? null,
961
+ providerCacheOpts: providerCacheOpts || null,
962
+ ownerSessionId: opts.ownerSessionId || null,
963
+ clientHostPid: opts.clientHostPid || null,
964
+ };
965
+ // In-process registry + async debounced save: same-process create → load
966
+ // reads live memory; disk flush is for cross-process / restart durability.
967
+ setLiveSession(session);
968
+ saveSession(session);
969
+ return session;
970
+ }
971
+
972
+ // ── Runtime liveness map ──────────────────────────────────────────────
973
+ // In-memory only. Tracks per-session stage + stream heartbeat so bridge type=list
974
+ // can surface whether a session is actually alive vs stuck. Never persisted —
975
+ // heartbeats would otherwise churn the session JSON on every SSE delta.
976
+ // Entry shape: {
977
+ // stage, lastStreamDeltaAt, lastToolCall, lastError, updatedAt,
978
+ // controller?: AbortController, // set while an ask is in flight
979
+ // generation?: number, // snapshot taken at ask start
980
+ // closed?: boolean, // flipped by closeSession()
981
+ // }
982
+ const _runtimeState = new Map();
983
+ const VALID_STAGES = new Set([
984
+ 'connecting', 'requesting', 'streaming', 'tool_running', 'idle', 'error', 'done', 'cancelling',
985
+ ]);
986
+ function _touchRuntime(id) {
987
+ let entry = _runtimeState.get(id);
988
+ if (!entry) {
989
+ entry = { stage: 'idle', lastStreamDeltaAt: null, lastToolCall: null, lastError: null, updatedAt: Date.now() };
990
+ _runtimeState.set(id, entry);
991
+ }
992
+ return entry;
993
+ }
994
+ export function updateSessionStage(id, stage) {
995
+ if (!id || !VALID_STAGES.has(stage)) return;
996
+ const entry = _touchRuntime(id);
997
+ const now = Date.now();
998
+ entry.stage = stage;
999
+ entry.lastProgressAt = now;
1000
+ entry.updatedAt = now;
1001
+ }
1002
+ /**
1003
+ * Reset heartbeat-visible fields for a new ask. Preserves controller/generation/
1004
+ * closed (lifecycle) but clears the previous run's streaming state so stale
1005
+ * lastToolCall / lastStreamDeltaAt from the previous ask don't leak into the
1006
+ * new one.
1007
+ */
1008
+ export function markSessionAskStart(id) {
1009
+ if (!id) return;
1010
+ const entry = _touchRuntime(id);
1011
+ entry.stage = 'connecting';
1012
+ entry.lastStreamDeltaAt = null;
1013
+ entry.lastToolCall = null;
1014
+ entry.lastError = null;
1015
+ // A new ask starts a fresh turn lifecycle — clear any stale empty-final
1016
+ // classification from the prior turn so inspectBridgeEntry doesn't keep
1017
+ // short-circuiting to 'empty-synthesis' (which would disable stall
1018
+ // detection for the entire new turn).
1019
+ entry.emptyFinal = false;
1020
+ entry.emptyFinalAt = null;
1021
+ // askStartedAt is the watchdog's fallback reference when a session
1022
+ // hangs before any stream delta arrives. Without it, a provider that
1023
+ // never returns a first token would stall forever because the watchdog
1024
+ // keys solely on lastStreamDeltaAt.
1025
+ const now = Date.now();
1026
+ entry.askStartedAt = now;
1027
+ entry.lastProgressAt = now;
1028
+ entry.updatedAt = now;
1029
+ // Publish heartbeat immediately so the status aggregator picks the
1030
+ // session up in the connecting / requesting window. Without this the
1031
+ // .hb file only landed on the first stream chunk — producing a 3–10s
1032
+ // (xhigh: 30s+) invisible gap where bridge sessions ran but the CC
1033
+ // statusline showed no maintenance/agent badge. STREAM_FRESH_MS (5 min)
1034
+ // still drops a session whose provider truly never returns a chunk;
1035
+ // markSessionStreamDelta keeps refreshing once chunks arrive.
1036
+ publishHeartbeat(id, now);
1037
+ }
1038
+ export async function markSessionStreamDelta(id) {
1039
+ if (!id) return;
1040
+ // Non-creating lookup: a live ask ALWAYS has a runtime entry (markSessionAskStart
1041
+ // creates it before streaming begins). _touchRuntime would instead resurrect a
1042
+ // blank entry — and closeSession()/idle-sweep clear _runtimeState on a deferred
1043
+ // tick while a detached provider stream may still be trickling deltas. A delta
1044
+ // arriving after that clear must NOT re-create an entry or it would republish the
1045
+ // .hb heartbeat that markSessionClosed deleted, orphaning a dead session's
1046
+ // heartbeat indefinitely (the disk tombstone blocks ask resumption but not this
1047
+ // path). Skip a missing, tombstoned, or aborted entry — never refresh liveness.
1048
+ const entry = _runtimeState.get(id);
1049
+ if (!entry || entry.closed || entry.controller?.signal?.aborted) return;
1050
+ const now = Date.now();
1051
+ entry.lastStreamDeltaAt = now;
1052
+ entry.lastProgressAt = now;
1053
+ // Only promote to 'streaming' if we were in a pre-stream stage; never downgrade
1054
+ // mid-tool (tool_running has its own delta source if the tool streams back).
1055
+ if (entry.stage === 'connecting' || entry.stage === 'requesting') {
1056
+ entry.stage = 'streaming';
1057
+ }
1058
+ // Lightweight heartbeat (≤5s self-throttled) for the status aggregator.
1059
+ // Disk-side session.lastHeartbeatAt below is the heavy 60s zombie-reaper
1060
+ // signal; the .hb file is the fast fresh-session signal consumed by the
1061
+ // status line.
1062
+ publishHeartbeat(id, now);
1063
+ const session = loadSession(id);
1064
+ if (session && now - (session.lastHeartbeatAt || 0) > HEARTBEAT_THROTTLE_MS) {
1065
+ session.lastHeartbeatAt = now;
1066
+ await saveSessionAsync(session, { expectedGeneration: session.generation });
1067
+ }
1068
+ entry.updatedAt = now;
1069
+ }
1070
+ export function markSessionToolCall(id, toolName) {
1071
+ if (!id) return;
1072
+ const entry = _touchRuntime(id);
1073
+ entry.stage = 'tool_running';
1074
+ entry.lastToolCall = toolName || null;
1075
+ entry.toolStartedAt = Date.now();
1076
+ entry.lastProgressAt = entry.toolStartedAt;
1077
+ entry.updatedAt = entry.toolStartedAt;
1078
+ publishHeartbeat(id, entry.toolStartedAt);
1079
+ }
1080
+ export function markSessionDone(id, { empty = false } = {}) {
1081
+ if (!id) return;
1082
+ const entry = _touchRuntime(id);
1083
+ entry.stage = 'done';
1084
+ entry.lastError = null;
1085
+ entry.askStartedAt = null;
1086
+ entry.toolStartedAt = null;
1087
+ // Non-empty completion: drop any stale empty-final flag so a subsequent
1088
+ // ask on the same reusable runtime entry starts clean. Empty-final
1089
+ // completions preserve the flag (set by markSessionEmptyFinal just prior).
1090
+ if (!empty) {
1091
+ entry.emptyFinal = false;
1092
+ entry.emptyFinalAt = null;
1093
+ }
1094
+ const doneTs = Date.now();
1095
+ entry.doneAt = doneTs;
1096
+ entry.lastProgressAt = doneTs;
1097
+ entry.updatedAt = doneTs;
1098
+ // Terminal stage — drop the heartbeat so the status badge releases
1099
+ // immediately. A subsequent ask on the same session re-publishes via
1100
+ // markSessionStreamDelta on the first chunk.
1101
+ deleteHeartbeat(id);
1102
+ }
1103
+ // Tag a session as having completed with empty final synthesis (no
1104
+ // content/reasoning). Distinct from `markSessionDone`: still a success
1105
+ // (no abort), but the stall watchdog and post-mortem tools can
1106
+ // distinguish "finished empty" from "finished with content" without
1107
+ // mistaking the silence for a stall.
1108
+ export function markSessionEmptyFinal(id) {
1109
+ if (!id) return;
1110
+ const entry = _touchRuntime(id);
1111
+ entry.emptyFinal = true;
1112
+ entry.emptyFinalAt = Date.now();
1113
+ }
1114
+ export function markSessionError(id, msg) {
1115
+ if (!id) return;
1116
+ const entry = _touchRuntime(id);
1117
+ entry.stage = 'error';
1118
+ entry.lastError = msg ? String(msg).slice(0, 200) : null;
1119
+ entry.askStartedAt = null;
1120
+ entry.toolStartedAt = null;
1121
+ // Error path is a non-empty completion (we have an error message, not a
1122
+ // silent empty final). Clear the flag so the next ask starts clean.
1123
+ entry.emptyFinal = false;
1124
+ entry.emptyFinalAt = null;
1125
+ const errTs = Date.now();
1126
+ entry.doneAt = errTs;
1127
+ entry.lastProgressAt = errTs;
1128
+ entry.updatedAt = errTs;
1129
+ deleteHeartbeat(id);
1130
+ }
1131
+ export function getSessionRuntime(id) {
1132
+ return id ? (_runtimeState.get(id) || null) : null;
1133
+ }
1134
+ /**
1135
+ * Iterate all active session runtimes. Used by the stream watchdog.
1136
+ * Returns an iterable of [sessionId, entry] pairs; consumers should
1137
+ * treat entries as read-only snapshots and avoid mutating them.
1138
+ */
1139
+ export function forEachSessionRuntime() {
1140
+ return _runtimeState.entries();
1141
+ }
1142
+
1143
+ // --- Incremental metric persistence (fix A) ---
1144
+ // Per-session idempotency tracking: sessionId → Set of seen iterationIndex keys.
1145
+ const _metricSeenIter = new Map();
1146
+
1147
+ /**
1148
+ * Persist incremental usage delta immediately after each provider.send iteration.
1149
+ * Idempotency key `sessionId:iterationIndex` ensures a retry of the same iteration
1150
+ * index overwrites instead of double-counting.
1151
+ */
1152
+ export async function persistIterationMetrics(delta) {
1153
+ if (!delta || !delta.sessionId) return;
1154
+ const { sessionId, iterationIndex, deltaInput, deltaOutput, deltaCachedRead, deltaCacheWrite, ts } = delta;
1155
+ let seen = _metricSeenIter.get(sessionId);
1156
+ if (!seen) {
1157
+ seen = new Set();
1158
+ _metricSeenIter.set(sessionId, seen);
1159
+ }
1160
+ const ikey = `${sessionId}:${iterationIndex}`;
1161
+ const isReplay = seen.has(ikey);
1162
+ seen.add(ikey);
1163
+ const runtimeEntry = _runtimeState.get(sessionId);
1164
+ const session = runtimeEntry?.session ?? loadSession(sessionId);
1165
+ if (!session || session.closed) return;
1166
+ if (!isReplay) {
1167
+ session.totalInputTokens = (session.totalInputTokens || 0) + (deltaInput || 0);
1168
+ session.totalOutputTokens = (session.totalOutputTokens || 0) + (deltaOutput || 0);
1169
+ session.tokensCumulative = (session.tokensCumulative || 0) + (deltaInput || 0) + (deltaOutput || 0);
1170
+ // Cache totals — additive fields, default 0 on legacy sessions; both
1171
+ // are undefined-safe so the schema migrates lazily as new iterations
1172
+ // land. Keeps live + terminal aggregates in lock-step (loop.mjs already
1173
+ // includes cached_read / cache_write in its terminal usage rollup).
1174
+ session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + (deltaCachedRead || 0);
1175
+ session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + (deltaCacheWrite || 0);
1176
+ // Window snapshot updated per iteration so bridge type=list reflects the
1177
+ // most-recent provider-reported input size even for short dispatches
1178
+ // that finish before askSession's terminal save lands.
1179
+ session.lastInputTokens = deltaInput || 0;
1180
+ session.lastOutputTokens = deltaOutput || 0;
1181
+ session.lastCachedReadTokens = deltaCachedRead || 0;
1182
+ // Normalized last-call context footprint: how many prompt tokens the
1183
+ // model actually saw on the most-recent send, comparable ACROSS
1184
+ // providers. Anthropic reports input_tokens EXCLUDING cache (cache_read
1185
+ // is a separate field), so the cached portion must be added back to
1186
+ // reflect real context size; openai/grok/gemini already fold cached
1187
+ // tokens INTO the input count, so input alone is the footprint.
1188
+ const _inputExcludesCache = providerInputExcludesCache(session.provider);
1189
+ session.lastContextTokens = _inputExcludesCache
1190
+ ? (deltaInput || 0) + (deltaCachedRead || 0)
1191
+ : (deltaInput || 0);
1192
+ }
1193
+ session.lastIterationIndex = iterationIndex;
1194
+ session.updatedAt = ts || Date.now();
1195
+ await saveSessionAsync(session, { expectedGeneration: session.generation });
1196
+ }
1197
+
1198
+ /** Force-flush session metrics to disk. Used by watchdog terminal-reap (fix B). */
1199
+ export async function flushSessionMetrics(sessionId) {
1200
+ if (!sessionId) return;
1201
+ const session = loadSession(sessionId);
1202
+ if (!session) return;
1203
+ session.updatedAt = Date.now();
1204
+ await saveSessionAsync(session, { expectedGeneration: session.generation });
1205
+ }
1206
+
1207
+ /** Mark session hidden so listSessions() filters it out (runtime-only). */
1208
+ export function hideSessionFromList(sessionId) {
1209
+ if (!sessionId) return;
1210
+ const entry = _runtimeState.get(sessionId);
1211
+ if (entry) entry.listHidden = true;
1212
+ }
1213
+
1214
+ export function getSessionAbortSignal(sessionId) {
1215
+ return _runtimeState.get(sessionId)?.controller?.signal ?? null;
1216
+ }
1217
+
1218
+ /**
1219
+ * Return the most recent "session is making progress" timestamp.
1220
+ *
1221
+ * Combines three independent progress signals so an idle watchdog can stay
1222
+ * alive across both streaming and long tool calls:
1223
+ * - lastStreamDeltaAt: provider stream chunk landed
1224
+ * - toolStartedAt: a tool call just kicked off (nested tool work may
1225
+ * stall the outer stream for a while; this keeps the watchdog from
1226
+ * killing legitimate sub-agent runs)
1227
+ * - askStartedAt: ask just started; covers the pre-stream connect window
1228
+ *
1229
+ * Returns 0 when the runtime entry is unknown so callers can decide to
1230
+ * either skip the watchdog or treat 0 as "no progress yet".
1231
+ */
1232
+ export function getSessionLastProgressAt(sessionId) {
1233
+ const entry = _runtimeState.get(sessionId);
1234
+ if (!entry) return 0;
1235
+ return Math.max(
1236
+ entry.lastStreamDeltaAt || 0,
1237
+ entry.toolStartedAt || 0,
1238
+ entry.askStartedAt || 0,
1239
+ );
1240
+ }
1241
+
1242
+ /**
1243
+ * Link a parent AbortSignal to a sub-session's controller so that aborting
1244
+ * the parent (fan-out deadline or caller ESC) tears down the bridge role's
1245
+ * provider call promptly. Safe to call after prepareBridgeSession but before
1246
+ * askSession completes. No-op if the session runtime isn't found.
1247
+ *
1248
+ * @param {string} sessionId — the sub-session to abort
1249
+ * @param {AbortSignal} parentSignal — upstream signal (from fan-out coordinator)
1250
+ */
1251
+ export function linkParentSignalToSession(sessionId, parentSignal) {
1252
+ if (!(parentSignal instanceof AbortSignal)) return;
1253
+ const entry = _touchRuntime(sessionId);
1254
+ if (!entry.controller) entry.controller = createAbortController();
1255
+ if (parentSignal.aborted) {
1256
+ try { entry.controller.abort(new Error('parent signal aborted')); } catch { /* ignore */ }
1257
+ return;
1258
+ }
1259
+ parentSignal.addEventListener('abort', () => {
1260
+ try { entry.controller?.abort(new Error('parent signal aborted')); } catch { /* ignore */ }
1261
+ }, { once: true });
1262
+ }
1263
+ function _clearSessionRuntime(id) {
1264
+ if (id) {
1265
+ _runtimeState.delete(id);
1266
+ // R15: also drop the per-session metric-idempotency Set; otherwise it
1267
+ // grows O(sessions x iterations) for the whole server lifetime since
1268
+ // nothing else deletes from _metricSeenIter on session close.
1269
+ _metricSeenIter.delete(id);
1270
+ }
1271
+ }
1272
+
1273
+ /**
1274
+ * Wrap an async call so that if the session's controller aborts mid-flight,
1275
+ * the wrapper settles with a SessionClosedError even if the underlying promise
1276
+ * hasn't returned yet. The original promise is kept alive with a detached
1277
+ * `.catch()` to prevent unhandled-rejection warnings once it eventually
1278
+ * settles. Callers still must check generation/closed after await returns
1279
+ * to handle providers that ignore the AbortSignal entirely.
1280
+ */
1281
+ export async function _api_call_with_interrupt(sessionId, fn) {
1282
+ const entry = _touchRuntime(sessionId);
1283
+ if (!entry.controller) entry.controller = createAbortController();
1284
+ const signal = entry.controller.signal;
1285
+ if (signal.aborted) throw new SessionClosedError(sessionId, 'aborted before call');
1286
+ const underlying = fn(signal);
1287
+ underlying.catch(() => {}); // prevent unhandled rejection if we race ahead
1288
+ let onAbort = null;
1289
+ const aborted = new Promise((_, reject) => {
1290
+ onAbort = () => reject(new SessionClosedError(sessionId, 'aborted during call'));
1291
+ if (signal.aborted) onAbort();
1292
+ else signal.addEventListener('abort', onAbort, { once: true });
1293
+ });
1294
+ try {
1295
+ return await Promise.race([underlying, aborted]);
1296
+ } finally {
1297
+ // If the underlying promise settled first, the abort listener is
1298
+ // still attached. Remove it to avoid accumulating listeners across
1299
+ // many asks on the same session.
1300
+ if (onAbort && !signal.aborted) {
1301
+ try { signal.removeEventListener('abort', onAbort); } catch { /* ignore */ }
1302
+ }
1303
+ }
1304
+ }
1305
+
1306
+ // Per-session mutex: queues concurrent askSession calls to prevent message loss
1307
+ const _sessionLocks = new Map();
1308
+ // Per-session pending-message queue (Claude Code `pendingMessages` pattern).
1309
+ // A `bridge type=send` to a worker whose turn is still in flight ENQUEUES the
1310
+ // message here instead of rejecting; askSession drains the queue after each
1311
+ // turn and runs the messages as the next user turn(s), preserving order — the
1312
+ // queued send runs AFTER the in-flight prompt, which also closes the spawn
1313
+ // startup race (a send landing before the initial turn settles no longer
1314
+ // jumps ahead of the original prompt). Map<sessionId, string[]>. Shared with
1315
+ // index.mjs's bridge send handler via the enqueue/drain accessors below — one
1316
+ // queue contract, two call sites.
1317
+ const _sessionPendingMessages = new Map();
1318
+ export function enqueuePendingMessage(sessionId, message) {
1319
+ if (!sessionId || typeof message !== 'string' || !message) return 0;
1320
+ let q = _sessionPendingMessages.get(sessionId);
1321
+ if (!q) { q = []; _sessionPendingMessages.set(sessionId, q); }
1322
+ q.push(message);
1323
+ return q.length;
1324
+ }
1325
+ export function drainPendingMessages(sessionId) {
1326
+ const q = _sessionPendingMessages.get(sessionId);
1327
+ if (!q || q.length === 0) return [];
1328
+ _sessionPendingMessages.delete(sessionId);
1329
+ return q;
1330
+ }
1331
+ function acquireSessionLock(sessionId) {
1332
+ let entry = _sessionLocks.get(sessionId);
1333
+ if (!entry) {
1334
+ entry = { promise: Promise.resolve(), count: 0 };
1335
+ _sessionLocks.set(sessionId, entry);
1336
+ }
1337
+ entry.count++;
1338
+ const prev = entry.promise;
1339
+ let release;
1340
+ entry.promise = new Promise(r => { release = r; });
1341
+ // Self-heal: if the previous holder rejected, swallow so subsequent
1342
+ // queued waiters don't propagate that rejection and brick the lock chain.
1343
+ return prev.catch(() => {}).then(() => () => {
1344
+ entry.count--;
1345
+ if (entry.count === 0) _sessionLocks.delete(sessionId);
1346
+ release();
1347
+ });
1348
+ }
1349
+
1350
+ export async function askSession(sessionId, prompt, context, onToolCall, cwdOverride, explicitPrefetch) {
1351
+ const _askStartedAt = Date.now();
1352
+ const _promptSrc = 'prompt';
1353
+ const _prefetchFiles = (explicitPrefetch?.files?.length) || 0;
1354
+ const _prefetchCallers = (explicitPrefetch?.callers?.length) || 0;
1355
+ const _prefetchRefs = (explicitPrefetch?.references?.length) || 0;
1356
+ if (process.env.MIXDOG_DEBUG_BRIDGE) {
1357
+ process.stderr.write(`[bridge-trace] t0-ask-start sessionHash=${createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 8)} role=? iteration=0 promptSrc=${_promptSrc} prefetchFiles=${_prefetchFiles} callers=${_prefetchCallers} references=${_prefetchRefs}\n`);
1358
+ }
1359
+ const unlock = await acquireSessionLock(sessionId);
1360
+ const _lockWaitedMs = Date.now() - _askStartedAt;
1361
+ if (process.env.MIXDOG_DEBUG_BRIDGE) {
1362
+ process.stderr.write(`[bridge-trace] lock-acquired waitedMs=${_lockWaitedMs}\n`);
1363
+ }
1364
+ // The mutex is held for the WHOLE askSession call, including any follow-up
1365
+ // turns drained from the pending-message queue below — the single outer
1366
+ // try/finally releases it exactly once. _result holds the last turn's
1367
+ // return value (the queued tail turns supersede the original prompt's
1368
+ // result, mirroring how a live chat returns the latest turn).
1369
+ let _result;
1370
+ // Local FIFO of follow-up prompts drained from the pending-message queue
1371
+ // after each turn — keeps queued `bridge type=send` messages in order.
1372
+ const _pendingTail = [];
1373
+ // Hoisted so the outer finally (which runs once after the whole turn loop)
1374
+ // can compare against the last turn's generation.
1375
+ let askGeneration = 0;
1376
+ try {
1377
+ // Turn loop (pendingMessages pattern): run the current prompt, then drain
1378
+ // any `bridge type=send` messages that were queued while this turn was in
1379
+ // flight and run them — in order — as the next user turn(s). Because the
1380
+ // queued send always lands AFTER the in-flight prompt here, ordering is
1381
+ // preserved and the spawn/connecting startup race disappears.
1382
+ for (;;) {
1383
+ // After the first turn, the next prompt comes from the drained queue.
1384
+ // (On the first iteration _pendingTail is empty and `prompt` is the
1385
+ // caller's original message.)
1386
+ if (_pendingTail.length > 0) {
1387
+ prompt = _pendingTail.shift();
1388
+ // Queued follow-ups are plain user turns — no caller context /
1389
+ // prefetch is re-applied (those belonged to the original ask).
1390
+ context = null;
1391
+ explicitPrefetch = null;
1392
+ }
1393
+ // ── Synchronous pre-await setup (must happen before any await so
1394
+ // closeSession() can't interleave between load and registration) ──
1395
+ const preSession = loadSession(sessionId);
1396
+ if (!preSession) {
1397
+ throw new Error(`Session "${sessionId}" not found`);
1398
+ }
1399
+ if (preSession.closed === true) {
1400
+ throw new SessionClosedError(sessionId, 'session already closed');
1401
+ }
1402
+ askGeneration = typeof preSession.generation === 'number' ? preSession.generation : 0;
1403
+ const runtime = _touchRuntime(sessionId);
1404
+ // Fresh controller per ask — the previous ask's controller may have aborted.
1405
+ runtime.controller = createAbortController();
1406
+ runtime.generation = askGeneration;
1407
+ runtime.closed = false;
1408
+ markSessionAskStart(sessionId);
1409
+ // Preprocessing is inside try so provider-not-available / trim failures
1410
+ // fall into the catch and mark the session as errored rather than
1411
+ // leaving stage='connecting' forever.
1412
+ try {
1413
+ const session = preSession;
1414
+ const provider = getProvider(session.provider);
1415
+ // Register the live session object into runtime so closeSession()
1416
+ // can read allBashSessionIds that loop.mjs appends mid-turn.
1417
+ runtime.session = session;
1418
+ if (!provider)
1419
+ throw new Error(`Provider "${session.provider}" not available`);
1420
+ // Cap caller-supplied / prefetched context so an oversized
1421
+ // payload can't blow the session token budget before the
1422
+ // first model call. 32 KB ~ 8k tokens at the 4 B/tok
1423
+ // working average; longer is silently truncated with a
1424
+ // visible marker so the model still sees the prefix and
1425
+ // a hint about the cut.
1426
+ const _CTX_CHAR_CAP = 32 * 1024;
1427
+ const _capCtx = (text) => {
1428
+ if (typeof text !== 'string') return '';
1429
+ if (text.length <= _CTX_CHAR_CAP) return text;
1430
+ return `${text.slice(0, _CTX_CHAR_CAP)}\n\n... [context truncated; original ${text.length} chars]`;
1431
+ };
1432
+ // Inline context + prefetch INTO the prompt as a single user turn,
1433
+ // marked with explicit section headers. The previous design pushed
1434
+ // context as separate user messages with pre-injected assistant
1435
+ // "Noted." acks; that conversational pattern taught some models a
1436
+ // low-effort rhythm and they responded with "Noted." / empty tags
1437
+ // even to the real task. Single-turn structure with a labelled
1438
+ // `# Task` block forces the model to treat the brief as the work
1439
+ // unit, not as another piece of context to ack.
1440
+ const explicitPrefetchResult = await _tryBridgeExplicitPrefetch(session, explicitPrefetch);
1441
+ let _contextBlock = '';
1442
+ if (context) {
1443
+ _contextBlock += `# Additional context\n${_capCtx(context)}\n\n`;
1444
+ }
1445
+ if (explicitPrefetchResult) {
1446
+ _contextBlock += `# Prefetch\n${_capCtx(explicitPrefetchResult)}\n\n`;
1447
+ }
1448
+ const beforeCount = session.messages.length + 1;
1449
+ // Soft warning only; real size management (compaction primary,
1450
+ // byte-budget trim as safety net) lives in agentLoop. Selecting a
1451
+ // 25% pre-trim here would starve compaction's 50% threshold.
1452
+ const softBudget = Math.floor(session.contextWindow * 0.25);
1453
+ const promptTokenEstimate = prompt.length * 0.5; // conservative for CJK
1454
+ if (promptTokenEstimate > softBudget * 0.7) {
1455
+ process.stderr.write(`[session] Warning: prompt is very large (est. ${Math.round(promptTokenEstimate)} tokens vs ${softBudget} soft budget)\n`);
1456
+ }
1457
+ const effectiveCwd = cwdOverride || session.cwd;
1458
+ const _userTurnContent = _contextBlock
1459
+ ? `${_contextBlock}# Task\n${prompt}`
1460
+ : prompt;
1461
+ const outgoing = [...session.messages, { role: 'user', content: _userTurnContent }];
1462
+ // Per-turn injected-context trace row (complements kind:"usage").
1463
+ // Cheap byte-length accounting — no hashing, no payload bodies.
1464
+ // Honors the same MIXDOG_BRIDGE_TRACE_DISABLE gate as usage rows;
1465
+ // appendBridgeTrace is a no-op when that env is set.
1466
+ try {
1467
+ const _ctxBytes = Buffer.byteLength(context || '', 'utf8');
1468
+ const _prefetchBytes = Buffer.byteLength(explicitPrefetchResult || '', 'utf8');
1469
+ const _promptBytes = Buffer.byteLength(prompt || '', 'utf8');
1470
+ const _userTurnBytes = Buffer.byteLength(_userTurnContent, 'utf8');
1471
+ const _messagesBytes = Buffer.byteLength(JSON.stringify(session.messages || []), 'utf8');
1472
+ const _totalBytes = _userTurnBytes + _messagesBytes;
1473
+ appendBridgeTrace({
1474
+ kind: 'context',
1475
+ sessionId,
1476
+ model: session.model,
1477
+ provider: session.provider,
1478
+ totalBytes: _totalBytes,
1479
+ breakdown: {
1480
+ contextBytes: _ctxBytes,
1481
+ prefetchBytes: _prefetchBytes,
1482
+ promptBytes: _promptBytes,
1483
+ userTurnBytes: _userTurnBytes,
1484
+ messagesBytes: _messagesBytes,
1485
+ messagesCount: Array.isArray(session.messages) ? session.messages.length : 0,
1486
+ },
1487
+ });
1488
+ } catch { /* trace must never break the ask path */ }
1489
+ const result = await _api_call_with_interrupt(sessionId, (signal) =>
1490
+ agentLoop(provider, outgoing, session.model, session.tools, onToolCall, effectiveCwd, {
1491
+ effort: session.effort || null,
1492
+ fast: session.fast === true,
1493
+ sessionId,
1494
+ onUsageDelta: (d) => persistIterationMetrics(d).catch(() => {}),
1495
+ promptCacheKey: session.promptCacheKey || sessionId,
1496
+ // Provider-scoped cache key (mixdog-codex, mixdog-claude…).
1497
+ // Distinct from sessionId — providers that pool sockets
1498
+ // per-session (openai-oauth WS) use sessionId as the
1499
+ // pool bucket and providerCacheKey as the server-side
1500
+ // prompt-cache shard so parallel callers don't collide
1501
+ // on a mid-turn socket while still sharing prefix cache.
1502
+ providerCacheKey: session.promptCacheKey || null,
1503
+ signal,
1504
+ providerState: session.providerState ?? undefined,
1505
+ session,
1506
+ // Smart Bridge cache settings — merged last so session overrides
1507
+ // don't get overridden by defaults. When session has no profile,
1508
+ // providerCacheOpts is null and this spread is a no-op.
1509
+ ...(session.providerCacheOpts || {}),
1510
+ onStageChange: (stage) => updateSessionStage(sessionId, stage),
1511
+ onStreamDelta: () => markSessionStreamDelta(sessionId).catch(() => {}),
1512
+ }),
1513
+ );
1514
+ // Post-loop validation: if closeSession() landed while we were awaiting,
1515
+ // drop the save so the tombstone on disk isn't overwritten.
1516
+ const currentRuntime = _runtimeState.get(sessionId);
1517
+ if (currentRuntime?.closed || currentRuntime?.generation !== askGeneration) {
1518
+ const reason = currentRuntime?.closedReason;
1519
+ throw new SessionClosedError(sessionId, `closed during call (reason=${reason || 'unknown'})`, reason || null);
1520
+ }
1521
+ // Update and save. outgoing is mutated in place by agentLoop
1522
+ // (compaction + safety trim), so its length reflects post-loop state.
1523
+ const messagesDropped = Math.max(0, beforeCount - outgoing.length);
1524
+ session.messages = outgoing;
1525
+ if (result.content || result.reasoningContent) {
1526
+ session.messages.push({
1527
+ role: 'assistant',
1528
+ content: result.content || '',
1529
+ ...(typeof result.reasoningContent === 'string' && result.reasoningContent
1530
+ ? { reasoningContent: result.reasoningContent }
1531
+ : {}),
1532
+ });
1533
+ } else {
1534
+ // Empty terminal turn: still persist a forensic record so
1535
+ // post-mortem inspection can distinguish "work landed but
1536
+ // synthesis missing" from "session never ran". Stop reason,
1537
+ // usage, iterations, and tool-call totals survive even when
1538
+ // the assistant produced no content/reasoning.
1539
+ const _emptyStop = result?.stopReason ?? result?.stop_reason ?? null;
1540
+ const _emptyUsage = result?.usage ? {
1541
+ inputTokens: result.usage.inputTokens || 0,
1542
+ outputTokens: result.usage.outputTokens || 0,
1543
+ cachedTokens: result.usage.cachedTokens || 0,
1544
+ cacheWriteTokens: result.usage.cacheWriteTokens || 0,
1545
+ } : null;
1546
+ // Provider content-block classification — distinguishes a
1547
+ // thinking-only stall (model emitted reasoning blocks but no
1548
+ // text/tool_use) from a true silent empty turn. Anthropic
1549
+ // providers (anthropic.mjs, anthropic-oauth.mjs) set these
1550
+ // fields on the result; other providers may omit them.
1551
+ const _emptyHasThinking = typeof result?.hasThinkingContent === 'boolean'
1552
+ ? result.hasThinkingContent
1553
+ : null;
1554
+ const _emptyBlockTypes = Array.isArray(result?.contentBlockTypes)
1555
+ ? result.contentBlockTypes.slice()
1556
+ : null;
1557
+ session.messages.push({
1558
+ role: 'assistant',
1559
+ content: '',
1560
+ emptyFinal: true,
1561
+ stopReason: _emptyStop,
1562
+ iterations: result?.iterations ?? null,
1563
+ toolCallsTotal: result?.toolCallsTotal ?? null,
1564
+ usage: _emptyUsage,
1565
+ ...(_emptyHasThinking !== null ? { hasThinkingContent: _emptyHasThinking } : {}),
1566
+ ...(_emptyBlockTypes !== null ? { contentBlockTypes: _emptyBlockTypes } : {}),
1567
+ ts: Date.now(),
1568
+ });
1569
+ try {
1570
+ const _blockTypesStr = _emptyBlockTypes ? _emptyBlockTypes.join(',') || 'none' : 'unknown';
1571
+ const _thinkingStr = _emptyHasThinking === null ? 'unknown' : String(_emptyHasThinking);
1572
+ process.stderr.write(`[session] empty-final persisted sessionId=${sessionId} stopReason=${_emptyStop ?? 'unknown'} iterations=${result?.iterations ?? 0} toolCallsTotal=${result?.toolCallsTotal ?? 0} outTokens=${_emptyUsage?.outputTokens ?? 0} hasThinking=${_thinkingStr} blockTypes=${_blockTypesStr}\n`);
1573
+ } catch {}
1574
+ }
1575
+ session.updatedAt = Date.now();
1576
+ session.lastUsedAt = Date.now();
1577
+ if (result.usage) {
1578
+ session.totalInputTokens += result.usage.inputTokens;
1579
+ session.totalOutputTokens += result.usage.outputTokens;
1580
+ session.tokensCumulative = (session.tokensCumulative || 0)
1581
+ + (result.usage.inputTokens || 0)
1582
+ + (result.usage.outputTokens || 0);
1583
+ // Cache totals — same `||0` undefined-safe accumulation pattern as
1584
+ // persistIterationMetrics so live + terminal paths stay in lock-step
1585
+ // and legacy sessions migrate lazily on first iteration.
1586
+ session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + (result.usage.cachedTokens || 0);
1587
+ session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + (result.usage.cacheWriteTokens || 0);
1588
+ // Window snapshot = the current context size, which is the LAST
1589
+ // single call — NOT result.usage (that is lastUsage, the per-turn
1590
+ // SUM accumulated with += across iterations in agentLoop). Use
1591
+ // lastTurnUsage (the final iteration's raw usage) so this reflects
1592
+ // "what's in the window now" rather than the lifetime sum.
1593
+ const _lastTurn = result.lastTurnUsage || result.usage || {};
1594
+ session.lastInputTokens = _lastTurn.inputTokens || 0;
1595
+ session.lastOutputTokens = _lastTurn.outputTokens || 0;
1596
+ session.lastCachedReadTokens = _lastTurn.cachedTokens || 0;
1597
+ session.lastCacheWriteTokens = _lastTurn.cacheWriteTokens || 0;
1598
+ // Provider-normalized footprint, identical formula to
1599
+ // persistIterationMetrics so both writers agree: Anthropic
1600
+ // input_tokens excludes cache (add it back), openai/grok/gemini
1601
+ // already include it.
1602
+ const _inputExcludesCache = providerInputExcludesCache(session.provider);
1603
+ session.lastContextTokens = _inputExcludesCache
1604
+ ? (_lastTurn.inputTokens || 0) + (_lastTurn.cachedTokens || 0)
1605
+ : (_lastTurn.inputTokens || 0);
1606
+ }
1607
+ // Smart Bridge cache stats — record hit/miss after every successful
1608
+ // ask so the registry reflects all bridge traffic, not just
1609
+ // maintenance cycles. Guarded against any smart-bridge error so
1610
+ // metric recording never breaks the ask itself.
1611
+ let prefixHashForLog = null;
1612
+ if (session.profileId && result.usage && _smartBridgeApi) {
1613
+ try {
1614
+ const profile = _smartBridgeApi.getProfile(session.profileId);
1615
+ if (profile) {
1616
+ // Collect every leading system-role message (BP1, BP2, ...)
1617
+ // until the first non-system message so the registry hash
1618
+ // captures the full ordered provider prefix, not just BP1.
1619
+ const systemMsgs = [];
1620
+ for (const m of session.messages) {
1621
+ if (m?.role !== 'system') break;
1622
+ systemMsgs.push(typeof m.content === 'string' ? m.content : '');
1623
+ }
1624
+ _smartBridgeApi.recordCall(profile, session.provider, {
1625
+ systemPrompt: systemMsgs,
1626
+ tools: session.tools || [],
1627
+ usage: result.usage,
1628
+ });
1629
+ const entry = _smartBridgeApi.registry?.data?.profiles?.[session.profileId]?.[session.provider];
1630
+ prefixHashForLog = entry?.prefixHash || null;
1631
+ }
1632
+ } catch {}
1633
+ }
1634
+ // Append to bridge-trace.jsonl with the rich bridge usage fields.
1635
+ if (result.usage) {
1636
+ const inputTokens = result.usage.inputTokens || 0;
1637
+ const outputTokens = result.usage.outputTokens || 0;
1638
+ const cacheReadTokens = result.usage.cachedTokens || 0;
1639
+ const cacheWriteTokens = result.usage.cacheWriteTokens || 0;
1640
+ // Unified total-prompt field. Anthropic = input+cache_read+cache_write
1641
+ // (additive); OpenAI/Codex/Gemini = input_tokens already includes the
1642
+ // cached portion (inclusive), so the fallback must not double-count.
1643
+ const { isInclusiveProvider, computeCostUsd } = await import('../../../shared/llm/cost.mjs');
1644
+ const inclusive = isInclusiveProvider(session.provider);
1645
+ const promptTokens = typeof result.usage.promptTokens === 'number'
1646
+ ? result.usage.promptTokens
1647
+ : (inclusive
1648
+ ? Math.max(inputTokens, cacheReadTokens + cacheWriteTokens)
1649
+ : inputTokens + cacheReadTokens + cacheWriteTokens);
1650
+ let costUsd = result.usage.costUsd || 0;
1651
+ if (!costUsd) {
1652
+ try {
1653
+ costUsd = computeCostUsd({
1654
+ model: session.model,
1655
+ provider: session.provider,
1656
+ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
1657
+ });
1658
+ } catch { /* best-effort */ }
1659
+ }
1660
+ logLlmCall({
1661
+ ts: new Date().toISOString(),
1662
+ sourceType: session.sourceType || 'lead',
1663
+ sourceName: session.sourceName || session.role || null,
1664
+ preset: session.presetName || null,
1665
+ model: session.model,
1666
+ provider: session.provider,
1667
+ duration: Date.now() - _askStartedAt,
1668
+ profileId: session.profileId || null,
1669
+ sessionId: session.id,
1670
+ inputTokens,
1671
+ outputTokens,
1672
+ cacheReadTokens,
1673
+ cacheWriteTokens,
1674
+ promptTokens,
1675
+ prefixHash: prefixHashForLog,
1676
+ costUsd,
1677
+ });
1678
+ }
1679
+ // Persist opaque providerState for future stateful providers.
1680
+ // No provider currently emits it (Codex OAuth is stateless per
1681
+ // contract), so this branch is dormant — kept so a future
1682
+ // Responses-API provider with stable continuation can plug in
1683
+ // without reworking the session shape.
1684
+ if (result.providerState !== undefined) {
1685
+ session.providerState = result.providerState;
1686
+ }
1687
+ await saveSessionAsync(session, { expectedGeneration: askGeneration });
1688
+ // Tag empty-synthesis BEFORE markSessionDone so the watchdog
1689
+ // (which inspects entry.emptyFinal first) classifies the
1690
+ // terminal state correctly even if it ticks during unwind.
1691
+ const isEmptyFinal = !result.content && !result.reasoningContent;
1692
+ if (isEmptyFinal) {
1693
+ markSessionEmptyFinal(sessionId);
1694
+ }
1695
+ markSessionDone(sessionId, { empty: isEmptyFinal });
1696
+ _result = {
1697
+ ...result,
1698
+ trimmed: messagesDropped > 0,
1699
+ messagesDropped,
1700
+ };
1701
+ } catch (err) {
1702
+ if (err instanceof SessionClosedError) {
1703
+ // Cancellation is not an error; propagate silently so callers
1704
+ // can render it as "cancelled" rather than a red failure.
1705
+ throw err;
1706
+ }
1707
+ markSessionError(sessionId, err && err.message ? err.message : String(err));
1708
+ throw err;
1709
+ }
1710
+ // ── Turn complete. Drain the pending-message queue (Claude Code
1711
+ // pendingMessages): any `bridge type=send` that arrived while this
1712
+ // turn was in flight runs next, in order, as a follow-up user turn.
1713
+ // The mutex is still held, so a send racing this drain either landed
1714
+ // before (picked up here) or enqueues for the next loop. When the
1715
+ // queue is empty we return the latest turn's result. ──
1716
+ const _drained = drainPendingMessages(sessionId);
1717
+ if (_drained.length > 0) {
1718
+ _pendingTail.push(..._drained);
1719
+ continue;
1720
+ }
1721
+ return _result;
1722
+ }
1723
+ } finally {
1724
+ // Clear the controller only if it's still ours (closeSession may have
1725
+ // swapped it). Leave the rest of the runtime entry intact so bridge type=list
1726
+ // can still surface the final stage (done/error/cancelling).
1727
+ const entry = _runtimeState.get(sessionId);
1728
+ if (entry && entry.generation === askGeneration) {
1729
+ entry.controller = null;
1730
+ // Detach the live session reference; ask is over.
1731
+ entry.session = null;
1732
+ }
1733
+ unlock();
1734
+ }
1735
+ }
1736
+ // Session lookup by scopeKey — used by CLI bridge to resume a pinned
1737
+ // scope session when the caller passes --scope (agent/<name>).
1738
+ export function findSessionByScopeKey(scopeKey) {
1739
+ if (!scopeKey) return null;
1740
+ const sessions = listStoredSessions();
1741
+ // Exclude tombstoned sessions (`closed === true`) so callers never receive
1742
+ // a session whose controller was aborted by closeSession(). The `closed`
1743
+ // bit is the authoritative tombstone flag; `status === 'error'` is not,
1744
+ // since transient-error sessions remain resumable.
1745
+ return sessions.find(s => s.scopeKey === scopeKey && s.closed !== true) || null;
1746
+ }
1747
+ // --- resume (reload tools for a stored session) ---
1748
+ export async function resumeSession(sessionId, preset) {
1749
+ const session = loadSession(sessionId);
1750
+ if (!session)
1751
+ return null;
1752
+ // Resuming a closed session is a resurrection attempt — refuse. The guarded
1753
+ // save below would also block the write, but failing fast here is cleaner
1754
+ // than silently dropping the tool-refresh side effects.
1755
+ if (session.closed === true) return null;
1756
+ if (!session.owner) session.owner = 'user';
1757
+ // Refresh tools (MCP connections may have changed).
1758
+ // Re-resolve from profile.tools when the session stored a profileId —
1759
+ // otherwise fall back to preset.tools. Same resolution order as
1760
+ // createSession so resume and spawn produce identical BP_1 shapes.
1761
+ const oldTools = session.tools || [];
1762
+ const skills = collectSkillsCached(session.cwd);
1763
+ let toolSpec = preset || session.preset || 'full';
1764
+ if (session.profileId && _smartBridgeApi?.getProfile) {
1765
+ try {
1766
+ const profile = _smartBridgeApi.getProfile(session.profileId);
1767
+ if (Array.isArray(profile?.tools)) toolSpec = profile.tools;
1768
+ } catch { /* ignore lookup failures, keep preset fallback */ }
1769
+ }
1770
+ session.tools = resolveSessionTools(toolSpec, skills, { ownerIsBridge: session.owner === 'bridge' });
1771
+ const newTools = session.tools;
1772
+ const missing = oldTools.filter(t => !newTools.find(n => n.name === t.name));
1773
+ if (missing.length) {
1774
+ process.stderr.write(`[session] Warning: ${missing.length} tools no longer available: ${missing.map(t => t.name).join(', ')}\n`);
1775
+ }
1776
+ await saveSessionAsync(session, { expectedGeneration: session.generation });
1777
+ return session;
1778
+ }
1779
+ // --- CRUD ---
1780
+ export function getSession(id) {
1781
+ return loadSession(id);
1782
+ }
1783
+ export function listSessions() {
1784
+ const sessions = listStoredSessions();
1785
+ const hiddenIds = new Set([..._runtimeState.entries()].filter(([, e]) => e.listHidden).map(([id]) => id));
1786
+ // Always exclude tombstoned sessions (closed===true) — closeSession plants the tombstone.
1787
+ return sessions.filter(s => s.closed !== true && !hiddenIds.has(s.id));
1788
+ }
1789
+ // --- Clear messages (keep system prompt + provider/model/cwd) ---
1790
+ export async function clearSessionMessages(sessionId) {
1791
+ const session = loadSession(sessionId);
1792
+ if (!session)
1793
+ return false;
1794
+ // Don't resurrect a closed session just to clear its messages.
1795
+ if (session.closed === true) return false;
1796
+ session.messages = (session.messages || []).filter(m => m && m.role === 'system');
1797
+ session.totalInputTokens = 0;
1798
+ session.totalOutputTokens = 0;
1799
+ session.updatedAt = Date.now();
1800
+ await saveSessionAsync(session, { expectedGeneration: session.generation });
1801
+ return true;
1802
+ }
1803
+ export async function updateSessionStatus(id, status) {
1804
+ const session = loadSession(id);
1805
+ if (!session) return false;
1806
+ // Respect tombstones — don't resurrect a closed session just to update a
1807
+ // status label (bridge handler emits running→idle/error around askSession).
1808
+ if (session.closed === true) return false;
1809
+ session.status = status;
1810
+ session.updatedAt = Date.now();
1811
+ await saveSessionAsync(session, { expectedGeneration: session.generation });
1812
+ return true;
1813
+ }
1814
+ /**
1815
+ * Close a session. Plants a `closed=true` tombstone on disk with a bumped
1816
+ * generation (so any racing saveSession() drops its write), aborts the
1817
+ * in-flight controller if one exists, and clears the in-memory runtime entry.
1818
+ *
1819
+ * IMPORTANT: we deliberately do NOT unlink the session file here. The tombstone
1820
+ * on disk is the authoritative signal that blocks resurrection — a late
1821
+ * saveSession() re-reads disk via _shouldDrop() and will find the tombstone.
1822
+ * If we delete the file, a late save sees no file, decides nothing to drop,
1823
+ * and recreates the session in its pre-close state.
1824
+ *
1825
+ * Long-term cleanup: `sweepTombstones()` below unlinks tombstones older than
1826
+ * TOMBSTONE_MAX_AGE_MS (24h — vastly longer than any realistic in-flight race).
1827
+ */
1828
+ export function closeSession(id, reason = 'manual') {
1829
+ if (!id) return false;
1830
+ // Prefer in-memory runtime session — allBashSessionIds may not be persisted
1831
+ // yet for shells opened in the current turn (BL-bash-disk-sync).
1832
+ const inMemory = _runtimeState.get(id)?.session;
1833
+ const persisted = inMemory || loadSession(id);
1834
+ const bashSessionId = persisted?.implicitBashSessionId || null;
1835
+ // Collect all persistent bash shells created during this session.
1836
+ const allBashIds = Array.isArray(persisted?.allBashSessionIds)
1837
+ ? persisted.allBashSessionIds.filter(Boolean)
1838
+ : (bashSessionId ? [bashSessionId] : []);
1839
+ // Deduplicate: allBashIds already covers implicitBashSessionId, but guard
1840
+ // against old session records that only have implicitBashSessionId.
1841
+ if (bashSessionId && !allBashIds.includes(bashSessionId)) allBashIds.push(bashSessionId);
1842
+ // 1. Tombstone first — this wins the race against saveSession().
1843
+ const newGen = markSessionClosed(id, reason);
1844
+ // 2. Mark runtime as closed so post-await validation in askSession fires.
1845
+ const entry = _runtimeState.get(id);
1846
+ if (entry) {
1847
+ entry.closed = true;
1848
+ entry.closedReason = reason;
1849
+ if (typeof newGen === 'number') entry.generation = newGen;
1850
+ entry.stage = 'cancelling';
1851
+ entry.updatedAt = Date.now();
1852
+ // 3. Abort the in-flight controller. Providers that honour the signal
1853
+ // unwind immediately; providers that don't will still be caught by
1854
+ // the generation check after their await eventually returns.
1855
+ try { entry.controller?.abort(new SessionClosedError(id, `closeSession (reason=${reason})`, reason)); } catch { /* ignore */ }
1856
+ }
1857
+ // Diagnostic: one-line stderr so operators can distinguish the four close
1858
+ // pathways (request-abort / manual / idle-sweep / runner-crash). iterCount
1859
+ // is not currently tracked on runtime state; askStartedAt is — derive
1860
+ // duration from it when present.
1861
+ try {
1862
+ const askStartedAt = entry?.askStartedAt;
1863
+ const durationMs = (typeof askStartedAt === 'number') ? (Date.now() - askStartedAt) : null;
1864
+ const parts = [`session=${id}`, `reason=${reason}`];
1865
+ if (durationMs != null) parts.push(`duration=${durationMs}ms`);
1866
+ process.stderr.write(`[bridge-close] ${parts.join(' ')}\n`);
1867
+ } catch { /* best-effort */ }
1868
+ for (const bsid of allBashIds) {
1869
+ try { closeBashSession(bsid, `bridge-close:${id}`); } catch { /* ignore */ }
1870
+ }
1871
+ // Drop session-scoped read dedup cache so the Map doesn't accumulate
1872
+ // entries across mcp-server lifetime.
1873
+ try { clearReadDedupSession(id); } catch { /* ignore */ }
1874
+ // Drop offload sidecars + module-level counter for this session so a
1875
+ // long-running mcp-server doesn't leak disk (tool-results/<id>/*.txt)
1876
+ // or Map entries across session lifetime. Fire-and-forget — close path
1877
+ // should not await disk IO; errors are swallowed inside.
1878
+ try { clearOffloadSession(id); } catch { /* ignore */ }
1879
+ // 4. Defer runtime map clear to next tick so any settling askSession can
1880
+ // observe `closed=true` / bumped generation before we yank the entry.
1881
+ // Disk tombstone remains — that's what blocks resurrection.
1882
+ setImmediate(() => {
1883
+ _clearSessionRuntime(id);
1884
+ });
1885
+ return true;
1886
+ }
1887
+
1888
+ // --- Periodic idle session cleanup ---
1889
+ const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // check every 5 minutes
1890
+ const TOMBSTONE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h — far longer than any realistic ask race window
1891
+ let _cleanupTimer = null;
1892
+
1893
+ function sweepIdleSessions() {
1894
+ try {
1895
+ const { cleaned, remaining, details } = sweepStaleSessions();
1896
+ if (cleaned > 0) {
1897
+ for (const d of details) {
1898
+ // Skip entries with an active in-flight controller — aborting
1899
+ // them via closeSession() is the safe path; clearing the runtime
1900
+ // without signalling the controller leaves orphan provider work.
1901
+ const rtEntry = _runtimeState.get(d.id);
1902
+ if (rtEntry && rtEntry.controller && !rtEntry.controller.signal?.aborted) {
1903
+ try { closeSession(d.id, 'idle-sweep'); } catch { /* ignore */ }
1904
+ } else {
1905
+ _clearSessionRuntime(d.id);
1906
+ if (d.bashSessionId) {
1907
+ try { closeBashSession(d.bashSessionId, `idle-sweep:${d.id}`); } catch { /* ignore */ }
1908
+ }
1909
+ }
1910
+ process.stderr.write(`[bridge-session] idle cleanup: closed ${d.id} (idle ${d.idleMinutes}m, owner=${d.owner})\n`);
1911
+ }
1912
+ process.stderr.write(`[bridge-session] idle sweep: cleaned ${cleaned} session(s), ${remaining} remaining\n`);
1913
+ }
1914
+ } catch (e) {
1915
+ process.stderr.write(`[bridge-session] idle sweep error: ${e && e.message || e}\n`);
1916
+ }
1917
+ }
1918
+
1919
+ /**
1920
+ * Unlink tombstone session files (closed=true) older than TOMBSTONE_MAX_AGE_MS.
1921
+ *
1922
+ * Rationale: closeSession() leaves the tombstone on disk as the authoritative
1923
+ * resurrection-blocker for racing saveSession() calls. That race resolves in
1924
+ * microseconds (the window inside _doSave between temp write and rename), so
1925
+ * 24h is vastly safe. After the TTL expires we reclaim the disk slot.
1926
+ *
1927
+ * Uses `getStoredSessionsRaw()` rather than `listStoredSessions()` because the
1928
+ * latter's inline 30-min idle cleanup would race-unlink tombstones before we
1929
+ * get to log them — we want to own the unlink decision and stderr line here.
1930
+ */
1931
+ export function sweepTombstones() {
1932
+ try {
1933
+ const now = Date.now();
1934
+ const sessions = getStoredSessionsRaw();
1935
+ let cleaned = 0;
1936
+ for (const s of sessions) {
1937
+ if (!s.closed) continue;
1938
+ const updated = Number(s.updatedAt);
1939
+ if (!Number.isFinite(updated)) continue;
1940
+ const age = now - updated;
1941
+ if (age < TOMBSTONE_MAX_AGE_MS) continue;
1942
+ try {
1943
+ deleteSession(s.id);
1944
+ _clearSessionRuntime(s.id);
1945
+ cleaned++;
1946
+ process.stderr.write(`[session-sweep] unlinked tombstone ${s.id} (age=${Math.floor(age / 1000)}s)\n`);
1947
+ } catch (e) {
1948
+ process.stderr.write(`[session-sweep] unlink failed ${s.id}: ${e && e.message || e}\n`);
1949
+ }
1950
+ }
1951
+ return cleaned;
1952
+ } catch (e) {
1953
+ process.stderr.write(`[session-sweep] tombstone sweep error: ${e && e.message || e}\n`);
1954
+ return 0;
1955
+ }
1956
+ }
1957
+
1958
+ function _runCleanupCycle() {
1959
+ sweepIdleSessions();
1960
+ sweepTombstones();
1961
+ }
1962
+
1963
+ export function startIdleCleanup() {
1964
+ if (_cleanupTimer) return;
1965
+ _runCleanupCycle();
1966
+ _cleanupTimer = setInterval(_runCleanupCycle, CLEANUP_INTERVAL_MS);
1967
+ if (_cleanupTimer.unref) _cleanupTimer.unref(); // don't block process exit
1968
+ }
1969
+
1970
+ export function stopIdleCleanup() {
1971
+ if (_cleanupTimer) {
1972
+ clearInterval(_cleanupTimer);
1973
+ _cleanupTimer = null;
1974
+ }
1975
+ }