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.
@@ -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.27.4";
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.27.4",
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",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "generation": 1,
4
- "generatedAt": "2026-07-14T04:41:45.824Z",
5
- "gitSha": "b254e321",
4
+ "generatedAt": "2026-07-14T05:33:57.949Z",
5
+ "gitSha": "f2208820",
6
6
  "catalog": {
7
7
  "agents": 164,
8
8
  "tools": 387,
@@ -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 { getInstalledCliVersion } from './helper-refresh.js';
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 = getInstalledCliVersion();
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
  */