@indigoai-us/hq-cli 5.119.1 → 5.119.4
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 +46 -0
- package/dist/commands/agent-inbox.js +19 -4
- package/dist/commands/agent-kit.d.ts +6 -2
- package/dist/commands/agent-kit.js +19 -4
- package/dist/lib/agent-kit/fallback.d.ts +35 -0
- package/dist/lib/agent-kit/fallback.js +65 -1
- package/dist/lib/agent-kit/inbox-state.d.ts +26 -0
- package/dist/lib/agent-kit/inbox-state.js +27 -0
- package/dist/lib/agent-kit/mcp/tools.js +5 -27
- package/dist/lib/agent-kit/run/mesh-listener.d.ts +30 -1
- package/dist/lib/agent-kit/run/mesh-listener.js +62 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,52 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.119.4] — 2026-09-17
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- On a host with no usable service manager, `hq agent kit install` now installs
|
|
10
|
+
the `@reboot` crontab entry that restarts the kit, instead of printing the
|
|
11
|
+
line and trusting it to be pasted. A supervisor that does not survive a
|
|
12
|
+
reboot is not a supervisor: on the Muse pilot the host restarted, the
|
|
13
|
+
supervisor died with it, nothing brought it back, and the agent silently
|
|
14
|
+
stopped seeing new messages while every surface still reported healthy. The
|
|
15
|
+
entry is marked and replaced on reinstall rather than stacked, and every
|
|
16
|
+
other crontab line is preserved. If the crontab cannot be written the install
|
|
17
|
+
still succeeds but says so in red, naming the consequence and printing the
|
|
18
|
+
line to add by hand.
|
|
19
|
+
|
|
20
|
+
## [5.119.3] — 2026-09-17
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- `hq agent inbox` now merges the agent's own server inbox with the kit's local
|
|
25
|
+
mirror instead of reading the mirror alone. The mirror is only as fresh as
|
|
26
|
+
the kit's poller, and when that process dies nothing says so — the listing
|
|
27
|
+
simply stops growing. On the Muse pilot the host rebooted, the kit supervisor
|
|
28
|
+
died with it and was never restarted, and the command kept confidently
|
|
29
|
+
answering with the eight messages from before the reboot while the server had
|
|
30
|
+
every newer one: no error, no empty result, no stale marker, just an agent
|
|
31
|
+
that had gone deaf and could not tell. When the server cannot be reached the
|
|
32
|
+
mirror is still returned, with a note saying it may be stale. `hq_inbox_read`
|
|
33
|
+
already merged; both now share one implementation.
|
|
34
|
+
|
|
35
|
+
## [5.119.2] — 2026-09-17
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- The work-mesh listener falls back to its periodic refetch when the MQTT
|
|
40
|
+
doorbell is unreachable, instead of retrying forever and reporting
|
|
41
|
+
`component-mesh=error` the whole time. The doorbell is an optimisation over a
|
|
42
|
+
REST source of truth, so a host that cannot reach AWS IoT has a slower
|
|
43
|
+
doorbell, not a broken agent. Some bot sandboxes sit behind an egress proxy
|
|
44
|
+
that intercepts the connection and answers with non-TLS bytes
|
|
45
|
+
(`tls_validate_record_header:wrong version number`). After five failed
|
|
46
|
+
attempts the listener says so once, keeps the cache current on the periodic
|
|
47
|
+
cadence, and retries MQTT every 30 minutes so a bot moved to an unrestricted
|
|
48
|
+
network recovers its instant doorbell on its own. Incoming DMs are
|
|
49
|
+
unaffected either way — the inbox poller is a separate service.
|
|
50
|
+
|
|
5
51
|
## [5.119.1] — 2026-09-17
|
|
6
52
|
|
|
7
53
|
### 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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
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: {
|
|
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
|
-
|
|
313
|
-
|
|
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:");
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|