mixdog 0.9.95 → 0.9.96

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 (178) 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/package.json +12 -9
  6. package/scripts/.tmp-coverage-report.mjs +96 -0
  7. package/scripts/build-tui.mjs +17 -1
  8. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  9. package/src/defaults/mixdog-config.template.json +5 -8
  10. package/src/rules/agent/30-explorer.md +7 -4
  11. package/src/rules/shared/01-tool.md +20 -29
  12. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  13. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  14. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  16. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  17. package/src/runtime/agent/orchestrator/mcp/client.mjs +26 -11
  18. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  19. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  20. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  21. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  22. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  23. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  25. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  26. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  28. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  29. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  30. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  31. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  32. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +28 -4
  34. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +4 -33
  35. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  36. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  37. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  38. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -27
  39. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -10
  40. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +5 -0
  41. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  42. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  44. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  45. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  46. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  47. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  48. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +12 -0
  49. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -33
  50. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  51. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  53. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  54. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +118 -18
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  56. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  58. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  59. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -26
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +3 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -23
  63. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +23 -9
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  66. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  67. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  70. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  72. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +87 -15
  73. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +6 -15
  74. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  76. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  77. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +29 -1
  78. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  79. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  80. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +10 -10
  81. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -8
  82. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  83. package/src/runtime/channels/lib/config.mjs +2 -2
  84. package/src/runtime/channels/lib/inbound-handler.mjs +1 -1
  85. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  86. package/src/runtime/memory/index.mjs +5 -38
  87. package/src/runtime/memory/lib/agent-ipc.mjs +145 -81
  88. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  89. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  90. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +12 -33
  91. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  92. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  93. package/src/runtime/memory/lib/http-router.mjs +8 -0
  94. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  95. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  96. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  97. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  98. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  99. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  100. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  101. package/src/runtime/memory/tool-defs.mjs +1 -5
  102. package/src/runtime/search/tool-defs.mjs +2 -2
  103. package/src/runtime/shared/agent-route-config.mjs +119 -0
  104. package/src/runtime/shared/atomic-file.mjs +80 -4
  105. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  106. package/src/runtime/shared/child-guardian.mjs +204 -108
  107. package/src/runtime/shared/child-spawn-gate.mjs +60 -31
  108. package/src/runtime/shared/config.mjs +130 -44
  109. package/src/runtime/shared/resource-admission.mjs +146 -97
  110. package/src/runtime/shared/stream-progress.mjs +6 -0
  111. package/src/runtime/shared/tool-surface.mjs +0 -5
  112. package/src/runtime/shared/turn-snapshot.mjs +31 -5
  113. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  114. package/src/session-runtime/config-helpers.mjs +39 -37
  115. package/src/session-runtime/model-route-api.mjs +7 -5
  116. package/src/session-runtime/provider-auth-api.mjs +0 -7
  117. package/src/session-runtime/runtime-core.mjs +11 -11
  118. package/src/session-runtime/runtime-tunables.mjs +4 -4
  119. package/src/session-runtime/session-lifecycle.mjs +9 -2
  120. package/src/session-runtime/session-turn-api.mjs +41 -9
  121. package/src/session-runtime/settings-api.mjs +5 -17
  122. package/src/session-runtime/tool-defs.mjs +3 -3
  123. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  124. package/src/session-runtime/workflow.mjs +14 -24
  125. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  126. package/src/standalone/agent-tool/helpers.mjs +2 -2
  127. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  128. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  129. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  130. package/src/standalone/backend-daemon.mjs +97 -36
  131. package/src/standalone/channel-admin.mjs +12 -12
  132. package/src/standalone/channel-daemon-client.mjs +17 -1
  133. package/src/standalone/channel-daemon-transport.mjs +90 -8
  134. package/src/standalone/engine-daemon-client.mjs +380 -205
  135. package/src/standalone/engine-daemon-protocol.mjs +3 -4
  136. package/src/standalone/engine-daemon-service.mjs +378 -134
  137. package/src/standalone/engine-daemon-transport.mjs +268 -77
  138. package/src/standalone/explore-tool.mjs +187 -70
  139. package/src/standalone/fair-call-scheduler.mjs +264 -0
  140. package/src/standalone/session-protocol.mjs +188 -0
  141. package/src/tui/App.jsx +30 -17
  142. package/src/tui/app/app-view.jsx +5 -2
  143. package/src/tui/app/core-memory-picker.mjs +19 -19
  144. package/src/tui/app/doctor.mjs +5 -11
  145. package/src/tui/app/message-selector.mjs +103 -0
  146. package/src/tui/app/onboarding-steps.mjs +1 -2
  147. package/src/tui/app/project-picker.mjs +80 -56
  148. package/src/tui/app/prompt-submit.mjs +46 -41
  149. package/src/tui/app/resume-picker.mjs +2 -2
  150. package/src/tui/app/route-pickers.mjs +3 -1
  151. package/src/tui/app/settings-picker.mjs +6 -8
  152. package/src/tui/app/transcript-window.mjs +38 -0
  153. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  154. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  155. package/src/tui/app/use-prompt-hint.mjs +3 -2
  156. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  157. package/src/tui/app/use-transcript-scroll.mjs +9 -2
  158. package/src/tui/app/use-transcript-window.mjs +50 -17
  159. package/src/tui/components/PromptInput.jsx +71 -38
  160. package/src/tui/components/Spinner.jsx +1 -1
  161. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  162. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  163. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  164. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  165. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  166. package/src/tui/dist/index.mjs +920 -1592
  167. package/src/tui/engine/live-share.mjs +34 -0
  168. package/src/tui/engine/session-api-ext.mjs +1 -4
  169. package/src/tui/engine/session-api.mjs +103 -33
  170. package/src/tui/engine/session-flow.mjs +7 -1
  171. package/src/tui/engine/tool-card-results.mjs +4 -2
  172. package/src/tui/engine/tool-result-text.mjs +10 -0
  173. package/src/tui/engine/turn.mjs +9 -69
  174. package/src/tui/engine-local-session.mjs +47 -31
  175. package/src/tui/index.jsx +1 -2
  176. package/src/tui/spinner-meta.mjs +4 -4
  177. package/src/tui/spinner-verbs.mjs +51 -3
  178. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
@@ -1,5 +1,9 @@
1
1
  import { resolvePluginData } from '../../shared/plugin-paths.mjs';
2
2
  import { readSection, updateSection, updateSectionAsync, getAgentApiKey, AGENT_PROVIDER_ENV } from '../../shared/config.mjs';
3
+ import {
4
+ agentRouteStorageNeedsMigration,
5
+ canonicalizeAgentRouteStorage,
6
+ } from '../../shared/agent-route-config.mjs';
3
7
  import { OPENAI_COMPAT_PRESETS } from './providers/openai-compat-presets.mjs';
4
8
  import {
5
9
  hasAnthropicOAuthCredentials,
@@ -7,20 +11,6 @@ import {
7
11
  hasGrokOAuthCredentials,
8
12
  } from './providers/oauth-credential-probes.mjs';
9
13
 
10
- // Keep config loading free of standalone provider-admin and its provider
11
- // runtimes. These identifiers mirror that module's static catalog, assembled
12
- // solely from the lightweight config/provider constants already imported here.
13
- const CONFIG_PROVIDER_IDS = new Set([
14
- ...Object.keys(AGENT_PROVIDER_ENV),
15
- ...Object.keys(OPENAI_COMPAT_PRESETS),
16
- 'openai-oauth',
17
- 'anthropic-oauth',
18
- 'grok-oauth',
19
- ]);
20
- function isConfiguredProviderId(provider) {
21
- return CONFIG_PROVIDER_IDS.has(String(provider || '').trim());
22
- }
23
-
24
14
  // Thin wrapper around resolvePluginData so callers in this orchestrator tree
25
15
  // can import a single helper without reaching into shared/.
26
16
  export function getPluginData() {
@@ -33,19 +23,13 @@ export function getPluginData() {
33
23
  // llm/index.mjs and setup-server.mjs so UI/runtime cannot drift from config.
34
24
  //
35
25
  // Explore and Maintainer start without a route so they dynamically inherit the
36
- // Main route. Explicit `maintenance.explore` / `maintenance.memory` choices
37
- // remain supported. The memory cycles (chunker / re-scorer / core reviewer)
38
- // share the optional `memory` preset knob — the cycle agents stay separate
39
- // (cycle1/2/3-agent, distinct slots and invokedBy) but resolve their model from
40
- // `maint.memory` via the `maintKey` override on their hidden-role entries.
26
+ // Main route. Their explicit routes live canonically in `agents.explore` and
27
+ // `agents.maintainer`; load-time migration still accepts the older workflow /
28
+ // maintenance aliases.
41
29
  // scheduler/webhook still let a per-entry config.json model win first (the
42
30
  // caller passes it explicitly via opts.preset); the haiku default below only
43
31
  // applies when an entry omits its own model.
44
- // Slots surfaced as tunable rows in the Maintenance Setup panel. This is the
45
- // UI / allow-list view (GET cleanup + POST validation) and is intentionally a
46
- // SUBSET of DEFAULT_MAINTENANCE: scheduler/webhook carry a per-entry model and
47
- // are not shown as shared rows, but still inherit the haiku default above when
48
- // an entry omits its own model.
32
+ // Legacy route slots accepted only at config ingress for migration.
49
33
  const MAINTENANCE_SLOTS = Object.freeze(['explore', 'memory']);
50
34
 
51
35
  // --- User profile (statusline /profile) -------------------------------------
@@ -213,6 +197,13 @@ function normalizeSearchRoute(route) {
213
197
  return null;
214
198
  const provider = normalizeAgentProviderId(route.provider);
215
199
  const model = String(route.model || '').trim();
200
+ if (!provider && model) {
201
+ return {
202
+ provider: 'default',
203
+ model: 'default',
204
+ ...(String(route.toolType || '').trim() ? { toolType: String(route.toolType).trim() } : {}),
205
+ };
206
+ }
216
207
  if (!provider || !model)
217
208
  return null;
218
209
  const out = { provider, model };
@@ -230,17 +221,11 @@ function normalizeSearchRoute(route) {
230
221
  // Normalize stored maintenance slots to the direct {provider, model} route
231
222
  // shape (provider/model/effort/fast only). Non-route values are dropped so the
232
223
  // DEFAULT_MAINTENANCE route fills the slot.
233
- function normalizeMaintenanceRoutes(rawMaint, defaultProvider) {
224
+ function normalizeMaintenanceRoutes(rawMaint) {
234
225
  const out = {};
235
- const configuredProvider = String(defaultProvider || '').trim();
236
- const fallbackProvider = isConfiguredProviderId(configuredProvider)
237
- ? configuredProvider
238
- : 'anthropic-oauth';
239
226
  for (const [slot, value] of Object.entries(rawMaint || {})) {
240
227
  if (value && typeof value === 'object' && !Array.isArray(value)) {
241
- const provider = normalizeAgentProviderId(
242
- value.provider || ((slot === 'explore' || slot === 'memory') ? fallbackProvider : ''),
243
- );
228
+ const provider = normalizeAgentProviderId(value.provider);
244
229
  const model = String(value.model || '').trim();
245
230
  if (provider && model) {
246
231
  const route = { provider, model };
@@ -281,6 +266,174 @@ function normalizeRecapConfig(rawRecap) {
281
266
  return recap;
282
267
  }
283
268
 
269
+ function configObject(value) {
270
+ return value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
271
+ }
272
+
273
+ function nonEmptyConfigObject(value) {
274
+ return Object.keys(value).length > 0 ? value : undefined;
275
+ }
276
+
277
+ function normalizeStoredCompactionType(value) {
278
+ const raw = String(value ?? '').trim().toLowerCase().replace(/_/g, '-');
279
+ if (['1', 'type1', 'type-1', 'semantic', 'summary', 'default'].includes(raw)) return 'semantic';
280
+ if (['2', 'type2', 'type-2', 'recall', 'recall-fast', 'recall-fasttrack',
281
+ 'recall-fast-track', 'fasttrack', 'fast-track'].includes(raw)) return 'recall-fasttrack';
282
+ return 'recall-fasttrack';
283
+ }
284
+
285
+ function canonicalizeAutoClearStorage(value) {
286
+ const raw = configObject(value);
287
+ const next = { ...raw };
288
+ const idleMs = Number(raw.idleMs ?? raw.thresholdMs ?? raw.idleMillis);
289
+ const providerSource = configObject(raw.providerIdleMs ?? raw.providerDefaults ?? raw.providers);
290
+ const providerIdleMs = {};
291
+ for (const [key, candidate] of Object.entries(providerSource)) {
292
+ const provider = String(key || '').trim().toLowerCase();
293
+ const duration = Number(candidate);
294
+ if (!provider || !Number.isFinite(duration) || duration <= 0) continue;
295
+ providerIdleMs[provider] = Math.max(60_000, Math.round(duration));
296
+ }
297
+ delete next.thresholdMs;
298
+ delete next.idleMillis;
299
+ delete next.providerDefaults;
300
+ delete next.providers;
301
+ delete next.custom;
302
+ if (Number.isFinite(idleMs) && idleMs > 0) next.idleMs = Math.max(60_000, Math.round(idleMs));
303
+ else delete next.idleMs;
304
+ if (Object.keys(providerIdleMs).length) next.providerIdleMs = providerIdleMs;
305
+ else delete next.providerIdleMs;
306
+ if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) next.enabled = raw.enabled !== false;
307
+ if (Object.prototype.hasOwnProperty.call(raw, 'minContextPercent')) {
308
+ const percent = Number(raw.minContextPercent);
309
+ if (Number.isFinite(percent)) next.minContextPercent = Math.min(100, Math.max(0, Math.round(percent)));
310
+ else delete next.minContextPercent;
311
+ }
312
+ return nonEmptyConfigObject(next);
313
+ }
314
+
315
+ function canonicalizeCompactionStorage(value) {
316
+ const raw = configObject(value);
317
+ const next = { ...raw };
318
+ const hasType = ['type', 'compactType', 'compact_type']
319
+ .some((key) => Object.prototype.hasOwnProperty.call(raw, key));
320
+ if (hasType) next.type = normalizeStoredCompactionType(raw.type ?? raw.compactType ?? raw.compact_type);
321
+ if (Object.prototype.hasOwnProperty.call(raw, 'auto')
322
+ || Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
323
+ next.auto = raw.auto !== false && raw.enabled !== false;
324
+ }
325
+ delete next.compactType;
326
+ delete next.compact_type;
327
+ delete next.enabled;
328
+ return nonEmptyConfigObject(next);
329
+ }
330
+
331
+ function canonicalizeShellStorage(value) {
332
+ const raw = configObject(value);
333
+ const next = { ...raw };
334
+ const command = String(raw.command ?? raw.path ?? raw.executable ?? raw.shell ?? '').trim();
335
+ delete next.path;
336
+ delete next.executable;
337
+ delete next.shell;
338
+ if (command) next.command = command;
339
+ else delete next.command;
340
+ return nonEmptyConfigObject(next);
341
+ }
342
+
343
+ function canonicalizeModulesStorage(value) {
344
+ const modules = configObject(value);
345
+ delete modules.memory;
346
+ return nonEmptyConfigObject(modules);
347
+ }
348
+
349
+ function migrateLegacyModelSettings(raw = {}) {
350
+ const modelSettings = raw.modelSettings && typeof raw.modelSettings === 'object'
351
+ ? { ...raw.modelSettings }
352
+ : {};
353
+ const legacyFastModels = raw.fastModels && typeof raw.fastModels === 'object'
354
+ ? raw.fastModels
355
+ : {};
356
+ for (const [key, enabled] of Object.entries(legacyFastModels)) {
357
+ if (enabled !== true) continue;
358
+ const current = modelSettings[key];
359
+ if (!current || typeof current !== 'object' || Array.isArray(current)) {
360
+ modelSettings[key] = { fast: true };
361
+ } else if (!Object.prototype.hasOwnProperty.call(current, 'fast')) {
362
+ modelSettings[key] = { ...current, fast: true };
363
+ }
364
+ }
365
+ return modelSettings;
366
+ }
367
+
368
+ function canonicalizeLegacyAgentStorage(value = {}) {
369
+ const next = canonicalizeAgentRouteStorage(value);
370
+ next.presets = Array.isArray(next.presets)
371
+ ? next.presets.map((preset) => normalizePreset(preset)).filter(Boolean)
372
+ : [];
373
+ next.modelSettings = migrateLegacyModelSettings(value);
374
+ const autoClear = canonicalizeAutoClearStorage(next.autoClear);
375
+ if (autoClear) next.autoClear = autoClear;
376
+ else delete next.autoClear;
377
+ const compaction = canonicalizeCompactionStorage(next.compaction);
378
+ if (compaction) next.compaction = compaction;
379
+ else delete next.compaction;
380
+ const shell = canonicalizeShellStorage(next.shell);
381
+ if (shell) next.shell = shell;
382
+ else delete next.shell;
383
+ if (Object.prototype.hasOwnProperty.call(next, 'profile')) {
384
+ next.profile = normalizeProfileConfig(next.profile);
385
+ }
386
+ if (Object.prototype.hasOwnProperty.call(next, 'skills')) {
387
+ const skills = normalizeSkillsConfig(next.skills);
388
+ if (skills.disabled.length) next.skills = skills;
389
+ else delete next.skills;
390
+ }
391
+ if (!next.searchRoute && value?.search && typeof value.search === 'object') {
392
+ next.searchRoute = value.search;
393
+ }
394
+ next.searchRoute = normalizeSearchRoute(next.searchRoute);
395
+ const modules = next.modules && typeof next.modules === 'object' && !Array.isArray(next.modules)
396
+ ? { ...next.modules }
397
+ : {};
398
+ if (Object.prototype.hasOwnProperty.call(modules, 'memory')) {
399
+ const legacyMemory = modules.memory;
400
+ if (!(next.recap && typeof next.recap === 'object' && Object.prototype.hasOwnProperty.call(next.recap, 'enabled'))) {
401
+ const enabled = legacyMemory && typeof legacyMemory === 'object'
402
+ ? legacyMemory.enabled !== false
403
+ : legacyMemory !== false;
404
+ next.recap = { ...(next.recap || {}), enabled };
405
+ }
406
+ delete modules.memory;
407
+ }
408
+ if (Object.keys(modules).length) next.modules = modules;
409
+ else delete next.modules;
410
+ delete next.fastModels;
411
+ delete next.agentMaintenance;
412
+ delete next.runtime;
413
+ delete next.search;
414
+ delete next.capabilities;
415
+ delete next.defaultProvider;
416
+ delete next.guide;
417
+ return next;
418
+ }
419
+
420
+ function agentConfigStorageNeedsMigration(value = {}) {
421
+ const modules = value?.modules;
422
+ const presets = Array.isArray(value?.presets) ? value.presets : [];
423
+ const canonical = canonicalizeLegacyAgentStorage(value);
424
+ const normalizedFields = ['autoClear', 'compaction', 'shell', 'profile', 'skills', 'modules', 'guide'];
425
+ return agentRouteStorageNeedsMigration(value)
426
+ || Object.prototype.hasOwnProperty.call(value || {}, 'fastModels')
427
+ || Object.prototype.hasOwnProperty.call(value || {}, 'agentMaintenance')
428
+ || Object.prototype.hasOwnProperty.call(value || {}, 'runtime')
429
+ || Object.prototype.hasOwnProperty.call(value || {}, 'search')
430
+ || Object.prototype.hasOwnProperty.call(value || {}, 'capabilities')
431
+ || Object.prototype.hasOwnProperty.call(value || {}, 'defaultProvider')
432
+ || presets.some((preset) => !normalizePreset(preset))
433
+ || (modules && typeof modules === 'object' && Object.prototype.hasOwnProperty.call(modules, 'memory'))
434
+ || normalizedFields.some((key) => JSON.stringify(value?.[key]) !== JSON.stringify(canonical?.[key]));
435
+ }
436
+
284
437
  export function loadConfig(options = {}) {
285
438
  const includeSecrets = options.secrets !== false;
286
439
  const sectionRaw = readSection('agent');
@@ -290,6 +443,8 @@ export function loadConfig(options = {}) {
290
443
  if (raw.agent && raw.agent.providers) {
291
444
  raw = raw.agent;
292
445
  }
446
+ const storageNeedsMigration = agentConfigStorageNeedsMigration(raw);
447
+ raw = canonicalizeLegacyAgentStorage(raw);
293
448
  const defaults = buildDefaultConfig({ detectCredentials: includeSecrets });
294
449
  // Deep-merge provider subkeys: unknown per-provider values are
295
450
  // preserved through save/load so future fields round-trip
@@ -371,27 +526,13 @@ export function loadConfig(options = {}) {
371
526
  // without overriding an explicit modelSettings.fast value. Keep
372
527
  // the compatibility read here, but never expose/persist the
373
528
  // legacy container again.
374
- const modelSettings = raw.modelSettings && typeof raw.modelSettings === 'object'
375
- ? { ...raw.modelSettings }
376
- : {};
377
- const legacyFastModels = raw.fastModels && typeof raw.fastModels === 'object'
378
- ? raw.fastModels
379
- : {};
380
- for (const [key, enabled] of Object.entries(legacyFastModels)) {
381
- if (enabled !== true) continue;
382
- const current = modelSettings[key];
383
- if (!current || typeof current !== 'object' || Array.isArray(current)) {
384
- modelSettings[key] = { fast: true };
385
- } else if (!Object.prototype.hasOwnProperty.call(current, 'fast')) {
386
- modelSettings[key] = { ...current, fast: true };
387
- }
388
- }
529
+ const modelSettings = migrateLegacyModelSettings(raw);
389
530
  const workflowRoutes = raw.workflowRoutes && typeof raw.workflowRoutes === 'object' ? { ...raw.workflowRoutes } : {};
390
531
  delete workflowRoutes.search;
391
532
  // Normalize maintenance slots to routes, then overlay onto the
392
533
  // route-shaped defaults.
393
- const normalizedMaint = normalizeMaintenanceRoutes(rawMaint, raw.defaultProvider);
394
- return {
534
+ const normalizedMaint = normalizeMaintenanceRoutes(rawMaint);
535
+ const loaded = canonicalizeAgentRouteStorage({
395
536
  providers: mergedProviders,
396
537
  mcpServers,
397
538
  mcpProjectOverrides: raw.mcpProjectOverrides && typeof raw.mcpProjectOverrides === 'object' && !Array.isArray(raw.mcpProjectOverrides)
@@ -418,7 +559,40 @@ export function loadConfig(options = {}) {
418
559
  update: raw.update && typeof raw.update === 'object' ? { ...raw.update } : {},
419
560
  recap: recapConfig,
420
561
  modules: raw.modules && typeof raw.modules === 'object' ? { ...raw.modules } : {},
421
- };
562
+ });
563
+ if (storageNeedsMigration) {
564
+ try {
565
+ // `readSection('agent')` sees cross-section migrations from
566
+ // canonicalizeUnifiedConfig (root autoClear/compaction/
567
+ // shell and memory.user.title), while the in-lock raw
568
+ // section does not. Seed only those missing/partial values
569
+ // from the canonical read snapshot; the current in-lock
570
+ // section still wins for concurrent edits.
571
+ persistAgentConfig((current) => {
572
+ const merged = { ...current };
573
+ for (const key of ['autoClear', 'compaction', 'shell', 'recap']) {
574
+ if (!Object.prototype.hasOwnProperty.call(current, key)
575
+ && Object.prototype.hasOwnProperty.call(raw, key)) {
576
+ merged[key] = raw[key];
577
+ }
578
+ }
579
+ if (Object.prototype.hasOwnProperty.call(raw, 'profile')) {
580
+ const currentProfile = normalizeProfileConfig(current.profile);
581
+ const migratedProfile = normalizeProfileConfig(raw.profile);
582
+ merged.profile = {
583
+ ...currentProfile,
584
+ ...(!currentProfile.title && migratedProfile.title
585
+ ? { title: migratedProfile.title }
586
+ : {}),
587
+ };
588
+ }
589
+ return canonicalizeLegacyAgentStorage(merged);
590
+ });
591
+ } catch (err) {
592
+ process.stderr.write(`[config] persist canonical agent config failed: ${err?.message}\n`);
593
+ }
594
+ }
595
+ return loaded;
422
596
  }
423
597
  catch { /* fall through */ }
424
598
  }
@@ -430,7 +604,6 @@ export function loadConfig(options = {}) {
430
604
  presets: DEFAULT_PRESETS.map(p => ({ ...p })),
431
605
  default: null,
432
606
  maintenance: { ...DEFAULT_MAINTENANCE },
433
- workflowRoutes: {},
434
607
  searchRoute: null,
435
608
  modelSettings: {},
436
609
  onboarding: {},
@@ -491,6 +664,7 @@ export async function patchSkillsDisabledAsync(disabledNames) {
491
664
  }
492
665
 
493
666
  function buildAgentSaveBuilder(config, appliedDirty) {
667
+ const canonicalRoutes = canonicalizeLegacyAgentStorage(config);
494
668
  // Strip ephemeral defaults from providers but preserve any unknown
495
669
  // per-provider subkey so future schema additions round-trip through the
496
670
  // setup UI without changes here. apiKey is intentionally omitted —
@@ -514,13 +688,15 @@ function buildAgentSaveBuilder(config, appliedDirty) {
514
688
  persistedProviders[name] = slim;
515
689
  }
516
690
  }
517
- const workflowRoutes = config.workflowRoutes && typeof config.workflowRoutes === 'object'
518
- ? { ...config.workflowRoutes }
519
- : {};
520
- delete workflowRoutes.search;
521
- const presets = Array.isArray(config.presets)
522
- ? config.presets.filter(p => p?.id !== 'workflow-search')
691
+ const presets = Array.isArray(canonicalRoutes.presets)
692
+ ? canonicalRoutes.presets.filter(p => p?.id !== 'workflow-search')
523
693
  : [];
694
+ const profile = normalizeProfileConfig(config.profile);
695
+ const skills = normalizeSkillsConfig(config.skills);
696
+ const autoClear = canonicalizeAutoClearStorage(config.autoClear);
697
+ const compaction = canonicalizeCompactionStorage(config.compaction);
698
+ const shell = canonicalizeShellStorage(config.shell);
699
+ const modules = canonicalizeModulesStorage(config.modules);
524
700
  // Build the replacement from `existingRaw` — the section read INSIDE the
525
701
  // file lock — not a snapshot taken before it, so unmanaged keys written by
526
702
  // a concurrent instance survive the save (lost-update guard).
@@ -559,27 +735,25 @@ function buildAgentSaveBuilder(config, appliedDirty) {
559
735
  }
560
736
  const next = {
561
737
  ...existingRaw,
562
- guide: config.guide || existingRaw.guide || undefined,
563
738
  providers: persistedProviders,
564
739
  mcpServers,
565
740
  mcpProjectOverrides,
566
741
  presets,
567
742
  default: config.default || null,
568
- maintenance: config.maintenance || {},
569
- workflowRoutes,
743
+ maintenance: canonicalRoutes.maintenance,
570
744
  searchRoute: normalizeSearchRoute(config.searchRoute),
571
745
  modelSettings: config.modelSettings || {},
572
746
  onboarding: config.onboarding || {},
573
- agents: config.agents || {},
747
+ agents: canonicalRoutes.agents,
574
748
  workflow: config.workflow || { active: 'default' },
575
- profile: normalizeProfileConfig(config.profile),
576
- skills: normalizeSkillsConfig(config.skills),
577
- autoClear: config.autoClear || {},
578
- compaction: config.compaction || {},
579
- shell: config.shell || {},
749
+ profile,
750
+ skills: skills.disabled.length ? skills : undefined,
751
+ autoClear,
752
+ compaction,
753
+ shell,
580
754
  update: config.update || {},
581
755
  recap: config.recap || {},
582
- modules: config.modules || existingRaw.modules || {},
756
+ modules,
583
757
  };
584
758
  // These keys were previously round-tripped despite having no runtime
585
759
  // consumer. Explicitly remove them from the in-lock baseline so a
@@ -587,6 +761,11 @@ function buildAgentSaveBuilder(config, appliedDirty) {
587
761
  delete next.fastModels;
588
762
  delete next.agentMaintenance;
589
763
  delete next.runtime;
764
+ delete next.workflowRoutes;
765
+ delete next.search;
766
+ delete next.capabilities;
767
+ delete next.defaultProvider;
768
+ delete next.guide;
590
769
  return next;
591
770
  };
592
771
  }
@@ -8,6 +8,7 @@ import { shutdownStdioChild, killStdioChildTreeFast } from './child-tree.mjs';
8
8
  import { readServicePort, markServiceUnreachable, isConnRefuseError } from '../../../shared/service-discovery.mjs';
9
9
  import { makeToolEnvelope, normalizeToolEnvelope } from '../session/tool-envelope.mjs';
10
10
  import { classifyResultKind } from '../session/result-classification.mjs';
11
+ import { createKeyedSingleflight } from './reconnect-singleflight.mjs';
11
12
  // --- Types ---
12
13
  /** Known auto-detect targets: port file path relative to tmpdir.
13
14
  * Note: `mixdog` used to self-loopback via active-instance.json's
@@ -23,6 +24,7 @@ const DEFAULT_MCP_CALL_TIMEOUT_MS = 120000;
23
24
  const DEFAULT_MCP_STARTUP_TIMEOUT_MS = 10000;
24
25
  // --- State ---
25
26
  const servers = new Map();
27
+ const reconnects = createKeyedSingleflight();
26
28
  let mcpSdkPromise = null;
27
29
  // Memo for mcpToolHasField(name, field) — keyed by `${toolName}|${field}`.
28
30
  // The lookup (regex parse + servers Map get + tools.find + schema property
@@ -173,6 +175,26 @@ export function getMcpServerInstructionsMap() {
173
175
  }
174
176
  return out;
175
177
  }
178
+
179
+ async function reconnectMcpServer(serverName, failedServer) {
180
+ return reconnects.run(serverName, async () => {
181
+ const current = servers.get(serverName);
182
+ // A peer already completed the replacement while this failed call was
183
+ // unwinding. Reuse it immediately instead of closing a fresh transport.
184
+ if (current && current !== failedServer) return current;
185
+ if (current) {
186
+ await _closeServer(current);
187
+ if (servers.get(serverName) === current) servers.delete(serverName);
188
+ }
189
+ await connectServer(serverName, failedServer.cfg);
190
+ const replacement = servers.get(serverName);
191
+ if (!replacement) {
192
+ throw new Error(`reconnect succeeded but server "${serverName}" entry is missing from registry`);
193
+ }
194
+ return replacement;
195
+ });
196
+ }
197
+
176
198
  /**
177
199
  * Execute an MCP tool call.
178
200
  * Name format: `mcp__{serverName}__{toolName}`
@@ -195,21 +217,14 @@ export async function executeMcpTool(name, args) {
195
217
  mcpLog(`[mcp-client] Tool call timed out; skipping reconnect retry for "${serverName}/${toolName}".\n`);
196
218
  throw firstErr;
197
219
  }
198
- mcpLog(`[mcp-client] Tool call failed, attempting reconnect...\n`);
199
- await new Promise(r => setTimeout(r, 500));
200
- try {
201
- await _closeServer(server);
202
- } catch { /* ignore close error */ }
220
+ mcpLog(`[mcp-client] Tool call failed, attempting shared reconnect...\n`);
221
+ let retryServer;
203
222
  try {
204
- await connectServer(serverName, server.cfg);
223
+ retryServer = await reconnectMcpServer(serverName, server);
205
224
  } catch (reconnectErr) {
206
225
  const reconnectMsg = reconnectErr instanceof Error ? reconnectErr.message : String(reconnectErr);
207
226
  throw new Error(`Tool call failed: ${firstMsg}; reconnect also failed: ${reconnectMsg}`);
208
227
  }
209
- const retryServer = servers.get(serverName);
210
- if (!retryServer) {
211
- throw new Error(`Tool call failed: ${firstMsg}; reconnect succeeded but server "${serverName}" entry is missing from registry`);
212
- }
213
228
  try {
214
229
  result = await _callToolWithTimeout(retryServer, toolName, args);
215
230
  } catch (retryErr) {
@@ -632,7 +647,7 @@ async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
632
647
  ...(t.annotations && typeof t.annotations === 'object' ? { annotations: t.annotations } : {}),
633
648
  }));
634
649
  const toolNames = tools.map(t => t.name);
635
- servers.set(name, { name, client, transport, tools, cfg, instructions });
650
+ servers.set(name, { name, client, transport, tools, cfg, instructions, generation: genAtStart });
636
651
  _invalidateMcpToolFieldMemo();
637
652
  mcpLog(`[mcp] connected: ${tools.length} tools — ${toolNames.join(', ')}\n`);
638
653
  }
@@ -0,0 +1,23 @@
1
+ export function createKeyedSingleflight() {
2
+ const pending = new Map();
3
+ return {
4
+ run(key, task) {
5
+ const lane = String(key || '');
6
+ const existing = pending.get(lane);
7
+ if (existing) return existing;
8
+ let tracked;
9
+ const promise = Promise.resolve().then(task);
10
+ tracked = promise.finally(() => {
11
+ if (pending.get(lane) === tracked) pending.delete(lane);
12
+ });
13
+ pending.set(lane, tracked);
14
+ return tracked;
15
+ },
16
+ has(key) {
17
+ return pending.has(String(key || ''));
18
+ },
19
+ get size() {
20
+ return pending.size;
21
+ },
22
+ };
23
+ }