mixdog 0.9.22 → 0.9.23

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 (91) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/build-runtime-windows.ps1 +242 -242
  4. package/scripts/channel-daemon-smoke.mjs +165 -0
  5. package/scripts/channel-daemon-stub.mjs +69 -0
  6. package/scripts/recall-usecase-cases.json +1 -1
  7. package/scripts/smoke-runtime-negative.ps1 +106 -106
  8. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  9. package/scripts/tool-efficiency-diag.mjs +1 -1
  10. package/scripts/tool-smoke.mjs +30 -17
  11. package/src/rules/lead/02-channels.md +3 -3
  12. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +187 -0
  13. package/src/runtime/agent/orchestrator/mcp/client.mjs +40 -3
  14. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
  15. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  16. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  17. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  20. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  21. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  23. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -0
  24. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  25. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +24 -11
  27. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  28. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -1
  29. package/src/runtime/channels/lib/owned-runtime.mjs +126 -167
  30. package/src/runtime/channels/lib/owner-heartbeat.mjs +11 -60
  31. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  32. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  33. package/src/runtime/channels/lib/seat-lock.mjs +196 -0
  34. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  35. package/src/runtime/channels/lib/tool-dispatch.mjs +24 -8
  36. package/src/runtime/channels/lib/worker-main.mjs +42 -11
  37. package/src/runtime/memory/index.mjs +54 -7
  38. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  39. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  40. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  41. package/src/runtime/shared/atomic-file.mjs +20 -4
  42. package/src/runtime/shared/tool-primitives.mjs +31 -1
  43. package/src/runtime/shared/tool-surface.mjs +23 -13
  44. package/src/session-runtime/config-lifecycle.mjs +48 -7
  45. package/src/session-runtime/lifecycle-api.mjs +9 -0
  46. package/src/session-runtime/mcp-glue.mjs +63 -1
  47. package/src/session-runtime/resource-api.mjs +62 -8
  48. package/src/session-runtime/runtime-core.mjs +32 -2
  49. package/src/session-runtime/session-text.mjs +41 -0
  50. package/src/session-runtime/session-turn-api.mjs +24 -0
  51. package/src/session-runtime/settings-api.mjs +8 -1
  52. package/src/session-runtime/tool-catalog.mjs +306 -38
  53. package/src/session-runtime/tool-defs.mjs +7 -7
  54. package/src/session-runtime/workflow.mjs +2 -1
  55. package/src/standalone/channel-daemon-client.mjs +224 -0
  56. package/src/standalone/channel-daemon-transport.mjs +351 -0
  57. package/src/standalone/channel-daemon.mjs +139 -0
  58. package/src/standalone/channel-worker.mjs +213 -4
  59. package/src/standalone/hook-bus.mjs +71 -3
  60. package/src/tui/App.jsx +105 -17
  61. package/src/tui/app/clipboard.mjs +39 -19
  62. package/src/tui/app/doctor.mjs +57 -0
  63. package/src/tui/app/extension-pickers.mjs +53 -9
  64. package/src/tui/app/maintenance-pickers.mjs +0 -5
  65. package/src/tui/app/slash-dispatch.mjs +4 -4
  66. package/src/tui/app/text-layout.mjs +11 -0
  67. package/src/tui/app/use-mouse-input.mjs +235 -51
  68. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  69. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  70. package/src/tui/app/use-transcript-window.mjs +55 -1
  71. package/src/tui/components/Message.jsx +1 -1
  72. package/src/tui/components/PromptInput.jsx +3 -1
  73. package/src/tui/components/QueuedCommands.jsx +21 -10
  74. package/src/tui/components/ToolExecution.jsx +2 -2
  75. package/src/tui/dist/index.mjs +635 -306
  76. package/src/tui/engine/session-api.mjs +17 -7
  77. package/src/tui/engine/session-flow.mjs +6 -0
  78. package/src/tui/engine.mjs +8 -0
  79. package/src/tui/index.jsx +62 -18
  80. package/src/tui/paste-attachments.mjs +26 -0
  81. package/src/ui/statusline.mjs +45 -5
  82. package/src/ui/tool-card.mjs +8 -1
  83. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  84. package/src/workflows/bench/WORKFLOW.md +46 -0
  85. package/src/workflows/default/WORKFLOW.md +6 -0
  86. package/src/workflows/solo/WORKFLOW.md +5 -0
  87. package/vendor/ink/build/ink.js +23 -1
  88. package/vendor/ink/build/output.js +154 -71
  89. package/vendor/ink/build/render-node-to-output.js +44 -2
  90. package/vendor/ink/build/render.js +4 -0
  91. package/vendor/ink/build/renderer.js +4 -1
@@ -8,6 +8,9 @@ import {
8
8
  stripToolPrefix,
9
9
  parseMcpToolName,
10
10
  isMcpToolName,
11
+ isExternalMcpToolName,
12
+ isSelfMcpToolName,
13
+ titleCaseMcpServer,
11
14
  normalizeToolName,
12
15
  truncateToolText,
13
16
  truncateSingleLine,
@@ -68,7 +71,10 @@ export {
68
71
  };
69
72
 
70
73
  export function displayToolName(name, args = {}) {
71
- if (isMcpToolName(name)) return 'MCP';
74
+ if (isExternalMcpToolName(name)) {
75
+ const mcp = parseMcpToolName(name);
76
+ return `MCP ${titleCaseMcpServer(mcp.server)}`;
77
+ }
72
78
  const normalized = normalizeToolName(name);
73
79
  switch (normalized) {
74
80
  case 'read':
@@ -94,7 +100,7 @@ export function displayToolName(name, args = {}) {
94
100
  case 'list':
95
101
  case 'ls':
96
102
  return 'Search';
97
- case 'tool_search':
103
+ case 'load_tool':
98
104
  return toolSearchDisplayLabel(parseToolArgs(args));
99
105
  case 'search':
100
106
  case 'search_query':
@@ -145,10 +151,10 @@ export function summarizeToolArgs(name, args, { max = DEFAULT_SUMMARY_MAX } = {}
145
151
  const a = parseToolArgs(args);
146
152
  if (!a || typeof a !== 'object') return '';
147
153
  const normalized = normalizeToolName(name);
148
- const mcpTarget = mcpToolTarget(name, max);
149
- if (mcpTarget) {
154
+ if (isExternalMcpToolName(name)) {
155
+ const mcp = parseMcpToolName(name);
150
156
  return compactParts([
151
- mcpTarget,
157
+ truncateToolText(mcp.tool, max),
152
158
  truncateToolText(firstText(a.query, a.q, a.text, a.prompt, a.path, a.uri, a.name, a.id, a.action), Math.min(max, 80)),
153
159
  ]);
154
160
  }
@@ -225,9 +231,9 @@ export function summarizeToolArgs(name, args, { max = DEFAULT_SUMMARY_MAX } = {}
225
231
  return formatCountedUnit(collectionCount(a.query, a.prompt, a.task, a.goal), 'query', 'queries');
226
232
  }
227
233
  return truncateSingleLine(firstText(a.query, a.prompt, a.task, a.goal, a.path), Math.min(max, 80));
228
- case 'tool_search':
234
+ case 'load_tool':
229
235
  {
230
- const selected = splitToolSearchSelection(a.select);
236
+ const selected = [...splitToolSearchSelection(a.names), ...splitToolSearchSelection(a.select)];
231
237
  if (selected.length) return truncateToolText(selected.map(displayToolSearchTarget).join(', '), max);
232
238
  return quoted(firstText(a.query, a.q, a.text), max);
233
239
  }
@@ -325,7 +331,7 @@ const TOOL_CATEGORY = new Map([
325
331
  ['glob', 'Search'],
326
332
  ['list', 'Search'],
327
333
  ['ls', 'Search'],
328
- ['tool_search', 'Load'],
334
+ ['load_tool', 'Load'],
329
335
  ['search', 'Web Research'],
330
336
  ['web_search', 'Web Research'],
331
337
  ['search_query', 'Web Research'],
@@ -364,7 +370,7 @@ const TOOL_CATEGORY = new Map([
364
370
 
365
371
  /** Return the aggregate category for a tool name + args. */
366
372
  export function classifyToolCategory(name, args = {}) {
367
- if (isMcpToolName(name)) return 'MCP';
373
+ if (isExternalMcpToolName(name)) return 'MCP';
368
374
  const normalized = normalizeToolName(name);
369
375
  if (normalized === 'code_graph') {
370
376
  const mode = String(args.mode || args.action || '').toLowerCase();
@@ -431,8 +437,12 @@ export function toolWorkUnit(name, args = {}, category = '') {
431
437
  const a = parseToolArgs(args);
432
438
  const normalized = normalizeToolName(name);
433
439
  const cat = category || classifyToolCategory(name, a);
434
- if (isMcpToolName(name)) {
435
- return unitDescriptor('MCP', { count: queryCount(a, 'query', 'q', 'text', 'prompt', 'path', 'uri', 'name', 'id', 'action') || 1 });
440
+ if (isExternalMcpToolName(name)) {
441
+ const mcp = parseMcpToolName(name);
442
+ return unitDescriptor('MCP', {
443
+ count: queryCount(a, 'query', 'q', 'text', 'prompt', 'path', 'uri', 'name', 'id', 'action') || 1,
444
+ noun: `${titleCaseMcpServer(mcp.server)} tool`,
445
+ });
436
446
  }
437
447
  switch (normalized) {
438
448
  case 'read':
@@ -470,8 +480,8 @@ export function toolWorkUnit(name, args = {}, category = '') {
470
480
  case 'list':
471
481
  case 'ls':
472
482
  return unitDescriptor('Search', { count: queryCount(a, 'path', 'paths', 'dir', 'dirs', 'cwd') || 1, active: 'Listing', done: 'Listed', noun: 'directory', pluralNoun: 'directories' });
473
- case 'tool_search': {
474
- const selected = splitToolSearchSelection(a.select);
483
+ case 'load_tool': {
484
+ const selected = [...splitToolSearchSelection(a.names), ...splitToolSearchSelection(a.select)];
475
485
  if (selected.length) return unitDescriptor('Load', { count: selected.length, noun: 'tool' });
476
486
  return unitDescriptor('Load', { count: queryCount(a, 'query', 'q', 'text') || 1, noun: 'query', pluralNoun: 'queries' });
477
487
  }
@@ -98,14 +98,24 @@ export function createConfigLifecycle({
98
98
  clearTimeout(configSaveTimer);
99
99
  configSaveTimer = null;
100
100
  }
101
- if (pendingConfigToSave === null) return;
102
- const snapshot = pendingConfigToSave;
103
- pendingConfigToSave = null;
104
- try {
105
- cfgMod.saveConfig(snapshot);
106
- } catch (err) {
107
- process.stderr.write(`[config] debounced saveConfig failed: ${err?.message || err}\n`);
101
+ if (pendingConfigToSave !== null) {
102
+ const snapshot = pendingConfigToSave;
103
+ pendingConfigToSave = null;
104
+ try {
105
+ cfgMod.saveConfig(snapshot);
106
+ } catch (err) {
107
+ process.stderr.write(`[config] debounced saveConfig failed: ${err?.message || err}\n`);
108
+ }
108
109
  }
110
+ // Config-save flush points (reloadFullConfig re-read, runtime teardown) are
111
+ // exactly where a pending skills.disabled patch must also land, so piggyback
112
+ // the skills flush here — AFTER saveConfig: the whole-section snapshot may
113
+ // predate the latest skills toggle (stale snapshot.skills), so the in-lock
114
+ // skills patch must be the last writer. When the snapshot is newer than the
115
+ // toggle it already carries the same skills value, so the order is always
116
+ // safe. Runs even when no config snapshot is pending (early return above
117
+ // must not skip it).
118
+ flushSkillsSave();
109
119
  }
110
120
 
111
121
  function saveConfigAndAdopt(nextConfig, { hasSecrets = getConfigHasSecrets() } = {}) {
@@ -147,6 +157,35 @@ export function createConfigLifecycle({
147
157
  backendSaveTimer.unref?.();
148
158
  }
149
159
 
160
+ // --- debounced skills.disabled persist -------------------------------------
161
+ // In-memory skills state is adopted synchronously by setDisabledSkills; the
162
+ // heavy in-lock file RMW (cfgMod.patchSkillsDisabled) is deferred here so a
163
+ // burst of settings-toggle key presses collapses into one disk write.
164
+ let pendingSkillsNames = null;
165
+ let skillsSaveTimer = null;
166
+
167
+ function flushSkillsSave() {
168
+ if (skillsSaveTimer) {
169
+ clearTimeout(skillsSaveTimer);
170
+ skillsSaveTimer = null;
171
+ }
172
+ if (pendingSkillsNames === null) return;
173
+ const names = pendingSkillsNames;
174
+ pendingSkillsNames = null;
175
+ try {
176
+ cfgMod.patchSkillsDisabled(names);
177
+ } catch (err) {
178
+ process.stderr.write(`[config] debounced patchSkillsDisabled failed: ${err?.message || err}\n`);
179
+ }
180
+ }
181
+
182
+ function scheduleSkillsSave(names) {
183
+ pendingSkillsNames = names;
184
+ if (skillsSaveTimer) clearTimeout(skillsSaveTimer);
185
+ skillsSaveTimer = setTimeout(flushSkillsSave, CONFIG_SAVE_DEBOUNCE_MS);
186
+ skillsSaveTimer.unref?.();
187
+ }
188
+
150
189
  // --- debounced top-level outputStyle persist -------------------------------
151
190
  let pendingOutputStyleId = null;
152
191
  let outputStyleSaveTimer = null;
@@ -221,6 +260,8 @@ export function createConfigLifecycle({
221
260
  flushConfigSave,
222
261
  flushBackendSave,
223
262
  scheduleBackendSave,
263
+ flushSkillsSave,
264
+ scheduleSkillsSave,
224
265
  flushOutputStyleSave,
225
266
  scheduleOutputStyleSave,
226
267
  // reload / ensure
@@ -28,6 +28,7 @@ export function createLifecycleApi(deps) {
28
28
  createCurrentSession, refreshRouteEffort,
29
29
  invalidateContextStatusCache, invalidatePreSessionToolSurface,
30
30
  applyResolvedCwd, resolveRoute, applyDeferredToolSurface, standaloneTools,
31
+ pushTranscriptRebind,
31
32
  } = deps;
32
33
  return {
33
34
  async close(reason = 'cli-exit', options = {}) {
@@ -56,6 +57,7 @@ export function createLifecycleApi(deps) {
56
57
  try { flushConfigSave(); } catch {}
57
58
  try { flushBackendSave(); } catch {}
58
59
  try { flushOutputStyleSave(); } catch {}
60
+ try { hooks.flushRules?.(); } catch {}
59
61
  if (prewarmTimers.channelStartTimer) {
60
62
  clearTimeout(prewarmTimers.channelStartTimer);
61
63
  prewarmTimers.channelStartTimer = null;
@@ -201,6 +203,10 @@ export function createLifecycleApi(deps) {
201
203
  }
202
204
  invalidateContextStatusCache();
203
205
  await createCurrentSession();
206
+ // New session.id => the worker's binding (and persisted status) now point
207
+ // at the previous session's transcript. Push the current transcript so
208
+ // outbound forwarding repoints immediately (best-effort, remote-gated).
209
+ pushTranscriptRebind?.();
204
210
  return getSession().id;
205
211
  },
206
212
  async resume(id) {
@@ -230,6 +236,9 @@ export function createLifecycleApi(deps) {
230
236
  invalidateContextStatusCache();
231
237
  setSessionNeedsCwdRefresh(false);
232
238
  writeStatuslineRoute(statusRoutes, session, getRoute());
239
+ // Session swapped to the resumed one: repoint the worker to the current
240
+ // transcript instead of waiting for the next inbound steal.
241
+ pushTranscriptRebind?.();
233
242
  return {
234
243
  id: resumed.id,
235
244
  messages: resumed.messages || [],
@@ -94,7 +94,69 @@ export function createMcpGlue({
94
94
  };
95
95
  }
96
96
 
97
- async function connectConfiguredMcp({ reset = false } = {}) {
97
+ // Connect/disconnect exactly one server in the live registry, leaving all
98
+ // others untouched. Used by the enable/disable toggle so a single-server
99
+ // change never triggers a full disconnectAll()/reconnect freeze.
100
+ async function applyMcpServerConnection(name, enabled) {
101
+ const target = clean(name);
102
+ if (!target) return;
103
+ const { servers, sources } = resolveEffectiveMcpServers();
104
+ // A project-local `.mcp.json` entry WINS over config for this name, so the
105
+ // config enable/disable flag doesn't change what's actually connected.
106
+ // Only act when the effective entry is config-sourced (or absent).
107
+ if (sources[target] && sources[target] !== 'config') return;
108
+ // Changing this server's state clears any stale failure record for it.
109
+ if (Array.isArray(state.mcpFailures)) {
110
+ state.mcpFailures = state.mcpFailures.filter((row) => row.name !== target);
111
+ }
112
+ if (enabled === false) {
113
+ await mcpClient.disconnectMcpServer?.(target);
114
+ return;
115
+ }
116
+ const cfg = servers[target];
117
+ if (!cfg) return;
118
+ // Drop any existing live entry first so connectMcpServers doesn't overwrite
119
+ // the registry Map entry and leak the old transport/process.
120
+ await mcpClient.disconnectMcpServer?.(target);
121
+ try {
122
+ await mcpClient.connectMcpServers({ [target]: cfg });
123
+ } catch (error) {
124
+ const failures = Array.isArray(error?.failures)
125
+ ? error.failures
126
+ : [{ name: target, msg: error?.message || String(error) }];
127
+ state.mcpFailures = [...(state.mcpFailures || []), ...failures];
128
+ }
129
+ }
130
+
131
+ async function connectConfiguredMcp({ reset = false, only = null, enabled = true } = {}) {
132
+ // Scoped single-server toggle: non-superseding. It must NEVER cancel a
133
+ // pending full {reset} (cwd-change/boot). So do not bump the generation;
134
+ // just wait for any in-flight run, then bail if a newer full reset has
135
+ // been requested in the meantime. Registering as in-flight makes a later
136
+ // reset serialize behind us instead of interleaving disconnect/connect.
137
+ if (only) {
138
+ // Atomically capture the current generation AND the prior in-flight
139
+ // promise in the same synchronous step, then chain our op onto it. No
140
+ // await sits between the capture and the `state.mcpConnectInFlight = run`
141
+ // assignment, so concurrent {only} calls queue FIFO instead of resuming
142
+ // together and clobbering the in-flight slot. We never bump the
143
+ // generation; a {reset} does, so any {only} queued behind a reset sees
144
+ // the newer generation when its turn comes and bails.
145
+ const gen = state.mcpConnectGeneration;
146
+ const prev = state.mcpConnectInFlight;
147
+ const run = (async () => {
148
+ if (prev) { try { await prev; } catch { /* prior run's failures already captured */ } }
149
+ if (gen !== state.mcpConnectGeneration) return;
150
+ await applyMcpServerConnection(only, enabled);
151
+ })();
152
+ state.mcpConnectInFlight = run;
153
+ try {
154
+ await run;
155
+ } finally {
156
+ if (state.mcpConnectInFlight === run) state.mcpConnectInFlight = null;
157
+ }
158
+ return mcpStatus();
159
+ }
98
160
  // Serialize reconnects: boot connect, cwd-change reset, and rapid cwd
99
161
  // switches must never interleave their disconnect/connect phases, or an
100
162
  // older run finishing after a newer reset could re-add stale servers into
@@ -29,8 +29,56 @@ export function createResourceApi(deps) {
29
29
  saveConfigAndAdopt, connectConfiguredMcp, invalidatePreSessionToolSurface,
30
30
  recreateCurrentSessionIfReady, normalizeMcpServerInput, mcpStatus,
31
31
  skillsStatus, skillContent, addProjectSkill, pluginsStatus, getMemoryModule,
32
- reloadFullConfig,
32
+ reloadFullConfig, getActiveTurnCount,
33
33
  } = deps;
34
+ // Per-server MCP toggle serialization. The synchronous config adopt in
35
+ // setMcpServerEnabled has already made the intent durable; the heavy
36
+ // connectConfiguredMcp (process spawn/handshake) + session close/recreate
37
+ // run here off the toggle's critical path. Rapid re-toggles on one server
38
+ // update `desired` and ride the in-flight chain so it converges to the last
39
+ // requested state, closing/recreating the session only once at the end.
40
+ const mcpToggleChains = new Map(); // name -> { desired, running }
41
+ // Close/recreate the live session only at a turn boundary: a background
42
+ // toggle must never abort an in-flight turn. If a turn is active, poll until
43
+ // it ends, then swap the session so it picks up the new tool surface.
44
+ function applyMcpToggleRecreate(serverName) {
45
+ if (typeof getActiveTurnCount === 'function' && getActiveTurnCount() > 0) {
46
+ const timer = setTimeout(() => applyMcpToggleRecreate(serverName), 250);
47
+ timer.unref?.();
48
+ return;
49
+ }
50
+ invalidatePreSessionToolSurface();
51
+ const session = getSession();
52
+ if (session?.id) mgr.closeSession(session.id, 'cli-mcp-toggle');
53
+ // Recreate off the critical path (see removeMcpServer notes): the next
54
+ // on-demand createCurrentSession dedupes onto this in-flight create.
55
+ void recreateCurrentSessionIfReady().catch((err) => {
56
+ process.stderr.write(`[mcp] session recreate after toggle failed: ${err?.message || err}\n`);
57
+ });
58
+ }
59
+ function scheduleMcpToggle(serverName, enabled) {
60
+ const chain = mcpToggleChains.get(serverName) || { desired: enabled, running: null };
61
+ chain.desired = enabled;
62
+ mcpToggleChains.set(serverName, chain);
63
+ if (!chain.running) {
64
+ chain.running = (async () => {
65
+ let status;
66
+ try {
67
+ let want;
68
+ do {
69
+ want = chain.desired;
70
+ status = await connectConfiguredMcp({ only: serverName, enabled: want });
71
+ } while (chain.desired !== want);
72
+ // Turn-safe: defers until any active turn ends (never aborts it).
73
+ applyMcpToggleRecreate(serverName);
74
+ } finally {
75
+ chain.running = null;
76
+ }
77
+ return status;
78
+ })();
79
+ }
80
+ return chain.running;
81
+ }
34
82
  return {
35
83
  mcpStatus() {
36
84
  return mcpStatus();
@@ -78,7 +126,7 @@ export function createResourceApi(deps) {
78
126
  await recreateCurrentSessionIfReady();
79
127
  return status;
80
128
  },
81
- async setMcpServerEnabled(name, enabled) {
129
+ setMcpServerEnabled(name, enabled) {
82
130
  const serverName = clean(name);
83
131
  if (!serverName) throw new Error('MCP server name is required');
84
132
  const nextConfig = { ...getConfig() };
@@ -88,14 +136,20 @@ export function createResourceApi(deps) {
88
136
  if (!Object.prototype.hasOwnProperty.call(current, serverName)) {
89
137
  throw new Error(`MCP server not configured: ${serverName}`);
90
138
  }
139
+ // A project-local `.mcp.json` entry WINS over config for this name, so a
140
+ // config enable/disable flag would persist but never change live state.
141
+ // Surface that to the caller instead of reporting a silent success.
142
+ const shadowRow = mcpStatus().servers.find((s) => s.name === serverName);
143
+ if (shadowRow && shadowRow.source === 'project') {
144
+ throw new Error(`'${serverName}' is defined by project .mcp.json — config toggle has no effect`);
145
+ }
146
+ // Adopt + persist config synchronously (fast) so intent is durable, then
147
+ // hand the heavy connect/close/recreate to the per-server background
148
+ // chain. Return that chain's promise so callers can settle the picker on
149
+ // completion, but the store no longer blocks on it.
91
150
  current[serverName] = { ...(current[serverName] || {}), enabled: enabled !== false };
92
151
  saveConfigAndAdopt({ ...nextConfig, mcpServers: current });
93
- const status = await connectConfiguredMcp({ reset: true });
94
- invalidatePreSessionToolSurface();
95
- const session = getSession();
96
- if (session?.id) mgr.closeSession(session.id, 'cli-mcp-toggle');
97
- await recreateCurrentSessionIfReady();
98
- return status;
152
+ return scheduleMcpToggle(serverName, enabled !== false);
99
153
  },
100
154
  skillsStatus() {
101
155
  return skillsStatus();
@@ -903,6 +903,11 @@ export async function createMixdogSessionRuntime({
903
903
  if (msg?.params?.state === 'acquired' && !remoteEnabled) {
904
904
  remoteEnabled = true;
905
905
  ensureRemoteTranscriptWriter();
906
+ // Auto-acquire: the worker restored yesterday's transcript from
907
+ // persisted status and we just created the CURRENT writer. Push the
908
+ // repoint now instead of waiting for the next inbound parent-chain
909
+ // steal, so outbound forwarding never tails a stale transcript.
910
+ pushTranscriptRebind();
906
911
  emitRemoteStateChange(true, 'acquired');
907
912
  }
908
913
  return;
@@ -1001,8 +1006,8 @@ export async function createMixdogSessionRuntime({
1001
1006
  if (!codeGraphMod?.executeCodeGraphTool) throw new Error('code_graph runtime is not available');
1002
1007
  return await codeGraphMod.executeCodeGraphTool(name, args || {}, args?.cwd || callerCwd);
1003
1008
  }
1004
- if (name === 'tool_search') {
1005
- return renderToolSearch(args, activeToolSurface(), mode);
1009
+ if (name === 'tool_search' || name === 'load_tool') {
1010
+ return renderToolSearch(args, activeToolSurface(), mode, { mcpStatus });
1006
1011
  }
1007
1012
  if (name === 'explore') {
1008
1013
  const callerSessionId = callerCtx?.callerSessionId || session?.id || null;
@@ -1107,6 +1112,8 @@ export async function createMixdogSessionRuntime({
1107
1112
  flushConfigSave,
1108
1113
  flushBackendSave,
1109
1114
  scheduleBackendSave,
1115
+ scheduleSkillsSave,
1116
+ flushSkillsSave,
1110
1117
  flushOutputStyleSave,
1111
1118
  scheduleOutputStyleSave,
1112
1119
  reloadFullConfig,
@@ -1523,6 +1530,25 @@ export async function createMixdogSessionRuntime({
1523
1530
  return _transcriptWriter != null;
1524
1531
  }
1525
1532
 
1533
+ // Push the CURRENT transcript path to the channel worker so outbound
1534
+ // forwarding repoints immediately at the moments the binding can go stale
1535
+ // (auto-acquire, newSession/resume, clear) instead of waiting for the next
1536
+ // inbound parent-chain steal. Best-effort: ensures the writer, then fires
1537
+ // the dedicated idempotent worker op — a missing/not-ready worker or a bind
1538
+ // failure must never throw into the lead paths that call this.
1539
+ function pushTranscriptRebind() {
1540
+ if (!remoteEnabled) return;
1541
+ if (!ensureRemoteTranscriptWriter()) return;
1542
+ const transcriptPath = _transcriptWriter?.transcriptPath;
1543
+ if (!transcriptPath || !channelsEnabled()) return;
1544
+ try {
1545
+ void channels.execute('rebind_current_transcript', { transcriptPath })
1546
+ .catch((error) => bootProfile('channels:rebind-push-failed', { error: error?.message || String(error) }));
1547
+ } catch (error) {
1548
+ bootProfile('channels:rebind-push-failed', { error: error?.message || String(error) });
1549
+ }
1550
+ }
1551
+
1526
1552
  // Remote (Discord channel) mode is opt-in per session. Only a session that
1527
1553
  // explicitly enables remote — via `mixdog --remote` or the runtime toggle —
1528
1554
  // boots the channel worker and contends for channel ownership.
@@ -1783,6 +1809,7 @@ export async function createMixdogSessionRuntime({
1783
1809
  adoptConfig,
1784
1810
  saveConfigAndAdopt,
1785
1811
  scheduleBackendSave,
1812
+ scheduleSkillsSave,
1786
1813
  cfgMod,
1787
1814
  hasOwn,
1788
1815
  normalizeAutoClearConfig,
@@ -1859,6 +1886,7 @@ export async function createMixdogSessionRuntime({
1859
1886
  statusRoutes,
1860
1887
  channels,
1861
1888
  agentTool,
1889
+ pushTranscriptRebind,
1862
1890
  mcpClient,
1863
1891
  warmupTimers,
1864
1892
  prewarmTimers,
@@ -1896,6 +1924,7 @@ export async function createMixdogSessionRuntime({
1896
1924
  pluginsStatus,
1897
1925
  getMemoryModule,
1898
1926
  reloadFullConfig,
1927
+ getActiveTurnCount: () => activeTurnCount,
1899
1928
  });
1900
1929
  const modelRouteApi = createModelRouteApi({
1901
1930
  getConfig: () => config,
@@ -1978,6 +2007,7 @@ export async function createMixdogSessionRuntime({
1978
2007
  refreshSessionForCwdIfNeeded,
1979
2008
  createCurrentSession,
1980
2009
  ensureRemoteTranscriptWriter,
2010
+ pushTranscriptRebind,
1981
2011
  channelsEnabled,
1982
2012
  invokeChannelStart,
1983
2013
  channels,
@@ -32,6 +32,7 @@ export function isSessionPreviewNoise(text) {
32
32
  return !value
33
33
  || value.startsWith('<system-reminder>')
34
34
  || value.startsWith('</system-reminder>')
35
+ || isLateToolAnnouncement(value)
35
36
  || /^#\s*permission\b/i.test(value)
36
37
  || /^permission:\s*/i.test(value)
37
38
  || /^cwd:\s*/i.test(value);
@@ -45,6 +46,46 @@ export function cleanSessionPreview(text) {
45
46
  .slice(0, 160);
46
47
  }
47
48
 
49
+ // Stable sentinel carried in every late-tool (deferred MCP) announcement
50
+ // reminder — must stay byte-identical to LATE_TOOL_REMINDER_SENTINEL in
51
+ // src/session-runtime/tool-catalog.mjs. Detection keys on this exact string
52
+ // (never fuzzy matching) so the raw announcement block can be hidden from
53
+ // user-facing surfaces while the model context stays untouched.
54
+ export const LATE_TOOL_ANNOUNCEMENT_SENTINEL = 'connected after this session started';
55
+
56
+ export function isLateToolAnnouncement(text) {
57
+ const value = String(text || '');
58
+ return value.includes(LATE_TOOL_ANNOUNCEMENT_SENTINEL)
59
+ && /<available-deferred-tools>/i.test(value);
60
+ }
61
+
62
+ // Derive a muted one-line notice from a late-tool announcement body, e.g.
63
+ // "MCP tools available: UnityMCP (12 tools)". MCP tool entries in the manifest
64
+ // are `- mcp__<server>__<tool>: ...` lines; the server name is the segment
65
+ // after the `mcp__` prefix. Returns '' for non-announcement text.
66
+ export function summarizeLateToolAnnouncement(text) {
67
+ const value = String(text || '');
68
+ if (!isLateToolAnnouncement(value)) return '';
69
+ const block = value.match(/<available-deferred-tools>([\s\S]*?)<\/available-deferred-tools>/i);
70
+ const body = block ? block[1] : value;
71
+ const names = [];
72
+ const lineRe = /^\s*-\s+([A-Za-z0-9_.:-]+)/gm;
73
+ let m;
74
+ while ((m = lineRe.exec(body))) names.push(m[1]);
75
+ const servers = new Set();
76
+ for (const name of names) {
77
+ const seg = name.startsWith('mcp__') ? name.slice(5) : name;
78
+ const server = seg.split('__')[0];
79
+ if (server) servers.add(server);
80
+ }
81
+ const count = names.length;
82
+ const label = servers.size === 1
83
+ ? [...servers][0]
84
+ : (servers.size ? `${servers.size} MCP servers` : 'MCP');
85
+ if (!count) return `MCP tools available: ${label}`;
86
+ return `MCP tools available: ${label} (${count} ${count === 1 ? 'tool' : 'tools'})`;
87
+ }
88
+
48
89
  export function clean(value) {
49
90
  return String(value ?? '').trim();
50
91
  }
@@ -6,7 +6,9 @@ import {
6
6
  toolSearchMatches,
7
7
  sortedNamesByMeasuredUsage,
8
8
  selectDeferredTools,
9
+ reconcileDeferredMcpToolCatalog,
9
10
  } from './tool-catalog.mjs';
11
+ import { getMcpTools } from '../runtime/agent/orchestrator/mcp/client.mjs';
10
12
 
11
13
  // Turn execution (ask) + session-manage/tool-surface/agent surfaces. Extracted
12
14
  // verbatim from the runtime API object; stateless helpers are imported directly
@@ -22,6 +24,7 @@ export function createSessionTurnApi(deps) {
22
24
  getTranscriptWriter, getTwKey, getLastAppendedAssistant, setLastAppendedAssistant,
23
25
  scheduleCodeGraphPrewarm, refreshSessionForCwdIfNeeded, createCurrentSession,
24
26
  ensureRemoteTranscriptWriter, channelsEnabled, invokeChannelStart, channels,
27
+ pushTranscriptRebind,
25
28
  hooks, hookCommonPayload, mgr, notifyFnForSession, bootProfile,
26
29
  scheduleProviderWarmup, scheduleProviderModelWarmup, invalidateContextStatusCache,
27
30
  agentTool, recreateCurrentSessionIfReady, invalidatePreSessionToolSurface,
@@ -56,6 +59,22 @@ export function createSessionTurnApi(deps) {
56
59
  }
57
60
  }
58
61
  const session0 = getSession();
62
+ // Turn-boundary snapshot: fold in MCP tools whose servers finished their
63
+ // handshake after this session was created, and announce the newly
64
+ // available deferred tool names via ONE appended, persistent
65
+ // system-reminder (append-only — never rewrites BP1 or touches the
66
+ // active tool surface, so the prompt-cache prefix stays intact).
67
+ try {
68
+ reconcileDeferredMcpToolCatalog(session0, getMcpTools(), {
69
+ // Deliver the late-tool announcement through the pending-message
70
+ // queue so it rides inside the next real user turn as a persisted
71
+ // system-reminder (no synthetic user + '.' assistant pair).
72
+ enqueue: (text) => (typeof mgr.enqueuePendingMessage === 'function'
73
+ ? mgr.enqueuePendingMessage(session0.id, text) > 0
74
+ : false),
75
+ });
76
+ }
77
+ catch { /* MCP delta must never break the turn */ }
59
78
  hooks.emit('turn:start', { sessionId: session0.id, prompt, cwd: getCurrentCwd() });
60
79
  // UserPromptSubmit: a hook FAILURE must not block the turn, but blocked===true MUST throw.
61
80
  let promptDispatch = null;
@@ -180,6 +199,11 @@ export function createSessionTurnApi(deps) {
180
199
  try { agentTool.recoverWorkers?.({ clientHostPid: getSession()?.clientHostPid || process.pid }); } catch {}
181
200
  }
182
201
  invalidateContextStatusCache();
202
+ // clearSessionMessages swaps the live session object; the worker binding
203
+ // + persisted status still reference the pre-clear transcript. Push the
204
+ // current transcript so outbound forwarding repoints now, not on the next
205
+ // inbound steal (best-effort, remote-gated inside pushTranscriptRebind).
206
+ pushTranscriptRebind?.();
183
207
  return true;
184
208
  },
185
209
  // session_manage tool handoff: the engine polls this at turn end and, if
@@ -16,6 +16,7 @@ export function createSettingsApi({
16
16
  adoptConfig,
17
17
  saveConfigAndAdopt,
18
18
  scheduleBackendSave,
19
+ scheduleSkillsSave,
19
20
  // normalizers / helpers
20
21
  cfgMod,
21
22
  hasOwn,
@@ -139,8 +140,14 @@ export function createSettingsApi({
139
140
  const names = disabled instanceof Set
140
141
  ? [...disabled]
141
142
  : (Array.isArray(disabled) ? disabled : []);
142
- const nextSkills = cfgMod.patchSkillsDisabled(names);
143
+ // Adopt in-memory synchronously so getDisabledSkills reflects the new
144
+ // value on the same tick (matches normalizeSkillsConfig({ disabled })
145
+ // used by patchSkillsDisabled). Defer the heavy in-lock file RMW through
146
+ // the skills debounce channel so the settings-toggle key handler does not
147
+ // hitch on a synchronous disk write.
148
+ const nextSkills = cfgMod.normalizeSkillsConfig({ disabled: names });
143
149
  adoptConfig({ ...getConfig(), skills: nextSkills });
150
+ scheduleSkillsSave(names);
144
151
  return this.getDisabledSkills();
145
152
  },
146
153
  setProfile(input = {}) {