@dumbmice/pi-mailbridge 0.0.0-stage → 0.1.1

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 (111) hide show
  1. package/CHANGELOG.md +836 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -2
  4. package/bin/pi-mailbridge.mjs +31 -0
  5. package/config.example.json +54 -0
  6. package/dist/core/attachments.js +87 -0
  7. package/dist/core/authz.js +65 -0
  8. package/dist/core/commands.js +72 -0
  9. package/dist/core/config-freshness.js +24 -0
  10. package/dist/core/config.js +286 -0
  11. package/dist/core/deliveries.js +180 -0
  12. package/dist/core/discover.js +282 -0
  13. package/dist/core/header-encoding.js +70 -0
  14. package/dist/core/html.js +48 -0
  15. package/dist/core/inline-images.js +355 -0
  16. package/dist/core/mail-command.js +62 -0
  17. package/dist/core/mailer.js +126 -0
  18. package/dist/core/markdown.js +96 -0
  19. package/dist/core/oauth-device.js +119 -0
  20. package/dist/core/oauth-openid.js +48 -0
  21. package/dist/core/oauth.js +137 -0
  22. package/dist/core/prime.js +51 -0
  23. package/dist/core/reply-parse.js +134 -0
  24. package/dist/core/sent-index.js +135 -0
  25. package/dist/core/settle.js +122 -0
  26. package/dist/core/setup.js +100 -0
  27. package/dist/core/smoke.js +164 -0
  28. package/dist/core/status.js +56 -0
  29. package/dist/core/token-source.js +27 -0
  30. package/dist/core/token.js +196 -0
  31. package/dist/core/version.js +44 -0
  32. package/dist/daemon/api.js +46 -0
  33. package/dist/daemon/imapflow.js +75 -0
  34. package/dist/daemon/main.js +802 -0
  35. package/dist/daemon/process.js +270 -0
  36. package/dist/daemon/scan-queue.js +38 -0
  37. package/dist/daemon/script.js +44 -0
  38. package/dist/daemon/send-budget.js +30 -0
  39. package/dist/daemon/service.js +455 -0
  40. package/dist/daemon/source-proxy.js +64 -0
  41. package/dist/daemon/spawn.js +227 -0
  42. package/dist/daemon/watcher.js +308 -0
  43. package/dist/extension.js +1233 -0
  44. package/dist/store/journal.js +92 -0
  45. package/dist/store/mode-store.js +47 -0
  46. package/dist/store/oauth-store.js +50 -0
  47. package/dist/store/paths.js +33 -0
  48. package/dist/store/pending-store.js +212 -0
  49. package/dist/store/registry.js +61 -0
  50. package/dist/store/routes.js +223 -0
  51. package/dist/store/schema.js +93 -0
  52. package/dist/store/secret.js +70 -0
  53. package/dist/store/threads.js +145 -0
  54. package/dist/store/uid-cursor.js +112 -0
  55. package/docs/ARCHITECTURE.md +356 -0
  56. package/docs/MAPPING.md +201 -0
  57. package/docs/OAUTH.md +448 -0
  58. package/docs/SECURITY.md +273 -0
  59. package/docs/images/flow.svg +124 -0
  60. package/docs/images/notification.png +0 -0
  61. package/package.json +85 -4
  62. package/scripts/render-teaser.ts +56 -0
  63. package/src/core/attachments.ts +129 -0
  64. package/src/core/authz.ts +87 -0
  65. package/src/core/commands.ts +99 -0
  66. package/src/core/config-freshness.ts +33 -0
  67. package/src/core/config.ts +373 -0
  68. package/src/core/deliveries.ts +233 -0
  69. package/src/core/discover.ts +333 -0
  70. package/src/core/header-encoding.ts +75 -0
  71. package/src/core/html.ts +62 -0
  72. package/src/core/inline-images.ts +421 -0
  73. package/src/core/mail-command.ts +76 -0
  74. package/src/core/mailer.ts +193 -0
  75. package/src/core/markdown.ts +114 -0
  76. package/src/core/oauth-device.ts +195 -0
  77. package/src/core/oauth-openid.ts +61 -0
  78. package/src/core/oauth.ts +182 -0
  79. package/src/core/prime.ts +74 -0
  80. package/src/core/reply-parse.ts +153 -0
  81. package/src/core/sent-index.ts +164 -0
  82. package/src/core/settle.ts +179 -0
  83. package/src/core/setup.ts +139 -0
  84. package/src/core/smoke.ts +207 -0
  85. package/src/core/status.ts +75 -0
  86. package/src/core/token-source.ts +34 -0
  87. package/src/core/token.ts +240 -0
  88. package/src/core/version.ts +45 -0
  89. package/src/daemon/api.ts +52 -0
  90. package/src/daemon/imapflow.ts +101 -0
  91. package/src/daemon/main.ts +832 -0
  92. package/src/daemon/process.ts +348 -0
  93. package/src/daemon/scan-queue.ts +45 -0
  94. package/src/daemon/script.ts +45 -0
  95. package/src/daemon/send-budget.ts +36 -0
  96. package/src/daemon/service.ts +550 -0
  97. package/src/daemon/source-proxy.ts +88 -0
  98. package/src/daemon/spawn.ts +332 -0
  99. package/src/daemon/watcher.ts +427 -0
  100. package/src/extension.ts +1381 -0
  101. package/src/store/journal.ts +104 -0
  102. package/src/store/mode-store.ts +60 -0
  103. package/src/store/oauth-store.ts +55 -0
  104. package/src/store/paths.ts +38 -0
  105. package/src/store/pending-store.ts +282 -0
  106. package/src/store/registry.ts +91 -0
  107. package/src/store/routes.ts +267 -0
  108. package/src/store/schema.ts +104 -0
  109. package/src/store/secret.ts +69 -0
  110. package/src/store/threads.ts +177 -0
  111. package/src/store/uid-cursor.ts +135 -0
@@ -0,0 +1,348 @@
1
+ // Scan → fetch → authorize → parse → persist. Port of opencode-afk `core/process.js`.
2
+ //
3
+ // THE ORDERING HERE IS THE CORRECTNESS PROPERTY (docs/ARCHITECTURE.md §2):
4
+ //
5
+ // 0. skip everything if mode is off `/mailbridge off` must close the channel, not just mute it
6
+ // 1. search `UID <cursor>+1:*` UID cursor, never \Seen, never SUBJECT
7
+ // 2. fetch envelope + body
8
+ // 3. self-echo, part 1: the marker header (before authorization, because in single-mailbox
9
+ // mode the sender IS the human and would otherwise pass every later gate)
10
+ // 4. authorize (loop → robot → allowlist → DMARC)
11
+ // 5. self-echo, part 2: our own recorded Message-ID
12
+ // 5. route: short id, then legacy signed token, then the reply chain (routes, does not authorize)
13
+ // 6. strip quoted text, then filter attachments
14
+ // 7. persist to the durable pending store <- nothing is lost after this
15
+ // 8. advance the cursor <- only after persist
16
+ //
17
+ // Marking \Seen and journaling happen later, in the daemon's /ack handler, once a
18
+ // session has injected the reply. Doing it here would lose replies on a crash.
19
+
20
+ import type { ResolvedConfig } from "../core/config.ts"
21
+ import { authorize } from "../core/authz.ts"
22
+ import { stripQuoted, type HeaderLike } from "../core/reply-parse.ts"
23
+ import { tokenFromSubject } from "../core/token.ts"
24
+ import { selectAttachments, type RawAttachment } from "../core/attachments.ts"
25
+ import { findOutbound, parseMessageIds, lookupChain } from "../store/threads.ts"
26
+ import { routeIdFromSubject, resolveRoute } from "../store/routes.ts"
27
+ import { addPending, expirePending, listPending, type PendingRecord } from "../store/pending-store.ts"
28
+ import { isJournaled } from "../store/journal.ts"
29
+ import { advanceCursor, readCursor } from "../store/uid-cursor.ts"
30
+
31
+ /** Longest body we keep. Well above any real instruction; far below "a whole newsletter". */
32
+ export const MAX_BODY_CHARS = 100_000
33
+
34
+ export interface RawMessage {
35
+ uid: number
36
+ from: string | undefined
37
+ subject: string | undefined
38
+ /** Plain-text body. */
39
+ text: string
40
+ /** Decoded attachments; the pipeline applies the allowlist and caps. */
41
+ attachments?: RawAttachment[]
42
+ headers: HeaderLike
43
+ receivedAt: number
44
+ /** Set when the server reported a message larger than we will parse. */
45
+ tooLarge?: number
46
+ }
47
+
48
+ /**
49
+ * Minimal mail source. `watcher.ts` implements this over imapflow; tests
50
+ * implement it over an in-memory fixture, so the pipeline never needs a socket.
51
+ */
52
+ export interface MailSource {
53
+ /** The mailbox's UIDVALIDITY, or undefined when the source cannot report one. */
54
+ uidValidity(): number | undefined
55
+ /** UIDs strictly greater than `afterUid`, ascending. */
56
+ searchAfter(afterUid: number): Promise<number[]>
57
+ /** The message, or undefined when it vanished between search and fetch. */
58
+ fetch(uid: number): Promise<RawMessage | undefined>
59
+ /**
60
+ * Mark the message seen on the server. Called only from `/ack`, i.e. after a
61
+ * session has injected the reply — never at parse time.
62
+ */
63
+ markSeen?(uid: number): Promise<void>
64
+ }
65
+
66
+ export interface ScanDeps {
67
+ source: MailSource
68
+ /** HMAC key for routing-token verification. */
69
+ secret: Buffer
70
+ env?: NodeJS.ProcessEnv
71
+ now?: () => number
72
+ /** Release a decision slot whose record was dropped, when nothing else holds it. */
73
+ onDropped?: (sessionId: string) => void
74
+ }
75
+
76
+ export interface ScanResult {
77
+ /** UIDs the source returned this scan. */
78
+ examined: number
79
+ /** Deliveries persisted to the pending store. */
80
+ persisted: number
81
+ /** Rejected: not for us, unauthorized, or machine-generated. */
82
+ rejected: number
83
+ /** Already journaled (acked earlier) — dedupe, not a rejection. */
84
+ skipped: number
85
+ /** Per-reason rejection counts, for logs and `/mailbridge status`. */
86
+ reasons: Record<string, number>
87
+ /** Cursor value after the scan. */
88
+ cursor: number
89
+ }
90
+
91
+ function bump(reasons: Record<string, number>, key: string): void {
92
+ reasons[key] = (reasons[key] ?? 0) + 1
93
+ }
94
+
95
+ /**
96
+ * One incremental scan.
97
+ *
98
+ * A message is "handled" (and the cursor moves past it) when it is persisted,
99
+ * rejected, skipped, or has vanished. Only a thrown error from the source or the
100
+ * pending store stops the scan with the cursor left before the offending UID, so
101
+ * the next scan retries it.
102
+ */
103
+ export async function scanAndProcess(config: ResolvedConfig, deps: ScanDeps): Promise<ScanResult> {
104
+ const env = deps.env ?? process.env
105
+ const now = deps.now ?? (() => Date.now())
106
+
107
+ const startCursor = readCursor(env)
108
+ const uidValidity = deps.source.uidValidity?.()
109
+ const uids = (await deps.source.searchAfter(startCursor)).slice().sort((a, b) => a - b)
110
+
111
+ const result: ScanResult = {
112
+ examined: uids.length,
113
+ persisted: 0,
114
+ rejected: 0,
115
+ skipped: 0,
116
+ reasons: {},
117
+ cursor: startCursor,
118
+ }
119
+
120
+ for (const uid of uids) {
121
+ // Dedupe: an acked UID must never be re-offered, whatever the server says.
122
+ if (isJournaled(env, uid, uidValidity)) {
123
+ result.skipped++
124
+ bump(result.reasons, "journaled")
125
+ result.cursor = advanceCursor(env, uid)
126
+ continue
127
+ }
128
+
129
+ const message = await deps.source.fetch(uid)
130
+ if (!message) {
131
+ // Deleted between search and fetch. Nothing to do; do not block the cursor.
132
+ result.skipped++
133
+ bump(result.reasons, "vanished")
134
+ result.cursor = advanceCursor(env, uid)
135
+ continue
136
+ }
137
+
138
+ // The marker is stamped on every notification we send. Seeing it inbound means the
139
+ // mail is ours coming back - check before authorization, because in single-mailbox
140
+ // mode it is From: the human and would otherwise pass every gate.
141
+ if (message.headers.piMailbridge) {
142
+ result.rejected++
143
+ bump(result.reasons, "self-echo")
144
+ result.cursor = advanceCursor(env, uid)
145
+ continue
146
+ }
147
+
148
+ // The watcher refuses to download anything over the size ceiling and marks it here.
149
+ // Reject before authorization: this is our resource guard, not the sender's fault.
150
+ if (message.tooLarge) {
151
+ result.rejected++
152
+ bump(result.reasons, "too-large")
153
+ result.cursor = advanceCursor(env, uid)
154
+ continue
155
+ }
156
+
157
+ const authz = authorize({ from: message.from, headers: message.headers }, config)
158
+ if (!authz.ok) {
159
+ result.rejected++
160
+ bump(result.reasons, authz.reason)
161
+ result.cursor = advanceCursor(env, uid)
162
+ continue
163
+ }
164
+
165
+ // Self-echo, before routing: never treat our own notification as a reply. It arrives
166
+ // From: the mailbox itself, and because notifications now carry In-Reply-To (threading)
167
+ // the single-mailbox guard in authorize() can no longer tell them from a genuine
168
+ // reply. The outbound registry can: if this message's own Message-ID is one we sent,
169
+ // it is ours, whatever its headers say.
170
+ const ownIds = parseMessageIds(message.headers.messageId)
171
+ if (ownIds.length > 0 && ownIds.some((id) => findOutbound(env, id))) {
172
+ result.rejected++
173
+ bump(result.reasons, "self-echo")
174
+ result.cursor = advanceCursor(env, uid)
175
+ continue
176
+ }
177
+
178
+ // Routing, in order of preference:
179
+ // 1. a short local route id [pi:a1b2c3] — what new notifications carry
180
+ // 2. a signed token [pi:<base64>.<mac>] — messages already in flight
181
+ // 3. the reply chain In-Reply-To / References → recorded Message-ID
182
+ // All three are routing only; authorization happened above.
183
+ let sessionId: string | undefined
184
+ const routeId = routeIdFromSubject(message.subject)
185
+
186
+ if (routeId) {
187
+ // Short ids are local, so an id minted on another machine - or one whose 30-day
188
+ // TTL has passed - does not resolve here. Fall through to the older rules rather
189
+ // than dropping the reply: a human answering an old notification should still
190
+ // reach the session through the token or the reply chain.
191
+ const route = resolveRoute(routeId, { device: config.device.name, now: now(), env })
192
+ if (route) {
193
+ sessionId = route.sessionId
194
+ bump(result.reasons, "routed")
195
+ } else {
196
+ bump(result.reasons, "route-miss")
197
+ }
198
+ }
199
+
200
+ if (!sessionId) {
201
+ const token = tokenFromSubject(message.subject, deps.secret, now(), config.device.name)
202
+ if (token) {
203
+ // deviceMatches === false means the token was minted for another machine.
204
+ // undefined means the format carries no device, which is not a mismatch.
205
+ if (token.deviceMatches === false || (token.device && token.device !== config.device.name)) {
206
+ result.rejected++
207
+ bump(result.reasons, "other-device")
208
+ result.cursor = advanceCursor(env, uid)
209
+ continue
210
+ }
211
+ sessionId = token.sessionId
212
+ } else if (config.security.allowHeaderThreading !== false) {
213
+ // The reply chain: a client or human that rewrote the subject still leaves
214
+ // In-Reply-To/References intact, which is how "reply to this mail" works.
215
+ const ids = [
216
+ ...parseMessageIds(message.headers.inReplyTo),
217
+ ...parseMessageIds((message.headers.references ?? []).join(" ")),
218
+ ]
219
+ const thread = lookupChain(env, ids, { now: now(), ttlHours: config.security.tokenTtlHours })
220
+ if (thread?.device && thread.device !== config.device.name) {
221
+ result.rejected++
222
+ bump(result.reasons, "other-device")
223
+ result.cursor = advanceCursor(env, uid)
224
+ continue
225
+ }
226
+ if (thread) {
227
+ sessionId = thread.sessionId
228
+ bump(result.reasons, "threaded")
229
+ }
230
+ }
231
+ }
232
+
233
+ if (!sessionId) {
234
+ result.rejected++
235
+ bump(result.reasons, "no-token")
236
+ result.cursor = advanceCursor(env, uid)
237
+ continue
238
+ }
239
+
240
+ // A body cap as well as a message cap: the pending store is re-read on every poll and
241
+ // on every /health, so one enormous reply would be paid for repeatedly.
242
+ const stripped = stripQuoted(message.text)
243
+ // Marked, not silently cut: the agent must be able to tell that its instruction is
244
+ // incomplete (the outbound truncation note does the same).
245
+ const body =
246
+ stripped.length > MAX_BODY_CHARS
247
+ ? `${stripped.slice(0, MAX_BODY_CHARS)}\n\n[truncated: ${stripped.length - MAX_BODY_CHARS} more characters]`
248
+ : stripped
249
+
250
+ // Attachments are filtered BEFORE persisting; a dropped one is recorded with
251
+ // a reason so `/mailbridge` can explain why the agent did not see it.
252
+ const { accepted, dropped } = selectAttachments(message.attachments ?? [])
253
+ for (const item of dropped) bump(result.reasons, `attachment-${item.reason}`)
254
+
255
+ // A reply is empty only if it has neither prose nor a surviving attachment. Judging
256
+ // the body first discarded the image-from-a-phone case: nothing persisted, the
257
+ // attachment left unread, and the cursor moved past it.
258
+ //
259
+ // The same applies when the *only* content was an attachment that got dropped (a phone
260
+ // image over the 1 MB total, say). Rejecting it loses the human's whole reply with
261
+ // no trace, so it is persisted with a note naming the drop and delivered: the agent can
262
+ // ask for the text, and the mail is no longer sitting past the cursor unread.
263
+ if (!body && accepted.length === 0) {
264
+ const reasons = [...new Set(dropped.map((d) => d.reason))].join(", ")
265
+ if (reasons.length > 0) {
266
+ const note = `[pi-mailbridge: ${dropped.length} attachment(s) dropped (${reasons}); this reply had no text]`
267
+ // Not counted as rejected: the reply *is* delivered, as a note. The reason is
268
+ // named so the panel can say the content was lost, without claiming the mail was.
269
+ bump(result.reasons, "attachment-only-dropped")
270
+ const stub: PendingRecord = {
271
+ uid,
272
+ sessionId,
273
+ from: authz.from,
274
+ subject: message.subject ?? "",
275
+ body: note,
276
+ receivedAt: message.receivedAt ?? now(),
277
+ ...(uidValidity === undefined ? {} : { uidValidity }),
278
+ device: config.device.name,
279
+ }
280
+ const persisted = addPending(env, stub)
281
+ if (!persisted.ok) {
282
+ // Same rule as a real record: do not advance past it, retry when there is room.
283
+ bump(result.reasons, "pending-full")
284
+ break
285
+ }
286
+ result.persisted++
287
+ result.cursor = advanceCursor(env, uid)
288
+ continue
289
+ }
290
+ result.rejected++
291
+ bump(result.reasons, "empty-body")
292
+ result.cursor = advanceCursor(env, uid)
293
+ continue
294
+ }
295
+
296
+ const record: PendingRecord = {
297
+ uid,
298
+ sessionId,
299
+ from: authz.from,
300
+ subject: message.subject ?? "",
301
+ body,
302
+ ...(accepted.length ? { attachments: accepted } : {}),
303
+ receivedAt: message.receivedAt,
304
+ // Re-read per record: a renumber can land mid-scan, and stamping the scan's opening
305
+ // space would leave the tail of the batch unclaimable until the next connect.
306
+ ...((deps.source.uidValidity?.() ?? uidValidity) === undefined
307
+ ? {}
308
+ : { uidValidity: deps.source.uidValidity?.() ?? uidValidity }),
309
+ }
310
+
311
+ // Persist BEFORE the cursor moves. A crash here leaves the mail unseen.
312
+ const added = addPending(env, { ...record, device: config.device.name })
313
+ if (!added.ok) {
314
+ // The queue is full. Before stalling every future reply, drop records nothing can
315
+ // deliver any more - a session that has not come back in a week. This is the only
316
+ // place age-based expiry runs, because it is the only place the loss buys progress.
317
+ const expired = expirePending(env)
318
+ if (expired.length > 0) {
319
+ result.rejected++
320
+ bump(result.reasons, "expired")
321
+ // The same rule as the UID-space drop: free a slot only when nothing else is
322
+ // queued for that session, or a live record would lose its reservation.
323
+ const stillQueued = new Set(listPending(env).map((r) => r.sessionId))
324
+ for (const gone of expired) {
325
+ if (!stillQueued.has(gone.sessionId)) deps.onDropped?.(gone.sessionId)
326
+ }
327
+ const retry = addPending(env, { ...record, device: config.device.name })
328
+ if (retry.ok) {
329
+ result.persisted++
330
+ result.cursor = advanceCursor(env, uid)
331
+ continue
332
+ }
333
+ }
334
+ // The queue is full. STOP the batch: advancing past this UID would lose it, and
335
+ // so would processing a later UID (the cursor is monotonic, so it would jump
336
+ // over this one). The mail stays unread and the whole batch is retried once
337
+ // space appears.
338
+ result.rejected++
339
+ bump(result.reasons, "pending-full")
340
+ break
341
+ }
342
+ result.persisted++
343
+
344
+ result.cursor = advanceCursor(env, uid)
345
+ }
346
+
347
+ return result
348
+ }
@@ -0,0 +1,45 @@
1
+ // One scan at a time, with at most one queued behind it.
2
+ //
3
+ // Three things ask for a scan concurrently: an IMAP `exists`, a reconnect's catch-up, and
4
+ // mode turning on. Overlapping scans are not harmless - two that pass the journal check
5
+ // before either persists would deliver the same reply twice, which is the one outcome this
6
+ // design cannot undo. A burst of IDLE events also should not become a burst of full scans,
7
+ // so a request that arrives while one is running coalesces into a single follow-up.
8
+
9
+ export interface ScanQueue {
10
+ (why: string): Promise<void>
11
+ /** True while a scan is running or one is queued. */
12
+ busy(): boolean
13
+ }
14
+
15
+ export function createScanQueue(run: (why: string) => Promise<void>): ScanQueue {
16
+ let current: Promise<void> | undefined
17
+ let queued: string | undefined
18
+
19
+ const start = (why: string): Promise<void> => {
20
+ current = (async () => {
21
+ try {
22
+ await run(why)
23
+ } catch {
24
+ // A scan failure is logged by the caller; the queue must survive it.
25
+ } finally {
26
+ current = undefined
27
+ if (queued !== undefined) {
28
+ const next = queued
29
+ queued = undefined
30
+ void start(next)
31
+ }
32
+ }
33
+ })()
34
+ return current
35
+ }
36
+
37
+ const scan = (why: string): Promise<void> => {
38
+ if (!current) return start(why)
39
+ // Coalesce: one follow-up covers any number of requests that arrive mid-scan.
40
+ queued ??= why
41
+ return current
42
+ }
43
+
44
+ return Object.assign(scan, { busy: () => current !== undefined })
45
+ }
@@ -0,0 +1,45 @@
1
+ // Which daemon entry should a session spawn?
2
+ //
3
+ // The pi manifest points at `src/extension.ts`, and pi loads it through jiti - so in a published
4
+ // install the loaded module is TypeScript *inside node_modules*, where Node refuses type
5
+ // stripping. The daemon beside it must therefore be the compiled `dist/` entry whenever one
6
+ // exists, whichever shape this module has.
7
+ //
8
+ // Pure and exported so both layouts can be tested without installing a package.
9
+
10
+ import { existsSync } from "node:fs"
11
+ import { dirname, join } from "node:path"
12
+ import { fileURLToPath } from "node:url"
13
+
14
+ /**
15
+ * Resolve the daemon script for the module at `moduleUrl`.
16
+ *
17
+ * - `dist/extension.js` -> `dist/daemon/main.js`
18
+ * - `src/extension.ts` -> `dist/daemon/main.js` when built (a published install)
19
+ * - `src/extension.ts` only -> `src/daemon/main.ts` (a checkout, run with the flag)
20
+ */
21
+ export function resolveDaemonScript(moduleUrl: string | URL, exists: (path: string) => boolean = existsSync): string {
22
+ const here = fileURLToPath(moduleUrl)
23
+ const dir = dirname(here)
24
+ const root = dirname(dir)
25
+ const compiled = here.endsWith(".js")
26
+ const candidates = compiled
27
+ ? [
28
+ // dist/extension.js -> dist/daemon/main.js
29
+ join(dir, "daemon", "main.js"),
30
+ join(root, "dist", "daemon", "main.js"),
31
+ join(dir, "daemon", "main.ts"),
32
+ ]
33
+ : [
34
+ // src/extension.ts -> the build, when the package was published
35
+ join(root, "dist", "daemon", "main.js"),
36
+ join(dir, "daemon", "main.js"),
37
+ // a checkout without a build
38
+ join(dir, "daemon", "main.ts"),
39
+ ]
40
+ for (const candidate of candidates) {
41
+ if (exists(candidate)) return candidate
42
+ }
43
+ // Nothing exists: name the source path, so the failure mentions what the caller expected.
44
+ return join(dir, "daemon", "main.ts")
45
+ }
@@ -0,0 +1,36 @@
1
+ // How much mail this daemon will send per minute.
2
+ //
3
+ // The delivery path is model-driven: `notify_user` and settle notifications can be produced by an
4
+ // agent, and an injected agent can ask for a great many. Providers answer a burst by throttling
5
+ // the account or refusing for the day, which silences the notifications that actually matter.
6
+ // A small sliding window keeps a loop from spending the quota.
7
+ //
8
+ // Deliberately not a queue: a refused send is reported to the caller (`429 send_budget`), so the
9
+ // agent sees it and the human is not mailed a backlog of stale notifications later.
10
+
11
+ export interface SendBudget {
12
+ /** True when a send is allowed now; consumes one slot. */
13
+ take(now: number): boolean
14
+ /** Slots used inside the current window, for tests and diagnostics. */
15
+ used(now: number): number
16
+ }
17
+
18
+ export function createSendBudget(options: { limit: number; windowMs: number }): SendBudget {
19
+ const sent: number[] = []
20
+ const prune = (now: number): void => {
21
+ const floor = now - options.windowMs
22
+ while (sent.length > 0 && sent[0]! <= floor) sent.shift()
23
+ }
24
+ return {
25
+ take(now: number) {
26
+ prune(now)
27
+ if (sent.length >= options.limit) return false
28
+ sent.push(now)
29
+ return true
30
+ },
31
+ used(now: number) {
32
+ prune(now)
33
+ return sent.length
34
+ },
35
+ }
36
+ }