@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.
- package/CHANGELOG.md +824 -0
- package/LICENSE +21 -0
- package/README.md +598 -2
- package/bin/pi-mailbridge.mjs +31 -0
- package/config.example.json +54 -0
- package/dist/core/attachments.js +87 -0
- package/dist/core/authz.js +65 -0
- package/dist/core/commands.js +72 -0
- package/dist/core/config-freshness.js +24 -0
- package/dist/core/config.js +286 -0
- package/dist/core/deliveries.js +180 -0
- package/dist/core/discover.js +282 -0
- package/dist/core/header-encoding.js +70 -0
- package/dist/core/html.js +48 -0
- package/dist/core/inline-images.js +355 -0
- package/dist/core/mail-command.js +62 -0
- package/dist/core/mailer.js +126 -0
- package/dist/core/markdown.js +96 -0
- package/dist/core/oauth-device.js +119 -0
- package/dist/core/oauth-openid.js +48 -0
- package/dist/core/oauth.js +137 -0
- package/dist/core/prime.js +51 -0
- package/dist/core/reply-parse.js +134 -0
- package/dist/core/sent-index.js +135 -0
- package/dist/core/settle.js +122 -0
- package/dist/core/setup.js +100 -0
- package/dist/core/smoke.js +164 -0
- package/dist/core/status.js +56 -0
- package/dist/core/token-source.js +27 -0
- package/dist/core/token.js +196 -0
- package/dist/core/version.js +44 -0
- package/dist/daemon/api.js +46 -0
- package/dist/daemon/imapflow.js +75 -0
- package/dist/daemon/main.js +802 -0
- package/dist/daemon/process.js +270 -0
- package/dist/daemon/scan-queue.js +38 -0
- package/dist/daemon/script.js +44 -0
- package/dist/daemon/send-budget.js +30 -0
- package/dist/daemon/service.js +455 -0
- package/dist/daemon/source-proxy.js +64 -0
- package/dist/daemon/spawn.js +227 -0
- package/dist/daemon/watcher.js +308 -0
- package/dist/extension.js +1233 -0
- package/dist/store/journal.js +92 -0
- package/dist/store/mode-store.js +47 -0
- package/dist/store/oauth-store.js +50 -0
- package/dist/store/paths.js +33 -0
- package/dist/store/pending-store.js +212 -0
- package/dist/store/registry.js +61 -0
- package/dist/store/routes.js +223 -0
- package/dist/store/schema.js +93 -0
- package/dist/store/secret.js +70 -0
- package/dist/store/threads.js +145 -0
- package/dist/store/uid-cursor.js +112 -0
- package/docs/ARCHITECTURE.md +356 -0
- package/docs/MAPPING.md +201 -0
- package/docs/OAUTH.md +448 -0
- package/docs/SECURITY.md +273 -0
- package/docs/images/flow.svg +124 -0
- package/docs/images/notification.png +0 -0
- package/package.json +85 -4
- package/scripts/render-teaser.ts +56 -0
- package/src/core/attachments.ts +129 -0
- package/src/core/authz.ts +87 -0
- package/src/core/commands.ts +99 -0
- package/src/core/config-freshness.ts +33 -0
- package/src/core/config.ts +373 -0
- package/src/core/deliveries.ts +233 -0
- package/src/core/discover.ts +333 -0
- package/src/core/header-encoding.ts +75 -0
- package/src/core/html.ts +62 -0
- package/src/core/inline-images.ts +421 -0
- package/src/core/mail-command.ts +76 -0
- package/src/core/mailer.ts +193 -0
- package/src/core/markdown.ts +114 -0
- package/src/core/oauth-device.ts +195 -0
- package/src/core/oauth-openid.ts +61 -0
- package/src/core/oauth.ts +182 -0
- package/src/core/prime.ts +74 -0
- package/src/core/reply-parse.ts +153 -0
- package/src/core/sent-index.ts +164 -0
- package/src/core/settle.ts +179 -0
- package/src/core/setup.ts +139 -0
- package/src/core/smoke.ts +207 -0
- package/src/core/status.ts +75 -0
- package/src/core/token-source.ts +34 -0
- package/src/core/token.ts +240 -0
- package/src/core/version.ts +45 -0
- package/src/daemon/api.ts +52 -0
- package/src/daemon/imapflow.ts +101 -0
- package/src/daemon/main.ts +832 -0
- package/src/daemon/process.ts +348 -0
- package/src/daemon/scan-queue.ts +45 -0
- package/src/daemon/script.ts +45 -0
- package/src/daemon/send-budget.ts +36 -0
- package/src/daemon/service.ts +550 -0
- package/src/daemon/source-proxy.ts +88 -0
- package/src/daemon/spawn.ts +332 -0
- package/src/daemon/watcher.ts +427 -0
- package/src/extension.ts +1381 -0
- package/src/store/journal.ts +104 -0
- package/src/store/mode-store.ts +60 -0
- package/src/store/oauth-store.ts +55 -0
- package/src/store/paths.ts +38 -0
- package/src/store/pending-store.ts +282 -0
- package/src/store/registry.ts +91 -0
- package/src/store/routes.ts +267 -0
- package/src/store/schema.ts +104 -0
- package/src/store/secret.ts +69 -0
- package/src/store/threads.ts +177 -0
- package/src/store/uid-cursor.ts +135 -0
package/src/extension.ts
ADDED
|
@@ -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
|
+
}
|