@indigoai-us/hq-cli 5.177.0 → 5.179.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,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.179.0] — 2026-09-24
6
+
7
+ ## [5.178.0] — 2026-09-24
8
+
9
+ ### Fixed
10
+
11
+ - Work Mesh transcript-watch still tracks hook-covered sessions (interactive Claude/Codex) and enqueues `session_end` after 10 minutes of quiet. Hooks keep owning start/turn events; the quiet `session_end` is the channel work-report trigger those sessions were missing. Resume (transcript mtime or a later hook event) resets the timer. A SessionEnd hook within 60s of that emit is not treated as resume, so `/exit` does not double-fire.
12
+ - 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.
13
+
5
14
  ## [5.177.0] — 2026-09-24
6
15
 
7
16
  ### Added
@@ -46,7 +55,6 @@
46
55
  - Bot LaunchAgents and systemd units, the Work Mesh daemon unit, the `hq daemon` unit, and the lanes and agent-kit units now name node by its upgrade-stable path (`/opt/homebrew/bin/node` or `/opt/homebrew/opt/<formula>/bin/node`, or nvm's `current` link) instead of the versioned `/opt/homebrew/Cellar/node/<version>/bin/node`. Before, `brew upgrade node` deleted that path and launchd could no longer start the bots. The stable path is used only when it resolves to the same node binary; otherwise the unit keeps the resolved path as before.
47
56
  - Installed bot LaunchAgents that still name a versioned or missing node are repaired: `hq bot start` rewrites and reloads the agent, a bot started by launchd rewrites its own agent and reloads it from a detached shell before it takes any message, and `hq doctor` reports it (`bots.<name>.daemon-node`), with `hq doctor --fix` rewriting only the plist file and not reloading launchd. `hq bot list` and `hq bot status` flag a missing node binary. `hq mesh daemon install --if-stale` also reinstalls a same-version unit whose node path is missing or versioned.
48
57
 
49
-
50
58
  ## [5.172.0] — 2026-09-24
51
59
 
52
60
  ### Changed
@@ -83,7 +91,6 @@
83
91
 
84
92
  - Switching the HQ session company (including `hq-session.sh set company_slug`) clears the Work Mesh project and task, posts `session_end` to the previous company, and holds a personal target as `needs_company`.
85
93
 
86
-
87
94
  ## [5.169.0] — 2026-09-24
88
95
 
89
96
  ### Fixed
@@ -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]);
@@ -24,6 +24,14 @@
24
24
  * Network: harness claude-desktop | codex, source "transcript" on every event.
25
25
  * Local state keeps transcriptMarker for channel-card / Atlas before network sync;
26
26
  * localHarness is no longer written (harness carries claude-desktop on the wire).
27
+ *
28
+ * Hook-covered sessions (durable hook-written state, or a hook event within
29
+ * TRANSCRIPT_HOOK_COVER_MS) do not get synthesized session_start / turn_end —
30
+ * hooks own those. The watcher still tracks last activity (transcript mtime or
31
+ * hook events) and enqueues session_end after TRANSCRIPT_SESSION_END_MS of
32
+ * quiet so the channel work report has a trigger (US-050b). Resume resets the
33
+ * timer. A hook event within TRANSCRIPT_HOOK_COVER_MS of our own emit is the
34
+ * SessionEnd hook catching up and is not treated as resume.
27
35
  */
28
36
  import * as fs from "node:fs";
29
37
  import type { MembershipValidationResult } from "../../../work-context/config.js";
@@ -260,6 +268,14 @@ export declare class TranscriptWatcher {
260
268
  /** Record that a hook path emitted an event for sessionId (daemon flush). */
261
269
  noteHookEvent(sessionId: string, atMs?: number): void;
262
270
  tick(): Promise<TranscriptWatchTickResult>;
271
+ /**
272
+ * Hook-covered sessions already emit start/turns. Still track last activity
273
+ * and enqueue session_end after TRANSCRIPT_SESSION_END_MS of quiet so the
274
+ * channel work report has a trigger (US-050b). Resume (mtime or a later hook
275
+ * event) resets the timer. A hook event shortly after our own emit is the
276
+ * SessionEnd hook catching up — do not treat it as resume.
277
+ */
278
+ private tickHookCoveredQuietEnd;
263
279
  /**
264
280
  * Heavy ticks double the poll interval up to MAX; a quiet tick resets to base.
265
281
  */
@@ -24,6 +24,14 @@
24
24
  * Network: harness claude-desktop | codex, source "transcript" on every event.
25
25
  * Local state keeps transcriptMarker for channel-card / Atlas before network sync;
26
26
  * localHarness is no longer written (harness carries claude-desktop on the wire).
27
+ *
28
+ * Hook-covered sessions (durable hook-written state, or a hook event within
29
+ * TRANSCRIPT_HOOK_COVER_MS) do not get synthesized session_start / turn_end —
30
+ * hooks own those. The watcher still tracks last activity (transcript mtime or
31
+ * hook events) and enqueues session_end after TRANSCRIPT_SESSION_END_MS of
32
+ * quiet so the channel work report has a trigger (US-050b). Resume resets the
33
+ * timer. A hook event within TRANSCRIPT_HOOK_COVER_MS of our own emit is the
34
+ * SessionEnd hook catching up and is not treated as resume.
27
35
  */
28
36
  import * as fs from "node:fs";
29
37
  import * as os from "node:os";
@@ -632,8 +640,7 @@ export class TranscriptWatcher {
632
640
  lastHookEventAt: this.deps.lastHookEventAt,
633
641
  readState: this.readState,
634
642
  })) {
635
- result.skippedHookCovered += 1;
636
- this.tracked.delete(d.path);
643
+ this.tickHookCoveredQuietEnd(d, nowMs, result);
637
644
  continue;
638
645
  }
639
646
  let track = this.tracked.get(d.path);
@@ -719,6 +726,63 @@ export class TranscriptWatcher {
719
726
  this.tickInFlight = false;
720
727
  }
721
728
  }
729
+ /**
730
+ * Hook-covered sessions already emit start/turns. Still track last activity
731
+ * and enqueue session_end after TRANSCRIPT_SESSION_END_MS of quiet so the
732
+ * channel work report has a trigger (US-050b). Resume (mtime or a later hook
733
+ * event) resets the timer. A hook event shortly after our own emit is the
734
+ * SessionEnd hook catching up — do not treat it as resume.
735
+ */
736
+ tickHookCoveredQuietEnd(d, nowMs, result) {
737
+ result.skippedHookCovered += 1;
738
+ const hookAt = this.deps.lastHookEventAt?.get(d.sessionId);
739
+ const lastActivity = Math.max(d.mtimeMs, hookAt ?? 0);
740
+ let track = this.tracked.get(d.path);
741
+ if (!track) {
742
+ if (nowMs - lastActivity > TRANSCRIPT_FRESH_MS)
743
+ return;
744
+ track = {
745
+ path: d.path,
746
+ sessionId: d.sessionId,
747
+ kind: d.kind,
748
+ cwd: d.cwd,
749
+ harness: d.harness,
750
+ lastMtimeMs: d.mtimeMs,
751
+ lastChangeAtMs: lastActivity,
752
+ started: true,
753
+ ended: false,
754
+ pendingTurn: false,
755
+ seq: 0,
756
+ startRetryAttempts: 0,
757
+ nextStartRetryAtMs: 0,
758
+ };
759
+ this.tracked.set(d.path, track);
760
+ }
761
+ if (d.mtimeMs > track.lastMtimeMs) {
762
+ track.lastMtimeMs = d.mtimeMs;
763
+ track.lastChangeAtMs = nowMs;
764
+ if (track.ended)
765
+ track.ended = false;
766
+ }
767
+ if (hookAt !== undefined) {
768
+ const resumeFromHook = !track.ended ||
769
+ track.endedAtMs === undefined ||
770
+ hookAt - track.endedAtMs >= TRANSCRIPT_HOOK_COVER_MS;
771
+ if (resumeFromHook && hookAt > track.lastChangeAtMs) {
772
+ track.lastChangeAtMs = hookAt;
773
+ if (track.ended)
774
+ track.ended = false;
775
+ }
776
+ }
777
+ if (track.started &&
778
+ !track.ended &&
779
+ nowMs - track.lastChangeAtMs >= TRANSCRIPT_SESSION_END_MS) {
780
+ this.emitKind(track, "session_end", nowMs);
781
+ track.ended = true;
782
+ track.endedAtMs = nowMs;
783
+ result.ended += 1;
784
+ }
785
+ }
722
786
  /**
723
787
  * Heavy ticks double the poll interval up to MAX; a quiet tick resets to base.
724
788
  */
@@ -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.179.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {