@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/command-catalog.generated.d.ts +88 -1
  3. package/dist/command-catalog.generated.js +112 -1
  4. package/dist/commands/daemon.d.ts +2 -0
  5. package/dist/commands/daemon.js +79 -22
  6. package/dist/commands/lanes.js +146 -31
  7. package/dist/commands/mesh.js +13 -19
  8. package/dist/commands/outpost-pruner.d.ts +3 -0
  9. package/dist/commands/outpost-pruner.js +125 -0
  10. package/dist/lib/daemon/catalog.d.ts +11 -1
  11. package/dist/lib/daemon/catalog.js +31 -6
  12. package/dist/lib/daemon/install.d.ts +7 -0
  13. package/dist/lib/daemon/install.js +18 -2
  14. package/dist/lib/daemon/legacy.js +6 -1
  15. package/dist/lib/daemon/machine-type.d.ts +4 -1
  16. package/dist/lib/daemon/machine-type.js +29 -9
  17. package/dist/lib/daemon/migrate.js +2 -2
  18. package/dist/lib/daemon/remote-control.d.ts +32 -9
  19. package/dist/lib/daemon/remote-control.js +55 -11
  20. package/dist/lib/daemon/run.d.ts +6 -0
  21. package/dist/lib/daemon/run.js +43 -2
  22. package/dist/lib/daemon/tools.d.ts +52 -0
  23. package/dist/lib/daemon/tools.js +95 -0
  24. package/dist/lib/daemon/updater.d.ts +23 -15
  25. package/dist/lib/daemon/updater.js +97 -60
  26. package/dist/lib/lanes/mesh-events.d.ts +4 -0
  27. package/dist/lib/lanes/mesh-events.js +72 -4
  28. package/dist/lib/lanes/outpost-pruner-install.d.ts +37 -0
  29. package/dist/lib/lanes/outpost-pruner-install.js +226 -0
  30. package/dist/lib/lanes/outpost-worktree-pruner-flag.d.ts +19 -0
  31. package/dist/lib/lanes/outpost-worktree-pruner-flag.js +76 -0
  32. package/dist/lib/lanes/outpost-worktree-pruner.d.ts +60 -0
  33. package/dist/lib/lanes/outpost-worktree-pruner.js +1135 -0
  34. package/dist/lib/mesh/live/session-events-client.js +3 -2
  35. package/dist/lib/mesh/project-board.d.ts +7 -0
  36. package/dist/lib/mesh/project-board.js +29 -1
  37. 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
- PATH: host.env?.PATH ?? "/usr/local/bin:/usr/bin:/bin",
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. Provisioning writes it to /etc/hq/machine-type (user=<name>).");
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-codex-runner.service", "remote-control"],
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
- return user ? { type, user } : { type };
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
- /** Box types run as the login user the provisioning marker names, however the type itself was chosen. */
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
- type: marker.type,
111
- source: "marker",
112
- detail: `from ${MACHINE_TYPE_MARKER_PATH}`,
113
- ...(marker.user ? { runAsUser: marker.user } : {}),
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 `remote-control` service host (`hq daemon service remote-control`).
2
+ * The remote-control service hosts (`hq daemon service remote-control` and
3
+ * `codex-remote-control`).
3
4
  *
4
- * Like the outpost runner, `claude remote-control` runs inside a detached tmux
5
- * session so Claude has a terminal. This host process starts the session and
6
- * watches it; the daemon supervises the host:
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
- claude: string;
25
- hqRoot: string;
26
- name: string;
27
- permissionMode?: string;
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 `remote-control` service host (`hq daemon service remote-control`).
2
+ * The remote-control service hosts (`hq daemon service remote-control` and
3
+ * `codex-remote-control`).
3
4
  *
4
- * Like the outpost runner, `claude remote-control` runs inside a detached tmux
5
- * session so Claude has a terminal. This host process starts the session and
6
- * watches it; the daemon supervises the host:
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", TMUX_SESSION);
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", TMUX_SESSION, "-x", "200", "-y", "50", "-c", deps.hqRoot, deps.claude, ...remoteControlArgs({ name: deps.name, permissionMode: deps.permissionMode }));
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(`claude remote-control started as "${deps.name}" in ${deps.hqRoot}`);
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("stopped remote control");
64
+ deps.log(`stopped ${deps.label}`);
38
65
  return 0;
39
66
  }
40
- if (tmux("has-session", "-t", TMUX_SESSION).status !== 0) {
41
- deps.log("the remote-control session ended");
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
  }
@@ -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). */
@@ -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: findOnPath("claude", env),
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
- * 2. Update Codex when it is installed as a global npm package and behind.
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
- export declare const CODEX_PACKAGE = "@openai/codex";
13
- export interface CodexUpdater {
14
- /** Version of the global npm install, or null when Codex is not installed that way. */
15
- installedVersion: () => string | null;
16
- latestVersion: () => string | null;
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
- updateCli: () => Promise<Pick<SelfUpdateOutcome, "action" | "latest">>;
24
- codex: CodexUpdater;
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 declare function runDaemonUpdater(deps: UpdaterDeps): Promise<{
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
- /** Codex through the global npm install, the way the boxes install it. */
38
- export declare function npmCodexUpdater(): CodexUpdater;
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