@claude-flow/cli 3.44.0 → 3.46.0
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/.claude/helpers/helpers.manifest.json +2 -2
- package/README.md +2 -0
- package/bin/cli.js +13 -0
- package/bin/mcp-server.js +16 -0
- package/catalog-manifest.json +2 -2
- package/dist/src/commands/doctor.d.ts +12 -0
- package/dist/src/commands/doctor.js +200 -3
- package/dist/src/commands/embeddings.js +50 -7
- package/dist/src/commands/init.js +5 -2
- package/dist/src/commands/mcp.js +27 -4
- package/dist/src/mcp-server.d.ts +50 -6
- package/dist/src/mcp-server.js +267 -33
- package/dist/src/mcp-tools/agent-execute-core.js +7 -0
- package/dist/src/mcp-tools/agentdb-tools.js +20 -8
- package/dist/src/mcp-tools/browser-tools.js +30 -23
- package/dist/src/mcp-tools/memory-tools.js +9 -4
- package/dist/src/mcp-tools/metaharness-tools.js +1 -1
- package/dist/src/mcp-tools/policy-enforcer.d.ts +4 -4
- package/dist/src/mcp-tools/policy-enforcer.js +4 -4
- package/dist/src/mcp-tools/terminal-tools.js +11 -1
- package/dist/src/mcp-tools/workflow-tools.js +1 -1
- package/dist/src/memory/ewc-consolidation.d.ts +16 -1
- package/dist/src/memory/ewc-consolidation.js +63 -13
- package/dist/src/memory/intelligence.d.ts +6 -2
- package/dist/src/memory/intelligence.js +18 -11
- package/dist/src/memory/memory-bridge.js +84 -0
- package/dist/src/ruvector/model-router.js +19 -1
- package/dist/src/services/distill-oracle.js +2 -1
- package/dist/src/services/git-workspace-identity.js +5 -8
- package/dist/src/services/policy-runtime.js +114 -4
- package/dist/src/services/worker-daemon.js +8 -3
- package/node_modules/@claude-flow/codex/dist/cli.js +0 -0
- package/node_modules/@claude-flow/codex/dist/worktrees/coordinator.d.ts.map +1 -1
- package/node_modules/@claude-flow/codex/dist/worktrees/coordinator.js +2 -6
- package/node_modules/@claude-flow/codex/dist/worktrees/coordinator.js.map +1 -1
- package/node_modules/@claude-flow/mcp/dist/transport/http.d.ts.map +1 -1
- package/node_modules/@claude-flow/mcp/dist/transport/http.js +14 -1
- package/node_modules/@claude-flow/mcp/dist/transport/http.js.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/a2a/planner-descriptor.d.ts +40 -0
- package/node_modules/@claude-flow/plugin-agent-federation/dist/a2a/planner-descriptor.d.ts.map +1 -0
- package/node_modules/@claude-flow/plugin-agent-federation/dist/a2a/planner-descriptor.js +100 -0
- package/node_modules/@claude-flow/plugin-agent-federation/dist/a2a/planner-descriptor.js.map +1 -0
- package/node_modules/@claude-flow/plugin-agent-federation/dist/bin.js +0 -0
- package/node_modules/@claude-flow/plugin-agent-federation/dist/index.d.ts +1 -0
- package/node_modules/@claude-flow/plugin-agent-federation/dist/index.d.ts.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/dist/index.js +1 -0
- package/node_modules/@claude-flow/plugin-agent-federation/dist/index.js.map +1 -1
- package/node_modules/@claude-flow/plugin-agent-federation/package.json +1 -0
- package/node_modules/@claude-flow/security/dist/safe-git.d.ts +22 -0
- package/node_modules/@claude-flow/security/dist/safe-git.d.ts.map +1 -0
- package/node_modules/@claude-flow/security/dist/safe-git.js +63 -0
- package/node_modules/@claude-flow/security/dist/safe-git.js.map +1 -0
- package/package.json +5 -5
- package/plugins/ruflo-metaharness/agents/metaharness-architect.md +1 -1
- package/plugins/ruflo-metaharness/commands/ruflo-metaharness.md +1 -1
- package/plugins/ruflo-metaharness/scripts/_darwin.mjs +84 -12
- package/plugins/ruflo-metaharness/scripts/_harness.mjs +10 -4
- package/plugins/ruflo-metaharness/scripts/_invoke.mjs +120 -6
- package/plugins/ruflo-metaharness/scripts/_redblue.mjs +33 -6
- package/plugins/ruflo-metaharness/scripts/audit-list.mjs +11 -10
- package/plugins/ruflo-metaharness/scripts/audit-trend.mjs +8 -7
- package/plugins/ruflo-metaharness/scripts/drift-from-history.mjs +2 -3
- package/plugins/ruflo-metaharness/scripts/oia-audit.mjs +8 -7
- package/plugins/ruflo-metaharness/scripts/similarity.mjs +9 -8
- package/plugins/ruflo-metaharness/scripts/smoke.sh +31 -0
- package/plugins/ruflo-metaharness/scripts/test-no-tool-time-npx.mjs +319 -0
- package/plugins/ruflo-metaharness/scripts/test-pin-alignment.mjs +182 -0
- package/plugins/ruflo-metaharness/skills/harness-evolve/SKILL.md +4 -2
- package/plugins/ruflo-metaharness/skills/harness-gepa/SKILL.md +1 -1
- package/plugins/ruflo-metaharness/skills/harness-learn/SKILL.md +1 -1
- package/plugins/ruflo-metaharness/skills/harness-mcp-scan/SKILL.md +1 -1
- package/plugins/ruflo-metaharness/skills/harness-score/SKILL.md +1 -1
- package/plugins/ruflo-metaharness/skills/harness-security-bench/SKILL.md +3 -1
- package/plugins/ruflo-metaharness/skills/harness-threat-model/SKILL.md +1 -1
package/dist/src/mcp-server.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export interface MCPServerOptions {
|
|
|
29
29
|
tools?: string[] | 'all';
|
|
30
30
|
daemonize?: boolean;
|
|
31
31
|
timeout?: number;
|
|
32
|
+
requestTimeoutMs?: number;
|
|
32
33
|
}
|
|
33
34
|
/**
|
|
34
35
|
* MCP Server status
|
|
@@ -85,6 +86,21 @@ export declare class MCPServerManager extends EventEmitter {
|
|
|
85
86
|
private startTime?;
|
|
86
87
|
private healthCheckInterval?;
|
|
87
88
|
private mcpServers;
|
|
89
|
+
/**
|
|
90
|
+
* This manager's own lifecycle (#3364). A stdio server keeps no PID record,
|
|
91
|
+
* so nothing on disk can answer "is this manager running?" — only the
|
|
92
|
+
* manager can. `idle` is a manager that has never started, and only there
|
|
93
|
+
* does the #2934 fallback in getStatus() ("assume a client-launched stdio
|
|
94
|
+
* server") apply; `stopped` is a handle on a server that is gone, and it
|
|
95
|
+
* says so rather than reporting whatever PID the shared file happens to hold.
|
|
96
|
+
*/
|
|
97
|
+
private lifecycle;
|
|
98
|
+
/**
|
|
99
|
+
* The exact bytes this manager wrote to the PID file, while they are still
|
|
100
|
+
* there (#3364). stop() retracts the record only while it is byte for byte
|
|
101
|
+
* the one we wrote: the slot may have changed hands in between.
|
|
102
|
+
*/
|
|
103
|
+
private ownedRecord;
|
|
88
104
|
constructor(options?: MCPServerOptions);
|
|
89
105
|
/**
|
|
90
106
|
* Start the MCP server
|
|
@@ -136,15 +152,43 @@ export declare class MCPServerManager extends EventEmitter {
|
|
|
136
152
|
*/
|
|
137
153
|
private startHealthMonitoring;
|
|
138
154
|
/**
|
|
139
|
-
* Write PID
|
|
155
|
+
* Write the PID record.
|
|
156
|
+
*
|
|
157
|
+
* Line 1 is the bare PID, byte for byte what this file has always held, so
|
|
158
|
+
* every existing reader keeps working: an older ruflo's
|
|
159
|
+
* `parseInt(content.trim(), 10)` stops at the newline, and
|
|
160
|
+
* `v3/scripts/start-mcp.sh` reads the first line. Line 2 is the durable
|
|
161
|
+
* identity of the instance that wrote it (#3364). Returns the bytes written,
|
|
162
|
+
* which stop() uses to retract only its own record.
|
|
140
163
|
*/
|
|
141
164
|
private writePidFile;
|
|
142
165
|
/**
|
|
143
|
-
* Read PID
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
*
|
|
166
|
+
* Read the PID record.
|
|
167
|
+
*
|
|
168
|
+
* A bare-integer file — an older ruflo, `start-mcp.sh --daemon`, or a
|
|
169
|
+
* hand-written one — parses to a record with no identity, which keeps
|
|
170
|
+
* exactly the old behaviour: the PID is checked for liveness and nothing
|
|
171
|
+
* more. An identity line is only believed for the PID it names.
|
|
172
|
+
*/
|
|
173
|
+
private readPidRecord;
|
|
174
|
+
/**
|
|
175
|
+
* Is the recorded server still the instance the record names? (#3364)
|
|
176
|
+
*
|
|
177
|
+
* `kill -0`, and isProcessRunning()'s process-name check on top of it, only
|
|
178
|
+
* answer "something with this number is alive". Durable identity answers
|
|
179
|
+
* "it is still the one we wrote down":
|
|
180
|
+
* - another host, OS, kernel boot or PID namespace issues its own PIDs, so
|
|
181
|
+
* the number says nothing here. On Linux this is the ordinary stale case,
|
|
182
|
+
* because /tmp commonly survives a reboot;
|
|
183
|
+
* - within one boot the OS reuses a PID once the process is reaped, and the
|
|
184
|
+
* owner's start time is what tells the two apart.
|
|
185
|
+
* Where the start time cannot be read — Windows — the answer falls back to
|
|
186
|
+
* isProcessRunning(), i.e. exactly the evidence used today.
|
|
187
|
+
*/
|
|
188
|
+
private recordedServerIsLive;
|
|
189
|
+
/**
|
|
190
|
+
* Remove PID file. With `ownedRecord`, only while the file still holds
|
|
191
|
+
* exactly those bytes: the slot may have changed hands (#3364).
|
|
148
192
|
*/
|
|
149
193
|
private removePidFile;
|
|
150
194
|
/**
|
package/dist/src/mcp-server.js
CHANGED
|
@@ -30,6 +30,73 @@ import { isPolicyEnforcementEnabled, loadMcpPolicy, evaluateToolCall, } from './
|
|
|
30
30
|
// ESM-compatible __dirname
|
|
31
31
|
const __filename = fileURLToPath(import.meta.url);
|
|
32
32
|
const __dirname = dirname(__filename);
|
|
33
|
+
/**
|
|
34
|
+
* Linux PID namespace of this process, e.g. `pid:[4026531836]`; undefined on
|
|
35
|
+
* other platforms or without /proc. A PID from another namespace (a container,
|
|
36
|
+
* a `bwrap --unshare-pid` sandbox) names a different process here.
|
|
37
|
+
*/
|
|
38
|
+
const PID_NAMESPACE = (() => {
|
|
39
|
+
try {
|
|
40
|
+
return fs.readlinkSync('/proc/self/ns/pid');
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
})();
|
|
46
|
+
/**
|
|
47
|
+
* Linux boot id: one uuid per kernel boot, shared by every process on that
|
|
48
|
+
* kernel and readable inside containers. `host` is only a name, and many
|
|
49
|
+
* distributions keep /tmp across a reboot — ADR-071 already names
|
|
50
|
+
* `/tmp/claude-flow-mcp.pid` as machine-wide on Linux — so without this a
|
|
51
|
+
* record written before the machine came back up looks exactly like a live one.
|
|
52
|
+
*/
|
|
53
|
+
const BOOT_ID = (() => {
|
|
54
|
+
try {
|
|
55
|
+
return fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
/**
|
|
62
|
+
* A token that changes when a PID is handed to a different process: the
|
|
63
|
+
* owner's start time.
|
|
64
|
+
*
|
|
65
|
+
* Linux reports it in /proc/<pid>/stat field 22 as clock ticks since boot,
|
|
66
|
+
* which is durable together with BOOT_ID above. Elsewhere `ps -o lstart=`
|
|
67
|
+
* reports an absolute wall-clock time, durable on its own. Returns undefined
|
|
68
|
+
* where neither exists — on Windows, so PID reuse stays unqualified there,
|
|
69
|
+
* exactly as isProcessRunning()'s process-name check already is.
|
|
70
|
+
*/
|
|
71
|
+
function processStartToken(pid) {
|
|
72
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
73
|
+
return undefined;
|
|
74
|
+
// DA-CRIT-3: validate the PID numerically and pass it as an argv entry,
|
|
75
|
+
// never interpolated into a shell string.
|
|
76
|
+
const safePid = String(Math.floor(pid));
|
|
77
|
+
try {
|
|
78
|
+
// `pid (comm) state ppid ...` — comm is parenthesised and may itself
|
|
79
|
+
// contain spaces and parentheses, so parse after the last ')'.
|
|
80
|
+
const stat = fs.readFileSync(`/proc/${safePid}/stat`, 'utf8');
|
|
81
|
+
const afterComm = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
82
|
+
const starttime = afterComm[19];
|
|
83
|
+
if (starttime && /^\d+$/.test(starttime))
|
|
84
|
+
return starttime;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Not Linux, or no /proc — fall through to ps.
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const lstart = execFileSync('ps', ['-p', safePid, '-o', 'lstart='], {
|
|
91
|
+
encoding: 'utf8',
|
|
92
|
+
timeout: 1000,
|
|
93
|
+
}).trim();
|
|
94
|
+
return lstart || undefined;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
33
100
|
/**
|
|
34
101
|
* Default configuration
|
|
35
102
|
*/
|
|
@@ -42,6 +109,7 @@ const DEFAULT_OPTIONS = {
|
|
|
42
109
|
tools: 'all',
|
|
43
110
|
daemonize: false,
|
|
44
111
|
timeout: 30000,
|
|
112
|
+
requestTimeoutMs: 30000,
|
|
45
113
|
};
|
|
46
114
|
export function parseMcpToolSelection(value) {
|
|
47
115
|
if (!value || value.trim().toLowerCase() === 'all')
|
|
@@ -103,6 +171,21 @@ export class MCPServerManager extends EventEmitter {
|
|
|
103
171
|
startTime;
|
|
104
172
|
healthCheckInterval;
|
|
105
173
|
mcpServers = [];
|
|
174
|
+
/**
|
|
175
|
+
* This manager's own lifecycle (#3364). A stdio server keeps no PID record,
|
|
176
|
+
* so nothing on disk can answer "is this manager running?" — only the
|
|
177
|
+
* manager can. `idle` is a manager that has never started, and only there
|
|
178
|
+
* does the #2934 fallback in getStatus() ("assume a client-launched stdio
|
|
179
|
+
* server") apply; `stopped` is a handle on a server that is gone, and it
|
|
180
|
+
* says so rather than reporting whatever PID the shared file happens to hold.
|
|
181
|
+
*/
|
|
182
|
+
lifecycle = 'idle';
|
|
183
|
+
/**
|
|
184
|
+
* The exact bytes this manager wrote to the PID file, while they are still
|
|
185
|
+
* there (#3364). stop() retracts the record only while it is byte for byte
|
|
186
|
+
* the one we wrote: the slot may have changed hands in between.
|
|
187
|
+
*/
|
|
188
|
+
ownedRecord = null;
|
|
106
189
|
constructor(options = {}) {
|
|
107
190
|
super();
|
|
108
191
|
// `options.tools`, populated by the `mcp start --tools` CLI flag, is
|
|
@@ -118,12 +201,35 @@ export class MCPServerManager extends EventEmitter {
|
|
|
118
201
|
* Start the MCP server
|
|
119
202
|
*/
|
|
120
203
|
async start() {
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
204
|
+
// #3364: the PID file is a single slot per os.tmpdir() (per user on
|
|
205
|
+
// macOS/Windows; the machine-wide /tmp on Linux unless TMPDIR is set), and
|
|
206
|
+
// it records the port-bound (http/websocket) server, where a second
|
|
207
|
+
// instance would conflict. A stdio server is owned by the client that
|
|
208
|
+
// spawned it and any number run side by side (one per MCP client or
|
|
209
|
+
// project), so a stdio server never claims that slot, and never refuses to
|
|
210
|
+
// start over — or clears — a live server's record. ADR-071 made this
|
|
211
|
+
// singleton safe against self-detection and PID reuse; a stdio server has
|
|
212
|
+
// no business claiming it at all. What it does instead is track its own
|
|
213
|
+
// lifecycle, because for a server that leaves no record that is the only
|
|
214
|
+
// evidence there is.
|
|
215
|
+
const usesPidFile = this.options.transport !== 'stdio';
|
|
216
|
+
// Refuse to start a second port-bound server over a live one. The record
|
|
217
|
+
// is read directly rather than through getStatus(), whose #2934 fallback
|
|
218
|
+
// answers about this process, not about the recorded server — and whose
|
|
219
|
+
// own PID would otherwise have to be filtered out again here.
|
|
220
|
+
if (usesPidFile) {
|
|
221
|
+
const record = await this.readPidRecord();
|
|
222
|
+
if (record && this.recordedServerIsLive(record)) {
|
|
223
|
+
if (record.pid !== process.pid) {
|
|
224
|
+
throw new Error(`MCP Server already running (PID: ${record.pid})`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
else if (record) {
|
|
228
|
+
// Stale record — a dead PID, a PID since handed to another process, or
|
|
229
|
+
// an identity from another host, kernel boot or PID namespace. Cleaned
|
|
230
|
+
// up here, as the getStatus() call this replaced used to do.
|
|
231
|
+
await this.removePidFile();
|
|
232
|
+
}
|
|
127
233
|
}
|
|
128
234
|
const startTime = performance.now();
|
|
129
235
|
this.startTime = new Date();
|
|
@@ -137,9 +243,12 @@ export class MCPServerManager extends EventEmitter {
|
|
|
137
243
|
// For HTTP/WebSocket, start in-process server
|
|
138
244
|
await this.startHttpServer();
|
|
139
245
|
}
|
|
246
|
+
this.lifecycle = 'running';
|
|
140
247
|
const duration = performance.now() - startTime;
|
|
141
|
-
// Write PID file
|
|
142
|
-
|
|
248
|
+
// Write PID file, and remember the bytes so stop() retracts only ours
|
|
249
|
+
if (usesPidFile) {
|
|
250
|
+
this.ownedRecord = await this.writePidFile();
|
|
251
|
+
}
|
|
143
252
|
// Start health check monitoring
|
|
144
253
|
this.startHealthMonitoring();
|
|
145
254
|
const finalStatus = await this.getStatus();
|
|
@@ -192,8 +301,19 @@ export class MCPServerManager extends EventEmitter {
|
|
|
192
301
|
this.mcpServers = [];
|
|
193
302
|
await Promise.all(servers.map((server) => server.stop()));
|
|
194
303
|
}
|
|
195
|
-
//
|
|
196
|
-
|
|
304
|
+
// #3364: retract the record only if this manager wrote it, and only
|
|
305
|
+
// while it is still ours byte for byte — another server may have taken
|
|
306
|
+
// the slot. A live stdio server wrote nothing, so it has nothing to
|
|
307
|
+
// retract; a manager that never started — the one `mcp stop` builds —
|
|
308
|
+
// still clears the recorded server, exactly as before.
|
|
309
|
+
if (this.ownedRecord !== null) {
|
|
310
|
+
await this.removePidFile(this.ownedRecord);
|
|
311
|
+
}
|
|
312
|
+
else if (this.lifecycle !== 'running' || this.options.transport !== 'stdio') {
|
|
313
|
+
await this.removePidFile();
|
|
314
|
+
}
|
|
315
|
+
this.lifecycle = 'stopped';
|
|
316
|
+
this.ownedRecord = null;
|
|
197
317
|
this.startTime = undefined;
|
|
198
318
|
this.emit('stopped');
|
|
199
319
|
}
|
|
@@ -206,9 +326,28 @@ export class MCPServerManager extends EventEmitter {
|
|
|
206
326
|
* Get server status
|
|
207
327
|
*/
|
|
208
328
|
async getStatus() {
|
|
329
|
+
// #3364: this manager's own lifecycle is the authority on this manager.
|
|
330
|
+
// While it is serving stdio it IS the server — it never wrote a record, so
|
|
331
|
+
// a recorded PID belongs to somebody else. Once it has stopped it is not
|
|
332
|
+
// running, whatever the record says. The marker is live lifecycle, not a
|
|
333
|
+
// one-way "has served stdio" flag that survives the server it described.
|
|
334
|
+
if (this.lifecycle === 'running' && this.options.transport === 'stdio') {
|
|
335
|
+
return {
|
|
336
|
+
running: true,
|
|
337
|
+
pid: process.pid,
|
|
338
|
+
transport: 'stdio',
|
|
339
|
+
startedAt: this.startTime?.toISOString(),
|
|
340
|
+
uptime: this.startTime
|
|
341
|
+
? Math.floor((Date.now() - this.startTime.getTime()) / 1000)
|
|
342
|
+
: undefined,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
if (this.lifecycle === 'stopped') {
|
|
346
|
+
return { running: false };
|
|
347
|
+
}
|
|
209
348
|
// Check PID file
|
|
210
|
-
const
|
|
211
|
-
if (!
|
|
349
|
+
const record = await this.readPidRecord();
|
|
350
|
+
if (!record) {
|
|
212
351
|
// No PID file found. Detect if we are running in stdio mode
|
|
213
352
|
// (e.g., launched by Claude Code via `claude mcp add`).
|
|
214
353
|
const isStdio = !process.stdin.isTTY;
|
|
@@ -226,9 +365,8 @@ export class MCPServerManager extends EventEmitter {
|
|
|
226
365
|
}
|
|
227
366
|
return { running: false };
|
|
228
367
|
}
|
|
229
|
-
// Check
|
|
230
|
-
|
|
231
|
-
if (!isRunning) {
|
|
368
|
+
// Check that the recorded server is still the instance the record names
|
|
369
|
+
if (!this.recordedServerIsLive(record)) {
|
|
232
370
|
// Clean up stale PID file
|
|
233
371
|
await this.removePidFile();
|
|
234
372
|
return { running: false };
|
|
@@ -236,7 +374,7 @@ export class MCPServerManager extends EventEmitter {
|
|
|
236
374
|
// Build status
|
|
237
375
|
const status = {
|
|
238
376
|
running: true,
|
|
239
|
-
pid,
|
|
377
|
+
pid: record.pid,
|
|
240
378
|
transport: this.options.transport,
|
|
241
379
|
host: this.options.host,
|
|
242
380
|
port: this.options.port,
|
|
@@ -256,12 +394,23 @@ export class MCPServerManager extends EventEmitter {
|
|
|
256
394
|
*/
|
|
257
395
|
async checkHealth() {
|
|
258
396
|
if (this.options.transport === 'stdio') {
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
397
|
+
// #3364: while this manager is serving stdio it IS the server, so it is
|
|
398
|
+
// healthy by definition and must not read — or, from the 30s monitor in
|
|
399
|
+
// startHealthMonitoring(), clear — the record it deliberately never
|
|
400
|
+
// wrote. Once it has stopped it is not healthy either: it is a handle on
|
|
401
|
+
// a dead server, not a window onto whatever PID the shared file holds.
|
|
402
|
+
if (this.lifecycle === 'running') {
|
|
403
|
+
return { healthy: true };
|
|
404
|
+
}
|
|
405
|
+
if (this.lifecycle === 'stopped') {
|
|
406
|
+
return { healthy: false, error: 'Server stopped' };
|
|
407
|
+
}
|
|
408
|
+
// For stdio, check if the recorded process is running
|
|
409
|
+
const record = await this.readPidRecord();
|
|
410
|
+
if (record === null) {
|
|
262
411
|
return { healthy: false, error: 'No PID file found' };
|
|
263
412
|
}
|
|
264
|
-
if (!this.
|
|
413
|
+
if (!this.recordedServerIsLive(record)) {
|
|
265
414
|
// Clean up stale PID file
|
|
266
415
|
await this.removePidFile();
|
|
267
416
|
return { healthy: false, error: 'Process not running (cleaned up stale PID)' };
|
|
@@ -613,6 +762,7 @@ export class MCPServerManager extends EventEmitter {
|
|
|
613
762
|
port: this.options.port,
|
|
614
763
|
enableMetrics: true,
|
|
615
764
|
enableCaching: true,
|
|
765
|
+
requestTimeout: this.options.requestTimeoutMs,
|
|
616
766
|
}, logger);
|
|
617
767
|
const registration = mcpServer.registerTools(cliTools);
|
|
618
768
|
if (registration.failed.length > 0) {
|
|
@@ -674,34 +824,118 @@ export class MCPServerManager extends EventEmitter {
|
|
|
674
824
|
this.healthCheckInterval.unref();
|
|
675
825
|
}
|
|
676
826
|
/**
|
|
677
|
-
* Write PID
|
|
827
|
+
* Write the PID record.
|
|
828
|
+
*
|
|
829
|
+
* Line 1 is the bare PID, byte for byte what this file has always held, so
|
|
830
|
+
* every existing reader keeps working: an older ruflo's
|
|
831
|
+
* `parseInt(content.trim(), 10)` stops at the newline, and
|
|
832
|
+
* `v3/scripts/start-mcp.sh` reads the first line. Line 2 is the durable
|
|
833
|
+
* identity of the instance that wrote it (#3364). Returns the bytes written,
|
|
834
|
+
* which stop() uses to retract only its own record.
|
|
678
835
|
*/
|
|
679
836
|
async writePidFile() {
|
|
680
837
|
const pid = this.process?.pid || process.pid;
|
|
681
|
-
|
|
838
|
+
const identity = {
|
|
839
|
+
v: 1,
|
|
840
|
+
pid,
|
|
841
|
+
host: os.hostname(),
|
|
842
|
+
platform: process.platform,
|
|
843
|
+
pidns: PID_NAMESPACE,
|
|
844
|
+
boot: BOOT_ID,
|
|
845
|
+
start: processStartToken(pid),
|
|
846
|
+
transport: this.options.transport,
|
|
847
|
+
port: this.options.port,
|
|
848
|
+
startedAt: (this.startTime ?? new Date()).toISOString(),
|
|
849
|
+
};
|
|
850
|
+
const record = `${pid}\n${JSON.stringify(identity)}\n`;
|
|
851
|
+
await fs.promises.writeFile(this.options.pidFile, record, 'utf8');
|
|
852
|
+
return record;
|
|
682
853
|
}
|
|
683
854
|
/**
|
|
684
|
-
* Read PID
|
|
855
|
+
* Read the PID record.
|
|
856
|
+
*
|
|
857
|
+
* A bare-integer file — an older ruflo, `start-mcp.sh --daemon`, or a
|
|
858
|
+
* hand-written one — parses to a record with no identity, which keeps
|
|
859
|
+
* exactly the old behaviour: the PID is checked for liveness and nothing
|
|
860
|
+
* more. An identity line is only believed for the PID it names.
|
|
685
861
|
*/
|
|
686
|
-
async
|
|
862
|
+
async readPidRecord() {
|
|
687
863
|
try {
|
|
688
|
-
const
|
|
689
|
-
const
|
|
690
|
-
|
|
864
|
+
const raw = await fs.promises.readFile(this.options.pidFile, 'utf8');
|
|
865
|
+
const newline = raw.indexOf('\n');
|
|
866
|
+
const pid = parseInt((newline === -1 ? raw : raw.slice(0, newline)).trim(), 10);
|
|
867
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
868
|
+
return null;
|
|
869
|
+
let identity;
|
|
870
|
+
const rest = newline === -1 ? '' : raw.slice(newline + 1).trim();
|
|
871
|
+
if (rest) {
|
|
872
|
+
try {
|
|
873
|
+
const parsed = JSON.parse(rest);
|
|
874
|
+
if (parsed && parsed.v === 1 && parsed.pid === pid)
|
|
875
|
+
identity = parsed;
|
|
876
|
+
}
|
|
877
|
+
catch {
|
|
878
|
+
// Truncated, or a second line we don't recognise — PID only.
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return { pid, raw, identity };
|
|
691
882
|
}
|
|
692
883
|
catch {
|
|
693
884
|
return null;
|
|
694
885
|
}
|
|
695
886
|
}
|
|
696
887
|
/**
|
|
697
|
-
*
|
|
888
|
+
* Is the recorded server still the instance the record names? (#3364)
|
|
889
|
+
*
|
|
890
|
+
* `kill -0`, and isProcessRunning()'s process-name check on top of it, only
|
|
891
|
+
* answer "something with this number is alive". Durable identity answers
|
|
892
|
+
* "it is still the one we wrote down":
|
|
893
|
+
* - another host, OS, kernel boot or PID namespace issues its own PIDs, so
|
|
894
|
+
* the number says nothing here. On Linux this is the ordinary stale case,
|
|
895
|
+
* because /tmp commonly survives a reboot;
|
|
896
|
+
* - within one boot the OS reuses a PID once the process is reaped, and the
|
|
897
|
+
* owner's start time is what tells the two apart.
|
|
898
|
+
* Where the start time cannot be read — Windows — the answer falls back to
|
|
899
|
+
* isProcessRunning(), i.e. exactly the evidence used today.
|
|
698
900
|
*/
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
901
|
+
recordedServerIsLive(record) {
|
|
902
|
+
const { pid, identity } = record;
|
|
903
|
+
if (identity) {
|
|
904
|
+
if (identity.host !== os.hostname() ||
|
|
905
|
+
identity.platform !== process.platform ||
|
|
906
|
+
identity.pidns !== PID_NAMESPACE ||
|
|
907
|
+
identity.boot !== BOOT_ID) {
|
|
908
|
+
return false;
|
|
909
|
+
}
|
|
910
|
+
if (identity.start !== undefined) {
|
|
911
|
+
const start = processStartToken(pid);
|
|
912
|
+
if (start !== undefined && start !== identity.start)
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
702
915
|
}
|
|
703
|
-
|
|
704
|
-
|
|
916
|
+
return this.isProcessRunning(pid);
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Remove PID file. With `ownedRecord`, only while the file still holds
|
|
920
|
+
* exactly those bytes: the slot may have changed hands (#3364).
|
|
921
|
+
*/
|
|
922
|
+
async removePidFile(ownedRecord) {
|
|
923
|
+
let ours = true;
|
|
924
|
+
if (ownedRecord !== undefined) {
|
|
925
|
+
try {
|
|
926
|
+
ours = (await fs.promises.readFile(this.options.pidFile, 'utf8')) === ownedRecord;
|
|
927
|
+
}
|
|
928
|
+
catch {
|
|
929
|
+
ours = false; // Already gone
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
if (ours) {
|
|
933
|
+
try {
|
|
934
|
+
await fs.promises.unlink(this.options.pidFile);
|
|
935
|
+
}
|
|
936
|
+
catch {
|
|
937
|
+
// Ignore errors
|
|
938
|
+
}
|
|
705
939
|
}
|
|
706
940
|
// Also clean up legacy PID file location from older versions
|
|
707
941
|
try {
|
|
@@ -454,7 +454,14 @@ export async function executeAgentTask(input) {
|
|
|
454
454
|
// Kept for legacy error-path remediation message + final-result `model` field
|
|
455
455
|
// (returned when the request fully fails with no successful retry).
|
|
456
456
|
const anthropicModel = firstCallModel;
|
|
457
|
+
// The agent registry persists per-agent instructions, including those set
|
|
458
|
+
// through agent_update. Workflow steps do not supply a systemPrompt, so
|
|
459
|
+
// omitting this fallback silently discards the configured agent context.
|
|
460
|
+
const configuredInstructions = typeof agent.config?.instructions === 'string' && agent.config.instructions.trim()
|
|
461
|
+
? agent.config.instructions
|
|
462
|
+
: undefined;
|
|
457
463
|
const systemPrompt = input.systemPrompt ||
|
|
464
|
+
configuredInstructions ||
|
|
458
465
|
`You are a ${agent.agentType} agent operating as part of a Ruflo swarm. ` +
|
|
459
466
|
`Agent ID: ${input.agentId}. Domain: ${agent.domain ?? 'general'}. ` +
|
|
460
467
|
`Respond directly and stay focused on the task. If you need information you don't have, state that explicitly.`;
|
|
@@ -1060,7 +1060,7 @@ export const agentdbCausalNodeDelete = {
|
|
|
1060
1060
|
};
|
|
1061
1061
|
export const agentdbGraphQuery = {
|
|
1062
1062
|
name: 'agentdb_graph-query',
|
|
1063
|
-
description: 'Unified graph traversal across the knowledge graph (ADR-130).
|
|
1063
|
+
description: 'Unified graph traversal across the knowledge graph (ADR-130). Native graph-node k-hop cannot filter by relation and returns an explicit unsupported error for that request; the SQL fallback can filter by relation. Inspect appliedDepth/truncated because SQL is bounded at 3 hops. Semantic and PageRank modes use their own backends. Use when you need structured graph traversal beyond flat memory search.',
|
|
1064
1064
|
inputSchema: {
|
|
1065
1065
|
type: 'object',
|
|
1066
1066
|
properties: {
|
|
@@ -1070,7 +1070,7 @@ export const agentdbGraphQuery = {
|
|
|
1070
1070
|
enum: ['k-hop', 'semantic', 'pagerank'],
|
|
1071
1071
|
description: 'Query mode: k-hop neighbor expansion, semantic cosine search, or PageRank scoring',
|
|
1072
1072
|
},
|
|
1073
|
-
depth: { type: 'number', description: '
|
|
1073
|
+
depth: { type: 'number', description: 'Requested k-hop depth (default 2, max 5); SQL responses report appliedDepth and truncated when limited to 3' },
|
|
1074
1074
|
topK: { type: 'number', description: 'Max results for semantic and pagerank modes (default 10)' },
|
|
1075
1075
|
relation: { type: 'string', description: 'Optional edge relation filter' },
|
|
1076
1076
|
complexityBudget: {
|
|
@@ -1111,13 +1111,19 @@ export const agentdbGraphQuery = {
|
|
|
1111
1111
|
const relation = validateString(params.relation, 'relation', 200) ?? undefined;
|
|
1112
1112
|
// ── k-hop mode ──────────────────────────────────────────────────────────
|
|
1113
1113
|
if (mode === 'k-hop') {
|
|
1114
|
-
//
|
|
1114
|
+
// graph-node's kHopNeighbors API has no relation parameter. Its graph
|
|
1115
|
+
// and the SQL graph are separate stores, and SQL writes can be async;
|
|
1116
|
+
// switching stores here could report a false negative. Refuse the
|
|
1117
|
+
// unsupported native query instead of silently widening it.
|
|
1115
1118
|
try {
|
|
1116
1119
|
const graphBackend = await getGraphBackend();
|
|
1117
1120
|
if (await graphBackend.isGraphBackendAvailable()) {
|
|
1121
|
+
if (relation) {
|
|
1122
|
+
return { success: false, error: 'Native graph-node k-hop does not support relation filtering', unsupported: 'relation', mode, nodeId, relation, backend: 'graph-node' };
|
|
1123
|
+
}
|
|
1118
1124
|
const neighbors = await graphBackend.getNeighbors(nodeId, depth);
|
|
1119
1125
|
return {
|
|
1120
|
-
success: true, mode, nodeId, depth,
|
|
1126
|
+
success: true, mode, nodeId, depth, appliedDepth: depth,
|
|
1121
1127
|
results: neighbors.map(id => ({ nodeId: id })),
|
|
1122
1128
|
count: neighbors.length,
|
|
1123
1129
|
backend: 'graph-node',
|
|
@@ -1125,20 +1131,23 @@ export const agentdbGraphQuery = {
|
|
|
1125
1131
|
};
|
|
1126
1132
|
}
|
|
1127
1133
|
}
|
|
1128
|
-
catch { /* fall through to
|
|
1129
|
-
// SQL CTE fallback
|
|
1134
|
+
catch { /* fall through to SQL */ }
|
|
1135
|
+
// SQL CTE fallback is bounded at depth 3. Report that bound so callers
|
|
1136
|
+
// cannot mistake an incomplete depth-5 cycle check for a complete one.
|
|
1130
1137
|
try {
|
|
1131
1138
|
const { getBridgeDb } = await getGraphEdgeWriter();
|
|
1132
1139
|
const db = await getBridgeDb();
|
|
1133
1140
|
if (db) {
|
|
1134
|
-
const
|
|
1141
|
+
const appliedDepth = Math.min(depth, 3);
|
|
1142
|
+
const cteSql = buildKHopCTE(nodeId, appliedDepth, relation, budget.maxNodesVisited);
|
|
1135
1143
|
// graph-edge-writer returns a better-sqlite3 Database after #2431.
|
|
1136
1144
|
// `db.exec(sql, params)` (sql.js style) is a runner with no result
|
|
1137
1145
|
// on better-sqlite3 — use `prepare(sql).raw().all(...)` to get the
|
|
1138
1146
|
// same array-of-arrays shape the downstream code expects.
|
|
1139
1147
|
const rows = db.prepare(cteSql).raw().all();
|
|
1140
1148
|
return {
|
|
1141
|
-
success: true, mode, nodeId, depth,
|
|
1149
|
+
success: true, mode, nodeId, depth, appliedDepth,
|
|
1150
|
+
...(appliedDepth < depth ? { truncated: true } : {}),
|
|
1142
1151
|
results: rows.map((r) => ({ nodeId: r[0], depth: r[1] })),
|
|
1143
1152
|
count: rows.length,
|
|
1144
1153
|
backend: 'sql-cte',
|
|
@@ -1147,6 +1156,9 @@ export const agentdbGraphQuery = {
|
|
|
1147
1156
|
}
|
|
1148
1157
|
}
|
|
1149
1158
|
catch { /* db unavailable */ }
|
|
1159
|
+
if (relation) {
|
|
1160
|
+
return { success: false, error: 'Relation-filtered k-hop query requires the SQL graph_edges backend', mode, nodeId, relation };
|
|
1161
|
+
}
|
|
1150
1162
|
return { success: false, error: 'No graph backend available for k-hop query', mode, nodeId };
|
|
1151
1163
|
}
|
|
1152
1164
|
// ── semantic mode ────────────────────────────────────────────────────────
|
|
@@ -8,6 +8,34 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
8
8
|
import { validateIdentifier, validateText } from './validate-input.js';
|
|
9
9
|
// Session registry for multi-session support
|
|
10
10
|
const browserSessions = new Map();
|
|
11
|
+
/** Preserve agent-browser's JSON error on non-zero exit without echoing other process output. */
|
|
12
|
+
function browserCommandFailure(error, missingExecutable) {
|
|
13
|
+
const err = error;
|
|
14
|
+
const stdout = typeof err?.stdout === 'string'
|
|
15
|
+
? err.stdout
|
|
16
|
+
: Buffer.isBuffer(err?.stdout) ? err.stdout.toString('utf8') : '';
|
|
17
|
+
let reason;
|
|
18
|
+
if (stdout.trim()) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(stdout);
|
|
21
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
22
|
+
const failure = parsed;
|
|
23
|
+
if (failure.success === false && typeof failure.error === 'string' && failure.error.trim()) {
|
|
24
|
+
reason = failure.error.slice(0, 2000);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch { /* non-JSON stdout is not an error message */ }
|
|
29
|
+
}
|
|
30
|
+
reason ??= err?.code === 'ENOENT' && missingExecutable
|
|
31
|
+
? missingExecutable
|
|
32
|
+
: err?.code === 'ETIMEDOUT'
|
|
33
|
+
? 'agent-browser command timed out'
|
|
34
|
+
: typeof err?.status === 'number'
|
|
35
|
+
? `agent-browser exited with status ${err.status} without a JSON error`
|
|
36
|
+
: `agent-browser failed${err?.code ? ` (${err.code})` : ''} without a JSON error`;
|
|
37
|
+
return { content: [{ type: 'text', text: JSON.stringify({ success: false, error: reason }) }], isError: true };
|
|
38
|
+
}
|
|
11
39
|
/**
|
|
12
40
|
* Execute agent-browser CLI command.
|
|
13
41
|
* Tries global agent-browser first, falls back to npx if ENOENT.
|
|
@@ -43,32 +71,11 @@ export async function execBrowserCommand(args, session = 'default') {
|
|
|
43
71
|
});
|
|
44
72
|
}
|
|
45
73
|
catch (npxError) {
|
|
46
|
-
|
|
47
|
-
return {
|
|
48
|
-
content: [{
|
|
49
|
-
type: 'text',
|
|
50
|
-
text: JSON.stringify({
|
|
51
|
-
success: false,
|
|
52
|
-
error: npxErr.code === 'ENOENT'
|
|
53
|
-
? 'Neither agent-browser nor npx found. Install with: npm i -g agent-browser'
|
|
54
|
-
: npxErr instanceof Error ? npxErr.message : String(npxError),
|
|
55
|
-
}),
|
|
56
|
-
}],
|
|
57
|
-
isError: true,
|
|
58
|
-
};
|
|
74
|
+
return browserCommandFailure(npxError, 'Neither agent-browser nor npx found. Install with: npm i -g agent-browser');
|
|
59
75
|
}
|
|
60
76
|
}
|
|
61
77
|
else {
|
|
62
|
-
return
|
|
63
|
-
content: [{
|
|
64
|
-
type: 'text',
|
|
65
|
-
text: JSON.stringify({
|
|
66
|
-
success: false,
|
|
67
|
-
error: err instanceof Error ? err.message : String(error),
|
|
68
|
-
}),
|
|
69
|
-
}],
|
|
70
|
-
isError: true,
|
|
71
|
-
};
|
|
78
|
+
return browserCommandFailure(error);
|
|
72
79
|
}
|
|
73
80
|
}
|
|
74
81
|
let data;
|