import { R as ResolveConfigOptions, A as AgenticMailAccount } from './config-FrDqSkD0.js'; /** * AgenticMail → OpenAI Codex event dispatcher. * * Long-lived daemon that bridges AgenticMail's event stream to the * OpenAI Codex SDK. Concretely: subscribes to the master API's SSE * stream for every AgenticMail account, and when an event arrives — * either a new mail in some agent's inbox, or a task assigned to some * agent — it spawns a Codex-powered worker that *is* that agent (same * persona, same `_account`-scoped MCP toolbelt) and lets it handle the * trigger. * * This is what makes "send an email to fola@localhost and she wakes up * and replies" work — without any always-on enterprise runtime, and * without an interactive Codex session having to be open. * * # Design notes * * - One SSE connection per account (the master API does not currently * expose a master-key "watch everything" endpoint). The dispatcher * polls `GET /accounts` every `accountSyncIntervalMs` to discover * newly-created accounts and tear down ones that disappeared, so * `create_account` is wake-able within ~one sync interval with zero * manual steps. * * - Workers are spawned via `@openai/codex-sdk`'s `Thread.runStreamed()` * — same OpenAI auth as the user's `codex`, same MCP server, same * persona prompt as the on-disk `.toml`. Each worker drains its event * stream to completion, then exits. Codex emits a different event shape * than Claude (item.started / item.updated / item.completed / turn.*), * so `defaultQuery()` includes a small adapter that translates Codex * events into the Claude-shaped frames the rest of the dispatcher * consumes — keeps the wake / coalesce / budget / catch-up logic * completely host-agnostic. * * - Concurrency is capped via a small semaphore (default 10). Beyond * that, wakes queue. This is a hard floor on OpenAI-side cost: * 50 simultaneous wakes = 50 simultaneous Codex calls, which the * user is unlikely to want by default. * * - Task events get an explicit "claim + submit_result" instruction * in the wake prompt so the call_agent long-poll on the master API * resolves cleanly. Mail events just say "you've got new mail" and * trust the persona to do the right thing (read / reply / archive). */ /** Event shape we accept off the SSE stream. */ interface SSEEvent { type?: string; uid?: number; from?: string; /** * Subject MAY appear at top-level on some code paths AND nested under * `message.subject` on others (the master API enriches new-mail events * with the full IMAP envelope, which lands under `message`). Always use * `extractSubject(event)` rather than reading either path directly. */ subject?: string; message?: { subject?: string; from?: unknown; to?: unknown; messageId?: string; }; taskId?: string; taskType?: string; task?: string; assignee?: string; /** * Optional wake allowlist set by the sender via `send_email({ wake })`. * When present, only listed agents (case-insensitive bare name) get a * Codex turn. When absent, every CC'd recipient wakes (v0.8.x default). */ wakeAllowlist?: string[]; /** Per-recipient "was I on the To field?" flag emitted by the * API in 0.9.1+. Pairs with the recipient's `wake_on_cc` * preference: when the agent registered with wake_on_cc:false * and `wasOnTo !== true`, the dispatcher drops the wake. */ wasOnTo?: boolean; [key: string]: unknown; } interface DispatcherOptions extends ResolveConfigOptions { /** Max concurrent workers. Default 10. Hard floor on Anthropic cost. */ maxConcurrentWorkers?: number; /** How often to re-poll /accounts for new agents. Default 60s. */ accountSyncIntervalMs?: number; /** How long to wait between SSE reconnect attempts (start). Default 2s. */ sseReconnectBaseMs?: number; /** Max backoff between SSE reconnect attempts. Default 60s. */ sseReconnectMaxMs?: number; /** * Max times a single agent is woken on the same thread before the * circuit breaker trips. Default 10. Protects against reply loops, * storms when many agents share a thread, and stuck agents that * keep replying without making progress. Per-(agent, thread). */ maxWakesPerThread?: number; /** * Window (ms) for the per-thread wake counter. Default 24h. The * counter resets after this period elapses since the FIRST wake in * the window — wall-clock-relative, not sliding, so a runaway * thread stays muted for the full period (which is what we want). */ wakeWindowMs?: number; /** Override the SDK `query` function. Used by tests. */ querySdk?: QueryFn; /** Override the global `fetch`. Used by tests. */ fetchImpl?: typeof fetch; /** Override the global EventSource. Optional — we don't use EventSource * by default (fetch + reader is simpler). */ log?: (level: 'info' | 'warn' | 'error', msg: string) => void; /** Override Date.now() — tests use this to advance the budget clock. */ nowMs?: () => number; /** Debounce window for wake-coalescing per (agent, thread). * Default 30 s — covers a typical "burst of back-to-back * replies in 5–15 s" pattern without making single replies * feel sluggish. Set to 0 to disable coalescing entirely * (one Codex turn per event, pre-0.9.0 behaviour). */ wakeCoalesceMs?: number; /** Override the ThreadCache disk root. Tests use a tmpdir; * production runs against ~/.agenticmail/thread-cache/. */ threadCacheDir?: string; /** Override the AgentMemoryStore disk root. Same rationale as * threadCacheDir — only tests should set this. */ agentMemoryDir?: string; /** Override the dispatcher state file (per-account cursors used * for restart recovery). Tests use a tmpdir; production runs * against ~/.agenticmail/dispatcher-state.json. */ stateFilePath?: string; /** * Disable catch-up scan + pending-task scan on channel open. * Default false. Tests that don't want the dispatcher hitting the * inbox/tasks endpoints on first connect set this true. Has no * effect on the persisted seenUids restore — that's always on. */ disableCatchupScan?: boolean; } /** * Minimal SDK-query signature we use. The shape historically matched Claude's * `@anthropic-ai/claude-agent-sdk` query() for historical reasons — * `runWorker` reads frames in that shape and adapter code translates * Codex's events into it (see `defaultQuery()` below). Tests can mock * this directly without going through the Codex SDK. */ interface QueryFn { (params: { prompt: string; options?: Record; }): AsyncIterable; } /** * Per-worker observation channel. `runWorker` calls `onMessage` for every * SDK message — assistant text, tool calls, tool results, result frames. * The caller (spawnWorker) wires this to: * - a per-worker log file at `~/.agenticmail/worker-logs/.log` * - a heartbeat ticker that POSTs progress to /dispatcher/worker-heartbeat * * Kept generic so tests don't need to mock disk + network to verify the * observation path. */ interface WorkerObserver { /** Called once per SDK message. Tag is a short event name. */ onMessage(tag: string, summary: string): void; } /** * The dispatcher itself. Construct once, call .start() to begin watching, * .stop() to tear down. Returns when stop() has finished cleaning up. */ declare class Dispatcher { private cfg; private maxConcurrent; private syncIntervalMs; private reconnectBaseMs; private reconnectMaxMs; private query; private fetchImpl; private log; private channels; private accountSyncTimer; private systemChannelController; private running; private waiters; private stopped; /** * Wake-budget store, keyed by `${accountId}::${threadId}`. See the * comment block on WakeBudgetEntry for the failure modes this guards. * Pruned opportunistically on each lookup — no separate timer. */ private wakeBudget; private maxWakesPerThread; private wakeWindowMs; private now; /** * Parked wakes — events that the wake-budget rejected, held so we can * retry once the (agent, thread) goes quiet. Prevents the silent-drop * failure mode where a budget-exhausted thread loses incoming work * forever even after the reply storm that filled the budget settles. * * Keyed by `${accountId}::${threadId}` to match wakeBudget. Each entry * accumulates events from successive budget-rejected wakes; a single * timer per entry retries once after PARK_QUIESCENCE_MS of no granted * wakes for the agent. If a wake fires for the agent during that * window, the timer is rescheduled — the assumption being "agent is * still in a hot loop, don't unpark yet". Once the agent is quiet * for the full quiescence window, the parked batch fires as ONE * worker turn (bypassing the budget — this is the safety valve). * * The bypass is intentional: if the agent has gone genuinely quiet * for 5 min, the reply-loop scenario the budget guards against is * not happening; the budget is just a stale circuit-breaker. Fire * the parked work and let coordination resume. If it WAS still a * loop, the agent will reply again, the budget will reject the next * wake, and we'll re-park — bounded re-trigger. */ private parkedWakes; /** Last time we actually granted a wake for an agent (any thread). * Read by the parked-wake retry to decide whether the agent is * quiet enough to unpark. */ private lastWakeAtByAgent; private readonly PARK_QUIESCENCE_MS; /** * Layered wake-context system. ThreadCache holds the last K * envelopes per thread (built passively on every SSE new-mail * event, even when no agent wakes). AgentMemoryStore holds * per-(agent, thread) markdown that workers write at end-of- * wake via the save_thread_memory MCP tool. Both are read on * worker spawn and injected into the wake prompt — see * spawnWorker for the rendering. */ private threadCache; private agentMemory; /** * Persistent dispatcher state — per-account `{ lastSeenUid, seenUids[] }` * that survives a restart. On `start()` we use it to seed each * channel's `seenUids` (so IMAP IDLE replays of old UIDs stay * deduped) and to drive the catch-up scan (anything strictly * newer than `lastSeenUid` got missed during downtime — route it * through handleEvent like a synthetic SSE 'new' event). * * Writes are debounced inside the state module; we just call * `markSeen(accountId, uid)` everywhere we decide on a UID. */ private state; /** Tracks which accounts have already gone through catch-up + pending-task scan * so reconnects don't replay the same backlog. */ private caughtUp; /** * Coalesced wake queue. Keyed by `${accountId}::${threadId}`, * each entry holds the pending events + the timer that will * fire the spawn. A new event arriving while the entry exists * EXTENDS the timer (debounce, not throttle) and appends to * the event list. When the timer fires, a single Codex turn * sees the union of new messages and replies once. * * Why debounce + not throttle: bursts of replies from one * sender are typically a single logical handoff, not N * separate actions. Throttling would still produce a stale * wake after the burst settles; debouncing collapses the * whole burst into one wake at the trailing edge. */ private wakeCoalesce; private wakeCoalesceMs; /** * Per-account live AbortControllers — one per in-flight worker. * Mirrors the claudecode dispatcher: when stop_agent fires (via the * `account_stopped` system event), every entry here gets `.abort()` * so any running Codex session is killed instead of being allowed to * run to completion. Without this, stop_agent only blocked FUTURE * wakes — which the operator saw as "they kept on working". */ private activeAborts; /** * In-memory queue of wakes that were deferred because the SDK call * came back with a rate-limit error. Each entry holds a timer + the * attempt count so we can: * * - clear all pending retries on `stop()` (clean shutdown) * - cap the per-wake retry budget so a wake that's been failing * for >24h doesn't sit in the queue forever * * Keyed by `${account.id}:${kind}:${uid|taskId|''}` so a fresh wake * for the same trigger resets the timer rather than stacking. * * Persistence is intentionally NOT done here: on dispatcher * restart, the catch-up scan (`runCatchUp`) re-emits SSE events * for unhandled mail/tasks from where the cursor left off, which * naturally re-fires worker turns — and rate-limit retries * become unnecessary because the SDK call will run when the * dispatcher comes back up (presumably hours later, well past * the rate-limit window). */ private deferredRetries; /** How long to wait before retrying a rate-limited wake. */ private static readonly RATE_LIMIT_RETRY_MS; /** Stop retrying after this many attempts (~1 day at 1h cadence). */ private static readonly RATE_LIMIT_MAX_ATTEMPTS; /** Wall-clock timestamp the dispatcher started. Surfaced via * process-heartbeat so check_activity can show uptime. */ private startedAtMs; /** Periodic timer that posts a process-heartbeat to the API. * Without this, a hung dispatcher looks identical to "no * events to wake on" — the host has no liveness signal. */ private processHeartbeatTimer; /** * Periodic catch-up scan timer. Runs every CATCHUP_POLL_INTERVAL_MS * across all open channels to defend against the silent-IDLE-death * failure mode: the SSE stream from /api/agenticmail/events stays * "connected" (no error event surfaces) but the upstream IMAP IDLE * socket has dropped, so no new-mail events ever come through. Real * symptom from production: kepler@localhost stopped receiving wake * events for ~8 minutes after a sustained burst of replies, while * sable/rivet kept receiving them on the same thread. Stalwart had * delivered the missed mail to kepler's mailbox — the dispatcher * just never heard about it. * * `runCatchUp` was already idempotent (it dedups against seenUids * and the persisted cursor); it was only being called on the FIRST * successful connect for each channel. Calling it periodically * surfaces any missed UIDs as synthetic SSE events that flow * through the normal wake path. */ private catchUpPollTimer; /** How often the periodic catch-up scan runs across all channels. */ private readonly CATCHUP_POLL_INTERVAL_MS; constructor(opts?: DispatcherOptions); private disableCatchupScan; /** * Charge one wake against the (agent, thread) budget. Returns true * if the wake should proceed, false if the circuit breaker is open. * * Empty threadId means "no thread context" (a fresh standalone email * with no Subject — rare); we always allow those since there is no * thread to runaway on. */ private chargeWake; /** * Park a budget-rejected event for later retry. Replaces the * previous "log and drop" behaviour that broke coordination chains * on busy threads — when kepler hit cap=10 wakes in 24h on the * Facebook Rebuild thread, every subsequent message to kepler was * silently lost, even after the agent went idle for hours. * * Now: parked events accumulate per (agent, thread); once the agent * has been quiet (no granted wakes) for PARK_QUIESCENCE_MS, the * parked batch fires as a single worker turn. The budget check is * bypassed on the unpark — if the loop is genuinely over the work * gets handled; if the loop resumes, the next reply will re-charge * the budget and re-park, so we stay bounded. */ private parkEvent; /** * Park a coalesced batch — same logic as parkEvent but takes the * full event array at once (so fireCoalescedWake's batch isn't * dropped when the post-coalesce budget check fails). */ private parkBatch; private scheduleParkedRetry; /** * Retry-timer callback. Re-checks the quiescence window against the * agent's actual last-granted-wake timestamp (not the parking time) * so a parked event scheduled while the agent was hot doesn't fire * too early if the loop kept going after we parked. */ private maybeFireParked; /** * Drop wake-budget entries that have aged out of their window. * * Called inline from chargeWake, but at most once per ~1024 inserts so * the cost stays bounded. We don't need a separate timer because the * Map only grows on real wakes (capped by maxWakesPerThread per pair), * and the prune is O(n) over the current entries — cheap enough. */ private wakeBudgetInsertsSinceLastPrune; private maybePruneWakeBudget; start(): Promise; stop(): Promise; /** Public for tests — directly hand an event to the routing path. */ handleEvent(account: AgenticMailAccount, event: SSEEvent): Promise; /** * Should the dispatcher own a wake-channel for this account? * * We skip the bridge agent (default name "codex"). The bridge is * the host session's own inbox proxy — when mail lands there, the * HOST Codex session reads it via MCP (`list_inbox` / * `wait_for_email` / `read_email`), NOT via a separately-spawned * dispatcher worker. Spawning a worker for the bridge would: * 1. Compete with the host (two Codex instances trying to "be" * the operator's Codex session, both potentially replying autonomously). * 2. Waste tokens — the host is already aware via its MCP polling. * 3. Send the bridge into an autonomous loop if it ever replies-all * (because that mail would wake it again, ad infinitum). * * Role="bridge" is also skipped for symmetry with selectExposableAgents * in install.ts — anything tagged as a bridge is host-managed. */ private shouldWatch; /** Re-fetch /accounts; open SSE for new ones, close for vanished ones. */ private syncAccounts; /** * Subscribe to the API's master-scoped system events SSE. * * Pushes from /system/events arrive as JSON-per-frame just like the * per-account stream: * { type: "connected" } * { type: "account_created", account: { id, name, email, apiKey, ... } } * { type: "account_deleted", accountId, name } * * On `account_created` we eagerly open a per-account SSE channel using * the apiKey carried in the event payload — no extra round trip, the * channel is live within milliseconds of the POST /accounts response. * * Reconnect with the same exponential backoff scheme as per-account * channels. If the API is older and doesn't expose /system/events * (404), we log once and stop trying — polling-only fallback still * works. */ private runSystemChannel; /** Apply an account-lifecycle event from /system/events. */ private handleSystemEvent; /** Watch one account's SSE stream forever; reconnect with backoff on drop. */ private runChannel; /** * One-shot backlog scan after a (re)connect: route unprocessed mail * + pending tasks that arrived while the dispatcher was unreachable. * * Mail path: pull the newest 50 envelopes from `/mail/inbox`. For * each UID strictly greater than the persisted `lastSeenUid` (and * not already in the channel's `seenUids`), synthesise an SSE * `new` event and hand it to `handleEvent`. The wake-budget * circuit breaker still applies, so a runaway thread that hit * the cap pre-restart STAYS muted — restart isn't a free reset. * * Tasks path: fetch `/tasks/pending`. Anything not in the * channel's `seenTaskIds` becomes a synthetic task SSE event. * * Failures here are NEVER fatal — they're "best effort". The * dispatcher continues processing live SSE traffic regardless. */ /** * Iterate every open channel and run runCatchUp against it. Each * channel's call is fire-and-forget so a slow inbox doesn't block * other channels; runCatchUp is internally idempotent (seenUids + * persisted cursor dedup) so concurrent invocations on the same * channel are safe. We only scan channels with an established * cursor — fresh accounts that haven't completed their first-run * seed yet should let the initial connect handle that, not the * periodic scan (which would otherwise replay every existing UID * on every tick). */ private runPeriodicCatchUp; private runCatchUp; /** Single SSE attach. Returns when the stream closes for any reason. */ private streamOne; /** * Enqueue (or extend) a wake for `(account, thread)`. First * event creates the entry + starts the debounce timer; every * subsequent event within the window APPENDS to the event * list and EXTENDS the timer to `now + wakeCoalesceMs`. * * When the timer fires, `fireCoalescedWake` synthesises a * single wake prompt covering every event that arrived in * the burst and spawns one worker. The wake-budget is * charged ONCE for the batch (a burst of 4 replies is one * logical handoff, not four). * * When `wakeCoalesceMs` is 0 (test mode / opt-out), we skip * the queue and spawn immediately to keep the pre-0.9.0 * one-event-per-wake semantics. */ private scheduleCoalescedWake; /** * Pre-0.9.0 fast path used when coalescing is disabled. Same * spawn that scheduleCoalescedWake/fireCoalescedWake would do * for a single-event batch. */ private fireWakeImmediately; /** * Timer callback for the coalesced wake. Builds a single wake * prompt that summarises every event in the batch and fires * one worker. Wake budget is charged once for the batch. */ private fireCoalescedWake; /** * Prepend the thread-context block (cache + memory) to the * wake prompt for a given account. Returns the prompt * unchanged when neither layer has content — the very first * wake on a brand-new thread shouldn't show the agent an * empty "Thread context" section that screams "you've seen * this before" when there's nothing to see. * * Exposed as a separate method so tests can drive it * directly without invoking the SDK. */ composeWakePromptWithContext(account: AgenticMailAccount, ctx: { kind: string; subject?: string; uid?: number; }, prompt: string): string; /** Acquire a concurrency slot, run a worker, release the slot. */ /** * Handle mail that lands in the host's OWN bridge inbox. * Resumes the operator's last Codex thread via @openai/codex-sdk's * `resumeThread(id)` instead of spawning a fresh worker. See * packages/codex/src/bridge-wake.ts for the SDK call, and the * matching method in packages/claudecode/src/dispatcher.ts for * the full rationale + short-circuit rules. */ private inFlightBridgeWakes; private handleBridgeMail; private spawnWorker; /** * Schedule a 1-hour retry for a wake that the SDK rejected with a * rate-limit error. Called from `spawnWorker`'s finally block when * `isRateLimitError(workerResult.error)` is true. * * Behaviour: * * - First failure → schedule a `setTimeout` for one hour, store * it under `deferredRetries[::]`, * increment the attempts counter. * - Subsequent rate-limit failures for the same trigger → clear * the previous timer (a fresh attempt resets the budget window) * and reschedule, but DO NOT reset attempts — that counter is * what stops a permanently-throttled account from sitting in * the queue forever. * - Once `attempts > RATE_LIMIT_MAX_ATTEMPTS` (~1 day at 1h * cadence), give up and log. Operator can wake the agent * manually if the rate-limit was billing-side and got resolved. * - On dispatcher `stop()`, every pending timer is cleared. * * The retry just calls `spawnWorker` again with the same `prompt` * and `ctx`. The `prompt` is closed over from the original * `fireCoalescedWake` build, so the agent sees the same thread * context, the same new-mail summary, the same handoff target. * * Persistence intentionally NOT here: a restart triggers * `runCatchUp` which re-emits SSE events for unhandled UIDs from * the persisted cursor, which naturally re-fires the worker. By * the time the dispatcher is back up (typically minutes-to-hours * later), the rate-limit window has almost certainly cleared. */ private scheduleRateLimitRetry; /** * Fire-and-forget POST to the API's worker-activity endpoints. * * Failures are swallowed deliberately — the dispatcher must never * block worker spawn or interrupt teardown because the API is briefly * unreachable. The activity registry is best-effort observability, not * load-bearing state. */ private postActivity; /** * Post a "skipped wake" notification with the reason the * dispatcher decided not to fire a Codex turn. Surfaced in * `check_activity` so the host can see the decision instead * of just observing silence ("did my mail land? did the * dispatcher skip it? is the dispatcher even alive?"). * * Reasons cover every filter that drops a wake: * - thread-closed — subject had [FINAL]/[DONE]/[CLOSED]/[WRAP] * - allowlist-excluded — sender's `wake` list did not include the agent * - wake-on-cc — agent registered wake_on_cc:false and was on Cc * - dedup — duplicate UID seen recently * - rpc-suppress — RPC-notification mail right after a task event * - budget-exhausted — per-(agent, thread) wake budget hit the cap */ private postSkipped; /** Build the env block we pass to the worker's MCP server child process. */ private buildMcpEnv; private acquireSlot; private releaseSlot; /** * Per-agent serialization. At most ONE worker runs for any * given agent at a time. When a new wake fires for an agent * whose worker is still running, the new wake's spawnWorker * waits on the prior worker's tail before proceeding. * * This is the fix for the "dispatcher crashed when sender * broadcast to a 5-CC thread" failure mode: under the old * design, 5 emails landing for vesper-on-3-different-threads * in the same second spawned 5 simultaneous vesper workers, * each opening its own IMAP connection, each calling the * SDK, racing on the same inbox cache. With this gate they * queue tail-to-head and run sequentially. * * `nextRun` is a chained promise: each new spawn calls * `then()` on the previous tail so the order is preserved. * When the chain resolves to a no-op (empty queue), the * entry is garbage-collected from the map so memory stays * bounded at #active-agents. */ private agentSerial; private acquireAgentSerial; } export { Dispatcher, type DispatcherOptions, type QueryFn, type WorkerObserver };