@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.
@@ -62,6 +62,8 @@ export interface PlanContext {
62
62
  hqRoot: string | undefined;
63
63
  /** hq-cloud's sync-runner.js, or null when it cannot be found. */
64
64
  syncRunnerPath: string | null;
65
+ /** hq-cloud's `hq-outpost-heartbeat` bin, when the hq-cloud in use has one. */
66
+ heartbeatRunnerPath: string | null;
65
67
  signedIn: boolean;
66
68
  /** ~/.hq-agent/kit.json exists. */
67
69
  kitConfigured: boolean;
@@ -86,6 +88,12 @@ export interface PlanContext {
86
88
  /** The box's Codex remote-control runner script, when it has one. */
87
89
  codexRunnerScript: string | null;
88
90
  }
91
+ /**
92
+ * The units that run hq-cloud's code on a machine type, restarted after the
93
+ * updater installs a new hq-cloud. Only outposts and self-hosted machines run
94
+ * the outpost heartbeat; agent machines run the agent kit's own.
95
+ */
96
+ export declare function hqCloudUnits(machineType: MachineType): ServiceName[];
89
97
  /** Written by outpost provisioning; the daemon runs it rather than re-implementing Codex login and relay setup. */
90
98
  export declare const CODEX_RUNNER_SCRIPT = "/usr/local/bin/outpost-codex-runner.sh";
91
99
  /** The outpost's Claude runner script, read for the session name it uses. */
@@ -96,6 +96,14 @@ function firstBlock(checks) {
96
96
  const hit = checks.find(([blocked]) => blocked);
97
97
  return hit ? { state: "waiting", reason: hit[1] } : undefined;
98
98
  }
99
+ /**
100
+ * The units that run hq-cloud's code on a machine type, restarted after the
101
+ * updater installs a new hq-cloud. Only outposts and self-hosted machines run
102
+ * the outpost heartbeat; agent machines run the agent kit's own.
103
+ */
104
+ export function hqCloudUnits(machineType) {
105
+ return machineType === "outpost" || machineType === "self-hosted" ? ["sync", "heartbeat"] : ["sync"];
106
+ }
99
107
  /** Written by outpost provisioning; the daemon runs it rather than re-implementing Codex login and relay setup. */
100
108
  export const CODEX_RUNNER_SCRIPT = "/usr/local/bin/outpost-codex-runner.sh";
101
109
  /** The outpost's Claude runner script, read for the session name it uses. */
@@ -113,7 +121,7 @@ function planService(service, ctx) {
113
121
  command: {
114
122
  type: "node",
115
123
  script: ctx.syncRunnerPath,
116
- args: ["--companies", "--direction", "both", "--on-conflict", "keep", "--hq-root", ctx.hqRoot ?? "", "--watch"],
124
+ args: ["--companies", "--direction", "both", "--on-conflict", "keep", "--hq-root", ctx.hqRoot ?? "", "--watch", "--event-push"],
117
125
  },
118
126
  });
119
127
  unit.blocked = firstBlock([
@@ -141,7 +149,10 @@ function planService(service, ctx) {
141
149
  }
142
150
  return [
143
151
  base(service, {
144
- command: { type: "hq", args: ["outposts", "heartbeat", "--home", ctx.home] },
152
+ // hq-cloud's own heartbeat picks up hq-cloud updates without a CLI release.
153
+ command: ctx.heartbeatRunnerPath
154
+ ? { type: "node", script: ctx.heartbeatRunnerPath, args: ["--home", ctx.home] }
155
+ : { type: "hq", args: ["outposts", "heartbeat", "--home", ctx.home] },
145
156
  blocked: firstBlock([[!ctx.signedIn, NOT_SIGNED_IN]]),
146
157
  }),
147
158
  ];
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Which hq-cloud the daemon runs sync and the outpost heartbeat from.
3
+ *
4
+ * The hq CLI ships with a copy of hq-cloud, but hq-cloud releases more often
5
+ * than the CLI. The updater keeps a newer copy of the same major version in
6
+ * `<daemon dir>/hq-cloud`, and the daemon runs whichever of the two is newer.
7
+ * A different major version is never used: it may not match this CLI.
8
+ */
9
+ export declare const HQ_CLOUD_PACKAGE = "@indigoai-us/hq-cloud";
10
+ export interface HqCloudCopy {
11
+ /** The package folder (the one holding its package.json). */
12
+ root: string;
13
+ version: string;
14
+ }
15
+ export interface PickedHqCloud extends HqCloudCopy {
16
+ source: "bundled" | "managed";
17
+ }
18
+ export declare function managedHqCloudDir(daemonRoot: string): string;
19
+ /** The package folder inside the updater's install prefix. */
20
+ export declare function managedHqCloudRoot(daemonRoot: string): string;
21
+ /**
22
+ * `usable` says whether a copy has what the daemon runs; a newer managed copy
23
+ * that fails it (an interrupted or damaged install) is passed over.
24
+ */
25
+ export declare function pickHqCloud(bundled: HqCloudCopy | null, managed: HqCloudCopy | null, usable?: (copy: HqCloudCopy) => boolean): PickedHqCloud | null;
26
+ export interface HqCloudRunners {
27
+ syncRunner: string | null;
28
+ /** `hq-outpost-heartbeat`, which older hq-cloud releases do not have. */
29
+ heartbeat: string | null;
30
+ }
31
+ export declare function hqCloudRunners(root: string, exists?: (p: string) => boolean): HqCloudRunners;
32
+ /** Install the newest release of the bundled major version, never older than the bundled copy. */
33
+ export declare function hqCloudInstallCommand(prefix: string, bundledVersion: string, platform?: NodeJS.Platform): {
34
+ file: string;
35
+ args: string[];
36
+ shell: boolean;
37
+ };
38
+ export declare function readHqCloudCopy(root: string): HqCloudCopy | null;
39
+ /** The copy bundled with this CLI. */
40
+ export declare function bundledHqCloud(): HqCloudCopy | null;
41
+ /** The hq-cloud the daemon should run right now. */
42
+ export declare function currentHqCloud(daemonRoot: string): PickedHqCloud | null;
43
+ export interface HqCloudUpdateOutcome {
44
+ ok: boolean;
45
+ from: string | null;
46
+ to: string | null;
47
+ detail?: string;
48
+ }
49
+ /** Install or update the updater's copy, as this (the login) user. */
50
+ export declare function updateManagedHqCloud(daemonRoot: string, env: NodeJS.ProcessEnv): HqCloudUpdateOutcome;
51
+ //# sourceMappingURL=hq-cloud.d.ts.map
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Which hq-cloud the daemon runs sync and the outpost heartbeat from.
3
+ *
4
+ * The hq CLI ships with a copy of hq-cloud, but hq-cloud releases more often
5
+ * than the CLI. The updater keeps a newer copy of the same major version in
6
+ * `<daemon dir>/hq-cloud`, and the daemon runs whichever of the two is newer.
7
+ * A different major version is never used: it may not match this CLI.
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ import * as fs from "node:fs";
11
+ import { createRequire } from "node:module";
12
+ import * as path from "node:path";
13
+ import { buildSpawnPlan } from "../../utils/windows-spawn.js";
14
+ export const HQ_CLOUD_PACKAGE = "@indigoai-us/hq-cloud";
15
+ export function managedHqCloudDir(daemonRoot) {
16
+ return path.join(daemonRoot, "hq-cloud");
17
+ }
18
+ /** The package folder inside the updater's install prefix. */
19
+ export function managedHqCloudRoot(daemonRoot) {
20
+ return path.join(managedHqCloudDir(daemonRoot), "node_modules", ...HQ_CLOUD_PACKAGE.split("/"));
21
+ }
22
+ function parseVersion(v) {
23
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(v.trim());
24
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
25
+ }
26
+ function newer(a, b) {
27
+ for (let i = 0; i < 3; i++) {
28
+ if (a[i] !== b[i])
29
+ return a[i] > b[i];
30
+ }
31
+ return false;
32
+ }
33
+ /**
34
+ * `usable` says whether a copy has what the daemon runs; a newer managed copy
35
+ * that fails it (an interrupted or damaged install) is passed over.
36
+ */
37
+ export function pickHqCloud(bundled, managed, usable = () => true) {
38
+ if (!bundled)
39
+ return null;
40
+ const b = parseVersion(bundled.version);
41
+ const m = managed ? parseVersion(managed.version) : null;
42
+ if (managed && b && m && m[0] === b[0] && newer(m, b) && usable(managed))
43
+ return { ...managed, source: "managed" };
44
+ return { ...bundled, source: "bundled" };
45
+ }
46
+ export function hqCloudRunners(root, exists = fs.existsSync) {
47
+ const bin = (name) => {
48
+ const p = path.join(root, "dist", "bin", name);
49
+ return exists(p) ? p : null;
50
+ };
51
+ return { syncRunner: bin("sync-runner.js"), heartbeat: bin("outpost-heartbeat.js") };
52
+ }
53
+ /** Install the newest release of the bundled major version, never older than the bundled copy. */
54
+ export function hqCloudInstallCommand(prefix, bundledVersion, platform = process.platform) {
55
+ // npm is a .cmd on Windows and needs the shell, so every argument is quoted for it.
56
+ const plan = buildSpawnPlan("npm", ["install", "--prefix", prefix, "--no-audit", "--no-fund", "--omit=dev", `${HQ_CLOUD_PACKAGE}@^${bundledVersion}`], platform);
57
+ return { file: plan.cmd, args: plan.args, shell: plan.shell };
58
+ }
59
+ export function readHqCloudCopy(root) {
60
+ try {
61
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
62
+ return typeof pkg.version === "string" ? { root, version: pkg.version } : null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /** The copy bundled with this CLI. */
69
+ export function bundledHqCloud() {
70
+ try {
71
+ const require = createRequire(import.meta.url);
72
+ return readHqCloudCopy(path.dirname(require.resolve(`${HQ_CLOUD_PACKAGE}/package.json`)));
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
78
+ /** The hq-cloud the daemon should run right now. */
79
+ export function currentHqCloud(daemonRoot) {
80
+ return pickHqCloud(bundledHqCloud(), readHqCloudCopy(managedHqCloudRoot(daemonRoot)), (copy) => hqCloudRunners(copy.root).syncRunner !== null);
81
+ }
82
+ /** Install or update the updater's copy, as this (the login) user. */
83
+ export function updateManagedHqCloud(daemonRoot, env) {
84
+ const bundled = bundledHqCloud();
85
+ const from = currentHqCloud(daemonRoot)?.version ?? null;
86
+ if (!bundled)
87
+ return { ok: false, from, to: null, detail: "this CLI install has no bundled hq-cloud to match" };
88
+ const prefix = managedHqCloudDir(daemonRoot);
89
+ try {
90
+ fs.mkdirSync(prefix, { recursive: true, mode: 0o700 });
91
+ }
92
+ catch (err) {
93
+ return { ok: false, from, to: null, detail: `could not create ${prefix}: ${err instanceof Error ? err.message : String(err)}` };
94
+ }
95
+ const cmd = hqCloudInstallCommand(prefix, bundled.version);
96
+ const r = spawnSync(cmd.file, cmd.args, {
97
+ encoding: "utf8",
98
+ env,
99
+ timeout: 10 * 60_000,
100
+ stdio: ["ignore", "pipe", "pipe"],
101
+ windowsHide: true,
102
+ shell: cmd.shell,
103
+ });
104
+ if (r.status !== 0) {
105
+ const tail = `${r.stderr ?? ""}\n${r.stdout ?? ""}`.trim().split("\n").slice(-3).join(" ").slice(0, 400);
106
+ return { ok: false, from, to: null, detail: tail || (r.error ? r.error.message : `npm exited ${r.status ?? "by signal"}`) };
107
+ }
108
+ return { ok: true, from, to: currentHqCloud(daemonRoot)?.version ?? null };
109
+ }
110
+ //# sourceMappingURL=hq-cloud.js.map
@@ -23,6 +23,7 @@ import { daemonRootRefusal } from "./install.js";
23
23
  import { detectMachineType } from "./machine-type.js";
24
24
  import { appendPrivate, daemonPaths, ensurePrivateDir, openPrivateAppend } from "./paths.js";
25
25
  import { takeControlRequests } from "./requests.js";
26
+ import { currentHqCloud, hqCloudRunners } from "./hq-cloud.js";
26
27
  import { runnerSessionName } from "./remote-control.js";
27
28
  import { STOP_ESCALATION_MS, createSupervisor } from "./supervisor.js";
28
29
  import { userToolDirs } from "./tools.js";
@@ -230,8 +231,7 @@ export async function runDaemon(opts) {
230
231
  return 1;
231
232
  }
232
233
  log("info", `starting hq daemon (pid ${process.pid}) as ${machineType} (${detected.detail})`);
233
- const syncRunnerPath = resolveSyncRunnerPath();
234
- if (!syncRunnerPath)
234
+ if (!resolveSyncRunnerPath())
235
235
  log("warn", "the hq-cloud sync runner was not found in this CLI install; sync is unavailable");
236
236
  const updatable = cliSelfUpdatable();
237
237
  const hostName = os.hostname().split(".")[0] || "hq-host";
@@ -247,12 +247,16 @@ export async function runDaemon(opts) {
247
247
  const legacy = legacyUnits();
248
248
  const configured = config.hqRoot ?? env.HQ_ROOT?.trim() ?? undefined;
249
249
  const hqRoot = configured && fs.existsSync(configured) ? configured : undefined;
250
+ // Re-picked every reconcile, so a newer hq-cloud from the updater is used as soon as it lands.
251
+ const hqCloud = currentHqCloud(paths.root);
252
+ const runners = hqCloud ? hqCloudRunners(hqCloud.root) : { syncRunner: null, heartbeat: null };
250
253
  return planUnits(enabledServices(machineType, config), {
251
254
  machineType,
252
255
  platform,
253
256
  home,
254
257
  hqRoot,
255
- syncRunnerPath,
258
+ syncRunnerPath: runners.syncRunner,
259
+ heartbeatRunnerPath: runners.heartbeat,
256
260
  signedIn: signedIn(env, home, log),
257
261
  kitConfigured: fs.existsSync(agentKitPaths(home, env).kitConfigPath),
258
262
  desktopAppManagesSync: machineType === "desktop" &&
@@ -8,11 +8,15 @@
8
8
  * 2. Update the Claude, Codex and Grok CLIs the login user installed, each
9
9
  * through its own update route. A copy this user cannot write (root's
10
10
  * under /usr) is reported and left alone.
11
- * 3. After a CLI update, ask the daemon to restart itself so the supervisor and
11
+ * 3. Install or update the daemon's own hq-cloud (sync and the outpost
12
+ * heartbeat run from it when it is newer than the CLI's bundled copy), and
13
+ * restart sync and the heartbeat right after it changes.
14
+ * 4. After a CLI update, ask the daemon to restart itself so the supervisor and
12
15
  * every service run the new version. The remote-control hosts notice an
13
16
  * updated Claude or Codex on disk themselves and restart once idle.
14
17
  */
15
18
  import type { SelfUpdateOutcome } from "../../utils/self-update.js";
19
+ import type { HqCloudUpdateOutcome } from "./hq-cloud.js";
16
20
  import { type ToolInstall, type ToolName } from "./tools.js";
17
21
  export type { ToolInstall } from "./tools.js";
18
22
  export interface ToolUpdater {
@@ -27,7 +31,13 @@ export interface UpdaterDeps {
27
31
  /** Null when this CLI is not a global install the updater may replace. */
28
32
  updateCli: (() => Promise<Pick<SelfUpdateOutcome, "action" | "latest">>) | null;
29
33
  tools: readonly ToolUpdater[];
34
+ /** Install or update the daemon's own hq-cloud; null where it has none. */
35
+ updateHqCloud: (() => Promise<HqCloudUpdateOutcome>) | null;
36
+ /** The units on this machine that run hq-cloud code, restarted after it updates. */
37
+ hqCloudUnits: readonly string[];
30
38
  requestDaemonRestart: () => void;
39
+ /** Restart one unit (a restart request to the running daemon). */
40
+ requestRestart: (unit: string) => void;
31
41
  log: (line: string) => void;
32
42
  }
33
43
  export interface UpdaterResult {
@@ -8,7 +8,10 @@
8
8
  * 2. Update the Claude, Codex and Grok CLIs the login user installed, each
9
9
  * through its own update route. A copy this user cannot write (root's
10
10
  * under /usr) is reported and left alone.
11
- * 3. After a CLI update, ask the daemon to restart itself so the supervisor and
11
+ * 3. Install or update the daemon's own hq-cloud (sync and the outpost
12
+ * heartbeat run from it when it is newer than the CLI's bundled copy), and
13
+ * restart sync and the heartbeat right after it changes.
14
+ * 4. After a CLI update, ask the daemon to restart itself so the supervisor and
12
15
  * every service run the new version. The remote-control hosts notice an
13
16
  * updated Claude or Codex on disk themselves and restart once idle.
14
17
  */
@@ -39,6 +42,26 @@ export async function runDaemonUpdater(deps) {
39
42
  deps.log(`hq CLI: ${cli.action === "current" ? "up to date" : cli.action}`);
40
43
  }
41
44
  }
45
+ if (deps.updateHqCloud) {
46
+ const cloud = await deps.updateHqCloud().catch((err) => ({
47
+ ok: false,
48
+ from: null,
49
+ to: null,
50
+ detail: err instanceof Error ? err.message : String(err),
51
+ }));
52
+ if (!cloud.ok) {
53
+ deps.log(`hq-cloud update failed: ${cloud.detail ?? "unknown error"}`);
54
+ ok = false;
55
+ }
56
+ else if (cloud.to && cloud.to !== cloud.from) {
57
+ deps.log(`hq-cloud updated ${cloud.from ?? "(none)"} -> ${cloud.to}; restarting ${deps.hqCloudUnits.join(" and ")} onto it`);
58
+ for (const unit of deps.hqCloudUnits)
59
+ deps.requestRestart(unit);
60
+ }
61
+ else {
62
+ deps.log(`hq-cloud ${cloud.to ?? cloud.from ?? "unknown version"}: up to date`);
63
+ }
64
+ }
42
65
  const updated = [];
43
66
  for (const tool of deps.tools) {
44
67
  const outcome = updateTool(tool, deps.log);
@@ -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;