# Message Queue ## Purpose Owns the messages a user queued while a session was busy, and sends them the moment the session goes idle. The queue lives in the web server so a closed tab, a locked phone, or a dropped connection no longer strands it. Structural template: `permission-auto-accept` — the server is authoritative, the shared UI renders a projection, and VS Code (which has no server of its own) keeps its UI-side queue and foreground auto-send hook. ## Files - `runtime.js` — `createMessageQueueRuntime(...)` (state, persistence, dispatch loop, event handling) and `registerMessageQueueRoutes(app, runtime)`. - `runtime.test.js` — delivery, idleness gates, retries, holds, persistence, concurrency with in-flight sends, slash commands, and project knowledge. Wiring: created in `server/index.js` after the global event hub and the session-knowledge runtime; routes registered in `opencode/feature-routes-runtime.js` (before the generic OpenCode proxy) with JSON bodies enabled in `opencode/core-routes.js`; stopped by `opencode/shutdown-runtime.js`. ## Item An item is what the UI would have sent itself, captured at queue time so the send never re-resolves mutable UI state: ``` { id, createdAt, content, // raw text for display and editing text, // text to deliver (agent mention stripped, file mentions resolved); defaults to content agentMention?, // delivered as an `agent` part attachments: [{ id, filename, mimeType, size, source, serverPath?, dataUrl }], context: [ // what the composer had attached, in send order { kind: 'context', text, metadata, instructions? }, // a draft chip or linked issue/PR; metadata is the UI's structured payload { kind: 'instruction', text }, // derived from the text (skill instruction) { kind: 'synthetic', text }, // handed to the composer by another surface ], sendConfig: { providerID, modelID, agent?, variant? } // required } ``` The server is a courier for `context`: it validates the shape (a kind it knows, a `metadata` object on `context` entries) and delivers each entry as a synthetic text part, an entry's `instructions` going out as its own part just before it and its `metadata` riding the part verbatim so the timeline renders the context block back. The payload inside `metadata` is the UI's contract (`lib/messages/contextParts.ts`), parsed by the UI on the way back. `parseQueuedItemInput` rejects anything the server could not deliver later (no text, attachments, or context; missing model; malformed attachment or context entry). Public snapshots and broadcasts strip the payloads — attachment `dataUrl` (megabytes of base64) and `context` (a PR diff, say) — so they do not ride every update; the only way to get them back is a `take`. ## Persistence `/message-queue.json` (`OPENCHAMBER_DATA_DIR` or `~/.config/openchamber`): `{ version, revision, sessions: { [sessionId]: { directory, items } } }`, written atomically (temp file + rename) through a serialized write chain. A missing file is an empty queue. A malformed file is a failure, not an empty queue: it is moved aside as `message-queue.json.corrupt-` before the runtime starts empty, so the next write cannot overwrite the user's data. A failed read leaves writes disabled until a later load succeeds. `revision` is a global monotonic counter bumped on every mutation; clients use it to reject stale snapshots. In-memory only, deliberately: the in-flight item (`sendingId`), retry backoff, abort timestamps, and holds. A restart has no in-flight sends; a persisted "sending" flag would strand a message forever. ## Delivery loop 1. `start()` subscribes to the global upstream hub and loads the file; on load and on every hub `connect` it arms every session that has items. 2. `session.status` for a queued session: `idle` arms a short quiet timer (500 ms, coalescing the burst around a turn boundary), `busy`/`retry` clears it. A `message.updated` for a completed assistant reply arms as well, so a missed idle event cannot strand the queue. `session.deleted` drops the session's queue. An assistant `MessageAbortedError` records an abort. 3. `tick(sessionId)` bails when the queue is empty, an item is in flight, or the session is held. It re-arms after a 2 s post-abort hold (the UI's old behavior: a stop is not immediately followed by the next prompt) or while the head item is in retry backoff. 4. Idleness is re-verified against OpenCode before sending, because `prompt_async` into a running turn steers into it instead of starting the next one: `GET /session/status` must not list the session as busy/retry, and the trailing message must not be an unfinished assistant reply (the status map only lists busy sessions, so a missed busy event leaves no entry while a turn still streams). A failed fetch is unknown, never idle: the tick re-arms with backoff. 5. The head is marked in flight (broadcast), then sent: - text starting with `/` that names a command in OpenCode's `/command` list (skills included) goes to `POST /session/:id/command` with the captured model, agent, variant, and file parts; - otherwise `POST /session/:id/prompt_async` with the parts in the same order a UI send uses: text, files, the captured context, pending project knowledge (`sessionKnowledgeRuntime.resolvePendingForSession`, synthetic, recorded as delivered only after the prompt is accepted), then the agent mention. The command path sends files and captured context as `parts`. Success removes the item, persists, broadcasts, and marks the user message sent for notifications. Failure keeps the item, backs off 2 s → 60 s (doubling per consecutive failure of that item), and re-arms. 6. The next item goes out after the next busy → idle cycle. ## Holds Auto-review is driven from the UI and bounces the original session through idle between iterations; the UI tells the server to hold that session's queue (`PUT .../hold { held: true, ttlMs? }`) while a run is going and releases it when the run ends. A hold expires on its own (default 5 min, cap 10 min) because the UI that asserted it may be gone; the UI re-asserts it every two minutes while the run continues. Releasing arms a dispatch. ## Routes (`/api/message-queue`) Normal authenticated OpenChamber runtime routes; never on browser URL-token allowlists. | Route | Purpose | |---|---| | `GET /api/message-queue` | Full snapshot `{ revision, sessions[] }` | | `POST .../sessions/:id/items` | Append `{ directory, item }`; returns `{ revision, session, itemId }` and arms a dispatch (the session may already be idle) | | `DELETE .../sessions/:id/items/:itemId` | Remove; `409` while that item is in flight | | `POST .../sessions/:id/items/:itemId/take` | Remove and return the full item (payloads included); `404`/`409` | | `POST .../sessions/:id/take` | Remove and return every item not in flight, in order | | `PUT .../sessions/:id/order` | `{ itemIds }` must be a complete permutation | | `DELETE .../sessions/:id` | Clear; the in-flight item stays | | `PUT .../sessions/:id/hold` | `{ held, ttlMs? }` | Every mutation broadcasts `openchamber:message-queue.updated` with `{ revision, session }` to all connected clients (SSE and WS), so several devices on one server see one queue. The session in that payload always names its `directory`, including the broadcast that removes the last item: the UI keys its projection by directory, and a broadcast without one left the delivered message on screen (a session's directory is remembered until the session is deleted or evicted). Limits: 20 items per session, 50 sessions (oldest evicted, never one with an item in flight), 200k characters of content; attachment payloads are bounded by the route family's 50 MB JSON limit. ## UI ownership `packages/ui/src/stores/messageQueueStore.ts` is the projection: see its section in `packages/ui/src/stores/DOCUMENTATION.md`. VS Code intentionally does not use this module; with all OpenChamber webviews closed, queued messages there are not delivered.