mixdog 0.9.22 → 0.9.24

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 (132) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/boot-smoke.mjs +1 -1
  4. package/scripts/channel-daemon-smoke.mjs +483 -0
  5. package/scripts/channel-daemon-stub.mjs +80 -0
  6. package/scripts/debounced-skills-async-save-test.mjs +57 -0
  7. package/scripts/explore-bench-tmp.mjs +17 -0
  8. package/scripts/find-fuzzy-hidden-test.mjs +145 -0
  9. package/scripts/mcp-grace-deferred-test.mjs +149 -0
  10. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  11. package/scripts/tool-smoke.mjs +68 -47
  12. package/src/rules/agent/30-explorer.md +6 -0
  13. package/src/rules/shared/01-tool.md +11 -4
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
  15. package/src/runtime/agent/orchestrator/config.mjs +33 -7
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
  17. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +226 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +184 -33
  19. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
  20. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
  21. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  24. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  25. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
  27. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  28. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  29. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  31. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  32. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -3
  33. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  34. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  35. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  36. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  37. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  38. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  39. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  40. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  41. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  42. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  43. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +67 -27
  45. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  46. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  47. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  48. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  49. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  50. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +126 -22
  51. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  52. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  53. package/src/runtime/channels/lib/owned-runtime.mjs +67 -223
  54. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -62
  55. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  56. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  57. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  58. package/src/runtime/channels/lib/tool-dispatch.mjs +22 -14
  59. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  60. package/src/runtime/channels/lib/worker-main.mjs +42 -30
  61. package/src/runtime/memory/index.mjs +54 -7
  62. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  63. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  64. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  65. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  66. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  67. package/src/runtime/memory/tool-defs.mjs +1 -1
  68. package/src/runtime/shared/atomic-file.mjs +150 -6
  69. package/src/runtime/shared/background-tasks.mjs +1 -1
  70. package/src/runtime/shared/config.mjs +53 -1
  71. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  72. package/src/runtime/shared/tool-primitives.mjs +31 -1
  73. package/src/runtime/shared/tool-surface.mjs +42 -13
  74. package/src/runtime/shared/update-checker.mjs +3 -0
  75. package/src/runtime/shared/user-data-guard.mjs +66 -0
  76. package/src/session-runtime/config-lifecycle.mjs +221 -20
  77. package/src/session-runtime/lifecycle-api.mjs +9 -0
  78. package/src/session-runtime/mcp-glue.mjs +93 -1
  79. package/src/session-runtime/resource-api.mjs +62 -8
  80. package/src/session-runtime/runtime-core.mjs +118 -4
  81. package/src/session-runtime/session-text.mjs +41 -0
  82. package/src/session-runtime/session-turn-api.mjs +50 -0
  83. package/src/session-runtime/settings-api.mjs +8 -1
  84. package/src/session-runtime/tool-catalog.mjs +350 -38
  85. package/src/session-runtime/tool-defs.mjs +7 -7
  86. package/src/session-runtime/workflow.mjs +2 -1
  87. package/src/standalone/channel-admin.mjs +32 -3
  88. package/src/standalone/channel-daemon-client.mjs +226 -0
  89. package/src/standalone/channel-daemon-transport.mjs +545 -0
  90. package/src/standalone/channel-daemon.mjs +176 -0
  91. package/src/standalone/channel-worker.mjs +224 -4
  92. package/src/standalone/explore-tool.mjs +87 -15
  93. package/src/standalone/hook-bus.mjs +71 -3
  94. package/src/tui/App.jsx +107 -19
  95. package/src/tui/app/clipboard.mjs +39 -19
  96. package/src/tui/app/doctor.mjs +57 -0
  97. package/src/tui/app/extension-pickers.mjs +53 -9
  98. package/src/tui/app/maintenance-pickers.mjs +0 -5
  99. package/src/tui/app/slash-dispatch.mjs +4 -4
  100. package/src/tui/app/text-layout.mjs +11 -0
  101. package/src/tui/app/use-mouse-input.mjs +235 -51
  102. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  103. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  104. package/src/tui/app/use-transcript-window.mjs +55 -1
  105. package/src/tui/components/Message.jsx +1 -1
  106. package/src/tui/components/PromptInput.jsx +3 -1
  107. package/src/tui/components/QueuedCommands.jsx +21 -10
  108. package/src/tui/components/StatusLine.jsx +3 -3
  109. package/src/tui/components/ToolExecution.jsx +16 -4
  110. package/src/tui/components/TranscriptItem.jsx +1 -1
  111. package/src/tui/dist/index.mjs +820 -326
  112. package/src/tui/engine/agent-job-feed.mjs +5 -0
  113. package/src/tui/engine/notification-plan.mjs +5 -0
  114. package/src/tui/engine/session-api.mjs +23 -8
  115. package/src/tui/engine/session-flow.mjs +6 -0
  116. package/src/tui/engine/tool-card-results.mjs +14 -5
  117. package/src/tui/engine/turn.mjs +9 -2
  118. package/src/tui/engine.mjs +32 -5
  119. package/src/tui/index.jsx +62 -18
  120. package/src/tui/paste-attachments.mjs +26 -0
  121. package/src/ui/statusline-agents.mjs +36 -0
  122. package/src/ui/statusline.mjs +60 -10
  123. package/src/ui/tool-card.mjs +8 -1
  124. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  125. package/src/workflows/bench/WORKFLOW.md +46 -0
  126. package/src/workflows/default/WORKFLOW.md +6 -0
  127. package/src/workflows/solo/WORKFLOW.md +5 -0
  128. package/vendor/ink/build/ink.js +23 -1
  129. package/vendor/ink/build/output.js +154 -71
  130. package/vendor/ink/build/render-node-to-output.js +44 -2
  131. package/vendor/ink/build/render.js +4 -0
  132. package/vendor/ink/build/renderer.js +4 -1
@@ -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
@@ -188,11 +250,41 @@ export function createMcpGlue({
188
250
  return { name, config };
189
251
  }
190
252
 
253
+ // First-turn gate: await the in-flight INITIAL connect, bounded by the global
254
+ // startup budget. Boot/UI never call this; only the first ask awaits so
255
+ // servers that connect within the budget land in THIS request's tool surface.
256
+ // Resolves (never rejects): a server still connecting after the budget flows
257
+ // through the existing late-tool deferred announcement path unchanged.
258
+ async function awaitInitialMcpConnect() {
259
+ const inFlight = state.mcpConnectInFlight;
260
+ if (!inFlight) return;
261
+ let budgetMs = 10000;
262
+ try {
263
+ const resolved = mcpClient.resolveMcpStartupTimeoutMs?.({});
264
+ if (Number.isFinite(resolved)) budgetMs = resolved;
265
+ } catch { /* fall back to default budget */ }
266
+ // Swallow the in-flight rejection: failures are already captured in
267
+ // state.mcpFailures, and this gate must never reject the turn.
268
+ const settled = Promise.resolve(inFlight).catch(() => {});
269
+ // Budget disabled (0/off) = no per-server startup timeout, so the connect
270
+ // promise may never settle; never gate the turn on it — fall back to the
271
+ // legacy fire-and-forget behavior (late servers use the deferred path).
272
+ if (!(budgetMs > 0)) return;
273
+ let timer = null;
274
+ const budget = new Promise((resolveBudget) => { timer = setTimeout(resolveBudget, budgetMs); });
275
+ try {
276
+ await Promise.race([settled, budget]);
277
+ } finally {
278
+ if (timer) clearTimeout(timer);
279
+ }
280
+ }
281
+
191
282
  return {
192
283
  mcpTransportLabel,
193
284
  resolveEffectiveMcpServers,
194
285
  mcpStatus,
195
286
  connectConfiguredMcp,
287
+ awaitInitialMcpConnect,
196
288
  normalizeMcpServerInput,
197
289
  };
198
290
  }
@@ -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();
@@ -67,6 +67,7 @@ import {
67
67
  saveWebhook,
68
68
  saveWebhookAuthtoken,
69
69
  setBackend,
70
+ setBackendAsync,
70
71
  setScheduleEnabled,
71
72
  setWebhookEnabled,
72
73
  setWebhookConfig,
@@ -333,6 +334,11 @@ export async function createMixdogSessionRuntime({
333
334
  // session.id + cwd inside ask(); only active while remoteEnabled.
334
335
  let _transcriptWriter = null;
335
336
  let _twKey = '';
337
+ // One-shot: an 'acquired' verdict (or other rebind trigger) landed before a
338
+ // session/writer existed, so the rebind push could not fire. Set true when a
339
+ // push is deferred; the next session-create / turn-start flushes it exactly
340
+ // once so the daemon forwarder always ends bound to THIS session's transcript.
341
+ let _pendingRebind = false;
336
342
  // Last assistant text handed to the transcript writer (via onAssistantText),
337
343
  // so the post-turn final-content append can skip an exact duplicate.
338
344
  let _lastAppendedAssistant = '';
@@ -599,6 +605,7 @@ export async function createMixdogSessionRuntime({
599
605
  resolveEffectiveMcpServers,
600
606
  mcpStatus,
601
607
  connectConfiguredMcp,
608
+ awaitInitialMcpConnect,
602
609
  normalizeMcpServerInput,
603
610
  } = createMcpGlue({
604
611
  mcpClient,
@@ -683,7 +690,19 @@ export async function createMixdogSessionRuntime({
683
690
  void (async () => {
684
691
  await checkForUpdateInternal({ force: true });
685
692
  if (autoUpdateEnabled() && updateCheckState.updateAvailable) {
686
- await runUpdateNowInternal();
693
+ const result = await runUpdateNowInternal();
694
+ // Surface the boot auto-update outcome as a UI-only notice (TUI maps
695
+ // meta.kind 'update-notice' to a transcript notice, never a
696
+ // model-visible message). Silent before: installed-but-needs-restart
697
+ // and failed installs were invisible unless the user opened the
698
+ // maintenance picker.
699
+ if (result?.phase === 'installed') {
700
+ const v = result.version && result.version !== 'unknown'
701
+ ? `v${result.version}` : (updateCheckState.latestVersion ? `v${updateCheckState.latestVersion}` : 'latest');
702
+ emitRuntimeNotification(`mixdog ${v} installed — restart to apply.`, { kind: 'update-notice', tone: 'info' });
703
+ } else if (result?.phase === 'failed') {
704
+ emitRuntimeNotification(`mixdog auto-update failed: ${result.error || 'unknown error'}`, { kind: 'update-notice', tone: 'warn' });
705
+ }
687
706
  }
688
707
  })().catch(() => {});
689
708
  }, 0);
@@ -903,6 +922,11 @@ export async function createMixdogSessionRuntime({
903
922
  if (msg?.params?.state === 'acquired' && !remoteEnabled) {
904
923
  remoteEnabled = true;
905
924
  ensureRemoteTranscriptWriter();
925
+ // Auto-acquire: the worker restored yesterday's transcript from
926
+ // persisted status and we just created the CURRENT writer. Push the
927
+ // repoint now instead of waiting for the next inbound parent-chain
928
+ // steal, so outbound forwarding never tails a stale transcript.
929
+ pushTranscriptRebind();
906
930
  emitRemoteStateChange(true, 'acquired');
907
931
  }
908
932
  return;
@@ -1001,8 +1025,8 @@ export async function createMixdogSessionRuntime({
1001
1025
  if (!codeGraphMod?.executeCodeGraphTool) throw new Error('code_graph runtime is not available');
1002
1026
  return await codeGraphMod.executeCodeGraphTool(name, args || {}, args?.cwd || callerCwd);
1003
1027
  }
1004
- if (name === 'tool_search') {
1005
- return renderToolSearch(args, activeToolSurface(), mode);
1028
+ if (name === 'tool_search' || name === 'load_tool') {
1029
+ return renderToolSearch(args, activeToolSurface(), mode, { mcpStatus });
1006
1030
  }
1007
1031
  if (name === 'explore') {
1008
1032
  const callerSessionId = callerCtx?.callerSessionId || session?.id || null;
@@ -1107,6 +1131,8 @@ export async function createMixdogSessionRuntime({
1107
1131
  flushConfigSave,
1108
1132
  flushBackendSave,
1109
1133
  scheduleBackendSave,
1134
+ scheduleSkillsSave,
1135
+ flushSkillsSave,
1110
1136
  flushOutputStyleSave,
1111
1137
  scheduleOutputStyleSave,
1112
1138
  reloadFullConfig,
@@ -1124,6 +1150,7 @@ export async function createMixdogSessionRuntime({
1124
1150
  cfgMod,
1125
1151
  sharedCfgMod,
1126
1152
  setBackend,
1153
+ setBackendAsync,
1127
1154
  setConfiguredShell,
1128
1155
  normalizeSystemShellConfig,
1129
1156
  normalizeSearchRouteConfig,
@@ -1373,7 +1400,28 @@ export async function createMixdogSessionRuntime({
1373
1400
  session = mgr.createSession(sessionOpts);
1374
1401
  sessionNeedsCwdRefresh = false;
1375
1402
  attachSessionHooks(session, { hooks, hookCommonPayload, getCwd: () => currentCwd });
1376
- applyDeferredToolSurface(session, deferredSurfaceModeForLead(mode), standaloneTools, { provider: route.provider });
1403
+ // Every-create MCP fold (NO blocking): seed the INITIAL deferred surface +
1404
+ // BP1 manifest from whatever MCP servers are ALREADY connected at create
1405
+ // time. There is no await — a boot connect still mid-handshake is caught on
1406
+ // the first user turn by refreshInitialDeferredMcpSurface (session-turn-api),
1407
+ // which re-folds the live registry into the initial manifest before the
1408
+ // prompt renders. This fold keeps recreate paths (cwd change with MCP
1409
+ // already connected) seeding their manifest instead of re-announcing late.
1410
+ let connectedMcpTools = [];
1411
+ try { connectedMcpTools = mcpClient.getMcpTools?.() || []; }
1412
+ catch { connectedMcpTools = []; }
1413
+ applyDeferredToolSurface(
1414
+ session,
1415
+ deferredSurfaceModeForLead(mode),
1416
+ connectedMcpTools.length ? [...standaloneTools, ...connectedMcpTools] : standaloneTools,
1417
+ { provider: route.provider },
1418
+ );
1419
+ // Session-local one-shot: mark this FRESH session eligible for the
1420
+ // first-turn deferred-surface refresh (session-turn-api). A resumed
1421
+ // session (prior transcript) is NEVER marked, so its already-baked BP1 is
1422
+ // never rebuilt or re-announced — the gate is per-session, not the
1423
+ // process-wide firstTurnCompleted.
1424
+ session.deferredInitialRefreshPending = !/resume/i.test(String(reason || ''));
1377
1425
  applyPreSessionToolSelection();
1378
1426
  writeStatuslineRoute(statusRoutes, session, route);
1379
1427
  hooks.emit('session:create', { sessionId: session.id, provider: route.provider, model: route.model, toolMode: mode, cwd: currentCwd });
@@ -1400,6 +1448,9 @@ export async function createMixdogSessionRuntime({
1400
1448
  tools: Array.isArray(session.tools) ? session.tools.length : 0,
1401
1449
  catalog: Array.isArray(session.deferredToolCatalog) ? session.deferredToolCatalog.length : 0,
1402
1450
  });
1451
+ // A rebind push may have been deferred (e.g. 'acquired' landed before this
1452
+ // session existed). The writer is now bindable — flush it exactly once.
1453
+ flushPendingTranscriptRebind();
1403
1454
  return session;
1404
1455
  })();
1405
1456
 
@@ -1523,6 +1574,63 @@ export async function createMixdogSessionRuntime({
1523
1574
  return _transcriptWriter != null;
1524
1575
  }
1525
1576
 
1577
+ // Push the CURRENT transcript path to the channel worker so outbound
1578
+ // forwarding repoints immediately at the moments the binding can go stale
1579
+ // (auto-acquire, newSession/resume, clear) instead of waiting for the next
1580
+ // inbound parent-chain steal. Best-effort: ensures the writer, then fires
1581
+ // the dedicated idempotent worker op — a missing/not-ready worker or a bind
1582
+ // failure must never throw into the lead paths that call this.
1583
+ function pushTranscriptRebind() {
1584
+ if (!remoteEnabled) return;
1585
+ // Writer not bindable yet (e.g. 'acquired' before the session exists in
1586
+ // lazy mode): defer instead of silently dropping the push. flushPending-
1587
+ // TranscriptRebind() re-fires this exactly once when the writer is ready.
1588
+ if (!ensureRemoteTranscriptWriter()) { _pendingRebind = true; return; }
1589
+ const transcriptPath = _transcriptWriter?.transcriptPath;
1590
+ if (!transcriptPath || !channelsEnabled()) { _pendingRebind = true; return; }
1591
+ _pendingRebind = false;
1592
+ executeTranscriptRebind(transcriptPath, 1);
1593
+ }
1594
+
1595
+ // Fire the idempotent worker op with bounded retry. A rejected/throwing
1596
+ // channels.execute retries a few times with short backoff; the final failure
1597
+ // surfaces one stderr line (not only the env-gated bootProfile) so a lost
1598
+ // rebind is diagnosable by default. Best-effort throughout — never throws
1599
+ // into the lead paths that call pushTranscriptRebind().
1600
+ function executeTranscriptRebind(transcriptPath, attempt) {
1601
+ const maxAttempts = 3;
1602
+ const onError = (error) => {
1603
+ const detail = error?.message || String(error);
1604
+ bootProfile('channels:rebind-push-failed', { attempt, error: detail });
1605
+ if (attempt < maxAttempts && remoteEnabled && !closeRequested) {
1606
+ const timer = setTimeout(() => {
1607
+ // Abort the retry chain silently if remote was dropped or the writer
1608
+ // moved on (supersede→re-acquire, newSession/clear): re-firing the
1609
+ // captured path would rebind forwarding back to a stale transcript.
1610
+ if (!remoteEnabled || !channelsEnabled()) return;
1611
+ if (_transcriptWriter?.transcriptPath !== transcriptPath) return;
1612
+ executeTranscriptRebind(transcriptPath, attempt + 1);
1613
+ }, 150 * attempt);
1614
+ timer.unref?.();
1615
+ } else {
1616
+ process.stderr.write(`mixdog: channels: rebind_current_transcript failed after ${attempt} attempt(s): ${detail}\n`);
1617
+ }
1618
+ };
1619
+ try {
1620
+ void channels.execute('rebind_current_transcript', { transcriptPath }).catch(onError);
1621
+ } catch (error) {
1622
+ onError(error);
1623
+ }
1624
+ }
1625
+
1626
+ // Re-fire a deferred rebind exactly once, after a session/writer becomes
1627
+ // available (session create or turn start). No-op unless a push was deferred,
1628
+ // so no unconditional rebind fires per turn for already-bound sessions.
1629
+ function flushPendingTranscriptRebind() {
1630
+ if (!_pendingRebind || !remoteEnabled) return;
1631
+ pushTranscriptRebind();
1632
+ }
1633
+
1526
1634
  // Remote (Discord channel) mode is opt-in per session. Only a session that
1527
1635
  // explicitly enables remote — via `mixdog --remote` or the runtime toggle —
1528
1636
  // boots the channel worker and contends for channel ownership.
@@ -1783,6 +1891,7 @@ export async function createMixdogSessionRuntime({
1783
1891
  adoptConfig,
1784
1892
  saveConfigAndAdopt,
1785
1893
  scheduleBackendSave,
1894
+ scheduleSkillsSave,
1786
1895
  cfgMod,
1787
1896
  hasOwn,
1788
1897
  normalizeAutoClearConfig,
@@ -1859,6 +1968,7 @@ export async function createMixdogSessionRuntime({
1859
1968
  statusRoutes,
1860
1969
  channels,
1861
1970
  agentTool,
1971
+ pushTranscriptRebind,
1862
1972
  mcpClient,
1863
1973
  warmupTimers,
1864
1974
  prewarmTimers,
@@ -1896,6 +2006,7 @@ export async function createMixdogSessionRuntime({
1896
2006
  pluginsStatus,
1897
2007
  getMemoryModule,
1898
2008
  reloadFullConfig,
2009
+ getActiveTurnCount: () => activeTurnCount,
1899
2010
  });
1900
2011
  const modelRouteApi = createModelRouteApi({
1901
2012
  getConfig: () => config,
@@ -1978,6 +2089,8 @@ export async function createMixdogSessionRuntime({
1978
2089
  refreshSessionForCwdIfNeeded,
1979
2090
  createCurrentSession,
1980
2091
  ensureRemoteTranscriptWriter,
2092
+ pushTranscriptRebind,
2093
+ flushPendingTranscriptRebind,
1981
2094
  channelsEnabled,
1982
2095
  invokeChannelStart,
1983
2096
  channels,
@@ -1997,6 +2110,7 @@ export async function createMixdogSessionRuntime({
1997
2110
  resolveCwdPath,
1998
2111
  agentStatusState,
1999
2112
  notificationListeners,
2113
+ awaitInitialMcpConnect,
2000
2114
  });
2001
2115
 
2002
2116
  return {
@@ -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,10 @@ import {
6
6
  toolSearchMatches,
7
7
  sortedNamesByMeasuredUsage,
8
8
  selectDeferredTools,
9
+ reconcileDeferredMcpToolCatalog,
10
+ refreshInitialDeferredMcpSurface,
9
11
  } from './tool-catalog.mjs';
12
+ import { getMcpTools } from '../runtime/agent/orchestrator/mcp/client.mjs';
10
13
 
11
14
  // Turn execution (ask) + session-manage/tool-surface/agent surfaces. Extracted
12
15
  // verbatim from the runtime API object; stateless helpers are imported directly
@@ -22,10 +25,12 @@ export function createSessionTurnApi(deps) {
22
25
  getTranscriptWriter, getTwKey, getLastAppendedAssistant, setLastAppendedAssistant,
23
26
  scheduleCodeGraphPrewarm, refreshSessionForCwdIfNeeded, createCurrentSession,
24
27
  ensureRemoteTranscriptWriter, channelsEnabled, invokeChannelStart, channels,
28
+ pushTranscriptRebind, flushPendingTranscriptRebind,
25
29
  hooks, hookCommonPayload, mgr, notifyFnForSession, bootProfile,
26
30
  scheduleProviderWarmup, scheduleProviderModelWarmup, invalidateContextStatusCache,
27
31
  agentTool, recreateCurrentSessionIfReady, invalidatePreSessionToolSurface,
28
32
  activeToolSurface, applyResolvedCwd, resolveCwdPath, agentStatusState, notificationListeners,
33
+ awaitInitialMcpConnect,
29
34
  } = deps;
30
35
  return {
31
36
  async ask(prompt, options = {}) {
@@ -46,6 +51,9 @@ export function createSessionTurnApi(deps) {
46
51
  setLastAppendedAssistant('');
47
52
  const prevKey = getTwKey();
48
53
  ensureRemoteTranscriptWriter();
54
+ // Flush a rebind deferred before the session/writer existed ('acquired'
55
+ // in lazy mode). One-shot: no-op unless a push was actually deferred.
56
+ flushPendingTranscriptRebind?.();
49
57
  if (getTwKey() && getTwKey() !== prevKey && channelsEnabled() && !envFlag('MIXDOG_DISABLE_CHANNEL_START')) {
50
58
  void invokeChannelStart()
51
59
  .then(() => {
@@ -56,6 +64,43 @@ export function createSessionTurnApi(deps) {
56
64
  }
57
65
  }
58
66
  const session0 = getSession();
67
+ if (session0.deferredInitialRefreshPending) {
68
+ // FIRST TURN of a FRESH session (session-local gate, NOT the
69
+ // process-wide firstTurnCompleted): an MCP server may have finished its
70
+ // handshake BETWEEN session-create and this first send. Re-fold the
71
+ // LIVE registry into the INITIAL deferred surface + BP1
72
+ // <available-deferred-tools> manifest (sync, in-place, idempotent) and
73
+ // pre-mark those names announced, so they ship in the initial manifest
74
+ // instead of a late-tool <system-reminder>. One-shot: cleared before
75
+ // the fold so a throw still never re-runs it, and a resumed session
76
+ // (flag unset) skips straight to the late path below.
77
+ session0.deferredInitialRefreshPending = false;
78
+ // First-turn gate: give the in-flight INITIAL MCP connect a bounded
79
+ // chance to finish so servers that connect within the startup budget
80
+ // land in THIS request's tool surface. Bounded by the same budget —
81
+ // UI/boot never blocks here; only this first ask waits, and only once.
82
+ try { await awaitInitialMcpConnect?.(); }
83
+ catch { /* gate must never break the turn */ }
84
+ try { refreshInitialDeferredMcpSurface(session0, getMcpTools()); }
85
+ catch { /* first-turn MCP fold must never break the turn */ }
86
+ } else {
87
+ // AFTER FIRST TURN: fold in MCP tools whose servers finished their
88
+ // handshake after this session was created, and announce the newly
89
+ // available deferred tool names via ONE appended, persistent
90
+ // system-reminder (append-only — never rewrites BP1 or touches the
91
+ // active tool surface, so the prompt-cache prefix stays intact).
92
+ try {
93
+ reconcileDeferredMcpToolCatalog(session0, getMcpTools(), {
94
+ // Deliver the late-tool announcement through the pending-message
95
+ // queue so it rides inside the next real user turn as a persisted
96
+ // system-reminder (no synthetic user + '.' assistant pair).
97
+ enqueue: (text) => (typeof mgr.enqueuePendingMessage === 'function'
98
+ ? mgr.enqueuePendingMessage(session0.id, text) > 0
99
+ : false),
100
+ });
101
+ }
102
+ catch { /* MCP delta must never break the turn */ }
103
+ }
59
104
  hooks.emit('turn:start', { sessionId: session0.id, prompt, cwd: getCurrentCwd() });
60
105
  // UserPromptSubmit: a hook FAILURE must not block the turn, but blocked===true MUST throw.
61
106
  let promptDispatch = null;
@@ -180,6 +225,11 @@ export function createSessionTurnApi(deps) {
180
225
  try { agentTool.recoverWorkers?.({ clientHostPid: getSession()?.clientHostPid || process.pid }); } catch {}
181
226
  }
182
227
  invalidateContextStatusCache();
228
+ // clearSessionMessages swaps the live session object; the worker binding
229
+ // + persisted status still reference the pre-clear transcript. Push the
230
+ // current transcript so outbound forwarding repoints now, not on the next
231
+ // inbound steal (best-effort, remote-gated inside pushTranscriptRebind).
232
+ pushTranscriptRebind?.();
183
233
  return true;
184
234
  },
185
235
  // 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 = {}) {