mixdog 0.9.23 → 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 (84) hide show
  1. package/package.json +1 -1
  2. package/scripts/boot-smoke.mjs +1 -1
  3. package/scripts/build-runtime-windows.ps1 +242 -242
  4. package/scripts/channel-daemon-smoke.mjs +327 -9
  5. package/scripts/channel-daemon-stub.mjs +12 -1
  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/recall-usecase-cases.json +1 -1
  11. package/scripts/smoke-runtime-negative.ps1 +106 -106
  12. package/scripts/tool-efficiency-diag.mjs +1 -1
  13. package/scripts/tool-smoke.mjs +38 -30
  14. package/src/rules/agent/30-explorer.md +6 -0
  15. package/src/rules/lead/02-channels.md +3 -3
  16. package/src/rules/shared/01-tool.md +11 -4
  17. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
  18. package/src/runtime/agent/orchestrator/config.mjs +33 -7
  19. package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
  20. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +39 -0
  21. package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -31
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
  24. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
  25. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  26. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -3
  27. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  28. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  29. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  30. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  31. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  32. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  33. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  34. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  35. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  36. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +43 -16
  37. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  38. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  39. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  40. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  41. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +70 -21
  42. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  43. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  44. package/src/runtime/channels/lib/owned-runtime.mjs +65 -180
  45. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -13
  46. package/src/runtime/channels/lib/runtime-paths.mjs +6 -6
  47. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -17
  48. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  49. package/src/runtime/channels/lib/worker-main.mjs +9 -28
  50. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  51. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  52. package/src/runtime/memory/tool-defs.mjs +1 -1
  53. package/src/runtime/shared/atomic-file.mjs +130 -2
  54. package/src/runtime/shared/background-tasks.mjs +1 -1
  55. package/src/runtime/shared/config.mjs +53 -1
  56. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  57. package/src/runtime/shared/tool-surface.mjs +19 -0
  58. package/src/runtime/shared/update-checker.mjs +3 -0
  59. package/src/runtime/shared/user-data-guard.mjs +66 -0
  60. package/src/session-runtime/config-lifecycle.mjs +175 -15
  61. package/src/session-runtime/mcp-glue.mjs +30 -0
  62. package/src/session-runtime/runtime-core.mjs +91 -7
  63. package/src/session-runtime/session-turn-api.mjs +42 -16
  64. package/src/session-runtime/tool-catalog.mjs +44 -0
  65. package/src/standalone/channel-admin.mjs +32 -3
  66. package/src/standalone/channel-daemon-client.mjs +3 -1
  67. package/src/standalone/channel-daemon-transport.mjs +202 -8
  68. package/src/standalone/channel-daemon.mjs +54 -17
  69. package/src/standalone/channel-worker.mjs +18 -7
  70. package/src/standalone/explore-tool.mjs +87 -15
  71. package/src/tui/App.jsx +2 -2
  72. package/src/tui/components/StatusLine.jsx +3 -3
  73. package/src/tui/components/ToolExecution.jsx +14 -2
  74. package/src/tui/components/TranscriptItem.jsx +1 -1
  75. package/src/tui/dist/index.mjs +209 -44
  76. package/src/tui/engine/agent-job-feed.mjs +5 -0
  77. package/src/tui/engine/notification-plan.mjs +5 -0
  78. package/src/tui/engine/session-api.mjs +6 -1
  79. package/src/tui/engine/tool-card-results.mjs +14 -5
  80. package/src/tui/engine/turn.mjs +9 -2
  81. package/src/tui/engine.mjs +31 -12
  82. package/src/ui/statusline-agents.mjs +36 -0
  83. package/src/ui/statusline.mjs +15 -5
  84. package/src/runtime/channels/lib/seat-lock.mjs +0 -196
@@ -13,7 +13,10 @@ import {
13
13
  } from 'fs';
14
14
  import { dirname, basename, join } from 'path';
15
15
  import { randomBytes } from 'crypto';
16
- import { execFileSync } from 'child_process';
16
+ import { execFile, execFileSync } from 'child_process';
17
+ import { promisify } from 'util';
18
+
19
+ const _execFileAsync = promisify(execFile);
17
20
 
18
21
  const RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY', 'EEXIST']);
19
22
  const LOCK_WAIT_CODES = new Set(['EEXIST', 'EPERM', 'EACCES', 'EBUSY']);
@@ -419,7 +422,10 @@ export async function withFileLock(lockPath, fn, opts = {}) {
419
422
  }
420
423
  try { writeFileSync(fd, `${process.pid} ${Date.now()} ${_OWNER_TOKEN}\n`, 'utf8'); } catch {}
421
424
  try {
422
- if (opts.secret === true) _enforceOwnerOnlyAclWin32(lockPath);
425
+ // Async ACL enforcement (promisified execFile) so a secret-bearing
426
+ // async holder never blocks the event loop on icacls; identical
427
+ // fail-closed semantics to the sync variant.
428
+ if (opts.secret === true) await _enforceOwnerOnlyAclWin32Async(lockPath);
423
429
  return await fn();
424
430
  } finally {
425
431
  try { closeSync(fd); } catch {}
@@ -523,6 +529,59 @@ function _enforceOwnerOnlyAclWin32(targetPath) {
523
529
  }
524
530
  }
525
531
 
532
+ // ── Async owner-only ACL enforcement (non-blocking, fail-closed) ─────
533
+ // Byte-for-byte the same policy as `_enforceOwnerOnlyAclWin32`, but the two
534
+ // icacls invocations (and the one-time whoami SID resolution) run through
535
+ // promisified execFile so the event loop is never blocked on a subprocess.
536
+ // Shares the `_cachedUserSid` cache with the sync variant, and throws the
537
+ // same EACL* codes so callers keep their fail-closed guarantees.
538
+ async function _resolveCurrentUserPrincipalAsync() {
539
+ const systemRoot = process.env.SystemRoot || process.env.windir;
540
+ if (systemRoot) {
541
+ const whoami = join(systemRoot, 'System32', 'whoami.exe');
542
+ if (existsSync(whoami)) {
543
+ try {
544
+ const { stdout } = await _execFileAsync(whoami, ['/user', '/fo', 'csv', '/nh'], {
545
+ encoding: 'utf8',
546
+ windowsHide: true,
547
+ });
548
+ const m = String(stdout).match(/S-1-5-[0-9-]+/);
549
+ if (m) return m[0];
550
+ } catch {}
551
+ }
552
+ }
553
+ const err = new Error('cannot resolve current Windows user for owner-only ACL enforcement');
554
+ err.code = 'EACLNOUSER';
555
+ throw err;
556
+ }
557
+
558
+ async function _enforceOwnerOnlyAclWin32Async(targetPath) {
559
+ if (process.platform !== 'win32') return;
560
+ const systemRoot = process.env.SystemRoot || process.env.windir;
561
+ if (!systemRoot) {
562
+ const err = new Error('SystemRoot not set; cannot locate icacls.exe for owner-only ACL enforcement');
563
+ err.code = 'EACLNOROOT';
564
+ throw err;
565
+ }
566
+ const icacls = join(systemRoot, 'System32', 'icacls.exe');
567
+ if (!existsSync(icacls)) {
568
+ const err = new Error(`icacls.exe not found at ${icacls}; refusing to leave secret world-readable`);
569
+ err.code = 'EACLNOICACLS';
570
+ throw err;
571
+ }
572
+ if (_cachedUserSid === null) _cachedUserSid = await _resolveCurrentUserPrincipalAsync();
573
+ const principal = _icaclsPrincipal(_cachedUserSid);
574
+ try {
575
+ await _execFileAsync(icacls, [targetPath, '/reset'], { windowsHide: true });
576
+ await _execFileAsync(icacls, [targetPath, '/inheritance:r', '/grant:r', `${principal}:(F)`], { windowsHide: true });
577
+ } catch (err) {
578
+ const e = new Error(`icacls failed to apply owner-only ACL to ${targetPath}: ${err?.message || err}`);
579
+ e.code = 'EACLFAIL';
580
+ e.cause = err;
581
+ throw e;
582
+ }
583
+ }
584
+
526
585
  export function writeFileAtomicSync(filePath, data, opts = {}) {
527
586
  const run = () => {
528
587
  const dir = dirname(filePath);
@@ -648,6 +707,75 @@ export function updateJsonAtomicSync(filePath, mutator, opts = {}) {
648
707
  }, opts);
649
708
  }
650
709
 
710
+ // ── Async atomic file write (non-blocking secret ACL) ───────────────
711
+ // Mirror of writeFileAtomicSync, but the owner-only ACL enforcement on the
712
+ // temp and final path runs through the async icacls variant so a debounced
713
+ // timer flush never blocks the event loop on a subprocess. The tiny
714
+ // writeFileSync/renameSync of a small JSON payload stay synchronous (they are
715
+ // not the hitch); only the icacls calls — the actual blockers — are awaited.
716
+ // The truncate renameFallback branch is intentionally omitted: it only ever
717
+ // applied to non-secret writes (opts.secret !== true), and every async caller
718
+ // here is a secret config write, so behavior is identical.
719
+ export async function writeFileAtomicAsync(filePath, data, opts = {}) {
720
+ const run = async () => {
721
+ const dir = dirname(filePath);
722
+ mkdirSync(dir, { recursive: true });
723
+ const tmp = join(dir, `.${basename(filePath)}.${randomBytes(12).toString('hex')}.tmp`);
724
+ try {
725
+ const writeOpts = { encoding: opts.encoding || 'utf8', flag: 'wx', mode: opts.mode !== undefined ? opts.mode : 0o600 };
726
+ writeFileSync(tmp, data, writeOpts);
727
+ if (opts.secret === true) await _enforceOwnerOnlyAclWin32Async(tmp);
728
+ if (opts.fsync !== false) {
729
+ let fd = null;
730
+ try {
731
+ fd = openSync(tmp, 'r');
732
+ fsyncSync(fd);
733
+ } catch (err) {
734
+ if (!['EPERM', 'ENOTSUP', 'EINVAL'].includes(err?.code)) throw err;
735
+ } finally {
736
+ try { if (fd !== null) closeSync(fd); } catch {}
737
+ }
738
+ }
739
+ if (opts.createOnly === true) {
740
+ try {
741
+ linkSync(tmp, filePath);
742
+ } catch (err) {
743
+ try { unlinkSync(tmp); } catch {}
744
+ if (err?.code === 'EEXIST') return false;
745
+ throw err;
746
+ }
747
+ try { unlinkSync(tmp); } catch {}
748
+ } else {
749
+ renameWithRetrySync(tmp, filePath, opts);
750
+ }
751
+ if (opts.secret === true) await _enforceOwnerOnlyAclWin32Async(filePath);
752
+ if (opts.fsyncDir === true) {
753
+ let dfd = null;
754
+ try {
755
+ dfd = openSync(dir, 'r');
756
+ fsyncSync(dfd);
757
+ } catch (err) {
758
+ if (!['EPERM', 'ENOTSUP', 'EINVAL', 'EACCES'].includes(err?.code)) throw err;
759
+ } finally {
760
+ try { if (dfd !== null) closeSync(dfd); } catch {}
761
+ }
762
+ }
763
+ return true;
764
+ } catch (err) {
765
+ try { if (existsSync(tmp)) unlinkSync(tmp); } catch {}
766
+ throw err;
767
+ }
768
+ };
769
+ if (opts.lock === true) {
770
+ return withFileLock(`${filePath}.lock`, run, opts);
771
+ }
772
+ return run();
773
+ }
774
+
775
+ export function writeJsonAtomicAsync(filePath, value, opts = {}) {
776
+ return writeFileAtomicAsync(filePath, JSON.stringify(value, null, opts.compact ? 0 : 2) + '\n', opts);
777
+ }
778
+
651
779
  // Async read-modify-write. Same lock path (`${filePath}.lock`) and protocol
652
780
  // as updateJsonAtomicSync, so it is mutually exclusive with the sync variant.
653
781
  // The critical section itself is synchronous (readFileSync + writeJsonAtomicSync);
@@ -51,7 +51,7 @@ export function executionModeSchemaDescription(defaultMode = 'sync') {
51
51
  if (defaultMode === 'async') {
52
52
  return 'sync = inline result; async = task_id + completion notification. Default async.';
53
53
  }
54
- return 'Runs sync; long-running auto-promotes to a background task_id + completion notification. async forces background.';
54
+ return 'Runs sync (inline result); no default auto-background. async forces a background task_id + completion notification.';
55
55
  }
56
56
 
57
57
  export function taskIdFromArgs(args = {}) {
@@ -6,9 +6,10 @@ import { readFileSync, mkdirSync, existsSync } from 'fs'
6
6
  import { join, dirname } from 'path'
7
7
  import { createRequire } from 'module'
8
8
  import { resolvePluginData } from './plugin-paths.mjs'
9
- import { renameWithRetrySync, writeJsonAtomicSync, withFileLockSync } from './atomic-file.mjs'
9
+ import { renameWithRetrySync, writeJsonAtomicSync, writeJsonAtomicAsync, withFileLockSync, withFileLock } from './atomic-file.mjs'
10
10
  import {
11
11
  backupUserData,
12
+ backupUserDataAsync,
12
13
  markUserDataInitialized,
13
14
  loadLatestMixdogConfigFromBackup,
14
15
  hasUserDataInitMarker,
@@ -210,6 +211,57 @@ export function updateSection(section, updater) {
210
211
  })
211
212
  }
212
213
 
214
+ // ── Async write path (non-blocking) ─────────────────────────────────
215
+ // Parity with the sync RMW above, but every heavy blocker (cross-process
216
+ // lock wait, owner-only icacls ACL, user-data backup copy tree) is awaited
217
+ // off the event loop. Reuses the SAME lock file (`${CONFIG_PATH}.lock`) and
218
+ // secret:true ACL protocol, so an async writer and a sync writer are mutually
219
+ // exclusive against one another AND against other processes — cross-process
220
+ // RMW linearizability is preserved. The in-lock read (readAllForRmW) stays
221
+ // synchronous: it is a small local read, not the hitch source.
222
+ async function writeJsonFileAsync(path, data) {
223
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
224
+ if (path === CONFIG_PATH) {
225
+ try { await backupUserDataAsync(DATA_DIR, 'pre-config-write') } catch {}
226
+ }
227
+ await writeJsonAtomicAsync(path, data, { lock: false, fsyncDir: true, mode: 0o600, secret: true })
228
+ if (path === CONFIG_PATH) {
229
+ try { markUserDataInitialized(DATA_DIR) } catch {}
230
+ try { await backupUserDataAsync(DATA_DIR, 'post-config-write') } catch {}
231
+ }
232
+ }
233
+
234
+ async function writeAllAsync(data) {
235
+ await writeJsonFileAsync(CONFIG_PATH, data)
236
+ }
237
+
238
+ // secret:true clamps the shared-home lock file owner-only on win32 via the
239
+ // ASYNC icacls variant inside withFileLock (fail-closed, non-blocking).
240
+ function withConfigLockAsync(fn) {
241
+ return withFileLock(`${CONFIG_PATH}.lock`, fn, { secret: true })
242
+ }
243
+
244
+ export async function updateConfigAsync(updater) {
245
+ let saved = null
246
+ await withConfigLockAsync(async () => {
247
+ const current = stripGeneratedMarker(readAllForRmW()) || {}
248
+ const next = typeof updater === 'function' ? updater({ ...current }) : updater
249
+ if (!isPlainObject(next)) throw new Error('[config] updateConfigAsync updater must return an object')
250
+ saved = stripGeneratedMarker(next) || {}
251
+ await writeAllAsync(saved)
252
+ })
253
+ return saved
254
+ }
255
+
256
+ export async function updateSectionAsync(section, updater) {
257
+ await withConfigLockAsync(async () => {
258
+ const all = readAllForRmW()
259
+ const current = stripGeneratedMarker(all[section] || {})
260
+ all[section] = stripGeneratedMarker(typeof updater === 'function' ? updater(current) : updater)
261
+ await writeAllAsync(all)
262
+ })
263
+ }
264
+
213
265
  // ── Capabilities (B2 central path policy) ───────────────────────────
214
266
  // Top-level `capabilities` section in mixdog-config.json. Safe defaults
215
267
  // win on missing/malformed input — every cap is OFF unless explicitly
@@ -2,7 +2,7 @@ export const TOOL_SYNC_EXECUTION_CONTRACT =
2
2
  'Runs synchronously in this tool call.';
3
3
 
4
4
  export const TOOL_ASYNC_EXECUTION_CONTRACT =
5
- 'Runs sync; long-running auto-promotes to a task_id + completion notification. async forces background. status/read/wait are recovery/blocking only.';
5
+ 'Runs sync inline; no default auto-background. async forces a background task_id + completion notification. status/read/wait are recovery/blocking only.';
6
6
 
7
7
  export const TOOL_MANUAL_CONTROL_CONTRACT =
8
8
  'wait/read/status/cancel are for explicit blocking or recovery only.';
@@ -583,6 +583,25 @@ export function aggregateToolCategoryEntry(name, args = {}, category = '') {
583
583
  };
584
584
  }
585
585
 
586
+ /**
587
+ * Rebuild the per-category count map for the DONE state, excluding calls that
588
+ * terminated in an error (rec.isError). Mirrors the call-time accumulation in
589
+ * turn.mjs (sum aggregateToolCategoryEntry(...).count per key) but drops failed
590
+ * calls so the completed label counts only successful work — across ALL
591
+ * categories, not just Memory. The active/in-flight header keeps using the raw
592
+ * `categories` map, so in-flight counting is unchanged.
593
+ */
594
+ export function aggregateDoneCategories(calls = []) {
595
+ const map = new Map();
596
+ for (const rec of calls || []) {
597
+ if (!rec || rec.isError) continue;
598
+ const entry = aggregateToolCategoryEntry(rec.name, rec.args, rec.category);
599
+ const prev = map.get(entry.key);
600
+ map.set(entry.key, { ...entry, count: Number(prev?.count || 0) + Number(entry.count || 1) });
601
+ }
602
+ return Object.fromEntries(map);
603
+ }
604
+
586
605
  function aggregateCount(value) {
587
606
  if (value && typeof value === 'object') return Math.max(0, Number(value.count || 0));
588
607
  return Math.max(0, Number(value || 0));
@@ -188,6 +188,9 @@ export function runGlobalUpdate() {
188
188
  child = spawn(npmCmd, ['install', '-g', `${PACKAGE_NAME}@latest`], {
189
189
  stdio: 'ignore',
190
190
  windowsHide: true,
191
+ // detached: survive parent exit — quitting the TUI mid-install must
192
+ // not kill npm halfway and leave the global install corrupted.
193
+ detached: true,
191
194
  shell: process.platform === 'win32',
192
195
  });
193
196
  } catch (err) {
@@ -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
  }