xaq-cli 0.0.0 → 0.1.0-nightly.72

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 (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -0
  3. package/dist/admin-5gThRwgj.js +162 -0
  4. package/dist/admin-5gThRwgj.js.map +1 -0
  5. package/dist/admin-Btrm8mCd.js +218 -0
  6. package/dist/admin-Btrm8mCd.js.map +1 -0
  7. package/dist/admin-CWofnT_Q.js +2 -0
  8. package/dist/api-BFpElwqK.js +89 -0
  9. package/dist/api-BFpElwqK.js.map +1 -0
  10. package/dist/audit-BB7jK9Z5.js +2 -0
  11. package/dist/audit-BVYknW2i.js +79 -0
  12. package/dist/audit-BVYknW2i.js.map +1 -0
  13. package/dist/auth-CXmBdMqw.js +2 -0
  14. package/dist/auth-D-GmwNv-.js +550 -0
  15. package/dist/auth-D-GmwNv-.js.map +1 -0
  16. package/dist/brand--ZB8jTn1.js +32 -0
  17. package/dist/brand--ZB8jTn1.js.map +1 -0
  18. package/dist/bus-Cz0flS37.js +12 -0
  19. package/dist/bus-Cz0flS37.js.map +1 -0
  20. package/dist/bus-OKIGut72.js +2 -0
  21. package/dist/cli.js +234 -0
  22. package/dist/cli.js.map +1 -0
  23. package/dist/commands-BNVLjYXA.js +167 -0
  24. package/dist/commands-BNVLjYXA.js.map +1 -0
  25. package/dist/commands-CdA6-WE7.js +112 -0
  26. package/dist/commands-CdA6-WE7.js.map +1 -0
  27. package/dist/config-C9XmYZWO.js +94 -0
  28. package/dist/config-C9XmYZWO.js.map +1 -0
  29. package/dist/config-CNVi0v5t.js +2 -0
  30. package/dist/connect-TtJ9n0Rw.js +362 -0
  31. package/dist/connect-TtJ9n0Rw.js.map +1 -0
  32. package/dist/content-C1lKMxFS.js +1170 -0
  33. package/dist/content-C1lKMxFS.js.map +1 -0
  34. package/dist/content-worker.js +28 -0
  35. package/dist/content-worker.js.map +1 -0
  36. package/dist/crypto-fv2fj3S8.js +75 -0
  37. package/dist/crypto-fv2fj3S8.js.map +1 -0
  38. package/dist/db-BEUEUPyP.js +561 -0
  39. package/dist/db-BEUEUPyP.js.map +1 -0
  40. package/dist/db-Q5BQuwga.js +2 -0
  41. package/dist/errors-FbMS3QBo.js +39 -0
  42. package/dist/errors-FbMS3QBo.js.map +1 -0
  43. package/dist/filter-Ck6OMxvu.js +158 -0
  44. package/dist/filter-Ck6OMxvu.js.map +1 -0
  45. package/dist/hermes-CKyzt1mH.js +3 -0
  46. package/dist/hermes-DHnDTYJp.js +1023 -0
  47. package/dist/hermes-DHnDTYJp.js.map +1 -0
  48. package/dist/import-DsN5NZT3.js +910 -0
  49. package/dist/import-DsN5NZT3.js.map +1 -0
  50. package/dist/instance-CwWbFbJ8.js +2 -0
  51. package/dist/instance-Dj9TpJB2.js +195 -0
  52. package/dist/instance-Dj9TpJB2.js.map +1 -0
  53. package/dist/listen-B9iO7HgQ.js +1568 -0
  54. package/dist/listen-B9iO7HgQ.js.map +1 -0
  55. package/dist/listen-CXVKG9XD.js +2 -0
  56. package/dist/manage-Bo6COB4p.js +477 -0
  57. package/dist/manage-Bo6COB4p.js.map +1 -0
  58. package/dist/manage-DoXqvjnU.js +2 -0
  59. package/dist/mcpheader-B8c5YO1B.js +2 -0
  60. package/dist/mcpheader-C9H4vudu.js +185 -0
  61. package/dist/mcpheader-C9H4vudu.js.map +1 -0
  62. package/dist/mcpsetup-BnHudwpq.js +2 -0
  63. package/dist/mcpsetup-DXXo2qjf.js +1053 -0
  64. package/dist/mcpsetup-DXXo2qjf.js.map +1 -0
  65. package/dist/node-check-CjTk6VNL.js +24 -0
  66. package/dist/node-check-CjTk6VNL.js.map +1 -0
  67. package/dist/paths-d-C2kdjo.js +2 -0
  68. package/dist/paths-yzOKD6ow.js +46 -0
  69. package/dist/paths-yzOKD6ow.js.map +1 -0
  70. package/dist/profile-CcMZ4hn7.js +2 -0
  71. package/dist/profile-NmMKAczz.js +222 -0
  72. package/dist/profile-NmMKAczz.js.map +1 -0
  73. package/dist/prompt-CKMQx_MJ.js +114 -0
  74. package/dist/prompt-CKMQx_MJ.js.map +1 -0
  75. package/dist/recipes-C7TIDlRj.js +3 -0
  76. package/dist/recipes-DG_-doGq.js +314 -0
  77. package/dist/recipes-DG_-doGq.js.map +1 -0
  78. package/dist/send-Dk-t8ia-.js +108 -0
  79. package/dist/send-Dk-t8ia-.js.map +1 -0
  80. package/dist/server-CDqDp1q3.js +20640 -0
  81. package/dist/server-CDqDp1q3.js.map +1 -0
  82. package/dist/service-BVn3soZZ.js +766 -0
  83. package/dist/service-BVn3soZZ.js.map +1 -0
  84. package/dist/service-BaWGPmSd.js +2 -0
  85. package/dist/service-C7jfo-Ex.js +457 -0
  86. package/dist/service-C7jfo-Ex.js.map +1 -0
  87. package/dist/service-DxDyKDQ-.js +2 -0
  88. package/dist/settings-B5qSHxRM.js +134 -0
  89. package/dist/settings-B5qSHxRM.js.map +1 -0
  90. package/dist/settings-DQvZaMN1.js +2 -0
  91. package/dist/setup-token-CwuSzfLK.js +48 -0
  92. package/dist/setup-token-CwuSzfLK.js.map +1 -0
  93. package/dist/skill/SKILL.md +445 -0
  94. package/dist/skill-1UQj3qgW.js +69 -0
  95. package/dist/skill-1UQj3qgW.js.map +1 -0
  96. package/dist/skill-pelq18JQ.js +2 -0
  97. package/dist/ssrf-BlVpGZ7C.js +181 -0
  98. package/dist/ssrf-BlVpGZ7C.js.map +1 -0
  99. package/dist/types-BhJDbSzE.js +135 -0
  100. package/dist/types-BhJDbSzE.js.map +1 -0
  101. package/dist/ui-B8Sd4Y7Y.js +81 -0
  102. package/dist/ui-B8Sd4Y7Y.js.map +1 -0
  103. package/dist/urls-Dp8pBGMU.js +2 -0
  104. package/dist/urls-U_LfYV61.js +27 -0
  105. package/dist/urls-U_LfYV61.js.map +1 -0
  106. package/dist/version-BxH7OuWD.js +20 -0
  107. package/dist/version-BxH7OuWD.js.map +1 -0
  108. package/dist/web/assets/_agentId-DonOCOlC.js +1 -0
  109. package/dist/web/assets/_app-B7t5lEia.js +1 -0
  110. package/dist/web/assets/_app-DMTGqYpM.js +65 -0
  111. package/dist/web/assets/_connectionId-DR5vFXOg.js +1 -0
  112. package/dist/web/assets/_webhookId-LfUlsULp.js +1 -0
  113. package/dist/web/assets/account-status-CEszs13C.js +1 -0
  114. package/dist/web/assets/accounts-5m7aiXuZ.js +1 -0
  115. package/dist/web/assets/accounts-list-YfJzThZa.js +1 -0
  116. package/dist/web/assets/activity-DOIe7SAg.js +1 -0
  117. package/dist/web/assets/add-connection-C8JR-13R.js +1 -0
  118. package/dist/web/assets/agent-create-form-CPxnPYH4.js +1 -0
  119. package/dist/web/assets/agents-B2Qj5KxT.js +1 -0
  120. package/dist/web/assets/approvals-BGaVaoFy.js +1 -0
  121. package/dist/web/assets/approve._id-BLrQnzPM.js +1 -0
  122. package/dist/web/assets/arrow-left-DZVAXoKj.js +1 -0
  123. package/dist/web/assets/arrow-right-C2zcqRr2.js +1 -0
  124. package/dist/web/assets/attention-gO9Nw5uW.js +1 -0
  125. package/dist/web/assets/audit-BPXXre8S.js +1 -0
  126. package/dist/web/assets/audit-bits-CGyIprzK.js +1 -0
  127. package/dist/web/assets/bell-ring-ig3zn1iS.js +1 -0
  128. package/dist/web/assets/bot-Zk76q_39.js +1 -0
  129. package/dist/web/assets/calendar-notice-DsCfq3VJ.js +1 -0
  130. package/dist/web/assets/calendar-plus-Cycfkk0y.js +1 -0
  131. package/dist/web/assets/calendar-setup-Pn61Wj1n.js +2 -0
  132. package/dist/web/assets/card-GSO5o-Ug.js +1 -0
  133. package/dist/web/assets/check-check-Ds1bPIGv.js +1 -0
  134. package/dist/web/assets/checkbox-B937U-Fd.js +1 -0
  135. package/dist/web/assets/chevron-right-Cqh4mDk1.js +1 -0
  136. package/dist/web/assets/chips-input-2xWDofmE.js +1 -0
  137. package/dist/web/assets/choice-cards-CgAIqBGX.js +1 -0
  138. package/dist/web/assets/circle-x-DcU-0zVh.js +1 -0
  139. package/dist/web/assets/command-khhto70y.js +1 -0
  140. package/dist/web/assets/common-BPZV8TFb.js +1 -0
  141. package/dist/web/assets/connect-agent-guide-CJdw31p2.js +1 -0
  142. package/dist/web/assets/connect-host-lr2c3xWh.js +6 -0
  143. package/dist/web/assets/connection-row-DYx_5o0c.js +1 -0
  144. package/dist/web/assets/connections-C4NOFb5R.js +1 -0
  145. package/dist/web/assets/data-table-kW1pkRB5.js +1 -0
  146. package/dist/web/assets/empty-DzTDioBP.js +1 -0
  147. package/dist/web/assets/events-C9zJnPKK.js +1 -0
  148. package/dist/web/assets/eye-B9gT1QSq.js +1 -0
  149. package/dist/web/assets/eye-off-Diktqz_g.js +1 -0
  150. package/dist/web/assets/field-C6ejurju.js +1 -0
  151. package/dist/web/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  152. package/dist/web/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  153. package/dist/web/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  154. package/dist/web/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  155. package/dist/web/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  156. package/dist/web/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  157. package/dist/web/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  158. package/dist/web/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  159. package/dist/web/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  160. package/dist/web/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  161. package/dist/web/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  162. package/dist/web/assets/get-started-C38bq1gZ.js +2 -0
  163. package/dist/web/assets/hidden-chars-Dr9TxfvF.js +1 -0
  164. package/dist/web/assets/index-DGzo3kGh.js +11 -0
  165. package/dist/web/assets/index-zKOCQLGX.css +2 -0
  166. package/dist/web/assets/input-CWwJgozn.js +1 -0
  167. package/dist/web/assets/input-group-HG8OXcJ4.js +1 -0
  168. package/dist/web/assets/label-multi-select-sH8mllw8.js +1 -0
  169. package/dist/web/assets/labels-B5UqVwFo.js +1 -0
  170. package/dist/web/assets/laptop-BxJ4Nbj7.js +1 -0
  171. package/dist/web/assets/last-call-D-O5O94z.js +1 -0
  172. package/dist/web/assets/listener-handled-HNCuVrQ7.js +1 -0
  173. package/dist/web/assets/login-DaeKclcf.js +1 -0
  174. package/dist/web/assets/mail-C5fm2pTC.js +1 -0
  175. package/dist/web/assets/mcp-call-UoOTPFyh.js +1 -0
  176. package/dist/web/assets/mcp-call-card-Cr-OEDX2.js +3 -0
  177. package/dist/web/assets/message-square-quote-se8Bf3RP.js +1 -0
  178. package/dist/web/assets/mutations-CLPqJ6cj.js +1 -0
  179. package/dist/web/assets/network-igNw0VVx.js +1 -0
  180. package/dist/web/assets/pairing-XjuaVr2u.js +1 -0
  181. package/dist/web/assets/pairing-notifier-KPzOEZSO.js +1 -0
  182. package/dist/web/assets/paperclip-znS4iTrg.js +1 -0
  183. package/dist/web/assets/password-input-DV3aqcNf.js +1 -0
  184. package/dist/web/assets/pause-o-dK3khK.js +1 -0
  185. package/dist/web/assets/pencil-DkPFAhEq.js +1 -0
  186. package/dist/web/assets/play-DvCHND93.js +1 -0
  187. package/dist/web/assets/plug-CXAniEiR.js +1 -0
  188. package/dist/web/assets/plug-zap-BwR-l5wb.js +1 -0
  189. package/dist/web/assets/plus-B_TUslu3.js +1 -0
  190. package/dist/web/assets/preload-helper-Cc_aKyUb.js +1 -0
  191. package/dist/web/assets/public-url-rF2Xjyel.js +1 -0
  192. package/dist/web/assets/queries-Cv3nXx0U.js +42 -0
  193. package/dist/web/assets/refresh-cw-Ch5FNrby.js +1 -0
  194. package/dist/web/assets/revealed-keys-C_9NtJPY.js +1 -0
  195. package/dist/web/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  196. package/dist/web/assets/rotate-ccw-B-mzPDk0.js +1 -0
  197. package/dist/web/assets/search-CTVKGomS.js +1 -0
  198. package/dist/web/assets/select-Bb806bpq.js +1 -0
  199. package/dist/web/assets/send-DuMVuYs1.js +1 -0
  200. package/dist/web/assets/settings-BWauhfFf.js +1 -0
  201. package/dist/web/assets/setup-B7bNKQOp.js +1 -0
  202. package/dist/web/assets/sheet-CYPwpUsH.js +1 -0
  203. package/dist/web/assets/shield-alert-CSYB-ThV.js +1 -0
  204. package/dist/web/assets/shield-check-BulpsYqK.js +1 -0
  205. package/dist/web/assets/sparkles-CUTiob4K.js +1 -0
  206. package/dist/web/assets/stamp-BNXUGNa3.js +1 -0
  207. package/dist/web/assets/step-url-51aMNak-.js +1 -0
  208. package/dist/web/assets/stream-Du7fY5_Y.js +1 -0
  209. package/dist/web/assets/tag-I5_-1jFL.js +1 -0
  210. package/dist/web/assets/terminal-BvxDFTJc.js +1 -0
  211. package/dist/web/assets/test-email-button-DYi7V_6X.js +1 -0
  212. package/dist/web/assets/toggle-group-Bdo1gmQ1.js +1 -0
  213. package/dist/web/assets/trash-Ci0aICEl.js +1 -0
  214. package/dist/web/assets/use-delivery-speed-BBh7W2q6.js +1 -0
  215. package/dist/web/assets/use-label-names-BXicesnP.js +1 -0
  216. package/dist/web/assets/use-sign-out-CPrY_oVO.js +1 -0
  217. package/dist/web/assets/useInfiniteQuery-DJLIM8yJ.js +1 -0
  218. package/dist/web/assets/useQuery-MrTwwNy5.js +1 -0
  219. package/dist/web/assets/useRouterState-D4OBK8l5.js +1 -0
  220. package/dist/web/assets/webhook-DjeeZiNC.js +1 -0
  221. package/dist/web/assets/webhook-bits-jw-y2D3u.js +1 -0
  222. package/dist/web/assets/webhook-sheet-B8h0skDe.js +34 -0
  223. package/dist/web/assets/webhooks-Cfsdpoyd.js +1 -0
  224. package/dist/web/assets/x-MF2TZ9aG.js +1 -0
  225. package/dist/web/favicon.svg +1 -0
  226. package/dist/web/index.html +27 -0
  227. package/package.json +98 -1
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ import { BlockList, isIP } from "node:net";
3
+ import { lookup } from "node:dns/promises";
4
+ //#region server/webhooks/ssrf.ts
5
+ var SsrfError = class extends Error {
6
+ kind;
7
+ hint;
8
+ constructor(kind, message, hint) {
9
+ super(message);
10
+ this.kind = kind;
11
+ this.hint = hint;
12
+ }
13
+ };
14
+ const defaultLookup = (hostname) => lookup(hostname, {
15
+ all: true,
16
+ verbatim: true
17
+ });
18
+ const BLOCKED = new BlockList();
19
+ for (const [net, prefix] of [
20
+ ["0.0.0.0", 8],
21
+ ["10.0.0.0", 8],
22
+ ["100.64.0.0", 10],
23
+ ["127.0.0.0", 8],
24
+ ["169.254.0.0", 16],
25
+ ["172.16.0.0", 12],
26
+ ["192.0.0.0", 24],
27
+ ["192.168.0.0", 16],
28
+ ["198.18.0.0", 15],
29
+ ["224.0.0.0", 4],
30
+ ["240.0.0.0", 4]
31
+ ]) BLOCKED.addSubnet(net, prefix, "ipv4");
32
+ for (const [net, prefix] of [
33
+ ["::", 128],
34
+ ["::1", 128],
35
+ ["100::", 64],
36
+ ["64:ff9b:1::", 48],
37
+ ["2001::", 32],
38
+ ["fc00::", 7],
39
+ ["fe80::", 10],
40
+ ["fec0::", 10],
41
+ ["ff00::", 8]
42
+ ]) BLOCKED.addSubnet(net, prefix, "ipv6");
43
+ const LOOPBACK = new BlockList();
44
+ LOOPBACK.addSubnet("127.0.0.0", 8, "ipv4");
45
+ LOOPBACK.addAddress("::1", "ipv6");
46
+ /** Cloud metadata endpoints: only an exact-IP allowlist entry unlocks these, never a CIDR or hostname. */
47
+ const METADATA = /* @__PURE__ */ new Set([
48
+ "169.254.169.254",
49
+ "fd00:ec2::254",
50
+ "169.254.170.2",
51
+ "100.100.100.200"
52
+ ]);
53
+ /**
54
+ * Canonical form for comparisons. IPv6 addresses that embed an IPv4 address become that IPv4
55
+ * address: IPv4-mapped ("::ffff:10.0.0.1", "::ffff:a00:1"), SIIT ::ffff:0:0:0/96, IPv4-compatible,
56
+ * NAT64 64:ff9b::/96 and 6to4 2002::/16. (Teredo and local-use NAT64 are blocked as whole ranges.)
57
+ */
58
+ function normalizeIp(ip) {
59
+ const bare = ip.replace(/^\[|\]$/g, "").replace(/%.*$/, "").toLowerCase();
60
+ if (isIP(bare) !== 6) return bare;
61
+ const g = expandV6(bare);
62
+ const zero = (from, to) => g.slice(from, to).every((x) => x === 0);
63
+ const mapped = zero(0, 5) && g[5] === 65535;
64
+ const compatible = zero(0, 6) && (g[6] > 0 || g[7] > 1);
65
+ const siit = zero(0, 4) && g[4] === 65535 && g[5] === 0;
66
+ const nat64 = g[0] === 100 && g[1] === 65435 && zero(2, 6);
67
+ const v4 = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
68
+ if (mapped || siit || compatible || nat64) return v4(g[6], g[7]);
69
+ if (g[0] === 8194) return v4(g[1], g[2]);
70
+ return new URL(`http://[${bare}]/`).hostname.slice(1, -1);
71
+ }
72
+ /** Eight 16-bit groups of a valid IPv6 address (dotted IPv4 tails included). */
73
+ function expandV6(ip) {
74
+ const parse = (part) => part ? part.split(":").flatMap((p) => {
75
+ if (!p.includes(".")) return [parseInt(p, 16)];
76
+ const [a, b, c, d] = p.split(".").map(Number);
77
+ return [a << 8 | b, c << 8 | d];
78
+ }) : [];
79
+ const [head, tail] = ip.includes("::") ? ip.split("::") : [ip, null];
80
+ const h = parse(head);
81
+ if (tail === null) return h;
82
+ const t = parse(tail);
83
+ return [
84
+ ...h,
85
+ ...Array(8 - h.length - t.length).fill(0),
86
+ ...t
87
+ ];
88
+ }
89
+ const family = (ip) => isIP(ip) === 6 ? "ipv6" : "ipv4";
90
+ function isBlockedAddress(ip) {
91
+ const n = normalizeIp(ip);
92
+ return BLOCKED.check(n, family(n)) || METADATA.has(n);
93
+ }
94
+ function compileAllowlist(entries) {
95
+ const out = {
96
+ hosts: [],
97
+ ips: /* @__PURE__ */ new Set(),
98
+ nets: new BlockList()
99
+ };
100
+ for (const raw of entries) {
101
+ const entry = raw.trim().toLowerCase();
102
+ if (!entry) continue;
103
+ const slash = entry.indexOf("/");
104
+ if (slash > 0) {
105
+ const net = normalizeIp(entry.slice(0, slash));
106
+ const prefix = Number(entry.slice(slash + 1));
107
+ const fam = isIP(net);
108
+ const adjusted = fam === 4 && isIP(entry.slice(0, slash).replace(/^\[|\]$/g, "")) === 6 ? prefix - 96 : prefix;
109
+ if (fam && Number.isInteger(adjusted) && adjusted >= 0 && adjusted <= (fam === 4 ? 32 : 128)) out.nets.addSubnet(net, adjusted, fam === 4 ? "ipv4" : "ipv6");
110
+ continue;
111
+ }
112
+ const ip = normalizeIp(entry);
113
+ if (isIP(ip)) out.ips.add(ip);
114
+ else out.hosts.push(entry.replace(/\.$/, ""));
115
+ }
116
+ return out;
117
+ }
118
+ /** Hostname entries match names only: an IP literal is never matched as text (e.g. by "*.0.0.1"). */
119
+ function hostAllowed(host, list) {
120
+ if (isIP(host)) return false;
121
+ const h = host.toLowerCase().replace(/\.$/, "");
122
+ return list.hosts.some((entry) => entry.startsWith("*.") ? h.endsWith(entry.slice(1)) : h === entry);
123
+ }
124
+ function ipAllowed(ip, list) {
125
+ return list.ips.has(ip) || list.nets.check(ip, family(ip));
126
+ }
127
+ const WEBHOOK_MESSAGES = {
128
+ subject: "Webhook URL",
129
+ allowHint: (host) => `To allow it, add "${host}" or a matching CIDR to the webhook allowlist in Settings.`,
130
+ httpHint: "Plain http is only allowed for loopback addresses and hosts on the webhook allowlist in Settings.",
131
+ hostHint: "Check the hostname in the webhook URL."
132
+ };
133
+ /** Hostname from a URL without IPv6 brackets. */
134
+ const urlHost = (url) => url.hostname.replace(/^\[|\]$/g, "");
135
+ /**
136
+ * Resolves the URL's host and returns the address to connect to. Throws SsrfError when the target
137
+ * is not allowed. Every resolved address must pass, so a mixed answer cannot slip a private one in.
138
+ */
139
+ async function resolveTarget(url, allowlist, lookup = defaultLookup, messages = WEBHOOK_MESSAGES) {
140
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new SsrfError("blocked", `${messages.subject} must use http or https`);
141
+ const host = urlHost(url);
142
+ const list = compileAllowlist(allowlist);
143
+ const hostListed = hostAllowed(host, list);
144
+ const literal = isIP(host) ? [{
145
+ address: host,
146
+ family: isIP(host)
147
+ }] : null;
148
+ let answers;
149
+ try {
150
+ answers = literal ?? await lookup(host);
151
+ } catch (err) {
152
+ const code = err.code;
153
+ if (code === "ENOTFOUND" || code === "ENODATA") throw new SsrfError("unresolved", `Could not resolve ${host}`, messages.hostHint);
154
+ throw new SsrfError("dns", `DNS lookup for ${host} failed: ${err.message}`);
155
+ }
156
+ if (answers.length === 0) throw new SsrfError("unresolved", `Could not resolve ${host}`, messages.hostHint);
157
+ for (const a of answers) {
158
+ const ip = normalizeIp(a.address);
159
+ if (METADATA.has(ip)) {
160
+ if (list.ips.has(ip)) continue;
161
+ throw new SsrfError("blocked", `${host} resolves to a cloud metadata address (${ip}), which is never allowed`);
162
+ }
163
+ if (!isBlockedAddress(ip) || hostListed || ipAllowed(ip, list)) continue;
164
+ throw new SsrfError("blocked", `${host === ip ? ip : `${host} resolves to ${ip}, which`} is a private, loopback or reserved address`, messages.allowHint(host));
165
+ }
166
+ if (url.protocol === "http:" && !hostListed) {
167
+ if (!answers.every((a) => {
168
+ const ip = normalizeIp(a.address);
169
+ return LOOPBACK.check(ip, family(ip)) || ipAllowed(ip, list);
170
+ })) throw new SsrfError("blocked", `${messages.subject}s must use https`, messages.httpHint);
171
+ }
172
+ const first = answers[0];
173
+ return {
174
+ address: first.address,
175
+ family: first.family === 6 ? 6 : 4
176
+ };
177
+ }
178
+ //#endregion
179
+ export { urlHost as a, resolveTarget as i, isBlockedAddress as n, normalizeIp as r, SsrfError as t };
180
+
181
+ //# sourceMappingURL=ssrf-BlVpGZ7C.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ssrf-BlVpGZ7C.js","names":["dnsLookup"],"sources":["../server/webhooks/ssrf.ts"],"sourcesContent":["import { lookup as dnsLookup } from \"node:dns/promises\";\nimport { BlockList, isIP } from \"node:net\";\n\n/**\n * SSRF guard for webhook targets. A target is refused when its host resolves to a non-public\n * address (private, loopback, link-local, unique-local, CGNAT, multicast, unspecified, reserved),\n * unless the host or the resolved address matches `Settings.webhookAllowlist` (hostnames,\n * \"*.suffix\" wildcards, IPs or CIDRs). Cloud metadata endpoints are refused unless their exact IP\n * is listed. Callers connect to the returned address (not the hostname) so a second DNS answer\n * cannot swap in a different target (DNS rebinding).\n */\n\nexport type LookupFn = (hostname: string) => Promise<{ address: string; family: number }[]>;\n\nexport interface ResolvedTarget {\n address: string;\n family: 4 | 6;\n}\n\nexport class SsrfError extends Error {\n constructor(\n /** \"dns\" is a transient lookup failure; the others are definitive. */\n readonly kind: \"blocked\" | \"unresolved\" | \"dns\",\n message: string,\n readonly hint?: string,\n ) {\n super(message);\n }\n}\n\nconst defaultLookup: LookupFn = (hostname) => dnsLookup(hostname, { all: true, verbatim: true });\n\nconst BLOCKED = new BlockList();\nfor (const [net, prefix] of [\n [\"0.0.0.0\", 8], // \"this\" network, unspecified\n [\"10.0.0.0\", 8],\n [\"100.64.0.0\", 10], // CGNAT\n [\"127.0.0.0\", 8],\n [\"169.254.0.0\", 16], // link-local, incl. cloud metadata\n [\"172.16.0.0\", 12],\n [\"192.0.0.0\", 24], // IETF protocol assignments\n [\"192.168.0.0\", 16],\n [\"198.18.0.0\", 15], // benchmarking\n [\"224.0.0.0\", 4], // multicast\n [\"240.0.0.0\", 4], // reserved + broadcast\n] as const) {\n BLOCKED.addSubnet(net, prefix, \"ipv4\");\n}\nfor (const [net, prefix] of [\n [\"::\", 128], // unspecified\n [\"::1\", 128],\n [\"100::\", 64], // discard\n [\"64:ff9b:1::\", 48], // local-use NAT64: the embedded IPv4 position depends on the operator's prefix\n [\"2001::\", 32], // Teredo tunnels to arbitrary IPv4 hosts\n [\"fc00::\", 7], // unique-local, incl. fd00:ec2::254\n [\"fe80::\", 10], // link-local\n [\"fec0::\", 10], // deprecated site-local\n [\"ff00::\", 8], // multicast\n] as const) {\n BLOCKED.addSubnet(net, prefix, \"ipv6\");\n}\n\nconst LOOPBACK = new BlockList();\nLOOPBACK.addSubnet(\"127.0.0.0\", 8, \"ipv4\");\nLOOPBACK.addAddress(\"::1\", \"ipv6\");\n\n/** Cloud metadata endpoints: only an exact-IP allowlist entry unlocks these, never a CIDR or hostname. */\nconst METADATA = new Set([\"169.254.169.254\", \"fd00:ec2::254\", \"169.254.170.2\", \"100.100.100.200\"]);\n\n/**\n * Canonical form for comparisons. IPv6 addresses that embed an IPv4 address become that IPv4\n * address: IPv4-mapped (\"::ffff:10.0.0.1\", \"::ffff:a00:1\"), SIIT ::ffff:0:0:0/96, IPv4-compatible,\n * NAT64 64:ff9b::/96 and 6to4 2002::/16. (Teredo and local-use NAT64 are blocked as whole ranges.)\n */\nexport function normalizeIp(ip: string): string {\n const bare = ip.replace(/^\\[|\\]$/g, \"\").replace(/%.*$/, \"\").toLowerCase();\n if (isIP(bare) !== 6) return bare;\n const g = expandV6(bare);\n const zero = (from: number, to: number) => g.slice(from, to).every((x) => x === 0);\n const mapped = zero(0, 5) && g[5] === 0xffff;\n const compatible = zero(0, 6) && (g[6]! > 0 || g[7]! > 1); // not \"::\" or \"::1\"\n const siit = zero(0, 4) && g[4] === 0xffff && g[5] === 0;\n const nat64 = g[0] === 0x64 && g[1] === 0xff9b && zero(2, 6);\n const v4 = (hi: number, lo: number) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;\n if (mapped || siit || compatible || nat64) return v4(g[6]!, g[7]!);\n if (g[0] === 0x2002) return v4(g[1]!, g[2]!);\n return new URL(`http://[${bare}]/`).hostname.slice(1, -1);\n}\n\n/** Eight 16-bit groups of a valid IPv6 address (dotted IPv4 tails included). */\nfunction expandV6(ip: string): number[] {\n const parse = (part: string): number[] =>\n part\n ? part.split(\":\").flatMap((p) => {\n if (!p.includes(\".\")) return [parseInt(p, 16)];\n const [a, b, c, d] = p.split(\".\").map(Number) as [number, number, number, number];\n return [(a << 8) | b, (c << 8) | d];\n })\n : [];\n const [head, tail] = ip.includes(\"::\") ? (ip.split(\"::\") as [string, string]) : [ip, null];\n const h = parse(head);\n if (tail === null) return h;\n const t = parse(tail);\n return [...h, ...Array<number>(8 - h.length - t.length).fill(0), ...t];\n}\n\nconst family = (ip: string): \"ipv4\" | \"ipv6\" => (isIP(ip) === 6 ? \"ipv6\" : \"ipv4\");\n\nexport function isBlockedAddress(ip: string): boolean {\n const n = normalizeIp(ip);\n return BLOCKED.check(n, family(n)) || METADATA.has(n);\n}\n\ninterface Allowlist {\n hosts: string[];\n ips: Set<string>;\n nets: BlockList;\n}\n\nfunction compileAllowlist(entries: readonly string[]): Allowlist {\n const out: Allowlist = { hosts: [], ips: new Set(), nets: new BlockList() };\n for (const raw of entries) {\n const entry = raw.trim().toLowerCase();\n if (!entry) continue;\n const slash = entry.indexOf(\"/\");\n if (slash > 0) {\n const net = normalizeIp(entry.slice(0, slash));\n const prefix = Number(entry.slice(slash + 1));\n const fam = isIP(net);\n // A mapped-IPv6 CIDR like ::ffff:10.0.0.0/104 becomes an IPv4 one.\n const adjusted = fam === 4 && isIP(entry.slice(0, slash).replace(/^\\[|\\]$/g, \"\")) === 6 ? prefix - 96 : prefix;\n if (fam && Number.isInteger(adjusted) && adjusted >= 0 && adjusted <= (fam === 4 ? 32 : 128)) {\n out.nets.addSubnet(net, adjusted, fam === 4 ? \"ipv4\" : \"ipv6\");\n }\n continue;\n }\n const ip = normalizeIp(entry);\n if (isIP(ip)) out.ips.add(ip);\n else out.hosts.push(entry.replace(/\\.$/, \"\"));\n }\n return out;\n}\n\n/** Hostname entries match names only: an IP literal is never matched as text (e.g. by \"*.0.0.1\"). */\nfunction hostAllowed(host: string, list: Allowlist): boolean {\n if (isIP(host)) return false;\n const h = host.toLowerCase().replace(/\\.$/, \"\");\n return list.hosts.some((entry) => (entry.startsWith(\"*.\") ? h.endsWith(entry.slice(1)) : h === entry));\n}\n\nfunction ipAllowed(ip: string, list: Allowlist): boolean {\n return list.ips.has(ip) || list.nets.check(ip, family(ip));\n}\n\n/** Wording of resolveTarget's errors, so other callers (the MCP broker's upstream fetch) name their own setting. */\nexport interface TargetMessages {\n /** What the URL is, e.g. \"Webhook URL\". */\n subject: string;\n /** How to allow a private host, e.g. \"add it to the webhook allowlist in Settings\". */\n allowHint: (host: string) => string;\n /** Where plain http is allowed. */\n httpHint: string;\n /** Where the hostname is configured. */\n hostHint: string;\n}\n\nconst WEBHOOK_MESSAGES: TargetMessages = {\n subject: \"Webhook URL\",\n allowHint: (host) => `To allow it, add \"${host}\" or a matching CIDR to the webhook allowlist in Settings.`,\n httpHint: \"Plain http is only allowed for loopback addresses and hosts on the webhook allowlist in Settings.\",\n hostHint: \"Check the hostname in the webhook URL.\",\n};\n\n/** Hostname from a URL without IPv6 brackets. */\nexport const urlHost = (url: URL): string => url.hostname.replace(/^\\[|\\]$/g, \"\");\n\n/**\n * Resolves the URL's host and returns the address to connect to. Throws SsrfError when the target\n * is not allowed. Every resolved address must pass, so a mixed answer cannot slip a private one in.\n */\nexport async function resolveTarget(\n url: URL,\n allowlist: readonly string[],\n lookup: LookupFn = defaultLookup,\n messages: TargetMessages = WEBHOOK_MESSAGES,\n): Promise<ResolvedTarget> {\n if (url.protocol !== \"http:\" && url.protocol !== \"https:\") throw new SsrfError(\"blocked\", `${messages.subject} must use http or https`);\n const host = urlHost(url);\n const list = compileAllowlist(allowlist);\n const hostListed = hostAllowed(host, list);\n const literal = isIP(host) ? [{ address: host, family: isIP(host) }] : null;\n let answers: { address: string; family: number }[];\n try {\n answers = literal ?? (await lookup(host));\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if (code === \"ENOTFOUND\" || code === \"ENODATA\") throw new SsrfError(\"unresolved\", `Could not resolve ${host}`, messages.hostHint);\n throw new SsrfError(\"dns\", `DNS lookup for ${host} failed: ${(err as Error).message}`);\n }\n if (answers.length === 0) throw new SsrfError(\"unresolved\", `Could not resolve ${host}`, messages.hostHint);\n for (const a of answers) {\n const ip = normalizeIp(a.address);\n if (METADATA.has(ip)) {\n if (list.ips.has(ip)) continue;\n throw new SsrfError(\"blocked\", `${host} resolves to a cloud metadata address (${ip}), which is never allowed`);\n }\n if (!isBlockedAddress(ip) || hostListed || ipAllowed(ip, list)) continue;\n throw new SsrfError(\n \"blocked\",\n `${host === ip ? ip : `${host} resolves to ${ip}, which`} is a private, loopback or reserved address`,\n messages.allowHint(host),\n );\n }\n // Plain http only where cleartext stays local or was explicitly allowed: mail content travels in it.\n if (url.protocol === \"http:\" && !hostListed) {\n const local = answers.every((a) => {\n const ip = normalizeIp(a.address);\n return LOOPBACK.check(ip, family(ip)) || ipAllowed(ip, list);\n });\n if (!local) {\n throw new SsrfError(\"blocked\", `${messages.subject}s must use https`, messages.httpHint);\n }\n }\n const first = answers[0]!;\n return { address: first.address, family: first.family === 6 ? 6 : 4 };\n}\n"],"mappings":"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"}
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ //#region shared/types.ts
3
+ /** The npm package (`npx xaq-cli`); the command it installs is `xaq`. */
4
+ const NPM_PACKAGE = "xaq-cli";
5
+ /** What an agent may do with a mailbox. `read` also grants search, labels, attachments and the event feed. */
6
+ const MAIL_CAPABILITIES = [
7
+ "read",
8
+ "draft",
9
+ "send",
10
+ "organize",
11
+ "trash"
12
+ ];
13
+ /**
14
+ * What an agent may do with the account's Google Calendar (see docs/calendar.md). Independent of the mail
15
+ * capabilities: existing grants have none of these (mail-only).
16
+ * - calendar.read: list calendars, list/search/get events, free/busy, and calendar.* events in the feed.
17
+ * - calendar.write: create, update and delete events and respond to invitations. How a write executes is
18
+ * Grant.calendarWriteMode. The console shows "no calendar.write" as write mode "None".
19
+ */
20
+ const CALENDAR_CAPABILITIES = ["calendar.read", "calendar.write"];
21
+ const CAPABILITIES = [...MAIL_CAPABILITIES, ...CALENDAR_CAPABILITIES];
22
+ const isCalendarCapability = (c) => CALENDAR_CAPABILITIES.includes(c);
23
+ /**
24
+ * How `send`/`reply`/`forward` behave for a grant.
25
+ * - direct: sent immediately (still subject to allowlist + rate limits)
26
+ * - draft_only: a Gmail draft is created instead; a human sends it from Gmail
27
+ * - approval: a draft is created and an approval request is queued in the console;
28
+ * approving sends the draft, rejecting deletes it
29
+ */
30
+ const SEND_MODES = [
31
+ "direct",
32
+ "draft_only",
33
+ "approval"
34
+ ];
35
+ /**
36
+ * How calendar writes (create/update/delete event, RSVP) execute for a grant that has `calendar.write`.
37
+ * - direct: applied to Google Calendar at once (attendee allowlist and send caps still apply)
38
+ * - approval: nothing touches Google until a human approves it in the console; approving applies the
39
+ * stored change, rejecting discards it. Default for new grants.
40
+ */
41
+ const CALENDAR_WRITE_MODES = ["direct", "approval"];
42
+ /** Prefix of Account.lastError when the account's Gmail watch (Pub/Sub push) couldn't be started or renewed. */
43
+ const GMAIL_WATCH_ERROR = "Gmail watch failed";
44
+ const EVENT_TYPES = [
45
+ "message.received",
46
+ "message.sent",
47
+ "thread.reply_received",
48
+ "label.added",
49
+ "label.removed",
50
+ "account.auth_failed",
51
+ "sync.gap",
52
+ "calendar.event_created",
53
+ "calendar.event_updated",
54
+ "calendar.event_cancelled",
55
+ "calendar.invitation_received"
56
+ ];
57
+ /**
58
+ * MCP broker events (docs/MCP-BROKER.md "Events from integrations"). They concern a connection, not an
59
+ * account: MailEvent.connectionId is set and accountId/accountEmail are "". Visible to agents with a grant
60
+ * on that connection, while the brokerUpstreams setting is on.
61
+ */
62
+ const BROKER_EVENT_TYPES = [
63
+ "connection.auth_failed",
64
+ "connection.tool_changed",
65
+ "connection.expiring",
66
+ "approval.requested"
67
+ ];
68
+ const PRESET_EVENT_RE = /^([a-z0-9-]{1,24})\.[a-z0-9_]{1,64}$/;
69
+ /** Prefixes of xaq's own events: a preset can't be named like one. */
70
+ const RESERVED_EVENT_PREFIXES = /* @__PURE__ */ new Set([
71
+ "message",
72
+ "thread",
73
+ "label",
74
+ "account",
75
+ "sync",
76
+ "calendar",
77
+ "connection",
78
+ "approval"
79
+ ]);
80
+ const isPresetEventType = (t) => {
81
+ const m = PRESET_EVENT_RE.exec(t);
82
+ return !!m && !RESERVED_EVENT_PREFIXES.has(m[1]);
83
+ };
84
+ /** Events about a connection (broker or preset), never an account. */
85
+ const isConnectionEventType = (t) => BROKER_EVENT_TYPES.includes(t) || isPresetEventType(t);
86
+ /** Any event type the server accepts in filters (webhooks, the listener stream, the console). */
87
+ const isEventType = (t) => EVENT_TYPES.includes(t) || isConnectionEventType(t);
88
+ const CALENDAR_EVENT_TYPES = [
89
+ "calendar.event_created",
90
+ "calendar.event_updated",
91
+ "calendar.event_cancelled",
92
+ "calendar.invitation_received"
93
+ ];
94
+ const isCalendarEventType = (t) => CALENDAR_EVENT_TYPES.includes(t);
95
+ /** "pubsub": the Pub/Sub scope for the provisioning account. "calendar": CALENDAR_SCOPES. */
96
+ const EXTRA_SCOPE_NAMES = ["pubsub", "calendar"];
97
+ const MAIL_APPROVAL_KINDS = [
98
+ "send",
99
+ "reply",
100
+ "forward"
101
+ ];
102
+ /** Calendar writes queued by calendarWriteMode "approval". Approving applies the stored change; rejecting discards it. */
103
+ const CALENDAR_APPROVAL_KINDS = [
104
+ "calendar.create",
105
+ "calendar.update",
106
+ "calendar.delete",
107
+ "calendar.rsvp"
108
+ ];
109
+ const isCalendarApprovalKind = (k) => CALENDAR_APPROVAL_KINDS.includes(k);
110
+ /** native_whoop (M2), stdio (M4) and native_google are reserved; M1 creates remote_http only. */
111
+ const CONNECTION_KINDS = [
112
+ "remote_http",
113
+ "native_whoop",
114
+ "stdio",
115
+ "native_google"
116
+ ];
117
+ /**
118
+ * What an agent may do with a connection (Wynn's model). No grant row = no access.
119
+ * - full: every tool listed and called without asking
120
+ * - ask: every tool listed; risky (irreversible) tools wait for an mcp.call approval. The default.
121
+ * - read: only read tools are listed or callable
122
+ */
123
+ const ACCESS_LEVELS = [
124
+ "full",
125
+ "ask",
126
+ "read"
127
+ ];
128
+ /** Connection slugs, chosen by the operator: tools are exposed as `<slug>__<tool>`. Same rule as the schema CHECK. */
129
+ const CONNECTION_SLUG_RE = /^[a-z0-9-]{1,24}$/;
130
+ /** Exposed tool names: `[A-Za-z0-9_-]`, at most 64 (clients prepend `mcp__<server>__` and cap at 128). */
131
+ const EXPOSED_TOOL_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
132
+ //#endregion
133
+ export { isConnectionEventType as _, CONNECTION_KINDS as a, EXPOSED_TOOL_NAME_RE as c, MAIL_APPROVAL_KINDS as d, NPM_PACKAGE as f, isCalendarEventType as g, isCalendarCapability as h, CAPABILITIES as i, EXTRA_SCOPE_NAMES as l, isCalendarApprovalKind as m, BROKER_EVENT_TYPES as n, CONNECTION_SLUG_RE as o, SEND_MODES as p, CALENDAR_WRITE_MODES as r, EVENT_TYPES as s, ACCESS_LEVELS as t, GMAIL_WATCH_ERROR as u, isEventType as v, isPresetEventType as y };
134
+
135
+ //# sourceMappingURL=types-BhJDbSzE.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types-BhJDbSzE.js","names":[],"sources":["../shared/types.ts"],"sourcesContent":["// Shared contract between the server (server/) and the web console (src/).\n// Everything crossing the HTTP boundary of the console API (/api/*) is typed here.\n// Timestamps are ISO-8601 strings in UTC. IDs are prefixed opaque strings (e.g. \"agt_…\").\n\n/** The npm package (`npx xaq-cli`); the command it installs is `xaq`. */\nexport const NPM_PACKAGE = \"xaq-cli\";\n/** The command the package installs (`npm i -g xaq-cli`, or the copy a service runs). */\nexport const CLI_NAME = \"xaq\";\n\n// ───────────────────────────── Domain enums ─────────────────────────────\n\n/** What an agent may do with a mailbox. `read` also grants search, labels, attachments and the event feed. */\nexport const MAIL_CAPABILITIES = [\"read\", \"draft\", \"send\", \"organize\", \"trash\"] as const;\n/**\n * What an agent may do with the account's Google Calendar (see docs/calendar.md). Independent of the mail\n * capabilities: existing grants have none of these (mail-only).\n * - calendar.read: list calendars, list/search/get events, free/busy, and calendar.* events in the feed.\n * - calendar.write: create, update and delete events and respond to invitations. How a write executes is\n * Grant.calendarWriteMode. The console shows \"no calendar.write\" as write mode \"None\".\n */\nexport const CALENDAR_CAPABILITIES = [\"calendar.read\", \"calendar.write\"] as const;\nexport const CAPABILITIES = [...MAIL_CAPABILITIES, ...CALENDAR_CAPABILITIES] as const;\nexport type Capability = (typeof CAPABILITIES)[number];\nexport type CalendarCapability = (typeof CALENDAR_CAPABILITIES)[number];\nexport const isCalendarCapability = (c: string): c is CalendarCapability => (CALENDAR_CAPABILITIES as readonly string[]).includes(c);\n\n/**\n * How `send`/`reply`/`forward` behave for a grant.\n * - direct: sent immediately (still subject to allowlist + rate limits)\n * - draft_only: a Gmail draft is created instead; a human sends it from Gmail\n * - approval: a draft is created and an approval request is queued in the console;\n * approving sends the draft, rejecting deletes it\n */\nexport const SEND_MODES = [\"direct\", \"draft_only\", \"approval\"] as const;\nexport type SendMode = (typeof SEND_MODES)[number];\n\n/**\n * How calendar writes (create/update/delete event, RSVP) execute for a grant that has `calendar.write`.\n * - direct: applied to Google Calendar at once (attendee allowlist and send caps still apply)\n * - approval: nothing touches Google until a human approves it in the console; approving applies the\n * stored change, rejecting discards it. Default for new grants.\n */\nexport const CALENDAR_WRITE_MODES = [\"direct\", \"approval\"] as const;\nexport type CalendarWriteMode = (typeof CALENDAR_WRITE_MODES)[number];\n\n/** Prefix of Account.lastError when the account's Gmail watch (Pub/Sub push) couldn't be started or renewed. */\nexport const GMAIL_WATCH_ERROR = \"Gmail watch failed\";\n\n/**\n * Whether an audit action is an MCP tool call: tool names never contain a \".\" (send_message), while\n * console and pairing actions do (agent.create, pairing.key_delivered). The SQL form of this rule is\n * mcpToolCallSql in server/audit.ts; keep the two in step. Any outcome: calls rejected before a tool\n * ran (unknown tool, invalid arguments) are tool calls with outcome \"error\"; proof that an agent\n * works needs outcome \"ok\".\n */\nexport const isMcpToolAction = (action: string): boolean => !action.includes(\".\");\n\nexport const EVENT_TYPES = [\n \"message.received\", // new message landed in the mailbox (not sent by the account; except the console's setup test email)\n \"message.sent\", // message sent from the account (by an agent or by a human)\n \"thread.reply_received\", // received message in a thread the account previously sent into\n \"label.added\",\n \"label.removed\",\n \"account.auth_failed\", // refresh token revoked/expired; account needs re-auth\n \"sync.gap\", // history was too old; a bounded resync ran and events may be missing\n // Google Calendar (MailEvent.calendarEvent is set, message is null). Visible with calendar.read + calendar scope.\n \"calendar.event_created\", // a new event appeared on a synced calendar (not an invitation)\n \"calendar.event_updated\", // a material change (detail.changes) to a known event\n \"calendar.event_cancelled\", // the event was cancelled or deleted\n \"calendar.invitation_received\", // someone else's event now lists the account as an attendee awaiting a response\n] as const;\n/** Gmail, Calendar and account events: the closed set that existed before the broker. */\nexport type NativeEventType = (typeof EVENT_TYPES)[number];\n\n/**\n * MCP broker events (docs/MCP-BROKER.md \"Events from integrations\"). They concern a connection, not an\n * account: MailEvent.connectionId is set and accountId/accountEmail are \"\". Visible to agents with a grant\n * on that connection, while the brokerUpstreams setting is on.\n */\nexport const BROKER_EVENT_TYPES = [\n \"connection.auth_failed\", // the upstream's credential stopped working; the owner must reconnect\n \"connection.tool_changed\", // an upstream tool changed or appeared and is held until the owner accepts it\n \"connection.expiring\", // the credential stops working on a known date (no refresh, or a cap): reconnect before it\n \"approval.requested\", // a risky upstream tool call waits for a human (detail.approvalUrl: the one-tap page)\n] as const;\nexport type BrokerEventType = (typeof BROKER_EVENT_TYPES)[number];\n/**\n * Events a native integration emits: `<preset>.<name>`, e.g. `whoop.recovery_updated` (IntegrationPreset.id and\n * NativeEvents.types). Open-ended, so EventType as a whole is too: code that needs the known set uses\n * EVENT_TYPES and BROKER_EVENT_TYPES.\n */\nexport type PresetEventType = `${string}.${string}`;\nexport type EventType = NativeEventType | BrokerEventType | PresetEventType;\n\nconst PRESET_EVENT_RE = /^([a-z0-9-]{1,24})\\.[a-z0-9_]{1,64}$/;\n/** Prefixes of xaq's own events: a preset can't be named like one. */\nconst RESERVED_EVENT_PREFIXES = new Set([\"message\", \"thread\", \"label\", \"account\", \"sync\", \"calendar\", \"connection\", \"approval\"]);\nexport const isPresetEventType = (t: string): t is PresetEventType => {\n const m = PRESET_EVENT_RE.exec(t);\n return !!m && !RESERVED_EVENT_PREFIXES.has(m[1]!);\n};\n/** Events about a connection (broker or preset), never an account. */\nexport const isConnectionEventType = (t: string): t is BrokerEventType | PresetEventType =>\n (BROKER_EVENT_TYPES as readonly string[]).includes(t) || isPresetEventType(t);\n/** Any event type the server accepts in filters (webhooks, the listener stream, the console). */\nexport const isEventType = (t: string): t is EventType => (EVENT_TYPES as readonly string[]).includes(t) || isConnectionEventType(t);\n\nexport const CALENDAR_EVENT_TYPES = [\n \"calendar.event_created\",\n \"calendar.event_updated\",\n \"calendar.event_cancelled\",\n \"calendar.invitation_received\",\n] as const satisfies readonly EventType[];\nexport type CalendarEventType = (typeof CALENDAR_EVENT_TYPES)[number];\nexport const isCalendarEventType = (t: string): t is CalendarEventType => (CALENDAR_EVENT_TYPES as readonly string[]).includes(t);\n\nexport type AccountStatus = \"active\" | \"needs_reauth\" | \"paused\" | \"error\";\nexport type IngestMode = \"pubsub\" | \"poll\";\nexport type DeliveryStatus = \"pending\" | \"succeeded\" | \"failed\" | \"dead\";\nexport type ApprovalStatus = \"pending\" | \"approved\" | \"rejected\" | \"sent\" | \"failed\" | \"expired\";\nexport type AuditOutcome = \"ok\" | \"denied\" | \"error\";\nexport type ActorType = \"agent\" | \"admin\" | \"system\";\n\n/** OAuth consent configuration the operator chose in Google Cloud. Drives setup guidance only. */\nexport type GoogleAppType = \"internal\" | \"external\";\n\n// ───────────────────────────── Setup / system ─────────────────────────────\n\nexport interface HealthResponse {\n status: \"ok\" | \"degraded\";\n version: string;\n setupComplete: boolean;\n}\n\n/** GET /api/setup/status (unauthenticated). Drives the console's first route decision: claim, login or app. */\nexport interface SetupStatus {\n claimed: boolean;\n /** The request carries a valid admin session. */\n authenticated: boolean;\n}\n\n/** Live state of the ingest pipeline (Pub/Sub pull loop or history polling). */\nexport interface IngestStatus {\n mode: IngestMode;\n running: boolean;\n lastPullAt: string | null;\n lastError: string | null;\n}\n\n/** GET /api/system: facts about this instance for the settings page and connect snippets. */\nexport interface SystemInfo {\n version: string;\n nodeVersion: string;\n dataDir: string;\n /** Running under `xaq service install` (systemd/launchd). */\n service: boolean;\n /** URLs this machine is reachable at (localhost first, then LAN addresses). */\n addresses: string[];\n publicUrl: string;\n ingest: IngestStatus;\n /** Installs this instance as a service with the flags it runs with (`… service install --port 9000`), as the startup banner prints it. */\n serviceCommand: string;\n}\n\n/** GET /api/system/latest: update notice. */\nexport interface UpdateInfo {\n current: string;\n latest: string | null;\n updateAvailable: boolean;\n /** Shell command that runs (or reinstalls the service with) the latest version using this instance's options. */\n command: string;\n}\n\n/** Ordered setup checklist; the console's setup wizard and overview both render this. */\nexport interface SetupState {\n /** True once an admin password exists. Before that, only /api/setup/claim with the setup token works. */\n claimed: boolean;\n googleClientConfigured: boolean;\n /** Google project id (needed for Pub/Sub). */\n gcpProjectId: string | null;\n googleAppType: GoogleAppType | null;\n pubsub: PubsubState;\n accountsConnected: number;\n agentsCreated: number;\n /** Some agent is known to work: its tool called /mcp (Agent.lastMcpAt), or a listener acked an event. */\n verified: boolean;\n /** All required steps done (claimed + client configured + ≥1 account). Pub/Sub is optional (poll fallback). */\n complete: boolean;\n /**\n * Problems with the setup as configured, most severe first (errors, then warnings, then info).\n * Recomputed on every GET /api/setup/state; empty when everything looks right. See SetupWarning.\n */\n warnings: SetupWarning[];\n}\n\n/**\n * - testing_mode: an account's refresh token was issued with a ~7-day lifetime (External app still in\n * Testing). One per affected account (accountId set); `url` opens the project's Audience page.\n * - sign_in_expires: an account's refresh token expires for another reason (any other lifetime, e.g.\n * time-based access the user chose on Google's consent screen). Neutral: reconnect before the date.\n * One per account (accountId set). Both are \"error\" within 24 h of the expiry or after it.\n * - app_type_mismatch: the app type saved here contradicts the connected accounts. \"warning\" when it is\n * \"internal\" but a personal (non-Workspace) Google account is connected (so the app is really External); \"info\" when it is\n * \"external\" and every account is a Workspace account of one domain (Internal would avoid the 7-day\n * Testing expiry and verification entirely).\n * - redirect_uri_missing: Google said the redirect URI isn't registered (GoogleClientCheck), or, when the\n * check couldn't tell, the imported client JSON doesn't list it. Clears on a check or re-import that\n * finds it, or once an account connects through the current redirect URI. Severity \"error\".\n * - redirect_uri_changed: the redirect URI changed (a public URL change) since the OAuth client was saved or\n * imported, and nothing has shown the new one is registered yet. Names both URIs. Clears on a client check\n * that finds it, a re-save/re-import of the client, or a connection through it. Severity \"error\".\n * - client_invalid: the last GoogleClientCheck found Google doesn't know the client ID. \"error\".\n * - client_secret_invalid: the last GoogleClientCheck found Google rejects the secret. \"error\".\n * - needs_reauth: an account's sign-in was rejected; one per account (accountId set). Severity \"error\".\n * - pubsub_failing: ingest mode is pubsub and Pub/Sub has an error (hint carries the remediation). \"error\".\n * - calendar_not_added: Google Calendar isn't added to this install yet (existing installs after the upgrade).\n * \"info\"; `url` is unset, the console links its \"Add Google Calendar\" flow. Gone once added or dismissed.\n * - calendar_api_disabled: Google said the Calendar API is disabled in the project (SERVICE_DISABLED). \"error\"\n * when some grant has a calendar capability, else \"warning\". `url` = the enable-API deep link.\n * - calendar_scope_missing: Calendar is added to this install but this account hasn't granted the calendar\n * scopes (Account.calendar.missingScopes). One per account. \"info\" (\"error\" when a grant on the account\n * has a calendar capability). Fixed by the per-account incremental consent.\n * - calendar_sync_failing: calendar sync for this account keeps failing (Account.calendar.error). \"error\".\n */\nexport type SetupWarningCode =\n | \"testing_mode\"\n | \"sign_in_expires\"\n | \"client_invalid\"\n | \"client_secret_invalid\"\n | \"app_type_mismatch\"\n | \"redirect_uri_missing\"\n | \"redirect_uri_changed\"\n | \"needs_reauth\"\n | \"pubsub_failing\"\n | \"calendar_not_added\"\n | \"calendar_api_disabled\"\n | \"calendar_scope_missing\"\n | \"calendar_sync_failing\";\n\nexport interface SetupWarning {\n code: SetupWarningCode;\n /** \"error\": something is broken or will break; \"warning\": will cause trouble later; \"info\": a suggestion. */\n severity: \"error\" | \"warning\" | \"info\";\n /** One sentence: what is wrong (names the account / date / URI when relevant). */\n message: string;\n /** What to do about it, with Google Cloud console click paths. */\n hint: string;\n /**\n * Deep link for the fix (a Google Cloud console page, project preselected), when there is one. The\n * hint always also names the menu path (e.g. \"Google Auth Platform → Audience\") in case the link\n * lands elsewhere: show both.\n */\n url?: string;\n /** Set for per-account warnings (testing_mode, sign_in_expires, needs_reauth, app_type_mismatch for a personal account). */\n accountId?: string;\n}\n\nexport interface PubsubState {\n mode: IngestMode;\n topic: string | null; // projects/{p}/topics/{t}\n subscription: string | null; // projects/{p}/subscriptions/{s}\n /** Last provisioning/health error, human readable, with remediation hint. */\n lastError: string | null;\n /** Last time a Pub/Sub message was pulled (proves the pipeline works end-to-end). */\n lastMessageAt: string | null;\n /**\n * The connected account whose Google identity pulls the subscription (\"Pub/Sub is set up with\n * <principalEmail>\"): the one the last successful provisioning used. Before any provisioning (or if it\n * became inactive), the fallback: first active account with the Pub/Sub scope, else first active account.\n * null when no active account exists.\n */\n principalAccountId: string | null;\n principalEmail: string | null;\n /**\n * An account chosen with POST /api/google/pubsub/provision {accountId} whose provisioning hasn't\n * succeeded yet (\"Switching to <pendingPrincipalEmail>…\"). Provisioning and the incremental Pub/Sub\n * consent (POST /api/accounts/connect {scopes:[\"pubsub\"]}) use it; it replaces the principal only once\n * provisioning (and its watch) succeed, so a failed switch never disturbs a working setup. null when none.\n */\n pendingPrincipalAccountId: string | null;\n pendingPrincipalEmail: string | null;\n}\n\nexport interface Settings {\n /**\n * How often calendars are polled for changes (incremental sync with syncToken), in seconds. With a\n * working push channel (public https publicUrl) polling slows to a 15-minute safety net. Default 60.\n */\n calendarPollSeconds: number;\n /** Externally reachable base URL of this instance, e.g. https://mail.example.com. Used in MCP snippets + OAuth redirect. */\n publicUrl: string;\n ingestMode: IngestMode;\n pollIntervalSeconds: number;\n /** Max characters of message body returned to agents per message (0 = unlimited). */\n maxBodyChars: number;\n /** Days to keep events, deliveries and audit entries. */\n retentionDays: number;\n /** Approval requests auto-expire after this many hours. */\n approvalTtlHours: number;\n /**\n * SSRF guard: webhook targets that resolve to private, loopback or link-local addresses are refused\n * unless their host matches one of these entries (hostnames, IPs or CIDRs, e.g. \"127.0.0.0/8\",\n * \"10.0.0.0/8\", \"n8n.internal\"). Defaults to loopback only.\n */\n webhookAllowlist: string[];\n /** Accept device-flow pairing requests from `npx xaq-cli connect` (each still needs approval). */\n pairingEnabled: boolean;\n /**\n * The MCP broker's upstream connections (docs/MCP-BROKER.md): the Connections page, connection access on agents,\n * `mcp.call` approvals and first run with Google optional. On by default since the console shipped (M1 PR 8).\n */\n brokerUpstreams: boolean;\n}\n\nexport interface GoogleClientConfig {\n clientId: string;\n /** Write-only: never returned by the API. */\n clientSecret?: string;\n projectId: string;\n appType: GoogleAppType;\n}\n\n/**\n * POST /api/google/client/import: the client-secret JSON downloaded from Google Cloud\n * (Google Auth Platform → Clients → your client → Download JSON), pasted or read from the file as text.\n * Only Web application clients (`{\"web\": {...}}`) are accepted.\n */\nexport interface GoogleClientImportInput {\n /** The file's contents, verbatim. */\n json: string;\n /** Omit to keep the app type saved before (null when there was none). */\n appType?: GoogleAppType;\n}\n\n/**\n * POST /api/google/client/check (also run automatically by PUT /api/google/client and\n * POST /api/google/client/import): asks Google whether the saved client works, so problems show up\n * before connecting (Google reports them on its own page and never redirects back).\n * Undocumented Google behavior, classified conservatively: \"unknown\" means \"couldn't tell\", not \"bad\".\n * Each probe times out after 5 s. Results are kept for GET /api/setup/state warnings until the client is\n * saved again or an account connects (which proves everything).\n */\nexport interface GoogleClientCheck {\n /** \"invalid\": Google doesn't know this client ID (deleted, mistyped, other project). */\n clientId: \"ok\" | \"invalid\" | \"unknown\";\n /** \"missing\": GoogleClientView.redirectUri isn't an Authorized redirect URI of the client. */\n redirectUri: \"ok\" | \"missing\" | \"unknown\";\n /** \"invalid\": Google rejected the secret. \"unknown\" whenever clientId is \"invalid\". */\n secret: \"ok\" | \"invalid\" | \"unknown\";\n /** client_invalid | client_secret_invalid | redirect_uri_missing (all severity \"error\"). */\n warnings: SetupWarning[];\n}\n\n/** Returned by PUT /api/google/client: the saved client plus the automatic check. */\nexport interface GoogleClientSaveResult extends GoogleClientView {\n /** Same as `check.warnings` (for import also the JSON's redirect URI list when the check couldn't tell). */\n warnings: SetupWarning[];\n check: GoogleClientCheck;\n}\n\n/**\n * Result of POST /api/google/client/import. The client is saved whenever the ID and secret are present;\n * problems that don't prevent saving come back as `warnings` (client_invalid, client_secret_invalid,\n * redirect_uri_missing: all severity \"error\", connecting will fail until fixed).\n *\n * Errors (400, ApiError.code):\n * - client_json_invalid: not JSON, or not a Google OAuth client file, or a malformed client ID\n * - client_json_desktop: an \"installed\" (Desktop app) client; a Web application client is needed\n * - client_secret_missing: the file has no client_secret and none is stored for this client ID\n * - bad_request: invalid project_id (same validation as PUT /api/google/client)\n */\nexport interface GoogleClientImportResult extends GoogleClientSaveResult {\n /** The redirect URIs listed in the imported JSON (what Google had registered when it was downloaded). */\n registeredRedirectUris: string[];\n}\n\n/** Returned by GET /api/google/client. */\nexport interface GoogleClientView {\n clientId: string | null;\n hasSecret: boolean;\n projectId: string | null;\n appType: GoogleAppType | null;\n /** The exact redirect URI the operator must register on the OAuth client. */\n redirectUri: string;\n /**\n * \"direct\": Google redirects straight back to this console (localhost or https publicUrl).\n * \"paste\": publicUrl is plain-http non-localhost; Google can't redirect here, so the console\n * asks the user to paste the URL they land on after consent.\n */\n callbackMode: \"direct\" | \"paste\";\n /** Last 4 characters of the saved secret (to recognize which one is saved), or null without a secret. */\n secretHint: string | null;\n /**\n * There is evidence `redirectUri` is registered on the saved client: an account connected through it,\n * a client check found it, or the imported client JSON lists it. False without a client.\n */\n redirectUriConfirmed: boolean;\n}\n\n// ───────────────────────────── Accounts ─────────────────────────────\n\nexport interface Account {\n id: string; // acc_…\n email: string;\n displayName: string | null;\n status: AccountStatus;\n lastError: string | null;\n historyId: string | null;\n watchExpiresAt: string | null;\n lastSyncAt: string | null;\n createdAt: string;\n /**\n * When Google said the refresh token expires (token response `refresh_token_expires_in`), or null for a\n * long-lived token. The account needs reconnecting before this time.\n */\n refreshTokenExpiresAt: string | null;\n /**\n * Why it expires (null when refreshTokenExpiresAt is null, or unknown): \"testing\" = issued with the ~7-day\n * Testing lifetime (External app not published; publish it, then reconnect); \"time_based\" = any other\n * lifetime (e.g. time-based access chosen on the consent screen).\n */\n refreshTokenExpiryKind: \"testing\" | \"time_based\" | null;\n /** Google Workspace domain (id_token `hd` claim), or null for a personal (non-Workspace) Google account (or not yet known). */\n workspaceDomain: string | null;\n /**\n * OAuth scopes Google says this account granted (token response `scope`, refreshed on every token\n * refresh), full URLs. null when unknown.\n */\n grantedScopes: string[] | null;\n /**\n * Google Calendar state of this account. null when the account has granted none of the calendar scopes\n * and nothing calendar-related was recorded for it (a mail-only account). Whether Calendar is wanted\n * for it is install-wide: see CalendarSetupState (its `accounts` list covers every account).\n */\n calendar: AccountCalendar | null;\n}\n\n/**\n * - not_granted: some CALENDAR_SCOPES are missing (missingScopes); mail is unaffected. Fix: per-account consent.\n * - api_disabled: Google answered SERVICE_DISABLED: the Calendar API is off in the Cloud project.\n * - active: scopes granted and the last probe/sync worked (or nothing has run yet).\n * - error: the last sync failed for another reason (error says why); retried on the next poll.\n */\nexport type AccountCalendarStatus = \"not_granted\" | \"api_disabled\" | \"active\" | \"error\";\n\nexport interface AccountCalendar {\n status: AccountCalendarStatus;\n /** CALENDAR_SCOPES (full URLs) the account hasn't granted; empty when all are granted. */\n missingScopes: string[];\n /** Last successful incremental sync of any of its calendars. null = never (no grant has calendar.read yet, or not granted). */\n lastSyncAt: string | null;\n /** Human-readable reason for api_disabled / error, null otherwise. */\n error: string | null;\n}\n\n/** POST /api/accounts/:id/test-email: a self-addressed message sent through Gmail to prove delivery end to end. */\nexport interface TestEmailResult {\n /** Gmail message id of the sent message (matches MailEvent.message.id of the resulting events). */\n messageId: string;\n threadId: string;\n sentAt: string;\n}\n\nexport interface GmailLabel {\n id: string; // Gmail label id, e.g. \"INBOX\" or \"Label_123\"\n name: string;\n type: \"system\" | \"user\";\n}\n\n/** POST /api/accounts/connect → start OAuth. */\nexport interface PubsubTestResult {\n ok: boolean;\n message: string;\n}\n\n/**\n * What the /oauth/callback page posts to its opener (window.postMessage) and on the\n * BroadcastChannel \"agentinbox-oauth\". On failure, `error`/`code`/`hint` mirror the ApiError that\n * POST /api/accounts/connect/complete returns for the same failure.\n */\nexport interface OAuthCallbackMessage {\n type: \"agentinbox:oauth\";\n ok: boolean;\n email: string | null;\n /** Human-readable message (null on success). */\n error: string | null;\n /** ApiError code, e.g. \"oauth_access_denied\" (null on success). See the route map for the list. */\n code: string | null;\n hint: string | null;\n}\n\n/**\n * POST /api/accounts/connect/status {state}: what became of one sign-in (ConnectAccountResponse.state). The\n * console asks when the callback page's message can't reach it (the console is open on another origin than\n * the redirect URI's, or Google severed the opener). Remembered in memory for an hour; \"unknown\" once the\n * state expired unused, after a restart, or for a state this server never issued.\n */\nexport type ConnectAttempt =\n | { status: \"pending\" }\n | { status: \"connected\"; accountId: string; email: string }\n /** `code`/`error`/`hint` mirror the ApiError POST /api/accounts/connect/complete returns for the same failure. */\n | { status: \"failed\"; code: string; error: string; hint: string | null }\n | { status: \"unknown\" };\n\nexport interface ConnectAccountResponse {\n authUrl: string;\n /** Echoed state; in paste mode the console POSTs the pasted URL to /api/accounts/connect/complete. */\n state: string;\n callbackMode: \"direct\" | \"paste\";\n}\n\n// ───────────────────────────── Agents & grants ─────────────────────────────\n\n/** How an agent was added: approved from `xaq connect` (its key lives in a profile on that machine), or created here with a copied key. */\nexport type AgentOrigin = \"paired\" | \"key\";\n\nexport interface Agent {\n id: string; // agt_…\n name: string;\n description: string | null;\n /** First chars of the key, for identification (e.g. \"aib_7Hk2\"). The full key is shown once at creation/rotation. */\n keyPrefix: string;\n disabled: boolean;\n origin: AgentOrigin;\n /** Last request with this agent's key (MCP or the listener API), written at most once a minute. */\n lastUsedAt: string | null;\n /** Last request to /mcp only (a tool's startup `tools/list` counts), written at most once a minute. */\n lastMcpAt: string | null;\n createdAt: string;\n grants: Grant[];\n /** `xaq listen` instances (one per consumer name) and their stream position. */\n listeners: ListenerInfo[];\n}\n\nexport interface ListenerInfo {\n consumer: string;\n connected: boolean;\n /**\n * Where the cursor started when the consumer was created (the head, or 0 for start=beginning):\n * events at or below it predate the listener and weren't delivered to it. Null = unknown (created\n * before this was recorded, or by an ack).\n */\n startSeq: number | null;\n ackedSeq: number;\n /**\n * Event types the listener's stream asked for when it last connected (every type when it named\n * none). Events of other types are acknowledged without running its command. Null = unknown\n * (recorded before this existed).\n */\n types: EventType[] | null;\n lastSeenAt: string | null;\n client: { hostname?: string; platform?: string; version?: string };\n}\n\nexport type PairingStatus = \"pending\" | \"approved\" | \"denied\" | \"expired\";\n\n/** A device-flow pairing request from `npx xaq-cli connect`, shown in the approvals inbox. */\nexport interface PairingRequest {\n id: string; // par_…\n /** The code the agent's terminal shows; the human must match it before approving. */\n userCode: string;\n name: string;\n description: string | null;\n requested: { accounts?: string[]; capabilities?: Capability[]; sendMode?: SendMode; calendarWriteMode?: CalendarWriteMode; reason?: string };\n client: { hostname?: string; platform?: string; tool?: string; version?: string };\n /** Requester IP as seen by the daemon. */\n ip: string | null;\n status: PairingStatus;\n /** Set once approved. */\n agentId: string | null;\n createdAt: string;\n expiresAt: string;\n decidedAt: string | null;\n decisionNote: string | null;\n}\n\n/** POST /api/pairings/:id/approve. Grants default to sendMode draft_only in the console. */\nexport interface ApprovePairingInput {\n /** Typed by the human from the agent's terminal (not pre-filled); case and dashes are ignored. 409 on mismatch. */\n userCode: string;\n name?: string;\n /** Mailbox grants; may be empty (an install with only connections). */\n grants: (GrantInput & { accountId: string })[];\n /** MCP broker connections: the level per connection (\"none\" = no access, also for \"All agents\" ones). */\n connections?: { connectionId: string; level: AccessLevel | \"none\" }[];\n}\n\nexport interface Grant {\n id: string; // grt_…\n agentId: string;\n accountId: string;\n capabilities: Capability[];\n /** Gmail label ids the agent is confined to (thread-level: a thread is visible if any message carries one). null = whole mailbox. */\n labelScope: string[] | null;\n sendMode: SendMode;\n /** Recipient allowlist: exact addresses or \"*@domain.com\" globs. Empty = any recipient. */\n allowTo: string[];\n /** Only allow sending replies inside threads the agent can already see. */\n replyOnly: boolean;\n /** Counts mail sends and calendar writes that email people (see docs/calendar.md, \"Send caps\"). */\n maxSendsPerHour: number | null;\n maxSendsPerDay: number | null;\n /**\n * Calendar ids (CalendarInfo.id) the agent's calendar capabilities are confined to. The primary calendar is\n * stored by its real id (the account email), never the alias \"primary\". null = every calendar in the\n * account's list.\n * Ignored without a calendar capability. Default null.\n */\n calendarScope: string[] | null;\n /** How calendar.write executes. Ignored without calendar.write. Default \"approval\". */\n calendarWriteMode: CalendarWriteMode;\n createdAt: string;\n}\n\n/**\n * The calendar fields are optional for older callers: omitted means \"unchanged\" on an existing grant and\n * the default (calendarScope null, calendarWriteMode \"approval\") on a new one. Never widened by omission.\n */\nexport type GrantInput = Omit<Grant, \"id\" | \"agentId\" | \"createdAt\" | \"calendarScope\" | \"calendarWriteMode\"> &\n Partial<Pick<Grant, \"calendarScope\" | \"calendarWriteMode\">>;\n\nexport interface CreateAgentInput {\n name: string;\n description?: string | null;\n grants?: GrantInput[];\n /** MCP broker connections (pairing approve): the level per connection; \"none\" opts out of an \"All agents\" one. */\n connections?: { connectionId: string; level: AccessLevel | \"none\" }[];\n}\n\n/** POST /api/agents/:id/test: runs MCP initialize + tools/list as the agent, in-process. */\nexport interface AgentTestResult {\n ok: boolean;\n tools: string[];\n message: string;\n}\n\n/** Returned once on create and on key rotation. */\nexport interface AgentWithKey {\n agent: Agent;\n apiKey: string; // aib_…\n}\n\n// ───────────────────────────── Events ─────────────────────────────\n\nexport interface Address {\n name: string | null;\n email: string;\n}\n\n/** Normalized message metadata carried in events. No bodies are stored server-side. */\nexport interface MessageSummary {\n id: string; // Gmail message id\n threadId: string;\n from: Address | null;\n to: Address[];\n cc: Address[];\n subject: string;\n snippet: string;\n date: string; // ISO\n labelIds: string[];\n hasAttachments: boolean;\n}\n\n/**\n * One item of the agent event feed, webhooks and listeners. The name is historical: calendar.* events use\n * the same envelope with `message: null` and `calendarEvent` set.\n */\n// ───────────────────────────── Calendar ─────────────────────────────\n\nexport type CalendarResponseStatus = \"needsAction\" | \"declined\" | \"tentative\" | \"accepted\";\nexport type CalendarAccessRole = \"owner\" | \"writer\" | \"reader\" | \"freeBusyReader\";\n\n/** A calendar in the account's calendar list (calendarList.list). */\nexport interface CalendarInfo {\n /** Google calendar id: the account email for the primary calendar, \"…@group.calendar.google.com\" for others. */\n id: string;\n /** Display name (the user's override if any). Cleaned single line. */\n name: string;\n primary: boolean;\n accessRole: CalendarAccessRole;\n timeZone: string | null;\n /** Google's color for the calendar, e.g. \"#9fe1e7\" (console only). */\n color: string | null;\n /** Changes on it are synced into calendar.* events (owner/writer calendars, while a grant reads them). */\n synced: boolean;\n}\n\nexport interface CalendarAttendee {\n email: string;\n name: string | null;\n responseStatus: CalendarResponseStatus;\n optional: boolean;\n organizer: boolean;\n /** This attendee is the account itself. */\n self: boolean;\n /** A room or other resource. */\n resource: boolean;\n}\n\n/** Fields compared to report a calendar.event_updated (detail.changes). */\nexport type CalendarEventChange = \"summary\" | \"time\" | \"location\" | \"description\" | \"attendees\" | \"responses\" | \"status\" | \"recurrence\" | \"conference\";\n\n/**\n * Normalized event metadata carried in calendar.* events and tool results. Like MessageSummary, no\n * free text body: the description is fetched (cleaned and wrapped as untrusted) on demand. Free-text\n * fields (summary, location, names) are third-party input: cleaned single lines, never instructions.\n */\nexport interface CalendarEventSummary {\n /** Google event id (unique within its calendar). */\n id: string;\n calendarId: string;\n /** Same across calendars (an invitation's copy on each attendee's calendar shares it). */\n iCalUID: string | null;\n /** Set on instances/exceptions of a recurring event: the series' event id. */\n recurringEventId: string | null;\n /** The event is a recurring series (has RRULEs). */\n recurring: boolean;\n status: \"confirmed\" | \"tentative\" | \"cancelled\";\n summary: string;\n location: string | null;\n /** RFC 3339 date-time, or YYYY-MM-DD for all-day events (end is exclusive, as in Google). */\n start: string;\n end: string;\n allDay: boolean;\n /** The event's time zone (IANA), null when Google gave none. */\n timeZone: string | null;\n organizer: Address | null;\n /** The account organizes this event. */\n organizerSelf: boolean;\n /** At most 50 attendees (attendeeCount has the total). */\n attendees: CalendarAttendee[];\n attendeeCount: number;\n /** The account's own response, null when it isn't an attendee. */\n selfResponse: CalendarResponseStatus | null;\n /** Video call link (Meet or another conference), null when none. */\n conferenceUrl: string | null;\n /** Link to the event in Google Calendar. */\n htmlLink: string | null;\n /** Google's eventType (\"default\", \"outOfOffice\", \"focusTime\", \"workingLocation\", \"birthday\", \"fromGmail\"). */\n eventType: string;\n created: string | null;\n updated: string | null;\n}\n\n/**\n * What an agent asked to write (create, or the changed fields of an update). Kept on calendar approvals so\n * the human sees exactly what will be applied. Times as in CalendarEventSummary.\n */\nexport interface CalendarEventInput {\n summary?: string;\n description?: string;\n location?: string;\n start?: string;\n end?: string;\n allDay?: boolean;\n timeZone?: string;\n /** Full attendee list after the change (emails). Omitted on update = unchanged. */\n attendees?: string[];\n /** Add a Google Meet link. */\n addConference?: boolean;\n /** RRULE/EXDATE lines, e.g. [\"RRULE:FREQ=WEEKLY;COUNT=4\"]. */\n recurrence?: string[];\n}\n\n/** POST /api/google/calendar/* and GET /api/google/calendar: the install's Google Calendar setup (\"Add Google Calendar\"). */\nexport interface CalendarSetupState {\n /**\n * Calendar is part of this install: every connect/reconnect requests CALENDAR_SCOPES. True by default on\n * new installs (no account connected before the upgrade); existing installs turn it on by starting \"Add Google Calendar\".\n */\n enabled: boolean;\n /** The operator hid the calendar_not_added notice. */\n dismissed: boolean;\n /** CALENDAR_SCOPES (full URLs), to list on the consent screen's Data Access page. */\n scopes: string[];\n /**\n * The Calendar API in the Cloud project, as last observed: \"disabled\" after a SERVICE_DISABLED answer,\n * \"enabled\" after any successful call, \"unknown\" before any account could try (no account has the scopes).\n */\n api: \"enabled\" | \"disabled\" | \"unknown\";\n /** Deep link enabling calendar-json.googleapis.com in the saved project (null without a project id). */\n enableApiUrl: string | null;\n /** `gcloud services enable calendar-json.googleapis.com --project=<id>` (null without a project id). */\n enableApiCommand: string | null;\n /** Google Auth Platform → Data Access page of the project, to add the scopes (null without a project id). */\n dataAccessUrl: string | null;\n /** Every connected account. */\n accounts: {\n accountId: string;\n email: string;\n status: AccountCalendarStatus;\n missingScopes: string[];\n /** Calendars whose changes are synced into events (owner/writer calendars), 0 while no grant reads them. */\n syncedCalendars: number;\n /** A verified push channel is live for at least one synced calendar (otherwise changes arrive by polling). */\n push: boolean;\n }[];\n /**\n * Next thing to do: enable_api (api \"disabled\"), connect_accounts (some active account lacks the scopes; the\n * Data Access step is shown with it, it can't be detected), done. \"off\" when not enabled.\n */\n step: \"off\" | \"enable_api\" | \"connect_accounts\" | \"done\";\n}\n\n/** PATCH /api/google/calendar. */\nexport interface CalendarSetupInput {\n enabled?: boolean;\n dismissed?: boolean;\n}\n\n/** POST /api/accounts/connect body. `scopes` adds optional scope sets incrementally (include_granted_scopes). */\nexport interface ConnectAccountInput {\n reauthAccountId?: string;\n scopes?: ExtraScopeName[];\n /**\n * Ask for mail only: no calendar scopes, even while Calendar is enabled or the account had them. For\n * Workspace admins who block Calendar (Google then rejects any consent that includes it). Not with scopes [\"calendar\"].\n */\n mailOnly?: boolean;\n}\n/** \"pubsub\": the Pub/Sub scope for the provisioning account. \"calendar\": CALENDAR_SCOPES. */\nexport const EXTRA_SCOPE_NAMES = [\"pubsub\", \"calendar\"] as const;\nexport type ExtraScopeName = (typeof EXTRA_SCOPE_NAMES)[number];\n\nexport interface MailEvent {\n id: string; // evt_…\n seq: number; // monotonic, used as cursor\n type: EventType;\n /** \"\" on connection events (BROKER_EVENT_TYPES and `<preset>.*`), which carry connectionId instead. */\n accountId: string;\n accountEmail: string;\n /** Connection events only. */\n connectionId?: string;\n message: MessageSummary | null;\n /** calendar.* events only (absent on mail events, so their payloads are unchanged). */\n calendarEvent?: CalendarEventSummary;\n /** For label.added / label.removed. */\n labelIds?: string[];\n /**\n * Free-form detail for account/sync events. calendar.event_updated: `{ changes: CalendarEventChange[] | null }`\n * (null = unknown: the previous version wasn't seen). calendar.event_cancelled: `{ wasOrganizer: boolean }`.\n */\n detail?: Record<string, unknown>;\n createdAt: string;\n /**\n * Console only (GET /api/events and the console stream): the thread's labels when the event was\n * recorded, which agents' label scopes are matched against. Never sent to agents or webhooks.\n */\n threadLabelIds?: string[];\n}\n\n// ───────────────────────────── Webhooks ─────────────────────────────\n\nexport interface WebhookFilter {\n accountIds?: string[];\n /** Match if the message has any of these label ids. */\n labelIds?: string[];\n /** Glob on sender address, e.g. \"*@stripe.com\". Any match passes. */\n from?: string[];\n /** Glob on any to/cc address. */\n to?: string[];\n /** Case-insensitive regular expression tested against the subject. */\n subject?: string;\n hasAttachments?: boolean;\n /**\n * calendar.* events: only these calendar ids. On calendar events `from` matches the organizer, `to` any\n * attendee and `subject` the event summary; labelIds and hasAttachments don't apply (ignored).\n */\n calendarIds?: string[];\n}\n\nexport interface Webhook {\n id: string; // whk_…\n agentId: string;\n name: string;\n url: string;\n eventTypes: EventType[];\n filter: WebhookFilter;\n /** Include the cleaned message body (markdown) in the payload. */\n includeBody: boolean;\n enabled: boolean;\n /** Shown once at creation and on rotation (\"whsec_…\", Standard Webhooks format). */\n secretPrefix: string;\n createdAt: string;\n stats: { succeeded24h: number; failed24h: number; lastDeliveryAt: string | null };\n}\n\nexport type WebhookInput = Omit<Webhook, \"id\" | \"secretPrefix\" | \"createdAt\" | \"stats\">;\n\nexport interface WebhookWithSecret {\n webhook: Webhook;\n secret: string;\n}\n\nexport interface Delivery {\n id: string; // dlv_…\n webhookId: string;\n eventId: string;\n eventType: EventType;\n status: DeliveryStatus;\n attempts: number;\n nextAttemptAt: string | null;\n lastStatusCode: number | null;\n lastError: string | null;\n /** First ~500 chars of the receiver's response body. */\n lastResponse: string | null;\n durationMs: number | null;\n createdAt: string;\n updatedAt: string;\n}\n\n/**\n * Webhook HTTP request body. Signed per https://www.standardwebhooks.com:\n * headers webhook-id, webhook-timestamp, webhook-signature (\"v1,<base64 hmac-sha256>\").\n */\nexport interface WebhookPayload {\n type: EventType;\n timestamp: string;\n data: MailEvent & {\n /**\n * Present only when the webhook has includeBody and the event concerns a message, or (calendar.* except\n * cancelled) an event with a description: then `body` is the cleaned description, attachments empty.\n */\n content?: MessageContent;\n /** Set instead of `content` when includeBody was requested but the body could not be fetched. */\n contentError?: string;\n };\n}\n\n// ───────────────────────────── Message content (returned to agents) ─────────────────────────────\n\n/** Signals attached to content. Informational: the content itself is still delivered (cleaned, not censored). */\nexport interface ContentSignals {\n /** Invisible content (display:none, zero-size/transparent text, HTML comments, zero-width/tag unicode) was removed. */\n hiddenContentRemoved: boolean;\n /** Phrases that commonly appear in prompt-injection attempts (\"ignore previous instructions\", …). */\n suspiciousInstructions: string[];\n /** SPF/DKIM/DMARC results parsed from Authentication-Results. null when the header is absent. */\n auth: { spf: string | null; dkim: string | null; dmarc: string | null } | null;\n /** Sender is outside the account's own domain. */\n external: boolean;\n}\n\nexport interface Attachment {\n id: string; // Gmail attachment id\n filename: string;\n mimeType: string;\n size: number;\n}\n\nexport interface MessageContent {\n /** Body converted to markdown (from HTML) or the plain-text part. */\n body: string;\n truncated: boolean;\n attachments: Attachment[];\n signals: ContentSignals;\n}\n\n// ───────────────────────────── Approvals ─────────────────────────────\n\nexport const MAIL_APPROVAL_KINDS = [\"send\", \"reply\", \"forward\"] as const;\n/** Calendar writes queued by calendarWriteMode \"approval\". Approving applies the stored change; rejecting discards it. */\nexport const CALENDAR_APPROVAL_KINDS = [\"calendar.create\", \"calendar.update\", \"calendar.delete\", \"calendar.rsvp\"] as const;\nexport type MailApprovalKind = (typeof MAIL_APPROVAL_KINDS)[number];\nexport type CalendarApprovalKind = (typeof CALENDAR_APPROVAL_KINDS)[number];\n/** A risky upstream tool call at the \"ask\" level (docs/MCP-BROKER.md). Goes through a connection, not an account. */\nexport const MCP_APPROVAL_KINDS = [\"mcp.call\"] as const;\nexport type McpApprovalKind = (typeof MCP_APPROVAL_KINDS)[number];\n/** The kinds that act on a Google account (an Approval with accountId set). */\nexport type GoogleApprovalKind = MailApprovalKind | CalendarApprovalKind;\nexport type ApprovalKind = GoogleApprovalKind | McpApprovalKind;\nexport const isCalendarApprovalKind = (k: string): k is CalendarApprovalKind => (CALENDAR_APPROVAL_KINDS as readonly string[]).includes(k);\nexport const isMcpApprovalKind = (k: string): k is McpApprovalKind => (MCP_APPROVAL_KINDS as readonly string[]).includes(k);\n\n/** The calendar change a calendar.* approval applies. */\nexport interface CalendarApprovalDetail {\n calendarId: string;\n calendarName: string | null;\n /**\n * Target event (update/delete/rsvp). For create, the id xaq chose when queuing (Google accepts\n * client ids), so a retried apply can't create a second event.\n */\n eventId: string | null;\n /** create: the new event; update: only the changed fields; delete/rsvp: {}. */\n change: CalendarEventInput;\n /** The event when the change was queued (update/delete/rsvp), to show a before/after. null for create. */\n before: CalendarEventSummary | null;\n /** Google etag of `before`: applying sends If-Match, so an event changed since fails instead of being overwritten. */\n etag: string | null;\n /** rsvp only. */\n response: CalendarResponseStatus | null;\n /** rsvp only: the note sent to the organizer with the answer. */\n comment?: string | null;\n /** Google emails attendees (sendUpdates \"all\"); false = \"none\". */\n notifyAttendees: boolean;\n /** Addresses Google will email when applied. */\n notified: string[];\n /**\n * Addresses the grant's allowTo had to cover when queued (attendees after the change, another organizer,\n * everyone emailed; see attendeeImpact), checked again at approval. Absent on approvals from before it\n * existed: then recomputed from the change.\n */\n checked?: string[];\n /**\n * When xaq last started sending this change to Google (set before each attempt). Once set, a later\n * attempt can't assume the earlier one didn't land: it checks the event first.\n */\n attemptedAt?: string | null;\n}\n\n/** The exact call an mcp.call approval forwards once approved (approvals.tool_call). */\nexport interface McpCallApprovalDetail {\n /** The upstream's own tool name (UpstreamTool.name). */\n tool: string;\n /** The name the agent called (UpstreamTool.exposedName, `<slug>__<tool>`). */\n exposedName: string;\n /** The validated, canonicalised arguments: exactly the bytes shown on the card and forwarded on approve. */\n args: string;\n /** sha256 (hex) of `args`, re-checked on approve. */\n argsSha256: string;\n /** The tool's pin when the call was queued; a different pin on approve means the tool changed. */\n pinHash: string;\n /** The upstream's result once the approved call ran (what get_call_status returns). */\n result?: McpCallResult | null;\n /** When the approved call was last forwarded upstream (set right before it is). */\n attemptedAt?: string | null;\n /**\n * The approved call was interrupted (crash, timeout, lost answer): it may or may not have run upstream.\n * Status is \"failed\"; it is never retried automatically.\n */\n outcomeUnknown?: boolean;\n}\n\n/**\n * GET /api/approvals/:id for an mcp.call: the card. `approval.call.args` holds the exact bytes that run on approve.\n * `tool` is the tool as the agent can reach it now (null when it no longer can: access removed, on hold).\n */\nexport interface McpCallApprovalView {\n approval: McpCallApproval;\n connection: { id: string; slug: string; name: string; status: ConnectionStatus } | null;\n tool: { exposedName: string; name: string; title: string | null; risky: boolean; read: boolean; targetPath: string | null; level: AccessLevel; pinMatches: boolean } | null;\n /** The exact argument bytes with invisible and control characters shown as \\u escapes. */\n argsDisplay: string;\n /** The same arguments indented (escaped the same way). */\n pretty: string;\n /** Email addresses and URLs found anywhere in the arguments: shown first on the card. */\n recipients: string[];\n urls: string[];\n /** The tool's target argument (ToolClass.targetPath, a JSON Pointer) and its value in this call, if any. */\n target: { path: string; value: unknown } | null;\n /** JSON Pointers a generic tool's \"always allow for this argument\" may pick from. */\n argumentPaths: string[];\n /** sha256 of the stored bytes still matches the fingerprint taken when the call was queued. */\n argsIntact: boolean;\n}\n\n/** POST /api/approvals/:id/approve for an mcp.call. */\nexport interface McpCallApproveInput {\n note?: string | null;\n /**\n * \"Always allow this\": a rule for this agent and tool, created when the call is approved. `target` limits it\n * to this call's target value (one rule per id when the target is an array); `targetPath` picks the argument\n * for generic tools (defaults to the tool's own targetPath).\n */\n alwaysAllow?: { target?: boolean; targetPath?: string };\n}\n\n/** An upstream CallToolResult as stored for get_call_status. */\nexport interface McpCallResult {\n content: unknown[];\n structuredContent?: unknown;\n isError?: boolean;\n}\n\ninterface ApprovalCommon {\n id: string; // apr_…\n agentId: string;\n agentName: string;\n status: ApprovalStatus;\n to: Address[];\n cc: Address[];\n bcc: Address[];\n subject: string;\n /** Full body as the agent wrote it (markdown/plain). */\n body: string;\n threadId: string | null;\n /** Files on the message: name, MIME type and decoded size (never the content). */\n attachments: { filename: string; size: number; mimeType?: string }[];\n /** Why the agent says it is sending (optional `reason` tool arg). */\n reason: string | null;\n decidedAt: string | null;\n decisionNote: string | null;\n sentMessageId: string | null;\n error: string | null;\n createdAt: string;\n expiresAt: string;\n}\n\n/**\n * A send or calendar change waiting for (or decided by) a human. For calendar kinds the mail fields are\n * filled so generic lists read well: to = `calendar.notified`, cc/bcc empty, subject = event summary,\n * body = description, draftId \"\" and threadId null; status \"sent\" means \"applied\" and sentMessageId holds\n * the Google event id.\n */\nexport interface GoogleApproval extends ApprovalCommon {\n kind: GoogleApprovalKind;\n accountId: string;\n accountEmail: string;\n draftId: string;\n /** Calendar kinds only. */\n calendar?: CalendarApprovalDetail;\n connectionId?: null;\n call?: undefined;\n}\n\n/**\n * A risky upstream tool call waiting for (or decided by) a human. No Google account and no draft; the\n * mail fields stay empty unless the server fills them for generic lists.\n */\nexport interface McpCallApproval extends ApprovalCommon {\n kind: McpApprovalKind;\n accountId: null;\n accountEmail: null;\n draftId: null;\n connectionId: string;\n call: McpCallApprovalDetail;\n calendar?: undefined;\n}\n\nexport type Approval = GoogleApproval | McpCallApproval;\n\n// ───────────────────────────── MCP broker: connections ─────────────────────────────\n// docs/MCP-BROKER.md. Stored since migration 8; nothing serves these until the broker PRs land (behind the\n// brokerUpstreams setting). Credentials never cross the API boundary: there's no DTO for them.\n// AccessLevel and ToolAnnotations mirror server/broker/types.ts: the console can't import that file (it uses\n// Node's Buffer, and tsconfig.app has no Node types). Keep them in step.\n\n/** native_whoop (M2), stdio (M4) and native_google are reserved; M1 creates remote_http only. */\nexport const CONNECTION_KINDS = [\"remote_http\", \"native_whoop\", \"stdio\", \"native_google\"] as const;\nexport type ConnectionKind = (typeof CONNECTION_KINDS)[number];\n\nexport const CONNECTION_AUTH_TYPES = [\"none\", \"header\", \"oauth\"] as const;\nexport type ConnectionAuthType = (typeof CONNECTION_AUTH_TYPES)[number];\n\nexport const CONNECTION_STATUSES = [\"active\", \"needs_reauth\", \"error\", \"disabled\"] as const;\nexport type ConnectionStatus = (typeof CONNECTION_STATUSES)[number];\n\n/**\n * What an agent may do with a connection (Wynn's model). No grant row = no access.\n * - full: every tool listed and called without asking\n * - ask: every tool listed; risky (irreversible) tools wait for an mcp.call approval. The default.\n * - read: only read tools are listed or callable\n */\nexport const ACCESS_LEVELS = [\"full\", \"ask\", \"read\"] as const;\nexport type AccessLevel = (typeof ACCESS_LEVELS)[number];\n\n/** Connection slugs, chosen by the operator: tools are exposed as `<slug>__<tool>`. Same rule as the schema CHECK. */\nexport const CONNECTION_SLUG_RE = /^[a-z0-9-]{1,24}$/;\n/** Exposed tool names: `[A-Za-z0-9_-]`, at most 64 (clients prepend `mcp__<server>__` and cap at 128). */\nexport const EXPOSED_TOOL_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;\n\n/** One upstream (an MCP server, or a native integration) with its credential. */\nexport interface Connection {\n id: string; // con_…\n /** null = the install's single owner. */\n ownerId: string | null;\n kind: ConnectionKind;\n /** IntegrationPreset.id (server/broker/types.ts), checked against the preset registry in code; null = custom. */\n preset: string | null;\n slug: string;\n name: string;\n /** The MCP endpoint (remote_http). */\n url: string | null;\n authType: ConnectionAuthType;\n /** The level new grants get. */\n levelDefault: AccessLevel;\n /** \"All agents\": agents created later get it too, at levelDefault. */\n allAgents: boolean;\n /** Admin-only private-target allow: the exact `scheme://host:port`, or null when off. */\n privateTarget: string | null;\n status: ConnectionStatus;\n statusReason: string | null;\n /** When the credential stops working without a human (no refresh, or a hard cap); null = not known to expire. */\n expiresAt: string | null;\n /** Negotiated protocol generation, cached. */\n protocol: string | null;\n toolsCachedAt: string | null;\n instructionsHash: string | null;\n createdAt: string;\n updatedAt: string;\n}\n\n/** MCP tool annotations as the upstream sent them: hints only, never trusted on their own. */\nexport interface ToolAnnotations {\n title?: string;\n readOnlyHint?: boolean;\n destructiveHint?: boolean;\n idempotentHint?: boolean;\n openWorldHint?: boolean;\n}\n\n/** A tool definition as pinned (upstream_tools.definition). The pin hashes this plus the tool's name. */\nexport interface UpstreamToolDefinition {\n title?: string;\n description?: string;\n inputSchema: Record<string, unknown>;\n outputSchema?: Record<string, unknown>;\n annotations?: ToolAnnotations;\n}\n\nexport type ToolClassSource = \"preset\" | \"annotation\" | \"heuristic\";\nexport type UpstreamToolState = \"active\" | \"held\";\n/** changed: the definition's hash differs from the pin; new: the tool wasn't there at connect. */\nexport type ToolHeldReason = \"changed\" | \"new\";\n\nexport interface UpstreamTool {\n connectionId: string;\n /** The upstream's own name. */\n name: string;\n /** `<slug>__<name>`, as listed to agents. */\n exposedName: string;\n /** What agents see. null only for a held new tool (nothing pinned yet). */\n definition: UpstreamToolDefinition | null;\n pinHash: string | null;\n /** Irreversible: waits for approval at the \"ask\" level. */\n risky: boolean;\n /** Listed at the \"read\" level. Never both risky and read. */\n read: boolean;\n classSource: ToolClassSource;\n state: UpstreamToolState;\n heldReason: ToolHeldReason | null;\n /** While held: what accepting would pin. */\n candidate: UpstreamToolDefinition | null;\n candidateHash: string | null;\n firstSeenAt: string;\n updatedAt: string;\n}\n\n/** One agent's access to one connection. */\nexport interface ConnectionGrant {\n agentId: string;\n connectionId: string;\n level: AccessLevel;\n createdAt: string;\n updatedAt: string;\n}\n\n/** \"Always allow this\": matching calls by this agent to this tool never ask again. */\nexport interface ApprovalRule {\n id: string; // rul_…\n agentId: string;\n connectionId: string;\n /** The upstream tool name (UpstreamTool.name). */\n tool: string;\n /** Only for one target: a JSON Pointer into the call's arguments, and that argument's canonical JSON. */\n targetPath: string | null;\n targetValue: string | null;\n createdAt: string;\n}\n\n// ───────────────────── MCP broker: connections admin API (/api/connections) ─────────────────────\n\n/** A connection as the Connections list shows it. */\nexport interface ConnectionView extends Connection {\n tools: {\n /** Every pinned or held tool. */\n total: number;\n active: number;\n /** Waiting for the owner to accept a changed or new definition. */\n held: number;\n };\n /** Whole days until expiresAt (0 = today or past), or null when not known to expire. */\n expiresInDays: number | null;\n /** Agents with a grant on this connection. */\n agents: number;\n}\n\n/** POST /api/connections: a custom MCP URL, or a preset (its URL and auth come from the preset). */\nexport interface CreateConnectionInput {\n /** IntegrationPreset.id (GET /api/connections/presets). */\n preset?: string;\n /** Required without a preset. */\n url?: string;\n /** Default: the preset's display name, else the URL's host. */\n name?: string;\n /** Default: the preset's slug, else derived from the host. */\n slug?: string;\n levelDefault?: AccessLevel;\n /** Agents that get the connection at levelDefault. Default: every agent, including ones created later (allAgents). */\n agentIds?: string[];\n}\n\n/**\n * PATCH /api/connections/:id. Changing the URL (custom connections only) deletes the credentials, clears the\n * private-target allow, cancels pending calls and sets status needs_reauth: reconnect afterwards.\n */\nexport interface UpdateConnectionInput {\n /** true: every agent without access gets it at levelDefault now, and agents created later too. */\n allAgents?: boolean;\n name?: string;\n slug?: string;\n url?: string;\n levelDefault?: AccessLevel;\n}\n\n/** PUT /api/connections/:id/grants: the full list; agents not listed lose access. */\nexport interface ConnectionGrantsInput {\n grants: { agentId: string; level: AccessLevel }[];\n}\n\n/** PUT /api/connections/:id/private-target. Admin-only: the allow is pinned to the URL's exact scheme://host:port. */\nexport interface PrivateTargetInput {\n allow: boolean;\n}\n\n/** What probing an MCP URL found (POST /api/connections/probe), so the console shows the right button or field. */\nexport interface ConnectionProbe {\n reachable: boolean;\n /** none: open; header: needs an API key or bearer; oauth: OAuth discovered; unknown: couldn't tell. */\n auth: \"none\" | \"header\" | \"oauth\" | \"unknown\";\n /** private_target: the host is on loopback or a private network and the connection has no allow for it. */\n blocked?: \"private_target\" | \"ssrf\";\n /** OAuth: the authorization server's issuer and how the client registers. */\n issuer?: string;\n registration?: \"preregistered\" | \"metadata_document\" | \"dcr\" | \"manual\";\n /** OAuth: the server issues refresh tokens (false: dated reconnect warnings). */\n refresh?: boolean;\n /** blocked: the exact scheme://host:port an admin could allow for this connection, when an allow would help. */\n privateTarget?: string | null;\n /** Plain words for the console. */\n message: string;\n}\n\n/** POST /api/connections/:id/connect. */\nexport interface StartConnectInput {\n /** Static header auth (bearer or API key). Stored encrypted; never returned. */\n header?: { name: string; value: string };\n /** Setup inputs (e.g. a pasted client id and secret), keyed by SetupStepView.input.key. */\n inputs?: Record<string, string>;\n}\n\nexport type StartConnectResult =\n | { status: \"connected\"; connection: ConnectionView }\n /**\n * Open this URL (the OAuth popup). direct: /oauth/connections/callback finishes the connection; paste: the user\n * copies the landing URL into POST /api/connections/finish.\n */\n | { status: \"authorize\"; authorizeUrl: string; callbackMode?: \"direct\" | \"paste\"; redirectUri?: string }\n | { status: \"error\"; message: string; probe?: ConnectionProbe };\n\n/** One step of a preset's guided setup (server/broker/types.ts SetupStep), resolved for this install. */\nexport interface SetupStepView {\n id: string;\n title: string;\n body: string;\n link?: { label: string; url: string };\n copy?: { label: string; value: string };\n input?: { key: string; label: string; secret?: boolean; pattern?: string; placeholder?: string };\n}\n\n/** GET /api/connections/presets. */\nexport interface PresetView {\n id: string;\n displayName: string;\n slug: string;\n kind: \"remote_http\" | \"native\";\n url: string | null;\n authType: ConnectionAuthType;\n setupSteps: SetupStepView[];\n reconnectPolicy: { maxLifetimeDays?: number; idleDays?: number } | null;\n}\n\n/** One line of a held tool's diff: pinned (-) against the candidate (+), on pretty-printed JSON. */\nexport interface ToolDiffLine {\n op: \" \" | \"-\" | \"+\";\n text: string;\n}\n\n/** GET /api/connections/:id/held: a tool waiting for the owner to accept its new definition. */\nexport interface HeldToolView {\n name: string;\n exposedName: string;\n heldReason: ToolHeldReason;\n /** null for a new tool. */\n pinned: UpstreamToolDefinition | null;\n candidate: UpstreamToolDefinition;\n /** Pass back to accept: a candidate that changed since is refused (409). */\n candidateHash: string;\n diff: ToolDiffLine[];\n updatedAt: string;\n}\n\n// ───────────────────────────── Audit ─────────────────────────────\n\nexport interface AuditEntry {\n id: number;\n ts: string;\n actorType: ActorType;\n actorId: string | null;\n actorName: string | null;\n action: string; // MCP tool name or console action, e.g. \"send_message\", \"agent.create\"\n accountId: string | null;\n target: string | null; // message/thread/draft id\n outcome: AuditOutcome;\n /** For denials: the guardrail that blocked it, e.g. \"allow_to\", \"label_scope\", \"rate_limit\". */\n reason: string | null;\n durationMs: number | null;\n detail: Record<string, unknown> | null;\n}\n\n// ───────────────────────────── Overview ─────────────────────────────\n\nexport interface OverviewStats {\n accounts: { total: number; active: number; needsAttention: number };\n agents: { total: number; activeLast24h: number };\n events24h: number;\n deliveries24h: { succeeded: number; failed: number };\n pendingApprovals: number;\n toolCalls24h: number;\n denied24h: number;\n /** Hourly buckets for the last 24h, oldest first. */\n activity: { hour: string; events: number; toolCalls: number }[];\n}\n\n// ───────────────────────────── Pagination & errors ─────────────────────────────\n\nexport interface Page<T> {\n items: T[];\n /** Opaque cursor for the next page; null when exhausted. */\n nextCursor: string | null;\n}\n\n/** All non-2xx console API responses have this body. */\nexport interface ApiError {\n /** `email`: only on oauth_already_connected (from the authenticated paste endpoint), the account that sign-in connected. */\n error: { code: string; message: string; hint?: string; email?: string };\n}\n\n// ───────────────────────────── Console API route map ─────────────────────────────\n// Session: cookie `aib_session` (HttpOnly, SameSite=Lax). Mutating requests must send header\n// `x-agentinbox-csrf: 1` (simple CSRF guard; the console fetch wrapper always sets it).\n//\n// Unauthenticated:\n// GET /api/health → HealthResponse\n// GET /api/setup/status → SetupStatus\n// POST /api/setup/claim {token,password} → 204 + session cookie (only while unclaimed)\n// POST /api/setup/verify-token {token} → 204 when the setup token is right (same limiter as claim)\n// POST /api/auth/login {password} → 204 + session cookie (5 failures/IP → 15 min lockout)\n// POST /api/auth/logout → 204\n// GET /oauth/callback?code&state → HTML result page; posts an OAuthCallbackMessage (ok/email or error/code/hint)\n// to its opener and on BroadcastChannel \"agentinbox-oauth\"\n//\n// Authenticated (admin session):\n// GET /api/setup/state → SetupState\n// GET /api/system → SystemInfo\n// GET /api/system/latest → UpdateInfo (npm registry check, cached 30 min)\n// GET /api/settings → Settings\n// PATCH /api/settings Partial<Settings> → Settings\n// POST /api/auth/password {current,next} → 204\n// GET /api/google/client → GoogleClientView\n// PUT /api/google/client GoogleClientConfig → GoogleClientSaveResult (manual entry; fallback to import; runs the check)\n// POST /api/google/client/check → GoogleClientCheck (400 google_client_missing when nothing is saved)\n// POST /api/google/client/import GoogleClientImportInput → GoogleClientImportResult (runs the check)\n// 400 client_json_invalid | client_json_desktop | client_secret_missing | bad_request (see GoogleClientImportResult)\n// POST /api/google/pubsub/provision {accountId?} → PubsubState (creates topic + IAM + subscription using the principal account's token;\n// `accountId` chooses the principal: saved as pending (PubsubState.pendingPrincipal*), so the follow-up\n// POST /api/accounts/connect {scopes:[\"pubsub\"]} targets it; it becomes the principal only when provisioning succeeds)\n// 409 pubsub_scope_required (the principal hasn't granted the Pub/Sub scope: run that incremental consent, retry)\n// 409 needs_reauth | 409 account_paused (chosen account unusable) | 404 not_found (unknown accountId)\n// 400 pubsub_permission_denied (principal lacks roles/pubsub.admin or Owner; hint: IAM link + gcloud command)\n// | pubsub_billing | pubsub_org_policy (Domain Restricted Sharing; hint: org policy link, polling alternative)\n// | pubsub_api_disabled | gmail_api_disabled | pubsub_publish_denied | pubsub_project_not_found\n// | pubsub_project_missing | pubsub_no_account | pubsub_provision_failed (anything else)\n// POST /api/google/pubsub/test → PubsubTestResult\n// GET /api/accounts → Account[]\n// POST /api/accounts/connect ConnectAccountInput → ConnectAccountResponse\n// 400 google_client_missing (no OAuth client saved yet)\n// {reauthAccountId, scopes:[\"calendar\"]}: the per-account \"Add Google Calendar\" consent (include_granted_scopes:\n// mail keeps working throughout; denying or unticking Calendar changes nothing). Its completion fails with\n// 400 oauth_calendar_not_granted when Calendar was (partly) unticked, and a cancel's oauth_access_denied says\n// nothing changed, as does oauth_admin_policy_enforced (ask the admin to trust the app). On other consents that\n// asked for Calendar, oauth_admin_policy_enforced hints at reconnecting with {mailOnly:true} or turning Calendar\n// off (PATCH /api/google/calendar {enabled:false}).\n// POST /api/accounts/connect/complete {url} → Account (paste mode)\n// POST /api/accounts/connect/status {state} → ConnectAttempt (the console's fallback when the callback page can't post to it)\n// Connect failures (same codes on this endpoint and in the callback page's OAuthCallbackMessage):\n// 400 oauth_access_denied (cancelled on Google's screen) | oauth_org_internal | oauth_admin_policy_enforced\n// | oauth_redirect_uri_mismatch | oauth_invalid_client | oauth_unauthorized_client | oauth_invalid_grant\n// | oauth_<other Google error> | oauth_state (expired/used link) | oauth_no_code | oauth_url_invalid\n// | oauth_gmail_scope_missing | oauth_no_refresh_token | oauth_wrong_account | oauth_account_removed\n// | gmail_api_disabled | google_client_missing; 502 google_error\n// POST /api/accounts/:id/resync → Account\n// POST /api/accounts/:id/pause | /resume → Account\n// DELETE /api/accounts/:id → 204 (stops watch, revokes token, deletes grants)\n// GET /api/accounts/:id/labels → GmailLabel[]\n// GET /api/accounts/:id/calendars → CalendarInfo[] (live calendarList, for the grant editor's calendar scope)\n// 409 calendar_not_granted (missing scopes; hint: run the per-account consent) | 409 calendar_api_disabled | 409 needs_reauth\n// GET /api/google/calendar → CalendarSetupState\n// PATCH /api/google/calendar CalendarSetupInput → CalendarSetupState (enable = start \"Add Google Calendar\"; dismiss the notice)\n// POST /api/google/calendar/check → CalendarSetupState (probes every account with the scopes: API on? scopes still granted?)\n// POST /api/accounts/:id/test-email → TestEmailResult (sends \"xaq test: your setup works\" from the\n// account to itself, then processes new mail at once; the resulting events are message.sent and message.received\n// (the latter only for this test message). Audited as account.test_email. 409 account_paused | 409 needs_reauth\n// | 429 rate_limited (5 per account per 10 min; hint says when to retry))\n// GET /api/agents → Agent[]\n// POST /api/agents CreateAgentInput → AgentWithKey\n// GET /api/agents/:id → Agent\n// PATCH /api/agents/:id {name?,description?,disabled?} → Agent\n// POST /api/agents/:id/rotate-key → AgentWithKey\n// DELETE /api/agents/:id → 204\n// PUT /api/agents/:id/grants/:accountId GrantInput → Grant (upsert; one grant per agent×account)\n// DELETE /api/agents/:id/grants/:accountId → 204\n// POST /api/agents/:id/test → AgentTestResult (runs MCP initialize+tools/list as the agent)\n// GET /api/webhooks?agentId → Webhook[]\n// POST /api/webhooks WebhookInput → WebhookWithSecret\n// PATCH /api/webhooks/:id Partial<WebhookInput> → Webhook\n// POST /api/webhooks/:id/rotate-secret → WebhookWithSecret\n// POST /api/webhooks/:id/test → Delivery (sends a synthetic event synchronously)\n// DELETE /api/webhooks/:id → 204\n// GET /api/deliveries?webhookId&status&cursor → Page<Delivery>\n// POST /api/deliveries/:id/replay → Delivery\n// GET /api/events?accountId&type&cursor → Page<MailEvent>\n// GET /api/events/stream → text/event-stream of ConsoleStreamMessage (SSE `data:` = JSON)\n// GET /api/pairings?status → PairingRequest[]\n// POST /api/pairings/:id/approve ApprovePairingInput → Agent (the key goes to the polling CLI, never to the console)\n// POST /api/pairings/:id/deny {note?} → PairingRequest\n// (MCP broker, 404 unless Settings.brokerUpstreams)\n// GET /api/connections → ConnectionView[]\n// POST /api/connections CreateConnectionInput → ConnectionView\n// GET /api/connections/presets → PresetView[]\n// POST /api/connections/probe {url, connectionId?} → ConnectionProbe\n// GET /api/connections/:id → ConnectionView\n// PATCH /api/connections/:id UpdateConnectionInput → ConnectionView\n// DELETE /api/connections/:id → 204 (kill switch, then removal)\n// POST /api/connections/:id/kill → 204 (disabled; onKill hooks, then credentials deleted and pending calls cancelled)\n// POST /api/connections/:id/connect StartConnectInput → StartConnectResult\n// POST /api/connections/finish {url} → StartConnectResult (paste mode: the copied landing URL)\n// GET /api/connections/:id/grants → ConnectionGrant[]\n// PUT /api/connections/:id/grants ConnectionGrantsInput → ConnectionGrant[]\n// PUT /api/connections/:id/private-target PrivateTargetInput → ConnectionView\n// POST /api/connections/:id/refresh → { ok, error, connection: ConnectionView } (list the tools again now)\n// GET /api/connections/:id/held → HeldToolView[]\n// POST /api/connections/:id/held/:name/accept {candidateHash} → HeldToolView[] (the rest still held; 409 if it changed again)\n// GET /api/approvals?status → Approval[]\n// POST /api/approvals/:id/approve {note?} → Approval\n// POST /api/approvals/:id/reject {note?} → Approval\n// GET /api/audit?agentId&outcome&cursor → Page<AuditEntry>\n// GET /api/overview → OverviewStats\n//\n// Agent-facing (bearer = agent API key):\n// POST/GET/DELETE /mcp MCP streamable HTTP (stateless)\n// GET /skill.md the agent skill (public, no secrets)\n// /agent/v1/* pairing + listener stream (see shared/agent-api.ts)\n// GET /health HealthResponse (alias of /api/health for uptime monitors; 503 when degraded)\n//\n// Google-facing (unauthenticated; verified by channel id + X-Goog-Channel-Token):\n// POST /google/calendar/push Calendar push notifications (events.watch channels) → 204\n\nexport type ConsoleStreamMessage =\n | { kind: \"event\"; data: MailEvent }\n | { kind: \"delivery\"; data: Delivery }\n | { kind: \"approval\"; data: Approval }\n | { kind: \"audit\"; data: AuditEntry }\n | { kind: \"account\"; data: Account }\n | { kind: \"pairing\"; data: PairingRequest }\n /** An agent's state changed: listener connect/disconnect/ack (at most ~1/s), first use and lastMcpAt, access changes. */\n | { kind: \"agent\"; data: Agent };\n"],"mappings":";;;AAKA,MAAa,cAAc;;AAO3B,MAAa,oBAAoB;CAAC;CAAQ;CAAS;CAAQ;CAAY;AAAO;;;;;;;;AAQ9E,MAAa,wBAAwB,CAAC,iBAAiB,gBAAgB;AACvE,MAAa,eAAe,CAAC,GAAG,mBAAmB,GAAG,qBAAqB;AAG3E,MAAa,wBAAwB,MAAwC,sBAA4C,SAAS,CAAC;;;;;;;;AASnI,MAAa,aAAa;CAAC;CAAU;CAAc;AAAU;;;;;;;AAS7D,MAAa,uBAAuB,CAAC,UAAU,UAAU;;AAIzD,MAAa,oBAAoB;AAWjC,MAAa,cAAc;CACzB;CACA;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;AACF;;;;;;AASA,MAAa,qBAAqB;CAChC;CACA;CACA;CACA;AACF;AAUA,MAAM,kBAAkB;;AAExB,MAAM,0CAA0B,IAAI,IAAI;CAAC;CAAW;CAAU;CAAS;CAAW;CAAQ;CAAY;CAAc;AAAU,CAAC;AAC/H,MAAa,qBAAqB,MAAoC;CACpE,MAAM,IAAI,gBAAgB,KAAK,CAAC;CAChC,OAAO,CAAC,CAAC,KAAK,CAAC,wBAAwB,IAAI,EAAE,EAAG;AAClD;;AAEA,MAAa,yBAAyB,MACnC,mBAAyC,SAAS,CAAC,KAAK,kBAAkB,CAAC;;AAE9E,MAAa,eAAe,MAA+B,YAAkC,SAAS,CAAC,KAAK,sBAAsB,CAAC;AAEnI,MAAa,uBAAuB;CAClC;CACA;CACA;CACA;AACF;AAEA,MAAa,uBAAuB,MAAuC,qBAA2C,SAAS,CAAC;;AAmsBhI,MAAa,oBAAoB,CAAC,UAAU,UAAU;AA2ItD,MAAa,sBAAsB;CAAC;CAAQ;CAAS;AAAS;;AAE9D,MAAa,0BAA0B;CAAC;CAAmB;CAAmB;CAAmB;AAAe;AAShH,MAAa,0BAA0B,MAA0C,wBAA8C,SAAS,CAAC;;AAuKzI,MAAa,mBAAmB;CAAC;CAAe;CAAgB;CAAS;AAAe;;;;;;;AAexF,MAAa,gBAAgB;CAAC;CAAQ;CAAO;AAAM;;AAInD,MAAa,qBAAqB;;AAElC,MAAa,uBAAuB"}
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ import pc from "picocolors";
3
+ //#region server/listen/ui.ts
4
+ /** Visible width of a string (ignores ANSI colour codes). */
5
+ const visible = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").length;
6
+ /** Draws a rounded box around lines of text. */
7
+ function box(lines, color = pc.cyan) {
8
+ const width = Math.max(...lines.map(visible));
9
+ const top = color(`╭${"─".repeat(width + 4)}╮`);
10
+ const bottom = color(`╰${"─".repeat(width + 4)}╯`);
11
+ return [
12
+ top,
13
+ ...lines.map((l) => `${color("│")} ${l}${" ".repeat(width - visible(l))} ${color("│")}`),
14
+ bottom
15
+ ].join("\n");
16
+ }
17
+ /** Makes untrusted text (email subjects, senders, command output) safe to print to a terminal: no escape sequences or control characters. */
18
+ const safeText = (s, max = 200) => {
19
+ const cleaned = s.replace(/[\u0000-\u001f\u007f-\u009f​-‏‪-‮⁦-⁩]/g, " ").replace(/\s+/g, " ").trim();
20
+ return cleaned.length > max ? `${cleaned.slice(0, max - 1)}…` : cleaned;
21
+ };
22
+ function formatDuration(ms) {
23
+ const s = Math.max(0, Math.round(ms / 1e3));
24
+ if (s < 60) return `${s}s`;
25
+ const m = Math.floor(s / 60);
26
+ if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
27
+ return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
28
+ }
29
+ const FRAMES = [
30
+ "⠋",
31
+ "⠙",
32
+ "⠹",
33
+ "⠸",
34
+ "⠼",
35
+ "⠴",
36
+ "⠦",
37
+ "⠧",
38
+ "⠇",
39
+ "⠏"
40
+ ];
41
+ /**
42
+ * A one-line status that redraws in place on a TTY. Without a TTY (CI, an agent capturing output,
43
+ * a log file) it prints a plain line only when the text changes meaningfully, at most every `plainEveryMs`.
44
+ */
45
+ var StatusLine = class {
46
+ out;
47
+ plainEveryMs;
48
+ frame = 0;
49
+ timer = null;
50
+ lastPlain = 0;
51
+ text = "";
52
+ constructor(out = process.stderr, plainEveryMs = 3e4) {
53
+ this.out = out;
54
+ this.plainEveryMs = plainEveryMs;
55
+ }
56
+ get tty() {
57
+ return !!this.out.isTTY && !process.env.CI;
58
+ }
59
+ start(render) {
60
+ const tick = () => {
61
+ this.text = render();
62
+ if (this.tty) this.out.write(`\r\x1b[2K${pc.cyan(FRAMES[this.frame++ % FRAMES.length])} ${this.text}`);
63
+ else if (Date.now() - this.lastPlain >= this.plainEveryMs) {
64
+ this.lastPlain = Date.now();
65
+ this.out.write(`${this.text}\n`);
66
+ }
67
+ };
68
+ tick();
69
+ this.timer = setInterval(tick, this.tty ? 100 : 1e3);
70
+ this.timer.unref();
71
+ }
72
+ stop() {
73
+ if (this.timer) clearInterval(this.timer);
74
+ this.timer = null;
75
+ if (this.tty) this.out.write("\r\x1B[2K");
76
+ }
77
+ };
78
+ //#endregion
79
+ export { safeText as i, box as n, formatDuration as r, StatusLine as t };
80
+
81
+ //# sourceMappingURL=ui-B8Sd4Y7Y.js.map