mixdog 0.9.2 → 0.9.4

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 (417) hide show
  1. package/package.json +8 -3
  2. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  3. package/scripts/bench/lead-review-tasks-r3.json +20 -0
  4. package/scripts/bench/lead-review-tasks.json +20 -0
  5. package/scripts/bench/r4-mixed-tasks.json +20 -0
  6. package/scripts/bench/review-tasks.json +20 -0
  7. package/scripts/bench/round-codex.json +114 -0
  8. package/scripts/bench/round-mixdog-lead-r3.json +269 -0
  9. package/scripts/bench/round-mixdog-lead.json +269 -0
  10. package/scripts/bench/round-mixdog.json +126 -0
  11. package/scripts/bench/round-r10-bigsample.json +679 -0
  12. package/scripts/bench/round-r11-codexalign.json +257 -0
  13. package/scripts/bench/round-r4-codex.json +114 -0
  14. package/scripts/bench/round-r4-mixed.json +225 -0
  15. package/scripts/bench/round-r5-gpt-lead.json +259 -0
  16. package/scripts/bench/round-r6-codex.json +114 -0
  17. package/scripts/bench/round-r6-solo.json +257 -0
  18. package/scripts/bench/round-r7-full.json +254 -0
  19. package/scripts/bench/round-r8-fulldefault.json +255 -0
  20. package/scripts/bench-run.mjs +215 -29
  21. package/scripts/build-tui.mjs +13 -1
  22. package/scripts/explore-bench.mjs +124 -0
  23. package/scripts/freevar-smoke.mjs +95 -0
  24. package/scripts/hook-bus-test.mjs +191 -0
  25. package/scripts/internal-comms-bench.mjs +1 -0
  26. package/scripts/internal-comms-smoke.mjs +10 -9
  27. package/scripts/mouse-probe.mjs +45 -0
  28. package/scripts/output-style-bench.mjs +13 -6
  29. package/scripts/output-style-smoke.mjs +4 -4
  30. package/scripts/path-suffix-test.mjs +57 -0
  31. package/scripts/provider-toolcall-test.mjs +7 -3
  32. package/scripts/recall-bench.mjs +207 -0
  33. package/scripts/recall-usecase-cases.json +18 -0
  34. package/scripts/recall-usecase-probe.json +6 -0
  35. package/scripts/session-bench.mjs +152 -6
  36. package/scripts/tool-smoke.mjs +30 -67
  37. package/scripts/tui-render-smoke.mjs +90 -0
  38. package/scripts/webhook-smoke.mjs +208 -0
  39. package/src/agents/debugger/AGENT.md +5 -2
  40. package/src/agents/heavy-worker/AGENT.md +21 -11
  41. package/src/agents/maintainer/AGENT.md +4 -0
  42. package/src/agents/reviewer/AGENT.md +3 -2
  43. package/src/agents/worker/AGENT.md +21 -11
  44. package/src/lib/rules-builder.cjs +4 -0
  45. package/src/mixdog-session-runtime.mjs +933 -3731
  46. package/src/output-styles/default.md +34 -9
  47. package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
  48. package/src/output-styles/minimal.md +4 -1
  49. package/src/output-styles/simple.md +22 -7
  50. package/src/repl.mjs +5 -5
  51. package/src/rules/agent/00-common.md +2 -0
  52. package/src/rules/agent/30-explorer.md +8 -11
  53. package/src/rules/lead/lead-brief.md +12 -0
  54. package/src/rules/lead/lead-tool.md +2 -9
  55. package/src/rules/shared/01-tool.md +11 -5
  56. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
  57. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
  58. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
  59. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
  60. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
  61. package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
  62. package/src/runtime/agent/orchestrator/config.mjs +69 -2
  63. package/src/runtime/agent/orchestrator/context/collect.mjs +51 -0
  64. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  65. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +63 -21
  66. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
  68. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +97 -1343
  70. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
  71. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +78 -10
  72. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +1 -13
  73. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
  74. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
  75. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
  76. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
  77. package/src/runtime/agent/orchestrator/providers/gemini.mjs +44 -1014
  78. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +18 -4
  79. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  80. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +86 -11
  81. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +348 -0
  82. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  83. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
  84. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -12
  85. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
  86. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
  87. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
  88. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +41 -1142
  89. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +732 -0
  90. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
  91. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +303 -2119
  92. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +140 -995
  93. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +227 -0
  94. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
  95. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +436 -0
  96. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
  97. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +38 -12
  99. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +7 -8
  100. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
  101. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
  102. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
  103. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
  104. package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
  105. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
  106. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
  107. package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
  108. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  109. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +274 -0
  110. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
  111. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  112. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  113. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  114. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +47 -0
  115. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +182 -0
  116. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
  117. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  118. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  119. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
  120. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  121. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
  122. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  123. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  124. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  125. package/src/runtime/agent/orchestrator/session/loop.mjs +409 -1304
  126. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
  127. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +230 -0
  128. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  129. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +149 -0
  130. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  131. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
  132. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
  133. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  134. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
  135. package/src/runtime/agent/orchestrator/session/manager.mjs +226 -2114
  136. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
  137. package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
  138. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -3
  139. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
  140. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
  141. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +33 -42
  142. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  143. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  144. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
  145. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  146. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +1 -1
  147. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  148. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
  149. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
  150. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
  151. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -87
  152. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
  153. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
  154. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +78 -304
  155. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -6
  156. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  157. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  158. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  159. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +551 -0
  160. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  161. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  162. package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
  163. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  164. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  165. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  166. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1080 -0
  167. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  168. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  169. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  170. package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
  171. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  172. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
  173. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +36 -4277
  174. package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
  175. package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
  176. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
  177. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
  178. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
  179. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
  180. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
  181. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
  182. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
  183. package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
  184. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -23
  185. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +10 -74
  186. package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
  187. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  188. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
  189. package/src/runtime/channels/backends/discord-access.mjs +32 -0
  190. package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
  191. package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
  192. package/src/runtime/channels/backends/discord.mjs +12 -292
  193. package/src/runtime/channels/index.mjs +241 -894
  194. package/src/runtime/channels/lib/backend-dispatch.mjs +44 -0
  195. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  196. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  197. package/src/runtime/channels/lib/event-pipeline.mjs +18 -1
  198. package/src/runtime/channels/lib/event-queue.mjs +63 -4
  199. package/src/runtime/channels/lib/inbound-routing.mjs +111 -0
  200. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  201. package/src/runtime/channels/lib/output-forwarder.mjs +9 -1
  202. package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
  203. package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
  204. package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
  205. package/src/runtime/channels/lib/session-discovery.mjs +56 -4
  206. package/src/runtime/channels/lib/telegram-format.mjs +19 -22
  207. package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
  208. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  209. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
  210. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
  211. package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
  212. package/src/runtime/channels/lib/webhook/deliveries.mjs +312 -0
  213. package/src/runtime/channels/lib/webhook/log.mjs +42 -0
  214. package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
  215. package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
  216. package/src/runtime/channels/lib/webhook.mjs +36 -570
  217. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  218. package/src/runtime/channels/tool-defs.mjs +11 -130
  219. package/src/runtime/memory/index.mjs +258 -2050
  220. package/src/runtime/memory/lib/core-memory-store.mjs +351 -1
  221. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
  222. package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
  223. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  224. package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
  225. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  226. package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
  227. package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
  228. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
  229. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
  230. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
  231. package/src/runtime/memory/lib/memory-cycle2.mjs +72 -837
  232. package/src/runtime/memory/lib/memory-embed.mjs +149 -0
  233. package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
  234. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  235. package/src/runtime/memory/lib/memory-recall-store.mjs +22 -2
  236. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  237. package/src/runtime/memory/lib/memory.mjs +20 -0
  238. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
  239. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  240. package/src/runtime/memory/lib/query-handlers.mjs +780 -0
  241. package/src/runtime/memory/lib/recall-format.mjs +238 -0
  242. package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
  243. package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
  244. package/src/runtime/memory/tool-defs.mjs +6 -14
  245. package/src/runtime/search/lib/http-fetch.mjs +274 -0
  246. package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
  247. package/src/runtime/search/lib/web-tools.mjs +24 -602
  248. package/src/runtime/shared/abort-controller.mjs +1 -1
  249. package/src/runtime/shared/atomic-file.mjs +26 -1
  250. package/src/runtime/shared/background-tasks.mjs +2 -3
  251. package/src/runtime/shared/buffered-appender.mjs +149 -0
  252. package/src/runtime/shared/launcher-control.mjs +2 -2
  253. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  254. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  255. package/src/runtime/shared/tool-primitives.mjs +308 -0
  256. package/src/runtime/shared/tool-result-summary.mjs +515 -0
  257. package/src/runtime/shared/tool-surface.mjs +80 -898
  258. package/src/runtime/shared/transcript-writer.mjs +52 -2
  259. package/src/runtime/shared/update-checker.mjs +7 -4
  260. package/src/session-runtime/config-helpers.mjs +291 -0
  261. package/src/session-runtime/config-lifecycle.mjs +232 -0
  262. package/src/session-runtime/cwd-plugins.mjs +226 -0
  263. package/src/session-runtime/effort.mjs +128 -0
  264. package/src/session-runtime/fs-utils.mjs +10 -0
  265. package/src/session-runtime/mcp-glue.mjs +177 -0
  266. package/src/session-runtime/model-capabilities.mjs +130 -0
  267. package/src/session-runtime/model-recency.mjs +111 -0
  268. package/src/session-runtime/native-search.mjs +247 -0
  269. package/src/session-runtime/output-styles.mjs +126 -0
  270. package/src/session-runtime/plugin-mcp.mjs +114 -0
  271. package/src/session-runtime/prewarm.mjs +142 -0
  272. package/src/session-runtime/provider-models.mjs +278 -0
  273. package/src/session-runtime/provider-usage.mjs +120 -0
  274. package/src/session-runtime/quick-model-rows.mjs +170 -0
  275. package/src/session-runtime/quick-search-models.mjs +46 -0
  276. package/src/session-runtime/session-hooks.mjs +93 -0
  277. package/src/session-runtime/session-text.mjs +100 -0
  278. package/src/session-runtime/settings-api.mjs +319 -0
  279. package/src/session-runtime/statusline-route.mjs +35 -0
  280. package/src/session-runtime/tool-catalog.mjs +720 -0
  281. package/src/session-runtime/tool-defs.mjs +84 -0
  282. package/src/session-runtime/warmup-schedulers.mjs +201 -0
  283. package/src/session-runtime/workflow.mjs +358 -0
  284. package/src/standalone/agent-tool/helpers.mjs +237 -0
  285. package/src/standalone/agent-tool/notify.mjs +107 -0
  286. package/src/standalone/agent-tool/provider-init.mjs +143 -0
  287. package/src/standalone/agent-tool/render.mjs +152 -0
  288. package/src/standalone/agent-tool/tool-def.mjs +55 -0
  289. package/src/standalone/agent-tool.mjs +155 -677
  290. package/src/standalone/channel-worker.mjs +7 -9
  291. package/src/standalone/explore-tool.mjs +40 -12
  292. package/src/standalone/hook-bus/config.mjs +207 -0
  293. package/src/standalone/hook-bus/constants.mjs +90 -0
  294. package/src/standalone/hook-bus/handlers.mjs +481 -0
  295. package/src/standalone/hook-bus/payload.mjs +31 -0
  296. package/src/standalone/hook-bus/rules.mjs +77 -0
  297. package/src/standalone/hook-bus.mjs +110 -746
  298. package/src/standalone/memory-runtime-proxy.mjs +7 -0
  299. package/src/standalone/opencode-go-login.mjs +125 -0
  300. package/src/standalone/provider-admin.mjs +15 -19
  301. package/src/standalone/usage-dashboard.mjs +3 -1
  302. package/src/tui/App.jsx +1163 -7571
  303. package/src/tui/app/app-format.mjs +206 -0
  304. package/src/tui/app/channel-pickers.mjs +510 -0
  305. package/src/tui/app/clipboard.mjs +67 -0
  306. package/src/tui/app/core-memory-picker.mjs +210 -0
  307. package/src/tui/app/extension-pickers.mjs +506 -0
  308. package/src/tui/app/input-parsers.mjs +193 -0
  309. package/src/tui/app/maintenance-pickers.mjs +324 -0
  310. package/src/tui/app/model-options.mjs +330 -0
  311. package/src/tui/app/model-picker.mjs +365 -0
  312. package/src/tui/app/onboarding-steps.mjs +400 -0
  313. package/src/tui/app/project-picker.mjs +247 -0
  314. package/src/tui/app/provider-setup-picker.mjs +580 -0
  315. package/src/tui/app/resume-picker.mjs +55 -0
  316. package/src/tui/app/route-pickers.mjs +419 -0
  317. package/src/tui/app/settings-picker.mjs +490 -0
  318. package/src/tui/app/slash-commands.mjs +101 -0
  319. package/src/tui/app/slash-dispatch.mjs +427 -0
  320. package/src/tui/app/text-layout.mjs +46 -0
  321. package/src/tui/app/theme-effort-pickers.mjs +154 -0
  322. package/src/tui/app/transcript-window.mjs +671 -0
  323. package/src/tui/app/use-mouse-input.mjs +460 -0
  324. package/src/tui/app/use-prompt-handlers.mjs +310 -0
  325. package/src/tui/app/use-transcript-scroll.mjs +510 -0
  326. package/src/tui/app/use-transcript-window.mjs +589 -0
  327. package/src/tui/components/ConfirmBar.jsx +1 -1
  328. package/src/tui/components/Picker.jsx +32 -4
  329. package/src/tui/components/PromptInput.jsx +259 -80
  330. package/src/tui/components/SlashCommandPalette.jsx +8 -1
  331. package/src/tui/components/StatusLine.jsx +63 -12
  332. package/src/tui/components/TextEntryPanel.jsx +11 -0
  333. package/src/tui/components/ToolExecution.jsx +56 -588
  334. package/src/tui/components/TranscriptItem.jsx +105 -0
  335. package/src/tui/components/UsagePanel.jsx +18 -4
  336. package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
  337. package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
  338. package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
  339. package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
  340. package/src/tui/components/tool-execution/text-format.mjs +161 -0
  341. package/src/tui/components/tool-output-format.mjs +2 -2
  342. package/src/tui/display-width.mjs +20 -3
  343. package/src/tui/dist/index.mjs +18034 -17188
  344. package/src/tui/engine/agent-envelope.mjs +296 -0
  345. package/src/tui/engine/agent-job-feed.mjs +133 -0
  346. package/src/tui/engine/boot-profile.mjs +21 -0
  347. package/src/tui/engine/labels.mjs +67 -0
  348. package/src/tui/engine/notice-text.mjs +112 -0
  349. package/src/tui/engine/notification-plan.mjs +76 -0
  350. package/src/tui/engine/queue-helpers.mjs +161 -0
  351. package/src/tui/engine/render-timing.mjs +17 -0
  352. package/src/tui/engine/session-stats.mjs +46 -0
  353. package/src/tui/engine/tool-approval.mjs +94 -0
  354. package/src/tui/engine/tool-call-fields.mjs +23 -0
  355. package/src/tui/engine/tool-card-results.mjs +234 -0
  356. package/src/tui/engine/tool-result-status.mjs +135 -0
  357. package/src/tui/engine/tool-result-text.mjs +126 -0
  358. package/src/tui/engine.mjs +405 -1385
  359. package/src/tui/figures.mjs +5 -0
  360. package/src/tui/index.jsx +105 -0
  361. package/src/tui/input-editing.mjs +60 -10
  362. package/src/tui/keyboard-protocol.mjs +2 -2
  363. package/src/tui/lib/voice-recorder.mjs +35 -19
  364. package/src/tui/markdown/format-token.mjs +11 -9
  365. package/src/tui/paste-attachments.mjs +38 -0
  366. package/src/tui/statusline-ansi-bridge.mjs +11 -3
  367. package/src/tui/theme.mjs +6 -0
  368. package/src/tui/themes/base.mjs +2 -2
  369. package/src/tui/themes/kanagawa.mjs +4 -4
  370. package/src/tui/themes/teal.mjs +4 -5
  371. package/src/tui/themes/utils.mjs +1 -1
  372. package/src/ui/statusline-agents.mjs +213 -0
  373. package/src/ui/statusline-format.mjs +146 -0
  374. package/src/ui/statusline-segments.mjs +148 -0
  375. package/src/ui/statusline.mjs +77 -462
  376. package/src/ui/tool-card.mjs +0 -1
  377. package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
  378. package/src/workflows/default/WORKFLOW.md +16 -9
  379. package/src/workflows/sequential/WORKFLOW.md +16 -11
  380. package/src/workflows/solo/WORKFLOW.md +5 -1
  381. package/vendor/ink/build/display-width.js +19 -3
  382. package/vendor/ink/build/ink.js +103 -6
  383. package/vendor/ink/build/log-update.js +17 -3
  384. package/vendor/ink/build/wrap-text.js +125 -0
  385. package/scripts/_test-folder-dialog.mjs +0 -30
  386. package/scripts/fix-brief-fn.mjs +0 -35
  387. package/scripts/fix-format-tool-surface.mjs +0 -24
  388. package/scripts/fix-tool-exec-visible.mjs +0 -42
  389. package/scripts/patch-agent-brief.mjs +0 -48
  390. package/scripts/patch-app.mjs +0 -21
  391. package/scripts/patch-app2.mjs +0 -18
  392. package/scripts/patch-dist-brief.mjs +0 -96
  393. package/scripts/patch-tool-exec.mjs +0 -70
  394. package/src/examples/schedules/SCHEDULE.example.md +0 -32
  395. package/src/examples/webhooks/WEBHOOK.example.md +0 -40
  396. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
  397. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
  398. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
  399. package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
  400. package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
  401. package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
  402. package/src/standalone/agent-task-status.test.mjs +0 -76
  403. package/src/tui/components/tool-output-format.test.mjs +0 -399
  404. package/src/tui/display-width.test.mjs +0 -35
  405. package/src/tui/engine-runtime-notification.test.mjs +0 -115
  406. package/src/tui/engine-tool-result-text.test.mjs +0 -75
  407. package/src/tui/markdown/format-token.test.mjs +0 -354
  408. package/src/tui/markdown/render-ansi.test.mjs +0 -108
  409. package/src/tui/markdown/stream-fence.test.mjs +0 -26
  410. package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
  411. package/src/tui/prompt-history-store.test.mjs +0 -52
  412. package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
  413. package/src/tui/transcript-tool-failures.test.mjs +0 -111
  414. package/src/ui/markdown.test.mjs +0 -70
  415. package/src/ui/statusline-context-label.test.mjs +0 -15
  416. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
  417. package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
@@ -1,76 +1,161 @@
1
1
  import { createRequire } from 'module';
2
2
  import { fileURLToPath } from 'url';
3
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';
4
+ import { getProvider } from '../providers/registry.mjs';
8
5
  // Image content is kept in-memory and in the model-visible history so multi-turn
9
- // recognition matches reference agent behavior (live transcript always retains images). The
6
+ // recognition works reliably (live transcript always retains images). The
10
7
  // stored-history placeholder swap now happens only at disk-serialization time
11
8
  // inside the session store, so it is no longer imported here.
12
9
  import {
13
- recallFastTrackCompactMessages,
14
- semanticCompactMessages,
15
- pruneToolOutputsUnanchored,
16
- effectiveBudget as compactEffectiveBudget,
17
- compactTypeIsRecallFastTrack,
18
- compactTypeIsSemantic,
19
10
  normalizeCompactType,
20
11
  DEFAULT_COMPACT_TYPE,
21
12
  SUMMARY_PREFIX,
22
- drainSessionCycle1,
23
13
  } from './compact.mjs';
24
- import { estimateMessagesTokens, estimateRequestReserveTokens, estimateTranscriptContextUsage, resolveCompactTriggerTokens } from './context-utils.mjs';
25
- import { getMcpTools } from '../mcp/client.mjs';
26
- import { getInternalTools, executeInternalTool } from '../internal-tools.mjs';
27
- import { BUILTIN_TOOLS } from '../tools/builtin/builtin-tools.mjs';
28
- import { PATCH_TOOL_DEFS } from '../tools/patch-tool-defs.mjs';
29
- import { CODE_GRAPH_TOOL_DEFS } from '../tools/code-graph-tool-defs.mjs';
30
- import { collectSkillsCached, buildSkillManifest, buildSkillToolDefs, composeSystemPrompt } from '../context/collect.mjs';
31
- import { saveSession, saveSessionAsync, loadSession, listStoredSessionSummaries, sweepStaleSessions, markSessionClosed, publishHeartbeat, deleteHeartbeat, setLiveSession } from './store.mjs';
14
+ import { estimateMessagesTokens, estimateTranscriptContextUsage } from './context-utils.mjs';
15
+ import { executeInternalTool } from '../internal-tools.mjs';
16
+ import { collectSkillsCached, buildSkillManifest, composeSystemPrompt } from '../context/collect.mjs';
17
+ import { saveSession, saveSessionAsync, loadSession, listStoredSessionSummaries, sweepStaleSessions, markSessionClosed, bumpSessionGeneration, setLiveSession } from './store.mjs';
32
18
  import { clearReadDedupSession, tryPrefetchCached, setPrefetchCached } from './read-dedup.mjs';
33
19
  import { clearOffloadSession } from './tool-result-offload.mjs';
34
20
  import { classifyResultKind } from './result-classification.mjs';
35
21
  import { createAbortController } from '../../../shared/abort-controller.mjs';
36
- import { isInternalRuntimeNotificationText as contractIsInternalRuntimeNotificationText } from '../../../shared/tool-execution-contract.mjs';
37
22
  import { logLlmCall } from '../../../shared/llm/usage-log.mjs';
38
- import { resolvePluginData, mixdogRoot } from '../../../shared/plugin-paths.mjs';
39
- import { updateJsonAtomicSync } from '../../../shared/atomic-file.mjs';
40
23
  import { appendAgentTrace } from '../agent-trace.mjs';
41
24
  import { isAgentOwner } from '../agent-owner.mjs';
42
- import { maxMtimeRecursive } from '../cache-mtime.mjs';
43
- import { getHiddenAgent, getAgentInstructionDir, listHiddenAgentNames } from '../internal-agents.mjs';
44
- import { DEFAULT_ACTIVITY_HEARTBEAT_MS } from '../stall-policy.mjs';
25
+ import { getHiddenAgent } from '../internal-agents.mjs';
26
+ import { loadConfig } from '../config.mjs';
27
+ import { buildProviderCacheOpts, cacheCapabilityForProvider } from '../agent-runtime/cache-strategy.mjs';
28
+ import { normalizeAutoClearConfig, resolveAutoClearIdleMs } from '../../../../session-runtime/config-helpers.mjs';
45
29
  import {
46
- buildGatewayLimits,
47
- recordGatewayUsageEvent,
48
- summarizeGatewayUsage,
49
- } from '../providers/statusline-route-meta.mjs';
50
- // Phase B: Pool B Tier 2 content builder (common rules only).
51
- // Loaded once per process via createRequire so the CJS module reaches us.
52
- const _require = createRequire(import.meta.url);
53
- const _rulesBuilder = (() => {
54
- const candidates = [
55
- join(mixdogRoot(), 'lib', 'rules-builder.cjs'),
56
- ].filter(Boolean);
57
- for (const p of candidates) {
58
- try { return _require(p); } catch { /* fall through */ }
59
- }
60
- // Fallback: walk up from this file's location to find lib/rules-builder.cjs.
61
- try { return _require('../../../../lib/rules-builder.cjs'); } catch { return null; }
62
- })();
63
-
64
- // BP1/BP2/BP3 prompt-layer caches — invalidated by source file mtime, not a
65
- // timer. Cheap: O(sentinel-count) stat calls on each session creation, no file
66
- // I/O when warm.
67
- let _sharedRulesCache = null;
68
- let _sharedRulesMtime = 0;
69
- const _agentRulesCacheByProfile = new Map();
70
- let _leadRulesCache = null;
71
- let _leadRulesMtime = 0;
72
- let _leadMetaCache = null;
73
- let _leadMetaMtime = 0;
30
+ recordStandaloneStatusTelemetry,
31
+ } from './manager/status-telemetry.mjs';
32
+ import {
33
+ normalizeStaleCompactingStage,
34
+ runSessionCompaction,
35
+ } from './manager/compaction-runner.mjs';
36
+ // Split modules — see manager/ directory. manager.mjs is now a thin facade that
37
+ // orchestrates these cohesive units while keeping the exact public surface.
38
+ import {
39
+ _buildSharedRules,
40
+ _buildAgentRules,
41
+ _buildLeadRules,
42
+ _buildLeadMetaContext,
43
+ _buildAgentSpecific,
44
+ } from './manager/rules-cache.mjs';
45
+ import {
46
+ applyToolPermissionNarrowing,
47
+ finalizeSessionToolList,
48
+ resolveSessionTools,
49
+ previewSessionTools,
50
+ permissionFromToolSpec,
51
+ } from './manager/tool-resolution.mjs';
52
+ import {
53
+ guessContextWindow,
54
+ positiveContextWindow,
55
+ preserveBufferConfigFields,
56
+ resolveSessionContextMeta,
57
+ compactTriggerForSession,
58
+ } from './manager/context-meta.mjs';
59
+ import {
60
+ promptContentText,
61
+ hasModelVisiblePromptContent,
62
+ promptContentBytes,
63
+ prefixUserTurnContent,
64
+ prefixSessionStartContent,
65
+ buildCurrentTimeBlock,
66
+ buildSessionStartBlock,
67
+ hasUserConversationMessage,
68
+ isInternalRuntimeNotificationText,
69
+ } from './manager/prompt-utils.mjs';
70
+ import {
71
+ _mergePendingMessageEntries,
72
+ enqueuePendingMessage,
73
+ drainPendingMessages,
74
+ _dropPendingMessageState,
75
+ } from './manager/pending-messages.mjs';
76
+ import {
77
+ bumpUsageMetricsTurnId,
78
+ resolveUsageMetricsTurnId,
79
+ bumpUsageMetricsEpoch,
80
+ resolveUsageMetricsEpoch,
81
+ usageMetricsSourceKey,
82
+ usageMetricsIdempotencyKey,
83
+ uncachedInputTokensForProvider,
84
+ applyAskTerminalUsageTotals,
85
+ persistIterationMetrics,
86
+ } from './manager/usage-metrics.mjs';
87
+ // Runtime-liveness map + accessors — extracted (pass-3) to
88
+ // manager/runtime-liveness.mjs. State is a module-level singleton there,
89
+ // preserving the baseline single-process shape. manager.mjs keeps askSession's
90
+ // controller/generation lifecycle and calls these via imports. The two
91
+ // _get/_entries accessors expose the private Map for in-place mutation from
92
+ // closeSession / idle-sweep / askSession.
93
+ import {
94
+ configureRuntimeLiveness,
95
+ updateSessionStage,
96
+ markSessionAskStart,
97
+ markSessionStreamDelta,
98
+ markSessionToolCall,
99
+ markSessionDone,
100
+ markSessionEmptyFinal,
101
+ markSessionError,
102
+ markSessionCancelled,
103
+ getSessionRuntime,
104
+ isSessionCompactionBlocked,
105
+ getSessionProgressSnapshot,
106
+ forEachSessionRuntime,
107
+ hideSessionFromList,
108
+ getSessionAbortSignal,
109
+ getSessionLastProgressAt,
110
+ linkParentSignalToSession,
111
+ _touchRuntime,
112
+ _stopToolActivityHeartbeat,
113
+ _unlinkParentAbortListener,
114
+ _clearSessionRuntime,
115
+ _getRuntimeEntry,
116
+ _runtimeEntries,
117
+ } from './manager/runtime-liveness.mjs';
118
+ // Facade re-exports — external importers (loop.mjs, loop/tool-exec.mjs,
119
+ // agent-dispatch.mjs, abort-lookup.mjs, statusline-agents.mjs) resolve these
120
+ // through manager.mjs unchanged.
121
+ export {
122
+ updateSessionStage,
123
+ markSessionAskStart,
124
+ markSessionStreamDelta,
125
+ markSessionToolCall,
126
+ markSessionDone,
127
+ markSessionEmptyFinal,
128
+ markSessionError,
129
+ markSessionCancelled,
130
+ getSessionRuntime,
131
+ isSessionCompactionBlocked,
132
+ getSessionProgressSnapshot,
133
+ forEachSessionRuntime,
134
+ hideSessionFromList,
135
+ getSessionAbortSignal,
136
+ getSessionLastProgressAt,
137
+ linkParentSignalToSession,
138
+ };
139
+ // Wire the store deps the liveness module needs without importing store.mjs
140
+ // back into it via manager.mjs (avoids re-entry / keeps one store contract).
141
+ configureRuntimeLiveness({ loadSession, saveSessionAsync });
142
+ // Re-export split-module public/test surface unchanged so every importer of the
143
+ // old symbol names keeps resolving through the facade.
144
+ export { previewSessionTools };
145
+ export { _mergePendingMessageEntries, enqueuePendingMessage, drainPendingMessages };
146
+ export { isInternalRuntimeNotificationText as _isInternalRuntimeNotificationText };
147
+ // Usage-metrics surface — re-exported unchanged so loop.mjs / smoke scripts keep
148
+ // resolving these through the facade.
149
+ export {
150
+ bumpUsageMetricsTurnId,
151
+ resolveUsageMetricsTurnId,
152
+ bumpUsageMetricsEpoch,
153
+ resolveUsageMetricsEpoch,
154
+ usageMetricsSourceKey,
155
+ usageMetricsIdempotencyKey,
156
+ applyAskTerminalUsageTotals,
157
+ persistIterationMetrics,
158
+ };
74
159
  let _codeGraphRuntimePromise = null;
75
160
  let _agentLoopPromise = null;
76
161
  let _bashSessionRuntimePromise = null;
@@ -93,127 +178,9 @@ function _closeBashSessionLazy(sessionId, reason) {
93
178
  .then((mod) => { if (typeof mod.closeBashSession === 'function') mod.closeBashSession(sessionId, reason); })
94
179
  .catch(() => {});
95
180
  }
96
- function _buildSharedRules() {
97
- if (!_rulesBuilder || typeof _rulesBuilder.buildSharedToolContent !== 'function') return '';
98
- const PLUGIN_ROOT = mixdogRoot();
99
- const RULES_DIR = join(PLUGIN_ROOT, 'rules');
100
- const mtime = maxMtimeRecursive([
101
- join(RULES_DIR, 'shared'),
102
- ]);
103
- if (_sharedRulesCache !== null && mtime <= _sharedRulesMtime) {
104
- return _sharedRulesCache;
105
- }
106
- try {
107
- const built = _rulesBuilder.buildSharedToolContent({ PLUGIN_ROOT, DATA_DIR: resolvePluginData() });
108
- _sharedRulesCache = built;
109
- _sharedRulesMtime = mtime;
110
- return built;
111
- } catch (e) {
112
- throw new Error(`[session] shared tool rules build failed: ${e.message}`);
113
- }
114
- }
115
-
116
- function _buildAgentRules(profile = 'full') {
117
- if (!_rulesBuilder || typeof _rulesBuilder.buildAgentRoleContent !== 'function') return '';
118
- const key = String(profile || 'full');
119
- const PLUGIN_ROOT = mixdogRoot();
120
- const DATA_DIR = resolvePluginData();
121
- const RULES_DIR = join(PLUGIN_ROOT, 'rules');
122
- const mtime = maxMtimeRecursive([
123
- join(RULES_DIR, 'agent'),
124
- join(DATA_DIR, 'mixdog-config.json'),
125
- ]);
126
- const cached = _agentRulesCacheByProfile.get(key);
127
- if (cached && mtime <= cached.mtime) {
128
- return cached.value;
129
- }
130
- try {
131
- const built = _rulesBuilder.buildAgentRoleContent({ PLUGIN_ROOT, DATA_DIR, profile: key });
132
- _agentRulesCacheByProfile.set(key, { mtime, value: built });
133
- return built;
134
- } catch (e) {
135
- throw new Error(`[session] agent role rules build failed: ${e.message}`);
136
- }
137
- }
138
-
139
- function _buildLeadRules() {
140
- if (!_rulesBuilder || typeof _rulesBuilder.buildLeadRoleContent !== 'function') return '';
141
- const PLUGIN_ROOT = mixdogRoot();
142
- const DATA_DIR = resolvePluginData();
143
- const RULES_DIR = join(PLUGIN_ROOT, 'rules');
144
- const mtime = maxMtimeRecursive([
145
- join(RULES_DIR, 'lead'),
146
- join(DATA_DIR, 'mixdog-config.json'),
147
- ]);
148
- if (_leadRulesCache !== null && mtime <= _leadRulesMtime) {
149
- return _leadRulesCache;
150
- }
151
- try {
152
- const built = _rulesBuilder.buildLeadRoleContent({ PLUGIN_ROOT, DATA_DIR });
153
- _leadRulesCache = built;
154
- _leadRulesMtime = mtime;
155
- return built;
156
- } catch (e) {
157
- throw new Error(`[session] lead role rules build failed: ${e.message}`);
158
- }
159
- }
160
-
161
- function _buildLeadMetaContext() {
162
- if (!_rulesBuilder || typeof _rulesBuilder.buildLeadMetaContent !== 'function') return '';
163
- const PLUGIN_ROOT = mixdogRoot();
164
- const DATA_DIR = resolvePluginData();
165
- const RULES_DIR = join(PLUGIN_ROOT, 'rules');
166
- const mtime = maxMtimeRecursive([
167
- join(RULES_DIR, 'lead'),
168
- join(DATA_DIR, 'history'),
169
- join(DATA_DIR, 'mixdog-config.json'),
170
- join(DATA_DIR, 'user-workflow.md'),
171
- join(PLUGIN_ROOT, 'output-styles'),
172
- join(DATA_DIR, 'output-styles'),
173
- ]);
174
- if (_leadMetaCache !== null && mtime <= _leadMetaMtime) {
175
- return _leadMetaCache;
176
- }
177
- try {
178
- const built = _rulesBuilder.buildLeadMetaContent({ PLUGIN_ROOT, DATA_DIR });
179
- _leadMetaCache = built;
180
- _leadMetaMtime = mtime;
181
- return built;
182
- } catch (e) {
183
- throw new Error(`[session] lead meta context build failed: ${e.message}`);
184
- }
185
- }
186
-
187
- // BP4-adjacent agent-specific data cache — keyed by agent. webhook / schedule
188
- // agents each have their own scoped instruction set; other agents return ''.
189
- const _roleSpecificCache = new Map(); // agent → { value, mtime }
190
- function _buildAgentSpecific(currentAgent) {
191
- if (!_rulesBuilder || typeof _rulesBuilder.buildAgentRoleSpecificContent !== 'function') return '';
192
- if (!currentAgent) return '';
193
- const PLUGIN_ROOT = mixdogRoot();
194
- const DATA_DIR = resolvePluginData();
195
- const RULES_DIR = join(PLUGIN_ROOT, 'rules');
196
- const roleInstructionDir = getAgentInstructionDir(currentAgent);
197
- const mtime = maxMtimeRecursive([
198
- join(RULES_DIR, 'shared'),
199
- join(DATA_DIR, 'mixdog-config.json'),
200
- join(DATA_DIR, 'webhooks'),
201
- join(DATA_DIR, 'schedules'),
202
- ...(roleInstructionDir ? [join(DATA_DIR, roleInstructionDir)] : []),
203
- join(PLUGIN_ROOT, 'defaults', 'agents.json'),
204
- ]);
205
- const entry = _roleSpecificCache.get(currentAgent);
206
- if (entry && mtime <= entry.mtime) {
207
- return entry.value;
208
- }
209
- try {
210
- const built = _rulesBuilder.buildAgentRoleSpecificContent({ PLUGIN_ROOT, DATA_DIR, currentAgent });
211
- _roleSpecificCache.set(currentAgent, { mtime, value: built });
212
- return built;
213
- } catch (e) {
214
- throw new Error(`[session] agent-specific rules build failed (agent: ${currentAgent}): ${e.message}`);
215
- }
216
- }
181
+ // Re-export the rules builders so any deep importer of the old symbol names
182
+ // keeps working through the facade.
183
+ export { _buildSharedRules, _buildAgentRules, _buildLeadRules, _buildLeadMetaContext, _buildAgentSpecific };
217
184
 
218
185
  // Agent Runtime is optional — injected via setAgentRuntime() during plugin init
219
186
  // so session creation never depends on a circular import. If never injected,
@@ -250,7 +217,6 @@ export class SessionClosedError extends Error {
250
217
  this.reason = closeReason || null;
251
218
  }
252
219
  }
253
- const HEARTBEAT_THROTTLE_MS = 60_000; // 60s
254
220
  // Cap how long the terminal unwind blocks on the post-result session save.
255
221
  // The result is already produced (and relayed for agent surfaces) before this
256
222
  // save, so a stalled disk write must not hold askSession() open — otherwise the
@@ -258,34 +224,10 @@ const HEARTBEAT_THROTTLE_MS = 60_000; // 60s
258
224
  // notification never fires. A slow write finishes in the background.
259
225
  const TERMINAL_SAVE_TIMEOUT_MS = nonNegativeIntEnv('MIXDOG_TERMINAL_SAVE_TIMEOUT_MS', 5_000);
260
226
 
261
- // Merge externally-connected MCP tools with the plugin's in-process tools
262
- // (registered by agent's toolExecutor adapter). Internal tools are exposed
263
- // under their bare names no mcp__ prefix, since the dispatcher in
264
- // server.mjs handles them directly without a transport.
265
- // Sorted deterministically by name — protects BP_1 hash stability from
266
- // listTools() ordering churn. Anthropic / OpenAI / Gemini all hash the
267
- // tools array verbatim, so any reorder rewrites the prefix.
268
- // No cache: getMcpTools() and getInternalTools() are O(n) in-memory reads;
269
- // the sort overhead on ~30 tools is negligible.
270
- function _getMcpTools() {
271
- const mcp = getMcpTools() || [];
272
- const internalRaw = getInternalTools() || [];
273
- const internal = internalRaw.map(t => ({
274
- name: t.name,
275
- description: typeof t.description === 'string' ? t.description : '',
276
- inputSchema: t.inputSchema || { type: 'object', properties: {} },
277
- // Keep annotations so the permission filter / role invariants can
278
- // tell read-only from write-capable internal tools, and so
279
- // agentHidden can be read during deny filtering.
280
- annotations: t.annotations || {},
281
- }));
282
- return [...mcp, ...internal].sort((a, b) => {
283
- const an = a?.name || '';
284
- const bn = b?.name || '';
285
- return an < bn ? -1 : an > bn ? 1 : 0;
286
- });
287
- }
288
-
227
+ // Session tool resolution + permission narrowing moved to
228
+ // manager/tool-resolution.mjs (imported above). The following section-level
229
+ // docs describe the toolSpec contract those helpers implement.
230
+ //
289
231
  // Phase D-2 — profile.tools resolution.
290
232
  //
291
233
  // `toolSpec` may be:
@@ -308,934 +250,12 @@ function _getMcpTools() {
308
250
  // surface intentionally tiny; runtime permission guards in loop.mjs remain
309
251
  // the fail-safe either way.
310
252
 
311
- const SESSION_ROUTE_TOOL_ORDER = [
312
- 'code_graph',
313
- 'find',
314
- 'glob',
315
- 'list',
316
- 'grep',
317
- 'read',
318
- 'apply_patch',
319
- 'shell',
320
- 'task',
321
- ];
322
- const SESSION_ROUTE_TOOL_RANK = new Map(SESSION_ROUTE_TOOL_ORDER.map((name, index) => [name, index]));
323
- const FILESYSTEM_TOOL_NAMES = new Set([
324
- 'code_graph',
325
- 'find',
326
- 'glob',
327
- 'list',
328
- 'grep',
329
- 'read',
330
- 'apply_patch',
331
- ]);
332
- const READONLY_TOOL_NAMES = new Set([
333
- 'code_graph',
334
- 'find',
335
- 'glob',
336
- 'list',
337
- 'grep',
338
- 'read',
339
- ]);
340
-
341
- const AGENT_STRING_PERMISSION_READ_ALLOW = Object.freeze([
342
- 'code_graph',
343
- 'find',
344
- 'glob',
345
- 'list',
346
- 'grep',
347
- 'read',
348
- 'explore',
349
- 'search',
350
- 'web_fetch',
351
- 'Skill',
352
- ]);
353
-
354
- function stringToolPermissionAllowList(toolPermission) {
355
- if (toolPermission === 'read') return AGENT_STRING_PERMISSION_READ_ALLOW;
356
- if (toolPermission === 'read-write') return AGENT_STRING_PERMISSION_READ_WRITE_ALLOW;
357
- if (toolPermission === 'none') return [];
358
- return null;
359
- }
360
-
361
- const AGENT_STRING_PERMISSION_READ_WRITE_ALLOW = Object.freeze([
362
- 'code_graph',
363
- 'find',
364
- 'glob',
365
- 'list',
366
- 'grep',
367
- 'read',
368
- 'apply_patch',
369
- 'explore',
370
- 'search',
371
- 'web_fetch',
372
- 'Skill',
373
- ]);
374
-
375
- function applyToolPermissionNarrowing(tools, toolPermission, warnRole = null) {
376
- if (toolPermission === 'none') return [];
377
- const allowList = stringToolPermissionAllowList(toolPermission);
378
- if (allowList) {
379
- const allowSet = new Set(allowList.map((n) => String(n).toLowerCase()));
380
- return tools.filter((t) => allowSet.has(String(t?.name || '').toLowerCase()));
381
- }
382
- if (toolPermission && typeof toolPermission === 'object') {
383
- const allowSet = Array.isArray(toolPermission.allow) && toolPermission.allow.length > 0
384
- ? new Set(toolPermission.allow.map(n => String(n).toLowerCase()))
385
- : null;
386
- const denySet = Array.isArray(toolPermission.deny) && toolPermission.deny.length > 0
387
- ? new Set(toolPermission.deny.map(n => String(n).toLowerCase()))
388
- : null;
389
- if (allowSet || denySet) {
390
- const filtered = tools.filter(t => {
391
- const name = String(t?.name || '').toLowerCase();
392
- if (denySet && denySet.has(name)) return false;
393
- if (allowSet && !allowSet.has(name)) return false;
394
- return true;
395
- });
396
- if (filtered.length === 0) {
397
- process.stderr.write(`[session] WARN: role permission intersection produced 0 tools — failing closed (role=${warnRole || 'unknown'})\n`);
398
- }
399
- return filtered;
400
- }
401
- }
402
- return tools;
403
- }
404
-
405
- function recursiveWrapperToolNameForPublicAgent(agent) {
406
- if (!agent) return null;
407
- const key = String(agent).trim();
408
- if (key === 'explore') return 'explore';
409
- for (const hiddenName of listHiddenAgentNames()) {
410
- const def = getHiddenAgent(hiddenName);
411
- const invokedBy = typeof def?.invokedBy === 'string' ? def.invokedBy.trim() : '';
412
- if (hiddenName === key && invokedBy) return invokedBy;
413
- if (invokedBy && invokedBy === key) return invokedBy;
414
- }
415
- return null;
416
- }
417
-
418
- function finalizeSessionToolList(tools, {
419
- schemaAllowedTools = null,
420
- disallowedTools = null,
421
- ownerIsAgent = false,
422
- resolvedAgent = null,
423
- } = {}) {
424
- let out = Array.isArray(tools) ? tools : [];
425
- const hasCallerAllow = Array.isArray(schemaAllowedTools);
426
- if (hasCallerAllow) {
427
- const allowSet = new Set(schemaAllowedTools.map(n => String(n).toLowerCase()));
428
- out = out.filter(t => allowSet.has(String(t?.name || '').toLowerCase()));
429
- }
430
- const callerDeny = Array.isArray(disallowedTools) ? disallowedTools.map(n => String(n)) : [];
431
- if (callerDeny.length) {
432
- const denySet = new Set(callerDeny.map(n => n.toLowerCase()));
433
- out = out.filter(t => !denySet.has(String(t?.name || '').toLowerCase()));
434
- }
435
- const recursiveDeny = ownerIsAgent ? recursiveWrapperToolNameForPublicAgent(resolvedAgent) : null;
436
- if (recursiveDeny) {
437
- const deny = recursiveDeny.toLowerCase();
438
- out = out.filter(t => String(t?.name || '').toLowerCase() !== deny);
439
- }
440
- if (ownerIsAgent) {
441
- out = out.filter(t => !t?.annotations?.agentHidden);
442
- out = orderSessionTools(out);
443
- }
444
- return out;
445
- }
446
-
447
- function orderSessionTools(tools) {
448
- return tools.map((tool, index) => ({ tool, index }))
449
- .sort((a, b) => {
450
- const ar = SESSION_ROUTE_TOOL_RANK.get(a.tool?.name) ?? 10_000;
451
- const br = SESSION_ROUTE_TOOL_RANK.get(b.tool?.name) ?? 10_000;
452
- if (ar !== br) return ar - br;
453
- return a.index - b.index;
454
- })
455
- .map((entry) => entry.tool);
456
- }
457
-
458
- const ALL_BUILTIN_SESSION_TOOLS = orderSessionTools(_dedupByName([
459
- ...BUILTIN_TOOLS,
460
- ...PATCH_TOOL_DEFS,
461
- ...CODE_GRAPH_TOOL_DEFS,
462
- ]));
463
-
464
- function resolveSessionTools(toolSpec, skills, { ownerIsAgentSession = false } = {}) {
465
- const mcp = _getMcpTools();
466
- // Agent sessions freeze the skill meta-tool into the schema
467
- // unconditionally — concrete skill resolution is cwd-scoped at tool-call
468
- // time (loop.mjs), so the schema bytes stay bit-identical across roles /
469
- // cwds and the provider cache shard does not fragment.
470
- const skillTools = buildSkillToolDefs(skills, { ownerIsAgentSession });
471
- return _computeBaseTools(toolSpec, mcp, skillTools);
472
- }
473
-
474
- export function previewSessionTools(toolSpec, skills = [], options = {}) {
475
- return resolveSessionTools(toolSpec, skills, options);
476
- }
477
-
478
- // Dedup by name, first occurrence wins. BUILTIN_TOOLS is passed in ahead
479
- // of the MCP-registered internal tools so plugin-side definitions take
480
- // precedence when both surfaces declare the same name (e.g. read / grep / glob).
481
- // Without this merge, Anthropic rejected the request with
482
- // "tools: Tool names must be unique" and the orchestrator burned up to
483
- // 20 iterations retrying before the final answer landed.
484
- function _dedupByName(tools) {
485
- const seen = new Map();
486
- for (const t of tools) {
487
- const n = t?.name;
488
- if (!n || seen.has(n)) continue;
489
- seen.set(n, t);
490
- }
491
- return [...seen.values()];
492
- }
493
-
494
- // Agent visibility is declared per-tool via annotations.agentHidden.
495
- // Tools with agentHidden:true are stripped from agent sessions at schema
496
- // build time (see deny filtering below). No code-level name list needed.
497
-
498
- function _computeBaseTools(toolSpec, mcp, skillTools) {
499
- if (Array.isArray(toolSpec)) {
500
- if (toolSpec.length === 0) {
501
- // Explicit "no tools" — skill meta tools still travel so the model
502
- // can at least discover and invoke skills if that is the one
503
- // dynamic surface the profile retains.
504
- return _dedupByName([...skillTools]);
505
- }
506
- if (toolSpec.includes('full')) {
507
- return _dedupByName([...ALL_BUILTIN_SESSION_TOOLS, ...mcp, ...skillTools]);
508
- }
509
- const byName = new Map();
510
- const add = (tool) => { if (tool?.name && !byName.has(tool.name)) byName.set(tool.name, tool); };
511
- const addMany = (arr) => { for (const t of arr) add(t); };
512
- for (const tagRaw of toolSpec) {
513
- const tag = String(tagRaw || '').trim();
514
- switch (tag) {
515
- case 'tools:filesystem':
516
- addMany(ALL_BUILTIN_SESSION_TOOLS.filter(t => FILESYSTEM_TOOL_NAMES.has(t.name)));
517
- break;
518
- case 'tools:readonly':
519
- addMany(ALL_BUILTIN_SESSION_TOOLS.filter(t => READONLY_TOOL_NAMES.has(t.name)));
520
- break;
521
- case 'tools:shell':
522
- case 'tools:git':
523
- case 'tools:analysis':
524
- // Shell-class toolset. `tools:git` / `tools:analysis` exist so
525
- // profile authors can name the intent (git workflows / data
526
- // analysis) without inventing new toolset ids.
527
- addMany(ALL_BUILTIN_SESSION_TOOLS.filter(t => t.name === 'shell' || t.name === 'task'));
528
- break;
529
- case 'tools:mcp':
530
- addMany(mcp);
531
- break;
532
- case 'tools:search':
533
- // Name-pattern match: picks up `search` and any future tool
534
- // whose name contains `search`. `recall` and `explore` deliberately do NOT match
535
- // — they need `tools:mcp` (full mcp surface) or their own
536
- // toolset id if a role wants targeted retrieval. Public agent
537
- // roles never reach the wrapper bodies regardless: see the
538
- // isBlockedPublicWrapperCall guard in session/loop.mjs.
539
- addMany(mcp.filter(t => /search/i.test(t?.name || '')));
540
- break;
541
- default:
542
- process.stderr.write(`[session] unknown toolset id "${tag}" (profile.tools); skipping\n`);
543
- }
544
- }
545
- return _dedupByName([...byName.values(), ...skillTools]);
546
- }
547
-
548
- switch (toolSpec) {
549
- case 'mcp':
550
- return _dedupByName([...mcp, ...skillTools]);
551
- case 'readonly': {
552
- const readTools = ALL_BUILTIN_SESSION_TOOLS.filter(t => READONLY_TOOL_NAMES.has(t.name));
553
- return _dedupByName([...readTools, ...mcp, ...skillTools]);
554
- }
555
- case 'full':
556
- default:
557
- return _dedupByName([...ALL_BUILTIN_SESSION_TOOLS, ...mcp, ...skillTools]);
558
- }
559
- }
560
-
561
- function permissionFromToolSpec(toolSpec) {
562
- if (toolSpec === 'readonly') return 'read';
563
- if (toolSpec === 'mcp') return 'mcp';
564
- if (Array.isArray(toolSpec)) {
565
- const tags = new Set(toolSpec.map(t => String(t || '').trim()));
566
- const hasWriteOrShell = tags.has('full')
567
- || tags.has('tools:filesystem')
568
- || tags.has('tools:shell')
569
- || tags.has('tools:git')
570
- || tags.has('tools:analysis');
571
- if (tags.has('tools:readonly') && !hasWriteOrShell) return 'read';
572
- }
573
- return null;
574
- }
575
-
576
253
  let nextId = Date.now();
577
- // Known context windows for the current-generation models this plugin
578
- // routes to. Anything not listed falls through to guessContextWindow() —
579
- // local llama/mistral/phi default to 8192, everything else 128000. Keep
580
- // this map trimmed to live models; older generations slow down reads
581
- // without buying anything.
582
- const CONTEXT_WINDOWS = {
583
- // OpenAI GPT-5.x family (openai / openai-oauth)
584
- 'gpt-5.5': 272000,
585
- 'gpt-5.4': 272000,
586
- 'gpt-5.4-mini': 272000,
587
- 'gpt-5.4-nano': 272000,
588
- // Anthropic Claude 4.x
589
- 'claude-opus-4-8': 1000000,
590
- 'claude-opus-4-7': 1000000,
591
- 'claude-sonnet-4-6': 1000000,
592
- 'claude-haiku-4-5-20251001': 200000,
593
- // Google Gemini 3.x
594
- 'gemini-3.1-pro': 1000000,
595
- 'gemini-3-pro': 1000000,
596
- 'gemini-3.5-flash': 1000000,
597
- 'gemini-3-flash': 1000000,
598
- // xAI Grok (catalog polyfill mirror — model-catalog PRICING_OVERRIDES)
599
- 'grok-build-0.1': 256000,
600
- 'grok-4.20': 1000000,
601
- };
602
- // Family-pattern fallback used only when both the provider catalog and the
603
- // exact-id table miss (cold metadata, before the LiteLLM/models.dev catalog
604
- // warms). Keep these aligned with the catalog so /context, gateway, and the
605
- // runtime agree on the boundary the first time a model is routed. Local models
606
- // (llama/mistral/phi/qwen/gemma) stay small so an unknown local id never claims
607
- // a giant window.
608
- function guessContextWindow(model) {
609
- if (CONTEXT_WINDOWS[model])
610
- return CONTEXT_WINDOWS[model];
611
- const m = String(model || '').toLowerCase();
612
- // Local/self-hosted families — never inflate an unknown local id.
613
- if (m.includes('llama') || m.includes('mistral') || m.includes('mixtral')
614
- || m.includes('phi') || m.includes('qwen') || m.includes('gemma')
615
- || m.includes('deepseek-r1') || m.includes('codellama'))
616
- return 8192;
617
- // Current hosted families by name pattern.
618
- if (m.startsWith('claude-opus') || m.startsWith('claude-sonnet')) return 1000000;
619
- if (m.startsWith('claude-haiku') || m.startsWith('claude-')) return 200000;
620
- if (m.startsWith('gemini-3') || m.startsWith('gemini-2')) return 1000000;
621
- if (m.startsWith('gpt-5')) return 272000;
622
- if (m.startsWith('grok-build')) return 256000;
623
- if (m.startsWith('grok-')) return 1000000;
624
- if (m.startsWith('deepseek-v')) return 1000000;
625
- return 128000;
626
- }
627
- function positiveContextWindow(value) {
628
- const n = Number(value);
629
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
630
- }
631
- function envFlag(name, fallback = false) {
632
- const v = process.env[name];
633
- if (v === undefined) return fallback;
634
- return !['0', 'false', 'off', 'no'].includes(String(v).trim().toLowerCase());
635
- }
636
- function boundedPercent(value, fallback = null) {
637
- const n = Number(value);
638
- if (Number.isFinite(n) && n > 0 && n <= 100) return n;
639
- return fallback;
640
- }
641
- function providerNameOf(provider) {
642
- if (typeof provider === 'string') return provider.toLowerCase();
643
- return String(provider?.name || provider?.id || '').toLowerCase();
644
- }
645
- // Carry the percent/ratio-named buffer config from a compaction config object
646
- // onto session.compaction so the shared compact-policy parser honors configured
647
- // buffer
648
- // percent/ratio. Only finite positive values are copied; absent fields stay
649
- // undefined so the default-ratio fallback still applies.
650
- function preserveBufferConfigFields(cfg = {}) {
651
- const out = {};
652
- for (const key of ['bufferPercent', 'bufferPct', 'bufferRatio', 'bufferFraction']) {
653
- const n = Number(cfg?.[key]);
654
- if (Number.isFinite(n) && n > 0) out[key] = n;
655
- }
656
- return out;
657
- }
658
- const COMPACT_TARGET_RATIO = 0.02;
659
- const COMPACT_TARGET_MIN_TOKENS = 4_000;
660
- const COMPACT_TARGET_MAX_TOKENS = 16_000;
661
- function compactTargetRatio() {
662
- const raw = process.env.MIXDOG_AGENT_COMPACT_TARGET_PERCENT
663
- ?? process.env.MIXDOG_COMPACT_TARGET_PERCENT
664
- ?? COMPACT_TARGET_RATIO;
665
- const n = Number(raw);
666
- if (!Number.isFinite(n) || n <= 0) return COMPACT_TARGET_RATIO;
667
- return n > 1 ? n / 100 : n;
668
- }
669
- function compactTargetTokensForBoundary(boundaryTokens) {
670
- const boundary = positiveContextWindow(boundaryTokens);
671
- if (!boundary) return null;
672
- const explicit = positiveContextWindow(
673
- process.env.MIXDOG_AGENT_COMPACT_TARGET_TOKENS
674
- ?? process.env.MIXDOG_COMPACT_TARGET_TOKENS,
675
- );
676
- if (explicit) return Math.max(1, Math.min(boundary, explicit));
677
- const minTarget = Math.min(boundary, positiveContextWindow(process.env.MIXDOG_COMPACT_TARGET_MIN_TOKENS) || COMPACT_TARGET_MIN_TOKENS);
678
- const maxTarget = Math.min(boundary, positiveContextWindow(process.env.MIXDOG_COMPACT_TARGET_MAX_TOKENS) || COMPACT_TARGET_MAX_TOKENS);
679
- const byRatio = Math.max(1, Math.floor(boundary * compactTargetRatio()));
680
- return Math.max(1, Math.min(boundary, maxTarget, Math.max(minTarget, byRatio)));
681
- }
682
- function defaultEffectiveContextWindowPercent(provider) {
683
- // Gateway/statusline route metadata reserves a universal 10% headroom from
684
- // the raw catalog window. Keep session compaction on the same effective
685
- // capacity so /context, the TUI statusline, and gateway telemetry agree.
686
- return 90;
687
- }
688
- const PROVIDER_SYNTHETIC_CONTEXT_DEFAULT = 1_000_000;
689
- function providerRawContextWindow(info, catalogInfo) {
690
- if (!info || typeof info !== 'object') return null;
691
- const fromApiFields = positiveContextWindow(info.context_window)
692
- || positiveContextWindow(info.max_context_window);
693
- if (fromApiFields) return fromApiFields;
694
- const fromCache = positiveContextWindow(info.contextWindow)
695
- || positiveContextWindow(info.maxContextWindow);
696
- if (!fromCache) return null;
697
- const catalogWindow = positiveContextWindow(catalogInfo?.contextWindow)
698
- || positiveContextWindow(catalogInfo?.maxContextWindow)
699
- || positiveContextWindow(catalogInfo?.context_window)
700
- || positiveContextWindow(catalogInfo?.max_context_window);
701
- if (fromCache === PROVIDER_SYNTHETIC_CONTEXT_DEFAULT
702
- && catalogWindow
703
- && fromCache !== catalogWindow) {
704
- return null;
705
- }
706
- return fromCache;
707
- }
708
- function resolveSessionContextMeta(provider, model, seed = {}) {
709
- const info = typeof provider?.getCachedModelInfo === 'function'
710
- ? provider.getCachedModelInfo(model)
711
- : null;
712
- const catalogInfo = getModelMetadataSync(model, providerNameOf(provider));
713
- const rawContextWindow = providerRawContextWindow(info, catalogInfo)
714
- || positiveContextWindow(catalogInfo?.contextWindow)
715
- || positiveContextWindow(catalogInfo?.maxContextWindow)
716
- || positiveContextWindow(catalogInfo?.context_window)
717
- || positiveContextWindow(catalogInfo?.max_context_window)
718
- || positiveContextWindow(seed.rawContextWindow)
719
- || positiveContextWindow(seed.raw_context_window)
720
- || positiveContextWindow(seed.contextWindow)
721
- || guessContextWindow(model);
722
- const effectiveContextWindowPercent = boundedPercent(
723
- seed.effectiveContextWindowPercent
724
- ?? seed.effective_context_window_percent
725
- ?? info?.effectiveContextWindowPercent
726
- ?? info?.effective_context_window_percent
727
- ?? catalogInfo?.effectiveContextWindowPercent
728
- ?? catalogInfo?.effective_context_window_percent,
729
- defaultEffectiveContextWindowPercent(provider),
730
- );
731
- const pct = boundedPercent(effectiveContextWindowPercent, 100);
732
- const contextWindow = Math.max(1, Math.floor(rawContextWindow * pct / 100));
733
- const compactBoundaryTokens = contextWindow;
734
- const rawCompactLimit = positiveContextWindow(
735
- seed.autoCompactTokenLimit
736
- ?? seed.auto_compact_token_limit
737
- ?? info?.autoCompactTokenLimit
738
- ?? info?.auto_compact_token_limit
739
- ?? catalogInfo?.autoCompactTokenLimit
740
- ?? catalogInfo?.auto_compact_token_limit,
741
- );
742
- // Legacy-data migration: old implementations derived autoCompactTokenLimit
743
- // from the full effective/raw window and persisted it onto the session.
744
- // A resumed session therefore re-seeds autoCompactTokenLimit == boundary
745
- // (or the raw window), which compactTriggerForSession / loop policy used to
746
- // honor as an explicit trigger, collapsing the compaction buffer to 0. Only
747
- // accept an explicit limit that is STRICTLY BELOW the boundary; a value at
748
- // or above the boundary is a derived full-window artifact and is dropped to
749
- // null so the trigger falls back to the default boundary trigger.
750
- const explicitCompactLimit = rawCompactLimit && rawCompactLimit < compactBoundaryTokens
751
- ? rawCompactLimit
752
- : null;
753
- // Do NOT derive the auto-compact limit from the full effective window.
754
- // Setting it to contextWindow makes autoTriggerTokens == boundary and the
755
- // compaction buffer collapse to 0 (loop.mjs:708-713 / compactTriggerForSession),
756
- // so auto-compact only fires when the context is already at the limit —
757
- // at which point semantic compact fails ("result exceeds budget" /
758
- // "summary cannot fit") and the turn can no longer be resumed.
759
- // Leave it null unless the provider/catalog/seed supplies an explicit
760
- // limit; the downstream buffer logic (default 10%, capped 25%) then
761
- // triggers compaction with headroom, matching the reference auto-compact threshold.
762
- const autoCompactTokenLimit = explicitCompactLimit || null;
763
- return {
764
- contextWindow,
765
- rawContextWindow,
766
- effectiveContextWindowPercent,
767
- autoCompactTokenLimit: autoCompactTokenLimit || null,
768
- compactBoundaryTokens,
769
- };
770
- }
771
- function compactTriggerForSession(session, boundaryTokens) {
772
- return resolveCompactTriggerTokens(session, boundaryTokens);
773
- }
774
254
  // Test-only exports for the legacy auto-compact-limit migration + buffer-config
775
255
  // preservation (see scripts/compact-trigger-migration-smoke.mjs).
776
256
  export const _resolveSessionContextMeta = resolveSessionContextMeta;
777
257
  export const _compactTriggerForSession = compactTriggerForSession;
778
258
  export const _preserveBufferConfigFields = preserveBufferConfigFields;
779
- // 'compacting' is a transient in-flight stage written just before semantic /
780
- // recall-fasttrack compaction runs. If the process crashes or only partially
781
- // saves while it is set, a later load/resume reads a session that is NOT
782
- // actually compacting but whose UI marker (App.jsx / ContextPanel) shows
783
- // "Compacting conversation" permanently. Normalize that stale transient stage
784
- // to 'interrupted' so the surface recovers. Terminal stages (post_turn /
785
- // manual / auto_clear / *_failed / overflow_failed) are intentionally left as
786
- // the durable record of the last real outcome.
787
- function normalizeStaleCompactingStage(session) {
788
- const c = session?.compaction;
789
- if (!c || typeof c !== 'object') return false;
790
- if (c.lastStage !== 'compacting' && c.inProgress !== true) return false;
791
- c.lastStage = 'interrupted';
792
- c.inProgress = false;
793
- c.lastCheckedAt = Date.now();
794
- return true;
795
- }
796
- function compactTargetBudget(boundaryTokens, reserveTokens, _sourceTokens = null, _ratio = null) {
797
- const boundary = positiveContextWindow(boundaryTokens);
798
- if (!boundary) return null;
799
- const reserve = Math.max(0, Number(reserveTokens) || 0);
800
- const targetEffective = compactTargetTokensForBoundary(boundary) || boundary;
801
- return Math.max(1, Math.min(boundary, targetEffective + reserve));
802
- }
803
- function semanticCompactionEnabledForSession(session) {
804
- const cfg = session?.compaction || {};
805
- if (process.env.MIXDOG_AGENT_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_AGENT_COMPACT_SEMANTIC', true);
806
- if (process.env.MIXDOG_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_COMPACT_SEMANTIC', true);
807
- if (cfg.semantic === false || cfg.semantic === 'false' || cfg.semantic === 'off') return false;
808
- if (cfg.semantic === true || cfg.semantic === 'true' || cfg.semantic === 'on' || cfg.semantic === 'auto') return true;
809
- return true;
810
- }
811
- function compactTypeForSession(session) {
812
- const cfg = session?.compaction || {};
813
- const configured = process.env.MIXDOG_AGENT_COMPACT_TYPE
814
- ?? process.env.MIXDOG_COMPACT_TYPE
815
- ?? cfg.type
816
- ?? cfg.compactType
817
- ?? cfg.compact_type;
818
- return normalizeCompactType(configured, DEFAULT_COMPACT_TYPE);
819
- }
820
- function addCompactUsageToSession(session, usage) {
821
- if (!session || !usage) return;
822
- const inputTokens = usage.inputTokens || 0;
823
- const outputTokens = usage.outputTokens || 0;
824
- const cachedTokens = usage.cachedTokens || 0;
825
- const cacheWriteTokens = usage.cacheWriteTokens || 0;
826
- const uncachedInputTokens = uncachedInputTokensForProvider(session.provider, inputTokens, cachedTokens, cacheWriteTokens);
827
- session.totalInputTokens = (session.totalInputTokens || 0) + inputTokens;
828
- session.totalOutputTokens = (session.totalOutputTokens || 0) + outputTokens;
829
- session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + cachedTokens;
830
- session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + cacheWriteTokens;
831
- session.totalUncachedInputTokens = (session.totalUncachedInputTokens || 0) + uncachedInputTokens;
832
- session.tokensCumulative = (session.tokensCumulative || 0) + inputTokens + outputTokens;
833
- }
834
- async function runRecallFastTrackForSession(session, messages, opts = {}) {
835
- const sessionId = opts.sessionId || session?.id || null;
836
- if (!sessionId) throw new Error('recall-fasttrack requires a session id');
837
- const query = `session:${sessionId}:all-chunks`;
838
- const querySha = createHash('sha256').update(query).digest('hex').slice(0, 16);
839
- const callerCtx = {
840
- callerSessionId: sessionId,
841
- callerCwd: session?.cwd || undefined,
842
- routingSessionId: sessionId,
843
- clientHostPid: session?.clientHostPid,
844
- signal: opts.signal || null,
845
- };
846
- const hydrateLimit = positiveContextWindow(session?.compaction?.recallIngestLimit)
847
- || Math.max(500, Math.min(5000, messages.length || 0));
848
- try {
849
- await executeInternalTool('memory', {
850
- action: 'ingest_session',
851
- sessionId,
852
- messages,
853
- cwd: session?.cwd,
854
- limit: hydrateLimit,
855
- }, callerCtx);
856
- } catch (err) {
857
- try { process.stderr.write(`[session] recall-fasttrack ingest skipped (sess=${sessionId}): ${err?.message || err}\n`); } catch {}
858
- }
859
- const dumpArgs = {
860
- action: 'dump_session_roots',
861
- sessionId,
862
- includeRaw: true,
863
- limit: positiveContextWindow(session?.compaction?.recallChunkLimit ?? session?.compaction?.recallLimit) || hydrateLimit,
864
- };
865
- const runTool = (name, args) => executeInternalTool(name, args, callerCtx);
866
- let recallText = await executeInternalTool('memory', dumpArgs, callerCtx);
867
- let cycle1Text = '';
868
- const hasRawRows = /(?:^|\n)# raw_pending\s+\d+\s+id=/i.test(String(recallText || ''));
869
- if (hasRawRows) {
870
- try {
871
- // Drain this session's cycle1 in window×concurrency units until no
872
- // raw rows remain, so the injected root is fully chunked rather than
873
- // carrying the unprocessed transcript tail (single-pass left raw in).
874
- const drained = await drainSessionCycle1(runTool, {
875
- sessionId,
876
- dumpArgs,
877
- deadlineMs: positiveContextWindow(session?.compaction?.recallCycle1DeadlineMs) || 120_000,
878
- maxPasses: positiveContextWindow(session?.compaction?.recallCycle1MaxPasses) || 0,
879
- cycleArgs: {
880
- min_batch: 1,
881
- session_cap: 1,
882
- batch_size: positiveContextWindow(session?.compaction?.recallCycle1BatchSize) || 100,
883
- rows_per_session: positiveContextWindow(session?.compaction?.recallRowsPerSession) || 100,
884
- window_size: positiveContextWindow(session?.compaction?.recallWindowSize) || 20,
885
- concurrency: positiveContextWindow(session?.compaction?.recallConcurrency) || 5,
886
- },
887
- });
888
- recallText = drained.recallText;
889
- cycle1Text = drained.cycle1Text;
890
- if (drained.rawRemaining > 0) {
891
- try { process.stderr.write(`[session] recall-fasttrack drained passes=${drained.passes} rawRemaining=${drained.rawRemaining} (sess=${sessionId})\n`); } catch {}
892
- }
893
- } catch (err) {
894
- try { process.stderr.write(`[session] recall-fasttrack cycle1 skipped (sess=${sessionId}): ${err?.message || err}\n`); } catch {}
895
- }
896
- } else {
897
- cycle1Text = 'cycle1: skipped (session chunks already hydrated)';
898
- }
899
- return { query, querySha, recallText: [`session_id=${sessionId}`, cycle1Text, recallText].map(v => String(v || '').trim()).filter(Boolean).join('\n\n') };
900
- }
901
- // Element-identity change detection (mirrors loop.mjs messagesArrayChanged): two
902
- // arrays are "unchanged" only when same length AND every slot is the same object
903
- // reference. Used to reject a no-op prune (which returns a fresh array whose
904
- // elements are the untouched originals) from being accepted as a recovery.
905
- function messagesChanged(before, after) {
906
- if (!Array.isArray(before) || !Array.isArray(after)) return before !== after;
907
- if (before.length !== after.length) return true;
908
- for (let i = 0; i < before.length; i += 1) {
909
- if (before[i] !== after[i]) return true;
910
- }
911
- return false;
912
- }
913
- async function runSessionCompaction(session, opts = {}) {
914
- if (!session || session.closed === true) return null;
915
- const mode = opts.mode === 'auto' ? 'auto' : 'manual';
916
- const force = opts.force === true || mode === 'manual';
917
- if (mode === 'auto' && session.compaction?.auto === false) return null;
918
- const messages = Array.isArray(session.messages) ? session.messages : [];
919
- if (messages.length < 3 && !force) return null;
920
- const boundary = positiveContextWindow(session.compactBoundaryTokens)
921
- || positiveContextWindow(session.autoCompactTokenLimit)
922
- || positiveContextWindow(session.contextWindow);
923
- if (!boundary) {
924
- if (force) throw new Error('compact: no context window is available for this session');
925
- return null;
926
- }
927
- // Reserve must mirror loop.mjs (buildCompactPolicy): request reserve (tool
928
- // schema) PLUS the configured reserve (session.compaction.reservedTokens or
929
- // MIXDOG_AGENT_COMPACT_RESERVED_TOKENS env). The old request-only value left
930
- // the manual / auto-clear compact budget without the configured headroom the
931
- // loop path reserves, so a compacted transcript could overflow on next send.
932
- const requestReserveTokens = estimateRequestReserveTokens(session.tools || []);
933
- const configuredReserveTokens = positiveContextWindow(session.compaction?.reservedTokens)
934
- || positiveContextWindow(process.env.MIXDOG_AGENT_COMPACT_RESERVED_TOKENS)
935
- || 0;
936
- const reserveTokens = requestReserveTokens + configuredReserveTokens;
937
- const beforeMessageTokens = estimateMessagesTokens(messages);
938
- const triggerTokens = compactTriggerForSession(session, boundary)
939
- || positiveContextWindow(session.compaction?.triggerTokens)
940
- || boundary;
941
- const bufferTokens = Math.max(0, boundary - triggerTokens);
942
- const bufferRatio = boundary ? (bufferTokens / boundary) : compactBufferRatioForSession(session);
943
- const pressureTokens = estimateTranscriptContextUsage(messages, session.tools || []);
944
- const beforeTokens = pressureTokens;
945
- const compactType = compactTypeForSession(session);
946
- if (!force && pressureTokens < triggerTokens) return {
947
- changed: false,
948
- reason: 'below threshold',
949
- compactType,
950
- beforeMessages: messages.length,
951
- afterMessages: messages.length,
952
- beforeTokens,
953
- afterTokens: beforeTokens,
954
- beforeMessageTokens,
955
- afterMessageTokens: beforeMessageTokens,
956
- pressureTokens,
957
- triggerTokens,
958
- bufferTokens,
959
- bufferRatio,
960
- boundaryTokens: boundary,
961
- budgetTokens: boundary,
962
- targetBudgetTokens: boundary,
963
- reserveTokens,
964
- semanticCompact: false,
965
- };
966
- const budgetSourceTokens = force ? Math.max(pressureTokens, triggerTokens) : pressureTokens;
967
- const compactBudget = compactTargetBudget(boundary, reserveTokens, budgetSourceTokens);
968
- const budget = compactBudget || boundary;
969
- try { opts.onStageChange?.('compacting'); } catch { /* best-effort */ }
970
- const provider = opts.provider || getProvider(session.provider) || null;
971
- let compacted;
972
- let compactError = null;
973
- let semanticCompactResult = null;
974
- let semanticCompactError = null;
975
- let recallFastTrackResult = null;
976
- let recallFastTrackError = null;
977
- if (compactTypeIsRecallFastTrack(compactType)) {
978
- try {
979
- const recallPayload = await runRecallFastTrackForSession(session, messages, opts);
980
- recallFastTrackResult = recallFastTrackCompactMessages(messages, budget, {
981
- reserveTokens,
982
- force: true,
983
- recallText: recallPayload.recallText,
984
- query: recallPayload.query,
985
- querySha: recallPayload.querySha,
986
- // Ingest just ran on the live transcript, so an empty recall dump
987
- // means the memory pipeline is broken — do NOT erase history
988
- // behind an empty summary shell. Empty recall now throws and is
989
- // handled by the semantic fallback below (or recorded failure).
990
- allowEmptyRecall: false,
991
- tailTurns: positiveContextWindow(session.compaction?.tailTurns) || 2,
992
- keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens),
993
- preserveRecentTokens: positiveContextWindow(session.compaction?.preserveRecentTokens),
994
- });
995
- if (Array.isArray(recallFastTrackResult?.messages)) {
996
- compacted = recallFastTrackResult.messages;
997
- }
998
- } catch (err) {
999
- recallFastTrackError = err;
1000
- compactError = err;
1001
- try {
1002
- process.stderr.write(`[session] recall-fasttrack ${mode} compact failed (sess=${session.id || 'unknown'}): ${err?.message || err}\n`);
1003
- } catch { /* best-effort */ }
1004
- // Degraded-compact fallback: recall-fasttrack failed (empty recall,
1005
- // ingest error, fit failure). Before recording a hard failure, try
1006
- // the semantic path once so auto-clear/manual compaction still makes
1007
- // progress WITHOUT shipping an empty-recall summary. History is only
1008
- // replaced when the semantic summary actually succeeds.
1009
- if (semanticCompactionEnabledForSession(session)
1010
- && provider && typeof provider.send === 'function') {
1011
- try {
1012
- semanticCompactResult = await semanticCompactMessages(
1013
- provider,
1014
- messages,
1015
- opts.model || session.model,
1016
- budget,
1017
- {
1018
- reserveTokens,
1019
- providerName: session.provider || provider?.name || null,
1020
- sessionId: opts.sessionId || session.id || null,
1021
- signal: opts.signal || null,
1022
- promptCacheKey: session.promptCacheKey || null,
1023
- providerCacheKey: session.promptCacheKey || null,
1024
- timeoutMs: positiveContextWindow(session.compaction?.timeoutMs) || 30_000,
1025
- tailTurns: positiveContextWindow(session.compaction?.tailTurns) || 2,
1026
- keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens),
1027
- preserveRecentTokens: positiveContextWindow(session.compaction?.preserveRecentTokens),
1028
- force: true,
1029
- },
1030
- );
1031
- if (Array.isArray(semanticCompactResult?.messages)) {
1032
- compacted = semanticCompactResult.messages;
1033
- compactError = null;
1034
- addCompactUsageToSession(session, semanticCompactResult.usage);
1035
- try {
1036
- process.stderr.write(`[session] degraded compact: recall-fasttrack failed, semantic fallback succeeded (sess=${session.id || 'unknown'}, mode=${mode})\n`);
1037
- } catch { /* best-effort */ }
1038
- }
1039
- } catch (fallbackErr) {
1040
- semanticCompactError = fallbackErr;
1041
- try {
1042
- process.stderr.write(`[session] degraded compact: semantic fallback also failed (sess=${session.id || 'unknown'}): ${fallbackErr?.message || fallbackErr}\n`);
1043
- } catch { /* best-effort */ }
1044
- }
1045
- }
1046
- }
1047
- } else if (compactTypeIsSemantic(compactType)) {
1048
- try {
1049
- if (!semanticCompactionEnabledForSession(session)) {
1050
- throw new Error('semantic compact is disabled for this session');
1051
- }
1052
- if (!provider || typeof provider.send !== 'function') {
1053
- throw new Error(`semantic compact provider unavailable: ${session.provider || 'unknown'}`);
1054
- }
1055
- semanticCompactResult = await semanticCompactMessages(
1056
- provider,
1057
- messages,
1058
- opts.model || session.model,
1059
- budget,
1060
- {
1061
- reserveTokens,
1062
- providerName: session.provider || provider?.name || null,
1063
- sessionId: opts.sessionId || session.id || null,
1064
- signal: opts.signal || null,
1065
- promptCacheKey: session.promptCacheKey || null,
1066
- providerCacheKey: session.promptCacheKey || null,
1067
- timeoutMs: positiveContextWindow(session.compaction?.timeoutMs) || 30_000,
1068
- tailTurns: positiveContextWindow(session.compaction?.tailTurns) || 2,
1069
- keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens),
1070
- preserveRecentTokens: positiveContextWindow(session.compaction?.preserveRecentTokens),
1071
- force: true,
1072
- },
1073
- );
1074
- if (Array.isArray(semanticCompactResult?.messages)) {
1075
- compacted = semanticCompactResult.messages;
1076
- addCompactUsageToSession(session, semanticCompactResult.usage);
1077
- }
1078
- } catch (err) {
1079
- semanticCompactError = err;
1080
- compactError = err;
1081
- try {
1082
- process.stderr.write(`[session] semantic ${mode} compact failed (sess=${session.id || 'unknown'}): ${err?.message || err}\n`);
1083
- } catch { /* best-effort */ }
1084
- }
1085
- }
1086
- if (!compacted && !compactError) {
1087
- compactError = new Error(`${compactType} compact produced no messages`);
1088
- }
1089
- // Anchor-independent prune safety net (mirror loop.mjs compact catch): when a
1090
- // non-recall (semantic) compact failed, try one non-LLM prune that needs no
1091
- // user anchor before recording failure, so Lead manual / auto-clear paths
1092
- // recover the same transcripts the loop path does. Gated off the recall
1093
- // path — a recall failure keeps its original contract (no silent prune).
1094
- if (!compacted && !recallFastTrackError) {
1095
- try {
1096
- const acceptThreshold = compactEffectiveBudget(budget, { reserveTokens });
1097
- const salvaged = pruneToolOutputsUnanchored(messages, budget, { reserveTokens });
1098
- // pruneToolOutputsUnanchored ALWAYS returns a fresh reconciled array
1099
- // (never the input identity), so `salvaged !== messages` is always
1100
- // true and cannot detect a no-op. Compare by element identity so a
1101
- // transcript that already fit (nothing pruned) is NOT falsely accepted
1102
- // as a recovery — that would clear compactError and unconditionally
1103
- // invalidate providerState for an unchanged transcript.
1104
- if (Array.isArray(salvaged)
1105
- && messagesChanged(messages, salvaged)
1106
- && estimateMessagesTokens(salvaged) <= acceptThreshold) {
1107
- compacted = salvaged;
1108
- compactError = null;
1109
- try {
1110
- process.stderr.write(`[session] compact fallback prune recovered (sess=${session.id || 'unknown'}, mode=${mode})\n`);
1111
- } catch { /* best-effort */ }
1112
- }
1113
- } catch { /* fall through to failure record */ }
1114
- }
1115
- if (!compacted) {
1116
- const now = Date.now();
1117
- session.compaction = {
1118
- ...(session.compaction || {}),
1119
- auto: mode === 'auto' ? true : session.compaction?.auto !== false,
1120
- boundaryTokens: boundary,
1121
- triggerTokens,
1122
- bufferTokens,
1123
- bufferRatio,
1124
- reserveTokens,
1125
- lastStage: mode === 'auto' ? 'post_turn_failed' : 'manual_failed',
1126
- lastBeforeTokens: beforeTokens,
1127
- lastAfterTokens: beforeTokens,
1128
- lastBeforeMessageTokens: beforeMessageTokens,
1129
- lastAfterMessageTokens: beforeMessageTokens,
1130
- lastPressureTokens: pressureTokens,
1131
- lastCheckedAt: now,
1132
- lastChanged: false,
1133
- type: compactType,
1134
- compactType,
1135
- lastCompactType: compactType,
1136
- lastSemantic: false,
1137
- lastSemanticError: semanticCompactError?.message || null,
1138
- lastRecallFastTrack: false,
1139
- lastRecallFastTrackError: recallFastTrackError?.message || null,
1140
- lastError: compactError?.message || semanticCompactError?.message || recallFastTrackError?.message || String(compactError || semanticCompactError || recallFastTrackError || 'compact failed'),
1141
- };
1142
- return {
1143
- changed: false,
1144
- error: session.compaction.lastError,
1145
- compactType,
1146
- beforeMessages: messages.length,
1147
- afterMessages: messages.length,
1148
- beforeTokens,
1149
- afterTokens: beforeTokens,
1150
- beforeMessageTokens,
1151
- afterMessageTokens: beforeMessageTokens,
1152
- pressureTokens,
1153
- triggerTokens,
1154
- bufferTokens,
1155
- bufferRatio,
1156
- boundaryTokens: boundary,
1157
- budgetTokens: boundary,
1158
- targetBudgetTokens: budget,
1159
- reserveTokens,
1160
- semanticCompact: false,
1161
- semanticError: semanticCompactError?.message || null,
1162
- recallFastTrack: false,
1163
- recallFastTrackError: recallFastTrackError?.message || null,
1164
- };
1165
- }
1166
- let beforeEncoded = '';
1167
- let afterEncoded = '';
1168
- try { beforeEncoded = JSON.stringify(messages); } catch { beforeEncoded = ''; }
1169
- try { afterEncoded = JSON.stringify(compacted); } catch { afterEncoded = ''; }
1170
- const afterMessageTokens = estimateMessagesTokens(compacted);
1171
- const afterTokens = afterMessageTokens + reserveTokens;
1172
- const changed = beforeEncoded && afterEncoded
1173
- ? beforeEncoded !== afterEncoded
1174
- : (compacted.length !== messages.length || afterMessageTokens !== beforeMessageTokens);
1175
- const unchangedReason = changed ? null : (force ? 'nothing to compact' : 'below threshold');
1176
- const now = Date.now();
1177
- session.messages = compacted;
1178
- session.providerState = undefined;
1179
- session.compaction = {
1180
- ...(session.compaction || {}),
1181
- auto: mode === 'auto' ? true : session.compaction?.auto !== false,
1182
- boundaryTokens: boundary,
1183
- triggerTokens,
1184
- bufferTokens,
1185
- bufferRatio,
1186
- reserveTokens,
1187
- type: compactType,
1188
- compactType,
1189
- lastCompactType: compactType,
1190
- lastStage: mode === 'auto' ? 'post_turn' : 'manual',
1191
- lastBeforeTokens: beforeTokens,
1192
- lastAfterTokens: afterTokens,
1193
- lastBeforeMessageTokens: beforeMessageTokens,
1194
- lastAfterMessageTokens: afterMessageTokens,
1195
- lastPressureTokens: pressureTokens,
1196
- lastCheckedAt: now,
1197
- lastChanged: changed,
1198
- lastChangedAt: changed ? now : session.compaction?.lastChangedAt || null,
1199
- lastCompactAt: changed ? now : session.compaction?.lastCompactAt || null,
1200
- lastSemantic: semanticCompactResult?.semantic === true,
1201
- lastSemanticError: semanticCompactError?.message || null,
1202
- lastRecallFastTrack: recallFastTrackResult?.recallFastTrack === true,
1203
- lastRecallFastTrackError: recallFastTrackError?.message || null,
1204
- lastRecallFastTrackQuerySha: recallFastTrackResult?.query ? createHash('sha256').update(recallFastTrackResult.query).digest('hex').slice(0, 16) : null,
1205
- lastSemanticUsage: semanticCompactResult?.usage ? {
1206
- inputTokens: semanticCompactResult.usage.inputTokens || 0,
1207
- outputTokens: semanticCompactResult.usage.outputTokens || 0,
1208
- cachedTokens: semanticCompactResult.usage.cachedTokens || 0,
1209
- cacheWriteTokens: semanticCompactResult.usage.cacheWriteTokens || 0,
1210
- } : null,
1211
- compactCount: (session.compaction?.compactCount || 0) + (changed ? 1 : 0),
1212
- };
1213
- if (changed && mode === 'auto') session.lastContextTokensStaleAfterCompact = true;
1214
- return {
1215
- changed,
1216
- reason: unchangedReason,
1217
- compactType,
1218
- beforeMessages: messages.length,
1219
- afterMessages: compacted.length,
1220
- beforeTokens,
1221
- afterTokens,
1222
- beforeMessageTokens,
1223
- afterMessageTokens,
1224
- pressureTokens,
1225
- triggerTokens,
1226
- bufferTokens,
1227
- bufferRatio,
1228
- boundaryTokens: boundary,
1229
- budgetTokens: boundary,
1230
- targetBudgetTokens: budget,
1231
- reserveTokens,
1232
- semanticCompact: semanticCompactResult?.semantic === true,
1233
- semanticError: semanticCompactError?.message || null,
1234
- recallFastTrack: recallFastTrackResult?.recallFastTrack === true,
1235
- recallFastTrackError: recallFastTrackError?.message || null,
1236
- usage: semanticCompactResult?.usage || null,
1237
- };
1238
- }
1239
259
  // Provider-scoped unified cache key. Goal: all orchestrator-internal
1240
260
  // dispatches (agent/maintenance/mcp/scheduler/webhook) targeting the
1241
261
  // same provider land in a single server-side cache shard, so the
@@ -1579,6 +599,34 @@ export function createSession(opts) {
1579
599
  if (!provider)
1580
600
  throw new Error(`Provider "${providerName}" not found or not enabled`);
1581
601
  const id = `sess_${process.pid}_${nextId++}_${Date.now()}_${randomBytes(16).toString('hex')}`;
602
+ // Provider cache strategy — agentRuntime.resolveSync() above is a
603
+ // best-effort injection point (setAgentRuntime() has no live caller
604
+ // today, so that branch never fires); build it directly here so every
605
+ // session still gets a cache strategy. Lead sessions (opts.agent ===
606
+ // 'lead', or no agent at all — raw/CLI callers) get their BP4 message
607
+ // tail TTL linked to the user's autoClear idle-sweep config; hidden and
608
+ // public agents keep the flat 5m default (see cache-strategy.mjs docs).
609
+ // Scoped to explicit-breakpoint (Anthropic-family) providers only — the
610
+ // non-Anthropic branches of buildProviderCacheOpts (e.g. the 'openai'
611
+ // cacheRetention:'24h' shape) were never exercised by createSession
612
+ // before this change, and are left untouched to avoid altering live
613
+ // OpenAI/other-provider request shape as a side effect of this fix.
614
+ if (!providerCacheOpts && cacheCapabilityForProvider(providerName) === 'explicit-breakpoint') {
615
+ try {
616
+ let autoClear = null;
617
+ if (!opts.agent || opts.agent === 'lead') {
618
+ const loadedConfig = loadConfig({ secrets: false });
619
+ const normalizedAutoClear = normalizeAutoClearConfig(loadedConfig?.autoClear);
620
+ autoClear = {
621
+ ...normalizedAutoClear,
622
+ idleMs: resolveAutoClearIdleMs(loadedConfig, providerName),
623
+ };
624
+ }
625
+ providerCacheOpts = buildProviderCacheOpts(providerName, id, opts.agent, { autoClear });
626
+ } catch {
627
+ providerCacheOpts = null;
628
+ }
629
+ }
1582
630
  const messages = [];
1583
631
  const ownerIsAgent = isAgentOwner(opts.owner);
1584
632
  const resolvedAgent = opts.agent || opts.role || profile?.taskType || null;
@@ -1818,69 +866,11 @@ export function createSession(opts) {
1818
866
  return session;
1819
867
  }
1820
868
 
1821
- // ── Runtime liveness map ──────────────────────────────────────────────
1822
- // In-memory only. Tracks per-session stage + stream heartbeat so agent type=list
1823
- // can surface whether a session is actually alive vs stuck. Never persisted —
1824
- // heartbeats would otherwise churn the session JSON on every SSE delta.
1825
- // Entry shape: {
1826
- // stage, lastStreamDeltaAt, lastToolCall, lastError, updatedAt,
1827
- // controller?: AbortController, // set while an ask is in flight
1828
- // generation?: number, // snapshot taken at ask start
1829
- // closed?: boolean, // flipped by closeSession()
1830
- // }
1831
- const _runtimeState = new Map();
1832
- const _toolActivityHeartbeats = new Map();
1833
- const VALID_STAGES = new Set([
1834
- 'connecting', 'requesting', 'streaming', 'tool_running', 'idle', 'error', 'done', 'cancelling',
1835
- ]);
1836
- function _touchRuntime(id) {
1837
- let entry = _runtimeState.get(id);
1838
- if (!entry) {
1839
- entry = { stage: 'idle', lastStreamDeltaAt: null, lastToolCall: null, lastError: null, updatedAt: Date.now() };
1840
- _runtimeState.set(id, entry);
1841
- }
1842
- return entry;
1843
- }
1844
-
1845
- function _stopToolActivityHeartbeat(id) {
1846
- if (!id) return;
1847
- const timer = _toolActivityHeartbeats.get(id);
1848
- if (!timer) return;
1849
- try { clearInterval(timer); } catch { /* ignore */ }
1850
- _toolActivityHeartbeats.delete(id);
1851
- }
1852
-
1853
- function _touchSessionActivityProgress(id) {
1854
- const entry = _runtimeState.get(id);
1855
- if (!entry || entry.closed || entry.controller?.signal?.aborted) return;
1856
- if (entry.stage !== 'tool_running') return;
1857
- const now = Date.now();
1858
- entry.lastProgressAt = now;
1859
- entry.updatedAt = now;
1860
- publishHeartbeat(id, now);
1861
- }
1862
-
1863
- function _startToolActivityHeartbeat(id) {
1864
- _stopToolActivityHeartbeat(id);
1865
- if (!(DEFAULT_ACTIVITY_HEARTBEAT_MS > 0)) return;
1866
- const timer = setInterval(() => _touchSessionActivityProgress(id), DEFAULT_ACTIVITY_HEARTBEAT_MS);
1867
- if (typeof timer.unref === 'function') timer.unref();
1868
- _toolActivityHeartbeats.set(id, timer);
1869
- }
1870
-
1871
- export function updateSessionStage(id, stage) {
1872
- if (!id || !VALID_STAGES.has(stage)) return;
1873
- const entry = _touchRuntime(id);
1874
- const now = Date.now();
1875
- entry.stage = stage;
1876
- if (stage === 'connecting' || stage === 'requesting') {
1877
- entry.modelRequestStartedAt = now;
1878
- }
1879
- entry.lastProgressAt = now;
1880
- entry.updatedAt = now;
1881
- if (stage !== 'tool_running') _stopToolActivityHeartbeat(id);
1882
- }
1883
-
869
+ // Runtime liveness map + its accessors moved to manager/runtime-liveness.mjs
870
+ // (imported/re-exported above). _runtimeState is now private to that module;
871
+ // manager.mjs internals that mutate entries in place use _getRuntimeEntry /
872
+ // _runtimeEntries. updateSessionRoute stays here (it's a persistence helper on
873
+ // the session store, not a liveness accessor).
1884
874
  export function updateSessionRoute(id, route = {}) {
1885
875
  if (!id) return null;
1886
876
  const session = loadSession(id);
@@ -1936,475 +926,15 @@ export function updateSessionRoute(id, route = {}) {
1936
926
  return session;
1937
927
  }
1938
928
 
1939
- /**
1940
- * Reset heartbeat-visible fields for a new ask. Preserves controller/generation/
1941
- * closed (lifecycle) but clears the previous run's streaming state so stale
1942
- * lastToolCall / lastStreamDeltaAt from the previous ask don't leak into the
1943
- * new one.
1944
- */
1945
- export function markSessionAskStart(id) {
1946
- if (!id) return;
1947
- _stopToolActivityHeartbeat(id);
1948
- const entry = _touchRuntime(id);
1949
- entry.usageMetricsTurnIncremental = false;
1950
- const sessionForTurn = entry.session ?? loadSession(id);
1951
- if (sessionForTurn) bumpUsageMetricsTurnId(sessionForTurn);
1952
- entry.stage = 'connecting';
1953
- entry.lastStreamDeltaAt = null;
1954
- entry.lastToolCall = null;
1955
- entry.toolStartedAt = null;
1956
- entry.lastError = null;
1957
- // A new ask starts a fresh turn lifecycle — clear any stale empty-final
1958
- // classification from the prior turn so inspectBridgeEntry doesn't keep
1959
- // short-circuiting to 'empty-synthesis' (which would disable stall
1960
- // detection for the entire new turn).
1961
- entry.emptyFinal = false;
1962
- entry.emptyFinalAt = null;
1963
- // askStartedAt is the watchdog's fallback reference when a session
1964
- // hangs before any stream delta arrives. Without it, a provider that
1965
- // never returns a first token would stall forever because the watchdog
1966
- // keys solely on lastStreamDeltaAt.
1967
- const now = Date.now();
1968
- entry.askStartedAt = now;
1969
- entry.modelRequestStartedAt = now;
1970
- entry.lastProgressAt = now;
1971
- entry.updatedAt = now;
1972
- // Publish heartbeat immediately so the status aggregator picks the
1973
- // session up in the connecting / requesting window. Without this the
1974
- // .hb file only landed on the first stream chunk — producing a 3–10s
1975
- // (xhigh: 30s+) invisible gap where agent sessions ran but the CC
1976
- // statusline showed no maintenance/agent badge. STREAM_FRESH_MS (5 min)
1977
- // still drops a session whose provider truly never returns a chunk;
1978
- // markSessionStreamDelta keeps refreshing once chunks arrive.
1979
- publishHeartbeat(id, now);
1980
- }
1981
- export async function markSessionStreamDelta(id) {
1982
- if (!id) return;
1983
- // Non-creating lookup: a live ask ALWAYS has a runtime entry (markSessionAskStart
1984
- // creates it before streaming begins). _touchRuntime would instead resurrect a
1985
- // blank entry — and closeSession()/idle-sweep clear _runtimeState on a deferred
1986
- // tick while a detached provider stream may still be trickling deltas. A delta
1987
- // arriving after that clear must NOT re-create an entry or it would republish the
1988
- // .hb heartbeat that markSessionClosed deleted, orphaning a dead session's
1989
- // heartbeat indefinitely (the disk tombstone blocks ask resumption but not this
1990
- // path). Skip a missing, tombstoned, or aborted entry — never refresh liveness.
1991
- const entry = _runtimeState.get(id);
1992
- if (!entry || entry.closed || entry.controller?.signal?.aborted) return;
1993
- _stopToolActivityHeartbeat(id);
1994
- const now = Date.now();
1995
- entry.lastStreamDeltaAt = now;
1996
- entry.lastProgressAt = now;
1997
- // Only promote to 'streaming' if we were in a pre-stream stage; never downgrade
1998
- // mid-tool (tool_running has its own delta source if the tool streams back).
1999
- if (entry.stage === 'connecting' || entry.stage === 'requesting') {
2000
- entry.stage = 'streaming';
2001
- }
2002
- // Lightweight heartbeat (≤5s self-throttled) for the status aggregator.
2003
- // Disk-side session.lastHeartbeatAt below is the heavy 60s zombie-reaper
2004
- // signal; the .hb file is the fast fresh-session signal consumed by the
2005
- // status line.
2006
- publishHeartbeat(id, now);
2007
- const session = entry.session;
2008
- if (session && now - (session.lastHeartbeatAt || 0) > HEARTBEAT_THROTTLE_MS) {
2009
- session.lastHeartbeatAt = now;
2010
- await saveSessionAsync(session, { expectedGeneration: session.generation });
2011
- }
2012
- entry.updatedAt = now;
2013
- }
2014
- export function markSessionToolCall(id, toolName) {
2015
- if (!id) return;
2016
- const entry = _touchRuntime(id);
2017
- entry.stage = 'tool_running';
2018
- entry.lastToolCall = toolName || null;
2019
- entry.toolStartedAt = Date.now();
2020
- entry.lastProgressAt = entry.toolStartedAt;
2021
- entry.updatedAt = entry.toolStartedAt;
2022
- publishHeartbeat(id, entry.toolStartedAt);
2023
- _startToolActivityHeartbeat(id);
2024
- }
2025
- // Parent AbortSignal listeners are dropped on askSession unwind (finally /
2026
- // terminal return) and on error/cancel/close — not in markSessionDone, which
2027
- // also runs between queued follow-up turns within one ask.
2028
- export function markSessionDone(id, { empty = false } = {}) {
2029
- if (!id) return;
2030
- _stopToolActivityHeartbeat(id);
2031
- const entry = _touchRuntime(id);
2032
- entry.stage = 'done';
2033
- entry.lastError = null;
2034
- entry.askStartedAt = null;
2035
- entry.toolStartedAt = null;
2036
- // Non-empty completion: drop any stale empty-final flag so a subsequent
2037
- // ask on the same reusable runtime entry starts clean. Empty-final
2038
- // completions preserve the flag (set by markSessionEmptyFinal just prior).
2039
- if (!empty) {
2040
- entry.emptyFinal = false;
2041
- entry.emptyFinalAt = null;
2042
- }
2043
- const doneTs = Date.now();
2044
- entry.doneAt = doneTs;
2045
- entry.lastProgressAt = doneTs;
2046
- entry.updatedAt = doneTs;
2047
- // Terminal stage — drop the heartbeat so the status badge releases
2048
- // immediately. A subsequent ask on the same session re-publishes via
2049
- // markSessionStreamDelta on the first chunk.
2050
- deleteHeartbeat(id);
2051
- }
2052
- // Tag a session as having completed with empty final synthesis (no
2053
- // content/reasoning). Distinct from `markSessionDone`: still a success
2054
- // (no abort), but the stall watchdog and post-mortem tools can
2055
- // distinguish "finished empty" from "finished with content" without
2056
- // mistaking the silence for a stall.
2057
- export function markSessionEmptyFinal(id) {
2058
- if (!id) return;
2059
- const entry = _touchRuntime(id);
2060
- entry.emptyFinal = true;
2061
- entry.emptyFinalAt = Date.now();
2062
- }
2063
- export function markSessionError(id, msg) {
2064
- if (!id) return;
2065
- _stopToolActivityHeartbeat(id);
2066
- const entry = _touchRuntime(id);
2067
- entry.stage = 'error';
2068
- entry.lastError = msg ? String(msg).slice(0, 200) : null;
2069
- entry.askStartedAt = null;
2070
- entry.toolStartedAt = null;
2071
- // Error path is a non-empty completion (we have an error message, not a
2072
- // silent empty final). Clear the flag so the next ask starts clean.
2073
- entry.emptyFinal = false;
2074
- entry.emptyFinalAt = null;
2075
- const errTs = Date.now();
2076
- entry.doneAt = errTs;
2077
- entry.lastProgressAt = errTs;
2078
- entry.updatedAt = errTs;
2079
- deleteHeartbeat(id);
2080
- _unlinkParentAbortListener(entry);
2081
- }
2082
- export function markSessionCancelled(id) {
2083
- if (!id) return;
2084
- _stopToolActivityHeartbeat(id);
2085
- const entry = _touchRuntime(id);
2086
- entry.stage = 'done';
2087
- entry.lastError = null;
2088
- entry.askStartedAt = null;
2089
- entry.toolStartedAt = null;
2090
- entry.emptyFinal = false;
2091
- entry.emptyFinalAt = null;
2092
- const doneTs = Date.now();
2093
- entry.doneAt = doneTs;
2094
- entry.lastProgressAt = doneTs;
2095
- entry.updatedAt = doneTs;
2096
- deleteHeartbeat(id);
2097
- _unlinkParentAbortListener(entry);
2098
- }
2099
- export function getSessionRuntime(id) {
2100
- return id ? (_runtimeState.get(id) || null) : null;
2101
- }
2102
-
2103
- const _COMPACTION_BLOCKED_STAGES = new Set([
2104
- 'connecting', 'requesting', 'streaming', 'tool_running', 'cancelling',
2105
- ]);
2106
-
2107
- export function isSessionCompactionBlocked(sessionId) {
2108
- if (!sessionId) return false;
2109
- const entry = _runtimeState.get(sessionId);
2110
- if (!entry || entry.closed === true) return false;
2111
- if (entry.controller && !entry.controller.signal?.aborted) return true;
2112
- return _COMPACTION_BLOCKED_STAGES.has(entry.stage);
2113
- }
2114
-
2115
- export function getSessionProgressSnapshot(sessionId) {
2116
- const entry = _runtimeState.get(sessionId);
2117
- if (!entry) return null;
2118
- const askStartedAt = entry.askStartedAt || 0;
2119
- const modelRequestStartedAt = entry.modelRequestStartedAt || askStartedAt;
2120
- const firstActivityAt = Math.max(
2121
- entry.lastStreamDeltaAt || 0,
2122
- entry.toolStartedAt || 0,
2123
- );
2124
- const stage = entry.stage || 'idle';
2125
- const waitingForFirstActivity = Boolean(
2126
- modelRequestStartedAt
2127
- && (stage === 'connecting' || stage === 'requesting')
2128
- && firstActivityAt <= modelRequestStartedAt
2129
- );
2130
- return {
2131
- stage,
2132
- askStartedAt,
2133
- modelRequestStartedAt,
2134
- firstActivityAt,
2135
- lastStreamDeltaAt: entry.lastStreamDeltaAt || 0,
2136
- toolStartedAt: entry.toolStartedAt || 0,
2137
- lastProgressAt: entry.lastProgressAt || 0,
2138
- updatedAt: entry.updatedAt || 0,
2139
- hasFirstActivity: Boolean(firstActivityAt && (!askStartedAt || firstActivityAt >= askStartedAt)),
2140
- waitingForFirstActivity,
2141
- };
2142
- }
2143
-
2144
- /**
2145
- * Iterate all active session runtimes. Used by the stream watchdog.
2146
- * Returns an iterable of [sessionId, entry] pairs; consumers should
2147
- * treat entries as read-only snapshots and avoid mutating them.
2148
- */
2149
- export function forEachSessionRuntime() {
2150
- return _runtimeState.entries();
2151
- }
2152
-
2153
929
  // --- Incremental metric persistence (fix A) ---
2154
- // Per-session idempotency tracking: sessionId → Set of seen turn:epoch:iteration:source keys.
2155
- const _metricSeenIter = new Map();
2156
-
2157
- /** Monotonic per-session ask/turn id for incremental usage idempotency. */
2158
- export function bumpUsageMetricsTurnId(session) {
2159
- if (!session || typeof session !== 'object') return 0;
2160
- const next = (Number(session.usageMetricsTurnId) || 0) + 1;
2161
- session.usageMetricsTurnId = next;
2162
- return next;
2163
- }
2164
-
2165
- export function resolveUsageMetricsTurnId(session, delta = {}) {
2166
- if (delta.usageMetricsTurnId != null && Number.isFinite(Number(delta.usageMetricsTurnId))) {
2167
- return Number(delta.usageMetricsTurnId);
2168
- }
2169
- return Number(session?.usageMetricsTurnId) || 0;
2170
- }
2171
-
2172
- /** Advance loop metrics epoch when agentLoop resets its iteration counter (post-compact). */
2173
- export function bumpUsageMetricsEpoch(session) {
2174
- if (!session || typeof session !== 'object') return 0;
2175
- const next = (Number(session.usageMetricsEpoch) || 0) + 1;
2176
- session.usageMetricsEpoch = next;
2177
- return next;
2178
- }
2179
-
2180
- /**
2181
- * Resolve usage-metrics epoch for idempotency (exported for regression smoke).
2182
- * Prefers session.usageMetricsEpoch (bumped in loop on compact reset) and optional
2183
- * delta.usageMetricsEpoch; falls back to iteration regression when loop did not bump.
2184
- */
2185
- export function resolveUsageMetricsEpoch(session, delta = {}) {
2186
- if (!session) return 0;
2187
- let epoch = Number(session.usageMetricsEpoch) || 0;
2188
- if (delta.usageMetricsEpoch != null && Number.isFinite(Number(delta.usageMetricsEpoch))) {
2189
- epoch = Math.max(epoch, Number(delta.usageMetricsEpoch));
2190
- }
2191
- const idx = Number(delta.iterationIndex);
2192
- const prevLastIdx = typeof session.lastIterationIndex === 'number'
2193
- ? session.lastIterationIndex
2194
- : null;
2195
- if (
2196
- (delta.usageMetricsEpoch == null || !Number.isFinite(Number(delta.usageMetricsEpoch)))
2197
- && prevLastIdx !== null
2198
- && Number.isFinite(idx)
2199
- && idx < prevLastIdx
2200
- ) {
2201
- epoch += 1;
2202
- }
2203
- return epoch;
2204
- }
2205
-
2206
- export function usageMetricsSourceKey(delta = {}) {
2207
- const raw = delta.source ?? delta.usageSource;
2208
- if (raw == null || raw === '') return 'provider_send';
2209
- return String(raw);
2210
- }
2211
-
2212
- /** Idempotency key for incremental usage persistence (exported for regression smoke). */
2213
- export function usageMetricsIdempotencyKey(sessionId, session, delta = {}) {
2214
- const turnId = resolveUsageMetricsTurnId(session, delta);
2215
- const epoch = resolveUsageMetricsEpoch(session, delta);
2216
- const source = usageMetricsSourceKey(delta);
2217
- return `${sessionId}:${turnId}:${epoch}:${delta.iterationIndex}:${source}`;
2218
- }
2219
-
2220
- function uncachedInputTokensForProvider(provider, inputTokens, cachedReadTokens = 0, cacheWriteTokens = 0) {
2221
- const input = Number(inputTokens) || 0;
2222
- if (input <= 0) return 0;
2223
- // Anthropic-style providers report input_tokens excluding cache reads; OpenAI
2224
- // Responses/Gemini-style providers report input_tokens inclusive of cached
2225
- // prefix tokens. Keep both views so UI can show the real context footprint
2226
- // and the fresh/new token portion without mistaking cache hits for a cache
2227
- // break.
2228
- if (providerInputExcludesCache(provider)) return input + (Number(cacheWriteTokens) || 0);
2229
- return Math.max(input - (Number(cachedReadTokens) || 0) - (Number(cacheWriteTokens) || 0), 0);
2230
- }
2231
-
2232
- /**
2233
- * Apply terminal ask usage to session totals. Skips lifetime totals when incremental
2234
- * per-iteration persistence already counted this turn (askSession path).
2235
- */
2236
- export function applyAskTerminalUsageTotals(session, result, options = {}) {
2237
- if (!session || !result?.usage) return;
2238
- const skipTotals = options.skipTotalsIfIncremental === true;
2239
- if (!skipTotals) {
2240
- const inputTokens = result.usage.inputTokens || 0;
2241
- const outputTokens = result.usage.outputTokens || 0;
2242
- const cachedTokens = result.usage.cachedTokens || 0;
2243
- const cacheWriteTokens = result.usage.cacheWriteTokens || 0;
2244
- const uncachedInputTokens = uncachedInputTokensForProvider(session.provider, inputTokens, cachedTokens, cacheWriteTokens);
2245
- session.totalInputTokens = (session.totalInputTokens || 0) + inputTokens;
2246
- session.totalOutputTokens = (session.totalOutputTokens || 0) + outputTokens;
2247
- session.tokensCumulative = (session.tokensCumulative || 0)
2248
- + inputTokens
2249
- + outputTokens;
2250
- session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + cachedTokens;
2251
- session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + cacheWriteTokens;
2252
- session.totalUncachedInputTokens = (session.totalUncachedInputTokens || 0) + uncachedInputTokens;
2253
- }
2254
- const _lastTurn = result.lastTurnUsage || result.usage || {};
2255
- const _lastInputTokens = _lastTurn.inputTokens || 0;
2256
- const _lastCachedReadTokens = _lastTurn.cachedTokens || 0;
2257
- const _lastCacheWriteTokens = _lastTurn.cacheWriteTokens || 0;
2258
- session.lastInputTokens = _lastInputTokens;
2259
- session.lastOutputTokens = _lastTurn.outputTokens || 0;
2260
- session.lastCachedReadTokens = _lastCachedReadTokens;
2261
- session.lastCacheWriteTokens = _lastCacheWriteTokens;
2262
- session.lastUncachedInputTokens = uncachedInputTokensForProvider(
2263
- session.provider,
2264
- _lastInputTokens,
2265
- _lastCachedReadTokens,
2266
- _lastCacheWriteTokens,
2267
- );
2268
- const _inputExcludesCache = providerInputExcludesCache(session.provider);
2269
- session.lastContextTokens = _inputExcludesCache
2270
- ? _lastInputTokens + _lastCachedReadTokens + _lastCacheWriteTokens
2271
- : _lastInputTokens;
2272
- session.lastContextTokensUpdatedAt = Date.now();
2273
- session.lastContextTokensStaleAfterCompact = false;
2274
- }
2275
-
2276
- /**
2277
- * Persist incremental usage delta immediately after each provider.send iteration.
2278
- * Idempotency key `sessionId:turnId:epoch:iterationIndex:source` scopes retries
2279
- * per ask, compaction epoch, iteration, and usage source.
2280
- */
2281
- export async function persistIterationMetrics(delta) {
2282
- if (!delta || !delta.sessionId) return;
2283
- const { sessionId, iterationIndex, deltaInput, deltaOutput, deltaCachedRead, deltaCacheWrite, ts } = delta;
2284
- const runtimeEntry = _runtimeState.get(sessionId);
2285
- const session = runtimeEntry?.session ?? loadSession(sessionId);
2286
- if (!session || session.closed) return;
2287
- const epoch = resolveUsageMetricsEpoch(session, delta);
2288
- if (epoch !== (Number(session.usageMetricsEpoch) || 0)) {
2289
- session.usageMetricsEpoch = epoch;
2290
- }
2291
- let seen = _metricSeenIter.get(sessionId);
2292
- if (!seen) {
2293
- seen = new Set();
2294
- _metricSeenIter.set(sessionId, seen);
2295
- }
2296
- const ikey = usageMetricsIdempotencyKey(sessionId, session, delta);
2297
- const isReplay = seen.has(ikey);
2298
- seen.add(ikey);
2299
- if (!isReplay) {
2300
- if (runtimeEntry) runtimeEntry.usageMetricsTurnIncremental = true;
2301
- const deltaUncachedInput = delta.deltaUncachedInput != null
2302
- ? Number(delta.deltaUncachedInput) || 0
2303
- : uncachedInputTokensForProvider(session.provider, deltaInput, deltaCachedRead, deltaCacheWrite);
2304
- session.totalInputTokens = (session.totalInputTokens || 0) + (deltaInput || 0);
2305
- session.totalOutputTokens = (session.totalOutputTokens || 0) + (deltaOutput || 0);
2306
- session.tokensCumulative = (session.tokensCumulative || 0) + (deltaInput || 0) + (deltaOutput || 0);
2307
- // Cache totals — additive fields, default 0 on legacy sessions; both
2308
- // are undefined-safe so the schema migrates lazily as new iterations
2309
- // land. Keeps live + terminal aggregates in lock-step (loop.mjs already
2310
- // includes cached_read / cache_write in its terminal usage rollup).
2311
- session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + (deltaCachedRead || 0);
2312
- session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + (deltaCacheWrite || 0);
2313
- session.totalUncachedInputTokens = (session.totalUncachedInputTokens || 0) + deltaUncachedInput;
2314
- // Window snapshot updated per iteration so agent type=list reflects the
2315
- // most-recent provider-reported input size even for short dispatches
2316
- // that finish before askSession's terminal save lands.
2317
- session.lastInputTokens = deltaInput || 0;
2318
- session.lastOutputTokens = deltaOutput || 0;
2319
- session.lastCachedReadTokens = deltaCachedRead || 0;
2320
- session.lastCacheWriteTokens = deltaCacheWrite || 0;
2321
- session.lastUncachedInputTokens = deltaUncachedInput;
2322
- // Normalized last-call context footprint: how many prompt tokens the
2323
- // model actually saw on the most-recent send, comparable ACROSS
2324
- // providers. Anthropic reports input_tokens EXCLUDING cache (cache_read
2325
- // is a separate field), so the cached portion must be added back to
2326
- // reflect real context size; openai/grok/gemini already fold cached
2327
- // tokens INTO the input count, so input alone is the footprint.
2328
- const _inputExcludesCache = providerInputExcludesCache(session.provider);
2329
- session.lastContextTokens = _inputExcludesCache
2330
- ? (deltaInput || 0) + (deltaCachedRead || 0) + (deltaCacheWrite || 0)
2331
- : (deltaInput || 0);
2332
- session.lastContextTokensUpdatedAt = ts || Date.now();
2333
- session.lastContextTokensStaleAfterCompact = false;
2334
- }
2335
- session.lastIterationIndex = iterationIndex;
2336
- session.updatedAt = ts || Date.now();
2337
- await saveSessionAsync(session, { expectedGeneration: session.generation });
2338
- }
2339
-
2340
- function standaloneStatusRouteInfo(session) {
2341
- if (!session) return null;
2342
- // autoCompactTokenLimit is an EXPLICIT sub-boundary auto-compact limit only.
2343
- // Do NOT fall back to compactBoundaryTokens/contextWindow here — that
2344
- // labels a derived full-window value as an explicit limit, which the
2345
- // runtime would treat as the compaction trigger and collapse the buffer.
2346
- // The boundary/window stays available via contextWindow/rawContextWindow.
2347
- const _boundary = Number(session.compactBoundaryTokens || session.contextWindow || 0);
2348
- const _limit = Number(session.autoCompactTokenLimit || 0);
2349
- const explicitAutoCompactTokenLimit = _limit > 0 && (!_boundary || _limit < _boundary) ? _limit : null;
2350
- return {
2351
- provider: session.provider,
2352
- model: session.model,
2353
- modelDisplay: session.modelDisplay || session.displayName || session.model,
2354
- effort: session.effort || '',
2355
- fast: session.fast === true,
2356
- contextWindow: session.contextWindow || null,
2357
- rawContextWindow: session.rawContextWindow || session.contextWindow || null,
2358
- effectiveContextWindowPercent: session.effectiveContextWindowPercent || null,
2359
- autoCompactTokenLimit: explicitAutoCompactTokenLimit,
2360
- presetId: session.presetId || null,
2361
- presetName: session.presetName || null,
2362
- };
2363
- }
2364
-
2365
- function recordStandaloneStatusTelemetry(session, result, durationMs) {
2366
- if (!session || !result?.usage) return;
2367
- const routeInfo = standaloneStatusRouteInfo(session);
2368
- if (!routeInfo?.provider || !routeInfo?.model) return;
2369
- const providerOut = {
2370
- usage: result.usage,
2371
- model: result.model,
2372
- serviceTier: result.serviceTier,
2373
- };
2374
- // The transcript estimate is the SSOT for the displayed context footprint.
2375
- // agentLoop()'s result has no `compact` field, so build a synthetic compact
2376
- // arg carrying the live monotonic estimate (estimateMessagesTokens+reserve)
2377
- // as afterTokens. This lights up summarizeGatewayUsage's estimate-based
2378
- // contextUsedPct branch (provider input_tokens swing wildly / unbounded on
2379
- // e.g. OpenAI gpt-5.5), and lets a genuine >100% pass through.
2380
- const _estTokens = estimateTranscriptContextUsage(session.messages, session.tools || []);
2381
- const _compactArg = { ...(result.compact && typeof result.compact === 'object' ? result.compact : {}), afterTokens: _estTokens };
2382
- try {
2383
- const summary = {
2384
- ...summarizeGatewayUsage(routeInfo, providerOut, _compactArg, durationMs),
2385
- requestKind: 'chat',
2386
- sessionId: session.id || null,
2387
- toolCount: result.toolCallsTotal ?? null,
2388
- messageCount: Array.isArray(session.messages) ? session.messages.length : null,
2389
- cacheStrategy: session.providerCacheOpts?.cacheStrategy || null,
2390
- };
2391
- recordGatewayUsageEvent(summary);
2392
- } catch {
2393
- // Statusline telemetry must never affect the model turn.
2394
- }
2395
-
2396
- const provider = getProvider(routeInfo.provider);
2397
- if (!provider) return;
2398
- fetchOAuthUsageSnapshot(routeInfo, provider, (message) => {
2399
- if (process.env.MIXDOG_STATUSLINE_TRACE) {
2400
- process.stderr.write(`[statusline] ${message}\n`);
2401
- }
2402
- })
2403
- .then((snapshot) => {
2404
- try { buildGatewayLimits(routeInfo, providerOut, snapshot); } catch {}
2405
- })
2406
- .catch(() => {});
2407
- }
930
+ // Usage-metrics accounting (idempotency Set, epoch/turn helpers, terminal +
931
+ // incremental persistence) moved to manager/usage-metrics.mjs. Its runtime
932
+ // accessor is now wired inside manager/runtime-liveness.mjs (which owns the
933
+ // _runtimeState Map), so persistIterationMetrics can read the live in-memory
934
+ // session and flag usageMetricsTurnIncremental.
935
+ //
936
+ // standaloneStatusRouteInfo + recordStandaloneStatusTelemetry moved to
937
+ // manager/status-telemetry.mjs (imported above).
2408
938
 
2409
939
  /** Force-flush session metrics to disk. Used by watchdog terminal-reap (fix B). */
2410
940
  export async function flushSessionMetrics(sessionId) {
@@ -2415,91 +945,6 @@ export async function flushSessionMetrics(sessionId) {
2415
945
  await saveSessionAsync(session, { expectedGeneration: session.generation });
2416
946
  }
2417
947
 
2418
- /** Mark session hidden so listSessions() filters it out (runtime-only). */
2419
- export function hideSessionFromList(sessionId) {
2420
- if (!sessionId) return;
2421
- const entry = _runtimeState.get(sessionId);
2422
- if (entry) entry.listHidden = true;
2423
- }
2424
-
2425
- export function getSessionAbortSignal(sessionId) {
2426
- return _runtimeState.get(sessionId)?.controller?.signal ?? null;
2427
- }
2428
-
2429
- /**
2430
- * Return the most recent "session is making progress" timestamp.
2431
- *
2432
- * Combines three independent progress signals so an idle watchdog can stay
2433
- * alive across both streaming and long tool calls:
2434
- * - lastStreamDeltaAt: provider stream chunk landed
2435
- * - toolStartedAt: a tool call just kicked off (nested tool work may
2436
- * stall the outer stream for a while; this keeps the watchdog from
2437
- * killing legitimate sub-agent runs)
2438
- * - askStartedAt: ask just started; covers the pre-stream connect window
2439
- *
2440
- * Returns 0 when the runtime entry is unknown so callers can decide to
2441
- * either skip the watchdog or treat 0 as "no progress yet".
2442
- */
2443
- export function getSessionLastProgressAt(sessionId) {
2444
- const entry = _runtimeState.get(sessionId);
2445
- if (!entry) return 0;
2446
- return Math.max(
2447
- entry.lastProgressAt || 0,
2448
- entry.lastStreamDeltaAt || 0,
2449
- entry.toolStartedAt || 0,
2450
- entry.askStartedAt || 0,
2451
- );
2452
- }
2453
-
2454
- /**
2455
- * Link a parent AbortSignal to a sub-session's controller so that aborting
2456
- * the parent (fan-out deadline or caller ESC) tears down the agent role's
2457
- * provider call promptly. Safe to call after prepareAgentSession but before
2458
- * askSession completes. No-op if the session runtime isn't found.
2459
- *
2460
- * @param {string} sessionId — the sub-session to abort
2461
- * @param {AbortSignal} parentSignal — upstream signal (from fan-out coordinator)
2462
- */
2463
- export function linkParentSignalToSession(sessionId, parentSignal) {
2464
- if (!(parentSignal instanceof AbortSignal)) return;
2465
- const entry = _touchRuntime(sessionId);
2466
- if (!entry.controller) entry.controller = createAbortController();
2467
- const abortReason = () => {
2468
- const reason = parentSignal.reason;
2469
- if (reason instanceof Error) return reason;
2470
- if (reason !== undefined && reason !== null && reason !== '') return new Error(String(reason));
2471
- return new Error('parent signal aborted');
2472
- };
2473
- if (parentSignal.aborted) {
2474
- _unlinkParentAbortListener(entry);
2475
- try { entry.controller.abort(abortReason()); } catch { /* ignore */ }
2476
- return;
2477
- }
2478
- _unlinkParentAbortListener(entry);
2479
- const onParentAbort = () => {
2480
- try { entry.controller?.abort(abortReason()); } catch { /* ignore */ }
2481
- };
2482
- parentSignal.addEventListener('abort', onParentAbort, { once: true });
2483
- entry.parentAbortLink = { signal: parentSignal, listener: onParentAbort };
2484
- }
2485
- function _unlinkParentAbortListener(entry) {
2486
- const link = entry?.parentAbortLink;
2487
- if (!link) return;
2488
- try { link.signal.removeEventListener('abort', link.listener); } catch { /* ignore */ }
2489
- entry.parentAbortLink = null;
2490
- }
2491
- function _clearSessionRuntime(id) {
2492
- if (id) {
2493
- _stopToolActivityHeartbeat(id);
2494
- _unlinkParentAbortListener(_runtimeState.get(id));
2495
- _runtimeState.delete(id);
2496
- // R15: also drop the per-session metric-idempotency Set; otherwise it
2497
- // grows O(sessions x iterations) for the whole server lifetime since
2498
- // nothing else deletes from _metricSeenIter on session close.
2499
- _metricSeenIter.delete(id);
2500
- }
2501
- }
2502
-
2503
948
  /**
2504
949
  * Wrap an async call so that if the session's controller aborts mid-flight,
2505
950
  * the wrapper settles with a SessionClosedError even if the underlying promise
@@ -2562,363 +1007,6 @@ const _sessionLocks = new Map();
2562
1007
  // queue contract, two call sites. Rich content is kept in memory for the live
2563
1008
  // relay path; the disk mirror stores only a text fallback so image bytes do not
2564
1009
  // leak into the pending-message JSON.
2565
- const _sessionPendingMessages = new Map();
2566
- const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
2567
- const PENDING_MESSAGES_MODE = 0o600;
2568
- const _pendingPersistBuffers = new Map();
2569
- let _pendingPersistImmediate = null;
2570
-
2571
- function pendingMessagesPath() {
2572
- return join(resolvePluginData(), PENDING_MESSAGES_FILE);
2573
- }
2574
-
2575
- function isValidPendingSessionId(sessionId) {
2576
- return typeof sessionId === 'string' && /^[A-Za-z0-9_-]+$/.test(sessionId);
2577
- }
2578
-
2579
- function normalizePendingStore(raw) {
2580
- const sessions = raw && typeof raw === 'object' && raw.sessions && typeof raw.sessions === 'object'
2581
- ? raw.sessions
2582
- : {};
2583
- const out = { version: 1, updatedAt: Date.now(), sessions: {} };
2584
- for (const [sid, value] of Object.entries(sessions)) {
2585
- if (!isValidPendingSessionId(sid) || !Array.isArray(value)) continue;
2586
- const q = value
2587
- .map((entry) => {
2588
- if (typeof entry === 'string') return entry;
2589
- if (entry && typeof entry === 'object' && typeof entry.message === 'string') return entry.message;
2590
- return '';
2591
- })
2592
- .filter(Boolean);
2593
- if (q.length > 0) out.sessions[sid] = q;
2594
- }
2595
- return out;
2596
- }
2597
-
2598
- function normalizePendingMessageEntry(entry) {
2599
- if (typeof entry === 'string') {
2600
- const text = entry.trim();
2601
- return text ? { content: text, text } : null;
2602
- }
2603
- if (Array.isArray(entry)) {
2604
- if (entry.length === 0) return null;
2605
- const text = promptContentText(entry).trim();
2606
- return { content: entry, text };
2607
- }
2608
- if (!entry || typeof entry !== 'object') return null;
2609
- const content = Object.prototype.hasOwnProperty.call(entry, 'content') ? entry.content : null;
2610
- if (content == null) return null;
2611
- const text = typeof entry.text === 'string' ? entry.text.trim() : promptContentText(content).trim();
2612
- if (Array.isArray(content)) return content.length > 0 ? { content, text } : null;
2613
- if (typeof content === 'string') {
2614
- const value = content.trim();
2615
- return value ? { content: value, text: text || value } : null;
2616
- }
2617
- const fallback = promptContentText(content).trim();
2618
- return fallback ? { content: fallback, text: text || fallback } : null;
2619
- }
2620
-
2621
- function pendingMessageText(entry) {
2622
- const normalized = normalizePendingMessageEntry(entry);
2623
- return normalized ? String(normalized.text || promptContentText(normalized.content) || '').trim() : '';
2624
- }
2625
-
2626
- function pendingMessageQueueEntry(entry) {
2627
- const normalized = normalizePendingMessageEntry(entry);
2628
- if (!normalized) return null;
2629
- if (typeof normalized.content === 'string' && normalized.content === normalized.text) return normalized.content;
2630
- return { content: normalized.content, text: normalized.text || promptContentText(normalized.content).trim() };
2631
- }
2632
-
2633
- function persistPendingMessages(sessionId, messages) {
2634
- if (!isValidPendingSessionId(sessionId)) return 0;
2635
- const persistedMessages = (Array.isArray(messages) ? messages : [messages])
2636
- .map(pendingMessageText)
2637
- .filter(Boolean);
2638
- if (persistedMessages.length === 0) return 0;
2639
- let depth = 0;
2640
- try {
2641
- updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
2642
- const next = normalizePendingStore(raw);
2643
- const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
2644
- q.push(...persistedMessages);
2645
- next.sessions[sessionId] = q;
2646
- next.updatedAt = Date.now();
2647
- depth = q.length;
2648
- return next;
2649
- }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
2650
- } catch (err) {
2651
- try { process.stderr.write(`[session] pending-message persist failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
2652
- }
2653
- return depth;
2654
- }
2655
-
2656
- function flushPendingMessagePersistsSync() {
2657
- if (_pendingPersistImmediate) {
2658
- try { clearImmediate(_pendingPersistImmediate); } catch {}
2659
- _pendingPersistImmediate = null;
2660
- }
2661
- if (_pendingPersistBuffers.size === 0) return;
2662
- const batches = [..._pendingPersistBuffers.entries()];
2663
- _pendingPersistBuffers.clear();
2664
- for (const [sid, messages] of batches) {
2665
- persistPendingMessages(sid, messages);
2666
- }
2667
- }
2668
-
2669
- function schedulePendingMessagePersist(sessionId, message) {
2670
- if (!isValidPendingSessionId(sessionId)) return 0;
2671
- const persistedMessage = pendingMessageText(message);
2672
- if (!persistedMessage) return 0;
2673
- const q = _pendingPersistBuffers.get(sessionId) || [];
2674
- q.push(persistedMessage);
2675
- _pendingPersistBuffers.set(sessionId, q);
2676
- if (!_pendingPersistImmediate) {
2677
- _pendingPersistImmediate = setImmediate(() => {
2678
- _pendingPersistImmediate = null;
2679
- flushPendingMessagePersistsSync();
2680
- });
2681
- }
2682
- return q.length;
2683
- }
2684
-
2685
- function takeBufferedPendingMessages(sessionId) {
2686
- if (!isValidPendingSessionId(sessionId)) return [];
2687
- const buffered = _pendingPersistBuffers.get(sessionId);
2688
- if (!buffered || buffered.length === 0) return [];
2689
- _pendingPersistBuffers.delete(sessionId);
2690
- return buffered.slice();
2691
- }
2692
-
2693
- function drainPersistedPendingMessages(sessionId) {
2694
- if (!isValidPendingSessionId(sessionId)) return [];
2695
- let drained = [];
2696
- try {
2697
- updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
2698
- const next = normalizePendingStore(raw);
2699
- const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
2700
- drained = q.filter((m) => typeof m === 'string' && m.length > 0);
2701
- if (drained.length === 0) return undefined;
2702
- delete next.sessions[sessionId];
2703
- next.updatedAt = Date.now();
2704
- return next;
2705
- }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
2706
- } catch (err) {
2707
- try { process.stderr.write(`[session] pending-message drain failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
2708
- }
2709
- return drained;
2710
- }
2711
-
2712
- export function enqueuePendingMessage(sessionId, message) {
2713
- const entry = pendingMessageQueueEntry(message);
2714
- if (!sessionId || !entry) return 0;
2715
- let q = _sessionPendingMessages.get(sessionId);
2716
- if (!q) { q = []; _sessionPendingMessages.set(sessionId, q); }
2717
- q.push(entry);
2718
- const bufferedDepth = schedulePendingMessagePersist(sessionId, entry);
2719
- return Math.max(q.length, bufferedDepth || 0);
2720
- }
2721
- export function drainPendingMessages(sessionId) {
2722
- const q = _sessionPendingMessages.get(sessionId);
2723
- const memory = q && q.length > 0 ? q.slice() : [];
2724
- _sessionPendingMessages.delete(sessionId);
2725
- const persisted = [...takeBufferedPendingMessages(sessionId), ...drainPersistedPendingMessages(sessionId)];
2726
- const memoryVisible = modelVisiblePendingMessages(memory);
2727
- const persistedVisible = modelVisiblePendingMessages(persisted);
2728
- if (memoryVisible.length === 0) return persistedVisible;
2729
- if (persistedVisible.length === 0) return memoryVisible;
2730
- const persistedTexts = persistedVisible.map(pendingMessageText);
2731
- const prefixMatches = memoryVisible.every((m, i) => persistedTexts[i] === pendingMessageText(m));
2732
- if (prefixMatches) return [...memoryVisible, ...persistedVisible.slice(memoryVisible.length)];
2733
- const out = persistedVisible.slice();
2734
- const seen = new Set(persistedTexts);
2735
- for (const m of memoryVisible) {
2736
- const text = pendingMessageText(m);
2737
- if (!text || seen.has(text)) continue;
2738
- out.push(m);
2739
- seen.add(text);
2740
- }
2741
- return out;
2742
- }
2743
-
2744
- function promptContentText(content) {
2745
- if (typeof content === 'string') return content;
2746
- if (Array.isArray(content)) {
2747
- return content.map((part) => {
2748
- if (typeof part === 'string') return part;
2749
- if (part?.type === 'text') return part.text || '';
2750
- if (part?.type === 'image') return '[Image]';
2751
- return part?.text || '';
2752
- }).filter(Boolean).join('\n');
2753
- }
2754
- return String(content ?? '');
2755
- }
2756
-
2757
- function hasModelVisiblePromptContent(prompt) {
2758
- return !!promptContentText(prompt).trim();
2759
- }
2760
-
2761
- function promptContentBytes(content) {
2762
- try {
2763
- if (typeof content === 'string') return Buffer.byteLength(content, 'utf8');
2764
- return Buffer.byteLength(JSON.stringify(content), 'utf8');
2765
- } catch {
2766
- return Buffer.byteLength(promptContentText(content), 'utf8');
2767
- }
2768
- }
2769
-
2770
- function prefixUserTurnContent(content, contextBlock) {
2771
- if (!contextBlock) return content;
2772
- if (Array.isArray(content)) {
2773
- return [{ type: 'text', text: `${contextBlock}# Task\n` }, ...content];
2774
- }
2775
- return `${contextBlock}# Task\n${content}`;
2776
- }
2777
-
2778
- function prefixSessionStartContent(content, sessionBlock) {
2779
- if (!sessionBlock) return content;
2780
- if (Array.isArray(content)) {
2781
- return [{ type: 'text', text: `${sessionBlock}\n\n` }, ...content];
2782
- }
2783
- return `${sessionBlock}\n\n${content}`;
2784
- }
2785
-
2786
- function localIsoDate(date = new Date()) {
2787
- const year = date.getFullYear();
2788
- const month = String(date.getMonth() + 1).padStart(2, '0');
2789
- const day = String(date.getDate()).padStart(2, '0');
2790
- return `${year}-${month}-${day}`;
2791
- }
2792
-
2793
- function localDateTimeWithZone(date = new Date()) {
2794
- const datePart = localIsoDate(date);
2795
- const hh = String(date.getHours()).padStart(2, '0');
2796
- const mm = String(date.getMinutes()).padStart(2, '0');
2797
- const ss = String(date.getSeconds()).padStart(2, '0');
2798
- let zone = '';
2799
- try { zone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch {}
2800
- return zone ? `${datePart} ${hh}:${mm}:${ss} ${zone}` : `${datePart} ${hh}:${mm}:${ss}`;
2801
- }
2802
-
2803
- function temporalPromptText(content) {
2804
- const text = promptContentText(content)
2805
- .replace(/\s+/g, ' ')
2806
- .trim()
2807
- .toLowerCase();
2808
- return text;
2809
- }
2810
-
2811
- function promptNeedsDateReminder(content) {
2812
- const text = temporalPromptText(content);
2813
- if (!text) return false;
2814
- return /(?:오늘|내일|어제|모레|그저께|요즘|최근|방금|아까|현재\s*(?:날짜|시간|시각)|지금\s*(?:몇\s*시|시간|날짜|요일)|몇\s*월\s*몇\s*일|몇\s*시|무슨\s*요일|요일|날짜|이번\s*(?:주|달|월|년)|지난\s*(?:주|달|월|년)|다음\s*(?:주|달|월|년)|올해|작년|내년|today|tomorrow|yesterday|recently|current\s+(?:date|time)|what\s+(?:date|time)|which\s+day|weekday|this\s+(?:week|month|year)|last\s+(?:week|month|year)|next\s+(?:week|month|year))/i.test(text);
2815
- }
2816
-
2817
- function promptNeedsTimeReminder(content) {
2818
- const text = temporalPromptText(content);
2819
- if (!text) return false;
2820
- return /(?:현재\s*(?:시간|시각)|지금\s*(?:몇\s*시|시간)|몇\s*시|시각|시간|current\s+time|what\s+time|time\s+is\s+it)/i.test(text);
2821
- }
2822
-
2823
- function buildCurrentTimeBlock(content) {
2824
- const needsTime = promptNeedsTimeReminder(content);
2825
- if (!needsTime && !promptNeedsDateReminder(content)) return '';
2826
- return localDateTimeWithZone(new Date());
2827
- }
2828
-
2829
- function sessionModelDisplay(model) {
2830
- const text = String(model || '').trim();
2831
- if (!text) return '';
2832
- return text
2833
- .replace(/-\d{4}-\d{2}-\d{2}$/, '')
2834
- .replace(/^gpt-/i, 'GPT-')
2835
- .replace(/(?:^|-)([a-z])/g, (m) => m.toUpperCase());
2836
- }
2837
-
2838
- function buildSessionStartBlock(session, cwd) {
2839
- if (!session || session.owner === 'agent') return '';
2840
- const lines = ['# Session'];
2841
- const effectiveCwd = String(cwd || session.cwd || '').trim();
2842
- if (effectiveCwd) lines.push(`Cwd: ${effectiveCwd}`);
2843
- const modelBits = [
2844
- sessionModelDisplay(session.model),
2845
- session.effort ? String(session.effort).trim().toUpperCase() : '',
2846
- session.fast === true ? 'FAST' : '',
2847
- ].filter(Boolean);
2848
- if (modelBits.length) lines.push(`Model: ${modelBits.join(' · ')}`);
2849
- const workflowName = String(session.workflow?.name || session.workflow?.id || '').trim();
2850
- if (workflowName) lines.push(`Workflow: ${workflowName}`);
2851
- return lines.length > 1 ? lines.join('\n') : '';
2852
- }
2853
-
2854
- function isReferenceFilesMessage(message) {
2855
- return message?.role === 'user'
2856
- && typeof message.content === 'string'
2857
- && /^Reference files:\s*/i.test(message.content.trimStart());
2858
- }
2859
-
2860
- function isProtectedContextUserMessage(message) {
2861
- return message?.role === 'user'
2862
- && typeof message.content === 'string'
2863
- && message.content.trimStart().startsWith('<system-reminder>');
2864
- }
2865
-
2866
- function hasUserConversationMessage(messages) {
2867
- return (Array.isArray(messages) ? messages : []).some((message) => (
2868
- message?.role === 'user'
2869
- && !isProtectedContextUserMessage(message)
2870
- && !isReferenceFilesMessage(message)
2871
- ));
2872
- }
2873
-
2874
- function modelVisiblePendingMessages(messages) {
2875
- return (Array.isArray(messages) ? messages : [])
2876
- .map(pendingMessageQueueEntry)
2877
- .filter(Boolean)
2878
- .filter((message) => !isInternalRuntimeNotificationText(
2879
- message && typeof message === 'object' && Object.prototype.hasOwnProperty.call(message, 'content')
2880
- ? message.content
2881
- : message,
2882
- ));
2883
- }
2884
-
2885
- export function _mergePendingMessageEntries(entries) {
2886
- const normalized = (Array.isArray(entries) ? entries : [])
2887
- .map(normalizePendingMessageEntry)
2888
- .filter(Boolean);
2889
- if (normalized.length === 0) return null;
2890
- const displayText = normalized.map((entry) => entry.text || promptContentText(entry.content))
2891
- .filter((text) => String(text || '').trim())
2892
- .join('\n');
2893
- if (normalized.every((entry) => typeof entry.content === 'string')) {
2894
- return {
2895
- content: normalized.map((entry) => entry.content).filter(Boolean).join('\n'),
2896
- text: displayText,
2897
- count: normalized.length,
2898
- };
2899
- }
2900
- const parts = [];
2901
- for (const entry of normalized) {
2902
- if (typeof entry.content === 'string') {
2903
- if (entry.content.trim()) parts.push({ type: 'text', text: entry.content });
2904
- } else if (Array.isArray(entry.content)) {
2905
- parts.push(...entry.content);
2906
- } else {
2907
- const text = promptContentText(entry.content);
2908
- if (text.trim()) parts.push({ type: 'text', text });
2909
- }
2910
- parts.push({ type: 'text', text: '\n' });
2911
- }
2912
- while (parts.length && parts[parts.length - 1]?.type === 'text' && parts[parts.length - 1]?.text === '\n') parts.pop();
2913
- return { content: parts, text: displayText || promptContentText(parts), count: normalized.length };
2914
- }
2915
-
2916
- function isInternalRuntimeNotificationText(content) {
2917
- return contractIsInternalRuntimeNotificationText(promptContentText(content));
2918
- }
2919
-
2920
- export const _isInternalRuntimeNotificationText = isInternalRuntimeNotificationText;
2921
-
2922
1010
  function isInternalCancelledAssistantMessage(message) {
2923
1011
  if (!message || message.role !== 'assistant') return false;
2924
1012
  if (message.cancelled === true) return true;
@@ -3052,7 +1140,7 @@ async function persistCompactedOutgoingAfterAskFailure({
3052
1140
  }) {
3053
1141
  if (!activeSession || activeSession.closed === true) return;
3054
1142
  if (!Array.isArray(turnOutgoing) || turnOutgoing.length === 0) return;
3055
- const currentRuntime = _runtimeState.get(sessionId);
1143
+ const currentRuntime = _getRuntimeEntry(sessionId);
3056
1144
  if (currentRuntime?.closed || currentRuntime?.generation !== askGeneration) return;
3057
1145
  const sanitized = sanitizeSessionMessagesForModel(turnOutgoing);
3058
1146
  const priorSanitized = sanitizeSessionMessagesForModel(
@@ -3130,7 +1218,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
3130
1218
  }
3131
1219
  }
3132
1220
  if (!hasModelVisiblePromptContent(prompt)) {
3133
- _unlinkParentAbortListener(_runtimeState.get(sessionId));
1221
+ _unlinkParentAbortListener(_getRuntimeEntry(sessionId));
3134
1222
  return _result;
3135
1223
  }
3136
1224
  // ── Synchronous pre-await setup (must happen before any await so
@@ -3360,7 +1448,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
3360
1448
  }
3361
1449
  // Post-loop validation: if closeSession() landed while we were awaiting,
3362
1450
  // drop the save so the tombstone on disk isn't overwritten.
3363
- const currentRuntime = _runtimeState.get(sessionId);
1451
+ const currentRuntime = _getRuntimeEntry(sessionId);
3364
1452
  if (currentRuntime?.closed || currentRuntime?.generation !== askGeneration) {
3365
1453
  const reason = currentRuntime?.closedReason;
3366
1454
  throw new SessionClosedError(sessionId, `closed during call (reason=${reason || 'unknown'})`, reason || null);
@@ -3576,7 +1664,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
3576
1664
  // stale in-flight array once the turn unwinds.
3577
1665
  if (activeSession) activeSession.liveTurnMessages = null;
3578
1666
  if (err instanceof SessionClosedError) {
3579
- const currentRuntime = _runtimeState.get(sessionId);
1667
+ const currentRuntime = _getRuntimeEntry(sessionId);
3580
1668
  if (!currentRuntime?.closed) {
3581
1669
  if (activeSession) {
3582
1670
  const originalMessages = Array.isArray(activeSession.messages) ? activeSession.messages : [];
@@ -3644,14 +1732,14 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
3644
1732
  continue;
3645
1733
  }
3646
1734
  }
3647
- _unlinkParentAbortListener(_runtimeState.get(sessionId));
1735
+ _unlinkParentAbortListener(_getRuntimeEntry(sessionId));
3648
1736
  return _result;
3649
1737
  }
3650
1738
  } finally {
3651
1739
  // Clear the controller only if it's still ours (closeSession may have
3652
1740
  // swapped it). Leave the rest of the runtime entry intact so agent type=list
3653
1741
  // can still surface the final stage (done/error/cancelling).
3654
- const entry = _runtimeState.get(sessionId);
1742
+ const entry = _getRuntimeEntry(sessionId);
3655
1743
  if (entry && entry.generation === askGeneration) {
3656
1744
  _unlinkParentAbortListener(entry);
3657
1745
  entry.controller = null;
@@ -3730,7 +1818,7 @@ export function getSession(id) {
3730
1818
  export function listSessions(opts = {}) {
3731
1819
  const includeClosed = opts.includeClosed === true;
3732
1820
  const sessions = listStoredSessionSummaries();
3733
- const hiddenIds = new Set([..._runtimeState.entries()].filter(([, e]) => e.listHidden).map(([id]) => id));
1821
+ const hiddenIds = new Set([..._runtimeEntries()].filter(([, e]) => e.listHidden).map(([id]) => id));
3734
1822
  // Tombstoned sessions (closed===true) are excluded unless the caller opts in
3735
1823
  // (e.g. agent list includeClosed:true).
3736
1824
  return sessions.filter(s => !hiddenIds.has(s.id) && (includeClosed || s.closed !== true));
@@ -3817,6 +1905,7 @@ export async function clearSessionMessages(sessionId, options = {}) {
3817
1905
  const afterTokens = estimateTranscriptContextUsage(keep, session.tools || []);
3818
1906
  const now = Date.now();
3819
1907
  session.messages = keep;
1908
+ session.sessionStartMetaInjected = false;
3820
1909
  session.totalInputTokens = 0;
3821
1910
  session.totalOutputTokens = 0;
3822
1911
  session.totalCachedReadTokens = 0;
@@ -3906,12 +1995,21 @@ export async function updateSessionStatus(id, status) {
3906
1995
  * Long-term cleanup: `sweepTombstones()` below unlinks tombstones older than
3907
1996
  * TOMBSTONE_MAX_AGE_MS (24h — vastly longer than any realistic in-flight race).
3908
1997
  */
3909
- export function closeSession(id, reason = 'manual') {
1998
+ export function closeSession(id, reason = 'manual', opts = {}) {
1999
+ // tombstone=false: detach runtime resources (heartbeat, bash shells,
2000
+ // controller abort, runtime-map clear) WITHOUT planting the disk
2001
+ // tombstone. Used for non-empty sessions on /resume-away, /new, and
2002
+ // TUI exit — previously every one of those paths unconditionally
2003
+ // tombstoned the outgoing session, which made it vanish from the
2004
+ // Resume list immediately and get hard-deleted by sweepTombstones()
2005
+ // after 24h even though it had real conversation content worth
2006
+ // resuming. Only truly-empty scratch sessions should still tombstone.
2007
+ const tombstone = opts.tombstone !== false;
3910
2008
  if (!id) return false;
3911
2009
  _stopToolActivityHeartbeat(id);
3912
2010
  // Prefer in-memory runtime session — allBashSessionIds may not be persisted
3913
2011
  // yet for shells opened in the current turn (BL-bash-disk-sync).
3914
- const inMemory = _runtimeState.get(id)?.session;
2012
+ const inMemory = _getRuntimeEntry(id)?.session;
3915
2013
  const persisted = inMemory || loadSession(id);
3916
2014
  const bashSessionId = persisted?.implicitBashSessionId || null;
3917
2015
  // Collect all persistent bash shells created during this session.
@@ -3922,9 +2020,19 @@ export function closeSession(id, reason = 'manual') {
3922
2020
  // against old session records that only have implicitBashSessionId.
3923
2021
  if (bashSessionId && !allBashIds.includes(bashSessionId)) allBashIds.push(bashSessionId);
3924
2022
  // 1. Tombstone first — this wins the race against saveSession().
3925
- const newGen = markSessionClosed(id, reason);
2023
+ // Skipped when tombstone=false: no closed:true marker is planted, so
2024
+ // the session file stays intact and resumeSession() will accept it.
2025
+ // We still bump the on-disk generation via bumpSessionGeneration() —
2026
+ // that alone is what protects the session from a late save race: any
2027
+ // saveSession() still in flight from this detached turn (e.g. the
2028
+ // cancel-cleanup save below) carries the OLD generation as its
2029
+ // expectedGeneration, so _shouldDrop()'s ownership-counter rule drops
2030
+ // it once disk generation moves past that. Without this bump the late
2031
+ // write could silently overwrite the session after the user resumes
2032
+ // it back (BL: burned-session late-save clobber).
2033
+ const newGen = tombstone ? markSessionClosed(id, reason) : bumpSessionGeneration(id, reason);
3926
2034
  // 2. Mark runtime as closed so post-await validation in askSession fires.
3927
- const entry = _runtimeState.get(id);
2035
+ const entry = _getRuntimeEntry(id);
3928
2036
  if (entry) {
3929
2037
  entry.closed = true;
3930
2038
  entry.closedReason = reason;
@@ -3943,7 +2051,7 @@ export function closeSession(id, reason = 'manual') {
3943
2051
  try {
3944
2052
  const askStartedAt = entry?.askStartedAt;
3945
2053
  const durationMs = (typeof askStartedAt === 'number') ? (Date.now() - askStartedAt) : null;
3946
- const parts = [`session=${id}`, `reason=${reason}`];
2054
+ const parts = [`session=${id}`, `reason=${reason}`, `tombstone=${tombstone}`];
3947
2055
  if (durationMs != null) parts.push(`duration=${durationMs}ms`);
3948
2056
  if (process.env.MIXDOG_DEBUG_SESSION_LOG) process.stderr.write(`[agent-close] ${parts.join(' ')}\n`);
3949
2057
  } catch { /* best-effort */ }
@@ -3958,6 +2066,10 @@ export function closeSession(id, reason = 'manual') {
3958
2066
  // or Map entries across session lifetime. Fire-and-forget — close path
3959
2067
  // should not await disk IO; errors are swallowed inside.
3960
2068
  try { clearOffloadSession(id); } catch { /* ignore */ }
2069
+ // Drop the in-memory pending-message queue and any buffered-persist entry
2070
+ // for this session — otherwise both Maps accumulate one entry per closed
2071
+ // session for the life of the mcp-server.
2072
+ _dropPendingMessageState(id);
3961
2073
  // 4. Defer runtime map clear to next tick so any settling askSession can
3962
2074
  // observe `closed=true` / bumped generation before we yank the entry.
3963
2075
  // Disk tombstone remains — that's what blocks resurrection.
@@ -3969,7 +2081,7 @@ export function closeSession(id, reason = 'manual') {
3969
2081
  export function abortSessionTurn(id, reason = 'turn-abort') {
3970
2082
  if (!id) return false;
3971
2083
  _stopToolActivityHeartbeat(id);
3972
- const entry = _runtimeState.get(id);
2084
+ const entry = _getRuntimeEntry(id);
3973
2085
  if (!entry || entry.closed) return false;
3974
2086
  entry.stage = 'cancelling';
3975
2087
  entry.closedReason = reason;
@@ -4019,7 +2131,7 @@ function sweepIdleSessions({ includeTombstones = true } = {}) {
4019
2131
  // Skip entries with an active in-flight controller — aborting
4020
2132
  // them via closeSession() is the safe path; clearing the runtime
4021
2133
  // without signalling the controller leaves orphan provider work.
4022
- const rtEntry = _runtimeState.get(d.id);
2134
+ const rtEntry = _getRuntimeEntry(d.id);
4023
2135
  if (rtEntry && rtEntry.controller && !rtEntry.controller.signal?.aborted) {
4024
2136
  try { closeSession(d.id, 'idle-sweep'); } catch { /* ignore */ }
4025
2137
  } else {
@@ -4087,7 +2199,7 @@ export function sweepTombstones() {
4087
2199
  }
4088
2200
 
4089
2201
  function hasActiveRuntimeWork() {
4090
- for (const [, entry] of _runtimeState) {
2202
+ for (const [, entry] of _runtimeEntries()) {
4091
2203
  if (!entry || entry.closed === true) continue;
4092
2204
  if (entry.controller && !entry.controller.signal?.aborted) return true;
4093
2205
  if (['connecting', 'requesting', 'streaming', 'tool_running', 'cancelling'].includes(entry.stage)) return true;