@indigoai-us/hq-cli 5.117.0 → 5.117.1

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.
@@ -0,0 +1,63 @@
1
+ /**
2
+ * No-service-manager fallback for `hq agent kit install`.
3
+ *
4
+ * When systemd --user cannot be reached (no user session bus, permission
5
+ * refused) or launchd is unavailable, the kit is started as ONE detached
6
+ * `hq agent kit run all` supervisor with output appended to
7
+ * ~/.hq-agent/logs/kit.log and its pid recorded in ~/.hq-agent/kit.pid.
8
+ * `kit status` reads the pidfile; `kit uninstall` kills it and removes it.
9
+ */
10
+ import type { ServiceHostPaths, ServiceSetResult } from "../service-manager/index.js";
11
+ import type { AgentKitPaths } from "./paths.js";
12
+ export declare const KIT_PID_NAME = "kit.pid";
13
+ export declare const KIT_FALLBACK_LOG_NAME = "kit.log";
14
+ export declare function kitPidPath(paths: Pick<AgentKitPaths, "agentDir">): string;
15
+ export declare function kitFallbackLogPath(paths: Pick<AgentKitPaths, "logsDir">): string;
16
+ /** True when an activation error means the service manager itself is unusable. */
17
+ export declare function isServiceManagerUnavailable(error: string | undefined): boolean;
18
+ /**
19
+ * Pick the fallback when activation was requested and the host has no service
20
+ * manager integration, or every failure is a service-manager-unavailable error.
21
+ */
22
+ export declare function shouldUseFallback(result: ServiceSetResult, activate: boolean): boolean;
23
+ export declare function readKitPid(paths: Pick<AgentKitPaths, "agentDir">): number | null;
24
+ export type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => boolean;
25
+ export declare function isPidAlive(pid: number, kill?: KillFn): boolean;
26
+ export interface FallbackStatus {
27
+ pidFile: string;
28
+ pid: number | null;
29
+ running: boolean;
30
+ }
31
+ export declare function fallbackStatus(paths: Pick<AgentKitPaths, "agentDir">, kill?: KillFn): FallbackStatus | null;
32
+ /** Kill a recorded supervisor (if alive) and remove the pidfile. */
33
+ export declare function stopFallback(paths: Pick<AgentKitPaths, "agentDir">, kill?: KillFn): {
34
+ pid: number | null;
35
+ killed: boolean;
36
+ };
37
+ export interface DetachedSpawn {
38
+ (cmd: string, args: string[], opts: {
39
+ detached: true;
40
+ stdio: ["ignore", number, number];
41
+ env: NodeJS.ProcessEnv;
42
+ cwd: string;
43
+ }): {
44
+ pid?: number;
45
+ unref(): void;
46
+ };
47
+ }
48
+ export declare function fallbackCommand(host: ServiceHostPaths): {
49
+ cmd: string;
50
+ args: string[];
51
+ };
52
+ /** crontab line that restarts the fallback supervisor after a reboot. */
53
+ export declare function rebootCrontabLine(paths: Pick<AgentKitPaths, "agentDir" | "logsDir">, host: ServiceHostPaths): string;
54
+ export declare function startFallback(paths: AgentKitPaths, host: ServiceHostPaths, deps?: {
55
+ spawn?: DetachedSpawn;
56
+ kill?: KillFn;
57
+ env?: NodeJS.ProcessEnv;
58
+ }): {
59
+ pid: number;
60
+ logPath: string;
61
+ pidFile: string;
62
+ };
63
+ //# sourceMappingURL=fallback.d.ts.map
@@ -0,0 +1,129 @@
1
+ /**
2
+ * No-service-manager fallback for `hq agent kit install`.
3
+ *
4
+ * When systemd --user cannot be reached (no user session bus, permission
5
+ * refused) or launchd is unavailable, the kit is started as ONE detached
6
+ * `hq agent kit run all` supervisor with output appended to
7
+ * ~/.hq-agent/logs/kit.log and its pid recorded in ~/.hq-agent/kit.pid.
8
+ * `kit status` reads the pidfile; `kit uninstall` kills it and removes it.
9
+ */
10
+ import { spawn } from "node:child_process";
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { kitServiceEnv } from "./services.js";
14
+ export const KIT_PID_NAME = "kit.pid";
15
+ export const KIT_FALLBACK_LOG_NAME = "kit.log";
16
+ export function kitPidPath(paths) {
17
+ return path.join(paths.agentDir, KIT_PID_NAME);
18
+ }
19
+ export function kitFallbackLogPath(paths) {
20
+ return path.join(paths.logsDir, KIT_FALLBACK_LOG_NAME);
21
+ }
22
+ const UNAVAILABLE_PATTERNS = [
23
+ /no user session bus/i,
24
+ /failed to connect to (?:user )?bus/i,
25
+ /DBUS_SESSION_BUS_ADDRESS/i,
26
+ /XDG_RUNTIME_DIR/i,
27
+ /permission denied/i,
28
+ /operation not permitted/i,
29
+ /access denied/i,
30
+ /\bENOENT\b/i,
31
+ /command not found|spawnSync \S+ ENOENT/i,
32
+ /launchctl bootstrap failed/i,
33
+ /System has not been booted with systemd/i,
34
+ ];
35
+ /** True when an activation error means the service manager itself is unusable. */
36
+ export function isServiceManagerUnavailable(error) {
37
+ if (!error)
38
+ return false;
39
+ return UNAVAILABLE_PATTERNS.some((re) => re.test(error));
40
+ }
41
+ /**
42
+ * Pick the fallback when activation was requested and the host has no service
43
+ * manager integration, or every failure is a service-manager-unavailable error.
44
+ */
45
+ export function shouldUseFallback(result, activate) {
46
+ if (!activate)
47
+ return false;
48
+ if (result.platform === "other")
49
+ return true;
50
+ const failed = result.units.filter((u) => u.running === false);
51
+ return failed.length > 0 && failed.some((u) => isServiceManagerUnavailable(u.error));
52
+ }
53
+ export function readKitPid(paths) {
54
+ try {
55
+ const n = Number.parseInt(fs.readFileSync(kitPidPath(paths), "utf8").trim(), 10);
56
+ return Number.isInteger(n) && n > 0 ? n : null;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ const defaultKill = (pid, signal) => process.kill(pid, signal);
63
+ export function isPidAlive(pid, kill = defaultKill) {
64
+ try {
65
+ kill(pid, 0);
66
+ return true;
67
+ }
68
+ catch (err) {
69
+ // EPERM: exists but owned by someone else — still alive.
70
+ return err?.code === "EPERM";
71
+ }
72
+ }
73
+ export function fallbackStatus(paths, kill) {
74
+ if (!fs.existsSync(kitPidPath(paths)))
75
+ return null;
76
+ const pid = readKitPid(paths);
77
+ return { pidFile: kitPidPath(paths), pid, running: pid !== null && isPidAlive(pid, kill) };
78
+ }
79
+ /** Kill a recorded supervisor (if alive) and remove the pidfile. */
80
+ export function stopFallback(paths, kill = defaultKill) {
81
+ const pid = readKitPid(paths);
82
+ let killed = false;
83
+ if (pid !== null && isPidAlive(pid, kill)) {
84
+ try {
85
+ kill(pid, "SIGTERM");
86
+ killed = true;
87
+ }
88
+ catch {
89
+ killed = false;
90
+ }
91
+ }
92
+ fs.rmSync(kitPidPath(paths), { force: true });
93
+ return { pid, killed };
94
+ }
95
+ export function fallbackCommand(host) {
96
+ return { cmd: host.nodeBinary, args: [host.hqBinary, "agent", "kit", "run", "all"] };
97
+ }
98
+ /** crontab line that restarts the fallback supervisor after a reboot. */
99
+ export function rebootCrontabLine(paths, host) {
100
+ const q = (v) => `'${v.replace(/'/g, `'\\''`)}'`;
101
+ const { cmd, args } = fallbackCommand(host);
102
+ return `@reboot HQ_AGENT_DIR=${q(paths.agentDir)} ${[cmd, ...args].map(q).join(" ")} >> ${q(kitFallbackLogPath(paths))} 2>&1`;
103
+ }
104
+ export function startFallback(paths, host, deps = {}) {
105
+ // Re-install replaces a running supervisor, like a unit restart.
106
+ stopFallback(paths, deps.kill);
107
+ fs.mkdirSync(paths.logsDir, { recursive: true, mode: 0o700 });
108
+ const logPath = kitFallbackLogPath(paths);
109
+ const fd = fs.openSync(logPath, "a", 0o600);
110
+ const doSpawn = deps.spawn ?? ((cmd, args, opts) => spawn(cmd, args, opts));
111
+ const { cmd, args } = fallbackCommand(host);
112
+ try {
113
+ const child = doSpawn(cmd, args, {
114
+ detached: true,
115
+ stdio: ["ignore", fd, fd],
116
+ env: { ...(deps.env ?? process.env), HOME: host.home, ...kitServiceEnv(paths) },
117
+ cwd: paths.agentDir,
118
+ });
119
+ if (!child.pid)
120
+ throw new Error(`could not start ${cmd} ${args.join(" ")}`);
121
+ child.unref();
122
+ fs.writeFileSync(kitPidPath(paths), `${child.pid}\n`, { mode: 0o600 });
123
+ return { pid: child.pid, logPath, pidFile: kitPidPath(paths) };
124
+ }
125
+ finally {
126
+ fs.closeSync(fd);
127
+ }
128
+ }
129
+ //# sourceMappingURL=fallback.js.map
@@ -37,19 +37,31 @@ export interface InboxDeps {
37
37
  ack: boolean;
38
38
  getToken: () => Promise<string>;
39
39
  log: KitLogger;
40
- fetchInbox?: (token: string) => Promise<{
41
- status: number;
42
- body: unknown;
43
- }>;
40
+ fetchInbox?: (token: string) => Promise<InboxFetchResult>;
44
41
  ackItem?: (token: string, id: string) => Promise<number>;
45
42
  warmCache?: (token: string, agentUid: string) => Promise<unknown>;
46
43
  sleep?: (ms: number) => Promise<void>;
47
44
  maxPolls?: number;
48
45
  }
49
- export declare function defaultFetchInbox(agentUid: string, apiBaseUrl: string): (token: string) => Promise<{
46
+ export declare const INBOX_ERROR_BODY_MAX = 300;
47
+ export interface InboxFetchResult {
50
48
  status: number;
51
49
  body: unknown;
52
- }>;
50
+ /** Full request URL (never carries credentials). */
51
+ url?: string;
52
+ /** Raw response text, truncated to INBOX_ERROR_BODY_MAX chars. */
53
+ bodyText?: string;
54
+ }
55
+ /**
56
+ * Join apiBaseUrl (from machine-creds.json) with the agent inbox route.
57
+ * `new URL("/v1/…", base)` would silently drop a base path such as an API
58
+ * Gateway stage (`…/prod`), and a base that already ends in `/v1` must not
59
+ * become `/v1/v1`. Trailing slashes are tolerated.
60
+ */
61
+ export declare function agentApiUrl(apiBaseUrl: string, route: string): string;
62
+ export declare function agentInboxUrl(apiBaseUrl: string, agentUid: string): string;
63
+ export declare function truncateBody(text: string, max?: number): string;
64
+ export declare function defaultFetchInbox(agentUid: string, apiBaseUrl: string): (token: string) => Promise<InboxFetchResult>;
53
65
  export declare function defaultAckItem(agentUid: string, apiBaseUrl: string): (token: string, id: string) => Promise<number>;
54
66
  export declare function pollInboxOnce(deps: InboxDeps): Promise<{
55
67
  ok: boolean;
@@ -60,15 +60,45 @@ export function mirrorInboxItem(paths, item) {
60
60
  fs.appendFileSync(path.join(paths.inboxDir, INBOX_JSONL_NAME), `${JSON.stringify({ id: item.id, mirroredAt: new Date().toISOString(), ...item.raw })}\n`, { mode: 0o600 });
61
61
  return dest;
62
62
  }
63
+ export const INBOX_ERROR_BODY_MAX = 300;
64
+ /**
65
+ * Join apiBaseUrl (from machine-creds.json) with the agent inbox route.
66
+ * `new URL("/v1/…", base)` would silently drop a base path such as an API
67
+ * Gateway stage (`…/prod`), and a base that already ends in `/v1` must not
68
+ * become `/v1/v1`. Trailing slashes are tolerated.
69
+ */
70
+ export function agentApiUrl(apiBaseUrl, route) {
71
+ const base = new URL(apiBaseUrl);
72
+ let prefix = base.pathname.replace(/\/+$/, "");
73
+ const suffix = route.startsWith("/") ? route : `/${route}`;
74
+ if (prefix.endsWith("/v1") && suffix.startsWith("/v1/"))
75
+ prefix = prefix.slice(0, -3);
76
+ return `${base.origin}${prefix}${suffix}`;
77
+ }
78
+ export function agentInboxUrl(apiBaseUrl, agentUid) {
79
+ return agentApiUrl(apiBaseUrl, `/v1/agents/${encodeURIComponent(agentUid)}/inbox`);
80
+ }
81
+ export function truncateBody(text, max = INBOX_ERROR_BODY_MAX) {
82
+ const flat = text.replace(/\s+/g, " ").trim();
83
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
84
+ }
63
85
  export function defaultFetchInbox(agentUid, apiBaseUrl) {
64
86
  return async (token) => {
87
+ const url = new URL(agentInboxUrl(apiBaseUrl, agentUid));
65
88
  const res = await vaultApiFetch({
66
89
  token,
67
- baseUrl: apiBaseUrl,
68
- path: `/v1/agents/${encodeURIComponent(agentUid)}/inbox`,
90
+ baseUrl: url.origin,
91
+ path: url.pathname,
69
92
  });
70
- const body = await res.json().catch(() => ({}));
71
- return { status: res.status, body };
93
+ const text = await res.text().catch(() => "");
94
+ let body;
95
+ try {
96
+ body = text ? JSON.parse(text) : {};
97
+ }
98
+ catch {
99
+ body = {};
100
+ }
101
+ return { status: res.status, body, url: url.toString(), bodyText: truncateBody(text) };
72
102
  };
73
103
  }
74
104
  export function defaultAckItem(agentUid, apiBaseUrl) {
@@ -101,12 +131,14 @@ export async function pollInboxOnce(deps) {
101
131
  }
102
132
  catch (err) {
103
133
  writeComponentStatus(deps.paths, "inbox", "error");
104
- deps.log("error", `inbox fetch failed: ${err instanceof Error ? err.message : String(err)}`);
134
+ deps.log("error", `inbox fetch failed: GET ${agentInboxUrl(deps.apiBaseUrl, deps.agentUid)}: ${err instanceof Error ? err.message : String(err)}`);
105
135
  return { ok: false, mirrored: 0 };
106
136
  }
107
137
  if (res.status !== 200) {
108
138
  writeComponentStatus(deps.paths, "inbox", "error");
109
- deps.log("warn", `inbox poll ${res.status}`);
139
+ const url = res.url ?? agentInboxUrl(deps.apiBaseUrl, deps.agentUid);
140
+ const bodyText = truncateBody(res.bodyText ?? (res.body === undefined ? "" : JSON.stringify(res.body)));
141
+ deps.log("warn", `inbox poll GET ${url} → ${res.status}${bodyText ? ` body=${bodyText}` : ""}`);
110
142
  return { ok: false, mirrored: 0 };
111
143
  }
112
144
  writeComponentStatus(deps.paths, "inbox", "ok");
@@ -3,25 +3,39 @@
3
3
  *
4
4
  * Policy hq-work-mesh-source-of-truth: the work mesh (REST) is the source of
5
5
  * truth; MQTT carries IDS-ONLY doorbells on the agent's personal topics
6
- * `hq/{agt_*}/{dm,work,sessions,notifications}`. This listener is the cache
6
+ * `hq/{agt_*}/{dm,work,sessions,notifications,inbox}`. This listener is the cache
7
7
  * writer: on any doorbell it refetches through the REST API into
8
8
  * ~/.hq/work-mesh/cache (warmMeshConversationCache) — message bodies are
9
9
  * never taken from MQTT. A periodic refresh covers missed doorbells.
10
10
  *
11
- * Credentials come from the same contract-3 vend the mesh daemon uses
12
- * (POST /v1/realtime/credentials); the WSS URL is SigV4-presigned. The
11
+ * Credentials come from the contract-2 (personal) vend
12
+ * (POST /v1/realtime/credentials {contractVersion: 2}), whose STS policy grants
13
+ * the caller's own `hq/{uid}/*` topics. The contract-3 vend the presence daemon
14
+ * uses only grants company presence + thread-directory topics, so subscribing
15
+ * to personal doorbells with it made AWS IoT drop the connection before SUBACK.
16
+ * Only doorbell topics the vend advertises are subscribed (one ungranted topic
17
+ * in a SUBSCRIBE disconnects the whole client). An `inbox` doorbell triggers
18
+ * an immediate inbox poll via `onInboxDoorbell`. The WSS URL is SigV4-presigned. The
13
19
  * connection is rebuilt before the vended credentials expire. `component-mesh`
14
20
  * is stamped ok while subscribed and refreshing, error otherwise.
15
21
  */
16
22
  import { type IClientOptions } from "mqtt";
17
- import { type CredentialsFetcher } from "../../mesh/live/daemon/credentials.js";
23
+ import { type PersonalCredentialsFetcher, type PersonalRealtimeBundle } from "../../mesh/live/daemon/credentials.js";
18
24
  import type { KitLogger } from "../log.js";
19
25
  import type { AgentKitPaths } from "../paths.js";
20
- export declare const DOORBELL_KINDS: readonly ["dm", "work", "sessions", "notifications"];
26
+ export declare const DOORBELL_KINDS: readonly ["dm", "work", "sessions", "notifications", "inbox"];
21
27
  export declare const DOORBELL_DEBOUNCE_MS = 2000;
22
28
  export declare const RECONNECT_BASE_MS = 1000;
23
29
  export declare const RECONNECT_MAX_MS = 60000;
24
30
  export declare function doorbellTopics(actorUid: string): string[];
31
+ /**
32
+ * Doorbell topics this session may subscribe to: the intersection of
33
+ * DOORBELL_KINDS with the personal topics the vend advertises. AWS IoT
34
+ * disconnects the client on any unauthorized topic in a SUBSCRIBE, so a kind
35
+ * the server does not (yet) grant is skipped rather than risked.
36
+ */
37
+ export declare function grantedDoorbellTopics(bundle: Pick<PersonalRealtimeBundle, "actorUid" | "topics">): string[];
38
+ export declare function isInboxDoorbell(topic: string): boolean;
25
39
  /** Minimal mqtt client surface the listener needs (test seam). */
26
40
  export interface DoorbellMqttClient {
27
41
  on(event: string, handler: (...args: any[]) => void): unknown;
@@ -38,7 +52,9 @@ export interface MeshListenerDeps {
38
52
  refreshMs: number;
39
53
  getToken: () => Promise<string>;
40
54
  log: KitLogger;
41
- fetchCredentials?: CredentialsFetcher;
55
+ fetchCredentials?: PersonalCredentialsFetcher;
56
+ /** Called immediately (not debounced) when an `hq/{uid}/inbox` doorbell rings. */
57
+ onInboxDoorbell?: () => Promise<unknown> | void;
42
58
  connect?: DoorbellConnectFn;
43
59
  refetch?: (token: string, actorUid: string) => Promise<unknown>;
44
60
  now?: () => Date;
@@ -3,28 +3,47 @@
3
3
  *
4
4
  * Policy hq-work-mesh-source-of-truth: the work mesh (REST) is the source of
5
5
  * truth; MQTT carries IDS-ONLY doorbells on the agent's personal topics
6
- * `hq/{agt_*}/{dm,work,sessions,notifications}`. This listener is the cache
6
+ * `hq/{agt_*}/{dm,work,sessions,notifications,inbox}`. This listener is the cache
7
7
  * writer: on any doorbell it refetches through the REST API into
8
8
  * ~/.hq/work-mesh/cache (warmMeshConversationCache) — message bodies are
9
9
  * never taken from MQTT. A periodic refresh covers missed doorbells.
10
10
  *
11
- * Credentials come from the same contract-3 vend the mesh daemon uses
12
- * (POST /v1/realtime/credentials); the WSS URL is SigV4-presigned. The
11
+ * Credentials come from the contract-2 (personal) vend
12
+ * (POST /v1/realtime/credentials {contractVersion: 2}), whose STS policy grants
13
+ * the caller's own `hq/{uid}/*` topics. The contract-3 vend the presence daemon
14
+ * uses only grants company presence + thread-directory topics, so subscribing
15
+ * to personal doorbells with it made AWS IoT drop the connection before SUBACK.
16
+ * Only doorbell topics the vend advertises are subscribed (one ungranted topic
17
+ * in a SUBSCRIBE disconnects the whole client). An `inbox` doorbell triggers
18
+ * an immediate inbox poll via `onInboxDoorbell`. The WSS URL is SigV4-presigned. The
13
19
  * connection is rebuilt before the vended credentials expire. `component-mesh`
14
20
  * is stamped ok while subscribed and refreshing, error otherwise.
15
21
  */
16
22
  import mqtt from "mqtt";
17
23
  import { warmMeshConversationCache } from "../../mesh/api.js";
18
- import { createContract3Fetcher, MQTT_KEEPALIVE_SECONDS, renewalDelayMs, } from "../../mesh/live/daemon/credentials.js";
24
+ import { createPersonalRealtimeFetcher, MQTT_KEEPALIVE_SECONDS, renewalDelayMs, } from "../../mesh/live/daemon/credentials.js";
19
25
  import { presignIotWssUrl } from "../../mesh/live/daemon/presign.js";
20
26
  import { writeComponentStatus } from "../creds.js";
21
- export const DOORBELL_KINDS = ["dm", "work", "sessions", "notifications"];
27
+ export const DOORBELL_KINDS = ["dm", "work", "sessions", "notifications", "inbox"];
22
28
  export const DOORBELL_DEBOUNCE_MS = 2_000;
23
29
  export const RECONNECT_BASE_MS = 1_000;
24
30
  export const RECONNECT_MAX_MS = 60_000;
25
31
  export function doorbellTopics(actorUid) {
26
32
  return DOORBELL_KINDS.map((k) => `hq/${actorUid}/${k}`);
27
33
  }
34
+ /**
35
+ * Doorbell topics this session may subscribe to: the intersection of
36
+ * DOORBELL_KINDS with the personal topics the vend advertises. AWS IoT
37
+ * disconnects the client on any unauthorized topic in a SUBSCRIBE, so a kind
38
+ * the server does not (yet) grant is skipped rather than risked.
39
+ */
40
+ export function grantedDoorbellTopics(bundle) {
41
+ const advertised = new Set(Object.values(bundle.topics));
42
+ return doorbellTopics(bundle.actorUid).filter((t) => advertised.has(t));
43
+ }
44
+ export function isInboxDoorbell(topic) {
45
+ return /^hq\/[^/]+\/inbox$/.test(topic);
46
+ }
28
47
  function backoff(attempt, random) {
29
48
  const cap = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** attempt);
30
49
  return Math.max(RECONNECT_BASE_MS, Math.floor(random() * cap));
@@ -39,7 +58,7 @@ export async function startMeshListener(deps) {
39
58
  const fetchCredentials = deps.fetchCredentials ??
40
59
  (async () => {
41
60
  const token = await deps.getToken();
42
- return createContract3Fetcher({ token, baseUrl: deps.apiBaseUrl })();
61
+ return createPersonalRealtimeFetcher({ token, baseUrl: deps.apiBaseUrl })();
43
62
  });
44
63
  let state = "idle";
45
64
  let client = null;
@@ -78,6 +97,14 @@ export async function startMeshListener(deps) {
78
97
  return refetching;
79
98
  };
80
99
  const ring = (topic) => {
100
+ if (isInboxDoorbell(topic) && deps.onInboxDoorbell) {
101
+ try {
102
+ void Promise.resolve(deps.onInboxDoorbell()).catch((err) => deps.log("warn", `inbox poll on doorbell failed: ${err instanceof Error ? err.message : String(err)}`));
103
+ }
104
+ catch (err) {
105
+ deps.log("warn", `inbox poll on doorbell failed: ${err instanceof Error ? err.message : String(err)}`);
106
+ }
107
+ }
81
108
  if (debounce)
82
109
  clearT(debounce);
83
110
  debounce = setT(() => {
@@ -128,8 +155,17 @@ export async function startMeshListener(deps) {
128
155
  });
129
156
  client = c;
130
157
  let settled = false;
158
+ const topics = grantedDoorbellTopics(bundle);
159
+ const skipped = doorbellTopics(bundle.actorUid).filter((t) => !topics.includes(t));
160
+ if (skipped.length > 0)
161
+ deps.log("info", `vend does not grant ${skipped.join(", ")}; not subscribing`);
131
162
  c.on("connect", () => {
132
- c.subscribe(doorbellTopics(bundle.actorUid), { qos: 1 }, (err) => {
163
+ if (topics.length === 0) {
164
+ deps.log("error", "realtime vend advertises no doorbell topics");
165
+ c.end(true);
166
+ return;
167
+ }
168
+ c.subscribe(topics, { qos: 1 }, (err) => {
133
169
  if (err) {
134
170
  deps.log("error", `subscribe failed: ${err.message}`);
135
171
  c.end(true);
@@ -139,7 +175,7 @@ export async function startMeshListener(deps) {
139
175
  attempt = 0;
140
176
  state = "subscribed";
141
177
  writeComponentStatus(deps.paths, "mesh", "ok");
142
- deps.log("info", `subscribed to ${doorbellTopics(bundle.actorUid).length} doorbell topics`);
178
+ deps.log("info", `subscribed to ${topics.length} doorbell topics`);
143
179
  void doRefetch("connect");
144
180
  if (renewal)
145
181
  clearT(renewal);
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `hq agent kit run all` — supervise the four kit services in ONE process.
3
+ *
4
+ * The fallback for hosts with no usable service manager (a Linux box with no
5
+ * systemd user session bus, a container, launchd unavailable): each service
6
+ * runs as a child `node hq agent kit run <service>` whose output is appended
7
+ * to its own ~/.hq-agent/logs/<service>.log, and a child that exits is
8
+ * restarted with per-service exponential backoff (1 s doubling to 60 s,
9
+ * reset once a child has stayed up for a minute).
10
+ */
11
+ import type { KitService } from "../services.js";
12
+ export declare const SUPERVISOR_BACKOFF_BASE_MS = 1000;
13
+ export declare const SUPERVISOR_BACKOFF_MAX_MS = 60000;
14
+ /** A child that ran at least this long resets its backoff. */
15
+ export declare const SUPERVISOR_HEALTHY_RUN_MS = 60000;
16
+ export interface SupervisedChild {
17
+ on(event: "exit", handler: (code: number | null, signal: string | null) => void): unknown;
18
+ kill(signal?: NodeJS.Signals | number): boolean;
19
+ }
20
+ export interface SupervisorDeps {
21
+ services: readonly KitService[];
22
+ spawnService: (service: KitService) => SupervisedChild;
23
+ log: (level: "info" | "warn" | "error", msg: string) => void;
24
+ now?: () => number;
25
+ setTimeout?: (fn: () => void, ms: number) => unknown;
26
+ clearTimeout?: (handle: unknown) => void;
27
+ }
28
+ export interface SupervisorHandle {
29
+ stop: (signal?: NodeJS.Signals) => void;
30
+ restarts: () => Record<string, number>;
31
+ running: () => KitService[];
32
+ }
33
+ export declare function supervisorBackoffMs(attempt: number): number;
34
+ export declare function superviseKitServices(deps: SupervisorDeps): SupervisorHandle;
35
+ //# sourceMappingURL=supervisor.d.ts.map
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `hq agent kit run all` — supervise the four kit services in ONE process.
3
+ *
4
+ * The fallback for hosts with no usable service manager (a Linux box with no
5
+ * systemd user session bus, a container, launchd unavailable): each service
6
+ * runs as a child `node hq agent kit run <service>` whose output is appended
7
+ * to its own ~/.hq-agent/logs/<service>.log, and a child that exits is
8
+ * restarted with per-service exponential backoff (1 s doubling to 60 s,
9
+ * reset once a child has stayed up for a minute).
10
+ */
11
+ export const SUPERVISOR_BACKOFF_BASE_MS = 1_000;
12
+ export const SUPERVISOR_BACKOFF_MAX_MS = 60_000;
13
+ /** A child that ran at least this long resets its backoff. */
14
+ export const SUPERVISOR_HEALTHY_RUN_MS = 60_000;
15
+ export function supervisorBackoffMs(attempt) {
16
+ return Math.min(SUPERVISOR_BACKOFF_MAX_MS, SUPERVISOR_BACKOFF_BASE_MS * 2 ** Math.max(0, attempt));
17
+ }
18
+ export function superviseKitServices(deps) {
19
+ const now = deps.now ?? Date.now;
20
+ const setT = deps.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
21
+ const clearT = deps.clearTimeout ?? ((h) => clearTimeout(h));
22
+ const children = new Map();
23
+ const attempts = new Map();
24
+ const restarts = {};
25
+ const timers = new Map();
26
+ let stopped = false;
27
+ const start = (service) => {
28
+ if (stopped)
29
+ return;
30
+ timers.delete(service);
31
+ let child;
32
+ const startedAt = now();
33
+ try {
34
+ child = deps.spawnService(service);
35
+ }
36
+ catch (err) {
37
+ deps.log("error", `${service} failed to start: ${err instanceof Error ? err.message : String(err)}`);
38
+ scheduleRestart(service);
39
+ return;
40
+ }
41
+ children.set(service, child);
42
+ child.on("exit", (code, signal) => {
43
+ if (children.get(service) === child)
44
+ children.delete(service);
45
+ if (stopped)
46
+ return;
47
+ if (now() - startedAt >= SUPERVISOR_HEALTHY_RUN_MS)
48
+ attempts.set(service, 0);
49
+ deps.log("warn", `${service} exited (code=${code ?? "null"} signal=${signal ?? "none"})`);
50
+ scheduleRestart(service);
51
+ });
52
+ };
53
+ const scheduleRestart = (service) => {
54
+ if (stopped)
55
+ return;
56
+ const attempt = attempts.get(service) ?? 0;
57
+ attempts.set(service, attempt + 1);
58
+ const delay = supervisorBackoffMs(attempt);
59
+ restarts[service] = (restarts[service] ?? 0) + 1;
60
+ deps.log("info", `restarting ${service} in ${delay}ms (restart ${restarts[service]})`);
61
+ timers.set(service, setT(() => start(service), delay));
62
+ };
63
+ for (const s of deps.services)
64
+ start(s);
65
+ return {
66
+ stop: (signal = "SIGTERM") => {
67
+ stopped = true;
68
+ for (const h of timers.values())
69
+ clearT(h);
70
+ timers.clear();
71
+ for (const child of children.values()) {
72
+ try {
73
+ child.kill(signal);
74
+ }
75
+ catch {
76
+ /* already gone */
77
+ }
78
+ }
79
+ children.clear();
80
+ },
81
+ restarts: () => ({ ...restarts }),
82
+ running: () => [...children.keys()],
83
+ };
84
+ }
85
+ //# sourceMappingURL=supervisor.js.map
@@ -131,4 +131,31 @@ export declare class CredentialRenewalManager {
131
131
  stop(): void;
132
132
  private clear;
133
133
  }
134
+ /**
135
+ * Contract-2 (personal) realtime vend. Backed server-side by the personal
136
+ * session policy, which grants Subscribe/Receive on the caller's own
137
+ * `hq/{principalUid}/{dm,sessions,work,notifications,meeting,sync}` topics —
138
+ * unlike contract 3, whose policy only covers company presence and the thread
139
+ * directory. Doorbell listeners on personal topics MUST use this contract;
140
+ * subscribing to personal topics with a contract-3 session makes AWS IoT drop
141
+ * the connection before SUBACK.
142
+ */
143
+ export interface PersonalRealtimeBundle {
144
+ contractVersion: 2;
145
+ credentials: IotCredentials;
146
+ iotEndpoint: string;
147
+ region: string;
148
+ clientId: string;
149
+ actorUid: string;
150
+ /** Advertised personal topics keyed by kind (dm, sessions, work, notifications, …). */
151
+ topics: Record<string, string>;
152
+ expiresAt: string;
153
+ }
154
+ export type PersonalCredentialsFetcher = () => Promise<PersonalRealtimeBundle>;
155
+ export declare function normalizePersonalRealtimeBundle(raw: unknown): PersonalRealtimeBundle;
156
+ export declare function createPersonalRealtimeFetcher(opts: {
157
+ token: string;
158
+ baseUrl?: string;
159
+ post?: (path: string, body: unknown) => Promise<CredentialVendPostResult>;
160
+ }): PersonalCredentialsFetcher;
134
161
  //# sourceMappingURL=credentials.d.ts.map