@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,1381 @@
1
+ // pi-mailbridge — pi extension entry.
2
+ //
3
+ // Everything here runs INSIDE the agent process. The mailbox is not touched: the
4
+ // daemon owns IMAP, and this file talks to it over loopback.
5
+ //
6
+ // Dependencies are injectable so the registration surface (which events, which
7
+ // commands, which tools) and the settle behaviour can be asserted with a fake
8
+ // `pi`, no daemon and no mailbox.
9
+
10
+ import { statSync } from "node:fs"
11
+ import { Type } from "@sinclair/typebox"
12
+
13
+ import { loadConfig, hasUsableMailConfig, type ResolvedConfig } from "./core/config.ts"
14
+ import { shouldReloadConfig } from "./core/config-freshness.ts"
15
+ import { configPath } from "./store/paths.ts"
16
+ import { ensureSecret, readSecret } from "./store/secret.ts"
17
+ import { formatScan, type ScanSummary } from "./core/status.ts"
18
+ import { createSettleNotifier, type SettleContext, type SettleNotifier } from "./core/settle.ts"
19
+ import { createDeliveryRunner, type DeliveryRunner } from "./core/deliveries.ts"
20
+ import { toUserContent, type UserContent } from "./core/attachments.ts"
21
+ import { parseMailCommand } from "./core/mail-command.ts"
22
+ import { API_VERSION, apiCompatible } from "./daemon/api.ts"
23
+ import { probeHealth, probeHealthDetail } from "./daemon/spawn.ts"
24
+ import { resolveDaemonScript } from "./daemon/script.ts"
25
+ import { daemonBaseUrl, ensureDaemon, createDaemonClient, type DaemonClient } from "./daemon/spawn.ts"
26
+ import { parseMailbridgeCommand, USAGE } from "./core/commands.ts"
27
+ import { runSetup } from "./core/setup.ts"
28
+ import { runSmoke } from "./core/smoke.ts"
29
+ import { buildTokenProvider } from "./core/token-source.ts"
30
+ import { createOpenidClient } from "./core/oauth-openid.ts"
31
+ import { createDeviceCodeProvider } from "./core/oauth-device.ts"
32
+ import { fileTokenStore } from "./store/oauth-store.ts"
33
+ import { ensureRouteForSession, registerRoute } from "./store/routes.ts"
34
+ import { existsSync } from "node:fs"
35
+ import { fileURLToPath } from "node:url"
36
+
37
+ const POLL_MS = 10_000
38
+ /** How often the poll may try to start a dead daemon. */
39
+ const RESPAWN_BACKOFF_MS = 60_000
40
+ /** How often to ask a healthy daemon which build it is. */
41
+ const VERSION_CHECK_MS = 60_000
42
+
43
+ /** The slice of the pi runtime this extension uses. */
44
+ export interface ExtensionLike {
45
+ on(event: string, handler: (event: Record<string, unknown>, ctx: ExtensionCtxLike) => unknown): void
46
+ registerCommand(name: string, spec: { description: string; handler: (args: string, ctx: ExtensionCtxLike) => Promise<void> }): void
47
+ registerTool(spec: {
48
+ name: string
49
+ label: string
50
+ description: string
51
+ parameters: unknown
52
+ execute: (
53
+ toolCallId: string,
54
+ params: Record<string, unknown>,
55
+ signal: unknown,
56
+ onUpdate: unknown,
57
+ ctx: ExtensionCtxLike,
58
+ ) => Promise<{ content: { type: "text"; text: string }[]; details: Record<string, unknown> }>
59
+ }): void
60
+ /** Used to answer a reply inside the session. */
61
+ sendUserMessage(
62
+ content: string | UserContent[],
63
+ options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean },
64
+ ): void
65
+ }
66
+
67
+ export interface ExtensionCtxLike {
68
+ cwd: string
69
+ sessionManager: {
70
+ getSessionId(): string
71
+ /** The real API. `getName` was a mistake here and always returned undefined. */
72
+ getSessionName?(): string | undefined
73
+ getName?(): string | undefined
74
+ /** Current branch entries; assistant text and turn count come from here. */
75
+ getBranch?(): unknown[]
76
+ }
77
+ ui: { notify(message: string, level?: "info" | "warning" | "error"): void }
78
+ }
79
+
80
+ export interface SettleTimers {
81
+ setTimeout(handler: () => void, ms: number): unknown
82
+ clearTimeout(handle: unknown): void
83
+ }
84
+
85
+ export interface MailbridgeDeps {
86
+ env?: NodeJS.ProcessEnv
87
+ loadConfig?: () => ResolvedConfig
88
+ secret?: () => Buffer | undefined
89
+ /** Build a daemon client for a base URL and bearer token. */
90
+ makeClient?: (baseUrl: string, token: string) => DaemonClient
91
+ /** Make sure the daemon is up. */
92
+ ensureDaemon?: (
93
+ baseUrl: string,
94
+ scriptPath: string,
95
+ ) => Promise<
96
+ boolean | { healthy: boolean; started?: boolean; incompatible?: boolean; error?: string; apiVersion?: number }
97
+ >
98
+ /** Injected by tests: is something answering on this base URL? */
99
+ probeHealth?: (options: { baseUrl: string }) => Promise<boolean>
100
+ /** As above, but reporting the protocol that answered. */
101
+ probeHealthDetail?: (options: { baseUrl: string }) => Promise<{ ok: boolean; apiVersion?: number }>
102
+ /** How often to re-check the daemon's protocol. Defaults to a minute. */
103
+ versionCheckMs?: number
104
+ /** How often the poll may respawn a dead daemon. Defaults to a minute. */
105
+ respawnBackoffMs?: number
106
+ /** How long to wait for a stopped daemon's port to close. Defaults to five seconds. */
107
+ stopWaitMs?: number
108
+ /** How long a command waits for a tick it interrupted. Defaults to eight seconds. */
109
+ joinTickMs?: number
110
+ timers?: SettleTimers
111
+ /** Poll interval for pending replies. */
112
+ pollMs?: number
113
+ }
114
+
115
+ const defaultTimers: SettleTimers = {
116
+ setTimeout: (handler, ms) => setTimeout(handler, ms),
117
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
118
+ }
119
+
120
+ export default function mailbridge(pi: ExtensionLike, deps: MailbridgeDeps = {}): void {
121
+ const env = deps.env ?? process.env
122
+ const load = deps.loadConfig ?? (() => loadConfig(env))
123
+ let config: ResolvedConfig = load()
124
+ /** The address the current client was built with; may lag `config` after a port edit. */
125
+ let clientBase: string | undefined
126
+ const secretFn = deps.secret ?? (() => readSecret(env))
127
+ const timers = deps.timers ?? defaultTimers
128
+ const pollMs = deps.pollMs ?? POLL_MS
129
+
130
+ let ctx: ExtensionCtxLike | undefined
131
+ /** Set by stopPolling(): a tick already in flight must not touch the daemon. */
132
+ let pollPaused = false
133
+ /** The tick that may be inside an await right now; see trackTick/settleTick. */
134
+ let tickInFlight: Promise<void> | undefined
135
+ let client: DaemonClient | undefined
136
+ let runner: DeliveryRunner | undefined
137
+ let pollHandle: unknown
138
+ let notifier: SettleNotifier | undefined
139
+ /** Mirror of the daemon's global mode. Default off: never mail unprompted. */
140
+ let emailMode: "on" | "off" = "off"
141
+ /** Stable id for this pi process, used for claims and for liveness pings. */
142
+ const instanceId = `pi-${process.pid}`
143
+ /** Throttle for respawning a daemon that exited for idleness. */
144
+ let lastRespawnAttempt = 0
145
+ /** Throttles the post-update daemon replacement; a failed respawn must not loop. */
146
+ let lastVersionCheck = 0
147
+ /** Set when the daemon rejects our bearer: said once, not every poll tick. */
148
+ let tokenRejected = false
149
+ /** Set when a respawn still spoke another protocol: only a new pi session can fix that. */
150
+ let protocolStuck = false
151
+
152
+ // Computed per use, not once: `config` is replaced whenever a /mailbridge command
153
+ // reloads it, and a frozen URL means editing daemon.port leaves every probe (including
154
+ // /mailbridge restart, the documented remedy) dialling the old port forever.
155
+ const currentBaseUrl = (): string => daemonBaseUrl(config.daemon.port, config.daemon.host)
156
+
157
+ /**
158
+ * Last assistant text and turn count from the session branch.
159
+ *
160
+ * Without this the notification body is just a placeholder — and the point of the
161
+ * email is to read the answer from whatever device you happen to be on. Branch
162
+ * entries are `{ type: "message", message: { role, content } }`; assistant
163
+ * content is `(TextContent | ThinkingContent | ToolCall)[]`, so only `text`
164
+ * parts are joined (thinking and tool calls are not the answer).
165
+ */
166
+ function readSessionSnapshot(manager: ExtensionCtxLike["sessionManager"]): {
167
+ lastAssistantText?: string
168
+ turns?: number
169
+ } {
170
+ const branch = manager.getBranch?.() ?? []
171
+ let lastAssistantText: string | undefined
172
+ let turns = 0
173
+
174
+ for (let i = branch.length - 1; i >= 0; i--) {
175
+ const entry = branch[i] as { type?: string; message?: { role?: string; content?: unknown } } | undefined
176
+ if (entry?.type !== "message" || entry.message?.role !== "assistant") continue
177
+ turns++
178
+ if (lastAssistantText) continue
179
+
180
+ const content = entry.message.content
181
+ if (typeof content === "string" && content.trim()) {
182
+ lastAssistantText = content.trim()
183
+ continue
184
+ }
185
+ if (!Array.isArray(content)) continue
186
+ const text = content
187
+ .filter((part): part is { type: "text"; text: string } => {
188
+ const p = part as { type?: unknown; text?: unknown }
189
+ return p?.type === "text" && typeof p.text === "string"
190
+ })
191
+ .map((part) => part.text)
192
+ .join("\n")
193
+ .trim()
194
+ if (text) lastAssistantText = text
195
+ }
196
+
197
+ return { ...(lastAssistantText ? { lastAssistantText } : {}), ...(turns > 0 ? { turns } : {}) }
198
+ }
199
+
200
+ function sessionContext(): SettleContext | undefined {
201
+ if (!ctx) return undefined
202
+ const sessionId = ctx.sessionManager.getSessionId()
203
+ // `getSessionName()` is the documented method; `getName` never existed.
204
+ const name = ctx.sessionManager.getSessionName?.() ?? ctx.sessionManager.getName?.()
205
+ return {
206
+ sessionId,
207
+ ...(name ? { sessionName: name } : {}),
208
+ device: config.device.name,
209
+ cwd: ctx.cwd,
210
+ ...readSessionSnapshot(ctx.sessionManager),
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Park the loop.
216
+ *
217
+ * Clearing the timer is not enough on its own: a tick that entered just before this call is
218
+ * already inside `tick()`, and it would find the daemon this command is stopping, respawn it,
219
+ * and manufacture the "still answering" evidence the command is measuring. The flag makes a
220
+ * tick in flight a no-op.
221
+ */
222
+ function stopPolling(): void {
223
+ pollPaused = true
224
+ if (pollHandle !== undefined) {
225
+ timers.clearTimeout(pollHandle)
226
+ pollHandle = undefined
227
+ }
228
+ }
229
+
230
+ function startPolling(): void {
231
+ stopPolling()
232
+ pollPaused = false
233
+ schedulePoll()
234
+ }
235
+
236
+ /**
237
+ * Start a tick, and remember it while it runs.
238
+ *
239
+ * A pause cannot stop a tick that is already inside an await - and one of those awaits spawns
240
+ * a daemon. The command that parks the loop waits for the tick it interrupted instead, so the
241
+ * daemon that tick started is on the port before the command looks at it.
242
+ */
243
+ function trackTick(running: Promise<void>): void {
244
+ tickInFlight = running
245
+ // `.then(clear, clear)`: a rejecting tick must not surface as an unhandled rejection.
246
+ void running.then(
247
+ () => {
248
+ if (tickInFlight === running) tickInFlight = undefined
249
+ },
250
+ () => {
251
+ if (tickInFlight === running) tickInFlight = undefined
252
+ },
253
+ )
254
+ }
255
+
256
+ /**
257
+ * Wait for an interrupted tick, bounded. Returns true when the budget ran out first.
258
+ *
259
+ * The budget has to exceed what a tick can spend inside `ensureDaemon` (a spawn, then probes
260
+ * for `waitMs`), or the join expires exactly in the window it exists to cover. The timer is
261
+ * cleared and uses the injected clock: a leaked five-second timer per command would keep the
262
+ * process alive for nothing.
263
+ */
264
+ async function settleTick(): Promise<boolean> {
265
+ const running = tickInFlight
266
+ if (running === undefined) return false
267
+ // A real timer, not the injected (fake) clock: it bounds a wait on another promise, so
268
+ // nothing else can resolve it. Cleared below, so it never outlives the command.
269
+ let handle: ReturnType<typeof setTimeout> | undefined
270
+ const expired = new Promise<boolean>((resolve) => {
271
+ handle = setTimeout(() => resolve(true), deps.joinTickMs ?? 8_000)
272
+ })
273
+ try {
274
+ return await Promise.race([running.then(() => false), expired])
275
+ } finally {
276
+ if (handle !== undefined) clearTimeout(handle)
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Arm the timer without touching `pollPaused`.
282
+ *
283
+ * For callers inside an await - `replaceOutdatedDaemon` runs within a command that parked the
284
+ * loop, and clearing the pause there would un-park it from underneath.
285
+ */
286
+ function schedulePoll(): void {
287
+ const tick = async () => {
288
+ if (pollPaused) return
289
+ try {
290
+ // Heartbeat: the daemon exits when every session stops pinging, so a quiet
291
+ // daemon is a stopping daemon.
292
+ const ping = client ? await client.ping(instanceId) : undefined
293
+ // Checked again after the await: a tick that entered before `stopPolling()` was
294
+ // suspended right here, and with the client nulled by the command it would read as
295
+ // "dead daemon" and respawn inside the wait that is measuring the port.
296
+ if (pollPaused) return
297
+ if (!client || (ping && !ping.ok)) {
298
+ // No client is not only "dead daemon": it can also be "a daemon on another
299
+ // protocol that this session never managed to replace". Only the no-client case
300
+ // is checked here - with a live client, a failed ping means it is gone, and the
301
+ // protocol is the else-branch's business.
302
+ if (!client && (await replaceOutdatedDaemon())) return
303
+ if (pollPaused) return
304
+ // The daemon exited for idleness (or crashed). Nothing else would bring it
305
+ // back for this session, so the poll becomes the supervisor.
306
+ const now = Date.now()
307
+ if (now - lastRespawnAttempt > (deps.respawnBackoffMs ?? RESPAWN_BACKOFF_MS)) {
308
+ lastRespawnAttempt = now
309
+ const healthy = await ensureDaemonSafe()
310
+ // The spawn itself can take seconds. A paused tick that returns here has
311
+ // already created the daemon the restart is about to measure.
312
+ if (pollPaused) return
313
+ if (healthy) {
314
+ // A settle waiting on its debounce is the one mail this product exists
315
+ // to send. startRuntime() rebuilds the notifier, so flush the old one
316
+ // after the client is live again - never cancel it.
317
+ await replaceRuntime()
318
+ }
319
+ }
320
+ // Fall through to the reschedule below: returning here is what stopped the
321
+ // poll loop permanently after a single failed ping.
322
+ } else {
323
+ // After `pi update` the daemon can still be running the previous build: it
324
+ // mints nothing and resolves nothing, so every reply after the upgrade would
325
+ // be rejected with the cursor already past it. Replace it once the build
326
+ // differs, throttled so a failing respawn cannot loop.
327
+ if (await replaceOutdatedDaemon()) return
328
+ if (pollPaused) return
329
+ const mode = await client?.mode()
330
+ if (pollPaused) return
331
+ if (mode?.ok) emailMode = mode.data.mode === "on" ? "on" : "off"
332
+ // `/mailbridge off` closes the channel: no claim, no injection while it is off.
333
+ // Anything already queued stays pending and is delivered on the next
334
+ // `on`, which is why the docs say so explicitly.
335
+ if (emailMode === "on") await runner?.drain()
336
+ }
337
+ } catch {
338
+ /* drain already swallows; a poll must never throw into the runtime */
339
+ } finally {
340
+ // Re-arm whenever this session is alive - not only when a runner exists: a
341
+ // daemon replacement clears the runner for a moment, and tying the loop to it
342
+ // stopped polling exactly when the daemon was missing.
343
+ if (ctx && pollHandle === undefined && !pollPaused) {
344
+ pollHandle = timers.setTimeout(() => {
345
+ pollHandle = undefined
346
+ trackTick(tick())
347
+ }, pollMs)
348
+ }
349
+ }
350
+ }
351
+ // Clearing first: arming twice leaves the previous handle live and untracked, so both
352
+ // fire and the next re-arm adds a third. (Unreachable from the two current callers, which
353
+ // always run with no handle - this keeps it that way if a third appears.)
354
+ if (pollHandle !== undefined) {
355
+ timers.clearTimeout(pollHandle)
356
+ pollHandle = undefined
357
+ }
358
+ pollHandle = timers.setTimeout(() => {
359
+ pollHandle = undefined
360
+ trackTick(tick())
361
+ }, pollMs)
362
+ }
363
+
364
+ // ---- lifecycle ---------------------------------------------------------
365
+
366
+ /**
367
+ * Is the running daemon this build? Returns true when it replaced one.
368
+ *
369
+ * The extension and the daemon are separate processes with separate lifetimes: `pi update`
370
+ * reloads this module while the daemon keeps executing what it started with. A version it
371
+ * cannot resolve routing ids against is worse than no daemon, because replies are consumed.
372
+ */
373
+ async function replaceOutdatedDaemon(known?: { apiVersion?: number; incompatible?: boolean }): Promise<boolean> {
374
+ // Once a respawn has proved the mismatch is not ours to fix, stop trying.
375
+ if (protocolStuck) return false
376
+ if (Date.now() - lastVersionCheck < (deps.versionCheckMs ?? VERSION_CHECK_MS)) return false
377
+ lastVersionCheck = Date.now()
378
+ // No client yet (a fresh session that found a daemon from another install) is exactly the
379
+ // case this must handle: use what the spawn probe already saw, or ask the daemon directly.
380
+ // `undefined` means *nothing answered* - the dead-daemon path owns that, and treating it as
381
+ // a protocol mismatch would "replace" a daemon that is not there instead of respawning it.
382
+ const health = client ? await client.health() : undefined
383
+ let theirs = known?.incompatible ? (known.apiVersion ?? 0) : known?.apiVersion
384
+ if (theirs === undefined && health?.ok) theirs = health.data.apiVersion ?? 0
385
+ if (theirs === undefined && !client) {
386
+ // No client and no client to ask: ask the port. `ok:false` (nothing answered) stays
387
+ // undefined so the dead-daemon path keeps ownership of a daemon that is not there.
388
+ const found = await probeDetail({ baseUrl: clientBase ?? currentBaseUrl() })
389
+ if (found.ok) theirs = found.apiVersion ?? 0
390
+ }
391
+ if (theirs === undefined || apiCompatible(API_VERSION, theirs)) return false
392
+ // The awaits above can take seconds; a command may have parked the loop in the meantime.
393
+ if (pollPaused) return false
394
+
395
+ ctx?.ui.notify(
396
+ `mailbridge: replacing the running daemon (protocol ${theirs ?? "0"} → ${API_VERSION})`,
397
+ "info",
398
+ )
399
+ // To stop it we need a client, even if this session has no runtime yet.
400
+ const stopping = client ?? buildClient()
401
+ await stopping?.shutdown().catch(() => undefined)
402
+ client = undefined
403
+ runner = undefined
404
+
405
+ // `/shutdown` answers before it closes the listener, so an immediate probe finds the
406
+ // dying daemon and "reuses" it - the replacement then reports success while the old
407
+ // protocol is still being served. Wait for the port to stop answering.
408
+ await waitForPortToClose(clientBase ?? currentBaseUrl())
409
+ // Stopped, and parked: the command that parked the loop owns the respawn, and spawning
410
+ // here would hand it the survivor it is measuring.
411
+ if (pollPaused) return false
412
+
413
+ // A failed respawn must not end the poll loop: with `runner` gone, a re-arm tied to it
414
+ // would stop the only thing that notices the daemon is missing.
415
+ const ensured = await ensureDaemonDetailed()
416
+ if (pollPaused) return false
417
+ if (!ensured.healthy || !(await replaceRuntime())) {
418
+ ctx?.ui.notify(
419
+ ensured.incompatible
420
+ ? `mailbridge: the daemon is still on protocol ${ensured.apiVersion ?? "0"} (this session: ${API_VERSION}) — /mailbridge restart forces it`
421
+ : `mailbridge: the daemon did not come back after the update${ensured.error ? ` (${ensured.error})` : ""} — retrying, and /mailbridge restart forces it`,
422
+ "warning",
423
+ )
424
+ return true
425
+ }
426
+
427
+ // If the respawn brought back another protocol again, this session is older than the
428
+ // package on disk. Replacing forever would kill the bridge once a minute, silently.
429
+ const after = await (client as DaemonClient | undefined)?.health()
430
+ if (after?.ok && !apiCompatible(API_VERSION, after.data.apiVersion)) {
431
+ protocolStuck = true
432
+ ctx?.ui.notify(
433
+ `mailbridge: this pi session speaks protocol ${API_VERSION} and the installed daemon speaks ` +
434
+ `${after.data.apiVersion ?? "0"} — restart pi to load the matching extension`,
435
+ "error",
436
+ )
437
+ return true
438
+ }
439
+ schedulePoll()
440
+ return true
441
+ }
442
+
443
+ const probe = deps.probeHealth ?? probeHealth
444
+ const probeDetail =
445
+ deps.probeHealthDetail ??
446
+ (deps.probeHealth
447
+ ? async (options: { baseUrl: string }): Promise<{ ok: boolean; apiVersion?: number }> =>
448
+ // A seam that only says "something answers" cannot also say "and it is protocol 0",
449
+ // so it means the protocol this build speaks - otherwise every such stub looks
450
+ // like an outdated daemon and gets shut down.
451
+ ({ ok: await deps.probeHealth!(options), apiVersion: API_VERSION })
452
+ : probeHealthDetail)
453
+
454
+ // The pi manifest points at this TypeScript file inside the installed package, so the daemon
455
+ // beside it must be the compiled build wherever one exists (Node refuses type stripping in
456
+ // node_modules). See daemon/script.ts.
457
+ const daemonScript = (): string => resolveDaemonScript(import.meta.url)
458
+
459
+ /**
460
+ * Wait for the listener to close, bounded, and report whether something still answers.
461
+ *
462
+ * `/shutdown` answers before it closes: the handler releases the lock, stops the watcher and
463
+ * logs out of IMAP first, so a wedged connection keeps `/health` alive well past the reply.
464
+ * The return value is the *last* probe, so a caller does not ask a second time.
465
+ */
466
+ async function waitForPortToClose(base: string): Promise<boolean> {
467
+ const deadline = Date.now() + (deps.stopWaitMs ?? 5_000)
468
+ while (Date.now() < deadline && (await probe({ baseUrl: base }))) {
469
+ await new Promise((resolve) => setTimeout(resolve, 100))
470
+ }
471
+ return probe({ baseUrl: base })
472
+ }
473
+
474
+ /**
475
+ * Build the client, notifier and delivery runner. Called on session_start and again
476
+ * if the poll tick finds the daemon gone, so a session recovers without a reload.
477
+ */
478
+ /**
479
+ * Rebuild the runtime, carrying a debounced settle across the gap.
480
+ *
481
+ * Every replacement path (dead daemon, version mismatch, /mailbridge restart) needs the same
482
+ * three steps, and each used to do them itself - which is why the same dropped-notification
483
+ * bug had to be fixed three times. It lives here now.
484
+ */
485
+ async function replaceRuntime(): Promise<boolean> {
486
+ const stale = notifier
487
+ notifier = undefined
488
+ if (!(await startRuntime())) return false
489
+ await stale?.flush().catch(() => undefined)
490
+ return true
491
+ }
492
+
493
+ /** A client for the current base URL, or undefined when there is no secret yet. */
494
+ function buildClient(secret: Buffer | undefined = secretFn()): DaemonClient | undefined {
495
+ if (!secret) return undefined
496
+ const token = secret.toString("base64url")
497
+ const makeClient =
498
+ deps.makeClient ??
499
+ ((base: string, tok: string) => createDaemonClient({ baseUrl: base, token: tok, device: config.device.name }))
500
+ return makeClient(clientBase ?? currentBaseUrl(), token)
501
+ }
502
+
503
+ async function startRuntime(): Promise<boolean> {
504
+ const current = ctx
505
+ if (!current) return false
506
+ const secret = secretFn()
507
+ if (!secret) return false
508
+
509
+ clientBase = currentBaseUrl()
510
+ client = buildClient(secret)
511
+ if (!client) return false
512
+ const active = client
513
+
514
+ const initialMode = await active.mode()
515
+ // A session_shutdown can land during that await. Without this the runtime is rebuilt
516
+ // around a dead ctx: the poll keeps pinging and the daemon's idle exit can never fire.
517
+ if (!ctx) {
518
+ client = undefined
519
+ return false
520
+ }
521
+ // A 401 is not "the mode is off": the daemon rejected this session's bearer, which means a
522
+ // rotated/removed `secret.key`. Flipping the mirror to "off" there silently stopped every
523
+ // notification and drain, and the poll then respawned a daemon that kept rejecting us.
524
+ if (!initialMode.ok && initialMode.status === 401 && !tokenRejected) {
525
+ tokenRejected = true
526
+ ctx.ui.notify(
527
+ "mailbridge: the daemon rejected this session's bearer token — restart pi (or /mailbridge restart) to load the current secret",
528
+ "error",
529
+ )
530
+ }
531
+ if (initialMode.ok) emailMode = initialMode.data.mode === "on" ? "on" : "off"
532
+
533
+ notifier = createSettleNotifier({
534
+ mode: () => emailMode,
535
+ context: sessionContext,
536
+ send: async (message) => {
537
+ const sent = await active.send({
538
+ subject: message.subject,
539
+ text: message.text,
540
+ // Exempt from the notification budget: a burst of `notify_user` mail must not
541
+ // be able to silence "your agent is waiting".
542
+ kind: "settle",
543
+ ...(ctx ? { cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId() } : {}),
544
+ })
545
+ // A non-ok result used to be discarded here, so a refused or timed-out send
546
+ // vanished: no toast, no log, no retry. Throwing routes it to onError.
547
+ // `sent === undefined` means there is no client at all; reporting that as a
548
+ // success would make "nothing was sent" indistinguishable from "sent".
549
+ if (!sent) throw new Error("send failed (daemon unavailable)")
550
+ if (!sent.ok) {
551
+ throw new Error(
552
+ sent.code === "send_budget"
553
+ ? "notification budget exhausted (10 a minute)"
554
+ : `send failed (${sent.code ?? sent.status})`,
555
+ )
556
+ }
557
+ },
558
+ secret: () => secret,
559
+ config: { ...config.notify, tokenTtlHours: config.security.tokenTtlHours },
560
+ tagFor: (context) =>
561
+ ensureRouteForSession({
562
+ sessionId: context.sessionId,
563
+ ...(context.device ? { device: context.device } : {}),
564
+ subject: `pi: ${context.sessionName ?? context.cwd}`,
565
+ ttlHours: config.security.tokenTtlHours,
566
+ env,
567
+ }),
568
+ timers,
569
+ onError: (error) => ctx?.ui.notify(`mailbridge: could not send (${error.message})`, "warning"),
570
+ })
571
+
572
+ runner = createDeliveryRunner({
573
+ client: {
574
+ // Pass every argument through. This adapter has silently dropped one before
575
+ // (uidValidity), which disabled the stale-space guard on the only production
576
+ // client while the store's own test stayed green.
577
+ claim: (uid, id, uidValidity) => active.claim(uid, id, uidValidity),
578
+ ack: (uid, id, sessionId, uidValidity) => active.ack(uid, id, sessionId, uidValidity),
579
+ pending: () => active.pending(),
580
+ },
581
+ instanceId,
582
+ sessionId: () => ctx?.sessionManager.getSessionId(),
583
+ stillDelivering: () => emailMode === "on",
584
+ inject: async (body, record) => {
585
+ // A command by mail runs the same handler the prompt would, and then there is
586
+ // nothing to inject: the human asked for an action, not for the agent to read a
587
+ // sentence. Anything we do not run here stays a message, so a quoted "/mailbridge off" or a
588
+ // refused "/mailbridge setup" is never executed by accident.
589
+ const command = parseMailCommand(body)
590
+ if (command) {
591
+ const handler = commandHandlers.get(command.name)
592
+ if (handler && !ctx) {
593
+ // The session is tearing down. Throwing keeps the record pending so the
594
+ // command runs in a live session; falling through would inject the
595
+ // command text into a dead session and then ack it, consuming the mail.
596
+ throw new Error("no active session for a command reply")
597
+ }
598
+ if (handler && ctx) {
599
+ // A command that throws must NOT propagate: deliveries treats an inject
600
+ // failure as "retry later" and never acks, so the same command would run
601
+ // again every poll tick - and `/mailbridge smoke` would send a real mail
602
+ // each time. The command was handled either way; say it failed and let
603
+ // the delivery be acked.
604
+ try {
605
+ await handler(command.args, ctx)
606
+ } catch (error) {
607
+ ctx.ui.notify(
608
+ `mailbridge: /${command.name} from mail failed: ${(error as Error).message}`,
609
+ "error",
610
+ )
611
+ }
612
+ return
613
+ }
614
+ }
615
+
616
+ const content = toUserContent(body, record.attachments ?? [])
617
+ // A reply is text from a human, never a command. With template expansion on, pi
618
+ // dispatches a message starting with "/" as an extension command and never gives
619
+ // it to the model - so a reply of "/mailbridge off" would run a command, return without a
620
+ // turn, and the runner would ack and \Seen it. The human would believe the agent
621
+ // had read it.
622
+ pi.sendUserMessage(content, { deliverAs: "followUp", expandPromptTemplates: false })
623
+ },
624
+ onError: (error) => ctx?.ui.notify(`mailbridge: reply not delivered (${error.message})`, "warning"),
625
+ })
626
+
627
+ // Ping before the first drain: a drain of several replies can outlast the idle window,
628
+ // and the daemon reaps itself when no ping arrives in that time. A client without ping
629
+ // (tests, older shape) is not a reason to skip the drain.
630
+ try {
631
+ await active.ping(instanceId)
632
+ } catch {
633
+ /* the drain below is what decides */
634
+ }
635
+ // Catch-up on connect, but only when the channel is actually open: `off` must not
636
+ // be bypassed by a session starting up.
637
+ if (emailMode === "on") await runner.drain()
638
+ // Same reason as above, after the drain's awaits.
639
+ if (!ctx) {
640
+ client = undefined
641
+ return false
642
+ }
643
+ return true
644
+ }
645
+
646
+ /**
647
+ * Tell a pre-existing daemon to re-read its config when the file is newer than the
648
+ * config it loaded. Best-effort: a failure here must not block the session.
649
+ */
650
+ async function reloadIfStale(): Promise<void> {
651
+ if (!client) return
652
+ try {
653
+ const health = await client.health()
654
+ if (!health.ok) return
655
+ const loadedAt = health.data.configLoadedAt
656
+ const mtime = statSync(configPath(env), { throwIfNoEntry: false })?.mtimeMs ?? null
657
+ if (!shouldReloadConfig({ configLoadedAt: loadedAt, configMtimeMs: mtime })) return
658
+ const reloaded = await client.reload()
659
+ // Never silent: this is the path that decides which address the agent's mail goes
660
+ // to, so a refused reload (daemon kept the old config) must be visible.
661
+ if (!reloaded.ok) {
662
+ ctx?.ui.notify(
663
+ "mailbridge: the daemon could not reload its config — run /mailbridge restart",
664
+ "warning",
665
+ )
666
+ } else if (reloaded.data.warning) {
667
+ ctx?.ui.notify(`mailbridge: ${reloaded.data.warning}`, "warning")
668
+ }
669
+ } catch {
670
+ /* best effort: a stale config is not a reason to fail the session */
671
+ }
672
+ }
673
+
674
+ async function ensureDaemonSafe(): Promise<boolean> {
675
+ return (await ensureDaemonDetailed()).healthy
676
+ }
677
+
678
+ /**
679
+ * Spawn-or-reuse, keeping the reason. `incompatible` means something answered but spoke
680
+ * another protocol - the replacement path, not the dead-daemon path.
681
+ */
682
+ async function ensureDaemonDetailed(): Promise<{
683
+ healthy: boolean
684
+ /** True when this call spawned the process, rather than finding one. */
685
+ started?: boolean
686
+ incompatible?: boolean
687
+ error?: string
688
+ apiVersion?: number
689
+ }> {
690
+ if (deps.ensureDaemon) {
691
+ const result = await deps.ensureDaemon(currentBaseUrl(), daemonScript())
692
+ return typeof result === "boolean" ? { healthy: result } : result
693
+ }
694
+ return ensureDaemon({ baseUrl: currentBaseUrl(), scriptPath: daemonScript(), env })
695
+ }
696
+
697
+ pi.on("session_start", async (_event, c) => {
698
+ ctx = c
699
+
700
+ // Spawning a daemon that is guaranteed to exit (no mailbox configured) would just
701
+ // cost five seconds on every session start, so check the mailbox first.
702
+ if (!hasUsableMailConfig(config)) {
703
+ c.ui.notify(
704
+ "mailbridge: no mailbox configured — run /mailbridge setup <pi-address> to <your-address>",
705
+ "warning",
706
+ )
707
+ return
708
+ }
709
+
710
+ // The daemon owns secret.key, but waiting for a daemon that only this code can start
711
+ // is a deadlock: on a machine that has never run it, the key does not exist yet and
712
+ // every session would bail out here. Mint it instead (first-writer-wins, 0600).
713
+ if (!secretFn()) {
714
+ try {
715
+ ensureSecret(env)
716
+ } catch (error) {
717
+ c.ui.notify(`mailbridge: could not create the shared secret (${(error as Error).message})`, "error")
718
+ return
719
+ }
720
+ }
721
+
722
+ protocolStuck = false
723
+ tokenRejected = false
724
+ const ensured = await ensureDaemonDetailed()
725
+ if (!ensured.healthy) {
726
+ c.ui.notify(
727
+ ensured.incompatible
728
+ ? "mailbridge: the running daemon speaks another protocol — restarting it"
729
+ : `mailbridge: daemon not reachable${ensured.error ? ` (${ensured.error})` : ""}; replies will not arrive`,
730
+ "warning",
731
+ )
732
+ // An incompatible daemon is the replacement path, not a dead one. It succeeds or it
733
+ // does not; either way this session must not then build a runtime on top of it.
734
+ if (ensured.incompatible) {
735
+ await replaceOutdatedDaemon(ensured)
736
+ // Success re-arms the poll itself; a failure must too, or the session sits with no
737
+ // runtime and no loop while the toast says it is retrying.
738
+ if (ctx) startPolling()
739
+ return
740
+ }
741
+ // Keep polling even with no runtime: the tick retries the spawn, which is the only
742
+ // automatic recovery a session has.
743
+ if (ctx) startPolling()
744
+ return
745
+ }
746
+
747
+ if (!(await startRuntime())) {
748
+ if (ctx) startPolling()
749
+ return
750
+ }
751
+ startPolling()
752
+
753
+ // A daemon that was already running may have read the config before the user edited
754
+ // it (hand-edited config.json, headless --setup). Re-read it rather than mailing the
755
+ // address they just replaced - but do NOT block the session on an IMAP redial, which
756
+ // can take imapflow's full connect timeout when the new config is wrong.
757
+ void reloadIfStale()
758
+ })
759
+
760
+ // The ONLY settle signal. `agent_end` is deliberately not registered: pi may
761
+ // still retry, compact or run queued follow-ups after it.
762
+ pi.on("agent_settled", (event) => {
763
+ notifier?.settled({ aborted: event["aborted"] === true })
764
+ })
765
+
766
+ pi.on("session_shutdown", async () => {
767
+ // A settle inside the debounce window is a notification the user asked for; dropping it
768
+ // because pi is closing makes a one-shot run (`pi -p`) mail nothing at all. The daemon
769
+ // outlives us for the idle timeout, so the send can still land.
770
+ await notifier?.flush().catch(() => undefined)
771
+ notifier?.cancel()
772
+ // Release our liveness slot so an idle daemon can exit promptly rather than
773
+ // waiting out the grace period.
774
+ void client?.bye(instanceId)
775
+ stopPolling()
776
+ client = undefined
777
+ runner = undefined
778
+ ctx = undefined
779
+ })
780
+
781
+ // ---- commands ----------------------------------------------------------
782
+
783
+ async function setMode(mode: "on" | "off", c: ExtensionCtxLike): Promise<void> {
784
+ // No client means this session never built a runtime (it started before a config
785
+ // existed). Writing mode.json alone would report ON with no notifier and no runner - the
786
+ // documented "copy config.example.json yourself" path lands here.
787
+ if (!client && (await startRuntime()) && !pollPaused) startPolling()
788
+ const viaDaemon = client ? await client.setMode(mode) : undefined
789
+ if (viaDaemon?.ok) {
790
+ emailMode = mode
791
+ c.ui.notify(mode === "on" ? "mailbridge: email mode ON" : "mailbridge: email mode OFF", "info")
792
+ return
793
+ }
794
+ // No daemon: fall back to the local file so the mode change is never a silent no-op.
795
+ const { writeMode } = await import("./store/mode-store.ts")
796
+ writeMode(env, mode)
797
+ // The settle notifier reads this mirror, not the file. Without it the command reports
798
+ // ON while every notification is dropped as "mode off".
799
+ emailMode = mode
800
+ c.ui.notify(`mailbridge: email mode ${mode.toUpperCase()} (daemon unreachable, stored locally)`, "warning")
801
+ }
802
+
803
+ // The same handlers back both the in-pi command and a command arriving as a mail reply, so
804
+ // the mode subcommands cannot drift between mail and prompt.
805
+ const commandHandlers = new Map<string, (args: string, c: ExtensionCtxLike) => Promise<void>>()
806
+ const registerCommand = (
807
+ name: string,
808
+ description: string,
809
+ handler: (args: string, c: ExtensionCtxLike) => Promise<void>,
810
+ ): void => {
811
+ commandHandlers.set(name, handler)
812
+ pi.registerCommand(name, { description, handler })
813
+ }
814
+
815
+ registerCommand("mailbridge",
816
+ "Status, on/off, setup, check, auth, smoke, restart",
817
+ async (args, c) => {
818
+ // Re-read config on every command. It can change underneath us: `--setup`
819
+ // run from a shell, or an edit to config.json, would otherwise leave the
820
+ // command surface reporting stale settings until pi restarts.
821
+ config = load()
822
+ const parsed = parseMailbridgeCommand(args)
823
+
824
+ /**
825
+ * Build client/notifier/runner and start polling if this session has no runtime.
826
+ *
827
+ * `session_start` returns early when there is no usable config - which is exactly
828
+ * the state on a fresh install. Every command that can make the config usable
829
+ * (`setup`, `restart`) must therefore be able to wire the runtime afterwards, or
830
+ * the documented first run ends in a session that reports "on" and never mails.
831
+ */
832
+ type UiLevel = "error" | "info" | "warning"
833
+ const ensureRuntime = async (ui: { notify: (m: string, level?: UiLevel) => void }): Promise<boolean> => {
834
+ if (client) return true
835
+ if (!secretFn()) {
836
+ try {
837
+ ensureSecret(env)
838
+ } catch (error) {
839
+ ui.notify(`mailbridge: could not create the shared secret (${(error as Error).message})`, "error")
840
+ return false
841
+ }
842
+ }
843
+ if (!(await ensureDaemonSafe())) {
844
+ ui.notify("mailbridge: daemon not reachable; replies will not arrive", "warning")
845
+ return false
846
+ }
847
+ if (!(await startRuntime())) return false
848
+ // The loop may be parked by a command that owns it; its own finally re-arms.
849
+ if (!pollPaused) startPolling()
850
+ return true
851
+ }
852
+
853
+ const statusReport = async () => {
854
+ const secret = secretFn()
855
+ const health = client ? await client.health() : undefined
856
+ // No client in *this* session is not the same as no daemon on the port.
857
+ const alive = health?.ok ? true : await probe({ baseUrl: clientBase ?? currentBaseUrl() })
858
+ const pending = client ? await client.pending() : undefined
859
+ // The daemon is a separate process with its own lifetime; after `pi update` it can
860
+ // be an older build, so name it here rather than making the user read /health.
861
+ const daemonVersion = health?.ok ? (health.data.version ?? "unknown") : "unknown"
862
+ // The protocol is what matters; the package version is diagnostic.
863
+ const build = health?.ok
864
+ ? apiCompatible(API_VERSION, health.data.apiVersion)
865
+ ? ` — daemon version ${daemonVersion}, protocol ${health.data.apiVersion}`
866
+ : ` — daemon protocol ${health.data.apiVersion ?? "0"}, this session is ${API_VERSION} (replacing it)`
867
+ : ""
868
+ c.ui.notify(
869
+ [
870
+ `daemon: ${health?.ok ? "healthy" : alive ? "running, but not attached to this session" : "unreachable"} (${clientBase ?? currentBaseUrl()})${build}`,
871
+ `device: ${config.device.name}`,
872
+ `mode: ${health?.ok ? health.data.mode : "(unknown)"}`,
873
+ `imap: ${health?.ok ? (health.data as { imap?: string }).imap ?? "unknown" : "(unknown)"}`,
874
+ `session: ${ctx?.sessionManager.getSessionId() ?? "(none)"}`,
875
+ `secret: ${secret ? "present" : "missing"}`,
876
+ `mailbox: ${config.mail.to || "(unset)"}`,
877
+ `allowlist: ${config.security.allowFrom.join(", ") || "(empty — denies all)"}`,
878
+ `pending replies: ${pending?.ok ? pending.data.pending.length : "?"}`,
879
+ // From the same response: /pending reports what it did not offer.
880
+ ...(pending?.ok && (pending.data.withheld ?? 0) > 0
881
+ ? [
882
+ `held for another device: ${pending.data.withheld} (a renamed device.name, or a copied state directory; see docs/ARCHITECTURE.md)`,
883
+ ]
884
+ : []),
885
+ ...formatScan(health?.ok ? (health.data.lastScan ?? null) : null),
886
+ ].join("\n"),
887
+ "info",
888
+ )
889
+ }
890
+
891
+ switch (parsed.sub) {
892
+ case "on":
893
+ return void (await setMode("on", c))
894
+
895
+ case "off":
896
+ return void (await setMode("off", c))
897
+
898
+ case "help":
899
+ return void c.ui.notify(USAGE, "info")
900
+
901
+ case "status":
902
+ return void (await statusReport())
903
+
904
+ case "setup": {
905
+ if (!parsed.address) return void c.ui.notify(`setup needs an address\n\n${USAGE}`, "warning")
906
+ const result = await runSetup({
907
+ address: parsed.address,
908
+ ...(parsed.to ? { to: parsed.to } : {}),
909
+ force: parsed.force,
910
+ env,
911
+ })
912
+ config = load() // pick up what was just written
913
+ const reloaded = client ? await client.reload() : undefined
914
+ c.ui.notify(
915
+ [
916
+ ...result.steps,
917
+ result.next ? `next: ${result.next}` : "",
918
+ client ? (reloaded?.ok ? "daemon reloaded" : "daemon did not reload — run /mailbridge restart") : "",
919
+ ]
920
+ .filter(Boolean)
921
+ .join("\n"),
922
+ result.ok ? "info" : "error",
923
+ )
924
+ // A session that started before the config existed has no runtime yet.
925
+ if (result.ok && !client) {
926
+ const started = await ensureRuntime(c.ui)
927
+ c.ui.notify(
928
+ started
929
+ ? "daemon started — /mailbridge smoke, then /mailbridge on"
930
+ : "config written, but the daemon did not start — /mailbridge restart",
931
+ started ? "info" : "warning",
932
+ )
933
+ }
934
+ return
935
+ }
936
+
937
+ case "check": {
938
+ // Mint rather than advise: "start the daemon once" is unfollowable in the only
939
+ // state where this is missing.
940
+ let secret = secretFn()
941
+ let mintedSecret = false
942
+ if (!secret) {
943
+ try {
944
+ secret = ensureSecret(env)
945
+ mintedSecret = Boolean(secret)
946
+ } catch {
947
+ /* reported below */
948
+ }
949
+ }
950
+ const problems = [...config.warnings]
951
+ if (config.security.allowSelfReplies && config.security.requireDmarcPass) {
952
+ problems.push(
953
+ "allowSelfReplies is on but requireDmarcPass is too: providers that do not stamp " +
954
+ "Authentication-Results on self-addressed mail will reject every reply (dmarc-missing)",
955
+ )
956
+ }
957
+ if (!secret) problems.push("shared secret missing and could not be created")
958
+ // A running daemon caches the bearer token from startup, so a freshly minted key
959
+ // is rejected until it restarts. Say so; "config ok" would be a lie.
960
+ else if (mintedSecret) problems.push("created a new shared secret — restart the daemon so it uses it")
961
+ const usesOAuth = config.mail.imap.authType === "XOAUTH2" || config.mail.smtp.authType === "XOAUTH2"
962
+ if (config.oauth && !usesOAuth) {
963
+ problems.push("oauth is configured but neither account uses XOAUTH2 — it would be ignored")
964
+ }
965
+ if (usesOAuth) {
966
+ const oauthConfigured = Boolean(config.oauth?.issuer && config.oauth.clientId)
967
+ const hasToken = Boolean(fileTokenStore(env).read()?.refreshToken)
968
+ const hasCommand = Boolean((config.mail.smtp.tokenCommand ?? config.mail.imap.tokenCommand ?? []).length)
969
+ if (oauthConfigured && !hasToken) problems.push("oauth configured but not authorised — /mailbridge auth")
970
+ if (!oauthConfigured && !hasCommand) problems.push("XOAUTH2 needs oauth.* or a tokenCommand")
971
+ }
972
+ c.ui.notify(
973
+ problems.length ? problems.map((p) => `! ${p}`).join("\n") : "config ok — run /mailbridge smoke to prove it",
974
+ problems.length ? "warning" : "info",
975
+ )
976
+ return
977
+ }
978
+
979
+ case "auth": {
980
+ if (!config.oauth?.issuer || !config.oauth.clientId) {
981
+ return void c.ui.notify("oauth.issuer and oauth.clientId are required — run /mailbridge setup first", "error")
982
+ }
983
+ if (config.oauth.clientId.startsWith("<")) {
984
+ return void c.ui.notify("oauth.clientId is still the placeholder — register an app (docs/OAUTH.md)", "error")
985
+ }
986
+ try {
987
+ const oauthClient = await createOpenidClient(config.oauth)
988
+ const authorization = await oauthClient.deviceAuthorization()
989
+ c.ui.notify(
990
+ [
991
+ "Approve this login on any device, then wait here:",
992
+ authorization.verificationUri,
993
+ authorization.userCode ? `code: ${authorization.userCode}` : "",
994
+ ]
995
+ .filter(Boolean)
996
+ .join("\n"),
997
+ "info",
998
+ )
999
+ // Poll in the background so the TUI stays usable.
1000
+ void oauthClient
1001
+ .pollDeviceAuthorization()
1002
+ .then(async (tokens) => {
1003
+ if (!tokens.refreshToken) {
1004
+ c.ui.notify("no refresh_token returned — add offline_access to oauth.scope", "error")
1005
+ return
1006
+ }
1007
+ fileTokenStore(env).write({
1008
+ refreshToken: tokens.refreshToken,
1009
+ accessToken: tokens.accessToken,
1010
+ expiresAt: tokens.expiresAt,
1011
+ })
1012
+ const reloaded = client ? await client.reload() : undefined
1013
+ c.ui.notify(
1014
+ `authorised; refresh token stored${reloaded?.ok ? ", daemon reloaded" : ""}`,
1015
+ "info",
1016
+ )
1017
+ })
1018
+ .catch((error: Error) => c.ui.notify(`login failed: ${error.message}`, "error"))
1019
+ } catch (error) {
1020
+ c.ui.notify(`login failed: ${(error as Error).message}`, "error")
1021
+ }
1022
+ return
1023
+ }
1024
+
1025
+ case "smoke": {
1026
+ try {
1027
+ // Reload first: smoke is the documented "does it work?" step after editing
1028
+ // the password, and the daemon holds the config it started with. Without
1029
+ // this, smoke passes (it reads the file) while /send keeps using the old
1030
+ // empty password and the first notification fails.
1031
+ const reloaded = client ? await client.reload() : undefined
1032
+ if (client && reloaded?.ok !== true) {
1033
+ c.ui.notify("daemon did not reload; run /mailbridge restart", "warning")
1034
+ } else if (reloaded?.ok && reloaded.data.warning) {
1035
+ c.ui.notify(`mailbridge: ${reloaded.data.warning}`, "warning")
1036
+ }
1037
+ config = load()
1038
+ const provider = await buildTokenProvider(config, env)
1039
+ const result = await runSmoke(config, {
1040
+ ...(provider ? { getAccessToken: () => provider.getToken() } : {}),
1041
+ })
1042
+ c.ui.notify(
1043
+ [
1044
+ ...result.steps.map((s) => `${s.step} ${s.ok ? "ok" : "FAILED"}: ${s.detail}`),
1045
+ ...result.steps.filter((s) => s.hint).map((s) => `hint: ${s.hint}`),
1046
+ ].join("\n"),
1047
+ result.ok ? "info" : "error",
1048
+ )
1049
+ } catch (error) {
1050
+ c.ui.notify(`smoke failed: ${(error as Error).message}`, "error")
1051
+ }
1052
+ return
1053
+ }
1054
+
1055
+ case "prime": {
1056
+ // First run only: the cursor is what makes mail visible, so moving it after
1057
+ // the bridge has been watching would skip replies that arrived unscanned.
1058
+ let imap: { logout: () => Promise<void> } | undefined
1059
+ try {
1060
+ const provider = await buildTokenProvider(config, env)
1061
+ const { createImapflowFactory } = await import("./daemon/imapflow.ts")
1062
+ const { advanceCursor, isPrimed, markPrimed, readCursor } = await import("./store/uid-cursor.ts")
1063
+ const { alreadyPrimedMessage, runPrime } = await import("./core/prime.ts")
1064
+ const imapClient = await createImapflowFactory({
1065
+ ...(provider ? { getAccessToken: () => provider.getToken() } : {}),
1066
+ })(config, { idleRenewMs: config.tuning.idleRenewMs })
1067
+ imap = imapClient
1068
+ const plan = await runPrime(config, {
1069
+ openMailbox: async (cfg) => {
1070
+ await imapClient.connect()
1071
+ await imapClient.mailboxOpen(cfg.mail.imap.folder || "INBOX")
1072
+ return {
1073
+ search: () => imapClient.search({ uid: "1:*" }, { uid: true }),
1074
+ close: async () => imapClient.logout(),
1075
+ }
1076
+ },
1077
+ readCursor: () => readCursor(env),
1078
+ isPrimed: () => isPrimed(env),
1079
+ markPrimed: () => markPrimed(env),
1080
+ advanceCursor: (uid) => {
1081
+ advanceCursor(env, uid)
1082
+ },
1083
+ })
1084
+ if (!plan.ok) {
1085
+ c.ui.notify(alreadyPrimedMessage(plan.currentUid), "warning")
1086
+ return
1087
+ }
1088
+ const reloaded = client ? await client.reload() : undefined
1089
+ c.ui.notify(
1090
+ `primed: cursor at uid ${plan.highest}; ${plan.count} existing message(s) will not be read${reloaded?.ok ? ", daemon reloaded" : ""}`,
1091
+ "info",
1092
+ )
1093
+ } catch (error) {
1094
+ c.ui.notify(`prime failed: ${(error as Error).message}`, "error")
1095
+ } finally {
1096
+ // A connect that succeeded before mailboxOpen threw would otherwise leak the
1097
+ // connection. logout is idempotent; the CLI does the same.
1098
+ await imap?.logout().catch(() => undefined)
1099
+ }
1100
+ return
1101
+ }
1102
+
1103
+ case "restart": {
1104
+ // A config reload cannot replace running code, so after an update the
1105
+ // daemon would keep executing the old version. Stop the process and let
1106
+ // the supervisor start a fresh one.
1107
+ // Park the poll first. It runs every ten seconds and the wait below lasts up to
1108
+ // five, so a tick landing inside it would hear the dying daemon's ping fail,
1109
+ // spawn or attach a replacement, and make `survivor` true - reporting a failure
1110
+ // for the restart this command just performed. Every exit below re-arms it.
1111
+ stopPolling()
1112
+ let joinExpired = false
1113
+ try {
1114
+ // A tick already past every pause check may be inside a spawn. Wait for it, so
1115
+ // the daemon it started is on the port before this command looks.
1116
+ joinExpired = await settleTick()
1117
+ // To stop it we need a client even when this session has no runtime yet - the
1118
+ // same rule the protocol replacement follows, and the remedy its own warning
1119
+ // names.
1120
+ const stopping = client ?? buildClient()
1121
+ // `ok` only means it acknowledged, and a 401 or a timeout means nothing was
1122
+ // confirmed at all - so none of them decides the outcome. `status: 0` with a
1123
+ // network code is a fetch that never got a reply (refused, or the process died
1124
+ // first): not a failure to stop, and the commonest way to run this command.
1125
+ let nothingToStop = false
1126
+ let survivor = false
1127
+ let rejected = false
1128
+ let refusedBase: string | undefined
1129
+ if (stopping) {
1130
+ const result = await stopping.shutdown().catch(() => undefined)
1131
+ nothingToStop = Boolean(result && !result.ok && result.status === 0 && result.code === "network")
1132
+ // A 401 is not a wedged daemon: retrying cannot help until it exits, and the
1133
+ // advice below has to say so. Only 401: the daemon has no 403 (service.ts
1134
+ // answers 401 for a bad bearer), so a 403 would be something else on the port.
1135
+ rejected = Boolean(result && !result.ok && result.status === 401)
1136
+ if (rejected) {
1137
+ // It answered on the client's base, which may not be the configured one
1138
+ // after a `daemon.port` edit: remember that separately, then check
1139
+ // whether it was still there once the stop had its chance.
1140
+ refusedBase = clientBase ?? currentBaseUrl()
1141
+ }
1142
+ client = undefined
1143
+ runner = undefined
1144
+ // Whether something still answers here is the only evidence that a restart did
1145
+ // or did not happen, and `ensureDaemon` would happily reuse a survivor.
1146
+ // `clientBase` follows the client, which may predate a `daemon.port` edit;
1147
+ // the daemon this command starts will be on the configured one.
1148
+ survivor = await waitForPortToClose(currentBaseUrl())
1149
+ }
1150
+ // A 401 proves only that something answered *then*. If it exited inside the
1151
+ // stop window, the restart worked and must not be reported as a failure.
1152
+ // One probe per base: when the refusing base *is* the configured port, `survivor`
1153
+ // already answers this, and asking twice can disagree.
1154
+ const refusalBase = refusedBase ?? currentBaseUrl()
1155
+ const rejectionSurvived = rejected
1156
+ ? refusalBase === currentBaseUrl()
1157
+ ? survivor
1158
+ : await probe({ baseUrl: refusalBase })
1159
+ : false
1160
+ const ensured = await ensureDaemonDetailed()
1161
+ if (ensured.healthy) {
1162
+ // Cleared before the rebuild: the rebuild is what reads the key again, so a
1163
+ // rejection after it must be able to say so. A daemon that still cannot be
1164
+ // reached never gets here, and keeps both latches.
1165
+ tokenRejected = false
1166
+ protocolStuck = false
1167
+ lastRespawnAttempt = 0
1168
+ // One rebuild, through the helper: it captures the notifier that holds a
1169
+ // debounced settle, rebuilds, and flushes. Calling ensureRuntime first would
1170
+ // overwrite that notifier and lose the settle.
1171
+ if (!secretFn()) {
1172
+ try {
1173
+ ensureSecret(env)
1174
+ } catch {
1175
+ /* reported by the toast below */
1176
+ }
1177
+ }
1178
+ const attached = await replaceRuntime()
1179
+ if (!attached) {
1180
+ c.ui.notify(
1181
+ stopping
1182
+ ? "daemon restarted, but this session could not attach — run /mailbridge restart again"
1183
+ : "the daemon is up, but this session could not attach — run /mailbridge restart again",
1184
+ "warning",
1185
+ )
1186
+ return
1187
+ }
1188
+ }
1189
+ // Tell the user what happened, not what was attempted: an answering daemon that
1190
+ // was never stopped is not a restart.
1191
+ let message: string
1192
+ let level: "info" | "warning" | "error" = "info"
1193
+ if (rejected && rejectionSurvived) {
1194
+ // First: it explains a failed respawn (the rejecting daemon holds the lock,
1195
+ // so the new one exits 0) and it is the only advice that works.
1196
+ level = "error"
1197
+ message =
1198
+ "the daemon rejected this session's key, so it could not be stopped — stop it (or let it idle out) and start pi again"
1199
+ } else if (!ensured.healthy) {
1200
+ level = "error"
1201
+ message = ensured.incompatible
1202
+ ? `daemon is still on another protocol (${ensured.apiVersion ?? "0"} → ${API_VERSION}) — close pi and start it again`
1203
+ : `daemon did not come back${ensured.error ? ` (${ensured.error})` : ""} — run /mailbridge restart again`
1204
+ } else if (survivor && joinExpired) {
1205
+ // The poll was still starting a daemon when the budget ran out, so the thing
1206
+ // on the port may be its spawn rather than something that survived the stop.
1207
+ level = "warning"
1208
+ message = "the poll was still starting a daemon when this ran — run /mailbridge restart again"
1209
+ } else if (survivor && nothingToStop) {
1210
+ // The stop was refused, yet something answers: either the connection failed
1211
+ // between the two processes or a supervisor restarted it. Not a failure, but
1212
+ // "with the current code" is not something this session can claim.
1213
+ level = "warning"
1214
+ message =
1215
+ "the old daemon was already gone, but something is still serving its port — try again, or close pi if it keeps answering"
1216
+ } else if (survivor) {
1217
+ // `ok:true` is not proof of exit: a shutdown that is still logging out of IMAP
1218
+ // keeps answering, and so does a timeout. Only the port can say.
1219
+ level = "error"
1220
+ message = "a daemon is still answering after the stop request — try again, or close pi if it keeps answering"
1221
+ } else if (!stopping && ensured.started) {
1222
+ // First run: no key existed, so there was nothing to stop, and this call is
1223
+ // what created both. Reporting that as a warning would greet every headless
1224
+ // setup with a caveat about a daemon that never existed.
1225
+ message = "daemon started with the current code"
1226
+ } else if (!stopping) {
1227
+ // No key and no client when the stop was tried, and a daemon was already
1228
+ // there - so this session cannot say what it was serving. State the
1229
+ // observation, not a cause: it may have minted its key a moment ago.
1230
+ level = "warning"
1231
+ message =
1232
+ "a daemon was already up, but this session had no key when it tried to stop anything — stop it and run this again"
1233
+ } else {
1234
+ message = nothingToStop
1235
+ ? "the old daemon was already gone; a new one is up with the current code"
1236
+ : "daemon restarted with the current code"
1237
+ }
1238
+ c.ui.notify(message, level)
1239
+ } finally {
1240
+ // One arm for every exit, including a thrown one: parking the loop must never
1241
+ // outlive the command that parked it.
1242
+ startPolling()
1243
+ }
1244
+ return
1245
+ }
1246
+ }
1247
+ },
1248
+ )
1249
+
1250
+ // ---- agent-facing tools ------------------------------------------------
1251
+
1252
+ pi.registerTool({
1253
+ name: "request_decision",
1254
+ label: "Request decision",
1255
+ description:
1256
+ "Email the user a decision request and end the turn. Use only when email mode is on and you cannot proceed without the user's choice.",
1257
+ parameters: Type.Object({
1258
+ subject: Type.String({ description: "Short subject line" }),
1259
+ question: Type.String({ description: "The decision, with enough context to answer from a phone" }),
1260
+ options: Type.Optional(Type.Array(Type.String())),
1261
+ recommendation: Type.Optional(Type.String()),
1262
+ }),
1263
+ async execute(_id, params, _signal, _update, c) {
1264
+ if (!client || !runner) {
1265
+ return { content: [{ type: "text", text: "Mailbridge daemon is not reachable; ask in the terminal instead." }], details: {} }
1266
+ }
1267
+ const mode = await client.mode()
1268
+ if (!mode.ok || mode.data.mode !== "on") {
1269
+ return { content: [{ type: "text", text: "Email mode is off — ask in the terminal." }], details: {} }
1270
+ }
1271
+ const sessionId = c.sessionManager.getSessionId()
1272
+ const reserved = await client.register(sessionId)
1273
+ if (!reserved.ok) {
1274
+ return { content: [{ type: "text", text: "Another decision is already outstanding for this session." }], details: {} }
1275
+ }
1276
+ if (reserved.data.alreadyPending) {
1277
+ return { content: [{ type: "text", text: "A decision is already outstanding for this session; not mailing twice." }], details: {} }
1278
+ }
1279
+
1280
+ // The slot is reserved above. Everything from here must either send the mail or free
1281
+ // the reservation: a leaked slot answers "already outstanding" for the rest of the
1282
+ // session, because re-registering refreshes its TTL.
1283
+ let sent = false
1284
+ try {
1285
+ const secret = secretFn()
1286
+ if (!secret) return { content: [{ type: "text", text: "Mailbridge secret missing." }], details: {} }
1287
+
1288
+ const options = (params["options"] as string[] | undefined) ?? []
1289
+ const recommendation = params["recommendation"] as string | undefined
1290
+ const body = [
1291
+ String(params["question"] ?? ""),
1292
+ options.length ? `\noptions:\n${options.map((o) => `- ${o}`).join("\n")}` : "",
1293
+ recommendation ? `\nrecommendation: ${recommendation}` : "",
1294
+ "\n",
1295
+ "--",
1296
+ "Reply above this line with your answer.",
1297
+ ].join("\n")
1298
+
1299
+ const tag = ensureRouteForSession({
1300
+ sessionId,
1301
+ device: config.device.name,
1302
+ subject: String(params["subject"] ?? "pi: decision"),
1303
+ ttlHours: config.security.tokenTtlHours,
1304
+ env,
1305
+ })
1306
+ const outcome = await client.send({
1307
+ kind: "decision",
1308
+ subject: `[pi:${tag}] ${String(params["subject"] ?? "pi: decision")}`,
1309
+ text: body,
1310
+ cwd: c.cwd,
1311
+ sessionId,
1312
+ })
1313
+ if (!outcome.ok) {
1314
+ const why =
1315
+ outcome.code === "send_budget"
1316
+ ? "Could not send the email: the notification budget is exhausted (10 a minute); ask in the terminal instead"
1317
+ : "Could not send the email; ask in the terminal instead"
1318
+ return { content: [{ type: "text", text: why }], details: {} }
1319
+ }
1320
+ sent = true
1321
+ } catch (error) {
1322
+ return {
1323
+ content: [{ type: "text", text: `Could not email the decision (${(error as Error).message}); ask in the terminal.` }],
1324
+ details: {},
1325
+ }
1326
+ } finally {
1327
+ // Free only a reservation that never produced a mail.
1328
+ if (!sent) await client.release(sessionId).catch(() => undefined)
1329
+ }
1330
+ return { content: [{ type: "text", text: "Emailed the decision. End your turn and wait for the reply." }], details: {} }
1331
+ },
1332
+ })
1333
+
1334
+ pi.registerTool({
1335
+ name: "notify_user",
1336
+ label: "Notify user",
1337
+ description: "Send the user a one-way email update. Does not end the turn and does not wait for a reply.",
1338
+ parameters: Type.Object({ subject: Type.String(), message: Type.String() }),
1339
+ async execute(_id, params, _signal, _update) {
1340
+ if (!client) return { content: [{ type: "text", text: "Mailbridge daemon is not reachable." }], details: {} }
1341
+ const mode = await client.mode()
1342
+ if (!mode.ok || mode.data.mode !== "on") {
1343
+ return { content: [{ type: "text", text: "Email mode is off." }], details: {} }
1344
+ }
1345
+ const secret = secretFn()
1346
+ if (!secret) return { content: [{ type: "text", text: "Mailbridge secret missing." }], details: {} }
1347
+ // No session means no session to route back to: minting a route for "" would be
1348
+ // dead state that the scanner treats as no-route anyway.
1349
+ const context = sessionContext()
1350
+ if (!context?.sessionId) {
1351
+ return { content: [{ type: "text", text: "No active session to attach this notification to." }], details: {} }
1352
+ }
1353
+ const tag = ensureRouteForSession({
1354
+ sessionId: context.sessionId,
1355
+ device: config.device.name,
1356
+ subject: String(params["subject"] ?? "pi: update"),
1357
+ ttlHours: config.security.tokenTtlHours,
1358
+ env,
1359
+ })
1360
+ const sent = await client.send({
1361
+ subject: `[pi:${tag}] ${String(params["subject"] ?? "pi: update")}`,
1362
+ text: String(params["message"] ?? ""),
1363
+ ...((sessionContext()?.cwd ?? undefined) ? { cwd: sessionContext()!.cwd } : {}),
1364
+ ...(sessionContext()?.sessionId ? { sessionId: sessionContext()!.sessionId } : {}),
1365
+ })
1366
+ return {
1367
+ content: [
1368
+ {
1369
+ type: "text",
1370
+ text: sent.ok
1371
+ ? "Sent."
1372
+ : sent.code === "send_budget"
1373
+ ? "Not sent: the notification budget is exhausted (10 a minute)."
1374
+ : "Could not send.",
1375
+ },
1376
+ ],
1377
+ details: {},
1378
+ }
1379
+ },
1380
+ })
1381
+ }