fullcourtdefense-cli 1.36.4 → 1.37.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.
Files changed (90) hide show
  1. package/dist/askDialog.d.ts +1 -0
  2. package/dist/askDialog.js +20 -3
  3. package/dist/callerEnv.d.ts +29 -2
  4. package/dist/callerEnv.js +48 -2
  5. package/dist/commands/daemon.d.ts +10 -0
  6. package/dist/commands/daemon.js +348 -170
  7. package/dist/commands/desktopChatGuard.js +2 -0
  8. package/dist/commands/discoverPaths.d.ts +12 -9
  9. package/dist/commands/discoverPaths.js +30 -0
  10. package/dist/commands/ephemeralStack.js +7 -0
  11. package/dist/commands/flushSpool.d.ts +2 -0
  12. package/dist/commands/flushSpool.js +9 -2
  13. package/dist/commands/hook.d.ts +14 -0
  14. package/dist/commands/hook.js +302 -101
  15. package/dist/commands/httpGatewayCommand.d.ts +1 -0
  16. package/dist/commands/httpGatewayCommand.js +7 -1
  17. package/dist/commands/installClaudeHook.js +32 -10
  18. package/dist/commands/installCursorHook.js +20 -9
  19. package/dist/commands/login.js +8 -1
  20. package/dist/commands/mcpGateway.d.ts +21 -0
  21. package/dist/commands/mcpGateway.js +90 -32
  22. package/dist/commands/refreshPolicyCache.d.ts +28 -0
  23. package/dist/commands/refreshPolicyCache.js +59 -0
  24. package/dist/commands/watchdog.d.ts +2 -0
  25. package/dist/commands/watchdog.js +30 -5
  26. package/dist/config.d.ts +36 -0
  27. package/dist/config.js +78 -12
  28. package/dist/daemonDetectorBoot.d.ts +1 -0
  29. package/dist/daemonDetectorBoot.js +12 -0
  30. package/dist/daemonForensics.d.ts +14 -1
  31. package/dist/daemonForensics.js +32 -0
  32. package/dist/detectorInProcess.d.ts +18 -0
  33. package/dist/detectorInProcess.js +234 -0
  34. package/dist/detectorInThread.d.ts +15 -0
  35. package/dist/detectorInThread.js +129 -0
  36. package/dist/distress.d.ts +2 -0
  37. package/dist/distress.js +2 -0
  38. package/dist/fileWriteCanon.d.ts +17 -0
  39. package/dist/fileWriteCanon.js +141 -0
  40. package/dist/gateReports.d.ts +144 -0
  41. package/dist/gateReports.js +488 -0
  42. package/dist/hookIo.d.ts +59 -0
  43. package/dist/hookIo.js +319 -9
  44. package/dist/hookSlim.d.ts +15 -8
  45. package/dist/hookSlim.js +187 -25
  46. package/dist/housekeeping.d.ts +64 -0
  47. package/dist/housekeeping.js +384 -0
  48. package/dist/housekeepingOps.d.ts +150 -0
  49. package/dist/housekeepingOps.js +218 -0
  50. package/dist/housekeepingWorker.d.ts +1 -0
  51. package/dist/housekeepingWorker.js +113 -0
  52. package/dist/index.d.ts +1 -1
  53. package/dist/index.js +19 -0
  54. package/dist/localDetectionData.d.ts +10 -0
  55. package/dist/localDetectionData.js +48 -0
  56. package/dist/localDetectionUpdates.d.ts +2 -2
  57. package/dist/localDetectionUpdates.js +4 -4
  58. package/dist/localSafetySnapshot.d.ts +48 -2
  59. package/dist/localSafetySnapshot.js +241 -25
  60. package/dist/machineIdentity.d.ts +7 -0
  61. package/dist/machineIdentity.js +20 -2
  62. package/dist/monitorFastPath.d.ts +191 -0
  63. package/dist/monitorFastPath.js +1198 -0
  64. package/dist/nativeHook.js +9 -2
  65. package/dist/pendingDrain.d.ts +82 -0
  66. package/dist/pendingDrain.js +215 -0
  67. package/dist/pendingDrainer.d.ts +30 -0
  68. package/dist/pendingDrainer.js +217 -0
  69. package/dist/perfSnapshot.d.ts +1 -1
  70. package/dist/perfSnapshot.js +28 -4
  71. package/dist/policyCacheFiles.d.ts +73 -0
  72. package/dist/policyCacheFiles.js +320 -0
  73. package/dist/policyGateHealth.d.ts +8 -0
  74. package/dist/policyGateHealth.js +17 -0
  75. package/dist/policyRefresh.d.ts +28 -0
  76. package/dist/policyRefresh.js +69 -0
  77. package/dist/protectionMaintenance.d.ts +15 -0
  78. package/dist/protectionMaintenance.js +43 -1
  79. package/dist/runtimeConfig.d.ts +77 -20
  80. package/dist/runtimeConfig.js +278 -57
  81. package/dist/selfTest.d.ts +6 -1
  82. package/dist/selfTest.js +11 -4
  83. package/dist/telemetry.d.ts +136 -4
  84. package/dist/telemetry.js +493 -96
  85. package/dist/verdictIpc.d.ts +60 -0
  86. package/dist/verdictIpc.js +504 -19
  87. package/dist/verdictIpcClient.d.ts +175 -18
  88. package/dist/verdictIpcClient.js +277 -59
  89. package/dist/version.json +1 -1
  90. package/package.json +2 -2
@@ -46,6 +46,7 @@ exports.buildTaskXml = buildTaskXml;
46
46
  exports.buildTaskXmlVariants = buildTaskXmlVariants;
47
47
  exports.registerHiddenTask = registerHiddenTask;
48
48
  exports.windowsTaskReferencesScriptHost = windowsTaskReferencesScriptHost;
49
+ exports.ensureWindowsAutostartHealthy = ensureWindowsAutostartHealthy;
49
50
  exports.daemonRuntimeState = daemonRuntimeState;
50
51
  exports.spawnDetachedDaemon = spawnDetachedDaemon;
51
52
  exports.pidIsAlive = pidIsAlive;
@@ -58,6 +59,7 @@ exports.daemonCommand = daemonCommand;
58
59
  const fs = __importStar(require("fs"));
59
60
  const os = __importStar(require("os"));
60
61
  const path = __importStar(require("path"));
62
+ const perf_hooks_1 = require("perf_hooks");
61
63
  const child_process_1 = require("child_process");
62
64
  const config_1 = require("../config");
63
65
  const distress_1 = require("../distress");
@@ -65,12 +67,8 @@ const pollLoop_1 = require("../pollLoop");
65
67
  const selfTest_1 = require("../selfTest");
66
68
  const daemonForensics_1 = require("../daemonForensics");
67
69
  const verdictIpcClient_1 = require("../verdictIpcClient");
68
- const securityAgents_1 = require("../securityAgents");
69
- const mcpGateway_1 = require("./mcpGateway");
70
- const protectionRepair_1 = require("./protectionRepair");
71
70
  const runtimeConfig_1 = require("../runtimeConfig");
72
71
  const discoverPaths_1 = require("./discoverPaths");
73
- const localSafetySnapshot_1 = require("../localSafetySnapshot");
74
72
  const policyRefresh_1 = require("../policyRefresh");
75
73
  const telemetry_1 = require("../telemetry");
76
74
  const identityRecovery_1 = require("../identityRecovery");
@@ -81,18 +79,23 @@ const machineIdentity_1 = require("../machineIdentity");
81
79
  const localDetectionUpdates_1 = require("../localDetectionUpdates");
82
80
  const discoveryMarker_1 = require("../discoveryMarker");
83
81
  const selfUpdate_1 = require("../selfUpdate");
84
- const cmdGuard_1 = require("./cmdGuard");
85
82
  const machineActionVerify_1 = require("../machineActionVerify");
86
83
  const perfSnapshot_1 = require("../perfSnapshot");
87
84
  const desktopChatGuard_1 = require("./desktopChatGuard");
88
85
  const honeypot_1 = require("../honeypot");
89
- const msiProductCode_1 = require("../msiProductCode");
90
86
  const windowsAudit_1 = require("./windowsAudit");
91
87
  const hook_1 = require("./hook");
88
+ // hook.ts loads the ask envelope lazily (one-shot hooks rarely ask). The
89
+ // resident daemon loads it at boot, as it always did: an in-place npm upgrade
90
+ // must not hand a long-running daemon a newer copy at its first ask.
91
+ require("../askDialog");
92
92
  const verdictIpc_1 = require("../verdictIpc");
93
93
  const windowsTaskState_1 = require("../windowsTaskState");
94
94
  Object.defineProperty(exports, "windowsTaskRunsInteractive", { enumerable: true, get: function () { return windowsTaskState_1.windowsTaskRunsInteractive; } });
95
95
  Object.defineProperty(exports, "windowsTaskRunsVisibly", { enumerable: true, get: function () { return windowsTaskState_1.windowsTaskRunsVisibly; } });
96
+ const housekeeping_1 = require("../housekeeping");
97
+ const hookIo_1 = require("../hookIo");
98
+ const detectorInThread_1 = require("../detectorInThread");
96
99
  const COLOR = {
97
100
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
98
101
  red: '\x1b[31m', yellow: '\x1b[33m', green: '\x1b[32m', cyan: '\x1b[36m', gray: '\x1b[90m',
@@ -457,13 +460,32 @@ function releasePidLock() {
457
460
  }
458
461
  catch { /* best-effort */ }
459
462
  }
460
- /** Cursor/Claude hook config files worth watching for tamper (deletion/rewrite). */
461
- function hookConfigFiles() {
462
- const home = os.homedir();
463
- return [
464
- path.join(home, '.cursor', 'hooks.json'),
465
- path.join(home, '.claude', 'settings.json'),
466
- ].filter(file => fs.existsSync(file));
463
+ /**
464
+ * Task Scheduler starts the daemon at its default priority 7: BelowNormal CPU
465
+ * and Low I/O. Under load the one process every hook waits on was preempted
466
+ * first, by the very hooks and fallbacks waiting for it. Windows only, and
467
+ * only upward to Normal; the task definitions themselves are not touched.
468
+ */
469
+ function raiseDaemonPriority() {
470
+ let launched;
471
+ try {
472
+ launched = os.getPriority();
473
+ }
474
+ catch {
475
+ return undefined;
476
+ }
477
+ if (process.platform !== 'win32' || process.env.FCD_DAEMON_PRIORITY === 'inherit')
478
+ return launched;
479
+ if (launched <= os.constants.priority.PRIORITY_NORMAL)
480
+ return launched;
481
+ try {
482
+ os.setPriority(os.constants.priority.PRIORITY_NORMAL);
483
+ log(`Daemon CPU priority raised to normal (was ${launched >= os.constants.priority.PRIORITY_LOW ? 'idle' : 'below normal'}) so hook verdicts are not starved under load.`);
484
+ }
485
+ catch (error) {
486
+ log(`Could not raise the daemon CPU priority (${error.message}) — continuing at the inherited priority.`);
487
+ }
488
+ return launched;
467
489
  }
468
490
  // ---------------------------------------------------------------------------
469
491
  // The resident loop
@@ -492,6 +514,19 @@ async function runDaemon(args, config) {
492
514
  console.log(`${COLOR.yellow}Another FullCourtDefense daemon is already running (pid file: ${pidFile()}).${COLOR.reset}`);
493
515
  return;
494
516
  }
517
+ // This event loop serves verdicts (verdict IPC, desktop chat guard): a
518
+ // policy refresh one of them requests (a hook pinned to another shield, an
519
+ // entry overdue after sleep) runs as an async task here, never as a spawned
520
+ // process — CreateProcess would stall every verdict in flight with it.
521
+ (0, runtimeConfig_1.refreshPolicyCachesInThisProcess)();
522
+ const launchPriority = raiseDaemonPriority();
523
+ // Housekeeping (protect-all, integrity walks, heartbeat diagnostics, DPAPI
524
+ // recovery, task self-heal) runs in a worker process so it never holds the
525
+ // event loop that answers hooks, at the priority this daemon was launched
526
+ // with: what all of it ran at before. Created before the crash handlers that stop it.
527
+ const housekeeping = new housekeeping_1.HousekeepingClient({ cliEntry: cliEntry(), log, priority: launchPriority });
528
+ // hook.log appends from in-daemon evaluations leave the verdict loop too.
529
+ (0, hookIo_1.useAsyncHookLog)();
495
530
  // The resident loop must never die silently from one bad tick (fs watcher
496
531
  // callback, fetch, timer). Log and keep running — the loop is timer-driven,
497
532
  // so surviving a failed tick is safe, and on Windows there is no supervisor
@@ -515,6 +550,8 @@ async function runDaemon(args, config) {
515
550
  // misreport this as an external kill (EDR/AV) — the distress code above
516
551
  // already carries the true story.
517
552
  (0, daemonForensics_1.recordCleanExit)('uncaught-exception burst — self-recycled');
553
+ housekeeping.stop();
554
+ (0, hookIo_1.flushHookLogSync)();
518
555
  releasePidLock();
519
556
  process.exit(1);
520
557
  }
@@ -531,17 +568,64 @@ async function runDaemon(args, config) {
531
568
  const quiet = args.quiet === 'true';
532
569
  log(`Daemon started (pid ${process.pid}, platform ${process.platform}).`);
533
570
  log(`Shield: ${creds.shieldId || '(none — protect-only mode, telemetry disabled)'} API: ${creds.apiUrl}`);
571
+ // Settle the machine id before anything reports: the only place a pinned
572
+ // but unconfirmed fallback id may be re-checked against the OS (#1853).
573
+ // Verdict evaluations report too, so this precedes the verdict server.
574
+ try {
575
+ const id = (0, machineIdentity_1.reconcileMachineIdentity)('heal');
576
+ log(`Machine id ${(0, machineIdentity_1.shortMachineId)(id.machineId)} (${id.source}${id.confirmed ? ', backend-confirmed' : ''}${id.probed ? `, native read ${id.probeError ? `failed: ${id.probeError}` : 'ok'}` : ''}).`
577
+ + (id.healed ? ` Healed from stale fallback id ${(0, machineIdentity_1.shortMachineId)(id.healed.from)}.` : '')
578
+ + (id.conflict ? ` Native id ${(0, machineIdentity_1.shortMachineId)(id.conflict.native)} disagrees with the confirmed id — kept the confirmed one (machine_id_conflict).` : ''));
579
+ }
580
+ catch (error) {
581
+ log(`Machine id reconcile failed: ${error instanceof Error ? error.message : String(error)}`);
582
+ }
583
+ // Verdict IPC server (resident-agent pattern): per-event hook processes
584
+ // become thin clients that ask THIS process for the verdict over a named
585
+ // pipe / Unix socket — everything (config, bundle, snapshot, policy engine)
586
+ // is warm here, so verdicts resolve in milliseconds instead of repeating
587
+ // cold-start work per IDE event. Config is re-read per request (small YAML,
588
+ // cheap) so on-disk edits apply without a daemon restart. Hooks fall back
589
+ // to full local evaluation whenever this server is down — never a blocker.
590
+ //
591
+ // Started FIRST, serving from the on-disk policy cache: boot self-heal, the
592
+ // bundle poll, the startup protection pass and the first heartbeat used to
593
+ // run before it, leaving every hook on the cold local path for 10-190s after
594
+ // each daemon start. None of them is needed to answer a hook, and all of the
595
+ // synchronous ones now run in the housekeeping worker.
596
+ let verdictServer;
597
+ // Evaluations served here only append to the spool. A debounced timer
598
+ // (never a verdict) hands the upload — spool read and trim, queued native
599
+ // allow reports, the POST, the guard-rule refresh — to the housekeeping
600
+ // worker, so neither the work nor a process spawn lands on this loop. In
601
+ // the degraded in-process housekeeping mode a detached flusher does it.
602
+ (0, telemetry_1.serveSpoolFlushOffLoop)(() => Boolean(creds.shieldId), {
603
+ upload: ({ refreshGuardRules }) => (housekeeping.offLoop
604
+ ? housekeeping.run({
605
+ op: 'spoolFlush',
606
+ input: { apiUrl: creds.apiUrl, shieldId: creds.shieldId || '', shieldKey: creds.shieldKey, heartbeat: false, refreshGuardRules },
607
+ })
608
+ : undefined),
609
+ });
610
+ try {
611
+ verdictServer = (0, verdictIpc_1.startVerdictServer)((hookArgs, stdin) => (0, hook_1.evaluateHookRequest)(hookArgs, stdin, (0, config_1.loadConfig)(), { viaDaemon: true }), log);
612
+ }
613
+ catch (error) {
614
+ log(`Verdict IPC: failed to start (${error.message}) — hooks will evaluate locally.`);
615
+ }
616
+ // How long the verdict loop was held, reported with each heartbeat.
617
+ // Sampled ten times a second: cheap enough to leave on for the daemon's life.
618
+ const loopDelay = (0, perf_hooks_1.monitorEventLoopDelay)({ resolution: envMs('FCD_DAEMON_LOOP_SAMPLE_MS', 100) });
619
+ loopDelay.enable();
534
620
  if (process.platform === 'win32') {
535
621
  // `reg query /s` over the uninstall hive can take seconds; keep it off the startup path.
536
622
  setTimeout(() => {
537
- try {
538
- const productCode = (0, msiProductCode_1.findMsiProductCode)();
539
- if ((0, msiProductCode_1.recordMsiProductCode)(productCode))
623
+ housekeeping.run({ op: 'msiProductCode' }).then(({ productCode, recorded }) => {
624
+ if (recorded)
540
625
  log(`Recorded installed MSI ProductCode ${productCode} for the Local Safety uninstall rule.`);
541
- }
542
- catch (error) {
626
+ }, error => {
543
627
  log(`Could not record the installed MSI ProductCode: ${error?.message || String(error)}`);
544
- }
628
+ });
545
629
  }, 20_000).unref();
546
630
  }
547
631
  // A machine enrolled with a DPAPI key whose decrypt fails (blocked
@@ -652,7 +736,12 @@ async function runDaemon(args, config) {
652
736
  * Now a changed enrollment file reloads credentials whether or not the
653
737
  * daemon had some — one heartbeat later it is on the new key.
654
738
  */
655
- const recoverCredentialsIfMissing = () => {
739
+ // The DPAPI decrypt behind a recovery spawns PowerShell: it runs in the
740
+ // housekeeping worker, whose view of PowerShell and of the key source then
741
+ // describes this daemon's credentials in the heartbeat.
742
+ let recoveredPowershell;
743
+ let recoveredTrace;
744
+ const recoverCredentialsIfMissing = async () => {
656
745
  let enrollmentChanged = false;
657
746
  try {
658
747
  const mtime = fs.statSync((0, config_1.getHomeConfigPath)()).mtimeMs;
@@ -670,11 +759,16 @@ async function runDaemon(args, config) {
670
759
  return;
671
760
  }
672
761
  try {
673
- const fresh = (0, config_1.resolveCliCredentials)((0, config_1.loadConfig)(args.config), {
674
- shieldId: args.shieldId,
675
- shieldKey: args.shieldKey,
676
- apiUrl: args.apiUrl,
762
+ const recovered = await housekeeping.run({
763
+ op: 'recoverCredentials',
764
+ configPath: args.config,
765
+ overrides: { shieldId: args.shieldId, shieldKey: args.shieldKey, apiUrl: args.apiUrl },
677
766
  });
767
+ if (recovered.powershell)
768
+ recoveredPowershell = recovered.powershell;
769
+ if (recovered.trace)
770
+ recoveredTrace = recovered.trace;
771
+ const fresh = recovered.creds;
678
772
  if (!fresh.shieldId || !fresh.shieldKey)
679
773
  return;
680
774
  const rotated = fresh.shieldId !== creds.shieldId || fresh.shieldKey !== creds.shieldKey || fresh.apiUrl !== creds.apiUrl;
@@ -733,23 +827,28 @@ async function runDaemon(args, config) {
733
827
  attentionTimer = undefined;
734
828
  if (stopped || suspended)
735
829
  return;
736
- let verified = false;
737
- try {
738
- verified = !repairFailed && (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true }).ok;
739
- }
740
- catch { /* cannot verify */ }
741
- deliverProtectionHealth(verified);
830
+ void (async () => {
831
+ let verified = false;
832
+ try {
833
+ verified = !repairFailed && (await housekeeping.run({ op: 'integrity', requireDaemon: true })).ok;
834
+ }
835
+ catch { /* cannot verify */ }
836
+ if (stopped || suspended)
837
+ return;
838
+ deliverProtectionHealth(verified);
839
+ })();
742
840
  }, PROTECTION_NOTICE_DELAY_MS);
743
841
  }
744
842
  };
745
- const inspectProtection = () => {
843
+ const inspectProtection = async (pass) => {
746
844
  // Snapshot BEFORE verification. An external write during/after inspection
747
845
  // remains different from this baseline and its queued event gets checked.
748
- const snapshot = (0, protectionMaintenance_1.captureProtectionConfiguration)(watchedFiles);
749
- const integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
750
- observedConfigurations = snapshot;
846
+ const { snapshot, integrity } = await housekeeping.run({ op: 'inspect', files: [...watchedFiles], pass });
847
+ observedConfigurations = new Map(snapshot);
751
848
  return integrity;
752
849
  };
850
+ // One scan-root walk serves every step of a protection pass (see setProjectConfigScanPass).
851
+ let passSeq = 0;
753
852
  /** One serialized repair path, including remote requests and new discovery. */
754
853
  const reprotect = async (reasonPaths, cause = 'drift') => {
755
854
  if (repairInFlight) {
@@ -767,8 +866,18 @@ async function runDaemon(args, config) {
767
866
  return;
768
867
  }
769
868
  repairInFlight = (async () => {
869
+ const pass = `repair-${++passSeq}`;
770
870
  try {
771
- const before = inspectProtection();
871
+ const before = await inspectProtection(pass);
872
+ // The inspection ran in the housekeeping worker; an admin suspension
873
+ // (or shutdown) may have arrived meanwhile. A suspended machine is not
874
+ // re-protected, whenever the pass was asked for.
875
+ if (stopped)
876
+ return before;
877
+ if (suspended) {
878
+ log(`${headline} (${reasonPaths.join(', ')}) but machine is suspended — not re-protecting.`);
879
+ return before;
880
+ }
772
881
  // Integrity/discovery triggers use the shared healable taxonomy. A real
773
882
  // content change still runs the full idempotent repair pipeline: the
774
883
  // integrity summary does not cover every hook migration/companion.
@@ -784,9 +893,9 @@ async function runDaemon(args, config) {
784
893
  // after our write, including when its watch event is lost. Persistent
785
894
  // failures still enter the existing backoff, never a repair spin loop.
786
895
  for (let attempt = 1; attempt <= 2; attempt++) {
787
- const repaired = await (0, protectionRepair_1.repairProtection)({ ...args, dryRun: undefined }, config);
788
- refreshWatchTargets();
789
- integrity = inspectProtection();
896
+ const repaired = await housekeeping.run({ op: 'repair', args: { ...args, dryRun: undefined }, config, pass });
897
+ await refreshWatchTargets(pass);
898
+ integrity = await inspectProtection(pass);
790
899
  log(`Re-protection pass complete (Cursor hooks ${repaired.cursorHooksHealthy ? 'healthy' : 'unhealthy'}, Claude hooks ${repaired.claudeHooksHealthy ? 'healthy' : 'unhealthy'}).`);
791
900
  if (!(0, integrity_1.hasHealableIntegrityReason)(integrity.reasons) || suspended || stopped)
792
901
  break;
@@ -821,26 +930,78 @@ async function runDaemon(args, config) {
821
930
  repairInFlight = undefined;
822
931
  }
823
932
  };
824
- const scheduleProtectionCheck = () => {
933
+ /**
934
+ * The drift check's fingerprints, taken in the housekeeping worker. If the
935
+ * worker cannot answer (wedged past its ceiling, lost twice), they are taken
936
+ * here, as before the worker existed: a few file hashes, never a walk.
937
+ */
938
+ const captureWatchedFiles = async () => {
939
+ const files = [...watchedFiles];
940
+ try {
941
+ return new Map((await housekeeping.run({ op: 'capture', files })).snapshot);
942
+ }
943
+ catch (error) {
944
+ if (stopped)
945
+ throw error;
946
+ log(`Protection change check: the housekeeping worker could not capture (${error?.message || String(error)}) — capturing in the daemon.`);
947
+ return (0, protectionMaintenance_1.captureProtectionConfiguration)(files);
948
+ }
949
+ };
950
+ /** Consecutive failed checks; a failed check is retried with back-off, never dropped. */
951
+ let checkFailures = 0;
952
+ const scheduleProtectionCheck = (delayMs = DEBOUNCE_MS) => {
825
953
  if (debounceTimer)
826
954
  clearTimeout(debounceTimer);
827
955
  debounceTimer = setTimeout(() => {
828
956
  debounceTimer = null;
829
957
  void (async () => {
830
- if (repairInFlight)
831
- await repairInFlight;
832
- if (stopped)
958
+ // The capture runs in the housekeeping worker, so a repair pass can
959
+ // start and observe the files while it is in flight. Comparing such a
960
+ // stale capture with the newer observation reads the pass's own writes
961
+ // as drift: capture again after that pass. A check is never dropped —
962
+ // after a few overtaken captures the last one is compared anyway (at
963
+ // worst one extra, idempotent pass).
964
+ for (let attempt = 1;; attempt++) {
965
+ // How that pass ended is its caller's to report; this check still runs.
966
+ if (repairInFlight)
967
+ await repairInFlight.catch(() => undefined);
968
+ if (stopped)
969
+ return;
970
+ if (refreshPending) {
971
+ refreshPending = false;
972
+ // A failed scan keeps the refresh pending for the retry below. A
973
+ // check's scan is protection convergence: it runs ahead of
974
+ // heartbeat walks and rescans in the worker.
975
+ try {
976
+ await refreshWatchTargets(`check-${++passSeq}`);
977
+ }
978
+ catch (error) {
979
+ refreshPending = true;
980
+ throw error;
981
+ }
982
+ }
983
+ const observed = observedConfigurations;
984
+ const current = await captureWatchedFiles();
985
+ if (stopped)
986
+ return;
987
+ if ((observed !== observedConfigurations || repairInFlight) && attempt < 3)
988
+ continue;
989
+ const changed = (0, protectionMaintenance_1.changedProtectionConfiguration)(observedConfigurations, current);
990
+ if (changed.length > 0)
991
+ await reprotect(changed);
833
992
  return;
834
- if (refreshPending) {
835
- refreshPending = false;
836
- refreshWatchTargets();
837
993
  }
838
- const current = (0, protectionMaintenance_1.captureProtectionConfiguration)(watchedFiles);
839
- const changed = (0, protectionMaintenance_1.changedProtectionConfiguration)(observedConfigurations, current);
840
- if (changed.length > 0)
841
- await reprotect(changed);
842
- })().catch(error => log(`Protection change check failed: ${error.message}`));
843
- }, DEBOUNCE_MS);
994
+ })().then(() => { checkFailures = 0; }, error => {
995
+ if (stopped)
996
+ return;
997
+ // The change that triggered this check emits no second event: retry it.
998
+ const retryMs = Math.min(60_000, DEBOUNCE_MS * 2 ** Math.min(checkFailures, 5));
999
+ checkFailures += 1;
1000
+ log(`Protection change check failed: ${error?.message || String(error)} — checking again in ${(retryMs / 1000).toFixed(1)}s.`);
1001
+ if (!debounceTimer)
1002
+ scheduleProtectionCheck(retryMs);
1003
+ });
1004
+ }, delayMs);
844
1005
  };
845
1006
  const onFsEvent = (dir, filename) => {
846
1007
  if (stopped)
@@ -872,13 +1033,13 @@ async function runDaemon(args, config) {
872
1033
  /**
873
1034
  * (Re)build the watch set from the current MCP client configs + hook files.
874
1035
  * Watches parent directories (not files) so atomic replaces on Windows and
875
- * editor save-via-rename still emit events.
1036
+ * editor save-via-rename still emit events. The scan-root walk that finds
1037
+ * the files runs in the housekeeping worker; only the watchers live here.
876
1038
  */
877
- const refreshWatchTargets = () => {
878
- const targets = [
879
- ...(0, mcpGateway_1.protectAllTargetFiles)(args.config).map(f => f.path),
880
- ...hookConfigFiles(),
881
- ];
1039
+ const refreshWatchTargets = async (pass) => {
1040
+ const { targets } = await housekeeping.run({ op: 'scanTargets', configPath: args.config, pass });
1041
+ if (stopped)
1042
+ return targets.length;
882
1043
  watchedFiles.clear();
883
1044
  watchedFileKeys.clear();
884
1045
  const wantedDirs = new Set();
@@ -933,7 +1094,7 @@ async function runDaemon(args, config) {
933
1094
  if (!creds.shieldId)
934
1095
  return;
935
1096
  try {
936
- const raw = fs.readFileSync(logFile(), 'utf8');
1097
+ const raw = await fs.promises.readFile(logFile(), 'utf8');
937
1098
  // 200 lines (~half a day of routine ticks): enough context to diagnose a
938
1099
  // failure loop remotely without asking the customer to paste anything.
939
1100
  const lines = raw.split(/\r?\n/).filter(line => line.trim()).slice(-200);
@@ -986,18 +1147,18 @@ async function runDaemon(args, config) {
986
1147
  // The complete remote support bundle: everything we asked Alex and Alin to
987
1148
  // paste by hand during the lptx1110 incident, collected in one action.
988
1149
  // Metadata + our own logs only — never customer files, prompts, or secrets.
989
- const buildSupportBundle = () => {
990
- const powershell = (0, config_1.getPowershellHealth)();
1150
+ const buildSupportBundle = async () => {
1151
+ const powershell = recoveredPowershell ?? (0, config_1.getPowershellHealth)();
991
1152
  let integrity;
992
1153
  try {
993
- integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
1154
+ integrity = await housekeeping.run({ op: 'integrity', requireDaemon: true });
994
1155
  }
995
1156
  catch (error) {
996
1157
  integrity = { error: error.message };
997
1158
  }
998
1159
  let daemonLogTail = [];
999
1160
  try {
1000
- daemonLogTail = fs.readFileSync(logFile(), 'utf8')
1161
+ daemonLogTail = (await fs.promises.readFile(logFile(), 'utf8'))
1001
1162
  .split(/\r?\n/).map(line => line.trim()).filter(Boolean).slice(-200).map(line => line.slice(0, 400));
1002
1163
  }
1003
1164
  catch { /* log unreadable — the bundle reports everything else */ }
@@ -1006,10 +1167,15 @@ async function runDaemon(args, config) {
1006
1167
  // content previews exist only when support opted into FCD_HOOK_DEBUG.
1007
1168
  let hookLogTail = [];
1008
1169
  try {
1009
- hookLogTail = fs.readFileSync(path.join(os.homedir(), '.fullcourtdefense-hook.log'), 'utf8')
1170
+ hookLogTail = (await fs.promises.readFile(path.join(os.homedir(), '.fullcourtdefense-hook.log'), 'utf8'))
1010
1171
  .split(/\r?\n/).map(line => line.trim()).filter(Boolean).slice(-80).map(line => line.slice(0, 400));
1011
1172
  }
1012
1173
  catch { /* no hook activity on this machine yet */ }
1174
+ let securityAgents;
1175
+ try {
1176
+ securityAgents = (await housekeeping.run({ op: 'securityAgents' })).products;
1177
+ }
1178
+ catch { /* reported without it */ }
1013
1179
  return {
1014
1180
  cliVersion: cliVersion(),
1015
1181
  platform: process.platform,
@@ -1018,9 +1184,9 @@ async function runDaemon(args, config) {
1018
1184
  hookLogTail,
1019
1185
  updaterLogTail: (0, selfUpdate_1.readUpdaterLogTail)(60),
1020
1186
  distress: (0, distress_1.readDistressLedger)(),
1021
- credentialTrace: (0, config_1.getCredentialResolutionTrace)(),
1187
+ credentialTrace: recoveredTrace ?? (0, config_1.getCredentialResolutionTrace)(),
1022
1188
  powershell,
1023
- securityAgents: (0, securityAgents_1.getSecurityAgentsReport)()?.products,
1189
+ securityAgents,
1024
1190
  integrity,
1025
1191
  lastCrash: (0, daemonForensics_1.readPostmortem)(),
1026
1192
  watchdogTaskInstalled: isWatchdogTaskInstalled(),
@@ -1178,13 +1344,15 @@ async function runDaemon(args, config) {
1178
1344
  log('Health check: running deep self-test across all subsystems…');
1179
1345
  await uploadLogTail();
1180
1346
  const identity = (0, machineIdentity_1.getMachineIdentity)();
1181
- const report = await (0, selfTest_1.runDeepSelfTest)({
1347
+ // PowerShell and DPAPI probes, schtasks queries and an integrity walk:
1348
+ // seconds of synchronous work, run in the housekeeping worker.
1349
+ const report = await housekeeping.run({
1350
+ op: 'selfTest',
1182
1351
  creds,
1183
1352
  developerName: identity.developerName,
1184
1353
  machineName: identity.hostname,
1185
1354
  machineId: identity.machineId,
1186
- isWatchdogTaskInstalled,
1187
- log,
1355
+ credentialTrace: recoveredTrace ?? (0, config_1.getCredentialResolutionTrace)(),
1188
1356
  });
1189
1357
  await uploadDiagnostics({ selfTest: report });
1190
1358
  resultSummary = (0, selfTest_1.summarizeSelfTest)(report);
@@ -1195,7 +1363,7 @@ async function runDaemon(args, config) {
1195
1363
  else if (action.type === 'collect_diagnostics') {
1196
1364
  log('Collect diagnostics: assembling support bundle (logs + distress ledger + environment — metadata only)…');
1197
1365
  await uploadLogTail();
1198
- const bundle = buildSupportBundle();
1366
+ const bundle = await buildSupportBundle();
1199
1367
  const uploaded = await uploadDiagnostics({ bundle });
1200
1368
  if (!uploaded)
1201
1369
  throw new Error('Support bundle could not be uploaded (network or backend rejection).');
@@ -1234,7 +1402,7 @@ async function runDaemon(args, config) {
1234
1402
  await uploadLogTail();
1235
1403
  await reprotect(['remote repair request'], 'manual');
1236
1404
  log('Repair protection: verifying hooks, gateways and daemon integrity…');
1237
- const verification = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
1405
+ const verification = await housekeeping.run({ op: 'integrity', requireDaemon: true });
1238
1406
  if (!verification.ok) {
1239
1407
  throw new Error(`Repair completed but verification still reports: ${verification.reasons.join(', ')}`);
1240
1408
  }
@@ -1378,16 +1546,20 @@ async function runDaemon(args, config) {
1378
1546
  }
1379
1547
  const identity = (0, machineIdentity_1.getMachineIdentity)();
1380
1548
  try {
1381
- const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({
1549
+ // The hooks' Local Safety snapshot rides the same refresh: fetched
1550
+ // unless the one on disk already is the bundle's policy hash, written
1551
+ // before the new bundle (hooks never fetch either cache — this poll is
1552
+ // what keeps them fresh). `scheduled` records the attempt, so hot paths
1553
+ // start no refresher of their own for this shield while it runs.
1554
+ const bundle = await (0, policyRefresh_1.refreshPolicyCaches)({
1382
1555
  apiUrl: creds.apiUrl,
1383
1556
  shieldId: creds.shieldId,
1384
1557
  shieldKey: creds.shieldKey,
1385
1558
  developerName: identity.developerName,
1386
1559
  machineName: identity.hostname,
1387
1560
  machineId: identity.machineId,
1388
- force: true,
1389
1561
  timeoutMs: BUNDLE_FETCH_TIMEOUT_MS,
1390
- requireServerValidation: true,
1562
+ scheduled: true,
1391
1563
  });
1392
1564
  if (typeof bundle.pollIntervalMs === 'number' && Number.isFinite(bundle.pollIntervalMs)) {
1393
1565
  bundlePollBaseMs = Math.min(Math.max(bundle.pollIntervalMs, 15_000), 60 * 60_000);
@@ -1469,22 +1641,6 @@ async function runDaemon(args, config) {
1469
1641
  log(`Honeypot: removal failed: ${error.message}`);
1470
1642
  }
1471
1643
  }
1472
- // Keep the IDE hooks' Local Safety snapshot warm. Hooks on monitor
1473
- // machines read this cache with preferCached and never fetch — the
1474
- // daemon is the only process paying the network cost, off the hot path.
1475
- try {
1476
- const hookIdentity = (0, localSafetySnapshot_1.hookSnapshotIdentity)();
1477
- await (0, localSafetySnapshot_1.loadLocalSafetySnapshot)({
1478
- apiUrl: creds.apiUrl,
1479
- shieldId: creds.shieldId,
1480
- shieldKey: creds.shieldKey,
1481
- developerName: hookIdentity.developerName,
1482
- machineName: hookIdentity.machineName,
1483
- expectedPolicyHash: bundle.policyHash || bundle.version,
1484
- timeoutMs: 8_000,
1485
- });
1486
- }
1487
- catch { /* hooks fall back to their own bounded fetch */ }
1488
1644
  }
1489
1645
  catch (error) {
1490
1646
  // Local enforcement can keep using its cache, but this delivery check
@@ -1493,9 +1649,22 @@ async function runDaemon(args, config) {
1493
1649
  throw error;
1494
1650
  }
1495
1651
  };
1652
+ /** Verdict-loop health for this heartbeat window: the server's own counters plus how long the loop was held. */
1653
+ const verdictIpcHealth = () => {
1654
+ const delay = loopDelay.count > 0
1655
+ ? { eventLoopDelayP99Ms: Math.round(loopDelay.percentile(99) / 1e6), eventLoopDelayMaxMs: Math.round(loopDelay.max / 1e6) }
1656
+ : {};
1657
+ return { ...(verdictServer ? verdictServer.stats() : { listening: false, state: 'closed' }), ...delay, detectorInThread: (0, detectorInThread_1.inThreadDetectorActive)() };
1658
+ };
1659
+ /** A delivered heartbeat starts the next percentile window. */
1660
+ const resetHealthWindows = () => {
1661
+ verdictServer?.stats(true);
1662
+ housekeeping.stats(true);
1663
+ loopDelay.reset();
1664
+ };
1496
1665
  const heartbeat = async () => {
1497
1666
  (0, daemonForensics_1.touchDaemonAlive)();
1498
- recoverCredentialsIfMissing();
1667
+ await recoverCredentialsIfMissing();
1499
1668
  if (!creds.shieldId)
1500
1669
  return;
1501
1670
  // Did THIS beat actually record daemon liveness upstream? `flushSpool`
@@ -1508,7 +1677,7 @@ async function runDaemon(args, config) {
1508
1677
  let reported = false;
1509
1678
  let refusal;
1510
1679
  try {
1511
- let integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
1680
+ let integrity = await housekeeping.run({ op: 'integrity', requireDaemon: true });
1512
1681
  // Recovery outside our repair pass (for example a manual fix) also clears
1513
1682
  // the old failure's backoff. It must not suppress an unrelated later gap.
1514
1683
  // An execution error can concern a component outside this summary (e.g.
@@ -1527,7 +1696,7 @@ async function runDaemon(args, config) {
1527
1696
  || Date.now() - lastIntegrityHealFailureAt >= INTEGRITY_HEAL_COOLDOWN_MS)
1528
1697
  && (repairFailed || (0, integrity_1.hasHealableIntegrityReason)(integrity.reasons))) {
1529
1698
  const repaired = await reprotect(integrity.reasons, 'integrity');
1530
- integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
1699
+ integrity = await housekeeping.run({ op: 'integrity', requireDaemon: true });
1531
1700
  if (repaired) {
1532
1701
  log(integrity.ok
1533
1702
  ? 'Integrity self-heal: protection restored.'
@@ -1539,39 +1708,52 @@ async function runDaemon(args, config) {
1539
1708
  // Report a previous daemon's unclean death exactly once.
1540
1709
  const crash = (0, daemonForensics_1.readPostmortem)();
1541
1710
  const unreportedCrash = crash && !crash.reportedAt ? crash : undefined;
1542
- const powershell = (0, config_1.getPowershellHealth)();
1543
- const result = await (0, telemetry_1.flushSpool)({
1544
- apiUrl: creds.apiUrl,
1545
- shieldId: creds.shieldId,
1546
- shieldKey: creds.shieldKey,
1547
- heartbeat: true,
1548
- daemon: true,
1549
- agentVersion: cliVersion(),
1550
- integrityOk: integrity.ok,
1551
- integrityReasons: integrity.reasons,
1552
- integrityCheckedAt: integrity.checkedAt,
1553
- desktopChatGuard: (0, desktopChatGuard_1.desktopChatGuardSupported)() && (0, desktopChatGuard_1.desktopChatGuardHealthy)(),
1554
- securityAgents: (0, securityAgents_1.getSecurityAgentsReport)()?.products,
1555
- powershellSpawnOk: powershell?.spawnOk,
1556
- powershellDecryptOk: powershell?.decryptOk,
1557
- powershellLanguageMode: powershell?.languageMode,
1558
- shieldKeySource: (0, config_1.getCredentialResolutionTrace)()?.shieldKeySource,
1559
- distress: (0, distress_1.readDistressSnapshot)(),
1560
- lastCrash: unreportedCrash
1561
- ? {
1562
- version: unreportedCrash.version,
1563
- startedAt: unreportedCrash.startedAt,
1564
- lastAliveAt: unreportedCrash.lastAliveAt,
1565
- detectedAt: unreportedCrash.detectedAt,
1566
- detectedBy: unreportedCrash.detectedBy,
1567
- cause: unreportedCrash.cause,
1568
- machineBootedAt: unreportedCrash.machineBootedAt,
1569
- }
1570
- : undefined,
1711
+ const powershell = recoveredPowershell ?? (0, config_1.getPowershellHealth)();
1712
+ // The flush (spool read/rewrite, shell-audit registry probes, guard-rule
1713
+ // refresh, EDR probe) runs in the housekeeping worker; what only this
1714
+ // process knows travels with it.
1715
+ const { result, failure } = await housekeeping.run({
1716
+ op: 'heartbeatFlush',
1717
+ securityAgents: true,
1718
+ input: {
1719
+ apiUrl: creds.apiUrl,
1720
+ shieldId: creds.shieldId,
1721
+ shieldKey: creds.shieldKey,
1722
+ heartbeat: true,
1723
+ daemon: true,
1724
+ agentVersion: cliVersion(),
1725
+ integrityOk: integrity.ok,
1726
+ integrityReasons: integrity.reasons,
1727
+ integrityCheckedAt: integrity.checkedAt,
1728
+ desktopChatGuard: (0, desktopChatGuard_1.desktopChatGuardSupported)() && (0, desktopChatGuard_1.desktopChatGuardHealthy)(),
1729
+ powershellSpawnOk: powershell?.spawnOk,
1730
+ powershellDecryptOk: powershell?.decryptOk,
1731
+ powershellLanguageMode: powershell?.languageMode,
1732
+ shieldKeySource: (recoveredTrace ?? (0, config_1.getCredentialResolutionTrace)())?.shieldKeySource,
1733
+ distress: (0, distress_1.readDistressSnapshot)(),
1734
+ lastCrash: unreportedCrash
1735
+ ? {
1736
+ version: unreportedCrash.version,
1737
+ startedAt: unreportedCrash.startedAt,
1738
+ lastAliveAt: unreportedCrash.lastAliveAt,
1739
+ detectedAt: unreportedCrash.detectedAt,
1740
+ detectedBy: unreportedCrash.detectedBy,
1741
+ cause: unreportedCrash.cause,
1742
+ machineBootedAt: unreportedCrash.machineBootedAt,
1743
+ }
1744
+ : undefined,
1745
+ verdictIpc: verdictIpcHealth(),
1746
+ housekeeping: { ...housekeeping.stats() },
1747
+ // The detector that decides verdicts is this process's, not the worker's.
1748
+ detectionUpdatesStatus: localDetectionUpdates_1.localDetectionUpdates.getStatus(),
1749
+ detectorUpdatesStatus: localDetectionUpdates_1.localDetectorUpdates.getDetectorStatus(),
1750
+ },
1571
1751
  });
1572
1752
  reported = result !== null;
1573
1753
  if (!reported)
1574
- refusal = (0, telemetry_1.getLastFlushFailure)();
1754
+ refusal = failure;
1755
+ else
1756
+ resetHealthWindows();
1575
1757
  if (result && unreportedCrash)
1576
1758
  (0, daemonForensics_1.markPostmortemReported)();
1577
1759
  if (result && result.accepted > 0)
@@ -1596,29 +1778,26 @@ async function runDaemon(args, config) {
1596
1778
  }
1597
1779
  };
1598
1780
  // --- boot ---------------------------------------------------------------
1599
- // Settle the machine id before anything reports: the only place a pinned
1600
- // but unconfirmed fallback id may be re-checked against the OS (#1853).
1781
+ // The machine id is settled and the verdict server is already serving (see
1782
+ // above). Autostart/watchdog task self-heal, updater task convergence and a
1783
+ // stale cmd AutoRun repair spawn schtasks/reg: housekeeping worker.
1601
1784
  try {
1602
- const id = (0, machineIdentity_1.reconcileMachineIdentity)('heal');
1603
- log(`Machine id ${(0, machineIdentity_1.shortMachineId)(id.machineId)} (${id.source}${id.confirmed ? ', backend-confirmed' : ''}${id.probed ? `, native read ${id.probeError ? `failed: ${id.probeError}` : 'ok'}` : ''}).`
1604
- + (id.healed ? ` Healed from stale fallback id ${(0, machineIdentity_1.shortMachineId)(id.healed.from)}.` : '')
1605
- + (id.conflict ? ` Native id ${(0, machineIdentity_1.shortMachineId)(id.conflict.native)} disagrees with the confirmed id — kept the confirmed one (machine_id_conflict).` : ''));
1785
+ const healed = await housekeeping.run({ op: 'bootSelfHeal', apiUrl: creds.apiUrl });
1786
+ if (healed.cmdAutorunRepaired)
1787
+ log('Repaired a stale cmd.exe AutoRun / doskey guard left by an earlier install.');
1788
+ if (healed.cacheTempsSwept)
1789
+ log(`Removed ${healed.cacheTempsSwept} stale policy-cache temp file(s).`);
1606
1790
  }
1607
1791
  catch (error) {
1608
- log(`Machine id reconcile failed: ${error instanceof Error ? error.message : String(error)}`);
1792
+ log(`Boot self-heal failed: ${error?.message || String(error)} — retried on the next daemon start.`);
1793
+ }
1794
+ let watched = 0;
1795
+ try {
1796
+ watched = await refreshWatchTargets();
1797
+ }
1798
+ catch (error) {
1799
+ log(`Config scan failed: ${error?.message || String(error)} — the rescan tick retries.`);
1609
1800
  }
1610
- // Self-heal autostart + watchdog tasks: machines installed by older versions
1611
- // (or where task creation failed once) must converge without a reinstall.
1612
- ensureWindowsAutostartHealthy(log);
1613
- // Converge pre-Node-updater machines onto the PowerShell-free updater task
1614
- // (best-effort; needs an elevated daemon to rewrite a SYSTEM task).
1615
- (0, selfUpdate_1.modernizeUpdaterTask)(log, creds.apiUrl);
1616
- // Heal a stale/broken cmd-guard AutoRun (bat deleted, or macros pointing at
1617
- // a dead node.exe) so wrapped commands (docker, git, …) never stay broken.
1618
- if ((0, cmdGuard_1.repairStaleCmdAutorun)()) {
1619
- log('Repaired a stale cmd.exe AutoRun / doskey guard left by an earlier install.');
1620
- }
1621
- const watched = refreshWatchTargets();
1622
1801
  log(`Watching ${watched} config file(s) across ${watchers.size} director${watchers.size === 1 ? 'y' : 'ies'}.`);
1623
1802
  // First: if this boot IS the post-upgrade relaunch, confirm the pending
1624
1803
  // upgrade_cli action before anything else touches the control plane.
@@ -1663,20 +1842,6 @@ async function runDaemon(args, config) {
1663
1842
  if (desktopChatGuard)
1664
1843
  log('Claude Desktop chat guard: supervising (advisory — warns on secrets typed into Claude Desktop).');
1665
1844
  }
1666
- // Verdict IPC server (resident-agent pattern): per-event hook processes
1667
- // become thin clients that ask THIS process for the verdict over a named
1668
- // pipe / Unix socket — everything (config, bundle, snapshot, policy engine)
1669
- // is warm here, so verdicts resolve in milliseconds instead of repeating
1670
- // cold-start work per IDE event. Config is re-read per request (small YAML,
1671
- // cheap) so on-disk edits apply without a daemon restart. Hooks fall back
1672
- // to full local evaluation whenever this server is down — never a blocker.
1673
- let verdictServer;
1674
- try {
1675
- verdictServer = (0, verdictIpc_1.startVerdictServer)((hookArgs, stdin) => (0, hook_1.evaluateHookRequest)(hookArgs, stdin, (0, config_1.loadConfig)(), { viaDaemon: true }), log);
1676
- }
1677
- catch (error) {
1678
- log(`Verdict IPC: failed to start (${error.message}) — hooks will evaluate locally.`);
1679
- }
1680
1845
  // Fresh machines have never uploaded an inventory (MSI/onboard defers the
1681
1846
  // initial discovery to keep setup fast), so the dashboard shows "Never" for
1682
1847
  // discovery + posture until the daily scheduled job fires — up to 24h later.
@@ -1748,18 +1913,30 @@ async function runDaemon(args, config) {
1748
1913
  // re-check still guarantees same-day freshness.
1749
1914
  const discoverCatchUpBootTimer = setTimeout(() => { void maybeCatchUpDiscovery('after start'); }, DISCOVER_BOOT_CATCHUP_DELAY_MS + Math.floor(Math.random() * DISCOVER_BOOT_CATCHUP_JITTER_MS));
1750
1915
  const discoverCatchUpTimer = setInterval(() => { void maybeCatchUpDiscovery('hourly check'); }, DISCOVER_CHECK_INTERVAL_MS);
1916
+ let rescanInFlight = false;
1751
1917
  const rescanTimer = setInterval(() => {
1752
- const previousTargets = new Set(watchedFiles);
1753
- const count = refreshWatchTargets();
1754
- log(`Rescan: watching ${count} config file(s).`);
1755
- // A config created before its parent was watched emits no event for us.
1756
- // Discovery is therefore a repair trigger, even during another gap's
1757
- // failure backoff. Repeated scans of the same targets do not retry it.
1758
- const added = [...watchedFiles].filter(file => !previousTargets.has(file));
1759
- if (added.length > 0 && !suspended && !stopped
1760
- && (0, integrity_1.hasHealableIntegrityReason)((0, integrity_1.getLocalIntegrityReport)().reasons)) {
1761
- void reprotect(added, 'discovery');
1762
- }
1918
+ // The walk runs in the housekeeping worker; a slow one never stacks. A
1919
+ // repair pass in flight rescans the targets itself when it finishes.
1920
+ if (rescanInFlight || repairInFlight)
1921
+ return;
1922
+ rescanInFlight = true;
1923
+ void (async () => {
1924
+ const pass = `rescan-${++passSeq}`;
1925
+ const previousTargets = new Set(watchedFiles);
1926
+ const count = await refreshWatchTargets(pass);
1927
+ if (stopped)
1928
+ return;
1929
+ log(`Rescan: watching ${count} config file(s).`);
1930
+ // A config created before its parent was watched emits no event for us.
1931
+ // Discovery is therefore a repair trigger, even during another gap's
1932
+ // failure backoff. Repeated scans of the same targets do not retry it.
1933
+ const added = [...watchedFiles].filter(file => !previousTargets.has(file));
1934
+ if (added.length > 0 && !suspended && !stopped
1935
+ && (0, integrity_1.hasHealableIntegrityReason)((await housekeeping.run({ op: 'integrity', pass })).reasons)) {
1936
+ void reprotect(added, 'discovery');
1937
+ }
1938
+ })().catch(error => log(`Rescan failed: ${error?.message || String(error)}`))
1939
+ .finally(() => { rescanInFlight = false; });
1763
1940
  }, RESCAN_INTERVAL_MS);
1764
1941
  // Jittered self-rescheduling polls (±20%) instead of fixed setInterval:
1765
1942
  // a fleet enrolled behind one corporate proxy must not hit the gate in
@@ -1856,14 +2033,12 @@ async function runDaemon(args, config) {
1856
2033
  // that never stay up 24h still get cleaned). Only touches FCD-managed
1857
2034
  // transcript folders; see pruneTranscripts.
1858
2035
  const runTranscriptPrune = () => {
1859
- try {
1860
- const result = (0, windowsAudit_1.pruneTranscripts)();
2036
+ housekeeping.run({ op: 'pruneTranscripts' }).then(result => {
1861
2037
  if (result.pruned > 0)
1862
2038
  log(`Transcript retention: pruned ${result.pruned} transcript file(s) older than ${(0, windowsAudit_1.transcriptRetentionDays)()} day(s).`);
1863
2039
  if ((result.prunedBySize || 0) > 0)
1864
2040
  log(`Transcript retention: pruned ${result.prunedBySize} more transcript file(s) (oldest first) to stay under the ${(0, windowsAudit_1.transcriptMaxMb)()}MB size budget.`);
1865
- }
1866
- catch { /* never let housekeeping hurt the daemon */ }
2041
+ }, () => { });
1867
2042
  };
1868
2043
  const transcriptPruneBootTimer = setTimeout(runTranscriptPrune, 5 * 60_000);
1869
2044
  const transcriptPruneTimer = setInterval(runTranscriptPrune, 24 * 60 * 60_000);
@@ -1890,6 +2065,9 @@ async function runDaemon(args, config) {
1890
2065
  desktopChatGuard.stop();
1891
2066
  if (verdictServer)
1892
2067
  verdictServer.close();
2068
+ housekeeping.stop();
2069
+ loopDelay.disable();
2070
+ (0, hookIo_1.flushHookLogSync)();
1893
2071
  for (const watcher of watchers.values())
1894
2072
  watcher.close();
1895
2073
  for (const watcher of rootWatchers.values())