@indigoai-us/hq-cli 5.162.0 → 5.163.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 +13 -0
- package/dist/command-catalog.generated.d.ts +188 -0
- package/dist/command-catalog.generated.js +235 -0
- package/dist/command-registration-plan.d.ts +7 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/agent-enroll.d.ts +6 -1
- package/dist/commands/agent-enroll.js +30 -3
- package/dist/commands/agents.js +15 -22
- package/dist/commands/bot.js +56 -6
- package/dist/commands/daemon.d.ts +53 -0
- package/dist/commands/daemon.js +554 -0
- package/dist/commands/lanes.js +7 -0
- package/dist/commands/meetings.js +18 -16
- package/dist/commands/mesh.js +9 -0
- package/dist/lib/daemon/catalog.d.ts +85 -0
- package/dist/lib/daemon/catalog.js +274 -0
- package/dist/lib/daemon/config.d.ts +26 -0
- package/dist/lib/daemon/config.js +85 -0
- package/dist/lib/daemon/handoff.d.ts +53 -0
- package/dist/lib/daemon/handoff.js +136 -0
- package/dist/lib/daemon/install.d.ts +76 -0
- package/dist/lib/daemon/install.js +299 -0
- package/dist/lib/daemon/legacy.d.ts +35 -0
- package/dist/lib/daemon/legacy.js +120 -0
- package/dist/lib/daemon/lock.d.ts +47 -0
- package/dist/lib/daemon/lock.js +138 -0
- package/dist/lib/daemon/machine-type.d.ts +48 -0
- package/dist/lib/daemon/machine-type.js +139 -0
- package/dist/lib/daemon/migrate.d.ts +61 -0
- package/dist/lib/daemon/migrate.js +238 -0
- package/dist/lib/daemon/paths.d.ts +22 -0
- package/dist/lib/daemon/paths.js +42 -0
- package/dist/lib/daemon/remote-control.d.ts +35 -0
- package/dist/lib/daemon/remote-control.js +47 -0
- package/dist/lib/daemon/requests.d.ts +24 -0
- package/dist/lib/daemon/requests.js +63 -0
- package/dist/lib/daemon/run.d.ts +41 -0
- package/dist/lib/daemon/run.js +319 -0
- package/dist/lib/daemon/supervisor.d.ts +87 -0
- package/dist/lib/daemon/supervisor.js +523 -0
- package/dist/lib/daemon/updater.d.ts +39 -0
- package/dist/lib/daemon/updater.js +103 -0
- package/dist/lib/flag-registry.d.ts +7 -1
- package/dist/lib/flag-registry.js +8 -0
- package/dist/lib/mesh/live/backoff.d.ts +2 -2
- package/dist/lib/mesh/live/backoff.js +16 -3
- package/dist/lib/mesh/live/daemon/run.d.ts +2 -2
- package/dist/lib/mesh/live/daemon/run.js +5 -2
- package/dist/lib/mesh/live/flush.d.ts +3 -2
- package/dist/lib/mesh/live/flush.js +34 -7
- package/dist/lib/mesh/live/session-events-client.d.ts +2 -0
- package/dist/lib/mesh/live/session-events-client.js +25 -0
- package/dist/lib/service-manager/systemd.d.ts +2 -2
- package/dist/lib/service-manager/systemd.js +1 -1
- package/dist/lib/service-manager/types.d.ts +2 -0
- package/dist/main.d.ts +6 -0
- package/dist/main.js +53 -13
- package/dist/utils/cognito-session.d.ts +4 -0
- package/dist/utils/cognito-session.js +11 -10
- package/dist/utils/plan-gate-error.d.ts +27 -13
- package/dist/utils/plan-gate-error.js +133 -58
- package/dist/utils/self-update.d.ts +9 -0
- package/dist/utils/self-update.js +15 -2
- package/package.json +1 -1
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-host daemon files. These describe this machine's processes, so they live
|
|
3
|
+
* under ~/.hq/daemon and never under a synced HQ folder. Service logs can hold
|
|
4
|
+
* private output, so the folders are 0700 and the files 0600.
|
|
5
|
+
*/
|
|
6
|
+
export interface DaemonPaths {
|
|
7
|
+
root: string;
|
|
8
|
+
configPath: string;
|
|
9
|
+
statePath: string;
|
|
10
|
+
lockPath: string;
|
|
11
|
+
logsDir: string;
|
|
12
|
+
requestsDir: string;
|
|
13
|
+
supervisorLogPath: string;
|
|
14
|
+
unitLogPath: (unitId: string) => string;
|
|
15
|
+
}
|
|
16
|
+
export declare function daemonPaths(home?: string, env?: NodeJS.ProcessEnv): DaemonPaths;
|
|
17
|
+
/** Create `dir` (and any missing parents) readable only by its owner, and tighten it if it already exists. */
|
|
18
|
+
export declare function ensurePrivateDir(dir: string): void;
|
|
19
|
+
/** Open a log file for appending, creating it (and its folder) owner-only. */
|
|
20
|
+
export declare function openPrivateAppend(file: string): number;
|
|
21
|
+
export declare function appendPrivate(file: string, text: string): void;
|
|
22
|
+
//# sourceMappingURL=paths.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-host daemon files. These describe this machine's processes, so they live
|
|
3
|
+
* under ~/.hq/daemon and never under a synced HQ folder. Service logs can hold
|
|
4
|
+
* private output, so the folders are 0700 and the files 0600.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
export function daemonPaths(home = os.homedir(), env = process.env) {
|
|
10
|
+
const root = env.HQ_DAEMON_DIR?.trim() || path.join(home, ".hq", "daemon");
|
|
11
|
+
const logsDir = path.join(root, "logs");
|
|
12
|
+
return {
|
|
13
|
+
root,
|
|
14
|
+
configPath: path.join(root, "config.json"),
|
|
15
|
+
statePath: path.join(root, "state.json"),
|
|
16
|
+
lockPath: path.join(root, "daemon.pid"),
|
|
17
|
+
logsDir,
|
|
18
|
+
requestsDir: path.join(root, "requests"),
|
|
19
|
+
supervisorLogPath: path.join(logsDir, "daemon.log"),
|
|
20
|
+
unitLogPath: (unitId) => path.join(logsDir, `${unitId.replace(/[^A-Za-z0-9_.-]/g, "-")}.log`),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Create `dir` (and any missing parents) readable only by its owner, and tighten it if it already exists. */
|
|
24
|
+
export function ensurePrivateDir(dir) {
|
|
25
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
26
|
+
fs.chmodSync(dir, 0o700);
|
|
27
|
+
}
|
|
28
|
+
/** Open a log file for appending, creating it (and its folder) owner-only. */
|
|
29
|
+
export function openPrivateAppend(file) {
|
|
30
|
+
ensurePrivateDir(path.dirname(file));
|
|
31
|
+
return fs.openSync(file, "a", 0o600);
|
|
32
|
+
}
|
|
33
|
+
export function appendPrivate(file, text) {
|
|
34
|
+
const fd = openPrivateAppend(file);
|
|
35
|
+
try {
|
|
36
|
+
fs.writeSync(fd, text);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
fs.closeSync(fd);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=paths.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `remote-control` service host (`hq daemon service remote-control`).
|
|
3
|
+
*
|
|
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:
|
|
7
|
+
*
|
|
8
|
+
* - the session ends -> the host exits 1 and the daemon restarts it with
|
|
9
|
+
* 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.
|
|
11
|
+
*
|
|
12
|
+
* The tmux server is a private socket (`-L hq-daemon`), so it never touches a
|
|
13
|
+
* user's own tmux sessions.
|
|
14
|
+
*/
|
|
15
|
+
import type { RunCommand } from "../service-manager/launchd.js";
|
|
16
|
+
export declare const TMUX_SOCKET = "hq-daemon";
|
|
17
|
+
export declare const TMUX_SESSION = "remote-control";
|
|
18
|
+
export declare function remoteControlArgs(opts: {
|
|
19
|
+
name: string;
|
|
20
|
+
permissionMode?: string;
|
|
21
|
+
}): string[];
|
|
22
|
+
export interface RemoteControlHostDeps {
|
|
23
|
+
tmux: string;
|
|
24
|
+
claude: string;
|
|
25
|
+
hqRoot: string;
|
|
26
|
+
name: string;
|
|
27
|
+
permissionMode?: string;
|
|
28
|
+
run: RunCommand;
|
|
29
|
+
sleep: (ms: number) => Promise<void>;
|
|
30
|
+
pollMs: number;
|
|
31
|
+
signal: AbortSignal;
|
|
32
|
+
log: (line: string) => void;
|
|
33
|
+
}
|
|
34
|
+
export declare function runRemoteControlHost(deps: RemoteControlHostDeps): Promise<number>;
|
|
35
|
+
//# sourceMappingURL=remote-control.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `remote-control` service host (`hq daemon service remote-control`).
|
|
3
|
+
*
|
|
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:
|
|
7
|
+
*
|
|
8
|
+
* - the session ends -> the host exits 1 and the daemon restarts it with
|
|
9
|
+
* 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.
|
|
11
|
+
*
|
|
12
|
+
* The tmux server is a private socket (`-L hq-daemon`), so it never touches a
|
|
13
|
+
* user's own tmux sessions.
|
|
14
|
+
*/
|
|
15
|
+
export const TMUX_SOCKET = "hq-daemon";
|
|
16
|
+
export const TMUX_SESSION = "remote-control";
|
|
17
|
+
export function remoteControlArgs(opts) {
|
|
18
|
+
const args = ["remote-control", "--name", opts.name, "--spawn=same-dir"];
|
|
19
|
+
if (opts.permissionMode)
|
|
20
|
+
args.push("--permission-mode", opts.permissionMode);
|
|
21
|
+
return args;
|
|
22
|
+
}
|
|
23
|
+
export async function runRemoteControlHost(deps) {
|
|
24
|
+
const tmux = (...args) => deps.run(deps.tmux, ["-L", TMUX_SOCKET, ...args]);
|
|
25
|
+
const killSession = () => tmux("kill-session", "-t", TMUX_SESSION);
|
|
26
|
+
// A session left by an earlier host (for example after a crash) is replaced.
|
|
27
|
+
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 }));
|
|
29
|
+
if (started.status !== 0) {
|
|
30
|
+
deps.log(`could not start the tmux session: ${started.stderr.trim() || `tmux exited ${started.status}`}`);
|
|
31
|
+
return 1;
|
|
32
|
+
}
|
|
33
|
+
deps.log(`claude remote-control started as "${deps.name}" in ${deps.hqRoot}`);
|
|
34
|
+
for (;;) {
|
|
35
|
+
if (deps.signal.aborted) {
|
|
36
|
+
killSession();
|
|
37
|
+
deps.log("stopped remote control");
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
if (tmux("has-session", "-t", TMUX_SESSION).status !== 0) {
|
|
41
|
+
deps.log("the remote-control session ended");
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
await deps.sleep(deps.pollMs);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=remote-control.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control requests from `hq daemon restart|trigger`, `hq bot start|stop` and the
|
|
3
|
+
* updater service to the running supervisor.
|
|
4
|
+
* Each request is one JSON file in ~/.hq/daemon/requests/; the supervisor
|
|
5
|
+
* polls the folder, applies requests oldest first, and deletes them. Files
|
|
6
|
+
* keep this working the same way on macOS, Linux and Windows.
|
|
7
|
+
*/
|
|
8
|
+
export type ControlRequest = {
|
|
9
|
+
action: "restart";
|
|
10
|
+
unit?: string;
|
|
11
|
+
} | {
|
|
12
|
+
action: "trigger";
|
|
13
|
+
unit: string;
|
|
14
|
+
} | {
|
|
15
|
+
action: "stop";
|
|
16
|
+
unit: string;
|
|
17
|
+
}
|
|
18
|
+
/** Restart the whole daemon, for example after the updater installed a new CLI. */
|
|
19
|
+
| {
|
|
20
|
+
action: "restart-daemon";
|
|
21
|
+
};
|
|
22
|
+
export declare function writeControlRequest(dir: string, request: ControlRequest, now?: () => number): string;
|
|
23
|
+
export declare function takeControlRequests(dir: string): ControlRequest[];
|
|
24
|
+
//# sourceMappingURL=requests.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control requests from `hq daemon restart|trigger`, `hq bot start|stop` and the
|
|
3
|
+
* updater service to the running supervisor.
|
|
4
|
+
* Each request is one JSON file in ~/.hq/daemon/requests/; the supervisor
|
|
5
|
+
* polls the folder, applies requests oldest first, and deletes them. Files
|
|
6
|
+
* keep this working the same way on macOS, Linux and Windows.
|
|
7
|
+
*/
|
|
8
|
+
import * as crypto from "node:crypto";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { ensurePrivateDir } from "./paths.js";
|
|
12
|
+
function isControlRequest(value) {
|
|
13
|
+
if (!value || typeof value !== "object")
|
|
14
|
+
return false;
|
|
15
|
+
const v = value;
|
|
16
|
+
if (v.action === "restart")
|
|
17
|
+
return v.unit === undefined || typeof v.unit === "string";
|
|
18
|
+
if (v.action === "trigger" || v.action === "stop")
|
|
19
|
+
return typeof v.unit === "string";
|
|
20
|
+
if (v.action === "restart-daemon")
|
|
21
|
+
return true;
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
// Orders requests written by one process within the same millisecond; the
|
|
25
|
+
// random part only keeps names from different processes apart.
|
|
26
|
+
let sequence = 0;
|
|
27
|
+
export function writeControlRequest(dir, request, now = Date.now) {
|
|
28
|
+
ensurePrivateDir(dir);
|
|
29
|
+
sequence = (sequence + 1) % 1_000_000;
|
|
30
|
+
const name = `${String(now()).padStart(15, "0")}-${String(sequence).padStart(6, "0")}-${crypto.randomBytes(4).toString("hex")}.json`;
|
|
31
|
+
const file = path.join(dir, name);
|
|
32
|
+
const tmp = `${file}.tmp`;
|
|
33
|
+
fs.writeFileSync(tmp, JSON.stringify(request), { mode: 0o600 });
|
|
34
|
+
fs.renameSync(tmp, file);
|
|
35
|
+
return file;
|
|
36
|
+
}
|
|
37
|
+
export function takeControlRequests(dir) {
|
|
38
|
+
let names;
|
|
39
|
+
try {
|
|
40
|
+
names = fs.readdirSync(dir).filter((n) => n.endsWith(".json")).sort();
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
if (err.code === "ENOENT")
|
|
44
|
+
return [];
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
const requests = [];
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
const file = path.join(dir, name);
|
|
50
|
+
let parsed;
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
parsed = null;
|
|
56
|
+
}
|
|
57
|
+
fs.rmSync(file, { force: true });
|
|
58
|
+
if (isControlRequest(parsed))
|
|
59
|
+
requests.push(parsed);
|
|
60
|
+
}
|
|
61
|
+
return requests;
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=requests.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq daemon run` — wires the supervisor to real processes, files and signals.
|
|
3
|
+
*
|
|
4
|
+
* Each reconcile re-reads the config and re-probes the host (bots, sign-in,
|
|
5
|
+
* legacy units, the desktop app), so enabling a service, creating a bot, or
|
|
6
|
+
* signing in takes effect without restarting the daemon.
|
|
7
|
+
*/
|
|
8
|
+
import { type UnitPlan } from "./catalog.js";
|
|
9
|
+
import { type LegacyUnit } from "./legacy.js";
|
|
10
|
+
export declare const LOG_ROTATE_BYTES: number;
|
|
11
|
+
/** Exit code for "restart me": non-zero so every service manager relaunches (Windows tasks only restart on failure). */
|
|
12
|
+
export declare const RESTART_EXIT_CODE = 75;
|
|
13
|
+
export interface RunDaemonOptions {
|
|
14
|
+
foreground?: boolean;
|
|
15
|
+
/** Started by the OS unit, which relaunches the daemon whenever it exits. */
|
|
16
|
+
managed?: boolean;
|
|
17
|
+
home?: string;
|
|
18
|
+
env?: NodeJS.ProcessEnv;
|
|
19
|
+
nodeBinary: string;
|
|
20
|
+
hqBinary: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function resolveSyncRunnerPath(): string | null;
|
|
23
|
+
export declare function listLocalBots(root?: string): string[];
|
|
24
|
+
export declare function windowsTaskExists(taskName: string): boolean;
|
|
25
|
+
export declare function probeLegacyUnits(platform: NodeJS.Platform, home: string): LegacyUnit[];
|
|
26
|
+
/** Absolute path of `name` on PATH, or null. */
|
|
27
|
+
export declare function findOnPath(name: string, env?: NodeJS.ProcessEnv): string | null;
|
|
28
|
+
/** This CLI is a global package install the updater can replace (not a dev checkout or a project dependency). */
|
|
29
|
+
export declare function cliSelfUpdatable(): boolean;
|
|
30
|
+
/** The HQ desktop app process is running here (it runs its own sync). */
|
|
31
|
+
export declare function desktopAppRunning(platform: NodeJS.Platform): boolean;
|
|
32
|
+
/** Copy-and-truncate, so children appending with O_APPEND keep writing to the live file. */
|
|
33
|
+
export declare function rotateLogs(logsDir: string, maxBytes?: number): number;
|
|
34
|
+
/**
|
|
35
|
+
* Environment for one service process. The CLI's own update check is off:
|
|
36
|
+
* a child that updates itself exits 0 and asks to be rerun, which would leave
|
|
37
|
+
* an on-failure unit down. The daemon's updater handles CLI updates.
|
|
38
|
+
*/
|
|
39
|
+
export declare function unitProcessEnv(base: NodeJS.ProcessEnv, unit: UnitPlan): NodeJS.ProcessEnv;
|
|
40
|
+
export declare function runDaemon(opts: RunDaemonOptions): Promise<number>;
|
|
41
|
+
//# sourceMappingURL=run.d.ts.map
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq daemon run` — wires the supervisor to real processes, files and signals.
|
|
3
|
+
*
|
|
4
|
+
* Each reconcile re-reads the config and re-probes the host (bots, sign-in,
|
|
5
|
+
* legacy units, the desktop app), so enabling a service, creating a bot, or
|
|
6
|
+
* signing in takes effect without restarting the daemon.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
14
|
+
import { isLocalDependencyInstall, resolveRunningInstall } from "../../utils/version-gate.js";
|
|
15
|
+
import { agentKitPaths } from "../agent-kit/paths.js";
|
|
16
|
+
import { readBotConfig } from "../bot/config.js";
|
|
17
|
+
import { botsRoot } from "../bot/paths.js";
|
|
18
|
+
import { enabledServices, planUnits } from "./catalog.js";
|
|
19
|
+
import { readDaemonConfig } from "./config.js";
|
|
20
|
+
import { findLegacyUnits, legacyOwnerLookup } from "./legacy.js";
|
|
21
|
+
import { acquireDaemonLock, releaseDaemonLock } from "./lock.js";
|
|
22
|
+
import { detectMachineType } from "./machine-type.js";
|
|
23
|
+
import { appendPrivate, daemonPaths, ensurePrivateDir, openPrivateAppend } from "./paths.js";
|
|
24
|
+
import { takeControlRequests } from "./requests.js";
|
|
25
|
+
import { STOP_ESCALATION_MS, createSupervisor } from "./supervisor.js";
|
|
26
|
+
export const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
|
|
27
|
+
/** Exit code for "restart me": non-zero so every service manager relaunches (Windows tasks only restart on failure). */
|
|
28
|
+
export const RESTART_EXIT_CODE = 75;
|
|
29
|
+
const LEGACY_PROBE_TTL_MS = 60_000;
|
|
30
|
+
export function resolveSyncRunnerPath() {
|
|
31
|
+
try {
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const pkg = require.resolve("@indigoai-us/hq-cloud/package.json");
|
|
34
|
+
const runner = path.join(path.dirname(pkg), "dist", "bin", "sync-runner.js");
|
|
35
|
+
return fs.existsSync(runner) ? runner : null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function listLocalBots(root = botsRoot()) {
|
|
42
|
+
if (!fs.existsSync(root))
|
|
43
|
+
return [];
|
|
44
|
+
return fs
|
|
45
|
+
.readdirSync(root, { withFileTypes: true })
|
|
46
|
+
.filter((d) => d.isDirectory() && readBotConfig(path.join(root, d.name)) !== null)
|
|
47
|
+
.map((d) => d.name)
|
|
48
|
+
.sort();
|
|
49
|
+
}
|
|
50
|
+
export function windowsTaskExists(taskName) {
|
|
51
|
+
const r = spawnSync("schtasks.exe", ["/Query", "/TN", taskName], { encoding: "utf8", windowsHide: true });
|
|
52
|
+
return r.status === 0;
|
|
53
|
+
}
|
|
54
|
+
export function probeLegacyUnits(platform, home) {
|
|
55
|
+
return findLegacyUnits({
|
|
56
|
+
platform,
|
|
57
|
+
home,
|
|
58
|
+
existsSync: fs.existsSync,
|
|
59
|
+
listDir: (dir) => fs.readdirSync(dir),
|
|
60
|
+
windowsTaskExists,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** Absolute path of `name` on PATH, or null. */
|
|
64
|
+
export function findOnPath(name, env = process.env) {
|
|
65
|
+
const dirs = (env.PATH ?? "").split(path.delimiter).filter(Boolean);
|
|
66
|
+
const exts = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
67
|
+
for (const dir of dirs) {
|
|
68
|
+
for (const ext of exts) {
|
|
69
|
+
const candidate = path.join(dir, `${name}${ext}`);
|
|
70
|
+
try {
|
|
71
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
72
|
+
if (fs.statSync(candidate).isFile())
|
|
73
|
+
return candidate;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// not here
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
/** This CLI is a global package install the updater can replace (not a dev checkout or a project dependency). */
|
|
83
|
+
export function cliSelfUpdatable() {
|
|
84
|
+
const install = resolveRunningInstall();
|
|
85
|
+
if (!install.packageRoot)
|
|
86
|
+
return false;
|
|
87
|
+
const inNodeModules = install.packageRoot.split(/[\\/]/).includes("node_modules");
|
|
88
|
+
return inNodeModules && !isLocalDependencyInstall(install);
|
|
89
|
+
}
|
|
90
|
+
/** The HQ desktop app process is running here (it runs its own sync). */
|
|
91
|
+
export function desktopAppRunning(platform) {
|
|
92
|
+
if (platform === "win32") {
|
|
93
|
+
const r = spawnSync("tasklist.exe", ["/FI", "IMAGENAME eq hq-sync-menubar.exe", "/NH"], {
|
|
94
|
+
encoding: "utf8",
|
|
95
|
+
windowsHide: true,
|
|
96
|
+
});
|
|
97
|
+
return r.status === 0 && /hq-sync-menubar\.exe/i.test(r.stdout);
|
|
98
|
+
}
|
|
99
|
+
const r = spawnSync("pgrep", ["-x", "hq-sync-menubar"], { encoding: "utf8" });
|
|
100
|
+
return r.status === 0;
|
|
101
|
+
}
|
|
102
|
+
function jobsReconcileScript(hqRoot) {
|
|
103
|
+
if (!hqRoot)
|
|
104
|
+
return null;
|
|
105
|
+
const script = path.join(hqRoot, "core", "scripts", "outpost-jobs-reconcile.sh");
|
|
106
|
+
return fs.existsSync(script) ? script : null;
|
|
107
|
+
}
|
|
108
|
+
function signedIn(env, home, log) {
|
|
109
|
+
if (fs.existsSync(agentKitPaths(home, env).machineCredsPath))
|
|
110
|
+
return true;
|
|
111
|
+
try {
|
|
112
|
+
return loadCachedTokens() !== null;
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
log("warn", `could not read the cached HQ sign-in; treating this machine as signed out: ${err.message}`);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function writeJsonAtomic(file, value) {
|
|
120
|
+
ensurePrivateDir(path.dirname(file));
|
|
121
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
122
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
123
|
+
fs.renameSync(tmp, file);
|
|
124
|
+
}
|
|
125
|
+
/** Copy-and-truncate, so children appending with O_APPEND keep writing to the live file. */
|
|
126
|
+
export function rotateLogs(logsDir, maxBytes = LOG_ROTATE_BYTES) {
|
|
127
|
+
let rotated = 0;
|
|
128
|
+
let names;
|
|
129
|
+
try {
|
|
130
|
+
names = fs.readdirSync(logsDir);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
if (err.code === "ENOENT")
|
|
134
|
+
return 0;
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
for (const name of names) {
|
|
138
|
+
if (!name.endsWith(".log"))
|
|
139
|
+
continue;
|
|
140
|
+
const file = path.join(logsDir, name);
|
|
141
|
+
if (fs.statSync(file).size <= maxBytes)
|
|
142
|
+
continue;
|
|
143
|
+
fs.copyFileSync(file, `${file}.1`);
|
|
144
|
+
fs.truncateSync(file, 0);
|
|
145
|
+
rotated++;
|
|
146
|
+
}
|
|
147
|
+
return rotated;
|
|
148
|
+
}
|
|
149
|
+
function makeLogger(paths, foreground) {
|
|
150
|
+
ensurePrivateDir(paths.logsDir);
|
|
151
|
+
return (level, msg) => {
|
|
152
|
+
const line = `${new Date().toISOString()} ${level.padEnd(5)} ${msg}\n`;
|
|
153
|
+
try {
|
|
154
|
+
appendPrivate(paths.supervisorLogPath, line);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
process.stderr.write(`hq daemon: could not write log: ${err.message}\n`);
|
|
158
|
+
}
|
|
159
|
+
if (foreground)
|
|
160
|
+
(level === "info" ? process.stdout : process.stderr).write(line);
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Environment for one service process. The CLI's own update check is off:
|
|
165
|
+
* a child that updates itself exits 0 and asks to be rerun, which would leave
|
|
166
|
+
* an on-failure unit down. The daemon's updater handles CLI updates.
|
|
167
|
+
*/
|
|
168
|
+
export function unitProcessEnv(base, unit) {
|
|
169
|
+
return { ...base, ...unit.env, HQ_DAEMON_UNIT: unit.id, HQ_NO_UPDATE_CHECK: "1" };
|
|
170
|
+
}
|
|
171
|
+
export async function runDaemon(opts) {
|
|
172
|
+
const env = opts.env ?? process.env;
|
|
173
|
+
const home = opts.home ?? os.homedir();
|
|
174
|
+
const platform = process.platform;
|
|
175
|
+
const paths = daemonPaths(home, env);
|
|
176
|
+
const log = makeLogger(paths, opts.foreground === true);
|
|
177
|
+
const lock = acquireDaemonLock(paths.lockPath);
|
|
178
|
+
if (!lock.ok) {
|
|
179
|
+
process.stderr.write(lock.owner ? `hq daemon is already running (pid ${lock.owner.pid}).\n` : "another hq daemon is starting up.\n");
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
182
|
+
const initial = readDaemonConfig(paths.configPath);
|
|
183
|
+
const detected = detectMachineType(undefined, {
|
|
184
|
+
platform,
|
|
185
|
+
env,
|
|
186
|
+
home,
|
|
187
|
+
savedType: initial.machineType,
|
|
188
|
+
existsSync: fs.existsSync,
|
|
189
|
+
readFileSync: (p) => fs.readFileSync(p, "utf8"),
|
|
190
|
+
});
|
|
191
|
+
const machineType = detected.type;
|
|
192
|
+
for (const w of detected.warnings ?? [])
|
|
193
|
+
log("warn", w);
|
|
194
|
+
log("info", `starting hq daemon (pid ${process.pid}) as ${machineType} (${detected.detail})`);
|
|
195
|
+
const syncRunnerPath = resolveSyncRunnerPath();
|
|
196
|
+
if (!syncRunnerPath)
|
|
197
|
+
log("warn", "the hq-cloud sync runner was not found in this CLI install; sync is unavailable");
|
|
198
|
+
const updatable = cliSelfUpdatable();
|
|
199
|
+
const hostName = os.hostname().split(".")[0] || "hq-host";
|
|
200
|
+
let legacyCache = null;
|
|
201
|
+
const legacyUnits = () => {
|
|
202
|
+
if (!legacyCache || Date.now() - legacyCache.at > LEGACY_PROBE_TTL_MS) {
|
|
203
|
+
legacyCache = { at: Date.now(), units: probeLegacyUnits(platform, home) };
|
|
204
|
+
}
|
|
205
|
+
return legacyCache.units;
|
|
206
|
+
};
|
|
207
|
+
const plan = () => {
|
|
208
|
+
const config = readDaemonConfig(paths.configPath);
|
|
209
|
+
const legacy = legacyUnits();
|
|
210
|
+
const configured = config.hqRoot ?? env.HQ_ROOT?.trim() ?? undefined;
|
|
211
|
+
const hqRoot = configured && fs.existsSync(configured) ? configured : undefined;
|
|
212
|
+
return planUnits(enabledServices(machineType, config), {
|
|
213
|
+
machineType,
|
|
214
|
+
platform,
|
|
215
|
+
home,
|
|
216
|
+
hqRoot,
|
|
217
|
+
syncRunnerPath,
|
|
218
|
+
signedIn: signedIn(env, home, log),
|
|
219
|
+
kitConfigured: fs.existsSync(agentKitPaths(home, env).kitConfigPath),
|
|
220
|
+
desktopAppManagesSync: machineType === "desktop" &&
|
|
221
|
+
(legacy.some((u) => u.service === "sync" && !u.migratable) || desktopAppRunning(platform)),
|
|
222
|
+
bots: listLocalBots(),
|
|
223
|
+
legacyOwner: legacyOwnerLookup(legacy),
|
|
224
|
+
cliSelfUpdatable: updatable,
|
|
225
|
+
jobsReconcileScript: jobsReconcileScript(hqRoot),
|
|
226
|
+
claudePath: findOnPath("claude", env),
|
|
227
|
+
tmuxPath: findOnPath("tmux", env),
|
|
228
|
+
hostName,
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
const spawnUnit = (unit) => {
|
|
232
|
+
const command = unit.command;
|
|
233
|
+
if (!command || command.type === "internal")
|
|
234
|
+
throw new Error(`${unit.id} has no process to start`);
|
|
235
|
+
const [file, args] = command.type === "hq"
|
|
236
|
+
? [opts.nodeBinary, [opts.hqBinary, ...command.args]]
|
|
237
|
+
: command.type === "node"
|
|
238
|
+
? [opts.nodeBinary, [command.script, ...command.args]]
|
|
239
|
+
: [command.file, command.args];
|
|
240
|
+
const fd = openPrivateAppend(paths.unitLogPath(unit.id));
|
|
241
|
+
try {
|
|
242
|
+
const cwd = unit.env.HQ_ROOT && fs.existsSync(unit.env.HQ_ROOT) ? unit.env.HQ_ROOT : home;
|
|
243
|
+
const child = spawn(file, args, {
|
|
244
|
+
cwd,
|
|
245
|
+
env: unitProcessEnv(env, unit),
|
|
246
|
+
stdio: ["ignore", fd, fd],
|
|
247
|
+
windowsHide: true,
|
|
248
|
+
});
|
|
249
|
+
return child;
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
fs.closeSync(fd);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
// Set once the shutdown path exists below; a restart request before then is dropped.
|
|
256
|
+
let restartDaemon = () => { };
|
|
257
|
+
const supervisor = createSupervisor({
|
|
258
|
+
machineType,
|
|
259
|
+
pid: process.pid,
|
|
260
|
+
plan,
|
|
261
|
+
spawn: spawnUnit,
|
|
262
|
+
runInternal: async (task) => {
|
|
263
|
+
if (task !== "rotate-logs")
|
|
264
|
+
throw new Error(`unknown internal task ${task}`);
|
|
265
|
+
const n = rotateLogs(paths.logsDir);
|
|
266
|
+
if (n > 0)
|
|
267
|
+
log("info", `rotated ${n} log file(s)`);
|
|
268
|
+
return true;
|
|
269
|
+
},
|
|
270
|
+
takeRequests: () => takeControlRequests(paths.requestsDir),
|
|
271
|
+
restartDaemon: () => restartDaemon(),
|
|
272
|
+
writeState: (state) => writeJsonAtomic(paths.statePath, state),
|
|
273
|
+
log,
|
|
274
|
+
});
|
|
275
|
+
return await new Promise((resolve) => {
|
|
276
|
+
let stopping = false;
|
|
277
|
+
const shutdown = (why, exitCode = 0) => {
|
|
278
|
+
if (stopping)
|
|
279
|
+
return;
|
|
280
|
+
stopping = true;
|
|
281
|
+
log("info", `${why}; stopping services`);
|
|
282
|
+
// The supervisor escalates to SIGKILL after STOP_ESCALATION_MS; the extra
|
|
283
|
+
// margin only covers a child that survives even that.
|
|
284
|
+
let capTimer;
|
|
285
|
+
const cap = new Promise((r) => {
|
|
286
|
+
capTimer = setTimeout(() => r("timeout"), STOP_ESCALATION_MS + 5_000);
|
|
287
|
+
});
|
|
288
|
+
void Promise.race([supervisor.stop(), cap]).then((result) => {
|
|
289
|
+
clearTimeout(capTimer);
|
|
290
|
+
if (result === "timeout")
|
|
291
|
+
log("error", "some services did not exit after SIGKILL; releasing the lock anyway");
|
|
292
|
+
else
|
|
293
|
+
log("info", "all services stopped");
|
|
294
|
+
releaseDaemonLock(paths.lockPath);
|
|
295
|
+
resolve(exitCode);
|
|
296
|
+
});
|
|
297
|
+
};
|
|
298
|
+
restartDaemon = () => {
|
|
299
|
+
if (!opts.managed) {
|
|
300
|
+
log("warn", "a restart was requested, but no service manager would start this daemon again; restart it yourself");
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
shutdown("restarting onto the updated CLI", RESTART_EXIT_CODE);
|
|
304
|
+
};
|
|
305
|
+
process.on("SIGTERM", () => shutdown("received SIGTERM"));
|
|
306
|
+
process.on("SIGINT", () => shutdown("received SIGINT"));
|
|
307
|
+
if (platform !== "win32")
|
|
308
|
+
process.on("SIGHUP", () => supervisor.reconcile());
|
|
309
|
+
try {
|
|
310
|
+
supervisor.start();
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
log("error", `supervisor failed to start: ${err.message}`);
|
|
314
|
+
releaseDaemonLock(paths.lockPath);
|
|
315
|
+
resolve(1);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
//# sourceMappingURL=run.js.map
|