@claude-flow/cli 3.45.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.
Files changed (73) hide show
  1. package/.claude/helpers/helpers.manifest.json +2 -2
  2. package/README.md +2 -0
  3. package/bin/cli.js +13 -0
  4. package/bin/mcp-server.js +16 -0
  5. package/catalog-manifest.json +2 -2
  6. package/dist/src/commands/doctor.d.ts +3 -0
  7. package/dist/src/commands/doctor.js +131 -2
  8. package/dist/src/commands/embeddings.js +50 -7
  9. package/dist/src/commands/init.js +5 -2
  10. package/dist/src/commands/mcp.js +27 -4
  11. package/dist/src/mcp-server.d.ts +50 -6
  12. package/dist/src/mcp-server.js +267 -33
  13. package/dist/src/mcp-tools/agent-execute-core.js +7 -0
  14. package/dist/src/mcp-tools/agentdb-tools.js +20 -8
  15. package/dist/src/mcp-tools/browser-tools.js +30 -23
  16. package/dist/src/mcp-tools/memory-tools.js +9 -4
  17. package/dist/src/mcp-tools/metaharness-tools.js +1 -1
  18. package/dist/src/mcp-tools/policy-enforcer.d.ts +4 -4
  19. package/dist/src/mcp-tools/policy-enforcer.js +4 -4
  20. package/dist/src/mcp-tools/terminal-tools.js +11 -1
  21. package/dist/src/mcp-tools/workflow-tools.js +1 -1
  22. package/dist/src/memory/ewc-consolidation.d.ts +16 -1
  23. package/dist/src/memory/ewc-consolidation.js +63 -13
  24. package/dist/src/memory/intelligence.d.ts +6 -2
  25. package/dist/src/memory/intelligence.js +18 -11
  26. package/dist/src/memory/memory-bridge.js +84 -0
  27. package/dist/src/ruvector/model-router.js +19 -1
  28. package/dist/src/services/distill-oracle.js +2 -1
  29. package/dist/src/services/git-workspace-identity.js +5 -8
  30. package/dist/src/services/policy-runtime.js +114 -4
  31. package/dist/src/services/worker-daemon.js +8 -3
  32. package/node_modules/@claude-flow/codex/dist/worktrees/coordinator.d.ts.map +1 -1
  33. package/node_modules/@claude-flow/codex/dist/worktrees/coordinator.js +2 -6
  34. package/node_modules/@claude-flow/codex/dist/worktrees/coordinator.js.map +1 -1
  35. package/node_modules/@claude-flow/mcp/dist/transport/http.d.ts.map +1 -1
  36. package/node_modules/@claude-flow/mcp/dist/transport/http.js +14 -1
  37. package/node_modules/@claude-flow/mcp/dist/transport/http.js.map +1 -1
  38. package/node_modules/@claude-flow/plugin-agent-federation/dist/a2a/planner-descriptor.d.ts +40 -0
  39. package/node_modules/@claude-flow/plugin-agent-federation/dist/a2a/planner-descriptor.d.ts.map +1 -0
  40. package/node_modules/@claude-flow/plugin-agent-federation/dist/a2a/planner-descriptor.js +100 -0
  41. package/node_modules/@claude-flow/plugin-agent-federation/dist/a2a/planner-descriptor.js.map +1 -0
  42. package/node_modules/@claude-flow/plugin-agent-federation/dist/index.d.ts +1 -0
  43. package/node_modules/@claude-flow/plugin-agent-federation/dist/index.d.ts.map +1 -1
  44. package/node_modules/@claude-flow/plugin-agent-federation/dist/index.js +1 -0
  45. package/node_modules/@claude-flow/plugin-agent-federation/dist/index.js.map +1 -1
  46. package/node_modules/@claude-flow/plugin-agent-federation/package.json +1 -0
  47. package/node_modules/@claude-flow/security/dist/input-validator.d.ts +6 -6
  48. package/node_modules/@claude-flow/security/dist/safe-git.d.ts +22 -0
  49. package/node_modules/@claude-flow/security/dist/safe-git.d.ts.map +1 -0
  50. package/node_modules/@claude-flow/security/dist/safe-git.js +63 -0
  51. package/node_modules/@claude-flow/security/dist/safe-git.js.map +1 -0
  52. package/package.json +5 -5
  53. package/plugins/ruflo-metaharness/agents/metaharness-architect.md +1 -1
  54. package/plugins/ruflo-metaharness/commands/ruflo-metaharness.md +1 -1
  55. package/plugins/ruflo-metaharness/scripts/_darwin.mjs +84 -12
  56. package/plugins/ruflo-metaharness/scripts/_harness.mjs +10 -4
  57. package/plugins/ruflo-metaharness/scripts/_invoke.mjs +120 -6
  58. package/plugins/ruflo-metaharness/scripts/_redblue.mjs +33 -6
  59. package/plugins/ruflo-metaharness/scripts/audit-list.mjs +11 -10
  60. package/plugins/ruflo-metaharness/scripts/audit-trend.mjs +8 -7
  61. package/plugins/ruflo-metaharness/scripts/drift-from-history.mjs +2 -3
  62. package/plugins/ruflo-metaharness/scripts/oia-audit.mjs +8 -7
  63. package/plugins/ruflo-metaharness/scripts/similarity.mjs +9 -8
  64. package/plugins/ruflo-metaharness/scripts/smoke.sh +31 -0
  65. package/plugins/ruflo-metaharness/scripts/test-no-tool-time-npx.mjs +319 -0
  66. package/plugins/ruflo-metaharness/scripts/test-pin-alignment.mjs +182 -0
  67. package/plugins/ruflo-metaharness/skills/harness-evolve/SKILL.md +4 -2
  68. package/plugins/ruflo-metaharness/skills/harness-gepa/SKILL.md +1 -1
  69. package/plugins/ruflo-metaharness/skills/harness-learn/SKILL.md +1 -1
  70. package/plugins/ruflo-metaharness/skills/harness-mcp-scan/SKILL.md +1 -1
  71. package/plugins/ruflo-metaharness/skills/harness-score/SKILL.md +1 -1
  72. package/plugins/ruflo-metaharness/skills/harness-security-bench/SKILL.md +3 -1
  73. package/plugins/ruflo-metaharness/skills/harness-threat-model/SKILL.md +1 -1
@@ -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
- // Check if already running (skip if status reports our own PID —
122
- // getStatus() returns running=true for the current process in stdio mode
123
- // even before the server is actually started)
124
- const status = await this.getStatus();
125
- if (status.running && status.pid !== process.pid) {
126
- throw new Error(`MCP Server already running (PID: ${status.pid})`);
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
- await this.writePidFile();
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
- // Remove PID file
196
- await this.removePidFile();
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 pid = await this.readPidFile();
211
- if (!pid) {
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 if process is running
230
- const isRunning = this.isProcessRunning(pid);
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
- // For stdio, check if process is running
260
- const pid = await this.readPidFile();
261
- if (pid === null) {
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.isProcessRunning(pid)) {
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 file
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
- await fs.promises.writeFile(this.options.pidFile, String(pid), 'utf8');
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 file
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 readPidFile() {
862
+ async readPidRecord() {
687
863
  try {
688
- const content = await fs.promises.readFile(this.options.pidFile, 'utf8');
689
- const pid = parseInt(content.trim(), 10);
690
- return isNaN(pid) ? null : pid;
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
- * Remove PID file
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
- async removePidFile() {
700
- try {
701
- await fs.promises.unlink(this.options.pidFile);
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
- catch {
704
- // Ignore errors
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). Dispatches to the most capable backend: graph-node native for k-hop, sql.js CTE for fallback, HNSW cosine for semantic, ruflo-graph-intelligence PageRank for pagerank mode. Use when you need structured graph traversal beyond flat memory search.',
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: 'Hop depth for k-hop mode (default 2, max 5)' },
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
- // Try graph-node native first
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 sql.js */ }
1129
- // SQL CTE fallback for k-hop up to depth 3
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 cteSql = buildKHopCTE(nodeId, Math.min(depth, 3), relation, budget.maxNodesVisited);
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
- const npxErr = npxError;
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;
@@ -1015,13 +1015,16 @@ export const memoryTools = [
1015
1015
  if (nameMatch)
1016
1016
  name = nameMatch[1].trim();
1017
1017
  }
1018
- // Split into sections for granular search
1018
+ // Split into sections for granular search. Persist each full section:
1019
+ // clipping at 4096 characters silently discarded its searchable tail (#3217).
1019
1020
  const sections = body.split(/^(?=## )/m).filter(s => s.trim().length > 20);
1020
1021
  if (sections.length === 0 && body.length > 10) {
1021
1022
  // #1884 — sanitize key so memory_delete can later remove it. Without
1022
1023
  // this, dangerous chars from frontmatter `name` strand the key.
1023
1024
  const key = sanitizeMemoryKey(`claude:${memFile.project}:${name}`);
1024
- await storeEntry({ key, value: body.slice(0, 4096), namespace: ns, generateEmbeddingFlag: true });
1025
+ const stored = await storeEntry({ key, value: body, namespace: ns, generateEmbeddingFlag: true });
1026
+ if (!stored.success)
1027
+ throw new Error(stored.error || `Failed to store ${key}`);
1025
1028
  imported++;
1026
1029
  }
1027
1030
  else {
@@ -1034,7 +1037,9 @@ export const memoryTools = [
1034
1037
  // #1884 — sanitize so any dangerous chars in the heading don't
1035
1038
  // produce keys memory_delete will reject.
1036
1039
  const key = sanitizeMemoryKey(`claude:${memFile.project}:${name}:${sectionTitle.slice(0, 50)}`);
1037
- await storeEntry({ key, value: sectionBody.slice(0, 4096), namespace: ns, generateEmbeddingFlag: true });
1040
+ const stored = await storeEntry({ key, value: sectionBody, namespace: ns, generateEmbeddingFlag: true });
1041
+ if (!stored.success)
1042
+ throw new Error(stored.error || `Failed to store ${key}`);
1038
1043
  imported++;
1039
1044
  }
1040
1045
  }
@@ -1054,7 +1059,7 @@ export const memoryTools = [
1054
1059
  }
1055
1060
  catch { /* probe failed — leave 'unknown' */ }
1056
1061
  return {
1057
- success: true,
1062
+ success: skipped === 0,
1058
1063
  imported,
1059
1064
  skipped,
1060
1065
  duplicatesSkipped,
@@ -402,7 +402,7 @@ export const metaharnessTools = [
402
402
  },
403
403
  // ───────────────────────────────────────────────────────────────────────
404
404
  // ADR-153 — @metaharness/darwin integration (3 tools).
405
- // Backed by the separate `@metaharness/darwin@~0.8.0` npm package, NOT
405
+ // Backed by the separate `@metaharness/darwin@~0.10.2` npm package, NOT
406
406
  // the umbrella `metaharness`. Plugin scripts shell out via _darwin.mjs.
407
407
  // Same {success, data, degraded, exitCode} contract.
408
408
  // ───────────────────────────────────────────────────────────────────────
@@ -30,10 +30,10 @@
30
30
  * misconfiguration defeats the feature. See `evaluateToolCall()`.
31
31
  *
32
32
  * Known scope limits (disclosed, not fixed here):
33
- * - Only wired into the stdio `tools/call` dispatch
34
- * (`MCPServerManager.handleMCPMessage`). The separate HTTP/websocket
35
- * path (`startHttpServer()`, via `@claude-flow/mcp`) does not call
36
- * this module and is unaffected even when this flag is set.
33
+ * - Wired into all three stdio `tools/call` dispatchers: the CLI fast
34
+ * path, the standalone MCP launcher, and MCPServerManager. The separate
35
+ * HTTP/websocket path (`startHttpServer()`, via `@claude-flow/mcp`)
36
+ * does not call this module and is unaffected even when this flag is set.
37
37
  *
38
38
  * `maxToolCallsPerTurn` reset semantics (dream-cycle 2026-09-01, follow-up
39
39
  * to 2026-08-31 review round 1): despite the field's name, the original
@@ -30,10 +30,10 @@
30
30
  * misconfiguration defeats the feature. See `evaluateToolCall()`.
31
31
  *
32
32
  * Known scope limits (disclosed, not fixed here):
33
- * - Only wired into the stdio `tools/call` dispatch
34
- * (`MCPServerManager.handleMCPMessage`). The separate HTTP/websocket
35
- * path (`startHttpServer()`, via `@claude-flow/mcp`) does not call
36
- * this module and is unaffected even when this flag is set.
33
+ * - Wired into all three stdio `tools/call` dispatchers: the CLI fast
34
+ * path, the standalone MCP launcher, and MCPServerManager. The separate
35
+ * HTTP/websocket path (`startHttpServer()`, via `@claude-flow/mcp`)
36
+ * does not call this module and is unaffected even when this flag is set.
37
37
  *
38
38
  * `maxToolCallsPerTurn` reset semantics (dream-cycle 2026-09-01, follow-up
39
39
  * to 2026-08-31 review round 1): despite the field's name, the original