claude-flow 3.47.0 → 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.47.0",
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-27T19:39:37.000Z",
5
- "gitSha": "09595b70",
4
+ "generatedAt": "2026-09-28T15:44:58.000Z",
5
+ "gitSha": "bbbd7241",
6
6
  "catalog": {
7
7
  "agents": 167,
8
8
  "tools": 418,
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-flow/cli",
3
- "version": "3.47.0",
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",