Files
openchamber/packages/web/server/lib/message-queue/runtime.js
T
Bohdan Triapitsyn d323b51a0a fix(queue): deliver a queued slash command with context as a prompt
OpenCode's command route accepts file parts only. The server queue was
attaching captured context as text parts to POST /session/:id/command, so
a slash command queued with a comment, quote, or PR diff was rejected with
400 and retried forever.

A command queued without context still takes the command route with its
files. One queued with context now takes the prompt route the way the
composer does: the command's template is expanded with its arguments, a
skill keeps its text and gets the explicit skill-invocation instruction,
and the context rides along.

Claude-Session: https://claude.ai/code/session_01VqV56Hez25hTxXH4ipJfzH
2026-09-05 12:27:55 +03:00

871 lines
34 KiB
JavaScript

// 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');
}
});
}