claude-flow 3.10.42 β†’ 3.10.44

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/README.md CHANGED
@@ -16,10 +16,6 @@
16
16
  [![Codex Plugin](https://img.shields.io/badge/Codex-Plugin-412991?style=for-the-badge&logoColor=white&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI%2BPHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0yMi4yODIgOS44MjFhNS45ODUgNS45ODUgMCAwIDAtLjUxNi00LjkxIDYuMDQ2IDYuMDQ2IDAgMCAwLTYuNTEtMi45QTYuMDY1IDYuMDY1IDAgMCAwIDQuOTgxIDQuMThhNS45ODUgNS45ODUgMCAwIDAtMy45OTggMi45IDYuMDQ2IDYuMDQ2IDAgMCAwIC43NDMgNy4wOTcgNS45OCA1Ljk4IDAgMCAwIC41MSA0LjkxMSA2LjA1MSA2LjA1MSAwIDAgMCA2LjUxNSAyLjlBNS45ODUgNS45ODUgMCAwIDAgMTMuMjYgMjRhNi4wNTYgNi4wNTYgMCAwIDAgNS43NzItNC4yMDYgNS45OSA1Ljk5IDAgMCAwIDMuOTk4LTIuOSA2LjA1NiA2LjA1NiAwIDAgMC0uNzQ3LTcuMDczek0xMy4yNiAyMi40M2E0LjQ3NiA0LjQ3NiAwIDAgMS0yLjg3Ni0xLjA0bC4xNDItLjA4IDQuNzc4LTIuNzU4YS43OTUuNzk1IDAgMCAwIC4zOTMtLjY4MXYtNi43MzdsMi4wMiAxLjE2OGEuMDcxLjA3MSAwIDAgMSAuMDM4LjA1MnY1LjU4M2E0LjUwNCA0LjUwNCAwIDAgMS00LjQ5NSA0LjQ5NHpNMy42IDE4LjMwNGE0LjQ3IDQuNDcgMCAwIDEtLjUzNS0zLjAxNGwuMTQyLjA4NSA0Ljc4MyAyLjc1OWEuNzcxLjc3MSAwIDAgMCAuNzgxIDBsNS44NDMtMy4zNjl2Mi4zMzJhLjA4LjA4IDAgMCAxLS4wMzMuMDYyTDkuNzQgMTkuOTVhNC41IDQuNSAwIDAgMS02LjE0LTEuNjQ2ek0yLjM0IDcuODk2YTQuNDg1IDQuNDg1IDAgMCAxIDIuMzY2LTEuOTczVjExLjZhLjc2Ni43NjYgMCAwIDAgLjM4OC42NzdsNS44MTUgMy4zNTQtMi4wMiAxLjE2OGEuMDc2LjA3NiAwIDAgMS0uMDcyIDBsLTQuODMtMi43ODZBNC41MDQgNC41MDQgMCAwIDEgMi4zNCA3Ljg3MnptMTYuNTk3IDMuODU1LTUuODMzLTMuMzg3IDIuMDE2LTEuMTY1YS4wNzYuMDc2IDAgMCAxIC4wNzEgMGw0LjgzIDIuNzkxYTQuNDk0IDQuNDk0IDAgMCAxLS42NzYgOC4xMDR2LTUuNjc3YS43OS43OSAwIDAgMC0uNDA3LS42Njd6bTIuMDEtMy4wMjMtLjE0MS0uMDg1LTQuNzc0LTIuNzgyYS43NzYuNzc2IDAgMCAwLS43ODUgMEw5LjQwOSA5LjIzVjYuODk3YS4wNjYuMDY2IDAgMCAxIC4wMjgtLjA2Mmw0LjgzLTIuNzg3YTQuNDk5IDQuNDk5IDAgMCAxIDYuNjggNC42NnpNOC4zMDcgMTIuODYzbC0yLjAyLTEuMTY0YS4wOC4wOCAwIDAgMS0uMDM4LS4wNTdWNi4wNzRhNC40OTkgNC40OTkgMCAwIDEgNy4zNzYtMy40NTRsLS4xNDIuMDgtNC43NzggMi43NThhLjc5NS43OTUgMCAwIDAtLjM5My42ODJ6bTEuMDk3LTIuMzY2IDIuNjAyLTEuNSAyLjYwNyAxLjV2Mi45OTlsLTIuNTk3IDEuNS0yLjYwNy0xLjVaIi8%2BPC9zdmc%2B)](https://www.npmjs.com/package/@claude-flow/codex)
17
17
  [![πŸ•ΈοΈ RuVector Graph Ai](https://img.shields.io/badge/RuVector_Agentic-DB-06b6d4?style=for-the-badge&logoColor=white&logo=graphql)](https://github.com/ruvnet/ruvector)
18
18
 
19
- [![RuFlo Agentic Appliance](v3/docs/assets/RuFlo-agentic-appliance.png)](https://cognitum.one/appliance)
20
-
21
- [![ruFlo Summit β€” Budapest, June 2–3, 2026](v3/docs/assets/ruFlo-Summit.jpg)](https://github.com/ruvnet/ruflo/issues/1967)
22
-
23
19
  # Ruflo
24
20
 
25
21
  **Multi-agent AI harness for Claude Code and Codex**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-flow",
3
- "version": "3.10.42",
3
+ "version": "3.10.44",
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",
@@ -16,10 +16,6 @@
16
16
  [![Codex Plugin](https://img.shields.io/badge/Codex-Plugin-412991?style=for-the-badge&logoColor=white&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI%2BPHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0yMi4yODIgOS44MjFhNS45ODUgNS45ODUgMCAwIDAtLjUxNi00LjkxIDYuMDQ2IDYuMDQ2IDAgMCAwLTYuNTEtMi45QTYuMDY1IDYuMDY1IDAgMCAwIDQuOTgxIDQuMThhNS45ODUgNS45ODUgMCAwIDAtMy45OTggMi45IDYuMDQ2IDYuMDQ2IDAgMCAwIC43NDMgNy4wOTcgNS45OCA1Ljk4IDAgMCAwIC41MSA0LjkxMSA2LjA1MSA2LjA1MSAwIDAgMCA2LjUxNSAyLjlBNS45ODUgNS45ODUgMCAwIDAgMTMuMjYgMjRhNi4wNTYgNi4wNTYgMCAwIDAgNS43NzItNC4yMDYgNS45OSA1Ljk5IDAgMCAwIDMuOTk4LTIuOSA2LjA1NiA2LjA1NiAwIDAgMC0uNzQ3LTcuMDczek0xMy4yNiAyMi40M2E0LjQ3NiA0LjQ3NiAwIDAgMS0yLjg3Ni0xLjA0bC4xNDItLjA4IDQuNzc4LTIuNzU4YS43OTUuNzk1IDAgMCAwIC4zOTMtLjY4MXYtNi43MzdsMi4wMiAxLjE2OGEuMDcxLjA3MSAwIDAgMSAuMDM4LjA1MnY1LjU4M2E0LjUwNCA0LjUwNCAwIDAgMS00LjQ5NSA0LjQ5NHpNMy42IDE4LjMwNGE0LjQ3IDQuNDcgMCAwIDEtLjUzNS0zLjAxNGwuMTQyLjA4NSA0Ljc4MyAyLjc1OWEuNzcxLjc3MSAwIDAgMCAuNzgxIDBsNS44NDMtMy4zNjl2Mi4zMzJhLjA4LjA4IDAgMCAxLS4wMzMuMDYyTDkuNzQgMTkuOTVhNC41IDQuNSAwIDAgMS02LjE0LTEuNjQ2ek0yLjM0IDcuODk2YTQuNDg1IDQuNDg1IDAgMCAxIDIuMzY2LTEuOTczVjExLjZhLjc2Ni43NjYgMCAwIDAgLjM4OC42NzdsNS44MTUgMy4zNTQtMi4wMiAxLjE2OGEuMDc2LjA3NiAwIDAgMS0uMDcyIDBsLTQuODMtMi43ODZBNC41MDQgNC41MDQgMCAwIDEgMi4zNCA3Ljg3MnptMTYuNTk3IDMuODU1LTUuODMzLTMuMzg3IDIuMDE2LTEuMTY1YS4wNzYuMDc2IDAgMCAxIC4wNzEgMGw0LjgzIDIuNzkxYTQuNDk0IDQuNDk0IDAgMCAxLS42NzYgOC4xMDR2LTUuNjc3YS43OS43OSAwIDAgMC0uNDA3LS42Njd6bTIuMDEtMy4wMjMtLjE0MS0uMDg1LTQuNzc0LTIuNzgyYS43NzYuNzc2IDAgMCAwLS43ODUgMEw5LjQwOSA5LjIzVjYuODk3YS4wNjYuMDY2IDAgMCAxIC4wMjgtLjA2Mmw0LjgzLTIuNzg3YTQuNDk5IDQuNDk5IDAgMCAxIDYuNjggNC42NnpNOC4zMDcgMTIuODYzbC0yLjAyLTEuMTY0YS4wOC4wOCAwIDAgMS0uMDM4LS4wNTdWNi4wNzRhNC40OTkgNC40OTkgMCAwIDEgNy4zNzYtMy40NTRsLS4xNDIuMDgtNC43NzggMi43NThhLjc5NS43OTUgMCAwIDAtLjM5My42ODJ6bTEuMDk3LTIuMzY2IDIuNjAyLTEuNSAyLjYwNyAxLjV2Mi45OTlsLTIuNTk3IDEuNS0yLjYwNy0xLjVaIi8%2BPC9zdmc%2B)](https://www.npmjs.com/package/@claude-flow/codex)
17
17
  [![πŸ•ΈοΈ RuVector Graph Ai](https://img.shields.io/badge/RuVector_Agentic-DB-06b6d4?style=for-the-badge&logoColor=white&logo=graphql)](https://github.com/ruvnet/ruvector)
18
18
 
19
- [![RuFlo Agentic Appliance](v3/docs/assets/RuFlo-agentic-appliance.png)](https://cognitum.one/appliance)
20
-
21
- [![ruFlo Summit β€” Budapest, June 2–3, 2026](v3/docs/assets/ruFlo-Summit.jpg)](https://github.com/ruvnet/ruflo/issues/1967)
22
-
23
19
  # Ruflo
24
20
 
25
21
  **Multi-agent AI harness for Claude Code and Codex**
@@ -23,6 +23,14 @@ export declare function resolveWorkspaceFlag(raw: unknown): string | null;
23
23
  * via the PID file.
24
24
  */
25
25
  export declare function daemonCommandLineBelongsToWorkspace(commandLine: string, workspaceRoot: string): boolean;
26
+ /**
27
+ * #2356: extract the workspace root from a daemon process command line for the
28
+ * global `daemon status --all` view. The launcher always appends
29
+ * `--workspace <root>` as the FINAL argv entry (see startBackgroundDaemon), so
30
+ * we capture everything after it to end-of-line and strip trailing quotes.
31
+ * Returns null for pre-#1914 daemons that never stamped a workspace.
32
+ */
33
+ export declare function extractWorkspaceFromDaemonLine(commandLine: string): string | null;
26
34
  export declare const daemonCommand: Command;
27
35
  export default daemonCommand;
28
36
  //# sourceMappingURL=daemon.d.ts.map
@@ -21,6 +21,9 @@ const startCommand = {
21
21
  { name: 'sandbox', type: 'string', description: 'Default sandbox mode for headless workers', choices: ['strict', 'permissive', 'disabled'] },
22
22
  { name: 'max-cpu-load', type: 'string', description: 'Override maxCpuLoad resource threshold (e.g. 4.0)' },
23
23
  { name: 'min-free-memory', type: 'string', description: 'Override minFreeMemoryPercent resource threshold (e.g. 15)' },
24
+ // #2356: self-terminating lifecycle. Caps how long a forgotten daemon can
25
+ // keep dispatching headless worker sweeps. Default 12h (or RUFLO_DAEMON_TTL_SECS); 0 = run until stopped.
26
+ { name: 'ttl', type: 'string', description: 'Max daemon age in seconds before graceful self-shutdown (0 = run until stopped; default 43200 = 12h)' },
24
27
  // #1914: workspace root for this daemon. Set automatically when the
25
28
  // background launcher forks the foreground child so the daemon process
26
29
  // carries its workspace path in argv β€” `killStaleDaemons` then only
@@ -71,6 +74,18 @@ const startCommand = {
71
74
  config.resourceThresholds = thresholds;
72
75
  }
73
76
  }
77
+ // #2356: parse --ttl (seconds β†’ ms). Integer-only so 0 (disable) is valid;
78
+ // INT_RE forbids the decimals NUMERIC_RE allows, since a TTL is whole seconds.
79
+ const rawTtl = ctx.flags.ttl;
80
+ const INT_RE = /^\d+$/;
81
+ if (rawTtl !== undefined) {
82
+ if (INT_RE.test(rawTtl)) {
83
+ config.ttlMs = parseInt(rawTtl, 10) * 1000;
84
+ }
85
+ else if (!quiet) {
86
+ output.printWarning(`Ignoring invalid --ttl value: ${sanitize(rawTtl)}`);
87
+ }
88
+ }
74
89
  // Check if background daemon already running (skip if we ARE the daemon process)
75
90
  if (!isDaemonProcess) {
76
91
  const bgPid = getBackgroundDaemonPid(projectRoot);
@@ -95,6 +110,7 @@ const startCommand = {
95
110
  workers: ctx.flags.workers,
96
111
  headless: ctx.flags.headless,
97
112
  sandbox: ctx.flags.sandbox,
113
+ ttl: rawTtl,
98
114
  });
99
115
  }
100
116
  // Foreground mode: run in current process (blocks terminal)
@@ -134,6 +150,9 @@ const startCommand = {
134
150
  output.printBox([
135
151
  `PID: ${status.pid}`,
136
152
  `Started: ${status.startedAt?.toISOString()}`,
153
+ status.config.ttlMs > 0
154
+ ? `TTL: ${Math.round(status.config.ttlMs / 3600000)}h (self-shutdown)`
155
+ : `TTL: off (runs until stopped)`,
137
156
  `Workers: ${status.config.workers.filter(w => w.enabled).length} enabled`,
138
157
  `Max Concurrent: ${status.config.maxConcurrent}`,
139
158
  `Max CPU Load: ${status.config.resourceThresholds.maxCpuLoad}`,
@@ -243,8 +262,22 @@ export function resolveWorkspaceFlag(raw) {
243
262
  export function daemonCommandLineBelongsToWorkspace(commandLine, workspaceRoot) {
244
263
  return commandLine.replace(/[\s"']+$/u, '').endsWith(`--workspace ${workspaceRoot}`);
245
264
  }
265
+ /**
266
+ * #2356: extract the workspace root from a daemon process command line for the
267
+ * global `daemon status --all` view. The launcher always appends
268
+ * `--workspace <root>` as the FINAL argv entry (see startBackgroundDaemon), so
269
+ * we capture everything after it to end-of-line and strip trailing quotes.
270
+ * Returns null for pre-#1914 daemons that never stamped a workspace.
271
+ */
272
+ export function extractWorkspaceFromDaemonLine(commandLine) {
273
+ const m = commandLine.match(/--workspace\s+(.+?)\s*$/u);
274
+ if (!m)
275
+ return null;
276
+ const ws = m[1].replace(/["']+$/u, '').trim();
277
+ return ws.length > 0 ? ws : null;
278
+ }
246
279
  async function startBackgroundDaemon(projectRoot, quiet, forwarded = {}) {
247
- const { maxCpuLoad, minFreeMemory, workers, headless, sandbox } = forwarded;
280
+ const { maxCpuLoad, minFreeMemory, workers, headless, sandbox, ttl } = forwarded;
248
281
  // Validate and resolve project root
249
282
  const resolvedRoot = resolve(projectRoot);
250
283
  validatePath(resolvedRoot, 'Project root');
@@ -312,6 +345,11 @@ async function startBackgroundDaemon(projectRoot, quiet, forwarded = {}) {
312
345
  if (minFreeMemory && SPAWN_NUMERIC_RE.test(minFreeMemory)) {
313
346
  forkArgs.push('--min-free-memory', minFreeMemory);
314
347
  }
348
+ // #2356: forward the TTL so the background daemon enforces it too. Integer
349
+ // seconds only (incl. 0 to disable) β€” reject anything else before it hits argv.
350
+ if (typeof ttl === 'string' && /^\d+$/.test(ttl)) {
351
+ forkArgs.push('--ttl', ttl);
352
+ }
315
353
  // #1968: forward worker-selection / sandbox flags. The previous launcher
316
354
  // dropped these, so `daemon start --workers map` ran with the default
317
355
  // five-worker set instead of just `map`. Validate each before passing
@@ -599,6 +637,116 @@ function isProcessRunning(pid) {
599
637
  return false;
600
638
  }
601
639
  }
640
+ /**
641
+ * #2356: enumerate every running ruflo daemon across ALL workspaces. Reuses
642
+ * the same `ps`/`tasklist` scan as killStaleDaemons but, instead of killing,
643
+ * returns each live daemon's PID + workspace so `daemon status --all` can
644
+ * surface daemons leaked in other projects. Best-effort: any tooling failure
645
+ * yields an empty list (matching the kill-stale paths).
646
+ */
647
+ async function scanRunningDaemons() {
648
+ const isWin = process.platform === 'win32';
649
+ try {
650
+ const { execFileSync } = await import('child_process');
651
+ const out = isWin
652
+ ? execFileSync('tasklist', ['/v', '/fo', 'csv', '/nh'], { encoding: 'utf-8', timeout: 5000 })
653
+ : execFileSync('ps', ['-eo', 'pid,command'], { encoding: 'utf-8', timeout: 5000 });
654
+ const lines = out.split(/\r?\n/);
655
+ const found = [];
656
+ for (const line of lines) {
657
+ if (!line.includes('daemon start --foreground'))
658
+ continue;
659
+ if (!line.includes('claude-flow') && !line.includes('@claude-flow/cli'))
660
+ continue;
661
+ let pid;
662
+ let cmd;
663
+ if (isWin) {
664
+ // tasklist /fo csv: quoted fields; PID is field[1], Window Title is last.
665
+ const fields = line.split(/","/).map(f => f.replace(/^"|"$/g, ''));
666
+ pid = parseInt(fields[1] ?? '', 10);
667
+ cmd = fields[fields.length - 1] ?? line;
668
+ }
669
+ else {
670
+ pid = parseInt(line.trim().split(/\s+/)[0], 10);
671
+ cmd = line;
672
+ }
673
+ if (Number.isNaN(pid) || !isProcessRunning(pid))
674
+ continue;
675
+ found.push({ pid, workspace: extractWorkspaceFromDaemonLine(cmd) });
676
+ }
677
+ return found;
678
+ }
679
+ catch {
680
+ return [];
681
+ }
682
+ }
683
+ /**
684
+ * #2356: render the global `daemon status --all` view. For each running daemon
685
+ * it reads that workspace's daemon-state.json to show age + configured TTL,
686
+ * and flags any daemon that has outlived its TTL (or 12h when TTL is unknown)
687
+ * as stale β€” the visibility that was missing when leaked daemons ran for days.
688
+ */
689
+ async function renderAllDaemonsStatus() {
690
+ const daemons = await scanRunningDaemons();
691
+ output.writeln();
692
+ if (daemons.length === 0) {
693
+ output.printBox('No ruflo daemons are running in any workspace.', 'RuFlo Daemons (all workspaces)');
694
+ return { success: true, data: { daemons: [] } };
695
+ }
696
+ const now = Date.now();
697
+ const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
698
+ let staleCount = 0;
699
+ const rows = daemons.map(d => {
700
+ let startedAt;
701
+ let ttlMs;
702
+ if (d.workspace) {
703
+ try {
704
+ const statePath = join(d.workspace, '.claude-flow', 'daemon-state.json');
705
+ if (fs.existsSync(statePath)) {
706
+ const st = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
707
+ if (st?.startedAt)
708
+ startedAt = new Date(st.startedAt);
709
+ if (typeof st?.config?.ttlMs === 'number')
710
+ ttlMs = st.config.ttlMs;
711
+ }
712
+ }
713
+ catch { /* unreadable/partial state β€” show what we have */ }
714
+ }
715
+ const ageMs = startedAt ? now - startedAt.getTime() : undefined;
716
+ const overTtl = ttlMs !== undefined && ttlMs > 0 && ageMs !== undefined && ageMs > ttlMs;
717
+ const overTwelveH = ageMs !== undefined && ageMs > TWELVE_HOURS_MS;
718
+ const isStale = overTtl || overTwelveH;
719
+ if (isStale)
720
+ staleCount++;
721
+ const ageText = ageMs !== undefined ? formatTimeAgo(startedAt).replace(' ago', '') : '?';
722
+ const ttlText = ttlMs !== undefined
723
+ ? (ttlMs > 0 ? `${Math.round(ttlMs / 3600000)}h` : 'off')
724
+ : '?';
725
+ return {
726
+ pid: isStale ? output.warning(String(d.pid)) : String(d.pid),
727
+ workspace: d.workspace ?? output.dim('(unknown)'),
728
+ age: isStale ? output.warning(ageText) : ageText,
729
+ ttl: ttlText === 'off' ? output.dim('off') : ttlText,
730
+ };
731
+ });
732
+ output.printTable({
733
+ columns: [
734
+ { key: 'pid', header: 'PID', width: 8 },
735
+ { key: 'age', header: 'Age', width: 8 },
736
+ { key: 'ttl', header: 'TTL', width: 6 },
737
+ { key: 'workspace', header: 'Workspace', width: 50 },
738
+ ],
739
+ data: rows,
740
+ });
741
+ output.writeln();
742
+ if (staleCount > 0) {
743
+ output.printWarning(`${staleCount} daemon(s) have outlived their TTL (or have run >12h). Stop one with: cd <workspace> && ruflo daemon stop`);
744
+ }
745
+ else {
746
+ output.printInfo(`${daemons.length} daemon(s) running, all within their TTL.`);
747
+ }
748
+ return { success: true, data: { daemons: rows.length } };
749
+ }
602
750
  // Status subcommand
603
751
  const statusCommand = {
604
752
  name: 'status',
@@ -606,15 +754,24 @@ const statusCommand = {
606
754
  options: [
607
755
  { name: 'verbose', short: 'v', type: 'boolean', description: 'Show detailed worker statistics' },
608
756
  { name: 'show-modes', type: 'boolean', description: 'Show worker execution modes (local/headless) and sandbox settings' },
757
+ // #2356: the default status reads only the CURRENT workspace, so a daemon
758
+ // leaked in another project is invisible. --all scans every running ruflo
759
+ // daemon across all workspaces (the global view that surfaces leaks).
760
+ { name: 'all', short: 'a', type: 'boolean', description: 'List ruflo daemons across ALL workspaces (global view β€” surfaces leaked daemons)' },
609
761
  ],
610
762
  examples: [
611
763
  { command: 'claude-flow daemon status', description: 'Show daemon status' },
612
764
  { command: 'claude-flow daemon status -v', description: 'Show detailed status' },
613
765
  { command: 'claude-flow daemon status --show-modes', description: 'Show worker execution modes' },
766
+ { command: 'claude-flow daemon status --all', description: 'List daemons across all workspaces' },
614
767
  ],
615
768
  action: async (ctx) => {
616
769
  const verbose = ctx.flags.verbose;
617
770
  const showModes = ctx.flags['show-modes'];
771
+ // #2356: global view across every workspace, not just cwd.
772
+ if (ctx.flags.all) {
773
+ return renderAllDaemonsStatus();
774
+ }
618
775
  const projectRoot = process.cwd();
619
776
  try {
620
777
  const daemon = getDaemon(projectRoot);
@@ -633,6 +790,9 @@ const statusCommand = {
633
790
  `Status: ${statusIcon} ${statusText}${mode}`,
634
791
  `PID: ${displayPid}`,
635
792
  status.startedAt ? `Started: ${status.startedAt.toISOString()}` : '',
793
+ status.config.ttlMs > 0
794
+ ? `TTL: ${Math.round(status.config.ttlMs / 3600000)}h (self-shutdown)`
795
+ : `TTL: ${output.dim('off (runs until stopped)')}`,
636
796
  `Workers Enabled: ${status.config.workers.filter(w => w.enabled).length}`,
637
797
  `Max Concurrent: ${status.config.maxConcurrent}`,
638
798
  `Max CPU Load: ${status.config.resourceThresholds.maxCpuLoad}`,
@@ -190,6 +190,7 @@ const initAction = async (ctx) => {
190
190
  // set still got `~/.claude/CLAUDE.md` modified. Read the real key.
191
191
  const noGlobal = ctx.flags['no-global'] === true || ctx.flags['global'] === false;
192
192
  const allAgents = ctx.flags['all-agents'];
193
+ const cloudMcp = ctx.flags['cloud-mcp'];
193
194
  const codexMode = ctx.flags.codex;
194
195
  const dualMode = ctx.flags.dual;
195
196
  const cwd = ctx.cwd;
@@ -230,6 +231,12 @@ const initAction = async (ctx) => {
230
231
  }
231
232
  else if (full) {
232
233
  options = { ...FULL_INIT_OPTIONS, targetDir: cwd, force };
234
+ // #2356: keep auth-gated cloud MCP servers opt-in even under --full. They
235
+ // require a login, get committed into .mcp.json, and add per-session MCP
236
+ // tool-definition token cost. --cloud-mcp restores the all-three behavior.
237
+ if (!cloudMcp) {
238
+ options.mcp = { ...options.mcp, ruvSwarm: false, flowNexus: false };
239
+ }
233
240
  }
234
241
  else {
235
242
  options = { ...DEFAULT_INIT_OPTIONS, targetDir: cwd, force };
@@ -970,6 +977,16 @@ export const initCommand = {
970
977
  type: 'boolean',
971
978
  default: false,
972
979
  },
980
+ {
981
+ // #2356: under --full, the auth-gated cloud MCP servers (ruv-swarm,
982
+ // flow-nexus) get written into a committed .mcp.json and add MCP
983
+ // tool-definition token cost every session. Keep them opt-in even with
984
+ // --full; pass --cloud-mcp to register them.
985
+ name: 'cloud-mcp',
986
+ description: 'Register auth-gated cloud MCP servers (ruv-swarm, flow-nexus) in .mcp.json (only relevant with --full)',
987
+ type: 'boolean',
988
+ default: false,
989
+ },
973
990
  {
974
991
  name: 'skip-claude',
975
992
  description: 'Skip .claude/ directory creation (runtime only)',
@@ -411,11 +411,21 @@ const statusCommand = {
411
411
  details: `${stats.patternsLearned} patterns stored`,
412
412
  },
413
413
  {
414
+ // #2356: distinguish "loaded in this process" from "installed but
415
+ // not yet loaded" from "not installed". Previously `neural status`
416
+ // always printed "Not loaded" because it never warms the lazy
417
+ // singleton β€” a false negative even when @ruvector/core is present.
414
418
  component: 'HNSW Index',
415
- status: hnswStatus.available ? output.success('Ready') : output.dim('Not loaded'),
416
- details: hnswStatus.available
419
+ status: hnswStatus.initialized
420
+ ? output.success('Ready')
421
+ : hnswStatus.available
422
+ ? output.info('Available')
423
+ : output.dim('Not installed'),
424
+ details: hnswStatus.initialized
417
425
  ? `${hnswStatus.entryCount} vectors, ${hnswStatus.dimensions}-dim`
418
- : '@ruvector/core not available',
426
+ : hnswStatus.available
427
+ ? '@ruvector/core installed (loads on first vector search)'
428
+ : '@ruvector/core not available',
419
429
  },
420
430
  {
421
431
  component: 'Embedding Model',
@@ -172,11 +172,15 @@ function setupAndBoundary() {
172
172
  return `## Setup
173
173
 
174
174
  \`\`\`bash
175
- claude mcp add claude-flow -- npx -y @claude-flow/cli@latest
176
- npx @claude-flow/cli@latest daemon start
177
- npx @claude-flow/cli@latest doctor --fix
175
+ claude mcp add claude-flow -- npx -y ruflo@latest mcp start
176
+ npx ruflo@latest doctor --fix
178
177
  \`\`\`
179
178
 
179
+ > The background \`daemon\` is optional. It runs interval workers that each spawn
180
+ > a headless \`claude\` session, so it consumes tokens continuously. Start it only
181
+ > if you want those sweeps: \`npx ruflo@latest daemon start\` (self-stops after 12h
182
+ > by default; \`--ttl 0\` to disable, \`daemon status --all\` to audit running daemons).
183
+
180
184
  **Agent tool** handles execution (agents, files, code, git). **MCP tools** handle coordination (swarm, memory, hooks). **CLI** is the same via Bash.`;
181
185
  }
182
186
  function buildAndTest() {
@@ -22,6 +22,7 @@ export interface AgentRecord {
22
22
  lastResult?: Record<string, unknown>;
23
23
  }
24
24
  export declare const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6";
25
+ export declare function modelRejectsSamplingParams(model: string): boolean;
25
26
  export interface AnthropicCallInput {
26
27
  prompt: string;
27
28
  systemPrompt?: string;
@@ -46,6 +46,15 @@ const MODEL_MAP = {
46
46
  'opus-4.7': 'claude-opus-4-7',
47
47
  inherit: DEFAULT_ANTHROPIC_MODEL,
48
48
  };
49
+ // #2357 β€” the adaptive-thinking family (Fable 5, Opus 4.8, Opus 4.7) removed
50
+ // the sampling parameters (temperature/top_p/top_k); the Anthropic API
51
+ // returns 400 "Extra inputs are not permitted" when any is present.
52
+ // Prefix-match so dated snapshots (e.g. claude-opus-4-8-YYYYMMDD) are
53
+ // covered. Applies only to the direct Anthropic path β€” the Ollama/OpenRouter
54
+ // OpenAI-compat paths accept temperature and are unchanged.
55
+ export function modelRejectsSamplingParams(model) {
56
+ return /^claude-(fable-5|opus-4-8|opus-4-7)/.test(model);
57
+ }
49
58
  /**
50
59
  * Generic Anthropic Messages API call. No agent registry coupling β€” used
51
60
  * by agent_execute (with the agent's configured model) and by the WASM
@@ -76,7 +85,11 @@ export async function callAnthropicMessages(input) {
76
85
  apiKey: openrouterKey,
77
86
  baseUrl: process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api',
78
87
  providerLabel: 'openrouter',
79
- defaultModel: process.env.OPENROUTER_DEFAULT_MODEL || 'anthropic/claude-3.5-sonnet',
88
+ // #2357 Finding C: anthropic/claude-3.5-sonnet was retired Oct 2025.
89
+ // Default to the same canonical family the rest of the resolver uses
90
+ // (MODEL_MAP). `OPENROUTER_DEFAULT_MODEL` still wins for callers who
91
+ // want to pin a specific OpenRouter slug.
92
+ defaultModel: process.env.OPENROUTER_DEFAULT_MODEL || 'anthropic/claude-sonnet-4-6',
80
93
  });
81
94
  }
82
95
  if (useOllama && ollamaKey) {
@@ -103,7 +116,13 @@ export async function callAnthropicMessages(input) {
103
116
  body: JSON.stringify({
104
117
  model,
105
118
  max_tokens: input.maxTokens || 1024,
106
- temperature: typeof input.temperature === 'number' ? input.temperature : 0.7,
119
+ // #2357 β€” omit temperature for models that reject sampling params
120
+ // (Fable 5 / Opus 4.8 / Opus 4.7 β†’ 400 "Extra inputs are not
121
+ // permitted"); keep the 0.7 default unchanged for models that still
122
+ // accept it (sonnet / haiku / opus ≀4.6).
123
+ ...(modelRejectsSamplingParams(model)
124
+ ? {}
125
+ : { temperature: typeof input.temperature === 'number' ? input.temperature : 0.7 }),
107
126
  // #8 prompt caching (hermes-agent pattern): mark the (often large,
108
127
  // stable) system prompt as an ephemeral cache breakpoint so repeated
109
128
  // agent_execute calls with the same system prompt hit Anthropic's
@@ -299,13 +318,15 @@ async function callOpenAICompat(input) {
299
318
  function resolveOpenAICompatModel(input, fallback) {
300
319
  if (!input)
301
320
  return fallback;
302
- // Logical Claude names β†’ OpenRouter Anthropic-vendored names
321
+ // Logical Claude names β†’ OpenRouter Anthropic-vendored names.
322
+ // #2357 Finding C: the 3.5 / 3-opus slugs were retired Oct 2025; align with
323
+ // MODEL_MAP (claude-haiku-4-5 / claude-sonnet-4-6 / claude-opus-4-8).
303
324
  if (input === 'haiku')
304
- return 'anthropic/claude-3.5-haiku';
325
+ return 'anthropic/claude-haiku-4-5';
305
326
  if (input === 'sonnet' || input === 'inherit')
306
- return 'anthropic/claude-3.5-sonnet';
327
+ return 'anthropic/claude-sonnet-4-6';
307
328
  if (input === 'opus')
308
- return 'anthropic/claude-3-opus';
329
+ return 'anthropic/claude-opus-4-8';
309
330
  return input;
310
331
  }
311
332
  function resolveOllamaModel(input) {
@@ -562,26 +562,35 @@ async function rescueAgentdbEmbedder(agentdb) {
562
562
  // own embed() does the right thing and we should not interpose.
563
563
  if (emb.pipeline)
564
564
  return;
565
- let generateEmbedding = null;
565
+ let localEmbed = null;
566
566
  try {
567
567
  const mod = (await import('./memory-initializer.js'));
568
- generateEmbedding = mod.generateEmbedding ?? null;
568
+ localEmbed = mod.generateLocalEmbedding ?? null;
569
569
  }
570
570
  catch {
571
571
  return; // can't import the rescuer β€” leave the mock fallback alone
572
572
  }
573
- if (!generateEmbedding)
573
+ if (!localEmbed)
574
574
  return;
575
- const embed = generateEmbedding;
576
- // Probe once to confirm the rescuer actually returns real (non-zero) vectors.
577
- // If our own fallback chain also dead-ends in mock embeddings, leave
578
- // agentdb as-is rather than swapping one mock for another.
575
+ const embed = localEmbed;
576
+ // Probe once to confirm the rescuer actually returns REAL ONNX vectors
577
+ // (#2312: the old probe only checked non-zero, which the deterministic
578
+ // hash fallback also satisfies β€” so it "rescued" agentdb's mock with our
579
+ // own mock and reported it as real). Require backend === 'onnx'.
579
580
  try {
580
581
  const probe = await embed('rescue-probe');
581
582
  const arr = probe?.embedding ? Array.from(probe.embedding) : [];
582
583
  const hasSignal = arr.length > 0 && arr.some((v) => Math.abs(v) > 1e-9);
583
- if (!hasSignal)
584
+ if (!hasSignal || probe.backend !== 'onnx') {
585
+ // Local chain is also degraded β€” leave agentdb's embedder alone, but
586
+ // tag it so bridgeGenerateEmbedding's AUDIT-#3 isMock check reports
587
+ // backend='mock' truthfully instead of labeling mock vectors 'onnx'.
588
+ try {
589
+ emb.backend = 'mock';
590
+ }
591
+ catch { /* frozen */ }
584
592
  return;
593
+ }
585
594
  }
586
595
  catch {
587
596
  return;
@@ -269,6 +269,27 @@ export declare function generateEmbedding(text: string): Promise<{
269
269
  model: string;
270
270
  backend: 'onnx' | 'mock';
271
271
  }>;
272
+ /**
273
+ * Generate an embedding using ONLY the local model chain (transformers.js /
274
+ * ruvector ONNX / hash fallback) β€” never the AgentDB bridge.
275
+ *
276
+ * #2312: this MUST stay bridge-free. `memory-bridge.ts` rescues a degraded
277
+ * agentdb embedder by delegating to this module; if that delegation went
278
+ * through `generateEmbedding` (bridge-first), the call would re-enter the
279
+ * patched `agentdb.embedder.embed` and recurse unboundedly:
280
+ *
281
+ * generateEmbedding β†’ bridgeGenerateEmbedding β†’ embedder.embed (patched)
282
+ * β†’ generateEmbedding β†’ … (heap OOM at ~4 GB on CI, no stack overflow
283
+ * because the cycle is async/microtask-driven)
284
+ *
285
+ * Keeping the local chain as its own export breaks that cycle structurally.
286
+ */
287
+ export declare function generateLocalEmbedding(text: string): Promise<{
288
+ embedding: number[];
289
+ dimensions: number;
290
+ model: string;
291
+ backend: 'onnx' | 'mock';
292
+ }>;
272
293
  /**
273
294
  * Generate embeddings for multiple texts
274
295
  * Uses parallel execution for API-based providers (2-4x faster)
@@ -10,7 +10,30 @@
10
10
  */
11
11
  import * as fs from 'fs';
12
12
  import * as path from 'path';
13
+ import { createRequire } from 'node:module';
13
14
  import { readFileMaybeEncrypted, writeFileRestricted } from '../fs-secure.js';
15
+ /**
16
+ * #2356 β€” cached, synchronous capability probe for @ruvector/core. `getHNSWStatus`
17
+ * is sync and is called by `neural status` in a fresh process that never warms
18
+ * the lazy HNSW singleton, so reporting availability off the warm singleton
19
+ * alone produced a false "Not loaded β€” @ruvector/core not available" even when
20
+ * the package is installed and exposes VectorDb. Resolving the module (without
21
+ * importing/initializing it) is a faithful, cheap availability signal.
22
+ */
23
+ let _ruvectorCoreResolvable;
24
+ function isRuvectorCoreResolvable() {
25
+ if (_ruvectorCoreResolvable !== undefined)
26
+ return _ruvectorCoreResolvable;
27
+ try {
28
+ const req = createRequire(import.meta.url);
29
+ req.resolve('@ruvector/core');
30
+ _ruvectorCoreResolvable = true;
31
+ }
32
+ catch {
33
+ _ruvectorCoreResolvable = false;
34
+ }
35
+ return _ruvectorCoreResolvable;
36
+ }
14
37
  /**
15
38
  * #1854: previously every site that needed the memory directory hardcoded
16
39
  * `getMemoryRoot()`, so the documented config entry
@@ -671,8 +694,13 @@ export function getHNSWStatus() {
671
694
  dimensions: hnswIndex?.dimensions ?? 384
672
695
  };
673
696
  }
697
+ // #2356: `available` now reflects real capability (index already loaded OR
698
+ // @ruvector/core installed and resolvable), not merely whether the lazy
699
+ // singleton happens to be warm in this process. `initialized` still reports
700
+ // whether the in-process index is actually loaded, so callers can tell
701
+ // "installed but not yet loaded" apart from "loaded".
674
702
  return {
675
- available: hnswIndex !== null,
703
+ available: hnswIndex !== null || isRuvectorCoreResolvable(),
676
704
  initialized: hnswIndex?.initialized ?? false,
677
705
  entryCount: hnswIndex?.entries.size ?? 0,
678
706
  dimensions: hnswIndex?.dimensions ?? 384
@@ -1581,6 +1609,24 @@ export async function generateEmbedding(text) {
1581
1609
  return { ...bridgeResult, backend };
1582
1610
  }
1583
1611
  }
1612
+ return generateLocalEmbedding(text);
1613
+ }
1614
+ /**
1615
+ * Generate an embedding using ONLY the local model chain (transformers.js /
1616
+ * ruvector ONNX / hash fallback) β€” never the AgentDB bridge.
1617
+ *
1618
+ * #2312: this MUST stay bridge-free. `memory-bridge.ts` rescues a degraded
1619
+ * agentdb embedder by delegating to this module; if that delegation went
1620
+ * through `generateEmbedding` (bridge-first), the call would re-enter the
1621
+ * patched `agentdb.embedder.embed` and recurse unboundedly:
1622
+ *
1623
+ * generateEmbedding β†’ bridgeGenerateEmbedding β†’ embedder.embed (patched)
1624
+ * β†’ generateEmbedding β†’ … (heap OOM at ~4 GB on CI, no stack overflow
1625
+ * because the cycle is async/microtask-driven)
1626
+ *
1627
+ * Keeping the local chain as its own export breaks that cycle structurally.
1628
+ */
1629
+ export async function generateLocalEmbedding(text) {
1584
1630
  // Ensure model is loaded
1585
1631
  if (!embeddingModelState?.loaded) {
1586
1632
  await loadEmbeddingModel();
@@ -8,6 +8,19 @@ import * as path from 'path';
8
8
  import { execFile } from 'child_process';
9
9
  import { promisify } from 'util';
10
10
  const execFileAsync = promisify(execFile);
11
+ // On Windows, `npm` is a shell script (no `.exe`) and `npm.cmd` is a batch
12
+ // wrapper. Since Node 18.20.2 / 20.12.2 (CVE-2024-27980) the runtime refuses
13
+ // to spawn `.cmd`/`.bat` files directly and throws `spawn EINVAL` β€” the only
14
+ // supported invocation is via a real `.exe` shell. We wrap every npm call
15
+ // through `cmd.exe /d /s /c npm <args>`, which keeps Node's safe array-form
16
+ // argument escaping intact and avoids both ENOENT and EINVAL.
17
+ const isWindows = process.platform === 'win32';
18
+ function runNpm(args, timeoutMs) {
19
+ if (isWindows) {
20
+ return execFileAsync('cmd.exe', ['/d', '/s', '/c', 'npm', ...args], { timeout: timeoutMs });
21
+ }
22
+ return execFileAsync('npm', args, { timeout: timeoutMs });
23
+ }
11
24
  /**
12
25
  * Validate npm package name to prevent shell injection (S-3)
13
26
  */
@@ -105,7 +118,7 @@ export class PluginManager {
105
118
  validatePackageName(versionSpec);
106
119
  // Use npm to install (array form prevents shell injection)
107
120
  console.log(`[PluginManager] Installing ${versionSpec}...`);
108
- await execFileAsync('npm', ['install', '--prefix', this.config.pluginsDir, versionSpec], { timeout: 120000 });
121
+ await runNpm(['install', '--prefix', this.config.pluginsDir, versionSpec], 120000);
109
122
  // Get installed version
110
123
  const packageJsonPath = path.join(installDir, packageName, 'package.json');
111
124
  let installedVersion = version || 'latest';
@@ -210,7 +223,7 @@ export class PluginManager {
210
223
  // For npm-installed plugins, remove from node_modules
211
224
  if (plugin.source === 'npm') {
212
225
  validatePackageName(packageName);
213
- await execFileAsync('npm', ['uninstall', '--prefix', this.config.pluginsDir, packageName], { timeout: 60000 });
226
+ await runNpm(['uninstall', '--prefix', this.config.pluginsDir, packageName], 60000);
214
227
  }
215
228
  // Remove from manifest
216
229
  delete this.manifest.plugins[packageName];
@@ -333,7 +346,7 @@ export class PluginManager {
333
346
  // Validate package name to prevent injection (S-3)
334
347
  validatePackageName(versionSpec);
335
348
  // Reinstall with new version (array form prevents shell injection)
336
- await execFileAsync('npm', ['install', '--prefix', this.config.pluginsDir, versionSpec], { timeout: 120000 });
349
+ await runNpm(['install', '--prefix', this.config.pluginsDir, versionSpec], 120000);
337
350
  // Update manifest
338
351
  const installDir = path.join(this.config.pluginsDir, 'node_modules');
339
352
  const packageJsonPath = path.join(installDir, packageName, 'package.json');
@@ -55,6 +55,8 @@ export interface DaemonConfig {
55
55
  maxCpuLoad: number;
56
56
  minFreeMemoryPercent: number;
57
57
  };
58
+ ttlMs: number;
59
+ idleShutdownMs: number;
58
60
  workers: WorkerConfig[];
59
61
  }
60
62
  /**
@@ -65,6 +67,7 @@ export declare class WorkerDaemon extends EventEmitter {
65
67
  private workers;
66
68
  private timers;
67
69
  private queuePollTimer?;
70
+ private lifecycleTimer?;
68
71
  private running;
69
72
  private startedAt?;
70
73
  private projectRoot;
@@ -222,6 +225,30 @@ export declare class WorkerDaemon extends EventEmitter {
222
225
  * Stop the daemon and all workers
223
226
  */
224
227
  stop(): Promise<void>;
228
+ /**
229
+ * #2356 β€” Self-terminating lifecycle monitor. A daemon with no upper bound
230
+ * on its lifetime is the documented root cause of multi-day token leaks:
231
+ * each interval worker spawns a headless `claude --print` sweep, so a daemon
232
+ * left running for days dispatches tens of thousands of sessions invisibly.
233
+ * This timer enforces a max age (`ttlMs`) and an optional idle window
234
+ * (`idleShutdownMs`), shutting the daemon down gracefully when either trips.
235
+ * Checked once a minute and `unref()`'d so it never keeps the process alive
236
+ * on its own. A no-op when both limits are disabled (0).
237
+ */
238
+ private startLifecycleMonitor;
239
+ /**
240
+ * Most recent worker start/finish time across all workers (epoch ms), or
241
+ * null if no worker has ever started. Used for idle-shutdown detection.
242
+ */
243
+ private lastWorkerActivityMs;
244
+ /**
245
+ * Graceful self-shutdown triggered by the lifecycle monitor. Mirrors the
246
+ * signal-handler path (`stop()` then `process.exit(0)`) because the
247
+ * foreground keep-alive in the daemon command is a *ref'd* `setInterval`
248
+ * that would otherwise hold the process open after `stop()` clears the
249
+ * service timers β€” leaving a zombie that reports stopped but never exits.
250
+ */
251
+ private selfShutdown;
225
252
  /**
226
253
  * Get daemon status
227
254
  */
@@ -27,6 +27,30 @@ const DEFAULT_WORKERS = [
27
27
  // Worker timeout β€” must exceed the longest per-worker headless timeout (15 min for audit/refactor).
28
28
  // Previously 5 min, which caused orphan processes when daemon timeout fired before executor timeout (#1117).
29
29
  const DEFAULT_WORKER_TIMEOUT_MS = 16 * 60 * 1000;
30
+ // #2356 β€” Self-terminating lifecycle defaults. A background daemon with no
31
+ // upper bound on its lifetime runs until the box reboots; in the field this
32
+ // leaked tens of thousands of headless `claude --print` sweeps over many days
33
+ // (one observed daemon ran 19 days). A 12h default age cap (matching the
34
+ // pacphi/ruflo-machine-ref kit's proven value) heals a forgotten daemon within
35
+ // half a day; set RUFLO_DAEMON_TTL_SECS=0 (or `--ttl 0`) to opt out. Idle
36
+ // shutdown is opt-in (0 = disabled) since a legitimately quiet daemon is not a leak.
37
+ const DEFAULT_DAEMON_TTL_MS = 12 * 60 * 60 * 1000;
38
+ const DEFAULT_DAEMON_IDLE_SHUTDOWN_MS = 0;
39
+ /**
40
+ * Read a non-negative seconds value from an env var and return it as ms.
41
+ * Unlike the `parseInt(x) || default` idiom used elsewhere, an explicit `0`
42
+ * is honored (it disables the corresponding limit) rather than falling back
43
+ * to the default. Invalid / negative / absent values fall back.
44
+ */
45
+ function readEnvSecsAsMs(name, defaultMs) {
46
+ const raw = process.env[name];
47
+ if (raw === undefined || raw.trim() === '')
48
+ return defaultMs;
49
+ const secs = Number.parseInt(raw, 10);
50
+ if (!Number.isFinite(secs) || secs < 0)
51
+ return defaultMs;
52
+ return secs * 1000;
53
+ }
30
54
  /**
31
55
  * Worker Daemon - Manages background workers with Node.js
32
56
  */
@@ -37,6 +61,9 @@ export class WorkerDaemon extends EventEmitter {
37
61
  // #1845: separate timer for the MCP-dispatch queue poller. Kept off
38
62
  // the per-worker map so stop() clears both kinds without confusion.
39
63
  queuePollTimer;
64
+ // #2356: separate timer that enforces the daemon's max-age TTL + idle
65
+ // shutdown. Cleared in stop() alongside the worker/queue timers.
66
+ lifecycleTimer;
40
67
  running = false;
41
68
  startedAt;
42
69
  projectRoot;
@@ -92,6 +119,11 @@ export class WorkerDaemon extends EventEmitter {
92
119
  maxCpuLoad: config?.resourceThresholds?.maxCpuLoad ?? fileConfig.maxCpuLoad ?? smartMaxCpuLoad,
93
120
  minFreeMemoryPercent: config?.resourceThresholds?.minFreeMemoryPercent ?? fileConfig.minFreeMemoryPercent ?? defaultMinFreeMemory,
94
121
  },
122
+ // #2356 β€” precedence: constructor arg > config.json (daemon.ttlSecs) >
123
+ // env (RUFLO_DAEMON_TTL_SECS) > built-in default. readEnvSecsAsMs folds
124
+ // env-or-default and honors an explicit 0 (disable).
125
+ ttlMs: config?.ttlMs ?? fileConfig.ttlMs ?? readEnvSecsAsMs('RUFLO_DAEMON_TTL_SECS', DEFAULT_DAEMON_TTL_MS),
126
+ idleShutdownMs: config?.idleShutdownMs ?? fileConfig.idleShutdownMs ?? readEnvSecsAsMs('RUFLO_DAEMON_IDLE_SECS', DEFAULT_DAEMON_IDLE_SHUTDOWN_MS),
95
127
  workers: config?.workers ?? DEFAULT_WORKERS,
96
128
  };
97
129
  // Setup graceful shutdown handlers
@@ -274,12 +306,19 @@ export class WorkerDaemon extends EventEmitter {
274
306
  const rawMinMem = cfg['daemon.resourceThresholds.minFreeMemoryPercent'] ?? raw['daemon.resourceThresholds.minFreeMemoryPercent'];
275
307
  const rawMaxConcurrent = cfg['daemon.maxConcurrent'] ?? raw['daemon.maxConcurrent'];
276
308
  const rawTimeout = cfg['daemon.workerTimeoutMs'] ?? raw['daemon.workerTimeoutMs'];
309
+ // #2356 β€” lifecycle limits are configured in SECONDS in config.json
310
+ // (`daemon.ttlSecs` / `daemon.idleSecs`) for parity with the CLI flag
311
+ // and env var; stored internally as ms. An explicit 0 disables.
312
+ const rawTtl = cfg['daemon.ttlSecs'] ?? raw['daemon.ttlSecs'];
313
+ const rawIdle = cfg['daemon.idleSecs'] ?? raw['daemon.idleSecs'];
277
314
  return {
278
315
  autoStart: typeof raw['daemon.autoStart'] === 'boolean' ? raw['daemon.autoStart'] : undefined,
279
316
  maxConcurrent: (typeof rawMaxConcurrent === 'number' && rawMaxConcurrent > 0) ? rawMaxConcurrent : undefined,
280
317
  workerTimeoutMs: (typeof rawTimeout === 'number' && rawTimeout > 0) ? rawTimeout : undefined,
281
318
  maxCpuLoad: (typeof rawCpuLoad === 'number' && rawCpuLoad > 0 && rawCpuLoad < 1000) ? rawCpuLoad : undefined,
282
319
  minFreeMemoryPercent: (typeof rawMinMem === 'number' && rawMinMem >= 0 && rawMinMem <= 100) ? rawMinMem : undefined,
320
+ ttlMs: (typeof rawTtl === 'number' && rawTtl >= 0) ? rawTtl * 1000 : undefined,
321
+ idleShutdownMs: (typeof rawIdle === 'number' && rawIdle >= 0) ? rawIdle * 1000 : undefined,
283
322
  };
284
323
  }
285
324
  catch {
@@ -668,6 +707,9 @@ export class WorkerDaemon extends EventEmitter {
668
707
  if (typeof this.queuePollTimer.unref === 'function') {
669
708
  this.queuePollTimer.unref();
670
709
  }
710
+ // #2356: self-terminating lifecycle. Without an upper bound on lifetime a
711
+ // forgotten daemon keeps dispatching headless worker sweeps for days.
712
+ this.startLifecycleMonitor();
671
713
  // Save state
672
714
  this.saveState();
673
715
  this.log('info', `Daemon started (PID: ${process.pid}, CPUs: ${cpus().length}, workers: ${this.config.workers.filter(w => w.enabled).length}, maxCpuLoad: ${this.config.resourceThresholds.maxCpuLoad}, minFreeMemoryPercent: ${this.config.resourceThresholds.minFreeMemoryPercent}%)`);
@@ -762,12 +804,93 @@ export class WorkerDaemon extends EventEmitter {
762
804
  clearInterval(this.queuePollTimer);
763
805
  this.queuePollTimer = undefined;
764
806
  }
807
+ // #2356: stop the TTL/idle lifecycle monitor.
808
+ if (this.lifecycleTimer) {
809
+ clearInterval(this.lifecycleTimer);
810
+ this.lifecycleTimer = undefined;
811
+ }
765
812
  this.running = false;
766
813
  this.removePidFile();
767
814
  this.saveState();
768
815
  this.emit('stopped', { stoppedAt: new Date() });
769
816
  this.log('info', 'Daemon stopped');
770
817
  }
818
+ /**
819
+ * #2356 β€” Self-terminating lifecycle monitor. A daemon with no upper bound
820
+ * on its lifetime is the documented root cause of multi-day token leaks:
821
+ * each interval worker spawns a headless `claude --print` sweep, so a daemon
822
+ * left running for days dispatches tens of thousands of sessions invisibly.
823
+ * This timer enforces a max age (`ttlMs`) and an optional idle window
824
+ * (`idleShutdownMs`), shutting the daemon down gracefully when either trips.
825
+ * Checked once a minute and `unref()`'d so it never keeps the process alive
826
+ * on its own. A no-op when both limits are disabled (0).
827
+ */
828
+ startLifecycleMonitor() {
829
+ const ttlMs = this.config.ttlMs;
830
+ const idleMs = this.config.idleShutdownMs;
831
+ if ((!ttlMs || ttlMs <= 0) && (!idleMs || idleMs <= 0)) {
832
+ return; // both limits disabled β€” preserve legacy run-until-stopped behavior
833
+ }
834
+ const CHECK_INTERVAL_MS = 60_000;
835
+ this.lifecycleTimer = setInterval(() => {
836
+ if (!this.running)
837
+ return;
838
+ const now = Date.now();
839
+ const startedMs = this.startedAt?.getTime() ?? now;
840
+ if (ttlMs > 0 && now - startedMs >= ttlMs) {
841
+ void this.selfShutdown(`max age ${Math.round(ttlMs / 1000)}s reached`);
842
+ return;
843
+ }
844
+ if (idleMs > 0) {
845
+ const lastActivity = this.lastWorkerActivityMs() ?? startedMs;
846
+ if (now - lastActivity >= idleMs) {
847
+ void this.selfShutdown(`idle for ${Math.round(idleMs / 1000)}s (no worker activity)`);
848
+ }
849
+ }
850
+ }, CHECK_INTERVAL_MS);
851
+ if (typeof this.lifecycleTimer.unref === 'function') {
852
+ this.lifecycleTimer.unref();
853
+ }
854
+ const parts = [];
855
+ if (ttlMs > 0)
856
+ parts.push(`ttl=${Math.round(ttlMs / 1000)}s`);
857
+ if (idleMs > 0)
858
+ parts.push(`idle=${Math.round(idleMs / 1000)}s`);
859
+ this.log('info', `Lifecycle monitor active (${parts.join(', ')})`);
860
+ }
861
+ /**
862
+ * Most recent worker start/finish time across all workers (epoch ms), or
863
+ * null if no worker has ever started. Used for idle-shutdown detection.
864
+ */
865
+ lastWorkerActivityMs() {
866
+ let latest = null;
867
+ for (const state of this.workers.values()) {
868
+ for (const t of [state.lastRun, state.lastStartedAt]) {
869
+ if (t) {
870
+ const ms = t.getTime();
871
+ if (latest === null || ms > latest)
872
+ latest = ms;
873
+ }
874
+ }
875
+ }
876
+ return latest;
877
+ }
878
+ /**
879
+ * Graceful self-shutdown triggered by the lifecycle monitor. Mirrors the
880
+ * signal-handler path (`stop()` then `process.exit(0)`) because the
881
+ * foreground keep-alive in the daemon command is a *ref'd* `setInterval`
882
+ * that would otherwise hold the process open after `stop()` clears the
883
+ * service timers β€” leaving a zombie that reports stopped but never exits.
884
+ */
885
+ async selfShutdown(reason) {
886
+ this.log('info', `Daemon self-shutdown: ${reason}`);
887
+ this.emit('self-shutdown', { reason });
888
+ try {
889
+ await this.stop();
890
+ }
891
+ catch { /* best-effort β€” we are exiting regardless */ }
892
+ process.exit(0);
893
+ }
771
894
  /**
772
895
  * Get daemon status
773
896
  */
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-flow/cli",
3
- "version": "3.10.42",
3
+ "version": "3.10.44",
4
4
  "type": "module",
5
5
  "description": "Ruflo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
6
6
  "main": "dist/src/index.js",