mixdog 0.9.1 → 0.9.3

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 (301) hide show
  1. package/package.json +9 -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/anthropic-maxtokens-test.mjs +119 -0
  6. package/scripts/background-task-meta-smoke.mjs +1 -1
  7. package/scripts/bench-run.mjs +262 -0
  8. package/scripts/build-tui.mjs +13 -1
  9. package/scripts/compact-smoke.mjs +12 -0
  10. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  11. package/scripts/explore-bench.mjs +124 -0
  12. package/scripts/hook-bus-test.mjs +191 -0
  13. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  14. package/scripts/internal-comms-bench.mjs +727 -0
  15. package/scripts/internal-comms-smoke.mjs +75 -0
  16. package/scripts/lead-workflow-smoke.mjs +4 -4
  17. package/scripts/live-worker-smoke.mjs +9 -9
  18. package/scripts/output-style-bench.mjs +285 -0
  19. package/scripts/output-style-smoke.mjs +13 -10
  20. package/scripts/patch-replay.mjs +90 -0
  21. package/scripts/path-suffix-test.mjs +57 -0
  22. package/scripts/provider-stream-stall-test.mjs +276 -0
  23. package/scripts/provider-toolcall-test.mjs +599 -1
  24. package/scripts/recall-bench.mjs +207 -0
  25. package/scripts/routing-corpus.mjs +281 -0
  26. package/scripts/session-bench.mjs +1526 -0
  27. package/scripts/session-diag.mjs +595 -0
  28. package/scripts/task-bench.mjs +207 -0
  29. package/scripts/tool-failures.mjs +6 -6
  30. package/scripts/tool-smoke.mjs +310 -67
  31. package/scripts/toolcall-args-test.mjs +81 -0
  32. package/src/agents/debugger/AGENT.md +4 -4
  33. package/src/agents/heavy-worker/AGENT.md +20 -9
  34. package/src/agents/reviewer/AGENT.md +4 -4
  35. package/src/agents/worker/AGENT.md +17 -9
  36. package/src/app.mjs +10 -6
  37. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  38. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  39. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  40. package/src/headless-role.mjs +14 -14
  41. package/src/help.mjs +1 -0
  42. package/src/lib/rules-builder.cjs +32 -54
  43. package/src/mixdog-session-runtime.mjs +1040 -2036
  44. package/src/output-styles/default.md +12 -7
  45. package/src/output-styles/minimal.md +25 -0
  46. package/src/output-styles/oneline.md +21 -0
  47. package/src/output-styles/simple.md +10 -9
  48. package/src/repl.mjs +17 -7
  49. package/src/rules/agent/00-common.md +7 -5
  50. package/src/rules/agent/30-explorer.md +8 -12
  51. package/src/rules/lead/01-general.md +3 -1
  52. package/src/rules/lead/lead-tool.md +13 -2
  53. package/src/rules/shared/01-tool.md +23 -12
  54. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  55. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  56. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  57. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  58. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  59. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  60. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  61. package/src/runtime/agent/orchestrator/context/collect.mjs +182 -67
  62. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  63. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  64. package/src/runtime/agent/orchestrator/mcp/client.mjs +100 -18
  65. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  66. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  68. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +313 -84
  70. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +104 -38
  71. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +28 -33
  72. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  73. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  74. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  75. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  76. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +227 -31
  78. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +83 -6
  79. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +229 -115
  80. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +213 -33
  81. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  82. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +49 -17
  83. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  84. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +22 -17
  85. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  86. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  87. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  88. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  89. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  90. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  91. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  92. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  93. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  94. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  95. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  96. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  97. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  98. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  99. package/src/runtime/agent/orchestrator/session/loop.mjs +513 -1000
  100. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  101. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  102. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  103. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  104. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  105. package/src/runtime/agent/orchestrator/session/manager.mjs +232 -1152
  106. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  107. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  108. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  109. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  110. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  113. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  114. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  115. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  116. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  117. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  118. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  119. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  120. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  121. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  122. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +14 -5
  123. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  124. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  125. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -2
  126. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  127. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  129. package/src/runtime/agent/orchestrator/tools/builtin.mjs +70 -1
  130. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  131. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  132. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  133. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  134. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  135. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  136. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  137. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  138. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  139. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  140. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  141. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  142. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  143. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +39 -4189
  146. package/src/runtime/agent/orchestrator/tools/patch.mjs +119 -5
  147. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  148. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  149. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  150. package/src/runtime/channels/backends/discord.mjs +99 -9
  151. package/src/runtime/channels/backends/telegram.mjs +501 -0
  152. package/src/runtime/channels/index.mjs +437 -1439
  153. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  154. package/src/runtime/channels/lib/config.mjs +54 -2
  155. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  156. package/src/runtime/channels/lib/format.mjs +4 -2
  157. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  158. package/src/runtime/channels/lib/output-forwarder.mjs +88 -67
  159. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  160. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  161. package/src/runtime/channels/lib/telegram-format.mjs +280 -0
  162. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  164. package/src/runtime/channels/lib/webhook.mjs +59 -31
  165. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  166. package/src/runtime/channels/tool-defs.mjs +1 -1
  167. package/src/runtime/memory/index.mjs +465 -345
  168. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  169. package/src/runtime/memory/lib/core-memory-store.mjs +352 -2
  170. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  171. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  172. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  173. package/src/runtime/memory/lib/memory-cycle2.mjs +65 -9
  174. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  175. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  176. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  177. package/src/runtime/memory/lib/memory.mjs +121 -4
  178. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  179. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  180. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  181. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  182. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  183. package/src/runtime/memory/tool-defs.mjs +10 -7
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/background-tasks.mjs +2 -3
  186. package/src/runtime/shared/buffered-appender.mjs +149 -0
  187. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  188. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  189. package/src/runtime/shared/config.mjs +9 -0
  190. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  191. package/src/runtime/shared/schedules-store.mjs +21 -19
  192. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  193. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  194. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  195. package/src/runtime/shared/tool-surface.mjs +98 -13
  196. package/src/runtime/shared/transcript-writer.mjs +156 -0
  197. package/src/runtime/shared/update-checker.mjs +214 -0
  198. package/src/session-runtime/config-helpers.mjs +209 -0
  199. package/src/session-runtime/effort.mjs +128 -0
  200. package/src/session-runtime/fs-utils.mjs +10 -0
  201. package/src/session-runtime/model-capabilities.mjs +130 -0
  202. package/src/session-runtime/output-styles.mjs +124 -0
  203. package/src/session-runtime/plugin-mcp.mjs +114 -0
  204. package/src/session-runtime/session-text.mjs +100 -0
  205. package/src/session-runtime/statusline-route.mjs +35 -0
  206. package/src/session-runtime/tool-catalog.mjs +720 -0
  207. package/src/session-runtime/workflow.mjs +358 -0
  208. package/src/standalone/agent-tool.mjs +302 -117
  209. package/src/standalone/channel-admin.mjs +133 -40
  210. package/src/standalone/channel-worker.mjs +10 -292
  211. package/src/standalone/explore-tool.mjs +12 -5
  212. package/src/standalone/hook-bus.mjs +165 -8
  213. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  214. package/src/standalone/opencode-go-login.mjs +121 -0
  215. package/src/standalone/provider-admin.mjs +25 -3
  216. package/src/standalone/usage-dashboard.mjs +1 -1
  217. package/src/tui/App.jsx +2883 -778
  218. package/src/tui/components/ConfirmBar.jsx +47 -0
  219. package/src/tui/components/ContextPanel.jsx +5 -3
  220. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  221. package/src/tui/components/Markdown.jsx +22 -98
  222. package/src/tui/components/Message.jsx +14 -35
  223. package/src/tui/components/Picker.jsx +87 -12
  224. package/src/tui/components/PromptInput.jsx +355 -22
  225. package/src/tui/components/QueuedCommands.jsx +1 -1
  226. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  227. package/src/tui/components/Spinner.jsx +7 -7
  228. package/src/tui/components/StatusLine.jsx +40 -21
  229. package/src/tui/components/TextEntryPanel.jsx +51 -7
  230. package/src/tui/components/ToolExecution.jsx +183 -101
  231. package/src/tui/components/TurnDone.jsx +4 -4
  232. package/src/tui/components/UsagePanel.jsx +1 -1
  233. package/src/tui/components/tool-output-format.mjs +161 -23
  234. package/src/tui/components/tool-output-format.test.mjs +87 -0
  235. package/src/tui/display-width.mjs +69 -0
  236. package/src/tui/display-width.test.mjs +35 -0
  237. package/src/tui/dist/index.mjs +6731 -2333
  238. package/src/tui/engine/agent-envelope.mjs +296 -0
  239. package/src/tui/engine/boot-profile.mjs +21 -0
  240. package/src/tui/engine/labels.mjs +67 -0
  241. package/src/tui/engine/notice-text.mjs +112 -0
  242. package/src/tui/engine/queue-helpers.mjs +161 -0
  243. package/src/tui/engine/session-stats.mjs +46 -0
  244. package/src/tui/engine/tool-call-fields.mjs +23 -0
  245. package/src/tui/engine/tool-result-text.mjs +126 -0
  246. package/src/tui/engine.mjs +569 -948
  247. package/src/tui/index.jsx +117 -7
  248. package/src/tui/input-editing.mjs +58 -8
  249. package/src/tui/input-editing.selection.test.mjs +75 -0
  250. package/src/tui/keyboard-protocol.mjs +42 -0
  251. package/src/tui/lib/voice-recorder.mjs +469 -0
  252. package/src/tui/markdown/format-token.mjs +128 -76
  253. package/src/tui/markdown/format-token.test.mjs +61 -19
  254. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  255. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  256. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  257. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  258. package/src/tui/markdown/table-layout.mjs +9 -9
  259. package/src/tui/paste-attachments.mjs +36 -9
  260. package/src/tui/paste-fix.test.mjs +119 -0
  261. package/src/tui/prompt-history-store.mjs +129 -0
  262. package/src/tui/prompt-history-store.test.mjs +52 -0
  263. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  264. package/src/tui/theme.mjs +41 -657
  265. package/src/tui/themes/base.mjs +86 -0
  266. package/src/tui/themes/basic.mjs +85 -0
  267. package/src/tui/themes/catppuccin.mjs +72 -0
  268. package/src/tui/themes/dracula.mjs +70 -0
  269. package/src/tui/themes/everforest.mjs +71 -0
  270. package/src/tui/themes/gruvbox.mjs +71 -0
  271. package/src/tui/themes/index.mjs +71 -0
  272. package/src/tui/themes/indigo.mjs +78 -0
  273. package/src/tui/themes/kanagawa.mjs +80 -0
  274. package/src/tui/themes/light.mjs +81 -0
  275. package/src/tui/themes/nord.mjs +72 -0
  276. package/src/tui/themes/onedark.mjs +16 -0
  277. package/src/tui/themes/rosepine.mjs +70 -0
  278. package/src/tui/themes/teal.mjs +80 -0
  279. package/src/tui/themes/tokyonight.mjs +79 -0
  280. package/src/tui/themes/utils.mjs +106 -0
  281. package/src/tui/themes/warm.mjs +79 -0
  282. package/src/tui/transcript-tool-failures.mjs +13 -2
  283. package/src/ui/markdown.mjs +1 -1
  284. package/src/ui/model-display.mjs +2 -2
  285. package/src/ui/statusline.mjs +75 -27
  286. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  287. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  288. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  289. package/src/workflows/default/WORKFLOW.md +46 -12
  290. package/src/workflows/sequential/WORKFLOW.md +51 -0
  291. package/src/workflows/solo/WORKFLOW.md +12 -1
  292. package/vendor/ink/build/display-width.js +62 -0
  293. package/vendor/ink/build/ink.js +100 -12
  294. package/vendor/ink/build/measure-text.js +4 -1
  295. package/vendor/ink/build/output.js +115 -9
  296. package/vendor/ink/build/render-node-to-output.js +4 -1
  297. package/vendor/ink/build/render.js +4 -0
  298. package/src/output-styles/extreme-simple.md +0 -20
  299. package/src/rules/lead/04-workflow.md +0 -51
  300. package/src/workflows/default/workflow.json +0 -13
  301. package/src/workflows/solo/workflow.json +0 -7
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { basename, dirname, join, resolve } from 'node:path';
3
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { performance } from 'node:perf_hooks';
6
6
  import { ensureStandaloneEnvironment } from './standalone/seeds.mjs';
@@ -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,17 +32,22 @@ import {
25
32
  PROVIDER_STATUS_TOOL,
26
33
  beginOAuthProviderLogin,
27
34
  forgetProviderAuth,
35
+ isKnownProvider,
28
36
  loginOAuthProvider,
29
37
  providerSetup,
30
38
  renderProviderStatus,
31
39
  saveOpenAIUsageSessionKey,
32
40
  saveOpenCodeGoUsageAuth,
41
+ loginOpenCodeGoUsage,
33
42
  saveProviderApiKey,
34
43
  setLocalProvider,
35
44
  } from './standalone/provider-admin.mjs';
36
45
  import { createUsageDashboard } from './standalone/usage-dashboard.mjs';
37
46
  import { fetchOAuthUsageSnapshot } from './runtime/agent/orchestrator/providers/oauth-usage.mjs';
38
- import { getModelMetadataSync } from './runtime/agent/orchestrator/providers/model-catalog.mjs';
47
+ import {
48
+ getModelMetadataSync,
49
+ warmCatalogsInBackground,
50
+ } from './runtime/agent/orchestrator/providers/model-catalog.mjs';
39
51
  import {
40
52
  isResponsesFreeformTool,
41
53
  toResponsesCustomTool,
@@ -46,13 +58,16 @@ import {
46
58
  deleteSchedule,
47
59
  deleteWebhook,
48
60
  forgetDiscordToken,
61
+ forgetTelegramToken,
49
62
  forgetWebhookAuthtoken,
50
63
  renderChannelStatus,
51
64
  saveChannel,
52
65
  saveDiscordToken,
66
+ saveTelegramToken,
53
67
  saveSchedule,
54
68
  saveWebhook,
55
69
  saveWebhookAuthtoken,
70
+ setBackend,
56
71
  setScheduleEnabled,
57
72
  setWebhookEnabled,
58
73
  setWebhookConfig,
@@ -69,233 +84,130 @@ import {
69
84
  estimateRequestReserveTokens,
70
85
  estimateTranscriptContextUsage,
71
86
  estimateToolSchemaTokens,
87
+ resolveCompactBufferTokens,
88
+ resolveCompactTriggerTokens,
89
+ summarizeContextMessages,
72
90
  } from './runtime/agent/orchestrator/session/context-utils.mjs';
73
91
 
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
- function sessionMessageText(content) {
141
- if (content == null) return '';
142
- if (typeof content === 'string') return content;
143
- const parts = Array.isArray(content)
144
- ? content
145
- : (content && typeof content === 'object' && Array.isArray(content.content) ? content.content : null);
146
- if (parts) {
147
- return parts.map((part) => {
148
- if (typeof part === 'string') return part;
149
- return part?.text ?? '';
150
- }).filter(Boolean).join('\n');
151
- }
152
- if (typeof content === 'object' && typeof content.text === 'string') return content.text;
153
- try { return JSON.stringify(content); } catch { return String(content); }
154
- }
155
-
156
- function roughTokenCount(text) {
157
- return Math.ceil(String(text ?? '').length / 4);
158
- }
159
-
160
- function messageContextText(message) {
161
- if (!message || typeof message !== 'object') return '';
162
- let text = sessionMessageText(message.content);
163
- if (message.role === 'assistant' && Array.isArray(message.toolCalls) && message.toolCalls.length) {
164
- try { text += `\n${JSON.stringify(message.toolCalls)}`; }
165
- catch { text += `\n[${message.toolCalls.length} tool calls]`; }
166
- }
167
- if (message.role === 'tool' && message.toolCallId) text += `\n${message.toolCallId}`;
168
- return text;
169
- }
170
-
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
- function isSessionPreviewNoise(text) {
283
- const value = String(text || '').trim();
284
- return !value
285
- || value.startsWith('<system-reminder>')
286
- || value.startsWith('</system-reminder>')
287
- || /^#\s*permission\b/i.test(value)
288
- || /^permission:\s*/i.test(value)
289
- || /^cwd:\s*/i.test(value);
290
- }
291
-
292
- function cleanSessionPreview(text) {
293
- return String(text || '')
294
- .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, ' ')
295
- .replace(/\s+/g, ' ')
296
- .trim()
297
- .slice(0, 160);
298
- }
92
+ import {
93
+ sessionMessageText,
94
+ messageContextText,
95
+ isSessionPreviewNoise,
96
+ cleanSessionPreview,
97
+ clean,
98
+ hasOwn,
99
+ toolResponseText,
100
+ isEmptyRecallText,
101
+ currentSessionRecallRows,
102
+ sessionHasConversationMessages,
103
+ } from './session-runtime/session-text.mjs';
104
+ import {
105
+ TOOL_MODES,
106
+ ALL_EFFORT_LEVELS,
107
+ EFFORT_LABELS,
108
+ EFFORT_OPTIONS_BY_PROVIDER,
109
+ EFFORT_BY_FAMILY,
110
+ EFFORT_FALLBACKS,
111
+ normalizeToolMode,
112
+ normalizeEffortInput,
113
+ effortOptionsFor,
114
+ coerceEffortFor,
115
+ normalizeSavedEffort,
116
+ effortItemsFor,
117
+ toolSpecForMode,
118
+ deferredSurfaceModeForLead,
119
+ } from './session-runtime/effort.mjs';
120
+ import {
121
+ LAZY_SECRET_PROVIDERS,
122
+ routeFastKey,
123
+ fastCapableFor,
124
+ makeSearchCapableFor,
125
+ fastPreferenceFor,
126
+ saveModelSettings,
127
+ } from './session-runtime/model-capabilities.mjs';
128
+ import {
129
+ DEFAULT_PROVIDER,
130
+ DEFAULT_MODEL,
131
+ makeResolveDefaultProvider,
132
+ findPreset,
133
+ makeResolveRoute,
134
+ isLikelyRawModelId,
135
+ validateRequestedModelSelector,
136
+ ensureProviderEnabled,
137
+ normalizeSystemShellConfig,
138
+ normalizeSystemShellCommand,
139
+ normalizeAutoClearConfig,
140
+ normalizeCompactionConfig,
141
+ moduleEnabled,
142
+ setModuleEnabledInConfig,
143
+ formatDurationMs,
144
+ parseDurationMs,
145
+ modelMetaLooksResolved,
146
+ modelSettingsFor,
147
+ normalizeCompactTypeSetting,
148
+ } from './session-runtime/config-helpers.mjs';
149
+ import {
150
+ routeForStatusline,
151
+ writeStatuslineRoute,
152
+ } from './session-runtime/statusline-route.mjs';
153
+ import {
154
+ normalizeOutputStyleId,
155
+ listOutputStyleCatalog,
156
+ findOutputStyle,
157
+ outputStyleStatus as outputStyleStatusRaw,
158
+ } from './session-runtime/output-styles.mjs';
159
+ import { readJsonSafe, readTextSafe } from './session-runtime/fs-utils.mjs';
160
+ import {
161
+ readProjectMcpServers,
162
+ countSkillFiles,
163
+ mcpScriptForPlugin,
164
+ normalizePluginMcpServerConfig,
165
+ pluginManifest,
166
+ pluginMcpServerName,
167
+ } from './session-runtime/plugin-mcp.mjs';
168
+ import {
169
+ WORKFLOW_ROUTE_SLOTS,
170
+ FIXED_AGENT_SLOTS,
171
+ SEARCH_DEFAULT_PROVIDER,
172
+ SEARCH_DEFAULT_MODEL,
173
+ workflowPresetId,
174
+ agentPresetSlot,
175
+ normalizeAgentId,
176
+ normalizeWorkflowId,
177
+ createWorkflowHelpers,
178
+ normalizeSearchProviderId,
179
+ isDefaultSearchRouteConfig,
180
+ isSearchCapableProvider,
181
+ normalizeSearchRouteConfig,
182
+ normalizeWorkflowRoute,
183
+ upsertWorkflowPreset,
184
+ createWorkflowRouteHelpers,
185
+ } from './session-runtime/workflow.mjs';
186
+ import {
187
+ MEASURED_TOOL_USAGE,
188
+ DEFERRED_DEFAULT_FULL_TOOLS,
189
+ DEFERRED_DEFAULT_READONLY_TOOLS,
190
+ DEFERRED_DEFAULT_LEAD_TOOLS,
191
+ toolKind,
192
+ toolSchemaBucket,
193
+ estimateToolSchemaBreakdown,
194
+ measuredToolUsage,
195
+ parseToolSelection,
196
+ parseToolSearchQuerySelection,
197
+ sortedCatalogByMeasuredUsage,
198
+ filterDisallowedTools,
199
+ sortedNamesByMeasuredUsage,
200
+ defaultDeferredToolNames,
201
+ compactToolSearchDescription,
202
+ toolRow,
203
+ toolSearchMatches,
204
+ applyDeferredToolSurface,
205
+ selectDeferredTools,
206
+ renderToolSearch,
207
+ } from './session-runtime/tool-catalog.mjs';
208
+ // Re-exported for external consumers (scripts/tool-smoke.mjs) that imported
209
+ // these from this module before the tool-catalog extraction.
210
+ export { defaultDeferredToolNames, compactToolSearchDescription } from './session-runtime/tool-catalog.mjs';
299
211
 
300
212
  const RUNTIME = './runtime/agent/orchestrator';
301
213
  const SEARCH_RUNTIME = './runtime/search/index.mjs';
@@ -315,18 +227,9 @@ const STANDALONE_ROOT = STANDALONE_SOURCE_ROOT;
315
227
  const MIXDOG_HOME = process.env.MIXDOG_HOME || join(homedir(), '.mixdog');
316
228
  const STANDALONE_DATA_DIR = process.env.MIXDOG_DATA_DIR || join(MIXDOG_HOME, 'data');
317
229
 
318
- const DEFAULT_PROVIDER = 'anthropic-oauth';
319
- const DEFAULT_MODEL = '';
320
- const TOOL_MODES = new Set(['full', 'readonly', 'lead']);
321
- const ALL_EFFORT_LEVELS = new Set(['none', 'low', 'medium', 'high', 'xhigh', 'max']);
322
- const EFFORT_LABELS = {
323
- none: 'None',
324
- low: 'Low',
325
- medium: 'Medium',
326
- high: 'High',
327
- xhigh: 'Extra High',
328
- max: 'Max',
329
- };
230
+ const resolveDefaultProvider = makeResolveDefaultProvider(isKnownProvider);
231
+ const resolveRoute = makeResolveRoute(resolveDefaultProvider);
232
+ const searchCapableFor = makeSearchCapableFor(normalizeSearchProviderId, isSearchCapableProvider);
330
233
 
331
234
  function envFlag(name) {
332
235
  return /^(1|true|yes|on)$/i.test(String(process.env[name] || ''));
@@ -373,37 +276,6 @@ async function profiledImport(label, spec, { optional = false } = {}) {
373
276
  throw error;
374
277
  }
375
278
  }
376
- const EFFORT_OPTIONS_BY_PROVIDER = {
377
- openai: ['none', 'low', 'medium', 'high', 'xhigh'],
378
- 'openai-oauth': ['none', 'low', 'medium', 'high', 'xhigh'],
379
- anthropic: ['low', 'medium', 'high', 'xhigh', 'max'],
380
- 'anthropic-oauth': ['low', 'medium', 'high', 'xhigh', 'max'],
381
- xai: ['none', 'low', 'medium', 'high'],
382
- 'grok-oauth': ['none', 'low', 'medium', 'high'],
383
- 'opencode-go': ['high', 'max'],
384
- };
385
- const EFFORT_BY_FAMILY = {
386
- opus: ['low', 'medium', 'high', 'xhigh', 'max'],
387
- sonnet: ['low', 'medium', 'high'],
388
- haiku: [],
389
- 'gpt-5.5': ['none', 'low', 'medium', 'high', 'xhigh'],
390
- 'gpt-5.4': ['none', 'low', 'medium', 'high', 'xhigh'],
391
- 'gpt-5.2': ['none', 'low', 'medium', 'high', 'xhigh'],
392
- 'gpt-5': ['none', 'low', 'medium', 'high', 'xhigh'],
393
- 'gpt-mini': ['none', 'low', 'medium', 'high', 'xhigh'],
394
- 'gpt-nano': ['none', 'low', 'medium', 'high'],
395
- 'gpt-codex': ['none', 'low', 'medium', 'high'],
396
- grok: ['none', 'low', 'medium', 'high'],
397
- };
398
- const EFFORT_FALLBACKS = {
399
- max: ['max', 'xhigh', 'high', 'medium', 'low'],
400
- xhigh: ['xhigh', 'high', 'medium', 'low'],
401
- high: ['high', 'medium', 'low'],
402
- medium: ['medium', 'low'],
403
- low: ['low'],
404
- none: ['none'],
405
- };
406
-
407
279
  export const TOOL_SEARCH_TOOL = {
408
280
  name: 'tool_search',
409
281
  title: 'Tool Search',
@@ -486,89 +358,10 @@ export const SKILL_TOOL = {
486
358
  },
487
359
  };
488
360
 
489
- const MEASURED_TOOL_USAGE = Object.freeze({
490
- read: 710,
491
- code_graph: 520,
492
- grep: 500,
493
- find: 480,
494
- glob: 460,
495
- list: 430,
496
- apply_patch: 400,
497
- explore: 360,
498
- agent: 330,
499
- shell: 81,
500
- cwd: 2,
501
- diagnostics: 2,
502
- recall: 2,
503
- search: 2,
504
- web_fetch: 2,
505
- provider_status: 2,
506
- channel_status: 2,
507
- });
508
- const MEASURED_TOOL_ORDER = Object.freeze(Object.keys(MEASURED_TOOL_USAGE));
509
361
  const LEAD_DISALLOWED_TOOLS = Object.freeze(['diagnostics', 'open_config']);
510
- const DEFERRED_DEFAULT_FULL_TOOLS = Object.freeze([
511
- 'read',
512
- 'code_graph',
513
- 'grep',
514
- 'find',
515
- 'glob',
516
- 'list',
517
- 'explore',
518
- 'apply_patch',
519
- 'Skill',
520
- 'tool_search',
521
- ]);
522
- const DEFERRED_DEFAULT_READONLY_TOOLS = Object.freeze([
523
- 'read',
524
- 'code_graph',
525
- 'grep',
526
- 'find',
527
- 'glob',
528
- 'list',
529
- 'explore',
530
- 'Skill',
531
- 'tool_search',
532
- ]);
533
- const DEFERRED_DEFAULT_LEAD_TOOLS = Object.freeze([
534
- 'read',
535
- 'code_graph',
536
- 'grep',
537
- 'find',
538
- 'glob',
539
- 'list',
540
- 'shell',
541
- 'task',
542
- 'explore',
543
- 'apply_patch',
544
- 'agent',
545
- 'recall',
546
- 'search',
547
- 'web_fetch',
548
- 'cwd',
549
- 'Skill',
550
- 'tool_search',
551
- ]);
552
- const READONLY_TOOL_NAMES = new Set([
553
- 'read',
554
- 'list',
555
- 'grep',
556
- 'find',
557
- 'glob',
558
- 'code_graph',
559
- 'search',
560
- 'web_fetch',
561
- 'recall',
562
- 'memory',
563
- 'provider_status',
564
- 'channel_status',
565
- 'schedule_status',
566
- 'fetch',
567
- 'Skill',
568
- ]);
569
- const AGENT_HIDDEN_WRAPPER_TOOLS = new Set(['search']);
570
-
571
- function applyStandaloneToolDefaults(tool) {
362
+ const AGENT_HIDDEN_WRAPPER_TOOLS = new Set([]);
363
+
364
+ export function __applyStandaloneToolDefaultsForTest(tool) {
572
365
  if (!tool || !AGENT_HIDDEN_WRAPPER_TOOLS.has(tool.name)) return tool;
573
366
  return {
574
367
  ...tool,
@@ -578,712 +371,10 @@ function applyStandaloneToolDefaults(tool) {
578
371
  },
579
372
  };
580
373
  }
581
- const DEFERRED_SELECT_ALIASES = {
582
- filesystem: ['read', 'list', 'grep', 'find', 'glob'],
583
- search: ['search', 'web_fetch'],
584
- web: ['web_fetch', 'search'],
585
- memory: ['memory', 'recall'],
586
- channels: ['reply', 'fetch', 'react', 'edit_message', 'download_attachment', 'schedule_status', 'trigger_schedule', 'schedule_control', 'reload_config'],
587
- discord: ['reply', 'fetch', 'react', 'edit_message', 'download_attachment'],
588
- providers: ['provider_status'],
589
- provider: ['provider_status'],
590
- status: ['provider_status', 'channel_status', 'schedule_status'],
591
- schedule: ['schedule_status', 'trigger_schedule', 'schedule_control'],
592
- channel: ['channel_status'],
593
- explore: ['explore'],
594
- discovery: ['explore'],
595
- agent: ['agent'],
596
- graph: ['code_graph'],
597
- code: ['code_graph'],
598
- shell: ['shell', 'task'],
599
- };
600
- const TOOL_SEARCH_SAFE_AUTO_ALIASES = new Set([
601
- 'shell',
602
- 'web',
603
- 'search',
604
- 'agent',
605
- 'provider',
606
- 'providers',
607
- 'channel',
608
- 'schedule',
609
- 'memory',
610
- ]);
611
- const TOOL_SEARCH_AMBIGUOUS_AUTO_QUERIES = new Set([
612
- 'status',
613
- 'state',
614
- 'info',
615
- 'list',
616
- 'show',
617
- 'config',
618
- ]);
619
- const TOOL_SEARCH_STOP_WORDS = new Set([
620
- 'a',
621
- 'an',
622
- 'and',
623
- 'are',
624
- 'as',
625
- 'for',
626
- 'from',
627
- 'how',
628
- 'i',
629
- 'in',
630
- 'is',
631
- 'me',
632
- 'need',
633
- 'of',
634
- 'on',
635
- 'please',
636
- 'the',
637
- 'to',
638
- 'tool',
639
- 'tools',
640
- 'use',
641
- 'using',
642
- 'with',
643
- ]);
644
- const TOOL_SEARCH_ROW_ALIASES = Object.freeze({
645
- agent: ['delegate', 'subagent', 'worker', 'parallel agent', 'background agent', 'reviewer', 'explorer'],
646
- channel_status: ['channel status', 'discord status', 'channel config'],
647
- cwd: ['cwd', 'working directory', 'current directory', 'project root', 'folder'],
648
- memory: ['save memory', 'store memory', 'delete memory', 'forget memory', 'memory status'],
649
- provider_status: ['provider status', 'auth status', 'model status', 'oauth status', 'provider config'],
650
- recall: ['recall', 'previous work', 'past work', 'prior context', 'history', 'resume context'],
651
- schedule_status: ['schedule status', 'cron status'],
652
- search: ['web search', 'internet search', 'current info', 'latest info', 'online search', 'docs search'],
653
- shell: ['run command', 'execute command', 'terminal', 'powershell', 'bash', 'run tests', 'test command', 'build command', 'npm', 'node', 'git'],
654
- task: ['background task', 'async task', 'wait task', 'cancel task', 'task status'],
655
- web_fetch: ['fetch url', 'fetch page', 'open url', 'web page', 'read url', 'docs page'],
656
- });
657
-
658
- function normalizeToolMode(mode) {
659
- const value = String(mode || '').trim().toLowerCase();
660
- return TOOL_MODES.has(value) ? value : 'full';
661
- }
662
-
663
- function normalizeEffortInput(value) {
664
- const v = clean(value).toLowerCase();
665
- if (!v || v === 'auto') return null;
666
- if (!ALL_EFFORT_LEVELS.has(v)) {
667
- throw new Error(`effort must be one of auto, ${[...ALL_EFFORT_LEVELS].join(', ')}`);
668
- }
669
- return v;
670
- }
671
-
672
- function effortOptionsFor(provider, model) {
673
- const providerAllowed = EFFORT_OPTIONS_BY_PROVIDER[provider] || null;
674
- const filterProvider = (values) => {
675
- const unique = [...new Set((values || []).map(clean).filter(Boolean))];
676
- return providerAllowed ? unique.filter((v) => providerAllowed.includes(v)) : unique;
677
- };
678
- const declared = Array.isArray(model?.reasoningLevels)
679
- ? model.reasoningLevels.map(clean).filter(Boolean)
680
- : [];
681
- const family = clean(model?.family).toLowerCase();
682
- if (Array.isArray(model?.reasoningLevels)) {
683
- if (declared.length) return filterProvider(declared);
684
- if (Object.prototype.hasOwnProperty.call(EFFORT_BY_FAMILY, family)) {
685
- return filterProvider(EFFORT_BY_FAMILY[family]);
686
- }
687
- return [];
688
- }
689
- const reasoningOptionEffort = Array.isArray(model?.reasoningOptions)
690
- ? model.reasoningOptions.find((option) => clean(option?.type).toLowerCase() === 'effort')
691
- : null;
692
- const reasoningOptionValues = Array.isArray(reasoningOptionEffort?.values)
693
- ? reasoningOptionEffort.values.map(clean).filter(Boolean)
694
- : [];
695
- if (reasoningOptionValues.length) return filterProvider(reasoningOptionValues);
696
- if (Object.prototype.hasOwnProperty.call(EFFORT_BY_FAMILY, family)) {
697
- return filterProvider(EFFORT_BY_FAMILY[family]);
698
- }
699
- return providerAllowed || [];
700
- }
701
-
702
- function coerceEffortFor(provider, model, effort) {
703
- if (!effort) return null;
704
- const allowed = effortOptionsFor(provider, model);
705
- if (!allowed || allowed.length === 0) return null;
706
- if (allowed.includes(effort)) return effort;
707
- for (const candidate of EFFORT_FALLBACKS[effort] || []) {
708
- if (allowed.includes(candidate)) return candidate;
709
- }
710
- return null;
711
- }
712
-
713
- function hasOwn(obj, key) {
714
- return Object.prototype.hasOwnProperty.call(obj || {}, key);
715
- }
716
-
717
- function modelSettingsFor(config, provider, model) {
718
- const key = routeFastKey(provider, model);
719
- const value = key ? config?.modelSettings?.[key] : null;
720
- return value && typeof value === 'object' ? value : {};
721
- }
722
-
723
- function normalizeSavedEffort(value) {
724
- try {
725
- return normalizeEffortInput(value);
726
- } catch {
727
- return null;
728
- }
729
- }
730
-
731
- function effortItemsFor(provider, model, activeEffort) {
732
- const allowed = effortOptionsFor(provider, model);
733
- const items = [];
734
- for (const value of allowed || []) {
735
- items.push({
736
- value,
737
- label: EFFORT_LABELS[value] || value,
738
- description: value === activeEffort ? 'current' : '',
739
- });
740
- }
741
- return items;
742
- }
743
-
744
- function toolSpecForMode(mode) {
745
- return mode === 'readonly' ? ['tools:readonly'] : 'full';
746
- }
747
-
748
- function deferredSurfaceModeForLead(mode) {
749
- return mode === 'readonly' ? 'readonly' : 'lead';
750
- }
751
-
752
- function clean(value) {
753
- return String(value ?? '').trim();
754
- }
755
-
756
- // A resolved model meta carries catalog-derived fields (contextWindow, pricing,
757
- // capabilities, …). The lookupModelMeta() fallback for an unknown id is the
758
- // bare shape `{ id, provider }`, so "more than id/provider" reliably tells a
759
- // real catalog hit apart from that placeholder.
760
- function modelMetaLooksResolved(meta) {
761
- if (!meta || typeof meta !== 'object') return false;
762
- return Object.keys(meta).some((key) => key !== 'id' && key !== 'provider');
763
- }
764
-
765
- const OUTPUT_STYLE_ORDER = ['default', 'simple', 'extreme-simple'];
766
- const OUTPUT_STYLE_ALIASES = new Map([
767
- ['compact', 'default'],
768
- ['normal', 'default'],
769
- ['extreme', 'extreme-simple'],
770
- ['extremesimple', 'extreme-simple'],
771
- ['extreme-simple', 'extreme-simple'],
772
- ['extreme_simple', 'extreme-simple'],
773
- ]);
774
-
775
- function normalizeOutputStyleId(value) {
776
- const raw = clean(value).toLowerCase();
777
- if (!raw) return '';
778
- const slug = raw.replace(/[_\s]+/g, '-').replace(/^-+|-+$/g, '');
779
- const compact = slug.replace(/[_.-]+/g, '');
780
- if (OUTPUT_STYLE_ALIASES.has(slug)) return OUTPUT_STYLE_ALIASES.get(slug);
781
- if (OUTPUT_STYLE_ALIASES.has(compact)) return OUTPUT_STYLE_ALIASES.get(compact);
782
- return /^[a-z0-9.-]+$/.test(slug) ? slug : '';
783
- }
784
-
785
- function outputStyleCompactKey(value) {
786
- return normalizeOutputStyleId(value).replace(/[_.-]+/g, '');
787
- }
788
-
789
- function titleCaseOutputStyle(id) {
790
- return clean(id)
791
- .split(/[_.-]+/)
792
- .filter(Boolean)
793
- .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
794
- .join(' ') || 'Default';
795
- }
796
-
797
- function parseOutputStyleFrontmatter(markdown) {
798
- const match = String(markdown || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
799
- const meta = {};
800
- if (!match) return meta;
801
- for (const line of match[1].split(/\r?\n/)) {
802
- const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/);
803
- if (!kv) continue;
804
- meta[kv[1]] = kv[2].replace(/^['"]|['"]$/g, '').trim();
805
- }
806
- return meta;
807
- }
808
-
809
- function readOutputStyleMetadata(filePath, source) {
810
- let raw = '';
811
- try { raw = readFileSync(filePath, 'utf8'); } catch { return null; }
812
- const meta = parseOutputStyleFrontmatter(raw);
813
- const fileId = normalizeOutputStyleId(basename(filePath).replace(/\.md$/i, ''));
814
- const id = normalizeOutputStyleId(meta.name) || fileId;
815
- if (!id) return null;
816
- const aliases = clean(meta.aliases)
817
- .split(',')
818
- .map((value) => normalizeOutputStyleId(value))
819
- .filter(Boolean);
820
- const label = clean(meta.title || meta.label) || titleCaseOutputStyle(id);
821
- return {
822
- id,
823
- label,
824
- description: clean(meta.description),
825
- aliases,
826
- source,
827
- };
828
- }
829
-
830
- function listOutputStyleCatalog(dataDir = STANDALONE_DATA_DIR) {
831
- const byId = new Map();
832
- const dirs = [
833
- { dir: join(STANDALONE_ROOT, 'output-styles'), source: 'builtin' },
834
- { dir: join(dataDir || STANDALONE_DATA_DIR, 'output-styles'), source: 'user' },
835
- ];
836
- for (const { dir, source } of dirs) {
837
- let entries = [];
838
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
839
- for (const entry of entries) {
840
- if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.md')) continue;
841
- const style = readOutputStyleMetadata(join(dir, entry.name), source);
842
- if (style) byId.set(style.id, style);
843
- }
844
- }
845
- return [...byId.values()].sort((a, b) => {
846
- const ai = OUTPUT_STYLE_ORDER.indexOf(a.id);
847
- const bi = OUTPUT_STYLE_ORDER.indexOf(b.id);
848
- if (ai !== bi) return (ai < 0 ? 999 : ai) - (bi < 0 ? 999 : bi);
849
- return a.label.localeCompare(b.label, 'en', { sensitivity: 'base' });
850
- });
851
- }
852
-
853
- function findOutputStyle(value, styles) {
854
- const id = normalizeOutputStyleId(value);
855
- const compact = outputStyleCompactKey(value);
856
- if (!id && !compact) return null;
857
- return (styles || []).find((style) => {
858
- if (style.id === id || outputStyleCompactKey(style.id) === compact) return true;
859
- if (outputStyleCompactKey(style.label) === compact) return true;
860
- return (style.aliases || []).some((alias) => alias === id || outputStyleCompactKey(alias) === compact);
861
- }) || null;
862
- }
863
-
864
- function configuredOutputStyleValue(dataDir = STANDALONE_DATA_DIR) {
865
- const unified = readJsonSafe(join(dataDir || STANDALONE_DATA_DIR, 'mixdog-config.json')) || {};
866
- return clean(unified.outputStyle || (unified.agent && unified.agent.outputStyle) || 'default') || 'default';
867
- }
868
-
869
- function outputStyleStatus(dataDir = STANDALONE_DATA_DIR) {
870
- const styles = listOutputStyleCatalog(dataDir);
871
- const configured = configuredOutputStyleValue(dataDir);
872
- const current = findOutputStyle(configured, styles)
873
- || findOutputStyle('default', styles)
874
- || styles[0]
875
- || { id: 'default', label: 'Default', description: '', aliases: [], source: 'builtin' };
876
- return { configured, current, styles };
877
- }
878
-
879
- function sessionHasConversationMessages(activeSession) {
880
- const messages = Array.isArray(activeSession?.messages) ? activeSession.messages : [];
881
- return messages.some((message) => {
882
- const role = message?.role;
883
- if (role !== 'user' && role !== 'assistant' && role !== 'tool') return false;
884
- const text = sessionMessageText(message.content).trim();
885
- if (!text && role !== 'assistant') return false;
886
- if (role === 'user' && isSessionPreviewNoise(text)) return false;
887
- return true;
888
- });
889
- }
890
-
891
- function readJsonSafe(path) {
892
- try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
893
- }
894
-
895
- function countSkillFiles(root) {
896
- const skillsDir = join(root, 'skills');
897
- if (!existsSync(skillsDir)) return 0;
898
- let count = 0;
899
- const walk = (dir) => {
900
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
901
- const full = join(dir, entry.name);
902
- if (entry.isDirectory()) walk(full);
903
- else if (/^(SKILL|skill)\.md$/i.test(entry.name) || entry.name.toLowerCase().endsWith('.md')) count += 1;
904
- }
905
- };
906
- try { walk(skillsDir); } catch { return count; }
907
- return count;
908
- }
909
-
910
- function mcpScriptForPlugin(root) {
911
- const candidates = [
912
- 'scripts/run-mcp.mjs',
913
- 'mcp/server.mjs',
914
- 'server.mjs',
915
- ];
916
- return candidates.find((rel) => existsSync(join(root, rel))) || null;
917
- }
918
-
919
- function pluginManifest(root) {
920
- return readJsonSafe(join(root, '.codex-plugin', 'plugin.json'))
921
- || readJsonSafe(join(root, 'plugin.json'))
922
- || {};
923
- }
924
-
925
- function pluginMcpServerName(plugin = {}) {
926
- const base = clean(plugin.name || plugin.title || 'plugin')
927
- .toLowerCase()
928
- .replace(/[^a-z0-9_.-]+/g, '-')
929
- .replace(/^-+|-+$/g, '');
930
- return base ? `plugin-${base}` : 'plugin-mcp';
931
- }
932
-
933
- function findPreset(config, key) {
934
- const wanted = clean(key).toLowerCase();
935
- if (!wanted) return null;
936
- const presets = Array.isArray(config?.presets) ? config.presets : [];
937
- return presets.find((p) => {
938
- const id = clean(p?.id).toLowerCase();
939
- const name = clean(p?.name).toLowerCase();
940
- return id === wanted || name === wanted;
941
- }) || null;
942
- }
943
-
944
- function resolveRoute(config, { provider, model, effort, fast } = {}) {
945
- const explicitProvider = clean(provider);
946
- const explicitModel = clean(model);
947
- const hasExplicitEffort = effort !== undefined;
948
- const explicitEffort = hasExplicitEffort ? normalizeEffortInput(effort) : undefined;
949
- const hasExplicitFast = fast !== undefined;
950
- const explicitFast = fast === true;
951
-
952
- if (explicitModel && !explicitProvider) {
953
- const preset = findPreset(config, explicitModel);
954
- if (preset) {
955
- const p = clean(preset.provider) || DEFAULT_PROVIDER;
956
- const m = clean(preset.model) || DEFAULT_MODEL;
957
- const saved = modelSettingsFor(config, p, m);
958
- return {
959
- provider: p,
960
- model: m,
961
- preset,
962
- effort: hasExplicitEffort ? explicitEffort : normalizeSavedEffort(saved.effort ?? preset.effort),
963
- fast: hasExplicitFast ? explicitFast : (hasOwn(saved, 'fast') ? saved.fast === true : (preset.fast === true || fastPreferenceFor(config, p, m))),
964
- };
965
- }
966
- }
967
-
968
- if (!explicitProvider && !explicitModel) {
969
- const defaultKey = config?.default;
970
- const preset = findPreset(config, defaultKey);
971
- if (preset) {
972
- const p = clean(preset.provider) || DEFAULT_PROVIDER;
973
- const m = clean(preset.model) || DEFAULT_MODEL;
974
- const saved = modelSettingsFor(config, p, m);
975
- return {
976
- provider: p,
977
- model: m,
978
- preset,
979
- effort: hasExplicitEffort ? explicitEffort : normalizeSavedEffort(saved.effort ?? preset.effort),
980
- fast: hasExplicitFast ? explicitFast : (hasOwn(saved, 'fast') ? saved.fast === true : (preset.fast === true || fastPreferenceFor(config, p, m))),
981
- };
982
- }
983
- }
984
-
985
- const p = explicitProvider || DEFAULT_PROVIDER;
986
- const m = explicitModel || DEFAULT_MODEL;
987
- const saved = modelSettingsFor(config, p, m);
988
- return {
989
- provider: p,
990
- model: m,
991
- preset: null,
992
- effort: hasExplicitEffort ? explicitEffort : normalizeSavedEffort(saved.effort),
993
- fast: hasExplicitFast ? explicitFast : (hasOwn(saved, 'fast') ? saved.fast === true : fastPreferenceFor(config, p, m)),
994
- };
995
- }
996
-
997
- function isLikelyRawModelId(value) {
998
- const model = clean(value);
999
- if (!model || model.length > 160) return false;
1000
- if (/\s/.test(model)) return false;
1001
- return /^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$/.test(model);
1002
- }
1003
-
1004
- function validateRequestedModelSelector(config, requested = {}) {
1005
- const model = clean(requested.model);
1006
- if (!model) return;
1007
- if (findPreset(config, model)) return;
1008
- if (isLikelyRawModelId(model)) return;
1009
- throw new Error(`Invalid model selector "${model}". Use a preset or a model id; free-form text cannot be used as a model.`);
1010
- }
1011
-
1012
- function ensureProviderEnabled(config, provider) {
1013
- const providers = { ...(config?.providers || {}) };
1014
- providers[provider] = { ...(providers[provider] || {}), enabled: true };
1015
- return providers;
1016
- }
1017
-
1018
- const AUTO_CLEAR_DEFAULT_IDLE_MS = 60 * 60 * 1000;
1019
-
1020
- function normalizeSystemShellConfig(value = {}) {
1021
- const raw = value && typeof value === 'object' ? value : {};
1022
- const command = clean(raw.command ?? raw.path ?? raw.executable ?? raw.shell);
1023
- const envCommand = clean(process.env.MIXDOG_SHELL);
1024
- return {
1025
- command,
1026
- effective: command || envCommand || '',
1027
- source: command ? 'config' : (envCommand ? 'env' : 'auto'),
1028
- };
1029
- }
1030
-
1031
- function normalizeSystemShellCommand(value) {
1032
- const command = clean(value).replace(/^auto$/i, '').replace(/^['"](.+)['"]$/, '$1').trim();
1033
- if (!command) return '';
1034
- if (process.platform === 'win32') {
1035
- const stem = command.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '');
1036
- if (stem !== 'powershell' && stem !== 'pwsh') {
1037
- throw new Error('system shell command must be powershell.exe or pwsh on Windows');
1038
- }
1039
- }
1040
- return command;
1041
- }
1042
-
1043
- function normalizeAutoClearConfig(value = {}) {
1044
- const raw = value && typeof value === 'object' ? value : {};
1045
- 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
- return {
1049
- enabled: raw.enabled !== false,
1050
- idleMs: Number.isFinite(idleMs) && idleMs > 0 ? Math.max(60_000, Math.round(idleMs)) : AUTO_CLEAR_DEFAULT_IDLE_MS,
1051
- ...(normalizedCompactType ? { compactType: normalizedCompactType } : {}),
1052
- };
1053
- }
1054
-
1055
- function normalizeCompactTypeSetting(value, fallback = 'semantic') {
1056
- const raw = clean(value).toLowerCase().replace(/_/g, '-');
1057
- if (!raw) return fallback;
1058
- if (raw === '1' || raw === 'type1' || raw === 'type-1' || raw === 'semantic' || raw === 'summary' || raw === 'default') return 'semantic';
1059
- if (raw === '2' || raw === 'type2' || raw === 'type-2' || raw === 'recall' || raw === 'recall-fast' || raw === 'recall-fasttrack' || raw === 'recall-fast-track' || raw === 'fasttrack' || raw === 'fast-track') return 'recall-fasttrack';
1060
- return fallback;
1061
- }
1062
-
1063
- function normalizeCompactionConfig(value = {}, { memoryEnabled = true } = {}) {
1064
- const raw = value && typeof value === 'object' ? value : {};
1065
- let compactType = normalizeCompactTypeSetting(raw.compactType ?? raw.compact_type ?? raw.type, 'semantic');
1066
- if (compactType === 'recall-fasttrack' && memoryEnabled === false) compactType = 'semantic';
1067
- return {
1068
- ...raw,
1069
- auto: raw.auto !== false && raw.enabled !== false,
1070
- type: compactType,
1071
- compactType,
1072
- };
1073
- }
1074
-
1075
- function moduleEnabled(configLike, name, fallback = true) {
1076
- const entry = configLike?.modules?.[name];
1077
- if (entry && typeof entry === 'object' && entry.enabled === false) return false;
1078
- return fallback !== false;
1079
- }
1080
-
1081
- function setModuleEnabledInConfig(configLike, name, enabled) {
1082
- const next = { ...(configLike || {}) };
1083
- next.modules = { ...(next.modules || {}) };
1084
- next.modules[name] = {
1085
- ...(next.modules[name] || {}),
1086
- enabled: enabled !== false,
1087
- };
1088
- return next;
1089
- }
1090
-
1091
- function formatDurationMs(ms) {
1092
- const value = Math.max(0, Number(ms) || 0);
1093
- if (value % 3_600_000 === 0) return `${value / 3_600_000}h`;
1094
- if (value % 60_000 === 0) return `${value / 60_000}m`;
1095
- return `${Math.round(value / 1000)}s`;
1096
- }
1097
-
1098
- function parseDurationMs(input) {
1099
- const text = clean(input).toLowerCase();
1100
- if (!text) return null;
1101
- const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(text);
1102
- if (!match) return null;
1103
- const n = Number(match[1]);
1104
- if (!Number.isFinite(n) || n <= 0) return null;
1105
- const unit = match[2] || 'm';
1106
- const mult = unit === 'h' ? 3_600_000 : unit === 'm' ? 60_000 : unit === 's' ? 1000 : 1;
1107
- return Math.max(60_000, Math.round(n * mult));
1108
- }
1109
-
1110
- const FAST_CAPABLE_PROVIDERS = new Set(['anthropic', 'anthropic-oauth', 'openai', 'openai-oauth']);
1111
- const LAZY_SECRET_PROVIDERS = new Set(['openai-oauth', 'anthropic-oauth', 'grok-oauth', 'ollama', 'lmstudio']);
1112
-
1113
- function routeFastKey(provider, model) {
1114
- const p = clean(provider);
1115
- const m = clean(model);
1116
- return p && m ? `${p}/${m}` : '';
1117
- }
1118
-
1119
- function openAiModelMetaSupportsFast(model) {
1120
- const tiers = Array.isArray(model?.serviceTiers) ? model.serviceTiers : [];
1121
- const speedTiers = Array.isArray(model?.additionalSpeedTiers) ? model.additionalSpeedTiers : [];
1122
- if (tiers.length || speedTiers.length || model?.defaultServiceTier) {
1123
- return tiers.some((tier) => tier?.id === 'priority')
1124
- || speedTiers.includes('priority')
1125
- || model?.defaultServiceTier === 'priority';
1126
- }
1127
- const id = clean(model?.id || model).toLowerCase();
1128
- if (id.includes('mini') || id.includes('nano') || id.includes('codex')) return false;
1129
- return /^gpt-5(\.|-|$)/.test(id);
1130
- }
1131
-
1132
- function openAiDirectModelSupportsFast(model) {
1133
- const id = clean(model?.id || model);
1134
- return /^gpt-5\.5(?:-\d{4}|$)/.test(id)
1135
- || /^gpt-5\.4(?:-\d{4}|$)/.test(id)
1136
- || /^gpt-5\.4-mini(?:-\d{4}|$)/.test(id);
1137
- }
1138
-
1139
- function openAiModelSupportsHostedWebSearch(model) {
1140
- const id = clean(model?.id || model).toLowerCase();
1141
- if (!id) return false;
1142
- if (model?.supportsWebSearch === true) return true;
1143
- const tools = [
1144
- ...(Array.isArray(model?.supportedTools) ? model.supportedTools : []),
1145
- ...(Array.isArray(model?.tools) ? model.tools : []),
1146
- ...(Array.isArray(model?.capabilities?.tools) ? model.capabilities.tools : []),
1147
- ].map((tool) => clean(tool?.type || tool?.name || tool).toLowerCase());
1148
- if (tools.some((tool) => tool === 'web_search' || tool === 'web_search_preview')) return true;
1149
- if (/codex|image|audio|tts|stt|embedding|rerank|moderation|search-preview/.test(id)) return false;
1150
- return /^gpt-(5(?:\.|$|-)|4\.1(?:-|$)|4o(?:-|$)|4\.5(?:-|$))/.test(id)
1151
- || /^o[34](?:-|$)/.test(id);
1152
- }
1153
-
1154
- function grokModelSupportsHostedWebSearch(model) {
1155
- const id = clean(model?.id || model).toLowerCase();
1156
- if (!id || /imagine|image|video|composer/.test(id)) return false;
1157
- if (id === 'grok-build') return false;
1158
- return /^grok-/.test(id);
1159
- }
1160
-
1161
- function geminiModelSupportsHostedWebSearch(model) {
1162
- const id = clean(model?.id || model).toLowerCase();
1163
- if (!id || /embedding|aqa|imagen|veo|tts|image|computer-use|customtools/.test(id)) return false;
1164
- return /^gemini-(3(?:\.|-|$)|2\.5-|2\.0-flash)/.test(id);
1165
- }
1166
-
1167
- function anthropicModelSupportsHostedWebSearch(model) {
1168
- const id = clean(model?.id || model).toLowerCase();
1169
- if (!id) return false;
1170
- const match = id.match(/^claude-(opus|sonnet|haiku)-(\d+)(?:[-.](\d+))?/);
1171
- if (!match) return false;
1172
- const major = Number(match[2]) || 0;
1173
- const minor = Number(match[3]) || 0;
1174
- return major > 4 || (major === 4 && minor >= 0);
1175
- }
1176
-
1177
- function anthropicModelMetaSupportsFast(model) {
1178
- const id = clean(model?.id || model).toLowerCase();
1179
- return /^claude-(opus|sonnet)/.test(id);
1180
- }
1181
-
1182
- function fastCapableFor(provider, model) {
1183
- const p = clean(provider);
1184
- if (!FAST_CAPABLE_PROVIDERS.has(p)) return false;
1185
- if (p === 'openai') return openAiDirectModelSupportsFast(model);
1186
- if (p === 'openai-oauth') return openAiModelMetaSupportsFast(model);
1187
- if (p === 'anthropic' || p === 'anthropic-oauth') return anthropicModelMetaSupportsFast(model);
1188
- return false;
1189
- }
1190
-
1191
- function searchCapableFor(provider, model) {
1192
- const p = normalizeSearchProviderId(provider);
1193
- if (!isSearchCapableProvider(p)) return false;
1194
- if (p === 'openai' || p === 'openai-oauth') return openAiModelSupportsHostedWebSearch(model);
1195
- if (p === 'grok-oauth' || p === 'xai') return grokModelSupportsHostedWebSearch(model);
1196
- if (p === 'gemini') return geminiModelSupportsHostedWebSearch(model);
1197
- if (p === 'anthropic' || p === 'anthropic-oauth') return anthropicModelSupportsHostedWebSearch(model);
1198
- return model?.supportsWebSearch === true;
1199
- }
1200
-
1201
- function fastPreferenceFor(config, provider, model) {
1202
- const key = routeFastKey(provider, model);
1203
- if (!key) return false;
1204
- const saved = config?.modelSettings?.[key];
1205
- if (saved && typeof saved === 'object' && hasOwn(saved, 'fast')) return saved.fast === true;
1206
- return config?.fastModels?.[key] === true;
1207
- }
1208
-
1209
- function saveModelSettings(cfgMod, route, { fastCapable = true, baseConfig = null } = {}) {
1210
- const key = routeFastKey(route?.provider, route?.model);
1211
- if (!key) return baseConfig || cfgMod.loadConfig();
1212
- const nextConfig = baseConfig || cfgMod.loadConfig();
1213
- const modelSettings = { ...(nextConfig.modelSettings || {}) };
1214
- const nextSetting = { ...(modelSettings[key] || {}) };
1215
- if (hasOwn(route, 'effort') && route.effort) nextSetting.effort = route.effort;
1216
- else delete nextSetting.effort;
1217
- if (fastCapable) nextSetting.fast = route.fast === true;
1218
- else nextSetting.fast = false;
1219
- modelSettings[key] = nextSetting;
1220
-
1221
- // Legacy compatibility: keep fastModels true entries for old readers, but
1222
- // let modelSettings.fast=false override them in new readers.
1223
- const fastModels = { ...(nextConfig.fastModels || {}) };
1224
- if (nextSetting.fast === true) fastModels[key] = true;
1225
- else delete fastModels[key];
1226
-
1227
- const savedConfig = { ...nextConfig, modelSettings, fastModels };
1228
- cfgMod.saveConfig(savedConfig);
1229
- return savedConfig;
1230
- }
1231
-
1232
- function routeForStatusline(route) {
1233
- const out = {
1234
- mode: 'fixed',
1235
- defaultProvider: route.provider,
1236
- defaultModel: route.model,
1237
- };
1238
- const preset = route.preset || {};
1239
- if (preset.id) out.presetId = preset.id;
1240
- if (preset.name) out.presetName = preset.name;
1241
- if (preset.modelDisplay) out.modelDisplay = preset.modelDisplay;
1242
- if (route.fast === true || route.fast === false) out.fast = route.fast;
1243
- else if (preset.fast === true || preset.fast === false) out.fast = preset.fast;
1244
- if (route.effectiveEffort) {
1245
- out.effort = route.effectiveEffort;
1246
- out.displayEffort = route.effectiveEffort;
1247
- } else if (hasOwn(route, 'effort')) {
1248
- delete out.effort;
1249
- delete out.displayEffort;
1250
- }
1251
- return out;
1252
- }
1253
-
1254
- function writeStatuslineRoute(statusRoutes, session, route) {
1255
- if (!session?.id || !route) return;
1256
- const clientHostPid = session?.clientHostPid || process.pid;
1257
- statusRoutes?.writeGatewaySessionRoute?.(session.id, routeForStatusline(route), { clientHostPid });
1258
- }
374
+ const applyStandaloneToolDefaults = __applyStandaloneToolDefaultsForTest;
375
+ const outputStyleStatus = (dataDir = STANDALONE_DATA_DIR) => outputStyleStatusRaw(STANDALONE_ROOT, dataDir || STANDALONE_DATA_DIR);
1259
376
 
1260
377
  const ONBOARDING_VERSION = 1;
1261
- const WORKFLOW_ROUTE_SLOTS = ['lead', 'agent', 'explorer', 'memory'];
1262
- const FIXED_AGENT_SLOTS = Object.freeze([
1263
- { id: 'explore', label: 'Explore', description: 'Broad repository exploration', workflowSlot: 'explorer' },
1264
- { id: 'maintainer', label: 'Maintainer', description: 'Background memory and upkeep', workflowSlot: 'memory' },
1265
- { id: 'worker', label: 'Worker', description: 'Scoped implementation' },
1266
- { id: 'heavy-worker', label: 'Heavy Worker', description: 'Broad or multi-file implementation' },
1267
- { id: 'reviewer', label: 'Reviewer', description: 'Diff review and risk checks' },
1268
- { id: 'debugger', label: 'Debugger', description: 'Root-cause analysis and failure tracing' },
1269
- ]);
1270
- const SEARCH_CAPABLE_PROVIDERS = new Set([
1271
- 'openai-oauth',
1272
- 'openai',
1273
- 'grok-oauth',
1274
- 'xai',
1275
- 'gemini',
1276
- 'anthropic',
1277
- 'anthropic-oauth',
1278
- ]);
1279
- const SEARCH_DEFAULT_PROVIDER = 'default';
1280
- const SEARCH_DEFAULT_MODEL = 'default';
1281
- const SEARCH_PROVIDER_ALIASES = Object.freeze({
1282
- 'openai-api': 'openai',
1283
- 'xai-api': 'xai',
1284
- 'gemini-api': 'gemini',
1285
- 'anthropic-api': 'anthropic',
1286
- });
1287
378
  const QUICK_SEARCH_MODELS = Object.freeze({
1288
379
  'openai-oauth': [
1289
380
  { id: 'gpt-5.5', display: 'GPT-5.5', latest: true, contextWindow: 1000000 },
@@ -1314,851 +405,37 @@ const QUICK_SEARCH_MODELS = Object.freeze({
1314
405
  { id: 'gemini-2.5-flash', display: 'Gemini 2.5 Flash', contextWindow: 1000000 },
1315
406
  { id: 'gemini-2.0-flash', display: 'Gemini 2.0 Flash', contextWindow: 1000000 },
1316
407
  ],
1317
- 'anthropic-oauth': [
1318
- { id: 'claude-opus-4-8', display: 'Claude Opus 4.8', latest: true, contextWindow: 1000000 },
1319
- { id: 'claude-sonnet-4-6', display: 'Claude Sonnet 4.6', latest: true, contextWindow: 1000000 },
1320
- { id: 'claude-haiku-4-5-20251001', display: 'Claude Haiku 4.5', contextWindow: 200000 },
1321
- ],
1322
- anthropic: [
1323
- { id: 'claude-opus-4-8', display: 'Claude Opus 4.8', latest: true, contextWindow: 1000000 },
1324
- { id: 'claude-sonnet-4-6', display: 'Claude Sonnet 4.6', latest: true, contextWindow: 1000000 },
1325
- { id: 'claude-haiku-4-5-20251001', display: 'Claude Haiku 4.5', contextWindow: 200000 },
1326
- ],
1327
- });
1328
- const AGENT_ROLE_IDS = new Set(FIXED_AGENT_SLOTS.map((agent) => agent.id));
1329
- const agentDefinitionCache = new Map();
1330
- const DEFAULT_WORKFLOW_ID = 'default';
1331
-
1332
- function workflowPresetId(slot) {
1333
- return `workflow-${slot}`;
1334
- }
1335
-
1336
- function workflowPresetName(slot) {
1337
- return `WORKFLOW ${String(slot || '').toUpperCase()}`;
1338
- }
1339
-
1340
- function agentPresetSlot(agentId) {
1341
- return `agent-${String(agentId || '').replace(/[^a-z0-9_.-]+/gi, '-').toLowerCase()}`;
1342
- }
1343
-
1344
- function normalizeAgentId(value) {
1345
- const id = clean(value).toLowerCase().replace(/[\s_]+/g, '-');
1346
- if (id === 'explorer') return 'explore';
1347
- if (id === 'maint' || id === 'maintenance' || id === 'memory') return 'maintainer';
1348
- if (id === 'heavy' || id === 'heavyworker') return 'heavy-worker';
1349
- if (id === 'review') return 'reviewer';
1350
- if (id === 'debug') return 'debugger';
1351
- return AGENT_ROLE_IDS.has(id) ? id : '';
1352
- }
1353
-
1354
- function normalizeWorkflowId(value, fallback = '') {
1355
- const id = clean(value).toLowerCase().replace(/[\s_]+/g, '-');
1356
- return /^[a-z0-9][a-z0-9_.-]*$/.test(id) ? id : fallback;
1357
- }
1358
-
1359
- function readTextSafe(path) {
1360
- try { return readFileSync(path, 'utf8').trim(); } catch { return ''; }
1361
- }
1362
-
1363
- function workflowSourceDirs(dataDir) {
1364
- return [
1365
- { root: join(STANDALONE_ROOT, 'workflows'), source: 'built-in' },
1366
- { root: join(dataDir || STANDALONE_DATA_DIR, 'workflows'), source: 'user' },
1367
- ];
1368
- }
1369
-
1370
- function agentSourceDirs(dataDir, id) {
1371
- return [
1372
- join(dataDir || STANDALONE_DATA_DIR, 'agents', id),
1373
- join(STANDALONE_ROOT, 'agents', id),
1374
- ];
1375
- }
1376
-
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));
1384
- if (!body) return null;
1385
- const agentsConfigured = Array.isArray(manifest.agents);
1386
- return {
1387
- id,
1388
- name: clean(manifest.name) || id,
1389
- description: clean(manifest.description),
1390
- entry,
1391
- agentsConfigured,
1392
- agents: agentsConfigured ? manifest.agents.map((agent) => normalizeAgentId(agent) || normalizeWorkflowId(agent)).filter(Boolean) : [],
1393
- body,
1394
- source,
1395
- };
1396
- }
1397
-
1398
- function listWorkflowPacks(dataDir) {
1399
- const byId = new Map();
1400
- for (const { root, source } of workflowSourceDirs(dataDir)) {
1401
- if (!existsSync(root)) continue;
1402
- let entries = [];
1403
- try { entries = readdirSync(root, { withFileTypes: true }); } catch { entries = []; }
1404
- for (const entry of entries) {
1405
- if (!entry.isDirectory()) continue;
1406
- const pack = readWorkflowPackFromDir(join(root, entry.name), source);
1407
- if (pack) byId.set(pack.id, pack);
1408
- }
1409
- }
1410
- return [...byId.values()].sort((a, b) => {
1411
- if (a.id === DEFAULT_WORKFLOW_ID) return -1;
1412
- if (b.id === DEFAULT_WORKFLOW_ID) return 1;
1413
- return a.name.localeCompare(b.name);
1414
- });
1415
- }
1416
-
1417
- function activeWorkflowId(config) {
1418
- return normalizeWorkflowId(config?.workflow?.active, DEFAULT_WORKFLOW_ID);
1419
- }
1420
-
1421
- function loadWorkflowPack(dataDir, id) {
1422
- const wanted = normalizeWorkflowId(id, DEFAULT_WORKFLOW_ID);
1423
- for (const { root, source } of workflowSourceDirs(dataDir).reverse()) {
1424
- const pack = readWorkflowPackFromDir(join(root, wanted), source);
1425
- if (pack) return pack;
1426
- }
1427
- return readWorkflowPackFromDir(join(STANDALONE_ROOT, 'workflows', DEFAULT_WORKFLOW_ID), 'built-in');
1428
- }
1429
-
1430
- function workflowSummary(pack) {
1431
- const id = normalizeWorkflowId(pack?.id, DEFAULT_WORKFLOW_ID);
1432
- return {
1433
- id,
1434
- name: clean(pack?.name) || (id === DEFAULT_WORKFLOW_ID ? 'Default' : id),
1435
- description: clean(pack?.description),
1436
- source: clean(pack?.source),
1437
- };
1438
- }
1439
-
1440
- function activeWorkflowSummary(config, dataDir) {
1441
- return workflowSummary(loadWorkflowPack(dataDir, activeWorkflowId(config)));
1442
- }
1443
-
1444
- function loadAgentDefinition(dataDir, id) {
1445
- const agentId = normalizeAgentId(id) || normalizeWorkflowId(id);
1446
- if (!agentId) return null;
1447
- const cacheKey = `${dataDir || STANDALONE_DATA_DIR}\n${agentId}`;
1448
- if (agentDefinitionCache.has(cacheKey)) return agentDefinitionCache.get(cacheKey);
1449
- for (const dir of agentSourceDirs(dataDir, agentId)) {
1450
- const manifest = readJsonSafe(join(dir, 'agent.json')) || {};
1451
- const entry = clean(manifest.entry) || 'AGENT.md';
1452
- const doc = readMarkdownDocument(readTextSafe(join(dir, entry)));
1453
- const body = doc.body;
1454
- if (!body) continue;
1455
- const definition = {
1456
- id: agentId,
1457
- name: clean(manifest.name) || FIXED_AGENT_SLOTS.find((agent) => agent.id === agentId)?.label || agentId,
1458
- description: clean(manifest.description) || FIXED_AGENT_SLOTS.find((agent) => agent.id === agentId)?.description || '',
1459
- permission: normalizeAgentPermissionOrNone(doc.frontmatter.permission),
1460
- frontmatter: doc.frontmatter,
1461
- body,
1462
- };
1463
- agentDefinitionCache.set(cacheKey, definition);
1464
- return definition;
1465
- }
1466
- const legacyDoc = readMarkdownDocument(readTextSafe(join(STANDALONE_ROOT, 'agents', `${agentId}.md`)));
1467
- if (!legacyDoc.body) {
1468
- agentDefinitionCache.set(cacheKey, null);
1469
- return null;
1470
- }
1471
- const definition = {
1472
- id: agentId,
1473
- name: FIXED_AGENT_SLOTS.find((agent) => agent.id === agentId)?.label || agentId,
1474
- description: '',
1475
- permission: normalizeAgentPermissionOrNone(legacyDoc.frontmatter.permission),
1476
- frontmatter: legacyDoc.frontmatter,
1477
- body: legacyDoc.body,
1478
- };
1479
- agentDefinitionCache.set(cacheKey, definition);
1480
- return definition;
1481
- }
1482
-
1483
- function workflowContextBlock(config, dataDir) {
1484
- const pack = loadWorkflowPack(dataDir, activeWorkflowId(config));
1485
- if (!pack) return '';
1486
- const lines = [
1487
- `# Active Workflow: ${pack.name}`,
1488
- ];
1489
- if (pack.description) lines.push(pack.description);
1490
- lines.push(pack.body);
1491
-
1492
- const agentIds = pack.agentsConfigured ? pack.agents : FIXED_AGENT_SLOTS.map((agent) => agent.id);
1493
- const agentBlocks = agentIds
1494
- .map((id) => loadAgentDefinition(dataDir, id))
1495
- .filter(Boolean);
1496
- if (agentBlocks.length) {
1497
- lines.push('# Available Agents');
1498
- for (const agent of agentBlocks) {
1499
- lines.push(`## ${agent.name} (${agent.id})`);
1500
- if (agent.description) lines.push(agent.description);
1501
- lines.push(agent.body);
1502
- }
1503
- }
1504
- return lines.join('\n\n');
1505
- }
1506
-
1507
- function normalizeWorkflowRoute(routeLike, fallback = {}) {
1508
- const provider = clean(routeLike?.provider) || clean(fallback.provider);
1509
- const model = clean(routeLike?.model) || clean(fallback.model);
1510
- if (!provider || !model) return null;
1511
- // Defensive: a workflow/agent route must carry a real model id. Reject values
1512
- // that are obviously free-form text (whitespace, prose) so a bad string can
1513
- // never be persisted as a preset/workflow route. Normal model ids pass
1514
- // isLikelyRawModelId unchanged.
1515
- if (!isLikelyRawModelId(model)) return null;
1516
- const effort = normalizeEffortInput(routeLike?.effort ?? fallback.effort);
1517
- const fast = routeLike?.fast ?? fallback.fast;
1518
- return {
1519
- provider,
1520
- model,
1521
- ...(effort ? { effort } : {}),
1522
- ...(fast === true ? { fast: true } : {}),
1523
- };
1524
- }
1525
-
1526
- function normalizeSearchProviderId(provider) {
1527
- const id = clean(provider);
1528
- return SEARCH_PROVIDER_ALIASES[id] || id;
1529
- }
1530
-
1531
- function isDefaultSearchRouteConfig(routeLike = {}) {
1532
- return normalizeSearchProviderId(routeLike?.provider) === SEARCH_DEFAULT_PROVIDER
1533
- && clean(routeLike?.model).toLowerCase() === SEARCH_DEFAULT_MODEL;
1534
- }
1535
-
1536
- function isSearchCapableProvider(provider) {
1537
- return SEARCH_CAPABLE_PROVIDERS.has(normalizeSearchProviderId(provider));
1538
- }
1539
-
1540
- function normalizeSearchRouteConfig(routeLike, fallback = {}) {
1541
- const provider = normalizeSearchProviderId(routeLike?.provider || fallback.provider);
1542
- const model = clean(routeLike?.model || fallback.model);
1543
- if (!provider || !model) return null;
1544
- let effort = null;
1545
- try {
1546
- effort = normalizeEffortInput(routeLike?.effort ?? fallback.effort);
1547
- } catch {
1548
- effort = null;
1549
- }
1550
- const fast = routeLike?.fast ?? fallback.fast;
1551
- const toolType = clean(routeLike?.toolType || fallback.toolType);
1552
- return {
1553
- provider,
1554
- model,
1555
- ...(effort ? { effort } : {}),
1556
- ...(fast === true ? { fast: true } : {}),
1557
- ...(toolType ? { toolType } : {}),
1558
- };
1559
- }
1560
-
1561
- function upsertWorkflowPreset(presets, slot, routeLike) {
1562
- const route = normalizeWorkflowRoute(routeLike);
1563
- if (!route) return presets;
1564
- const id = workflowPresetId(slot);
1565
- const preset = {
1566
- id,
1567
- name: workflowPresetName(slot),
1568
- type: 'agent',
1569
- provider: route.provider,
1570
- model: route.model,
1571
- ...(route.effort ? { effort: route.effort } : {}),
1572
- ...(route.fast === true ? { fast: true } : {}),
1573
- tools: 'full',
1574
- };
1575
- const next = (Array.isArray(presets) ? presets : []).filter((p) => clean(p?.id) !== id && clean(p?.name) !== preset.name);
1576
- next.push(preset);
1577
- return next;
1578
- }
1579
-
1580
- function summarizeWorkflowRoutes(config) {
1581
- const routes = config?.workflowRoutes && typeof config.workflowRoutes === 'object' ? config.workflowRoutes : {};
1582
- const out = {};
1583
- for (const slot of WORKFLOW_ROUTE_SLOTS) {
1584
- const route = routes[slot];
1585
- if (route?.provider && route?.model) out[slot] = normalizeWorkflowRoute(route);
1586
- }
1587
- return out;
1588
- }
1589
-
1590
- function routeFromPreset(config, slotValue) {
1591
- // Maintenance slots now store a direct {provider, model} route. Accept that
1592
- // shape first; fall back to the legacy preset-NAME string lookup so configs
1593
- // written before the route migration still resolve.
1594
- if (slotValue && typeof slotValue === 'object' && !Array.isArray(slotValue)) {
1595
- const direct = normalizeWorkflowRoute(slotValue);
1596
- if (direct) return direct;
1597
- }
1598
- const preset = findPreset(config, slotValue);
1599
- return preset ? normalizeWorkflowRoute(preset) : null;
1600
- }
1601
-
1602
- function agentRouteFromConfig(config, agentId, _dataDir) {
1603
- const id = normalizeAgentId(agentId);
1604
- if (!id) return null;
1605
- const explicit = normalizeWorkflowRoute(config?.agents?.[id])
1606
- || (id === 'maintainer' ? normalizeWorkflowRoute(config?.agents?.maintenance) : null);
1607
- if (explicit) return explicit;
1608
-
1609
- const agent = FIXED_AGENT_SLOTS.find((item) => item.id === id);
1610
- if (agent?.workflowSlot) {
1611
- const workflowRoute = normalizeWorkflowRoute(config?.workflowRoutes?.[agent.workflowSlot]);
1612
- if (workflowRoute) return workflowRoute;
1613
- }
1614
-
1615
- if (id === 'explore') return routeFromPreset(config, config?.maintenance?.explore);
1616
- if (id === 'maintainer') return routeFromPreset(config, config?.maintenance?.memory);
1617
-
1618
- return null;
1619
- }
1620
-
1621
- function toolResponseText(result) {
1622
- if (result && typeof result === 'object' && Array.isArray(result.content)) {
1623
- return result.content
1624
- .map((part) => (part?.type === 'text' ? part.text || '' : JSON.stringify(part)))
1625
- .join('\n');
1626
- }
1627
- if (typeof result === 'string') return result;
1628
- return JSON.stringify(result, null, 2);
1629
- }
1630
-
1631
- function isEmptyRecallText(value) {
1632
- const text = String(value || '').trim();
1633
- return !text || /^\(?no results\)?$/i.test(text) || /^\(?empty memory result\)?$/i.test(text);
1634
- }
1635
-
1636
- function currentSessionRecallRows(session, query, { limit = 10 } = {}) {
1637
- const messages = Array.isArray(session?.messages) ? session.messages : [];
1638
- if (!messages.length) return '(no results)';
1639
- const terms = [...new Set(String(query || '').toLowerCase().match(/[\p{L}\p{N}_./:-]{2,}/gu) || [])]
1640
- .filter(Boolean)
1641
- .slice(0, 16);
1642
- const max = Math.max(1, Math.min(100, Number(limit) || 10));
1643
- const rows = [];
1644
- for (let i = messages.length - 1; i >= 0 && rows.length < max; i -= 1) {
1645
- const m = messages[i];
1646
- if (!m || (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool')) continue;
1647
- const text = messageContextText(m).replace(/\s+/g, ' ').trim();
1648
- if (!text) continue;
1649
- if (terms.length && !terms.some((term) => text.toLowerCase().includes(term))) continue;
1650
- rows.push(`[session:${i + 1}] ${m.role}: ${text.slice(0, 1000)}`);
1651
- }
1652
- return rows.length ? rows.join('\n') : '(no results)';
1653
- }
1654
-
1655
- function parseToolSelection(value) {
1656
- if (Array.isArray(value)) return value.map(clean).filter(Boolean);
1657
- if (value && typeof value !== 'string' && typeof value[Symbol.iterator] === 'function') {
1658
- return [...value].map(clean).filter(Boolean);
1659
- }
1660
- return String(value || '').replace(/^select\s*:/i, '')
1661
- .split(/[,\s]+/)
1662
- .map(clean)
1663
- .filter(Boolean);
1664
- }
1665
-
1666
- function parseToolSearchQuerySelection(query) {
1667
- const match = clean(query).match(/^select\s*:\s*(.+)$/i);
1668
- return match ? parseToolSelection(match[1]) : [];
1669
- }
1670
-
1671
- function toolKind(tool) {
1672
- const name = clean(tool?.name);
1673
- if (name.startsWith('mcp__')) return 'mcp';
1674
- if (name.startsWith('skill:') || tool?.annotations?.mixdogKind === 'skill') return 'skill';
1675
- if (name === 'Skill' || name.startsWith('skill_') || name === 'skills_list' || name === 'skill_view') return 'skill';
1676
- if (tool?.annotations?.agentHidden) return 'control';
1677
- if (['apply_patch', 'shell'].includes(name)) return 'mutation';
1678
- return 'tool';
1679
- }
1680
-
1681
- function toolSchemaBucket(tool) {
1682
- const name = clean(tool?.name);
1683
- const kind = toolKind(tool);
1684
- if (kind === 'mcp') return 'mcp';
1685
- if (kind === 'skill') return 'skills';
1686
- if (name === 'memory' || name === 'recall' || name.includes('memory')) return 'memory';
1687
- if (name === 'search' || name === 'web_fetch') return 'web';
1688
- if (['read', 'grep', 'find', 'glob', 'list', 'code_graph', 'explore'].includes(name)) return 'code';
1689
- if (['shell', 'apply_patch'].includes(name)) return 'mutation';
1690
- if (name === 'agent' || name === 'delegate') return 'agents';
1691
- if (name.includes('channel') || name.includes('discord') || name.includes('webhook')) return 'channels';
1692
- if (name.includes('provider') || name === 'tool_search' || name === 'cwd') return 'setup';
1693
- return 'other';
1694
- }
1695
-
1696
- function estimateToolSchemaBreakdown(tools) {
1697
- const out = {};
1698
- for (const tool of Array.isArray(tools) ? tools : []) {
1699
- const bucket = toolSchemaBucket(tool);
1700
- const row = out[bucket] || { count: 0, tokens: 0 };
1701
- row.count += 1;
1702
- row.tokens += estimateToolSchemaTokens([tool]);
1703
- out[bucket] = row;
1704
- }
1705
- return out;
1706
- }
1707
-
1708
- function measuredToolUsage(name) {
1709
- return MEASURED_TOOL_USAGE[clean(name)] || 0;
1710
- }
1711
-
1712
- function measuredToolRank(name) {
1713
- const index = MEASURED_TOOL_ORDER.indexOf(clean(name));
1714
- return index === -1 ? Number.MAX_SAFE_INTEGER : index;
1715
- }
1716
-
1717
- function sortedCatalogByMeasuredUsage(catalog) {
1718
- return (catalog || [])
1719
- .map((tool, index) => ({ tool, index }))
1720
- .sort((a, b) => {
1721
- const au = measuredToolUsage(a.tool?.name);
1722
- const bu = measuredToolUsage(b.tool?.name);
1723
- if (bu !== au) return bu - au;
1724
- const ar = measuredToolRank(a.tool?.name);
1725
- const br = measuredToolRank(b.tool?.name);
1726
- if (ar !== br) return ar - br;
1727
- return a.index - b.index;
1728
- })
1729
- .map((entry) => entry.tool);
1730
- }
1731
-
1732
- function activeToolForSurface(tool) {
1733
- if (!tool || typeof tool !== 'object') return tool;
1734
- return JSON.parse(JSON.stringify(tool));
1735
- }
1736
-
1737
- function deferredProviderMode(provider) {
1738
- const p = clean(provider).toLowerCase();
1739
- if (p === 'gemini') return 'full';
1740
- if (p === 'anthropic' || p === 'anthropic-oauth'
1741
- || p === 'openai' || p === 'openai-oauth'
1742
- || p === 'xai' || p === 'grok-oauth') {
1743
- return 'native';
1744
- }
1745
- return 'legacy';
1746
- }
1747
-
1748
- function filterDisallowedTools(tools, disallowed = []) {
1749
- if (!Array.isArray(disallowed) || disallowed.length === 0) return tools;
1750
- const deny = new Set(disallowed.map((name) => clean(name)).filter(Boolean));
1751
- if (deny.size === 0) return tools;
1752
- return (tools || []).filter((tool) => !deny.has(clean(tool?.name)));
1753
- }
1754
-
1755
- function sortedNamesByMeasuredUsage(names) {
1756
- return [...(names || [])].sort((a, b) => {
1757
- const au = measuredToolUsage(a);
1758
- const bu = measuredToolUsage(b);
1759
- if (bu !== au) return bu - au;
1760
- const ar = measuredToolRank(a);
1761
- const br = measuredToolRank(b);
1762
- if (ar !== br) return ar - br;
1763
- return String(a).localeCompare(String(b));
1764
- });
1765
- }
1766
-
1767
- export function defaultDeferredToolNames(catalog, mode) {
1768
- const available = new Set((catalog || []).map((tool) => clean(tool?.name)).filter(Boolean));
1769
- if (mode === 'lead') {
1770
- return new Set(DEFERRED_DEFAULT_LEAD_TOOLS.filter((name) => available.has(name)));
1771
- }
1772
- if (mode === 'readonly') {
1773
- return new Set(DEFERRED_DEFAULT_READONLY_TOOLS.filter((name) => available.has(name)));
1774
- }
1775
- return new Set(DEFERRED_DEFAULT_FULL_TOOLS.filter((name) => available.has(name)));
1776
- }
1777
-
1778
- export function compactToolSearchDescription(value, max = 220) {
1779
- const text = clean(value).replace(/\s+/g, ' ');
1780
- return text.length > max ? `${text.slice(0, max - 1)}…` : text;
1781
- }
1782
-
1783
- function toolRow(tool, activeNames = new Set()) {
1784
- const name = clean(tool?.name);
1785
- return {
1786
- name,
1787
- kind: toolKind(tool),
1788
- usage: measuredToolUsage(name),
1789
- active: activeNames.has(name),
1790
- description: compactToolSearchDescription(tool?.description),
1791
- };
1792
- }
1793
-
1794
- function providerSupportsResponsesCustomTools(provider) {
1795
- const p = clean(provider).toLowerCase();
1796
- if (!p) return true;
1797
- return p === 'openai' || p === 'openai-oauth';
1798
- }
1799
-
1800
- function openAILoadableToolSpec(tool, provider = '') {
1801
- if (providerSupportsResponsesCustomTools(provider) && isResponsesFreeformTool(tool)) return toResponsesCustomTool(tool);
1802
- return {
1803
- type: 'function',
1804
- name: clean(tool?.name),
1805
- description: clean(tool?.description),
1806
- defer_loading: true,
1807
- parameters: tool?.inputSchema && typeof tool.inputSchema === 'object'
1808
- ? tool.inputSchema
1809
- : { type: 'object', properties: {} },
1810
- };
1811
- }
1812
-
1813
- function toolSearchNativePayload(catalog, names, provider = '') {
1814
- const selected = new Set((names || []).map(clean).filter(Boolean));
1815
- if (!selected.size) return null;
1816
- const tools = [];
1817
- const refs = [];
1818
- for (const tool of catalog || []) {
1819
- const name = clean(tool?.name);
1820
- if (!name || !selected.has(name)) continue;
1821
- refs.push(name);
1822
- tools.push(openAILoadableToolSpec(tool, provider));
1823
- }
1824
- if (!refs.length) return null;
1825
- return {
1826
- toolReferences: refs,
1827
- openaiTools: tools,
1828
- summary: `Loaded deferred tools: ${refs.join(', ')}`,
1829
- };
1830
- }
1831
-
1832
- function toolSearchTokens(value) {
1833
- return (clean(value).toLowerCase().match(/[a-z0-9_.-]+/g) || [])
1834
- .map((token) => token.replace(/[-.]+/g, '_'))
1835
- .filter(Boolean);
1836
- }
1837
-
1838
- function toolSearchMeaningfulTokens(value) {
1839
- return toolSearchTokens(value).filter((token) => !TOOL_SEARCH_STOP_WORDS.has(token));
1840
- }
1841
-
1842
- function toolSearchText(row) {
1843
- const text = `${row.name} ${String(row.name || '').replace(/_/g, ' ')} ${row.kind} ${row.description} ${row.active ? 'active' : 'deferred'}`;
1844
- return `${text} ${text.replace(/[-.]+/g, '_')}`.toLowerCase();
1845
- }
1846
-
1847
- function toolSearchRowAliases(name) {
1848
- return TOOL_SEARCH_ROW_ALIASES[clean(name)] || [];
1849
- }
1850
-
1851
- function toolSearchRank(row, query) {
1852
- const raw = clean(query).toLowerCase();
1853
- if (!raw) return { score: 0, reasons: [] };
1854
- const name = clean(row?.name).toLowerCase();
1855
- const prettyName = name.replace(/_/g, ' ');
1856
- const haystack = toolSearchText(row);
1857
- const aliases = toolSearchRowAliases(name);
1858
- const aliasText = aliases.join(' ').toLowerCase();
1859
- const queryTokens = toolSearchMeaningfulTokens(raw);
1860
- const nameTokens = new Set(toolSearchTokens(`${name} ${prettyName}`));
1861
- const aliasTokens = new Set(toolSearchTokens(aliasText));
1862
- let score = 0;
1863
- const reasons = [];
1864
- if (raw === name || raw === prettyName) {
1865
- score += 120;
1866
- reasons.push('exact-name');
1867
- }
1868
- if (aliases.some((alias) => raw === alias || raw === alias.replace(/[_-]+/g, ' '))) {
1869
- score += 100;
1870
- reasons.push('exact-alias');
1871
- }
1872
- if (haystack.includes(raw)) {
1873
- score += 34;
1874
- reasons.push('phrase');
1875
- }
1876
- for (const alias of aliases) {
1877
- const normalizedAlias = alias.toLowerCase();
1878
- if (normalizedAlias && raw.includes(normalizedAlias)) {
1879
- score += 58;
1880
- reasons.push('alias-phrase');
1881
- break;
1882
- }
1883
- }
1884
- let matchedTokens = 0;
1885
- for (const token of queryTokens) {
1886
- if (nameTokens.has(token) || name.includes(token)) {
1887
- score += 24;
1888
- matchedTokens += 1;
1889
- continue;
1890
- }
1891
- if (aliasTokens.has(token) || aliasText.includes(token)) {
1892
- score += 18;
1893
- matchedTokens += 1;
1894
- continue;
1895
- }
1896
- if (haystack.includes(token)) {
1897
- score += 7;
1898
- matchedTokens += 1;
1899
- }
1900
- }
1901
- if (queryTokens.length && matchedTokens === queryTokens.length) {
1902
- score += Math.min(18, queryTokens.length * 6);
1903
- reasons.push('all-tokens');
1904
- }
1905
- if (clean(row?.kind).toLowerCase() === raw) score += 10;
1906
- if (score > 0) score += Math.min(6, Math.floor(measuredToolUsage(name) / 150));
1907
- return { score, reasons };
1908
- }
1909
-
1910
- function toolSearchMatches(row, query) {
1911
- const raw = clean(query).toLowerCase();
1912
- if (!raw) return true;
1913
- return toolSearchRank(row, raw).score > 0;
1914
- }
1915
-
1916
- function rankedToolSearchRows(rows, query) {
1917
- const raw = clean(query).toLowerCase();
1918
- if (!raw) return rows;
1919
- return rows
1920
- .map((row) => {
1921
- const ranked = toolSearchRank(row, raw);
1922
- return { ...row, score: ranked.score, reasons: ranked.reasons };
1923
- })
1924
- .filter((row) => row.score > 0)
1925
- .sort((a, b) => {
1926
- if (b.score !== a.score) return b.score - a.score;
1927
- if (a.active !== b.active) return a.active ? 1 : -1;
1928
- if (b.usage !== a.usage) return b.usage - a.usage;
1929
- return String(a.name).localeCompare(String(b.name));
1930
- });
1931
- }
1932
-
1933
- function queryHasAnyPhrase(query, phrases) {
1934
- const text = clean(query).toLowerCase().replace(/[_-]+/g, ' ');
1935
- return phrases.some((phrase) => text.includes(phrase));
1936
- }
1937
-
1938
- function autoToolSelectionNames(query, rows) {
1939
- const raw = clean(query).toLowerCase();
1940
- if (!raw || TOOL_SEARCH_AMBIGUOUS_AUTO_QUERIES.has(raw)) return [];
1941
- if (TOOL_SEARCH_SAFE_AUTO_ALIASES.has(raw) && DEFERRED_SELECT_ALIASES[raw]) {
1942
- return DEFERRED_SELECT_ALIASES[raw];
1943
- }
1944
- if (queryHasAnyPhrase(raw, ['save memory', 'store memory', 'delete memory', 'forget memory', 'memory status', '기억 저장', '메모리 저장'])) {
1945
- return ['memory'];
1946
- }
1947
- if (queryHasAnyPhrase(raw, ['run', 'execute', 'test', 'tests', 'build', 'terminal', 'command', 'powershell', 'bash', 'shell', 'npm', 'node', 'git', '실행', '테스트', '빌드', '터미널', '쉘'])) {
1948
- return ['shell', 'task'];
1949
- }
1950
- if (queryHasAnyPhrase(raw, ['web', 'internet', 'online', 'current info', 'latest', 'news', 'browse', 'docs', 'documentation', '웹', '인터넷', '최신', '뉴스', '문서'])) {
1951
- return ['search', 'web_fetch'];
1952
- }
1953
- if (queryHasAnyPhrase(raw, ['recall', 'remember', 'memory previous', 'previous', 'history', 'past', 'prior', 'earlier', 'resume', '이전', '기억', '히스토리'])) {
1954
- return ['recall'];
1955
- }
1956
- if (queryHasAnyPhrase(raw, ['delegate', 'subagent', 'worker', 'parallel agent', 'background agent', 'reviewer', 'explorer', '에이전트', '워커', '병렬'])) {
1957
- return ['agent'];
1958
- }
1959
- if (queryHasAnyPhrase(raw, ['working directory', 'project root', 'current directory', 'cwd'])) {
1960
- return ['cwd'];
1961
- }
1962
- const ranked = rankedToolSearchRows(rows, raw);
1963
- const top = ranked[0];
1964
- if (!top) return [];
1965
- const reasons = new Set(top.reasons || []);
1966
- if (reasons.has('exact-name') || reasons.has('exact-alias')) return [top.name];
1967
- return [];
1968
- }
1969
-
1970
- function expandSelectionNames(names) {
1971
- const out = [];
1972
- for (const raw of names || []) {
1973
- const key = clean(raw);
1974
- if (!key) continue;
1975
- const alias = DEFERRED_SELECT_ALIASES[key.toLowerCase()];
1976
- if (alias) out.push(...alias);
1977
- else out.push(key);
1978
- }
1979
- return [...new Set(out)];
1980
- }
1981
-
1982
- function storedDeferredToolNames(session) {
1983
- for (const source of [session?.deferredDiscoveredTools, session?.deferredSelectedTools]) {
1984
- const names = parseToolSelection(source);
1985
- if (names.length) return names;
1986
- }
1987
- return [];
1988
- }
1989
-
1990
- function canonicalDeferredToolNames(catalog, names) {
1991
- const byName = new Map();
1992
- for (const tool of catalog || []) {
1993
- const name = clean(tool?.name);
1994
- if (!name) continue;
1995
- byName.set(name, name);
1996
- byName.set(name.toLowerCase(), name);
1997
- }
1998
- const out = [];
1999
- for (const raw of expandSelectionNames(names)) {
2000
- const name = clean(raw);
2001
- const canonical = byName.get(name) || byName.get(name.toLowerCase());
2002
- if (canonical) out.push(canonical);
2003
- }
2004
- return sortedNamesByMeasuredUsage(new Set(out));
2005
- }
2006
-
2007
- function setDeferredToolState(session, names) {
2008
- if (!session) return [];
2009
- const selected = sortedNamesByMeasuredUsage(new Set(parseToolSelection(names)));
2010
- session.deferredDiscoveredTools = selected;
2011
- session.deferredSelectedTools = selected;
2012
- return selected;
2013
- }
2014
-
2015
- function isReadonlySelectable(tool) {
2016
- const name = clean(tool?.name);
2017
- if (READONLY_TOOL_NAMES.has(name)) return true;
2018
- const annotations = tool?.annotations || {};
2019
- if (annotations.destructiveHint === true) return false;
2020
- if (annotations.readOnlyHint === true) return true;
2021
- return false;
2022
- }
2023
-
2024
- function applyDeferredToolSurface(session, mode, extraTools = [], options = {}) {
2025
- if (!session || !Array.isArray(session.tools)) return session;
2026
- const providerMode = deferredProviderMode(options.provider || session.provider);
2027
- const byName = new Map();
2028
- for (const tool of [...session.tools, ...(extraTools || [])]) {
2029
- const name = clean(tool?.name);
2030
- if (!name || byName.has(name)) continue;
2031
- byName.set(name, activeToolForSurface(tool));
2032
- }
2033
- const catalog = sortedCatalogByMeasuredUsage([...byName.values()]);
2034
- const defaultNames = defaultDeferredToolNames(catalog, mode);
2035
- const storedNames = providerMode === 'native' ? [] : storedDeferredToolNames(session);
2036
- let selectedNames = providerMode === 'full'
2037
- ? sortedNamesByMeasuredUsage(catalog.map((tool) => clean(tool?.name)).filter(Boolean))
2038
- : [];
2039
- if (providerMode !== 'full') {
2040
- selectedNames = storedNames.length ? canonicalDeferredToolNames(catalog, storedNames) : [];
2041
- if (!selectedNames.length || providerMode === 'native') selectedNames = sortedNamesByMeasuredUsage(defaultNames);
2042
- }
2043
- const selected = new Set(selectedNames);
2044
- session.deferredToolCatalog = catalog;
2045
- session.deferredToolUsage = MEASURED_TOOL_USAGE;
2046
- session.deferredDefaultTools = sortedNamesByMeasuredUsage(defaultNames);
2047
- session.deferredProviderMode = providerMode;
2048
- session.deferredNativeTools = providerMode === 'native';
2049
- session.tools.length = 0;
2050
- const active = [];
2051
- for (const tool of catalog) {
2052
- if (!selected.has(clean(tool?.name))) continue;
2053
- if (mode === 'readonly' && !isReadonlySelectable(tool)) continue;
2054
- session.tools.push(tool);
2055
- active.push(clean(tool?.name));
2056
- }
2057
- if (providerMode === 'native') {
2058
- const discovered = canonicalDeferredToolNames(catalog, session.deferredDiscoveredTools || []);
2059
- session.deferredSelectedTools = active;
2060
- session.deferredDiscoveredTools = discovered.filter((name) => !selected.has(name));
2061
- } else {
2062
- setDeferredToolState(session, active);
2063
- }
2064
- return session;
2065
- }
2066
-
2067
- function selectDeferredTools(session, names, mode) {
2068
- const catalog = Array.isArray(session?.deferredToolCatalog)
2069
- ? session.deferredToolCatalog
2070
- : (Array.isArray(session?.tools) ? session.tools : []);
2071
- const active = new Set((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
2072
- const native = session?.deferredProviderMode === 'native' || session?.deferredNativeTools === true;
2073
- const discovered = new Set(Array.isArray(session?.deferredDiscoveredTools) ? session.deferredDiscoveredTools : []);
2074
- const byName = new Map();
2075
- for (const tool of catalog) {
2076
- const name = clean(tool?.name);
2077
- if (!name) continue;
2078
- byName.set(name, tool);
2079
- byName.set(name.toLowerCase(), tool);
2080
- }
2081
- const added = [];
2082
- const already = [];
2083
- const blocked = [];
2084
- const missing = [];
2085
- for (const rawName of expandSelectionNames(names)) {
2086
- const requestedName = clean(rawName);
2087
- const tool = byName.get(requestedName) || byName.get(requestedName.toLowerCase());
2088
- const name = clean(tool?.name);
2089
- if (!tool) {
2090
- missing.push(requestedName);
2091
- continue;
2092
- }
2093
- if (mode === 'readonly' && !isReadonlySelectable(tool)) {
2094
- blocked.push({ name, reason: 'readonly mode' });
2095
- continue;
2096
- }
2097
- if (active.has(name) || discovered.has(name)) {
2098
- already.push(name);
2099
- continue;
2100
- }
2101
- if (native) {
2102
- discovered.add(name);
2103
- } else {
2104
- session.tools.push(tool);
2105
- active.add(name);
2106
- }
2107
- added.push(name);
2108
- }
2109
- if (native) {
2110
- session.deferredDiscoveredTools = sortedNamesByMeasuredUsage(discovered);
2111
- session.deferredSelectedTools = sortedNamesByMeasuredUsage(active);
2112
- } else {
2113
- setDeferredToolState(session, active);
2114
- }
2115
- return { added, already, blocked, missing, native };
2116
- }
2117
-
2118
- function renderToolSearch(args = {}, session, mode = 'full') {
2119
- const catalog = Array.isArray(session?.deferredToolCatalog)
2120
- ? session.deferredToolCatalog
2121
- : (Array.isArray(session?.tools) ? session.tools : []);
2122
- const rawQuery = clean(args.query);
2123
- const explicitSelectedNames = parseToolSelection(args.select);
2124
- const querySelectedNames = explicitSelectedNames.length ? [] : parseToolSearchQuerySelection(rawQuery);
2125
- const forcedSelectedNames = explicitSelectedNames.length ? explicitSelectedNames : querySelectedNames;
2126
- const query = querySelectedNames.length ? '' : rawQuery.toLowerCase();
2127
- const limit = Math.max(1, Math.min(50, Number(args.limit) || 20));
2128
- const initialActiveNames = new Set((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
2129
- const initialRows = catalog.map((tool) => toolRow(tool, initialActiveNames)).filter((row) => row.name);
2130
- const autoSelectedNames = (!forcedSelectedNames.length && query)
2131
- ? autoToolSelectionNames(query, initialRows)
2132
- : [];
2133
- const selectedNames = forcedSelectedNames.length ? forcedSelectedNames : autoSelectedNames;
2134
- const toolSelection = selectedNames.length ? selectDeferredTools(session, selectedNames, mode) : null;
2135
- const selectionMode = forcedSelectedNames.length ? 'select' : (autoSelectedNames.length ? 'auto' : null);
2136
- const nextActiveNames = new Set((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
2137
- const rows = [
2138
- ...catalog.map((tool) => toolRow(tool, nextActiveNames)),
2139
- ].filter((row) => row.name);
2140
- const matches = query
2141
- ? rankedToolSearchRows(rows, query)
2142
- : rows;
2143
- const selected = toolSelection
2144
- ? {
2145
- mode: selectionMode,
2146
- tools: toolSelection,
2147
- }
2148
- : null;
2149
- const nativeToolSearch = toolSelection?.native
2150
- ? toolSearchNativePayload(catalog, toolSelection.added, session?.provider)
2151
- : null;
2152
- return JSON.stringify({
2153
- selected,
2154
- ...(nativeToolSearch ? { nativeToolSearch } : {}),
2155
- totalMatches: matches.length,
2156
- matches: matches.slice(0, limit),
2157
- activeTools: sortedNamesByMeasuredUsage(nextActiveNames),
2158
- discoveredTools: sortedNamesByMeasuredUsage(session?.deferredDiscoveredTools || []),
2159
- note: 'query ranks matches and auto-loads high-confidence deferred tools; select exact tool names, or query select:a,b, to force load.',
2160
- }, null, 2);
2161
- }
408
+ 'anthropic-oauth': [
409
+ { id: 'claude-opus-4-8', display: 'Claude Opus 4.8', latest: true, contextWindow: 1000000 },
410
+ { id: 'claude-sonnet-4-6', display: 'Claude Sonnet 4.6', latest: true, contextWindow: 1000000 },
411
+ { id: 'claude-haiku-4-5-20251001', display: 'Claude Haiku 4.5', contextWindow: 200000 },
412
+ ],
413
+ anthropic: [
414
+ { id: 'claude-opus-4-8', display: 'Claude Opus 4.8', latest: true, contextWindow: 1000000 },
415
+ { id: 'claude-sonnet-4-6', display: 'Claude Sonnet 4.6', latest: true, contextWindow: 1000000 },
416
+ { id: 'claude-haiku-4-5-20251001', display: 'Claude Haiku 4.5', contextWindow: 200000 },
417
+ ],
418
+ });
419
+ // Workflow/agent pack loaders bound to this runtime's root/data layout.
420
+ const {
421
+ listWorkflowPacks,
422
+ activeWorkflowId,
423
+ loadWorkflowPack,
424
+ workflowSummary,
425
+ activeWorkflowSummary,
426
+ loadAgentDefinition,
427
+ workflowContextBlock,
428
+ } = createWorkflowHelpers({
429
+ rootDir: STANDALONE_ROOT,
430
+ dataDir: STANDALONE_DATA_DIR,
431
+ readMarkdownDocument,
432
+ normalizeAgentPermissionOrNone,
433
+ });
434
+ const {
435
+ summarizeWorkflowRoutes,
436
+ routeFromPreset,
437
+ agentRouteFromConfig,
438
+ } = createWorkflowRouteHelpers({ resolveDefaultProvider, findPreset });
2162
439
 
2163
440
  export function __renderToolSearchForTest(args = {}, session = {}, mode = 'full') {
2164
441
  return renderToolSearch(args, session, mode);
@@ -2182,8 +459,17 @@ export async function createMixdogSessionRuntime({
2182
459
  model,
2183
460
  cwd = process.cwd(),
2184
461
  toolMode = 'full',
462
+ remote = false,
2185
463
  } = {}) {
2186
464
  bootProfile('session-runtime:start', { provider, model, toolMode, cwd });
465
+ let remoteEnabled = remote === true;
466
+ // Remote-mode transcript writer (Discord outbound). Lazily created per
467
+ // session.id + cwd inside ask(); only active while remoteEnabled.
468
+ let _transcriptWriter = null;
469
+ let _twKey = '';
470
+ // Last assistant text handed to the transcript writer (via onAssistantText),
471
+ // so the post-turn final-content append can skip an exact duplicate.
472
+ let _lastAppendedAssistant = '';
2187
473
  process.env.MIXDOG_QUIET_SESSION_LOG ??= '1';
2188
474
  const standaloneStartedAt = performance.now();
2189
475
  ensureStandaloneEnvironment({
@@ -2590,9 +876,11 @@ export async function createMixdogSessionRuntime({
2590
876
  let sessionNeedsCwdRefresh = false;
2591
877
  let closeRequested = false;
2592
878
  let channelStartTimer = null;
879
+ let channelStartPromise = null;
2593
880
  let providerSetupWarmupTimer = null;
2594
881
  let providerWarmupTimer = null;
2595
882
  let providerModelWarmupTimer = null;
883
+ let modelCatalogWarmupTimer = null;
2596
884
  let codeGraphPrewarmTimer = null;
2597
885
  let codeGraphPrewarmInFlight = false;
2598
886
  let codeGraphPrewarmQueuedCwd = '';
@@ -2622,6 +910,7 @@ export async function createMixdogSessionRuntime({
2622
910
  }
2623
911
  const sessionPrewarmDelayMs = envDelayMs('MIXDOG_SESSION_PREWARM_DELAY_MS', 50, { min: 0, max: 10_000 });
2624
912
  const providerSetupWarmupDelayMs = envDelayMs('MIXDOG_PROVIDER_SETUP_WARMUP_DELAY_MS', 300, { min: 0, max: 60_000 });
913
+ const modelCatalogWarmupDelayMs = envDelayMs('MIXDOG_MODEL_CATALOG_WARMUP_DELAY_MS', 200, { min: 0, max: 60_000 });
2625
914
  const providerWarmupDelayMs = envDelayMs('MIXDOG_PROVIDER_WARMUP_DELAY_MS', 1_500, { min: 0, max: 60_000 });
2626
915
  // Background model-catalog prefetch delay. Kept short so the first `/model`
2627
916
  // open finds a warm cache instead of paying a cold full network load. The
@@ -2655,6 +944,7 @@ export async function createMixdogSessionRuntime({
2655
944
  const modelPrefetchEnabled = !envFlag('MIXDOG_DISABLE_PROVIDER_WARMUP')
2656
945
  && !envFlag('MIXDOG_DISABLE_MODEL_PREFETCH');
2657
946
  const codeGraphPrewarmEnabled = !envFlag('MIXDOG_DISABLE_CODE_GRAPH_PREWARM');
947
+ const modelCatalogWarmupEnabled = !envFlag('MIXDOG_DISABLE_MODEL_CATALOG_WARMUP');
2658
948
  // Lazy code-graph prewarm (default ON): do NOT prewarm at startup / on cwd
2659
949
  // change — that fired ~250ms after the first frame and, in a large tree,
2660
950
  // burned a worker (and felt like a freeze) before the user did anything.
@@ -2667,8 +957,8 @@ export async function createMixdogSessionRuntime({
2667
957
  const notificationListeners = new Set();
2668
958
  let providerModelsCache = { models: null, at: 0 };
2669
959
  let providerModelsPromise = null;
960
+ let providerModelsLoadSeq = 0;
2670
961
  let searchProviderModelsCache = { models: null, at: 0 };
2671
- let searchProviderModelsPromise = null;
2672
962
  let usageDashboardCache = { dashboard: null, at: 0 };
2673
963
  let usageDashboardPromise = null;
2674
964
  let providerSetupCache = { setup: null, at: 0 };
@@ -2676,6 +966,9 @@ export async function createMixdogSessionRuntime({
2676
966
  let providerSetupPromise = null;
2677
967
  let providerInitPromise = null;
2678
968
  let mcpFailures = [];
969
+ let lastProjectMcpKey = null;
970
+ let mcpConnectGeneration = 0;
971
+ let mcpConnectInFlight = null;
2679
972
  let preSessionToolSurface = null;
2680
973
  let contextStatusCacheKey = null;
2681
974
  let contextStatusCacheValue = null;
@@ -2684,11 +977,87 @@ export async function createMixdogSessionRuntime({
2684
977
  hooks.emit('runtime:start', { cwd: currentCwd, provider: route.provider, model: route.model, toolMode: mode });
2685
978
  bootProfile('hooks:ready', { ms: (performance.now() - hooksStartedAt).toFixed(1) });
2686
979
 
980
+ // ---------------------------------------------------------------------
981
+ // Self-update (npm registry version check + optional auto-install).
982
+ // updateCheckState mirrors the last-known checkLatestVersion() result;
983
+ // updateProcessState tracks the in-flight install lifecycle so the TUI can
984
+ // poll getUpdateStatus() instead of needing a push/event channel. Both are
985
+ // purely in-memory (per runtime instance) — the on-disk cache lives inside
986
+ // update-checker.mjs and is what actually enforces the 24h TTL.
987
+ let updateCheckState = {
988
+ currentVersion: null,
989
+ latestVersion: null,
990
+ updateAvailable: false,
991
+ lastCheckedAt: 0,
992
+ };
993
+ // phase: 'idle' | 'checking' | 'installing' | 'installed' | 'failed'
994
+ let updateProcessState = { phase: 'idle', version: null, error: null };
995
+
996
+ function autoUpdateEnabled() {
997
+ return config?.update?.auto === true;
998
+ }
999
+
1000
+ async function checkForUpdateInternal({ force = false } = {}) {
1001
+ if (updateProcessState.phase !== 'installing') updateProcessState.phase = 'checking';
1002
+ try {
1003
+ const result = await checkLatestVersion({ force, dataDir: cfgMod.getPluginData?.() || STANDALONE_DATA_DIR });
1004
+ updateCheckState = {
1005
+ currentVersion: result.currentVersion,
1006
+ latestVersion: result.latestVersion,
1007
+ updateAvailable: result.updateAvailable,
1008
+ lastCheckedAt: result.lastCheckedAt,
1009
+ };
1010
+ } catch {
1011
+ // checkLatestVersion() is already silent-safe; this catch is belt-and-
1012
+ // braces so a boot-time call can never crash the runtime.
1013
+ } finally {
1014
+ if (updateProcessState.phase === 'checking') updateProcessState.phase = 'idle';
1015
+ }
1016
+ return updateCheckState;
1017
+ }
1018
+
1019
+ async function runUpdateNowInternal() {
1020
+ if (updateProcessState.phase === 'installing') {
1021
+ return { ...updateProcessState, alreadyInstalling: true, error: 'update already in progress' };
1022
+ }
1023
+ updateProcessState = { phase: 'installing', version: null, error: null };
1024
+ try {
1025
+ const result = await runGlobalUpdate();
1026
+ if (result?.ok) {
1027
+ updateProcessState = { phase: 'installed', version: result.version || null, error: null };
1028
+ } else {
1029
+ updateProcessState = { phase: 'failed', version: null, error: result?.error || 'update failed' };
1030
+ }
1031
+ } catch (err) {
1032
+ updateProcessState = { phase: 'failed', version: null, error: err?.message || String(err) };
1033
+ }
1034
+ return updateProcessState;
1035
+ }
1036
+
1037
+ // Non-blocking boot hook: fires after the runtime object below is fully
1038
+ // constructed (setTimeout(0) defers past the synchronous return), so a
1039
+ // slow/hanging registry request or npm install can never delay session
1040
+ // boot. Auto-update defaults to OFF (config.update.auto is undefined until
1041
+ // the user opts in via setAutoUpdate(true)); a failed check or install is
1042
+ // swallowed — getUpdateStatus()/getUpdateSettings() are the only surfaces,
1043
+ // there is no push notice channel from runtime -> TUI today.
1044
+ const updateBootTimer = setTimeout(() => {
1045
+ void (async () => {
1046
+ await checkForUpdateInternal({ force: false });
1047
+ if (autoUpdateEnabled() && updateCheckState.updateAvailable) {
1048
+ await runUpdateNowInternal();
1049
+ }
1050
+ })().catch(() => {});
1051
+ }, 0);
1052
+ updateBootTimer.unref?.();
1053
+
2687
1054
  function mcpTransportLabel(cfg = {}) {
2688
1055
  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';
1056
+ try {
1057
+ return mcpClient.resolveMcpTransportKind(cfg);
1058
+ } catch {
1059
+ return 'unknown';
1060
+ }
2692
1061
  }
2693
1062
 
2694
1063
  function emitRuntimeNotification(content, meta = {}) {
@@ -2733,9 +1102,7 @@ export async function createMixdogSessionRuntime({
2733
1102
  }
2734
1103
 
2735
1104
  function mcpStatus() {
2736
- const configured = config?.mcpServers && typeof config.mcpServers === 'object'
2737
- ? config.mcpServers
2738
- : {};
1105
+ const { servers: configured, sources } = resolveEffectiveMcpServers();
2739
1106
  const connected = new Map((mcpClient.getMcpServerStatus?.() || []).map((row) => [row.name, row]));
2740
1107
  const failures = new Map((mcpFailures || []).map((row) => [row.name, row]));
2741
1108
  const servers = [];
@@ -2752,6 +1119,7 @@ export async function createMixdogSessionRuntime({
2752
1119
  toolCount: live?.toolCount || 0,
2753
1120
  tools: live?.tools || [],
2754
1121
  error: fail?.msg || null,
1122
+ source: sources[name] || 'config',
2755
1123
  });
2756
1124
  connected.delete(name);
2757
1125
  }
@@ -2866,6 +1234,26 @@ export async function createMixdogSessionRuntime({
2866
1234
  } else {
2867
1235
  scheduleCodeGraphPrewarm(changed ? 0 : codeGraphPrewarmDelayMs, changed ? 'cwd-change' : 'cwd');
2868
1236
  }
1237
+ // Project-local `.mcp.json` follows the cwd: when the effective project MCP
1238
+ // set changes, reconnect in the background (never await — this stays sync,
1239
+ // and the session is preserved). Guarded so a no-op cwd change does not
1240
+ // churn connections.
1241
+ if (changed) {
1242
+ try {
1243
+ const nextKey = resolved + '\u0000' + JSON.stringify(readProjectMcpServers(resolved));
1244
+ if (nextKey !== lastProjectMcpKey) {
1245
+ lastProjectMcpKey = nextKey;
1246
+ void connectConfiguredMcp({ reset: true })
1247
+ .then(() => invalidatePreSessionToolSurface())
1248
+ .catch(() => {});
1249
+ }
1250
+ } catch {}
1251
+ }
1252
+ // CwdChanged: bridge an effective cwd switch to the standard hook bus.
1253
+ // No matcher event — payload is minimal { cwd }. Fire-and-forget.
1254
+ if (changed) {
1255
+ try { void hooks.dispatch('CwdChanged', hookCommonPayload({ cwd: currentCwd })); } catch {}
1256
+ }
2869
1257
  return currentCwd;
2870
1258
  }
2871
1259
 
@@ -2879,17 +1267,23 @@ export async function createMixdogSessionRuntime({
2879
1267
  }
2880
1268
 
2881
1269
  function skillContent(name) {
2882
- const content = typeof contextMod.loadSkillContent === 'function'
2883
- ? contextMod.loadSkillContent(name, currentCwd)
1270
+ const res = typeof contextMod.loadSkillResource === 'function'
1271
+ ? contextMod.loadSkillResource(name, currentCwd)
2884
1272
  : null;
2885
- if (!content) throw new Error(`skill not found: ${name}`);
2886
- return { name, content };
1273
+ if (!res) throw new Error(`skill not found: ${name}`);
1274
+ return { name, content: res.content, dir: res.dir };
2887
1275
  }
2888
1276
 
2889
1277
  function skillToolContent(name) {
2890
1278
  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>`;
1279
+ // Return the general tool envelope so the main/Lead session behaves the
1280
+ // same as agent-loop sessions: the model-visible tool_result is the short
1281
+ // stub (`Loaded skill: <name>`) and the full SKILL.md body is delivered
1282
+ // ONCE as a separate injected role:'user' message (newMessages). The
1283
+ // envelope passes through internal-tools._normalize untouched (it preserves
1284
+ // __toolEnvelope objects), and the agent loop's central normalizeToolEnvelope
1285
+ // splits it into stub + injected user body.
1286
+ return contextMod.buildSkillToolEnvelope(skill.name, skill.content, skill.dir);
2893
1287
  }
2894
1288
 
2895
1289
  function addProjectSkill(input = {}) {
@@ -2943,7 +1337,8 @@ export async function createMixdogSessionRuntime({
2943
1337
  mcpScript: mcpScriptForPlugin(root),
2944
1338
  };
2945
1339
  plugin.mcpServerName = pluginMcpServerName(plugin);
2946
- plugin.mcpEnabled = Object.prototype.hasOwnProperty.call(configuredMcp, plugin.mcpServerName);
1340
+ plugin.mcpEnabled = Object.prototype.hasOwnProperty.call(configuredMcp, plugin.mcpServerName)
1341
+ || Object.keys(configuredMcp).some((k) => k.startsWith(`${plugin.mcpServerName}--`));
2947
1342
  plugins.push(plugin);
2948
1343
  };
2949
1344
 
@@ -2964,19 +1359,53 @@ export async function createMixdogSessionRuntime({
2964
1359
  };
2965
1360
  }
2966
1361
 
2967
- async function connectConfiguredMcp({ reset = false } = {}) {
2968
- if (reset) await mcpClient.disconnectAll?.();
2969
- mcpFailures = [];
2970
- const servers = config?.mcpServers && typeof config.mcpServers === 'object'
1362
+ // Merge mixdog-config `agent.mcpServers` with project-local `.mcp.json`.
1363
+ // On name collision the project-local `.mcp.json` entry WINS (Claude Code
1364
+ // precedence: project > user config). `sources[name]` records each server's
1365
+ // origin ('config' | 'project') for status reporting.
1366
+ function resolveEffectiveMcpServers() {
1367
+ const configured = config?.mcpServers && typeof config.mcpServers === 'object'
2971
1368
  ? config.mcpServers
2972
1369
  : {};
2973
- if (Object.keys(servers).length === 0) return mcpStatus();
1370
+ const project = readProjectMcpServers(currentCwd);
1371
+ const servers = { ...configured, ...project };
1372
+ const sources = {};
1373
+ for (const name of Object.keys(configured)) sources[name] = 'config';
1374
+ for (const name of Object.keys(project)) sources[name] = 'project';
1375
+ return { servers, sources };
1376
+ }
1377
+
1378
+ async function connectConfiguredMcp({ reset = false } = {}) {
1379
+ // Serialize reconnects: boot connect, cwd-change reset, and rapid cwd
1380
+ // switches must never interleave their disconnect/connect phases, or an
1381
+ // older run finishing after a newer reset could re-add stale servers into
1382
+ // the shared client registry. Approach: a generation token + a single
1383
+ // in-flight promise. Each call bumps the generation, waits for any prior
1384
+ // run to finish, then bails if a newer call has superseded it — leaving the
1385
+ // latest requested effective-server-set in the registry.
1386
+ const gen = ++mcpConnectGeneration;
1387
+ if (mcpConnectInFlight) {
1388
+ try { await mcpConnectInFlight; } catch { /* prior run's failures already captured */ }
1389
+ }
1390
+ if (gen !== mcpConnectGeneration) return mcpStatus();
1391
+ const run = (async () => {
1392
+ if (reset) await mcpClient.disconnectAll?.();
1393
+ mcpFailures = [];
1394
+ const { servers } = resolveEffectiveMcpServers();
1395
+ if (Object.keys(servers).length === 0) return;
1396
+ try {
1397
+ await mcpClient.connectMcpServers(servers);
1398
+ } catch (error) {
1399
+ mcpFailures = Array.isArray(error?.failures)
1400
+ ? error.failures
1401
+ : [{ name: 'mcp', msg: error?.message || String(error) }];
1402
+ }
1403
+ })();
1404
+ mcpConnectInFlight = run;
2974
1405
  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) }];
1406
+ await run;
1407
+ } finally {
1408
+ if (mcpConnectInFlight === run) mcpConnectInFlight = null;
2980
1409
  }
2981
1410
  return mcpStatus();
2982
1411
  }
@@ -2984,10 +1413,42 @@ export async function createMixdogSessionRuntime({
2984
1413
  function normalizeMcpServerInput(input = {}) {
2985
1414
  const name = clean(input.name).toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
2986
1415
  if (!name) throw new Error('MCP server name is required');
1416
+ const coerceStringRecord = (value) => {
1417
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
1418
+ const out = {};
1419
+ for (const [key, val] of Object.entries(value)) {
1420
+ if (val === undefined || val === null) continue;
1421
+ out[String(key)] = String(val);
1422
+ }
1423
+ return Object.keys(out).length > 0 ? out : null;
1424
+ };
1425
+ const withOptionalHeaders = (config) => {
1426
+ const headers = coerceStringRecord(input.headers);
1427
+ if (headers) config.headers = headers;
1428
+ return config;
1429
+ };
2987
1430
  const url = clean(input.url);
1431
+ const type = clean(input.type).toLowerCase();
2988
1432
  if (url) {
1433
+ if (type === 'sse') {
1434
+ if (!/^https?:\/\//i.test(url)) throw new Error('MCP URL must start with http:// or https://');
1435
+ return { name, config: withOptionalHeaders({ type: 'sse', url }) };
1436
+ }
1437
+ if (type === 'ws') {
1438
+ if (!/^(?:wss?|https?):\/\//i.test(url)) {
1439
+ throw new Error('MCP WebSocket URL must start with ws://, wss://, http://, or https://');
1440
+ }
1441
+ return { name, config: withOptionalHeaders({ type: 'ws', url }) };
1442
+ }
1443
+ if (type === 'http' || type === 'streamable-http') {
1444
+ if (!/^https?:\/\//i.test(url)) throw new Error('MCP URL must start with http:// or https://');
1445
+ return { name, config: withOptionalHeaders({ type: 'http', url }) };
1446
+ }
1447
+ if (/^wss?:\/\//i.test(url)) {
1448
+ return { name, config: withOptionalHeaders({ type: 'ws', url }) };
1449
+ }
2989
1450
  if (!/^https?:\/\//i.test(url)) throw new Error('MCP URL must start with http:// or https://');
2990
- return { name, config: { transport: 'http', url } };
1451
+ return { name, config: withOptionalHeaders({ type: 'http', url }) };
2991
1452
  }
2992
1453
  const command = clean(input.command);
2993
1454
  if (!command) throw new Error('MCP server command or URL is required');
@@ -3001,7 +1462,10 @@ export async function createMixdogSessionRuntime({
3001
1462
  if (resolvedCwd !== root && !resolvedCwd.startsWith(`${root}\\`) && !resolvedCwd.startsWith(`${root}/`)) {
3002
1463
  throw new Error('MCP server cwd must stay under the current project');
3003
1464
  }
3004
- return { name, config: { command, args, cwd: resolvedCwd } };
1465
+ const config = { type: 'stdio', command, args, cwd: resolvedCwd };
1466
+ const env = coerceStringRecord(input.env);
1467
+ if (env) config.env = env;
1468
+ return { name, config };
3005
1469
  }
3006
1470
 
3007
1471
  const agentToolStartedAt = performance.now();
@@ -3011,6 +1475,18 @@ export async function createMixdogSessionRuntime({
3011
1475
  mgr,
3012
1476
  dataDir: cfgMod.getPluginData(),
3013
1477
  cwd,
1478
+ // SubagentStart/SubagentStop: bridge internal worker spawn/finish to the
1479
+ // standard hook bus. agent_type is passed top-level via hookCommonPayload
1480
+ // (added to hook-bus buildEventPayload passthrough). Best-effort.
1481
+ onSubagentEvent: (phase, info = {}) => {
1482
+ try {
1483
+ const event = phase === 'stop' ? 'SubagentStop' : 'SubagentStart';
1484
+ void hooks.dispatch(event, hookCommonPayload({
1485
+ session_id: info?.session_id || null,
1486
+ agent_type: info?.agent_type || null,
1487
+ }));
1488
+ } catch { /* best-effort: subagent hook must never affect worker lifecycle */ }
1489
+ },
3014
1490
  });
3015
1491
  bootProfile('agent:ready', { ms: (performance.now() - agentToolStartedAt).toFixed(1) });
3016
1492
  const agentStatusState = () => {
@@ -3035,10 +1511,12 @@ export async function createMixdogSessionRuntime({
3035
1511
  if (msg?.method !== 'notifications/claude/channel') return;
3036
1512
  const params = msg?.params && typeof msg.params === 'object' ? msg.params : {};
3037
1513
  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);
1514
+ const content = channelNotificationModelContent(params);
1515
+ if (!content) return;
1516
+ const handled = emitRuntimeNotification(content, meta);
1517
+ if (!handled && session?.id && shouldMirrorChannelNotificationToPending(meta)) {
1518
+ try { mgr.enqueuePendingMessage(session.id, content); } catch {}
1519
+ }
3042
1520
  },
3043
1521
  });
3044
1522
  bootProfile('channels:worker-ready', { ms: (performance.now() - channelsStartedAt).toFixed(1) });
@@ -3169,6 +1647,7 @@ export async function createMixdogSessionRuntime({
3169
1647
  },
3170
1648
  });
3171
1649
  internalTools.markBootReady?.();
1650
+ try { lastProjectMcpKey = resolve(currentCwd) + '\u0000' + JSON.stringify(readProjectMcpServers(currentCwd)); } catch { lastProjectMcpKey = null; }
3172
1651
  void connectConfiguredMcp()
3173
1652
  .then((status) => bootProfile('mcp:ready', {
3174
1653
  connected: Number(status?.connectedCount || 0),
@@ -3183,8 +1662,8 @@ export async function createMixdogSessionRuntime({
3183
1662
  function invalidateProviderCaches() {
3184
1663
  providerModelsCache = { models: null, at: 0 };
3185
1664
  providerModelsPromise = null;
1665
+ providerModelsLoadSeq += 1;
3186
1666
  searchProviderModelsCache = { models: null, at: 0 };
3187
- searchProviderModelsPromise = null;
3188
1667
  usageDashboardCache = { dashboard: null, at: 0 };
3189
1668
  usageDashboardPromise = null;
3190
1669
  providerSetupCache = { setup: null, at: 0 };
@@ -3216,6 +1695,58 @@ export async function createMixdogSessionRuntime({
3216
1695
  let pendingConfigToSave = null;
3217
1696
  let configSaveTimer = null;
3218
1697
  const CONFIG_SAVE_DEBOUNCE_MS = 150;
1698
+ // Same debounce pattern as saveConfigAndAdopt below, but for the channels-
1699
+ // section backend switch: channel-admin's setBackend() does a synchronous
1700
+ // file-locked read-modify-write (updateChannelsSection), which is a
1701
+ // secondary hitch source on the settings-toggle key handler. Coalesce
1702
+ // rapid toggles and move the write off the key-handler tick.
1703
+ let pendingBackendName = null;
1704
+ let backendSaveTimer = null;
1705
+ function flushBackendSave() {
1706
+ if (backendSaveTimer) {
1707
+ clearTimeout(backendSaveTimer);
1708
+ backendSaveTimer = null;
1709
+ }
1710
+ if (pendingBackendName === null) return;
1711
+ const name = pendingBackendName;
1712
+ pendingBackendName = null;
1713
+ try {
1714
+ setBackend(name);
1715
+ } catch (err) {
1716
+ process.stderr.write(`[channels] debounced setBackend failed: ${err?.message || err}\n`);
1717
+ }
1718
+ }
1719
+ // Debounced persist for the top-level `outputStyle` config key. This CANNOT
1720
+ // ride saveConfigAndAdopt/flushConfigSave: cfgMod.saveConfig() serializes
1721
+ // ONLY the agent-section fields (see orchestrator config.mjs saveConfig),
1722
+ // so a top-level outputStyle adopted in memory would never reach disk and
1723
+ // would silently revert on the next fresh catalog read/restart. Persist it
1724
+ // through sharedCfgMod.updateConfig (whole-root RMW under the same file
1725
+ // lock), debounced off the key-handler tick like the backend switch above.
1726
+ let pendingOutputStyleId = null;
1727
+ let outputStyleSaveTimer = null;
1728
+ function flushOutputStyleSave() {
1729
+ if (outputStyleSaveTimer) {
1730
+ clearTimeout(outputStyleSaveTimer);
1731
+ outputStyleSaveTimer = null;
1732
+ }
1733
+ if (pendingOutputStyleId === null) return;
1734
+ const styleId = pendingOutputStyleId;
1735
+ pendingOutputStyleId = null;
1736
+ try {
1737
+ sharedCfgMod.updateConfig((root) => {
1738
+ const next = { ...(root || {}), outputStyle: styleId };
1739
+ if (next.agent && typeof next.agent === 'object' && !Array.isArray(next.agent)) {
1740
+ const agent = { ...next.agent };
1741
+ delete agent.outputStyle;
1742
+ next.agent = agent;
1743
+ }
1744
+ return next;
1745
+ });
1746
+ } catch (err) {
1747
+ process.stderr.write(`[config] debounced outputStyle save failed: ${err?.message || err}\n`);
1748
+ }
1749
+ }
3219
1750
  function flushConfigSave() {
3220
1751
  if (configSaveTimer) {
3221
1752
  clearTimeout(configSaveTimer);
@@ -3631,17 +2162,11 @@ function parsedProviderModelVersion(id) {
3631
2162
  searchProviderModelsCache = { models: rows, at: Date.now() };
3632
2163
  return providerModelsFromCacheRows(rows);
3633
2164
  }
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
- });
2165
+ if (force) {
2166
+ const models = await loadSearchProviderModelsFresh({ forceRefresh: true });
2167
+ searchProviderModelsCache = { models, at: Date.now() };
2168
+ return providerModelsFromCacheRows(models);
3643
2169
  }
3644
- return providerModelsFromCacheRows(await searchProviderModelsPromise);
3645
2170
  }
3646
2171
 
3647
2172
  function enabledSearchProviderConfig() {
@@ -3655,7 +2180,7 @@ function parsedProviderModelVersion(id) {
3655
2180
  return out;
3656
2181
  }
3657
2182
 
3658
- async function loadSearchProviderModelsFresh() {
2183
+ async function loadSearchProviderModelsFresh({ forceRefresh = false } = {}) {
3659
2184
  const searchProviders = enabledSearchProviderConfig();
3660
2185
  const providerNames = Object.keys(searchProviders);
3661
2186
  if (!providerNames.length) return [];
@@ -3664,7 +2189,13 @@ function parsedProviderModelVersion(id) {
3664
2189
  const provider = reg.getProvider(name);
3665
2190
  if (typeof provider?.listModels !== 'function') return [];
3666
2191
  try {
3667
- const models = await provider.listModels();
2192
+ let models = null;
2193
+ if (forceRefresh && typeof provider._refreshModelCache === 'function') {
2194
+ models = await provider._refreshModelCache();
2195
+ }
2196
+ if (!Array.isArray(models)) {
2197
+ models = await provider.listModels();
2198
+ }
3668
2199
  if (!Array.isArray(models)) return [];
3669
2200
  const rows = [];
3670
2201
  for (const m of models) {
@@ -3698,14 +2229,20 @@ function parsedProviderModelVersion(id) {
3698
2229
  return results;
3699
2230
  }
3700
2231
 
3701
- async function loadProviderModelsFresh() {
2232
+ async function loadProviderModelsFresh({ forceRefresh = false } = {}) {
3702
2233
  ensureFullConfig();
3703
2234
  await ensureProvidersReady(config.providers || {});
3704
2235
  const allProviders = [...reg.getAllProviders()];
3705
2236
  const providerResults = await Promise.all(allProviders.map(async ([name, provider]) => {
3706
2237
  if (typeof provider?.listModels !== 'function') return [];
3707
2238
  try {
3708
- const models = await provider.listModels();
2239
+ let models = null;
2240
+ if (forceRefresh && typeof provider._refreshModelCache === 'function') {
2241
+ models = await provider._refreshModelCache();
2242
+ }
2243
+ if (!Array.isArray(models)) {
2244
+ models = await provider.listModels();
2245
+ }
3709
2246
  if (!Array.isArray(models)) return [];
3710
2247
  const rows = [];
3711
2248
  for (const m of models) {
@@ -3740,10 +2277,17 @@ function parsedProviderModelVersion(id) {
3740
2277
  warmProviderModelCache();
3741
2278
  return quickProviderModelRows();
3742
2279
  }
2280
+ if (force) {
2281
+ const seq = ++providerModelsLoadSeq;
2282
+ const models = await loadProviderModelsFresh({ forceRefresh: true });
2283
+ if (seq === providerModelsLoadSeq) providerModelsCache = { models, at: Date.now() };
2284
+ return providerModelsFromCacheRows(models);
2285
+ }
3743
2286
  if (!providerModelsPromise) {
2287
+ const seq = ++providerModelsLoadSeq;
3744
2288
  providerModelsPromise = loadProviderModelsFresh()
3745
2289
  .then((models) => {
3746
- providerModelsCache = { models, at: Date.now() };
2290
+ if (seq === providerModelsLoadSeq) providerModelsCache = { models, at: Date.now() };
3747
2291
  return models;
3748
2292
  })
3749
2293
  .finally(() => {
@@ -3755,9 +2299,10 @@ function parsedProviderModelVersion(id) {
3755
2299
 
3756
2300
  function warmProviderModelCache() {
3757
2301
  if (Array.isArray(providerModelsCache.models) || providerModelsPromise) return providerModelsPromise;
2302
+ const seq = ++providerModelsLoadSeq;
3758
2303
  providerModelsPromise = loadProviderModelsFresh()
3759
2304
  .then((models) => {
3760
- providerModelsCache = { models, at: Date.now() };
2305
+ if (seq === providerModelsLoadSeq) providerModelsCache = { models, at: Date.now() };
3761
2306
  bootProfile('provider-models:warm-ready', { count: models.length });
3762
2307
  return models;
3763
2308
  })
@@ -3794,12 +2339,22 @@ function parsedProviderModelVersion(id) {
3794
2339
  const requested = hasOwn(route, 'effort') ? route.effort : (route.preset?.effort || null);
3795
2340
  const effectiveEffort = coerceEffortFor(route.provider, modelMeta, requested);
3796
2341
  const fastCapable = fastCapableFor(route.provider, modelMeta);
2342
+ // Carry the catalog display name onto the route so the statusline shows a
2343
+ // human label (e.g. "Claude Fable 5") for preset-less direct models instead
2344
+ // of the raw id. `name` is only trusted when it differs from the raw model
2345
+ // id (some providers echo the id as `name`), so it can't clobber a better
2346
+ // already-resolved label. Falls back to existing route.modelDisplay, then unset.
2347
+ const metaName = clean(modelMeta?.name);
2348
+ const modelDisplay = clean(modelMeta?.display) || clean(modelMeta?.displayName)
2349
+ || (metaName && metaName !== clean(route.model) ? metaName : '')
2350
+ || clean(route.modelDisplay);
3797
2351
  route = {
3798
2352
  ...route,
3799
2353
  fast: fastCapable ? route.fast === true : false,
3800
2354
  fastCapable,
3801
2355
  effectiveEffort,
3802
2356
  effortOptions: effortItemsFor(route.provider, modelMeta, effectiveEffort),
2357
+ ...(modelDisplay ? { modelDisplay } : {}),
3803
2358
  };
3804
2359
  return route;
3805
2360
  }
@@ -3857,7 +2412,7 @@ function parsedProviderModelVersion(id) {
3857
2412
  preset: route.preset || undefined,
3858
2413
  tools: toolSpecForMode(mode),
3859
2414
  owner: 'cli',
3860
- role: 'lead',
2415
+ agent: 'lead',
3861
2416
  lane: 'cli',
3862
2417
  sourceType: 'lead',
3863
2418
  sourceName: 'main',
@@ -3906,11 +2461,61 @@ function parsedProviderModelVersion(id) {
3906
2461
  configurable: true,
3907
2462
  writable: true,
3908
2463
  });
2464
+ // PostToolUseFailure: dispatched by loop.mjs only when a tool execution
2465
+ // resolved to a failure (thrown-error path or an is_error result). Same
2466
+ // shape as afterToolHook; `result` carries the error text. Best-effort.
2467
+ Object.defineProperty(session, 'afterToolFailureHook', {
2468
+ value: (input) => hooks.dispatch('PostToolUseFailure', hookCommonPayload({
2469
+ session_id: input?.sessionId || input?.session_id || session?.id,
2470
+ cwd: input?.cwd || currentCwd,
2471
+ tool_name: input?.name,
2472
+ tool_input: input?.args,
2473
+ tool_use_id: input?.toolCallId || input?.tool_use_id,
2474
+ tool_response: input?.result,
2475
+ })),
2476
+ enumerable: false,
2477
+ configurable: true,
2478
+ writable: true,
2479
+ });
2480
+ // PostToolBatch: dispatched by loop.mjs after a full parallel batch of
2481
+ // tool calls resolves and before the next model call. No matcher event.
2482
+ Object.defineProperty(session, 'afterToolBatchHook', {
2483
+ value: (input) => hooks.dispatch('PostToolBatch', hookCommonPayload({
2484
+ session_id: input?.sessionId || input?.session_id || session?.id,
2485
+ cwd: input?.cwd || currentCwd,
2486
+ })),
2487
+ enumerable: false,
2488
+ configurable: true,
2489
+ writable: true,
2490
+ });
2491
+ // PreCompact / PostCompact: dispatched by manager.mjs/loop.mjs compaction
2492
+ // flow via these session-property hooks (manager has no hooks bus access).
2493
+ // payload { trigger: 'auto' | 'manual' }. Best-effort.
2494
+ Object.defineProperty(session, 'preCompactHook', {
2495
+ value: (input) => hooks.dispatch('PreCompact', hookCommonPayload({
2496
+ session_id: input?.sessionId || input?.session_id || session?.id,
2497
+ cwd: input?.cwd || currentCwd,
2498
+ trigger: input?.trigger || 'auto',
2499
+ })),
2500
+ enumerable: false,
2501
+ configurable: true,
2502
+ writable: true,
2503
+ });
2504
+ Object.defineProperty(session, 'postCompactHook', {
2505
+ value: (input) => hooks.dispatch('PostCompact', hookCommonPayload({
2506
+ session_id: input?.sessionId || input?.session_id || session?.id,
2507
+ cwd: input?.cwd || currentCwd,
2508
+ trigger: input?.trigger || 'auto',
2509
+ })),
2510
+ enumerable: false,
2511
+ configurable: true,
2512
+ writable: true,
2513
+ });
3909
2514
  applyDeferredToolSurface(session, deferredSurfaceModeForLead(mode), standaloneTools, { provider: route.provider });
3910
2515
  applyPreSessionToolSelection();
3911
2516
  writeStatuslineRoute(statusRoutes, session, route);
3912
2517
  hooks.emit('session:create', { sessionId: session.id, provider: route.provider, model: route.model, toolMode: mode, cwd: currentCwd });
3913
- // SessionStart: bridge to the standard Claude Code hook bus. Best-effort;
2518
+ // SessionStart: bridge to the standard project hook bus. Best-effort;
3914
2519
  // a hook error must never break session creation. additionalContext is
3915
2520
  // injected before the first user turn as a system-reminder context pair.
3916
2521
  try {
@@ -4021,6 +2626,27 @@ function parsedProviderModelVersion(id) {
4021
2626
  providerModelWarmupTimer.unref?.();
4022
2627
  }
4023
2628
 
2629
+ function scheduleModelCatalogWarmup(delayMs = modelCatalogWarmupDelayMs) {
2630
+ if (!modelCatalogWarmupEnabled) {
2631
+ bootProfile('model-catalog:warm-skipped', { reason: 'disabled' });
2632
+ return;
2633
+ }
2634
+ if (modelCatalogWarmupTimer || closeRequested) return;
2635
+ modelCatalogWarmupTimer = setTimeout(() => {
2636
+ modelCatalogWarmupTimer = null;
2637
+ if (closeRequested) return;
2638
+ if (activeTurnCount > 0 || sessionCreatePromise) {
2639
+ bootProfile('model-catalog:warm-deferred', { reason: activeTurnCount > 0 ? 'turn-active' : 'session-create' });
2640
+ scheduleModelCatalogWarmup(backgroundBusyRetryMs);
2641
+ return;
2642
+ }
2643
+ void warmCatalogsInBackground()
2644
+ .then(() => bootProfile('model-catalog:warm-ready'))
2645
+ .catch((error) => bootProfile('model-catalog:warm-failed', { error: error?.message || String(error) }));
2646
+ }, delayMs);
2647
+ modelCatalogWarmupTimer.unref?.();
2648
+ }
2649
+
4024
2650
  function scheduleStatuslineUsageWarmup(delayMs = statuslineUsageWarmupDelayMs) {
4025
2651
  const providerId = clean(route?.provider);
4026
2652
  if (!providerId || !providerId.includes('oauth')) {
@@ -4081,6 +2707,22 @@ function parsedProviderModelVersion(id) {
4081
2707
  statuslineUsageRefreshTimer.unref?.();
4082
2708
  }
4083
2709
 
2710
+ function invokeChannelStart() {
2711
+ if (channelStartPromise) return channelStartPromise;
2712
+ const startedAt = performance.now();
2713
+ bootProfile('channels:start:begin');
2714
+ channelStartPromise = channels.start()
2715
+ .then(() => bootProfile('channels:start:ready', { ms: (performance.now() - startedAt).toFixed(1) }))
2716
+ .catch((error) => bootProfile('channels:start:failed', {
2717
+ ms: (performance.now() - startedAt).toFixed(1),
2718
+ error: error?.message || String(error),
2719
+ }))
2720
+ .finally(() => {
2721
+ channelStartPromise = null;
2722
+ });
2723
+ return channelStartPromise;
2724
+ }
2725
+
4084
2726
  function scheduleChannelStart(delayMs = channelStartDelayMs) {
4085
2727
  if (envFlag('MIXDOG_DISABLE_CHANNEL_START')) {
4086
2728
  bootProfile('channels:start-skipped');
@@ -4090,28 +2732,63 @@ function parsedProviderModelVersion(id) {
4090
2732
  bootProfile('channels:start-disabled');
4091
2733
  return;
4092
2734
  }
4093
- if (channelStartTimer || closeRequested) return;
2735
+ if (channelStartTimer || channelStartPromise || closeRequested) return;
4094
2736
  bootProfile('channels:start-scheduled', { delayMs });
4095
2737
  channelStartTimer = setTimeout(() => {
4096
2738
  channelStartTimer = null;
4097
2739
  if (closeRequested) return;
2740
+ // A deferred start may straddle a stopRemote(); re-check before booting so
2741
+ // a turned-off session neither starts channels nor keeps rescheduling.
2742
+ if (!remoteEnabled) return;
4098
2743
  if (activeTurnCount > 0 || sessionCreatePromise) {
4099
2744
  bootProfile('channels:start-deferred', { reason: activeTurnCount > 0 ? 'turn-active' : 'session-create' });
4100
2745
  scheduleChannelStart(backgroundBusyRetryMs);
4101
2746
  return;
4102
2747
  }
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
- }));
2748
+ void invokeChannelStart();
4111
2749
  }, delayMs);
4112
2750
  channelStartTimer.unref?.();
4113
2751
  }
4114
2752
 
2753
+ // Remote (Discord channel) mode is opt-in per session. Only a session that
2754
+ // explicitly enables remote — via `mixdog --remote` or the runtime toggle —
2755
+ // boots the channel worker and contends for channel ownership.
2756
+ function startRemote() {
2757
+ remoteEnabled = true;
2758
+ // A backend switch may still be sitting in its debounce window; flush it
2759
+ // so the channel worker boots against the backend the user just chose,
2760
+ // not the previous on-disk value.
2761
+ try { flushBackendSave(); } catch {}
2762
+ if (envFlag('MIXDOG_DISABLE_CHANNEL_START')) {
2763
+ bootProfile('channels:start-skipped');
2764
+ return true;
2765
+ }
2766
+ if (!channelsEnabled()) {
2767
+ bootProfile('channels:start-disabled');
2768
+ return true;
2769
+ }
2770
+ if (closeRequested) return true;
2771
+ if (channelStartTimer) {
2772
+ clearTimeout(channelStartTimer);
2773
+ channelStartTimer = null;
2774
+ }
2775
+ bootProfile('channels:start-scheduled', { delayMs: 0, immediate: true });
2776
+ void invokeChannelStart();
2777
+ return true;
2778
+ }
2779
+
2780
+ function stopRemote(reason) {
2781
+ remoteEnabled = false;
2782
+ // Cancel any pending deferred start so it can't fire after remote is off.
2783
+ if (channelStartTimer) { clearTimeout(channelStartTimer); channelStartTimer = null; }
2784
+ channels.stop(reason || 'remote-disabled', { waitForExit: false }).catch(() => {});
2785
+ return true;
2786
+ }
2787
+
2788
+ function isRemoteEnabled() {
2789
+ return remoteEnabled;
2790
+ }
2791
+
4115
2792
  function withTeardownDeadline(promise, ms, fallback = false) {
4116
2793
  let timer = null;
4117
2794
  return Promise.race([
@@ -4139,6 +2816,7 @@ function parsedProviderModelVersion(id) {
4139
2816
  bootProfile('code-graph:prewarm-lazy', { reason: 'startup-deferred-to-first-turn' });
4140
2817
  }
4141
2818
  scheduleProviderSetupWarmup();
2819
+ scheduleModelCatalogWarmup();
4142
2820
  scheduleProviderWarmup();
4143
2821
  // Warm the provider model catalog in the background, but keep it on its own
4144
2822
  // delay so short-lived detached runtimes can exit before /models I/O starts.
@@ -4146,7 +2824,10 @@ function parsedProviderModelVersion(id) {
4146
2824
  // MIXDOG_PROVIDER_MODEL_WARMUP_DELAY_MS explicitly.
4147
2825
  scheduleProviderModelWarmup();
4148
2826
  scheduleStatuslineUsageWarmup();
4149
- scheduleChannelStart();
2827
+ // Channels are opt-in: only boot the worker when this session started in (or
2828
+ // was toggled into) remote mode. Non-remote sessions never contend for the
2829
+ // channel; see startRemote()/stopRemote() and the `/remote` toggle.
2830
+ if (remoteEnabled) startRemote();
4150
2831
 
4151
2832
  function contextStatusCacheKeyFor({ messages, tools }) {
4152
2833
  const compaction = session?.compaction || {};
@@ -4173,10 +2854,12 @@ function parsedProviderModelVersion(id) {
4173
2854
  lastContextTokensUpdatedAt: Number(session?.lastContextTokensUpdatedAt || 0),
4174
2855
  lastContextTokensStaleAfterCompact: session?.lastContextTokensStaleAfterCompact === true,
4175
2856
  lastInputTokens: Number(session?.lastInputTokens || 0),
2857
+ lastUncachedInputTokens: Number(session?.lastUncachedInputTokens || 0),
4176
2858
  lastOutputTokens: Number(session?.lastOutputTokens || 0),
4177
2859
  lastCachedReadTokens: Number(session?.lastCachedReadTokens || 0),
4178
2860
  lastCacheWriteTokens: Number(session?.lastCacheWriteTokens || 0),
4179
2861
  totalInputTokens: Number(session?.totalInputTokens || 0),
2862
+ totalUncachedInputTokens: Number(session?.totalUncachedInputTokens || 0),
4180
2863
  totalOutputTokens: Number(session?.totalOutputTokens || 0),
4181
2864
  totalCachedReadTokens: Number(session?.totalCachedReadTokens || 0),
4182
2865
  totalCacheWriteTokens: Number(session?.totalCacheWriteTokens || 0),
@@ -4299,38 +2982,13 @@ function parsedProviderModelVersion(id) {
4299
2982
  // the gauge numerator.
4300
2983
  const usedTokens = estimatedContextTokens;
4301
2984
  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
2985
+ // Use the same shared compact-policy math as manager/loop. Do not trust
2986
+ // persisted trigger telemetry as an independent policy input: it is an
2987
+ // output snapshot and was the source of repeated /context false positives.
2988
+ const compactTriggerTokens = resolveCompactTriggerTokens(session || {}, compactBoundaryTokens) || 0;
2989
+ const compactBufferTokens = compactBoundaryTokens
4332
2990
  ? Math.max(0, compactBoundaryTokens - compactTriggerTokens)
4333
- : defaultCompactBufferTokens);
2991
+ : resolveCompactBufferTokens(compactBoundaryTokens, session?.compaction || {});
4334
2992
  const value = {
4335
2993
  sessionId: session?.id || null,
4336
2994
  provider: route.provider,
@@ -4365,11 +3023,13 @@ function parsedProviderModelVersion(id) {
4365
3023
  },
4366
3024
  usage: {
4367
3025
  lastInputTokens: Number(session?.lastInputTokens || 0),
3026
+ lastUncachedInputTokens: Number(session?.lastUncachedInputTokens || 0),
4368
3027
  lastOutputTokens: Number(session?.lastOutputTokens || 0),
4369
3028
  lastCachedReadTokens: Number(session?.lastCachedReadTokens || 0),
4370
3029
  lastCacheWriteTokens: Number(session?.lastCacheWriteTokens || 0),
4371
3030
  lastContextTokens,
4372
3031
  totalInputTokens: Number(session?.totalInputTokens || 0),
3032
+ totalUncachedInputTokens: Number(session?.totalUncachedInputTokens || 0),
4373
3033
  totalOutputTokens: Number(session?.totalOutputTokens || 0),
4374
3034
  totalCachedReadTokens: Number(session?.totalCachedReadTokens || 0),
4375
3035
  totalCacheWriteTokens: Number(session?.totalCacheWriteTokens || 0),
@@ -4445,6 +3105,21 @@ function parsedProviderModelVersion(id) {
4445
3105
  workflowRoutes: summarizeWorkflowRoutes(nextConfig),
4446
3106
  };
4447
3107
  },
3108
+ // Mark onboarding as done WITHOUT touching routes/agents/provider. Used by
3109
+ // the TUI "skip" (Esc) path so the wizard doesn't reappear next launch,
3110
+ // while leaving any existing config routes untouched.
3111
+ skipOnboarding() {
3112
+ const nextConfig = { ...config };
3113
+ nextConfig.onboarding = {
3114
+ ...(nextConfig.onboarding || {}),
3115
+ completed: true,
3116
+ version: ONBOARDING_VERSION,
3117
+ completedAt: new Date().toISOString(),
3118
+ skipped: true,
3119
+ };
3120
+ saveConfigAndAdopt(nextConfig);
3121
+ return this.getOnboardingStatus();
3122
+ },
4448
3123
  getAutoClear() {
4449
3124
  return normalizeAutoClearConfig(config.autoClear);
4450
3125
  },
@@ -4546,7 +3221,9 @@ function parsedProviderModelVersion(id) {
4546
3221
  }
4547
3222
  await channels.stop('settings-disabled', { waitForExit: false }).catch(() => {});
4548
3223
  } else {
4549
- scheduleChannelStart(0);
3224
+ // Enabling channels in settings only boots the worker when this session
3225
+ // is in remote mode; otherwise the toggle just persists config.
3226
+ if (remoteEnabled) scheduleChannelStart(0);
4550
3227
  }
4551
3228
  invalidatePreSessionToolSurface();
4552
3229
  return this.getChannelSettings();
@@ -4581,14 +3258,57 @@ function parsedProviderModelVersion(id) {
4581
3258
  saveConfigAndAdopt({ ...config, autoClear: next });
4582
3259
  return { ...normalizeAutoClearConfig(config.autoClear), label: formatDurationMs(normalizeAutoClearConfig(config.autoClear).idleMs) };
4583
3260
  },
3261
+ getUpdateSettings() {
3262
+ return {
3263
+ autoUpdate: autoUpdateEnabled(),
3264
+ currentVersion: updateCheckState.currentVersion || localPackageVersion(),
3265
+ latestVersion: updateCheckState.latestVersion,
3266
+ updateAvailable: updateCheckState.updateAvailable,
3267
+ lastCheckedAt: updateCheckState.lastCheckedAt,
3268
+ };
3269
+ },
3270
+ setAutoUpdate(enabled) {
3271
+ saveConfigAndAdopt({
3272
+ ...config,
3273
+ update: { ...(config.update || {}), auto: enabled === true },
3274
+ });
3275
+ return this.getUpdateSettings();
3276
+ },
3277
+ async checkForUpdate(options = {}) {
3278
+ await checkForUpdateInternal({ force: options?.force === true });
3279
+ return this.getUpdateSettings();
3280
+ },
3281
+ async runUpdateNow() {
3282
+ const state = await runUpdateNowInternal();
3283
+ return { ok: state.phase === 'installed', ...state };
3284
+ },
3285
+ getUpdateStatus() {
3286
+ return { ...updateProcessState };
3287
+ },
4584
3288
  async completeOnboarding(payload = {}) {
4585
- const defaultRoute = normalizeWorkflowRoute(payload.defaultRoute, route);
3289
+ // Only fall back to the live runtime route when the caller actually sent a
3290
+ // defaultRoute. The onboarding "partial save" path (Main left unset, only
3291
+ // Search/agent picks) omits defaultRoute entirely and must NOT persist the
3292
+ // current route as Main or recreate the session.
3293
+ const defaultRoute = hasOwn(payload, 'defaultRoute')
3294
+ ? normalizeWorkflowRoute(payload.defaultRoute, route)
3295
+ : null;
4586
3296
  const workflowInput = payload.workflowRoutes && typeof payload.workflowRoutes === 'object'
4587
3297
  ? payload.workflowRoutes
4588
3298
  : {};
4589
3299
  const nextConfig = { ...config };
3300
+ if (hasOwn(payload, 'defaultProvider')) {
3301
+ const requested = clean(payload.defaultProvider);
3302
+ if (requested) {
3303
+ if (!isKnownProvider(requested)) throw new Error(`unknown provider "${payload.defaultProvider}"`);
3304
+ nextConfig.defaultProvider = requested;
3305
+ }
3306
+ }
4590
3307
  let presets = Array.isArray(nextConfig.presets) ? nextConfig.presets.slice() : [];
4591
3308
  const workflowRoutes = { ...(nextConfig.workflowRoutes || {}) };
3309
+ // Track slots actually written THIS call so maintenance mirroring never
3310
+ // clobbers an existing slot the payload did not touch.
3311
+ const touchedWorkflowSlots = new Set();
4592
3312
 
4593
3313
  if (defaultRoute) {
4594
3314
  presets = upsertWorkflowPreset(presets, 'lead', defaultRoute);
@@ -4601,18 +3321,51 @@ function parsedProviderModelVersion(id) {
4601
3321
  if (!normalized) continue;
4602
3322
  workflowRoutes[slot] = normalized;
4603
3323
  presets = upsertWorkflowPreset(presets, slot, normalized);
3324
+ touchedWorkflowSlots.add(slot);
4604
3325
  }
4605
3326
 
4606
3327
  nextConfig.presets = presets;
4607
3328
  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.
3329
+ // Maintenance slots store a direct {provider, model} route. Only mirror a
3330
+ // slot that was actually written this call (touchedWorkflowSlots); an
3331
+ // untouched slot keeps its existing maintenance value.
4611
3332
  nextConfig.maintenance = {
4612
3333
  ...(nextConfig.maintenance || {}),
4613
- ...(workflowRoutes.explorer ? { explore: normalizeWorkflowRoute(workflowRoutes.explorer) } : {}),
4614
- ...(workflowRoutes.memory ? { memory: normalizeWorkflowRoute(workflowRoutes.memory) } : {}),
3334
+ ...(touchedWorkflowSlots.has('explorer') ? { explore: normalizeWorkflowRoute(workflowRoutes.explorer) } : {}),
3335
+ ...(touchedWorkflowSlots.has('memory') ? { memory: normalizeWorkflowRoute(workflowRoutes.memory) } : {}),
4615
3336
  };
3337
+ // Per-agent onboarding routes (id → route) for the full FIXED_AGENT_SLOTS
3338
+ // roster. Mirrors setAgentRoute persistence: config.agents[id], an
3339
+ // agent-<id> preset, and (for workflow-backed agents) the workflowRoute +
3340
+ // maintenance slot. Agents omitted from the payload inherit defaultRoute.
3341
+ const agentInput = payload.agentRoutes && typeof payload.agentRoutes === 'object'
3342
+ ? payload.agentRoutes
3343
+ : null;
3344
+ if (agentInput) {
3345
+ const nextAgents = { ...(nextConfig.agents || {}) };
3346
+ const nextMaintenance = { ...(nextConfig.maintenance || {}) };
3347
+ for (const agent of FIXED_AGENT_SLOTS) {
3348
+ // Persist ONLY agents with an explicit override in the payload. An
3349
+ // omitted agent is left untouched (existing value preserved, or
3350
+ // unwritten so the runtime dynamically follows the Main Model). Never
3351
+ // fall back to defaultRoute here — that would overwrite an agent the
3352
+ // user did not choose.
3353
+ const routeToSave = normalizeWorkflowRoute(agentInput[agent.id]);
3354
+ if (!routeToSave) continue;
3355
+ nextAgents[agent.id] = routeToSave;
3356
+ presets = upsertWorkflowPreset(presets, agentPresetSlot(agent.id), routeToSave);
3357
+ if (agent.workflowSlot) {
3358
+ workflowRoutes[agent.workflowSlot] = routeToSave;
3359
+ presets = upsertWorkflowPreset(presets, agent.workflowSlot, routeToSave);
3360
+ if (agent.id === 'explore') nextMaintenance.explore = routeToSave;
3361
+ if (agent.id === 'maintainer') nextMaintenance.memory = routeToSave;
3362
+ }
3363
+ }
3364
+ nextConfig.agents = nextAgents;
3365
+ nextConfig.presets = presets;
3366
+ nextConfig.workflowRoutes = workflowRoutes;
3367
+ nextConfig.maintenance = nextMaintenance;
3368
+ }
4616
3369
  nextConfig.onboarding = {
4617
3370
  ...(nextConfig.onboarding || {}),
4618
3371
  completed: true,
@@ -4620,6 +3373,14 @@ function parsedProviderModelVersion(id) {
4620
3373
  completedAt: new Date().toISOString(),
4621
3374
  };
4622
3375
 
3376
+ // Optional native web-search route. Only persisted when provided; mirrors
3377
+ // setSearchRoute's config write (skip provider capability validation here
3378
+ // since onboarding routes come from the vetted provider model list).
3379
+ if (payload.searchRoute) {
3380
+ const searchToSave = normalizeSearchRouteConfig(payload.searchRoute);
3381
+ if (searchToSave) nextConfig.searchRoute = searchToSave;
3382
+ }
3383
+
4623
3384
  saveConfigAndAdopt(nextConfig);
4624
3385
  if (defaultRoute) {
4625
3386
  route = resolveRoute(config, { provider: defaultRoute.provider, model: defaultRoute.model, effort: defaultRoute.effort });
@@ -4629,11 +3390,24 @@ function parsedProviderModelVersion(id) {
4629
3390
  return this.getOnboardingStatus();
4630
3391
  },
4631
3392
  getChannelSetup() {
3393
+ // Flush a pending debounced backend switch first so setup readers
3394
+ // (Settings → Channel Setting, remote toggles) never observe the
3395
+ // previous backend during the 150ms debounce window.
3396
+ try { flushBackendSave(); } catch {}
4632
3397
  return channelSetup();
4633
3398
  },
4634
3399
  getChannelWorkerStatus() {
4635
3400
  return channels.status();
4636
3401
  },
3402
+ startRemote() {
3403
+ return startRemote();
3404
+ },
3405
+ stopRemote(reason) {
3406
+ return stopRemote(reason);
3407
+ },
3408
+ isRemoteEnabled() {
3409
+ return isRemoteEnabled();
3410
+ },
4637
3411
  saveDiscordToken(token) {
4638
3412
  const result = saveDiscordToken(token);
4639
3413
  reloadChannelsSoon();
@@ -4644,6 +3418,33 @@ function parsedProviderModelVersion(id) {
4644
3418
  reloadChannelsSoon();
4645
3419
  return result;
4646
3420
  },
3421
+ saveTelegramToken(token) {
3422
+ const result = saveTelegramToken(token);
3423
+ reloadChannelsSoon();
3424
+ return result;
3425
+ },
3426
+ forgetTelegramToken() {
3427
+ const result = forgetTelegramToken();
3428
+ reloadChannelsSoon();
3429
+ return result;
3430
+ },
3431
+ setBackend(name) {
3432
+ // Validate synchronously (same contract as before: bad input throws on
3433
+ // this call so the TUI's try/catch can react immediately). The actual
3434
+ // channels-section file read-modify-write is the hitch source on the
3435
+ // settings-toggle key handler, so defer it through the same debounce
3436
+ // pattern as saveConfigAndAdopt/flushConfigSave instead of writing to
3437
+ // disk synchronously inside the key handler.
3438
+ const value = String(name || '').trim();
3439
+ if (value !== 'discord' && value !== 'telegram') {
3440
+ throw new Error('backend must be discord or telegram');
3441
+ }
3442
+ pendingBackendName = value;
3443
+ if (backendSaveTimer) clearTimeout(backendSaveTimer);
3444
+ backendSaveTimer = setTimeout(flushBackendSave, CONFIG_SAVE_DEBOUNCE_MS);
3445
+ backendSaveTimer.unref?.();
3446
+ return { ok: true, backend: value };
3447
+ },
4647
3448
  saveWebhookAuthtoken(token) {
4648
3449
  const result = saveWebhookAuthtoken(token);
4649
3450
  reloadChannelsSoon();
@@ -4756,6 +3557,12 @@ function parsedProviderModelVersion(id) {
4756
3557
  invalidateProviderCaches();
4757
3558
  return result;
4758
3559
  },
3560
+ async loginOpenCodeGoUsage() {
3561
+ const result = await loginOpenCodeGoUsage(cfgMod);
3562
+ reloadFullConfig();
3563
+ invalidateProviderCaches();
3564
+ return result;
3565
+ },
4759
3566
  setLocalProvider(providerId, opts) {
4760
3567
  const result = setLocalProvider(cfgMod, providerId, opts);
4761
3568
  reloadFullConfig();
@@ -4866,26 +3673,51 @@ function parsedProviderModelVersion(id) {
4866
3673
  const names = before.styles.map((style) => style.label || style.id).join(', ') || 'Default';
4867
3674
  throw new Error(`output style must be one of ${names}`);
4868
3675
  }
4869
- if (typeof sharedCfgMod.updateConfig !== 'function') throw new Error('output style config writer unavailable');
4870
- sharedCfgMod.updateConfig((root) => {
4871
- const next = { ...(root || {}), outputStyle: selected.id };
4872
- if (next.agent && typeof next.agent === 'object' && !Array.isArray(next.agent)) {
4873
- const agent = { ...next.agent };
4874
- delete agent.outputStyle;
4875
- next.agent = agent;
4876
- }
4877
- return next;
4878
- });
4879
- invalidateOutputStyleStatusCache();
3676
+ // Adopt in-memory immediately so same-tick readers see the new style;
3677
+ // persist off the key-handler tick. NOTE: the disk write goes through
3678
+ // the dedicated flushOutputStyleSave debounce (sharedCfgMod whole-root
3679
+ // RMW) cfgMod.saveConfig only serializes agent-section fields and
3680
+ // would drop the top-level outputStyle key.
3681
+ const nextConfig = { ...config, outputStyle: selected.id };
3682
+ if (nextConfig.agent && typeof nextConfig.agent === 'object' && !Array.isArray(nextConfig.agent)) {
3683
+ const agent = { ...nextConfig.agent };
3684
+ delete agent.outputStyle;
3685
+ nextConfig.agent = agent;
3686
+ }
3687
+ adoptConfig(nextConfig);
3688
+ pendingOutputStyleId = selected.id;
3689
+ if (outputStyleSaveTimer) clearTimeout(outputStyleSaveTimer);
3690
+ outputStyleSaveTimer = setTimeout(flushOutputStyleSave, CONFIG_SAVE_DEBOUNCE_MS);
3691
+ outputStyleSaveTimer.unref?.();
3692
+ // Reuse the catalog already scanned above for `before` instead of a
3693
+ // second forced-fresh filesystem scan; refresh the status cache
3694
+ // in-memory (short TTL, same as getOutputStyleStatusCached) so reads
3695
+ // during the debounce window see the just-selected style.
3696
+ const freshStatus = { configured: selected.id, current: selected, styles: before.styles };
3697
+ outputStyleStatusCache = freshStatus;
3698
+ outputStyleStatusCacheAt = performance.now();
3699
+ outputStyleStatusCacheDir = resolve(cfgMod.getPluginData?.() || STANDALONE_DATA_DIR);
4880
3700
  const hasConversation = sessionHasConversationMessages(session);
4881
3701
  let appliedToCurrentSession = !hasConversation;
4882
3702
  if (session?.id && !hasConversation) {
4883
- mgr.closeSession(session.id, 'cli-output-style-switch');
3703
+ const closedSessionId = session.id;
3704
+ mgr.closeSession(closedSessionId, 'cli-output-style-switch');
4884
3705
  session = null;
4885
- await recreateCurrentSessionIfReady();
3706
+ // Defer the recreate so the output-style picker can repaint first;
3707
+ // any failure still surfaces via the existing notice path.
3708
+ setTimeout(() => {
3709
+ recreateCurrentSessionIfReady().catch((err) => {
3710
+ try {
3711
+ notifyFnForSession(closedSessionId)(
3712
+ `Failed to start a new session after output style change: ${err?.message || err}`,
3713
+ { level: 'error' },
3714
+ );
3715
+ } catch {}
3716
+ });
3717
+ }, 0);
4886
3718
  }
4887
3719
  invalidateContextStatusCache();
4888
- return { ...getOutputStyleStatusCached({ fresh: true }), appliedToCurrentSession };
3720
+ return { ...freshStatus, appliedToCurrentSession };
4889
3721
  },
4890
3722
  async setWorkflow(workflowId) {
4891
3723
  const id = normalizeWorkflowId(workflowId, DEFAULT_WORKFLOW_ID);
@@ -4933,6 +3765,13 @@ function parsedProviderModelVersion(id) {
4933
3765
  saveConfigAndAdopt(nextConfig);
4934
3766
  return routeToSave;
4935
3767
  },
3768
+ async setDefaultProvider(provider) {
3769
+ const requested = clean(provider);
3770
+ if (!requested) throw new Error('provider is required');
3771
+ if (!isKnownProvider(requested)) throw new Error(`unknown provider "${provider}"`);
3772
+ saveConfigAndAdopt({ ...config, defaultProvider: requested });
3773
+ return requested;
3774
+ },
4936
3775
  async ask(prompt, options = {}) {
4937
3776
  activeTurnCount += 1;
4938
3777
  // Lazy code-graph prewarm: kick off the build ONCE, on the first real
@@ -4947,6 +3786,32 @@ function parsedProviderModelVersion(id) {
4947
3786
  try {
4948
3787
  await refreshSessionForCwdIfNeeded('cwd-change');
4949
3788
  if (!session?.id) await createCurrentSession('turn');
3789
+ // Remote outbound: ensure a transcript writer bound to the current
3790
+ // session.id + cwd. The channel worker's OutputForwarder tails this
3791
+ // JSONL and pushes the surface view to Discord. Gated on remoteEnabled
3792
+ // so non-remote sessions write nothing.
3793
+ if (remoteEnabled) {
3794
+ const twKey = `${session.id}\u0000${currentCwd}`;
3795
+ // Reset per-turn dedup tracker so an identical answer in a later turn
3796
+ // is still written (the guard only prevents same-turn double-write).
3797
+ _lastAppendedAssistant = '';
3798
+ if (_twKey !== twKey) {
3799
+ try {
3800
+ _transcriptWriter = createTranscriptWriter({
3801
+ mixdogHome: mixdogHome(),
3802
+ sessionId: session.id,
3803
+ cwd: currentCwd,
3804
+ pid: process.pid,
3805
+ });
3806
+ _transcriptWriter.writeSessionRecord();
3807
+ _twKey = twKey;
3808
+ } catch (error) {
3809
+ process.stderr.write(`mixdog: transcript-writer: init failed: ${error?.message || error}\n`);
3810
+ _transcriptWriter = null;
3811
+ _twKey = '';
3812
+ }
3813
+ }
3814
+ }
4950
3815
  hooks.emit('turn:start', { sessionId: session.id, prompt, cwd: currentCwd });
4951
3816
  // UserPromptSubmit: bridge to the standard hook bus. A hook FAILURE
4952
3817
  // must not block the turn, but a genuine blocked===true MUST throw.
@@ -4975,6 +3840,14 @@ function parsedProviderModelVersion(id) {
4975
3840
  name: call?.name || 'tool',
4976
3841
  callId: call?.id || null,
4977
3842
  });
3843
+ // Mirror each tool card the TUI shows into the transcript so the
3844
+ // forwarder renders the same one-line tool summary on Discord.
3845
+ // calls carry { name, arguments, id } (loop.mjs response.toolCalls);
3846
+ // some providers use `input` — accept either.
3847
+ if (remoteEnabled && _transcriptWriter) {
3848
+ try { _transcriptWriter.appendToolUse(call?.name, call?.input ?? call?.arguments); }
3849
+ catch (error) { process.stderr.write(`mixdog: transcript-writer: onToolCall failed: ${error?.message || error}\n`); }
3850
+ }
4978
3851
  }
4979
3852
  if (typeof options.onToolCall === 'function') {
4980
3853
  return await options.onToolCall(iter, calls);
@@ -4986,9 +3859,40 @@ function parsedProviderModelVersion(id) {
4986
3859
  {
4987
3860
  onTextDelta: options.onTextDelta,
4988
3861
  onReasoningDelta: options.onReasoningDelta,
4989
- onAssistantText: options.onAssistantText,
3862
+ onAssistantText: (text) => {
3863
+ // Capture the same assistant text the TUI renders (final answer +
3864
+ // mid-turn preamble). Feed the writer, then ALWAYS call through to
3865
+ // the caller's hook so terminal rendering is never affected.
3866
+ if (remoteEnabled && _transcriptWriter) {
3867
+ try {
3868
+ const value = typeof text === 'string' ? text : (text == null ? '' : String(text));
3869
+ if (value.trim()) {
3870
+ _transcriptWriter.appendAssistant(value);
3871
+ // Track the last line we wrote so the post-turn final
3872
+ // append below can skip an identical re-write (buffered
3873
+ // providers surface the final answer here AND in result).
3874
+ _lastAppendedAssistant = value;
3875
+ }
3876
+ }
3877
+ catch (error) { process.stderr.write(`mixdog: transcript-writer: onAssistantText failed: ${error?.message || error}\n`); }
3878
+ }
3879
+ return options.onAssistantText?.(text);
3880
+ },
4990
3881
  onUsageDelta: options.onUsageDelta,
4991
- onToolResult: options.onToolResult,
3882
+ onToolResult: (message) => {
3883
+ // Surface Edit diffs only: the forwarder reads toolUseResult
3884
+ // {oldString,newString}. Other tool results carry no diff payload,
3885
+ // so we append only when those fields are present.
3886
+ if (remoteEnabled && _transcriptWriter) {
3887
+ try {
3888
+ const tur = message?.toolUseResult;
3889
+ if (tur && (tur.oldString != null || tur.newString != null)) {
3890
+ _transcriptWriter.appendToolResult({ oldString: tur.oldString ?? '', newString: tur.newString ?? '' });
3891
+ }
3892
+ } catch (error) { process.stderr.write(`mixdog: transcript-writer: onToolResult failed: ${error?.message || error}\n`); }
3893
+ }
3894
+ return options.onToolResult?.(message);
3895
+ },
4992
3896
  onToolApproval: options.onToolApproval,
4993
3897
  onCompactEvent: options.onCompactEvent,
4994
3898
  onStageChange: options.onStageChange,
@@ -4999,6 +3903,24 @@ function parsedProviderModelVersion(id) {
4999
3903
  },
5000
3904
  );
5001
3905
  session = mgr.getSession(session.id) || session;
3906
+ // Final assistant text: onAssistantText fires only for buffered
3907
+ // (non-streaming) providers and for mid-turn preamble. Streaming
3908
+ // providers deliver the final answer via onTextDelta (which we do NOT
3909
+ // tap, to avoid per-delta spam), so append the authoritative final
3910
+ // content here from result.content. To avoid double-writing the
3911
+ // buffered case, only append when it differs from the last assistant
3912
+ // line onAssistantText already wrote.
3913
+ if (remoteEnabled && _transcriptWriter) {
3914
+ try {
3915
+ const finalText = result?.content != null ? String(result.content) : '';
3916
+ if (finalText.trim() && finalText !== _lastAppendedAssistant) {
3917
+ _transcriptWriter.appendAssistant(finalText);
3918
+ _lastAppendedAssistant = finalText;
3919
+ }
3920
+ } catch (error) {
3921
+ process.stderr.write(`mixdog: transcript-writer: final append failed: ${error?.message || error}\n`);
3922
+ }
3923
+ }
5002
3924
  hooks.emit('turn:end', { sessionId: session.id, elapsedMs: Date.now() - startedAt });
5003
3925
  // Stop: bridge to the standard hook bus. Best-effort; ignore result,
5004
3926
  // never throw.
@@ -5008,6 +3930,18 @@ function parsedProviderModelVersion(id) {
5008
3930
  return { result, session };
5009
3931
  } catch (error) {
5010
3932
  hooks.emit('turn:error', { sessionId: session?.id || null, elapsedMs: Date.now() - startedAt, error: error?.message || String(error) });
3933
+ // StopFailure: bridge a turn error to the standard hook bus. Spec:
3934
+ // output + exit code ignored, so pure fire-and-forget. error_type is a
3935
+ // simple regex mapping from the error message (default 'unknown').
3936
+ try {
3937
+ const msg = String(error?.message || error || '').toLowerCase();
3938
+ const errorType = /rate.?limit|429|too many requests/.test(msg) ? 'rate_limit'
3939
+ : /overloaded|529/.test(msg) ? 'overloaded'
3940
+ : /authenticat|unauthorized|401|invalid.*api.?key/.test(msg) ? 'authentication_failed'
3941
+ : /server.?error|5\d\d|internal error/.test(msg) ? 'server_error'
3942
+ : 'unknown';
3943
+ void hooks.dispatch('StopFailure', hookCommonPayload({ session_id: session?.id || null, error_type: errorType }));
3944
+ } catch { /* best-effort: StopFailure hook must never break teardown */ }
5011
3945
  throw error;
5012
3946
  } finally {
5013
3947
  activeTurnCount = Math.max(0, activeTurnCount - 1);
@@ -5034,7 +3968,14 @@ function parsedProviderModelVersion(id) {
5034
3968
  if (activeTurnCount > 0) {
5035
3969
  return { changed: false, reason: 'compact skipped: turn in progress' };
5036
3970
  }
3971
+ // Manual compact bypasses loop.mjs, so its PreCompact/PostCompact never
3972
+ // fire here — dispatch them explicitly via the session-property hooks
3973
+ // (wired at session create). Best-effort; a hook must not block compaction.
3974
+ try { await session.preCompactHook?.({ trigger: 'manual' }); }
3975
+ catch { /* best-effort: PreCompact hook must never break manual compact */ }
5037
3976
  const result = await mgr.compactSessionMessages(session.id);
3977
+ try { await session.postCompactHook?.({ trigger: 'manual' }); }
3978
+ catch { /* best-effort: PostCompact hook must never break manual compact */ }
5038
3979
  session = mgr.getSession(session.id) || session;
5039
3980
  if (options.recoverAgent === true) {
5040
3981
  try { agentTool.recoverWorkers?.({ clientHostPid: session?.clientHostPid || process.pid }); } catch {}
@@ -5206,9 +4147,15 @@ function parsedProviderModelVersion(id) {
5206
4147
  const removed = registryRemovePlugin(key, { dataDir });
5207
4148
  const nextConfig = { ...config };
5208
4149
  const serverName = pluginMcpServerName(plugin);
5209
- if (nextConfig.mcpServers && Object.prototype.hasOwnProperty.call(nextConfig.mcpServers, serverName)) {
4150
+ const prefix = `${serverName}--`;
4151
+ const hasMatch = nextConfig.mcpServers && Object.keys(nextConfig.mcpServers).some(
4152
+ (k) => k === serverName || k.startsWith(prefix)
4153
+ );
4154
+ if (hasMatch) {
5210
4155
  const current = { ...nextConfig.mcpServers };
5211
- delete current[serverName];
4156
+ for (const k of Object.keys(current)) {
4157
+ if (k === serverName || k.startsWith(prefix)) delete current[k];
4158
+ }
5212
4159
  saveConfigAndAdopt({ ...nextConfig, mcpServers: current });
5213
4160
  await connectConfiguredMcp({ reset: true });
5214
4161
  invalidatePreSessionToolSurface();
@@ -5221,22 +4168,50 @@ function parsedProviderModelVersion(id) {
5221
4168
  const root = clean(plugin.root);
5222
4169
  const script = clean(plugin.mcpScript);
5223
4170
  if (!root || !script) throw new Error('plugin has no MCP script');
5224
- const scriptPath = join(root, script);
5225
- if (!existsSync(scriptPath)) throw new Error(`plugin MCP script not found: ${scriptPath}`);
5226
4171
  const serverName = pluginMcpServerName(plugin);
5227
4172
  const nextConfig = { ...config };
5228
- nextConfig.mcpServers = {
5229
- ...(nextConfig.mcpServers || {}),
5230
- [serverName]: {
5231
- command: 'node',
5232
- args: [scriptPath],
5233
- cwd: root,
5234
- env: {
4173
+ if (script === '.mcp.json') {
4174
+ const mcpJsonPath = join(root, script);
4175
+ if (!existsSync(mcpJsonPath)) throw new Error(`plugin MCP manifest not found: ${mcpJsonPath}`);
4176
+ const manifest = readJsonSafe(mcpJsonPath) || {};
4177
+ const rawServers = manifest.mcpServers;
4178
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
4179
+ if (!isPlainObject(rawServers)) throw new Error(`plugin .mcp.json missing mcpServers object: ${mcpJsonPath}`);
4180
+ const keys = Object.keys(rawServers).filter((k) => isPlainObject(rawServers[k]));
4181
+ if (!keys.length) throw new Error(`plugin .mcp.json has no mcpServers: ${mcpJsonPath}`);
4182
+ const ownedPrefix = `${serverName}--`;
4183
+ const nextServers = {};
4184
+ for (const [k, v] of Object.entries(nextConfig.mcpServers || {})) {
4185
+ if (k === serverName || k.startsWith(ownedPrefix)) continue;
4186
+ nextServers[k] = v;
4187
+ }
4188
+ for (const serverKey of keys) {
4189
+ const cfg = normalizePluginMcpServerConfig(rawServers[serverKey], root);
4190
+ cfg.env = {
4191
+ ...(cfg.env || {}),
5235
4192
  MIXDOG_PLUGIN_ROOT: root,
5236
4193
  MIXDOG_PLUGIN_DATA: join(cfgMod.getPluginData?.() || STANDALONE_DATA_DIR, 'plugins', 'data', clean(plugin.id || plugin.name || serverName)),
4194
+ };
4195
+ const key = keys.length === 1 ? serverName : `${serverName}--${serverKey}`;
4196
+ nextServers[key] = cfg;
4197
+ }
4198
+ nextConfig.mcpServers = nextServers;
4199
+ } else {
4200
+ const scriptPath = join(root, script);
4201
+ if (!existsSync(scriptPath)) throw new Error(`plugin MCP script not found: ${scriptPath}`);
4202
+ nextConfig.mcpServers = {
4203
+ ...(nextConfig.mcpServers || {}),
4204
+ [serverName]: {
4205
+ command: 'node',
4206
+ args: [scriptPath],
4207
+ cwd: root,
4208
+ env: {
4209
+ MIXDOG_PLUGIN_ROOT: root,
4210
+ MIXDOG_PLUGIN_DATA: join(cfgMod.getPluginData?.() || STANDALONE_DATA_DIR, 'plugins', 'data', clean(plugin.id || plugin.name || serverName)),
4211
+ },
5237
4212
  },
5238
- },
5239
- };
4213
+ };
4214
+ }
5240
4215
  saveConfigAndAdopt(nextConfig);
5241
4216
  const status = await connectConfiguredMcp({ reset: true });
5242
4217
  invalidatePreSessionToolSurface();
@@ -5306,7 +4281,15 @@ function parsedProviderModelVersion(id) {
5306
4281
  return result;
5307
4282
  },
5308
4283
  async setRoute(next, options = {}) {
5309
- const applyToCurrentSession = options?.applyToCurrentSession !== false;
4284
+ // Model/provider changes take effect on the NEXT session only — never
4285
+ // rewrite a running session's provider/model in place. A live turn's
4286
+ // prompt cache (Anthropic/OpenAI/etc.) is provider-keyed; flipping
4287
+ // session.provider mid-conversation forces a full cache-miss rewrite
4288
+ // of the entire history on the very next turn (seen as a promptΔ
4289
+ // spike + cache_ratio=0% in session-bench). `route` (this closure
4290
+ // variable) still updates immediately so the NEXT createCurrentSession()
4291
+ // picks it up; only the currently-open session is left untouched.
4292
+ const applyToCurrentSession = options?.applyToCurrentSession === true;
5310
4293
  const requested = { ...(next || {}) };
5311
4294
  validateRequestedModelSelector(config, requested);
5312
4295
  const providerExplicitlyRequested = clean(next?.provider) !== '';
@@ -5334,12 +4317,6 @@ function parsedProviderModelVersion(id) {
5334
4317
  const fastCapable = fastCapableFor(selectedRoute.provider, modelMeta);
5335
4318
  selectedRoute = { ...selectedRoute, fast: fastCapable ? selectedRoute.fast === true : false };
5336
4319
  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
4320
  const leadRoute = persistLeadRoute(selectedRoute);
5344
4321
  route = resolveRoute(config, leadRoute
5345
4322
  ? { model: workflowPresetId('lead') }
@@ -5347,6 +4324,11 @@ function parsedProviderModelVersion(id) {
5347
4324
  await refreshRouteEffort(modelMeta);
5348
4325
  refreshStatuslineUsageSnapshot(route);
5349
4326
  scheduleStatuslineUsageRefresh();
4327
+ if (!applyToCurrentSession) {
4328
+ // `route` is updated for the next session; the open session (and its
4329
+ // live provider/model/cache chain) is left alone on purpose.
4330
+ return route;
4331
+ }
5350
4332
  if (session) {
5351
4333
  const updated = mgr.updateSessionRoute?.(session.id, {
5352
4334
  provider: route.provider,
@@ -5413,10 +4395,28 @@ function parsedProviderModelVersion(id) {
5413
4395
  async close(reason = 'cli-exit', options = {}) {
5414
4396
  const detach = options?.detach === true || options?.wait === false || options?.waitForExit === false;
5415
4397
  closeRequested = true;
4398
+ // SessionEnd: bridge teardown to the standard hook bus. reason mapped to
4399
+ // standard values ('clear'/'exit' where applicable, else 'other'). Short
4400
+ // await guard so a slow hook cannot wedge teardown; best-effort.
4401
+ try {
4402
+ const rl = String(reason || '').toLowerCase();
4403
+ const endReason = /clear/.test(rl) ? 'clear'
4404
+ : /exit|quit|cli-exit|shutdown|sigint|sigterm/.test(rl) ? 'exit'
4405
+ : 'other';
4406
+ if (session?.id) {
4407
+ await withTeardownDeadline(
4408
+ Promise.resolve(hooks.dispatch('SessionEnd', hookCommonPayload({ session_id: session.id, reason: endReason }))).catch(() => {}),
4409
+ 300,
4410
+ undefined,
4411
+ );
4412
+ }
4413
+ } catch { /* best-effort: SessionEnd hook must never wedge teardown */ }
5416
4414
  // Persist any change that is still sitting in the debounce window so a
5417
4415
  // toggle made right before exit is not lost. Synchronous + best-effort:
5418
4416
  // teardown must continue even if the final write fails.
5419
4417
  try { flushConfigSave(); } catch {}
4418
+ try { flushBackendSave(); } catch {}
4419
+ try { flushOutputStyleSave(); } catch {}
5420
4420
  if (channelStartTimer) {
5421
4421
  clearTimeout(channelStartTimer);
5422
4422
  channelStartTimer = null;
@@ -5433,6 +4433,10 @@ function parsedProviderModelVersion(id) {
5433
4433
  clearTimeout(providerModelWarmupTimer);
5434
4434
  providerModelWarmupTimer = null;
5435
4435
  }
4436
+ if (modelCatalogWarmupTimer) {
4437
+ clearTimeout(modelCatalogWarmupTimer);
4438
+ modelCatalogWarmupTimer = null;
4439
+ }
5436
4440
  if (codeGraphPrewarmTimer) {
5437
4441
  clearTimeout(codeGraphPrewarmTimer);
5438
4442
  codeGraphPrewarmTimer = null;
@@ -5511,8 +4515,8 @@ function parsedProviderModelVersion(id) {
5511
4515
  if (owner && !['cli', 'user', 'mixdog', 'legacy'].includes(owner)) return null;
5512
4516
  const sourceType = clean(s.sourceType || '').toLowerCase();
5513
4517
  const sourceName = clean(s.sourceName || '').toLowerCase();
5514
- const role = clean(s.role || '').toLowerCase();
5515
- const leadish = role === 'lead'
4518
+ const agent = clean(s.agent || '').toLowerCase();
4519
+ const leadish = agent === 'lead'
5516
4520
  || sourceType === 'lead'
5517
4521
  || (sourceType === 'cli' && (!sourceName || sourceName === 'main'))
5518
4522
  || (!sourceType && !sourceName && !isAgentOwner(owner));