claude-flow 3.46.1 → 3.47.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-flow",
3
- "version": "3.46.1",
3
+ "version": "3.47.1",
4
4
  "workspaces": [
5
5
  "v3/@claude-flow/codex",
6
6
  "v3/@claude-flow/mcp",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "generation": 6,
4
- "generatedAt": "2026-09-26T23:25:25.296Z",
5
- "gitSha": "a5adb5aa",
4
+ "generatedAt": "2026-09-28T15:44:58.000Z",
5
+ "gitSha": "bbbd7241",
6
6
  "catalog": {
7
7
  "agents": 167,
8
8
  "tools": 418,
@@ -13,6 +13,8 @@ async function run(ctx, tool, args, title) {
13
13
  // CLI flag --gateway takes precedence over the RUFLO_X_GATEWAY_URL env var (ADR-125).
14
14
  const data = await callMCPTool(tool, { gatewayUrl: ctx.flags.gateway, ...args });
15
15
  printJsonOrTable(ctx, data, title);
16
+ if (data && typeof data === 'object' && (('ok' in data && data.ok === false) || ('degraded' in data && data.degraded === true)))
17
+ return { success: false, exitCode: 1, data };
16
18
  return { success: true, data };
17
19
  }
18
20
  catch (e) {
@@ -28,8 +30,8 @@ export const federationCommand = {
28
30
  { name: 'gateway', description: 'Gateway base URL (takes precedence over RUFLO_X_GATEWAY_URL; default https://x.ruv.io)', type: 'string' },
29
31
  ],
30
32
  subcommands: [
31
- { name: 'join', description: 'Join the open swarm with YOUR OWN key using an invite code (generates ~/.ruflo/nostr.key if absent, claims via NIP-98, verifies via NIP-42)',
32
- options: [{ name: 'code', description: 'Invite code (v2.…) — a bearer secret, keep it private', type: 'string', required: true }],
33
+ { name: 'join', description: 'Join the open swarm with YOUR OWN key without an invite on open relays (generates ~/.ruflo/nostr.key if absent, registers via NIP-98, verifies via NIP-42)',
34
+ options: [{ name: 'code', description: 'Invite code (v2.…) — a bearer secret, keep it private', type: 'string' }],
33
35
  action: (ctx) => run(ctx, 'x_federation_join', { code: ctx.flags.code }, 'Join federation') },
34
36
  { name: 'sync', description: 'Fetch recent verified swarm messages',
35
37
  options: [{ name: 'since', description: 'Look-back seconds (default 3600)', type: 'number' }, { name: 'limit', description: 'Max messages', type: 'number' }, { name: 'type', description: 'Filter by message type', type: 'string' }],
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Hive-mind worker execution: the MCP config handed to a spawned Claude, and
3
+ * `hive-mind work`, which actually runs a queued task.
4
+ *
5
+ * Before this, `hive-mind task` only wrote a `pending` row to the task store.
6
+ * Workers are records, not processes, and nothing ever claimed the row: the
7
+ * task stayed pending and the worker stayed idle forever. `work` is the missing
8
+ * consumer — claim one task, assign it to an idle hive worker, run it through
9
+ * `claude -p`, and record the outcome.
10
+ */
11
+ import type { Command } from '../types.js';
12
+ export interface WorkerMcpConfigOptions {
13
+ /** `--mcp-config` from the caller; used verbatim when given. */
14
+ explicit?: string;
15
+ cwd: string;
16
+ home: string;
17
+ /** Where the generated config is written (e.g. `.hive-mind/sessions`). */
18
+ outDir: string;
19
+ swarmId: string;
20
+ /** The ruflo MCP server to register when no source config provides one. */
21
+ rufloServer: {
22
+ command: string;
23
+ args: string[];
24
+ };
25
+ }
26
+ /**
27
+ * The MCP config file to pass to a spawned Claude.
28
+ *
29
+ * A source file is never passed through as-is. `~/.claude.json` is Claude
30
+ * Code's whole state file; on a machine where it has no top-level
31
+ * `mcpServers` (a fresh desktop), Claude exits at startup with "Invalid MCP
32
+ * configuration: mcpServers: Invalid input", before any task can run. And a
33
+ * config lacking a `ruflo` server leaves the worker without the tools its
34
+ * prompt names. So the servers are extracted, a `ruflo` server is guaranteed,
35
+ * and the result is written as a config Claude is known to accept.
36
+ */
37
+ export declare function resolveWorkerMcpConfig(opts: WorkerMcpConfigOptions): string;
38
+ /**
39
+ * The ruflo MCP server as launched by THIS cli, so a worker and the CLI that
40
+ * dispatched it share one version and one store format. `@latest` could
41
+ * resolve to a different release mid-session.
42
+ */
43
+ export declare function currentRufloServer(): {
44
+ command: string;
45
+ args: string[];
46
+ };
47
+ export interface RunOutcome {
48
+ ok: boolean;
49
+ output: string;
50
+ exitCode: number | null;
51
+ sessionId?: string;
52
+ costUsd?: number;
53
+ durationMs: number;
54
+ }
55
+ export type TaskRunner = (prompt: string, opts: {
56
+ mcpConfig: string;
57
+ timeoutMs: number;
58
+ cwd: string;
59
+ }) => Promise<RunOutcome>;
60
+ /** Run one prompt through `claude -p` and parse its JSON result. */
61
+ export declare const runWithClaude: TaskRunner;
62
+ export interface WorkOnceOptions {
63
+ cwd: string;
64
+ mcpConfig: string;
65
+ runner: TaskRunner;
66
+ taskId?: string;
67
+ workerId?: string;
68
+ defaultTimeoutMs?: number;
69
+ }
70
+ export type WorkOnceResult = {
71
+ dispatched: false;
72
+ reason: string;
73
+ } | {
74
+ dispatched: true;
75
+ taskId: string;
76
+ workerId: string;
77
+ status: 'completed' | 'failed';
78
+ outcome: RunOutcome;
79
+ };
80
+ /**
81
+ * One dispatch cycle: the oldest pending hive task goes to an idle hive worker,
82
+ * runs, and its outcome is recorded on the task. Every state change goes
83
+ * through the MCP task tools, so the store is the same one `hive-mind status`
84
+ * and `task status` read.
85
+ */
86
+ export declare function workOnce(opts: WorkOnceOptions): Promise<WorkOnceResult>;
87
+ export declare const workCommand: Command;
88
+ //# sourceMappingURL=hive-mind-worker.d.ts.map
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Hive-mind worker execution: the MCP config handed to a spawned Claude, and
3
+ * `hive-mind work`, which actually runs a queued task.
4
+ *
5
+ * Before this, `hive-mind task` only wrote a `pending` row to the task store.
6
+ * Workers are records, not processes, and nothing ever claimed the row: the
7
+ * task stayed pending and the worker stayed idle forever. `work` is the missing
8
+ * consumer — claim one task, assign it to an idle hive worker, run it through
9
+ * `claude -p`, and record the outcome.
10
+ */
11
+ import { output } from '../output.js';
12
+ import { callMCPTool, MCPClientError } from '../mcp-client.js';
13
+ import { spawn as childSpawn } from 'child_process';
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
15
+ import { join } from 'path';
16
+ import { resolveClaudeLaunchCommand } from '../runtime/claude-command.js';
17
+ /** The server key the hive-mind prompt addresses (`mcp__ruflo__*`). */
18
+ const RUFLO_SERVER = 'ruflo';
19
+ function readServers(path, cwd) {
20
+ try {
21
+ if (!existsSync(path))
22
+ return null;
23
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
24
+ const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length > 0;
25
+ if (isMap(parsed.mcpServers))
26
+ return parsed.mcpServers;
27
+ // ~/.claude.json keeps project-scoped servers under projects[<cwd>].
28
+ const project = parsed.projects?.[cwd];
29
+ if (isMap(project?.mcpServers))
30
+ return project.mcpServers;
31
+ }
32
+ catch {
33
+ // Unreadable or not JSON: not a usable config.
34
+ }
35
+ return null;
36
+ }
37
+ /**
38
+ * The MCP config file to pass to a spawned Claude.
39
+ *
40
+ * A source file is never passed through as-is. `~/.claude.json` is Claude
41
+ * Code's whole state file; on a machine where it has no top-level
42
+ * `mcpServers` (a fresh desktop), Claude exits at startup with "Invalid MCP
43
+ * configuration: mcpServers: Invalid input", before any task can run. And a
44
+ * config lacking a `ruflo` server leaves the worker without the tools its
45
+ * prompt names. So the servers are extracted, a `ruflo` server is guaranteed,
46
+ * and the result is written as a config Claude is known to accept.
47
+ */
48
+ export function resolveWorkerMcpConfig(opts) {
49
+ if (opts.explicit)
50
+ return opts.explicit;
51
+ const candidates = [join(opts.cwd, '.mcp.json')];
52
+ if (opts.home)
53
+ candidates.push(join(opts.home, '.claude.json'), join(opts.home, '.claude', 'mcp.json'));
54
+ let servers = {};
55
+ for (const c of candidates) {
56
+ const found = readServers(c, opts.cwd);
57
+ if (found) {
58
+ servers = found;
59
+ break;
60
+ }
61
+ }
62
+ if (!servers[RUFLO_SERVER]) {
63
+ servers = {
64
+ ...servers,
65
+ [RUFLO_SERVER]: {
66
+ command: opts.rufloServer.command,
67
+ args: opts.rufloServer.args,
68
+ // The MCP server keys its stores off this, so the worker's tool calls
69
+ // land in the same task/agent stores as the CLI that dispatched it.
70
+ env: { CLAUDE_FLOW_CWD: opts.cwd },
71
+ },
72
+ };
73
+ }
74
+ mkdirSync(opts.outDir, { recursive: true });
75
+ const path = join(opts.outDir, `mcp-${opts.swarmId}.json`);
76
+ writeFileSync(path, JSON.stringify({ mcpServers: servers }, null, 2), 'utf-8');
77
+ return path;
78
+ }
79
+ /**
80
+ * The ruflo MCP server as launched by THIS cli, so a worker and the CLI that
81
+ * dispatched it share one version and one store format. `@latest` could
82
+ * resolve to a different release mid-session.
83
+ */
84
+ export function currentRufloServer() {
85
+ const entry = process.argv[1];
86
+ return entry
87
+ ? { command: process.execPath, args: [entry, 'mcp', 'start'] }
88
+ : { command: 'npx', args: ['-y', '@claude-flow/cli@latest', 'mcp', 'start'] };
89
+ }
90
+ /** Run one prompt through `claude -p` and parse its JSON result. */
91
+ export const runWithClaude = (prompt, opts) => new Promise((resolve) => {
92
+ const started = Date.now();
93
+ const launch = resolveClaudeLaunchCommand();
94
+ if (!launch) {
95
+ resolve({ ok: false, output: 'Claude Code CLI not found in PATH', exitCode: null, durationMs: 0 });
96
+ return;
97
+ }
98
+ // The prompt goes on stdin, not argv: a task description has no length
99
+ // bound and `--mcp-config` is variadic (#1780).
100
+ const child = childSpawn(launch.command, [...launch.argsPrefix, '-p', '--output-format', 'json', `--mcp-config=${opts.mcpConfig}`], { cwd: opts.cwd, stdio: ['pipe', 'pipe', 'pipe'] });
101
+ let stdout = '';
102
+ let stderr = '';
103
+ child.stdout.on('data', (d) => (stdout += d));
104
+ child.stderr.on('data', (d) => (stderr += d));
105
+ const timer = setTimeout(() => child.kill('SIGTERM'), opts.timeoutMs);
106
+ child.on('error', (e) => {
107
+ clearTimeout(timer);
108
+ resolve({ ok: false, output: e.message, exitCode: null, durationMs: Date.now() - started });
109
+ });
110
+ child.on('close', (code, signal) => {
111
+ clearTimeout(timer);
112
+ const durationMs = Date.now() - started;
113
+ try {
114
+ const r = JSON.parse(stdout);
115
+ resolve({
116
+ ok: code === 0 && !r.is_error,
117
+ output: r.result ?? '',
118
+ exitCode: code,
119
+ sessionId: r.session_id,
120
+ costUsd: r.total_cost_usd,
121
+ durationMs,
122
+ });
123
+ }
124
+ catch {
125
+ const why = signal === 'SIGTERM' ? `timed out after ${opts.timeoutMs}ms` : (stderr || stdout).trim().slice(-2000);
126
+ resolve({ ok: false, output: why, exitCode: code, durationMs });
127
+ }
128
+ });
129
+ child.stdin.end(prompt);
130
+ });
131
+ function timeoutFromTags(tags, fallback) {
132
+ const tag = tags?.find((t) => /^timeout:\d+s$/.test(t));
133
+ return tag ? Number(tag.slice('timeout:'.length, -1)) * 1000 : fallback;
134
+ }
135
+ /**
136
+ * One dispatch cycle: the oldest pending hive task goes to an idle hive worker,
137
+ * runs, and its outcome is recorded on the task. Every state change goes
138
+ * through the MCP task tools, so the store is the same one `hive-mind status`
139
+ * and `task status` read.
140
+ */
141
+ export async function workOnce(opts) {
142
+ let taskId = opts.taskId;
143
+ if (!taskId) {
144
+ const listed = await callMCPTool('task_list', { status: 'pending', type: 'hive-mind', limit: 1000 });
145
+ const oldest = [...(listed.tasks ?? [])].sort((a, b) => a.createdAt.localeCompare(b.createdAt))[0];
146
+ if (!oldest)
147
+ return { dispatched: false, reason: 'No pending hive-mind tasks' };
148
+ taskId = oldest.taskId;
149
+ }
150
+ const task = await callMCPTool('task_status', { taskId });
151
+ if (task.status !== 'pending') {
152
+ return { dispatched: false, reason: `Task ${taskId} is ${task.status}, not pending` };
153
+ }
154
+ let workerId = opts.workerId;
155
+ if (!workerId) {
156
+ const hive = await callMCPTool('hive-mind_status', {});
157
+ workerId = hive.workers?.find((w) => w.status === 'idle')?.id;
158
+ if (!workerId)
159
+ return { dispatched: false, reason: 'No idle hive worker — run "hive-mind spawn" first' };
160
+ }
161
+ await callMCPTool('task_assign', { taskId, agentIds: [workerId] });
162
+ const prompt = `You are hive-mind worker ${workerId}. Complete this task, then reply with ` +
163
+ `the result only.\n\nTask ${taskId}:\n${task.description}`;
164
+ const outcome = await opts.runner(prompt, {
165
+ mcpConfig: opts.mcpConfig,
166
+ timeoutMs: timeoutFromTags(task.tags, opts.defaultTimeoutMs ?? 300_000),
167
+ cwd: opts.cwd,
168
+ });
169
+ const result = {
170
+ worker: workerId,
171
+ output: outcome.output,
172
+ exitCode: outcome.exitCode,
173
+ durationMs: outcome.durationMs,
174
+ ...(outcome.sessionId ? { sessionId: outcome.sessionId } : {}),
175
+ ...(outcome.costUsd !== undefined ? { costUsd: outcome.costUsd } : {}),
176
+ };
177
+ if (outcome.ok) {
178
+ await callMCPTool('task_complete', { taskId, result });
179
+ return { dispatched: true, taskId, workerId, status: 'completed', outcome };
180
+ }
181
+ await callMCPTool('task_update', { taskId, status: 'failed', result });
182
+ return { dispatched: true, taskId, workerId, status: 'failed', outcome };
183
+ }
184
+ export const workCommand = {
185
+ name: 'work',
186
+ description: 'Run the next pending hive task on an idle worker via Claude Code and record the result',
187
+ options: [
188
+ { name: 'task', description: 'Run this task id instead of the oldest pending one', type: 'string' },
189
+ { name: 'worker', description: 'Assign to this worker id instead of the first idle one', type: 'string' },
190
+ { name: 'mcp-config', description: 'MCP config for the worker (default: generated, with the ruflo server)', type: 'string' },
191
+ ],
192
+ examples: [
193
+ { command: 'claude-flow hive-mind work', description: 'Run the oldest pending hive task' },
194
+ { command: 'claude-flow hive-mind work --task task-123', description: 'Run a specific task' },
195
+ ],
196
+ action: async (ctx) => {
197
+ const cwd = ctx.cwd || process.cwd();
198
+ try {
199
+ const mcpConfig = resolveWorkerMcpConfig({
200
+ explicit: ctx.flags.mcpConfig,
201
+ cwd,
202
+ home: process.env.HOME || process.env.USERPROFILE || '',
203
+ outDir: join(cwd, '.hive-mind', 'sessions'),
204
+ swarmId: 'worker',
205
+ rufloServer: currentRufloServer(),
206
+ });
207
+ const r = await workOnce({
208
+ cwd,
209
+ mcpConfig,
210
+ runner: runWithClaude,
211
+ taskId: ctx.flags.task,
212
+ workerId: ctx.flags.worker,
213
+ });
214
+ if (ctx.flags.format === 'json') {
215
+ output.printJson(r);
216
+ }
217
+ else if (!r.dispatched) {
218
+ output.printInfo(r.reason);
219
+ }
220
+ else {
221
+ output.printBox([
222
+ `Task ID: ${r.taskId}`,
223
+ `Worker: ${r.workerId}`,
224
+ `Status: ${r.status}`,
225
+ `Duration: ${r.outcome.durationMs}ms`,
226
+ '',
227
+ r.outcome.output.slice(0, 2000),
228
+ ].join('\n'), r.status === 'completed' ? 'Task Completed' : 'Task Failed');
229
+ output.writeln(output.dim(` Full record: claude-flow task status ${r.taskId}`));
230
+ }
231
+ // Nothing to do is not an error; a task that ran and failed is.
232
+ const failed = r.dispatched && r.status === 'failed';
233
+ return { success: !failed, exitCode: failed ? 1 : 0, data: r };
234
+ }
235
+ catch (error) {
236
+ const msg = error instanceof MCPClientError ? error.message : String(error);
237
+ output.printError(`Work error: ${msg}`);
238
+ return { success: false, exitCode: 1 };
239
+ }
240
+ },
241
+ };
242
+ //# sourceMappingURL=hive-mind-worker.js.map
@@ -10,7 +10,7 @@ import { select, confirm, input } from '../prompt.js';
10
10
  import { callMCPTool, MCPClientError } from '../mcp-client.js';
11
11
  import { spawn as childSpawn } from 'child_process';
12
12
  import { mkdir, writeFile } from 'fs/promises';
13
- import { existsSync } from 'fs';
13
+ import { resolveWorkerMcpConfig, currentRufloServer, workCommand } from './hive-mind-worker.js';
14
14
  import { join } from 'path';
15
15
  import { resolveClaudeLaunchCommand } from '../runtime/claude-command.js';
16
16
  import { getHiveTokenForCli } from '../mcp-tools/hive-mind-tools.js';
@@ -202,44 +202,25 @@ async function spawnClaudeCodeInstance(swarmId, swarmName, objective, workers, f
202
202
  // Build arguments - flags first, then prompt
203
203
  const claudeArgs = [];
204
204
  // #1748 Issue 2 — pass --mcp-config so the spawned worker actually has
205
- // mcp__ruflo__* tools registered. Before this, the coordination prompt
206
- // referenced tools the worker didn't know about and exited silently.
207
- // Resolution order:
208
- // 1. explicit --mcp-config <path> flag passed by the caller
209
- // 2. ./.mcp.json in cwd (project-local Ruflo MCP config)
210
- // 3. ~/.claude.json or ~/.claude/mcp.json (user-global)
211
- // If none found, we still spawn but warn — that's the pre-fix behavior
212
- // and the user's debug log will surface the missing tools.
213
- const explicitMcpConfig = flags['mcp-config'];
214
- let mcpConfigPath = explicitMcpConfig;
215
- if (!mcpConfigPath) {
216
- const candidates = [
217
- join(process.cwd(), '.mcp.json'),
218
- join(process.env.HOME || process.env.USERPROFILE || '', '.claude.json'),
219
- join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'mcp.json'),
220
- ];
221
- for (const c of candidates) {
222
- try {
223
- if (c && existsSync(c)) {
224
- mcpConfigPath = c;
225
- break;
226
- }
227
- }
228
- catch { /* continue */ }
229
- }
230
- }
231
- if (mcpConfigPath) {
232
- // #1780 — Claude Code's `--mcp-config` is variadic; passing it as two
233
- // argv tokens (`--mcp-config`, `<path>`) lets a later positional (the
234
- // hive-mind prompt) be slurped as a second config file, producing
235
- // `ENAMETOOLONG: name too long, open` once the prompt exceeds PATH_MAX.
236
- // Use `=`-syntax so the flag stays attached to its single value.
237
- claudeArgs.push(`--mcp-config=${mcpConfigPath}`);
238
- output.printInfo(`Spawned worker MCP config: ${mcpConfigPath}`);
239
- }
240
- else {
241
- output.printWarning('No .mcp.json or ~/.claude.json found — spawned worker will not have mcp__ruflo__* tools (#1748 Issue 2). Pass --mcp-config <path> or run "ruflo init" to generate one.');
242
- }
205
+ // mcp__ruflo__* tools registered. The config is always a generated file
206
+ // that Claude accepts; see resolveWorkerMcpConfig for why a source
207
+ // config (notably ~/.claude.json) is never passed through as-is.
208
+ // The parser stores flags camelCased (#2269), so read both forms.
209
+ const mcpConfigPath = resolveWorkerMcpConfig({
210
+ explicit: (flags['mcp-config'] ?? flags.mcpConfig),
211
+ cwd: process.cwd(),
212
+ home: process.env.HOME || process.env.USERPROFILE || '',
213
+ outDir: sessionsDir,
214
+ swarmId,
215
+ rufloServer: currentRufloServer(),
216
+ });
217
+ // #1780 — Claude Code's `--mcp-config` is variadic; passing it as two
218
+ // argv tokens (`--mcp-config`, `<path>`) lets a later positional (the
219
+ // hive-mind prompt) be slurped as a second config file, producing
220
+ // `ENAMETOOLONG: name too long, open` once the prompt exceeds PATH_MAX.
221
+ // Use `=`-syntax so the flag stays attached to its single value.
222
+ claudeArgs.push(`--mcp-config=${mcpConfigPath}`);
223
+ output.printInfo(`Spawned worker MCP config: ${mcpConfigPath}`);
243
224
  // Check for non-interactive mode
244
225
  const isNonInteractive = flags['non-interactive'] || flags.nonInteractive;
245
226
  if (isNonInteractive) {
@@ -882,7 +863,8 @@ const taskCommand = {
882
863
  ].join('\n'), 'Task Submitted');
883
864
  output.writeln();
884
865
  output.printSuccess('Task submitted to hive');
885
- output.writeln(output.dim(` Track with: claude-flow hive-mind task-status ${result.taskId}`));
866
+ output.writeln(output.dim(` Run it with: claude-flow hive-mind work --task ${result.taskId}`));
867
+ output.writeln(output.dim(` Track with: claude-flow task status ${result.taskId}`));
886
868
  return { success: true, data: result };
887
869
  }
888
870
  catch (error) {
@@ -1210,7 +1192,7 @@ export const hiveMindCommand = {
1210
1192
  name: 'hive-mind',
1211
1193
  aliases: ['hive'],
1212
1194
  description: 'Queen-led consensus-based multi-agent coordination',
1213
- subcommands: [initCommand, spawnCommand, statusCommand, taskCommand, joinCommand, leaveCommand, consensusCommand, broadcastCommand, memorySubCommand, optimizeMemoryCommand, shutdownCommand],
1195
+ subcommands: [initCommand, spawnCommand, statusCommand, taskCommand, workCommand, joinCommand, leaveCommand, consensusCommand, broadcastCommand, memorySubCommand, optimizeMemoryCommand, shutdownCommand],
1214
1196
  options: [],
1215
1197
  examples: [
1216
1198
  { command: 'claude-flow hive-mind init -t hierarchical-mesh', description: 'Initialize hive' },
@@ -1230,6 +1212,7 @@ export const hiveMindCommand = {
1230
1212
  `${output.highlight('spawn')} - Spawn worker agents (use --claude to launch Claude Code)`,
1231
1213
  `${output.highlight('status')} - Show hive status`,
1232
1214
  `${output.highlight('task')} - Submit task to hive`,
1215
+ `${output.highlight('work')} - Run the next pending task on an idle worker`,
1233
1216
  `${output.highlight('join')} - Join an agent to the hive`,
1234
1217
  `${output.highlight('leave')} - Remove an agent from the hive`,
1235
1218
  `${output.highlight('consensus')} - Manage consensus proposals`,
@@ -40,6 +40,53 @@ function saveTaskStore(store) {
40
40
  ensureTaskDir();
41
41
  writeFileSync(getTaskPath(), JSON.stringify(store, null, 2), 'utf-8');
42
42
  }
43
+ /**
44
+ * Apply an agent-state change to every store that holds the agent.
45
+ *
46
+ * `hive-mind_spawn` keeps its workers in `.claude-flow/agents.json`, not the
47
+ * canonical `.claude-flow/agents/store.json` (#1916). Syncing only the
48
+ * canonical store meant assigning a task to a hive worker never marked it
49
+ * busy, and completing one never counted it: `hive-mind status` showed the
50
+ * worker idle with 0 completed while its task sat in the queue.
51
+ */
52
+ function updateAgents(agentIds, mutate) {
53
+ if (agentIds.length === 0)
54
+ return;
55
+ const paths = [
56
+ join(getProjectCwd(), STORAGE_DIR, 'agents', 'store.json'),
57
+ join(getProjectCwd(), STORAGE_DIR, 'agents.json'),
58
+ ];
59
+ for (const path of paths) {
60
+ try {
61
+ if (!existsSync(path))
62
+ continue;
63
+ const store = JSON.parse(readFileSync(path, 'utf-8'));
64
+ if (!store.agents)
65
+ continue;
66
+ let touched = false;
67
+ for (const id of agentIds) {
68
+ if (store.agents[id]) {
69
+ mutate(store.agents[id]);
70
+ touched = true;
71
+ }
72
+ }
73
+ if (touched)
74
+ writeFileSync(path, JSON.stringify(store, null, 2), 'utf-8');
75
+ }
76
+ catch {
77
+ // Best-effort agent sync: a corrupt agent store must not fail the task op.
78
+ }
79
+ }
80
+ }
81
+ /** Return agents still holding `taskId` to idle. */
82
+ function releaseAgents(agentIds, taskId) {
83
+ updateAgents(agentIds, (agent) => {
84
+ if (agent.currentTask === taskId) {
85
+ agent.status = 'idle';
86
+ agent.currentTask = null;
87
+ }
88
+ });
89
+ }
43
90
  export const taskTools = [
44
91
  {
45
92
  name: 'task_create',
@@ -214,33 +261,22 @@ export const taskTools = [
214
261
  const taskId = input.taskId;
215
262
  const task = store.tasks[taskId];
216
263
  if (task) {
264
+ if (task.status === 'completed') {
265
+ return { taskId: task.taskId, status: task.status, completedAt: task.completedAt, result: task.result };
266
+ }
217
267
  task.status = 'completed';
218
268
  task.progress = 100;
219
269
  task.completedAt = new Date().toISOString();
220
270
  task.result = input.result || {};
221
271
  saveTaskStore(store);
222
272
  // Sync assigned agents back to idle and increment taskCount
223
- if (task.assignedTo.length > 0) {
224
- const agentStorePath = join(getProjectCwd(), STORAGE_DIR, 'agents', 'store.json');
225
- try {
226
- let agentStore = { agents: {} };
227
- if (existsSync(agentStorePath)) {
228
- agentStore = JSON.parse(readFileSync(agentStorePath, 'utf-8'));
229
- }
230
- for (const agentId of task.assignedTo) {
231
- if (agentStore.agents[agentId]) {
232
- agentStore.agents[agentId].status = 'idle';
233
- agentStore.agents[agentId].currentTask = null;
234
- agentStore.agents[agentId].taskCount =
235
- (agentStore.agents[agentId].taskCount || 0) + 1;
236
- }
237
- }
238
- writeFileSync(agentStorePath, JSON.stringify(agentStore, null, 2), 'utf-8');
239
- }
240
- catch {
241
- // Best-effort agent sync
273
+ updateAgents(task.assignedTo, (agent) => {
274
+ if (agent.currentTask === taskId) {
275
+ agent.status = 'idle';
276
+ agent.currentTask = null;
242
277
  }
243
- }
278
+ agent.taskCount = (agent.taskCount || 0) + 1;
279
+ });
244
280
  return {
245
281
  taskId: task.taskId,
246
282
  status: task.status,
@@ -266,6 +302,7 @@ export const taskTools = [
266
302
  status: { type: 'string', description: 'New status' },
267
303
  progress: { type: 'number', description: 'Progress percentage (0-100)' },
268
304
  assignTo: { type: 'array', items: { type: 'string' }, description: 'Agent IDs to assign' },
305
+ result: { type: 'object', description: 'Result data (e.g. the failure of a failed task)' },
269
306
  },
270
307
  required: ['taskId'],
271
308
  },
@@ -284,6 +321,12 @@ export const taskTools = [
284
321
  if (newStatus === 'in_progress' && !task.startedAt) {
285
322
  task.startedAt = new Date().toISOString();
286
323
  }
324
+ // A failed task frees its workers, as task_complete and task_cancel do;
325
+ // otherwise a worker whose run failed stays `busy` forever.
326
+ if (newStatus === 'failed') {
327
+ task.completedAt = new Date().toISOString();
328
+ releaseAgents(task.assignedTo, taskId);
329
+ }
287
330
  }
288
331
  if (typeof input.progress === 'number') {
289
332
  task.progress = Math.min(100, Math.max(0, input.progress));
@@ -291,6 +334,9 @@ export const taskTools = [
291
334
  if (input.assignTo) {
292
335
  task.assignedTo = input.assignTo;
293
336
  }
337
+ if (input.result && typeof input.result === 'object') {
338
+ task.result = input.result;
339
+ }
294
340
  saveTaskStore(store);
295
341
  return {
296
342
  success: true,
@@ -332,41 +378,20 @@ export const taskTools = [
332
378
  return { taskId, error: 'Task not found' };
333
379
  }
334
380
  const previouslyAssigned = [...task.assignedTo];
335
- // Load agent store to sync worker state
336
- const agentStorePath = join(getProjectCwd(), STORAGE_DIR, 'agents', 'store.json');
337
- let agentStore = { agents: {} };
338
- try {
339
- if (existsSync(agentStorePath)) {
340
- agentStore = JSON.parse(readFileSync(agentStorePath, 'utf-8'));
341
- }
342
- }
343
- catch { /* ignore */ }
344
381
  if (input.unassign) {
345
382
  // Revert previously assigned agents to idle
346
- for (const agentId of previouslyAssigned) {
347
- if (agentStore.agents[agentId]) {
348
- agentStore.agents[agentId].status = 'idle';
349
- agentStore.agents[agentId].currentTask = null;
350
- }
351
- }
383
+ releaseAgents(previouslyAssigned, taskId);
352
384
  task.assignedTo = [];
353
385
  }
354
386
  else {
355
387
  const agentIds = input.agentIds || [];
356
388
  // Revert old agents to idle
357
- for (const agentId of previouslyAssigned) {
358
- if (!agentIds.includes(agentId) && agentStore.agents[agentId]) {
359
- agentStore.agents[agentId].status = 'idle';
360
- agentStore.agents[agentId].currentTask = null;
361
- }
362
- }
389
+ releaseAgents(previouslyAssigned.filter((id) => !agentIds.includes(id)), taskId);
363
390
  // Set new agents to active
364
- for (const agentId of agentIds) {
365
- if (agentStore.agents[agentId]) {
366
- agentStore.agents[agentId].status = 'active';
367
- agentStore.agents[agentId].currentTask = taskId;
368
- }
369
- }
391
+ updateAgents(agentIds, (agent) => {
392
+ agent.status = 'busy';
393
+ agent.currentTask = taskId;
394
+ });
370
395
  task.assignedTo = agentIds;
371
396
  // Auto-transition task to in_progress if pending
372
397
  if (task.status === 'pending' && agentIds.length > 0) {
@@ -377,12 +402,6 @@ export const taskTools = [
377
402
  }
378
403
  }
379
404
  saveTaskStore(store);
380
- // Save agent store
381
- const agentDir = join(getProjectCwd(), STORAGE_DIR, 'agents');
382
- if (!existsSync(agentDir)) {
383
- mkdirSync(agentDir, { recursive: true });
384
- }
385
- writeFileSync(agentStorePath, JSON.stringify(agentStore, null, 2), 'utf-8');
386
405
  return {
387
406
  taskId: task.taskId,
388
407
  assignedTo: task.assignedTo,
@@ -421,6 +440,7 @@ export const taskTools = [
421
440
  task.completedAt = new Date().toISOString();
422
441
  task.result = { cancelReason: input.reason || 'Cancelled by user' };
423
442
  saveTaskStore(store);
443
+ releaseAgents(task.assignedTo, taskId);
424
444
  return {
425
445
  success: true,
426
446
  taskId: task.taskId,
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Self-service join for the open swarm federation — the user-facing invite path.
2
+ * Self-service federation join with a local key: public registration or a private invite.
3
3
  *
4
4
  * Decentralized by design: the user generates/holds THEIR OWN Nostr key locally,
5
- * redeems an invite code with a NIP-98-signed claim (no admin in the loop), proves
5
+ * registers with a NIP-98-signed request (no admin in the user flow), proves
6
6
  * membership with NIP-42, and announces themselves. The gateway never signs for them.
7
7
  *
8
8
  * `nostr-tools` is an optional dependency (secp256k1/Schnorr is not in node:crypto):
@@ -36,49 +36,92 @@ export function nip98Header(nt, sk, url, method, body) {
36
36
  export async function verifyMembership(nt, sk, relayWs) {
37
37
  const { default: WebSocket } = await import('ws');
38
38
  return new Promise((resolve) => {
39
- const ws = new WebSocket(relayWs, { perMessageDeflate: false });
40
- let done = false;
39
+ const ws = new WebSocket(relayWs, { perMessageDeflate: false, maxPayload: 64 * 1024 });
40
+ let done = false, authId;
41
41
  const fin = (r) => { if (done)
42
- return; done = true; try {
42
+ return; done = true; clearTimeout(timer); try {
43
43
  ws.close();
44
44
  }
45
45
  catch { /* */ } resolve(r); };
46
+ const timer = setTimeout(() => fin({ ok: false, reason: 'timeout' }), 15000);
46
47
  ws.on('message', (d) => {
47
- const m = JSON.parse(d.toString());
48
- if (m[0] === 'AUTH' && typeof m[1] === 'string')
49
- ws.send(JSON.stringify(['AUTH', nt.finalizeEvent({ kind: 22242, created_at: Math.floor(Date.now() / 1000), tags: [['relay', relayWs], ['challenge', m[1]]], content: '' }, sk)]));
50
- else if (m[0] === 'OK')
51
- fin({ ok: !!m[2], reason: m[3] });
48
+ let m;
49
+ try {
50
+ m = JSON.parse(d.toString());
51
+ }
52
+ catch {
53
+ return;
54
+ }
55
+ if (!Array.isArray(m))
56
+ return;
57
+ if (m[0] === 'AUTH' && typeof m[1] === 'string' && m[1].length <= 1024 && !authId) {
58
+ const ev = nt.finalizeEvent({ kind: 22242, created_at: Math.floor(Date.now() / 1000), tags: [['relay', relayWs], ['challenge', m[1]]], content: '' }, sk);
59
+ authId = ev.id;
60
+ ws.send(JSON.stringify(['AUTH', ev]));
61
+ }
62
+ else if (m[0] === 'OK' && authId && m[1] === authId)
63
+ fin({ ok: m[2] === true, reason: m[3] });
52
64
  });
53
65
  ws.on('error', (e) => fin({ ok: false, reason: e.message }));
54
- setTimeout(() => fin({ ok: false, reason: 'timeout' }), 15000);
66
+ ws.on('close', () => fin({ ok: false, reason: 'closed before authentication' }));
55
67
  });
56
68
  }
69
+ function secureBase(value) {
70
+ const u = new URL(value);
71
+ if ((u.protocol !== 'https:' && !(u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname)))
72
+ || u.username || u.password || u.search || u.hash || u.pathname !== '/')
73
+ throw new Error('gateway must be an HTTPS origin (HTTP allowed only for loopback tests)');
74
+ return u.origin;
75
+ }
57
76
  export const xFederationJoinTools = [{
58
77
  name: 'x_federation_join',
59
- description: 'Join the open swarm federation with YOUR OWN key using an invite code: generates (or reuses) a local Nostr key at ~/.ruflo/nostr.key (0600), redeems the code with a NIP-98-signed claim directly against the relay, proves membership via NIP-42, and returns your pubkey. Use when you have been handed an invite code and want to participate as yourself. Asking an admin to `federation_admit` you instead is wrong for an open swarm because it centralizes onboarding and requires trusting a pubkey out of band; the invite claim binds membership to the key you hold. Never share the invite code publicly — it is a bearer secret.',
78
+ description: 'Join the federation with YOUR OWN local key, without an invite when public registration is enabled. Reuses ~/.ruflo/nostr.key (0600), proves key ownership with NIP-98, and verifies membership with NIP-42. Use when a user wants to join as themselves. Publishing as the gateway is wrong for personal identity. Optional private invite codes remain supported; never share keys or codes in chat.',
60
79
  inputSchema: { type: 'object', properties: {
61
- code: { type: 'string', description: 'Invite code (v2.…) received privately from a member/admin.' },
80
+ code: { type: 'string', description: 'Optional private invite code (v2.…), for invite-only relays.' },
81
+ gatewayUrl: { type: 'string', description: 'Public registration gateway origin; defaults to https://x.ruv.io.' },
62
82
  relayHttp: { type: 'string', description: 'Relay HTTPS base for the claim; takes precedence over RUFLO_X_RELAY_HTTP.' },
63
83
  relayWs: { type: 'string', description: 'Relay wss URL for NIP-42; takes precedence over RUFLO_X_RELAY_WS.' },
64
84
  keyFile: { type: 'string', description: 'Key file path; takes precedence over RUFLO_NOSTR_KEY_FILE (default ~/.ruflo/nostr.key).' }
65
- }, required: ['code'] },
85
+ }, required: [] },
66
86
  handler: async (input) => {
67
87
  const i = input;
68
88
  const nt = await loadNostrTools();
69
89
  // Validate input before the optional-dependency check so a bad code fails fast and identically
70
90
  // whether or not nostr-tools is present.
71
- if (!/^v2\.[A-Za-z0-9._-]{8,}$/.test(i.code))
91
+ if (i.code !== undefined && !/^v2\.[A-Za-z0-9._-]{8,}$/.test(i.code))
72
92
  throw new Error('invite code must look like v2.<token>');
73
93
  if (!nt)
74
94
  return { degraded: true, reason: 'nostr-tools not installed', hint: 'npm i -g nostr-tools (secp256k1 signing is not in node:crypto)' };
95
+ const registrationBase = i.code ? undefined : secureBase(i.gatewayUrl || process.env.RUFLO_X_GATEWAY_URL || 'https://x.ruv.io');
75
96
  const { sk, pubkey, created } = loadOrCreateKey(nt, i.keyFile);
76
- const url = `${HTTP_BASE(i.relayHttp)}/api/invites/claim`;
77
- const body = JSON.stringify({ code: i.code });
78
- const r = await fetch(url, { method: 'POST', headers: { Authorization: nip98Header(nt, sk, url, 'POST', body), 'Content-Type': 'application/json' }, body, signal: AbortSignal.timeout(20_000) });
97
+ // Existing members need no new grant. In particular never downgrade/re-admit
98
+ // their key just because they run join again.
99
+ if (!i.code) {
100
+ const existing = await verifyMembership(nt, sk, RELAY_WS(i.relayWs));
101
+ if (existing.ok)
102
+ return { ok: true, pubkey, keyCreated: created, membershipVerified: true, alreadyMember: true };
103
+ }
104
+ const url = i.code ? `${secureBase(HTTP_BASE(i.relayHttp))}/api/invites/claim` : `${registrationBase}/api/registration`;
105
+ const body = i.code ? JSON.stringify({ code: i.code }) : '{}';
106
+ let r;
107
+ try {
108
+ r = await fetch(url, { method: 'POST', redirect: 'error', headers: { Authorization: nip98Header(nt, sk, url, 'POST', body), 'Content-Type': 'application/json' }, body, signal: AbortSignal.timeout(20_000) });
109
+ }
110
+ catch {
111
+ const check = await verifyMembership(nt, sk, RELAY_WS(i.relayWs));
112
+ if (check.ok)
113
+ return { ok: true, pubkey, keyCreated: created, membershipVerified: true };
114
+ throw new Error('Admission response unavailable and relay membership unverified. Keep your local key and retry; do not create another identity.');
115
+ }
79
116
  const claim = (await r.json().catch(() => ({})));
80
- if (!r.ok)
81
- throw new Error(`claim rejected (${r.status}): ${claim.error ?? claim.message ?? 'unknown'}`);
117
+ if (!r.ok) {
118
+ // A lost acknowledgment can follow a successful grant. Check with the
119
+ // relay before declaring failure; never silently generate a second key.
120
+ const check = await verifyMembership(nt, sk, RELAY_WS(i.relayWs));
121
+ if (check.ok)
122
+ return { ok: true, pubkey, keyCreated: created, membershipVerified: true };
123
+ throw new Error(`join rejected (${r.status}): ${claim.error ?? claim.message ?? 'unknown'}`);
124
+ }
82
125
  const auth = await verifyMembership(nt, sk, RELAY_WS(i.relayWs));
83
126
  return { ok: auth.ok, pubkey, keyCreated: created, role: claim.role ?? 'member', membershipVerified: auth.ok, ...(auth.ok ? {} : { reason: auth.reason }),
84
127
  next: 'Publish kind-1 events tagged ["t","ruflo-swarm"] — or run `ruflo federation sync` to read the swarm.' };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-flow/cli",
3
- "version": "3.46.1",
3
+ "version": "3.47.1",
4
4
  "type": "module",
5
5
  "description": "Ruflo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
6
6
  "main": "dist/src/index.js",