@indigoai-us/hq-cli 5.171.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,27 @@
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
+
13
+ ## [5.172.0] — 2026-09-24
14
+
15
+ ### Changed
16
+
17
+ - The `hq daemon` updater keeps hq-cloud current as well. Every 6 hours it
18
+ installs the newest hq-cloud with the same major version into
19
+ `~/.hq/daemon/hq-cloud`, and it restarts sync and the outpost heartbeat
20
+ right after an update. The daemon runs sync, and the heartbeat when that
21
+ hq-cloud has `hq-outpost-heartbeat`, from whichever copy is newer: this one
22
+ or the one bundled with the CLI.
23
+ - `hq daemon` sync pushes local changes as they happen (`--event-push`), the
24
+ same as outpost sync does today.
25
+
5
26
  ## [5.171.0] — 2026-09-24
6
27
 
7
28
  ### Fixed
@@ -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
  })());
@@ -16,7 +16,7 @@
16
16
  import * as fs from "node:fs";
17
17
  import * as os from "node:os";
18
18
  import * as path from "node:path";
19
- import { DEFAULT_SERVICES, SERVICE_DESCRIPTIONS, enabledServices } from "../lib/daemon/catalog.js";
19
+ import { DEFAULT_SERVICES, SERVICE_DESCRIPTIONS, enabledServices, hqCloudUnits } from "../lib/daemon/catalog.js";
20
20
  import { applyServiceToggle, readDaemonConfig, writeDaemonConfig } from "../lib/daemon/config.js";
21
21
  import { daemonUnitInstalled } from "../lib/daemon/handoff.js";
22
22
  import { adoptLegacyUnits, readAdoptedUnits, restoreLegacyUnits } from "../lib/daemon/migrate.js";
@@ -418,19 +418,23 @@ async function runMigrate(d, opts) {
418
418
  d.out("Nothing was changed. `hq daemon migrate --apply` takes them over.");
419
419
  }
420
420
  async function runServiceSelfUpdate(d) {
421
- const [{ daemonSelfUpdate }, { hostToolUpdater, runDaemonUpdater, updaterEnv }, { TOOL_NAMES }, { cliSelfUpdatable }] = await Promise.all([
421
+ const [{ daemonSelfUpdate }, { hostToolUpdater, runDaemonUpdater, updaterEnv }, { TOOL_NAMES }, { cliSelfUpdatable }, { updateManagedHqCloud },] = await Promise.all([
422
422
  import("../utils/self-update.js"),
423
423
  import("../lib/daemon/updater.js"),
424
424
  import("../lib/daemon/tools.js"),
425
425
  import("../lib/daemon/run.js"),
426
+ import("../lib/daemon/hq-cloud.js"),
426
427
  ]);
427
- const { paths } = currentHost(d);
428
+ const { paths, detected } = currentHost(d);
428
429
  const env = updaterEnv(d.env);
429
430
  const result = await runDaemonUpdater({
430
431
  // Same guard as every self-update: never replace a dev checkout or a project's copy with a global install.
431
432
  updateCli: cliSelfUpdatable() ? () => daemonSelfUpdate({ env }) : null,
432
433
  tools: TOOL_NAMES.map((name) => hostToolUpdater(name, env, d.home)),
434
+ updateHqCloud: async () => updateManagedHqCloud(paths.root, env),
435
+ hqCloudUnits: hqCloudUnits(detected.type),
433
436
  requestDaemonRestart: () => writeControlRequest(paths.requestsDir, { action: "restart-daemon" }),
437
+ requestRestart: (unit) => writeControlRequest(paths.requestsDir, { action: "restart", unit }),
434
438
  log: d.out,
435
439
  });
436
440
  if (!result.ok)
@@ -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