@primitive.ai/prim 0.1.0-alpha.60 → 0.1.0-alpha.62

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/dist/index.js CHANGED
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  bold,
4
+ boundedHealthError,
4
5
  color,
5
6
  colorForArea,
6
7
  dim,
7
8
  requireDurableIngestAcknowledgement,
8
9
  stripAnsi,
9
10
  stripControlChars
10
- } from "./chunk-QY75BQMF.js";
11
+ } from "./chunk-JQFN6LBO.js";
11
12
  import {
12
13
  checkAffectedDecisions,
13
14
  daemonOrDirectGet,
@@ -24,15 +25,22 @@ import {
24
25
  } from "./chunk-S4LFRVGD.js";
25
26
  import {
26
27
  bindRepository,
28
+ compareSemver,
29
+ ensureEffectivePostCommitHook,
30
+ ensurePostCommitHookAtPath,
31
+ inspectEffectivePostCommitHook,
32
+ postCommitHookBlock,
27
33
  printJson,
28
- registerSkillCommands
29
- } from "./chunk-MZQO7K2P.js";
34
+ registerSkillCommands,
35
+ uninstallPostCommitHookAtPath,
36
+ uninstallProjectPostCommitHook
37
+ } from "./chunk-LOGW54W5.js";
30
38
  import {
31
39
  fetchFeedbackCapability
32
40
  } from "./chunk-R4CUX6FY.js";
33
41
  import {
34
42
  inspectWorkspaceId
35
- } from "./chunk-IMAIBPUC.js";
43
+ } from "./chunk-UVIWZWM6.js";
36
44
  import {
37
45
  binFile,
38
46
  commandMatchesBin,
@@ -40,16 +48,17 @@ import {
40
48
  packageVersion,
41
49
  pinnedHookCommand,
42
50
  warmBinCache
43
- } from "./chunk-YIMUIPMD.js";
51
+ } from "./chunk-KBFFVYBC.js";
44
52
  import {
45
53
  canonicalGitRoot,
46
54
  canonicalRepositoryPath,
47
55
  decisionIngestionStatus,
48
56
  gitToplevel,
49
57
  isRepoActiveForCapture,
58
+ isValidRepoSyncId,
50
59
  repoSyncId,
51
60
  setRepoActive
52
- } from "./chunk-LDQZASIF.js";
61
+ } from "./chunk-XTNMSS4C.js";
53
62
  import {
54
63
  HttpError,
55
64
  REFRESH_TOKEN_PATH,
@@ -89,17 +98,19 @@ async function applyActivation(active) {
89
98
  }
90
99
  try {
91
100
  let bound;
101
+ let postCommitHook;
92
102
  if (active) {
103
+ postCommitHook = ensureEffectivePostCommitHook(root).path;
93
104
  bound = await bindRepository(root);
94
105
  }
95
106
  setRepoActive(root, active);
96
107
  process.stderr.write(`[prim] prim ${active ? "enabled" : "disabled"} in ${root}
97
108
  `);
98
- printJson({ active, repo: root, ...bound });
109
+ printJson({ active, repo: root, ...bound, ...postCommitHook ? { postCommitHook } : {} });
99
110
  } catch (err) {
100
111
  const detail = err instanceof Error ? err.message : String(err);
101
112
  process.stderr.write(
102
- `[prim] failed to ${active ? "bind and enable" : "disable"} prim: ${detail}
113
+ `[prim] failed to ${active ? "cover, bind, and enable" : "disable"} prim: ${detail}
103
114
  `
104
115
  );
105
116
  process.exit(1);
@@ -636,28 +647,58 @@ import { dirname as dirname2, join as join2 } from "path";
636
647
  import { spawnSync } from "child_process";
637
648
  import { createHash as createHash2 } from "crypto";
638
649
  import {
650
+ constants,
651
+ accessSync,
639
652
  chmodSync,
640
653
  copyFileSync,
641
654
  existsSync as existsSync2,
642
655
  mkdirSync,
643
656
  mkdtempSync,
644
657
  readFileSync as readFileSync2,
658
+ realpathSync,
645
659
  renameSync,
646
660
  rmSync,
647
661
  symlinkSync,
648
662
  writeFileSync
649
663
  } from "fs";
650
664
  import { homedir } from "os";
651
- import { dirname, isAbsolute, join, resolve } from "path";
665
+ import { basename, dirname, isAbsolute, join, resolve } from "path";
666
+
667
+ // src/daemon/env-binding.ts
668
+ function normalizeApiUrl(url) {
669
+ return url.trim().replace(/\/+$/, "");
670
+ }
671
+
672
+ // src/daemon/launchd.ts
652
673
  var LAUNCHD_LABEL = "ai.getprimitive.prim-daemon";
653
- var RUNTIME_SCHEMA_VERSION = 1;
674
+ var RUNTIME_SCHEMA_VERSION = 2;
654
675
  var RUNTIME_DIR_MODE = 448;
655
676
  var RUNTIME_FILE_MODE = 384;
656
677
  var RUNTIME_LAUNCHER_MODE = 448;
657
678
  var PLIST_FILE_MODE = 384;
658
- var READY_TIMEOUT_MS = 15e3;
679
+ var LAUNCHER_SCHEMA_VERSION = 1;
680
+ var TRANSITION_TIMEOUT_MS = 15e3;
681
+ var COMMAND_TIMEOUT_MS = 5e3;
659
682
  var READY_POLL_MS = 100;
660
683
  var READY_PROBE_TIMEOUT_MS = 250;
684
+ var RETRY_MAX_MS = 500;
685
+ var SERVICE_NOT_FOUND = 113;
686
+ var TRANSITION_IN_PROGRESS = 5;
687
+ var OUTPUT_LIMIT = 4096;
688
+ var LaunchctlError = class extends Error {
689
+ status;
690
+ timedOut;
691
+ constructor(operation, result) {
692
+ const stdout = result.stdout.slice(0, OUTPUT_LIMIT);
693
+ const stderr = result.stderr.slice(0, OUTPUT_LIMIT);
694
+ const raw = stderr.trim() || stdout.trim() || `exit ${String(result.status)}`;
695
+ const detail = raw.replace(/\n?Try re-running the command as root for richer errors\.?/giu, "").trim() || `exit ${String(result.status)}`;
696
+ super(`launchctl ${operation} failed: ${detail}`);
697
+ this.name = "LaunchctlError";
698
+ this.status = result.status;
699
+ this.timedOut = result.timedOut === true;
700
+ }
701
+ };
661
702
  function xdgDataHome(options) {
662
703
  const home = options.homeDir ?? homedir();
663
704
  const configured = (options.env ?? process.env).XDG_DATA_HOME;
@@ -672,14 +713,22 @@ function runtimePaths(options = {}) {
672
713
  runtimeDir,
673
714
  releasesDir: join(runtimeDir, "releases"),
674
715
  currentLink,
675
- daemonEntry: join(currentLink, "prim-daemon-server"),
676
- statuslineEntry: join(currentLink, "prim-statusline"),
677
716
  statuslineLauncher: join(runtimeDir, "prim-statusline"),
678
- manifestPath: join(currentLink, "manifest.json"),
679
717
  disabledMarker: join(dataDir, "daemon.disabled"),
680
718
  lifecycleLockDir: join(dataDir, "daemon.lifecycle.lock")
681
719
  };
682
720
  }
721
+ function daemonControlPaths(options = {}) {
722
+ const configDir = join(options.homeDir ?? homedir(), ".config", "prim");
723
+ const legacy = runtimePaths(options);
724
+ return {
725
+ launcher: join(configDir, `prim-daemon-launcher-v${LAUNCHER_SCHEMA_VERSION}`),
726
+ disabledMarker: join(configDir, "daemon.disabled"),
727
+ lifecycleLockDir: join(configDir, "daemon.lifecycle.lock"),
728
+ legacyDisabledMarker: legacy.disabledMarker,
729
+ legacyLifecycleLockDir: legacy.lifecycleLockDir
730
+ };
731
+ }
683
732
  function findPackageVersion(entryFile) {
684
733
  let dir = dirname(resolve(entryFile));
685
734
  for (let depth = 0; depth < 6; depth++) {
@@ -702,7 +751,7 @@ function findPackageVersion(entryFile) {
702
751
  function readRuntimeManifest(path) {
703
752
  try {
704
753
  const parsed = JSON.parse(readFileSync2(path, "utf8"));
705
- if (parsed.schemaVersion !== RUNTIME_SCHEMA_VERSION || typeof parsed.version !== "string" || typeof parsed.nodePath !== "string" || parsed.daemonFile !== "prim-daemon-server") {
754
+ if (parsed.schemaVersion !== RUNTIME_SCHEMA_VERSION || typeof parsed.version !== "string" || typeof parsed.nodePath !== "string" || parsed.daemonFile !== "prim-daemon-server" || typeof parsed.daemonSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(parsed.daemonSha256) || parsed.statuslineFile !== void 0 && parsed.statuslineFile !== "prim-statusline" || parsed.statuslineFile === void 0 !== (parsed.statuslineSha256 === void 0) || parsed.statuslineSha256 !== void 0 && !/^[a-f0-9]{64}$/u.test(parsed.statuslineSha256)) {
706
755
  return null;
707
756
  }
708
757
  return parsed;
@@ -710,10 +759,18 @@ function readRuntimeManifest(path) {
710
759
  return null;
711
760
  }
712
761
  }
713
- function sameRuntime(current, desired, paths) {
714
- return Boolean(
715
- current && current.version === desired.version && current.nodePath === desired.nodePath && current.statuslineFile === desired.statuslineFile && existsSync2(paths.daemonEntry) && (desired.statuslineFile === void 0 || existsSync2(paths.statuslineEntry))
716
- );
762
+ function sameRuntime(current, desired, daemonPath, statuslinePath) {
763
+ if (!current || current.version !== desired.version || current.nodePath !== desired.nodePath || current.daemonSha256 !== desired.daemonSha256 || current.statuslineFile !== desired.statuslineFile || current.statuslineSha256 !== desired.statuslineSha256) {
764
+ return false;
765
+ }
766
+ try {
767
+ return sha256File(daemonPath) === desired.daemonSha256 && (desired.statuslineSha256 === void 0 || sha256File(statuslinePath) === desired.statuslineSha256);
768
+ } catch {
769
+ return false;
770
+ }
771
+ }
772
+ function sha256File(path) {
773
+ return createHash2("sha256").update(readFileSync2(path)).digest("hex");
717
774
  }
718
775
  function atomicSymlink(target, linkPath) {
719
776
  const tempLink = `${linkPath}.tmp-${process.pid}-${createHash2("sha256").update(`${target}\0${Date.now()}\0${Math.random()}`).digest("hex").slice(0, 12)}`;
@@ -740,35 +797,60 @@ function stageRuntime(options = {}) {
740
797
  version,
741
798
  nodePath,
742
799
  daemonFile: "prim-daemon-server",
743
- ...statuslineSource ? { statuslineFile: "prim-statusline" } : {},
800
+ daemonSha256: sha256File(daemonSource),
801
+ ...statuslineSource ? {
802
+ statuslineFile: "prim-statusline",
803
+ statuslineSha256: sha256File(statuslineSource)
804
+ } : {},
744
805
  stagedAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString()
745
806
  };
746
- const current = readRuntimeManifest(paths.manifestPath);
747
- if (sameRuntime(current, desired, paths)) {
748
- if (current?.statuslineFile) {
807
+ let currentRelease = null;
808
+ try {
809
+ currentRelease = realpathSync(paths.currentLink);
810
+ } catch {
811
+ }
812
+ const current = currentRelease ? readRuntimeManifest(join(currentRelease, "manifest.json")) : null;
813
+ const currentDaemon = currentRelease ? join(currentRelease, desired.daemonFile) : "";
814
+ const currentStatusline = currentRelease ? join(currentRelease, "prim-statusline") : "";
815
+ if (currentRelease && sameRuntime(current, desired, currentDaemon, currentStatusline)) {
816
+ const manifest = current;
817
+ const statuslinePath = manifest.statuslineFile ? currentStatusline : void 0;
818
+ if (statuslinePath) {
749
819
  atomicWrite(
750
820
  paths.statuslineLauncher,
751
- statuslineLauncherContent(paths, current.nodePath),
821
+ statuslineLauncherContent(statuslinePath, manifest.nodePath),
752
822
  RUNTIME_LAUNCHER_MODE
753
823
  );
754
824
  } else {
755
825
  rmSync(paths.statuslineLauncher, { force: true });
756
826
  }
757
- return { changed: false, manifest: current, paths };
827
+ return {
828
+ changed: false,
829
+ manifest,
830
+ paths,
831
+ daemonPath: currentDaemon
832
+ };
758
833
  }
759
834
  mkdirSync(paths.releasesDir, { recursive: true, mode: RUNTIME_DIR_MODE });
760
835
  chmodSync(paths.runtimeDir, RUNTIME_DIR_MODE);
761
836
  chmodSync(paths.releasesDir, RUNTIME_DIR_MODE);
762
837
  const stagingDir = mkdtempSync(join(paths.releasesDir, ".stage-"));
763
838
  chmodSync(stagingDir, RUNTIME_DIR_MODE);
839
+ let releaseDir;
764
840
  try {
765
841
  const daemonTarget = join(stagingDir, desired.daemonFile);
766
842
  copyFileSync(daemonSource, daemonTarget);
767
843
  chmodSync(daemonTarget, RUNTIME_FILE_MODE);
844
+ if (sha256File(daemonTarget) !== desired.daemonSha256) {
845
+ throw new Error("cannot stage runtime: daemon bundle changed while it was copied");
846
+ }
768
847
  if (statuslineSource && desired.statuslineFile) {
769
848
  const statuslineTarget = join(stagingDir, desired.statuslineFile);
770
849
  copyFileSync(statuslineSource, statuslineTarget);
771
850
  chmodSync(statuslineTarget, RUNTIME_FILE_MODE);
851
+ if (sha256File(statuslineTarget) !== desired.statuslineSha256) {
852
+ throw new Error("cannot stage runtime: statusline bundle changed while it was copied");
853
+ }
772
854
  }
773
855
  const manifestTarget = join(stagingDir, "manifest.json");
774
856
  writeFileSync(manifestTarget, `${JSON.stringify(desired, null, 2)}
@@ -780,13 +862,15 @@ function stageRuntime(options = {}) {
780
862
  const releaseName = `release-${createHash2("sha256").update(
781
863
  `${version}\0${nodePath}\0${desired.statuslineFile ?? ""}\0${desired.stagedAt}\0${process.pid}\0${Math.random()}`
782
864
  ).digest("hex").slice(0, 16)}`;
783
- const releaseDir = join(paths.releasesDir, releaseName);
865
+ releaseDir = join(paths.releasesDir, releaseName);
784
866
  renameSync(stagingDir, releaseDir);
867
+ releaseDir = realpathSync(releaseDir);
785
868
  atomicSymlink(join("releases", releaseName), paths.currentLink);
786
869
  if (desired.statuslineFile) {
870
+ const statuslinePath = join(releaseDir, desired.statuslineFile);
787
871
  atomicWrite(
788
872
  paths.statuslineLauncher,
789
- statuslineLauncherContent(paths, desired.nodePath),
873
+ statuslineLauncherContent(statuslinePath, desired.nodePath),
790
874
  RUNTIME_LAUNCHER_MODE
791
875
  );
792
876
  } else {
@@ -796,36 +880,91 @@ function stageRuntime(options = {}) {
796
880
  rmSync(stagingDir, { recursive: true, force: true });
797
881
  throw error;
798
882
  }
799
- return { changed: true, manifest: desired, paths };
883
+ return {
884
+ changed: true,
885
+ manifest: desired,
886
+ paths,
887
+ daemonPath: join(releaseDir, desired.daemonFile)
888
+ };
800
889
  }
801
890
  function shellQuote(value) {
802
891
  return `'${value.replaceAll("'", `'"'"'`)}'`;
803
892
  }
804
- function statuslineLauncherContent(paths, nodePath) {
893
+ function statuslineLauncherContent(statuslinePath, nodePath) {
805
894
  return `#!/bin/sh
806
- exec ${shellQuote(nodePath)} ${shellQuote(paths.statuslineEntry)}
895
+ exec ${shellQuote(nodePath)} ${shellQuote(statuslinePath)}
807
896
  `;
808
897
  }
809
898
  function runtimeStatuslineCommand(options = {}) {
810
899
  return shellQuote(runtimePaths(options).statuslineLauncher);
811
900
  }
901
+ function generateDaemonLauncher(config) {
902
+ const apiUrl = config.apiUrl ? normalizeApiUrl(config.apiUrl) || void 0 : void 0;
903
+ const metadata = {
904
+ schemaVersion: LAUNCHER_SCHEMA_VERSION,
905
+ nodePath: config.nodePath,
906
+ daemonPath: config.daemonPath,
907
+ runtimeVersion: config.runtimeVersion,
908
+ apiUrl: apiUrl ?? null
909
+ };
910
+ const revision = createHash2("sha256").update(JSON.stringify(metadata)).digest("hex");
911
+ const encoded = Buffer.from(JSON.stringify({ ...metadata, revision })).toString("base64url");
912
+ return {
913
+ revision,
914
+ content: `#!/bin/sh
915
+ # prim-daemon-launcher: ${encoded}
916
+ export PRIM_RUNTIME_VERSION=${shellQuote(config.runtimeVersion)}
917
+ export PRIM_LAUNCH_REVISION=${shellQuote(revision)}
918
+ ${apiUrl ? `export PRIM_API_URL=${shellQuote(apiUrl)}` : "unset PRIM_API_URL"}
919
+ exec ${shellQuote(config.nodePath)} ${shellQuote(config.daemonPath)}
920
+ `
921
+ };
922
+ }
923
+ function readDaemonLauncher(path) {
924
+ try {
925
+ const content = readFileSync2(path, "utf8");
926
+ const encoded = /^# prim-daemon-launcher: ([A-Za-z0-9_-]+)$/mu.exec(content)?.[1];
927
+ if (!encoded) return null;
928
+ const value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
929
+ if (value.schemaVersion !== LAUNCHER_SCHEMA_VERSION || typeof value.nodePath !== "string" || typeof value.daemonPath !== "string" || typeof value.runtimeVersion !== "string" || value.apiUrl !== null && typeof value.apiUrl !== "string" || typeof value.revision !== "string") {
930
+ return null;
931
+ }
932
+ const config = {
933
+ nodePath: value.nodePath,
934
+ daemonPath: value.daemonPath,
935
+ runtimeVersion: value.runtimeVersion,
936
+ ...value.apiUrl === null ? {} : { apiUrl: value.apiUrl }
937
+ };
938
+ const expected = generateDaemonLauncher(config);
939
+ return expected.revision === value.revision && expected.content === content ? config : null;
940
+ } catch {
941
+ return null;
942
+ }
943
+ }
944
+ function readUsableDaemonConfig(path) {
945
+ const config = readDaemonLauncher(path);
946
+ if (!config) return null;
947
+ const manifest = readRuntimeManifest(join(dirname(config.daemonPath), "manifest.json"));
948
+ try {
949
+ accessSync(config.nodePath, constants.X_OK);
950
+ return manifest?.version === config.runtimeVersion && manifest.nodePath === config.nodePath && manifest.daemonSha256 === sha256File(config.daemonPath) ? config : null;
951
+ } catch {
952
+ return null;
953
+ }
954
+ }
812
955
  function xmlEscape(value) {
813
956
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
814
957
  }
815
958
  function generateLaunchAgentPlist(config) {
816
- const apiUrl = config.apiUrl ? `
817
- <key>PRIM_API_URL</key>
818
- <string>${xmlEscape(config.apiUrl)}</string>` : "";
819
959
  return `<?xml version="1.0" encoding="UTF-8"?>
820
960
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
821
961
  <plist version="1.0">
822
962
  <dict>
823
963
  <key>Label</key>
824
- <string>${LAUNCHD_LABEL}</string>
964
+ <string>${xmlEscape(config.label ?? LAUNCHD_LABEL)}</string>
825
965
  <key>ProgramArguments</key>
826
966
  <array>
827
- <string>${xmlEscape(config.nodePath)}</string>
828
- <string>${xmlEscape(config.daemonPath)}</string>
967
+ <string>${xmlEscape(config.launcherPath)}</string>
829
968
  </array>
830
969
  <key>RunAtLoad</key>
831
970
  <true/>
@@ -835,6 +974,8 @@ function generateLaunchAgentPlist(config) {
835
974
  <integer>10</integer>
836
975
  <key>ProcessType</key>
837
976
  <string>Background</string>
977
+ <key>ExitTimeOut</key>
978
+ <integer>5</integer>
838
979
  <key>Umask</key>
839
980
  <integer>63</integer>
840
981
  <key>WorkingDirectory</key>
@@ -843,11 +984,6 @@ function generateLaunchAgentPlist(config) {
843
984
  <string>${xmlEscape(config.logPath)}</string>
844
985
  <key>StandardErrorPath</key>
845
986
  <string>${xmlEscape(config.logPath)}</string>
846
- <key>EnvironmentVariables</key>
847
- <dict>
848
- <key>PRIM_RUNTIME_VERSION</key>
849
- <string>${xmlEscape(config.runtimeVersion)}</string>${apiUrl}
850
- </dict>
851
987
  </dict>
852
988
  </plist>
853
989
  `;
@@ -855,45 +991,57 @@ function generateLaunchAgentPlist(config) {
855
991
  function launchdPaths(options = {}) {
856
992
  const home = options.homeDir ?? homedir();
857
993
  const uid = options.uid ?? process.getuid?.();
994
+ const label = options.label ?? LAUNCHD_LABEL;
858
995
  if (uid === void 0) throw new Error("cannot determine uid for launchd user domain");
859
996
  return {
860
997
  domainTarget: `gui/${uid}`,
861
- serviceTarget: `gui/${uid}/${LAUNCHD_LABEL}`,
862
- plistPath: join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`),
998
+ serviceTarget: `gui/${uid}/${label}`,
999
+ plistPath: join(home, "Library", "LaunchAgents", `${label}.plist`),
863
1000
  logPath: join(home, ".config", "prim", "daemon.log")
864
1001
  };
865
1002
  }
866
- var runLaunchctl = (args) => {
867
- const result = spawnSync("/bin/launchctl", args, { encoding: "utf8" });
1003
+ var runLaunchctl = (args, timeoutMs2 = COMMAND_TIMEOUT_MS) => {
1004
+ const result = spawnSync("/bin/launchctl", args, { encoding: "utf8", timeout: timeoutMs2 });
1005
+ const error = result.error;
868
1006
  return {
869
1007
  status: result.status,
870
1008
  stdout: result.stdout ?? "",
871
- stderr: result.error?.message ?? result.stderr ?? ""
1009
+ stderr: error?.message ?? result.stderr ?? "",
1010
+ timedOut: error?.code === "ETIMEDOUT"
872
1011
  };
873
1012
  };
874
1013
  function parseLaunchdService(result) {
875
- if (result.status !== 0) return { loaded: false };
1014
+ if (result.status === SERVICE_NOT_FOUND) return { loaded: false };
1015
+ if (result.status !== 0) throw new LaunchctlError("print", result);
876
1016
  const state = /^\s*state = (.+?)\s*$/m.exec(result.stdout)?.[1];
877
1017
  const rawPid = /^\s*pid = (\d+)\s*$/m.exec(result.stdout)?.[1];
1018
+ const program2 = /^\s*program = (.+?)\s*$/m.exec(result.stdout)?.[1];
1019
+ if (!program2) throw new Error("launchctl print did not report the registered program");
878
1020
  const pid = rawPid ? Number(rawPid) : void 0;
879
- return { loaded: true, state, pid, raw: result.stdout };
1021
+ return { loaded: true, state, pid, program: program2 };
880
1022
  }
881
1023
  function getLaunchdService(options = {}) {
882
- const paths = launchdPaths(options);
883
- return parseLaunchdService((options.runner ?? runLaunchctl)(["print", paths.serviceTarget]));
1024
+ return parseLaunchdService(
1025
+ (options.runner ?? runLaunchctl)(
1026
+ ["print", launchdPaths(options).serviceTarget],
1027
+ options.timeoutMs ?? COMMAND_TIMEOUT_MS
1028
+ )
1029
+ );
884
1030
  }
885
- function atomicWrite(path, content, mode) {
1031
+ function atomicWrite(path, content, mode, validate) {
886
1032
  try {
887
1033
  if (readFileSync2(path, "utf8") === content) {
888
1034
  chmodSync(path, mode);
1035
+ validate?.(path, COMMAND_TIMEOUT_MS);
889
1036
  return false;
890
1037
  }
891
1038
  } catch {
892
1039
  }
893
- mkdirSync(dirname(path), { recursive: true });
1040
+ mkdirSync(dirname(path), { recursive: true, mode: RUNTIME_DIR_MODE });
894
1041
  const temp = `${path}.tmp-${process.pid}-${Date.now()}`;
895
1042
  writeFileSync(temp, content, { encoding: "utf8", mode, flag: "wx" });
896
1043
  try {
1044
+ validate?.(temp, COMMAND_TIMEOUT_MS);
897
1045
  renameSync(temp, path);
898
1046
  } catch (error) {
899
1047
  rmSync(temp, { force: true });
@@ -901,20 +1049,25 @@ function atomicWrite(path, content, mode) {
901
1049
  }
902
1050
  return true;
903
1051
  }
904
- function launchctlOrThrow(runner, args, operation) {
905
- const result = runner(args);
906
- if (result.status !== 0) {
907
- const detail = result.stderr.trim() || result.stdout.trim() || `exit ${String(result.status)}`;
908
- throw new Error(`launchctl ${operation} failed: ${detail}`);
909
- }
910
- return result;
911
- }
912
1052
  function defaultSleep(ms) {
913
1053
  return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
914
1054
  }
915
- async function identifyDaemon(timeoutMs) {
916
- const snapshot = await daemonRequest("status_snapshot", {}, { timeoutMs });
917
- return typeof snapshot?.pid === "number" ? snapshot.pid : null;
1055
+ function validatePlist(path, timeoutMs2) {
1056
+ if (process.platform !== "darwin") return;
1057
+ const result = spawnSync("/usr/bin/plutil", ["-lint", path], {
1058
+ encoding: "utf8",
1059
+ timeout: timeoutMs2
1060
+ });
1061
+ if (result.status !== 0) {
1062
+ throw new Error(
1063
+ `invalid launchd plist: ${result.error?.message ?? result.stderr?.trim() ?? result.stdout?.trim() ?? `exit ${String(result.status)}`}`
1064
+ );
1065
+ }
1066
+ }
1067
+ async function inspectDaemon(timeoutMs2) {
1068
+ const value = await daemonRequest("status_snapshot", {}, { timeoutMs: timeoutMs2 });
1069
+ if (!value || !Number.isInteger(value.pid) || (value.pid ?? 0) <= 0) return null;
1070
+ return value;
918
1071
  }
919
1072
  function processIsAlive(pid) {
920
1073
  try {
@@ -924,11 +1077,31 @@ function processIsAlive(pid) {
924
1077
  return error.code === "EPERM";
925
1078
  }
926
1079
  }
1080
+ function canonicalProspectivePath(path) {
1081
+ const suffix = [];
1082
+ let ancestor = resolve(path);
1083
+ while (!existsSync2(ancestor)) {
1084
+ const parent = dirname(ancestor);
1085
+ if (parent === ancestor) return resolve(path);
1086
+ suffix.unshift(basename(ancestor));
1087
+ ancestor = parent;
1088
+ }
1089
+ try {
1090
+ return join(realpathSync(ancestor), ...suffix);
1091
+ } catch {
1092
+ return resolve(path);
1093
+ }
1094
+ }
927
1095
  async function withDaemonLifecycleLock(operation, options = {}) {
928
- const lockDir = runtimePaths(options).lifecycleLockDir;
929
- return withFileLock(lockDir, operation, options.lifecycleLock);
1096
+ const paths = daemonControlPaths(options);
1097
+ const sameLock = canonicalProspectivePath(paths.lifecycleLockDir) === canonicalProspectivePath(paths.legacyLifecycleLockDir);
1098
+ return withFileLock(
1099
+ paths.lifecycleLockDir,
1100
+ () => sameLock ? operation() : withFileLock(paths.legacyLifecycleLockDir, operation, options.lifecycleLock),
1101
+ options.lifecycleLock
1102
+ );
930
1103
  }
931
- async function stopVerifiedLegacyDaemon(homeDir) {
1104
+ async function stopVerifiedLegacyDaemon(homeDir, deadlineMs, nowMs, sleep2) {
932
1105
  const pidPath = join(homeDir, ".config", "prim", "daemon.pid");
933
1106
  let pid;
934
1107
  try {
@@ -936,122 +1109,314 @@ async function stopVerifiedLegacyDaemon(homeDir) {
936
1109
  } catch {
937
1110
  throw new Error("an unsupervised daemon answered, but it has no verifiable pidfile");
938
1111
  }
939
- const snapshot = await daemonRequest("status_snapshot", {}, { timeoutMs: 500 });
1112
+ if (nowMs() >= deadlineMs) throw new Error("legacy verification exceeded the lifecycle deadline");
1113
+ const snapshot = await daemonRequest(
1114
+ "status_snapshot",
1115
+ {},
1116
+ {
1117
+ timeoutMs: timeoutMs(nowMs, deadlineMs, 500)
1118
+ }
1119
+ );
940
1120
  if (!Number.isInteger(pid) || pid <= 0 || !processIsAlive(pid) || snapshot?.pid !== pid) {
941
1121
  throw new Error("refusing to signal an unsupervised daemon whose pid cannot be verified");
942
1122
  }
1123
+ if (nowMs() >= deadlineMs) throw new Error("legacy verification exceeded the lifecycle deadline");
943
1124
  process.kill(pid, "SIGTERM");
944
- const deadline = Date.now() + 5e3;
945
- while (Date.now() < deadline && processIsAlive(pid)) {
946
- await defaultSleep(100);
1125
+ while (nowMs() < deadlineMs && processIsAlive(pid)) {
1126
+ await sleep2(Math.min(READY_POLL_MS, deadlineMs - nowMs()));
947
1127
  }
948
1128
  if (processIsAlive(pid)) {
949
- throw new Error(`verified legacy daemon pid=${pid} did not stop within 5000ms`);
1129
+ throw new Error(`verified legacy daemon pid=${pid} did not stop before the lifecycle deadline`);
950
1130
  }
951
1131
  return true;
952
1132
  }
953
- async function waitForReady(probe, sleep2) {
954
- const deadline = Date.now() + READY_TIMEOUT_MS;
955
- while (Date.now() < deadline) {
956
- if (await probe(READY_PROBE_TIMEOUT_MS)) return true;
957
- await sleep2(READY_POLL_MS);
1133
+ function timeoutMs(nowMs, deadlineMs, cap = COMMAND_TIMEOUT_MS) {
1134
+ return Math.max(1, Math.min(cap, Math.ceil(deadlineMs - nowMs())));
1135
+ }
1136
+ function desiredDaemonIsRunning(transition, observed) {
1137
+ const { service, identity } = observed;
1138
+ return Boolean(
1139
+ service.loaded && service.program === transition.launcherPath && service.pid !== void 0 && identity?.pid === service.pid && identity.version === transition.version && identity.launchRevision === transition.revision
1140
+ );
1141
+ }
1142
+ function getService(transition) {
1143
+ return parseLaunchdService(
1144
+ transition.runner(
1145
+ ["print", transition.paths.serviceTarget],
1146
+ timeoutMs(transition.nowMs, transition.deadlineMs)
1147
+ )
1148
+ );
1149
+ }
1150
+ async function observe(transition) {
1151
+ const service = getService(transition);
1152
+ const identity = service.loaded && service.program === transition.launcherPath ? await transition.inspect(
1153
+ timeoutMs(transition.nowMs, transition.deadlineMs, READY_PROBE_TIMEOUT_MS)
1154
+ ) : null;
1155
+ return { service, identity };
1156
+ }
1157
+ async function tryObserve(transition) {
1158
+ try {
1159
+ return await observe(transition);
1160
+ } catch (error) {
1161
+ if (!(error instanceof LaunchctlError)) throw error;
1162
+ return null;
1163
+ }
1164
+ }
1165
+ async function bootstrap(transition, oldProgram) {
1166
+ let retryMs = READY_POLL_MS;
1167
+ let lastError = null;
1168
+ while (transition.nowMs() < transition.deadlineMs) {
1169
+ const result = transition.runner(
1170
+ ["bootstrap", transition.paths.domainTarget, transition.paths.plistPath],
1171
+ timeoutMs(transition.nowMs, transition.deadlineMs)
1172
+ );
1173
+ if (result.status === 0) return;
1174
+ const error = new LaunchctlError("bootstrap", result);
1175
+ lastError = error;
1176
+ const observed = await tryObserve(transition);
1177
+ const retryable = error.status === TRANSITION_IN_PROGRESS || error.timedOut;
1178
+ if (observed?.service.loaded) {
1179
+ if (observed.service.program === transition.launcherPath) {
1180
+ if (desiredDaemonIsRunning(transition, observed)) return;
1181
+ if (retryable) return;
1182
+ } else if (observed.service.program !== oldProgram) {
1183
+ throw new Error(
1184
+ `refusing to replace unexpected launchd program ${observed.service.program}`
1185
+ );
1186
+ }
1187
+ }
1188
+ if (!retryable) throw error;
1189
+ const remaining = transition.deadlineMs - transition.nowMs();
1190
+ if (remaining <= 0) break;
1191
+ await transition.sleep(Math.min(retryMs, remaining));
1192
+ retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
1193
+ }
1194
+ throw new Error(
1195
+ `launchctl bootstrap did not converge within ${TRANSITION_TIMEOUT_MS}ms${lastError ? `: ${lastError.message}` : ""}`
1196
+ );
1197
+ }
1198
+ async function reloadDefinition(transition, oldProgram) {
1199
+ const result = transition.runner(
1200
+ ["bootout", transition.paths.serviceTarget],
1201
+ timeoutMs(transition.nowMs, transition.deadlineMs)
1202
+ );
1203
+ if (result.status !== 0) {
1204
+ const error = new LaunchctlError("bootout", result);
1205
+ const observed = await tryObserve(transition);
1206
+ const retryable = error.status === TRANSITION_IN_PROGRESS || error.timedOut;
1207
+ if (observed?.service.loaded) {
1208
+ if (observed.service.program === transition.launcherPath) {
1209
+ if (desiredDaemonIsRunning(transition, observed)) return;
1210
+ if (retryable) return;
1211
+ }
1212
+ if (observed.service.program !== oldProgram || !retryable) throw error;
1213
+ } else if (!observed && !retryable) {
1214
+ throw error;
1215
+ }
1216
+ }
1217
+ await bootstrap(transition, oldProgram);
1218
+ }
1219
+ async function waitForDesired(transition, repairMismatch = false) {
1220
+ let observed = { service: { loaded: false }, identity: null };
1221
+ let repaired = false;
1222
+ do {
1223
+ const current = await tryObserve(transition);
1224
+ if (current) {
1225
+ observed = current;
1226
+ if (desiredDaemonIsRunning(transition, observed)) return observed;
1227
+ if (observed.service.loaded && observed.service.program !== transition.launcherPath) {
1228
+ throw new Error(`launchd registered unexpected program ${observed.service.program}`);
1229
+ }
1230
+ if (repairMismatch && !repaired && observed.service.loaded && observed.service.program === transition.launcherPath && observed.identity) {
1231
+ repaired = true;
1232
+ await kickstart(transition);
1233
+ }
1234
+ }
1235
+ const remaining = transition.deadlineMs - transition.nowMs();
1236
+ if (remaining <= 0) break;
1237
+ await transition.sleep(Math.min(READY_POLL_MS, remaining));
1238
+ } while (transition.nowMs() < transition.deadlineMs);
1239
+ return observed;
1240
+ }
1241
+ async function kickstart(transition) {
1242
+ const result = transition.runner(
1243
+ ["kickstart", "-k", transition.paths.serviceTarget],
1244
+ timeoutMs(transition.nowMs, transition.deadlineMs)
1245
+ );
1246
+ if (result.status === 0) return;
1247
+ const error = new LaunchctlError("kickstart", result);
1248
+ const observed = await tryObserve(transition);
1249
+ if (observed && desiredDaemonIsRunning(transition, observed)) return;
1250
+ if (!error.timedOut) throw error;
1251
+ const ready = await waitForDesired(transition);
1252
+ if (!desiredDaemonIsRunning(transition, ready)) throw error;
1253
+ }
1254
+ async function waitForAbsence(context) {
1255
+ let lastError = null;
1256
+ while (context.nowMs() < context.deadlineMs) {
1257
+ try {
1258
+ const service = getLaunchdService({
1259
+ ...context.options,
1260
+ runner: context.runner,
1261
+ timeoutMs: timeoutMs(context.nowMs, context.deadlineMs)
1262
+ });
1263
+ if (!service.loaded) {
1264
+ const identity = await context.inspect(
1265
+ timeoutMs(context.nowMs, context.deadlineMs, READY_PROBE_TIMEOUT_MS)
1266
+ );
1267
+ const socketReleased = context.previousPid === void 0 || identity?.pid !== context.previousPid;
1268
+ const processExited = context.previousPid === void 0 || !processIsAlive(context.previousPid);
1269
+ if (socketReleased && processExited) return;
1270
+ }
1271
+ } catch (error) {
1272
+ if (!(error instanceof LaunchctlError)) throw error;
1273
+ lastError = error;
1274
+ }
1275
+ const remaining = context.deadlineMs - context.nowMs();
1276
+ if (remaining <= 0) break;
1277
+ await context.sleep(Math.min(READY_POLL_MS, remaining));
958
1278
  }
959
- return probe(READY_PROBE_TIMEOUT_MS);
1279
+ throw new Error(
1280
+ `launchctl bootout did not converge within ${TRANSITION_TIMEOUT_MS}ms${lastError ? `: ${lastError.message}` : ""}`
1281
+ );
960
1282
  }
961
1283
  function daemonExplicitlyDisabled(options = {}) {
962
- return existsSync2(runtimePaths(options).disabledMarker);
1284
+ const paths = daemonControlPaths(options);
1285
+ return existsSync2(paths.disabledMarker) || existsSync2(paths.legacyDisabledMarker);
963
1286
  }
964
1287
  function setDaemonExplicitlyDisabled(disabled, options = {}) {
965
- const marker = runtimePaths(options).disabledMarker;
1288
+ const paths = daemonControlPaths(options);
966
1289
  if (!disabled) {
967
- rmSync(marker, { force: true });
1290
+ rmSync(paths.disabledMarker, { force: true });
1291
+ rmSync(paths.legacyDisabledMarker, { force: true });
968
1292
  return;
969
1293
  }
970
- mkdirSync(dirname(marker), { recursive: true, mode: RUNTIME_DIR_MODE });
971
- writeFileSync(marker, "disabled by `prim daemon stop`\n", {
972
- encoding: "utf8",
973
- mode: RUNTIME_FILE_MODE
974
- });
975
- chmodSync(marker, RUNTIME_FILE_MODE);
1294
+ const content = "disabled by `prim daemon stop`\n";
1295
+ if (paths.legacyDisabledMarker !== paths.disabledMarker) {
1296
+ atomicWrite(paths.legacyDisabledMarker, content, RUNTIME_FILE_MODE);
1297
+ }
1298
+ atomicWrite(paths.disabledMarker, content, RUNTIME_FILE_MODE);
976
1299
  }
977
1300
  async function ensureMacDaemon(options = {}) {
978
1301
  return withDaemonLifecycleLock(() => ensureMacDaemonLocked(options), options);
979
1302
  }
980
1303
  async function ensureMacDaemonLocked(options) {
1304
+ const runner = options.runner ?? runLaunchctl;
1305
+ let service = getLaunchdService({ ...options, runner });
981
1306
  if (!options.explicitlyStarted && daemonExplicitlyDisabled(options)) {
1307
+ const control2 = daemonControlPaths(options);
1308
+ if (!existsSync2(control2.disabledMarker)) {
1309
+ atomicWrite(control2.disabledMarker, "disabled by `prim daemon stop`\n", RUNTIME_FILE_MODE);
1310
+ }
982
1311
  return {
983
1312
  state: "disabled",
984
1313
  action: "none",
985
1314
  runtimeChanged: false,
986
1315
  plistChanged: false,
987
1316
  responding: false,
988
- service: getLaunchdService(options)
1317
+ service
989
1318
  };
990
1319
  }
991
1320
  if (options.explicitlyStarted) setDaemonExplicitlyDisabled(false, options);
992
- const runtime = stageRuntime(options);
1321
+ const control = daemonControlPaths(options);
1322
+ const requestedSource = options.daemonSource ?? binFile("prim-daemon-server");
1323
+ if (!options.version && !requestedSource) {
1324
+ throw new Error("cannot determine runtime version: daemon is unavailable");
1325
+ }
1326
+ const requestedVersion = options.version ?? findPackageVersion(requestedSource);
1327
+ const selected = options.explicitlyStarted ? null : readDaemonLauncher(control.launcher);
1328
+ const precedence = selected ? compareSemver(selected.runtimeVersion, requestedVersion) : null;
1329
+ const retainSelected = selected !== null && (precedence === 1 || precedence === void 0 && selected.runtimeVersion !== requestedVersion);
1330
+ let runtime;
1331
+ let daemonConfig;
1332
+ const configuredApiUrl = (options.env ?? process.env).PRIM_API_URL;
1333
+ const apiUrl = configuredApiUrl ? normalizeApiUrl(configuredApiUrl) || void 0 : void 0;
1334
+ if (retainSelected && selected) {
1335
+ const usable = readUsableDaemonConfig(control.launcher);
1336
+ if (!usable) {
1337
+ throw new Error(
1338
+ `selected daemon runtime ${selected.runtimeVersion} is unavailable or corrupt; refusing automatic downgrade to ${requestedVersion}`
1339
+ );
1340
+ }
1341
+ daemonConfig = {
1342
+ nodePath: usable.nodePath,
1343
+ daemonPath: usable.daemonPath,
1344
+ runtimeVersion: usable.runtimeVersion,
1345
+ ...apiUrl ? { apiUrl } : {}
1346
+ };
1347
+ } else {
1348
+ runtime = stageRuntime({ ...options, version: requestedVersion });
1349
+ daemonConfig = {
1350
+ nodePath: runtime.manifest.nodePath,
1351
+ daemonPath: runtime.daemonPath,
1352
+ runtimeVersion: runtime.manifest.version,
1353
+ ...apiUrl ? { apiUrl } : {}
1354
+ };
1355
+ }
993
1356
  const servicePaths = launchdPaths(options);
994
1357
  mkdirSync(dirname(servicePaths.logPath), { recursive: true, mode: RUNTIME_DIR_MODE });
995
1358
  writeFileSync(servicePaths.logPath, "", { mode: RUNTIME_FILE_MODE, flag: "a" });
996
1359
  chmodSync(servicePaths.logPath, RUNTIME_FILE_MODE);
1360
+ const launcher = generateDaemonLauncher(daemonConfig);
1361
+ atomicWrite(control.launcher, launcher.content, RUNTIME_LAUNCHER_MODE);
997
1362
  const plist = generateLaunchAgentPlist({
998
- nodePath: runtime.manifest.nodePath,
999
- daemonPath: runtime.paths.daemonEntry,
1000
- runtimeVersion: runtime.manifest.version,
1363
+ launcherPath: control.launcher,
1001
1364
  logPath: servicePaths.logPath,
1002
1365
  workingDirectory: options.homeDir ?? homedir(),
1003
- apiUrl: (options.env ?? process.env).PRIM_API_URL
1366
+ label: options.label
1004
1367
  });
1005
- const plistChanged = atomicWrite(servicePaths.plistPath, plist, PLIST_FILE_MODE);
1006
- const runner = options.runner ?? runLaunchctl;
1007
- let service = getLaunchdService({ ...options, runner });
1368
+ const plistChanged = atomicWrite(
1369
+ servicePaths.plistPath,
1370
+ plist,
1371
+ PLIST_FILE_MODE,
1372
+ options.validatePlist ?? validatePlist
1373
+ );
1374
+ const sleep2 = options.sleep ?? defaultSleep;
1375
+ const nowMs = options.nowMs ?? performance.now.bind(performance);
1376
+ const deadlineMs = nowMs() + TRANSITION_TIMEOUT_MS;
1377
+ const inspect = options.inspectDaemon ?? inspectDaemon;
1378
+ const transition = {
1379
+ paths: servicePaths,
1380
+ runner,
1381
+ inspect,
1382
+ sleep: sleep2,
1383
+ nowMs,
1384
+ deadlineMs,
1385
+ launcherPath: control.launcher,
1386
+ version: daemonConfig.runtimeVersion,
1387
+ revision: launcher.revision
1388
+ };
1389
+ const migrateLegacy = options.migrateLegacy ?? (() => stopVerifiedLegacyDaemon(options.homeDir ?? homedir(), deadlineMs, nowMs, sleep2));
1008
1390
  let action = "none";
1009
- if (service.loaded && plistChanged) {
1010
- launchctlOrThrow(runner, ["bootout", servicePaths.serviceTarget], "bootout");
1011
- launchctlOrThrow(
1012
- runner,
1013
- ["bootstrap", servicePaths.domainTarget, servicePaths.plistPath],
1014
- "bootstrap"
1015
- );
1016
- action = "reload";
1017
- } else if (!service.loaded) {
1018
- if (await (options.probe ?? daemonIsLive)(READY_PROBE_TIMEOUT_MS)) {
1019
- await (options.migrateLegacy ?? (() => stopVerifiedLegacyDaemon(options.homeDir ?? homedir())))();
1391
+ const observed = await observe(transition);
1392
+ service = observed.service;
1393
+ if (!service.loaded) {
1394
+ if (await inspect(timeoutMs(nowMs, deadlineMs, READY_PROBE_TIMEOUT_MS))) {
1395
+ await migrateLegacy();
1020
1396
  }
1021
- launchctlOrThrow(
1022
- runner,
1023
- ["bootstrap", servicePaths.domainTarget, servicePaths.plistPath],
1024
- "bootstrap"
1025
- );
1397
+ await bootstrap(transition);
1026
1398
  action = "bootstrap";
1027
- } else {
1028
- const healthy = await (options.probe ?? daemonIsLive)(READY_PROBE_TIMEOUT_MS);
1029
- const socketPid2 = healthy ? await (options.identify ?? identifyDaemon)(READY_PROBE_TIMEOUT_MS) : null;
1030
- const ownsSocket = service.pid !== void 0 && socketPid2 !== null && service.pid === socketPid2;
1031
- if (healthy && socketPid2 !== null && !ownsSocket) {
1032
- await (options.migrateLegacy ?? (() => stopVerifiedLegacyDaemon(options.homeDir ?? homedir())))();
1033
- }
1034
- if (runtime.changed || options.forceRestart || !healthy || !ownsSocket) {
1035
- launchctlOrThrow(runner, ["kickstart", "-k", servicePaths.serviceTarget], "kickstart");
1036
- action = "kickstart";
1037
- }
1038
- }
1039
- const responding = await waitForReady(
1040
- options.probe ?? daemonIsLive,
1041
- options.sleep ?? defaultSleep
1042
- );
1043
- service = getLaunchdService({ ...options, runner });
1044
- const socketPid = responding ? await (options.identify ?? identifyDaemon)(READY_PROBE_TIMEOUT_MS) : null;
1045
- const running = responding && service.loaded && service.pid !== void 0 && socketPid !== null && service.pid === socketPid;
1399
+ } else if (service.program !== control.launcher) {
1400
+ if (!service.program) throw new Error("launchd did not report the registered program");
1401
+ await reloadDefinition(transition, service.program);
1402
+ action = "reload";
1403
+ } else if (options.forceRestart || !desiredDaemonIsRunning(transition, observed)) {
1404
+ await kickstart(transition);
1405
+ action = "kickstart";
1406
+ }
1407
+ const ready = await waitForDesired(transition, action === "bootstrap" || action === "reload");
1408
+ service = ready.service;
1409
+ const responding = ready.identity !== null;
1410
+ const running = desiredDaemonIsRunning(transition, ready);
1046
1411
  return {
1047
1412
  state: running ? "running" : "unhealthy",
1048
1413
  action,
1049
- runtimeChanged: runtime.changed,
1414
+ runtimeChanged: runtime?.changed ?? false,
1050
1415
  plistChanged,
1051
1416
  responding,
1052
- ...socketPid === null ? {} : { socketPid },
1417
+ ...ready.identity ? { socketPid: ready.identity.pid } : {},
1053
1418
  service,
1054
- runtime
1419
+ ...runtime ? { runtime } : {}
1055
1420
  };
1056
1421
  }
1057
1422
  async function bootoutMacDaemon(options = {}) {
@@ -1060,14 +1425,39 @@ async function bootoutMacDaemon(options = {}) {
1060
1425
  async function bootoutMacDaemonLocked(options) {
1061
1426
  setDaemonExplicitlyDisabled(true, options);
1062
1427
  const runner = options.runner ?? runLaunchctl;
1428
+ const inspect = options.inspectDaemon ?? inspectDaemon;
1429
+ const sleep2 = options.sleep ?? defaultSleep;
1430
+ const nowMs = options.nowMs ?? performance.now.bind(performance);
1431
+ const deadlineMs = nowMs() + TRANSITION_TIMEOUT_MS;
1063
1432
  const paths = launchdPaths(options);
1433
+ const migrateLegacy = options.migrateLegacy ?? (() => stopVerifiedLegacyDaemon(options.homeDir ?? homedir(), deadlineMs, nowMs, sleep2));
1064
1434
  const service = getLaunchdService({ ...options, runner });
1065
- if (service.loaded) {
1066
- launchctlOrThrow(runner, ["bootout", paths.serviceTarget], "bootout");
1067
- return { wasLoaded: true, legacyStopped: false, service };
1435
+ const wasLoaded = service.loaded;
1436
+ if (wasLoaded) {
1437
+ const result = runner(["bootout", paths.serviceTarget], timeoutMs(nowMs, deadlineMs));
1438
+ if (result.status !== 0) {
1439
+ const error = new LaunchctlError("bootout", result);
1440
+ const after = getLaunchdService({
1441
+ ...options,
1442
+ runner,
1443
+ timeoutMs: timeoutMs(nowMs, deadlineMs)
1444
+ });
1445
+ if (!error.timedOut && error.status !== TRANSITION_IN_PROGRESS && after.loaded) {
1446
+ throw error;
1447
+ }
1448
+ }
1449
+ await waitForAbsence({
1450
+ options,
1451
+ runner,
1452
+ inspect,
1453
+ sleep: sleep2,
1454
+ nowMs,
1455
+ deadlineMs,
1456
+ previousPid: service.pid
1457
+ });
1068
1458
  }
1069
- const legacyStopped = await (options.probe ?? daemonIsLive)(READY_PROBE_TIMEOUT_MS) ? await (options.migrateLegacy ?? (() => stopVerifiedLegacyDaemon(options.homeDir ?? homedir())))() : false;
1070
- return { wasLoaded: false, legacyStopped, service };
1459
+ const legacyStopped = await inspect(timeoutMs(nowMs, deadlineMs, READY_PROBE_TIMEOUT_MS)) ? await migrateLegacy() : false;
1460
+ return { wasLoaded, legacyStopped, service };
1071
1461
  }
1072
1462
 
1073
1463
  // src/commands/claude-install.ts
@@ -1703,7 +2093,7 @@ var LOG_FILE_MODE = 384;
1703
2093
  var STOP_TIMEOUT_MS = 5e3;
1704
2094
  var STOP_POLL_MS = 100;
1705
2095
  var STATUS_PROBE_TIMEOUT_MS = 500;
1706
- var READY_TIMEOUT_MS2 = 5e3;
2096
+ var READY_TIMEOUT_MS = 5e3;
1707
2097
  var READY_POLL_MS2 = 100;
1708
2098
  var READY_PROBE_TIMEOUT_MS2 = 250;
1709
2099
  var HEALTHY_TIMEOUT_MS = 3e4;
@@ -1717,13 +2107,6 @@ function formatDaemonLifecycleMessage(message, decisionIngestion) {
1717
2107
  function formatCurrentDaemonLifecycleMessage(message) {
1718
2108
  return formatDaemonLifecycleMessage(message, decisionIngestionStatus(process.cwd()));
1719
2109
  }
1720
- var MAX_HEALTH_ERROR_LENGTH = 240;
1721
- function boundedHealthError(value) {
1722
- if (!value) return void 0;
1723
- const clean = stripControlChars(value).replace(/\s+/g, " ").trim();
1724
- if (!clean) return void 0;
1725
- return clean.length <= MAX_HEALTH_ERROR_LENGTH ? clean : `${clean.slice(0, MAX_HEALTH_ERROR_LENGTH - 1)}\u2026`;
1726
- }
1727
2110
  function daemonDegradedReason(snapshot) {
1728
2111
  if (!snapshot || snapshot.healthy !== false) return void 0;
1729
2112
  if (snapshot.needsReauth) {
@@ -1783,8 +2166,8 @@ function openDaemonLog(configDir = CONFIG_DIR) {
1783
2166
  }
1784
2167
  return openSync2(join4(configDir, "daemon.log"), "a", LOG_FILE_MODE);
1785
2168
  }
1786
- async function waitForReady2() {
1787
- const deadline = Date.now() + READY_TIMEOUT_MS2;
2169
+ async function waitForReady() {
2170
+ const deadline = Date.now() + READY_TIMEOUT_MS;
1788
2171
  while (Date.now() < deadline) {
1789
2172
  if (await daemonIsLive(READY_PROBE_TIMEOUT_MS2)) {
1790
2173
  return true;
@@ -1888,7 +2271,7 @@ async function detachedDaemonStart(opts) {
1888
2271
  if (logFd !== void 0) {
1889
2272
  closeSync2(logFd);
1890
2273
  }
1891
- const live = await waitForReady2();
2274
+ const live = await waitForReady();
1892
2275
  if (live) {
1893
2276
  const after = readPidfile();
1894
2277
  process.stderr.write(
@@ -1899,7 +2282,7 @@ async function detachedDaemonStart(opts) {
1899
2282
  return;
1900
2283
  }
1901
2284
  process.stderr.write(
1902
- `[prim] \u2717 daemon start: spawned but the socket did not respond within ${READY_TIMEOUT_MS2}ms (check that \`${DAEMON_BIN}\` resolves, and see ${LOG_PATH})
2285
+ `[prim] \u2717 daemon start: spawned but the socket did not respond within ${READY_TIMEOUT_MS}ms (check that \`${DAEMON_BIN}\` resolves, and see ${LOG_PATH})
1903
2286
  `
1904
2287
  );
1905
2288
  console.log(JSON.stringify({ started: false }, null, 2));
@@ -1981,8 +2364,9 @@ async function macDaemonStart(forceRestart = false) {
1981
2364
  const healthy = daemonStartIsHealthy(serviceReady, snapshot, expectedVersion);
1982
2365
  if (healthy) {
1983
2366
  const verb = result.action === "none" ? "already running" : "started";
2367
+ const servicePid = result.service.loaded ? result.service.pid : void 0;
1984
2368
  process.stderr.write(
1985
- `${formatCurrentDaemonLifecycleMessage(`[prim] \u2713 daemon ${verb} under launchd (pid=${snapshot?.pid ?? result.service.pid ?? "?"})`)}
2369
+ `${formatCurrentDaemonLifecycleMessage(`[prim] \u2713 daemon ${verb} under launchd (pid=${snapshot?.pid ?? servicePid ?? "?"})`)}
1986
2370
  `
1987
2371
  );
1988
2372
  } else {
@@ -1999,7 +2383,7 @@ async function macDaemonStart(forceRestart = false) {
1999
2383
  started: healthy,
2000
2384
  supervised: true,
2001
2385
  action: result.action,
2002
- pid: snapshot?.pid ?? result.service.pid,
2386
+ pid: snapshot?.pid ?? (result.service.loaded ? result.service.pid : void 0),
2003
2387
  loaded: result.service.loaded,
2004
2388
  responding: result.responding,
2005
2389
  healthy,
@@ -2071,6 +2455,19 @@ function classifyStatus(pidAlive, responding, snapshot, pid) {
2071
2455
  return { json: { running: true, responding: true, ...snapshot }, exitCode: EXIT_OK2 };
2072
2456
  }
2073
2457
  function classifyLaunchdStatus(service, responding, snapshot, disabled) {
2458
+ if (!service) {
2459
+ return {
2460
+ json: {
2461
+ running: responding,
2462
+ responding,
2463
+ supervised: true,
2464
+ state: "unknown",
2465
+ disabled,
2466
+ ...snapshot ?? {}
2467
+ },
2468
+ exitCode: EXIT_BOOTING
2469
+ };
2470
+ }
2074
2471
  if (!service.loaded) {
2075
2472
  if (responding) {
2076
2473
  return {
@@ -2198,7 +2595,13 @@ async function detachedDaemonStatus() {
2198
2595
  }
2199
2596
  }
2200
2597
  async function macDaemonStatus() {
2201
- const service = getLaunchdService();
2598
+ let service;
2599
+ let launchdError;
2600
+ try {
2601
+ service = getLaunchdService();
2602
+ } catch (error) {
2603
+ launchdError = error;
2604
+ }
2202
2605
  const responding = await daemonIsLive(STATUS_PROBE_TIMEOUT_MS);
2203
2606
  const snapshot = responding ? await daemonRequest(
2204
2607
  "status_snapshot",
@@ -2207,7 +2610,13 @@ async function macDaemonStatus() {
2207
2610
  ) : null;
2208
2611
  const disabled = daemonExplicitlyDisabled();
2209
2612
  const { json, exitCode } = classifyLaunchdStatus(service, responding, snapshot, disabled);
2210
- if (!service.loaded && responding) {
2613
+ if (!service) {
2614
+ const detail = boundedHealthError(
2615
+ launchdError instanceof Error ? launchdError.message : String(launchdError)
2616
+ );
2617
+ process.stderr.write(`[prim] \u2717 launchd status unavailable${detail ? `: ${detail}` : ""}
2618
+ `);
2619
+ } else if (!service.loaded && responding) {
2211
2620
  process.stderr.write(
2212
2621
  `[prim] \u25CC daemon pid=${snapshot?.pid ?? "?"} is live but not supervised by launchd
2213
2622
  `
@@ -3347,8 +3756,13 @@ async function checkDaemon() {
3347
3756
  if (process.platform === "darwin") {
3348
3757
  try {
3349
3758
  service = getLaunchdService();
3350
- } catch {
3351
- service = { loaded: false };
3759
+ } catch (error) {
3760
+ const detail = boundedHealthError(error instanceof Error ? error.message : String(error));
3761
+ return {
3762
+ name: "daemon",
3763
+ status: "fail",
3764
+ detail: `launchd status unavailable${detail ? `: ${detail}` : ""}`
3765
+ };
3352
3766
  }
3353
3767
  }
3354
3768
  const snapshot = await daemonRequest(
@@ -3443,6 +3857,46 @@ function checkWorkspaceIdentity() {
3443
3857
  };
3444
3858
  }
3445
3859
  }
3860
+ function classifyRepositoryBinding(value) {
3861
+ return isValidRepoSyncId(value) ? {
3862
+ name: "repo-binding",
3863
+ status: "ok",
3864
+ detail: "valid local repository binding ready"
3865
+ } : {
3866
+ name: "repo-binding",
3867
+ status: "fail",
3868
+ detail: "missing or invalid local prim.repoSyncId \u2014 run `prim enable`"
3869
+ };
3870
+ }
3871
+ function checkRepositoryBinding() {
3872
+ return classifyRepositoryBinding(repoSyncId(process.cwd()));
3873
+ }
3874
+ function classifyPostCommitHook(inspection) {
3875
+ if (inspection.covered) {
3876
+ return {
3877
+ name: "post-commit",
3878
+ status: "ok",
3879
+ detail: `effective and executable \xB7 ${inspection.kind} \xB7 ${inspection.hookPath}`
3880
+ };
3881
+ }
3882
+ return {
3883
+ name: "post-commit",
3884
+ status: "fail",
3885
+ detail: `${inspection.reason ?? "uncovered"} \xB7 ${inspection.hookPath}`
3886
+ };
3887
+ }
3888
+ function checkPostCommitHook() {
3889
+ try {
3890
+ return classifyPostCommitHook(inspectEffectivePostCommitHook());
3891
+ } catch (error) {
3892
+ const detail = error instanceof Error ? error.message : String(error);
3893
+ return {
3894
+ name: "post-commit",
3895
+ status: /not a git repository/iu.test(detail) ? "warn" : "fail",
3896
+ detail: detail.slice(0, 120)
3897
+ };
3898
+ }
3899
+ }
3446
3900
  function checkFeedbackHooks() {
3447
3901
  try {
3448
3902
  const status = performStatus();
@@ -3472,6 +3926,9 @@ function parseMovesStatus(value) {
3472
3926
  if (status.captureState !== "enabled" && status.captureState !== "disabled" || !nullableNumber(status.latestIngestAt) || !nullableNumber(status.latestClassificationAt) || !nullableNumber(status.highWaterMark) || typeof status.pendingSessionCount !== "number" || !Number.isInteger(status.pendingSessionCount) || status.pendingSessionCount < 0 || typeof status.sampled !== "boolean") {
3473
3927
  throw new Error("moves status returned an invalid response");
3474
3928
  }
3929
+ if (status.oldestPendingAt !== void 0 && !nullableNumber(status.oldestPendingAt) || status.oldestPendingAgeMs !== void 0 && !nullableNumber(status.oldestPendingAgeMs) || typeof status.oldestPendingAgeMs === "number" && status.oldestPendingAgeMs < 0 || status.pendingCommitCorrelationCount !== void 0 && (!Number.isInteger(status.pendingCommitCorrelationCount) || status.pendingCommitCorrelationCount < 0)) {
3930
+ throw new Error("moves status returned invalid pending-age fields");
3931
+ }
3475
3932
  return status;
3476
3933
  }
3477
3934
  function classifyMovesStatus(status) {
@@ -3482,10 +3939,11 @@ function classifyMovesStatus(status) {
3482
3939
  };
3483
3940
  let classification;
3484
3941
  if (status.pendingSessionCount > 0) {
3942
+ const oldest = typeof status.oldestPendingAgeMs === "number" ? ` \xB7 oldest ${String(Math.round(status.oldestPendingAgeMs / MS_PER_SECOND))}s` : typeof status.oldestPendingAt === "number" ? ` \xB7 oldest ${String(Math.max(0, Math.round((Date.now() - status.oldestPendingAt) / MS_PER_SECOND)))}s` : "";
3485
3943
  classification = {
3486
3944
  name: "classification",
3487
3945
  status: "warn",
3488
- detail: `${String(status.pendingSessionCount)} session(s) pending${status.sampled ? " in a bounded sample" : ""}`
3946
+ detail: `${String(status.pendingSessionCount)} session(s) pending${oldest}${status.sampled ? " in a bounded sample" : ""}`
3489
3947
  };
3490
3948
  } else if (status.sampled) {
3491
3949
  classification = {
@@ -3500,7 +3958,16 @@ function classifyMovesStatus(status) {
3500
3958
  detail: status.latestClassificationAt === null ? "no sessions awaiting classification" : `caught up \xB7 last ${new Date(status.latestClassificationAt).toISOString()}`
3501
3959
  };
3502
3960
  }
3503
- return [capture, classification];
3961
+ const correlation = status.pendingCommitCorrelationCount === void 0 ? void 0 : status.pendingCommitCorrelationCount > 0 ? {
3962
+ name: "commit-correlation",
3963
+ status: "warn",
3964
+ detail: `${String(status.pendingCommitCorrelationCount)} commit(s) awaiting evidence correlation`
3965
+ } : {
3966
+ name: "commit-correlation",
3967
+ status: "ok",
3968
+ detail: "caught up"
3969
+ };
3970
+ return correlation ? [capture, classification, correlation] : [capture, classification];
3504
3971
  }
3505
3972
  async function checkBackend() {
3506
3973
  try {
@@ -3549,6 +4016,8 @@ async function collectChecks() {
3549
4016
  checkStranded(),
3550
4017
  checkFeedbackHooks(),
3551
4018
  checkWorkspaceIdentity(),
4019
+ checkRepositoryBinding(),
4020
+ checkPostCommitHook(),
3552
4021
  ...backend,
3553
4022
  await checkFeedbackCapability()
3554
4023
  ];
@@ -3932,6 +4401,7 @@ import { Option as Option2 } from "commander";
3932
4401
  var PRE_COMMIT = { hookName: "pre-commit", binName: "prim-pre-commit" };
3933
4402
  var POST_COMMIT = { hookName: "post-commit", binName: "prim-post-commit" };
3934
4403
  var HOOKS = [PRE_COMMIT, POST_COMMIT];
4404
+ var GIT_TIMEOUT_MS = 1e3;
3935
4405
  function blockMarkers(spec) {
3936
4406
  return {
3937
4407
  start: `# >>> prim ${spec.hookName} hook >>>`,
@@ -3958,12 +4428,14 @@ ${hookShim(spec.binName)}
3958
4428
  `;
3959
4429
  }
3960
4430
  function huskyBlock(spec) {
4431
+ if (spec.hookName === POST_COMMIT.hookName) return postCommitHookBlock();
3961
4432
  const { start, end } = blockMarkers(spec);
3962
4433
  return `${start}
3963
4434
  ${hookShim(spec.binName)}
3964
4435
  ${end}`;
3965
4436
  }
3966
4437
  function gatedBlock(spec) {
4438
+ if (spec.hookName === POST_COMMIT.hookName) return postCommitHookBlock();
3967
4439
  const { start, end } = blockMarkers(spec);
3968
4440
  return `${start}
3969
4441
  ${gatedShim(spec.binName)}
@@ -3972,6 +4444,21 @@ ${end}`;
3972
4444
  function mergePrimBlock(hookPath, block, binName) {
3973
4445
  if (existsSync7(hookPath)) {
3974
4446
  const existing = readFileSync6(hookPath, "utf-8");
4447
+ const start = block.slice(0, block.indexOf("\n"));
4448
+ const end = block.slice(block.lastIndexOf("\n") + 1);
4449
+ const starts = existing.split(start).length - 1;
4450
+ const ends = existing.split(end).length - 1;
4451
+ if (starts !== 0 || ends !== 0) {
4452
+ if (starts !== 1 || ends !== 1 || existing.indexOf(end) < existing.indexOf(start)) {
4453
+ throw new Error(`malformed Prim hook markers in ${hookPath}`);
4454
+ }
4455
+ const from = existing.indexOf(start);
4456
+ const through = existing.indexOf(end) + end.length;
4457
+ const refreshed = existing.slice(0, from) + block + existing.slice(through);
4458
+ if (refreshed === existing) return false;
4459
+ writeFileSync4(hookPath, refreshed, { mode: 493 });
4460
+ return true;
4461
+ }
3975
4462
  if (containsPrimHook(existing, binName)) return false;
3976
4463
  const separator = existing.endsWith("\n") ? "\n" : "\n\n";
3977
4464
  writeFileSync4(hookPath, `${existing}${separator}${block}
@@ -4027,7 +4514,7 @@ function installToHusky(gitRoot, spec = PRE_COMMIT) {
4027
4514
  }
4028
4515
  }
4029
4516
  function installToDotGit(gitRoot, spec = PRE_COMMIT) {
4030
- const hooksDir = resolve2(gitRoot, ".git", "hooks");
4517
+ const hooksDir = projectHooksDir(gitRoot);
4031
4518
  const hookPath = resolve2(hooksDir, spec.hookName);
4032
4519
  if (!existsSync7(hooksDir)) {
4033
4520
  mkdirSync5(hooksDir, { recursive: true });
@@ -4045,6 +4532,18 @@ function installToDotGit(gitRoot, spec = PRE_COMMIT) {
4045
4532
  writeFileSync4(hookPath, dotGitScript(spec), { mode: 493 });
4046
4533
  console.log(`Installed ${spec.hookName} hook at ${hookPath}`);
4047
4534
  }
4535
+ function projectHooksDir(gitRoot) {
4536
+ const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
4537
+ cwd: gitRoot,
4538
+ encoding: "utf-8",
4539
+ stdio: ["ignore", "pipe", "ignore"],
4540
+ timeout: GIT_TIMEOUT_MS
4541
+ }).trim();
4542
+ if (commonDir === "") {
4543
+ throw new Error("git returned an empty common directory");
4544
+ }
4545
+ return resolve2(gitRoot, commonDir, "hooks");
4546
+ }
4048
4547
  var PRIM_GIT_HOOKS_DIR = join6(homedir6(), ".config", "prim", "git-hooks");
4049
4548
  function expandTilde(p) {
4050
4549
  return p.startsWith("~/") ? join6(homedir6(), p.slice(2)) : p;
@@ -4056,7 +4555,8 @@ function gitConfigGet(level) {
4056
4555
  try {
4057
4556
  return execFileSync("git", ["config", level, "--get", "core.hooksPath"], {
4058
4557
  encoding: "utf-8",
4059
- stdio: ["ignore", "pipe", "ignore"]
4558
+ stdio: ["ignore", "pipe", "ignore"],
4559
+ timeout: GIT_TIMEOUT_MS
4060
4560
  }).trim();
4061
4561
  } catch {
4062
4562
  return "";
@@ -4084,14 +4584,18 @@ var PASSTHROUGH_HOOKS = [
4084
4584
  ];
4085
4585
  function globalHookScript(spec) {
4086
4586
  const chainExit = spec.hookName === "pre-commit" ? "|| exit $?" : "|| true";
4587
+ const invocation = spec.hookName === POST_COMMIT.hookName ? postCommitHookBlock() : gatedShim(spec.binName);
4588
+ const beforeComments = spec.hookName === POST_COMMIT.hookName ? `${invocation}
4589
+ ` : "";
4590
+ const afterComments = spec.hookName === POST_COMMIT.hookName ? "" : `${invocation}
4591
+ `;
4087
4592
  return `#!/bin/sh
4088
- # prim global ${spec.hookName} hook (core.hooksPath) \u2014 managed by prim; do not edit.
4593
+ ${beforeComments}# prim global ${spec.hookName} hook (core.hooksPath) \u2014 managed by prim; do not edit.
4089
4594
  # Install/uninstall: prim hooks install|uninstall --scope user
4090
4595
  # Runs prim only where activated \u2014 'prim enable' (this repo) or
4091
4596
  # 'git config --global prim.active true' (every repo). Chains to the repo's own
4092
4597
  # hook regardless, so inactive repos are unaffected.
4093
- ${gatedShim(spec.binName)}
4094
- common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0
4598
+ ${afterComments}common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0
4095
4599
  repo_hook="$common_dir/hooks/${spec.hookName}"
4096
4600
  if [ -x "$repo_hook" ] && ! grep -q '${PRIM_MANAGED_MARK}' "$repo_hook" 2>/dev/null; then
4097
4601
  "$repo_hook" "$@" ${chainExit}
@@ -4116,18 +4620,29 @@ function writeOwnHooks() {
4116
4620
  mkdirSync5(PRIM_GIT_HOOKS_DIR, { recursive: true });
4117
4621
  }
4118
4622
  for (const spec of HOOKS) {
4119
- writeFileSync4(resolve2(PRIM_GIT_HOOKS_DIR, spec.hookName), globalHookScript(spec), {
4120
- mode: 493
4121
- });
4623
+ const path = resolve2(PRIM_GIT_HOOKS_DIR, spec.hookName);
4624
+ if (spec.hookName === POST_COMMIT.hookName) {
4625
+ ensurePostCommitHookAtPath(path, globalHookScript(spec));
4626
+ } else {
4627
+ writeFileSync4(path, globalHookScript(spec), { mode: 493 });
4628
+ }
4122
4629
  }
4123
4630
  for (const name of PASSTHROUGH_HOOKS) {
4124
4631
  writeFileSync4(resolve2(PRIM_GIT_HOOKS_DIR, name), passThroughScript(name), { mode: 493 });
4125
4632
  }
4126
4633
  }
4127
4634
  function appendPrimBlock(hookPath, spec) {
4635
+ if (spec.hookName === POST_COMMIT.hookName) {
4636
+ ensurePostCommitHookAtPath(hookPath);
4637
+ return;
4638
+ }
4128
4639
  mergePrimBlock(hookPath, gatedBlock(spec), spec.binName);
4129
4640
  }
4130
4641
  function stripPrimBlock(hookPath, spec) {
4642
+ if (spec.hookName === POST_COMMIT.hookName) {
4643
+ uninstallPostCommitHookAtPath(hookPath);
4644
+ return;
4645
+ }
4131
4646
  if (!existsSync7(hookPath)) return;
4132
4647
  const existing = readFileSync6(hookPath, "utf-8");
4133
4648
  const primCreated = existing.includes(PRIM_CREATED_MARK);
@@ -4159,7 +4674,10 @@ function installGlobalHooks(opts = {}) {
4159
4674
  );
4160
4675
  }
4161
4676
  writeOwnHooks();
4162
- execFileSync("git", ["config", "--global", "core.hooksPath", PRIM_GIT_HOOKS_DIR]);
4677
+ execFileSync("git", ["config", "--global", "core.hooksPath", PRIM_GIT_HOOKS_DIR], {
4678
+ stdio: ["ignore", "ignore", "pipe"],
4679
+ timeout: GIT_TIMEOUT_MS
4680
+ });
4163
4681
  console.log(
4164
4682
  `Installed prim global git hooks; set core.hooksPath to ${PRIM_GIT_HOOKS_DIR}. Repos are opt-in: run \`prim enable\` in each repo to capture, or \`git config --global prim.active true\` for all.`
4165
4683
  );
@@ -4186,7 +4704,10 @@ function uninstallGlobalHooks() {
4186
4704
  const p = resolve2(PRIM_GIT_HOOKS_DIR, name);
4187
4705
  if (existsSync7(p)) unlinkSync2(p);
4188
4706
  }
4189
- execFileSync("git", ["config", "--global", "--unset", "core.hooksPath"]);
4707
+ execFileSync("git", ["config", "--global", "--unset", "core.hooksPath"], {
4708
+ stdio: ["ignore", "ignore", "pipe"],
4709
+ timeout: GIT_TIMEOUT_MS
4710
+ });
4190
4711
  console.log("Removed prim global git hooks and unset core.hooksPath.");
4191
4712
  return;
4192
4713
  }
@@ -4201,13 +4722,17 @@ function uninstallGlobalHooks() {
4201
4722
  console.log("No prim global git hooks found.");
4202
4723
  }
4203
4724
  function installHooks(gitRoot, target) {
4204
- for (const spec of HOOKS) {
4725
+ for (const spec of HOOKS.filter((candidate) => candidate !== POST_COMMIT)) {
4205
4726
  if (target === "husky") {
4206
4727
  installToHusky(gitRoot, spec);
4207
4728
  } else {
4208
4729
  installToDotGit(gitRoot, spec);
4209
4730
  }
4210
4731
  }
4732
+ const postCommit = ensureEffectivePostCommitHook(gitRoot);
4733
+ console.log(
4734
+ `${postCommit.changed ? "Installed" : "Refreshed"} effective post-commit hook at ${postCommit.path}.`
4735
+ );
4211
4736
  }
4212
4737
  function registerHooksCommands(program2) {
4213
4738
  const hooks = program2.command("hooks").description("Manage git hooks");
@@ -4269,8 +4794,9 @@ function registerHooksCommands(program2) {
4269
4794
  return;
4270
4795
  }
4271
4796
  const gitRoot = getGitRoot();
4272
- for (const spec of HOOKS) {
4273
- const hookPath = resolve2(gitRoot, ".git", "hooks", spec.hookName);
4797
+ const hooksDir = projectHooksDir(gitRoot);
4798
+ for (const spec of HOOKS.filter((candidate) => candidate !== POST_COMMIT)) {
4799
+ const hookPath = resolve2(hooksDir, spec.hookName);
4274
4800
  if (!existsSync7(hookPath)) {
4275
4801
  console.log(`No ${spec.hookName} hook found.`);
4276
4802
  continue;
@@ -4282,6 +4808,14 @@ function registerHooksCommands(program2) {
4282
4808
  console.log(`Left ${spec.hookName} hook at ${hookPath} untouched (not a prim hook).`);
4283
4809
  }
4284
4810
  }
4811
+ const postCommit = uninstallProjectPostCommitHook(gitRoot);
4812
+ if (!postCommit.changed) {
4813
+ console.log(`No Prim post-commit block found at ${postCommit.path}.`);
4814
+ } else if (postCommit.removedFile) {
4815
+ console.log(`Removed Prim-created post-commit hook at ${postCommit.path}.`);
4816
+ } else {
4817
+ console.log(`Removed the Prim post-commit block from ${postCommit.path}.`);
4818
+ }
4285
4819
  });
4286
4820
  }
4287
4821
 
@@ -4725,12 +5259,6 @@ function planSetupSteps(opts) {
4725
5259
  args: ["daemon", "start"],
4726
5260
  required: true
4727
5261
  });
4728
- steps.push({
4729
- key: "health",
4730
- label: "Capture health",
4731
- args: ["doctor"],
4732
- required: true
4733
- });
4734
5262
  } else {
4735
5263
  steps.push({
4736
5264
  key: "daemon-opt-out",
@@ -4749,10 +5277,18 @@ function planSetupSteps(opts) {
4749
5277
  steps.push({ key: "skill", label: "Agent skill", args: skillArgs, required: true });
4750
5278
  steps.push({
4751
5279
  key: "enable",
4752
- label: "Bind and activate this repo",
5280
+ label: "Activate this repo",
4753
5281
  args: ["enable"],
4754
5282
  required: true
4755
5283
  });
5284
+ if (opts.daemon) {
5285
+ steps.push({
5286
+ key: "health",
5287
+ label: "Capture health",
5288
+ args: ["doctor"],
5289
+ required: true
5290
+ });
5291
+ }
4756
5292
  return steps;
4757
5293
  }
4758
5294
  var CONFLICT_SESSION = "session";
@@ -4890,7 +5426,10 @@ function registerSetupCommand(program2, dependencies = {}) {
4890
5426
  note("pre-authorize \xB7 writing prim allow-rule (user scope)\u2026");
4891
5427
  results.preauth = run(["claude", "preauth", "--scope", "user"]).code === 0 ? "ok" : "skipped";
4892
5428
  }
4893
- for (const step of planSetupSteps({ agent, daemon: opts.daemon, scope })) {
5429
+ const setupSteps = planSetupSteps({ agent, daemon: opts.daemon, scope });
5430
+ for (const step of setupSteps.filter(
5431
+ (candidate) => candidate.key !== "enable" && candidate.key !== "health"
5432
+ )) {
4894
5433
  note(`${step.label} \xB7 installing\u2026`);
4895
5434
  const { code } = run(step.args);
4896
5435
  results[step.key] = code === 0 ? "ok" : step.required ? "failed" : "skipped";
@@ -4907,6 +5446,18 @@ function registerSetupCommand(program2, dependencies = {}) {
4907
5446
  );
4908
5447
  }
4909
5448
  }
5449
+ const enableStep = setupSteps.find((candidate) => candidate.key === "enable");
5450
+ if (enableStep) {
5451
+ note(`${enableStep.label} \xB7 installing\u2026`);
5452
+ const { code } = run(enableStep.args);
5453
+ results[enableStep.key] = code === 0 ? "ok" : enableStep.required ? "failed" : "skipped";
5454
+ }
5455
+ const healthStep = setupSteps.find((candidate) => candidate.key === "health");
5456
+ if (healthStep) {
5457
+ note(`${healthStep.label} \xB7 verifying\u2026`);
5458
+ const { code } = run(healthStep.args);
5459
+ results[healthStep.key] = code === 0 ? "ok" : healthStep.required ? "failed" : "skipped";
5460
+ }
4910
5461
  note("welcome");
4911
5462
  results.welcome = run(["welcome", "--agent", agent]).code === 0 ? "ok" : "failed";
4912
5463
  const failed = Object.entries(results).filter(([, v]) => v === "failed").map(([k]) => k);