mixdog 0.9.0 → 0.9.2

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 (240) hide show
  1. package/package.json +10 -3
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/session-ingest-smoke.mjs +2 -2
  23. package/scripts/task-bench.mjs +207 -0
  24. package/scripts/tool-failures.mjs +6 -6
  25. package/scripts/tool-smoke.mjs +306 -66
  26. package/scripts/toolcall-args-test.mjs +81 -0
  27. package/src/agents/debugger/AGENT.md +4 -4
  28. package/src/agents/heavy-worker/AGENT.md +4 -2
  29. package/src/agents/reviewer/AGENT.md +4 -4
  30. package/src/agents/worker/AGENT.md +4 -2
  31. package/src/app.mjs +10 -6
  32. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  33. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  34. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  35. package/src/headless-role.mjs +14 -14
  36. package/src/help.mjs +1 -0
  37. package/src/lib/mixdog-debug.cjs +0 -22
  38. package/src/lib/plugin-paths.cjs +1 -7
  39. package/src/lib/rules-builder.cjs +34 -56
  40. package/src/mixdog-session-runtime.mjs +710 -319
  41. package/src/output-styles/default.md +12 -7
  42. package/src/output-styles/minimal.md +25 -0
  43. package/src/output-styles/oneline.md +21 -0
  44. package/src/output-styles/simple.md +10 -9
  45. package/src/repl.mjs +12 -4
  46. package/src/rules/agent/00-common.md +7 -5
  47. package/src/rules/agent/30-explorer.md +7 -8
  48. package/src/rules/lead/01-general.md +3 -1
  49. package/src/rules/lead/lead-tool.md +7 -0
  50. package/src/rules/shared/01-tool.md +17 -12
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  52. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  54. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  55. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  56. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  57. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  59. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  60. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  61. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  62. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
  66. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
  67. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
  75. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  76. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  78. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
  79. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  81. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  82. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  83. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
  89. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  91. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  92. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  104. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  105. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  106. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  107. package/src/runtime/channels/backends/discord.mjs +99 -9
  108. package/src/runtime/channels/backends/telegram.mjs +501 -0
  109. package/src/runtime/channels/index.mjs +441 -1254
  110. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  111. package/src/runtime/channels/lib/config.mjs +54 -3
  112. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  113. package/src/runtime/channels/lib/executor.mjs +0 -3
  114. package/src/runtime/channels/lib/format.mjs +4 -2
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  116. package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
  117. package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
  118. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  119. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  120. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  121. package/src/runtime/channels/lib/tool-format.mjs +1 -2
  122. package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
  123. package/src/runtime/channels/lib/webhook.mjs +59 -31
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  126. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  127. package/src/runtime/memory/index.mjs +187 -43
  128. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  129. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  130. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  131. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  132. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  133. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  134. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  135. package/src/runtime/memory/lib/memory.mjs +101 -4
  136. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  137. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  138. package/src/runtime/memory/lib/session-ingest.mjs +116 -7
  139. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  140. package/src/runtime/memory/tool-defs.mjs +6 -3
  141. package/src/runtime/search/index.mjs +2 -7
  142. package/src/runtime/search/lib/config.mjs +0 -4
  143. package/src/runtime/search/lib/state.mjs +1 -15
  144. package/src/runtime/search/lib/web-tools.mjs +0 -1
  145. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  146. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  147. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  148. package/src/runtime/shared/config.mjs +9 -0
  149. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  150. package/src/runtime/shared/schedules-store.mjs +21 -19
  151. package/src/runtime/shared/tool-surface.mjs +98 -13
  152. package/src/runtime/shared/transcript-writer.mjs +129 -0
  153. package/src/runtime/shared/update-checker.mjs +214 -0
  154. package/src/standalone/agent-tool.mjs +255 -109
  155. package/src/standalone/channel-admin.mjs +133 -40
  156. package/src/standalone/channel-worker.mjs +8 -291
  157. package/src/standalone/explore-tool.mjs +2 -2
  158. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  159. package/src/standalone/provider-admin.mjs +11 -0
  160. package/src/standalone/seeds.mjs +1 -11
  161. package/src/standalone/usage-dashboard.mjs +1 -1
  162. package/src/tui/App.jsx +2137 -750
  163. package/src/tui/components/ConfirmBar.jsx +47 -0
  164. package/src/tui/components/ContextPanel.jsx +5 -3
  165. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  166. package/src/tui/components/Markdown.jsx +22 -98
  167. package/src/tui/components/Message.jsx +14 -35
  168. package/src/tui/components/Picker.jsx +87 -12
  169. package/src/tui/components/PromptInput.jsx +146 -9
  170. package/src/tui/components/QueuedCommands.jsx +1 -1
  171. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  172. package/src/tui/components/Spinner.jsx +7 -7
  173. package/src/tui/components/StatusLine.jsx +40 -21
  174. package/src/tui/components/TextEntryPanel.jsx +51 -7
  175. package/src/tui/components/ToolExecution.jsx +177 -100
  176. package/src/tui/components/TurnDone.jsx +4 -4
  177. package/src/tui/components/UsagePanel.jsx +1 -1
  178. package/src/tui/components/tool-output-format.mjs +312 -40
  179. package/src/tui/components/tool-output-format.test.mjs +180 -1
  180. package/src/tui/display-width.mjs +69 -0
  181. package/src/tui/display-width.test.mjs +35 -0
  182. package/src/tui/dist/index.mjs +7324 -2393
  183. package/src/tui/engine.mjs +287 -126
  184. package/src/tui/index.jsx +117 -7
  185. package/src/tui/keyboard-protocol.mjs +42 -0
  186. package/src/tui/lib/voice-recorder.mjs +453 -0
  187. package/src/tui/markdown/format-token.mjs +354 -142
  188. package/src/tui/markdown/format-token.test.mjs +155 -17
  189. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  190. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  191. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  192. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  193. package/src/tui/markdown/table-layout.mjs +9 -9
  194. package/src/tui/paste-attachments.mjs +0 -11
  195. package/src/tui/prompt-history-store.mjs +129 -0
  196. package/src/tui/prompt-history-store.test.mjs +52 -0
  197. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  198. package/src/tui/theme.mjs +41 -647
  199. package/src/tui/themes/base.mjs +86 -0
  200. package/src/tui/themes/basic.mjs +85 -0
  201. package/src/tui/themes/catppuccin.mjs +72 -0
  202. package/src/tui/themes/dracula.mjs +70 -0
  203. package/src/tui/themes/everforest.mjs +71 -0
  204. package/src/tui/themes/gruvbox.mjs +71 -0
  205. package/src/tui/themes/index.mjs +71 -0
  206. package/src/tui/themes/indigo.mjs +78 -0
  207. package/src/tui/themes/kanagawa.mjs +80 -0
  208. package/src/tui/themes/light.mjs +81 -0
  209. package/src/tui/themes/nord.mjs +72 -0
  210. package/src/tui/themes/onedark.mjs +16 -0
  211. package/src/tui/themes/rosepine.mjs +70 -0
  212. package/src/tui/themes/teal.mjs +81 -0
  213. package/src/tui/themes/tokyonight.mjs +79 -0
  214. package/src/tui/themes/utils.mjs +106 -0
  215. package/src/tui/themes/warm.mjs +79 -0
  216. package/src/tui/transcript-tool-failures.mjs +13 -2
  217. package/src/ui/markdown.mjs +1 -1
  218. package/src/ui/model-display.mjs +2 -2
  219. package/src/ui/statusline.mjs +26 -27
  220. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  221. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  222. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  223. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  224. package/src/workflows/default/WORKFLOW.md +39 -12
  225. package/src/workflows/sequential/WORKFLOW.md +46 -0
  226. package/src/workflows/solo/WORKFLOW.md +7 -0
  227. package/vendor/ink/build/display-width.js +62 -0
  228. package/vendor/ink/build/ink.js +154 -20
  229. package/vendor/ink/build/measure-text.js +4 -1
  230. package/vendor/ink/build/output.js +115 -9
  231. package/vendor/ink/build/render-node-to-output.js +4 -1
  232. package/vendor/ink/build/render.js +4 -0
  233. package/src/hooks/lib/permission-rules.cjs +0 -170
  234. package/src/hooks/lib/settings-loader.cjs +0 -112
  235. package/src/lib/hook-pipe-path.cjs +0 -10
  236. package/src/output-styles/extreme-simple.md +0 -20
  237. package/src/rules/lead/04-workflow.md +0 -51
  238. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  239. package/src/workflows/default/workflow.json +0 -13
  240. package/src/workflows/solo/workflow.json +0 -7
@@ -12,10 +12,17 @@ import { createStandaloneMemoryRuntime } from './standalone/memory-runtime-proxy
12
12
  import { createStandaloneHookBus } from './standalone/hook-bus.mjs';
13
13
  import { writeLastSessionCwd } from './runtime/shared/user-cwd.mjs';
14
14
  import { cancelBackgroundTasks } from './runtime/shared/background-tasks.mjs';
15
+ import { createTranscriptWriter } from './runtime/shared/transcript-writer.mjs';
16
+ import { mixdogHome } from './runtime/shared/plugin-paths.mjs';
17
+ import { checkLatestVersion, runGlobalUpdate, localPackageVersion } from './runtime/shared/update-checker.mjs';
15
18
  import {
16
19
  modelVisibleToolCompletionMessage,
17
20
  shouldPersistModelVisibleToolCompletion,
18
21
  } from './runtime/shared/tool-execution-contract.mjs';
22
+ import {
23
+ channelNotificationModelContent,
24
+ shouldMirrorChannelNotificationToPending,
25
+ } from './runtime/shared/channel-notification-routing.mjs';
19
26
  import {
20
27
  normalizeAgentPermissionOrNone,
21
28
  readMarkdownDocument,
@@ -25,6 +32,7 @@ import {
25
32
  PROVIDER_STATUS_TOOL,
26
33
  beginOAuthProviderLogin,
27
34
  forgetProviderAuth,
35
+ isKnownProvider,
28
36
  loginOAuthProvider,
29
37
  providerSetup,
30
38
  renderProviderStatus,
@@ -35,7 +43,10 @@ import {
35
43
  } from './standalone/provider-admin.mjs';
36
44
  import { createUsageDashboard } from './standalone/usage-dashboard.mjs';
37
45
  import { fetchOAuthUsageSnapshot } from './runtime/agent/orchestrator/providers/oauth-usage.mjs';
38
- import { getModelMetadataSync } from './runtime/agent/orchestrator/providers/model-catalog.mjs';
46
+ import {
47
+ getModelMetadataSync,
48
+ warmCatalogsInBackground,
49
+ } from './runtime/agent/orchestrator/providers/model-catalog.mjs';
39
50
  import {
40
51
  isResponsesFreeformTool,
41
52
  toResponsesCustomTool,
@@ -46,13 +57,16 @@ import {
46
57
  deleteSchedule,
47
58
  deleteWebhook,
48
59
  forgetDiscordToken,
60
+ forgetTelegramToken,
49
61
  forgetWebhookAuthtoken,
50
62
  renderChannelStatus,
51
63
  saveChannel,
52
64
  saveDiscordToken,
65
+ saveTelegramToken,
53
66
  saveSchedule,
54
67
  saveWebhook,
55
68
  saveWebhookAuthtoken,
69
+ setBackend,
56
70
  setScheduleEnabled,
57
71
  setWebhookEnabled,
58
72
  setWebhookConfig,
@@ -69,74 +83,11 @@ import {
69
83
  estimateRequestReserveTokens,
70
84
  estimateTranscriptContextUsage,
71
85
  estimateToolSchemaTokens,
86
+ resolveCompactBufferTokens,
87
+ resolveCompactTriggerTokens,
88
+ summarizeContextMessages,
72
89
  } from './runtime/agent/orchestrator/session/context-utils.mjs';
73
90
 
74
- // Default compaction buffer rules — kept in lockstep with the worker runtime
75
- // (loop.mjs resolveCompactBufferRatio / manager.mjs compactBufferRatioForSession
76
- // and compact.mjs compactionBufferTokensForBoundary). By default the trigger is
77
- // the effective compact boundary itself; explicit buffer settings can lower it.
78
- const CONTEXT_DEFAULT_BUFFER_RATIO = 0;
79
- const CONTEXT_MAX_BUFFER_RATIO = 0.25;
80
- const CONTEXT_LEGACY_DEFAULT_BUFFER_RATIO = 0.1;
81
- function resolveContextBufferRatio(cfg = {}) {
82
- // Percent-named inputs (bufferPercent/bufferPct/*_BUFFER_PERCENT): a value of
83
- // 1 means 1% (→0.01). Ratio-named inputs (bufferRatio/bufferFraction): 0.01
84
- // means 1%, while a value > 1 is treated as a legacy percent (10 → 10%).
85
- const candidates = [
86
- [cfg.bufferPercent, true],
87
- [cfg.bufferPct, true],
88
- [cfg.bufferRatio, false],
89
- [cfg.bufferFraction, false],
90
- [process.env.MIXDOG_AGENT_COMPACT_BUFFER_PERCENT, true],
91
- [process.env.MIXDOG_AGENT_COMPACT_BUFFER_RATIO, false],
92
- ];
93
- for (const [raw, isPercent] of candidates) {
94
- const n = Number(raw);
95
- if (!Number.isFinite(n) || n <= 0) continue;
96
- return isPercent ? Math.min(1, n / 100) : (n > 1 ? n / 100 : n);
97
- }
98
- return CONTEXT_DEFAULT_BUFFER_RATIO;
99
- }
100
- function isLegacyDefaultContextBufferTelemetry(cfg = {}, boundaryTokens = 0) {
101
- const boundary = Number(boundaryTokens);
102
- if (!Number.isFinite(boundary) || boundary <= 0) return false;
103
- if (Number(process.env.MIXDOG_AGENT_COMPACT_BUFFER_TOKENS) > 0) return false;
104
- for (const envName of ['MIXDOG_AGENT_COMPACT_BUFFER_PERCENT', 'MIXDOG_AGENT_COMPACT_BUFFER_RATIO']) {
105
- const n = Number(process.env[envName]);
106
- if (Number.isFinite(n) && n > 0) return false;
107
- }
108
- for (const key of ['bufferPercent', 'bufferPct', 'bufferFraction']) {
109
- const n = Number(cfg?.[key]);
110
- if (Number.isFinite(n) && n > 0) return false;
111
- }
112
- const explicitTokens = Number(cfg?.bufferTokens ?? cfg?.buffer);
113
- const ratio = Number(cfg?.bufferRatio);
114
- if (!Number.isFinite(explicitTokens) || explicitTokens <= 0 || !Number.isFinite(ratio) || Math.abs(ratio - CONTEXT_LEGACY_DEFAULT_BUFFER_RATIO) > 1e-9) {
115
- return false;
116
- }
117
- const expectedTokens = Math.floor(boundary * CONTEXT_LEGACY_DEFAULT_BUFFER_RATIO);
118
- const cfgBoundary = Number(cfg?.boundaryTokens);
119
- const cfgTrigger = Number(cfg?.triggerTokens);
120
- return Math.floor(explicitTokens) === expectedTokens
121
- || (Number.isFinite(cfgBoundary) && Math.floor(cfgBoundary) === Math.floor(boundary)
122
- && Number.isFinite(cfgTrigger) && cfgTrigger > 0
123
- && Math.floor(explicitTokens) === Math.max(0, Math.floor(boundary - cfgTrigger)));
124
- }
125
- function contextDefaultBufferTokens(boundaryTokens, cfg = {}) {
126
- const boundary = Number(boundaryTokens);
127
- if (!Number.isFinite(boundary) || boundary <= 0) return 0;
128
- const effectiveCfg = isLegacyDefaultContextBufferTelemetry(cfg, boundary)
129
- ? { ...cfg, bufferTokens: null, buffer: null, bufferRatio: null }
130
- : cfg;
131
- const cap = Math.max(0, Math.floor(boundary * CONTEXT_MAX_BUFFER_RATIO));
132
- const explicit = Number(effectiveCfg.bufferTokens ?? effectiveCfg.buffer);
133
- if (Number.isFinite(explicit) && explicit > 0) {
134
- return Math.min(Math.floor(explicit), cap);
135
- }
136
- const ratio = resolveContextBufferRatio(effectiveCfg);
137
- return Math.max(0, Math.min(Math.floor(boundary * ratio), cap));
138
- }
139
-
140
91
  function sessionMessageText(content) {
141
92
  if (content == null) return '';
142
93
  if (typeof content === 'string') return content;
@@ -153,10 +104,6 @@ function sessionMessageText(content) {
153
104
  try { return JSON.stringify(content); } catch { return String(content); }
154
105
  }
155
106
 
156
- function roughTokenCount(text) {
157
- return Math.ceil(String(text ?? '').length / 4);
158
- }
159
-
160
107
  function messageContextText(message) {
161
108
  if (!message || typeof message !== 'object') return '';
162
109
  let text = sessionMessageText(message.content);
@@ -168,117 +115,6 @@ function messageContextText(message) {
168
115
  return text;
169
116
  }
170
117
 
171
- function stripSystemReminder(text) {
172
- return String(text || '')
173
- .replace(/^\s*<system-reminder>\s*/i, '')
174
- .replace(/\s*<\/system-reminder>\s*$/i, '')
175
- .trim();
176
- }
177
-
178
- function splitMarkdownSections(text) {
179
- const sections = [];
180
- let current = [];
181
- for (const line of String(text || '').split(/\r?\n/)) {
182
- if (/^#\s+/.test(line) && current.length) {
183
- const body = current.join('\n').trim();
184
- if (body) sections.push(body);
185
- current = [line];
186
- } else {
187
- current.push(line);
188
- }
189
- }
190
- const tail = current.join('\n').trim();
191
- if (tail) sections.push(tail);
192
- return sections;
193
- }
194
-
195
- function reminderSectionBucket(section) {
196
- const heading = String(section.match(/^#\s+([^\n]+)/)?.[1] || '').trim().toLowerCase();
197
- if (heading.includes('core memory')) return 'memory';
198
- if (heading.includes('active workflow') || heading.includes('available agents') || heading.includes('workflow')) return 'workflow';
199
- if (heading.includes('workspace')) return 'workspace';
200
- if (heading.includes('environment')) return 'environment';
201
- return 'other';
202
- }
203
-
204
- function summarizeContextMessages(messages) {
205
- const rows = {
206
- system: { count: 0, tokens: 0 },
207
- user: { count: 0, tokens: 0 },
208
- assistant: { count: 0, tokens: 0 },
209
- tool: { count: 0, tokens: 0 },
210
- other: { count: 0, tokens: 0 },
211
- };
212
- const semantic = {
213
- system: { count: 0, tokens: 0 },
214
- chat: { count: 0, tokens: 0 },
215
- assistant: { count: 0, tokens: 0 },
216
- toolResults: { count: 0, tokens: 0 },
217
- reminders: { count: 0, tokens: 0, otherTokens: 0 },
218
- workflow: { tokens: 0 },
219
- memory: { tokens: 0 },
220
- workspace: { tokens: 0 },
221
- environment: { tokens: 0 },
222
- other: { tokens: 0 },
223
- };
224
- let toolCallCount = 0;
225
- let toolCallTokens = 0;
226
- let toolResultCount = 0;
227
- let toolResultTokens = 0;
228
- for (const message of messages || []) {
229
- const role = rows[message?.role] ? message.role : 'other';
230
- const text = messageContextText(message);
231
- const tokens = roughTokenCount(text) + 4;
232
- rows[role].count += 1;
233
- rows[role].tokens += tokens;
234
- if (role === 'system') {
235
- semantic.system.count += 1;
236
- semantic.system.tokens += tokens;
237
- } else if (role === 'user') {
238
- if (String(text || '').trim().startsWith('<system-reminder>')) {
239
- semantic.reminders.count += 1;
240
- semantic.reminders.tokens += tokens;
241
- let sectionTokens = 0;
242
- for (const section of splitMarkdownSections(stripSystemReminder(text))) {
243
- const bucket = reminderSectionBucket(section);
244
- const sectionTokenCount = roughTokenCount(section);
245
- semantic[bucket].tokens += sectionTokenCount;
246
- sectionTokens += sectionTokenCount;
247
- }
248
- semantic.reminders.otherTokens += Math.max(0, tokens - sectionTokens);
249
- } else {
250
- semantic.chat.count += 1;
251
- semantic.chat.tokens += tokens;
252
- }
253
- } else if (role === 'assistant') {
254
- semantic.assistant.count += 1;
255
- semantic.assistant.tokens += tokens;
256
- } else if (role === 'tool') {
257
- semantic.toolResults.count += 1;
258
- semantic.toolResults.tokens += tokens;
259
- }
260
- if (message?.role === 'assistant' && Array.isArray(message.toolCalls) && message.toolCalls.length) {
261
- toolCallCount += message.toolCalls.length;
262
- try { toolCallTokens += roughTokenCount(JSON.stringify(message.toolCalls)); }
263
- catch { toolCallTokens += roughTokenCount(`[${message.toolCalls.length} tool calls]`); }
264
- }
265
- if (message?.role === 'tool') {
266
- toolResultCount += 1;
267
- toolResultTokens += tokens;
268
- }
269
- }
270
- return {
271
- count: Array.isArray(messages) ? messages.length : 0,
272
- estimatedTokens: Array.isArray(messages) ? estimateMessagesTokens(messages) : 0,
273
- roles: rows,
274
- semantic,
275
- toolCallCount,
276
- toolCallTokens,
277
- toolResultCount,
278
- toolResultTokens,
279
- };
280
- }
281
-
282
118
  function isSessionPreviewNoise(text) {
283
119
  const value = String(text || '').trim();
284
120
  return !value
@@ -317,6 +153,14 @@ const STANDALONE_DATA_DIR = process.env.MIXDOG_DATA_DIR || join(MIXDOG_HOME, 'da
317
153
 
318
154
  const DEFAULT_PROVIDER = 'anthropic-oauth';
319
155
  const DEFAULT_MODEL = '';
156
+
157
+ // Resolve the provider to use when a route carries no explicit provider.
158
+ // Priority: config.defaultProvider (when it names a known provider) > DEFAULT_PROVIDER.
159
+ function resolveDefaultProvider(config) {
160
+ const configured = clean(config?.defaultProvider);
161
+ if (configured && isKnownProvider(configured)) return configured;
162
+ return DEFAULT_PROVIDER;
163
+ }
320
164
  const TOOL_MODES = new Set(['full', 'readonly', 'lead']);
321
165
  const ALL_EFFORT_LEVELS = new Set(['none', 'low', 'medium', 'high', 'xhigh', 'max']);
322
166
  const EFFORT_LABELS = {
@@ -566,9 +410,9 @@ const READONLY_TOOL_NAMES = new Set([
566
410
  'fetch',
567
411
  'Skill',
568
412
  ]);
569
- const AGENT_HIDDEN_WRAPPER_TOOLS = new Set(['search']);
413
+ const AGENT_HIDDEN_WRAPPER_TOOLS = new Set([]);
570
414
 
571
- function applyStandaloneToolDefaults(tool) {
415
+ export function __applyStandaloneToolDefaultsForTest(tool) {
572
416
  if (!tool || !AGENT_HIDDEN_WRAPPER_TOOLS.has(tool.name)) return tool;
573
417
  return {
574
418
  ...tool,
@@ -578,6 +422,7 @@ function applyStandaloneToolDefaults(tool) {
578
422
  },
579
423
  };
580
424
  }
425
+ const applyStandaloneToolDefaults = __applyStandaloneToolDefaultsForTest;
581
426
  const DEFERRED_SELECT_ALIASES = {
582
427
  filesystem: ['read', 'list', 'grep', 'find', 'glob'],
583
428
  search: ['search', 'web_fetch'],
@@ -762,14 +607,18 @@ function modelMetaLooksResolved(meta) {
762
607
  return Object.keys(meta).some((key) => key !== 'id' && key !== 'provider');
763
608
  }
764
609
 
765
- const OUTPUT_STYLE_ORDER = ['default', 'simple', 'extreme-simple'];
610
+ const OUTPUT_STYLE_ORDER = ['default', 'simple', 'minimal', 'oneline'];
766
611
  const OUTPUT_STYLE_ALIASES = new Map([
767
612
  ['compact', 'default'],
768
613
  ['normal', 'default'],
769
- ['extreme', 'extreme-simple'],
770
- ['extremesimple', 'extreme-simple'],
771
- ['extreme-simple', 'extreme-simple'],
772
- ['extreme_simple', 'extreme-simple'],
614
+ ['extreme', 'minimal'],
615
+ ['extremesimple', 'minimal'],
616
+ ['extreme-simple', 'minimal'],
617
+ ['extreme_simple', 'minimal'],
618
+ ['mono', 'oneline'],
619
+ ['oneline', 'oneline'],
620
+ ['one-line', 'oneline'],
621
+ ['one_line', 'oneline'],
773
622
  ]);
774
623
 
775
624
  function normalizeOutputStyleId(value) {
@@ -892,6 +741,49 @@ function readJsonSafe(path) {
892
741
  try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
893
742
  }
894
743
 
744
+ // Standard Claude Code project-local MCP ingress: read `.mcp.json` from the
745
+ // project root and return a cleaned { name: cfg } map. Best-effort — never
746
+ // throws. Accepts either the standard `{ mcpServers: {...} }` shape or a bare
747
+ // name->cfg map. Self-ref servers (`mixdog` / `trib-plugin`) are stripped for
748
+ // parity with loadConfig (which strips them from the persisted agent section
749
+ // but never sees `.mcp.json`). Inputs are not mutated.
750
+ function readProjectMcpServers(cwd) {
751
+ const path = join(cwd || '.', '.mcp.json');
752
+ if (!existsSync(path)) return {};
753
+ let raw;
754
+ try {
755
+ raw = JSON.parse(readFileSync(path, 'utf8'));
756
+ } catch (error) {
757
+ process.stderr.write(`[mcp-client] Ignoring unparseable .mcp.json at ${path}: ${error?.message || String(error)}\n`);
758
+ return {};
759
+ }
760
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
761
+ const map = raw.mcpServers && typeof raw.mcpServers === 'object' && !Array.isArray(raw.mcpServers)
762
+ ? raw.mcpServers
763
+ : raw;
764
+ if (!map || typeof map !== 'object' || Array.isArray(map)) return {};
765
+ const out = {};
766
+ for (const [name, cfg] of Object.entries(map)) {
767
+ const key = clean(name);
768
+ if (!key) continue;
769
+ const lower = key.toLowerCase();
770
+ if (lower === 'mixdog' || lower === 'trib-plugin') continue;
771
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) continue;
772
+ // stdio entries (command + no url) spawn relative to the process launch
773
+ // dir, but mixdog tracks the project dir in memory (no process.chdir).
774
+ // Anchor their cwd to the .mcp.json directory: default when absent, resolve
775
+ // relative values against it, keep absolute values as-is. resolve(base, p)
776
+ // already returns `p` unchanged when it is absolute. Clone — never mutate.
777
+ const isStdio = typeof cfg.command === 'string' && cfg.command !== '' && !cfg.url;
778
+ if (isStdio) {
779
+ out[key] = { ...cfg, cwd: typeof cfg.cwd === 'string' && cfg.cwd ? resolve(cwd, cfg.cwd) : resolve(cwd) };
780
+ } else {
781
+ out[key] = cfg;
782
+ }
783
+ }
784
+ return out;
785
+ }
786
+
895
787
  function countSkillFiles(root) {
896
788
  const skillsDir = join(root, 'skills');
897
789
  if (!existsSync(skillsDir)) return 0;
@@ -982,7 +874,7 @@ function resolveRoute(config, { provider, model, effort, fast } = {}) {
982
874
  }
983
875
  }
984
876
 
985
- const p = explicitProvider || DEFAULT_PROVIDER;
877
+ const p = explicitProvider || resolveDefaultProvider(config);
986
878
  const m = explicitModel || DEFAULT_MODEL;
987
879
  const saved = modelSettingsFor(config, p, m);
988
880
  return {
@@ -1043,12 +935,9 @@ function normalizeSystemShellCommand(value) {
1043
935
  function normalizeAutoClearConfig(value = {}) {
1044
936
  const raw = value && typeof value === 'object' ? value : {};
1045
937
  const idleMs = Number(raw.idleMs ?? raw.thresholdMs ?? raw.idleMillis);
1046
- const compactType = clean(raw.compactType ?? raw.compact_type ?? raw.type);
1047
- const normalizedCompactType = compactType ? normalizeCompactTypeSetting(compactType, 'semantic') : '';
1048
938
  return {
1049
939
  enabled: raw.enabled !== false,
1050
940
  idleMs: Number.isFinite(idleMs) && idleMs > 0 ? Math.max(60_000, Math.round(idleMs)) : AUTO_CLEAR_DEFAULT_IDLE_MS,
1051
- ...(normalizedCompactType ? { compactType: normalizedCompactType } : {}),
1052
941
  };
1053
942
  }
1054
943
 
@@ -1238,7 +1127,12 @@ function routeForStatusline(route) {
1238
1127
  const preset = route.preset || {};
1239
1128
  if (preset.id) out.presetId = preset.id;
1240
1129
  if (preset.name) out.presetName = preset.name;
1241
- if (preset.modelDisplay) out.modelDisplay = preset.modelDisplay;
1130
+ // Prefer the preset's curated label, then the route's resolved model display
1131
+ // (set by refreshRouteEffort from the live/offline catalog). Without the
1132
+ // route fallback, a preset-less direct model (e.g. claude-fable-5) reaches
1133
+ // the statusline with no display and renders as the raw id.
1134
+ const modelDisplay = clean(preset.modelDisplay) || clean(route.modelDisplay);
1135
+ if (modelDisplay) out.modelDisplay = modelDisplay;
1242
1136
  if (route.fast === true || route.fast === false) out.fast = route.fast;
1243
1137
  else if (preset.fast === true || preset.fast === false) out.fast = preset.fast;
1244
1138
  if (route.effectiveEffort) {
@@ -1374,22 +1268,27 @@ function agentSourceDirs(dataDir, id) {
1374
1268
  ];
1375
1269
  }
1376
1270
 
1377
- function readWorkflowPackFromDir(dir, source = 'built-in') {
1378
- const manifest = readJsonSafe(join(dir, 'workflow.json'));
1379
- if (!manifest || typeof manifest !== 'object') return null;
1380
- const id = normalizeWorkflowId(manifest.id || manifest.name);
1381
- if (!id) return null;
1382
- const entry = clean(manifest.entry) || 'WORKFLOW.md';
1383
- const body = readTextSafe(join(dir, entry));
1271
+ function readWorkflowPackFromDir(dir, source = 'built-in', dirName = '') {
1272
+ const entry = 'WORKFLOW.md';
1273
+ const doc = readMarkdownDocument(readTextSafe(join(dir, entry)));
1274
+ const body = doc.body;
1384
1275
  if (!body) return null;
1385
- const agentsConfigured = Array.isArray(manifest.agents);
1276
+ const fm = doc.frontmatter || {};
1277
+ const id = normalizeWorkflowId(clean(fm.id) || dirName || basename(dir));
1278
+ if (!id) return null;
1279
+ const agentsConfigured = Object.prototype.hasOwnProperty.call(fm, 'agents');
1386
1280
  return {
1387
1281
  id,
1388
- name: clean(manifest.name) || id,
1389
- description: clean(manifest.description),
1282
+ name: clean(fm.name) || id,
1283
+ description: clean(fm.description),
1390
1284
  entry,
1391
1285
  agentsConfigured,
1392
- agents: agentsConfigured ? manifest.agents.map((agent) => normalizeAgentId(agent) || normalizeWorkflowId(agent)).filter(Boolean) : [],
1286
+ agents: agentsConfigured
1287
+ ? String(fm.agents || '')
1288
+ .split(',')
1289
+ .map((agent) => normalizeAgentId(agent) || normalizeWorkflowId(agent))
1290
+ .filter(Boolean)
1291
+ : [],
1393
1292
  body,
1394
1293
  source,
1395
1294
  };
@@ -1403,7 +1302,9 @@ function listWorkflowPacks(dataDir) {
1403
1302
  try { entries = readdirSync(root, { withFileTypes: true }); } catch { entries = []; }
1404
1303
  for (const entry of entries) {
1405
1304
  if (!entry.isDirectory()) continue;
1406
- const pack = readWorkflowPackFromDir(join(root, entry.name), source);
1305
+ const dir = join(root, entry.name);
1306
+ if (!existsSync(join(dir, 'WORKFLOW.md'))) continue;
1307
+ const pack = readWorkflowPackFromDir(dir, source, entry.name);
1407
1308
  if (pack) byId.set(pack.id, pack);
1408
1309
  }
1409
1310
  }
@@ -1421,10 +1322,10 @@ function activeWorkflowId(config) {
1421
1322
  function loadWorkflowPack(dataDir, id) {
1422
1323
  const wanted = normalizeWorkflowId(id, DEFAULT_WORKFLOW_ID);
1423
1324
  for (const { root, source } of workflowSourceDirs(dataDir).reverse()) {
1424
- const pack = readWorkflowPackFromDir(join(root, wanted), source);
1325
+ const pack = readWorkflowPackFromDir(join(root, wanted), source, wanted);
1425
1326
  if (pack) return pack;
1426
1327
  }
1427
- return readWorkflowPackFromDir(join(STANDALONE_ROOT, 'workflows', DEFAULT_WORKFLOW_ID), 'built-in');
1328
+ return readWorkflowPackFromDir(join(STANDALONE_ROOT, 'workflows', DEFAULT_WORKFLOW_ID), 'built-in', DEFAULT_WORKFLOW_ID);
1428
1329
  }
1429
1330
 
1430
1331
  function workflowSummary(pack) {
@@ -1579,10 +1480,15 @@ function upsertWorkflowPreset(presets, slot, routeLike) {
1579
1480
 
1580
1481
  function summarizeWorkflowRoutes(config) {
1581
1482
  const routes = config?.workflowRoutes && typeof config.workflowRoutes === 'object' ? config.workflowRoutes : {};
1483
+ const fallbackProvider = resolveDefaultProvider(config);
1582
1484
  const out = {};
1583
1485
  for (const slot of WORKFLOW_ROUTE_SLOTS) {
1584
1486
  const route = routes[slot];
1585
- if (route?.provider && route?.model) out[slot] = normalizeWorkflowRoute(route);
1487
+ // Read/interpret path: a route with a model but no provider falls back to
1488
+ // config.defaultProvider (then DEFAULT_PROVIDER).
1489
+ if (route?.model && (route?.provider || fallbackProvider)) {
1490
+ out[slot] = normalizeWorkflowRoute(route, { provider: fallbackProvider });
1491
+ }
1586
1492
  }
1587
1493
  return out;
1588
1494
  }
@@ -1602,13 +1508,16 @@ function routeFromPreset(config, slotValue) {
1602
1508
  function agentRouteFromConfig(config, agentId, _dataDir) {
1603
1509
  const id = normalizeAgentId(agentId);
1604
1510
  if (!id) return null;
1605
- const explicit = normalizeWorkflowRoute(config?.agents?.[id])
1606
- || (id === 'maintainer' ? normalizeWorkflowRoute(config?.agents?.maintenance) : null);
1511
+ // Read/interpret path: inject config.defaultProvider (then DEFAULT_PROVIDER)
1512
+ // when a stored route omits its provider.
1513
+ const fallback = { provider: resolveDefaultProvider(config) };
1514
+ const explicit = normalizeWorkflowRoute(config?.agents?.[id], fallback)
1515
+ || (id === 'maintainer' ? normalizeWorkflowRoute(config?.agents?.maintenance, fallback) : null);
1607
1516
  if (explicit) return explicit;
1608
1517
 
1609
1518
  const agent = FIXED_AGENT_SLOTS.find((item) => item.id === id);
1610
1519
  if (agent?.workflowSlot) {
1611
- const workflowRoute = normalizeWorkflowRoute(config?.workflowRoutes?.[agent.workflowSlot]);
1520
+ const workflowRoute = normalizeWorkflowRoute(config?.workflowRoutes?.[agent.workflowSlot], fallback);
1612
1521
  if (workflowRoute) return workflowRoute;
1613
1522
  }
1614
1523
 
@@ -2182,8 +2091,17 @@ export async function createMixdogSessionRuntime({
2182
2091
  model,
2183
2092
  cwd = process.cwd(),
2184
2093
  toolMode = 'full',
2094
+ remote = false,
2185
2095
  } = {}) {
2186
2096
  bootProfile('session-runtime:start', { provider, model, toolMode, cwd });
2097
+ let remoteEnabled = remote === true;
2098
+ // Remote-mode transcript writer (Discord outbound). Lazily created per
2099
+ // session.id + cwd inside ask(); only active while remoteEnabled.
2100
+ let _transcriptWriter = null;
2101
+ let _twKey = '';
2102
+ // Last assistant text handed to the transcript writer (via onAssistantText),
2103
+ // so the post-turn final-content append can skip an exact duplicate.
2104
+ let _lastAppendedAssistant = '';
2187
2105
  process.env.MIXDOG_QUIET_SESSION_LOG ??= '1';
2188
2106
  const standaloneStartedAt = performance.now();
2189
2107
  ensureStandaloneEnvironment({
@@ -2590,9 +2508,11 @@ export async function createMixdogSessionRuntime({
2590
2508
  let sessionNeedsCwdRefresh = false;
2591
2509
  let closeRequested = false;
2592
2510
  let channelStartTimer = null;
2511
+ let channelStartPromise = null;
2593
2512
  let providerSetupWarmupTimer = null;
2594
2513
  let providerWarmupTimer = null;
2595
2514
  let providerModelWarmupTimer = null;
2515
+ let modelCatalogWarmupTimer = null;
2596
2516
  let codeGraphPrewarmTimer = null;
2597
2517
  let codeGraphPrewarmInFlight = false;
2598
2518
  let codeGraphPrewarmQueuedCwd = '';
@@ -2622,6 +2542,7 @@ export async function createMixdogSessionRuntime({
2622
2542
  }
2623
2543
  const sessionPrewarmDelayMs = envDelayMs('MIXDOG_SESSION_PREWARM_DELAY_MS', 50, { min: 0, max: 10_000 });
2624
2544
  const providerSetupWarmupDelayMs = envDelayMs('MIXDOG_PROVIDER_SETUP_WARMUP_DELAY_MS', 300, { min: 0, max: 60_000 });
2545
+ const modelCatalogWarmupDelayMs = envDelayMs('MIXDOG_MODEL_CATALOG_WARMUP_DELAY_MS', 200, { min: 0, max: 60_000 });
2625
2546
  const providerWarmupDelayMs = envDelayMs('MIXDOG_PROVIDER_WARMUP_DELAY_MS', 1_500, { min: 0, max: 60_000 });
2626
2547
  // Background model-catalog prefetch delay. Kept short so the first `/model`
2627
2548
  // open finds a warm cache instead of paying a cold full network load. The
@@ -2655,6 +2576,7 @@ export async function createMixdogSessionRuntime({
2655
2576
  const modelPrefetchEnabled = !envFlag('MIXDOG_DISABLE_PROVIDER_WARMUP')
2656
2577
  && !envFlag('MIXDOG_DISABLE_MODEL_PREFETCH');
2657
2578
  const codeGraphPrewarmEnabled = !envFlag('MIXDOG_DISABLE_CODE_GRAPH_PREWARM');
2579
+ const modelCatalogWarmupEnabled = !envFlag('MIXDOG_DISABLE_MODEL_CATALOG_WARMUP');
2658
2580
  // Lazy code-graph prewarm (default ON): do NOT prewarm at startup / on cwd
2659
2581
  // change — that fired ~250ms after the first frame and, in a large tree,
2660
2582
  // burned a worker (and felt like a freeze) before the user did anything.
@@ -2667,8 +2589,8 @@ export async function createMixdogSessionRuntime({
2667
2589
  const notificationListeners = new Set();
2668
2590
  let providerModelsCache = { models: null, at: 0 };
2669
2591
  let providerModelsPromise = null;
2592
+ let providerModelsLoadSeq = 0;
2670
2593
  let searchProviderModelsCache = { models: null, at: 0 };
2671
- let searchProviderModelsPromise = null;
2672
2594
  let usageDashboardCache = { dashboard: null, at: 0 };
2673
2595
  let usageDashboardPromise = null;
2674
2596
  let providerSetupCache = { setup: null, at: 0 };
@@ -2676,6 +2598,9 @@ export async function createMixdogSessionRuntime({
2676
2598
  let providerSetupPromise = null;
2677
2599
  let providerInitPromise = null;
2678
2600
  let mcpFailures = [];
2601
+ let lastProjectMcpKey = null;
2602
+ let mcpConnectGeneration = 0;
2603
+ let mcpConnectInFlight = null;
2679
2604
  let preSessionToolSurface = null;
2680
2605
  let contextStatusCacheKey = null;
2681
2606
  let contextStatusCacheValue = null;
@@ -2684,11 +2609,87 @@ export async function createMixdogSessionRuntime({
2684
2609
  hooks.emit('runtime:start', { cwd: currentCwd, provider: route.provider, model: route.model, toolMode: mode });
2685
2610
  bootProfile('hooks:ready', { ms: (performance.now() - hooksStartedAt).toFixed(1) });
2686
2611
 
2612
+ // ---------------------------------------------------------------------
2613
+ // Self-update (npm registry version check + optional auto-install).
2614
+ // updateCheckState mirrors the last-known checkLatestVersion() result;
2615
+ // updateProcessState tracks the in-flight install lifecycle so the TUI can
2616
+ // poll getUpdateStatus() instead of needing a push/event channel. Both are
2617
+ // purely in-memory (per runtime instance) — the on-disk cache lives inside
2618
+ // update-checker.mjs and is what actually enforces the 24h TTL.
2619
+ let updateCheckState = {
2620
+ currentVersion: null,
2621
+ latestVersion: null,
2622
+ updateAvailable: false,
2623
+ lastCheckedAt: 0,
2624
+ };
2625
+ // phase: 'idle' | 'checking' | 'installing' | 'installed' | 'failed'
2626
+ let updateProcessState = { phase: 'idle', version: null, error: null };
2627
+
2628
+ function autoUpdateEnabled() {
2629
+ return config?.update?.auto === true;
2630
+ }
2631
+
2632
+ async function checkForUpdateInternal({ force = false } = {}) {
2633
+ if (updateProcessState.phase !== 'installing') updateProcessState.phase = 'checking';
2634
+ try {
2635
+ const result = await checkLatestVersion({ force, dataDir: cfgMod.getPluginData?.() || STANDALONE_DATA_DIR });
2636
+ updateCheckState = {
2637
+ currentVersion: result.currentVersion,
2638
+ latestVersion: result.latestVersion,
2639
+ updateAvailable: result.updateAvailable,
2640
+ lastCheckedAt: result.lastCheckedAt,
2641
+ };
2642
+ } catch {
2643
+ // checkLatestVersion() is already silent-safe; this catch is belt-and-
2644
+ // braces so a boot-time call can never crash the runtime.
2645
+ } finally {
2646
+ if (updateProcessState.phase === 'checking') updateProcessState.phase = 'idle';
2647
+ }
2648
+ return updateCheckState;
2649
+ }
2650
+
2651
+ async function runUpdateNowInternal() {
2652
+ if (updateProcessState.phase === 'installing') {
2653
+ return { ...updateProcessState, alreadyInstalling: true, error: 'update already in progress' };
2654
+ }
2655
+ updateProcessState = { phase: 'installing', version: null, error: null };
2656
+ try {
2657
+ const result = await runGlobalUpdate();
2658
+ if (result?.ok) {
2659
+ updateProcessState = { phase: 'installed', version: result.version || null, error: null };
2660
+ } else {
2661
+ updateProcessState = { phase: 'failed', version: null, error: result?.error || 'update failed' };
2662
+ }
2663
+ } catch (err) {
2664
+ updateProcessState = { phase: 'failed', version: null, error: err?.message || String(err) };
2665
+ }
2666
+ return updateProcessState;
2667
+ }
2668
+
2669
+ // Non-blocking boot hook: fires after the runtime object below is fully
2670
+ // constructed (setTimeout(0) defers past the synchronous return), so a
2671
+ // slow/hanging registry request or npm install can never delay session
2672
+ // boot. Auto-update defaults to OFF (config.update.auto is undefined until
2673
+ // the user opts in via setAutoUpdate(true)); a failed check or install is
2674
+ // swallowed — getUpdateStatus()/getUpdateSettings() are the only surfaces,
2675
+ // there is no push notice channel from runtime -> TUI today.
2676
+ const updateBootTimer = setTimeout(() => {
2677
+ void (async () => {
2678
+ await checkForUpdateInternal({ force: false });
2679
+ if (autoUpdateEnabled() && updateCheckState.updateAvailable) {
2680
+ await runUpdateNowInternal();
2681
+ }
2682
+ })().catch(() => {});
2683
+ }, 0);
2684
+ updateBootTimer.unref?.();
2685
+
2687
2686
  function mcpTransportLabel(cfg = {}) {
2688
2687
  if (cfg.autoDetect) return `autoDetect:${cfg.autoDetect}`;
2689
- if (cfg.transport === 'http' || cfg.url) return 'http';
2690
- if (cfg.command) return 'stdio';
2691
- return 'unknown';
2688
+ try {
2689
+ return mcpClient.resolveMcpTransportKind(cfg);
2690
+ } catch {
2691
+ return 'unknown';
2692
+ }
2692
2693
  }
2693
2694
 
2694
2695
  function emitRuntimeNotification(content, meta = {}) {
@@ -2733,9 +2734,7 @@ export async function createMixdogSessionRuntime({
2733
2734
  }
2734
2735
 
2735
2736
  function mcpStatus() {
2736
- const configured = config?.mcpServers && typeof config.mcpServers === 'object'
2737
- ? config.mcpServers
2738
- : {};
2737
+ const { servers: configured, sources } = resolveEffectiveMcpServers();
2739
2738
  const connected = new Map((mcpClient.getMcpServerStatus?.() || []).map((row) => [row.name, row]));
2740
2739
  const failures = new Map((mcpFailures || []).map((row) => [row.name, row]));
2741
2740
  const servers = [];
@@ -2752,6 +2751,7 @@ export async function createMixdogSessionRuntime({
2752
2751
  toolCount: live?.toolCount || 0,
2753
2752
  tools: live?.tools || [],
2754
2753
  error: fail?.msg || null,
2754
+ source: sources[name] || 'config',
2755
2755
  });
2756
2756
  connected.delete(name);
2757
2757
  }
@@ -2856,7 +2856,6 @@ export async function createMixdogSessionRuntime({
2856
2856
  // context (see composeSystemPrompt), so there is nothing prompt-side to
2857
2857
  // refresh either. `markRefresh`/`changed` are kept only for signature
2858
2858
  // compatibility with existing callers.
2859
- void changed;
2860
2859
  void markRefresh;
2861
2860
  // Lazy mode: before the first turn (e.g. the initial project-selection
2862
2861
  // cwd set), do NOT prewarm — that is exactly the post-first-frame freeze
@@ -2867,6 +2866,21 @@ export async function createMixdogSessionRuntime({
2867
2866
  } else {
2868
2867
  scheduleCodeGraphPrewarm(changed ? 0 : codeGraphPrewarmDelayMs, changed ? 'cwd-change' : 'cwd');
2869
2868
  }
2869
+ // Project-local `.mcp.json` follows the cwd: when the effective project MCP
2870
+ // set changes, reconnect in the background (never await — this stays sync,
2871
+ // and the session is preserved). Guarded so a no-op cwd change does not
2872
+ // churn connections.
2873
+ if (changed) {
2874
+ try {
2875
+ const nextKey = resolved + '\u0000' + JSON.stringify(readProjectMcpServers(resolved));
2876
+ if (nextKey !== lastProjectMcpKey) {
2877
+ lastProjectMcpKey = nextKey;
2878
+ void connectConfiguredMcp({ reset: true })
2879
+ .then(() => invalidatePreSessionToolSurface())
2880
+ .catch(() => {});
2881
+ }
2882
+ } catch {}
2883
+ }
2870
2884
  return currentCwd;
2871
2885
  }
2872
2886
 
@@ -2880,17 +2894,23 @@ export async function createMixdogSessionRuntime({
2880
2894
  }
2881
2895
 
2882
2896
  function skillContent(name) {
2883
- const content = typeof contextMod.loadSkillContent === 'function'
2884
- ? contextMod.loadSkillContent(name, currentCwd)
2897
+ const res = typeof contextMod.loadSkillResource === 'function'
2898
+ ? contextMod.loadSkillResource(name, currentCwd)
2885
2899
  : null;
2886
- if (!content) throw new Error(`skill not found: ${name}`);
2887
- return { name, content };
2900
+ if (!res) throw new Error(`skill not found: ${name}`);
2901
+ return { name, content: res.content, dir: res.dir };
2888
2902
  }
2889
2903
 
2890
2904
  function skillToolContent(name) {
2891
2905
  const skill = skillContent(name);
2892
- const escapedName = String(skill.name || '').replace(/[<>&]/g, (ch) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[ch]));
2893
- return `<skill>\n<name>${escapedName}</name>\n${skill.content}\n</skill>`;
2906
+ // Return the general tool envelope so the main/Lead session behaves the
2907
+ // same as agent-loop sessions: the model-visible tool_result is the short
2908
+ // stub (`Loaded skill: <name>`) and the full SKILL.md body is delivered
2909
+ // ONCE as a separate injected role:'user' message (newMessages). The
2910
+ // envelope passes through internal-tools._normalize untouched (it preserves
2911
+ // __toolEnvelope objects), and the agent loop's central normalizeToolEnvelope
2912
+ // splits it into stub + injected user body.
2913
+ return contextMod.buildSkillToolEnvelope(skill.name, skill.content, skill.dir);
2894
2914
  }
2895
2915
 
2896
2916
  function addProjectSkill(input = {}) {
@@ -2965,19 +2985,52 @@ export async function createMixdogSessionRuntime({
2965
2985
  };
2966
2986
  }
2967
2987
 
2968
- async function connectConfiguredMcp({ reset = false } = {}) {
2969
- if (reset) await mcpClient.disconnectAll?.();
2970
- mcpFailures = [];
2971
- const servers = config?.mcpServers && typeof config.mcpServers === 'object'
2988
+ // Merge mixdog-config `agent.mcpServers` with project-local `.mcp.json`.
2989
+ // On name collision the mixdog-config entry WINS. `sources[name]` records
2990
+ // each server's origin ('config' | 'project') for status reporting.
2991
+ function resolveEffectiveMcpServers() {
2992
+ const configured = config?.mcpServers && typeof config.mcpServers === 'object'
2972
2993
  ? config.mcpServers
2973
2994
  : {};
2974
- if (Object.keys(servers).length === 0) return mcpStatus();
2995
+ const project = readProjectMcpServers(currentCwd);
2996
+ const servers = { ...project, ...configured };
2997
+ const sources = {};
2998
+ for (const name of Object.keys(project)) sources[name] = 'project';
2999
+ for (const name of Object.keys(configured)) sources[name] = 'config';
3000
+ return { servers, sources };
3001
+ }
3002
+
3003
+ async function connectConfiguredMcp({ reset = false } = {}) {
3004
+ // Serialize reconnects: boot connect, cwd-change reset, and rapid cwd
3005
+ // switches must never interleave their disconnect/connect phases, or an
3006
+ // older run finishing after a newer reset could re-add stale servers into
3007
+ // the shared client registry. Approach: a generation token + a single
3008
+ // in-flight promise. Each call bumps the generation, waits for any prior
3009
+ // run to finish, then bails if a newer call has superseded it — leaving the
3010
+ // latest requested effective-server-set in the registry.
3011
+ const gen = ++mcpConnectGeneration;
3012
+ if (mcpConnectInFlight) {
3013
+ try { await mcpConnectInFlight; } catch { /* prior run's failures already captured */ }
3014
+ }
3015
+ if (gen !== mcpConnectGeneration) return mcpStatus();
3016
+ const run = (async () => {
3017
+ if (reset) await mcpClient.disconnectAll?.();
3018
+ mcpFailures = [];
3019
+ const { servers } = resolveEffectiveMcpServers();
3020
+ if (Object.keys(servers).length === 0) return;
3021
+ try {
3022
+ await mcpClient.connectMcpServers(servers);
3023
+ } catch (error) {
3024
+ mcpFailures = Array.isArray(error?.failures)
3025
+ ? error.failures
3026
+ : [{ name: 'mcp', msg: error?.message || String(error) }];
3027
+ }
3028
+ })();
3029
+ mcpConnectInFlight = run;
2975
3030
  try {
2976
- await mcpClient.connectMcpServers(servers);
2977
- } catch (error) {
2978
- mcpFailures = Array.isArray(error?.failures)
2979
- ? error.failures
2980
- : [{ name: 'mcp', msg: error?.message || String(error) }];
3031
+ await run;
3032
+ } finally {
3033
+ if (mcpConnectInFlight === run) mcpConnectInFlight = null;
2981
3034
  }
2982
3035
  return mcpStatus();
2983
3036
  }
@@ -2985,10 +3038,42 @@ export async function createMixdogSessionRuntime({
2985
3038
  function normalizeMcpServerInput(input = {}) {
2986
3039
  const name = clean(input.name).toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
2987
3040
  if (!name) throw new Error('MCP server name is required');
3041
+ const coerceStringRecord = (value) => {
3042
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
3043
+ const out = {};
3044
+ for (const [key, val] of Object.entries(value)) {
3045
+ if (val === undefined || val === null) continue;
3046
+ out[String(key)] = String(val);
3047
+ }
3048
+ return Object.keys(out).length > 0 ? out : null;
3049
+ };
3050
+ const withOptionalHeaders = (config) => {
3051
+ const headers = coerceStringRecord(input.headers);
3052
+ if (headers) config.headers = headers;
3053
+ return config;
3054
+ };
2988
3055
  const url = clean(input.url);
3056
+ const type = clean(input.type).toLowerCase();
2989
3057
  if (url) {
3058
+ if (type === 'sse') {
3059
+ if (!/^https?:\/\//i.test(url)) throw new Error('MCP URL must start with http:// or https://');
3060
+ return { name, config: withOptionalHeaders({ type: 'sse', url }) };
3061
+ }
3062
+ if (type === 'ws') {
3063
+ if (!/^(?:wss?|https?):\/\//i.test(url)) {
3064
+ throw new Error('MCP WebSocket URL must start with ws://, wss://, http://, or https://');
3065
+ }
3066
+ return { name, config: withOptionalHeaders({ type: 'ws', url }) };
3067
+ }
3068
+ if (type === 'http' || type === 'streamable-http') {
3069
+ if (!/^https?:\/\//i.test(url)) throw new Error('MCP URL must start with http:// or https://');
3070
+ return { name, config: withOptionalHeaders({ type: 'http', url }) };
3071
+ }
3072
+ if (/^wss?:\/\//i.test(url)) {
3073
+ return { name, config: withOptionalHeaders({ type: 'ws', url }) };
3074
+ }
2990
3075
  if (!/^https?:\/\//i.test(url)) throw new Error('MCP URL must start with http:// or https://');
2991
- return { name, config: { transport: 'http', url } };
3076
+ return { name, config: withOptionalHeaders({ type: 'http', url }) };
2992
3077
  }
2993
3078
  const command = clean(input.command);
2994
3079
  if (!command) throw new Error('MCP server command or URL is required');
@@ -3002,7 +3087,10 @@ export async function createMixdogSessionRuntime({
3002
3087
  if (resolvedCwd !== root && !resolvedCwd.startsWith(`${root}\\`) && !resolvedCwd.startsWith(`${root}/`)) {
3003
3088
  throw new Error('MCP server cwd must stay under the current project');
3004
3089
  }
3005
- return { name, config: { command, args, cwd: resolvedCwd } };
3090
+ const config = { type: 'stdio', command, args, cwd: resolvedCwd };
3091
+ const env = coerceStringRecord(input.env);
3092
+ if (env) config.env = env;
3093
+ return { name, config };
3006
3094
  }
3007
3095
 
3008
3096
  const agentToolStartedAt = performance.now();
@@ -3036,10 +3124,12 @@ export async function createMixdogSessionRuntime({
3036
3124
  if (msg?.method !== 'notifications/claude/channel') return;
3037
3125
  const params = msg?.params && typeof msg.params === 'object' ? msg.params : {};
3038
3126
  const meta = params.meta && typeof params.meta === 'object' ? params.meta : {};
3039
- if (meta.silent_to_agent === true || meta.silent_to_agent === 'true') return;
3040
- const instruction = typeof meta.instruction === 'string' ? meta.instruction.trim() : '';
3041
- const content = instruction || String(params.content || '').trim();
3042
- emitRuntimeNotification(content, meta);
3127
+ const content = channelNotificationModelContent(params);
3128
+ if (!content) return;
3129
+ const handled = emitRuntimeNotification(content, meta);
3130
+ if (!handled && session?.id && shouldMirrorChannelNotificationToPending(meta)) {
3131
+ try { mgr.enqueuePendingMessage(session.id, content); } catch {}
3132
+ }
3043
3133
  },
3044
3134
  });
3045
3135
  bootProfile('channels:worker-ready', { ms: (performance.now() - channelsStartedAt).toFixed(1) });
@@ -3170,6 +3260,7 @@ export async function createMixdogSessionRuntime({
3170
3260
  },
3171
3261
  });
3172
3262
  internalTools.markBootReady?.();
3263
+ try { lastProjectMcpKey = resolve(currentCwd) + '\u0000' + JSON.stringify(readProjectMcpServers(currentCwd)); } catch { lastProjectMcpKey = null; }
3173
3264
  void connectConfiguredMcp()
3174
3265
  .then((status) => bootProfile('mcp:ready', {
3175
3266
  connected: Number(status?.connectedCount || 0),
@@ -3184,8 +3275,8 @@ export async function createMixdogSessionRuntime({
3184
3275
  function invalidateProviderCaches() {
3185
3276
  providerModelsCache = { models: null, at: 0 };
3186
3277
  providerModelsPromise = null;
3278
+ providerModelsLoadSeq += 1;
3187
3279
  searchProviderModelsCache = { models: null, at: 0 };
3188
- searchProviderModelsPromise = null;
3189
3280
  usageDashboardCache = { dashboard: null, at: 0 };
3190
3281
  usageDashboardPromise = null;
3191
3282
  providerSetupCache = { setup: null, at: 0 };
@@ -3632,17 +3723,11 @@ function parsedProviderModelVersion(id) {
3632
3723
  searchProviderModelsCache = { models: rows, at: Date.now() };
3633
3724
  return providerModelsFromCacheRows(rows);
3634
3725
  }
3635
- if (!searchProviderModelsPromise) {
3636
- searchProviderModelsPromise = loadSearchProviderModelsFresh()
3637
- .then((models) => {
3638
- searchProviderModelsCache = { models, at: Date.now() };
3639
- return models;
3640
- })
3641
- .finally(() => {
3642
- searchProviderModelsPromise = null;
3643
- });
3726
+ if (force) {
3727
+ const models = await loadSearchProviderModelsFresh({ forceRefresh: true });
3728
+ searchProviderModelsCache = { models, at: Date.now() };
3729
+ return providerModelsFromCacheRows(models);
3644
3730
  }
3645
- return providerModelsFromCacheRows(await searchProviderModelsPromise);
3646
3731
  }
3647
3732
 
3648
3733
  function enabledSearchProviderConfig() {
@@ -3656,7 +3741,7 @@ function parsedProviderModelVersion(id) {
3656
3741
  return out;
3657
3742
  }
3658
3743
 
3659
- async function loadSearchProviderModelsFresh() {
3744
+ async function loadSearchProviderModelsFresh({ forceRefresh = false } = {}) {
3660
3745
  const searchProviders = enabledSearchProviderConfig();
3661
3746
  const providerNames = Object.keys(searchProviders);
3662
3747
  if (!providerNames.length) return [];
@@ -3665,7 +3750,13 @@ function parsedProviderModelVersion(id) {
3665
3750
  const provider = reg.getProvider(name);
3666
3751
  if (typeof provider?.listModels !== 'function') return [];
3667
3752
  try {
3668
- const models = await provider.listModels();
3753
+ let models = null;
3754
+ if (forceRefresh && typeof provider._refreshModelCache === 'function') {
3755
+ models = await provider._refreshModelCache();
3756
+ }
3757
+ if (!Array.isArray(models)) {
3758
+ models = await provider.listModels();
3759
+ }
3669
3760
  if (!Array.isArray(models)) return [];
3670
3761
  const rows = [];
3671
3762
  for (const m of models) {
@@ -3699,14 +3790,20 @@ function parsedProviderModelVersion(id) {
3699
3790
  return results;
3700
3791
  }
3701
3792
 
3702
- async function loadProviderModelsFresh() {
3793
+ async function loadProviderModelsFresh({ forceRefresh = false } = {}) {
3703
3794
  ensureFullConfig();
3704
3795
  await ensureProvidersReady(config.providers || {});
3705
3796
  const allProviders = [...reg.getAllProviders()];
3706
3797
  const providerResults = await Promise.all(allProviders.map(async ([name, provider]) => {
3707
3798
  if (typeof provider?.listModels !== 'function') return [];
3708
3799
  try {
3709
- const models = await provider.listModels();
3800
+ let models = null;
3801
+ if (forceRefresh && typeof provider._refreshModelCache === 'function') {
3802
+ models = await provider._refreshModelCache();
3803
+ }
3804
+ if (!Array.isArray(models)) {
3805
+ models = await provider.listModels();
3806
+ }
3710
3807
  if (!Array.isArray(models)) return [];
3711
3808
  const rows = [];
3712
3809
  for (const m of models) {
@@ -3741,10 +3838,17 @@ function parsedProviderModelVersion(id) {
3741
3838
  warmProviderModelCache();
3742
3839
  return quickProviderModelRows();
3743
3840
  }
3841
+ if (force) {
3842
+ const seq = ++providerModelsLoadSeq;
3843
+ const models = await loadProviderModelsFresh({ forceRefresh: true });
3844
+ if (seq === providerModelsLoadSeq) providerModelsCache = { models, at: Date.now() };
3845
+ return providerModelsFromCacheRows(models);
3846
+ }
3744
3847
  if (!providerModelsPromise) {
3848
+ const seq = ++providerModelsLoadSeq;
3745
3849
  providerModelsPromise = loadProviderModelsFresh()
3746
3850
  .then((models) => {
3747
- providerModelsCache = { models, at: Date.now() };
3851
+ if (seq === providerModelsLoadSeq) providerModelsCache = { models, at: Date.now() };
3748
3852
  return models;
3749
3853
  })
3750
3854
  .finally(() => {
@@ -3756,9 +3860,10 @@ function parsedProviderModelVersion(id) {
3756
3860
 
3757
3861
  function warmProviderModelCache() {
3758
3862
  if (Array.isArray(providerModelsCache.models) || providerModelsPromise) return providerModelsPromise;
3863
+ const seq = ++providerModelsLoadSeq;
3759
3864
  providerModelsPromise = loadProviderModelsFresh()
3760
3865
  .then((models) => {
3761
- providerModelsCache = { models, at: Date.now() };
3866
+ if (seq === providerModelsLoadSeq) providerModelsCache = { models, at: Date.now() };
3762
3867
  bootProfile('provider-models:warm-ready', { count: models.length });
3763
3868
  return models;
3764
3869
  })
@@ -3795,12 +3900,22 @@ function parsedProviderModelVersion(id) {
3795
3900
  const requested = hasOwn(route, 'effort') ? route.effort : (route.preset?.effort || null);
3796
3901
  const effectiveEffort = coerceEffortFor(route.provider, modelMeta, requested);
3797
3902
  const fastCapable = fastCapableFor(route.provider, modelMeta);
3903
+ // Carry the catalog display name onto the route so the statusline shows a
3904
+ // human label (e.g. "Claude Fable 5") for preset-less direct models instead
3905
+ // of the raw id. `name` is only trusted when it differs from the raw model
3906
+ // id (some providers echo the id as `name`), so it can't clobber a better
3907
+ // already-resolved label. Falls back to existing route.modelDisplay, then unset.
3908
+ const metaName = clean(modelMeta?.name);
3909
+ const modelDisplay = clean(modelMeta?.display) || clean(modelMeta?.displayName)
3910
+ || (metaName && metaName !== clean(route.model) ? metaName : '')
3911
+ || clean(route.modelDisplay);
3798
3912
  route = {
3799
3913
  ...route,
3800
3914
  fast: fastCapable ? route.fast === true : false,
3801
3915
  fastCapable,
3802
3916
  effectiveEffort,
3803
3917
  effortOptions: effortItemsFor(route.provider, modelMeta, effectiveEffort),
3918
+ ...(modelDisplay ? { modelDisplay } : {}),
3804
3919
  };
3805
3920
  return route;
3806
3921
  }
@@ -3858,7 +3973,7 @@ function parsedProviderModelVersion(id) {
3858
3973
  preset: route.preset || undefined,
3859
3974
  tools: toolSpecForMode(mode),
3860
3975
  owner: 'cli',
3861
- role: 'lead',
3976
+ agent: 'lead',
3862
3977
  lane: 'cli',
3863
3978
  sourceType: 'lead',
3864
3979
  sourceName: 'main',
@@ -4022,6 +4137,27 @@ function parsedProviderModelVersion(id) {
4022
4137
  providerModelWarmupTimer.unref?.();
4023
4138
  }
4024
4139
 
4140
+ function scheduleModelCatalogWarmup(delayMs = modelCatalogWarmupDelayMs) {
4141
+ if (!modelCatalogWarmupEnabled) {
4142
+ bootProfile('model-catalog:warm-skipped', { reason: 'disabled' });
4143
+ return;
4144
+ }
4145
+ if (modelCatalogWarmupTimer || closeRequested) return;
4146
+ modelCatalogWarmupTimer = setTimeout(() => {
4147
+ modelCatalogWarmupTimer = null;
4148
+ if (closeRequested) return;
4149
+ if (activeTurnCount > 0 || sessionCreatePromise) {
4150
+ bootProfile('model-catalog:warm-deferred', { reason: activeTurnCount > 0 ? 'turn-active' : 'session-create' });
4151
+ scheduleModelCatalogWarmup(backgroundBusyRetryMs);
4152
+ return;
4153
+ }
4154
+ void warmCatalogsInBackground()
4155
+ .then(() => bootProfile('model-catalog:warm-ready'))
4156
+ .catch((error) => bootProfile('model-catalog:warm-failed', { error: error?.message || String(error) }));
4157
+ }, delayMs);
4158
+ modelCatalogWarmupTimer.unref?.();
4159
+ }
4160
+
4025
4161
  function scheduleStatuslineUsageWarmup(delayMs = statuslineUsageWarmupDelayMs) {
4026
4162
  const providerId = clean(route?.provider);
4027
4163
  if (!providerId || !providerId.includes('oauth')) {
@@ -4082,6 +4218,22 @@ function parsedProviderModelVersion(id) {
4082
4218
  statuslineUsageRefreshTimer.unref?.();
4083
4219
  }
4084
4220
 
4221
+ function invokeChannelStart() {
4222
+ if (channelStartPromise) return channelStartPromise;
4223
+ const startedAt = performance.now();
4224
+ bootProfile('channels:start:begin');
4225
+ channelStartPromise = channels.start()
4226
+ .then(() => bootProfile('channels:start:ready', { ms: (performance.now() - startedAt).toFixed(1) }))
4227
+ .catch((error) => bootProfile('channels:start:failed', {
4228
+ ms: (performance.now() - startedAt).toFixed(1),
4229
+ error: error?.message || String(error),
4230
+ }))
4231
+ .finally(() => {
4232
+ channelStartPromise = null;
4233
+ });
4234
+ return channelStartPromise;
4235
+ }
4236
+
4085
4237
  function scheduleChannelStart(delayMs = channelStartDelayMs) {
4086
4238
  if (envFlag('MIXDOG_DISABLE_CHANNEL_START')) {
4087
4239
  bootProfile('channels:start-skipped');
@@ -4091,28 +4243,59 @@ function parsedProviderModelVersion(id) {
4091
4243
  bootProfile('channels:start-disabled');
4092
4244
  return;
4093
4245
  }
4094
- if (channelStartTimer || closeRequested) return;
4246
+ if (channelStartTimer || channelStartPromise || closeRequested) return;
4095
4247
  bootProfile('channels:start-scheduled', { delayMs });
4096
4248
  channelStartTimer = setTimeout(() => {
4097
4249
  channelStartTimer = null;
4098
4250
  if (closeRequested) return;
4251
+ // A deferred start may straddle a stopRemote(); re-check before booting so
4252
+ // a turned-off session neither starts channels nor keeps rescheduling.
4253
+ if (!remoteEnabled) return;
4099
4254
  if (activeTurnCount > 0 || sessionCreatePromise) {
4100
4255
  bootProfile('channels:start-deferred', { reason: activeTurnCount > 0 ? 'turn-active' : 'session-create' });
4101
4256
  scheduleChannelStart(backgroundBusyRetryMs);
4102
4257
  return;
4103
4258
  }
4104
- const startedAt = performance.now();
4105
- bootProfile('channels:start:begin');
4106
- channels.start()
4107
- .then(() => bootProfile('channels:start:ready', { ms: (performance.now() - startedAt).toFixed(1) }))
4108
- .catch((error) => bootProfile('channels:start:failed', {
4109
- ms: (performance.now() - startedAt).toFixed(1),
4110
- error: error?.message || String(error),
4111
- }));
4259
+ void invokeChannelStart();
4112
4260
  }, delayMs);
4113
4261
  channelStartTimer.unref?.();
4114
4262
  }
4115
4263
 
4264
+ // Remote (Discord channel) mode is opt-in per session. Only a session that
4265
+ // explicitly enables remote — via `mixdog --remote` or the runtime toggle —
4266
+ // boots the channel worker and contends for channel ownership.
4267
+ function startRemote() {
4268
+ remoteEnabled = true;
4269
+ if (envFlag('MIXDOG_DISABLE_CHANNEL_START')) {
4270
+ bootProfile('channels:start-skipped');
4271
+ return true;
4272
+ }
4273
+ if (!channelsEnabled()) {
4274
+ bootProfile('channels:start-disabled');
4275
+ return true;
4276
+ }
4277
+ if (closeRequested) return true;
4278
+ if (channelStartTimer) {
4279
+ clearTimeout(channelStartTimer);
4280
+ channelStartTimer = null;
4281
+ }
4282
+ bootProfile('channels:start-scheduled', { delayMs: 0, immediate: true });
4283
+ void invokeChannelStart();
4284
+ return true;
4285
+ }
4286
+
4287
+ function stopRemote(reason) {
4288
+ remoteEnabled = false;
4289
+ // Cancel any pending deferred start so it can't fire after remote is off.
4290
+ if (channelStartTimer) { clearTimeout(channelStartTimer); channelStartTimer = null; }
4291
+ channels.stop(reason || 'remote-disabled', { waitForExit: false }).catch(() => {});
4292
+ return true;
4293
+ }
4294
+
4295
+ function isRemoteEnabled() {
4296
+ return remoteEnabled;
4297
+ }
4298
+
4116
4299
  function withTeardownDeadline(promise, ms, fallback = false) {
4117
4300
  let timer = null;
4118
4301
  return Promise.race([
@@ -4140,6 +4323,7 @@ function parsedProviderModelVersion(id) {
4140
4323
  bootProfile('code-graph:prewarm-lazy', { reason: 'startup-deferred-to-first-turn' });
4141
4324
  }
4142
4325
  scheduleProviderSetupWarmup();
4326
+ scheduleModelCatalogWarmup();
4143
4327
  scheduleProviderWarmup();
4144
4328
  // Warm the provider model catalog in the background, but keep it on its own
4145
4329
  // delay so short-lived detached runtimes can exit before /models I/O starts.
@@ -4147,7 +4331,10 @@ function parsedProviderModelVersion(id) {
4147
4331
  // MIXDOG_PROVIDER_MODEL_WARMUP_DELAY_MS explicitly.
4148
4332
  scheduleProviderModelWarmup();
4149
4333
  scheduleStatuslineUsageWarmup();
4150
- scheduleChannelStart();
4334
+ // Channels are opt-in: only boot the worker when this session started in (or
4335
+ // was toggled into) remote mode. Non-remote sessions never contend for the
4336
+ // channel; see startRemote()/stopRemote() and the `/remote` toggle.
4337
+ if (remoteEnabled) startRemote();
4151
4338
 
4152
4339
  function contextStatusCacheKeyFor({ messages, tools }) {
4153
4340
  const compaction = session?.compaction || {};
@@ -4174,10 +4361,12 @@ function parsedProviderModelVersion(id) {
4174
4361
  lastContextTokensUpdatedAt: Number(session?.lastContextTokensUpdatedAt || 0),
4175
4362
  lastContextTokensStaleAfterCompact: session?.lastContextTokensStaleAfterCompact === true,
4176
4363
  lastInputTokens: Number(session?.lastInputTokens || 0),
4364
+ lastUncachedInputTokens: Number(session?.lastUncachedInputTokens || 0),
4177
4365
  lastOutputTokens: Number(session?.lastOutputTokens || 0),
4178
4366
  lastCachedReadTokens: Number(session?.lastCachedReadTokens || 0),
4179
4367
  lastCacheWriteTokens: Number(session?.lastCacheWriteTokens || 0),
4180
4368
  totalInputTokens: Number(session?.totalInputTokens || 0),
4369
+ totalUncachedInputTokens: Number(session?.totalUncachedInputTokens || 0),
4181
4370
  totalOutputTokens: Number(session?.totalOutputTokens || 0),
4182
4371
  totalCachedReadTokens: Number(session?.totalCachedReadTokens || 0),
4183
4372
  totalCacheWriteTokens: Number(session?.totalCacheWriteTokens || 0),
@@ -4300,38 +4489,13 @@ function parsedProviderModelVersion(id) {
4300
4489
  // the gauge numerator.
4301
4490
  const usedTokens = estimatedContextTokens;
4302
4491
  const freeTokens = displayWindow ? Math.max(0, displayWindow - usedTokens) : 0;
4303
- const autoCompactTokenLimit = Number(session?.autoCompactTokenLimit || 0);
4304
- // The runtime fires auto-compaction at the effective boundary by default.
4305
- // Compute the same trigger here so /context shows what will actually fire
4306
- // before any compaction telemetry (session.compaction.triggerTokens) is
4307
- // recorded. Explicit buffer settings can still lower the trigger.
4308
- // An explicit autoCompactTokenLimit (provider/catalog/seed supplied)
4309
- // still takes precedence and is honored verbatim when it sits at or below
4310
- // the boundary.
4311
- const defaultCompactBufferTokens = contextDefaultBufferTokens(compactBoundaryTokens, session?.compaction || {});
4312
- const defaultCompactTriggerTokens = compactBoundaryTokens
4313
- ? Math.max(1, compactBoundaryTokens - defaultCompactBufferTokens)
4314
- : 0;
4315
- // Only an explicit auto-compact limit STRICTLY BELOW the boundary is a
4316
- // real trigger; a persisted value == boundary is a legacy derived
4317
- // full-window artifact and must fall through to the default trigger.
4318
- // Likewise ignore persisted default-buffer telemetry so the displayed
4319
- // trigger matches what the runtime now actually fires.
4320
- const persistedTriggerTokens = Number(session?.compaction?.triggerTokens || 0);
4321
- const legacyDefaultTriggerTelemetry = isLegacyDefaultContextBufferTelemetry(session?.compaction || {}, compactBoundaryTokens)
4322
- && persistedTriggerTokens > 0
4323
- && persistedTriggerTokens < compactBoundaryTokens;
4324
- const usablePersistedTrigger = persistedTriggerTokens > 0
4325
- && !legacyDefaultTriggerTelemetry
4326
- && (!compactBoundaryTokens || persistedTriggerTokens < compactBoundaryTokens)
4327
- ? persistedTriggerTokens
4328
- : 0;
4329
- const compactTriggerTokens = autoCompactTokenLimit && compactBoundaryTokens && autoCompactTokenLimit < compactBoundaryTokens
4330
- ? autoCompactTokenLimit
4331
- : (usablePersistedTrigger || defaultCompactTriggerTokens || 0);
4332
- const compactBufferTokens = Number(compactBoundaryTokens && compactTriggerTokens
4492
+ // Use the same shared compact-policy math as manager/loop. Do not trust
4493
+ // persisted trigger telemetry as an independent policy input: it is an
4494
+ // output snapshot and was the source of repeated /context false positives.
4495
+ const compactTriggerTokens = resolveCompactTriggerTokens(session || {}, compactBoundaryTokens) || 0;
4496
+ const compactBufferTokens = compactBoundaryTokens
4333
4497
  ? Math.max(0, compactBoundaryTokens - compactTriggerTokens)
4334
- : defaultCompactBufferTokens);
4498
+ : resolveCompactBufferTokens(compactBoundaryTokens, session?.compaction || {});
4335
4499
  const value = {
4336
4500
  sessionId: session?.id || null,
4337
4501
  provider: route.provider,
@@ -4366,11 +4530,13 @@ function parsedProviderModelVersion(id) {
4366
4530
  },
4367
4531
  usage: {
4368
4532
  lastInputTokens: Number(session?.lastInputTokens || 0),
4533
+ lastUncachedInputTokens: Number(session?.lastUncachedInputTokens || 0),
4369
4534
  lastOutputTokens: Number(session?.lastOutputTokens || 0),
4370
4535
  lastCachedReadTokens: Number(session?.lastCachedReadTokens || 0),
4371
4536
  lastCacheWriteTokens: Number(session?.lastCacheWriteTokens || 0),
4372
4537
  lastContextTokens,
4373
4538
  totalInputTokens: Number(session?.totalInputTokens || 0),
4539
+ totalUncachedInputTokens: Number(session?.totalUncachedInputTokens || 0),
4374
4540
  totalOutputTokens: Number(session?.totalOutputTokens || 0),
4375
4541
  totalCachedReadTokens: Number(session?.totalCachedReadTokens || 0),
4376
4542
  totalCacheWriteTokens: Number(session?.totalCacheWriteTokens || 0),
@@ -4446,6 +4612,21 @@ function parsedProviderModelVersion(id) {
4446
4612
  workflowRoutes: summarizeWorkflowRoutes(nextConfig),
4447
4613
  };
4448
4614
  },
4615
+ // Mark onboarding as done WITHOUT touching routes/agents/provider. Used by
4616
+ // the TUI "skip" (Esc) path so the wizard doesn't reappear next launch,
4617
+ // while leaving any existing config routes untouched.
4618
+ skipOnboarding() {
4619
+ const nextConfig = { ...config };
4620
+ nextConfig.onboarding = {
4621
+ ...(nextConfig.onboarding || {}),
4622
+ completed: true,
4623
+ version: ONBOARDING_VERSION,
4624
+ completedAt: new Date().toISOString(),
4625
+ skipped: true,
4626
+ };
4627
+ saveConfigAndAdopt(nextConfig);
4628
+ return this.getOnboardingStatus();
4629
+ },
4449
4630
  getAutoClear() {
4450
4631
  return normalizeAutoClearConfig(config.autoClear);
4451
4632
  },
@@ -4547,7 +4728,9 @@ function parsedProviderModelVersion(id) {
4547
4728
  }
4548
4729
  await channels.stop('settings-disabled', { waitForExit: false }).catch(() => {});
4549
4730
  } else {
4550
- scheduleChannelStart(0);
4731
+ // Enabling channels in settings only boots the worker when this session
4732
+ // is in remote mode; otherwise the toggle just persists config.
4733
+ if (remoteEnabled) scheduleChannelStart(0);
4551
4734
  }
4552
4735
  invalidatePreSessionToolSurface();
4553
4736
  return this.getChannelSettings();
@@ -4582,14 +4765,57 @@ function parsedProviderModelVersion(id) {
4582
4765
  saveConfigAndAdopt({ ...config, autoClear: next });
4583
4766
  return { ...normalizeAutoClearConfig(config.autoClear), label: formatDurationMs(normalizeAutoClearConfig(config.autoClear).idleMs) };
4584
4767
  },
4768
+ getUpdateSettings() {
4769
+ return {
4770
+ autoUpdate: autoUpdateEnabled(),
4771
+ currentVersion: updateCheckState.currentVersion || localPackageVersion(),
4772
+ latestVersion: updateCheckState.latestVersion,
4773
+ updateAvailable: updateCheckState.updateAvailable,
4774
+ lastCheckedAt: updateCheckState.lastCheckedAt,
4775
+ };
4776
+ },
4777
+ setAutoUpdate(enabled) {
4778
+ saveConfigAndAdopt({
4779
+ ...config,
4780
+ update: { ...(config.update || {}), auto: enabled === true },
4781
+ });
4782
+ return this.getUpdateSettings();
4783
+ },
4784
+ async checkForUpdate(options = {}) {
4785
+ await checkForUpdateInternal({ force: options?.force === true });
4786
+ return this.getUpdateSettings();
4787
+ },
4788
+ async runUpdateNow() {
4789
+ const state = await runUpdateNowInternal();
4790
+ return { ok: state.phase === 'installed', ...state };
4791
+ },
4792
+ getUpdateStatus() {
4793
+ return { ...updateProcessState };
4794
+ },
4585
4795
  async completeOnboarding(payload = {}) {
4586
- const defaultRoute = normalizeWorkflowRoute(payload.defaultRoute, route);
4796
+ // Only fall back to the live runtime route when the caller actually sent a
4797
+ // defaultRoute. The onboarding "partial save" path (Main left unset, only
4798
+ // Search/agent picks) omits defaultRoute entirely and must NOT persist the
4799
+ // current route as Main or recreate the session.
4800
+ const defaultRoute = hasOwn(payload, 'defaultRoute')
4801
+ ? normalizeWorkflowRoute(payload.defaultRoute, route)
4802
+ : null;
4587
4803
  const workflowInput = payload.workflowRoutes && typeof payload.workflowRoutes === 'object'
4588
4804
  ? payload.workflowRoutes
4589
4805
  : {};
4590
4806
  const nextConfig = { ...config };
4807
+ if (hasOwn(payload, 'defaultProvider')) {
4808
+ const requested = clean(payload.defaultProvider);
4809
+ if (requested) {
4810
+ if (!isKnownProvider(requested)) throw new Error(`unknown provider "${payload.defaultProvider}"`);
4811
+ nextConfig.defaultProvider = requested;
4812
+ }
4813
+ }
4591
4814
  let presets = Array.isArray(nextConfig.presets) ? nextConfig.presets.slice() : [];
4592
4815
  const workflowRoutes = { ...(nextConfig.workflowRoutes || {}) };
4816
+ // Track slots actually written THIS call so maintenance mirroring never
4817
+ // clobbers an existing slot the payload did not touch.
4818
+ const touchedWorkflowSlots = new Set();
4593
4819
 
4594
4820
  if (defaultRoute) {
4595
4821
  presets = upsertWorkflowPreset(presets, 'lead', defaultRoute);
@@ -4602,18 +4828,51 @@ function parsedProviderModelVersion(id) {
4602
4828
  if (!normalized) continue;
4603
4829
  workflowRoutes[slot] = normalized;
4604
4830
  presets = upsertWorkflowPreset(presets, slot, normalized);
4831
+ touchedWorkflowSlots.add(slot);
4605
4832
  }
4606
4833
 
4607
4834
  nextConfig.presets = presets;
4608
4835
  nextConfig.workflowRoutes = workflowRoutes;
4609
- // Maintenance slots store a direct {provider, model} route. Reuse the
4610
- // onboarding workflow routes directly; only overwrite a slot when its
4611
- // workflow route was actually provided, otherwise keep the existing slot.
4836
+ // Maintenance slots store a direct {provider, model} route. Only mirror a
4837
+ // slot that was actually written this call (touchedWorkflowSlots); an
4838
+ // untouched slot keeps its existing maintenance value.
4612
4839
  nextConfig.maintenance = {
4613
4840
  ...(nextConfig.maintenance || {}),
4614
- ...(workflowRoutes.explorer ? { explore: normalizeWorkflowRoute(workflowRoutes.explorer) } : {}),
4615
- ...(workflowRoutes.memory ? { memory: normalizeWorkflowRoute(workflowRoutes.memory) } : {}),
4841
+ ...(touchedWorkflowSlots.has('explorer') ? { explore: normalizeWorkflowRoute(workflowRoutes.explorer) } : {}),
4842
+ ...(touchedWorkflowSlots.has('memory') ? { memory: normalizeWorkflowRoute(workflowRoutes.memory) } : {}),
4616
4843
  };
4844
+ // Per-agent onboarding routes (id → route) for the full FIXED_AGENT_SLOTS
4845
+ // roster. Mirrors setAgentRoute persistence: config.agents[id], an
4846
+ // agent-<id> preset, and (for workflow-backed agents) the workflowRoute +
4847
+ // maintenance slot. Agents omitted from the payload inherit defaultRoute.
4848
+ const agentInput = payload.agentRoutes && typeof payload.agentRoutes === 'object'
4849
+ ? payload.agentRoutes
4850
+ : null;
4851
+ if (agentInput) {
4852
+ const nextAgents = { ...(nextConfig.agents || {}) };
4853
+ const nextMaintenance = { ...(nextConfig.maintenance || {}) };
4854
+ for (const agent of FIXED_AGENT_SLOTS) {
4855
+ // Persist ONLY agents with an explicit override in the payload. An
4856
+ // omitted agent is left untouched (existing value preserved, or
4857
+ // unwritten so the runtime dynamically follows the Main Model). Never
4858
+ // fall back to defaultRoute here — that would overwrite an agent the
4859
+ // user did not choose.
4860
+ const routeToSave = normalizeWorkflowRoute(agentInput[agent.id]);
4861
+ if (!routeToSave) continue;
4862
+ nextAgents[agent.id] = routeToSave;
4863
+ presets = upsertWorkflowPreset(presets, agentPresetSlot(agent.id), routeToSave);
4864
+ if (agent.workflowSlot) {
4865
+ workflowRoutes[agent.workflowSlot] = routeToSave;
4866
+ presets = upsertWorkflowPreset(presets, agent.workflowSlot, routeToSave);
4867
+ if (agent.id === 'explore') nextMaintenance.explore = routeToSave;
4868
+ if (agent.id === 'maintainer') nextMaintenance.memory = routeToSave;
4869
+ }
4870
+ }
4871
+ nextConfig.agents = nextAgents;
4872
+ nextConfig.presets = presets;
4873
+ nextConfig.workflowRoutes = workflowRoutes;
4874
+ nextConfig.maintenance = nextMaintenance;
4875
+ }
4617
4876
  nextConfig.onboarding = {
4618
4877
  ...(nextConfig.onboarding || {}),
4619
4878
  completed: true,
@@ -4621,6 +4880,14 @@ function parsedProviderModelVersion(id) {
4621
4880
  completedAt: new Date().toISOString(),
4622
4881
  };
4623
4882
 
4883
+ // Optional native web-search route. Only persisted when provided; mirrors
4884
+ // setSearchRoute's config write (skip provider capability validation here
4885
+ // since onboarding routes come from the vetted provider model list).
4886
+ if (payload.searchRoute) {
4887
+ const searchToSave = normalizeSearchRouteConfig(payload.searchRoute);
4888
+ if (searchToSave) nextConfig.searchRoute = searchToSave;
4889
+ }
4890
+
4624
4891
  saveConfigAndAdopt(nextConfig);
4625
4892
  if (defaultRoute) {
4626
4893
  route = resolveRoute(config, { provider: defaultRoute.provider, model: defaultRoute.model, effort: defaultRoute.effort });
@@ -4635,6 +4902,15 @@ function parsedProviderModelVersion(id) {
4635
4902
  getChannelWorkerStatus() {
4636
4903
  return channels.status();
4637
4904
  },
4905
+ startRemote() {
4906
+ return startRemote();
4907
+ },
4908
+ stopRemote(reason) {
4909
+ return stopRemote(reason);
4910
+ },
4911
+ isRemoteEnabled() {
4912
+ return isRemoteEnabled();
4913
+ },
4638
4914
  saveDiscordToken(token) {
4639
4915
  const result = saveDiscordToken(token);
4640
4916
  reloadChannelsSoon();
@@ -4645,6 +4921,20 @@ function parsedProviderModelVersion(id) {
4645
4921
  reloadChannelsSoon();
4646
4922
  return result;
4647
4923
  },
4924
+ saveTelegramToken(token) {
4925
+ const result = saveTelegramToken(token);
4926
+ reloadChannelsSoon();
4927
+ return result;
4928
+ },
4929
+ forgetTelegramToken() {
4930
+ const result = forgetTelegramToken();
4931
+ reloadChannelsSoon();
4932
+ return result;
4933
+ },
4934
+ setBackend(name) {
4935
+ const result = setBackend(name);
4936
+ return result;
4937
+ },
4648
4938
  saveWebhookAuthtoken(token) {
4649
4939
  const result = saveWebhookAuthtoken(token);
4650
4940
  reloadChannelsSoon();
@@ -4934,6 +5224,13 @@ function parsedProviderModelVersion(id) {
4934
5224
  saveConfigAndAdopt(nextConfig);
4935
5225
  return routeToSave;
4936
5226
  },
5227
+ async setDefaultProvider(provider) {
5228
+ const requested = clean(provider);
5229
+ if (!requested) throw new Error('provider is required');
5230
+ if (!isKnownProvider(requested)) throw new Error(`unknown provider "${provider}"`);
5231
+ saveConfigAndAdopt({ ...config, defaultProvider: requested });
5232
+ return requested;
5233
+ },
4937
5234
  async ask(prompt, options = {}) {
4938
5235
  activeTurnCount += 1;
4939
5236
  // Lazy code-graph prewarm: kick off the build ONCE, on the first real
@@ -4948,6 +5245,32 @@ function parsedProviderModelVersion(id) {
4948
5245
  try {
4949
5246
  await refreshSessionForCwdIfNeeded('cwd-change');
4950
5247
  if (!session?.id) await createCurrentSession('turn');
5248
+ // Remote outbound: ensure a transcript writer bound to the current
5249
+ // session.id + cwd. The channel worker's OutputForwarder tails this
5250
+ // JSONL and pushes the surface view to Discord. Gated on remoteEnabled
5251
+ // so non-remote sessions write nothing.
5252
+ if (remoteEnabled) {
5253
+ const twKey = `${session.id}\u0000${currentCwd}`;
5254
+ // Reset per-turn dedup tracker so an identical answer in a later turn
5255
+ // is still written (the guard only prevents same-turn double-write).
5256
+ _lastAppendedAssistant = '';
5257
+ if (_twKey !== twKey) {
5258
+ try {
5259
+ _transcriptWriter = createTranscriptWriter({
5260
+ mixdogHome: mixdogHome(),
5261
+ sessionId: session.id,
5262
+ cwd: currentCwd,
5263
+ pid: process.pid,
5264
+ });
5265
+ _transcriptWriter.writeSessionRecord();
5266
+ _twKey = twKey;
5267
+ } catch (error) {
5268
+ process.stderr.write(`mixdog: transcript-writer: init failed: ${error?.message || error}\n`);
5269
+ _transcriptWriter = null;
5270
+ _twKey = '';
5271
+ }
5272
+ }
5273
+ }
4951
5274
  hooks.emit('turn:start', { sessionId: session.id, prompt, cwd: currentCwd });
4952
5275
  // UserPromptSubmit: bridge to the standard hook bus. A hook FAILURE
4953
5276
  // must not block the turn, but a genuine blocked===true MUST throw.
@@ -4976,6 +5299,14 @@ function parsedProviderModelVersion(id) {
4976
5299
  name: call?.name || 'tool',
4977
5300
  callId: call?.id || null,
4978
5301
  });
5302
+ // Mirror each tool card the TUI shows into the transcript so the
5303
+ // forwarder renders the same one-line tool summary on Discord.
5304
+ // calls carry { name, arguments, id } (loop.mjs response.toolCalls);
5305
+ // some providers use `input` — accept either.
5306
+ if (remoteEnabled && _transcriptWriter) {
5307
+ try { _transcriptWriter.appendToolUse(call?.name, call?.input ?? call?.arguments); }
5308
+ catch (error) { process.stderr.write(`mixdog: transcript-writer: onToolCall failed: ${error?.message || error}\n`); }
5309
+ }
4979
5310
  }
4980
5311
  if (typeof options.onToolCall === 'function') {
4981
5312
  return await options.onToolCall(iter, calls);
@@ -4987,9 +5318,40 @@ function parsedProviderModelVersion(id) {
4987
5318
  {
4988
5319
  onTextDelta: options.onTextDelta,
4989
5320
  onReasoningDelta: options.onReasoningDelta,
4990
- onAssistantText: options.onAssistantText,
5321
+ onAssistantText: (text) => {
5322
+ // Capture the same assistant text the TUI renders (final answer +
5323
+ // mid-turn preamble). Feed the writer, then ALWAYS call through to
5324
+ // the caller's hook so terminal rendering is never affected.
5325
+ if (remoteEnabled && _transcriptWriter) {
5326
+ try {
5327
+ const value = typeof text === 'string' ? text : (text == null ? '' : String(text));
5328
+ if (value.trim()) {
5329
+ _transcriptWriter.appendAssistant(value);
5330
+ // Track the last line we wrote so the post-turn final
5331
+ // append below can skip an identical re-write (buffered
5332
+ // providers surface the final answer here AND in result).
5333
+ _lastAppendedAssistant = value;
5334
+ }
5335
+ }
5336
+ catch (error) { process.stderr.write(`mixdog: transcript-writer: onAssistantText failed: ${error?.message || error}\n`); }
5337
+ }
5338
+ return options.onAssistantText?.(text);
5339
+ },
4991
5340
  onUsageDelta: options.onUsageDelta,
4992
- onToolResult: options.onToolResult,
5341
+ onToolResult: (message) => {
5342
+ // Surface Edit diffs only: the forwarder reads toolUseResult
5343
+ // {oldString,newString}. Other tool results carry no diff payload,
5344
+ // so we append only when those fields are present.
5345
+ if (remoteEnabled && _transcriptWriter) {
5346
+ try {
5347
+ const tur = message?.toolUseResult;
5348
+ if (tur && (tur.oldString != null || tur.newString != null)) {
5349
+ _transcriptWriter.appendToolResult({ oldString: tur.oldString ?? '', newString: tur.newString ?? '' });
5350
+ }
5351
+ } catch (error) { process.stderr.write(`mixdog: transcript-writer: onToolResult failed: ${error?.message || error}\n`); }
5352
+ }
5353
+ return options.onToolResult?.(message);
5354
+ },
4993
5355
  onToolApproval: options.onToolApproval,
4994
5356
  onCompactEvent: options.onCompactEvent,
4995
5357
  onStageChange: options.onStageChange,
@@ -5000,6 +5362,24 @@ function parsedProviderModelVersion(id) {
5000
5362
  },
5001
5363
  );
5002
5364
  session = mgr.getSession(session.id) || session;
5365
+ // Final assistant text: onAssistantText fires only for buffered
5366
+ // (non-streaming) providers and for mid-turn preamble. Streaming
5367
+ // providers deliver the final answer via onTextDelta (which we do NOT
5368
+ // tap, to avoid per-delta spam), so append the authoritative final
5369
+ // content here from result.content. To avoid double-writing the
5370
+ // buffered case, only append when it differs from the last assistant
5371
+ // line onAssistantText already wrote.
5372
+ if (remoteEnabled && _transcriptWriter) {
5373
+ try {
5374
+ const finalText = result?.content != null ? String(result.content) : '';
5375
+ if (finalText.trim() && finalText !== _lastAppendedAssistant) {
5376
+ _transcriptWriter.appendAssistant(finalText);
5377
+ _lastAppendedAssistant = finalText;
5378
+ }
5379
+ } catch (error) {
5380
+ process.stderr.write(`mixdog: transcript-writer: final append failed: ${error?.message || error}\n`);
5381
+ }
5382
+ }
5003
5383
  hooks.emit('turn:end', { sessionId: session.id, elapsedMs: Date.now() - startedAt });
5004
5384
  // Stop: bridge to the standard hook bus. Best-effort; ignore result,
5005
5385
  // never throw.
@@ -5307,7 +5687,15 @@ function parsedProviderModelVersion(id) {
5307
5687
  return result;
5308
5688
  },
5309
5689
  async setRoute(next, options = {}) {
5310
- const applyToCurrentSession = options?.applyToCurrentSession !== false;
5690
+ // Model/provider changes take effect on the NEXT session only — never
5691
+ // rewrite a running session's provider/model in place. A live turn's
5692
+ // prompt cache (Anthropic/OpenAI/etc.) is provider-keyed; flipping
5693
+ // session.provider mid-conversation forces a full cache-miss rewrite
5694
+ // of the entire history on the very next turn (seen as a promptΔ
5695
+ // spike + cache_ratio=0% in session-bench). `route` (this closure
5696
+ // variable) still updates immediately so the NEXT createCurrentSession()
5697
+ // picks it up; only the currently-open session is left untouched.
5698
+ const applyToCurrentSession = options?.applyToCurrentSession === true;
5311
5699
  const requested = { ...(next || {}) };
5312
5700
  validateRequestedModelSelector(config, requested);
5313
5701
  const providerExplicitlyRequested = clean(next?.provider) !== '';
@@ -5335,12 +5723,6 @@ function parsedProviderModelVersion(id) {
5335
5723
  const fastCapable = fastCapableFor(selectedRoute.provider, modelMeta);
5336
5724
  selectedRoute = { ...selectedRoute, fast: fastCapable ? selectedRoute.fast === true : false };
5337
5725
  adoptConfig(saveModelSettings(cfgMod, selectedRoute, { fastCapable, baseConfig: config }), { hasSecrets: configHasSecrets });
5338
- if (!applyToCurrentSession) {
5339
- persistLeadRoute(selectedRoute);
5340
- refreshStatuslineUsageSnapshot(route);
5341
- scheduleStatuslineUsageRefresh();
5342
- return selectedRoute;
5343
- }
5344
5726
  const leadRoute = persistLeadRoute(selectedRoute);
5345
5727
  route = resolveRoute(config, leadRoute
5346
5728
  ? { model: workflowPresetId('lead') }
@@ -5348,6 +5730,11 @@ function parsedProviderModelVersion(id) {
5348
5730
  await refreshRouteEffort(modelMeta);
5349
5731
  refreshStatuslineUsageSnapshot(route);
5350
5732
  scheduleStatuslineUsageRefresh();
5733
+ if (!applyToCurrentSession) {
5734
+ // `route` is updated for the next session; the open session (and its
5735
+ // live provider/model/cache chain) is left alone on purpose.
5736
+ return route;
5737
+ }
5351
5738
  if (session) {
5352
5739
  const updated = mgr.updateSessionRoute?.(session.id, {
5353
5740
  provider: route.provider,
@@ -5434,6 +5821,10 @@ function parsedProviderModelVersion(id) {
5434
5821
  clearTimeout(providerModelWarmupTimer);
5435
5822
  providerModelWarmupTimer = null;
5436
5823
  }
5824
+ if (modelCatalogWarmupTimer) {
5825
+ clearTimeout(modelCatalogWarmupTimer);
5826
+ modelCatalogWarmupTimer = null;
5827
+ }
5437
5828
  if (codeGraphPrewarmTimer) {
5438
5829
  clearTimeout(codeGraphPrewarmTimer);
5439
5830
  codeGraphPrewarmTimer = null;
@@ -5512,8 +5903,8 @@ function parsedProviderModelVersion(id) {
5512
5903
  if (owner && !['cli', 'user', 'mixdog', 'legacy'].includes(owner)) return null;
5513
5904
  const sourceType = clean(s.sourceType || '').toLowerCase();
5514
5905
  const sourceName = clean(s.sourceName || '').toLowerCase();
5515
- const role = clean(s.role || '').toLowerCase();
5516
- const leadish = role === 'lead'
5906
+ const agent = clean(s.agent || '').toLowerCase();
5907
+ const leadish = agent === 'lead'
5517
5908
  || sourceType === 'lead'
5518
5909
  || (sourceType === 'cli' && (!sourceName || sourceName === 'main'))
5519
5910
  || (!sourceType && !sourceName && !isAgentOwner(owner));