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.
- package/package.json +1 -1
- package/scripts/boot-smoke.mjs +1 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/channel-daemon-smoke.mjs +327 -9
- package/scripts/channel-daemon-stub.mjs +12 -1
- package/scripts/debounced-skills-async-save-test.mjs +57 -0
- package/scripts/explore-bench-tmp.mjs +17 -0
- package/scripts/find-fuzzy-hidden-test.mjs +145 -0
- package/scripts/mcp-grace-deferred-test.mjs +149 -0
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/tool-smoke.mjs +38 -30
- package/src/rules/agent/30-explorer.md +6 -0
- package/src/rules/lead/02-channels.md +3 -3
- package/src/rules/shared/01-tool.md +11 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
- package/src/runtime/agent/orchestrator/config.mjs +33 -7
- package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +39 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -31
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -3
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +43 -16
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +70 -21
- package/src/runtime/channels/lib/crash-log.mjs +4 -2
- package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +65 -180
- package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -13
- package/src/runtime/channels/lib/runtime-paths.mjs +6 -6
- package/src/runtime/channels/lib/tool-dispatch.mjs +9 -17
- package/src/runtime/channels/lib/tool-format.mjs +7 -2
- package/src/runtime/channels/lib/worker-main.mjs +9 -28
- package/src/runtime/memory/lib/query-handlers.mjs +4 -1
- package/src/runtime/memory/lib/recall-format.mjs +7 -3
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +130 -2
- package/src/runtime/shared/background-tasks.mjs +1 -1
- package/src/runtime/shared/config.mjs +53 -1
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +19 -0
- package/src/runtime/shared/update-checker.mjs +3 -0
- package/src/runtime/shared/user-data-guard.mjs +66 -0
- package/src/session-runtime/config-lifecycle.mjs +175 -15
- package/src/session-runtime/mcp-glue.mjs +30 -0
- package/src/session-runtime/runtime-core.mjs +91 -7
- package/src/session-runtime/session-turn-api.mjs +42 -16
- package/src/session-runtime/tool-catalog.mjs +44 -0
- package/src/standalone/channel-admin.mjs +32 -3
- package/src/standalone/channel-daemon-client.mjs +3 -1
- package/src/standalone/channel-daemon-transport.mjs +202 -8
- package/src/standalone/channel-daemon.mjs +54 -17
- package/src/standalone/channel-worker.mjs +18 -7
- package/src/standalone/explore-tool.mjs +87 -15
- package/src/tui/App.jsx +2 -2
- package/src/tui/components/StatusLine.jsx +3 -3
- package/src/tui/components/ToolExecution.jsx +14 -2
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/dist/index.mjs +209 -44
- package/src/tui/engine/agent-job-feed.mjs +5 -0
- package/src/tui/engine/notification-plan.mjs +5 -0
- package/src/tui/engine/session-api.mjs +6 -1
- package/src/tui/engine/tool-card-results.mjs +14 -5
- package/src/tui/engine/turn.mjs +9 -2
- package/src/tui/engine.mjs +31 -12
- package/src/ui/statusline-agents.mjs +36 -0
- package/src/ui/statusline.mjs +15 -5
- 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
|
-
|
|
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;
|
|
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;
|
|
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(
|
|
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(
|
|
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(
|
|
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((
|
|
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(
|
|
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
|
-
|
|
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
|
}
|