moflo 4.8.2 → 4.8.3

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/bin/hooks.mjs CHANGED
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import { spawn } from 'child_process';
23
- import { existsSync, appendFileSync, readFileSync } from 'fs';
23
+ import { existsSync, appendFileSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'fs';
24
24
  import { resolve, dirname } from 'path';
25
25
  import { fileURLToPath } from 'url';
26
26
 
@@ -306,10 +306,13 @@ async function main() {
306
306
  }
307
307
 
308
308
  case 'daemon-start': {
309
- if (!isDaemonLockHeld()) {
310
- await runClaudeFlow('daemon', ['start', '--quiet']);
311
- } else {
309
+ if (isDaemonLockHeld()) {
312
310
  log('info', 'Daemon already running (lock held), skipping start');
311
+ } else if (isDaemonSpawnRecent()) {
312
+ log('info', 'Daemon spawn debounced (recent attempt), skipping');
313
+ } else {
314
+ touchSpawnStamp();
315
+ await runClaudeFlow('daemon', ['start', '--quiet']);
313
316
  }
314
317
  break;
315
318
  }
@@ -479,17 +482,30 @@ function runBackgroundTraining() {
479
482
  spawnWindowless('node', [localCli, 'neural', 'optimize'], 'neural optimize');
480
483
  }
481
484
 
482
- // Check if daemon lock exists fast pre-check to avoid spawning a Node process
483
- // just to have it bail out via the atomic lock in daemon.ts.
484
- // Uses daemon.lock (atomic wx-based) instead of the old daemon.pid (TOCTOU-vulnerable).
485
+ // Delegate to daemon-lock.js for proper PID + command-line verification.
486
+ // Falls back to a naive kill(0) check if the import fails (e.g. dist not built).
487
+ let _getDaemonLockHolder = null;
488
+ try {
489
+ const daemonLockPath = resolve(__dirname, '..', 'src', '@claude-flow', 'cli', 'dist', 'src', 'services', 'daemon-lock.js');
490
+ if (existsSync(daemonLockPath)) {
491
+ const mod = await import('file://' + daemonLockPath.replace(/\\/g, '/'));
492
+ _getDaemonLockHolder = mod.getDaemonLockHolder;
493
+ }
494
+ } catch { /* fallback below */ }
495
+
485
496
  function isDaemonLockHeld() {
497
+ // Prefer the real daemon-lock module (PID + command-line verification)
498
+ if (_getDaemonLockHolder) {
499
+ return _getDaemonLockHolder(projectRoot) !== null;
500
+ }
501
+
502
+ // Fallback: naive PID check (only if daemon-lock.js unavailable)
486
503
  const lockFile = resolve(projectRoot, '.claude-flow', 'daemon.lock');
487
504
  if (!existsSync(lockFile)) return false;
488
-
489
505
  try {
490
506
  const data = JSON.parse(readFileSync(lockFile, 'utf-8'));
491
507
  if (typeof data.pid === 'number' && data.pid > 0) {
492
- process.kill(data.pid, 0); // check if alive
508
+ process.kill(data.pid, 0);
493
509
  return true;
494
510
  }
495
511
  } catch {
@@ -498,21 +514,52 @@ function isDaemonLockHeld() {
498
514
  return false;
499
515
  }
500
516
 
517
+ // Debounce file — prevents thundering-herd spawns when multiple hooks fire
518
+ // within the same second (e.g. subagents each triggering SessionStart).
519
+ const SPAWN_DEBOUNCE_MS = 30_000;
520
+ const SPAWN_STAMP_FILE = resolve(projectRoot, '.claude-flow', 'daemon-spawn.stamp');
521
+
522
+ function isDaemonSpawnRecent() {
523
+ try {
524
+ if (existsSync(SPAWN_STAMP_FILE)) {
525
+ const age = Date.now() - statSync(SPAWN_STAMP_FILE).mtimeMs;
526
+ return age < SPAWN_DEBOUNCE_MS;
527
+ }
528
+ } catch { /* non-fatal */ }
529
+ return false;
530
+ }
531
+
532
+ function touchSpawnStamp() {
533
+ try {
534
+ const dir = resolve(projectRoot, '.claude-flow');
535
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
536
+ writeFileSync(SPAWN_STAMP_FILE, String(Date.now()));
537
+ } catch { /* non-fatal */ }
538
+ }
539
+
501
540
  // Run daemon start in background (non-blocking) — skip if already running
502
541
  function runDaemonStartBackground() {
503
- // Fast check: if daemon lock is held by a live process, skip spawning entirely.
504
- // This avoids zombie Node processes from subagents that all fire SessionStart.
542
+ // 1. Check if a live daemon already holds the lock
505
543
  if (isDaemonLockHeld()) {
506
544
  log('info', 'Daemon already running (lock held), skipping start');
507
545
  return;
508
546
  }
509
547
 
548
+ // 2. Debounce: skip if we spawned recently (prevents thundering herd)
549
+ if (isDaemonSpawnRecent()) {
550
+ log('info', 'Daemon spawn debounced (recent attempt), skipping');
551
+ return;
552
+ }
553
+
510
554
  const localCli = getLocalCliPath();
511
555
  if (!localCli) {
512
556
  log('warn', 'Local CLI not found, skipping daemon start');
513
557
  return;
514
558
  }
515
559
 
560
+ // 3. Write stamp BEFORE spawning so concurrent callers see it immediately
561
+ touchSpawnStamp();
562
+
516
563
  spawnWindowless('node', [localCli, 'daemon', 'start', '--quiet'], 'daemon');
517
564
  }
518
565
 
@@ -635,6 +635,29 @@ function indexFile(db, filePath, keyPrefix) {
635
635
  }
636
636
  }
637
637
 
638
+ /**
639
+ * Recursively collect all .md files under a directory.
640
+ * Skips node_modules, .git, and other non-content directories.
641
+ */
642
+ function walkMdFiles(dir) {
643
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next']);
644
+ const files = [];
645
+
646
+ function walk(current) {
647
+ if (!existsSync(current)) return;
648
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
649
+ if (entry.isDirectory()) {
650
+ if (!SKIP_DIRS.has(entry.name)) walk(resolve(current, entry.name));
651
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
652
+ files.push(resolve(current, entry.name));
653
+ }
654
+ }
655
+ }
656
+
657
+ walk(dir);
658
+ return files;
659
+ }
660
+
638
661
  function indexDirectory(db, dirConfig) {
639
662
  const dirPath = dirConfig.absolute ? dirConfig.path : resolve(projectRoot, dirConfig.path);
640
663
  const results = [];
@@ -644,13 +667,12 @@ function indexDirectory(db, dirConfig) {
644
667
  return results;
645
668
  }
646
669
 
647
- const allMdFiles = readdirSync(dirPath).filter(f => f.endsWith('.md'));
648
- const files = dirConfig.fileFilter
649
- ? allMdFiles.filter(f => dirConfig.fileFilter.includes(f))
670
+ const allMdFiles = walkMdFiles(dirPath);
671
+ const filtered = dirConfig.fileFilter
672
+ ? allMdFiles.filter(f => dirConfig.fileFilter.includes(basename(f)))
650
673
  : allMdFiles;
651
674
 
652
- for (const file of files) {
653
- const filePath = resolve(dirPath, file);
675
+ for (const filePath of filtered) {
654
676
  const result = indexFile(db, filePath, dirConfig.prefix);
655
677
  results.push(result);
656
678
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.8.2",
3
+ "version": "4.8.3",
4
4
  "description": "MoFlo — AI agent orchestration for Claude Code. Forked from ruflo/claude-flow with patches applied to source, plus feature-level orchestration.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -83,7 +83,7 @@
83
83
  "@types/bcrypt": "^5.0.2",
84
84
  "@types/node": "^20.19.37",
85
85
  "eslint": "^8.0.0",
86
- "moflo": "^4.8.1",
86
+ "moflo": "^4.8.2",
87
87
  "tsx": "^4.21.0",
88
88
  "typescript": "^5.9.3",
89
89
  "vitest": "^4.0.0"
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { output } from '../output.js';
6
6
  import { getDaemon, startDaemon, stopDaemon } from '../services/worker-daemon.js';
7
- import { acquireDaemonLock, releaseDaemonLock, getDaemonLockHolder } from '../services/daemon-lock.js';
7
+ import { acquireDaemonLock, releaseDaemonLock, getDaemonLockHolder, transferDaemonLock } from '../services/daemon-lock.js';
8
8
  import { spawn } from 'child_process';
9
9
  import { fileURLToPath } from 'url';
10
10
  import { dirname, join, resolve } from 'path';
@@ -71,16 +71,16 @@ const startCommand = {
71
71
  }
72
72
  // Foreground mode: run in current process (blocks terminal)
73
73
  try {
74
- // Acquire atomic daemon lock (prevents duplicate daemons)
75
- // Skip lock acquisition if we're the spawned child parent already holds it
76
- if (!isDaemonProcess) {
77
- const lockResult = acquireDaemonLock(projectRoot);
78
- if (!lockResult.acquired) {
79
- if (!quiet) {
80
- output.printWarning(`Daemon already running (PID: ${lockResult.holder})`);
81
- }
82
- return { success: true };
74
+ // Acquire atomic daemon lock (prevents duplicate daemons).
75
+ // Always acquire here even when spawned as a child (CLAUDE_FLOW_DAEMON=1)
76
+ // because on Windows the parent's child.pid is the shell PID (cmd.exe),
77
+ // not the actual node process. The child must write its own real PID.
78
+ const lockResult = acquireDaemonLock(projectRoot);
79
+ if (!lockResult.acquired) {
80
+ if (!quiet) {
81
+ output.printWarning(`Daemon already running (PID: ${lockResult.holder})`);
83
82
  }
83
+ return { success: true };
84
84
  }
85
85
  // Clean up lock file on exit
86
86
  const cleanup = () => {
@@ -256,11 +256,21 @@ async function startBackgroundDaemon(projectRoot, quiet, maxCpuLoad, minFreeMemo
256
256
  child.unref();
257
257
  // Small delay to let the child process fully detach on macOS
258
258
  await new Promise(resolve => setTimeout(resolve, 100));
259
- // Update the lock file with the child's PID (parent acquired it, child owns it)
260
- // We force-release our lock and re-acquire with the child PID so the lock
261
- // accurately reflects the running daemon process.
262
- releaseDaemonLock(resolvedRoot, process.pid, true);
263
- acquireDaemonLock(resolvedRoot, pid);
259
+ // On Windows with shell: true, child.pid is the cmd.exe shell PID, not the
260
+ // actual node daemon. The child will acquire the lock itself with its real PID
261
+ // (see foreground start path). Release the parent's lock so the child can take it.
262
+ //
263
+ // On POSIX (no shell), child.pid IS the real daemon PID, so we can transfer
264
+ // atomically to avoid any gap where the lock is absent.
265
+ if (isWin) {
266
+ releaseDaemonLock(resolvedRoot, process.pid, true);
267
+ }
268
+ else {
269
+ if (!transferDaemonLock(resolvedRoot, pid)) {
270
+ releaseDaemonLock(resolvedRoot, process.pid, true);
271
+ acquireDaemonLock(resolvedRoot, pid);
272
+ }
273
+ }
264
274
  if (!quiet) {
265
275
  output.printSuccess(`Daemon started in background (PID: ${pid})`);
266
276
  output.printInfo(`Logs: ${logFile}`);
@@ -91,20 +91,20 @@ async function checkConfigFile() {
91
91
  }
92
92
  return { name: 'Config File', status: 'warn', message: 'No config file (using defaults)', fix: 'claude-flow config init' };
93
93
  }
94
- // Check daemon status
94
+ // Check daemon status — delegates to daemon-lock module for proper
95
+ // PID + command-line verification (avoids Windows PID-recycling false positives).
95
96
  async function checkDaemonStatus() {
96
97
  try {
98
+ const holderPid = getDaemonLockHolder(process.cwd());
99
+ if (holderPid) {
100
+ return { name: 'Daemon Status', status: 'pass', message: `Running (PID: ${holderPid})` };
101
+ }
102
+ // getDaemonLockHolder auto-cleans stale locks, but check for legacy PID file
97
103
  const lockFile = '.claude-flow/daemon.lock';
98
104
  if (existsSync(lockFile)) {
99
- try {
100
- const data = JSON.parse(readFileSync(lockFile, 'utf8'));
101
- const pid = data.pid;
102
- process.kill(pid, 0); // Check if process exists
103
- return { name: 'Daemon Status', status: 'pass', message: `Running (PID: ${pid})` };
104
- }
105
- catch {
106
- return { name: 'Daemon Status', status: 'warn', message: 'Stale lock file', fix: 'rm .claude-flow/daemon.lock && claude-flow daemon start' };
107
- }
105
+ // Lock exists but holder is null — getDaemonLockHolder already cleaned it,
106
+ // but if it persists it means cleanup failed (permissions, etc.)
107
+ return { name: 'Daemon Status', status: 'warn', message: 'Stale lock file', fix: 'rm .claude-flow/daemon.lock && claude-flow daemon start' };
108
108
  }
109
109
  // Also check legacy PID file
110
110
  const pidFile = '.claude-flow/daemon.pid';
@@ -31,6 +31,13 @@ export declare function acquireDaemonLock(projectRoot: string, pid?: number): {
31
31
  * Release the daemon lock. Only removes if we own it (or force = true).
32
32
  */
33
33
  export declare function releaseDaemonLock(projectRoot: string, pid?: number, force?: boolean): void;
34
+ /**
35
+ * Atomically transfer the daemon lock to a new PID (e.g. parent → child).
36
+ *
37
+ * Overwrites the lock file in-place so there is no window where the lock
38
+ * is absent. Only succeeds if the lock is currently held by `fromPid`.
39
+ */
40
+ export declare function transferDaemonLock(projectRoot: string, newPid: number, fromPid?: number): boolean;
34
41
  /**
35
42
  * Check if the daemon lock is currently held by a live daemon.
36
43
  * Returns the holder PID or null.
@@ -79,6 +79,32 @@ export function releaseDaemonLock(projectRoot, pid = process.pid, force = false)
79
79
  safeUnlink(lock);
80
80
  }
81
81
  }
82
+ /**
83
+ * Atomically transfer the daemon lock to a new PID (e.g. parent → child).
84
+ *
85
+ * Overwrites the lock file in-place so there is no window where the lock
86
+ * is absent. Only succeeds if the lock is currently held by `fromPid`.
87
+ */
88
+ export function transferDaemonLock(projectRoot, newPid, fromPid = process.pid) {
89
+ const lock = lockPath(projectRoot);
90
+ const existing = readLockPayload(lock);
91
+ if (!existing || existing.pid !== fromPid) {
92
+ return false; // We don't own the lock — can't transfer
93
+ }
94
+ const payload = {
95
+ pid: newPid,
96
+ startedAt: Date.now(),
97
+ label: LOCK_LABEL,
98
+ };
99
+ try {
100
+ // Atomic overwrite — no unlink/recreate gap
101
+ fs.writeFileSync(lock, JSON.stringify(payload));
102
+ return true;
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
82
108
  /**
83
109
  * Check if the daemon lock is currently held by a live daemon.
84
110
  * Returns the holder PID or null.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moflo/cli",
3
- "version": "4.8.2",
3
+ "version": "4.8.3",
4
4
  "type": "module",
5
5
  "description": "MoFlo CLI — AI agent orchestration with specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
6
6
  "main": "dist/src/index.js",
@@ -1,5 +0,0 @@
1
- {
2
- "tasksCreated": true,
3
- "taskCount": 1,
4
- "memorySearched": true
5
- }