@claude-flow/cli 3.27.3 → 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.
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "manifest": {
3
- "version": "3.27.3",
3
+ "version": "3.28.0",
4
4
  "files": {
5
5
  "auto-memory-hook.mjs": "68be7e9a9eba7bf9c4e8a230db7bf61a243b965639f8504842799d6c6ca28762",
6
6
  "hook-handler.cjs": "2e4927ff158c0079a67a6f0ef0b99ac748d600d33798b0a5fbd5a6a82225ec03",
7
7
  "intelligence.cjs": "bd1f8e4b034944aee1df0391dc47ac2e8cc4b3aa542c65407a59d49620bbf76b",
8
- "statusline.cjs": "ca46b9c780fee974d4b54cfd610b3b09d77c78c40761dc49ae76355da2f643ba"
8
+ "statusline.cjs": "8ce3ea9b732baaad8dd0df4123760496ad2f857b6c3ab2b55b4de879b5cf7276"
9
9
  }
10
10
  },
11
- "signature": "XuS7Mn0CwcqZYTTEoSRuCXG3f9fH7Mu7pILRJi86I+Qj4tDmaroqEhu8TWN1PEVXCK5tiLTMnG20ZxcY+twbDg==",
11
+ "signature": "K7iXHhAaLffQjHpnBB3K9PtOwYreUOw9aROTN5jkb+bn7BH4dzdaDT/L0nBJstvt8uzUZHhAZSXRjnXR5malDw==",
12
12
  "algorithm": "ed25519"
13
13
  }
@@ -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.3";
621
+ let ver = "3.28.0";
622
622
  try {
623
623
  const home = os.homedir();
624
624
  const pkgPaths = [
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "generation": 1,
4
- "generatedAt": "2026-07-14T04:26:59.494Z",
5
- "gitSha": "6643d44d",
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
  ],
@@ -468,7 +468,9 @@ const initAction = async (ctx) => {
468
468
  output.writeln(output.bold('Next steps:'));
469
469
  output.printList([
470
470
  `Run ${output.highlight(`${bin} daemon start`)} to start background workers`,
471
- `Run ${output.highlight(`${bin} memory init`)} to initialize memory database`,
471
+ // Memory is initialized automatically during init (persistent by
472
+ // default — see executor.ts) — no separate `memory init` step needed
473
+ // unless the DB was skipped (MINIMAL_INIT_OPTIONS) or needs --force.
472
474
  `Run ${output.highlight(`${bin} swarm init`)} to initialize a swarm`,
473
475
  `Or use ${output.highlight(`${bin} init --start-all`)} to do all of the above`,
474
476
  options.components.settings ? `Review ${output.highlight('.claude/settings.json')} for hook configurations` : '',
@@ -212,15 +212,39 @@ export async function executeInit(options) {
212
212
  // the `vector_indexes` table (older CLI / agentdb-written), self-heal it
213
213
  // so the statusline vector count + namespace routing work. Best-effort,
214
214
  // dynamically imported so a WASM-only host without better-sqlite3 just
215
- // skips it. Fresh projects have no DB yet — this is a no-op there.
215
+ // skips it.
216
+ //
217
+ // Persistent memory ON BY DEFAULT: `runtime.memoryBackend` already
218
+ // defaults to 'hybrid' in DEFAULT_INIT_OPTIONS, but that only ever
219
+ // configured the DECLARED backend — the actual .swarm/memory.db file
220
+ // was never created until something eventually called `memory store`
221
+ // or the user ran `memory init --force` by hand (both the generated
222
+ // CLAUDE.md and the quickstart docs told users to do this as a
223
+ // separate step). A fresh project could sit for days looking
224
+ // "configured for AgentDB" while genuinely capturing nothing, with no
225
+ // signal that the config and the on-disk reality had diverged. Fresh
226
+ // projects now get the DB eagerly created here, matching what
227
+ // `memoryBackend` already promised; MINIMAL_INIT_OPTIONS
228
+ // (memoryBackend: 'memory') opts out, matching its non-persistent intent.
216
229
  try {
217
230
  const memDbPath = path.join(targetDir, '.swarm', 'memory.db');
218
231
  if (fs.existsSync(memDbPath)) {
219
232
  const { repairVectorIndexes } = await import('../memory/memory-initializer.js');
220
233
  await repairVectorIndexes(memDbPath, { autoRecover: true });
221
234
  }
235
+ else if (options.runtime.memoryBackend !== 'memory') {
236
+ const { initializeMemoryDatabase } = await import('../memory/memory-initializer.js');
237
+ const initResult = await initializeMemoryDatabase({
238
+ backend: options.runtime.memoryBackend,
239
+ dbPath: memDbPath,
240
+ verbose: false,
241
+ });
242
+ if (initResult.success) {
243
+ result.created.files.push('.swarm/memory.db');
244
+ }
245
+ }
222
246
  }
223
- catch { /* best-effort — never block init on memory repair */ }
247
+ catch { /* best-effort — never block init on memory setup */ }
224
248
  }
225
249
  // Generate statusline
226
250
  if (options.components.statusline) {
@@ -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
  */
@@ -336,6 +336,42 @@ export function isLocalWorker(type) {
336
336
  export function getModelId(model) {
337
337
  return MODEL_IDS[model];
338
338
  }
339
+ /**
340
+ * #2661 root-fix — best-effort parse of `claude --print --output-format
341
+ * json`'s response envelope. Deliberately lenient: probes a couple of
342
+ * plausible field-name shapes (the CLI's JSON schema is not a versioned
343
+ * public contract) and returns null on anything unexpected rather than
344
+ * throwing, so a schema mismatch degrades to "no usage captured" — the
345
+ * caller then falls back to the raw stdout text, exactly today's behavior.
346
+ * Exported for direct unit testing without spawning a real process.
347
+ */
348
+ export function parseClaudePrintJsonEnvelope(raw) {
349
+ const trimmed = raw.trim();
350
+ if (!trimmed || trimmed[0] !== '{')
351
+ return null;
352
+ let parsed;
353
+ try {
354
+ parsed = JSON.parse(trimmed);
355
+ }
356
+ catch {
357
+ return null;
358
+ }
359
+ if (!parsed || typeof parsed !== 'object')
360
+ return null;
361
+ const obj = parsed;
362
+ const result = typeof obj.result === 'string' ? obj.result : undefined;
363
+ if (result === undefined)
364
+ return null; // not the envelope shape we expect
365
+ const usage = (obj.usage && typeof obj.usage === 'object') ? obj.usage : undefined;
366
+ const numOrUndef = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
367
+ return {
368
+ result,
369
+ inputTokens: numOrUndef(usage?.input_tokens ?? usage?.inputTokens),
370
+ outputTokens: numOrUndef(usage?.output_tokens ?? usage?.outputTokens),
371
+ costUsd: numOrUndef(obj.total_cost_usd ?? obj.cost_usd ?? obj.totalCostUsd),
372
+ durationMs: numOrUndef(obj.duration_ms ?? obj.durationMs),
373
+ };
374
+ }
339
375
  /**
340
376
  * Get worker configuration by type
341
377
  */
@@ -721,6 +757,9 @@ export class HeadlessWorkerExecutor extends EventEmitter {
721
757
  parsedOutput,
722
758
  durationMs: Date.now() - startTime,
723
759
  tokensUsed: result.tokensUsed,
760
+ inputTokens: result.inputTokens,
761
+ outputTokens: result.outputTokens,
762
+ costUsd: result.costUsd,
724
763
  model: headless.model || 'sonnet',
725
764
  sandboxMode: headless.sandbox,
726
765
  workerType,
@@ -730,6 +769,17 @@ export class HeadlessWorkerExecutor extends EventEmitter {
730
769
  };
731
770
  // Log result
732
771
  this.logExecution(executionId, 'result', JSON.stringify(executionResult, null, 2));
772
+ // #2661 root-fix — structured per-launch telemetry, receipted
773
+ // regardless of success/failure (a failed launch still spent
774
+ // whatever tokens it spent before erroring).
775
+ budget.recordUsage(permit.permitId, {
776
+ workerType,
777
+ model,
778
+ inputTokens: result.inputTokens,
779
+ outputTokens: result.outputTokens,
780
+ durationMs: result.apiDurationMs ?? executionResult.durationMs,
781
+ costUsd: result.costUsd,
782
+ });
733
783
  // #2661 invariant 5 — record the success so sibling worktrees at the
734
784
  // same HEAD skip this job for the rest of the freshness window.
735
785
  if (result.success) {
@@ -988,7 +1038,15 @@ Analyze the above codebase context and provide your response following the forma
988
1038
  // diagnosed as a 5-second redispatch + subprocess-table growth.
989
1039
  // `detached: true` puts the child in its own process group so we
990
1040
  // can signal the whole tree with `process.kill(-pid, sig)`.
991
- const child = spawn('claude', ['--print'], {
1041
+ // #2661 root-fix structured usage telemetry. `--output-format json`
1042
+ // wraps the response in an envelope carrying `result` (the actual
1043
+ // text — what `output` must still contain, unchanged for every
1044
+ // existing downstream parser) alongside `usage`/cost/duration
1045
+ // metadata. Parsing is lenient (parseClaudePrintJsonEnvelope below)
1046
+ // and ALWAYS falls back to the raw text on any mismatch — a schema
1047
+ // surprise from an older/newer `claude` CLI must degrade to exactly
1048
+ // today's behavior (no usage captured), never break analysis output.
1049
+ const child = spawn('claude', ['--print', '--output-format', 'json'], {
992
1050
  cwd: this.projectRoot,
993
1051
  env,
994
1052
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -1068,9 +1126,17 @@ Analyze the above codebase context and provide your response following the forma
1068
1126
  return;
1069
1127
  resolved = true;
1070
1128
  cleanup();
1129
+ const envelope = parseClaudePrintJsonEnvelope(stdout);
1071
1130
  resolve({
1072
1131
  success: code === 0,
1073
- output: stdout || stderr,
1132
+ output: envelope?.result ?? stdout ?? stderr,
1133
+ inputTokens: envelope?.inputTokens,
1134
+ outputTokens: envelope?.outputTokens,
1135
+ tokensUsed: envelope
1136
+ ? (envelope.inputTokens ?? 0) + (envelope.outputTokens ?? 0)
1137
+ : undefined,
1138
+ costUsd: envelope?.costUsd,
1139
+ apiDurationMs: envelope?.durationMs,
1074
1140
  error: code !== 0 ? stderr || `Process exited with code ${code}` : undefined,
1075
1141
  });
1076
1142
  });