@indigoai-us/hq-cli 5.172.0 → 5.174.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 +18 -0
- package/dist/commands/bot.d.ts +18 -0
- package/dist/commands/bot.js +93 -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/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/lanes/outpost-worktree-pruner.d.ts +2 -0
- package/dist/lib/lanes/outpost-worktree-pruner.js +69 -3
- 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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.174.0] — 2026-09-24
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Outpost worktree pruning now tolerates cwd `EACCES` only for the verified
|
|
10
|
+
same-user systemd `(sd-pam)` helper. Other same-user cwd failures and all fd
|
|
11
|
+
scan failures remain fail-closed. This entry was listed under 5.173.0 by
|
|
12
|
+
mistake: 5.173.0 was tagged before the change merged, so it first ships here.
|
|
13
|
+
|
|
14
|
+
## [5.173.0] — 2026-09-24
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- `hq bot stop <name>` run from inside that bot's own turn now lets the turn finish: it signals the bot to drain (finish the running turn, post the reply, exit 0), which launchd leaves stopped. Before, it booted out the bot's own launchd job, which killed the bot, the turn, and the stop command itself (exit 137), so no reply was posted. `hq bot restart <name>` from inside the bot's own turn is refused with instructions, and `hq bot start` from inside the bot skips the agent reload.
|
|
19
|
+
- Bot LaunchAgents and systemd units, the Work Mesh daemon unit, the `hq daemon` unit, and the lanes and agent-kit units now name node by its upgrade-stable path (`/opt/homebrew/bin/node` or `/opt/homebrew/opt/<formula>/bin/node`, or nvm's `current` link) instead of the versioned `/opt/homebrew/Cellar/node/<version>/bin/node`. Before, `brew upgrade node` deleted that path and launchd could no longer start the bots. The stable path is used only when it resolves to the same node binary; otherwise the unit keeps the resolved path as before.
|
|
20
|
+
- Installed bot LaunchAgents that still name a versioned or missing node are repaired: `hq bot start` rewrites and reloads the agent, a bot started by launchd rewrites its own agent and reloads it from a detached shell before it takes any message, and `hq doctor` reports it (`bots.<name>.daemon-node`), with `hq doctor --fix` rewriting only the plist file and not reloading launchd. `hq bot list` and `hq bot status` flag a missing node binary. `hq mesh daemon install --if-stale` also reinstalls a same-version unit whose node path is missing or versioned.
|
|
21
|
+
|
|
5
22
|
## [5.172.0] — 2026-09-24
|
|
6
23
|
|
|
7
24
|
### Changed
|
|
@@ -38,6 +55,7 @@
|
|
|
38
55
|
|
|
39
56
|
- Switching the HQ session company (including `hq-session.sh set company_slug`) clears the Work Mesh project and task, posts `session_end` to the previous company, and holds a personal target as `needs_company`.
|
|
40
57
|
|
|
58
|
+
|
|
41
59
|
## [5.169.0] — 2026-09-24
|
|
42
60
|
|
|
43
61
|
### Fixed
|
package/dist/commands/bot.d.ts
CHANGED
|
@@ -41,6 +41,11 @@ export interface BotRow {
|
|
|
41
41
|
runtimeSignIn: RuntimeSignInIssue | null;
|
|
42
42
|
daemonInstalled: boolean;
|
|
43
43
|
daemonLoaded: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* The startup agent's node binary is gone (a node upgrade removed the
|
|
46
|
+
* versioned path it names), so launchd cannot start the bot. Null when fine.
|
|
47
|
+
*/
|
|
48
|
+
daemonNodeMissing: string | null;
|
|
44
49
|
dir: string;
|
|
45
50
|
introSentAt?: string;
|
|
46
51
|
/** "personal" acts as its owner everywhere; "company" acts as itself. */
|
|
@@ -63,6 +68,19 @@ export declare function listBotNames(root: string): string[];
|
|
|
63
68
|
export declare function botTokenSupplier(dir: string): () => Promise<string>;
|
|
64
69
|
/** Spawn `hq bot run <name>` detached (no launchd). */
|
|
65
70
|
export declare function spawnDetachedRun(name: string, dir: string): number;
|
|
71
|
+
/**
|
|
72
|
+
* `output`: "json" | "text" print a result; "silent" prints nothing — used
|
|
73
|
+
* when stop is a step inside rm/restart, whose own JSON must be the only
|
|
74
|
+
* document on stdout (the desktop app parses it; a second object made it
|
|
75
|
+
* "unreadable output: trailing characters").
|
|
76
|
+
*/
|
|
77
|
+
/** Seams for the self-stop path, replaced in tests. */
|
|
78
|
+
export declare const botStopSeams: {
|
|
79
|
+
/** True when this process runs inside the bot process `botPid` (one of its turns). */
|
|
80
|
+
isInsideBot: (botPid: number) => boolean;
|
|
81
|
+
/** Ask the bot to finish its running turns and exit 0 (the drain signal). */
|
|
82
|
+
signalDrain: (botPid: number) => boolean;
|
|
83
|
+
};
|
|
66
84
|
/**
|
|
67
85
|
* The kind a new bot gets, before anything is provisioned. Personal unless
|
|
68
86
|
* `--kind company` is given (the desktop app always passes --kind). A company
|
package/dist/commands/bot.js
CHANGED
|
@@ -42,8 +42,10 @@ import { getCompanyUid, resolveCallerPersonUid } from "../utils/vault-api.js";
|
|
|
42
42
|
import { resolveHqBinary } from "../lib/mesh/live/daemon/install.js";
|
|
43
43
|
import { askDaemonToStart, askDaemonToStop, daemonOwnsProcess, daemonSupervises, daemonWillSupervise, handOffInstallHere, } from "../lib/daemon/handoff.js";
|
|
44
44
|
import { daemonPaths } from "../lib/daemon/paths.js";
|
|
45
|
+
import { launchdLabel } from "../lib/bot/paths.js";
|
|
46
|
+
import { isAncestorProcess } from "../lib/bot/self-process.js";
|
|
45
47
|
import { runtimeDisplayName } from "../lib/bot/runtime-sign-in.js";
|
|
46
|
-
import { BotApi, BotApiError, BOT_RESTORE_DEFAULTS, buildBotLocalConfig, describeBotLocalConfig, orphanBotRunMessage, parseBotLocalConfig, removeOrphanBotDaemon, botDaemonStatus, botDir, botCredsPath, botLogPath, botsRoot, botTokenStateDir, botWorkerRelDir, buildBotDaemonPaths, createBotLogger, deleteBotCreds, installBotDaemon, repairBotDaemonIfStale, introDmText, isBotMemoryMode, botLocalMemoryDir, botMemoryMode, resolveBotMemoryDir, validateBotIntro, validateBotKickoff, validateBotEffort, validateBotModel, effectiveBotEffort, BOT_EFFORT_LEVELS, DEFAULT_BOT_EFFORT, BOT_MEMORY_MODES, BOT_KINDS, isBotKind, effectiveBotKind, effectiveBotCompanies, normalizeBotCompanies, SETUP_BOT_WORKER_ID, isBotRuntimeId, localNameForRemoteBot, remoteBotRunnability, isPidAlive, listBotWorkerOptions, patchBotConfig, readBotConfig, readBotCredsIdentity, readBotStatus, resolveBotWorker, runBot, runtimeFor, scaffoldBotMemory, scaffoldBotWorker, startBotDaemon, stopBotDaemon, uninstallBotDaemon, validateBotName, writeBotConfig, writeBotCreds, BOT_RUNTIMES, BOT_INTRO_MAX_CHARS, BOT_KICKOFF_MAX_CHARS, detectBotPlatform, } from "../lib/bot/index.js";
|
|
48
|
+
import { BotApi, BotApiError, BOT_RESTORE_DEFAULTS, buildBotLocalConfig, describeBotLocalConfig, orphanBotRunMessage, parseBotLocalConfig, removeOrphanBotDaemon, botDaemonStatus, botDir, botCredsPath, botLogPath, botsRoot, botTokenStateDir, botWorkerRelDir, buildBotDaemonPaths, createBotLogger, deleteBotCreds, installBotDaemon, repairBotDaemonIfStale, healBotDaemonNodeFromJob, introDmText, isBotMemoryMode, botLocalMemoryDir, botMemoryMode, resolveBotMemoryDir, validateBotIntro, validateBotKickoff, validateBotEffort, validateBotModel, effectiveBotEffort, BOT_EFFORT_LEVELS, DEFAULT_BOT_EFFORT, BOT_MEMORY_MODES, BOT_KINDS, isBotKind, effectiveBotKind, effectiveBotCompanies, normalizeBotCompanies, SETUP_BOT_WORKER_ID, isBotRuntimeId, localNameForRemoteBot, remoteBotRunnability, isPidAlive, listBotWorkerOptions, patchBotConfig, readBotConfig, readBotCredsIdentity, readBotStatus, resolveBotWorker, runBot, runtimeFor, scaffoldBotMemory, scaffoldBotWorker, startBotDaemon, stopBotDaemon, uninstallBotDaemon, validateBotName, writeBotConfig, writeBotCreds, BOT_RUNTIMES, BOT_INTRO_MAX_CHARS, BOT_KICKOFF_MAX_CHARS, detectBotPlatform, } from "../lib/bot/index.js";
|
|
47
49
|
function collectRepeated(value, previous) {
|
|
48
50
|
return [...previous, value];
|
|
49
51
|
}
|
|
@@ -135,11 +137,14 @@ function rowFor(name, opts = {}) {
|
|
|
135
137
|
const inflightTurns = readInflightTurns(dir);
|
|
136
138
|
let daemonInstalled = false;
|
|
137
139
|
let daemonLoaded = false;
|
|
140
|
+
let daemonNodeMissing = null;
|
|
138
141
|
if (opts.withDaemon !== false) {
|
|
139
142
|
try {
|
|
140
143
|
const d = botDaemonStatus(buildBotDaemonPaths({ name, botDir: dir, hqRoot: config.hqRoot }), daemonDeps());
|
|
141
144
|
daemonInstalled = d.installed;
|
|
142
145
|
daemonLoaded = d.loaded;
|
|
146
|
+
if (d.nodeProblem === "missing" && d.nodeBinary)
|
|
147
|
+
daemonNodeMissing = d.nodeBinary;
|
|
143
148
|
}
|
|
144
149
|
catch {
|
|
145
150
|
/* hq binary unresolvable in odd environments — daemon status unknown */
|
|
@@ -168,6 +173,7 @@ function rowFor(name, opts = {}) {
|
|
|
168
173
|
runtimeSignIn: status?.runtimeSignIn ?? null,
|
|
169
174
|
daemonInstalled,
|
|
170
175
|
daemonLoaded,
|
|
176
|
+
daemonNodeMissing,
|
|
171
177
|
dir,
|
|
172
178
|
introSentAt: config.introSentAt,
|
|
173
179
|
kind: effectiveBotKind(config),
|
|
@@ -278,7 +284,11 @@ async function startBot(name, dir, config, output, extra = {}) {
|
|
|
278
284
|
}
|
|
279
285
|
}
|
|
280
286
|
else if (d.installed && process.platform === "darwin") {
|
|
281
|
-
|
|
287
|
+
// A repair reloads the job; from inside one of the bot's own turns that
|
|
288
|
+
// bootout would kill the turn and this command with it.
|
|
289
|
+
const self = readBotStatus(dir);
|
|
290
|
+
if (!(self && isPidAlive(self.pid) && selfStopProbe(self.pid)))
|
|
291
|
+
repairBotDaemonIfStale(paths, daemonDeps());
|
|
282
292
|
const r = startBotDaemon(paths, daemonDeps());
|
|
283
293
|
how = r.message;
|
|
284
294
|
}
|
|
@@ -318,6 +328,32 @@ async function startBot(name, dir, config, output, extra = {}) {
|
|
|
318
328
|
* document on stdout (the desktop app parses it; a second object made it
|
|
319
329
|
* "unreadable output: trailing characters").
|
|
320
330
|
*/
|
|
331
|
+
/** Seams for the self-stop path, replaced in tests. */
|
|
332
|
+
export const botStopSeams = {
|
|
333
|
+
/** True when this process runs inside the bot process `botPid` (one of its turns). */
|
|
334
|
+
isInsideBot: (botPid) => isAncestorProcess(botPid),
|
|
335
|
+
/** Ask the bot to finish its running turns and exit 0 (the drain signal). */
|
|
336
|
+
signalDrain: (botPid) => {
|
|
337
|
+
try {
|
|
338
|
+
process.kill(botPid, "SIGUSR2");
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
function selfStopProbe(botPid) {
|
|
347
|
+
try {
|
|
348
|
+
return botStopSeams.isInsideBot(botPid);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function requestSelfStopDrain(botPid) {
|
|
355
|
+
return botStopSeams.signalDrain(botPid);
|
|
356
|
+
}
|
|
321
357
|
async function stopBot(name, dir, config, output) {
|
|
322
358
|
if (hasPromotionHold(dir))
|
|
323
359
|
throw new Error("This bot is held for cloud promotion. Continue its promotion before changing local settings or lifecycle.");
|
|
@@ -326,6 +362,25 @@ async function stopBot(name, dir, config, output) {
|
|
|
326
362
|
const before = readBotStatus(dir);
|
|
327
363
|
const wasRunning = !!before && isPidAlive(before.pid);
|
|
328
364
|
const supervised = daemonSupervises(hqDaemon, `bots:${name}`);
|
|
365
|
+
// Stopping itself from inside one of its own turns: a bootout or a kill would
|
|
366
|
+
// take down this command and the turn with it, so the reply is lost and the
|
|
367
|
+
// job is left unloaded. Ask the bot to drain instead — finish the running
|
|
368
|
+
// turn (reply and ack included), then exit 0, which launchd's
|
|
369
|
+
// KeepAlive{SuccessfulExit:false} leaves down. `hq bot start` brings it back.
|
|
370
|
+
if (!supervised && wasRunning && selfStopProbe(before.pid)) {
|
|
371
|
+
const drained = requestSelfStopDrain(before.pid);
|
|
372
|
+
if (output === "silent")
|
|
373
|
+
return;
|
|
374
|
+
const how = drained ? "stops after this turn finishes" : "could not signal the bot";
|
|
375
|
+
if (output === "json") {
|
|
376
|
+
console.log(JSON.stringify({ ok: drained, name, signalled: drained, stopped: false, how, self: true }, null, 2));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
console.log(drained
|
|
380
|
+
? chalk.green(`${name} will stop once this turn finishes`) + chalk.dim(` (stopping itself; start again with: hq bot start ${name})`)
|
|
381
|
+
: chalk.yellow(`${name} could not be asked to stop (pid ${before.pid})`));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
329
384
|
// Keep the daemon's copy down either way, so it does not start one as soon as this bot is gone.
|
|
330
385
|
if (supervised)
|
|
331
386
|
askDaemonToStop(hqDaemon, `bots:${name}`);
|
|
@@ -732,6 +787,8 @@ async function runBotResident(nameArg) {
|
|
|
732
787
|
console.log(orphanBotRunMessage(name, outcome));
|
|
733
788
|
return;
|
|
734
789
|
}
|
|
790
|
+
if (healNodePathFromJob(name, dir, config))
|
|
791
|
+
return;
|
|
735
792
|
const token = botTokenSupplier(dir);
|
|
736
793
|
const api = new BotApi({ token });
|
|
737
794
|
const owner = new OwnerContextCache(() => lookupOwnerContext(config.ownerUid), Date.now, undefined, config.ownerUid);
|
|
@@ -756,6 +813,30 @@ async function runBotResident(nameArg) {
|
|
|
756
813
|
await handle.done;
|
|
757
814
|
void name;
|
|
758
815
|
}
|
|
816
|
+
/**
|
|
817
|
+
* When this run IS the bot's launchd job and its plist names node by a
|
|
818
|
+
* versioned path (/opt/homebrew/Cellar/node/<v>/bin/node, removed by the next
|
|
819
|
+
* `brew upgrade node`), rewrite it to the stable alias and reload the job from
|
|
820
|
+
* a detached shell. Returns true when the caller should exit now (0, so
|
|
821
|
+
* KeepAlive leaves it alone) and let the reloaded job take over.
|
|
822
|
+
*/
|
|
823
|
+
function healNodePathFromJob(name, dir, config) {
|
|
824
|
+
// Only the job's own main process (launchd, pid 1, is its parent): a copy
|
|
825
|
+
// spawned by the bot inherits XPC_SERVICE_NAME but is not the job.
|
|
826
|
+
if (process.platform !== "darwin" || process.env.XPC_SERVICE_NAME !== launchdLabel(name) || process.ppid !== 1)
|
|
827
|
+
return false;
|
|
828
|
+
try {
|
|
829
|
+
const healed = healBotDaemonNodeFromJob(buildBotDaemonPaths({ name, botDir: dir, hqRoot: config.hqRoot }), daemonDeps());
|
|
830
|
+
if (!healed)
|
|
831
|
+
return false;
|
|
832
|
+
createBotLogger(dir)("info", `startup agent ran node from ${healed.from}, which the next node upgrade removes; rewrote it to ${healed.to} and reloading`);
|
|
833
|
+
return true;
|
|
834
|
+
}
|
|
835
|
+
catch (err) {
|
|
836
|
+
createBotLogger(dir)("warn", `could not rewrite the startup agent's node path: ${err instanceof Error ? err.message : String(err)}`);
|
|
837
|
+
return false;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
759
840
|
// ── list / status / logs ─────────────────────────────────────────────────────
|
|
760
841
|
async function runBotList(opts) {
|
|
761
842
|
if (opts.remote) {
|
|
@@ -773,7 +854,7 @@ async function runBotList(opts) {
|
|
|
773
854
|
return;
|
|
774
855
|
}
|
|
775
856
|
for (const r of rows) {
|
|
776
|
-
console.log(`${chalk.bold(r.name.padEnd(16))} ${r.agentUid} ${r.runtime.padEnd(6)} ${humanOnline(r).padEnd(8)} ${r.processAlive ? chalk.green(`pid ${r.pid}`) : chalk.gray(r.state)}${chalk.dim(` ${r.kind === "company" ? `company bot: ${r.companies.join(", ") || "no company"}` : "personal"}`)}${r.daemonInstalled ? chalk.dim(" launchd") : ""}${r.workerId ? chalk.dim(` worker ${r.workerId}${r.companySlug ? ` (${r.companySlug})` : ""}`) : ""}`);
|
|
857
|
+
console.log(`${chalk.bold(r.name.padEnd(16))} ${r.agentUid} ${r.runtime.padEnd(6)} ${humanOnline(r).padEnd(8)} ${r.processAlive ? chalk.green(`pid ${r.pid}`) : chalk.gray(r.state)}${chalk.dim(` ${r.kind === "company" ? `company bot: ${r.companies.join(", ") || "no company"}` : "personal"}`)}${r.daemonInstalled ? chalk.dim(" launchd") : ""}${r.daemonNodeMissing ? chalk.red(` node missing (hq bot start ${r.name})`) : ""}${r.workerId ? chalk.dim(` worker ${r.workerId}${r.companySlug ? ` (${r.companySlug})` : ""}`) : ""}`);
|
|
777
858
|
}
|
|
778
859
|
}
|
|
779
860
|
async function runBotStatus(nameArg, opts) {
|
|
@@ -802,6 +883,9 @@ async function runBotStatus(nameArg, opts) {
|
|
|
802
883
|
}
|
|
803
884
|
console.log(` answering: ${row.busy ? chalk.green(`yes${row.busySince ? `, since ${row.busySince}` : ""}`) : chalk.gray("no")}`);
|
|
804
885
|
console.log(` launchd: ${row.daemonInstalled ? (row.daemonLoaded ? "installed, loaded" : "installed, not loaded") : "not installed"}`);
|
|
886
|
+
if (row.daemonNodeMissing) {
|
|
887
|
+
console.log(` ${chalk.red(`node missing: ${row.daemonNodeMissing}`)} — launchd cannot start ${name}; fix with: hq bot start ${name}`);
|
|
888
|
+
}
|
|
805
889
|
console.log(` creds: ${creds ? `present for ${creds.entityUid}` : chalk.red("missing")}`);
|
|
806
890
|
if (status) {
|
|
807
891
|
console.log(` started: ${status.startedAt} restarts: ${status.restarts} replies: ${status.repliesSent}`);
|
|
@@ -1712,6 +1796,12 @@ export function registerBotCommand(program) {
|
|
|
1712
1796
|
.option("--json", "Print machine-readable JSON")
|
|
1713
1797
|
.action((name, opts) => wrap(async () => {
|
|
1714
1798
|
const b = requireBot(name);
|
|
1799
|
+
const running = readBotStatus(b.dir);
|
|
1800
|
+
if (running && isPidAlive(running.pid) && selfStopProbe(running.pid)) {
|
|
1801
|
+
// The stop half would end this turn (and this command) before the
|
|
1802
|
+
// start half ran, leaving the bot down. Say so instead.
|
|
1803
|
+
throw new Error(`${b.name} cannot restart itself from inside one of its own turns: the stop would end this turn before the start ran. Run \`hq bot restart ${b.name}\` from outside the bot, or \`hq bot stop ${b.name}\` to stop it after this turn.`);
|
|
1804
|
+
}
|
|
1715
1805
|
await stopBot(b.name, b.dir, b.config, "silent");
|
|
1716
1806
|
await startBot(b.name, b.dir, b.config, opts.json ? "json" : "text");
|
|
1717
1807
|
})());
|
package/dist/lib/bot/daemon.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* `launchctl` is injected so tests never touch the real service manager.
|
|
11
11
|
*/
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
|
+
import { type NodeBinaryIo } from "../node-binary.js";
|
|
13
14
|
export type BotDaemonPlatform = "darwin" | "linux" | "other";
|
|
14
15
|
export interface BotDaemonPaths {
|
|
15
16
|
home: string;
|
|
@@ -54,6 +55,9 @@ export interface BotDaemonResult {
|
|
|
54
55
|
installed: boolean;
|
|
55
56
|
loaded: boolean;
|
|
56
57
|
dest?: string;
|
|
58
|
+
/** Set when the installed unit's node path is missing or versioned. */
|
|
59
|
+
nodeBinary?: string;
|
|
60
|
+
nodeProblem?: "missing" | "versioned";
|
|
57
61
|
message: string;
|
|
58
62
|
}
|
|
59
63
|
export interface BotDaemonDeps {
|
|
@@ -87,11 +91,74 @@ export declare function installedDaemonHqBinary(p: BotDaemonPaths, io?: {
|
|
|
87
91
|
existsSync(path: string): boolean;
|
|
88
92
|
readFileSync(path: string, encoding: "utf8"): string;
|
|
89
93
|
}): string | null;
|
|
94
|
+
/** The node binary baked into an installed unit, or null if none. */
|
|
95
|
+
export declare function installedDaemonNodeBinary(p: BotDaemonPaths, io?: {
|
|
96
|
+
existsSync(path: string): boolean;
|
|
97
|
+
readFileSync(path: string, encoding: "utf8"): string;
|
|
98
|
+
}): string | null;
|
|
99
|
+
/**
|
|
100
|
+
* What is wrong with the node path baked into the bot's installed unit:
|
|
101
|
+
* "missing" (launchd can no longer start the bot — a `brew upgrade node`
|
|
102
|
+
* removed the versioned Cellar directory), "versioned" (it still exists, but
|
|
103
|
+
* the next upgrade removes it; a stable alias for the same binary exists), or
|
|
104
|
+
* null when the unit is fine or not installed.
|
|
105
|
+
*/
|
|
106
|
+
export declare function botDaemonNodeProblem(p: BotDaemonPaths, deps?: {
|
|
107
|
+
fsImpl?: Pick<typeof fs, "existsSync">;
|
|
108
|
+
readFile?: (file: string) => string;
|
|
109
|
+
nodeIo?: NodeBinaryIo;
|
|
110
|
+
}): {
|
|
111
|
+
nodeBinary: string;
|
|
112
|
+
problem: "missing" | "versioned";
|
|
113
|
+
replacement: string | null;
|
|
114
|
+
} | null;
|
|
90
115
|
/**
|
|
91
116
|
* Rewrite the unit when its hq path is missing or is the pre-move
|
|
92
|
-
* toolchain/node/bin/hq while toolchain/npm-global/bin/hq exists
|
|
117
|
+
* toolchain/node/bin/hq while toolchain/npm-global/bin/hq exists, or when its
|
|
118
|
+
* node path is missing or a versioned directory an upgrade will remove.
|
|
119
|
+
*
|
|
120
|
+
* A node-only repair keeps the hq script the unit already runs, so repairing
|
|
121
|
+
* from a worktree or a one-off build never repoints the bot at that build.
|
|
122
|
+
*/
|
|
123
|
+
export declare function repairBotDaemonIfStale(p: BotDaemonPaths, deps?: BotDaemonDeps & {
|
|
124
|
+
readFile?: (file: string) => string;
|
|
125
|
+
nodeIo?: NodeBinaryIo;
|
|
126
|
+
}): BotDaemonResult | null;
|
|
127
|
+
/**
|
|
128
|
+
* The in-job half of the node-path self-heal, run by `hq bot run` when it is
|
|
129
|
+
* itself the launchd job. A `bootout` from inside the job kills the caller,
|
|
130
|
+
* so this only rewrites the plist and hands the reload to a detached shell in
|
|
131
|
+
* its own session; the caller then exits 0, which KeepAlive{SuccessfulExit:
|
|
132
|
+
* false} leaves alone, and the shell boots the old definition out and
|
|
133
|
+
* bootstraps the rewritten one (RunAtLoad starts the bot again). Runs before
|
|
134
|
+
* the bot takes any message, so no turn is cut short.
|
|
135
|
+
*
|
|
136
|
+
* Returns null (and changes nothing) unless the unit's node path is a
|
|
137
|
+
* versioned directory with a stable alias for the same binary.
|
|
138
|
+
*/
|
|
139
|
+
export declare function healBotDaemonNodeFromJob(p: BotDaemonPaths, deps?: BotDaemonDeps & {
|
|
140
|
+
readFile?: (file: string) => string;
|
|
141
|
+
nodeIo?: NodeBinaryIo;
|
|
142
|
+
}): {
|
|
143
|
+
from: string;
|
|
144
|
+
to: string;
|
|
145
|
+
} | null;
|
|
146
|
+
/** `program = …` of the loaded job (`launchctl print`), or null when not loaded or unknown. */
|
|
147
|
+
export declare function loadedLaunchdProgram(p: BotDaemonPaths, deps: Pick<BotDaemonDeps, "launchctl" | "uid">): string | null;
|
|
148
|
+
/**
|
|
149
|
+
* Rewrite the plist's node path to the stable alias WITHOUT touching launchd:
|
|
150
|
+
* no bootout, no bootstrap, so a running bot's turn is never cut short and a
|
|
151
|
+
* stopped bot is not started. The new path applies at the next login, the next
|
|
152
|
+
* `hq bot start`, or the bot's next start (which reloads its own job). This is
|
|
153
|
+
* what `hq doctor --fix` runs, including unattended at session start.
|
|
93
154
|
*/
|
|
94
|
-
export declare function
|
|
155
|
+
export declare function rewriteBotDaemonNodePath(p: BotDaemonPaths, deps?: Pick<BotDaemonDeps, "fsImpl" | "platform"> & {
|
|
156
|
+
readFile?: (file: string) => string;
|
|
157
|
+
nodeIo?: NodeBinaryIo;
|
|
158
|
+
}): {
|
|
159
|
+
from: string;
|
|
160
|
+
to: string;
|
|
161
|
+
} | null;
|
|
95
162
|
export declare function installBotDaemon(p: BotDaemonPaths, deps?: BotDaemonDeps): BotDaemonResult;
|
|
96
163
|
export declare function uninstallBotDaemon(p: BotDaemonPaths, deps?: BotDaemonDeps): BotDaemonResult;
|
|
97
164
|
export declare function botDaemonStatus(p: BotDaemonPaths, deps?: BotDaemonDeps): BotDaemonResult;
|
package/dist/lib/bot/daemon.js
CHANGED
|
@@ -15,6 +15,7 @@ import * as path from "node:path";
|
|
|
15
15
|
import { spawn } from "node:child_process";
|
|
16
16
|
import { resolveHqBinary, resolveNodeBinary, preferStableHqBinary } from "../mesh/live/daemon/install.js";
|
|
17
17
|
import { botLogPath, launchdLabel } from "./paths.js";
|
|
18
|
+
import { nodeBinaryProblem, preferStableNodeBinary } from "../node-binary.js";
|
|
18
19
|
export function detectBotPlatform(platform = process.platform) {
|
|
19
20
|
if (platform === "darwin")
|
|
20
21
|
return "darwin";
|
|
@@ -211,17 +212,65 @@ export function installedDaemonHqBinary(p, io = fs) {
|
|
|
211
212
|
}
|
|
212
213
|
return null;
|
|
213
214
|
}
|
|
215
|
+
/** The node binary baked into an installed unit, or null if none. */
|
|
216
|
+
export function installedDaemonNodeBinary(p, io = fs) {
|
|
217
|
+
if (io.existsSync(p.plistPath)) {
|
|
218
|
+
const args = launchdPlistProgramArguments(io.readFileSync(p.plistPath, "utf8"));
|
|
219
|
+
return args[0] ?? null;
|
|
220
|
+
}
|
|
221
|
+
if (io.existsSync(p.systemdUnitPath)) {
|
|
222
|
+
const unit = io.readFileSync(p.systemdUnitPath, "utf8");
|
|
223
|
+
const m = unit.match(/^ExecStart=(\S+)/m);
|
|
224
|
+
return m?.[1] ?? null;
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* What is wrong with the node path baked into the bot's installed unit:
|
|
230
|
+
* "missing" (launchd can no longer start the bot — a `brew upgrade node`
|
|
231
|
+
* removed the versioned Cellar directory), "versioned" (it still exists, but
|
|
232
|
+
* the next upgrade removes it; a stable alias for the same binary exists), or
|
|
233
|
+
* null when the unit is fine or not installed.
|
|
234
|
+
*/
|
|
235
|
+
export function botDaemonNodeProblem(p, deps = {}) {
|
|
236
|
+
const io = deps.fsImpl ?? fs;
|
|
237
|
+
let node;
|
|
238
|
+
try {
|
|
239
|
+
node = installedDaemonNodeBinary(p, {
|
|
240
|
+
existsSync: (q) => io.existsSync(q),
|
|
241
|
+
readFileSync: (q) => (deps.readFile ?? ((f) => fs.readFileSync(f, "utf8")))(q),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
if (!node)
|
|
248
|
+
return null;
|
|
249
|
+
const nodeOpts = deps.nodeIo ? { io: deps.nodeIo } : {};
|
|
250
|
+
const problem = nodeBinaryProblem(node, nodeOpts);
|
|
251
|
+
if (!problem)
|
|
252
|
+
return null;
|
|
253
|
+
// Prefer the stable alias of the node the unit already ran (same install),
|
|
254
|
+
// so a repair run from another node (an nvm shell) does not switch runtimes.
|
|
255
|
+
const stable = preferStableNodeBinary(node, nodeOpts);
|
|
256
|
+
return { nodeBinary: node, problem, replacement: stable !== node ? stable : null };
|
|
257
|
+
}
|
|
214
258
|
/**
|
|
215
259
|
* Rewrite the unit when its hq path is missing or is the pre-move
|
|
216
|
-
* toolchain/node/bin/hq while toolchain/npm-global/bin/hq exists
|
|
260
|
+
* toolchain/node/bin/hq while toolchain/npm-global/bin/hq exists, or when its
|
|
261
|
+
* node path is missing or a versioned directory an upgrade will remove.
|
|
262
|
+
*
|
|
263
|
+
* A node-only repair keeps the hq script the unit already runs, so repairing
|
|
264
|
+
* from a worktree or a one-off build never repoints the bot at that build.
|
|
217
265
|
*/
|
|
218
266
|
export function repairBotDaemonIfStale(p, deps = {}) {
|
|
219
267
|
const io = deps.fsImpl ?? fs;
|
|
268
|
+
const readFile = deps.readFile ?? ((f) => fs.readFileSync(f, "utf8"));
|
|
220
269
|
let current;
|
|
221
270
|
try {
|
|
222
271
|
current = installedDaemonHqBinary(p, {
|
|
223
272
|
existsSync: (q) => io.existsSync(q),
|
|
224
|
-
readFileSync: (q) =>
|
|
273
|
+
readFileSync: (q) => readFile(q),
|
|
225
274
|
});
|
|
226
275
|
}
|
|
227
276
|
catch {
|
|
@@ -229,11 +278,107 @@ export function repairBotDaemonIfStale(p, deps = {}) {
|
|
|
229
278
|
}
|
|
230
279
|
if (!current)
|
|
231
280
|
return null;
|
|
232
|
-
const
|
|
233
|
-
const
|
|
234
|
-
|
|
281
|
+
const hqStale = !io.existsSync(current) || preferStableHqBinary(current) !== current;
|
|
282
|
+
const found = botDaemonNodeProblem(p, { fsImpl: io, readFile, ...(deps.nodeIo ? { nodeIo: deps.nodeIo } : {}) });
|
|
283
|
+
// Rewriting only helps when it changes the node path.
|
|
284
|
+
const nodeTarget = found ? (found.replacement ?? p.nodeBinary) : p.nodeBinary;
|
|
285
|
+
const node = found && found.nodeBinary !== nodeTarget ? found : null;
|
|
286
|
+
// A plist already rewritten on disk (`hq doctor --fix`) whose loaded job still
|
|
287
|
+
// runs the versioned or missing node needs a reload all the same.
|
|
288
|
+
const loaded = loadedLaunchdProgram(p, deps);
|
|
289
|
+
const loadedStale = !!loaded && loaded !== nodeTarget && nodeBinaryProblem(loaded, deps.nodeIo ? { io: deps.nodeIo } : {}) !== null;
|
|
290
|
+
if (!hqStale && !node && !loadedStale)
|
|
291
|
+
return null;
|
|
292
|
+
return installBotDaemon({ ...p, nodeBinary: nodeTarget, ...(hqStale ? {} : { hqBinary: current }) }, deps);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* The in-job half of the node-path self-heal, run by `hq bot run` when it is
|
|
296
|
+
* itself the launchd job. A `bootout` from inside the job kills the caller,
|
|
297
|
+
* so this only rewrites the plist and hands the reload to a detached shell in
|
|
298
|
+
* its own session; the caller then exits 0, which KeepAlive{SuccessfulExit:
|
|
299
|
+
* false} leaves alone, and the shell boots the old definition out and
|
|
300
|
+
* bootstraps the rewritten one (RunAtLoad starts the bot again). Runs before
|
|
301
|
+
* the bot takes any message, so no turn is cut short.
|
|
302
|
+
*
|
|
303
|
+
* Returns null (and changes nothing) unless the unit's node path is a
|
|
304
|
+
* versioned directory with a stable alias for the same binary.
|
|
305
|
+
*/
|
|
306
|
+
export function healBotDaemonNodeFromJob(p, deps = {}) {
|
|
307
|
+
const platform = deps.platform ?? detectBotPlatform();
|
|
308
|
+
if (platform !== "darwin")
|
|
309
|
+
return null;
|
|
310
|
+
const io = deps.fsImpl ?? fs;
|
|
311
|
+
const readFile = deps.readFile ?? ((f) => fs.readFileSync(f, "utf8"));
|
|
312
|
+
const nodeOpts = deps.nodeIo ? { io: deps.nodeIo } : {};
|
|
313
|
+
const file = botDaemonNodeProblem(p, { fsImpl: io, readFile, ...(deps.nodeIo ? { nodeIo: deps.nodeIo } : {}) });
|
|
314
|
+
const fileTarget = file?.replacement ?? null;
|
|
315
|
+
const fileNeedsRewrite = !!file && file.problem === "versioned" && !!fileTarget;
|
|
316
|
+
// The plist may already be stable (rewritten by `hq doctor --fix`) while the
|
|
317
|
+
// loaded definition — what launchd execs on the next respawn — is not.
|
|
318
|
+
const loaded = loadedLaunchdProgram(p, deps);
|
|
319
|
+
const loadedTarget = loaded ? preferStableNodeBinary(loaded, nodeOpts) : null;
|
|
320
|
+
const loadedStale = !!loaded && !!loadedTarget && loadedTarget !== loaded && nodeBinaryProblem(loaded, nodeOpts) === "versioned";
|
|
321
|
+
if (!fileNeedsRewrite && !loadedStale)
|
|
322
|
+
return null;
|
|
323
|
+
if (fileNeedsRewrite) {
|
|
324
|
+
let hq;
|
|
325
|
+
try {
|
|
326
|
+
hq = installedDaemonHqBinary(p, { existsSync: (q) => io.existsSync(q), readFileSync: (q) => readFile(q) });
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
// Rewrite only the file here (no launchctl: a bootout would kill us).
|
|
332
|
+
installBotDaemon({ ...p, nodeBinary: fileTarget, hqBinary: hq ?? p.hqBinary }, { ...deps, platform, launchctl: undefined });
|
|
333
|
+
}
|
|
334
|
+
const domain = launchdDomain(deps.uid);
|
|
335
|
+
const target = `${domain}/${p.label}`;
|
|
336
|
+
const script = [
|
|
337
|
+
"sleep 1",
|
|
338
|
+
`launchctl bootout ${shQuote(target)} >/dev/null 2>&1`,
|
|
339
|
+
`i=0; while launchctl print ${shQuote(target)} >/dev/null 2>&1 && [ $i -lt 40 ]; do sleep 0.25; i=$((i+1)); done`,
|
|
340
|
+
`for d in 0 1 2 4; do sleep $d; launchctl bootstrap ${shQuote(domain)} ${shQuote(p.plistPath)} >/dev/null 2>&1 && exit 0; done`,
|
|
341
|
+
"exit 1",
|
|
342
|
+
].join("; ");
|
|
343
|
+
(deps.spawnDetached ?? defaultSpawnDetached)("/bin/sh", ["-c", script]);
|
|
344
|
+
return fileNeedsRewrite ? { from: file.nodeBinary, to: fileTarget } : { from: loaded, to: loadedTarget };
|
|
345
|
+
}
|
|
346
|
+
/** `program = …` of the loaded job (`launchctl print`), or null when not loaded or unknown. */
|
|
347
|
+
export function loadedLaunchdProgram(p, deps) {
|
|
348
|
+
if (!deps.launchctl)
|
|
349
|
+
return null;
|
|
350
|
+
const r = deps.launchctl(["print", `${launchdDomain(deps.uid)}/${p.label}`]);
|
|
351
|
+
if (!r.ok)
|
|
235
352
|
return null;
|
|
236
|
-
|
|
353
|
+
const m = /^\s*program = (.+?)\s*$/m.exec(r.stdout);
|
|
354
|
+
return m?.[1] ?? null;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Rewrite the plist's node path to the stable alias WITHOUT touching launchd:
|
|
358
|
+
* no bootout, no bootstrap, so a running bot's turn is never cut short and a
|
|
359
|
+
* stopped bot is not started. The new path applies at the next login, the next
|
|
360
|
+
* `hq bot start`, or the bot's next start (which reloads its own job). This is
|
|
361
|
+
* what `hq doctor --fix` runs, including unattended at session start.
|
|
362
|
+
*/
|
|
363
|
+
export function rewriteBotDaemonNodePath(p, deps = {}) {
|
|
364
|
+
const platform = deps.platform ?? detectBotPlatform();
|
|
365
|
+
if (platform !== "darwin")
|
|
366
|
+
return null;
|
|
367
|
+
const io = deps.fsImpl ?? fs;
|
|
368
|
+
const readFile = deps.readFile ?? ((f) => fs.readFileSync(f, "utf8"));
|
|
369
|
+
const file = botDaemonNodeProblem(p, { fsImpl: io, readFile, ...(deps.nodeIo ? { nodeIo: deps.nodeIo } : {}) });
|
|
370
|
+
const to = file ? (file.replacement ?? p.nodeBinary) : null;
|
|
371
|
+
if (!file || !to || file.nodeBinary === to)
|
|
372
|
+
return null;
|
|
373
|
+
let hq;
|
|
374
|
+
try {
|
|
375
|
+
hq = installedDaemonHqBinary(p, { existsSync: (q) => io.existsSync(q), readFileSync: (q) => readFile(q) });
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
installBotDaemon({ ...p, nodeBinary: to, hqBinary: hq ?? p.hqBinary }, { fsImpl: deps.fsImpl, platform });
|
|
381
|
+
return { from: file.nodeBinary, to };
|
|
237
382
|
}
|
|
238
383
|
export function installBotDaemon(p, deps = {}) {
|
|
239
384
|
const platform = deps.platform ?? detectBotPlatform();
|
|
@@ -341,14 +486,19 @@ export function botDaemonStatus(p, deps = {}) {
|
|
|
341
486
|
if (platform === "darwin") {
|
|
342
487
|
const installed = io.existsSync(p.plistPath);
|
|
343
488
|
const loaded = installed && launchdLoaded(p, deps);
|
|
489
|
+
const node = installed ? botDaemonNodeProblem(p, { fsImpl: io }) : null;
|
|
490
|
+
const nodeNote = node?.problem === "missing"
|
|
491
|
+
? `; node binary missing (${node.nodeBinary}) — launchd cannot start it; run: hq bot start ${p.name}`
|
|
492
|
+
: "";
|
|
344
493
|
return {
|
|
345
494
|
platform,
|
|
346
495
|
action: "status",
|
|
347
496
|
installed,
|
|
348
497
|
loaded,
|
|
349
498
|
dest: p.plistPath,
|
|
499
|
+
...(node ? { nodeBinary: node.nodeBinary, nodeProblem: node.problem } : {}),
|
|
350
500
|
message: installed
|
|
351
|
-
? `LaunchAgent ${p.label}: ${loaded ? "loaded" : "installed (not loaded)"}`
|
|
501
|
+
? `LaunchAgent ${p.label}: ${loaded ? "loaded" : "installed (not loaded)"}${nodeNote}`
|
|
352
502
|
: `LaunchAgent ${p.label}: not installed`,
|
|
353
503
|
};
|
|
354
504
|
}
|
package/dist/lib/bot/run.js
CHANGED
|
@@ -1565,7 +1565,7 @@ export async function runBot(deps) {
|
|
|
1565
1565
|
for (const turn of currentTurns)
|
|
1566
1566
|
turn.abort();
|
|
1567
1567
|
await Promise.allSettled([heartbeatLoop, inboxLoop]);
|
|
1568
|
-
await finish("stopped", 0, mode === "drain" ? "
|
|
1568
|
+
await finish("stopped", 0, mode === "drain" ? "drain completed (running turns finished)" : "stop requested");
|
|
1569
1569
|
};
|
|
1570
1570
|
const onSignal = () => {
|
|
1571
1571
|
void stop();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is this `hq` command running inside a bot's own turn?
|
|
3
|
+
*
|
|
4
|
+
* A bot's model turn is a child of the bot process, and the tools it runs are
|
|
5
|
+
* children of that. When the turn runs `hq bot stop <itself>`, the stop used
|
|
6
|
+
* to `launchctl bootout` the bot's own job. launchd then kills the whole job —
|
|
7
|
+
* the bot, the model turn, and the very `hq bot stop` asking — so the reply
|
|
8
|
+
* the turn was writing is lost (exit 137) and the job is left unloaded.
|
|
9
|
+
* Seen on 2026-09-24: george stopped itself mid-turn to break a bot-to-bot
|
|
10
|
+
* loop, and nothing was posted after the stop.
|
|
11
|
+
*
|
|
12
|
+
* The caller walks its own parent chain; `parentOf` is injectable for tests.
|
|
13
|
+
*/
|
|
14
|
+
export type ParentPidLookup = (pid: number) => number | null;
|
|
15
|
+
/** Parent pid via `ps` (macOS and Linux), or null when it cannot be read. */
|
|
16
|
+
export declare const defaultParentOf: ParentPidLookup;
|
|
17
|
+
/** True when `ancestorPid` is `startPid` itself or one of its ancestors. */
|
|
18
|
+
export declare function isAncestorProcess(ancestorPid: number, startPid?: number, parentOf?: ParentPidLookup, maxDepth?: number): boolean;
|
|
19
|
+
//# sourceMappingURL=self-process.d.ts.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is this `hq` command running inside a bot's own turn?
|
|
3
|
+
*
|
|
4
|
+
* A bot's model turn is a child of the bot process, and the tools it runs are
|
|
5
|
+
* children of that. When the turn runs `hq bot stop <itself>`, the stop used
|
|
6
|
+
* to `launchctl bootout` the bot's own job. launchd then kills the whole job —
|
|
7
|
+
* the bot, the model turn, and the very `hq bot stop` asking — so the reply
|
|
8
|
+
* the turn was writing is lost (exit 137) and the job is left unloaded.
|
|
9
|
+
* Seen on 2026-09-24: george stopped itself mid-turn to break a bot-to-bot
|
|
10
|
+
* loop, and nothing was posted after the stop.
|
|
11
|
+
*
|
|
12
|
+
* The caller walks its own parent chain; `parentOf` is injectable for tests.
|
|
13
|
+
*/
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
/** Parent pid via `ps` (macOS and Linux), or null when it cannot be read. */
|
|
16
|
+
export const defaultParentOf = (pid) => {
|
|
17
|
+
const r = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" });
|
|
18
|
+
if (r.status !== 0 || typeof r.stdout !== "string")
|
|
19
|
+
return null;
|
|
20
|
+
const n = Number.parseInt(r.stdout.trim(), 10);
|
|
21
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
22
|
+
};
|
|
23
|
+
/** True when `ancestorPid` is `startPid` itself or one of its ancestors. */
|
|
24
|
+
export function isAncestorProcess(ancestorPid, startPid = process.pid, parentOf = defaultParentOf, maxDepth = 64) {
|
|
25
|
+
if (!Number.isInteger(ancestorPid) || ancestorPid <= 1)
|
|
26
|
+
return false;
|
|
27
|
+
let pid = startPid;
|
|
28
|
+
for (let depth = 0; pid !== null && pid > 1 && depth < maxDepth; depth++) {
|
|
29
|
+
if (pid === ancestorPid)
|
|
30
|
+
return true;
|
|
31
|
+
pid = parentOf(pid);
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=self-process.js.map
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { type NodeBinaryIo } from "../../node-binary.js";
|
|
1
2
|
import type { CheckContext, CheckFamily, CheckResult } from "../types.js";
|
|
2
3
|
export declare const BOTS_FAMILY_ID = "bots";
|
|
3
4
|
export declare const BOTS_FAMILY_TITLE = "Local bots";
|
|
4
5
|
export interface BotsDoctorDeps {
|
|
5
6
|
home: () => string;
|
|
6
7
|
root: (home?: string) => string;
|
|
8
|
+
/** Filesystem view used to judge the startup agent's node path (tests). */
|
|
9
|
+
nodeIo?: NodeBinaryIo;
|
|
7
10
|
}
|
|
8
11
|
export declare const botsFamily: CheckFamily;
|
|
9
12
|
export declare function checkBots(_context: CheckContext, deps?: BotsDoctorDeps): CheckResult[];
|
|
@@ -9,6 +9,7 @@ import * as path from "node:path";
|
|
|
9
9
|
import { botsRoot, botDir, launchdLabel } from "../../bot/paths.js";
|
|
10
10
|
import { readBotConfig } from "../../bot/config.js";
|
|
11
11
|
import { isPidAlive, readBotStatus } from "../../bot/status.js";
|
|
12
|
+
import { nodeBinaryProblem } from "../../node-binary.js";
|
|
12
13
|
export const BOTS_FAMILY_ID = "bots";
|
|
13
14
|
export const BOTS_FAMILY_TITLE = "Local bots";
|
|
14
15
|
const DEFAULT_DEPS = {
|
|
@@ -34,12 +35,14 @@ export function checkBots(_context, deps = DEFAULT_DEPS) {
|
|
|
34
35
|
];
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
|
-
function
|
|
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.
|
|
@@ -22,6 +22,8 @@ export interface ProcessPathReader {
|
|
|
22
22
|
readdirSync(path: string): string[];
|
|
23
23
|
readlinkSync(path: string): string;
|
|
24
24
|
uid(path: string): number;
|
|
25
|
+
/** Test seam for read-only process identity checks used by the cwd exception. */
|
|
26
|
+
readFileSync?(path: string): string;
|
|
25
27
|
}
|
|
26
28
|
export interface PackageStorePruneResult {
|
|
27
29
|
attempted: boolean;
|
|
@@ -521,7 +521,71 @@ const defaultProcessPathReader = {
|
|
|
521
521
|
readdirSync: (entryPath) => fs.readdirSync(entryPath),
|
|
522
522
|
readlinkSync: (entryPath) => fs.readlinkSync(entryPath),
|
|
523
523
|
uid: (entryPath) => fs.statSync(entryPath).uid,
|
|
524
|
+
readFileSync: (entryPath) => fs.readFileSync(entryPath, "utf8"),
|
|
524
525
|
};
|
|
526
|
+
function readProcFile(reader, procRoot, pid, name) {
|
|
527
|
+
if (!reader.readFileSync)
|
|
528
|
+
return null;
|
|
529
|
+
try {
|
|
530
|
+
return reader.readFileSync(path.join(procRoot, pid, name));
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
// Missing or unreadable identity metadata disables the exception.
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
function procStatusIdentity(contents) {
|
|
538
|
+
if (contents === null)
|
|
539
|
+
return null;
|
|
540
|
+
const value = (name) => {
|
|
541
|
+
const prefix = `${name}:`;
|
|
542
|
+
const line = contents.split(/\r?\n/).find((entry) => entry.startsWith(prefix));
|
|
543
|
+
return line?.slice(prefix.length).trim() || null;
|
|
544
|
+
};
|
|
545
|
+
const numeric = (name) => {
|
|
546
|
+
const raw = value(name);
|
|
547
|
+
if (!raw)
|
|
548
|
+
return null;
|
|
549
|
+
const parsed = Number(raw.split(/\s+/)[0]);
|
|
550
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
551
|
+
};
|
|
552
|
+
const name = value("Name");
|
|
553
|
+
const pid = numeric("Pid");
|
|
554
|
+
const parentPid = numeric("PPid");
|
|
555
|
+
const uid = numeric("Uid");
|
|
556
|
+
if (name === null || pid === null || parentPid === null || uid === null)
|
|
557
|
+
return null;
|
|
558
|
+
return { name, pid, parentPid, uid };
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* `sd-pam` is systemd's non-dumpable PAM-session helper. Its cwd link can return
|
|
562
|
+
* EACCES to its own UID, so require the exact proc identity and user-manager
|
|
563
|
+
* ancestry before treating that one cwd as harmless.
|
|
564
|
+
*/
|
|
565
|
+
function isSystemdUserSdPamProcess(procRoot, pid, currentUid, ownerUid, reader) {
|
|
566
|
+
if (currentUid === null || ownerUid !== currentUid)
|
|
567
|
+
return false;
|
|
568
|
+
const helper = procStatusIdentity(readProcFile(reader, procRoot, pid, "status"));
|
|
569
|
+
if (!helper || helper.name !== "(sd-pam)" || helper.pid !== Number(pid) || helper.uid !== currentUid ||
|
|
570
|
+
readProcFile(reader, procRoot, pid, "comm")?.trim() !== "(sd-pam)")
|
|
571
|
+
return false;
|
|
572
|
+
const managerPid = String(helper.parentPid);
|
|
573
|
+
const manager = procStatusIdentity(readProcFile(reader, procRoot, managerPid, "status"));
|
|
574
|
+
if (!manager || manager.name !== "systemd" || manager.pid !== helper.parentPid || manager.parentPid !== 1 ||
|
|
575
|
+
manager.uid !== currentUid || readProcFile(reader, procRoot, managerPid, "comm")?.trim() !== "systemd")
|
|
576
|
+
return false;
|
|
577
|
+
const init = procStatusIdentity(readProcFile(reader, procRoot, "1", "status"));
|
|
578
|
+
if (!init || init.name !== "systemd" || init.pid !== 1 || init.parentPid !== 0 || init.uid !== 0 ||
|
|
579
|
+
readProcFile(reader, procRoot, "1", "comm")?.trim() !== "systemd")
|
|
580
|
+
return false;
|
|
581
|
+
try {
|
|
582
|
+
return reader.uid(path.join(procRoot, managerPid)) === currentUid && reader.uid(path.join(procRoot, "1")) === 0;
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
// A vanished or inaccessible parent does not prove the helper identity.
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
525
589
|
function scanProcessPaths(procRoot, childName, currentUid = currentProcessUserId(), reader = defaultProcessPathReader) {
|
|
526
590
|
const paths = [];
|
|
527
591
|
let entries;
|
|
@@ -547,7 +611,7 @@ function scanProcessPaths(procRoot, childName, currentUid = currentProcessUserId
|
|
|
547
611
|
continue;
|
|
548
612
|
}
|
|
549
613
|
const base = path.join(processDirectory, childName);
|
|
550
|
-
//
|
|
614
|
+
// Foreign UID access stays ignorable; same-UID EACCES is excepted only for a verified sd-pam cwd below.
|
|
551
615
|
let descriptors;
|
|
552
616
|
if (childName === "fd") {
|
|
553
617
|
try {
|
|
@@ -573,8 +637,10 @@ function scanProcessPaths(procRoot, childName, currentUid = currentProcessUserId
|
|
|
573
637
|
}
|
|
574
638
|
catch (err) {
|
|
575
639
|
const code = err.code;
|
|
576
|
-
|
|
577
|
-
|
|
640
|
+
const inaccessibleForeign = code === "EACCES" && currentUid !== null && ownerUid !== currentUid;
|
|
641
|
+
const knownSystemdCwd = code === "EACCES" && childName === "cwd" &&
|
|
642
|
+
isSystemdUserSdPamProcess(procRoot, pid, currentUid, ownerUid, reader);
|
|
643
|
+
if (code !== "ENOENT" && code !== "ESRCH" && !inaccessibleForeign && !knownSystemdCwd)
|
|
578
644
|
complete = false;
|
|
579
645
|
}
|
|
580
646
|
}
|
|
@@ -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;
|
|
@@ -24,6 +24,7 @@ import { retireLegacyListen } from "./legacy-listen.js";
|
|
|
24
24
|
import { readDaemonState } from "./state.js";
|
|
25
25
|
import { activateLaunchd, launchdPlistProgramArguments, launchdProgramArgumentsDiffer, parseLaunchctlPrintArguments, } from "../../../service-manager/launchd.js";
|
|
26
26
|
import { activateSystemd } from "../../../service-manager/systemd.js";
|
|
27
|
+
import { nodeBinaryProblem, preferStableNodeBinary } from "../../../node-binary.js";
|
|
27
28
|
/**
|
|
28
29
|
* `schtasks /End` terminates the task's root process only. The daemon is
|
|
29
30
|
* launched through `conhost.exe --headless`, so ending the task kills conhost
|
|
@@ -113,7 +114,7 @@ function migrateLegacyDaemonUnit(opts) {
|
|
|
113
114
|
env: opts.env,
|
|
114
115
|
hqRoot: verified,
|
|
115
116
|
hqBinary: "/usr/bin/hq",
|
|
116
|
-
nodeBinary: process.execPath
|
|
117
|
+
nodeBinary: process.execPath ? resolveNodeBinary(process.execPath, opts.env ?? process.env) : "/usr/bin/node",
|
|
117
118
|
});
|
|
118
119
|
try {
|
|
119
120
|
paths.hqBinary = resolveHqBinary(process.argv, opts.env ?? process.env);
|
|
@@ -264,18 +265,25 @@ export function resolveRunningCliEntry(argv = process.argv) {
|
|
|
264
265
|
throw new Error(`Cannot resolve hq binary: ${entry} is not a real path`);
|
|
265
266
|
}
|
|
266
267
|
}
|
|
267
|
-
/**
|
|
268
|
+
/**
|
|
269
|
+
* Absolute node binary for a service unit: the realpath of process.execPath,
|
|
270
|
+
* then its upgrade-stable alias (/opt/homebrew/bin/node rather than
|
|
271
|
+
* /opt/homebrew/Cellar/node/<version>/bin/node) when one resolves to the same
|
|
272
|
+
* binary. No PATH lookup of `node` by name.
|
|
273
|
+
*/
|
|
268
274
|
export function resolveAbsoluteNodeBinary(execPath = process.execPath) {
|
|
269
275
|
if (!execPath)
|
|
270
276
|
throw new Error("Cannot resolve node binary: execPath is empty");
|
|
277
|
+
let real;
|
|
271
278
|
try {
|
|
272
|
-
|
|
279
|
+
real = fs.realpathSync(execPath);
|
|
273
280
|
}
|
|
274
281
|
catch {
|
|
275
282
|
if (path.isAbsolute(execPath))
|
|
276
283
|
return execPath;
|
|
277
284
|
throw new Error(`Cannot resolve node binary: ${execPath} is not a real path`);
|
|
278
285
|
}
|
|
286
|
+
return preferStableNodeBinary(real);
|
|
279
287
|
}
|
|
280
288
|
/** Resolve the hq binary the current shell uses (argv[1] or PATH). */
|
|
281
289
|
export function resolveHqBinary(argv = process.argv, env = process.env) {
|
|
@@ -312,7 +320,14 @@ export function isNewerCliVersion(current, recorded) {
|
|
|
312
320
|
*/
|
|
313
321
|
export function installDaemonIfStale(opts) {
|
|
314
322
|
const recorded = readDaemonState(opts.paths.daemonDir)?.cliVersion;
|
|
315
|
-
|
|
323
|
+
const platformForUnit = opts.platform ?? detectPlatform();
|
|
324
|
+
const installedNode = installedDaemonNodeBinary(opts.paths, platformForUnit, opts.readUnit);
|
|
325
|
+
// Only a rewrite that changes the node path helps: a unit whose node is gone
|
|
326
|
+
// or versioned, while this install resolves a different (stable) one.
|
|
327
|
+
const nodeProblem = installedNode && installedNode !== opts.paths.nodeBinary
|
|
328
|
+
? (opts.nodeProblem ?? nodeBinaryProblem)(installedNode)
|
|
329
|
+
: null;
|
|
330
|
+
if (!isNewerCliVersion(opts.currentVersion, recorded) && !nodeProblem) {
|
|
316
331
|
return {
|
|
317
332
|
reinstalled: false,
|
|
318
333
|
message: `daemon already records hq ${recorded}; not reinstalling`,
|
|
@@ -350,9 +365,42 @@ export function installDaemonIfStale(opts) {
|
|
|
350
365
|
message: `${installed.message} Restarted with ${opts.paths.nodeBinary} ${opts.paths.hqBinary}.`,
|
|
351
366
|
};
|
|
352
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* The node binary baked into the installed daemon unit (darwin plist or linux
|
|
370
|
+
* user unit), or null when there is no unit or it cannot be read. Used to
|
|
371
|
+
* reinstall a unit whose node was a versioned path an upgrade removed.
|
|
372
|
+
*/
|
|
373
|
+
export function installedDaemonNodeBinary(paths, platform, readUnit = (file) => {
|
|
374
|
+
try {
|
|
375
|
+
return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}) {
|
|
381
|
+
if (platform === "darwin") {
|
|
382
|
+
const text = readUnit(paths.plistPath);
|
|
383
|
+
if (!text)
|
|
384
|
+
return null;
|
|
385
|
+
const block = text.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
|
|
386
|
+
const first = block?.[1]?.match(/<string>([^<]*)<\/string>/);
|
|
387
|
+
return first?.[1] ?? null;
|
|
388
|
+
}
|
|
389
|
+
if (platform === "linux") {
|
|
390
|
+
const text = readUnit(paths.systemdUnitPath);
|
|
391
|
+
if (!text)
|
|
392
|
+
return null;
|
|
393
|
+
const m = text.match(/^ExecStart=('(?:[^']|'\\'')*'|\S+)/m);
|
|
394
|
+
if (!m)
|
|
395
|
+
return null;
|
|
396
|
+
const raw = m[1];
|
|
397
|
+
return raw.startsWith("'") ? raw.slice(1, -1).replace(/'\\''/g, "'") : raw;
|
|
398
|
+
}
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
353
401
|
export function resolveNodeBinary(execPath = process.execPath, env = process.env) {
|
|
354
402
|
if (execPath && fs.existsSync(execPath))
|
|
355
|
-
return execPath;
|
|
403
|
+
return preferStableNodeBinary(execPath, { env });
|
|
356
404
|
const pathEnv = env.PATH ?? "";
|
|
357
405
|
for (const dir of pathEnv.split(path.delimiter)) {
|
|
358
406
|
const candidate = path.join(dir, "node");
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A node path that survives a node upgrade, for service units (LaunchAgents,
|
|
3
|
+
* systemd user units) that name node by absolute path.
|
|
4
|
+
*
|
|
5
|
+
* `process.execPath` is the resolved binary, e.g.
|
|
6
|
+
* /opt/homebrew/Cellar/node/26.0.0/bin/node. `brew upgrade node` deletes that
|
|
7
|
+
* directory, and every unit that baked it in stops launching (launchd cannot
|
|
8
|
+
* exec a missing program, so KeepAlive never brings the job back).
|
|
9
|
+
*
|
|
10
|
+
* The fix is to write the stable name the package manager keeps pointing at
|
|
11
|
+
* the current version (/opt/homebrew/bin/node, /opt/homebrew/opt/node/bin/node)
|
|
12
|
+
* instead of the versioned directory it points at today. A candidate is only
|
|
13
|
+
* used when it resolves to the SAME binary this process runs, so the unit
|
|
14
|
+
* keeps running the exact node it was installed with; a machine where no such
|
|
15
|
+
* alias exists (nvm without a `current` link, a hand-built node) keeps the
|
|
16
|
+
* execPath, as before.
|
|
17
|
+
*/
|
|
18
|
+
export interface NodeBinaryIo {
|
|
19
|
+
existsSync(p: string): boolean;
|
|
20
|
+
realpathSync(p: string): string;
|
|
21
|
+
}
|
|
22
|
+
/** True when `p` is a node binary inside a version-specific directory. */
|
|
23
|
+
export declare function isVersionedNodePath(p: string): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Stable names that may point at `execPath`, most specific first:
|
|
26
|
+
* the Homebrew prefix's bin/ and opt/<formula>/ links, a `current` link under
|
|
27
|
+
* nvm, then the usual global bins and every PATH entry.
|
|
28
|
+
*/
|
|
29
|
+
export declare function stableNodeCandidates(execPath: string, env?: NodeJS.ProcessEnv, home?: string): string[];
|
|
30
|
+
/**
|
|
31
|
+
* The stable name for `execPath` when it sits in a versioned directory and an
|
|
32
|
+
* alias resolving to the same binary exists; otherwise `execPath` unchanged.
|
|
33
|
+
*/
|
|
34
|
+
export declare function preferStableNodeBinary(execPath: string, opts?: {
|
|
35
|
+
env?: NodeJS.ProcessEnv;
|
|
36
|
+
home?: string;
|
|
37
|
+
io?: NodeBinaryIo;
|
|
38
|
+
}): string;
|
|
39
|
+
/**
|
|
40
|
+
* Why a unit's node path needs rewriting, or null when it is fine:
|
|
41
|
+
* "missing" — the binary is gone (launchd can no longer start the job);
|
|
42
|
+
* "versioned" — it still exists but the next upgrade will remove it, and a
|
|
43
|
+
* stable alias for the same binary exists.
|
|
44
|
+
*/
|
|
45
|
+
export declare function nodeBinaryProblem(nodePath: string, opts?: {
|
|
46
|
+
env?: NodeJS.ProcessEnv;
|
|
47
|
+
home?: string;
|
|
48
|
+
io?: NodeBinaryIo;
|
|
49
|
+
}): "missing" | "versioned" | null;
|
|
50
|
+
//# sourceMappingURL=node-binary.d.ts.map
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A node path that survives a node upgrade, for service units (LaunchAgents,
|
|
3
|
+
* systemd user units) that name node by absolute path.
|
|
4
|
+
*
|
|
5
|
+
* `process.execPath` is the resolved binary, e.g.
|
|
6
|
+
* /opt/homebrew/Cellar/node/26.0.0/bin/node. `brew upgrade node` deletes that
|
|
7
|
+
* directory, and every unit that baked it in stops launching (launchd cannot
|
|
8
|
+
* exec a missing program, so KeepAlive never brings the job back).
|
|
9
|
+
*
|
|
10
|
+
* The fix is to write the stable name the package manager keeps pointing at
|
|
11
|
+
* the current version (/opt/homebrew/bin/node, /opt/homebrew/opt/node/bin/node)
|
|
12
|
+
* instead of the versioned directory it points at today. A candidate is only
|
|
13
|
+
* used when it resolves to the SAME binary this process runs, so the unit
|
|
14
|
+
* keeps running the exact node it was installed with; a machine where no such
|
|
15
|
+
* alias exists (nvm without a `current` link, a hand-built node) keeps the
|
|
16
|
+
* execPath, as before.
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as os from "node:os";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
const defaultIo = {
|
|
22
|
+
existsSync: (p) => fs.existsSync(p),
|
|
23
|
+
realpathSync: (p) => fs.realpathSync(p),
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Install layouts whose node lives in a per-version directory that an upgrade
|
|
27
|
+
* or version switch removes or abandons.
|
|
28
|
+
*/
|
|
29
|
+
const VERSIONED_NODE_PATTERNS = [
|
|
30
|
+
// Homebrew: <prefix>/Cellar/node/26.0.0/bin/node, <prefix>/Cellar/node@22/22.9.0/bin/node
|
|
31
|
+
/\/Cellar\/node(?:@[^/]+)?\/[^/]+\/bin\/node$/,
|
|
32
|
+
// nvm
|
|
33
|
+
/\/\.nvm\/versions\/node\/[^/]+\/bin\/node$/,
|
|
34
|
+
// Volta
|
|
35
|
+
/\/\.volta\/tools\/image\/node\/[^/]+\/bin\/node$/,
|
|
36
|
+
// fnm (installed versions and the per-shell multishell links)
|
|
37
|
+
/\/fnm\/node-versions\/[^/]+\/installation\/bin\/node$/,
|
|
38
|
+
/\/fnm_multishells\/[^/]+\/bin\/node$/,
|
|
39
|
+
// asdf / mise
|
|
40
|
+
/\/\.asdf\/installs\/nodejs\/[^/]+\/bin\/node$/,
|
|
41
|
+
/\/mise\/installs\/node\/[^/]+\/bin\/node$/,
|
|
42
|
+
// n
|
|
43
|
+
/\/n\/versions\/node\/[^/]+\/bin\/node$/,
|
|
44
|
+
];
|
|
45
|
+
/** True when `p` is a node binary inside a version-specific directory. */
|
|
46
|
+
export function isVersionedNodePath(p) {
|
|
47
|
+
const normalized = p.split(path.sep).join("/");
|
|
48
|
+
return VERSIONED_NODE_PATTERNS.some((re) => re.test(normalized));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Stable names that may point at `execPath`, most specific first:
|
|
52
|
+
* the Homebrew prefix's bin/ and opt/<formula>/ links, a `current` link under
|
|
53
|
+
* nvm, then the usual global bins and every PATH entry.
|
|
54
|
+
*/
|
|
55
|
+
export function stableNodeCandidates(execPath, env = process.env, home = os.homedir()) {
|
|
56
|
+
const normalized = execPath.split(path.sep).join("/");
|
|
57
|
+
const out = [];
|
|
58
|
+
const brew = /^(.*)\/Cellar\/(node(?:@[^/]+)?)\/[^/]+\/bin\/node$/.exec(normalized);
|
|
59
|
+
if (brew) {
|
|
60
|
+
const prefix = brew[1];
|
|
61
|
+
out.push(`${prefix}/bin/node`, `${prefix}/opt/${brew[2]}/bin/node`);
|
|
62
|
+
}
|
|
63
|
+
const nvmDir = env.NVM_DIR?.trim() || path.join(home, ".nvm");
|
|
64
|
+
if (/\/\.nvm\/versions\/node\//.test(normalized))
|
|
65
|
+
out.push(path.join(nvmDir, "current", "bin", "node"));
|
|
66
|
+
out.push("/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node");
|
|
67
|
+
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
|
|
68
|
+
if (dir && path.isAbsolute(dir))
|
|
69
|
+
out.push(path.join(dir, "node"));
|
|
70
|
+
}
|
|
71
|
+
return [...new Set(out)];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The stable name for `execPath` when it sits in a versioned directory and an
|
|
75
|
+
* alias resolving to the same binary exists; otherwise `execPath` unchanged.
|
|
76
|
+
*/
|
|
77
|
+
export function preferStableNodeBinary(execPath, opts = {}) {
|
|
78
|
+
if (!execPath || !isVersionedNodePath(execPath))
|
|
79
|
+
return execPath;
|
|
80
|
+
const io = opts.io ?? defaultIo;
|
|
81
|
+
let target;
|
|
82
|
+
try {
|
|
83
|
+
target = io.realpathSync(execPath);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// A versioned node that is already gone (a stale unit being repaired):
|
|
87
|
+
// there is no binary to match, so any working stable node is the answer.
|
|
88
|
+
return firstWorkingStableNode(execPath, opts, io) ?? execPath;
|
|
89
|
+
}
|
|
90
|
+
for (const candidate of stableNodeCandidates(execPath, opts.env ?? process.env, opts.home ?? os.homedir())) {
|
|
91
|
+
if (isVersionedNodePath(candidate))
|
|
92
|
+
continue;
|
|
93
|
+
try {
|
|
94
|
+
if (!io.existsSync(candidate))
|
|
95
|
+
continue;
|
|
96
|
+
if (io.realpathSync(candidate) === target)
|
|
97
|
+
return candidate;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
/* dangling link — not a candidate */
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return execPath;
|
|
104
|
+
}
|
|
105
|
+
function firstWorkingStableNode(execPath, opts, io) {
|
|
106
|
+
for (const candidate of stableNodeCandidates(execPath, opts.env ?? process.env, opts.home ?? os.homedir())) {
|
|
107
|
+
if (isVersionedNodePath(candidate))
|
|
108
|
+
continue;
|
|
109
|
+
try {
|
|
110
|
+
if (io.existsSync(candidate) && io.existsSync(io.realpathSync(candidate)))
|
|
111
|
+
return candidate;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
/* dangling link */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Why a unit's node path needs rewriting, or null when it is fine:
|
|
121
|
+
* "missing" — the binary is gone (launchd can no longer start the job);
|
|
122
|
+
* "versioned" — it still exists but the next upgrade will remove it, and a
|
|
123
|
+
* stable alias for the same binary exists.
|
|
124
|
+
*/
|
|
125
|
+
export function nodeBinaryProblem(nodePath, opts = {}) {
|
|
126
|
+
const io = opts.io ?? defaultIo;
|
|
127
|
+
if (!nodePath)
|
|
128
|
+
return null;
|
|
129
|
+
if (!io.existsSync(nodePath))
|
|
130
|
+
return "missing";
|
|
131
|
+
return preferStableNodeBinary(nodePath, opts) !== nodePath ? "versioned" : null;
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=node-binary.js.map
|