mixdog 0.9.23 → 0.9.25

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 (89) hide show
  1. package/package.json +2 -1
  2. package/scripts/boot-smoke.mjs +1 -1
  3. package/scripts/channel-daemon-smoke.mjs +327 -9
  4. package/scripts/channel-daemon-stub.mjs +12 -1
  5. package/scripts/debounced-skills-async-save-test.mjs +57 -0
  6. package/scripts/explore-bench-tmp.mjs +17 -0
  7. package/scripts/find-fuzzy-hidden-test.mjs +145 -0
  8. package/scripts/mcp-grace-deferred-test.mjs +149 -0
  9. package/scripts/tool-smoke.mjs +38 -30
  10. package/src/defaults/cycle3-review-prompt.md +11 -4
  11. package/src/defaults/memory-promote-prompt.md +9 -0
  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 +39 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -31
  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/session/agent-loop.mjs +24 -7
  22. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  23. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -3
  24. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  25. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  26. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  27. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  28. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  29. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  30. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  31. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  32. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  33. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +43 -16
  34. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  35. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  36. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  37. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  38. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +70 -21
  39. package/src/runtime/channels/backends/discord.mjs +10 -3
  40. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  41. package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
  42. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  43. package/src/runtime/channels/lib/owned-runtime.mjs +65 -180
  44. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -13
  45. package/src/runtime/channels/lib/runtime-paths.mjs +6 -6
  46. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -17
  47. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  48. package/src/runtime/channels/lib/worker-main.mjs +9 -28
  49. package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -1
  50. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
  51. package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
  52. package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
  53. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  54. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  55. package/src/runtime/memory/tool-defs.mjs +1 -1
  56. package/src/runtime/shared/atomic-file.mjs +130 -2
  57. package/src/runtime/shared/background-tasks.mjs +1 -1
  58. package/src/runtime/shared/config.mjs +53 -1
  59. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  60. package/src/runtime/shared/tool-surface.mjs +19 -0
  61. package/src/runtime/shared/update-checker.mjs +3 -0
  62. package/src/runtime/shared/user-data-guard.mjs +66 -0
  63. package/src/session-runtime/config-lifecycle.mjs +175 -15
  64. package/src/session-runtime/mcp-glue.mjs +30 -0
  65. package/src/session-runtime/runtime-core.mjs +91 -7
  66. package/src/session-runtime/session-turn-api.mjs +42 -16
  67. package/src/session-runtime/tool-catalog.mjs +44 -0
  68. package/src/standalone/channel-admin.mjs +32 -3
  69. package/src/standalone/channel-daemon-client.mjs +3 -1
  70. package/src/standalone/channel-daemon-transport.mjs +202 -8
  71. package/src/standalone/channel-daemon.mjs +54 -17
  72. package/src/standalone/channel-worker.mjs +18 -7
  73. package/src/standalone/explore-tool.mjs +87 -15
  74. package/src/tui/App.jsx +2 -2
  75. package/src/tui/components/StatusLine.jsx +3 -3
  76. package/src/tui/components/ToolExecution.jsx +14 -2
  77. package/src/tui/components/TranscriptItem.jsx +1 -1
  78. package/src/tui/dist/index.mjs +246 -47
  79. package/src/tui/engine/agent-job-feed.mjs +47 -3
  80. package/src/tui/engine/notification-plan.mjs +5 -0
  81. package/src/tui/engine/session-api.mjs +6 -1
  82. package/src/tui/engine/tool-card-results.mjs +14 -5
  83. package/src/tui/engine/turn.mjs +9 -2
  84. package/src/tui/engine.mjs +31 -12
  85. package/src/ui/statusline-agents.mjs +36 -0
  86. package/src/ui/statusline.mjs +15 -5
  87. package/src/workflows/default/WORKFLOW.md +29 -38
  88. package/src/workflows/solo/WORKFLOW.md +15 -20
  89. package/src/runtime/channels/lib/seat-lock.mjs +0 -196
@@ -8,6 +8,13 @@ import {
8
8
  statSync,
9
9
  writeFileSync,
10
10
  } from 'fs';
11
+ import {
12
+ copyFile as copyFileP,
13
+ mkdir as mkdirP,
14
+ readdir as readdirP,
15
+ rm as rmP,
16
+ stat as statP,
17
+ } from 'fs/promises';
11
18
  import { dirname, join, resolve } from 'path';
12
19
  import { homedir } from 'os';
13
20
  import { createHash } from 'crypto';
@@ -158,6 +165,65 @@ export function backupUserData(dataDir, reason = 'snapshot') {
158
165
  return { dir: copied.length > 0 ? backupDir : null, copied };
159
166
  }
160
167
 
168
+ // ── Async backup variant (fs.promises) ──────────────────────────────
169
+ // Byte-for-byte the same policy/skip guards/prune behavior as backupUserData,
170
+ // but every filesystem op yields via fs.promises so a debounced config flush
171
+ // on the UI event loop never blocks on the copy tree or the prune sweep.
172
+ async function copyTreeAsync(src, dst, copied) {
173
+ const st = await statP(src);
174
+ if (st.isDirectory()) {
175
+ for (const name of await readdirP(src)) {
176
+ await copyTreeAsync(join(src, name), join(dst, name), copied);
177
+ }
178
+ return;
179
+ }
180
+ if (!st.isFile()) return;
181
+ await mkdirP(dirname(dst), { recursive: true });
182
+ await copyFileP(src, dst);
183
+ copied.push(dst);
184
+ }
185
+
186
+ async function pruneBackupsAsync(keep = 40) {
187
+ let entries = [];
188
+ try {
189
+ entries = (await readdirP(getBackupRoot(), { withFileTypes: true }))
190
+ .filter((entry) => entry.isDirectory())
191
+ .map((entry) => entry.name)
192
+ .sort()
193
+ .reverse();
194
+ } catch {
195
+ return;
196
+ }
197
+ for (const name of entries.slice(keep)) {
198
+ try { await rmP(join(getBackupRoot(), name), { recursive: true, force: true }); } catch {}
199
+ }
200
+ }
201
+
202
+ export async function backupUserDataAsync(dataDir, reason = 'snapshot') {
203
+ if (process.env.MIXDOG_SKIP_USER_DATA_BACKUP === '1' || process.env.MIXDOG_SKIP_USER_DATA_BACKUP === 'true') {
204
+ return { dir: null, copied: [] };
205
+ }
206
+ if (!dataDir || !existsSync(dataDir)) return { dir: null, copied: [] };
207
+ const backupDir = join(getBackupRoot(), `${stamp()}-${safeReason(reason)}`);
208
+ const copied = [];
209
+ for (const rel of USER_DATA_FILES) {
210
+ const src = join(dataDir, rel);
211
+ if (existsSync(src)) await copyTreeAsync(src, join(backupDir, rel), copied);
212
+ }
213
+ for (const rel of USER_DATA_DIRS) {
214
+ const src = join(dataDir, rel);
215
+ if (existsSync(src)) await copyTreeAsync(src, join(backupDir, rel), copied);
216
+ }
217
+ if (copied.length > 0) {
218
+ markUserDataInitialized(dataDir);
219
+ await pruneBackupsAsync();
220
+ if (process.env.MIXDOG_SETUP_QUIET !== '1') {
221
+ process.stderr.write(`[user-data-backup] ${reason}: copied ${copied.length} file(s) to ${backupDir}\n`);
222
+ }
223
+ }
224
+ return { dir: copied.length > 0 ? backupDir : null, copied };
225
+ }
226
+
161
227
  export function shouldSeedMissingUserData(dataDir, rel) {
162
228
  if (!dataDir) return true;
163
229
  if (existsSync(join(dataDir, rel))) {
@@ -31,6 +31,7 @@ export function createConfigLifecycle({
31
31
  cfgMod,
32
32
  sharedCfgMod,
33
33
  setBackend,
34
+ setBackendAsync,
34
35
  setConfiguredShell,
35
36
  normalizeSystemShellConfig,
36
37
  normalizeSearchRouteConfig,
@@ -90,8 +91,61 @@ export function createConfigLifecycle({
90
91
  }
91
92
 
92
93
  // --- debounced config save --------------------------------------------------
94
+ // Coexistence strategy (sync vs async flush):
95
+ // * The debounce TIMER fires the ASYNC flush (async lock wait + async icacls
96
+ // + async backup + async atomic write) so a toggle never blocks the UI
97
+ // event loop.
98
+ // * Per-channel serialization: each channel keeps ONE in-flight promise tail;
99
+ // a new async flush chains after it so flushes never interleave. The pending
100
+ // payload is re-read at write time (identity guard), so a burst collapses to
101
+ // the last writer without dropping a newer toggle.
102
+ // * The SYNC flush is retained for reloadFullConfig/teardown (they need the
103
+ // write durable before continuing). It nulls the pending payload and writes
104
+ // synchronously; that sync write takes the SAME cross-process lock file as
105
+ // any in-flight async write, so it serializes AFTER it (lock contention),
106
+ // and the async loop's identity guard then finds a null/superseded payload
107
+ // and does not rewrite — never a revert, and no double-write except a rare
108
+ // idempotent same-content window if a sync flush lands mid async disk-write.
93
109
  let pendingConfigToSave = null;
94
110
  let configSaveTimer = null;
111
+ let configFlushInFlight = null;
112
+
113
+ async function runConfigFlushAsync() {
114
+ // Drain config, then skills, and RE-CHECK config. A config snapshot queued
115
+ // after the skills patch may carry a stale snapshot.skills (the snapshot
116
+ // captured an older config object ref, before the skills toggle replaced it)
117
+ // that would overwrite the just-patched skills.disabled. Looping until BOTH
118
+ // channels are quiescent keeps the skills patch the last writer relative to
119
+ // EVERY pending/queued config snapshot.
120
+ do {
121
+ let configFailed = false;
122
+ while (pendingConfigToSave !== null) {
123
+ const snapshot = pendingConfigToSave;
124
+ try {
125
+ await cfgMod.saveConfigAsync(snapshot);
126
+ } catch (err) {
127
+ process.stderr.write(`[config] async saveConfig failed: ${err?.message || err}\n`);
128
+ // Keep the payload: a failed write must not drop the pending change.
129
+ configFailed = true;
130
+ break;
131
+ }
132
+ if (pendingConfigToSave === snapshot) pendingConfigToSave = null;
133
+ }
134
+ // Ordering invariant: skills.disabled patch lands AFTER the config save.
135
+ await flushSkillsSaveAsync();
136
+ if (configFailed) break; // avoid a hot spin on a persistently failing write
137
+ } while (pendingConfigToSave !== null);
138
+ }
139
+
140
+ function flushConfigSaveAsync() {
141
+ if (configSaveTimer) { clearTimeout(configSaveTimer); configSaveTimer = null; }
142
+ const start = () => runConfigFlushAsync();
143
+ const p = configFlushInFlight ? configFlushInFlight.then(start, start) : start();
144
+ configFlushInFlight = p;
145
+ const clear = () => { if (configFlushInFlight === p) configFlushInFlight = null; };
146
+ p.then(clear, clear);
147
+ return p;
148
+ }
95
149
 
96
150
  function flushConfigSave() {
97
151
  if (configSaveTimer) {
@@ -100,9 +154,13 @@ export function createConfigLifecycle({
100
154
  }
101
155
  if (pendingConfigToSave !== null) {
102
156
  const snapshot = pendingConfigToSave;
103
- pendingConfigToSave = null;
104
157
  try {
105
158
  cfgMod.saveConfig(snapshot);
159
+ // Clear ONLY after a durable write. saveConfig blocks on the same
160
+ // cross-process lock an async flush may hold, so it serializes after it;
161
+ // if it still fails (e.g. lock timeout) we keep the payload so a later
162
+ // flush / reloadFullConfig retries instead of reverting to stale disk.
163
+ if (pendingConfigToSave === snapshot) pendingConfigToSave = null;
106
164
  } catch (err) {
107
165
  process.stderr.write(`[config] debounced saveConfig failed: ${err?.message || err}\n`);
108
166
  }
@@ -126,7 +184,7 @@ export function createConfigLifecycle({
126
184
  // disk write after CONFIG_SAVE_DEBOUNCE_MS of quiet.
127
185
  pendingConfigToSave = getConfig();
128
186
  if (configSaveTimer) clearTimeout(configSaveTimer);
129
- configSaveTimer = setTimeout(flushConfigSave, CONFIG_SAVE_DEBOUNCE_MS);
187
+ configSaveTimer = setTimeout(() => { flushConfigSaveAsync(); }, CONFIG_SAVE_DEBOUNCE_MS);
130
188
  configSaveTimer.unref?.();
131
189
  return adopted;
132
190
  }
@@ -134,6 +192,31 @@ export function createConfigLifecycle({
134
192
  // --- debounced backend switch ----------------------------------------------
135
193
  let pendingBackendName = null;
136
194
  let backendSaveTimer = null;
195
+ let backendFlushInFlight = null;
196
+
197
+ async function runBackendFlushAsync() {
198
+ while (pendingBackendName !== null) {
199
+ const name = pendingBackendName;
200
+ try {
201
+ await setBackendAsync(name);
202
+ } catch (err) {
203
+ process.stderr.write(`[channels] async setBackend failed: ${err?.message || err}\n`);
204
+ if (pendingBackendName === name) pendingBackendName = null;
205
+ break;
206
+ }
207
+ if (pendingBackendName === name) pendingBackendName = null;
208
+ }
209
+ }
210
+
211
+ function flushBackendSaveAsync() {
212
+ if (backendSaveTimer) { clearTimeout(backendSaveTimer); backendSaveTimer = null; }
213
+ const start = () => runBackendFlushAsync();
214
+ const p = backendFlushInFlight ? backendFlushInFlight.then(start, start) : start();
215
+ backendFlushInFlight = p;
216
+ const clear = () => { if (backendFlushInFlight === p) backendFlushInFlight = null; };
217
+ p.then(clear, clear);
218
+ return p;
219
+ }
137
220
 
138
221
  function flushBackendSave() {
139
222
  if (backendSaveTimer) {
@@ -153,7 +236,7 @@ export function createConfigLifecycle({
153
236
  function scheduleBackendSave(name) {
154
237
  pendingBackendName = name;
155
238
  if (backendSaveTimer) clearTimeout(backendSaveTimer);
156
- backendSaveTimer = setTimeout(flushBackendSave, CONFIG_SAVE_DEBOUNCE_MS);
239
+ backendSaveTimer = setTimeout(() => { flushBackendSaveAsync(); }, CONFIG_SAVE_DEBOUNCE_MS);
157
240
  backendSaveTimer.unref?.();
158
241
  }
159
242
 
@@ -163,6 +246,31 @@ export function createConfigLifecycle({
163
246
  // burst of settings-toggle key presses collapses into one disk write.
164
247
  let pendingSkillsNames = null;
165
248
  let skillsSaveTimer = null;
249
+ let skillsFlushInFlight = null;
250
+
251
+ async function runSkillsFlushAsync() {
252
+ while (pendingSkillsNames !== null) {
253
+ const names = pendingSkillsNames;
254
+ try {
255
+ await cfgMod.patchSkillsDisabledAsync(names);
256
+ } catch (err) {
257
+ process.stderr.write(`[config] async patchSkillsDisabled failed: ${err?.message || err}\n`);
258
+ if (pendingSkillsNames === names) pendingSkillsNames = null;
259
+ break;
260
+ }
261
+ if (pendingSkillsNames === names) pendingSkillsNames = null;
262
+ }
263
+ }
264
+
265
+ function flushSkillsSaveAsync() {
266
+ if (skillsSaveTimer) { clearTimeout(skillsSaveTimer); skillsSaveTimer = null; }
267
+ const start = () => runSkillsFlushAsync();
268
+ const p = skillsFlushInFlight ? skillsFlushInFlight.then(start, start) : start();
269
+ skillsFlushInFlight = p;
270
+ const clear = () => { if (skillsFlushInFlight === p) skillsFlushInFlight = null; };
271
+ p.then(clear, clear);
272
+ return p;
273
+ }
166
274
 
167
275
  function flushSkillsSave() {
168
276
  if (skillsSaveTimer) {
@@ -182,13 +290,50 @@ export function createConfigLifecycle({
182
290
  function scheduleSkillsSave(names) {
183
291
  pendingSkillsNames = names;
184
292
  if (skillsSaveTimer) clearTimeout(skillsSaveTimer);
185
- skillsSaveTimer = setTimeout(flushSkillsSave, CONFIG_SAVE_DEBOUNCE_MS);
293
+ skillsSaveTimer = setTimeout(() => { flushSkillsSaveAsync(); }, CONFIG_SAVE_DEBOUNCE_MS);
186
294
  skillsSaveTimer.unref?.();
187
295
  }
188
296
 
189
297
  // --- debounced top-level outputStyle persist -------------------------------
190
298
  let pendingOutputStyleId = null;
191
299
  let outputStyleSaveTimer = null;
300
+ let outputStyleFlushInFlight = null;
301
+
302
+ function outputStyleUpdater(styleId) {
303
+ return (root) => {
304
+ const next = { ...(root || {}), outputStyle: styleId };
305
+ if (next.agent && typeof next.agent === 'object' && !Array.isArray(next.agent)) {
306
+ const agent = { ...next.agent };
307
+ delete agent.outputStyle;
308
+ next.agent = agent;
309
+ }
310
+ return next;
311
+ };
312
+ }
313
+
314
+ async function runOutputStyleFlushAsync() {
315
+ while (pendingOutputStyleId !== null) {
316
+ const styleId = pendingOutputStyleId;
317
+ try {
318
+ await sharedCfgMod.updateConfigAsync(outputStyleUpdater(styleId));
319
+ } catch (err) {
320
+ process.stderr.write(`[config] async outputStyle save failed: ${err?.message || err}\n`);
321
+ if (pendingOutputStyleId === styleId) pendingOutputStyleId = null;
322
+ break;
323
+ }
324
+ if (pendingOutputStyleId === styleId) pendingOutputStyleId = null;
325
+ }
326
+ }
327
+
328
+ function flushOutputStyleSaveAsync() {
329
+ if (outputStyleSaveTimer) { clearTimeout(outputStyleSaveTimer); outputStyleSaveTimer = null; }
330
+ const start = () => runOutputStyleFlushAsync();
331
+ const p = outputStyleFlushInFlight ? outputStyleFlushInFlight.then(start, start) : start();
332
+ outputStyleFlushInFlight = p;
333
+ const clear = () => { if (outputStyleFlushInFlight === p) outputStyleFlushInFlight = null; };
334
+ p.then(clear, clear);
335
+ return p;
336
+ }
192
337
 
193
338
  function flushOutputStyleSave() {
194
339
  if (outputStyleSaveTimer) {
@@ -199,15 +344,7 @@ export function createConfigLifecycle({
199
344
  const styleId = pendingOutputStyleId;
200
345
  pendingOutputStyleId = null;
201
346
  try {
202
- sharedCfgMod.updateConfig((root) => {
203
- const next = { ...(root || {}), outputStyle: styleId };
204
- if (next.agent && typeof next.agent === 'object' && !Array.isArray(next.agent)) {
205
- const agent = { ...next.agent };
206
- delete agent.outputStyle;
207
- next.agent = agent;
208
- }
209
- return next;
210
- });
347
+ sharedCfgMod.updateConfig(outputStyleUpdater(styleId));
211
348
  } catch (err) {
212
349
  process.stderr.write(`[config] debounced outputStyle save failed: ${err?.message || err}\n`);
213
350
  }
@@ -216,7 +353,7 @@ export function createConfigLifecycle({
216
353
  function scheduleOutputStyleSave(styleId) {
217
354
  pendingOutputStyleId = styleId;
218
355
  if (outputStyleSaveTimer) clearTimeout(outputStyleSaveTimer);
219
- outputStyleSaveTimer = setTimeout(flushOutputStyleSave, CONFIG_SAVE_DEBOUNCE_MS);
356
+ outputStyleSaveTimer = setTimeout(() => { flushOutputStyleSaveAsync(); }, CONFIG_SAVE_DEBOUNCE_MS);
220
357
  outputStyleSaveTimer.unref?.();
221
358
  }
222
359
 
@@ -227,7 +364,30 @@ export function createConfigLifecycle({
227
364
  // subsequent adopt preserves) that change instead of reverting to a stale
228
365
  // on-disk snapshot.
229
366
  flushConfigSave();
230
- return adoptConfig(cfgMod.loadConfig(), { hasSecrets: true });
367
+ const loaded = cfgMod.loadConfig();
368
+ if (pendingConfigToSave !== null) {
369
+ // The debounced write could not land (e.g. lock timeout), so on-disk is
370
+ // stale. Prefer the freshest in-memory state and re-overlay the keychain
371
+ // provider secrets that only the disk load carries, so a failed flush
372
+ // never reverts the user's latest change.
373
+ const current = getConfig();
374
+ const merged = { ...loaded, ...current, providers: { ...(current.providers || {}) } };
375
+ for (const [name, val] of Object.entries(loaded.providers || {})) {
376
+ if (val && val.apiKey) {
377
+ // Match loadConfig's keychain overlay: apiKey ⇒ enabled:true, UNLESS
378
+ // the in-memory pending state EXPLICITLY disabled this provider (a
379
+ // genuine newer user change that must not be reverted).
380
+ const explicitlyDisabled = current.providers?.[name]?.enabled === false;
381
+ merged.providers[name] = {
382
+ ...(merged.providers[name] || {}),
383
+ apiKey: val.apiKey,
384
+ enabled: explicitlyDisabled ? false : true,
385
+ };
386
+ }
387
+ }
388
+ return adoptConfig(merged, { hasSecrets: true });
389
+ }
390
+ return adoptConfig(loaded, { hasSecrets: true });
231
391
  }
232
392
 
233
393
  function ensureFullConfig() {
@@ -250,11 +250,41 @@ export function createMcpGlue({
250
250
  return { name, config };
251
251
  }
252
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
+
253
282
  return {
254
283
  mcpTransportLabel,
255
284
  resolveEffectiveMcpServers,
256
285
  mcpStatus,
257
286
  connectConfiguredMcp,
287
+ awaitInitialMcpConnect,
258
288
  normalizeMcpServerInput,
259
289
  };
260
290
  }
@@ -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);
@@ -1131,6 +1150,7 @@ export async function createMixdogSessionRuntime({
1131
1150
  cfgMod,
1132
1151
  sharedCfgMod,
1133
1152
  setBackend,
1153
+ setBackendAsync,
1134
1154
  setConfiguredShell,
1135
1155
  normalizeSystemShellConfig,
1136
1156
  normalizeSearchRouteConfig,
@@ -1380,7 +1400,28 @@ export async function createMixdogSessionRuntime({
1380
1400
  session = mgr.createSession(sessionOpts);
1381
1401
  sessionNeedsCwdRefresh = false;
1382
1402
  attachSessionHooks(session, { hooks, hookCommonPayload, getCwd: () => currentCwd });
1383
- 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 || ''));
1384
1425
  applyPreSessionToolSelection();
1385
1426
  writeStatuslineRoute(statusRoutes, session, route);
1386
1427
  hooks.emit('session:create', { sessionId: session.id, provider: route.provider, model: route.model, toolMode: mode, cwd: currentCwd });
@@ -1407,6 +1448,9 @@ export async function createMixdogSessionRuntime({
1407
1448
  tools: Array.isArray(session.tools) ? session.tools.length : 0,
1408
1449
  catalog: Array.isArray(session.deferredToolCatalog) ? session.deferredToolCatalog.length : 0,
1409
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();
1410
1454
  return session;
1411
1455
  })();
1412
1456
 
@@ -1538,17 +1582,55 @@ export async function createMixdogSessionRuntime({
1538
1582
  // failure must never throw into the lead paths that call this.
1539
1583
  function pushTranscriptRebind() {
1540
1584
  if (!remoteEnabled) return;
1541
- if (!ensureRemoteTranscriptWriter()) 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; }
1542
1589
  const transcriptPath = _transcriptWriter?.transcriptPath;
1543
- if (!transcriptPath || !channelsEnabled()) return;
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
+ };
1544
1619
  try {
1545
- void channels.execute('rebind_current_transcript', { transcriptPath })
1546
- .catch((error) => bootProfile('channels:rebind-push-failed', { error: error?.message || String(error) }));
1620
+ void channels.execute('rebind_current_transcript', { transcriptPath }).catch(onError);
1547
1621
  } catch (error) {
1548
- bootProfile('channels:rebind-push-failed', { error: error?.message || String(error) });
1622
+ onError(error);
1549
1623
  }
1550
1624
  }
1551
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
+
1552
1634
  // Remote (Discord channel) mode is opt-in per session. Only a session that
1553
1635
  // explicitly enables remote — via `mixdog --remote` or the runtime toggle —
1554
1636
  // boots the channel worker and contends for channel ownership.
@@ -2008,6 +2090,7 @@ export async function createMixdogSessionRuntime({
2008
2090
  createCurrentSession,
2009
2091
  ensureRemoteTranscriptWriter,
2010
2092
  pushTranscriptRebind,
2093
+ flushPendingTranscriptRebind,
2011
2094
  channelsEnabled,
2012
2095
  invokeChannelStart,
2013
2096
  channels,
@@ -2027,6 +2110,7 @@ export async function createMixdogSessionRuntime({
2027
2110
  resolveCwdPath,
2028
2111
  agentStatusState,
2029
2112
  notificationListeners,
2113
+ awaitInitialMcpConnect,
2030
2114
  });
2031
2115
 
2032
2116
  return {
@@ -7,6 +7,7 @@ import {
7
7
  sortedNamesByMeasuredUsage,
8
8
  selectDeferredTools,
9
9
  reconcileDeferredMcpToolCatalog,
10
+ refreshInitialDeferredMcpSurface,
10
11
  } from './tool-catalog.mjs';
11
12
  import { getMcpTools } from '../runtime/agent/orchestrator/mcp/client.mjs';
12
13
 
@@ -24,11 +25,12 @@ export function createSessionTurnApi(deps) {
24
25
  getTranscriptWriter, getTwKey, getLastAppendedAssistant, setLastAppendedAssistant,
25
26
  scheduleCodeGraphPrewarm, refreshSessionForCwdIfNeeded, createCurrentSession,
26
27
  ensureRemoteTranscriptWriter, channelsEnabled, invokeChannelStart, channels,
27
- pushTranscriptRebind,
28
+ pushTranscriptRebind, flushPendingTranscriptRebind,
28
29
  hooks, hookCommonPayload, mgr, notifyFnForSession, bootProfile,
29
30
  scheduleProviderWarmup, scheduleProviderModelWarmup, invalidateContextStatusCache,
30
31
  agentTool, recreateCurrentSessionIfReady, invalidatePreSessionToolSurface,
31
32
  activeToolSurface, applyResolvedCwd, resolveCwdPath, agentStatusState, notificationListeners,
33
+ awaitInitialMcpConnect,
32
34
  } = deps;
33
35
  return {
34
36
  async ask(prompt, options = {}) {
@@ -49,6 +51,9 @@ export function createSessionTurnApi(deps) {
49
51
  setLastAppendedAssistant('');
50
52
  const prevKey = getTwKey();
51
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?.();
52
57
  if (getTwKey() && getTwKey() !== prevKey && channelsEnabled() && !envFlag('MIXDOG_DISABLE_CHANNEL_START')) {
53
58
  void invokeChannelStart()
54
59
  .then(() => {
@@ -59,22 +64,43 @@ export function createSessionTurnApi(deps) {
59
64
  }
60
65
  }
61
66
  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
- });
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 */ }
76
103
  }
77
- catch { /* MCP delta must never break the turn */ }
78
104
  hooks.emit('turn:start', { sessionId: session0.id, prompt, cwd: getCurrentCwd() });
79
105
  // UserPromptSubmit: a hook FAILURE must not block the turn, but blocked===true MUST throw.
80
106
  let promptDispatch = null;