@indigoai-us/hq-cli 5.177.0 → 5.178.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.178.0] — 2026-09-24
6
+
7
+ ### Fixed
8
+
9
+ - Reloading a macOS LaunchAgent (`hq mesh daemon install`, including `--if-stale` and the post-update restart, `hq daemon install`, `hq agent kit` services, and bot agents) now waits for `launchctl bootout` to finish before bootstrapping. Before, bootstrap could race the old job's teardown, fail with "Bootstrap failed: 5: Input/output error", and leave the service unloaded with nothing running. Bootstrap now polls `launchctl print` for up to 10 seconds, retries EIO with backoff (0.5s to 8s), and on final failure either restarts the still-loaded previous job or reports that the service is NOT loaded with the `launchctl bootstrap` command to fix it.
10
+
5
11
  ## [5.177.0] — 2026-09-24
6
12
 
7
13
  ### Added
@@ -15,6 +15,7 @@ import * as path from "node:path";
15
15
  import { spawn } from "node:child_process";
16
16
  import { resolveHqBinary, resolveNodeBinary, preferStableHqBinary } from "../mesh/live/daemon/install.js";
17
17
  import { botLogPath, launchdLabel } from "./paths.js";
18
+ import { reloadLaunchd } from "../service-manager/launchd.js";
18
19
  import { nodeBinaryProblem, preferStableNodeBinary } from "../node-binary.js";
19
20
  export function detectBotPlatform(platform = process.platform) {
20
21
  if (platform === "darwin")
@@ -171,22 +172,17 @@ function bootoutLaunchd(p, deps) {
171
172
  }
172
173
  /**
173
174
  * `launchctl bootout` returns before the job is actually gone; a `bootstrap`
174
- * issued right away fails ("service already loaded" / EIO) and the old job
175
- * then finishes unloading, leaving nothing running even though `print` said
176
- * loaded a moment ago. Wait for the unload, then bootstrap with retries.
175
+ * issued right away fails with EIO and the old job then finishes unloading,
176
+ * leaving nothing running. Shared with the mesh daemon and kit services:
177
+ * wait for the unload, then bootstrap with backoff.
177
178
  */
178
179
  function rebootstrapLaunchd(p, deps) {
179
180
  const launchctl = deps.launchctl;
180
- const sleep = deps.sleepMs ?? blockingSleep;
181
- launchctl(["bootout", `${launchdDomain(deps.uid)}/${p.label}`]);
182
- for (let i = 0; i < 20 && launchdLoaded(p, deps); i++)
183
- sleep(250);
184
- let r = launchctl(["bootstrap", launchdDomain(deps.uid), p.plistPath]);
185
- for (let i = 0; i < 3 && !r.ok; i++) {
186
- sleep(500);
187
- r = launchctl(["bootstrap", launchdDomain(deps.uid), p.plistPath]);
188
- }
189
- return r;
181
+ const run = (_cmd, args) => {
182
+ const r = launchctl(args);
183
+ return { status: r.ok ? 0 : 1, stdout: r.stdout, stderr: r.stderr };
184
+ };
185
+ return reloadLaunchd(p.plistPath, run, deps.uid ?? os.userInfo().uid, { sleep: deps.sleepMs ?? blockingSleep });
190
186
  }
191
187
  function launchdLoaded(p, deps) {
192
188
  if (!deps.launchctl)
@@ -397,17 +393,24 @@ export function installBotDaemon(p, deps = {}) {
397
393
  if (deps.launchctl) {
398
394
  // bootout first so a re-install picks up a changed plist.
399
395
  const r = rebootstrapLaunchd(p, deps);
400
- loaded = r.ok || launchdLoaded(p, deps);
401
- if (!loaded) {
396
+ if (!r.ok) {
397
+ const detail = `launchctl bootstrap failed after ${r.bootstrapAttempts} attempts: ${(r.error ?? "").slice(0, 200)}`;
398
+ if (r.loaded) {
399
+ // The previous definition survived the bootout; keep it running.
400
+ deps.launchctl(["kickstart", "-k", `${launchdDomain(deps.uid)}/${p.label}`]);
401
+ }
402
402
  return {
403
403
  platform,
404
404
  action: "install",
405
405
  installed: true,
406
- loaded: false,
406
+ loaded: r.loaded,
407
407
  dest: p.plistPath,
408
- message: `Wrote ${p.plistPath} but launchctl bootstrap failed: ${(r.stderr || r.stdout).trim().slice(0, 200)}`,
408
+ message: r.loaded
409
+ ? `Wrote ${p.plistPath} but ${detail}. The previous definition of ${p.label} is still loaded and was restarted; the rewritten plist is not active yet.`
410
+ : `Wrote ${p.plistPath} but ${detail}. ${p.label} is NOT loaded. Load it with: launchctl bootstrap ${launchdDomain(deps.uid)} ${p.plistPath}`,
409
411
  };
410
412
  }
413
+ loaded = true;
411
414
  }
412
415
  return {
413
416
  platform,
@@ -15,7 +15,7 @@
15
15
  * services are added. Every service-manager call goes through `run`, so tests
16
16
  * never touch launchctl, systemctl or schtasks.
17
17
  */
18
- import { type RunCommand } from "../service-manager/launchd.js";
18
+ import { type RunCommand, type SleepFn } from "../service-manager/launchd.js";
19
19
  import type { MachineType } from "./machine-type.js";
20
20
  export declare const DAEMON_LAUNCHD_LABEL = "ai.getindigo.hq-daemon";
21
21
  export declare const DAEMON_SYSTEMD_UNIT = "hq-daemon.service";
@@ -63,6 +63,8 @@ export interface DaemonUnitResult {
63
63
  }
64
64
  export interface InstallDeps {
65
65
  run: RunCommand;
66
+ /** Blocking sleep for the launchd reload wait (tests inject). */
67
+ sleep?: SleepFn;
66
68
  }
67
69
  export declare const ROOT_REFUSAL = "hq daemon runs its services as the machine's login user, never as root.";
68
70
  /**
@@ -204,10 +204,10 @@ export function planDaemonUnit(host, options) {
204
204
  function commandError(what, r) {
205
205
  return new Error(`${what} failed: ${r.stderr.trim() || r.stdout.trim() || `exit ${r.status}`}`);
206
206
  }
207
- function activate(plan, host, run) {
207
+ function activate(plan, host, run, sleep) {
208
208
  switch (plan.kind) {
209
209
  case "launchd":
210
- activateLaunchd(plan.unitPath, run, host.uid);
210
+ activateLaunchd(plan.unitPath, run, host.uid, { sleep });
211
211
  return;
212
212
  case "systemd-user":
213
213
  activateSystemd(DAEMON_SYSTEMD_UNIT, run);
@@ -251,7 +251,7 @@ export function installDaemonUnit(host, options, deps) {
251
251
  else
252
252
  fs.writeFileSync(plan.unitPath, plan.rendered, { mode: plan.kind === "systemd-system" ? 0o644 : 0o600 });
253
253
  try {
254
- activate(plan, host, deps.run);
254
+ activate(plan, host, deps.run, deps.sleep);
255
255
  return { kind: plan.kind, unitPath: plan.unitPath, installed: true, running: true };
256
256
  }
257
257
  catch (err) {
@@ -184,8 +184,8 @@ export function restartDaemonServiceIfInstalled(opts = {}) {
184
184
  activateLaunchd(plist, asRunCommand(run), uid, { sleep: opts.sleep });
185
185
  return { attempted: true, restarted: true };
186
186
  }
187
- catch {
188
- return { attempted: true, restarted: false };
187
+ catch (err) {
188
+ return { attempted: true, restarted: false, message: err instanceof Error ? err.message : String(err) };
189
189
  }
190
190
  }
191
191
  const result = run("launchctl", ["kickstart", "-k", target]);
@@ -17,10 +17,14 @@ export type RunCommand = (cmd: string, args: string[]) => {
17
17
  stderr: string;
18
18
  };
19
19
  export type SleepFn = (ms: number) => void;
20
+ /** How long to wait for a booted-out job to leave launchd before bootstrapping anyway. */
21
+ export declare const LAUNCHD_UNLOAD_DEADLINE_MS = 10000;
22
+ /** Interval between `launchctl print` polls while the old job unloads. */
23
+ export declare const LAUNCHD_UNLOAD_POLL_MS = 250;
24
+ /** Backoff before each bootstrap retry on EIO (one retry per entry). */
25
+ export declare const LAUNCHD_BOOTSTRAP_BACKOFF_MS: readonly number[];
20
26
  /** Bootstrap attempts after bootout (first try + EIO retries). */
21
- export declare const LAUNCHD_BOOTSTRAP_ATTEMPTS = 5;
22
- /** Delay between bootstrap retries while the old job is still exiting. */
23
- export declare const LAUNCHD_BOOTSTRAP_RETRY_MS = 1000;
27
+ export declare const LAUNCHD_BOOTSTRAP_ATTEMPTS: number;
24
28
  export declare const defaultRunCommand: RunCommand;
25
29
  export declare function launchdServiceTarget(plistPath: string, uid?: number): string;
26
30
  /** launchd returns EIO (5) when bootstrap races the previous job's exit. */
@@ -38,13 +42,46 @@ export declare function launchdPlistProgramArguments(plist: string): string[];
38
42
  export declare function parseLaunchctlPrintArguments(stdout: string): string[];
39
43
  export declare function launchdProgramArgumentsDiffer(loaded: string[], plist: string[]): boolean;
40
44
  /**
41
- * (Re)load a plist so launchd picks up a rewritten ProgramArguments.
42
- * bootout is best-effort (not loaded yet is fine). bootstrap retries EIO a
43
- * bounded number of times — bootout returns before the old job has actually
44
- * exited. kickstart then starts the newly loaded program.
45
+ * Poll `launchctl print <target>` until the label is gone. Returns false when
46
+ * it is still loaded at the deadline. Elapsed time is counted in slept
47
+ * intervals so an injected sleep keeps tests deterministic.
48
+ */
49
+ export declare function waitForLaunchdUnload(target: string, run: RunCommand, opts?: {
50
+ sleep?: SleepFn;
51
+ deadlineMs?: number;
52
+ }): boolean;
53
+ export interface LaunchdReloadResult {
54
+ /** bootstrap of the plist succeeded. */
55
+ ok: boolean;
56
+ /** The label is present in launchd after the reload (new or previous definition). */
57
+ loaded: boolean;
58
+ /** The previous job was still loaded when the unload deadline passed. */
59
+ unloadTimedOut: boolean;
60
+ bootstrapAttempts: number;
61
+ /** Last bootstrap error when !ok. */
62
+ error?: string;
63
+ }
64
+ /**
65
+ * bootout, wait for the label to actually leave launchd, then bootstrap with
66
+ * backoff on EIO. `launchctl bootout` returns before launchd finishes tearing
67
+ * the job down, and a bootstrap issued in that window fails with
68
+ * "Bootstrap failed: 5: Input/output error" — after which the old job
69
+ * finishes unloading and nothing is left running.
70
+ */
71
+ export declare function reloadLaunchd(plistPath: string, run: RunCommand, uid?: number, opts?: {
72
+ sleep?: SleepFn;
73
+ unloadDeadlineMs?: number;
74
+ }): LaunchdReloadResult;
75
+ /**
76
+ * (Re)load a plist so launchd picks up a rewritten ProgramArguments, then
77
+ * kickstart it. Never leaves the service silently unloaded: when bootstrap
78
+ * keeps failing but the previous job is still loaded, that job is restarted
79
+ * and the error says so; when nothing is loaded, the error says the service
80
+ * is NOT loaded and gives the command to load it.
45
81
  */
46
82
  export declare function activateLaunchd(plistPath: string, run: RunCommand, uid?: number, opts?: {
47
83
  sleep?: SleepFn;
84
+ unloadDeadlineMs?: number;
48
85
  }): void;
49
86
  export declare function deactivateLaunchd(plistPath: string, run: RunCommand, uid?: number): void;
50
87
  export declare function launchdIsLoaded(label: string, run: RunCommand, uid?: number): boolean;
@@ -60,10 +60,14 @@ ${envXml}
60
60
  </plist>
61
61
  `;
62
62
  }
63
+ /** How long to wait for a booted-out job to leave launchd before bootstrapping anyway. */
64
+ export const LAUNCHD_UNLOAD_DEADLINE_MS = 10_000;
65
+ /** Interval between `launchctl print` polls while the old job unloads. */
66
+ export const LAUNCHD_UNLOAD_POLL_MS = 250;
67
+ /** Backoff before each bootstrap retry on EIO (one retry per entry). */
68
+ export const LAUNCHD_BOOTSTRAP_BACKOFF_MS = [500, 1000, 2000, 4000, 8000];
63
69
  /** Bootstrap attempts after bootout (first try + EIO retries). */
64
- export const LAUNCHD_BOOTSTRAP_ATTEMPTS = 5;
65
- /** Delay between bootstrap retries while the old job is still exiting. */
66
- export const LAUNCHD_BOOTSTRAP_RETRY_MS = 1000;
70
+ export const LAUNCHD_BOOTSTRAP_ATTEMPTS = LAUNCHD_BOOTSTRAP_BACKOFF_MS.length + 1;
67
71
  export const defaultRunCommand = (cmd, args) => {
68
72
  const r = spawnSync(cmd, args, { encoding: "utf8" });
69
73
  return {
@@ -127,25 +131,76 @@ export function launchdProgramArgumentsDiffer(loaded, plist) {
127
131
  return loaded.some((value, i) => value !== plist[i]);
128
132
  }
129
133
  /**
130
- * (Re)load a plist so launchd picks up a rewritten ProgramArguments.
131
- * bootout is best-effort (not loaded yet is fine). bootstrap retries EIO a
132
- * bounded number of times — bootout returns before the old job has actually
133
- * exited. kickstart then starts the newly loaded program.
134
+ * Poll `launchctl print <target>` until the label is gone. Returns false when
135
+ * it is still loaded at the deadline. Elapsed time is counted in slept
136
+ * intervals so an injected sleep keeps tests deterministic.
134
137
  */
135
- export function activateLaunchd(plistPath, run, uid, opts) {
138
+ export function waitForLaunchdUnload(target, run, opts) {
139
+ const sleep = opts?.sleep ?? blockingSleep;
140
+ const deadline = opts?.deadlineMs ?? LAUNCHD_UNLOAD_DEADLINE_MS;
141
+ for (let waited = 0; run("launchctl", ["print", target]).status === 0; waited += LAUNCHD_UNLOAD_POLL_MS) {
142
+ if (waited >= deadline)
143
+ return false;
144
+ sleep(LAUNCHD_UNLOAD_POLL_MS);
145
+ }
146
+ return true;
147
+ }
148
+ /**
149
+ * bootout, wait for the label to actually leave launchd, then bootstrap with
150
+ * backoff on EIO. `launchctl bootout` returns before launchd finishes tearing
151
+ * the job down, and a bootstrap issued in that window fails with
152
+ * "Bootstrap failed: 5: Input/output error" — after which the old job
153
+ * finishes unloading and nothing is left running.
154
+ */
155
+ export function reloadLaunchd(plistPath, run, uid, opts) {
136
156
  const domain = guiDomain(uid);
137
157
  const target = launchdServiceTarget(plistPath, uid);
138
- run("launchctl", ["bootout", target]);
139
158
  const sleep = opts?.sleep ?? blockingSleep;
159
+ // bootout is best-effort: "not loaded" on a first install is fine.
160
+ run("launchctl", ["bootout", target]);
161
+ const unloaded = waitForLaunchdUnload(target, run, { sleep, deadlineMs: opts?.unloadDeadlineMs });
140
162
  let last = run("launchctl", ["bootstrap", domain, plistPath]);
141
- for (let attempt = 1; attempt < LAUNCHD_BOOTSTRAP_ATTEMPTS && last.status !== 0 && isRetryableLaunchdBootstrap(last); attempt++) {
142
- sleep(LAUNCHD_BOOTSTRAP_RETRY_MS);
163
+ let attempts = 1;
164
+ for (const backoff of LAUNCHD_BOOTSTRAP_BACKOFF_MS) {
165
+ if (last.status === 0 || !isRetryableLaunchdBootstrap(last))
166
+ break;
167
+ sleep(backoff);
143
168
  last = run("launchctl", ["bootstrap", domain, plistPath]);
169
+ attempts++;
170
+ }
171
+ if (last.status === 0) {
172
+ return { ok: true, loaded: true, unloadTimedOut: !unloaded, bootstrapAttempts: attempts };
173
+ }
174
+ return {
175
+ ok: false,
176
+ loaded: run("launchctl", ["print", target]).status === 0,
177
+ unloadTimedOut: !unloaded,
178
+ bootstrapAttempts: attempts,
179
+ error: last.stderr.trim() || last.stdout.trim() || `exit ${last.status}`,
180
+ };
181
+ }
182
+ /**
183
+ * (Re)load a plist so launchd picks up a rewritten ProgramArguments, then
184
+ * kickstart it. Never leaves the service silently unloaded: when bootstrap
185
+ * keeps failing but the previous job is still loaded, that job is restarted
186
+ * and the error says so; when nothing is loaded, the error says the service
187
+ * is NOT loaded and gives the command to load it.
188
+ */
189
+ export function activateLaunchd(plistPath, run, uid, opts) {
190
+ const domain = guiDomain(uid);
191
+ const target = launchdServiceTarget(plistPath, uid);
192
+ const r = reloadLaunchd(plistPath, run, uid, opts);
193
+ if (r.ok) {
194
+ run("launchctl", ["kickstart", "-k", target]);
195
+ return;
144
196
  }
145
- if (last.status !== 0) {
146
- throw new Error(`launchctl bootstrap failed for ${plistPath}: ${last.stderr.trim() || last.stdout.trim() || `exit ${last.status}`}`);
197
+ const detail = `launchctl bootstrap failed for ${plistPath} after ${r.bootstrapAttempts} attempts: ${r.error}`;
198
+ if (r.loaded) {
199
+ run("launchctl", ["kickstart", "-k", target]);
200
+ throw new Error(`${detail}. The previous definition of ${target} is still loaded and was restarted; ` +
201
+ `the rewritten plist is not active yet. Retry, or run: launchctl bootout ${target} && launchctl bootstrap ${domain} ${plistPath}`);
147
202
  }
148
- run("launchctl", ["kickstart", "-k", target]);
203
+ throw new Error(`${detail}. ${target} is NOT loaded and will not run until it is. Load it with: launchctl bootstrap ${domain} ${plistPath}`);
149
204
  }
150
205
  export function deactivateLaunchd(plistPath, run, uid) {
151
206
  run("launchctl", ["bootout", guiDomain(uid), plistPath]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.177.0",
3
+ "version": "5.178.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {