claude-flow 3.26.1 → 3.27.0

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