claude-flow 3.26.0 → 3.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/helpers/.helpers-version +1 -1
- package/.claude/helpers/helpers.manifest.json +4 -5
- package/.claude/helpers/hook-handler.cjs +0 -129
- package/.claude/helpers/intelligence.cjs +0 -10
- package/.claude/helpers/statusline.cjs +12 -2
- package/package.json +1 -1
- package/v3/@claude-flow/cli/catalog-manifest.json +2 -2
- package/v3/@claude-flow/cli/dist/src/commands/daemon.js +163 -3
- package/v3/@claude-flow/cli/dist/src/init/helpers-generator.js +31 -0
- package/v3/@claude-flow/cli/dist/src/init/statusline-generator.js +12 -2
- package/v3/@claude-flow/cli/dist/src/services/ai-job-dedup.d.ts +61 -0
- package/v3/@claude-flow/cli/dist/src/services/ai-job-dedup.js +136 -0
- package/v3/@claude-flow/cli/dist/src/services/git-workspace-identity.d.ts +42 -0
- package/v3/@claude-flow/cli/dist/src/services/git-workspace-identity.js +99 -0
- package/v3/@claude-flow/cli/dist/src/services/global-ai-budget.d.ts +110 -0
- package/v3/@claude-flow/cli/dist/src/services/global-ai-budget.js +359 -0
- package/v3/@claude-flow/cli/dist/src/services/headless-worker-executor.d.ts +14 -0
- package/v3/@claude-flow/cli/dist/src/services/headless-worker-executor.js +104 -3
- package/v3/@claude-flow/cli/dist/src/services/worker-daemon.d.ts +14 -0
- package/v3/@claude-flow/cli/dist/src/services/worker-daemon.js +81 -17
- package/v3/@claude-flow/cli/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.23.0
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest": {
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.23.0",
|
|
4
4
|
"files": {
|
|
5
5
|
"auto-memory-hook.mjs": "68be7e9a9eba7bf9c4e8a230db7bf61a243b965639f8504842799d6c6ca28762",
|
|
6
|
-
"hook-handler.cjs": "
|
|
7
|
-
"intelligence.cjs": "
|
|
8
|
-
"statusline.cjs": "ac6be4ed31b30cef7b325c15d0feb77f3a8a26a69f7186907ba0340d332f2d05"
|
|
6
|
+
"hook-handler.cjs": "bc4d2d26823d49b3ab1dded2946b66369f1f67bd76b4c30dca9f3b6df3cca8f0",
|
|
7
|
+
"intelligence.cjs": "760cb82ed2000031abc1ac1fa90a014e7d91d93966e3aa4741b07ab7aff8d066"
|
|
9
8
|
}
|
|
10
9
|
},
|
|
11
|
-
"signature": "
|
|
10
|
+
"signature": "of5l9RP68iPcmcC5dckDv+KIOiENJKifWxYKwQTwAiXiyRNNZuMwQ8y8Bd8+wGviTRxIzBfSuxL1nuJ/rX3xDQ==",
|
|
12
11
|
"algorithm": "ed25519"
|
|
13
12
|
}
|
|
@@ -15,79 +15,9 @@
|
|
|
15
15
|
|
|
16
16
|
const path = require('path');
|
|
17
17
|
const fs = require('fs');
|
|
18
|
-
const os = require('os');
|
|
19
18
|
|
|
20
19
|
const helpersDir = __dirname;
|
|
21
20
|
|
|
22
|
-
// Resolve an installed @claude-flow/cli (or ruflo) bin — mirrors
|
|
23
|
-
// statusline-generator.ts's resolveCliBin() candidate list. Used only to
|
|
24
|
-
// spawn the detached funnel-refresh helper below; failures are silent (no
|
|
25
|
-
// candidate found just means the refresh never fires this session).
|
|
26
|
-
function resolveCliBinForHook() {
|
|
27
|
-
try {
|
|
28
|
-
const home = os.homedir();
|
|
29
|
-
const cwd = process.cwd();
|
|
30
|
-
const candidates = [
|
|
31
|
-
path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'bin', 'cli.js'),
|
|
32
|
-
path.join(cwd, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'),
|
|
33
|
-
path.join(cwd, 'node_modules', 'ruflo', 'bin', 'cli.js'),
|
|
34
|
-
path.join(cwd, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'),
|
|
35
|
-
// helpersDir is .claude/helpers/ inside the package itself when this
|
|
36
|
-
// file is running from a real @claude-flow/cli install (not a project
|
|
37
|
-
// that merely copied the helper) — its bin/ is two levels up.
|
|
38
|
-
path.join(helpersDir, '..', '..', 'bin', 'cli.js'),
|
|
39
|
-
];
|
|
40
|
-
for (const p of candidates) {
|
|
41
|
-
if (fs.existsSync(p)) return p;
|
|
42
|
-
}
|
|
43
|
-
} catch (e) { /* ignore */ }
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Fire-and-forget doesn't work when the CALLER is itself a short-lived
|
|
48
|
-
// subprocess (confirmed live: two consecutive statusline renders 5s apart
|
|
49
|
-
// both saw an empty funnel-messages-cache, because the async HTTPS fetch
|
|
50
|
-
// inside a `void refreshRemoteMessages()` call gets killed when the spawning
|
|
51
|
-
// process exits before the request completes). Spawning fully DETACHED +
|
|
52
|
-
// unref'd decouples the refresh's lifetime from this hook's — it keeps
|
|
53
|
-
// running (up to message-transport.ts's own 4s fetch timeout) even after
|
|
54
|
-
// session-restore's own process has already exited, so it actually gets a
|
|
55
|
-
// chance to write the cache. Never awaited here — must not add to
|
|
56
|
-
// SessionStart's own timeout budget.
|
|
57
|
-
function spawnDetachedFunnelRefresh() {
|
|
58
|
-
try {
|
|
59
|
-
const cliBin = resolveCliBinForHook();
|
|
60
|
-
if (!cliBin) return;
|
|
61
|
-
const { spawn } = require('child_process');
|
|
62
|
-
const child = spawn(process.execPath, [cliBin, 'hooks', 'refresh-funnel', '--quiet'], {
|
|
63
|
-
detached: true,
|
|
64
|
-
stdio: 'ignore',
|
|
65
|
-
env: process.env,
|
|
66
|
-
});
|
|
67
|
-
child.unref();
|
|
68
|
-
} catch (e) { /* best-effort only */ }
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Same detached/unref'd pattern as spawnDetachedFunnelRefresh() above, for
|
|
72
|
-
// ADR-316's co-pilot advisor tip. Safe to call on EVERY session-restore:
|
|
73
|
-
// refresh-advisor's own action checks consent + a 24h TTL BEFORE spending
|
|
74
|
-
// anything, so an unconsented or already-fresh install is a fast no-op file
|
|
75
|
-
// read, never a network call. Never awaited here — must not add to
|
|
76
|
-
// SessionStart's own timeout budget.
|
|
77
|
-
function spawnDetachedAdvisorRefresh() {
|
|
78
|
-
try {
|
|
79
|
-
const cliBin = resolveCliBinForHook();
|
|
80
|
-
if (!cliBin) return;
|
|
81
|
-
const { spawn } = require('child_process');
|
|
82
|
-
const child = spawn(process.execPath, [cliBin, 'hooks', 'refresh-advisor', '--quiet'], {
|
|
83
|
-
detached: true,
|
|
84
|
-
stdio: 'ignore',
|
|
85
|
-
env: process.env,
|
|
86
|
-
});
|
|
87
|
-
child.unref();
|
|
88
|
-
} catch (e) { /* best-effort only */ }
|
|
89
|
-
}
|
|
90
|
-
|
|
91
21
|
// Safe require with stdout suppression - the helper modules have CLI
|
|
92
22
|
// sections that run unconditionally on require(), so we mute console
|
|
93
23
|
// during the require to prevent noisy output.
|
|
@@ -238,57 +168,6 @@ const handlers = {
|
|
|
238
168
|
} else {
|
|
239
169
|
console.log('[INFO] Router not available, using default routing');
|
|
240
170
|
}
|
|
241
|
-
|
|
242
|
-
// Rate-limit -> sponsored-capacity nudge (ADR-312/313). Fires here,
|
|
243
|
-
// client-side, BEFORE the API call this prompt would make — so it still
|
|
244
|
-
// reaches the transcript even if that call then fails from the rate
|
|
245
|
-
// limit. Cheap local file reads only; never a network call or a child
|
|
246
|
-
// process, so it cannot add latency to prompt submission.
|
|
247
|
-
try {
|
|
248
|
-
const rlFunnelEnv = process.env.RUFLO_FUNNEL;
|
|
249
|
-
const rlDisabledByEnv = rlFunnelEnv !== undefined && /^(0|false|off|no)$/i.test(String(rlFunnelEnv).trim());
|
|
250
|
-
const rlCiVars = ['CI', 'GITHUB_ACTIONS', 'GITLAB_CI', 'CIRCLECI', 'TRAVIS', 'BUILDKITE', 'JENKINS_URL', 'TEAMCITY_VERSION', 'TF_BUILD'];
|
|
251
|
-
const rlIsCi = rlCiVars.some((v) => {
|
|
252
|
-
const val = process.env[v];
|
|
253
|
-
return val !== undefined && val !== '' && val !== '0' && String(val).toLowerCase() !== 'false';
|
|
254
|
-
});
|
|
255
|
-
const rlHome = path.join(os.homedir(), '.ruflo');
|
|
256
|
-
let rlUserDisabled = false;
|
|
257
|
-
try {
|
|
258
|
-
const rlUserCfg = JSON.parse(fs.readFileSync(path.join(rlHome, 'funnel.json'), 'utf8'));
|
|
259
|
-
rlUserDisabled = !!(rlUserCfg && rlUserCfg.enabled === false);
|
|
260
|
-
} catch (e) { /* absent/malformed = not disabled */ }
|
|
261
|
-
let rlProjectDisabled = false;
|
|
262
|
-
try {
|
|
263
|
-
const rlProjCfg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'claude-flow.config.json'), 'utf8'));
|
|
264
|
-
rlProjectDisabled = !!(rlProjCfg && rlProjCfg.funnel && rlProjCfg.funnel.enabled === false);
|
|
265
|
-
} catch (e) { /* absent/malformed = not disabled */ }
|
|
266
|
-
|
|
267
|
-
if (!rlDisabledByEnv && !rlIsCi && !rlUserDisabled && !rlProjectDisabled) {
|
|
268
|
-
let rlStatus = null;
|
|
269
|
-
try { rlStatus = JSON.parse(fs.readFileSync(path.join(rlHome, 'rate-limit-status.json'), 'utf8')); } catch (e) { /* not flagged */ }
|
|
270
|
-
let rlIsLimited = false;
|
|
271
|
-
if (rlStatus && rlStatus.limited) {
|
|
272
|
-
if (rlStatus.since) {
|
|
273
|
-
const rlSinceMs = Date.parse(rlStatus.since);
|
|
274
|
-
rlIsLimited = isNaN(rlSinceMs) ? true : (Date.now() - rlSinceMs) < 6 * 60 * 60 * 1000;
|
|
275
|
-
} else {
|
|
276
|
-
rlIsLimited = true;
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
if (rlIsLimited) {
|
|
280
|
-
let rlConsented = false;
|
|
281
|
-
try {
|
|
282
|
-
const rlConsentFile = JSON.parse(fs.readFileSync(path.join(rlHome, 'consent.json'), 'utf8'));
|
|
283
|
-
const rlReceipt = rlConsentFile && rlConsentFile['sponsored-downtime'];
|
|
284
|
-
rlConsented = !!(rlReceipt && rlReceipt.granted === true && rlReceipt.at !== null && rlReceipt.policyVersion === 1);
|
|
285
|
-
} catch (e) { /* not consented */ }
|
|
286
|
-
if (!rlConsented) {
|
|
287
|
-
console.log('[COGNITUM] Hit your Claude usage limit? Free sponsored capacity is available at cognitum.one/meta-llm — run: ruflo proxy sponsor-enable --yes');
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
} catch (e) { /* nudge must never break the hook */ }
|
|
292
171
|
},
|
|
293
172
|
|
|
294
173
|
'pre-bash': () => {
|
|
@@ -355,14 +234,6 @@ const handlers = {
|
|
|
355
234
|
console.log(`[INTELLIGENCE] Loaded ${initResult.nodes} patterns, ${initResult.edges} edges`);
|
|
356
235
|
}
|
|
357
236
|
}
|
|
358
|
-
// Warm the funnel message cache once per session (see
|
|
359
|
-
// spawnDetachedFunnelRefresh's doc comment for why this must happen
|
|
360
|
-
// here, detached, rather than as the statusline's own fire-and-forget).
|
|
361
|
-
spawnDetachedFunnelRefresh();
|
|
362
|
-
// ADR-316 co-pilot advisor tip — same detached pattern; cheap no-op
|
|
363
|
-
// when not consented or still within the 24h TTL (see refresh-advisor's
|
|
364
|
-
// own doc comment).
|
|
365
|
-
spawnDetachedAdvisorRefresh();
|
|
366
237
|
},
|
|
367
238
|
|
|
368
239
|
'session-end': async () => {
|
|
@@ -587,16 +587,6 @@ function recordEdit(file, success) {
|
|
|
587
587
|
sessionId: sessionGet('sessionId') || null,
|
|
588
588
|
});
|
|
589
589
|
fs.appendFileSync(PENDING_PATH, entry + '\n', 'utf-8');
|
|
590
|
-
// Runaway-storage guard: pending-insights is append-only and only drained by
|
|
591
|
-
// consolidation. If it grows past ~512KB (thousands of un-consolidated edits
|
|
592
|
-
// — e.g. the daemon never ran), keep only the most recent 2000 lines so it
|
|
593
|
-
// can never grow unbounded. Cheap (a statSync per edit; rewrite only when over).
|
|
594
|
-
try {
|
|
595
|
-
if (fs.statSync(PENDING_PATH).size > 512 * 1024) {
|
|
596
|
-
const lines = fs.readFileSync(PENDING_PATH, 'utf-8').split('\n').filter(Boolean);
|
|
597
|
-
if (lines.length > 2000) fs.writeFileSync(PENDING_PATH, lines.slice(-2000).join('\n') + '\n', 'utf-8');
|
|
598
|
-
}
|
|
599
|
-
} catch (e) { /* non-fatal */ }
|
|
600
590
|
}
|
|
601
591
|
|
|
602
592
|
/**
|
|
@@ -199,9 +199,19 @@ function getStatuslineData() {
|
|
|
199
199
|
// back to `npx --prefer-offline @claude-flow/cli` (no @latest); an existing
|
|
200
200
|
// but broken install (e.g. a stale marketplace checkout missing a bundled
|
|
201
201
|
// workspace dep) must not block trying the next one.
|
|
202
|
+
//
|
|
203
|
+
// No `2>/dev/null` here (deliberately) — the execSync call below already
|
|
204
|
+
// sets stdio: ['pipe','pipe','pipe'], which captures/discards stderr at the
|
|
205
|
+
// Node level regardless of shell. The redirect was redundant on POSIX and
|
|
206
|
+
// actively broke every candidate on Windows: cmd.exe (execSync's default
|
|
207
|
+
// shell there) doesn't understand /dev/null, so the CLI delegation always
|
|
208
|
+
// failed, silently degrading every render to buildLocalFallback() — 0%
|
|
209
|
+
// intelligence and an empty promo row (the memo cache that keeps the row
|
|
210
|
+
// populated across CLI hiccups is only ever written from a SUCCESSFUL
|
|
211
|
+
// delegation, so it could never get seeded on Windows either).
|
|
202
212
|
const cmds = resolveCliBinCandidates()
|
|
203
|
-
.map((bin) => '"' + process.execPath + '" "' + bin + '" hooks statusline --json
|
|
204
|
-
.concat(['npx --prefer-offline @claude-flow/cli hooks statusline --json
|
|
213
|
+
.map((bin) => '"' + process.execPath + '" "' + bin + '" hooks statusline --json')
|
|
214
|
+
.concat(['npx --prefer-offline @claude-flow/cli hooks statusline --json']);
|
|
205
215
|
for (const cmd of cmds) {
|
|
206
216
|
try {
|
|
207
217
|
const raw = execSync(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-flow",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.27.0",
|
|
4
4
|
"description": "Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -23,7 +23,11 @@ const startCommand = {
|
|
|
23
23
|
{ name: 'quiet', short: 'Q', type: 'boolean', description: 'Suppress output' },
|
|
24
24
|
{ name: 'background', short: 'b', type: 'boolean', description: 'Run daemon in background (detached process)', default: true },
|
|
25
25
|
{ name: 'foreground', short: 'f', type: 'boolean', description: 'Run daemon in foreground (blocks terminal)' },
|
|
26
|
-
|
|
26
|
+
// #2661: --headless is the explicit consent gate for scheduled AI workers.
|
|
27
|
+
// Without it (or daemon.aiWorkers.enabled / RUFLO_DAEMON_AI_WORKERS=1),
|
|
28
|
+
// every worker runs its $0 local path — the daemon never spawns
|
|
29
|
+
// `claude --print` merely because the Claude CLI is on PATH.
|
|
30
|
+
{ name: 'headless', type: 'boolean', description: 'Enable AI workers (scheduled `claude --print` execution, governed by the user-global AI budget). Default: off — workers run local-only' },
|
|
27
31
|
{ name: 'sandbox', type: 'string', description: 'Default sandbox mode for headless workers', choices: ['strict', 'permissive', 'disabled'] },
|
|
28
32
|
{ name: 'max-cpu-load', type: 'string', description: 'Override maxCpuLoad resource threshold (e.g. 4.0)' },
|
|
29
33
|
{ name: 'min-free-memory', type: 'string', description: 'Override minFreeMemoryPercent resource threshold (e.g. 15)' },
|
|
@@ -60,6 +64,14 @@ const startCommand = {
|
|
|
60
64
|
const isDaemonProcess = process.env.CLAUDE_FLOW_DAEMON === '1';
|
|
61
65
|
// Parse resource threshold overrides from CLI flags
|
|
62
66
|
const config = {};
|
|
67
|
+
// #2661: thread --headless into DaemonConfig so it actually gates the
|
|
68
|
+
// headless executor. Previously the flag was forwarded to the forked
|
|
69
|
+
// child but never consumed — AI workers auto-enabled whenever the
|
|
70
|
+
// Claude CLI was detected. Only set when true so config.json/env can
|
|
71
|
+
// still opt in when the flag is absent.
|
|
72
|
+
if (ctx.flags.headless === true) {
|
|
73
|
+
config.aiWorkersEnabled = true;
|
|
74
|
+
}
|
|
63
75
|
const rawMaxCpu = ctx.flags['max-cpu-load'];
|
|
64
76
|
const rawMinMem = ctx.flags['min-free-memory'];
|
|
65
77
|
// Strict numeric pattern to prevent command injection when forwarding to subprocess (S1)
|
|
@@ -296,6 +308,7 @@ const startCommand = {
|
|
|
296
308
|
status.config.ttlMs > 0
|
|
297
309
|
? `TTL: ${Math.round(status.config.ttlMs / 3600000)}h (self-shutdown)`
|
|
298
310
|
: `TTL: off (runs until stopped)`,
|
|
311
|
+
`AI Workers: ${status.config.aiWorkersEnabled ? 'enabled (budget-capped)' : 'off (local-only, default)'}`,
|
|
299
312
|
`Workers: ${status.config.workers.filter(w => w.enabled).length} enabled`,
|
|
300
313
|
`Max Concurrent: ${status.config.maxConcurrent}`,
|
|
301
314
|
`Max CPU Load: ${status.config.resourceThresholds.maxCpuLoad}`,
|
|
@@ -552,6 +565,21 @@ async function startBackgroundDaemon(projectRoot, quiet, forwarded = {}) {
|
|
|
552
565
|
output.printSuccess(`Daemon started in background (PID: ${pid})`);
|
|
553
566
|
output.printInfo(`Logs: ${logFile}`);
|
|
554
567
|
output.printInfo(`Stop with: claude-flow daemon stop`);
|
|
568
|
+
// #2661: worktree-fanout warning. Each Git worktree gets its own daemon
|
|
569
|
+
// (per-workspace scope, #1914), so `init --start-daemon` across N
|
|
570
|
+
// worktrees quietly accumulates N daemons. Surface the fleet size at
|
|
571
|
+
// start time so the accumulation is visible where it happens.
|
|
572
|
+
try {
|
|
573
|
+
const fleet = await scanRunningDaemons();
|
|
574
|
+
if (fleet.length > 1) {
|
|
575
|
+
output.writeln();
|
|
576
|
+
output.printWarning(`Found ${fleet.length} ruflo daemons running across workspaces/worktrees.`);
|
|
577
|
+
output.printInfo('Scheduled AI workers are off by default and every AI launch is capped by the user-global budget.');
|
|
578
|
+
output.printInfo('Inspect: ruflo daemon status --all');
|
|
579
|
+
output.printInfo('Stop all: ruflo daemon stop --all');
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
catch { /* best-effort visibility — never fail the start */ }
|
|
555
583
|
}
|
|
556
584
|
return { success: true };
|
|
557
585
|
}
|
|
@@ -561,13 +589,24 @@ const stopCommand = {
|
|
|
561
589
|
description: 'Stop the worker daemon and all background workers',
|
|
562
590
|
options: [
|
|
563
591
|
{ name: 'quiet', short: 'Q', type: 'boolean', description: 'Suppress output' },
|
|
592
|
+
// #2661: emergency stop for worktree-daemon fleets. Stops every ruflo
|
|
593
|
+
// daemon owned by the current user across ALL workspaces/worktrees.
|
|
594
|
+
{ name: 'all', short: 'a', type: 'boolean', description: 'Stop ruflo daemons in ALL workspaces/worktrees (not just the current one)' },
|
|
564
595
|
],
|
|
565
596
|
examples: [
|
|
566
|
-
{ command: 'claude-flow daemon stop', description: 'Stop the daemon' },
|
|
597
|
+
{ command: 'claude-flow daemon stop', description: 'Stop the daemon in this workspace' },
|
|
598
|
+
{ command: 'claude-flow daemon stop --all', description: 'Stop ruflo daemons in every workspace/worktree' },
|
|
567
599
|
],
|
|
568
600
|
action: async (ctx) => {
|
|
569
601
|
const quiet = ctx.flags.quiet;
|
|
570
602
|
const projectRoot = process.cwd();
|
|
603
|
+
// #2661: `stop --all` — the containment lever for daemon fanout across
|
|
604
|
+
// Git worktrees. Only processes positively identified as ruflo daemons
|
|
605
|
+
// (via their self-identifying argv) are touched; each receives SIGTERM
|
|
606
|
+
// so its own shutdown path reaps in-flight Claude process groups.
|
|
607
|
+
if (ctx.flags.all) {
|
|
608
|
+
return stopAllDaemons(quiet);
|
|
609
|
+
}
|
|
571
610
|
try {
|
|
572
611
|
if (!quiet) {
|
|
573
612
|
const spinner = output.createSpinner({ text: 'Stopping worker daemon...', spinner: 'dots' });
|
|
@@ -593,6 +632,81 @@ const stopCommand = {
|
|
|
593
632
|
}
|
|
594
633
|
},
|
|
595
634
|
};
|
|
635
|
+
/**
|
|
636
|
+
* #2661: stop every running ruflo daemon across all workspaces/worktrees.
|
|
637
|
+
*
|
|
638
|
+
* Reuses the same positive identification as `daemon status --all`
|
|
639
|
+
* (scanRunningDaemons): a process is only touched when its command line is
|
|
640
|
+
* self-identifying as a ruflo daemon (`daemon start --foreground` +
|
|
641
|
+
* claude-flow markers). Interactive Claude sessions and non-ruflo processes
|
|
642
|
+
* are never candidates. Each daemon gets SIGTERM first — its own shutdown
|
|
643
|
+
* handler cancels in-flight headless Claude process groups and removes its
|
|
644
|
+
* PID file — with a SIGKILL fallback for daemons that don't exit within 2s.
|
|
645
|
+
* Only ruflo-owned registry entries (each workspace's daemon.pid) are removed.
|
|
646
|
+
*/
|
|
647
|
+
async function stopAllDaemons(quiet) {
|
|
648
|
+
// Stop any in-process daemon plus this workspace's tracked daemon first,
|
|
649
|
+
// matching plain `daemon stop` semantics for the current directory.
|
|
650
|
+
try {
|
|
651
|
+
await stopDaemon();
|
|
652
|
+
}
|
|
653
|
+
catch { /* not running in-process */ }
|
|
654
|
+
await killBackgroundDaemon(process.cwd());
|
|
655
|
+
const daemons = await scanRunningDaemons();
|
|
656
|
+
if (daemons.length === 0) {
|
|
657
|
+
if (!quiet) {
|
|
658
|
+
output.printInfo('No ruflo daemons are running in any workspace.');
|
|
659
|
+
}
|
|
660
|
+
return { success: true, data: { stopped: 0 } };
|
|
661
|
+
}
|
|
662
|
+
const isWin = process.platform === 'win32';
|
|
663
|
+
let stopped = 0;
|
|
664
|
+
for (const d of daemons) {
|
|
665
|
+
try {
|
|
666
|
+
if (isWin) {
|
|
667
|
+
const { execFileSync } = await import('child_process');
|
|
668
|
+
// /t terminates the daemon's child tree too (no /f: graceful first).
|
|
669
|
+
execFileSync('taskkill', ['/pid', String(d.pid), '/t'], { encoding: 'utf-8', timeout: 5000 });
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
process.kill(d.pid, 'SIGTERM');
|
|
673
|
+
}
|
|
674
|
+
stopped++;
|
|
675
|
+
if (!quiet) {
|
|
676
|
+
output.printInfo(`Stopping daemon PID ${d.pid}${d.workspace ? ` (${d.workspace})` : ''}`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
catch { /* exited between scan and kill */ }
|
|
680
|
+
}
|
|
681
|
+
// Give SIGTERM handlers time to reap children and clean up, then
|
|
682
|
+
// force-kill anything still alive (POSIX; taskkill /t already recursed).
|
|
683
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
684
|
+
for (const d of daemons) {
|
|
685
|
+
if (!isWin && isProcessRunning(d.pid)) {
|
|
686
|
+
try {
|
|
687
|
+
process.kill(d.pid, 'SIGKILL');
|
|
688
|
+
}
|
|
689
|
+
catch { /* already dead */ }
|
|
690
|
+
}
|
|
691
|
+
// Remove the ruflo-owned PID file for that workspace — but only when it
|
|
692
|
+
// still points at the daemon we just stopped (never clobber a newer one).
|
|
693
|
+
if (d.workspace) {
|
|
694
|
+
try {
|
|
695
|
+
const pidFile = join(d.workspace, '.claude-flow', 'daemon.pid');
|
|
696
|
+
if (fs.existsSync(pidFile)) {
|
|
697
|
+
const filePid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
698
|
+
if (filePid === d.pid)
|
|
699
|
+
fs.unlinkSync(pidFile);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
catch { /* workspace removed or unreadable — nothing to clean */ }
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (!quiet) {
|
|
706
|
+
output.printSuccess(`Stopped ${stopped} ruflo daemon(s) across all workspaces.`);
|
|
707
|
+
}
|
|
708
|
+
return { success: true, data: { stopped } };
|
|
709
|
+
}
|
|
596
710
|
/**
|
|
597
711
|
* Kill background daemon process using PID file
|
|
598
712
|
*/
|
|
@@ -893,6 +1007,31 @@ async function renderAllDaemonsStatus() {
|
|
|
893
1007
|
else {
|
|
894
1008
|
output.printInfo(`${daemons.length} daemon(s) running, all within their TTL.`);
|
|
895
1009
|
}
|
|
1010
|
+
if (daemons.length > 1) {
|
|
1011
|
+
output.printInfo('Stop all daemons across workspaces with: ruflo daemon stop --all');
|
|
1012
|
+
}
|
|
1013
|
+
// #2661: user-global AI launch usage — the shared budget every daemon
|
|
1014
|
+
// draws from, independent of worktree count.
|
|
1015
|
+
try {
|
|
1016
|
+
const { getGlobalAiBudget } = await import('../services/global-ai-budget.js');
|
|
1017
|
+
const budget = getGlobalAiBudget();
|
|
1018
|
+
const usage = budget.getUsage();
|
|
1019
|
+
const limits = budget.getLimits();
|
|
1020
|
+
output.writeln();
|
|
1021
|
+
// #2661: per-workspace 24h launch attribution — which worktree is
|
|
1022
|
+
// actually spending the shared budget.
|
|
1023
|
+
const byWs = usage.byWorkspace.slice(0, 5).map((w) => ` ${w.launches}× ${w.workspace}`);
|
|
1024
|
+
output.printBox([
|
|
1025
|
+
`Launches (last hour): ${usage.lastHour}/${limits.maxLaunchesPerHour}`,
|
|
1026
|
+
`Launches (last 24h): ${usage.lastDay}/${limits.maxLaunchesPerDay}`,
|
|
1027
|
+
`Active Claude children: ${usage.active}/${limits.maxConcurrentGlobal}`,
|
|
1028
|
+
usage.pausedUntil
|
|
1029
|
+
? output.warning(`PAUSED until ${new Date(usage.pausedUntil).toISOString()} (${usage.pauseReason ?? 'quota error'})`)
|
|
1030
|
+
: `Circuit breaker: ${output.dim('closed (normal)')}`,
|
|
1031
|
+
...(byWs.length > 0 ? ['Launches by workspace (24h):', ...byWs] : []),
|
|
1032
|
+
].join('\n'), 'Global AI Budget (all workspaces)');
|
|
1033
|
+
}
|
|
1034
|
+
catch { /* budget ledger unavailable — skip the panel */ }
|
|
896
1035
|
return { success: true, data: { daemons: rows.length } };
|
|
897
1036
|
}
|
|
898
1037
|
// Status subcommand
|
|
@@ -929,6 +1068,21 @@ const statusCommand = {
|
|
|
929
1068
|
const bgRunning = bgPid ? isProcessRunning(bgPid) : false;
|
|
930
1069
|
const isRunning = status.running || bgRunning;
|
|
931
1070
|
const displayPid = bgPid || status.pid;
|
|
1071
|
+
// #2661: this CLI process constructs its own (default-config) daemon
|
|
1072
|
+
// instance, so status.config.aiWorkersEnabled reflects THIS process,
|
|
1073
|
+
// not the running background daemon. The background daemon persists
|
|
1074
|
+
// its real consent state into daemon-state.json — prefer that when it
|
|
1075
|
+
// is the one running.
|
|
1076
|
+
let aiWorkersEnabled = status.config.aiWorkersEnabled;
|
|
1077
|
+
if (bgRunning) {
|
|
1078
|
+
try {
|
|
1079
|
+
const st = JSON.parse(fs.readFileSync(join(projectRoot, '.claude-flow', 'daemon-state.json'), 'utf-8'));
|
|
1080
|
+
if (typeof st?.config?.aiWorkersEnabled === 'boolean') {
|
|
1081
|
+
aiWorkersEnabled = st.config.aiWorkersEnabled;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
catch { /* no/partial state — fall back to in-process config */ }
|
|
1085
|
+
}
|
|
932
1086
|
output.writeln();
|
|
933
1087
|
// Daemon status box
|
|
934
1088
|
const statusIcon = isRunning ? output.success('●') : output.error('○');
|
|
@@ -941,6 +1095,9 @@ const statusCommand = {
|
|
|
941
1095
|
status.config.ttlMs > 0
|
|
942
1096
|
? `TTL: ${Math.round(status.config.ttlMs / 3600000)}h (self-shutdown)`
|
|
943
1097
|
: `TTL: ${output.dim('off (runs until stopped)')}`,
|
|
1098
|
+
// #2661: surface the AI-consent gate so "why is audit local-only?"
|
|
1099
|
+
// is answerable from `daemon status` alone.
|
|
1100
|
+
`AI Workers: ${aiWorkersEnabled ? output.warning('enabled (budget-capped)') : output.dim('off (local-only, default)')}`,
|
|
944
1101
|
`Workers Enabled: ${status.config.workers.filter(w => w.enabled).length}`,
|
|
945
1102
|
`Max Concurrent: ${status.config.maxConcurrent}`,
|
|
946
1103
|
`Max CPU Load: ${status.config.resourceThresholds.maxCpuLoad}`,
|
|
@@ -1043,7 +1200,10 @@ const triggerCommand = {
|
|
|
1043
1200
|
return { success: false, exitCode: 1 };
|
|
1044
1201
|
}
|
|
1045
1202
|
try {
|
|
1046
|
-
|
|
1203
|
+
// #2661: an explicit `trigger --headless` is user consent for AI
|
|
1204
|
+
// execution of THIS run (still governed by the global AI budget).
|
|
1205
|
+
// Without the flag, config.json / env opt-in still applies.
|
|
1206
|
+
const daemon = getDaemon(process.cwd(), ctx.flags.headless === true ? { aiWorkersEnabled: true } : undefined);
|
|
1047
1207
|
const spinner = output.createSpinner({ text: `Running ${workerType} worker...`, spinner: 'dots' });
|
|
1048
1208
|
spinner.start();
|
|
1049
1209
|
const result = await daemon.triggerWorker(workerType);
|
|
@@ -449,9 +449,39 @@ export function generateHookHandler() {
|
|
|
449
449
|
"const path = require('path');",
|
|
450
450
|
"const fs = require('fs');",
|
|
451
451
|
"const os = require('os');",
|
|
452
|
+
"const { spawn } = require('child_process');",
|
|
452
453
|
'',
|
|
453
454
|
'const helpersDir = __dirname;',
|
|
454
455
|
'',
|
|
456
|
+
// #2661-adjacent fix: `refreshRemoteMessages()` (the funnel promo/disclosure
|
|
457
|
+
// pool) is fire-and-forget by design so the statusline's own short-lived
|
|
458
|
+
// per-render subprocess never blocks on a network call — but that also
|
|
459
|
+
// means it NEVER gets a chance to finish there (confirmed live: two
|
|
460
|
+
// consecutive cold-cache statusline renders returned promo:null and no
|
|
461
|
+
// cache file was ever written). `refresh-funnel` exists specifically to
|
|
462
|
+
// be spawned from a longer-lived context; wire that spawn here, once per
|
|
463
|
+
// session, detached so it survives this hook process exiting and isn't
|
|
464
|
+
// awaited so it never adds to the hook's own timeout budget.
|
|
465
|
+
//
|
|
466
|
+
// Deliberately always via npx (--prefer-offline avoids a registry round
|
|
467
|
+
// trip when already cached), never a locally-resolved bin/cli.js path:
|
|
468
|
+
// a fire-and-forget detached spawn has no way to recover if the first
|
|
469
|
+
// candidate is a broken/unbuilt local install (confirmed live — a stale
|
|
470
|
+
// marketplace checkout with a bin/cli.js that exists but throws
|
|
471
|
+
// MODULE_NOT_FOUND on its own dist/ silently ate the spawn with no
|
|
472
|
+
// fallback and no visible error, since stdio is intentionally ignored).
|
|
473
|
+
// npx resolves a real, structurally-valid published package every time.
|
|
474
|
+
'function spawnFunnelRefresh() {',
|
|
475
|
+
' try {',
|
|
476
|
+
" var cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';",
|
|
477
|
+
" var args = ['--prefer-offline', '@claude-flow/cli', 'hooks', 'refresh-funnel', '--quiet'];",
|
|
478
|
+
' var child = spawn(cmd, args, {',
|
|
479
|
+
" detached: true, stdio: 'ignore', env: Object.assign({}, process.env),",
|
|
480
|
+
' });',
|
|
481
|
+
' child.unref();',
|
|
482
|
+
' } catch (e) { /* best-effort — the statusline\'s own fallback still renders */ }',
|
|
483
|
+
'}',
|
|
484
|
+
'',
|
|
455
485
|
'function safeRequire(modulePath) {',
|
|
456
486
|
' try {',
|
|
457
487
|
' if (fs.existsSync(modulePath)) {',
|
|
@@ -617,6 +647,7 @@ export function generateHookHandler() {
|
|
|
617
647
|
' },',
|
|
618
648
|
'',
|
|
619
649
|
" 'session-restore': () => {",
|
|
650
|
+
' spawnFunnelRefresh();',
|
|
620
651
|
' if (session) {',
|
|
621
652
|
' var existing = session.restore && session.restore();',
|
|
622
653
|
' if (!existing) {',
|
|
@@ -222,9 +222,19 @@ function getStatuslineData() {
|
|
|
222
222
|
// back to \`npx --prefer-offline @claude-flow/cli\` (no @latest); an existing
|
|
223
223
|
// but broken install (e.g. a stale marketplace checkout missing a bundled
|
|
224
224
|
// workspace dep) must not block trying the next one.
|
|
225
|
+
//
|
|
226
|
+
// No \`2>/dev/null\` here (deliberately) — the execSync call below already
|
|
227
|
+
// sets stdio: ['pipe','pipe','pipe'], which captures/discards stderr at the
|
|
228
|
+
// Node level regardless of shell. The redirect was redundant on POSIX and
|
|
229
|
+
// actively broke every candidate on Windows: cmd.exe (execSync's default
|
|
230
|
+
// shell there) doesn't understand /dev/null, so the CLI delegation always
|
|
231
|
+
// failed, silently degrading every render to buildLocalFallback() — 0%
|
|
232
|
+
// intelligence and an empty promo row (the memo cache that keeps the row
|
|
233
|
+
// populated across CLI hiccups is only ever written from a SUCCESSFUL
|
|
234
|
+
// delegation, so it could never get seeded on Windows either).
|
|
225
235
|
const cmds = resolveCliBinCandidates()
|
|
226
|
-
.map((bin) => '"' + process.execPath + '" "' + bin + '" hooks statusline --json
|
|
227
|
-
.concat(['npx --prefer-offline @claude-flow/cli hooks statusline --json
|
|
236
|
+
.map((bin) => '"' + process.execPath + '" "' + bin + '" hooks statusline --json')
|
|
237
|
+
.concat(['npx --prefer-offline @claude-flow/cli hooks statusline --json']);
|
|
228
238
|
for (const cmd of cmds) {
|
|
229
239
|
try {
|
|
230
240
|
const raw = execSync(
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2661 — Cross-worktree AI job dedup (issue invariant 5).
|
|
3
|
+
*
|
|
4
|
+
* N worktrees of one repository checked out at the same HEAD schedule the
|
|
5
|
+
* same analyses independently: N audits of identical content, N optimize
|
|
6
|
+
* passes, N testgap sweeps. Before a model launch, callers compute
|
|
7
|
+
*
|
|
8
|
+
* jobKey = sha256(repositoryId, head, workerType, workerConfigHash)
|
|
9
|
+
*
|
|
10
|
+
* and skip the launch when the same job succeeded within the freshness
|
|
11
|
+
* window. HEAD moves → new key → the job runs again.
|
|
12
|
+
*
|
|
13
|
+
* The registry lives next to the AI budget ledger under the user's home
|
|
14
|
+
* directory (owner-only, symlink-rejecting) so all daemons share it:
|
|
15
|
+
*
|
|
16
|
+
* ~/.claude-flow/ai-jobs.json
|
|
17
|
+
*
|
|
18
|
+
* This is a best-effort OPTIMIZATION layered under the budget: two daemons
|
|
19
|
+
* racing the same key may both miss and both attempt a launch, but the
|
|
20
|
+
* budget's atomic reservation (maxConcurrentGlobal / hourly cap) is the hard
|
|
21
|
+
* invariant that bounds actual launches. Only operational metadata is
|
|
22
|
+
* persisted — never prompts, outputs, or source content.
|
|
23
|
+
*/
|
|
24
|
+
export interface AiJobKeyParts {
|
|
25
|
+
repositoryId: string;
|
|
26
|
+
head: string;
|
|
27
|
+
workerType: string;
|
|
28
|
+
/** Hash of the effective worker config (prompt/model/sandbox/patterns). */
|
|
29
|
+
configHash: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function computeAiJobKey(parts: AiJobKeyParts): string;
|
|
32
|
+
/** Stable hash of an arbitrary config object (key-sorted JSON). */
|
|
33
|
+
export declare function hashWorkerConfig(config: unknown): string;
|
|
34
|
+
export declare class AiJobDedupRegistry {
|
|
35
|
+
private readonly dir;
|
|
36
|
+
private readonly file;
|
|
37
|
+
constructor(options?: {
|
|
38
|
+
baseDir?: string;
|
|
39
|
+
});
|
|
40
|
+
/**
|
|
41
|
+
* True when the job succeeded within `freshnessMs`. Any registry error
|
|
42
|
+
* reads as "not fresh" — dedup failing open only costs a (budget-capped)
|
|
43
|
+
* launch, never correctness.
|
|
44
|
+
*/
|
|
45
|
+
isFresh(jobKey: string, freshnessMs: number): {
|
|
46
|
+
fresh: boolean;
|
|
47
|
+
lastRunAt?: number;
|
|
48
|
+
};
|
|
49
|
+
/** Record a successful run of a job. Best-effort. */
|
|
50
|
+
recordSuccess(jobKey: string, meta: {
|
|
51
|
+
workerType: string;
|
|
52
|
+
repositoryId: string;
|
|
53
|
+
workspace: string;
|
|
54
|
+
}): void;
|
|
55
|
+
private read;
|
|
56
|
+
private write;
|
|
57
|
+
}
|
|
58
|
+
export declare function getAiJobDedupRegistry(): AiJobDedupRegistry;
|
|
59
|
+
/** Test hook: reset the singleton (e.g. after changing RUFLO_AI_BUDGET_DIR). */
|
|
60
|
+
export declare function resetAiJobDedupRegistryForTests(): void;
|
|
61
|
+
//# sourceMappingURL=ai-job-dedup.d.ts.map
|