talon-agent 5.24.0 → 5.25.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.
Files changed (79) hide show
  1. package/package.json +2 -1
  2. package/src/app.ts +30 -0
  3. package/src/backend/agy/process/orphans.ts +5 -1
  4. package/src/backend/claude-sdk/one-shot.ts +8 -1
  5. package/src/backend/codex/factory.ts +5 -1
  6. package/src/backend/codex/plan-usage.ts +12 -0
  7. package/src/backend/runtime/turn/turn-phases.ts +7 -1
  8. package/src/bootstrap.ts +1 -0
  9. package/src/cli.ts +18 -12
  10. package/src/core/agent-runtime/capabilities.ts +7 -0
  11. package/src/core/agents/index.ts +1 -1
  12. package/src/core/agents/prompt.ts +29 -2
  13. package/src/core/agents/runner.ts +7 -1
  14. package/src/core/agents/types.ts +6 -0
  15. package/src/core/background/cron/job-oneshot.ts +7 -1
  16. package/src/core/background/cron/scheduler.ts +28 -14
  17. package/src/core/background/cron/spec.ts +23 -3
  18. package/src/core/background/heartbeat/agent.ts +6 -0
  19. package/src/core/background/triggers/resume.ts +19 -4
  20. package/src/core/background/triggers/spawn.ts +1 -1
  21. package/src/core/config/index.ts +82 -0
  22. package/src/core/daemon/discovery.ts +160 -0
  23. package/src/core/daemon/pidfile.ts +107 -0
  24. package/src/core/daemon/respawn.ts +4 -1
  25. package/src/core/engine/backend-router/breaker.ts +158 -0
  26. package/src/core/engine/backend-router/headroom.ts +90 -22
  27. package/src/core/engine/backend-router/index.ts +11 -0
  28. package/src/core/engine/gateway-actions/agents/control.ts +9 -0
  29. package/src/core/engine/gateway-actions/agents/index.ts +4 -0
  30. package/src/core/engine/gateway-actions/agents/preflight.ts +215 -0
  31. package/src/core/engine/gateway-actions/cron.ts +5 -0
  32. package/src/core/engine/gateway-actions/fetch-url/guard.ts +13 -44
  33. package/src/core/engine/gateway-actions/fetch-url/index.ts +23 -43
  34. package/src/core/engine/gateway-actions/mesh.ts +4 -0
  35. package/src/core/engine/gateway-actions/models.ts +19 -4
  36. package/src/core/engine/gateway-routes.ts +31 -2
  37. package/src/core/engine/gateway.ts +11 -1
  38. package/src/core/fetch/classify.ts +73 -0
  39. package/src/core/fetch/curl-impersonate.ts +447 -0
  40. package/src/core/fetch/errors.ts +14 -0
  41. package/src/core/fetch/index.ts +133 -0
  42. package/src/core/fetch/ladder.ts +401 -0
  43. package/src/core/fetch/rungs.ts +319 -0
  44. package/src/core/fetch/types.ts +105 -0
  45. package/src/core/frontend-runtime/admin-notify.ts +100 -12
  46. package/src/core/mcp-hub/child-guard.ts +215 -0
  47. package/src/core/mcp-hub/child-transport.ts +89 -39
  48. package/src/core/mcp-hub/children.ts +65 -9
  49. package/src/core/mcp-hub/guest-scope.ts +3 -2
  50. package/src/core/mcp-hub/index.ts +36 -16
  51. package/src/core/mcp-hub/launcher.ts +81 -37
  52. package/src/core/mcp-hub/proxy-server.ts +12 -8
  53. package/src/core/mcp-hub/reaper.ts +120 -0
  54. package/src/core/mesh/credentials/index.ts +1 -1
  55. package/src/core/mesh/credentials/store.ts +1 -1
  56. package/src/core/mesh/devices/service.ts +8 -0
  57. package/src/core/mesh/links/bridge-links.ts +37 -0
  58. package/src/core/mesh/links/companion-pairing.ts +2 -2
  59. package/src/core/plugin/mcp.ts +8 -8
  60. package/src/core/tools/bridge.ts +5 -0
  61. package/src/core/tools/content/web.ts +1 -1
  62. package/src/core/tools/index.ts +2 -1
  63. package/src/core/tools/ops/agents.ts +28 -0
  64. package/src/core/tools/ops/mesh.ts +21 -0
  65. package/src/core/tools/ops/scheduling.ts +15 -0
  66. package/src/frontend/native/bridge/credentials/claims.ts +11 -1
  67. package/src/frontend/telegram/actions/media.ts +164 -12
  68. package/src/index.ts +22 -19
  69. package/src/plugins/playwright/version-coupling.ts +172 -62
  70. package/src/storage/cron.ts +34 -2
  71. package/src/storage/db.ts +1 -0
  72. package/src/storage/repositories/cron-repo.ts +9 -0
  73. package/src/storage/sql/cron.sql +5 -5
  74. package/src/storage/sql/db.sql +5 -0
  75. package/src/storage/sql/schema.sql +2 -1
  76. package/src/storage/sql/statements.generated.ts +10 -6
  77. package/src/util/log.ts +2 -1
  78. package/src/util/paths.ts +2 -0
  79. package/src/core/background/triggers/pid.ts +0 -28
@@ -0,0 +1,319 @@
1
+ /**
2
+ * The non-impersonation rungs: plain fetch, anti-detect browser
3
+ * (Camoufox over the Playwright wire protocol) and a mesh egress device.
4
+ * curl-impersonate lives in curl-impersonate.ts.
5
+ */
6
+
7
+ import { execFile } from "node:child_process";
8
+ import { readFileSync } from "node:fs";
9
+ import { createRequire } from "node:module";
10
+ import { binaryOnPath } from "../../util/binary-on-path.js";
11
+ import {
12
+ readBodyLimited,
13
+ ResponseTooLargeError,
14
+ } from "../../util/http-body.js";
15
+ import { getMeshService } from "../mesh/index.js";
16
+ import { CHALLENGE_PATTERN } from "./classify.js";
17
+ import { fetchError } from "./errors.js";
18
+ import type { RawResponse, Rung, RungRequest } from "./types.js";
19
+
20
+ // ── plain ───────────────────────────────────────────────────────────────────
21
+
22
+ /** The runtime's own fetch, honest User-Agent, one hop per call. */
23
+ export function plainRung(): Rung {
24
+ return {
25
+ name: "plain",
26
+ relayed: false,
27
+ followsRedirects: false,
28
+ publicOnly: false,
29
+ async available() {
30
+ return true;
31
+ },
32
+ async request(req: RungRequest): Promise<RawResponse> {
33
+ const resp = await fetch(req.url, {
34
+ redirect: "manual",
35
+ signal: AbortSignal.timeout(req.timeoutMs),
36
+ headers: { "User-Agent": "Talon/1.0", ...req.headers },
37
+ });
38
+ const contentLength = Number(resp.headers.get("content-length") ?? "");
39
+ if (contentLength > req.maxBytes) {
40
+ await resp.body?.cancel().catch(() => {});
41
+ throw new ResponseTooLargeError();
42
+ }
43
+ const body =
44
+ resp.status >= 300 && resp.status < 400
45
+ ? (await resp.body?.cancel().catch(() => {}), Buffer.alloc(0))
46
+ : await readBodyLimited(resp, req.maxBytes);
47
+ return {
48
+ status: resp.status,
49
+ headers: resp.headers,
50
+ body,
51
+ url: req.url.href,
52
+ };
53
+ },
54
+ };
55
+ }
56
+
57
+ // ── anti-detect browser ─────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * playwright-core as bundled by @playwright/mcp — the one client version
61
+ * the playwright plugin already pins to match the Camoufox server (see
62
+ * plugins/playwright/version-coupling.ts). Undefined when not installed
63
+ * (e.g. a standalone binary without node_modules).
64
+ */
65
+ function playwrightCorePath(): string | undefined {
66
+ try {
67
+ const here = createRequire(import.meta.url);
68
+ const mcpPkg = here.resolve("@playwright/mcp/package.json");
69
+ return createRequire(mcpPkg).resolve("playwright-core");
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ }
74
+
75
+ /** Read the playwright plugin's endpoint the same way the plugin does. */
76
+ export function playwrightEndpoint(pw?: {
77
+ endpoint?: string;
78
+ endpointFile?: string;
79
+ }): string | undefined {
80
+ if (pw?.endpoint) return pw.endpoint;
81
+ if (pw?.endpointFile) {
82
+ try {
83
+ return readFileSync(pw.endpointFile, "utf-8").trim() || undefined;
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ /**
92
+ * The browser driver, run under Node in a child process: playwright's
93
+ * WebSocket client does not connect under Bun (verified 2026-09-30 —
94
+ * `firefox.connect` times out), which is also why the playwright plugin
95
+ * runs @playwright/mcp under `node`. argv: playwright-core path, endpoint,
96
+ * url, timeout ms, challenge regex source. Prints one JSON object.
97
+ */
98
+ const BROWSER_DRIVER = `
99
+ const [pwPath, endpoint, url, timeoutMs, challenge] = process.argv.slice(1);
100
+ const { firefox } = require(pwPath);
101
+ const CH = new RegExp(challenge, "i");
102
+ (async () => {
103
+ const browser = await firefox.connect(endpoint, { timeout: Math.min(15000, +timeoutMs) });
104
+ let out;
105
+ try {
106
+ const ctx = await browser.newContext();
107
+ try {
108
+ const page = await ctx.newPage();
109
+ let last = null;
110
+ page.on("response", (r) => {
111
+ if (r.request().isNavigationRequest() && r.frame() === page.mainFrame()) last = r;
112
+ });
113
+ const first = await page.goto(url, { waitUntil: "domcontentloaded", timeout: +timeoutMs });
114
+ last = last || first;
115
+ let html = await page.content();
116
+ if (html.length < 32768 && CH.test(html)) {
117
+ await page.waitForLoadState("networkidle", { timeout: 12000 }).catch(() => {});
118
+ await page.waitForTimeout(1000);
119
+ html = await page.content();
120
+ }
121
+ const headers = last ? await last.allHeaders().catch(() => ({})) : {};
122
+ out = { status: last ? last.status() : 200, url: page.url(), headers, html };
123
+ } finally {
124
+ await ctx.close().catch(() => {});
125
+ }
126
+ } finally {
127
+ // For a connected browser this disconnects; the server keeps running.
128
+ await browser.close().catch(() => {});
129
+ }
130
+ process.stdout.write(JSON.stringify(out));
131
+ })().catch((e) => {
132
+ process.stderr.write(String((e && e.message) || e).split("\\n")[0]);
133
+ process.exit(1);
134
+ });
135
+ `;
136
+
137
+ type DriverOutput = {
138
+ status: number;
139
+ url: string;
140
+ headers: Record<string, string>;
141
+ html: string;
142
+ };
143
+
144
+ /** Runs the driver; resolves with its parsed stdout. Test seam. */
145
+ type RunBrowserDriver = (
146
+ args: string[],
147
+ timeoutMs: number,
148
+ ) => Promise<DriverOutput>;
149
+
150
+ const runBrowserDriver: RunBrowserDriver = (args, timeoutMs) =>
151
+ new Promise((resolve, reject) => {
152
+ const node = process.versions.bun ? "node" : process.execPath;
153
+ execFile(
154
+ node,
155
+ ["-e", BROWSER_DRIVER, ...args],
156
+ { timeout: timeoutMs + 20_000, maxBuffer: 64 * 1024 * 1024 },
157
+ (err, stdout, stderr) => {
158
+ if (err) {
159
+ reject(fetchError(String(stderr).trim() || err.message));
160
+ return;
161
+ }
162
+ try {
163
+ resolve(JSON.parse(stdout) as DriverOutput);
164
+ } catch {
165
+ reject(fetchError("browser driver returned no result"));
166
+ }
167
+ },
168
+ );
169
+ });
170
+
171
+ export type BrowserRungOptions = {
172
+ endpoint: string;
173
+ /** fetchUrl.allowPrivateNetworks: false — see available(). */
174
+ guard: boolean;
175
+ corePath?: () => string | undefined;
176
+ run?: RunBrowserDriver;
177
+ };
178
+
179
+ export function browserRung(opts: BrowserRungOptions): Rung {
180
+ const corePath = opts.corePath ?? playwrightCorePath;
181
+ const run = opts.run ?? runBrowserDriver;
182
+ return {
183
+ name: "camoufox",
184
+ relayed: false,
185
+ followsRedirects: true,
186
+ publicOnly: true,
187
+ timeoutMs: 45_000,
188
+ async available() {
189
+ // A page pulls subresources and follows redirects inside the
190
+ // browser, out of reach of the per-hop SSRF check.
191
+ if (opts.guard) {
192
+ return "not used with fetchUrl.allowPrivateNetworks: false (the browser's own requests can't be guarded)";
193
+ }
194
+ if (!corePath())
195
+ return "playwright-core (via @playwright/mcp) is not installed";
196
+ if (process.versions.bun && !binaryOnPath("node")) {
197
+ return "node is not on PATH (the browser driver runs under Node)";
198
+ }
199
+ return true;
200
+ },
201
+ async request(req: RungRequest): Promise<RawResponse> {
202
+ const core = corePath();
203
+ if (!core) throw fetchError("playwright-core not installed");
204
+ const out = await run(
205
+ [
206
+ core,
207
+ opts.endpoint,
208
+ req.url.href,
209
+ String(req.timeoutMs),
210
+ CHALLENGE_PATTERN,
211
+ ],
212
+ req.timeoutMs,
213
+ );
214
+ const headers = new Headers();
215
+ for (const [k, v] of Object.entries(out.headers ?? {})) {
216
+ try {
217
+ headers.set(k, v);
218
+ } catch {
219
+ /* skip headers Headers refuses */
220
+ }
221
+ }
222
+ // The body is the rendered DOM, serialized as UTF-8 HTML.
223
+ headers.set("content-type", "text/html; charset=utf-8");
224
+ headers.delete("content-length");
225
+ headers.delete("content-encoding");
226
+ headers.delete("location");
227
+ const body = Buffer.from(out.html ?? "", "utf8");
228
+ if (body.length > req.maxBytes) throw new ResponseTooLargeError();
229
+ return {
230
+ status: out.status || 200,
231
+ headers,
232
+ body,
233
+ url: out.url || req.url.href,
234
+ };
235
+ },
236
+ };
237
+ }
238
+
239
+ // ── mesh egress device ──────────────────────────────────────────────────────
240
+
241
+ /** Runs a shell command on a device; resolves with its stdout. */
242
+ export type DeviceExec = (
243
+ device: string,
244
+ cmd: string,
245
+ timeoutMs: number,
246
+ ) => Promise<{ ok: boolean; stdout: string; error?: string }>;
247
+
248
+ const meshExec: DeviceExec = async (device, cmd, timeoutMs) => {
249
+ const dispatched = await getMeshService().dispatchCommand(
250
+ device,
251
+ "exec",
252
+ { cmd, timeoutMs },
253
+ timeoutMs + 5_000,
254
+ );
255
+ if ("error" in dispatched)
256
+ return { ok: false, stdout: "", error: dispatched.error };
257
+ const data = dispatched.result.data ?? {};
258
+ return {
259
+ ok: dispatched.result.ok,
260
+ stdout: typeof data.stdout === "string" ? data.stdout : "",
261
+ error: dispatched.result.message,
262
+ };
263
+ };
264
+
265
+ /** POSIX single-quote a shell word. */
266
+ export function shellQuote(value: string): string {
267
+ return `'${value.replace(/'/g, `'\\''`)}'`;
268
+ }
269
+
270
+ const EGRESS_MARK = "__TALON_FETCH__";
271
+ const EGRESS_UA =
272
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15";
273
+
274
+ export function egressRung(device: string, exec: DeviceExec = meshExec): Rung {
275
+ return {
276
+ name: `egress:${device}`,
277
+ relayed: true,
278
+ followsRedirects: false,
279
+ publicOnly: true,
280
+ async available() {
281
+ return true;
282
+ },
283
+ async request(req: RungRequest): Promise<RawResponse> {
284
+ const secs = Math.max(1, Math.ceil(req.timeoutMs / 1000) - 2);
285
+ const parts = [
286
+ "curl -sS --proto =http,https",
287
+ `--max-time ${secs}`,
288
+ `--max-filesize ${req.maxBytes}`,
289
+ `-A ${shellQuote(EGRESS_UA)}`,
290
+ ...Object.entries(req.headers).map(
291
+ ([k, v]) => `-H ${shellQuote(`${k}: ${v}`)}`,
292
+ ),
293
+ "-o -",
294
+ `-w ${shellQuote(`\n${EGRESS_MARK} %{http_code} %{redirect_url} %{content_type}`)}`,
295
+ shellQuote(req.url.href),
296
+ ];
297
+ const out = await exec(device, parts.join(" "), req.timeoutMs);
298
+ const at = out.stdout.lastIndexOf(`\n${EGRESS_MARK} `);
299
+ if (at < 0) {
300
+ throw fetchError(
301
+ out.error || `egress device ${device} returned no HTTP answer`,
302
+ );
303
+ }
304
+ const [code = "0", redirect = "", ...ct] = out.stdout
305
+ .slice(at + EGRESS_MARK.length + 2)
306
+ .trim()
307
+ .split(" ");
308
+ const status = Number(code);
309
+ if (!status)
310
+ throw fetchError(out.error || `curl on ${device} got no HTTP answer`);
311
+ const headers = new Headers();
312
+ if (ct.length) headers.set("content-type", ct.join(" "));
313
+ if (redirect) headers.set("location", redirect);
314
+ // Device exec carries text; binary bodies do not survive the trip.
315
+ const body = Buffer.from(out.stdout.slice(0, at), "utf8");
316
+ return { status, headers, body, url: req.url.href };
317
+ },
318
+ };
319
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Shared shapes for the fetch ladder (see ladder.ts and docs/fetch-ladder.md).
3
+ */
4
+
5
+ /** One HTTP request as a rung sees it. */
6
+ export type RungRequest = {
7
+ url: URL;
8
+ /** Extra headers on top of whatever the rung's browser profile sends. */
9
+ headers: Record<string, string>;
10
+ timeoutMs: number;
11
+ /** Hard body cap; a rung must fail rather than buffer more. */
12
+ maxBytes: number;
13
+ /**
14
+ * When the SSRF guard is on: the public addresses the host was checked
15
+ * against. A rung that can pin the connection to them (curl `--resolve`)
16
+ * should, closing the DNS-rebinding race the guard otherwise leaves.
17
+ */
18
+ pinnedAddresses?: readonly string[];
19
+ /** Cookie jar shared across the redirect hops of one attempt (curl rungs). */
20
+ cookieJar?: string;
21
+ };
22
+
23
+ /** What a rung got back for one hop. */
24
+ export type RawResponse = {
25
+ status: number;
26
+ headers: Headers;
27
+ body: Buffer;
28
+ /** Final URL (differs from the request only for rungs that follow redirects). */
29
+ url: string;
30
+ };
31
+
32
+ export type Rung = {
33
+ /** Shown to the model and in logs: "impersonate:safari184", "plain", … */
34
+ readonly name: string;
35
+ /**
36
+ * True for rungs whose bytes cross somebody else's link (SOCKS exits, a
37
+ * mesh device) — they count against `fetch.dailyByteCap`.
38
+ */
39
+ readonly relayed: boolean;
40
+ /**
41
+ * True when the rung follows redirects itself (a browser, a remote curl).
42
+ * Otherwise the ladder follows them hop by hop so the guard sees each one.
43
+ */
44
+ readonly followsRedirects: boolean;
45
+ /**
46
+ * True for rungs that make no sense for a private/loopback target (a
47
+ * home-lab page has no bot wall, and an exit can't reach it anyway).
48
+ */
49
+ readonly publicOnly: boolean;
50
+ /** Per-attempt timeout override (a browser needs longer than curl). */
51
+ readonly timeoutMs?: number;
52
+ /**
53
+ * Whether the rung can run right now (binary present, endpoint set,
54
+ * device online). May do one-time setup such as a binary download.
55
+ * Returning a string means "no" with a reason worth reporting.
56
+ */
57
+ available(): Promise<true | string>;
58
+ request(req: RungRequest): Promise<RawResponse>;
59
+ };
60
+
61
+ /**
62
+ * How a response is judged:
63
+ * - ok: usable content.
64
+ * - blocked: a bot wall (403/429/503/999 or a challenge page) — climb on.
65
+ * - network: no HTTP answer at all (timeout, reset, DNS) — climb on.
66
+ * - not-found: 404/410 — the URL is wrong, not blocked; stop climbing.
67
+ * - http-error: any other definitive HTTP failure — stop climbing.
68
+ */
69
+ export type Verdict = "ok" | "blocked" | "network" | "not-found" | "http-error";
70
+
71
+ /** One rung's outcome, as recorded in the attempt log. */
72
+ export type RungOutcome = {
73
+ via: string;
74
+ ok: boolean;
75
+ verdict: Verdict | "skipped";
76
+ status?: number;
77
+ bytes?: number;
78
+ /** Why the rung failed or was skipped. */
79
+ detail?: string;
80
+ };
81
+
82
+ type LadderSuccess = {
83
+ ok: true;
84
+ via: string;
85
+ status: number;
86
+ headers: Headers;
87
+ body: Buffer;
88
+ url: string;
89
+ attempts: RungOutcome[];
90
+ /** Set when the result deserves a caveat (see FetchLadder.fetch). */
91
+ note?: string;
92
+ };
93
+
94
+ type LadderFailure = {
95
+ ok: false;
96
+ /** The verdict that ended the climb (last rung's, or the definitive one). */
97
+ verdict: Verdict | "skipped";
98
+ status?: number;
99
+ via?: string;
100
+ /** Human-readable, honest summary for the tool result. */
101
+ error: string;
102
+ attempts: RungOutcome[];
103
+ };
104
+
105
+ export type LadderResult = LadderSuccess | LadderFailure;
@@ -4,9 +4,11 @@
4
4
  *
5
5
  * Core cannot import frontends, so the composition root injects the
6
6
  * delivery function at boot (bootstrap.ts, next to the plan-alerts
7
- * wiring, which does the same thing privately). Unwired — early boot,
8
- * tests, terminal mode with no admin — notifications degrade to a log
9
- * line rather than throwing.
7
+ * wiring, which does the same thing privately). Alerts raised before that
8
+ * — early boot is exactly when restore reports and security alerts fire —
9
+ * are held in a small bounded queue and flushed, oldest first, the moment
10
+ * a notifier is wired. With nothing ever wired (tests, terminal mode with
11
+ * no admin) they stay a log line; nothing throws.
10
12
  *
11
13
  * First consumer: WhatsApp pairing. When WhatsApp unlinks the device,
12
14
  * recovery needs a human to type a pairing code into the phone — a code
@@ -16,30 +18,116 @@
16
18
 
17
19
  import { log, logWarn } from "../../util/log.js";
18
20
 
19
- let deliver: ((text: string) => Promise<void>) | null = null;
21
+ type Deliver = (text: string) => Promise<void>;
20
22
 
21
- /** Wire (or clear) the delivery function. Called by the composition root. */
22
- export function setAdminNotifier(
23
- fn: ((text: string) => Promise<void>) | null,
24
- ): void {
23
+ let deliver: Deliver | null = null;
24
+
25
+ /** Most alerts held while no notifier is wired; the oldest are dropped past it. */
26
+ export const ADMIN_NOTIFY_QUEUE_MAX = 20;
27
+
28
+ type Pending = { text: string; at: number };
29
+ const pending: Pending[] = [];
30
+ let droppedWhileUnwired = 0;
31
+ let flushing: Promise<void> | null = null;
32
+
33
+ /**
34
+ * Wire (or clear) the delivery function. Called by the composition root.
35
+ * Wiring a notifier flushes anything queued before it existed, in the
36
+ * background (the call itself stays synchronous).
37
+ */
38
+ export function setAdminNotifier(fn: Deliver | null): void {
25
39
  deliver = fn;
40
+ if (fn && (pending.length > 0 || droppedWhileUnwired > 0)) {
41
+ flushing = flushPending(fn).finally(() => {
42
+ flushing = null;
43
+ });
44
+ }
45
+ }
46
+
47
+ /** Resolves once any in-flight flush of queued alerts has finished. */
48
+ export function adminNotifyFlushed(): Promise<void> {
49
+ return flushing ?? Promise.resolve();
50
+ }
51
+
52
+ /** Drop queued, undelivered alerts (tests; a notifier that will never come). */
53
+ export function clearPendingAdminNotifications(): void {
54
+ pending.length = 0;
55
+ droppedWhileUnwired = 0;
56
+ }
57
+
58
+ /** How many alerts are waiting for a notifier. */
59
+ export function pendingAdminNotificationCount(): number {
60
+ return pending.length;
61
+ }
62
+
63
+ async function flushPending(fn: Deliver): Promise<void> {
64
+ const batch = pending.splice(0, pending.length);
65
+ const dropped = droppedWhileUnwired;
66
+ droppedWhileUnwired = 0;
67
+ if (dropped > 0) {
68
+ batch.unshift({
69
+ text: `${dropped} earlier admin alert(s) were dropped before a notifier was wired (queue holds ${ADMIN_NOTIFY_QUEUE_MAX}); see the daemon log.`,
70
+ at: Date.now(),
71
+ });
72
+ }
73
+ log("notify", `Flushing ${batch.length} queued admin alert(s)`);
74
+ for (const item of batch) {
75
+ // The notifier was swapped or cleared mid-flush: requeue the rest for
76
+ // whichever notifier comes next rather than sending on a stale one.
77
+ if (deliver !== fn) {
78
+ enqueue(item);
79
+ continue;
80
+ }
81
+ const ageS = Math.round((Date.now() - item.at) / 1000);
82
+ const text = ageS >= 5 ? `(delayed ${ageS}s) ${item.text}` : item.text;
83
+ try {
84
+ await fn(text);
85
+ log("notify", `Admin notified (queued): ${preview(item.text)}`);
86
+ } catch (err) {
87
+ // No requeue: a notifier that just failed would fail again, and a
88
+ // retry loop on the alert path is worse than one lost alert.
89
+ logWarn(
90
+ "notify",
91
+ `Queued admin notification failed: ${err instanceof Error ? err.message : err}; dropping: ${item.text.slice(0, 120)}`,
92
+ );
93
+ }
94
+ }
95
+ }
96
+
97
+ function enqueue(item: Pending): void {
98
+ pending.push(item);
99
+ while (pending.length > ADMIN_NOTIFY_QUEUE_MAX) {
100
+ const lost = pending.shift();
101
+ droppedWhileUnwired++;
102
+ if (lost)
103
+ logWarn(
104
+ "notify",
105
+ `Admin alert queue full; dropping oldest: ${lost.text.slice(0, 120)}`,
106
+ );
107
+ }
108
+ }
109
+
110
+ function preview(text: string): string {
111
+ return text.slice(0, 80).replace(/\n/g, " ");
26
112
  }
27
113
 
28
114
  /**
29
- * Send `text` to the admin chat. Never throws; returns whether delivery
30
- * was attempted (false = no notifier wired).
115
+ * Send `text` to the admin chat. Never throws; returns whether it was
116
+ * delivered now (false = failed, or queued because no notifier is wired
117
+ * yet — it is sent when one is).
31
118
  */
32
119
  export async function notifyAdmin(text: string): Promise<boolean> {
33
120
  if (!deliver) {
121
+ enqueue({ text, at: Date.now() });
34
122
  logWarn(
35
123
  "notify",
36
- `No admin notifier wired; dropping: ${text.slice(0, 120)}`,
124
+ `No admin notifier wired yet; queued (${pending.length}/${ADMIN_NOTIFY_QUEUE_MAX}): ${text.slice(0, 120)}`,
37
125
  );
38
126
  return false;
39
127
  }
40
128
  try {
41
129
  await deliver(text);
42
- log("notify", `Admin notified: ${text.slice(0, 80).replace(/\n/g, " ")}`);
130
+ log("notify", `Admin notified: ${preview(text)}`);
43
131
  return true;
44
132
  } catch (err) {
45
133
  log(