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
|
@@ -33,6 +33,62 @@ export function isAgentProgressWatchdogAbortError(err) {
|
|
|
33
33
|
return typeof msg === 'string' && WATCHDOG_ABORT_RE.test(msg);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// Tools that enforce their own execution deadline: 'shell' kills the process
|
|
37
|
+
// at its configured timeout; 'task' wait is bounded by its own wait budget.
|
|
38
|
+
// These are NOT blanket-exempted from the tool-running watchdog — if their own
|
|
39
|
+
// deadline timer dies the session would otherwise hang forever. Instead the
|
|
40
|
+
// watchdog raises the tool-running ceiling to their self-deadline + a grace
|
|
41
|
+
// window (below), so normal long runs are allowed but a dead timer is still
|
|
42
|
+
// caught. Unknown/missing self-deadline falls back to the plain toolRunningMs.
|
|
43
|
+
const SELF_DEADLINE_TOOLS = new Set(['shell', 'task']);
|
|
44
|
+
// Grace added on top of a tool's own deadline before the watchdog steps in, so
|
|
45
|
+
// the tool's in-process kill always fires first under normal operation.
|
|
46
|
+
const TOOL_SELF_DEADLINE_GRACE_MS = 60_000;
|
|
47
|
+
// Fallback deadlines matching the tool implementations (bash-tool.mjs default
|
|
48
|
+
// 120s foreground timeout; task/shell-job wait default 30s budget).
|
|
49
|
+
const SHELL_DEFAULT_TIMEOUT_MS = 120_000;
|
|
50
|
+
const TASK_DEFAULT_WAIT_MS = 30_000;
|
|
51
|
+
|
|
52
|
+
function bareToolName(toolName) {
|
|
53
|
+
if (typeof toolName !== 'string' || !toolName) return '';
|
|
54
|
+
// Strip any MCP/server prefix (e.g. 'server__shell' or 'server.shell').
|
|
55
|
+
return toolName.split(/[.]|__/).pop();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isSelfDeadlineTool(toolName) {
|
|
59
|
+
const bare = bareToolName(toolName);
|
|
60
|
+
return SELF_DEADLINE_TOOLS.has(bare) || SELF_DEADLINE_TOOLS.has(toolName);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the self-enforced deadline (ms) for a tool call from its arguments,
|
|
65
|
+
* recorded into the progress snapshot at dispatch time. Returns a positive
|
|
66
|
+
* number when the tool enforces its own deadline, or null when unknown/missing
|
|
67
|
+
* (caller then falls back to the plain toolRunningMs ceiling).
|
|
68
|
+
* - shell: explicit `timeout` (ms) if positive, else the 120s default.
|
|
69
|
+
* - task: explicit `timeout_ms` if positive, else 30s for the `wait` action
|
|
70
|
+
* (other actions carry no wait budget -> null).
|
|
71
|
+
*/
|
|
72
|
+
export function resolveToolSelfDeadlineMs(toolName, args) {
|
|
73
|
+
if (!isSelfDeadlineTool(toolName)) return null;
|
|
74
|
+
const bare = bareToolName(toolName);
|
|
75
|
+
const a = (args && typeof args === 'object') ? args : {};
|
|
76
|
+
if (bare === 'shell') {
|
|
77
|
+
const t = Number(a.timeout);
|
|
78
|
+
if (Number.isFinite(t) && t > 0) return t;
|
|
79
|
+
const envDefault = parseInt(process.env.BASH_DEFAULT_TIMEOUT_MS ?? '', 10);
|
|
80
|
+
return envDefault > 0 ? envDefault : SHELL_DEFAULT_TIMEOUT_MS;
|
|
81
|
+
}
|
|
82
|
+
if (bare === 'task') {
|
|
83
|
+
const t = Number(a.timeout_ms);
|
|
84
|
+
if (Number.isFinite(t) && t > 0) return t;
|
|
85
|
+
const action = typeof a.action === 'string' ? a.action : '';
|
|
86
|
+
if (action === 'wait') return TASK_DEFAULT_WAIT_MS;
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
36
92
|
function assistantMessageText(content) {
|
|
37
93
|
if (typeof content === 'string') return content;
|
|
38
94
|
if (!Array.isArray(content)) return '';
|
|
@@ -209,7 +265,26 @@ export function evaluateAgentWatchdogAbort(snapshot, now, policy) {
|
|
|
209
265
|
&& policy.toolRunningMs > 0
|
|
210
266
|
&& now - snapshot.toolStartedAt > policy.toolRunningMs
|
|
211
267
|
) {
|
|
212
|
-
|
|
268
|
+
// Deadline-aware ceiling for tools that self-enforce their own deadline
|
|
269
|
+
// ('shell'/'task'). Rather than a blanket exemption (which would hang
|
|
270
|
+
// forever if the tool's own timer died), raise the tool-running ceiling
|
|
271
|
+
// to max(toolRunningMs, selfDeadlineMs + grace): normal long runs are
|
|
272
|
+
// allowed because the tool kills itself first, but a dead deadline timer
|
|
273
|
+
// is still caught after the grace window. Unknown/missing self-deadline
|
|
274
|
+
// (selfDeadlineMs <= 0) keeps the plain toolRunningMs behavior. All
|
|
275
|
+
// other tools are unaffected.
|
|
276
|
+
let ceilingMs = policy.toolRunningMs;
|
|
277
|
+
const selfDeadlineMs = Number(snapshot.toolSelfDeadlineMs);
|
|
278
|
+
if (
|
|
279
|
+
isSelfDeadlineTool(snapshot.currentTool)
|
|
280
|
+
&& Number.isFinite(selfDeadlineMs)
|
|
281
|
+
&& selfDeadlineMs > 0
|
|
282
|
+
) {
|
|
283
|
+
ceilingMs = Math.max(policy.toolRunningMs, selfDeadlineMs + TOOL_SELF_DEADLINE_GRACE_MS);
|
|
284
|
+
}
|
|
285
|
+
if (now - snapshot.toolStartedAt > ceilingMs) {
|
|
286
|
+
return new AgentStallAbortError(`agent tool running stale (${ceilingMs}ms)`);
|
|
287
|
+
}
|
|
213
288
|
}
|
|
214
289
|
|
|
215
290
|
return null;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolvePluginData } from '../../shared/plugin-paths.mjs';
|
|
2
|
-
import { readSection, updateSection, getAgentApiKey, AGENT_PROVIDER_ENV } from '../../shared/config.mjs';
|
|
2
|
+
import { readSection, updateSection, updateSectionAsync, getAgentApiKey, AGENT_PROVIDER_ENV } from '../../shared/config.mjs';
|
|
3
3
|
import { OPENAI_COMPAT_PRESETS } from './providers/openai-compat-presets.mjs';
|
|
4
4
|
import {
|
|
5
5
|
hasAnthropicOAuthCredentials,
|
|
@@ -241,6 +241,14 @@ function persistAgentConfig(build) {
|
|
|
241
241
|
updateSection('agent', (current) => build(hasKeys(current) ? current : {}));
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
// Async twin of persistAgentConfig: same in-lock rebase-on-current semantics,
|
|
245
|
+
// but the whole-section RMW runs through updateSectionAsync so a debounced
|
|
246
|
+
// timer flush never blocks the event loop on icacls/backup. Reuses the same
|
|
247
|
+
// config lock file, so it stays linearizable with the sync writers.
|
|
248
|
+
async function persistAgentConfigAsync(build) {
|
|
249
|
+
await updateSectionAsync('agent', (current) => build(hasKeys(current) ? current : {}));
|
|
250
|
+
}
|
|
251
|
+
|
|
244
252
|
// Recap toggle (recap.enabled, default true) gates ONLY the background memory
|
|
245
253
|
// cycles. The memory module itself is always-on. On load we fold a legacy
|
|
246
254
|
// `modules.memory === false` flag into recap.enabled=false one time; the caller
|
|
@@ -506,23 +514,34 @@ export function loadConfig(options = {}) {
|
|
|
506
514
|
* concurrent instance's edits are not reverted.
|
|
507
515
|
*/
|
|
508
516
|
/** In-lock patch of `skills.disabled` only (avoids whole-config lost-update). */
|
|
509
|
-
|
|
517
|
+
function buildSkillsDisabledPatch(disabledNames) {
|
|
510
518
|
const names = disabledNames instanceof Set
|
|
511
519
|
? [...disabledNames]
|
|
512
520
|
: (Array.isArray(disabledNames) ? disabledNames : []);
|
|
513
521
|
const nextSkills = normalizeSkillsConfig({ disabled: names });
|
|
514
|
-
|
|
522
|
+
const build = (current) => {
|
|
515
523
|
const cur = { ...current };
|
|
516
524
|
const target = (cur.agent && cur.agent.providers)
|
|
517
525
|
? (cur.agent = { ...cur.agent })
|
|
518
526
|
: cur;
|
|
519
527
|
target.skills = nextSkills;
|
|
520
528
|
return cur;
|
|
521
|
-
}
|
|
529
|
+
};
|
|
530
|
+
return { build, nextSkills };
|
|
531
|
+
}
|
|
532
|
+
export function patchSkillsDisabled(disabledNames) {
|
|
533
|
+
const { build, nextSkills } = buildSkillsDisabledPatch(disabledNames);
|
|
534
|
+
persistAgentConfig(build);
|
|
535
|
+
return nextSkills;
|
|
536
|
+
}
|
|
537
|
+
// Async twin used by the debounced skills flush timer.
|
|
538
|
+
export async function patchSkillsDisabledAsync(disabledNames) {
|
|
539
|
+
const { build, nextSkills } = buildSkillsDisabledPatch(disabledNames);
|
|
540
|
+
await persistAgentConfigAsync(build);
|
|
522
541
|
return nextSkills;
|
|
523
542
|
}
|
|
524
543
|
|
|
525
|
-
|
|
544
|
+
function buildAgentSaveBuilder(config) {
|
|
526
545
|
// Strip ephemeral defaults from providers but preserve any unknown
|
|
527
546
|
// per-provider subkey so future schema additions round-trip through the
|
|
528
547
|
// setup UI without changes here. apiKey is intentionally omitted —
|
|
@@ -556,7 +575,7 @@ export function saveConfig(config) {
|
|
|
556
575
|
// Build the replacement from `existingRaw` — the section read INSIDE the
|
|
557
576
|
// file lock — not a snapshot taken before it, so unmanaged keys written by
|
|
558
577
|
// a concurrent instance survive the save (lost-update guard).
|
|
559
|
-
|
|
578
|
+
return (existingRaw) => ({
|
|
560
579
|
...existingRaw,
|
|
561
580
|
guide: config.guide || existingRaw.guide || undefined,
|
|
562
581
|
providers: persistedProviders,
|
|
@@ -582,7 +601,14 @@ export function saveConfig(config) {
|
|
|
582
601
|
update: config.update || {},
|
|
583
602
|
recap: config.recap || {},
|
|
584
603
|
modules: config.modules || existingRaw.modules || {},
|
|
585
|
-
})
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
export function saveConfig(config) {
|
|
607
|
+
persistAgentConfig(buildAgentSaveBuilder(config));
|
|
608
|
+
}
|
|
609
|
+
// Async twin used by the debounced config-save flush timer.
|
|
610
|
+
export async function saveConfigAsync(config) {
|
|
611
|
+
await persistAgentConfigAsync(buildAgentSaveBuilder(config));
|
|
586
612
|
}
|
|
587
613
|
// --- Preset helpers ---
|
|
588
614
|
// preset shape: { id, name, type: 'agent', provider, model, effort?, fast?, tools? }
|
|
@@ -407,9 +407,38 @@ export function stripDeferredToolManifestBlock(text) {
|
|
|
407
407
|
.trimEnd();
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
410
|
+
// Rebuild path: replace the FIRST previously-injected <available-deferred-tools>
|
|
411
|
+
// block (with its leading `---` separator) with the fresh manifest IN PLACE, so
|
|
412
|
+
// the block keeps its original position and no sibling BP1 block (skills
|
|
413
|
+
// manifest, agent rules, …) is reordered or dropped. The fresh manifest already
|
|
414
|
+
// carries the mcp-instructions companion, so any pre-existing standalone one is
|
|
415
|
+
// removed first to avoid duplication.
|
|
416
|
+
export function rebuildDeferredToolManifestBlock(text, manifest) {
|
|
417
|
+
let out = String(text || '').replace(MCP_INSTRUCTIONS_BLOCK_RE, '');
|
|
418
|
+
let replaced = false;
|
|
419
|
+
out = out.replace(DEFERRED_TOOLS_BLOCK_RE, (match, sep) => {
|
|
420
|
+
if (replaced) return '';
|
|
421
|
+
replaced = true;
|
|
422
|
+
return `${sep || ''}${manifest}`;
|
|
423
|
+
});
|
|
424
|
+
if (!replaced) {
|
|
425
|
+
const base = out.trimEnd();
|
|
426
|
+
out = base ? `${base}\n\n---\n\n${manifest}` : manifest;
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Inject the skill-style deferred pool (name + description) into BP1 at session
|
|
433
|
+
* start. Normally once; with `{ rebuild: true }` it strips any existing
|
|
434
|
+
* <available-deferred-tools>/<mcp-instructions> block and re-injects the fresh
|
|
435
|
+
* pool in place (used by the first-turn MCP refresh, before the prompt renders,
|
|
436
|
+
* so late-connected MCP tools land in the INITIAL manifest — never duplicated).
|
|
437
|
+
*/
|
|
438
|
+
export function applyInitialDeferredToolManifestToBp1(session, poolNames, options = {}) {
|
|
439
|
+
const rebuild = options?.rebuild === true;
|
|
440
|
+
if (!session || !Array.isArray(session.messages)) return false;
|
|
441
|
+
if (session.deferredToolBp1Applied && !rebuild) return false;
|
|
413
442
|
const pool = Array.isArray(poolNames) ? poolNames : [];
|
|
414
443
|
const descByName = new Map();
|
|
415
444
|
for (const tool of Array.isArray(session?.deferredToolCatalog) ? session.deferredToolCatalog : []) {
|
|
@@ -432,15 +461,21 @@ export function applyInitialDeferredToolManifestToBp1(session, poolNames) {
|
|
|
432
461
|
}
|
|
433
462
|
if (idx === -1) return false;
|
|
434
463
|
const raw = typeof session.messages[idx].content === 'string' ? session.messages[idx].content : '';
|
|
435
|
-
if (bp1HasDeferredToolManifestBlock(raw)) {
|
|
464
|
+
if (bp1HasDeferredToolManifestBlock(raw) && !rebuild) {
|
|
436
465
|
session.deferredToolBp1Applied = true;
|
|
437
466
|
return true;
|
|
438
467
|
}
|
|
439
468
|
if (manifest) {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
469
|
+
if (rebuild && bp1HasDeferredToolManifestBlock(raw)) {
|
|
470
|
+
// Anchored in-place rebuild: swap the previously injected manifest
|
|
471
|
+
// block for the fresh one at its EXISTING position.
|
|
472
|
+
session.messages[idx].content = rebuildDeferredToolManifestBlock(raw, manifest);
|
|
473
|
+
} else {
|
|
474
|
+
const base = stripDeferredToolManifestBlock(raw);
|
|
475
|
+
session.messages[idx].content = base
|
|
476
|
+
? `${base}\n\n---\n\n${manifest}`
|
|
477
|
+
: manifest;
|
|
478
|
+
}
|
|
444
479
|
}
|
|
445
480
|
session.deferredToolBp1Applied = true;
|
|
446
481
|
session.updatedAt = Date.now();
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Full-tree shutdown for stdio MCP child processes.
|
|
2
|
+
//
|
|
3
|
+
// The MCP SDK's StdioClientTransport.close() closes stdin, waits ~2s, then
|
|
4
|
+
// SIGTERMs / SIGKILLs the *direct* child only. On Windows that maps to
|
|
5
|
+
// TerminateProcess on the spawned wrapper (uvx/npx/uv) and leaves its
|
|
6
|
+
// grandchildren (uv.exe -> mcp-for-unity.exe -> python.exe) orphaned. This
|
|
7
|
+
// module implements the MCP spec shutdown order over the whole process tree:
|
|
8
|
+
// close stdin -> grace wait for voluntary exit -> force-kill the recorded
|
|
9
|
+
// tree (taskkill /T /F on Windows, SIGTERM->SIGKILL of every descendant on
|
|
10
|
+
// POSIX). No external dependencies.
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
const isWin = process.platform === 'win32';
|
|
14
|
+
|
|
15
|
+
function delay(ms) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const t = setTimeout(resolve, ms);
|
|
18
|
+
if (t.unref) t.unref();
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Spawn a helper command and resolve its stdout (empty string on any error).
|
|
23
|
+
function run(cmd, args) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
let out = '';
|
|
26
|
+
let cp;
|
|
27
|
+
try {
|
|
28
|
+
cp = spawn(cmd, args, { windowsHide: true });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
resolve('');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
cp.stdout?.on('data', (d) => { out += String(d); });
|
|
35
|
+
cp.on('error', () => resolve(out));
|
|
36
|
+
cp.on('close', () => resolve(out));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Snapshot the whole process table once: parent map + a per-pid identity
|
|
41
|
+
// token (Windows CreationDate, POSIX lstart) so a later kill can prove a pid
|
|
42
|
+
// number was not recycled onto a different process before signalling it.
|
|
43
|
+
async function enumerate() {
|
|
44
|
+
const childrenOf = new Map();
|
|
45
|
+
const tokenOf = new Map();
|
|
46
|
+
const out = isWin
|
|
47
|
+
? await run('powershell.exe', [
|
|
48
|
+
'-NoProfile', '-NonInteractive', '-Command',
|
|
49
|
+
`Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId) $($_.CreationDate.ToString('o'))" }`,
|
|
50
|
+
])
|
|
51
|
+
: await run('ps', ['-A', '-o', 'pid=,ppid=,lstart=']);
|
|
52
|
+
for (const line of out.split(/\r?\n/)) {
|
|
53
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)(?:\s+(.+))?$/);
|
|
54
|
+
if (!m) continue;
|
|
55
|
+
const pid = Number(m[1]);
|
|
56
|
+
const ppid = Number(m[2]);
|
|
57
|
+
const token = (m[3] || '').trim() || null;
|
|
58
|
+
if (!childrenOf.has(ppid)) childrenOf.set(ppid, []);
|
|
59
|
+
childrenOf.get(ppid).push(pid);
|
|
60
|
+
tokenOf.set(pid, token);
|
|
61
|
+
}
|
|
62
|
+
return { childrenOf, tokenOf };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// BFS every descendant pid of rootPid (excludes rootPid).
|
|
66
|
+
function descendantsOf(childrenOf, rootPid) {
|
|
67
|
+
const result = [];
|
|
68
|
+
const seen = new Set([rootPid]);
|
|
69
|
+
const stack = [rootPid];
|
|
70
|
+
while (stack.length) {
|
|
71
|
+
const cur = stack.pop();
|
|
72
|
+
for (const child of childrenOf.get(cur) ?? []) {
|
|
73
|
+
if (!seen.has(child)) {
|
|
74
|
+
seen.add(child);
|
|
75
|
+
result.push(child);
|
|
76
|
+
stack.push(child);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Exposed for tests: descendant pid list captured while the tree is intact.
|
|
84
|
+
export async function collectDescendants(rootPid) {
|
|
85
|
+
const { childrenOf } = await enumerate();
|
|
86
|
+
return descendantsOf(childrenOf, rootPid);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Immediate, non-blocking tree kill for a transport whose MCP handshake never
|
|
91
|
+
* completed (shutdown-during-connect). Unlike shutdownStdioChild there is no
|
|
92
|
+
* grace wait and no identity-checked enumeration on Windows — the ChildProcess
|
|
93
|
+
* handle is live, so the pid is current, and taskkill /T reaps the still-
|
|
94
|
+
* parented tree. Everything is unref'd/fire-and-forget so the kill can never
|
|
95
|
+
* hold the caller's event loop open (the caller is a process about to exit).
|
|
96
|
+
*/
|
|
97
|
+
export function killStdioChildTreeFast(transport) {
|
|
98
|
+
const proc = transport?._process;
|
|
99
|
+
const pid = (typeof transport?.pid === 'number' ? transport.pid : null) ?? proc?.pid ?? null;
|
|
100
|
+
if (!pid) return false;
|
|
101
|
+
if (proc && (proc.exitCode !== null || proc.signalCode !== null)) return false;
|
|
102
|
+
try { proc?.unref?.(); } catch { /* ignore */ }
|
|
103
|
+
for (const s of [proc?.stdin, proc?.stdout, proc?.stderr]) {
|
|
104
|
+
try { s?.unref?.(); } catch { /* ignore */ }
|
|
105
|
+
try { s?.destroy?.(); } catch { /* ignore */ }
|
|
106
|
+
}
|
|
107
|
+
if (isWin) {
|
|
108
|
+
try {
|
|
109
|
+
const cp = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], {
|
|
110
|
+
windowsHide: true,
|
|
111
|
+
detached: true,
|
|
112
|
+
stdio: 'ignore',
|
|
113
|
+
});
|
|
114
|
+
cp.unref();
|
|
115
|
+
} catch { /* ignore */ }
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
// POSIX: cheap ps enumerate, then SIGKILL descendants + root. Best-effort
|
|
119
|
+
// and un-awaited; a straggler orphan is reparented to init, not leaked by us.
|
|
120
|
+
void collectDescendants(pid).then((pids) => {
|
|
121
|
+
for (const p of [...pids, pid]) {
|
|
122
|
+
try { process.kill(p, 'SIGKILL'); } catch { /* ignore */ }
|
|
123
|
+
}
|
|
124
|
+
}).catch(() => { /* ignore */ });
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isAlive(pid) {
|
|
129
|
+
try {
|
|
130
|
+
process.kill(pid, 0);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
// EPERM => exists but not signalable by us; ESRCH => gone.
|
|
135
|
+
return Boolean(e && e.code === 'EPERM');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function waitExit(proc, ms) {
|
|
140
|
+
if (!proc || proc.exitCode !== null || proc.signalCode !== null) {
|
|
141
|
+
return Promise.resolve(true);
|
|
142
|
+
}
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
let done = false;
|
|
145
|
+
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
|
146
|
+
const t = setTimeout(() => finish(false), ms);
|
|
147
|
+
if (t.unref) t.unref();
|
|
148
|
+
proc.once('exit', () => { clearTimeout(t); finish(true); });
|
|
149
|
+
proc.once('close', () => { clearTimeout(t); finish(true); });
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A pid is safe to signal only if its CURRENT identity token equals the one
|
|
154
|
+
// recorded at snapshot time. Both must be present; a gone/recycled pid has a
|
|
155
|
+
// missing or mismatched token and is skipped. Tokens come from batched table
|
|
156
|
+
// reads (Map), so verification is O(1) with zero extra process spawns.
|
|
157
|
+
function stillSame(pid, snapshotToken, currentTokens) {
|
|
158
|
+
const want = snapshotToken.get(pid);
|
|
159
|
+
const cur = currentTokens.get(pid);
|
|
160
|
+
return Boolean(want && cur && want === cur);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Force-kill the given pids, identity-checked against a batched current-token
|
|
164
|
+
// table captured immediately before signalling so a recycled pid is never hit.
|
|
165
|
+
async function killVerified(pids, snapshotToken, currentTokens) {
|
|
166
|
+
const verified = pids.filter((pid) => stillSame(pid, snapshotToken, currentTokens));
|
|
167
|
+
if (verified.length === 0) return;
|
|
168
|
+
if (isWin) {
|
|
169
|
+
// /T also reaps any still-live children spawned since enumeration;
|
|
170
|
+
// every listed /PID was identity-checked just above.
|
|
171
|
+
const args = ['/F', '/T'];
|
|
172
|
+
for (const p of verified) args.push('/PID', String(p));
|
|
173
|
+
await run('taskkill', args);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// POSIX: terminate, brief wait, then hard-kill survivors. Re-verify with a
|
|
177
|
+
// single fresh batched table read (not per-pid) before the SIGKILL pass.
|
|
178
|
+
for (const p of verified) { try { process.kill(p, 'SIGTERM'); } catch { /* ignore */ } }
|
|
179
|
+
await delay(500);
|
|
180
|
+
const { tokenOf: fresh } = await enumerate().catch(() => ({ tokenOf: new Map() }));
|
|
181
|
+
for (const p of verified) {
|
|
182
|
+
if (isAlive(p) && stillSame(p, snapshotToken, fresh)) {
|
|
183
|
+
try { process.kill(p, 'SIGKILL'); } catch { /* ignore */ }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Shut down an stdio MCP transport's child process tree per MCP spec order.
|
|
190
|
+
* Returns false when the transport has no live child (non-stdio / already
|
|
191
|
+
* exited); true once the tree has been shut down.
|
|
192
|
+
*/
|
|
193
|
+
export async function shutdownStdioChild(transport, { graceMs = 2000 } = {}) {
|
|
194
|
+
const proc = transport?._process;
|
|
195
|
+
const pid = (typeof transport?.pid === 'number' ? transport.pid : null) ?? proc?.pid ?? null;
|
|
196
|
+
if (!pid) return false;
|
|
197
|
+
const empty = { childrenOf: new Map(), tokenOf: new Map() };
|
|
198
|
+
const snapshotToken = new Map();
|
|
199
|
+
// Snapshot #1: tree + identity tokens while it is still fully intact.
|
|
200
|
+
// Covers children that later get re-parented/orphaned during shutdown.
|
|
201
|
+
const before = await enumerate().catch(() => empty);
|
|
202
|
+
const initial = descendantsOf(before.childrenOf, pid);
|
|
203
|
+
snapshotToken.set(pid, before.tokenOf.get(pid) ?? null);
|
|
204
|
+
for (const d of initial) snapshotToken.set(d, before.tokenOf.get(d) ?? null);
|
|
205
|
+
// 1. Close stdin to request a voluntary shutdown.
|
|
206
|
+
try { proc?.stdin?.end(); } catch { /* ignore */ }
|
|
207
|
+
// 2. Grace period for the server to exit on its own.
|
|
208
|
+
const exited = await waitExit(proc, graceMs);
|
|
209
|
+
// 3. Snapshot #2 after the grace: catches children spawned late during
|
|
210
|
+
// shutdown that were absent from the first snapshot.
|
|
211
|
+
const after = await enumerate().catch(() => empty);
|
|
212
|
+
const fresh = descendantsOf(after.childrenOf, pid);
|
|
213
|
+
for (const d of fresh) {
|
|
214
|
+
if (!snapshotToken.has(d)) snapshotToken.set(d, after.tokenOf.get(d) ?? null);
|
|
215
|
+
}
|
|
216
|
+
// 4. Candidate set = union of both snapshots' descendants; add the root
|
|
217
|
+
// only when it did NOT exit on its own (skip stale-root kill per spec).
|
|
218
|
+
const candidates = new Set(initial);
|
|
219
|
+
for (const d of fresh) candidates.add(d);
|
|
220
|
+
if (!exited) candidates.add(pid);
|
|
221
|
+
if (candidates.size === 0) return true;
|
|
222
|
+
// 5. Force-kill. Snapshot #2 is the pre-kill verify table (captured just
|
|
223
|
+
// now, immediately before signalling) — no per-pid queries.
|
|
224
|
+
await killVerified([...candidates], snapshotToken, after.tokenOf);
|
|
225
|
+
return true;
|
|
226
|
+
}
|