mixdog 0.9.1 → 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 (214) hide show
  1. package/package.json +8 -1
  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/task-bench.mjs +207 -0
  23. package/scripts/tool-failures.mjs +6 -6
  24. package/scripts/tool-smoke.mjs +306 -66
  25. package/scripts/toolcall-args-test.mjs +81 -0
  26. package/src/agents/debugger/AGENT.md +4 -4
  27. package/src/agents/heavy-worker/AGENT.md +4 -2
  28. package/src/agents/reviewer/AGENT.md +4 -4
  29. package/src/agents/worker/AGENT.md +4 -2
  30. package/src/app.mjs +10 -6
  31. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  32. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  33. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  34. package/src/headless-role.mjs +14 -14
  35. package/src/help.mjs +1 -0
  36. package/src/lib/rules-builder.cjs +32 -54
  37. package/src/mixdog-session-runtime.mjs +710 -318
  38. package/src/output-styles/default.md +12 -7
  39. package/src/output-styles/minimal.md +25 -0
  40. package/src/output-styles/oneline.md +21 -0
  41. package/src/output-styles/simple.md +10 -9
  42. package/src/repl.mjs +12 -2
  43. package/src/rules/agent/00-common.md +7 -5
  44. package/src/rules/agent/30-explorer.md +7 -8
  45. package/src/rules/lead/01-general.md +3 -1
  46. package/src/rules/lead/lead-tool.md +7 -0
  47. package/src/rules/shared/01-tool.md +17 -12
  48. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  50. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  51. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  52. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  53. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  54. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  55. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  56. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  57. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  58. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  59. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  60. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  61. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  62. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +332 -57
  63. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +59 -32
  64. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  65. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  66. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  67. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  68. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  69. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +78 -3
  70. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +202 -98
  71. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +183 -20
  72. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  73. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  74. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  75. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -9
  76. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  77. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  78. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  79. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  80. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  81. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  82. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  83. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  84. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  85. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  86. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  87. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  88. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  90. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  91. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  92. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  93. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  94. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  95. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  96. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  97. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  99. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  100. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  101. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  102. package/src/runtime/channels/backends/discord.mjs +99 -9
  103. package/src/runtime/channels/backends/telegram.mjs +501 -0
  104. package/src/runtime/channels/index.mjs +441 -1224
  105. package/src/runtime/channels/lib/config.mjs +54 -2
  106. package/src/runtime/channels/lib/format.mjs +4 -2
  107. package/src/runtime/channels/lib/output-forwarder.mjs +80 -67
  108. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  109. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  110. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  111. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  112. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  113. package/src/runtime/channels/lib/webhook.mjs +59 -31
  114. package/src/runtime/channels/tool-defs.mjs +1 -1
  115. package/src/runtime/memory/index.mjs +184 -19
  116. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  117. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  118. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  119. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  120. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  121. package/src/runtime/memory/lib/memory.mjs +101 -4
  122. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  123. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  124. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  125. package/src/runtime/memory/tool-defs.mjs +6 -3
  126. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  127. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  128. package/src/runtime/shared/config.mjs +9 -0
  129. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  130. package/src/runtime/shared/schedules-store.mjs +21 -19
  131. package/src/runtime/shared/tool-surface.mjs +98 -13
  132. package/src/runtime/shared/transcript-writer.mjs +129 -0
  133. package/src/runtime/shared/update-checker.mjs +214 -0
  134. package/src/standalone/agent-tool.mjs +255 -109
  135. package/src/standalone/channel-admin.mjs +133 -40
  136. package/src/standalone/channel-worker.mjs +8 -291
  137. package/src/standalone/explore-tool.mjs +2 -2
  138. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  139. package/src/standalone/provider-admin.mjs +11 -0
  140. package/src/standalone/usage-dashboard.mjs +1 -1
  141. package/src/tui/App.jsx +2096 -732
  142. package/src/tui/components/ConfirmBar.jsx +47 -0
  143. package/src/tui/components/ContextPanel.jsx +5 -3
  144. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  145. package/src/tui/components/Markdown.jsx +22 -98
  146. package/src/tui/components/Message.jsx +14 -35
  147. package/src/tui/components/Picker.jsx +87 -12
  148. package/src/tui/components/PromptInput.jsx +83 -7
  149. package/src/tui/components/QueuedCommands.jsx +1 -1
  150. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  151. package/src/tui/components/Spinner.jsx +7 -7
  152. package/src/tui/components/StatusLine.jsx +40 -21
  153. package/src/tui/components/TextEntryPanel.jsx +51 -7
  154. package/src/tui/components/ToolExecution.jsx +170 -98
  155. package/src/tui/components/TurnDone.jsx +4 -4
  156. package/src/tui/components/UsagePanel.jsx +1 -1
  157. package/src/tui/components/tool-output-format.mjs +159 -21
  158. package/src/tui/components/tool-output-format.test.mjs +87 -0
  159. package/src/tui/display-width.mjs +69 -0
  160. package/src/tui/display-width.test.mjs +35 -0
  161. package/src/tui/dist/index.mjs +6965 -2391
  162. package/src/tui/engine.mjs +287 -126
  163. package/src/tui/index.jsx +117 -7
  164. package/src/tui/keyboard-protocol.mjs +42 -0
  165. package/src/tui/lib/voice-recorder.mjs +453 -0
  166. package/src/tui/markdown/format-token.mjs +129 -76
  167. package/src/tui/markdown/format-token.test.mjs +61 -19
  168. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  169. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  170. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  171. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  172. package/src/tui/markdown/table-layout.mjs +9 -9
  173. package/src/tui/paste-attachments.mjs +0 -11
  174. package/src/tui/prompt-history-store.mjs +129 -0
  175. package/src/tui/prompt-history-store.test.mjs +52 -0
  176. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  177. package/src/tui/theme.mjs +41 -657
  178. package/src/tui/themes/base.mjs +86 -0
  179. package/src/tui/themes/basic.mjs +85 -0
  180. package/src/tui/themes/catppuccin.mjs +72 -0
  181. package/src/tui/themes/dracula.mjs +70 -0
  182. package/src/tui/themes/everforest.mjs +71 -0
  183. package/src/tui/themes/gruvbox.mjs +71 -0
  184. package/src/tui/themes/index.mjs +71 -0
  185. package/src/tui/themes/indigo.mjs +78 -0
  186. package/src/tui/themes/kanagawa.mjs +80 -0
  187. package/src/tui/themes/light.mjs +81 -0
  188. package/src/tui/themes/nord.mjs +72 -0
  189. package/src/tui/themes/onedark.mjs +16 -0
  190. package/src/tui/themes/rosepine.mjs +70 -0
  191. package/src/tui/themes/teal.mjs +81 -0
  192. package/src/tui/themes/tokyonight.mjs +79 -0
  193. package/src/tui/themes/utils.mjs +106 -0
  194. package/src/tui/themes/warm.mjs +79 -0
  195. package/src/tui/transcript-tool-failures.mjs +13 -2
  196. package/src/ui/markdown.mjs +1 -1
  197. package/src/ui/model-display.mjs +2 -2
  198. package/src/ui/statusline.mjs +26 -27
  199. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  200. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  201. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  202. package/src/workflows/default/WORKFLOW.md +39 -12
  203. package/src/workflows/sequential/WORKFLOW.md +46 -0
  204. package/src/workflows/solo/WORKFLOW.md +7 -0
  205. package/vendor/ink/build/display-width.js +62 -0
  206. package/vendor/ink/build/ink.js +100 -12
  207. package/vendor/ink/build/measure-text.js +4 -1
  208. package/vendor/ink/build/output.js +115 -9
  209. package/vendor/ink/build/render-node-to-output.js +4 -1
  210. package/vendor/ink/build/render.js +4 -0
  211. package/src/output-styles/extreme-simple.md +0 -20
  212. package/src/rules/lead/04-workflow.md +0 -51
  213. package/src/workflows/default/workflow.json +0 -13
  214. 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
  }
@@ -2866,6 +2866,21 @@ export async function createMixdogSessionRuntime({
2866
2866
  } else {
2867
2867
  scheduleCodeGraphPrewarm(changed ? 0 : codeGraphPrewarmDelayMs, changed ? 'cwd-change' : 'cwd');
2868
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
+ }
2869
2884
  return currentCwd;
2870
2885
  }
2871
2886
 
@@ -2879,17 +2894,23 @@ export async function createMixdogSessionRuntime({
2879
2894
  }
2880
2895
 
2881
2896
  function skillContent(name) {
2882
- const content = typeof contextMod.loadSkillContent === 'function'
2883
- ? contextMod.loadSkillContent(name, currentCwd)
2897
+ const res = typeof contextMod.loadSkillResource === 'function'
2898
+ ? contextMod.loadSkillResource(name, currentCwd)
2884
2899
  : null;
2885
- if (!content) throw new Error(`skill not found: ${name}`);
2886
- return { name, content };
2900
+ if (!res) throw new Error(`skill not found: ${name}`);
2901
+ return { name, content: res.content, dir: res.dir };
2887
2902
  }
2888
2903
 
2889
2904
  function skillToolContent(name) {
2890
2905
  const skill = skillContent(name);
2891
- const escapedName = String(skill.name || '').replace(/[<>&]/g, (ch) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[ch]));
2892
- 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);
2893
2914
  }
2894
2915
 
2895
2916
  function addProjectSkill(input = {}) {
@@ -2964,19 +2985,52 @@ export async function createMixdogSessionRuntime({
2964
2985
  };
2965
2986
  }
2966
2987
 
2967
- async function connectConfiguredMcp({ reset = false } = {}) {
2968
- if (reset) await mcpClient.disconnectAll?.();
2969
- mcpFailures = [];
2970
- 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'
2971
2993
  ? config.mcpServers
2972
2994
  : {};
2973
- 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;
2974
3030
  try {
2975
- await mcpClient.connectMcpServers(servers);
2976
- } catch (error) {
2977
- mcpFailures = Array.isArray(error?.failures)
2978
- ? error.failures
2979
- : [{ name: 'mcp', msg: error?.message || String(error) }];
3031
+ await run;
3032
+ } finally {
3033
+ if (mcpConnectInFlight === run) mcpConnectInFlight = null;
2980
3034
  }
2981
3035
  return mcpStatus();
2982
3036
  }
@@ -2984,10 +3038,42 @@ export async function createMixdogSessionRuntime({
2984
3038
  function normalizeMcpServerInput(input = {}) {
2985
3039
  const name = clean(input.name).toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
2986
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
+ };
2987
3055
  const url = clean(input.url);
3056
+ const type = clean(input.type).toLowerCase();
2988
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
+ }
2989
3075
  if (!/^https?:\/\//i.test(url)) throw new Error('MCP URL must start with http:// or https://');
2990
- return { name, config: { transport: 'http', url } };
3076
+ return { name, config: withOptionalHeaders({ type: 'http', url }) };
2991
3077
  }
2992
3078
  const command = clean(input.command);
2993
3079
  if (!command) throw new Error('MCP server command or URL is required');
@@ -3001,7 +3087,10 @@ export async function createMixdogSessionRuntime({
3001
3087
  if (resolvedCwd !== root && !resolvedCwd.startsWith(`${root}\\`) && !resolvedCwd.startsWith(`${root}/`)) {
3002
3088
  throw new Error('MCP server cwd must stay under the current project');
3003
3089
  }
3004
- 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 };
3005
3094
  }
3006
3095
 
3007
3096
  const agentToolStartedAt = performance.now();
@@ -3035,10 +3124,12 @@ export async function createMixdogSessionRuntime({
3035
3124
  if (msg?.method !== 'notifications/claude/channel') return;
3036
3125
  const params = msg?.params && typeof msg.params === 'object' ? msg.params : {};
3037
3126
  const meta = params.meta && typeof params.meta === 'object' ? params.meta : {};
3038
- if (meta.silent_to_agent === true || meta.silent_to_agent === 'true') return;
3039
- const instruction = typeof meta.instruction === 'string' ? meta.instruction.trim() : '';
3040
- const content = instruction || String(params.content || '').trim();
3041
- 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
+ }
3042
3133
  },
3043
3134
  });
3044
3135
  bootProfile('channels:worker-ready', { ms: (performance.now() - channelsStartedAt).toFixed(1) });
@@ -3169,6 +3260,7 @@ export async function createMixdogSessionRuntime({
3169
3260
  },
3170
3261
  });
3171
3262
  internalTools.markBootReady?.();
3263
+ try { lastProjectMcpKey = resolve(currentCwd) + '\u0000' + JSON.stringify(readProjectMcpServers(currentCwd)); } catch { lastProjectMcpKey = null; }
3172
3264
  void connectConfiguredMcp()
3173
3265
  .then((status) => bootProfile('mcp:ready', {
3174
3266
  connected: Number(status?.connectedCount || 0),
@@ -3183,8 +3275,8 @@ export async function createMixdogSessionRuntime({
3183
3275
  function invalidateProviderCaches() {
3184
3276
  providerModelsCache = { models: null, at: 0 };
3185
3277
  providerModelsPromise = null;
3278
+ providerModelsLoadSeq += 1;
3186
3279
  searchProviderModelsCache = { models: null, at: 0 };
3187
- searchProviderModelsPromise = null;
3188
3280
  usageDashboardCache = { dashboard: null, at: 0 };
3189
3281
  usageDashboardPromise = null;
3190
3282
  providerSetupCache = { setup: null, at: 0 };
@@ -3631,17 +3723,11 @@ function parsedProviderModelVersion(id) {
3631
3723
  searchProviderModelsCache = { models: rows, at: Date.now() };
3632
3724
  return providerModelsFromCacheRows(rows);
3633
3725
  }
3634
- if (!searchProviderModelsPromise) {
3635
- searchProviderModelsPromise = loadSearchProviderModelsFresh()
3636
- .then((models) => {
3637
- searchProviderModelsCache = { models, at: Date.now() };
3638
- return models;
3639
- })
3640
- .finally(() => {
3641
- searchProviderModelsPromise = null;
3642
- });
3726
+ if (force) {
3727
+ const models = await loadSearchProviderModelsFresh({ forceRefresh: true });
3728
+ searchProviderModelsCache = { models, at: Date.now() };
3729
+ return providerModelsFromCacheRows(models);
3643
3730
  }
3644
- return providerModelsFromCacheRows(await searchProviderModelsPromise);
3645
3731
  }
3646
3732
 
3647
3733
  function enabledSearchProviderConfig() {
@@ -3655,7 +3741,7 @@ function parsedProviderModelVersion(id) {
3655
3741
  return out;
3656
3742
  }
3657
3743
 
3658
- async function loadSearchProviderModelsFresh() {
3744
+ async function loadSearchProviderModelsFresh({ forceRefresh = false } = {}) {
3659
3745
  const searchProviders = enabledSearchProviderConfig();
3660
3746
  const providerNames = Object.keys(searchProviders);
3661
3747
  if (!providerNames.length) return [];
@@ -3664,7 +3750,13 @@ function parsedProviderModelVersion(id) {
3664
3750
  const provider = reg.getProvider(name);
3665
3751
  if (typeof provider?.listModels !== 'function') return [];
3666
3752
  try {
3667
- 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
+ }
3668
3760
  if (!Array.isArray(models)) return [];
3669
3761
  const rows = [];
3670
3762
  for (const m of models) {
@@ -3698,14 +3790,20 @@ function parsedProviderModelVersion(id) {
3698
3790
  return results;
3699
3791
  }
3700
3792
 
3701
- async function loadProviderModelsFresh() {
3793
+ async function loadProviderModelsFresh({ forceRefresh = false } = {}) {
3702
3794
  ensureFullConfig();
3703
3795
  await ensureProvidersReady(config.providers || {});
3704
3796
  const allProviders = [...reg.getAllProviders()];
3705
3797
  const providerResults = await Promise.all(allProviders.map(async ([name, provider]) => {
3706
3798
  if (typeof provider?.listModels !== 'function') return [];
3707
3799
  try {
3708
- 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
+ }
3709
3807
  if (!Array.isArray(models)) return [];
3710
3808
  const rows = [];
3711
3809
  for (const m of models) {
@@ -3740,10 +3838,17 @@ function parsedProviderModelVersion(id) {
3740
3838
  warmProviderModelCache();
3741
3839
  return quickProviderModelRows();
3742
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
+ }
3743
3847
  if (!providerModelsPromise) {
3848
+ const seq = ++providerModelsLoadSeq;
3744
3849
  providerModelsPromise = loadProviderModelsFresh()
3745
3850
  .then((models) => {
3746
- providerModelsCache = { models, at: Date.now() };
3851
+ if (seq === providerModelsLoadSeq) providerModelsCache = { models, at: Date.now() };
3747
3852
  return models;
3748
3853
  })
3749
3854
  .finally(() => {
@@ -3755,9 +3860,10 @@ function parsedProviderModelVersion(id) {
3755
3860
 
3756
3861
  function warmProviderModelCache() {
3757
3862
  if (Array.isArray(providerModelsCache.models) || providerModelsPromise) return providerModelsPromise;
3863
+ const seq = ++providerModelsLoadSeq;
3758
3864
  providerModelsPromise = loadProviderModelsFresh()
3759
3865
  .then((models) => {
3760
- providerModelsCache = { models, at: Date.now() };
3866
+ if (seq === providerModelsLoadSeq) providerModelsCache = { models, at: Date.now() };
3761
3867
  bootProfile('provider-models:warm-ready', { count: models.length });
3762
3868
  return models;
3763
3869
  })
@@ -3794,12 +3900,22 @@ function parsedProviderModelVersion(id) {
3794
3900
  const requested = hasOwn(route, 'effort') ? route.effort : (route.preset?.effort || null);
3795
3901
  const effectiveEffort = coerceEffortFor(route.provider, modelMeta, requested);
3796
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);
3797
3912
  route = {
3798
3913
  ...route,
3799
3914
  fast: fastCapable ? route.fast === true : false,
3800
3915
  fastCapable,
3801
3916
  effectiveEffort,
3802
3917
  effortOptions: effortItemsFor(route.provider, modelMeta, effectiveEffort),
3918
+ ...(modelDisplay ? { modelDisplay } : {}),
3803
3919
  };
3804
3920
  return route;
3805
3921
  }
@@ -3857,7 +3973,7 @@ function parsedProviderModelVersion(id) {
3857
3973
  preset: route.preset || undefined,
3858
3974
  tools: toolSpecForMode(mode),
3859
3975
  owner: 'cli',
3860
- role: 'lead',
3976
+ agent: 'lead',
3861
3977
  lane: 'cli',
3862
3978
  sourceType: 'lead',
3863
3979
  sourceName: 'main',
@@ -4021,6 +4137,27 @@ function parsedProviderModelVersion(id) {
4021
4137
  providerModelWarmupTimer.unref?.();
4022
4138
  }
4023
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
+
4024
4161
  function scheduleStatuslineUsageWarmup(delayMs = statuslineUsageWarmupDelayMs) {
4025
4162
  const providerId = clean(route?.provider);
4026
4163
  if (!providerId || !providerId.includes('oauth')) {
@@ -4081,6 +4218,22 @@ function parsedProviderModelVersion(id) {
4081
4218
  statuslineUsageRefreshTimer.unref?.();
4082
4219
  }
4083
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
+
4084
4237
  function scheduleChannelStart(delayMs = channelStartDelayMs) {
4085
4238
  if (envFlag('MIXDOG_DISABLE_CHANNEL_START')) {
4086
4239
  bootProfile('channels:start-skipped');
@@ -4090,28 +4243,59 @@ function parsedProviderModelVersion(id) {
4090
4243
  bootProfile('channels:start-disabled');
4091
4244
  return;
4092
4245
  }
4093
- if (channelStartTimer || closeRequested) return;
4246
+ if (channelStartTimer || channelStartPromise || closeRequested) return;
4094
4247
  bootProfile('channels:start-scheduled', { delayMs });
4095
4248
  channelStartTimer = setTimeout(() => {
4096
4249
  channelStartTimer = null;
4097
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;
4098
4254
  if (activeTurnCount > 0 || sessionCreatePromise) {
4099
4255
  bootProfile('channels:start-deferred', { reason: activeTurnCount > 0 ? 'turn-active' : 'session-create' });
4100
4256
  scheduleChannelStart(backgroundBusyRetryMs);
4101
4257
  return;
4102
4258
  }
4103
- const startedAt = performance.now();
4104
- bootProfile('channels:start:begin');
4105
- channels.start()
4106
- .then(() => bootProfile('channels:start:ready', { ms: (performance.now() - startedAt).toFixed(1) }))
4107
- .catch((error) => bootProfile('channels:start:failed', {
4108
- ms: (performance.now() - startedAt).toFixed(1),
4109
- error: error?.message || String(error),
4110
- }));
4259
+ void invokeChannelStart();
4111
4260
  }, delayMs);
4112
4261
  channelStartTimer.unref?.();
4113
4262
  }
4114
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
+
4115
4299
  function withTeardownDeadline(promise, ms, fallback = false) {
4116
4300
  let timer = null;
4117
4301
  return Promise.race([
@@ -4139,6 +4323,7 @@ function parsedProviderModelVersion(id) {
4139
4323
  bootProfile('code-graph:prewarm-lazy', { reason: 'startup-deferred-to-first-turn' });
4140
4324
  }
4141
4325
  scheduleProviderSetupWarmup();
4326
+ scheduleModelCatalogWarmup();
4142
4327
  scheduleProviderWarmup();
4143
4328
  // Warm the provider model catalog in the background, but keep it on its own
4144
4329
  // delay so short-lived detached runtimes can exit before /models I/O starts.
@@ -4146,7 +4331,10 @@ function parsedProviderModelVersion(id) {
4146
4331
  // MIXDOG_PROVIDER_MODEL_WARMUP_DELAY_MS explicitly.
4147
4332
  scheduleProviderModelWarmup();
4148
4333
  scheduleStatuslineUsageWarmup();
4149
- 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();
4150
4338
 
4151
4339
  function contextStatusCacheKeyFor({ messages, tools }) {
4152
4340
  const compaction = session?.compaction || {};
@@ -4173,10 +4361,12 @@ function parsedProviderModelVersion(id) {
4173
4361
  lastContextTokensUpdatedAt: Number(session?.lastContextTokensUpdatedAt || 0),
4174
4362
  lastContextTokensStaleAfterCompact: session?.lastContextTokensStaleAfterCompact === true,
4175
4363
  lastInputTokens: Number(session?.lastInputTokens || 0),
4364
+ lastUncachedInputTokens: Number(session?.lastUncachedInputTokens || 0),
4176
4365
  lastOutputTokens: Number(session?.lastOutputTokens || 0),
4177
4366
  lastCachedReadTokens: Number(session?.lastCachedReadTokens || 0),
4178
4367
  lastCacheWriteTokens: Number(session?.lastCacheWriteTokens || 0),
4179
4368
  totalInputTokens: Number(session?.totalInputTokens || 0),
4369
+ totalUncachedInputTokens: Number(session?.totalUncachedInputTokens || 0),
4180
4370
  totalOutputTokens: Number(session?.totalOutputTokens || 0),
4181
4371
  totalCachedReadTokens: Number(session?.totalCachedReadTokens || 0),
4182
4372
  totalCacheWriteTokens: Number(session?.totalCacheWriteTokens || 0),
@@ -4299,38 +4489,13 @@ function parsedProviderModelVersion(id) {
4299
4489
  // the gauge numerator.
4300
4490
  const usedTokens = estimatedContextTokens;
4301
4491
  const freeTokens = displayWindow ? Math.max(0, displayWindow - usedTokens) : 0;
4302
- const autoCompactTokenLimit = Number(session?.autoCompactTokenLimit || 0);
4303
- // The runtime fires auto-compaction at the effective boundary by default.
4304
- // Compute the same trigger here so /context shows what will actually fire
4305
- // before any compaction telemetry (session.compaction.triggerTokens) is
4306
- // recorded. Explicit buffer settings can still lower the trigger.
4307
- // An explicit autoCompactTokenLimit (provider/catalog/seed supplied)
4308
- // still takes precedence and is honored verbatim when it sits at or below
4309
- // the boundary.
4310
- const defaultCompactBufferTokens = contextDefaultBufferTokens(compactBoundaryTokens, session?.compaction || {});
4311
- const defaultCompactTriggerTokens = compactBoundaryTokens
4312
- ? Math.max(1, compactBoundaryTokens - defaultCompactBufferTokens)
4313
- : 0;
4314
- // Only an explicit auto-compact limit STRICTLY BELOW the boundary is a
4315
- // real trigger; a persisted value == boundary is a legacy derived
4316
- // full-window artifact and must fall through to the default trigger.
4317
- // Likewise ignore persisted default-buffer telemetry so the displayed
4318
- // trigger matches what the runtime now actually fires.
4319
- const persistedTriggerTokens = Number(session?.compaction?.triggerTokens || 0);
4320
- const legacyDefaultTriggerTelemetry = isLegacyDefaultContextBufferTelemetry(session?.compaction || {}, compactBoundaryTokens)
4321
- && persistedTriggerTokens > 0
4322
- && persistedTriggerTokens < compactBoundaryTokens;
4323
- const usablePersistedTrigger = persistedTriggerTokens > 0
4324
- && !legacyDefaultTriggerTelemetry
4325
- && (!compactBoundaryTokens || persistedTriggerTokens < compactBoundaryTokens)
4326
- ? persistedTriggerTokens
4327
- : 0;
4328
- const compactTriggerTokens = autoCompactTokenLimit && compactBoundaryTokens && autoCompactTokenLimit < compactBoundaryTokens
4329
- ? autoCompactTokenLimit
4330
- : (usablePersistedTrigger || defaultCompactTriggerTokens || 0);
4331
- 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
4332
4497
  ? Math.max(0, compactBoundaryTokens - compactTriggerTokens)
4333
- : defaultCompactBufferTokens);
4498
+ : resolveCompactBufferTokens(compactBoundaryTokens, session?.compaction || {});
4334
4499
  const value = {
4335
4500
  sessionId: session?.id || null,
4336
4501
  provider: route.provider,
@@ -4365,11 +4530,13 @@ function parsedProviderModelVersion(id) {
4365
4530
  },
4366
4531
  usage: {
4367
4532
  lastInputTokens: Number(session?.lastInputTokens || 0),
4533
+ lastUncachedInputTokens: Number(session?.lastUncachedInputTokens || 0),
4368
4534
  lastOutputTokens: Number(session?.lastOutputTokens || 0),
4369
4535
  lastCachedReadTokens: Number(session?.lastCachedReadTokens || 0),
4370
4536
  lastCacheWriteTokens: Number(session?.lastCacheWriteTokens || 0),
4371
4537
  lastContextTokens,
4372
4538
  totalInputTokens: Number(session?.totalInputTokens || 0),
4539
+ totalUncachedInputTokens: Number(session?.totalUncachedInputTokens || 0),
4373
4540
  totalOutputTokens: Number(session?.totalOutputTokens || 0),
4374
4541
  totalCachedReadTokens: Number(session?.totalCachedReadTokens || 0),
4375
4542
  totalCacheWriteTokens: Number(session?.totalCacheWriteTokens || 0),
@@ -4445,6 +4612,21 @@ function parsedProviderModelVersion(id) {
4445
4612
  workflowRoutes: summarizeWorkflowRoutes(nextConfig),
4446
4613
  };
4447
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
+ },
4448
4630
  getAutoClear() {
4449
4631
  return normalizeAutoClearConfig(config.autoClear);
4450
4632
  },
@@ -4546,7 +4728,9 @@ function parsedProviderModelVersion(id) {
4546
4728
  }
4547
4729
  await channels.stop('settings-disabled', { waitForExit: false }).catch(() => {});
4548
4730
  } else {
4549
- 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);
4550
4734
  }
4551
4735
  invalidatePreSessionToolSurface();
4552
4736
  return this.getChannelSettings();
@@ -4581,14 +4765,57 @@ function parsedProviderModelVersion(id) {
4581
4765
  saveConfigAndAdopt({ ...config, autoClear: next });
4582
4766
  return { ...normalizeAutoClearConfig(config.autoClear), label: formatDurationMs(normalizeAutoClearConfig(config.autoClear).idleMs) };
4583
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
+ },
4584
4795
  async completeOnboarding(payload = {}) {
4585
- 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;
4586
4803
  const workflowInput = payload.workflowRoutes && typeof payload.workflowRoutes === 'object'
4587
4804
  ? payload.workflowRoutes
4588
4805
  : {};
4589
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
+ }
4590
4814
  let presets = Array.isArray(nextConfig.presets) ? nextConfig.presets.slice() : [];
4591
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();
4592
4819
 
4593
4820
  if (defaultRoute) {
4594
4821
  presets = upsertWorkflowPreset(presets, 'lead', defaultRoute);
@@ -4601,18 +4828,51 @@ function parsedProviderModelVersion(id) {
4601
4828
  if (!normalized) continue;
4602
4829
  workflowRoutes[slot] = normalized;
4603
4830
  presets = upsertWorkflowPreset(presets, slot, normalized);
4831
+ touchedWorkflowSlots.add(slot);
4604
4832
  }
4605
4833
 
4606
4834
  nextConfig.presets = presets;
4607
4835
  nextConfig.workflowRoutes = workflowRoutes;
4608
- // Maintenance slots store a direct {provider, model} route. Reuse the
4609
- // onboarding workflow routes directly; only overwrite a slot when its
4610
- // 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.
4611
4839
  nextConfig.maintenance = {
4612
4840
  ...(nextConfig.maintenance || {}),
4613
- ...(workflowRoutes.explorer ? { explore: normalizeWorkflowRoute(workflowRoutes.explorer) } : {}),
4614
- ...(workflowRoutes.memory ? { memory: normalizeWorkflowRoute(workflowRoutes.memory) } : {}),
4841
+ ...(touchedWorkflowSlots.has('explorer') ? { explore: normalizeWorkflowRoute(workflowRoutes.explorer) } : {}),
4842
+ ...(touchedWorkflowSlots.has('memory') ? { memory: normalizeWorkflowRoute(workflowRoutes.memory) } : {}),
4615
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
+ }
4616
4876
  nextConfig.onboarding = {
4617
4877
  ...(nextConfig.onboarding || {}),
4618
4878
  completed: true,
@@ -4620,6 +4880,14 @@ function parsedProviderModelVersion(id) {
4620
4880
  completedAt: new Date().toISOString(),
4621
4881
  };
4622
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
+
4623
4891
  saveConfigAndAdopt(nextConfig);
4624
4892
  if (defaultRoute) {
4625
4893
  route = resolveRoute(config, { provider: defaultRoute.provider, model: defaultRoute.model, effort: defaultRoute.effort });
@@ -4634,6 +4902,15 @@ function parsedProviderModelVersion(id) {
4634
4902
  getChannelWorkerStatus() {
4635
4903
  return channels.status();
4636
4904
  },
4905
+ startRemote() {
4906
+ return startRemote();
4907
+ },
4908
+ stopRemote(reason) {
4909
+ return stopRemote(reason);
4910
+ },
4911
+ isRemoteEnabled() {
4912
+ return isRemoteEnabled();
4913
+ },
4637
4914
  saveDiscordToken(token) {
4638
4915
  const result = saveDiscordToken(token);
4639
4916
  reloadChannelsSoon();
@@ -4644,6 +4921,20 @@ function parsedProviderModelVersion(id) {
4644
4921
  reloadChannelsSoon();
4645
4922
  return result;
4646
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
+ },
4647
4938
  saveWebhookAuthtoken(token) {
4648
4939
  const result = saveWebhookAuthtoken(token);
4649
4940
  reloadChannelsSoon();
@@ -4933,6 +5224,13 @@ function parsedProviderModelVersion(id) {
4933
5224
  saveConfigAndAdopt(nextConfig);
4934
5225
  return routeToSave;
4935
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
+ },
4936
5234
  async ask(prompt, options = {}) {
4937
5235
  activeTurnCount += 1;
4938
5236
  // Lazy code-graph prewarm: kick off the build ONCE, on the first real
@@ -4947,6 +5245,32 @@ function parsedProviderModelVersion(id) {
4947
5245
  try {
4948
5246
  await refreshSessionForCwdIfNeeded('cwd-change');
4949
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
+ }
4950
5274
  hooks.emit('turn:start', { sessionId: session.id, prompt, cwd: currentCwd });
4951
5275
  // UserPromptSubmit: bridge to the standard hook bus. A hook FAILURE
4952
5276
  // must not block the turn, but a genuine blocked===true MUST throw.
@@ -4975,6 +5299,14 @@ function parsedProviderModelVersion(id) {
4975
5299
  name: call?.name || 'tool',
4976
5300
  callId: call?.id || null,
4977
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
+ }
4978
5310
  }
4979
5311
  if (typeof options.onToolCall === 'function') {
4980
5312
  return await options.onToolCall(iter, calls);
@@ -4986,9 +5318,40 @@ function parsedProviderModelVersion(id) {
4986
5318
  {
4987
5319
  onTextDelta: options.onTextDelta,
4988
5320
  onReasoningDelta: options.onReasoningDelta,
4989
- 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
+ },
4990
5340
  onUsageDelta: options.onUsageDelta,
4991
- 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
+ },
4992
5355
  onToolApproval: options.onToolApproval,
4993
5356
  onCompactEvent: options.onCompactEvent,
4994
5357
  onStageChange: options.onStageChange,
@@ -4999,6 +5362,24 @@ function parsedProviderModelVersion(id) {
4999
5362
  },
5000
5363
  );
5001
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
+ }
5002
5383
  hooks.emit('turn:end', { sessionId: session.id, elapsedMs: Date.now() - startedAt });
5003
5384
  // Stop: bridge to the standard hook bus. Best-effort; ignore result,
5004
5385
  // never throw.
@@ -5306,7 +5687,15 @@ function parsedProviderModelVersion(id) {
5306
5687
  return result;
5307
5688
  },
5308
5689
  async setRoute(next, options = {}) {
5309
- 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;
5310
5699
  const requested = { ...(next || {}) };
5311
5700
  validateRequestedModelSelector(config, requested);
5312
5701
  const providerExplicitlyRequested = clean(next?.provider) !== '';
@@ -5334,12 +5723,6 @@ function parsedProviderModelVersion(id) {
5334
5723
  const fastCapable = fastCapableFor(selectedRoute.provider, modelMeta);
5335
5724
  selectedRoute = { ...selectedRoute, fast: fastCapable ? selectedRoute.fast === true : false };
5336
5725
  adoptConfig(saveModelSettings(cfgMod, selectedRoute, { fastCapable, baseConfig: config }), { hasSecrets: configHasSecrets });
5337
- if (!applyToCurrentSession) {
5338
- persistLeadRoute(selectedRoute);
5339
- refreshStatuslineUsageSnapshot(route);
5340
- scheduleStatuslineUsageRefresh();
5341
- return selectedRoute;
5342
- }
5343
5726
  const leadRoute = persistLeadRoute(selectedRoute);
5344
5727
  route = resolveRoute(config, leadRoute
5345
5728
  ? { model: workflowPresetId('lead') }
@@ -5347,6 +5730,11 @@ function parsedProviderModelVersion(id) {
5347
5730
  await refreshRouteEffort(modelMeta);
5348
5731
  refreshStatuslineUsageSnapshot(route);
5349
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
+ }
5350
5738
  if (session) {
5351
5739
  const updated = mgr.updateSessionRoute?.(session.id, {
5352
5740
  provider: route.provider,
@@ -5433,6 +5821,10 @@ function parsedProviderModelVersion(id) {
5433
5821
  clearTimeout(providerModelWarmupTimer);
5434
5822
  providerModelWarmupTimer = null;
5435
5823
  }
5824
+ if (modelCatalogWarmupTimer) {
5825
+ clearTimeout(modelCatalogWarmupTimer);
5826
+ modelCatalogWarmupTimer = null;
5827
+ }
5436
5828
  if (codeGraphPrewarmTimer) {
5437
5829
  clearTimeout(codeGraphPrewarmTimer);
5438
5830
  codeGraphPrewarmTimer = null;
@@ -5511,8 +5903,8 @@ function parsedProviderModelVersion(id) {
5511
5903
  if (owner && !['cli', 'user', 'mixdog', 'legacy'].includes(owner)) return null;
5512
5904
  const sourceType = clean(s.sourceType || '').toLowerCase();
5513
5905
  const sourceName = clean(s.sourceName || '').toLowerCase();
5514
- const role = clean(s.role || '').toLowerCase();
5515
- const leadish = role === 'lead'
5906
+ const agent = clean(s.agent || '').toLowerCase();
5907
+ const leadish = agent === 'lead'
5516
5908
  || sourceType === 'lead'
5517
5909
  || (sourceType === 'cli' && (!sourceName || sourceName === 'main'))
5518
5910
  || (!sourceType && !sourceName && !isAgentOwner(owner));