@indigoai-us/hq-cli 5.172.0 → 5.173.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,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.173.0] — 2026-09-24
6
+
7
+ ### Fixed
8
+
9
+ - `hq bot stop <name>` run from inside that bot's own turn now lets the turn finish: it signals the bot to drain (finish the running turn, post the reply, exit 0), which launchd leaves stopped. Before, it booted out the bot's own launchd job, which killed the bot, the turn, and the stop command itself (exit 137), so no reply was posted. `hq bot restart <name>` from inside the bot's own turn is refused with instructions, and `hq bot start` from inside the bot skips the agent reload.
10
+ - 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.
11
+ - 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.
12
+
5
13
  ## [5.172.0] — 2026-09-24
6
14
 
7
15
  ### Changed
@@ -41,6 +41,11 @@ export interface BotRow {
41
41
  runtimeSignIn: RuntimeSignInIssue | null;
42
42
  daemonInstalled: boolean;
43
43
  daemonLoaded: boolean;
44
+ /**
45
+ * The startup agent's node binary is gone (a node upgrade removed the
46
+ * versioned path it names), so launchd cannot start the bot. Null when fine.
47
+ */
48
+ daemonNodeMissing: string | null;
44
49
  dir: string;
45
50
  introSentAt?: string;
46
51
  /** "personal" acts as its owner everywhere; "company" acts as itself. */
@@ -63,6 +68,19 @@ export declare function listBotNames(root: string): string[];
63
68
  export declare function botTokenSupplier(dir: string): () => Promise<string>;
64
69
  /** Spawn `hq bot run <name>` detached (no launchd). */
65
70
  export declare function spawnDetachedRun(name: string, dir: string): number;
71
+ /**
72
+ * `output`: "json" | "text" print a result; "silent" prints nothing — used
73
+ * when stop is a step inside rm/restart, whose own JSON must be the only
74
+ * document on stdout (the desktop app parses it; a second object made it
75
+ * "unreadable output: trailing characters").
76
+ */
77
+ /** Seams for the self-stop path, replaced in tests. */
78
+ export declare const botStopSeams: {
79
+ /** True when this process runs inside the bot process `botPid` (one of its turns). */
80
+ isInsideBot: (botPid: number) => boolean;
81
+ /** Ask the bot to finish its running turns and exit 0 (the drain signal). */
82
+ signalDrain: (botPid: number) => boolean;
83
+ };
66
84
  /**
67
85
  * The kind a new bot gets, before anything is provisioned. Personal unless
68
86
  * `--kind company` is given (the desktop app always passes --kind). A company
@@ -42,8 +42,10 @@ import { getCompanyUid, resolveCallerPersonUid } from "../utils/vault-api.js";
42
42
  import { resolveHqBinary } from "../lib/mesh/live/daemon/install.js";
43
43
  import { askDaemonToStart, askDaemonToStop, daemonOwnsProcess, daemonSupervises, daemonWillSupervise, handOffInstallHere, } from "../lib/daemon/handoff.js";
44
44
  import { daemonPaths } from "../lib/daemon/paths.js";
45
+ import { launchdLabel } from "../lib/bot/paths.js";
46
+ import { isAncestorProcess } from "../lib/bot/self-process.js";
45
47
  import { runtimeDisplayName } from "../lib/bot/runtime-sign-in.js";
46
- import { BotApi, BotApiError, BOT_RESTORE_DEFAULTS, buildBotLocalConfig, describeBotLocalConfig, orphanBotRunMessage, parseBotLocalConfig, removeOrphanBotDaemon, botDaemonStatus, botDir, botCredsPath, botLogPath, botsRoot, botTokenStateDir, botWorkerRelDir, buildBotDaemonPaths, createBotLogger, deleteBotCreds, installBotDaemon, repairBotDaemonIfStale, introDmText, isBotMemoryMode, botLocalMemoryDir, botMemoryMode, resolveBotMemoryDir, validateBotIntro, validateBotKickoff, validateBotEffort, validateBotModel, effectiveBotEffort, BOT_EFFORT_LEVELS, DEFAULT_BOT_EFFORT, BOT_MEMORY_MODES, BOT_KINDS, isBotKind, effectiveBotKind, effectiveBotCompanies, normalizeBotCompanies, SETUP_BOT_WORKER_ID, isBotRuntimeId, localNameForRemoteBot, remoteBotRunnability, isPidAlive, listBotWorkerOptions, patchBotConfig, readBotConfig, readBotCredsIdentity, readBotStatus, resolveBotWorker, runBot, runtimeFor, scaffoldBotMemory, scaffoldBotWorker, startBotDaemon, stopBotDaemon, uninstallBotDaemon, validateBotName, writeBotConfig, writeBotCreds, BOT_RUNTIMES, BOT_INTRO_MAX_CHARS, BOT_KICKOFF_MAX_CHARS, detectBotPlatform, } from "../lib/bot/index.js";
48
+ import { BotApi, BotApiError, BOT_RESTORE_DEFAULTS, buildBotLocalConfig, describeBotLocalConfig, orphanBotRunMessage, parseBotLocalConfig, removeOrphanBotDaemon, botDaemonStatus, botDir, botCredsPath, botLogPath, botsRoot, botTokenStateDir, botWorkerRelDir, buildBotDaemonPaths, createBotLogger, deleteBotCreds, installBotDaemon, repairBotDaemonIfStale, healBotDaemonNodeFromJob, introDmText, isBotMemoryMode, botLocalMemoryDir, botMemoryMode, resolveBotMemoryDir, validateBotIntro, validateBotKickoff, validateBotEffort, validateBotModel, effectiveBotEffort, BOT_EFFORT_LEVELS, DEFAULT_BOT_EFFORT, BOT_MEMORY_MODES, BOT_KINDS, isBotKind, effectiveBotKind, effectiveBotCompanies, normalizeBotCompanies, SETUP_BOT_WORKER_ID, isBotRuntimeId, localNameForRemoteBot, remoteBotRunnability, isPidAlive, listBotWorkerOptions, patchBotConfig, readBotConfig, readBotCredsIdentity, readBotStatus, resolveBotWorker, runBot, runtimeFor, scaffoldBotMemory, scaffoldBotWorker, startBotDaemon, stopBotDaemon, uninstallBotDaemon, validateBotName, writeBotConfig, writeBotCreds, BOT_RUNTIMES, BOT_INTRO_MAX_CHARS, BOT_KICKOFF_MAX_CHARS, detectBotPlatform, } from "../lib/bot/index.js";
47
49
  function collectRepeated(value, previous) {
48
50
  return [...previous, value];
49
51
  }
@@ -135,11 +137,14 @@ function rowFor(name, opts = {}) {
135
137
  const inflightTurns = readInflightTurns(dir);
136
138
  let daemonInstalled = false;
137
139
  let daemonLoaded = false;
140
+ let daemonNodeMissing = null;
138
141
  if (opts.withDaemon !== false) {
139
142
  try {
140
143
  const d = botDaemonStatus(buildBotDaemonPaths({ name, botDir: dir, hqRoot: config.hqRoot }), daemonDeps());
141
144
  daemonInstalled = d.installed;
142
145
  daemonLoaded = d.loaded;
146
+ if (d.nodeProblem === "missing" && d.nodeBinary)
147
+ daemonNodeMissing = d.nodeBinary;
143
148
  }
144
149
  catch {
145
150
  /* hq binary unresolvable in odd environments — daemon status unknown */
@@ -168,6 +173,7 @@ function rowFor(name, opts = {}) {
168
173
  runtimeSignIn: status?.runtimeSignIn ?? null,
169
174
  daemonInstalled,
170
175
  daemonLoaded,
176
+ daemonNodeMissing,
171
177
  dir,
172
178
  introSentAt: config.introSentAt,
173
179
  kind: effectiveBotKind(config),
@@ -278,7 +284,11 @@ async function startBot(name, dir, config, output, extra = {}) {
278
284
  }
279
285
  }
280
286
  else if (d.installed && process.platform === "darwin") {
281
- repairBotDaemonIfStale(paths, daemonDeps());
287
+ // A repair reloads the job; from inside one of the bot's own turns that
288
+ // bootout would kill the turn and this command with it.
289
+ const self = readBotStatus(dir);
290
+ if (!(self && isPidAlive(self.pid) && selfStopProbe(self.pid)))
291
+ repairBotDaemonIfStale(paths, daemonDeps());
282
292
  const r = startBotDaemon(paths, daemonDeps());
283
293
  how = r.message;
284
294
  }
@@ -318,6 +328,32 @@ async function startBot(name, dir, config, output, extra = {}) {
318
328
  * document on stdout (the desktop app parses it; a second object made it
319
329
  * "unreadable output: trailing characters").
320
330
  */
331
+ /** Seams for the self-stop path, replaced in tests. */
332
+ export const botStopSeams = {
333
+ /** True when this process runs inside the bot process `botPid` (one of its turns). */
334
+ isInsideBot: (botPid) => isAncestorProcess(botPid),
335
+ /** Ask the bot to finish its running turns and exit 0 (the drain signal). */
336
+ signalDrain: (botPid) => {
337
+ try {
338
+ process.kill(botPid, "SIGUSR2");
339
+ return true;
340
+ }
341
+ catch {
342
+ return false;
343
+ }
344
+ },
345
+ };
346
+ function selfStopProbe(botPid) {
347
+ try {
348
+ return botStopSeams.isInsideBot(botPid);
349
+ }
350
+ catch {
351
+ return false;
352
+ }
353
+ }
354
+ function requestSelfStopDrain(botPid) {
355
+ return botStopSeams.signalDrain(botPid);
356
+ }
321
357
  async function stopBot(name, dir, config, output) {
322
358
  if (hasPromotionHold(dir))
323
359
  throw new Error("This bot is held for cloud promotion. Continue its promotion before changing local settings or lifecycle.");
@@ -326,6 +362,25 @@ async function stopBot(name, dir, config, output) {
326
362
  const before = readBotStatus(dir);
327
363
  const wasRunning = !!before && isPidAlive(before.pid);
328
364
  const supervised = daemonSupervises(hqDaemon, `bots:${name}`);
365
+ // Stopping itself from inside one of its own turns: a bootout or a kill would
366
+ // take down this command and the turn with it, so the reply is lost and the
367
+ // job is left unloaded. Ask the bot to drain instead — finish the running
368
+ // turn (reply and ack included), then exit 0, which launchd's
369
+ // KeepAlive{SuccessfulExit:false} leaves down. `hq bot start` brings it back.
370
+ if (!supervised && wasRunning && selfStopProbe(before.pid)) {
371
+ const drained = requestSelfStopDrain(before.pid);
372
+ if (output === "silent")
373
+ return;
374
+ const how = drained ? "stops after this turn finishes" : "could not signal the bot";
375
+ if (output === "json") {
376
+ console.log(JSON.stringify({ ok: drained, name, signalled: drained, stopped: false, how, self: true }, null, 2));
377
+ return;
378
+ }
379
+ console.log(drained
380
+ ? chalk.green(`${name} will stop once this turn finishes`) + chalk.dim(` (stopping itself; start again with: hq bot start ${name})`)
381
+ : chalk.yellow(`${name} could not be asked to stop (pid ${before.pid})`));
382
+ return;
383
+ }
329
384
  // Keep the daemon's copy down either way, so it does not start one as soon as this bot is gone.
330
385
  if (supervised)
331
386
  askDaemonToStop(hqDaemon, `bots:${name}`);
@@ -732,6 +787,8 @@ async function runBotResident(nameArg) {
732
787
  console.log(orphanBotRunMessage(name, outcome));
733
788
  return;
734
789
  }
790
+ if (healNodePathFromJob(name, dir, config))
791
+ return;
735
792
  const token = botTokenSupplier(dir);
736
793
  const api = new BotApi({ token });
737
794
  const owner = new OwnerContextCache(() => lookupOwnerContext(config.ownerUid), Date.now, undefined, config.ownerUid);
@@ -756,6 +813,30 @@ async function runBotResident(nameArg) {
756
813
  await handle.done;
757
814
  void name;
758
815
  }
816
+ /**
817
+ * When this run IS the bot's launchd job and its plist names node by a
818
+ * versioned path (/opt/homebrew/Cellar/node/<v>/bin/node, removed by the next
819
+ * `brew upgrade node`), rewrite it to the stable alias and reload the job from
820
+ * a detached shell. Returns true when the caller should exit now (0, so
821
+ * KeepAlive leaves it alone) and let the reloaded job take over.
822
+ */
823
+ function healNodePathFromJob(name, dir, config) {
824
+ // Only the job's own main process (launchd, pid 1, is its parent): a copy
825
+ // spawned by the bot inherits XPC_SERVICE_NAME but is not the job.
826
+ if (process.platform !== "darwin" || process.env.XPC_SERVICE_NAME !== launchdLabel(name) || process.ppid !== 1)
827
+ return false;
828
+ try {
829
+ const healed = healBotDaemonNodeFromJob(buildBotDaemonPaths({ name, botDir: dir, hqRoot: config.hqRoot }), daemonDeps());
830
+ if (!healed)
831
+ return false;
832
+ createBotLogger(dir)("info", `startup agent ran node from ${healed.from}, which the next node upgrade removes; rewrote it to ${healed.to} and reloading`);
833
+ return true;
834
+ }
835
+ catch (err) {
836
+ createBotLogger(dir)("warn", `could not rewrite the startup agent's node path: ${err instanceof Error ? err.message : String(err)}`);
837
+ return false;
838
+ }
839
+ }
759
840
  // ── list / status / logs ─────────────────────────────────────────────────────
760
841
  async function runBotList(opts) {
761
842
  if (opts.remote) {
@@ -773,7 +854,7 @@ async function runBotList(opts) {
773
854
  return;
774
855
  }
775
856
  for (const r of rows) {
776
- console.log(`${chalk.bold(r.name.padEnd(16))} ${r.agentUid} ${r.runtime.padEnd(6)} ${humanOnline(r).padEnd(8)} ${r.processAlive ? chalk.green(`pid ${r.pid}`) : chalk.gray(r.state)}${chalk.dim(` ${r.kind === "company" ? `company bot: ${r.companies.join(", ") || "no company"}` : "personal"}`)}${r.daemonInstalled ? chalk.dim(" launchd") : ""}${r.workerId ? chalk.dim(` worker ${r.workerId}${r.companySlug ? ` (${r.companySlug})` : ""}`) : ""}`);
857
+ console.log(`${chalk.bold(r.name.padEnd(16))} ${r.agentUid} ${r.runtime.padEnd(6)} ${humanOnline(r).padEnd(8)} ${r.processAlive ? chalk.green(`pid ${r.pid}`) : chalk.gray(r.state)}${chalk.dim(` ${r.kind === "company" ? `company bot: ${r.companies.join(", ") || "no company"}` : "personal"}`)}${r.daemonInstalled ? chalk.dim(" launchd") : ""}${r.daemonNodeMissing ? chalk.red(` node missing (hq bot start ${r.name})`) : ""}${r.workerId ? chalk.dim(` worker ${r.workerId}${r.companySlug ? ` (${r.companySlug})` : ""}`) : ""}`);
777
858
  }
778
859
  }
779
860
  async function runBotStatus(nameArg, opts) {
@@ -802,6 +883,9 @@ async function runBotStatus(nameArg, opts) {
802
883
  }
803
884
  console.log(` answering: ${row.busy ? chalk.green(`yes${row.busySince ? `, since ${row.busySince}` : ""}`) : chalk.gray("no")}`);
804
885
  console.log(` launchd: ${row.daemonInstalled ? (row.daemonLoaded ? "installed, loaded" : "installed, not loaded") : "not installed"}`);
886
+ if (row.daemonNodeMissing) {
887
+ console.log(` ${chalk.red(`node missing: ${row.daemonNodeMissing}`)} — launchd cannot start ${name}; fix with: hq bot start ${name}`);
888
+ }
805
889
  console.log(` creds: ${creds ? `present for ${creds.entityUid}` : chalk.red("missing")}`);
806
890
  if (status) {
807
891
  console.log(` started: ${status.startedAt} restarts: ${status.restarts} replies: ${status.repliesSent}`);
@@ -1712,6 +1796,12 @@ export function registerBotCommand(program) {
1712
1796
  .option("--json", "Print machine-readable JSON")
1713
1797
  .action((name, opts) => wrap(async () => {
1714
1798
  const b = requireBot(name);
1799
+ const running = readBotStatus(b.dir);
1800
+ if (running && isPidAlive(running.pid) && selfStopProbe(running.pid)) {
1801
+ // The stop half would end this turn (and this command) before the
1802
+ // start half ran, leaving the bot down. Say so instead.
1803
+ throw new Error(`${b.name} cannot restart itself from inside one of its own turns: the stop would end this turn before the start ran. Run \`hq bot restart ${b.name}\` from outside the bot, or \`hq bot stop ${b.name}\` to stop it after this turn.`);
1804
+ }
1715
1805
  await stopBot(b.name, b.dir, b.config, "silent");
1716
1806
  await startBot(b.name, b.dir, b.config, opts.json ? "json" : "text");
1717
1807
  })());
@@ -10,6 +10,7 @@
10
10
  * `launchctl` is injected so tests never touch the real service manager.
11
11
  */
12
12
  import * as fs from "node:fs";
13
+ import { type NodeBinaryIo } from "../node-binary.js";
13
14
  export type BotDaemonPlatform = "darwin" | "linux" | "other";
14
15
  export interface BotDaemonPaths {
15
16
  home: string;
@@ -54,6 +55,9 @@ export interface BotDaemonResult {
54
55
  installed: boolean;
55
56
  loaded: boolean;
56
57
  dest?: string;
58
+ /** Set when the installed unit's node path is missing or versioned. */
59
+ nodeBinary?: string;
60
+ nodeProblem?: "missing" | "versioned";
57
61
  message: string;
58
62
  }
59
63
  export interface BotDaemonDeps {
@@ -87,11 +91,74 @@ export declare function installedDaemonHqBinary(p: BotDaemonPaths, io?: {
87
91
  existsSync(path: string): boolean;
88
92
  readFileSync(path: string, encoding: "utf8"): string;
89
93
  }): string | null;
94
+ /** The node binary baked into an installed unit, or null if none. */
95
+ export declare function installedDaemonNodeBinary(p: BotDaemonPaths, io?: {
96
+ existsSync(path: string): boolean;
97
+ readFileSync(path: string, encoding: "utf8"): string;
98
+ }): string | null;
99
+ /**
100
+ * What is wrong with the node path baked into the bot's installed unit:
101
+ * "missing" (launchd can no longer start the bot — a `brew upgrade node`
102
+ * removed the versioned Cellar directory), "versioned" (it still exists, but
103
+ * the next upgrade removes it; a stable alias for the same binary exists), or
104
+ * null when the unit is fine or not installed.
105
+ */
106
+ export declare function botDaemonNodeProblem(p: BotDaemonPaths, deps?: {
107
+ fsImpl?: Pick<typeof fs, "existsSync">;
108
+ readFile?: (file: string) => string;
109
+ nodeIo?: NodeBinaryIo;
110
+ }): {
111
+ nodeBinary: string;
112
+ problem: "missing" | "versioned";
113
+ replacement: string | null;
114
+ } | null;
90
115
  /**
91
116
  * Rewrite the unit when its hq path is missing or is the pre-move
92
- * toolchain/node/bin/hq while toolchain/npm-global/bin/hq exists.
117
+ * toolchain/node/bin/hq while toolchain/npm-global/bin/hq exists, or when its
118
+ * node path is missing or a versioned directory an upgrade will remove.
119
+ *
120
+ * A node-only repair keeps the hq script the unit already runs, so repairing
121
+ * from a worktree or a one-off build never repoints the bot at that build.
122
+ */
123
+ export declare function repairBotDaemonIfStale(p: BotDaemonPaths, deps?: BotDaemonDeps & {
124
+ readFile?: (file: string) => string;
125
+ nodeIo?: NodeBinaryIo;
126
+ }): BotDaemonResult | null;
127
+ /**
128
+ * The in-job half of the node-path self-heal, run by `hq bot run` when it is
129
+ * itself the launchd job. A `bootout` from inside the job kills the caller,
130
+ * so this only rewrites the plist and hands the reload to a detached shell in
131
+ * its own session; the caller then exits 0, which KeepAlive{SuccessfulExit:
132
+ * false} leaves alone, and the shell boots the old definition out and
133
+ * bootstraps the rewritten one (RunAtLoad starts the bot again). Runs before
134
+ * the bot takes any message, so no turn is cut short.
135
+ *
136
+ * Returns null (and changes nothing) unless the unit's node path is a
137
+ * versioned directory with a stable alias for the same binary.
138
+ */
139
+ export declare function healBotDaemonNodeFromJob(p: BotDaemonPaths, deps?: BotDaemonDeps & {
140
+ readFile?: (file: string) => string;
141
+ nodeIo?: NodeBinaryIo;
142
+ }): {
143
+ from: string;
144
+ to: string;
145
+ } | null;
146
+ /** `program = …` of the loaded job (`launchctl print`), or null when not loaded or unknown. */
147
+ export declare function loadedLaunchdProgram(p: BotDaemonPaths, deps: Pick<BotDaemonDeps, "launchctl" | "uid">): string | null;
148
+ /**
149
+ * Rewrite the plist's node path to the stable alias WITHOUT touching launchd:
150
+ * no bootout, no bootstrap, so a running bot's turn is never cut short and a
151
+ * stopped bot is not started. The new path applies at the next login, the next
152
+ * `hq bot start`, or the bot's next start (which reloads its own job). This is
153
+ * what `hq doctor --fix` runs, including unattended at session start.
93
154
  */
94
- export declare function repairBotDaemonIfStale(p: BotDaemonPaths, deps?: BotDaemonDeps): BotDaemonResult | null;
155
+ export declare function rewriteBotDaemonNodePath(p: BotDaemonPaths, deps?: Pick<BotDaemonDeps, "fsImpl" | "platform"> & {
156
+ readFile?: (file: string) => string;
157
+ nodeIo?: NodeBinaryIo;
158
+ }): {
159
+ from: string;
160
+ to: string;
161
+ } | null;
95
162
  export declare function installBotDaemon(p: BotDaemonPaths, deps?: BotDaemonDeps): BotDaemonResult;
96
163
  export declare function uninstallBotDaemon(p: BotDaemonPaths, deps?: BotDaemonDeps): BotDaemonResult;
97
164
  export declare function botDaemonStatus(p: BotDaemonPaths, deps?: BotDaemonDeps): BotDaemonResult;
@@ -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 { nodeBinaryProblem, preferStableNodeBinary } from "../node-binary.js";
18
19
  export function detectBotPlatform(platform = process.platform) {
19
20
  if (platform === "darwin")
20
21
  return "darwin";
@@ -211,17 +212,65 @@ export function installedDaemonHqBinary(p, io = fs) {
211
212
  }
212
213
  return null;
213
214
  }
215
+ /** The node binary baked into an installed unit, or null if none. */
216
+ export function installedDaemonNodeBinary(p, io = fs) {
217
+ if (io.existsSync(p.plistPath)) {
218
+ const args = launchdPlistProgramArguments(io.readFileSync(p.plistPath, "utf8"));
219
+ return args[0] ?? null;
220
+ }
221
+ if (io.existsSync(p.systemdUnitPath)) {
222
+ const unit = io.readFileSync(p.systemdUnitPath, "utf8");
223
+ const m = unit.match(/^ExecStart=(\S+)/m);
224
+ return m?.[1] ?? null;
225
+ }
226
+ return null;
227
+ }
228
+ /**
229
+ * What is wrong with the node path baked into the bot's installed unit:
230
+ * "missing" (launchd can no longer start the bot — a `brew upgrade node`
231
+ * removed the versioned Cellar directory), "versioned" (it still exists, but
232
+ * the next upgrade removes it; a stable alias for the same binary exists), or
233
+ * null when the unit is fine or not installed.
234
+ */
235
+ export function botDaemonNodeProblem(p, deps = {}) {
236
+ const io = deps.fsImpl ?? fs;
237
+ let node;
238
+ try {
239
+ node = installedDaemonNodeBinary(p, {
240
+ existsSync: (q) => io.existsSync(q),
241
+ readFileSync: (q) => (deps.readFile ?? ((f) => fs.readFileSync(f, "utf8")))(q),
242
+ });
243
+ }
244
+ catch {
245
+ return null;
246
+ }
247
+ if (!node)
248
+ return null;
249
+ const nodeOpts = deps.nodeIo ? { io: deps.nodeIo } : {};
250
+ const problem = nodeBinaryProblem(node, nodeOpts);
251
+ if (!problem)
252
+ return null;
253
+ // Prefer the stable alias of the node the unit already ran (same install),
254
+ // so a repair run from another node (an nvm shell) does not switch runtimes.
255
+ const stable = preferStableNodeBinary(node, nodeOpts);
256
+ return { nodeBinary: node, problem, replacement: stable !== node ? stable : null };
257
+ }
214
258
  /**
215
259
  * Rewrite the unit when its hq path is missing or is the pre-move
216
- * toolchain/node/bin/hq while toolchain/npm-global/bin/hq exists.
260
+ * toolchain/node/bin/hq while toolchain/npm-global/bin/hq exists, or when its
261
+ * node path is missing or a versioned directory an upgrade will remove.
262
+ *
263
+ * A node-only repair keeps the hq script the unit already runs, so repairing
264
+ * from a worktree or a one-off build never repoints the bot at that build.
217
265
  */
218
266
  export function repairBotDaemonIfStale(p, deps = {}) {
219
267
  const io = deps.fsImpl ?? fs;
268
+ const readFile = deps.readFile ?? ((f) => fs.readFileSync(f, "utf8"));
220
269
  let current;
221
270
  try {
222
271
  current = installedDaemonHqBinary(p, {
223
272
  existsSync: (q) => io.existsSync(q),
224
- readFileSync: (q) => fs.readFileSync(q, "utf8"),
273
+ readFileSync: (q) => readFile(q),
225
274
  });
226
275
  }
227
276
  catch {
@@ -229,11 +278,107 @@ export function repairBotDaemonIfStale(p, deps = {}) {
229
278
  }
230
279
  if (!current)
231
280
  return null;
232
- const missing = !io.existsSync(current);
233
- const stale = preferStableHqBinary(current) !== current;
234
- if (!missing && !stale)
281
+ const hqStale = !io.existsSync(current) || preferStableHqBinary(current) !== current;
282
+ const found = botDaemonNodeProblem(p, { fsImpl: io, readFile, ...(deps.nodeIo ? { nodeIo: deps.nodeIo } : {}) });
283
+ // Rewriting only helps when it changes the node path.
284
+ const nodeTarget = found ? (found.replacement ?? p.nodeBinary) : p.nodeBinary;
285
+ const node = found && found.nodeBinary !== nodeTarget ? found : null;
286
+ // A plist already rewritten on disk (`hq doctor --fix`) whose loaded job still
287
+ // runs the versioned or missing node needs a reload all the same.
288
+ const loaded = loadedLaunchdProgram(p, deps);
289
+ const loadedStale = !!loaded && loaded !== nodeTarget && nodeBinaryProblem(loaded, deps.nodeIo ? { io: deps.nodeIo } : {}) !== null;
290
+ if (!hqStale && !node && !loadedStale)
291
+ return null;
292
+ return installBotDaemon({ ...p, nodeBinary: nodeTarget, ...(hqStale ? {} : { hqBinary: current }) }, deps);
293
+ }
294
+ /**
295
+ * The in-job half of the node-path self-heal, run by `hq bot run` when it is
296
+ * itself the launchd job. A `bootout` from inside the job kills the caller,
297
+ * so this only rewrites the plist and hands the reload to a detached shell in
298
+ * its own session; the caller then exits 0, which KeepAlive{SuccessfulExit:
299
+ * false} leaves alone, and the shell boots the old definition out and
300
+ * bootstraps the rewritten one (RunAtLoad starts the bot again). Runs before
301
+ * the bot takes any message, so no turn is cut short.
302
+ *
303
+ * Returns null (and changes nothing) unless the unit's node path is a
304
+ * versioned directory with a stable alias for the same binary.
305
+ */
306
+ export function healBotDaemonNodeFromJob(p, deps = {}) {
307
+ const platform = deps.platform ?? detectBotPlatform();
308
+ if (platform !== "darwin")
309
+ return null;
310
+ const io = deps.fsImpl ?? fs;
311
+ const readFile = deps.readFile ?? ((f) => fs.readFileSync(f, "utf8"));
312
+ const nodeOpts = deps.nodeIo ? { io: deps.nodeIo } : {};
313
+ const file = botDaemonNodeProblem(p, { fsImpl: io, readFile, ...(deps.nodeIo ? { nodeIo: deps.nodeIo } : {}) });
314
+ const fileTarget = file?.replacement ?? null;
315
+ const fileNeedsRewrite = !!file && file.problem === "versioned" && !!fileTarget;
316
+ // The plist may already be stable (rewritten by `hq doctor --fix`) while the
317
+ // loaded definition — what launchd execs on the next respawn — is not.
318
+ const loaded = loadedLaunchdProgram(p, deps);
319
+ const loadedTarget = loaded ? preferStableNodeBinary(loaded, nodeOpts) : null;
320
+ const loadedStale = !!loaded && !!loadedTarget && loadedTarget !== loaded && nodeBinaryProblem(loaded, nodeOpts) === "versioned";
321
+ if (!fileNeedsRewrite && !loadedStale)
322
+ return null;
323
+ if (fileNeedsRewrite) {
324
+ let hq;
325
+ try {
326
+ hq = installedDaemonHqBinary(p, { existsSync: (q) => io.existsSync(q), readFileSync: (q) => readFile(q) });
327
+ }
328
+ catch {
329
+ return null;
330
+ }
331
+ // Rewrite only the file here (no launchctl: a bootout would kill us).
332
+ installBotDaemon({ ...p, nodeBinary: fileTarget, hqBinary: hq ?? p.hqBinary }, { ...deps, platform, launchctl: undefined });
333
+ }
334
+ const domain = launchdDomain(deps.uid);
335
+ const target = `${domain}/${p.label}`;
336
+ const script = [
337
+ "sleep 1",
338
+ `launchctl bootout ${shQuote(target)} >/dev/null 2>&1`,
339
+ `i=0; while launchctl print ${shQuote(target)} >/dev/null 2>&1 && [ $i -lt 40 ]; do sleep 0.25; i=$((i+1)); done`,
340
+ `for d in 0 1 2 4; do sleep $d; launchctl bootstrap ${shQuote(domain)} ${shQuote(p.plistPath)} >/dev/null 2>&1 && exit 0; done`,
341
+ "exit 1",
342
+ ].join("; ");
343
+ (deps.spawnDetached ?? defaultSpawnDetached)("/bin/sh", ["-c", script]);
344
+ return fileNeedsRewrite ? { from: file.nodeBinary, to: fileTarget } : { from: loaded, to: loadedTarget };
345
+ }
346
+ /** `program = …` of the loaded job (`launchctl print`), or null when not loaded or unknown. */
347
+ export function loadedLaunchdProgram(p, deps) {
348
+ if (!deps.launchctl)
349
+ return null;
350
+ const r = deps.launchctl(["print", `${launchdDomain(deps.uid)}/${p.label}`]);
351
+ if (!r.ok)
235
352
  return null;
236
- return installBotDaemon(p, deps);
353
+ const m = /^\s*program = (.+?)\s*$/m.exec(r.stdout);
354
+ return m?.[1] ?? null;
355
+ }
356
+ /**
357
+ * Rewrite the plist's node path to the stable alias WITHOUT touching launchd:
358
+ * no bootout, no bootstrap, so a running bot's turn is never cut short and a
359
+ * stopped bot is not started. The new path applies at the next login, the next
360
+ * `hq bot start`, or the bot's next start (which reloads its own job). This is
361
+ * what `hq doctor --fix` runs, including unattended at session start.
362
+ */
363
+ export function rewriteBotDaemonNodePath(p, deps = {}) {
364
+ const platform = deps.platform ?? detectBotPlatform();
365
+ if (platform !== "darwin")
366
+ return null;
367
+ const io = deps.fsImpl ?? fs;
368
+ const readFile = deps.readFile ?? ((f) => fs.readFileSync(f, "utf8"));
369
+ const file = botDaemonNodeProblem(p, { fsImpl: io, readFile, ...(deps.nodeIo ? { nodeIo: deps.nodeIo } : {}) });
370
+ const to = file ? (file.replacement ?? p.nodeBinary) : null;
371
+ if (!file || !to || file.nodeBinary === to)
372
+ return null;
373
+ let hq;
374
+ try {
375
+ hq = installedDaemonHqBinary(p, { existsSync: (q) => io.existsSync(q), readFileSync: (q) => readFile(q) });
376
+ }
377
+ catch {
378
+ return null;
379
+ }
380
+ installBotDaemon({ ...p, nodeBinary: to, hqBinary: hq ?? p.hqBinary }, { fsImpl: deps.fsImpl, platform });
381
+ return { from: file.nodeBinary, to };
237
382
  }
238
383
  export function installBotDaemon(p, deps = {}) {
239
384
  const platform = deps.platform ?? detectBotPlatform();
@@ -341,14 +486,19 @@ export function botDaemonStatus(p, deps = {}) {
341
486
  if (platform === "darwin") {
342
487
  const installed = io.existsSync(p.plistPath);
343
488
  const loaded = installed && launchdLoaded(p, deps);
489
+ const node = installed ? botDaemonNodeProblem(p, { fsImpl: io }) : null;
490
+ const nodeNote = node?.problem === "missing"
491
+ ? `; node binary missing (${node.nodeBinary}) — launchd cannot start it; run: hq bot start ${p.name}`
492
+ : "";
344
493
  return {
345
494
  platform,
346
495
  action: "status",
347
496
  installed,
348
497
  loaded,
349
498
  dest: p.plistPath,
499
+ ...(node ? { nodeBinary: node.nodeBinary, nodeProblem: node.problem } : {}),
350
500
  message: installed
351
- ? `LaunchAgent ${p.label}: ${loaded ? "loaded" : "installed (not loaded)"}`
501
+ ? `LaunchAgent ${p.label}: ${loaded ? "loaded" : "installed (not loaded)"}${nodeNote}`
352
502
  : `LaunchAgent ${p.label}: not installed`,
353
503
  };
354
504
  }
@@ -1565,7 +1565,7 @@ export async function runBot(deps) {
1565
1565
  for (const turn of currentTurns)
1566
1566
  turn.abort();
1567
1567
  await Promise.allSettled([heartbeatLoop, inboxLoop]);
1568
- await finish("stopped", 0, mode === "drain" ? "promotion drain completed" : "stop requested");
1568
+ await finish("stopped", 0, mode === "drain" ? "drain completed (running turns finished)" : "stop requested");
1569
1569
  };
1570
1570
  const onSignal = () => {
1571
1571
  void stop();
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Is this `hq` command running inside a bot's own turn?
3
+ *
4
+ * A bot's model turn is a child of the bot process, and the tools it runs are
5
+ * children of that. When the turn runs `hq bot stop <itself>`, the stop used
6
+ * to `launchctl bootout` the bot's own job. launchd then kills the whole job —
7
+ * the bot, the model turn, and the very `hq bot stop` asking — so the reply
8
+ * the turn was writing is lost (exit 137) and the job is left unloaded.
9
+ * Seen on 2026-09-24: george stopped itself mid-turn to break a bot-to-bot
10
+ * loop, and nothing was posted after the stop.
11
+ *
12
+ * The caller walks its own parent chain; `parentOf` is injectable for tests.
13
+ */
14
+ export type ParentPidLookup = (pid: number) => number | null;
15
+ /** Parent pid via `ps` (macOS and Linux), or null when it cannot be read. */
16
+ export declare const defaultParentOf: ParentPidLookup;
17
+ /** True when `ancestorPid` is `startPid` itself or one of its ancestors. */
18
+ export declare function isAncestorProcess(ancestorPid: number, startPid?: number, parentOf?: ParentPidLookup, maxDepth?: number): boolean;
19
+ //# sourceMappingURL=self-process.d.ts.map
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Is this `hq` command running inside a bot's own turn?
3
+ *
4
+ * A bot's model turn is a child of the bot process, and the tools it runs are
5
+ * children of that. When the turn runs `hq bot stop <itself>`, the stop used
6
+ * to `launchctl bootout` the bot's own job. launchd then kills the whole job —
7
+ * the bot, the model turn, and the very `hq bot stop` asking — so the reply
8
+ * the turn was writing is lost (exit 137) and the job is left unloaded.
9
+ * Seen on 2026-09-24: george stopped itself mid-turn to break a bot-to-bot
10
+ * loop, and nothing was posted after the stop.
11
+ *
12
+ * The caller walks its own parent chain; `parentOf` is injectable for tests.
13
+ */
14
+ import { spawnSync } from "node:child_process";
15
+ /** Parent pid via `ps` (macOS and Linux), or null when it cannot be read. */
16
+ export const defaultParentOf = (pid) => {
17
+ const r = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" });
18
+ if (r.status !== 0 || typeof r.stdout !== "string")
19
+ return null;
20
+ const n = Number.parseInt(r.stdout.trim(), 10);
21
+ return Number.isFinite(n) && n > 0 ? n : null;
22
+ };
23
+ /** True when `ancestorPid` is `startPid` itself or one of its ancestors. */
24
+ export function isAncestorProcess(ancestorPid, startPid = process.pid, parentOf = defaultParentOf, maxDepth = 64) {
25
+ if (!Number.isInteger(ancestorPid) || ancestorPid <= 1)
26
+ return false;
27
+ let pid = startPid;
28
+ for (let depth = 0; pid !== null && pid > 1 && depth < maxDepth; depth++) {
29
+ if (pid === ancestorPid)
30
+ return true;
31
+ pid = parentOf(pid);
32
+ }
33
+ return false;
34
+ }
35
+ //# sourceMappingURL=self-process.js.map
@@ -1,9 +1,12 @@
1
+ import { type NodeBinaryIo } from "../../node-binary.js";
1
2
  import type { CheckContext, CheckFamily, CheckResult } from "../types.js";
2
3
  export declare const BOTS_FAMILY_ID = "bots";
3
4
  export declare const BOTS_FAMILY_TITLE = "Local bots";
4
5
  export interface BotsDoctorDeps {
5
6
  home: () => string;
6
7
  root: (home?: string) => string;
8
+ /** Filesystem view used to judge the startup agent's node path (tests). */
9
+ nodeIo?: NodeBinaryIo;
7
10
  }
8
11
  export declare const botsFamily: CheckFamily;
9
12
  export declare function checkBots(_context: CheckContext, deps?: BotsDoctorDeps): CheckResult[];
@@ -9,6 +9,7 @@ import * as path from "node:path";
9
9
  import { botsRoot, botDir, launchdLabel } from "../../bot/paths.js";
10
10
  import { readBotConfig } from "../../bot/config.js";
11
11
  import { isPidAlive, readBotStatus } from "../../bot/status.js";
12
+ import { nodeBinaryProblem } from "../../node-binary.js";
12
13
  export const BOTS_FAMILY_ID = "bots";
13
14
  export const BOTS_FAMILY_TITLE = "Local bots";
14
15
  const DEFAULT_DEPS = {
@@ -34,12 +35,14 @@ export function checkBots(_context, deps = DEFAULT_DEPS) {
34
35
  ];
35
36
  }
36
37
  }
37
- function launchdPlistHqBinary(plist) {
38
+ function launchdPlistArgs(plist) {
38
39
  const block = plist.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
39
40
  if (!block)
40
- return null;
41
- const args = [...block[1].matchAll(/<string>([^<]*)<\/string>/g)].map((m) => m[1]);
42
- return args[1] ?? null;
41
+ return [];
42
+ return [...block[1].matchAll(/<string>([^<]*)<\/string>/g)].map((m) => m[1]);
43
+ }
44
+ function launchdPlistHqBinary(plist) {
45
+ return launchdPlistArgs(plist)[1] ?? null;
43
46
  }
44
47
  function runChecks(deps) {
45
48
  const home = deps.home();
@@ -115,11 +118,45 @@ function runChecks(deps) {
115
118
  const plistPath = path.join(home, "Library", "LaunchAgents", `${launchdLabel(name)}.plist`);
116
119
  if (fs.existsSync(plistPath)) {
117
120
  let hqPath;
121
+ let nodePath;
118
122
  try {
119
- hqPath = launchdPlistHqBinary(fs.readFileSync(plistPath, "utf8"));
123
+ const text = fs.readFileSync(plistPath, "utf8");
124
+ hqPath = launchdPlistHqBinary(text);
125
+ nodePath = launchdPlistArgs(text)[0] ?? null;
120
126
  }
121
127
  catch {
122
128
  hqPath = null;
129
+ nodePath = null;
130
+ }
131
+ // The node the startup agent runs. A versioned Homebrew/nvm path
132
+ // (…/Cellar/node/26.0.0/bin/node) is deleted by the next upgrade, and
133
+ // launchd cannot start the bot after that.
134
+ const nodeProblem = nodePath ? nodeBinaryProblem(nodePath, deps.nodeIo ? { io: deps.nodeIo } : {}) : null;
135
+ if (nodePath && nodeProblem === "missing") {
136
+ results.push({
137
+ status: "FAIL",
138
+ checkId: `bots.${name}.daemon-node`,
139
+ target: name,
140
+ message: `${name} startup agent runs a node binary that no longer exists (${nodePath}); launchd cannot start it.`,
141
+ remediation: `hq bot start ${name}`,
142
+ });
143
+ }
144
+ else if (nodePath && nodeProblem === "versioned") {
145
+ results.push({
146
+ status: "WARN",
147
+ checkId: `bots.${name}.daemon-node`,
148
+ target: name,
149
+ message: `${name} startup agent runs node from a versioned folder (${nodePath}) that the next node upgrade deletes.`,
150
+ remediation: `hq bot start ${name}`,
151
+ });
152
+ }
153
+ else if (nodePath) {
154
+ results.push({
155
+ status: "PASS",
156
+ checkId: `bots.${name}.daemon-node`,
157
+ target: name,
158
+ message: `${name} startup agent node path is upgrade-safe.`,
159
+ });
123
160
  }
124
161
  if (hqPath && !fs.existsSync(hqPath)) {
125
162
  results.push({
@@ -70,6 +70,15 @@ export interface ApplyFixesOptions {
70
70
  * so `--fix` never hits the network.
71
71
  */
72
72
  refreshSync?: (target: RefreshSyncTarget) => Promise<RefreshSyncResult>;
73
+ /**
74
+ * File-only rewrite used by the `bot-node-path` class. Default rewrites
75
+ * ~/Library/LaunchAgents/ai.getindigo.hq-bot.<name>.plist and never calls
76
+ * launchctl. Tests inject a stub.
77
+ */
78
+ rewriteBotNodePath?: (name: string) => {
79
+ from: string;
80
+ to: string;
81
+ } | null;
73
82
  }
74
83
  /** One company the `refresh-sync` class will pull. */
75
84
  export interface RefreshSyncTarget {
@@ -138,6 +147,11 @@ export declare function defaultRefreshSync(target: RefreshSyncTarget): Promise<R
138
147
  * the backup still covers the write.
139
148
  */
140
149
  export declare function uncommittedHookConfigChanges(hqRoot: string): string[];
150
+ /** Default `bot-node-path` repair against the real bot state and LaunchAgents. */
151
+ export declare function defaultRewriteBotNodePath(name: string): {
152
+ from: string;
153
+ to: string;
154
+ } | null;
141
155
  /**
142
156
  * Insert a new `case` arm allowlisting `hookId` (leading to `return 0`) at the
143
157
  * top of each named profile function's `case "$1" in` block. Additive: it never
@@ -37,6 +37,9 @@ import { gateMembership, parseHookGateProfiles, } from "../hook-gate-profiles.js
37
37
  import { createBackup } from "./backup.js";
38
38
  import { removeStrayGateEntries } from "../stray-gate-entries.js";
39
39
  import { deriveRemediation } from "./remediation.js";
40
+ import { botDir } from "../../bot/paths.js";
41
+ import { readBotConfig } from "../../bot/config.js";
42
+ import { buildBotDaemonPaths, rewriteBotDaemonNodePath } from "../../bot/daemon.js";
40
43
  /** The tree subtrees whose uncommitted changes block a `--fix` run. */
41
44
  export const HOOK_CONFIG_DIRS = [".claude", ".codex", ".grok"];
42
45
  /** Per-company bound for the default `hq sync pull` repair. */
@@ -75,6 +78,7 @@ export async function applyFixes(options) {
75
78
  }
76
79
  const collect = options.runChecks ?? defaultRunChecks;
77
80
  const refreshSync = options.refreshSync ?? defaultRefreshSync;
81
+ const rewriteBotNodePath = options.rewriteBotNodePath ?? defaultRewriteBotNodePath;
78
82
  // 2. Collect findings and keep only the allowlisted auto-fixable ones.
79
83
  const findings = await collect(hqRoot);
80
84
  const fixable = [];
@@ -88,7 +92,7 @@ export async function applyFixes(options) {
88
92
  return empty();
89
93
  }
90
94
  const plans = fixable
91
- .map(({ result, rem }) => planFix(hqRoot, result, rem, refreshSync))
95
+ .map(({ result, rem }) => planFix(hqRoot, result, rem, refreshSync, rewriteBotNodePath))
92
96
  .filter((plan) => plan !== null);
93
97
  if (plans.length === 0) {
94
98
  write("hq doctor --fix: nothing to repair — no auto-fixable findings.\n");
@@ -232,8 +236,10 @@ export function uncommittedHookConfigChanges(hqRoot) {
232
236
  }
233
237
  // --- Fix planning --------------------------------------------------------------
234
238
  /** Build the concrete plan for one auto-fixable finding, or null if unplannable. */
235
- function planFix(hqRoot, result, rem, refreshSync) {
239
+ function planFix(hqRoot, result, rem, refreshSync, rewriteBotNodePath = defaultRewriteBotNodePath) {
236
240
  switch (rem.fixClass) {
241
+ case "bot-node-path":
242
+ return planBotNodePath(result, rem, rewriteBotNodePath);
237
243
  case "executable-bit":
238
244
  return planExecutableBit(hqRoot, result, rem);
239
245
  case "gate-profile":
@@ -246,6 +252,36 @@ function planFix(hqRoot, result, rem, refreshSync) {
246
252
  return null;
247
253
  }
248
254
  }
255
+ /** Rewrite one bot LaunchAgent's node path (file only; launchd untouched). */
256
+ function planBotNodePath(result, rem, rewrite) {
257
+ const name = rem.fixTarget;
258
+ if (!name)
259
+ return null;
260
+ return {
261
+ checkId: result.checkId,
262
+ fixClass: "bot-node-path",
263
+ target: name,
264
+ relpath: "",
265
+ preview: ` ~/Library/LaunchAgents/ai.getindigo.hq-bot.${name}.plist\n` +
266
+ ` ~ node path -> upgrade-stable alias of the same node\n` +
267
+ ` file only: the bot is not stopped or started; the new path loads at its next start`,
268
+ summary: `rewrite ${name} startup agent node path`,
269
+ apply: () => {
270
+ rewrite(name);
271
+ },
272
+ postStatus: (results) => results.find((entry) => entry.checkId === result.checkId)?.status ?? null,
273
+ };
274
+ }
275
+ /** Default `bot-node-path` repair against the real bot state and LaunchAgents. */
276
+ export function defaultRewriteBotNodePath(name) {
277
+ if (process.platform !== "darwin")
278
+ return null;
279
+ const dir = botDir(name);
280
+ const config = readBotConfig(dir);
281
+ if (!config)
282
+ return null;
283
+ return rewriteBotDaemonNodePath(buildBotDaemonPaths({ name, botDir: dir, hqRoot: config.hqRoot, hqBinary: "hq" }), { platform: "darwin" });
284
+ }
249
285
  /** Pull one company to refresh a stale or never-synced journal. */
250
286
  function planRefreshSync(hqRoot, result, rem, refreshSync) {
251
287
  const slug = rem.fixTarget;
@@ -38,7 +38,7 @@
38
38
  */
39
39
  import type { CheckResult } from "../types.js";
40
40
  /** The allowlisted safe repair classes `--fix` is permitted to apply. */
41
- export type FixClass = "executable-bit" | "gate-profile" | "remove-stray-gate-entries" | "refresh-sync";
41
+ export type FixClass = "executable-bit" | "gate-profile" | "remove-stray-gate-entries" | "refresh-sync" | "bot-node-path";
42
42
  /**
43
43
  * A finding's structured remediation. `autoFixable`, `action`, and `command`
44
44
  * are the `--json` surface (AC1); `fixTarget`/`fixClass` are the internal handle
@@ -104,6 +104,19 @@ export function deriveRemediation(result) {
104
104
  command: remediation ?? `Remove the matcher-less hook-gate.sh entries from ${target}.`,
105
105
  };
106
106
  }
107
+ // bot-node-path — a bot LaunchAgent that names node by a versioned path
108
+ // (…/Cellar/node/<v>/bin/node) that an upgrade removed or will remove.
109
+ // The repair rewrites only the plist file; it never reloads launchd, so it
110
+ // cannot cut a running turn short or start a stopped bot.
111
+ if (checkId.endsWith(".daemon-node") && checkId.startsWith("bots.") && target) {
112
+ return {
113
+ autoFixable: true,
114
+ fixClass: "bot-node-path",
115
+ fixTarget: target,
116
+ action: `Rewrite the ${target} startup agent to run node from its upgrade-stable path (applies at the bot's next start).`,
117
+ command: remediation ?? `hq bot start ${target}`,
118
+ };
119
+ }
107
120
  // refresh-sync — a company journal that is stale or never-synced. Only
108
121
  // company slugs: personal shards have no companies/<slug> tree and a
109
122
  // SessionStart `--fix --yes` must not pull the personal vault unprompted.
@@ -125,7 +125,12 @@ export declare function preferStableHqBinary(entry: string): string;
125
125
  * realpath of argv[1] only — never a PATH lookup of `hq`.
126
126
  */
127
127
  export declare function resolveRunningCliEntry(argv?: string[]): string;
128
- /** Absolute node binary (realpath of process.execPath). No PATH lookup. */
128
+ /**
129
+ * Absolute node binary for a service unit: the realpath of process.execPath,
130
+ * then its upgrade-stable alias (/opt/homebrew/bin/node rather than
131
+ * /opt/homebrew/Cellar/node/<version>/bin/node) when one resolves to the same
132
+ * binary. No PATH lookup of `node` by name.
133
+ */
129
134
  export declare function resolveAbsoluteNodeBinary(execPath?: string): string;
130
135
  /** Resolve the hq binary the current shell uses (argv[1] or PATH). */
131
136
  export declare function resolveHqBinary(argv?: string[], env?: NodeJS.ProcessEnv): string;
@@ -161,7 +166,17 @@ export declare function installDaemonIfStale(opts: {
161
166
  stderr: string;
162
167
  };
163
168
  windowsLegacyTaskPresent?: boolean;
169
+ /** Reads the installed unit (tests); defaults to the file on disk. */
170
+ readUnit?: (file: string) => string | null;
171
+ /** Checks the installed unit's node path (tests); defaults to the real filesystem. */
172
+ nodeProblem?: (nodePath: string) => "missing" | "versioned" | null;
164
173
  }): InstallIfStaleResult;
174
+ /**
175
+ * The node binary baked into the installed daemon unit (darwin plist or linux
176
+ * user unit), or null when there is no unit or it cannot be read. Used to
177
+ * reinstall a unit whose node was a versioned path an upgrade removed.
178
+ */
179
+ export declare function installedDaemonNodeBinary(paths: Pick<InstallPaths, "plistPath" | "systemdUnitPath">, platform: DaemonInstallPlatform, readUnit?: (file: string) => string | null): string | null;
165
180
  export declare function resolveNodeBinary(execPath?: string, env?: NodeJS.ProcessEnv): string;
166
181
  export declare function buildInstallPaths(opts: {
167
182
  home?: string;
@@ -24,6 +24,7 @@ import { retireLegacyListen } from "./legacy-listen.js";
24
24
  import { readDaemonState } from "./state.js";
25
25
  import { activateLaunchd, launchdPlistProgramArguments, launchdProgramArgumentsDiffer, parseLaunchctlPrintArguments, } from "../../../service-manager/launchd.js";
26
26
  import { activateSystemd } from "../../../service-manager/systemd.js";
27
+ import { nodeBinaryProblem, preferStableNodeBinary } from "../../../node-binary.js";
27
28
  /**
28
29
  * `schtasks /End` terminates the task's root process only. The daemon is
29
30
  * launched through `conhost.exe --headless`, so ending the task kills conhost
@@ -113,7 +114,7 @@ function migrateLegacyDaemonUnit(opts) {
113
114
  env: opts.env,
114
115
  hqRoot: verified,
115
116
  hqBinary: "/usr/bin/hq",
116
- nodeBinary: process.execPath || "/usr/bin/node",
117
+ nodeBinary: process.execPath ? resolveNodeBinary(process.execPath, opts.env ?? process.env) : "/usr/bin/node",
117
118
  });
118
119
  try {
119
120
  paths.hqBinary = resolveHqBinary(process.argv, opts.env ?? process.env);
@@ -264,18 +265,25 @@ export function resolveRunningCliEntry(argv = process.argv) {
264
265
  throw new Error(`Cannot resolve hq binary: ${entry} is not a real path`);
265
266
  }
266
267
  }
267
- /** Absolute node binary (realpath of process.execPath). No PATH lookup. */
268
+ /**
269
+ * Absolute node binary for a service unit: the realpath of process.execPath,
270
+ * then its upgrade-stable alias (/opt/homebrew/bin/node rather than
271
+ * /opt/homebrew/Cellar/node/<version>/bin/node) when one resolves to the same
272
+ * binary. No PATH lookup of `node` by name.
273
+ */
268
274
  export function resolveAbsoluteNodeBinary(execPath = process.execPath) {
269
275
  if (!execPath)
270
276
  throw new Error("Cannot resolve node binary: execPath is empty");
277
+ let real;
271
278
  try {
272
- return fs.realpathSync(execPath);
279
+ real = fs.realpathSync(execPath);
273
280
  }
274
281
  catch {
275
282
  if (path.isAbsolute(execPath))
276
283
  return execPath;
277
284
  throw new Error(`Cannot resolve node binary: ${execPath} is not a real path`);
278
285
  }
286
+ return preferStableNodeBinary(real);
279
287
  }
280
288
  /** Resolve the hq binary the current shell uses (argv[1] or PATH). */
281
289
  export function resolveHqBinary(argv = process.argv, env = process.env) {
@@ -312,7 +320,14 @@ export function isNewerCliVersion(current, recorded) {
312
320
  */
313
321
  export function installDaemonIfStale(opts) {
314
322
  const recorded = readDaemonState(opts.paths.daemonDir)?.cliVersion;
315
- if (!isNewerCliVersion(opts.currentVersion, recorded)) {
323
+ const platformForUnit = opts.platform ?? detectPlatform();
324
+ const installedNode = installedDaemonNodeBinary(opts.paths, platformForUnit, opts.readUnit);
325
+ // Only a rewrite that changes the node path helps: a unit whose node is gone
326
+ // or versioned, while this install resolves a different (stable) one.
327
+ const nodeProblem = installedNode && installedNode !== opts.paths.nodeBinary
328
+ ? (opts.nodeProblem ?? nodeBinaryProblem)(installedNode)
329
+ : null;
330
+ if (!isNewerCliVersion(opts.currentVersion, recorded) && !nodeProblem) {
316
331
  return {
317
332
  reinstalled: false,
318
333
  message: `daemon already records hq ${recorded}; not reinstalling`,
@@ -350,9 +365,42 @@ export function installDaemonIfStale(opts) {
350
365
  message: `${installed.message} Restarted with ${opts.paths.nodeBinary} ${opts.paths.hqBinary}.`,
351
366
  };
352
367
  }
368
+ /**
369
+ * The node binary baked into the installed daemon unit (darwin plist or linux
370
+ * user unit), or null when there is no unit or it cannot be read. Used to
371
+ * reinstall a unit whose node was a versioned path an upgrade removed.
372
+ */
373
+ export function installedDaemonNodeBinary(paths, platform, readUnit = (file) => {
374
+ try {
375
+ return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
376
+ }
377
+ catch {
378
+ return null;
379
+ }
380
+ }) {
381
+ if (platform === "darwin") {
382
+ const text = readUnit(paths.plistPath);
383
+ if (!text)
384
+ return null;
385
+ const block = text.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
386
+ const first = block?.[1]?.match(/<string>([^<]*)<\/string>/);
387
+ return first?.[1] ?? null;
388
+ }
389
+ if (platform === "linux") {
390
+ const text = readUnit(paths.systemdUnitPath);
391
+ if (!text)
392
+ return null;
393
+ const m = text.match(/^ExecStart=('(?:[^']|'\\'')*'|\S+)/m);
394
+ if (!m)
395
+ return null;
396
+ const raw = m[1];
397
+ return raw.startsWith("'") ? raw.slice(1, -1).replace(/'\\''/g, "'") : raw;
398
+ }
399
+ return null;
400
+ }
353
401
  export function resolveNodeBinary(execPath = process.execPath, env = process.env) {
354
402
  if (execPath && fs.existsSync(execPath))
355
- return execPath;
403
+ return preferStableNodeBinary(execPath, { env });
356
404
  const pathEnv = env.PATH ?? "";
357
405
  for (const dir of pathEnv.split(path.delimiter)) {
358
406
  const candidate = path.join(dir, "node");
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A node path that survives a node upgrade, for service units (LaunchAgents,
3
+ * systemd user units) that name node by absolute path.
4
+ *
5
+ * `process.execPath` is the resolved binary, e.g.
6
+ * /opt/homebrew/Cellar/node/26.0.0/bin/node. `brew upgrade node` deletes that
7
+ * directory, and every unit that baked it in stops launching (launchd cannot
8
+ * exec a missing program, so KeepAlive never brings the job back).
9
+ *
10
+ * The fix is to write the stable name the package manager keeps pointing at
11
+ * the current version (/opt/homebrew/bin/node, /opt/homebrew/opt/node/bin/node)
12
+ * instead of the versioned directory it points at today. A candidate is only
13
+ * used when it resolves to the SAME binary this process runs, so the unit
14
+ * keeps running the exact node it was installed with; a machine where no such
15
+ * alias exists (nvm without a `current` link, a hand-built node) keeps the
16
+ * execPath, as before.
17
+ */
18
+ export interface NodeBinaryIo {
19
+ existsSync(p: string): boolean;
20
+ realpathSync(p: string): string;
21
+ }
22
+ /** True when `p` is a node binary inside a version-specific directory. */
23
+ export declare function isVersionedNodePath(p: string): boolean;
24
+ /**
25
+ * Stable names that may point at `execPath`, most specific first:
26
+ * the Homebrew prefix's bin/ and opt/<formula>/ links, a `current` link under
27
+ * nvm, then the usual global bins and every PATH entry.
28
+ */
29
+ export declare function stableNodeCandidates(execPath: string, env?: NodeJS.ProcessEnv, home?: string): string[];
30
+ /**
31
+ * The stable name for `execPath` when it sits in a versioned directory and an
32
+ * alias resolving to the same binary exists; otherwise `execPath` unchanged.
33
+ */
34
+ export declare function preferStableNodeBinary(execPath: string, opts?: {
35
+ env?: NodeJS.ProcessEnv;
36
+ home?: string;
37
+ io?: NodeBinaryIo;
38
+ }): string;
39
+ /**
40
+ * Why a unit's node path needs rewriting, or null when it is fine:
41
+ * "missing" — the binary is gone (launchd can no longer start the job);
42
+ * "versioned" — it still exists but the next upgrade will remove it, and a
43
+ * stable alias for the same binary exists.
44
+ */
45
+ export declare function nodeBinaryProblem(nodePath: string, opts?: {
46
+ env?: NodeJS.ProcessEnv;
47
+ home?: string;
48
+ io?: NodeBinaryIo;
49
+ }): "missing" | "versioned" | null;
50
+ //# sourceMappingURL=node-binary.d.ts.map
@@ -0,0 +1,133 @@
1
+ /**
2
+ * A node path that survives a node upgrade, for service units (LaunchAgents,
3
+ * systemd user units) that name node by absolute path.
4
+ *
5
+ * `process.execPath` is the resolved binary, e.g.
6
+ * /opt/homebrew/Cellar/node/26.0.0/bin/node. `brew upgrade node` deletes that
7
+ * directory, and every unit that baked it in stops launching (launchd cannot
8
+ * exec a missing program, so KeepAlive never brings the job back).
9
+ *
10
+ * The fix is to write the stable name the package manager keeps pointing at
11
+ * the current version (/opt/homebrew/bin/node, /opt/homebrew/opt/node/bin/node)
12
+ * instead of the versioned directory it points at today. A candidate is only
13
+ * used when it resolves to the SAME binary this process runs, so the unit
14
+ * keeps running the exact node it was installed with; a machine where no such
15
+ * alias exists (nvm without a `current` link, a hand-built node) keeps the
16
+ * execPath, as before.
17
+ */
18
+ import * as fs from "node:fs";
19
+ import * as os from "node:os";
20
+ import * as path from "node:path";
21
+ const defaultIo = {
22
+ existsSync: (p) => fs.existsSync(p),
23
+ realpathSync: (p) => fs.realpathSync(p),
24
+ };
25
+ /**
26
+ * Install layouts whose node lives in a per-version directory that an upgrade
27
+ * or version switch removes or abandons.
28
+ */
29
+ const VERSIONED_NODE_PATTERNS = [
30
+ // Homebrew: <prefix>/Cellar/node/26.0.0/bin/node, <prefix>/Cellar/node@22/22.9.0/bin/node
31
+ /\/Cellar\/node(?:@[^/]+)?\/[^/]+\/bin\/node$/,
32
+ // nvm
33
+ /\/\.nvm\/versions\/node\/[^/]+\/bin\/node$/,
34
+ // Volta
35
+ /\/\.volta\/tools\/image\/node\/[^/]+\/bin\/node$/,
36
+ // fnm (installed versions and the per-shell multishell links)
37
+ /\/fnm\/node-versions\/[^/]+\/installation\/bin\/node$/,
38
+ /\/fnm_multishells\/[^/]+\/bin\/node$/,
39
+ // asdf / mise
40
+ /\/\.asdf\/installs\/nodejs\/[^/]+\/bin\/node$/,
41
+ /\/mise\/installs\/node\/[^/]+\/bin\/node$/,
42
+ // n
43
+ /\/n\/versions\/node\/[^/]+\/bin\/node$/,
44
+ ];
45
+ /** True when `p` is a node binary inside a version-specific directory. */
46
+ export function isVersionedNodePath(p) {
47
+ const normalized = p.split(path.sep).join("/");
48
+ return VERSIONED_NODE_PATTERNS.some((re) => re.test(normalized));
49
+ }
50
+ /**
51
+ * Stable names that may point at `execPath`, most specific first:
52
+ * the Homebrew prefix's bin/ and opt/<formula>/ links, a `current` link under
53
+ * nvm, then the usual global bins and every PATH entry.
54
+ */
55
+ export function stableNodeCandidates(execPath, env = process.env, home = os.homedir()) {
56
+ const normalized = execPath.split(path.sep).join("/");
57
+ const out = [];
58
+ const brew = /^(.*)\/Cellar\/(node(?:@[^/]+)?)\/[^/]+\/bin\/node$/.exec(normalized);
59
+ if (brew) {
60
+ const prefix = brew[1];
61
+ out.push(`${prefix}/bin/node`, `${prefix}/opt/${brew[2]}/bin/node`);
62
+ }
63
+ const nvmDir = env.NVM_DIR?.trim() || path.join(home, ".nvm");
64
+ if (/\/\.nvm\/versions\/node\//.test(normalized))
65
+ out.push(path.join(nvmDir, "current", "bin", "node"));
66
+ out.push("/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node");
67
+ for (const dir of (env.PATH ?? "").split(path.delimiter)) {
68
+ if (dir && path.isAbsolute(dir))
69
+ out.push(path.join(dir, "node"));
70
+ }
71
+ return [...new Set(out)];
72
+ }
73
+ /**
74
+ * The stable name for `execPath` when it sits in a versioned directory and an
75
+ * alias resolving to the same binary exists; otherwise `execPath` unchanged.
76
+ */
77
+ export function preferStableNodeBinary(execPath, opts = {}) {
78
+ if (!execPath || !isVersionedNodePath(execPath))
79
+ return execPath;
80
+ const io = opts.io ?? defaultIo;
81
+ let target;
82
+ try {
83
+ target = io.realpathSync(execPath);
84
+ }
85
+ catch {
86
+ // A versioned node that is already gone (a stale unit being repaired):
87
+ // there is no binary to match, so any working stable node is the answer.
88
+ return firstWorkingStableNode(execPath, opts, io) ?? execPath;
89
+ }
90
+ for (const candidate of stableNodeCandidates(execPath, opts.env ?? process.env, opts.home ?? os.homedir())) {
91
+ if (isVersionedNodePath(candidate))
92
+ continue;
93
+ try {
94
+ if (!io.existsSync(candidate))
95
+ continue;
96
+ if (io.realpathSync(candidate) === target)
97
+ return candidate;
98
+ }
99
+ catch {
100
+ /* dangling link — not a candidate */
101
+ }
102
+ }
103
+ return execPath;
104
+ }
105
+ function firstWorkingStableNode(execPath, opts, io) {
106
+ for (const candidate of stableNodeCandidates(execPath, opts.env ?? process.env, opts.home ?? os.homedir())) {
107
+ if (isVersionedNodePath(candidate))
108
+ continue;
109
+ try {
110
+ if (io.existsSync(candidate) && io.existsSync(io.realpathSync(candidate)))
111
+ return candidate;
112
+ }
113
+ catch {
114
+ /* dangling link */
115
+ }
116
+ }
117
+ return null;
118
+ }
119
+ /**
120
+ * Why a unit's node path needs rewriting, or null when it is fine:
121
+ * "missing" — the binary is gone (launchd can no longer start the job);
122
+ * "versioned" — it still exists but the next upgrade will remove it, and a
123
+ * stable alias for the same binary exists.
124
+ */
125
+ export function nodeBinaryProblem(nodePath, opts = {}) {
126
+ const io = opts.io ?? defaultIo;
127
+ if (!nodePath)
128
+ return null;
129
+ if (!io.existsSync(nodePath))
130
+ return "missing";
131
+ return preferStableNodeBinary(nodePath, opts) !== nodePath ? "versioned" : null;
132
+ }
133
+ //# sourceMappingURL=node-binary.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.172.0",
3
+ "version": "5.173.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {