@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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.173.0] — 2026-09-24
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `hq bot stop <name>` run from inside that bot's own turn now lets the turn finish: it signals the bot to drain (finish the running turn, post the reply, exit 0), which launchd leaves stopped. Before, it booted out the bot's own launchd job, which killed the bot, the turn, and the stop command itself (exit 137), so no reply was posted. `hq bot restart <name>` from inside the bot's own turn is refused with instructions, and `hq bot start` from inside the bot skips the agent reload.
|
|
10
|
+
- Bot LaunchAgents and systemd units, the Work Mesh daemon unit, the `hq daemon` unit, and the lanes and agent-kit units now name node by its upgrade-stable path (`/opt/homebrew/bin/node` or `/opt/homebrew/opt/<formula>/bin/node`, or nvm's `current` link) instead of the versioned `/opt/homebrew/Cellar/node/<version>/bin/node`. Before, `brew upgrade node` deleted that path and launchd could no longer start the bots. The stable path is used only when it resolves to the same node binary; otherwise the unit keeps the resolved path as before.
|
|
11
|
+
- Installed bot LaunchAgents that still name a versioned or missing node are repaired: `hq bot start` rewrites and reloads the agent, a bot started by launchd rewrites its own agent and reloads it from a detached shell before it takes any message, and `hq doctor` reports it (`bots.<name>.daemon-node`), with `hq doctor --fix` rewriting only the plist file and not reloading launchd. `hq bot list` and `hq bot status` flag a missing node binary. `hq mesh daemon install --if-stale` also reinstalls a same-version unit whose node path is missing or versioned.
|
|
12
|
+
|
|
13
|
+
## [5.172.0] — 2026-09-24
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- The `hq daemon` updater keeps hq-cloud current as well. Every 6 hours it
|
|
18
|
+
installs the newest hq-cloud with the same major version into
|
|
19
|
+
`~/.hq/daemon/hq-cloud`, and it restarts sync and the outpost heartbeat
|
|
20
|
+
right after an update. The daemon runs sync, and the heartbeat when that
|
|
21
|
+
hq-cloud has `hq-outpost-heartbeat`, from whichever copy is newer: this one
|
|
22
|
+
or the one bundled with the CLI.
|
|
23
|
+
- `hq daemon` sync pushes local changes as they happen (`--event-push`), the
|
|
24
|
+
same as outpost sync does today.
|
|
25
|
+
|
|
5
26
|
## [5.171.0] — 2026-09-24
|
|
6
27
|
|
|
7
28
|
### Fixed
|
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/commands/daemon.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import * as fs from "node:fs";
|
|
17
17
|
import * as os from "node:os";
|
|
18
18
|
import * as path from "node:path";
|
|
19
|
-
import { DEFAULT_SERVICES, SERVICE_DESCRIPTIONS, enabledServices } from "../lib/daemon/catalog.js";
|
|
19
|
+
import { DEFAULT_SERVICES, SERVICE_DESCRIPTIONS, enabledServices, hqCloudUnits } from "../lib/daemon/catalog.js";
|
|
20
20
|
import { applyServiceToggle, readDaemonConfig, writeDaemonConfig } from "../lib/daemon/config.js";
|
|
21
21
|
import { daemonUnitInstalled } from "../lib/daemon/handoff.js";
|
|
22
22
|
import { adoptLegacyUnits, readAdoptedUnits, restoreLegacyUnits } from "../lib/daemon/migrate.js";
|
|
@@ -418,19 +418,23 @@ async function runMigrate(d, opts) {
|
|
|
418
418
|
d.out("Nothing was changed. `hq daemon migrate --apply` takes them over.");
|
|
419
419
|
}
|
|
420
420
|
async function runServiceSelfUpdate(d) {
|
|
421
|
-
const [{ daemonSelfUpdate }, { hostToolUpdater, runDaemonUpdater, updaterEnv }, { TOOL_NAMES }, { cliSelfUpdatable }] = await Promise.all([
|
|
421
|
+
const [{ daemonSelfUpdate }, { hostToolUpdater, runDaemonUpdater, updaterEnv }, { TOOL_NAMES }, { cliSelfUpdatable }, { updateManagedHqCloud },] = await Promise.all([
|
|
422
422
|
import("../utils/self-update.js"),
|
|
423
423
|
import("../lib/daemon/updater.js"),
|
|
424
424
|
import("../lib/daemon/tools.js"),
|
|
425
425
|
import("../lib/daemon/run.js"),
|
|
426
|
+
import("../lib/daemon/hq-cloud.js"),
|
|
426
427
|
]);
|
|
427
|
-
const { paths } = currentHost(d);
|
|
428
|
+
const { paths, detected } = currentHost(d);
|
|
428
429
|
const env = updaterEnv(d.env);
|
|
429
430
|
const result = await runDaemonUpdater({
|
|
430
431
|
// Same guard as every self-update: never replace a dev checkout or a project's copy with a global install.
|
|
431
432
|
updateCli: cliSelfUpdatable() ? () => daemonSelfUpdate({ env }) : null,
|
|
432
433
|
tools: TOOL_NAMES.map((name) => hostToolUpdater(name, env, d.home)),
|
|
434
|
+
updateHqCloud: async () => updateManagedHqCloud(paths.root, env),
|
|
435
|
+
hqCloudUnits: hqCloudUnits(detected.type),
|
|
433
436
|
requestDaemonRestart: () => writeControlRequest(paths.requestsDir, { action: "restart-daemon" }),
|
|
437
|
+
requestRestart: (unit) => writeControlRequest(paths.requestsDir, { action: "restart", unit }),
|
|
434
438
|
log: d.out,
|
|
435
439
|
});
|
|
436
440
|
if (!result.ok)
|
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
|