@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 +21 -0
- package/dist/commands/bot.d.ts +18 -0
- package/dist/commands/bot.js +93 -3
- package/dist/commands/daemon.js +7 -3
- package/dist/lib/bot/daemon.d.ts +69 -2
- package/dist/lib/bot/daemon.js +157 -7
- package/dist/lib/bot/run.js +1 -1
- package/dist/lib/bot/self-process.d.ts +19 -0
- package/dist/lib/bot/self-process.js +35 -0
- package/dist/lib/daemon/catalog.d.ts +8 -0
- package/dist/lib/daemon/catalog.js +13 -2
- package/dist/lib/daemon/hq-cloud.d.ts +51 -0
- package/dist/lib/daemon/hq-cloud.js +110 -0
- package/dist/lib/daemon/run.js +7 -3
- package/dist/lib/daemon/updater.d.ts +11 -1
- package/dist/lib/daemon/updater.js +24 -1
- package/dist/lib/doctor/checks/bots.d.ts +3 -0
- package/dist/lib/doctor/checks/bots.js +42 -5
- package/dist/lib/doctor/fix/apply.d.ts +14 -0
- package/dist/lib/doctor/fix/apply.js +38 -2
- package/dist/lib/doctor/fix/remediation.d.ts +1 -1
- package/dist/lib/doctor/fix/remediation.js +13 -0
- package/dist/lib/mesh/live/daemon/install.d.ts +16 -1
- package/dist/lib/mesh/live/daemon/install.js +53 -5
- package/dist/lib/node-binary.d.ts +50 -0
- package/dist/lib/node-binary.js +133 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
38
|
+
function launchdPlistArgs(plist) {
|
|
38
39
|
const block = plist.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
|
|
39
40
|
if (!block)
|
|
40
|
-
return
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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;
|