mixdog 0.9.95 → 0.9.97

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 (282) hide show
  1. package/LICENSES/Apache-2.0.txt +201 -0
  2. package/LICENSES/MIT.txt +56 -0
  3. package/LICENSES/codex-NOTICE.txt +6 -0
  4. package/NOTICE.md +46 -9
  5. package/README.md +0 -17
  6. package/package.json +16 -12
  7. package/scripts/.tmp-coverage-report.mjs +96 -0
  8. package/scripts/build-tui.mjs +17 -1
  9. package/scripts/{channel-daemon-stub.mjs → daemon-stub.mjs} +18 -12
  10. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  11. package/src/defaults/mixdog-config.template.json +5 -8
  12. package/src/defaults/skills/setup/SKILL.md +1 -1
  13. package/src/lib/keychain-cjs.cjs +1 -1
  14. package/src/lib/mixdog-debug.cjs +2 -3
  15. package/src/rules/agent/30-explorer.md +7 -4
  16. package/src/rules/lead/01-general.md +1 -2
  17. package/src/rules/lead/lead-tool.md +0 -3
  18. package/src/rules/shared/01-tool.md +21 -29
  19. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  20. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  23. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -13
  24. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  25. package/src/runtime/agent/orchestrator/mcp/client.mjs +104 -41
  26. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  27. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  29. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  30. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  31. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  32. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  33. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  34. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  35. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  37. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +3 -6
  39. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  41. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  42. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  43. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +36 -10
  44. package/src/runtime/agent/orchestrator/session/compact.mjs +1 -1
  45. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +22 -33
  46. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  48. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  49. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +21 -29
  50. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +7 -10
  51. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +45 -25
  52. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -3
  53. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  54. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +12 -6
  55. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  56. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  57. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +38 -2
  58. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  59. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  60. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  61. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  62. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +10 -3
  63. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
  64. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +71 -42
  65. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +4 -33
  66. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  67. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  70. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +116 -26
  71. package/src/runtime/agent/orchestrator/tools/builtin/binary-file.mjs +92 -19
  72. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  73. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  74. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +2 -2
  76. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  77. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +12 -26
  78. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +5 -5
  79. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +83 -33
  80. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +42 -45
  81. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +27 -9
  82. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  84. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  85. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  86. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  87. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  88. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  89. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  90. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +142 -26
  91. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +12 -1
  92. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +47 -19
  93. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -2
  94. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +1 -2
  95. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  96. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  97. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +94 -20
  99. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  100. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  101. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +12 -11
  102. package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
  103. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +63 -9
  104. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  105. package/src/runtime/channels/lib/config.mjs +34 -34
  106. package/src/runtime/channels/lib/inbound-handler.mjs +8 -8
  107. package/src/runtime/channels/lib/interaction-handlers.mjs +6 -6
  108. package/src/runtime/channels/lib/memory-client.mjs +31 -157
  109. package/src/runtime/channels/lib/output-forwarder.mjs +16 -16
  110. package/src/runtime/channels/lib/owned-runtime.mjs +60 -60
  111. package/src/runtime/channels/lib/owner-heartbeat.mjs +1 -1
  112. package/src/runtime/channels/lib/{backend-dispatch.mjs → provider-dispatch.mjs} +6 -6
  113. package/src/runtime/channels/lib/runtime-paths.mjs +4 -30
  114. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  115. package/src/runtime/channels/lib/status-snapshot.mjs +3 -3
  116. package/src/runtime/channels/lib/tool-dispatch.mjs +4 -4
  117. package/src/runtime/channels/lib/worker-bootstrap.mjs +1 -1
  118. package/src/runtime/channels/lib/worker-ipc.mjs +7 -7
  119. package/src/runtime/channels/lib/worker-main.mjs +38 -38
  120. package/src/runtime/channels/{backends → providers}/discord-access.mjs +1 -1
  121. package/src/runtime/channels/{backends → providers}/discord-gateway.mjs +1 -1
  122. package/src/runtime/channels/{backends → providers}/discord.mjs +3 -3
  123. package/src/runtime/channels/{backends → providers}/telegram.mjs +12 -12
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/memory/index.mjs +76 -248
  126. package/src/runtime/memory/lib/agent-ipc.mjs +146 -81
  127. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  128. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  129. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +13 -34
  130. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  131. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  132. package/src/runtime/memory/lib/http-router.mjs +9 -6
  133. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  134. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  135. package/src/runtime/memory/lib/memory-config-flags.mjs +1 -1
  136. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  137. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  138. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  139. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +4 -1
  140. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  141. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +5 -0
  142. package/src/runtime/memory/lib/memory-log.mjs +1 -1
  143. package/src/runtime/memory/lib/memory-process-lock.mjs +5 -60
  144. package/src/runtime/memory/lib/memory.mjs +1 -1
  145. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  146. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  147. package/src/runtime/memory/tool-defs.mjs +1 -5
  148. package/src/runtime/search/index.mjs +2 -2
  149. package/src/runtime/search/tool-defs.mjs +2 -2
  150. package/src/runtime/shared/abort-race.mjs +70 -0
  151. package/src/runtime/shared/agent-route-config.mjs +119 -0
  152. package/src/runtime/shared/atomic-file.mjs +80 -4
  153. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  154. package/src/runtime/shared/child-guardian.mjs +204 -108
  155. package/src/runtime/shared/child-spawn-gate.mjs +139 -72
  156. package/src/runtime/shared/config.mjs +120 -47
  157. package/src/runtime/shared/llm/index.mjs +1 -1
  158. package/src/runtime/shared/owner-fair-gate.mjs +134 -0
  159. package/src/runtime/shared/resource-admission.mjs +150 -100
  160. package/src/runtime/shared/stream-progress.mjs +6 -0
  161. package/src/runtime/shared/tool-execution-owner.mjs +12 -0
  162. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  163. package/src/runtime/shared/tool-surface.mjs +91 -27
  164. package/src/runtime/shared/tool-workload-gates.mjs +29 -0
  165. package/src/runtime/shared/turn-snapshot.mjs +47 -6
  166. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  167. package/src/session-runtime/channel-config-api.mjs +5 -5
  168. package/src/session-runtime/config-helpers.mjs +39 -37
  169. package/src/session-runtime/config-lifecycle.mjs +27 -27
  170. package/src/session-runtime/lifecycle-api.mjs +22 -3
  171. package/src/session-runtime/memory-runtime-prewarm.mjs +15 -0
  172. package/src/session-runtime/model-route-api.mjs +7 -5
  173. package/src/session-runtime/prewarm.mjs +1 -1
  174. package/src/session-runtime/provider-auth-api.mjs +0 -7
  175. package/src/session-runtime/provider-models.mjs +1 -1
  176. package/src/session-runtime/remote-control.mjs +6 -7
  177. package/src/session-runtime/runtime-core.mjs +63 -39
  178. package/src/session-runtime/runtime-tunables.mjs +4 -4
  179. package/src/session-runtime/self-update.mjs +1 -1
  180. package/src/session-runtime/session-lifecycle.mjs +45 -17
  181. package/src/session-runtime/session-title.mjs +1 -1
  182. package/src/session-runtime/session-turn-api.mjs +96 -24
  183. package/src/session-runtime/settings-api.mjs +12 -24
  184. package/src/session-runtime/tool-defs.mjs +3 -3
  185. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  186. package/src/session-runtime/workflow.mjs +14 -24
  187. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  188. package/src/standalone/agent-tool/helpers.mjs +2 -2
  189. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  190. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  191. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  192. package/src/standalone/channel-admin.mjs +53 -64
  193. package/src/standalone/{channel-daemon-client.mjs → channel-client.mjs} +33 -19
  194. package/src/standalone/{channel-daemon-transport.mjs → channel-transport.mjs} +127 -46
  195. package/src/standalone/channel-worker.mjs +124 -647
  196. package/src/standalone/{backend-daemon.mjs → daemon.mjs} +270 -154
  197. package/src/standalone/explore-tool.mjs +187 -70
  198. package/src/standalone/fair-call-scheduler.mjs +264 -0
  199. package/src/standalone/memory-runtime-proxy.mjs +58 -1
  200. package/src/standalone/session-client.mjs +1134 -0
  201. package/src/standalone/session-protocol.mjs +192 -0
  202. package/src/standalone/session-runtime-pool.mjs +393 -0
  203. package/src/standalone/session-runtime-record.mjs +20 -0
  204. package/src/standalone/session-runtime-worker.mjs +203 -0
  205. package/src/standalone/{engine-daemon-service.mjs → session-service.mjs} +481 -224
  206. package/src/standalone/{engine-daemon-transport.mjs → session-transport.mjs} +390 -121
  207. package/src/standalone/session-wire.mjs +55 -0
  208. package/src/tui/App.jsx +34 -21
  209. package/src/tui/app/app-format.mjs +1 -1
  210. package/src/tui/app/app-view.jsx +5 -2
  211. package/src/tui/app/channel-pickers.mjs +41 -41
  212. package/src/tui/app/core-memory-picker.mjs +19 -19
  213. package/src/tui/app/doctor.mjs +5 -11
  214. package/src/tui/app/input-parsers.mjs +2 -2
  215. package/src/tui/app/message-selector.mjs +103 -0
  216. package/src/tui/app/onboarding-steps.mjs +3 -4
  217. package/src/tui/app/project-picker.mjs +80 -56
  218. package/src/tui/app/prompt-submit.mjs +48 -43
  219. package/src/tui/app/resume-picker.mjs +2 -2
  220. package/src/tui/app/route-pickers.mjs +3 -1
  221. package/src/tui/app/settings-picker.mjs +31 -33
  222. package/src/tui/app/transcript-window.mjs +38 -0
  223. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  224. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  225. package/src/tui/app/use-prompt-hint.mjs +3 -2
  226. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  227. package/src/tui/app/use-transcript-scroll.mjs +10 -3
  228. package/src/tui/app/use-transcript-window.mjs +50 -17
  229. package/src/tui/components/PromptInput.jsx +71 -38
  230. package/src/tui/components/Spinner.jsx +1 -1
  231. package/src/tui/components/ToolExecution.jsx +1 -1
  232. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  233. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  234. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  235. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  236. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  237. package/src/tui/components/tool-execution/surface-detail.mjs +1 -1
  238. package/src/tui/dist/index.mjs +1031 -1676
  239. package/src/tui/hooks/useSession.mjs +16 -0
  240. package/src/tui/index.jsx +12 -13
  241. package/src/tui/{engine → session}/agent-envelope.mjs +3 -3
  242. package/src/tui/{engine → session}/agent-job-feed.mjs +4 -4
  243. package/src/tui/{engine → session}/boot-profile.mjs +2 -2
  244. package/src/tui/{engine → session}/context-state.mjs +3 -3
  245. package/src/tui/{engine → session}/labels.mjs +2 -2
  246. package/src/tui/{engine → session}/live-share.mjs +38 -4
  247. package/src/tui/{engine → session}/notice-text.mjs +2 -2
  248. package/src/tui/{engine → session}/notification-plan.mjs +3 -3
  249. package/src/tui/session/oauth-flows.mjs +140 -0
  250. package/src/tui/{engine → session}/prompt-history.mjs +3 -3
  251. package/src/tui/{engine → session}/queue-helpers.mjs +2 -2
  252. package/src/tui/{engine → session}/render-timing.mjs +2 -2
  253. package/src/tui/{engine → session}/session-api-ext.mjs +30 -20
  254. package/src/tui/{engine → session}/session-api.mjs +110 -93
  255. package/src/tui/{engine → session}/session-flow.mjs +8 -2
  256. package/src/tui/{engine → session}/session-stats.mjs +3 -3
  257. package/src/tui/{engine → session}/tool-approval.mjs +3 -3
  258. package/src/tui/{engine → session}/tool-call-fields.mjs +2 -2
  259. package/src/tui/{engine → session}/tool-card-results.mjs +8 -6
  260. package/src/tui/{engine → session}/tool-result-status.mjs +5 -5
  261. package/src/tui/{engine → session}/tool-result-text.mjs +13 -3
  262. package/src/tui/{engine → session}/transcript-spill.mjs +34 -34
  263. package/src/tui/{engine → session}/turn.mjs +38 -176
  264. package/src/tui/{engine-local-session.mjs → session-local.mjs} +103 -103
  265. package/src/tui/session.mjs +22 -0
  266. package/src/tui/spinner-meta.mjs +4 -4
  267. package/src/tui/spinner-verbs.mjs +51 -3
  268. package/src/ui/statusline-segments.mjs +1 -1
  269. package/src/ui/statusline.mjs +1 -1
  270. package/vendor/ink/build/components/App.js +10 -1
  271. package/src/session-runtime/memory-daemon-probe.mjs +0 -61
  272. package/src/standalone/engine-daemon-client.mjs +0 -894
  273. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
  274. package/src/standalone/engine-daemon-protocol.mjs +0 -33
  275. package/src/tui/engine/turn-watchdog.mjs +0 -92
  276. package/src/tui/engine.mjs +0 -22
  277. package/src/tui/hooks/useEngine.mjs +0 -16
  278. /package/src/runtime/agent/orchestrator/session/compact/{engine.mjs → runner.mjs} +0 -0
  279. /package/src/runtime/channels/{backends → providers}/discord-attachments.mjs +0 -0
  280. /package/src/tui/{engine → session}/agent-response-tail.mjs +0 -0
  281. /package/src/tui/{engine → session}/frame-batched-store.mjs +0 -0
  282. /package/src/tui/{engine → session}/tui-steering-persist.mjs +0 -0
@@ -11,7 +11,9 @@ import {
11
11
  } from './tool-catalog.mjs';
12
12
  import { getMcpTools } from '../runtime/agent/orchestrator/mcp/client.mjs';
13
13
  import { traceTurnTiming } from '../runtime/agent/orchestrator/agent-trace.mjs';
14
- import { beginTurnSnapshot, completeTurnSnapshot } from '../runtime/shared/turn-snapshot.mjs';
14
+ import { beginTurnSnapshot, cancelTurnSnapshot, completeTurnSnapshot } from '../runtime/shared/turn-snapshot.mjs';
15
+ import { isVisibleStreamProgress } from '../runtime/shared/stream-progress.mjs';
16
+ import { runAbortable, settleWithin, throwIfAborted } from '../runtime/shared/abort-race.mjs';
15
17
 
16
18
  export function splitToolStatusCounts(rows) {
17
19
  const list = Array.isArray(rows) ? rows : [];
@@ -44,8 +46,12 @@ export function createSessionTurnApi(deps) {
44
46
  scheduleProviderWarmup, scheduleProviderModelWarmup, invalidateContextStatusCache,
45
47
  agentTool, recreateCurrentSessionIfReady, invalidatePreSessionToolSurface,
46
48
  activeToolSurface, applyResolvedCwd, resolveCwdPath, agentStatusState, notificationListeners,
47
- awaitInitialMcpConnect, mcpTurnGraceMs = 150, awaitRoutePreparation,
48
- getReservedSessionId, sessionTitles,
49
+ awaitInitialMcpConnect, mcpTurnGraceMs = 0, awaitRoutePreparation,
50
+ getReservedSessionId, sessionTitles, registerActiveTurnController,
51
+ beginTurnSnapshotForTurn = beginTurnSnapshot,
52
+ cancelTurnSnapshotForTurn = cancelTurnSnapshot,
53
+ completeTurnSnapshotForTurn = completeTurnSnapshot,
54
+ turnCleanupSettleMs = 2_000,
49
55
  } = deps;
50
56
  const enqueueRemoteAttachedPrompt = (prompt) => {
51
57
  const attachedSession = getSession();
@@ -79,7 +85,14 @@ export function createSessionTurnApi(deps) {
79
85
  // a normal user turn and this surface refreshes from disk.
80
86
  const attachedSession = getSession();
81
87
  if (attachedSession?.remoteAttached) {
82
- const delivered = enqueueRemoteAttachedPrompt(prompt);
88
+ // Carry the submission id into the spool: the owner reuses it for the
89
+ // queue entry and the settled user row, so the submitting surface's
90
+ // optimistic bubble is released instead of standing beside the
91
+ // mirrored twin (user: 같은 세션에서 메세지가 두 개로 보인다).
92
+ const submissionId = String(options.id || '').trim();
93
+ const delivered = enqueueRemoteAttachedPrompt(
94
+ submissionId ? { content: prompt, id: submissionId } : prompt,
95
+ );
83
96
  return {
84
97
  // This branch is only a race-safe fallback for callers that reached
85
98
  // ask() before the live pipe was ready. Never manufacture an
@@ -93,6 +106,12 @@ export function createSessionTurnApi(deps) {
93
106
  // actual turn waits, guaranteeing that it cannot run on a stale route.
94
107
  const timingStartedAt = performance.now();
95
108
  const timingStartedAtEpoch = Date.now();
109
+ const turnAbortController = new AbortController();
110
+ const unregisterTurnAbort = typeof registerActiveTurnController === 'function'
111
+ ? registerActiveTurnController(turnAbortController)
112
+ : () => {};
113
+ const turnSignal = turnAbortController.signal;
114
+ const awaitTurn = (task) => runAbortable(turnSignal, task, 'Turn aborted');
96
115
  const submittedAt = Number(options.submittedAt);
97
116
  const hasSubmittedAt = Number.isFinite(submittedAt) && submittedAt > 0;
98
117
  let turnSnapshotSessionId = null;
@@ -104,12 +123,13 @@ export function createSessionTurnApi(deps) {
104
123
  // Capture immediately, but do not put Git on the first-token critical
105
124
  // path. Every actual tool execution joins this same promise below, so
106
125
  // shell/apply_patch cannot mutate the worktree before the baseline.
107
- turnSnapshotPromise = Promise.resolve(beginTurnSnapshot(getCurrentCwd(), id)).catch(() => undefined);
126
+ turnSnapshotPromise = Promise.resolve()
127
+ .then(() => beginTurnSnapshotForTurn(getCurrentCwd(), id))
128
+ .catch(() => undefined);
108
129
  };
109
130
  const routeStartedAt = performance.now();
110
131
  startTurnSnapshot(getSession()?.id || getReservedSessionId?.());
111
- await awaitRoutePreparation?.();
112
- const routeWaitMs = performance.now() - routeStartedAt;
132
+ let routeWaitMs = 0;
113
133
  setActiveTurnCount(getActiveTurnCount() + 1);
114
134
  let mcpWaitMs = 0;
115
135
  let providerStartedAt = 0;
@@ -117,9 +137,10 @@ export function createSessionTurnApi(deps) {
117
137
  let turnTimingEmitted = false;
118
138
  // Heavy runtime warmup is one-shot and demand-driven: idle desktop panes
119
139
  // never spawn shell/token/shard helpers or a code-graph worker. More
120
- // importantly, do not start them until the provider has produced its
121
- // first stream delta: launching PowerShell + graph workers before that
122
- // point directly competes with the first-token critical path.
140
+ // importantly, do not start them until the provider has produced visible
141
+ // text/reasoning/tool progress: transport headers and response-created
142
+ // acknowledgements arrive earlier and would make PowerShell + graph
143
+ // workers compete with the actual first-token critical path.
123
144
  const heavyRuntimeWarmupPending = typeof getCodeGraphFirstTurnPrewarmDone === 'function'
124
145
  && !getCodeGraphFirstTurnPrewarmDone();
125
146
  if (heavyRuntimeWarmupPending) {
@@ -167,8 +188,13 @@ export function createSessionTurnApi(deps) {
167
188
  };
168
189
  const startedAt = Date.now();
169
190
  try {
170
- await refreshSessionForCwdIfNeeded('cwd-change');
171
- if (!getSession()?.id) await createCurrentSession('turn');
191
+ await awaitTurn(() => awaitRoutePreparation?.());
192
+ routeWaitMs = performance.now() - routeStartedAt;
193
+ await awaitTurn(() => refreshSessionForCwdIfNeeded('cwd-change'));
194
+ if (!getSession()?.id) {
195
+ await awaitTurn(() => createCurrentSession('turn', { signal: turnSignal }));
196
+ }
197
+ throwIfAborted(turnSignal);
172
198
  // Remote outbound: ensure a transcript writer bound to the current
173
199
  // session.id + cwd. Gated on remoteEnabled so non-remote sessions write nothing.
174
200
  if (getRemoteEnabled()) {
@@ -218,7 +244,7 @@ export function createSessionTurnApi(deps) {
218
244
  session0.deferredInitialRefreshPending = false;
219
245
  // Give an in-flight INITIAL MCP connect only a short TTFT grace.
220
246
  // Slower servers remain available through the late-tool path.
221
- try { await awaitMcpGrace(); }
247
+ try { await awaitTurn(() => awaitMcpGrace()); }
222
248
  catch { /* gate must never break the turn */ }
223
249
  try { refreshInitialDeferredMcpSurface(session0, getMcpTools()); }
224
250
  catch { /* first-turn MCP fold must never break the turn */ }
@@ -234,7 +260,7 @@ export function createSessionTurnApi(deps) {
234
260
  // short TTFT grace BEFORE folding the catalog so a slow reconnect
235
261
  // cannot hold the provider request. No reconnect in flight →
236
262
  // immediate no-op.
237
- try { await awaitMcpGrace(); }
263
+ try { await awaitTurn(() => awaitMcpGrace()); }
238
264
  catch { /* gate must never break the turn */ }
239
265
  try {
240
266
  reconcileDeferredMcpToolCatalog(session0, getMcpTools(), {
@@ -252,8 +278,13 @@ export function createSessionTurnApi(deps) {
252
278
  // UserPromptSubmit: a hook FAILURE must not block the turn, but blocked===true MUST throw.
253
279
  let promptDispatch = null;
254
280
  try {
255
- promptDispatch = await hooks.dispatch('UserPromptSubmit', hookCommonPayload({ session_id: session0.id, prompt }));
256
- } catch { /* hook failure never blocks the turn */ }
281
+ promptDispatch = await awaitTurn(
282
+ () => hooks.dispatch('UserPromptSubmit', hookCommonPayload({ session_id: session0.id, prompt })),
283
+ );
284
+ } catch {
285
+ throwIfAborted(turnSignal);
286
+ // Ordinary hook failure never blocks the turn.
287
+ }
257
288
  if (promptDispatch?.blocked === true) {
258
289
  throw new Error(`prompt blocked by hook: ${promptDispatch.reason || ''}`);
259
290
  }
@@ -264,7 +295,6 @@ export function createSessionTurnApi(deps) {
264
295
  .map((part) => String(part || '').trim())
265
296
  .filter(Boolean)
266
297
  .join('\n\n');
267
- providerStartedAt = performance.now();
268
298
  const result = await mgr.askSession(
269
299
  session0.id,
270
300
  prompt,
@@ -322,22 +352,36 @@ export function createSessionTurnApi(deps) {
322
352
  onToolApproval: options.onToolApproval,
323
353
  onCompactEvent: options.onCompactEvent,
324
354
  onStageChange: options.onStageChange,
355
+ onProviderSendStarted: (...args) => {
356
+ // This callback is emitted immediately before provider.send,
357
+ // after session locking, history shaping and pre-send compact.
358
+ // The old timestamp above mgr.askSession mislabeled all of that
359
+ // Mixdog work as provider latency.
360
+ if (!providerStartedAt) providerStartedAt = performance.now();
361
+ return options.onProviderSendStarted?.(...args);
362
+ },
363
+ onToolPhaseStarted: options.onToolPhaseStarted,
364
+ onToolPhaseCompleted: options.onToolPhaseCompleted,
325
365
  onStreamDelta: (...args) => {
326
366
  let value;
327
367
  try {
328
368
  value = options.onStreamDelta?.(...args);
329
369
  } finally {
330
- turnTimingStatus = 'first-delta';
331
- emitTurnTiming(turnTimingStatus);
332
- armHeavyRuntimeWarmup('first-stream');
370
+ if (isVisibleStreamProgress(args[0])) {
371
+ turnTimingStatus = 'first-visible';
372
+ emitTurnTiming(turnTimingStatus);
373
+ armHeavyRuntimeWarmup('first-visible');
374
+ }
333
375
  }
334
376
  return value;
335
377
  },
336
378
  drainSteering: options.drainSteering,
337
379
  onSteerMessage: options.onSteerMessage,
338
380
  notifyFn: notifyFnForSession(session0.id),
381
+ signal: turnSignal,
339
382
  },
340
383
  );
384
+ throwIfAborted(turnSignal);
341
385
  if (!turnTimingEmitted) turnTimingStatus = 'complete-no-delta';
342
386
  setSession(mgr.getSession(session0.id) || getSession());
343
387
  try { sessionTitles?.observeThird(getSession()); } catch { /* title refresh is best-effort */ }
@@ -354,8 +398,11 @@ export function createSessionTurnApi(deps) {
354
398
  }
355
399
  hooks.emit('turn:end', { sessionId: session0.id, elapsedMs: Date.now() - startedAt });
356
400
  try {
357
- await hooks.dispatch('Stop', hookCommonPayload({ session_id: session0.id }));
358
- } catch { /* best-effort: Stop hook must never break the turn */ }
401
+ await awaitTurn(() => hooks.dispatch('Stop', hookCommonPayload({ session_id: session0.id })));
402
+ } catch {
403
+ throwIfAborted(turnSignal);
404
+ // Ordinary Stop hook failure is best-effort.
405
+ }
359
406
  return { result, session: getSession() };
360
407
  } catch (error) {
361
408
  hooks.emit('turn:error', { sessionId: getSession()?.id || null, elapsedMs: Date.now() - startedAt, error: error?.message || String(error) });
@@ -372,8 +419,21 @@ export function createSessionTurnApi(deps) {
372
419
  } finally {
373
420
  emitTurnTiming(turnTimingStatus);
374
421
  armHeavyRuntimeWarmup('turn-settled');
375
- try { await turnSnapshotPromise; } catch { /* Git snapshot is optional */ }
376
- try { await completeTurnSnapshot(turnSnapshotSessionId); } catch { /* review cleanup never breaks a turn */ }
422
+ if (turnSignal.aborted) {
423
+ try { cancelTurnSnapshotForTurn(turnSnapshotSessionId); } catch {}
424
+ void Promise.resolve(turnSnapshotPromise).catch(() => {});
425
+ } else {
426
+ const snapshotCleanup = Promise.resolve(turnSnapshotPromise)
427
+ .then(() => completeTurnSnapshotForTurn(turnSnapshotSessionId));
428
+ snapshotCleanup.catch(() => {});
429
+ try {
430
+ await awaitTurn(() => settleWithin(snapshotCleanup, turnCleanupSettleMs));
431
+ } catch { /* optional review cleanup never overrides turn settlement */ }
432
+ if (turnSignal.aborted) {
433
+ try { cancelTurnSnapshotForTurn(turnSnapshotSessionId); } catch {}
434
+ }
435
+ }
436
+ try { unregisterTurnAbort?.(); } catch {}
377
437
  setActiveTurnCount(Math.max(0, getActiveTurnCount() - 1));
378
438
  if (!isFirstTurnCompleted()) {
379
439
  setFirstTurnCompleted(true);
@@ -399,6 +459,18 @@ export function createSessionTurnApi(deps) {
399
459
  pushTranscriptRebind?.();
400
460
  return true;
401
461
  },
462
+ // Message-selector rewind: drop the chosen user prompt and everything
463
+ // after it from the model history. Unlike clear() the session object is
464
+ // kept (same id, same provider/model/cwd) — only the tail is undone.
465
+ async rewindMessages(options = {}) {
466
+ const session = getSession();
467
+ if (!session?.id) return null;
468
+ const result = await mgr.rewindSessionMessagesTo?.(session.id, options);
469
+ if (!result) return null;
470
+ setSession(mgr.getSession(session.id) || session);
471
+ invalidateContextStatusCache();
472
+ return result;
473
+ },
402
474
  // session_manage tool handoff: the engine polls this at turn end and, if
403
475
  // set, runs the same clear path the idle auto-clear uses. One-shot read.
404
476
  consumePendingSessionReset() {
@@ -15,7 +15,7 @@ export function createSettingsApi({
15
15
  // config-lifecycle
16
16
  adoptConfig,
17
17
  saveConfigAndAdopt,
18
- scheduleBackendSave,
18
+ scheduleChannelProviderSave,
19
19
  scheduleSkillsSave,
20
20
  // normalizers / helpers
21
21
  cfgMod,
@@ -35,7 +35,6 @@ export function createSettingsApi({
35
35
  formatDurationMs,
36
36
  localPackageVersion,
37
37
  // state getters / feature flags
38
- memoryEnabled,
39
38
  recapEnabledFn,
40
39
  channelsEnabled,
41
40
  autoUpdateEnabled,
@@ -56,7 +55,7 @@ export function createSettingsApi({
56
55
  forgetDiscordToken,
57
56
  saveTelegramToken,
58
57
  forgetTelegramToken,
59
- setBackend,
58
+ setChannelProvider,
60
59
  }) {
61
60
  return {
62
61
  getOnboardingStatus() {
@@ -173,11 +172,11 @@ export function createSettingsApi({
173
172
  },
174
173
  getCompactionSettings() {
175
174
  const config = getConfig();
176
- return normalizeCompactionConfig(config.compaction, { memoryEnabled: memoryEnabled() });
175
+ return normalizeCompactionConfig(config.compaction);
177
176
  },
178
177
  setCompactionSettings(input = {}) {
179
178
  const config = getConfig();
180
- const current = normalizeCompactionConfig(config.compaction, { memoryEnabled: memoryEnabled() });
179
+ const current = normalizeCompactionConfig(config.compaction);
181
180
  const next = { ...current };
182
181
  if (hasOwn(input, 'auto')) next.auto = input.auto !== false;
183
182
  if (hasOwn(input, 'enabled')) next.auto = input.enabled !== false;
@@ -193,23 +192,23 @@ export function createSettingsApi({
193
192
  if (hasOwn(input, key)) next[key] = input[key];
194
193
  }
195
194
  const nextConfig = { ...config };
196
- nextConfig.compaction = normalizeCompactionConfig(next, { memoryEnabled: memoryEnabled() });
195
+ nextConfig.compaction = normalizeCompactionConfig(next);
197
196
  saveConfigAndAdopt(nextConfig);
198
197
  const config2 = getConfig();
199
198
  const session = getSession();
200
199
  if (session) {
201
200
  session.compaction = {
202
201
  ...(session.compaction || {}),
203
- ...normalizeCompactionConfig(config2.compaction, { memoryEnabled: memoryEnabled() }),
202
+ ...normalizeCompactionConfig(config2.compaction),
204
203
  };
205
204
  }
206
205
  invalidateContextStatusCache();
207
- return normalizeCompactionConfig(config2.compaction, { memoryEnabled: memoryEnabled() });
206
+ return normalizeCompactionConfig(config2.compaction);
208
207
  },
209
208
  // Recap toggle: user-facing switch that gates ONLY the background memory
210
209
  // cycles (1/2/3). The memory module (transcript watcher/ingest, on-demand
211
210
  // recall/fasttrack drains) is always-on. Persisted via the same
212
- // saveConfigAndAdopt path as compaction/autoClear. The memory daemon
211
+ // saveConfigAndAdopt path as compaction/autoClear. The memory runtime
213
212
  // re-reads recap from the agent config section each cycle tick, so toggling
214
213
  // takes effect without a restart (no memory-service stop/start here).
215
214
  getRecapSettings() {
@@ -223,17 +222,6 @@ export function createSettingsApi({
223
222
  invalidateContextStatusCache();
224
223
  return this.getRecapSettings();
225
224
  },
226
- // Thin aliases kept for the current TUI callsites (updated separately).
227
- // enabled here reflects the recap toggle; memory itself is always-on.
228
- getMemorySettings() {
229
- return {
230
- enabled: recapEnabledFn(),
231
- compactFastTrackAvailable: true,
232
- };
233
- },
234
- async setMemoryEnabled(enabled) {
235
- return this.setRecapEnabled(enabled);
236
- },
237
225
  getChannelSettings(options = {}) {
238
226
  return {
239
227
  enabled: channelsEnabled(),
@@ -362,7 +350,7 @@ export function createSettingsApi({
362
350
  reloadChannelsSoon();
363
351
  return result;
364
352
  },
365
- setBackend(name) {
353
+ setChannelProvider(name) {
366
354
  // Validate synchronously (same contract as before: bad input throws on
367
355
  // this call so the TUI's try/catch can react immediately). The actual
368
356
  // channels-section file read-modify-write is the hitch source on the
@@ -371,10 +359,10 @@ export function createSettingsApi({
371
359
  // disk synchronously inside the key handler.
372
360
  const value = String(name || '').trim();
373
361
  if (value !== 'discord' && value !== 'telegram') {
374
- throw new Error('backend must be discord or telegram');
362
+ throw new Error('provider must be discord or telegram');
375
363
  }
376
- scheduleBackendSave(value);
377
- return { ok: true, backend: value };
364
+ scheduleChannelProviderSave(value);
365
+ return { ok: true, provider: value };
378
366
  },
379
367
  };
380
368
  }
@@ -13,7 +13,7 @@ export const TOOL_SEARCH_TOOL = {
13
13
  openWorldHint: false,
14
14
  agentHidden: true,
15
15
  },
16
- description: 'Load deferred tools by exact name via names[]; reports loaded/already-active/missing plus MCP servers still connecting or failed. Deferred tools also auto-load when called directly.',
16
+ description: 'Deferred-tool activation status; direct calls auto-load.',
17
17
  inputSchema: {
18
18
  type: 'object',
19
19
  properties: {
@@ -34,7 +34,7 @@ export const CWD_TOOL = {
34
34
  openWorldHint: false,
35
35
  agentHidden: true,
36
36
  },
37
- description: 'Show or set the session work project for tool execution. Use only for explicit project path changes.',
37
+ description: 'Show or set the session work project for tool execution.',
38
38
  inputSchema: {
39
39
  type: 'object',
40
40
  properties: {
@@ -84,7 +84,7 @@ export const SESSION_MANAGE_TOOL = {
84
84
  openWorldHint: false,
85
85
  agentHidden: true,
86
86
  },
87
- description: 'Reset this conversation on explicit user request. action=clear wipes all context (like /clear); action=compact_clear summarizes first and carries context forward (auto-clear style). Applies when the current turn ends.',
87
+ description: 'Schedule conversation reset at current-turn end.',
88
88
  inputSchema: {
89
89
  type: 'object',
90
90
  properties: {
@@ -10,7 +10,6 @@ import {
10
10
  workflowPresetId,
11
11
  WORKFLOW_ROUTE_SLOTS,
12
12
  FIXED_AGENT_SLOTS,
13
- agentPresetSlot,
14
13
  normalizeAgentId,
15
14
  normalizeWorkflowId,
16
15
  workflowIdFromName,
@@ -24,6 +23,7 @@ import { ONBOARDING_VERSION } from './quick-search-models.mjs';
24
23
  import { findOutputStyle } from './output-styles.mjs';
25
24
  import { ensureProviderEnabled } from './config-helpers.mjs';
26
25
  import { fastCapableFor } from './model-capabilities.mjs';
26
+ import { canonicalizeAgentRouteStorage } from '../runtime/shared/agent-route-config.mjs';
27
27
 
28
28
  // Onboarding + agents/workflows/output-style selection surface. Extracted
29
29
  // verbatim from the runtime API object; stateless helpers are imported directly
@@ -46,67 +46,54 @@ export function createWorkflowAgentsApi(deps) {
46
46
  // Search/agent picks) omits defaultRoute entirely and must NOT persist the
47
47
  // current route as Main or recreate the session.
48
48
  const config = getConfig();
49
- const defaultRoute = hasOwn(payload, 'defaultRoute')
50
- ? normalizeWorkflowRoute(payload.defaultRoute, getRoute())
51
- : null;
52
49
  const workflowInput = payload.workflowRoutes && typeof payload.workflowRoutes === 'object'
53
50
  ? payload.workflowRoutes
54
51
  : {};
55
52
  const nextConfig = { ...config };
56
- if (hasOwn(payload, 'defaultProvider')) {
57
- const requested = clean(payload.defaultProvider);
58
- if (requested) {
59
- if (!isKnownProvider(requested)) throw new Error(`unknown provider "${payload.defaultProvider}"`);
60
- nextConfig.defaultProvider = requested;
61
- }
62
- }
53
+ const defaultRoute = hasOwn(payload, 'defaultRoute')
54
+ ? normalizeWorkflowRoute(payload.defaultRoute, getRoute())
55
+ : null;
63
56
  let presets = Array.isArray(nextConfig.presets) ? nextConfig.presets.slice() : [];
64
- const workflowRoutes = { ...(nextConfig.workflowRoutes || {}) };
65
- const touchedWorkflowSlots = new Set();
57
+ const agentRoutes = { ...(nextConfig.agents || {}) };
66
58
 
67
59
  if (defaultRoute) {
68
60
  presets = upsertWorkflowPreset(presets, 'lead', defaultRoute);
69
- workflowRoutes.lead = defaultRoute;
70
61
  nextConfig.default = workflowPresetId('lead');
71
62
  }
72
63
 
73
64
  for (const slot of WORKFLOW_ROUTE_SLOTS) {
65
+ if (!hasOwn(workflowInput, slot)) continue;
74
66
  const normalized = normalizeWorkflowRoute(workflowInput[slot]);
75
- if (!normalized) continue;
76
- workflowRoutes[slot] = normalized;
77
- presets = upsertWorkflowPreset(presets, slot, normalized);
78
- touchedWorkflowSlots.add(slot);
67
+ if (slot === 'lead') {
68
+ if (!normalized) continue;
69
+ presets = upsertWorkflowPreset(presets, 'lead', normalized);
70
+ nextConfig.default = workflowPresetId('lead');
71
+ } else if (slot === 'agent') {
72
+ if (normalized) agentRoutes.worker = normalized;
73
+ else delete agentRoutes.worker;
74
+ } else if (slot === 'explorer') {
75
+ if (normalized) agentRoutes.explore = normalized;
76
+ else delete agentRoutes.explore;
77
+ } else if (slot === 'memory') {
78
+ if (normalized) agentRoutes.maintainer = normalized;
79
+ else delete agentRoutes.maintainer;
80
+ }
79
81
  }
80
82
 
81
83
  nextConfig.presets = presets;
82
- nextConfig.workflowRoutes = workflowRoutes;
83
- nextConfig.maintenance = {
84
- ...(nextConfig.maintenance || {}),
85
- ...(touchedWorkflowSlots.has('explorer') ? { explore: normalizeWorkflowRoute(workflowRoutes.explorer) } : {}),
86
- ...(touchedWorkflowSlots.has('memory') ? { memory: normalizeWorkflowRoute(workflowRoutes.memory) } : {}),
87
- };
84
+ nextConfig.agents = agentRoutes;
88
85
  const agentInput = payload.agentRoutes && typeof payload.agentRoutes === 'object'
89
86
  ? payload.agentRoutes
90
87
  : null;
91
88
  if (agentInput) {
92
- const nextAgents = { ...(nextConfig.agents || {}) };
93
- const nextMaintenance = { ...(nextConfig.maintenance || {}) };
89
+ const nextAgents = { ...agentRoutes };
94
90
  for (const agent of FIXED_AGENT_SLOTS) {
91
+ if (!hasOwn(agentInput, agent.id)) continue;
95
92
  const routeToSave = normalizeWorkflowRoute(agentInput[agent.id]);
96
- if (!routeToSave) continue;
97
- nextAgents[agent.id] = routeToSave;
98
- presets = upsertWorkflowPreset(presets, agentPresetSlot(agent.id), routeToSave);
99
- if (agent.workflowSlot) {
100
- workflowRoutes[agent.workflowSlot] = routeToSave;
101
- presets = upsertWorkflowPreset(presets, agent.workflowSlot, routeToSave);
102
- if (agent.id === 'explore') nextMaintenance.explore = routeToSave;
103
- if (agent.id === 'maintainer') nextMaintenance.memory = routeToSave;
104
- }
93
+ if (routeToSave) nextAgents[agent.id] = routeToSave;
94
+ else delete nextAgents[agent.id];
105
95
  }
106
96
  nextConfig.agents = nextAgents;
107
- nextConfig.presets = presets;
108
- nextConfig.workflowRoutes = workflowRoutes;
109
- nextConfig.maintenance = nextMaintenance;
110
97
  }
111
98
  nextConfig.onboarding = {
112
99
  ...(nextConfig.onboarding || {}),
@@ -116,11 +103,13 @@ export function createWorkflowAgentsApi(deps) {
116
103
  };
117
104
 
118
105
  if (payload.searchRoute) {
119
- const searchToSave = normalizeSearchRouteConfig(payload.searchRoute);
106
+ const searchToSave = clean(payload.searchRoute.provider)
107
+ ? normalizeSearchRouteConfig(payload.searchRoute)
108
+ : normalizeSearchRouteConfig({ provider: 'default', model: 'default', toolType: payload.searchRoute.toolType });
120
109
  if (searchToSave) nextConfig.searchRoute = searchToSave;
121
110
  }
122
111
 
123
- saveConfigAndAdopt(nextConfig);
112
+ saveConfigAndAdopt(canonicalizeAgentRouteStorage(nextConfig));
124
113
  if (defaultRoute) {
125
114
  setRouteState(resolveRoute(getConfig(), { provider: defaultRoute.provider, model: defaultRoute.model, effort: defaultRoute.effort }));
126
115
  const session = getSession();
@@ -362,12 +351,6 @@ export function createWorkflowAgentsApi(deps) {
362
351
  clearAgentDefinitionCache(id);
363
352
  if (payload.route) {
364
353
  await this.setAgentRoute(id, payload.route);
365
- } else if (!FIXED_AGENT_SLOTS.some((agent) => agent.id === id)
366
- && !agentRouteFromConfig(getConfig(), id, dataDir)) {
367
- // Custom roles have no built-in preset fallback, so a model-less save
368
- // would spawn as `agent "<id>" has no model assignment`. Seed the
369
- // current default route; an unconfigured install just stays routeless.
370
- try { await this.setAgentRoute(id, {}); } catch { /* no default route yet */ }
371
354
  }
372
355
  return this.getAgentDefinition(id);
373
356
  },
@@ -412,10 +395,7 @@ export function createWorkflowAgentsApi(deps) {
412
395
  delete agents[id];
413
396
  nextConfig.agents = agents;
414
397
  }
415
- const presetId = workflowPresetId(agentPresetSlot(id));
416
- nextConfig.presets = (Array.isArray(nextConfig.presets) ? nextConfig.presets : [])
417
- .filter((preset) => clean(preset?.id) !== presetId);
418
- saveConfigAndAdopt(nextConfig);
398
+ saveConfigAndAdopt(canonicalizeAgentRouteStorage(nextConfig));
419
399
  }
420
400
  return { id, deleted: true, revertedToBuiltIn };
421
401
  },
@@ -435,23 +415,31 @@ export function createWorkflowAgentsApi(deps) {
435
415
  const requested = { ...(next || {}) };
436
416
  const routeDataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
437
417
  const stored = agentRouteFromConfig(getConfig(), id, routeDataDir) || {};
438
- // A request with neither provider nor model is the "seed from the current
439
- // default" path (new custom agent) and inherits Main verbatim.
440
- const inheritsDefault = !clean(requested.provider) && !clean(requested.model);
441
- let selectedRoute = resolveRoute(getConfig(), requested);
442
- if (!inheritsDefault) {
443
- const sameModel = clean(selectedRoute.provider) === clean(stored.provider)
444
- && clean(selectedRoute.model) === clean(stored.model);
445
- selectedRoute = {
446
- ...selectedRoute,
447
- effort: requested.effort !== undefined
448
- ? selectedRoute.effort
449
- : (sameModel ? (stored.effort || null) : null),
450
- fast: requested.fast !== undefined
451
- ? selectedRoute.fast === true
452
- : (sameModel && stored.fast === true),
453
- };
418
+ // No provider means no explicit override. Remove the stored route so the
419
+ // agent follows Main dynamically; never synthesize a provider.
420
+ if (!clean(requested.provider)) {
421
+ const nextConfig = { ...getConfig() };
422
+ const agents = { ...(nextConfig.agents || {}) };
423
+ const hadOverride = Object.prototype.hasOwnProperty.call(agents, id);
424
+ delete agents[id];
425
+ nextConfig.agents = agents;
426
+ if (hadOverride) saveConfigAndAdopt(canonicalizeAgentRouteStorage(nextConfig));
427
+ const inherited = normalizeWorkflowRoute(resolveRoute(getConfig(), {}));
428
+ return { ...(inherited || {}), inherited: true };
454
429
  }
430
+ if (!clean(requested.model)) throw new Error('agent route requires provider and model');
431
+ let selectedRoute = resolveRoute(getConfig(), requested);
432
+ const sameModel = clean(selectedRoute.provider) === clean(stored.provider)
433
+ && clean(selectedRoute.model) === clean(stored.model);
434
+ selectedRoute = {
435
+ ...selectedRoute,
436
+ effort: requested.effort !== undefined
437
+ ? selectedRoute.effort
438
+ : (sameModel ? (stored.effort || null) : null),
439
+ fast: requested.fast !== undefined
440
+ ? selectedRoute.fast === true
441
+ : (sameModel && stored.fast === true),
442
+ };
455
443
  await ensureProvidersReady(ensureProviderEnabled(getConfig(), selectedRoute.provider));
456
444
  const modelMeta = await lookupModelMeta(selectedRoute.provider, selectedRoute.model);
457
445
  const fastCapable = fastCapableFor(selectedRoute.provider, modelMeta);
@@ -459,26 +447,12 @@ export function createWorkflowAgentsApi(deps) {
459
447
 
460
448
  const routeToSave = normalizeWorkflowRoute(selectedRoute);
461
449
  if (!routeToSave) throw new Error('agent route requires provider and model');
462
- const agent = FIXED_AGENT_SLOTS.find((item) => item.id === id);
463
450
  const nextConfig = { ...getConfig() };
464
451
  nextConfig.agents = {
465
452
  ...(nextConfig.agents || {}),
466
453
  [id]: routeToSave,
467
454
  };
468
- nextConfig.presets = upsertWorkflowPreset(nextConfig.presets, agentPresetSlot(id), routeToSave);
469
- if (agent?.workflowSlot) {
470
- nextConfig.workflowRoutes = {
471
- ...(nextConfig.workflowRoutes || {}),
472
- [agent.workflowSlot]: routeToSave,
473
- };
474
- nextConfig.presets = upsertWorkflowPreset(nextConfig.presets, agent.workflowSlot, routeToSave);
475
- nextConfig.maintenance = {
476
- ...(nextConfig.maintenance || {}),
477
- ...(id === 'explore' ? { explore: routeToSave } : {}),
478
- ...(id === 'maintainer' ? { memory: routeToSave } : {}),
479
- };
480
- }
481
- saveConfigAndAdopt(nextConfig);
455
+ saveConfigAndAdopt(canonicalizeAgentRouteStorage(nextConfig));
482
456
  return routeToSave;
483
457
  },
484
458
  };
@@ -9,6 +9,7 @@ import { normalizeEffortInput } from './effort.mjs';
9
9
  import { isLikelyRawModelId } from './config-helpers.mjs';
10
10
  import { readTextSafe, readJsonSafe } from './fs-utils.mjs';
11
11
  import { isHiddenAgent } from '../runtime/agent/orchestrator/internal-agents.mjs';
12
+ import { configuredAgentRouteCandidates } from '../runtime/shared/agent-route-config.mjs';
12
13
 
13
14
  export const WORKFLOW_ROUTE_SLOTS = ['lead', 'agent', 'explorer', 'memory'];
14
15
  export const FIXED_AGENT_SLOTS = Object.freeze([
@@ -415,16 +416,16 @@ export function upsertWorkflowPreset(presets, slot, routeLike) {
415
416
  // the runtime; created via this factory.
416
417
  export function createWorkflowRouteHelpers({ resolveDefaultProvider, findPreset }) {
417
418
  function summarizeWorkflowRoutes(config) {
418
- const routes = config?.workflowRoutes && typeof config.workflowRoutes === 'object' ? config.workflowRoutes : {};
419
- const fallbackProvider = resolveDefaultProvider(config);
420
419
  const out = {};
421
- for (const slot of WORKFLOW_ROUTE_SLOTS) {
422
- const route = routes[slot];
423
- // Read/interpret path: a route with a model but no provider falls back to
424
- // config.defaultProvider (then DEFAULT_PROVIDER).
425
- if (route?.model && (route?.provider || fallbackProvider)) {
426
- out[slot] = normalizeWorkflowRoute(route, { provider: fallbackProvider });
427
- }
420
+ const lead = routeFromPreset(config, config?.default);
421
+ if (lead) out.lead = lead;
422
+ for (const [slot, agentId] of [
423
+ ['agent', 'worker'],
424
+ ['explorer', 'explore'],
425
+ ['memory', 'maintainer'],
426
+ ]) {
427
+ const route = agentRouteFromConfig(config, agentId);
428
+ if (route) out[slot] = route;
428
429
  }
429
430
  return out;
430
431
  }
@@ -446,22 +447,11 @@ export function createWorkflowRouteHelpers({ resolveDefaultProvider, findPreset
446
447
  // their routes live in config.agents[<id>] like the fixed roles.
447
448
  const id = normalizeAgentId(agentId) || normalizeWorkflowId(agentId);
448
449
  if (!id) return null;
449
- // Read/interpret path: inject config.defaultProvider (then DEFAULT_PROVIDER)
450
- // when a stored route omits its provider.
451
- const fallback = { provider: resolveDefaultProvider(config) };
452
- const explicit = normalizeWorkflowRoute(config?.agents?.[id], fallback)
453
- || (id === 'maintainer' ? normalizeWorkflowRoute(config?.agents?.maintenance, fallback) : null);
454
- if (explicit) return explicit;
455
-
456
- const agent = FIXED_AGENT_SLOTS.find((item) => item.id === id);
457
- if (agent?.workflowSlot) {
458
- const workflowRoute = normalizeWorkflowRoute(config?.workflowRoutes?.[agent.workflowSlot], fallback);
459
- if (workflowRoute) return workflowRoute;
450
+ for (const candidate of configuredAgentRouteCandidates(config, id)) {
451
+ const route = normalizeWorkflowRoute(candidate)
452
+ || routeFromPreset(config, candidate);
453
+ if (route) return route;
460
454
  }
461
-
462
- if (id === 'explore') return routeFromPreset(config, config?.maintenance?.explore);
463
- if (id === 'maintainer') return routeFromPreset(config, config?.maintenance?.memory);
464
-
465
455
  return null;
466
456
  }
467
457