@claude-flow/cli 3.26.0 → 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.
- package/.claude/helpers/helpers.manifest.json +3 -3
- package/.claude/helpers/statusline.cjs +12 -2
- package/catalog-manifest.json +2 -2
- package/dist/src/commands/daemon.js +163 -3
- package/dist/src/init/helpers-generator.js +31 -0
- package/dist/src/init/statusline-generator.js +12 -2
- package/dist/src/services/ai-job-dedup.d.ts +61 -0
- package/dist/src/services/ai-job-dedup.js +136 -0
- package/dist/src/services/git-workspace-identity.d.ts +42 -0
- package/dist/src/services/git-workspace-identity.js +99 -0
- package/dist/src/services/global-ai-budget.d.ts +110 -0
- package/dist/src/services/global-ai-budget.js +359 -0
- package/dist/src/services/headless-worker-executor.d.ts +14 -0
- package/dist/src/services/headless-worker-executor.js +104 -3
- package/dist/src/services/worker-daemon.d.ts +14 -0
- package/dist/src/services/worker-daemon.js +81 -17
- package/package.json +1 -1
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest": {
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.27.0",
|
|
4
4
|
"files": {
|
|
5
5
|
"auto-memory-hook.mjs": "68be7e9a9eba7bf9c4e8a230db7bf61a243b965639f8504842799d6c6ca28762",
|
|
6
6
|
"hook-handler.cjs": "2a18a761f0dc4839df8b6eeadf8f0ceb6badcc5bf14414c91555711a5e9d4bdc",
|
|
7
7
|
"intelligence.cjs": "bd1f8e4b034944aee1df0391dc47ac2e8cc4b3aa542c65407a59d49620bbf76b",
|
|
8
|
-
"statusline.cjs": "
|
|
8
|
+
"statusline.cjs": "8abc67b7512e66fe5f8fe6cc5f099f827de6f7e2023a493f920090248d326e01"
|
|
9
9
|
}
|
|
10
10
|
},
|
|
11
|
-
"signature": "
|
|
11
|
+
"signature": "f0oa4b6TIdjXxPc8TFjFEXODgr4+gNsdQXc54NszbvhpR066pXyaCgxrYTsN9iRwFFY1dp6RtaUCuaMpN4NqDg==",
|
|
12
12
|
"algorithm": "ed25519"
|
|
13
13
|
}
|
|
@@ -199,9 +199,19 @@ function getStatuslineData() {
|
|
|
199
199
|
// back to `npx --prefer-offline @claude-flow/cli` (no @latest); an existing
|
|
200
200
|
// but broken install (e.g. a stale marketplace checkout missing a bundled
|
|
201
201
|
// workspace dep) must not block trying the next one.
|
|
202
|
+
//
|
|
203
|
+
// No `2>/dev/null` here (deliberately) — the execSync call below already
|
|
204
|
+
// sets stdio: ['pipe','pipe','pipe'], which captures/discards stderr at the
|
|
205
|
+
// Node level regardless of shell. The redirect was redundant on POSIX and
|
|
206
|
+
// actively broke every candidate on Windows: cmd.exe (execSync's default
|
|
207
|
+
// shell there) doesn't understand /dev/null, so the CLI delegation always
|
|
208
|
+
// failed, silently degrading every render to buildLocalFallback() — 0%
|
|
209
|
+
// intelligence and an empty promo row (the memo cache that keeps the row
|
|
210
|
+
// populated across CLI hiccups is only ever written from a SUCCESSFUL
|
|
211
|
+
// delegation, so it could never get seeded on Windows either).
|
|
202
212
|
const cmds = resolveCliBinCandidates()
|
|
203
|
-
.map((bin) => '"' + process.execPath + '" "' + bin + '" hooks statusline --json
|
|
204
|
-
.concat(['npx --prefer-offline @claude-flow/cli hooks statusline --json
|
|
213
|
+
.map((bin) => '"' + process.execPath + '" "' + bin + '" hooks statusline --json')
|
|
214
|
+
.concat(['npx --prefer-offline @claude-flow/cli hooks statusline --json']);
|
|
205
215
|
for (const cmd of cmds) {
|
|
206
216
|
try {
|
|
207
217
|
const raw = execSync(
|
package/catalog-manifest.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {',
|
|
@@ -222,9 +222,19 @@ function getStatuslineData() {
|
|
|
222
222
|
// back to \`npx --prefer-offline @claude-flow/cli\` (no @latest); an existing
|
|
223
223
|
// but broken install (e.g. a stale marketplace checkout missing a bundled
|
|
224
224
|
// workspace dep) must not block trying the next one.
|
|
225
|
+
//
|
|
226
|
+
// No \`2>/dev/null\` here (deliberately) — the execSync call below already
|
|
227
|
+
// sets stdio: ['pipe','pipe','pipe'], which captures/discards stderr at the
|
|
228
|
+
// Node level regardless of shell. The redirect was redundant on POSIX and
|
|
229
|
+
// actively broke every candidate on Windows: cmd.exe (execSync's default
|
|
230
|
+
// shell there) doesn't understand /dev/null, so the CLI delegation always
|
|
231
|
+
// failed, silently degrading every render to buildLocalFallback() — 0%
|
|
232
|
+
// intelligence and an empty promo row (the memo cache that keeps the row
|
|
233
|
+
// populated across CLI hiccups is only ever written from a SUCCESSFUL
|
|
234
|
+
// delegation, so it could never get seeded on Windows either).
|
|
225
235
|
const cmds = resolveCliBinCandidates()
|
|
226
|
-
.map((bin) => '"' + process.execPath + '" "' + bin + '" hooks statusline --json
|
|
227
|
-
.concat(['npx --prefer-offline @claude-flow/cli hooks statusline --json
|
|
236
|
+
.map((bin) => '"' + process.execPath + '" "' + bin + '" hooks statusline --json')
|
|
237
|
+
.concat(['npx --prefer-offline @claude-flow/cli hooks statusline --json']);
|
|
228
238
|
for (const cmd of cmds) {
|
|
229
239
|
try {
|
|
230
240
|
const raw = execSync(
|
|
@@ -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
|
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
import * as fs from 'fs';
|
|
25
|
+
import { join } from 'path';
|
|
26
|
+
import { homedir } from 'os';
|
|
27
|
+
import { createHash } from 'crypto';
|
|
28
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
export function computeAiJobKey(parts) {
|
|
30
|
+
return createHash('sha256')
|
|
31
|
+
.update([parts.repositoryId, parts.head, parts.workerType, parts.configHash].join('\n'))
|
|
32
|
+
.digest('hex');
|
|
33
|
+
}
|
|
34
|
+
/** Stable hash of an arbitrary config object (key-sorted JSON). */
|
|
35
|
+
export function hashWorkerConfig(config) {
|
|
36
|
+
const canonical = JSON.stringify(config, (_k, v) => {
|
|
37
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
38
|
+
return Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)));
|
|
39
|
+
}
|
|
40
|
+
return v;
|
|
41
|
+
});
|
|
42
|
+
return createHash('sha256').update(canonical ?? 'null').digest('hex');
|
|
43
|
+
}
|
|
44
|
+
/** Invariant 9: registry files must never be symlinks. */
|
|
45
|
+
function assertNotSymlink(path) {
|
|
46
|
+
try {
|
|
47
|
+
const st = fs.lstatSync(path);
|
|
48
|
+
if (st.isSymbolicLink()) {
|
|
49
|
+
throw new Error(`AI job registry is a symlink (refusing): ${path}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
if (e.code === 'ENOENT')
|
|
54
|
+
return;
|
|
55
|
+
throw e;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export class AiJobDedupRegistry {
|
|
59
|
+
dir;
|
|
60
|
+
file;
|
|
61
|
+
constructor(options) {
|
|
62
|
+
this.dir = options?.baseDir
|
|
63
|
+
?? process.env.RUFLO_AI_BUDGET_DIR
|
|
64
|
+
?? join(homedir(), '.claude-flow');
|
|
65
|
+
this.file = join(this.dir, 'ai-jobs.json');
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* True when the job succeeded within `freshnessMs`. Any registry error
|
|
69
|
+
* reads as "not fresh" — dedup failing open only costs a (budget-capped)
|
|
70
|
+
* launch, never correctness.
|
|
71
|
+
*/
|
|
72
|
+
isFresh(jobKey, freshnessMs) {
|
|
73
|
+
if (process.env.RUFLO_AI_DEDUP_DISABLE === '1')
|
|
74
|
+
return { fresh: false };
|
|
75
|
+
try {
|
|
76
|
+
const records = this.read();
|
|
77
|
+
const rec = records[jobKey];
|
|
78
|
+
if (rec && Date.now() - rec.at < freshnessMs) {
|
|
79
|
+
return { fresh: true, lastRunAt: rec.at };
|
|
80
|
+
}
|
|
81
|
+
return { fresh: false, lastRunAt: rec?.at };
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return { fresh: false };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Record a successful run of a job. Best-effort. */
|
|
88
|
+
recordSuccess(jobKey, meta) {
|
|
89
|
+
try {
|
|
90
|
+
const records = this.read();
|
|
91
|
+
records[jobKey] = { at: Date.now(), ...meta };
|
|
92
|
+
this.write(records);
|
|
93
|
+
}
|
|
94
|
+
catch { /* dedup is an optimization — never block on it */ }
|
|
95
|
+
}
|
|
96
|
+
read() {
|
|
97
|
+
assertNotSymlink(this.file);
|
|
98
|
+
if (!fs.existsSync(this.file))
|
|
99
|
+
return {};
|
|
100
|
+
const raw = JSON.parse(fs.readFileSync(this.file, 'utf-8'));
|
|
101
|
+
if (!raw || typeof raw !== 'object')
|
|
102
|
+
return {};
|
|
103
|
+
// Prune anything older than 24h — freshness windows are far shorter,
|
|
104
|
+
// and HEAD churn would otherwise grow the file without bound.
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const out = {};
|
|
107
|
+
for (const [key, rec] of Object.entries(raw)) {
|
|
108
|
+
if (rec && typeof rec.at === 'number' && now - rec.at < DAY_MS) {
|
|
109
|
+
out[key] = rec;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
write(records) {
|
|
115
|
+
if (!fs.existsSync(this.dir)) {
|
|
116
|
+
fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
117
|
+
}
|
|
118
|
+
assertNotSymlink(this.file);
|
|
119
|
+
const tmp = `${this.file}.tmp.${process.pid}`;
|
|
120
|
+
fs.writeFileSync(tmp, JSON.stringify(records), { mode: 0o600 });
|
|
121
|
+
fs.renameSync(tmp, this.file);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Singleton — one registry per process.
|
|
125
|
+
let registryInstance = null;
|
|
126
|
+
export function getAiJobDedupRegistry() {
|
|
127
|
+
if (!registryInstance) {
|
|
128
|
+
registryInstance = new AiJobDedupRegistry();
|
|
129
|
+
}
|
|
130
|
+
return registryInstance;
|
|
131
|
+
}
|
|
132
|
+
/** Test hook: reset the singleton (e.g. after changing RUFLO_AI_BUDGET_DIR). */
|
|
133
|
+
export function resetAiJobDedupRegistryForTests() {
|
|
134
|
+
registryInstance = null;
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=ai-job-dedup.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2661 — Git workspace identity: separate WORKTREE identity from
|
|
3
|
+
* REPOSITORY identity.
|
|
4
|
+
*
|
|
5
|
+
* Daemon dedup, state, and scheduling have historically been keyed on the
|
|
6
|
+
* worktree path (`process.cwd()`), so N Git worktrees of the same repository
|
|
7
|
+
* behave as N unrelated projects — the cardinality bug behind the worktree
|
|
8
|
+
* daemon fanout. This service resolves the identity that is SHARED across
|
|
9
|
+
* worktrees:
|
|
10
|
+
*
|
|
11
|
+
* worktreeRoot `git rev-parse --show-toplevel` — per-worktree
|
|
12
|
+
* commonGitDir `git rev-parse --git-common-dir` — shared by all worktrees
|
|
13
|
+
* repositoryId sha256(canonical commonGitDir) — stable repo key
|
|
14
|
+
* head `git rev-parse HEAD` — current commit
|
|
15
|
+
*
|
|
16
|
+
* Two worktrees of one repository resolve to the SAME repositoryId (and,
|
|
17
|
+
* when checked out at the same commit, the same head) — the key ingredient
|
|
18
|
+
* for cross-worktree job dedup (issue invariant 5).
|
|
19
|
+
*
|
|
20
|
+
* Non-git directories degrade gracefully: repositoryId falls back to a hash
|
|
21
|
+
* of the resolved directory path (prefixed `dir:`-style via isGit=false), so
|
|
22
|
+
* callers never need a special case.
|
|
23
|
+
*/
|
|
24
|
+
export interface GitWorkspaceIdentity {
|
|
25
|
+
/** Absolute root of this worktree (or the input dir when not a git repo). */
|
|
26
|
+
worktreeRoot: string;
|
|
27
|
+
/** Absolute path of the shared .git directory (equals worktreeRoot/.git for non-worktree clones). */
|
|
28
|
+
commonGitDir: string;
|
|
29
|
+
/** Stable id shared by ALL worktrees of one repository. */
|
|
30
|
+
repositoryId: string;
|
|
31
|
+
/** Current HEAD commit sha ('' when not a git repo or unborn HEAD). */
|
|
32
|
+
head: string;
|
|
33
|
+
/** False when the directory is not inside a git repository. */
|
|
34
|
+
isGit: boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the git workspace identity for a directory. Never throws.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveGitWorkspaceIdentity(dir: string): GitWorkspaceIdentity;
|
|
40
|
+
/** Test hook: clear the per-process identity cache. */
|
|
41
|
+
export declare function resetGitIdentityCacheForTests(): void;
|
|
42
|
+
//# sourceMappingURL=git-workspace-identity.d.ts.map
|