ruvnet-brain 3.9.85-dev → 3.9.129-dev
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 +17 -15
- package/bin/install.mjs +698 -70
- package/kb/brain-profile.mjs +145 -0
- package/kb/model-requirements.mjs +72 -0
- package/kb/zip-extract.mjs +297 -0
- package/package.json +32 -4
- package/plugin/mcp/managed-cli-interface.mjs +236 -0
- package/plugin/mcp/server.mjs +133 -32
- package/plugin/scripts/codex-hook-wrapper.mjs +37 -0
- package/scripts/dual-host-deliberation.mjs +284 -0
- package/scripts/dual-host-suggest.mjs +58 -0
- package/scripts/hook-registry.mjs +567 -0
- package/scripts/install-scope.mjs +708 -0
- package/scripts/model-router-outcome.mjs +19 -7
- package/scripts/selfcheck.mjs +646 -0
- package/scripts/subscription-hosts.mjs +105 -0
- package/scripts/upgrade-notice.mjs +465 -0
- package/scripts/user-settings.mjs +640 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const MANAGED_EXECUTABLES = Object.freeze([
|
|
7
|
+
'ruflo',
|
|
8
|
+
'claude-flow',
|
|
9
|
+
'agentic-flow',
|
|
10
|
+
'agentic-qe',
|
|
11
|
+
'ruvector',
|
|
12
|
+
'agent-browser',
|
|
13
|
+
'ruv-swarm',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const MANAGED = new Set(MANAGED_EXECUTABLES);
|
|
17
|
+
const SUBCOMMAND = /^[a-z][a-z0-9-]*$/;
|
|
18
|
+
const MAX_ARGS = 256;
|
|
19
|
+
const MAX_ARG_BYTES = 8192;
|
|
20
|
+
const DEFAULT_FRESH_MS = 24 * 60 * 60 * 1000;
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
22
|
+
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
const executableSchema = {
|
|
25
|
+
type: 'string',
|
|
26
|
+
enum: [...MANAGED_EXECUTABLES],
|
|
27
|
+
description: 'One of the finite RuvNet ecosystem executables managed by this boundary.',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const MANAGED_CLI_TOOLS = Object.freeze([
|
|
31
|
+
{
|
|
32
|
+
name: 'ruvnet_cli_help',
|
|
33
|
+
description: 'Read a managed CLI interface from the executable itself. Runs only the supplied subcommand path plus --help and records a fresh stamp only after exit 0.',
|
|
34
|
+
inputSchema: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
properties: {
|
|
38
|
+
executable: executableSchema,
|
|
39
|
+
argv: {
|
|
40
|
+
type: 'array',
|
|
41
|
+
description: 'Zero, one, or two literal lowercase subcommand tokens. --help is appended by the server.',
|
|
42
|
+
items: { type: 'string', pattern: '^[a-z][a-z0-9-]*$' },
|
|
43
|
+
maxItems: 2,
|
|
44
|
+
default: [],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
required: ['executable', 'argv'],
|
|
48
|
+
},
|
|
49
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'ruvnet_cli_run',
|
|
53
|
+
description: 'Run a managed CLI with a literal argv array and no shell. Refuses unless the matching interface help was read successfully in the last 24 hours.',
|
|
54
|
+
inputSchema: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
properties: {
|
|
58
|
+
executable: executableSchema,
|
|
59
|
+
argv: {
|
|
60
|
+
type: 'array',
|
|
61
|
+
description: 'Literal argv tokens. Shell metacharacters are ordinary argument bytes and are never evaluated.',
|
|
62
|
+
items: { type: 'string', maxLength: MAX_ARG_BYTES },
|
|
63
|
+
maxItems: MAX_ARGS,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
required: ['executable', 'argv'],
|
|
67
|
+
},
|
|
68
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
69
|
+
},
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
function assertExecutable(executable) {
|
|
73
|
+
if (typeof executable !== 'string' || !MANAGED.has(executable)) {
|
|
74
|
+
throw new Error(`unknown managed executable: ${String(executable || '')}`);
|
|
75
|
+
}
|
|
76
|
+
return executable;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function literalArgv(argv) {
|
|
80
|
+
if (!Array.isArray(argv) || argv.length > MAX_ARGS) throw new Error('argv must be an array of at most 256 strings');
|
|
81
|
+
return argv.map((arg) => {
|
|
82
|
+
if (typeof arg !== 'string' || arg.includes('\0') || Buffer.byteLength(arg) > MAX_ARG_BYTES) {
|
|
83
|
+
throw new Error('every argv entry must be a literal string of at most 8192 bytes without NUL');
|
|
84
|
+
}
|
|
85
|
+
return arg;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function helpKey(executable, argv) {
|
|
90
|
+
const tool = assertExecutable(executable);
|
|
91
|
+
const args = literalArgv(argv);
|
|
92
|
+
const subcommands = [];
|
|
93
|
+
for (const arg of args) {
|
|
94
|
+
if (!SUBCOMMAND.test(arg)) break;
|
|
95
|
+
subcommands.push(arg);
|
|
96
|
+
if (subcommands.length === 2) break;
|
|
97
|
+
}
|
|
98
|
+
return [tool, ...subcommands].join('.');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function stampKeysForHelp(executable, argv) {
|
|
102
|
+
const tool = assertExecutable(executable);
|
|
103
|
+
const args = literalArgv(argv);
|
|
104
|
+
if (args.length > 2 || args.some((arg) => !SUBCOMMAND.test(arg))) {
|
|
105
|
+
throw new Error('invalid subcommand path: help accepts zero, one, or two lowercase tokens');
|
|
106
|
+
}
|
|
107
|
+
const exact = [tool, ...args].join('.');
|
|
108
|
+
if (args.length < 2) return [exact];
|
|
109
|
+
return [exact, [tool, args[0]].join('.')];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function stateDir(env) {
|
|
113
|
+
const brainHome = env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain');
|
|
114
|
+
return env.RUVNET_BRAIN_HELP_READ_DIR || path.join(brainHome, 'help-read');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function freshStamp(executable, argv, env, now = Date.now()) {
|
|
118
|
+
const stamp = path.join(stateDir(env), helpKey(executable, argv));
|
|
119
|
+
let stat;
|
|
120
|
+
try {
|
|
121
|
+
stat = fs.statSync(stamp);
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
const configured = Number(env.RUVNET_BRAIN_HELP_MAX_AGE_MS);
|
|
126
|
+
const maxAge = Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_FRESH_MS;
|
|
127
|
+
return now - stat.mtimeMs < maxAge;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function writeStamps(executable, argv, env) {
|
|
131
|
+
const dir = stateDir(env);
|
|
132
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
133
|
+
for (const key of stampKeysForHelp(executable, argv)) {
|
|
134
|
+
const stamp = path.join(dir, key);
|
|
135
|
+
const temporary = path.join(dir, `.${key}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
|
|
136
|
+
try {
|
|
137
|
+
fs.writeFileSync(temporary, '', { flag: 'wx', mode: 0o600 });
|
|
138
|
+
fs.renameSync(temporary, stamp);
|
|
139
|
+
} finally {
|
|
140
|
+
try { fs.unlinkSync(temporary); } catch { /* renamed or never created */ }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function execute(executable, argv, env) {
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
const child = spawn(executable, argv, {
|
|
148
|
+
env,
|
|
149
|
+
shell: false,
|
|
150
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
151
|
+
});
|
|
152
|
+
const stdout = [];
|
|
153
|
+
const stderr = [];
|
|
154
|
+
let stdoutBytes = 0;
|
|
155
|
+
let stderrBytes = 0;
|
|
156
|
+
let settled = false;
|
|
157
|
+
const append = (chunks, chunk, used) => {
|
|
158
|
+
const remaining = MAX_OUTPUT_BYTES - used;
|
|
159
|
+
if (remaining > 0) chunks.push(chunk.subarray(0, remaining));
|
|
160
|
+
return used + Math.min(chunk.length, Math.max(remaining, 0));
|
|
161
|
+
};
|
|
162
|
+
child.stdout.on('data', (chunk) => { stdoutBytes = append(stdout, chunk, stdoutBytes); });
|
|
163
|
+
child.stderr.on('data', (chunk) => { stderrBytes = append(stderr, chunk, stderrBytes); });
|
|
164
|
+
const configured = Number(env.RUVNET_BRAIN_MANAGED_CLI_TIMEOUT_MS);
|
|
165
|
+
const timeoutMs = Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_TIMEOUT_MS;
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
if (settled) return;
|
|
168
|
+
settled = true;
|
|
169
|
+
child.kill('SIGTERM');
|
|
170
|
+
resolve({ code: null, stdout: Buffer.concat(stdout).toString(), stderr: Buffer.concat(stderr).toString(), error: `timed out after ${timeoutMs}ms` });
|
|
171
|
+
}, timeoutMs);
|
|
172
|
+
child.once('error', (error) => {
|
|
173
|
+
if (settled) return;
|
|
174
|
+
settled = true;
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
resolve({ code: null, stdout: '', stderr: '', error: error.message });
|
|
177
|
+
});
|
|
178
|
+
child.once('close', (code) => {
|
|
179
|
+
if (settled) return;
|
|
180
|
+
settled = true;
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
resolve({ code, stdout: Buffer.concat(stdout).toString(), stderr: Buffer.concat(stderr).toString(), error: null });
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function resultOf(executable, argv, result) {
|
|
188
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join(result.stdout && result.stderr ? '\n' : '');
|
|
189
|
+
if (result.error || result.code !== 0) {
|
|
190
|
+
const reason = result.error || `exit ${result.code}`;
|
|
191
|
+
return {
|
|
192
|
+
content: [{ type: 'text', text: output || `${executable} ${argv.join(' ')} failed: ${reason}` }],
|
|
193
|
+
isError: true,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
content: [{ type: 'text', text: output || `${executable} ${argv.join(' ')} completed successfully` }],
|
|
198
|
+
isError: false,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function callManagedCli(toolName, args, env = process.env) {
|
|
203
|
+
try {
|
|
204
|
+
const executable = assertExecutable(args?.executable);
|
|
205
|
+
const argv = literalArgv(args?.argv);
|
|
206
|
+
|
|
207
|
+
if (toolName === 'ruvnet_cli_help') {
|
|
208
|
+
stampKeysForHelp(executable, argv);
|
|
209
|
+
const commandArgv = [...argv, '--help'];
|
|
210
|
+
const execution = await execute(executable, commandArgv, env);
|
|
211
|
+
if (execution.code === 0 && !execution.error) writeStamps(executable, argv, env);
|
|
212
|
+
return resultOf(executable, commandArgv, execution);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (toolName === 'ruvnet_cli_run') {
|
|
216
|
+
if (!freshStamp(executable, argv, env)) {
|
|
217
|
+
const key = helpKey(executable, argv).replaceAll('.', ' ');
|
|
218
|
+
return {
|
|
219
|
+
content: [{ type: 'text', text: `Read the interface first with ruvnet_cli_help for: ${key}` }],
|
|
220
|
+
isError: true,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return resultOf(executable, argv, await execute(executable, argv, env));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
content: [{ type: 'text', text: `unknown managed CLI tool: ${String(toolName || '')}` }],
|
|
228
|
+
isError: true,
|
|
229
|
+
};
|
|
230
|
+
} catch (error) {
|
|
231
|
+
return {
|
|
232
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
233
|
+
isError: true,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
package/plugin/mcp/server.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import fs from 'node:fs';
|
|
|
33
33
|
import path from 'node:path';
|
|
34
34
|
import os from 'node:os';
|
|
35
35
|
import readline from 'node:readline';
|
|
36
|
+
import { callManagedCli, MANAGED_CLI_TOOLS } from './managed-cli-interface.mjs';
|
|
36
37
|
|
|
37
38
|
const BRAIN_HOME = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
|
|
38
39
|
const KB = process.env.RUVNET_BRAIN_KB || path.join(BRAIN_HOME, 'kb');
|
|
@@ -43,9 +44,9 @@ const LEASE = path.join(LEASES, `mcp-${process.pid}.json`);
|
|
|
43
44
|
|
|
44
45
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
45
46
|
const SERVER_INFO = { name: 'ruvnet-brain', version: '2.0.0' };
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
const
|
|
47
|
+
// Local frozen tool declarations. search_ruvnet is only a fallback: a live child's declaration wins
|
|
48
|
+
// so its description can refresh. The two managed-CLI schemas are owned by this protocol shell.
|
|
49
|
+
const SEARCH_TOOL = {
|
|
49
50
|
name: 'search_ruvnet',
|
|
50
51
|
description: 'Source-grounded knowledge base for the RuvNet ecosystem. (Brain bundle not installed on this machine — calls will return install guidance.)',
|
|
51
52
|
inputSchema: {
|
|
@@ -56,7 +57,19 @@ const FALLBACK_TOOLS = [{
|
|
|
56
57
|
},
|
|
57
58
|
required: ['query'],
|
|
58
59
|
},
|
|
59
|
-
}
|
|
60
|
+
};
|
|
61
|
+
const FALLBACK_TOOLS = [SEARCH_TOOL, ...MANAGED_CLI_TOOLS];
|
|
62
|
+
|
|
63
|
+
function withLocalTools(tools) {
|
|
64
|
+
const merged = new Map();
|
|
65
|
+
for (const tool of Array.isArray(tools) ? tools : []) {
|
|
66
|
+
if (tool?.name) merged.set(tool.name, tool);
|
|
67
|
+
}
|
|
68
|
+
for (const tool of FALLBACK_TOOLS) {
|
|
69
|
+
if (!merged.has(tool.name)) merged.set(tool.name, tool);
|
|
70
|
+
}
|
|
71
|
+
return [...merged.values()];
|
|
72
|
+
}
|
|
60
73
|
|
|
61
74
|
const out = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
|
|
62
75
|
const clientOk = (id, result) => out({ jsonrpc: '2.0', id, result });
|
|
@@ -80,45 +93,117 @@ process.on('exit', () => { try { fs.rmSync(LEASE, { force: true }); } catch { /*
|
|
|
80
93
|
// ── the warm child + private protocol ───────────────────────────────────────────────────────────
|
|
81
94
|
let child = null; // { proc, generation, nextId, pending: Map<childId, {resolve, reject}> }
|
|
82
95
|
let pendingCount = 0; // client tools/call requests currently in flight (drain gate for swaps)
|
|
96
|
+
let childRetirement = Promise.resolve();
|
|
97
|
+
let childStartup = null;
|
|
98
|
+
const CHILD_TERM_GRACE_MS = 3_000;
|
|
99
|
+
const CHILD_INIT_TIMEOUT_MS = Number(process.env.RUVNET_BRAIN_INIT_TIMEOUT_MS) || 60_000;
|
|
83
100
|
|
|
84
101
|
function killChild(reason) {
|
|
85
|
-
if (!child) return;
|
|
102
|
+
if (!child) return childRetirement;
|
|
86
103
|
const c = child; child = null;
|
|
87
104
|
for (const [, p] of c.pending) p.reject(new Error(`brain worker ${reason}`));
|
|
88
105
|
c.pending.clear();
|
|
106
|
+
childRetirement = new Promise((resolve) => {
|
|
107
|
+
let settled = false;
|
|
108
|
+
const finish = () => {
|
|
109
|
+
if (settled) return;
|
|
110
|
+
settled = true;
|
|
111
|
+
clearTimeout(force);
|
|
112
|
+
resolve();
|
|
113
|
+
};
|
|
114
|
+
c.proc.once('exit', finish);
|
|
115
|
+
const force = setTimeout(() => {
|
|
116
|
+
try { c.proc.kill('SIGKILL'); } catch { /* already gone */ }
|
|
117
|
+
}, CHILD_TERM_GRACE_MS);
|
|
118
|
+
force.unref?.();
|
|
119
|
+
});
|
|
89
120
|
try { c.proc.kill('SIGTERM'); } catch { /* already dead */ }
|
|
121
|
+
return childRetirement;
|
|
90
122
|
}
|
|
91
123
|
|
|
92
124
|
async function ensureChild() {
|
|
93
125
|
const gen = currentGeneration();
|
|
94
|
-
if (child && child.generation !== gen && pendingCount === 0) killChild('superseded by a newer generation');
|
|
126
|
+
if (child && child.generation !== gen && pendingCount === 0) await killChild('superseded by a newer generation');
|
|
95
127
|
if (child) return child;
|
|
128
|
+
await childRetirement;
|
|
96
129
|
if (!fs.existsSync(CHILD_MCP)) return null;
|
|
97
130
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
131
|
+
if (!childStartup) {
|
|
132
|
+
childStartup = (async () => {
|
|
133
|
+
const env = { ...process.env, KB_DIR: KB };
|
|
134
|
+
if (!env.KB_MODEL_CACHE) env.KB_MODEL_CACHE = path.join(BRAIN_HOME, 'models');
|
|
135
|
+
const proc = spawn(process.execPath, [CHILD_MCP], { stdio: ['pipe', 'pipe', 'inherit'], env });
|
|
136
|
+
const c = { proc, generation: currentGeneration(), nextId: 1, pending: new Map() };
|
|
137
|
+
const rl = readline.createInterface({ input: proc.stdout });
|
|
138
|
+
rl.on('line', (line) => {
|
|
139
|
+
let msg; try { msg = JSON.parse(line); } catch { return; }
|
|
140
|
+
const waiter = c.pending.get(msg.id);
|
|
141
|
+
if (waiter) { c.pending.delete(msg.id); waiter.resolve(msg); }
|
|
142
|
+
});
|
|
143
|
+
proc.on('exit', () => {
|
|
144
|
+
if (child === c) child = null;
|
|
145
|
+
for (const [, p] of c.pending) p.reject(new Error('brain worker exited'));
|
|
146
|
+
c.pending.clear();
|
|
147
|
+
});
|
|
148
|
+
proc.on('error', () => { if (child === c) child = null; });
|
|
149
|
+
child = c;
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
await childRequest(c, 'initialize', {
|
|
153
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
154
|
+
capabilities: {},
|
|
155
|
+
clientInfo: { name: 'ruvnet-brain-shell', version: SERVER_INFO.version },
|
|
156
|
+
}, CHILD_INIT_TIMEOUT_MS);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
await killChild('failed initialize');
|
|
159
|
+
throw new Error(`brain worker failed to initialize: ${e.message}`);
|
|
160
|
+
}
|
|
161
|
+
return c;
|
|
162
|
+
})();
|
|
163
|
+
}
|
|
164
|
+
const attempt = childStartup;
|
|
165
|
+
try { return await attempt; }
|
|
166
|
+
finally { if (childStartup === attempt) childStartup = null; }
|
|
116
167
|
}
|
|
117
168
|
|
|
118
|
-
|
|
169
|
+
// A TIMEOUT IS AN OUTAGE, and it must both STOP and be RECORDED. Measured 2026-07-27: the timeout
|
|
170
|
+
// below deleted its pending entry and rejected, and did nothing else — so
|
|
171
|
+
// (a) the child kept computing an answer nobody would ever read, at ~95% CPU, and on the
|
|
172
|
+
// all-repos path (605 cross-encoder pairs, a fan-out that alone exceeds this deadline) that
|
|
173
|
+
// is minutes of burn per abandoned query, which then slows the RETRY, which times out too; and
|
|
174
|
+
// (b) nothing wrote health.json, so a total retrieval failure read as healthy. The live file
|
|
175
|
+
// said "status":"ok" dated four days earlier while every query was timing out.
|
|
176
|
+
// (b) is the worse half: the product reporting green while it is down is the one thing it may
|
|
177
|
+
// never do. The child's own alarm cannot cover this — it only rings when a search RETURNS failure,
|
|
178
|
+
// and a timeout is precisely the case where it never returns at all. Only the parent can see it.
|
|
179
|
+
async function onChildTimeout(method, timeoutMs) {
|
|
180
|
+
killChild(`timed out after ${timeoutMs / 1000}s on ${method}`); // stop the burn; ensureChild() respawns
|
|
181
|
+
try {
|
|
182
|
+
const alarm = await import(new URL('../../kb/brain-alarm.mjs', import.meta.url).href);
|
|
183
|
+
await alarm.reportBrainDown({
|
|
184
|
+
error: `brain worker timed out after ${timeoutMs / 1000}s on ${method} (no answer returned)`,
|
|
185
|
+
source: 'mcp-parent-timeout',
|
|
186
|
+
});
|
|
187
|
+
} catch (e) {
|
|
188
|
+
// REPORTED, never swallowed: brain-alarm.mjs lives in the KB, which can legitimately be absent
|
|
189
|
+
// or half-installed — but a health reporter that fails silently is the same lie one layer down.
|
|
190
|
+
console.error(`[ruvnet-brain] timeout on ${method} could not be recorded to health.json: ${e.message}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Overridable so the outage path above can actually be TESTED — a 120s default is untestable, and
|
|
195
|
+
// an untestable failure path is how this one went four days reporting "ok" while it was down. Also
|
|
196
|
+
// genuinely useful in the field: a slow machine can raise it, and a CI runner can lower it.
|
|
197
|
+
const CALL_TIMEOUT_MS = Number(process.env.RUVNET_BRAIN_CALL_TIMEOUT_MS) || 120_000;
|
|
198
|
+
|
|
199
|
+
function childRequest(c, method, params, timeoutMs = CALL_TIMEOUT_MS) {
|
|
119
200
|
return new Promise((resolve, reject) => {
|
|
120
201
|
const id = c.nextId++;
|
|
121
|
-
const timer = setTimeout(() => {
|
|
202
|
+
const timer = setTimeout(() => {
|
|
203
|
+
c.pending.delete(id);
|
|
204
|
+
reject(new Error(`brain worker timeout on ${method}`));
|
|
205
|
+
void onChildTimeout(method, timeoutMs); // after the reject — the caller must not wait on the alarm
|
|
206
|
+
}, timeoutMs);
|
|
122
207
|
c.pending.set(id, { resolve: (m) => { clearTimeout(timer); resolve(m); }, reject: (e) => { clearTimeout(timer); reject(e); } });
|
|
123
208
|
try { c.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); }
|
|
124
209
|
catch (e) { clearTimeout(timer); c.pending.delete(id); reject(e); }
|
|
@@ -135,17 +220,33 @@ async function handleClient(msg) {
|
|
|
135
220
|
case 'ping':
|
|
136
221
|
return clientOk(id, {});
|
|
137
222
|
case 'tools/list': {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
223
|
+
try {
|
|
224
|
+
const c = await ensureChild();
|
|
225
|
+
if (c) {
|
|
226
|
+
try {
|
|
227
|
+
const r = await childRequest(c, 'tools/list', {}, 15_000);
|
|
228
|
+
if (r.result?.tools?.length) return clientOk(id, { ...r.result, tools: withLocalTools(r.result.tools) });
|
|
229
|
+
}
|
|
230
|
+
catch { /* fall through to the static declaration */ }
|
|
231
|
+
}
|
|
232
|
+
} catch { /* fall through to the static declaration during a transient startup outage */ }
|
|
143
233
|
return clientOk(id, { tools: FALLBACK_TOOLS });
|
|
144
234
|
}
|
|
145
235
|
case 'tools/call': {
|
|
236
|
+
if (params?.name === 'ruvnet_cli_help' || params?.name === 'ruvnet_cli_run') {
|
|
237
|
+
return clientOk(id, await callManagedCli(params.name, params.arguments || {}));
|
|
238
|
+
}
|
|
146
239
|
if (params?.name !== 'search_ruvnet') return clientErr(id, -32602, `unknown tool: ${params?.name}`);
|
|
147
240
|
refreshLease();
|
|
148
|
-
|
|
241
|
+
let c;
|
|
242
|
+
try {
|
|
243
|
+
c = await ensureChild();
|
|
244
|
+
} catch (e) {
|
|
245
|
+
return clientOk(id, {
|
|
246
|
+
content: [{ type: 'text', text: `search_ruvnet error: brain worker temporarily unavailable (${e.message})` }],
|
|
247
|
+
isError: true,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
149
250
|
if (!c) {
|
|
150
251
|
return clientOk(id, { content: [{ type: 'text', text: `search_ruvnet error: the brain bundle is not installed at ${KB}. Install it with: npx github:stuinfla/ruvnet-brain (or set RUVNET_BRAIN_KB to your brain's kb dir).` }], isError: true });
|
|
151
252
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const brainHome = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
|
|
8
|
+
const versions = path.join(brainHome, 'versions');
|
|
9
|
+
|
|
10
|
+
function activeRoot() {
|
|
11
|
+
try {
|
|
12
|
+
const active = JSON.parse(fs.readFileSync(path.join(brainHome, 'active.json'), 'utf8'));
|
|
13
|
+
if (!active || typeof active.codeRoot !== 'string') return null;
|
|
14
|
+
const candidate = path.isAbsolute(active.codeRoot)
|
|
15
|
+
? active.codeRoot
|
|
16
|
+
: path.join(brainHome, active.codeRoot);
|
|
17
|
+
const real = fs.realpathSync(candidate);
|
|
18
|
+
const versionsReal = fs.realpathSync(versions);
|
|
19
|
+
return real.startsWith(`${versionsReal}${path.sep}`) ? real : null;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const input = fs.readFileSync(0);
|
|
26
|
+
const root = activeRoot();
|
|
27
|
+
const adapter = root && path.join(root, 'scripts', 'codex-hook-adapter.mjs');
|
|
28
|
+
if (!adapter || !fs.existsSync(adapter)) process.exit(0);
|
|
29
|
+
|
|
30
|
+
const result = spawnSync(process.execPath, [adapter, ...process.argv.slice(2)], {
|
|
31
|
+
input,
|
|
32
|
+
encoding: 'utf8',
|
|
33
|
+
env: process.env,
|
|
34
|
+
});
|
|
35
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
36
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
37
|
+
process.exit(result.status ?? 0);
|