// Server-owned message queue: messages the user queued while a session was // busy, delivered by the web server the moment the session goes idle. The // queue lives here, not in the browser, so closing the tab, locking the phone, // or losing the connection no longer strands what was queued. Structural // template: permission-auto-accept (server-authoritative state, UI as a // projection, VS Code keeps its own foreground implementation). // // Event-driven like session-goal: the shared upstream hub delivers // `session.status`, and an idle transition arms a short per-session timer. The // tick re-verifies idleness against OpenCode (status map + message tail) before // it sends, because a queued prompt sent into a running turn would be steered // into it instead of starting the next one. import fs from 'fs'; import path from 'path'; const QUEUE_FILE_NAME = 'message-queue.json'; const QUEUE_FILE_VERSION = 1; const MAX_SESSIONS = 50; const MAX_ITEMS_PER_SESSION = 20; const CONTENT_CHAR_LIMIT = 200_000; // Idle events arrive in bursts around a turn boundary; a short quiet window // coalesces them before the tick verifies idleness against OpenCode. const DISPATCH_QUIET_MS = 500; // After a user abort the UI held the queue for two seconds so the stop is not // immediately followed by the next prompt; the server keeps that window. const ABORT_HOLD_MS = 2_000; const RETRY_BASE_DELAY_MS = 2_000; const RETRY_MAX_DELAY_MS = 60_000; // A hold is asserted by a UI-driven process (auto-review) that dies with the // UI; it expires unless the UI keeps re-asserting it. const HOLD_DEFAULT_TTL_MS = 5 * 60 * 1000; const HOLD_MAX_TTL_MS = 10 * 60 * 1000; const FETCH_TIMEOUT_MS = 15_000; const MESSAGE_TAIL_LIMIT = 2; const ATTACHMENT_SOURCES = new Set(['local', 'server', 'vscode']); // Context captured with a queued message (see QueuedContextPart in the UI // store): attached context items carry metadata the timeline renders back; // the other kinds are plain synthetic text. const CONTEXT_PART_KINDS = new Set(['context', 'instruction', 'synthetic']); const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{4,128}$/; const getQueuedSendRetryDelayMs = (failures) => Math.min(RETRY_BASE_DELAY_MS * 2 ** Math.max(failures - 1, 0), RETRY_MAX_DELAY_MS); // Boundary readers: the only place raw JSON (client bodies, the queue file, // OpenCode responses, hub events) is inspected. Everything below them // branches on the domain values they return. const asNonEmptyString = (value) => (typeof value === 'string' && value.trim() ? value.trim() : ''); const asText = (value) => (typeof value === 'string' ? value : ''); const asRecord = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : null); const asList = (value) => (Array.isArray(value) ? value : null); const asCount = (value) => (Number.isFinite(value) && value >= 0 ? Math.floor(value) : null); const isValidSessionId = (value) => SESSION_ID_PATTERN.test(asNonEmptyString(value)); const httpError = (message, status) => Object.assign(new Error(message), { status }); const parseSendConfig = (value) => { const raw = asRecord(value); if (!raw) return null; const providerID = asNonEmptyString(raw.providerID); const modelID = asNonEmptyString(raw.modelID); if (!providerID || !modelID) return null; const sendConfig = { providerID, modelID }; const agent = asNonEmptyString(raw.agent); if (agent) sendConfig.agent = agent; const variant = asNonEmptyString(raw.variant); if (variant) sendConfig.variant = variant; return sendConfig; }; const parseAttachment = (value) => { const raw = asRecord(value); if (!raw) return null; const filename = asNonEmptyString(raw.filename); const mimeType = asNonEmptyString(raw.mimeType); const dataUrl = asText(raw.dataUrl); if (!filename || !mimeType || !dataUrl) return null; const attachment = { id: asNonEmptyString(raw.id) || `attachment-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, filename, mimeType, size: asCount(raw.size) ?? 0, source: ATTACHMENT_SOURCES.has(raw.source) ? raw.source : 'local', }; const serverPath = asNonEmptyString(raw.serverPath); if (serverPath) attachment.serverPath = serverPath; attachment.dataUrl = dataUrl; return attachment; }; const parseContextPart = (value) => { const raw = asRecord(value); if (!raw || !CONTEXT_PART_KINDS.has(raw.kind)) return null; const text = asText(raw.text); if (raw.kind !== 'context') return { kind: raw.kind, text }; // The metadata is the UI's structured payload; the server only carries it // to the prompt, so its shape is the UI's to validate on the way back. const metadata = asRecord(raw.metadata); if (!metadata) return null; const part = { kind: 'context', text, metadata }; const instructions = asNonEmptyString(raw.instructions); if (instructions) part.instructions = instructions; return part; }; /** * Validates a queued item posted by a client. Throws a TypeError (→ 400) for * anything that could not be delivered later: a queue must never hold an item * the server cannot send. */ export const parseQueuedItemInput = (value) => { const raw = asRecord(value); if (!raw) throw new TypeError('item is required'); const content = asText(raw.content).replace(/^\n+|\n+$/g, ''); if (content.length > CONTENT_CHAR_LIMIT) throw new TypeError('item content is too long'); const text = raw.text === undefined ? content : asText(raw.text); const attachments = (asList(raw.attachments) ?? []).map(parseAttachment); if (attachments.some((attachment) => attachment === null)) throw new TypeError('invalid attachment'); const context = (asList(raw.context) ?? []).map(parseContextPart); if (context.some((part) => part === null)) throw new TypeError('invalid context part'); if (!text.trim() && attachments.length === 0 && context.length === 0) { throw new TypeError('item needs text, attachments, or context'); } const sendConfig = parseSendConfig(raw.sendConfig); if (!sendConfig) throw new TypeError('item sendConfig with providerID and modelID is required'); const item = { content, text }; const agentMention = asNonEmptyString(raw.agentMention); if (agentMention) item.agentMention = agentMention; item.attachments = attachments; item.context = context; item.sendConfig = sendConfig; return item; }; const parseStoredItem = (value) => { const raw = asRecord(value); const id = raw ? asNonEmptyString(raw.id) : ''; if (!id) return null; try { return { id, createdAt: asCount(raw.createdAt) ?? Date.now(), ...parseQueuedItemInput(raw) }; } catch { return null; } }; const toPublicAttachment = ({ dataUrl: _dataUrl, ...attachment }) => attachment; // What clients see: everything except the payloads — attachment data URLs // (megabytes of base64) and captured context (a PR diff, say) — which would // otherwise ride every broadcast. A take hands the full item back. const toPublicItem = (item) => { const publicItem = { id: item.id, createdAt: item.createdAt, content: item.content, text: item.text }; if (item.agentMention) publicItem.agentMention = item.agentMention; publicItem.attachments = item.attachments.map(toPublicAttachment); publicItem.sendConfig = { ...item.sendConfig }; return publicItem; }; const extractSessionStatus = (payload) => { if (payload.type !== 'session.status') return null; const properties = asRecord(payload.properties) ?? {}; const status = asRecord(properties.status) ?? {}; const info = asRecord(properties.info) ?? {}; const sessionId = asNonEmptyString(properties.sessionID); const type = asNonEmptyString(status.type) || asNonEmptyString(info.type); if (!sessionId || !type) return null; return { sessionId, type }; }; const extractAssistantMessageUpdate = (payload) => { if (payload.type !== 'message.updated') return null; const info = asRecord(asRecord(payload.properties)?.info); if (!info || info.role !== 'assistant') return null; const sessionId = asNonEmptyString(info.sessionID); if (!sessionId) return null; return { sessionId, aborted: asRecord(info.error)?.name === 'MessageAbortedError', completed: asCount(asRecord(info.time)?.completed) !== null, }; }; const extractDeletedSessionId = (payload) => { if (payload.type !== 'session.deleted') return null; const properties = asRecord(payload.properties) ?? {}; return asNonEmptyString(asRecord(properties.info)?.id) || asNonEmptyString(properties.sessionID) || null; }; export function createMessageQueueRuntime({ globalEventHub, buildOpenCodeUrl, getOpenCodeAuthHeaders, sessionKnowledgeRuntime = null, broadcastGlobalUiEvent, onPromptSent, dataDir, fetchImpl = fetch, now = Date.now, dispatchQuietMs = DISPATCH_QUIET_MS, abortHoldMs = ABORT_HOLD_MS, retryDelayMs = getQueuedSendRetryDelayMs, }) { const filePath = path.join(dataDir, QUEUE_FILE_NAME); /** sessionId → { directory, items } */ const queues = new Map(); let revision = 0; let loadPromise = null; let writePromise = Promise.resolve(); let stopped = false; /** In-memory only — a restart has no in-flight sends. */ const sending = new Map(); // sessionId → itemId const timers = new Map(); // sessionId → timeout const failures = new Map(); // sessionId → { itemId, failures, nextAttemptAt } const abortedAt = new Map(); // sessionId → timestamp const holds = new Map(); // sessionId → expiresAt // sessionId → directory, kept after the queue empties: the UI keys its // projection by directory, so the broadcast that removes the last item must // still name it or the client cannot tell which queue just finished. const directories = new Map(); // --- persistence --------------------------------------------------------- const serialize = () => ({ version: QUEUE_FILE_VERSION, revision, sessions: Object.fromEntries( Array.from(queues.entries()).map(([sessionId, queue]) => [sessionId, { directory: queue.directory, items: queue.items }]), ), }); const readFile = async () => { let raw; try { raw = await fs.promises.readFile(filePath, 'utf8'); } catch (error) { if (asRecord(error)?.code === 'ENOENT') return { sessions: {}, revision: 0 }; throw error; } let parsed; try { parsed = JSON.parse(raw); } catch (error) { // Malformed is a failure, not an empty queue: keep the bytes for the // user and start over rather than overwriting them on the next write. const backup = `${filePath}.corrupt-${now()}`; await fs.promises.rename(filePath, backup).catch(() => undefined); console.warn(`[message-queue] queue file was unreadable and moved to ${backup}: ${error?.message ?? error}`); return { sessions: {}, revision: 0 }; } const stored = asRecord(parsed) ?? {}; const sessions = {}; for (const [sessionId, value] of Object.entries(asRecord(stored.sessions) ?? {})) { const entry = asRecord(value); if (!entry || !isValidSessionId(sessionId)) continue; const directory = asNonEmptyString(entry.directory); const items = (asList(entry.items) ?? []).map(parseStoredItem).filter(Boolean); if (!directory || items.length === 0) continue; sessions[sessionId] = { directory, items }; } return { sessions, revision: asCount(stored.revision) ?? 0 }; }; const load = () => { if (!loadPromise) { loadPromise = readFile() .then((stored) => { for (const [sessionId, entry] of Object.entries(stored.sessions)) queues.set(sessionId, entry); revision = Math.max(revision, stored.revision); }) .catch((error) => { // A read failure keeps the in-memory (empty) queue but must not be // mistaken for "nothing queued": the next write would clobber the // file, so writes stay disabled until a later load succeeds. loadPromise = null; throw error; }); } return loadPromise; }; const persist = () => { const payload = JSON.stringify(serialize()); writePromise = writePromise .then(async () => { await fs.promises.mkdir(dataDir, { recursive: true }); const tmpPath = `${filePath}.${process.pid}.tmp`; await fs.promises.writeFile(tmpPath, payload, 'utf8'); await fs.promises.rename(tmpPath, filePath); }) .catch((error) => { console.warn('[message-queue] failed to persist queue:', error?.message ?? error); }); return writePromise; }; // --- snapshots ----------------------------------------------------------- const sessionSnapshot = (sessionId) => { const queue = queues.get(sessionId); return { sessionId, directory: queue?.directory ?? directories.get(sessionId) ?? '', items: (queue?.items ?? []).map(toPublicItem), sendingId: sending.get(sessionId) ?? null, }; }; const snapshot = () => ({ revision, sessions: Array.from(queues.keys()).map(sessionSnapshot), }); const broadcast = (sessionId) => { broadcastGlobalUiEvent?.({ type: 'openchamber:message-queue.updated', properties: { revision, session: sessionSnapshot(sessionId) }, }); }; /** Every mutation goes through here: bump, persist, broadcast. */ const commit = (sessionId) => { revision += 1; void persist(); broadcast(sessionId); return { revision, session: sessionSnapshot(sessionId) }; }; const setQueueItems = (sessionId, directory, items) => { directories.set(sessionId, directory); if (items.length === 0) { queues.delete(sessionId); return; } queues.set(sessionId, { directory, items }); }; // --- OpenCode access ----------------------------------------------------- const openCodeFetch = async (fetchPath, { directory, method = 'GET', body, query } = {}) => { const base = buildOpenCodeUrl(fetchPath, ''); const params = new URLSearchParams(query || {}); if (directory) params.set('directory', directory); const search = params.toString(); const headers = { Accept: 'application/json', ...getOpenCodeAuthHeaders() }; const init = { method, headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }; if (body) { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); } const response = await fetchImpl(search ? `${base}?${search}` : base, init); if (!response.ok) { const detail = await response.text().catch(() => ''); throw httpError(`OpenCode ${method} ${fetchPath} failed with ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`, response.status); } return response.json().catch(() => null); }; /** * Live idleness, or null when it could not be established. Unknown is never * idle: a fetch failure re-arms instead of sending into a running turn. */ const isSessionIdle = async (sessionId, directory) => { const statuses = asRecord(await openCodeFetch('/session/status', { directory }).catch(() => null)); if (!statuses) return null; const type = asRecord(statuses[sessionId])?.type; if (type === 'busy' || type === 'retry') return false; // The status map lists only busy sessions, so a missed busy event leaves // no entry while a turn still streams. The trailing unfinished assistant // message is the live evidence of that turn (mirrors the UI gate). const messages = asList(await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/message`, { directory, query: { limit: String(MESSAGE_TAIL_LIMIT) }, }).catch(() => null)); if (!messages) return null; const last = asRecord(asRecord(messages[messages.length - 1])?.info); if (last?.role === 'assistant' && asCount(asRecord(last.time)?.completed) === null) return false; return true; }; const resolveSlashCommand = async (text, directory) => { if (!text.startsWith('/')) return null; const [head, ...tail] = text.split(' '); const name = head.slice(1); if (!name) return null; const commands = asList(await openCodeFetch('/command', { directory })) ?? []; const match = commands.map(asRecord).find((command) => command?.name === name); if (!match) return null; return { name, arguments: tail.join(' '), isSkill: match.source === 'skill', template: asNonEmptyString(match.template), }; }; /** * The prompt a slash command stands for, expanded the way OpenCode expands * it: `$ARGUMENTS` takes the whole argument string, `$1..$N` take quoted or * bare words with the last position absorbing the rest, and a template with * no placeholder gets the arguments appended. Twin of the UI's * `expandSlashCommandGoalObjective` in `packages/ui/src/sync/session-ui-store.ts`. */ const expandCommandTemplate = (template, argumentsText) => { if (template.includes('$ARGUMENTS')) return template.replaceAll('$ARGUMENTS', argumentsText); const positions = [...template.matchAll(/\$(\d+)/g)].map((match) => Number(match[1])); if (positions.length > 0) { const parsed = [...argumentsText.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)] .map((match) => match[1] ?? match[2] ?? match[3] ?? ''); const last = Math.max(...positions); return template.replace(/\$(\d+)/g, (_match, value) => { const position = Number(value); return position === last ? parsed.slice(position - 1).join(' ') : (parsed[position - 1] ?? ''); }); } return argumentsText ? `${template}\n\n${argumentsText}` : template; }; const toFilePart = (attachment) => ({ type: 'file', mime: attachment.mimeType, filename: attachment.filename, url: attachment.dataUrl, }); // Captured context is delivered the way the composer delivers it: one // synthetic text part per entry, an attached item's metadata riding along // and its reading instructions (a linked PR) going first. const toContextParts = (part) => { const synthetic = { type: 'text', text: part.text, synthetic: true }; if (part.kind !== 'context') return [synthetic]; synthetic.metadata = part.metadata; return part.instructions ? [{ type: 'text', text: part.instructions, synthetic: true }, synthetic] : [synthetic]; }; const sendItem = async (sessionId, directory, item) => { const { providerID, modelID, agent, variant } = item.sendConfig; const fileParts = item.attachments.map(toFilePart); const contextParts = item.context.flatMap(toContextParts); // OpenCode's command route takes file parts only, so a command queued // with captured context cannot go through it. Same rule as the composer: // without context the command route keeps its semantics; with context the // prompt route carries the expanded template (or the skill invocation as an // explicit instruction) together with the context. const command = await resolveSlashCommand(item.text, directory); if (command && contextParts.length === 0) { const body = { command: command.name, arguments: command.arguments, model: `${providerID}/${modelID}` }; if (agent) body.agent = agent; if (variant) body.variant = variant; if (fileParts.length > 0) body.parts = fileParts; await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/command`, { directory, method: 'POST', body }); return; } let text = item.text; const commandParts = []; if (command?.isSkill) { commandParts.push({ type: 'text', text: `The user explicitly invoked the ${command.name} skill. Use the corresponding skill tool to handle this request.`, synthetic: true, }); } else if (command?.template) { text = expandCommandTemplate(command.template, command.arguments); } // Standing project context rides the prompt exactly as a UI send would // attach it; a failed lookup sends without it rather than not at all. const knowledge = sessionKnowledgeRuntime ? await sessionKnowledgeRuntime.resolvePendingForSession(sessionId, directory) .catch(() => ({ text: '', signature: '' })) : { text: '', signature: '' }; // Same order as a UI send: the user's text and files, the context queued // with them, then the standing context, then the mentioned agent. const parts = []; if (text.trim()) parts.push({ type: 'text', text }); parts.push(...fileParts); parts.push(...contextParts); parts.push(...commandParts); if (knowledge.text) parts.push({ type: 'text', text: knowledge.text, synthetic: true }); if (item.agentMention) parts.push({ type: 'agent', name: item.agentMention }); const body = { model: { providerID, modelID } }; if (agent) body.agent = agent; if (variant) body.variant = variant; body.parts = parts; await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/prompt_async`, { directory, method: 'POST', body }); if (knowledge.text && sessionKnowledgeRuntime) { // After the prompt is accepted, so a rejected dispatch carries it again. await sessionKnowledgeRuntime.recordDelivered(sessionId, directory, knowledge.signature).catch(() => undefined); } }; // --- dispatch loop ------------------------------------------------------- const clearTimer = (sessionId) => { const existing = timers.get(sessionId); if (existing) { clearTimeout(existing); timers.delete(sessionId); } }; const armDispatch = (sessionId, delayMs = dispatchQuietMs) => { if (stopped || !queues.has(sessionId)) return; clearTimer(sessionId); const timer = setTimeout(() => { timers.delete(sessionId); tick(sessionId).catch((error) => { console.warn('[message-queue] dispatch tick failed:', error?.message ?? error); }); }, Math.max(0, delayMs)); timer.unref?.(); timers.set(sessionId, timer); }; const isHeld = (sessionId) => { const expiresAt = holds.get(sessionId); if (expiresAt === undefined) return false; if (expiresAt > now()) return true; holds.delete(sessionId); return false; }; async function tick(sessionId) { if (stopped) return; const queue = queues.get(sessionId); if (!queue || queue.items.length === 0 || sending.has(sessionId) || isHeld(sessionId)) return; const abortHoldUntil = (abortedAt.get(sessionId) ?? 0) + abortHoldMs; if (abortHoldUntil > now()) { armDispatch(sessionId, abortHoldUntil - now()); return; } const head = queue.items[0]; const failure = failures.get(sessionId); if (failure && failure.itemId !== head.id) failures.delete(sessionId); else if (failure && failure.nextAttemptAt > now()) { armDispatch(sessionId, failure.nextAttemptAt - now()); return; } const idle = await isSessionIdle(sessionId, queue.directory); if (idle === null) { armDispatch(sessionId, retryDelayMs(1)); return; } // Busy: the next idle status event re-arms the loop. if (!idle) return; // Re-read after the awaits — the user may have edited the queue meanwhile. const current = queues.get(sessionId); const item = current?.items[0]; if (!item || item.id !== head.id || sending.has(sessionId)) return; sending.set(sessionId, item.id); broadcast(sessionId); try { await sendItem(sessionId, current.directory, item); const after = queues.get(sessionId); if (after) setQueueItems(sessionId, after.directory, after.items.filter((entry) => entry.id !== item.id)); failures.delete(sessionId); sending.delete(sessionId); commit(sessionId); try { onPromptSent?.(sessionId); } catch { // bookkeeping only } console.log(`[message-queue] sent queued message to ${sessionId}`); } catch (error) { sending.delete(sessionId); const count = (failure?.itemId === item.id ? failure.failures : 0) + 1; const nextAttemptAt = now() + retryDelayMs(count); failures.set(sessionId, { itemId: item.id, failures: count, nextAttemptAt }); console.warn(`[message-queue] send to ${sessionId} failed (attempt ${count}):`, error?.message ?? error); broadcast(sessionId); armDispatch(sessionId, nextAttemptAt - now()); } } const reconcileAll = () => { for (const sessionId of queues.keys()) { if (!timers.has(sessionId)) armDispatch(sessionId, dispatchQuietMs); } }; // --- public mutations ---------------------------------------------------- const requireSessionId = (sessionId) => { if (!isValidSessionId(sessionId)) throw new TypeError('sessionId is invalid'); return sessionId; }; const enqueue = async (sessionIdInput, directoryInput, itemInput) => { const sessionId = requireSessionId(sessionIdInput); const directory = asNonEmptyString(directoryInput); if (!directory) throw new TypeError('directory is required'); const parsed = parseQueuedItemInput(itemInput); await load(); const item = { id: `queued-${now()}-${Math.random().toString(36).slice(2, 9)}`, createdAt: now(), ...parsed, }; const existing = queues.get(sessionId); const items = [...(existing?.items ?? []), item].slice(-MAX_ITEMS_PER_SESSION); queues.set(sessionId, { directory, items }); directories.set(sessionId, directory); if (queues.size > MAX_SESSIONS) { const oldest = Array.from(queues.entries()) .filter(([id]) => id !== sessionId && !sending.has(id)) .sort((left, right) => (left[1].items[0]?.createdAt ?? 0) - (right[1].items[0]?.createdAt ?? 0)) .slice(0, queues.size - MAX_SESSIONS); for (const [staleId] of oldest) { queues.delete(staleId); clearTimer(staleId); broadcast(staleId); directories.delete(staleId); } } const result = commit(sessionId); // The session may already be idle (queued from a busy-looking composer // right as the turn ended); the tick verifies before sending. armDispatch(sessionId); return { ...result, itemId: item.id }; }; const remove = async (sessionIdInput, itemId) => { const sessionId = requireSessionId(sessionIdInput); await load(); if (sending.get(sessionId) === itemId) throw httpError('message is being sent', 409); const queue = queues.get(sessionId); if (!queue || !queue.items.some((item) => item.id === itemId)) { return { revision, session: sessionSnapshot(sessionId) }; } setQueueItems(sessionId, queue.directory, queue.items.filter((item) => item.id !== itemId)); return commit(sessionId); }; /** Removes the item and hands its full payload (attachments included) back. */ const take = async (sessionIdInput, itemId) => { const sessionId = requireSessionId(sessionIdInput); await load(); if (sending.get(sessionId) === itemId) throw httpError('message is being sent', 409); const queue = queues.get(sessionId); const item = queue?.items.find((entry) => entry.id === itemId); if (!queue || !item) throw httpError('queued message not found', 404); setQueueItems(sessionId, queue.directory, queue.items.filter((entry) => entry.id !== itemId)); return { ...commit(sessionId), item }; }; /** Removes every item not currently being sent and hands them back in order. */ const takeAll = async (sessionIdInput) => { const sessionId = requireSessionId(sessionIdInput); await load(); const queue = queues.get(sessionId); if (!queue) return { revision, session: sessionSnapshot(sessionId), items: [] }; const sendingId = sending.get(sessionId) ?? null; const items = queue.items.filter((item) => item.id !== sendingId); if (items.length === 0) return { revision, session: sessionSnapshot(sessionId), items: [] }; setQueueItems(sessionId, queue.directory, queue.items.filter((item) => item.id === sendingId)); return { ...commit(sessionId), items }; }; const reorder = async (sessionIdInput, itemIds) => { const sessionId = requireSessionId(sessionIdInput); if (!asList(itemIds) || itemIds.some((id) => !asNonEmptyString(id))) { throw new TypeError('itemIds must be a list of ids'); } await load(); const queue = queues.get(sessionId); if (!queue) return { revision, session: sessionSnapshot(sessionId) }; const byId = new Map(queue.items.map((item) => [item.id, item])); if (itemIds.length !== byId.size || new Set(itemIds).size !== itemIds.length || itemIds.some((id) => !byId.has(id))) { throw new TypeError('itemIds must list every queued message exactly once'); } queues.set(sessionId, { directory: queue.directory, items: itemIds.map((id) => byId.get(id)) }); return commit(sessionId); }; const clear = async (sessionIdInput) => { const sessionId = requireSessionId(sessionIdInput); await load(); const queue = queues.get(sessionId); if (!queue) return { revision, session: sessionSnapshot(sessionId) }; // Never drop a message already handed to OpenCode: its send resolves and // must find its entry. const sendingId = sending.get(sessionId) ?? null; setQueueItems(sessionId, queue.directory, queue.items.filter((item) => item.id === sendingId)); clearTimer(sessionId); return commit(sessionId); }; const setHold = (sessionIdInput, held, ttlMs = HOLD_DEFAULT_TTL_MS) => { const sessionId = requireSessionId(sessionIdInput); if (held !== true && held !== false) throw new TypeError('held must be a boolean'); if (held) { const ttl = Math.min(asCount(ttlMs) || HOLD_DEFAULT_TTL_MS, HOLD_MAX_TTL_MS); holds.set(sessionId, now() + ttl); clearTimer(sessionId); return { held: true, expiresAt: holds.get(sessionId) }; } holds.delete(sessionId); armDispatch(sessionId); return { held: false, expiresAt: null }; }; // --- events -------------------------------------------------------------- const processPayload = (value) => { const payload = asRecord(value); if (stopped || !payload) return; const deletedSessionId = extractDeletedSessionId(payload); if (deletedSessionId) { if (!queues.has(deletedSessionId)) return; queues.delete(deletedSessionId); clearTimer(deletedSessionId); failures.delete(deletedSessionId); commit(deletedSessionId); directories.delete(deletedSessionId); return; } const status = extractSessionStatus(payload); if (status) { if (!queues.has(status.sessionId)) return; if (status.type === 'idle') armDispatch(status.sessionId); else clearTimer(status.sessionId); return; } const assistant = extractAssistantMessageUpdate(payload); if (assistant && queues.has(assistant.sessionId)) { if (assistant.aborted) abortedAt.set(assistant.sessionId, now()); // A completed reply without a following idle status (missed event) // must still drain the queue; the tick verifies idleness itself. if (assistant.completed && !timers.has(assistant.sessionId)) armDispatch(assistant.sessionId); } }; const processEvent = (event) => { const raw = asRecord(asRecord(event)?.payload); processPayload(asRecord(raw?.payload) ?? raw); }; const start = () => { const unsubscribeEvent = globalEventHub.subscribeEvent(processEvent); const unsubscribeStatus = globalEventHub.subscribeStatus((status) => { if (status?.type === 'connect') reconcileAll(); }); void load() .then(() => { if (queues.size > 0) console.log(`[message-queue] restored queues for ${queues.size} session(s)`); reconcileAll(); }) .catch((error) => { console.warn('[message-queue] failed to load queue file:', error?.message ?? error); }); return () => { unsubscribeEvent(); unsubscribeStatus(); }; }; const stop = () => { stopped = true; for (const timer of timers.values()) clearTimeout(timer); timers.clear(); }; return { load, snapshot, sessionSnapshot, enqueue, remove, take, takeAll, reorder, clear, setHold, processPayload, start, stop, /** Drains the pending write; tests and shutdown use it. */ flush: () => writePromise, }; } export function registerMessageQueueRoutes(app, runtime) { const respondError = (res, error, fallback) => { const status = error instanceof TypeError ? 400 : (Number.isInteger(error?.status) ? error.status : 500); res.status(status).json({ error: error?.message ?? fallback }); }; app.get('/api/message-queue', async (_req, res) => { try { await runtime.load(); res.json(runtime.snapshot()); } catch (error) { respondError(res, error, 'Failed to load message queue'); } }); app.post('/api/message-queue/sessions/:sessionId/items', async (req, res) => { try { res.json(await runtime.enqueue(req.params.sessionId, req.body?.directory, req.body?.item)); } catch (error) { respondError(res, error, 'Failed to queue message'); } }); app.post('/api/message-queue/sessions/:sessionId/take', async (req, res) => { try { res.json(await runtime.takeAll(req.params.sessionId)); } catch (error) { respondError(res, error, 'Failed to take queued messages'); } }); app.put('/api/message-queue/sessions/:sessionId/order', async (req, res) => { try { res.json(await runtime.reorder(req.params.sessionId, req.body?.itemIds)); } catch (error) { respondError(res, error, 'Failed to reorder queue'); } }); app.put('/api/message-queue/sessions/:sessionId/hold', async (req, res) => { try { await runtime.load(); res.json(runtime.setHold(req.params.sessionId, req.body?.held, req.body?.ttlMs)); } catch (error) { respondError(res, error, 'Failed to update queue hold'); } }); app.delete('/api/message-queue/sessions/:sessionId', async (req, res) => { try { res.json(await runtime.clear(req.params.sessionId)); } catch (error) { respondError(res, error, 'Failed to clear queue'); } }); app.post('/api/message-queue/sessions/:sessionId/items/:itemId/take', async (req, res) => { try { res.json(await runtime.take(req.params.sessionId, req.params.itemId)); } catch (error) { respondError(res, error, 'Failed to take queued message'); } }); app.delete('/api/message-queue/sessions/:sessionId/items/:itemId', async (req, res) => { try { res.json(await runtime.remove(req.params.sessionId, req.params.itemId)); } catch (error) { respondError(res, error, 'Failed to remove queued message'); } }); }