@indigoai-us/hq-cli 5.285.0 → 5.286.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 CHANGED
@@ -2,8 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.286.0] — 2026-09-29
6
+
7
+ ### Changed
8
+
9
+ - On Linux the daemon's sync runner writes its protocol output to a named
10
+ pipe, `~/.hq/daemon/protocol/sync.fifo`, instead of an anonymous pipe. With
11
+ `daemon.supervisor-handoff` on, an outpost daemon restart now hands sync to
12
+ the next daemon like the other services instead of stopping and restarting
13
+ it, and output the old daemon had not read reaches the new one. Other
14
+ platforms keep the anonymous pipe.
15
+
5
16
  ## [5.285.0] — 2026-09-29
6
17
 
18
+ - Route eligible Linux CI jobs onto ARM runners while retaining existing test and platform gates.
19
+
7
20
  ## [5.284.0] — 2026-09-29
8
21
  - The existing Work Mesh MQTT subscriber can signal agent inbox wakes through a private local marker without persisting MQTT message contents.
9
22
  - Add `hq agents jobs resume <uid> <jobId>` to re-enable paused agent job schedules.
@@ -14,6 +27,7 @@
14
27
  capacity probes, or mirror snapshots to other Outposts. Two Outposts that
15
28
  each held a lane placed on the other ran `hq lanes list --json` on each other
16
29
  without end, which filled both boxes with SSM commands.
30
+ - `hq doctor` labels the detected agent runtime value as `Agent platform` in human output. The JSON platform field and schema are unchanged.
17
31
 
18
32
  ### Fixed
19
33
 
@@ -12,6 +12,8 @@ export interface DaemonPaths {
12
12
  requestsDir: string;
13
13
  supervisorLogPath: string;
14
14
  unitLogPath: (unitId: string) => string;
15
+ /** The FIFO a unit that speaks the sync protocol writes its stdout to (see protocol-tail.ts); kept out of logs/ so nothing reading logs drains it. */
16
+ unitProtocolPath: (unitId: string) => string;
15
17
  }
16
18
  export declare function daemonPaths(home?: string, env?: NodeJS.ProcessEnv): DaemonPaths;
17
19
  /** Create `dir` (and any missing parents) readable only by its owner, and tighten it if it already exists. */
@@ -18,6 +18,7 @@ export function daemonPaths(home = os.homedir(), env = process.env) {
18
18
  requestsDir: path.join(root, "requests"),
19
19
  supervisorLogPath: path.join(logsDir, "daemon.log"),
20
20
  unitLogPath: (unitId) => path.join(logsDir, `${unitId.replace(/[^A-Za-z0-9_.-]/g, "-")}.log`),
21
+ unitProtocolPath: (unitId) => path.join(root, "protocol", `${unitId.replace(/[^A-Za-z0-9_.-]/g, "-")}.fifo`),
21
22
  };
22
23
  }
23
24
  /** Create `dir` (and any missing parents) readable only by its owner, and tighten it if it already exists. */
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A service's protocol output read from a named pipe instead of an anonymous
3
+ * one.
4
+ *
5
+ * An anonymous pipe loses its reader when the daemon exits, so a service
6
+ * writing to one cannot be handed to the next daemon. Here the service's
7
+ * stdout is a FIFO it holds open for reading and writing, so it always has a
8
+ * reader and never gets EPIPE. Each daemon polls the FIFO without blocking and
9
+ * feeds what it reads to the supervisor as the child's stdout. On a handoff
10
+ * the daemon stops reading without draining, so output it has not read stays
11
+ * in the kernel buffer for the next daemon, and nothing accumulates on disk.
12
+ */
13
+ export interface ProtocolTail {
14
+ /** "data" carries each chunk read; "end" fires once, after close(). */
15
+ on(event: "data" | "end", handler: (chunk?: Buffer | string) => void): ProtocolTail;
16
+ /** The service exited: read what is left, emit "end" and stop polling. */
17
+ close(): void;
18
+ /** Handing over: stop polling without reading, leaving the rest for the next daemon. */
19
+ detach(): void;
20
+ }
21
+ /** Make `file` a FIFO, replacing anything else found there. */
22
+ export declare function ensureProtocolFifo(file: string): void;
23
+ /**
24
+ * The service's stdout. Opened for reading and writing, so the FIFO keeps a
25
+ * reader while no daemon has it open and the service's writes never fail.
26
+ */
27
+ export declare function openProtocolWriter(file: string): number;
28
+ export declare function createProtocolTail(file: string, opts?: {
29
+ pollMs?: number;
30
+ setInterval?: (fn: () => void, ms: number) => unknown;
31
+ clearInterval?: (handle: unknown) => void;
32
+ }): ProtocolTail;
33
+ //# sourceMappingURL=protocol-tail.d.ts.map
@@ -0,0 +1,131 @@
1
+ /**
2
+ * A service's protocol output read from a named pipe instead of an anonymous
3
+ * one.
4
+ *
5
+ * An anonymous pipe loses its reader when the daemon exits, so a service
6
+ * writing to one cannot be handed to the next daemon. Here the service's
7
+ * stdout is a FIFO it holds open for reading and writing, so it always has a
8
+ * reader and never gets EPIPE. Each daemon polls the FIFO without blocking and
9
+ * feeds what it reads to the supervisor as the child's stdout. On a handoff
10
+ * the daemon stops reading without draining, so output it has not read stays
11
+ * in the kernel buffer for the next daemon, and nothing accumulates on disk.
12
+ */
13
+ import { spawnSync } from "node:child_process";
14
+ import { EventEmitter } from "node:events";
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import { ensurePrivateDir } from "./paths.js";
18
+ const POLL_MS = 1_000;
19
+ const READ_CHUNK_BYTES = 64 * 1024;
20
+ /** Make `file` a FIFO, replacing anything else found there. */
21
+ export function ensureProtocolFifo(file) {
22
+ ensurePrivateDir(path.dirname(file));
23
+ try {
24
+ if (fs.lstatSync(file).isFIFO())
25
+ return;
26
+ fs.rmSync(file, { force: true });
27
+ }
28
+ catch {
29
+ // Missing: created below.
30
+ }
31
+ const r = spawnSync("mkfifo", ["-m", "600", file], { encoding: "utf8" });
32
+ if (r.error)
33
+ throw r.error;
34
+ if (r.status !== 0)
35
+ throw new Error(`mkfifo ${file} failed: ${(r.stderr ?? "").trim() || `exit ${r.status}`}`);
36
+ }
37
+ /**
38
+ * The service's stdout. Opened for reading and writing, so the FIFO keeps a
39
+ * reader while no daemon has it open and the service's writes never fail.
40
+ */
41
+ export function openProtocolWriter(file) {
42
+ return fs.openSync(file, fs.constants.O_RDWR);
43
+ }
44
+ export function createProtocolTail(file, opts = {}) {
45
+ const events = new EventEmitter();
46
+ const setI = opts.setInterval ?? ((fn, ms) => setInterval(fn, ms));
47
+ const clearI = opts.clearInterval ?? ((h) => clearInterval(h));
48
+ let fd = null;
49
+ let done = false;
50
+ const open = () => {
51
+ if (fd !== null)
52
+ return fd;
53
+ try {
54
+ fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
55
+ }
56
+ catch {
57
+ fd = null;
58
+ }
59
+ return fd;
60
+ };
61
+ const read = () => {
62
+ const handle = open();
63
+ if (handle === null)
64
+ return;
65
+ const buf = Buffer.alloc(READ_CHUNK_BYTES);
66
+ for (;;) {
67
+ let n;
68
+ try {
69
+ n = fs.readSync(handle, buf, 0, buf.length, null);
70
+ }
71
+ catch (err) {
72
+ // EAGAIN: nothing written since the last read.
73
+ if (err.code === "EAGAIN")
74
+ return;
75
+ throw err;
76
+ }
77
+ if (n <= 0)
78
+ return;
79
+ events.emit("data", Buffer.from(buf.subarray(0, n)));
80
+ }
81
+ };
82
+ const stop = () => {
83
+ done = true;
84
+ clearI(timer);
85
+ if (fd !== null) {
86
+ try {
87
+ fs.closeSync(fd);
88
+ }
89
+ catch {
90
+ // already closed
91
+ }
92
+ fd = null;
93
+ }
94
+ };
95
+ const timer = setI(() => {
96
+ try {
97
+ read();
98
+ }
99
+ catch {
100
+ // An unreadable FIFO reads as silence; the heartbeat watchdog restarts the service.
101
+ }
102
+ }, opts.pollMs ?? POLL_MS);
103
+ timer.unref?.();
104
+ // Open now, so the FIFO has a reader before the service starts writing.
105
+ open();
106
+ const tail = {
107
+ on(event, handler) {
108
+ events.on(event, handler);
109
+ return tail;
110
+ },
111
+ close() {
112
+ if (done)
113
+ return;
114
+ try {
115
+ read();
116
+ }
117
+ catch {
118
+ // nothing more to read
119
+ }
120
+ stop();
121
+ events.emit("end");
122
+ },
123
+ detach() {
124
+ if (done)
125
+ return;
126
+ stop();
127
+ },
128
+ };
129
+ return tail;
130
+ }
131
+ //# sourceMappingURL=protocol-tail.js.map
@@ -25,6 +25,7 @@ import { acquireDaemonLock, releaseDaemonLock } from "./lock.js";
25
25
  import { daemonRootRefusal } from "./install.js";
26
26
  import { detectMachineType } from "./machine-type.js";
27
27
  import { appendPrivate, daemonPaths, ensurePrivateDir, openPrivateAppend } from "./paths.js";
28
+ import { createProtocolTail, ensureProtocolFifo, openProtocolWriter } from "./protocol-tail.js";
28
29
  import { takeControlRequests } from "./requests.js";
29
30
  import { currentHqCloud, hqCloudRunners } from "./hq-cloud.js";
30
31
  import { runnerSessionName } from "./remote-control.js";
@@ -224,6 +225,14 @@ export function unitProcessEnv(base, unit) {
224
225
  env.NODE_OPTIONS = syncRunnerNodeOptions(env);
225
226
  return env;
226
227
  }
228
+ function isFifo(file) {
229
+ try {
230
+ return fs.lstatSync(file).isFIFO();
231
+ }
232
+ catch {
233
+ return false;
234
+ }
235
+ }
227
236
  export async function runDaemon(opts) {
228
237
  const env = opts.env ?? process.env;
229
238
  const home = opts.home ?? os.homedir();
@@ -263,6 +272,8 @@ export async function runDaemon(opts) {
263
272
  process.kill(pid, sig);
264
273
  };
265
274
  let adopt = [];
275
+ /** Readers of units' protocol FIFOs; a handoff detaches them so the next daemon reads what is left. */
276
+ const protocolTails = new Set();
266
277
  if (succession) {
267
278
  const taken = takeSuccession(succession, { now: Date.now, readStart: readProcStart, log });
268
279
  await stopOrphans(taken.orphans, {
@@ -271,7 +282,20 @@ export async function runDaemon(opts) {
271
282
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
272
283
  log,
273
284
  });
274
- adopt = taken.adopt.map((u) => ({ id: u.id, key: u.key, child: adoptChild(u, { readStart: readProcStart, signal: signalPid }) }));
285
+ adopt = taken.adopt.map((u) => {
286
+ // A unit that speaks the sync protocol writes it to a FIFO; read what the previous daemon left.
287
+ const protocolPath = paths.unitProtocolPath(u.id);
288
+ const stdout = isFifo(protocolPath) ? createProtocolTail(protocolPath) : undefined;
289
+ const child = adoptChild(u, { readStart: readProcStart, signal: signalPid, ...(stdout ? { stdout } : {}) });
290
+ if (stdout) {
291
+ protocolTails.add(stdout);
292
+ child.on("exit", () => {
293
+ protocolTails.delete(stdout);
294
+ stdout.close();
295
+ });
296
+ }
297
+ return { id: u.id, key: u.key, child };
298
+ });
275
299
  }
276
300
  const tracker = succession ? createChildTracker(succession.childrenPath, { readStart: readProcStart, log }) : null;
277
301
  for (const unit of adopt)
@@ -381,11 +405,57 @@ export async function runDaemon(opts) {
381
405
  : [command.file, command.args];
382
406
  const logPath = paths.unitLogPath(unit.id);
383
407
  const cwd = unit.env.HQ_ROOT && fs.existsSync(unit.env.HQ_ROOT) ? unit.env.HQ_ROOT : home;
408
+ if (unit.heartbeatTimeoutMs && succession) {
409
+ // The supervisor reads this unit's protocol heartbeats from stdout. Where
410
+ // services can outlive the daemon, stdout is a FIFO (see protocol-tail.ts)
411
+ // rather than a pipe, so the unit can be handed to the next daemon.
412
+ const protocolPath = paths.unitProtocolPath(unit.id);
413
+ ensureProtocolFifo(protocolPath);
414
+ const stdout = createProtocolTail(protocolPath);
415
+ const outFd = openProtocolWriter(protocolPath);
416
+ const errFd = openPrivateAppend(logPath);
417
+ try {
418
+ const child = spawn(file, args, {
419
+ cwd,
420
+ env: unitProcessEnv(env, unit),
421
+ stdio: ["ignore", outFd, errFd],
422
+ windowsHide: true,
423
+ });
424
+ spawned.add(child);
425
+ if (unit.service === "sync" && child.pid !== undefined)
426
+ daemonSyncRunnerPids.add(child.pid);
427
+ protocolTails.add(stdout);
428
+ const finish = () => {
429
+ protocolTails.delete(stdout);
430
+ stdout.close();
431
+ };
432
+ child.once("exit", () => {
433
+ spawned.delete(child);
434
+ if (child.pid !== undefined)
435
+ daemonSyncRunnerPids.delete(child.pid);
436
+ finish();
437
+ });
438
+ child.once("error", finish);
439
+ const supervised = {
440
+ pid: child.pid,
441
+ stdout,
442
+ on(event, handler) {
443
+ child.on(event, handler);
444
+ return supervised;
445
+ },
446
+ kill: (signal) => child.kill(signal),
447
+ };
448
+ if (unit.kind === "long-running")
449
+ tracker?.track(unit.id, supervised);
450
+ return supervised;
451
+ }
452
+ finally {
453
+ fs.closeSync(outFd);
454
+ fs.closeSync(errFd);
455
+ }
456
+ }
384
457
  if (unit.heartbeatTimeoutMs) {
385
- // The supervisor reads this unit's protocol heartbeats from stdout, so its
386
- // output goes through the daemon. A piped child cannot outlive the daemon,
387
- // so it is never handed over: a handoff stops it and the next daemon
388
- // starts it again.
458
+ // Elsewhere the service stops with the daemon, so its output can go through a pipe.
389
459
  const logFd = openPrivateAppend(logPath);
390
460
  const logStream = fs.createWriteStream(logPath, { fd: logFd, autoClose: true });
391
461
  logStream.on("error", (err) => log("error", `could not write ${unit.id} log ${logPath}: ${err.message}`));
@@ -525,6 +595,10 @@ export async function runDaemon(opts) {
525
595
  return;
526
596
  stopping = true;
527
597
  const released = supervisor.release();
598
+ // What these readers have not read stays in the FIFO for the next daemon.
599
+ for (const tail of protocolTails)
600
+ tail.detach();
601
+ protocolTails.clear();
528
602
  const starts = new Map((tracker?.records() ?? []).map((r) => [r.pid, r.start]));
529
603
  const units = released.flatMap((u) => {
530
604
  const start = starts.get(u.pid);
@@ -95,6 +95,8 @@ export declare function adoptChild(record: ChildRecord, deps: {
95
95
  setInterval?: (fn: () => void, ms: number) => unknown;
96
96
  clearInterval?: (handle: unknown) => void;
97
97
  pollMs?: number;
98
+ /** Its protocol output, read from the file it writes to (see protocol-tail.ts). */
99
+ stdout?: SupervisedChild["stdout"];
98
100
  }): SupervisedChild;
99
101
  /** The loaded daemon unit's KillMode, or null when systemctl cannot tell. */
100
102
  export declare function systemdKillMode(run: (file: string, args: string[]) => {
@@ -226,6 +226,7 @@ export function adoptChild(record, deps) {
226
226
  poll.unref?.();
227
227
  const child = {
228
228
  pid: record.pid,
229
+ ...(deps.stdout ? { stdout: deps.stdout } : {}),
229
230
  on(event, handler) {
230
231
  events.on(event, handler);
231
232
  return child;
@@ -103,7 +103,7 @@ export function renderText(options) {
103
103
  const pad = " ".repeat(STATUS_LABEL_WIDTH);
104
104
  const lines = [];
105
105
  lines.push(`hq doctor — HQ root: ${hqRoot}`);
106
- lines.push(`Platform: ${platform.id}`);
106
+ lines.push(`Agent platform: ${platform.id}`);
107
107
  for (const run of families) {
108
108
  lines.push("");
109
109
  lines.push(`${run.family.title} (${run.family.id})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.285.0",
3
+ "version": "5.286.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {