@indigoai-us/hq-cli 5.167.0 → 5.168.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/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 +2 -0
- 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/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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { outpostWorktreePrunerEnabled, OUTPOST_WORKTREE_PRUNER_FLAG } from "../lib/lanes/outpost-worktree-pruner-flag.js";
|
|
5
|
+
import { OUTPOST_WORKTREE_PRUNER_DEFAULT_MIN_AGE_DAYS, runOutpostWorktreePruner, } from "../lib/lanes/outpost-worktree-pruner.js";
|
|
6
|
+
import { defaultOutpostPrunerSystemdDir, installOutpostPrunerTimers, uninstallOutpostPrunerTimers, } from "../lib/lanes/outpost-pruner-install.js";
|
|
7
|
+
import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
|
|
8
|
+
function errorType(error) {
|
|
9
|
+
return error instanceof Error ? error.name : "UnknownError";
|
|
10
|
+
}
|
|
11
|
+
function printJson(value) {
|
|
12
|
+
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
13
|
+
}
|
|
14
|
+
function printErrorJson(event, error, fields = {}) {
|
|
15
|
+
printJson({ event, ...fields, error: errorType(error) });
|
|
16
|
+
}
|
|
17
|
+
function runSystemctl(binary, args) {
|
|
18
|
+
const result = spawnSync(binary, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
19
|
+
return {
|
|
20
|
+
status: result.status ?? 1,
|
|
21
|
+
stdout: result.stdout ?? "",
|
|
22
|
+
stderr: result.stderr ?? "",
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function systemdRoot(option) {
|
|
26
|
+
return path.resolve(option ?? defaultOutpostPrunerSystemdDir(os.homedir()));
|
|
27
|
+
}
|
|
28
|
+
function skippedResult(trigger, minAgeDays, reason) {
|
|
29
|
+
return {
|
|
30
|
+
event: "outpost.worktree_pruner.run",
|
|
31
|
+
trigger,
|
|
32
|
+
dry_run: false,
|
|
33
|
+
feature_enabled: false,
|
|
34
|
+
skipped: reason,
|
|
35
|
+
flag: OUTPOST_WORKTREE_PRUNER_FLAG,
|
|
36
|
+
min_age_days: minAgeDays,
|
|
37
|
+
candidates: [],
|
|
38
|
+
removed: [],
|
|
39
|
+
kept: [],
|
|
40
|
+
bytes_freed: 0,
|
|
41
|
+
bytes_freed_basis: "statfs_available_byte_delta",
|
|
42
|
+
package_store: { attempted: false, reason: "not_run" },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function registerOutpostPrunerCommand(lanes) {
|
|
46
|
+
const pruner = lanes
|
|
47
|
+
.command("outpost-pruner")
|
|
48
|
+
.description("Inspect, install, and run safe outpost worktree pruning");
|
|
49
|
+
pruner
|
|
50
|
+
.command("run")
|
|
51
|
+
.description("Run the guarded outpost worktree and user-cache cleanup")
|
|
52
|
+
.option("--trigger <kind>", "pressure | daily | manual", "manual")
|
|
53
|
+
.option("--min-age-days <days>", "Minimum age before cleanup (1–365 days)", (value) => Number(value), OUTPOST_WORKTREE_PRUNER_DEFAULT_MIN_AGE_DAYS)
|
|
54
|
+
.option("--dry-run", "List eligible worktrees without deleting files")
|
|
55
|
+
.action(async (options) => {
|
|
56
|
+
const trigger = options.trigger ?? "manual";
|
|
57
|
+
const minAgeDays = options.minAgeDays ?? OUTPOST_WORKTREE_PRUNER_DEFAULT_MIN_AGE_DAYS;
|
|
58
|
+
try {
|
|
59
|
+
if (!["pressure", "daily", "manual"].includes(trigger)) {
|
|
60
|
+
throw new TypeError("invalid trigger");
|
|
61
|
+
}
|
|
62
|
+
const featureEnabled = options.dryRun ? false : await outpostWorktreePrunerEnabled();
|
|
63
|
+
if (!options.dryRun && !featureEnabled) {
|
|
64
|
+
printJson(skippedResult(trigger, minAgeDays, "feature_flag_off"));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const result = runOutpostWorktreePruner({
|
|
68
|
+
hqRoot: resolveLanesHqRoot(),
|
|
69
|
+
trigger,
|
|
70
|
+
dryRun: Boolean(options.dryRun),
|
|
71
|
+
featureEnabled,
|
|
72
|
+
minAgeDays,
|
|
73
|
+
});
|
|
74
|
+
printJson({ ...result, flag: OUTPOST_WORKTREE_PRUNER_FLAG });
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
printErrorJson("outpost.worktree_pruner.run", error, {
|
|
78
|
+
trigger,
|
|
79
|
+
dry_run: Boolean(options.dryRun),
|
|
80
|
+
});
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
pruner
|
|
85
|
+
.command("install")
|
|
86
|
+
.description("Write the pressure and daily systemd user timers")
|
|
87
|
+
.option("--systemd-root <path>", "Systemd user unit directory")
|
|
88
|
+
.option("--enable", "Enable both timers after writing the units")
|
|
89
|
+
.action((options) => {
|
|
90
|
+
try {
|
|
91
|
+
const result = installOutpostPrunerTimers({
|
|
92
|
+
systemdRoot: systemdRoot(options.systemdRoot),
|
|
93
|
+
hqRoot: resolveLanesHqRoot(),
|
|
94
|
+
home: os.homedir(),
|
|
95
|
+
enable: Boolean(options.enable),
|
|
96
|
+
runCommand: options.enable ? runSystemctl : undefined,
|
|
97
|
+
});
|
|
98
|
+
printJson(result);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
printErrorJson("outpost.worktree_pruner.install", error);
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
pruner
|
|
106
|
+
.command("uninstall")
|
|
107
|
+
.description("Disable and remove the pressure and daily systemd user timers")
|
|
108
|
+
.option("--systemd-root <path>", "Systemd user unit directory")
|
|
109
|
+
.option("--disable", "Disable both timers before removing their units")
|
|
110
|
+
.action((options) => {
|
|
111
|
+
try {
|
|
112
|
+
const result = uninstallOutpostPrunerTimers({
|
|
113
|
+
systemdRoot: systemdRoot(options.systemdRoot),
|
|
114
|
+
disable: Boolean(options.disable),
|
|
115
|
+
runCommand: options.disable ? runSystemctl : undefined,
|
|
116
|
+
});
|
|
117
|
+
printJson(result);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
printErrorJson("outpost.worktree_pruner.uninstall", error);
|
|
121
|
+
process.exitCode = 1;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=outpost-pruner.js.map
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* them instead of silently dropping them.
|
|
11
11
|
*/
|
|
12
12
|
import type { MachineType } from "./machine-type.js";
|
|
13
|
-
export declare const SERVICE_NAMES: readonly ["sync", "mesh", "heartbeat", "inbox", "bots", "remote-control", "session-host", "lanes-reconcile", "search-index", "jobs-reconcile", "updater", "housekeeping"];
|
|
13
|
+
export declare const SERVICE_NAMES: readonly ["sync", "mesh", "heartbeat", "inbox", "bots", "remote-control", "codex-remote-control", "session-host", "lanes-reconcile", "search-index", "jobs-reconcile", "updater", "housekeeping"];
|
|
14
14
|
export type ServiceName = (typeof SERVICE_NAMES)[number];
|
|
15
15
|
export declare function isServiceName(value: unknown): value is ServiceName;
|
|
16
16
|
export declare const SERVICE_DESCRIPTIONS: Record<ServiceName, string>;
|
|
@@ -79,7 +79,17 @@ export interface PlanContext {
|
|
|
79
79
|
tmuxPath: string | null;
|
|
80
80
|
/** Short host name, used to name this machine's remote-control session. */
|
|
81
81
|
hostName: string;
|
|
82
|
+
/** The session name the box's own runner used (`--name` in its script), kept when the daemon takes over. */
|
|
83
|
+
remoteControlName: string | null;
|
|
84
|
+
/** Absolute path of the login user's codex binary, or null. */
|
|
85
|
+
codexPath: string | null;
|
|
86
|
+
/** The box's Codex remote-control runner script, when it has one. */
|
|
87
|
+
codexRunnerScript: string | null;
|
|
82
88
|
}
|
|
89
|
+
/** Written by outpost provisioning; the daemon runs it rather than re-implementing Codex login and relay setup. */
|
|
90
|
+
export declare const CODEX_RUNNER_SCRIPT = "/usr/local/bin/outpost-codex-runner.sh";
|
|
91
|
+
/** The outpost's Claude runner script, read for the session name it uses. */
|
|
92
|
+
export declare const CLAUDE_RUNNER_SCRIPT = "/usr/local/bin/outpost-runner.sh";
|
|
83
93
|
/** Turn enabled services into concrete units for this host. */
|
|
84
94
|
export declare function planUnits(services: readonly ServiceName[], ctx: PlanContext): UnitPlan[];
|
|
85
95
|
//# sourceMappingURL=catalog.d.ts.map
|
|
@@ -16,6 +16,7 @@ export const SERVICE_NAMES = [
|
|
|
16
16
|
"inbox",
|
|
17
17
|
"bots",
|
|
18
18
|
"remote-control",
|
|
19
|
+
"codex-remote-control",
|
|
19
20
|
"session-host",
|
|
20
21
|
"lanes-reconcile",
|
|
21
22
|
"search-index",
|
|
@@ -33,16 +34,17 @@ export const SERVICE_DESCRIPTIONS = {
|
|
|
33
34
|
inbox: "picks up the agent's incoming messages and tasks",
|
|
34
35
|
bots: "runs local bots",
|
|
35
36
|
"remote-control": "keeps Claude remote control running",
|
|
37
|
+
"codex-remote-control": "keeps Codex remote control running",
|
|
36
38
|
"session-host": "lets the desktop app open this machine's Claude sessions",
|
|
37
39
|
"lanes-reconcile": "cleans up stalled background lanes",
|
|
38
40
|
"search-index": "keeps local search up to date",
|
|
39
41
|
"jobs-reconcile": "keeps scheduled jobs matched to the cloud",
|
|
40
|
-
updater: "keeps the hq CLI
|
|
42
|
+
updater: "keeps the hq CLI, Claude, Codex and Grok up to date",
|
|
41
43
|
housekeeping: "rotates daemon logs",
|
|
42
44
|
};
|
|
43
45
|
/** The services each machine type runs without being asked. */
|
|
44
46
|
export const DEFAULT_SERVICES = {
|
|
45
|
-
desktop: ["sync", "mesh", "heartbeat", "bots", "lanes-reconcile", "search-index", "housekeeping"],
|
|
47
|
+
desktop: ["sync", "mesh", "heartbeat", "bots", "lanes-reconcile", "search-index", "updater", "housekeeping"],
|
|
46
48
|
"self-hosted": [
|
|
47
49
|
"sync",
|
|
48
50
|
"mesh",
|
|
@@ -59,6 +61,7 @@ export const DEFAULT_SERVICES = {
|
|
|
59
61
|
"mesh",
|
|
60
62
|
"heartbeat",
|
|
61
63
|
"remote-control",
|
|
64
|
+
"codex-remote-control",
|
|
62
65
|
"session-host",
|
|
63
66
|
"lanes-reconcile",
|
|
64
67
|
"search-index",
|
|
@@ -93,6 +96,10 @@ function firstBlock(checks) {
|
|
|
93
96
|
const hit = checks.find(([blocked]) => blocked);
|
|
94
97
|
return hit ? { state: "waiting", reason: hit[1] } : undefined;
|
|
95
98
|
}
|
|
99
|
+
/** Written by outpost provisioning; the daemon runs it rather than re-implementing Codex login and relay setup. */
|
|
100
|
+
export const CODEX_RUNNER_SCRIPT = "/usr/local/bin/outpost-codex-runner.sh";
|
|
101
|
+
/** The outpost's Claude runner script, read for the session name it uses. */
|
|
102
|
+
export const CLAUDE_RUNNER_SCRIPT = "/usr/local/bin/outpost-runner.sh";
|
|
96
103
|
const NO_HQ_ROOT = "no HQ folder configured (run `hq daemon install --hq-root <path>`)";
|
|
97
104
|
const NOT_SIGNED_IN = "not signed in (run `hq login`)";
|
|
98
105
|
const KIT_NOT_SET_UP = "the agent kit is not set up on this machine (run `hq agent kit install`)";
|
|
@@ -205,9 +212,6 @@ function planService(service, ctx) {
|
|
|
205
212
|
intervalSec: 6 * 3600,
|
|
206
213
|
initialDelaySec: 900,
|
|
207
214
|
command: { type: "hq", args: ["daemon", "service", "self-update"] },
|
|
208
|
-
blocked: firstBlock([
|
|
209
|
-
[!ctx.cliSelfUpdatable, "this hq CLI is not a global install, so it is updated with the project or app that owns it"],
|
|
210
|
-
]),
|
|
211
215
|
}),
|
|
212
216
|
];
|
|
213
217
|
case "jobs-reconcile": {
|
|
@@ -232,12 +236,33 @@ function planService(service, ctx) {
|
|
|
232
236
|
}),
|
|
233
237
|
];
|
|
234
238
|
}
|
|
239
|
+
case "codex-remote-control": {
|
|
240
|
+
if (ctx.platform === "win32")
|
|
241
|
+
return [unavailable(service, "not supported on Windows")];
|
|
242
|
+
const script = ctx.codexRunnerScript;
|
|
243
|
+
return [
|
|
244
|
+
base(service, {
|
|
245
|
+
command: {
|
|
246
|
+
type: "hq",
|
|
247
|
+
args: ["daemon", "service", "codex-remote-control", "--script", script ?? "", "--codex", ctx.codexPath ?? ""],
|
|
248
|
+
},
|
|
249
|
+
env: ctx.hqRoot ? { HQ_ROOT: ctx.hqRoot } : {},
|
|
250
|
+
blocked: firstBlock([
|
|
251
|
+
[!script, `this machine has no Codex remote-control runner (${CODEX_RUNNER_SCRIPT})`],
|
|
252
|
+
[!ctx.codexPath, "the codex CLI is not installed"],
|
|
253
|
+
[!ctx.tmuxPath, "tmux is not installed (Codex remote control runs inside tmux)"],
|
|
254
|
+
[!ctx.hqRoot, NO_HQ_ROOT],
|
|
255
|
+
]),
|
|
256
|
+
}),
|
|
257
|
+
];
|
|
258
|
+
}
|
|
235
259
|
case "remote-control": {
|
|
236
260
|
if (ctx.platform === "win32")
|
|
237
261
|
return [unavailable(service, "not supported on Windows yet")];
|
|
238
262
|
// Outposts keep the runner's session name and permission mode.
|
|
239
263
|
const outpost = ctx.machineType === "outpost";
|
|
240
|
-
const
|
|
264
|
+
const name = ctx.remoteControlName ?? (outpost ? "outpost-1" : ctx.hostName);
|
|
265
|
+
const args = ["daemon", "service", "remote-control", "--claude", ctx.claudePath ?? "", "--name", name];
|
|
241
266
|
if (outpost)
|
|
242
267
|
args.push("--permission-mode", "bypassPermissions");
|
|
243
268
|
return [
|
|
@@ -64,6 +64,13 @@ export interface DaemonUnitResult {
|
|
|
64
64
|
export interface InstallDeps {
|
|
65
65
|
run: RunCommand;
|
|
66
66
|
}
|
|
67
|
+
export declare const ROOT_REFUSAL = "hq daemon runs its services as the machine's login user, never as root.";
|
|
68
|
+
/**
|
|
69
|
+
* Why the daemon must not run here as root, or null when it may. Agent kits
|
|
70
|
+
* are the one exception: they run in containers where root is often the only
|
|
71
|
+
* account.
|
|
72
|
+
*/
|
|
73
|
+
export declare function daemonRootRefusal(isRoot: boolean, machineType: MachineType): string | null;
|
|
67
74
|
/** Boxes run the daemon as a system unit; everything else per user. */
|
|
68
75
|
export declare function installScopeFor(machineType: MachineType): InstallScope;
|
|
69
76
|
export declare function planDaemonUnit(host: DaemonHost, options: InstallOptions): DaemonUnitPlan;
|
|
@@ -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). */
|