import path from 'node:path'; import { createOpencodeClient } from '@opencode-ai/sdk/v2'; import { OpenChamberControlError, asControlError } from './error.js'; import { OPENCHAMBER_ALL_ACTIONS } from './actions.js'; import { writeScreenshot } from './screenshots.js'; const DEFAULT_WAIT_TIMEOUT_SECONDS = 600; const MAX_WAIT_TIMEOUT_SECONDS = 86_400; const WAIT_POLL_INTERVAL_MS = 500; // One service, both capabilities: which tool asked is the caller's concern. const CONTROL_ACTIONS = new Set(OPENCHAMBER_ALL_ACTIONS); const SCHEDULE_TASK_ID_ACTIONS = new Set([ 'schedule.run', 'schedule.delete', 'schedule.toggle', ]); const asNonEmptyString = (value) => { if (typeof value !== 'string') return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; }; const positiveInteger = (value, fallback, field) => { if (value === undefined || value === null) return fallback; const number = Number(value); if (!Number.isSafeInteger(number) || number < 1) { throw new OpenChamberControlError(`${field} must be a positive integer`, 400); } return number; }; const normalizeWaitTimeoutMs = (value) => { const seconds = value === undefined || value === null ? DEFAULT_WAIT_TIMEOUT_SECONDS : Number(value); if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > MAX_WAIT_TIMEOUT_SECONDS) { throw new OpenChamberControlError(`timeout must be from 1 to ${MAX_WAIT_TIMEOUT_SECONDS} seconds`, 400); } return seconds * 1000; }; const extractTextMessages = (messages, role = 'all') => { const result = []; for (const record of Array.isArray(messages) ? messages : []) { const info = record?.info; const messageRole = info?.role; if ((messageRole !== 'user' && messageRole !== 'assistant') || (role !== 'all' && role !== messageRole)) continue; const text = Array.isArray(record?.parts) ? record.parts.filter((part) => part?.type === 'text' && typeof part.text === 'string').map((part) => part.text).join('').trim() : ''; if (!text) continue; const providerID = asNonEmptyString(info.providerID); const modelID = asNonEmptyString(info.modelID); result.push({ id: asNonEmptyString(info.id) || '', role: messageRole, createdAt: Number.isFinite(info?.time?.created) ? info.time.created : null, completedAt: Number.isFinite(info?.time?.completed) ? info.time.completed : null, model: providerID && modelID ? `${providerID}/${modelID}` : null, text, }); } return result.sort((left, right) => (left.createdAt || 0) - (right.createdAt || 0)); }; const parseModel = (value) => { const model = asNonEmptyString(value); if (!model) throw new OpenChamberControlError('model is required', 400); const slashIndex = model.indexOf('/'); if (slashIndex <= 0 || slashIndex === model.length - 1) { throw new OpenChamberControlError('model must be in provider/model format', 400); } return { providerID: model.slice(0, slashIndex), modelID: model.slice(slashIndex + 1) }; }; const parseWeekdays = (value) => { const raw = asNonEmptyString(value); if (!raw) throw new OpenChamberControlError('weekly is required', 400); const weekdays = raw.split(',').map((entry) => Number.parseInt(entry.trim(), 10)); if (weekdays.some((entry) => !Number.isInteger(entry) || entry < 0 || entry > 6)) { throw new OpenChamberControlError('weekly must contain weekdays from 0 to 6', 400); } return Array.from(new Set(weekdays)).sort((a, b) => a - b); }; const buildSchedule = (input) => { const daily = asNonEmptyString(input.daily); const weekly = asNonEmptyString(input.weekly); const once = asNonEmptyString(input.once); const cron = asNonEmptyString(input.cron); const selectors = [daily, weekly, once, cron].filter(Boolean); if (selectors.length !== 1) { throw new OpenChamberControlError('Provide exactly one of daily, weekly, once, or cron', 400); } const timezone = asNonEmptyString(input.timezone); if (daily) return { kind: 'daily', times: [daily], ...(timezone ? { timezone } : {}) }; if (weekly) { const time = asNonEmptyString(input.time); if (!time) throw new OpenChamberControlError('time is required with weekly', 400); return { kind: 'weekly', weekdays: parseWeekdays(weekly), times: [time], ...(timezone ? { timezone } : {}) }; } if (once) { const time = asNonEmptyString(input.time); if (!time) throw new OpenChamberControlError('time is required with once', 400); return { kind: 'once', date: once, time, ...(timezone ? { timezone } : {}) }; } return { kind: 'cron', cron, ...(timezone ? { timezone } : {}) }; }; const buildScheduledTask = (input) => { const name = asNonEmptyString(input.name); const prompt = asNonEmptyString(input.prompt); if (!name) throw new OpenChamberControlError('name is required', 400); if (!prompt) throw new OpenChamberControlError('prompt is required', 400); const model = parseModel(input.model); const goalTokenBudget = input.goalTokenBudget; if (goalTokenBudget !== undefined && input.goal !== true) { throw new OpenChamberControlError('goalTokenBudget requires goal', 400); } if (goalTokenBudget !== undefined && (!Number.isSafeInteger(goalTokenBudget) || goalTokenBudget < 1000 || goalTokenBudget > 100_000_000)) { throw new OpenChamberControlError('goalTokenBudget must be from 1000 to 100000000', 400); } return { name, enabled: input.disabled !== true, schedule: buildSchedule(input), execution: { prompt, ...model, ...(asNonEmptyString(input.agent) ? { agent: input.agent.trim() } : {}), ...(asNonEmptyString(input.variant) ? { variant: input.variant.trim() } : {}), ...(input.goal === true ? { goalEnabled: true } : {}), ...(goalTokenBudget !== undefined ? { goalTokenBudget } : {}), }, }; }; export const createOpenChamberControlService = (dependencies) => { const { readSettingsFromDiskMigrated, sanitizeProjects, buildOpenCodeUrl, getOpenCodeAuthHeaders, waitForOpenCodeReady, sessionService, scheduledTaskService, browserControl = null, agentMemoryActions = null, createClient = createOpencodeClient, sleep = (duration) => new Promise((resolve) => setTimeout(resolve, duration)), now = Date.now, } = dependencies; const wait = (duration, signal) => { if (!signal) return sleep(duration); if (signal.aborted) return Promise.reject(new OpenChamberControlError('OpenChamber action was cancelled', 499)); return new Promise((resolve, reject) => { const onAbort = () => { signal.removeEventListener('abort', onAbort); reject(new OpenChamberControlError('OpenChamber action was cancelled', 499)); }; signal.addEventListener('abort', onAbort, { once: true }); sleep(duration).then(() => { signal.removeEventListener('abort', onAbort); resolve(); }, (error) => { signal.removeEventListener('abort', onAbort); reject(error); }); }); }; const getClient = async () => { if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250); return createClient({ baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''), headers: getOpenCodeAuthHeaders(), }); }; const projects = async () => { const settings = await readSettingsFromDiskMigrated(); return sanitizeProjects(settings?.projects || []).map((project) => ({ id: project.id, path: path.resolve(project.path), label: asNonEmptyString(project.label) || path.basename(project.path) || project.path, })); }; const models = async () => { const settings = await readSettingsFromDiskMigrated(); return { defaultModel: asNonEmptyString(settings?.defaultModel), defaultVariant: asNonEmptyString(settings?.defaultVariant), defaultAgent: asNonEmptyString(settings?.defaultAgent), favoriteModels: Array.isArray(settings?.favoriteModels) ? settings.favoriteModels : [], recentModels: Array.isArray(settings?.recentModels) ? settings.recentModels : [], }; }; const sessionStatus = async (client, sessionID, directory) => { const response = await client.session.status({ directory }); const statuses = response?.data; if (!statuses || typeof statuses !== 'object' || Array.isArray(statuses)) { throw new OpenChamberControlError('Invalid session status response', 500); } return statuses[sessionID] || { type: 'idle' }; }; const sessionMessages = async (client, sessionID, directory, role, limit) => { const fetchLimit = limit === undefined ? undefined : Math.max(100, limit * 4); let response = await client.session.messages({ sessionID, directory, ...(fetchLimit ? { limit: fetchLimit } : {}) }); let raw = Array.isArray(response?.data) ? response.data : []; let messages = extractTextMessages(raw, role); if (limit !== undefined && messages.length < limit && raw.length >= fetchLimit) { response = await client.session.messages({ sessionID, directory }); raw = Array.isArray(response?.data) ? response.data : []; messages = extractTextMessages(raw, role); } return limit === undefined ? messages : messages.slice(-limit); }; const waitForIdle = async ({ client, sessionID, directory, timeoutMs, requireActivity, baselineMessageID, startedAt, signal }) => { const deadline = now() + timeoutMs; let observedActivity = false; while (true) { if (signal?.aborted) throw new OpenChamberControlError('OpenChamber action was cancelled', 499); const status = await sessionStatus(client, sessionID, directory); if (status.type === 'busy' || status.type === 'retry') { observedActivity = true; } else if (!requireActivity || observedActivity) { return status; } else { const messages = await sessionMessages(client, sessionID, directory, 'assistant', 1); const message = messages[0]; if (message?.completedAt && (baselineMessageID ? message.id !== baselineMessageID : message.completedAt >= startedAt)) { return status; } } const remaining = deadline - now(); if (remaining <= 0) { throw new OpenChamberControlError(`Session did not become idle within ${Math.ceil(timeoutMs / 1000)} seconds`, 500); } await wait(Math.min(WAIT_POLL_INTERVAL_MS, remaining), signal); } }; // session.send/fork default the directory to the caller's context directory, // which is wrong for sessions living in other worktrees: prompt_async then // targets an instance that does not hold the session and the run dies with // UnknownError. Resolve the target session's directory from the global // session list when the caller did not scope explicitly. const resolveSessionDirectory = async (sessionID) => { try { const client = await getClient(); const response = await client.experimental?.session?.list?.({}); const sessions = Array.isArray(response?.data) ? response.data : []; const session = sessions.find((item) => item?.id === sessionID); return asNonEmptyString(session?.directory) || null; } catch { return null; } }; const executeSessionAction = async (action, input, contextDirectory, signal) => { if (input.timeout !== undefined && input.wait !== true) throw new OpenChamberControlError('timeout requires wait', 400); if (input.lastAssistant === true && input.wait !== true) throw new OpenChamberControlError('lastAssistant requires wait', 400); const sessionID = asNonEmptyString(input.sessionId); let directory = asNonEmptyString(input.directory) || (!input.projectId ? asNonEmptyString(contextDirectory) : null); if (sessionID && action !== 'session.create' && !asNonEmptyString(input.directory) && !input.projectId) { const resolvedSessionDirectory = await resolveSessionDirectory(sessionID); if (resolvedSessionDirectory) directory = resolvedSessionDirectory; } const payload = { ...(directory ? { directory } : {}), ...(asNonEmptyString(input.projectId) ? { projectId: input.projectId.trim() } : {}), ...(asNonEmptyString(input.title) ? { title: input.title.trim() } : {}), ...(asNonEmptyString(input.prompt) ? { prompt: input.prompt.trim() } : {}), ...(asNonEmptyString(input.model) ? { model: input.model.trim() } : {}), ...(asNonEmptyString(input.agent) ? { agent: input.agent.trim() } : {}), ...(asNonEmptyString(input.variant) ? { variant: input.variant.trim() } : {}), ...(input.goal === true ? { goal: true } : {}), ...(input.goalTokenBudget !== undefined ? { goalTokenBudget: input.goalTokenBudget } : {}), ...(asNonEmptyString(input.worktree) ? { worktree: { name: input.worktree.trim(), ...(asNonEmptyString(input.branch) ? { branchName: input.branch.trim() } : {}), ...(asNonEmptyString(input.startRef) ? { startRef: input.startRef.trim() } : {}), } } : {}), ...(typeof input.setUpstream === 'boolean' ? { setUpstream: input.setUpstream } : {}), ...(asNonEmptyString(input.messageId) ? { messageId: input.messageId.trim() } : {}), }; const startedAt = now(); let result; if (action === 'session.create') { result = await sessionService.create(payload); } else { if (!sessionID) throw new OpenChamberControlError('sessionId is required', 400); if (action === 'session.send') { result = await sessionService.send(sessionID, payload); } else { result = await sessionService.fork(sessionID, payload); } } if (input.wait !== true) { const publicResult = { ...result }; delete publicResult.baselineAssistantMessageId; return publicResult; } const client = await getClient(); const status = await waitForIdle({ client, sessionID: result.sessionId, directory: result.directory, timeoutMs: normalizeWaitTimeoutMs(input.timeout), requireActivity: result.promptDispatched === true, baselineMessageID: result.baselineAssistantMessageId, startedAt, signal, }); const publicResult = { ...result, sessionStatus: status }; delete publicResult.baselineAssistantMessageId; if (input.lastAssistant === true) { publicResult.lastAssistantMessage = (await sessionMessages(client, result.sessionId, result.directory, 'assistant', 1))[0] || null; } return publicResult; }; /** * Validates browser inputs here rather than in the renderer: an invalid call * should come back as a usage error the agent can correct, without waking a * client or waiting for a round trip. */ const browserAction = async (action, input, signal, contextDirectory) => { const parameters = {}; const readViewport = (required) => { const viewport = asNonEmptyString(input.viewport); if (!viewport) { if (required) throw new OpenChamberControlError('viewport is required for browser.resize', 400); return; } if (!['mobile', 'tablet', 'desktop', 'fill'].includes(viewport)) { throw new OpenChamberControlError('viewport must be mobile, tablet, desktop, or fill', 400); } parameters.viewport = viewport; }; if (action === 'browser.resize') readViewport(true); if (action === 'browser.capture') { const label = asNonEmptyString(input.label); if (label) parameters.label = label; } if (action === 'browser.open') { readViewport(false); const url = asNonEmptyString(input.url); if (!url) throw new OpenChamberControlError('url is required for browser.open', 400); let parsed; try { parsed = new URL(url); } catch { throw new OpenChamberControlError('url must be an absolute http(s) URL', 400); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new OpenChamberControlError('url must use http or https', 400); } parameters.url = parsed.toString(); } if (action === 'browser.click') { const selector = asNonEmptyString(input.selector); const text = asNonEmptyString(input.text); if (!selector && !text) { throw new OpenChamberControlError('browser.click requires selector or text', 400); } if (selector) parameters.selector = selector; if (text) parameters.text = text; } if (action === 'browser.snapshot') { const selector = asNonEmptyString(input.selector); if (selector) parameters.selector = selector; } if (action === 'browser.inspect') { const selector = asNonEmptyString(input.selector); if (!selector) throw new OpenChamberControlError('selector is required for browser.inspect', 400); parameters.selector = selector; } if (action === 'browser.type') { const selector = asNonEmptyString(input.selector); if (!selector) throw new OpenChamberControlError('selector is required for browser.type', 400); if (typeof input.value !== 'string') { throw new OpenChamberControlError('value is required for browser.type', 400); } parameters.selector = selector; parameters.value = input.value; parameters.submit = input.submit === true; } if (action === 'browser.scroll') { const selector = asNonEmptyString(input.selector); const direction = asNonEmptyString(input.direction); if (!selector && !direction) { throw new OpenChamberControlError('browser.scroll requires direction or selector', 400); } if (direction && !['up', 'down', 'top', 'bottom'].includes(direction)) { throw new OpenChamberControlError('direction must be up, down, top, or bottom', 400); } if (selector) parameters.selector = selector; if (direction) parameters.direction = direction; } // Opening a page waits for the navigation to settle, so its budget has to // exceed the client's own wait; sharing one timeout with the quick actions // made a slow page indistinguishable from an unreachable browser. const timeoutMs = action === 'browser.open' ? 45_000 : 20_000; const result = await browserControl.request(action, parameters, { signal, timeoutMs }); // The image is written here rather than in the renderer: the file belongs // beside the code it documents, and the client that took it may be on a // different machine than the repository. if (action === 'browser.capture') { const directory = asNonEmptyString(input.directory) || asNonEmptyString(contextDirectory); if (!directory) { throw new OpenChamberControlError('directory is required to save a screenshot', 400); } const capture = result && typeof result === 'object' ? result : {}; const saved = await writeScreenshot({ directory, base64: capture.base64, mime: capture.mime, label: input.label, }); // The base64 never goes back to the caller: it is large, and the path is // what an answer, a commit, or a review can actually use. return { path: saved.path, // Saving the file is only half of showing it. Chat collects the image // paths written in a finished answer and renders them below it, so the // agent is told the one thing it cannot infer: that writing the path is // what puts the picture in front of the user. hint: `Write ![](${saved.path}) in your reply to show this image to the user; it is rendered under your message.`, url: capture.url ?? null, title: capture.title ?? null, viewport: capture.viewport ?? null, width: capture.width ?? null, height: capture.height ?? null, }; } return result; }; const execute = async (action, input = {}, contextDirectory, options = {}) => { try { if (!CONTROL_ACTIONS.has(action)) { throw new OpenChamberControlError(`Unsupported OpenChamber action: ${action || 'missing'}`, 400); } if (action.startsWith('memory.')) { if (!agentMemoryActions) { throw new OpenChamberControlError('Agent memory is not available on this server', 503); } return agentMemoryActions.execute(action, input, contextDirectory); } if (action.startsWith('browser.')) { if (!browserControl) { throw new OpenChamberControlError('The in-app browser is not available on this server', 503); } return browserAction(action, input, options.signal, contextDirectory); } if (action === 'projects.list') return { projects: await projects() }; if (action === 'models.list') return models(); if (action === 'schedule.status') return scheduledTaskService.status(); if (action.startsWith('schedule.')) { const taskID = asNonEmptyString(input.taskId); if (SCHEDULE_TASK_ID_ACTIONS.has(action) && !taskID) { throw new OpenChamberControlError('taskId is required', 400); } const explicitProjectID = asNonEmptyString(input.projectId); const explicitDirectory = asNonEmptyString(input.directory); const contextDirectoryFallback = explicitProjectID ? undefined : asNonEmptyString(contextDirectory) || undefined; const projectID = await scheduledTaskService.resolveProjectID({ projectId: explicitProjectID || undefined, directory: explicitDirectory || contextDirectoryFallback, }); switch (action) { case 'schedule.list': return { scheduler: await scheduledTaskService.status(), tasks: await scheduledTaskService.list(projectID) }; case 'schedule.create': { const result = await scheduledTaskService.upsert(projectID, buildScheduledTask(input)); return { task: result.task, created: result.created }; } case 'schedule.run': return scheduledTaskService.run(projectID, taskID); case 'schedule.delete': return { deleted: true, tasks: await scheduledTaskService.remove(projectID, taskID) }; case 'schedule.toggle': { if (typeof input.disabled !== 'boolean') { throw new OpenChamberControlError('disabled is required for schedule.toggle', 400); } const enabled = input.disabled === false; return { task: await scheduledTaskService.setEnabled(projectID, taskID, enabled), enabled }; } } } if (action === 'session.create' || action === 'session.send' || action === 'session.fork') { return executeSessionAction(action, input, contextDirectory, options.signal); } if (action.startsWith('session.')) { const directory = asNonEmptyString(input.directory) || asNonEmptyString(contextDirectory); const sessionID = asNonEmptyString(input.sessionId); const client = await getClient(); if (action === 'session.list') { const limit = positiveInteger(input.limit, 10, 'limit'); const response = await client.session.list(directory ? { directory } : {}); let sessions = Array.isArray(response?.data) ? response.data : []; if (input.all !== true) sessions = sessions.filter((session) => !session?.time?.archived); sessions = sessions.slice(0, limit); if (input.withStatus === true) { const cache = new Map(); sessions = await Promise.all(sessions.map(async (session) => { const sessionDirectory = asNonEmptyString(session?.directory); if (!sessionDirectory) return { ...session, status: { type: 'unknown' } }; if (!cache.has(sessionDirectory)) { const statusRequest = client.session.status({ directory: sessionDirectory }).catch(() => null); cache.set(sessionDirectory, statusRequest); } const statusResponse = await cache.get(sessionDirectory); return { ...session, status: statusResponse?.data?.[session.id] || (statusResponse ? { type: 'idle' } : { type: 'unknown' }) }; })); } return { sessions, limit, directory, archived: input.all === true ? 'included' : 'excluded' }; } if (!sessionID) throw new OpenChamberControlError('sessionId is required', 400); if (!directory) throw new OpenChamberControlError('directory is required', 400); if (action === 'session.status') { return { sessionId: sessionID, directory, sessionStatus: await sessionStatus(client, sessionID, directory) }; } if (action === 'session.messages') { if (input.timeout !== undefined && input.wait !== true) throw new OpenChamberControlError('timeout requires wait', 400); const role = input.lastAssistant === true ? 'assistant' : (asNonEmptyString(input.role) || 'all'); if (!['all', 'user', 'assistant'].includes(role)) throw new OpenChamberControlError('role must be all, user, or assistant', 400); const last = input.last === true || input.lastAssistant === true; if (input.all === true && (last || input.limit !== undefined)) throw new OpenChamberControlError('all cannot be combined with last or limit', 400); if (last && input.limit !== undefined) throw new OpenChamberControlError('last cannot be combined with limit', 400); const currentStatus = input.wait === true ? await waitForIdle({ client, sessionID, directory, timeoutMs: normalizeWaitTimeoutMs(input.timeout), requireActivity: false, startedAt: now(), signal: options.signal }) : await sessionStatus(client, sessionID, directory); const limit = input.all === true ? undefined : (last ? 1 : positiveInteger(input.limit, 10, 'limit')); return { sessionId: sessionID, directory, role, sessionStatus: currentStatus, messages: await sessionMessages(client, sessionID, directory, role, limit) }; } } throw new OpenChamberControlError(`Unsupported OpenChamber action: ${action || 'missing'}`, 400); } catch (error) { throw asControlError(error, `Failed to execute ${action || 'OpenChamber action'}`); } }; return { execute }; };