mixdog 0.9.22 → 0.9.23
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/build-runtime-windows.ps1 +242 -242
- package/scripts/channel-daemon-smoke.mjs +165 -0
- package/scripts/channel-daemon-stub.mjs +69 -0
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/tool-smoke.mjs +30 -17
- package/src/rules/lead/02-channels.md +3 -3
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +187 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +40 -3
- 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/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/session-lifecycle.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +24 -11
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +126 -167
- package/src/runtime/channels/lib/owner-heartbeat.mjs +11 -60
- 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/seat-lock.mjs +196 -0
- package/src/runtime/channels/lib/session-discovery.mjs +2 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +24 -8
- package/src/runtime/channels/lib/worker-main.mjs +42 -11
- 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/shared/atomic-file.mjs +20 -4
- package/src/runtime/shared/tool-primitives.mjs +31 -1
- package/src/runtime/shared/tool-surface.mjs +23 -13
- package/src/session-runtime/config-lifecycle.mjs +48 -7
- package/src/session-runtime/lifecycle-api.mjs +9 -0
- package/src/session-runtime/mcp-glue.mjs +63 -1
- package/src/session-runtime/resource-api.mjs +62 -8
- package/src/session-runtime/runtime-core.mjs +32 -2
- package/src/session-runtime/session-text.mjs +41 -0
- package/src/session-runtime/session-turn-api.mjs +24 -0
- package/src/session-runtime/settings-api.mjs +8 -1
- package/src/session-runtime/tool-catalog.mjs +306 -38
- package/src/session-runtime/tool-defs.mjs +7 -7
- package/src/session-runtime/workflow.mjs +2 -1
- package/src/standalone/channel-daemon-client.mjs +224 -0
- package/src/standalone/channel-daemon-transport.mjs +351 -0
- package/src/standalone/channel-daemon.mjs +139 -0
- package/src/standalone/channel-worker.mjs +213 -4
- package/src/standalone/hook-bus.mjs +71 -3
- package/src/tui/App.jsx +105 -17
- 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/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +635 -306
- package/src/tui/engine/session-api.mjs +17 -7
- package/src/tui/engine/session-flow.mjs +6 -0
- package/src/tui/engine.mjs +8 -0
- package/src/tui/index.jsx +62 -18
- package/src/tui/paste-attachments.mjs +26 -0
- package/src/ui/statusline.mjs +45 -5
- 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
|
@@ -8,6 +8,9 @@ import {
|
|
|
8
8
|
stripToolPrefix,
|
|
9
9
|
parseMcpToolName,
|
|
10
10
|
isMcpToolName,
|
|
11
|
+
isExternalMcpToolName,
|
|
12
|
+
isSelfMcpToolName,
|
|
13
|
+
titleCaseMcpServer,
|
|
11
14
|
normalizeToolName,
|
|
12
15
|
truncateToolText,
|
|
13
16
|
truncateSingleLine,
|
|
@@ -68,7 +71,10 @@ export {
|
|
|
68
71
|
};
|
|
69
72
|
|
|
70
73
|
export function displayToolName(name, args = {}) {
|
|
71
|
-
if (
|
|
74
|
+
if (isExternalMcpToolName(name)) {
|
|
75
|
+
const mcp = parseMcpToolName(name);
|
|
76
|
+
return `MCP ${titleCaseMcpServer(mcp.server)}`;
|
|
77
|
+
}
|
|
72
78
|
const normalized = normalizeToolName(name);
|
|
73
79
|
switch (normalized) {
|
|
74
80
|
case 'read':
|
|
@@ -94,7 +100,7 @@ export function displayToolName(name, args = {}) {
|
|
|
94
100
|
case 'list':
|
|
95
101
|
case 'ls':
|
|
96
102
|
return 'Search';
|
|
97
|
-
case '
|
|
103
|
+
case 'load_tool':
|
|
98
104
|
return toolSearchDisplayLabel(parseToolArgs(args));
|
|
99
105
|
case 'search':
|
|
100
106
|
case 'search_query':
|
|
@@ -145,10 +151,10 @@ export function summarizeToolArgs(name, args, { max = DEFAULT_SUMMARY_MAX } = {}
|
|
|
145
151
|
const a = parseToolArgs(args);
|
|
146
152
|
if (!a || typeof a !== 'object') return '';
|
|
147
153
|
const normalized = normalizeToolName(name);
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
if (isExternalMcpToolName(name)) {
|
|
155
|
+
const mcp = parseMcpToolName(name);
|
|
150
156
|
return compactParts([
|
|
151
|
-
|
|
157
|
+
truncateToolText(mcp.tool, max),
|
|
152
158
|
truncateToolText(firstText(a.query, a.q, a.text, a.prompt, a.path, a.uri, a.name, a.id, a.action), Math.min(max, 80)),
|
|
153
159
|
]);
|
|
154
160
|
}
|
|
@@ -225,9 +231,9 @@ export function summarizeToolArgs(name, args, { max = DEFAULT_SUMMARY_MAX } = {}
|
|
|
225
231
|
return formatCountedUnit(collectionCount(a.query, a.prompt, a.task, a.goal), 'query', 'queries');
|
|
226
232
|
}
|
|
227
233
|
return truncateSingleLine(firstText(a.query, a.prompt, a.task, a.goal, a.path), Math.min(max, 80));
|
|
228
|
-
case '
|
|
234
|
+
case 'load_tool':
|
|
229
235
|
{
|
|
230
|
-
const selected = splitToolSearchSelection(a.select);
|
|
236
|
+
const selected = [...splitToolSearchSelection(a.names), ...splitToolSearchSelection(a.select)];
|
|
231
237
|
if (selected.length) return truncateToolText(selected.map(displayToolSearchTarget).join(', '), max);
|
|
232
238
|
return quoted(firstText(a.query, a.q, a.text), max);
|
|
233
239
|
}
|
|
@@ -325,7 +331,7 @@ const TOOL_CATEGORY = new Map([
|
|
|
325
331
|
['glob', 'Search'],
|
|
326
332
|
['list', 'Search'],
|
|
327
333
|
['ls', 'Search'],
|
|
328
|
-
['
|
|
334
|
+
['load_tool', 'Load'],
|
|
329
335
|
['search', 'Web Research'],
|
|
330
336
|
['web_search', 'Web Research'],
|
|
331
337
|
['search_query', 'Web Research'],
|
|
@@ -364,7 +370,7 @@ const TOOL_CATEGORY = new Map([
|
|
|
364
370
|
|
|
365
371
|
/** Return the aggregate category for a tool name + args. */
|
|
366
372
|
export function classifyToolCategory(name, args = {}) {
|
|
367
|
-
if (
|
|
373
|
+
if (isExternalMcpToolName(name)) return 'MCP';
|
|
368
374
|
const normalized = normalizeToolName(name);
|
|
369
375
|
if (normalized === 'code_graph') {
|
|
370
376
|
const mode = String(args.mode || args.action || '').toLowerCase();
|
|
@@ -431,8 +437,12 @@ export function toolWorkUnit(name, args = {}, category = '') {
|
|
|
431
437
|
const a = parseToolArgs(args);
|
|
432
438
|
const normalized = normalizeToolName(name);
|
|
433
439
|
const cat = category || classifyToolCategory(name, a);
|
|
434
|
-
if (
|
|
435
|
-
|
|
440
|
+
if (isExternalMcpToolName(name)) {
|
|
441
|
+
const mcp = parseMcpToolName(name);
|
|
442
|
+
return unitDescriptor('MCP', {
|
|
443
|
+
count: queryCount(a, 'query', 'q', 'text', 'prompt', 'path', 'uri', 'name', 'id', 'action') || 1,
|
|
444
|
+
noun: `${titleCaseMcpServer(mcp.server)} tool`,
|
|
445
|
+
});
|
|
436
446
|
}
|
|
437
447
|
switch (normalized) {
|
|
438
448
|
case 'read':
|
|
@@ -470,8 +480,8 @@ export function toolWorkUnit(name, args = {}, category = '') {
|
|
|
470
480
|
case 'list':
|
|
471
481
|
case 'ls':
|
|
472
482
|
return unitDescriptor('Search', { count: queryCount(a, 'path', 'paths', 'dir', 'dirs', 'cwd') || 1, active: 'Listing', done: 'Listed', noun: 'directory', pluralNoun: 'directories' });
|
|
473
|
-
case '
|
|
474
|
-
const selected = splitToolSearchSelection(a.select);
|
|
483
|
+
case 'load_tool': {
|
|
484
|
+
const selected = [...splitToolSearchSelection(a.names), ...splitToolSearchSelection(a.select)];
|
|
475
485
|
if (selected.length) return unitDescriptor('Load', { count: selected.length, noun: 'tool' });
|
|
476
486
|
return unitDescriptor('Load', { count: queryCount(a, 'query', 'q', 'text') || 1, noun: 'query', pluralNoun: 'queries' });
|
|
477
487
|
}
|
|
@@ -98,14 +98,24 @@ export function createConfigLifecycle({
|
|
|
98
98
|
clearTimeout(configSaveTimer);
|
|
99
99
|
configSaveTimer = null;
|
|
100
100
|
}
|
|
101
|
-
if (pendingConfigToSave
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
101
|
+
if (pendingConfigToSave !== null) {
|
|
102
|
+
const snapshot = pendingConfigToSave;
|
|
103
|
+
pendingConfigToSave = null;
|
|
104
|
+
try {
|
|
105
|
+
cfgMod.saveConfig(snapshot);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
process.stderr.write(`[config] debounced saveConfig failed: ${err?.message || err}\n`);
|
|
108
|
+
}
|
|
108
109
|
}
|
|
110
|
+
// Config-save flush points (reloadFullConfig re-read, runtime teardown) are
|
|
111
|
+
// exactly where a pending skills.disabled patch must also land, so piggyback
|
|
112
|
+
// the skills flush here — AFTER saveConfig: the whole-section snapshot may
|
|
113
|
+
// predate the latest skills toggle (stale snapshot.skills), so the in-lock
|
|
114
|
+
// skills patch must be the last writer. When the snapshot is newer than the
|
|
115
|
+
// toggle it already carries the same skills value, so the order is always
|
|
116
|
+
// safe. Runs even when no config snapshot is pending (early return above
|
|
117
|
+
// must not skip it).
|
|
118
|
+
flushSkillsSave();
|
|
109
119
|
}
|
|
110
120
|
|
|
111
121
|
function saveConfigAndAdopt(nextConfig, { hasSecrets = getConfigHasSecrets() } = {}) {
|
|
@@ -147,6 +157,35 @@ export function createConfigLifecycle({
|
|
|
147
157
|
backendSaveTimer.unref?.();
|
|
148
158
|
}
|
|
149
159
|
|
|
160
|
+
// --- debounced skills.disabled persist -------------------------------------
|
|
161
|
+
// In-memory skills state is adopted synchronously by setDisabledSkills; the
|
|
162
|
+
// heavy in-lock file RMW (cfgMod.patchSkillsDisabled) is deferred here so a
|
|
163
|
+
// burst of settings-toggle key presses collapses into one disk write.
|
|
164
|
+
let pendingSkillsNames = null;
|
|
165
|
+
let skillsSaveTimer = null;
|
|
166
|
+
|
|
167
|
+
function flushSkillsSave() {
|
|
168
|
+
if (skillsSaveTimer) {
|
|
169
|
+
clearTimeout(skillsSaveTimer);
|
|
170
|
+
skillsSaveTimer = null;
|
|
171
|
+
}
|
|
172
|
+
if (pendingSkillsNames === null) return;
|
|
173
|
+
const names = pendingSkillsNames;
|
|
174
|
+
pendingSkillsNames = null;
|
|
175
|
+
try {
|
|
176
|
+
cfgMod.patchSkillsDisabled(names);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
process.stderr.write(`[config] debounced patchSkillsDisabled failed: ${err?.message || err}\n`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function scheduleSkillsSave(names) {
|
|
183
|
+
pendingSkillsNames = names;
|
|
184
|
+
if (skillsSaveTimer) clearTimeout(skillsSaveTimer);
|
|
185
|
+
skillsSaveTimer = setTimeout(flushSkillsSave, CONFIG_SAVE_DEBOUNCE_MS);
|
|
186
|
+
skillsSaveTimer.unref?.();
|
|
187
|
+
}
|
|
188
|
+
|
|
150
189
|
// --- debounced top-level outputStyle persist -------------------------------
|
|
151
190
|
let pendingOutputStyleId = null;
|
|
152
191
|
let outputStyleSaveTimer = null;
|
|
@@ -221,6 +260,8 @@ export function createConfigLifecycle({
|
|
|
221
260
|
flushConfigSave,
|
|
222
261
|
flushBackendSave,
|
|
223
262
|
scheduleBackendSave,
|
|
263
|
+
flushSkillsSave,
|
|
264
|
+
scheduleSkillsSave,
|
|
224
265
|
flushOutputStyleSave,
|
|
225
266
|
scheduleOutputStyleSave,
|
|
226
267
|
// reload / ensure
|
|
@@ -28,6 +28,7 @@ export function createLifecycleApi(deps) {
|
|
|
28
28
|
createCurrentSession, refreshRouteEffort,
|
|
29
29
|
invalidateContextStatusCache, invalidatePreSessionToolSurface,
|
|
30
30
|
applyResolvedCwd, resolveRoute, applyDeferredToolSurface, standaloneTools,
|
|
31
|
+
pushTranscriptRebind,
|
|
31
32
|
} = deps;
|
|
32
33
|
return {
|
|
33
34
|
async close(reason = 'cli-exit', options = {}) {
|
|
@@ -56,6 +57,7 @@ export function createLifecycleApi(deps) {
|
|
|
56
57
|
try { flushConfigSave(); } catch {}
|
|
57
58
|
try { flushBackendSave(); } catch {}
|
|
58
59
|
try { flushOutputStyleSave(); } catch {}
|
|
60
|
+
try { hooks.flushRules?.(); } catch {}
|
|
59
61
|
if (prewarmTimers.channelStartTimer) {
|
|
60
62
|
clearTimeout(prewarmTimers.channelStartTimer);
|
|
61
63
|
prewarmTimers.channelStartTimer = null;
|
|
@@ -201,6 +203,10 @@ export function createLifecycleApi(deps) {
|
|
|
201
203
|
}
|
|
202
204
|
invalidateContextStatusCache();
|
|
203
205
|
await createCurrentSession();
|
|
206
|
+
// New session.id => the worker's binding (and persisted status) now point
|
|
207
|
+
// at the previous session's transcript. Push the current transcript so
|
|
208
|
+
// outbound forwarding repoints immediately (best-effort, remote-gated).
|
|
209
|
+
pushTranscriptRebind?.();
|
|
204
210
|
return getSession().id;
|
|
205
211
|
},
|
|
206
212
|
async resume(id) {
|
|
@@ -230,6 +236,9 @@ export function createLifecycleApi(deps) {
|
|
|
230
236
|
invalidateContextStatusCache();
|
|
231
237
|
setSessionNeedsCwdRefresh(false);
|
|
232
238
|
writeStatuslineRoute(statusRoutes, session, getRoute());
|
|
239
|
+
// Session swapped to the resumed one: repoint the worker to the current
|
|
240
|
+
// transcript instead of waiting for the next inbound steal.
|
|
241
|
+
pushTranscriptRebind?.();
|
|
233
242
|
return {
|
|
234
243
|
id: resumed.id,
|
|
235
244
|
messages: resumed.messages || [],
|
|
@@ -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
|
|
@@ -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();
|
|
@@ -903,6 +903,11 @@ export async function createMixdogSessionRuntime({
|
|
|
903
903
|
if (msg?.params?.state === 'acquired' && !remoteEnabled) {
|
|
904
904
|
remoteEnabled = true;
|
|
905
905
|
ensureRemoteTranscriptWriter();
|
|
906
|
+
// Auto-acquire: the worker restored yesterday's transcript from
|
|
907
|
+
// persisted status and we just created the CURRENT writer. Push the
|
|
908
|
+
// repoint now instead of waiting for the next inbound parent-chain
|
|
909
|
+
// steal, so outbound forwarding never tails a stale transcript.
|
|
910
|
+
pushTranscriptRebind();
|
|
906
911
|
emitRemoteStateChange(true, 'acquired');
|
|
907
912
|
}
|
|
908
913
|
return;
|
|
@@ -1001,8 +1006,8 @@ export async function createMixdogSessionRuntime({
|
|
|
1001
1006
|
if (!codeGraphMod?.executeCodeGraphTool) throw new Error('code_graph runtime is not available');
|
|
1002
1007
|
return await codeGraphMod.executeCodeGraphTool(name, args || {}, args?.cwd || callerCwd);
|
|
1003
1008
|
}
|
|
1004
|
-
if (name === 'tool_search') {
|
|
1005
|
-
return renderToolSearch(args, activeToolSurface(), mode);
|
|
1009
|
+
if (name === 'tool_search' || name === 'load_tool') {
|
|
1010
|
+
return renderToolSearch(args, activeToolSurface(), mode, { mcpStatus });
|
|
1006
1011
|
}
|
|
1007
1012
|
if (name === 'explore') {
|
|
1008
1013
|
const callerSessionId = callerCtx?.callerSessionId || session?.id || null;
|
|
@@ -1107,6 +1112,8 @@ export async function createMixdogSessionRuntime({
|
|
|
1107
1112
|
flushConfigSave,
|
|
1108
1113
|
flushBackendSave,
|
|
1109
1114
|
scheduleBackendSave,
|
|
1115
|
+
scheduleSkillsSave,
|
|
1116
|
+
flushSkillsSave,
|
|
1110
1117
|
flushOutputStyleSave,
|
|
1111
1118
|
scheduleOutputStyleSave,
|
|
1112
1119
|
reloadFullConfig,
|
|
@@ -1523,6 +1530,25 @@ export async function createMixdogSessionRuntime({
|
|
|
1523
1530
|
return _transcriptWriter != null;
|
|
1524
1531
|
}
|
|
1525
1532
|
|
|
1533
|
+
// Push the CURRENT transcript path to the channel worker so outbound
|
|
1534
|
+
// forwarding repoints immediately at the moments the binding can go stale
|
|
1535
|
+
// (auto-acquire, newSession/resume, clear) instead of waiting for the next
|
|
1536
|
+
// inbound parent-chain steal. Best-effort: ensures the writer, then fires
|
|
1537
|
+
// the dedicated idempotent worker op — a missing/not-ready worker or a bind
|
|
1538
|
+
// failure must never throw into the lead paths that call this.
|
|
1539
|
+
function pushTranscriptRebind() {
|
|
1540
|
+
if (!remoteEnabled) return;
|
|
1541
|
+
if (!ensureRemoteTranscriptWriter()) return;
|
|
1542
|
+
const transcriptPath = _transcriptWriter?.transcriptPath;
|
|
1543
|
+
if (!transcriptPath || !channelsEnabled()) return;
|
|
1544
|
+
try {
|
|
1545
|
+
void channels.execute('rebind_current_transcript', { transcriptPath })
|
|
1546
|
+
.catch((error) => bootProfile('channels:rebind-push-failed', { error: error?.message || String(error) }));
|
|
1547
|
+
} catch (error) {
|
|
1548
|
+
bootProfile('channels:rebind-push-failed', { error: error?.message || String(error) });
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1526
1552
|
// Remote (Discord channel) mode is opt-in per session. Only a session that
|
|
1527
1553
|
// explicitly enables remote — via `mixdog --remote` or the runtime toggle —
|
|
1528
1554
|
// boots the channel worker and contends for channel ownership.
|
|
@@ -1783,6 +1809,7 @@ export async function createMixdogSessionRuntime({
|
|
|
1783
1809
|
adoptConfig,
|
|
1784
1810
|
saveConfigAndAdopt,
|
|
1785
1811
|
scheduleBackendSave,
|
|
1812
|
+
scheduleSkillsSave,
|
|
1786
1813
|
cfgMod,
|
|
1787
1814
|
hasOwn,
|
|
1788
1815
|
normalizeAutoClearConfig,
|
|
@@ -1859,6 +1886,7 @@ export async function createMixdogSessionRuntime({
|
|
|
1859
1886
|
statusRoutes,
|
|
1860
1887
|
channels,
|
|
1861
1888
|
agentTool,
|
|
1889
|
+
pushTranscriptRebind,
|
|
1862
1890
|
mcpClient,
|
|
1863
1891
|
warmupTimers,
|
|
1864
1892
|
prewarmTimers,
|
|
@@ -1896,6 +1924,7 @@ export async function createMixdogSessionRuntime({
|
|
|
1896
1924
|
pluginsStatus,
|
|
1897
1925
|
getMemoryModule,
|
|
1898
1926
|
reloadFullConfig,
|
|
1927
|
+
getActiveTurnCount: () => activeTurnCount,
|
|
1899
1928
|
});
|
|
1900
1929
|
const modelRouteApi = createModelRouteApi({
|
|
1901
1930
|
getConfig: () => config,
|
|
@@ -1978,6 +2007,7 @@ export async function createMixdogSessionRuntime({
|
|
|
1978
2007
|
refreshSessionForCwdIfNeeded,
|
|
1979
2008
|
createCurrentSession,
|
|
1980
2009
|
ensureRemoteTranscriptWriter,
|
|
2010
|
+
pushTranscriptRebind,
|
|
1981
2011
|
channelsEnabled,
|
|
1982
2012
|
invokeChannelStart,
|
|
1983
2013
|
channels,
|
|
@@ -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,9 @@ import {
|
|
|
6
6
|
toolSearchMatches,
|
|
7
7
|
sortedNamesByMeasuredUsage,
|
|
8
8
|
selectDeferredTools,
|
|
9
|
+
reconcileDeferredMcpToolCatalog,
|
|
9
10
|
} from './tool-catalog.mjs';
|
|
11
|
+
import { getMcpTools } from '../runtime/agent/orchestrator/mcp/client.mjs';
|
|
10
12
|
|
|
11
13
|
// Turn execution (ask) + session-manage/tool-surface/agent surfaces. Extracted
|
|
12
14
|
// verbatim from the runtime API object; stateless helpers are imported directly
|
|
@@ -22,6 +24,7 @@ export function createSessionTurnApi(deps) {
|
|
|
22
24
|
getTranscriptWriter, getTwKey, getLastAppendedAssistant, setLastAppendedAssistant,
|
|
23
25
|
scheduleCodeGraphPrewarm, refreshSessionForCwdIfNeeded, createCurrentSession,
|
|
24
26
|
ensureRemoteTranscriptWriter, channelsEnabled, invokeChannelStart, channels,
|
|
27
|
+
pushTranscriptRebind,
|
|
25
28
|
hooks, hookCommonPayload, mgr, notifyFnForSession, bootProfile,
|
|
26
29
|
scheduleProviderWarmup, scheduleProviderModelWarmup, invalidateContextStatusCache,
|
|
27
30
|
agentTool, recreateCurrentSessionIfReady, invalidatePreSessionToolSurface,
|
|
@@ -56,6 +59,22 @@ export function createSessionTurnApi(deps) {
|
|
|
56
59
|
}
|
|
57
60
|
}
|
|
58
61
|
const session0 = getSession();
|
|
62
|
+
// Turn-boundary snapshot: fold in MCP tools whose servers finished their
|
|
63
|
+
// handshake after this session was created, and announce the newly
|
|
64
|
+
// available deferred tool names via ONE appended, persistent
|
|
65
|
+
// system-reminder (append-only — never rewrites BP1 or touches the
|
|
66
|
+
// active tool surface, so the prompt-cache prefix stays intact).
|
|
67
|
+
try {
|
|
68
|
+
reconcileDeferredMcpToolCatalog(session0, getMcpTools(), {
|
|
69
|
+
// Deliver the late-tool announcement through the pending-message
|
|
70
|
+
// queue so it rides inside the next real user turn as a persisted
|
|
71
|
+
// system-reminder (no synthetic user + '.' assistant pair).
|
|
72
|
+
enqueue: (text) => (typeof mgr.enqueuePendingMessage === 'function'
|
|
73
|
+
? mgr.enqueuePendingMessage(session0.id, text) > 0
|
|
74
|
+
: false),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch { /* MCP delta must never break the turn */ }
|
|
59
78
|
hooks.emit('turn:start', { sessionId: session0.id, prompt, cwd: getCurrentCwd() });
|
|
60
79
|
// UserPromptSubmit: a hook FAILURE must not block the turn, but blocked===true MUST throw.
|
|
61
80
|
let promptDispatch = null;
|
|
@@ -180,6 +199,11 @@ export function createSessionTurnApi(deps) {
|
|
|
180
199
|
try { agentTool.recoverWorkers?.({ clientHostPid: getSession()?.clientHostPid || process.pid }); } catch {}
|
|
181
200
|
}
|
|
182
201
|
invalidateContextStatusCache();
|
|
202
|
+
// clearSessionMessages swaps the live session object; the worker binding
|
|
203
|
+
// + persisted status still reference the pre-clear transcript. Push the
|
|
204
|
+
// current transcript so outbound forwarding repoints now, not on the next
|
|
205
|
+
// inbound steal (best-effort, remote-gated inside pushTranscriptRebind).
|
|
206
|
+
pushTranscriptRebind?.();
|
|
183
207
|
return true;
|
|
184
208
|
},
|
|
185
209
|
// 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 = {}) {
|