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.
Files changed (90) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/channel-daemon-smoke.mjs +165 -0
  4. package/scripts/channel-daemon-stub.mjs +69 -0
  5. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  6. package/scripts/tool-smoke.mjs +30 -17
  7. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +187 -0
  8. package/src/runtime/agent/orchestrator/mcp/client.mjs +40 -3
  9. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
  10. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  11. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  12. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  13. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  14. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  15. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  16. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  18. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  20. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +10 -10
  21. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +8 -6
  22. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +14 -1
  23. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  24. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +24 -11
  25. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  26. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +23 -13
  27. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -1
  28. package/src/runtime/channels/lib/owned-runtime.mjs +126 -167
  29. package/src/runtime/channels/lib/owner-heartbeat.mjs +11 -60
  30. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  31. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  32. package/src/runtime/channels/lib/seat-lock.mjs +196 -0
  33. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  34. package/src/runtime/channels/lib/tool-dispatch.mjs +24 -8
  35. package/src/runtime/channels/lib/worker-main.mjs +42 -11
  36. package/src/runtime/memory/index.mjs +54 -7
  37. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  38. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  39. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  40. package/src/runtime/shared/atomic-file.mjs +44 -10
  41. package/src/runtime/shared/tool-primitives.mjs +31 -1
  42. package/src/runtime/shared/tool-surface.mjs +23 -13
  43. package/src/session-runtime/config-lifecycle.mjs +48 -7
  44. package/src/session-runtime/lifecycle-api.mjs +9 -0
  45. package/src/session-runtime/mcp-glue.mjs +63 -1
  46. package/src/session-runtime/resource-api.mjs +62 -8
  47. package/src/session-runtime/runtime-core.mjs +32 -2
  48. package/src/session-runtime/session-text.mjs +41 -0
  49. package/src/session-runtime/session-turn-api.mjs +24 -0
  50. package/src/session-runtime/settings-api.mjs +8 -1
  51. package/src/session-runtime/tool-catalog.mjs +306 -38
  52. package/src/session-runtime/tool-defs.mjs +7 -7
  53. package/src/session-runtime/workflow.mjs +2 -1
  54. package/src/standalone/channel-daemon-client.mjs +224 -0
  55. package/src/standalone/channel-daemon-transport.mjs +351 -0
  56. package/src/standalone/channel-daemon.mjs +139 -0
  57. package/src/standalone/channel-worker.mjs +213 -4
  58. package/src/standalone/hook-bus.mjs +71 -3
  59. package/src/tui/App.jsx +105 -17
  60. package/src/tui/app/clipboard.mjs +39 -19
  61. package/src/tui/app/doctor.mjs +57 -0
  62. package/src/tui/app/extension-pickers.mjs +53 -9
  63. package/src/tui/app/maintenance-pickers.mjs +0 -5
  64. package/src/tui/app/slash-dispatch.mjs +4 -4
  65. package/src/tui/app/text-layout.mjs +11 -0
  66. package/src/tui/app/use-mouse-input.mjs +235 -51
  67. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  68. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  69. package/src/tui/app/use-transcript-window.mjs +55 -1
  70. package/src/tui/components/Message.jsx +1 -1
  71. package/src/tui/components/PromptInput.jsx +3 -1
  72. package/src/tui/components/QueuedCommands.jsx +21 -10
  73. package/src/tui/components/ToolExecution.jsx +2 -2
  74. package/src/tui/dist/index.mjs +653 -310
  75. package/src/tui/engine/session-api.mjs +17 -7
  76. package/src/tui/engine/session-flow.mjs +6 -0
  77. package/src/tui/engine.mjs +8 -0
  78. package/src/tui/index.jsx +62 -18
  79. package/src/tui/paste-attachments.mjs +26 -0
  80. package/src/ui/statusline.mjs +45 -5
  81. package/src/ui/tool-card.mjs +8 -1
  82. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  83. package/src/workflows/bench/WORKFLOW.md +46 -0
  84. package/src/workflows/default/WORKFLOW.md +6 -0
  85. package/src/workflows/solo/WORKFLOW.md +5 -0
  86. package/vendor/ink/build/ink.js +23 -1
  87. package/vendor/ink/build/output.js +154 -71
  88. package/vendor/ink/build/render-node-to-output.js +44 -2
  89. package/vendor/ink/build/render.js +4 -0
  90. 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. Re-run it anytime with `mixdog --onboarding`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.21",
3
+ "version": "0.9.23",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -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
+ });
@@ -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', 'tool_search']) {
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', 'tool_search']) {
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
- ['tool_search', 900],
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', 'tool_search']) {
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('tool_search')) {
1363
- throw new Error(`agent session schema must not expose deferred tool_search: ${workerToolNames.join(', ')}`);
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('tool_search') || writeTools.includes('tool_search')) {
1424
- throw new Error(`agent session fixed schemas must omit tool_search: read=${readTools.join(', ')} write=${writeTools.join(', ')}`);
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 || '') || !TOOL_SEARCH_TOOL.inputSchema?.properties?.select) {
1850
- throw new Error('tool_search schema must preserve selection guidance and select field');
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
- // A plain query is a case-insensitive substring filter over name+description.
1858
- // It lists matches only it never auto-loads or ranks.
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(`tool_search plain query must not auto-load: ${JSON.stringify(listQueryResult.selected)}`);
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.matches.some((row) => row.name === 'shell')) {
1864
- throw new Error(`tool_search plain query should list substring matches: ${JSON.stringify(listQueryResult.matches)}`);
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(`tool_search plain query must not activate/discover tools: ${JSON.stringify(listQueryResult)}`);
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
+ }