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
@@ -28,8 +28,8 @@ import {
28
28
  readMarkdownDocument,
29
29
  } from './runtime/shared/markdown-frontmatter.mjs';
30
30
  import { setConfiguredShell } from './runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs';
31
+ import { hasUserConversationMessage } from './runtime/agent/orchestrator/session/manager/prompt-utils.mjs';
31
32
  import {
32
- PROVIDER_STATUS_TOOL,
33
33
  beginOAuthProviderLogin,
34
34
  forgetProviderAuth,
35
35
  isKnownProvider,
@@ -54,14 +54,12 @@ import {
54
54
  } from './runtime/agent/orchestrator/providers/custom-tool-wire.mjs';
55
55
  import {
56
56
  channelSetup,
57
- deleteChannel,
58
57
  deleteSchedule,
59
58
  deleteWebhook,
60
59
  forgetDiscordToken,
61
60
  forgetTelegramToken,
62
61
  forgetWebhookAuthtoken,
63
- renderChannelStatus,
64
- saveChannel,
62
+ setChannel,
65
63
  saveDiscordToken,
66
64
  saveTelegramToken,
67
65
  saveSchedule,
@@ -137,9 +135,14 @@ import {
137
135
  normalizeSystemShellConfig,
138
136
  normalizeSystemShellCommand,
139
137
  normalizeAutoClearConfig,
138
+ resolveAutoClearIdleMs,
139
+ autoClearIdleMsForProvider,
140
+ autoClearProviderDefaults,
140
141
  normalizeCompactionConfig,
141
142
  moduleEnabled,
142
143
  setModuleEnabledInConfig,
144
+ recapEnabled,
145
+ setRecapEnabledInConfig,
143
146
  formatDurationMs,
144
147
  parseDurationMs,
145
148
  modelMetaLooksResolved,
@@ -174,6 +177,7 @@ import {
174
177
  agentPresetSlot,
175
178
  normalizeAgentId,
176
179
  normalizeWorkflowId,
180
+ DEFAULT_WORKFLOW_ID,
177
181
  createWorkflowHelpers,
178
182
  normalizeSearchProviderId,
179
183
  isDefaultSearchRouteConfig,
@@ -208,6 +212,36 @@ import {
208
212
  // Re-exported for external consumers (scripts/tool-smoke.mjs) that imported
209
213
  // these from this module before the tool-catalog extraction.
210
214
  export { defaultDeferredToolNames, compactToolSearchDescription } from './session-runtime/tool-catalog.mjs';
215
+ import {
216
+ TOOL_SEARCH_TOOL,
217
+ CWD_TOOL,
218
+ SKILL_TOOL,
219
+ LEAD_DISALLOWED_TOOLS,
220
+ applyStandaloneToolDefaults,
221
+ } from './session-runtime/tool-defs.mjs';
222
+ import { ONBOARDING_VERSION, QUICK_SEARCH_MODELS } from './session-runtime/quick-search-models.mjs';
223
+ import {
224
+ sortProviderModels as sortProviderModelsRaw,
225
+ providerModelCacheRow as providerModelCacheRowRaw,
226
+ } from './session-runtime/model-recency.mjs';
227
+ import { createNativeSearch } from './session-runtime/native-search.mjs';
228
+ import { createConfigLifecycle } from './session-runtime/config-lifecycle.mjs';
229
+ import { attachSessionHooks } from './session-runtime/session-hooks.mjs';
230
+ import { createQuickModelRows } from './session-runtime/quick-model-rows.mjs';
231
+ import { createWarmupSchedulers } from './session-runtime/warmup-schedulers.mjs';
232
+ import { createPrewarmSchedulers } from './session-runtime/prewarm.mjs';
233
+ import { createMcpGlue } from './session-runtime/mcp-glue.mjs';
234
+ import { createCwdPlugins } from './session-runtime/cwd-plugins.mjs';
235
+ import { createSettingsApi } from './session-runtime/settings-api.mjs';
236
+ import { createProviderModels } from './session-runtime/provider-models.mjs';
237
+ import { createProviderUsage } from './session-runtime/provider-usage.mjs';
238
+ // Re-exported for external consumers (scripts/tool-smoke.mjs) that imported
239
+ // these from this module before the tool-defs extraction.
240
+ export { TOOL_SEARCH_TOOL, SKILL_TOOL };
241
+ // Back-compat test alias; delegates to the extracted helper.
242
+ export function __applyStandaloneToolDefaultsForTest(tool) {
243
+ return applyStandaloneToolDefaults(tool);
244
+ }
211
245
 
212
246
  const RUNTIME = './runtime/agent/orchestrator';
213
247
  const SEARCH_RUNTIME = './runtime/search/index.mjs';
@@ -276,146 +310,7 @@ async function profiledImport(label, spec, { optional = false } = {}) {
276
310
  throw error;
277
311
  }
278
312
  }
279
- export const TOOL_SEARCH_TOOL = {
280
- name: 'tool_search',
281
- title: 'Tool Search',
282
- annotations: {
283
- title: 'Tool Search',
284
- readOnlyHint: true,
285
- destructiveHint: false,
286
- idempotentHint: true,
287
- openWorldHint: false,
288
- agentHidden: true,
289
- },
290
- description: 'Search deferred tools; confident queries load matches.',
291
- inputSchema: {
292
- type: 'object',
293
- properties: {
294
- query: { type: 'string', description: 'Search text; confident hits load. select:a,b forces exact names.' },
295
- select: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], description: 'Force exact tool names.' },
296
- limit: { type: 'number', description: 'Max matches.' },
297
- },
298
- additionalProperties: false,
299
- },
300
- };
301
-
302
- const CHANNEL_STATUS_TOOL = {
303
- name: 'channel_status',
304
- title: 'Channel Status',
305
- annotations: {
306
- title: 'Channel Status',
307
- readOnlyHint: true,
308
- destructiveHint: false,
309
- idempotentHint: true,
310
- openWorldHint: false,
311
- agentHidden: true,
312
- },
313
- description: 'List channel/schedule/webhook status. No secrets.',
314
- inputSchema: { type: 'object', properties: {}, additionalProperties: false },
315
- };
316
-
317
- const CWD_TOOL = {
318
- name: 'cwd',
319
- title: 'Work Project',
320
- annotations: {
321
- title: 'Work Project',
322
- readOnlyHint: false,
323
- destructiveHint: false,
324
- idempotentHint: false,
325
- openWorldHint: false,
326
- agentHidden: true,
327
- },
328
- description: 'Show or set the session work project for tool execution.',
329
- inputSchema: {
330
- type: 'object',
331
- properties: {
332
- action: { type: 'string', enum: ['get', 'set'], description: 'Default get.' },
333
- path: { type: 'string', description: 'Project directory for set.' },
334
- },
335
- additionalProperties: false,
336
- },
337
- };
338
-
339
- export const SKILL_TOOL = {
340
- name: 'Skill',
341
- title: 'Skill',
342
- annotations: {
343
- title: 'Skill',
344
- readOnlyHint: true,
345
- destructiveHint: false,
346
- idempotentHint: true,
347
- openWorldHint: false,
348
- agentHidden: false,
349
- },
350
- description: 'Load a named SKILL.md into context.',
351
- inputSchema: {
352
- type: 'object',
353
- properties: {
354
- name: { type: 'string', description: 'Skill name.' },
355
- },
356
- required: ['name'],
357
- additionalProperties: false,
358
- },
359
- };
360
-
361
- const LEAD_DISALLOWED_TOOLS = Object.freeze(['diagnostics', 'open_config']);
362
- const AGENT_HIDDEN_WRAPPER_TOOLS = new Set([]);
363
-
364
- export function __applyStandaloneToolDefaultsForTest(tool) {
365
- if (!tool || !AGENT_HIDDEN_WRAPPER_TOOLS.has(tool.name)) return tool;
366
- return {
367
- ...tool,
368
- annotations: {
369
- ...(tool.annotations || {}),
370
- agentHidden: true,
371
- },
372
- };
373
- }
374
- const applyStandaloneToolDefaults = __applyStandaloneToolDefaultsForTest;
375
313
  const outputStyleStatus = (dataDir = STANDALONE_DATA_DIR) => outputStyleStatusRaw(STANDALONE_ROOT, dataDir || STANDALONE_DATA_DIR);
376
-
377
- const ONBOARDING_VERSION = 1;
378
- const QUICK_SEARCH_MODELS = Object.freeze({
379
- 'openai-oauth': [
380
- { id: 'gpt-5.5', display: 'GPT-5.5', latest: true, contextWindow: 1000000 },
381
- { id: 'gpt-5.4', display: 'GPT-5.4', latest: true, contextWindow: 1000000 },
382
- { id: 'gpt-5', display: 'GPT-5', contextWindow: 400000 },
383
- { id: 'gpt-4.1', display: 'GPT-4.1', contextWindow: 1000000 },
384
- ],
385
- openai: [
386
- { id: 'gpt-5.5', display: 'GPT-5.5', latest: true, contextWindow: 1000000 },
387
- { id: 'gpt-5.4', display: 'GPT-5.4', latest: true, contextWindow: 1000000 },
388
- { id: 'gpt-5', display: 'GPT-5', contextWindow: 400000 },
389
- { id: 'gpt-4.1', display: 'GPT-4.1', contextWindow: 1000000 },
390
- { id: 'gpt-4o', display: 'GPT-4o', contextWindow: 128000 },
391
- ],
392
- 'grok-oauth': [
393
- { id: 'grok-4.3', display: 'Grok 4.3', latest: true, contextWindow: 1000000 },
394
- { id: 'grok-4.20', display: 'Grok 4.20', contextWindow: 1000000 },
395
- { id: 'grok-4', display: 'Grok 4', contextWindow: 256000 },
396
- ],
397
- xai: [
398
- { id: 'grok-4.3', display: 'Grok 4.3', latest: true, contextWindow: 1000000 },
399
- { id: 'grok-4.20', display: 'Grok 4.20', contextWindow: 1000000 },
400
- { id: 'grok-4', display: 'Grok 4', contextWindow: 256000 },
401
- ],
402
- gemini: [
403
- { id: 'gemini-3-pro', display: 'Gemini 3 Pro', latest: true, contextWindow: 1000000 },
404
- { id: 'gemini-2.5-pro', display: 'Gemini 2.5 Pro', contextWindow: 1000000 },
405
- { id: 'gemini-2.5-flash', display: 'Gemini 2.5 Flash', contextWindow: 1000000 },
406
- { id: 'gemini-2.0-flash', display: 'Gemini 2.0 Flash', contextWindow: 1000000 },
407
- ],
408
- 'anthropic-oauth': [
409
- { id: 'claude-opus-4-8', display: 'Claude Opus 4.8', latest: true, contextWindow: 1000000 },
410
- { id: 'claude-sonnet-4-6', display: 'Claude Sonnet 4.6', latest: true, contextWindow: 1000000 },
411
- { id: 'claude-haiku-4-5-20251001', display: 'Claude Haiku 4.5', contextWindow: 200000 },
412
- ],
413
- anthropic: [
414
- { id: 'claude-opus-4-8', display: 'Claude Opus 4.8', latest: true, contextWindow: 1000000 },
415
- { id: 'claude-sonnet-4-6', display: 'Claude Sonnet 4.6', latest: true, contextWindow: 1000000 },
416
- { id: 'claude-haiku-4-5-20251001', display: 'Claude Haiku 4.5', contextWindow: 200000 },
417
- ],
418
- });
419
314
  // Workflow/agent pack loaders bound to this runtime's root/data layout.
420
315
  const {
421
316
  listWorkflowPacks,
@@ -445,15 +340,6 @@ export function __saveModelSettingsForTest(cfgMod, route, options = {}) {
445
340
  return saveModelSettings(cfgMod, route, options);
446
341
  }
447
342
 
448
- function resolveCwdPath(currentCwd, value) {
449
- const raw = clean(value);
450
- if (!raw) throw new Error('cwd: path is required for action=set');
451
- const next = resolve(currentCwd || process.cwd(), raw);
452
- const stat = statSync(next);
453
- if (!stat.isDirectory()) throw new Error(`cwd: not a directory: ${next}`);
454
- return next;
455
- }
456
-
457
343
  export async function createMixdogSessionRuntime({
458
344
  provider,
459
345
  model,
@@ -516,37 +402,16 @@ export async function createMixdogSessionRuntime({
516
402
  let memoryModPromise = null;
517
403
  let searchModPromise = null;
518
404
  let codeGraphModPromise = null;
519
- let outputStyleStatusCache = null;
520
- let outputStyleStatusCacheAt = 0;
521
- let outputStyleStatusCacheDir = '';
522
405
 
523
- const memoryEnabled = () => moduleEnabled(config, 'memory', true);
406
+ // Memory module is always-on. `memoryEnabled()` is kept as a thin alias that
407
+ // now always returns true (callers/compaction helpers still reference it);
408
+ // the user-facing toggle is `recap` (background cycles only), read via
409
+ // recapEnabled(config).
410
+ const memoryEnabled = () => true;
411
+ const recapEnabledFn = () => recapEnabled(config, true);
524
412
  const channelsEnabled = () => moduleEnabled(config, 'channels', true);
525
- const getOutputStyleStatusCached = ({ fresh = false } = {}) => {
526
- const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
527
- const cacheDir = resolve(dataDir);
528
- const now = performance.now();
529
- if (
530
- !fresh
531
- && outputStyleStatusCache
532
- && outputStyleStatusCacheDir === cacheDir
533
- && now - outputStyleStatusCacheAt < 2500
534
- ) {
535
- return outputStyleStatusCache;
536
- }
537
- outputStyleStatusCache = outputStyleStatus(dataDir);
538
- outputStyleStatusCacheAt = now;
539
- outputStyleStatusCacheDir = cacheDir;
540
- return outputStyleStatusCache;
541
- };
542
- const invalidateOutputStyleStatusCache = () => {
543
- outputStyleStatusCache = null;
544
- outputStyleStatusCacheAt = 0;
545
- outputStyleStatusCacheDir = '';
546
- };
547
413
 
548
414
  async function getMemoryModule() {
549
- if (!memoryEnabled()) throw new Error('memory is disabled in settings');
550
415
  const startedAt = performance.now();
551
416
  memoryModPromise ??= Promise.resolve(memoryRuntime);
552
417
  const mod = await memoryModPromise;
@@ -565,211 +430,6 @@ export async function createMixdogSessionRuntime({
565
430
  return mod;
566
431
  }
567
432
 
568
- function normalizeSearchAllowedDomain(site) {
569
- const raw = clean(site);
570
- if (!raw) return '';
571
- try {
572
- return new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`).hostname.toLowerCase();
573
- } catch {
574
- return raw.replace(/^https?:\/\//i, '').split('/')[0].toLowerCase();
575
- }
576
- }
577
-
578
- function nativeSearchUserLocation(locale) {
579
- if (!locale || typeof locale !== 'object' || Array.isArray(locale)) return null;
580
- const location = { type: 'approximate' };
581
- for (const key of ['country', 'region', 'city', 'timezone']) {
582
- const value = clean(locale[key]);
583
- if (value) location[key] = value;
584
- }
585
- return Object.keys(location).length > 1 ? location : null;
586
- }
587
-
588
- function nativeSearchTool(args = {}, toolType = 'web_search', providerId = '') {
589
- const providerName = normalizeSearchProviderId(providerId);
590
- const domain = normalizeSearchAllowedDomain(args.site);
591
- const type = clean(toolType) || 'web_search';
592
- const location = nativeSearchUserLocation(args.locale);
593
- if (providerName === 'gemini') {
594
- return { type: type || 'google_search' };
595
- }
596
- if (providerName === 'anthropic' || providerName === 'anthropic-oauth') {
597
- const tool = {
598
- type: 'web_search_20250305',
599
- name: 'web_search',
600
- max_uses: Math.max(1, Math.min(10, Number(args.maxResults) || 5)),
601
- };
602
- if (domain) tool.allowed_domains = [domain];
603
- if (location) tool.user_location = location;
604
- return tool;
605
- }
606
- if (providerName === 'grok-oauth' || providerName === 'xai') {
607
- const tool = { type };
608
- if (domain) tool.filters = { allowed_domains: [domain] };
609
- return tool;
610
- }
611
- const tool = {
612
- type,
613
- };
614
- if (type === 'web_search') {
615
- tool.search_context_size = clean(args.contextSize) || 'low';
616
- if (domain) tool.filters = { allowed_domains: [domain] };
617
- if (location) tool.user_location = location;
618
- }
619
- return tool;
620
- }
621
-
622
- function nativeSearchToolTypes(routeLike = {}) {
623
- const envToolType = clean(process.env.MIXDOG_NATIVE_SEARCH_TOOL_TYPE);
624
- if (envToolType) return [envToolType];
625
- const configured = clean(routeLike.toolType);
626
- if (configured) return [configured];
627
- const providerName = normalizeSearchProviderId(routeLike.provider);
628
- if (providerName === 'gemini') return ['google_search'];
629
- if (providerName === 'anthropic' || providerName === 'anthropic-oauth') return ['web_search'];
630
- if (providerName === 'grok-oauth' || providerName === 'xai') return ['web_search'];
631
- return ['web_search', 'web_search_preview'];
632
- }
633
-
634
- function currentMainSearchModelMeta() {
635
- if (!route?.provider || !route?.model) return null;
636
- return { ...route, id: route.model, display: route.model, name: route.model };
637
- }
638
-
639
- function nativeSearchRoutes() {
640
- const cfg = ensureFullConfig();
641
- searchRoute = normalizeSearchRouteConfig(cfg.searchRoute) || normalizeSearchRouteConfig(searchRoute);
642
- if (!searchRoute) return [];
643
- if (isDefaultSearchRouteConfig(searchRoute)) {
644
- const mainModel = currentMainSearchModelMeta();
645
- if (!mainModel || !searchCapableFor(route.provider, mainModel)) return [];
646
- return [{
647
- key: `default\n${route.provider}\n${route.model}`,
648
- provider: normalizeSearchProviderId(route.provider),
649
- model: route.model,
650
- source: 'default-search-route',
651
- effort: route.effectiveEffort || route.effort || null,
652
- fast: route.fast === true,
653
- toolType: searchRoute.toolType || null,
654
- }];
655
- }
656
- const providerName = normalizeSearchProviderId(searchRoute.provider);
657
- if (!isSearchCapableProvider(providerName)) return [];
658
- return [{
659
- key: `${providerName}\n${searchRoute.model}`,
660
- provider: providerName,
661
- model: searchRoute.model,
662
- source: 'search-route',
663
- effort: searchRoute.effort || null,
664
- fast: searchRoute.fast === true,
665
- toolType: searchRoute.toolType || null,
666
- }];
667
- }
668
-
669
- function nativeSearchMessages(searchArgs = {}) {
670
- const prompt = searchArgs.prompt || '';
671
- return [
672
- {
673
- role: 'system',
674
- content: [
675
- 'You are Mixdog native web search.',
676
- 'Use the hosted web_search tool for current or external facts.',
677
- 'Answer concisely, cite source URLs, and do not request local tools or file edits.',
678
- ].join('\n'),
679
- },
680
- { role: 'user', content: prompt },
681
- ];
682
- }
683
-
684
- function flattenNativeSearchSources(result = {}) {
685
- const out = [];
686
- const add = (source, fallbackTitle = '') => {
687
- if (!source || typeof source !== 'object') return;
688
- const url = clean(source.url || source.uri || source.href || source.source_url);
689
- if (!url) return;
690
- out.push({
691
- title: clean(source.title || source.query || source.name || fallbackTitle || url),
692
- url,
693
- snippet: clean(source.snippet || source.text || source.description),
694
- source: source.source || 'native-web-search',
695
- provider: source.provider || 'native-web-search',
696
- });
697
- };
698
- for (const citation of Array.isArray(result.citations) ? result.citations : []) add(citation);
699
- for (const call of Array.isArray(result.webSearchCalls) ? result.webSearchCalls : []) {
700
- const action = call?.action || {};
701
- for (const source of Array.isArray(action.sources) ? action.sources : []) add(source, action.query || '');
702
- if (action.url) add({ url: action.url, title: action.query || '' });
703
- for (const url of Array.isArray(action.urls) ? action.urls : []) add({ url, title: action.query || '' });
704
- }
705
- const seen = new Set();
706
- return out.filter((item) => {
707
- const key = item.url || `${item.title}\n${item.snippet}`;
708
- if (!key || seen.has(key)) return false;
709
- seen.add(key);
710
- return true;
711
- });
712
- }
713
-
714
- async function runNativeWebSearch(searchArgs = {}, { signal } = {}) {
715
- const candidates = nativeSearchRoutes();
716
- if (!candidates.length) {
717
- if (isDefaultSearchRouteConfig(searchRoute)) {
718
- throw new Error(`default search route requires the current main model to support native web search (${route?.provider || 'unknown'}/${route?.model || 'unknown'})`);
719
- }
720
- throw new Error('search route is not configured; open /search to choose a search provider/model');
721
- }
722
- const errors = [];
723
- for (const candidate of candidates) {
724
- for (const toolType of nativeSearchToolTypes(candidate)) {
725
- try {
726
- await ensureProvidersReady(ensureProviderEnabled(config, candidate.provider));
727
- const providerImpl = reg.getProvider(candidate.provider);
728
- if (!providerImpl || typeof providerImpl.send !== 'function') {
729
- throw new Error(`provider "${candidate.provider}" is not ready`);
730
- }
731
- const model = candidate.model;
732
- const searchTool = nativeSearchTool(searchArgs, toolType, candidate.provider);
733
- const startedAt = Date.now();
734
- const result = await providerImpl.send(
735
- nativeSearchMessages(searchArgs),
736
- model,
737
- undefined,
738
- {
739
- signal,
740
- role: 'web-search',
741
- sessionId: `${session?.id || 'search'}:native-search:${Date.now().toString(36)}`,
742
- sourceType: 'native-search',
743
- sourceName: 'search',
744
- nativeTools: [searchTool],
745
- nativeInclude: candidate.provider === 'openai' || candidate.provider === 'openai-oauth'
746
- ? ['web_search_call.action.sources']
747
- : [],
748
- toolChoice: candidate.provider === 'gemini' ? 'auto' : 'required',
749
- ...(candidate.effort ? { effort: candidate.effort } : {}),
750
- fast: candidate.fast === true,
751
- onStageChange: () => {},
752
- onStreamDelta: () => {},
753
- },
754
- );
755
- const sources = flattenNativeSearchSources(result);
756
- return {
757
- content: String(result?.content || '').trim(),
758
- provider: candidate.provider,
759
- model: result?.model || candidate.model || null,
760
- usage: result?.usage || null,
761
- citations: sources,
762
- webSearchCalls: result?.webSearchCalls || [],
763
- durationMs: Date.now() - startedAt,
764
- };
765
- } catch (err) {
766
- errors.push(`${candidate.provider}${candidate.model ? `/${candidate.model}` : ''}/${toolType}: ${err?.message || String(err)}`);
767
- }
768
- }
769
- }
770
- throw new Error(`native web search failed: ${errors.join(' | ')}`);
771
- }
772
-
773
433
  async function getCodeGraphModule() {
774
434
  const startedAt = performance.now();
775
435
  codeGraphModPromise ??= import(CODE_GRAPH_RUNTIME);
@@ -808,60 +468,6 @@ export async function createMixdogSessionRuntime({
808
468
  }
809
469
  }
810
470
 
811
- function formatCoreMemoryLines(payload = {}) {
812
- const seen = new Set();
813
- const lines = [];
814
- for (const value of [
815
- ...(Array.isArray(payload.userLines) ? payload.userLines : []),
816
- ...(Array.isArray(payload.dbLines) ? payload.dbLines : []),
817
- ]) {
818
- const text = clean(value).replace(/\s+/g, ' ');
819
- if (!text) continue;
820
- const key = text.toLowerCase();
821
- if (seen.has(key)) continue;
822
- seen.add(key);
823
- lines.push(`- ${text}`);
824
- if (lines.length >= 40) break;
825
- }
826
- const out = lines.join('\n');
827
- const maxChars = 6000;
828
- return out.length > maxChars ? `${out.slice(0, maxChars).replace(/\s+\S*$/, '')}\n- ...` : out;
829
- }
830
-
831
- async function loadCoreMemoryContext() {
832
- // Boot should not pay for memory/PG startup unless explicitly requested.
833
- // Recall and memory tools still initialize the memory service on first use.
834
- if (!memoryEnabled()) {
835
- bootProfile('core-memory:disabled');
836
- return '';
837
- }
838
- if (process.env.MIXDOG_BOOT_CORE_MEMORY !== '1') {
839
- bootProfile('core-memory:skipped');
840
- return '';
841
- }
842
- const startedAt = performance.now();
843
- let timer = null;
844
- const timeout = new Promise((resolve) => {
845
- timer = setTimeout(() => resolve(''), 2000);
846
- timer.unref?.();
847
- });
848
- try {
849
- return await Promise.race([
850
- (async () => {
851
- const memoryMod = await getMemoryModule();
852
- if (typeof memoryMod?.buildSessionCoreMemoryPayload !== 'function') return '';
853
- return formatCoreMemoryLines(await memoryMod.buildSessionCoreMemoryPayload(currentCwd));
854
- })(),
855
- timeout,
856
- ]);
857
- } catch {
858
- return '';
859
- } finally {
860
- if (timer) clearTimeout(timer);
861
- bootProfile('core-memory:done', { ms: (performance.now() - startedAt).toFixed(1) });
862
- }
863
- }
864
-
865
471
  const configStartedAt = performance.now();
866
472
  let config = cfgMod.loadConfig({ secrets: false });
867
473
  setConfiguredShell(normalizeSystemShellConfig(config.shell).command);
@@ -875,17 +481,26 @@ export async function createMixdogSessionRuntime({
875
481
  let currentCwd = cwd;
876
482
  let sessionNeedsCwdRefresh = false;
877
483
  let closeRequested = false;
878
- let channelStartTimer = null;
879
- let channelStartPromise = null;
880
- let providerSetupWarmupTimer = null;
881
- let providerWarmupTimer = null;
882
- let providerModelWarmupTimer = null;
883
- let modelCatalogWarmupTimer = null;
884
- let codeGraphPrewarmTimer = null;
885
- let codeGraphPrewarmInFlight = false;
886
- let codeGraphPrewarmQueuedCwd = '';
887
- let statuslineUsageWarmupTimer = null;
888
- let statuslineUsageRefreshTimer = null;
484
+ const warmupTimers = {
485
+ providerSetupWarmupTimer: null,
486
+ providerWarmupTimer: null,
487
+ providerModelWarmupTimer: null,
488
+ modelCatalogWarmupTimer: null,
489
+ statuslineUsageWarmupTimer: null,
490
+ statuslineUsageRefreshTimer: null,
491
+ };
492
+ // Prewarm/channel-start timer handles + async state, owned here so the
493
+ // teardown clearTimeout sweep still sees them; the prewarm scheduler factory
494
+ // mutates these objects in place (see createPrewarmSchedulers).
495
+ const prewarmTimers = {
496
+ codeGraphPrewarmTimer: null,
497
+ channelStartTimer: null,
498
+ };
499
+ const prewarmState = {
500
+ codeGraphPrewarmInFlight: false,
501
+ codeGraphPrewarmQueuedCwd: '',
502
+ channelStartPromise: null,
503
+ };
889
504
  let activeTurnCount = 0;
890
505
  let firstTurnCompleted = false;
891
506
  function hookTranscriptPath(sessionId) {
@@ -955,20 +570,50 @@ export async function createMixdogSessionRuntime({
955
570
  let codeGraphFirstTurnPrewarmDone = false;
956
571
  const modelMetaByRoute = new Map();
957
572
  const notificationListeners = new Set();
958
- let providerModelsCache = { models: null, at: 0 };
959
- let providerModelsPromise = null;
960
- let providerModelsLoadSeq = 0;
961
- let searchProviderModelsCache = { models: null, at: 0 };
962
- let usageDashboardCache = { dashboard: null, at: 0 };
963
- let usageDashboardPromise = null;
964
- let providerSetupCache = { setup: null, at: 0 };
965
- let providerSetupQuickCache = { setup: null, at: 0 };
966
- let providerSetupPromise = null;
573
+ // Remote seat listeners (TUI): fired when remote mode flips outside a direct
574
+ // user action — currently only the superseded (seat stolen) path.
575
+ const remoteStateListeners = new Set();
576
+ function emitRemoteStateChange(enabled, reason = '') {
577
+ for (const listener of [...remoteStateListeners]) {
578
+ try { listener({ enabled: enabled === true, reason: String(reason || '') }); } catch {}
579
+ }
580
+ }
581
+ const providerModelCaches = {
582
+ providerModelsCache: { models: null, at: 0 },
583
+ providerModelsPromise: null,
584
+ providerModelsLoadSeq: 0,
585
+ searchProviderModelsCache: { models: null, at: 0 },
586
+ };
587
+ const providerUsageCaches = {
588
+ usageDashboardCache: { dashboard: null, at: 0 },
589
+ usageDashboardPromise: null,
590
+ providerSetupCache: { setup: null, at: 0 },
591
+ providerSetupQuickCache: { setup: null, at: 0 },
592
+ providerSetupPromise: null,
593
+ };
967
594
  let providerInitPromise = null;
968
- let mcpFailures = [];
969
595
  let lastProjectMcpKey = null;
970
- let mcpConnectGeneration = 0;
971
- let mcpConnectInFlight = null;
596
+ // MCP connect state, owned here so teardown/reconnect paths still observe it;
597
+ // the mcp-glue factory mutates this object in place (see createMcpGlue).
598
+ const mcpState = {
599
+ mcpFailures: [],
600
+ mcpConnectGeneration: 0,
601
+ mcpConnectInFlight: null,
602
+ };
603
+ // MCP glue factory — config/currentCwd live-bound; connect state shared via
604
+ // the caller-owned mcpState object above.
605
+ const {
606
+ mcpTransportLabel,
607
+ resolveEffectiveMcpServers,
608
+ mcpStatus,
609
+ connectConfiguredMcp,
610
+ normalizeMcpServerInput,
611
+ } = createMcpGlue({
612
+ mcpClient,
613
+ getConfig: () => config,
614
+ getCurrentCwd: () => currentCwd,
615
+ state: mcpState,
616
+ });
972
617
  let preSessionToolSurface = null;
973
618
  let contextStatusCacheKey = null;
974
619
  let contextStatusCacheValue = null;
@@ -994,7 +639,7 @@ export async function createMixdogSessionRuntime({
994
639
  let updateProcessState = { phase: 'idle', version: null, error: null };
995
640
 
996
641
  function autoUpdateEnabled() {
997
- return config?.update?.auto === true;
642
+ return config?.update?.auto !== false;
998
643
  }
999
644
 
1000
645
  async function checkForUpdateInternal({ force = false } = {}) {
@@ -1037,13 +682,16 @@ export async function createMixdogSessionRuntime({
1037
682
  // Non-blocking boot hook: fires after the runtime object below is fully
1038
683
  // constructed (setTimeout(0) defers past the synchronous return), so a
1039
684
  // slow/hanging registry request or npm install can never delay session
1040
- // boot. Auto-update defaults to OFF (config.update.auto is undefined until
1041
- // the user opts in via setAutoUpdate(true)); a failed check or install is
685
+ // boot. Auto-update defaults to ON unless config.update.auto is explicitly
686
+ // false; a failed check or install is
1042
687
  // swallowed — getUpdateStatus()/getUpdateSettings() are the only surfaces,
1043
688
  // there is no push notice channel from runtime -> TUI today.
689
+ // force:true — always hit the registry at boot (the 24h disk cache went
690
+ // stale-visible: it kept reporting an older "latest" than the installed
691
+ // version). checkLatestVersion() still falls back to the cache offline.
1044
692
  const updateBootTimer = setTimeout(() => {
1045
693
  void (async () => {
1046
- await checkForUpdateInternal({ force: false });
694
+ await checkForUpdateInternal({ force: true });
1047
695
  if (autoUpdateEnabled() && updateCheckState.updateAvailable) {
1048
696
  await runUpdateNowInternal();
1049
697
  }
@@ -1051,15 +699,6 @@ export async function createMixdogSessionRuntime({
1051
699
  }, 0);
1052
700
  updateBootTimer.unref?.();
1053
701
 
1054
- function mcpTransportLabel(cfg = {}) {
1055
- if (cfg.autoDetect) return `autoDetect:${cfg.autoDetect}`;
1056
- try {
1057
- return mcpClient.resolveMcpTransportKind(cfg);
1058
- } catch {
1059
- return 'unknown';
1060
- }
1061
- }
1062
-
1063
702
  function emitRuntimeNotification(content, meta = {}) {
1064
703
  const text = String(content || '').trim();
1065
704
  if (!text) return false;
@@ -1101,40 +740,6 @@ export async function createMixdogSessionRuntime({
1101
740
  };
1102
741
  }
1103
742
 
1104
- function mcpStatus() {
1105
- const { servers: configured, sources } = resolveEffectiveMcpServers();
1106
- const connected = new Map((mcpClient.getMcpServerStatus?.() || []).map((row) => [row.name, row]));
1107
- const failures = new Map((mcpFailures || []).map((row) => [row.name, row]));
1108
- const servers = [];
1109
- for (const [name, cfg] of Object.entries(configured)) {
1110
- const live = connected.get(name);
1111
- const fail = failures.get(name);
1112
- servers.push({
1113
- name,
1114
- configured: true,
1115
- enabled: cfg?.enabled !== false,
1116
- connected: Boolean(live),
1117
- status: cfg?.enabled === false ? 'disabled' : live ? 'connected' : fail ? 'failed' : 'disconnected',
1118
- transport: mcpTransportLabel(cfg),
1119
- toolCount: live?.toolCount || 0,
1120
- tools: live?.tools || [],
1121
- error: fail?.msg || null,
1122
- source: sources[name] || 'config',
1123
- });
1124
- connected.delete(name);
1125
- }
1126
- for (const live of connected.values()) {
1127
- servers.push({ ...live, configured: false, status: 'connected' });
1128
- }
1129
- servers.sort((a, b) => String(a.name).localeCompare(String(b.name)));
1130
- return {
1131
- servers,
1132
- configuredCount: Object.keys(configured).length,
1133
- connectedCount: servers.filter((row) => row.connected).length,
1134
- failedCount: servers.filter((row) => row.status === 'failed').length,
1135
- };
1136
- }
1137
-
1138
743
  function skillsStatus() {
1139
744
  const skills = typeof contextMod.collectSkillsCached === 'function'
1140
745
  ? contextMod.collectSkillsCached(currentCwd)
@@ -1158,113 +763,51 @@ export async function createMixdogSessionRuntime({
1158
763
  };
1159
764
  }
1160
765
 
1161
- function scheduleCodeGraphPrewarm(delayMs = codeGraphPrewarmDelayMs, reason = 'cwd') {
1162
- if (!codeGraphPrewarmEnabled) {
1163
- bootProfile('code-graph:prewarm-skipped', { reason: 'disabled' });
1164
- return;
1165
- }
1166
- if (closeRequested) return;
1167
- codeGraphPrewarmQueuedCwd = currentCwd;
1168
- if (codeGraphPrewarmTimer) return;
1169
- codeGraphPrewarmTimer = setTimeout(() => {
1170
- codeGraphPrewarmTimer = null;
1171
- if (closeRequested) return;
1172
- if (activeTurnCount > 0 || sessionCreatePromise) {
1173
- bootProfile('code-graph:prewarm-deferred', { reason: activeTurnCount > 0 ? 'turn-active' : 'session-create' });
1174
- scheduleCodeGraphPrewarm(backgroundBusyRetryMs, 'busy');
1175
- return;
1176
- }
1177
- if (codeGraphPrewarmInFlight) {
1178
- bootProfile('code-graph:prewarm-deferred', { reason: 'in-flight' });
1179
- scheduleCodeGraphPrewarm(backgroundBusyRetryMs, 'in-flight');
1180
- return;
1181
- }
1182
- const prewarmCwd = codeGraphPrewarmQueuedCwd || currentCwd;
1183
- codeGraphPrewarmQueuedCwd = '';
1184
- codeGraphPrewarmInFlight = true;
1185
- const startedAt = performance.now();
1186
- bootProfile('code-graph:prewarm:start', { cwd: prewarmCwd, reason });
1187
- void getCodeGraphModule()
1188
- .then((mod) => {
1189
- if (typeof mod?.prewarmCodeGraphIfProject !== 'function') return false;
1190
- return mod.prewarmCodeGraphIfProject(prewarmCwd);
1191
- })
1192
- .then((scheduled) => bootProfile(scheduled ? 'code-graph:prewarm:scheduled' : 'code-graph:prewarm:no-project', {
1193
- cwd: prewarmCwd,
1194
- ms: (performance.now() - startedAt).toFixed(1),
1195
- }))
1196
- .catch((error) => bootProfile('code-graph:prewarm:failed', {
1197
- cwd: prewarmCwd,
1198
- ms: (performance.now() - startedAt).toFixed(1),
1199
- error: error?.message || String(error),
1200
- }))
1201
- .finally(() => {
1202
- codeGraphPrewarmInFlight = false;
1203
- if (codeGraphPrewarmQueuedCwd && !closeRequested) {
1204
- scheduleCodeGraphPrewarm(backgroundBusyRetryMs, 'queued');
1205
- }
1206
- });
1207
- }, delayMs);
1208
- codeGraphPrewarmTimer.unref?.();
1209
- }
1210
-
1211
- function applyResolvedCwd(nextCwd, { markRefresh = true } = {}) {
1212
- const resolved = resolve(nextCwd);
1213
- const stat = statSync(resolved);
1214
- if (!stat.isDirectory()) throw new Error(`cwd: not a directory: ${resolved}`);
1215
- const changed = resolve(currentCwd) !== resolved;
1216
- currentCwd = resolved;
1217
- process.env.MIXDOG_SESSION_CWD = currentCwd;
1218
- writeLastSessionCwd(currentCwd);
1219
- if (session) session.cwd = currentCwd;
1220
- // cwd changes NEVER recreate the session: a mid-conversation cwd switch must
1221
- // preserve the full message history (and the BP1–BP3 prompt cache). We only
1222
- // retarget the live session's cwd in place; tool execution already reads the
1223
- // current cwd per turn. `cwd` is intentionally absent from the prompt
1224
- // context (see composeSystemPrompt), so there is nothing prompt-side to
1225
- // refresh either. `markRefresh`/`changed` are kept only for signature
1226
- // compatibility with existing callers.
1227
- void markRefresh;
1228
- // Lazy mode: before the first turn (e.g. the initial project-selection
1229
- // cwd set), do NOT prewarm — that is exactly the post-first-frame freeze
1230
- // we are avoiding. Once a turn has run, an in-session cwd switch DOES
1231
- // prewarm the new dir, since a lookup there is now likely.
1232
- if (codeGraphPrewarmLazy && !codeGraphFirstTurnPrewarmDone) {
1233
- bootProfile('code-graph:prewarm-lazy', { reason: 'cwd-deferred-to-first-turn' });
1234
- } else {
1235
- scheduleCodeGraphPrewarm(changed ? 0 : codeGraphPrewarmDelayMs, changed ? 'cwd-change' : 'cwd');
1236
- }
1237
- // Project-local `.mcp.json` follows the cwd: when the effective project MCP
1238
- // set changes, reconnect in the background (never await — this stays sync,
1239
- // and the session is preserved). Guarded so a no-op cwd change does not
1240
- // churn connections.
1241
- if (changed) {
1242
- try {
1243
- const nextKey = resolved + '\u0000' + JSON.stringify(readProjectMcpServers(resolved));
1244
- if (nextKey !== lastProjectMcpKey) {
1245
- lastProjectMcpKey = nextKey;
1246
- void connectConfiguredMcp({ reset: true })
1247
- .then(() => invalidatePreSessionToolSurface())
1248
- .catch(() => {});
1249
- }
1250
- } catch {}
1251
- }
1252
- // CwdChanged: bridge an effective cwd switch to the standard hook bus.
1253
- // No matcher event — payload is minimal { cwd }. Fire-and-forget.
1254
- if (changed) {
1255
- try { void hooks.dispatch('CwdChanged', hookCommonPayload({ cwd: currentCwd })); } catch {}
1256
- }
1257
- return currentCwd;
1258
- }
1259
-
1260
- async function refreshSessionForCwdIfNeeded(reason = 'cwd-change') {
1261
- // No-op: cwd changes are applied in place by applyResolvedCwd and never
1262
- // tear down the session. Retained as a stable hook for ask()'s pre-turn
1263
- // call so the surrounding turn flow is unchanged.
1264
- void reason;
1265
- sessionNeedsCwdRefresh = false;
1266
- return session;
1267
- }
766
+ // cwd resolution/apply + plugins-status + core-memory context. Extracted to
767
+ // session-runtime/cwd-plugins.mjs; the facade keeps ownership of the mutable
768
+ // currentCwd/session/config/lastProjectMcpKey locals via getter/setter
769
+ // injection and passes the later-defined callbacks (prewarm/tool-surface/
770
+ // memory) as closures.
771
+ const {
772
+ resolveCwdPath,
773
+ applyResolvedCwd,
774
+ refreshSessionForCwdIfNeeded,
775
+ pluginsStatus,
776
+ loadCoreMemoryContext,
777
+ } = createCwdPlugins({
778
+ getCurrentCwd: () => currentCwd,
779
+ setCurrentCwd: (next) => { currentCwd = next; },
780
+ getConfig: () => config,
781
+ getSession: () => session,
782
+ getRoute: () => route,
783
+ getLastProjectMcpKey: () => lastProjectMcpKey,
784
+ setLastProjectMcpKey: (next) => { lastProjectMcpKey = next; },
785
+ isCodeGraphPrewarmLazy: () => codeGraphPrewarmLazy,
786
+ isCodeGraphFirstTurnPrewarmDone: () => codeGraphFirstTurnPrewarmDone,
787
+ getCodeGraphPrewarmDelayMs: () => codeGraphPrewarmDelayMs,
788
+ setSessionNeedsCwdRefresh: (next) => { sessionNeedsCwdRefresh = next; },
789
+ connectConfiguredMcp,
790
+ invalidatePreSessionToolSurface: (...a) => invalidatePreSessionToolSurface(...a),
791
+ scheduleCodeGraphPrewarm: (...a) => scheduleCodeGraphPrewarm(...a),
792
+ hooks,
793
+ hookCommonPayload: (...a) => hookCommonPayload(...a),
794
+ bootProfile,
795
+ getMemoryModule: (...a) => getMemoryModule(...a),
796
+ listRegisteredPlugins,
797
+ pluginAdminStatus,
798
+ pluginManifest,
799
+ pluginMcpServerName,
800
+ mcpScriptForPlugin,
801
+ countSkillFiles,
802
+ readProjectMcpServers,
803
+ writeLastSessionCwd,
804
+ clean,
805
+ resolve,
806
+ statSync,
807
+ existsSync,
808
+ cfgMod,
809
+ STANDALONE_DATA_DIR,
810
+ });
1268
811
 
1269
812
  function skillContent(name) {
1270
813
  const res = typeof contextMod.loadSkillResource === 'function'
@@ -1308,166 +851,6 @@ export async function createMixdogSessionRuntime({
1308
851
  return { name, filePath };
1309
852
  }
1310
853
 
1311
- function pluginsStatus() {
1312
- const dataDir = cfgMod.getPluginData?.();
1313
- const configuredMcp = config?.mcpServers && typeof config.mcpServers === 'object'
1314
- ? config.mcpServers
1315
- : {};
1316
- const plugins = [];
1317
- const addRegisteredPlugin = (entry) => {
1318
- const root = clean(entry.root);
1319
- if (!root || !existsSync(root)) return;
1320
- const manifest = pluginManifest(root);
1321
- const name = clean(manifest.name) || clean(manifest.id) || clean(entry.name) || root.split(/[\\/]/).pop() || root;
1322
- const plugin = {
1323
- id: clean(entry.id) || name,
1324
- name,
1325
- title: clean(manifest.title) || clean(manifest.displayName) || clean(entry.title) || name,
1326
- version: clean(manifest.version) || clean(entry.version) || null,
1327
- description: clean(manifest.description) || clean(entry.description),
1328
- marketplace: null,
1329
- source: clean(entry.sourceType) === 'local' ? 'local' : 'registry',
1330
- sourceUrl: clean(entry.source),
1331
- sourceType: clean(entry.sourceType) || 'git',
1332
- managed: entry.managed !== false,
1333
- root,
1334
- installedAt: entry.installedAt || null,
1335
- updatedAt: entry.updatedAt || null,
1336
- skillCount: countSkillFiles(root),
1337
- mcpScript: mcpScriptForPlugin(root),
1338
- };
1339
- plugin.mcpServerName = pluginMcpServerName(plugin);
1340
- plugin.mcpEnabled = Object.prototype.hasOwnProperty.call(configuredMcp, plugin.mcpServerName)
1341
- || Object.keys(configuredMcp).some((k) => k.startsWith(`${plugin.mcpServerName}--`));
1342
- plugins.push(plugin);
1343
- };
1344
-
1345
- for (const entry of listRegisteredPlugins({ dataDir })) addRegisteredPlugin(entry);
1346
-
1347
- plugins.sort((a, b) => {
1348
- if (a.source !== b.source) return a.source.localeCompare(b.source);
1349
- return a.name.localeCompare(b.name);
1350
- });
1351
- const admin = pluginAdminStatus({ dataDir });
1352
- return {
1353
- count: plugins.length,
1354
- plugins,
1355
- roots: {
1356
- registry: admin.registryPath,
1357
- installed: admin.installRoot,
1358
- },
1359
- };
1360
- }
1361
-
1362
- // Merge mixdog-config `agent.mcpServers` with project-local `.mcp.json`.
1363
- // On name collision the project-local `.mcp.json` entry WINS (Claude Code
1364
- // precedence: project > user config). `sources[name]` records each server's
1365
- // origin ('config' | 'project') for status reporting.
1366
- function resolveEffectiveMcpServers() {
1367
- const configured = config?.mcpServers && typeof config.mcpServers === 'object'
1368
- ? config.mcpServers
1369
- : {};
1370
- const project = readProjectMcpServers(currentCwd);
1371
- const servers = { ...configured, ...project };
1372
- const sources = {};
1373
- for (const name of Object.keys(configured)) sources[name] = 'config';
1374
- for (const name of Object.keys(project)) sources[name] = 'project';
1375
- return { servers, sources };
1376
- }
1377
-
1378
- async function connectConfiguredMcp({ reset = false } = {}) {
1379
- // Serialize reconnects: boot connect, cwd-change reset, and rapid cwd
1380
- // switches must never interleave their disconnect/connect phases, or an
1381
- // older run finishing after a newer reset could re-add stale servers into
1382
- // the shared client registry. Approach: a generation token + a single
1383
- // in-flight promise. Each call bumps the generation, waits for any prior
1384
- // run to finish, then bails if a newer call has superseded it — leaving the
1385
- // latest requested effective-server-set in the registry.
1386
- const gen = ++mcpConnectGeneration;
1387
- if (mcpConnectInFlight) {
1388
- try { await mcpConnectInFlight; } catch { /* prior run's failures already captured */ }
1389
- }
1390
- if (gen !== mcpConnectGeneration) return mcpStatus();
1391
- const run = (async () => {
1392
- if (reset) await mcpClient.disconnectAll?.();
1393
- mcpFailures = [];
1394
- const { servers } = resolveEffectiveMcpServers();
1395
- if (Object.keys(servers).length === 0) return;
1396
- try {
1397
- await mcpClient.connectMcpServers(servers);
1398
- } catch (error) {
1399
- mcpFailures = Array.isArray(error?.failures)
1400
- ? error.failures
1401
- : [{ name: 'mcp', msg: error?.message || String(error) }];
1402
- }
1403
- })();
1404
- mcpConnectInFlight = run;
1405
- try {
1406
- await run;
1407
- } finally {
1408
- if (mcpConnectInFlight === run) mcpConnectInFlight = null;
1409
- }
1410
- return mcpStatus();
1411
- }
1412
-
1413
- function normalizeMcpServerInput(input = {}) {
1414
- const name = clean(input.name).toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
1415
- if (!name) throw new Error('MCP server name is required');
1416
- const coerceStringRecord = (value) => {
1417
- if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
1418
- const out = {};
1419
- for (const [key, val] of Object.entries(value)) {
1420
- if (val === undefined || val === null) continue;
1421
- out[String(key)] = String(val);
1422
- }
1423
- return Object.keys(out).length > 0 ? out : null;
1424
- };
1425
- const withOptionalHeaders = (config) => {
1426
- const headers = coerceStringRecord(input.headers);
1427
- if (headers) config.headers = headers;
1428
- return config;
1429
- };
1430
- const url = clean(input.url);
1431
- const type = clean(input.type).toLowerCase();
1432
- if (url) {
1433
- if (type === 'sse') {
1434
- if (!/^https?:\/\//i.test(url)) throw new Error('MCP URL must start with http:// or https://');
1435
- return { name, config: withOptionalHeaders({ type: 'sse', url }) };
1436
- }
1437
- if (type === 'ws') {
1438
- if (!/^(?:wss?|https?):\/\//i.test(url)) {
1439
- throw new Error('MCP WebSocket URL must start with ws://, wss://, http://, or https://');
1440
- }
1441
- return { name, config: withOptionalHeaders({ type: 'ws', url }) };
1442
- }
1443
- if (type === 'http' || type === 'streamable-http') {
1444
- if (!/^https?:\/\//i.test(url)) throw new Error('MCP URL must start with http:// or https://');
1445
- return { name, config: withOptionalHeaders({ type: 'http', url }) };
1446
- }
1447
- if (/^wss?:\/\//i.test(url)) {
1448
- return { name, config: withOptionalHeaders({ type: 'ws', url }) };
1449
- }
1450
- if (!/^https?:\/\//i.test(url)) throw new Error('MCP URL must start with http:// or https://');
1451
- return { name, config: withOptionalHeaders({ type: 'http', url }) };
1452
- }
1453
- const command = clean(input.command);
1454
- if (!command) throw new Error('MCP server command or URL is required');
1455
- const args = Array.isArray(input.args)
1456
- ? input.args.map((v) => String(v)).filter(Boolean)
1457
- : clean(input.args).split(/\s+/).filter(Boolean);
1458
- const requestedCwd = clean(input.cwd);
1459
- const cwdForServer = requestedCwd ? resolve(currentCwd, requestedCwd) : currentCwd;
1460
- const root = resolve(currentCwd);
1461
- const resolvedCwd = resolve(cwdForServer);
1462
- if (resolvedCwd !== root && !resolvedCwd.startsWith(`${root}\\`) && !resolvedCwd.startsWith(`${root}/`)) {
1463
- throw new Error('MCP server cwd must stay under the current project');
1464
- }
1465
- const config = { type: 'stdio', command, args, cwd: resolvedCwd };
1466
- const env = coerceStringRecord(input.env);
1467
- if (env) config.env = env;
1468
- return { name, config };
1469
- }
1470
-
1471
854
  const agentToolStartedAt = performance.now();
1472
855
  const agentTool = createStandaloneAgent({
1473
856
  cfgMod,
@@ -1508,6 +891,16 @@ export async function createMixdogSessionRuntime({
1508
891
  dataDir: cfgMod.getPluginData(),
1509
892
  cwd,
1510
893
  onNotify: (msg) => {
894
+ // Single-holder remote: the worker reports it lost the bridge seat to a
895
+ // newer remote session. Drop remote mode entirely on this session (no
896
+ // handover, no retry) and tell UI listeners so the indicator updates.
897
+ if (msg?.method === 'notifications/mixdog/remote') {
898
+ if (msg?.params?.state === 'superseded' && remoteEnabled) {
899
+ stopRemote('superseded-by-newer-remote-session');
900
+ emitRemoteStateChange(false, 'superseded');
901
+ }
902
+ return;
903
+ }
1511
904
  if (msg?.method !== 'notifications/claude/channel') return;
1512
905
  const params = msg?.params && typeof msg.params === 'object' ? msg.params : {};
1513
906
  const meta = params.meta && typeof params.meta === 'object' ? params.meta : {};
@@ -1531,8 +924,6 @@ export async function createMixdogSessionRuntime({
1531
924
  ...(channelToolDefs?.TOOL_DEFS || []).filter((tool) => channels.isChannelTool(tool?.name)),
1532
925
  ...(codeGraphToolDefs?.CODE_GRAPH_TOOL_DEFS || []).filter((tool) => tool?.name === 'code_graph'),
1533
926
  ...agentTool.tools,
1534
- PROVIDER_STATUS_TOOL,
1535
- CHANNEL_STATUS_TOOL,
1536
927
  ].map(applyStandaloneToolDefaults);
1537
928
  bootProfile('tools:ready', { ms: (performance.now() - toolsStartedAt).toFixed(1), count: standaloneTools.length });
1538
929
 
@@ -1607,7 +998,7 @@ export async function createMixdogSessionRuntime({
1607
998
  if (name === 'explore') {
1608
999
  const callerSessionId = callerCtx?.callerSessionId || session?.id || null;
1609
1000
  return await runExplore(args || {}, {
1610
- callerCwd: args?.cwd ? resolveCwdPath(currentCwd, args.cwd) : callerCwd,
1001
+ callerCwd: args?.cwd ? resolveCwdPath(args.cwd) : callerCwd,
1611
1002
  callerSessionId,
1612
1003
  routingSessionId: callerSessionId,
1613
1004
  clientHostPid: callerCtx?.clientHostPid || session?.clientHostPid || process.pid,
@@ -1617,7 +1008,7 @@ export async function createMixdogSessionRuntime({
1617
1008
  if (name === 'cwd') {
1618
1009
  const action = clean(args?.action || (args?.path ? 'set' : 'get')).toLowerCase();
1619
1010
  if (action === 'set') {
1620
- applyResolvedCwd(resolveCwdPath(currentCwd, args?.path));
1011
+ applyResolvedCwd(resolveCwdPath(args?.path));
1621
1012
  } else if (action !== 'get') {
1622
1013
  throw new Error(`cwd: unknown action "${action}"`);
1623
1014
  }
@@ -1637,8 +1028,6 @@ export async function createMixdogSessionRuntime({
1637
1028
  notifyFn: notifyFnForSession(callerSessionId),
1638
1029
  });
1639
1030
  }
1640
- if (name === 'provider_status') return renderProviderStatus(displayConfig());
1641
- if (name === 'channel_status') return renderChannelStatus();
1642
1031
  if (channels.isChannelTool(name)) {
1643
1032
  if (!channelsEnabled()) throw new Error('channels are disabled in settings');
1644
1033
  return await channels.execute(name, args || {});
@@ -1660,159 +1049,58 @@ export async function createMixdogSessionRuntime({
1660
1049
  }
1661
1050
 
1662
1051
  function invalidateProviderCaches() {
1663
- providerModelsCache = { models: null, at: 0 };
1664
- providerModelsPromise = null;
1665
- providerModelsLoadSeq += 1;
1666
- searchProviderModelsCache = { models: null, at: 0 };
1667
- usageDashboardCache = { dashboard: null, at: 0 };
1668
- usageDashboardPromise = null;
1669
- providerSetupCache = { setup: null, at: 0 };
1670
- providerSetupQuickCache = { setup: null, at: 0 };
1671
- providerSetupPromise = null;
1052
+ providerModelCaches.providerModelsCache = { models: null, at: 0 };
1053
+ providerModelCaches.providerModelsPromise = null;
1054
+ providerModelCaches.providerModelsLoadSeq += 1;
1055
+ providerModelCaches.searchProviderModelsCache = { models: null, at: 0 };
1056
+ providerUsageCaches.usageDashboardCache = { dashboard: null, at: 0 };
1057
+ providerUsageCaches.usageDashboardPromise = null;
1058
+ providerUsageCaches.providerSetupCache = { setup: null, at: 0 };
1059
+ providerUsageCaches.providerSetupQuickCache = { setup: null, at: 0 };
1060
+ providerUsageCaches.providerSetupPromise = null;
1672
1061
  providerInitPromise = null;
1673
1062
  modelMetaByRoute.clear();
1674
1063
  }
1675
1064
 
1676
- function adoptConfig(nextConfig, { hasSecrets = configHasSecrets } = {}) {
1677
- config = nextConfig;
1678
- configHasSecrets = hasSecrets === true;
1679
- setConfiguredShell(normalizeSystemShellConfig(config.shell).command);
1680
- searchRoute = normalizeSearchRouteConfig(config.searchRoute) || normalizeSearchRouteConfig(searchRoute);
1681
- return config;
1682
- }
1683
-
1684
- // Persisting mixdog-config.json is heavy: it takes a cross-process file
1685
- // lock, does an atomic temp+rename, and on win32 shells out to icacls
1686
- // (an external process, multiple times per write) to enforce an owner-only
1687
- // ACL. Doing that synchronously on EVERY option toggle froze the TUI main
1688
- // thread, so adjusting a picker felt laggy and unresponsive. We split the
1689
- // two concerns: adopt the new config in-memory IMMEDIATELY (so getProfile/
1690
- // getAutoClear/etc. and the UI read fresh values on the same tick), and
1691
- // DEBOUNCE the actual disk write so a burst of toggles collapses into a
1692
- // single persist after input settles. The security model is unchanged —
1693
- // cfgMod.saveConfig still strips apiKey (keychain-only) and still applies
1694
- // the owner-only ACL; we only changed WHEN it runs, never HOW.
1695
- let pendingConfigToSave = null;
1696
- let configSaveTimer = null;
1697
- const CONFIG_SAVE_DEBOUNCE_MS = 150;
1698
- // Same debounce pattern as saveConfigAndAdopt below, but for the channels-
1699
- // section backend switch: channel-admin's setBackend() does a synchronous
1700
- // file-locked read-modify-write (updateChannelsSection), which is a
1701
- // secondary hitch source on the settings-toggle key handler. Coalesce
1702
- // rapid toggles and move the write off the key-handler tick.
1703
- let pendingBackendName = null;
1704
- let backendSaveTimer = null;
1705
- function flushBackendSave() {
1706
- if (backendSaveTimer) {
1707
- clearTimeout(backendSaveTimer);
1708
- backendSaveTimer = null;
1709
- }
1710
- if (pendingBackendName === null) return;
1711
- const name = pendingBackendName;
1712
- pendingBackendName = null;
1713
- try {
1714
- setBackend(name);
1715
- } catch (err) {
1716
- process.stderr.write(`[channels] debounced setBackend failed: ${err?.message || err}\n`);
1717
- }
1718
- }
1719
- // Debounced persist for the top-level `outputStyle` config key. This CANNOT
1720
- // ride saveConfigAndAdopt/flushConfigSave: cfgMod.saveConfig() serializes
1721
- // ONLY the agent-section fields (see orchestrator config.mjs saveConfig),
1722
- // so a top-level outputStyle adopted in memory would never reach disk and
1723
- // would silently revert on the next fresh catalog read/restart. Persist it
1724
- // through sharedCfgMod.updateConfig (whole-root RMW under the same file
1725
- // lock), debounced off the key-handler tick like the backend switch above.
1726
- let pendingOutputStyleId = null;
1727
- let outputStyleSaveTimer = null;
1728
- function flushOutputStyleSave() {
1729
- if (outputStyleSaveTimer) {
1730
- clearTimeout(outputStyleSaveTimer);
1731
- outputStyleSaveTimer = null;
1732
- }
1733
- if (pendingOutputStyleId === null) return;
1734
- const styleId = pendingOutputStyleId;
1735
- pendingOutputStyleId = null;
1736
- try {
1737
- sharedCfgMod.updateConfig((root) => {
1738
- const next = { ...(root || {}), outputStyle: styleId };
1739
- if (next.agent && typeof next.agent === 'object' && !Array.isArray(next.agent)) {
1740
- const agent = { ...next.agent };
1741
- delete agent.outputStyle;
1742
- next.agent = agent;
1743
- }
1744
- return next;
1745
- });
1746
- } catch (err) {
1747
- process.stderr.write(`[config] debounced outputStyle save failed: ${err?.message || err}\n`);
1748
- }
1749
- }
1750
- function flushConfigSave() {
1751
- if (configSaveTimer) {
1752
- clearTimeout(configSaveTimer);
1753
- configSaveTimer = null;
1754
- }
1755
- if (pendingConfigToSave === null) return;
1756
- const snapshot = pendingConfigToSave;
1757
- pendingConfigToSave = null;
1758
- try {
1759
- cfgMod.saveConfig(snapshot);
1760
- } catch (err) {
1761
- process.stderr.write(`[config] debounced saveConfig failed: ${err?.message || err}\n`);
1762
- }
1763
- }
1764
- function saveConfigAndAdopt(nextConfig, { hasSecrets = configHasSecrets } = {}) {
1765
- // In-memory adopt is synchronous and first so callers that read back the
1766
- // value immediately (e.g. setProfile -> getProfile) see the new state.
1767
- const adopted = adoptConfig(nextConfig, { hasSecrets });
1768
- // Persist the adopted object; coalesce rapid successive changes into one
1769
- // disk write after CONFIG_SAVE_DEBOUNCE_MS of quiet.
1770
- pendingConfigToSave = config;
1771
- if (configSaveTimer) clearTimeout(configSaveTimer);
1772
- configSaveTimer = setTimeout(flushConfigSave, CONFIG_SAVE_DEBOUNCE_MS);
1773
- configSaveTimer.unref?.();
1774
- return adopted;
1775
- }
1776
-
1777
- function reloadFullConfig() {
1778
- // A pending debounced write holds the only copy of the latest change.
1779
- // Flush it before re-reading from disk so loadConfig() observes (and the
1780
- // subsequent adopt preserves) that change instead of reverting to a stale
1781
- // on-disk snapshot.
1782
- flushConfigSave();
1783
- return adoptConfig(cfgMod.loadConfig(), { hasSecrets: true });
1784
- }
1785
-
1786
- function ensureFullConfig() {
1787
- if (configHasSecrets) return config;
1788
- return reloadFullConfig();
1789
- }
1790
-
1791
- function displayConfig() {
1792
- return config;
1793
- }
1794
-
1795
- function ensureConfigForRouteProvider() {
1796
- const providerId = clean(route.provider);
1797
- const providerCfg = config?.providers?.[providerId];
1798
- if (configHasSecrets || LAZY_SECRET_PROVIDERS.has(providerId) || providerCfg?.apiKey) {
1799
- return config;
1800
- }
1801
- return ensureFullConfig();
1802
- }
1803
-
1804
- function refreshStatuslineUsageSnapshot(routeLike = {}) {
1805
- const providerId = clean(routeLike.provider);
1806
- const modelId = clean(routeLike.model);
1807
- if (!providerId || !providerId.includes('oauth')) return;
1808
- const providerObj = reg.getProvider(providerId);
1809
- if (!providerObj) return;
1810
- void fetchOAuthUsageSnapshot({ provider: providerId, model: modelId }, providerObj, (message) => {
1811
- if (process.env.MIXDOG_STATUSLINE_TRACE) {
1812
- try { process.stderr.write(`[statusline] ${message}\n`); } catch {}
1813
- }
1814
- }).catch(() => {});
1815
- }
1065
+ // Config reload/save/adopt family + output-style status cache. Extracted to
1066
+ // session-runtime/config-lifecycle.mjs; the facade retains ownership of the
1067
+ // config/searchRoute/configHasSecrets mutable locals via getter/setter
1068
+ // injection (the proven mutable-state pattern).
1069
+ const {
1070
+ getOutputStyleStatusCached,
1071
+ invalidateOutputStyleStatusCache,
1072
+ seedOutputStyleStatusCache,
1073
+ adoptConfig,
1074
+ saveConfigAndAdopt,
1075
+ flushConfigSave,
1076
+ flushBackendSave,
1077
+ scheduleBackendSave,
1078
+ flushOutputStyleSave,
1079
+ scheduleOutputStyleSave,
1080
+ reloadFullConfig,
1081
+ ensureFullConfig,
1082
+ displayConfig,
1083
+ ensureConfigForRouteProvider,
1084
+ } = createConfigLifecycle({
1085
+ getConfig: () => config,
1086
+ setConfig: (next) => { config = next; },
1087
+ getSearchRoute: () => searchRoute,
1088
+ setSearchRoute: (next) => { searchRoute = next; },
1089
+ getConfigHasSecrets: () => configHasSecrets,
1090
+ setConfigHasSecrets: (next) => { configHasSecrets = next; },
1091
+ getRoute: () => route,
1092
+ cfgMod,
1093
+ sharedCfgMod,
1094
+ setBackend,
1095
+ setConfiguredShell,
1096
+ normalizeSystemShellConfig,
1097
+ normalizeSearchRouteConfig,
1098
+ outputStyleStatus,
1099
+ LAZY_SECRET_PROVIDERS,
1100
+ clean,
1101
+ resolve,
1102
+ STANDALONE_DATA_DIR,
1103
+ });
1816
1104
 
1817
1105
  async function ensureProvidersReady(providerConfig = config.providers || {}) {
1818
1106
  if (providerInitPromise) return await providerInitPromise;
@@ -1823,499 +1111,113 @@ export async function createMixdogSessionRuntime({
1823
1111
  return await providerInitPromise;
1824
1112
  }
1825
1113
 
1826
- async function cachedProviderSetup({ force = false, quick = false } = {}) {
1827
- if (!force && providerSetupCache.setup) {
1828
- return providerSetupCache.setup;
1829
- }
1830
- if (quick) {
1831
- if (!force && providerSetupQuickCache.setup) {
1832
- return providerSetupQuickCache.setup;
1833
- }
1834
- const setup = await providerSetup(displayConfig(), { detectLocal: false, checkSecrets: false });
1835
- providerSetupQuickCache = { setup, at: Date.now() };
1836
- if (!providerSetupPromise && !providerSetupWarmupTimer && !closeRequested) {
1837
- scheduleProviderSetupWarmup(0);
1838
- }
1839
- return setup;
1840
- }
1841
- if (providerSetupPromise) return await providerSetupPromise;
1842
- providerSetupPromise = providerSetup(displayConfig(), { detectLocal: true })
1843
- .then((setup) => {
1844
- providerSetupCache = { setup, at: Date.now() };
1845
- return setup;
1846
- })
1847
- .finally(() => {
1848
- providerSetupPromise = null;
1849
- });
1850
- return await providerSetupPromise;
1851
- }
1852
-
1853
- function modelMetaKey(providerId, modelId) {
1854
- return `${clean(providerId)}\n${clean(modelId)}`;
1855
- }
1856
-
1857
- async function lookupModelMeta(providerId, modelId, { allowFetch = false } = {}) {
1858
- const key = modelMetaKey(providerId, modelId);
1859
- if (modelMetaByRoute.has(key)) return modelMetaByRoute.get(key);
1860
- const providerImpl = reg.getProvider(providerId);
1861
- if (!providerImpl || typeof providerImpl.listModels !== 'function') {
1862
- const fallback = { id: modelId, provider: providerId };
1863
- modelMetaByRoute.set(key, fallback);
1864
- return fallback;
1865
- }
1866
- if (typeof providerImpl.getCachedModelInfo === 'function') {
1867
- const cached = providerImpl.getCachedModelInfo(modelId);
1868
- if (cached) {
1869
- const meta = { ...cached, id: cached.id || modelId, provider: providerId };
1870
- modelMetaByRoute.set(key, meta);
1871
- return meta;
1872
- }
1873
- }
1874
- if (!allowFetch) {
1875
- const fallback = { id: modelId, provider: providerId };
1876
- modelMetaByRoute.set(key, fallback);
1877
- scheduleProviderModelWarmup();
1878
- return fallback;
1879
- }
1880
- try {
1881
- const models = await providerImpl.listModels();
1882
- const found = Array.isArray(models) ? models.find((m) => m?.id === modelId) : null;
1883
- const meta = found || { id: modelId, provider: providerId };
1884
- modelMetaByRoute.set(key, meta);
1885
- return meta;
1886
- } catch {
1887
- const fallback = { id: modelId, provider: providerId };
1888
- modelMetaByRoute.set(key, fallback);
1889
- return fallback;
1890
- }
1891
- }
1892
-
1893
- function parsedProviderModelVersion(id) {
1894
- const text = clean(id).toLowerCase();
1895
- const claude = text.match(/^claude-[a-z]+-(\d+)(?:[-.](\d+))?/);
1896
- if (claude) return [Number(claude[1]) || 0, Number(claude[2]) || 0];
1897
- const compact = text.match(/(?:^|[-_])(?:o|gpt|grok|qwen|llama|mistral|gemma|phi|glm)(\d+)(?:\.(\d+))?(?:\.(\d{1,3}))?/);
1898
- if (compact) return compact.slice(1).filter((v) => v != null).map((v) => Number(v) || 0);
1899
- const generic = text.match(/(?:^|[-_v])(\d+)(?:\.(\d+))?(?:\.(\d{1,3}))?/);
1900
- return generic ? generic.slice(1).filter((v) => v != null).map((v) => Number(v) || 0) : [];
1901
- }
1902
-
1903
- function compareProviderModelVersion(a, b) {
1904
- const va = parsedProviderModelVersion(a.id || a.display || a.name);
1905
- const vb = parsedProviderModelVersion(b.id || b.display || b.name);
1906
- if (va.length === 0 && vb.length === 0) return 0;
1907
- if (va.length === 0) return 1;
1908
- if (vb.length === 0) return -1;
1909
- for (let i = 0; i < Math.max(va.length, vb.length); i += 1) {
1910
- const delta = (vb[i] || 0) - (va[i] || 0);
1911
- if (delta) return delta;
1912
- }
1913
- return 0;
1914
- }
1915
-
1916
- function providerModelReleaseTime(model) {
1917
- if (model?.releaseDate) {
1918
- const t = Date.parse(model.releaseDate);
1919
- if (Number.isFinite(t)) return t;
1920
- }
1921
- const created = Number(model?.created);
1922
- if (Number.isFinite(created) && created > 0) {
1923
- return created < 1_000_000_000_000 ? created * 1000 : created;
1924
- }
1925
- const dated = clean(model?.id).match(/(?:^|-)(\d{4})(\d{2})(\d{2})(?:$|-)/);
1926
- return dated ? (Date.parse(`${dated[1]}-${dated[2]}-${dated[3]}`) || 0) : 0;
1927
- }
1928
-
1929
- function isClaudeProviderModel(model) {
1930
- return clean(model?.provider).toLowerCase().includes('anthropic')
1931
- && /^claude-[a-z]+-/.test(clean(model?.id).toLowerCase());
1932
- }
1933
-
1934
- function compareProviderModelRecency(a, b) {
1935
- if (isClaudeProviderModel(a) && isClaudeProviderModel(b)) {
1936
- if (a.latest !== b.latest) return a.latest ? -1 : 1;
1937
- const versionDelta = compareProviderModelVersion(a, b);
1938
- if (versionDelta) return versionDelta;
1939
- const ta = providerModelReleaseTime(a);
1940
- const tb = providerModelReleaseTime(b);
1941
- if (ta !== tb) return tb - ta;
1942
- return clean(a.display || a.id).localeCompare(clean(b.display || b.id));
1943
- }
1944
- const ta = providerModelReleaseTime(a);
1945
- const tb = providerModelReleaseTime(b);
1946
- if (ta !== tb) return tb - ta;
1947
- if (a.latest !== b.latest) return a.latest ? -1 : 1;
1948
- const versionDelta = compareProviderModelVersion(a, b);
1949
- if (versionDelta) return versionDelta;
1950
- return clean(a.display || a.id).localeCompare(clean(b.display || b.id));
1951
- }
1952
-
1953
- function sortProviderModels(models) {
1954
- return (models || []).sort((a, b) => {
1955
- const ar = a.provider === route.provider ? 0 : 1;
1956
- const br = b.provider === route.provider ? 0 : 1;
1957
- if (ar !== br) return ar - br;
1958
- if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
1959
- return compareProviderModelRecency(a, b);
1960
- });
1961
- }
1962
-
1963
- function isSelectableLlmModel(model) {
1964
- const id = clean(model?.id).toLowerCase();
1965
- const display = clean(model?.display || model?.name).toLowerCase();
1966
- const mode = clean(model?.mode).toLowerCase();
1967
- const text = `${id} ${display}`;
1968
- if (!id) return false;
1969
- if (mode && !['chat', 'completion', 'responses', 'messages'].includes(mode)) return false;
1970
- if (/(^|[-_\s])(image|images|video|videos|audio|tts|stt|speech|embedding|embeddings|rerank|moderation|imagine)([-_\s]|$)/i.test(text)) return false;
1971
- if (/(^|[-_\s])(dall[-_\s]?e|sora|imagen)([-_\s]|$)/i.test(text)) return false;
1972
- return true;
1973
- }
1974
-
1975
- function providerModelCacheRow(name, m) {
1976
- return {
1977
- id: m.id,
1978
- provider: name,
1979
- display: m.display || m.name || m.id,
1980
- created: typeof m.created === 'number' ? m.created : null,
1981
- releaseDate: m.releaseDate || null,
1982
- contextWindow: m.contextWindow,
1983
- outputTokens: m.outputTokens || null,
1984
- family: m.family || null,
1985
- tier: m.tier || null,
1986
- latest: m.latest === true,
1987
- description: m.description || '',
1988
- supportsVision: m.supportsVision === true,
1989
- supportsFunctionCalling: m.supportsFunctionCalling === true,
1990
- supportsWebSearch: searchCapableFor(name, m),
1991
- supportsPromptCaching: m.supportsPromptCaching === true,
1992
- supportsReasoning: m.supportsReasoning === true,
1993
- reasoningLevels: Array.isArray(m.reasoningLevels) ? m.reasoningLevels : undefined,
1994
- reasoningOptions: Array.isArray(m.reasoningOptions) ? m.reasoningOptions : [],
1995
- reasoningContentField: m.reasoningContentField || null,
1996
- mode: m.mode || null,
1997
- };
1998
- }
1999
-
2000
- function hydrateProviderModelRow(row) {
2001
- return {
2002
- ...row,
2003
- effortOptions: effortItemsFor(row.provider, row, null),
2004
- fastCapable: fastCapableFor(row.provider, row),
2005
- fastPreferred: fastPreferenceFor(config, row.provider, row.id),
2006
- savedEffort: modelSettingsFor(config, row.provider, row.id).effort || null,
2007
- savedFast: modelSettingsFor(config, row.provider, row.id).fast === true,
2008
- };
2009
- }
2010
-
2011
- function providerModelsFromCacheRows(rows) {
2012
- return sortProviderModels((rows || []).map(hydrateProviderModelRow));
2013
- }
2014
-
2015
- function quickProviderModelRows() {
2016
- const pickerConfig = displayConfig();
2017
- const rows = [];
2018
- const seen = new Set();
2019
- const addRoute = (routeLike = {}) => {
2020
- const provider = clean(routeLike.provider);
2021
- const model = clean(routeLike.model);
2022
- if (!provider || !model) return;
2023
- const key = `${provider}:${model}`;
2024
- if (seen.has(key)) return;
2025
- seen.add(key);
2026
- const row = providerModelCacheRow(provider, {
2027
- id: model,
2028
- name: routeLike.modelDisplay || routeLike.display || model,
2029
- display: routeLike.modelDisplay || routeLike.display || model,
2030
- latest: routeLike.latest === true,
2031
- supportsReasoning: !!routeLike.effort,
2032
- mode: 'chat',
2033
- });
2034
- rows.push(row);
2035
- modelMetaByRoute.set(modelMetaKey(provider, model), row);
2036
- };
2037
-
2038
- addRoute(route);
2039
- for (const preset of pickerConfig.presets || []) addRoute(preset);
2040
- for (const workflowRoute of Object.values(pickerConfig.workflowRoutes || {})) addRoute(workflowRoute);
2041
- for (const agentRoute of Object.values(pickerConfig.agents || {})) addRoute(agentRoute);
2042
- return providerModelsFromCacheRows(rows);
2043
- }
2044
-
2045
- function addQuickSearchModel(rows, seen, provider, model) {
2046
- const providerName = normalizeSearchProviderId(provider);
2047
- const modelId = clean(model?.id || model);
2048
- if (!providerName || !modelId || !isSearchCapableProvider(providerName)) return;
2049
- const key = `${providerName}:${modelId}`;
2050
- if (seen.has(key)) return;
2051
- const row = providerModelCacheRow(providerName, {
2052
- id: modelId,
2053
- name: model?.name || model?.display || modelId,
2054
- display: model?.display || model?.name || modelId,
2055
- contextWindow: model?.contextWindow || null,
2056
- outputTokens: model?.outputTokens || null,
2057
- latest: model?.latest === true,
2058
- supportsWebSearch: true,
2059
- supportsFunctionCalling: model?.supportsFunctionCalling === true,
2060
- supportsPromptCaching: model?.supportsPromptCaching === true,
2061
- supportsReasoning: model?.supportsReasoning === true,
2062
- reasoningLevels: Array.isArray(model?.reasoningLevels) ? model.reasoningLevels : undefined,
2063
- reasoningOptions: Array.isArray(model?.reasoningOptions) ? model.reasoningOptions : [],
2064
- mode: 'chat',
2065
- });
2066
- if (row.supportsWebSearch !== true) return;
2067
- seen.add(key);
2068
- rows.push({
2069
- ...row,
2070
- provider: providerName,
2071
- searchCapable: true,
2072
- searchToolType: row.searchToolType || 'web_search',
2073
- });
2074
- }
2075
-
2076
- function addDefaultSearchModel(rows, seen = new Set()) {
2077
- const mainModel = currentMainSearchModelMeta();
2078
- if (!mainModel || !searchCapableFor(route.provider, mainModel)) return;
2079
- const key = `${SEARCH_DEFAULT_PROVIDER}:${SEARCH_DEFAULT_MODEL}`;
2080
- if (seen.has(key)) return;
2081
- seen.add(key);
2082
- rows.push({
2083
- id: SEARCH_DEFAULT_MODEL,
2084
- provider: SEARCH_DEFAULT_PROVIDER,
2085
- display: 'Default',
2086
- name: 'Default',
2087
- description: `Use current main model: ${route.provider}/${route.model}`,
2088
- supportsWebSearch: true,
2089
- searchCapable: true,
2090
- searchToolType: 'web_search',
2091
- mode: 'chat',
2092
- });
2093
- }
2094
-
2095
- function quickSearchProviderModelRows() {
2096
- const pickerConfig = displayConfig();
2097
- const rows = [];
2098
- const seen = new Set();
2099
- addDefaultSearchModel(rows, seen);
2100
- for (const [name, providerConfig] of Object.entries(pickerConfig.providers || {})) {
2101
- const providerName = normalizeSearchProviderId(name);
2102
- if (!providerConfig?.enabled || !isSearchCapableProvider(providerName)) continue;
2103
- for (const model of QUICK_SEARCH_MODELS[providerName] || []) {
2104
- addQuickSearchModel(rows, seen, providerName, model);
2105
- }
2106
- }
2107
- const configuredSearch = normalizeSearchRouteConfig(pickerConfig.searchRoute) || normalizeSearchRouteConfig(searchRoute);
2108
- if (configuredSearch?.provider && configuredSearch?.model) {
2109
- addQuickSearchModel(rows, seen, configuredSearch.provider, {
2110
- id: configuredSearch.model,
2111
- display: configuredSearch.model,
2112
- });
2113
- }
2114
- const mainModel = currentMainSearchModelMeta();
2115
- if (mainModel && searchCapableFor(route.provider, mainModel)) {
2116
- addQuickSearchModel(rows, seen, route.provider, {
2117
- id: route.model,
2118
- display: route.model,
2119
- });
2120
- }
2121
- return searchModelsFromRows(rows);
2122
- }
2123
-
2124
- function searchModelsFromRows(rows) {
2125
- return sortProviderModels((rows || [])
2126
- .filter((row) => row.supportsWebSearch === true)
2127
- .map((row) => ({
2128
- ...row,
2129
- provider: normalizeSearchProviderId(row.provider),
2130
- searchCapable: true,
2131
- searchToolType: row.searchToolType || 'web_search',
2132
- })));
2133
- }
2134
-
2135
- function searchRowsWithDefault(rows = []) {
2136
- const out = [];
2137
- const seen = new Set();
2138
- addDefaultSearchModel(out, seen);
2139
- for (const row of rows || []) {
2140
- const providerName = normalizeSearchProviderId(row?.provider);
2141
- const modelId = clean(row?.id || row?.model);
2142
- if (providerName === SEARCH_DEFAULT_PROVIDER && modelId.toLowerCase() === SEARCH_DEFAULT_MODEL) continue;
2143
- const key = `${providerName}:${modelId}`;
2144
- if (!providerName || !modelId || seen.has(key)) continue;
2145
- seen.add(key);
2146
- out.push(row);
2147
- }
2148
- return out;
2149
- }
2150
-
2151
- async function collectSearchProviderModels({ force = false } = {}) {
2152
- if (!force && Array.isArray(searchProviderModelsCache.models)) {
2153
- return providerModelsFromCacheRows(searchRowsWithDefault(searchProviderModelsCache.models));
2154
- }
2155
- if (!force && Array.isArray(providerModelsCache.models)) {
2156
- const rows = searchRowsWithDefault(searchModelsFromRows(providerModelsCache.models));
2157
- searchProviderModelsCache = { models: rows, at: Date.now() };
2158
- return providerModelsFromCacheRows(rows);
2159
- }
2160
- if (!force) {
2161
- const rows = searchRowsWithDefault(quickSearchProviderModelRows());
2162
- searchProviderModelsCache = { models: rows, at: Date.now() };
2163
- return providerModelsFromCacheRows(rows);
2164
- }
2165
- if (force) {
2166
- const models = await loadSearchProviderModelsFresh({ forceRefresh: true });
2167
- searchProviderModelsCache = { models, at: Date.now() };
2168
- return providerModelsFromCacheRows(models);
2169
- }
2170
- }
2171
-
2172
- function enabledSearchProviderConfig() {
2173
- ensureFullConfig();
2174
- const out = {};
2175
- for (const [name, providerConfig] of Object.entries(config.providers || {})) {
2176
- const providerName = normalizeSearchProviderId(name);
2177
- if (!providerConfig?.enabled || !isSearchCapableProvider(providerName)) continue;
2178
- out[providerName] = { ...providerConfig, enabled: true };
2179
- }
2180
- return out;
2181
- }
2182
-
2183
- async function loadSearchProviderModelsFresh({ forceRefresh = false } = {}) {
2184
- const searchProviders = enabledSearchProviderConfig();
2185
- const providerNames = Object.keys(searchProviders);
2186
- if (!providerNames.length) return [];
2187
- await ensureProvidersReady(config.providers || {});
2188
- const providerResults = await Promise.all(providerNames.map(async (name) => {
2189
- const provider = reg.getProvider(name);
2190
- if (typeof provider?.listModels !== 'function') return [];
2191
- try {
2192
- let models = null;
2193
- if (forceRefresh && typeof provider._refreshModelCache === 'function') {
2194
- models = await provider._refreshModelCache();
2195
- }
2196
- if (!Array.isArray(models)) {
2197
- models = await provider.listModels();
2198
- }
2199
- if (!Array.isArray(models)) return [];
2200
- const rows = [];
2201
- for (const m of models) {
2202
- if (!m?.id || !isSelectableLlmModel(m)) continue;
2203
- const row = providerModelCacheRow(name, m);
2204
- if (row.supportsWebSearch !== true) continue;
2205
- rows.push({
2206
- ...row,
2207
- provider: normalizeSearchProviderId(row.provider),
2208
- searchCapable: true,
2209
- searchToolType: row.searchToolType || 'web_search',
2210
- });
2211
- modelMetaByRoute.set(modelMetaKey(name, m.id), row);
2212
- }
2213
- return rows;
2214
- } catch {
2215
- // Keep the picker responsive if one search-capable provider has a
2216
- // transient catalog/auth failure.
2217
- return [];
2218
- }
2219
- }));
2220
- const results = [];
2221
- const seen = new Set();
2222
- addDefaultSearchModel(results, seen);
2223
- for (const row of providerResults.flat()) {
2224
- const key = `${normalizeSearchProviderId(row.provider)}:${row.id}`;
2225
- if (seen.has(key)) continue;
2226
- seen.add(key);
2227
- results.push(row);
2228
- }
2229
- return results;
2230
- }
1114
+ const {
1115
+ currentMainSearchModelMeta,
1116
+ runNativeWebSearch,
1117
+ } = createNativeSearch({
1118
+ getRoute: () => route,
1119
+ getSearchRoute: () => searchRoute,
1120
+ setSearchRoute: (next) => { searchRoute = next; },
1121
+ getConfig: () => config,
1122
+ getSession: () => session,
1123
+ getReg: () => reg,
1124
+ ensureFullConfig,
1125
+ ensureProvidersReady,
1126
+ ensureProviderEnabled,
1127
+ normalizeSearchProviderId,
1128
+ normalizeSearchRouteConfig,
1129
+ isDefaultSearchRouteConfig,
1130
+ isSearchCapableProvider,
1131
+ searchCapableFor,
1132
+ });
2231
1133
 
2232
- async function loadProviderModelsFresh({ forceRefresh = false } = {}) {
2233
- ensureFullConfig();
2234
- await ensureProvidersReady(config.providers || {});
2235
- const allProviders = [...reg.getAllProviders()];
2236
- const providerResults = await Promise.all(allProviders.map(async ([name, provider]) => {
2237
- if (typeof provider?.listModels !== 'function') return [];
2238
- try {
2239
- let models = null;
2240
- if (forceRefresh && typeof provider._refreshModelCache === 'function') {
2241
- models = await provider._refreshModelCache();
2242
- }
2243
- if (!Array.isArray(models)) {
2244
- models = await provider.listModels();
2245
- }
2246
- if (!Array.isArray(models)) return [];
2247
- const rows = [];
2248
- for (const m of models) {
2249
- if (!m?.id) continue;
2250
- if (!isSelectableLlmModel(m)) continue;
2251
- rows.push(providerModelCacheRow(name, m));
2252
- }
2253
- return rows;
2254
- } catch {
2255
- // Ignore per-provider catalog failures so one bad credential or
2256
- // transient /models error does not hide other authenticated models.
2257
- return [];
2258
- }
2259
- }));
2260
- const results = [];
2261
- const seen = new Set();
2262
- for (const row of providerResults.flat()) {
2263
- const key = `${row.provider}:${row.id}`;
2264
- if (seen.has(key)) continue;
2265
- seen.add(key);
2266
- results.push(row);
2267
- modelMetaByRoute.set(modelMetaKey(row.provider, row.id), row);
2268
- }
2269
- return results;
2270
- }
1134
+ // Late-bound: createWarmupSchedulers is constructed after this factory, but
1135
+ // cachedProviderSetup(quick) may nudge scheduleProviderSetupWarmup on a cold
1136
+ // quick-cache fill. Thread it by reference so the scheduler is reachable once
1137
+ // it exists (a pre-scheduler quick fill simply skips the warmup nudge).
1138
+ let scheduleProviderSetupWarmupRef = () => {};
1139
+ const {
1140
+ refreshStatuslineUsageSnapshot,
1141
+ cachedProviderSetup,
1142
+ getUsageDashboard,
1143
+ } = createProviderUsage({
1144
+ caches: providerUsageCaches,
1145
+ getConfig: () => config,
1146
+ getReg: () => reg,
1147
+ displayConfig,
1148
+ providerSetup,
1149
+ createUsageDashboard,
1150
+ fetchOAuthUsageSnapshot,
1151
+ isCloseRequested: () => closeRequested,
1152
+ getProviderSetupWarmupTimer: () => warmupTimers.providerSetupWarmupTimer,
1153
+ scheduleProviderSetupWarmup: (delayMs) => scheduleProviderSetupWarmupRef(delayMs),
1154
+ });
2271
1155
 
2272
- async function collectProviderModels({ force = false, quick = false } = {}) {
2273
- if (!force && Array.isArray(providerModelsCache.models)) {
2274
- return providerModelsFromCacheRows(providerModelsCache.models);
2275
- }
2276
- if (!force && quick) {
2277
- warmProviderModelCache();
2278
- return quickProviderModelRows();
2279
- }
2280
- if (force) {
2281
- const seq = ++providerModelsLoadSeq;
2282
- const models = await loadProviderModelsFresh({ forceRefresh: true });
2283
- if (seq === providerModelsLoadSeq) providerModelsCache = { models, at: Date.now() };
2284
- return providerModelsFromCacheRows(models);
2285
- }
2286
- if (!providerModelsPromise) {
2287
- const seq = ++providerModelsLoadSeq;
2288
- providerModelsPromise = loadProviderModelsFresh()
2289
- .then((models) => {
2290
- if (seq === providerModelsLoadSeq) providerModelsCache = { models, at: Date.now() };
2291
- return models;
2292
- })
2293
- .finally(() => {
2294
- providerModelsPromise = null;
2295
- });
2296
- }
2297
- return providerModelsFromCacheRows(await providerModelsPromise);
2298
- }
1156
+ // Holder filled after createQuickModelRows resolves; provider-models and
1157
+ // quick-model-rows are mutually dependent (rows need cache-row helpers, the
1158
+ // model factory needs quick fallbacks) so we thread the quick surface in by
1159
+ // reference after both are constructed.
1160
+ const providerModelQuickHelpers = {};
1161
+ // Late-bound: createWarmupSchedulers is constructed after this factory, but
1162
+ // lookupModelMeta may fire scheduleProviderModelWarmup on a cache miss. Thread
1163
+ // it by reference so the scheduler is called once it exists (miss handling is
1164
+ // best-effort; a pre-scheduler miss simply skips the warmup nudge).
1165
+ let scheduleProviderModelWarmupRef = () => {};
1166
+ const {
1167
+ modelMetaKey,
1168
+ lookupModelMeta,
1169
+ sortProviderModels,
1170
+ providerModelCacheRow,
1171
+ providerModelsFromCacheRows,
1172
+ collectSearchProviderModels,
1173
+ collectProviderModels,
1174
+ warmProviderModelCache,
1175
+ } = createProviderModels({
1176
+ caches: providerModelCaches,
1177
+ modelMetaByRoute,
1178
+ getRoute: () => route,
1179
+ getConfig: () => config,
1180
+ getReg: () => reg,
1181
+ searchCapableFor,
1182
+ sortProviderModelsRaw,
1183
+ providerModelCacheRowRaw,
1184
+ normalizeSearchProviderId,
1185
+ isSearchCapableProvider,
1186
+ ensureFullConfig,
1187
+ ensureProvidersReady,
1188
+ bootProfile,
1189
+ scheduleProviderModelWarmup: () => scheduleProviderModelWarmupRef(),
1190
+ quickHelpers: providerModelQuickHelpers,
1191
+ });
2299
1192
 
2300
- function warmProviderModelCache() {
2301
- if (Array.isArray(providerModelsCache.models) || providerModelsPromise) return providerModelsPromise;
2302
- const seq = ++providerModelsLoadSeq;
2303
- providerModelsPromise = loadProviderModelsFresh()
2304
- .then((models) => {
2305
- if (seq === providerModelsLoadSeq) providerModelsCache = { models, at: Date.now() };
2306
- bootProfile('provider-models:warm-ready', { count: models.length });
2307
- return models;
2308
- })
2309
- .catch((err) => {
2310
- const msg = err instanceof Error ? err.message : String(err);
2311
- bootProfile('provider-models:warm-failed', { error: msg });
2312
- return [];
2313
- })
2314
- .finally(() => {
2315
- providerModelsPromise = null;
2316
- });
2317
- return providerModelsPromise;
2318
- }
1193
+ const {
1194
+ quickProviderModelRows,
1195
+ addDefaultSearchModel,
1196
+ quickSearchProviderModelRows,
1197
+ searchModelsFromRows,
1198
+ searchRowsWithDefault,
1199
+ } = createQuickModelRows({
1200
+ getRoute: () => route,
1201
+ getSearchRoute: () => searchRoute,
1202
+ displayConfig,
1203
+ providerModelCacheRow,
1204
+ providerModelsFromCacheRows,
1205
+ sortProviderModels,
1206
+ modelMetaByRoute,
1207
+ modelMetaKey,
1208
+ normalizeSearchProviderId,
1209
+ normalizeSearchRouteConfig,
1210
+ isSearchCapableProvider,
1211
+ searchCapableFor,
1212
+ currentMainSearchModelMeta,
1213
+ });
1214
+ Object.assign(providerModelQuickHelpers, {
1215
+ quickProviderModelRows,
1216
+ addDefaultSearchModel,
1217
+ quickSearchProviderModelRows,
1218
+ searchModelsFromRows,
1219
+ searchRowsWithDefault,
1220
+ });
2319
1221
 
2320
1222
  async function resolveMissingRouteModelForFirstTurn() {
2321
1223
  if (routeHasModel()) return route;
@@ -2432,85 +1334,7 @@ function parsedProviderModelVersion(id) {
2432
1334
  }
2433
1335
  session = mgr.createSession(sessionOpts);
2434
1336
  sessionNeedsCwdRefresh = false;
2435
- Object.defineProperty(session, 'beforeToolHook', {
2436
- value: (input) => hooks.beforeTool(hookCommonPayload({
2437
- ...input,
2438
- session_id: input?.sessionId || input?.session_id || session?.id,
2439
- tool_name: input?.name || input?.tool_name,
2440
- tool_input: input?.args || input?.tool_input,
2441
- tool_use_id: input?.toolCallId || input?.tool_use_id,
2442
- cwd: input?.cwd || currentCwd,
2443
- })),
2444
- enumerable: false,
2445
- configurable: true,
2446
- writable: true,
2447
- });
2448
- // PostToolUse: bridge runtime tool completions to the standard hook bus.
2449
- // dispatch() returns a promise; the loop's afterToolHook caller already
2450
- // try/catches, so a rejection cannot escape the tool loop.
2451
- Object.defineProperty(session, 'afterToolHook', {
2452
- value: (input) => hooks.dispatch('PostToolUse', hookCommonPayload({
2453
- session_id: input?.sessionId || input?.session_id || session?.id,
2454
- cwd: input?.cwd || currentCwd,
2455
- tool_name: input?.name,
2456
- tool_input: input?.args,
2457
- tool_use_id: input?.toolCallId || input?.tool_use_id,
2458
- tool_response: input?.result,
2459
- })),
2460
- enumerable: false,
2461
- configurable: true,
2462
- writable: true,
2463
- });
2464
- // PostToolUseFailure: dispatched by loop.mjs only when a tool execution
2465
- // resolved to a failure (thrown-error path or an is_error result). Same
2466
- // shape as afterToolHook; `result` carries the error text. Best-effort.
2467
- Object.defineProperty(session, 'afterToolFailureHook', {
2468
- value: (input) => hooks.dispatch('PostToolUseFailure', hookCommonPayload({
2469
- session_id: input?.sessionId || input?.session_id || session?.id,
2470
- cwd: input?.cwd || currentCwd,
2471
- tool_name: input?.name,
2472
- tool_input: input?.args,
2473
- tool_use_id: input?.toolCallId || input?.tool_use_id,
2474
- tool_response: input?.result,
2475
- })),
2476
- enumerable: false,
2477
- configurable: true,
2478
- writable: true,
2479
- });
2480
- // PostToolBatch: dispatched by loop.mjs after a full parallel batch of
2481
- // tool calls resolves and before the next model call. No matcher event.
2482
- Object.defineProperty(session, 'afterToolBatchHook', {
2483
- value: (input) => hooks.dispatch('PostToolBatch', hookCommonPayload({
2484
- session_id: input?.sessionId || input?.session_id || session?.id,
2485
- cwd: input?.cwd || currentCwd,
2486
- })),
2487
- enumerable: false,
2488
- configurable: true,
2489
- writable: true,
2490
- });
2491
- // PreCompact / PostCompact: dispatched by manager.mjs/loop.mjs compaction
2492
- // flow via these session-property hooks (manager has no hooks bus access).
2493
- // payload { trigger: 'auto' | 'manual' }. Best-effort.
2494
- Object.defineProperty(session, 'preCompactHook', {
2495
- value: (input) => hooks.dispatch('PreCompact', hookCommonPayload({
2496
- session_id: input?.sessionId || input?.session_id || session?.id,
2497
- cwd: input?.cwd || currentCwd,
2498
- trigger: input?.trigger || 'auto',
2499
- })),
2500
- enumerable: false,
2501
- configurable: true,
2502
- writable: true,
2503
- });
2504
- Object.defineProperty(session, 'postCompactHook', {
2505
- value: (input) => hooks.dispatch('PostCompact', hookCommonPayload({
2506
- session_id: input?.sessionId || input?.session_id || session?.id,
2507
- cwd: input?.cwd || currentCwd,
2508
- trigger: input?.trigger || 'auto',
2509
- })),
2510
- enumerable: false,
2511
- configurable: true,
2512
- writable: true,
2513
- });
1337
+ attachSessionHooks(session, { hooks, hookCommonPayload, getCwd: () => currentCwd });
2514
1338
  applyDeferredToolSurface(session, deferredSurfaceModeForLead(mode), standaloneTools, { provider: route.provider });
2515
1339
  applyPreSessionToolSelection();
2516
1340
  writeStatuslineRoute(statusRoutes, session, route);
@@ -2549,212 +1373,133 @@ function parsedProviderModelVersion(id) {
2549
1373
  }
2550
1374
  }
2551
1375
 
2552
- function scheduleLeadSessionPrewarm() {
2553
- if (!sessionPrewarmEnabled) {
2554
- bootProfile('session:prewarm-skipped');
2555
- return;
2556
- }
2557
- const timer = setTimeout(() => {
2558
- if (closeRequested || session?.id || sessionCreatePromise || activeTurnCount > 0) return;
2559
- void createCurrentSession('prewarm')
2560
- .then(() => bootProfile('session:prewarm:ready'))
2561
- .catch((error) => bootProfile('session:prewarm:failed', { error: error?.message || String(error) }));
2562
- }, sessionPrewarmDelayMs);
2563
- timer.unref?.();
2564
- }
2565
-
2566
- function scheduleProviderWarmup(delayMs = providerWarmupDelayMs) {
2567
- if (!providerWarmupEnabled) {
2568
- bootProfile('providers:warm-skipped');
2569
- return;
2570
- }
2571
- if (providerWarmupTimer || closeRequested) return;
2572
- providerWarmupTimer = setTimeout(() => {
2573
- providerWarmupTimer = null;
2574
- if (closeRequested) return;
2575
- if (!firstTurnCompleted && !envFlag('MIXDOG_PROVIDER_WARMUP_BEFORE_FIRST_TURN')) {
2576
- bootProfile('providers:warm-deferred', { reason: 'first-turn-pending' });
2577
- return;
2578
- }
2579
- if (activeTurnCount > 0 || sessionCreatePromise) {
2580
- bootProfile('providers:warm-deferred', { reason: activeTurnCount > 0 ? 'turn-active' : 'session-create' });
2581
- scheduleProviderWarmup(backgroundBusyRetryMs);
2582
- return;
2583
- }
2584
- const providersStartedAt = performance.now();
2585
- try {
2586
- reloadFullConfig();
2587
- } catch (error) {
2588
- bootProfile('config:full-failed', { error: error?.message || String(error) });
2589
- }
2590
- void ensureProvidersReady(config.providers || {})
2591
- .then(() => {
2592
- bootProfile('providers:init:ready', { ms: (performance.now() - providersStartedAt).toFixed(1) });
2593
- if (closeRequested) return null;
2594
- return true;
2595
- })
2596
- .catch((error) => bootProfile('providers:warm-failed', { error: error?.message || String(error) }));
2597
- }, delayMs);
2598
- providerWarmupTimer.unref?.();
2599
- }
2600
-
2601
- function scheduleProviderSetupWarmup(delayMs = providerSetupWarmupDelayMs) {
2602
- if (providerSetupWarmupTimer || closeRequested) return;
2603
- providerSetupWarmupTimer = setTimeout(() => {
2604
- providerSetupWarmupTimer = null;
2605
- if (closeRequested) return;
2606
- void cachedProviderSetup()
2607
- .then(() => bootProfile('provider-setup:warm-ready'))
2608
- .catch((error) => bootProfile('provider-setup:warm-failed', { error: error?.message || String(error) }));
2609
- }, delayMs);
2610
- providerSetupWarmupTimer.unref?.();
2611
- }
2612
-
2613
- function scheduleProviderModelWarmup(delayMs = providerModelWarmupDelayMs) {
2614
- if (!modelPrefetchEnabled) return;
2615
- if (providerModelWarmupTimer || closeRequested) return;
2616
- providerModelWarmupTimer = setTimeout(() => {
2617
- providerModelWarmupTimer = null;
2618
- if (closeRequested || Array.isArray(providerModelsCache.models) || providerModelsPromise) return;
2619
- if (activeTurnCount > 0 || sessionCreatePromise) {
2620
- bootProfile('provider-models:warm-deferred', { reason: activeTurnCount > 0 ? 'turn-active' : 'session-create' });
2621
- scheduleProviderModelWarmup(backgroundBusyRetryMs);
2622
- return;
2623
- }
2624
- warmProviderModelCache();
2625
- }, delayMs);
2626
- providerModelWarmupTimer.unref?.();
2627
- }
2628
-
2629
- function scheduleModelCatalogWarmup(delayMs = modelCatalogWarmupDelayMs) {
2630
- if (!modelCatalogWarmupEnabled) {
2631
- bootProfile('model-catalog:warm-skipped', { reason: 'disabled' });
2632
- return;
2633
- }
2634
- if (modelCatalogWarmupTimer || closeRequested) return;
2635
- modelCatalogWarmupTimer = setTimeout(() => {
2636
- modelCatalogWarmupTimer = null;
2637
- if (closeRequested) return;
2638
- if (activeTurnCount > 0 || sessionCreatePromise) {
2639
- bootProfile('model-catalog:warm-deferred', { reason: activeTurnCount > 0 ? 'turn-active' : 'session-create' });
2640
- scheduleModelCatalogWarmup(backgroundBusyRetryMs);
2641
- return;
2642
- }
2643
- void warmCatalogsInBackground()
2644
- .then(() => bootProfile('model-catalog:warm-ready'))
2645
- .catch((error) => bootProfile('model-catalog:warm-failed', { error: error?.message || String(error) }));
2646
- }, delayMs);
2647
- modelCatalogWarmupTimer.unref?.();
2648
- }
1376
+ const {
1377
+ scheduleProviderWarmup,
1378
+ scheduleProviderSetupWarmup,
1379
+ scheduleProviderModelWarmup,
1380
+ scheduleModelCatalogWarmup,
1381
+ scheduleStatuslineUsageWarmup,
1382
+ scheduleStatuslineUsageRefresh,
1383
+ } = createWarmupSchedulers({
1384
+ timers: warmupTimers,
1385
+ bootProfile,
1386
+ getRoute: () => route,
1387
+ getConfig: () => config,
1388
+ isCloseRequested: () => closeRequested,
1389
+ getActiveTurnCount: () => activeTurnCount,
1390
+ getSessionCreatePromise: () => sessionCreatePromise,
1391
+ getProviderModelsCache: () => providerModelCaches.providerModelsCache,
1392
+ getProviderModelsPromise: () => providerModelCaches.providerModelsPromise,
1393
+ reloadFullConfig,
1394
+ ensureConfigForRouteProvider,
1395
+ ensureProvidersReady,
1396
+ ensureProviderEnabled,
1397
+ refreshStatuslineUsageSnapshot,
1398
+ warmProviderModelCache,
1399
+ cachedProviderSetup,
1400
+ warmCatalogsInBackground,
1401
+ isFirstTurnCompleted: () => firstTurnCompleted,
1402
+ envFlag,
1403
+ delays: {
1404
+ providerWarmupDelayMs,
1405
+ providerSetupWarmupDelayMs,
1406
+ providerModelWarmupDelayMs,
1407
+ modelCatalogWarmupDelayMs,
1408
+ statuslineUsageWarmupDelayMs,
1409
+ statuslineUsageRefreshDelayMs,
1410
+ backgroundBusyRetryMs,
1411
+ },
1412
+ flags: {
1413
+ providerWarmupEnabled,
1414
+ modelPrefetchEnabled,
1415
+ modelCatalogWarmupEnabled,
1416
+ },
1417
+ });
1418
+ scheduleProviderModelWarmupRef = scheduleProviderModelWarmup;
1419
+ scheduleProviderSetupWarmupRef = scheduleProviderSetupWarmup;
1420
+
1421
+ const {
1422
+ scheduleCodeGraphPrewarm,
1423
+ scheduleLeadSessionPrewarm,
1424
+ invokeChannelStart,
1425
+ scheduleChannelStart,
1426
+ } = createPrewarmSchedulers({
1427
+ timers: prewarmTimers,
1428
+ bootProfile,
1429
+ getCurrentCwd: () => currentCwd,
1430
+ isCloseRequested: () => closeRequested,
1431
+ getActiveTurnCount: () => activeTurnCount,
1432
+ getSessionCreatePromise: () => sessionCreatePromise,
1433
+ getSession: () => session,
1434
+ isRemoteEnabled: () => remoteEnabled,
1435
+ channelsEnabled,
1436
+ getCodeGraphModule,
1437
+ createCurrentSession,
1438
+ channels,
1439
+ envFlag,
1440
+ delays: {
1441
+ codeGraphPrewarmDelayMs,
1442
+ channelStartDelayMs,
1443
+ sessionPrewarmDelayMs,
1444
+ backgroundBusyRetryMs,
1445
+ },
1446
+ flags: {
1447
+ codeGraphPrewarmEnabled,
1448
+ sessionPrewarmEnabled,
1449
+ },
1450
+ state: prewarmState,
1451
+ });
2649
1452
 
2650
- function scheduleStatuslineUsageWarmup(delayMs = statuslineUsageWarmupDelayMs) {
2651
- const providerId = clean(route?.provider);
2652
- if (!providerId || !providerId.includes('oauth')) {
2653
- bootProfile('statusline-usage:warm-skipped', { provider: providerId || null });
2654
- return;
2655
- }
2656
- if (statuslineUsageWarmupTimer || closeRequested) return;
2657
- statuslineUsageWarmupTimer = setTimeout(async () => {
2658
- statuslineUsageWarmupTimer = null;
2659
- if (closeRequested) return;
2660
- if (activeTurnCount > 0 || sessionCreatePromise) {
2661
- bootProfile('statusline-usage:warm-deferred', { reason: activeTurnCount > 0 ? 'turn-active' : 'session-create' });
2662
- scheduleStatuslineUsageWarmup(backgroundBusyRetryMs);
2663
- return;
2664
- }
1453
+ // Eagerly create/refresh the remote transcript writer for the CURRENT
1454
+ // session + cwd, publish the session record, and ensure the transcript
1455
+ // file exists on disk. Called from startRemote() (so the channel worker's
1456
+ // activate-time discovery finds THIS session immediately instead of
1457
+ // waiting for the first turn) and from ask() at turn start. Returns true
1458
+ // when a writer is bound.
1459
+ function ensureRemoteTranscriptWriter() {
1460
+ if (!remoteEnabled || !session?.id) return false;
1461
+ const twKey = `${session.id}\u0000${currentCwd}`;
1462
+ if (_twKey !== twKey) {
2665
1463
  try {
2666
- ensureConfigForRouteProvider();
2667
- await ensureProvidersReady(ensureProviderEnabled(config, route.provider));
2668
- if (closeRequested) return;
2669
- refreshStatuslineUsageSnapshot(route);
2670
- bootProfile('statusline-usage:warm-ready', { provider: clean(route?.provider) });
1464
+ _transcriptWriter = createTranscriptWriter({
1465
+ mixdogHome: mixdogHome(),
1466
+ sessionId: session.id,
1467
+ cwd: currentCwd,
1468
+ pid: process.pid,
1469
+ });
1470
+ _transcriptWriter.writeSessionRecord();
1471
+ _twKey = twKey;
2671
1472
  } catch (error) {
2672
- bootProfile('statusline-usage:warm-failed', { error: error?.message || String(error) });
2673
- } finally {
2674
- scheduleStatuslineUsageRefresh();
2675
- }
2676
- }, delayMs);
2677
- statuslineUsageWarmupTimer.unref?.();
2678
- }
2679
-
2680
- // Idle keep-alive loop: periodically re-fetch the OAuth usage snapshot so its
2681
- // cachedAt stays "live-fresh" and the statusline usage segment does not vanish
2682
- // after LIVE_USAGE_SNAPSHOT_MAX_AGE_MS while the session is idle. Turn-driven
2683
- // refreshes (recordStandaloneStatusTelemetry) already cover active sessions.
2684
- function scheduleStatuslineUsageRefresh(delayMs = statuslineUsageRefreshDelayMs) {
2685
- const providerId = clean(route?.provider);
2686
- if (!providerId || !providerId.includes('oauth')) return;
2687
- if (statuslineUsageRefreshTimer || closeRequested) return;
2688
- statuslineUsageRefreshTimer = setTimeout(async () => {
2689
- statuslineUsageRefreshTimer = null;
2690
- if (closeRequested) return;
2691
- if (activeTurnCount > 0 || sessionCreatePromise) {
2692
- // Active turns refresh usage on their own; just re-arm the idle loop.
2693
- scheduleStatuslineUsageRefresh();
2694
- return;
1473
+ process.stderr.write(`mixdog: transcript-writer: init failed: ${error?.message || error}\n`);
1474
+ _transcriptWriter = null;
1475
+ _twKey = '';
1476
+ return false;
2695
1477
  }
2696
- try {
2697
- ensureConfigForRouteProvider();
2698
- await ensureProvidersReady(ensureProviderEnabled(config, route.provider));
2699
- if (closeRequested) return;
2700
- refreshStatuslineUsageSnapshot(route);
2701
- } catch {
2702
- // Usage display must never affect the session runtime.
2703
- } finally {
2704
- scheduleStatuslineUsageRefresh();
2705
- }
2706
- }, delayMs);
2707
- statuslineUsageRefreshTimer.unref?.();
2708
- }
2709
-
2710
- function invokeChannelStart() {
2711
- if (channelStartPromise) return channelStartPromise;
2712
- const startedAt = performance.now();
2713
- bootProfile('channels:start:begin');
2714
- channelStartPromise = channels.start()
2715
- .then(() => bootProfile('channels:start:ready', { ms: (performance.now() - startedAt).toFixed(1) }))
2716
- .catch((error) => bootProfile('channels:start:failed', {
2717
- ms: (performance.now() - startedAt).toFixed(1),
2718
- error: error?.message || String(error),
2719
- }))
2720
- .finally(() => {
2721
- channelStartPromise = null;
2722
- });
2723
- return channelStartPromise;
2724
- }
2725
-
2726
- function scheduleChannelStart(delayMs = channelStartDelayMs) {
2727
- if (envFlag('MIXDOG_DISABLE_CHANNEL_START')) {
2728
- bootProfile('channels:start-skipped');
2729
- return;
2730
- }
2731
- if (!channelsEnabled()) {
2732
- bootProfile('channels:start-disabled');
2733
- return;
1478
+ } else {
1479
+ // Same binding — refresh updatedAt so worker-side discovery keeps
1480
+ // ranking this session as the live parent-chain candidate.
1481
+ try { _transcriptWriter?.writeSessionRecord(); } catch {}
2734
1482
  }
2735
- if (channelStartTimer || channelStartPromise || closeRequested) return;
2736
- bootProfile('channels:start-scheduled', { delayMs });
2737
- channelStartTimer = setTimeout(() => {
2738
- channelStartTimer = null;
2739
- if (closeRequested) return;
2740
- // A deferred start may straddle a stopRemote(); re-check before booting so
2741
- // a turned-off session neither starts channels nor keeps rescheduling.
2742
- if (!remoteEnabled) return;
2743
- if (activeTurnCount > 0 || sessionCreatePromise) {
2744
- bootProfile('channels:start-deferred', { reason: activeTurnCount > 0 ? 'turn-active' : 'session-create' });
2745
- scheduleChannelStart(backgroundBusyRetryMs);
2746
- return;
2747
- }
2748
- void invokeChannelStart();
2749
- }, delayMs);
2750
- channelStartTimer.unref?.();
1483
+ try { _transcriptWriter?.ensureTranscriptFile(); }
1484
+ catch (error) { process.stderr.write(`mixdog: transcript-writer: ensureTranscriptFile failed: ${error?.message || error}\n`); }
1485
+ return _transcriptWriter != null;
2751
1486
  }
2752
1487
 
2753
1488
  // Remote (Discord channel) mode is opt-in per session. Only a session that
2754
1489
  // explicitly enables remote — via `mixdog --remote` or the runtime toggle —
2755
1490
  // boots the channel worker and contends for channel ownership.
1491
+ // startRemote() is FORCE-TAKEOVER: it always (re)claims the bridge seat and
1492
+ // rebinds output forwarding to this session, even when the worker is
1493
+ // already running (e.g. `/remote` re-issued after another session took the
1494
+ // seat, or to re-pin forwarding onto the current transcript).
2756
1495
  function startRemote() {
2757
1496
  remoteEnabled = true;
1497
+ // Publish this session's record + transcript file BEFORE the worker's
1498
+ // activate-time discovery polls, so output forwarding binds to this
1499
+ // terminal session immediately instead of waiting for the first turn.
1500
+ // No-op when the session has not been created yet (lazy mode); that
1501
+ // case is covered by the turn-start rebind in ask().
1502
+ ensureRemoteTranscriptWriter();
2758
1503
  // A backend switch may still be sitting in its debounce window; flush it
2759
1504
  // so the channel worker boots against the backend the user just chose,
2760
1505
  // not the previous on-disk value.
@@ -2768,19 +1513,43 @@ function parsedProviderModelVersion(id) {
2768
1513
  return true;
2769
1514
  }
2770
1515
  if (closeRequested) return true;
2771
- if (channelStartTimer) {
2772
- clearTimeout(channelStartTimer);
2773
- channelStartTimer = null;
1516
+ if (prewarmTimers.channelStartTimer) {
1517
+ clearTimeout(prewarmTimers.channelStartTimer);
1518
+ prewarmTimers.channelStartTimer = null;
2774
1519
  }
2775
1520
  bootProfile('channels:start-scheduled', { delayMs: 0, immediate: true });
2776
- void invokeChannelStart();
1521
+ void (async () => {
1522
+ // Immediate-occupancy guarantee: make sure a session + transcript
1523
+ // exist BEFORE the worker boots — a freshly-forked worker claims and
1524
+ // runs transcript discovery inside its own start(), so publishing the
1525
+ // session record/file first lets that very first discovery pass bind
1526
+ // output forwarding to THIS terminal instead of a persisted/stale
1527
+ // neighbour. Lazy mode means the session may not exist yet at /remote
1528
+ // time; create it here (idempotent — reuses a live session, joins an
1529
+ // in-flight create). On create failure we still claim: that matches
1530
+ // the pre-eager behavior (bind resolves on the first turn's rebind).
1531
+ try { await createCurrentSession('remote-start'); }
1532
+ catch (error) { bootProfile('channels:remote-session-create-failed', { error: error?.message || String(error) }); }
1533
+ ensureRemoteTranscriptWriter();
1534
+ // Re-check after the awaits above: stopRemote()/superseded or runtime
1535
+ // close may have landed mid-chain — do not boot/claim for a session
1536
+ // that already turned remote off.
1537
+ if (!remoteEnabled || closeRequested) return;
1538
+ await invokeChannelStart();
1539
+ if (!remoteEnabled || closeRequested) return;
1540
+ // Unconditional claim + forwarder rebind. A freshly-forked worker
1541
+ // already claims in its own start(), so this is idempotent there; the
1542
+ // already-running case is where it matters (last-wins seat overwrite +
1543
+ // transcript rebind onto this session).
1544
+ await channels.execute('activate_channel_bridge', { active: true });
1545
+ })().catch((error) => bootProfile('channels:claim-failed', { error: error?.message || String(error) }));
2777
1546
  return true;
2778
1547
  }
2779
1548
 
2780
1549
  function stopRemote(reason) {
2781
1550
  remoteEnabled = false;
2782
1551
  // Cancel any pending deferred start so it can't fire after remote is off.
2783
- if (channelStartTimer) { clearTimeout(channelStartTimer); channelStartTimer = null; }
1552
+ if (prewarmTimers.channelStartTimer) { clearTimeout(prewarmTimers.channelStartTimer); prewarmTimers.channelStartTimer = null; }
2784
1553
  channels.stop(reason || 'remote-disabled', { waitForExit: false }).catch(() => {});
2785
1554
  return true;
2786
1555
  }
@@ -2879,7 +1648,64 @@ function parsedProviderModelVersion(id) {
2879
1648
  return true;
2880
1649
  }
2881
1650
 
1651
+ // Pure settings-delegate methods (onboarding status/skip, autoClear, profile,
1652
+ // compaction, recap/memory, channels, systemShell, update settings, channel
1653
+ // token save/forget, setBackend). Extracted to session-runtime/settings-api.mjs
1654
+ // and SPREAD into the API object below so the external surface is unchanged.
1655
+ const settingsApi = createSettingsApi({
1656
+ getConfig: () => config,
1657
+ getRoute: () => route,
1658
+ getSession: () => session,
1659
+ getRemoteEnabled: () => remoteEnabled,
1660
+ saveConfigAndAdopt,
1661
+ scheduleBackendSave,
1662
+ cfgMod,
1663
+ hasOwn,
1664
+ normalizeAutoClearConfig,
1665
+ autoClearIdleMsForProvider,
1666
+ autoClearProviderDefaults,
1667
+ normalizeCompactionConfig,
1668
+ normalizeCompactTypeSetting,
1669
+ normalizeSystemShellConfig,
1670
+ normalizeSystemShellCommand,
1671
+ setConfiguredShell,
1672
+ setRecapEnabledInConfig,
1673
+ setModuleEnabledInConfig,
1674
+ summarizeWorkflowRoutes,
1675
+ parseDurationMs,
1676
+ formatDurationMs,
1677
+ localPackageVersion,
1678
+ memoryEnabled,
1679
+ recapEnabledFn,
1680
+ channelsEnabled,
1681
+ autoUpdateEnabled,
1682
+ getUpdateCheckState: () => updateCheckState,
1683
+ getUpdateProcessState: () => updateProcessState,
1684
+ invalidateContextStatusCache: (...a) => invalidateContextStatusCache(...a),
1685
+ invalidatePreSessionToolSurface: (...a) => invalidatePreSessionToolSurface(...a),
1686
+ scheduleChannelStart: (...a) => scheduleChannelStart(...a),
1687
+ channels,
1688
+ clearChannelStartTimer: () => {
1689
+ if (prewarmTimers.channelStartTimer) {
1690
+ clearTimeout(prewarmTimers.channelStartTimer);
1691
+ prewarmTimers.channelStartTimer = null;
1692
+ }
1693
+ },
1694
+ checkForUpdateInternal: (...a) => checkForUpdateInternal(...a),
1695
+ runUpdateNowInternal: (...a) => runUpdateNowInternal(...a),
1696
+ reloadChannelsSoon: (...a) => reloadChannelsSoon(...a),
1697
+ ONBOARDING_VERSION,
1698
+ saveDiscordToken,
1699
+ forgetDiscordToken,
1700
+ saveTelegramToken,
1701
+ forgetTelegramToken,
1702
+ saveWebhookAuthtoken,
1703
+ forgetWebhookAuthtoken,
1704
+ setBackend,
1705
+ });
1706
+
2882
1707
  return {
1708
+ ...settingsApi,
2883
1709
  get id() {
2884
1710
  return session?.id || null;
2885
1711
  },
@@ -2914,7 +1740,7 @@ function parsedProviderModelVersion(id) {
2914
1740
  return mode;
2915
1741
  },
2916
1742
  get autoClear() {
2917
- return normalizeAutoClearConfig(config.autoClear);
1743
+ return this.getAutoClear();
2918
1744
  },
2919
1745
  get systemShell() {
2920
1746
  return normalizeSystemShellConfig(config.shell);
@@ -2925,10 +1751,14 @@ function parsedProviderModelVersion(id) {
2925
1751
  },
2926
1752
  get workflow() {
2927
1753
  const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
1754
+ const active = activeWorkflowSummary(config, dataDir);
2928
1755
  if (session?.workflow && typeof session.workflow === 'object') {
2929
- return workflowSummary(session.workflow);
1756
+ const current = workflowSummary(session.workflow);
1757
+ return current?.id && active?.id && current.id !== active.id
1758
+ ? { ...active, currentSession: current, appliedToCurrentSession: false }
1759
+ : active;
2930
1760
  }
2931
- return activeWorkflowSummary(config, dataDir);
1761
+ return active;
2932
1762
  },
2933
1763
  get outputStyle() {
2934
1764
  return getOutputStyleStatusCached().current;
@@ -3046,244 +1876,7 @@ function parsedProviderModelVersion(id) {
3046
1876
  return await cachedProviderSetup();
3047
1877
  },
3048
1878
  async getUsageDashboard(options = {}) {
3049
- const forceSetup = options?.force === true || options?.refresh === true;
3050
- if (!forceSetup && usageDashboardCache.dashboard) {
3051
- const cached = {
3052
- ...usageDashboardCache.dashboard,
3053
- refresh: false,
3054
- checking: false,
3055
- cached: true,
3056
- cachedAt: usageDashboardCache.at,
3057
- };
3058
- if (typeof options?.onUpdate === 'function') {
3059
- try { options.onUpdate(cached); } catch {}
3060
- }
3061
- return cached;
3062
- }
3063
- if (!forceSetup && usageDashboardPromise) return await usageDashboardPromise;
3064
- const quickSetup = options?.quickSetup !== false;
3065
- const getProvider = (providerId) => reg.getProvider(providerId);
3066
- const log = (message) => {
3067
- if (process.env.MIXDOG_USAGE_TRACE) {
3068
- try { process.stderr.write(`[usage] ${message}\n`); } catch {}
3069
- }
3070
- };
3071
- if (quickSetup && typeof options?.onUpdate === 'function') {
3072
- const previewConfig = displayConfig();
3073
- const previewSetup = await cachedProviderSetup({ force: false, quick: true });
3074
- await createUsageDashboard(previewConfig, {
3075
- ...(options || {}),
3076
- preview: true,
3077
- setup: previewSetup,
3078
- getProvider,
3079
- log,
3080
- });
3081
- }
3082
- const buildDashboard = async () => {
3083
- const dashboard = await createUsageDashboard(displayConfig(), {
3084
- ...(options || {}),
3085
- setup: await cachedProviderSetup({ force: forceSetup, quick: false }),
3086
- getProvider,
3087
- log,
3088
- });
3089
- usageDashboardCache = { dashboard, at: Date.now() };
3090
- return dashboard;
3091
- };
3092
- if (forceSetup) return await buildDashboard();
3093
- usageDashboardPromise = buildDashboard()
3094
- .finally(() => {
3095
- usageDashboardPromise = null;
3096
- });
3097
- return await usageDashboardPromise;
3098
- },
3099
- getOnboardingStatus() {
3100
- const nextConfig = displayConfig();
3101
- return {
3102
- completed: nextConfig?.onboarding?.completed === true,
3103
- version: nextConfig?.onboarding?.version || 0,
3104
- default: nextConfig?.default || null,
3105
- workflowRoutes: summarizeWorkflowRoutes(nextConfig),
3106
- };
3107
- },
3108
- // Mark onboarding as done WITHOUT touching routes/agents/provider. Used by
3109
- // the TUI "skip" (Esc) path so the wizard doesn't reappear next launch,
3110
- // while leaving any existing config routes untouched.
3111
- skipOnboarding() {
3112
- const nextConfig = { ...config };
3113
- nextConfig.onboarding = {
3114
- ...(nextConfig.onboarding || {}),
3115
- completed: true,
3116
- version: ONBOARDING_VERSION,
3117
- completedAt: new Date().toISOString(),
3118
- skipped: true,
3119
- };
3120
- saveConfigAndAdopt(nextConfig);
3121
- return this.getOnboardingStatus();
3122
- },
3123
- getAutoClear() {
3124
- return normalizeAutoClearConfig(config.autoClear);
3125
- },
3126
- // --- User profile (/profile statusline command) ---------------------
3127
- // getProfile returns the normalized { title, language } plus the resolved
3128
- // language catalog entry and the full language list for the picker UI.
3129
- getProfile() {
3130
- const profile = cfgMod.normalizeProfileConfig(config.profile);
3131
- return {
3132
- ...profile,
3133
- languageEntry: cfgMod.profileLanguageEntry(profile.language),
3134
- languages: cfgMod.PROFILE_LANGUAGES,
3135
- };
3136
- },
3137
- // setProfile patches title and/or language and persists. Unknown language
3138
- // ids normalize back to 'system'. Prompt-side injection is wired separately
3139
- // (composeSystemPrompt) — this only owns the stored value.
3140
- setProfile(input = {}) {
3141
- const current = cfgMod.normalizeProfileConfig(config.profile);
3142
- const next = { ...current };
3143
- if (hasOwn(input, 'title') || hasOwn(input, 'name')) {
3144
- next.title = input.title ?? input.name ?? '';
3145
- }
3146
- if (hasOwn(input, 'language') || hasOwn(input, 'lang')) {
3147
- next.language = input.language ?? input.lang ?? 'system';
3148
- }
3149
- const normalized = cfgMod.normalizeProfileConfig(next);
3150
- saveConfigAndAdopt({ ...config, profile: normalized });
3151
- return this.getProfile();
3152
- },
3153
- getCompactionSettings() {
3154
- return normalizeCompactionConfig(config.compaction, { memoryEnabled: memoryEnabled() });
3155
- },
3156
- setCompactionSettings(input = {}) {
3157
- const current = normalizeCompactionConfig(config.compaction, { memoryEnabled: memoryEnabled() });
3158
- const next = { ...current };
3159
- if (hasOwn(input, 'auto')) next.auto = input.auto !== false;
3160
- if (hasOwn(input, 'enabled')) next.auto = input.enabled !== false;
3161
- if (hasOwn(input, 'type') || hasOwn(input, 'compactType') || hasOwn(input, 'compact_type')) {
3162
- const requestedType = input.type ?? input.compactType ?? input.compact_type;
3163
- const compactType = normalizeCompactTypeSetting(requestedType, current.compactType || current.type || 'semantic');
3164
- if (compactType === 'recall-fasttrack' && !memoryEnabled()) {
3165
- throw new Error('recall-fasttrack compact requires memory to be enabled');
3166
- }
3167
- next.type = compactType;
3168
- next.compactType = compactType;
3169
- }
3170
- const nextConfig = { ...config };
3171
- nextConfig.compaction = normalizeCompactionConfig(next, { memoryEnabled: memoryEnabled() });
3172
- saveConfigAndAdopt(nextConfig);
3173
- if (session) {
3174
- session.compaction = {
3175
- ...(session.compaction || {}),
3176
- ...normalizeCompactionConfig(config.compaction, { memoryEnabled: memoryEnabled() }),
3177
- };
3178
- }
3179
- invalidateContextStatusCache();
3180
- return normalizeCompactionConfig(config.compaction, { memoryEnabled: memoryEnabled() });
3181
- },
3182
- getMemorySettings() {
3183
- return {
3184
- enabled: memoryEnabled(),
3185
- compactFastTrackAvailable: memoryEnabled(),
3186
- };
3187
- },
3188
- async setMemoryEnabled(enabled) {
3189
- const nextConfig = setModuleEnabledInConfig({ ...config }, 'memory', enabled !== false);
3190
- if (enabled === false) {
3191
- nextConfig.compaction = normalizeCompactionConfig(nextConfig.compaction, { memoryEnabled: false });
3192
- }
3193
- saveConfigAndAdopt(nextConfig);
3194
- if (!memoryEnabled() && memoryModPromise) {
3195
- await memoryModPromise.then((mod) => mod?.stop?.()).catch(() => {});
3196
- memoryModPromise = null;
3197
- }
3198
- if (session && config.compaction) {
3199
- session.compaction = {
3200
- ...(session.compaction || {}),
3201
- ...normalizeCompactionConfig(config.compaction, { memoryEnabled: memoryEnabled() }),
3202
- };
3203
- }
3204
- invalidatePreSessionToolSurface();
3205
- invalidateContextStatusCache();
3206
- return this.getMemorySettings();
3207
- },
3208
- getChannelSettings(options = {}) {
3209
- return {
3210
- enabled: channelsEnabled(),
3211
- ...(options?.includeStatus === false ? {} : { status: channels.status() }),
3212
- };
3213
- },
3214
- async setChannelsEnabled(enabled) {
3215
- const nextConfig = setModuleEnabledInConfig({ ...config }, 'channels', enabled !== false);
3216
- saveConfigAndAdopt(nextConfig);
3217
- if (!channelsEnabled()) {
3218
- if (channelStartTimer) {
3219
- clearTimeout(channelStartTimer);
3220
- channelStartTimer = null;
3221
- }
3222
- await channels.stop('settings-disabled', { waitForExit: false }).catch(() => {});
3223
- } else {
3224
- // Enabling channels in settings only boots the worker when this session
3225
- // is in remote mode; otherwise the toggle just persists config.
3226
- if (remoteEnabled) scheduleChannelStart(0);
3227
- }
3228
- invalidatePreSessionToolSurface();
3229
- return this.getChannelSettings();
3230
- },
3231
- getSystemShell() {
3232
- return normalizeSystemShellConfig(config.shell);
3233
- },
3234
- setSystemShell(input = {}) {
3235
- const command = normalizeSystemShellCommand(typeof input === 'string' ? input : input?.command);
3236
- saveConfigAndAdopt({
3237
- ...config,
3238
- shell: command ? { ...(config.shell || {}), command } : {},
3239
- });
3240
- setConfiguredShell(command);
3241
- return normalizeSystemShellConfig(config.shell);
3242
- },
3243
- setAutoClear(input = {}) {
3244
- const current = normalizeAutoClearConfig(config.autoClear);
3245
- const next = { ...current };
3246
- if (hasOwn(input, 'enabled')) next.enabled = input.enabled !== false;
3247
- if (hasOwn(input, 'idleMs')) {
3248
- const idleMs = Number(input.idleMs);
3249
- if (!Number.isFinite(idleMs) || idleMs <= 0) throw new Error('autoclear idleMs must be a positive number');
3250
- next.idleMs = Math.max(60_000, Math.round(idleMs));
3251
- }
3252
- if (hasOwn(input, 'duration')) {
3253
- const idleMs = parseDurationMs(input.duration);
3254
- if (!idleMs) throw new Error('usage: /autoclear [on|off|status|<minutes|1h|90m>]');
3255
- next.idleMs = idleMs;
3256
- if (!hasOwn(input, 'enabled')) next.enabled = true;
3257
- }
3258
- saveConfigAndAdopt({ ...config, autoClear: next });
3259
- return { ...normalizeAutoClearConfig(config.autoClear), label: formatDurationMs(normalizeAutoClearConfig(config.autoClear).idleMs) };
3260
- },
3261
- getUpdateSettings() {
3262
- return {
3263
- autoUpdate: autoUpdateEnabled(),
3264
- currentVersion: updateCheckState.currentVersion || localPackageVersion(),
3265
- latestVersion: updateCheckState.latestVersion,
3266
- updateAvailable: updateCheckState.updateAvailable,
3267
- lastCheckedAt: updateCheckState.lastCheckedAt,
3268
- };
3269
- },
3270
- setAutoUpdate(enabled) {
3271
- saveConfigAndAdopt({
3272
- ...config,
3273
- update: { ...(config.update || {}), auto: enabled === true },
3274
- });
3275
- return this.getUpdateSettings();
3276
- },
3277
- async checkForUpdate(options = {}) {
3278
- await checkForUpdateInternal({ force: options?.force === true });
3279
- return this.getUpdateSettings();
3280
- },
3281
- async runUpdateNow() {
3282
- const state = await runUpdateNowInternal();
3283
- return { ok: state.phase === 'installed', ...state };
3284
- },
3285
- getUpdateStatus() {
3286
- return { ...updateProcessState };
1879
+ return await getUsageDashboard(options);
3287
1880
  },
3288
1881
  async completeOnboarding(payload = {}) {
3289
1882
  // Only fall back to the live runtime route when the caller actually sent a
@@ -3408,60 +2001,16 @@ function parsedProviderModelVersion(id) {
3408
2001
  isRemoteEnabled() {
3409
2002
  return isRemoteEnabled();
3410
2003
  },
3411
- saveDiscordToken(token) {
3412
- const result = saveDiscordToken(token);
3413
- reloadChannelsSoon();
3414
- return result;
3415
- },
3416
- forgetDiscordToken() {
3417
- const result = forgetDiscordToken();
3418
- reloadChannelsSoon();
3419
- return result;
3420
- },
3421
- saveTelegramToken(token) {
3422
- const result = saveTelegramToken(token);
3423
- reloadChannelsSoon();
3424
- return result;
3425
- },
3426
- forgetTelegramToken() {
3427
- const result = forgetTelegramToken();
3428
- reloadChannelsSoon();
3429
- return result;
3430
- },
3431
- setBackend(name) {
3432
- // Validate synchronously (same contract as before: bad input throws on
3433
- // this call so the TUI's try/catch can react immediately). The actual
3434
- // channels-section file read-modify-write is the hitch source on the
3435
- // settings-toggle key handler, so defer it through the same debounce
3436
- // pattern as saveConfigAndAdopt/flushConfigSave instead of writing to
3437
- // disk synchronously inside the key handler.
3438
- const value = String(name || '').trim();
3439
- if (value !== 'discord' && value !== 'telegram') {
3440
- throw new Error('backend must be discord or telegram');
3441
- }
3442
- pendingBackendName = value;
3443
- if (backendSaveTimer) clearTimeout(backendSaveTimer);
3444
- backendSaveTimer = setTimeout(flushBackendSave, CONFIG_SAVE_DEBOUNCE_MS);
3445
- backendSaveTimer.unref?.();
3446
- return { ok: true, backend: value };
3447
- },
3448
- saveWebhookAuthtoken(token) {
3449
- const result = saveWebhookAuthtoken(token);
3450
- reloadChannelsSoon();
3451
- return result;
3452
- },
3453
- forgetWebhookAuthtoken() {
3454
- const result = forgetWebhookAuthtoken();
3455
- reloadChannelsSoon();
3456
- return result;
3457
- },
3458
- saveChannel(entry) {
3459
- const result = saveChannel(entry);
3460
- reloadChannelsSoon();
3461
- return result;
2004
+ // Subscribe to non-user-initiated remote flips (seat superseded). Returns
2005
+ // an unsubscribe function. TUI uses this to sync its Remote indicator and
2006
+ // show a "remote taken over" notice.
2007
+ onRemoteStateChange(listener) {
2008
+ if (typeof listener !== 'function') return () => {};
2009
+ remoteStateListeners.add(listener);
2010
+ return () => remoteStateListeners.delete(listener);
3462
2011
  },
3463
- deleteChannel(name) {
3464
- const result = deleteChannel(name);
2012
+ setChannel(entry) {
2013
+ const result = setChannel(entry);
3465
2014
  reloadChannelsSoon();
3466
2015
  return result;
3467
2016
  },
@@ -3685,18 +2234,13 @@ function parsedProviderModelVersion(id) {
3685
2234
  nextConfig.agent = agent;
3686
2235
  }
3687
2236
  adoptConfig(nextConfig);
3688
- pendingOutputStyleId = selected.id;
3689
- if (outputStyleSaveTimer) clearTimeout(outputStyleSaveTimer);
3690
- outputStyleSaveTimer = setTimeout(flushOutputStyleSave, CONFIG_SAVE_DEBOUNCE_MS);
3691
- outputStyleSaveTimer.unref?.();
2237
+ scheduleOutputStyleSave(selected.id);
3692
2238
  // Reuse the catalog already scanned above for `before` instead of a
3693
2239
  // second forced-fresh filesystem scan; refresh the status cache
3694
2240
  // in-memory (short TTL, same as getOutputStyleStatusCached) so reads
3695
2241
  // during the debounce window see the just-selected style.
3696
2242
  const freshStatus = { configured: selected.id, current: selected, styles: before.styles };
3697
- outputStyleStatusCache = freshStatus;
3698
- outputStyleStatusCacheAt = performance.now();
3699
- outputStyleStatusCacheDir = resolve(cfgMod.getPluginData?.() || STANDALONE_DATA_DIR);
2243
+ seedOutputStyleStatusCache(freshStatus);
3700
2244
  const hasConversation = sessionHasConversationMessages(session);
3701
2245
  let appliedToCurrentSession = !hasConversation;
3702
2246
  if (session?.id && !hasConversation) {
@@ -3791,25 +2335,28 @@ function parsedProviderModelVersion(id) {
3791
2335
  // JSONL and pushes the surface view to Discord. Gated on remoteEnabled
3792
2336
  // so non-remote sessions write nothing.
3793
2337
  if (remoteEnabled) {
3794
- const twKey = `${session.id}\u0000${currentCwd}`;
3795
2338
  // Reset per-turn dedup tracker so an identical answer in a later turn
3796
2339
  // is still written (the guard only prevents same-turn double-write).
3797
2340
  _lastAppendedAssistant = '';
3798
- if (_twKey !== twKey) {
3799
- try {
3800
- _transcriptWriter = createTranscriptWriter({
3801
- mixdogHome: mixdogHome(),
3802
- sessionId: session.id,
3803
- cwd: currentCwd,
3804
- pid: process.pid,
3805
- });
3806
- _transcriptWriter.writeSessionRecord();
3807
- _twKey = twKey;
3808
- } catch (error) {
3809
- process.stderr.write(`mixdog: transcript-writer: init failed: ${error?.message || error}\n`);
3810
- _transcriptWriter = null;
3811
- _twKey = '';
3812
- }
2341
+ const prevKey = _twKey;
2342
+ ensureRemoteTranscriptWriter();
2343
+ // The writer moved to a NEW session/cwd (e.g. lazy first-turn
2344
+ // session create after /remote, or /new mid-remote). Re-pin the
2345
+ // channel worker's output forwarder onto the fresh transcript NOW —
2346
+ // otherwise it keeps tailing the previous binding until an inbound
2347
+ // Discord message triggers the steal path, so terminal-initiated
2348
+ // turns would never forward. activate_channel_bridge is idempotent
2349
+ // force-takeover (claim + rebind); fire-and-forget so the turn is
2350
+ // never blocked on worker IPC.
2351
+ if (_twKey && _twKey !== prevKey && channelsEnabled() && !envFlag('MIXDOG_DISABLE_CHANNEL_START')) {
2352
+ void invokeChannelStart()
2353
+ .then(() => {
2354
+ // stopRemote()/superseded/close may have landed while the
2355
+ // worker was starting — never re-claim for a turned-off session.
2356
+ if (!remoteEnabled || closeRequested) return undefined;
2357
+ return channels.execute('activate_channel_bridge', { active: true });
2358
+ })
2359
+ .catch((error) => bootProfile('channels:turn-rebind-failed', { error: error?.message || String(error) }));
3813
2360
  }
3814
2361
  }
3815
2362
  hooks.emit('turn:start', { sessionId: session.id, prompt, cwd: currentCwd });
@@ -4037,7 +2584,7 @@ function parsedProviderModelVersion(id) {
4037
2584
  return { ...result, status: this.toolsStatus() };
4038
2585
  },
4039
2586
  setCwd(path) {
4040
- applyResolvedCwd(resolveCwdPath(currentCwd, path));
2587
+ applyResolvedCwd(resolveCwdPath(path));
4041
2588
  return currentCwd;
4042
2589
  },
4043
2590
  mcpStatus() {
@@ -4248,9 +2795,19 @@ function parsedProviderModelVersion(id) {
4248
2795
  ...(args || {}),
4249
2796
  query: baseQuery,
4250
2797
  cwd: args?.cwd || currentCwd,
2798
+ // Grouping hint for multi-session browse output: lets the memory
2799
+ // service mark THIS session's group as "(current)". Not a filter.
2800
+ ...(session?.id ? { currentSessionId: session.id } : {}),
4251
2801
  };
4252
2802
  let result = '(no results)';
4253
2803
  if (session?.id && args?.currentSession !== false && args?.forceCycleOnEmpty !== false) {
2804
+ // Empty-fallback: hydrate the current transcript into the memory DB
2805
+ // (no LLM — ingest only) and re-search session-scoped with the raw
2806
+ // leg on. The old path also ran a synchronous cycle1 (LLM chunking)
2807
+ // here, which made an empty recall the slowest possible recall and
2808
+ // called the LLM even with recap/memory off; the raw rows are
2809
+ // searchable directly (FTS/trgm + post-ingest raw embeddings), so
2810
+ // chunking is left to the background cycle.
4254
2811
  const messages = Array.isArray(session.messages) ? session.messages : [];
4255
2812
  if (messages.length > 0) {
4256
2813
  await memoryMod.handleToolCall('memory', {
@@ -4259,12 +2816,6 @@ function parsedProviderModelVersion(id) {
4259
2816
  cwd: currentCwd,
4260
2817
  messages,
4261
2818
  });
4262
- await memoryMod.handleToolCall('memory', {
4263
- action: 'cycle1',
4264
- min_batch: 1,
4265
- session_cap: 1,
4266
- batch_size: Math.max(1, Math.min(100, messages.length)),
4267
- });
4268
2819
  result = toolResponseText(await memoryMod.handleToolCall('recall', {
4269
2820
  ...baseArgs,
4270
2821
  sessionId: session.id,
@@ -4417,37 +2968,30 @@ function parsedProviderModelVersion(id) {
4417
2968
  try { flushConfigSave(); } catch {}
4418
2969
  try { flushBackendSave(); } catch {}
4419
2970
  try { flushOutputStyleSave(); } catch {}
4420
- if (channelStartTimer) {
4421
- clearTimeout(channelStartTimer);
4422
- channelStartTimer = null;
4423
- }
4424
- if (providerSetupWarmupTimer) {
4425
- clearTimeout(providerSetupWarmupTimer);
4426
- providerSetupWarmupTimer = null;
4427
- }
4428
- if (providerWarmupTimer) {
4429
- clearTimeout(providerWarmupTimer);
4430
- providerWarmupTimer = null;
4431
- }
4432
- if (providerModelWarmupTimer) {
4433
- clearTimeout(providerModelWarmupTimer);
4434
- providerModelWarmupTimer = null;
4435
- }
4436
- if (modelCatalogWarmupTimer) {
4437
- clearTimeout(modelCatalogWarmupTimer);
4438
- modelCatalogWarmupTimer = null;
4439
- }
4440
- if (codeGraphPrewarmTimer) {
4441
- clearTimeout(codeGraphPrewarmTimer);
4442
- codeGraphPrewarmTimer = null;
2971
+ if (prewarmTimers.channelStartTimer) {
2972
+ clearTimeout(prewarmTimers.channelStartTimer);
2973
+ prewarmTimers.channelStartTimer = null;
2974
+ }
2975
+ for (const timerKey of [
2976
+ 'providerSetupWarmupTimer',
2977
+ 'providerWarmupTimer',
2978
+ 'providerModelWarmupTimer',
2979
+ 'modelCatalogWarmupTimer',
2980
+ ]) {
2981
+ if (warmupTimers[timerKey]) {
2982
+ clearTimeout(warmupTimers[timerKey]);
2983
+ warmupTimers[timerKey] = null;
2984
+ }
4443
2985
  }
4444
- if (statuslineUsageWarmupTimer) {
4445
- clearTimeout(statuslineUsageWarmupTimer);
4446
- statuslineUsageWarmupTimer = null;
2986
+ if (prewarmTimers.codeGraphPrewarmTimer) {
2987
+ clearTimeout(prewarmTimers.codeGraphPrewarmTimer);
2988
+ prewarmTimers.codeGraphPrewarmTimer = null;
4447
2989
  }
4448
- if (statuslineUsageRefreshTimer) {
4449
- clearTimeout(statuslineUsageRefreshTimer);
4450
- statuslineUsageRefreshTimer = null;
2990
+ for (const timerKey of ['statuslineUsageWarmupTimer', 'statuslineUsageRefreshTimer']) {
2991
+ if (warmupTimers[timerKey]) {
2992
+ clearTimeout(warmupTimers[timerKey]);
2993
+ warmupTimers[timerKey] = null;
2994
+ }
4451
2995
  }
4452
2996
  try { cancelBackgroundTasks({ reason, notify: false }); } catch {}
4453
2997
  const channelStop = channels.stop(reason, detach ? { waitForExit: false } : undefined);
@@ -4471,7 +3015,19 @@ function parsedProviderModelVersion(id) {
4471
3015
  let ok = false;
4472
3016
  if (session?.id) {
4473
3017
  statusRoutes?.clearGatewaySessionRoute?.(session.id);
4474
- ok = mgr.closeSession(session.id, reason);
3018
+ // Bug fix: runtime stop/exit (TUI Ctrl-C, process exit) previously
3019
+ // always tombstoned the current session, so a session you were
3020
+ // mid-conversation in vanished from the Resume list the instant you
3021
+ // quit and was hard-deleted by the 24h tombstone sweep. Only
3022
+ // tombstone truly-empty scratch sessions; non-empty sessions must
3023
+ // survive exit resumable.
3024
+ // liveTurnMessages holds the in-flight user prompt until turn
3025
+ // commit — an active first-turn ask has its user message there,
3026
+ // not yet in session.messages, so it must also be checked or a
3027
+ // first-turn exit could still burn a real session.
3028
+ const tombstone = !hasUserConversationMessage(session.messages)
3029
+ && !hasUserConversationMessage(session.liveTurnMessages);
3030
+ ok = mgr.closeSession(session.id, reason, { tombstone });
4475
3031
  session = null;
4476
3032
  }
4477
3033
  const shellJobsStop = globalThis.__mixdogShellJobsRuntimeLoaded === true
@@ -4518,7 +3074,10 @@ function parsedProviderModelVersion(id) {
4518
3074
  const agent = clean(s.agent || '').toLowerCase();
4519
3075
  const leadish = agent === 'lead'
4520
3076
  || sourceType === 'lead'
4521
- || (sourceType === 'cli' && (!sourceName || sourceName === 'main'))
3077
+ // Bug fix: side-terminal cli sessions have a non-empty/non-'main' sourceName
3078
+ // (e.g. terminal id) and were being hidden from resume even though they are
3079
+ // legitimate user sessions, not agent-owned. Any sourceName is fine for cli.
3080
+ || (sourceType === 'cli')
4522
3081
  || (!sourceType && !sourceName && !isAgentOwner(owner));
4523
3082
  if (!leadish) return null;
4524
3083
  let preview = cleanSessionPreview(s.preview || '');
@@ -4532,7 +3091,12 @@ function parsedProviderModelVersion(id) {
4532
3091
  preview = userPreviews[userPreviews.length - 1] || userPreviews[0] || '';
4533
3092
  messageCount = msgs.filter(m => m && (m.role === 'user' || m.role === 'assistant')).length;
4534
3093
  }
4535
- if (!preview) return null;
3094
+ // Bug fix: sessions whose preview couldn't be derived (e.g. noise-only user
3095
+ // turns) were silently dropped from the resume list even when they had real
3096
+ // messages. Keep the row and let preview fall back to '' (TUI renders
3097
+ // '(no message)' for empty preview); only drop truly-empty scratch sessions
3098
+ // with zero visible messages.
3099
+ if (!preview && messageCount === 0) return null;
4536
3100
  return {
4537
3101
  id: s.id,
4538
3102
  updatedAt: s.updatedAt,
@@ -4545,17 +3109,44 @@ function parsedProviderModelVersion(id) {
4545
3109
  }).filter(Boolean);
4546
3110
  },
4547
3111
  async newSession() {
4548
- if (session?.id) mgr.closeSession(session.id, 'cli-new');
3112
+ if (session?.id) {
3113
+ // Bug fix: /new used to unconditionally tombstone the outgoing
3114
+ // session, so switching to a fresh session burned whatever you'd
3115
+ // been working on — it dropped off the Resume list immediately and
3116
+ // was hard-deleted after the 24h tombstone sweep. Only tombstone
3117
+ // truly-empty scratch sessions; keep non-empty ones resumable.
3118
+ const tombstone = !hasUserConversationMessage(session.messages)
3119
+ && !hasUserConversationMessage(session.liveTurnMessages);
3120
+ mgr.closeSession(session.id, 'cli-new', { tombstone });
3121
+ // Bug fix: closeSession({tombstone:false}) keeps the outgoing
3122
+ // session file intact so it stays resumable — but that meant
3123
+ // createCurrentSession()'s live-session reuse check (session?.id →
3124
+ // mgr.getSession(...) not closed) happily reloaded the SAME session,
3125
+ // so /new never actually reset the transcript and the context gauge
3126
+ // never returned to 0. Drop the in-memory reference so a fresh
3127
+ // session is always created.
3128
+ session = null;
3129
+ }
3130
+ invalidateContextStatusCache();
4549
3131
  await createCurrentSession();
4550
3132
  return session.id;
4551
3133
  },
4552
3134
  async resume(id) {
4553
3135
  const previousId = session?.id || null;
3136
+ const previousMessages = session?.messages || null;
3137
+ const previousLive = session?.liveTurnMessages || null;
4554
3138
  const resumed = await mgr.resumeSession(id, toolSpecForMode(mode));
4555
3139
  if (!resumed) return null;
4556
3140
  if (previousId && previousId !== resumed.id) {
4557
3141
  statusRoutes?.clearGatewaySessionRoute?.(previousId);
4558
- mgr.closeSession(previousId, 'cli-resume');
3142
+ // Bug fix: /resume used to unconditionally tombstone the session
3143
+ // you were switching away from, so it vanished from the Resume
3144
+ // list right away and was hard-deleted after the 24h sweep — the
3145
+ // exact "burn a resumable session" bug this fix targets. Only
3146
+ // tombstone truly-empty scratch sessions.
3147
+ const tombstone = !hasUserConversationMessage(previousMessages)
3148
+ && !hasUserConversationMessage(previousLive);
3149
+ mgr.closeSession(previousId, 'cli-resume', { tombstone });
4559
3150
  }
4560
3151
  session = resumed;
4561
3152
  currentCwd = resumed.cwd || currentCwd;