mixdog 0.9.3 → 0.9.5

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 (382) hide show
  1. package/README.md +112 -38
  2. package/package.json +10 -3
  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/r5-orchestrated-task.json +7 -0
  7. package/scripts/bench/review-tasks.json +20 -0
  8. package/scripts/bench/round-codex.json +114 -0
  9. package/scripts/bench/round-mixdog-lead-r3.json +269 -0
  10. package/scripts/bench/round-mixdog-lead.json +269 -0
  11. package/scripts/bench/round-mixdog.json +126 -0
  12. package/scripts/bench/round-r10-bigsample.json +679 -0
  13. package/scripts/bench/round-r11-codexalign.json +257 -0
  14. package/scripts/bench/round-r13-clientmeta.json +464 -0
  15. package/scripts/bench/round-r14-betafeatures.json +466 -0
  16. package/scripts/bench/round-r15-fulldefault.json +462 -0
  17. package/scripts/bench/round-r16-sessionid.json +466 -0
  18. package/scripts/bench/round-r17-wirebytes.json +456 -0
  19. package/scripts/bench/round-r18-prewarm.json +468 -0
  20. package/scripts/bench/round-r19-clean.json +472 -0
  21. package/scripts/bench/round-r20-prewarm-clean.json +475 -0
  22. package/scripts/bench/round-r21-delta-retry.json +473 -0
  23. package/scripts/bench/round-r22-full-probe.json +693 -0
  24. package/scripts/bench/round-r23-itemprobe.json +701 -0
  25. package/scripts/bench/round-r24-shapefix.json +677 -0
  26. package/scripts/bench/round-r25-serial.json +464 -0
  27. package/scripts/bench/round-r26-parallel3.json +671 -0
  28. package/scripts/bench/round-r27-parallel10.json +894 -0
  29. package/scripts/bench/round-r28-parallel10-stagger.json +882 -0
  30. package/scripts/bench/round-r29-parallel10-stagger166.json +886 -0
  31. package/scripts/bench/round-r30-instid.json +253 -0
  32. package/scripts/bench/round-r31-upgradeprobe.json +256 -0
  33. package/scripts/bench/round-r32-vs-codex-lead.json +254 -0
  34. package/scripts/bench/round-r33-vs-codex-codex.json +115 -0
  35. package/scripts/bench/round-r34-orchestrated.json +120 -0
  36. package/scripts/bench/round-r35-orchestrated-codex.json +61 -0
  37. package/scripts/bench/round-r36-orchestrated-capped.json +128 -0
  38. package/scripts/bench/round-r4-codex.json +114 -0
  39. package/scripts/bench/round-r4-mixed.json +225 -0
  40. package/scripts/bench/round-r5-gpt-lead.json +259 -0
  41. package/scripts/bench/round-r6-codex.json +114 -0
  42. package/scripts/bench/round-r6-solo.json +257 -0
  43. package/scripts/bench/round-r7-full.json +254 -0
  44. package/scripts/bench/round-r8-fulldefault.json +255 -0
  45. package/scripts/bench-run.mjs +251 -32
  46. package/scripts/freevar-smoke.mjs +95 -0
  47. package/scripts/internal-comms-bench.mjs +3 -4
  48. package/scripts/internal-comms-smoke.mjs +10 -9
  49. package/scripts/model-catalog-audit.mjs +209 -0
  50. package/scripts/model-list-sanitize-test.mjs +37 -0
  51. package/scripts/mouse-probe.mjs +45 -0
  52. package/scripts/output-style-bench.mjs +13 -6
  53. package/scripts/output-style-smoke.mjs +4 -4
  54. package/scripts/provider-toolcall-test.mjs +7 -3
  55. package/scripts/recall-bench.mjs +76 -13
  56. package/scripts/recall-quality-cases.json +12 -0
  57. package/scripts/recall-usecase-cases.json +18 -0
  58. package/scripts/session-bench.mjs +152 -6
  59. package/scripts/tool-smoke.mjs +25 -65
  60. package/scripts/tui-render-smoke.mjs +90 -0
  61. package/scripts/webhook-smoke.mjs +208 -0
  62. package/src/agents/debugger/AGENT.md +4 -1
  63. package/src/agents/heavy-worker/AGENT.md +9 -8
  64. package/src/agents/maintainer/AGENT.md +4 -0
  65. package/src/agents/reviewer/AGENT.md +2 -1
  66. package/src/agents/scheduler-task/AGENT.md +2 -3
  67. package/src/agents/webhook-handler/AGENT.md +2 -3
  68. package/src/agents/worker/AGENT.md +10 -7
  69. package/src/app.mjs +12 -1
  70. package/src/headless-role.mjs +7 -1
  71. package/src/lib/rules-builder.cjs +4 -0
  72. package/src/mixdog-session-runtime.mjs +647 -2056
  73. package/src/output-styles/default.md +30 -9
  74. package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
  75. package/src/output-styles/minimal.md +8 -6
  76. package/src/output-styles/simple.md +21 -7
  77. package/src/rules/agent/00-common.md +6 -3
  78. package/src/rules/agent/30-explorer.md +16 -5
  79. package/src/rules/lead/01-general.md +5 -5
  80. package/src/rules/lead/lead-brief.md +15 -0
  81. package/src/rules/lead/lead-tool.md +6 -15
  82. package/src/rules/shared/01-tool.md +17 -21
  83. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +8 -3
  84. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
  85. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
  86. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
  87. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
  88. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
  89. package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
  90. package/src/runtime/agent/orchestrator/config.mjs +69 -2
  91. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +62 -20
  92. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
  93. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
  94. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +81 -1281
  95. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
  96. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +32 -3
  97. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
  98. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
  99. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
  100. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
  101. package/src/runtime/agent/orchestrator/providers/gemini.mjs +43 -1013
  102. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +17 -3
  103. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +105 -11
  104. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +356 -0
  105. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
  107. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
  108. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
  109. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +40 -1143
  110. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +740 -0
  111. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
  112. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +349 -2131
  113. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +143 -1002
  114. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +229 -0
  115. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
  116. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +465 -0
  117. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
  118. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
  119. package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +80 -0
  120. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
  121. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
  122. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
  123. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
  124. package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
  125. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
  126. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
  127. package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
  128. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +14 -2
  129. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
  130. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +1 -3
  131. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +275 -0
  132. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
  133. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
  134. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
  135. package/src/runtime/agent/orchestrator/session/loop.mjs +278 -402
  136. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
  137. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +7 -4
  138. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +12 -0
  139. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
  140. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
  141. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
  142. package/src/runtime/agent/orchestrator/session/manager.mjs +166 -1087
  143. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
  144. package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
  145. package/src/runtime/agent/orchestrator/stall-policy.mjs +20 -1
  146. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
  147. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
  148. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +35 -44
  149. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
  150. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
  151. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
  152. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
  153. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -92
  154. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
  155. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
  156. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +28 -265
  157. package/src/runtime/agent/orchestrator/tools/builtin.mjs +0 -6
  158. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +57 -3
  159. package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
  160. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +10 -122
  161. package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
  162. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
  163. package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
  164. package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
  165. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
  166. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
  167. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
  168. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
  169. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
  170. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
  171. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
  172. package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
  173. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +0 -21
  174. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -72
  175. package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
  176. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
  177. package/src/runtime/channels/backends/discord-access.mjs +32 -0
  178. package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
  179. package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
  180. package/src/runtime/channels/backends/discord.mjs +27 -318
  181. package/src/runtime/channels/backends/telegram.mjs +8 -12
  182. package/src/runtime/channels/index.mjs +247 -701
  183. package/src/runtime/channels/lib/backend-dispatch.mjs +46 -0
  184. package/src/runtime/channels/lib/config.mjs +37 -149
  185. package/src/runtime/channels/lib/event-pipeline.mjs +22 -5
  186. package/src/runtime/channels/lib/event-queue.mjs +78 -13
  187. package/src/runtime/channels/lib/inbound-routing.mjs +74 -0
  188. package/src/runtime/channels/lib/interaction-workflows.mjs +5 -113
  189. package/src/runtime/channels/lib/output-forwarder.mjs +1 -1
  190. package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
  191. package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
  192. package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
  193. package/src/runtime/channels/lib/scheduler.mjs +27 -113
  194. package/src/runtime/channels/lib/session-discovery.mjs +56 -4
  195. package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
  196. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  197. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
  198. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
  199. package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
  200. package/src/runtime/channels/lib/webhook/deliveries.mjs +313 -0
  201. package/src/runtime/channels/lib/webhook/log.mjs +42 -0
  202. package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
  203. package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
  204. package/src/runtime/channels/lib/webhook.mjs +43 -616
  205. package/src/runtime/channels/tool-defs.mjs +11 -130
  206. package/src/runtime/memory/index.mjs +210 -1948
  207. package/src/runtime/memory/lib/core-memory-store.mjs +5 -1
  208. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
  209. package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
  210. package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
  211. package/src/runtime/memory/lib/ko-morph.mjs +195 -0
  212. package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
  213. package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
  214. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
  215. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
  216. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
  217. package/src/runtime/memory/lib/memory-cycle2.mjs +24 -842
  218. package/src/runtime/memory/lib/memory-embed.mjs +149 -0
  219. package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
  220. package/src/runtime/memory/lib/memory-recall-store.mjs +69 -12
  221. package/src/runtime/memory/lib/memory-text-utils.mjs +46 -0
  222. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
  223. package/src/runtime/memory/lib/query-handlers.mjs +802 -0
  224. package/src/runtime/memory/lib/recall-format.mjs +55 -0
  225. package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
  226. package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
  227. package/src/runtime/memory/tool-defs.mjs +5 -13
  228. package/src/runtime/search/lib/http-fetch.mjs +274 -0
  229. package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
  230. package/src/runtime/search/lib/web-tools.mjs +24 -602
  231. package/src/runtime/shared/atomic-file.mjs +26 -1
  232. package/src/runtime/shared/config.mjs +14 -4
  233. package/src/runtime/shared/launcher-control.mjs +2 -2
  234. package/src/runtime/shared/markdown-frontmatter.mjs +19 -0
  235. package/src/runtime/shared/schedules-store.mjs +13 -3
  236. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  237. package/src/runtime/shared/tool-primitives.mjs +308 -0
  238. package/src/runtime/shared/tool-result-summary.mjs +515 -0
  239. package/src/runtime/shared/tool-surface.mjs +80 -898
  240. package/src/runtime/shared/transcript-writer.mjs +23 -0
  241. package/src/runtime/shared/update-checker.mjs +7 -4
  242. package/src/session-runtime/config-helpers.mjs +119 -2
  243. package/src/session-runtime/config-lifecycle.mjs +232 -0
  244. package/src/session-runtime/cwd-plugins.mjs +226 -0
  245. package/src/session-runtime/mcp-glue.mjs +177 -0
  246. package/src/session-runtime/model-recency.mjs +111 -0
  247. package/src/session-runtime/native-search.mjs +247 -0
  248. package/src/session-runtime/output-styles.mjs +11 -9
  249. package/src/session-runtime/prewarm.mjs +142 -0
  250. package/src/session-runtime/provider-models.mjs +278 -0
  251. package/src/session-runtime/provider-usage.mjs +120 -0
  252. package/src/session-runtime/quick-model-rows.mjs +205 -0
  253. package/src/session-runtime/quick-search-models.mjs +47 -0
  254. package/src/session-runtime/session-hooks.mjs +93 -0
  255. package/src/session-runtime/settings-api.mjs +352 -0
  256. package/src/session-runtime/tool-catalog.mjs +29 -29
  257. package/src/session-runtime/tool-defs.mjs +84 -0
  258. package/src/session-runtime/warmup-schedulers.mjs +201 -0
  259. package/src/session-runtime/workflow.mjs +1 -1
  260. package/src/standalone/agent-tool/helpers.mjs +237 -0
  261. package/src/standalone/agent-tool/notify.mjs +107 -0
  262. package/src/standalone/agent-tool/provider-init.mjs +143 -0
  263. package/src/standalone/agent-tool/render.mjs +152 -0
  264. package/src/standalone/agent-tool/tool-def.mjs +55 -0
  265. package/src/standalone/agent-tool.mjs +138 -669
  266. package/src/standalone/channel-admin.mjs +102 -90
  267. package/src/standalone/channel-worker.mjs +4 -7
  268. package/src/standalone/explore-tool.mjs +64 -14
  269. package/src/standalone/hook-bus/config.mjs +207 -0
  270. package/src/standalone/hook-bus/constants.mjs +90 -0
  271. package/src/standalone/hook-bus/handlers.mjs +481 -0
  272. package/src/standalone/hook-bus/payload.mjs +31 -0
  273. package/src/standalone/hook-bus/rules.mjs +77 -0
  274. package/src/standalone/hook-bus.mjs +77 -870
  275. package/src/standalone/memory-runtime-proxy.mjs +7 -0
  276. package/src/standalone/opencode-go-login.mjs +5 -1
  277. package/src/standalone/provider-admin.mjs +1 -16
  278. package/src/standalone/usage-dashboard.mjs +3 -1
  279. package/src/tui/App.jsx +1059 -8110
  280. package/src/tui/app/app-format.mjs +213 -0
  281. package/src/tui/app/channel-pickers.mjs +508 -0
  282. package/src/tui/app/clipboard.mjs +67 -0
  283. package/src/tui/app/core-memory-picker.mjs +210 -0
  284. package/src/tui/app/extension-pickers.mjs +506 -0
  285. package/src/tui/app/input-parsers.mjs +193 -0
  286. package/src/tui/app/maintenance-pickers.mjs +356 -0
  287. package/src/tui/app/model-options.mjs +334 -0
  288. package/src/tui/app/model-picker.mjs +365 -0
  289. package/src/tui/app/onboarding-steps.mjs +400 -0
  290. package/src/tui/app/project-picker.mjs +247 -0
  291. package/src/tui/app/provider-setup-picker.mjs +580 -0
  292. package/src/tui/app/resume-picker.mjs +55 -0
  293. package/src/tui/app/route-pickers.mjs +419 -0
  294. package/src/tui/app/settings-picker.mjs +489 -0
  295. package/src/tui/app/slash-commands.mjs +101 -0
  296. package/src/tui/app/slash-dispatch.mjs +427 -0
  297. package/src/tui/app/text-layout.mjs +46 -0
  298. package/src/tui/app/theme-effort-pickers.mjs +154 -0
  299. package/src/tui/app/transcript-window.mjs +677 -0
  300. package/src/tui/app/use-mouse-input.mjs +460 -0
  301. package/src/tui/app/use-prompt-handlers.mjs +310 -0
  302. package/src/tui/app/use-transcript-scroll.mjs +512 -0
  303. package/src/tui/app/use-transcript-window.mjs +607 -0
  304. package/src/tui/components/ConfirmBar.jsx +10 -7
  305. package/src/tui/components/Picker.jsx +64 -15
  306. package/src/tui/components/PromptInput.jsx +33 -102
  307. package/src/tui/components/SlashCommandPalette.jsx +8 -1
  308. package/src/tui/components/StatusLine.jsx +69 -15
  309. package/src/tui/components/TextEntryPanel.jsx +11 -0
  310. package/src/tui/components/ToolExecution.jsx +52 -594
  311. package/src/tui/components/TranscriptItem.jsx +105 -0
  312. package/src/tui/components/UsagePanel.jsx +18 -4
  313. package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
  314. package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
  315. package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
  316. package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
  317. package/src/tui/components/tool-execution/text-format.mjs +161 -0
  318. package/src/tui/display-width.mjs +20 -3
  319. package/src/tui/dist/index.mjs +13553 -12384
  320. package/src/tui/engine/agent-job-feed.mjs +133 -0
  321. package/src/tui/engine/notification-plan.mjs +76 -0
  322. package/src/tui/engine/render-timing.mjs +17 -0
  323. package/src/tui/engine/tool-approval.mjs +94 -0
  324. package/src/tui/engine/tool-card-results.mjs +234 -0
  325. package/src/tui/engine/tool-result-status.mjs +135 -0
  326. package/src/tui/engine.mjs +170 -574
  327. package/src/tui/figures.mjs +5 -0
  328. package/src/tui/index.jsx +65 -1
  329. package/src/tui/input-editing.mjs +2 -2
  330. package/src/tui/markdown/format-token.mjs +4 -1
  331. package/src/tui/statusline-ansi-bridge.mjs +11 -3
  332. package/src/tui/theme.mjs +6 -0
  333. package/src/ui/statusline-agents.mjs +213 -0
  334. package/src/ui/statusline-format.mjs +146 -0
  335. package/src/ui/statusline-segments.mjs +148 -0
  336. package/src/ui/statusline.mjs +77 -501
  337. package/src/ui/tool-card.mjs +0 -1
  338. package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
  339. package/src/workflows/default/WORKFLOW.md +16 -18
  340. package/src/workflows/sequential/WORKFLOW.md +16 -18
  341. package/vendor/ink/build/display-width.js +19 -3
  342. package/vendor/ink/build/ink.js +112 -7
  343. package/vendor/ink/build/log-update.js +17 -3
  344. package/vendor/ink/build/wrap-text.js +125 -0
  345. package/scripts/_test-folder-dialog.mjs +0 -30
  346. package/scripts/fix-brief-fn.mjs +0 -35
  347. package/scripts/fix-format-tool-surface.mjs +0 -24
  348. package/scripts/fix-tool-exec-visible.mjs +0 -42
  349. package/scripts/patch-agent-brief.mjs +0 -48
  350. package/scripts/patch-app.mjs +0 -21
  351. package/scripts/patch-app2.mjs +0 -18
  352. package/scripts/patch-dist-brief.mjs +0 -96
  353. package/scripts/patch-tool-exec.mjs +0 -70
  354. package/src/examples/schedules/SCHEDULE.example.md +0 -32
  355. package/src/examples/webhooks/WEBHOOK.example.md +0 -40
  356. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
  357. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
  358. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
  359. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +0 -162
  360. package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
  361. package/src/runtime/channels/lib/holidays.mjs +0 -138
  362. package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
  363. package/src/runtime/shared/task-notification-envelope.test.mjs +0 -107
  364. package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
  365. package/src/standalone/agent-task-status.test.mjs +0 -76
  366. package/src/tui/components/tool-output-format.test.mjs +0 -399
  367. package/src/tui/display-width.test.mjs +0 -35
  368. package/src/tui/engine-runtime-notification.test.mjs +0 -115
  369. package/src/tui/engine-tool-result-text.test.mjs +0 -75
  370. package/src/tui/input-editing.selection.test.mjs +0 -75
  371. package/src/tui/markdown/format-token.test.mjs +0 -354
  372. package/src/tui/markdown/render-ansi.test.mjs +0 -108
  373. package/src/tui/markdown/stream-fence.test.mjs +0 -26
  374. package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
  375. package/src/tui/paste-fix.test.mjs +0 -119
  376. package/src/tui/prompt-history-store.test.mjs +0 -52
  377. package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
  378. package/src/tui/transcript-tool-failures.test.mjs +0 -111
  379. package/src/ui/markdown.test.mjs +0 -70
  380. package/src/ui/statusline-context-label.test.mjs +0 -15
  381. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
  382. package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
@@ -1,28 +1,20 @@
1
1
  import { createRequire } from 'module';
2
2
  import { fileURLToPath } from 'url';
3
3
  import { randomBytes, createHash } from 'crypto';
4
- import { getProvider, providerInputExcludesCache } from '../providers/registry.mjs';
5
- import { fetchOAuthUsageSnapshot } from '../providers/oauth-usage.mjs';
4
+ import { getProvider } from '../providers/registry.mjs';
6
5
  // Image content is kept in-memory and in the model-visible history so multi-turn
7
6
  // recognition works reliably (live transcript always retains images). The
8
7
  // stored-history placeholder swap now happens only at disk-serialization time
9
8
  // inside the session store, so it is no longer imported here.
10
9
  import {
11
- recallFastTrackCompactMessages,
12
- semanticCompactMessages,
13
- pruneToolOutputsUnanchored,
14
- effectiveBudget as compactEffectiveBudget,
15
- compactTypeIsRecallFastTrack,
16
- compactTypeIsSemantic,
17
10
  normalizeCompactType,
18
11
  DEFAULT_COMPACT_TYPE,
19
12
  SUMMARY_PREFIX,
20
- drainSessionCycle1,
21
13
  } from './compact.mjs';
22
- import { estimateMessagesTokens, estimateRequestReserveTokens, estimateTranscriptContextUsage } from './context-utils.mjs';
14
+ import { estimateMessagesTokens, estimateTranscriptContextUsage } from './context-utils.mjs';
23
15
  import { executeInternalTool } from '../internal-tools.mjs';
24
16
  import { collectSkillsCached, buildSkillManifest, composeSystemPrompt } from '../context/collect.mjs';
25
- import { saveSession, saveSessionAsync, loadSession, listStoredSessionSummaries, sweepStaleSessions, markSessionClosed, publishHeartbeat, deleteHeartbeat, setLiveSession } from './store.mjs';
17
+ import { saveSession, saveSessionAsync, loadSession, listStoredSessionSummaries, sweepStaleSessions, markSessionClosed, bumpSessionGeneration, setLiveSession } from './store.mjs';
26
18
  import { clearReadDedupSession, tryPrefetchCached, setPrefetchCached } from './read-dedup.mjs';
27
19
  import { clearOffloadSession } from './tool-result-offload.mjs';
28
20
  import { classifyResultKind } from './result-classification.mjs';
@@ -31,12 +23,16 @@ import { logLlmCall } from '../../../shared/llm/usage-log.mjs';
31
23
  import { appendAgentTrace } from '../agent-trace.mjs';
32
24
  import { isAgentOwner } from '../agent-owner.mjs';
33
25
  import { getHiddenAgent } from '../internal-agents.mjs';
34
- import { DEFAULT_ACTIVITY_HEARTBEAT_MS } from '../stall-policy.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';
35
29
  import {
36
- buildGatewayLimits,
37
- recordGatewayUsageEvent,
38
- summarizeGatewayUsage,
39
- } from '../providers/statusline-route-meta.mjs';
30
+ recordStandaloneStatusTelemetry,
31
+ } from './manager/status-telemetry.mjs';
32
+ import {
33
+ normalizeStaleCompactingStage,
34
+ runSessionCompaction,
35
+ } from './manager/compaction-runner.mjs';
40
36
  // Split modules — see manager/ directory. manager.mjs is now a thin facade that
41
37
  // orchestrates these cohesive units while keeping the exact public surface.
42
38
  import {
@@ -59,9 +55,6 @@ import {
59
55
  preserveBufferConfigFields,
60
56
  resolveSessionContextMeta,
61
57
  compactTriggerForSession,
62
- compactTargetBudget,
63
- semanticCompactionEnabledForSession,
64
- compactTypeForSession,
65
58
  } from './manager/context-meta.mjs';
66
59
  import {
67
60
  promptContentText,
@@ -80,11 +73,89 @@ import {
80
73
  drainPendingMessages,
81
74
  _dropPendingMessageState,
82
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 });
83
142
  // Re-export split-module public/test surface unchanged so every importer of the
84
143
  // old symbol names keeps resolving through the facade.
85
144
  export { previewSessionTools };
86
145
  export { _mergePendingMessageEntries, enqueuePendingMessage, drainPendingMessages };
87
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
+ };
88
159
  let _codeGraphRuntimePromise = null;
89
160
  let _agentLoopPromise = null;
90
161
  let _bashSessionRuntimePromise = null;
@@ -146,7 +217,6 @@ export class SessionClosedError extends Error {
146
217
  this.reason = closeReason || null;
147
218
  }
148
219
  }
149
- const HEARTBEAT_THROTTLE_MS = 60_000; // 60s
150
220
  // Cap how long the terminal unwind blocks on the post-result session save.
151
221
  // The result is already produced (and relayed for agent surfaces) before this
152
222
  // save, so a stalled disk write must not hold askSession() open — otherwise the
@@ -186,442 +256,6 @@ let nextId = Date.now();
186
256
  export const _resolveSessionContextMeta = resolveSessionContextMeta;
187
257
  export const _compactTriggerForSession = compactTriggerForSession;
188
258
  export const _preserveBufferConfigFields = preserveBufferConfigFields;
189
- // 'compacting' is a transient in-flight stage written just before semantic /
190
- // recall-fasttrack compaction runs. If the process crashes or only partially
191
- // saves while it is set, a later load/resume reads a session that is NOT
192
- // actually compacting but whose UI marker (App.jsx / ContextPanel) shows
193
- // "Compacting conversation" permanently. Normalize that stale transient stage
194
- // to 'interrupted' so the surface recovers. Terminal stages (post_turn /
195
- // manual / auto_clear / *_failed / overflow_failed) are intentionally left as
196
- // the durable record of the last real outcome.
197
- function normalizeStaleCompactingStage(session) {
198
- const c = session?.compaction;
199
- if (!c || typeof c !== 'object') return false;
200
- if (c.lastStage !== 'compacting' && c.inProgress !== true) return false;
201
- c.lastStage = 'interrupted';
202
- c.inProgress = false;
203
- c.lastCheckedAt = Date.now();
204
- return true;
205
- }
206
- function addCompactUsageToSession(session, usage) {
207
- if (!session || !usage) return;
208
- const inputTokens = usage.inputTokens || 0;
209
- const outputTokens = usage.outputTokens || 0;
210
- const cachedTokens = usage.cachedTokens || 0;
211
- const cacheWriteTokens = usage.cacheWriteTokens || 0;
212
- const uncachedInputTokens = uncachedInputTokensForProvider(session.provider, inputTokens, cachedTokens, cacheWriteTokens);
213
- session.totalInputTokens = (session.totalInputTokens || 0) + inputTokens;
214
- session.totalOutputTokens = (session.totalOutputTokens || 0) + outputTokens;
215
- session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + cachedTokens;
216
- session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + cacheWriteTokens;
217
- session.totalUncachedInputTokens = (session.totalUncachedInputTokens || 0) + uncachedInputTokens;
218
- session.tokensCumulative = (session.tokensCumulative || 0) + inputTokens + outputTokens;
219
- }
220
- async function runRecallFastTrackForSession(session, messages, opts = {}) {
221
- const sessionId = opts.sessionId || session?.id || null;
222
- if (!sessionId) throw new Error('recall-fasttrack requires a session id');
223
- const query = `session:${sessionId}:all-chunks`;
224
- const querySha = createHash('sha256').update(query).digest('hex').slice(0, 16);
225
- const callerCtx = {
226
- callerSessionId: sessionId,
227
- callerCwd: session?.cwd || undefined,
228
- routingSessionId: sessionId,
229
- clientHostPid: session?.clientHostPid,
230
- signal: opts.signal || null,
231
- };
232
- const hydrateLimit = positiveContextWindow(session?.compaction?.recallIngestLimit)
233
- || Math.max(500, Math.min(5000, messages.length || 0));
234
- try {
235
- await executeInternalTool('memory', {
236
- action: 'ingest_session',
237
- sessionId,
238
- messages,
239
- cwd: session?.cwd,
240
- limit: hydrateLimit,
241
- }, callerCtx);
242
- } catch (err) {
243
- try { process.stderr.write(`[session] recall-fasttrack ingest skipped (sess=${sessionId}): ${err?.message || err}\n`); } catch {}
244
- }
245
- const dumpArgs = {
246
- action: 'dump_session_roots',
247
- sessionId,
248
- includeRaw: true,
249
- limit: positiveContextWindow(session?.compaction?.recallChunkLimit ?? session?.compaction?.recallLimit) || hydrateLimit,
250
- };
251
- const runTool = (name, args) => executeInternalTool(name, args, callerCtx);
252
- let recallText = await executeInternalTool('memory', dumpArgs, callerCtx);
253
- let cycle1Text = '';
254
- const hasRawRows = /(?:^|\n)# raw_pending\s+\d+\s+id=/i.test(String(recallText || ''));
255
- if (hasRawRows) {
256
- try {
257
- // Drain this session's cycle1 in window×concurrency units until no
258
- // raw rows remain, so the injected root is fully chunked rather than
259
- // carrying the unprocessed transcript tail (single-pass left raw in).
260
- const drained = await drainSessionCycle1(runTool, {
261
- sessionId,
262
- dumpArgs,
263
- deadlineMs: positiveContextWindow(session?.compaction?.recallCycle1DeadlineMs) || 120_000,
264
- maxPasses: positiveContextWindow(session?.compaction?.recallCycle1MaxPasses) || 0,
265
- cycleArgs: {
266
- min_batch: 1,
267
- session_cap: 1,
268
- batch_size: positiveContextWindow(session?.compaction?.recallCycle1BatchSize) || 100,
269
- rows_per_session: positiveContextWindow(session?.compaction?.recallRowsPerSession) || 100,
270
- window_size: positiveContextWindow(session?.compaction?.recallWindowSize) || 20,
271
- concurrency: positiveContextWindow(session?.compaction?.recallConcurrency) || 5,
272
- },
273
- });
274
- recallText = drained.recallText;
275
- cycle1Text = drained.cycle1Text;
276
- if (drained.rawRemaining > 0) {
277
- try { process.stderr.write(`[session] recall-fasttrack drained passes=${drained.passes} rawRemaining=${drained.rawRemaining} (sess=${sessionId})\n`); } catch {}
278
- }
279
- } catch (err) {
280
- try { process.stderr.write(`[session] recall-fasttrack cycle1 skipped (sess=${sessionId}): ${err?.message || err}\n`); } catch {}
281
- }
282
- } else {
283
- cycle1Text = 'cycle1: skipped (session chunks already hydrated)';
284
- }
285
- return { query, querySha, recallText: [`session_id=${sessionId}`, cycle1Text, recallText].map(v => String(v || '').trim()).filter(Boolean).join('\n\n') };
286
- }
287
- // Element-identity change detection (same approach as loop.mjs messagesArrayChanged): two
288
- // arrays are "unchanged" only when same length AND every slot is the same object
289
- // reference. Used to reject a no-op prune (which returns a fresh array whose
290
- // elements are the untouched originals) from being accepted as a recovery.
291
- function messagesChanged(before, after) {
292
- if (!Array.isArray(before) || !Array.isArray(after)) return before !== after;
293
- if (before.length !== after.length) return true;
294
- for (let i = 0; i < before.length; i += 1) {
295
- if (before[i] !== after[i]) return true;
296
- }
297
- return false;
298
- }
299
- async function runSessionCompaction(session, opts = {}) {
300
- if (!session || session.closed === true) return null;
301
- const mode = opts.mode === 'auto' ? 'auto' : 'manual';
302
- const force = opts.force === true || mode === 'manual';
303
- if (mode === 'auto' && session.compaction?.auto === false) return null;
304
- const messages = Array.isArray(session.messages) ? session.messages : [];
305
- if (messages.length < 3 && !force) return null;
306
- const boundary = positiveContextWindow(session.compactBoundaryTokens)
307
- || positiveContextWindow(session.autoCompactTokenLimit)
308
- || positiveContextWindow(session.contextWindow);
309
- if (!boundary) {
310
- if (force) throw new Error('compact: no context window is available for this session');
311
- return null;
312
- }
313
- // Reserve must mirror loop.mjs (buildCompactPolicy): request reserve (tool
314
- // schema) PLUS the configured reserve (session.compaction.reservedTokens or
315
- // MIXDOG_AGENT_COMPACT_RESERVED_TOKENS env). The old request-only value left
316
- // the manual / auto-clear compact budget without the configured headroom the
317
- // loop path reserves, so a compacted transcript could overflow on next send.
318
- const requestReserveTokens = estimateRequestReserveTokens(session.tools || []);
319
- const configuredReserveTokens = positiveContextWindow(session.compaction?.reservedTokens)
320
- || positiveContextWindow(process.env.MIXDOG_AGENT_COMPACT_RESERVED_TOKENS)
321
- || 0;
322
- const reserveTokens = requestReserveTokens + configuredReserveTokens;
323
- const beforeMessageTokens = estimateMessagesTokens(messages);
324
- const triggerTokens = compactTriggerForSession(session, boundary)
325
- || positiveContextWindow(session.compaction?.triggerTokens)
326
- || boundary;
327
- const bufferTokens = Math.max(0, boundary - triggerTokens);
328
- const bufferRatio = boundary ? (bufferTokens / boundary) : compactBufferRatioForSession(session);
329
- const pressureTokens = estimateTranscriptContextUsage(messages, session.tools || []);
330
- const beforeTokens = pressureTokens;
331
- const compactType = compactTypeForSession(session);
332
- if (!force && pressureTokens < triggerTokens) return {
333
- changed: false,
334
- reason: 'below threshold',
335
- compactType,
336
- beforeMessages: messages.length,
337
- afterMessages: messages.length,
338
- beforeTokens,
339
- afterTokens: beforeTokens,
340
- beforeMessageTokens,
341
- afterMessageTokens: beforeMessageTokens,
342
- pressureTokens,
343
- triggerTokens,
344
- bufferTokens,
345
- bufferRatio,
346
- boundaryTokens: boundary,
347
- budgetTokens: boundary,
348
- targetBudgetTokens: boundary,
349
- reserveTokens,
350
- semanticCompact: false,
351
- };
352
- const budgetSourceTokens = force ? Math.max(pressureTokens, triggerTokens) : pressureTokens;
353
- const compactBudget = compactTargetBudget(boundary, reserveTokens, budgetSourceTokens);
354
- const budget = compactBudget || boundary;
355
- try { opts.onStageChange?.('compacting'); } catch { /* best-effort */ }
356
- const provider = opts.provider || getProvider(session.provider) || null;
357
- let compacted;
358
- let compactError = null;
359
- let semanticCompactResult = null;
360
- let semanticCompactError = null;
361
- let recallFastTrackResult = null;
362
- let recallFastTrackError = null;
363
- if (compactTypeIsRecallFastTrack(compactType)) {
364
- try {
365
- const recallPayload = await runRecallFastTrackForSession(session, messages, opts);
366
- recallFastTrackResult = recallFastTrackCompactMessages(messages, budget, {
367
- reserveTokens,
368
- force: true,
369
- recallText: recallPayload.recallText,
370
- query: recallPayload.query,
371
- querySha: recallPayload.querySha,
372
- // Ingest just ran on the live transcript, so an empty recall dump
373
- // means the memory pipeline is broken — do NOT erase history
374
- // behind an empty summary shell. Empty recall now throws and is
375
- // handled by the semantic fallback below (or recorded failure).
376
- allowEmptyRecall: false,
377
- tailTurns: positiveContextWindow(session.compaction?.tailTurns) || 2,
378
- keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens),
379
- preserveRecentTokens: positiveContextWindow(session.compaction?.preserveRecentTokens),
380
- });
381
- if (Array.isArray(recallFastTrackResult?.messages)) {
382
- compacted = recallFastTrackResult.messages;
383
- }
384
- } catch (err) {
385
- recallFastTrackError = err;
386
- compactError = err;
387
- try {
388
- process.stderr.write(`[session] recall-fasttrack ${mode} compact failed (sess=${session.id || 'unknown'}): ${err?.message || err}\n`);
389
- } catch { /* best-effort */ }
390
- // Degraded-compact fallback: recall-fasttrack failed (empty recall,
391
- // ingest error, fit failure). Before recording a hard failure, try
392
- // the semantic path once so auto-clear/manual compaction still makes
393
- // progress WITHOUT shipping an empty-recall summary. History is only
394
- // replaced when the semantic summary actually succeeds.
395
- if (semanticCompactionEnabledForSession(session)
396
- && provider && typeof provider.send === 'function') {
397
- try {
398
- semanticCompactResult = await semanticCompactMessages(
399
- provider,
400
- messages,
401
- opts.model || session.model,
402
- budget,
403
- {
404
- reserveTokens,
405
- providerName: session.provider || provider?.name || null,
406
- sessionId: opts.sessionId || session.id || null,
407
- signal: opts.signal || null,
408
- promptCacheKey: session.promptCacheKey || null,
409
- providerCacheKey: session.promptCacheKey || null,
410
- timeoutMs: positiveContextWindow(session.compaction?.timeoutMs) || 30_000,
411
- tailTurns: positiveContextWindow(session.compaction?.tailTurns) || 2,
412
- keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens),
413
- preserveRecentTokens: positiveContextWindow(session.compaction?.preserveRecentTokens),
414
- force: true,
415
- },
416
- );
417
- if (Array.isArray(semanticCompactResult?.messages)) {
418
- compacted = semanticCompactResult.messages;
419
- compactError = null;
420
- addCompactUsageToSession(session, semanticCompactResult.usage);
421
- try {
422
- process.stderr.write(`[session] degraded compact: recall-fasttrack failed, semantic fallback succeeded (sess=${session.id || 'unknown'}, mode=${mode})\n`);
423
- } catch { /* best-effort */ }
424
- }
425
- } catch (fallbackErr) {
426
- semanticCompactError = fallbackErr;
427
- try {
428
- process.stderr.write(`[session] degraded compact: semantic fallback also failed (sess=${session.id || 'unknown'}): ${fallbackErr?.message || fallbackErr}\n`);
429
- } catch { /* best-effort */ }
430
- }
431
- }
432
- }
433
- } else if (compactTypeIsSemantic(compactType)) {
434
- try {
435
- if (!semanticCompactionEnabledForSession(session)) {
436
- throw new Error('semantic compact is disabled for this session');
437
- }
438
- if (!provider || typeof provider.send !== 'function') {
439
- throw new Error(`semantic compact provider unavailable: ${session.provider || 'unknown'}`);
440
- }
441
- semanticCompactResult = await semanticCompactMessages(
442
- provider,
443
- messages,
444
- opts.model || session.model,
445
- budget,
446
- {
447
- reserveTokens,
448
- providerName: session.provider || provider?.name || null,
449
- sessionId: opts.sessionId || session.id || null,
450
- signal: opts.signal || null,
451
- promptCacheKey: session.promptCacheKey || null,
452
- providerCacheKey: session.promptCacheKey || null,
453
- timeoutMs: positiveContextWindow(session.compaction?.timeoutMs) || 30_000,
454
- tailTurns: positiveContextWindow(session.compaction?.tailTurns) || 2,
455
- keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens),
456
- preserveRecentTokens: positiveContextWindow(session.compaction?.preserveRecentTokens),
457
- force: true,
458
- },
459
- );
460
- if (Array.isArray(semanticCompactResult?.messages)) {
461
- compacted = semanticCompactResult.messages;
462
- addCompactUsageToSession(session, semanticCompactResult.usage);
463
- }
464
- } catch (err) {
465
- semanticCompactError = err;
466
- compactError = err;
467
- try {
468
- process.stderr.write(`[session] semantic ${mode} compact failed (sess=${session.id || 'unknown'}): ${err?.message || err}\n`);
469
- } catch { /* best-effort */ }
470
- }
471
- }
472
- if (!compacted && !compactError) {
473
- compactError = new Error(`${compactType} compact produced no messages`);
474
- }
475
- // Anchor-independent prune safety net (mirror loop.mjs compact catch): when a
476
- // non-recall (semantic) compact failed, try one non-LLM prune that needs no
477
- // user anchor before recording failure, so Lead manual / auto-clear paths
478
- // recover the same transcripts the loop path does. Gated off the recall
479
- // path — a recall failure keeps its original contract (no silent prune).
480
- if (!compacted && !recallFastTrackError) {
481
- try {
482
- const acceptThreshold = compactEffectiveBudget(budget, { reserveTokens });
483
- const salvaged = pruneToolOutputsUnanchored(messages, budget, { reserveTokens });
484
- // pruneToolOutputsUnanchored ALWAYS returns a fresh reconciled array
485
- // (never the input identity), so `salvaged !== messages` is always
486
- // true and cannot detect a no-op. Compare by element identity so a
487
- // transcript that already fit (nothing pruned) is NOT falsely accepted
488
- // as a recovery — that would clear compactError and unconditionally
489
- // invalidate providerState for an unchanged transcript.
490
- if (Array.isArray(salvaged)
491
- && messagesChanged(messages, salvaged)
492
- && estimateMessagesTokens(salvaged) <= acceptThreshold) {
493
- compacted = salvaged;
494
- compactError = null;
495
- try {
496
- process.stderr.write(`[session] compact fallback prune recovered (sess=${session.id || 'unknown'}, mode=${mode})\n`);
497
- } catch { /* best-effort */ }
498
- }
499
- } catch { /* fall through to failure record */ }
500
- }
501
- if (!compacted) {
502
- const now = Date.now();
503
- session.compaction = {
504
- ...(session.compaction || {}),
505
- auto: mode === 'auto' ? true : session.compaction?.auto !== false,
506
- boundaryTokens: boundary,
507
- triggerTokens,
508
- bufferTokens,
509
- bufferRatio,
510
- reserveTokens,
511
- lastStage: mode === 'auto' ? 'post_turn_failed' : 'manual_failed',
512
- lastBeforeTokens: beforeTokens,
513
- lastAfterTokens: beforeTokens,
514
- lastBeforeMessageTokens: beforeMessageTokens,
515
- lastAfterMessageTokens: beforeMessageTokens,
516
- lastPressureTokens: pressureTokens,
517
- lastCheckedAt: now,
518
- lastChanged: false,
519
- type: compactType,
520
- compactType,
521
- lastCompactType: compactType,
522
- lastSemantic: false,
523
- lastSemanticError: semanticCompactError?.message || null,
524
- lastRecallFastTrack: false,
525
- lastRecallFastTrackError: recallFastTrackError?.message || null,
526
- lastError: compactError?.message || semanticCompactError?.message || recallFastTrackError?.message || String(compactError || semanticCompactError || recallFastTrackError || 'compact failed'),
527
- };
528
- return {
529
- changed: false,
530
- error: session.compaction.lastError,
531
- compactType,
532
- beforeMessages: messages.length,
533
- afterMessages: messages.length,
534
- beforeTokens,
535
- afterTokens: beforeTokens,
536
- beforeMessageTokens,
537
- afterMessageTokens: beforeMessageTokens,
538
- pressureTokens,
539
- triggerTokens,
540
- bufferTokens,
541
- bufferRatio,
542
- boundaryTokens: boundary,
543
- budgetTokens: boundary,
544
- targetBudgetTokens: budget,
545
- reserveTokens,
546
- semanticCompact: false,
547
- semanticError: semanticCompactError?.message || null,
548
- recallFastTrack: false,
549
- recallFastTrackError: recallFastTrackError?.message || null,
550
- };
551
- }
552
- let beforeEncoded = '';
553
- let afterEncoded = '';
554
- try { beforeEncoded = JSON.stringify(messages); } catch { beforeEncoded = ''; }
555
- try { afterEncoded = JSON.stringify(compacted); } catch { afterEncoded = ''; }
556
- const afterMessageTokens = estimateMessagesTokens(compacted);
557
- const afterTokens = afterMessageTokens + reserveTokens;
558
- const changed = beforeEncoded && afterEncoded
559
- ? beforeEncoded !== afterEncoded
560
- : (compacted.length !== messages.length || afterMessageTokens !== beforeMessageTokens);
561
- const unchangedReason = changed ? null : (force ? 'nothing to compact' : 'below threshold');
562
- const now = Date.now();
563
- session.messages = compacted;
564
- session.providerState = undefined;
565
- session.compaction = {
566
- ...(session.compaction || {}),
567
- auto: mode === 'auto' ? true : session.compaction?.auto !== false,
568
- boundaryTokens: boundary,
569
- triggerTokens,
570
- bufferTokens,
571
- bufferRatio,
572
- reserveTokens,
573
- type: compactType,
574
- compactType,
575
- lastCompactType: compactType,
576
- lastStage: mode === 'auto' ? 'post_turn' : 'manual',
577
- lastBeforeTokens: beforeTokens,
578
- lastAfterTokens: afterTokens,
579
- lastBeforeMessageTokens: beforeMessageTokens,
580
- lastAfterMessageTokens: afterMessageTokens,
581
- lastPressureTokens: pressureTokens,
582
- lastCheckedAt: now,
583
- lastChanged: changed,
584
- lastChangedAt: changed ? now : session.compaction?.lastChangedAt || null,
585
- lastCompactAt: changed ? now : session.compaction?.lastCompactAt || null,
586
- lastSemantic: semanticCompactResult?.semantic === true,
587
- lastSemanticError: semanticCompactError?.message || null,
588
- lastRecallFastTrack: recallFastTrackResult?.recallFastTrack === true,
589
- lastRecallFastTrackError: recallFastTrackError?.message || null,
590
- lastRecallFastTrackQuerySha: recallFastTrackResult?.query ? createHash('sha256').update(recallFastTrackResult.query).digest('hex').slice(0, 16) : null,
591
- lastSemanticUsage: semanticCompactResult?.usage ? {
592
- inputTokens: semanticCompactResult.usage.inputTokens || 0,
593
- outputTokens: semanticCompactResult.usage.outputTokens || 0,
594
- cachedTokens: semanticCompactResult.usage.cachedTokens || 0,
595
- cacheWriteTokens: semanticCompactResult.usage.cacheWriteTokens || 0,
596
- } : null,
597
- compactCount: (session.compaction?.compactCount || 0) + (changed ? 1 : 0),
598
- };
599
- if (changed && mode === 'auto') session.lastContextTokensStaleAfterCompact = true;
600
- return {
601
- changed,
602
- reason: unchangedReason,
603
- compactType,
604
- beforeMessages: messages.length,
605
- afterMessages: compacted.length,
606
- beforeTokens,
607
- afterTokens,
608
- beforeMessageTokens,
609
- afterMessageTokens,
610
- pressureTokens,
611
- triggerTokens,
612
- bufferTokens,
613
- bufferRatio,
614
- boundaryTokens: boundary,
615
- budgetTokens: boundary,
616
- targetBudgetTokens: budget,
617
- reserveTokens,
618
- semanticCompact: semanticCompactResult?.semantic === true,
619
- semanticError: semanticCompactError?.message || null,
620
- recallFastTrack: recallFastTrackResult?.recallFastTrack === true,
621
- recallFastTrackError: recallFastTrackError?.message || null,
622
- usage: semanticCompactResult?.usage || null,
623
- };
624
- }
625
259
  // Provider-scoped unified cache key. Goal: all orchestrator-internal
626
260
  // dispatches (agent/maintenance/mcp/scheduler/webhook) targeting the
627
261
  // same provider land in a single server-side cache shard, so the
@@ -965,6 +599,34 @@ export function createSession(opts) {
965
599
  if (!provider)
966
600
  throw new Error(`Provider "${providerName}" not found or not enabled`);
967
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
+ }
968
630
  const messages = [];
969
631
  const ownerIsAgent = isAgentOwner(opts.owner);
970
632
  const resolvedAgent = opts.agent || opts.role || profile?.taskType || null;
@@ -1204,69 +866,11 @@ export function createSession(opts) {
1204
866
  return session;
1205
867
  }
1206
868
 
1207
- // ── Runtime liveness map ──────────────────────────────────────────────
1208
- // In-memory only. Tracks per-session stage + stream heartbeat so agent type=list
1209
- // can surface whether a session is actually alive vs stuck. Never persisted —
1210
- // heartbeats would otherwise churn the session JSON on every SSE delta.
1211
- // Entry shape: {
1212
- // stage, lastStreamDeltaAt, lastToolCall, lastError, updatedAt,
1213
- // controller?: AbortController, // set while an ask is in flight
1214
- // generation?: number, // snapshot taken at ask start
1215
- // closed?: boolean, // flipped by closeSession()
1216
- // }
1217
- const _runtimeState = new Map();
1218
- const _toolActivityHeartbeats = new Map();
1219
- const VALID_STAGES = new Set([
1220
- 'connecting', 'requesting', 'streaming', 'tool_running', 'idle', 'error', 'done', 'cancelling',
1221
- ]);
1222
- function _touchRuntime(id) {
1223
- let entry = _runtimeState.get(id);
1224
- if (!entry) {
1225
- entry = { stage: 'idle', lastStreamDeltaAt: null, lastToolCall: null, lastError: null, updatedAt: Date.now() };
1226
- _runtimeState.set(id, entry);
1227
- }
1228
- return entry;
1229
- }
1230
-
1231
- function _stopToolActivityHeartbeat(id) {
1232
- if (!id) return;
1233
- const timer = _toolActivityHeartbeats.get(id);
1234
- if (!timer) return;
1235
- try { clearInterval(timer); } catch { /* ignore */ }
1236
- _toolActivityHeartbeats.delete(id);
1237
- }
1238
-
1239
- function _touchSessionActivityProgress(id) {
1240
- const entry = _runtimeState.get(id);
1241
- if (!entry || entry.closed || entry.controller?.signal?.aborted) return;
1242
- if (entry.stage !== 'tool_running') return;
1243
- const now = Date.now();
1244
- entry.lastProgressAt = now;
1245
- entry.updatedAt = now;
1246
- publishHeartbeat(id, now);
1247
- }
1248
-
1249
- function _startToolActivityHeartbeat(id) {
1250
- _stopToolActivityHeartbeat(id);
1251
- if (!(DEFAULT_ACTIVITY_HEARTBEAT_MS > 0)) return;
1252
- const timer = setInterval(() => _touchSessionActivityProgress(id), DEFAULT_ACTIVITY_HEARTBEAT_MS);
1253
- if (typeof timer.unref === 'function') timer.unref();
1254
- _toolActivityHeartbeats.set(id, timer);
1255
- }
1256
-
1257
- export function updateSessionStage(id, stage) {
1258
- if (!id || !VALID_STAGES.has(stage)) return;
1259
- const entry = _touchRuntime(id);
1260
- const now = Date.now();
1261
- entry.stage = stage;
1262
- if (stage === 'connecting' || stage === 'requesting') {
1263
- entry.modelRequestStartedAt = now;
1264
- }
1265
- entry.lastProgressAt = now;
1266
- entry.updatedAt = now;
1267
- if (stage !== 'tool_running') _stopToolActivityHeartbeat(id);
1268
- }
1269
-
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).
1270
874
  export function updateSessionRoute(id, route = {}) {
1271
875
  if (!id) return null;
1272
876
  const session = loadSession(id);
@@ -1322,475 +926,15 @@ export function updateSessionRoute(id, route = {}) {
1322
926
  return session;
1323
927
  }
1324
928
 
1325
- /**
1326
- * Reset heartbeat-visible fields for a new ask. Preserves controller/generation/
1327
- * closed (lifecycle) but clears the previous run's streaming state so stale
1328
- * lastToolCall / lastStreamDeltaAt from the previous ask don't leak into the
1329
- * new one.
1330
- */
1331
- export function markSessionAskStart(id) {
1332
- if (!id) return;
1333
- _stopToolActivityHeartbeat(id);
1334
- const entry = _touchRuntime(id);
1335
- entry.usageMetricsTurnIncremental = false;
1336
- const sessionForTurn = entry.session ?? loadSession(id);
1337
- if (sessionForTurn) bumpUsageMetricsTurnId(sessionForTurn);
1338
- entry.stage = 'connecting';
1339
- entry.lastStreamDeltaAt = null;
1340
- entry.lastToolCall = null;
1341
- entry.toolStartedAt = null;
1342
- entry.lastError = null;
1343
- // A new ask starts a fresh turn lifecycle — clear any stale empty-final
1344
- // classification from the prior turn so inspectBridgeEntry doesn't keep
1345
- // short-circuiting to 'empty-synthesis' (which would disable stall
1346
- // detection for the entire new turn).
1347
- entry.emptyFinal = false;
1348
- entry.emptyFinalAt = null;
1349
- // askStartedAt is the watchdog's fallback reference when a session
1350
- // hangs before any stream delta arrives. Without it, a provider that
1351
- // never returns a first token would stall forever because the watchdog
1352
- // keys solely on lastStreamDeltaAt.
1353
- const now = Date.now();
1354
- entry.askStartedAt = now;
1355
- entry.modelRequestStartedAt = now;
1356
- entry.lastProgressAt = now;
1357
- entry.updatedAt = now;
1358
- // Publish heartbeat immediately so the status aggregator picks the
1359
- // session up in the connecting / requesting window. Without this the
1360
- // .hb file only landed on the first stream chunk — producing a 3–10s
1361
- // (xhigh: 30s+) invisible gap where agent sessions ran but the CC
1362
- // statusline showed no maintenance/agent badge. STREAM_FRESH_MS (5 min)
1363
- // still drops a session whose provider truly never returns a chunk;
1364
- // markSessionStreamDelta keeps refreshing once chunks arrive.
1365
- publishHeartbeat(id, now);
1366
- }
1367
- export async function markSessionStreamDelta(id) {
1368
- if (!id) return;
1369
- // Non-creating lookup: a live ask ALWAYS has a runtime entry (markSessionAskStart
1370
- // creates it before streaming begins). _touchRuntime would instead resurrect a
1371
- // blank entry — and closeSession()/idle-sweep clear _runtimeState on a deferred
1372
- // tick while a detached provider stream may still be trickling deltas. A delta
1373
- // arriving after that clear must NOT re-create an entry or it would republish the
1374
- // .hb heartbeat that markSessionClosed deleted, orphaning a dead session's
1375
- // heartbeat indefinitely (the disk tombstone blocks ask resumption but not this
1376
- // path). Skip a missing, tombstoned, or aborted entry — never refresh liveness.
1377
- const entry = _runtimeState.get(id);
1378
- if (!entry || entry.closed || entry.controller?.signal?.aborted) return;
1379
- _stopToolActivityHeartbeat(id);
1380
- const now = Date.now();
1381
- entry.lastStreamDeltaAt = now;
1382
- entry.lastProgressAt = now;
1383
- // Only promote to 'streaming' if we were in a pre-stream stage; never downgrade
1384
- // mid-tool (tool_running has its own delta source if the tool streams back).
1385
- if (entry.stage === 'connecting' || entry.stage === 'requesting') {
1386
- entry.stage = 'streaming';
1387
- }
1388
- // Lightweight heartbeat (≤5s self-throttled) for the status aggregator.
1389
- // Disk-side session.lastHeartbeatAt below is the heavy 60s zombie-reaper
1390
- // signal; the .hb file is the fast fresh-session signal consumed by the
1391
- // status line.
1392
- publishHeartbeat(id, now);
1393
- const session = entry.session;
1394
- if (session && now - (session.lastHeartbeatAt || 0) > HEARTBEAT_THROTTLE_MS) {
1395
- session.lastHeartbeatAt = now;
1396
- await saveSessionAsync(session, { expectedGeneration: session.generation });
1397
- }
1398
- entry.updatedAt = now;
1399
- }
1400
- export function markSessionToolCall(id, toolName) {
1401
- if (!id) return;
1402
- const entry = _touchRuntime(id);
1403
- entry.stage = 'tool_running';
1404
- entry.lastToolCall = toolName || null;
1405
- entry.toolStartedAt = Date.now();
1406
- entry.lastProgressAt = entry.toolStartedAt;
1407
- entry.updatedAt = entry.toolStartedAt;
1408
- publishHeartbeat(id, entry.toolStartedAt);
1409
- _startToolActivityHeartbeat(id);
1410
- }
1411
- // Parent AbortSignal listeners are dropped on askSession unwind (finally /
1412
- // terminal return) and on error/cancel/close — not in markSessionDone, which
1413
- // also runs between queued follow-up turns within one ask.
1414
- export function markSessionDone(id, { empty = false } = {}) {
1415
- if (!id) return;
1416
- _stopToolActivityHeartbeat(id);
1417
- const entry = _touchRuntime(id);
1418
- entry.stage = 'done';
1419
- entry.lastError = null;
1420
- entry.askStartedAt = null;
1421
- entry.toolStartedAt = null;
1422
- // Non-empty completion: drop any stale empty-final flag so a subsequent
1423
- // ask on the same reusable runtime entry starts clean. Empty-final
1424
- // completions preserve the flag (set by markSessionEmptyFinal just prior).
1425
- if (!empty) {
1426
- entry.emptyFinal = false;
1427
- entry.emptyFinalAt = null;
1428
- }
1429
- const doneTs = Date.now();
1430
- entry.doneAt = doneTs;
1431
- entry.lastProgressAt = doneTs;
1432
- entry.updatedAt = doneTs;
1433
- // Terminal stage — drop the heartbeat so the status badge releases
1434
- // immediately. A subsequent ask on the same session re-publishes via
1435
- // markSessionStreamDelta on the first chunk.
1436
- deleteHeartbeat(id);
1437
- }
1438
- // Tag a session as having completed with empty final synthesis (no
1439
- // content/reasoning). Distinct from `markSessionDone`: still a success
1440
- // (no abort), but the stall watchdog and post-mortem tools can
1441
- // distinguish "finished empty" from "finished with content" without
1442
- // mistaking the silence for a stall.
1443
- export function markSessionEmptyFinal(id) {
1444
- if (!id) return;
1445
- const entry = _touchRuntime(id);
1446
- entry.emptyFinal = true;
1447
- entry.emptyFinalAt = Date.now();
1448
- }
1449
- export function markSessionError(id, msg) {
1450
- if (!id) return;
1451
- _stopToolActivityHeartbeat(id);
1452
- const entry = _touchRuntime(id);
1453
- entry.stage = 'error';
1454
- entry.lastError = msg ? String(msg).slice(0, 200) : null;
1455
- entry.askStartedAt = null;
1456
- entry.toolStartedAt = null;
1457
- // Error path is a non-empty completion (we have an error message, not a
1458
- // silent empty final). Clear the flag so the next ask starts clean.
1459
- entry.emptyFinal = false;
1460
- entry.emptyFinalAt = null;
1461
- const errTs = Date.now();
1462
- entry.doneAt = errTs;
1463
- entry.lastProgressAt = errTs;
1464
- entry.updatedAt = errTs;
1465
- deleteHeartbeat(id);
1466
- _unlinkParentAbortListener(entry);
1467
- }
1468
- export function markSessionCancelled(id) {
1469
- if (!id) return;
1470
- _stopToolActivityHeartbeat(id);
1471
- const entry = _touchRuntime(id);
1472
- entry.stage = 'done';
1473
- entry.lastError = null;
1474
- entry.askStartedAt = null;
1475
- entry.toolStartedAt = null;
1476
- entry.emptyFinal = false;
1477
- entry.emptyFinalAt = null;
1478
- const doneTs = Date.now();
1479
- entry.doneAt = doneTs;
1480
- entry.lastProgressAt = doneTs;
1481
- entry.updatedAt = doneTs;
1482
- deleteHeartbeat(id);
1483
- _unlinkParentAbortListener(entry);
1484
- }
1485
- export function getSessionRuntime(id) {
1486
- return id ? (_runtimeState.get(id) || null) : null;
1487
- }
1488
-
1489
- const _COMPACTION_BLOCKED_STAGES = new Set([
1490
- 'connecting', 'requesting', 'streaming', 'tool_running', 'cancelling',
1491
- ]);
1492
-
1493
- export function isSessionCompactionBlocked(sessionId) {
1494
- if (!sessionId) return false;
1495
- const entry = _runtimeState.get(sessionId);
1496
- if (!entry || entry.closed === true) return false;
1497
- if (entry.controller && !entry.controller.signal?.aborted) return true;
1498
- return _COMPACTION_BLOCKED_STAGES.has(entry.stage);
1499
- }
1500
-
1501
- export function getSessionProgressSnapshot(sessionId) {
1502
- const entry = _runtimeState.get(sessionId);
1503
- if (!entry) return null;
1504
- const askStartedAt = entry.askStartedAt || 0;
1505
- const modelRequestStartedAt = entry.modelRequestStartedAt || askStartedAt;
1506
- const firstActivityAt = Math.max(
1507
- entry.lastStreamDeltaAt || 0,
1508
- entry.toolStartedAt || 0,
1509
- );
1510
- const stage = entry.stage || 'idle';
1511
- const waitingForFirstActivity = Boolean(
1512
- modelRequestStartedAt
1513
- && (stage === 'connecting' || stage === 'requesting')
1514
- && firstActivityAt <= modelRequestStartedAt
1515
- );
1516
- return {
1517
- stage,
1518
- askStartedAt,
1519
- modelRequestStartedAt,
1520
- firstActivityAt,
1521
- lastStreamDeltaAt: entry.lastStreamDeltaAt || 0,
1522
- toolStartedAt: entry.toolStartedAt || 0,
1523
- lastProgressAt: entry.lastProgressAt || 0,
1524
- updatedAt: entry.updatedAt || 0,
1525
- hasFirstActivity: Boolean(firstActivityAt && (!askStartedAt || firstActivityAt >= askStartedAt)),
1526
- waitingForFirstActivity,
1527
- };
1528
- }
1529
-
1530
- /**
1531
- * Iterate all active session runtimes. Used by the stream watchdog.
1532
- * Returns an iterable of [sessionId, entry] pairs; consumers should
1533
- * treat entries as read-only snapshots and avoid mutating them.
1534
- */
1535
- export function forEachSessionRuntime() {
1536
- return _runtimeState.entries();
1537
- }
1538
-
1539
929
  // --- Incremental metric persistence (fix A) ---
1540
- // Per-session idempotency tracking: sessionId → Set of seen turn:epoch:iteration:source keys.
1541
- const _metricSeenIter = new Map();
1542
-
1543
- /** Monotonic per-session ask/turn id for incremental usage idempotency. */
1544
- export function bumpUsageMetricsTurnId(session) {
1545
- if (!session || typeof session !== 'object') return 0;
1546
- const next = (Number(session.usageMetricsTurnId) || 0) + 1;
1547
- session.usageMetricsTurnId = next;
1548
- return next;
1549
- }
1550
-
1551
- export function resolveUsageMetricsTurnId(session, delta = {}) {
1552
- if (delta.usageMetricsTurnId != null && Number.isFinite(Number(delta.usageMetricsTurnId))) {
1553
- return Number(delta.usageMetricsTurnId);
1554
- }
1555
- return Number(session?.usageMetricsTurnId) || 0;
1556
- }
1557
-
1558
- /** Advance loop metrics epoch when agentLoop resets its iteration counter (post-compact). */
1559
- export function bumpUsageMetricsEpoch(session) {
1560
- if (!session || typeof session !== 'object') return 0;
1561
- const next = (Number(session.usageMetricsEpoch) || 0) + 1;
1562
- session.usageMetricsEpoch = next;
1563
- return next;
1564
- }
1565
-
1566
- /**
1567
- * Resolve usage-metrics epoch for idempotency (exported for regression smoke).
1568
- * Prefers session.usageMetricsEpoch (bumped in loop on compact reset) and optional
1569
- * delta.usageMetricsEpoch; falls back to iteration regression when loop did not bump.
1570
- */
1571
- export function resolveUsageMetricsEpoch(session, delta = {}) {
1572
- if (!session) return 0;
1573
- let epoch = Number(session.usageMetricsEpoch) || 0;
1574
- if (delta.usageMetricsEpoch != null && Number.isFinite(Number(delta.usageMetricsEpoch))) {
1575
- epoch = Math.max(epoch, Number(delta.usageMetricsEpoch));
1576
- }
1577
- const idx = Number(delta.iterationIndex);
1578
- const prevLastIdx = typeof session.lastIterationIndex === 'number'
1579
- ? session.lastIterationIndex
1580
- : null;
1581
- if (
1582
- (delta.usageMetricsEpoch == null || !Number.isFinite(Number(delta.usageMetricsEpoch)))
1583
- && prevLastIdx !== null
1584
- && Number.isFinite(idx)
1585
- && idx < prevLastIdx
1586
- ) {
1587
- epoch += 1;
1588
- }
1589
- return epoch;
1590
- }
1591
-
1592
- export function usageMetricsSourceKey(delta = {}) {
1593
- const raw = delta.source ?? delta.usageSource;
1594
- if (raw == null || raw === '') return 'provider_send';
1595
- return String(raw);
1596
- }
1597
-
1598
- /** Idempotency key for incremental usage persistence (exported for regression smoke). */
1599
- export function usageMetricsIdempotencyKey(sessionId, session, delta = {}) {
1600
- const turnId = resolveUsageMetricsTurnId(session, delta);
1601
- const epoch = resolveUsageMetricsEpoch(session, delta);
1602
- const source = usageMetricsSourceKey(delta);
1603
- return `${sessionId}:${turnId}:${epoch}:${delta.iterationIndex}:${source}`;
1604
- }
1605
-
1606
- function uncachedInputTokensForProvider(provider, inputTokens, cachedReadTokens = 0, cacheWriteTokens = 0) {
1607
- const input = Number(inputTokens) || 0;
1608
- if (input <= 0) return 0;
1609
- // Anthropic-style providers report input_tokens excluding cache reads; OpenAI
1610
- // Responses/Gemini-style providers report input_tokens inclusive of cached
1611
- // prefix tokens. Keep both views so UI can show the real context footprint
1612
- // and the fresh/new token portion without mistaking cache hits for a cache
1613
- // break.
1614
- if (providerInputExcludesCache(provider)) return input + (Number(cacheWriteTokens) || 0);
1615
- return Math.max(input - (Number(cachedReadTokens) || 0) - (Number(cacheWriteTokens) || 0), 0);
1616
- }
1617
-
1618
- /**
1619
- * Apply terminal ask usage to session totals. Skips lifetime totals when incremental
1620
- * per-iteration persistence already counted this turn (askSession path).
1621
- */
1622
- export function applyAskTerminalUsageTotals(session, result, options = {}) {
1623
- if (!session || !result?.usage) return;
1624
- const skipTotals = options.skipTotalsIfIncremental === true;
1625
- if (!skipTotals) {
1626
- const inputTokens = result.usage.inputTokens || 0;
1627
- const outputTokens = result.usage.outputTokens || 0;
1628
- const cachedTokens = result.usage.cachedTokens || 0;
1629
- const cacheWriteTokens = result.usage.cacheWriteTokens || 0;
1630
- const uncachedInputTokens = uncachedInputTokensForProvider(session.provider, inputTokens, cachedTokens, cacheWriteTokens);
1631
- session.totalInputTokens = (session.totalInputTokens || 0) + inputTokens;
1632
- session.totalOutputTokens = (session.totalOutputTokens || 0) + outputTokens;
1633
- session.tokensCumulative = (session.tokensCumulative || 0)
1634
- + inputTokens
1635
- + outputTokens;
1636
- session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + cachedTokens;
1637
- session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + cacheWriteTokens;
1638
- session.totalUncachedInputTokens = (session.totalUncachedInputTokens || 0) + uncachedInputTokens;
1639
- }
1640
- const _lastTurn = result.lastTurnUsage || result.usage || {};
1641
- const _lastInputTokens = _lastTurn.inputTokens || 0;
1642
- const _lastCachedReadTokens = _lastTurn.cachedTokens || 0;
1643
- const _lastCacheWriteTokens = _lastTurn.cacheWriteTokens || 0;
1644
- session.lastInputTokens = _lastInputTokens;
1645
- session.lastOutputTokens = _lastTurn.outputTokens || 0;
1646
- session.lastCachedReadTokens = _lastCachedReadTokens;
1647
- session.lastCacheWriteTokens = _lastCacheWriteTokens;
1648
- session.lastUncachedInputTokens = uncachedInputTokensForProvider(
1649
- session.provider,
1650
- _lastInputTokens,
1651
- _lastCachedReadTokens,
1652
- _lastCacheWriteTokens,
1653
- );
1654
- const _inputExcludesCache = providerInputExcludesCache(session.provider);
1655
- session.lastContextTokens = _inputExcludesCache
1656
- ? _lastInputTokens + _lastCachedReadTokens + _lastCacheWriteTokens
1657
- : _lastInputTokens;
1658
- session.lastContextTokensUpdatedAt = Date.now();
1659
- session.lastContextTokensStaleAfterCompact = false;
1660
- }
1661
-
1662
- /**
1663
- * Persist incremental usage delta immediately after each provider.send iteration.
1664
- * Idempotency key `sessionId:turnId:epoch:iterationIndex:source` scopes retries
1665
- * per ask, compaction epoch, iteration, and usage source.
1666
- */
1667
- export async function persistIterationMetrics(delta) {
1668
- if (!delta || !delta.sessionId) return;
1669
- const { sessionId, iterationIndex, deltaInput, deltaOutput, deltaCachedRead, deltaCacheWrite, ts } = delta;
1670
- const runtimeEntry = _runtimeState.get(sessionId);
1671
- const session = runtimeEntry?.session ?? loadSession(sessionId);
1672
- if (!session || session.closed) return;
1673
- const epoch = resolveUsageMetricsEpoch(session, delta);
1674
- if (epoch !== (Number(session.usageMetricsEpoch) || 0)) {
1675
- session.usageMetricsEpoch = epoch;
1676
- }
1677
- let seen = _metricSeenIter.get(sessionId);
1678
- if (!seen) {
1679
- seen = new Set();
1680
- _metricSeenIter.set(sessionId, seen);
1681
- }
1682
- const ikey = usageMetricsIdempotencyKey(sessionId, session, delta);
1683
- const isReplay = seen.has(ikey);
1684
- seen.add(ikey);
1685
- if (!isReplay) {
1686
- if (runtimeEntry) runtimeEntry.usageMetricsTurnIncremental = true;
1687
- const deltaUncachedInput = delta.deltaUncachedInput != null
1688
- ? Number(delta.deltaUncachedInput) || 0
1689
- : uncachedInputTokensForProvider(session.provider, deltaInput, deltaCachedRead, deltaCacheWrite);
1690
- session.totalInputTokens = (session.totalInputTokens || 0) + (deltaInput || 0);
1691
- session.totalOutputTokens = (session.totalOutputTokens || 0) + (deltaOutput || 0);
1692
- session.tokensCumulative = (session.tokensCumulative || 0) + (deltaInput || 0) + (deltaOutput || 0);
1693
- // Cache totals — additive fields, default 0 on legacy sessions; both
1694
- // are undefined-safe so the schema migrates lazily as new iterations
1695
- // land. Keeps live + terminal aggregates in lock-step (loop.mjs already
1696
- // includes cached_read / cache_write in its terminal usage rollup).
1697
- session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + (deltaCachedRead || 0);
1698
- session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + (deltaCacheWrite || 0);
1699
- session.totalUncachedInputTokens = (session.totalUncachedInputTokens || 0) + deltaUncachedInput;
1700
- // Window snapshot updated per iteration so agent type=list reflects the
1701
- // most-recent provider-reported input size even for short dispatches
1702
- // that finish before askSession's terminal save lands.
1703
- session.lastInputTokens = deltaInput || 0;
1704
- session.lastOutputTokens = deltaOutput || 0;
1705
- session.lastCachedReadTokens = deltaCachedRead || 0;
1706
- session.lastCacheWriteTokens = deltaCacheWrite || 0;
1707
- session.lastUncachedInputTokens = deltaUncachedInput;
1708
- // Normalized last-call context footprint: how many prompt tokens the
1709
- // model actually saw on the most-recent send, comparable ACROSS
1710
- // providers. Anthropic reports input_tokens EXCLUDING cache (cache_read
1711
- // is a separate field), so the cached portion must be added back to
1712
- // reflect real context size; openai/grok/gemini already fold cached
1713
- // tokens INTO the input count, so input alone is the footprint.
1714
- const _inputExcludesCache = providerInputExcludesCache(session.provider);
1715
- session.lastContextTokens = _inputExcludesCache
1716
- ? (deltaInput || 0) + (deltaCachedRead || 0) + (deltaCacheWrite || 0)
1717
- : (deltaInput || 0);
1718
- session.lastContextTokensUpdatedAt = ts || Date.now();
1719
- session.lastContextTokensStaleAfterCompact = false;
1720
- }
1721
- session.lastIterationIndex = iterationIndex;
1722
- session.updatedAt = ts || Date.now();
1723
- await saveSessionAsync(session, { expectedGeneration: session.generation });
1724
- }
1725
-
1726
- function standaloneStatusRouteInfo(session) {
1727
- if (!session) return null;
1728
- // autoCompactTokenLimit is an EXPLICIT sub-boundary auto-compact limit only.
1729
- // Do NOT fall back to compactBoundaryTokens/contextWindow here — that
1730
- // labels a derived full-window value as an explicit limit, which the
1731
- // runtime would treat as the compaction trigger and collapse the buffer.
1732
- // The boundary/window stays available via contextWindow/rawContextWindow.
1733
- const _boundary = Number(session.compactBoundaryTokens || session.contextWindow || 0);
1734
- const _limit = Number(session.autoCompactTokenLimit || 0);
1735
- const explicitAutoCompactTokenLimit = _limit > 0 && (!_boundary || _limit < _boundary) ? _limit : null;
1736
- return {
1737
- provider: session.provider,
1738
- model: session.model,
1739
- modelDisplay: session.modelDisplay || session.displayName || session.model,
1740
- effort: session.effort || '',
1741
- fast: session.fast === true,
1742
- contextWindow: session.contextWindow || null,
1743
- rawContextWindow: session.rawContextWindow || session.contextWindow || null,
1744
- effectiveContextWindowPercent: session.effectiveContextWindowPercent || null,
1745
- autoCompactTokenLimit: explicitAutoCompactTokenLimit,
1746
- presetId: session.presetId || null,
1747
- presetName: session.presetName || null,
1748
- };
1749
- }
1750
-
1751
- function recordStandaloneStatusTelemetry(session, result, durationMs) {
1752
- if (!session || !result?.usage) return;
1753
- const routeInfo = standaloneStatusRouteInfo(session);
1754
- if (!routeInfo?.provider || !routeInfo?.model) return;
1755
- const providerOut = {
1756
- usage: result.usage,
1757
- model: result.model,
1758
- serviceTier: result.serviceTier,
1759
- };
1760
- // The transcript estimate is the SSOT for the displayed context footprint.
1761
- // agentLoop()'s result has no `compact` field, so build a synthetic compact
1762
- // arg carrying the live monotonic estimate (estimateMessagesTokens+reserve)
1763
- // as afterTokens. This lights up summarizeGatewayUsage's estimate-based
1764
- // contextUsedPct branch (provider input_tokens swing wildly / unbounded on
1765
- // e.g. OpenAI gpt-5.5), and lets a genuine >100% pass through.
1766
- const _estTokens = estimateTranscriptContextUsage(session.messages, session.tools || []);
1767
- const _compactArg = { ...(result.compact && typeof result.compact === 'object' ? result.compact : {}), afterTokens: _estTokens };
1768
- try {
1769
- const summary = {
1770
- ...summarizeGatewayUsage(routeInfo, providerOut, _compactArg, durationMs),
1771
- requestKind: 'chat',
1772
- sessionId: session.id || null,
1773
- toolCount: result.toolCallsTotal ?? null,
1774
- messageCount: Array.isArray(session.messages) ? session.messages.length : null,
1775
- cacheStrategy: session.providerCacheOpts?.cacheStrategy || null,
1776
- };
1777
- recordGatewayUsageEvent(summary);
1778
- } catch {
1779
- // Statusline telemetry must never affect the model turn.
1780
- }
1781
-
1782
- const provider = getProvider(routeInfo.provider);
1783
- if (!provider) return;
1784
- fetchOAuthUsageSnapshot(routeInfo, provider, (message) => {
1785
- if (process.env.MIXDOG_STATUSLINE_TRACE) {
1786
- process.stderr.write(`[statusline] ${message}\n`);
1787
- }
1788
- })
1789
- .then((snapshot) => {
1790
- try { buildGatewayLimits(routeInfo, providerOut, snapshot); } catch {}
1791
- })
1792
- .catch(() => {});
1793
- }
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).
1794
938
 
1795
939
  /** Force-flush session metrics to disk. Used by watchdog terminal-reap (fix B). */
1796
940
  export async function flushSessionMetrics(sessionId) {
@@ -1801,91 +945,6 @@ export async function flushSessionMetrics(sessionId) {
1801
945
  await saveSessionAsync(session, { expectedGeneration: session.generation });
1802
946
  }
1803
947
 
1804
- /** Mark session hidden so listSessions() filters it out (runtime-only). */
1805
- export function hideSessionFromList(sessionId) {
1806
- if (!sessionId) return;
1807
- const entry = _runtimeState.get(sessionId);
1808
- if (entry) entry.listHidden = true;
1809
- }
1810
-
1811
- export function getSessionAbortSignal(sessionId) {
1812
- return _runtimeState.get(sessionId)?.controller?.signal ?? null;
1813
- }
1814
-
1815
- /**
1816
- * Return the most recent "session is making progress" timestamp.
1817
- *
1818
- * Combines three independent progress signals so an idle watchdog can stay
1819
- * alive across both streaming and long tool calls:
1820
- * - lastStreamDeltaAt: provider stream chunk landed
1821
- * - toolStartedAt: a tool call just kicked off (nested tool work may
1822
- * stall the outer stream for a while; this keeps the watchdog from
1823
- * killing legitimate sub-agent runs)
1824
- * - askStartedAt: ask just started; covers the pre-stream connect window
1825
- *
1826
- * Returns 0 when the runtime entry is unknown so callers can decide to
1827
- * either skip the watchdog or treat 0 as "no progress yet".
1828
- */
1829
- export function getSessionLastProgressAt(sessionId) {
1830
- const entry = _runtimeState.get(sessionId);
1831
- if (!entry) return 0;
1832
- return Math.max(
1833
- entry.lastProgressAt || 0,
1834
- entry.lastStreamDeltaAt || 0,
1835
- entry.toolStartedAt || 0,
1836
- entry.askStartedAt || 0,
1837
- );
1838
- }
1839
-
1840
- /**
1841
- * Link a parent AbortSignal to a sub-session's controller so that aborting
1842
- * the parent (fan-out deadline or caller ESC) tears down the agent role's
1843
- * provider call promptly. Safe to call after prepareAgentSession but before
1844
- * askSession completes. No-op if the session runtime isn't found.
1845
- *
1846
- * @param {string} sessionId — the sub-session to abort
1847
- * @param {AbortSignal} parentSignal — upstream signal (from fan-out coordinator)
1848
- */
1849
- export function linkParentSignalToSession(sessionId, parentSignal) {
1850
- if (!(parentSignal instanceof AbortSignal)) return;
1851
- const entry = _touchRuntime(sessionId);
1852
- if (!entry.controller) entry.controller = createAbortController();
1853
- const abortReason = () => {
1854
- const reason = parentSignal.reason;
1855
- if (reason instanceof Error) return reason;
1856
- if (reason !== undefined && reason !== null && reason !== '') return new Error(String(reason));
1857
- return new Error('parent signal aborted');
1858
- };
1859
- if (parentSignal.aborted) {
1860
- _unlinkParentAbortListener(entry);
1861
- try { entry.controller.abort(abortReason()); } catch { /* ignore */ }
1862
- return;
1863
- }
1864
- _unlinkParentAbortListener(entry);
1865
- const onParentAbort = () => {
1866
- try { entry.controller?.abort(abortReason()); } catch { /* ignore */ }
1867
- };
1868
- parentSignal.addEventListener('abort', onParentAbort, { once: true });
1869
- entry.parentAbortLink = { signal: parentSignal, listener: onParentAbort };
1870
- }
1871
- function _unlinkParentAbortListener(entry) {
1872
- const link = entry?.parentAbortLink;
1873
- if (!link) return;
1874
- try { link.signal.removeEventListener('abort', link.listener); } catch { /* ignore */ }
1875
- entry.parentAbortLink = null;
1876
- }
1877
- function _clearSessionRuntime(id) {
1878
- if (id) {
1879
- _stopToolActivityHeartbeat(id);
1880
- _unlinkParentAbortListener(_runtimeState.get(id));
1881
- _runtimeState.delete(id);
1882
- // R15: also drop the per-session metric-idempotency Set; otherwise it
1883
- // grows O(sessions x iterations) for the whole server lifetime since
1884
- // nothing else deletes from _metricSeenIter on session close.
1885
- _metricSeenIter.delete(id);
1886
- }
1887
- }
1888
-
1889
948
  /**
1890
949
  * Wrap an async call so that if the session's controller aborts mid-flight,
1891
950
  * the wrapper settles with a SessionClosedError even if the underlying promise
@@ -2081,7 +1140,7 @@ async function persistCompactedOutgoingAfterAskFailure({
2081
1140
  }) {
2082
1141
  if (!activeSession || activeSession.closed === true) return;
2083
1142
  if (!Array.isArray(turnOutgoing) || turnOutgoing.length === 0) return;
2084
- const currentRuntime = _runtimeState.get(sessionId);
1143
+ const currentRuntime = _getRuntimeEntry(sessionId);
2085
1144
  if (currentRuntime?.closed || currentRuntime?.generation !== askGeneration) return;
2086
1145
  const sanitized = sanitizeSessionMessagesForModel(turnOutgoing);
2087
1146
  const priorSanitized = sanitizeSessionMessagesForModel(
@@ -2159,7 +1218,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2159
1218
  }
2160
1219
  }
2161
1220
  if (!hasModelVisiblePromptContent(prompt)) {
2162
- _unlinkParentAbortListener(_runtimeState.get(sessionId));
1221
+ _unlinkParentAbortListener(_getRuntimeEntry(sessionId));
2163
1222
  return _result;
2164
1223
  }
2165
1224
  // ── Synchronous pre-await setup (must happen before any await so
@@ -2389,7 +1448,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2389
1448
  }
2390
1449
  // Post-loop validation: if closeSession() landed while we were awaiting,
2391
1450
  // drop the save so the tombstone on disk isn't overwritten.
2392
- const currentRuntime = _runtimeState.get(sessionId);
1451
+ const currentRuntime = _getRuntimeEntry(sessionId);
2393
1452
  if (currentRuntime?.closed || currentRuntime?.generation !== askGeneration) {
2394
1453
  const reason = currentRuntime?.closedReason;
2395
1454
  throw new SessionClosedError(sessionId, `closed during call (reason=${reason || 'unknown'})`, reason || null);
@@ -2605,7 +1664,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2605
1664
  // stale in-flight array once the turn unwinds.
2606
1665
  if (activeSession) activeSession.liveTurnMessages = null;
2607
1666
  if (err instanceof SessionClosedError) {
2608
- const currentRuntime = _runtimeState.get(sessionId);
1667
+ const currentRuntime = _getRuntimeEntry(sessionId);
2609
1668
  if (!currentRuntime?.closed) {
2610
1669
  if (activeSession) {
2611
1670
  const originalMessages = Array.isArray(activeSession.messages) ? activeSession.messages : [];
@@ -2673,14 +1732,14 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2673
1732
  continue;
2674
1733
  }
2675
1734
  }
2676
- _unlinkParentAbortListener(_runtimeState.get(sessionId));
1735
+ _unlinkParentAbortListener(_getRuntimeEntry(sessionId));
2677
1736
  return _result;
2678
1737
  }
2679
1738
  } finally {
2680
1739
  // Clear the controller only if it's still ours (closeSession may have
2681
1740
  // swapped it). Leave the rest of the runtime entry intact so agent type=list
2682
1741
  // can still surface the final stage (done/error/cancelling).
2683
- const entry = _runtimeState.get(sessionId);
1742
+ const entry = _getRuntimeEntry(sessionId);
2684
1743
  if (entry && entry.generation === askGeneration) {
2685
1744
  _unlinkParentAbortListener(entry);
2686
1745
  entry.controller = null;
@@ -2759,7 +1818,7 @@ export function getSession(id) {
2759
1818
  export function listSessions(opts = {}) {
2760
1819
  const includeClosed = opts.includeClosed === true;
2761
1820
  const sessions = listStoredSessionSummaries();
2762
- 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));
2763
1822
  // Tombstoned sessions (closed===true) are excluded unless the caller opts in
2764
1823
  // (e.g. agent list includeClosed:true).
2765
1824
  return sessions.filter(s => !hiddenIds.has(s.id) && (includeClosed || s.closed !== true));
@@ -2846,6 +1905,7 @@ export async function clearSessionMessages(sessionId, options = {}) {
2846
1905
  const afterTokens = estimateTranscriptContextUsage(keep, session.tools || []);
2847
1906
  const now = Date.now();
2848
1907
  session.messages = keep;
1908
+ session.sessionStartMetaInjected = false;
2849
1909
  session.totalInputTokens = 0;
2850
1910
  session.totalOutputTokens = 0;
2851
1911
  session.totalCachedReadTokens = 0;
@@ -2935,12 +1995,21 @@ export async function updateSessionStatus(id, status) {
2935
1995
  * Long-term cleanup: `sweepTombstones()` below unlinks tombstones older than
2936
1996
  * TOMBSTONE_MAX_AGE_MS (24h — vastly longer than any realistic in-flight race).
2937
1997
  */
2938
- 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;
2939
2008
  if (!id) return false;
2940
2009
  _stopToolActivityHeartbeat(id);
2941
2010
  // Prefer in-memory runtime session — allBashSessionIds may not be persisted
2942
2011
  // yet for shells opened in the current turn (BL-bash-disk-sync).
2943
- const inMemory = _runtimeState.get(id)?.session;
2012
+ const inMemory = _getRuntimeEntry(id)?.session;
2944
2013
  const persisted = inMemory || loadSession(id);
2945
2014
  const bashSessionId = persisted?.implicitBashSessionId || null;
2946
2015
  // Collect all persistent bash shells created during this session.
@@ -2951,9 +2020,19 @@ export function closeSession(id, reason = 'manual') {
2951
2020
  // against old session records that only have implicitBashSessionId.
2952
2021
  if (bashSessionId && !allBashIds.includes(bashSessionId)) allBashIds.push(bashSessionId);
2953
2022
  // 1. Tombstone first — this wins the race against saveSession().
2954
- 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);
2955
2034
  // 2. Mark runtime as closed so post-await validation in askSession fires.
2956
- const entry = _runtimeState.get(id);
2035
+ const entry = _getRuntimeEntry(id);
2957
2036
  if (entry) {
2958
2037
  entry.closed = true;
2959
2038
  entry.closedReason = reason;
@@ -2972,7 +2051,7 @@ export function closeSession(id, reason = 'manual') {
2972
2051
  try {
2973
2052
  const askStartedAt = entry?.askStartedAt;
2974
2053
  const durationMs = (typeof askStartedAt === 'number') ? (Date.now() - askStartedAt) : null;
2975
- const parts = [`session=${id}`, `reason=${reason}`];
2054
+ const parts = [`session=${id}`, `reason=${reason}`, `tombstone=${tombstone}`];
2976
2055
  if (durationMs != null) parts.push(`duration=${durationMs}ms`);
2977
2056
  if (process.env.MIXDOG_DEBUG_SESSION_LOG) process.stderr.write(`[agent-close] ${parts.join(' ')}\n`);
2978
2057
  } catch { /* best-effort */ }
@@ -3002,7 +2081,7 @@ export function closeSession(id, reason = 'manual') {
3002
2081
  export function abortSessionTurn(id, reason = 'turn-abort') {
3003
2082
  if (!id) return false;
3004
2083
  _stopToolActivityHeartbeat(id);
3005
- const entry = _runtimeState.get(id);
2084
+ const entry = _getRuntimeEntry(id);
3006
2085
  if (!entry || entry.closed) return false;
3007
2086
  entry.stage = 'cancelling';
3008
2087
  entry.closedReason = reason;
@@ -3052,7 +2131,7 @@ function sweepIdleSessions({ includeTombstones = true } = {}) {
3052
2131
  // Skip entries with an active in-flight controller — aborting
3053
2132
  // them via closeSession() is the safe path; clearing the runtime
3054
2133
  // without signalling the controller leaves orphan provider work.
3055
- const rtEntry = _runtimeState.get(d.id);
2134
+ const rtEntry = _getRuntimeEntry(d.id);
3056
2135
  if (rtEntry && rtEntry.controller && !rtEntry.controller.signal?.aborted) {
3057
2136
  try { closeSession(d.id, 'idle-sweep'); } catch { /* ignore */ }
3058
2137
  } else {
@@ -3120,7 +2199,7 @@ export function sweepTombstones() {
3120
2199
  }
3121
2200
 
3122
2201
  function hasActiveRuntimeWork() {
3123
- for (const [, entry] of _runtimeState) {
2202
+ for (const [, entry] of _runtimeEntries()) {
3124
2203
  if (!entry || entry.closed === true) continue;
3125
2204
  if (entry.controller && !entry.controller.signal?.aborted) return true;
3126
2205
  if (['connecting', 'requesting', 'streaming', 'tool_running', 'cancelling'].includes(entry.stage)) return true;