@indigoai-us/hq-cli 5.120.1 → 5.121.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,6 +2,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### Documentation
6
+
7
+ - The 2026-09-18 fleet-bots GA notes group the published CLI changes for plan
8
+ notices, DM attachments, staged skills, version reporting, knowledge-index
9
+ protection, and ambiguous people resolution (#634, #636, #641, #642, #643,
10
+ #644, #645, #647, #648, #649).
11
+
12
+ ## [5.121.0] — 2026-09-18
13
+
14
+ ### Changed
15
+
16
+ - The agent kit no longer installs a work-mesh doorbell listener, and
17
+ `hq agent probe` no longer checks one. The doorbell was never what made a
18
+ bot reachable — messages arrive over the inbox either way — but it was the
19
+ loudest part of setup: it needed a realtime connection that restricted bot
20
+ hosts routinely intercept, so a perfectly healthy bot reported a failure it
21
+ could do nothing about.
22
+
23
+ The probe's `work-mesh` line becomes `presence`: the agent's own inbox
24
+ answers and its heartbeat is fresh. That is what the check was really
25
+ proving. `hq mesh` remains available to a bot that is asked to use it; the
26
+ kit simply no longer runs a listener or ships a skill telling it to.
27
+
28
+ One trade-off: an inbox doorbell used to trigger an immediate poll. Without
29
+ it, incoming messages are picked up by the inbox poller on its own interval
30
+ (15 seconds by default) rather than instantly.
31
+
5
32
  ## [5.120.1] — 2026-09-18
6
33
 
7
34
  - A local bot no longer replaces your own sign-in. Bots that run on your
@@ -4255,7 +4255,7 @@ export declare const COMMAND_CATALOG: readonly [{
4255
4255
  readonly options: readonly [];
4256
4256
  readonly subcommands: readonly [{
4257
4257
  readonly name: "install";
4258
- readonly description: "Install the work-mesh, inbox and heartbeat services plus the skills directory";
4258
+ readonly description: "Install the inbox and heartbeat services plus the skills directory";
4259
4259
  readonly aliases: readonly [];
4260
4260
  readonly hidden: false;
4261
4261
  readonly usage: "[options]";
@@ -4383,7 +4383,7 @@ export declare const COMMAND_CATALOG: readonly [{
4383
4383
  }];
4384
4384
  }, {
4385
4385
  readonly name: "run";
4386
- readonly description: "Run one kit service in the foreground (sync|mesh|inbox|heartbeat), or `all` to supervise every service in one process";
4386
+ readonly description: "Run one kit service in the foreground (sync|inbox|heartbeat), or `all` to supervise every service in one process";
4387
4387
  readonly aliases: readonly [];
4388
4388
  readonly hidden: false;
4389
4389
  readonly usage: "[options] <service>";
@@ -4397,7 +4397,7 @@ export declare const COMMAND_CATALOG: readonly [{
4397
4397
  }];
4398
4398
  }, {
4399
4399
  readonly name: "probe";
4400
- readonly description: "Verify identity, vault sync, work-mesh presence, DMs and secrets; report to the console";
4400
+ readonly description: "Verify identity, vault access, presence, DMs and secrets; report to the console";
4401
4401
  readonly aliases: readonly [];
4402
4402
  readonly hidden: false;
4403
4403
  readonly usage: "[options]";
@@ -5507,7 +5507,7 @@ export const COMMAND_CATALOG = [
5507
5507
  "subcommands": [
5508
5508
  {
5509
5509
  "name": "install",
5510
- "description": "Install the work-mesh, inbox and heartbeat services plus the skills directory",
5510
+ "description": "Install the inbox and heartbeat services plus the skills directory",
5511
5511
  "aliases": [],
5512
5512
  "hidden": false,
5513
5513
  "usage": "[options]",
@@ -5668,7 +5668,7 @@ export const COMMAND_CATALOG = [
5668
5668
  },
5669
5669
  {
5670
5670
  "name": "run",
5671
- "description": "Run one kit service in the foreground (sync|mesh|inbox|heartbeat), or `all` to supervise every service in one process",
5671
+ "description": "Run one kit service in the foreground (sync|inbox|heartbeat), or `all` to supervise every service in one process",
5672
5672
  "aliases": [],
5673
5673
  "hidden": false,
5674
5674
  "usage": "[options] <service>",
@@ -5686,7 +5686,7 @@ export const COMMAND_CATALOG = [
5686
5686
  },
5687
5687
  {
5688
5688
  "name": "probe",
5689
- "description": "Verify identity, vault sync, work-mesh presence, DMs and secrets; report to the console",
5689
+ "description": "Verify identity, vault access, presence, DMs and secrets; report to the console",
5690
5690
  "aliases": [],
5691
5691
  "hidden": false,
5692
5692
  "usage": "[options]",
@@ -3,7 +3,6 @@
3
3
  * services that make an external agent a full team member:
4
4
  *
5
5
  * sync company vault pull loop → component-sync
6
- * mesh work-mesh doorbell listener → component-mesh
7
6
  * inbox inbox poller (mirror + optional ack) → component-inbox
8
7
  * heartbeat 60 s POST /v1/agents/{uid}/heartbeat with the three above
9
8
  *
@@ -77,7 +76,7 @@ export interface KitRuntime {
77
76
  }
78
77
  export declare function buildKitRuntime(service: KitService | "all"): KitRuntime;
79
78
  export declare function runKitService(service: KitService, rt: KitRuntime): Promise<void>;
80
- /** `kit run all`: supervise the four services as child processes with restart backoff. */
79
+ /** `kit run all`: supervise the kit services as child processes with restart backoff. */
81
80
  export declare function runAllKitServices(rt: KitRuntime, host: ServiceHostPaths): Promise<void>;
82
81
  export declare function formatFallbackStatus(paths: AgentKitPaths, kill?: KillFn): string[] | null;
83
82
  export declare function registerAgentKitCommand(agent: Command): void;
@@ -3,7 +3,6 @@
3
3
  * services that make an external agent a full team member:
4
4
  *
5
5
  * sync company vault pull loop → component-sync
6
- * mesh work-mesh doorbell listener → component-mesh
7
6
  * inbox inbox poller (mirror + optional ack) → component-inbox
8
7
  * heartbeat 60 s POST /v1/agents/{uid}/heartbeat with the three above
9
8
  *
@@ -28,12 +27,11 @@ import { agentKitPaths } from "../lib/agent-kit/paths.js";
28
27
  import { isKitService, KIT_SERVICES, enabledKitServices, kitServiceSpec, kitServiceSpecs, } from "../lib/agent-kit/services.js";
29
28
  import { writeKitSkills } from "../lib/agent-kit/skills.js";
30
29
  import { runHeartbeatLoop } from "../lib/agent-kit/run/heartbeat.js";
31
- import { pollInboxOnce, runInboxLoop } from "../lib/agent-kit/run/inbox.js";
30
+ import { runInboxLoop } from "../lib/agent-kit/run/inbox.js";
32
31
  import { superviseKitServices } from "../lib/agent-kit/run/supervisor.js";
33
32
  import { fallbackStatus, rebootCrontabLine, ensureRebootCrontab, shouldUseFallback, startFallback, stopFallback, } from "../lib/agent-kit/fallback.js";
34
33
  import { serviceLogPath } from "../lib/agent-kit/paths.js";
35
34
  import { spawn } from "node:child_process";
36
- import { startMeshListener } from "../lib/agent-kit/run/mesh-listener.js";
37
35
  import { pendingInbox } from "../lib/agent-kit/inbox-state.js";
38
36
  import { buildWakeNotice, clearWakeConfig, currentWakeState, DEFAULT_WAKE_RETRY_MS, describeWake, fireWake, readWakeConfig, validateWakeUrl, writeWakeConfig, } from "../lib/agent-kit/wake.js";
39
37
  import { runSyncLoop } from "../lib/agent-kit/run/sync.js";
@@ -182,53 +180,9 @@ export async function runKitService(service, rt) {
182
180
  log: rt.log,
183
181
  });
184
182
  return;
185
- case "mesh": {
186
- const handle = await startMeshListener({
187
- paths: rt.paths,
188
- agentUid: rt.creds.entityUid,
189
- apiBaseUrl: rt.creds.apiBaseUrl,
190
- refreshMs: rt.config.meshRefreshMs,
191
- getToken: rt.getToken,
192
- log: rt.log,
193
- // Any doorbell (and the periodic refresh) re-polls the agent inbox, the
194
- // only conversation source agent callers are authorized to read.
195
- refetch: async () => {
196
- const r = await pollInboxOnce({
197
- paths: rt.paths,
198
- agentUid: rt.creds.entityUid,
199
- apiBaseUrl: rt.creds.apiBaseUrl,
200
- pollMs: rt.config.inboxPollMs,
201
- ack: rt.config.inboxAck,
202
- getToken: rt.getToken,
203
- log: rt.log,
204
- });
205
- if (!r.ok)
206
- throw new Error("agent inbox poll failed; see inbox.log");
207
- },
208
- // An inbox doorbell means "poll now" rather than waiting for the loop.
209
- onInboxDoorbell: () => pollInboxOnce({
210
- paths: rt.paths,
211
- agentUid: rt.creds.entityUid,
212
- apiBaseUrl: rt.creds.apiBaseUrl,
213
- pollMs: rt.config.inboxPollMs,
214
- ack: rt.config.inboxAck,
215
- getToken: rt.getToken,
216
- log: rt.log,
217
- }),
218
- });
219
- const stop = () => {
220
- void handle.stop().then(() => process.exit(0));
221
- };
222
- process.once("SIGTERM", stop);
223
- process.once("SIGINT", stop);
224
- await new Promise(() => {
225
- /* resident until signalled */
226
- });
227
- return;
228
- }
229
183
  }
230
184
  }
231
- /** `kit run all`: supervise the four services as child processes with restart backoff. */
185
+ /** `kit run all`: supervise the kit services as child processes with restart backoff. */
232
186
  export async function runAllKitServices(rt, host) {
233
187
  const services = enabledKitServices({ syncFiles: rt.config.syncFiles });
234
188
  rt.log("info", `supervisor starting ${services.join(",")} agent=${rt.creds.entityUid} cli=${CLI_VERSION}`);
@@ -286,7 +240,7 @@ export function registerAgentKitCommand(agent) {
286
240
  .description("Install, inspect, or run the external-agent background services");
287
241
  kit
288
242
  .command("install")
289
- .description("Install the work-mesh, inbox and heartbeat services plus the skills directory")
243
+ .description("Install the inbox and heartbeat services plus the skills directory")
290
244
  .option("--hq-root <path>", "Local HQ tree to sync the company vault into (default: ~/.hq-agent/hq)")
291
245
  .option("--sync-files", "Also mirror the company vault to disk. Off by default: files are read on demand through the hq MCP server")
292
246
  .option("--no-sync-files", "Do not mirror the vault to disk (default; removes the loop if an earlier install added it)")
@@ -5,11 +5,8 @@
5
5
  * whoami mint as the machine identity; token names this agent
6
6
  * files a vault listing answers on demand (or, with --sync-files, a
7
7
  * pull succeeds and the company folder exists)
8
- * work-mesh the personal (contract-2) realtime vend names this agent, the
9
- * agent-authorized inbox route answers 200, and this host's own
10
- * presence is live: a fresh last-heartbeat.json plus an ok
11
- * component-mesh stamp from the doorbell listener. (The company
12
- * roster route is owner/admin-only and 404s for agt_ callers.)
8
+ * presence the agent-authorized inbox route answers 200 and this host's
9
+ * own presence is live (a fresh last-heartbeat.json)
13
10
  * dm send a DM to self via POST /v1/notify/dm (accepted) and read the agent inbox (200)
14
11
  * through GET /v1/agents/{uid}/inbox (the agent read surface;
15
12
  * /v1/notify/thread is intentionally closed to agents)
@@ -51,9 +48,6 @@ export interface ProbeDeps {
51
48
  runHq: (args: string[]) => Promise<number>;
52
49
  /** Mirror the vault to disk. Off by default: files are read through the API. */
53
50
  syncFiles?: boolean;
54
- vendRealtime: (token: string) => Promise<{
55
- actorUid: string;
56
- }>;
57
51
  /** GET /v1/agents/{self}/inbox — agent-authorized, self-scoped. */
58
52
  readInbox: (token: string) => Promise<InboxFetchResult>;
59
53
  sendDm: (token: string, toUid: string, body: string) => Promise<void>;
@@ -5,11 +5,8 @@
5
5
  * whoami mint as the machine identity; token names this agent
6
6
  * files a vault listing answers on demand (or, with --sync-files, a
7
7
  * pull succeeds and the company folder exists)
8
- * work-mesh the personal (contract-2) realtime vend names this agent, the
9
- * agent-authorized inbox route answers 200, and this host's own
10
- * presence is live: a fresh last-heartbeat.json plus an ok
11
- * component-mesh stamp from the doorbell listener. (The company
12
- * roster route is owner/admin-only and 404s for agt_ callers.)
8
+ * presence the agent-authorized inbox route answers 200 and this host's
9
+ * own presence is live (a fresh last-heartbeat.json)
13
10
  * dm send a DM to self via POST /v1/notify/dm (accepted) and read the agent inbox (200)
14
11
  * through GET /v1/agents/{uid}/inbox (the agent read surface;
15
12
  * /v1/notify/thread is intentionally closed to agents)
@@ -33,9 +30,6 @@ import { ensureCognitoToken } from "../utils/cognito-session.js";
33
30
  import { peekIdToken } from "../utils/id-token.js";
34
31
  import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
35
32
  import { sendAgentDm } from "./agents.js";
36
- import { createPersonalRealtimeFetcher } from "../lib/mesh/live/daemon/credentials.js";
37
- import { readComponentStatus } from "../lib/agent-kit/creds.js";
38
- import { COMPONENT_STALE_AFTER_MS } from "../lib/agent-kit/run/heartbeat.js";
39
33
  import { defaultFetchInbox } from "../lib/agent-kit/run/inbox.js";
40
34
  import { agentKitPaths } from "../lib/agent-kit/paths.js";
41
35
  import { readKitConfig } from "../lib/agent-kit/kit-config.js";
@@ -142,40 +136,33 @@ export async function runProbe(deps) {
142
136
  else {
143
137
  checks.push({ name: "files", ok: false, detail: "skipped: no token" });
144
138
  }
145
- // 3. work-mesh: vend + agent-authorized reachability + self presence
146
- if (token && companyUid) {
139
+ // 3. presence: HQ can see this agent, and the agent can read its own inbox.
140
+ //
141
+ // This replaces a work-mesh check that also required the realtime doorbell
142
+ // to be connected. The doorbell was never what made an agent reachable —
143
+ // messages arrive over the inbox either way — and on a host whose egress
144
+ // intercepts that connection it could never pass, so a perfectly healthy
145
+ // bot reported a failure it could do nothing about.
146
+ if (token) {
147
147
  try {
148
- const vend = await deps.vendRealtime(token);
149
- if (vend.actorUid !== self) {
150
- checks.push({ name: "work-mesh", ok: false, detail: `realtime vend is for ${vend.actorUid}` });
151
- }
152
- else {
153
- const inbox = await deps.readInbox(token);
154
- const age = heartbeatAgeSeconds(deps.paths, now);
155
- const mesh = readComponentStatus(deps.paths, "mesh", COMPONENT_STALE_AFTER_MS.mesh, now);
156
- const reachable = inbox.status === 200;
157
- const fresh = age !== null && age <= PRESENCE_FRESH_SECONDS;
158
- const meshOk = mesh.status === "ok";
159
- const ok = reachable && fresh && meshOk;
160
- const parts = [
161
- "realtime vend ok",
162
- `inbox GET → ${inbox.status}`,
163
- `heartbeat ${age === null ? "never" : `${age}s ago`}`,
164
- `mesh listener ${mesh.status}${mesh.at ? ` @${mesh.at.toISOString()}` : ""}`,
165
- ];
166
- checks.push({
167
- name: "work-mesh",
168
- ok,
169
- detail: `${parts.join("; ")}${ok ? "" : " — is `hq agent kit` installed and running? see ~/.hq-agent/logs/mesh.log"}`,
170
- });
171
- }
148
+ const inbox = await deps.readInbox(token);
149
+ const age = heartbeatAgeSeconds(deps.paths, now);
150
+ const reachable = inbox.status === 200;
151
+ const fresh = age !== null && age <= PRESENCE_FRESH_SECONDS;
152
+ const ok = reachable && fresh;
153
+ checks.push({
154
+ name: "presence",
155
+ ok,
156
+ detail: `inbox GET → ${inbox.status}; heartbeat ${age === null ? "never" : `${age}s ago`}` +
157
+ (ok ? "" : " is `hq agent kit` installed and running? see ~/.hq-agent/logs/"),
158
+ });
172
159
  }
173
160
  catch (err) {
174
- checks.push({ name: "work-mesh", ok: false, detail: errText(err) });
161
+ checks.push({ name: "presence", ok: false, detail: errText(err) });
175
162
  }
176
163
  }
177
164
  else {
178
- checks.push({ name: "work-mesh", ok: false, detail: "skipped: no token/company" });
165
+ checks.push({ name: "presence", ok: false, detail: "skipped: no token" });
179
166
  }
180
167
  // 4. dm: the send is accepted and the agent inbox is readable.
181
168
  // The server never delivers a self-DM into the sender's own inbox
@@ -289,10 +276,6 @@ export function defaultProbeDeps(paths, creds) {
289
276
  getToken: () => ensureCognitoToken({ tokenSource: "machine", interactive: false }),
290
277
  resolveCompanyUid: (token, slug) => getCompanyUid(token, slug),
291
278
  runHq: defaultRunPull(process.execPath, process.argv[1], process.env),
292
- vendRealtime: async (token) => {
293
- const bundle = await createPersonalRealtimeFetcher({ token, baseUrl: base })();
294
- return { actorUid: bundle.actorUid };
295
- },
296
279
  readInbox: (token) => defaultFetchInbox(creds.entityUid, base)(token),
297
280
  sendDm: (token, to, body) => sendAgentDm(token, to, body),
298
281
  listSecrets: async (token, companyUid) => {
@@ -318,7 +301,7 @@ export function defaultProbeDeps(paths, creds) {
318
301
  export function registerAgentProbeCommand(agent) {
319
302
  agent
320
303
  .command("probe")
321
- .description("Verify identity, vault sync, work-mesh presence, DMs and secrets; report to the console")
304
+ .description("Verify identity, vault access, presence, DMs and secrets; report to the console")
322
305
  .option("--json", "Print machine-readable JSON")
323
306
  .action(async (opts) => {
324
307
  try {
@@ -20,7 +20,7 @@ export declare const HOST_KEY_PUB_NAME = "host-key.pub";
20
20
  export declare const MACHINE_CREDS_NAME = "machine-creds.json";
21
21
  export declare const KIT_CONFIG_NAME = "kit.json";
22
22
  export declare const LAST_HEARTBEAT_NAME = "last-heartbeat.json";
23
- export type KitComponent = "sync" | "mesh" | "inbox";
23
+ export type KitComponent = "sync" | "inbox";
24
24
  export declare const KIT_COMPONENTS: readonly KitComponent[];
25
25
  export interface AgentKitPaths {
26
26
  home: string;
@@ -22,7 +22,7 @@ export const HOST_KEY_PUB_NAME = "host-key.pub";
22
22
  export const MACHINE_CREDS_NAME = "machine-creds.json";
23
23
  export const KIT_CONFIG_NAME = "kit.json";
24
24
  export const LAST_HEARTBEAT_NAME = "last-heartbeat.json";
25
- export const KIT_COMPONENTS = ["sync", "mesh", "inbox"];
25
+ export const KIT_COMPONENTS = ["sync", "inbox"];
26
26
  export function agentDir(home = os.homedir(), env = process.env) {
27
27
  const override = env[AGENT_DIR_ENV]?.trim();
28
28
  if (override)
@@ -20,7 +20,6 @@ import { KIT_COMPONENTS } from "../paths.js";
20
20
  /** A component stamp older than this reads as error. */
21
21
  export const COMPONENT_STALE_AFTER_MS = {
22
22
  sync: 3 * 60 * 60_000, // sync passes every 5 min; three hours of silence is real trouble
23
- mesh: 3 * 60_000,
24
23
  inbox: 3 * 60_000,
25
24
  };
26
25
  export function buildHeartbeatBody(paths, now = () => new Date(), hostInfo, components_ = KIT_COMPONENTS) {
@@ -1,11 +1,11 @@
1
1
  /**
2
- * The four kit services and how each is launched. Every unit runs
2
+ * The kit services and how each is launched. Every unit runs
3
3
  * `node hq agent kit run <service>`; the service reads kit.json and the
4
4
  * machine-creds file itself, so the units carry paths and nothing else.
5
5
  */
6
6
  import type { ServiceSpec } from "../service-manager/types.js";
7
7
  import type { AgentKitPaths } from "./paths.js";
8
- export type KitService = "sync" | "mesh" | "inbox" | "heartbeat";
8
+ export type KitService = "sync" | "inbox" | "heartbeat";
9
9
  export declare const KIT_SERVICES: readonly KitService[];
10
10
  export declare const KIT_LABEL_PREFIX = "ai.getindigo.hq-agent";
11
11
  export declare function isKitService(value: string): value is KitService;
@@ -1,17 +1,16 @@
1
1
  /**
2
- * The four kit services and how each is launched. Every unit runs
2
+ * The kit services and how each is launched. Every unit runs
3
3
  * `node hq agent kit run <service>`; the service reads kit.json and the
4
4
  * machine-creds file itself, so the units carry paths and nothing else.
5
5
  */
6
6
  import { serviceLogPath } from "./paths.js";
7
- export const KIT_SERVICES = ["sync", "mesh", "inbox", "heartbeat"];
7
+ export const KIT_SERVICES = ["sync", "inbox", "heartbeat"];
8
8
  export const KIT_LABEL_PREFIX = "ai.getindigo.hq-agent";
9
9
  export function isKitService(value) {
10
10
  return KIT_SERVICES.includes(value);
11
11
  }
12
12
  const DESCRIPTIONS = {
13
13
  sync: "HQ agent kit: company vault sync loop",
14
- mesh: "HQ agent kit: work-mesh doorbell listener",
15
14
  inbox: "HQ agent kit: inbox poller",
16
15
  heartbeat: "HQ agent kit: heartbeat reporter",
17
16
  };
@@ -125,29 +125,6 @@ Rules:
125
125
  - Request the narrowest \`--only\` set the command needs.
126
126
  - If a secret is missing, say which NAME is missing and ask a human admin to
127
127
  add it with \`hq secrets set\` — do not ask for the value in chat.
128
- `,
129
- },
130
- {
131
- name: "work-mesh-status",
132
- description: "Report and update this agent's live work status and read the team's work mesh via the hq CLI.",
133
- body: `# Work mesh status
134
-
135
- The work mesh is HQ's live board of who is working on what. It is the source
136
- of truth for project stories and active sessions. The kit's listener re-polls
137
- this agent's inbox whenever a doorbell rings.
138
-
139
- \`\`\`bash
140
- hq mesh session status --company <slug> # who is working on what right now
141
- hq mesh start --company <slug> --project <slug> --summary "<what you are starting>"
142
- hq mesh progress --company <slug> --project <slug> --summary "<what changed>"
143
- hq mesh blocked --company <slug> --project <slug> --summary "<what blocks you>"
144
- hq mesh done --company <slug> --project <slug> --summary "<what you finished>"
145
- hq mesh note --company <slug> --project <slug> --summary "<short note>"
146
- \`\`\`
147
-
148
- Post \`start\` when you pick up a piece of work and \`done\` when you finish it.
149
- Keep entries to one line. Presence (online / stale / offline) is derived
150
- from the kit heartbeat automatically — you do not need to report it.
151
128
  `,
152
129
  },
153
130
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.120.1",
3
+ "version": "5.121.0",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -1,103 +0,0 @@
1
- /**
2
- * Kit service: work-mesh doorbell listener.
3
- *
4
- * Policy hq-work-mesh-source-of-truth: the work mesh (REST) is the source of
5
- * truth; MQTT carries IDS-ONLY doorbells on the agent's personal topics
6
- * `hq/{agt_*}/{dm,work,sessions,notifications,inbox}`. This listener is the cache
7
- * writer: on any doorbell it refetches through the REST API into
8
- * ~/.hq/work-mesh/cache (warmMeshConversationCache) — message bodies are
9
- * never taken from MQTT. A periodic refresh covers missed doorbells.
10
- *
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
19
- * connection is rebuilt before the vended credentials expire. `component-mesh`
20
- * is stamped ok while subscribed and refreshing, error otherwise.
21
- */
22
- import { type IClientOptions } from "mqtt";
23
- import { type PersonalCredentialsFetcher, type PersonalRealtimeBundle } from "../../mesh/live/daemon/credentials.js";
24
- import type { KitLogger } from "../log.js";
25
- import type { AgentKitPaths } from "../paths.js";
26
- export declare const DOORBELL_KINDS: readonly ["dm", "work", "sessions", "notifications", "inbox"];
27
- export declare const DOORBELL_DEBOUNCE_MS = 2000;
28
- export declare const RECONNECT_BASE_MS = 1000;
29
- export declare const RECONNECT_MAX_MS = 60000;
30
- /**
31
- * Consecutive failed MQTT attempts before the listener stops treating the
32
- * doorbell as reachable and runs on the periodic refetch alone.
33
- *
34
- * The doorbell is an OPTIMISATION, not the source of truth: policy
35
- * hq-work-mesh-source-of-truth puts the work mesh behind REST, MQTT only
36
- * carries ids, and `schedulePeriodic` already refetches on its own cadence. So
37
- * a host that cannot reach AWS IoT is not a broken agent — it is an agent with
38
- * a slower doorbell. Some bot sandboxes sit behind an egress proxy that
39
- * intercepts the connection and answers with non-TLS bytes (observed on the
40
- * Muse pilot: `tls_validate_record_header:wrong version number`, reconnecting
41
- * forever while vend, inbox and heartbeat were all healthy). Before this, that
42
- * host retried until the process died and reported component-mesh=error the
43
- * whole time.
44
- */
45
- export declare const MESH_FALLBACK_AFTER_ATTEMPTS = 5;
46
- /**
47
- * How often polling mode re-tries MQTT. Long, because the usual cause is the
48
- * network the host is on, which does not change minute to minute — but it is
49
- * retried, so a bot moved to an unrestricted network recovers its instant
50
- * doorbell without being reinstalled.
51
- */
52
- export declare const MESH_FALLBACK_RETRY_MS: number;
53
- export declare function doorbellTopics(actorUid: string): string[];
54
- /**
55
- * Doorbell topics this session may subscribe to: the intersection of
56
- * DOORBELL_KINDS with the personal topics the vend advertises. AWS IoT
57
- * disconnects the client on any unauthorized topic in a SUBSCRIBE, so a kind
58
- * the server does not (yet) grant is skipped rather than risked.
59
- */
60
- export declare function grantedDoorbellTopics(bundle: Pick<PersonalRealtimeBundle, "actorUid" | "topics">): string[];
61
- export declare function isInboxDoorbell(topic: string): boolean;
62
- /** Minimal mqtt client surface the listener needs (test seam). */
63
- export interface DoorbellMqttClient {
64
- on(event: string, handler: (...args: any[]) => void): unknown;
65
- subscribe(topics: string | string[], opts: {
66
- qos: 0 | 1 | 2;
67
- }, callback?: (err: Error | null) => void): unknown;
68
- end(force?: boolean, opts?: unknown, cb?: () => void): unknown;
69
- }
70
- export type DoorbellConnectFn = (url: string, opts: IClientOptions) => DoorbellMqttClient;
71
- export interface MeshListenerDeps {
72
- paths: AgentKitPaths;
73
- agentUid: string;
74
- apiBaseUrl: string;
75
- refreshMs: number;
76
- getToken: () => Promise<string>;
77
- log: KitLogger;
78
- fetchCredentials?: PersonalCredentialsFetcher;
79
- /** Called immediately (not debounced) when an `hq/{uid}/inbox` doorbell rings. */
80
- onInboxDoorbell?: () => Promise<unknown> | void;
81
- connect?: DoorbellConnectFn;
82
- refetch?: (token: string, actorUid: string) => Promise<unknown>;
83
- now?: () => Date;
84
- random?: () => number;
85
- setTimeout?: (fn: () => void, ms: number) => unknown;
86
- clearTimeout?: (handle: unknown) => void;
87
- }
88
- export interface MeshListenerHandle {
89
- stop: () => Promise<void>;
90
- /** Force a cache refetch (tests / doorbell path). */
91
- refetchNow: (reason: string) => Promise<void>;
92
- /** Simulate a doorbell (tests). */
93
- ring: (topic: string) => void;
94
- state: () => MeshListenerState;
95
- }
96
- /**
97
- * `polling` is a healthy state: the doorbell is unreachable, so the cache is
98
- * kept current by the periodic refetch alone. Incoming DMs are unaffected —
99
- * the inbox poller is a separate service on its own interval.
100
- */
101
- export type MeshListenerState = "idle" | "connecting" | "subscribed" | "polling" | "closed";
102
- export declare function startMeshListener(deps: MeshListenerDeps): Promise<MeshListenerHandle>;
103
- //# sourceMappingURL=mesh-listener.d.ts.map
@@ -1,296 +0,0 @@
1
- /**
2
- * Kit service: work-mesh doorbell listener.
3
- *
4
- * Policy hq-work-mesh-source-of-truth: the work mesh (REST) is the source of
5
- * truth; MQTT carries IDS-ONLY doorbells on the agent's personal topics
6
- * `hq/{agt_*}/{dm,work,sessions,notifications,inbox}`. This listener is the cache
7
- * writer: on any doorbell it refetches through the REST API into
8
- * ~/.hq/work-mesh/cache (warmMeshConversationCache) — message bodies are
9
- * never taken from MQTT. A periodic refresh covers missed doorbells.
10
- *
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
19
- * connection is rebuilt before the vended credentials expire. `component-mesh`
20
- * is stamped ok while subscribed and refreshing, error otherwise.
21
- */
22
- import mqtt from "mqtt";
23
- import { defaultFetchInbox } from "./inbox.js";
24
- import { createPersonalRealtimeFetcher, MQTT_KEEPALIVE_SECONDS, renewalDelayMs, } from "../../mesh/live/daemon/credentials.js";
25
- import { presignIotWssUrl } from "../../mesh/live/daemon/presign.js";
26
- import { writeComponentStatus } from "../creds.js";
27
- export const DOORBELL_KINDS = ["dm", "work", "sessions", "notifications", "inbox"];
28
- export const DOORBELL_DEBOUNCE_MS = 2_000;
29
- export const RECONNECT_BASE_MS = 1_000;
30
- export const RECONNECT_MAX_MS = 60_000;
31
- /**
32
- * Consecutive failed MQTT attempts before the listener stops treating the
33
- * doorbell as reachable and runs on the periodic refetch alone.
34
- *
35
- * The doorbell is an OPTIMISATION, not the source of truth: policy
36
- * hq-work-mesh-source-of-truth puts the work mesh behind REST, MQTT only
37
- * carries ids, and `schedulePeriodic` already refetches on its own cadence. So
38
- * a host that cannot reach AWS IoT is not a broken agent — it is an agent with
39
- * a slower doorbell. Some bot sandboxes sit behind an egress proxy that
40
- * intercepts the connection and answers with non-TLS bytes (observed on the
41
- * Muse pilot: `tls_validate_record_header:wrong version number`, reconnecting
42
- * forever while vend, inbox and heartbeat were all healthy). Before this, that
43
- * host retried until the process died and reported component-mesh=error the
44
- * whole time.
45
- */
46
- export const MESH_FALLBACK_AFTER_ATTEMPTS = 5;
47
- /**
48
- * How often polling mode re-tries MQTT. Long, because the usual cause is the
49
- * network the host is on, which does not change minute to minute — but it is
50
- * retried, so a bot moved to an unrestricted network recovers its instant
51
- * doorbell without being reinstalled.
52
- */
53
- export const MESH_FALLBACK_RETRY_MS = 30 * 60_000;
54
- export function doorbellTopics(actorUid) {
55
- return DOORBELL_KINDS.map((k) => `hq/${actorUid}/${k}`);
56
- }
57
- /**
58
- * Doorbell topics this session may subscribe to: the intersection of
59
- * DOORBELL_KINDS with the personal topics the vend advertises. AWS IoT
60
- * disconnects the client on any unauthorized topic in a SUBSCRIBE, so a kind
61
- * the server does not (yet) grant is skipped rather than risked.
62
- */
63
- export function grantedDoorbellTopics(bundle) {
64
- const advertised = new Set(Object.values(bundle.topics));
65
- return doorbellTopics(bundle.actorUid).filter((t) => advertised.has(t));
66
- }
67
- export function isInboxDoorbell(topic) {
68
- return /^hq\/[^/]+\/inbox$/.test(topic);
69
- }
70
- function backoff(attempt, random) {
71
- const cap = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** attempt);
72
- return Math.max(RECONNECT_BASE_MS, Math.floor(random() * cap));
73
- }
74
- export async function startMeshListener(deps) {
75
- const now = deps.now ?? (() => new Date());
76
- const random = deps.random ?? Math.random;
77
- const setT = deps.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
78
- const clearT = deps.clearTimeout ?? ((h) => clearTimeout(h));
79
- const connect = deps.connect ?? ((url, opts) => mqtt.connect(url, opts));
80
- // Agent callers are refused by the human conversation routes
81
- // (/v1/notify/inbox|contacts|thread → 403 AGENT_ROUTE_NOT_SUPPORTED), so the
82
- // default refetch reads the agent's own inbox, the agent-authorized source.
83
- const refetch = deps.refetch ??
84
- (async (token, uid) => {
85
- const res = await defaultFetchInbox(uid, deps.apiBaseUrl)(token);
86
- if (res.status !== 200) {
87
- throw new Error(`GET ${res.url ?? "agent inbox"} → ${res.status}${res.bodyText ? ` body=${res.bodyText}` : ""}`);
88
- }
89
- });
90
- const fetchCredentials = deps.fetchCredentials ??
91
- (async () => {
92
- const token = await deps.getToken();
93
- return createPersonalRealtimeFetcher({ token, baseUrl: deps.apiBaseUrl })();
94
- });
95
- let state = "idle";
96
- /**
97
- * The doorbell is unreachable and the periodic refetch is carrying the
98
- * cache. Tracked separately from `state`, which follows the MQTT connection
99
- * through every retry attempt.
100
- */
101
- let polling = false;
102
- let client = null;
103
- let stopped = false;
104
- let attempt = 0;
105
- let debounce = null;
106
- let renewal = null;
107
- let periodic = null;
108
- let refetching = null;
109
- let pendingReason = null;
110
- const doRefetch = async (reason) => {
111
- if (refetching) {
112
- pendingReason = reason;
113
- return refetching;
114
- }
115
- refetching = (async () => {
116
- try {
117
- const token = await deps.getToken();
118
- await refetch(token, deps.agentUid);
119
- // Polling counts as healthy: the component's job is keeping the cache
120
- // current, and in this mode it is doing exactly that, just on the
121
- // periodic cadence instead of on a doorbell.
122
- const healthy = state === "subscribed" || polling;
123
- writeComponentStatus(deps.paths, "mesh", healthy ? "ok" : "error");
124
- deps.log("info", `cache refetched (${reason})`);
125
- }
126
- catch (err) {
127
- writeComponentStatus(deps.paths, "mesh", "error");
128
- deps.log("error", `refetch failed (${reason}): ${err instanceof Error ? err.message : String(err)}`);
129
- }
130
- finally {
131
- refetching = null;
132
- }
133
- if (pendingReason) {
134
- const next = pendingReason;
135
- pendingReason = null;
136
- await doRefetch(next);
137
- }
138
- })();
139
- return refetching;
140
- };
141
- const ring = (topic) => {
142
- if (isInboxDoorbell(topic) && deps.onInboxDoorbell) {
143
- try {
144
- void Promise.resolve(deps.onInboxDoorbell()).catch((err) => deps.log("warn", `inbox poll on doorbell failed: ${err instanceof Error ? err.message : String(err)}`));
145
- }
146
- catch (err) {
147
- deps.log("warn", `inbox poll on doorbell failed: ${err instanceof Error ? err.message : String(err)}`);
148
- }
149
- }
150
- if (debounce)
151
- clearT(debounce);
152
- debounce = setT(() => {
153
- debounce = null;
154
- void doRefetch(`doorbell ${topic}`);
155
- }, DOORBELL_DEBOUNCE_MS);
156
- };
157
- const schedulePeriodic = () => {
158
- if (periodic)
159
- clearT(periodic);
160
- periodic = setT(() => {
161
- periodic = null;
162
- void doRefetch("periodic").then(schedulePeriodic);
163
- }, deps.refreshMs);
164
- };
165
- const scheduleReconnect = () => {
166
- if (stopped)
167
- return;
168
- if (attempt >= MESH_FALLBACK_AFTER_ATTEMPTS) {
169
- // Stop reporting an unreachable doorbell as a broken component, and stop
170
- // hammering a connection this host cannot make. The periodic refetch
171
- // carries the cache; MQTT is retried occasionally in case the network
172
- // changes.
173
- const wasPolling = polling;
174
- polling = true;
175
- if (!wasPolling) {
176
- deps.log("warn", `mqtt unreachable after ${attempt} attempts; falling back to periodic refetch every ${deps.refreshMs}ms. ` +
177
- "Incoming DMs are unaffected (the inbox poller is a separate service). " +
178
- `Retrying mqtt in ${MESH_FALLBACK_RETRY_MS}ms.`);
179
- void doRefetch("fallback");
180
- }
181
- setT(() => {
182
- attempt = 0;
183
- void connectOnce();
184
- }, MESH_FALLBACK_RETRY_MS);
185
- return;
186
- }
187
- const delay = backoff(attempt, random);
188
- attempt += 1;
189
- deps.log("warn", `mqtt reconnect in ${delay}ms (attempt ${attempt})`);
190
- setT(() => void connectOnce(), delay);
191
- };
192
- const connectOnce = async () => {
193
- if (stopped)
194
- return;
195
- state = "connecting";
196
- let bundle;
197
- try {
198
- bundle = await fetchCredentials();
199
- if (bundle.actorUid !== deps.agentUid) {
200
- throw new Error(`realtime vend is for ${bundle.actorUid}, expected ${deps.agentUid}`);
201
- }
202
- }
203
- catch (err) {
204
- if (!polling)
205
- writeComponentStatus(deps.paths, "mesh", "error");
206
- deps.log("error", `credential vend failed: ${err instanceof Error ? err.message : String(err)}`);
207
- scheduleReconnect();
208
- return;
209
- }
210
- const url = presignIotWssUrl(bundle.credentials, bundle.iotEndpoint, bundle.region, now());
211
- const c = connect(url, {
212
- clientId: bundle.clientId,
213
- keepalive: MQTT_KEEPALIVE_SECONDS,
214
- clean: true,
215
- reconnectPeriod: 0,
216
- protocolVersion: 4,
217
- });
218
- client = c;
219
- let settled = false;
220
- const topics = grantedDoorbellTopics(bundle);
221
- const skipped = doorbellTopics(bundle.actorUid).filter((t) => !topics.includes(t));
222
- if (skipped.length > 0)
223
- deps.log("info", `vend does not grant ${skipped.join(", ")}; not subscribing`);
224
- c.on("connect", () => {
225
- if (topics.length === 0) {
226
- deps.log("error", "realtime vend advertises no doorbell topics");
227
- c.end(true);
228
- return;
229
- }
230
- c.subscribe(topics, { qos: 1 }, (err) => {
231
- if (err) {
232
- deps.log("error", `subscribe failed: ${err.message}`);
233
- c.end(true);
234
- return;
235
- }
236
- settled = true;
237
- attempt = 0;
238
- polling = false;
239
- state = "subscribed";
240
- writeComponentStatus(deps.paths, "mesh", "ok");
241
- deps.log("info", `subscribed to ${topics.length} doorbell topics`);
242
- void doRefetch("connect");
243
- if (renewal)
244
- clearT(renewal);
245
- renewal = setT(() => {
246
- deps.log("info", "renewing realtime credentials");
247
- c.end(true);
248
- }, renewalDelayMs(now().getTime(), bundle.expiresAt));
249
- });
250
- });
251
- c.on("message", (topic) => {
252
- // Ids-only doorbell: the payload is never parsed for content.
253
- ring(topic);
254
- });
255
- c.on("error", (err) => {
256
- deps.log("warn", `mqtt error: ${err.message}`);
257
- });
258
- c.on("close", () => {
259
- if (client !== c)
260
- return;
261
- client = null;
262
- const wasSubscribed = state === "subscribed";
263
- state = stopped ? "closed" : "idle";
264
- // A close while polling is the periodic MQTT retry failing again; the
265
- // periodic refetch is still doing the component's job, so it is not an
266
- // error.
267
- if (!polling)
268
- writeComponentStatus(deps.paths, "mesh", "error");
269
- if (!settled || !wasSubscribed)
270
- deps.log("warn", "mqtt closed before subscribe settled");
271
- scheduleReconnect();
272
- });
273
- };
274
- schedulePeriodic();
275
- await connectOnce();
276
- return {
277
- stop: async () => {
278
- stopped = true;
279
- state = "closed";
280
- if (debounce)
281
- clearT(debounce);
282
- if (renewal)
283
- clearT(renewal);
284
- if (periodic)
285
- clearT(periodic);
286
- const c = client;
287
- client = null;
288
- if (c)
289
- await new Promise((r) => c.end(true, undefined, () => r()));
290
- },
291
- refetchNow: doRefetch,
292
- ring,
293
- state: () => (polling && state !== "subscribed" && state !== "closed" ? "polling" : state),
294
- };
295
- }
296
- //# sourceMappingURL=mesh-listener.js.map