claude-flow 3.41.4 → 3.42.1
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/.proven-config-version +1 -0
- package/.claude/proven-config.json +42 -0
- package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.d.ts +4 -0
- package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.d.ts.map +1 -1
- package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.js +38 -1
- package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.js.map +1 -1
- package/node_modules/@claude-flow/codex/package.json +2 -1
- package/node_modules/@claude-flow/security/dist/index.d.ts +1 -1
- package/node_modules/@claude-flow/security/dist/index.d.ts.map +1 -1
- package/node_modules/@claude-flow/security/dist/index.js +1 -1
- package/node_modules/@claude-flow/security/dist/index.js.map +1 -1
- package/node_modules/@claude-flow/security/dist/mcp-caller-identity.d.ts +11 -0
- package/node_modules/@claude-flow/security/dist/mcp-caller-identity.d.ts.map +1 -1
- package/node_modules/@claude-flow/security/dist/mcp-caller-identity.js +0 -0
- package/node_modules/@claude-flow/security/dist/mcp-caller-identity.js.map +1 -1
- package/node_modules/@claude-flow/security/package.json +1 -0
- package/package.json +1 -1
- package/v3/@claude-flow/cli/catalog-manifest.json +2 -2
- package/v3/@claude-flow/cli/dist/src/commands/doctor.d.ts +7 -0
- package/v3/@claude-flow/cli/dist/src/commands/doctor.js +58 -35
- package/v3/@claude-flow/cli/dist/src/commands/hive-mind.js +4 -3
- package/v3/@claude-flow/cli/dist/src/commands/swarm.js +89 -18
- package/v3/@claude-flow/cli/dist/src/init/claudemd-generator.js +2 -2
- package/v3/@claude-flow/cli/dist/src/mcp-server.js +12 -0
- package/v3/@claude-flow/cli/dist/src/mcp-tools/agentbbs-tools.d.ts +15 -0
- package/v3/@claude-flow/cli/dist/src/mcp-tools/agentbbs-tools.js +98 -14
- package/v3/@claude-flow/cli/dist/src/mcp-tools/agentdb-tools.js +17 -1
- package/v3/@claude-flow/cli/dist/src/mcp-tools/hive-mind-tools.d.ts +8 -0
- package/v3/@claude-flow/cli/dist/src/mcp-tools/hive-mind-tools.js +80 -2
- package/v3/@claude-flow/cli/dist/src/mcp-tools/memory-tools.js +102 -31
- package/v3/@claude-flow/cli/dist/src/mcp-tools/policy-enforcer.d.ts +126 -0
- package/v3/@claude-flow/cli/dist/src/mcp-tools/policy-enforcer.js +177 -0
- package/v3/@claude-flow/cli/dist/src/mcp-tools/seraphina-tools.js +7 -2
- package/v3/@claude-flow/cli/dist/src/mcp-tools/x-federation-tools.d.ts +12 -0
- package/v3/@claude-flow/cli/dist/src/mcp-tools/x-federation-tools.js +87 -5
- package/v3/@claude-flow/cli/dist/src/memory/intelligence.d.ts +14 -2
- package/v3/@claude-flow/cli/dist/src/memory/intelligence.js +33 -12
- package/v3/@claude-flow/cli/dist/src/memory/memory-bridge.js +10 -2
- package/v3/@claude-flow/cli/dist/src/memory/memory-initializer.js +19 -2
- package/v3/@claude-flow/cli/dist/src/ruvector/graph-backend.js +112 -26
- package/v3/@claude-flow/cli/dist/src/services/policy-runtime.js +63 -5
- package/v3/@claude-flow/cli/package.json +1 -1
- package/v3/@claude-flow/cli/dist/src/ruvector/diskann-backend.d.ts +0 -78
- package/v3/@claude-flow/cli/dist/src/ruvector/diskann-backend.js +0 -310
|
@@ -13,6 +13,7 @@ import { mkdir, writeFile } from 'fs/promises';
|
|
|
13
13
|
import { existsSync } from 'fs';
|
|
14
14
|
import { join } from 'path';
|
|
15
15
|
import { resolveClaudeLaunchCommand } from '../runtime/claude-command.js';
|
|
16
|
+
import { getHiveTokenForCli } from '../mcp-tools/hive-mind-tools.js';
|
|
16
17
|
// Hive topologies
|
|
17
18
|
const TOPOLOGIES = [
|
|
18
19
|
{ value: 'hierarchical', label: 'Hierarchical', hint: 'Queen-led with worker agents' },
|
|
@@ -977,7 +978,7 @@ const joinCommand = {
|
|
|
977
978
|
return { success: false, exitCode: 1 };
|
|
978
979
|
}
|
|
979
980
|
try {
|
|
980
|
-
const result = await callMCPTool('hive-mind_join', { agentId, role: ctx.flags.role });
|
|
981
|
+
const result = await callMCPTool('hive-mind_join', { agentId, role: ctx.flags.role, hiveToken: getHiveTokenForCli() });
|
|
981
982
|
if (!result.success) {
|
|
982
983
|
output.printError(result.error || 'Failed');
|
|
983
984
|
return { success: false, exitCode: 1 };
|
|
@@ -1003,7 +1004,7 @@ const leaveCommand = {
|
|
|
1003
1004
|
return { success: false, exitCode: 1 };
|
|
1004
1005
|
}
|
|
1005
1006
|
try {
|
|
1006
|
-
const result = await callMCPTool('hive-mind_leave', { agentId });
|
|
1007
|
+
const result = await callMCPTool('hive-mind_leave', { agentId, hiveToken: getHiveTokenForCli() });
|
|
1007
1008
|
if (!result.success) {
|
|
1008
1009
|
output.printError(result.error || 'Failed');
|
|
1009
1010
|
return { success: false, exitCode: 1 };
|
|
@@ -1032,7 +1033,7 @@ const consensusCommand = {
|
|
|
1032
1033
|
action: async (ctx) => {
|
|
1033
1034
|
const action = ctx.flags.action || 'list';
|
|
1034
1035
|
try {
|
|
1035
|
-
const result = await callMCPTool('hive-mind_consensus', { action, proposalId: ctx.flags.proposalId, type: ctx.flags.type, value: ctx.flags.value, vote: ctx.flags.vote === 'yes', voterId: ctx.flags.voterId });
|
|
1036
|
+
const result = await callMCPTool('hive-mind_consensus', { action, proposalId: ctx.flags.proposalId, type: ctx.flags.type, value: ctx.flags.value, vote: ctx.flags.vote === 'yes', voterId: ctx.flags.voterId, hiveToken: getHiveTokenForCli() });
|
|
1036
1037
|
if (ctx.flags.format === 'json') {
|
|
1037
1038
|
output.printJson(result);
|
|
1038
1039
|
return { success: true, data: result };
|
|
@@ -8,6 +8,56 @@ import { callMCPTool, MCPClientError } from '../mcp-client.js';
|
|
|
8
8
|
import { swarmJoinCommand } from './agntcy/swarm-join.js';
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import * as path from 'path';
|
|
11
|
+
// Read the CLI-side swarm state file (`.swarm/state.json`), written by
|
|
12
|
+
// `swarm init` and rewritten by `swarm start` / `swarm stop`.
|
|
13
|
+
function readLocalSwarmState() {
|
|
14
|
+
const swarmStateFile = path.join(process.cwd(), '.swarm', 'state.json');
|
|
15
|
+
if (!fs.existsSync(swarmStateFile))
|
|
16
|
+
return null;
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs.readFileSync(swarmStateFile, 'utf-8'));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Ignore parse errors
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// Resolve the id of the current swarm. Three writers persist it, under two
|
|
26
|
+
// different keys and in two different files:
|
|
27
|
+
//
|
|
28
|
+
// `.swarm/state.json` `id` ← `swarm init`
|
|
29
|
+
// `.swarm/state.json` `swarmId` ← `swarm start`
|
|
30
|
+
// `.claude-flow/swarm/swarm-state.json` `swarms[id].swarmId` ← MCP `swarm_init`
|
|
31
|
+
//
|
|
32
|
+
// The status payload used to read only the first key of the first file, so a
|
|
33
|
+
// Claude Code session (which drives the MCP path) reported an active swarm
|
|
34
|
+
// with agents but no usable id. Check every writer, most local first.
|
|
35
|
+
function resolveSwarmId(swarmState) {
|
|
36
|
+
// A state file left behind by a previous `swarm stop` must not shadow a
|
|
37
|
+
// swarm that is still live in the MCP store — otherwise `stop` names the
|
|
38
|
+
// dead id while `swarm_shutdown`'s own "most recent running" fallback
|
|
39
|
+
// terminates a different swarm.
|
|
40
|
+
if (swarmState?.status !== 'stopped') {
|
|
41
|
+
const fromState = swarmState?.id ?? swarmState?.swarmId;
|
|
42
|
+
if (typeof fromState === 'string' && fromState)
|
|
43
|
+
return fromState;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const storePath = path.join(process.cwd(), '.claude-flow', 'swarm', 'swarm-state.json');
|
|
47
|
+
if (!fs.existsSync(storePath))
|
|
48
|
+
return null;
|
|
49
|
+
const store = JSON.parse(fs.readFileSync(storePath, 'utf-8'));
|
|
50
|
+
// Most recently updated swarm that has not been shut down.
|
|
51
|
+
const live = Object.values(store.swarms ?? {})
|
|
52
|
+
.filter(swarm => swarm.status !== 'terminated')
|
|
53
|
+
.sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime());
|
|
54
|
+
return live[0]?.swarmId ?? null;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Ignore — an unreadable MCP store just means no id to resolve.
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
11
61
|
// Get dynamic swarm status from memory/session files
|
|
12
62
|
function getSwarmStatus(swarmId) {
|
|
13
63
|
const swarmDir = path.join(process.cwd(), '.swarm');
|
|
@@ -17,16 +67,7 @@ function getSwarmStatus(swarmId) {
|
|
|
17
67
|
path.join(process.cwd(), '.claude', 'memory.db'),
|
|
18
68
|
];
|
|
19
69
|
// Check for active swarm state file
|
|
20
|
-
const
|
|
21
|
-
let swarmState = null;
|
|
22
|
-
if (fs.existsSync(swarmStateFile)) {
|
|
23
|
-
try {
|
|
24
|
-
swarmState = JSON.parse(fs.readFileSync(swarmStateFile, 'utf-8'));
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
// Ignore parse errors
|
|
28
|
-
}
|
|
29
|
-
}
|
|
70
|
+
const swarmState = readLocalSwarmState();
|
|
30
71
|
// Count active agents from process files
|
|
31
72
|
let activeAgents = 0;
|
|
32
73
|
let totalAgents = 0;
|
|
@@ -175,10 +216,22 @@ function getSwarmStatus(swarmId) {
|
|
|
175
216
|
status = 'completed';
|
|
176
217
|
}
|
|
177
218
|
else if (swarmState) {
|
|
178
|
-
|
|
219
|
+
// The file's own lifecycle state wins over the "a file exists, so we are
|
|
220
|
+
// ready" default — a swarm explicitly recorded as stopped is not ready.
|
|
221
|
+
// Live agents still win above: they are observed reality, whereas the file
|
|
222
|
+
// records an intent that may be stale.
|
|
223
|
+
status = swarmState.status === 'stopped' ? 'stopped' : 'ready';
|
|
179
224
|
}
|
|
225
|
+
// Resolve once — the id also settles whether there is a swarm to report.
|
|
226
|
+
// An MCP `swarm_init` with no agents spawned yet leaves no `.swarm/state.json`
|
|
227
|
+
// and no agent store, so without this the payload contradicted itself:
|
|
228
|
+
// a real `id` alongside `hasActiveSwarm: false`.
|
|
229
|
+
const resolvedId = swarmId || resolveSwarmId(swarmState);
|
|
180
230
|
return {
|
|
181
|
-
|
|
231
|
+
// `null` when genuinely unknown — never a sentinel string. `status` and
|
|
232
|
+
// `hasActiveSwarm` already carry the "no active swarm" fact, and a
|
|
233
|
+
// consumer reading `.id` must get an id or nothing.
|
|
234
|
+
id: resolvedId,
|
|
182
235
|
topology: swarmState?.topology || 'none',
|
|
183
236
|
status,
|
|
184
237
|
objective: swarmState?.objective || 'No active objective',
|
|
@@ -290,7 +343,13 @@ function getSwarmStatus(swarmId) {
|
|
|
290
343
|
}
|
|
291
344
|
return { consensusRounds, messagesSent, conflictsResolved };
|
|
292
345
|
})(),
|
|
293
|
-
|
|
346
|
+
// A state file left behind by `swarm stop` is not an active swarm — without
|
|
347
|
+
// this it claimed one it could not name (`id: null`, zero agents), which is
|
|
348
|
+
// the same contradiction from the other side. Live agents or a resolvable
|
|
349
|
+
// id still count, so a stopped file never masks real activity.
|
|
350
|
+
hasActiveSwarm: (!!swarmState && swarmState.status !== 'stopped')
|
|
351
|
+
|| totalAgents > 0
|
|
352
|
+
|| resolvedId !== null
|
|
294
353
|
};
|
|
295
354
|
}
|
|
296
355
|
// Swarm topologies
|
|
@@ -630,6 +689,9 @@ const startCommand = {
|
|
|
630
689
|
if (!fs.existsSync(swarmDir))
|
|
631
690
|
fs.mkdirSync(swarmDir, { recursive: true });
|
|
632
691
|
const executionState = {
|
|
692
|
+
// `id` mirrors what `swarm init` writes so both files share one key;
|
|
693
|
+
// `swarmId` stays for existing files and readers.
|
|
694
|
+
id: swarmId,
|
|
633
695
|
swarmId,
|
|
634
696
|
objective,
|
|
635
697
|
strategy,
|
|
@@ -673,7 +735,7 @@ const statusCommand = {
|
|
|
673
735
|
output.writeln();
|
|
674
736
|
return { success: true, data: status };
|
|
675
737
|
}
|
|
676
|
-
output.writeln(output.bold(`Swarm Status: ${status.id}`));
|
|
738
|
+
output.writeln(output.bold(`Swarm Status: ${status.id ?? output.dim('unknown id')}`));
|
|
677
739
|
output.writeln();
|
|
678
740
|
// Progress bar
|
|
679
741
|
output.writeln(`Overall Progress: ${output.progressBar(status.progress, 100, 40)}`);
|
|
@@ -747,10 +809,16 @@ const stopCommand = {
|
|
|
747
809
|
}
|
|
748
810
|
],
|
|
749
811
|
action: async (ctx) => {
|
|
750
|
-
|
|
812
|
+
// Bare `swarm stop` used to hard-fail, and no CLI surface handed out an
|
|
813
|
+
// id to pass (there is no `swarm list`). Default to the persisted swarm;
|
|
814
|
+
// an explicit argument still wins.
|
|
815
|
+
const swarmId = ctx.args[0] || resolveSwarmId(readLocalSwarmState());
|
|
751
816
|
const force = ctx.flags.force;
|
|
752
817
|
if (!swarmId) {
|
|
753
|
-
output.printError('
|
|
818
|
+
output.printError('No swarm found to stop');
|
|
819
|
+
output.writeln(output.dim(' Find an id with: claude-flow swarm status --format json (the "id" field)'));
|
|
820
|
+
output.writeln(output.dim(' Or pass one: claude-flow swarm stop <swarm-id>'));
|
|
821
|
+
output.writeln(output.dim(' Or start one: claude-flow swarm init'));
|
|
754
822
|
return { success: false, exitCode: 1 };
|
|
755
823
|
}
|
|
756
824
|
if (ctx.interactive && !force) {
|
|
@@ -810,11 +878,14 @@ const scaleCommand = {
|
|
|
810
878
|
}
|
|
811
879
|
],
|
|
812
880
|
action: async (ctx) => {
|
|
813
|
-
|
|
881
|
+
// Same resolution as `stop` — the id is persisted, so don't demand it.
|
|
882
|
+
const swarmId = ctx.args[0] || resolveSwarmId(readLocalSwarmState());
|
|
814
883
|
const targetAgents = ctx.flags.agents;
|
|
815
884
|
const agentType = ctx.flags.type;
|
|
816
885
|
if (!swarmId) {
|
|
817
|
-
output.printError('
|
|
886
|
+
output.printError('No swarm found to scale');
|
|
887
|
+
output.writeln(output.dim(' Find an id with: claude-flow swarm status --format json (the "id" field)'));
|
|
888
|
+
output.writeln(output.dim(' Or pass one: claude-flow swarm scale <swarm-id> --agents N'));
|
|
818
889
|
return { success: false, exitCode: 1 };
|
|
819
890
|
}
|
|
820
891
|
if (!targetAgents) {
|
|
@@ -258,7 +258,7 @@ function performanceSection() {
|
|
|
258
258
|
|
|
259
259
|
- Always benchmark before AND after optimization
|
|
260
260
|
- Always profile before optimizing — never guess bottlenecks
|
|
261
|
-
- Use HNSW
|
|
261
|
+
- Use HNSW for vector search, Int8 quantization for memory reduction — measure the actual speedup/recall on your own corpus via \`node scripts/benchmark-intelligence.mjs --only=hnsw\` rather than trusting a hardcoded multiplier (recall@10 varies by corpus size — 0.99 at N=5k, 0.92 at N=20k on the reference host, dream-cycle 2026-08-30)
|
|
262
262
|
|
|
263
263
|
\`\`\`bash
|
|
264
264
|
npx @claude-flow/cli@latest performance benchmark --suite all
|
|
@@ -293,7 +293,7 @@ function intelligenceSystem() {
|
|
|
293
293
|
Pipeline: **RETRIEVE** (vector search) → **JUDGE** (success/failure) → **DISTILL** (extract patterns) → **CONSOLIDATE** (persist)
|
|
294
294
|
|
|
295
295
|
- **ONNX Embeddings**: all-MiniLM-L6-v2, 384-dim
|
|
296
|
-
- **HNSW
|
|
296
|
+
- **HNSW**: faster than brute-force search, magnitude scales with corpus size — reproduce with \`node scripts/benchmark-intelligence.mjs --only=hnsw\` rather than a hardcoded multiplier
|
|
297
297
|
- **SONA**: Sub-millisecond pattern adaptation
|
|
298
298
|
- **Claude Bridge**: Auto-imports \`~/.claude/projects/*/memory/*.md\` into AgentDB`;
|
|
299
299
|
}
|
|
@@ -26,6 +26,7 @@ import * as os from 'os';
|
|
|
26
26
|
import { fileURLToPath } from 'url';
|
|
27
27
|
import { dirname } from 'path';
|
|
28
28
|
import { trackRequest } from './mcp-tools/request-tracker.js';
|
|
29
|
+
import { isPolicyEnforcementEnabled, loadMcpPolicy, evaluateToolCall, } from './mcp-tools/policy-enforcer.js';
|
|
29
30
|
// ESM-compatible __dirname
|
|
30
31
|
const __filename = fileURLToPath(import.meta.url);
|
|
31
32
|
const __dirname = dirname(__filename);
|
|
@@ -503,6 +504,17 @@ export class MCPServerManager extends EventEmitter {
|
|
|
503
504
|
error: { code: -32601, message: `Tool not found: ${toolName}` },
|
|
504
505
|
};
|
|
505
506
|
}
|
|
507
|
+
if (isPolicyEnforcementEnabled()) {
|
|
508
|
+
const check = evaluateToolCall(loadMcpPolicy(), sessionId, toolName);
|
|
509
|
+
if (!check.allowed) {
|
|
510
|
+
trackRequest(toolName, false);
|
|
511
|
+
return {
|
|
512
|
+
jsonrpc: '2.0',
|
|
513
|
+
id: message.id,
|
|
514
|
+
error: { code: -32001, message: `Policy denied: ${check.reason}` },
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
}
|
|
506
518
|
try {
|
|
507
519
|
const result = await callMCPTool(toolName, toolParams, { sessionId });
|
|
508
520
|
trackRequest(toolName, true);
|
|
@@ -35,5 +35,20 @@
|
|
|
35
35
|
* @module @claude-flow/cli/mcp-tools/agentbbs
|
|
36
36
|
*/
|
|
37
37
|
import type { MCPTool } from './types.js';
|
|
38
|
+
/** Last envelope's seq by scanning only the file tail; full-read fallback keeps it exact. */
|
|
39
|
+
export declare function nextSeq(path: string): number;
|
|
40
|
+
/** Timestamp (ms) of the most recent PeerHello for a room, or -Infinity if none. */
|
|
41
|
+
export declare function lastPeerHelloMs(logPath: string): number;
|
|
42
|
+
/**
|
|
43
|
+
* Whether a `register` call should append a PeerHello: only for a genuinely new
|
|
44
|
+
* registration, or once the heartbeat window has elapsed since the last one.
|
|
45
|
+
* A re-register within the window is a retry and must NOT spam a near-identical
|
|
46
|
+
* hello. Pure — the decision the handler makes, exported for test.
|
|
47
|
+
*/
|
|
48
|
+
export declare function shouldEmitHello(alreadyRegistered: boolean, nowMs: number, lastHelloMs: number, windowSecs: number): boolean;
|
|
49
|
+
/** Collapse duplicate envelopeIds, preserving order (first occurrence wins). Pure. */
|
|
50
|
+
export declare function dedupEnvelopes<T extends {
|
|
51
|
+
envelopeId: string;
|
|
52
|
+
}>(list: T[]): T[];
|
|
38
53
|
export declare const agentbbsTools: MCPTool[];
|
|
39
54
|
//# sourceMappingURL=agentbbs-tools.d.ts.map
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
*
|
|
35
35
|
* @module @claude-flow/cli/mcp-tools/agentbbs
|
|
36
36
|
*/
|
|
37
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
37
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
38
38
|
import { resolve, isAbsolute, join } from 'node:path';
|
|
39
39
|
import { randomBytes, createHash } from 'node:crypto';
|
|
40
40
|
import { execFileSync } from 'node:child_process';
|
|
@@ -133,12 +133,83 @@ function readEnvelopes(path) {
|
|
|
133
133
|
}
|
|
134
134
|
return out;
|
|
135
135
|
}
|
|
136
|
-
|
|
136
|
+
// Tail of the log we scan to find the last seq. Envelopes are small; one line
|
|
137
|
+
// always fits well inside this. Reading only the tail keeps append O(1) instead
|
|
138
|
+
// of O(n) as a room log grows (ADR-164 §3.2.2 requires monotonic seq, and a
|
|
139
|
+
// full re-parse on every publish was the cost).
|
|
140
|
+
const SEQ_TAIL_BYTES = 65536;
|
|
141
|
+
/** Last envelope's seq by scanning only the file tail; full-read fallback keeps it exact. */
|
|
142
|
+
export function nextSeq(path) {
|
|
143
|
+
if (!existsSync(path))
|
|
144
|
+
return 1;
|
|
145
|
+
try {
|
|
146
|
+
const size = statSync(path).size;
|
|
147
|
+
if (size === 0)
|
|
148
|
+
return 1;
|
|
149
|
+
const start = Math.max(0, size - SEQ_TAIL_BYTES);
|
|
150
|
+
const fd = openSync(path, 'r');
|
|
151
|
+
let text;
|
|
152
|
+
try {
|
|
153
|
+
const buf = Buffer.alloc(size - start);
|
|
154
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
155
|
+
text = buf.toString('utf-8');
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
closeSync(fd);
|
|
159
|
+
}
|
|
160
|
+
// If we started mid-file the first fragment may be a partial line — drop it.
|
|
161
|
+
const lines = text.split(/\r?\n/).filter(l => l.trim());
|
|
162
|
+
const candidates = start > 0 && lines.length > 1 ? lines.slice(1) : lines;
|
|
163
|
+
for (let i = candidates.length - 1; i >= 0; i--) {
|
|
164
|
+
try {
|
|
165
|
+
const e = JSON.parse(candidates[i]);
|
|
166
|
+
if (typeof e.seq === 'number')
|
|
167
|
+
return e.seq + 1;
|
|
168
|
+
}
|
|
169
|
+
catch { /* keep scanning upward for the last parseable line */ }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch { /* fall through to the exact full-read path */ }
|
|
173
|
+
// Fallback: whole-file parse (rare — huge single line, or a tail with no seq).
|
|
137
174
|
const env = readEnvelopes(path);
|
|
138
175
|
if (env.length === 0)
|
|
139
176
|
return 1;
|
|
140
177
|
return (env[env.length - 1].seq ?? env.length) + 1;
|
|
141
178
|
}
|
|
179
|
+
/** Window within which a fresh PeerHello is suppressed as a duplicate (heartbeat, not spam). */
|
|
180
|
+
const HELLO_WINDOW_SECS = Number(process.env.FEDERATION_BBS_HELLO_WINDOW_SECS ?? 60);
|
|
181
|
+
/** Timestamp (ms) of the most recent PeerHello for a room, or -Infinity if none. */
|
|
182
|
+
export function lastPeerHelloMs(logPath) {
|
|
183
|
+
if (!existsSync(logPath))
|
|
184
|
+
return -Infinity;
|
|
185
|
+
const env = readEnvelopes(logPath);
|
|
186
|
+
for (let i = env.length - 1; i >= 0; i--) {
|
|
187
|
+
if (env[i].msgType === 'PeerHello') {
|
|
188
|
+
const t = Date.parse(env[i].timestamp);
|
|
189
|
+
return Number.isNaN(t) ? -Infinity : t;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return -Infinity;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Whether a `register` call should append a PeerHello: only for a genuinely new
|
|
196
|
+
* registration, or once the heartbeat window has elapsed since the last one.
|
|
197
|
+
* A re-register within the window is a retry and must NOT spam a near-identical
|
|
198
|
+
* hello. Pure — the decision the handler makes, exported for test.
|
|
199
|
+
*/
|
|
200
|
+
export function shouldEmitHello(alreadyRegistered, nowMs, lastHelloMs, windowSecs) {
|
|
201
|
+
return !alreadyRegistered || nowMs - lastHelloMs >= windowSecs * 1000;
|
|
202
|
+
}
|
|
203
|
+
/** Collapse duplicate envelopeIds, preserving order (first occurrence wins). Pure. */
|
|
204
|
+
export function dedupEnvelopes(list) {
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
return list.filter(e => {
|
|
207
|
+
if (seen.has(e.envelopeId))
|
|
208
|
+
return false;
|
|
209
|
+
seen.add(e.envelopeId);
|
|
210
|
+
return true;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
142
213
|
/**
|
|
143
214
|
* Ephemeral per-process Ed25519 keypair for human-join token signing.
|
|
144
215
|
* Phase 1 contract: keys are NOT persisted across process restart — every
|
|
@@ -212,6 +283,7 @@ export const agentbbsTools = [
|
|
|
212
283
|
const registryPath = roomsRegistryPath(basePath);
|
|
213
284
|
const registry = existsSync(registryPath) ? JSON.parse(readFileSync(registryPath, 'utf-8')) : {};
|
|
214
285
|
// Idempotent — re-registering the same label updates timestamp but keeps id stable.
|
|
286
|
+
const alreadyRegistered = roomId in registry;
|
|
215
287
|
const entry = {
|
|
216
288
|
roomId,
|
|
217
289
|
roomLabel,
|
|
@@ -220,17 +292,25 @@ export const agentbbsTools = [
|
|
|
220
292
|
};
|
|
221
293
|
registry[roomId] = entry;
|
|
222
294
|
writeFileSync(registryPath, JSON.stringify(registry, null, 2));
|
|
223
|
-
// Emit a
|
|
295
|
+
// Emit a PeerHello only when it carries information: a genuinely new
|
|
296
|
+
// registration, or a heartbeat refresh once HELLO_WINDOW_SECS has elapsed.
|
|
297
|
+
// A re-register within the window is a retry/no-op — appending a
|
|
298
|
+
// near-identical hello every call is the spam ADR-164 dedup guards against
|
|
299
|
+
// (observed live: one node published 8 near-identical PeerHellos).
|
|
224
300
|
const logPath = roomLogPath(basePath, roomId);
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
301
|
+
const nowMs = Date.parse(entry.registeredAt);
|
|
302
|
+
const helloEmitted = shouldEmitHello(alreadyRegistered, nowMs, lastPeerHelloMs(logPath), HELLO_WINDOW_SECS);
|
|
303
|
+
if (helloEmitted) {
|
|
304
|
+
const env = {
|
|
305
|
+
envelopeId: base64url(randomBytes(12)),
|
|
306
|
+
roomId,
|
|
307
|
+
seq: nextSeq(logPath),
|
|
308
|
+
msgType: 'PeerHello',
|
|
309
|
+
payload: { roomLabel, trustLevel: 'attested' },
|
|
310
|
+
timestamp: entry.registeredAt,
|
|
311
|
+
};
|
|
312
|
+
appendFileSync(logPath, JSON.stringify(env) + '\n');
|
|
313
|
+
}
|
|
234
314
|
// nodeId: deterministic per (cwd, roomId) so re-registers reuse identity.
|
|
235
315
|
const nodeId = createHash('sha256')
|
|
236
316
|
.update(`agentbbs:node:${basePath}:${roomId}`)
|
|
@@ -241,6 +321,7 @@ export const agentbbsTools = [
|
|
|
241
321
|
roomId,
|
|
242
322
|
nodeId,
|
|
243
323
|
trustLevel: 'attested',
|
|
324
|
+
helloEmitted,
|
|
244
325
|
};
|
|
245
326
|
},
|
|
246
327
|
},
|
|
@@ -357,13 +438,16 @@ export const agentbbsTools = [
|
|
|
357
438
|
const idx = all.findIndex(e => e.envelopeId === sinceEnvelopeId);
|
|
358
439
|
slice = idx >= 0 ? all.slice(idx + 1) : all;
|
|
359
440
|
}
|
|
360
|
-
|
|
441
|
+
// Dedup by envelopeId before applying the limit — a relay replay or a
|
|
442
|
+
// double-append must not surface the same envelope twice to a watcher.
|
|
443
|
+
const deduped = dedupEnvelopes(slice);
|
|
444
|
+
const envelopes = deduped.slice(-limit);
|
|
361
445
|
return {
|
|
362
446
|
success: true,
|
|
363
447
|
roomId,
|
|
364
448
|
envelopes,
|
|
365
449
|
count: envelopes.length,
|
|
366
|
-
hasMore:
|
|
450
|
+
hasMore: deduped.length > envelopes.length,
|
|
367
451
|
};
|
|
368
452
|
},
|
|
369
453
|
},
|
|
@@ -303,13 +303,24 @@ export const agentdbPatternSearch = {
|
|
|
303
303
|
// Tier 1 — semantic
|
|
304
304
|
let results = [];
|
|
305
305
|
let tier = 'semantic';
|
|
306
|
+
// #3325: a thrown error and a genuine zero-match were indistinguishable
|
|
307
|
+
// — `catch {}` discarded the former, `semantic?.results ?? []` treated
|
|
308
|
+
// `{success: false, error}` (which searchEntries returns WITHOUT
|
|
309
|
+
// throwing) as the latter. Capture whichever fires so a caller that
|
|
310
|
+
// lands on tier=substring can tell why, instead of source-reading.
|
|
311
|
+
let semanticError;
|
|
306
312
|
try {
|
|
307
313
|
const semantic = await searchEntries({ query, namespace: 'pattern', limit: topK });
|
|
314
|
+
if (semantic && semantic.success === false) {
|
|
315
|
+
semanticError = semantic.error ?? 'searchEntries returned success:false';
|
|
316
|
+
}
|
|
308
317
|
results = (semantic?.results ?? [])
|
|
309
318
|
.map(parseEntry)
|
|
310
319
|
.filter((r) => r !== null);
|
|
311
320
|
}
|
|
312
|
-
catch {
|
|
321
|
+
catch (err) {
|
|
322
|
+
semanticError = sanitizeError(err);
|
|
323
|
+
}
|
|
313
324
|
// Tier 2 — substring scan (catches just-written entries before HNSW indexes them).
|
|
314
325
|
// #2226: listEntries returns metadata only (no content/value — see open #2014),
|
|
315
326
|
// so parseEntry would always null out here. Fetch each entry's content by key via
|
|
@@ -358,6 +369,11 @@ export const agentdbPatternSearch = {
|
|
|
358
369
|
reason: result ? `reasoningBank-empty:${result.controller ?? 'unknown'}` : 'reasoningBank-unavailable:registry-null',
|
|
359
370
|
controller: 'memory-store-fallback',
|
|
360
371
|
tier,
|
|
372
|
+
// #3325: when tier=substring because tier 1 hit a real error (thrown,
|
|
373
|
+
// or {success:false, error}) rather than a genuine zero-match,
|
|
374
|
+
// surface why — otherwise a hard failure and an honest "not found"
|
|
375
|
+
// are indistinguishable from the response alone.
|
|
376
|
+
...(tier === 'substring' && semanticError ? { semanticError } : {}),
|
|
361
377
|
note: result
|
|
362
378
|
? `ReasoningBank returned 0 results; tier=${tier} from pattern namespace.`
|
|
363
379
|
: `ReasoningBank controller unavailable; tier=${tier} from pattern namespace.`,
|
|
@@ -4,5 +4,13 @@
|
|
|
4
4
|
* Tool definitions for collective intelligence and swarm coordination.
|
|
5
5
|
*/
|
|
6
6
|
import { type MCPTool } from './types.js';
|
|
7
|
+
/**
|
|
8
|
+
* Read the current hiveToken directly off disk, for same-machine callers
|
|
9
|
+
* that already have filesystem access to hive state (the CLI's own
|
|
10
|
+
* `hive-mind join/leave/consensus` subcommands) -- NOT exposed over any MCP
|
|
11
|
+
* tool response (in particular, not hive-mind_status), since that's a
|
|
12
|
+
* remote-reachable surface a capability token must not leak through.
|
|
13
|
+
*/
|
|
14
|
+
export declare function getHiveTokenForCli(): string | undefined;
|
|
7
15
|
export declare const hiveMindTools: MCPTool[];
|
|
8
16
|
//# sourceMappingURL=hive-mind-tools.d.ts.map
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
8
9
|
import { getProjectCwd } from './types.js';
|
|
9
10
|
import { validateIdentifier, validateText } from './validate-input.js';
|
|
10
11
|
// Storage paths
|
|
@@ -83,6 +84,36 @@ function tryResolveProposal(proposal, totalNodes) {
|
|
|
83
84
|
}
|
|
84
85
|
return null;
|
|
85
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Verify a caller-supplied hiveToken against the one minted by hive-mind_init,
|
|
89
|
+
* using a constant-time comparison (bearer-token capability check -- callers
|
|
90
|
+
* that never joined, and callers that guess/omit the token, get identical
|
|
91
|
+
* rejection). Returns an error string on failure, or null on success.
|
|
92
|
+
*/
|
|
93
|
+
function requireHiveToken(state, suppliedToken) {
|
|
94
|
+
if (!state.hiveToken) {
|
|
95
|
+
return 'Hive-mind has no capability token minted (re-run hive-mind_init)';
|
|
96
|
+
}
|
|
97
|
+
if (typeof suppliedToken !== 'string' || !suppliedToken) {
|
|
98
|
+
return 'hiveToken is required';
|
|
99
|
+
}
|
|
100
|
+
const expected = Buffer.from(state.hiveToken, 'utf-8');
|
|
101
|
+
const actual = Buffer.from(suppliedToken, 'utf-8');
|
|
102
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
|
103
|
+
return 'Invalid hiveToken';
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Read the current hiveToken directly off disk, for same-machine callers
|
|
109
|
+
* that already have filesystem access to hive state (the CLI's own
|
|
110
|
+
* `hive-mind join/leave/consensus` subcommands) -- NOT exposed over any MCP
|
|
111
|
+
* tool response (in particular, not hive-mind_status), since that's a
|
|
112
|
+
* remote-reachable surface a capability token must not leak through.
|
|
113
|
+
*/
|
|
114
|
+
export function getHiveTokenForCli() {
|
|
115
|
+
return loadHiveState().hiveToken;
|
|
116
|
+
}
|
|
86
117
|
function getHiveDir() {
|
|
87
118
|
return join(getProjectCwd(), STORAGE_DIR, HIVE_DIR);
|
|
88
119
|
}
|
|
@@ -248,6 +279,14 @@ export const hiveMindTools = [
|
|
|
248
279
|
electedAt: new Date().toISOString(),
|
|
249
280
|
term: 1,
|
|
250
281
|
};
|
|
282
|
+
// Mint a capability token on first init; a re-init (topology/consensus
|
|
283
|
+
// change on an already-initialized hive) keeps the existing token and
|
|
284
|
+
// roster rather than silently invalidating workers who already hold
|
|
285
|
+
// it -- only re-generated if somehow absent (e.g. state predates this
|
|
286
|
+
// field).
|
|
287
|
+
if (!state.hiveToken) {
|
|
288
|
+
state.hiveToken = randomBytes(32).toString('hex');
|
|
289
|
+
}
|
|
251
290
|
saveHiveState(state);
|
|
252
291
|
return {
|
|
253
292
|
success: true,
|
|
@@ -256,6 +295,7 @@ export const hiveMindTools = [
|
|
|
256
295
|
consensus: state.consensusStrategy,
|
|
257
296
|
queenId,
|
|
258
297
|
status: 'initialized',
|
|
298
|
+
hiveToken: state.hiveToken,
|
|
259
299
|
config: {
|
|
260
300
|
topology: state.topology,
|
|
261
301
|
consensus: state.consensusStrategy,
|
|
@@ -375,8 +415,9 @@ export const hiveMindTools = [
|
|
|
375
415
|
properties: {
|
|
376
416
|
agentId: { type: 'string', description: 'Agent ID to join' },
|
|
377
417
|
role: { type: 'string', enum: ['worker', 'specialist', 'scout'], description: 'Agent role in hive' },
|
|
418
|
+
hiveToken: { type: 'string', description: 'Capability token minted by hive-mind_init' },
|
|
378
419
|
},
|
|
379
|
-
required: ['agentId'],
|
|
420
|
+
required: ['agentId', 'hiveToken'],
|
|
380
421
|
},
|
|
381
422
|
handler: async (input) => {
|
|
382
423
|
const state = loadHiveState();
|
|
@@ -389,6 +430,13 @@ export const hiveMindTools = [
|
|
|
389
430
|
if (!state.initialized) {
|
|
390
431
|
return { success: false, error: 'Hive-mind not initialized' };
|
|
391
432
|
}
|
|
433
|
+
// Fail-closed: an unrecognized/missing token makes no membership
|
|
434
|
+
// change at all -- state.workers is untouched, not just left
|
|
435
|
+
// unsaved (the write below never happens on this path).
|
|
436
|
+
const tokenError = requireHiveToken(state, input.hiveToken);
|
|
437
|
+
if (tokenError) {
|
|
438
|
+
return { success: false, agentId, error: tokenError };
|
|
439
|
+
}
|
|
392
440
|
if (!state.workers.includes(agentId)) {
|
|
393
441
|
state.workers.push(agentId);
|
|
394
442
|
saveHiveState(state);
|
|
@@ -410,8 +458,9 @@ export const hiveMindTools = [
|
|
|
410
458
|
type: 'object',
|
|
411
459
|
properties: {
|
|
412
460
|
agentId: { type: 'string', description: 'Agent ID to remove' },
|
|
461
|
+
hiveToken: { type: 'string', description: 'Capability token minted by hive-mind_init' },
|
|
413
462
|
},
|
|
414
|
-
required: ['agentId'],
|
|
463
|
+
required: ['agentId', 'hiveToken'],
|
|
415
464
|
},
|
|
416
465
|
handler: async (input) => {
|
|
417
466
|
const state = loadHiveState();
|
|
@@ -421,6 +470,12 @@ export const hiveMindTools = [
|
|
|
421
470
|
if (!v.valid)
|
|
422
471
|
return { success: false, agentId, error: v.error };
|
|
423
472
|
}
|
|
473
|
+
// Fail-closed: a denied caller makes no membership change -- the
|
|
474
|
+
// splice/save below is unreachable on this path.
|
|
475
|
+
const tokenError = requireHiveToken(state, input.hiveToken);
|
|
476
|
+
if (tokenError) {
|
|
477
|
+
return { success: false, agentId, error: tokenError };
|
|
478
|
+
}
|
|
424
479
|
const index = state.workers.indexOf(agentId);
|
|
425
480
|
if (index > -1) {
|
|
426
481
|
state.workers.splice(index, 1);
|
|
@@ -448,6 +503,7 @@ export const hiveMindTools = [
|
|
|
448
503
|
value: { description: 'Proposal value (for propose)' },
|
|
449
504
|
vote: { type: 'boolean', description: 'Vote (true=for, false=against)' },
|
|
450
505
|
voterId: { type: 'string', description: 'Voter agent ID' },
|
|
506
|
+
hiveToken: { type: 'string', description: 'Capability token minted by hive-mind_init (required to vote)' },
|
|
451
507
|
strategy: { type: 'string', enum: ['bft', 'raft', 'quorum'], description: 'Consensus strategy (default: raft)' },
|
|
452
508
|
quorumPreset: { type: 'string', enum: ['unanimous', 'majority', 'supermajority'], description: 'Quorum threshold preset (for quorum strategy, default: majority)' },
|
|
453
509
|
term: { type: 'number', description: 'Term number (for raft strategy)' },
|
|
@@ -531,6 +587,28 @@ export const hiveMindTools = [
|
|
|
531
587
|
if (!voterId) {
|
|
532
588
|
return { action, error: 'voterId is required for voting' };
|
|
533
589
|
}
|
|
590
|
+
// Fail-closed: a denied caller records no vote at all -- the
|
|
591
|
+
// votes[voterId] write below is unreachable on this path, and
|
|
592
|
+
// nothing about the proposal (vote tallies, status) changes.
|
|
593
|
+
const tokenError = requireHiveToken(state, input.hiveToken);
|
|
594
|
+
if (tokenError) {
|
|
595
|
+
return { action, error: tokenError, proposalId: proposal.proposalId };
|
|
596
|
+
}
|
|
597
|
+
// voterId was previously trusted as-is: any caller-supplied string
|
|
598
|
+
// was recorded into proposal.votes and counted toward
|
|
599
|
+
// calculateRequiredVotes()'s threshold (derived from
|
|
600
|
+
// state.workers.length), with no check that it named a worker who
|
|
601
|
+
// actually joined this hive-mind. That let a single caller cross
|
|
602
|
+
// any strategy's quorum (raft/bft/quorum alike) by voting under
|
|
603
|
+
// fabricated ids — a Sybil attack on consensus, not merely a
|
|
604
|
+
// double-vote. Require the voter to be a registered worker.
|
|
605
|
+
if (!state.workers.includes(voterId)) {
|
|
606
|
+
return {
|
|
607
|
+
action,
|
|
608
|
+
error: `Voter ${voterId} is not a registered hive-mind worker`,
|
|
609
|
+
proposalId: proposal.proposalId,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
534
612
|
const voteValue = input.vote;
|
|
535
613
|
const proposalStrategy = proposal.strategy || 'raft';
|
|
536
614
|
const required = calculateRequiredVotes(proposalStrategy, totalNodes, proposal.quorumPreset);
|