moflo 4.10.7 → 4.10.8

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.
Files changed (32) hide show
  1. package/.claude/guidance/shipped/moflo-cli-reference.md +1 -1
  2. package/.claude/guidance/shipped/moflo-memory-strategy.md +1 -1
  3. package/.claude/guidance/shipped/moflo-yaml-reference.md +4 -4
  4. package/.claude/skills/memory-optimization/SKILL.md +1 -1
  5. package/.claude/skills/memory-patterns/SKILL.md +3 -3
  6. package/.claude/skills/vector-search/SKILL.md +2 -2
  7. package/README.md +5 -5
  8. package/bin/lib/daemon-port.mjs +66 -0
  9. package/dist/src/cli/commands/daemon.js +31 -10
  10. package/dist/src/cli/commands/doctor-checks-config.js +139 -1
  11. package/dist/src/cli/commands/doctor-fixes.js +75 -2
  12. package/dist/src/cli/commands/doctor-registry.js +10 -1
  13. package/dist/src/cli/commands/memory.js +8 -8
  14. package/dist/src/cli/commands/neural.js +8 -6
  15. package/dist/src/cli/config/moflo-config.js +68 -3
  16. package/dist/src/cli/index.js +18 -19
  17. package/dist/src/cli/init/moflo-yaml-template.js +1 -1
  18. package/dist/src/cli/mcp-server.js +59 -10
  19. package/dist/src/cli/mcp-tools/memory-tools.js +46 -27
  20. package/dist/src/cli/memory/auto-memory-bridge.js +1 -1
  21. package/dist/src/cli/memory/controllers/attestation-log.js +1 -1
  22. package/dist/src/cli/memory/controllers/causal-graph.js +1 -1
  23. package/dist/src/cli/memory/daemon-write-client.js +178 -49
  24. package/dist/src/cli/memory/database-provider.js +58 -3
  25. package/dist/src/cli/memory/intelligence.js +54 -26
  26. package/dist/src/cli/memory/memory-initializer.js +21 -11
  27. package/dist/src/cli/services/daemon-dashboard.js +94 -25
  28. package/dist/src/cli/services/daemon-lock.js +390 -3
  29. package/dist/src/cli/services/daemon-port.js +217 -0
  30. package/dist/src/cli/version.js +1 -1
  31. package/package.json +2 -2
  32. package/dist/src/cli/config-adapter.js +0 -182
@@ -16,7 +16,18 @@ import { createServer } from 'node:http';
16
16
  import { errorDetail } from '../shared/utils/error-detail.js';
17
17
  import { handleMemoryStore, handleMemoryDelete, handleMemoryBatch, handleMemoryGet, handleMemorySearch, handleMemoryList, matchMemoryRpcRoute, } from './daemon-memory-rpc.js';
18
18
  import { aggregateClaudeStats, emptyClaudeStatsShape } from './claude-stats.js';
19
- export const DEFAULT_DASHBOARD_PORT = 3117;
19
+ import { serverPortCandidates, LEGACY_DEFAULT_PORT } from './daemon-port.js';
20
+ import { writeLockPort } from './daemon-lock.js';
21
+ import { findProjectRoot } from './project-root.js';
22
+ import { readOwnMofloVersion } from './daemon-lock.js';
23
+ /**
24
+ * Legacy default port retained as a re-export of {@link LEGACY_DEFAULT_PORT}
25
+ * for backward compat with existing importers (`commands/daemon.ts`,
26
+ * `__tests__/daemon-dashboard.test.ts`). The actual port a daemon binds is
27
+ * now resolved deterministically per project via `serverPortCandidates()` —
28
+ * see `daemon-port.ts` and `docs/internal/1145-daemon-port-collision-analysis.md`.
29
+ */
30
+ export const DEFAULT_DASHBOARD_PORT = LEGACY_DEFAULT_PORT;
20
31
  /**
21
32
  * Process-wide promise for the shared MemoryAccessor. Memoized as a *promise*
22
33
  * (not the resolved value) so concurrent first-callers share a single init
@@ -129,6 +140,27 @@ function tryParseSafe(s) {
129
140
  return s;
130
141
  }
131
142
  }
143
+ /**
144
+ * Build the `/api/health` response (#1145).
145
+ *
146
+ * Identity payload — clients compare `projectRoot` against their own
147
+ * `findProjectRoot()` and refuse to route to this daemon on mismatch.
148
+ * Also surfaces `pid`, `version`, and `uptimeMs` for healer-class
149
+ * diagnostics and orphan-daemon detection.
150
+ *
151
+ * Read-only, no-auth, localhost-only (the dashboard binds 127.0.0.1).
152
+ */
153
+ function handleHealth(daemon, opts) {
154
+ const status = daemon.getStatus();
155
+ const startedAt = status.startedAt instanceof Date ? status.startedAt : null;
156
+ return {
157
+ status: 'ok',
158
+ projectRoot: opts.projectRoot ?? findProjectRoot(),
159
+ pid: status.pid ?? process.pid,
160
+ version: readOwnMofloVersion() ?? null,
161
+ uptimeMs: startedAt ? Date.now() - startedAt.getTime() : 0,
162
+ };
163
+ }
132
164
  function handleStatus(daemon) {
133
165
  const status = daemon.getStatus();
134
166
  // Index config rows by worker type so the row renderer can show a
@@ -244,15 +276,18 @@ function tryParse(s) {
244
276
  }
245
277
  }
246
278
  async function handleMemoryStats() {
247
- // Single GROUP BY query — no hardcoded namespace list, no row fetching
248
- try {
249
- const { getNamespaceCounts } = await import('../memory/memory-initializer.js');
250
- const { namespaces, total } = await getNamespaceCounts();
251
- return { namespaces, totalEntries: total, available: total > 0 || Object.keys(namespaces).length > 0 };
252
- }
253
- catch {
254
- return { namespaces: {}, totalEntries: 0, available: false };
255
- }
279
+ // Single GROUP BY query — no hardcoded namespace list, no row fetching.
280
+ // Errors propagate to the request handler's outer try/catch → 500, so
281
+ // MCP clients see a real failure instead of a silent `totalEntries: 0`.
282
+ const { getNamespaceCounts } = await import('../memory/memory-initializer.js');
283
+ const { namespaces, total, withEmbeddings } = await getNamespaceCounts();
284
+ return {
285
+ ok: true,
286
+ namespaces,
287
+ totalEntries: total,
288
+ withEmbeddings,
289
+ available: total > 0 || Object.keys(namespaces).length > 0,
290
+ };
256
291
  }
257
292
  /**
258
293
  * Build the `/api/claude-stats` response (#1044).
@@ -433,6 +468,11 @@ async function handleRequest(req, res, daemon, opts) {
433
468
  if (url === '/') {
434
469
  sendHtml(res, DASHBOARD_HTML);
435
470
  }
471
+ else if (url === '/api/health') {
472
+ // #1145 — identity probe. Clients use this to confirm they're talking
473
+ // to the daemon for their OWN project before routing memory ops here.
474
+ sendJson(res, 200, handleHealth(daemon, opts));
475
+ }
436
476
  else if (url === '/api/status') {
437
477
  sendJson(res, 200, handleStatus(daemon));
438
478
  }
@@ -588,33 +628,62 @@ const MAX_PORT_ATTEMPTS = 10;
588
628
  /**
589
629
  * Start the dashboard HTTP server.
590
630
  *
591
- * Tries the requested port first, then falls back to port+1, port+2, ...
592
- * up to MAX_PORT_ATTEMPTS to avoid crashing the daemon when another
593
- * project's daemon already holds the default port.
631
+ * Port selection (#1145):
632
+ * 1. `opts.port`, if explicitly set (CLI `--dashboard-port` flag).
633
+ * 2. Otherwise `serverPortCandidates(projectRoot)` deterministic per-
634
+ * project port + collision-fallback range.
635
+ * Both honor `MOFLO_DAEMON_PORT` (collapses the candidate list to one).
636
+ *
637
+ * On successful bind the bound port is stamped into `.moflo/daemon.lock`
638
+ * via `writeLockPort()` so clients can discover it without guessing.
594
639
  *
595
- * @param daemon - WorkerDaemon instance for status data
596
- * @param opts - Dashboard configuration
597
- * @returns A handle to stop the server (port reflects the actual bound port)
640
+ * On bind exhaustion (every candidate in use) the server throws — the
641
+ * caller is expected to surface the failure rather than stay half-alive
642
+ * (the silent-trap pattern that produced #1145).
643
+ *
644
+ * @returns handle whose `.port` field reflects the actually bound port
598
645
  */
599
646
  export async function startDashboard(daemon, opts) {
600
- const basePort = opts.port;
601
- for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
602
- const port = basePort + attempt;
647
+ const projectRoot = opts.projectRoot ?? findProjectRoot();
648
+ const candidates = buildBindCandidates(opts.port, projectRoot, MAX_PORT_ATTEMPTS);
649
+ let lastErr = null;
650
+ for (let i = 0; i < candidates.length; i++) {
651
+ const port = candidates[i];
603
652
  try {
604
- const handle = await tryListenOnPort(daemon, opts, port);
653
+ const handle = await tryListenOnPort(daemon, { ...opts, projectRoot }, port);
654
+ // Stamp the bound port into the lock so clients discover us reliably.
655
+ // Best-effort: a missing/locked-by-another-pid lock means stamping
656
+ // is a no-op — the deterministic fallback still works.
657
+ try {
658
+ writeLockPort(projectRoot, handle.port);
659
+ }
660
+ catch { /* ignore */ }
605
661
  return handle;
606
662
  }
607
663
  catch (err) {
664
+ lastErr = err;
608
665
  const code = err && typeof err === 'object' && 'code' in err ? err.code : '';
609
- if (code === 'EADDRINUSE' && attempt < MAX_PORT_ATTEMPTS - 1) {
610
- // Port taken — try the next one
666
+ if (code === 'EADDRINUSE' && i < candidates.length - 1)
611
667
  continue;
612
- }
613
668
  throw err;
614
669
  }
615
670
  }
616
- // Should be unreachable, but satisfies the type checker
617
- throw new Error(`All dashboard ports ${basePort}–${basePort + MAX_PORT_ATTEMPTS - 1} are in use`);
671
+ // Bind exhaustion surface so the daemon can hard-fail (#1145 §9.4).
672
+ throw lastErr ?? new Error(`All dashboard ports (${candidates[0]}…${candidates[candidates.length - 1]}) are in use`);
673
+ }
674
+ /**
675
+ * Build the ordered list of ports to try.
676
+ *
677
+ * When the caller pinned a port (CLI flag), respect it without any
678
+ * fallback — the consumer pinned it on purpose. When they didn't, use
679
+ * the deterministic per-project candidates so two projects never collide
680
+ * silently on a fixed default.
681
+ */
682
+ function buildBindCandidates(explicitPort, projectRoot, maxAttempts) {
683
+ if (typeof explicitPort === 'number' && explicitPort > 0 && explicitPort < 65536) {
684
+ return [explicitPort];
685
+ }
686
+ return serverPortCandidates(projectRoot, maxAttempts);
618
687
  }
619
688
  /**
620
689
  * Attempt to bind the dashboard server to a specific port.
@@ -9,9 +9,11 @@
9
9
  * and verifying the process command line before trusting a "live" PID.
10
10
  */
11
11
  import * as fs from 'fs';
12
- import { dirname, join } from 'path';
12
+ import { dirname, join, sep } from 'path';
13
13
  import { fileURLToPath } from 'url';
14
- import { execSync } from 'child_process';
14
+ import { execFileSync, execSync } from 'child_process';
15
+ import { atomicWriteFileSync } from '../shared/utils/atomic-file-write.js';
16
+ import { normalizeProjectRoot } from './daemon-port.js';
15
17
  const LOCK_FILENAME = 'daemon.lock';
16
18
  const LOCK_LABEL = 'moflo-daemon';
17
19
  /** Resolve the lock file path for a project root. */
@@ -48,6 +50,18 @@ export function readOwnMofloVersion() {
48
50
  /**
49
51
  * Try to acquire the daemon lock atomically.
50
52
  *
53
+ * Before the EEXIST atomic write, runs a same-project orphan scan (#1150):
54
+ * enumerates moflo daemon processes whose command line is rooted at THIS
55
+ * project's CLI binary. If any are found that the lock doesn't account for,
56
+ * they get SIGTERM'd (3s graceful → SIGKILL) before we try to acquire.
57
+ * Catches the failure mode where the lock was unlinked (e.g. by an old
58
+ * doctor-fix or a crashed shutdown handler) but the daemon process is still
59
+ * alive — without this scan, a fresh daemon would spawn alongside it.
60
+ *
61
+ * Tests can opt out of the scan via `MOFLO_TEST_SKIP_ORPHAN_SCAN=1` (the same
62
+ * env-var also disables the post-spawn fallback in #1086 so vitest workers
63
+ * don't pay the 8s Windows introspection cost on every acquire).
64
+ *
51
65
  * @returns `{ acquired: true }` on success,
52
66
  * `{ acquired: false, holder: pid }` if another daemon owns the lock.
53
67
  */
@@ -58,6 +72,14 @@ export function acquireDaemonLock(projectRoot, pid = process.pid) {
58
72
  if (!fs.existsSync(stateDir)) {
59
73
  fs.mkdirSync(stateDir, { recursive: true });
60
74
  }
75
+ // #1150 — same-project orphan scan. Runs BEFORE the atomic write because
76
+ // (a) it lets us reclaim the lock after a crash that left the daemon
77
+ // running but the lock unlinked, and
78
+ // (b) the second-spawn case (lock absent, prior daemon alive) is exactly
79
+ // the failure mode that produced two-daemons-per-project in #1145's
80
+ // waxstack audit.
81
+ const lockHolderPid = readLockPayload(lock)?.pid;
82
+ reapSameProjectOrphans(projectRoot, pid, lockHolderPid);
61
83
  const payload = {
62
84
  pid,
63
85
  startedAt: Date.now(),
@@ -108,6 +130,48 @@ export function releaseDaemonLock(projectRoot, pid = process.pid, force = false)
108
130
  safeUnlink(lock);
109
131
  }
110
132
  }
133
+ /**
134
+ * Stamp the daemon's bound HTTP port into the lock file (#1145).
135
+ *
136
+ * Called by `daemon-dashboard.startDashboard()` after a successful bind so
137
+ * clients can read the actual port (vs. guessing the fixed default and
138
+ * silently hitting another project's daemon).
139
+ *
140
+ * Best-effort by design:
141
+ * - Missing lock → no-op (the daemon didn't acquire the lock; this is
142
+ * a test or unusual startup path).
143
+ * - Lock owned by a different PID → no-op (we don't overwrite locks we
144
+ * don't own).
145
+ * - Write failure → swallowed (the daemon still serves; clients fall
146
+ * back to the deterministic port resolution).
147
+ *
148
+ * Returns `true` on a successful stamp, `false` otherwise. The boolean is
149
+ * informational — production callers don't branch on it.
150
+ */
151
+ export function writeLockPort(projectRoot, port, pid = process.pid) {
152
+ if (!Number.isFinite(port) || port < 1 || port > 65535)
153
+ return false;
154
+ const lock = lockPath(projectRoot);
155
+ const existing = readLockPayload(lock);
156
+ if (!existing)
157
+ return false;
158
+ if (existing.pid !== pid)
159
+ return false;
160
+ if (existing.port === port)
161
+ return true;
162
+ const updated = { ...existing, port };
163
+ try {
164
+ // Atomic write-then-rename: a client reading mid-write never sees a
165
+ // truncated JSON. The vulnerable window is precisely a re-stamp after
166
+ // a daemon recycle on a different port, when clients are likeliest
167
+ // to be probing the lock for the new port.
168
+ atomicWriteFileSync(lock, JSON.stringify(updated));
169
+ return true;
170
+ }
171
+ catch {
172
+ return false;
173
+ }
174
+ }
111
175
  /**
112
176
  * Atomically transfer the daemon lock to a new PID (e.g. parent → child).
113
177
  *
@@ -125,6 +189,9 @@ export function transferDaemonLock(projectRoot, newPid, fromPid = process.pid) {
125
189
  startedAt: Date.now(),
126
190
  label: LOCK_LABEL,
127
191
  version: existing.version ?? readOwnMofloVersion(),
192
+ // Preserve the port field across PID transfers (#1145) — the child
193
+ // process inherits the parent's binding, so the port is still valid.
194
+ ...(existing.port != null ? { port: existing.port } : {}),
128
195
  };
129
196
  try {
130
197
  // Atomic overwrite — no unlink/recreate gap
@@ -214,11 +281,33 @@ function safeUnlink(path) {
214
281
  function isProcessAlive(pid) {
215
282
  try {
216
283
  process.kill(pid, 0);
217
- return true;
218
284
  }
219
285
  catch {
220
286
  return false;
221
287
  }
288
+ // Linux zombie handling: `kill(pid, 0)` succeeds for zombie processes
289
+ // (exited but not yet reaped). A zombie can't write to the DB or hold
290
+ // a lock, so treating it as alive exhausts the kill window polling a
291
+ // corpse. Read /proc/<pid>/stat and treat 'Z' as dead — same logic as
292
+ // bin/lib/daemon-recycler.mjs:51-69. The case surfaces in tests AND
293
+ // in any production path where the daemon and our process share a
294
+ // parent (foreground mode, vitest worker that spawned a child); on
295
+ // standard detached-daemon production paths init reaps so this is a
296
+ // no-op there.
297
+ if (process.platform === 'linux') {
298
+ try {
299
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf-8');
300
+ const lastParen = stat.lastIndexOf(')');
301
+ if (lastParen !== -1 && stat.charAt(lastParen + 2) === 'Z')
302
+ return false;
303
+ }
304
+ catch (err) {
305
+ if (err && err.code === 'ENOENT')
306
+ return false;
307
+ // /proc unavailable — fall through with the kill(0) verdict.
308
+ }
309
+ }
310
+ return true;
222
311
  }
223
312
  /**
224
313
  * Cross-platform check: is this PID actually a moflo/claude-flow daemon?
@@ -293,4 +382,302 @@ function isDaemonProcessUnix(pid) {
293
382
  return true; // fallback
294
383
  }
295
384
  }
385
+ // ---------------------------------------------------------------------------
386
+ // Same-project orphan detection (#1150)
387
+ // ---------------------------------------------------------------------------
388
+ /**
389
+ * Enumerate moflo daemon node processes whose command line is rooted at THIS
390
+ * project's CLI binary (consumer install OR dogfood-source).
391
+ *
392
+ * Returns PIDs. Used by `acquireDaemonLock` (pre-acquire reap) and the
393
+ * `daemon-orphan` doctor check/fix.
394
+ *
395
+ * Matching strategy: cmdline must contain BOTH a moflo daemon marker
396
+ * (`daemon ... start` + `moflo`/`claude-flow`) AND one of the two
397
+ * project-rooted cli.js paths. This keeps daemons for OTHER projects out of
398
+ * scope — they have their own project root and (post-#1145) their own port.
399
+ *
400
+ * Cross-platform:
401
+ * - Windows: `Get-CimInstance Win32_Process` via PowerShell (single shell
402
+ * invocation that returns all node processes with command lines).
403
+ * - Linux: `/proc/<pid>/cmdline` walk.
404
+ * - macOS: `ps -axo pid,command` (no `/proc`).
405
+ *
406
+ * Falls back to `[]` if the platform probe fails — better to spawn an extra
407
+ * daemon than to wrongly kill a foreign-project one.
408
+ */
409
+ export function findProjectDaemonPids(projectRoot, opts = {}) {
410
+ if (process.env.MOFLO_TEST_SKIP_ORPHAN_SCAN === '1')
411
+ return [];
412
+ const candidates = projectCliCandidates(projectRoot);
413
+ if (candidates.length === 0)
414
+ return [];
415
+ let processes;
416
+ if (opts.pidsHint) {
417
+ processes = opts.pidsHint;
418
+ }
419
+ else {
420
+ try {
421
+ if (process.platform === 'win32')
422
+ processes = listMofloDaemonsWindows();
423
+ else if (process.platform === 'linux')
424
+ processes = listMofloDaemonsLinux();
425
+ else
426
+ processes = listMofloDaemonsUnix();
427
+ }
428
+ catch {
429
+ return [];
430
+ }
431
+ }
432
+ return processes.filter(p => cmdlineMatchesProject(p.cmdline, candidates)).map(p => p.pid);
433
+ }
434
+ /**
435
+ * Candidate absolute paths for THIS project's daemon CLI binary.
436
+ *
437
+ * Returns the two layouts moflo ships with — consumer install
438
+ * (`<root>/node_modules/moflo/bin/cli.js`) and dogfood-source
439
+ * (`<root>/bin/cli.js`) — normalised for case-insensitive substring match
440
+ * via the shared `normalizeProjectRoot` helper (which realpaths + lowercases
441
+ * on Windows, matching the #1145 daemon-identity surface so the two checks
442
+ * agree about which root a process belongs to).
443
+ *
444
+ * Never includes the bare `projectRoot` prefix as a match candidate: an
445
+ * unrelated process (editor, npm script) whose cmdline happens to mention
446
+ * the project path would otherwise false-positive once the daemon-marker
447
+ * regex also incidentally matched.
448
+ */
449
+ function projectCliCandidates(projectRoot) {
450
+ const cliRelatives = [
451
+ join('node_modules', 'moflo', 'bin', 'cli.js'),
452
+ join('bin', 'cli.js'),
453
+ ];
454
+ // realpath both the input AND each candidate path — on macOS the
455
+ // command-line records the realpath'd form (`/private/var/folders/...`)
456
+ // while the cwd-rooted candidate stays under `/var/folders/...`.
457
+ const normRoot = normalizeProjectRoot(projectRoot);
458
+ const out = new Set();
459
+ for (const rel of cliRelatives) {
460
+ // Apply normalizeForMatch ON TOP of normalizeProjectRoot so the
461
+ // substring match also tolerates mixed separators in the spawn-recorded
462
+ // cmdline ("\\" vs "/"). `normalizeProjectRoot` realpaths + lowercases
463
+ // on Windows; `normalizeForMatch` collapses slashes.
464
+ out.add(normalizeForMatch(normalizeProjectRoot(join(projectRoot, rel))));
465
+ out.add(normalizeForMatch(normalizeProjectRoot(join(normRoot, rel))));
466
+ }
467
+ return Array.from(out).filter(s => s.length > 0);
468
+ }
469
+ function cmdlineMatchesProject(cmdline, candidates) {
470
+ // Daemon marker — must look like a moflo daemon to even consider matching.
471
+ if (!/daemon[\s\S]{0,40}start/i.test(cmdline))
472
+ return false;
473
+ if (!/moflo|claude-flow/i.test(cmdline))
474
+ return false;
475
+ // Substring match against case-folded, slash-normalised forms.
476
+ const norm = normalizeForMatch(cmdline);
477
+ return candidates.some(c => c.length > 0 && norm.includes(c));
478
+ }
479
+ function normalizeForMatch(p) {
480
+ // Collapse mixed slashes to the OS separator so the substring check works
481
+ // regardless of how spawn quoted the path. Case-fold on Windows.
482
+ const collapsed = p.replace(/[\\/]+/g, sep);
483
+ return process.platform === 'win32' ? collapsed.toLowerCase() : collapsed;
484
+ }
485
+ function listMofloDaemonsWindows() {
486
+ // Use execFileSync so the PS command is passed as a single argument vector
487
+ // (no cmd.exe quote-mangling). The `@($res)` array-cast handles the
488
+ // single-result case (`ConvertTo-Json` emits a bare object otherwise, and
489
+ // `-AsArray` is PS 6+ only). The `if ($res)` guard avoids emitting an
490
+ // empty string that JSON.parse can't read.
491
+ const script = "$res = Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" " +
492
+ "| Select-Object ProcessId, CommandLine; " +
493
+ "if ($res) { @($res) | ConvertTo-Json -Compress -Depth 3 }";
494
+ let raw;
495
+ try {
496
+ raw = execFileSync('powershell', ['-NoProfile', '-Command', script], {
497
+ encoding: 'utf-8',
498
+ timeout: 10000,
499
+ windowsHide: true,
500
+ maxBuffer: 16 * 1024 * 1024,
501
+ });
502
+ }
503
+ catch {
504
+ return [];
505
+ }
506
+ if (!raw.trim())
507
+ return [];
508
+ let parsed;
509
+ try {
510
+ parsed = JSON.parse(raw);
511
+ }
512
+ catch {
513
+ return [];
514
+ }
515
+ if (!Array.isArray(parsed))
516
+ parsed = [parsed];
517
+ return parsed
518
+ .filter((p) => p && typeof p.CommandLine === 'string' && p.CommandLine.length > 0)
519
+ .map((p) => ({ pid: Number(p.ProcessId), cmdline: String(p.CommandLine) }))
520
+ .filter((p) => Number.isFinite(p.pid) && p.pid > 0);
521
+ }
522
+ function listMofloDaemonsLinux() {
523
+ const out = [];
524
+ let entries;
525
+ try {
526
+ entries = fs.readdirSync('/proc');
527
+ }
528
+ catch {
529
+ return [];
530
+ }
531
+ for (const entry of entries) {
532
+ if (!/^\d+$/.test(entry))
533
+ continue;
534
+ const pid = parseInt(entry, 10);
535
+ try {
536
+ // cmdline is NUL-separated argv. Replace NULs with spaces for matching.
537
+ const raw = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf-8');
538
+ if (!raw)
539
+ continue;
540
+ const cmdline = raw.replace(/\0+$/, '').replace(/\0/g, ' ');
541
+ if (!/\bnode\b/i.test(cmdline) && !/\.js\b/.test(cmdline))
542
+ continue;
543
+ out.push({ pid, cmdline });
544
+ }
545
+ catch { /* process exited mid-scan / no perms — skip */ }
546
+ }
547
+ return out;
548
+ }
549
+ function listMofloDaemonsUnix() {
550
+ let raw;
551
+ try {
552
+ // -axww = all processes including session leaders (BSD form portable to
553
+ // macOS/Linux), unlimited line width so long cmdlines don't truncate.
554
+ // execFileSync (no shell) keeps quoting consistent with the rest of the
555
+ // codebase.
556
+ raw = execFileSync('ps', ['-axww', '-o', 'pid=,command='], {
557
+ encoding: 'utf-8',
558
+ timeout: 5000,
559
+ maxBuffer: 16 * 1024 * 1024,
560
+ });
561
+ }
562
+ catch {
563
+ return [];
564
+ }
565
+ const out = [];
566
+ for (const line of raw.split('\n')) {
567
+ const trimmed = line.trim();
568
+ if (!trimmed)
569
+ continue;
570
+ const sepIdx = trimmed.indexOf(' ');
571
+ if (sepIdx === -1)
572
+ continue;
573
+ const pid = parseInt(trimmed.slice(0, sepIdx), 10);
574
+ if (!Number.isFinite(pid) || pid <= 0)
575
+ continue;
576
+ const cmdline = trimmed.slice(sepIdx + 1).trim();
577
+ if (!/\bnode\b/i.test(cmdline) && !/\.js\b/.test(cmdline))
578
+ continue;
579
+ out.push({ pid, cmdline });
580
+ }
581
+ return out;
582
+ }
583
+ /**
584
+ * Same-project orphan reap. Called from `acquireDaemonLock` BEFORE the atomic
585
+ * write. PIDs that match the lock-holder OR our own process are skipped.
586
+ *
587
+ * Best-effort: failures during kill are swallowed because the next step
588
+ * (atomic exclusive write of the lock) is the source of truth — if the
589
+ * orphan survives, the lock-acquire still fails cleanly and the caller
590
+ * reports a stale lock-holder rather than spawning a duplicate.
591
+ *
592
+ * Exported for the `Daemon Orphan` healer fix which reuses the same logic.
593
+ */
594
+ export function reapSameProjectOrphans(projectRoot, ownPid = process.pid, lockHolderPid,
595
+ /**
596
+ * Pre-computed project-daemon PIDs. Skips re-running the OS process scan
597
+ * when the caller already has them — the `Daemon Orphan` doctor-fix
598
+ * computes them once via `findProjectDaemonPids` and then reuses the
599
+ * same list here.
600
+ */
601
+ pidsHint) {
602
+ const reaped = [];
603
+ const survived = [];
604
+ const allPids = pidsHint ?? findProjectDaemonPids(projectRoot);
605
+ const foreignPids = allPids.filter(p => {
606
+ if (p === ownPid)
607
+ return false;
608
+ if (lockHolderPid != null && p === lockHolderPid)
609
+ return false;
610
+ return true;
611
+ });
612
+ if (foreignPids.length === 0)
613
+ return { reaped, survived };
614
+ for (const pid of foreignPids) {
615
+ if (terminateOrphan(pid))
616
+ reaped.push(pid);
617
+ else
618
+ survived.push(pid);
619
+ }
620
+ return { reaped, survived };
621
+ }
622
+ /**
623
+ * Terminate a same-project daemon orphan: SIGTERM → 3s graceful poll →
624
+ * SIGKILL (POSIX) / `taskkill /F /T` (Windows). Returns true once the PID
625
+ * is no longer alive.
626
+ */
627
+ function terminateOrphan(pid) {
628
+ if (!isProcessAlive(pid))
629
+ return true;
630
+ try {
631
+ if (process.platform === 'win32') {
632
+ // No SIGTERM equivalent for our detached Node daemon on Windows — go
633
+ // straight to /F /T (same shape as bin/lib/daemon-recycler.mjs and
634
+ // killBackgroundDaemon). execFileSync keeps args un-shell-quoted.
635
+ try {
636
+ execFileSync('taskkill', ['/F', '/T', '/PID', String(pid)], {
637
+ windowsHide: true,
638
+ timeout: 3000,
639
+ });
640
+ }
641
+ catch { /* already exiting */ }
642
+ }
643
+ else {
644
+ try {
645
+ process.kill(pid, 'SIGTERM');
646
+ }
647
+ catch { /* already dead */ }
648
+ }
649
+ }
650
+ catch { /* fall through to liveness poll */ }
651
+ // Graceful window
652
+ const gracefulDeadline = Date.now() + 3000;
653
+ while (Date.now() < gracefulDeadline && isProcessAlive(pid)) {
654
+ sleepSyncMs(100);
655
+ }
656
+ if (!isProcessAlive(pid))
657
+ return true;
658
+ // Escalate to SIGKILL on POSIX (Windows already used /F)
659
+ if (process.platform !== 'win32') {
660
+ try {
661
+ process.kill(pid, 'SIGKILL');
662
+ }
663
+ catch { /* already dead */ }
664
+ }
665
+ const killDeadline = Date.now() + 1000;
666
+ while (Date.now() < killDeadline && isProcessAlive(pid)) {
667
+ sleepSyncMs(100);
668
+ }
669
+ return !isProcessAlive(pid);
670
+ }
671
+ function sleepSyncMs(ms) {
672
+ try {
673
+ const buf = new Int32Array(new SharedArrayBuffer(4));
674
+ Atomics.wait(buf, 0, 0, ms);
675
+ }
676
+ catch {
677
+ // SharedArrayBuffer disabled (rare — exotic Node flags); fall back to a
678
+ // tight loop. Caller's wait windows are bounded so this is safe.
679
+ const deadline = Date.now() + ms;
680
+ while (Date.now() < deadline) { /* spin */ }
681
+ }
682
+ }
296
683
  //# sourceMappingURL=daemon-lock.js.map