claude-flow 3.27.4 → 3.28.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/hook-handler.cjs +149 -0
- package/.claude/helpers/intelligence.cjs +10 -0
- package/.claude/helpers/statusline.cjs +1 -1
- package/package.json +1 -1
- package/v3/@claude-flow/cli/catalog-manifest.json +2 -2
- package/v3/@claude-flow/cli/dist/src/commands/daemon.d.ts +23 -0
- package/v3/@claude-flow/cli/dist/src/commands/daemon.js +172 -0
- package/v3/@claude-flow/cli/dist/src/init/statusline-generator.js +45 -2
- package/v3/@claude-flow/cli/dist/src/services/global-ai-budget.d.ts +25 -0
- package/v3/@claude-flow/cli/dist/src/services/global-ai-budget.js +56 -0
- package/v3/@claude-flow/cli/dist/src/services/headless-worker-executor.d.ts +21 -0
- package/v3/@claude-flow/cli/dist/src/services/headless-worker-executor.js +68 -2
- package/v3/@claude-flow/cli/dist/src/services/repo-supervisor.d.ts +70 -0
- package/v3/@claude-flow/cli/dist/src/services/repo-supervisor.js +228 -0
- package/v3/@claude-flow/cli/dist/src/services/worker-daemon.d.ts +26 -0
- package/v3/@claude-flow/cli/dist/src/services/worker-daemon.js +95 -5
- package/v3/@claude-flow/cli/dist/src/services/workspace-lease.d.ts +55 -0
- package/v3/@claude-flow/cli/dist/src/services/workspace-lease.js +191 -0
- package/v3/@claude-flow/cli/package.json +1 -1
|
@@ -15,9 +15,99 @@
|
|
|
15
15
|
|
|
16
16
|
const path = require('path');
|
|
17
17
|
const fs = require('fs');
|
|
18
|
+
const os = require('os');
|
|
18
19
|
|
|
19
20
|
const helpersDir = __dirname;
|
|
20
21
|
|
|
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
|
+
//
|
|
27
|
+
// Verifies dist/src/index.js exists alongside bin/cli.js, not just the bin
|
|
28
|
+
// itself — Claude Code's own plugin marketplace mechanism installs by
|
|
29
|
+
// `git clone`/`git pull` with no build step, so `~/.claude/plugins/
|
|
30
|
+
// marketplaces/ruflo` is a SOURCE-ONLY checkout by construction: bin/cli.js
|
|
31
|
+
// is present on disk but importing dist/src/index.js throws
|
|
32
|
+
// ERR_MODULE_NOT_FOUND on every real command (confirmed live — only
|
|
33
|
+
// `--version` happens to survive it, since it reads package.json directly).
|
|
34
|
+
// Without this check, resolveCliBinForHook() picked that doomed candidate
|
|
35
|
+
// first every time and spawnDetachedFunnelRefresh() below had no fallback,
|
|
36
|
+
// so the promo/disclosure row could never populate for any marketplace
|
|
37
|
+
// install, on any OS.
|
|
38
|
+
function resolveCliBinForHook() {
|
|
39
|
+
try {
|
|
40
|
+
const home = os.homedir();
|
|
41
|
+
const cwd = process.cwd();
|
|
42
|
+
const candidates = [
|
|
43
|
+
path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo', 'bin', 'cli.js'),
|
|
44
|
+
path.join(cwd, 'node_modules', '@claude-flow', 'cli', 'bin', 'cli.js'),
|
|
45
|
+
path.join(cwd, 'node_modules', 'ruflo', 'bin', 'cli.js'),
|
|
46
|
+
path.join(cwd, 'v3', '@claude-flow', 'cli', 'bin', 'cli.js'),
|
|
47
|
+
// helpersDir is .claude/helpers/ inside the package itself when this
|
|
48
|
+
// file is running from a real @claude-flow/cli install (not a project
|
|
49
|
+
// that merely copied the helper) — its bin/ is two levels up.
|
|
50
|
+
path.join(helpersDir, '..', '..', 'bin', 'cli.js'),
|
|
51
|
+
];
|
|
52
|
+
for (const p of candidates) {
|
|
53
|
+
try {
|
|
54
|
+
if (fs.existsSync(p) && fs.existsSync(path.join(path.dirname(p), '..', 'dist', 'src', 'index.js'))) {
|
|
55
|
+
return p;
|
|
56
|
+
}
|
|
57
|
+
} catch (e) { /* try next candidate */ }
|
|
58
|
+
}
|
|
59
|
+
} catch (e) { /* ignore */ }
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Fire-and-forget doesn't work when the CALLER is itself a short-lived
|
|
64
|
+
// subprocess (confirmed live: two consecutive statusline renders 5s apart
|
|
65
|
+
// both saw an empty funnel-messages-cache, because the async HTTPS fetch
|
|
66
|
+
// inside a `void refreshRemoteMessages()` call gets killed when the spawning
|
|
67
|
+
// process exits before the request completes). Spawning fully DETACHED +
|
|
68
|
+
// unref'd decouples the refresh's lifetime from this hook's — it keeps
|
|
69
|
+
// running (up to message-transport.ts's own 4s fetch timeout) even after
|
|
70
|
+
// session-restore's own process has already exited, so it actually gets a
|
|
71
|
+
// chance to write the cache. Never awaited here — must not add to
|
|
72
|
+
// SessionStart's own timeout budget.
|
|
73
|
+
//
|
|
74
|
+
// No usable local candidate (resolveCliBinForHook() returned null) falls
|
|
75
|
+
// back to npx: this call is detached/unref'd, so a slower npx cold-start
|
|
76
|
+
// costs nothing perceptible — unlike the statusline's own synchronous
|
|
77
|
+
// render path, where local-first exists purely for per-render latency.
|
|
78
|
+
// `--prefer-offline` avoids a registry round trip for the tarball when
|
|
79
|
+
// already cached while still resolving the current `@latest` version.
|
|
80
|
+
function spawnDetachedHookRefresh(subcommand) {
|
|
81
|
+
try {
|
|
82
|
+
const { spawn } = require('child_process');
|
|
83
|
+
const cliBin = resolveCliBinForHook();
|
|
84
|
+
const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
85
|
+
const spawnArgs = cliBin
|
|
86
|
+
? [process.execPath, [cliBin, 'hooks', subcommand, '--quiet']]
|
|
87
|
+
: [cmd, ['--prefer-offline', '@claude-flow/cli', 'hooks', subcommand, '--quiet']];
|
|
88
|
+
const child = spawn(spawnArgs[0], spawnArgs[1], {
|
|
89
|
+
detached: true,
|
|
90
|
+
stdio: 'ignore',
|
|
91
|
+
env: process.env,
|
|
92
|
+
});
|
|
93
|
+
child.unref();
|
|
94
|
+
} catch (e) { /* best-effort only */ }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function spawnDetachedFunnelRefresh() {
|
|
98
|
+
spawnDetachedHookRefresh('refresh-funnel');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Same fallback-aware pattern as spawnDetachedFunnelRefresh() above, for
|
|
102
|
+
// ADR-316's co-pilot advisor tip. Safe to call on EVERY session-restore:
|
|
103
|
+
// refresh-advisor's own action checks consent + a 24h TTL BEFORE spending
|
|
104
|
+
// anything, so an unconsented or already-fresh install is a fast no-op file
|
|
105
|
+
// read, never a network call. Never awaited here — must not add to
|
|
106
|
+
// SessionStart's own timeout budget.
|
|
107
|
+
function spawnDetachedAdvisorRefresh() {
|
|
108
|
+
spawnDetachedHookRefresh('refresh-advisor');
|
|
109
|
+
}
|
|
110
|
+
|
|
21
111
|
// Safe require with stdout suppression - the helper modules have CLI
|
|
22
112
|
// sections that run unconditionally on require(), so we mute console
|
|
23
113
|
// during the require to prevent noisy output.
|
|
@@ -168,6 +258,57 @@ const handlers = {
|
|
|
168
258
|
} else {
|
|
169
259
|
console.log('[INFO] Router not available, using default routing');
|
|
170
260
|
}
|
|
261
|
+
|
|
262
|
+
// Rate-limit -> sponsored-capacity nudge (ADR-312/313). Fires here,
|
|
263
|
+
// client-side, BEFORE the API call this prompt would make — so it still
|
|
264
|
+
// reaches the transcript even if that call then fails from the rate
|
|
265
|
+
// limit. Cheap local file reads only; never a network call or a child
|
|
266
|
+
// process, so it cannot add latency to prompt submission.
|
|
267
|
+
try {
|
|
268
|
+
const rlFunnelEnv = process.env.RUFLO_FUNNEL;
|
|
269
|
+
const rlDisabledByEnv = rlFunnelEnv !== undefined && /^(0|false|off|no)$/i.test(String(rlFunnelEnv).trim());
|
|
270
|
+
const rlCiVars = ['CI', 'GITHUB_ACTIONS', 'GITLAB_CI', 'CIRCLECI', 'TRAVIS', 'BUILDKITE', 'JENKINS_URL', 'TEAMCITY_VERSION', 'TF_BUILD'];
|
|
271
|
+
const rlIsCi = rlCiVars.some((v) => {
|
|
272
|
+
const val = process.env[v];
|
|
273
|
+
return val !== undefined && val !== '' && val !== '0' && String(val).toLowerCase() !== 'false';
|
|
274
|
+
});
|
|
275
|
+
const rlHome = path.join(os.homedir(), '.ruflo');
|
|
276
|
+
let rlUserDisabled = false;
|
|
277
|
+
try {
|
|
278
|
+
const rlUserCfg = JSON.parse(fs.readFileSync(path.join(rlHome, 'funnel.json'), 'utf8'));
|
|
279
|
+
rlUserDisabled = !!(rlUserCfg && rlUserCfg.enabled === false);
|
|
280
|
+
} catch (e) { /* absent/malformed = not disabled */ }
|
|
281
|
+
let rlProjectDisabled = false;
|
|
282
|
+
try {
|
|
283
|
+
const rlProjCfg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'claude-flow.config.json'), 'utf8'));
|
|
284
|
+
rlProjectDisabled = !!(rlProjCfg && rlProjCfg.funnel && rlProjCfg.funnel.enabled === false);
|
|
285
|
+
} catch (e) { /* absent/malformed = not disabled */ }
|
|
286
|
+
|
|
287
|
+
if (!rlDisabledByEnv && !rlIsCi && !rlUserDisabled && !rlProjectDisabled) {
|
|
288
|
+
let rlStatus = null;
|
|
289
|
+
try { rlStatus = JSON.parse(fs.readFileSync(path.join(rlHome, 'rate-limit-status.json'), 'utf8')); } catch (e) { /* not flagged */ }
|
|
290
|
+
let rlIsLimited = false;
|
|
291
|
+
if (rlStatus && rlStatus.limited) {
|
|
292
|
+
if (rlStatus.since) {
|
|
293
|
+
const rlSinceMs = Date.parse(rlStatus.since);
|
|
294
|
+
rlIsLimited = isNaN(rlSinceMs) ? true : (Date.now() - rlSinceMs) < 6 * 60 * 60 * 1000;
|
|
295
|
+
} else {
|
|
296
|
+
rlIsLimited = true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (rlIsLimited) {
|
|
300
|
+
let rlConsented = false;
|
|
301
|
+
try {
|
|
302
|
+
const rlConsentFile = JSON.parse(fs.readFileSync(path.join(rlHome, 'consent.json'), 'utf8'));
|
|
303
|
+
const rlReceipt = rlConsentFile && rlConsentFile['sponsored-downtime'];
|
|
304
|
+
rlConsented = !!(rlReceipt && rlReceipt.granted === true && rlReceipt.at !== null && rlReceipt.policyVersion === 1);
|
|
305
|
+
} catch (e) { /* not consented */ }
|
|
306
|
+
if (!rlConsented) {
|
|
307
|
+
console.log('[COGNITUM] Hit your Claude usage limit? Free sponsored capacity is available at cognitum.one/meta-llm — run: ruflo proxy sponsor-enable --yes');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
} catch (e) { /* nudge must never break the hook */ }
|
|
171
312
|
},
|
|
172
313
|
|
|
173
314
|
'pre-bash': () => {
|
|
@@ -234,6 +375,14 @@ const handlers = {
|
|
|
234
375
|
console.log(`[INTELLIGENCE] Loaded ${initResult.nodes} patterns, ${initResult.edges} edges`);
|
|
235
376
|
}
|
|
236
377
|
}
|
|
378
|
+
// Warm the funnel message cache once per session (see
|
|
379
|
+
// spawnDetachedFunnelRefresh's doc comment for why this must happen
|
|
380
|
+
// here, detached, rather than as the statusline's own fire-and-forget).
|
|
381
|
+
spawnDetachedFunnelRefresh();
|
|
382
|
+
// ADR-316 co-pilot advisor tip — same detached pattern; cheap no-op
|
|
383
|
+
// when not consented or still within the 24h TTL (see refresh-advisor's
|
|
384
|
+
// own doc comment).
|
|
385
|
+
spawnDetachedAdvisorRefresh();
|
|
237
386
|
},
|
|
238
387
|
|
|
239
388
|
'session-end': async () => {
|
|
@@ -587,6 +587,16 @@ 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 */ }
|
|
590
600
|
}
|
|
591
601
|
|
|
592
602
|
/**
|
|
@@ -618,7 +618,7 @@ function getPkgVersion() {
|
|
|
618
618
|
// version (see generateStatuslineScript()'s doc comment) — correct even
|
|
619
619
|
// when this renders via a pure npx invocation with no local install for
|
|
620
620
|
// the candidate scan below to find.
|
|
621
|
-
let ver = "3.
|
|
621
|
+
let ver = "3.28.0";
|
|
622
622
|
try {
|
|
623
623
|
const home = os.homedir();
|
|
624
624
|
const pkgPaths = [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-flow",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.28.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",
|
|
@@ -31,6 +31,29 @@ export declare function daemonCommandLineBelongsToWorkspace(commandLine: string,
|
|
|
31
31
|
* Returns null for pre-#1914 daemons that never stamped a workspace.
|
|
32
32
|
*/
|
|
33
33
|
export declare function extractWorkspaceFromDaemonLine(commandLine: string): string | null;
|
|
34
|
+
/**
|
|
35
|
+
* #2661 root-fix — one-time upgrade migration warning. A user who had
|
|
36
|
+
* `aiWorkersEnabled: true` configured BEFORE this fix landed (old config
|
|
37
|
+
* file or RUFLO_DAEMON_AI_WORKERS=1) and already has multiple worktree
|
|
38
|
+
* daemons running is exactly the P0 scenario the issue describes — surface
|
|
39
|
+
* it plainly, ONCE ever (not on every `daemon start`, which would just be
|
|
40
|
+
* noise once the user has seen and acted on it). The supervisor/lease
|
|
41
|
+
* mechanism (task #9) already makes only one of those daemons actually
|
|
42
|
+
* schedule AI workers going forward; this warning's job is purely to make
|
|
43
|
+
* a pre-existing fleet VISIBLE the first time this code runs, not to take
|
|
44
|
+
* any destructive action — nothing here stops or kills another daemon.
|
|
45
|
+
*
|
|
46
|
+
* `opts` exists for tests ONLY, mirroring the injectable-dependency pattern
|
|
47
|
+
* used elsewhere in this codebase (e.g. helper-refresh.ts's
|
|
48
|
+
* sourceDirOverride) — real callers always use the defaults.
|
|
49
|
+
*/
|
|
50
|
+
export declare function maybeShowMultiDaemonMigrationWarning(opts?: {
|
|
51
|
+
markerFile?: string;
|
|
52
|
+
fleetScanner?: () => Promise<Array<{
|
|
53
|
+
pid: number;
|
|
54
|
+
workspace: string | null;
|
|
55
|
+
}>>;
|
|
56
|
+
}): Promise<void>;
|
|
34
57
|
export declare const daemonCommand: Command;
|
|
35
58
|
export default daemonCommand;
|
|
36
59
|
//# sourceMappingURL=daemon.d.ts.map
|
|
@@ -7,6 +7,7 @@ import { getDaemon, startDaemon, stopDaemon } from '../services/worker-daemon.js
|
|
|
7
7
|
import { fork } from 'child_process';
|
|
8
8
|
import { fileURLToPath } from 'url';
|
|
9
9
|
import { dirname, join, resolve } from 'path';
|
|
10
|
+
import { homedir } from 'os';
|
|
10
11
|
import * as fs from 'fs';
|
|
11
12
|
// Start daemon subcommand
|
|
12
13
|
const startCommand = {
|
|
@@ -580,6 +581,11 @@ async function startBackgroundDaemon(projectRoot, quiet, forwarded = {}) {
|
|
|
580
581
|
}
|
|
581
582
|
}
|
|
582
583
|
catch { /* best-effort visibility — never fail the start */ }
|
|
584
|
+
// #2661 root-fix — one-time migration warning for a pre-existing
|
|
585
|
+
// multi-daemon fleet that already had AI workers enabled before this
|
|
586
|
+
// fix landed. Separate from the always-shown notice above: this one
|
|
587
|
+
// fires at most once ever, and only for the genuinely risky shape.
|
|
588
|
+
await maybeShowMultiDaemonMigrationWarning();
|
|
583
589
|
}
|
|
584
590
|
return { success: true };
|
|
585
591
|
}
|
|
@@ -942,6 +948,68 @@ async function scanRunningDaemons() {
|
|
|
942
948
|
return [];
|
|
943
949
|
}
|
|
944
950
|
}
|
|
951
|
+
function defaultMultiDaemonWarningMarker() {
|
|
952
|
+
return join(homedir(), '.claude-flow', 'multi-daemon-warning-shown.json');
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* #2661 root-fix — one-time upgrade migration warning. A user who had
|
|
956
|
+
* `aiWorkersEnabled: true` configured BEFORE this fix landed (old config
|
|
957
|
+
* file or RUFLO_DAEMON_AI_WORKERS=1) and already has multiple worktree
|
|
958
|
+
* daemons running is exactly the P0 scenario the issue describes — surface
|
|
959
|
+
* it plainly, ONCE ever (not on every `daemon start`, which would just be
|
|
960
|
+
* noise once the user has seen and acted on it). The supervisor/lease
|
|
961
|
+
* mechanism (task #9) already makes only one of those daemons actually
|
|
962
|
+
* schedule AI workers going forward; this warning's job is purely to make
|
|
963
|
+
* a pre-existing fleet VISIBLE the first time this code runs, not to take
|
|
964
|
+
* any destructive action — nothing here stops or kills another daemon.
|
|
965
|
+
*
|
|
966
|
+
* `opts` exists for tests ONLY, mirroring the injectable-dependency pattern
|
|
967
|
+
* used elsewhere in this codebase (e.g. helper-refresh.ts's
|
|
968
|
+
* sourceDirOverride) — real callers always use the defaults.
|
|
969
|
+
*/
|
|
970
|
+
export async function maybeShowMultiDaemonMigrationWarning(opts) {
|
|
971
|
+
const markerFile = opts?.markerFile ?? defaultMultiDaemonWarningMarker();
|
|
972
|
+
try {
|
|
973
|
+
if (fs.existsSync(markerFile))
|
|
974
|
+
return;
|
|
975
|
+
const fleet = await (opts?.fleetScanner ?? scanRunningDaemons)();
|
|
976
|
+
if (fleet.length <= 1)
|
|
977
|
+
return;
|
|
978
|
+
let anyAiEnabled = false;
|
|
979
|
+
for (const d of fleet) {
|
|
980
|
+
if (!d.workspace)
|
|
981
|
+
continue;
|
|
982
|
+
try {
|
|
983
|
+
const statePath = join(d.workspace, '.claude-flow', 'daemon-state.json');
|
|
984
|
+
if (!fs.existsSync(statePath))
|
|
985
|
+
continue;
|
|
986
|
+
const st = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
987
|
+
if (st?.config?.aiWorkersEnabled === true) {
|
|
988
|
+
anyAiEnabled = true;
|
|
989
|
+
break;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
catch { /* unreadable state — skip this daemon */ }
|
|
993
|
+
}
|
|
994
|
+
// Only the genuinely risky shape (pre-existing fleet + AI workers
|
|
995
|
+
// enabled somewhere in it) warrants the migration warning. A harmless
|
|
996
|
+
// multi-daemon fleet with AI workers off everywhere already gets the
|
|
997
|
+
// lighter, always-shown fleet-size notice at daemon start.
|
|
998
|
+
if (anyAiEnabled) {
|
|
999
|
+
output.writeln();
|
|
1000
|
+
output.printWarning(`Ruflo found ${fleet.length} worktree daemons. Scheduled AI workers are now supervisor-gated.`);
|
|
1001
|
+
output.printInfo('Inspect: ruflo daemon status --all');
|
|
1002
|
+
output.printInfo('Stop all: ruflo daemon stop --all');
|
|
1003
|
+
output.printInfo('Pause autonomous launches: ruflo daemon budget pause');
|
|
1004
|
+
output.writeln();
|
|
1005
|
+
}
|
|
1006
|
+
const dir = dirname(markerFile);
|
|
1007
|
+
if (!fs.existsSync(dir))
|
|
1008
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1009
|
+
fs.writeFileSync(markerFile, JSON.stringify({ shownAt: new Date().toISOString(), fleetSize: fleet.length, anyAiEnabled }), { mode: 0o600 });
|
|
1010
|
+
}
|
|
1011
|
+
catch { /* best-effort visibility — never fail the command */ }
|
|
1012
|
+
}
|
|
945
1013
|
/**
|
|
946
1014
|
* #2356: render the global `daemon status --all` view. For each running daemon
|
|
947
1015
|
* it reads that workspace's daemon-state.json to show age + configured TTL,
|
|
@@ -1010,6 +1078,39 @@ async function renderAllDaemonsStatus() {
|
|
|
1010
1078
|
if (daemons.length > 1) {
|
|
1011
1079
|
output.printInfo('Stop all daemons across workspaces with: ruflo daemon stop --all');
|
|
1012
1080
|
}
|
|
1081
|
+
// #2661 root-fix — repository supervisor state, one row per distinct
|
|
1082
|
+
// repository among the scanned daemons' workspaces. Resolving identity
|
|
1083
|
+
// per workspace is cheap (a couple of `git rev-parse` calls, cached).
|
|
1084
|
+
try {
|
|
1085
|
+
const { resolveGitWorkspaceIdentity } = await import('../services/git-workspace-identity.js');
|
|
1086
|
+
const { getRepoSupervisorRegistry } = await import('../services/repo-supervisor.js');
|
|
1087
|
+
const { getWorkspaceLeaseRegistry } = await import('../services/workspace-lease.js');
|
|
1088
|
+
const seenRepos = new Map(); // repositoryId -> a representative workspace path
|
|
1089
|
+
for (const d of daemons) {
|
|
1090
|
+
if (!d.workspace)
|
|
1091
|
+
continue;
|
|
1092
|
+
const identity = resolveGitWorkspaceIdentity(d.workspace);
|
|
1093
|
+
if (identity.isGit && !seenRepos.has(identity.repositoryId)) {
|
|
1094
|
+
seenRepos.set(identity.repositoryId, d.workspace);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
if (seenRepos.size > 0) {
|
|
1098
|
+
const supervisorReg = getRepoSupervisorRegistry();
|
|
1099
|
+
const leaseReg = getWorkspaceLeaseRegistry();
|
|
1100
|
+
const lines = [];
|
|
1101
|
+
for (const [repositoryId, sampleWorkspace] of seenRepos) {
|
|
1102
|
+
const record = supervisorReg.getRecord(repositoryId);
|
|
1103
|
+
const activeLeases = leaseReg.listActive(repositoryId).length;
|
|
1104
|
+
const label = repositoryId.slice(0, 12);
|
|
1105
|
+
lines.push(record
|
|
1106
|
+
? ` ${label}… supervisor: ${record.worktreeRoot} (pid ${record.pid}) | active leases: ${activeLeases}`
|
|
1107
|
+
: ` ${label}… supervisor: ${output.dim('none elected')} | active leases: ${activeLeases}`);
|
|
1108
|
+
}
|
|
1109
|
+
output.writeln();
|
|
1110
|
+
output.printBox(lines.join('\n'), 'Repository Supervisors (#2661 root-fix)');
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
catch { /* supervisor registry unavailable — skip the panel */ }
|
|
1013
1114
|
// #2661: user-global AI launch usage — the shared budget every daemon
|
|
1014
1115
|
// draws from, independent of worktree count.
|
|
1015
1116
|
try {
|
|
@@ -1496,6 +1597,76 @@ const uninstallSupervisorCommand = {
|
|
|
1496
1597
|
return { success: false, exitCode: 1 };
|
|
1497
1598
|
},
|
|
1498
1599
|
};
|
|
1600
|
+
// #2661 root-fix — `daemon budget show|pause|resume`. The budget state was
|
|
1601
|
+
// previously visible only inline in `daemon status --all`; these give it an
|
|
1602
|
+
// independently scriptable surface (e.g. `ruflo daemon budget pause` before
|
|
1603
|
+
// a long interactive session, `... resume` after).
|
|
1604
|
+
const budgetShowCommand = {
|
|
1605
|
+
name: 'show',
|
|
1606
|
+
description: 'Show the user-global AI launch budget (launches, active children, circuit-breaker state)',
|
|
1607
|
+
options: [],
|
|
1608
|
+
examples: [{ command: 'claude-flow daemon budget show', description: 'Show current budget usage and limits' }],
|
|
1609
|
+
action: async () => {
|
|
1610
|
+
const { getGlobalAiBudget } = await import('../services/global-ai-budget.js');
|
|
1611
|
+
const budget = getGlobalAiBudget();
|
|
1612
|
+
const usage = budget.getUsage();
|
|
1613
|
+
const limits = budget.getLimits();
|
|
1614
|
+
output.writeln();
|
|
1615
|
+
const byWs = usage.byWorkspace.slice(0, 10).map((w) => ` ${w.launches}× ${w.workspace}`);
|
|
1616
|
+
output.printBox([
|
|
1617
|
+
`Launches (last hour): ${usage.lastHour}/${limits.maxLaunchesPerHour}`,
|
|
1618
|
+
`Launches (last 24h): ${usage.lastDay}/${limits.maxLaunchesPerDay}`,
|
|
1619
|
+
`Active Claude children: ${usage.active}/${limits.maxConcurrentGlobal}`,
|
|
1620
|
+
usage.pausedUntil
|
|
1621
|
+
? output.warning(`PAUSED until ${new Date(usage.pausedUntil).toISOString()} (${usage.pauseReason ?? 'quota error'})`)
|
|
1622
|
+
: `Circuit breaker: ${output.dim('closed (normal)')}`,
|
|
1623
|
+
...(byWs.length > 0 ? ['Launches by workspace (24h):', ...byWs] : []),
|
|
1624
|
+
].join('\n'), 'Global AI Budget');
|
|
1625
|
+
return { success: true, data: { usage, limits } };
|
|
1626
|
+
},
|
|
1627
|
+
};
|
|
1628
|
+
const budgetPauseCommand = {
|
|
1629
|
+
name: 'pause',
|
|
1630
|
+
description: 'Pause ALL autonomous Claude launches across every daemon until resumed',
|
|
1631
|
+
options: [
|
|
1632
|
+
{ name: 'reason', short: 'r', type: 'string', description: 'Optional reason recorded in the pause receipt' },
|
|
1633
|
+
],
|
|
1634
|
+
examples: [{ command: 'claude-flow daemon budget pause --reason "conserving quota for a demo"', description: 'Pause autonomous launches' }],
|
|
1635
|
+
action: async (ctx) => {
|
|
1636
|
+
const { getGlobalAiBudget } = await import('../services/global-ai-budget.js');
|
|
1637
|
+
await getGlobalAiBudget().pause(ctx.flags.reason);
|
|
1638
|
+
output.printSuccess('Autonomous AI worker launches paused across all daemons. Resume with: ruflo daemon budget resume');
|
|
1639
|
+
return { success: true };
|
|
1640
|
+
},
|
|
1641
|
+
};
|
|
1642
|
+
const budgetResumeCommand = {
|
|
1643
|
+
name: 'resume',
|
|
1644
|
+
description: 'Resume autonomous Claude launches (clears a manual pause or a quota-triggered circuit-breaker pause)',
|
|
1645
|
+
options: [],
|
|
1646
|
+
examples: [{ command: 'claude-flow daemon budget resume', description: 'Resume autonomous launches' }],
|
|
1647
|
+
action: async () => {
|
|
1648
|
+
const { getGlobalAiBudget } = await import('../services/global-ai-budget.js');
|
|
1649
|
+
await getGlobalAiBudget().resume();
|
|
1650
|
+
output.printSuccess('Autonomous AI worker launches resumed.');
|
|
1651
|
+
return { success: true };
|
|
1652
|
+
},
|
|
1653
|
+
};
|
|
1654
|
+
const budgetCommand = {
|
|
1655
|
+
name: 'budget',
|
|
1656
|
+
description: 'Inspect and control the user-global AI launch budget (#2661)',
|
|
1657
|
+
subcommands: [budgetShowCommand, budgetPauseCommand, budgetResumeCommand],
|
|
1658
|
+
options: [],
|
|
1659
|
+
examples: [
|
|
1660
|
+
{ command: 'claude-flow daemon budget show', description: 'Show current usage/limits' },
|
|
1661
|
+
{ command: 'claude-flow daemon budget pause', description: 'Pause all autonomous launches' },
|
|
1662
|
+
{ command: 'claude-flow daemon budget resume', description: 'Resume autonomous launches' },
|
|
1663
|
+
],
|
|
1664
|
+
// Bare `daemon budget` (no subcommand) shows usage — same as `show`.
|
|
1665
|
+
action: async (ctx) => {
|
|
1666
|
+
const result = await budgetShowCommand.action(ctx);
|
|
1667
|
+
return result ?? { success: true };
|
|
1668
|
+
},
|
|
1669
|
+
};
|
|
1499
1670
|
// Main daemon command
|
|
1500
1671
|
export const daemonCommand = {
|
|
1501
1672
|
name: 'daemon',
|
|
@@ -1506,6 +1677,7 @@ export const daemonCommand = {
|
|
|
1506
1677
|
statusCommand,
|
|
1507
1678
|
triggerCommand,
|
|
1508
1679
|
enableCommand,
|
|
1680
|
+
budgetCommand,
|
|
1509
1681
|
installSupervisorCommand,
|
|
1510
1682
|
uninstallSupervisorCommand,
|
|
1511
1683
|
],
|
|
@@ -9,7 +9,50 @@
|
|
|
9
9
|
* - Shared settings cache
|
|
10
10
|
* - Strict 2s timeouts on all shell calls
|
|
11
11
|
*/
|
|
12
|
-
import
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
import { createRequire } from 'module';
|
|
16
|
+
const __dirname_sg = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
/**
|
|
18
|
+
* Resolves the running CLI's own version — same createRequire/walk-up
|
|
19
|
+
* approach as helper-refresh.ts's getInstalledCliVersion(), duplicated
|
|
20
|
+
* here rather than imported. helper-refresh.ts pulls in the `semver`
|
|
21
|
+
* package at module scope (for autoRefreshHelpersIfStale()'s version
|
|
22
|
+
* comparison, unrelated to this) — ES module imports load a module's
|
|
23
|
+
* ENTIRE top-level regardless of which export is used, so importing just
|
|
24
|
+
* getInstalledCliVersion from there still requires `semver` to be resolvable.
|
|
25
|
+
* Confirmed live: the CI smoke job that loads this generator via a minimal
|
|
26
|
+
* "smoke deps" install (no full `npm install`) failed with
|
|
27
|
+
* ERR_MODULE_NOT_FOUND('semver') the moment this file gained that import,
|
|
28
|
+
* even though this function itself never touches semver. Keeping this
|
|
29
|
+
* generator's own dependency footprint to bare Node builtins avoids
|
|
30
|
+
* dragging every future helper-refresh.ts dependency into every context
|
|
31
|
+
* that merely wants to render a statusline script.
|
|
32
|
+
*/
|
|
33
|
+
function getInstalledCliVersionLocal() {
|
|
34
|
+
try {
|
|
35
|
+
const esmRequire = createRequire(import.meta.url);
|
|
36
|
+
const pkg = JSON.parse(fs.readFileSync(esmRequire.resolve('@claude-flow/cli/package.json'), 'utf-8'));
|
|
37
|
+
return String(pkg.version || '0.0.0');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
let dir = __dirname_sg;
|
|
41
|
+
for (let i = 0; i < 6; i++) {
|
|
42
|
+
try {
|
|
43
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
|
|
44
|
+
if (pkg && pkg.name === '@claude-flow/cli')
|
|
45
|
+
return String(pkg.version || '0.0.0');
|
|
46
|
+
}
|
|
47
|
+
catch { /* no package.json here, or unreadable — keep climbing */ }
|
|
48
|
+
const parent = path.dirname(dir);
|
|
49
|
+
if (parent === dir)
|
|
50
|
+
break;
|
|
51
|
+
dir = parent;
|
|
52
|
+
}
|
|
53
|
+
return '0.0.0';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
13
56
|
/**
|
|
14
57
|
* Generate optimized statusline script
|
|
15
58
|
* Output format:
|
|
@@ -33,7 +76,7 @@ export function generateStatuslineScript(options) {
|
|
|
33
76
|
// previous hardcoded "3.6" placeholder. getPkgVersion()'s own runtime
|
|
34
77
|
// candidate scan still wins over this baked-in value when it finds
|
|
35
78
|
// something newer (e.g. a later `npm update` in the same project).
|
|
36
|
-
const bakedVersion =
|
|
79
|
+
const bakedVersion = getInstalledCliVersionLocal();
|
|
37
80
|
return `#!/usr/bin/env node
|
|
38
81
|
/**
|
|
39
82
|
* RuFlo V3 Statusline — delegation build (#2195)
|
|
@@ -80,6 +80,31 @@ export declare class GlobalAiBudget {
|
|
|
80
80
|
* daemon pauses ALL autonomous Claude launches for the cooldown window.
|
|
81
81
|
*/
|
|
82
82
|
recordQuotaError(detail: string): Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* #2661 root-fix — manual pause, via `ruflo daemon budget pause`. Distinct
|
|
85
|
+
* from the automatic quota-error circuit breaker only in duration (open-
|
|
86
|
+
* ended, until explicitly resumed, instead of a fixed cooldown) and
|
|
87
|
+
* reason text — the enforcement path in reserve() is identical, so a
|
|
88
|
+
* manual pause is just as hard a stop as a quota-triggered one.
|
|
89
|
+
*/
|
|
90
|
+
pause(reason?: string): Promise<void>;
|
|
91
|
+
/** #2661 root-fix — `ruflo daemon budget resume`. Clears ANY pause (manual or quota-triggered). */
|
|
92
|
+
resume(): Promise<void>;
|
|
93
|
+
/**
|
|
94
|
+
* #2661 root-fix — structured per-launch token telemetry. Best-effort,
|
|
95
|
+
* receipt-only: usage is recorded as a distinct receipt keyed by permitId
|
|
96
|
+
* rather than mutated into the launch ledger, so a usage-recording failure
|
|
97
|
+
* can never corrupt the budget-enforcement ledger. Only operational
|
|
98
|
+
* metadata — never prompts or source content.
|
|
99
|
+
*/
|
|
100
|
+
recordUsage(permitId: string | undefined, usage: {
|
|
101
|
+
workerType: string;
|
|
102
|
+
model: string;
|
|
103
|
+
inputTokens?: number;
|
|
104
|
+
outputTokens?: number;
|
|
105
|
+
durationMs?: number;
|
|
106
|
+
costUsd?: number;
|
|
107
|
+
}): void;
|
|
83
108
|
/** Snapshot for `daemon status` / diagnostics. */
|
|
84
109
|
getUsage(): {
|
|
85
110
|
lastHour: number;
|
|
@@ -221,6 +221,62 @@ export class GlobalAiBudget {
|
|
|
221
221
|
unlock?.();
|
|
222
222
|
}
|
|
223
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* #2661 root-fix — manual pause, via `ruflo daemon budget pause`. Distinct
|
|
226
|
+
* from the automatic quota-error circuit breaker only in duration (open-
|
|
227
|
+
* ended, until explicitly resumed, instead of a fixed cooldown) and
|
|
228
|
+
* reason text — the enforcement path in reserve() is identical, so a
|
|
229
|
+
* manual pause is just as hard a stop as a quota-triggered one.
|
|
230
|
+
*/
|
|
231
|
+
async pause(reason) {
|
|
232
|
+
let unlock = null;
|
|
233
|
+
try {
|
|
234
|
+
unlock = await this.acquireLock();
|
|
235
|
+
const now = Date.now();
|
|
236
|
+
const ledger = this.readLedger(now);
|
|
237
|
+
// Sentinel far-future timestamp rather than a real duration — resume()
|
|
238
|
+
// is the only thing that clears it. year ~2255, safely beyond any
|
|
239
|
+
// realistic process lifetime, and still a valid finite JS timestamp.
|
|
240
|
+
ledger.pausedUntil = 9_000_000_000_000;
|
|
241
|
+
ledger.pauseReason = (reason ?? 'manual pause (ruflo daemon budget pause)').slice(0, 200);
|
|
242
|
+
this.writeLedger(ledger);
|
|
243
|
+
this.appendReceipt({ event: 'manual-pause', at: now, reason: ledger.pauseReason });
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
unlock?.();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** #2661 root-fix — `ruflo daemon budget resume`. Clears ANY pause (manual or quota-triggered). */
|
|
250
|
+
async resume() {
|
|
251
|
+
let unlock = null;
|
|
252
|
+
try {
|
|
253
|
+
unlock = await this.acquireLock();
|
|
254
|
+
const now = Date.now();
|
|
255
|
+
const ledger = this.readLedger(now);
|
|
256
|
+
const wasPaused = ledger.pausedUntil !== undefined && ledger.pausedUntil > now;
|
|
257
|
+
ledger.pausedUntil = undefined;
|
|
258
|
+
ledger.pauseReason = undefined;
|
|
259
|
+
this.writeLedger(ledger);
|
|
260
|
+
if (wasPaused) {
|
|
261
|
+
this.appendReceipt({ event: 'manual-resume', at: now });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
unlock?.();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* #2661 root-fix — structured per-launch token telemetry. Best-effort,
|
|
270
|
+
* receipt-only: usage is recorded as a distinct receipt keyed by permitId
|
|
271
|
+
* rather than mutated into the launch ledger, so a usage-recording failure
|
|
272
|
+
* can never corrupt the budget-enforcement ledger. Only operational
|
|
273
|
+
* metadata — never prompts or source content.
|
|
274
|
+
*/
|
|
275
|
+
recordUsage(permitId, usage) {
|
|
276
|
+
if (!permitId || permitId.startsWith('bypass_'))
|
|
277
|
+
return;
|
|
278
|
+
this.appendReceipt({ event: 'usage', at: Date.now(), permitId, ...usage });
|
|
279
|
+
}
|
|
224
280
|
/** Snapshot for `daemon status` / diagnostics. */
|
|
225
281
|
getUsage() {
|
|
226
282
|
try {
|
|
@@ -119,6 +119,10 @@ export interface HeadlessExecutionResult {
|
|
|
119
119
|
durationMs: number;
|
|
120
120
|
/** Estimated tokens used (if available) */
|
|
121
121
|
tokensUsed?: number;
|
|
122
|
+
/** #2661 root-fix — structured usage, when `claude --print --output-format json` exposed it. */
|
|
123
|
+
inputTokens?: number;
|
|
124
|
+
outputTokens?: number;
|
|
125
|
+
costUsd?: number;
|
|
122
126
|
/** Model used for execution */
|
|
123
127
|
model: string;
|
|
124
128
|
/** Sandbox mode used */
|
|
@@ -190,6 +194,23 @@ export declare function isLocalWorker(type: WorkerType): type is LocalWorkerType
|
|
|
190
194
|
* Get model ID from model type
|
|
191
195
|
*/
|
|
192
196
|
export declare function getModelId(model: ModelType): string;
|
|
197
|
+
export interface ClaudePrintEnvelope {
|
|
198
|
+
result: string;
|
|
199
|
+
inputTokens?: number;
|
|
200
|
+
outputTokens?: number;
|
|
201
|
+
costUsd?: number;
|
|
202
|
+
durationMs?: number;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* #2661 root-fix — best-effort parse of `claude --print --output-format
|
|
206
|
+
* json`'s response envelope. Deliberately lenient: probes a couple of
|
|
207
|
+
* plausible field-name shapes (the CLI's JSON schema is not a versioned
|
|
208
|
+
* public contract) and returns null on anything unexpected rather than
|
|
209
|
+
* throwing, so a schema mismatch degrades to "no usage captured" — the
|
|
210
|
+
* caller then falls back to the raw stdout text, exactly today's behavior.
|
|
211
|
+
* Exported for direct unit testing without spawning a real process.
|
|
212
|
+
*/
|
|
213
|
+
export declare function parseClaudePrintJsonEnvelope(raw: string): ClaudePrintEnvelope | null;
|
|
193
214
|
/**
|
|
194
215
|
* Get worker configuration by type
|
|
195
216
|
*/
|