@primitive.ai/prim 0.1.0-alpha.59 → 0.1.0-alpha.61

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,
@@ -23,9 +24,11 @@ import {
23
24
  readMovesFromPath
24
25
  } from "./chunk-S4LFRVGD.js";
25
26
  import {
27
+ bindRepository,
28
+ compareSemver,
26
29
  printJson,
27
30
  registerSkillCommands
28
- } from "./chunk-UBEBEBLM.js";
31
+ } from "./chunk-N4R7EXZT.js";
29
32
  import {
30
33
  fetchFeedbackCapability
31
34
  } from "./chunk-R4CUX6FY.js";
@@ -45,11 +48,9 @@ import {
45
48
  canonicalRepositoryPath,
46
49
  decisionIngestionStatus,
47
50
  gitToplevel,
48
- githubRepositoryFullName,
49
51
  isRepoActiveForCapture,
50
52
  repoSyncId,
51
- setRepoActive,
52
- setRepoSyncId
53
+ setRepoActive
53
54
  } from "./chunk-LDQZASIF.js";
54
55
  import {
55
56
  HttpError,
@@ -91,18 +92,7 @@ async function applyActivation(active) {
91
92
  try {
92
93
  let bound;
93
94
  if (active) {
94
- const repositoryFullName = githubRepositoryFullName(root);
95
- if (!repositoryFullName) {
96
- throw new Error("origin must be a GitHub HTTPS/SSH remote in owner/name form");
97
- }
98
- const response = await getClient().post("/api/cli/repositories/bind", {
99
- repositoryFullName
100
- });
101
- if (typeof response.repoSyncId !== "string" || response.repoSyncId.length === 0) {
102
- throw new Error("server returned no repository binding");
103
- }
104
- setRepoSyncId(root, response.repoSyncId);
105
- bound = { repoSyncId: response.repoSyncId, repositoryFullName };
95
+ bound = await bindRepository(root);
106
96
  }
107
97
  setRepoActive(root, active);
108
98
  process.stderr.write(`[prim] prim ${active ? "enabled" : "disabled"} in ${root}
@@ -648,28 +638,58 @@ import { dirname as dirname2, join as join2 } from "path";
648
638
  import { spawnSync } from "child_process";
649
639
  import { createHash as createHash2 } from "crypto";
650
640
  import {
641
+ constants,
642
+ accessSync,
651
643
  chmodSync,
652
644
  copyFileSync,
653
645
  existsSync as existsSync2,
654
646
  mkdirSync,
655
647
  mkdtempSync,
656
648
  readFileSync as readFileSync2,
649
+ realpathSync,
657
650
  renameSync,
658
651
  rmSync,
659
652
  symlinkSync,
660
653
  writeFileSync
661
654
  } from "fs";
662
655
  import { homedir } from "os";
663
- import { dirname, isAbsolute, join, resolve } from "path";
656
+ import { basename, dirname, isAbsolute, join, resolve } from "path";
657
+
658
+ // src/daemon/env-binding.ts
659
+ function normalizeApiUrl(url) {
660
+ return url.trim().replace(/\/+$/, "");
661
+ }
662
+
663
+ // src/daemon/launchd.ts
664
664
  var LAUNCHD_LABEL = "ai.getprimitive.prim-daemon";
665
- var RUNTIME_SCHEMA_VERSION = 1;
665
+ var RUNTIME_SCHEMA_VERSION = 2;
666
666
  var RUNTIME_DIR_MODE = 448;
667
667
  var RUNTIME_FILE_MODE = 384;
668
668
  var RUNTIME_LAUNCHER_MODE = 448;
669
669
  var PLIST_FILE_MODE = 384;
670
- var READY_TIMEOUT_MS = 15e3;
670
+ var LAUNCHER_SCHEMA_VERSION = 1;
671
+ var TRANSITION_TIMEOUT_MS = 15e3;
672
+ var COMMAND_TIMEOUT_MS = 5e3;
671
673
  var READY_POLL_MS = 100;
672
674
  var READY_PROBE_TIMEOUT_MS = 250;
675
+ var RETRY_MAX_MS = 500;
676
+ var SERVICE_NOT_FOUND = 113;
677
+ var TRANSITION_IN_PROGRESS = 5;
678
+ var OUTPUT_LIMIT = 4096;
679
+ var LaunchctlError = class extends Error {
680
+ status;
681
+ timedOut;
682
+ constructor(operation, result) {
683
+ const stdout = result.stdout.slice(0, OUTPUT_LIMIT);
684
+ const stderr = result.stderr.slice(0, OUTPUT_LIMIT);
685
+ const raw = stderr.trim() || stdout.trim() || `exit ${String(result.status)}`;
686
+ const detail = raw.replace(/\n?Try re-running the command as root for richer errors\.?/giu, "").trim() || `exit ${String(result.status)}`;
687
+ super(`launchctl ${operation} failed: ${detail}`);
688
+ this.name = "LaunchctlError";
689
+ this.status = result.status;
690
+ this.timedOut = result.timedOut === true;
691
+ }
692
+ };
673
693
  function xdgDataHome(options) {
674
694
  const home = options.homeDir ?? homedir();
675
695
  const configured = (options.env ?? process.env).XDG_DATA_HOME;
@@ -684,14 +704,22 @@ function runtimePaths(options = {}) {
684
704
  runtimeDir,
685
705
  releasesDir: join(runtimeDir, "releases"),
686
706
  currentLink,
687
- daemonEntry: join(currentLink, "prim-daemon-server"),
688
- statuslineEntry: join(currentLink, "prim-statusline"),
689
707
  statuslineLauncher: join(runtimeDir, "prim-statusline"),
690
- manifestPath: join(currentLink, "manifest.json"),
691
708
  disabledMarker: join(dataDir, "daemon.disabled"),
692
709
  lifecycleLockDir: join(dataDir, "daemon.lifecycle.lock")
693
710
  };
694
711
  }
712
+ function daemonControlPaths(options = {}) {
713
+ const configDir = join(options.homeDir ?? homedir(), ".config", "prim");
714
+ const legacy = runtimePaths(options);
715
+ return {
716
+ launcher: join(configDir, `prim-daemon-launcher-v${LAUNCHER_SCHEMA_VERSION}`),
717
+ disabledMarker: join(configDir, "daemon.disabled"),
718
+ lifecycleLockDir: join(configDir, "daemon.lifecycle.lock"),
719
+ legacyDisabledMarker: legacy.disabledMarker,
720
+ legacyLifecycleLockDir: legacy.lifecycleLockDir
721
+ };
722
+ }
695
723
  function findPackageVersion(entryFile) {
696
724
  let dir = dirname(resolve(entryFile));
697
725
  for (let depth = 0; depth < 6; depth++) {
@@ -714,7 +742,7 @@ function findPackageVersion(entryFile) {
714
742
  function readRuntimeManifest(path) {
715
743
  try {
716
744
  const parsed = JSON.parse(readFileSync2(path, "utf8"));
717
- if (parsed.schemaVersion !== RUNTIME_SCHEMA_VERSION || typeof parsed.version !== "string" || typeof parsed.nodePath !== "string" || parsed.daemonFile !== "prim-daemon-server") {
745
+ 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)) {
718
746
  return null;
719
747
  }
720
748
  return parsed;
@@ -722,10 +750,18 @@ function readRuntimeManifest(path) {
722
750
  return null;
723
751
  }
724
752
  }
725
- function sameRuntime(current, desired, paths) {
726
- return Boolean(
727
- current && current.version === desired.version && current.nodePath === desired.nodePath && current.statuslineFile === desired.statuslineFile && existsSync2(paths.daemonEntry) && (desired.statuslineFile === void 0 || existsSync2(paths.statuslineEntry))
728
- );
753
+ function sameRuntime(current, desired, daemonPath, statuslinePath) {
754
+ if (!current || current.version !== desired.version || current.nodePath !== desired.nodePath || current.daemonSha256 !== desired.daemonSha256 || current.statuslineFile !== desired.statuslineFile || current.statuslineSha256 !== desired.statuslineSha256) {
755
+ return false;
756
+ }
757
+ try {
758
+ return sha256File(daemonPath) === desired.daemonSha256 && (desired.statuslineSha256 === void 0 || sha256File(statuslinePath) === desired.statuslineSha256);
759
+ } catch {
760
+ return false;
761
+ }
762
+ }
763
+ function sha256File(path) {
764
+ return createHash2("sha256").update(readFileSync2(path)).digest("hex");
729
765
  }
730
766
  function atomicSymlink(target, linkPath) {
731
767
  const tempLink = `${linkPath}.tmp-${process.pid}-${createHash2("sha256").update(`${target}\0${Date.now()}\0${Math.random()}`).digest("hex").slice(0, 12)}`;
@@ -752,35 +788,60 @@ function stageRuntime(options = {}) {
752
788
  version,
753
789
  nodePath,
754
790
  daemonFile: "prim-daemon-server",
755
- ...statuslineSource ? { statuslineFile: "prim-statusline" } : {},
791
+ daemonSha256: sha256File(daemonSource),
792
+ ...statuslineSource ? {
793
+ statuslineFile: "prim-statusline",
794
+ statuslineSha256: sha256File(statuslineSource)
795
+ } : {},
756
796
  stagedAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString()
757
797
  };
758
- const current = readRuntimeManifest(paths.manifestPath);
759
- if (sameRuntime(current, desired, paths)) {
760
- if (current?.statuslineFile) {
798
+ let currentRelease = null;
799
+ try {
800
+ currentRelease = realpathSync(paths.currentLink);
801
+ } catch {
802
+ }
803
+ const current = currentRelease ? readRuntimeManifest(join(currentRelease, "manifest.json")) : null;
804
+ const currentDaemon = currentRelease ? join(currentRelease, desired.daemonFile) : "";
805
+ const currentStatusline = currentRelease ? join(currentRelease, "prim-statusline") : "";
806
+ if (currentRelease && sameRuntime(current, desired, currentDaemon, currentStatusline)) {
807
+ const manifest = current;
808
+ const statuslinePath = manifest.statuslineFile ? currentStatusline : void 0;
809
+ if (statuslinePath) {
761
810
  atomicWrite(
762
811
  paths.statuslineLauncher,
763
- statuslineLauncherContent(paths, current.nodePath),
812
+ statuslineLauncherContent(statuslinePath, manifest.nodePath),
764
813
  RUNTIME_LAUNCHER_MODE
765
814
  );
766
815
  } else {
767
816
  rmSync(paths.statuslineLauncher, { force: true });
768
817
  }
769
- return { changed: false, manifest: current, paths };
818
+ return {
819
+ changed: false,
820
+ manifest,
821
+ paths,
822
+ daemonPath: currentDaemon
823
+ };
770
824
  }
771
825
  mkdirSync(paths.releasesDir, { recursive: true, mode: RUNTIME_DIR_MODE });
772
826
  chmodSync(paths.runtimeDir, RUNTIME_DIR_MODE);
773
827
  chmodSync(paths.releasesDir, RUNTIME_DIR_MODE);
774
828
  const stagingDir = mkdtempSync(join(paths.releasesDir, ".stage-"));
775
829
  chmodSync(stagingDir, RUNTIME_DIR_MODE);
830
+ let releaseDir;
776
831
  try {
777
832
  const daemonTarget = join(stagingDir, desired.daemonFile);
778
833
  copyFileSync(daemonSource, daemonTarget);
779
834
  chmodSync(daemonTarget, RUNTIME_FILE_MODE);
835
+ if (sha256File(daemonTarget) !== desired.daemonSha256) {
836
+ throw new Error("cannot stage runtime: daemon bundle changed while it was copied");
837
+ }
780
838
  if (statuslineSource && desired.statuslineFile) {
781
839
  const statuslineTarget = join(stagingDir, desired.statuslineFile);
782
840
  copyFileSync(statuslineSource, statuslineTarget);
783
841
  chmodSync(statuslineTarget, RUNTIME_FILE_MODE);
842
+ if (sha256File(statuslineTarget) !== desired.statuslineSha256) {
843
+ throw new Error("cannot stage runtime: statusline bundle changed while it was copied");
844
+ }
784
845
  }
785
846
  const manifestTarget = join(stagingDir, "manifest.json");
786
847
  writeFileSync(manifestTarget, `${JSON.stringify(desired, null, 2)}
@@ -792,13 +853,15 @@ function stageRuntime(options = {}) {
792
853
  const releaseName = `release-${createHash2("sha256").update(
793
854
  `${version}\0${nodePath}\0${desired.statuslineFile ?? ""}\0${desired.stagedAt}\0${process.pid}\0${Math.random()}`
794
855
  ).digest("hex").slice(0, 16)}`;
795
- const releaseDir = join(paths.releasesDir, releaseName);
856
+ releaseDir = join(paths.releasesDir, releaseName);
796
857
  renameSync(stagingDir, releaseDir);
858
+ releaseDir = realpathSync(releaseDir);
797
859
  atomicSymlink(join("releases", releaseName), paths.currentLink);
798
860
  if (desired.statuslineFile) {
861
+ const statuslinePath = join(releaseDir, desired.statuslineFile);
799
862
  atomicWrite(
800
863
  paths.statuslineLauncher,
801
- statuslineLauncherContent(paths, desired.nodePath),
864
+ statuslineLauncherContent(statuslinePath, desired.nodePath),
802
865
  RUNTIME_LAUNCHER_MODE
803
866
  );
804
867
  } else {
@@ -808,36 +871,91 @@ function stageRuntime(options = {}) {
808
871
  rmSync(stagingDir, { recursive: true, force: true });
809
872
  throw error;
810
873
  }
811
- return { changed: true, manifest: desired, paths };
874
+ return {
875
+ changed: true,
876
+ manifest: desired,
877
+ paths,
878
+ daemonPath: join(releaseDir, desired.daemonFile)
879
+ };
812
880
  }
813
881
  function shellQuote(value) {
814
882
  return `'${value.replaceAll("'", `'"'"'`)}'`;
815
883
  }
816
- function statuslineLauncherContent(paths, nodePath) {
884
+ function statuslineLauncherContent(statuslinePath, nodePath) {
817
885
  return `#!/bin/sh
818
- exec ${shellQuote(nodePath)} ${shellQuote(paths.statuslineEntry)}
886
+ exec ${shellQuote(nodePath)} ${shellQuote(statuslinePath)}
819
887
  `;
820
888
  }
821
889
  function runtimeStatuslineCommand(options = {}) {
822
890
  return shellQuote(runtimePaths(options).statuslineLauncher);
823
891
  }
892
+ function generateDaemonLauncher(config) {
893
+ const apiUrl = config.apiUrl ? normalizeApiUrl(config.apiUrl) || void 0 : void 0;
894
+ const metadata = {
895
+ schemaVersion: LAUNCHER_SCHEMA_VERSION,
896
+ nodePath: config.nodePath,
897
+ daemonPath: config.daemonPath,
898
+ runtimeVersion: config.runtimeVersion,
899
+ apiUrl: apiUrl ?? null
900
+ };
901
+ const revision = createHash2("sha256").update(JSON.stringify(metadata)).digest("hex");
902
+ const encoded = Buffer.from(JSON.stringify({ ...metadata, revision })).toString("base64url");
903
+ return {
904
+ revision,
905
+ content: `#!/bin/sh
906
+ # prim-daemon-launcher: ${encoded}
907
+ export PRIM_RUNTIME_VERSION=${shellQuote(config.runtimeVersion)}
908
+ export PRIM_LAUNCH_REVISION=${shellQuote(revision)}
909
+ ${apiUrl ? `export PRIM_API_URL=${shellQuote(apiUrl)}` : "unset PRIM_API_URL"}
910
+ exec ${shellQuote(config.nodePath)} ${shellQuote(config.daemonPath)}
911
+ `
912
+ };
913
+ }
914
+ function readDaemonLauncher(path) {
915
+ try {
916
+ const content = readFileSync2(path, "utf8");
917
+ const encoded = /^# prim-daemon-launcher: ([A-Za-z0-9_-]+)$/mu.exec(content)?.[1];
918
+ if (!encoded) return null;
919
+ const value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
920
+ 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") {
921
+ return null;
922
+ }
923
+ const config = {
924
+ nodePath: value.nodePath,
925
+ daemonPath: value.daemonPath,
926
+ runtimeVersion: value.runtimeVersion,
927
+ ...value.apiUrl === null ? {} : { apiUrl: value.apiUrl }
928
+ };
929
+ const expected = generateDaemonLauncher(config);
930
+ return expected.revision === value.revision && expected.content === content ? config : null;
931
+ } catch {
932
+ return null;
933
+ }
934
+ }
935
+ function readUsableDaemonConfig(path) {
936
+ const config = readDaemonLauncher(path);
937
+ if (!config) return null;
938
+ const manifest = readRuntimeManifest(join(dirname(config.daemonPath), "manifest.json"));
939
+ try {
940
+ accessSync(config.nodePath, constants.X_OK);
941
+ return manifest?.version === config.runtimeVersion && manifest.nodePath === config.nodePath && manifest.daemonSha256 === sha256File(config.daemonPath) ? config : null;
942
+ } catch {
943
+ return null;
944
+ }
945
+ }
824
946
  function xmlEscape(value) {
825
947
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
826
948
  }
827
949
  function generateLaunchAgentPlist(config) {
828
- const apiUrl = config.apiUrl ? `
829
- <key>PRIM_API_URL</key>
830
- <string>${xmlEscape(config.apiUrl)}</string>` : "";
831
950
  return `<?xml version="1.0" encoding="UTF-8"?>
832
951
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
833
952
  <plist version="1.0">
834
953
  <dict>
835
954
  <key>Label</key>
836
- <string>${LAUNCHD_LABEL}</string>
955
+ <string>${xmlEscape(config.label ?? LAUNCHD_LABEL)}</string>
837
956
  <key>ProgramArguments</key>
838
957
  <array>
839
- <string>${xmlEscape(config.nodePath)}</string>
840
- <string>${xmlEscape(config.daemonPath)}</string>
958
+ <string>${xmlEscape(config.launcherPath)}</string>
841
959
  </array>
842
960
  <key>RunAtLoad</key>
843
961
  <true/>
@@ -847,6 +965,8 @@ function generateLaunchAgentPlist(config) {
847
965
  <integer>10</integer>
848
966
  <key>ProcessType</key>
849
967
  <string>Background</string>
968
+ <key>ExitTimeOut</key>
969
+ <integer>5</integer>
850
970
  <key>Umask</key>
851
971
  <integer>63</integer>
852
972
  <key>WorkingDirectory</key>
@@ -855,11 +975,6 @@ function generateLaunchAgentPlist(config) {
855
975
  <string>${xmlEscape(config.logPath)}</string>
856
976
  <key>StandardErrorPath</key>
857
977
  <string>${xmlEscape(config.logPath)}</string>
858
- <key>EnvironmentVariables</key>
859
- <dict>
860
- <key>PRIM_RUNTIME_VERSION</key>
861
- <string>${xmlEscape(config.runtimeVersion)}</string>${apiUrl}
862
- </dict>
863
978
  </dict>
864
979
  </plist>
865
980
  `;
@@ -867,45 +982,57 @@ function generateLaunchAgentPlist(config) {
867
982
  function launchdPaths(options = {}) {
868
983
  const home = options.homeDir ?? homedir();
869
984
  const uid = options.uid ?? process.getuid?.();
985
+ const label = options.label ?? LAUNCHD_LABEL;
870
986
  if (uid === void 0) throw new Error("cannot determine uid for launchd user domain");
871
987
  return {
872
988
  domainTarget: `gui/${uid}`,
873
- serviceTarget: `gui/${uid}/${LAUNCHD_LABEL}`,
874
- plistPath: join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`),
989
+ serviceTarget: `gui/${uid}/${label}`,
990
+ plistPath: join(home, "Library", "LaunchAgents", `${label}.plist`),
875
991
  logPath: join(home, ".config", "prim", "daemon.log")
876
992
  };
877
993
  }
878
- var runLaunchctl = (args) => {
879
- const result = spawnSync("/bin/launchctl", args, { encoding: "utf8" });
994
+ var runLaunchctl = (args, timeoutMs2 = COMMAND_TIMEOUT_MS) => {
995
+ const result = spawnSync("/bin/launchctl", args, { encoding: "utf8", timeout: timeoutMs2 });
996
+ const error = result.error;
880
997
  return {
881
998
  status: result.status,
882
999
  stdout: result.stdout ?? "",
883
- stderr: result.error?.message ?? result.stderr ?? ""
1000
+ stderr: error?.message ?? result.stderr ?? "",
1001
+ timedOut: error?.code === "ETIMEDOUT"
884
1002
  };
885
1003
  };
886
1004
  function parseLaunchdService(result) {
887
- if (result.status !== 0) return { loaded: false };
1005
+ if (result.status === SERVICE_NOT_FOUND) return { loaded: false };
1006
+ if (result.status !== 0) throw new LaunchctlError("print", result);
888
1007
  const state = /^\s*state = (.+?)\s*$/m.exec(result.stdout)?.[1];
889
1008
  const rawPid = /^\s*pid = (\d+)\s*$/m.exec(result.stdout)?.[1];
1009
+ const program2 = /^\s*program = (.+?)\s*$/m.exec(result.stdout)?.[1];
1010
+ if (!program2) throw new Error("launchctl print did not report the registered program");
890
1011
  const pid = rawPid ? Number(rawPid) : void 0;
891
- return { loaded: true, state, pid, raw: result.stdout };
1012
+ return { loaded: true, state, pid, program: program2 };
892
1013
  }
893
1014
  function getLaunchdService(options = {}) {
894
- const paths = launchdPaths(options);
895
- return parseLaunchdService((options.runner ?? runLaunchctl)(["print", paths.serviceTarget]));
1015
+ return parseLaunchdService(
1016
+ (options.runner ?? runLaunchctl)(
1017
+ ["print", launchdPaths(options).serviceTarget],
1018
+ options.timeoutMs ?? COMMAND_TIMEOUT_MS
1019
+ )
1020
+ );
896
1021
  }
897
- function atomicWrite(path, content, mode) {
1022
+ function atomicWrite(path, content, mode, validate) {
898
1023
  try {
899
1024
  if (readFileSync2(path, "utf8") === content) {
900
1025
  chmodSync(path, mode);
1026
+ validate?.(path, COMMAND_TIMEOUT_MS);
901
1027
  return false;
902
1028
  }
903
1029
  } catch {
904
1030
  }
905
- mkdirSync(dirname(path), { recursive: true });
1031
+ mkdirSync(dirname(path), { recursive: true, mode: RUNTIME_DIR_MODE });
906
1032
  const temp = `${path}.tmp-${process.pid}-${Date.now()}`;
907
1033
  writeFileSync(temp, content, { encoding: "utf8", mode, flag: "wx" });
908
1034
  try {
1035
+ validate?.(temp, COMMAND_TIMEOUT_MS);
909
1036
  renameSync(temp, path);
910
1037
  } catch (error) {
911
1038
  rmSync(temp, { force: true });
@@ -913,20 +1040,25 @@ function atomicWrite(path, content, mode) {
913
1040
  }
914
1041
  return true;
915
1042
  }
916
- function launchctlOrThrow(runner, args, operation) {
917
- const result = runner(args);
918
- if (result.status !== 0) {
919
- const detail = result.stderr.trim() || result.stdout.trim() || `exit ${String(result.status)}`;
920
- throw new Error(`launchctl ${operation} failed: ${detail}`);
921
- }
922
- return result;
923
- }
924
1043
  function defaultSleep(ms) {
925
1044
  return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
926
1045
  }
927
- async function identifyDaemon(timeoutMs) {
928
- const snapshot = await daemonRequest("status_snapshot", {}, { timeoutMs });
929
- return typeof snapshot?.pid === "number" ? snapshot.pid : null;
1046
+ function validatePlist(path, timeoutMs2) {
1047
+ if (process.platform !== "darwin") return;
1048
+ const result = spawnSync("/usr/bin/plutil", ["-lint", path], {
1049
+ encoding: "utf8",
1050
+ timeout: timeoutMs2
1051
+ });
1052
+ if (result.status !== 0) {
1053
+ throw new Error(
1054
+ `invalid launchd plist: ${result.error?.message ?? result.stderr?.trim() ?? result.stdout?.trim() ?? `exit ${String(result.status)}`}`
1055
+ );
1056
+ }
1057
+ }
1058
+ async function inspectDaemon(timeoutMs2) {
1059
+ const value = await daemonRequest("status_snapshot", {}, { timeoutMs: timeoutMs2 });
1060
+ if (!value || !Number.isInteger(value.pid) || (value.pid ?? 0) <= 0) return null;
1061
+ return value;
930
1062
  }
931
1063
  function processIsAlive(pid) {
932
1064
  try {
@@ -936,11 +1068,31 @@ function processIsAlive(pid) {
936
1068
  return error.code === "EPERM";
937
1069
  }
938
1070
  }
1071
+ function canonicalProspectivePath(path) {
1072
+ const suffix = [];
1073
+ let ancestor = resolve(path);
1074
+ while (!existsSync2(ancestor)) {
1075
+ const parent = dirname(ancestor);
1076
+ if (parent === ancestor) return resolve(path);
1077
+ suffix.unshift(basename(ancestor));
1078
+ ancestor = parent;
1079
+ }
1080
+ try {
1081
+ return join(realpathSync(ancestor), ...suffix);
1082
+ } catch {
1083
+ return resolve(path);
1084
+ }
1085
+ }
939
1086
  async function withDaemonLifecycleLock(operation, options = {}) {
940
- const lockDir = runtimePaths(options).lifecycleLockDir;
941
- return withFileLock(lockDir, operation, options.lifecycleLock);
1087
+ const paths = daemonControlPaths(options);
1088
+ const sameLock = canonicalProspectivePath(paths.lifecycleLockDir) === canonicalProspectivePath(paths.legacyLifecycleLockDir);
1089
+ return withFileLock(
1090
+ paths.lifecycleLockDir,
1091
+ () => sameLock ? operation() : withFileLock(paths.legacyLifecycleLockDir, operation, options.lifecycleLock),
1092
+ options.lifecycleLock
1093
+ );
942
1094
  }
943
- async function stopVerifiedLegacyDaemon(homeDir) {
1095
+ async function stopVerifiedLegacyDaemon(homeDir, deadlineMs, nowMs, sleep2) {
944
1096
  const pidPath = join(homeDir, ".config", "prim", "daemon.pid");
945
1097
  let pid;
946
1098
  try {
@@ -948,122 +1100,314 @@ async function stopVerifiedLegacyDaemon(homeDir) {
948
1100
  } catch {
949
1101
  throw new Error("an unsupervised daemon answered, but it has no verifiable pidfile");
950
1102
  }
951
- const snapshot = await daemonRequest("status_snapshot", {}, { timeoutMs: 500 });
1103
+ if (nowMs() >= deadlineMs) throw new Error("legacy verification exceeded the lifecycle deadline");
1104
+ const snapshot = await daemonRequest(
1105
+ "status_snapshot",
1106
+ {},
1107
+ {
1108
+ timeoutMs: timeoutMs(nowMs, deadlineMs, 500)
1109
+ }
1110
+ );
952
1111
  if (!Number.isInteger(pid) || pid <= 0 || !processIsAlive(pid) || snapshot?.pid !== pid) {
953
1112
  throw new Error("refusing to signal an unsupervised daemon whose pid cannot be verified");
954
1113
  }
1114
+ if (nowMs() >= deadlineMs) throw new Error("legacy verification exceeded the lifecycle deadline");
955
1115
  process.kill(pid, "SIGTERM");
956
- const deadline = Date.now() + 5e3;
957
- while (Date.now() < deadline && processIsAlive(pid)) {
958
- await defaultSleep(100);
1116
+ while (nowMs() < deadlineMs && processIsAlive(pid)) {
1117
+ await sleep2(Math.min(READY_POLL_MS, deadlineMs - nowMs()));
959
1118
  }
960
1119
  if (processIsAlive(pid)) {
961
- throw new Error(`verified legacy daemon pid=${pid} did not stop within 5000ms`);
1120
+ throw new Error(`verified legacy daemon pid=${pid} did not stop before the lifecycle deadline`);
962
1121
  }
963
1122
  return true;
964
1123
  }
965
- async function waitForReady(probe, sleep2) {
966
- const deadline = Date.now() + READY_TIMEOUT_MS;
967
- while (Date.now() < deadline) {
968
- if (await probe(READY_PROBE_TIMEOUT_MS)) return true;
969
- await sleep2(READY_POLL_MS);
1124
+ function timeoutMs(nowMs, deadlineMs, cap = COMMAND_TIMEOUT_MS) {
1125
+ return Math.max(1, Math.min(cap, Math.ceil(deadlineMs - nowMs())));
1126
+ }
1127
+ function desiredDaemonIsRunning(transition, observed) {
1128
+ const { service, identity } = observed;
1129
+ return Boolean(
1130
+ service.loaded && service.program === transition.launcherPath && service.pid !== void 0 && identity?.pid === service.pid && identity.version === transition.version && identity.launchRevision === transition.revision
1131
+ );
1132
+ }
1133
+ function getService(transition) {
1134
+ return parseLaunchdService(
1135
+ transition.runner(
1136
+ ["print", transition.paths.serviceTarget],
1137
+ timeoutMs(transition.nowMs, transition.deadlineMs)
1138
+ )
1139
+ );
1140
+ }
1141
+ async function observe(transition) {
1142
+ const service = getService(transition);
1143
+ const identity = service.loaded && service.program === transition.launcherPath ? await transition.inspect(
1144
+ timeoutMs(transition.nowMs, transition.deadlineMs, READY_PROBE_TIMEOUT_MS)
1145
+ ) : null;
1146
+ return { service, identity };
1147
+ }
1148
+ async function tryObserve(transition) {
1149
+ try {
1150
+ return await observe(transition);
1151
+ } catch (error) {
1152
+ if (!(error instanceof LaunchctlError)) throw error;
1153
+ return null;
970
1154
  }
971
- return probe(READY_PROBE_TIMEOUT_MS);
1155
+ }
1156
+ async function bootstrap(transition, oldProgram) {
1157
+ let retryMs = READY_POLL_MS;
1158
+ let lastError = null;
1159
+ while (transition.nowMs() < transition.deadlineMs) {
1160
+ const result = transition.runner(
1161
+ ["bootstrap", transition.paths.domainTarget, transition.paths.plistPath],
1162
+ timeoutMs(transition.nowMs, transition.deadlineMs)
1163
+ );
1164
+ if (result.status === 0) return;
1165
+ const error = new LaunchctlError("bootstrap", result);
1166
+ lastError = error;
1167
+ const observed = await tryObserve(transition);
1168
+ const retryable = error.status === TRANSITION_IN_PROGRESS || error.timedOut;
1169
+ if (observed?.service.loaded) {
1170
+ if (observed.service.program === transition.launcherPath) {
1171
+ if (desiredDaemonIsRunning(transition, observed)) return;
1172
+ if (retryable) return;
1173
+ } else if (observed.service.program !== oldProgram) {
1174
+ throw new Error(
1175
+ `refusing to replace unexpected launchd program ${observed.service.program}`
1176
+ );
1177
+ }
1178
+ }
1179
+ if (!retryable) throw error;
1180
+ const remaining = transition.deadlineMs - transition.nowMs();
1181
+ if (remaining <= 0) break;
1182
+ await transition.sleep(Math.min(retryMs, remaining));
1183
+ retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
1184
+ }
1185
+ throw new Error(
1186
+ `launchctl bootstrap did not converge within ${TRANSITION_TIMEOUT_MS}ms${lastError ? `: ${lastError.message}` : ""}`
1187
+ );
1188
+ }
1189
+ async function reloadDefinition(transition, oldProgram) {
1190
+ const result = transition.runner(
1191
+ ["bootout", transition.paths.serviceTarget],
1192
+ timeoutMs(transition.nowMs, transition.deadlineMs)
1193
+ );
1194
+ if (result.status !== 0) {
1195
+ const error = new LaunchctlError("bootout", result);
1196
+ const observed = await tryObserve(transition);
1197
+ const retryable = error.status === TRANSITION_IN_PROGRESS || error.timedOut;
1198
+ if (observed?.service.loaded) {
1199
+ if (observed.service.program === transition.launcherPath) {
1200
+ if (desiredDaemonIsRunning(transition, observed)) return;
1201
+ if (retryable) return;
1202
+ }
1203
+ if (observed.service.program !== oldProgram || !retryable) throw error;
1204
+ } else if (!observed && !retryable) {
1205
+ throw error;
1206
+ }
1207
+ }
1208
+ await bootstrap(transition, oldProgram);
1209
+ }
1210
+ async function waitForDesired(transition, repairMismatch = false) {
1211
+ let observed = { service: { loaded: false }, identity: null };
1212
+ let repaired = false;
1213
+ do {
1214
+ const current = await tryObserve(transition);
1215
+ if (current) {
1216
+ observed = current;
1217
+ if (desiredDaemonIsRunning(transition, observed)) return observed;
1218
+ if (observed.service.loaded && observed.service.program !== transition.launcherPath) {
1219
+ throw new Error(`launchd registered unexpected program ${observed.service.program}`);
1220
+ }
1221
+ if (repairMismatch && !repaired && observed.service.loaded && observed.service.program === transition.launcherPath && observed.identity) {
1222
+ repaired = true;
1223
+ await kickstart(transition);
1224
+ }
1225
+ }
1226
+ const remaining = transition.deadlineMs - transition.nowMs();
1227
+ if (remaining <= 0) break;
1228
+ await transition.sleep(Math.min(READY_POLL_MS, remaining));
1229
+ } while (transition.nowMs() < transition.deadlineMs);
1230
+ return observed;
1231
+ }
1232
+ async function kickstart(transition) {
1233
+ const result = transition.runner(
1234
+ ["kickstart", "-k", transition.paths.serviceTarget],
1235
+ timeoutMs(transition.nowMs, transition.deadlineMs)
1236
+ );
1237
+ if (result.status === 0) return;
1238
+ const error = new LaunchctlError("kickstart", result);
1239
+ const observed = await tryObserve(transition);
1240
+ if (observed && desiredDaemonIsRunning(transition, observed)) return;
1241
+ if (!error.timedOut) throw error;
1242
+ const ready = await waitForDesired(transition);
1243
+ if (!desiredDaemonIsRunning(transition, ready)) throw error;
1244
+ }
1245
+ async function waitForAbsence(context) {
1246
+ let lastError = null;
1247
+ while (context.nowMs() < context.deadlineMs) {
1248
+ try {
1249
+ const service = getLaunchdService({
1250
+ ...context.options,
1251
+ runner: context.runner,
1252
+ timeoutMs: timeoutMs(context.nowMs, context.deadlineMs)
1253
+ });
1254
+ if (!service.loaded) {
1255
+ const identity = await context.inspect(
1256
+ timeoutMs(context.nowMs, context.deadlineMs, READY_PROBE_TIMEOUT_MS)
1257
+ );
1258
+ const socketReleased = context.previousPid === void 0 || identity?.pid !== context.previousPid;
1259
+ const processExited = context.previousPid === void 0 || !processIsAlive(context.previousPid);
1260
+ if (socketReleased && processExited) return;
1261
+ }
1262
+ } catch (error) {
1263
+ if (!(error instanceof LaunchctlError)) throw error;
1264
+ lastError = error;
1265
+ }
1266
+ const remaining = context.deadlineMs - context.nowMs();
1267
+ if (remaining <= 0) break;
1268
+ await context.sleep(Math.min(READY_POLL_MS, remaining));
1269
+ }
1270
+ throw new Error(
1271
+ `launchctl bootout did not converge within ${TRANSITION_TIMEOUT_MS}ms${lastError ? `: ${lastError.message}` : ""}`
1272
+ );
972
1273
  }
973
1274
  function daemonExplicitlyDisabled(options = {}) {
974
- return existsSync2(runtimePaths(options).disabledMarker);
1275
+ const paths = daemonControlPaths(options);
1276
+ return existsSync2(paths.disabledMarker) || existsSync2(paths.legacyDisabledMarker);
975
1277
  }
976
1278
  function setDaemonExplicitlyDisabled(disabled, options = {}) {
977
- const marker = runtimePaths(options).disabledMarker;
1279
+ const paths = daemonControlPaths(options);
978
1280
  if (!disabled) {
979
- rmSync(marker, { force: true });
1281
+ rmSync(paths.disabledMarker, { force: true });
1282
+ rmSync(paths.legacyDisabledMarker, { force: true });
980
1283
  return;
981
1284
  }
982
- mkdirSync(dirname(marker), { recursive: true, mode: RUNTIME_DIR_MODE });
983
- writeFileSync(marker, "disabled by `prim daemon stop`\n", {
984
- encoding: "utf8",
985
- mode: RUNTIME_FILE_MODE
986
- });
987
- chmodSync(marker, RUNTIME_FILE_MODE);
1285
+ const content = "disabled by `prim daemon stop`\n";
1286
+ if (paths.legacyDisabledMarker !== paths.disabledMarker) {
1287
+ atomicWrite(paths.legacyDisabledMarker, content, RUNTIME_FILE_MODE);
1288
+ }
1289
+ atomicWrite(paths.disabledMarker, content, RUNTIME_FILE_MODE);
988
1290
  }
989
1291
  async function ensureMacDaemon(options = {}) {
990
1292
  return withDaemonLifecycleLock(() => ensureMacDaemonLocked(options), options);
991
1293
  }
992
1294
  async function ensureMacDaemonLocked(options) {
1295
+ const runner = options.runner ?? runLaunchctl;
1296
+ let service = getLaunchdService({ ...options, runner });
993
1297
  if (!options.explicitlyStarted && daemonExplicitlyDisabled(options)) {
1298
+ const control2 = daemonControlPaths(options);
1299
+ if (!existsSync2(control2.disabledMarker)) {
1300
+ atomicWrite(control2.disabledMarker, "disabled by `prim daemon stop`\n", RUNTIME_FILE_MODE);
1301
+ }
994
1302
  return {
995
1303
  state: "disabled",
996
1304
  action: "none",
997
1305
  runtimeChanged: false,
998
1306
  plistChanged: false,
999
1307
  responding: false,
1000
- service: getLaunchdService(options)
1308
+ service
1001
1309
  };
1002
1310
  }
1003
1311
  if (options.explicitlyStarted) setDaemonExplicitlyDisabled(false, options);
1004
- const runtime = stageRuntime(options);
1312
+ const control = daemonControlPaths(options);
1313
+ const requestedSource = options.daemonSource ?? binFile("prim-daemon-server");
1314
+ if (!options.version && !requestedSource) {
1315
+ throw new Error("cannot determine runtime version: daemon is unavailable");
1316
+ }
1317
+ const requestedVersion = options.version ?? findPackageVersion(requestedSource);
1318
+ const selected = options.explicitlyStarted ? null : readDaemonLauncher(control.launcher);
1319
+ const precedence = selected ? compareSemver(selected.runtimeVersion, requestedVersion) : null;
1320
+ const retainSelected = selected !== null && (precedence === 1 || precedence === void 0 && selected.runtimeVersion !== requestedVersion);
1321
+ let runtime;
1322
+ let daemonConfig;
1323
+ const configuredApiUrl = (options.env ?? process.env).PRIM_API_URL;
1324
+ const apiUrl = configuredApiUrl ? normalizeApiUrl(configuredApiUrl) || void 0 : void 0;
1325
+ if (retainSelected && selected) {
1326
+ const usable = readUsableDaemonConfig(control.launcher);
1327
+ if (!usable) {
1328
+ throw new Error(
1329
+ `selected daemon runtime ${selected.runtimeVersion} is unavailable or corrupt; refusing automatic downgrade to ${requestedVersion}`
1330
+ );
1331
+ }
1332
+ daemonConfig = {
1333
+ nodePath: usable.nodePath,
1334
+ daemonPath: usable.daemonPath,
1335
+ runtimeVersion: usable.runtimeVersion,
1336
+ ...apiUrl ? { apiUrl } : {}
1337
+ };
1338
+ } else {
1339
+ runtime = stageRuntime({ ...options, version: requestedVersion });
1340
+ daemonConfig = {
1341
+ nodePath: runtime.manifest.nodePath,
1342
+ daemonPath: runtime.daemonPath,
1343
+ runtimeVersion: runtime.manifest.version,
1344
+ ...apiUrl ? { apiUrl } : {}
1345
+ };
1346
+ }
1005
1347
  const servicePaths = launchdPaths(options);
1006
1348
  mkdirSync(dirname(servicePaths.logPath), { recursive: true, mode: RUNTIME_DIR_MODE });
1007
1349
  writeFileSync(servicePaths.logPath, "", { mode: RUNTIME_FILE_MODE, flag: "a" });
1008
1350
  chmodSync(servicePaths.logPath, RUNTIME_FILE_MODE);
1351
+ const launcher = generateDaemonLauncher(daemonConfig);
1352
+ atomicWrite(control.launcher, launcher.content, RUNTIME_LAUNCHER_MODE);
1009
1353
  const plist = generateLaunchAgentPlist({
1010
- nodePath: runtime.manifest.nodePath,
1011
- daemonPath: runtime.paths.daemonEntry,
1012
- runtimeVersion: runtime.manifest.version,
1354
+ launcherPath: control.launcher,
1013
1355
  logPath: servicePaths.logPath,
1014
1356
  workingDirectory: options.homeDir ?? homedir(),
1015
- apiUrl: (options.env ?? process.env).PRIM_API_URL
1357
+ label: options.label
1016
1358
  });
1017
- const plistChanged = atomicWrite(servicePaths.plistPath, plist, PLIST_FILE_MODE);
1018
- const runner = options.runner ?? runLaunchctl;
1019
- let service = getLaunchdService({ ...options, runner });
1359
+ const plistChanged = atomicWrite(
1360
+ servicePaths.plistPath,
1361
+ plist,
1362
+ PLIST_FILE_MODE,
1363
+ options.validatePlist ?? validatePlist
1364
+ );
1365
+ const sleep2 = options.sleep ?? defaultSleep;
1366
+ const nowMs = options.nowMs ?? performance.now.bind(performance);
1367
+ const deadlineMs = nowMs() + TRANSITION_TIMEOUT_MS;
1368
+ const inspect = options.inspectDaemon ?? inspectDaemon;
1369
+ const transition = {
1370
+ paths: servicePaths,
1371
+ runner,
1372
+ inspect,
1373
+ sleep: sleep2,
1374
+ nowMs,
1375
+ deadlineMs,
1376
+ launcherPath: control.launcher,
1377
+ version: daemonConfig.runtimeVersion,
1378
+ revision: launcher.revision
1379
+ };
1380
+ const migrateLegacy = options.migrateLegacy ?? (() => stopVerifiedLegacyDaemon(options.homeDir ?? homedir(), deadlineMs, nowMs, sleep2));
1020
1381
  let action = "none";
1021
- if (service.loaded && plistChanged) {
1022
- launchctlOrThrow(runner, ["bootout", servicePaths.serviceTarget], "bootout");
1023
- launchctlOrThrow(
1024
- runner,
1025
- ["bootstrap", servicePaths.domainTarget, servicePaths.plistPath],
1026
- "bootstrap"
1027
- );
1028
- action = "reload";
1029
- } else if (!service.loaded) {
1030
- if (await (options.probe ?? daemonIsLive)(READY_PROBE_TIMEOUT_MS)) {
1031
- await (options.migrateLegacy ?? (() => stopVerifiedLegacyDaemon(options.homeDir ?? homedir())))();
1382
+ const observed = await observe(transition);
1383
+ service = observed.service;
1384
+ if (!service.loaded) {
1385
+ if (await inspect(timeoutMs(nowMs, deadlineMs, READY_PROBE_TIMEOUT_MS))) {
1386
+ await migrateLegacy();
1032
1387
  }
1033
- launchctlOrThrow(
1034
- runner,
1035
- ["bootstrap", servicePaths.domainTarget, servicePaths.plistPath],
1036
- "bootstrap"
1037
- );
1388
+ await bootstrap(transition);
1038
1389
  action = "bootstrap";
1039
- } else {
1040
- const healthy = await (options.probe ?? daemonIsLive)(READY_PROBE_TIMEOUT_MS);
1041
- const socketPid2 = healthy ? await (options.identify ?? identifyDaemon)(READY_PROBE_TIMEOUT_MS) : null;
1042
- const ownsSocket = service.pid !== void 0 && socketPid2 !== null && service.pid === socketPid2;
1043
- if (healthy && socketPid2 !== null && !ownsSocket) {
1044
- await (options.migrateLegacy ?? (() => stopVerifiedLegacyDaemon(options.homeDir ?? homedir())))();
1045
- }
1046
- if (runtime.changed || options.forceRestart || !healthy || !ownsSocket) {
1047
- launchctlOrThrow(runner, ["kickstart", "-k", servicePaths.serviceTarget], "kickstart");
1048
- action = "kickstart";
1049
- }
1050
- }
1051
- const responding = await waitForReady(
1052
- options.probe ?? daemonIsLive,
1053
- options.sleep ?? defaultSleep
1054
- );
1055
- service = getLaunchdService({ ...options, runner });
1056
- const socketPid = responding ? await (options.identify ?? identifyDaemon)(READY_PROBE_TIMEOUT_MS) : null;
1057
- const running = responding && service.loaded && service.pid !== void 0 && socketPid !== null && service.pid === socketPid;
1390
+ } else if (service.program !== control.launcher) {
1391
+ if (!service.program) throw new Error("launchd did not report the registered program");
1392
+ await reloadDefinition(transition, service.program);
1393
+ action = "reload";
1394
+ } else if (options.forceRestart || !desiredDaemonIsRunning(transition, observed)) {
1395
+ await kickstart(transition);
1396
+ action = "kickstart";
1397
+ }
1398
+ const ready = await waitForDesired(transition, action === "bootstrap" || action === "reload");
1399
+ service = ready.service;
1400
+ const responding = ready.identity !== null;
1401
+ const running = desiredDaemonIsRunning(transition, ready);
1058
1402
  return {
1059
1403
  state: running ? "running" : "unhealthy",
1060
1404
  action,
1061
- runtimeChanged: runtime.changed,
1405
+ runtimeChanged: runtime?.changed ?? false,
1062
1406
  plistChanged,
1063
1407
  responding,
1064
- ...socketPid === null ? {} : { socketPid },
1408
+ ...ready.identity ? { socketPid: ready.identity.pid } : {},
1065
1409
  service,
1066
- runtime
1410
+ ...runtime ? { runtime } : {}
1067
1411
  };
1068
1412
  }
1069
1413
  async function bootoutMacDaemon(options = {}) {
@@ -1072,14 +1416,39 @@ async function bootoutMacDaemon(options = {}) {
1072
1416
  async function bootoutMacDaemonLocked(options) {
1073
1417
  setDaemonExplicitlyDisabled(true, options);
1074
1418
  const runner = options.runner ?? runLaunchctl;
1419
+ const inspect = options.inspectDaemon ?? inspectDaemon;
1420
+ const sleep2 = options.sleep ?? defaultSleep;
1421
+ const nowMs = options.nowMs ?? performance.now.bind(performance);
1422
+ const deadlineMs = nowMs() + TRANSITION_TIMEOUT_MS;
1075
1423
  const paths = launchdPaths(options);
1424
+ const migrateLegacy = options.migrateLegacy ?? (() => stopVerifiedLegacyDaemon(options.homeDir ?? homedir(), deadlineMs, nowMs, sleep2));
1076
1425
  const service = getLaunchdService({ ...options, runner });
1077
- if (service.loaded) {
1078
- launchctlOrThrow(runner, ["bootout", paths.serviceTarget], "bootout");
1079
- return { wasLoaded: true, legacyStopped: false, service };
1426
+ const wasLoaded = service.loaded;
1427
+ if (wasLoaded) {
1428
+ const result = runner(["bootout", paths.serviceTarget], timeoutMs(nowMs, deadlineMs));
1429
+ if (result.status !== 0) {
1430
+ const error = new LaunchctlError("bootout", result);
1431
+ const after = getLaunchdService({
1432
+ ...options,
1433
+ runner,
1434
+ timeoutMs: timeoutMs(nowMs, deadlineMs)
1435
+ });
1436
+ if (!error.timedOut && error.status !== TRANSITION_IN_PROGRESS && after.loaded) {
1437
+ throw error;
1438
+ }
1439
+ }
1440
+ await waitForAbsence({
1441
+ options,
1442
+ runner,
1443
+ inspect,
1444
+ sleep: sleep2,
1445
+ nowMs,
1446
+ deadlineMs,
1447
+ previousPid: service.pid
1448
+ });
1080
1449
  }
1081
- const legacyStopped = await (options.probe ?? daemonIsLive)(READY_PROBE_TIMEOUT_MS) ? await (options.migrateLegacy ?? (() => stopVerifiedLegacyDaemon(options.homeDir ?? homedir())))() : false;
1082
- return { wasLoaded: false, legacyStopped, service };
1450
+ const legacyStopped = await inspect(timeoutMs(nowMs, deadlineMs, READY_PROBE_TIMEOUT_MS)) ? await migrateLegacy() : false;
1451
+ return { wasLoaded, legacyStopped, service };
1083
1452
  }
1084
1453
 
1085
1454
  // src/commands/claude-install.ts
@@ -1715,7 +2084,7 @@ var LOG_FILE_MODE = 384;
1715
2084
  var STOP_TIMEOUT_MS = 5e3;
1716
2085
  var STOP_POLL_MS = 100;
1717
2086
  var STATUS_PROBE_TIMEOUT_MS = 500;
1718
- var READY_TIMEOUT_MS2 = 5e3;
2087
+ var READY_TIMEOUT_MS = 5e3;
1719
2088
  var READY_POLL_MS2 = 100;
1720
2089
  var READY_PROBE_TIMEOUT_MS2 = 250;
1721
2090
  var HEALTHY_TIMEOUT_MS = 3e4;
@@ -1729,13 +2098,6 @@ function formatDaemonLifecycleMessage(message, decisionIngestion) {
1729
2098
  function formatCurrentDaemonLifecycleMessage(message) {
1730
2099
  return formatDaemonLifecycleMessage(message, decisionIngestionStatus(process.cwd()));
1731
2100
  }
1732
- var MAX_HEALTH_ERROR_LENGTH = 240;
1733
- function boundedHealthError(value) {
1734
- if (!value) return void 0;
1735
- const clean = stripControlChars(value).replace(/\s+/g, " ").trim();
1736
- if (!clean) return void 0;
1737
- return clean.length <= MAX_HEALTH_ERROR_LENGTH ? clean : `${clean.slice(0, MAX_HEALTH_ERROR_LENGTH - 1)}\u2026`;
1738
- }
1739
2101
  function daemonDegradedReason(snapshot) {
1740
2102
  if (!snapshot || snapshot.healthy !== false) return void 0;
1741
2103
  if (snapshot.needsReauth) {
@@ -1795,8 +2157,8 @@ function openDaemonLog(configDir = CONFIG_DIR) {
1795
2157
  }
1796
2158
  return openSync2(join4(configDir, "daemon.log"), "a", LOG_FILE_MODE);
1797
2159
  }
1798
- async function waitForReady2() {
1799
- const deadline = Date.now() + READY_TIMEOUT_MS2;
2160
+ async function waitForReady() {
2161
+ const deadline = Date.now() + READY_TIMEOUT_MS;
1800
2162
  while (Date.now() < deadline) {
1801
2163
  if (await daemonIsLive(READY_PROBE_TIMEOUT_MS2)) {
1802
2164
  return true;
@@ -1900,7 +2262,7 @@ async function detachedDaemonStart(opts) {
1900
2262
  if (logFd !== void 0) {
1901
2263
  closeSync2(logFd);
1902
2264
  }
1903
- const live = await waitForReady2();
2265
+ const live = await waitForReady();
1904
2266
  if (live) {
1905
2267
  const after = readPidfile();
1906
2268
  process.stderr.write(
@@ -1911,7 +2273,7 @@ async function detachedDaemonStart(opts) {
1911
2273
  return;
1912
2274
  }
1913
2275
  process.stderr.write(
1914
- `[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})
2276
+ `[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})
1915
2277
  `
1916
2278
  );
1917
2279
  console.log(JSON.stringify({ started: false }, null, 2));
@@ -1993,8 +2355,9 @@ async function macDaemonStart(forceRestart = false) {
1993
2355
  const healthy = daemonStartIsHealthy(serviceReady, snapshot, expectedVersion);
1994
2356
  if (healthy) {
1995
2357
  const verb = result.action === "none" ? "already running" : "started";
2358
+ const servicePid = result.service.loaded ? result.service.pid : void 0;
1996
2359
  process.stderr.write(
1997
- `${formatCurrentDaemonLifecycleMessage(`[prim] \u2713 daemon ${verb} under launchd (pid=${snapshot?.pid ?? result.service.pid ?? "?"})`)}
2360
+ `${formatCurrentDaemonLifecycleMessage(`[prim] \u2713 daemon ${verb} under launchd (pid=${snapshot?.pid ?? servicePid ?? "?"})`)}
1998
2361
  `
1999
2362
  );
2000
2363
  } else {
@@ -2011,7 +2374,7 @@ async function macDaemonStart(forceRestart = false) {
2011
2374
  started: healthy,
2012
2375
  supervised: true,
2013
2376
  action: result.action,
2014
- pid: snapshot?.pid ?? result.service.pid,
2377
+ pid: snapshot?.pid ?? (result.service.loaded ? result.service.pid : void 0),
2015
2378
  loaded: result.service.loaded,
2016
2379
  responding: result.responding,
2017
2380
  healthy,
@@ -2083,6 +2446,19 @@ function classifyStatus(pidAlive, responding, snapshot, pid) {
2083
2446
  return { json: { running: true, responding: true, ...snapshot }, exitCode: EXIT_OK2 };
2084
2447
  }
2085
2448
  function classifyLaunchdStatus(service, responding, snapshot, disabled) {
2449
+ if (!service) {
2450
+ return {
2451
+ json: {
2452
+ running: responding,
2453
+ responding,
2454
+ supervised: true,
2455
+ state: "unknown",
2456
+ disabled,
2457
+ ...snapshot ?? {}
2458
+ },
2459
+ exitCode: EXIT_BOOTING
2460
+ };
2461
+ }
2086
2462
  if (!service.loaded) {
2087
2463
  if (responding) {
2088
2464
  return {
@@ -2210,7 +2586,13 @@ async function detachedDaemonStatus() {
2210
2586
  }
2211
2587
  }
2212
2588
  async function macDaemonStatus() {
2213
- const service = getLaunchdService();
2589
+ let service;
2590
+ let launchdError;
2591
+ try {
2592
+ service = getLaunchdService();
2593
+ } catch (error) {
2594
+ launchdError = error;
2595
+ }
2214
2596
  const responding = await daemonIsLive(STATUS_PROBE_TIMEOUT_MS);
2215
2597
  const snapshot = responding ? await daemonRequest(
2216
2598
  "status_snapshot",
@@ -2219,7 +2601,13 @@ async function macDaemonStatus() {
2219
2601
  ) : null;
2220
2602
  const disabled = daemonExplicitlyDisabled();
2221
2603
  const { json, exitCode } = classifyLaunchdStatus(service, responding, snapshot, disabled);
2222
- if (!service.loaded && responding) {
2604
+ if (!service) {
2605
+ const detail = boundedHealthError(
2606
+ launchdError instanceof Error ? launchdError.message : String(launchdError)
2607
+ );
2608
+ process.stderr.write(`[prim] \u2717 launchd status unavailable${detail ? `: ${detail}` : ""}
2609
+ `);
2610
+ } else if (!service.loaded && responding) {
2223
2611
  process.stderr.write(
2224
2612
  `[prim] \u25CC daemon pid=${snapshot?.pid ?? "?"} is live but not supervised by launchd
2225
2613
  `
@@ -3359,8 +3747,13 @@ async function checkDaemon() {
3359
3747
  if (process.platform === "darwin") {
3360
3748
  try {
3361
3749
  service = getLaunchdService();
3362
- } catch {
3363
- service = { loaded: false };
3750
+ } catch (error) {
3751
+ const detail = boundedHealthError(error instanceof Error ? error.message : String(error));
3752
+ return {
3753
+ name: "daemon",
3754
+ status: "fail",
3755
+ detail: `launchd status unavailable${detail ? `: ${detail}` : ""}`
3756
+ };
3364
3757
  }
3365
3758
  }
3366
3759
  const snapshot = await daemonRequest(