Files
claudecodeui/server/opencode-cli.js
Haileyesus 9aa927002e feat: support session-scoped model overrides
Model selection was acting like a provider-level preference.

That made resumed sessions drift back to a default or request-time model.

Users expect /models changes made inside a conversation to affect that session.

Store explicit session choices in app-owned ~/.cloudcli state.

This avoids editing provider transcripts or native provider config.

Resolve the effective model before launching each provider runtime.

Claude, Cursor, Codex, Gemini, and OpenCode now honor stored resume choices.

Expose a backend active-model change endpoint for existing sessions.

The models modal can now distinguish default changes from session overrides.

It also shows when a selected model will apply on the next response.

For Claude, stop probing active model state by resuming with a dummy prompt.

Read the indexed JSONL transcript from the end instead.

This preserves provider history while honoring /model stdout or model fields.

Add service tests for adapter delegation and resume-model precedence.

The tests keep cache state, override state, and requested fallback separate.
2026-05-18 16:57:29 +03:00

247 lines
7.4 KiB
JavaScript

import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { createNormalizedMessage } from './shared/utils.js';
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
const activeOpenCodeProcesses = new Map();
function readOpenCodeSessionId(event) {
if (!event || typeof event !== 'object') {
return null;
}
return event.sessionID || event.sessionId || null;
}
async function spawnOpenCode(command, options = {}, ws) {
return new Promise((resolve, reject) => {
const { sessionId, projectPath, cwd, model, sessionSummary } = options;
const workingDir = cwd || projectPath || process.cwd();
const processKey = sessionId || Date.now().toString();
let capturedSessionId = sessionId || null;
let sessionCreatedSent = false;
let stdoutLineBuffer = '';
let terminalNotificationSent = false;
const notifyTerminalState = ({ code = null, error = null } = {}) => {
if (terminalNotificationSent) {
return;
}
terminalNotificationSent = true;
const finalSessionId = capturedSessionId || sessionId || processKey;
if (code === 0 && !error) {
notifyRunStopped({
userId: ws?.userId || null,
provider: 'opencode',
sessionId: finalSessionId,
sessionName: sessionSummary,
stopReason: 'completed',
});
return;
}
notifyRunFailed({
userId: ws?.userId || null,
provider: 'opencode',
sessionId: finalSessionId,
sessionName: sessionSummary,
error: error || `OpenCode CLI exited with code ${code}`,
});
};
const registerSession = (nextSessionId) => {
if (!nextSessionId || capturedSessionId === nextSessionId) {
return;
}
capturedSessionId = nextSessionId;
if (processKey !== capturedSessionId) {
activeOpenCodeProcesses.delete(processKey);
activeOpenCodeProcesses.set(capturedSessionId, opencodeProcess);
}
opencodeProcess.sessionId = capturedSessionId;
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
ws.setSessionId(capturedSessionId);
}
if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true;
ws.send(createNormalizedMessage({
kind: 'session_created',
newSessionId: capturedSessionId,
sessionId: capturedSessionId,
provider: 'opencode',
}));
}
};
const processOpenCodeOutputLine = (line) => {
if (!line || !line.trim()) {
return;
}
try {
const response = JSON.parse(line);
registerSession(readOpenCodeSessionId(response));
const normalized = sessionsService.normalizeMessage(
'opencode',
response,
capturedSessionId || sessionId || null,
);
for (const msg of normalized) {
ws.send(msg);
}
} catch {
ws.send(createNormalizedMessage({
kind: 'stream_delta',
content: line,
sessionId: capturedSessionId || sessionId || null,
provider: 'opencode',
}));
}
};
void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((resolvedModel) => {
const args = ['run', '--format', 'json'];
if (sessionId) {
args.push('--session', sessionId);
}
if (resolvedModel) {
args.push('--model', resolvedModel);
}
if (command && command.trim()) {
args.push(command.trim());
}
const opencodeProcess = spawnFunction('opencode', args, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
activeOpenCodeProcesses.set(processKey, opencodeProcess);
opencodeProcess.sessionId = processKey;
opencodeProcess.stdin.end();
opencodeProcess.stdout.on('data', (data) => {
stdoutLineBuffer += data.toString();
const completeLines = stdoutLineBuffer.split(/\r?\n/);
stdoutLineBuffer = completeLines.pop() || '';
completeLines.forEach((line) => {
processOpenCodeOutputLine(line.trim());
});
});
opencodeProcess.stderr.on('data', (data) => {
const stderrText = data.toString();
if (!stderrText.trim()) {
return;
}
ws.send(createNormalizedMessage({
kind: 'error',
content: stderrText,
sessionId: capturedSessionId || sessionId || null,
provider: 'opencode',
}));
});
opencodeProcess.on('close', async (code) => {
const finalSessionId = capturedSessionId || sessionId || processKey;
activeOpenCodeProcesses.delete(finalSessionId);
activeOpenCodeProcesses.delete(processKey);
if (stdoutLineBuffer.trim()) {
processOpenCodeOutputLine(stdoutLineBuffer.trim());
stdoutLineBuffer = '';
}
ws.send(createNormalizedMessage({
kind: 'complete',
exitCode: code,
isNewSession: !sessionId && !!command,
sessionId: finalSessionId,
provider: 'opencode',
}));
if (code === 0) {
notifyTerminalState({ code });
resolve();
return;
}
if (code === 127 || code === null) {
const installed = await providerAuthService.isProviderInstalled('opencode');
if (!installed) {
ws.send(createNormalizedMessage({
kind: 'error',
content: 'OpenCode CLI is not installed. Install it from https://opencode.ai/docs/',
sessionId: finalSessionId,
provider: 'opencode',
}));
}
}
notifyTerminalState({ code });
reject(new Error(code === null ? 'OpenCode CLI process was terminated' : `OpenCode CLI exited with code ${code}`));
});
opencodeProcess.on('error', async (error) => {
const finalSessionId = capturedSessionId || sessionId || processKey;
activeOpenCodeProcesses.delete(finalSessionId);
activeOpenCodeProcesses.delete(processKey);
const installed = await providerAuthService.isProviderInstalled('opencode');
const errorContent = !installed
? 'OpenCode CLI is not installed. Install it from https://opencode.ai/docs/'
: error.message;
ws.send(createNormalizedMessage({
kind: 'error',
content: errorContent,
sessionId: finalSessionId,
provider: 'opencode',
}));
notifyTerminalState({ error });
reject(error);
});
}).catch(reject);
});
}
function abortOpenCodeSession(sessionId) {
const process = activeOpenCodeProcesses.get(sessionId);
if (!process) {
return false;
}
process.kill('SIGTERM');
activeOpenCodeProcesses.delete(sessionId);
return true;
}
function isOpenCodeSessionActive(sessionId) {
return activeOpenCodeProcesses.has(sessionId);
}
function getActiveOpenCodeSessions() {
return Array.from(activeOpenCodeProcesses.keys());
}
export {
spawnOpenCode,
abortOpenCodeSession,
isOpenCodeSessionActive,
getActiveOpenCodeSessions,
};