xaq-cli 0.0.0 → 0.1.0-nightly.71
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/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/admin-5gThRwgj.js +162 -0
- package/dist/admin-5gThRwgj.js.map +1 -0
- package/dist/admin-Btrm8mCd.js +218 -0
- package/dist/admin-Btrm8mCd.js.map +1 -0
- package/dist/admin-CWofnT_Q.js +2 -0
- package/dist/api-BFpElwqK.js +89 -0
- package/dist/api-BFpElwqK.js.map +1 -0
- package/dist/audit-BB7jK9Z5.js +2 -0
- package/dist/audit-BVYknW2i.js +79 -0
- package/dist/audit-BVYknW2i.js.map +1 -0
- package/dist/auth-CXmBdMqw.js +2 -0
- package/dist/auth-D-GmwNv-.js +550 -0
- package/dist/auth-D-GmwNv-.js.map +1 -0
- package/dist/brand--ZB8jTn1.js +32 -0
- package/dist/brand--ZB8jTn1.js.map +1 -0
- package/dist/bus-Cz0flS37.js +12 -0
- package/dist/bus-Cz0flS37.js.map +1 -0
- package/dist/bus-OKIGut72.js +2 -0
- package/dist/cli.js +234 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands-BNVLjYXA.js +167 -0
- package/dist/commands-BNVLjYXA.js.map +1 -0
- package/dist/commands-CdA6-WE7.js +112 -0
- package/dist/commands-CdA6-WE7.js.map +1 -0
- package/dist/config-C9XmYZWO.js +94 -0
- package/dist/config-C9XmYZWO.js.map +1 -0
- package/dist/config-CNVi0v5t.js +2 -0
- package/dist/connect-TtJ9n0Rw.js +362 -0
- package/dist/connect-TtJ9n0Rw.js.map +1 -0
- package/dist/content-C1lKMxFS.js +1170 -0
- package/dist/content-C1lKMxFS.js.map +1 -0
- package/dist/content-worker.js +28 -0
- package/dist/content-worker.js.map +1 -0
- package/dist/crypto-fv2fj3S8.js +75 -0
- package/dist/crypto-fv2fj3S8.js.map +1 -0
- package/dist/db-BEUEUPyP.js +561 -0
- package/dist/db-BEUEUPyP.js.map +1 -0
- package/dist/db-Q5BQuwga.js +2 -0
- package/dist/errors-FbMS3QBo.js +39 -0
- package/dist/errors-FbMS3QBo.js.map +1 -0
- package/dist/filter-Ck6OMxvu.js +158 -0
- package/dist/filter-Ck6OMxvu.js.map +1 -0
- package/dist/hermes-CKyzt1mH.js +3 -0
- package/dist/hermes-DHnDTYJp.js +1023 -0
- package/dist/hermes-DHnDTYJp.js.map +1 -0
- package/dist/import-DsN5NZT3.js +910 -0
- package/dist/import-DsN5NZT3.js.map +1 -0
- package/dist/instance-CwWbFbJ8.js +2 -0
- package/dist/instance-Dj9TpJB2.js +195 -0
- package/dist/instance-Dj9TpJB2.js.map +1 -0
- package/dist/listen-B9iO7HgQ.js +1568 -0
- package/dist/listen-B9iO7HgQ.js.map +1 -0
- package/dist/listen-CXVKG9XD.js +2 -0
- package/dist/manage-Bo6COB4p.js +477 -0
- package/dist/manage-Bo6COB4p.js.map +1 -0
- package/dist/manage-DoXqvjnU.js +2 -0
- package/dist/mcpheader-B8c5YO1B.js +2 -0
- package/dist/mcpheader-C9H4vudu.js +185 -0
- package/dist/mcpheader-C9H4vudu.js.map +1 -0
- package/dist/mcpsetup-BnHudwpq.js +2 -0
- package/dist/mcpsetup-DXXo2qjf.js +1053 -0
- package/dist/mcpsetup-DXXo2qjf.js.map +1 -0
- package/dist/node-check-CjTk6VNL.js +24 -0
- package/dist/node-check-CjTk6VNL.js.map +1 -0
- package/dist/paths-d-C2kdjo.js +2 -0
- package/dist/paths-yzOKD6ow.js +46 -0
- package/dist/paths-yzOKD6ow.js.map +1 -0
- package/dist/profile-CcMZ4hn7.js +2 -0
- package/dist/profile-NmMKAczz.js +222 -0
- package/dist/profile-NmMKAczz.js.map +1 -0
- package/dist/prompt-CKMQx_MJ.js +114 -0
- package/dist/prompt-CKMQx_MJ.js.map +1 -0
- package/dist/recipes-C7TIDlRj.js +3 -0
- package/dist/recipes-DG_-doGq.js +314 -0
- package/dist/recipes-DG_-doGq.js.map +1 -0
- package/dist/send-Dk-t8ia-.js +108 -0
- package/dist/send-Dk-t8ia-.js.map +1 -0
- package/dist/server-CDqDp1q3.js +20640 -0
- package/dist/server-CDqDp1q3.js.map +1 -0
- package/dist/service-BVn3soZZ.js +766 -0
- package/dist/service-BVn3soZZ.js.map +1 -0
- package/dist/service-BaWGPmSd.js +2 -0
- package/dist/service-C7jfo-Ex.js +457 -0
- package/dist/service-C7jfo-Ex.js.map +1 -0
- package/dist/service-DxDyKDQ-.js +2 -0
- package/dist/settings-B5qSHxRM.js +134 -0
- package/dist/settings-B5qSHxRM.js.map +1 -0
- package/dist/settings-DQvZaMN1.js +2 -0
- package/dist/setup-token-CwuSzfLK.js +48 -0
- package/dist/setup-token-CwuSzfLK.js.map +1 -0
- package/dist/skill/SKILL.md +445 -0
- package/dist/skill-1UQj3qgW.js +69 -0
- package/dist/skill-1UQj3qgW.js.map +1 -0
- package/dist/skill-pelq18JQ.js +2 -0
- package/dist/ssrf-BlVpGZ7C.js +181 -0
- package/dist/ssrf-BlVpGZ7C.js.map +1 -0
- package/dist/types-BhJDbSzE.js +135 -0
- package/dist/types-BhJDbSzE.js.map +1 -0
- package/dist/ui-B8Sd4Y7Y.js +81 -0
- package/dist/ui-B8Sd4Y7Y.js.map +1 -0
- package/dist/urls-Dp8pBGMU.js +2 -0
- package/dist/urls-U_LfYV61.js +27 -0
- package/dist/urls-U_LfYV61.js.map +1 -0
- package/dist/version-BxH7OuWD.js +20 -0
- package/dist/version-BxH7OuWD.js.map +1 -0
- package/dist/web/assets/_agentId-DonOCOlC.js +1 -0
- package/dist/web/assets/_app-B7t5lEia.js +1 -0
- package/dist/web/assets/_app-DMTGqYpM.js +65 -0
- package/dist/web/assets/_connectionId-DR5vFXOg.js +1 -0
- package/dist/web/assets/_webhookId-LfUlsULp.js +1 -0
- package/dist/web/assets/account-status-CEszs13C.js +1 -0
- package/dist/web/assets/accounts-5m7aiXuZ.js +1 -0
- package/dist/web/assets/accounts-list-YfJzThZa.js +1 -0
- package/dist/web/assets/activity-DOIe7SAg.js +1 -0
- package/dist/web/assets/add-connection-C8JR-13R.js +1 -0
- package/dist/web/assets/agent-create-form-CPxnPYH4.js +1 -0
- package/dist/web/assets/agents-B2Qj5KxT.js +1 -0
- package/dist/web/assets/approvals-BGaVaoFy.js +1 -0
- package/dist/web/assets/approve._id-BLrQnzPM.js +1 -0
- package/dist/web/assets/arrow-left-DZVAXoKj.js +1 -0
- package/dist/web/assets/arrow-right-C2zcqRr2.js +1 -0
- package/dist/web/assets/attention-gO9Nw5uW.js +1 -0
- package/dist/web/assets/audit-BPXXre8S.js +1 -0
- package/dist/web/assets/audit-bits-CGyIprzK.js +1 -0
- package/dist/web/assets/bell-ring-ig3zn1iS.js +1 -0
- package/dist/web/assets/bot-Zk76q_39.js +1 -0
- package/dist/web/assets/calendar-notice-DsCfq3VJ.js +1 -0
- package/dist/web/assets/calendar-plus-Cycfkk0y.js +1 -0
- package/dist/web/assets/calendar-setup-Pn61Wj1n.js +2 -0
- package/dist/web/assets/card-GSO5o-Ug.js +1 -0
- package/dist/web/assets/check-check-Ds1bPIGv.js +1 -0
- package/dist/web/assets/checkbox-B937U-Fd.js +1 -0
- package/dist/web/assets/chevron-right-Cqh4mDk1.js +1 -0
- package/dist/web/assets/chips-input-2xWDofmE.js +1 -0
- package/dist/web/assets/choice-cards-CgAIqBGX.js +1 -0
- package/dist/web/assets/circle-x-DcU-0zVh.js +1 -0
- package/dist/web/assets/command-khhto70y.js +1 -0
- package/dist/web/assets/common-BPZV8TFb.js +1 -0
- package/dist/web/assets/connect-agent-guide-CJdw31p2.js +1 -0
- package/dist/web/assets/connect-host-lr2c3xWh.js +6 -0
- package/dist/web/assets/connection-row-DYx_5o0c.js +1 -0
- package/dist/web/assets/connections-C4NOFb5R.js +1 -0
- package/dist/web/assets/data-table-kW1pkRB5.js +1 -0
- package/dist/web/assets/empty-DzTDioBP.js +1 -0
- package/dist/web/assets/events-C9zJnPKK.js +1 -0
- package/dist/web/assets/eye-B9gT1QSq.js +1 -0
- package/dist/web/assets/eye-off-Diktqz_g.js +1 -0
- package/dist/web/assets/field-C6ejurju.js +1 -0
- package/dist/web/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/web/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/web/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/web/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/web/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- package/dist/web/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- package/dist/web/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- package/dist/web/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/dist/web/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- package/dist/web/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- package/dist/web/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/web/assets/get-started-C38bq1gZ.js +2 -0
- package/dist/web/assets/hidden-chars-Dr9TxfvF.js +1 -0
- package/dist/web/assets/index-DGzo3kGh.js +11 -0
- package/dist/web/assets/index-zKOCQLGX.css +2 -0
- package/dist/web/assets/input-CWwJgozn.js +1 -0
- package/dist/web/assets/input-group-HG8OXcJ4.js +1 -0
- package/dist/web/assets/label-multi-select-sH8mllw8.js +1 -0
- package/dist/web/assets/labels-B5UqVwFo.js +1 -0
- package/dist/web/assets/laptop-BxJ4Nbj7.js +1 -0
- package/dist/web/assets/last-call-D-O5O94z.js +1 -0
- package/dist/web/assets/listener-handled-HNCuVrQ7.js +1 -0
- package/dist/web/assets/login-DaeKclcf.js +1 -0
- package/dist/web/assets/mail-C5fm2pTC.js +1 -0
- package/dist/web/assets/mcp-call-UoOTPFyh.js +1 -0
- package/dist/web/assets/mcp-call-card-Cr-OEDX2.js +3 -0
- package/dist/web/assets/message-square-quote-se8Bf3RP.js +1 -0
- package/dist/web/assets/mutations-CLPqJ6cj.js +1 -0
- package/dist/web/assets/network-igNw0VVx.js +1 -0
- package/dist/web/assets/pairing-XjuaVr2u.js +1 -0
- package/dist/web/assets/pairing-notifier-KPzOEZSO.js +1 -0
- package/dist/web/assets/paperclip-znS4iTrg.js +1 -0
- package/dist/web/assets/password-input-DV3aqcNf.js +1 -0
- package/dist/web/assets/pause-o-dK3khK.js +1 -0
- package/dist/web/assets/pencil-DkPFAhEq.js +1 -0
- package/dist/web/assets/play-DvCHND93.js +1 -0
- package/dist/web/assets/plug-CXAniEiR.js +1 -0
- package/dist/web/assets/plug-zap-BwR-l5wb.js +1 -0
- package/dist/web/assets/plus-B_TUslu3.js +1 -0
- package/dist/web/assets/preload-helper-Cc_aKyUb.js +1 -0
- package/dist/web/assets/public-url-rF2Xjyel.js +1 -0
- package/dist/web/assets/queries-Cv3nXx0U.js +42 -0
- package/dist/web/assets/refresh-cw-Ch5FNrby.js +1 -0
- package/dist/web/assets/revealed-keys-C_9NtJPY.js +1 -0
- package/dist/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/dist/web/assets/rotate-ccw-B-mzPDk0.js +1 -0
- package/dist/web/assets/search-CTVKGomS.js +1 -0
- package/dist/web/assets/select-Bb806bpq.js +1 -0
- package/dist/web/assets/send-DuMVuYs1.js +1 -0
- package/dist/web/assets/settings-BWauhfFf.js +1 -0
- package/dist/web/assets/setup-B7bNKQOp.js +1 -0
- package/dist/web/assets/sheet-CYPwpUsH.js +1 -0
- package/dist/web/assets/shield-alert-CSYB-ThV.js +1 -0
- package/dist/web/assets/shield-check-BulpsYqK.js +1 -0
- package/dist/web/assets/sparkles-CUTiob4K.js +1 -0
- package/dist/web/assets/stamp-BNXUGNa3.js +1 -0
- package/dist/web/assets/step-url-51aMNak-.js +1 -0
- package/dist/web/assets/stream-Du7fY5_Y.js +1 -0
- package/dist/web/assets/tag-I5_-1jFL.js +1 -0
- package/dist/web/assets/terminal-BvxDFTJc.js +1 -0
- package/dist/web/assets/test-email-button-DYi7V_6X.js +1 -0
- package/dist/web/assets/toggle-group-Bdo1gmQ1.js +1 -0
- package/dist/web/assets/trash-Ci0aICEl.js +1 -0
- package/dist/web/assets/use-delivery-speed-BBh7W2q6.js +1 -0
- package/dist/web/assets/use-label-names-BXicesnP.js +1 -0
- package/dist/web/assets/use-sign-out-CPrY_oVO.js +1 -0
- package/dist/web/assets/useInfiniteQuery-DJLIM8yJ.js +1 -0
- package/dist/web/assets/useQuery-MrTwwNy5.js +1 -0
- package/dist/web/assets/useRouterState-D4OBK8l5.js +1 -0
- package/dist/web/assets/webhook-DjeeZiNC.js +1 -0
- package/dist/web/assets/webhook-bits-jw-y2D3u.js +1 -0
- package/dist/web/assets/webhook-sheet-B8h0skDe.js +34 -0
- package/dist/web/assets/webhooks-Cfsdpoyd.js +1 -0
- package/dist/web/assets/x-MF2TZ9aG.js +1 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +27 -0
- package/package.json +98 -1
|
@@ -0,0 +1,1568 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { i as underService, n as envVar } from "./brand--ZB8jTn1.js";
|
|
3
|
+
import { n as version, t as NPX } from "./version-BxH7OuWD.js";
|
|
4
|
+
import { d as tighten, f as writePrivateFile, i as saveProfile, l as ensurePrivateDir, n as loadProfile } from "./profile-NmMKAczz.js";
|
|
5
|
+
import { f as statusPath, i as deadPath, l as profileStateDir, o as logPath, p as validProfileName, s as oneOffLogPath } from "./paths-yzOKD6ow.js";
|
|
6
|
+
import { m as writeClaudeMcpConfig, t as claudeMcpConfigPath } from "./mcpheader-C9H4vudu.js";
|
|
7
|
+
import { S as insecureBanner, a as TOOL_LABELS, f as isHermesRoute, g as recipeToolFor, h as recipeOf, o as hermesChangeNote, r as SILENT_MARKER, v as shellQuote, x as checkTransport } from "./recipes-DG_-doGq.js";
|
|
8
|
+
import { i as safeText, r as formatDuration } from "./ui-B8Sd4Y7Y.js";
|
|
9
|
+
import { A as clientHeader, C as commandInput, D as ApiError, E as skipReason, I as userAgent, L as abortable, M as describeFetchError, N as getSelf, O as ackEvents, S as batchEnv, T as parsePayload, _ as throwawayConsumer, b as forwardEvent, d as THROWAWAY_SUFFIXES, f as categoryNote, g as resolveListen, k as apiError, m as describeFilter, o as routeWarning, r as gatewayWarning, v as typesNotes, w as eventEnv, x as UNTRUSTED_NOTICE } from "./hermes-DHnDTYJp.js";
|
|
10
|
+
import { appendFileSync, closeSync, constants, existsSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { hostname, tmpdir } from "node:os";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import pc from "picocolors";
|
|
15
|
+
//#region server/listen/grants.ts
|
|
16
|
+
/**
|
|
17
|
+
* A grant in plain words, e.g. "ops@acme.dev: read, draft, send; sends need your approval in the
|
|
18
|
+
* console". Uses the same wording as the console's approval choices (Read only, Read and draft,
|
|
19
|
+
* Send with my approval, Full access) so the human recognizes what they picked. Calendar access is
|
|
20
|
+
* described after the mail part.
|
|
21
|
+
*/
|
|
22
|
+
function describeGrant(g) {
|
|
23
|
+
const all = g.capabilities;
|
|
24
|
+
const caps = all.filter((c) => !c.startsWith("calendar."));
|
|
25
|
+
const parts = [];
|
|
26
|
+
const readOnly = caps.length === 1 && caps[0] === "read";
|
|
27
|
+
if (readOnly) parts.push(all.length > 1 ? `${all.join(", ")}; mail read only (can't draft or send)` : "read only (can't draft or send)");
|
|
28
|
+
else parts.push(all.join(", ") || "no capabilities");
|
|
29
|
+
if (caps.includes("send")) {
|
|
30
|
+
if (g.sendMode === "approval") parts.push("sends need your approval in the console");
|
|
31
|
+
else if (g.sendMode === "direct") parts.push(g.allowTo?.length ? "sends go out directly to allowed recipients" : "sends go out directly");
|
|
32
|
+
else parts.push("sends become Gmail drafts for you to send");
|
|
33
|
+
if (g.allowTo?.length) {
|
|
34
|
+
const shown = g.allowTo.slice(0, 3).map((a) => safeText(a, 80));
|
|
35
|
+
parts.push(`only to ${shown.join(", ")}${g.allowTo.length > 3 ? ` and ${g.allowTo.length - 3} more` : ""}`);
|
|
36
|
+
}
|
|
37
|
+
if (g.replyOnly) parts.push("replies in existing threads only");
|
|
38
|
+
const limits = [g.maxSendsPerHour ? `${g.maxSendsPerHour}/hour` : null, g.maxSendsPerDay ? `${g.maxSendsPerDay}/day` : null].filter(Boolean);
|
|
39
|
+
if (limits.length) parts.push(`at most ${limits.join(", ")}`);
|
|
40
|
+
} else if (!readOnly && caps.length) parts.push("can't send");
|
|
41
|
+
if (g.labelScope?.length) parts.push(`only label${g.labelScope.length > 1 ? "s" : ""} ${g.labelScope.map((l) => safeText(l, 60)).join(", ")}`);
|
|
42
|
+
if (all.includes("calendar.write")) parts.push(g.calendarWriteMode === "direct" ? "calendar changes apply directly" : "calendar changes need your approval in the console");
|
|
43
|
+
if (g.calendarScope?.length && all.some((c) => c.startsWith("calendar."))) parts.push(`only calendar${g.calendarScope.length > 1 ? "s" : ""} ${g.calendarScope.map((c) => safeText(c, 80)).join(", ")}`);
|
|
44
|
+
return `${safeText(g.accountEmail || g.accountId, 120)}: ${parts.join("; ")}`;
|
|
45
|
+
}
|
|
46
|
+
const LEVEL_WORDS = {
|
|
47
|
+
full: "full access",
|
|
48
|
+
ask: "ask before risky actions",
|
|
49
|
+
read: "read only"
|
|
50
|
+
};
|
|
51
|
+
/** A connection grant in plain words, e.g. "Notion (notion__… tools): ask before risky actions". */
|
|
52
|
+
const describeConnection = (c) => `${safeText(c.name, 80)} (${safeText(c.slug, 24)}__… tools): ${LEVEL_WORDS[c.level] ?? safeText(String(c.level), 20)}`;
|
|
53
|
+
/** One line per mailbox grant and per connection, or a hint when there are none. */
|
|
54
|
+
function describeGrants(grants, connections = []) {
|
|
55
|
+
if (!grants.length && !connections.length) return ["no mailbox or calendar access yet: add a grant for this agent in the console (Agents page)"];
|
|
56
|
+
return [...grants.map(describeGrant), ...connections.map(describeConnection)];
|
|
57
|
+
}
|
|
58
|
+
/** Whether there's anything to describe (else describeGrants' single line is the "nothing yet" hint). */
|
|
59
|
+
const hasAccess = (self) => self.grants.length > 0 || (self.connections?.length ?? 0) > 0;
|
|
60
|
+
/** Stable key for "did the grants change?" (order-independent; connections too). */
|
|
61
|
+
const grantsKey = (grants, connections = []) => JSON.stringify(connections.length ? [grantsList(grants), connections.map((c) => [c.slug, c.level]).sort()] : grantsList(grants));
|
|
62
|
+
const grantsList = (grants) => grants.map((g) => [
|
|
63
|
+
g.accountId,
|
|
64
|
+
[...g.capabilities].sort(),
|
|
65
|
+
g.sendMode,
|
|
66
|
+
g.labelScope ? [...g.labelScope].sort() : null,
|
|
67
|
+
g.allowTo ? [...g.allowTo].sort() : null,
|
|
68
|
+
g.replyOnly ?? null,
|
|
69
|
+
g.maxSendsPerHour ?? null,
|
|
70
|
+
g.maxSendsPerDay ?? null,
|
|
71
|
+
g.calendarScope ? [...g.calendarScope].sort() : null,
|
|
72
|
+
g.calendarWriteMode ?? null
|
|
73
|
+
]).sort((a, b) => String(a[0]).localeCompare(String(b[0])));
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region server/listen/dispatcher.ts
|
|
76
|
+
const defaultRetryDelay = (attempt) => Math.min(5e3 * 2 ** (attempt - 1), 3e5) * (.8 + Math.random() * .4);
|
|
77
|
+
/**
|
|
78
|
+
* Runs events with bounded concurrency and retries, and tracks the **contiguous prefix** of finished
|
|
79
|
+
* events (succeeded, dead or filtered out). Only that prefix is acknowledged, so a crash never loses
|
|
80
|
+
* an event that was still running, collecting into a batch, or waiting to retry: it is delivered
|
|
81
|
+
* again on restart (at-least-once; handlers dedupe on the event id). Events can finish out of order
|
|
82
|
+
* when concurrency is above 1 or an event is waiting for a retry. A batch job takes its first event's
|
|
83
|
+
* place in that order, so nothing after it is acknowledged before the whole batch has finished.
|
|
84
|
+
*/
|
|
85
|
+
var Dispatcher = class {
|
|
86
|
+
opts;
|
|
87
|
+
jobs = [];
|
|
88
|
+
running = 0;
|
|
89
|
+
highestSeen = 0;
|
|
90
|
+
advancedTo = 0;
|
|
91
|
+
stopped = false;
|
|
92
|
+
controller = new AbortController();
|
|
93
|
+
timers = /* @__PURE__ */ new Set();
|
|
94
|
+
idleWaiters = [];
|
|
95
|
+
/** The batch still collecting events, if any. */
|
|
96
|
+
open = null;
|
|
97
|
+
constructor(opts) {
|
|
98
|
+
this.opts = opts;
|
|
99
|
+
}
|
|
100
|
+
/** Highest seq received this session (sent as Last-Event-ID on reconnect). */
|
|
101
|
+
get lastSeq() {
|
|
102
|
+
return this.highestSeen;
|
|
103
|
+
}
|
|
104
|
+
/** Seq the finished prefix reaches. */
|
|
105
|
+
get finishedThrough() {
|
|
106
|
+
return this.advancedTo;
|
|
107
|
+
}
|
|
108
|
+
/** Events received but not yet started (for stream backpressure). */
|
|
109
|
+
get backlog() {
|
|
110
|
+
return this.jobs.filter((j) => j.state === "queued" || j.state === "collecting").reduce((n, j) => n + j.payloads.length, 0);
|
|
111
|
+
}
|
|
112
|
+
get inFlight() {
|
|
113
|
+
return this.jobs.filter((j) => j.state !== "done").length;
|
|
114
|
+
}
|
|
115
|
+
/** Seeds the position (the server's acked cursor) so older replays are ignored. */
|
|
116
|
+
seed(seq) {
|
|
117
|
+
this.highestSeen = Math.max(this.highestSeen, seq);
|
|
118
|
+
this.advancedTo = Math.max(this.advancedTo, seq);
|
|
119
|
+
}
|
|
120
|
+
/** Adds an event. Returns false when it is a duplicate of one already seen this session. */
|
|
121
|
+
push(payload, opts = {}) {
|
|
122
|
+
const seq = payload.data.seq;
|
|
123
|
+
if (this.stopped || seq <= this.highestSeen) return false;
|
|
124
|
+
this.highestSeen = seq;
|
|
125
|
+
const remaining = new Set(opts.targets ? this.opts.targets.filter((t) => opts.targets.includes(t)) : this.opts.targets);
|
|
126
|
+
const batch = this.opts.batch && !opts.targets ? this.opts.batch : null;
|
|
127
|
+
if (!opts.skip && remaining.size && batch && this.open) {
|
|
128
|
+
const job = this.open.job;
|
|
129
|
+
job.payloads.push(payload);
|
|
130
|
+
job.lastSeq = seq;
|
|
131
|
+
if (job.payloads.length >= batch.max) this.close();
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
const job = {
|
|
135
|
+
seq,
|
|
136
|
+
payload,
|
|
137
|
+
payloads: [payload],
|
|
138
|
+
lastSeq: seq,
|
|
139
|
+
remaining,
|
|
140
|
+
forwarded: /* @__PURE__ */ new Set(),
|
|
141
|
+
attempts: 0,
|
|
142
|
+
deferrals: 0,
|
|
143
|
+
lastError: null,
|
|
144
|
+
state: "queued",
|
|
145
|
+
firstSeenAt: Date.now()
|
|
146
|
+
};
|
|
147
|
+
this.jobs.push(job);
|
|
148
|
+
if (opts.skip || remaining.size === 0) {
|
|
149
|
+
job.state = "done";
|
|
150
|
+
job.outcome = "skipped";
|
|
151
|
+
this.advance();
|
|
152
|
+
} else if (batch && batch.max > 1) {
|
|
153
|
+
job.state = "collecting";
|
|
154
|
+
const timer = setTimeout(() => this.close(), batch.windowMs);
|
|
155
|
+
this.open = {
|
|
156
|
+
job,
|
|
157
|
+
timer
|
|
158
|
+
};
|
|
159
|
+
} else this.pump();
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
/** Ends the collecting batch: it runs as soon as a slot is free. */
|
|
163
|
+
close() {
|
|
164
|
+
if (!this.open) return;
|
|
165
|
+
clearTimeout(this.open.timer);
|
|
166
|
+
this.open.job.state = "queued";
|
|
167
|
+
this.open = null;
|
|
168
|
+
this.pump();
|
|
169
|
+
}
|
|
170
|
+
pump() {
|
|
171
|
+
if (this.stopped) return;
|
|
172
|
+
for (const job of this.jobs) {
|
|
173
|
+
if (this.running >= this.opts.concurrency) break;
|
|
174
|
+
if (job.state === "queued") this.start(job);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async start(job) {
|
|
178
|
+
job.state = "running";
|
|
179
|
+
job.attempts++;
|
|
180
|
+
job.retryAfterMs = void 0;
|
|
181
|
+
this.running++;
|
|
182
|
+
let errors;
|
|
183
|
+
try {
|
|
184
|
+
errors = await this.opts.run(job, this.controller.signal);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
errors = [err instanceof Error ? err.message : String(err)];
|
|
187
|
+
}
|
|
188
|
+
this.running--;
|
|
189
|
+
if (this.stopped) {
|
|
190
|
+
job.state = "queued";
|
|
191
|
+
this.notifyIdle();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (job.remaining.size === 0) {
|
|
195
|
+
job.state = "done";
|
|
196
|
+
job.outcome = "succeeded";
|
|
197
|
+
this.opts.onFinished?.(job);
|
|
198
|
+
} else {
|
|
199
|
+
job.lastError = errors.join("; ") || "failed";
|
|
200
|
+
const deferred = job.retryAfterMs !== void 0 && errors.length === 1 && job.deferrals < 20;
|
|
201
|
+
if (deferred) {
|
|
202
|
+
job.deferrals++;
|
|
203
|
+
job.attempts--;
|
|
204
|
+
}
|
|
205
|
+
if (job.attempts >= this.opts.maxAttempts) {
|
|
206
|
+
job.state = "done";
|
|
207
|
+
job.outcome = "dead";
|
|
208
|
+
this.opts.onDead(job);
|
|
209
|
+
this.opts.onFinished?.(job);
|
|
210
|
+
} else {
|
|
211
|
+
job.state = "waiting";
|
|
212
|
+
const delay = deferred ? job.retryAfterMs : (this.opts.retryDelayMs ?? defaultRetryDelay)(job.attempts);
|
|
213
|
+
this.opts.onRetry?.(job, delay);
|
|
214
|
+
const t = setTimeout(() => {
|
|
215
|
+
this.timers.delete(t);
|
|
216
|
+
if (job.state === "waiting") job.state = "queued";
|
|
217
|
+
this.pump();
|
|
218
|
+
}, delay);
|
|
219
|
+
this.timers.add(t);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
this.advance();
|
|
223
|
+
this.pump();
|
|
224
|
+
this.notifyIdle();
|
|
225
|
+
}
|
|
226
|
+
advance() {
|
|
227
|
+
let to = this.advancedTo;
|
|
228
|
+
while (this.jobs.length && this.jobs[0].state === "done") to = Math.max(to, this.jobs.shift().lastSeq);
|
|
229
|
+
if (to > this.advancedTo) {
|
|
230
|
+
this.advancedTo = to;
|
|
231
|
+
this.opts.onAdvance(to);
|
|
232
|
+
}
|
|
233
|
+
this.notifyIdle();
|
|
234
|
+
}
|
|
235
|
+
notifyIdle() {
|
|
236
|
+
if (this.jobs.length === 0 && this.running === 0) {
|
|
237
|
+
const waiters = this.idleWaiters;
|
|
238
|
+
this.idleWaiters = [];
|
|
239
|
+
for (const w of waiters) w();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** Resolves once every received event has finished (tests and `dead --replay`). */
|
|
243
|
+
idle() {
|
|
244
|
+
if (this.jobs.length === 0 && this.running === 0) return Promise.resolve();
|
|
245
|
+
return new Promise((r) => this.idleWaiters.push(r));
|
|
246
|
+
}
|
|
247
|
+
/** Stops starting work, aborts what is running (process groups are killed) and waits for it to unwind. A collecting batch isn't run (nor acknowledged). */
|
|
248
|
+
async stop() {
|
|
249
|
+
this.stopped = true;
|
|
250
|
+
if (this.open) clearTimeout(this.open.timer);
|
|
251
|
+
this.open = null;
|
|
252
|
+
for (const t of this.timers) clearTimeout(t);
|
|
253
|
+
this.timers.clear();
|
|
254
|
+
this.controller.abort();
|
|
255
|
+
while (this.running > 0) await new Promise((r) => setTimeout(r, 20));
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
//#endregion
|
|
259
|
+
//#region server/listen/exec.ts
|
|
260
|
+
const KILL_GRACE_MS = 5e3;
|
|
261
|
+
var Tail = class {
|
|
262
|
+
buf = "";
|
|
263
|
+
push(chunk) {
|
|
264
|
+
this.buf = (this.buf + chunk.toString("utf8")).slice(-4e3);
|
|
265
|
+
}
|
|
266
|
+
toString() {
|
|
267
|
+
return this.buf;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
/**
|
|
271
|
+
* The child's environment: ours minus anything xaq-specific (XAQ_* and the pre-rename AGENTINBOX_*:
|
|
272
|
+
* service flags, a master key that happened to be exported…), plus the safe event ids and the event file
|
|
273
|
+
* path. Each of those is set as XAQ_<name> and, for commands written before the rename, AGENTINBOX_<name>.
|
|
274
|
+
*/
|
|
275
|
+
function childEnv(input, eventFile) {
|
|
276
|
+
const env = {};
|
|
277
|
+
for (const [k, v] of Object.entries(process.env)) if (!k.startsWith("XAQ_") && !k.startsWith("AGENTINBOX_")) env[k] = v;
|
|
278
|
+
const ids = {
|
|
279
|
+
...Array.isArray(input) ? batchEnv(input) : {
|
|
280
|
+
...eventEnv(input),
|
|
281
|
+
XAQ_EVENT_COUNT: "1"
|
|
282
|
+
},
|
|
283
|
+
XAQ_EVENT_FILE: eventFile
|
|
284
|
+
};
|
|
285
|
+
const legacy = Object.fromEntries(Object.entries(ids).map(([k, v]) => [k.replace(/^XAQ_/, "AGENTINBOX_"), v]));
|
|
286
|
+
return {
|
|
287
|
+
...env,
|
|
288
|
+
...ids,
|
|
289
|
+
...legacy
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/** stdin for one event ({ notice, event }) or a --batch ({ notice, events: […] }, even for a batch of one). */
|
|
293
|
+
function commandStdin(input) {
|
|
294
|
+
const body = Array.isArray(input) ? {
|
|
295
|
+
notice: UNTRUSTED_NOTICE,
|
|
296
|
+
events: input.map((p) => commandInput(p).event)
|
|
297
|
+
} : commandInput(input);
|
|
298
|
+
return JSON.stringify(body, null, 2) + "\n";
|
|
299
|
+
}
|
|
300
|
+
/** Running commands, so a forced exit (second Ctrl-C) can still kill their process groups and remove temp files. */
|
|
301
|
+
const active = /* @__PURE__ */ new Map();
|
|
302
|
+
/** Synchronous last-resort cleanup before process.exit. */
|
|
303
|
+
function killAllRuns() {
|
|
304
|
+
for (const [pid, dir] of active) {
|
|
305
|
+
killGroup(pid, "SIGKILL");
|
|
306
|
+
rmSync(dir, {
|
|
307
|
+
recursive: true,
|
|
308
|
+
force: true
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
active.clear();
|
|
312
|
+
}
|
|
313
|
+
function groupAlive(pid) {
|
|
314
|
+
if (!pid) return false;
|
|
315
|
+
try {
|
|
316
|
+
process.kill(-pid, 0);
|
|
317
|
+
return true;
|
|
318
|
+
} catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function killGroup(pid, signal) {
|
|
323
|
+
if (!pid) return;
|
|
324
|
+
try {
|
|
325
|
+
process.kill(-pid, signal);
|
|
326
|
+
} catch {}
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Runs the configured command for one event, or for a batch (an array). The command string is fixed
|
|
330
|
+
* configuration and goes to `/bin/sh -c` verbatim; it is never built from event data. The events reach
|
|
331
|
+
* the command only as JSON on stdin, as a 0600 file named by XAQ_EVENT_FILE, and as a few
|
|
332
|
+
* validated ids in env. On timeout the whole process group gets SIGTERM, then SIGKILL after a grace period.
|
|
333
|
+
*/
|
|
334
|
+
function runCommand(command, payload, opts) {
|
|
335
|
+
const started = performance.now();
|
|
336
|
+
const input = commandStdin(payload);
|
|
337
|
+
const dir = mkdtempSync(join(tmpdir(), "xaq-event-"));
|
|
338
|
+
const eventFile = join(dir, "event.json");
|
|
339
|
+
writeFileSync(eventFile, input, {
|
|
340
|
+
mode: 384,
|
|
341
|
+
flag: "wx"
|
|
342
|
+
});
|
|
343
|
+
return new Promise((resolve) => {
|
|
344
|
+
const stdout = new Tail();
|
|
345
|
+
const stderr = new Tail();
|
|
346
|
+
let timedOut = false;
|
|
347
|
+
let settled = false;
|
|
348
|
+
let killTimer;
|
|
349
|
+
const child = spawn("/bin/sh", ["-c", command], {
|
|
350
|
+
cwd: opts.cwd,
|
|
351
|
+
env: childEnv(payload, eventFile),
|
|
352
|
+
stdio: [
|
|
353
|
+
"pipe",
|
|
354
|
+
"pipe",
|
|
355
|
+
"pipe"
|
|
356
|
+
],
|
|
357
|
+
detached: true
|
|
358
|
+
});
|
|
359
|
+
if (child.pid) active.set(child.pid, dir);
|
|
360
|
+
let terminatedAt = null;
|
|
361
|
+
const finish = (r) => {
|
|
362
|
+
if (settled) return;
|
|
363
|
+
settled = true;
|
|
364
|
+
clearTimeout(timer);
|
|
365
|
+
clearTimeout(killTimer);
|
|
366
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
367
|
+
(async () => {
|
|
368
|
+
if (terminatedAt !== null) {
|
|
369
|
+
while (groupAlive(child.pid) && Date.now() - terminatedAt < KILL_GRACE_MS) await new Promise((res) => setTimeout(res, 100));
|
|
370
|
+
killGroup(child.pid, "SIGKILL");
|
|
371
|
+
}
|
|
372
|
+
if (child.pid) active.delete(child.pid);
|
|
373
|
+
await new Promise((res) => setTimeout(res, 50));
|
|
374
|
+
child.stdout?.destroy();
|
|
375
|
+
child.stderr?.destroy();
|
|
376
|
+
rmSync(dir, {
|
|
377
|
+
recursive: true,
|
|
378
|
+
force: true
|
|
379
|
+
});
|
|
380
|
+
resolve({
|
|
381
|
+
...r,
|
|
382
|
+
ok: r.ok && !timedOut,
|
|
383
|
+
timedOut,
|
|
384
|
+
durationMs: Math.round(performance.now() - started),
|
|
385
|
+
stdoutTail: stdout.toString(),
|
|
386
|
+
stderrTail: stderr.toString()
|
|
387
|
+
});
|
|
388
|
+
})();
|
|
389
|
+
};
|
|
390
|
+
const terminate = () => {
|
|
391
|
+
if (terminatedAt !== null) return;
|
|
392
|
+
terminatedAt = Date.now();
|
|
393
|
+
killGroup(child.pid, "SIGTERM");
|
|
394
|
+
killTimer = setTimeout(() => killGroup(child.pid, "SIGKILL"), KILL_GRACE_MS);
|
|
395
|
+
killTimer.unref();
|
|
396
|
+
};
|
|
397
|
+
const timer = setTimeout(() => {
|
|
398
|
+
timedOut = true;
|
|
399
|
+
terminate();
|
|
400
|
+
}, opts.timeoutMs);
|
|
401
|
+
const onAbort = () => terminate();
|
|
402
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
403
|
+
child.stdout.on("data", (c) => stdout.push(c));
|
|
404
|
+
child.stderr.on("data", (c) => stderr.push(c));
|
|
405
|
+
child.stdin.on("error", () => {});
|
|
406
|
+
child.stdin.end(input);
|
|
407
|
+
child.on("error", (err) => finish({
|
|
408
|
+
ok: false,
|
|
409
|
+
exitCode: null,
|
|
410
|
+
signal: null,
|
|
411
|
+
error: `Could not start /bin/sh: ${err.message}`
|
|
412
|
+
}));
|
|
413
|
+
child.on("exit", (code, signal) => {
|
|
414
|
+
const error = timedOut ? `Timed out after ${Math.round(opts.timeoutMs / 1e3)} s; the process group was killed` : signal ? `Killed by ${signal}` : code !== 0 ? `Exited with code ${code}` : null;
|
|
415
|
+
finish({
|
|
416
|
+
ok: code === 0 && !signal,
|
|
417
|
+
exitCode: code,
|
|
418
|
+
signal: signal ?? null,
|
|
419
|
+
error
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
//#endregion
|
|
425
|
+
//#region server/listen/logfile.ts
|
|
426
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
427
|
+
/**
|
|
428
|
+
* Structured JSONL log in the state dir (rotated by size, 0600) plus friendly console lines.
|
|
429
|
+
* Meta values go to the file only; the console shows the message.
|
|
430
|
+
*/
|
|
431
|
+
function createListenLogger(opts) {
|
|
432
|
+
const maxBytes = opts.maxBytes ?? 5242880;
|
|
433
|
+
const keep = opts.keep ?? 3;
|
|
434
|
+
let size = -1;
|
|
435
|
+
if (opts.path) {
|
|
436
|
+
ensurePrivateDir(dirname(opts.path));
|
|
437
|
+
tighten(opts.path);
|
|
438
|
+
}
|
|
439
|
+
const rotate = (path) => {
|
|
440
|
+
rmSync(`${path}.${keep}`, { force: true });
|
|
441
|
+
for (let i = keep - 1; i >= 1; i--) if (existsSync(`${path}.${i}`)) renameSync(`${path}.${i}`, `${path}.${i + 1}`);
|
|
442
|
+
renameSync(path, `${path}.1`);
|
|
443
|
+
size = 0;
|
|
444
|
+
};
|
|
445
|
+
const toFile = (level, msg, meta) => {
|
|
446
|
+
if (!opts.path) return;
|
|
447
|
+
const line = JSON.stringify({
|
|
448
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
449
|
+
level,
|
|
450
|
+
msg: msg.replace(ANSI, ""),
|
|
451
|
+
...meta
|
|
452
|
+
}) + "\n";
|
|
453
|
+
try {
|
|
454
|
+
if (size < 0) size = existsSync(opts.path) ? statSync(opts.path).size : 0;
|
|
455
|
+
if (size + line.length > maxBytes && size > 0) rotate(opts.path);
|
|
456
|
+
appendFileSync(opts.path, line, { mode: 384 });
|
|
457
|
+
size += Buffer.byteLength(line);
|
|
458
|
+
} catch {}
|
|
459
|
+
};
|
|
460
|
+
const colors = {
|
|
461
|
+
debug: pc.gray,
|
|
462
|
+
info: (s) => s,
|
|
463
|
+
warn: pc.yellow,
|
|
464
|
+
error: pc.red
|
|
465
|
+
};
|
|
466
|
+
const toConsole = (level, msg) => {
|
|
467
|
+
if (opts.console === false || level === "debug" && !opts.debug) return;
|
|
468
|
+
const ts = pc.gray((/* @__PURE__ */ new Date()).toTimeString().slice(0, 8));
|
|
469
|
+
const tag = level === "warn" ? pc.yellow("warn ") : level === "error" ? pc.red("error ") : "";
|
|
470
|
+
const text = msg.split("\n").join("\n ");
|
|
471
|
+
(level === "error" || level === "warn" ? process.stderr : process.stdout).write(`${ts} ${tag}${colors[level](text)}\n`);
|
|
472
|
+
};
|
|
473
|
+
const emit = (level) => (msg, meta) => {
|
|
474
|
+
toFile(level, msg, meta);
|
|
475
|
+
toConsole(level, msg);
|
|
476
|
+
};
|
|
477
|
+
return {
|
|
478
|
+
debug: emit("debug"),
|
|
479
|
+
info: emit("info"),
|
|
480
|
+
warn: emit("warn"),
|
|
481
|
+
error: emit("error")
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
//#endregion
|
|
485
|
+
//#region server/listen/state.ts
|
|
486
|
+
function appendDead(profile, entry) {
|
|
487
|
+
const path = deadPath(profile);
|
|
488
|
+
ensurePrivateDir(dirname(path));
|
|
489
|
+
appendFileSync(path, JSON.stringify(entry) + "\n", { mode: 384 });
|
|
490
|
+
tighten(path);
|
|
491
|
+
}
|
|
492
|
+
function readDead(profile) {
|
|
493
|
+
const path = deadPath(profile);
|
|
494
|
+
if (!existsSync(path)) return [];
|
|
495
|
+
const out = [];
|
|
496
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
497
|
+
if (!line.trim()) continue;
|
|
498
|
+
try {
|
|
499
|
+
out.push(JSON.parse(line));
|
|
500
|
+
} catch {}
|
|
501
|
+
}
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
function writeDead(profile, entries) {
|
|
505
|
+
writePrivateFile(deadPath(profile), entries.map((e) => JSON.stringify(e) + "\n").join(""));
|
|
506
|
+
}
|
|
507
|
+
const consumersPath = (profile) => join(profileStateDir(profile), "consumers.json");
|
|
508
|
+
function knownConsumers(profile) {
|
|
509
|
+
const list = [];
|
|
510
|
+
try {
|
|
511
|
+
const parsed = JSON.parse(readFileSync(consumersPath(profile), "utf8"));
|
|
512
|
+
if (Array.isArray(parsed)) list.push(...parsed.filter((c) => typeof c === "string"));
|
|
513
|
+
} catch {}
|
|
514
|
+
const last = readStatus(profile)?.consumer;
|
|
515
|
+
if (last && !list.includes(last)) list.push(last);
|
|
516
|
+
return list;
|
|
517
|
+
}
|
|
518
|
+
function rememberConsumer(profile, consumer) {
|
|
519
|
+
const list = knownConsumers(profile);
|
|
520
|
+
if (list.includes(consumer)) return;
|
|
521
|
+
try {
|
|
522
|
+
writePrivateFile(consumersPath(profile), JSON.stringify([...list, consumer]) + "\n");
|
|
523
|
+
} catch {}
|
|
524
|
+
}
|
|
525
|
+
function readStatus(profile) {
|
|
526
|
+
try {
|
|
527
|
+
return JSON.parse(readFileSync(statusPath(profile), "utf8"));
|
|
528
|
+
} catch {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
/** Writes status.json at most once a second (and immediately on `flush`). */
|
|
533
|
+
var StatusWriter = class {
|
|
534
|
+
profile;
|
|
535
|
+
status;
|
|
536
|
+
persist;
|
|
537
|
+
timer = null;
|
|
538
|
+
constructor(profile, status, persist = true) {
|
|
539
|
+
this.profile = profile;
|
|
540
|
+
this.status = status;
|
|
541
|
+
this.persist = persist;
|
|
542
|
+
}
|
|
543
|
+
update(patch) {
|
|
544
|
+
Object.assign(this.status, patch);
|
|
545
|
+
if (this.timer) return;
|
|
546
|
+
this.timer = setTimeout(() => this.flush(), 1e3);
|
|
547
|
+
this.timer.unref();
|
|
548
|
+
}
|
|
549
|
+
addRecent(e) {
|
|
550
|
+
this.status.recent = [e, ...this.status.recent.filter((r) => r.seq !== e.seq)].slice(0, 20);
|
|
551
|
+
this.update({});
|
|
552
|
+
}
|
|
553
|
+
flush() {
|
|
554
|
+
if (this.timer) clearTimeout(this.timer);
|
|
555
|
+
this.timer = null;
|
|
556
|
+
if (!this.persist) return;
|
|
557
|
+
try {
|
|
558
|
+
writePrivateFile(statusPath(this.profile), JSON.stringify(this.status, null, 2) + "\n");
|
|
559
|
+
} catch {}
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
/** Whether a process with this pid is alive (status.json may be left over from a crash). */
|
|
563
|
+
function pidAlive(pid) {
|
|
564
|
+
try {
|
|
565
|
+
process.kill(pid, 0);
|
|
566
|
+
return true;
|
|
567
|
+
} catch (err) {
|
|
568
|
+
return err.code === "EPERM";
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
//#endregion
|
|
572
|
+
//#region server/listen/lock.ts
|
|
573
|
+
/**
|
|
574
|
+
* One listener per profile: two would share a consumer cursor and run every event twice. The lock is
|
|
575
|
+
* a file with the owner's pid and start time, created exclusively; a lock whose process is gone is
|
|
576
|
+
* stale and taken over.
|
|
577
|
+
*/
|
|
578
|
+
const lockPath = (profile) => join(profileStateDir(profile), "listen.lock");
|
|
579
|
+
var ProfileLockedError = class extends Error {
|
|
580
|
+
holder;
|
|
581
|
+
constructor(holder, profile) {
|
|
582
|
+
super(`Another listener is already running profile "${profile}" (pid ${holder.pid}, since ${holder.startedAt}).\n If it's the background service, check it with \`${NPX} listen service status${profile === "default" ? "" : ` --profile ${profile}`}\`; stop it before running one in the foreground.`);
|
|
583
|
+
this.holder = holder;
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
function readLock(path) {
|
|
587
|
+
try {
|
|
588
|
+
const info = JSON.parse(readFileSync(path, "utf8"));
|
|
589
|
+
return typeof info.pid === "number" ? info : null;
|
|
590
|
+
} catch {
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
/** Lock files held by this process (a second in-process holder has the same pid). */
|
|
595
|
+
const held = /* @__PURE__ */ new Set();
|
|
596
|
+
/**
|
|
597
|
+
* Takes an exclusive lock file (pid + start time, created with O_EXCL) or throws `busy(holder)`. A lock
|
|
598
|
+
* whose process is gone is stale and taken over. Returns a release function (also run on process exit).
|
|
599
|
+
*/
|
|
600
|
+
function acquireFileLock(path, busy) {
|
|
601
|
+
ensurePrivateDir(dirname(path));
|
|
602
|
+
const mine = {
|
|
603
|
+
pid: process.pid,
|
|
604
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
605
|
+
};
|
|
606
|
+
if (held.has(path)) throw busy(readLock(path) ?? mine);
|
|
607
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
608
|
+
try {
|
|
609
|
+
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 384);
|
|
610
|
+
writeSync(fd, JSON.stringify(mine));
|
|
611
|
+
closeSync(fd);
|
|
612
|
+
held.add(path);
|
|
613
|
+
let released = false;
|
|
614
|
+
const release = () => {
|
|
615
|
+
if (released) return;
|
|
616
|
+
released = true;
|
|
617
|
+
held.delete(path);
|
|
618
|
+
process.off("exit", release);
|
|
619
|
+
if (readLock(path)?.pid === process.pid) rmSync(path, { force: true });
|
|
620
|
+
};
|
|
621
|
+
process.on("exit", release);
|
|
622
|
+
return release;
|
|
623
|
+
} catch (err) {
|
|
624
|
+
if (err.code !== "EEXIST") throw err;
|
|
625
|
+
}
|
|
626
|
+
const holder = readLock(path);
|
|
627
|
+
if (holder && holder.pid !== process.pid && pidAlive(holder.pid)) throw busy(holder);
|
|
628
|
+
rmSync(path, { force: true });
|
|
629
|
+
}
|
|
630
|
+
throw new Error(`Could not take the lock ${path}.`);
|
|
631
|
+
}
|
|
632
|
+
/** Takes the profile's listener lock or throws ProfileLockedError. */
|
|
633
|
+
function acquireProfileLock(profile) {
|
|
634
|
+
return acquireFileLock(lockPath(profile), (holder) => new ProfileLockedError(holder, profile));
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* `listen --backfill` has its own lock: it uses its own cursor, so it runs fine next to the profile's
|
|
638
|
+
* listener (or its service), but two backfills at once would run every stored event twice.
|
|
639
|
+
*/
|
|
640
|
+
const backfillLockPath = (profile) => join(profileStateDir(profile), "backfill.lock");
|
|
641
|
+
function acquireBackfillLock(profile) {
|
|
642
|
+
return acquireFileLock(backfillLockPath(profile), (holder) => /* @__PURE__ */ new Error(`A backfill is already running for profile "${profile}" (pid ${holder.pid}, since ${holder.startedAt}). Let it finish, or stop it first.`));
|
|
643
|
+
}
|
|
644
|
+
//#endregion
|
|
645
|
+
//#region server/listen/sse.ts
|
|
646
|
+
var SseParser = class {
|
|
647
|
+
onMessage;
|
|
648
|
+
onComment;
|
|
649
|
+
buf = "";
|
|
650
|
+
data = [];
|
|
651
|
+
event = "";
|
|
652
|
+
id = null;
|
|
653
|
+
constructor(onMessage, onComment = () => {}) {
|
|
654
|
+
this.onMessage = onMessage;
|
|
655
|
+
this.onComment = onComment;
|
|
656
|
+
}
|
|
657
|
+
push(chunk) {
|
|
658
|
+
this.buf += chunk;
|
|
659
|
+
for (;;) {
|
|
660
|
+
const m = /\r\n|\r|\n/.exec(this.buf);
|
|
661
|
+
if (!m) break;
|
|
662
|
+
if (m[0] === "\r" && m.index === this.buf.length - 1) break;
|
|
663
|
+
const line = this.buf.slice(0, m.index);
|
|
664
|
+
this.buf = this.buf.slice(m.index + m[0].length);
|
|
665
|
+
this.line(line);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
line(line) {
|
|
669
|
+
if (line === "") {
|
|
670
|
+
if (this.data.length) this.onMessage({
|
|
671
|
+
id: this.id,
|
|
672
|
+
event: this.event || "message",
|
|
673
|
+
data: this.data.join("\n")
|
|
674
|
+
});
|
|
675
|
+
this.data = [];
|
|
676
|
+
this.event = "";
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (line.startsWith(":")) {
|
|
680
|
+
this.onComment(line.slice(1).trim());
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const colon = line.indexOf(":");
|
|
684
|
+
const field = colon < 0 ? line : line.slice(0, colon);
|
|
685
|
+
let value = colon < 0 ? "" : line.slice(colon + 1);
|
|
686
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
687
|
+
if (field === "data") this.data.push(value);
|
|
688
|
+
else if (field === "event") this.event = value;
|
|
689
|
+
else if (field === "id" && !value.includes("\0")) this.id = value;
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
//#endregion
|
|
693
|
+
//#region server/listen/stream.ts
|
|
694
|
+
/**
|
|
695
|
+
* Why the daemon refused or ended the stream. Permanent ones end the listener (exit 78); reversible
|
|
696
|
+
* ones (an admin disabled the agent, or took away its last read grant) keep it waiting.
|
|
697
|
+
*/
|
|
698
|
+
const REFUSALS = {
|
|
699
|
+
agent_deleted: {
|
|
700
|
+
permanent: true,
|
|
701
|
+
text: "This agent was deleted in the xaq console."
|
|
702
|
+
},
|
|
703
|
+
key_rotated: {
|
|
704
|
+
permanent: true,
|
|
705
|
+
text: "This agent's API key was replaced (rotated) in the console."
|
|
706
|
+
},
|
|
707
|
+
unauthorized: {
|
|
708
|
+
permanent: true,
|
|
709
|
+
text: "The daemon doesn't recognize this API key (it was rotated, or the agent was deleted)."
|
|
710
|
+
},
|
|
711
|
+
agent_disabled: {
|
|
712
|
+
permanent: false,
|
|
713
|
+
text: "An admin disabled this agent. Waiting; it resumes on its own when the agent is re-enabled."
|
|
714
|
+
},
|
|
715
|
+
no_read_grant: {
|
|
716
|
+
permanent: false,
|
|
717
|
+
text: "This agent has no read access to any mailbox or calendar right now. Waiting; it resumes when an admin grants read or calendar.read."
|
|
718
|
+
},
|
|
719
|
+
forbidden: {
|
|
720
|
+
permanent: false,
|
|
721
|
+
text: "The daemon refused access for now. Waiting and retrying."
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
/** Classifies an HTTP refusal or a `revoked` reason code. 401 is permanent; 403 is reversible unless its code says otherwise. */
|
|
725
|
+
function classifyRefusal(code, status) {
|
|
726
|
+
const known = code && REFUSALS[code] ? code : status === 401 ? "unauthorized" : status === 403 ? "forbidden" : code ? null : "unauthorized";
|
|
727
|
+
if (known) return {
|
|
728
|
+
code: known,
|
|
729
|
+
...REFUSALS[known]
|
|
730
|
+
};
|
|
731
|
+
return {
|
|
732
|
+
code,
|
|
733
|
+
permanent: true,
|
|
734
|
+
text: `Access revoked (${code}).`
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
const BLOCKED_RETRY_MS = 6e4;
|
|
738
|
+
var Stalled = class extends Error {};
|
|
739
|
+
var Paused = class extends Error {};
|
|
740
|
+
var Reconnect = class extends Error {};
|
|
741
|
+
const sleepJump = 2e4;
|
|
742
|
+
/**
|
|
743
|
+
* Holds the outbound event stream open forever: reconnects with jittered exponential backoff,
|
|
744
|
+
* resumes with Last-Event-ID (the server also resumes from the consumer's acked cursor), treats
|
|
745
|
+
* ~45 s of silence as a dead connection, and reconnects at once after the machine wakes from sleep.
|
|
746
|
+
* Only permanent revocation (401, or `event: revoked` for a deleted agent or replaced key) and
|
|
747
|
+
* unrecoverable request errors end it; a disabled agent or a missing read grant just waits.
|
|
748
|
+
*/
|
|
749
|
+
var EventStream = class {
|
|
750
|
+
o;
|
|
751
|
+
stopped = false;
|
|
752
|
+
current = null;
|
|
753
|
+
wake = null;
|
|
754
|
+
failures = 0;
|
|
755
|
+
constructor(o) {
|
|
756
|
+
this.o = o;
|
|
757
|
+
}
|
|
758
|
+
stop() {
|
|
759
|
+
this.stopped = true;
|
|
760
|
+
this.current?.abort();
|
|
761
|
+
this.wake?.();
|
|
762
|
+
}
|
|
763
|
+
/** Forces a reconnect now (network change, wake from sleep). */
|
|
764
|
+
reconnect() {
|
|
765
|
+
this.failures = 0;
|
|
766
|
+
this.current?.abort(new Reconnect("reconnect requested"));
|
|
767
|
+
this.wake?.();
|
|
768
|
+
}
|
|
769
|
+
sleep(ms) {
|
|
770
|
+
if (this.stopped) return Promise.resolve();
|
|
771
|
+
return new Promise((resolve) => {
|
|
772
|
+
const t = setTimeout(done, ms);
|
|
773
|
+
function done() {
|
|
774
|
+
clearTimeout(t);
|
|
775
|
+
resolve();
|
|
776
|
+
}
|
|
777
|
+
this.wake = done;
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
backoff() {
|
|
781
|
+
const min = this.o.minBackoffMs ?? 1e3;
|
|
782
|
+
const max = this.o.maxBackoffMs ?? 6e4;
|
|
783
|
+
const cap = Math.min(max, min * 2 ** Math.min(this.failures, 16));
|
|
784
|
+
return Math.round(min + Math.random() * (cap - min));
|
|
785
|
+
}
|
|
786
|
+
streamUrl() {
|
|
787
|
+
const u = new URL(`${this.o.url}/agent/v1/events/stream`);
|
|
788
|
+
u.searchParams.set("consumer", this.o.consumer);
|
|
789
|
+
if (this.o.types?.length) u.searchParams.set("types", this.o.types.join(","));
|
|
790
|
+
if (this.o.includeBody) u.searchParams.set("include_body", "1");
|
|
791
|
+
if (this.o.fromStart) u.searchParams.set("start", "beginning");
|
|
792
|
+
if (this.o.after) u.searchParams.set("after", String(this.o.after));
|
|
793
|
+
return u.toString();
|
|
794
|
+
}
|
|
795
|
+
async run() {
|
|
796
|
+
let lastTick = Date.now();
|
|
797
|
+
const sleepWatch = setInterval(() => {
|
|
798
|
+
const now = Date.now();
|
|
799
|
+
if (now - lastTick > sleepJump) this.reconnect();
|
|
800
|
+
lastTick = now;
|
|
801
|
+
}, 5e3);
|
|
802
|
+
sleepWatch.unref();
|
|
803
|
+
try {
|
|
804
|
+
while (!this.stopped) {
|
|
805
|
+
while (this.o.shouldPause?.() && !this.stopped) await this.sleep(250);
|
|
806
|
+
if (this.stopped) break;
|
|
807
|
+
const result = await this.connectOnce();
|
|
808
|
+
if (result) return result;
|
|
809
|
+
}
|
|
810
|
+
return { kind: "stopped" };
|
|
811
|
+
} finally {
|
|
812
|
+
clearInterval(sleepWatch);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
/** One connection. Returns a terminal result, or null to reconnect (after waiting, unless paused). */
|
|
816
|
+
async connectOnce() {
|
|
817
|
+
const ac = new AbortController();
|
|
818
|
+
this.current = ac;
|
|
819
|
+
const stallMs = this.o.stallMs ?? 45e3;
|
|
820
|
+
let stallTimer;
|
|
821
|
+
const armStall = () => {
|
|
822
|
+
clearTimeout(stallTimer);
|
|
823
|
+
stallTimer = setTimeout(() => ac.abort(new Stalled(`no data or ping for ${Math.round(stallMs / 1e3)} s`)), stallMs);
|
|
824
|
+
};
|
|
825
|
+
const opened = Date.now();
|
|
826
|
+
let delivered = false;
|
|
827
|
+
let revoked = null;
|
|
828
|
+
try {
|
|
829
|
+
const refused = await this.o.beforeConnect?.();
|
|
830
|
+
if (refused) {
|
|
831
|
+
this.failures++;
|
|
832
|
+
const wait = Math.max(this.backoff(), 3e4);
|
|
833
|
+
this.disconnected(refused, wait, true);
|
|
834
|
+
await this.sleep(wait);
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
const headers = {
|
|
838
|
+
accept: "text/event-stream",
|
|
839
|
+
authorization: `Bearer ${this.o.apiKey}`,
|
|
840
|
+
"cache-control": "no-cache",
|
|
841
|
+
"user-agent": userAgent(),
|
|
842
|
+
"x-agentinbox-client": clientHeader()
|
|
843
|
+
};
|
|
844
|
+
const last = this.o.lastEventId();
|
|
845
|
+
if (last > 0) headers["last-event-id"] = String(last);
|
|
846
|
+
armStall();
|
|
847
|
+
const pending = (this.o.fetch ?? fetch)(this.streamUrl(), {
|
|
848
|
+
headers,
|
|
849
|
+
signal: ac.signal,
|
|
850
|
+
redirect: "error"
|
|
851
|
+
});
|
|
852
|
+
pending.then((r) => ac.signal.aborted ? r.body?.cancel().catch(() => {}) : void 0).catch(() => {});
|
|
853
|
+
const res = await abortable(pending, ac.signal);
|
|
854
|
+
if (!res.ok) {
|
|
855
|
+
const err = await abortable(apiError(res, "Opening the event stream"), ac.signal);
|
|
856
|
+
if (res.status === 401 || res.status === 403) {
|
|
857
|
+
const r = classifyRefusal(err.code, res.status);
|
|
858
|
+
if (r.permanent) return {
|
|
859
|
+
kind: "revoked",
|
|
860
|
+
code: r.code,
|
|
861
|
+
reason: r.text
|
|
862
|
+
};
|
|
863
|
+
clearTimeout(stallTimer);
|
|
864
|
+
this.o.onBlocked?.(r.code, r.text);
|
|
865
|
+
await this.sleep(this.o.blockedRetryMs ?? BLOCKED_RETRY_MS);
|
|
866
|
+
return null;
|
|
867
|
+
}
|
|
868
|
+
if (res.status === 400 || res.status === 422) return {
|
|
869
|
+
kind: "fatal",
|
|
870
|
+
reason: err.message
|
|
871
|
+
};
|
|
872
|
+
clearTimeout(stallTimer);
|
|
873
|
+
const longWait = res.status === 404 ? 3e5 : null;
|
|
874
|
+
if (res.status === 404) {
|
|
875
|
+
this.disconnected("This xaq server has no /agent/v1 event stream (upgrade the daemon). Retrying in 5 minutes", longWait, true);
|
|
876
|
+
await this.sleep(longWait);
|
|
877
|
+
} else {
|
|
878
|
+
this.failures++;
|
|
879
|
+
const wait = err.retryAfter ? err.retryAfter * 1e3 : this.backoff();
|
|
880
|
+
this.disconnected(err.message, wait, res.status >= 500 || res.status === 429 ? false : true);
|
|
881
|
+
await this.sleep(wait);
|
|
882
|
+
}
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
if (!(res.headers.get("content-type") ?? "").includes("text/event-stream") || !res.body) throw new Error(`expected text/event-stream, got ${res.headers.get("content-type") ?? "no content type"}`);
|
|
886
|
+
this.o.onOpen();
|
|
887
|
+
armStall();
|
|
888
|
+
const parser = new SseParser((m) => {
|
|
889
|
+
if (m.event === "revoked") {
|
|
890
|
+
let reason = "unauthorized";
|
|
891
|
+
try {
|
|
892
|
+
const r = JSON.parse(m.data).reason;
|
|
893
|
+
if (typeof r === "string" && r) reason = safeText(r, 60);
|
|
894
|
+
} catch {}
|
|
895
|
+
revoked = reason;
|
|
896
|
+
ac.abort();
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
delivered = true;
|
|
900
|
+
this.o.onMessage(m);
|
|
901
|
+
if (this.o.shouldPause?.()) ac.abort(new Paused("backlog full"));
|
|
902
|
+
}, () => {});
|
|
903
|
+
const decoder = new TextDecoder();
|
|
904
|
+
const reader = res.body.getReader();
|
|
905
|
+
const cancel = () => void reader.cancel().catch(() => {});
|
|
906
|
+
ac.signal.addEventListener("abort", cancel, { once: true });
|
|
907
|
+
try {
|
|
908
|
+
for (;;) {
|
|
909
|
+
const { done, value } = await abortable(reader.read(), ac.signal);
|
|
910
|
+
if (done) break;
|
|
911
|
+
armStall();
|
|
912
|
+
parser.push(decoder.decode(value, { stream: true }));
|
|
913
|
+
if (revoked !== null) break;
|
|
914
|
+
}
|
|
915
|
+
} finally {
|
|
916
|
+
ac.signal.removeEventListener("abort", cancel);
|
|
917
|
+
cancel();
|
|
918
|
+
}
|
|
919
|
+
if (revoked !== null) return this.afterRevoked(revoked);
|
|
920
|
+
throw new Error("the server closed the stream");
|
|
921
|
+
} catch (err) {
|
|
922
|
+
if (revoked !== null) return this.afterRevoked(revoked);
|
|
923
|
+
if (this.stopped) return { kind: "stopped" };
|
|
924
|
+
const reason = ac.signal.aborted && ac.signal.reason instanceof Error ? ac.signal.reason : err;
|
|
925
|
+
if (reason instanceof Paused) return null;
|
|
926
|
+
if (delivered || Date.now() - opened > 6e4) this.failures = 0;
|
|
927
|
+
if (reason instanceof Reconnect) {
|
|
928
|
+
this.disconnected("reconnecting (network change or wake from sleep)", 0, false);
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
this.failures++;
|
|
932
|
+
const wait = this.backoff();
|
|
933
|
+
const text = reason instanceof Stalled ? `stream stalled (${reason.message})` : describeFetchError(reason);
|
|
934
|
+
this.disconnected(text, wait, this.failures > 3);
|
|
935
|
+
await this.sleep(wait);
|
|
936
|
+
return null;
|
|
937
|
+
} finally {
|
|
938
|
+
clearTimeout(stallTimer);
|
|
939
|
+
ac.abort();
|
|
940
|
+
this.current = null;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
/** `event: revoked`: permanent reasons end the listener; reversible ones wait and retry. */
|
|
944
|
+
async afterRevoked(code) {
|
|
945
|
+
const r = classifyRefusal(code);
|
|
946
|
+
if (r.permanent) return {
|
|
947
|
+
kind: "revoked",
|
|
948
|
+
code: r.code,
|
|
949
|
+
reason: r.text
|
|
950
|
+
};
|
|
951
|
+
this.o.onBlocked?.(r.code, r.text);
|
|
952
|
+
await this.sleep(this.o.blockedRetryMs ?? BLOCKED_RETRY_MS);
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
disconnected(reason, wait, serious) {
|
|
956
|
+
if (!this.stopped) this.o.onDisconnect(reason, wait, { serious });
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
//#endregion
|
|
960
|
+
//#region server/listen/listen.ts
|
|
961
|
+
/** Exit codes. 78 (EX_CONFIG) means "don't restart me": the service units are told not to. */
|
|
962
|
+
const EXIT = {
|
|
963
|
+
ok: 0,
|
|
964
|
+
error: 1,
|
|
965
|
+
revoked: 78
|
|
966
|
+
};
|
|
967
|
+
/** Acknowledges the finished prefix, coalescing and retrying in the background. */
|
|
968
|
+
var Acker = class {
|
|
969
|
+
post;
|
|
970
|
+
log;
|
|
971
|
+
wanted = 0;
|
|
972
|
+
acked = 0;
|
|
973
|
+
busy = null;
|
|
974
|
+
retry = null;
|
|
975
|
+
constructor(post, log) {
|
|
976
|
+
this.post = post;
|
|
977
|
+
this.log = log;
|
|
978
|
+
}
|
|
979
|
+
request(seq) {
|
|
980
|
+
this.wanted = Math.max(this.wanted, seq);
|
|
981
|
+
this.kick();
|
|
982
|
+
}
|
|
983
|
+
kick() {
|
|
984
|
+
if (this.busy || this.retry || this.wanted <= this.acked) return;
|
|
985
|
+
const seq = this.wanted;
|
|
986
|
+
this.busy = this.post(seq).then((acked) => {
|
|
987
|
+
this.acked = Math.max(this.acked, acked, seq);
|
|
988
|
+
}).catch((err) => {
|
|
989
|
+
this.log.debug(`ack failed, retrying: ${err instanceof Error ? err.message : String(err)}`, { seq });
|
|
990
|
+
this.retry = setTimeout(() => {
|
|
991
|
+
this.retry = null;
|
|
992
|
+
this.kick();
|
|
993
|
+
}, 5e3);
|
|
994
|
+
this.retry.unref();
|
|
995
|
+
}).finally(() => {
|
|
996
|
+
this.busy = null;
|
|
997
|
+
this.kick();
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
/** Best-effort final ack on shutdown. */
|
|
1001
|
+
async flush(timeoutMs = 3e3) {
|
|
1002
|
+
if (this.retry) clearTimeout(this.retry);
|
|
1003
|
+
this.retry = null;
|
|
1004
|
+
const deadline = Date.now() + timeoutMs;
|
|
1005
|
+
while (Date.now() < deadline) if (this.busy) await Promise.race([this.busy, new Promise((r) => setTimeout(r, 100))]);
|
|
1006
|
+
else if (this.wanted > this.acked) {
|
|
1007
|
+
this.kick();
|
|
1008
|
+
if (this.retry) break;
|
|
1009
|
+
} else return;
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
const describeEvent = (p) => {
|
|
1013
|
+
const m = p.data.message;
|
|
1014
|
+
const e = p.data.calendarEvent;
|
|
1015
|
+
const sender = m?.from?.email ?? (e && !e.organizerSelf ? e.organizer?.email : void 0);
|
|
1016
|
+
const who = sender ? ` from ${safeText(sender, 80)}` : "";
|
|
1017
|
+
const title = m?.subject ?? e?.summary;
|
|
1018
|
+
const subject = title ? ` "${safeText(title, 70)}"` : "";
|
|
1019
|
+
return `#${p.data.seq} ${p.type}${who}${subject}${e ? ` (${safeText(e.start, 30)})` : ""}`;
|
|
1020
|
+
};
|
|
1021
|
+
/** A finished job in the log: the event, or "batch of N (#a–#b)". */
|
|
1022
|
+
const describeJob = (job) => job.payloads.length === 1 ? describeEvent(job.payload) : `batch of ${job.payloads.length} events (#${job.seq}–#${job.lastSeq})`;
|
|
1023
|
+
/** The last few non-empty lines of a command's output, for the log (sanitized: it may echo email content). */
|
|
1024
|
+
const lastLines = (t, n = 3) => t.trim().split("\n").filter((l) => l.trim()).slice(-n).map((l) => ` │ ${safeText(l, 300)}`).join("\n");
|
|
1025
|
+
/** The agent's whole reply was the triage prompt's "nothing to do" (anything more is shown). */
|
|
1026
|
+
const isSilent = (stdout) => stdout.trim() === SILENT_MARKER;
|
|
1027
|
+
async function runListener(opts) {
|
|
1028
|
+
const profileName = validProfileName(opts.flags.profile ?? "default");
|
|
1029
|
+
let profile = loadProfile(profileName);
|
|
1030
|
+
const { config, next, changed } = resolveListen(profileName, profile, opts.flags);
|
|
1031
|
+
if (changed) {
|
|
1032
|
+
saveProfile(profileName, next);
|
|
1033
|
+
profile = next;
|
|
1034
|
+
}
|
|
1035
|
+
const quietConsole = underService() && process.platform === "darwin";
|
|
1036
|
+
const backfill = !!opts.flags.backfill;
|
|
1037
|
+
const oneOff = backfill || !!opts.flags.dryRun;
|
|
1038
|
+
const log = opts.log ?? createListenLogger({
|
|
1039
|
+
path: oneOff ? oneOffLogPath(profileName) : logPath(profileName),
|
|
1040
|
+
debug: !!envVar("DEBUG"),
|
|
1041
|
+
console: !quietConsole
|
|
1042
|
+
});
|
|
1043
|
+
if (opts.flags.save) log.info(`Saved these settings to profile "${profileName}".`);
|
|
1044
|
+
const verdict = await checkTransport(profile.url, { insecure: config.insecure });
|
|
1045
|
+
if (verdict.warning) {
|
|
1046
|
+
process.stderr.write(`${insecureBanner(verdict.warning)}\n`);
|
|
1047
|
+
log.warn(verdict.warning);
|
|
1048
|
+
}
|
|
1049
|
+
const targets = [];
|
|
1050
|
+
if (config.exec) targets.push("exec");
|
|
1051
|
+
if (config.forward) targets.push("forward");
|
|
1052
|
+
const dryRun = !!opts.flags.dryRun;
|
|
1053
|
+
if (!targets.length && !dryRun) throw new Error(`Nothing to run for events. Pass --exec "<command>" and/or --forward http://localhost:<port>/<path> (add --save to keep it in profile "${profileName}"),\n or --dry-run to just print matching events. Recipes: ${NPX} listen --help`);
|
|
1054
|
+
if (config.exec && process.platform === "win32" && !dryRun) throw new Error("--exec needs a POSIX shell (/bin/sh) and isn't supported on Windows. Use --forward to a local HTTP handler instead.");
|
|
1055
|
+
const releaseLock = dryRun ? () => {} : backfill ? acquireBackfillLock(profileName) : acquireProfileLock(profileName);
|
|
1056
|
+
try {
|
|
1057
|
+
const fromStart = !!opts.flags.fromStart || backfill;
|
|
1058
|
+
const known = knownConsumers(profileName).includes(config.consumer);
|
|
1059
|
+
const streamConsumer = backfill ? throwawayConsumer(config.consumer, THROWAWAY_SUFFIXES.backfill) : dryRun ? throwawayConsumer(config.consumer, THROWAWAY_SUFFIXES.dryRun) : config.consumer;
|
|
1060
|
+
let streamAfter = 0;
|
|
1061
|
+
if (dryRun && !fromStart) streamAfter = await getSelf(profile.url, profile.apiKey, 8e3).then((s) => s.headSeq ?? 0, () => 0);
|
|
1062
|
+
const backfillHead = backfill ? await backfillEnd(profileName, profile, config.consumer, streamConsumer, opts.flags.until, log) : 0;
|
|
1063
|
+
const backfillHint = backfillCommand(profileName, profile, config);
|
|
1064
|
+
if (config.cwdIsDefault) ensurePrivateDir(config.cwd);
|
|
1065
|
+
if (!existsSync(claudeMcpConfigPath(profileName))) writeClaudeMcpConfig(profileName, `${profile.url}/mcp`);
|
|
1066
|
+
if (!quietConsole) printBanner(log, profileName, profile, config, dryRun, backfill);
|
|
1067
|
+
const notes = typesNotes(config);
|
|
1068
|
+
for (const m of notes.info) log.info(m);
|
|
1069
|
+
for (const m of notes.warn) log.warn(m);
|
|
1070
|
+
const categories = categoryNote(profileName, profile, opts.flags, config);
|
|
1071
|
+
if (categories) log.info(categories);
|
|
1072
|
+
const change = hermesChangeNote(opts.flags.recipe, config.exec);
|
|
1073
|
+
if (change) log.info(change);
|
|
1074
|
+
if (config.forward && isHermesRoute(config.forward.url) && !dryRun) {
|
|
1075
|
+
gatewayWarning(config.forward.url).then((w) => w && log.warn(w));
|
|
1076
|
+
const route = routeWarning(profileName, config.forward.url);
|
|
1077
|
+
if (route) log[route.level](route.message);
|
|
1078
|
+
}
|
|
1079
|
+
if (backfill && backfillHead === 0) {
|
|
1080
|
+
log.info(`Nothing to backfill: the listener (consumer "${config.consumer}") started with the oldest stored mail, so it handles all of it.`);
|
|
1081
|
+
return EXIT.ok;
|
|
1082
|
+
}
|
|
1083
|
+
if (backfill) log.info(`Backfill: handling the mail from before the listener started (events up to #${backfillHead}), oldest first, then exiting${dryRun ? " (dry run: nothing executed)" : ""}. Everything newer is the listener's.`);
|
|
1084
|
+
else if (dryRun) log.info(fromStart ? "Dry run from the oldest stored event." : "Dry run: showing new events from now on.");
|
|
1085
|
+
else if (fromStart && known) log.warn(`This listener (consumer "${config.consumer}") already has a position on the daemon, so --from-start does nothing.\n--from-start only applies to a new consumer. To run the mail from before this listener started through it once (fine while it runs): ${backfillHint}`);
|
|
1086
|
+
else if (fromStart) log.info("Processing every stored event this agent can see, from the oldest (--from-start).");
|
|
1087
|
+
else if (!known) log.info(`Listening for new events from now on. To also run the mail from before this listener started, once (in another terminal, while this keeps running): ${backfillHint}`);
|
|
1088
|
+
const status = new StatusWriter(profileName, {
|
|
1089
|
+
pid: process.pid,
|
|
1090
|
+
profile: profileName,
|
|
1091
|
+
url: profile.url,
|
|
1092
|
+
consumer: streamConsumer,
|
|
1093
|
+
version,
|
|
1094
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1095
|
+
connected: false,
|
|
1096
|
+
connectedSince: null,
|
|
1097
|
+
lastDisconnect: null,
|
|
1098
|
+
lastEventAt: null,
|
|
1099
|
+
receivedSeq: 0,
|
|
1100
|
+
ackedSeq: 0,
|
|
1101
|
+
running: 0,
|
|
1102
|
+
recent: [],
|
|
1103
|
+
stoppedAt: null,
|
|
1104
|
+
exitReason: null,
|
|
1105
|
+
exec: config.exec,
|
|
1106
|
+
forward: config.forward?.url ?? null,
|
|
1107
|
+
filters: describeFilter(config),
|
|
1108
|
+
batchSeconds: config.batchSeconds,
|
|
1109
|
+
skipped: 0
|
|
1110
|
+
}, !oneOff);
|
|
1111
|
+
status.flush();
|
|
1112
|
+
const transportOk = () => checkTransport(profile.url, { insecure: config.insecure }).then(() => null, (err) => err instanceof Error ? err.message.split("\n")[0] : String(err));
|
|
1113
|
+
let grantsSeen = null;
|
|
1114
|
+
let grantsBusy = false;
|
|
1115
|
+
const checkGrants = async () => {
|
|
1116
|
+
if (grantsBusy || await transportOk()) return;
|
|
1117
|
+
grantsBusy = true;
|
|
1118
|
+
try {
|
|
1119
|
+
const self = await getSelf(profile.url, profile.apiKey, 8e3);
|
|
1120
|
+
const key = grantsKey(self.grants, self.connections);
|
|
1121
|
+
if (key === grantsSeen) return;
|
|
1122
|
+
const first = grantsSeen === null;
|
|
1123
|
+
grantsSeen = key;
|
|
1124
|
+
const lines = describeGrants(self.grants, self.connections).map((l) => ` ${hasAccess(self) ? l : pc.yellow(l)}`);
|
|
1125
|
+
log.info(`${first ? `Signed in as agent "${safeText(self.name)}". Access` : "Access changed in the console. Now"}:\n${lines.join("\n")}`);
|
|
1126
|
+
} catch (err) {
|
|
1127
|
+
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) return;
|
|
1128
|
+
log.debug(`identity check failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1129
|
+
} finally {
|
|
1130
|
+
grantsBusy = false;
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
checkGrants();
|
|
1134
|
+
const grantsTimer = setInterval(() => void checkGrants(), opts.grantsRefreshMs ?? 3e5);
|
|
1135
|
+
grantsTimer.unref();
|
|
1136
|
+
const acker = new Acker(async (seq) => {
|
|
1137
|
+
const refused = await transportOk();
|
|
1138
|
+
if (refused) throw new Error(refused);
|
|
1139
|
+
const acked = (await ackEvents(profile.url, profile.apiKey, config.consumer, seq)).ackedSeq;
|
|
1140
|
+
status.update({ ackedSeq: acked });
|
|
1141
|
+
return acked;
|
|
1142
|
+
}, log);
|
|
1143
|
+
const timeoutMs = config.timeoutSeconds * 1e3;
|
|
1144
|
+
const batched = config.batchSeconds > 0 && !!config.exec;
|
|
1145
|
+
const wrapForward = !!config.forward && isHermesRoute(config.forward.url);
|
|
1146
|
+
const runTargets = async (job, signal) => {
|
|
1147
|
+
const errors = [];
|
|
1148
|
+
const tag = job.payloads.length === 1 ? `#${job.seq}` : `batch #${job.seq}–#${job.lastSeq} (${job.payloads.length} events)`;
|
|
1149
|
+
const ids = job.payloads.map((p) => p.data.id);
|
|
1150
|
+
if (job.remaining.has("exec") && config.exec) {
|
|
1151
|
+
const r = await runCommand(config.exec, batched ? job.payloads : job.payload, {
|
|
1152
|
+
timeoutMs,
|
|
1153
|
+
signal,
|
|
1154
|
+
cwd: config.cwd
|
|
1155
|
+
});
|
|
1156
|
+
const meta = {
|
|
1157
|
+
seq: job.seq,
|
|
1158
|
+
lastSeq: job.lastSeq,
|
|
1159
|
+
ids,
|
|
1160
|
+
attempt: job.attempts,
|
|
1161
|
+
exitCode: r.exitCode,
|
|
1162
|
+
signal: r.signal,
|
|
1163
|
+
durationMs: r.durationMs,
|
|
1164
|
+
stdoutTail: r.stdoutTail,
|
|
1165
|
+
stderrTail: r.stderrTail
|
|
1166
|
+
};
|
|
1167
|
+
if (signal.aborted) {
|
|
1168
|
+
errors.push("stopped");
|
|
1169
|
+
log.debug(`${tag} command stopped by shutdown`, meta);
|
|
1170
|
+
} else if (r.ok) {
|
|
1171
|
+
job.remaining.delete("exec");
|
|
1172
|
+
log.debug(`${tag} command finished in ${formatDuration(r.durationMs)}`, meta);
|
|
1173
|
+
if (isSilent(r.stdoutTail)) log.info(pc.gray(` ${tag}: nothing to do`));
|
|
1174
|
+
else if (!r.stdoutTail.trim()) log.info(pc.gray(` ${tag}: no output`));
|
|
1175
|
+
else log.info(` ${tag} output:\n${lastLines(r.stdoutTail, 6)}`);
|
|
1176
|
+
} else {
|
|
1177
|
+
errors.push(`command: ${r.error}`);
|
|
1178
|
+
const tail = lastLines(r.stderrTail) || lastLines(r.stdoutTail);
|
|
1179
|
+
log.warn(`${tag} command failed (attempt ${job.attempts}/${config.maxAttempts}): ${r.error}${tail ? `\n${tail}` : ""}`, meta);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
if (job.remaining.has("forward") && config.forward) {
|
|
1183
|
+
let failed = false;
|
|
1184
|
+
for (const p of job.payloads) {
|
|
1185
|
+
if (job.forwarded.has(p.data.seq)) continue;
|
|
1186
|
+
const r = await forwardEvent(config.forward.url, config.forward.secret, p, {
|
|
1187
|
+
timeoutMs: Math.min(timeoutMs, 3e4),
|
|
1188
|
+
signal,
|
|
1189
|
+
wrap: wrapForward
|
|
1190
|
+
});
|
|
1191
|
+
const meta = {
|
|
1192
|
+
seq: p.data.seq,
|
|
1193
|
+
id: p.data.id,
|
|
1194
|
+
attempt: job.attempts,
|
|
1195
|
+
statusCode: r.statusCode,
|
|
1196
|
+
durationMs: r.durationMs,
|
|
1197
|
+
response: r.response
|
|
1198
|
+
};
|
|
1199
|
+
if (signal.aborted) {
|
|
1200
|
+
errors.push("stopped");
|
|
1201
|
+
failed = true;
|
|
1202
|
+
break;
|
|
1203
|
+
} else if (r.ok) {
|
|
1204
|
+
job.forwarded.add(p.data.seq);
|
|
1205
|
+
log.debug(`#${p.data.seq} forwarded (${r.statusCode}) in ${formatDuration(r.durationMs)}`, meta);
|
|
1206
|
+
} else {
|
|
1207
|
+
errors.push(`forward: ${r.error}`);
|
|
1208
|
+
failed = true;
|
|
1209
|
+
if (r.retryAfterMs !== void 0) {
|
|
1210
|
+
job.retryAfterMs = r.retryAfterMs;
|
|
1211
|
+
log.info(`#${p.data.seq} forward: the receiver is rate-limiting (429); trying again in ${formatDuration(r.retryAfterMs)} (doesn't count as a failed attempt)`, meta);
|
|
1212
|
+
} else log.warn(`#${p.data.seq} forward failed (attempt ${job.attempts}/${config.maxAttempts}): ${r.error}`, meta);
|
|
1213
|
+
break;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
if (!failed) job.remaining.delete("forward");
|
|
1217
|
+
}
|
|
1218
|
+
return errors;
|
|
1219
|
+
};
|
|
1220
|
+
const dispatcher = new Dispatcher({
|
|
1221
|
+
targets: dryRun ? [] : targets,
|
|
1222
|
+
concurrency: config.concurrency,
|
|
1223
|
+
maxAttempts: config.maxAttempts,
|
|
1224
|
+
retryDelayMs: opts.retryDelayMs,
|
|
1225
|
+
batch: batched && !dryRun ? {
|
|
1226
|
+
windowMs: config.batchSeconds * 1e3,
|
|
1227
|
+
max: 20
|
|
1228
|
+
} : void 0,
|
|
1229
|
+
run: runTargets,
|
|
1230
|
+
onAdvance: (seq) => {
|
|
1231
|
+
if (dryRun || backfill) return;
|
|
1232
|
+
acker.request(seq);
|
|
1233
|
+
},
|
|
1234
|
+
onRetry: (job, delay) => {
|
|
1235
|
+
log.info(`${describeJob(job)}: retrying in ${formatDuration(delay)}`);
|
|
1236
|
+
for (const p of job.payloads) status.addRecent({
|
|
1237
|
+
seq: p.data.seq,
|
|
1238
|
+
id: p.data.id,
|
|
1239
|
+
type: p.type,
|
|
1240
|
+
outcome: "retrying",
|
|
1241
|
+
attempts: job.attempts,
|
|
1242
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1243
|
+
error: job.lastError
|
|
1244
|
+
});
|
|
1245
|
+
},
|
|
1246
|
+
onDead: (job) => {
|
|
1247
|
+
failed += job.payloads.length;
|
|
1248
|
+
for (const p of job.payloads) {
|
|
1249
|
+
const targets = [...job.remaining].filter((t) => t !== "forward" || !job.forwarded.has(p.data.seq));
|
|
1250
|
+
appendDead(profileName, {
|
|
1251
|
+
deadAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1252
|
+
seq: p.data.seq,
|
|
1253
|
+
id: p.data.id,
|
|
1254
|
+
type: p.type,
|
|
1255
|
+
attempts: job.attempts,
|
|
1256
|
+
error: job.lastError ?? "failed",
|
|
1257
|
+
targets,
|
|
1258
|
+
payload: p
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
log.error(`${describeJob(job)}: gave up after ${job.attempts} attempts (${job.lastError}). Saved to dead letters: ${NPX} listen dead${profileName === "default" ? "" : ` --profile ${profileName}`}`, {
|
|
1262
|
+
seq: job.seq,
|
|
1263
|
+
ids: job.payloads.map((p) => p.data.id)
|
|
1264
|
+
});
|
|
1265
|
+
},
|
|
1266
|
+
onFinished: (job) => {
|
|
1267
|
+
if (job.outcome === "succeeded") handled += job.payloads.length;
|
|
1268
|
+
if (job.outcome === "succeeded") log.info(`${pc.green("✓")} ${describeJob(job)} ${pc.gray(`(${formatDuration(Date.now() - job.firstSeenAt)})`)}`, {
|
|
1269
|
+
seq: job.seq,
|
|
1270
|
+
ids: job.payloads.map((p) => p.data.id),
|
|
1271
|
+
attempts: job.attempts
|
|
1272
|
+
});
|
|
1273
|
+
for (const p of job.payloads) status.addRecent({
|
|
1274
|
+
seq: p.data.seq,
|
|
1275
|
+
id: p.data.id,
|
|
1276
|
+
type: p.type,
|
|
1277
|
+
outcome: job.outcome === "dead" ? "dead" : "succeeded",
|
|
1278
|
+
attempts: job.attempts,
|
|
1279
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1280
|
+
error: job.lastError
|
|
1281
|
+
});
|
|
1282
|
+
status.update({ running: dispatcher.inFlight });
|
|
1283
|
+
}
|
|
1284
|
+
});
|
|
1285
|
+
let downSince = null;
|
|
1286
|
+
let connectedNow = false;
|
|
1287
|
+
/** --backfill: last time something arrived or ran; and whether an event past the end was seen. */
|
|
1288
|
+
let lastActivity = Date.now();
|
|
1289
|
+
let pastHead = false;
|
|
1290
|
+
/** Reversible refusal currently in effect (logged once per change). */
|
|
1291
|
+
let blocked = null;
|
|
1292
|
+
const stream = new EventStream({
|
|
1293
|
+
url: profile.url,
|
|
1294
|
+
apiKey: profile.apiKey,
|
|
1295
|
+
consumer: streamConsumer,
|
|
1296
|
+
after: streamAfter,
|
|
1297
|
+
types: config.types,
|
|
1298
|
+
includeBody: config.includeBody,
|
|
1299
|
+
lastEventId: () => dispatcher.lastSeq,
|
|
1300
|
+
stallMs: opts.stallMs,
|
|
1301
|
+
beforeConnect: transportOk,
|
|
1302
|
+
fromStart,
|
|
1303
|
+
blockedRetryMs: opts.blockedRetryMs,
|
|
1304
|
+
onBlocked: (code, text) => {
|
|
1305
|
+
downSince ??= Date.now();
|
|
1306
|
+
if (blocked !== code) log.warn(`${pc.yellow("⏸")} ${text}`, { code });
|
|
1307
|
+
blocked = code;
|
|
1308
|
+
status.update({
|
|
1309
|
+
connected: false,
|
|
1310
|
+
lastDisconnect: {
|
|
1311
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1312
|
+
reason: text
|
|
1313
|
+
}
|
|
1314
|
+
});
|
|
1315
|
+
},
|
|
1316
|
+
minBackoffMs: opts.minBackoffMs,
|
|
1317
|
+
maxBackoffMs: opts.maxBackoffMs,
|
|
1318
|
+
shouldPause: () => dispatcher.backlog >= 200,
|
|
1319
|
+
onOpen: () => {
|
|
1320
|
+
const after = downSince ? ` after ${formatDuration(Date.now() - downSince)}` : "";
|
|
1321
|
+
if (blocked) log.info("Access restored.");
|
|
1322
|
+
if (blocked || downSince) checkGrants();
|
|
1323
|
+
blocked = null;
|
|
1324
|
+
connectedNow = true;
|
|
1325
|
+
lastActivity = Date.now();
|
|
1326
|
+
log.info(`${pc.green("●")} Connected${after}. ${backfill ? "Receiving stored events…" : "Waiting for events…"}`);
|
|
1327
|
+
if (!dryRun && !backfill) rememberConsumer(profileName, config.consumer);
|
|
1328
|
+
downSince = null;
|
|
1329
|
+
status.update({
|
|
1330
|
+
connected: true,
|
|
1331
|
+
connectedSince: (/* @__PURE__ */ new Date()).toISOString()
|
|
1332
|
+
});
|
|
1333
|
+
},
|
|
1334
|
+
onDisconnect: (reason, retryInMs, { serious }) => {
|
|
1335
|
+
downSince ??= Date.now();
|
|
1336
|
+
connectedNow = false;
|
|
1337
|
+
const msg = `${pc.yellow("○")} Disconnected: ${reason}. Reconnecting${retryInMs ? ` in ${formatDuration(retryInMs)}` : ""}…`;
|
|
1338
|
+
if (serious) log.warn(msg);
|
|
1339
|
+
else log.info(msg);
|
|
1340
|
+
status.update({
|
|
1341
|
+
connected: false,
|
|
1342
|
+
lastDisconnect: {
|
|
1343
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1344
|
+
reason
|
|
1345
|
+
}
|
|
1346
|
+
});
|
|
1347
|
+
},
|
|
1348
|
+
onMessage: (m) => {
|
|
1349
|
+
if (m.event !== "mail") {
|
|
1350
|
+
log.debug(`ignoring unknown stream event "${m.event}"`);
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
let payload;
|
|
1354
|
+
try {
|
|
1355
|
+
payload = parsePayload(m.data);
|
|
1356
|
+
} catch (err) {
|
|
1357
|
+
log.warn(`Ignoring a malformed event from the stream: ${err.message}`, { id: m.id });
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
if (backfill && payload.data.seq > backfillHead) {
|
|
1361
|
+
pastHead = true;
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
lastActivity = Date.now();
|
|
1365
|
+
const skip = skipReason(payload, config);
|
|
1366
|
+
const match = skip === null;
|
|
1367
|
+
if (dryRun && match) log.info(`${pc.cyan("→")} ${describeEvent(payload)} ${pc.gray("(dry run: nothing executed, cursor not advanced)")}`);
|
|
1368
|
+
else if (match) {
|
|
1369
|
+
const waits = batched && dispatcher.lastSeq < payload.data.seq ? pc.gray(` (batching for up to ${formatDuration(config.batchSeconds * 1e3)})`) : "";
|
|
1370
|
+
log.info(`${pc.cyan("→")} ${describeEvent(payload)}${waits}`, {
|
|
1371
|
+
seq: payload.data.seq,
|
|
1372
|
+
id: payload.data.id,
|
|
1373
|
+
type: payload.type
|
|
1374
|
+
});
|
|
1375
|
+
} else {
|
|
1376
|
+
const line = `skip ${describeEvent(payload)} (${safeText(skip, 120)})`;
|
|
1377
|
+
if (dryRun) log.info(pc.gray(` ${line}`));
|
|
1378
|
+
else log.debug(line, {
|
|
1379
|
+
seq: payload.data.seq,
|
|
1380
|
+
id: payload.data.id
|
|
1381
|
+
});
|
|
1382
|
+
if (dispatcher.lastSeq < payload.data.seq) status.update({ skipped: (status.status.skipped ?? 0) + 1 });
|
|
1383
|
+
}
|
|
1384
|
+
if (!dispatcher.push(payload, { skip: !match })) log.debug(`duplicate #${payload.data.seq} ignored`);
|
|
1385
|
+
status.update({
|
|
1386
|
+
receivedSeq: dispatcher.lastSeq,
|
|
1387
|
+
lastEventAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1388
|
+
running: dispatcher.inFlight
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1392
|
+
let stopReason = null;
|
|
1393
|
+
const stop = (why) => {
|
|
1394
|
+
if (stopReason) return;
|
|
1395
|
+
stopReason = why;
|
|
1396
|
+
stream.stop();
|
|
1397
|
+
};
|
|
1398
|
+
if (opts.signal?.aborted) stop("stopped");
|
|
1399
|
+
else opts.signal?.addEventListener("abort", () => stop("stopped"), { once: true });
|
|
1400
|
+
const onSignal = (sig) => {
|
|
1401
|
+
if (stopReason) {
|
|
1402
|
+
log.warn("Exiting now.");
|
|
1403
|
+
killAllRuns();
|
|
1404
|
+
process.exit(130);
|
|
1405
|
+
}
|
|
1406
|
+
log.info(`${sig === "SIGINT" ? "\n" : ""}Stopping (${sig}). Unfinished events will be delivered again next time. Press Ctrl-C again to force.`);
|
|
1407
|
+
stop(sig);
|
|
1408
|
+
};
|
|
1409
|
+
if (opts.handleSignals) {
|
|
1410
|
+
process.on("SIGINT", onSignal);
|
|
1411
|
+
process.on("SIGTERM", onSignal);
|
|
1412
|
+
}
|
|
1413
|
+
let handled = 0;
|
|
1414
|
+
let failed = 0;
|
|
1415
|
+
let backfillTimer = null;
|
|
1416
|
+
if (backfill) {
|
|
1417
|
+
const idleMs = opts.backfillIdleMs ?? 6e4;
|
|
1418
|
+
const unreachableMs = opts.backfillUnreachableMs ?? 6e5;
|
|
1419
|
+
backfillTimer = setInterval(() => {
|
|
1420
|
+
const now = Date.now();
|
|
1421
|
+
if (!connectedNow) {
|
|
1422
|
+
if (downSince !== null && now - downSince >= unreachableMs) stop("backfill unreachable");
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
if (dispatcher.inFlight > 0) {
|
|
1426
|
+
lastActivity = now;
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
if (pastHead || dispatcher.lastSeq >= backfillHead) stop("backfill complete");
|
|
1430
|
+
else if (now - lastActivity >= idleMs) {
|
|
1431
|
+
log.info(`No more stored events arrived for ${formatDuration(idleMs)}; the rest up to #${backfillHead} aren't visible to this listener (event types, label scope, or removed by retention).`);
|
|
1432
|
+
stop("backfill complete");
|
|
1433
|
+
}
|
|
1434
|
+
}, 100);
|
|
1435
|
+
}
|
|
1436
|
+
const end = await stream.run();
|
|
1437
|
+
if (backfillTimer) clearInterval(backfillTimer);
|
|
1438
|
+
clearInterval(grantsTimer);
|
|
1439
|
+
await dispatcher.stop();
|
|
1440
|
+
if (!dryRun) await acker.flush();
|
|
1441
|
+
if (opts.handleSignals) {
|
|
1442
|
+
process.off("SIGINT", onSignal);
|
|
1443
|
+
process.off("SIGTERM", onSignal);
|
|
1444
|
+
}
|
|
1445
|
+
if (end.kind === "revoked") return revokedExit(log, status, profileName, profile, end.reason, opts);
|
|
1446
|
+
if (end.kind === "fatal") {
|
|
1447
|
+
log.error(`The daemon rejected the stream request: ${end.reason}`);
|
|
1448
|
+
status.update({
|
|
1449
|
+
connected: false,
|
|
1450
|
+
stoppedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1451
|
+
exitReason: end.reason
|
|
1452
|
+
});
|
|
1453
|
+
status.flush();
|
|
1454
|
+
return EXIT.revoked;
|
|
1455
|
+
}
|
|
1456
|
+
status.update({
|
|
1457
|
+
connected: false,
|
|
1458
|
+
stoppedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1459
|
+
exitReason: stopReason ?? "stopped"
|
|
1460
|
+
});
|
|
1461
|
+
status.flush();
|
|
1462
|
+
if (backfill && stopReason === "backfill unreachable") {
|
|
1463
|
+
log.error(`Gave up: the daemon was unreachable for ${formatDuration(opts.backfillUnreachableMs ?? 6e5)}. ${handled} event${handled === 1 ? "" : "s"} handled so far; run the backfill again later (it starts over from the oldest event, so handlers should dedupe on the event id).`);
|
|
1464
|
+
return EXIT.error;
|
|
1465
|
+
}
|
|
1466
|
+
if (backfill && stopReason === "backfill complete") {
|
|
1467
|
+
const p = profileName === "default" ? "" : ` --profile ${profileName}`;
|
|
1468
|
+
log.info(`${failed ? pc.yellow("Backfill done") : `${pc.green("✔")} Backfill done`}: ${handled} event${handled === 1 ? "" : "s"} handled${failed ? `, ${failed} failed (see ${NPX} listen dead${p})` : ""}.`);
|
|
1469
|
+
return failed ? EXIT.error : EXIT.ok;
|
|
1470
|
+
}
|
|
1471
|
+
log.info("Stopped.");
|
|
1472
|
+
return EXIT.ok;
|
|
1473
|
+
} finally {
|
|
1474
|
+
releaseLock();
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
async function revokedExit(log, status, profileName, profile, reason, opts) {
|
|
1478
|
+
const p = profileName === "default" ? "" : ` --profile ${profileName}`;
|
|
1479
|
+
log.error(`Access revoked: ${safeText(reason, 300)}\nThis key won't work again. Pair again with: ${NPX} connect ${profile.url}${p} --force\n(your listener settings in the profile are kept; restart the listener or its service afterwards)`);
|
|
1480
|
+
status.update({
|
|
1481
|
+
connected: false,
|
|
1482
|
+
stoppedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1483
|
+
exitReason: `revoked: ${safeText(reason, 300)}`
|
|
1484
|
+
});
|
|
1485
|
+
status.flush();
|
|
1486
|
+
if (underService() && process.platform === "darwin") await new Promise((r) => setTimeout(r, opts.revokedExitDelayMs ?? 9e5));
|
|
1487
|
+
return EXIT.revoked;
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Where `--backfill` ends: the seq the listener's consumer started at on the daemon (recorded when the
|
|
1491
|
+
* consumer was created), or --until. Refuses when that can't be known, rather than replaying mail the
|
|
1492
|
+
* listener already handled.
|
|
1493
|
+
*/
|
|
1494
|
+
async function backfillEnd(profileName, profile, consumer, replay, until, log) {
|
|
1495
|
+
if (until !== void 0 && (!Number.isSafeInteger(until) || until < 0)) throw new Error("--until must be an event number (0 or more).");
|
|
1496
|
+
const p = profileName === "default" ? "" : ` --profile ${profileName}`;
|
|
1497
|
+
let self;
|
|
1498
|
+
try {
|
|
1499
|
+
self = await getSelf(profile.url, profile.apiKey, 8e3);
|
|
1500
|
+
} catch (err) {
|
|
1501
|
+
throw new Error(`Couldn't ask the daemon where the listener started: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
|
|
1502
|
+
}
|
|
1503
|
+
const replayCursor = self.consumers?.find((c) => c.consumer === replay);
|
|
1504
|
+
if (replayCursor && replayCursor.ackedSeq > 0) throw new Error(`The backfill cursor "${replay}" on the daemon has been moved to #${replayCursor.ackedSeq} (something acknowledged it), so a backfill would skip the oldest mail.\n Use another consumer name for this listener (--consumer <name> --save), or remove that consumer in the console.`);
|
|
1505
|
+
const mine = self.consumers?.find((c) => c.consumer === consumer);
|
|
1506
|
+
if (until !== void 0) {
|
|
1507
|
+
if (mine?.startSeq != null && until > mine.startSeq) log.warn(`--until #${until} is past where the listener started (#${mine.startSeq}): ${until === mine.startSeq + 1 ? `event #${until} was` : `events #${mine.startSeq + 1}–#${until} were`} (or will be) handled by the listener too, so ${until === mine.startSeq + 1 ? "it runs" : "they run"} twice.`);
|
|
1508
|
+
return until;
|
|
1509
|
+
}
|
|
1510
|
+
if (!self.consumers) throw new Error("This xaq daemon doesn't report where listeners started (it's older than this CLI), so a backfill could re-run mail the listener already handled.\n Update xaq, or pass --until <event number>: the newest event to replay (events after it are the listener's).");
|
|
1511
|
+
if (!mine) throw new Error(`The listener (consumer "${consumer}") hasn't connected yet, so no mail predates it.\n Start it with --from-start instead: a new listener then begins with the oldest stored mail. E.g. ${NPX} listen${p} --from-start`);
|
|
1512
|
+
if (mine.startSeq === null) throw new Error(`The daemon doesn't know where consumer "${consumer}" started (it was created by an older xaq).\n Pass --until <event number>: the newest event to replay. The listener's first handled events are in ${NPX} listen status${p} and its log.`);
|
|
1513
|
+
return mine.startSeq;
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* The one-off backfill command for this listener: `listen --backfill`, plus the targets when they came
|
|
1517
|
+
* from flags rather than the profile (the backfill must run the same thing).
|
|
1518
|
+
*/
|
|
1519
|
+
function backfillCommand(profileName, profile, c) {
|
|
1520
|
+
const parts = [`${NPX} listen`];
|
|
1521
|
+
if (profileName !== "default") parts.push(`--profile ${profileName}`);
|
|
1522
|
+
parts.push("--backfill");
|
|
1523
|
+
const savedExec = profile.exec ?? null;
|
|
1524
|
+
const savedForward = profile.forward?.url ?? null;
|
|
1525
|
+
if (c.exec !== savedExec || (c.forward?.url ?? null) !== savedForward) {
|
|
1526
|
+
const tool = recipeToolFor(c.exec, profileName);
|
|
1527
|
+
const fwdTool = c.exec ? null : recipeToolFor(null, profileName, c.forward?.url ?? null);
|
|
1528
|
+
if (tool) parts.push(`--recipe ${tool}`);
|
|
1529
|
+
else if (c.exec) parts.push(`--exec ${shellQuote(c.exec)}`);
|
|
1530
|
+
if (fwdTool) parts.push(`--recipe ${fwdTool}`);
|
|
1531
|
+
else if (c.forward) parts.push(`--forward ${shellQuote(c.forward.url)}`);
|
|
1532
|
+
}
|
|
1533
|
+
return parts.join(" ");
|
|
1534
|
+
}
|
|
1535
|
+
/** Where the agent's output ends up, said plainly at start (and in `listen status`). */
|
|
1536
|
+
function outputLine(profileName, c, log) {
|
|
1537
|
+
const r = recipeOf(c, profileName);
|
|
1538
|
+
const parts = [];
|
|
1539
|
+
if (c.exec) parts.push(r?.exec ? `${TOOL_LABELS[r.tool]}'s reply only goes to the listener's log (${log}, and the console when run in the foreground); it can't notify you by itself (--recipe hermes can, see docs)` : `your command's last output lines go to the listener's log (${log}, and the console when run in the foreground)`);
|
|
1540
|
+
if (c.forward) parts.push(r?.forward ? "Hermes delivers its triage where its route says (listen hermes); this listener only sees the HTTP status" : "the receiver at --forward handles it; this listener only sees the HTTP status");
|
|
1541
|
+
return parts.join(" · ");
|
|
1542
|
+
}
|
|
1543
|
+
function printBanner(log, name, profile, c, dryRun, backfill = false) {
|
|
1544
|
+
const lines = [`${pc.bold("xaq listen")} ${pc.gray(`v${version}`)} · profile ${pc.cyan(name)} · agent ${pc.cyan(safeText(profile.agentName))}`, ` daemon ${profile.url} ${pc.gray(`(consumer ${c.consumer} on ${hostname()})`)}`];
|
|
1545
|
+
if (dryRun) lines.push(` mode ${pc.yellow("dry run")}: matching events are printed; nothing runs and the cursor is not advanced`);
|
|
1546
|
+
if (backfill) lines.push(` mode ${pc.yellow("backfill")}: mail from before the listener started, then exit (own cursor; the listener is not affected)`);
|
|
1547
|
+
if (c.exec) lines.push(` exec ${safeText(c.exec, 300)}`, ` cwd ${c.cwd}${c.cwdIsDefault ? pc.gray(" (private; --cwd to change)") : ""}`);
|
|
1548
|
+
if (c.forward) lines.push(` forward ${c.forward.url} ${pc.gray(`(signed, see \`${NPX} listen secret\`)`)}`);
|
|
1549
|
+
lines.push(` events ${describeFilter(c)}${c.includeBody ? " · with bodies" : ""}`);
|
|
1550
|
+
if (c.batchSeconds) lines.push(c.exec ? ` batch up to 20 events per run, collected for ${formatDuration(c.batchSeconds * 1e3)} after the first${c.forward ? pc.gray(" (forward stays one POST per event)") : ""}` : pc.gray(` batch ${formatDuration(c.batchSeconds * 1e3)} ignored: --batch applies to --exec; forward is one POST per event`));
|
|
1551
|
+
lines.push(` limits concurrency ${c.concurrency} · timeout ${c.timeoutSeconds}s · ${c.maxAttempts} attempts`);
|
|
1552
|
+
lines.push(` output ${outputLine(name, c, backfill || dryRun ? oneOffLogPath(name) : logPath(name))}`);
|
|
1553
|
+
lines.push(pc.gray(` log ${backfill || dryRun ? oneOffLogPath(name) : logPath(name)}`));
|
|
1554
|
+
for (const l of lines) process.stdout.write(`${l}\n`);
|
|
1555
|
+
log.debug("listener starting", {
|
|
1556
|
+
profile: name,
|
|
1557
|
+
url: profile.url,
|
|
1558
|
+
consumer: c.consumer,
|
|
1559
|
+
exec: c.exec,
|
|
1560
|
+
forward: c.forward?.url,
|
|
1561
|
+
types: c.types,
|
|
1562
|
+
filter: c.filter
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
//#endregion
|
|
1566
|
+
export { runListener as a, readDead as c, runCommand as d, Dispatcher as f, outputLine as i, readStatus as l, hasAccess as m, backfillCommand as n, acquireFileLock as o, describeGrants as p, isSilent as r, pidAlive as s, EXIT as t, writeDead as u };
|
|
1567
|
+
|
|
1568
|
+
//# sourceMappingURL=listen-B9iO7HgQ.js.map
|