`prompt_async` answers 204 as soon as OpenCode forks the run and reports every later failure only on the session event stream, so an unusable model, agent, or variant produced a session with no message while the result still claimed `promptDispatched: true`. Validate an explicitly requested model, agent, and variant against the directory's own agent and provider lists before any session, worktree, or goal is created, and confirm a new user message actually reached the session before reporting the dispatch. A failed or empty lookup never turns a valid selection into a rejection.
828 lines
30 KiB
JavaScript
828 lines
30 KiB
JavaScript
import express from 'express';
|
|
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
|
import { createWorktree } 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) => ({
|
|
...(directory ? { 'x-opencode-directory': 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);
|
|
};
|
|
|
|
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;
|
|
|
|
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,
|
|
} = 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 });
|
|
try {
|
|
await runPromptAsync({
|
|
baseUrl,
|
|
authHeaders,
|
|
sessionID,
|
|
directory,
|
|
payload: {
|
|
model,
|
|
...(agent ? { agent } : {}),
|
|
...(variant ? { variant } : {}),
|
|
parts: [
|
|
{ type: 'text', text: expandedPrompt },
|
|
...(goalInput.enabled
|
|
? [{ type: 'text', text: buildGoalIntroText(goalInput.tokenBudget), synthetic: true }]
|
|
: []),
|
|
],
|
|
},
|
|
});
|
|
} catch (error) {
|
|
throw markGoalPartial(error);
|
|
}
|
|
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) };
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
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,
|
|
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/: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');
|
|
}
|
|
},
|
|
);
|
|
};
|