mixdog 0.7.18 → 0.8.1

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