@indigoai-us/hq-cli 5.120.0 → 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 +37 -0
- package/dist/command-catalog.generated.d.ts +3 -3
- package/dist/command-catalog.generated.js +3 -3
- package/dist/commands/agent-kit.d.ts +1 -2
- package/dist/commands/agent-kit.js +3 -49
- package/dist/commands/agent-probe.d.ts +2 -8
- package/dist/commands/agent-probe.js +24 -41
- package/dist/lib/agent-kit/paths.d.ts +1 -1
- package/dist/lib/agent-kit/paths.js +1 -1
- package/dist/lib/agent-kit/run/heartbeat.js +0 -1
- package/dist/lib/agent-kit/services.d.ts +2 -2
- package/dist/lib/agent-kit/services.js +2 -3
- package/dist/lib/agent-kit/skills.js +0 -23
- package/package.json +2 -2
- package/dist/lib/agent-kit/run/mesh-listener.d.ts +0 -103
- package/dist/lib/agent-kit/run/mesh-listener.js +0 -296
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,43 @@
|
|
|
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
|
+
|
|
32
|
+
## [5.120.1] — 2026-09-18
|
|
33
|
+
|
|
34
|
+
- A local bot no longer replaces your own sign-in. Bots that run on your
|
|
35
|
+
computer (the setup bot included) keep their session in their own folder
|
|
36
|
+
now, instead of the file HQ uses to know who you are. Before, a bot could
|
|
37
|
+
overwrite that file and HQ would then treat the bot as you: its name in
|
|
38
|
+
place of yours, no companies, a profile that would not save, and sync
|
|
39
|
+
failing. If that already happened on your computer, `hq` now ignores the
|
|
40
|
+
bot's session there and asks you to sign in as yourself. (hq-cloud 6.16.60)
|
|
41
|
+
|
|
5
42
|
## [5.120.0] — 2026-09-18
|
|
6
43
|
|
|
7
44
|
### Added
|
|
@@ -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
|
|
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|
|
|
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
|
|
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
|
|
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|
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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.
|
|
146
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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: "
|
|
161
|
+
checks.push({ name: "presence", ok: false, detail: errText(err) });
|
|
175
162
|
}
|
|
176
163
|
}
|
|
177
164
|
else {
|
|
178
|
-
checks.push({ name: "
|
|
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
|
|
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" | "
|
|
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", "
|
|
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
|
|
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" | "
|
|
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
|
|
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", "
|
|
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.
|
|
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": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
36
36
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
37
|
-
"@indigoai-us/hq-cloud": "~6.16.
|
|
37
|
+
"@indigoai-us/hq-cloud": "~6.16.60",
|
|
38
38
|
"@indigoai-us/hq-flags-client": "^0.1.2",
|
|
39
39
|
"@sentry/node": "^10.49.0",
|
|
40
40
|
"@tobilu/qmd": "2.5.3",
|
|
@@ -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
|