relay-companion 0.1.407 → 0.1.409

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/bin/relay.js CHANGED
@@ -217,10 +217,8 @@ async function applyInstall({
217
217
  requireLiveTools = false,
218
218
  requiredHosts = requiredLiveHosts(),
219
219
  claim = false,
220
+ reload = true,
220
221
  } = {}) {
221
- // Installing or repairing is also the migration point for old local
222
- // capability settings. writeConfig sanitizes them before touching disk.
223
- writeConfig({});
224
222
  const {
225
223
  installed,
226
224
  missing,
@@ -232,7 +230,7 @@ async function applyInstall({
232
230
  codexHooks = null,
233
231
  desktopRestarts = [],
234
232
  sweptStaleEntries = [],
235
- } = await runSetupInstall({ claim });
233
+ } = await runSetupInstall({ claim, reload });
236
234
  if (installed.length) console.log(`Added Relay to ${installed.join(" and ")} on this machine.`);
237
235
  if (!binStable) {
238
236
  console.log(
@@ -351,6 +349,10 @@ async function cmdSetup(flags) {
351
349
  // An explicit setup is the migration/ownership handoff. Runtime verification
352
350
  // in runSetupInstall completes before either platform's autostart is changed.
353
351
  claim: true,
352
+ // The signed thin bootstrap asks setup to write all registrations without
353
+ // starting them, then performs the same exact-root lifecycle as the updater.
354
+ // Ordinary setup keeps its historical install-and-start behaviour.
355
+ reload: !(flags["no-restart"] && process.env.RELAY_BOOTSTRAP_ACTIVATED === "1"),
354
356
  });
355
357
  const token = setupOpenRelayToken(flags);
356
358
  if (token && readConfig().deviceToken) {
@@ -389,8 +391,11 @@ async function cmdInstall(flags = {}) {
389
391
  }
390
392
 
391
393
  function cmdRepairDesktop(flags = {}) {
392
- writeConfig({});
393
394
  const reload = !flags["no-restart"];
395
+ // Repair is deliberately local and non-destructive. It may rewrite Relay's
396
+ // launchers and Relay-owned hook registrations, but it never rewrites account
397
+ // config, deletes credentials/E2EE state, clears messages/outboxes/preferences,
398
+ // or calls a cloud revocation endpoint.
394
399
  // The updater invokes this command from the verified candidate tree before it
395
400
  // switches/restarts services. First atomically migrate any existing raw hook
396
401
  // registrations to Relay's stable bridge; if that fails, do not claim the
@@ -726,7 +731,7 @@ async function cmdDoctor(flags = {}) {
726
731
  const since = failure.firstAt ? new Date(Number(failure.firstAt)).toISOString() : "unknown";
727
732
  console.log(` UPDATES ARE FAILING: ${failure.count} consecutive attempt(s) to install ${failure.target} since ${since}`);
728
733
  console.log(` cause: see ${updateLogPath}`);
729
- console.log(" if autostart is broken, run: relay repair-desktop");
734
+ console.log(" if autostart is broken, run: relay repair-installation");
730
735
  } else if (failureInFlight) {
731
736
  console.log(` update attempt in flight: ${failure.target} (launched ${new Date(Number(failure.lastAt)).toISOString()})`);
732
737
  } else if (!canonicalFailures.length) {
@@ -786,7 +791,7 @@ async function cmdDoctor(flags = {}) {
786
791
  const missingTasks = taskStatus.missing;
787
792
  if (missingTasks.length) {
788
793
  console.log(` MISSING Scheduled Task(s): ${missingTasks.join(", ")}`);
789
- console.log(" fix with: relay repair-desktop");
794
+ console.log(" fix with: relay repair-installation");
790
795
  } else if (taskStatus.unavailable.length) {
791
796
  console.log(" Scheduled Tasks: unable to verify from this process");
792
797
  console.log(" run relay doctor in a normal terminal to check autostart");
@@ -795,7 +800,7 @@ async function cmdDoctor(flags = {}) {
795
800
  // Windows 11 overflow chevron) is the only way back to the pill.
796
801
  if (windowsStartMenuShortcutMissing()) {
797
802
  console.log(` MISSING Start Menu shortcut: ${windowsStartMenuShortcutPath()}`);
798
- console.log(" fix with: relay repair-desktop");
803
+ console.log(" fix with: relay repair-installation");
799
804
  } else {
800
805
  console.log(" Start Menu shortcut: ok (Start → \"relay\" opens the pill)");
801
806
  }
@@ -876,9 +881,8 @@ function cmdEnv(positional = [], flags = {}) {
876
881
 
877
882
  function cmdUpdateChannel(positional = []) {
878
883
  const requested = String(positional[0] || "").trim().toLowerCase();
879
- const overridden = Boolean(process.env.RELAY_UPDATE_CHANNEL);
880
884
  if (!requested) {
881
- const source = overridden ? " (from RELAY_UPDATE_CHANNEL)" : "";
885
+ const source = process.env.RELAY_UPDATE_CHANNEL ? " (from RELAY_UPDATE_CHANNEL)" : "";
882
886
  console.log(`[relay] update channel: ${updateChannel()}${source}`);
883
887
  console.log(`[relay] switch with: relay update-channel ${UPDATE_CHANNEL_DEV} | relay update-channel ${UPDATE_CHANNEL_STAGING} | relay update-channel ${UPDATE_CHANNEL_STABLE}`);
884
888
  return;
@@ -888,18 +892,13 @@ function cmdUpdateChannel(positional = []) {
888
892
  process.exitCode = 1;
889
893
  return;
890
894
  }
891
- writeConfig({ updateChannel: requested });
892
- if (overridden) {
893
- console.log(`[relay] saved channel ${requested}, but RELAY_UPDATE_CHANNEL=${process.env.RELAY_UPDATE_CHANNEL} overrides it in this environment`);
894
- return;
895
- }
896
- if (requested === UPDATE_CHANNEL_DEV) {
897
- console.log("[relay] update channel set to dev — this machine now installs builds deliberately promoted for dev testing (usually within a minute of the daemon's next check)");
898
- } else if (requested === UPDATE_CHANNEL_STAGING) {
899
- console.log("[relay] update channel set to staging — this machine now installs production-like release candidates deliberately promoted to staging");
900
- } else {
901
- console.log("[relay] update channel set to stable — this machine keeps its current build and follows production releases from here on");
902
- }
895
+ // Release code and its server contract are one environment. Keeping these as
896
+ // independent switches allowed a dev Companion to keep calling production,
897
+ // where unreleased routes (notably Slack) correctly do not exist. Preserve
898
+ // the old command as an alias, but make the resulting state coherent.
899
+ if (requested === UPDATE_CHANNEL_DEV) return cmdEnv(["dev"]);
900
+ if (requested === UPDATE_CHANNEL_STAGING) return cmdEnv(["staging"]);
901
+ if (requested === UPDATE_CHANNEL_STABLE) return cmdEnv(["prod"]);
903
902
  }
904
903
 
905
904
  async function cmdE2eeHistoryImport(flags) {
@@ -974,6 +973,7 @@ async function main() {
974
973
  return cmdPair(flags);
975
974
  case "install":
976
975
  return cmdInstall(flags);
976
+ case "repair-installation":
977
977
  case "repair-desktop":
978
978
  return cmdRepairDesktop(flags);
979
979
  case "repair-runtime":
@@ -1048,7 +1048,8 @@ async function main() {
1048
1048
  " relay install Add Relay to your agents",
1049
1049
  " relay uninstall Remove Relay from your agents and stop the daemon (keeps the pairing)",
1050
1050
  " relay uninstall --purge Also revoke this device and delete all local Relay state, as if never installed",
1051
- " relay repair-desktop [--no-restart] Repair Relay.app and background services without changing MCP/account state",
1051
+ " relay repair-installation [--no-restart] Non-destructively repair Relay.app and services; preserves account, encryption, messages, outbox, and preferences",
1052
+ " relay repair-desktop [--no-restart] Compatibility alias for repair-installation",
1052
1053
  " relay repair-runtime [--no-restart] Internal: refresh existing Relay registrations and runtime services",
1053
1054
  " relay pair [--api URL] [--web URL] [--code CODE] [--no-restart] Pair this machine only (no agent install); restarts running Relay services onto the new account",
1054
1055
  " relay tasks Pull and process the developer Request runtime once",
@@ -11,7 +11,8 @@ const os = require("node:os");
11
11
  const path = require("node:path");
12
12
  const { spawnSync } = require("node:child_process");
13
13
  const { pipeline } = require("node:stream/promises");
14
- const { exactRuntimeHealth } = require("./runtime-health.cjs");
14
+ const { activateMacRuntimeServices, exactRuntimeHealth } = require("./runtime-health.cjs");
15
+ const { verifyRuntimeExecutables } = require("./runtime-executables.cjs");
15
16
 
16
17
  const RELEASE_ORIGIN = "https://api.sendrelays.com";
17
18
  const RELEASE_BASE_PATH = "/v1/companion-releases";
@@ -19,7 +20,7 @@ const PACKAGE_NAME = "relay-companion";
19
20
  const WINDOWS_RELAY_TASKS = ["Relay Companion Pill", "Relay Companion Daemon"];
20
21
  const WINDOWS_STOP_RELAY_SERVICES_PS = [
21
22
  "$p=Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {",
22
- " $_.CommandLine -and (($_.CommandLine -match '[\\\\/]relay\\.js.*\\bdaemon\\b') -or ($_.CommandLine -match '[\\\\/]overlay[\\\\/]main\\.cjs'))",
23
+ " $_.CommandLine -and ($_.CommandLine -match '[\\\\/]node_modules[\\\\/]relay-companion[\\\\/]') -and (($_.CommandLine -match '[\\\\/]relay\\.js.*\\bdaemon\\b') -or ($_.CommandLine -match '[\\\\/]overlay[\\\\/]main\\.cjs'))",
23
24
  "}; foreach($x in $p){ try { Invoke-CimMethod -InputObject $x -MethodName Terminate -ErrorAction Stop | Out-Null } catch {} }",
24
25
  ].join(" ");
25
26
  const packageJson = require("../package.json");
@@ -426,11 +427,21 @@ function verifyExtractedRuntime(packageRoot, version, platformKey) {
426
427
  }
427
428
  const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
428
429
  if (manifest.name !== PACKAGE_NAME || manifest.version !== version) fail("Relay runtime package identity is invalid.");
429
- const electron = platformKey.startsWith("win32-")
430
- ? path.join(path.dirname(packageRoot), "electron", "dist", "electron.exe")
431
- : path.join(path.dirname(packageRoot), "electron", "dist", "Electron.app", "Contents", "MacOS", "Electron");
432
- if (!fs.statSync(electron, { throwIfNoEntry: false })?.isFile()) fail("Relay runtime has no verified native Electron executable.");
433
- return { packageRoot, bin: path.join(packageRoot, "bin", "relay.js"), electron };
430
+ const bin = path.join(packageRoot, "bin", "relay.js");
431
+ if (!platformKey.startsWith("win32-")) {
432
+ try { fs.accessSync(bin, fs.constants.X_OK); }
433
+ catch { fail("Relay runtime CLI is not executable."); }
434
+ }
435
+ const executables = verifyRuntimeExecutables(packageRoot, { platform: platformKey });
436
+ if (!executables.ok) {
437
+ fail(`Relay runtime executable verification failed (${executables.reason}${executables.detail ? `: ${executables.detail}` : ""}).`);
438
+ }
439
+ return {
440
+ packageRoot,
441
+ bin,
442
+ electron: executables.electronPath,
443
+ executablePaths: executables.paths.map((entry) => entry.path),
444
+ };
434
445
  }
435
446
 
436
447
  function runtimeLayout(version, platformKey) {
@@ -586,6 +597,7 @@ function stopPreviousWindowsRuntime({ platform = process.platform, spawnImpl = s
586
597
 
587
598
  async function activateRuntime(layout, runtime, version, {
588
599
  platform = process.platform,
600
+ homeDir = os.homedir(),
589
601
  spawnImpl = spawnSync,
590
602
  existsSync = fs.existsSync,
591
603
  readFileSync = fs.readFileSync,
@@ -595,6 +607,9 @@ async function activateRuntime(layout, runtime, version, {
595
607
  healthAttempts = 60,
596
608
  healthIntervalMs = 500,
597
609
  sleep,
610
+ activateMacServices = activateMacRuntimeServices,
611
+ activationDeadlineMs = 90_000,
612
+ now = Date.now,
598
613
  } = {}) {
599
614
  let previous = null;
600
615
  try { previous = JSON.parse(readFileSync(layout.pointerPath, "utf8")); } catch {}
@@ -674,7 +689,12 @@ async function activateRuntime(layout, runtime, version, {
674
689
  fail(message);
675
690
  }
676
691
 
677
- const result = spawnImpl(process.execPath, [runtime.bin, "setup", "--no-trampoline", "--claim"], {
692
+ // On macOS setup owns registration, but the shared activation state machine
693
+ // owns service restart. This keeps first-install MCP/plist creation in the
694
+ // candidate while bootstrap and the updater use the same exact-root handoff.
695
+ const setupArgs = [runtime.bin, "setup", "--no-trampoline", "--claim"];
696
+ if (platform === "darwin") setupArgs.push("--no-restart");
697
+ const result = spawnImpl(process.execPath, setupArgs, {
678
698
  stdio: "inherit",
679
699
  windowsHide: true,
680
700
  env: { ...process.env, RELAY_BOOTSTRAP_ACTIVATED: "1" },
@@ -684,12 +704,32 @@ async function activateRuntime(layout, runtime, version, {
684
704
  rollback(message);
685
705
  fail(message);
686
706
  }
687
- const health = await waitForRuntimeHealth(runtime, {
688
- healthCheck,
689
- attempts: healthAttempts,
690
- intervalMs: healthIntervalMs,
691
- ...(sleep ? { sleep } : {}),
692
- });
707
+ let health = null;
708
+ if (platform === "darwin") {
709
+ const activated = await activateMacServices(runtime, {
710
+ homeDir,
711
+ platform,
712
+ run: spawnImpl,
713
+ healthCheck,
714
+ activationDeadlineMs,
715
+ now,
716
+ ...(sleep ? { sleep } : {}),
717
+ });
718
+ if (!activated?.ok) {
719
+ const detail = activated?.detail ? ` (${activated.detail})` : "";
720
+ const message = `Relay runtime activation could not start the exact registered macOS services${detail}.`;
721
+ rollback(message);
722
+ fail(message);
723
+ }
724
+ health = activated.health;
725
+ } else {
726
+ health = await waitForRuntimeHealth(runtime, {
727
+ healthCheck,
728
+ attempts: healthAttempts,
729
+ intervalMs: healthIntervalMs,
730
+ ...(sleep ? { sleep } : {}),
731
+ });
732
+ }
693
733
  if (!health?.ok) {
694
734
  const missing = [health?.daemon ? "" : "daemon", health?.pill ? "" : "pill"].filter(Boolean).join(" and ");
695
735
  const message = `Relay runtime activation failed exact-root health${missing ? ` (${missing} did not start)` : ""}.`;
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
+ const MAC_HELPER_EXECUTABLES = [
7
+ ["Electron Helper.app", "Electron Helper"],
8
+ ["Electron Helper (GPU).app", "Electron Helper (GPU)"],
9
+ ["Electron Helper (Plugin).app", "Electron Helper (Plugin)"],
10
+ ["Electron Helper (Renderer).app", "Electron Helper (Renderer)"],
11
+ ];
12
+
13
+ function platformName(value = process.platform) {
14
+ return String(value).split("-")[0];
15
+ }
16
+
17
+ function pathsFor(platform) {
18
+ return platformName(platform) === "win32" ? path.win32 : path.posix;
19
+ }
20
+
21
+ function runtimeExecutableInventory(packageRoot, {
22
+ platform = process.platform,
23
+ existsSync = fs.existsSync,
24
+ } = {}) {
25
+ const name = platformName(platform);
26
+ const api = pathsFor(name);
27
+ const roots = [api.join(packageRoot, "node_modules", "electron"), api.join(api.dirname(packageRoot), "electron")];
28
+ const mainRelative = name === "win32"
29
+ ? api.join("dist", "electron.exe")
30
+ : name === "darwin"
31
+ ? api.join("dist", "Electron.app", "Contents", "MacOS", "Electron")
32
+ : api.join("dist", "electron");
33
+ const electronRoot = roots.find((root) => {
34
+ try { return existsSync(api.join(root, mainRelative)); } catch { return false; }
35
+ });
36
+ if (!electronRoot) return { ok: false, reason: "candidate-electron-missing", paths: [] };
37
+
38
+ const entries = [{ role: "electron", path: api.join(electronRoot, mainRelative) }];
39
+ if (name === "darwin") {
40
+ const frameworks = api.join(electronRoot, "dist", "Electron.app", "Contents", "Frameworks");
41
+ for (const [app, executable] of MAC_HELPER_EXECUTABLES) {
42
+ entries.push({ role: `helper:${executable}`, path: api.join(frameworks, app, "Contents", "MacOS", executable) });
43
+ }
44
+ entries.push({
45
+ role: "helper:chrome_crashpad_handler",
46
+ path: api.join(
47
+ frameworks,
48
+ "Electron Framework.framework",
49
+ "Versions",
50
+ "A",
51
+ "Helpers",
52
+ "chrome_crashpad_handler",
53
+ ),
54
+ });
55
+ }
56
+ return { ok: true, electronRoot, electronPath: entries[0].path, paths: entries };
57
+ }
58
+
59
+ function verifyRuntimeExecutables(packageRoot, {
60
+ platform = process.platform,
61
+ existsSync = fs.existsSync,
62
+ statSync = fs.statSync,
63
+ accessSync = fs.accessSync,
64
+ } = {}) {
65
+ const name = platformName(platform);
66
+ const inventory = runtimeExecutableInventory(packageRoot, { platform: name, existsSync });
67
+ if (!inventory.ok) return inventory;
68
+ for (const entry of inventory.paths) {
69
+ try {
70
+ if (!existsSync(entry.path)) return { ok: false, reason: "candidate-executable-missing", detail: entry.role };
71
+ if (!statSync(entry.path).isFile()) return { ok: false, reason: "candidate-executable-invalid", detail: entry.role };
72
+ if (name !== "win32") accessSync(entry.path, fs.constants.X_OK);
73
+ } catch (error) {
74
+ return {
75
+ ok: false,
76
+ reason: error?.code === "ENOENT" ? "candidate-executable-missing" : "candidate-not-executable",
77
+ detail: entry.role,
78
+ };
79
+ }
80
+ }
81
+ return { ...inventory, ok: true };
82
+ }
83
+
84
+ module.exports = {
85
+ MAC_HELPER_EXECUTABLES,
86
+ runtimeExecutableInventory,
87
+ verifyRuntimeExecutables,
88
+ };
@@ -1,8 +1,12 @@
1
1
  "use strict";
2
2
 
3
3
  const path = require("node:path");
4
+ const os = require("node:os");
4
5
  const { spawnSync } = require("node:child_process");
5
6
 
7
+ const DAEMON_LABEL = "work.relay.companion";
8
+ const PILL_LABEL = "work.relay.companion.pill";
9
+
6
10
  // A local checkout may run beside Relay without invalidating the installed
7
11
  // runtime. Only installed relay-companion trees count as production services.
8
12
  const SERVICE_TREE_RE = /node_modules[\\/]relay-companion[\\/]/i;
@@ -44,4 +48,148 @@ function exactRuntimeHealth(target, {
44
48
  return { ok: daemon && pill && !oldDaemon && !oldPill, daemon, pill, oldDaemon, oldPill, packageRoot: target.packageRoot };
45
49
  }
46
50
 
47
- module.exports = { exactRuntimeHealth, runtimeProcessCommands };
51
+ function installedServiceProcessRows(target, {
52
+ run = defaultRun,
53
+ includeTarget = false,
54
+ processId = process.pid,
55
+ } = {}) {
56
+ const result = run("/bin/ps", ["-axo", "pid=,command="]);
57
+ if (!commandOk(result)) {
58
+ return {
59
+ ok: false,
60
+ rows: [],
61
+ reason: "service-process-query-failed",
62
+ detail: result?.error?.message || String(result?.stderr || result?.stdout || "").trim(),
63
+ };
64
+ }
65
+ const targetNeedle = String(target?.packageRoot || "").replaceAll("\\", "/");
66
+ const rows = [];
67
+ for (const line of String(result.stdout || "").split("\n")) {
68
+ const match = line.match(/^\s*(\d+)\s+(.*)$/);
69
+ if (!match) continue;
70
+ const pid = Number(match[1]);
71
+ const command = match[2];
72
+ // This is the safety boundary: only an immutable/global production install
73
+ // has node_modules/relay-companion in its command. A checkout's daemon or
74
+ // Electron pill is deliberately invisible and must never be terminated.
75
+ if (!SERVICE_TREE_RE.test(command)) continue;
76
+ const isDaemon = /(?:^|[\\/])relay\.js(?:"|'|\s).*\bdaemon\b/i.test(command);
77
+ const isPill = /(?:^|[\\/])overlay[\\/]main\.cjs(?:"|'|\s|$)/i.test(command);
78
+ if (!isDaemon && !isPill) continue;
79
+ if (!includeTarget && targetNeedle && command.replaceAll("\\", "/").includes(targetNeedle)) continue;
80
+ if (!Number.isInteger(pid) || pid <= 0 || pid === processId) continue;
81
+ rows.push({ pid, command });
82
+ }
83
+ return { ok: true, rows };
84
+ }
85
+
86
+ async function terminateInstalledServiceProcesses(target, {
87
+ run = defaultRun,
88
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
89
+ graceMs = 2000,
90
+ includeTarget = false,
91
+ processId = process.pid,
92
+ } = {}) {
93
+ const first = installedServiceProcessRows(target, { run, includeTarget, processId });
94
+ if (!first.ok) return first;
95
+ if (!first.rows.length) return { ok: true, terminated: [] };
96
+ for (const row of first.rows) run("/bin/kill", ["-TERM", String(row.pid)]);
97
+ await sleep(graceMs);
98
+ const afterTerm = installedServiceProcessRows(target, { run, includeTarget, processId });
99
+ if (!afterTerm.ok) return afterTerm;
100
+ const originalPids = new Set(first.rows.map((row) => row.pid));
101
+ const survivors = afterTerm.rows.filter((row) => originalPids.has(row.pid));
102
+ for (const row of survivors) run("/bin/kill", ["-KILL", String(row.pid)]);
103
+ if (survivors.length) await sleep(500);
104
+ const afterKill = installedServiceProcessRows(target, { run, includeTarget, processId });
105
+ if (!afterKill.ok) return afterKill;
106
+ const remaining = afterKill.rows.filter((row) => originalPids.has(row.pid));
107
+ if (remaining.length) {
108
+ return { ok: false, reason: "service-process-stop-failed", detail: remaining.map((row) => row.pid).join(",") };
109
+ }
110
+ return { ok: true, terminated: first.rows.map((row) => row.pid) };
111
+ }
112
+
113
+ /**
114
+ * Restart Relay's two macOS jobs from the registrations already written by the
115
+ * caller, then prove that both processes belong to the requested package root.
116
+ * Registration remains owned by setup/repair; this helper owns only the narrow
117
+ * launchd handoff and never edits a plist, account file, or installed binary.
118
+ */
119
+ async function activateMacRuntimeServices(target, {
120
+ homeDir = os.homedir(),
121
+ platform = process.platform,
122
+ run = defaultRun,
123
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
124
+ domainPollMs = 250,
125
+ bootstrapDelayMs = 500,
126
+ healthPollMs = 500,
127
+ activationDeadlineMs = 90_000,
128
+ now = Date.now,
129
+ processId = process.pid,
130
+ healthCheck = exactRuntimeHealth,
131
+ } = {}) {
132
+ if (platform !== "darwin") return { ok: false, reason: "activation-platform-unsupported" };
133
+ if (!target?.packageRoot || !target?.bin) return { ok: false, reason: "activation-target-invalid" };
134
+ const domain = `gui/${typeof process.getuid === "function" ? process.getuid() : 0}`;
135
+ const agents = path.posix.join(String(homeDir || ""), "Library", "LaunchAgents");
136
+ const labels = [PILL_LABEL, DAEMON_LABEL];
137
+ const deadline = now() + Math.max(1, activationDeadlineMs);
138
+ for (const label of labels) run("/bin/launchctl", ["bootout", `${domain}/${label}`]);
139
+
140
+ // launchctl bootout is asynchronous. Keep quiescence, bootstrap, and liveness
141
+ // under one deadline so a racing EIO cannot create an unbounded retry loop.
142
+ while (now() <= deadline) {
143
+ const labelsPresent = labels.filter((label) => commandOk(run("/bin/launchctl", ["print", `${domain}/${label}`])));
144
+ if (labelsPresent.length) {
145
+ for (const label of labelsPresent) run("/bin/launchctl", ["bootout", `${domain}/${label}`]);
146
+ await sleep(domainPollMs);
147
+ continue;
148
+ }
149
+
150
+ const stopped = await terminateInstalledServiceProcesses(target, {
151
+ run,
152
+ sleep,
153
+ includeTarget: true,
154
+ processId,
155
+ });
156
+ if (!stopped.ok) return stopped;
157
+
158
+ let bootstrapFailure = null;
159
+ for (const label of labels) {
160
+ const started = run("/bin/launchctl", ["bootstrap", domain, path.posix.join(agents, `${label}.plist`)]);
161
+ if (!commandOk(started)) {
162
+ bootstrapFailure = { label, result: started };
163
+ break;
164
+ }
165
+ }
166
+ if (bootstrapFailure) {
167
+ for (const label of labels) run("/bin/launchctl", ["bootout", `${domain}/${label}`]);
168
+ const detail = String(bootstrapFailure.result?.stderr || bootstrapFailure.result?.stdout || "");
169
+ if (!/(?:Bootstrap failed:\s*5|Input\/output error|I\/O error)/i.test(detail)) {
170
+ return { ok: false, reason: "service-bootstrap-failed", detail: `${bootstrapFailure.label}: ${detail}` };
171
+ }
172
+ await sleep(bootstrapDelayMs);
173
+ continue;
174
+ }
175
+
176
+ while (now() <= deadline) {
177
+ const health = await healthCheck(target, { platform, run });
178
+ if (health?.ok) return { ok: true, health };
179
+ if (health?.oldDaemon || health?.oldPill) {
180
+ for (const label of labels) run("/bin/launchctl", ["bootout", `${domain}/${label}`]);
181
+ break;
182
+ }
183
+ await sleep(healthPollMs);
184
+ }
185
+ }
186
+ return { ok: false, reason: "activation-deadline-exceeded", detail: target.packageRoot };
187
+ }
188
+
189
+ module.exports = {
190
+ activateMacRuntimeServices,
191
+ exactRuntimeHealth,
192
+ installedServiceProcessRows,
193
+ runtimeProcessCommands,
194
+ terminateInstalledServiceProcesses,
195
+ };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.407",
3
+ "version": "0.1.409",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "relay-companion",
9
- "version": "0.1.407",
9
+ "version": "0.1.409",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@anthropic-ai/claude-agent-sdk": "0.3.195",