mixdog 0.9.21 → 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/channel-daemon-smoke.mjs +165 -0
- package/scripts/channel-daemon-stub.mjs +69 -0
- package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
- package/scripts/tool-smoke.mjs +30 -17
- 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/bash-session.mjs +10 -10
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +8 -6
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +14 -1
- 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/patch/orchestrator.mjs +23 -13
- 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 +44 -10
- 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 +653 -310
- 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
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ mixdog
|
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
First run walks you through onboarding: provider auth, model pick, and
|
|
26
|
-
workflow setup.
|
|
26
|
+
workflow setup.
|
|
27
27
|
|
|
28
28
|
## Terminal-Bench 2.1 — 89.9% (self-reported)
|
|
29
29
|
|
|
@@ -117,9 +117,6 @@ mixdog --readonly
|
|
|
117
117
|
|
|
118
118
|
# Enable remote/channel mode for this session
|
|
119
119
|
mixdog --remote
|
|
120
|
-
|
|
121
|
-
# Re-run first-run setup
|
|
122
|
-
mixdog --onboarding
|
|
123
120
|
```
|
|
124
121
|
|
|
125
122
|
Headless role mode is also supported:
|
package/package.json
CHANGED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Scripted two-client simulation for the machine-global channels daemon
|
|
2
|
+
// transport (no Discord token needed — the channels runtime is stubbed).
|
|
3
|
+
//
|
|
4
|
+
// Verifies the transport + attach client contracts that later slices depend on:
|
|
5
|
+
// 1. two TUIs attach to ONE daemon and each get an independent SSE stream;
|
|
6
|
+
// 2. tool calls round-trip per client and carry the caller's leadPid;
|
|
7
|
+
// 3. notifies are TARGETED (never broadcast) to the routing-pointer client;
|
|
8
|
+
// 4. a bind-intent call (rebind_current_transcript) moves the pointer;
|
|
9
|
+
// 5. once every client deregisters, the daemon self-shutdown fires.
|
|
10
|
+
//
|
|
11
|
+
// Run: node scripts/channel-daemon-smoke.mjs
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { createChannelDaemonTransport } from '../src/standalone/channel-daemon-transport.mjs';
|
|
17
|
+
import { attachToDaemon } from '../src/standalone/channel-daemon-client.mjs';
|
|
18
|
+
|
|
19
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const STUB_ENTRY = path.join(HERE, 'channel-daemon-stub.mjs');
|
|
21
|
+
|
|
22
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
23
|
+
const waitFor = async (fn, ms = 1500) => {
|
|
24
|
+
const end = Date.now() + ms;
|
|
25
|
+
while (Date.now() < end) { if (fn()) return true; await delay(20); }
|
|
26
|
+
return fn();
|
|
27
|
+
};
|
|
28
|
+
let failures = 0;
|
|
29
|
+
function check(label, cond) {
|
|
30
|
+
const ok = !!cond;
|
|
31
|
+
if (!ok) failures++;
|
|
32
|
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${label}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function main() {
|
|
36
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-daemon-smoke-'));
|
|
37
|
+
const discoveryPath = path.join(tmp, 'channel-daemon.json');
|
|
38
|
+
let clientsEmptyFired = false;
|
|
39
|
+
let sideEffects = 0; // counts non-idempotent 'reply' dispatches
|
|
40
|
+
|
|
41
|
+
// Stub channels runtime: echoes the call + the caller identity the transport
|
|
42
|
+
// resolved, so we can assert per-client leadPid threading.
|
|
43
|
+
const handleCall = async (name, args, ctx) => {
|
|
44
|
+
if (name === 'reply') { sideEffects++; await delay(40); } // simulate a side effect
|
|
45
|
+
return { ok: true, name, args, leadPid: ctx.leadPid };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const transport = createChannelDaemonTransport({
|
|
49
|
+
handleCall,
|
|
50
|
+
discoveryPath,
|
|
51
|
+
clientGraceMs: 250,
|
|
52
|
+
sweepMs: 1000,
|
|
53
|
+
onClientsEmpty: () => { clientsEmptyFired = true; },
|
|
54
|
+
log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[daemon] ${m}`),
|
|
55
|
+
});
|
|
56
|
+
const { port, token } = await transport.start();
|
|
57
|
+
const discovery = { port, token, pid: process.pid };
|
|
58
|
+
|
|
59
|
+
const notA = [];
|
|
60
|
+
const notB = [];
|
|
61
|
+
const clientA = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'A', onNotify: (m) => notA.push(m) });
|
|
62
|
+
const clientB = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'B', onNotify: (m) => notB.push(m) });
|
|
63
|
+
await delay(150); // let both SSE streams attach
|
|
64
|
+
|
|
65
|
+
check('two clients registered', transport._clientsForTest.size === 2);
|
|
66
|
+
|
|
67
|
+
// (2) calls round-trip per client.
|
|
68
|
+
const rA = await clientA.call('reload_config', { k: 1 });
|
|
69
|
+
check('client A call round-trips', rA?.ok === true && rA?.name === 'reload_config');
|
|
70
|
+
const rB = await clientB.call('fetch', { q: 2 });
|
|
71
|
+
check('client B call round-trips', rB?.ok === true && rB?.name === 'fetch');
|
|
72
|
+
|
|
73
|
+
// (3) notify targets the pointer client. First registrant (A) is the pointer.
|
|
74
|
+
transport.notify('notifications/claude/channel', { content: 'to-A' });
|
|
75
|
+
await delay(120);
|
|
76
|
+
check('notify #1 delivered to A only',
|
|
77
|
+
notA.length === 1 && notA[0]?.params?.content === 'to-A' && notB.length === 0);
|
|
78
|
+
|
|
79
|
+
// (4) bind-intent call moves the pointer to B.
|
|
80
|
+
await clientB.call('rebind_current_transcript', { transcriptPath: '/tmp/b.jsonl' });
|
|
81
|
+
transport.notify('notifications/claude/channel', { content: 'to-B' });
|
|
82
|
+
await delay(120);
|
|
83
|
+
check('notify #2 delivered to B only after rebind',
|
|
84
|
+
notB.length === 1 && notB[0]?.params?.content === 'to-B' && notA.length === 1);
|
|
85
|
+
|
|
86
|
+
// (fix 1) idempotent replay: two /call with the SAME callId (a retried
|
|
87
|
+
// transport failure) must run the non-idempotent side-effect exactly once.
|
|
88
|
+
const dupId = 'dup-call-1';
|
|
89
|
+
const [d1, d2] = await Promise.all([
|
|
90
|
+
clientA.call('reply', { n: 1 }, { callId: dupId }),
|
|
91
|
+
clientA.call('reply', { n: 1 }, { callId: dupId }),
|
|
92
|
+
]);
|
|
93
|
+
check('idempotent replay: same callId → exactly one side-effect',
|
|
94
|
+
sideEffects === 1 && d1?.ok === true && d2?.ok === true);
|
|
95
|
+
|
|
96
|
+
// (5) deregister both → self-shutdown fires after the client grace window.
|
|
97
|
+
await clientA.close();
|
|
98
|
+
await clientB.close();
|
|
99
|
+
await delay(600);
|
|
100
|
+
check('daemon self-shutdown fired after last client left', clientsEmptyFired === true);
|
|
101
|
+
|
|
102
|
+
await transport.stop();
|
|
103
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
104
|
+
|
|
105
|
+
await flipTest();
|
|
106
|
+
|
|
107
|
+
console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`);
|
|
108
|
+
process.exit(failures === 0 ? 0 : 1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Flip coverage: two TUIs attach to ONE daemon via the REAL channel-worker.mjs
|
|
112
|
+
// spawn-or-attach path (against the stub daemon entry — no Discord token). The
|
|
113
|
+
// first worker spawns the daemon; the second attaches to it.
|
|
114
|
+
async function flipTest() {
|
|
115
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-flip-smoke-'));
|
|
116
|
+
// channel-worker.mjs resolves DAEMON_ENTRY + runtimeRoot at import/call time,
|
|
117
|
+
// so the env must be set BEFORE importing it.
|
|
118
|
+
process.env.MIXDOG_RUNTIME_ROOT = tmp;
|
|
119
|
+
process.env.MIXDOG_DATA_DIR = tmp;
|
|
120
|
+
process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = STUB_ENTRY;
|
|
121
|
+
const { createStandaloneChannelWorker } = await import('../src/standalone/channel-worker.mjs');
|
|
122
|
+
const { readDaemonDiscovery } = await import('../src/standalone/channel-daemon-client.mjs');
|
|
123
|
+
const discFile = path.join(tmp, 'channel-daemon.json');
|
|
124
|
+
|
|
125
|
+
const notA = [];
|
|
126
|
+
const notB = [];
|
|
127
|
+
const mk = (onNotify) => createStandaloneChannelWorker({ entry: STUB_ENTRY, rootDir: tmp, dataDir: tmp, cwd: tmp, onNotify });
|
|
128
|
+
const wA = mk((m) => notA.push(m));
|
|
129
|
+
const wB = mk((m) => notB.push(m));
|
|
130
|
+
|
|
131
|
+
await wA.start(); // spawns the (stub) daemon then attaches
|
|
132
|
+
await wB.start(); // attaches to the SAME daemon (no second spawn)
|
|
133
|
+
check('flip: worker A call round-trips', (await wA.execute('reply', { x: 1 }))?.ok === true);
|
|
134
|
+
check('flip: worker B call round-trips', (await wB.execute('reply', { y: 2 }))?.ok === true);
|
|
135
|
+
|
|
136
|
+
// B claims the transcript → routing pointer moves to B; a notify emitted by
|
|
137
|
+
// the daemon (on A's 'fetch') must reach B only, never both TUIs.
|
|
138
|
+
await wB.execute('rebind_current_transcript', { transcriptPath: path.join(tmp, 'b.jsonl') });
|
|
139
|
+
await wA.execute('fetch', { probe: true });
|
|
140
|
+
await waitFor(() => notB.some((m) => m?.params?.content === 'ping-from-stub'), 1500);
|
|
141
|
+
check('flip: notify targets the pointer TUI (B) only',
|
|
142
|
+
notB.some((m) => m?.params?.content === 'ping-from-stub') &&
|
|
143
|
+
!notA.some((m) => m?.params?.content === 'ping-from-stub'));
|
|
144
|
+
|
|
145
|
+
// (fix 1) daemon death mid-session: SIGKILL it, then the surviving client's
|
|
146
|
+
// next call must transparently respawn + re-attach (bounded retry inside
|
|
147
|
+
// execute) — no TUI process restart.
|
|
148
|
+
const beforeKill = readDaemonDiscovery(discFile);
|
|
149
|
+
if (beforeKill?.pid) { try { process.kill(beforeKill.pid, 'SIGKILL'); } catch {} }
|
|
150
|
+
await delay(400);
|
|
151
|
+
let recovered = false;
|
|
152
|
+
try { recovered = (await wA.execute('reply', { after: 'kill' }))?.ok === true; } catch {}
|
|
153
|
+
const afterKill = readDaemonDiscovery(discFile);
|
|
154
|
+
check('flip: call transparently respawns+reattaches after daemon death',
|
|
155
|
+
recovered === true && !!afterKill?.pid && afterKill.pid !== beforeKill?.pid);
|
|
156
|
+
|
|
157
|
+
await wA.stop();
|
|
158
|
+
await wB.stop();
|
|
159
|
+
await delay(700);
|
|
160
|
+
const disc = readDaemonDiscovery(discFile);
|
|
161
|
+
check('flip: daemon self-shutdown after last TUI detaches', disc === null);
|
|
162
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
main().catch((err) => { console.error(err); process.exit(1); });
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Stub channels daemon for the flip smoke: same lifecycle contract as
|
|
2
|
+
// src/standalone/channel-daemon.mjs (pid-verified singleton claim, HTTP+SSE
|
|
3
|
+
// transport, discovery file, ready handshake, client-grace self-shutdown) but
|
|
4
|
+
// with a STUB runtime instead of worker-main — no Discord token needed. The
|
|
5
|
+
// real channel-worker.mjs spawn-or-attach path forks THIS via
|
|
6
|
+
// MIXDOG_CHANNEL_DAEMON_ENTRY, so the flip is exercised end to end.
|
|
7
|
+
process.env.MIXDOG_WORKER_MODE = process.env.MIXDOG_WORKER_MODE || '1';
|
|
8
|
+
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { mkdirSync } from 'node:fs';
|
|
12
|
+
import { claimSingletonOwner, releaseSingletonOwner } from '../src/runtime/shared/singleton-owner.mjs';
|
|
13
|
+
import { createChannelDaemonTransport } from '../src/standalone/channel-daemon-transport.mjs';
|
|
14
|
+
|
|
15
|
+
function runtimeRoot() {
|
|
16
|
+
return process.env.MIXDOG_RUNTIME_ROOT
|
|
17
|
+
? path.resolve(process.env.MIXDOG_RUNTIME_ROOT)
|
|
18
|
+
: path.join(os.tmpdir(), 'mixdog');
|
|
19
|
+
}
|
|
20
|
+
const RUNTIME_ROOT = runtimeRoot();
|
|
21
|
+
const DATA_DIR = process.env.MIXDOG_DATA_DIR ? path.resolve(process.env.MIXDOG_DATA_DIR) : RUNTIME_ROOT;
|
|
22
|
+
const DISCOVERY_PATH = path.join(RUNTIME_ROOT, 'channel-daemon.json');
|
|
23
|
+
const OWNER_PATH = path.join(DATA_DIR, 'channel-daemon-owner.json');
|
|
24
|
+
|
|
25
|
+
function log(line) { if (process.env.DAEMON_SMOKE_VERBOSE) process.stderr.write(`[stub-daemon] ${line}\n`); }
|
|
26
|
+
|
|
27
|
+
let transport = null;
|
|
28
|
+
let shuttingDown = false;
|
|
29
|
+
async function shutdown(reason, code = 0) {
|
|
30
|
+
if (shuttingDown) return;
|
|
31
|
+
shuttingDown = true;
|
|
32
|
+
try { await transport?.stop?.(); } catch {}
|
|
33
|
+
try { releaseSingletonOwner(OWNER_PATH, process.pid); } catch {}
|
|
34
|
+
process.exit(code);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function main() {
|
|
38
|
+
try { mkdirSync(RUNTIME_ROOT, { recursive: true }); } catch {}
|
|
39
|
+
const claim = claimSingletonOwner(OWNER_PATH, { kind: 'channel-runtime-daemon', pid: process.pid, meta: { cwd: process.cwd() } });
|
|
40
|
+
if (!claim.owned) { process.exit(0); } // race loser → spawner attaches to winner
|
|
41
|
+
process.on('exit', () => { try { releaseSingletonOwner(OWNER_PATH, process.pid); } catch {} });
|
|
42
|
+
|
|
43
|
+
// Stub runtime: echo the call + caller identity, and (for 'fetch') emit a
|
|
44
|
+
// notify AFTER responding so the smoke can assert targeted routing.
|
|
45
|
+
const handleCall = async (name, args, ctx) => {
|
|
46
|
+
if (name === 'fetch') {
|
|
47
|
+
setTimeout(() => { try { transport.notify('notifications/claude/channel', { content: 'ping-from-stub' }); } catch {} }, 20);
|
|
48
|
+
}
|
|
49
|
+
return { ok: true, name, args, leadPid: ctx.leadPid };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
transport = createChannelDaemonTransport({
|
|
53
|
+
handleCall,
|
|
54
|
+
discoveryPath: DISCOVERY_PATH,
|
|
55
|
+
clientGraceMs: 250,
|
|
56
|
+
sweepMs: 1000,
|
|
57
|
+
log,
|
|
58
|
+
onClientsEmpty: () => { void shutdown('no live clients'); },
|
|
59
|
+
});
|
|
60
|
+
const { port, token } = await transport.start();
|
|
61
|
+
if (process.send) { try { process.send({ type: 'ready', port, token }); } catch {} }
|
|
62
|
+
log(`ready port=${port} pid=${process.pid}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
|
|
66
|
+
process.on('SIGINT', () => { void shutdown('SIGINT'); });
|
|
67
|
+
process.on('message', (msg) => { if (msg && msg.type === 'shutdown') void shutdown('IPC shutdown'); });
|
|
68
|
+
|
|
69
|
+
main().catch((err) => { log(`fatal: ${err?.stack || err}`); void shutdown('fatal', 2); });
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { acceptQuotaSnapshot } from '../src/ui/statusline.mjs';
|
|
4
|
+
|
|
5
|
+
// Monotonic hysteresis for the 5H/7D usage segment: once a value has rendered,
|
|
6
|
+
// an OLDER shared-cache snapshot (written by another mixdog instance) must not
|
|
7
|
+
// displace it, but a NEWER one — or confirmed own-instance live data — must.
|
|
8
|
+
test('older shared-cache snapshot is rejected, newer is accepted', () => {
|
|
9
|
+
const displayedOwnLive = { segments: ['5H 12%'], asOf: 2000, owned: true };
|
|
10
|
+
|
|
11
|
+
// Another instance overwrites the shared cache with an OLDER snapshot →
|
|
12
|
+
// metricsMatch flips false, source alternates to that unowned older snapshot.
|
|
13
|
+
assert.equal(
|
|
14
|
+
acceptQuotaSnapshot(displayedOwnLive, { asOf: 1000, owned: false }),
|
|
15
|
+
false,
|
|
16
|
+
'older unowned snapshot must not displace displayed own-live value',
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
// A strictly newer shared snapshot is allowed to advance the value.
|
|
20
|
+
assert.equal(
|
|
21
|
+
acceptQuotaSnapshot(displayedOwnLive, { asOf: 3000, owned: false }),
|
|
22
|
+
true,
|
|
23
|
+
'newer snapshot must replace the displayed value',
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('own-instance live data always wins; empty timestamps preserve prior behavior', () => {
|
|
28
|
+
const displayedShared = { segments: ['5H 12%'], asOf: 5000, owned: false };
|
|
29
|
+
// Own-instance live data replaces even an apparently newer shared snapshot.
|
|
30
|
+
assert.equal(acceptQuotaSnapshot(displayedShared, { asOf: 1, owned: true }), true);
|
|
31
|
+
// Nothing displayed yet → accept.
|
|
32
|
+
assert.equal(acceptQuotaSnapshot(undefined, { asOf: 1000, owned: false }), true);
|
|
33
|
+
// No comparable timestamps → accept (byte-for-byte prior behavior).
|
|
34
|
+
assert.equal(acceptQuotaSnapshot({ asOf: 0, owned: true }, { asOf: 0, owned: false }), true);
|
|
35
|
+
// Shared→shared same asOf → accept (idempotent refresh).
|
|
36
|
+
assert.equal(acceptQuotaSnapshot({ asOf: 2000, owned: false }, { asOf: 2000, owned: false }), true);
|
|
37
|
+
});
|
package/scripts/tool-smoke.mjs
CHANGED
|
@@ -906,7 +906,7 @@ const fullDefaults = defaultDeferredToolNames(smokeCatalog, 'full');
|
|
|
906
906
|
if (fullDefaults.size !== 10) {
|
|
907
907
|
throw new Error(`full default surface should stay 10 tools, got ${fullDefaults.size}: ${[...fullDefaults].join(', ')}`);
|
|
908
908
|
}
|
|
909
|
-
for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'apply_patch', 'explore', 'Skill', '
|
|
909
|
+
for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'apply_patch', 'explore', 'Skill', 'load_tool']) {
|
|
910
910
|
assertHas(fullDefaults, name);
|
|
911
911
|
}
|
|
912
912
|
for (const name of ['shell', 'task', 'agent', 'recall', 'search', 'web_fetch', 'cwd']) {
|
|
@@ -917,7 +917,7 @@ const leadDefaults = defaultDeferredToolNames(smokeCatalog, 'lead');
|
|
|
917
917
|
if (leadDefaults.size !== 16) {
|
|
918
918
|
throw new Error(`lead default surface should stay 16 tools for this static catalog, got ${leadDefaults.size}: ${[...leadDefaults].join(', ')}`);
|
|
919
919
|
}
|
|
920
|
-
for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'shell', 'task', 'apply_patch', 'explore', 'agent', 'recall', 'search', 'web_fetch', 'Skill', '
|
|
920
|
+
for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'shell', 'task', 'apply_patch', 'explore', 'agent', 'recall', 'search', 'web_fetch', 'Skill', 'load_tool']) {
|
|
921
921
|
assertHas(leadDefaults, name);
|
|
922
922
|
}
|
|
923
923
|
if (TOOL_SEARCH_TOOL.annotations?.agentHidden !== true) {
|
|
@@ -943,7 +943,7 @@ for (const [name, cap] of [
|
|
|
943
943
|
['recall', 2400],
|
|
944
944
|
['search', 3200],
|
|
945
945
|
['web_fetch', 900],
|
|
946
|
-
['
|
|
946
|
+
['load_tool', 900],
|
|
947
947
|
]) {
|
|
948
948
|
const tool = smokeCatalog.find((item) => item?.name === name);
|
|
949
949
|
const size = toolSchemaSize(tool);
|
|
@@ -954,7 +954,7 @@ const readonlyDefaults = defaultDeferredToolNames(smokeCatalog, 'readonly');
|
|
|
954
954
|
if (readonlyDefaults.size !== 9) {
|
|
955
955
|
throw new Error(`readonly default surface should stay 9 tools, got ${readonlyDefaults.size}: ${[...readonlyDefaults].join(', ')}`);
|
|
956
956
|
}
|
|
957
|
-
for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'explore', 'Skill', '
|
|
957
|
+
for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'explore', 'Skill', 'load_tool']) {
|
|
958
958
|
assertHas(readonlyDefaults, name);
|
|
959
959
|
}
|
|
960
960
|
for (const name of ['apply_patch', 'agent', 'shell']) {
|
|
@@ -1359,8 +1359,8 @@ setInternalToolsProvider({
|
|
|
1359
1359
|
throw new Error(`agent context must not inject environment reminder: ${visible.slice(0, 1200)}`);
|
|
1360
1360
|
}
|
|
1361
1361
|
const workerToolNames = (workerSession.tools || []).map((tool) => tool?.name).filter(Boolean);
|
|
1362
|
-
if (workerToolNames.includes('
|
|
1363
|
-
throw new Error(`agent session schema must not expose deferred
|
|
1362
|
+
if (workerToolNames.includes('load_tool')) {
|
|
1363
|
+
throw new Error(`agent session schema must not expose deferred load_tool: ${workerToolNames.join(', ')}`);
|
|
1364
1364
|
}
|
|
1365
1365
|
for (const name of ['shell', 'task']) {
|
|
1366
1366
|
if (!workerToolNames.includes(name)) {
|
|
@@ -1420,8 +1420,8 @@ setInternalToolsProvider({
|
|
|
1420
1420
|
if (JSON.stringify(writeTools) !== JSON.stringify(expectedWriteTools)) {
|
|
1421
1421
|
throw new Error(`read-write agent schema must be fixed allow-list: expected=${expectedWriteTools.join(', ')} actual=${writeTools.join(', ')}`);
|
|
1422
1422
|
}
|
|
1423
|
-
if (readTools.includes('
|
|
1424
|
-
throw new Error(`agent session fixed schemas must omit
|
|
1423
|
+
if (readTools.includes('load_tool') || writeTools.includes('load_tool')) {
|
|
1424
|
+
throw new Error(`agent session fixed schemas must omit load_tool: read=${readTools.join(', ')} write=${writeTools.join(', ')}`);
|
|
1425
1425
|
}
|
|
1426
1426
|
if (readTools.includes('shell')) {
|
|
1427
1427
|
throw new Error(`read agent schema must omit shell: read=${readTools.join(', ')}`);
|
|
@@ -1846,25 +1846,38 @@ if (!/Use after search/i.test(webFetchTool?.description || '') || !webFetchProps
|
|
|
1846
1846
|
if (!/offset/i.test(webFetchProps.startIndex?.description || '') || !/Maximum characters/i.test(webFetchProps.maxLength?.description || '')) {
|
|
1847
1847
|
throw new Error('web_fetch schema must describe paging window fields');
|
|
1848
1848
|
}
|
|
1849
|
-
if (!/deferred tools/i.test(TOOL_SEARCH_TOOL.description || '')
|
|
1850
|
-
|
|
1849
|
+
if (!/deferred tools/i.test(TOOL_SEARCH_TOOL.description || '')
|
|
1850
|
+
|| !TOOL_SEARCH_TOOL.inputSchema?.properties?.names
|
|
1851
|
+
|| !TOOL_SEARCH_TOOL.inputSchema?.properties?.select) {
|
|
1852
|
+
throw new Error('load_tool schema must preserve loader guidance plus names + legacy select fields');
|
|
1851
1853
|
}
|
|
1852
1854
|
const toolSearchSession = {
|
|
1853
1855
|
tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
|
|
1854
1856
|
deferredToolCatalog: smokeCatalog.slice(),
|
|
1855
1857
|
deferredSelectedTools: [...fullDefaults],
|
|
1856
1858
|
};
|
|
1857
|
-
//
|
|
1858
|
-
//
|
|
1859
|
+
// load_tool is a pure loader: a free-text query is NOT a search. It loads
|
|
1860
|
+
// nothing, returns an error steering to names[], and never activates tools.
|
|
1859
1861
|
const listQueryResult = JSON.parse(__renderToolSearchForTest({ query: 'shell' }, toolSearchSession, 'full'));
|
|
1860
|
-
if (listQueryResult.selected) {
|
|
1861
|
-
throw new Error(`
|
|
1862
|
+
if (listQueryResult.selected || (Array.isArray(listQueryResult.loaded) && listQueryResult.loaded.length)) {
|
|
1863
|
+
throw new Error(`load_tool free-text query must not load: ${JSON.stringify(listQueryResult)}`);
|
|
1862
1864
|
}
|
|
1863
|
-
if (!listQueryResult.
|
|
1864
|
-
throw new Error(`
|
|
1865
|
+
if (!listQueryResult.error || !/names/i.test(listQueryResult.error)) {
|
|
1866
|
+
throw new Error(`load_tool free-text query must steer to names[]: ${JSON.stringify(listQueryResult)}`);
|
|
1865
1867
|
}
|
|
1866
1868
|
if (listQueryResult.activeTools.includes('shell') || (Array.isArray(listQueryResult.discoveredTools) && listQueryResult.discoveredTools.includes('shell'))) {
|
|
1867
|
-
throw new Error(`
|
|
1869
|
+
throw new Error(`load_tool free-text query must not activate/discover tools: ${JSON.stringify(listQueryResult)}`);
|
|
1870
|
+
}
|
|
1871
|
+
// names[] is the primary loader input (aliases expand, tools activate).
|
|
1872
|
+
const namesLoadResult = JSON.parse(__renderToolSearchForTest({ names: ['shell', 'recall'] }, {
|
|
1873
|
+
tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
|
|
1874
|
+
deferredToolCatalog: smokeCatalog.slice(),
|
|
1875
|
+
deferredSelectedTools: [...fullDefaults],
|
|
1876
|
+
}, 'full'));
|
|
1877
|
+
for (const name of ['shell', 'recall']) {
|
|
1878
|
+
if (!namesLoadResult.activeTools.includes(name) || !namesLoadResult.loaded.includes(name)) {
|
|
1879
|
+
throw new Error(`load_tool names[] must load ${name}: ${JSON.stringify(namesLoadResult)}`);
|
|
1880
|
+
}
|
|
1868
1881
|
}
|
|
1869
1882
|
// query "select:a,b" is the explicit query-side loader (aliases expand).
|
|
1870
1883
|
const bulkSelectResult = JSON.parse(__renderToolSearchForTest({ query: 'select:shell,recall' }, toolSearchSession, 'full'));
|
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
function isAlive(pid) {
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 0);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
// EPERM => exists but not signalable by us; ESRCH => gone.
|
|
96
|
+
return Boolean(e && e.code === 'EPERM');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function waitExit(proc, ms) {
|
|
101
|
+
if (!proc || proc.exitCode !== null || proc.signalCode !== null) {
|
|
102
|
+
return Promise.resolve(true);
|
|
103
|
+
}
|
|
104
|
+
return new Promise((resolve) => {
|
|
105
|
+
let done = false;
|
|
106
|
+
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
|
107
|
+
const t = setTimeout(() => finish(false), ms);
|
|
108
|
+
if (t.unref) t.unref();
|
|
109
|
+
proc.once('exit', () => { clearTimeout(t); finish(true); });
|
|
110
|
+
proc.once('close', () => { clearTimeout(t); finish(true); });
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// A pid is safe to signal only if its CURRENT identity token equals the one
|
|
115
|
+
// recorded at snapshot time. Both must be present; a gone/recycled pid has a
|
|
116
|
+
// missing or mismatched token and is skipped. Tokens come from batched table
|
|
117
|
+
// reads (Map), so verification is O(1) with zero extra process spawns.
|
|
118
|
+
function stillSame(pid, snapshotToken, currentTokens) {
|
|
119
|
+
const want = snapshotToken.get(pid);
|
|
120
|
+
const cur = currentTokens.get(pid);
|
|
121
|
+
return Boolean(want && cur && want === cur);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Force-kill the given pids, identity-checked against a batched current-token
|
|
125
|
+
// table captured immediately before signalling so a recycled pid is never hit.
|
|
126
|
+
async function killVerified(pids, snapshotToken, currentTokens) {
|
|
127
|
+
const verified = pids.filter((pid) => stillSame(pid, snapshotToken, currentTokens));
|
|
128
|
+
if (verified.length === 0) return;
|
|
129
|
+
if (isWin) {
|
|
130
|
+
// /T also reaps any still-live children spawned since enumeration;
|
|
131
|
+
// every listed /PID was identity-checked just above.
|
|
132
|
+
const args = ['/F', '/T'];
|
|
133
|
+
for (const p of verified) args.push('/PID', String(p));
|
|
134
|
+
await run('taskkill', args);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// POSIX: terminate, brief wait, then hard-kill survivors. Re-verify with a
|
|
138
|
+
// single fresh batched table read (not per-pid) before the SIGKILL pass.
|
|
139
|
+
for (const p of verified) { try { process.kill(p, 'SIGTERM'); } catch { /* ignore */ } }
|
|
140
|
+
await delay(500);
|
|
141
|
+
const { tokenOf: fresh } = await enumerate().catch(() => ({ tokenOf: new Map() }));
|
|
142
|
+
for (const p of verified) {
|
|
143
|
+
if (isAlive(p) && stillSame(p, snapshotToken, fresh)) {
|
|
144
|
+
try { process.kill(p, 'SIGKILL'); } catch { /* ignore */ }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Shut down an stdio MCP transport's child process tree per MCP spec order.
|
|
151
|
+
* Returns false when the transport has no live child (non-stdio / already
|
|
152
|
+
* exited); true once the tree has been shut down.
|
|
153
|
+
*/
|
|
154
|
+
export async function shutdownStdioChild(transport, { graceMs = 2000 } = {}) {
|
|
155
|
+
const proc = transport?._process;
|
|
156
|
+
const pid = (typeof transport?.pid === 'number' ? transport.pid : null) ?? proc?.pid ?? null;
|
|
157
|
+
if (!pid) return false;
|
|
158
|
+
const empty = { childrenOf: new Map(), tokenOf: new Map() };
|
|
159
|
+
const snapshotToken = new Map();
|
|
160
|
+
// Snapshot #1: tree + identity tokens while it is still fully intact.
|
|
161
|
+
// Covers children that later get re-parented/orphaned during shutdown.
|
|
162
|
+
const before = await enumerate().catch(() => empty);
|
|
163
|
+
const initial = descendantsOf(before.childrenOf, pid);
|
|
164
|
+
snapshotToken.set(pid, before.tokenOf.get(pid) ?? null);
|
|
165
|
+
for (const d of initial) snapshotToken.set(d, before.tokenOf.get(d) ?? null);
|
|
166
|
+
// 1. Close stdin to request a voluntary shutdown.
|
|
167
|
+
try { proc?.stdin?.end(); } catch { /* ignore */ }
|
|
168
|
+
// 2. Grace period for the server to exit on its own.
|
|
169
|
+
const exited = await waitExit(proc, graceMs);
|
|
170
|
+
// 3. Snapshot #2 after the grace: catches children spawned late during
|
|
171
|
+
// shutdown that were absent from the first snapshot.
|
|
172
|
+
const after = await enumerate().catch(() => empty);
|
|
173
|
+
const fresh = descendantsOf(after.childrenOf, pid);
|
|
174
|
+
for (const d of fresh) {
|
|
175
|
+
if (!snapshotToken.has(d)) snapshotToken.set(d, after.tokenOf.get(d) ?? null);
|
|
176
|
+
}
|
|
177
|
+
// 4. Candidate set = union of both snapshots' descendants; add the root
|
|
178
|
+
// only when it did NOT exit on its own (skip stale-root kill per spec).
|
|
179
|
+
const candidates = new Set(initial);
|
|
180
|
+
for (const d of fresh) candidates.add(d);
|
|
181
|
+
if (!exited) candidates.add(pid);
|
|
182
|
+
if (candidates.size === 0) return true;
|
|
183
|
+
// 5. Force-kill. Snapshot #2 is the pre-kill verify table (captured just
|
|
184
|
+
// now, immediately before signalling) — no per-pid queries.
|
|
185
|
+
await killVerified([...candidates], snapshotToken, after.tokenOf);
|
|
186
|
+
return true;
|
|
187
|
+
}
|