@indigoai-us/hq-cli 5.170.0 → 5.172.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,36 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.172.0] — 2026-09-24
6
+
7
+ ### Changed
8
+
9
+ - The `hq daemon` updater keeps hq-cloud current as well. Every 6 hours it
10
+ installs the newest hq-cloud with the same major version into
11
+ `~/.hq/daemon/hq-cloud`, and it restarts sync and the outpost heartbeat
12
+ right after an update. The daemon runs sync, and the heartbeat when that
13
+ hq-cloud has `hq-outpost-heartbeat`, from whichever copy is newer: this one
14
+ or the one bundled with the CLI.
15
+ - `hq daemon` sync pushes local changes as they happen (`--event-push`), the
16
+ same as outpost sync does today.
17
+
18
+ ## [5.171.0] — 2026-09-24
19
+
20
+ ### Fixed
21
+
22
+ - Local bots (`hq bot run`) now restart onto an updated hq-cli install. A
23
+ running bot checks the installed package behind its launch path every
24
+ minute. When a complete, valid, different version is seen on two checks in a
25
+ row, it stops taking new inbox items, lets running turns finish (queued items
26
+ stay un-acked for the next process), logs
27
+ `hq-cli updated <old> -> <new>; restarting to load it`, and exits 75 so
28
+ launchd, systemd, or `hq daemon` starts it again. A bot with no supervisor
29
+ starts its own replacement first. The new process logs its version at
30
+ startup and whether the update converged. A restart that comes back on the
31
+ old version blocks that target for 6 hours, and update restarts are capped
32
+ at 4 per hour. `hq bot status` shows the hq-cli version a bot is running.
33
+ Set `HQ_BOT_NO_CLI_UPDATE_RESTART=1` to turn this off.
34
+
5
35
  ## [5.170.0] — 2026-09-24
6
36
 
7
37
  ### Fixed
@@ -25,6 +25,7 @@ import { hasPromotionHold, promotionHoldSummary } from "../lib/bot/promotion-hol
25
25
  * list (online flag), and rm. Secrets are never printed.
26
26
  */
27
27
  import { promoteLocalBot } from "../lib/bot/promote.js";
28
+ import { CLI_UPDATE_DISABLE_ENV, createCliUpdateWatcher, handOffToUpdatedCli } from "../lib/bot/cli-update.js";
28
29
  import { createVaultObjectDownloader } from "../lib/agent-kit/attachment-download.js";
29
30
  import { addBotToCompanies, describeCompanyFailures, removeBotFromCompanies } from "./bot-companies.js";
30
31
  import { registerBotContinuityCommand } from "./bot-continuity.js";
@@ -743,6 +744,14 @@ async function runBotResident(nameArg) {
743
744
  // Chat attachments are fetched with the bot's own machine identity against
744
745
  // the same API its inbox and posts use (BotApi has no baseUrl override either).
745
746
  downloadAttachment: createVaultObjectDownloader({ getToken: token }),
747
+ // Reload onto an updated hq-cli: the watched path is the script this
748
+ // process was launched with, which is what its supervisor launches next.
749
+ ...(process.env[CLI_UPDATE_DISABLE_ENV] === "1"
750
+ ? {}
751
+ : { cliUpdate: createCliUpdateWatcher({ dir, entry: process.argv[1] ?? null }) }),
752
+ onCliUpdateRestart: (_update, log) => {
753
+ handOffToUpdatedCli(name, log, { spawnReplacement: () => spawnDetachedRun(name, dir) });
754
+ },
746
755
  });
747
756
  await handle.done;
748
757
  void name;
@@ -796,6 +805,8 @@ async function runBotStatus(nameArg, opts) {
796
805
  console.log(` creds: ${creds ? `present for ${creds.entityUid}` : chalk.red("missing")}`);
797
806
  if (status) {
798
807
  console.log(` started: ${status.startedAt} restarts: ${status.restarts} replies: ${status.repliesSent}`);
808
+ if (status.cliVersion)
809
+ console.log(` hq-cli: ${status.cliVersion}`);
799
810
  if (status.runtimeSignIn?.state === "expired") {
800
811
  const tool = runtimeDisplayName(status.runtimeSignIn.runtime);
801
812
  console.log(` ${chalk.yellow(`${tool} needs to sign in again`)} — sign in from HQ, or run \`${status.runtimeSignIn.runtime} ${status.runtimeSignIn.runtime === "claude" ? "auth login" : "login"}\`, then \`hq bot restart ${name}\``);
@@ -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)
@@ -0,0 +1,146 @@
1
+ /**
2
+ * A resident bot notices that the hq-cli install it was started from has been
3
+ * updated on disk, and restarts itself onto the new code.
4
+ *
5
+ * `hq bot run <name>` loads the CLI once and runs for days. The install under
6
+ * it moves on (the attended self-updater, the `hq daemon` updater, a manual
7
+ * `npm i -g`), but nothing reloaded a running bot: on 2026-09-24 the bot
8
+ * dr-love was still running code from 2026-09-19 while its install had moved
9
+ * to 5.163.0, so a shipped attachment fix never reached it.
10
+ *
11
+ * The watcher re-resolves the entry script the process was launched with
12
+ * (process.argv[1], the same path launchd / systemd / `hq daemon` will launch
13
+ * again) to its package.json on a timer. Once a complete, valid, different
14
+ * version is seen on consecutive checks, the run loop stops taking new inbox
15
+ * items, lets the running turns finish, and exits with
16
+ * CLI_UPDATE_RESTART_EXIT_CODE so its supervisor starts it again on the new
17
+ * code (or, with no supervisor, starts its own replacement first).
18
+ *
19
+ * Convergence (policy hq-self-updater-must-verify-convergence-after-install):
20
+ * the restart is recorded in <botDir>/cli-update.json before the exit. The next
21
+ * process checks the version it actually loaded against that record: a match
22
+ * is logged as converged; a process that came back on the OLD version blocks
23
+ * that target for CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS instead of restarting
24
+ * again. Restarts are also capped per window, so an install that keeps
25
+ * flipping cannot turn into a restart loop.
26
+ */
27
+ import type { BotLogger } from "./log.js";
28
+ /**
29
+ * Exit code for "restart me onto the updated CLI". Non-zero on purpose: every
30
+ * supervisor a bot runs under restarts only a failed exit — launchd
31
+ * KeepAlive{SuccessfulExit:false}, systemd Restart=on-failure, and the
32
+ * `hq daemon` "on-failure" bot units — while a clean exit 0 (a stopped or
33
+ * disabled bot) stays down. Same value as the daemon's RESTART_EXIT_CODE.
34
+ */
35
+ export declare const CLI_UPDATE_RESTART_EXIT_CODE = 75;
36
+ /** How often the installed package.json is read. */
37
+ export declare const CLI_UPDATE_CHECK_INTERVAL_MS = 60000;
38
+ /** Consecutive checks that must agree on the new version before acting. */
39
+ export declare const CLI_UPDATE_CONFIRMATIONS = 2;
40
+ /** At most this many update restarts per window, whatever the versions. */
41
+ export declare const CLI_UPDATE_RESTART_LIMIT = 4;
42
+ export declare const CLI_UPDATE_RESTART_WINDOW_MS: number;
43
+ /** A restart that came back on the old version blocks that target this long. */
44
+ export declare const CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS: number;
45
+ /** Set to "1" to turn the watcher off (development runs). */
46
+ export declare const CLI_UPDATE_DISABLE_ENV = "HQ_BOT_NO_CLI_UPDATE_RESTART";
47
+ export declare const CLI_UPDATE_LEDGER_FILE = "cli-update.json";
48
+ export type InstalledCliRead = {
49
+ ok: true;
50
+ version: string;
51
+ packageRoot: string;
52
+ } | {
53
+ ok: false;
54
+ reason: string;
55
+ packageRoot?: string;
56
+ };
57
+ export interface InstalledCliFs {
58
+ realpathSync(p: string): string;
59
+ readFileSync(p: string, encoding: "utf8"): string;
60
+ }
61
+ /**
62
+ * The hq-cli version installed behind `entry`, resolved the way the next launch
63
+ * of the same command line will resolve it: follow the symlinks now (a pnpm or
64
+ * versioned layout moves the link, npm rewrites the directory in place), then
65
+ * walk up to the package.json that names this CLI. Anything short of a
66
+ * readable manifest with a valid semver version is not a version.
67
+ */
68
+ export declare function readInstalledCli(entry: string, io?: InstalledCliFs): InstalledCliRead;
69
+ export interface CliUpdate {
70
+ from: string;
71
+ to: string;
72
+ packageRoot: string;
73
+ }
74
+ interface CliUpdateLedger {
75
+ v: 1;
76
+ /** Written just before an update restart; settled by the next process. */
77
+ pending?: {
78
+ from: string;
79
+ to: string;
80
+ at: string;
81
+ };
82
+ /** ISO times of recent update restarts (for the per-window cap). */
83
+ restarts: string[];
84
+ /** A target that did not converge, and when it may be tried again. */
85
+ blocked?: {
86
+ version: string;
87
+ until: string;
88
+ };
89
+ }
90
+ export declare function readCliUpdateLedger(dir: string): CliUpdateLedger;
91
+ /** What the run loop needs from the watcher (injectable in tests). */
92
+ export interface CliUpdateWatch {
93
+ /** The version of the code this process loaded. */
94
+ readonly runningVersion: string;
95
+ /** Settle the previous restart (converged or not) and say what is watched. */
96
+ startup(log: BotLogger): void;
97
+ /** A confirmed update to restart onto, or null. Cheap; rate-limits itself. */
98
+ poll(log: BotLogger): CliUpdate | null;
99
+ /** Record the restart about to happen, before the process exits. */
100
+ recordRestart(update: CliUpdate): void;
101
+ }
102
+ export interface CliUpdateWatcherOptions {
103
+ dir: string;
104
+ /** The script this process was launched with (process.argv[1]); null = do not watch. */
105
+ entry: string | null;
106
+ runningVersion?: string;
107
+ now?: () => number;
108
+ read?: (entry: string) => InstalledCliRead;
109
+ /** True while an installer is provably rewriting the package dir. */
110
+ rewriteInProgress?: (packageRoot: string) => boolean;
111
+ checkIntervalMs?: number;
112
+ confirmations?: number;
113
+ restartLimit?: number;
114
+ restartWindowMs?: number;
115
+ cooldownMs?: number;
116
+ }
117
+ export declare function createCliUpdateWatcher(opts: CliUpdateWatcherOptions): CliUpdateWatch;
118
+ /**
119
+ * Who starts this bot again after it exits.
120
+ *
121
+ * - "hq-daemon": `hq daemon` sets HQ_DAEMON_UNIT=bots:<name> on its children;
122
+ * the unit is "on-failure" (supervisor.ts).
123
+ * - "launchd": launchd sets XPC_SERVICE_NAME to the job label for every job it
124
+ * spawns, including LaunchAgents installed before this code existed.
125
+ * - "systemd": the unit `hq-bot-<name>.service` shows up in the process cgroup.
126
+ * - "none": `hq bot start` without a service (Windows, --no-daemon, a Linux
127
+ * unit never enabled) or a foreground run. Nobody restarts this process.
128
+ */
129
+ export type BotSupervisor = "hq-daemon" | "launchd" | "systemd" | "none";
130
+ export declare function detectBotSupervisor(name: string, opts?: {
131
+ env?: NodeJS.ProcessEnv;
132
+ platform?: NodeJS.Platform;
133
+ readCgroup?: () => string | null;
134
+ }): BotSupervisor;
135
+ /**
136
+ * The last step of an update restart, run after the pid lock is released and
137
+ * just before the exit. A supervised bot only has to exit; an unsupervised one
138
+ * starts its replacement on the same entry script (now the new code) first,
139
+ * because nothing else will.
140
+ */
141
+ export declare function handOffToUpdatedCli(name: string, log: BotLogger, deps: {
142
+ supervisor?: BotSupervisor;
143
+ spawnReplacement: () => number;
144
+ }): BotSupervisor;
145
+ export {};
146
+ //# sourceMappingURL=cli-update.d.ts.map
@@ -0,0 +1,278 @@
1
+ /**
2
+ * A resident bot notices that the hq-cli install it was started from has been
3
+ * updated on disk, and restarts itself onto the new code.
4
+ *
5
+ * `hq bot run <name>` loads the CLI once and runs for days. The install under
6
+ * it moves on (the attended self-updater, the `hq daemon` updater, a manual
7
+ * `npm i -g`), but nothing reloaded a running bot: on 2026-09-24 the bot
8
+ * dr-love was still running code from 2026-09-19 while its install had moved
9
+ * to 5.163.0, so a shipped attachment fix never reached it.
10
+ *
11
+ * The watcher re-resolves the entry script the process was launched with
12
+ * (process.argv[1], the same path launchd / systemd / `hq daemon` will launch
13
+ * again) to its package.json on a timer. Once a complete, valid, different
14
+ * version is seen on consecutive checks, the run loop stops taking new inbox
15
+ * items, lets the running turns finish, and exits with
16
+ * CLI_UPDATE_RESTART_EXIT_CODE so its supervisor starts it again on the new
17
+ * code (or, with no supervisor, starts its own replacement first).
18
+ *
19
+ * Convergence (policy hq-self-updater-must-verify-convergence-after-install):
20
+ * the restart is recorded in <botDir>/cli-update.json before the exit. The next
21
+ * process checks the version it actually loaded against that record: a match
22
+ * is logged as converged; a process that came back on the OLD version blocks
23
+ * that target for CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS instead of restarting
24
+ * again. Restarts are also capped per window, so an install that keeps
25
+ * flipping cannot turn into a restart loop.
26
+ */
27
+ import * as fs from "node:fs";
28
+ import * as path from "node:path";
29
+ import semver from "semver";
30
+ import { CLI_NAME, CLI_VERSION } from "../../cli-version.js";
31
+ import { installRewriteInProgress } from "../../utils/install-tree-torn.js";
32
+ import { updateLockPath } from "../../utils/update-lock.js";
33
+ import { launchdLabel } from "./paths.js";
34
+ /**
35
+ * Exit code for "restart me onto the updated CLI". Non-zero on purpose: every
36
+ * supervisor a bot runs under restarts only a failed exit — launchd
37
+ * KeepAlive{SuccessfulExit:false}, systemd Restart=on-failure, and the
38
+ * `hq daemon` "on-failure" bot units — while a clean exit 0 (a stopped or
39
+ * disabled bot) stays down. Same value as the daemon's RESTART_EXIT_CODE.
40
+ */
41
+ export const CLI_UPDATE_RESTART_EXIT_CODE = 75;
42
+ /** How often the installed package.json is read. */
43
+ export const CLI_UPDATE_CHECK_INTERVAL_MS = 60_000;
44
+ /** Consecutive checks that must agree on the new version before acting. */
45
+ export const CLI_UPDATE_CONFIRMATIONS = 2;
46
+ /** At most this many update restarts per window, whatever the versions. */
47
+ export const CLI_UPDATE_RESTART_LIMIT = 4;
48
+ export const CLI_UPDATE_RESTART_WINDOW_MS = 60 * 60_000;
49
+ /** A restart that came back on the old version blocks that target this long. */
50
+ export const CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS = 6 * 60 * 60_000;
51
+ /** Set to "1" to turn the watcher off (development runs). */
52
+ export const CLI_UPDATE_DISABLE_ENV = "HQ_BOT_NO_CLI_UPDATE_RESTART";
53
+ export const CLI_UPDATE_LEDGER_FILE = "cli-update.json";
54
+ /**
55
+ * The hq-cli version installed behind `entry`, resolved the way the next launch
56
+ * of the same command line will resolve it: follow the symlinks now (a pnpm or
57
+ * versioned layout moves the link, npm rewrites the directory in place), then
58
+ * walk up to the package.json that names this CLI. Anything short of a
59
+ * readable manifest with a valid semver version is not a version.
60
+ */
61
+ export function readInstalledCli(entry, io = fs) {
62
+ let real;
63
+ try {
64
+ real = io.realpathSync(entry);
65
+ }
66
+ catch {
67
+ return { ok: false, reason: `${entry} does not resolve` };
68
+ }
69
+ let dir = path.dirname(real);
70
+ for (let i = 0; i < 8; i++) {
71
+ const manifest = path.join(dir, "package.json");
72
+ let raw;
73
+ try {
74
+ raw = io.readFileSync(manifest, "utf8");
75
+ }
76
+ catch {
77
+ raw = null;
78
+ }
79
+ if (raw !== null) {
80
+ let parsed;
81
+ try {
82
+ parsed = JSON.parse(raw);
83
+ }
84
+ catch {
85
+ return { ok: false, reason: `${manifest} is not valid JSON`, packageRoot: dir };
86
+ }
87
+ if (parsed.name === CLI_NAME) {
88
+ const version = typeof parsed.version === "string" ? semver.valid(parsed.version) : null;
89
+ if (!version)
90
+ return { ok: false, reason: `${manifest} has no valid version`, packageRoot: dir };
91
+ return { ok: true, version, packageRoot: dir };
92
+ }
93
+ }
94
+ const parent = path.dirname(dir);
95
+ if (parent === dir)
96
+ break;
97
+ dir = parent;
98
+ }
99
+ return { ok: false, reason: `no ${CLI_NAME} package.json above ${real}` };
100
+ }
101
+ function ledgerPath(dir) {
102
+ return path.join(dir, CLI_UPDATE_LEDGER_FILE);
103
+ }
104
+ export function readCliUpdateLedger(dir) {
105
+ try {
106
+ const raw = JSON.parse(fs.readFileSync(ledgerPath(dir), "utf8"));
107
+ return {
108
+ v: 1,
109
+ restarts: Array.isArray(raw.restarts) ? raw.restarts.filter((s) => typeof s === "string") : [],
110
+ ...(raw.pending && typeof raw.pending.from === "string" && typeof raw.pending.to === "string" ? { pending: raw.pending } : {}),
111
+ ...(raw.blocked && typeof raw.blocked.version === "string" && typeof raw.blocked.until === "string" ? { blocked: raw.blocked } : {}),
112
+ };
113
+ }
114
+ catch {
115
+ return { v: 1, restarts: [] };
116
+ }
117
+ }
118
+ function writeCliUpdateLedger(dir, ledger) {
119
+ const target = ledgerPath(dir);
120
+ const tmp = `${target}.${process.pid}.tmp`;
121
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
122
+ fs.writeFileSync(tmp, `${JSON.stringify(ledger, null, 2)}\n`, { mode: 0o600 });
123
+ fs.renameSync(tmp, target);
124
+ }
125
+ export function createCliUpdateWatcher(opts) {
126
+ const runningVersion = opts.runningVersion ?? CLI_VERSION;
127
+ const now = opts.now ?? Date.now;
128
+ const read = opts.read ?? ((entry) => readInstalledCli(entry));
129
+ const rewriteInProgress = opts.rewriteInProgress ?? ((packageRoot) => installRewriteInProgress({ packageRoot, lockPath: updateLockPath() }));
130
+ const checkIntervalMs = opts.checkIntervalMs ?? CLI_UPDATE_CHECK_INTERVAL_MS;
131
+ const confirmations = Math.max(1, opts.confirmations ?? CLI_UPDATE_CONFIRMATIONS);
132
+ const restartLimit = Math.max(1, opts.restartLimit ?? CLI_UPDATE_RESTART_LIMIT);
133
+ const restartWindowMs = opts.restartWindowMs ?? CLI_UPDATE_RESTART_WINDOW_MS;
134
+ const cooldownMs = opts.cooldownMs ?? CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS;
135
+ const { dir, entry } = opts;
136
+ let nextCheckAt = now() + checkIntervalMs;
137
+ let candidate = null;
138
+ let seen = 0;
139
+ /** Last "why not" line, so a condition that persists is logged once. */
140
+ let lastNote = null;
141
+ const note = (log, level, message) => {
142
+ if (message === lastNote)
143
+ return;
144
+ lastNote = message;
145
+ log(level, message);
146
+ };
147
+ const recentRestarts = (ledger, at) => ledger.restarts.filter((iso) => {
148
+ const t = Date.parse(iso);
149
+ return Number.isFinite(t) && at - t < restartWindowMs;
150
+ });
151
+ return {
152
+ runningVersion,
153
+ startup(log) {
154
+ if (!entry) {
155
+ log("info", "not watching for hq-cli updates (no entry script)");
156
+ return;
157
+ }
158
+ const ledger = readCliUpdateLedger(dir);
159
+ const pending = ledger.pending;
160
+ if (pending) {
161
+ delete ledger.pending;
162
+ if (runningVersion === pending.to) {
163
+ log("info", `hq-cli update converged: now running ${runningVersion} (was ${pending.from})`);
164
+ if (ledger.blocked?.version === runningVersion)
165
+ delete ledger.blocked;
166
+ }
167
+ else if (runningVersion === pending.from) {
168
+ const until = new Date(now() + cooldownMs).toISOString();
169
+ ledger.blocked = { version: pending.to, until };
170
+ log("error", `restarted to load hq-cli ${pending.to} but this process still loaded ${runningVersion}; ` +
171
+ `not restarting for ${pending.to} again until ${until} (check which install ${entry} resolves to)`);
172
+ }
173
+ else {
174
+ log("info", `restarted for hq-cli ${pending.to}; now running ${runningVersion}`);
175
+ }
176
+ try {
177
+ writeCliUpdateLedger(dir, ledger);
178
+ }
179
+ catch (err) {
180
+ log("warn", `could not update ${CLI_UPDATE_LEDGER_FILE}: ${err instanceof Error ? err.message : String(err)}`);
181
+ }
182
+ }
183
+ log("info", `watching ${entry} for hq-cli updates (running ${runningVersion})`);
184
+ },
185
+ poll(log) {
186
+ if (!entry)
187
+ return null;
188
+ const at = now();
189
+ if (at < nextCheckAt)
190
+ return null;
191
+ nextCheckAt = at + checkIntervalMs;
192
+ const installed = read(entry);
193
+ if (!installed.ok) {
194
+ candidate = null;
195
+ seen = 0;
196
+ note(log, "warn", `hq-cli install not readable, not restarting: ${installed.reason}`);
197
+ return null;
198
+ }
199
+ if (installed.version === runningVersion) {
200
+ candidate = null;
201
+ seen = 0;
202
+ lastNote = null;
203
+ return null;
204
+ }
205
+ if (rewriteInProgress(installed.packageRoot)) {
206
+ candidate = null;
207
+ seen = 0;
208
+ note(log, "info", `hq-cli ${installed.version} is still being installed; waiting for it to finish`);
209
+ return null;
210
+ }
211
+ if (candidate === installed.version)
212
+ seen += 1;
213
+ else {
214
+ candidate = installed.version;
215
+ seen = 1;
216
+ }
217
+ if (seen < confirmations) {
218
+ note(log, "info", `hq-cli ${installed.version} is installed (running ${runningVersion}); confirming before restarting`);
219
+ return null;
220
+ }
221
+ const ledger = readCliUpdateLedger(dir);
222
+ if (ledger.blocked?.version === installed.version && at < Date.parse(ledger.blocked.until)) {
223
+ note(log, "warn", `not restarting for hq-cli ${installed.version}: an earlier restart for it did not load it (retry after ${ledger.blocked.until})`);
224
+ return null;
225
+ }
226
+ if (recentRestarts(ledger, at).length >= restartLimit) {
227
+ note(log, "warn", `not restarting for hq-cli ${installed.version}: already restarted ${restartLimit} times for updates in the last ${Math.round(restartWindowMs / 60_000)} minutes`);
228
+ return null;
229
+ }
230
+ return { from: runningVersion, to: installed.version, packageRoot: installed.packageRoot };
231
+ },
232
+ recordRestart(update) {
233
+ const at = now();
234
+ const ledger = readCliUpdateLedger(dir);
235
+ ledger.restarts = [...recentRestarts(ledger, at), new Date(at).toISOString()];
236
+ ledger.pending = { from: update.from, to: update.to, at: new Date(at).toISOString() };
237
+ writeCliUpdateLedger(dir, ledger);
238
+ },
239
+ };
240
+ }
241
+ export function detectBotSupervisor(name, opts = {}) {
242
+ const env = opts.env ?? process.env;
243
+ const platform = opts.platform ?? process.platform;
244
+ if (env.HQ_DAEMON_UNIT === `bots:${name}`)
245
+ return "hq-daemon";
246
+ if (platform === "darwin" && env.XPC_SERVICE_NAME === launchdLabel(name))
247
+ return "launchd";
248
+ if (platform === "linux") {
249
+ const readCgroup = opts.readCgroup ??
250
+ (() => {
251
+ try {
252
+ return fs.readFileSync("/proc/self/cgroup", "utf8");
253
+ }
254
+ catch {
255
+ return null;
256
+ }
257
+ });
258
+ if (readCgroup()?.includes(`/hq-bot-${name}.service`))
259
+ return "systemd";
260
+ }
261
+ return "none";
262
+ }
263
+ /**
264
+ * The last step of an update restart, run after the pid lock is released and
265
+ * just before the exit. A supervised bot only has to exit; an unsupervised one
266
+ * starts its replacement on the same entry script (now the new code) first,
267
+ * because nothing else will.
268
+ */
269
+ export function handOffToUpdatedCli(name, log, deps) {
270
+ const supervisor = deps.supervisor ?? detectBotSupervisor(name);
271
+ if (supervisor !== "none") {
272
+ log("info", `exiting for ${supervisor} to start the updated hq-cli`);
273
+ return supervisor;
274
+ }
275
+ log("info", `no supervisor; started replacement pid ${deps.spawnReplacement()}`);
276
+ return supervisor;
277
+ }
278
+ //# sourceMappingURL=cli-update.js.map
@@ -53,6 +53,7 @@ import { type BotRuntime, type RuntimeTurnInput, type RuntimeTurnResult } from "
53
53
  import type { VaultObjectDownloader } from "../agent-kit/attachment-download.js";
54
54
  import { type HeartbeatAgentUidSource, type HeartbeatCredsSource, type HeartbeatIdentityMismatchReporter } from "../heartbeat-identity.js";
55
55
  import { type BotState, type BotStatusFile } from "./status.js";
56
+ import { type CliUpdate, type CliUpdateWatch } from "./cli-update.js";
56
57
  export declare const INBOX_POLL_INTERVAL_MS = 2000;
57
58
  export declare const HEARTBEAT_INTERVAL_MS = 30000;
58
59
  /** First 429/5xx wait when Retry-After is absent (inbox polls at 2s). */
@@ -150,6 +151,17 @@ export interface BotRunDeps {
150
151
  agentUidSource?: HeartbeatAgentUidSource;
151
152
  credsSource?: HeartbeatCredsSource;
152
153
  reportIdentityMismatch?: HeartbeatIdentityMismatchReporter;
154
+ /**
155
+ * Watches the hq-cli install this process was started from (cli-update.ts).
156
+ * When it reports an update, the bot stops taking inbox items, lets running
157
+ * turns finish, and exits CLI_UPDATE_RESTART_EXIT_CODE. Unset = no watch.
158
+ */
159
+ cliUpdate?: CliUpdateWatch;
160
+ /**
161
+ * Called after the pid lock is released and just before the update-restart
162
+ * exit: the place to start a replacement when no supervisor will.
163
+ */
164
+ onCliUpdateRestart?: (update: CliUpdate, log: BotLogger) => void;
153
165
  }
154
166
  export declare const KICKOFF_MESSAGE_ID = "kickoff";
155
167
  /**
@@ -74,6 +74,8 @@ import { HQ_BOT_AGENT_UID_ENV } from "../../commands/onboard-bot-membership.js";
74
74
  import { captureHeartbeatIdentityMismatch, identityMismatchMessage, isHeartbeatIdentityMismatch, } from "../heartbeat-identity.js";
75
75
  import { clearBotSession, readBotSession, writeBotSession } from "./session.js";
76
76
  import { defaultBotStatus, patchBotStatus, readBotStatus } from "./status.js";
77
+ import { CLI_VERSION } from "../../cli-version.js";
78
+ import { CLI_UPDATE_RESTART_EXIT_CODE } from "./cli-update.js";
77
79
  export const INBOX_POLL_INTERVAL_MS = 2_000;
78
80
  export const HEARTBEAT_INTERVAL_MS = 30_000;
79
81
  /** First 429/5xx wait when Retry-After is absent (inbox polls at 2s). */
@@ -285,7 +287,7 @@ export async function runBot(deps) {
285
287
  pid,
286
288
  ...(deps.isProcessAlive ? { isProcessAlive: deps.isProcessAlive } : {}),
287
289
  });
288
- const finish = async (final, code, reason) => {
290
+ const finish = async (final, code, reason, beforeExit) => {
289
291
  if (state === "stopped" || state === "failed")
290
292
  return;
291
293
  state = final;
@@ -293,9 +295,18 @@ export async function runBot(deps) {
293
295
  log(final === "failed" ? "error" : "info", `bot ${final}: ${reason}`);
294
296
  if (!deps.skipPidLock)
295
297
  releasePidLock(dir, lockDeps);
298
+ try {
299
+ beforeExit?.();
300
+ }
301
+ catch (err) {
302
+ log("error", `before exit: ${err instanceof Error ? err.message : String(err)}`);
303
+ }
296
304
  resolveDone(final);
297
305
  exit(code);
298
306
  };
307
+ const cliVersion = deps.cliUpdate?.runningVersion ?? CLI_VERSION;
308
+ /** Set once an update restart is due: no new turns, then exit for the supervisor. */
309
+ let cliUpdatePending = null;
299
310
  // ── Preflight: creds, pid lock, owner verification ─────────────────────────
300
311
  const credsProblem = preflightCreds(dir, config);
301
312
  if (credsProblem) {
@@ -327,8 +338,9 @@ export async function runBot(deps) {
327
338
  return { stop: async () => { }, done, status: () => null, idle: () => true };
328
339
  }
329
340
  // A restart is the owner's "try again" after signing in: start clean.
330
- setStatus({ ...fallback(), pid, state: "starting", canDrainForPromotion: false, runtimeSignIn: null });
331
- log("info", `starting ${config.name} (${config.runtime}, ${kind} bot${companies.length ? ` of ${companies.join(", ")}` : ""}) as ${config.agentUid} for ${config.ownerUid}`);
341
+ setStatus({ ...fallback(), pid, state: "starting", canDrainForPromotion: false, runtimeSignIn: null, cliVersion });
342
+ log("info", `starting ${config.name} on hq-cli ${cliVersion} (${config.runtime}, ${kind} bot${companies.length ? ` of ${companies.join(", ")}` : ""}) as ${config.agentUid} for ${config.ownerUid}`);
343
+ deps.cliUpdate?.startup(log);
332
344
  try {
333
345
  const record = await api.getAgent(config.agentUid);
334
346
  // Promotion keeps the identity and owner. Ownership alone must not let
@@ -1476,6 +1488,17 @@ export async function runBot(deps) {
1476
1488
  while (!stopping && state === "running") {
1477
1489
  if (hasPromotionHold(dir))
1478
1490
  break;
1491
+ // The install under this process was updated: take nothing new, let the
1492
+ // turns already running finish, then exit so the supervisor loads it.
1493
+ // Queued items were never started or acked; the next process gets them.
1494
+ const update = deps.cliUpdate?.poll(log) ?? null;
1495
+ if (update) {
1496
+ cliUpdatePending = update;
1497
+ stopping = true;
1498
+ const busy = running.size;
1499
+ log("info", `hq-cli updated ${update.from} -> ${update.to}; restarting to load it${busy ? ` after ${busy} running turn${busy === 1 ? "" : "s"} finish${busy === 1 ? "es" : ""}` : ""}`);
1500
+ break;
1501
+ }
1479
1502
  // A kickoff held for a sign-in retries on the same timer as messages.
1480
1503
  if (busyLanes.size === 0 &&
1481
1504
  (!signInIssue || now().getTime() >= nextSignInRetryAt) &&
@@ -1525,6 +1548,9 @@ export async function runBot(deps) {
1525
1548
  return readBotStatus(dir);
1526
1549
  }
1527
1550
  const stop = async (mode = "abort") => {
1551
+ // An owner stop or a promotion while an update restart drains wins: the
1552
+ // bot then exits 0 and stays down instead of being restarted.
1553
+ cliUpdatePending = null;
1528
1554
  if (stopping) {
1529
1555
  if (mode === "abort")
1530
1556
  for (const turn of currentTurns)
@@ -1552,8 +1578,20 @@ export async function runBot(deps) {
1552
1578
  void stop("drain"); });
1553
1579
  setStatus({ canDrainForPromotion: true });
1554
1580
  void Promise.allSettled([heartbeatLoop, inboxLoop]).then(() => {
1555
- if (state === "running")
1581
+ if (state !== "running")
1582
+ return;
1583
+ const update = cliUpdatePending;
1584
+ if (!update) {
1556
1585
  void finish("stopped", 0, "loops ended");
1586
+ return;
1587
+ }
1588
+ try {
1589
+ deps.cliUpdate?.recordRestart(update);
1590
+ }
1591
+ catch (err) {
1592
+ log("warn", `could not record the update restart: ${err instanceof Error ? err.message : String(err)}`);
1593
+ }
1594
+ void finish("stopped", CLI_UPDATE_RESTART_EXIT_CODE, `hq-cli updated ${update.from} -> ${update.to}; restarting to load it`, () => deps.onCliUpdateRestart?.(update, log));
1557
1595
  });
1558
1596
  return { stop, done, status: readStatus, idle: () => pending.size === 0 };
1559
1597
  }
@@ -44,6 +44,8 @@ export interface BotStatusFile {
44
44
  * reason); cleared by the next turn that works. `null` = known to work.
45
45
  */
46
46
  runtimeSignIn?: RuntimeSignInIssue | null;
47
+ /** hq-cli version this process loaded (a bot keeps it until it restarts). */
48
+ cliVersion?: string;
47
49
  updatedAt: string;
48
50
  }
49
51
  export declare function defaultBotStatus(pid: number, runtime: BotRuntimeId, now?: () => Date): BotStatusFile;
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.170.0",
3
+ "version": "5.172.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {