mixdog 0.7.17 → 0.8.0

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 (836) hide show
  1. package/README.md +37 -331
  2. package/package.json +67 -99
  3. package/scripts/boot-smoke.mjs +94 -0
  4. package/scripts/build-tui.mjs +52 -0
  5. package/scripts/compact-smoke.mjs +199 -0
  6. package/scripts/lead-workflow-smoke.mjs +598 -0
  7. package/scripts/live-worker-smoke.mjs +239 -0
  8. package/scripts/output-style-smoke.mjs +101 -0
  9. package/scripts/smoke-loop-report.mjs +221 -0
  10. package/scripts/smoke-loop.mjs +201 -0
  11. package/scripts/smoke.mjs +113 -0
  12. package/scripts/tool-failures.mjs +143 -0
  13. package/scripts/tool-smoke.mjs +456 -0
  14. package/src/agents/debugger/AGENT.md +3 -0
  15. package/src/agents/debugger/agent.json +6 -0
  16. package/src/agents/explore/AGENT.md +4 -0
  17. package/src/agents/explore/agent.json +6 -0
  18. package/src/agents/heavy-worker/AGENT.md +3 -0
  19. package/src/agents/heavy-worker/agent.json +6 -0
  20. package/src/agents/maintainer/AGENT.md +3 -0
  21. package/src/agents/maintainer/agent.json +6 -0
  22. package/src/agents/reviewer/AGENT.md +3 -0
  23. package/src/agents/reviewer/agent.json +6 -0
  24. package/src/agents/scheduler-task.md +3 -0
  25. package/src/agents/web-researcher/AGENT.md +3 -0
  26. package/src/agents/web-researcher/agent.json +6 -0
  27. package/src/agents/webhook-handler.md +3 -0
  28. package/src/agents/worker/AGENT.md +3 -0
  29. package/src/agents/worker/agent.json +6 -0
  30. package/src/app.mjs +90 -0
  31. package/src/cli.mjs +11 -0
  32. package/src/defaults/hidden-roles.json +72 -0
  33. package/src/defaults/mixdog-config.template.json +15 -0
  34. package/src/hooks/lib/permission-evaluator.cjs +488 -0
  35. package/src/hooks/lib/settings-loader.cjs +112 -0
  36. package/src/lib/keychain-cjs.cjs +332 -0
  37. package/src/lib/plugin-paths.cjs +28 -0
  38. package/src/lib/rules-builder.cjs +315 -0
  39. package/src/mixdog-session-runtime.mjs +3704 -0
  40. package/src/output-styles/default.md +38 -0
  41. package/src/output-styles/extreme-simple.md +17 -0
  42. package/src/output-styles/simple.md +17 -0
  43. package/src/repl.mjs +322 -0
  44. package/src/rules/bridge/00-common.md +5 -0
  45. package/src/rules/bridge/20-skip-protocol.md +11 -0
  46. package/src/rules/bridge/30-explorer.md +4 -0
  47. package/src/rules/bridge/40-cycle1-agent.md +28 -0
  48. package/src/rules/bridge/41-cycle2-agent.md +59 -0
  49. package/src/rules/lead/00-tool-lead.md +5 -0
  50. package/src/rules/lead/01-general.md +5 -0
  51. package/src/rules/lead/02-channels.md +3 -0
  52. package/src/rules/lead/04-workflow.md +12 -0
  53. package/src/rules/shared/00-language.md +3 -0
  54. package/src/rules/shared/01-tool.md +3 -0
  55. package/src/runtime/agent/orchestrator/bridge-trace.mjs +814 -0
  56. package/src/runtime/agent/orchestrator/cache-mtime.mjs +60 -0
  57. package/src/runtime/agent/orchestrator/config.mjs +446 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +796 -0
  59. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +417 -0
  60. package/src/runtime/agent/orchestrator/internal-roles.mjs +188 -0
  61. package/src/runtime/agent/orchestrator/internal-tools.mjs +88 -0
  62. package/src/runtime/agent/orchestrator/mcp/client.mjs +345 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +2104 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +784 -0
  65. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +341 -0
  66. package/src/runtime/agent/orchestrator/providers/gemini.mjs +1679 -0
  67. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +959 -0
  68. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +213 -0
  69. package/src/runtime/agent/orchestrator/providers/model-cache.mjs +38 -0
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +471 -0
  71. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +615 -0
  72. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +808 -0
  73. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +1719 -0
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +2587 -0
  75. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1953 -0
  76. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +136 -0
  77. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +317 -0
  78. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +109 -0
  79. package/src/runtime/agent/orchestrator/providers/registry.mjs +247 -0
  80. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +332 -0
  81. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +11 -0
  82. package/src/runtime/agent/orchestrator/providers/trace-utils.mjs +50 -0
  83. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +142 -0
  84. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +318 -0
  85. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +367 -0
  86. package/src/runtime/agent/orchestrator/session/compact.mjs +882 -0
  87. package/src/runtime/agent/orchestrator/session/context-utils.mjs +233 -0
  88. package/src/runtime/agent/orchestrator/session/loop.mjs +2320 -0
  89. package/src/runtime/agent/orchestrator/session/manager.mjs +2960 -0
  90. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  91. package/src/runtime/agent/orchestrator/session/store.mjs +663 -0
  92. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +166 -0
  93. package/src/runtime/agent/orchestrator/smart-bridge/bridge-llm.mjs +339 -0
  94. package/src/runtime/agent/orchestrator/smart-bridge/cache-strategy.mjs +419 -0
  95. package/src/runtime/agent/orchestrator/stall-policy.mjs +227 -0
  96. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +235 -0
  97. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +723 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +389 -0
  99. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +637 -0
  100. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +165 -0
  101. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +104 -0
  102. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +194 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +596 -0
  104. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +110 -0
  105. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +153 -0
  106. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +118 -0
  107. package/src/runtime/agent/orchestrator/tools/builtin/read-open.mjs +189 -0
  108. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +731 -0
  109. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +168 -0
  110. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +602 -0
  111. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +465 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +160 -0
  113. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +982 -0
  114. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +1087 -0
  115. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +231 -0
  116. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +223 -0
  117. package/src/runtime/agent/orchestrator/tools/builtin.mjs +478 -0
  118. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +24 -0
  119. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4102 -0
  120. package/src/runtime/agent/orchestrator/tools/destructive-warning.mjs +323 -0
  121. package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +154 -0
  122. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +26 -0
  123. package/src/runtime/agent/orchestrator/tools/patch-binary-fetcher.mjs +143 -0
  124. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +18 -0
  125. package/src/runtime/agent/orchestrator/tools/patch.mjs +2772 -0
  126. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +114 -0
  127. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +880 -0
  128. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +312 -0
  129. package/src/runtime/channels/backends/discord.mjs +781 -0
  130. package/src/runtime/channels/data/voice-runtime-manifest.json +138 -0
  131. package/src/runtime/channels/index.mjs +3309 -0
  132. package/src/runtime/channels/lib/config.mjs +285 -0
  133. package/src/runtime/channels/lib/drop-trace.mjs +71 -0
  134. package/src/runtime/channels/lib/event-pipeline.mjs +81 -0
  135. package/src/runtime/channels/lib/holidays.mjs +138 -0
  136. package/src/runtime/channels/lib/hook-pipe-server.mjs +671 -0
  137. package/src/runtime/channels/lib/output-forwarder.mjs +765 -0
  138. package/src/runtime/channels/lib/runtime-paths.mjs +497 -0
  139. package/src/runtime/channels/lib/scheduler.mjs +710 -0
  140. package/src/runtime/channels/lib/session-discovery.mjs +102 -0
  141. package/src/runtime/channels/lib/state-file.mjs +68 -0
  142. package/src/runtime/channels/lib/status-snapshot.mjs +224 -0
  143. package/src/runtime/channels/lib/tool-format.mjs +124 -0
  144. package/src/runtime/channels/lib/transcript-discovery.mjs +195 -0
  145. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +734 -0
  146. package/src/runtime/channels/lib/webhook.mjs +1288 -0
  147. package/src/runtime/channels/tool-defs.mjs +177 -0
  148. package/src/runtime/lib/keychain-cjs.cjs +289 -0
  149. package/src/runtime/memory/data/runtime-manifest.json +40 -0
  150. package/src/runtime/memory/index.mjs +3600 -0
  151. package/src/runtime/memory/lib/core-memory-store.mjs +336 -0
  152. package/src/runtime/memory/lib/embedding-provider.mjs +275 -0
  153. package/src/runtime/memory/lib/embedding-worker.mjs +331 -0
  154. package/src/runtime/memory/lib/memory-cycle-requests.mjs +276 -0
  155. package/src/runtime/memory/lib/memory-cycle1.mjs +783 -0
  156. package/src/runtime/memory/lib/memory-cycle2.mjs +1389 -0
  157. package/src/runtime/memory/lib/memory-cycle3.mjs +646 -0
  158. package/src/runtime/memory/lib/memory-embed.mjs +300 -0
  159. package/src/runtime/memory/lib/memory-ops-policy.mjs +149 -0
  160. package/src/runtime/memory/lib/memory-recall-store.mjs +644 -0
  161. package/src/runtime/memory/lib/memory.mjs +418 -0
  162. package/src/runtime/memory/lib/pg/adapter.mjs +314 -0
  163. package/src/runtime/memory/lib/pg/process.mjs +366 -0
  164. package/src/runtime/memory/lib/pg/supervisor.mjs +495 -0
  165. package/src/runtime/memory/lib/runtime-fetcher.mjs +464 -0
  166. package/src/runtime/memory/lib/trace-store.mjs +734 -0
  167. package/src/runtime/memory/tool-defs.mjs +79 -0
  168. package/src/runtime/search/index.mjs +925 -0
  169. package/src/runtime/search/lib/config.mjs +61 -0
  170. package/src/runtime/search/lib/web-tools.mjs +1278 -0
  171. package/src/runtime/search/tool-defs.mjs +64 -0
  172. package/src/runtime/shared/atomic-file.mjs +435 -0
  173. package/src/runtime/shared/background-tasks.mjs +376 -0
  174. package/src/runtime/shared/child-guardian.mjs +98 -0
  175. package/src/runtime/shared/config.mjs +393 -0
  176. package/src/runtime/shared/err-text.mjs +121 -0
  177. package/src/runtime/shared/launcher-control.mjs +259 -0
  178. package/src/runtime/shared/llm/cost.mjs +66 -0
  179. package/src/runtime/shared/llm/http-agent.mjs +129 -0
  180. package/src/runtime/shared/open-url.mjs +37 -0
  181. package/src/runtime/shared/plugin-paths.mjs +25 -0
  182. package/src/runtime/shared/process-shutdown.mjs +147 -0
  183. package/src/runtime/shared/schedules-store.mjs +70 -0
  184. package/src/runtime/shared/tool-execution-contract.mjs +104 -0
  185. package/src/runtime/shared/tool-surface.mjs +950 -0
  186. package/src/runtime/shared/user-cwd.mjs +221 -0
  187. package/src/runtime/shared/user-data-guard.mjs +232 -0
  188. package/src/runtime/shared/workspace-router.mjs +259 -0
  189. package/src/standalone/bridge-tool.mjs +1414 -0
  190. package/src/standalone/channel-admin.mjs +366 -0
  191. package/src/standalone/channel-worker-preload.cjs +3 -0
  192. package/src/standalone/channel-worker.mjs +353 -0
  193. package/src/standalone/explore-tool.mjs +233 -0
  194. package/src/standalone/hook-bus.mjs +246 -0
  195. package/src/standalone/plugin-admin.mjs +247 -0
  196. package/src/standalone/provider-admin.mjs +338 -0
  197. package/src/standalone/seeds.mjs +94 -0
  198. package/src/standalone/usage-dashboard.mjs +510 -0
  199. package/src/tui/App.jsx +5438 -0
  200. package/src/tui/components/AnsiText.jsx +199 -0
  201. package/src/tui/components/ContextPanel.jsx +217 -0
  202. package/src/tui/components/Markdown.jsx +205 -0
  203. package/src/tui/components/MarkdownTable.jsx +204 -0
  204. package/src/tui/components/Message.jsx +103 -0
  205. package/src/tui/components/Picker.jsx +317 -0
  206. package/src/tui/components/PromptInput.jsx +584 -0
  207. package/src/tui/components/QueuedCommands.jsx +47 -0
  208. package/src/tui/components/SlashCommandPalette.jsx +114 -0
  209. package/src/tui/components/Spinner.jsx +317 -0
  210. package/src/tui/components/StatusLine.jsx +87 -0
  211. package/src/tui/components/TextEntryPanel.jsx +323 -0
  212. package/src/tui/components/ToolExecution.jsx +772 -0
  213. package/src/tui/components/TurnDone.jsx +78 -0
  214. package/src/tui/components/UsagePanel.jsx +331 -0
  215. package/src/tui/dist/index.mjs +12359 -0
  216. package/src/tui/engine.mjs +2410 -0
  217. package/src/tui/figures.mjs +50 -0
  218. package/src/tui/hooks/useEngine.mjs +16 -0
  219. package/src/tui/index.jsx +254 -0
  220. package/src/tui/input-editing.mjs +242 -0
  221. package/src/tui/markdown/format-token.mjs +194 -0
  222. package/src/tui/paste-attachments.mjs +198 -0
  223. package/src/tui/runtime/shared/process-shutdown.mjs +1 -0
  224. package/src/tui/spinner-verbs.mjs +45 -0
  225. package/src/tui/theme.mjs +67 -0
  226. package/src/tui/time-format.mjs +53 -0
  227. package/src/ui/ansi.mjs +115 -0
  228. package/src/ui/markdown.mjs +195 -0
  229. package/src/ui/statusline.mjs +730 -0
  230. package/src/ui/tool-card.mjs +101 -0
  231. package/src/vendor/statusline/bin/statusline-lib.mjs +805 -0
  232. package/src/vendor/statusline/bin/statusline-route.mjs +596 -0
  233. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +285 -0
  234. package/src/vendor/statusline/src/gateway/claude-current.mjs +320 -0
  235. package/src/vendor/statusline/src/gateway/route-meta.mjs +753 -0
  236. package/src/vendor/statusline/src/gateway/session-routes.mjs +244 -0
  237. package/src/workflows/default/WORKFLOW.md +7 -0
  238. package/src/workflows/default/workflow.json +14 -0
  239. package/vendor/ink/build/ansi-tokenizer.d.ts +38 -0
  240. package/vendor/ink/build/ansi-tokenizer.js +316 -0
  241. package/vendor/ink/build/ansi-tokenizer.js.map +1 -0
  242. package/vendor/ink/build/colorize.d.ts +3 -0
  243. package/vendor/ink/build/colorize.js +48 -0
  244. package/vendor/ink/build/colorize.js.map +1 -0
  245. package/vendor/ink/build/components/AccessibilityContext.d.ts +3 -0
  246. package/vendor/ink/build/components/AccessibilityContext.js +5 -0
  247. package/vendor/ink/build/components/AccessibilityContext.js.map +1 -0
  248. package/vendor/ink/build/components/AnimationContext.d.ts +9 -0
  249. package/vendor/ink/build/components/AnimationContext.js +13 -0
  250. package/vendor/ink/build/components/AnimationContext.js.map +1 -0
  251. package/vendor/ink/build/components/App.d.ts +24 -0
  252. package/vendor/ink/build/components/App.js +554 -0
  253. package/vendor/ink/build/components/App.js.map +1 -0
  254. package/vendor/ink/build/components/AppContext.d.ts +80 -0
  255. package/vendor/ink/build/components/AppContext.js +25 -0
  256. package/vendor/ink/build/components/AppContext.js.map +1 -0
  257. package/vendor/ink/build/components/BackgroundContext.d.ts +4 -0
  258. package/vendor/ink/build/components/BackgroundContext.js +3 -0
  259. package/vendor/ink/build/components/BackgroundContext.js.map +1 -0
  260. package/vendor/ink/build/components/Box.d.ts +130 -0
  261. package/vendor/ink/build/components/Box.js +34 -0
  262. package/vendor/ink/build/components/Box.js.map +1 -0
  263. package/vendor/ink/build/components/CursorContext.d.ts +11 -0
  264. package/vendor/ink/build/components/CursorContext.js +8 -0
  265. package/vendor/ink/build/components/CursorContext.js.map +1 -0
  266. package/vendor/ink/build/components/ErrorBoundary.d.ts +18 -0
  267. package/vendor/ink/build/components/ErrorBoundary.js +23 -0
  268. package/vendor/ink/build/components/ErrorBoundary.js.map +1 -0
  269. package/vendor/ink/build/components/ErrorOverview.d.ts +6 -0
  270. package/vendor/ink/build/components/ErrorOverview.js +90 -0
  271. package/vendor/ink/build/components/ErrorOverview.js.map +1 -0
  272. package/vendor/ink/build/components/FocusContext.d.ts +16 -0
  273. package/vendor/ink/build/components/FocusContext.js +17 -0
  274. package/vendor/ink/build/components/FocusContext.js.map +1 -0
  275. package/vendor/ink/build/components/Newline.d.ts +13 -0
  276. package/vendor/ink/build/components/Newline.js +8 -0
  277. package/vendor/ink/build/components/Newline.js.map +1 -0
  278. package/vendor/ink/build/components/Spacer.d.ts +7 -0
  279. package/vendor/ink/build/components/Spacer.js +11 -0
  280. package/vendor/ink/build/components/Spacer.js.map +1 -0
  281. package/vendor/ink/build/components/Static.d.ts +24 -0
  282. package/vendor/ink/build/components/Static.js +28 -0
  283. package/vendor/ink/build/components/Static.js.map +1 -0
  284. package/vendor/ink/build/components/StderrContext.d.ts +15 -0
  285. package/vendor/ink/build/components/StderrContext.js +13 -0
  286. package/vendor/ink/build/components/StderrContext.js.map +1 -0
  287. package/vendor/ink/build/components/StdinContext.d.ts +28 -0
  288. package/vendor/ink/build/components/StdinContext.js +20 -0
  289. package/vendor/ink/build/components/StdinContext.js.map +1 -0
  290. package/vendor/ink/build/components/StdoutContext.d.ts +15 -0
  291. package/vendor/ink/build/components/StdoutContext.js +13 -0
  292. package/vendor/ink/build/components/StdoutContext.js.map +1 -0
  293. package/vendor/ink/build/components/Text.d.ts +55 -0
  294. package/vendor/ink/build/components/Text.js +50 -0
  295. package/vendor/ink/build/components/Text.js.map +1 -0
  296. package/vendor/ink/build/components/Transform.d.ts +16 -0
  297. package/vendor/ink/build/components/Transform.js +15 -0
  298. package/vendor/ink/build/components/Transform.js.map +1 -0
  299. package/vendor/ink/build/cursor-helpers.d.ts +39 -0
  300. package/vendor/ink/build/cursor-helpers.js +62 -0
  301. package/vendor/ink/build/cursor-helpers.js.map +1 -0
  302. package/vendor/ink/build/devtools-window-polyfill.d.ts +1 -0
  303. package/vendor/ink/build/devtools-window-polyfill.js +68 -0
  304. package/vendor/ink/build/devtools-window-polyfill.js.map +1 -0
  305. package/vendor/ink/build/devtools.d.ts +1 -0
  306. package/vendor/ink/build/devtools.js +36 -0
  307. package/vendor/ink/build/devtools.js.map +1 -0
  308. package/vendor/ink/build/dom.d.ts +62 -0
  309. package/vendor/ink/build/dom.js +143 -0
  310. package/vendor/ink/build/dom.js.map +1 -0
  311. package/vendor/ink/build/get-max-width.d.ts +3 -0
  312. package/vendor/ink/build/get-max-width.js +10 -0
  313. package/vendor/ink/build/get-max-width.js.map +1 -0
  314. package/vendor/ink/build/hooks/use-animation.d.ts +49 -0
  315. package/vendor/ink/build/hooks/use-animation.js +87 -0
  316. package/vendor/ink/build/hooks/use-animation.js.map +1 -0
  317. package/vendor/ink/build/hooks/use-app.d.ts +5 -0
  318. package/vendor/ink/build/hooks/use-app.js +8 -0
  319. package/vendor/ink/build/hooks/use-app.js.map +1 -0
  320. package/vendor/ink/build/hooks/use-box-metrics.d.ts +59 -0
  321. package/vendor/ink/build/hooks/use-box-metrics.js +81 -0
  322. package/vendor/ink/build/hooks/use-box-metrics.js.map +1 -0
  323. package/vendor/ink/build/hooks/use-cursor.d.ts +12 -0
  324. package/vendor/ink/build/hooks/use-cursor.js +29 -0
  325. package/vendor/ink/build/hooks/use-cursor.js.map +1 -0
  326. package/vendor/ink/build/hooks/use-focus-manager.d.ts +43 -0
  327. package/vendor/ink/build/hooks/use-focus-manager.js +18 -0
  328. package/vendor/ink/build/hooks/use-focus-manager.js.map +1 -0
  329. package/vendor/ink/build/hooks/use-focus.d.ts +30 -0
  330. package/vendor/ink/build/hooks/use-focus.js +43 -0
  331. package/vendor/ink/build/hooks/use-focus.js.map +1 -0
  332. package/vendor/ink/build/hooks/use-input.d.ts +132 -0
  333. package/vendor/ink/build/hooks/use-input.js +126 -0
  334. package/vendor/ink/build/hooks/use-input.js.map +1 -0
  335. package/vendor/ink/build/hooks/use-is-screen-reader-enabled.d.ts +6 -0
  336. package/vendor/ink/build/hooks/use-is-screen-reader-enabled.js +12 -0
  337. package/vendor/ink/build/hooks/use-is-screen-reader-enabled.js.map +1 -0
  338. package/vendor/ink/build/hooks/use-paste.d.ts +35 -0
  339. package/vendor/ink/build/hooks/use-paste.js +62 -0
  340. package/vendor/ink/build/hooks/use-paste.js.map +1 -0
  341. package/vendor/ink/build/hooks/use-stderr.d.ts +5 -0
  342. package/vendor/ink/build/hooks/use-stderr.js +8 -0
  343. package/vendor/ink/build/hooks/use-stderr.js.map +1 -0
  344. package/vendor/ink/build/hooks/use-stdin.d.ts +7 -0
  345. package/vendor/ink/build/hooks/use-stdin.js +9 -0
  346. package/vendor/ink/build/hooks/use-stdin.js.map +1 -0
  347. package/vendor/ink/build/hooks/use-stdout.d.ts +5 -0
  348. package/vendor/ink/build/hooks/use-stdout.js +8 -0
  349. package/vendor/ink/build/hooks/use-stdout.js.map +1 -0
  350. package/vendor/ink/build/hooks/use-window-size.d.ts +18 -0
  351. package/vendor/ink/build/hooks/use-window-size.js +22 -0
  352. package/vendor/ink/build/hooks/use-window-size.js.map +1 -0
  353. package/vendor/ink/build/index.d.ts +42 -0
  354. package/vendor/ink/build/index.js +24 -0
  355. package/vendor/ink/build/index.js.map +1 -0
  356. package/vendor/ink/build/ink.d.ts +146 -0
  357. package/vendor/ink/build/ink.js +1022 -0
  358. package/vendor/ink/build/ink.js.map +1 -0
  359. package/vendor/ink/build/input-parser.d.ts +10 -0
  360. package/vendor/ink/build/input-parser.js +194 -0
  361. package/vendor/ink/build/input-parser.js.map +1 -0
  362. package/vendor/ink/build/instances.d.ts +3 -0
  363. package/vendor/ink/build/instances.js +8 -0
  364. package/vendor/ink/build/instances.js.map +1 -0
  365. package/vendor/ink/build/kitty-keyboard.d.ts +23 -0
  366. package/vendor/ink/build/kitty-keyboard.js +32 -0
  367. package/vendor/ink/build/kitty-keyboard.js.map +1 -0
  368. package/vendor/ink/build/log-update.d.ts +20 -0
  369. package/vendor/ink/build/log-update.js +261 -0
  370. package/vendor/ink/build/log-update.js.map +1 -0
  371. package/vendor/ink/build/measure-element.d.ts +20 -0
  372. package/vendor/ink/build/measure-element.js +13 -0
  373. package/vendor/ink/build/measure-element.js.map +1 -0
  374. package/vendor/ink/build/measure-text.d.ts +6 -0
  375. package/vendor/ink/build/measure-text.js +21 -0
  376. package/vendor/ink/build/measure-text.js.map +1 -0
  377. package/vendor/ink/build/output.d.ts +35 -0
  378. package/vendor/ink/build/output.js +328 -0
  379. package/vendor/ink/build/output.js.map +1 -0
  380. package/vendor/ink/build/parse-keypress.d.ts +20 -0
  381. package/vendor/ink/build/parse-keypress.js +495 -0
  382. package/vendor/ink/build/parse-keypress.js.map +1 -0
  383. package/vendor/ink/build/reconciler.d.ts +4 -0
  384. package/vendor/ink/build/reconciler.js +306 -0
  385. package/vendor/ink/build/reconciler.js.map +1 -0
  386. package/vendor/ink/build/render-background.d.ts +4 -0
  387. package/vendor/ink/build/render-background.js +25 -0
  388. package/vendor/ink/build/render-background.js.map +1 -0
  389. package/vendor/ink/build/render-border.d.ts +4 -0
  390. package/vendor/ink/build/render-border.js +84 -0
  391. package/vendor/ink/build/render-border.js.map +1 -0
  392. package/vendor/ink/build/render-node-to-output.d.ts +14 -0
  393. package/vendor/ink/build/render-node-to-output.js +162 -0
  394. package/vendor/ink/build/render-node-to-output.js.map +1 -0
  395. package/vendor/ink/build/render-to-string.d.ts +38 -0
  396. package/vendor/ink/build/render-to-string.js +116 -0
  397. package/vendor/ink/build/render-to-string.js.map +1 -0
  398. package/vendor/ink/build/render.d.ts +176 -0
  399. package/vendor/ink/build/render.js +71 -0
  400. package/vendor/ink/build/render.js.map +1 -0
  401. package/vendor/ink/build/renderer.d.ts +8 -0
  402. package/vendor/ink/build/renderer.js +64 -0
  403. package/vendor/ink/build/renderer.js.map +1 -0
  404. package/vendor/ink/build/sanitize-ansi.d.ts +2 -0
  405. package/vendor/ink/build/sanitize-ansi.js +27 -0
  406. package/vendor/ink/build/sanitize-ansi.js.map +1 -0
  407. package/vendor/ink/build/squash-text-nodes.d.ts +3 -0
  408. package/vendor/ink/build/squash-text-nodes.js +36 -0
  409. package/vendor/ink/build/squash-text-nodes.js.map +1 -0
  410. package/vendor/ink/build/styles.d.ts +302 -0
  411. package/vendor/ink/build/styles.js +303 -0
  412. package/vendor/ink/build/styles.js.map +1 -0
  413. package/vendor/ink/build/utils.d.ts +9 -0
  414. package/vendor/ink/build/utils.js +19 -0
  415. package/vendor/ink/build/utils.js.map +1 -0
  416. package/vendor/ink/build/wrap-text.d.ts +3 -0
  417. package/vendor/ink/build/wrap-text.js +38 -0
  418. package/vendor/ink/build/wrap-text.js.map +1 -0
  419. package/vendor/ink/build/write-synchronized.d.ts +4 -0
  420. package/vendor/ink/build/write-synchronized.js +9 -0
  421. package/vendor/ink/build/write-synchronized.js.map +1 -0
  422. package/vendor/ink/license +10 -0
  423. package/vendor/ink/package.json +137 -0
  424. package/.claude-plugin/marketplace.json +0 -34
  425. package/.claude-plugin/plugin.json +0 -20
  426. package/.gitattributes +0 -34
  427. package/.mcp.json +0 -14
  428. package/ARCHITECTURE.md +0 -77
  429. package/CHANGELOG.md +0 -30
  430. package/CONTRIBUTING.md +0 -45
  431. package/DATA-FLOW.md +0 -79
  432. package/LICENSE +0 -21
  433. package/SECURITY.md +0 -138
  434. package/UNINSTALL.md +0 -112
  435. package/agents/maintenance.md +0 -5
  436. package/agents/memory-classification.md +0 -30
  437. package/agents/scheduler-task.md +0 -18
  438. package/agents/webhook-handler.md +0 -27
  439. package/agents/worker.md +0 -24
  440. package/bin/bridge +0 -133
  441. package/bin/statusline-launcher.mjs +0 -82
  442. package/bin/statusline-lib.mjs +0 -558
  443. package/bin/statusline.mjs +0 -615
  444. package/bun.lock +0 -927
  445. package/commands/config.md +0 -16
  446. package/commands/doctor.md +0 -13
  447. package/commands/setup.md +0 -17
  448. package/defaults/hidden-roles.json +0 -68
  449. package/defaults/memory-chunk-prompt.md +0 -63
  450. package/defaults/mixdog-config.template.json +0 -27
  451. package/defaults/user-workflow.json +0 -8
  452. package/defaults/user-workflow.md +0 -17
  453. package/hooks/hooks.json +0 -73
  454. package/hooks/lib/active-instance.cjs +0 -77
  455. package/hooks/lib/permission-evaluator.cjs +0 -411
  456. package/hooks/lib/permission-route.cjs +0 -63
  457. package/hooks/lib/settings-loader.cjs +0 -117
  458. package/hooks/post-tool-use.cjs +0 -84
  459. package/hooks/pre-mcp-sandbox.cjs +0 -158
  460. package/hooks/pre-tool-subagent.cjs +0 -258
  461. package/hooks/session-start.cjs +0 -1479
  462. package/hooks/shim-launcher.cjs +0 -65
  463. package/hooks/turn-timer.cjs +0 -82
  464. package/lib/claude-md-writer.cjs +0 -386
  465. package/lib/keychain-cjs.cjs +0 -290
  466. package/lib/plugin-paths.cjs +0 -69
  467. package/lib/rules-builder.cjs +0 -241
  468. package/native/README.md +0 -117
  469. package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
  470. package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
  471. package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
  472. package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
  473. package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
  474. package/prompts/code-review.txt +0 -16
  475. package/prompts/security-audit.txt +0 -17
  476. package/rules/bridge/00-common.md +0 -39
  477. package/rules/bridge/20-skip-protocol.md +0 -18
  478. package/rules/bridge/30-explorer.md +0 -33
  479. package/rules/bridge/40-cycle1-agent.md +0 -52
  480. package/rules/bridge/41-cycle2-agent.md +0 -62
  481. package/rules/lead/00-tool-lead.md +0 -61
  482. package/rules/lead/01-general.md +0 -23
  483. package/rules/lead/02-channels.md +0 -49
  484. package/rules/lead/03-team.md +0 -27
  485. package/rules/lead/04-workflow.md +0 -20
  486. package/rules/shared/00-language.md +0 -14
  487. package/rules/shared/01-tool.md +0 -138
  488. package/scripts/bootstrap.mjs +0 -130
  489. package/scripts/bridge-unify-smoke.mjs +0 -308
  490. package/scripts/build-runtime-linux.sh +0 -348
  491. package/scripts/build-runtime-macos.sh +0 -217
  492. package/scripts/build-runtime-windows.ps1 +0 -242
  493. package/scripts/builtin-utils-smoke.mjs +0 -398
  494. package/scripts/bump.mjs +0 -80
  495. package/scripts/check-json.mjs +0 -45
  496. package/scripts/check-syntax-changed.mjs +0 -102
  497. package/scripts/check-syntax.mjs +0 -58
  498. package/scripts/code-graph-batch.test.mjs +0 -33
  499. package/scripts/config-preserve-smoke.mjs +0 -180
  500. package/scripts/doctor.mjs +0 -489
  501. package/scripts/edit-normalize-fuzz.mjs +0 -130
  502. package/scripts/edit-normalize-smoke.mjs +0 -401
  503. package/scripts/edit-operation-smoke.mjs +0 -369
  504. package/scripts/edit2-smoke.mjs +0 -63
  505. package/scripts/ensure-deps.mjs +0 -259
  506. package/scripts/fuzzy-e2e.mjs +0 -28
  507. package/scripts/fuzzy-smoke.mjs +0 -26
  508. package/scripts/generate-runtime-manifest.mjs +0 -166
  509. package/scripts/guard-smoke.mjs +0 -66
  510. package/scripts/hidden-role-schema-smoke.mjs +0 -162
  511. package/scripts/hook-routing-smoke.mjs +0 -29
  512. package/scripts/inject-input.ps1 +0 -204
  513. package/scripts/io-complex-smoke.mjs +0 -667
  514. package/scripts/io-explore-bench.mjs +0 -424
  515. package/scripts/io-guardrails-smoke.mjs +0 -205
  516. package/scripts/io-mini-bench-baseline.json +0 -11
  517. package/scripts/io-mini-bench.mjs +0 -216
  518. package/scripts/io-route-harness.mjs +0 -933
  519. package/scripts/io-telemetry-report.mjs +0 -691
  520. package/scripts/mutation-bench.mjs +0 -564
  521. package/scripts/mutation-io-smoke.mjs +0 -1097
  522. package/scripts/native-patch-bridge-smoke.mjs +0 -288
  523. package/scripts/native-patch-smoke.mjs +0 -304
  524. package/scripts/openai-oauth-catalog-smoke.mjs +0 -53
  525. package/scripts/patch-interior-context-smoke.mjs +0 -49
  526. package/scripts/patch-newline-utf8-smoke.mjs +0 -157
  527. package/scripts/perf-hook-smoke.mjs +0 -71
  528. package/scripts/permission-eval-smoke.mjs +0 -443
  529. package/scripts/prep-patch.mjs +0 -53
  530. package/scripts/prep-shim.mjs +0 -96
  531. package/scripts/provider-cache-smoke.mjs +0 -687
  532. package/scripts/report-runtime-health.mjs +0 -132
  533. package/scripts/resolve-bun.mjs +0 -60
  534. package/scripts/run-mcp.mjs +0 -1448
  535. package/scripts/salvage-v4a-shatter.test.mjs +0 -58
  536. package/scripts/scoped-cache-io-smoke.mjs +0 -103
  537. package/scripts/shell-policy-round3-smoke.mjs +0 -46
  538. package/scripts/smoke-runtime-negative.ps1 +0 -100
  539. package/scripts/smoke-runtime-negative.sh +0 -95
  540. package/scripts/stall-policy-smoke.mjs +0 -50
  541. package/scripts/start-memory-worker.mjs +0 -23
  542. package/scripts/statusline-launcher-smoke.mjs +0 -82
  543. package/scripts/stress-atomic-write.mjs +0 -1028
  544. package/scripts/test-fault-inject.mjs +0 -164
  545. package/scripts/test-large-file.mjs +0 -174
  546. package/scripts/tool-edge-smoke.mjs +0 -209
  547. package/scripts/uninstall.mjs +0 -201
  548. package/scripts/webhook-selfheal-smoke.mjs +0 -27
  549. package/scripts/write-overwrite-guard-smoke.mjs +0 -56
  550. package/server-main.mjs +0 -3109
  551. package/server.mjs +0 -468
  552. package/setup/config-merge.mjs +0 -246
  553. package/setup/install.mjs +0 -574
  554. package/setup/launch-core.mjs +0 -617
  555. package/setup/launch.mjs +0 -101
  556. package/setup/locate-claude.mjs +0 -56
  557. package/setup/mixdog-cli.mjs +0 -122
  558. package/setup/setup-server.mjs +0 -3305
  559. package/setup/setup.html +0 -3740
  560. package/setup/tui.mjs +0 -325
  561. package/skills/retro-skill-proposer/SKILL.md +0 -92
  562. package/skills/schedule-add/SKILL.md +0 -77
  563. package/skills/setup/SKILL.md +0 -356
  564. package/skills/webhook-add/SKILL.md +0 -81
  565. package/src/agent/bridge-stall-watchdog.mjs +0 -337
  566. package/src/agent/index.mjs +0 -2138
  567. package/src/agent/orchestrator/ai-wrapped-dispatch.mjs +0 -1010
  568. package/src/agent/orchestrator/bridge-retry.mjs +0 -220
  569. package/src/agent/orchestrator/bridge-trace.mjs +0 -583
  570. package/src/agent/orchestrator/cache-mtime.mjs +0 -58
  571. package/src/agent/orchestrator/config.mjs +0 -405
  572. package/src/agent/orchestrator/context/collect.mjs +0 -651
  573. package/src/agent/orchestrator/dispatch-persist.mjs +0 -549
  574. package/src/agent/orchestrator/drain-registry.mjs +0 -50
  575. package/src/agent/orchestrator/explore-validator.mjs +0 -8
  576. package/src/agent/orchestrator/internal-roles.mjs +0 -118
  577. package/src/agent/orchestrator/internal-tools.mjs +0 -88
  578. package/src/agent/orchestrator/jobs.mjs +0 -116
  579. package/src/agent/orchestrator/mcp/client.mjs +0 -364
  580. package/src/agent/orchestrator/providers/anthropic-oauth.mjs +0 -1881
  581. package/src/agent/orchestrator/providers/anthropic.mjs +0 -594
  582. package/src/agent/orchestrator/providers/gemini.mjs +0 -1530
  583. package/src/agent/orchestrator/providers/grok-oauth.mjs +0 -779
  584. package/src/agent/orchestrator/providers/model-catalog.mjs +0 -374
  585. package/src/agent/orchestrator/providers/openai-compat-stream.mjs +0 -366
  586. package/src/agent/orchestrator/providers/openai-compat.mjs +0 -1511
  587. package/src/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -1891
  588. package/src/agent/orchestrator/providers/openai-oauth.mjs +0 -1456
  589. package/src/agent/orchestrator/providers/openai-ws.mjs +0 -127
  590. package/src/agent/orchestrator/providers/registry.mjs +0 -192
  591. package/src/agent/orchestrator/providers/retry-classifier.mjs +0 -325
  592. package/src/agent/orchestrator/session/cache/prefetch-cache.mjs +0 -142
  593. package/src/agent/orchestrator/session/cache/read-cache.mjs +0 -319
  594. package/src/agent/orchestrator/session/cache/scoped-cache.mjs +0 -361
  595. package/src/agent/orchestrator/session/loop.mjs +0 -1478
  596. package/src/agent/orchestrator/session/manager.mjs +0 -1991
  597. package/src/agent/orchestrator/session/result-classification.mjs +0 -65
  598. package/src/agent/orchestrator/session/store.mjs +0 -632
  599. package/src/agent/orchestrator/session/stream-watchdog.mjs +0 -130
  600. package/src/agent/orchestrator/session/tool-result-offload.mjs +0 -166
  601. package/src/agent/orchestrator/session/trim.mjs +0 -491
  602. package/src/agent/orchestrator/smart-bridge/CACHE-SHARD.md +0 -115
  603. package/src/agent/orchestrator/smart-bridge/bridge-llm.mjs +0 -331
  604. package/src/agent/orchestrator/smart-bridge/cache-obs.mjs +0 -150
  605. package/src/agent/orchestrator/smart-bridge/cache-strategy.mjs +0 -228
  606. package/src/agent/orchestrator/smart-bridge/index.mjs +0 -215
  607. package/src/agent/orchestrator/smart-bridge/profiles.mjs +0 -37
  608. package/src/agent/orchestrator/smart-bridge/registry.mjs +0 -348
  609. package/src/agent/orchestrator/stall-policy.mjs +0 -201
  610. package/src/agent/orchestrator/tool-loop-guard.mjs +0 -75
  611. package/src/agent/orchestrator/tools/bash-session.mjs +0 -722
  612. package/src/agent/orchestrator/tools/builtin/arg-guard.mjs +0 -455
  613. package/src/agent/orchestrator/tools/builtin/bash-tool.mjs +0 -480
  614. package/src/agent/orchestrator/tools/builtin/builtin-tools.mjs +0 -256
  615. package/src/agent/orchestrator/tools/builtin/edit-base-guard.mjs +0 -58
  616. package/src/agent/orchestrator/tools/builtin/edit-byte-plan.mjs +0 -240
  617. package/src/agent/orchestrator/tools/builtin/edit-byte-utils.mjs +0 -113
  618. package/src/agent/orchestrator/tools/builtin/edit-commit.mjs +0 -74
  619. package/src/agent/orchestrator/tools/builtin/edit-context-utils.mjs +0 -242
  620. package/src/agent/orchestrator/tools/builtin/edit-diagnostics.mjs +0 -211
  621. package/src/agent/orchestrator/tools/builtin/edit-engine.mjs +0 -1364
  622. package/src/agent/orchestrator/tools/builtin/edit-failure-context.mjs +0 -126
  623. package/src/agent/orchestrator/tools/builtin/edit-hint.mjs +0 -141
  624. package/src/agent/orchestrator/tools/builtin/edit-match-utils.mjs +0 -194
  625. package/src/agent/orchestrator/tools/builtin/edit-partial-write.mjs +0 -60
  626. package/src/agent/orchestrator/tools/builtin/edit-stale-refresh.mjs +0 -168
  627. package/src/agent/orchestrator/tools/builtin/edit-tool.mjs +0 -173
  628. package/src/agent/orchestrator/tools/builtin/edit-utf8-guard.mjs +0 -48
  629. package/src/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -99
  630. package/src/agent/orchestrator/tools/builtin/glob-walk.mjs +0 -193
  631. package/src/agent/orchestrator/tools/builtin/list-tool.mjs +0 -597
  632. package/src/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  633. package/src/agent/orchestrator/tools/builtin/notebook-edit-tool.mjs +0 -300
  634. package/src/agent/orchestrator/tools/builtin/path-diagnostics.mjs +0 -152
  635. package/src/agent/orchestrator/tools/builtin/read-formatting.mjs +0 -118
  636. package/src/agent/orchestrator/tools/builtin/read-open.mjs +0 -190
  637. package/src/agent/orchestrator/tools/builtin/read-single-tool.mjs +0 -728
  638. package/src/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +0 -173
  639. package/src/agent/orchestrator/tools/builtin/read-streaming.mjs +0 -602
  640. package/src/agent/orchestrator/tools/builtin/rename-tool.mjs +0 -196
  641. package/src/agent/orchestrator/tools/builtin/rg-runner.mjs +0 -422
  642. package/src/agent/orchestrator/tools/builtin/search-builders.mjs +0 -158
  643. package/src/agent/orchestrator/tools/builtin/search-tool.mjs +0 -869
  644. package/src/agent/orchestrator/tools/builtin/shell-jobs.mjs +0 -962
  645. package/src/agent/orchestrator/tools/builtin/shell-runtime.mjs +0 -223
  646. package/src/agent/orchestrator/tools/builtin/snapshot-store.mjs +0 -206
  647. package/src/agent/orchestrator/tools/builtin/write-tool.mjs +0 -401
  648. package/src/agent/orchestrator/tools/builtin.mjs +0 -503
  649. package/src/agent/orchestrator/tools/code-graph-tool-defs.mjs +0 -24
  650. package/src/agent/orchestrator/tools/code-graph.mjs +0 -4095
  651. package/src/agent/orchestrator/tools/cwd-tool.mjs +0 -298
  652. package/src/agent/orchestrator/tools/destructive-warning.mjs +0 -323
  653. package/src/agent/orchestrator/tools/edit-normalize.mjs +0 -603
  654. package/src/agent/orchestrator/tools/graph-binary-fetcher.mjs +0 -154
  655. package/src/agent/orchestrator/tools/graph-manifest.json +0 -26
  656. package/src/agent/orchestrator/tools/host-input.mjs +0 -204
  657. package/src/agent/orchestrator/tools/patch-binary-fetcher.mjs +0 -143
  658. package/src/agent/orchestrator/tools/patch-manifest.json +0 -26
  659. package/src/agent/orchestrator/tools/patch-tool-defs.mjs +0 -20
  660. package/src/agent/orchestrator/tools/patch.mjs +0 -2754
  661. package/src/agent/orchestrator/tools/progress-message.mjs +0 -118
  662. package/src/agent/orchestrator/tools/shell-command.mjs +0 -865
  663. package/src/agent/orchestrator/tools/shell-policy-imports.mjs +0 -7
  664. package/src/agent/orchestrator/tools/shell-snapshot.mjs +0 -313
  665. package/src/agent/orchestrator/workflow-store.mjs +0 -93
  666. package/src/agent/tool-defs.mjs +0 -103
  667. package/src/channels/backends/discord.mjs +0 -784
  668. package/src/channels/data/voice-runtime-manifest.json +0 -138
  669. package/src/channels/index.mjs +0 -3268
  670. package/src/channels/lib/config.mjs +0 -292
  671. package/src/channels/lib/drop-trace.mjs +0 -71
  672. package/src/channels/lib/event-pipeline.mjs +0 -81
  673. package/src/channels/lib/holidays.mjs +0 -138
  674. package/src/channels/lib/hook-pipe-server.mjs +0 -822
  675. package/src/channels/lib/output-forwarder.mjs +0 -765
  676. package/src/channels/lib/runtime-paths.mjs +0 -517
  677. package/src/channels/lib/scheduler.mjs +0 -723
  678. package/src/channels/lib/session-discovery.mjs +0 -103
  679. package/src/channels/lib/state-file.mjs +0 -68
  680. package/src/channels/lib/status-snapshot.mjs +0 -219
  681. package/src/channels/lib/tool-format.mjs +0 -140
  682. package/src/channels/lib/transcript-discovery.mjs +0 -195
  683. package/src/channels/lib/voice-runtime-fetcher.mjs +0 -734
  684. package/src/channels/lib/webhook.mjs +0 -1318
  685. package/src/channels/tool-defs.mjs +0 -170
  686. package/src/daemon/host.mjs +0 -118
  687. package/src/daemon/mcp-transport.mjs +0 -47
  688. package/src/daemon/session.mjs +0 -100
  689. package/src/daemon/thin-client.mjs +0 -71
  690. package/src/daemon/transport.mjs +0 -163
  691. package/src/memory/data/runtime-manifest.json +0 -40
  692. package/src/memory/index.mjs +0 -3332
  693. package/src/memory/lib/core-memory-store.mjs +0 -330
  694. package/src/memory/lib/embedding-provider.mjs +0 -269
  695. package/src/memory/lib/embedding-worker.mjs +0 -323
  696. package/src/memory/lib/memory-cycle1.mjs +0 -645
  697. package/src/memory/lib/memory-cycle2.mjs +0 -1284
  698. package/src/memory/lib/memory-cycle3.mjs +0 -540
  699. package/src/memory/lib/memory-embed.mjs +0 -299
  700. package/src/memory/lib/memory-ops-policy.mjs +0 -190
  701. package/src/memory/lib/memory-recall-store.mjs +0 -638
  702. package/src/memory/lib/memory.mjs +0 -412
  703. package/src/memory/lib/pg/adapter.mjs +0 -308
  704. package/src/memory/lib/pg/process.mjs +0 -360
  705. package/src/memory/lib/pg/supervisor.mjs +0 -396
  706. package/src/memory/lib/runtime-fetcher.mjs +0 -458
  707. package/src/memory/lib/trace-store.mjs +0 -728
  708. package/src/memory/tool-defs.mjs +0 -79
  709. package/src/search/index.mjs +0 -1173
  710. package/src/search/lib/backends/anthropic-oauth.mjs +0 -98
  711. package/src/search/lib/backends/exa.mjs +0 -50
  712. package/src/search/lib/backends/firecrawl.mjs +0 -61
  713. package/src/search/lib/backends/gemini-api.mjs +0 -83
  714. package/src/search/lib/backends/grok-oauth.mjs +0 -86
  715. package/src/search/lib/backends/index.mjs +0 -150
  716. package/src/search/lib/backends/openai-api.mjs +0 -144
  717. package/src/search/lib/backends/openai-oauth.mjs +0 -102
  718. package/src/search/lib/backends/openai-web-search.mjs +0 -76
  719. package/src/search/lib/backends/tavily.mjs +0 -55
  720. package/src/search/lib/backends/xai-api.mjs +0 -113
  721. package/src/search/lib/config.mjs +0 -192
  722. package/src/search/lib/provider-usage.mjs +0 -67
  723. package/src/search/lib/providers.mjs +0 -47
  724. package/src/search/lib/search-intent.mjs +0 -109
  725. package/src/search/lib/setup-handler.mjs +0 -261
  726. package/src/search/lib/web-tools.mjs +0 -1219
  727. package/src/search/tool-defs.mjs +0 -83
  728. package/src/setup/defender-exclusion.mjs +0 -183
  729. package/src/shared/atomic-file.mjs +0 -436
  730. package/src/shared/config.mjs +0 -372
  731. package/src/shared/daemon-recycle.mjs +0 -108
  732. package/src/shared/disable-claude-builtins.mjs +0 -91
  733. package/src/shared/err-text.mjs +0 -12
  734. package/src/shared/llm/cost.mjs +0 -66
  735. package/src/shared/llm/http-agent.mjs +0 -123
  736. package/src/shared/open-url.mjs +0 -62
  737. package/src/shared/plugin-paths.mjs +0 -58
  738. package/src/shared/schedules-store.mjs +0 -70
  739. package/src/shared/seed.mjs +0 -136
  740. package/src/shared/user-cwd.mjs +0 -225
  741. package/src/shared/user-data-guard.mjs +0 -244
  742. package/src/status/aggregator.mjs +0 -584
  743. package/src/status/server.mjs +0 -413
  744. package/tools.json +0 -1653
  745. /package/{defaults → src/defaults}/cycle3-review-prompt.md +0 -0
  746. /package/{defaults → src/defaults}/memory-promote-prompt.md +0 -0
  747. /package/{hooks → src/hooks}/lib/permission-rules.cjs +0 -0
  748. /package/{lib → src/lib}/config-cjs.cjs +0 -0
  749. /package/{lib → src/lib}/hook-pipe-path.cjs +0 -0
  750. /package/{lib → src/lib}/mixdog-debug.cjs +0 -0
  751. /package/{lib → src/lib}/text-utils.cjs +0 -0
  752. /package/{rules → src/rules}/bridge/42-cycle3-agent.md +0 -0
  753. /package/src/{agent → runtime/agent}/orchestrator/activity-bus.mjs +0 -0
  754. /package/src/{agent → runtime/agent}/orchestrator/providers/anthropic-betas.mjs +0 -0
  755. /package/src/{agent → runtime/agent}/orchestrator/session/abort-lookup.mjs +0 -0
  756. /package/src/{agent → runtime/agent}/orchestrator/session/cache/post-edit-marks.mjs +0 -0
  757. /package/src/{agent → runtime/agent}/orchestrator/session/cache/scoped-cache-outcome.mjs +0 -0
  758. /package/src/{agent → runtime/agent}/orchestrator/session/cache/util.mjs +0 -0
  759. /package/src/{agent → runtime/agent}/orchestrator/session/read-dedup.mjs +0 -0
  760. /package/src/{agent → runtime/agent}/orchestrator/session/save-session-worker.mjs +0 -0
  761. /package/src/{agent → runtime/agent}/orchestrator/smart-bridge/session-builder.mjs +0 -0
  762. /package/src/{agent → runtime/agent}/orchestrator/tools/bash-policy-scan.mjs +0 -0
  763. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/advisory-lock.mjs +0 -0
  764. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/atomic-write.mjs +0 -0
  765. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/binary-file.mjs +0 -0
  766. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/cache-layers.mjs +0 -0
  767. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/cwd-utils.mjs +0 -0
  768. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/device-paths.mjs +0 -0
  769. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -0
  770. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/diff-utils.mjs +0 -0
  771. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/fs-reachability.mjs +0 -0
  772. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/grep-formatting.mjs +0 -0
  773. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/hash-utils.mjs +0 -0
  774. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/list-formatting.mjs +0 -0
  775. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/open-config-tool.mjs +0 -0
  776. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/path-locks.mjs +0 -0
  777. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/path-utils.mjs +0 -0
  778. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-args.mjs +0 -0
  779. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-batch.mjs +0 -0
  780. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-constants.mjs +0 -0
  781. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-image-resize.mjs +0 -0
  782. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-image.mjs +0 -0
  783. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-lines.mjs +0 -0
  784. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-mode-tool.mjs +0 -0
  785. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-range-index.mjs +0 -0
  786. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-ranges.mjs +0 -0
  787. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-special-files.mjs +0 -0
  788. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-tool.mjs +0 -0
  789. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-windows.mjs +0 -0
  790. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/shell-analysis.mjs +0 -0
  791. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/shell-output.mjs +0 -0
  792. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/snapshot-helpers.mjs +0 -0
  793. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/snapshot-validation.mjs +0 -0
  794. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/text-stats.mjs +0 -0
  795. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/windows-roots.mjs +0 -0
  796. /package/src/{agent → runtime/agent}/orchestrator/tools/code-graph-prewarm-worker.mjs +0 -0
  797. /package/src/{agent → runtime/agent}/orchestrator/tools/env-scrub.mjs +0 -0
  798. /package/src/{agent → runtime/agent}/orchestrator/tools/mutation-content-cache.mjs +0 -0
  799. /package/src/{agent → runtime/agent}/orchestrator/tools/mutation-planner.mjs +0 -0
  800. /package/src/{agent → runtime/agent}/orchestrator/tools/next-call-utils.mjs +0 -0
  801. /package/src/{agent → runtime/agent}/orchestrator/tools/result-compression.mjs +0 -0
  802. /package/src/{agent → runtime/agent}/orchestrator/tools/shell-exec-policy.mjs +0 -0
  803. /package/src/{agent → runtime/agent}/orchestrator/tools/shell-policy-danger-target.mjs +0 -0
  804. /package/src/{agent → runtime/agent}/orchestrator/tools/shell-policy.mjs +0 -0
  805. /package/src/{channels → runtime/channels}/lib/cli-worker-host.mjs +0 -0
  806. /package/src/{channels → runtime/channels}/lib/config-lock.mjs +0 -0
  807. /package/src/{channels → runtime/channels}/lib/event-queue.mjs +0 -0
  808. /package/src/{channels → runtime/channels}/lib/executor.mjs +0 -0
  809. /package/src/{channels → runtime/channels}/lib/format.mjs +0 -0
  810. /package/src/{channels → runtime/channels}/lib/interaction-workflows.mjs +0 -0
  811. /package/src/{channels → runtime/channels}/lib/memory-client.mjs +0 -0
  812. /package/src/{channels → runtime/channels}/lib/session-control.mjs +0 -0
  813. /package/src/{channels → runtime/channels}/lib/settings.mjs +0 -0
  814. /package/src/{channels → runtime/channels}/lib/whisper-server.mjs +0 -0
  815. /package/src/{memory → runtime/memory}/lib/agent-ipc.mjs +0 -0
  816. /package/src/{memory → runtime/memory}/lib/bridge-trace-queries.mjs +0 -0
  817. /package/src/{memory → runtime/memory}/lib/llm-worker-host.mjs +0 -0
  818. /package/src/{memory → runtime/memory}/lib/memory-cycle.mjs +0 -0
  819. /package/src/{memory → runtime/memory}/lib/memory-extraction.mjs +0 -0
  820. /package/src/{memory → runtime/memory}/lib/memory-maintenance-store.mjs +0 -0
  821. /package/src/{memory → runtime/memory}/lib/memory-recall-id-patch.mjs +0 -0
  822. /package/src/{memory → runtime/memory}/lib/memory-recall-read-query.mjs +0 -0
  823. /package/src/{memory → runtime/memory}/lib/memory-recall-scope-filter.mjs +0 -0
  824. /package/src/{memory → runtime/memory}/lib/memory-retrievers.mjs +0 -0
  825. /package/src/{memory → runtime/memory}/lib/memory-score.mjs +0 -0
  826. /package/src/{memory → runtime/memory}/lib/memory-text-utils.mjs +0 -0
  827. /package/src/{memory → runtime/memory}/lib/model-profile.mjs +0 -0
  828. /package/src/{memory → runtime/memory}/lib/project-id-resolver.mjs +0 -0
  829. /package/src/{search → runtime/search}/lib/cache.mjs +0 -0
  830. /package/src/{search → runtime/search}/lib/formatter.mjs +0 -0
  831. /package/src/{search → runtime/search}/lib/state.mjs +0 -0
  832. /package/src/{shared → runtime/shared}/abort-controller.mjs +0 -0
  833. /package/src/{shared → runtime/shared}/llm/index.mjs +0 -0
  834. /package/src/{shared → runtime/shared}/llm/pid-cleanup.mjs +0 -0
  835. /package/src/{shared → runtime/shared}/llm/usage-log.mjs +0 -0
  836. /package/src/{shared → runtime/shared}/wsl.mjs +0 -0
@@ -0,0 +1,2960 @@
1
+ import { createRequire } from 'module';
2
+ import { fileURLToPath } from 'url';
3
+ import { randomBytes, createHash } from 'crypto';
4
+ import { join } from 'path';
5
+ import { getProvider, providerInputExcludesCache } from '../providers/registry.mjs';
6
+ import { getModelMetadataSync } from '../providers/model-catalog.mjs';
7
+ import { fetchOAuthUsageSnapshot } from '../providers/oauth-usage.mjs';
8
+ import { agentLoop } from './loop.mjs';
9
+ import {
10
+ compactActiveTurn,
11
+ compactMessages,
12
+ semanticCompactMessages,
13
+ DEFAULT_COMPACTION_BUFFER_RATIO,
14
+ compactionBufferTokensForBoundary,
15
+ normalizeCompactionBufferRatio,
16
+ } from './compact.mjs';
17
+ import { estimateMessagesTokens, estimateRequestReserveTokens } from './context-utils.mjs';
18
+ import { getMcpTools } from '../mcp/client.mjs';
19
+ import { getInternalTools, executeInternalTool } from '../internal-tools.mjs';
20
+ import { BUILTIN_TOOLS } from '../tools/builtin.mjs';
21
+ import { PATCH_TOOL_DEFS } from '../tools/patch-tool-defs.mjs';
22
+ import { CODE_GRAPH_TOOL_DEFS } from '../tools/code-graph-tool-defs.mjs';
23
+ import { executeCodeGraphTool } from '../tools/code-graph.mjs';
24
+ import { closeBashSession } from '../tools/bash-session.mjs';
25
+ import { collectSkillsCached, buildSkillToolDefs, loadAgentTemplate, loadRoleTemplate, composeSystemPrompt, collectMixdogMd } from '../context/collect.mjs';
26
+ import { saveSession, saveSessionAsync, loadSession, listStoredSessions, sweepStaleSessions, markSessionClosed, publishHeartbeat, deleteHeartbeat, setLiveSession } from './store.mjs';
27
+ import { clearReadDedupSession, tryPrefetchCached, setPrefetchCached } from './read-dedup.mjs';
28
+ import { clearOffloadSession } from './tool-result-offload.mjs';
29
+ import { classifyResultKind } from './result-classification.mjs';
30
+ import { createAbortController } from '../../../shared/abort-controller.mjs';
31
+ import { logLlmCall } from '../../../shared/llm/usage-log.mjs';
32
+ import { resolvePluginData, mixdogRoot } from '../../../shared/plugin-paths.mjs';
33
+ import { updateJsonAtomicSync } from '../../../shared/atomic-file.mjs';
34
+ import { appendBridgeTrace } from '../bridge-trace.mjs';
35
+ import { maxMtime, maxMtimeRecursive } from '../cache-mtime.mjs';
36
+ import { getRoleInstructionDir } from '../internal-roles.mjs';
37
+ import {
38
+ buildGatewayLimits,
39
+ recordGatewayUsageEvent,
40
+ summarizeGatewayUsage,
41
+ } from '../providers/statusline-route-meta.mjs';
42
+ // Phase B: Pool B Tier 2 content builder (common rules only).
43
+ // Loaded once per process via createRequire so the CJS module reaches us.
44
+ const _require = createRequire(import.meta.url);
45
+ const _rulesBuilder = (() => {
46
+ const candidates = [
47
+ join(mixdogRoot(), 'lib', 'rules-builder.cjs'),
48
+ ].filter(Boolean);
49
+ for (const p of candidates) {
50
+ try { return _require(p); } catch { /* fall through */ }
51
+ }
52
+ // Fallback: walk up from this file's location to find lib/rules-builder.cjs.
53
+ try { return _require('../../../../lib/rules-builder.cjs'); } catch { return null; }
54
+ })();
55
+
56
+ // bridgeRules is the bridge shared prefix (shared rules + bridge common rules +
57
+ // user agent configs). It's rebuilt from disk
58
+ // by rules-builder.cjs on every call; since createSession fires on every
59
+ // Pool B/C bridge turn, that's a lot of redundant readFileSync + concat.
60
+ // BP1/BP3 cache — invalidated by source file mtime, not a timer.
61
+ // Cheap: O(sentinel-count) stat calls on each bridge turn, no I/O otherwise.
62
+ // BP1 cache — single shared entry. buildBridgeInjectionContent is
63
+ // role-agnostic (true cross-role common), so every bridge role reuses the
64
+ // same prefix bytes.
65
+ let _bridgeRulesCache = null;
66
+ let _bridgeRulesMtime = 0;
67
+ function _buildBridgeRules() {
68
+ if (!_rulesBuilder || typeof _rulesBuilder.buildBridgeInjectionContent !== 'function') return '';
69
+ const PLUGIN_ROOT = mixdogRoot();
70
+ const DATA_DIR = resolvePluginData();
71
+ const RULES_DIR = join(PLUGIN_ROOT, 'rules');
72
+ const mtime = maxMtimeRecursive([
73
+ join(RULES_DIR, 'shared'),
74
+ join(RULES_DIR, 'bridge'),
75
+ join(DATA_DIR, 'roles'),
76
+ join(DATA_DIR, 'mixdog-config.json'),
77
+ ]);
78
+ if (_bridgeRulesCache !== null && mtime <= _bridgeRulesMtime) {
79
+ return _bridgeRulesCache;
80
+ }
81
+ try {
82
+ const built = _rulesBuilder.buildBridgeInjectionContent({ PLUGIN_ROOT, DATA_DIR });
83
+ _bridgeRulesCache = built;
84
+ _bridgeRulesMtime = mtime;
85
+ return built;
86
+ } catch (e) {
87
+ throw new Error(`[session] bridge common rules build failed: ${e.message}`);
88
+ }
89
+ }
90
+
91
+ let _leadRulesCache = null;
92
+ let _leadRulesMtime = 0;
93
+ function _buildLeadRules() {
94
+ if (!_rulesBuilder || typeof _rulesBuilder.buildInjectionContent !== 'function') return '';
95
+ const PLUGIN_ROOT = mixdogRoot();
96
+ const DATA_DIR = resolvePluginData();
97
+ const RULES_DIR = join(PLUGIN_ROOT, 'rules');
98
+ const mtime = Math.max(maxMtime([
99
+ PLUGIN_ROOT,
100
+ DATA_DIR,
101
+ ]), maxMtimeRecursive([
102
+ join(RULES_DIR, 'shared'),
103
+ join(RULES_DIR, 'lead'),
104
+ join(DATA_DIR, 'history'),
105
+ join(DATA_DIR, 'mixdog-config.json'),
106
+ join(DATA_DIR, 'user-workflow.md'),
107
+ join(DATA_DIR, 'user-workflow.json'),
108
+ join(PLUGIN_ROOT, 'output-styles'),
109
+ join(DATA_DIR, 'output-styles'),
110
+ ]));
111
+ if (_leadRulesCache !== null && mtime <= _leadRulesMtime) {
112
+ return _leadRulesCache;
113
+ }
114
+ try {
115
+ const built = _rulesBuilder.buildInjectionContent({ PLUGIN_ROOT, DATA_DIR });
116
+ _leadRulesCache = built;
117
+ _leadRulesMtime = mtime;
118
+ return built;
119
+ } catch (e) {
120
+ throw new Error(`[session] lead rules build failed: ${e.message}`);
121
+ }
122
+ }
123
+
124
+ // BP3 role-specific cache — keyed by role. webhook / schedule / hidden
125
+ // retrieval roles each have their own scoped instruction set; other roles
126
+ // return ''.
127
+ const _roleSpecificCache = new Map(); // role → { value, mtime }
128
+ function _buildRoleSpecific(currentRole) {
129
+ if (!_rulesBuilder || typeof _rulesBuilder.buildBridgeRoleSpecificContent !== 'function') return '';
130
+ if (!currentRole) return '';
131
+ const PLUGIN_ROOT = mixdogRoot();
132
+ const DATA_DIR = resolvePluginData();
133
+ const RULES_DIR = join(PLUGIN_ROOT, 'rules');
134
+ const roleInstructionDir = getRoleInstructionDir(currentRole);
135
+ const mtime = maxMtimeRecursive([
136
+ join(RULES_DIR, 'shared'),
137
+ join(DATA_DIR, 'mixdog-config.json'),
138
+ join(DATA_DIR, 'webhooks'),
139
+ join(DATA_DIR, 'schedules'),
140
+ ...(roleInstructionDir ? [join(DATA_DIR, roleInstructionDir)] : []),
141
+ join(PLUGIN_ROOT, 'defaults', 'hidden-roles.json'),
142
+ ]);
143
+ const entry = _roleSpecificCache.get(currentRole);
144
+ if (entry && mtime <= entry.mtime) {
145
+ return entry.value;
146
+ }
147
+ try {
148
+ const built = _rulesBuilder.buildBridgeRoleSpecificContent({ PLUGIN_ROOT, DATA_DIR, currentRole });
149
+ _roleSpecificCache.set(currentRole, { mtime, value: built });
150
+ return built;
151
+ } catch (e) {
152
+ throw new Error(`[session] role-specific rules build failed (role: ${currentRole}): ${e.message}`);
153
+ }
154
+ }
155
+
156
+ // Smart Bridge is optional — injected via setSmartBridge() during plugin init
157
+ // so session creation never depends on a circular import. If never injected,
158
+ // createSession simply falls back to classic preset-only behavior.
159
+ let _smartBridgeApi = null;
160
+ let _smartBridgeWarned = false;
161
+
162
+ /**
163
+ * Inject the Smart Bridge singleton. Called once by agent/index.mjs init()
164
+ * after initSmartBridge(). Safe to call multiple times — later calls
165
+ * replace the previous reference.
166
+ */
167
+ export function setSmartBridge(api) {
168
+ _smartBridgeApi = api || null;
169
+ }
170
+
171
+ function getSmartBridgeSync() {
172
+ return _smartBridgeApi;
173
+ }
174
+
175
+ /**
176
+ * Thrown when a session is closed while a call is in-flight. Callers (bridge
177
+ * handler, CLI) should render this as "cancelled" rather than a hard error.
178
+ */
179
+ export class SessionClosedError extends Error {
180
+ constructor(sessionId, reason, closeReason) {
181
+ super(reason ? `Session "${sessionId}" closed: ${reason}` : `Session "${sessionId}" closed`);
182
+ this.name = 'SessionClosedError';
183
+ this.sessionId = sessionId;
184
+ this.cancelled = true;
185
+ // closeReason is the diagnostic enum (request-abort / manual /
186
+ // idle-sweep / runner-crash). Kept separate from `reason` (the free
187
+ // -form message) so consumers can branch on it without regex parsing.
188
+ this.reason = closeReason || null;
189
+ }
190
+ }
191
+ const HEARTBEAT_THROTTLE_MS = 60_000; // 60s
192
+
193
+ // Merge externally-connected MCP tools with the plugin's in-process tools
194
+ // (registered by agent's toolExecutor bridge). Internal tools are exposed
195
+ // under their bare names — no mcp__ prefix, since the dispatcher in
196
+ // server.mjs handles them directly without a transport.
197
+ // Sorted deterministically by name — protects BP_1 hash stability from
198
+ // listTools() ordering churn. Anthropic / OpenAI / Gemini all hash the
199
+ // tools array verbatim, so any reorder rewrites the prefix.
200
+ // No cache: getMcpTools() and getInternalTools() are O(n) in-memory reads;
201
+ // the sort overhead on ~30 tools is negligible.
202
+ function _getMcpTools() {
203
+ const mcp = getMcpTools() || [];
204
+ const internalRaw = getInternalTools() || [];
205
+ const internal = internalRaw.map(t => ({
206
+ name: t.name,
207
+ description: typeof t.description === 'string' ? t.description : '',
208
+ inputSchema: t.inputSchema || { type: 'object', properties: {} },
209
+ // Keep annotations so the permission filter / role invariants can
210
+ // tell read-only from write-capable internal tools, and so
211
+ // bridgeHidden can be read during deny filtering.
212
+ annotations: t.annotations || {},
213
+ }));
214
+ return [...mcp, ...internal].sort((a, b) => {
215
+ const an = a?.name || '';
216
+ const bn = b?.name || '';
217
+ return an < bn ? -1 : an > bn ? 1 : 0;
218
+ });
219
+ }
220
+
221
+ // Phase D-2 — profile.tools resolution.
222
+ //
223
+ // `toolSpec` may be:
224
+ // • Array<string> (profile.tools) — toolset ids like "tools:filesystem",
225
+ // "tools:git", "tools:mcp", "tools:search",
226
+ // "tools:readonly", or the literal "full"
227
+ // • 'full' / 'readonly' / 'mcp' — legacy preset.tools strings
228
+ // • null / undefined — same as 'full' (historical default)
229
+ //
230
+ // Array form is the Phase B/D target: each profile declares its tool surface
231
+ // explicitly, BP_1 hash differs across profiles with different tool subsets
232
+ // (by design — sub-task profile cannot see bash; worker-full can), and
233
+ // adding a new toolset id here is a localised change.
234
+ //
235
+ // Unified-shard policy — the session's tool array normally never narrows
236
+ // with permission or role. Bridge sessions share the same schema so BP_1
237
+ // stays bit-identical and the provider-side cache shard is shared
238
+ // workspace-wide. Rare specialist roles may pass schemaAllowedTools from a
239
+ // declarative hidden-role toolSchemaProfile to keep their first-turn routing
240
+ // surface intentionally tiny; runtime permission guards in loop.mjs remain
241
+ // the fail-safe either way.
242
+
243
+ const SESSION_ROUTE_TOOL_ORDER = [
244
+ 'code_graph',
245
+ 'glob',
246
+ 'list',
247
+ 'grep',
248
+ 'read',
249
+ 'apply_patch',
250
+ 'shell',
251
+ 'task',
252
+ ];
253
+ const SESSION_ROUTE_TOOL_RANK = new Map(SESSION_ROUTE_TOOL_ORDER.map((name, index) => [name, index]));
254
+ const MODEL_HIDDEN_FILE_MUTATION_TOOLS = new Set(['edit', 'write']);
255
+ const FILESYSTEM_TOOL_NAMES = new Set([
256
+ 'code_graph',
257
+ 'glob',
258
+ 'list',
259
+ 'grep',
260
+ 'read',
261
+ 'apply_patch',
262
+ ]);
263
+ const READONLY_TOOL_NAMES = new Set([
264
+ 'code_graph',
265
+ 'glob',
266
+ 'list',
267
+ 'grep',
268
+ 'read',
269
+ ]);
270
+
271
+ function orderSessionTools(tools) {
272
+ return tools.map((tool, index) => ({ tool, index }))
273
+ .sort((a, b) => {
274
+ const ar = SESSION_ROUTE_TOOL_RANK.get(a.tool?.name) ?? 10_000;
275
+ const br = SESSION_ROUTE_TOOL_RANK.get(b.tool?.name) ?? 10_000;
276
+ if (ar !== br) return ar - br;
277
+ return a.index - b.index;
278
+ })
279
+ .map((entry) => entry.tool);
280
+ }
281
+
282
+ const ALL_BUILTIN_SESSION_TOOLS = orderSessionTools(_dedupByName([
283
+ ...BUILTIN_TOOLS.filter(t => !MODEL_HIDDEN_FILE_MUTATION_TOOLS.has(t?.name)),
284
+ ...PATCH_TOOL_DEFS,
285
+ ...CODE_GRAPH_TOOL_DEFS,
286
+ ]));
287
+
288
+ function resolveSessionTools(toolSpec, skills, { ownerIsBridge = false } = {}) {
289
+ const mcp = _getMcpTools();
290
+ // Bridge sessions freeze the 3 skill meta-tools into the schema
291
+ // unconditionally — concrete skill resolution is cwd-scoped at tool-call
292
+ // time (loop.mjs), so the schema bytes stay bit-identical across roles /
293
+ // cwds and the provider cache shard does not fragment.
294
+ const skillTools = buildSkillToolDefs(skills, { ownerIsBridge });
295
+ return _computeBaseTools(toolSpec, mcp, skillTools);
296
+ }
297
+
298
+ export function previewSessionTools(toolSpec, skills = [], options = {}) {
299
+ return resolveSessionTools(toolSpec, skills, options);
300
+ }
301
+
302
+ // Dedup by name, first occurrence wins. BUILTIN_TOOLS is passed in ahead
303
+ // of the MCP-registered internal tools so plugin-side definitions take
304
+ // precedence when both surfaces declare the same name (e.g. read / grep / glob).
305
+ // Without this merge, Anthropic rejected the request with
306
+ // "tools: Tool names must be unique" and the orchestrator burned up to
307
+ // 20 iterations retrying before the final answer landed.
308
+ function _dedupByName(tools) {
309
+ const seen = new Map();
310
+ for (const t of tools) {
311
+ const n = t?.name;
312
+ if (!n || seen.has(n)) continue;
313
+ seen.set(n, t);
314
+ }
315
+ return [...seen.values()];
316
+ }
317
+
318
+ // Bridge visibility is declared per-tool via annotations.bridgeHidden.
319
+ // Tools with bridgeHidden:true are stripped from bridge sessions at schema
320
+ // build time (see deny filtering below). No code-level name list needed.
321
+
322
+ function _computeBaseTools(toolSpec, mcp, skillTools) {
323
+ if (Array.isArray(toolSpec)) {
324
+ if (toolSpec.length === 0) {
325
+ // Explicit "no tools" — skill meta tools still travel so the model
326
+ // can at least discover and invoke skills if that is the one
327
+ // dynamic surface the profile retains.
328
+ return _dedupByName([...skillTools]);
329
+ }
330
+ if (toolSpec.includes('full')) {
331
+ return _dedupByName([...ALL_BUILTIN_SESSION_TOOLS, ...mcp, ...skillTools]);
332
+ }
333
+ const byName = new Map();
334
+ const add = (tool) => { if (tool?.name && !byName.has(tool.name)) byName.set(tool.name, tool); };
335
+ const addMany = (arr) => { for (const t of arr) add(t); };
336
+ for (const tagRaw of toolSpec) {
337
+ const tag = String(tagRaw || '').trim();
338
+ switch (tag) {
339
+ case 'tools:filesystem':
340
+ addMany(ALL_BUILTIN_SESSION_TOOLS.filter(t => FILESYSTEM_TOOL_NAMES.has(t.name)));
341
+ break;
342
+ case 'tools:readonly':
343
+ addMany(ALL_BUILTIN_SESSION_TOOLS.filter(t => READONLY_TOOL_NAMES.has(t.name)));
344
+ break;
345
+ case 'tools:shell':
346
+ case 'tools:git':
347
+ case 'tools:analysis':
348
+ // Shell-class toolset. `tools:git` / `tools:analysis` exist so
349
+ // profile authors can name the intent (git workflows / data
350
+ // analysis) without inventing new toolset ids.
351
+ addMany(ALL_BUILTIN_SESSION_TOOLS.filter(t => t.name === 'shell' || t.name === 'task'));
352
+ break;
353
+ case 'tools:mcp':
354
+ addMany(mcp);
355
+ break;
356
+ case 'tools:search':
357
+ // Name-pattern match: picks up `search` and any future tool
358
+ // whose name contains `search`. `recall` and `explore` deliberately do NOT match
359
+ // — they need `tools:mcp` (full mcp surface) or their own
360
+ // toolset id if a role wants targeted retrieval. Public bridge
361
+ // roles never reach the wrapper bodies regardless: see the
362
+ // isBlockedPublicWrapperCall guard in session/loop.mjs.
363
+ addMany(mcp.filter(t => /search/i.test(t?.name || '')));
364
+ break;
365
+ default:
366
+ process.stderr.write(`[session] unknown toolset id "${tag}" (profile.tools); skipping\n`);
367
+ }
368
+ }
369
+ return _dedupByName([...byName.values(), ...skillTools]);
370
+ }
371
+
372
+ switch (toolSpec) {
373
+ case 'mcp':
374
+ return _dedupByName([...mcp, ...skillTools]);
375
+ case 'readonly': {
376
+ const readTools = ALL_BUILTIN_SESSION_TOOLS.filter(t => READONLY_TOOL_NAMES.has(t.name));
377
+ return _dedupByName([...readTools, ...mcp, ...skillTools]);
378
+ }
379
+ case 'full':
380
+ default:
381
+ return _dedupByName([...ALL_BUILTIN_SESSION_TOOLS, ...mcp, ...skillTools]);
382
+ }
383
+ }
384
+
385
+ function permissionFromToolSpec(toolSpec) {
386
+ if (toolSpec === 'readonly') return 'read';
387
+ if (toolSpec === 'mcp') return 'mcp';
388
+ if (Array.isArray(toolSpec)) {
389
+ const tags = new Set(toolSpec.map(t => String(t || '').trim()));
390
+ const hasWriteOrShell = tags.has('full')
391
+ || tags.has('tools:filesystem')
392
+ || tags.has('tools:shell')
393
+ || tags.has('tools:git')
394
+ || tags.has('tools:analysis');
395
+ if (tags.has('tools:readonly') && !hasWriteOrShell) return 'read';
396
+ }
397
+ return null;
398
+ }
399
+
400
+ let nextId = Date.now();
401
+ // Known context windows for the current-generation models this plugin
402
+ // routes to. Anything not listed falls through to guessContextWindow() —
403
+ // local llama/mistral/phi default to 8192, everything else 128000. Keep
404
+ // this map trimmed to live models; older generations slow down reads
405
+ // without buying anything.
406
+ const CONTEXT_WINDOWS = {
407
+ // OpenAI GPT-5.x family
408
+ 'gpt-5.5': 272000,
409
+ 'gpt-5.4': 272000,
410
+ 'gpt-5.4-mini': 272000,
411
+ 'gpt-5.4-nano': 272000,
412
+ // Anthropic Claude 4.x
413
+ 'claude-opus-4-8': 1000000,
414
+ 'claude-opus-4-7': 1000000,
415
+ 'claude-sonnet-4-6': 1000000,
416
+ 'claude-haiku-4-5-20251001': 200000,
417
+ // Google Gemini 3.x
418
+ 'gemini-3.1-pro': 1000000,
419
+ 'gemini-3-pro': 1000000,
420
+ 'gemini-3.5-flash': 1000000,
421
+ 'gemini-3-flash': 1000000,
422
+ };
423
+ function guessContextWindow(model) {
424
+ if (CONTEXT_WINDOWS[model])
425
+ return CONTEXT_WINDOWS[model];
426
+ if (model.includes('llama') || model.includes('mistral') || model.includes('phi'))
427
+ return 8192;
428
+ return 128000;
429
+ }
430
+ function positiveContextWindow(value) {
431
+ const n = Number(value);
432
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
433
+ }
434
+ function envFlag(name, fallback = false) {
435
+ const v = process.env[name];
436
+ if (v === undefined) return fallback;
437
+ return !['0', 'false', 'off', 'no'].includes(String(v).trim().toLowerCase());
438
+ }
439
+ function boundedPercent(value, fallback = null) {
440
+ const n = Number(value);
441
+ if (Number.isFinite(n) && n > 0 && n <= 100) return n;
442
+ return fallback;
443
+ }
444
+ function providerNameOf(provider) {
445
+ if (typeof provider === 'string') return provider.toLowerCase();
446
+ return String(provider?.name || provider?.id || '').toLowerCase();
447
+ }
448
+ function compactBufferRatioForSession(session) {
449
+ const cfg = session?.compaction || {};
450
+ return normalizeCompactionBufferRatio(
451
+ cfg.bufferPercent
452
+ ?? cfg.bufferPct
453
+ ?? cfg.bufferRatio
454
+ ?? cfg.bufferFraction
455
+ ?? process.env.MIXDOG_BRIDGE_COMPACT_BUFFER_PERCENT
456
+ ?? process.env.MIXDOG_BRIDGE_COMPACT_BUFFER_RATIO,
457
+ DEFAULT_COMPACTION_BUFFER_RATIO,
458
+ );
459
+ }
460
+ function compactBufferTokensForSession(session, boundaryTokens) {
461
+ const cfg = session?.compaction || {};
462
+ const explicit = positiveContextWindow(cfg.bufferTokens ?? cfg.buffer)
463
+ || positiveContextWindow(process.env.MIXDOG_BRIDGE_COMPACT_BUFFER_TOKENS)
464
+ || 0;
465
+ return compactionBufferTokensForBoundary(boundaryTokens, {
466
+ explicitTokens: explicit,
467
+ ratio: compactBufferRatioForSession(session),
468
+ maxRatio: 0.25,
469
+ });
470
+ }
471
+ const COMPACT_TARGET_RATIO = 0.02;
472
+ const COMPACT_TARGET_MIN_TOKENS = 4_000;
473
+ const COMPACT_TARGET_MAX_TOKENS = 16_000;
474
+ function compactTargetRatio() {
475
+ const raw = process.env.MIXDOG_COMPACT_TARGET_PERCENT
476
+ ?? process.env.MIXDOG_BRIDGE_COMPACT_TARGET_PERCENT
477
+ ?? COMPACT_TARGET_RATIO;
478
+ const n = Number(raw);
479
+ if (!Number.isFinite(n) || n <= 0) return COMPACT_TARGET_RATIO;
480
+ return n > 1 ? n / 100 : n;
481
+ }
482
+ function compactTargetTokensForBoundary(boundaryTokens) {
483
+ const boundary = positiveContextWindow(boundaryTokens);
484
+ if (!boundary) return null;
485
+ const explicit = positiveContextWindow(
486
+ process.env.MIXDOG_COMPACT_TARGET_TOKENS
487
+ ?? process.env.MIXDOG_BRIDGE_COMPACT_TARGET_TOKENS,
488
+ );
489
+ if (explicit) return Math.max(1, Math.min(boundary, explicit));
490
+ const minTarget = Math.min(boundary, positiveContextWindow(process.env.MIXDOG_COMPACT_TARGET_MIN_TOKENS) || COMPACT_TARGET_MIN_TOKENS);
491
+ const maxTarget = Math.min(boundary, positiveContextWindow(process.env.MIXDOG_COMPACT_TARGET_MAX_TOKENS) || COMPACT_TARGET_MAX_TOKENS);
492
+ const byRatio = Math.max(1, Math.floor(boundary * compactTargetRatio()));
493
+ return Math.max(1, Math.min(boundary, maxTarget, Math.max(minTarget, byRatio)));
494
+ }
495
+ function defaultEffectiveContextWindowPercent(provider) {
496
+ // Gateway/statusline route metadata reserves a universal 5% headroom from
497
+ // the raw catalog window. Keep session compaction on the same effective
498
+ // capacity so /context, the TUI statusline, and gateway telemetry agree.
499
+ return 95;
500
+ }
501
+ function resolveSessionContextMeta(provider, model, seed = {}) {
502
+ const info = typeof provider?.getCachedModelInfo === 'function'
503
+ ? provider.getCachedModelInfo(model)
504
+ : null;
505
+ const catalogInfo = getModelMetadataSync(model, providerNameOf(provider));
506
+ const rawContextWindow = positiveContextWindow(info?.contextWindow)
507
+ || positiveContextWindow(info?.maxContextWindow)
508
+ || positiveContextWindow(info?.context_window)
509
+ || positiveContextWindow(info?.max_context_window)
510
+ || positiveContextWindow(catalogInfo?.contextWindow)
511
+ || positiveContextWindow(catalogInfo?.maxContextWindow)
512
+ || positiveContextWindow(catalogInfo?.context_window)
513
+ || positiveContextWindow(catalogInfo?.max_context_window)
514
+ || positiveContextWindow(seed.rawContextWindow)
515
+ || positiveContextWindow(seed.raw_context_window)
516
+ || positiveContextWindow(seed.contextWindow)
517
+ || guessContextWindow(model);
518
+ const effectiveContextWindowPercent = boundedPercent(
519
+ seed.effectiveContextWindowPercent
520
+ ?? seed.effective_context_window_percent
521
+ ?? info?.effectiveContextWindowPercent
522
+ ?? info?.effective_context_window_percent
523
+ ?? catalogInfo?.effectiveContextWindowPercent
524
+ ?? catalogInfo?.effective_context_window_percent,
525
+ defaultEffectiveContextWindowPercent(provider),
526
+ );
527
+ const pct = boundedPercent(effectiveContextWindowPercent, 100);
528
+ const contextWindow = Math.max(1, Math.floor(rawContextWindow * pct / 100));
529
+ const explicitCompactLimit = positiveContextWindow(
530
+ seed.autoCompactTokenLimit
531
+ ?? seed.auto_compact_token_limit
532
+ ?? info?.autoCompactTokenLimit
533
+ ?? info?.auto_compact_token_limit
534
+ ?? catalogInfo?.autoCompactTokenLimit
535
+ ?? catalogInfo?.auto_compact_token_limit,
536
+ );
537
+ const derivedCompactLimit = contextWindow;
538
+ const autoCompactTokenLimit = explicitCompactLimit && derivedCompactLimit
539
+ ? Math.min(explicitCompactLimit, derivedCompactLimit)
540
+ : (explicitCompactLimit || derivedCompactLimit);
541
+ const compactBoundaryTokens = contextWindow;
542
+ return {
543
+ contextWindow,
544
+ rawContextWindow,
545
+ effectiveContextWindowPercent,
546
+ autoCompactTokenLimit: autoCompactTokenLimit || null,
547
+ compactBoundaryTokens,
548
+ };
549
+ }
550
+ function compactTriggerForSession(session, boundaryTokens) {
551
+ const boundary = positiveContextWindow(boundaryTokens);
552
+ if (!boundary) return null;
553
+ const autoLimit = positiveContextWindow(session?.autoCompactTokenLimit ?? session?.compaction?.autoCompactTokenLimit);
554
+ if (autoLimit && autoLimit <= boundary) return Math.max(1, autoLimit);
555
+ const buffer = compactBufferTokensForSession(session, boundary);
556
+ return Math.max(1, boundary - buffer);
557
+ }
558
+ function compactTargetBudget(boundaryTokens, reserveTokens, _sourceTokens = null, _ratio = null) {
559
+ const boundary = positiveContextWindow(boundaryTokens);
560
+ if (!boundary) return null;
561
+ const reserve = Math.max(0, Number(reserveTokens) || 0);
562
+ const targetEffective = compactTargetTokensForBoundary(boundary) || boundary;
563
+ return Math.max(1, Math.min(boundary, targetEffective + reserve));
564
+ }
565
+ function semanticCompactionEnabledForSession(session) {
566
+ const cfg = session?.compaction || {};
567
+ if (process.env.MIXDOG_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_COMPACT_SEMANTIC', true);
568
+ if (process.env.MIXDOG_BRIDGE_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_BRIDGE_COMPACT_SEMANTIC', true);
569
+ if (cfg.semantic === false || cfg.semantic === 'false' || cfg.semantic === 'off') return false;
570
+ if (cfg.semantic === true || cfg.semantic === 'true' || cfg.semantic === 'on' || cfg.semantic === 'auto') return true;
571
+ return true;
572
+ }
573
+ function addCompactUsageToSession(session, usage) {
574
+ if (!session || !usage) return;
575
+ const inputTokens = usage.inputTokens || 0;
576
+ const outputTokens = usage.outputTokens || 0;
577
+ const cachedTokens = usage.cachedTokens || 0;
578
+ const cacheWriteTokens = usage.cacheWriteTokens || 0;
579
+ session.totalInputTokens = (session.totalInputTokens || 0) + inputTokens;
580
+ session.totalOutputTokens = (session.totalOutputTokens || 0) + outputTokens;
581
+ session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + cachedTokens;
582
+ session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + cacheWriteTokens;
583
+ session.tokensCumulative = (session.tokensCumulative || 0) + inputTokens + outputTokens;
584
+ }
585
+ async function runSessionCompaction(session, opts = {}) {
586
+ if (!session || session.closed === true) return null;
587
+ const mode = opts.mode === 'auto' ? 'auto' : 'manual';
588
+ const force = opts.force === true || mode === 'manual';
589
+ if (mode === 'auto' && session.compaction?.auto === false) return null;
590
+ const messages = Array.isArray(session.messages) ? session.messages : [];
591
+ if (messages.length < 3 && !force) return null;
592
+ const boundary = positiveContextWindow(session.compactBoundaryTokens)
593
+ || positiveContextWindow(session.autoCompactTokenLimit)
594
+ || positiveContextWindow(session.contextWindow);
595
+ if (!boundary) {
596
+ if (force) throw new Error('compact: no context window is available for this session');
597
+ return null;
598
+ }
599
+ const reserveTokens = estimateRequestReserveTokens(session.tools || []);
600
+ const beforeMessageTokens = estimateMessagesTokens(messages);
601
+ const lastContextTokens = positiveContextWindow(session.lastContextTokens) || 0;
602
+ const triggerTokens = compactTriggerForSession(session, boundary)
603
+ || positiveContextWindow(session.compaction?.triggerTokens)
604
+ || boundary;
605
+ const bufferTokens = Math.max(0, boundary - triggerTokens);
606
+ const bufferRatio = boundary ? (bufferTokens / boundary) : compactBufferRatioForSession(session);
607
+ const pressureTokens = Math.max(beforeMessageTokens + reserveTokens, lastContextTokens);
608
+ const beforeTokens = pressureTokens;
609
+ if (!force && pressureTokens < triggerTokens) return {
610
+ changed: false,
611
+ reason: 'below threshold',
612
+ beforeMessages: messages.length,
613
+ afterMessages: messages.length,
614
+ beforeTokens,
615
+ afterTokens: beforeTokens,
616
+ beforeMessageTokens,
617
+ afterMessageTokens: beforeMessageTokens,
618
+ pressureTokens,
619
+ triggerTokens,
620
+ bufferTokens,
621
+ bufferRatio,
622
+ boundaryTokens: boundary,
623
+ budgetTokens: boundary,
624
+ targetBudgetTokens: boundary,
625
+ reserveTokens,
626
+ semanticCompact: false,
627
+ };
628
+ const budgetSourceTokens = force ? Math.max(pressureTokens, triggerTokens) : pressureTokens;
629
+ const compactBudget = compactTargetBudget(boundary, reserveTokens, budgetSourceTokens);
630
+ const budget = compactBudget || boundary;
631
+ const provider = opts.provider || getProvider(session.provider) || null;
632
+ let compacted;
633
+ let compactError = null;
634
+ let semanticCompactResult = null;
635
+ let semanticCompactError = null;
636
+ if (semanticCompactionEnabledForSession(session)) {
637
+ try {
638
+ if (!provider || typeof provider.send !== 'function') {
639
+ throw new Error(`semantic compact provider unavailable: ${session.provider || 'unknown'}`);
640
+ }
641
+ semanticCompactResult = await semanticCompactMessages(
642
+ provider,
643
+ messages,
644
+ opts.model || session.model,
645
+ budget,
646
+ {
647
+ reserveTokens,
648
+ providerName: session.provider || provider?.name || null,
649
+ sessionId: opts.sessionId || session.id || null,
650
+ signal: opts.signal || null,
651
+ promptCacheKey: session.promptCacheKey || null,
652
+ providerCacheKey: session.promptCacheKey || null,
653
+ timeoutMs: positiveContextWindow(session.compaction?.timeoutMs) || 30_000,
654
+ tailTurns: positiveContextWindow(session.compaction?.tailTurns) || 2,
655
+ keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens),
656
+ preserveRecentTokens: positiveContextWindow(session.compaction?.preserveRecentTokens),
657
+ force: true,
658
+ },
659
+ );
660
+ if (Array.isArray(semanticCompactResult?.messages)) {
661
+ compacted = semanticCompactResult.messages;
662
+ addCompactUsageToSession(session, semanticCompactResult.usage);
663
+ }
664
+ } catch (err) {
665
+ semanticCompactError = err;
666
+ try {
667
+ process.stderr.write(`[session] semantic ${mode} compact failed (sess=${session.id || 'unknown'}): ${err?.message || err}; falling back to deterministic compact\n`);
668
+ } catch { /* best-effort */ }
669
+ }
670
+ }
671
+ try {
672
+ if (!compacted) compacted = compactMessages(messages, budget, { reserveTokens, force: true });
673
+ } catch (err) {
674
+ try {
675
+ process.stderr.write(`[session] ${mode} compact fallback (sess=${session.id || 'unknown'}): ${err?.message || err}\n`);
676
+ } catch { /* best-effort */ }
677
+ try {
678
+ compacted = compactActiveTurn(messages, budget, { reserveTokens, force: true });
679
+ } catch (fallbackErr) {
680
+ compactError = fallbackErr;
681
+ }
682
+ }
683
+ if (!compacted) {
684
+ const now = Date.now();
685
+ session.compaction = {
686
+ ...(session.compaction || {}),
687
+ auto: mode === 'auto' ? true : session.compaction?.auto !== false,
688
+ boundaryTokens: boundary,
689
+ triggerTokens,
690
+ bufferTokens,
691
+ bufferRatio,
692
+ reserveTokens,
693
+ lastStage: mode === 'auto' ? 'post_turn_failed' : 'manual_failed',
694
+ lastBeforeTokens: beforeTokens,
695
+ lastAfterTokens: beforeTokens,
696
+ lastBeforeMessageTokens: beforeMessageTokens,
697
+ lastAfterMessageTokens: beforeMessageTokens,
698
+ lastPressureTokens: pressureTokens,
699
+ lastCheckedAt: now,
700
+ lastChanged: false,
701
+ lastSemantic: false,
702
+ lastSemanticError: semanticCompactError?.message || null,
703
+ lastError: compactError?.message || semanticCompactError?.message || String(compactError || semanticCompactError || 'compact failed'),
704
+ };
705
+ return {
706
+ changed: false,
707
+ error: session.compaction.lastError,
708
+ beforeMessages: messages.length,
709
+ afterMessages: messages.length,
710
+ beforeTokens,
711
+ afterTokens: beforeTokens,
712
+ beforeMessageTokens,
713
+ afterMessageTokens: beforeMessageTokens,
714
+ pressureTokens,
715
+ triggerTokens,
716
+ bufferTokens,
717
+ bufferRatio,
718
+ boundaryTokens: boundary,
719
+ budgetTokens: boundary,
720
+ targetBudgetTokens: budget,
721
+ reserveTokens,
722
+ semanticCompact: false,
723
+ semanticError: semanticCompactError?.message || null,
724
+ };
725
+ }
726
+ let beforeEncoded = '';
727
+ let afterEncoded = '';
728
+ try { beforeEncoded = JSON.stringify(messages); } catch { beforeEncoded = ''; }
729
+ try { afterEncoded = JSON.stringify(compacted); } catch { afterEncoded = ''; }
730
+ const afterMessageTokens = estimateMessagesTokens(compacted);
731
+ const afterTokens = afterMessageTokens + reserveTokens;
732
+ const changed = beforeEncoded && afterEncoded
733
+ ? beforeEncoded !== afterEncoded
734
+ : (compacted.length !== messages.length || afterMessageTokens !== beforeMessageTokens);
735
+ const now = Date.now();
736
+ session.messages = compacted;
737
+ session.providerState = undefined;
738
+ session.compaction = {
739
+ ...(session.compaction || {}),
740
+ auto: mode === 'auto' ? true : session.compaction?.auto !== false,
741
+ boundaryTokens: boundary,
742
+ triggerTokens,
743
+ bufferTokens,
744
+ bufferRatio,
745
+ reserveTokens,
746
+ lastStage: mode === 'auto' ? 'post_turn' : 'manual',
747
+ lastBeforeTokens: beforeTokens,
748
+ lastAfterTokens: afterTokens,
749
+ lastBeforeMessageTokens: beforeMessageTokens,
750
+ lastAfterMessageTokens: afterMessageTokens,
751
+ lastPressureTokens: pressureTokens,
752
+ lastCheckedAt: now,
753
+ lastChanged: changed,
754
+ lastChangedAt: changed ? now : session.compaction?.lastChangedAt || null,
755
+ lastCompactAt: changed ? now : session.compaction?.lastCompactAt || null,
756
+ lastSemantic: semanticCompactResult?.semantic === true,
757
+ lastSemanticError: semanticCompactError?.message || null,
758
+ lastSemanticUsage: semanticCompactResult?.usage ? {
759
+ inputTokens: semanticCompactResult.usage.inputTokens || 0,
760
+ outputTokens: semanticCompactResult.usage.outputTokens || 0,
761
+ cachedTokens: semanticCompactResult.usage.cachedTokens || 0,
762
+ cacheWriteTokens: semanticCompactResult.usage.cacheWriteTokens || 0,
763
+ } : null,
764
+ compactCount: (session.compaction?.compactCount || 0) + (changed ? 1 : 0),
765
+ };
766
+ if (changed && mode === 'auto') session.lastContextTokensStaleAfterCompact = true;
767
+ return {
768
+ changed,
769
+ beforeMessages: messages.length,
770
+ afterMessages: compacted.length,
771
+ beforeTokens,
772
+ afterTokens,
773
+ beforeMessageTokens,
774
+ afterMessageTokens,
775
+ pressureTokens,
776
+ triggerTokens,
777
+ bufferTokens,
778
+ bufferRatio,
779
+ boundaryTokens: boundary,
780
+ budgetTokens: boundary,
781
+ targetBudgetTokens: budget,
782
+ reserveTokens,
783
+ semanticCompact: semanticCompactResult?.semantic === true,
784
+ semanticError: semanticCompactError?.message || null,
785
+ usage: semanticCompactResult?.usage || null,
786
+ };
787
+ }
788
+ async function autoCompactSessionAfterTurn(session, opts = {}) {
789
+ return runSessionCompaction(session, { ...opts, mode: 'auto', force: false });
790
+ }
791
+ // Provider-scoped unified cache key. Goal: all orchestrator-internal
792
+ // dispatches (bridge/maintenance/mcp/scheduler/webhook) targeting the
793
+ // same provider land in a single server-side cache shard, so the
794
+ // shared prefix (tools + system + pool system prompt) is reused
795
+ // regardless of role. Per-role / per-session differentiation lives in
796
+ // the message tail, which is naturally separated by content hashing.
797
+ const PROVIDER_ALIAS = {
798
+ 'openai-oauth': 'codex', // ChatGPT subscription (Codex backend)
799
+ 'anthropic-oauth': 'claude', // Claude Max subscription
800
+ 'openai': 'openai',
801
+ 'anthropic': 'anthropic',
802
+ 'gemini': 'gemini',
803
+ 'deepseek': 'deepseek',
804
+ 'xai': 'xai',
805
+ };
806
+ function providerCacheKey(provider, override) {
807
+ if (override) return String(override);
808
+ if (!provider) return 'mixdog-default';
809
+ return `mixdog-${PROVIDER_ALIAS[provider] || provider}`;
810
+ }
811
+
812
+ // ── Prefetch permission guard ─────────────────────────────────────────────────
813
+ // Mirrors _checkWorkerPermission in loop.mjs for tool calls that originate
814
+ // in the prefetch path (outside the agent loop). Returns an error string if
815
+ // blocked, or null if allowed.
816
+ const _permEvalForPrefetch = (() => {
817
+ const _req = createRequire(import.meta.url);
818
+ try {
819
+ const { dirname: _pdir, resolve: _pres } = _req('path');
820
+ const _hooksLib = _pres(_pdir(fileURLToPath(import.meta.url)), '../../../../hooks/lib/permission-evaluator.cjs');
821
+ return _req(_hooksLib).evaluatePermission;
822
+ } catch { return null; }
823
+ })();
824
+ function _guardedPrefetchTool(toolName, toolArgs, session) {
825
+ if (!_permEvalForPrefetch) return null;
826
+ // Same baseline as _checkWorkerPermission: when no explicit mode is
827
+ // attached to the session, run the evaluator under 'default' so the
828
+ // bypass-proof hard-deny patterns still apply during prefetch dispatch.
829
+ const permissionMode = session?.permissionMode || 'default';
830
+ const projectDir = session?.cwd || undefined;
831
+ const userCwd = session?.cwd || undefined;
832
+ const MCP_PFX = 'mcp__plugin_mixdog_mixdog__';
833
+ const fullName = toolName.startsWith(MCP_PFX) || toolName.startsWith('mcp__') ? toolName : `${MCP_PFX}${toolName}`;
834
+ try {
835
+ const { decision, reason } = _permEvalForPrefetch({ toolName: fullName, toolInput: toolArgs || {}, permissionMode, projectDir, userCwd });
836
+ if (decision === 'deny' || decision === 'ask') {
837
+ return `Error: prefetch tool "${toolName}" blocked (decision=${decision}): ${reason}`;
838
+ }
839
+ } catch (e) {
840
+ process.stderr.write(`[prefetch-guard] evaluator error: ${e?.message}\n`);
841
+ }
842
+ return null;
843
+ }
844
+
845
+ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
846
+ if (!explicitPrefetch || typeof explicitPrefetch !== 'object') return null;
847
+ if (session?.owner !== 'bridge') return null;
848
+ const parts = [];
849
+ const failed = [];
850
+ const totalEntries = [];
851
+ // files[] — string entries use the default head excerpt; object entries
852
+ // {path, n?, full?} let the caller widen the window or pull the full file
853
+ // so worker doesn't have to re-read deep ranges of an already-prefetched
854
+ // file (a recurring iter burner observed in baseline session telemetry).
855
+ const _rawFilesIn = Array.isArray(explicitPrefetch.files) ? explicitPrefetch.files : [];
856
+ const _readOptsByFile = new Map();
857
+ const files = [];
858
+ const _seenFiles = new Set();
859
+ const _addPrefetchFile = (file, opts = null) => {
860
+ if (typeof file !== 'string' || !file) return;
861
+ if (!_seenFiles.has(file)) {
862
+ _seenFiles.add(file);
863
+ files.push(file);
864
+ }
865
+ if (!opts || Object.keys(opts).length === 0) return;
866
+ const prev = _readOptsByFile.get(file) || {};
867
+ const merged = { ...prev };
868
+ if (opts.mode === 'full') {
869
+ merged.mode = 'full';
870
+ delete merged.n;
871
+ } else if (merged.mode !== 'full' && Number.isFinite(opts.n) && opts.n > 0) {
872
+ merged.n = Math.max(Number(merged.n) || 0, opts.n);
873
+ }
874
+ if (Object.keys(merged).length > 0) _readOptsByFile.set(file, merged);
875
+ };
876
+ for (const entry of _rawFilesIn) {
877
+ if (typeof entry === 'string' && entry) {
878
+ _addPrefetchFile(entry);
879
+ } else if (entry && typeof entry === 'object' && typeof entry.path === 'string' && entry.path) {
880
+ const opts = {};
881
+ if (entry.full === true) opts.mode = 'full';
882
+ else if (Number.isFinite(entry.n) && entry.n > 0) opts.n = entry.n;
883
+ _addPrefetchFile(entry.path, opts);
884
+ }
885
+ }
886
+ if (files.length > 0) {
887
+ const _pfGuard = _guardedPrefetchTool('read', { path: files }, session);
888
+ if (_pfGuard) {
889
+ process.stderr.write(`[bridge-prefetch] files read blocked: ${_pfGuard}\n`);
890
+ failed.push(...files);
891
+ totalEntries.push(...files);
892
+ } else {
893
+ totalEntries.push(...files);
894
+ // R20: per-file prefetch cache (cross-dispatch, process-local).
895
+ // Try each file from cache first; batch misses into one disk read.
896
+ const { resolve: _pfResolve, isAbsolute: _pfIsAbs, normalize: _pfNorm } = await import('path');
897
+ const _pfCwd = session.cwd || null;
898
+ function _pfAbsPath(f) {
899
+ const abs = _pfIsAbs(f) ? f : _pfResolve(_pfCwd || process.cwd(), f);
900
+ return _pfNorm(abs);
901
+ }
902
+ const fileHits = []; // { file, abs, content } — satisfied from cache
903
+ const fileMisses = []; // { file, abs } — need disk read
904
+ for (const f of files) {
905
+ const abs = _pfAbsPath(f);
906
+ // Skip the cross-dispatch cache when the caller asked for a
907
+ // non-default window (custom n or full-file). Cache key is the
908
+ // path alone, so a default-window cache hit would silently feed
909
+ // the wrong slice back to the next caller.
910
+ const hit = _readOptsByFile.has(f) ? null : tryPrefetchCached(abs);
911
+ if (hit) {
912
+ fileHits.push({ file: f, abs, content: hit.content });
913
+ } else {
914
+ fileMisses.push({ file: f, abs });
915
+ }
916
+ }
917
+ // Disk read for misses (single batch call).
918
+ const missFiles = fileMisses.map(m => m.file);
919
+ const missResults = {}; // file → content string
920
+ if (missFiles.length > 0) {
921
+ // Read each miss file individually so we can cache per-file.
922
+ // The files list is small (typically 2-5), so N awaits is fine.
923
+ await Promise.all(missFiles.map(async (f) => {
924
+ const opts = _readOptsByFile.get(f) || {};
925
+ const readArgs = { path: f };
926
+ if (opts.mode === 'full') {
927
+ readArgs.mode = 'full';
928
+ } else {
929
+ readArgs.mode = 'head';
930
+ readArgs.n = Number.isFinite(opts.n) ? opts.n : 120;
931
+ }
932
+ const out = await executeInternalTool('read', readArgs).catch((e) => {
933
+ process.stderr.write(`[bridge-prefetch] file read failed (${f}): ${e && e.message || e}\n`);
934
+ return null;
935
+ });
936
+ if (out !== null) {
937
+ missResults[f] = String(out);
938
+ }
939
+ }));
940
+ // Cache successful miss results.
941
+ for (const { file, abs } of fileMisses) {
942
+ const content = missResults[file];
943
+ if (content && classifyResultKind(content) !== 'error') {
944
+ // Only cache default-window reads; custom-window results
945
+ // would poison the shared cross-dispatch cache.
946
+ if (!_readOptsByFile.has(file)) setPrefetchCached(abs, content);
947
+ } else if (content === undefined || classifyResultKind(content) === 'error') {
948
+ failed.push(file);
949
+ }
950
+ }
951
+ }
952
+ // Assemble combined output preserving original file order.
953
+ const readParts = [];
954
+ const hitByFile = new Map(fileHits.map((h) => [h.file, h]));
955
+ for (const f of files) {
956
+ const hitEntry = hitByFile.get(f);
957
+ if (hitEntry) {
958
+ readParts.push(hitEntry.content);
959
+ continue;
960
+ }
961
+ const content = missResults[f];
962
+ if (content && classifyResultKind(content) !== 'error') {
963
+ readParts.push(content);
964
+ }
965
+ // else: already pushed to failed above
966
+ }
967
+ if (readParts.length > 0) {
968
+ parts.push(`### prefetch files\nread ${readParts.length}\n\n${readParts.join('\n\n')}`);
969
+ }
970
+ // Log hit/miss counters so dispatch telemetry shows prefetch effectiveness.
971
+ process.stderr.write(
972
+ `[prefetch] files=${files.length} cached=${fileHits.length} miss=${fileMisses.length} failed=${failed.length}\n`
973
+ );
974
+ // Attach stats to session so post-hoc analyzers (inspect-session.mjs)
975
+ // can see prefetch effectiveness without parsing stderr logs.
976
+ if (session && typeof session === 'object') {
977
+ if (!session.prefetchStats) session.prefetchStats = { files: 0, cached: 0, miss: 0, failed: 0 };
978
+ session.prefetchStats.files += files.length;
979
+ session.prefetchStats.cached += fileHits.length;
980
+ session.prefetchStats.miss += fileMisses.length;
981
+ session.prefetchStats.failed += failed.length;
982
+ }
983
+ }
984
+ }
985
+ // callers[]
986
+ const callers = Array.isArray(explicitPrefetch.callers) ? explicitPrefetch.callers.filter(c => c && typeof c.symbol === 'string') : [];
987
+ {
988
+ const callerTasks = callers.map(({ symbol, file }) => {
989
+ const cgArgs = { mode: 'callers', symbol };
990
+ if (file) cgArgs.file = file;
991
+ if (session?.cwd) cgArgs.cwd = session.cwd;
992
+ totalEntries.push(symbol);
993
+ const blocked = _guardedPrefetchTool('code_graph', cgArgs, session);
994
+ if (blocked) {
995
+ process.stderr.write(`[bridge-prefetch] callers(${symbol}) blocked: ${blocked}\n`);
996
+ return Promise.resolve({ symbol, out: null, blocked: true });
997
+ }
998
+ return executeCodeGraphTool('code_graph', cgArgs, session?.cwd)
999
+ .then(out => ({ symbol, out }))
1000
+ .catch(e => {
1001
+ process.stderr.write(`[bridge-prefetch] callers(${symbol}) failed: ${e && e.message || e}\n`);
1002
+ return { symbol, out: null };
1003
+ });
1004
+ });
1005
+ const callerResults = await Promise.allSettled(callerTasks);
1006
+ for (const r of callerResults) {
1007
+ const { symbol, out, blocked } = r.status === 'fulfilled' ? r.value : { symbol: '?', out: null };
1008
+ if (blocked) { failed.push(symbol); continue; }
1009
+ if (out && classifyResultKind(String(out)) !== 'error') {
1010
+ parts.push(`### prefetch callers ${symbol}\n${out}`);
1011
+ } else {
1012
+ failed.push(symbol);
1013
+ }
1014
+ }
1015
+ }
1016
+ // references[]
1017
+ const references = Array.isArray(explicitPrefetch.references) ? explicitPrefetch.references.filter(r => r && typeof r.symbol === 'string') : [];
1018
+ {
1019
+ const refTasks = references.map(({ symbol, file }) => {
1020
+ const cgArgs = { mode: 'references', symbol };
1021
+ if (file) cgArgs.file = file;
1022
+ if (session?.cwd) cgArgs.cwd = session.cwd;
1023
+ totalEntries.push(symbol);
1024
+ const blocked = _guardedPrefetchTool('code_graph', cgArgs, session);
1025
+ if (blocked) {
1026
+ process.stderr.write(`[bridge-prefetch] references(${symbol}) blocked: ${blocked}\n`);
1027
+ return Promise.resolve({ symbol, out: null, blocked: true });
1028
+ }
1029
+ return executeCodeGraphTool('code_graph', cgArgs, session?.cwd)
1030
+ .then(out => ({ symbol, out }))
1031
+ .catch(e => {
1032
+ process.stderr.write(`[bridge-prefetch] references(${symbol}) failed: ${e && e.message || e}\n`);
1033
+ return { symbol, out: null };
1034
+ });
1035
+ });
1036
+ const refResults = await Promise.allSettled(refTasks);
1037
+ for (const r of refResults) {
1038
+ const { symbol, out, blocked } = r.status === 'fulfilled' ? r.value : { symbol: '?', out: null };
1039
+ if (blocked) { failed.push(symbol); continue; }
1040
+ if (out && classifyResultKind(String(out)) !== 'error') {
1041
+ parts.push(`### prefetch references ${symbol}\n${out}`);
1042
+ } else {
1043
+ failed.push(symbol);
1044
+ }
1045
+ }
1046
+ }
1047
+ if (session && typeof session === 'object' && (callers.length > 0 || references.length > 0)) {
1048
+ if (!session.prefetchStats) session.prefetchStats = { files: 0, cached: 0, miss: 0, failed: 0, callers: 0, references: 0 };
1049
+ session.prefetchStats.callers = (session.prefetchStats.callers || 0) + callers.length;
1050
+ session.prefetchStats.references = (session.prefetchStats.references || 0) + references.length;
1051
+ }
1052
+ if (parts.length === 0) {
1053
+ // All entries failed but Lead presence must still be signalled — emit
1054
+ // warn-only so the gate logic can distinguish "prefetch was requested"
1055
+ // from "no prefetch at all".
1056
+ if (totalEntries.length > 0 && failed.length > 0) {
1057
+ return `<prefetch-warn>${failed.length} of ${totalEntries.length} prefetch entries failed: ${[...new Set(failed)].join(', ')}</prefetch-warn>`;
1058
+ }
1059
+ return null;
1060
+ }
1061
+ const warnLine = failed.length > 0
1062
+ ? `<prefetch-warn>${failed.length} of ${totalEntries.length} prefetch entries failed: ${[...new Set(failed)].join(', ')}</prefetch-warn>\n`
1063
+ : '';
1064
+ return `${warnLine}<prefetch>\n${parts.join('\n\n')}\n</prefetch>`;
1065
+ }
1066
+
1067
+ // --- bridge spawn (createSession) ---
1068
+ // opts can pass either a `preset` object (from config.presets) or raw provider/model.
1069
+ // Preset shape: { name, provider, model, effort?, fast?, tools? }
1070
+ //
1071
+ // Smart Bridge integration:
1072
+ // opts.taskType / opts.role / opts.profileId — enables profile-aware routing.
1073
+ // Rule-based SmartRouter resolves these synchronously; the resolved
1074
+ // profile controls context filtering (skip.skills/memory/etc) and cache
1075
+ // strategy. If no rule matches, falls back to classic preset behavior.
1076
+ // opts.profile — pre-resolved profile (bypasses router; used by async
1077
+ // callers who already ran SmartBridge.resolve()).
1078
+ // opts.providerCacheOpts — pre-resolved cache options merged into ask() sendOpts.
1079
+ export function createSession(opts) {
1080
+ const presetObj = opts.preset && typeof opts.preset === 'object' ? opts.preset : null;
1081
+
1082
+ // --- Smart Bridge profile resolution (best-effort, sync) ---
1083
+ let profile = opts.profile || null;
1084
+ let providerCacheOpts = opts.providerCacheOpts || null;
1085
+ if (!profile && (opts.taskType || opts.role || opts.profileId)) {
1086
+ const smartBridge = getSmartBridgeSync();
1087
+ if (smartBridge) {
1088
+ try {
1089
+ const resolved = smartBridge.resolveSync({
1090
+ taskType: opts.taskType,
1091
+ role: opts.role,
1092
+ profileId: opts.profileId,
1093
+ preset: presetObj?.name || (typeof opts.preset === 'string' ? opts.preset : null),
1094
+ provider: opts.provider || presetObj?.provider,
1095
+ });
1096
+ if (resolved) {
1097
+ profile = resolved.profile;
1098
+ providerCacheOpts = resolved.providerCacheOpts;
1099
+ }
1100
+ } catch (e) {
1101
+ // Smart Bridge error — log once, fall back to classic behavior.
1102
+ if (!_smartBridgeWarned) {
1103
+ _smartBridgeWarned = true;
1104
+ process.stderr.write(`[session] smart bridge resolve failed: ${e.message}\n`);
1105
+ }
1106
+ }
1107
+ }
1108
+ }
1109
+
1110
+ const providerName = opts.provider || presetObj?.provider
1111
+ || (profile?.preferredProviders?.[0]);
1112
+ const modelName = opts.model || presetObj?.model;
1113
+ // opts.tools (caller-supplied) wins over presetObj.tools — caller
1114
+ // intent ('tools:readonly' from Pool C, etc.) must override the
1115
+ // preset's default 'full'. Previous priority let HAIKU's tools='full'
1116
+ // shadow Pool C's explicit readonly request, leaking write tools and
1117
+ // bash into a read-only agent.
1118
+ const toolPreset = opts.tools || presetObj?.tools || (typeof opts.preset === 'string' ? opts.preset : null) || 'full';
1119
+ const effort = Object.prototype.hasOwnProperty.call(opts, 'effort')
1120
+ ? (opts.effort || null)
1121
+ : (presetObj?.effort || null);
1122
+ const fast = presetObj?.fast === true || opts.fast === true;
1123
+ if (!providerName)
1124
+ throw new Error('createSession: provider is required');
1125
+ if (!modelName)
1126
+ throw new Error('createSession: model is required');
1127
+ const provider = getProvider(providerName);
1128
+ if (!provider)
1129
+ throw new Error(`Provider "${providerName}" not found or not enabled`);
1130
+ const id = `sess_${process.pid}_${nextId++}_${Date.now()}_${randomBytes(16).toString('hex')}`;
1131
+ const messages = [];
1132
+ const agentTemplate = opts.agent ? loadAgentTemplate(opts.agent, opts.cwd) : null;
1133
+ const skills = opts.skipSkills ? [] : collectSkillsCached(opts.cwd);
1134
+
1135
+ // Bridge shared prefix (bit-identical across roles). Hidden roles reuse the
1136
+ // same shared bridge rules so the cache shard stays stable across bridge
1137
+ // callers. User-defined data (DATA_DIR roles/schedules/webhooks) is baked
1138
+ // into BP1 as a single fixed-value monolithic block so every role shares
1139
+ // one cache shard. A user edit invalidates BP1 once and the new prefix
1140
+ // re-warms across all roles together.
1141
+ const bridgeRulesRole = opts.role || profile?.taskType || null;
1142
+ const injectedRules = opts.skipBridgeRules ? '' : (opts.owner === 'bridge' ? _buildBridgeRules() : _buildLeadRules());
1143
+ const roleSpecific = opts.owner === 'bridge' && !opts.skipBridgeRules ? _buildRoleSpecific(bridgeRulesRole) : '';
1144
+ // mixdog.md user/project context (global + cwd ancestors, broad-to-specific).
1145
+ const projectContext = collectMixdogMd(opts.cwd);
1146
+
1147
+ // Role template (Phase B §4 — UI-managed). Reads <DATA_DIR>/roles/<role>.md
1148
+ // and parses frontmatter (description, permission). The template is
1149
+ // injected into the Tier 3 system-reminder so role differences never
1150
+ // touch the BP_2 cache prefix.
1151
+ const resolvedRole = opts.role || profile?.taskType || null;
1152
+ const dataDir = resolvePluginData();
1153
+ const roleTemplate = resolvedRole && dataDir
1154
+ ? loadRoleTemplate(resolvedRole, dataDir)
1155
+ : null;
1156
+
1157
+ // Bridge sessions must not inherit role/profile/preset tool narrowing: Pool
1158
+ // B and Pool C share one bit-identical tool schema for BP_1/BP_2 cache
1159
+ // reuse, and permission differences are enforced only at call time. Raw
1160
+ // non-bridge callers keep the historical profile.tools / preset.tools
1161
+ // behaviour.
1162
+ const toolSpec = opts.owner === 'bridge'
1163
+ ? 'full'
1164
+ : (Array.isArray(profile?.tools) ? profile.tools : toolPreset);
1165
+
1166
+ // Prompt permission is metadata only. Preset tool restrictions must NOT
1167
+ // enter the prompt, or they split the shared bridge cache tail; they map
1168
+ // to toolPermission below and are enforced only at call time.
1169
+ const permission = opts.permission
1170
+ || roleTemplate?.permission
1171
+ || null;
1172
+ const toolPermission = opts.permission
1173
+ || profile?.permission
1174
+ || roleTemplate?.permission
1175
+ || permissionFromToolSpec(toolPreset)
1176
+ || null;
1177
+ let toolsForRouting = resolveSessionTools(toolSpec, skills, { ownerIsBridge: opts.owner === 'bridge' });
1178
+ // Fail-closed permission intersection: when a role declares an explicit
1179
+ // permission (from user-workflow.json or the role template), intersect the
1180
+ // resolved tool list with the permission's allow/deny lists. If the
1181
+ // intersection produces an empty set the permission config is broken —
1182
+ // fail closed (zero tools) rather than silently falling back to the full
1183
+ // preset, which would grant the role more surface than declared.
1184
+ if (toolPermission && typeof toolPermission === 'object') {
1185
+ const allowSet = Array.isArray(toolPermission.allow) && toolPermission.allow.length > 0
1186
+ ? new Set(toolPermission.allow.map(n => String(n).toLowerCase()))
1187
+ : null;
1188
+ const denySet = Array.isArray(toolPermission.deny) && toolPermission.deny.length > 0
1189
+ ? new Set(toolPermission.deny.map(n => String(n).toLowerCase()))
1190
+ : null;
1191
+ if (allowSet || denySet) {
1192
+ const filtered = toolsForRouting.filter(t => {
1193
+ const name = String(t?.name || '').toLowerCase();
1194
+ if (denySet && denySet.has(name)) return false;
1195
+ if (allowSet && !allowSet.has(name)) return false;
1196
+ return true;
1197
+ });
1198
+ // Fail-closed: an empty intersection means the permission config is
1199
+ // misconfigured — do not silently fall back to the full preset.
1200
+ toolsForRouting = filtered;
1201
+ if (filtered.length === 0) {
1202
+ process.stderr.write(`[session] WARN: role permission intersection produced 0 tools — failing closed (role=${opts.role || 'unknown'})
1203
+ `);
1204
+ }
1205
+ }
1206
+ }
1207
+
1208
+ const { baseRules, roleCatalog, sessionMarker, volatileTail } = composeSystemPrompt({
1209
+ userPrompt: opts.systemPrompt,
1210
+ bridgeRules: injectedRules || undefined,
1211
+ roleSpecific: roleSpecific || undefined,
1212
+ skipRoleCatalog: opts.owner !== 'bridge',
1213
+ agentTemplate: agentTemplate || undefined,
1214
+ roleTemplate: roleTemplate || undefined,
1215
+ hasSkills: skills.length > 0,
1216
+ profile: profile || undefined,
1217
+ role: resolvedRole,
1218
+ skipRoleReminder: opts.skipRoleReminder || false,
1219
+ permission,
1220
+ taskBrief: opts.taskBrief || null,
1221
+ workflowContext: opts.workflowContext || null,
1222
+ workspaceContext: opts.workspaceContext || null,
1223
+ coreMemoryContext: opts.coreMemoryContext || null,
1224
+ projectContext: projectContext || null,
1225
+ tools: toolsForRouting,
1226
+ bashIsPersistent: opts.owner === 'bridge' && toolsForRouting.some(t => t?.name === 'shell'),
1227
+ // Effective cwd rides in tier3Reminder so explore-like tools know
1228
+ // their search root without needing to shove "Override cwd:" into
1229
+ // the user message body (that used to fragment the shard prefix).
1230
+ cwd: opts.cwd || null,
1231
+ // BP2 catalog policy — explicit-cache providers see the unified
1232
+ // all-roles catalog; implicit-prefix-hash providers keep self-only.
1233
+ provider: providerName || null,
1234
+ });
1235
+ // 4-BP layout (see composeSystemPrompt docs):
1236
+ // system block #1 = baseRules — BP1 (1h) shared across ALL roles
1237
+ // system block #2 = roleCatalog — BP2 (1h) scoped role catalog
1238
+ // first <system-reminder> user = sessionMarker — BP3 (1h) mixdog.md + stable role body
1239
+ // second <system-reminder> user = volatileTail — rides near BP4 (5m)
1240
+ // Anthropic multi-block system pins each block with cache_control.
1241
+ // OpenAI gets a stable provider cache key/session prefix. Gemini relies
1242
+ // on implicit prompt caching only, so hits are observed, not treated as a
1243
+ // guaranteed warm shard.
1244
+ if (baseRules) {
1245
+ messages.push({ role: 'system', content: baseRules });
1246
+ }
1247
+ if (roleCatalog) {
1248
+ messages.push({ role: 'system', content: roleCatalog });
1249
+ }
1250
+ if (sessionMarker) {
1251
+ messages.push({ role: 'user', content: `<system-reminder>\n${sessionMarker}\n</system-reminder>` });
1252
+ messages.push({ role: 'assistant', content: '.' });
1253
+ }
1254
+ if (volatileTail) {
1255
+ messages.push({ role: 'user', content: `<system-reminder>\n${volatileTail}\n</system-reminder>` });
1256
+ messages.push({ role: 'assistant', content: '.' });
1257
+ }
1258
+ if (opts.files?.length) {
1259
+ const fileContext = opts.files
1260
+ .map(f => `### ${f.path}\n\`\`\`\n${f.content}\n\`\`\``)
1261
+ .join('\n\n');
1262
+ messages.push({ role: 'user', content: `Reference files:\n\n${fileContext}` });
1263
+ messages.push({ role: 'assistant', content: '.' });
1264
+ }
1265
+ let tools = toolsForRouting;
1266
+
1267
+ // Schema filtering applied after schema build:
1268
+ // - opts.schemaAllowedTools : declarative hidden-role schema profile
1269
+ // allowlist for tiny specialist roles where one-shot tool routing
1270
+ // beats the shared-schema cache win.
1271
+ // - opts.disallowedTools : per-call caller override (Anthropic
1272
+ // BuiltInAgentDefinition pattern)
1273
+ // - annotations.bridgeHidden : declarative per-tool flag (tools.json
1274
+ // and internal tool defs). Pool A (Lead) still sees all tools.
1275
+ //
1276
+ const hasCallerAllow = Array.isArray(opts.schemaAllowedTools);
1277
+ const callerAllow = hasCallerAllow ? opts.schemaAllowedTools.map(n => String(n).toLowerCase()) : [];
1278
+ if (hasCallerAllow) {
1279
+ const allowSet = new Set(callerAllow);
1280
+ const before = tools.length;
1281
+ tools = tools.filter(t => allowSet.has(String(t?.name || '').toLowerCase()));
1282
+ if (tools.length !== before && !process.env.MIXDOG_QUIET_SESSION_LOG) {
1283
+ process.stderr.write(`[session] schemaAllowedTools=${callerAllow.join(',')} kept ${tools.length}/${before} tools\n`);
1284
+ }
1285
+ }
1286
+ const callerDeny = Array.isArray(opts.disallowedTools) ? opts.disallowedTools.map(n => String(n)) : [];
1287
+ if (callerDeny.length) {
1288
+ const denySet = new Set(callerDeny);
1289
+ const before = tools.length;
1290
+ tools = tools.filter(t => !denySet.has(String(t?.name || '').toLowerCase()));
1291
+ if (tools.length !== before && !process.env.MIXDOG_QUIET_SESSION_LOG) {
1292
+ process.stderr.write(`[session] disallowedTools=${callerDeny.join(',')} stripped ${before - tools.length} tools\n`);
1293
+ }
1294
+ }
1295
+ if (opts.owner === 'bridge') {
1296
+ const before = tools.length;
1297
+ tools = tools.filter(t => !t?.annotations?.bridgeHidden);
1298
+ if (tools.length !== before && !process.env.MIXDOG_QUIET_SESSION_LOG) {
1299
+ process.stderr.write(`[session] bridgeHidden stripped ${before - tools.length} tools\n`);
1300
+ }
1301
+ }
1302
+
1303
+ // Bridge tool canonicalization: keep route-sensitive tools in policy order
1304
+ // while preserving deterministic MCP/skill order for BP1 shard stability.
1305
+ if (opts.owner === 'bridge') {
1306
+ tools = orderSessionTools(tools);
1307
+ }
1308
+
1309
+ // Unified-shard policy — no broad role-specific schema filter. Keep
1310
+ // bridge schemas shared unless a hidden-role schema profile explicitly
1311
+ // passes schemaAllowedTools for a small specialist; broad role
1312
+ // whitelists would fragment the cache shard.
1313
+ if (resolvedRole && !process.env.MIXDOG_QUIET_SESSION_LOG) {
1314
+ process.stderr.write(`[session] role=${resolvedRole} permission=${permission || 'full'} toolPermission=${toolPermission || 'full'} tools=${tools.length}\n`);
1315
+ }
1316
+ const contextMeta = resolveSessionContextMeta(provider, modelName);
1317
+ const session = {
1318
+ id,
1319
+ provider: providerName,
1320
+ model: modelName,
1321
+ messages,
1322
+ contextWindow: contextMeta.contextWindow,
1323
+ rawContextWindow: contextMeta.rawContextWindow,
1324
+ effectiveContextWindowPercent: contextMeta.effectiveContextWindowPercent,
1325
+ autoCompactTokenLimit: contextMeta.autoCompactTokenLimit,
1326
+ compactBoundaryTokens: contextMeta.compactBoundaryTokens,
1327
+ compaction: {
1328
+ auto: opts.compaction?.auto !== false,
1329
+ prune: opts.compaction?.prune === true,
1330
+ semantic: opts.compaction?.semantic ?? 'auto',
1331
+ model: opts.compaction?.model || null,
1332
+ timeoutMs: positiveContextWindow(opts.compaction?.timeoutMs),
1333
+ tailTurns: positiveContextWindow(opts.compaction?.tailTurns),
1334
+ bufferTokens: positiveContextWindow(opts.compaction?.bufferTokens ?? opts.compaction?.buffer),
1335
+ keepTokens: positiveContextWindow(opts.compaction?.keepTokens ?? opts.compaction?.keep?.tokens),
1336
+ preserveRecentTokens: positiveContextWindow(opts.compaction?.preserveRecentTokens),
1337
+ reservedTokens: positiveContextWindow(opts.compaction?.reservedTokens),
1338
+ boundaryTokens: contextMeta.compactBoundaryTokens,
1339
+ },
1340
+ tools,
1341
+ preset: toolPreset,
1342
+ presetName: presetObj?.name || null,
1343
+ effort,
1344
+ fast,
1345
+ agent: opts.agent,
1346
+ owner: opts.owner || 'user',
1347
+ mcpPid: process.pid,
1348
+ scopeKey: opts.scopeKey || null,
1349
+ lane: opts.lane || 'bridge',
1350
+ cwd: opts.cwd,
1351
+ createdAt: Date.now(),
1352
+ updatedAt: Date.now(),
1353
+ lastHeartbeatAt: null,
1354
+ totalInputTokens: 0,
1355
+ totalOutputTokens: 0,
1356
+ // Refreshed on each completed ask() — surfaced by bridge type=list for
1357
+ // debugging + consumed by store.mjs's idle-sweep to reclaim stalled
1358
+ // bridge sessions past RUNNING_STALL_MS.
1359
+ lastUsedAt: Date.now(),
1360
+ tokensCumulative: 0,
1361
+ role: opts.role || null,
1362
+ taskType: opts.taskType || null,
1363
+ maxLoopIterations: Number.isFinite(opts.maxLoopIterations) ? opts.maxLoopIterations : null,
1364
+ // Bridge tag (auto worker{n} on spawn) persisted so the forked status
1365
+ // process (statusline) + aggregator can read it from the session JSON.
1366
+ // In-process send/close still resolve via _tagSessionRegistry.
1367
+ bridgeTag: opts.bridgeTag || null,
1368
+ // Prompt permission is separate from runtime toolPermission so preset
1369
+ // restrictions do not fragment the bridge cache prefix.
1370
+ permission: permission || null,
1371
+ toolPermission: toolPermission || null,
1372
+ // Origin tag written into every bridge-trace usage row so analytics
1373
+ // can slice by (sourceType, sourceName) — e.g. maintenance/cycle1,
1374
+ // scheduler/daily-standup, webhook/github-push, lead/worker.
1375
+ sourceType: opts.sourceType || null,
1376
+ sourceName: opts.sourceName || null,
1377
+ // Provider-scoped unified cache key — one shard per provider,
1378
+ // shared across all roles / sources (bridge/maintenance/mcp/
1379
+ // scheduler/webhook). Role or source-specific context must be
1380
+ // injected into the message tail, not the shared prefix.
1381
+ promptCacheKey: providerCacheKey(presetObj?.provider || opts.provider, opts.cacheKeyOverride),
1382
+ // Bridge shell continuity: when a bridge session explicitly opts into
1383
+ // persistent shell state (`bash` with `persistent:true`, or direct
1384
+ // `bash_session`), the minted bash_session id is stored here so later
1385
+ // opted-in `bash` calls can reuse the same shell state.
1386
+ implicitBashSessionId: null,
1387
+ // Tracks every persistent bash session id minted during this
1388
+ // orchestrator session so closeSession can kill them all, not just
1389
+ // the most recently recorded one.
1390
+ allBashSessionIds: [],
1391
+ // Smart Bridge metadata — optional. Applied on every ask() to merge
1392
+ // profile-driven cache settings into provider sendOpts.
1393
+ profileId: profile?.id || null,
1394
+ permissionMode: opts.permissionMode ?? null,
1395
+ providerCacheOpts: providerCacheOpts || null,
1396
+ ownerSessionId: opts.ownerSessionId || null,
1397
+ clientHostPid: opts.clientHostPid || null,
1398
+ };
1399
+ // In-process registry + async debounced save: same-process create → load
1400
+ // reads live memory; disk flush is for cross-process / restart durability.
1401
+ setLiveSession(session);
1402
+ saveSession(session);
1403
+ return session;
1404
+ }
1405
+
1406
+ // ── Runtime liveness map ──────────────────────────────────────────────
1407
+ // In-memory only. Tracks per-session stage + stream heartbeat so bridge type=list
1408
+ // can surface whether a session is actually alive vs stuck. Never persisted —
1409
+ // heartbeats would otherwise churn the session JSON on every SSE delta.
1410
+ // Entry shape: {
1411
+ // stage, lastStreamDeltaAt, lastToolCall, lastError, updatedAt,
1412
+ // controller?: AbortController, // set while an ask is in flight
1413
+ // generation?: number, // snapshot taken at ask start
1414
+ // closed?: boolean, // flipped by closeSession()
1415
+ // }
1416
+ const _runtimeState = new Map();
1417
+ const VALID_STAGES = new Set([
1418
+ 'connecting', 'requesting', 'streaming', 'tool_running', 'idle', 'error', 'done', 'cancelling',
1419
+ ]);
1420
+ function _touchRuntime(id) {
1421
+ let entry = _runtimeState.get(id);
1422
+ if (!entry) {
1423
+ entry = { stage: 'idle', lastStreamDeltaAt: null, lastToolCall: null, lastError: null, updatedAt: Date.now() };
1424
+ _runtimeState.set(id, entry);
1425
+ }
1426
+ return entry;
1427
+ }
1428
+ export function updateSessionStage(id, stage) {
1429
+ if (!id || !VALID_STAGES.has(stage)) return;
1430
+ const entry = _touchRuntime(id);
1431
+ const now = Date.now();
1432
+ entry.stage = stage;
1433
+ if (stage === 'connecting' || stage === 'requesting') {
1434
+ entry.modelRequestStartedAt = now;
1435
+ }
1436
+ entry.lastProgressAt = now;
1437
+ entry.updatedAt = now;
1438
+ }
1439
+
1440
+ export function updateSessionRoute(id, route = {}) {
1441
+ if (!id) return null;
1442
+ const session = loadSession(id);
1443
+ if (!session || session.closed === true) return null;
1444
+ const previousProvider = session.provider || null;
1445
+ const previousModel = session.model || null;
1446
+ if (route.provider) session.provider = route.provider;
1447
+ if (route.model) session.model = route.model;
1448
+ if (Object.prototype.hasOwnProperty.call(route, 'fast')) session.fast = route.fast === true;
1449
+ if (Object.prototype.hasOwnProperty.call(route, 'effort')) session.effort = route.effort || null;
1450
+ const provider = session.provider ? getProvider(session.provider) : null;
1451
+ if (provider && session.model) {
1452
+ const contextMeta = resolveSessionContextMeta(provider, session.model);
1453
+ session.contextWindow = contextMeta.contextWindow;
1454
+ session.rawContextWindow = contextMeta.rawContextWindow;
1455
+ session.effectiveContextWindowPercent = contextMeta.effectiveContextWindowPercent;
1456
+ session.autoCompactTokenLimit = contextMeta.autoCompactTokenLimit;
1457
+ session.compactBoundaryTokens = contextMeta.compactBoundaryTokens;
1458
+ session.compaction = {
1459
+ ...(session.compaction || {}),
1460
+ boundaryTokens: contextMeta.compactBoundaryTokens,
1461
+ contextWindow: contextMeta.contextWindow,
1462
+ rawContextWindow: contextMeta.rawContextWindow,
1463
+ effectiveContextWindowPercent: contextMeta.effectiveContextWindowPercent,
1464
+ autoCompactTokenLimit: contextMeta.autoCompactTokenLimit,
1465
+ };
1466
+ } else {
1467
+ delete session.contextWindow;
1468
+ delete session.rawContextWindow;
1469
+ delete session.effectiveContextWindowPercent;
1470
+ delete session.autoCompactTokenLimit;
1471
+ delete session.compactBoundaryTokens;
1472
+ }
1473
+ const routeChanged = (route.provider && route.provider !== previousProvider)
1474
+ || (route.model && route.model !== previousModel);
1475
+ if (routeChanged) {
1476
+ const now = Date.now();
1477
+ session.lastInputTokens = 0;
1478
+ session.lastOutputTokens = 0;
1479
+ session.lastCachedReadTokens = 0;
1480
+ session.lastCacheWriteTokens = 0;
1481
+ session.lastContextTokens = 0;
1482
+ session.lastContextTokensUpdatedAt = now;
1483
+ session.lastContextTokensStaleAfterCompact = false;
1484
+ session.providerState = undefined;
1485
+ }
1486
+ session.updatedAt = Date.now();
1487
+ setLiveSession(session);
1488
+ void saveSessionAsync(session, { expectedGeneration: session.generation })
1489
+ .catch((err) => {
1490
+ try { process.stderr.write(`[session] route update save failed: ${err?.message || err}\n`); } catch {}
1491
+ });
1492
+ return session;
1493
+ }
1494
+
1495
+ /**
1496
+ * Reset heartbeat-visible fields for a new ask. Preserves controller/generation/
1497
+ * closed (lifecycle) but clears the previous run's streaming state so stale
1498
+ * lastToolCall / lastStreamDeltaAt from the previous ask don't leak into the
1499
+ * new one.
1500
+ */
1501
+ export function markSessionAskStart(id) {
1502
+ if (!id) return;
1503
+ const entry = _touchRuntime(id);
1504
+ entry.stage = 'connecting';
1505
+ entry.lastStreamDeltaAt = null;
1506
+ entry.lastToolCall = null;
1507
+ entry.toolStartedAt = null;
1508
+ entry.lastError = null;
1509
+ // A new ask starts a fresh turn lifecycle — clear any stale empty-final
1510
+ // classification from the prior turn so inspectBridgeEntry doesn't keep
1511
+ // short-circuiting to 'empty-synthesis' (which would disable stall
1512
+ // detection for the entire new turn).
1513
+ entry.emptyFinal = false;
1514
+ entry.emptyFinalAt = null;
1515
+ // askStartedAt is the watchdog's fallback reference when a session
1516
+ // hangs before any stream delta arrives. Without it, a provider that
1517
+ // never returns a first token would stall forever because the watchdog
1518
+ // keys solely on lastStreamDeltaAt.
1519
+ const now = Date.now();
1520
+ entry.askStartedAt = now;
1521
+ entry.modelRequestStartedAt = now;
1522
+ entry.lastProgressAt = now;
1523
+ entry.updatedAt = now;
1524
+ // Publish heartbeat immediately so the status aggregator picks the
1525
+ // session up in the connecting / requesting window. Without this the
1526
+ // .hb file only landed on the first stream chunk — producing a 3–10s
1527
+ // (xhigh: 30s+) invisible gap where bridge sessions ran but the CC
1528
+ // statusline showed no maintenance/agent badge. STREAM_FRESH_MS (5 min)
1529
+ // still drops a session whose provider truly never returns a chunk;
1530
+ // markSessionStreamDelta keeps refreshing once chunks arrive.
1531
+ publishHeartbeat(id, now);
1532
+ }
1533
+ export async function markSessionStreamDelta(id) {
1534
+ if (!id) return;
1535
+ // Non-creating lookup: a live ask ALWAYS has a runtime entry (markSessionAskStart
1536
+ // creates it before streaming begins). _touchRuntime would instead resurrect a
1537
+ // blank entry — and closeSession()/idle-sweep clear _runtimeState on a deferred
1538
+ // tick while a detached provider stream may still be trickling deltas. A delta
1539
+ // arriving after that clear must NOT re-create an entry or it would republish the
1540
+ // .hb heartbeat that markSessionClosed deleted, orphaning a dead session's
1541
+ // heartbeat indefinitely (the disk tombstone blocks ask resumption but not this
1542
+ // path). Skip a missing, tombstoned, or aborted entry — never refresh liveness.
1543
+ const entry = _runtimeState.get(id);
1544
+ if (!entry || entry.closed || entry.controller?.signal?.aborted) return;
1545
+ const now = Date.now();
1546
+ entry.lastStreamDeltaAt = now;
1547
+ entry.lastProgressAt = now;
1548
+ // Only promote to 'streaming' if we were in a pre-stream stage; never downgrade
1549
+ // mid-tool (tool_running has its own delta source if the tool streams back).
1550
+ if (entry.stage === 'connecting' || entry.stage === 'requesting') {
1551
+ entry.stage = 'streaming';
1552
+ }
1553
+ // Lightweight heartbeat (≤5s self-throttled) for the status aggregator.
1554
+ // Disk-side session.lastHeartbeatAt below is the heavy 60s zombie-reaper
1555
+ // signal; the .hb file is the fast fresh-session signal consumed by the
1556
+ // status line.
1557
+ publishHeartbeat(id, now);
1558
+ const session = entry.session;
1559
+ if (session && now - (session.lastHeartbeatAt || 0) > HEARTBEAT_THROTTLE_MS) {
1560
+ session.lastHeartbeatAt = now;
1561
+ await saveSessionAsync(session, { expectedGeneration: session.generation });
1562
+ }
1563
+ entry.updatedAt = now;
1564
+ }
1565
+ export function markSessionToolCall(id, toolName) {
1566
+ if (!id) return;
1567
+ const entry = _touchRuntime(id);
1568
+ entry.stage = 'tool_running';
1569
+ entry.lastToolCall = toolName || null;
1570
+ entry.toolStartedAt = Date.now();
1571
+ entry.lastProgressAt = entry.toolStartedAt;
1572
+ entry.updatedAt = entry.toolStartedAt;
1573
+ publishHeartbeat(id, entry.toolStartedAt);
1574
+ }
1575
+ export function markSessionDone(id, { empty = false } = {}) {
1576
+ if (!id) return;
1577
+ const entry = _touchRuntime(id);
1578
+ entry.stage = 'done';
1579
+ entry.lastError = null;
1580
+ entry.askStartedAt = null;
1581
+ entry.toolStartedAt = null;
1582
+ // Non-empty completion: drop any stale empty-final flag so a subsequent
1583
+ // ask on the same reusable runtime entry starts clean. Empty-final
1584
+ // completions preserve the flag (set by markSessionEmptyFinal just prior).
1585
+ if (!empty) {
1586
+ entry.emptyFinal = false;
1587
+ entry.emptyFinalAt = null;
1588
+ }
1589
+ const doneTs = Date.now();
1590
+ entry.doneAt = doneTs;
1591
+ entry.lastProgressAt = doneTs;
1592
+ entry.updatedAt = doneTs;
1593
+ // Terminal stage — drop the heartbeat so the status badge releases
1594
+ // immediately. A subsequent ask on the same session re-publishes via
1595
+ // markSessionStreamDelta on the first chunk.
1596
+ deleteHeartbeat(id);
1597
+ }
1598
+ // Tag a session as having completed with empty final synthesis (no
1599
+ // content/reasoning). Distinct from `markSessionDone`: still a success
1600
+ // (no abort), but the stall watchdog and post-mortem tools can
1601
+ // distinguish "finished empty" from "finished with content" without
1602
+ // mistaking the silence for a stall.
1603
+ export function markSessionEmptyFinal(id) {
1604
+ if (!id) return;
1605
+ const entry = _touchRuntime(id);
1606
+ entry.emptyFinal = true;
1607
+ entry.emptyFinalAt = Date.now();
1608
+ }
1609
+ export function markSessionError(id, msg) {
1610
+ if (!id) return;
1611
+ const entry = _touchRuntime(id);
1612
+ entry.stage = 'error';
1613
+ entry.lastError = msg ? String(msg).slice(0, 200) : null;
1614
+ entry.askStartedAt = null;
1615
+ entry.toolStartedAt = null;
1616
+ // Error path is a non-empty completion (we have an error message, not a
1617
+ // silent empty final). Clear the flag so the next ask starts clean.
1618
+ entry.emptyFinal = false;
1619
+ entry.emptyFinalAt = null;
1620
+ const errTs = Date.now();
1621
+ entry.doneAt = errTs;
1622
+ entry.lastProgressAt = errTs;
1623
+ entry.updatedAt = errTs;
1624
+ deleteHeartbeat(id);
1625
+ }
1626
+ export function markSessionCancelled(id) {
1627
+ if (!id) return;
1628
+ const entry = _touchRuntime(id);
1629
+ entry.stage = 'done';
1630
+ entry.lastError = null;
1631
+ entry.askStartedAt = null;
1632
+ entry.toolStartedAt = null;
1633
+ entry.emptyFinal = false;
1634
+ entry.emptyFinalAt = null;
1635
+ const doneTs = Date.now();
1636
+ entry.doneAt = doneTs;
1637
+ entry.lastProgressAt = doneTs;
1638
+ entry.updatedAt = doneTs;
1639
+ deleteHeartbeat(id);
1640
+ }
1641
+ export function getSessionRuntime(id) {
1642
+ return id ? (_runtimeState.get(id) || null) : null;
1643
+ }
1644
+
1645
+ export function getSessionProgressSnapshot(sessionId) {
1646
+ const entry = _runtimeState.get(sessionId);
1647
+ if (!entry) return null;
1648
+ const askStartedAt = entry.askStartedAt || 0;
1649
+ const modelRequestStartedAt = entry.modelRequestStartedAt || askStartedAt;
1650
+ const firstActivityAt = Math.max(
1651
+ entry.lastStreamDeltaAt || 0,
1652
+ entry.toolStartedAt || 0,
1653
+ );
1654
+ const stage = entry.stage || 'idle';
1655
+ const waitingForFirstActivity = Boolean(
1656
+ modelRequestStartedAt
1657
+ && (stage === 'connecting' || stage === 'requesting')
1658
+ && firstActivityAt <= modelRequestStartedAt
1659
+ );
1660
+ return {
1661
+ stage,
1662
+ askStartedAt,
1663
+ modelRequestStartedAt,
1664
+ firstActivityAt,
1665
+ lastStreamDeltaAt: entry.lastStreamDeltaAt || 0,
1666
+ toolStartedAt: entry.toolStartedAt || 0,
1667
+ lastProgressAt: entry.lastProgressAt || 0,
1668
+ updatedAt: entry.updatedAt || 0,
1669
+ hasFirstActivity: Boolean(firstActivityAt && (!askStartedAt || firstActivityAt >= askStartedAt)),
1670
+ waitingForFirstActivity,
1671
+ };
1672
+ }
1673
+
1674
+ /**
1675
+ * Iterate all active session runtimes. Used by the stream watchdog.
1676
+ * Returns an iterable of [sessionId, entry] pairs; consumers should
1677
+ * treat entries as read-only snapshots and avoid mutating them.
1678
+ */
1679
+ export function forEachSessionRuntime() {
1680
+ return _runtimeState.entries();
1681
+ }
1682
+
1683
+ // --- Incremental metric persistence (fix A) ---
1684
+ // Per-session idempotency tracking: sessionId → Set of seen iterationIndex keys.
1685
+ const _metricSeenIter = new Map();
1686
+
1687
+ /**
1688
+ * Persist incremental usage delta immediately after each provider.send iteration.
1689
+ * Idempotency key `sessionId:iterationIndex` ensures a retry of the same iteration
1690
+ * index overwrites instead of double-counting.
1691
+ */
1692
+ export async function persistIterationMetrics(delta) {
1693
+ if (!delta || !delta.sessionId) return;
1694
+ const { sessionId, iterationIndex, deltaInput, deltaOutput, deltaCachedRead, deltaCacheWrite, ts } = delta;
1695
+ let seen = _metricSeenIter.get(sessionId);
1696
+ if (!seen) {
1697
+ seen = new Set();
1698
+ _metricSeenIter.set(sessionId, seen);
1699
+ }
1700
+ const ikey = `${sessionId}:${iterationIndex}`;
1701
+ const isReplay = seen.has(ikey);
1702
+ seen.add(ikey);
1703
+ const runtimeEntry = _runtimeState.get(sessionId);
1704
+ const session = runtimeEntry?.session ?? loadSession(sessionId);
1705
+ if (!session || session.closed) return;
1706
+ if (!isReplay) {
1707
+ session.totalInputTokens = (session.totalInputTokens || 0) + (deltaInput || 0);
1708
+ session.totalOutputTokens = (session.totalOutputTokens || 0) + (deltaOutput || 0);
1709
+ session.tokensCumulative = (session.tokensCumulative || 0) + (deltaInput || 0) + (deltaOutput || 0);
1710
+ // Cache totals — additive fields, default 0 on legacy sessions; both
1711
+ // are undefined-safe so the schema migrates lazily as new iterations
1712
+ // land. Keeps live + terminal aggregates in lock-step (loop.mjs already
1713
+ // includes cached_read / cache_write in its terminal usage rollup).
1714
+ session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + (deltaCachedRead || 0);
1715
+ session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + (deltaCacheWrite || 0);
1716
+ // Window snapshot updated per iteration so bridge type=list reflects the
1717
+ // most-recent provider-reported input size even for short dispatches
1718
+ // that finish before askSession's terminal save lands.
1719
+ session.lastInputTokens = deltaInput || 0;
1720
+ session.lastOutputTokens = deltaOutput || 0;
1721
+ session.lastCachedReadTokens = deltaCachedRead || 0;
1722
+ // Normalized last-call context footprint: how many prompt tokens the
1723
+ // model actually saw on the most-recent send, comparable ACROSS
1724
+ // providers. Anthropic reports input_tokens EXCLUDING cache (cache_read
1725
+ // is a separate field), so the cached portion must be added back to
1726
+ // reflect real context size; openai/grok/gemini already fold cached
1727
+ // tokens INTO the input count, so input alone is the footprint.
1728
+ const _inputExcludesCache = providerInputExcludesCache(session.provider);
1729
+ session.lastContextTokens = _inputExcludesCache
1730
+ ? (deltaInput || 0) + (deltaCachedRead || 0)
1731
+ : (deltaInput || 0);
1732
+ session.lastContextTokensUpdatedAt = ts || Date.now();
1733
+ session.lastContextTokensStaleAfterCompact = false;
1734
+ }
1735
+ session.lastIterationIndex = iterationIndex;
1736
+ session.updatedAt = ts || Date.now();
1737
+ await saveSessionAsync(session, { expectedGeneration: session.generation });
1738
+ }
1739
+
1740
+ function standaloneStatusRouteInfo(session) {
1741
+ if (!session) return null;
1742
+ return {
1743
+ provider: session.provider,
1744
+ model: session.model,
1745
+ modelDisplay: session.modelDisplay || session.displayName || session.model,
1746
+ effort: session.effort || '',
1747
+ fast: session.fast === true,
1748
+ contextWindow: session.contextWindow || null,
1749
+ rawContextWindow: session.rawContextWindow || session.contextWindow || null,
1750
+ effectiveContextWindowPercent: session.effectiveContextWindowPercent || null,
1751
+ autoCompactTokenLimit: session.autoCompactTokenLimit || session.compactBoundaryTokens || null,
1752
+ presetId: session.presetId || null,
1753
+ presetName: session.presetName || null,
1754
+ };
1755
+ }
1756
+
1757
+ function recordStandaloneStatusTelemetry(session, result, durationMs) {
1758
+ if (!session || !result?.usage) return;
1759
+ const routeInfo = standaloneStatusRouteInfo(session);
1760
+ if (!routeInfo?.provider || !routeInfo?.model) return;
1761
+ const providerOut = {
1762
+ usage: result.usage,
1763
+ model: result.model,
1764
+ serviceTier: result.serviceTier,
1765
+ };
1766
+ try {
1767
+ const summary = {
1768
+ ...summarizeGatewayUsage(routeInfo, providerOut, result.compact || null, durationMs),
1769
+ requestKind: 'chat',
1770
+ sessionId: session.id || null,
1771
+ toolCount: result.toolCallsTotal ?? null,
1772
+ messageCount: Array.isArray(session.messages) ? session.messages.length : null,
1773
+ cacheStrategy: session.providerCacheOpts?.cacheStrategy || null,
1774
+ };
1775
+ recordGatewayUsageEvent(summary);
1776
+ } catch {
1777
+ // Statusline telemetry must never affect the model turn.
1778
+ }
1779
+
1780
+ const provider = getProvider(routeInfo.provider);
1781
+ if (!provider) return;
1782
+ fetchOAuthUsageSnapshot(routeInfo, provider, (message) => {
1783
+ if (process.env.MIXDOG_STATUSLINE_TRACE) {
1784
+ process.stderr.write(`[statusline] ${message}\n`);
1785
+ }
1786
+ })
1787
+ .then((snapshot) => {
1788
+ try { buildGatewayLimits(routeInfo, providerOut, snapshot); } catch {}
1789
+ })
1790
+ .catch(() => {});
1791
+ }
1792
+
1793
+ /** Force-flush session metrics to disk. Used by watchdog terminal-reap (fix B). */
1794
+ export async function flushSessionMetrics(sessionId) {
1795
+ if (!sessionId) return;
1796
+ const session = loadSession(sessionId);
1797
+ if (!session) return;
1798
+ session.updatedAt = Date.now();
1799
+ await saveSessionAsync(session, { expectedGeneration: session.generation });
1800
+ }
1801
+
1802
+ /** Mark session hidden so listSessions() filters it out (runtime-only). */
1803
+ export function hideSessionFromList(sessionId) {
1804
+ if (!sessionId) return;
1805
+ const entry = _runtimeState.get(sessionId);
1806
+ if (entry) entry.listHidden = true;
1807
+ }
1808
+
1809
+ export function getSessionAbortSignal(sessionId) {
1810
+ return _runtimeState.get(sessionId)?.controller?.signal ?? null;
1811
+ }
1812
+
1813
+ /**
1814
+ * Return the most recent "session is making progress" timestamp.
1815
+ *
1816
+ * Combines three independent progress signals so an idle watchdog can stay
1817
+ * alive across both streaming and long tool calls:
1818
+ * - lastStreamDeltaAt: provider stream chunk landed
1819
+ * - toolStartedAt: a tool call just kicked off (nested tool work may
1820
+ * stall the outer stream for a while; this keeps the watchdog from
1821
+ * killing legitimate sub-agent runs)
1822
+ * - askStartedAt: ask just started; covers the pre-stream connect window
1823
+ *
1824
+ * Returns 0 when the runtime entry is unknown so callers can decide to
1825
+ * either skip the watchdog or treat 0 as "no progress yet".
1826
+ */
1827
+ export function getSessionLastProgressAt(sessionId) {
1828
+ const entry = _runtimeState.get(sessionId);
1829
+ if (!entry) return 0;
1830
+ return Math.max(
1831
+ entry.lastStreamDeltaAt || 0,
1832
+ entry.toolStartedAt || 0,
1833
+ entry.askStartedAt || 0,
1834
+ );
1835
+ }
1836
+
1837
+ /**
1838
+ * Link a parent AbortSignal to a sub-session's controller so that aborting
1839
+ * the parent (fan-out deadline or caller ESC) tears down the bridge role's
1840
+ * provider call promptly. Safe to call after prepareBridgeSession but before
1841
+ * askSession completes. No-op if the session runtime isn't found.
1842
+ *
1843
+ * @param {string} sessionId — the sub-session to abort
1844
+ * @param {AbortSignal} parentSignal — upstream signal (from fan-out coordinator)
1845
+ */
1846
+ export function linkParentSignalToSession(sessionId, parentSignal) {
1847
+ if (!(parentSignal instanceof AbortSignal)) return;
1848
+ const entry = _touchRuntime(sessionId);
1849
+ if (!entry.controller) entry.controller = createAbortController();
1850
+ const abortReason = () => {
1851
+ const reason = parentSignal.reason;
1852
+ if (reason instanceof Error) return reason;
1853
+ if (reason !== undefined && reason !== null && reason !== '') return new Error(String(reason));
1854
+ return new Error('parent signal aborted');
1855
+ };
1856
+ if (parentSignal.aborted) {
1857
+ try { entry.controller.abort(abortReason()); } catch { /* ignore */ }
1858
+ return;
1859
+ }
1860
+ parentSignal.addEventListener('abort', () => {
1861
+ try { entry.controller?.abort(abortReason()); } catch { /* ignore */ }
1862
+ }, { once: true });
1863
+ }
1864
+ function _clearSessionRuntime(id) {
1865
+ if (id) {
1866
+ _runtimeState.delete(id);
1867
+ // R15: also drop the per-session metric-idempotency Set; otherwise it
1868
+ // grows O(sessions x iterations) for the whole server lifetime since
1869
+ // nothing else deletes from _metricSeenIter on session close.
1870
+ _metricSeenIter.delete(id);
1871
+ }
1872
+ }
1873
+
1874
+ /**
1875
+ * Wrap an async call so that if the session's controller aborts mid-flight,
1876
+ * the wrapper settles with a SessionClosedError even if the underlying promise
1877
+ * hasn't returned yet. The original promise is kept alive with a detached
1878
+ * `.catch()` to prevent unhandled-rejection warnings once it eventually
1879
+ * settles. Callers still must check generation/closed after await returns
1880
+ * to handle providers that ignore the AbortSignal entirely.
1881
+ */
1882
+ export async function _api_call_with_interrupt(sessionId, fn) {
1883
+ const entry = _touchRuntime(sessionId);
1884
+ if (!entry.controller) entry.controller = createAbortController();
1885
+ const signal = entry.controller.signal;
1886
+ const closedFromAbort = (phase) => {
1887
+ const reason = signal.reason;
1888
+ if (reason instanceof SessionClosedError) return reason;
1889
+ const detail = reason instanceof Error
1890
+ ? reason.message
1891
+ : (reason !== undefined && reason !== null && reason !== '' ? String(reason) : '');
1892
+ return new SessionClosedError(sessionId, detail ? `${phase}: ${detail}` : phase);
1893
+ };
1894
+ if (signal.aborted) throw closedFromAbort('aborted before call');
1895
+ const underlying = fn(signal);
1896
+ underlying.catch(() => {}); // prevent unhandled rejection if we race ahead
1897
+ let onAbort = null;
1898
+ const aborted = new Promise((_, reject) => {
1899
+ onAbort = () => reject(closedFromAbort('aborted during call'));
1900
+ if (signal.aborted) onAbort();
1901
+ else signal.addEventListener('abort', onAbort, { once: true });
1902
+ });
1903
+ try {
1904
+ return await Promise.race([underlying, aborted]);
1905
+ } finally {
1906
+ // If the underlying promise settled first, the abort listener is
1907
+ // still attached. Remove it to avoid accumulating listeners across
1908
+ // many asks on the same session.
1909
+ if (onAbort && !signal.aborted) {
1910
+ try { signal.removeEventListener('abort', onAbort); } catch { /* ignore */ }
1911
+ }
1912
+ }
1913
+ }
1914
+
1915
+ // Per-session mutex: queues concurrent askSession calls to prevent message loss
1916
+ const _sessionLocks = new Map();
1917
+ // Per-session pending-message queue (Claude Code `pendingMessages` pattern).
1918
+ // A `bridge type=send` to a worker whose turn is still in flight ENQUEUES the
1919
+ // message here instead of rejecting; askSession drains the queue after each
1920
+ // turn and runs the messages as the next user turn(s), preserving order — the
1921
+ // queued send runs AFTER the in-flight prompt, which also closes the spawn
1922
+ // startup race (a send landing before the initial turn settles no longer
1923
+ // jumps ahead of the original prompt).
1924
+ //
1925
+ // The in-memory map is mirrored to disk. Without that, a compact/API error or
1926
+ // daemon restart after returning queued:true can strand or lose a follow-up:
1927
+ // the original ask never reaches its drain point, and no new ask is scheduled.
1928
+ // Keeping the queue outside the session JSON avoids racing session saves that
1929
+ // loaded before the send arrived.
1930
+ //
1931
+ // Map<sessionId, string[]>. Shared with index.mjs's bridge send handler via
1932
+ // the enqueue/drain accessors below — one queue contract, two call sites.
1933
+ const _sessionPendingMessages = new Map();
1934
+ const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
1935
+ const PENDING_MESSAGES_MODE = 0o600;
1936
+
1937
+ function pendingMessagesPath() {
1938
+ return join(resolvePluginData(), PENDING_MESSAGES_FILE);
1939
+ }
1940
+
1941
+ function isValidPendingSessionId(sessionId) {
1942
+ return typeof sessionId === 'string' && /^[A-Za-z0-9_-]+$/.test(sessionId);
1943
+ }
1944
+
1945
+ function normalizePendingStore(raw) {
1946
+ const sessions = raw && typeof raw === 'object' && raw.sessions && typeof raw.sessions === 'object'
1947
+ ? raw.sessions
1948
+ : {};
1949
+ const out = { version: 1, updatedAt: Date.now(), sessions: {} };
1950
+ for (const [sid, value] of Object.entries(sessions)) {
1951
+ if (!isValidPendingSessionId(sid) || !Array.isArray(value)) continue;
1952
+ const q = value
1953
+ .map((entry) => {
1954
+ if (typeof entry === 'string') return entry;
1955
+ if (entry && typeof entry === 'object' && typeof entry.message === 'string') return entry.message;
1956
+ return '';
1957
+ })
1958
+ .filter(Boolean);
1959
+ if (q.length > 0) out.sessions[sid] = q;
1960
+ }
1961
+ return out;
1962
+ }
1963
+
1964
+ function persistPendingMessage(sessionId, message) {
1965
+ if (!isValidPendingSessionId(sessionId)) return 0;
1966
+ let depth = 0;
1967
+ try {
1968
+ updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
1969
+ const next = normalizePendingStore(raw);
1970
+ const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
1971
+ q.push(message);
1972
+ next.sessions[sessionId] = q;
1973
+ next.updatedAt = Date.now();
1974
+ depth = q.length;
1975
+ return next;
1976
+ }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
1977
+ } catch (err) {
1978
+ try { process.stderr.write(`[session] pending-message persist failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
1979
+ }
1980
+ return depth;
1981
+ }
1982
+
1983
+ function drainPersistedPendingMessages(sessionId) {
1984
+ if (!isValidPendingSessionId(sessionId)) return [];
1985
+ let drained = [];
1986
+ try {
1987
+ updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
1988
+ const next = normalizePendingStore(raw);
1989
+ const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
1990
+ drained = q.filter((m) => typeof m === 'string' && m.length > 0);
1991
+ if (drained.length === 0) return undefined;
1992
+ delete next.sessions[sessionId];
1993
+ next.updatedAt = Date.now();
1994
+ return next;
1995
+ }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
1996
+ } catch (err) {
1997
+ try { process.stderr.write(`[session] pending-message drain failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
1998
+ }
1999
+ return drained;
2000
+ }
2001
+
2002
+ export function enqueuePendingMessage(sessionId, message) {
2003
+ if (!sessionId || typeof message !== 'string' || !message) return 0;
2004
+ let q = _sessionPendingMessages.get(sessionId);
2005
+ if (!q) { q = []; _sessionPendingMessages.set(sessionId, q); }
2006
+ q.push(message);
2007
+ const persistedDepth = persistPendingMessage(sessionId, message);
2008
+ return Math.max(q.length, persistedDepth || 0);
2009
+ }
2010
+ export function drainPendingMessages(sessionId) {
2011
+ const q = _sessionPendingMessages.get(sessionId);
2012
+ const memory = q && q.length > 0 ? q.slice() : [];
2013
+ _sessionPendingMessages.delete(sessionId);
2014
+ const persisted = drainPersistedPendingMessages(sessionId);
2015
+ if (memory.length === 0) return persisted;
2016
+ if (persisted.length === 0) return memory;
2017
+ const prefixMatches = memory.every((m, i) => persisted[i] === m);
2018
+ if (prefixMatches) return [...memory, ...persisted.slice(memory.length)];
2019
+ const out = persisted.slice();
2020
+ for (const m of memory) {
2021
+ if (!out.includes(m)) out.push(m);
2022
+ }
2023
+ return out;
2024
+ }
2025
+
2026
+ function promptContentText(content) {
2027
+ if (typeof content === 'string') return content;
2028
+ if (Array.isArray(content)) {
2029
+ return content.map((part) => {
2030
+ if (typeof part === 'string') return part;
2031
+ if (part?.type === 'text') return part.text || '';
2032
+ if (part?.type === 'image') return '[Image]';
2033
+ return part?.text || '';
2034
+ }).filter(Boolean).join('\n');
2035
+ }
2036
+ return String(content ?? '');
2037
+ }
2038
+
2039
+ function promptContentBytes(content) {
2040
+ try {
2041
+ if (typeof content === 'string') return Buffer.byteLength(content, 'utf8');
2042
+ return Buffer.byteLength(JSON.stringify(content), 'utf8');
2043
+ } catch {
2044
+ return Buffer.byteLength(promptContentText(content), 'utf8');
2045
+ }
2046
+ }
2047
+
2048
+ function prefixUserTurnContent(content, contextBlock) {
2049
+ if (!contextBlock) return content;
2050
+ if (Array.isArray(content)) {
2051
+ return [{ type: 'text', text: `${contextBlock}# Task\n` }, ...content];
2052
+ }
2053
+ return `${contextBlock}# Task\n${content}`;
2054
+ }
2055
+ function acquireSessionLock(sessionId) {
2056
+ let entry = _sessionLocks.get(sessionId);
2057
+ if (!entry) {
2058
+ entry = { promise: Promise.resolve(), count: 0 };
2059
+ _sessionLocks.set(sessionId, entry);
2060
+ }
2061
+ entry.count++;
2062
+ const prev = entry.promise;
2063
+ let release;
2064
+ entry.promise = new Promise(r => { release = r; });
2065
+ // Self-heal: if the previous holder rejected, swallow so subsequent
2066
+ // queued waiters don't propagate that rejection and brick the lock chain.
2067
+ return prev.catch(() => {}).then(() => () => {
2068
+ entry.count--;
2069
+ if (entry.count === 0) _sessionLocks.delete(sessionId);
2070
+ release();
2071
+ });
2072
+ }
2073
+
2074
+ export async function askSession(sessionId, prompt, context, onToolCall, cwdOverride, explicitPrefetch, askOpts = {}) {
2075
+ const _askStartedAt = Date.now();
2076
+ const _promptSrc = 'prompt';
2077
+ const _prefetchFiles = (explicitPrefetch?.files?.length) || 0;
2078
+ const _prefetchCallers = (explicitPrefetch?.callers?.length) || 0;
2079
+ const _prefetchRefs = (explicitPrefetch?.references?.length) || 0;
2080
+ if (process.env.MIXDOG_DEBUG_BRIDGE) {
2081
+ 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`);
2082
+ }
2083
+ const unlock = await acquireSessionLock(sessionId);
2084
+ const _lockWaitedMs = Date.now() - _askStartedAt;
2085
+ if (process.env.MIXDOG_DEBUG_BRIDGE) {
2086
+ process.stderr.write(`[bridge-trace] lock-acquired waitedMs=${_lockWaitedMs}\n`);
2087
+ }
2088
+ // The mutex is held for the WHOLE askSession call, including any follow-up
2089
+ // turns drained from the pending-message queue below — the single outer
2090
+ // try/finally releases it exactly once. _result holds the last turn's
2091
+ // return value (the queued tail turns supersede the original prompt's
2092
+ // result, mirroring how a live chat returns the latest turn).
2093
+ let _result;
2094
+ // Local FIFO of follow-up prompts drained from the pending-message queue
2095
+ // after each turn — keeps queued `bridge type=send` messages in order.
2096
+ const _pendingTail = [];
2097
+ // Hoisted so the outer finally (which runs once after the whole turn loop)
2098
+ // can compare against the last turn's generation.
2099
+ let askGeneration = 0;
2100
+ try {
2101
+ // Turn loop (pendingMessages pattern): run the current prompt, then drain
2102
+ // any `bridge type=send` messages that were queued while this turn was in
2103
+ // flight and run them — in order — as the next user turn(s). Because the
2104
+ // queued send always lands AFTER the in-flight prompt here, ordering is
2105
+ // preserved and the spawn/connecting startup race disappears.
2106
+ for (;;) {
2107
+ // After the first turn, the next prompt comes from the drained queue.
2108
+ // (On the first iteration _pendingTail is empty and `prompt` is the
2109
+ // caller's original message.)
2110
+ if (_pendingTail.length > 0) {
2111
+ prompt = _pendingTail.shift();
2112
+ // Queued follow-ups are plain user turns — no caller context /
2113
+ // prefetch is re-applied (those belonged to the original ask).
2114
+ context = null;
2115
+ explicitPrefetch = null;
2116
+ }
2117
+ // ── Synchronous pre-await setup (must happen before any await so
2118
+ // closeSession() can't interleave between load and registration) ──
2119
+ const preSession = loadSession(sessionId);
2120
+ if (!preSession) {
2121
+ throw new Error(`Session "${sessionId}" not found`);
2122
+ }
2123
+ if (preSession.closed === true) {
2124
+ throw new SessionClosedError(sessionId, 'session already closed');
2125
+ }
2126
+ askGeneration = typeof preSession.generation === 'number' ? preSession.generation : 0;
2127
+ const runtime = _touchRuntime(sessionId);
2128
+ // Fresh controller per ask — the previous ask's controller may have aborted.
2129
+ runtime.controller = createAbortController();
2130
+ runtime.generation = askGeneration;
2131
+ runtime.closed = false;
2132
+ runtime.session = preSession;
2133
+ markSessionAskStart(sessionId);
2134
+ // Preprocessing is inside try so provider-not-available / trim failures
2135
+ // fall into the catch and mark the session as errored rather than
2136
+ // leaving stage='connecting' forever.
2137
+ let activeSession = preSession;
2138
+ let cancelledUserTurnContent = '';
2139
+ try {
2140
+ const session = activeSession;
2141
+ const provider = getProvider(session.provider);
2142
+ // Register the live session object into runtime so closeSession()
2143
+ // can read allBashSessionIds that loop.mjs appends mid-turn.
2144
+ runtime.session = session;
2145
+ if (!provider)
2146
+ throw new Error(`Provider "${session.provider}" not available`);
2147
+ const contextMeta = resolveSessionContextMeta(provider, session.model, session);
2148
+ session.contextWindow = contextMeta.contextWindow;
2149
+ session.rawContextWindow = contextMeta.rawContextWindow;
2150
+ session.effectiveContextWindowPercent = contextMeta.effectiveContextWindowPercent;
2151
+ session.autoCompactTokenLimit = contextMeta.autoCompactTokenLimit;
2152
+ session.compactBoundaryTokens = contextMeta.compactBoundaryTokens;
2153
+ session.compaction = {
2154
+ ...(session.compaction || {}),
2155
+ auto: session.compaction?.auto !== false,
2156
+ semantic: session.compaction?.semantic ?? 'auto',
2157
+ boundaryTokens: contextMeta.compactBoundaryTokens,
2158
+ bufferTokens: positiveContextWindow(session.compaction?.bufferTokens ?? session.compaction?.buffer) || session.compaction?.bufferTokens || null,
2159
+ keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens) || session.compaction?.keepTokens || null,
2160
+ contextWindow: contextMeta.contextWindow,
2161
+ rawContextWindow: contextMeta.rawContextWindow,
2162
+ effectiveContextWindowPercent: contextMeta.effectiveContextWindowPercent,
2163
+ autoCompactTokenLimit: contextMeta.autoCompactTokenLimit,
2164
+ };
2165
+ // Cap caller-supplied / prefetched context so an oversized
2166
+ // payload can't blow the session token budget before the
2167
+ // first model call. 32 KB ~ 8k tokens at the 4 B/tok
2168
+ // working average; longer is silently truncated with a
2169
+ // visible marker so the model still sees the prefix and
2170
+ // a hint about the cut.
2171
+ const _CTX_CHAR_CAP = 32 * 1024;
2172
+ const _capCtx = (text) => {
2173
+ if (typeof text !== 'string') return '';
2174
+ if (text.length <= _CTX_CHAR_CAP) return text;
2175
+ return `${text.slice(0, _CTX_CHAR_CAP)}\n\n... [context truncated; original ${text.length} chars]`;
2176
+ };
2177
+ // Inline context + prefetch INTO the prompt as a single user turn,
2178
+ // marked with explicit section headers. The previous design pushed
2179
+ // context as separate user messages with pre-injected assistant
2180
+ // "Noted." acks; that conversational pattern taught some models a
2181
+ // low-effort rhythm and they responded with "Noted." / empty tags
2182
+ // even to the real task. Single-turn structure with a labelled
2183
+ // `# Task` block forces the model to treat the brief as the work
2184
+ // unit, not as another piece of context to ack.
2185
+ const explicitPrefetchResult = await _tryBridgeExplicitPrefetch(session, explicitPrefetch);
2186
+ let _contextBlock = '';
2187
+ if (context) {
2188
+ _contextBlock += `# Additional context\n${_capCtx(context)}\n\n`;
2189
+ }
2190
+ if (explicitPrefetchResult) {
2191
+ _contextBlock += `# Prefetch\n${_capCtx(explicitPrefetchResult)}\n\n`;
2192
+ }
2193
+ const beforeCount = session.messages.length + 1;
2194
+ const promptTextForMetrics = promptContentText(prompt);
2195
+ // Soft warning only; real size management (compaction primary,
2196
+ // byte-budget trim as safety net) lives in agentLoop. Selecting a
2197
+ // 25% pre-trim here would starve compaction's 50% threshold.
2198
+ const softBudget = Math.floor(session.contextWindow * 0.25);
2199
+ const promptTokenEstimate = promptTextForMetrics.length * 0.5; // conservative for CJK
2200
+ if (promptTokenEstimate > softBudget * 0.7) {
2201
+ process.stderr.write(`[session] Warning: prompt is very large (est. ${Math.round(promptTokenEstimate)} tokens vs ${softBudget} soft budget)\n`);
2202
+ }
2203
+ const effectiveCwd = cwdOverride || session.cwd;
2204
+ const _userTurnContent = prefixUserTurnContent(prompt, _contextBlock);
2205
+ cancelledUserTurnContent = _userTurnContent;
2206
+ const outgoing = [...session.messages, { role: 'user', content: _userTurnContent }];
2207
+ // Per-turn injected-context trace row (complements kind:"usage").
2208
+ // Cheap byte-length accounting — no hashing, no payload bodies.
2209
+ // Honors the same MIXDOG_BRIDGE_TRACE_DISABLE gate as usage rows;
2210
+ // appendBridgeTrace is a no-op when that env is set.
2211
+ try {
2212
+ const _ctxBytes = Buffer.byteLength(context || '', 'utf8');
2213
+ const _prefetchBytes = Buffer.byteLength(explicitPrefetchResult || '', 'utf8');
2214
+ const _promptBytes = promptContentBytes(prompt);
2215
+ const _userTurnBytes = promptContentBytes(_userTurnContent);
2216
+ const _messagesBytes = Buffer.byteLength(JSON.stringify(session.messages || []), 'utf8');
2217
+ const _totalBytes = _userTurnBytes + _messagesBytes;
2218
+ appendBridgeTrace({
2219
+ kind: 'context',
2220
+ sessionId,
2221
+ model: session.model,
2222
+ provider: session.provider,
2223
+ totalBytes: _totalBytes,
2224
+ breakdown: {
2225
+ contextBytes: _ctxBytes,
2226
+ prefetchBytes: _prefetchBytes,
2227
+ promptBytes: _promptBytes,
2228
+ userTurnBytes: _userTurnBytes,
2229
+ messagesBytes: _messagesBytes,
2230
+ messagesCount: Array.isArray(session.messages) ? session.messages.length : 0,
2231
+ },
2232
+ });
2233
+ } catch { /* trace must never break the ask path */ }
2234
+ const result = await _api_call_with_interrupt(sessionId, (signal) =>
2235
+ agentLoop(provider, outgoing, session.model, session.tools, onToolCall, effectiveCwd, {
2236
+ effort: session.effort || null,
2237
+ fast: session.fast === true,
2238
+ sessionId,
2239
+ onTextDelta: typeof askOpts?.onTextDelta === 'function' ? askOpts.onTextDelta : undefined,
2240
+ onReasoningDelta: typeof askOpts?.onReasoningDelta === 'function' ? askOpts.onReasoningDelta : undefined,
2241
+ onUsageDelta: (d) => {
2242
+ persistIterationMetrics(d).catch(() => {});
2243
+ try { askOpts?.onUsageDelta?.(d); } catch {}
2244
+ },
2245
+ onToolResult: typeof askOpts?.onToolResult === 'function' ? askOpts.onToolResult : undefined,
2246
+ // Mid-turn steering drain. agentLoop calls this at every
2247
+ // tool-batch boundary (before the next provider.send) and
2248
+ // injects any returned strings as user turns — so input
2249
+ // (user typing, `bridge type=send`) that arrives WHILE a
2250
+ // long multi-tool turn is in flight is picked up on the
2251
+ // model's very next iteration instead of waiting for the
2252
+ // whole task to finish. The post-turn _pendingTail drain
2253
+ // below still handles "followUp" input that lands after the
2254
+ // agent would otherwise stop. Same queue, two drain points.
2255
+ drainSteering: (sid) => {
2256
+ const out = [];
2257
+ if (typeof askOpts?.drainSteering === 'function') {
2258
+ try {
2259
+ const drained = askOpts.drainSteering(sid || sessionId);
2260
+ if (Array.isArray(drained)) out.push(...drained);
2261
+ } catch { /* best-effort steering drain */ }
2262
+ }
2263
+ try { out.push(...drainPendingMessages(sid || sessionId)); }
2264
+ catch { /* best-effort pending drain */ }
2265
+ return out;
2266
+ },
2267
+ onSteerMessage: typeof askOpts?.onSteerMessage === 'function' ? askOpts.onSteerMessage : undefined,
2268
+ promptCacheKey: session.promptCacheKey || sessionId,
2269
+ // Provider-scoped cache key (mixdog-codex, mixdog-claude…).
2270
+ // Distinct from sessionId — providers that pool sockets
2271
+ // per-session (openai-oauth WS) use sessionId as the
2272
+ // pool bucket and providerCacheKey as the server-side
2273
+ // prompt-cache shard so parallel callers don't collide
2274
+ // on a mid-turn socket while still sharing prefix cache.
2275
+ providerCacheKey: session.promptCacheKey || null,
2276
+ signal,
2277
+ providerState: session.providerState ?? undefined,
2278
+ session,
2279
+ // Smart Bridge cache settings — merged last so session overrides
2280
+ // don't get overridden by defaults. When session has no profile,
2281
+ // providerCacheOpts is null and this spread is a no-op.
2282
+ ...(session.providerCacheOpts || {}),
2283
+ onStageChange: (stage) => {
2284
+ updateSessionStage(sessionId, stage);
2285
+ try { askOpts?.onStageChange?.(stage); } catch {}
2286
+ },
2287
+ onStreamDelta: () => {
2288
+ markSessionStreamDelta(sessionId).catch(() => {});
2289
+ try { askOpts?.onStreamDelta?.(); } catch {}
2290
+ },
2291
+ }),
2292
+ );
2293
+ // Post-loop validation: if closeSession() landed while we were awaiting,
2294
+ // drop the save so the tombstone on disk isn't overwritten.
2295
+ const currentRuntime = _runtimeState.get(sessionId);
2296
+ if (currentRuntime?.closed || currentRuntime?.generation !== askGeneration) {
2297
+ const reason = currentRuntime?.closedReason;
2298
+ throw new SessionClosedError(sessionId, `closed during call (reason=${reason || 'unknown'})`, reason || null);
2299
+ }
2300
+ // Update and save. outgoing is mutated in place by agentLoop
2301
+ // (compaction + safety trim), so its length reflects post-loop state.
2302
+ const messagesDropped = Math.max(0, beforeCount - outgoing.length);
2303
+ session.messages = outgoing;
2304
+ if (result.content || result.reasoningContent) {
2305
+ session.messages.push({
2306
+ role: 'assistant',
2307
+ content: result.content || '',
2308
+ ...(typeof result.reasoningContent === 'string' && result.reasoningContent
2309
+ ? { reasoningContent: result.reasoningContent }
2310
+ : {}),
2311
+ });
2312
+ } else {
2313
+ // Empty terminal turn: still persist a forensic record so
2314
+ // post-mortem inspection can distinguish "work landed but
2315
+ // synthesis missing" from "session never ran". Stop reason,
2316
+ // usage, iterations, and tool-call totals survive even when
2317
+ // the assistant produced no content/reasoning.
2318
+ const _emptyStop = result?.stopReason ?? result?.stop_reason ?? null;
2319
+ const _emptyUsage = result?.usage ? {
2320
+ inputTokens: result.usage.inputTokens || 0,
2321
+ outputTokens: result.usage.outputTokens || 0,
2322
+ cachedTokens: result.usage.cachedTokens || 0,
2323
+ cacheWriteTokens: result.usage.cacheWriteTokens || 0,
2324
+ } : null;
2325
+ // Provider content-block classification — distinguishes a
2326
+ // thinking-only stall (model emitted reasoning blocks but no
2327
+ // text/tool_use) from a true silent empty turn. Anthropic
2328
+ // providers (anthropic.mjs, anthropic-oauth.mjs) set these
2329
+ // fields on the result; other providers may omit them.
2330
+ const _emptyHasThinking = typeof result?.hasThinkingContent === 'boolean'
2331
+ ? result.hasThinkingContent
2332
+ : null;
2333
+ const _emptyBlockTypes = Array.isArray(result?.contentBlockTypes)
2334
+ ? result.contentBlockTypes.slice()
2335
+ : null;
2336
+ session.messages.push({
2337
+ role: 'assistant',
2338
+ content: '',
2339
+ emptyFinal: true,
2340
+ stopReason: _emptyStop,
2341
+ iterations: result?.iterations ?? null,
2342
+ toolCallsTotal: result?.toolCallsTotal ?? null,
2343
+ usage: _emptyUsage,
2344
+ ...(_emptyHasThinking !== null ? { hasThinkingContent: _emptyHasThinking } : {}),
2345
+ ...(_emptyBlockTypes !== null ? { contentBlockTypes: _emptyBlockTypes } : {}),
2346
+ ts: Date.now(),
2347
+ });
2348
+ try {
2349
+ const _blockTypesStr = _emptyBlockTypes ? _emptyBlockTypes.join(',') || 'none' : 'unknown';
2350
+ const _thinkingStr = _emptyHasThinking === null ? 'unknown' : String(_emptyHasThinking);
2351
+ 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`);
2352
+ } catch {}
2353
+ }
2354
+ session.updatedAt = Date.now();
2355
+ session.lastUsedAt = Date.now();
2356
+ if (result.usage) {
2357
+ session.totalInputTokens += result.usage.inputTokens;
2358
+ session.totalOutputTokens += result.usage.outputTokens;
2359
+ session.tokensCumulative = (session.tokensCumulative || 0)
2360
+ + (result.usage.inputTokens || 0)
2361
+ + (result.usage.outputTokens || 0);
2362
+ // Cache totals — same `||0` undefined-safe accumulation pattern as
2363
+ // persistIterationMetrics so live + terminal paths stay in lock-step
2364
+ // and legacy sessions migrate lazily on first iteration.
2365
+ session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + (result.usage.cachedTokens || 0);
2366
+ session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + (result.usage.cacheWriteTokens || 0);
2367
+ // Window snapshot = the current context size, which is the LAST
2368
+ // single call — NOT result.usage (that is lastUsage, the per-turn
2369
+ // SUM accumulated with += across iterations in agentLoop). Use
2370
+ // lastTurnUsage (the final iteration's raw usage) so this reflects
2371
+ // "what's in the window now" rather than the lifetime sum.
2372
+ const _lastTurn = result.lastTurnUsage || result.usage || {};
2373
+ session.lastInputTokens = _lastTurn.inputTokens || 0;
2374
+ session.lastOutputTokens = _lastTurn.outputTokens || 0;
2375
+ session.lastCachedReadTokens = _lastTurn.cachedTokens || 0;
2376
+ session.lastCacheWriteTokens = _lastTurn.cacheWriteTokens || 0;
2377
+ // Provider-normalized footprint, identical formula to
2378
+ // persistIterationMetrics so both writers agree: Anthropic
2379
+ // input_tokens excludes cache (add it back), openai/grok/gemini
2380
+ // already include it.
2381
+ const _inputExcludesCache = providerInputExcludesCache(session.provider);
2382
+ session.lastContextTokens = _inputExcludesCache
2383
+ ? (_lastTurn.inputTokens || 0) + (_lastTurn.cachedTokens || 0)
2384
+ : (_lastTurn.inputTokens || 0);
2385
+ session.lastContextTokensUpdatedAt = Date.now();
2386
+ session.lastContextTokensStaleAfterCompact = false;
2387
+ }
2388
+ // Smart Bridge cache stats — record hit/miss after every successful
2389
+ // ask so the registry reflects all bridge traffic, not just
2390
+ // maintenance cycles. Guarded against any smart-bridge error so
2391
+ // metric recording never breaks the ask itself.
2392
+ let prefixHashForLog = null;
2393
+ if (session.profileId && result.usage && _smartBridgeApi) {
2394
+ try {
2395
+ const profile = _smartBridgeApi.getProfile(session.profileId);
2396
+ if (profile) {
2397
+ // Collect every leading system-role message (BP1, BP2, ...)
2398
+ // until the first non-system message so the registry hash
2399
+ // captures the full ordered provider prefix, not just BP1.
2400
+ const systemMsgs = [];
2401
+ for (const m of session.messages) {
2402
+ if (m?.role !== 'system') break;
2403
+ systemMsgs.push(typeof m.content === 'string' ? m.content : '');
2404
+ }
2405
+ _smartBridgeApi.recordCall(profile, session.provider, {
2406
+ systemPrompt: systemMsgs,
2407
+ tools: session.tools || [],
2408
+ usage: result.usage,
2409
+ });
2410
+ const entry = _smartBridgeApi.registry?.data?.profiles?.[session.profileId]?.[session.provider];
2411
+ prefixHashForLog = entry?.prefixHash || null;
2412
+ }
2413
+ } catch {}
2414
+ }
2415
+ // Append to bridge-trace.jsonl with the rich bridge usage fields.
2416
+ if (result.usage) {
2417
+ const inputTokens = result.usage.inputTokens || 0;
2418
+ const outputTokens = result.usage.outputTokens || 0;
2419
+ const cacheReadTokens = result.usage.cachedTokens || 0;
2420
+ const cacheWriteTokens = result.usage.cacheWriteTokens || 0;
2421
+ // Unified total-prompt field. Anthropic = input+cache_read+cache_write
2422
+ // (additive); OpenAI/Codex/Gemini = input_tokens already includes the
2423
+ // cached portion (inclusive), so the fallback must not double-count.
2424
+ const { isInclusiveProvider, computeCostUsd } = await import('../../../shared/llm/cost.mjs');
2425
+ const inclusive = isInclusiveProvider(session.provider);
2426
+ const promptTokens = typeof result.usage.promptTokens === 'number'
2427
+ ? result.usage.promptTokens
2428
+ : (inclusive
2429
+ ? Math.max(inputTokens, cacheReadTokens + cacheWriteTokens)
2430
+ : inputTokens + cacheReadTokens + cacheWriteTokens);
2431
+ let costUsd = result.usage.costUsd || 0;
2432
+ if (!costUsd) {
2433
+ try {
2434
+ costUsd = computeCostUsd({
2435
+ model: session.model,
2436
+ provider: session.provider,
2437
+ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
2438
+ });
2439
+ } catch { /* best-effort */ }
2440
+ }
2441
+ logLlmCall({
2442
+ ts: new Date().toISOString(),
2443
+ sourceType: session.sourceType || 'lead',
2444
+ sourceName: session.sourceName || session.role || null,
2445
+ preset: session.presetName || null,
2446
+ model: session.model,
2447
+ provider: session.provider,
2448
+ duration: Date.now() - _askStartedAt,
2449
+ profileId: session.profileId || null,
2450
+ sessionId: session.id,
2451
+ inputTokens,
2452
+ outputTokens,
2453
+ cacheReadTokens,
2454
+ cacheWriteTokens,
2455
+ promptTokens,
2456
+ prefixHash: prefixHashForLog,
2457
+ costUsd,
2458
+ });
2459
+ recordStandaloneStatusTelemetry(session, result, Date.now() - _askStartedAt);
2460
+ }
2461
+ // Persist opaque providerState for future stateful providers.
2462
+ // No provider currently emits it (Codex OAuth is stateless per
2463
+ // contract), so this branch is dormant — kept so a future
2464
+ // Responses-API provider with stable continuation can plug in
2465
+ // without reworking the session shape.
2466
+ if (result.providerState !== undefined) {
2467
+ session.providerState = result.providerState;
2468
+ }
2469
+ const postTurnCompact = await autoCompactSessionAfterTurn(session, {
2470
+ provider,
2471
+ model: session.model,
2472
+ sessionId,
2473
+ signal: getSessionAbortSignal(sessionId),
2474
+ });
2475
+ if (postTurnCompact?.changed) {
2476
+ try {
2477
+ process.stderr.write(
2478
+ `[session] auto compacted after turn sessionId=${sessionId} ` +
2479
+ `${postTurnCompact.beforeTokens}->${postTurnCompact.afterTokens} ` +
2480
+ `pressure=${postTurnCompact.pressureTokens} trigger=${postTurnCompact.triggerTokens}\n`,
2481
+ );
2482
+ } catch { /* best-effort */ }
2483
+ }
2484
+ await saveSessionAsync(session, { expectedGeneration: askGeneration });
2485
+ activeSession = session;
2486
+ runtime.session = session;
2487
+ // Tag empty-synthesis BEFORE markSessionDone so the watchdog
2488
+ // (which inspects entry.emptyFinal first) classifies the
2489
+ // terminal state correctly even if it ticks during unwind.
2490
+ const isEmptyFinal = !result.content && !result.reasoningContent;
2491
+ if (isEmptyFinal) {
2492
+ markSessionEmptyFinal(sessionId);
2493
+ }
2494
+ markSessionDone(sessionId, { empty: isEmptyFinal });
2495
+ _result = {
2496
+ ...result,
2497
+ trimmed: messagesDropped > 0,
2498
+ messagesDropped,
2499
+ postTurnCompact: postTurnCompact?.changed ? postTurnCompact : null,
2500
+ };
2501
+ } catch (err) {
2502
+ if (err instanceof SessionClosedError) {
2503
+ const currentRuntime = _runtimeState.get(sessionId);
2504
+ if (!currentRuntime?.closed && activeSession && cancelledUserTurnContent) {
2505
+ activeSession.messages = [
2506
+ ...(Array.isArray(activeSession.messages) ? activeSession.messages : []),
2507
+ { role: 'user', content: cancelledUserTurnContent },
2508
+ {
2509
+ role: 'assistant',
2510
+ content: '[cancelled] This turn was interrupted before completion. Preserve the user request above as the active task context if the user asks to continue.',
2511
+ cancelled: true,
2512
+ ts: Date.now(),
2513
+ },
2514
+ ];
2515
+ activeSession.updatedAt = Date.now();
2516
+ activeSession.lastUsedAt = Date.now();
2517
+ try {
2518
+ await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
2519
+ } catch { /* cancellation persistence is best-effort */ }
2520
+ markSessionCancelled(sessionId);
2521
+ }
2522
+ // Cancellation is not an error; propagate silently so callers
2523
+ // can render it as "cancelled" rather than a red failure.
2524
+ throw err;
2525
+ }
2526
+ markSessionError(sessionId, err && err.message ? err.message : String(err));
2527
+ throw err;
2528
+ }
2529
+ // ── Turn complete. Drain the pending-message queue (Claude Code
2530
+ // pendingMessages): any `bridge type=send` that arrived while this
2531
+ // turn was in flight runs next, in order, as a follow-up user turn.
2532
+ // The mutex is still held, so a send racing this drain either landed
2533
+ // before (picked up here) or enqueues for the next loop. When the
2534
+ // queue is empty we return the latest turn's result. ──
2535
+ const _drained = drainPendingMessages(sessionId);
2536
+ if (_drained.length > 0) {
2537
+ // Same merge rule as the mid-turn steering drain (loop.mjs) and
2538
+ // the TUI engine.mjs drain(): a single drain batch is joined with
2539
+ // "\n" and delivered as ONE follow-up turn, not N isolated turns.
2540
+ // Keeps every steering/follow-up path on identical
2541
+ // merge-then-deliver semantics. Anything that arrives AFTER this
2542
+ // drain enqueues for the next loop pass and is merged there.
2543
+ const _mergedTail = _drained
2544
+ .filter((m) => typeof m === 'string' && m.length > 0)
2545
+ .join('\n');
2546
+ if (_mergedTail.length > 0) {
2547
+ _pendingTail.push(_mergedTail);
2548
+ const refreshed = loadSession(sessionId);
2549
+ if (refreshed && refreshed.closed !== true) {
2550
+ activeSession = refreshed;
2551
+ runtime.session = refreshed;
2552
+ }
2553
+ continue;
2554
+ }
2555
+ }
2556
+ return _result;
2557
+ }
2558
+ } finally {
2559
+ // Clear the controller only if it's still ours (closeSession may have
2560
+ // swapped it). Leave the rest of the runtime entry intact so bridge type=list
2561
+ // can still surface the final stage (done/error/cancelling).
2562
+ const entry = _runtimeState.get(sessionId);
2563
+ if (entry && entry.generation === askGeneration) {
2564
+ entry.controller = null;
2565
+ // Detach the live session reference; ask is over.
2566
+ entry.session = null;
2567
+ }
2568
+ unlock();
2569
+ }
2570
+ }
2571
+ // Session lookup by scopeKey — used by CLI bridge to resume a pinned
2572
+ // scope session when the caller passes --scope (agent/<name>).
2573
+ export function findSessionByScopeKey(scopeKey) {
2574
+ if (!scopeKey) return null;
2575
+ const sessions = listStoredSessions();
2576
+ // Exclude tombstoned sessions (`closed === true`) so callers never receive
2577
+ // a session whose controller was aborted by closeSession(). The `closed`
2578
+ // bit is the authoritative tombstone flag; `status === 'error'` is not,
2579
+ // since transient-error sessions remain resumable.
2580
+ return sessions.find(s => s.scopeKey === scopeKey && s.closed !== true) || null;
2581
+ }
2582
+ // --- resume (reload tools for a stored session) ---
2583
+ export async function resumeSession(sessionId, preset) {
2584
+ const session = loadSession(sessionId);
2585
+ if (!session)
2586
+ return null;
2587
+ // Resuming a closed session is a resurrection attempt — refuse. The guarded
2588
+ // save below would also block the write, but failing fast here is cleaner
2589
+ // than silently dropping the tool-refresh side effects.
2590
+ if (session.closed === true) return null;
2591
+ if (!session.owner) session.owner = 'user';
2592
+ // Refresh tools (MCP connections may have changed).
2593
+ // Re-resolve from profile.tools when the session stored a profileId —
2594
+ // otherwise fall back to preset.tools. Same resolution order as
2595
+ // createSession so resume and spawn produce identical BP_1 shapes.
2596
+ const oldTools = session.tools || [];
2597
+ const skills = collectSkillsCached(session.cwd);
2598
+ let toolSpec = preset || session.preset || 'full';
2599
+ if (session.profileId && _smartBridgeApi?.getProfile) {
2600
+ try {
2601
+ const profile = _smartBridgeApi.getProfile(session.profileId);
2602
+ if (Array.isArray(profile?.tools)) toolSpec = profile.tools;
2603
+ } catch { /* ignore lookup failures, keep preset fallback */ }
2604
+ }
2605
+ session.tools = resolveSessionTools(toolSpec, skills, { ownerIsBridge: session.owner === 'bridge' });
2606
+ const newTools = session.tools;
2607
+ const missing = oldTools.filter(t => !newTools.find(n => n.name === t.name));
2608
+ if (missing.length) {
2609
+ process.stderr.write(`[session] Warning: ${missing.length} tools no longer available: ${missing.map(t => t.name).join(', ')}\n`);
2610
+ }
2611
+ await saveSessionAsync(session, { expectedGeneration: session.generation });
2612
+ return session;
2613
+ }
2614
+ // --- CRUD ---
2615
+ export function getSession(id) {
2616
+ return loadSession(id);
2617
+ }
2618
+ export function listSessions(opts = {}) {
2619
+ const includeClosed = opts.includeClosed === true;
2620
+ const sessions = listStoredSessions();
2621
+ const hiddenIds = new Set([..._runtimeState.entries()].filter(([, e]) => e.listHidden).map(([id]) => id));
2622
+ // Tombstoned sessions (closed===true) are excluded unless the caller opts in
2623
+ // (e.g. bridge list includeClosed:true).
2624
+ return sessions.filter(s => !hiddenIds.has(s.id) && (includeClosed || s.closed !== true));
2625
+ }
2626
+ // --- Clear messages (keep system prompt + provider/model/cwd) ---
2627
+ export async function clearSessionMessages(sessionId) {
2628
+ const session = loadSession(sessionId);
2629
+ if (!session)
2630
+ return false;
2631
+ // Don't resurrect a closed session just to clear its messages.
2632
+ if (session.closed === true) return false;
2633
+ const keep = [];
2634
+ const messages = Array.isArray(session.messages) ? session.messages : [];
2635
+ const beforeMessageTokens = estimateMessagesTokens(messages);
2636
+ for (let i = 0; i < messages.length; i += 1) {
2637
+ const m = messages[i];
2638
+ if (!m) continue;
2639
+ if (m.role === 'system') {
2640
+ keep.push(m);
2641
+ continue;
2642
+ }
2643
+ const stableContext =
2644
+ m.role === 'user'
2645
+ && typeof m.content === 'string'
2646
+ && m.content.startsWith('<system-reminder>')
2647
+ && m.content.includes('<!-- bp3-sentinel -->');
2648
+ if (stableContext) {
2649
+ keep.push(m);
2650
+ const next = messages[i + 1];
2651
+ if (next?.role === 'assistant' && String(next.content || '').trim() === '.') {
2652
+ keep.push(next);
2653
+ i += 1;
2654
+ }
2655
+ }
2656
+ }
2657
+ const afterMessageTokens = estimateMessagesTokens(keep);
2658
+ const reserveTokens = estimateRequestReserveTokens(session.tools || []);
2659
+ const beforeTokens = Math.max(beforeMessageTokens + reserveTokens, positiveContextWindow(session.lastContextTokens) || 0);
2660
+ const afterTokens = afterMessageTokens + reserveTokens;
2661
+ const now = Date.now();
2662
+ session.messages = keep;
2663
+ session.totalInputTokens = 0;
2664
+ session.totalOutputTokens = 0;
2665
+ session.totalCachedReadTokens = 0;
2666
+ session.totalCacheWriteTokens = 0;
2667
+ session.lastInputTokens = 0;
2668
+ session.lastOutputTokens = 0;
2669
+ session.lastCachedReadTokens = 0;
2670
+ session.lastCacheWriteTokens = 0;
2671
+ session.lastContextTokens = 0;
2672
+ session.lastContextTokensUpdatedAt = now;
2673
+ session.lastContextTokensStaleAfterCompact = false;
2674
+ session.providerState = undefined;
2675
+ session.compaction = {
2676
+ ...(session.compaction || {}),
2677
+ lastStage: 'auto_clear',
2678
+ lastBeforeTokens: beforeTokens,
2679
+ lastAfterTokens: afterTokens,
2680
+ lastBeforeMessageTokens: beforeMessageTokens,
2681
+ lastAfterMessageTokens: afterMessageTokens,
2682
+ lastPressureTokens: beforeTokens,
2683
+ lastCheckedAt: now,
2684
+ lastChanged: beforeTokens !== afterTokens,
2685
+ lastClearAt: now,
2686
+ lastClearBeforeTokens: beforeTokens,
2687
+ lastClearAfterTokens: afterTokens,
2688
+ lastClearBeforeMessageTokens: beforeMessageTokens,
2689
+ lastClearAfterMessageTokens: afterMessageTokens,
2690
+ };
2691
+ session.updatedAt = now;
2692
+ await saveSessionAsync(session, { expectedGeneration: session.generation });
2693
+ return session;
2694
+ }
2695
+ export async function compactSessionMessages(sessionId) {
2696
+ const session = loadSession(sessionId);
2697
+ if (!session) return null;
2698
+ if (session.closed === true) return null;
2699
+ const result = await runSessionCompaction(session, {
2700
+ mode: 'manual',
2701
+ force: true,
2702
+ provider: getProvider(session.provider),
2703
+ model: session.model,
2704
+ sessionId,
2705
+ signal: getSessionAbortSignal(sessionId),
2706
+ });
2707
+ if (!result) return null;
2708
+ const now = Date.now();
2709
+ if (!result.error) {
2710
+ session.lastInputTokens = 0;
2711
+ session.lastOutputTokens = 0;
2712
+ session.lastCachedReadTokens = 0;
2713
+ session.lastCacheWriteTokens = 0;
2714
+ session.lastContextTokens = 0;
2715
+ session.lastContextTokensUpdatedAt = now;
2716
+ session.lastContextTokensStaleAfterCompact = false;
2717
+ }
2718
+ session.updatedAt = Date.now();
2719
+ await saveSessionAsync(session, { expectedGeneration: session.generation });
2720
+ return result;
2721
+ }
2722
+ export async function updateSessionStatus(id, status) {
2723
+ const session = loadSession(id);
2724
+ if (!session) return false;
2725
+ // Respect tombstones — don't resurrect a closed session just to update a
2726
+ // status label (bridge handler emits running→idle/error around askSession).
2727
+ if (session.closed === true) return false;
2728
+ session.status = status;
2729
+ session.updatedAt = Date.now();
2730
+ await saveSessionAsync(session, { expectedGeneration: session.generation });
2731
+ return true;
2732
+ }
2733
+ /**
2734
+ * Close a session. Plants a `closed=true` tombstone on disk with a bumped
2735
+ * generation (so any racing saveSession() drops its write), aborts the
2736
+ * in-flight controller if one exists, and clears the in-memory runtime entry.
2737
+ *
2738
+ * IMPORTANT: we deliberately do NOT unlink the session file here. The tombstone
2739
+ * on disk is the authoritative signal that blocks resurrection — a late
2740
+ * saveSession() re-reads disk via _shouldDrop() and will find the tombstone.
2741
+ * If we delete the file, a late save sees no file, decides nothing to drop,
2742
+ * and recreates the session in its pre-close state.
2743
+ *
2744
+ * Long-term cleanup: `sweepTombstones()` below unlinks tombstones older than
2745
+ * TOMBSTONE_MAX_AGE_MS (24h — vastly longer than any realistic in-flight race).
2746
+ */
2747
+ export function closeSession(id, reason = 'manual') {
2748
+ if (!id) return false;
2749
+ // Prefer in-memory runtime session — allBashSessionIds may not be persisted
2750
+ // yet for shells opened in the current turn (BL-bash-disk-sync).
2751
+ const inMemory = _runtimeState.get(id)?.session;
2752
+ const persisted = inMemory || loadSession(id);
2753
+ const bashSessionId = persisted?.implicitBashSessionId || null;
2754
+ // Collect all persistent bash shells created during this session.
2755
+ const allBashIds = Array.isArray(persisted?.allBashSessionIds)
2756
+ ? persisted.allBashSessionIds.filter(Boolean)
2757
+ : (bashSessionId ? [bashSessionId] : []);
2758
+ // Deduplicate: allBashIds already covers implicitBashSessionId, but guard
2759
+ // against old session records that only have implicitBashSessionId.
2760
+ if (bashSessionId && !allBashIds.includes(bashSessionId)) allBashIds.push(bashSessionId);
2761
+ // 1. Tombstone first — this wins the race against saveSession().
2762
+ const newGen = markSessionClosed(id, reason);
2763
+ // 2. Mark runtime as closed so post-await validation in askSession fires.
2764
+ const entry = _runtimeState.get(id);
2765
+ if (entry) {
2766
+ entry.closed = true;
2767
+ entry.closedReason = reason;
2768
+ if (typeof newGen === 'number') entry.generation = newGen;
2769
+ entry.stage = 'cancelling';
2770
+ entry.updatedAt = Date.now();
2771
+ // 3. Abort the in-flight controller. Providers that honour the signal
2772
+ // unwind immediately; providers that don't will still be caught by
2773
+ // the generation check after their await eventually returns.
2774
+ try { entry.controller?.abort(new SessionClosedError(id, `closeSession (reason=${reason})`, reason)); } catch { /* ignore */ }
2775
+ }
2776
+ // Diagnostic: one-line stderr so operators can distinguish the four close
2777
+ // pathways (request-abort / manual / idle-sweep / runner-crash). iterCount
2778
+ // is not currently tracked on runtime state; askStartedAt is — derive
2779
+ // duration from it when present.
2780
+ try {
2781
+ const askStartedAt = entry?.askStartedAt;
2782
+ const durationMs = (typeof askStartedAt === 'number') ? (Date.now() - askStartedAt) : null;
2783
+ const parts = [`session=${id}`, `reason=${reason}`];
2784
+ if (durationMs != null) parts.push(`duration=${durationMs}ms`);
2785
+ if (!process.env.MIXDOG_QUIET_SESSION_LOG) process.stderr.write(`[bridge-close] ${parts.join(' ')}\n`);
2786
+ } catch { /* best-effort */ }
2787
+ for (const bsid of allBashIds) {
2788
+ try { closeBashSession(bsid, `bridge-close:${id}`); } catch { /* ignore */ }
2789
+ }
2790
+ // Drop session-scoped read dedup cache so the Map doesn't accumulate
2791
+ // entries across mcp-server lifetime.
2792
+ try { clearReadDedupSession(id); } catch { /* ignore */ }
2793
+ // Drop offload sidecars + module-level counter for this session so a
2794
+ // long-running mcp-server doesn't leak disk (tool-results/<id>/*.txt)
2795
+ // or Map entries across session lifetime. Fire-and-forget — close path
2796
+ // should not await disk IO; errors are swallowed inside.
2797
+ try { clearOffloadSession(id); } catch { /* ignore */ }
2798
+ // 4. Defer runtime map clear to next tick so any settling askSession can
2799
+ // observe `closed=true` / bumped generation before we yank the entry.
2800
+ // Disk tombstone remains — that's what blocks resurrection.
2801
+ setImmediate(() => {
2802
+ _clearSessionRuntime(id);
2803
+ });
2804
+ return true;
2805
+ }
2806
+ export function abortSessionTurn(id, reason = 'turn-abort') {
2807
+ if (!id) return false;
2808
+ const entry = _runtimeState.get(id);
2809
+ if (!entry || entry.closed) return false;
2810
+ entry.stage = 'cancelling';
2811
+ entry.closedReason = reason;
2812
+ entry.updatedAt = Date.now();
2813
+ try {
2814
+ entry.controller?.abort(new SessionClosedError(id, `abortSessionTurn (reason=${reason})`, reason));
2815
+ } catch { /* ignore */ }
2816
+ return true;
2817
+ }
2818
+
2819
+ // --- Periodic idle session cleanup ---
2820
+ const CLEANUP_INTERVAL_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INTERVAL_MS', 5 * 60 * 1000); // check every 5 minutes
2821
+ const CLEANUP_INITIAL_DELAY_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INITIAL_DELAY_MS', 60 * 1000);
2822
+ const CLEANUP_SLOW_LOG_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_SLOW_LOG_MS', 250);
2823
+ const TOMBSTONE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h — far longer than any realistic ask race window
2824
+ let _cleanupTimer = null;
2825
+ let _cleanupInitialTimer = null;
2826
+
2827
+ function nonNegativeIntEnv(name, fallback) {
2828
+ const value = Number(process.env[name]);
2829
+ return Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback;
2830
+ }
2831
+
2832
+ function _previewIds(items, limit = 5) {
2833
+ const ids = (items || []).slice(0, limit).map((item) => item.id).filter(Boolean);
2834
+ if (ids.length === 0) return '';
2835
+ const more = items.length > limit ? `, +${items.length - limit} more` : '';
2836
+ return ` (${ids.join(', ')}${more})`;
2837
+ }
2838
+
2839
+ function sweepIdleSessions({ includeTombstones = true } = {}) {
2840
+ const startedAt = Date.now();
2841
+ try {
2842
+ const result = sweepStaleSessions({
2843
+ tombstoneMaxAgeMs: includeTombstones ? TOMBSTONE_MAX_AGE_MS : 0,
2844
+ });
2845
+ const {
2846
+ cleaned,
2847
+ remaining,
2848
+ details,
2849
+ tombstonesCleaned = 0,
2850
+ tombstoneDetails = [],
2851
+ tombstoneErrors = [],
2852
+ } = result;
2853
+ if (cleaned > 0) {
2854
+ for (const d of details) {
2855
+ // Skip entries with an active in-flight controller — aborting
2856
+ // them via closeSession() is the safe path; clearing the runtime
2857
+ // without signalling the controller leaves orphan provider work.
2858
+ const rtEntry = _runtimeState.get(d.id);
2859
+ if (rtEntry && rtEntry.controller && !rtEntry.controller.signal?.aborted) {
2860
+ try { closeSession(d.id, 'idle-sweep'); } catch { /* ignore */ }
2861
+ } else {
2862
+ _clearSessionRuntime(d.id);
2863
+ if (d.bashSessionId) {
2864
+ try { closeBashSession(d.bashSessionId, `idle-sweep:${d.id}`); } catch { /* ignore */ }
2865
+ }
2866
+ }
2867
+ process.stderr.write(`[bridge-session] idle cleanup: closed ${d.id} (idle ${d.idleMinutes}m, owner=${d.owner})\n`);
2868
+ }
2869
+ process.stderr.write(`[bridge-session] idle sweep: cleaned ${cleaned} session(s), ${remaining} remaining\n`);
2870
+ }
2871
+ if (tombstonesCleaned > 0) {
2872
+ for (const d of tombstoneDetails) {
2873
+ if (d?.id) _clearSessionRuntime(d.id);
2874
+ }
2875
+ process.stderr.write(`[session-sweep] unlinked ${tombstonesCleaned} tombstone(s)${_previewIds(tombstoneDetails)}\n`);
2876
+ }
2877
+ if (tombstoneErrors.length > 0) {
2878
+ const first = tombstoneErrors[0];
2879
+ process.stderr.write(`[session-sweep] tombstone unlink failed for ${tombstoneErrors.length} session(s): ${first?.id || 'unknown'} ${first?.message || ''}\n`);
2880
+ }
2881
+ const elapsed = Date.now() - startedAt;
2882
+ if (elapsed >= CLEANUP_SLOW_LOG_MS) {
2883
+ process.stderr.write(`[session-sweep] cleanup took ${elapsed}ms (idle=${cleaned}, tombstones=${tombstonesCleaned}, remaining=${remaining})\n`);
2884
+ }
2885
+ } catch (e) {
2886
+ process.stderr.write(`[bridge-session] idle sweep error: ${e && e.message || e}\n`);
2887
+ }
2888
+ }
2889
+
2890
+ /**
2891
+ * Unlink tombstone session files (closed=true) older than TOMBSTONE_MAX_AGE_MS.
2892
+ *
2893
+ * Rationale: closeSession() leaves the tombstone on disk as the authoritative
2894
+ * resurrection-blocker for racing saveSession() calls. That race resolves in
2895
+ * microseconds (the window inside _doSave between temp write and rename), so
2896
+ * 24h is vastly safe. After the TTL expires we reclaim the disk slot.
2897
+ *
2898
+ * Uses `getStoredSessionsRaw()` rather than `listStoredSessions()` because the
2899
+ * latter's inline 30-min idle cleanup would race-unlink tombstones before we
2900
+ * get to log them — we want to own the unlink decision and stderr line here.
2901
+ */
2902
+ export function sweepTombstones() {
2903
+ try {
2904
+ const { tombstonesCleaned = 0, tombstoneDetails = [], tombstoneErrors = [] } = sweepStaleSessions({
2905
+ sweepIdle: false,
2906
+ tombstoneMaxAgeMs: TOMBSTONE_MAX_AGE_MS,
2907
+ });
2908
+ for (const d of tombstoneDetails) {
2909
+ if (d?.id) _clearSessionRuntime(d.id);
2910
+ }
2911
+ if (tombstonesCleaned > 0) {
2912
+ process.stderr.write(`[session-sweep] unlinked ${tombstonesCleaned} tombstone(s)${_previewIds(tombstoneDetails)}\n`);
2913
+ }
2914
+ if (tombstoneErrors.length > 0) {
2915
+ const first = tombstoneErrors[0];
2916
+ process.stderr.write(`[session-sweep] tombstone unlink failed for ${tombstoneErrors.length} session(s): ${first?.id || 'unknown'} ${first?.message || ''}\n`);
2917
+ }
2918
+ return tombstonesCleaned;
2919
+ } catch (e) {
2920
+ process.stderr.write(`[session-sweep] tombstone sweep error: ${e && e.message || e}\n`);
2921
+ return 0;
2922
+ }
2923
+ }
2924
+
2925
+ function _runCleanupCycle() {
2926
+ sweepIdleSessions({ includeTombstones: true });
2927
+ }
2928
+
2929
+ function _startCleanupInterval() {
2930
+ if (_cleanupTimer) return;
2931
+ if (CLEANUP_INTERVAL_MS <= 0) return;
2932
+ _cleanupTimer = setInterval(_runCleanupCycle, CLEANUP_INTERVAL_MS);
2933
+ if (_cleanupTimer.unref) _cleanupTimer.unref(); // don't block process exit
2934
+ }
2935
+
2936
+ export function startIdleCleanup() {
2937
+ if (_cleanupTimer || _cleanupInitialTimer) return;
2938
+ if (CLEANUP_INITIAL_DELAY_MS <= 0) {
2939
+ _runCleanupCycle();
2940
+ _startCleanupInterval();
2941
+ return;
2942
+ }
2943
+ _cleanupInitialTimer = setTimeout(() => {
2944
+ _cleanupInitialTimer = null;
2945
+ _runCleanupCycle();
2946
+ _startCleanupInterval();
2947
+ }, CLEANUP_INITIAL_DELAY_MS);
2948
+ if (_cleanupInitialTimer.unref) _cleanupInitialTimer.unref();
2949
+ }
2950
+
2951
+ export function stopIdleCleanup() {
2952
+ if (_cleanupInitialTimer) {
2953
+ clearTimeout(_cleanupInitialTimer);
2954
+ _cleanupInitialTimer = null;
2955
+ }
2956
+ if (_cleanupTimer) {
2957
+ clearInterval(_cleanupTimer);
2958
+ _cleanupTimer = null;
2959
+ }
2960
+ }