Files
openchamber/packages/web/server/lib/openchamber-sessions/routes.js
T
Iuliia Ivashko 0ef189f3c8 perf(sessions): archive a worktree's sessions through one server batch
Removing a worktree archived its sessions one SDK call at a time and
then re-rendered the whole sidebar once per streamed session.updated
echo. On a worktree with 121 sessions that meant 14.8s of main-thread
work, 121 requests, and 328 localStorage writes.

- Add POST /api/openchamber/sessions/archive: validates a batch (max
  500 ids, per-request archivedAt), archives sequentially, and reports
  partial failures instead of dropping the batch. VS Code serves no
  such route and answers 501; the shared UI then falls back to the
  per-session path.
- Plan batches from the sessions this client actually holds, live
  directory stores first, so worktree-only sessions still batch.
- Claim (id, archivedAt) pairs before the request and consume the
  matching session.updated echoes, so the server's own confirmations
  no longer fan out into 121 store publications. Runtime-scoped, TTL
  30s, released on response or fallback; non-matching updates pass.
- Make the managed-chats persistence a real trailing debounce instead
  of a 50ms throttle, so a burst of publications coalesces into one
  localStorage write.

Benchmark (121 sessions, production build, real Chrome): 14785ms ->
~1030ms, long tasks 100 -> 1, global store publications 236 -> 1,
persistence writes 328 -> 3.
2026-09-04 16:50:03 +03:00

973 lines
36 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 }) => {
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 } : {}) }),
});
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 } : {}),
});
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');
}
},
);
};