2026-07-24 21:54:28 +03:00
import express from 'express' ;
import { createOpencodeClient } from '@opencode-ai/sdk/v2' ;
2026-08-06 17:47:30 -03:00
import { createWorktree , getWorktreeBootstrapStatus } from '../git/index.js' ;
2026-07-24 21:54:28 +03:00
import { expandSnippets } from '../opencode/snippets.js' ;
2026-07-29 00:47:29 +03:00
import { expandCommandGoalObjective , parseScheduledCommandPrompt } from '../scheduled-tasks/runtime.js' ;
2026-07-24 21:54:28 +03:00
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 ) => ({
2026-08-24 02:21:35 +03:00
// 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 ) } : {}),
2026-07-24 21:54:28 +03:00
});
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 } ` : '' } ` );
}
};
2026-09-07 22:34:56 +00:00
const createSession = async ({ baseUrl , authHeaders , directory , title , agent , model , variant , cardID }) => {
2026-07-24 21:54:28 +03:00
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' ,
},
2026-09-06 17:23:57 +00:00
body : JSON . stringify ({
directory ,
...( title ? { title } : {}),
...( agent ? { agent } : {}),
...( model ? { model } : {}),
...( variant ? { variant } : {}),
}),
2026-07-24 21:54:28 +03:00
});
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' );
}
2026-09-07 22:34:56 +00:00
if ( cardID ) {
const patchUrl = new URL ( ` ${ baseUrl } /session/ ${ encodeURIComponent ( sessionID ) } ` );
patchUrl . searchParams . set ( 'directory' , directory );
await fetch ( patchUrl . toString (), {
method : 'PATCH' ,
headers : {
... authHeaders ,
'content-type' : 'application/json' ,
accept : 'application/json' ,
},
body : JSON . stringify ({ metadata : { openchamber : { cardID } } }),
}). catch (() => {});
}
2026-07-24 21:54:28 +03:00
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 );
};
2026-09-04 16:50:03 +03:00
/**
* 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 () };
};
2026-07-24 21:54:28 +03:00
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' };
};
2026-08-04 11:08:09 +03:00
const PROMPT_LANDED_TIMEOUT_MS = 5_000 ;
const PROMPT_LANDED_POLL_MS = 150 ;
2026-08-06 17:47:30 -03:00
// 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 ));
}
};
2026-08-04 11:08:09 +03:00
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 ));
}
};
2026-07-24 21:54:28 +03:00
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 ,
2026-08-18 02:59:04 +03:00
sessionKnowledgeRuntime = null ,
2026-09-07 23:41:36 +00:00
planGateRuntime = null ,
2026-07-24 21:54:28 +03:00
} = 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 ;
};
2026-08-04 11:08:09 +03:00
// 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 ,
);
}
}
};
2026-07-24 21:54:28 +03:00
const dispatchPrompt = async ({
client ,
baseUrl ,
authHeaders ,
sessionID ,
directory ,
prompt ,
goalInput ,
2026-09-07 22:34:56 +00:00
planGate = false ,
2026-07-24 21:54:28 +03:00
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 );
2026-07-29 00:47:29 +03:00
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 {
}
}
2026-07-24 21:54:28 +03:00
if ( goalInput . enabled ) {
2026-07-29 00:47:29 +03:00
const commandObjective = resolvedCommand
? expandCommandGoalObjective ( resolvedCommand . template , resolvedCommand . arguments )
: null ;
2026-07-24 21:54:28 +03:00
await ( createSessionGoalOverride || createSessionGoal )({
baseUrl ,
authHeaders ,
sessionID ,
directory ,
2026-07-29 00:47:29 +03:00
objective : commandObjective ?? expandedPrompt ,
2026-07-24 21:54:28 +03:00
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 ;
};
2026-07-29 00:47:29 +03:00
if ( resolvedCommand ) {
2026-07-24 21:54:28 +03:00
try {
2026-07-29 00:47:29 +03:00
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 );
2026-07-24 21:54:28 +03:00
}
2026-07-29 00:47:29 +03:00
} else {
2026-08-04 11:08:09 +03:00
const baseline = await latestUserMessageID ({ client , sessionID , directory });
2026-08-18 02:59:04 +03:00
// 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 : '' };
2026-07-24 21:54:28 +03:00
try {
await runPromptAsync ({
baseUrl ,
authHeaders ,
sessionID ,
directory ,
payload : {
model ,
...( agent ? { agent } : {}),
...( variant ? { variant } : {}),
parts : [
2026-08-18 02:59:04 +03:00
...( knowledge . text ? [{ type : 'text' , text : knowledge . text , synthetic : true }] : []),
2026-07-24 21:54:28 +03:00
{ type : 'text' , text : expandedPrompt },
...( goalInput . enabled
? [{ type : 'text' , text : buildGoalIntroText ( goalInput . tokenBudget ), synthetic : true }]
: []),
2026-09-07 22:34:56 +00:00
...( planGate
? [{ type : 'text' , text : '<system-reminder>\nPlan gate is active. Before editing any files, output your implementation plan as a markdown block starting with `## Plan`. Then STOP and wait for approval. Do not read files beyond what you need to write the plan.\n</system-reminder>' , synthetic : true }]
: []),
2026-07-24 21:54:28 +03:00
],
},
});
} catch ( error ) {
throw markGoalPartial ( error );
}
2026-08-18 02:59:04 +03:00
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 );
}
2026-08-04 11:08:09 +03:00
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' ,
};
}
2026-07-24 21:54:28 +03:00
}
2026-07-29 00:47:29 +03:00
return { model , agent , variant , promptDispatched : true , dispatchedAsCommand : Boolean ( resolvedCommand ) };
2026-07-24 21:54:28 +03:00
};
2026-09-04 16:50:03 +03:00
/**
* 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 };
};
2026-07-24 21:54:28 +03:00
const create = async ( payload = {}) => {
const title = asNonEmptyString ( payload . title );
const prompt = asNonEmptyString ( payload . prompt );
2026-09-07 22:34:56 +00:00
const cardID = asNonEmptyString ( payload . cardID );
const planGate = payload . planGate === true ;
2026-07-24 21:54:28 +03:00
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 );
}
2026-08-04 11:08:09 +03:00
if ( typeof waitForOpenCodeReady === 'function' ) await waitForOpenCodeReady ( 10_000 , 250 );
if ( prompt ) {
await validateRequestedSelection ({
directory : resolvedDirectory . directory ,
requestedModel : model ,
requestedAgent : agent ,
requestedVariant : variant ,
});
}
2026-07-24 21:54:28 +03:00
if ( worktreeInput ) {
worktree = await createWorktree ( resolvedDirectory . directory , worktreeInput );
sessionDirectory = worktree . path ;
2026-08-06 17:47:30 -03:00
await waitForWorktreeBootstrapReady ({ directory : sessionDirectory });
2026-07-24 21:54:28 +03:00
}
const baseUrl = buildOpenCodeUrl ( '/' , '' ). replace ( /\/$/ , '' );
const authHeaders = getOpenCodeAuthHeaders ();
const client = createOpencodeClient ({ baseUrl , headers : authHeaders });
const sessionID = await createSession ({
client ,
baseUrl ,
authHeaders ,
directory : sessionDirectory ,
...( title ? { title } : {}),
2026-09-06 17:23:57 +00:00
...( agent ? { agent } : {}),
...( model ? { model } : {}),
...( variant ? { variant } : {}),
2026-09-07 22:34:56 +00:00
...( cardID ? { cardID } : {}),
2026-07-24 21:54:28 +03:00
});
2026-09-07 23:41:36 +00:00
if ( planGate && planGateRuntime && typeof planGateRuntime . activate === 'function' ) {
planGateRuntime . activate ( sessionID , sessionDirectory , cardID || '' );
}
2026-07-24 21:54:28 +03:00
let dispatch = { model , agent , variant , promptDispatched : false , dispatchedAsCommand : false };
if ( prompt ) {
dispatch = await dispatchPrompt ({
client ,
baseUrl ,
authHeaders ,
sessionID ,
directory : sessionDirectory ,
prompt ,
goalInput ,
2026-09-07 22:34:56 +00:00
planGate ,
2026-07-24 21:54:28 +03:00
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 ,
2026-08-04 11:08:09 +03:00
...( dispatch . promptError ? { promptError : dispatch . promptError } : {}),
2026-07-24 21:54:28 +03:00
dispatchedAsCommand : dispatch . dispatchedAsCommand ,
...( goalInput . enabled ? { goalEnabled : true } : {}),
...( goalInput . tokenBudget ? { goalTokenBudget : goalInput . tokenBudget } : {}),
2026-09-07 22:34:56 +00:00
...( cardID ? { cardID } : {}),
...( planGate ? { planGate : true } : {}),
2026-07-24 21:54:28 +03:00
};
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 } : {}),
2026-09-07 22:34:56 +00:00
...( cardID ? { cardID } : {}),
...( planGate ? { planGate : true } : {}),
2026-07-24 21:54:28 +03:00
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 );
2026-08-04 11:08:09 +03:00
await validateRequestedSelection ({
directory ,
requestedModel ,
requestedAgent : asNonEmptyString ( payload . agent ),
requestedVariant : asNonEmptyString ( payload . variant ),
});
2026-07-24 21:54:28 +03:00
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 } : {}),
2026-08-04 11:08:09 +03:00
promptDispatched : dispatch . promptDispatched ,
...( dispatch . promptError ? { promptError : dispatch . promptError } : {}),
2026-07-24 21:54:28 +03:00
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 } : {}),
2026-08-04 11:08:09 +03:00
promptDispatched : dispatch . promptDispatched ,
2026-07-24 21:54:28 +03:00
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 ,
2026-09-04 16:50:03 +03:00
archive ,
2026-07-24 21:54:28 +03:00
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' );
}
});
2026-09-04 16:50:03 +03:00
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' );
}
});
2026-07-24 21:54:28 +03:00
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' );
}
},
);
};