mixdog 0.9.22 → 0.9.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -4
- package/package.json +1 -1
- package/scripts/boot-smoke.mjs +1 -1
- package/scripts/channel-daemon-smoke.mjs +483 -0
- package/scripts/channel-daemon-stub.mjs +80 -0
- 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/statusline-quota-hysteresis-test.mjs +37 -0
- package/scripts/tool-smoke.mjs +68 -47
- package/src/rules/agent/30-explorer.md +6 -0
- 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 +226 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +184 -33
- 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/providers/openai-compat-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -3
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
- package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
- 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-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +67 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
- 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 +126 -22
- 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 +67 -223
- package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -62
- package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
- package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
- package/src/runtime/channels/lib/session-discovery.mjs +2 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +22 -14
- package/src/runtime/channels/lib/tool-format.mjs +7 -2
- package/src/runtime/channels/lib/worker-main.mjs +42 -30
- package/src/runtime/memory/index.mjs +54 -7
- package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
- package/src/runtime/memory/lib/pg/process.mjs +85 -40
- package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
- 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 +150 -6
- 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-primitives.mjs +31 -1
- package/src/runtime/shared/tool-surface.mjs +42 -13
- 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 +221 -20
- package/src/session-runtime/lifecycle-api.mjs +9 -0
- package/src/session-runtime/mcp-glue.mjs +93 -1
- package/src/session-runtime/resource-api.mjs +62 -8
- package/src/session-runtime/runtime-core.mjs +118 -4
- package/src/session-runtime/session-text.mjs +41 -0
- package/src/session-runtime/session-turn-api.mjs +50 -0
- package/src/session-runtime/settings-api.mjs +8 -1
- package/src/session-runtime/tool-catalog.mjs +350 -38
- package/src/session-runtime/tool-defs.mjs +7 -7
- package/src/session-runtime/workflow.mjs +2 -1
- package/src/standalone/channel-admin.mjs +32 -3
- package/src/standalone/channel-daemon-client.mjs +226 -0
- package/src/standalone/channel-daemon-transport.mjs +545 -0
- package/src/standalone/channel-daemon.mjs +176 -0
- package/src/standalone/channel-worker.mjs +224 -4
- package/src/standalone/explore-tool.mjs +87 -15
- package/src/standalone/hook-bus.mjs +71 -3
- package/src/tui/App.jsx +107 -19
- package/src/tui/app/clipboard.mjs +39 -19
- package/src/tui/app/doctor.mjs +57 -0
- package/src/tui/app/extension-pickers.mjs +53 -9
- package/src/tui/app/maintenance-pickers.mjs +0 -5
- package/src/tui/app/slash-dispatch.mjs +4 -4
- package/src/tui/app/text-layout.mjs +11 -0
- package/src/tui/app/use-mouse-input.mjs +235 -51
- package/src/tui/app/use-prompt-handlers.mjs +49 -30
- package/src/tui/app/use-transcript-scroll.mjs +124 -27
- package/src/tui/app/use-transcript-window.mjs +55 -1
- package/src/tui/components/Message.jsx +1 -1
- package/src/tui/components/PromptInput.jsx +3 -1
- package/src/tui/components/QueuedCommands.jsx +21 -10
- package/src/tui/components/StatusLine.jsx +3 -3
- package/src/tui/components/ToolExecution.jsx +16 -4
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/dist/index.mjs +820 -326
- 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 +23 -8
- package/src/tui/engine/session-flow.mjs +6 -0
- package/src/tui/engine/tool-card-results.mjs +14 -5
- package/src/tui/engine/turn.mjs +9 -2
- package/src/tui/engine.mjs +32 -5
- package/src/tui/index.jsx +62 -18
- package/src/tui/paste-attachments.mjs +26 -0
- package/src/ui/statusline-agents.mjs +36 -0
- package/src/ui/statusline.mjs +60 -10
- package/src/ui/tool-card.mjs +8 -1
- package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
- package/src/workflows/bench/WORKFLOW.md +46 -0
- package/src/workflows/default/WORKFLOW.md +6 -0
- package/src/workflows/solo/WORKFLOW.md +5 -0
- package/vendor/ink/build/ink.js +23 -1
- package/vendor/ink/build/output.js +154 -71
- package/vendor/ink/build/render-node-to-output.js +44 -2
- package/vendor/ink/build/render.js +4 -0
- package/vendor/ink/build/renderer.js +4 -1
|
@@ -94,7 +94,69 @@ export function createMcpGlue({
|
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
// Connect/disconnect exactly one server in the live registry, leaving all
|
|
98
|
+
// others untouched. Used by the enable/disable toggle so a single-server
|
|
99
|
+
// change never triggers a full disconnectAll()/reconnect freeze.
|
|
100
|
+
async function applyMcpServerConnection(name, enabled) {
|
|
101
|
+
const target = clean(name);
|
|
102
|
+
if (!target) return;
|
|
103
|
+
const { servers, sources } = resolveEffectiveMcpServers();
|
|
104
|
+
// A project-local `.mcp.json` entry WINS over config for this name, so the
|
|
105
|
+
// config enable/disable flag doesn't change what's actually connected.
|
|
106
|
+
// Only act when the effective entry is config-sourced (or absent).
|
|
107
|
+
if (sources[target] && sources[target] !== 'config') return;
|
|
108
|
+
// Changing this server's state clears any stale failure record for it.
|
|
109
|
+
if (Array.isArray(state.mcpFailures)) {
|
|
110
|
+
state.mcpFailures = state.mcpFailures.filter((row) => row.name !== target);
|
|
111
|
+
}
|
|
112
|
+
if (enabled === false) {
|
|
113
|
+
await mcpClient.disconnectMcpServer?.(target);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const cfg = servers[target];
|
|
117
|
+
if (!cfg) return;
|
|
118
|
+
// Drop any existing live entry first so connectMcpServers doesn't overwrite
|
|
119
|
+
// the registry Map entry and leak the old transport/process.
|
|
120
|
+
await mcpClient.disconnectMcpServer?.(target);
|
|
121
|
+
try {
|
|
122
|
+
await mcpClient.connectMcpServers({ [target]: cfg });
|
|
123
|
+
} catch (error) {
|
|
124
|
+
const failures = Array.isArray(error?.failures)
|
|
125
|
+
? error.failures
|
|
126
|
+
: [{ name: target, msg: error?.message || String(error) }];
|
|
127
|
+
state.mcpFailures = [...(state.mcpFailures || []), ...failures];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function connectConfiguredMcp({ reset = false, only = null, enabled = true } = {}) {
|
|
132
|
+
// Scoped single-server toggle: non-superseding. It must NEVER cancel a
|
|
133
|
+
// pending full {reset} (cwd-change/boot). So do not bump the generation;
|
|
134
|
+
// just wait for any in-flight run, then bail if a newer full reset has
|
|
135
|
+
// been requested in the meantime. Registering as in-flight makes a later
|
|
136
|
+
// reset serialize behind us instead of interleaving disconnect/connect.
|
|
137
|
+
if (only) {
|
|
138
|
+
// Atomically capture the current generation AND the prior in-flight
|
|
139
|
+
// promise in the same synchronous step, then chain our op onto it. No
|
|
140
|
+
// await sits between the capture and the `state.mcpConnectInFlight = run`
|
|
141
|
+
// assignment, so concurrent {only} calls queue FIFO instead of resuming
|
|
142
|
+
// together and clobbering the in-flight slot. We never bump the
|
|
143
|
+
// generation; a {reset} does, so any {only} queued behind a reset sees
|
|
144
|
+
// the newer generation when its turn comes and bails.
|
|
145
|
+
const gen = state.mcpConnectGeneration;
|
|
146
|
+
const prev = state.mcpConnectInFlight;
|
|
147
|
+
const run = (async () => {
|
|
148
|
+
if (prev) { try { await prev; } catch { /* prior run's failures already captured */ } }
|
|
149
|
+
if (gen !== state.mcpConnectGeneration) return;
|
|
150
|
+
await applyMcpServerConnection(only, enabled);
|
|
151
|
+
})();
|
|
152
|
+
state.mcpConnectInFlight = run;
|
|
153
|
+
try {
|
|
154
|
+
await run;
|
|
155
|
+
} finally {
|
|
156
|
+
if (state.mcpConnectInFlight === run) state.mcpConnectInFlight = null;
|
|
157
|
+
}
|
|
158
|
+
return mcpStatus();
|
|
159
|
+
}
|
|
98
160
|
// Serialize reconnects: boot connect, cwd-change reset, and rapid cwd
|
|
99
161
|
// switches must never interleave their disconnect/connect phases, or an
|
|
100
162
|
// older run finishing after a newer reset could re-add stale servers into
|
|
@@ -188,11 +250,41 @@ export function createMcpGlue({
|
|
|
188
250
|
return { name, config };
|
|
189
251
|
}
|
|
190
252
|
|
|
253
|
+
// First-turn gate: await the in-flight INITIAL connect, bounded by the global
|
|
254
|
+
// startup budget. Boot/UI never call this; only the first ask awaits so
|
|
255
|
+
// servers that connect within the budget land in THIS request's tool surface.
|
|
256
|
+
// Resolves (never rejects): a server still connecting after the budget flows
|
|
257
|
+
// through the existing late-tool deferred announcement path unchanged.
|
|
258
|
+
async function awaitInitialMcpConnect() {
|
|
259
|
+
const inFlight = state.mcpConnectInFlight;
|
|
260
|
+
if (!inFlight) return;
|
|
261
|
+
let budgetMs = 10000;
|
|
262
|
+
try {
|
|
263
|
+
const resolved = mcpClient.resolveMcpStartupTimeoutMs?.({});
|
|
264
|
+
if (Number.isFinite(resolved)) budgetMs = resolved;
|
|
265
|
+
} catch { /* fall back to default budget */ }
|
|
266
|
+
// Swallow the in-flight rejection: failures are already captured in
|
|
267
|
+
// state.mcpFailures, and this gate must never reject the turn.
|
|
268
|
+
const settled = Promise.resolve(inFlight).catch(() => {});
|
|
269
|
+
// Budget disabled (0/off) = no per-server startup timeout, so the connect
|
|
270
|
+
// promise may never settle; never gate the turn on it — fall back to the
|
|
271
|
+
// legacy fire-and-forget behavior (late servers use the deferred path).
|
|
272
|
+
if (!(budgetMs > 0)) return;
|
|
273
|
+
let timer = null;
|
|
274
|
+
const budget = new Promise((resolveBudget) => { timer = setTimeout(resolveBudget, budgetMs); });
|
|
275
|
+
try {
|
|
276
|
+
await Promise.race([settled, budget]);
|
|
277
|
+
} finally {
|
|
278
|
+
if (timer) clearTimeout(timer);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
191
282
|
return {
|
|
192
283
|
mcpTransportLabel,
|
|
193
284
|
resolveEffectiveMcpServers,
|
|
194
285
|
mcpStatus,
|
|
195
286
|
connectConfiguredMcp,
|
|
287
|
+
awaitInitialMcpConnect,
|
|
196
288
|
normalizeMcpServerInput,
|
|
197
289
|
};
|
|
198
290
|
}
|
|
@@ -29,8 +29,56 @@ export function createResourceApi(deps) {
|
|
|
29
29
|
saveConfigAndAdopt, connectConfiguredMcp, invalidatePreSessionToolSurface,
|
|
30
30
|
recreateCurrentSessionIfReady, normalizeMcpServerInput, mcpStatus,
|
|
31
31
|
skillsStatus, skillContent, addProjectSkill, pluginsStatus, getMemoryModule,
|
|
32
|
-
reloadFullConfig,
|
|
32
|
+
reloadFullConfig, getActiveTurnCount,
|
|
33
33
|
} = deps;
|
|
34
|
+
// Per-server MCP toggle serialization. The synchronous config adopt in
|
|
35
|
+
// setMcpServerEnabled has already made the intent durable; the heavy
|
|
36
|
+
// connectConfiguredMcp (process spawn/handshake) + session close/recreate
|
|
37
|
+
// run here off the toggle's critical path. Rapid re-toggles on one server
|
|
38
|
+
// update `desired` and ride the in-flight chain so it converges to the last
|
|
39
|
+
// requested state, closing/recreating the session only once at the end.
|
|
40
|
+
const mcpToggleChains = new Map(); // name -> { desired, running }
|
|
41
|
+
// Close/recreate the live session only at a turn boundary: a background
|
|
42
|
+
// toggle must never abort an in-flight turn. If a turn is active, poll until
|
|
43
|
+
// it ends, then swap the session so it picks up the new tool surface.
|
|
44
|
+
function applyMcpToggleRecreate(serverName) {
|
|
45
|
+
if (typeof getActiveTurnCount === 'function' && getActiveTurnCount() > 0) {
|
|
46
|
+
const timer = setTimeout(() => applyMcpToggleRecreate(serverName), 250);
|
|
47
|
+
timer.unref?.();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
invalidatePreSessionToolSurface();
|
|
51
|
+
const session = getSession();
|
|
52
|
+
if (session?.id) mgr.closeSession(session.id, 'cli-mcp-toggle');
|
|
53
|
+
// Recreate off the critical path (see removeMcpServer notes): the next
|
|
54
|
+
// on-demand createCurrentSession dedupes onto this in-flight create.
|
|
55
|
+
void recreateCurrentSessionIfReady().catch((err) => {
|
|
56
|
+
process.stderr.write(`[mcp] session recreate after toggle failed: ${err?.message || err}\n`);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function scheduleMcpToggle(serverName, enabled) {
|
|
60
|
+
const chain = mcpToggleChains.get(serverName) || { desired: enabled, running: null };
|
|
61
|
+
chain.desired = enabled;
|
|
62
|
+
mcpToggleChains.set(serverName, chain);
|
|
63
|
+
if (!chain.running) {
|
|
64
|
+
chain.running = (async () => {
|
|
65
|
+
let status;
|
|
66
|
+
try {
|
|
67
|
+
let want;
|
|
68
|
+
do {
|
|
69
|
+
want = chain.desired;
|
|
70
|
+
status = await connectConfiguredMcp({ only: serverName, enabled: want });
|
|
71
|
+
} while (chain.desired !== want);
|
|
72
|
+
// Turn-safe: defers until any active turn ends (never aborts it).
|
|
73
|
+
applyMcpToggleRecreate(serverName);
|
|
74
|
+
} finally {
|
|
75
|
+
chain.running = null;
|
|
76
|
+
}
|
|
77
|
+
return status;
|
|
78
|
+
})();
|
|
79
|
+
}
|
|
80
|
+
return chain.running;
|
|
81
|
+
}
|
|
34
82
|
return {
|
|
35
83
|
mcpStatus() {
|
|
36
84
|
return mcpStatus();
|
|
@@ -78,7 +126,7 @@ export function createResourceApi(deps) {
|
|
|
78
126
|
await recreateCurrentSessionIfReady();
|
|
79
127
|
return status;
|
|
80
128
|
},
|
|
81
|
-
|
|
129
|
+
setMcpServerEnabled(name, enabled) {
|
|
82
130
|
const serverName = clean(name);
|
|
83
131
|
if (!serverName) throw new Error('MCP server name is required');
|
|
84
132
|
const nextConfig = { ...getConfig() };
|
|
@@ -88,14 +136,20 @@ export function createResourceApi(deps) {
|
|
|
88
136
|
if (!Object.prototype.hasOwnProperty.call(current, serverName)) {
|
|
89
137
|
throw new Error(`MCP server not configured: ${serverName}`);
|
|
90
138
|
}
|
|
139
|
+
// A project-local `.mcp.json` entry WINS over config for this name, so a
|
|
140
|
+
// config enable/disable flag would persist but never change live state.
|
|
141
|
+
// Surface that to the caller instead of reporting a silent success.
|
|
142
|
+
const shadowRow = mcpStatus().servers.find((s) => s.name === serverName);
|
|
143
|
+
if (shadowRow && shadowRow.source === 'project') {
|
|
144
|
+
throw new Error(`'${serverName}' is defined by project .mcp.json — config toggle has no effect`);
|
|
145
|
+
}
|
|
146
|
+
// Adopt + persist config synchronously (fast) so intent is durable, then
|
|
147
|
+
// hand the heavy connect/close/recreate to the per-server background
|
|
148
|
+
// chain. Return that chain's promise so callers can settle the picker on
|
|
149
|
+
// completion, but the store no longer blocks on it.
|
|
91
150
|
current[serverName] = { ...(current[serverName] || {}), enabled: enabled !== false };
|
|
92
151
|
saveConfigAndAdopt({ ...nextConfig, mcpServers: current });
|
|
93
|
-
|
|
94
|
-
invalidatePreSessionToolSurface();
|
|
95
|
-
const session = getSession();
|
|
96
|
-
if (session?.id) mgr.closeSession(session.id, 'cli-mcp-toggle');
|
|
97
|
-
await recreateCurrentSessionIfReady();
|
|
98
|
-
return status;
|
|
152
|
+
return scheduleMcpToggle(serverName, enabled !== false);
|
|
99
153
|
},
|
|
100
154
|
skillsStatus() {
|
|
101
155
|
return skillsStatus();
|
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
saveWebhook,
|
|
68
68
|
saveWebhookAuthtoken,
|
|
69
69
|
setBackend,
|
|
70
|
+
setBackendAsync,
|
|
70
71
|
setScheduleEnabled,
|
|
71
72
|
setWebhookEnabled,
|
|
72
73
|
setWebhookConfig,
|
|
@@ -333,6 +334,11 @@ export async function createMixdogSessionRuntime({
|
|
|
333
334
|
// session.id + cwd inside ask(); only active while remoteEnabled.
|
|
334
335
|
let _transcriptWriter = null;
|
|
335
336
|
let _twKey = '';
|
|
337
|
+
// One-shot: an 'acquired' verdict (or other rebind trigger) landed before a
|
|
338
|
+
// session/writer existed, so the rebind push could not fire. Set true when a
|
|
339
|
+
// push is deferred; the next session-create / turn-start flushes it exactly
|
|
340
|
+
// once so the daemon forwarder always ends bound to THIS session's transcript.
|
|
341
|
+
let _pendingRebind = false;
|
|
336
342
|
// Last assistant text handed to the transcript writer (via onAssistantText),
|
|
337
343
|
// so the post-turn final-content append can skip an exact duplicate.
|
|
338
344
|
let _lastAppendedAssistant = '';
|
|
@@ -599,6 +605,7 @@ export async function createMixdogSessionRuntime({
|
|
|
599
605
|
resolveEffectiveMcpServers,
|
|
600
606
|
mcpStatus,
|
|
601
607
|
connectConfiguredMcp,
|
|
608
|
+
awaitInitialMcpConnect,
|
|
602
609
|
normalizeMcpServerInput,
|
|
603
610
|
} = createMcpGlue({
|
|
604
611
|
mcpClient,
|
|
@@ -683,7 +690,19 @@ export async function createMixdogSessionRuntime({
|
|
|
683
690
|
void (async () => {
|
|
684
691
|
await checkForUpdateInternal({ force: true });
|
|
685
692
|
if (autoUpdateEnabled() && updateCheckState.updateAvailable) {
|
|
686
|
-
await runUpdateNowInternal();
|
|
693
|
+
const result = await runUpdateNowInternal();
|
|
694
|
+
// Surface the boot auto-update outcome as a UI-only notice (TUI maps
|
|
695
|
+
// meta.kind 'update-notice' to a transcript notice, never a
|
|
696
|
+
// model-visible message). Silent before: installed-but-needs-restart
|
|
697
|
+
// and failed installs were invisible unless the user opened the
|
|
698
|
+
// maintenance picker.
|
|
699
|
+
if (result?.phase === 'installed') {
|
|
700
|
+
const v = result.version && result.version !== 'unknown'
|
|
701
|
+
? `v${result.version}` : (updateCheckState.latestVersion ? `v${updateCheckState.latestVersion}` : 'latest');
|
|
702
|
+
emitRuntimeNotification(`mixdog ${v} installed — restart to apply.`, { kind: 'update-notice', tone: 'info' });
|
|
703
|
+
} else if (result?.phase === 'failed') {
|
|
704
|
+
emitRuntimeNotification(`mixdog auto-update failed: ${result.error || 'unknown error'}`, { kind: 'update-notice', tone: 'warn' });
|
|
705
|
+
}
|
|
687
706
|
}
|
|
688
707
|
})().catch(() => {});
|
|
689
708
|
}, 0);
|
|
@@ -903,6 +922,11 @@ export async function createMixdogSessionRuntime({
|
|
|
903
922
|
if (msg?.params?.state === 'acquired' && !remoteEnabled) {
|
|
904
923
|
remoteEnabled = true;
|
|
905
924
|
ensureRemoteTranscriptWriter();
|
|
925
|
+
// Auto-acquire: the worker restored yesterday's transcript from
|
|
926
|
+
// persisted status and we just created the CURRENT writer. Push the
|
|
927
|
+
// repoint now instead of waiting for the next inbound parent-chain
|
|
928
|
+
// steal, so outbound forwarding never tails a stale transcript.
|
|
929
|
+
pushTranscriptRebind();
|
|
906
930
|
emitRemoteStateChange(true, 'acquired');
|
|
907
931
|
}
|
|
908
932
|
return;
|
|
@@ -1001,8 +1025,8 @@ export async function createMixdogSessionRuntime({
|
|
|
1001
1025
|
if (!codeGraphMod?.executeCodeGraphTool) throw new Error('code_graph runtime is not available');
|
|
1002
1026
|
return await codeGraphMod.executeCodeGraphTool(name, args || {}, args?.cwd || callerCwd);
|
|
1003
1027
|
}
|
|
1004
|
-
if (name === 'tool_search') {
|
|
1005
|
-
return renderToolSearch(args, activeToolSurface(), mode);
|
|
1028
|
+
if (name === 'tool_search' || name === 'load_tool') {
|
|
1029
|
+
return renderToolSearch(args, activeToolSurface(), mode, { mcpStatus });
|
|
1006
1030
|
}
|
|
1007
1031
|
if (name === 'explore') {
|
|
1008
1032
|
const callerSessionId = callerCtx?.callerSessionId || session?.id || null;
|
|
@@ -1107,6 +1131,8 @@ export async function createMixdogSessionRuntime({
|
|
|
1107
1131
|
flushConfigSave,
|
|
1108
1132
|
flushBackendSave,
|
|
1109
1133
|
scheduleBackendSave,
|
|
1134
|
+
scheduleSkillsSave,
|
|
1135
|
+
flushSkillsSave,
|
|
1110
1136
|
flushOutputStyleSave,
|
|
1111
1137
|
scheduleOutputStyleSave,
|
|
1112
1138
|
reloadFullConfig,
|
|
@@ -1124,6 +1150,7 @@ export async function createMixdogSessionRuntime({
|
|
|
1124
1150
|
cfgMod,
|
|
1125
1151
|
sharedCfgMod,
|
|
1126
1152
|
setBackend,
|
|
1153
|
+
setBackendAsync,
|
|
1127
1154
|
setConfiguredShell,
|
|
1128
1155
|
normalizeSystemShellConfig,
|
|
1129
1156
|
normalizeSearchRouteConfig,
|
|
@@ -1373,7 +1400,28 @@ export async function createMixdogSessionRuntime({
|
|
|
1373
1400
|
session = mgr.createSession(sessionOpts);
|
|
1374
1401
|
sessionNeedsCwdRefresh = false;
|
|
1375
1402
|
attachSessionHooks(session, { hooks, hookCommonPayload, getCwd: () => currentCwd });
|
|
1376
|
-
|
|
1403
|
+
// Every-create MCP fold (NO blocking): seed the INITIAL deferred surface +
|
|
1404
|
+
// BP1 manifest from whatever MCP servers are ALREADY connected at create
|
|
1405
|
+
// time. There is no await — a boot connect still mid-handshake is caught on
|
|
1406
|
+
// the first user turn by refreshInitialDeferredMcpSurface (session-turn-api),
|
|
1407
|
+
// which re-folds the live registry into the initial manifest before the
|
|
1408
|
+
// prompt renders. This fold keeps recreate paths (cwd change with MCP
|
|
1409
|
+
// already connected) seeding their manifest instead of re-announcing late.
|
|
1410
|
+
let connectedMcpTools = [];
|
|
1411
|
+
try { connectedMcpTools = mcpClient.getMcpTools?.() || []; }
|
|
1412
|
+
catch { connectedMcpTools = []; }
|
|
1413
|
+
applyDeferredToolSurface(
|
|
1414
|
+
session,
|
|
1415
|
+
deferredSurfaceModeForLead(mode),
|
|
1416
|
+
connectedMcpTools.length ? [...standaloneTools, ...connectedMcpTools] : standaloneTools,
|
|
1417
|
+
{ provider: route.provider },
|
|
1418
|
+
);
|
|
1419
|
+
// Session-local one-shot: mark this FRESH session eligible for the
|
|
1420
|
+
// first-turn deferred-surface refresh (session-turn-api). A resumed
|
|
1421
|
+
// session (prior transcript) is NEVER marked, so its already-baked BP1 is
|
|
1422
|
+
// never rebuilt or re-announced — the gate is per-session, not the
|
|
1423
|
+
// process-wide firstTurnCompleted.
|
|
1424
|
+
session.deferredInitialRefreshPending = !/resume/i.test(String(reason || ''));
|
|
1377
1425
|
applyPreSessionToolSelection();
|
|
1378
1426
|
writeStatuslineRoute(statusRoutes, session, route);
|
|
1379
1427
|
hooks.emit('session:create', { sessionId: session.id, provider: route.provider, model: route.model, toolMode: mode, cwd: currentCwd });
|
|
@@ -1400,6 +1448,9 @@ export async function createMixdogSessionRuntime({
|
|
|
1400
1448
|
tools: Array.isArray(session.tools) ? session.tools.length : 0,
|
|
1401
1449
|
catalog: Array.isArray(session.deferredToolCatalog) ? session.deferredToolCatalog.length : 0,
|
|
1402
1450
|
});
|
|
1451
|
+
// A rebind push may have been deferred (e.g. 'acquired' landed before this
|
|
1452
|
+
// session existed). The writer is now bindable — flush it exactly once.
|
|
1453
|
+
flushPendingTranscriptRebind();
|
|
1403
1454
|
return session;
|
|
1404
1455
|
})();
|
|
1405
1456
|
|
|
@@ -1523,6 +1574,63 @@ export async function createMixdogSessionRuntime({
|
|
|
1523
1574
|
return _transcriptWriter != null;
|
|
1524
1575
|
}
|
|
1525
1576
|
|
|
1577
|
+
// Push the CURRENT transcript path to the channel worker so outbound
|
|
1578
|
+
// forwarding repoints immediately at the moments the binding can go stale
|
|
1579
|
+
// (auto-acquire, newSession/resume, clear) instead of waiting for the next
|
|
1580
|
+
// inbound parent-chain steal. Best-effort: ensures the writer, then fires
|
|
1581
|
+
// the dedicated idempotent worker op — a missing/not-ready worker or a bind
|
|
1582
|
+
// failure must never throw into the lead paths that call this.
|
|
1583
|
+
function pushTranscriptRebind() {
|
|
1584
|
+
if (!remoteEnabled) return;
|
|
1585
|
+
// Writer not bindable yet (e.g. 'acquired' before the session exists in
|
|
1586
|
+
// lazy mode): defer instead of silently dropping the push. flushPending-
|
|
1587
|
+
// TranscriptRebind() re-fires this exactly once when the writer is ready.
|
|
1588
|
+
if (!ensureRemoteTranscriptWriter()) { _pendingRebind = true; return; }
|
|
1589
|
+
const transcriptPath = _transcriptWriter?.transcriptPath;
|
|
1590
|
+
if (!transcriptPath || !channelsEnabled()) { _pendingRebind = true; return; }
|
|
1591
|
+
_pendingRebind = false;
|
|
1592
|
+
executeTranscriptRebind(transcriptPath, 1);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// Fire the idempotent worker op with bounded retry. A rejected/throwing
|
|
1596
|
+
// channels.execute retries a few times with short backoff; the final failure
|
|
1597
|
+
// surfaces one stderr line (not only the env-gated bootProfile) so a lost
|
|
1598
|
+
// rebind is diagnosable by default. Best-effort throughout — never throws
|
|
1599
|
+
// into the lead paths that call pushTranscriptRebind().
|
|
1600
|
+
function executeTranscriptRebind(transcriptPath, attempt) {
|
|
1601
|
+
const maxAttempts = 3;
|
|
1602
|
+
const onError = (error) => {
|
|
1603
|
+
const detail = error?.message || String(error);
|
|
1604
|
+
bootProfile('channels:rebind-push-failed', { attempt, error: detail });
|
|
1605
|
+
if (attempt < maxAttempts && remoteEnabled && !closeRequested) {
|
|
1606
|
+
const timer = setTimeout(() => {
|
|
1607
|
+
// Abort the retry chain silently if remote was dropped or the writer
|
|
1608
|
+
// moved on (supersede→re-acquire, newSession/clear): re-firing the
|
|
1609
|
+
// captured path would rebind forwarding back to a stale transcript.
|
|
1610
|
+
if (!remoteEnabled || !channelsEnabled()) return;
|
|
1611
|
+
if (_transcriptWriter?.transcriptPath !== transcriptPath) return;
|
|
1612
|
+
executeTranscriptRebind(transcriptPath, attempt + 1);
|
|
1613
|
+
}, 150 * attempt);
|
|
1614
|
+
timer.unref?.();
|
|
1615
|
+
} else {
|
|
1616
|
+
process.stderr.write(`mixdog: channels: rebind_current_transcript failed after ${attempt} attempt(s): ${detail}\n`);
|
|
1617
|
+
}
|
|
1618
|
+
};
|
|
1619
|
+
try {
|
|
1620
|
+
void channels.execute('rebind_current_transcript', { transcriptPath }).catch(onError);
|
|
1621
|
+
} catch (error) {
|
|
1622
|
+
onError(error);
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// Re-fire a deferred rebind exactly once, after a session/writer becomes
|
|
1627
|
+
// available (session create or turn start). No-op unless a push was deferred,
|
|
1628
|
+
// so no unconditional rebind fires per turn for already-bound sessions.
|
|
1629
|
+
function flushPendingTranscriptRebind() {
|
|
1630
|
+
if (!_pendingRebind || !remoteEnabled) return;
|
|
1631
|
+
pushTranscriptRebind();
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1526
1634
|
// Remote (Discord channel) mode is opt-in per session. Only a session that
|
|
1527
1635
|
// explicitly enables remote — via `mixdog --remote` or the runtime toggle —
|
|
1528
1636
|
// boots the channel worker and contends for channel ownership.
|
|
@@ -1783,6 +1891,7 @@ export async function createMixdogSessionRuntime({
|
|
|
1783
1891
|
adoptConfig,
|
|
1784
1892
|
saveConfigAndAdopt,
|
|
1785
1893
|
scheduleBackendSave,
|
|
1894
|
+
scheduleSkillsSave,
|
|
1786
1895
|
cfgMod,
|
|
1787
1896
|
hasOwn,
|
|
1788
1897
|
normalizeAutoClearConfig,
|
|
@@ -1859,6 +1968,7 @@ export async function createMixdogSessionRuntime({
|
|
|
1859
1968
|
statusRoutes,
|
|
1860
1969
|
channels,
|
|
1861
1970
|
agentTool,
|
|
1971
|
+
pushTranscriptRebind,
|
|
1862
1972
|
mcpClient,
|
|
1863
1973
|
warmupTimers,
|
|
1864
1974
|
prewarmTimers,
|
|
@@ -1896,6 +2006,7 @@ export async function createMixdogSessionRuntime({
|
|
|
1896
2006
|
pluginsStatus,
|
|
1897
2007
|
getMemoryModule,
|
|
1898
2008
|
reloadFullConfig,
|
|
2009
|
+
getActiveTurnCount: () => activeTurnCount,
|
|
1899
2010
|
});
|
|
1900
2011
|
const modelRouteApi = createModelRouteApi({
|
|
1901
2012
|
getConfig: () => config,
|
|
@@ -1978,6 +2089,8 @@ export async function createMixdogSessionRuntime({
|
|
|
1978
2089
|
refreshSessionForCwdIfNeeded,
|
|
1979
2090
|
createCurrentSession,
|
|
1980
2091
|
ensureRemoteTranscriptWriter,
|
|
2092
|
+
pushTranscriptRebind,
|
|
2093
|
+
flushPendingTranscriptRebind,
|
|
1981
2094
|
channelsEnabled,
|
|
1982
2095
|
invokeChannelStart,
|
|
1983
2096
|
channels,
|
|
@@ -1997,6 +2110,7 @@ export async function createMixdogSessionRuntime({
|
|
|
1997
2110
|
resolveCwdPath,
|
|
1998
2111
|
agentStatusState,
|
|
1999
2112
|
notificationListeners,
|
|
2113
|
+
awaitInitialMcpConnect,
|
|
2000
2114
|
});
|
|
2001
2115
|
|
|
2002
2116
|
return {
|
|
@@ -32,6 +32,7 @@ export function isSessionPreviewNoise(text) {
|
|
|
32
32
|
return !value
|
|
33
33
|
|| value.startsWith('<system-reminder>')
|
|
34
34
|
|| value.startsWith('</system-reminder>')
|
|
35
|
+
|| isLateToolAnnouncement(value)
|
|
35
36
|
|| /^#\s*permission\b/i.test(value)
|
|
36
37
|
|| /^permission:\s*/i.test(value)
|
|
37
38
|
|| /^cwd:\s*/i.test(value);
|
|
@@ -45,6 +46,46 @@ export function cleanSessionPreview(text) {
|
|
|
45
46
|
.slice(0, 160);
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
// Stable sentinel carried in every late-tool (deferred MCP) announcement
|
|
50
|
+
// reminder — must stay byte-identical to LATE_TOOL_REMINDER_SENTINEL in
|
|
51
|
+
// src/session-runtime/tool-catalog.mjs. Detection keys on this exact string
|
|
52
|
+
// (never fuzzy matching) so the raw announcement block can be hidden from
|
|
53
|
+
// user-facing surfaces while the model context stays untouched.
|
|
54
|
+
export const LATE_TOOL_ANNOUNCEMENT_SENTINEL = 'connected after this session started';
|
|
55
|
+
|
|
56
|
+
export function isLateToolAnnouncement(text) {
|
|
57
|
+
const value = String(text || '');
|
|
58
|
+
return value.includes(LATE_TOOL_ANNOUNCEMENT_SENTINEL)
|
|
59
|
+
&& /<available-deferred-tools>/i.test(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Derive a muted one-line notice from a late-tool announcement body, e.g.
|
|
63
|
+
// "MCP tools available: UnityMCP (12 tools)". MCP tool entries in the manifest
|
|
64
|
+
// are `- mcp__<server>__<tool>: ...` lines; the server name is the segment
|
|
65
|
+
// after the `mcp__` prefix. Returns '' for non-announcement text.
|
|
66
|
+
export function summarizeLateToolAnnouncement(text) {
|
|
67
|
+
const value = String(text || '');
|
|
68
|
+
if (!isLateToolAnnouncement(value)) return '';
|
|
69
|
+
const block = value.match(/<available-deferred-tools>([\s\S]*?)<\/available-deferred-tools>/i);
|
|
70
|
+
const body = block ? block[1] : value;
|
|
71
|
+
const names = [];
|
|
72
|
+
const lineRe = /^\s*-\s+([A-Za-z0-9_.:-]+)/gm;
|
|
73
|
+
let m;
|
|
74
|
+
while ((m = lineRe.exec(body))) names.push(m[1]);
|
|
75
|
+
const servers = new Set();
|
|
76
|
+
for (const name of names) {
|
|
77
|
+
const seg = name.startsWith('mcp__') ? name.slice(5) : name;
|
|
78
|
+
const server = seg.split('__')[0];
|
|
79
|
+
if (server) servers.add(server);
|
|
80
|
+
}
|
|
81
|
+
const count = names.length;
|
|
82
|
+
const label = servers.size === 1
|
|
83
|
+
? [...servers][0]
|
|
84
|
+
: (servers.size ? `${servers.size} MCP servers` : 'MCP');
|
|
85
|
+
if (!count) return `MCP tools available: ${label}`;
|
|
86
|
+
return `MCP tools available: ${label} (${count} ${count === 1 ? 'tool' : 'tools'})`;
|
|
87
|
+
}
|
|
88
|
+
|
|
48
89
|
export function clean(value) {
|
|
49
90
|
return String(value ?? '').trim();
|
|
50
91
|
}
|
|
@@ -6,7 +6,10 @@ import {
|
|
|
6
6
|
toolSearchMatches,
|
|
7
7
|
sortedNamesByMeasuredUsage,
|
|
8
8
|
selectDeferredTools,
|
|
9
|
+
reconcileDeferredMcpToolCatalog,
|
|
10
|
+
refreshInitialDeferredMcpSurface,
|
|
9
11
|
} from './tool-catalog.mjs';
|
|
12
|
+
import { getMcpTools } from '../runtime/agent/orchestrator/mcp/client.mjs';
|
|
10
13
|
|
|
11
14
|
// Turn execution (ask) + session-manage/tool-surface/agent surfaces. Extracted
|
|
12
15
|
// verbatim from the runtime API object; stateless helpers are imported directly
|
|
@@ -22,10 +25,12 @@ export function createSessionTurnApi(deps) {
|
|
|
22
25
|
getTranscriptWriter, getTwKey, getLastAppendedAssistant, setLastAppendedAssistant,
|
|
23
26
|
scheduleCodeGraphPrewarm, refreshSessionForCwdIfNeeded, createCurrentSession,
|
|
24
27
|
ensureRemoteTranscriptWriter, channelsEnabled, invokeChannelStart, channels,
|
|
28
|
+
pushTranscriptRebind, flushPendingTranscriptRebind,
|
|
25
29
|
hooks, hookCommonPayload, mgr, notifyFnForSession, bootProfile,
|
|
26
30
|
scheduleProviderWarmup, scheduleProviderModelWarmup, invalidateContextStatusCache,
|
|
27
31
|
agentTool, recreateCurrentSessionIfReady, invalidatePreSessionToolSurface,
|
|
28
32
|
activeToolSurface, applyResolvedCwd, resolveCwdPath, agentStatusState, notificationListeners,
|
|
33
|
+
awaitInitialMcpConnect,
|
|
29
34
|
} = deps;
|
|
30
35
|
return {
|
|
31
36
|
async ask(prompt, options = {}) {
|
|
@@ -46,6 +51,9 @@ export function createSessionTurnApi(deps) {
|
|
|
46
51
|
setLastAppendedAssistant('');
|
|
47
52
|
const prevKey = getTwKey();
|
|
48
53
|
ensureRemoteTranscriptWriter();
|
|
54
|
+
// Flush a rebind deferred before the session/writer existed ('acquired'
|
|
55
|
+
// in lazy mode). One-shot: no-op unless a push was actually deferred.
|
|
56
|
+
flushPendingTranscriptRebind?.();
|
|
49
57
|
if (getTwKey() && getTwKey() !== prevKey && channelsEnabled() && !envFlag('MIXDOG_DISABLE_CHANNEL_START')) {
|
|
50
58
|
void invokeChannelStart()
|
|
51
59
|
.then(() => {
|
|
@@ -56,6 +64,43 @@ export function createSessionTurnApi(deps) {
|
|
|
56
64
|
}
|
|
57
65
|
}
|
|
58
66
|
const session0 = getSession();
|
|
67
|
+
if (session0.deferredInitialRefreshPending) {
|
|
68
|
+
// FIRST TURN of a FRESH session (session-local gate, NOT the
|
|
69
|
+
// process-wide firstTurnCompleted): an MCP server may have finished its
|
|
70
|
+
// handshake BETWEEN session-create and this first send. Re-fold the
|
|
71
|
+
// LIVE registry into the INITIAL deferred surface + BP1
|
|
72
|
+
// <available-deferred-tools> manifest (sync, in-place, idempotent) and
|
|
73
|
+
// pre-mark those names announced, so they ship in the initial manifest
|
|
74
|
+
// instead of a late-tool <system-reminder>. One-shot: cleared before
|
|
75
|
+
// the fold so a throw still never re-runs it, and a resumed session
|
|
76
|
+
// (flag unset) skips straight to the late path below.
|
|
77
|
+
session0.deferredInitialRefreshPending = false;
|
|
78
|
+
// First-turn gate: give the in-flight INITIAL MCP connect a bounded
|
|
79
|
+
// chance to finish so servers that connect within the startup budget
|
|
80
|
+
// land in THIS request's tool surface. Bounded by the same budget —
|
|
81
|
+
// UI/boot never blocks here; only this first ask waits, and only once.
|
|
82
|
+
try { await awaitInitialMcpConnect?.(); }
|
|
83
|
+
catch { /* gate must never break the turn */ }
|
|
84
|
+
try { refreshInitialDeferredMcpSurface(session0, getMcpTools()); }
|
|
85
|
+
catch { /* first-turn MCP fold must never break the turn */ }
|
|
86
|
+
} else {
|
|
87
|
+
// AFTER FIRST TURN: fold in MCP tools whose servers finished their
|
|
88
|
+
// handshake after this session was created, and announce the newly
|
|
89
|
+
// available deferred tool names via ONE appended, persistent
|
|
90
|
+
// system-reminder (append-only — never rewrites BP1 or touches the
|
|
91
|
+
// active tool surface, so the prompt-cache prefix stays intact).
|
|
92
|
+
try {
|
|
93
|
+
reconcileDeferredMcpToolCatalog(session0, getMcpTools(), {
|
|
94
|
+
// Deliver the late-tool announcement through the pending-message
|
|
95
|
+
// queue so it rides inside the next real user turn as a persisted
|
|
96
|
+
// system-reminder (no synthetic user + '.' assistant pair).
|
|
97
|
+
enqueue: (text) => (typeof mgr.enqueuePendingMessage === 'function'
|
|
98
|
+
? mgr.enqueuePendingMessage(session0.id, text) > 0
|
|
99
|
+
: false),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch { /* MCP delta must never break the turn */ }
|
|
103
|
+
}
|
|
59
104
|
hooks.emit('turn:start', { sessionId: session0.id, prompt, cwd: getCurrentCwd() });
|
|
60
105
|
// UserPromptSubmit: a hook FAILURE must not block the turn, but blocked===true MUST throw.
|
|
61
106
|
let promptDispatch = null;
|
|
@@ -180,6 +225,11 @@ export function createSessionTurnApi(deps) {
|
|
|
180
225
|
try { agentTool.recoverWorkers?.({ clientHostPid: getSession()?.clientHostPid || process.pid }); } catch {}
|
|
181
226
|
}
|
|
182
227
|
invalidateContextStatusCache();
|
|
228
|
+
// clearSessionMessages swaps the live session object; the worker binding
|
|
229
|
+
// + persisted status still reference the pre-clear transcript. Push the
|
|
230
|
+
// current transcript so outbound forwarding repoints now, not on the next
|
|
231
|
+
// inbound steal (best-effort, remote-gated inside pushTranscriptRebind).
|
|
232
|
+
pushTranscriptRebind?.();
|
|
183
233
|
return true;
|
|
184
234
|
},
|
|
185
235
|
// session_manage tool handoff: the engine polls this at turn end and, if
|
|
@@ -16,6 +16,7 @@ export function createSettingsApi({
|
|
|
16
16
|
adoptConfig,
|
|
17
17
|
saveConfigAndAdopt,
|
|
18
18
|
scheduleBackendSave,
|
|
19
|
+
scheduleSkillsSave,
|
|
19
20
|
// normalizers / helpers
|
|
20
21
|
cfgMod,
|
|
21
22
|
hasOwn,
|
|
@@ -139,8 +140,14 @@ export function createSettingsApi({
|
|
|
139
140
|
const names = disabled instanceof Set
|
|
140
141
|
? [...disabled]
|
|
141
142
|
: (Array.isArray(disabled) ? disabled : []);
|
|
142
|
-
|
|
143
|
+
// Adopt in-memory synchronously so getDisabledSkills reflects the new
|
|
144
|
+
// value on the same tick (matches normalizeSkillsConfig({ disabled })
|
|
145
|
+
// used by patchSkillsDisabled). Defer the heavy in-lock file RMW through
|
|
146
|
+
// the skills debounce channel so the settings-toggle key handler does not
|
|
147
|
+
// hitch on a synchronous disk write.
|
|
148
|
+
const nextSkills = cfgMod.normalizeSkillsConfig({ disabled: names });
|
|
143
149
|
adoptConfig({ ...getConfig(), skills: nextSkills });
|
|
150
|
+
scheduleSkillsSave(names);
|
|
144
151
|
return this.getDisabledSkills();
|
|
145
152
|
},
|
|
146
153
|
setProfile(input = {}) {
|