The panel stored notes, todos and plans inside one shared JSON file that six unrelated domains also wrote to, synchronised itself through window CustomEvents, and could only read plans. It is now Project knowledge: server-owned storage with explicit routes, a store with rollback, a section sidebar, plans that open and edit in place, and search across all of it. Notes and plans the user pins travel with every message sent in that project. Pinning is project state, not an attachment to one message, so it holds until unpinned and the work status panel names what is riding along and can detach it. Agent memory is added alongside, in two scopes: what is true about the user, and what is true about this codebase. The split is not cosmetic — a wrong project fact costs one project and is noticed, while a wrong global fact quietly shapes every session everywhere and the user has no code to check it against. It stays separate from notes so an agent mistake cannot land in what the user wrote. Sessions receive an index of titles only; bodies are read on demand, because an index carrying full text grows until it crowds out the conversation. Deciding what a session must be told, and whether it has been told, now lives on the server. The client owned it before, which meant sessions started without a UI — scheduled tasks, sessions the agent dispatches — received nothing at all, and a tab's record of what it had sent outlived the conversation: after compaction the agent no longer held the block while the tab went on believing it did. What was delivered is recorded in the session's own metadata, and compaction restores it through the runtime that already restores pinned messages, in the same turn. Agent memory ships dark behind OPENCHAMBER_MEMORY_ENABLE: unset, there is no tool, no routes, no session index, no settings row and no panel tab. Absent rather than switched off, so nothing invites turning on a feature that has not been announced. Pinned notes and plans are unaffected and ship as normal.
868 lines
32 KiB
JavaScript
868 lines
32 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) => ({
|
|
...(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;
|
|
|
|
// 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) };
|
|
};
|
|
|
|
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,
|
|
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');
|
|
}
|
|
},
|
|
);
|
|
};
|