@primitive.ai/prim 0.1.0-alpha.60 → 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.
@@ -64,6 +64,13 @@ function stripControlChars(text) {
64
64
  }
65
65
  return out;
66
66
  }
67
+ var MAX_HEALTH_ERROR_LENGTH = 240;
68
+ function boundedHealthError(value) {
69
+ if (!value) return void 0;
70
+ const clean = stripControlChars(value).replace(/\s+/g, " ").trim();
71
+ if (!clean) return void 0;
72
+ return clean.length <= MAX_HEALTH_ERROR_LENGTH ? clean : `${clean.slice(0, MAX_HEALTH_ERROR_LENGTH - 1)}\u2026`;
73
+ }
67
74
  function stripAnsi(text) {
68
75
  return text.replace(/\u001b\[[0-9;]*m/g, "");
69
76
  }
@@ -97,6 +104,7 @@ export {
97
104
  bold,
98
105
  colorForArea,
99
106
  stripControlChars,
107
+ boundedHealthError,
100
108
  stripAnsi,
101
109
  requireDurableIngestAcknowledgement
102
110
  };
@@ -56,6 +56,45 @@ import { homedir } from "os";
56
56
  import { dirname, isAbsolute, join, relative, resolve, sep } from "path";
57
57
  import { fileURLToPath } from "url";
58
58
  import { parse as parseYaml } from "yaml";
59
+
60
+ // src/lib/semver.ts
61
+ function parseSemver(value) {
62
+ if (typeof value !== "string" || value.length > 256) return;
63
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u.exec(
64
+ value
65
+ );
66
+ if (!match) return;
67
+ const prerelease = match[4]?.split(".") ?? [];
68
+ if (prerelease.some((part) => /^\d+$/u.test(part) && part.length > 1 && part.startsWith("0"))) {
69
+ return;
70
+ }
71
+ return { core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])], prerelease };
72
+ }
73
+ function compareSemver(left, right) {
74
+ const a = parseSemver(left);
75
+ const b = parseSemver(right);
76
+ if (!a || !b) return;
77
+ for (let i = 0; i < a.core.length; i += 1) {
78
+ if (a.core[i] !== b.core[i]) return a.core[i] < b.core[i] ? -1 : 1;
79
+ }
80
+ if (a.prerelease.length === 0 || b.prerelease.length === 0) {
81
+ return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length === 0 ? 1 : -1;
82
+ }
83
+ for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i += 1) {
84
+ const x = a.prerelease[i];
85
+ const y = b.prerelease[i];
86
+ if (x === void 0 || y === void 0) return x === void 0 ? -1 : 1;
87
+ if (x === y) continue;
88
+ const xNumeric = /^\d+$/u.test(x);
89
+ const yNumeric = /^\d+$/u.test(y);
90
+ if (xNumeric && yNumeric) return BigInt(x) < BigInt(y) ? -1 : 1;
91
+ if (xNumeric !== yNumeric) return xNumeric ? -1 : 1;
92
+ return x < y ? -1 : 1;
93
+ }
94
+ return 0;
95
+ }
96
+
97
+ // src/commands/claude-plugin.ts
59
98
  var __dirname = dirname(fileURLToPath(import.meta.url));
60
99
  var PLUGIN_DESCRIPTION = "Primitive decision-graph guidance for the prim CLI \u2014 a model-invoked skill installed by prim skill install.";
61
100
  var MAX_MANIFEST_BYTES = 64 * 1024;
@@ -100,42 +139,6 @@ function pluginPaths(dir) {
100
139
  skillPath: join(dir, "SKILL.md")
101
140
  };
102
141
  }
103
- function parseSemver(value) {
104
- if (typeof value !== "string" || value.length > 256) return;
105
- const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u.exec(
106
- value
107
- );
108
- if (!match) return;
109
- const prerelease = match[4]?.split(".") ?? [];
110
- if (prerelease.some((part) => /^\d+$/u.test(part) && part.length > 1 && part.startsWith("0"))) {
111
- return;
112
- }
113
- return { core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])], prerelease };
114
- }
115
- function compareSemver(left, right) {
116
- const a = parseSemver(left);
117
- const b = parseSemver(right);
118
- if (!a || !b) return;
119
- for (let i = 0; i < a.core.length; i += 1) {
120
- if (a.core[i] !== b.core[i]) return a.core[i] < b.core[i] ? -1 : 1;
121
- }
122
- if (a.prerelease.length === 0 || b.prerelease.length === 0) {
123
- return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length === 0 ? 1 : -1;
124
- }
125
- const length = Math.max(a.prerelease.length, b.prerelease.length);
126
- for (let i = 0; i < length; i += 1) {
127
- const x = a.prerelease[i];
128
- const y = b.prerelease[i];
129
- if (x === void 0 || y === void 0) return x === void 0 ? -1 : 1;
130
- if (x === y) continue;
131
- const xNumeric = /^\d+$/u.test(x);
132
- const yNumeric = /^\d+$/u.test(y);
133
- if (xNumeric && yNumeric) return BigInt(x) < BigInt(y) ? -1 : 1;
134
- if (xNumeric !== yNumeric) return xNumeric ? -1 : 1;
135
- return x < y ? -1 : 1;
136
- }
137
- return 0;
138
- }
139
142
  function pathIsInside(root, path) {
140
143
  const rel = relative(root, path);
141
144
  return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
@@ -610,6 +613,7 @@ async function bindRepository(root, options) {
610
613
  export {
611
614
  bindRepository,
612
615
  printJson,
616
+ compareSemver,
613
617
  refreshClaudePlugins,
614
618
  hasUsableCodexGuidance,
615
619
  registerSkillCommands
@@ -1517,6 +1517,7 @@ function handleStatusSnapshot(params) {
1517
1517
  sessionId: activeSessionId,
1518
1518
  lastHeartbeatAt,
1519
1519
  version: runtimeVersion,
1520
+ launchRevision: process.env.PRIM_LAUNCH_REVISION,
1520
1521
  healthy: daemonHealth.healthy,
1521
1522
  needsReauth: daemonHealth.needsReauth === true,
1522
1523
  heartbeat: { ...daemonHealth.heartbeat },
@@ -3,7 +3,7 @@ import {
3
3
  bold,
4
4
  color,
5
5
  requireDurableIngestAcknowledgement
6
- } from "../chunk-QY75BQMF.js";
6
+ } from "../chunk-JQFN6LBO.js";
7
7
  import {
8
8
  resolvePreflightTargets
9
9
  } from "../chunk-B4SHJYIN.js";
@@ -3,7 +3,7 @@ import {
3
3
  bindRepository,
4
4
  hasUsableCodexGuidance,
5
5
  refreshClaudePlugins
6
- } from "../chunk-MZQO7K2P.js";
6
+ } from "../chunk-N4R7EXZT.js";
7
7
  import {
8
8
  buildHookOutput,
9
9
  handoffHookOutput
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,9 +25,10 @@ import {
24
25
  } from "./chunk-S4LFRVGD.js";
25
26
  import {
26
27
  bindRepository,
28
+ compareSemver,
27
29
  printJson,
28
30
  registerSkillCommands
29
- } from "./chunk-MZQO7K2P.js";
31
+ } from "./chunk-N4R7EXZT.js";
30
32
  import {
31
33
  fetchFeedbackCapability
32
34
  } from "./chunk-R4CUX6FY.js";
@@ -636,28 +638,58 @@ import { dirname as dirname2, join as join2 } from "path";
636
638
  import { spawnSync } from "child_process";
637
639
  import { createHash as createHash2 } from "crypto";
638
640
  import {
641
+ constants,
642
+ accessSync,
639
643
  chmodSync,
640
644
  copyFileSync,
641
645
  existsSync as existsSync2,
642
646
  mkdirSync,
643
647
  mkdtempSync,
644
648
  readFileSync as readFileSync2,
649
+ realpathSync,
645
650
  renameSync,
646
651
  rmSync,
647
652
  symlinkSync,
648
653
  writeFileSync
649
654
  } from "fs";
650
655
  import { homedir } from "os";
651
- 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
652
664
  var LAUNCHD_LABEL = "ai.getprimitive.prim-daemon";
653
- var RUNTIME_SCHEMA_VERSION = 1;
665
+ var RUNTIME_SCHEMA_VERSION = 2;
654
666
  var RUNTIME_DIR_MODE = 448;
655
667
  var RUNTIME_FILE_MODE = 384;
656
668
  var RUNTIME_LAUNCHER_MODE = 448;
657
669
  var PLIST_FILE_MODE = 384;
658
- var READY_TIMEOUT_MS = 15e3;
670
+ var LAUNCHER_SCHEMA_VERSION = 1;
671
+ var TRANSITION_TIMEOUT_MS = 15e3;
672
+ var COMMAND_TIMEOUT_MS = 5e3;
659
673
  var READY_POLL_MS = 100;
660
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
+ };
661
693
  function xdgDataHome(options) {
662
694
  const home = options.homeDir ?? homedir();
663
695
  const configured = (options.env ?? process.env).XDG_DATA_HOME;
@@ -672,14 +704,22 @@ function runtimePaths(options = {}) {
672
704
  runtimeDir,
673
705
  releasesDir: join(runtimeDir, "releases"),
674
706
  currentLink,
675
- daemonEntry: join(currentLink, "prim-daemon-server"),
676
- statuslineEntry: join(currentLink, "prim-statusline"),
677
707
  statuslineLauncher: join(runtimeDir, "prim-statusline"),
678
- manifestPath: join(currentLink, "manifest.json"),
679
708
  disabledMarker: join(dataDir, "daemon.disabled"),
680
709
  lifecycleLockDir: join(dataDir, "daemon.lifecycle.lock")
681
710
  };
682
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
+ }
683
723
  function findPackageVersion(entryFile) {
684
724
  let dir = dirname(resolve(entryFile));
685
725
  for (let depth = 0; depth < 6; depth++) {
@@ -702,7 +742,7 @@ function findPackageVersion(entryFile) {
702
742
  function readRuntimeManifest(path) {
703
743
  try {
704
744
  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") {
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)) {
706
746
  return null;
707
747
  }
708
748
  return parsed;
@@ -710,10 +750,18 @@ function readRuntimeManifest(path) {
710
750
  return null;
711
751
  }
712
752
  }
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
- );
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");
717
765
  }
718
766
  function atomicSymlink(target, linkPath) {
719
767
  const tempLink = `${linkPath}.tmp-${process.pid}-${createHash2("sha256").update(`${target}\0${Date.now()}\0${Math.random()}`).digest("hex").slice(0, 12)}`;
@@ -740,35 +788,60 @@ function stageRuntime(options = {}) {
740
788
  version,
741
789
  nodePath,
742
790
  daemonFile: "prim-daemon-server",
743
- ...statuslineSource ? { statuslineFile: "prim-statusline" } : {},
791
+ daemonSha256: sha256File(daemonSource),
792
+ ...statuslineSource ? {
793
+ statuslineFile: "prim-statusline",
794
+ statuslineSha256: sha256File(statuslineSource)
795
+ } : {},
744
796
  stagedAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString()
745
797
  };
746
- const current = readRuntimeManifest(paths.manifestPath);
747
- if (sameRuntime(current, desired, paths)) {
748
- 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) {
749
810
  atomicWrite(
750
811
  paths.statuslineLauncher,
751
- statuslineLauncherContent(paths, current.nodePath),
812
+ statuslineLauncherContent(statuslinePath, manifest.nodePath),
752
813
  RUNTIME_LAUNCHER_MODE
753
814
  );
754
815
  } else {
755
816
  rmSync(paths.statuslineLauncher, { force: true });
756
817
  }
757
- return { changed: false, manifest: current, paths };
818
+ return {
819
+ changed: false,
820
+ manifest,
821
+ paths,
822
+ daemonPath: currentDaemon
823
+ };
758
824
  }
759
825
  mkdirSync(paths.releasesDir, { recursive: true, mode: RUNTIME_DIR_MODE });
760
826
  chmodSync(paths.runtimeDir, RUNTIME_DIR_MODE);
761
827
  chmodSync(paths.releasesDir, RUNTIME_DIR_MODE);
762
828
  const stagingDir = mkdtempSync(join(paths.releasesDir, ".stage-"));
763
829
  chmodSync(stagingDir, RUNTIME_DIR_MODE);
830
+ let releaseDir;
764
831
  try {
765
832
  const daemonTarget = join(stagingDir, desired.daemonFile);
766
833
  copyFileSync(daemonSource, daemonTarget);
767
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
+ }
768
838
  if (statuslineSource && desired.statuslineFile) {
769
839
  const statuslineTarget = join(stagingDir, desired.statuslineFile);
770
840
  copyFileSync(statuslineSource, statuslineTarget);
771
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
+ }
772
845
  }
773
846
  const manifestTarget = join(stagingDir, "manifest.json");
774
847
  writeFileSync(manifestTarget, `${JSON.stringify(desired, null, 2)}
@@ -780,13 +853,15 @@ function stageRuntime(options = {}) {
780
853
  const releaseName = `release-${createHash2("sha256").update(
781
854
  `${version}\0${nodePath}\0${desired.statuslineFile ?? ""}\0${desired.stagedAt}\0${process.pid}\0${Math.random()}`
782
855
  ).digest("hex").slice(0, 16)}`;
783
- const releaseDir = join(paths.releasesDir, releaseName);
856
+ releaseDir = join(paths.releasesDir, releaseName);
784
857
  renameSync(stagingDir, releaseDir);
858
+ releaseDir = realpathSync(releaseDir);
785
859
  atomicSymlink(join("releases", releaseName), paths.currentLink);
786
860
  if (desired.statuslineFile) {
861
+ const statuslinePath = join(releaseDir, desired.statuslineFile);
787
862
  atomicWrite(
788
863
  paths.statuslineLauncher,
789
- statuslineLauncherContent(paths, desired.nodePath),
864
+ statuslineLauncherContent(statuslinePath, desired.nodePath),
790
865
  RUNTIME_LAUNCHER_MODE
791
866
  );
792
867
  } else {
@@ -796,36 +871,91 @@ function stageRuntime(options = {}) {
796
871
  rmSync(stagingDir, { recursive: true, force: true });
797
872
  throw error;
798
873
  }
799
- return { changed: true, manifest: desired, paths };
874
+ return {
875
+ changed: true,
876
+ manifest: desired,
877
+ paths,
878
+ daemonPath: join(releaseDir, desired.daemonFile)
879
+ };
800
880
  }
801
881
  function shellQuote(value) {
802
882
  return `'${value.replaceAll("'", `'"'"'`)}'`;
803
883
  }
804
- function statuslineLauncherContent(paths, nodePath) {
884
+ function statuslineLauncherContent(statuslinePath, nodePath) {
805
885
  return `#!/bin/sh
806
- exec ${shellQuote(nodePath)} ${shellQuote(paths.statuslineEntry)}
886
+ exec ${shellQuote(nodePath)} ${shellQuote(statuslinePath)}
807
887
  `;
808
888
  }
809
889
  function runtimeStatuslineCommand(options = {}) {
810
890
  return shellQuote(runtimePaths(options).statuslineLauncher);
811
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
+ }
812
946
  function xmlEscape(value) {
813
947
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
814
948
  }
815
949
  function generateLaunchAgentPlist(config) {
816
- const apiUrl = config.apiUrl ? `
817
- <key>PRIM_API_URL</key>
818
- <string>${xmlEscape(config.apiUrl)}</string>` : "";
819
950
  return `<?xml version="1.0" encoding="UTF-8"?>
820
951
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
821
952
  <plist version="1.0">
822
953
  <dict>
823
954
  <key>Label</key>
824
- <string>${LAUNCHD_LABEL}</string>
955
+ <string>${xmlEscape(config.label ?? LAUNCHD_LABEL)}</string>
825
956
  <key>ProgramArguments</key>
826
957
  <array>
827
- <string>${xmlEscape(config.nodePath)}</string>
828
- <string>${xmlEscape(config.daemonPath)}</string>
958
+ <string>${xmlEscape(config.launcherPath)}</string>
829
959
  </array>
830
960
  <key>RunAtLoad</key>
831
961
  <true/>
@@ -835,6 +965,8 @@ function generateLaunchAgentPlist(config) {
835
965
  <integer>10</integer>
836
966
  <key>ProcessType</key>
837
967
  <string>Background</string>
968
+ <key>ExitTimeOut</key>
969
+ <integer>5</integer>
838
970
  <key>Umask</key>
839
971
  <integer>63</integer>
840
972
  <key>WorkingDirectory</key>
@@ -843,11 +975,6 @@ function generateLaunchAgentPlist(config) {
843
975
  <string>${xmlEscape(config.logPath)}</string>
844
976
  <key>StandardErrorPath</key>
845
977
  <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
978
  </dict>
852
979
  </plist>
853
980
  `;
@@ -855,45 +982,57 @@ function generateLaunchAgentPlist(config) {
855
982
  function launchdPaths(options = {}) {
856
983
  const home = options.homeDir ?? homedir();
857
984
  const uid = options.uid ?? process.getuid?.();
985
+ const label = options.label ?? LAUNCHD_LABEL;
858
986
  if (uid === void 0) throw new Error("cannot determine uid for launchd user domain");
859
987
  return {
860
988
  domainTarget: `gui/${uid}`,
861
- serviceTarget: `gui/${uid}/${LAUNCHD_LABEL}`,
862
- plistPath: join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`),
989
+ serviceTarget: `gui/${uid}/${label}`,
990
+ plistPath: join(home, "Library", "LaunchAgents", `${label}.plist`),
863
991
  logPath: join(home, ".config", "prim", "daemon.log")
864
992
  };
865
993
  }
866
- var runLaunchctl = (args) => {
867
- 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;
868
997
  return {
869
998
  status: result.status,
870
999
  stdout: result.stdout ?? "",
871
- stderr: result.error?.message ?? result.stderr ?? ""
1000
+ stderr: error?.message ?? result.stderr ?? "",
1001
+ timedOut: error?.code === "ETIMEDOUT"
872
1002
  };
873
1003
  };
874
1004
  function parseLaunchdService(result) {
875
- 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);
876
1007
  const state = /^\s*state = (.+?)\s*$/m.exec(result.stdout)?.[1];
877
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");
878
1011
  const pid = rawPid ? Number(rawPid) : void 0;
879
- return { loaded: true, state, pid, raw: result.stdout };
1012
+ return { loaded: true, state, pid, program: program2 };
880
1013
  }
881
1014
  function getLaunchdService(options = {}) {
882
- const paths = launchdPaths(options);
883
- 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
+ );
884
1021
  }
885
- function atomicWrite(path, content, mode) {
1022
+ function atomicWrite(path, content, mode, validate) {
886
1023
  try {
887
1024
  if (readFileSync2(path, "utf8") === content) {
888
1025
  chmodSync(path, mode);
1026
+ validate?.(path, COMMAND_TIMEOUT_MS);
889
1027
  return false;
890
1028
  }
891
1029
  } catch {
892
1030
  }
893
- mkdirSync(dirname(path), { recursive: true });
1031
+ mkdirSync(dirname(path), { recursive: true, mode: RUNTIME_DIR_MODE });
894
1032
  const temp = `${path}.tmp-${process.pid}-${Date.now()}`;
895
1033
  writeFileSync(temp, content, { encoding: "utf8", mode, flag: "wx" });
896
1034
  try {
1035
+ validate?.(temp, COMMAND_TIMEOUT_MS);
897
1036
  renameSync(temp, path);
898
1037
  } catch (error) {
899
1038
  rmSync(temp, { force: true });
@@ -901,20 +1040,25 @@ function atomicWrite(path, content, mode) {
901
1040
  }
902
1041
  return true;
903
1042
  }
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
1043
  function defaultSleep(ms) {
913
1044
  return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
914
1045
  }
915
- async function identifyDaemon(timeoutMs) {
916
- const snapshot = await daemonRequest("status_snapshot", {}, { timeoutMs });
917
- 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;
918
1062
  }
919
1063
  function processIsAlive(pid) {
920
1064
  try {
@@ -924,11 +1068,31 @@ function processIsAlive(pid) {
924
1068
  return error.code === "EPERM";
925
1069
  }
926
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
+ }
927
1086
  async function withDaemonLifecycleLock(operation, options = {}) {
928
- const lockDir = runtimePaths(options).lifecycleLockDir;
929
- 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
+ );
930
1094
  }
931
- async function stopVerifiedLegacyDaemon(homeDir) {
1095
+ async function stopVerifiedLegacyDaemon(homeDir, deadlineMs, nowMs, sleep2) {
932
1096
  const pidPath = join(homeDir, ".config", "prim", "daemon.pid");
933
1097
  let pid;
934
1098
  try {
@@ -936,122 +1100,314 @@ async function stopVerifiedLegacyDaemon(homeDir) {
936
1100
  } catch {
937
1101
  throw new Error("an unsupervised daemon answered, but it has no verifiable pidfile");
938
1102
  }
939
- 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
+ );
940
1111
  if (!Number.isInteger(pid) || pid <= 0 || !processIsAlive(pid) || snapshot?.pid !== pid) {
941
1112
  throw new Error("refusing to signal an unsupervised daemon whose pid cannot be verified");
942
1113
  }
1114
+ if (nowMs() >= deadlineMs) throw new Error("legacy verification exceeded the lifecycle deadline");
943
1115
  process.kill(pid, "SIGTERM");
944
- const deadline = Date.now() + 5e3;
945
- while (Date.now() < deadline && processIsAlive(pid)) {
946
- await defaultSleep(100);
1116
+ while (nowMs() < deadlineMs && processIsAlive(pid)) {
1117
+ await sleep2(Math.min(READY_POLL_MS, deadlineMs - nowMs()));
947
1118
  }
948
1119
  if (processIsAlive(pid)) {
949
- 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`);
950
1121
  }
951
1122
  return true;
952
1123
  }
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);
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;
958
1154
  }
959
- 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
+ );
960
1273
  }
961
1274
  function daemonExplicitlyDisabled(options = {}) {
962
- return existsSync2(runtimePaths(options).disabledMarker);
1275
+ const paths = daemonControlPaths(options);
1276
+ return existsSync2(paths.disabledMarker) || existsSync2(paths.legacyDisabledMarker);
963
1277
  }
964
1278
  function setDaemonExplicitlyDisabled(disabled, options = {}) {
965
- const marker = runtimePaths(options).disabledMarker;
1279
+ const paths = daemonControlPaths(options);
966
1280
  if (!disabled) {
967
- rmSync(marker, { force: true });
1281
+ rmSync(paths.disabledMarker, { force: true });
1282
+ rmSync(paths.legacyDisabledMarker, { force: true });
968
1283
  return;
969
1284
  }
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);
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);
976
1290
  }
977
1291
  async function ensureMacDaemon(options = {}) {
978
1292
  return withDaemonLifecycleLock(() => ensureMacDaemonLocked(options), options);
979
1293
  }
980
1294
  async function ensureMacDaemonLocked(options) {
1295
+ const runner = options.runner ?? runLaunchctl;
1296
+ let service = getLaunchdService({ ...options, runner });
981
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
+ }
982
1302
  return {
983
1303
  state: "disabled",
984
1304
  action: "none",
985
1305
  runtimeChanged: false,
986
1306
  plistChanged: false,
987
1307
  responding: false,
988
- service: getLaunchdService(options)
1308
+ service
989
1309
  };
990
1310
  }
991
1311
  if (options.explicitlyStarted) setDaemonExplicitlyDisabled(false, options);
992
- 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
+ }
993
1347
  const servicePaths = launchdPaths(options);
994
1348
  mkdirSync(dirname(servicePaths.logPath), { recursive: true, mode: RUNTIME_DIR_MODE });
995
1349
  writeFileSync(servicePaths.logPath, "", { mode: RUNTIME_FILE_MODE, flag: "a" });
996
1350
  chmodSync(servicePaths.logPath, RUNTIME_FILE_MODE);
1351
+ const launcher = generateDaemonLauncher(daemonConfig);
1352
+ atomicWrite(control.launcher, launcher.content, RUNTIME_LAUNCHER_MODE);
997
1353
  const plist = generateLaunchAgentPlist({
998
- nodePath: runtime.manifest.nodePath,
999
- daemonPath: runtime.paths.daemonEntry,
1000
- runtimeVersion: runtime.manifest.version,
1354
+ launcherPath: control.launcher,
1001
1355
  logPath: servicePaths.logPath,
1002
1356
  workingDirectory: options.homeDir ?? homedir(),
1003
- apiUrl: (options.env ?? process.env).PRIM_API_URL
1357
+ label: options.label
1004
1358
  });
1005
- const plistChanged = atomicWrite(servicePaths.plistPath, plist, PLIST_FILE_MODE);
1006
- const runner = options.runner ?? runLaunchctl;
1007
- 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));
1008
1381
  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())))();
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();
1020
1387
  }
1021
- launchctlOrThrow(
1022
- runner,
1023
- ["bootstrap", servicePaths.domainTarget, servicePaths.plistPath],
1024
- "bootstrap"
1025
- );
1388
+ await bootstrap(transition);
1026
1389
  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;
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);
1046
1402
  return {
1047
1403
  state: running ? "running" : "unhealthy",
1048
1404
  action,
1049
- runtimeChanged: runtime.changed,
1405
+ runtimeChanged: runtime?.changed ?? false,
1050
1406
  plistChanged,
1051
1407
  responding,
1052
- ...socketPid === null ? {} : { socketPid },
1408
+ ...ready.identity ? { socketPid: ready.identity.pid } : {},
1053
1409
  service,
1054
- runtime
1410
+ ...runtime ? { runtime } : {}
1055
1411
  };
1056
1412
  }
1057
1413
  async function bootoutMacDaemon(options = {}) {
@@ -1060,14 +1416,39 @@ async function bootoutMacDaemon(options = {}) {
1060
1416
  async function bootoutMacDaemonLocked(options) {
1061
1417
  setDaemonExplicitlyDisabled(true, options);
1062
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;
1063
1423
  const paths = launchdPaths(options);
1424
+ const migrateLegacy = options.migrateLegacy ?? (() => stopVerifiedLegacyDaemon(options.homeDir ?? homedir(), deadlineMs, nowMs, sleep2));
1064
1425
  const service = getLaunchdService({ ...options, runner });
1065
- if (service.loaded) {
1066
- launchctlOrThrow(runner, ["bootout", paths.serviceTarget], "bootout");
1067
- 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
+ });
1068
1449
  }
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 };
1450
+ const legacyStopped = await inspect(timeoutMs(nowMs, deadlineMs, READY_PROBE_TIMEOUT_MS)) ? await migrateLegacy() : false;
1451
+ return { wasLoaded, legacyStopped, service };
1071
1452
  }
1072
1453
 
1073
1454
  // src/commands/claude-install.ts
@@ -1703,7 +2084,7 @@ var LOG_FILE_MODE = 384;
1703
2084
  var STOP_TIMEOUT_MS = 5e3;
1704
2085
  var STOP_POLL_MS = 100;
1705
2086
  var STATUS_PROBE_TIMEOUT_MS = 500;
1706
- var READY_TIMEOUT_MS2 = 5e3;
2087
+ var READY_TIMEOUT_MS = 5e3;
1707
2088
  var READY_POLL_MS2 = 100;
1708
2089
  var READY_PROBE_TIMEOUT_MS2 = 250;
1709
2090
  var HEALTHY_TIMEOUT_MS = 3e4;
@@ -1717,13 +2098,6 @@ function formatDaemonLifecycleMessage(message, decisionIngestion) {
1717
2098
  function formatCurrentDaemonLifecycleMessage(message) {
1718
2099
  return formatDaemonLifecycleMessage(message, decisionIngestionStatus(process.cwd()));
1719
2100
  }
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
2101
  function daemonDegradedReason(snapshot) {
1728
2102
  if (!snapshot || snapshot.healthy !== false) return void 0;
1729
2103
  if (snapshot.needsReauth) {
@@ -1783,8 +2157,8 @@ function openDaemonLog(configDir = CONFIG_DIR) {
1783
2157
  }
1784
2158
  return openSync2(join4(configDir, "daemon.log"), "a", LOG_FILE_MODE);
1785
2159
  }
1786
- async function waitForReady2() {
1787
- const deadline = Date.now() + READY_TIMEOUT_MS2;
2160
+ async function waitForReady() {
2161
+ const deadline = Date.now() + READY_TIMEOUT_MS;
1788
2162
  while (Date.now() < deadline) {
1789
2163
  if (await daemonIsLive(READY_PROBE_TIMEOUT_MS2)) {
1790
2164
  return true;
@@ -1888,7 +2262,7 @@ async function detachedDaemonStart(opts) {
1888
2262
  if (logFd !== void 0) {
1889
2263
  closeSync2(logFd);
1890
2264
  }
1891
- const live = await waitForReady2();
2265
+ const live = await waitForReady();
1892
2266
  if (live) {
1893
2267
  const after = readPidfile();
1894
2268
  process.stderr.write(
@@ -1899,7 +2273,7 @@ async function detachedDaemonStart(opts) {
1899
2273
  return;
1900
2274
  }
1901
2275
  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})
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})
1903
2277
  `
1904
2278
  );
1905
2279
  console.log(JSON.stringify({ started: false }, null, 2));
@@ -1981,8 +2355,9 @@ async function macDaemonStart(forceRestart = false) {
1981
2355
  const healthy = daemonStartIsHealthy(serviceReady, snapshot, expectedVersion);
1982
2356
  if (healthy) {
1983
2357
  const verb = result.action === "none" ? "already running" : "started";
2358
+ const servicePid = result.service.loaded ? result.service.pid : void 0;
1984
2359
  process.stderr.write(
1985
- `${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 ?? "?"})`)}
1986
2361
  `
1987
2362
  );
1988
2363
  } else {
@@ -1999,7 +2374,7 @@ async function macDaemonStart(forceRestart = false) {
1999
2374
  started: healthy,
2000
2375
  supervised: true,
2001
2376
  action: result.action,
2002
- pid: snapshot?.pid ?? result.service.pid,
2377
+ pid: snapshot?.pid ?? (result.service.loaded ? result.service.pid : void 0),
2003
2378
  loaded: result.service.loaded,
2004
2379
  responding: result.responding,
2005
2380
  healthy,
@@ -2071,6 +2446,19 @@ function classifyStatus(pidAlive, responding, snapshot, pid) {
2071
2446
  return { json: { running: true, responding: true, ...snapshot }, exitCode: EXIT_OK2 };
2072
2447
  }
2073
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
+ }
2074
2462
  if (!service.loaded) {
2075
2463
  if (responding) {
2076
2464
  return {
@@ -2198,7 +2586,13 @@ async function detachedDaemonStatus() {
2198
2586
  }
2199
2587
  }
2200
2588
  async function macDaemonStatus() {
2201
- const service = getLaunchdService();
2589
+ let service;
2590
+ let launchdError;
2591
+ try {
2592
+ service = getLaunchdService();
2593
+ } catch (error) {
2594
+ launchdError = error;
2595
+ }
2202
2596
  const responding = await daemonIsLive(STATUS_PROBE_TIMEOUT_MS);
2203
2597
  const snapshot = responding ? await daemonRequest(
2204
2598
  "status_snapshot",
@@ -2207,7 +2601,13 @@ async function macDaemonStatus() {
2207
2601
  ) : null;
2208
2602
  const disabled = daemonExplicitlyDisabled();
2209
2603
  const { json, exitCode } = classifyLaunchdStatus(service, responding, snapshot, disabled);
2210
- 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) {
2211
2611
  process.stderr.write(
2212
2612
  `[prim] \u25CC daemon pid=${snapshot?.pid ?? "?"} is live but not supervised by launchd
2213
2613
  `
@@ -3347,8 +3747,13 @@ async function checkDaemon() {
3347
3747
  if (process.platform === "darwin") {
3348
3748
  try {
3349
3749
  service = getLaunchdService();
3350
- } catch {
3351
- 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
+ };
3352
3757
  }
3353
3758
  }
3354
3759
  const snapshot = await daemonRequest(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitive.ai/prim",
3
- "version": "0.1.0-alpha.60",
3
+ "version": "0.1.0-alpha.61",
4
4
  "description": "CLI for Primitive's decision graph — passive decision capture, conflict gate, and team presence",
5
5
  "type": "module",
6
6
  "license": "MIT",