@dumbmice/pi-mailbridge 0.0.0-stage → 0.1.0

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