@indigoai-us/hq-cli 5.119.1 → 5.119.5

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,68 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.119.5] — 2026-09-17
6
+
7
+ ### Fixed
8
+
9
+ - When a new Slack agent is waiting for a Socket Mode app-level token, `hq
10
+ agents status` now shows a link to create the token and a direct link to the
11
+ HQ form where it can be pasted. The same links are included in JSON output.
12
+
13
+ ### Changed
14
+
15
+ - Starter now includes 1 integration instead of none, and the lock copy says
16
+ so. The notice reads "too many integrations", quotes "Integrations allowed
17
+ on Starter: 1", and tells you to "disconnect integrations until you are at 1
18
+ or fewer"; the per-turn line reads "over its 1-integration limit". Nothing in
19
+ the CLI says Starter has no integrations any more.
20
+
21
+ ## [5.119.4] — 2026-09-17
22
+
23
+ ### Fixed
24
+
25
+ - On a host with no usable service manager, `hq agent kit install` now installs
26
+ the `@reboot` crontab entry that restarts the kit, instead of printing the
27
+ line and trusting it to be pasted. A supervisor that does not survive a
28
+ reboot is not a supervisor: on the Muse pilot the host restarted, the
29
+ supervisor died with it, nothing brought it back, and the agent silently
30
+ stopped seeing new messages while every surface still reported healthy. The
31
+ entry is marked and replaced on reinstall rather than stacked, and every
32
+ other crontab line is preserved. If the crontab cannot be written the install
33
+ still succeeds but says so in red, naming the consequence and printing the
34
+ line to add by hand.
35
+
36
+ ## [5.119.3] — 2026-09-17
37
+
38
+ ### Fixed
39
+
40
+ - `hq agent inbox` now merges the agent's own server inbox with the kit's local
41
+ mirror instead of reading the mirror alone. The mirror is only as fresh as
42
+ the kit's poller, and when that process dies nothing says so — the listing
43
+ simply stops growing. On the Muse pilot the host rebooted, the kit supervisor
44
+ died with it and was never restarted, and the command kept confidently
45
+ answering with the eight messages from before the reboot while the server had
46
+ every newer one: no error, no empty result, no stale marker, just an agent
47
+ that had gone deaf and could not tell. When the server cannot be reached the
48
+ mirror is still returned, with a note saying it may be stale. `hq_inbox_read`
49
+ already merged; both now share one implementation.
50
+
51
+ ## [5.119.2] — 2026-09-17
52
+
53
+ ### Fixed
54
+
55
+ - The work-mesh listener falls back to its periodic refetch when the MQTT
56
+ doorbell is unreachable, instead of retrying forever and reporting
57
+ `component-mesh=error` the whole time. The doorbell is an optimisation over a
58
+ REST source of truth, so a host that cannot reach AWS IoT has a slower
59
+ doorbell, not a broken agent. Some bot sandboxes sit behind an egress proxy
60
+ that intercepts the connection and answers with non-TLS bytes
61
+ (`tls_validate_record_header:wrong version number`). After five failed
62
+ attempts the listener says so once, keeps the cache current on the periodic
63
+ cadence, and retries MQTT every 30 minutes so a bot moved to an unrestricted
64
+ network recovers its instant doorbell on its own. Incoming DMs are
65
+ unaffected either way — the inbox poller is a separate service.
66
+
5
67
  ## [5.119.1] — 2026-09-17
6
68
 
7
69
  ### Fixed
@@ -8,7 +8,7 @@ import chalk from "chalk";
8
8
  import * as os from "node:os";
9
9
  import { ensureCognitoToken } from "../utils/cognito-session.js";
10
10
  import { vaultApiFetch } from "../utils/vault-api.js";
11
- import { markInboxDone, readDoneIds, readMirroredInbox, summarizeInboxEntry } from "../lib/agent-kit/inbox-state.js";
11
+ import { markInboxDone, mergeAgentInbox, readDoneIds, summarizeInboxEntry } from "../lib/agent-kit/inbox-state.js";
12
12
  import { agentKitPaths } from "../lib/agent-kit/paths.js";
13
13
  import { requireExternalCreds } from "./agent-kit.js";
14
14
  const ID_RE = /^[A-Za-z0-9._:-]{1,200}$/;
@@ -18,16 +18,31 @@ export function registerAgentInboxCommand(agent) {
18
18
  .description("List this agent's pending HQ messages (mirrored by the kit), or mark them handled")
19
19
  .option("--all", "Include items already marked handled")
20
20
  .option("--json", "Print machine-readable JSON")
21
- .action((opts) => {
21
+ .action(async (opts) => {
22
22
  const paths = agentKitPaths(os.homedir(), process.env);
23
+ const creds = requireExternalCreds(paths);
23
24
  const done = readDoneIds(paths);
24
- const rows = readMirroredInbox(paths)
25
+ // Merged with the server, never the local mirror alone: a dead kit
26
+ // poller would otherwise make this command answer confidently with
27
+ // yesterday's mail and no sign anything was wrong.
28
+ const { entries, note } = await mergeAgentInbox(paths, async () => {
29
+ const token = await ensureCognitoToken({ tokenSource: "machine", interactive: false });
30
+ const res = await vaultApiFetch({
31
+ token,
32
+ baseUrl: creds.apiBaseUrl,
33
+ path: `/v1/agents/${encodeURIComponent(creds.entityUid)}/inbox`,
34
+ });
35
+ return { status: res.status, body: await res.json().catch(() => ({})) };
36
+ });
37
+ const rows = entries
25
38
  .filter((e) => opts.all || !done.has(e.id))
26
39
  .map((e) => summarizeInboxEntry(e, done.has(e.id)));
27
40
  if (opts.json) {
28
- console.log(JSON.stringify({ count: rows.length, messages: rows }, null, 2));
41
+ console.log(JSON.stringify({ count: rows.length, messages: rows, ...(note ? { note } : {}) }, null, 2));
29
42
  return;
30
43
  }
44
+ if (note)
45
+ console.error(chalk.yellow(note));
31
46
  if (rows.length === 0) {
32
47
  console.log(opts.all ? "Inbox is empty." : "No pending messages.");
33
48
  return;
@@ -22,7 +22,7 @@ import { type KitConfig } from "../lib/agent-kit/kit-config.js";
22
22
  import { type KitLogger } from "../lib/agent-kit/log.js";
23
23
  import { type AgentKitPaths } from "../lib/agent-kit/paths.js";
24
24
  import { type KitService } from "../lib/agent-kit/services.js";
25
- import { type DetachedSpawn, type KillFn } from "../lib/agent-kit/fallback.js";
25
+ import { type CronRunner, type RebootCronResult, type DetachedSpawn, type KillFn } from "../lib/agent-kit/fallback.js";
26
26
  import { type ServiceHostPaths, type ServiceManagerDeps, type ServiceSetResult } from "../lib/service-manager/index.js";
27
27
  export declare class KitError extends Error {
28
28
  constructor(message: string);
@@ -48,8 +48,10 @@ export interface KitFallbackInfo {
48
48
  pid: number;
49
49
  pidFile: string;
50
50
  logPath: string;
51
- /** Add to `crontab -e` so the supervisor survives a reboot. */
51
+ /** The @reboot entry that restarts the supervisor. */
52
52
  crontab: string;
53
+ /** Whether that entry was installed for you, and why not if it was not. */
54
+ reboot: RebootCronResult;
53
55
  }
54
56
  export interface KitInstallResult {
55
57
  config: KitConfig;
@@ -60,6 +62,8 @@ export interface KitInstallResult {
60
62
  export interface KitFallbackDeps {
61
63
  spawn?: DetachedSpawn;
62
64
  kill?: KillFn;
65
+ /** Injected crontab runner (tests). */
66
+ cron?: CronRunner;
63
67
  }
64
68
  export declare function installKit(paths: AgentKitPaths, opts: KitInstallOptions, host: ServiceHostPaths, deps?: ServiceManagerDeps, fallbackDeps?: KitFallbackDeps): KitInstallResult;
65
69
  export declare function formatServiceSet(result: ServiceSetResult): string[];
@@ -30,7 +30,7 @@ import { writeKitSkills } from "../lib/agent-kit/skills.js";
30
30
  import { runHeartbeatLoop } from "../lib/agent-kit/run/heartbeat.js";
31
31
  import { pollInboxOnce, runInboxLoop } from "../lib/agent-kit/run/inbox.js";
32
32
  import { superviseKitServices } from "../lib/agent-kit/run/supervisor.js";
33
- import { fallbackStatus, rebootCrontabLine, shouldUseFallback, startFallback, stopFallback, } from "../lib/agent-kit/fallback.js";
33
+ import { fallbackStatus, rebootCrontabLine, ensureRebootCrontab, shouldUseFallback, startFallback, stopFallback, } from "../lib/agent-kit/fallback.js";
34
34
  import { serviceLogPath } from "../lib/agent-kit/paths.js";
35
35
  import { spawn } from "node:child_process";
36
36
  import { startMeshListener } from "../lib/agent-kit/run/mesh-listener.js";
@@ -99,11 +99,19 @@ export function installKit(paths, opts, host, deps = {}, fallbackDeps = {}) {
99
99
  });
100
100
  if (shouldUseFallback(services, activate)) {
101
101
  const started = startFallback(paths, host, fallbackDeps);
102
+ // A supervisor that does not survive a reboot is not a supervisor. Install
103
+ // the @reboot entry rather than printing it and hoping.
104
+ const reboot = ensureRebootCrontab(paths, host, fallbackDeps.cron);
102
105
  return {
103
106
  config,
104
107
  skills,
105
108
  services,
106
- fallback: { mode: "fallback", ...started, crontab: rebootCrontabLine(paths, host) },
109
+ fallback: {
110
+ mode: "fallback",
111
+ ...started,
112
+ crontab: rebootCrontabLine(paths, host),
113
+ reboot,
114
+ },
107
115
  };
108
116
  }
109
117
  return { config, skills, services };
@@ -309,8 +317,15 @@ export function registerAgentKitCommand(agent) {
309
317
  `(hq agent kit run all, pid ${result.fallback.pid})`));
310
318
  console.log(` pidfile: ${result.fallback.pidFile}`);
311
319
  console.log(` kit log: ${result.fallback.logPath}`);
312
- console.log(" To restart it after a reboot, add this line with `crontab -e`:");
313
- console.log(` ${result.fallback.crontab}`);
320
+ if (result.fallback.reboot.installed) {
321
+ console.log(chalk.green(" reboot: a @reboot crontab entry was installed, so the kit restarts with the host"));
322
+ }
323
+ else {
324
+ console.log(chalk.red(` reboot: NOT protected — ${result.fallback.reboot.reason}. Until this is fixed the kit ` +
325
+ "stops at the next restart and this agent silently stops seeing new messages."));
326
+ console.log(" Add this line yourself with `crontab -e`:");
327
+ console.log(` ${result.fallback.crontab}`);
328
+ }
314
329
  }
315
330
  else {
316
331
  console.log(" services:");
@@ -445,6 +445,26 @@ function currentDeviceSignInAction(status) {
445
445
  }
446
446
  function pendingSlackAction(status) {
447
447
  const agent = recordValue(status.agent);
448
+ const configuredSlack = recordValue(recordValue(agent?.channels)?.slack);
449
+ const appId = nonEmptyString(configuredSlack?.appId);
450
+ const agentUid = nonEmptyString(agent?.uid);
451
+ const companyUid = nonEmptyString(agent?.companyUid);
452
+ const tokenPage = safeActionUrl(configuredSlack?.appTokenPendingUrl);
453
+ if (tokenPage &&
454
+ appId && /^A[A-Z0-9]+$/.test(appId) &&
455
+ new URL(tokenPage).hostname === "api.slack.com" &&
456
+ agentUid && AGENT_UID_PATTERN.test(agentUid) &&
457
+ companyUid && /^cmp_[A-Za-z0-9_-]+$/.test(companyUid)) {
458
+ return {
459
+ type: "slack-app-token",
460
+ title: `Finish ${nonEmptyString(agent?.name) ?? "the agent"}'s Slack connection`,
461
+ summary: "connect it to Slack",
462
+ instruction: "Create an app-level token with the connections:write scope, then paste it into HQ. HQ verifies the token and resumes setup; if Slack asks you to install the app, follow the install link shown there.",
463
+ url: `https://api.slack.com/apps/${appId}/general`,
464
+ urlLabel: "Get token",
465
+ pasteUrl: `https://hq.computer/companies/${encodeURIComponent(companyUid)}/agents?setup=${encodeURIComponent(agentUid)}`,
466
+ };
467
+ }
448
468
  const diagnostics = recordValue(agent?.channelDiagnostics);
449
469
  const slack = recordValue(diagnostics?.slack);
450
470
  const url = safeActionUrl(slack?.pendingInstallUrl);
@@ -483,11 +503,12 @@ function pendingOperatorActions(status) {
483
503
  ].filter((action) => action !== null);
484
504
  }
485
505
  function pendingActionsJson(actions) {
486
- return actions.map(({ type, title, instruction, url, code }) => ({
506
+ return actions.map(({ type, title, instruction, url, pasteUrl, code }) => ({
487
507
  type,
488
508
  title,
489
509
  instruction,
490
510
  ...(url ? { url } : {}),
511
+ ...(pasteUrl ? { pasteUrl } : {}),
491
512
  ...(code ? { code } : {}),
492
513
  }));
493
514
  }
@@ -514,6 +535,8 @@ function printPendingOperatorActions(slug, actions) {
514
535
  console.log(action.instruction);
515
536
  if (action.url)
516
537
  console.log(chalk.cyan(`${action.urlLabel ?? "Open"}: ${action.url}`));
538
+ if (action.pasteUrl)
539
+ console.log(chalk.cyan(`Paste token: ${action.pasteUrl}`));
517
540
  if (action.code)
518
541
  console.log(chalk.bold(`Code: ${action.code}`));
519
542
  }
@@ -60,4 +60,39 @@ export declare function startFallback(paths: AgentKitPaths, host: ServiceHostPat
60
60
  logPath: string;
61
61
  pidFile: string;
62
62
  };
63
+ /**
64
+ * Marker for the crontab entry this module owns. Present so a reinstall
65
+ * replaces its own line instead of stacking duplicates, and so a human reading
66
+ * their crontab can see what wrote it and that removing it is safe.
67
+ */
68
+ export declare const REBOOT_CRON_MARKER = "# hq-agent-kit (managed): restart the HQ agent kit after a reboot";
69
+ export interface CronRunner {
70
+ (args: string[], input?: string): {
71
+ status: number;
72
+ stdout: string;
73
+ stderr: string;
74
+ };
75
+ }
76
+ export interface RebootCronResult {
77
+ installed: boolean;
78
+ /** Why it could not be installed, for the caller to print verbatim. */
79
+ reason?: string;
80
+ }
81
+ /** Drop any previously managed block, leaving every other entry untouched. */
82
+ export declare function stripManagedCron(existing: string): string;
83
+ /**
84
+ * Install the @reboot entry that brings the fallback supervisor back after a
85
+ * restart.
86
+ *
87
+ * Printing the line and trusting it to be pasted is what the installer used to
88
+ * do, and it is how the Muse pilot lost its messages: the host rebooted, the
89
+ * supervisor died with it, nothing restarted it, and the bot went quiet while
90
+ * every surface still reported healthy. A supervisor that does not survive a
91
+ * reboot is not a supervisor, so the entry is now written for real.
92
+ *
93
+ * Failure is NOT fatal — a host with no crontab is still a working kit until
94
+ * it restarts — but it is reported, so the caller can tell the operator the
95
+ * one thing they must then do by hand.
96
+ */
97
+ export declare function ensureRebootCrontab(paths: Pick<AgentKitPaths, "agentDir" | "logsDir">, host: ServiceHostPaths, run?: CronRunner): RebootCronResult;
63
98
  //# sourceMappingURL=fallback.d.ts.map
@@ -7,7 +7,7 @@
7
7
  * ~/.hq-agent/logs/kit.log and its pid recorded in ~/.hq-agent/kit.pid.
8
8
  * `kit status` reads the pidfile; `kit uninstall` kills it and removes it.
9
9
  */
10
- import { spawn } from "node:child_process";
10
+ import { spawn, spawnSync } from "node:child_process";
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { kitServiceEnv } from "./services.js";
@@ -126,4 +126,68 @@ export function startFallback(paths, host, deps = {}) {
126
126
  fs.closeSync(fd);
127
127
  }
128
128
  }
129
+ /**
130
+ * Marker for the crontab entry this module owns. Present so a reinstall
131
+ * replaces its own line instead of stacking duplicates, and so a human reading
132
+ * their crontab can see what wrote it and that removing it is safe.
133
+ */
134
+ export const REBOOT_CRON_MARKER = "# hq-agent-kit (managed): restart the HQ agent kit after a reboot";
135
+ /** Drop any previously managed block, leaving every other entry untouched. */
136
+ export function stripManagedCron(existing) {
137
+ const lines = existing.split("\n");
138
+ const out = [];
139
+ for (let i = 0; i < lines.length; i++) {
140
+ if (lines[i].trim() === REBOOT_CRON_MARKER) {
141
+ // Skip the marker and the entry it introduces.
142
+ if (i + 1 < lines.length && lines[i + 1].startsWith("@reboot"))
143
+ i += 1;
144
+ continue;
145
+ }
146
+ out.push(lines[i]);
147
+ }
148
+ return out.join("\n").replace(/\n{3,}/g, "\n\n").replace(/^\n+/, "");
149
+ }
150
+ /**
151
+ * Install the @reboot entry that brings the fallback supervisor back after a
152
+ * restart.
153
+ *
154
+ * Printing the line and trusting it to be pasted is what the installer used to
155
+ * do, and it is how the Muse pilot lost its messages: the host rebooted, the
156
+ * supervisor died with it, nothing restarted it, and the bot went quiet while
157
+ * every surface still reported healthy. A supervisor that does not survive a
158
+ * reboot is not a supervisor, so the entry is now written for real.
159
+ *
160
+ * Failure is NOT fatal — a host with no crontab is still a working kit until
161
+ * it restarts — but it is reported, so the caller can tell the operator the
162
+ * one thing they must then do by hand.
163
+ */
164
+ export function ensureRebootCrontab(paths, host, run) {
165
+ const exec = run ??
166
+ ((args, input) => {
167
+ const r = spawnSync("crontab", args, { input, encoding: "utf8" });
168
+ if (r.error)
169
+ return { status: 127, stdout: "", stderr: r.error.message };
170
+ return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
171
+ });
172
+ const listed = exec(["-l"]);
173
+ // An empty crontab exits non-zero with "no crontab for <user>"; that is not
174
+ // a failure, it just means there is nothing to preserve. Anything else is.
175
+ const noCrontabYet = listed.status !== 0 && /no crontab for/i.test(listed.stderr);
176
+ if (listed.status !== 0 && !noCrontabYet) {
177
+ return {
178
+ installed: false,
179
+ reason: `could not read the crontab (${(listed.stderr || "").trim() || `exit ${listed.status}`})`,
180
+ };
181
+ }
182
+ const kept = stripManagedCron(noCrontabYet ? "" : listed.stdout);
183
+ const body = `${kept.trimEnd()}\n${REBOOT_CRON_MARKER}\n${rebootCrontabLine(paths, host)}\n`.replace(/^\n+/, "");
184
+ const wrote = exec(["-"], body);
185
+ if (wrote.status !== 0) {
186
+ return {
187
+ installed: false,
188
+ reason: `could not write the crontab (${(wrote.stderr || "").trim() || `exit ${wrote.status}`})`,
189
+ };
190
+ }
191
+ return { installed: true };
192
+ }
129
193
  //# sourceMappingURL=fallback.js.map
@@ -31,4 +31,30 @@ export declare function summarizeInboxEntry(e: MirroredInboxEntry, done: boolean
31
31
  text: string | undefined;
32
32
  done: boolean;
33
33
  };
34
+ /**
35
+ * The agent's inbox as the bot should see it: the kit's local mirror merged
36
+ * with the agent's own server inbox.
37
+ *
38
+ * Reading the mirror ALONE is a silent-blindness bug. The mirror is only as
39
+ * fresh as the kit's poller, and when that process dies nothing says so — the
40
+ * listing simply stops growing. Observed on the Muse pilot: its host rebooted,
41
+ * the kit supervisor died with it and was never restarted, and `hq agent
42
+ * inbox` kept answering with the eight messages from before the reboot while
43
+ * the server had every newer one. No error, no empty result, no stale marker —
44
+ * just an agent that had gone deaf and could not tell.
45
+ *
46
+ * So the server is consulted on every read and anything it knows about that
47
+ * the mirror lacks is included. When the server cannot be reached the mirror
48
+ * is still returned, with a `note` naming the degradation: a partial answer
49
+ * that says it is partial beats a confident wrong one.
50
+ */
51
+ export interface MergedInbox {
52
+ entries: MirroredInboxEntry[];
53
+ /** Set when the server could not be consulted, so the caller can say so. */
54
+ note?: string;
55
+ }
56
+ export declare function mergeAgentInbox(paths: Pick<AgentKitPaths, "inboxDir">, fetchServerInbox: () => Promise<{
57
+ status: number;
58
+ body: unknown;
59
+ }>): Promise<MergedInbox>;
34
60
  //# sourceMappingURL=inbox-state.d.ts.map
@@ -83,4 +83,31 @@ export function summarizeInboxEntry(e, done) {
83
83
  done,
84
84
  };
85
85
  }
86
+ export async function mergeAgentInbox(paths, fetchServerInbox) {
87
+ const byId = new Map();
88
+ for (const e of readMirroredInbox(paths))
89
+ byId.set(e.id, e);
90
+ let note;
91
+ try {
92
+ const res = await fetchServerInbox();
93
+ if (res.status === 200) {
94
+ const messages = res.body?.messages;
95
+ for (const m of Array.isArray(messages) ? messages : []) {
96
+ if (!m || typeof m !== "object")
97
+ continue;
98
+ const raw = m;
99
+ const id = typeof raw.id === "string" ? raw.id : typeof raw.messageId === "string" ? raw.messageId : null;
100
+ if (id && !byId.has(id))
101
+ byId.set(id, { ...raw, id });
102
+ }
103
+ }
104
+ else {
105
+ note = `server inbox answered ${res.status}; showing the local mirror only, which may be stale`;
106
+ }
107
+ }
108
+ catch (err) {
109
+ note = `server inbox unavailable (${err instanceof Error ? err.message : String(err)}); showing the local mirror only, which may be stale`;
110
+ }
111
+ return { entries: [...byId.values()], ...(note ? { note } : {}) };
112
+ }
86
113
  //# sourceMappingURL=inbox-state.js.map
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { CLI_VERSION } from "../../../cli-version.js";
19
19
  import { peekIdToken } from "../../../utils/id-token.js";
20
- import { markInboxDone, readDoneIds, readMirroredInbox, summarizeInboxEntry } from "../inbox-state.js";
20
+ import { markInboxDone, mergeAgentInbox, readDoneIds, summarizeInboxEntry } from "../inbox-state.js";
21
21
  import { errorResult, McpToolInputError, textResult } from "./jsonrpc.js";
22
22
  export const MAX_TOOL_OUTPUT_CHARS = 40_000;
23
23
  function str(args, key, opts = {}) {
@@ -252,33 +252,11 @@ export function buildAgentMcpTools(clients) {
252
252
  const limit = int(args, "limit", { min: 1, max: 100, fallback: 20 });
253
253
  const includeDone = args.include_done === true;
254
254
  const done = readDoneIds(clients.paths);
255
- // The kit mirror is the source of truth; merge the agent's own server
256
- // inbox so items the poller has not mirrored yet are not missed.
257
- const byId = new Map();
258
- for (const e of readMirroredInbox(clients.paths))
259
- byId.set(e.id, e);
260
- let serverNote;
261
- try {
255
+ const { entries, note: serverNote } = await mergeAgentInbox(clients.paths, async () => {
262
256
  const token = await clients.getToken();
263
- const res = await clients.apiJson(token, `/v1/agents/${encodeURIComponent(creds.entityUid)}/inbox`);
264
- if (res.status === 200) {
265
- const messages = res.body?.messages;
266
- for (const m of Array.isArray(messages) ? messages : []) {
267
- if (!m || typeof m !== "object")
268
- continue;
269
- const raw = m;
270
- const id = typeof raw.id === "string" ? raw.id : typeof raw.messageId === "string" ? raw.messageId : null;
271
- if (id && !byId.has(id))
272
- byId.set(id, { ...raw, id });
273
- }
274
- }
275
- else {
276
- serverNote = `server inbox answered ${res.status}: ${describeError(res.body)}; showing the local mirror only`;
277
- }
278
- }
279
- catch (err) {
280
- serverNote = `server inbox unavailable (${err instanceof Error ? err.message : String(err)}); showing the local mirror only`;
281
- }
257
+ return clients.apiJson(token, `/v1/agents/${encodeURIComponent(creds.entityUid)}/inbox`);
258
+ });
259
+ const byId = new Map(entries.map((e) => [e.id, e]));
282
260
  const rows = [...byId.values()]
283
261
  .filter((e) => includeDone || !done.has(e.id))
284
262
  .slice(0, limit)
@@ -27,6 +27,29 @@ export declare const DOORBELL_KINDS: readonly ["dm", "work", "sessions", "notifi
27
27
  export declare const DOORBELL_DEBOUNCE_MS = 2000;
28
28
  export declare const RECONNECT_BASE_MS = 1000;
29
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;
30
53
  export declare function doorbellTopics(actorUid: string): string[];
31
54
  /**
32
55
  * Doorbell topics this session may subscribe to: the intersection of
@@ -68,7 +91,13 @@ export interface MeshListenerHandle {
68
91
  refetchNow: (reason: string) => Promise<void>;
69
92
  /** Simulate a doorbell (tests). */
70
93
  ring: (topic: string) => void;
71
- state: () => "idle" | "connecting" | "subscribed" | "closed";
94
+ state: () => MeshListenerState;
72
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";
73
102
  export declare function startMeshListener(deps: MeshListenerDeps): Promise<MeshListenerHandle>;
74
103
  //# sourceMappingURL=mesh-listener.d.ts.map
@@ -28,6 +28,29 @@ export const DOORBELL_KINDS = ["dm", "work", "sessions", "notifications", "inbox
28
28
  export const DOORBELL_DEBOUNCE_MS = 2_000;
29
29
  export const RECONNECT_BASE_MS = 1_000;
30
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;
31
54
  export function doorbellTopics(actorUid) {
32
55
  return DOORBELL_KINDS.map((k) => `hq/${actorUid}/${k}`);
33
56
  }
@@ -70,6 +93,12 @@ export async function startMeshListener(deps) {
70
93
  return createPersonalRealtimeFetcher({ token, baseUrl: deps.apiBaseUrl })();
71
94
  });
72
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;
73
102
  let client = null;
74
103
  let stopped = false;
75
104
  let attempt = 0;
@@ -87,7 +116,11 @@ export async function startMeshListener(deps) {
87
116
  try {
88
117
  const token = await deps.getToken();
89
118
  await refetch(token, deps.agentUid);
90
- writeComponentStatus(deps.paths, "mesh", state === "subscribed" ? "ok" : "error");
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");
91
124
  deps.log("info", `cache refetched (${reason})`);
92
125
  }
93
126
  catch (err) {
@@ -132,6 +165,25 @@ export async function startMeshListener(deps) {
132
165
  const scheduleReconnect = () => {
133
166
  if (stopped)
134
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
+ }
135
187
  const delay = backoff(attempt, random);
136
188
  attempt += 1;
137
189
  deps.log("warn", `mqtt reconnect in ${delay}ms (attempt ${attempt})`);
@@ -149,7 +201,8 @@ export async function startMeshListener(deps) {
149
201
  }
150
202
  }
151
203
  catch (err) {
152
- writeComponentStatus(deps.paths, "mesh", "error");
204
+ if (!polling)
205
+ writeComponentStatus(deps.paths, "mesh", "error");
153
206
  deps.log("error", `credential vend failed: ${err instanceof Error ? err.message : String(err)}`);
154
207
  scheduleReconnect();
155
208
  return;
@@ -182,6 +235,7 @@ export async function startMeshListener(deps) {
182
235
  }
183
236
  settled = true;
184
237
  attempt = 0;
238
+ polling = false;
185
239
  state = "subscribed";
186
240
  writeComponentStatus(deps.paths, "mesh", "ok");
187
241
  deps.log("info", `subscribed to ${topics.length} doorbell topics`);
@@ -207,7 +261,11 @@ export async function startMeshListener(deps) {
207
261
  client = null;
208
262
  const wasSubscribed = state === "subscribed";
209
263
  state = stopped ? "closed" : "idle";
210
- writeComponentStatus(deps.paths, "mesh", "error");
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");
211
269
  if (!settled || !wasSubscribed)
212
270
  deps.log("warn", "mqtt closed before subscribe settled");
213
271
  scheduleReconnect();
@@ -232,7 +290,7 @@ export async function startMeshListener(deps) {
232
290
  },
233
291
  refetchNow: doRefetch,
234
292
  ring,
235
- state: () => state,
293
+ state: () => (polling && state !== "subscribed" && state !== "closed" ? "polling" : state),
236
294
  };
237
295
  }
238
296
  //# sourceMappingURL=mesh-listener.js.map
@@ -41,6 +41,7 @@ export interface PlanLock {
41
41
  export interface PlanLockFixOptions {
42
42
  removeMembersTo: number;
43
43
  disconnectIntegrations: boolean;
44
+ disconnectIntegrationsTo?: number;
44
45
  removeSecretsTo?: number;
45
46
  deprovisionAgentsTo?: number;
46
47
  }
@@ -64,6 +65,12 @@ export interface PlanLockStatus {
64
65
  }
65
66
  /** Starter member cap quoted when the server did not send `removeMembersTo`. */
66
67
  export declare const STARTER_MEMBER_TARGET = 5;
68
+ /**
69
+ * Starter integration cap quoted when the server did not send
70
+ * `disconnectIntegrationsTo`. One: Starter includes a single integration
71
+ * (US-020 owner decision 11, 2026-09-17 — it was zero before that).
72
+ */
73
+ export declare const STARTER_INTEGRATION_TARGET = 1;
67
74
  /** Starter secret cap quoted when the server did not send `removeSecretsTo`. */
68
75
  export declare const STARTER_SECRET_TARGET = 10;
69
76
  /** Starter agent cap quoted when the server did not send `deprovisionAgentsTo`. */
@@ -36,6 +36,12 @@ function isPlanLockReason(value) {
36
36
  }
37
37
  /** Starter member cap quoted when the server did not send `removeMembersTo`. */
38
38
  export const STARTER_MEMBER_TARGET = 5;
39
+ /**
40
+ * Starter integration cap quoted when the server did not send
41
+ * `disconnectIntegrationsTo`. One: Starter includes a single integration
42
+ * (US-020 owner decision 11, 2026-09-17 — it was zero before that).
43
+ */
44
+ export const STARTER_INTEGRATION_TARGET = 1;
39
45
  /** Starter secret cap quoted when the server did not send `removeSecretsTo`. */
40
46
  export const STARTER_SECRET_TARGET = 10;
41
47
  /** Starter agent cap quoted when the server did not send `deprovisionAgentsTo`. */
@@ -73,6 +79,7 @@ export function parsePlanLock(value) {
73
79
  }
74
80
  const fix = asRecord(rec.fixOptions);
75
81
  const removeMembersTo = finiteNumber(fix?.removeMembersTo) ?? STARTER_MEMBER_TARGET;
82
+ const disconnectIntegrationsTo = finiteNumber(fix?.disconnectIntegrationsTo);
76
83
  const removeSecretsTo = finiteNumber(fix?.removeSecretsTo);
77
84
  const deprovisionAgentsTo = finiteNumber(fix?.deprovisionAgentsTo);
78
85
  const fixOptions = {
@@ -81,6 +88,9 @@ export function parsePlanLock(value) {
81
88
  };
82
89
  // Absent stays absent: an omitted remedy target is UNKNOWN, and the renderer
83
90
  // quotes the published Starter cap rather than inventing a server answer.
91
+ if (disconnectIntegrationsTo !== null) {
92
+ fixOptions.disconnectIntegrationsTo = disconnectIntegrationsTo;
93
+ }
84
94
  if (removeSecretsTo !== null)
85
95
  fixOptions.removeSecretsTo = removeSecretsTo;
86
96
  if (deprovisionAgentsTo !== null) {
@@ -200,13 +210,16 @@ export function reasonLabel(reason) {
200
210
  case "users":
201
211
  return "too many members";
202
212
  case "integrations":
203
- return "integrations are not included on Starter";
213
+ return "too many integrations";
204
214
  case "secrets":
205
215
  return "too many secrets";
206
216
  case "agents":
207
217
  return "agents are not included on Starter";
208
218
  }
209
219
  }
220
+ function integrationTarget(lock) {
221
+ return lock.fixOptions.disconnectIntegrationsTo ?? STARTER_INTEGRATION_TARGET;
222
+ }
210
223
  function secretTarget(lock) {
211
224
  return lock.fixOptions.removeSecretsTo ?? STARTER_SECRET_TARGET;
212
225
  }
@@ -219,7 +232,7 @@ function reasonRemedy(reason, lock) {
219
232
  case "users":
220
233
  return `remove members until you are at ${lock.fixOptions.removeMembersTo} or fewer`;
221
234
  case "integrations":
222
- return "disconnect the workspace's integrations";
235
+ return `disconnect integrations until you are at ${integrationTarget(lock)} or fewer`;
223
236
  case "secrets":
224
237
  return `delete secrets until you are at ${secretTarget(lock)} or fewer`;
225
238
  case "agents": {
@@ -241,7 +254,7 @@ function reasonDetail(reason, status) {
241
254
  : `over its ${target}-member limit`;
242
255
  }
243
256
  case "integrations":
244
- return "integrations are not included on Starter";
257
+ return `over its ${integrationTarget(status.lock)}-integration limit`;
245
258
  case "secrets":
246
259
  return `over its ${secretTarget(status.lock)}-secret limit`;
247
260
  case "agents":
@@ -275,6 +288,9 @@ export function renderPlanLockNotice(status) {
275
288
  ? ` Members: ${members.used} of ${target}.`
276
289
  : ` Members allowed on Starter: ${target}.`);
277
290
  }
291
+ if (lock.reasons.includes("integrations")) {
292
+ lines.push(` Integrations allowed on Starter: ${integrationTarget(lock)}.`);
293
+ }
278
294
  if (lock.reasons.includes("secrets")) {
279
295
  lines.push(` Secrets allowed on Starter: ${secretTarget(lock)}.`);
280
296
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.119.1",
3
+ "version": "5.119.5",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {