Files
openchamber/packages/web/server/lib/openchamber-sessions/routes.js
T
bot-hermes 4b95b327dd fix: forward agent/model/variant to session creation
createSession() only sent { directory, title } to the OpenCode server,
dropping the agent field. The create() function read payload.agent but
never forwarded it, so every session was created with the OpenCode
default (build) and the agent was only applied later at prompt time.

Now forwards agent/model/variant in the session create POST body so the
session is created with the requested agent from the start.
2026-09-06 17:25:40 +00:00

982 lines
37 KiB
JavaScript

import express from 'express';
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import { createWorktree, getWorktreeBootstrapStatus } from '../git/index.js';
import { expandSnippets } from '../opencode/snippets.js';
import { expandCommandGoalObjective, parseScheduledCommandPrompt } from '../scheduled-tasks/runtime.js';
import { buildGoalIntroText, createSessionGoal } from '../session-goal/create.js';
import { OpenChamberControlError, asControlError } from '../openchamber-control/error.js';
const asNonEmptyString = (value) => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const splitModel = (value) => {
const model = asNonEmptyString(value);
if (!model) return null;
const slashIndex = model.indexOf('/');
if (slashIndex <= 0 || slashIndex === model.length - 1) return null;
return {
providerID: model.slice(0, slashIndex),
modelID: model.slice(slashIndex + 1),
};
};
const resolveRequestedModel = (payload) => {
const model = splitModel(payload?.model);
if (model) return model;
const providerID = asNonEmptyString(payload?.providerID);
const modelID = asNonEmptyString(payload?.modelID);
return providerID && modelID ? { providerID, modelID } : null;
};
const FALLBACK_PROVIDER_ID = 'opencode';
const FALLBACK_MODEL_ID = 'big-pickle';
const MIN_GOAL_TOKEN_BUDGET = 1_000;
const MAX_GOAL_TOKEN_BUDGET = 100_000_000;
const resolveGoalInput = (payload, prompt) => {
const enabled = payload?.goal === true;
if (payload?.goalTokenBudget !== undefined && !enabled) {
return { ok: false, error: 'goalTokenBudget requires goal' };
}
if (enabled && !prompt) {
return { ok: false, error: 'prompt is required when goal is enabled' };
}
if (payload?.goalTokenBudget === undefined) {
return { ok: true, enabled, tokenBudget: null };
}
const tokenBudget = payload.goalTokenBudget;
if (!Number.isSafeInteger(tokenBudget)
|| tokenBudget < MIN_GOAL_TOKEN_BUDGET
|| tokenBudget > MAX_GOAL_TOKEN_BUDGET) {
return { ok: false, error: `goalTokenBudget must be an integer from ${MIN_GOAL_TOKEN_BUDGET} to ${MAX_GOAL_TOKEN_BUDGET}` };
}
return { ok: true, enabled, tokenBudget };
};
const isPrimaryAgentMode = (mode) => !mode || mode === 'primary' || mode === 'all';
const providerModels = (provider) => {
if (Array.isArray(provider?.models)) return provider.models;
if (provider?.models && typeof provider.models === 'object') return Object.values(provider.models);
return [];
};
const hasProviderModel = (providers, providerID, modelID) => {
return providers.some((provider) => provider?.id === providerID
&& providerModels(provider).some((model) => model?.id === modelID));
};
const resolveVariant = (providers, providerID, modelID, variant) => {
const normalized = asNonEmptyString(variant);
if (!normalized) return undefined;
const provider = providers.find((entry) => entry?.id === providerID);
const model = providerModels(provider).find((entry) => entry?.id === modelID);
return model?.variants && Object.prototype.hasOwnProperty.call(model.variants, normalized)
? normalized
: undefined;
};
const parseConfigModel = (value) => splitModel(value);
const buildDirectoryHeaders = (directory) => ({
// OpenCode rejects non-ASCII header values; the official SDK sends this
// header percent-encoded, so match that wire format (non-ASCII checkout
// paths such as "Masaüstü" otherwise fail every dispatched prompt).
...(directory ? { 'x-opencode-directory': encodeURIComponent(directory) } : {}),
});
const fetchJson = async (url, authHeaders, fallback, directory) => {
const response = await fetch(url.toString(), {
headers: { ...authHeaders, ...buildDirectoryHeaders(directory), accept: 'application/json' },
});
if (!response.ok) return fallback;
return response.json().catch(() => fallback);
};
const fetchSelectionInputs = async ({ buildOpenCodeUrl, authHeaders, directory, readSettingsFromDiskMigrated }) => {
const settings = await readSettingsFromDiskMigrated();
const providersUrl = new URL(buildOpenCodeUrl('/config/providers', ''));
providersUrl.searchParams.set('directory', directory);
const agentsUrl = new URL(buildOpenCodeUrl('/agent', ''));
agentsUrl.searchParams.set('directory', directory);
const configUrl = new URL(buildOpenCodeUrl('/config', ''));
configUrl.searchParams.set('directory', directory);
const [providersBody, agentsBody, configBody] = await Promise.all([
fetchJson(providersUrl, authHeaders, { providers: [] }, directory),
fetchJson(agentsUrl, authHeaders, [], directory),
fetchJson(configUrl, authHeaders, {}, directory),
]);
return {
settings,
providers: Array.isArray(providersBody?.providers) ? providersBody.providers : [],
agents: Array.isArray(agentsBody) ? agentsBody : [],
opencodeDefaultAgent: asNonEmptyString(configBody?.default_agent) || asNonEmptyString(configBody?.defaultAgent),
opencodeDefaultModel: asNonEmptyString(configBody?.model),
};
};
const resolveDefaultSelection = ({ agents, providers, settings, opencodeDefaultAgent, opencodeDefaultModel }) => {
const primaryAgents = agents.filter((agent) => isPrimaryAgentMode(agent?.mode) && agent?.hidden !== true);
let resolvedAgent = null;
const settingsDefaultAgent = asNonEmptyString(settings?.defaultAgent);
if (settingsDefaultAgent) {
resolvedAgent = agents.find((agent) => agent?.name === settingsDefaultAgent) || null;
}
if (!resolvedAgent && opencodeDefaultAgent) {
const candidate = agents.find((agent) => agent?.name === opencodeDefaultAgent) || null;
if (candidate && isPrimaryAgentMode(candidate.mode) && candidate.hidden !== true) {
resolvedAgent = candidate;
}
}
if (!resolvedAgent) {
resolvedAgent = primaryAgents.find((agent) => agent?.name === 'build') || primaryAgents[0] || agents[0] || null;
}
let model = null;
let variant;
const settingsDefaultModel = parseConfigModel(settings?.defaultModel);
if (settingsDefaultModel && hasProviderModel(providers, settingsDefaultModel.providerID, settingsDefaultModel.modelID)) {
model = settingsDefaultModel;
variant = resolveVariant(providers, model.providerID, model.modelID, settings?.defaultVariant);
}
if (!model && resolvedAgent?.model?.providerID && resolvedAgent?.model?.modelID
&& hasProviderModel(providers, resolvedAgent.model.providerID, resolvedAgent.model.modelID)) {
model = { providerID: resolvedAgent.model.providerID, modelID: resolvedAgent.model.modelID };
variant = resolveVariant(providers, model.providerID, model.modelID, resolvedAgent.variant);
}
const opencodeModel = parseConfigModel(opencodeDefaultModel);
if (!model && opencodeModel && hasProviderModel(providers, opencodeModel.providerID, opencodeModel.modelID)) {
model = opencodeModel;
}
if (!model && hasProviderModel(providers, FALLBACK_PROVIDER_ID, FALLBACK_MODEL_ID)) {
model = { providerID: FALLBACK_PROVIDER_ID, modelID: FALLBACK_MODEL_ID };
}
if (!model) {
const provider = providers[0];
const firstModel = providerModels(provider)[0];
if (provider?.id && firstModel?.id) {
model = { providerID: provider.id, modelID: firstModel.id };
}
}
return {
agent: resolvedAgent?.name,
model,
variant,
};
};
const runPromptAsync = async ({ baseUrl, authHeaders, sessionID, directory, payload }) => {
const promptUrl = new URL(`${baseUrl}/session/${encodeURIComponent(sessionID)}/prompt_async`);
promptUrl.searchParams.set('directory', directory);
const response = await fetch(promptUrl.toString(), {
method: 'POST',
headers: {
...authHeaders,
...buildDirectoryHeaders(directory),
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`prompt_async failed (${response.status})${body ? `: ${body}` : ''}`);
}
};
const createSession = async ({ baseUrl, authHeaders, directory, title, agent, model, variant }) => {
const sessionUrl = new URL(`${baseUrl}/session`);
sessionUrl.searchParams.set('directory', directory);
const response = await fetch(sessionUrl.toString(), {
method: 'POST',
headers: {
...authHeaders,
...buildDirectoryHeaders(directory),
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({
directory,
...(title ? { title } : {}),
...(agent ? { agent } : {}),
...(model ? { model } : {}),
...(variant ? { variant } : {}),
}),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`session create failed (${response.status})${body ? `: ${body}` : ''}`);
}
const body = await response.json().catch(() => null);
const sessionID = body?.id || body?.data?.id;
if (!sessionID) {
throw new Error('failed to create session');
}
return sessionID;
};
const forkSession = async ({ client, sessionID, directory, messageID }) => {
const response = await client.session.fork({
sessionID,
directory,
...(messageID ? { messageID } : {}),
});
const session = response?.data;
if (!session?.id) {
throw new Error('failed to fork session');
}
return session;
};
const latestCompletedAssistantMessageID = async ({ client, sessionID, directory }) => {
let response;
try {
response = await client.session.messages({ sessionID, directory, limit: 100 });
} catch {
return null;
}
const messages = Array.isArray(response?.data) ? response.data : [];
let latest = null;
for (const message of messages) {
const info = message?.info;
if (info?.role !== 'assistant' || !Number.isFinite(info?.time?.completed)) continue;
if (!latest || (info.time.created || 0) >= (latest.time?.created || 0)) latest = info;
}
return asNonEmptyString(latest?.id);
};
/**
* Upper bound on one archive batch.
*
* The batch is applied one session at a time against OpenCode, so an unbounded
* list would hold a request open for as long as the list is large. Callers with
* more sessions than this send several batches and keep their own partial
* results.
*/
const MAX_ARCHIVE_BATCH = 500;
const parseArchiveRequest = (payload) => {
const rawIds = payload?.ids;
if (!Array.isArray(rawIds) || rawIds.length === 0) {
return { ok: false, error: 'ids must be a non-empty array of session ids' };
}
if (rawIds.length > MAX_ARCHIVE_BATCH) {
return { ok: false, error: `ids must contain at most ${MAX_ARCHIVE_BATCH} session ids` };
}
const ids = [];
for (const value of rawIds) {
const id = asNonEmptyString(value);
if (!id) return { ok: false, error: 'ids must contain non-empty session ids' };
ids.push(id);
}
const archivedAt = payload?.archivedAt;
if (archivedAt !== undefined && (!Number.isSafeInteger(archivedAt) || archivedAt <= 0)) {
return { ok: false, error: 'archivedAt must be a positive integer timestamp' };
}
return { ok: true, ids, archivedAt: archivedAt ?? Date.now() };
};
const resolveRequestedDirectory = async ({ payload, readSettingsFromDiskMigrated, sanitizeProjects, validateDirectoryPath }) => {
const projectID = asNonEmptyString(payload?.projectId) || asNonEmptyString(payload?.projectID);
if (projectID) {
const settings = await readSettingsFromDiskMigrated();
const projects = sanitizeProjects(settings?.projects || []);
const project = projects.find((entry) => entry.id === projectID) || null;
if (!project?.path) {
return { ok: false, status: 404, error: 'Project not found' };
}
const validated = await validateDirectoryPath(project.path);
return validated.ok
? { ok: true, directory: validated.directory, projectId: projectID }
: { ok: false, status: 400, error: validated.error || 'Invalid project directory' };
}
const directory = asNonEmptyString(payload?.directory);
const validated = await validateDirectoryPath(directory);
return validated.ok
? { ok: true, directory: validated.directory }
: { ok: false, status: 400, error: validated.error || 'Invalid directory' };
};
const PROMPT_LANDED_TIMEOUT_MS = 5_000;
const PROMPT_LANDED_POLL_MS = 150;
// createWorktree returns while the worktree is still being populated in the
// background (git reset --hard after a --no-checkout add). Dispatching a
// prompt into a half-populated directory makes opencode's run die with
// UnknownError (agent and config files are not there yet), so wait until the
// bootstrap reaches git-ready (population done) or fails before creating the
// session and dispatching.
const WORKTREE_BOOTSTRAP_TIMEOUT_MS = 60_000;
const WORKTREE_BOOTSTRAP_POLL_MS = 150;
const waitForWorktreeBootstrapReady = async ({ directory }) => {
const deadline = Date.now() + WORKTREE_BOOTSTRAP_TIMEOUT_MS;
for (;;) {
const status = await getWorktreeBootstrapStatus(directory);
if (status?.status === 'failed') {
throw new OpenChamberControlError(`Worktree bootstrap failed: ${status.error || 'unknown error'}`, 500);
}
const phase = status?.phase;
if (status?.status === 'ready' || phase === 'git-ready' || phase === 'setup-ready') return;
if (Date.now() >= deadline) {
throw new OpenChamberControlError('Timed out waiting for the worktree bootstrap', 500);
}
await new Promise((resolve) => setTimeout(resolve, WORKTREE_BOOTSTRAP_POLL_MS));
}
};
const latestUserMessageID = async ({ client, sessionID, directory }) => {
let response;
try {
response = await client.session.messages({ sessionID, directory, limit: 100 });
} catch {
return { ok: false, messageID: null };
}
const messages = Array.isArray(response?.data) ? response.data : [];
let latest = null;
for (const message of messages) {
const info = message?.info;
if (info?.role !== 'user') continue;
if (!latest || (info.time?.created || 0) >= (latest.time?.created || 0)) latest = info;
}
return { ok: true, messageID: asNonEmptyString(latest?.id) };
};
// `prompt_async` answers 204 as soon as OpenCode forks the run, and every later
// failure is reported only on the session event stream. Confirm the prompt was
// actually recorded so `promptDispatched` never claims a dispatch that vanished.
const waitForPromptLanded = async ({ client, sessionID, directory, baselineUserMessageID }) => {
const deadline = Date.now() + PROMPT_LANDED_TIMEOUT_MS;
for (;;) {
const latest = await latestUserMessageID({ client, sessionID, directory });
// A failed lookup is not authoritative evidence that the prompt was lost.
if (!latest.ok) return true;
if (latest.messageID && latest.messageID !== baselineUserMessageID) return true;
if (Date.now() >= deadline) return false;
await new Promise((resolve) => setTimeout(resolve, PROMPT_LANDED_POLL_MS));
}
};
const resolveWorktreeInput = (payload) => {
if (!payload?.worktree || typeof payload.worktree !== 'object') return null;
const name = asNonEmptyString(payload.worktree.name);
if (!name) return null;
const branchName = asNonEmptyString(payload.worktree.branchName);
const startRef = asNonEmptyString(payload.worktree.startRef);
return {
mode: 'new',
name,
...(branchName ? { branchName } : {}),
...(startRef ? { startRef } : {}),
...(typeof payload.setUpstream === 'boolean' ? { setUpstream: payload.setUpstream } : {}),
};
};
export const createOpenChamberSessionService = (dependencies) => {
const {
readSettingsFromDiskMigrated,
sanitizeProjects,
validateDirectoryPath,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
waitForOpenCodeReady,
emitSessionCreatedEvent,
createSessionGoal: createSessionGoalOverride,
sessionKnowledgeRuntime = null,
} = dependencies;
// Last user message of an existing session, as a selection to reuse. Returns
// null when the session has no user message carrying a model.
const fetchLastUserSelection = async ({ client, sessionID, directory }) => {
try {
const response = await client.session.messages({ sessionID, directory, limit: 20 });
const records = Array.isArray(response?.data) ? response.data : [];
for (let index = records.length - 1; index >= 0; index -= 1) {
const info = records[index]?.info;
if (info?.role !== 'user') continue;
const providerID = asNonEmptyString(info.model?.providerID);
const modelID = asNonEmptyString(info.model?.modelID);
if (!providerID || !modelID) continue;
return {
model: { providerID, modelID },
agent: asNonEmptyString(info.agent),
variant: asNonEmptyString(info.model?.variant),
};
}
} catch {
}
return null;
};
// Explicit model/agent/variant are never checked by `prompt_async`: an unknown
// agent makes the forked run fail silently, leaving a session with no message.
// Reject them before any session, worktree, or goal side effect happens.
const validateRequestedSelection = async ({ directory, requestedModel, requestedAgent, requestedVariant }) => {
if (!requestedModel && !requestedAgent && !requestedVariant) return;
const authHeaders = getOpenCodeAuthHeaders();
const { providers, agents } = await fetchSelectionInputs({
buildOpenCodeUrl,
authHeaders,
directory,
readSettingsFromDiskMigrated,
});
// An empty list means the lookup failed or returned nothing authoritative;
// it must not turn a valid selection into a rejection.
if (requestedAgent && agents.length > 0) {
const agent = agents.find((entry) => entry?.name === requestedAgent) || null;
if (!agent) {
throw new OpenChamberControlError(`Unknown agent '${requestedAgent}' for ${directory}`, 400);
}
if (!isPrimaryAgentMode(agent.mode)) {
throw new OpenChamberControlError(`Agent '${requestedAgent}' is a subagent and cannot receive a prompt directly`, 400);
}
}
if (requestedModel && providers.length > 0) {
if (!hasProviderModel(providers, requestedModel.providerID, requestedModel.modelID)) {
throw new OpenChamberControlError(
`Unknown model '${requestedModel.providerID}/${requestedModel.modelID}' for ${directory}`,
400,
);
}
if (requestedVariant
&& !resolveVariant(providers, requestedModel.providerID, requestedModel.modelID, requestedVariant)) {
throw new OpenChamberControlError(
`Unknown variant '${requestedVariant}' for model '${requestedModel.providerID}/${requestedModel.modelID}'`,
400,
);
}
}
};
const dispatchPrompt = async ({
client,
baseUrl,
authHeaders,
sessionID,
directory,
prompt,
goalInput,
requestedModel,
requestedAgent,
requestedVariant,
reuseSessionSelection = false,
}) => {
let model = requestedModel;
let agent = requestedAgent;
let variant = requestedVariant;
if (reuseSessionSelection && (!model || !agent)) {
const previous = await fetchLastUserSelection({ client, sessionID, directory });
if (previous) {
if (!model && previous.model) {
model = previous.model;
if (variant == null) variant = previous.variant ?? undefined;
}
if (!agent && previous.agent) agent = previous.agent;
}
}
if (!model || !agent) {
const inputs = await fetchSelectionInputs({
buildOpenCodeUrl,
authHeaders,
directory,
readSettingsFromDiskMigrated,
});
const defaults = resolveDefaultSelection(inputs);
if (!model) {
model = defaults.model;
if (variant == null) variant = defaults.variant;
}
agent = agent || defaults.agent;
}
if (!model) {
const error = new Error('No model is configured or available for the requested directory');
error.statusCode = 400;
throw error;
}
const expandedPrompt = expandSnippets(prompt, directory);
const parsedCommand = parseScheduledCommandPrompt(prompt);
let resolvedCommand = null;
if (parsedCommand) {
try {
const response = await client.command.list({ directory });
const commands = Array.isArray(response?.data) ? response.data : [];
const command = commands.find((candidate) => candidate?.name === parsedCommand.command);
if (command) resolvedCommand = { ...parsedCommand, template: command.template };
} catch {
}
}
if (goalInput.enabled) {
const commandObjective = resolvedCommand
? expandCommandGoalObjective(resolvedCommand.template, resolvedCommand.arguments)
: null;
await (createSessionGoalOverride || createSessionGoal)({
baseUrl,
authHeaders,
sessionID,
directory,
objective: commandObjective ?? expandedPrompt,
tokenBudget: goalInput.tokenBudget,
providerID: model.providerID,
modelID: model.modelID,
onWarning: (message, error) => console.warn(`[OpenChamberSessions] ${message}:`, error?.message || error),
});
}
const markGoalPartial = (error) => {
if (goalInput.enabled && error && typeof error === 'object') error.goalConfigured = true;
return error;
};
if (resolvedCommand) {
try {
await client.session.command({
sessionID,
directory,
command: resolvedCommand.command,
arguments: resolvedCommand.arguments,
...(agent ? { agent } : {}),
model: `${model.providerID}/${model.modelID}`,
...(variant ? { variant } : {}),
});
} catch (error) {
throw markGoalPartial(error);
}
} else {
const baseline = await latestUserMessageID({ client, sessionID, directory });
// A session the agent dispatched has no UI to attach the project's
// standing context, so it is asked for here. Never fails the dispatch:
// a session that runs without its background beats one that never runs.
const knowledge = sessionKnowledgeRuntime
? await sessionKnowledgeRuntime.resolvePendingForSession(sessionID, directory)
.catch(() => ({ text: '', signature: '' }))
: { text: '', signature: '' };
try {
await runPromptAsync({
baseUrl,
authHeaders,
sessionID,
directory,
payload: {
model,
...(agent ? { agent } : {}),
...(variant ? { variant } : {}),
parts: [
...(knowledge.text ? [{ type: 'text', text: knowledge.text, synthetic: true }] : []),
{ type: 'text', text: expandedPrompt },
...(goalInput.enabled
? [{ type: 'text', text: buildGoalIntroText(goalInput.tokenBudget), synthetic: true }]
: []),
],
},
});
} catch (error) {
throw markGoalPartial(error);
}
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);
}
const landed = await waitForPromptLanded({
client,
sessionID,
directory,
baselineUserMessageID: baseline.messageID,
});
if (!landed) {
return {
model,
agent,
variant,
promptDispatched: false,
dispatchedAsCommand: false,
promptError: 'OpenCode accepted the prompt but it never appeared in the session',
};
}
}
return { model, agent, variant, promptDispatched: true, dispatchedAsCommand: Boolean(resolvedCommand) };
};
/**
* Archive a batch of sessions in one request.
*
* The UI archives every session linked to a worktree before removing it.
* Doing that from the browser costs one request per session plus a store
* reconciliation between each of them, which is what made deleting a
* worktree with many sessions take tens of seconds. Here the batch stays on
* the server, next to OpenCode, and the client reconciles once.
*
* Sessions are updated one at a time on purpose: they are archived against a
* single OpenCode instance, and a fan-out of concurrent writes would trade a
* UI stall for server event-loop starvation. One failed session never stops
* the batch — it is reported in `failedIds` while the rest still archive, so
* callers keep the partial-failure behaviour they already show.
*/
const archive = async (payload = {}) => {
const parsed = parseArchiveRequest(payload);
if (!parsed.ok) {
throw new OpenChamberControlError(parsed.error, 400);
}
const resolvedDirectory = await resolveRequestedDirectory({
payload,
readSettingsFromDiskMigrated,
sanitizeProjects,
validateDirectoryPath,
});
if (!resolvedDirectory.ok) {
throw new OpenChamberControlError(resolvedDirectory.error, resolvedDirectory.status || 400);
}
if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
const directory = resolvedDirectory.directory;
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
const client = createOpencodeClient({ baseUrl, headers: getOpenCodeAuthHeaders() });
const archived = [];
const failedIds = [];
for (const sessionID of parsed.ids) {
try {
const response = await client.session.update({
sessionID,
directory,
time: { archived: parsed.archivedAt },
});
const session = response?.data;
if (session?.id) archived.push(session);
else failedIds.push(sessionID);
} catch (error) {
console.warn('[OpenChamberSessions] failed to archive session', sessionID, error);
failedIds.push(sessionID);
}
}
return { directory, archived, failedIds };
};
const create = async (payload = {}) => {
const title = asNonEmptyString(payload.title);
const prompt = asNonEmptyString(payload.prompt);
const goalInput = resolveGoalInput(payload, prompt);
if (!goalInput.ok) {
throw new OpenChamberControlError(goalInput.error, 400);
}
const model = resolveRequestedModel(payload);
const agent = asNonEmptyString(payload.agent);
const variant = asNonEmptyString(payload.variant);
const resolvedDirectory = await resolveRequestedDirectory({
payload,
readSettingsFromDiskMigrated,
sanitizeProjects,
validateDirectoryPath,
});
if (!resolvedDirectory.ok) {
throw new OpenChamberControlError(resolvedDirectory.error, resolvedDirectory.status || 400);
}
const worktreeInput = resolveWorktreeInput(payload);
let worktree = null;
let sessionDirectory = resolvedDirectory.directory;
if (payload?.worktree && !worktreeInput) {
throw new OpenChamberControlError('worktree.name is required when worktree is provided', 400);
}
if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
if (prompt) {
await validateRequestedSelection({
directory: resolvedDirectory.directory,
requestedModel: model,
requestedAgent: agent,
requestedVariant: variant,
});
}
if (worktreeInput) {
worktree = await createWorktree(resolvedDirectory.directory, worktreeInput);
sessionDirectory = worktree.path;
await waitForWorktreeBootstrapReady({ directory: sessionDirectory });
}
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
const authHeaders = getOpenCodeAuthHeaders();
const client = createOpencodeClient({ baseUrl, headers: authHeaders });
const sessionID = await createSession({
client,
baseUrl,
authHeaders,
directory: sessionDirectory,
...(title ? { title } : {}),
...(agent ? { agent } : {}),
...(model ? { model } : {}),
...(variant ? { variant } : {}),
});
let dispatch = { model, agent, variant, promptDispatched: false, dispatchedAsCommand: false };
if (prompt) {
dispatch = await dispatchPrompt({
client,
baseUrl,
authHeaders,
sessionID,
directory: sessionDirectory,
prompt,
goalInput,
requestedModel: model,
requestedAgent: agent,
requestedVariant: variant,
});
}
const result = {
sessionId: sessionID,
directory: sessionDirectory,
...(resolvedDirectory.projectId ? { projectId: resolvedDirectory.projectId } : {}),
...(title ? { title } : {}),
...(worktree ? { worktree } : {}),
...(prompt && dispatch.model ? { model: dispatch.model } : {}),
...(prompt && dispatch.agent ? { agent: dispatch.agent } : {}),
...(prompt && dispatch.variant ? { variant: dispatch.variant } : {}),
promptDispatched: dispatch.promptDispatched,
...(dispatch.promptError ? { promptError: dispatch.promptError } : {}),
dispatchedAsCommand: dispatch.dispatchedAsCommand,
...(goalInput.enabled ? { goalEnabled: true } : {}),
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
};
try {
emitSessionCreatedEvent?.({
sessionID,
directory: sessionDirectory,
...(resolvedDirectory.projectId ? { projectID: resolvedDirectory.projectId } : {}),
...(title ? { title } : {}),
...(worktree ? { worktree } : {}),
...(prompt && dispatch.model ? { model: dispatch.model } : {}),
...(prompt && dispatch.agent ? { agent: dispatch.agent } : {}),
...(prompt && dispatch.variant ? { variant: dispatch.variant } : {}),
promptDispatched: dispatch.promptDispatched,
dispatchedAsCommand: dispatch.dispatchedAsCommand,
...(goalInput.enabled ? { goalEnabled: true } : {}),
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
createdAt: Date.now(),
});
} catch {
}
return result;
};
const runExisting = async (action, sourceSessionId, payload = {}) => {
const sourceSessionID = asNonEmptyString(sourceSessionId);
const prompt = asNonEmptyString(payload.prompt);
if (!sourceSessionID) throw new OpenChamberControlError('sessionId is required', 400);
if (!prompt) throw new OpenChamberControlError('prompt is required', 400);
const goalInput = resolveGoalInput(payload, prompt);
if (!goalInput.ok) throw new OpenChamberControlError(goalInput.error, 400);
const requestedModel = resolveRequestedModel(payload);
let targetSessionID = sourceSessionID;
let targetSession = null;
let directory = null;
try {
const resolvedDirectory = await resolveRequestedDirectory({
payload,
readSettingsFromDiskMigrated,
sanitizeProjects,
validateDirectoryPath,
});
if (!resolvedDirectory.ok) {
throw new OpenChamberControlError(resolvedDirectory.error, resolvedDirectory.status || 400);
}
directory = resolvedDirectory.directory;
if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
await validateRequestedSelection({
directory,
requestedModel,
requestedAgent: asNonEmptyString(payload.agent),
requestedVariant: asNonEmptyString(payload.variant),
});
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
const authHeaders = getOpenCodeAuthHeaders();
const client = createOpencodeClient({ baseUrl, headers: authHeaders });
if (action === 'fork') {
targetSession = await forkSession({
client,
sessionID: sourceSessionID,
directory,
messageID: asNonEmptyString(payload.messageId) || undefined,
});
targetSessionID = targetSession.id;
}
const baselineAssistantMessageId = await latestCompletedAssistantMessageID({
client,
sessionID: targetSessionID,
directory,
});
const dispatch = await dispatchPrompt({
client,
baseUrl,
authHeaders,
sessionID: targetSessionID,
directory,
prompt,
goalInput,
requestedModel,
requestedAgent: asNonEmptyString(payload.agent),
requestedVariant: asNonEmptyString(payload.variant),
reuseSessionSelection: true,
});
const result = {
action,
sessionId: targetSessionID,
directory,
...(action === 'fork' ? { sourceSessionId: sourceSessionID } : {}),
...(targetSession?.title ? { title: targetSession.title } : {}),
...(baselineAssistantMessageId ? { baselineAssistantMessageId } : {}),
model: dispatch.model,
...(dispatch.agent ? { agent: dispatch.agent } : {}),
...(dispatch.variant ? { variant: dispatch.variant } : {}),
promptDispatched: dispatch.promptDispatched,
...(dispatch.promptError ? { promptError: dispatch.promptError } : {}),
dispatchedAsCommand: dispatch.dispatchedAsCommand,
...(goalInput.enabled ? { goalEnabled: true } : {}),
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
};
if (action === 'fork') {
try {
emitSessionCreatedEvent?.({
sessionID: targetSessionID,
directory,
sourceSessionID,
...(targetSession?.title ? { title: targetSession.title } : {}),
model: dispatch.model,
...(dispatch.agent ? { agent: dispatch.agent } : {}),
...(dispatch.variant ? { variant: dispatch.variant } : {}),
promptDispatched: dispatch.promptDispatched,
dispatchedAsCommand: dispatch.dispatchedAsCommand,
...(goalInput.enabled ? { goalEnabled: true } : {}),
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
createdAt: Date.now(),
});
} catch {
}
}
return result;
} catch (error) {
const statusCode = Number(error?.statusCode) || 500;
const forkCreated = action === 'fork' && targetSessionID !== sourceSessionID;
const goalConfigured = error?.goalConfigured === true;
throw new OpenChamberControlError(
error instanceof Error ? error.message : `Failed to ${action} session`,
statusCode,
{
...(forkCreated || goalConfigured
? {
partial: true,
partialAction: forkCreated ? 'fork-created' : 'goal-configured',
sessionId: targetSessionID,
directory,
}
: {}),
},
);
}
};
return {
create,
archive,
send: (sessionID, payload) => runExisting('send', sessionID, payload),
fork: (sessionID, payload) => runExisting('fork', sessionID, payload),
};
};
const sendServiceError = (res, error, fallback) => {
const controlError = asControlError(error, fallback);
return res.status(controlError.statusCode).json({
error: controlError.message,
...(controlError.partial === true ? {
partial: true,
partialAction: controlError.partialAction,
sessionId: controlError.sessionId,
directory: controlError.directory,
} : {}),
});
};
export const registerOpenChamberSessionRoutes = (app, dependencies) => {
const service = dependencies.sessionService || createOpenChamberSessionService(dependencies);
app.post('/api/openchamber/sessions', express.json({ limit: '1mb' }), async (req, res) => {
try {
return res.json(await service.create(req.body && typeof req.body === 'object' ? req.body : {}));
} catch (error) {
console.error('[OpenChamberSessions] failed to create session:', error);
return sendServiceError(res, error, 'Failed to create session');
}
});
app.post('/api/openchamber/sessions/archive', express.json({ limit: '1mb' }), async (req, res) => {
try {
return res.json(await service.archive(req.body && typeof req.body === 'object' ? req.body : {}));
} catch (error) {
console.error('[OpenChamberSessions] failed to archive sessions:', error);
return sendServiceError(res, error, 'Failed to archive sessions');
}
});
app.post(
'/api/openchamber/sessions/:sessionId/send',
express.json({ limit: '1mb' }),
async (req, res) => {
try {
return res.json(await service.send(req.params.sessionId, req.body));
} catch (error) {
console.error('[OpenChamberSessions] failed to send session:', error);
return sendServiceError(res, error, 'Failed to send session');
}
},
);
app.post(
'/api/openchamber/sessions/:sessionId/fork',
express.json({ limit: '1mb' }),
async (req, res) => {
try {
return res.json(await service.fork(req.params.sessionId, req.body));
} catch (error) {
console.error('[OpenChamberSessions] failed to fork session:', error);
return sendServiceError(res, error, 'Failed to fork session');
}
},
);
};