@indigoai-us/hq-cli 5.77.12 → 5.77.13

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.
@@ -63,7 +63,7 @@ async function peekPlanLimitStatus(response) {
63
63
  }
64
64
  }
65
65
  export async function vaultApiFetch(opts) {
66
- const url = new URL(opts.path, DEFAULT_VAULT_API_URL);
66
+ const url = new URL(opts.path, opts.baseUrl ?? DEFAULT_VAULT_API_URL);
67
67
  if (opts.query) {
68
68
  for (const [k, v] of Object.entries(opts.query)) {
69
69
  url.searchParams.set(k, v);
@@ -274,10 +274,11 @@ export async function getCompanyUid(token, companySlug) {
274
274
  }
275
275
  // Same selection rule as the backend's `resolveCallerPersonUid`: ascending by
276
276
  // createdAt, tie-break by uid ascending. Returns the `prs_*` UID.
277
- export async function resolveCallerPersonUid(token) {
277
+ export async function resolveCallerPersonUid(token, baseUrl) {
278
278
  const res = await vaultApiFetch({
279
279
  token,
280
280
  path: '/entity/by-type/person',
281
+ baseUrl,
281
282
  });
282
283
  if (!res.ok) {
283
284
  throw new Error("Failed to fetch person entity — run `hq login` and try again");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.77.12",
3
+ "version": "5.77.13",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "clean": "rm -rf dist"
22
22
  },
23
23
  "dependencies": {
24
+ "@aws-sdk/client-iot-data-plane": "^3.1096.0",
24
25
  "@aws-sdk/client-s3": "^3.1049.0",
25
26
  "@indigoai-us/hq-cloud": "^6.14.27",
26
27
  "@indigoai-us/hq-onboarding": "^0.1.0",
@@ -42,6 +43,7 @@
42
43
  "@types/node": "^22.0.0",
43
44
  "@types/semver": "^7.5.8",
44
45
  "@vitest/coverage-v8": "4.1.6",
46
+ "aws-sdk-client-mock": "^4.1.0",
45
47
  "eslint": "^10.5.0",
46
48
  "typescript": "^5.7.0",
47
49
  "typescript-eslint": "^8.61.1",
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Unit tests for `hq outposts heartbeat` (outposts-heartbeat.ts).
3
+ *
4
+ * The loop runs ON the box under systemd, so the properties that matter are
5
+ * operational, not cosmetic:
6
+ * - it ticks on a cadence and NEVER dies on a transient failure;
7
+ * - it resolves identity ONCE per process, not once per tick (the whole point
8
+ * of moving the cadence in-process — the old bash loop re-authenticated and
9
+ * re-resolved a package every 5s);
10
+ * - nothing leaves the box that fails the no-secrets contract.
11
+ *
12
+ * Everything the loop touches (clock, sleep, publish, identity, fs) is injected
13
+ * so these run without a network, a real box, or real time.
14
+ */
15
+
16
+ import { describe, expect, it, vi } from "vitest";
17
+ import {
18
+ resolveIntervalSeconds,
19
+ runHeartbeatLoop,
20
+ type HeartbeatLoopDeps,
21
+ } from "./outposts-heartbeat.js";
22
+ import type {
23
+ FileSystemPort,
24
+ SessionsHeartbeatPayload,
25
+ } from "../outpost/session-heartbeat.js";
26
+
27
+ const PERSON_UID = "prs_01TESTPERSON";
28
+
29
+ /** An fs port with no sessions on disk — enumeration yields an empty payload. */
30
+ const emptyFs: FileSystemPort = {
31
+ readDir: async () => [],
32
+ stat: async () => ({ mtimeMs: 0, birthtimeMs: 0, size: 0 }),
33
+ readTextFile: async () => "",
34
+ readBounded: async () => "",
35
+ };
36
+
37
+ function makeDeps(over: Partial<HeartbeatLoopDeps> = {}): {
38
+ deps: HeartbeatLoopDeps;
39
+ published: { topic: string; payload: SessionsHeartbeatPayload }[];
40
+ sleeps: number[];
41
+ logs: string[];
42
+ personUidCalls: () => number;
43
+ } {
44
+ const published: { topic: string; payload: SessionsHeartbeatPayload }[] = [];
45
+ const sleeps: number[] = [];
46
+ const logs: string[] = [];
47
+ const getPersonUid = vi.fn(async () => PERSON_UID);
48
+
49
+ const deps: HeartbeatLoopDeps = {
50
+ fs: emptyFs,
51
+ publish: async (topic, payload) => {
52
+ published.push({ topic, payload });
53
+ },
54
+ getPersonUid,
55
+ sleep: async (ms: number) => {
56
+ sleeps.push(ms);
57
+ },
58
+ now: () => new Date("2026-07-29T00:00:00.000Z"),
59
+ log: (line: string) => logs.push(line),
60
+ ...over,
61
+ };
62
+
63
+ return {
64
+ deps,
65
+ published,
66
+ sleeps,
67
+ logs,
68
+ personUidCalls: () => getPersonUid.mock.calls.length,
69
+ };
70
+ }
71
+
72
+ describe("resolveIntervalSeconds", () => {
73
+ it("prefers the explicit flag over the environment", () => {
74
+ expect(
75
+ resolveIntervalSeconds("30", {
76
+ OUTPOST_SESSIONS_HEARTBEAT_INTERVAL_SECONDS: "9",
77
+ }),
78
+ ).toBe(30);
79
+ });
80
+
81
+ it("falls back to the environment when no flag is given", () => {
82
+ expect(
83
+ resolveIntervalSeconds(undefined, {
84
+ OUTPOST_SESSIONS_HEARTBEAT_INTERVAL_SECONDS: "9",
85
+ }),
86
+ ).toBe(9);
87
+ });
88
+
89
+ it("defaults to 5s when neither is set", () => {
90
+ expect(resolveIntervalSeconds(undefined, {})).toBe(5);
91
+ });
92
+
93
+ it("floors at 1s so a bad value cannot hammer the fabric", () => {
94
+ expect(resolveIntervalSeconds("0", {})).toBe(1);
95
+ expect(resolveIntervalSeconds("-4", {})).toBe(1);
96
+ expect(resolveIntervalSeconds("not-a-number", {})).toBe(5);
97
+ });
98
+ });
99
+
100
+ describe("runHeartbeatLoop", () => {
101
+ it("--once emits exactly one heartbeat and returns", async () => {
102
+ const { deps, published, sleeps } = makeDeps();
103
+
104
+ const ticks = await runHeartbeatLoop({ once: true }, deps);
105
+
106
+ expect(ticks).toBe(1);
107
+ expect(published).toHaveLength(1);
108
+ expect(published[0].topic).toBe(`hq/${PERSON_UID}/sessions`);
109
+ expect(published[0].payload.type).toBe("sessions");
110
+ expect(published[0].payload.origin).toBe("outpost");
111
+ // One-shot must not sleep — systemd/an operator owns the cadence there.
112
+ expect(sleeps).toEqual([]);
113
+ });
114
+
115
+ it("ticks repeatedly on the configured cadence, sleeping between ticks", async () => {
116
+ const { deps, published, sleeps } = makeDeps();
117
+
118
+ const ticks = await runHeartbeatLoop(
119
+ { intervalSeconds: 5, maxTicks: 3 },
120
+ deps,
121
+ );
122
+
123
+ expect(ticks).toBe(3);
124
+ expect(published).toHaveLength(3);
125
+ // N ticks ⇒ N-1 gaps. The loop must not sleep before exiting.
126
+ expect(sleeps).toEqual([5000, 5000]);
127
+ });
128
+
129
+ it("resolves identity ONCE per process, not once per tick", async () => {
130
+ // Regression guard for the original design: the bash loop re-ran
131
+ // `hq auth refresh` AND re-resolved an npm package on every single tick.
132
+ const { deps, personUidCalls } = makeDeps();
133
+
134
+ await runHeartbeatLoop({ intervalSeconds: 1, maxTicks: 10 }, deps);
135
+
136
+ expect(personUidCalls()).toBe(1);
137
+ });
138
+
139
+ it("survives a publish failure and keeps beating", async () => {
140
+ let attempt = 0;
141
+ const { deps, published, logs } = makeDeps({
142
+ publish: async (topic, payload) => {
143
+ attempt += 1;
144
+ if (attempt === 2) throw new Error("iot unavailable");
145
+ published.push({ topic, payload });
146
+ },
147
+ });
148
+
149
+ const ticks = await runHeartbeatLoop({ maxTicks: 3 }, deps);
150
+
151
+ expect(ticks).toBe(3);
152
+ // Ticks 1 and 3 landed; tick 2 failed without killing the loop.
153
+ expect(published).toHaveLength(2);
154
+ expect(logs.join("\n")).toContain("iot unavailable");
155
+ });
156
+
157
+ it("survives a collection failure and keeps beating", async () => {
158
+ let calls = 0;
159
+ const { deps, published, logs } = makeDeps({
160
+ fs: {
161
+ ...emptyFs,
162
+ readDir: async () => {
163
+ calls += 1;
164
+ if (calls === 1) throw new Error("EIO: disk unhappy");
165
+ return [];
166
+ },
167
+ },
168
+ });
169
+
170
+ const ticks = await runHeartbeatLoop({ maxTicks: 2 }, deps);
171
+
172
+ expect(ticks).toBe(2);
173
+ expect(published).toHaveLength(1);
174
+ expect(logs.join("\n")).toContain("disk unhappy");
175
+ });
176
+
177
+ it("keeps beating when identity cannot be resolved yet", async () => {
178
+ // A box that boots before its session is warm must retry, not exit — a
179
+ // crash here is what systemd Restart= masks into a silent flap.
180
+ let calls = 0;
181
+ const { deps, published } = makeDeps({
182
+ getPersonUid: async () => {
183
+ calls += 1;
184
+ if (calls === 1) throw new Error("no cached HQ session");
185
+ return PERSON_UID;
186
+ },
187
+ });
188
+
189
+ const ticks = await runHeartbeatLoop({ maxTicks: 2 }, deps);
190
+
191
+ expect(ticks).toBe(2);
192
+ expect(published).toHaveLength(1);
193
+ });
194
+
195
+ it("never publishes a payload that fails the no-secrets guard", async () => {
196
+ const { deps, published, logs } = makeDeps({
197
+ fs: {
198
+ ...emptyFs,
199
+ readDir: async (path: string) => {
200
+ // Only the Claude tree has anything in it; Codex enumeration must
201
+ // come back empty so the leak under test is unambiguously the
202
+ // sniffed Claude `model` field.
203
+ if (path.endsWith("/.claude/projects")) {
204
+ return [{ name: "-home-ec2-user-hq", isDirectory: true, isFile: false }];
205
+ }
206
+ if (path.endsWith("/.claude/projects/-home-ec2-user-hq")) {
207
+ return [
208
+ {
209
+ name: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl",
210
+ isDirectory: false,
211
+ isFile: true,
212
+ },
213
+ ];
214
+ }
215
+ return [];
216
+ },
217
+ stat: async () => ({
218
+ mtimeMs: Date.parse("2026-07-29T00:00:00.000Z"),
219
+ birthtimeMs: Date.parse("2026-07-29T00:00:00.000Z"),
220
+ size: 128,
221
+ }),
222
+ // An adversarial transcript that tries to smuggle a key out via the
223
+ // sniffed model field.
224
+ readBounded: async () =>
225
+ JSON.stringify({ model: "sk-ant-api03-LEAKED-KEY-VALUE" }),
226
+ },
227
+ });
228
+
229
+ const ticks = await runHeartbeatLoop({ once: true }, deps);
230
+
231
+ expect(ticks).toBe(1);
232
+ expect(published).toEqual([]);
233
+ expect(logs.join("\n").toLowerCase()).toContain("refusing to publish");
234
+ });
235
+
236
+ it("records a liveness marker after a successful publish", async () => {
237
+ // The marker is what the box audit reads. Without it the audit can only see
238
+ // "systemd says active", which is exactly the signal that stayed green for
239
+ // nine days while every tick failed.
240
+ const marks: number[] = [];
241
+ const { deps } = makeDeps({
242
+ onPublished: async () => {
243
+ marks.push(1);
244
+ },
245
+ });
246
+
247
+ await runHeartbeatLoop({ maxTicks: 3 }, deps);
248
+
249
+ expect(marks).toHaveLength(3);
250
+ });
251
+
252
+ it("does NOT record a liveness marker when the publish failed", async () => {
253
+ // A marker written regardless of outcome would recreate the original bug in
254
+ // a new place: a freshness check that is always fresh.
255
+ const marks: number[] = [];
256
+ const { deps } = makeDeps({
257
+ publish: async () => {
258
+ throw new Error("iot unavailable");
259
+ },
260
+ onPublished: async () => {
261
+ marks.push(1);
262
+ },
263
+ });
264
+
265
+ await runHeartbeatLoop({ maxTicks: 3 }, deps);
266
+
267
+ expect(marks).toEqual([]);
268
+ });
269
+
270
+ it("keeps beating when the liveness marker cannot be written", async () => {
271
+ const { deps, published } = makeDeps({
272
+ onPublished: async () => {
273
+ throw new Error("EACCES: read-only filesystem");
274
+ },
275
+ });
276
+
277
+ const ticks = await runHeartbeatLoop({ maxTicks: 2 }, deps);
278
+
279
+ expect(ticks).toBe(2);
280
+ expect(published).toHaveLength(2);
281
+ });
282
+
283
+ it("stops promptly when aborted", async () => {
284
+ const controller = new AbortController();
285
+ const { deps, published } = makeDeps({
286
+ sleep: async () => {
287
+ controller.abort();
288
+ },
289
+ });
290
+
291
+ const ticks = await runHeartbeatLoop(
292
+ { intervalSeconds: 5, signal: controller.signal },
293
+ deps,
294
+ );
295
+
296
+ expect(ticks).toBe(1);
297
+ expect(published).toHaveLength(1);
298
+ });
299
+ });
@@ -0,0 +1,310 @@
1
+ /**
2
+ * `hq outposts heartbeat` — publish this box's agent-session summary to the
3
+ * realtime fabric.
4
+ *
5
+ * Runs ON an Outpost, under systemd. It enumerates the box's local Claude Code
6
+ * and Codex sessions with cheap scandir + stat + bounded reads, projects them
7
+ * down to the compact secret-free `AgentSession[]` payload, and publishes to
8
+ * `hq/{personUid}/sessions` so Mission Control can see what the box is doing.
9
+ *
10
+ * ## Why the cadence lives in here
11
+ *
12
+ * The original box-side design was a bash `while` loop that, every 5 seconds,
13
+ * ran `hq auth refresh` and then `npx -y --package=@indigoai-us/hq-cloud@latest
14
+ * outpost-session-heartbeat-runner`. That is ~35k process launches a day, and
15
+ * every `npx` re-resolves the package and re-reads it off disk — on a t3 box
16
+ * whose EBS budget is the scarce resource, that is a meaningful, permanent tax
17
+ * for a job that should be nearly free.
18
+ *
19
+ * So the loop lives in-process instead:
20
+ * - identity is resolved ONCE per process, not once per tick;
21
+ * - the Cognito session refreshes itself on demand (`ensureCognitoToken`
22
+ * returns the cached token until it is close to expiry), so there is no
23
+ * separate per-tick `hq auth refresh`;
24
+ * - the IoT client is cached across ticks by the publisher and only rebuilt
25
+ * when its credentials near expiry.
26
+ *
27
+ * The loop is deliberately unkillable-by-transients: a failed publish, a failed
28
+ * enumeration, or a not-yet-warm session are all logged and retried on the next
29
+ * tick. Nothing here should ever take the service down, because a service that
30
+ * exits looks identical to a service that is quietly failing.
31
+ */
32
+
33
+ import type { Command } from "commander";
34
+ import { mkdir, writeFile } from "node:fs/promises";
35
+ import { homedir } from "node:os";
36
+ import { dirname, join } from "node:path";
37
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
38
+ import { DEFAULT_VAULT_API_URL } from "../utils/cognito-session.js";
39
+ import { resolveCallerPersonUid } from "../utils/vault-api.js";
40
+ import {
41
+ DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
42
+ collectSessions,
43
+ nodeFileSystem,
44
+ sessionsTopicForPerson,
45
+ type FileSystemPort,
46
+ type PublishPort,
47
+ type SessionsHeartbeatPayload,
48
+ } from "../outpost/session-heartbeat.js";
49
+ import {
50
+ createIotPublishPort,
51
+ defaultRealtimeCredentialsFetcher,
52
+ } from "../outpost/session-heartbeat-publisher.js";
53
+
54
+ /** Everything the loop touches, injected so it is testable without a box. */
55
+ export interface HeartbeatLoopDeps {
56
+ fs: FileSystemPort;
57
+ publish: PublishPort;
58
+ /** Resolve the caller's canonical `prs_*` id. Called once per process. */
59
+ getPersonUid: () => Promise<string>;
60
+ /**
61
+ * Wait between ticks. MUST return early when `signal` aborts — otherwise a
62
+ * SIGTERM arriving mid-sleep is not noticed until the full interval elapses,
63
+ * and systemd force-kills the process instead of it stopping cooperatively.
64
+ */
65
+ sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
66
+ now: () => Date;
67
+ log: (line: string) => void;
68
+ /**
69
+ * Record that a heartbeat actually LANDED. Called only after a successful
70
+ * publish — never on failure, or the freshness signal it feeds would always
71
+ * read fresh and be worth nothing.
72
+ */
73
+ onPublished?: (payload: SessionsHeartbeatPayload) => Promise<void>;
74
+ }
75
+
76
+ export interface HeartbeatLoopOptions {
77
+ /** Emit a single heartbeat and return. */
78
+ once?: boolean;
79
+ /** Cadence between ticks. Defaults to {@link DEFAULT_HEARTBEAT_INTERVAL_SECONDS}. */
80
+ intervalSeconds?: number;
81
+ /** Home directory to scan (defaults to the process HOME). */
82
+ home?: string;
83
+ /** Print each published payload as JSON. */
84
+ json?: boolean;
85
+ /** Stop the loop cooperatively. */
86
+ signal?: AbortSignal;
87
+ /** Bound the loop — used by tests; production runs until aborted. */
88
+ maxTicks?: number;
89
+ }
90
+
91
+ /**
92
+ * Resolve the cadence from the flag, then the environment, then the default.
93
+ * Floored at 1s: a sub-second cadence hammers the fabric for no benefit, and a
94
+ * zero/negative value would spin the loop hot.
95
+ */
96
+ export function resolveIntervalSeconds(
97
+ flag: string | undefined,
98
+ env: NodeJS.ProcessEnv = process.env,
99
+ ): number {
100
+ for (const raw of [flag, env.OUTPOST_SESSIONS_HEARTBEAT_INTERVAL_SECONDS]) {
101
+ if (raw === undefined || raw === null || raw === "") continue;
102
+ const parsed = Number.parseInt(raw, 10);
103
+ if (!Number.isFinite(parsed)) return DEFAULT_HEARTBEAT_INTERVAL_SECONDS;
104
+ return Math.max(1, parsed);
105
+ }
106
+ return DEFAULT_HEARTBEAT_INTERVAL_SECONDS;
107
+ }
108
+
109
+ /**
110
+ * Where the box records its last LANDED heartbeat. The post-provision audit
111
+ * reads this file's freshness, because "systemd says the unit is active" is not
112
+ * evidence that anything was published — that gap is what let a heartbeat fail
113
+ * every five seconds for nine days while every dashboard stayed green.
114
+ *
115
+ * Lives under the service user's home so the unit (User=ec2-user) can write it
116
+ * without extra tmpfiles.d wiring; the SSM collector reads it as root.
117
+ */
118
+ export const DEFAULT_HEARTBEAT_STATE_FILE = join(
119
+ homedir(),
120
+ ".hq",
121
+ "outpost-session-heartbeat.json",
122
+ );
123
+
124
+ /** Persist the liveness marker the box audit reads. */
125
+ export async function writeHeartbeatState(
126
+ path: string,
127
+ payload: SessionsHeartbeatPayload,
128
+ ): Promise<void> {
129
+ await mkdir(dirname(path), { recursive: true });
130
+ await writeFile(
131
+ path,
132
+ `${JSON.stringify({
133
+ lastPublishAt: payload.emittedAt,
134
+ sessions: payload.sessions.length,
135
+ })}\n`,
136
+ "utf8",
137
+ );
138
+ }
139
+
140
+ /** Structured one-line log — journald gets JSON, not prose. */
141
+ function logLine(
142
+ step: string,
143
+ outcome: "ok" | "error",
144
+ extra: Record<string, unknown>,
145
+ now: () => Date,
146
+ ): string {
147
+ return JSON.stringify({
148
+ service: "outpost-session-heartbeat",
149
+ step,
150
+ outcome,
151
+ ...extra,
152
+ timestamp: now().toISOString(),
153
+ });
154
+ }
155
+
156
+ /**
157
+ * Run the heartbeat loop. Returns the number of ticks performed.
158
+ *
159
+ * A "tick" is one attempt, whether or not it published — the count is the
160
+ * loop's liveness, not its success rate.
161
+ */
162
+ export async function runHeartbeatLoop(
163
+ options: HeartbeatLoopOptions,
164
+ deps: HeartbeatLoopDeps,
165
+ ): Promise<number> {
166
+ const intervalMs =
167
+ (options.intervalSeconds ?? DEFAULT_HEARTBEAT_INTERVAL_SECONDS) * 1000;
168
+ const maxTicks = options.once ? 1 : options.maxTicks;
169
+
170
+ // Resolved once and reused. Re-resolving per tick was the original sin.
171
+ let personUid: string | null = null;
172
+ let ticks = 0;
173
+
174
+ for (;;) {
175
+ ticks += 1;
176
+
177
+ try {
178
+ if (!personUid) personUid = await deps.getPersonUid();
179
+ const payload = await collectSessions(
180
+ { personUid, home: options.home, now: deps.now },
181
+ { fs: deps.fs },
182
+ );
183
+ const topic = sessionsTopicForPerson(personUid);
184
+ await deps.publish(topic, payload);
185
+ // Ordering matters: the marker is written only once the publish has
186
+ // resolved, so a stale marker means "nothing landed", not "nothing ran".
187
+ await deps.onPublished?.(payload);
188
+ if (options.json) deps.log(JSON.stringify(payload));
189
+ deps.log(
190
+ logLine(
191
+ "publish",
192
+ "ok",
193
+ { topic, sessions: payload.sessions.length },
194
+ deps.now,
195
+ ),
196
+ );
197
+ } catch (err) {
198
+ // Every failure mode lands here on purpose: a transient must never take
199
+ // the service down, because systemd restarting a hot-failing loop looks
200
+ // exactly like a healthy one.
201
+ deps.log(
202
+ logLine(
203
+ "tick",
204
+ "error",
205
+ { message: err instanceof Error ? err.message : String(err) },
206
+ deps.now,
207
+ ),
208
+ );
209
+ }
210
+
211
+ if (options.once) break;
212
+ if (maxTicks !== undefined && ticks >= maxTicks) break;
213
+ if (options.signal?.aborted) break;
214
+
215
+ await deps.sleep(intervalMs, options.signal);
216
+ if (options.signal?.aborted) break;
217
+ }
218
+
219
+ return ticks;
220
+ }
221
+
222
+ /** Wire the production dependencies and register the subcommand. */
223
+ export function registerHeartbeatCommand(outposts: Command): void {
224
+ outposts
225
+ .command("heartbeat", { hidden: true })
226
+ .description(
227
+ "Publish this box's agent-session summary to the realtime fabric (runs on the Outpost, under systemd)",
228
+ )
229
+ .option("--once", "Emit a single heartbeat and exit")
230
+ .option(
231
+ "--interval <seconds>",
232
+ "Cadence in seconds (default 5; env OUTPOST_SESSIONS_HEARTBEAT_INTERVAL_SECONDS)",
233
+ )
234
+ .option("--home <path>", "Home directory to scan for sessions")
235
+ .option("--api-base-url <url>", "HQ API base URL")
236
+ .option(
237
+ "--state-file <path>",
238
+ `Liveness marker read by the box audit (default ${DEFAULT_HEARTBEAT_STATE_FILE})`,
239
+ )
240
+ .option("--json", "Print each published payload as JSON")
241
+ .action(
242
+ async (opts: {
243
+ once?: boolean;
244
+ interval?: string;
245
+ home?: string;
246
+ apiBaseUrl?: string;
247
+ stateFile?: string;
248
+ json?: boolean;
249
+ }) => {
250
+ const apiBaseUrl =
251
+ opts.apiBaseUrl ??
252
+ process.env.HQAPI_BASE_URL ??
253
+ DEFAULT_VAULT_API_URL;
254
+
255
+ const getJwt = () => ensureCognitoToken({ interactive: false });
256
+
257
+ const deps: HeartbeatLoopDeps = {
258
+ fs: nodeFileSystem,
259
+ publish: createIotPublishPort({
260
+ fetchCredentials: defaultRealtimeCredentialsFetcher({
261
+ apiBaseUrl,
262
+ getJwt,
263
+ }),
264
+ }),
265
+ // Same base as the credentials fetch. Resolving identity against the
266
+ // default plane while vending credentials from another sends a
267
+ // deployment-specific token to the wrong control plane, which is
268
+ // rejected — and the loop then retries forever without publishing.
269
+ getPersonUid: async () =>
270
+ resolveCallerPersonUid(await getJwt(), apiBaseUrl),
271
+ sleep: (ms, signal) =>
272
+ new Promise((resolve) => {
273
+ if (signal?.aborted) return resolve();
274
+ const t = setTimeout(done, ms);
275
+ function done() {
276
+ clearTimeout(t);
277
+ signal?.removeEventListener("abort", done);
278
+ resolve();
279
+ }
280
+ signal?.addEventListener("abort", done, { once: true });
281
+ }),
282
+ now: () => new Date(),
283
+ log: (line) => console.error(line),
284
+ onPublished: (payload) =>
285
+ writeHeartbeatState(
286
+ opts.stateFile ?? DEFAULT_HEARTBEAT_STATE_FILE,
287
+ payload,
288
+ ),
289
+ };
290
+
291
+ // systemd sends SIGTERM on stop/restart — finish the in-flight tick,
292
+ // then exit cleanly rather than being killed mid-publish.
293
+ const controller = new AbortController();
294
+ const stop = () => controller.abort();
295
+ process.once("SIGTERM", stop);
296
+ process.once("SIGINT", stop);
297
+
298
+ await runHeartbeatLoop(
299
+ {
300
+ once: opts.once,
301
+ intervalSeconds: resolveIntervalSeconds(opts.interval),
302
+ home: opts.home,
303
+ json: opts.json,
304
+ signal: controller.signal,
305
+ },
306
+ deps,
307
+ );
308
+ },
309
+ );
310
+ }
@@ -33,6 +33,7 @@ import * as yaml from "js-yaml";
33
33
  import { loadCachedTokens } from "@indigoai-us/hq-cloud";
34
34
  import { ensureCognitoToken } from "../utils/cognito-session.js";
35
35
  import { vaultApiFetch } from "../utils/vault-api.js";
36
+ import { registerHeartbeatCommand } from "./outposts-heartbeat.js";
36
37
  import {
37
38
  OUTPOST_PRICE_CENTS,
38
39
  confirmChargeOrExit,
@@ -1160,6 +1161,9 @@ export function registerOutpostsCommand(
1160
1161
  .command("outposts")
1161
1162
  .description("Manage your personal HQ Outposts (EC2 boxes)");
1162
1163
 
1164
+ // On-box session heartbeat (systemd). Hidden — operators never run it.
1165
+ registerHeartbeatCommand(outposts);
1166
+
1163
1167
  outposts
1164
1168
  .command("self-deploy", { hidden: true })
1165
1169
  .description("Configure this EC2 host as a locally self-hosted HQ outpost")