@indigoai-us/hq-cli 5.167.0 → 5.169.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 +30 -0
- package/dist/command-catalog.generated.d.ts +88 -1
- package/dist/command-catalog.generated.js +112 -1
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +79 -22
- package/dist/commands/lanes.js +146 -31
- package/dist/commands/mesh.js +13 -19
- package/dist/commands/outpost-pruner.d.ts +3 -0
- package/dist/commands/outpost-pruner.js +125 -0
- package/dist/lib/daemon/catalog.d.ts +11 -1
- package/dist/lib/daemon/catalog.js +31 -6
- package/dist/lib/daemon/install.d.ts +7 -0
- package/dist/lib/daemon/install.js +18 -2
- package/dist/lib/daemon/legacy.js +6 -1
- package/dist/lib/daemon/machine-type.d.ts +4 -1
- package/dist/lib/daemon/machine-type.js +29 -9
- package/dist/lib/daemon/migrate.js +2 -2
- package/dist/lib/daemon/remote-control.d.ts +32 -9
- package/dist/lib/daemon/remote-control.js +55 -11
- package/dist/lib/daemon/run.d.ts +6 -0
- package/dist/lib/daemon/run.js +43 -2
- package/dist/lib/daemon/tools.d.ts +52 -0
- package/dist/lib/daemon/tools.js +95 -0
- package/dist/lib/daemon/updater.d.ts +23 -15
- package/dist/lib/daemon/updater.js +97 -60
- package/dist/lib/lanes/mesh-events.d.ts +4 -0
- package/dist/lib/lanes/mesh-events.js +72 -4
- package/dist/lib/lanes/outpost-pruner-install.d.ts +37 -0
- package/dist/lib/lanes/outpost-pruner-install.js +226 -0
- package/dist/lib/lanes/outpost-worktree-pruner-flag.d.ts +19 -0
- package/dist/lib/lanes/outpost-worktree-pruner-flag.js +76 -0
- package/dist/lib/lanes/outpost-worktree-pruner.d.ts +60 -0
- package/dist/lib/lanes/outpost-worktree-pruner.js +1135 -0
- package/dist/lib/mesh/live/session-events-client.js +3 -2
- package/dist/lib/mesh/project-board.d.ts +7 -0
- package/dist/lib/mesh/project-board.js +29 -1
- package/package.json +1 -1
|
@@ -21,10 +21,22 @@ import { activateLaunchd, deactivateLaunchd, launchdPlistPath, renderLaunchdPlis
|
|
|
21
21
|
import { activateSystemd, deactivateSystemd, renderSystemdUserUnit, systemdUnitPath, } from "../service-manager/systemd.js";
|
|
22
22
|
import { escapeXml, shellQuote } from "../service-manager/types.js";
|
|
23
23
|
import { readPidLock } from "../mesh/live/daemon/pid-lock.js";
|
|
24
|
+
import { userToolDirs } from "./tools.js";
|
|
24
25
|
export const DAEMON_LAUNCHD_LABEL = "ai.getindigo.hq-daemon";
|
|
25
26
|
export const DAEMON_SYSTEMD_UNIT = "hq-daemon.service";
|
|
26
27
|
export const DAEMON_WINDOWS_TASK = "ai.getindigo.hq-daemon";
|
|
27
28
|
export const SYSTEM_UNIT_DIR = "/etc/systemd/system";
|
|
29
|
+
export const ROOT_REFUSAL = "hq daemon runs its services as the machine's login user, never as root.";
|
|
30
|
+
/**
|
|
31
|
+
* Why the daemon must not run here as root, or null when it may. Agent kits
|
|
32
|
+
* are the one exception: they run in containers where root is often the only
|
|
33
|
+
* account.
|
|
34
|
+
*/
|
|
35
|
+
export function daemonRootRefusal(isRoot, machineType) {
|
|
36
|
+
if (!isRoot || machineType === "agent-kit")
|
|
37
|
+
return null;
|
|
38
|
+
return `${ROOT_REFUSAL} Run it as that user instead of root.`;
|
|
39
|
+
}
|
|
28
40
|
/** Boxes run the daemon as a system unit; everything else per user. */
|
|
29
41
|
export function installScopeFor(machineType) {
|
|
30
42
|
return machineType === "outpost" || machineType === "agent-box" ? "system" : "user";
|
|
@@ -55,7 +67,9 @@ function renderSystemdSystemUnit(host, user, userHome) {
|
|
|
55
67
|
const env = {
|
|
56
68
|
...host.env,
|
|
57
69
|
HOME: userHome,
|
|
58
|
-
|
|
70
|
+
// The login user's own tools come first, so the daemon runs (and can
|
|
71
|
+
// update) their Claude, Codex and Grok rather than root's copies in /usr.
|
|
72
|
+
PATH: [...userToolDirs(userHome), host.env?.PATH ?? "/usr/local/bin:/usr/bin:/bin"].join(":"),
|
|
59
73
|
};
|
|
60
74
|
const envLines = Object.entries(env)
|
|
61
75
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
@@ -163,8 +177,10 @@ export function planDaemonUnit(host, options) {
|
|
|
163
177
|
if (host.platform === "linux") {
|
|
164
178
|
if (options.scope === "system") {
|
|
165
179
|
if (!options.runAsUser) {
|
|
166
|
-
throw new Error("A system install needs the box's login user
|
|
180
|
+
throw new Error("A system install needs the box's login user: pass --user <name>, or provisioning writes it to /etc/hq/machine-type (user=<name>).");
|
|
167
181
|
}
|
|
182
|
+
if (options.runAsUser === "root")
|
|
183
|
+
throw new Error(ROOT_REFUSAL);
|
|
168
184
|
if (!options.runAsHome)
|
|
169
185
|
throw new Error(`Could not find the home directory of ${options.runAsUser}.`);
|
|
170
186
|
return {
|
|
@@ -19,9 +19,14 @@ const SYSTEM_UNITS = [
|
|
|
19
19
|
["hq-agent-inbox.service", "inbox"],
|
|
20
20
|
["hq-agent-qmd-index.timer", "search-index"],
|
|
21
21
|
["outpost-jobs-reconcile.timer", "jobs-reconcile"],
|
|
22
|
+
// The runners' watchdogs restart the old runners, so they are taken over with them.
|
|
22
23
|
["outpost-runner.service", "remote-control"],
|
|
23
|
-
["outpost-
|
|
24
|
+
["outpost-runner-watchdog.timer", "remote-control"],
|
|
25
|
+
["outpost-rc-watchdog.timer", "remote-control"],
|
|
26
|
+
["outpost-codex-runner.service", "codex-remote-control"],
|
|
27
|
+
["outpost-codex-watchdog.timer", "codex-remote-control"],
|
|
24
28
|
["hq-cli-update.timer", "updater"],
|
|
29
|
+
["outpost-codex-update.timer", "updater"],
|
|
25
30
|
];
|
|
26
31
|
const KIND_LABEL = {
|
|
27
32
|
launchd: "LaunchAgent",
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* 1. `--machine-type` flag
|
|
7
7
|
* 2. the type saved in ~/.hq/daemon/config.json by an earlier install
|
|
8
8
|
* 3. /etc/hq/machine-type, written by box provisioning (may name a login user)
|
|
9
|
+
* 3b. an outpost runner unit (outposts provisioned before the marker)
|
|
9
10
|
* 4. agent-kit machine credentials present → agent-kit
|
|
10
11
|
* 5. running in a container → agent-kit
|
|
11
12
|
* 6. macOS or Windows → desktop
|
|
@@ -14,7 +15,9 @@
|
|
|
14
15
|
export declare const MACHINE_TYPES: readonly ["desktop", "self-hosted", "outpost", "agent-box", "agent-kit"];
|
|
15
16
|
export type MachineType = (typeof MACHINE_TYPES)[number];
|
|
16
17
|
export declare const MACHINE_TYPE_MARKER_PATH = "/etc/hq/machine-type";
|
|
17
|
-
export type MachineTypeSource = "flag" | "config" | "marker" | "agent-kit-creds" | "container" | "platform";
|
|
18
|
+
export type MachineTypeSource = "flag" | "config" | "marker" | "outpost-units" | "agent-kit-creds" | "container" | "platform";
|
|
19
|
+
/** The Claude runner unit outpost provisioning installs; its `User=` is the box's login user. */
|
|
20
|
+
export declare const OUTPOST_RUNNER_UNIT = "/etc/systemd/system/outpost-runner.service";
|
|
18
21
|
export interface MachineTypeResult {
|
|
19
22
|
type: MachineType;
|
|
20
23
|
source: MachineTypeSource;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* 1. `--machine-type` flag
|
|
7
7
|
* 2. the type saved in ~/.hq/daemon/config.json by an earlier install
|
|
8
8
|
* 3. /etc/hq/machine-type, written by box provisioning (may name a login user)
|
|
9
|
+
* 3b. an outpost runner unit (outposts provisioned before the marker)
|
|
9
10
|
* 4. agent-kit machine credentials present → agent-kit
|
|
10
11
|
* 5. running in a container → agent-kit
|
|
11
12
|
* 6. macOS or Windows → desktop
|
|
@@ -15,6 +16,8 @@ import * as path from "node:path";
|
|
|
15
16
|
import { AGENT_DIR_ENV, MACHINE_CREDS_NAME, agentRootDir } from "../agent-kit/paths.js";
|
|
16
17
|
export const MACHINE_TYPES = ["desktop", "self-hosted", "outpost", "agent-box", "agent-kit"];
|
|
17
18
|
export const MACHINE_TYPE_MARKER_PATH = "/etc/hq/machine-type";
|
|
19
|
+
/** The Claude runner unit outpost provisioning installs; its `User=` is the box's login user. */
|
|
20
|
+
export const OUTPOST_RUNNER_UNIT = "/etc/systemd/system/outpost-runner.service";
|
|
18
21
|
export function isMachineType(value) {
|
|
19
22
|
return typeof value === "string" && MACHINE_TYPES.includes(value);
|
|
20
23
|
}
|
|
@@ -46,7 +49,8 @@ export function parseMachineTypeMarker(text) {
|
|
|
46
49
|
return null;
|
|
47
50
|
if (user !== undefined && !ACCOUNT_NAME.test(user))
|
|
48
51
|
return null;
|
|
49
|
-
|
|
52
|
+
// The daemon never runs as root, so root is never taken as the login user.
|
|
53
|
+
return user && user !== "root" ? { type, user } : { type };
|
|
50
54
|
}
|
|
51
55
|
function platformName(platform) {
|
|
52
56
|
if (platform === "darwin")
|
|
@@ -72,12 +76,29 @@ function readMarker(deps, warnings) {
|
|
|
72
76
|
return null;
|
|
73
77
|
}
|
|
74
78
|
}
|
|
75
|
-
/**
|
|
79
|
+
/** The login user the outpost runner unit runs as, or undefined (never root). */
|
|
80
|
+
function outpostRunnerUser(deps) {
|
|
81
|
+
if (!deps.existsSync(OUTPOST_RUNNER_UNIT))
|
|
82
|
+
return undefined;
|
|
83
|
+
let text;
|
|
84
|
+
try {
|
|
85
|
+
text = deps.readFileSync(OUTPOST_RUNNER_UNIT);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const user = /^\s*User\s*=\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
91
|
+
return user && user !== "root" && ACCOUNT_NAME.test(user) ? user : undefined;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Box types run as the login user the provisioning marker names, or else the
|
|
95
|
+
* user the outpost runner unit runs as, however the type itself was chosen.
|
|
96
|
+
*/
|
|
76
97
|
function withMarkerUser(result, deps) {
|
|
77
98
|
if (result.type !== "outpost" && result.type !== "agent-box")
|
|
78
99
|
return result;
|
|
79
100
|
const warnings = [];
|
|
80
|
-
const user = readMarker(deps, warnings)?.user;
|
|
101
|
+
const user = readMarker(deps, warnings)?.user ?? outpostRunnerUser(deps);
|
|
81
102
|
return withWarnings(user ? { ...result, runAsUser: user } : result, warnings);
|
|
82
103
|
}
|
|
83
104
|
/**
|
|
@@ -106,12 +127,11 @@ export function detectMachineType(flag, deps) {
|
|
|
106
127
|
const warnings = [];
|
|
107
128
|
const marker = readMarker(deps, warnings);
|
|
108
129
|
if (marker) {
|
|
109
|
-
return {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
};
|
|
130
|
+
return withMarkerUser({ type: marker.type, source: "marker", detail: `from ${MACHINE_TYPE_MARKER_PATH}` }, deps);
|
|
131
|
+
}
|
|
132
|
+
// Outposts provisioned before the marker existed still have their runner unit.
|
|
133
|
+
if (deps.platform === "linux" && deps.existsSync(OUTPOST_RUNNER_UNIT)) {
|
|
134
|
+
return withWarnings(withMarkerUser({ type: "outpost", source: "outpost-units", detail: `outpost runner at ${OUTPOST_RUNNER_UNIT}` }, deps), warnings);
|
|
115
135
|
}
|
|
116
136
|
const kitCreds = hostAgentCredsPath(deps.home, deps.env);
|
|
117
137
|
if (deps.existsSync(kitCreds)) {
|
|
@@ -165,8 +165,8 @@ function moveBack(unit, to) {
|
|
|
165
165
|
return;
|
|
166
166
|
moveFile(unit.movedTo, to);
|
|
167
167
|
}
|
|
168
|
-
const SYSTEM_UNIT_NAME = /^[A-Za-z0-9@._-]+\.service$/;
|
|
169
|
-
/** A system record entry may only put a .service file back into the system unit folder, from the root-owned store. */
|
|
168
|
+
const SYSTEM_UNIT_NAME = /^[A-Za-z0-9@._-]+\.(service|timer)$/;
|
|
169
|
+
/** A system record entry may only put a .service or .timer file back into the system unit folder, from the root-owned store. */
|
|
170
170
|
function checkSystemEntry(unit, deps) {
|
|
171
171
|
const expectedPath = path.join(deps.systemUnitDir, unit.name);
|
|
172
172
|
const expectedSaved = path.join(deps.systemAdoptedDir, savedName(unit));
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The remote-control service hosts (`hq daemon service remote-control` and
|
|
3
|
+
* `codex-remote-control`).
|
|
3
4
|
*
|
|
4
|
-
* Like the outpost
|
|
5
|
-
* session so
|
|
6
|
-
* watches it; the daemon supervises the
|
|
5
|
+
* Like the outpost runners, the command (`claude remote-control`, or the box's
|
|
6
|
+
* Codex runner) runs inside a detached tmux session so it has a terminal. This
|
|
7
|
+
* host process starts the session and watches it; the daemon supervises the
|
|
8
|
+
* host:
|
|
7
9
|
*
|
|
8
10
|
* - the session ends -> the host exits 1 and the daemon restarts it with
|
|
9
11
|
* backoff (replacing the runner's `while true` loop and its watchdogs);
|
|
10
|
-
* - the daemon stops the host -> the host kills the session and exits 0
|
|
12
|
+
* - the daemon stops the host -> the host kills the session and exits 0;
|
|
13
|
+
* - the tool's binary changes on disk (the updater installed a new version) ->
|
|
14
|
+
* once the session has been quiet for 10 minutes, or after 2 hours at the
|
|
15
|
+
* latest, the host kills the session and exits 0, and the daemon starts it
|
|
16
|
+
* again on the new version.
|
|
11
17
|
*
|
|
12
18
|
* The tmux server is a private socket (`-L hq-daemon`), so it never touches a
|
|
13
19
|
* user's own tmux sessions.
|
|
@@ -15,16 +21,33 @@
|
|
|
15
21
|
import type { RunCommand } from "../service-manager/launchd.js";
|
|
16
22
|
export declare const TMUX_SOCKET = "hq-daemon";
|
|
17
23
|
export declare const TMUX_SESSION = "remote-control";
|
|
24
|
+
export declare const CODEX_TMUX_SESSION = "codex-remote-control";
|
|
18
25
|
export declare function remoteControlArgs(opts: {
|
|
19
26
|
name: string;
|
|
20
27
|
permissionMode?: string;
|
|
21
28
|
}): string[];
|
|
29
|
+
/**
|
|
30
|
+
* The session name a box's own runner script passes to `claude remote-control`
|
|
31
|
+
* (`--name outpost-1-2`), so taking the runner over keeps the name people see.
|
|
32
|
+
*/
|
|
33
|
+
export declare function runnerSessionName(script: string): string | null;
|
|
34
|
+
/** A tool updated on disk restarts its session once quiet this long... */
|
|
35
|
+
export declare const IDLE_BEFORE_RESTART_MS: number;
|
|
36
|
+
/** ...or after this long, however busy the session is. */
|
|
37
|
+
export declare const MAX_RESTART_WAIT_MS: number;
|
|
22
38
|
export interface RemoteControlHostDeps {
|
|
23
39
|
tmux: string;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
40
|
+
/** tmux session name on the daemon's private socket. */
|
|
41
|
+
session: string;
|
|
42
|
+
/** Folder the command starts in (the HQ folder). */
|
|
43
|
+
cwd: string;
|
|
44
|
+
/** The command to keep running, binary first. */
|
|
45
|
+
argv: string[];
|
|
46
|
+
/** Name used in log lines ("claude remote-control"). */
|
|
47
|
+
label: string;
|
|
48
|
+
/** Identity of the tool's binary on disk; a change means it was updated. Null when it cannot be read. */
|
|
49
|
+
binaryFingerprint: () => string | null;
|
|
50
|
+
now: () => number;
|
|
28
51
|
run: RunCommand;
|
|
29
52
|
sleep: (ms: number) => Promise<void>;
|
|
30
53
|
pollMs: number;
|
|
@@ -1,46 +1,90 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The remote-control service hosts (`hq daemon service remote-control` and
|
|
3
|
+
* `codex-remote-control`).
|
|
3
4
|
*
|
|
4
|
-
* Like the outpost
|
|
5
|
-
* session so
|
|
6
|
-
* watches it; the daemon supervises the
|
|
5
|
+
* Like the outpost runners, the command (`claude remote-control`, or the box's
|
|
6
|
+
* Codex runner) runs inside a detached tmux session so it has a terminal. This
|
|
7
|
+
* host process starts the session and watches it; the daemon supervises the
|
|
8
|
+
* host:
|
|
7
9
|
*
|
|
8
10
|
* - the session ends -> the host exits 1 and the daemon restarts it with
|
|
9
11
|
* backoff (replacing the runner's `while true` loop and its watchdogs);
|
|
10
|
-
* - the daemon stops the host -> the host kills the session and exits 0
|
|
12
|
+
* - the daemon stops the host -> the host kills the session and exits 0;
|
|
13
|
+
* - the tool's binary changes on disk (the updater installed a new version) ->
|
|
14
|
+
* once the session has been quiet for 10 minutes, or after 2 hours at the
|
|
15
|
+
* latest, the host kills the session and exits 0, and the daemon starts it
|
|
16
|
+
* again on the new version.
|
|
11
17
|
*
|
|
12
18
|
* The tmux server is a private socket (`-L hq-daemon`), so it never touches a
|
|
13
19
|
* user's own tmux sessions.
|
|
14
20
|
*/
|
|
15
21
|
export const TMUX_SOCKET = "hq-daemon";
|
|
16
22
|
export const TMUX_SESSION = "remote-control";
|
|
23
|
+
export const CODEX_TMUX_SESSION = "codex-remote-control";
|
|
17
24
|
export function remoteControlArgs(opts) {
|
|
18
25
|
const args = ["remote-control", "--name", opts.name, "--spawn=same-dir"];
|
|
19
26
|
if (opts.permissionMode)
|
|
20
27
|
args.push("--permission-mode", opts.permissionMode);
|
|
21
28
|
return args;
|
|
22
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* The session name a box's own runner script passes to `claude remote-control`
|
|
32
|
+
* (`--name outpost-1-2`), so taking the runner over keeps the name people see.
|
|
33
|
+
*/
|
|
34
|
+
export function runnerSessionName(script) {
|
|
35
|
+
const m = /remote-control\b[^\n]*?--name[= ]+"?([^\s"]+)"?/.exec(script);
|
|
36
|
+
return m ? m[1] : null;
|
|
37
|
+
}
|
|
38
|
+
/** A tool updated on disk restarts its session once quiet this long... */
|
|
39
|
+
export const IDLE_BEFORE_RESTART_MS = 10 * 60_000;
|
|
40
|
+
/** ...or after this long, however busy the session is. */
|
|
41
|
+
export const MAX_RESTART_WAIT_MS = 2 * 60 * 60_000;
|
|
23
42
|
export async function runRemoteControlHost(deps) {
|
|
24
43
|
const tmux = (...args) => deps.run(deps.tmux, ["-L", TMUX_SOCKET, ...args]);
|
|
25
|
-
const killSession = () => tmux("kill-session", "-t",
|
|
44
|
+
const killSession = () => tmux("kill-session", "-t", deps.session);
|
|
45
|
+
/** When the session last printed anything, in ms; null when tmux cannot say. */
|
|
46
|
+
const lastActivity = () => {
|
|
47
|
+
const r = tmux("display-message", "-p", "-t", deps.session, "#{window_activity}");
|
|
48
|
+
const secs = r.status === 0 ? Number.parseInt(r.stdout.trim(), 10) : Number.NaN;
|
|
49
|
+
return Number.isFinite(secs) ? secs * 1000 : null;
|
|
50
|
+
};
|
|
26
51
|
// A session left by an earlier host (for example after a crash) is replaced.
|
|
27
52
|
killSession();
|
|
28
|
-
const started = tmux("new-session", "-d", "-s",
|
|
53
|
+
const started = tmux("new-session", "-d", "-s", deps.session, "-x", "200", "-y", "50", "-c", deps.cwd, ...deps.argv);
|
|
29
54
|
if (started.status !== 0) {
|
|
30
55
|
deps.log(`could not start the tmux session: ${started.stderr.trim() || `tmux exited ${started.status}`}`);
|
|
31
56
|
return 1;
|
|
32
57
|
}
|
|
33
|
-
deps.log(
|
|
58
|
+
deps.log(`${deps.label} started in ${deps.cwd}`);
|
|
59
|
+
const startedWith = deps.binaryFingerprint();
|
|
60
|
+
let updatedAt = null;
|
|
34
61
|
for (;;) {
|
|
35
62
|
if (deps.signal.aborted) {
|
|
36
63
|
killSession();
|
|
37
|
-
deps.log(
|
|
64
|
+
deps.log(`stopped ${deps.label}`);
|
|
38
65
|
return 0;
|
|
39
66
|
}
|
|
40
|
-
if (tmux("has-session", "-t",
|
|
41
|
-
deps.log(
|
|
67
|
+
if (tmux("has-session", "-t", deps.session).status !== 0) {
|
|
68
|
+
deps.log(`the ${deps.label} session ended`);
|
|
42
69
|
return 1;
|
|
43
70
|
}
|
|
71
|
+
const now = deps.now();
|
|
72
|
+
if (updatedAt === null) {
|
|
73
|
+
const current = deps.binaryFingerprint();
|
|
74
|
+
if (startedWith !== null && current !== null && current !== startedWith) {
|
|
75
|
+
updatedAt = now;
|
|
76
|
+
deps.log(`${deps.label}: the tool was updated; restarting onto it once the session is quiet (within 2 hours)`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (updatedAt !== null) {
|
|
80
|
+
const activity = lastActivity();
|
|
81
|
+
const quiet = activity !== null && now - activity >= IDLE_BEFORE_RESTART_MS;
|
|
82
|
+
if (quiet || now - updatedAt >= MAX_RESTART_WAIT_MS) {
|
|
83
|
+
killSession();
|
|
84
|
+
deps.log(`${deps.label}: restarting onto the updated tool${quiet ? "" : " (the session stayed busy for 2 hours)"}`);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
44
88
|
await deps.sleep(deps.pollMs);
|
|
45
89
|
}
|
|
46
90
|
}
|
package/dist/lib/daemon/run.d.ts
CHANGED
|
@@ -25,6 +25,12 @@ export declare function windowsTaskExists(taskName: string): boolean;
|
|
|
25
25
|
export declare function probeLegacyUnits(platform: NodeJS.Platform, home: string): LegacyUnit[];
|
|
26
26
|
/** Absolute path of `name` on PATH, or null. */
|
|
27
27
|
export declare function findOnPath(name: string, env?: NodeJS.ProcessEnv): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* A tool the login user installed (~/.local/bin, ~/.npm-global/bin,
|
|
30
|
+
* ~/.grok/bin) before anything on PATH, so a box runs and updates the user's
|
|
31
|
+
* own copy instead of one root installed under /usr.
|
|
32
|
+
*/
|
|
33
|
+
export declare function findUserBinary(name: string, env: NodeJS.ProcessEnv, home: string): string | null;
|
|
28
34
|
/** This CLI is a global package install the updater can replace (not a dev checkout or a project dependency). */
|
|
29
35
|
export declare function cliSelfUpdatable(): boolean;
|
|
30
36
|
/** The HQ desktop app process is running here (it runs its own sync). */
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -15,14 +15,17 @@ import { isLocalDependencyInstall, resolveRunningInstall } from "../../utils/ver
|
|
|
15
15
|
import { agentKitPaths } from "../agent-kit/paths.js";
|
|
16
16
|
import { readBotConfig } from "../bot/config.js";
|
|
17
17
|
import { botsRoot } from "../bot/paths.js";
|
|
18
|
-
import { enabledServices, planUnits } from "./catalog.js";
|
|
18
|
+
import { CLAUDE_RUNNER_SCRIPT, CODEX_RUNNER_SCRIPT, enabledServices, planUnits } from "./catalog.js";
|
|
19
19
|
import { readDaemonConfig } from "./config.js";
|
|
20
20
|
import { findLegacyUnits, legacyOwnerLookup } from "./legacy.js";
|
|
21
21
|
import { acquireDaemonLock, releaseDaemonLock } from "./lock.js";
|
|
22
|
+
import { daemonRootRefusal } from "./install.js";
|
|
22
23
|
import { detectMachineType } from "./machine-type.js";
|
|
23
24
|
import { appendPrivate, daemonPaths, ensurePrivateDir, openPrivateAppend } from "./paths.js";
|
|
24
25
|
import { takeControlRequests } from "./requests.js";
|
|
26
|
+
import { runnerSessionName } from "./remote-control.js";
|
|
25
27
|
import { STOP_ESCALATION_MS, createSupervisor } from "./supervisor.js";
|
|
28
|
+
import { userToolDirs } from "./tools.js";
|
|
26
29
|
export const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
|
|
27
30
|
/** Exit code for "restart me": non-zero so every service manager relaunches (Windows tasks only restart on failure). */
|
|
28
31
|
export const RESTART_EXIT_CODE = 75;
|
|
@@ -79,6 +82,35 @@ export function findOnPath(name, env = process.env) {
|
|
|
79
82
|
}
|
|
80
83
|
return null;
|
|
81
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* A tool the login user installed (~/.local/bin, ~/.npm-global/bin,
|
|
87
|
+
* ~/.grok/bin) before anything on PATH, so a box runs and updates the user's
|
|
88
|
+
* own copy instead of one root installed under /usr.
|
|
89
|
+
*/
|
|
90
|
+
export function findUserBinary(name, env, home) {
|
|
91
|
+
if (process.platform !== "win32") {
|
|
92
|
+
for (const dir of userToolDirs(home)) {
|
|
93
|
+
const candidate = path.join(dir, name);
|
|
94
|
+
try {
|
|
95
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
96
|
+
if (fs.statSync(candidate).isFile())
|
|
97
|
+
return candidate;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// not here
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return findOnPath(name, env);
|
|
105
|
+
}
|
|
106
|
+
function readIfExists(file) {
|
|
107
|
+
try {
|
|
108
|
+
return fs.readFileSync(file, "utf8");
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
82
114
|
/** This CLI is a global package install the updater can replace (not a dev checkout or a project dependency). */
|
|
83
115
|
export function cliSelfUpdatable() {
|
|
84
116
|
const install = resolveRunningInstall();
|
|
@@ -191,6 +223,12 @@ export async function runDaemon(opts) {
|
|
|
191
223
|
const machineType = detected.type;
|
|
192
224
|
for (const w of detected.warnings ?? [])
|
|
193
225
|
log("warn", w);
|
|
226
|
+
const refusal = daemonRootRefusal(typeof process.getuid === "function" && process.getuid() === 0, machineType);
|
|
227
|
+
if (refusal) {
|
|
228
|
+
log("error", refusal);
|
|
229
|
+
releaseDaemonLock(paths.lockPath);
|
|
230
|
+
return 1;
|
|
231
|
+
}
|
|
194
232
|
log("info", `starting hq daemon (pid ${process.pid}) as ${machineType} (${detected.detail})`);
|
|
195
233
|
const syncRunnerPath = resolveSyncRunnerPath();
|
|
196
234
|
if (!syncRunnerPath)
|
|
@@ -223,7 +261,10 @@ export async function runDaemon(opts) {
|
|
|
223
261
|
legacyOwner: legacyOwnerLookup(legacy),
|
|
224
262
|
cliSelfUpdatable: updatable,
|
|
225
263
|
jobsReconcileScript: jobsReconcileScript(hqRoot),
|
|
226
|
-
claudePath:
|
|
264
|
+
claudePath: findUserBinary("claude", env, home),
|
|
265
|
+
codexPath: findUserBinary("codex", env, home),
|
|
266
|
+
codexRunnerScript: platform === "linux" && fs.existsSync(CODEX_RUNNER_SCRIPT) ? CODEX_RUNNER_SCRIPT : null,
|
|
267
|
+
remoteControlName: runnerSessionName(readIfExists(CLAUDE_RUNNER_SCRIPT) ?? ""),
|
|
227
268
|
tmuxPath: findOnPath("tmux", env),
|
|
228
269
|
hostName,
|
|
229
270
|
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI CLIs the daemon keeps current (Claude, Codex, Grok): where the login
|
|
3
|
+
* user's copy is, which version it is, and how to update it.
|
|
4
|
+
*
|
|
5
|
+
* Boxes can carry two copies of a tool: one root installed under /usr during
|
|
6
|
+
* provisioning, and the login user's own under ~/.npm-global or ~/.local. The
|
|
7
|
+
* daemon runs as the login user, so it always prefers that user's copy (which
|
|
8
|
+
* it can update) and never updates a copy it cannot write.
|
|
9
|
+
*/
|
|
10
|
+
export declare const TOOL_NAMES: readonly ["claude", "codex", "grok"];
|
|
11
|
+
export type ToolName = (typeof TOOL_NAMES)[number];
|
|
12
|
+
export interface ToolProbeDeps {
|
|
13
|
+
home: string;
|
|
14
|
+
/** Defaults to this process's platform. */
|
|
15
|
+
platform?: NodeJS.Platform;
|
|
16
|
+
env: NodeJS.ProcessEnv;
|
|
17
|
+
exists: (p: string) => boolean;
|
|
18
|
+
realpath: (p: string) => string;
|
|
19
|
+
/** Whether this user can write the directory. */
|
|
20
|
+
writable: (dir: string) => boolean;
|
|
21
|
+
/** `<bin> --version`. */
|
|
22
|
+
version: (bin: string) => {
|
|
23
|
+
status: number | null;
|
|
24
|
+
stdout: string;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export interface ToolInstall {
|
|
28
|
+
/** The command as found (for example ~/.npm-global/bin/claude). */
|
|
29
|
+
path: string;
|
|
30
|
+
/** Where that resolves to on disk. */
|
|
31
|
+
realPath: string;
|
|
32
|
+
version: string | null;
|
|
33
|
+
/** False for a copy this user cannot update (for example root's under /usr). */
|
|
34
|
+
writable: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** The login user's own install folders, searched before PATH. */
|
|
37
|
+
export declare function userToolDirs(home: string): string[];
|
|
38
|
+
export declare function findUserTool(name: ToolName, deps: ToolProbeDeps): string | null;
|
|
39
|
+
export declare function parseToolVersion(output: string): string | null;
|
|
40
|
+
export declare function locateTool(name: ToolName, deps: ToolProbeDeps): ToolInstall | null;
|
|
41
|
+
export interface ToolUpdateCommand {
|
|
42
|
+
file: string;
|
|
43
|
+
args: string[];
|
|
44
|
+
env: Record<string, string>;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* An npm install is updated through npm, into the prefix it was found in (the
|
|
48
|
+
* npm on PATH may be configured for another one); anything else through the
|
|
49
|
+
* CLI's own `update`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function toolUpdateCommand(name: ToolName, install: Pick<ToolInstall, "path" | "realPath">, platform?: NodeJS.Platform): ToolUpdateCommand;
|
|
52
|
+
//# sourceMappingURL=tools.d.ts.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI CLIs the daemon keeps current (Claude, Codex, Grok): where the login
|
|
3
|
+
* user's copy is, which version it is, and how to update it.
|
|
4
|
+
*
|
|
5
|
+
* Boxes can carry two copies of a tool: one root installed under /usr during
|
|
6
|
+
* provisioning, and the login user's own under ~/.npm-global or ~/.local. The
|
|
7
|
+
* daemon runs as the login user, so it always prefers that user's copy (which
|
|
8
|
+
* it can update) and never updates a copy it cannot write.
|
|
9
|
+
*/
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
export const TOOL_NAMES = ["claude", "codex", "grok"];
|
|
12
|
+
/** npm package for tools that can be installed with npm. */
|
|
13
|
+
const NPM_PACKAGES = {
|
|
14
|
+
claude: "@anthropic-ai/claude-code",
|
|
15
|
+
codex: "@openai/codex",
|
|
16
|
+
grok: "@vibe-kit/grok-cli",
|
|
17
|
+
};
|
|
18
|
+
/** Environment each tool's own `update` command needs to run unattended. */
|
|
19
|
+
const UPDATE_ENV = {
|
|
20
|
+
codex: { CODEX_NON_INTERACTIVE: "1" },
|
|
21
|
+
};
|
|
22
|
+
/** The login user's own install folders, searched before PATH. */
|
|
23
|
+
export function userToolDirs(home) {
|
|
24
|
+
return [
|
|
25
|
+
path.posix.join(home, ".local", "bin"),
|
|
26
|
+
path.posix.join(home, ".npm-global", "bin"),
|
|
27
|
+
path.posix.join(home, ".grok", "bin"),
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
export function findUserTool(name, deps) {
|
|
31
|
+
const windows = (deps.platform ?? process.platform) === "win32";
|
|
32
|
+
if (!windows) {
|
|
33
|
+
for (const dir of userToolDirs(deps.home)) {
|
|
34
|
+
const candidate = path.posix.join(dir, name);
|
|
35
|
+
if (deps.exists(candidate))
|
|
36
|
+
return candidate;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const p = windows ? path.win32 : path.posix;
|
|
40
|
+
// Windows installs are claude.exe, codex.cmd and so on.
|
|
41
|
+
const exts = windows ? (deps.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
|
|
42
|
+
for (const dir of (deps.env.PATH ?? "").split(p.delimiter)) {
|
|
43
|
+
if (!dir)
|
|
44
|
+
continue;
|
|
45
|
+
for (const ext of exts) {
|
|
46
|
+
const candidate = p.join(dir, `${name}${ext.toLowerCase()}`);
|
|
47
|
+
if (deps.exists(candidate))
|
|
48
|
+
return candidate;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
export function parseToolVersion(output) {
|
|
54
|
+
const m = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)\b/.exec(output);
|
|
55
|
+
return m ? m[1] : null;
|
|
56
|
+
}
|
|
57
|
+
export function locateTool(name, deps) {
|
|
58
|
+
const found = findUserTool(name, deps);
|
|
59
|
+
if (!found)
|
|
60
|
+
return null;
|
|
61
|
+
const realPath = deps.realpath(found);
|
|
62
|
+
const r = deps.version(found);
|
|
63
|
+
return {
|
|
64
|
+
path: found,
|
|
65
|
+
realPath,
|
|
66
|
+
version: r.status === 0 ? parseToolVersion(r.stdout) : null,
|
|
67
|
+
writable: deps.writable(path.dirname(realPath)),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The npm global prefix an install lives in: `<prefix>/lib/node_modules/<pkg>`
|
|
72
|
+
* on Unix, `<prefix>\\node_modules\\<pkg>` on Windows. Null when the real path
|
|
73
|
+
* is not inside that package.
|
|
74
|
+
*/
|
|
75
|
+
function npmPrefixOf(realPath, pkg, platform) {
|
|
76
|
+
const windows = platform === "win32";
|
|
77
|
+
const p = windows ? path.win32 : path.posix;
|
|
78
|
+
const marker = windows ? `${p.sep}node_modules${p.sep}${pkg.split("/").join(p.sep)}${p.sep}` : `/lib/node_modules/${pkg}/`;
|
|
79
|
+
const at = realPath.indexOf(marker);
|
|
80
|
+
return at > 0 ? realPath.slice(0, at) : null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* An npm install is updated through npm, into the prefix it was found in (the
|
|
84
|
+
* npm on PATH may be configured for another one); anything else through the
|
|
85
|
+
* CLI's own `update`.
|
|
86
|
+
*/
|
|
87
|
+
export function toolUpdateCommand(name, install, platform = process.platform) {
|
|
88
|
+
const pkg = NPM_PACKAGES[name];
|
|
89
|
+
const prefix = pkg ? npmPrefixOf(install.realPath, pkg, platform) : null;
|
|
90
|
+
if (pkg && prefix) {
|
|
91
|
+
return { file: "npm", args: ["install", "-g", "--prefix", prefix, `${pkg}@latest`], env: {} };
|
|
92
|
+
}
|
|
93
|
+
return { file: install.path, args: ["update"], env: { ...(UPDATE_ENV[name] ?? {}) } };
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=tools.js.map
|
|
@@ -3,37 +3,45 @@
|
|
|
3
3
|
* replaces the boxes' `hq-cli-update.timer` and `*-codex-update.timer`:
|
|
4
4
|
*
|
|
5
5
|
* 1. Update the hq CLI through the shared self-update path (same install
|
|
6
|
-
* routing, lock and convergence check as every other CLI update)
|
|
7
|
-
*
|
|
6
|
+
* routing, lock and convergence check as every other CLI update), when it
|
|
7
|
+
* is a global install this process may replace.
|
|
8
|
+
* 2. Update the Claude, Codex and Grok CLIs the login user installed, each
|
|
9
|
+
* through its own update route. A copy this user cannot write (root's
|
|
10
|
+
* under /usr) is reported and left alone.
|
|
8
11
|
* 3. After a CLI update, ask the daemon to restart itself so the supervisor and
|
|
9
|
-
* every service run the new version.
|
|
12
|
+
* every service run the new version. The remote-control hosts notice an
|
|
13
|
+
* updated Claude or Codex on disk themselves and restart once idle.
|
|
10
14
|
*/
|
|
11
15
|
import type { SelfUpdateOutcome } from "../../utils/self-update.js";
|
|
12
|
-
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
install:
|
|
16
|
+
import { type ToolInstall, type ToolName } from "./tools.js";
|
|
17
|
+
export type { ToolInstall } from "./tools.js";
|
|
18
|
+
export interface ToolUpdater {
|
|
19
|
+
name: ToolName;
|
|
20
|
+
locate: () => ToolInstall | null;
|
|
21
|
+
update: (install: ToolInstall) => {
|
|
18
22
|
ok: boolean;
|
|
19
23
|
detail?: string;
|
|
20
24
|
};
|
|
21
25
|
}
|
|
22
26
|
export interface UpdaterDeps {
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
/** Null when this CLI is not a global install the updater may replace. */
|
|
28
|
+
updateCli: (() => Promise<Pick<SelfUpdateOutcome, "action" | "latest">>) | null;
|
|
29
|
+
tools: readonly ToolUpdater[];
|
|
25
30
|
requestDaemonRestart: () => void;
|
|
26
31
|
log: (line: string) => void;
|
|
27
32
|
}
|
|
28
|
-
export
|
|
33
|
+
export interface UpdaterResult {
|
|
29
34
|
ok: boolean;
|
|
30
|
-
|
|
35
|
+
/** Tools whose version changed in this run. */
|
|
36
|
+
updated: ToolName[];
|
|
37
|
+
}
|
|
38
|
+
export declare function runDaemonUpdater(deps: UpdaterDeps): Promise<UpdaterResult>;
|
|
31
39
|
/**
|
|
32
40
|
* The variables for the updater's own CLI update. Every supervised service
|
|
33
41
|
* gets HQ_NO_UPDATE_CHECK=1 so it never updates itself; the updater is the one
|
|
34
42
|
* process that must, so it drops that opt-out.
|
|
35
43
|
*/
|
|
36
44
|
export declare function updaterEnv(vars: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
37
|
-
/**
|
|
38
|
-
export declare function
|
|
45
|
+
/** The real updater for one tool, run as this (the login) user. */
|
|
46
|
+
export declare function hostToolUpdater(name: ToolName, env: NodeJS.ProcessEnv, home: string): ToolUpdater;
|
|
39
47
|
//# sourceMappingURL=updater.d.ts.map
|