Files
openchamber/packages/web/server/index.js
T

3598 lines
108 KiB
JavaScript
Raw Normal View History

2025-12-07 19:32:53 +02:00
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
import path from 'path';
import { spawn, spawnSync } from 'child_process';
import fs from 'fs';
import http from 'http';
import { fileURLToPath } from 'url';
import os from 'os';
import { createUiAuth } from './lib/ui-auth.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DEFAULT_PORT = 3000;
const DEFAULT_OPENCODE_PORT = 0;
const HEALTH_CHECK_INTERVAL = 30000;
const SHUTDOWN_TIMEOUT = 10000;
const MODELS_DEV_API_URL = 'https://models.dev/api.json';
const MODELS_METADATA_CACHE_TTL = 5 * 60 * 1000;
const CLIENT_RELOAD_DELAY_MS = 800;
const OPEN_CODE_READY_GRACE_MS = 12000;
const LONG_REQUEST_TIMEOUT_MS = 4 * 60 * 1000;
const fsPromises = fs.promises;
const DEFAULT_FILE_SEARCH_LIMIT = 60;
const MAX_FILE_SEARCH_LIMIT = 400;
const FILE_SEARCH_MAX_CONCURRENCY = 5;
const FILE_SEARCH_EXCLUDED_DIRS = new Set([
'node_modules',
'.git',
'dist',
'build',
'.next',
'.turbo',
'.cache',
'coverage',
'tmp',
'logs'
]);
const normalizeDirectoryPath = (value) => {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
if (!trimmed) {
return trimmed;
}
if (trimmed === '~') {
return os.homedir();
}
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
return path.join(os.homedir(), trimmed.slice(2));
}
return trimmed;
};
2025-12-07 19:32:53 +02:00
const normalizeRelativeSearchPath = (rootPath, targetPath) => {
const relative = path.relative(rootPath, targetPath) || path.basename(targetPath);
return relative.split(path.sep).join('/') || targetPath;
};
const shouldSkipSearchDirectory = (name) => {
if (!name) {
return false;
}
if (name.startsWith('.')) {
return true;
}
return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase());
};
const listDirectoryEntries = async (dirPath) => {
try {
return await fsPromises.readdir(dirPath, { withFileTypes: true });
} catch {
return [];
}
};
2025-12-26 01:18:29 +02:00
/**
* Fuzzy match scoring function.
* Returns a score > 0 if the query fuzzy-matches the candidate, null otherwise.
* Higher scores indicate better matches.
*/
const fuzzyMatchScore = (query, candidate) => {
if (!query) return 0;
const q = query.toLowerCase();
const c = candidate.toLowerCase();
// Fast path: exact substring match gets high score
if (c.includes(q)) {
const idx = c.indexOf(q);
// Bonus for match at start or after word boundary
let bonus = 0;
if (idx === 0) {
bonus = 20;
} else {
const prev = c[idx - 1];
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
bonus = 15;
}
}
return 100 + bonus - Math.min(idx, 20) - Math.floor(c.length / 5);
}
// Fuzzy match: all query chars must appear in order
let score = 0;
let lastIndex = -1;
let consecutive = 0;
for (let i = 0; i < q.length; i++) {
const ch = q[i];
if (!ch || ch === ' ') continue;
const idx = c.indexOf(ch, lastIndex + 1);
if (idx === -1) {
return null; // No match
}
const gap = idx - lastIndex - 1;
if (gap === 0) {
consecutive++;
} else {
consecutive = 0;
}
score += 10;
score += Math.max(0, 18 - idx); // Prefer matches near start
score -= Math.min(gap, 10); // Penalize gaps
// Bonus for word boundary matches
if (idx === 0) {
score += 12;
} else {
const prev = c[idx - 1];
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
score += 10;
}
}
score += consecutive > 0 ? 12 : 0; // Bonus for consecutive matches
lastIndex = idx;
}
// Prefer shorter paths
score += Math.max(0, 24 - Math.floor(c.length / 3));
return score;
};
2025-12-07 19:32:53 +02:00
const searchFilesystemFiles = async (rootPath, options) => {
const { limit, query } = options;
const normalizedQuery = query.trim().toLowerCase();
const matchAll = normalizedQuery.length === 0;
const queue = [rootPath];
const visited = new Set([rootPath]);
2025-12-26 01:18:29 +02:00
// Collect more candidates for fuzzy matching, then sort and trim
const collectLimit = matchAll ? limit : Math.max(limit * 3, 200);
const candidates = [];
2025-12-07 19:32:53 +02:00
2025-12-26 01:18:29 +02:00
while (queue.length > 0 && candidates.length < collectLimit) {
2025-12-07 19:32:53 +02:00
const batch = queue.splice(0, FILE_SEARCH_MAX_CONCURRENCY);
const dirLists = await Promise.all(batch.map((dir) => listDirectoryEntries(dir)));
for (let index = 0; index < batch.length; index += 1) {
const currentDir = batch[index];
const dirents = dirLists[index];
for (const dirent of dirents) {
const entryName = dirent.name;
if (!entryName || entryName.startsWith('.')) {
continue;
}
const entryPath = path.join(currentDir, entryName);
if (dirent.isDirectory()) {
if (shouldSkipSearchDirectory(entryName)) {
continue;
}
if (!visited.has(entryPath)) {
visited.add(entryPath);
queue.push(entryPath);
}
continue;
}
if (!dirent.isFile()) {
continue;
}
const relativePath = normalizeRelativeSearchPath(rootPath, entryPath);
2025-12-26 01:18:29 +02:00
const extension = entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined;
if (matchAll) {
candidates.push({
name: entryName,
path: entryPath,
relativePath,
extension,
score: 0
});
} else {
// Try fuzzy match against relative path (includes filename)
const score = fuzzyMatchScore(normalizedQuery, relativePath);
if (score !== null) {
candidates.push({
name: entryName,
path: entryPath,
relativePath,
extension,
score
});
2025-12-07 19:32:53 +02:00
}
}
2025-12-26 01:18:29 +02:00
if (candidates.length >= collectLimit) {
2025-12-07 19:32:53 +02:00
queue.length = 0;
break;
}
}
2025-12-26 01:18:29 +02:00
if (candidates.length >= collectLimit) {
2025-12-07 19:32:53 +02:00
break;
}
}
}
2025-12-26 01:18:29 +02:00
// Sort by score descending, then by path length, then alphabetically
if (!matchAll) {
candidates.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (a.relativePath.length !== b.relativePath.length) {
return a.relativePath.length - b.relativePath.length;
}
return a.relativePath.localeCompare(b.relativePath);
});
}
// Return top results without the score field
return candidates.slice(0, limit).map(({ name, path: filePath, relativePath, extension }) => ({
name,
path: filePath,
relativePath,
extension
}));
2025-12-07 19:32:53 +02:00
};
const createTimeoutSignal = (timeoutMs) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
return {
signal: controller.signal,
cleanup: () => clearTimeout(timer),
};
};
const stripJsonMarkdownWrapper = (value) => {
if (typeof value !== 'string') {
return '';
}
let trimmed = value.trim();
if (!trimmed) {
return '';
}
if (trimmed.startsWith('```')) {
trimmed = trimmed.replace(/^```(?:json)?\s*/i, '');
const closingFenceIndex = trimmed.lastIndexOf('```');
if (closingFenceIndex !== -1) {
trimmed = trimmed.slice(0, closingFenceIndex);
}
trimmed = trimmed.trim();
}
if (trimmed.endsWith('```')) {
trimmed = trimmed.slice(0, -3).trim();
}
return trimmed;
};
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
const SETTINGS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
const readSettingsFromDisk = async () => {
try {
const raw = await fsPromises.readFile(SETTINGS_FILE_PATH, 'utf8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return parsed;
}
return {};
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return {};
}
console.warn('Failed to read settings file:', error);
return {};
}
};
const writeSettingsToDisk = async (settings) => {
try {
await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true });
await fsPromises.writeFile(SETTINGS_FILE_PATH, JSON.stringify(settings, null, 2), 'utf8');
} catch (error) {
console.warn('Failed to write settings file:', error);
throw error;
}
};
const sanitizeTypographySizesPartial = (input) => {
if (!input || typeof input !== 'object') {
return undefined;
}
const candidate = input;
const result = {};
let populated = false;
const assign = (key) => {
if (typeof candidate[key] === 'string' && candidate[key].length > 0) {
result[key] = candidate[key];
populated = true;
}
};
assign('markdown');
assign('code');
assign('uiHeader');
assign('uiLabel');
assign('meta');
assign('micro');
return populated ? result : undefined;
};
const normalizeStringArray = (input) => {
if (!Array.isArray(input)) {
return [];
}
return Array.from(
new Set(
input.filter((entry) => typeof entry === 'string' && entry.length > 0)
)
);
};
const sanitizeSettingsUpdate = (payload) => {
if (!payload || typeof payload !== 'object') {
return {};
}
const candidate = payload;
const result = {};
if (typeof candidate.themeId === 'string' && candidate.themeId.length > 0) {
result.themeId = candidate.themeId;
}
if (typeof candidate.themeVariant === 'string' && (candidate.themeVariant === 'light' || candidate.themeVariant === 'dark')) {
result.themeVariant = candidate.themeVariant;
}
if (typeof candidate.useSystemTheme === 'boolean') {
result.useSystemTheme = candidate.useSystemTheme;
}
if (typeof candidate.lightThemeId === 'string' && candidate.lightThemeId.length > 0) {
result.lightThemeId = candidate.lightThemeId;
}
if (typeof candidate.darkThemeId === 'string' && candidate.darkThemeId.length > 0) {
result.darkThemeId = candidate.darkThemeId;
}
if (typeof candidate.lastDirectory === 'string' && candidate.lastDirectory.length > 0) {
result.lastDirectory = candidate.lastDirectory;
}
if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) {
result.homeDirectory = candidate.homeDirectory;
}
if (Array.isArray(candidate.approvedDirectories)) {
result.approvedDirectories = normalizeStringArray(candidate.approvedDirectories);
}
if (Array.isArray(candidate.securityScopedBookmarks)) {
result.securityScopedBookmarks = normalizeStringArray(candidate.securityScopedBookmarks);
}
if (Array.isArray(candidate.pinnedDirectories)) {
result.pinnedDirectories = normalizeStringArray(candidate.pinnedDirectories);
}
if (typeof candidate.uiFont === 'string' && candidate.uiFont.length > 0) {
result.uiFont = candidate.uiFont;
}
if (typeof candidate.monoFont === 'string' && candidate.monoFont.length > 0) {
result.monoFont = candidate.monoFont;
}
if (typeof candidate.markdownDisplayMode === 'string' && candidate.markdownDisplayMode.length > 0) {
result.markdownDisplayMode = candidate.markdownDisplayMode;
}
if (typeof candidate.showReasoningTraces === 'boolean') {
result.showReasoningTraces = candidate.showReasoningTraces;
}
if (typeof candidate.autoDeleteEnabled === 'boolean') {
result.autoDeleteEnabled = candidate.autoDeleteEnabled;
}
if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) {
const normalizedDays = Math.max(1, Math.min(365, Math.round(candidate.autoDeleteAfterDays)));
result.autoDeleteAfterDays = normalizedDays;
}
2025-12-07 19:32:53 +02:00
const typography = sanitizeTypographySizesPartial(candidate.typographySizes);
if (typography) {
result.typographySizes = typography;
}
if (typeof candidate.defaultModel === 'string' && candidate.defaultModel.length > 0) {
result.defaultModel = candidate.defaultModel;
}
if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) {
result.defaultAgent = candidate.defaultAgent;
}
2025-12-07 19:32:53 +02:00
return result;
};
const mergePersistedSettings = (current, changes) => {
const baseApproved = Array.isArray(changes.approvedDirectories)
? changes.approvedDirectories
: Array.isArray(current.approvedDirectories)
? current.approvedDirectories
: [];
const additionalApproved = [];
if (typeof changes.lastDirectory === 'string' && changes.lastDirectory.length > 0) {
additionalApproved.push(changes.lastDirectory);
}
if (typeof changes.homeDirectory === 'string' && changes.homeDirectory.length > 0) {
additionalApproved.push(changes.homeDirectory);
}
const approvedSource = [...baseApproved, ...additionalApproved];
const baseBookmarks = Array.isArray(changes.securityScopedBookmarks)
? changes.securityScopedBookmarks
: Array.isArray(current.securityScopedBookmarks)
? current.securityScopedBookmarks
: [];
const nextTypographySizes = changes.typographySizes
? {
...(current.typographySizes || {}),
...changes.typographySizes
}
: current.typographySizes;
const next = {
...current,
...changes,
approvedDirectories: Array.from(
new Set(
approvedSource.filter((entry) => typeof entry === 'string' && entry.length > 0)
)
),
securityScopedBookmarks: Array.from(
new Set(
baseBookmarks.filter((entry) => typeof entry === 'string' && entry.length > 0)
)
),
typographySizes: nextTypographySizes
};
return next;
};
const formatSettingsResponse = (settings) => {
const sanitized = sanitizeSettingsUpdate(settings);
const approved = normalizeStringArray(settings.approvedDirectories);
const bookmarks = normalizeStringArray(settings.securityScopedBookmarks);
return {
...sanitized,
approvedDirectories: approved,
securityScopedBookmarks: bookmarks,
pinnedDirectories: normalizeStringArray(settings.pinnedDirectories),
typographySizes: sanitizeTypographySizesPartial(settings.typographySizes),
showReasoningTraces:
typeof settings.showReasoningTraces === 'boolean'
? settings.showReasoningTraces
: typeof sanitized.showReasoningTraces === 'boolean'
? sanitized.showReasoningTraces
: false
};
};
const persistSettings = async (changes) => {
const current = await readSettingsFromDisk();
const sanitized = sanitizeSettingsUpdate(changes);
const next = mergePersistedSettings(current, sanitized);
await writeSettingsToDisk(next);
return formatSettingsResponse(next);
};
// Global state
let openCodeProcess = null;
let openCodePort = null;
let healthCheckInterval = null;
let server = null;
let isShuttingDown = false;
let cachedModelsMetadata = null;
let cachedModelsMetadataTimestamp = 0;
let expressApp = null;
let currentRestartPromise = null;
let isRestartingOpenCode = false;
let openCodeApiPrefix = '';
let openCodeApiPrefixDetected = false;
let openCodeApiDetectionTimer = null;
let isDetectingApiPrefix = false;
let openCodeApiDetectionPromise = null;
let lastOpenCodeError = null;
let openCodePortWaiters = [];
let isOpenCodeReady = false;
let openCodeNotReadySince = 0;
let exitOnShutdown = true;
let signalsAttached = false;
let openCodeWorkingDirectory = process.cwd();
let uiAuthController = null;
const OPENCODE_BINARY_ENV =
process.env.OPENCODE_BINARY ||
process.env.OPENCHAMBER_BINARY ||
process.env.OPENCODE_PATH ||
process.env.OPENCHAMBER_OPENCODE_PATH ||
null;
function buildAugmentedPath() {
const augmented = new Set();
const loginShellPath = getLoginShellPath();
if (loginShellPath) {
for (const segment of loginShellPath.split(path.delimiter)) {
if (segment) {
augmented.add(segment);
}
}
}
const current = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
for (const segment of current) {
augmented.add(segment);
}
return Array.from(augmented).join(path.delimiter);
}
function getLoginShellPath() {
if (process.platform === 'win32') {
return null;
}
const shell = process.env.SHELL || '/bin/zsh';
2025-12-26 01:44:33 +02:00
const shellName = path.basename(shell);
// Nushell requires different flag syntax and PATH access
const isNushell = shellName === 'nu' || shellName === 'nushell';
const args = isNushell
? ['-l', '-i', '-c', '$env.PATH | str join (char esep)']
: ['-lic', 'echo -n "$PATH"'];
2025-12-07 19:32:53 +02:00
try {
2025-12-26 01:44:33 +02:00
const result = spawnSync(shell, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
2025-12-07 19:32:53 +02:00
if (result.status === 0 && typeof result.stdout === 'string') {
const value = result.stdout.trim();
if (value) {
return value;
}
} else if (result.stderr) {
console.warn(`Failed to read PATH from login shell (${shell}): ${result.stderr}`);
}
} catch (error) {
console.warn(`Error executing login shell (${shell}) for PATH detection: ${error.message}`);
}
return null;
}
function resolveBinaryFromPath(binaryName, searchPath) {
if (!binaryName) {
return null;
}
if (path.isAbsolute(binaryName)) {
return fs.existsSync(binaryName) ? binaryName : null;
}
const directories = searchPath.split(path.delimiter).filter(Boolean);
for (const directory of directories) {
try {
const candidate = path.join(directory, binaryName);
if (fs.existsSync(candidate)) {
const stats = fs.statSync(candidate);
if (stats.isFile()) {
return candidate;
}
}
} catch {
// Ignore resolution errors, continue searching
}
}
return null;
}
function getOpencodeSpawnConfig() {
const envPath = buildAugmentedPath();
const resolvedEnv = { ...process.env, PATH: envPath };
if (OPENCODE_BINARY_ENV) {
const explicit = resolveBinaryFromPath(OPENCODE_BINARY_ENV, envPath);
if (explicit) {
console.log(`Using OpenCode binary from OPENCODE_BINARY: ${explicit}`);
return { command: explicit, env: resolvedEnv };
}
console.warn(
`OPENCODE_BINARY path "${OPENCODE_BINARY_ENV}" not found. Falling back to search.`
);
}
return { command: 'opencode', env: resolvedEnv };
}
const ENV_CONFIGURED_OPENCODE_PORT = (() => {
const raw =
process.env.OPENCODE_PORT ||
process.env.OPENCHAMBER_OPENCODE_PORT ||
process.env.OPENCHAMBER_INTERNAL_PORT;
if (!raw) {
return null;
}
const parsed = parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
})();
const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix(
process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || ''
);
if (ENV_CONFIGURED_API_PREFIX) {
openCodeApiPrefix = ENV_CONFIGURED_API_PREFIX;
openCodeApiPrefixDetected = true;
console.log(`Using OpenCode API prefix from environment: ${openCodeApiPrefix}`);
}
function setOpenCodePort(port) {
if (!Number.isFinite(port) || port <= 0) {
return;
}
const numericPort = Math.trunc(port);
const portChanged = openCodePort !== numericPort;
if (portChanged || openCodePort === null) {
openCodePort = numericPort;
console.log(`Detected OpenCode port: ${openCodePort}`);
if (portChanged) {
isOpenCodeReady = false;
}
openCodeNotReadySince = Date.now();
}
lastOpenCodeError = null;
if (openCodePortWaiters.length > 0) {
const waiters = openCodePortWaiters;
openCodePortWaiters = [];
for (const notify of waiters) {
try {
notify(numericPort);
} catch (error) {
console.warn('Failed to notify OpenCode port waiter:', error);
}
}
}
}
async function waitForOpenCodePort(timeoutMs = 15000) {
if (openCodePort !== null) {
return openCodePort;
}
return new Promise((resolve, reject) => {
const onPortDetected = (port) => {
clearTimeout(timeout);
resolve(port);
};
const timeout = setTimeout(() => {
openCodePortWaiters = openCodePortWaiters.filter((cb) => cb !== onPortDetected);
reject(new Error('Timed out waiting for OpenCode port'));
}, timeoutMs);
openCodePortWaiters.push(onPortDetected);
});
}
const API_PREFIX_CANDIDATES = ['', '/api']; // Simplified - only check root and /api
function normalizeApiPrefix(prefix) {
if (!prefix) {
return '';
}
if (prefix.includes('://')) {
try {
const parsed = new URL(prefix);
return normalizeApiPrefix(parsed.pathname);
} catch (error) {
return '';
}
}
const trimmed = prefix.trim();
if (!trimmed || trimmed === '/') {
return '';
}
const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
return withLeading.endsWith('/') ? withLeading.slice(0, -1) : withLeading;
}
function setDetectedOpenCodeApiPrefix(prefix) {
const normalized = normalizeApiPrefix(prefix);
if (!openCodeApiPrefixDetected || openCodeApiPrefix !== normalized) {
openCodeApiPrefix = normalized;
openCodeApiPrefixDetected = true;
if (openCodeApiDetectionTimer) {
clearTimeout(openCodeApiDetectionTimer);
openCodeApiDetectionTimer = null;
}
console.log(`Detected OpenCode API prefix: ${normalized || '(root)'}`);
}
}
function detectPortFromLogMessage(message) {
if (openCodePort && ENV_CONFIGURED_OPENCODE_PORT) {
return;
}
const regex = /https?:\/\/[^:\s]+:(\d+)/gi;
let match;
while ((match = regex.exec(message)) !== null) {
const port = parseInt(match[1], 10);
if (Number.isFinite(port) && port > 0) {
setOpenCodePort(port);
return;
}
}
const fallbackMatch = /(?:^|\s)(?:127\.0\.0\.1|localhost):(\d+)/i.exec(message);
if (fallbackMatch) {
const port = parseInt(fallbackMatch[1], 10);
if (Number.isFinite(port) && port > 0) {
setOpenCodePort(port);
}
}
}
function detectPrefixFromLogMessage(message) {
if (!openCodePort) {
return;
}
const urlRegex = /https?:\/\/[^:\s]+:(\d+)(\/[^\s"']*)?/gi;
let match;
while ((match = urlRegex.exec(message)) !== null) {
const portMatch = parseInt(match[1], 10);
if (portMatch !== openCodePort) {
continue;
}
const path = match[2] || '';
const normalized = normalizeApiPrefix(path);
setDetectedOpenCodeApiPrefix(normalized);
return;
}
}
function getCandidateApiPrefixes() {
if (openCodeApiPrefixDetected) {
return [openCodeApiPrefix];
}
const candidates = [];
if (openCodeApiPrefix && !candidates.includes(openCodeApiPrefix)) {
candidates.push(openCodeApiPrefix);
}
for (const candidate of API_PREFIX_CANDIDATES) {
if (!candidates.includes(candidate)) {
candidates.push(candidate);
}
}
return candidates;
}
function buildOpenCodeUrl(path, prefixOverride) {
if (!openCodePort) {
throw new Error('OpenCode port is not available');
}
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const prefix = normalizeApiPrefix(
prefixOverride !== undefined ? prefixOverride : openCodeApiPrefixDetected ? openCodeApiPrefix : ''
);
const fullPath = `${prefix}${normalizedPath}`;
return `http://localhost:${openCodePort}${fullPath}`;
}
function parseSseDataPayload(block) {
if (!block || typeof block !== 'string') {
return null;
}
const dataLines = block
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).replace(/^\s/, ''));
if (dataLines.length === 0) {
return null;
}
const payloadText = dataLines.join('\n').trim();
if (!payloadText) {
return null;
}
try {
return JSON.parse(payloadText);
} catch {
return null;
}
}
function deriveSessionActivity(payload) {
if (!payload || typeof payload !== 'object') {
return null;
}
if (payload.type === 'session.status') {
const status = payload.properties?.status;
const sessionId = payload.properties?.sessionID;
const statusType = status?.type;
if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') {
const phase = statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle';
return { sessionId, phase };
}
}
if (payload.type === 'message.updated') {
const info = payload.properties?.info;
const sessionId = info?.sessionID;
const role = info?.role;
const finish = info?.finish;
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
return { sessionId, phase: 'cooldown' };
}
}
if (payload.type === 'session.idle') {
const sessionId = payload.properties?.sessionID;
if (typeof sessionId === 'string' && sessionId.length > 0) {
return { sessionId, phase: 'idle' };
}
}
return null;
}
function writeSseEvent(res, payload) {
res.write(`data: ${JSON.stringify(payload)}\n\n`);
}
2025-12-07 19:32:53 +02:00
function extractApiPrefixFromUrl(urlString, expectedSuffix) {
if (!urlString) {
return null;
}
try {
const parsed = new URL(urlString);
const pathname = parsed.pathname || '';
if (expectedSuffix && pathname.endsWith(expectedSuffix)) {
const prefix = pathname.slice(0, pathname.length - expectedSuffix.length);
return normalizeApiPrefix(prefix);
}
} catch (error) {
console.warn(`Failed to parse OpenCode URL "${urlString}": ${error.message}`);
}
return null;
}
async function tryDetectOpenCodeApiPrefix() {
if (!openCodePort) {
return false;
}
const docPrefix = await detectPrefixFromDocumentation();
if (docPrefix !== null) {
setDetectedOpenCodeApiPrefix(docPrefix);
return true;
}
const candidates = getCandidateApiPrefixes();
for (const candidate of candidates) {
try {
const response = await fetch(buildOpenCodeUrl('/config', candidate), {
method: 'GET',
headers: { Accept: 'application/json' }
});
if (response.ok) {
await response.json().catch(() => null);
setDetectedOpenCodeApiPrefix(candidate);
return true;
}
} catch (error) {
}
}
return false;
}
async function detectOpenCodeApiPrefix() {
if (openCodeApiPrefixDetected) {
return true;
}
if (!openCodePort) {
return false;
}
if (isDetectingApiPrefix) {
try {
await openCodeApiDetectionPromise;
} catch (error) {
}
return openCodeApiPrefixDetected;
}
isDetectingApiPrefix = true;
openCodeApiDetectionPromise = (async () => {
const success = await tryDetectOpenCodeApiPrefix();
if (!success) {
console.warn('Failed to detect OpenCode API prefix via documentation or known candidates');
}
return success;
})();
try {
const result = await openCodeApiDetectionPromise;
return result;
} finally {
isDetectingApiPrefix = false;
openCodeApiDetectionPromise = null;
}
}
async function ensureOpenCodeApiPrefix() {
if (openCodeApiPrefixDetected) {
return true;
}
const result = await detectOpenCodeApiPrefix();
if (!result) {
scheduleOpenCodeApiDetection();
}
return result;
}
function scheduleOpenCodeApiDetection(delayMs = 500) {
if (openCodeApiPrefixDetected) {
return;
}
if (openCodeApiDetectionTimer) {
clearTimeout(openCodeApiDetectionTimer);
}
openCodeApiDetectionTimer = setTimeout(async () => {
openCodeApiDetectionTimer = null;
const success = await detectOpenCodeApiPrefix();
if (!success) {
const nextDelay = Math.min(delayMs * 2, 8000);
scheduleOpenCodeApiDetection(nextDelay);
}
}, delayMs);
}
const OPENAPI_DOC_PATHS = ['/doc'];
function extractPrefixFromOpenApiDocument(content) {
const globalMatch = content.match(/__OPENCODE_API_BASE__\s*=\s*['"]([^'"]+)['"]/);
if (globalMatch && globalMatch[1]) {
return normalizeApiPrefix(globalMatch[1]);
}
return null;
}
async function detectPrefixFromDocumentation() {
if (!openCodePort) {
return null;
}
const prefixesToTry = [...new Set(['', ...API_PREFIX_CANDIDATES])];
for (const prefix of prefixesToTry) {
for (const docPath of OPENAPI_DOC_PATHS) {
try {
const response = await fetch(buildOpenCodeUrl(docPath, prefix), {
method: 'GET',
headers: { Accept: '*/*' }
});
if (!response.ok) {
continue;
}
const text = await response.text();
const extracted = extractPrefixFromOpenApiDocument(text);
if (extracted !== null) {
return extracted;
}
} catch (error) {
}
}
}
return null;
}
function parseArgs(argv = process.argv.slice(2)) {
const args = Array.isArray(argv) ? [...argv] : [];
const envPassword =
process.env.OPENCHAMBER_UI_PASSWORD ||
process.env.OPENCODE_UI_PASSWORD ||
null;
const options = { port: DEFAULT_PORT, uiPassword: envPassword };
const consumeValue = (currentIndex, inlineValue) => {
if (typeof inlineValue === 'string') {
return { value: inlineValue, nextIndex: currentIndex };
}
const nextArg = args[currentIndex + 1];
if (typeof nextArg === 'string' && !nextArg.startsWith('--')) {
return { value: nextArg, nextIndex: currentIndex + 1 };
}
return { value: undefined, nextIndex: currentIndex };
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (!arg.startsWith('--')) {
continue;
}
const eqIndex = arg.indexOf('=');
const optionName = eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2);
const inlineValue = eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined;
if (optionName === 'port' || optionName === 'p') {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
const parsedPort = parseInt(value ?? '', 10);
options.port = Number.isFinite(parsedPort) ? parsedPort : DEFAULT_PORT;
continue;
}
if (optionName === 'ui-password') {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.uiPassword = typeof value === 'string' ? value : '';
continue;
}
}
return options;
}
async function startOpenCode() {
const desiredPort = ENV_CONFIGURED_OPENCODE_PORT ?? DEFAULT_OPENCODE_PORT;
console.log(
desiredPort
? `Starting OpenCode on requested port ${desiredPort}...`
: 'Starting OpenCode with dynamic port assignment...'
);
console.log(`Starting OpenCode in working directory: ${openCodeWorkingDirectory}`);
const { command, env } = getOpencodeSpawnConfig();
const args = ['serve', '--port', desiredPort.toString()];
console.log(`Launching OpenCode via "${command}" with args ${args.join(' ')}`);
const child = spawn(command, args, {
stdio: 'pipe',
env,
cwd: openCodeWorkingDirectory
});
isOpenCodeReady = false;
openCodeNotReadySince = Date.now();
let firstSignalResolver;
const firstSignalPromise = new Promise((resolve) => {
firstSignalResolver = resolve;
});
let firstSignalSettled = false;
const settleFirstSignal = () => {
if (firstSignalSettled) {
return;
}
firstSignalSettled = true;
clearTimeout(firstSignalTimer);
child.stdout.off('data', settleFirstSignal);
child.stderr.off('data', settleFirstSignal);
child.off('exit', settleFirstSignal);
if (firstSignalResolver) {
firstSignalResolver();
}
};
const firstSignalTimer = setTimeout(settleFirstSignal, 750);
child.stdout.once('data', settleFirstSignal);
child.stderr.once('data', settleFirstSignal);
child.once('exit', settleFirstSignal);
child.stdout.on('data', (data) => {
const text = data.toString();
console.log(`OpenCode: ${text.trim()}`);
detectPortFromLogMessage(text);
detectPrefixFromLogMessage(text);
settleFirstSignal();
});
child.stderr.on('data', (data) => {
const text = data.toString();
lastOpenCodeError = text.trim();
console.error(`OpenCode Error: ${lastOpenCodeError}`);
detectPortFromLogMessage(text);
detectPrefixFromLogMessage(text);
settleFirstSignal();
});
let startupError = await new Promise((resolve, reject) => {
const onSpawn = () => {
setOpenCodePort(desiredPort);
child.off('error', onError);
resolve(null);
};
const onError = (error) => {
child.off('spawn', onSpawn);
reject(error);
};
child.once('spawn', onSpawn);
child.once('error', onError);
}).catch((error) => {
lastOpenCodeError = error.message;
openCodePort = null;
settleFirstSignal();
return error;
});
if (startupError) {
if (startupError.code === 'ENOENT') {
const enhanced = new Error(
`Failed to start OpenCode executable "${command}" not found. ` +
'Set OPENCODE_BINARY to the full path of the opencode CLI or ensure it is on PATH.'
);
enhanced.code = startupError.code;
startupError = enhanced;
}
throw startupError;
}
child.on('exit', (code, signal) => {
lastOpenCodeError = `OpenCode exited with code ${code}, signal ${signal ?? 'null'}`;
isOpenCodeReady = false;
openCodeNotReadySince = Date.now();
if (!isShuttingDown && !isRestartingOpenCode) {
console.log(`OpenCode process exited with code ${code}, signal ${signal}`);
setTimeout(() => {
restartOpenCode().catch((err) => {
console.error('Failed to restart OpenCode after exit:', err);
});
}, 5000);
} else if (isRestartingOpenCode) {
console.log('OpenCode exit during controlled restart, not triggering auto-restart');
}
});
child.on('error', (error) => {
lastOpenCodeError = error.message;
isOpenCodeReady = false;
openCodeNotReadySince = Date.now();
console.error(`OpenCode process error: ${error.message}`);
if (!isShuttingDown) {
setTimeout(() => {
restartOpenCode().catch((err) => {
console.error('Failed to restart OpenCode after error:', err);
});
}, 5000);
}
});
await firstSignalPromise;
return child;
}
async function restartOpenCode() {
if (isShuttingDown) return;
if (currentRestartPromise) {
await currentRestartPromise;
return;
}
currentRestartPromise = (async () => {
isRestartingOpenCode = true;
isOpenCodeReady = false;
openCodeNotReadySince = Date.now();
console.log('Restarting OpenCode process...');
if (openCodeProcess) {
console.log('Waiting for OpenCode process to terminate...');
const processToTerminate = openCodeProcess;
let forcedTermination = false;
if (processToTerminate.exitCode === null && processToTerminate.signalCode === null) {
processToTerminate.kill('SIGTERM');
await new Promise((resolve) => {
let resolved = false;
const cleanup = () => {
processToTerminate.off('exit', onExit);
clearTimeout(forceKillTimer);
clearTimeout(hardStopTimer);
if (!resolved) {
resolved = true;
resolve();
}
};
const onExit = () => {
cleanup();
};
const forceKillTimer = setTimeout(() => {
if (resolved) {
return;
}
forcedTermination = true;
console.warn('OpenCode process did not exit after SIGTERM, sending SIGKILL');
processToTerminate.kill('SIGKILL');
}, 3000);
const hardStopTimer = setTimeout(() => {
if (resolved) {
return;
}
console.warn('OpenCode process unresponsive after SIGKILL, continuing restart');
cleanup();
}, 5000);
processToTerminate.once('exit', onExit);
});
if (forcedTermination) {
console.log('OpenCode process terminated forcefully during restart');
}
} else {
console.log('OpenCode process already stopped before restart command');
}
openCodeProcess = null;
await new Promise((resolve) => setTimeout(resolve, 250));
}
if (ENV_CONFIGURED_OPENCODE_PORT) {
console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`);
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT);
} else {
openCodePort = null;
}
openCodeApiPrefixDetected = false;
if (openCodeApiDetectionTimer) {
clearTimeout(openCodeApiDetectionTimer);
openCodeApiDetectionTimer = null;
}
openCodeApiDetectionPromise = null;
lastOpenCodeError = null;
openCodeProcess = await startOpenCode();
if (!ENV_CONFIGURED_OPENCODE_PORT) {
await waitForOpenCodePort();
}
if (expressApp) {
setupProxy(expressApp);
scheduleOpenCodeApiDetection();
}
})();
try {
await currentRestartPromise;
} catch (error) {
console.error(`Failed to restart OpenCode: ${error.message}`);
lastOpenCodeError = error.message;
if (!ENV_CONFIGURED_OPENCODE_PORT) {
openCodePort = null;
}
openCodeApiPrefixDetected = false;
throw error;
} finally {
currentRestartPromise = null;
isRestartingOpenCode = false;
}
}
async function waitForOpenCodeReady(timeoutMs = 20000, intervalMs = 400) {
if (!openCodePort) {
throw new Error('OpenCode port is not available');
}
const deadline = Date.now() + timeoutMs;
let lastError = null;
while (Date.now() < deadline) {
const prefixes = getCandidateApiPrefixes();
for (const prefix of prefixes) {
try {
const normalizedPrefix = normalizeApiPrefix(prefix);
const configPromise = fetch(buildOpenCodeUrl('/config', normalizedPrefix), {
2025-12-07 19:32:53 +02:00
method: 'GET',
headers: { Accept: 'application/json' }
}).catch((error) => error);
const agentPromise = fetch(buildOpenCodeUrl('/agent', normalizedPrefix), {
2025-12-07 19:32:53 +02:00
method: 'GET',
headers: { Accept: 'application/json' }
}).catch((error) => error);
const [configResult, agentResult] = await Promise.all([configPromise, agentPromise]);
2025-12-07 19:32:53 +02:00
if (configResult instanceof Error) {
lastError = configResult;
continue;
}
if (!configResult.ok) {
if (configResult.status === 404 && !openCodeApiPrefixDetected && normalizedPrefix === '') {
lastError = new Error('OpenCode config endpoint returned 404 on root prefix');
} else {
lastError = new Error(`OpenCode config endpoint responded with status ${configResult.status}`);
}
continue;
}
await configResult.json().catch(() => null);
const detectedPrefix = extractApiPrefixFromUrl(configResult.url, '/config');
if (detectedPrefix !== null) {
setDetectedOpenCodeApiPrefix(detectedPrefix);
} else if (normalizedPrefix) {
setDetectedOpenCodeApiPrefix(normalizedPrefix);
}
if (agentResult instanceof Error) {
lastError = agentResult;
2025-12-07 19:32:53 +02:00
continue;
}
if (!agentResult.ok) {
lastError = new Error(`Agent endpoint responded with status ${agentResult.status}`);
2025-12-07 19:32:53 +02:00
continue;
}
await agentResult.json().catch(() => []);
2025-12-07 19:32:53 +02:00
const effectivePrefix = detectedPrefix !== null ? detectedPrefix : normalizedPrefix;
2025-12-07 19:32:53 +02:00
if (detectedPrefix === null) {
const agentPrefix = extractApiPrefixFromUrl(agentResult.url, '/agent');
2025-12-07 19:32:53 +02:00
if (agentPrefix !== null) {
setDetectedOpenCodeApiPrefix(agentPrefix);
} else if (normalizedPrefix) {
setDetectedOpenCodeApiPrefix(normalizedPrefix);
}
}
isOpenCodeReady = true;
lastOpenCodeError = null;
return;
} catch (error) {
lastError = error;
}
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
if (lastError) {
lastOpenCodeError = lastError.message || String(lastError);
throw lastError;
}
const timeoutError = new Error('Timed out waiting for OpenCode to become ready');
lastOpenCodeError = timeoutError.message;
throw timeoutError;
}
async function waitForAgentPresence(agentName, timeoutMs = 15000, intervalMs = 300) {
if (!openCodePort) {
throw new Error('OpenCode port is not available');
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(buildOpenCodeUrl('/agent'), {
method: 'GET',
headers: { Accept: 'application/json' }
});
if (response.ok) {
const agents = await response.json();
if (Array.isArray(agents) && agents.some((agent) => agent?.name === agentName)) {
return;
}
}
} catch (error) {
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`Agent "${agentName}" not available after OpenCode restart`);
}
async function fetchAgentsSnapshot() {
if (!openCodePort) {
throw new Error('OpenCode port is not available');
}
const response = await fetch(buildOpenCodeUrl('/agent'), {
method: 'GET',
headers: { Accept: 'application/json' }
});
if (!response.ok) {
throw new Error(`Failed to fetch agents snapshot (status ${response.status})`);
}
const agents = await response.json().catch(() => null);
if (!Array.isArray(agents)) {
throw new Error('Invalid agents payload from OpenCode');
}
return agents;
}
async function fetchProvidersSnapshot() {
if (!openCodePort) {
throw new Error('OpenCode port is not available');
}
const response = await fetch(buildOpenCodeUrl('/provider'), {
method: 'GET',
headers: { Accept: 'application/json' }
});
if (!response.ok) {
throw new Error(`Failed to fetch providers snapshot (status ${response.status})`);
}
const providers = await response.json().catch(() => null);
if (!Array.isArray(providers)) {
throw new Error('Invalid providers payload from OpenCode');
}
return providers;
}
async function fetchModelsSnapshot() {
if (!openCodePort) {
throw new Error('OpenCode port is not available');
}
const response = await fetch(buildOpenCodeUrl('/model'), {
method: 'GET',
headers: { Accept: 'application/json' }
});
if (!response.ok) {
throw new Error(`Failed to fetch models snapshot (status ${response.status})`);
}
const models = await response.json().catch(() => null);
if (!Array.isArray(models)) {
throw new Error('Invalid models payload from OpenCode');
}
return models;
}
async function refreshOpenCodeAfterConfigChange(reason, options = {}) {
const { agentName } = options;
console.log(`Refreshing OpenCode after ${reason}`);
await restartOpenCode();
try {
await waitForOpenCodeReady();
isOpenCodeReady = true;
openCodeNotReadySince = 0;
if (agentName) {
await waitForAgentPresence(agentName);
}
isOpenCodeReady = true;
openCodeNotReadySince = 0;
} catch (error) {
isOpenCodeReady = false;
openCodeNotReadySince = Date.now();
console.error(`Failed to refresh OpenCode after ${reason}:`, error.message);
throw error;
}
}
function setupProxy(app) {
if (!openCodePort) return;
if (app.get('opencodeProxyConfigured')) {
return;
}
console.log(`Setting up proxy to OpenCode on port ${openCodePort}`);
app.set('opencodeProxyConfigured', true);
app.use('/api', (req, res, next) => {
if (
req.path.startsWith('/themes/custom') ||
req.path.startsWith('/config/agents') ||
req.path.startsWith('/config/settings') ||
req.path === '/health'
) {
return next();
}
const waitElapsed = openCodeNotReadySince === 0 ? 0 : Date.now() - openCodeNotReadySince;
const stillWaiting =
(!isOpenCodeReady && (openCodeNotReadySince === 0 || waitElapsed < OPEN_CODE_READY_GRACE_MS)) ||
isRestartingOpenCode ||
!openCodePort;
if (stillWaiting) {
return res.status(503).json({
error: 'OpenCode is restarting',
restarting: true,
});
}
next();
});
app.use('/api', async (req, res, next) => {
try {
await ensureOpenCodeApiPrefix();
} catch (error) {
console.warn(`OpenCode API prefix detection failed for ${req.method} ${req.path}: ${error.message}`);
}
next();
});
app.use('/api', (req, res, next) => {
if (
req.path.startsWith('/themes/custom') ||
req.path.startsWith('/config/agents') ||
req.path.startsWith('/config/settings') ||
req.path === '/health'
) {
return next();
}
console.log(`API → OpenCode: ${req.method} ${req.path}`);
next();
});
const proxyMiddleware = createProxyMiddleware({
target: openCodePort ? `http://localhost:${openCodePort}` : 'http://127.0.0.1:0',
router: () => {
if (!openCodePort) {
return 'http://127.0.0.1:0';
}
return `http://localhost:${openCodePort}`;
},
changeOrigin: true,
pathRewrite: (path) => {
if (!path.startsWith('/api')) {
return path;
}
const suffix = path.slice(4) || '/';
if (!openCodeApiPrefixDetected || openCodeApiPrefix === '') {
return suffix;
}
return `${openCodeApiPrefix}${suffix}`;
},
ws: true,
onError: (err, req, res) => {
console.error(`Proxy error: ${err.message}`);
if (!res.headersSent) {
res.status(503).json({ error: 'OpenCode service unavailable' });
}
},
onProxyReq: (proxyReq, req, res) => {
console.log(`Proxying ${req.method} ${req.path} to OpenCode`);
if (req.headers.accept && req.headers.accept.includes('text/event-stream')) {
console.log(`[SSE] Setting up SSE proxy for ${req.method} ${req.path}`);
proxyReq.setHeader('Accept', 'text/event-stream');
proxyReq.setHeader('Cache-Control', 'no-cache');
proxyReq.setHeader('Connection', 'keep-alive');
}
},
onProxyRes: (proxyRes, req, res) => {
if (req.url?.includes('/event')) {
console.log(`[SSE] Proxy response for ${req.method} ${req.url} - Status: ${proxyRes.statusCode}`);
proxyRes.headers['Access-Control-Allow-Origin'] = '*';
proxyRes.headers['Access-Control-Allow-Headers'] = 'Cache-Control, Accept';
proxyRes.headers['Content-Type'] = 'text/event-stream';
proxyRes.headers['Cache-Control'] = 'no-cache';
proxyRes.headers['Connection'] = 'keep-alive';
proxyRes.headers['X-Accel-Buffering'] = 'no';
proxyRes.headers['X-Content-Type-Options'] = 'nosniff';
}
if (proxyRes.statusCode === 404 && !openCodeApiPrefixDetected) {
scheduleOpenCodeApiDetection();
}
}
});
app.use('/api', proxyMiddleware);
}
function startHealthMonitoring() {
if (healthCheckInterval) {
clearInterval(healthCheckInterval);
}
healthCheckInterval = setInterval(async () => {
if (!openCodeProcess || isShuttingDown) return;
try {
if (openCodeProcess.exitCode !== null) {
console.log('OpenCode process not running, restarting...');
await restartOpenCode();
}
} catch (error) {
console.error(`Health check error: ${error.message}`);
}
}, HEALTH_CHECK_INTERVAL);
}
async function gracefulShutdown(options = {}) {
if (isShuttingDown) return;
isShuttingDown = true;
console.log('Starting graceful shutdown...');
const exitProcess = typeof options.exitProcess === 'boolean' ? options.exitProcess : exitOnShutdown;
if (healthCheckInterval) {
clearInterval(healthCheckInterval);
}
if (openCodeProcess) {
console.log('Stopping OpenCode process...');
openCodeProcess.kill('SIGTERM');
await new Promise((resolve) => {
const timeout = setTimeout(() => {
openCodeProcess.kill('SIGKILL');
resolve();
}, SHUTDOWN_TIMEOUT);
openCodeProcess.on('exit', () => {
clearTimeout(timeout);
resolve();
});
});
}
if (server) {
await new Promise((resolve) => {
server.close(() => {
console.log('HTTP server closed');
resolve();
});
});
}
if (uiAuthController) {
uiAuthController.dispose();
uiAuthController = null;
}
console.log('Graceful shutdown complete');
if (exitProcess) {
process.exit(0);
}
}
async function main(options = {}) {
const port = Number.isFinite(options.port) && options.port >= 0 ? Math.trunc(options.port) : DEFAULT_PORT;
const attachSignals = options.attachSignals !== false;
if (typeof options.exitOnShutdown === 'boolean') {
exitOnShutdown = options.exitOnShutdown;
}
console.log(`Starting OpenChamber on port ${port}`);
const app = express();
expressApp = app;
server = http.createServer(app);
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
openCodePort: openCodePort,
openCodeRunning: Boolean(openCodeProcess && openCodeProcess.exitCode === null),
openCodeApiPrefix,
openCodeApiPrefixDetected,
isOpenCodeReady,
lastOpenCodeError
});
});
app.use((req, res, next) => {
if (
req.path.startsWith('/api/config/agents') ||
req.path.startsWith('/api/config/commands') ||
req.path.startsWith('/api/config/settings') ||
req.path.startsWith('/api/fs') ||
req.path.startsWith('/api/git') ||
req.path.startsWith('/api/prompts') ||
req.path.startsWith('/api/terminal') ||
req.path.startsWith('/api/opencode')
) {
express.json()(req, res, next);
} else if (req.path.startsWith('/api')) {
next();
} else {
express.json()(req, res, next);
}
});
app.use(express.urlencoded({ extended: true }));
app.use((req, res, next) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
next();
});
const uiPassword = typeof options.uiPassword === 'string' ? options.uiPassword : null;
uiAuthController = createUiAuth({ password: uiPassword });
if (uiAuthController.enabled) {
console.log('UI password protection enabled for browser sessions');
}
app.get('/auth/session', (req, res) => uiAuthController.handleSessionStatus(req, res));
app.post('/auth/session', (req, res) => uiAuthController.handleSessionCreate(req, res));
app.use('/api', (req, res, next) => uiAuthController.requireAuth(req, res, next));
app.get('/api/openchamber/update-check', async (_req, res) => {
try {
const { checkForUpdates } = await import('./lib/package-manager.js');
const updateInfo = await checkForUpdates();
res.json(updateInfo);
} catch (error) {
console.error('Failed to check for updates:', error);
res.status(500).json({
available: false,
error: error instanceof Error ? error.message : 'Failed to check for updates',
});
}
});
app.post('/api/openchamber/update-install', async (_req, res) => {
try {
const { spawn: spawnChild } = await import('child_process');
const {
checkForUpdates,
getUpdateCommand,
detectPackageManager,
} = await import('./lib/package-manager.js');
// Verify update is available
const updateInfo = await checkForUpdates();
if (!updateInfo.available) {
return res.status(400).json({ error: 'No update available' });
}
const pm = detectPackageManager();
const updateCmd = getUpdateCommand(pm);
// Get current server port for restart
const currentPort = server.address()?.port || 3000;
// Try to read stored instance options for restart
const tmpDir = os.tmpdir();
const instanceFilePath = path.join(tmpDir, `openchamber-${currentPort}.json`);
let storedOptions = { port: currentPort, daemon: true };
try {
const content = fs.readFileSync(instanceFilePath, 'utf8');
storedOptions = JSON.parse(content);
} catch {
// Use defaults
}
// Build restart command with stored options
let restartCmd = `openchamber serve --port ${storedOptions.port} --daemon`;
if (storedOptions.uiPassword) {
// Escape password for shell
const escapedPw = storedOptions.uiPassword.replace(/'/g, "'\\''");
restartCmd += ` --ui-password '${escapedPw}'`;
}
// Respond immediately - update will happen after response
res.json({
success: true,
message: 'Update starting, server will restart shortly',
version: updateInfo.version,
packageManager: pm,
});
// Give time for response to be sent
setTimeout(() => {
console.log(`\nInstalling update using ${pm}...`);
console.log(`Running: ${updateCmd}`);
// Create a script that will:
// 1. Wait for current process to exit
// 2. Run the update
// 3. Restart the server with original options
const script = `
sleep 2
${updateCmd}
if [ $? -eq 0 ]; then
echo "Update successful, restarting OpenChamber..."
${restartCmd}
else
echo "Update failed"
exit 1
fi
`;
// Spawn detached shell to run update after we exit
const child = spawnChild('sh', ['-c', script], {
detached: true,
stdio: 'ignore',
env: process.env,
});
child.unref();
console.log('Update process spawned, shutting down server...');
// Give child process time to start, then exit
setTimeout(() => {
process.exit(0);
}, 500);
}, 500);
} catch (error) {
console.error('Failed to install update:', error);
res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to install update',
});
}
});
2025-12-07 19:32:53 +02:00
app.get('/api/openchamber/models-metadata', async (req, res) => {
const now = Date.now();
if (cachedModelsMetadata && now - cachedModelsMetadataTimestamp < MODELS_METADATA_CACHE_TTL) {
res.setHeader('Cache-Control', 'public, max-age=60');
return res.json(cachedModelsMetadata);
}
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null;
try {
const response = await fetch(MODELS_DEV_API_URL, {
signal: controller?.signal,
headers: {
Accept: 'application/json'
}
});
if (!response.ok) {
throw new Error(`models.dev responded with status ${response.status}`);
}
const metadata = await response.json();
cachedModelsMetadata = metadata;
cachedModelsMetadataTimestamp = Date.now();
res.setHeader('Cache-Control', 'public, max-age=300');
res.json(metadata);
} catch (error) {
console.warn('Failed to fetch models.dev metadata via server:', error);
if (cachedModelsMetadata) {
res.setHeader('Cache-Control', 'public, max-age=60');
res.json(cachedModelsMetadata);
} else {
const statusCode = error?.name === 'AbortError' ? 504 : 502;
res.status(statusCode).json({ error: 'Failed to retrieve model metadata' });
}
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
});
app.get('/api/event', async (req, res) => {
if (!openCodePort) {
return res.status(503).json({ error: 'OpenCode service unavailable' });
}
if (!openCodeApiPrefixDetected) {
try {
await detectOpenCodeApiPrefix();
} catch {
// ignore detection failures
}
}
let targetUrl;
try {
const prefix = openCodeApiPrefixDetected ? openCodeApiPrefix : '';
targetUrl = new URL(buildOpenCodeUrl('/event', prefix));
} catch (error) {
return res.status(503).json({ error: 'OpenCode service unavailable' });
}
const directoryParam = Array.isArray(req.query.directory)
? req.query.directory[0]
: req.query.directory;
if (typeof directoryParam === 'string' && directoryParam.trim().length > 0) {
targetUrl.searchParams.set('directory', directoryParam.trim());
}
const headers = {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
};
const lastEventId = req.header('Last-Event-ID');
if (typeof lastEventId === 'string' && lastEventId.length > 0) {
headers['Last-Event-ID'] = lastEventId;
}
const controller = new AbortController();
const cleanup = () => {
if (!controller.signal.aborted) {
controller.abort();
}
};
req.on('close', cleanup);
req.on('error', cleanup);
let upstream;
try {
upstream = await fetch(targetUrl.toString(), {
headers,
signal: controller.signal,
});
} catch (error) {
return res.status(502).json({ error: 'Failed to connect to OpenCode event stream' });
}
if (!upstream.ok || !upstream.body) {
return res.status(502).json({ error: `OpenCode event stream unavailable (${upstream.status})` });
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
if (typeof res.flushHeaders === 'function') {
res.flushHeaders();
}
const decoder = new TextDecoder();
const reader = upstream.body.getReader();
let buffer = '';
const forwardBlock = (block) => {
if (!block) return;
res.write(`${block}\n\n`);
const payload = parseSseDataPayload(block);
const activity = deriveSessionActivity(payload);
if (activity) {
writeSseEvent(res, {
type: 'openchamber:session-activity',
properties: {
sessionId: activity.sessionId,
phase: activity.phase,
}
});
}
};
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
let separatorIndex;
while ((separatorIndex = buffer.indexOf('\n\n')) !== -1) {
const block = buffer.slice(0, separatorIndex);
buffer = buffer.slice(separatorIndex + 2);
forwardBlock(block);
}
}
if (buffer.trim().length > 0) {
forwardBlock(buffer.trim());
}
} catch (error) {
if (!controller.signal.aborted) {
console.warn('SSE proxy stream error:', error);
}
} finally {
cleanup();
try {
res.end();
} catch {
// ignore
}
}
});
2025-12-07 19:32:53 +02:00
app.get('/api/config/settings', async (_req, res) => {
try {
const settings = await readSettingsFromDisk();
res.json(formatSettingsResponse(settings));
} catch (error) {
console.error('Failed to load settings:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load settings' });
}
});
app.put('/api/config/settings', async (req, res) => {
try {
const updated = await persistSettings(req.body ?? {});
res.json(updated);
} catch (error) {
console.error('Failed to save settings:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to save settings' });
}
});
const {
getAgentSources,
createAgent,
updateAgent,
deleteAgent,
getCommandSources,
createCommand,
updateCommand,
deleteCommand
} = await import('./lib/opencode-config.js');
app.get('/api/config/agents/:name', (req, res) => {
try {
const agentName = req.params.name;
const sources = getAgentSources(agentName);
res.json({
name: agentName,
sources: sources,
isBuiltIn: !sources.md.exists && !sources.json.exists
});
} catch (error) {
console.error('Failed to get agent sources:', error);
res.status(500).json({ error: 'Failed to get agent configuration metadata' });
}
});
app.post('/api/config/agents/:name', async (req, res) => {
try {
const agentName = req.params.name;
const config = req.body;
createAgent(agentName, config);
await refreshOpenCodeAfterConfigChange('agent creation', {
agentName
});
res.json({
success: true,
requiresReload: true,
message: `Agent ${agentName} created successfully. Reloading interface…`,
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
});
} catch (error) {
console.error('Failed to create agent:', error);
res.status(500).json({ error: error.message || 'Failed to create agent' });
}
});
app.patch('/api/config/agents/:name', async (req, res) => {
try {
const agentName = req.params.name;
const updates = req.body;
console.log(`[Server] Updating agent: ${agentName}`);
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
updateAgent(agentName, updates);
await refreshOpenCodeAfterConfigChange('agent update');
console.log(`[Server] Agent ${agentName} updated successfully`);
res.json({
success: true,
requiresReload: true,
message: `Agent ${agentName} updated successfully. Reloading interface…`,
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
});
} catch (error) {
console.error('[Server] Failed to update agent:', error);
console.error('[Server] Error stack:', error.stack);
res.status(500).json({ error: error.message || 'Failed to update agent' });
}
});
app.delete('/api/config/agents/:name', async (req, res) => {
try {
const agentName = req.params.name;
deleteAgent(agentName);
await refreshOpenCodeAfterConfigChange('agent deletion');
res.json({
success: true,
requiresReload: true,
message: `Agent ${agentName} deleted successfully. Reloading interface…`,
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
});
} catch (error) {
console.error('Failed to delete agent:', error);
res.status(500).json({ error: error.message || 'Failed to delete agent' });
}
});
app.get('/api/config/commands/:name', (req, res) => {
try {
const commandName = req.params.name;
const sources = getCommandSources(commandName);
res.json({
name: commandName,
sources: sources,
isBuiltIn: !sources.md.exists && !sources.json.exists
});
} catch (error) {
console.error('Failed to get command sources:', error);
res.status(500).json({ error: 'Failed to get command configuration metadata' });
}
});
app.post('/api/config/commands/:name', async (req, res) => {
try {
const commandName = req.params.name;
const config = req.body;
console.log('[Server] Creating command:', commandName);
console.log('[Server] Config received:', JSON.stringify(config, null, 2));
createCommand(commandName, config);
await refreshOpenCodeAfterConfigChange('command creation', {
commandName
});
res.json({
success: true,
requiresReload: true,
message: `Command ${commandName} created successfully. Reloading interface…`,
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
});
} catch (error) {
console.error('Failed to create command:', error);
res.status(500).json({ error: error.message || 'Failed to create command' });
}
});
app.patch('/api/config/commands/:name', async (req, res) => {
try {
const commandName = req.params.name;
const updates = req.body;
console.log(`[Server] Updating command: ${commandName}`);
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
updateCommand(commandName, updates);
await refreshOpenCodeAfterConfigChange('command update');
console.log(`[Server] Command ${commandName} updated successfully`);
res.json({
success: true,
requiresReload: true,
message: `Command ${commandName} updated successfully. Reloading interface…`,
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
});
} catch (error) {
console.error('[Server] Failed to update command:', error);
console.error('[Server] Error stack:', error.stack);
res.status(500).json({ error: error.message || 'Failed to update command' });
}
});
app.delete('/api/config/commands/:name', async (req, res) => {
try {
const commandName = req.params.name;
deleteCommand(commandName);
await refreshOpenCodeAfterConfigChange('command deletion');
res.json({
success: true,
requiresReload: true,
message: `Command ${commandName} deleted successfully. Reloading interface…`,
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
});
} catch (error) {
console.error('Failed to delete command:', error);
res.status(500).json({ error: error.message || 'Failed to delete command' });
}
});
app.post('/api/config/reload', async (req, res) => {
try {
console.log('[Server] Manual configuration reload requested');
await refreshOpenCodeAfterConfigChange('manual configuration reload');
res.json({
success: true,
requiresReload: true,
message: 'Configuration reloaded successfully. Refreshing interface…',
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
});
} catch (error) {
console.error('[Server] Failed to reload configuration:', error);
res.status(500).json({
error: error.message || 'Failed to reload configuration',
success: false
});
}
});
let authLibrary = null;
const getAuthLibrary = async () => {
if (!authLibrary) {
authLibrary = await import('./lib/opencode-auth.js');
}
return authLibrary;
};
app.delete('/api/provider/:providerId/auth', async (req, res) => {
try {
const { providerId } = req.params;
if (!providerId) {
return res.status(400).json({ error: 'Provider ID is required' });
}
const { removeProviderAuth } = await getAuthLibrary();
const removed = removeProviderAuth(providerId);
await refreshOpenCodeAfterConfigChange(`provider ${providerId} disconnected`);
res.json({
success: true,
removed,
requiresReload: true,
message: removed ? 'Provider disconnected successfully' : 'Provider was not connected',
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
});
} catch (error) {
console.error('Failed to disconnect provider:', error);
res.status(500).json({ error: error.message || 'Failed to disconnect provider' });
}
});
2025-12-07 19:32:53 +02:00
let gitLibraries = null;
const getGitLibraries = async () => {
if (!gitLibraries) {
const [storage, service] = await Promise.all([
import('./lib/git-identity-storage.js'),
import('./lib/git-service.js')
]);
gitLibraries = { ...storage, ...service };
}
return gitLibraries;
};
app.get('/api/git/identities', async (req, res) => {
const { getProfiles } = await getGitLibraries();
try {
const profiles = getProfiles();
res.json(profiles);
} catch (error) {
console.error('Failed to list git identity profiles:', error);
res.status(500).json({ error: 'Failed to list git identity profiles' });
}
});
app.post('/api/git/identities', async (req, res) => {
const { createProfile } = await getGitLibraries();
try {
const profile = createProfile(req.body);
console.log(`Created git identity profile: ${profile.name} (${profile.id})`);
res.json(profile);
} catch (error) {
console.error('Failed to create git identity profile:', error);
res.status(400).json({ error: error.message || 'Failed to create git identity profile' });
}
});
app.put('/api/git/identities/:id', async (req, res) => {
const { updateProfile } = await getGitLibraries();
try {
const profile = updateProfile(req.params.id, req.body);
console.log(`Updated git identity profile: ${profile.name} (${profile.id})`);
res.json(profile);
} catch (error) {
console.error('Failed to update git identity profile:', error);
res.status(400).json({ error: error.message || 'Failed to update git identity profile' });
}
});
app.delete('/api/git/identities/:id', async (req, res) => {
const { deleteProfile } = await getGitLibraries();
try {
deleteProfile(req.params.id);
console.log(`Deleted git identity profile: ${req.params.id}`);
res.json({ success: true });
} catch (error) {
console.error('Failed to delete git identity profile:', error);
res.status(400).json({ error: error.message || 'Failed to delete git identity profile' });
}
});
app.get('/api/git/global-identity', async (req, res) => {
const { getGlobalIdentity } = await getGitLibraries();
try {
const identity = await getGlobalIdentity();
res.json(identity);
} catch (error) {
console.error('Failed to get global git identity:', error);
res.status(500).json({ error: 'Failed to get global git identity' });
}
});
app.get('/api/git/check', async (req, res) => {
const { isGitRepository } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const isRepo = await isGitRepository(directory);
res.json({ isGitRepository: isRepo });
} catch (error) {
console.error('Failed to check git repository:', error);
res.status(500).json({ error: 'Failed to check git repository' });
}
});
app.get('/api/git/current-identity', async (req, res) => {
const { getCurrentIdentity } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const identity = await getCurrentIdentity(directory);
res.json(identity);
} catch (error) {
console.error('Failed to get current git identity:', error);
res.status(500).json({ error: 'Failed to get current git identity' });
}
});
app.post('/api/git/set-identity', async (req, res) => {
const { getProfile, setLocalIdentity, getGlobalIdentity } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { profileId } = req.body;
if (!profileId) {
return res.status(400).json({ error: 'profileId is required' });
}
let profile = null;
if (profileId === 'global') {
const globalIdentity = await getGlobalIdentity();
if (!globalIdentity?.userName || !globalIdentity?.userEmail) {
return res.status(404).json({ error: 'Global identity is not configured' });
}
profile = {
id: 'global',
name: 'Global Identity',
userName: globalIdentity.userName,
userEmail: globalIdentity.userEmail,
sshKey: globalIdentity.sshCommand
? globalIdentity.sshCommand.replace('ssh -i ', '')
: null,
};
} else {
profile = getProfile(profileId);
if (!profile) {
return res.status(404).json({ error: 'Profile not found' });
}
}
await setLocalIdentity(directory, profile);
res.json({ success: true, profile });
} catch (error) {
console.error('Failed to set git identity:', error);
res.status(500).json({ error: error.message || 'Failed to set git identity' });
}
});
app.get('/api/git/status', async (req, res) => {
const { getStatus } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const status = await getStatus(directory);
res.json(status);
} catch (error) {
console.error('Failed to get git status:', error);
res.status(500).json({ error: error.message || 'Failed to get git status' });
}
});
app.get('/api/git/diff', async (req, res) => {
const { getDiff } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const path = req.query.path;
if (!path || typeof path !== 'string') {
return res.status(400).json({ error: 'path parameter is required' });
}
const staged = req.query.staged === 'true';
const context = req.query.context ? parseInt(String(req.query.context), 10) : undefined;
const diff = await getDiff(directory, {
path,
staged,
contextLines: Number.isFinite(context) ? context : 3,
});
res.json({ diff });
} catch (error) {
console.error('Failed to get git diff:', error);
res.status(500).json({ error: error.message || 'Failed to get git diff' });
}
});
app.get('/api/git/file-diff', async (req, res) => {
const { getFileDiff } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const pathParam = req.query.path;
if (!pathParam || typeof pathParam !== 'string') {
return res.status(400).json({ error: 'path parameter is required' });
}
const staged = req.query.staged === 'true';
const result = await getFileDiff(directory, {
path: pathParam,
staged,
});
res.json({
original: result.original,
modified: result.modified,
path: result.path,
});
} catch (error) {
console.error('Failed to get git file diff:', error);
res.status(500).json({ error: error.message || 'Failed to get git file diff' });
}
});
app.post('/api/git/revert', async (req, res) => {
const { revertFile } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { path } = req.body || {};
if (!path || typeof path !== 'string') {
return res.status(400).json({ error: 'path parameter is required' });
}
await revertFile(directory, path);
res.json({ success: true });
} catch (error) {
console.error('Failed to revert git file:', error);
res.status(500).json({ error: error.message || 'Failed to revert git file' });
}
});
app.post('/api/git/commit-message', async (req, res) => {
const { collectDiffs } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const files = Array.isArray(req.body?.files) ? req.body.files : [];
if (files.length === 0) {
return res.status(400).json({ error: 'At least one file is required' });
}
const diffs = await collectDiffs(directory, files);
if (diffs.length === 0) {
return res.status(400).json({ error: 'No diffs available for selected files' });
}
const MAX_DIFF_LENGTH = 4000;
const diffSummaries = diffs
.map(({ path, diff }) => {
const trimmed = diff.length > MAX_DIFF_LENGTH ? `${diff.slice(0, MAX_DIFF_LENGTH)}\n...` : diff;
return `FILE: ${path}\n${trimmed}`;
})
.join('\n\n');
const prompt = `You are drafting git commit notes for this codebase. Respond in JSON of the shape {"subject": string, "highlights": string[]} (ONLY the JSON in response, no markdown wrappers or anything except JSON) with these rules:\n- subject follows our convention: type[optional-scope]: summary (examples: "feat: add diff virtualization", "fix(chat): restore enter key handling")\n- allowed types: feat, fix, chore, style, refactor, perf, docs, test, build, ci (choose the best match or fallback to chore)\n- summary must be imperative, concise, <= 70 characters, no trailing punctuation\n- scope is optional; include only when obvious from filenames/folders; do not invent scopes\n- focus on the most impactful user-facing change; if multiple capabilities ship together, align the subject with the dominant theme and use highlights to cover the other major outcomes\n- highlights array should contain 2-3 plain sentences (<= 90 chars each) that describe distinct features or UI changes users will notice (e.g. "Add per-file revert action in Changes list"). Avoid subjective benefit statements, marketing tone, repeating the subject, or referencing helper function names. Highlight additions such as new controls/buttons, new actions (e.g. revert), or stored state changes explicitly. Skip highlights if fewer than two meaningful points exist.\n- text must be plain (no markdown bullets); each highlight should start with an uppercase verb\n\nDiff summary:\n${diffSummaries}`;
const completionTimeout = createTimeoutSignal(LONG_REQUEST_TIMEOUT_MS);
let response;
try {
response = await fetch('https://opencode.ai/zen/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'big-pickle',
messages: [{ role: 'user', content: prompt }],
max_tokens: 3000,
stream: false,
reasoning: {
effort: 'low'
}
}),
signal: completionTimeout.signal,
});
} finally {
completionTimeout.cleanup();
}
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
console.error('Commit message generation failed:', errorBody);
return res.status(502).json({ error: 'Failed to generate commit message' });
}
const data = await response.json();
const raw = data?.choices?.[0]?.message?.content?.trim();
if (!raw) {
return res.status(502).json({ error: 'No commit message returned by generator' });
}
const cleanedJson = stripJsonMarkdownWrapper(raw);
const candidates = [cleanedJson, raw].filter((candidate, index, array) => {
return candidate && array.indexOf(candidate) === index;
});
for (const candidate of candidates) {
if (!(candidate.startsWith('{') || candidate.startsWith('['))) {
continue;
}
try {
const parsed = JSON.parse(candidate);
return res.json({ message: parsed });
} catch (parseError) {
console.warn('Commit message generation returned non-JSON body:', parseError);
}
}
res.json({ message: { subject: raw, highlights: [] } });
} catch (error) {
console.error('Failed to generate commit message:', error);
res.status(500).json({ error: error.message || 'Failed to generate commit message' });
}
});
app.post('/api/git/pull', async (req, res) => {
const { pull } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const result = await pull(directory, req.body);
res.json(result);
} catch (error) {
console.error('Failed to pull:', error);
res.status(500).json({ error: error.message || 'Failed to pull from remote' });
}
});
app.post('/api/git/push', async (req, res) => {
const { push } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const result = await push(directory, req.body);
res.json(result);
} catch (error) {
console.error('Failed to push:', error);
res.status(500).json({ error: error.message || 'Failed to push to remote' });
}
});
app.post('/api/git/fetch', async (req, res) => {
const { fetch: gitFetch } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const result = await gitFetch(directory, req.body);
res.json(result);
} catch (error) {
console.error('Failed to fetch:', error);
res.status(500).json({ error: error.message || 'Failed to fetch from remote' });
}
});
app.post('/api/git/commit', async (req, res) => {
const { commit } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { message, addAll, files } = req.body;
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
const result = await commit(directory, message, {
addAll,
files,
});
res.json(result);
} catch (error) {
console.error('Failed to commit:', error);
res.status(500).json({ error: error.message || 'Failed to create commit' });
}
});
app.get('/api/git/branches', async (req, res) => {
const { getBranches } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const branches = await getBranches(directory);
res.json(branches);
} catch (error) {
console.error('Failed to get branches:', error);
res.status(500).json({ error: error.message || 'Failed to get branches' });
}
});
app.post('/api/git/branches', async (req, res) => {
const { createBranch } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { name, startPoint } = req.body;
if (!name) {
return res.status(400).json({ error: 'name is required' });
}
const result = await createBranch(directory, name, { startPoint });
res.json(result);
} catch (error) {
console.error('Failed to create branch:', error);
res.status(500).json({ error: error.message || 'Failed to create branch' });
}
});
app.delete('/api/git/branches', async (req, res) => {
const { deleteBranch } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { branch, force } = req.body;
if (!branch) {
return res.status(400).json({ error: 'branch is required' });
}
const result = await deleteBranch(directory, branch, { force });
res.json(result);
} catch (error) {
console.error('Failed to delete branch:', error);
res.status(500).json({ error: error.message || 'Failed to delete branch' });
}
});
app.delete('/api/git/remote-branches', async (req, res) => {
const { deleteRemoteBranch } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { branch, remote } = req.body;
if (!branch) {
return res.status(400).json({ error: 'branch is required' });
}
const result = await deleteRemoteBranch(directory, { branch, remote });
res.json(result);
} catch (error) {
console.error('Failed to delete remote branch:', error);
res.status(500).json({ error: error.message || 'Failed to delete remote branch' });
}
});
app.post('/api/git/checkout', async (req, res) => {
const { checkoutBranch } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { branch } = req.body;
if (!branch) {
return res.status(400).json({ error: 'branch is required' });
}
const result = await checkoutBranch(directory, branch);
res.json(result);
} catch (error) {
console.error('Failed to checkout branch:', error);
res.status(500).json({ error: error.message || 'Failed to checkout branch' });
}
});
app.get('/api/git/worktrees', async (req, res) => {
const { getWorktrees } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const worktrees = await getWorktrees(directory);
res.json(worktrees);
} catch (error) {
// Worktrees are an optional feature. Avoid repeated 500s (and repeated client retries)
// when the directory isn't a git repo or uses shell shorthand like "~/".
console.warn('Failed to get worktrees, returning empty list:', error?.message || error);
res.setHeader('X-OpenChamber-Warning', 'git worktrees unavailable');
res.json([]);
2025-12-07 19:32:53 +02:00
}
});
app.post('/api/git/worktrees', async (req, res) => {
const { addWorktree } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { path, branch, createBranch } = req.body;
if (!path || !branch) {
return res.status(400).json({ error: 'path and branch are required' });
}
const result = await addWorktree(directory, path, branch, { createBranch });
res.json(result);
} catch (error) {
console.error('Failed to add worktree:', error);
res.status(500).json({ error: error.message || 'Failed to add worktree' });
}
});
app.delete('/api/git/worktrees', async (req, res) => {
const { removeWorktree } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { path, force } = req.body;
if (!path) {
return res.status(400).json({ error: 'path is required' });
}
const result = await removeWorktree(directory, path, { force });
res.json(result);
} catch (error) {
console.error('Failed to remove worktree:', error);
res.status(500).json({ error: error.message || 'Failed to remove worktree' });
}
});
app.post('/api/git/ignore-openchamber', async (req, res) => {
const { ensureOpenChamberIgnored } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
await ensureOpenChamberIgnored(directory);
res.json({ success: true });
} catch (error) {
console.error('Failed to ignore .openchamber directory:', error);
res.status(500).json({ error: error.message || 'Failed to update git ignore' });
}
});
app.get('/api/git/worktree-type', async (req, res) => {
const { isLinkedWorktree } = await getGitLibraries();
try {
const { directory } = req.query;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const linked = await isLinkedWorktree(directory);
res.json({ linked });
} catch (error) {
console.error('Failed to determine worktree type:', error);
res.status(500).json({ error: error.message || 'Failed to determine worktree type' });
}
});
app.get('/api/git/log', async (req, res) => {
const { getLog } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { maxCount, from, to, file } = req.query;
const log = await getLog(directory, {
maxCount: maxCount ? parseInt(maxCount) : undefined,
from,
to,
file
});
res.json(log);
} catch (error) {
console.error('Failed to get log:', error);
res.status(500).json({ error: error.message || 'Failed to get commit log' });
}
});
app.get('/api/git/commit-files', async (req, res) => {
const { getCommitFiles } = await getGitLibraries();
try {
const { directory, hash } = req.query;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
if (!hash) {
return res.status(400).json({ error: 'hash parameter is required' });
}
const result = await getCommitFiles(directory, hash);
res.json(result);
} catch (error) {
console.error('Failed to get commit files:', error);
res.status(500).json({ error: error.message || 'Failed to get commit files' });
}
});
app.get('/api/fs/home', (req, res) => {
try {
const home = os.homedir();
if (!home || typeof home !== 'string' || home.length === 0) {
return res.status(500).json({ error: 'Failed to resolve home directory' });
}
res.json({ home });
} catch (error) {
console.error('Failed to resolve home directory:', error);
res.status(500).json({ error: (error && error.message) || 'Failed to resolve home directory' });
}
});
app.post('/api/fs/mkdir', (req, res) => {
try {
const { path: dirPath } = req.body;
if (!dirPath) {
return res.status(400).json({ error: 'Path is required' });
}
const expandedPath = normalizeDirectoryPath(dirPath);
const normalizedPath = path.normalize(expandedPath);
2025-12-07 19:32:53 +02:00
if (normalizedPath.includes('..')) {
return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
}
const resolvedPath = path.resolve(expandedPath);
fs.mkdirSync(resolvedPath, { recursive: true });
2025-12-07 19:32:53 +02:00
res.json({ success: true, path: resolvedPath });
2025-12-07 19:32:53 +02:00
} catch (error) {
console.error('Failed to create directory:', error);
res.status(500).json({ error: error.message || 'Failed to create directory' });
}
});
app.post('/api/opencode/directory', async (req, res) => {
try {
const requestedPath = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
if (!requestedPath) {
return res.status(400).json({ error: 'Path is required' });
}
const resolvedPath = path.resolve(normalizeDirectoryPath(requestedPath));
2025-12-07 19:32:53 +02:00
let stats;
try {
stats = await fsPromises.stat(resolvedPath);
} catch (error) {
const err = error;
if (err && typeof err === 'object' && 'code' in err) {
if (err.code === 'ENOENT') {
return res.status(404).json({ error: 'Directory not found' });
}
if (err.code === 'EACCES') {
return res.status(403).json({ error: 'Access to directory denied' });
}
}
throw error;
}
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Specified path is not a directory' });
}
if (openCodeWorkingDirectory === resolvedPath && openCodeProcess && openCodeProcess.exitCode === null) {
return res.json({ success: true, restarted: false, path: resolvedPath });
}
openCodeWorkingDirectory = resolvedPath;
await refreshOpenCodeAfterConfigChange('directory change');
res.json({
success: true,
restarted: true,
path: resolvedPath
});
} catch (error) {
console.error('Failed to update OpenCode working directory:', error);
res.status(500).json({ error: error.message || 'Failed to update working directory' });
}
});
app.get('/api/fs/list', async (req, res) => {
const rawPath = typeof req.query.path === 'string' && req.query.path.trim().length > 0
? req.query.path.trim()
: os.homedir();
try {
const resolvedPath = path.resolve(normalizeDirectoryPath(rawPath));
2025-12-07 19:32:53 +02:00
const stats = await fsPromises.stat(resolvedPath);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Specified path is not a directory' });
}
const dirents = await fsPromises.readdir(resolvedPath, { withFileTypes: true });
const entries = await Promise.all(
dirents.map(async (dirent) => {
const entryPath = path.join(resolvedPath, dirent.name);
let isDirectory = dirent.isDirectory();
const isSymbolicLink = dirent.isSymbolicLink();
if (!isDirectory && isSymbolicLink) {
try {
const linkStats = await fsPromises.stat(entryPath);
isDirectory = linkStats.isDirectory();
} catch {
isDirectory = false;
}
}
return {
name: dirent.name,
path: entryPath,
isDirectory,
isFile: dirent.isFile(),
isSymbolicLink
};
})
);
res.json({
path: resolvedPath,
entries
});
} catch (error) {
console.error('Failed to list directory:', error);
const err = error;
if (err && typeof err === 'object' && 'code' in err) {
const code = err.code;
if (code === 'ENOENT') {
return res.status(404).json({ error: 'Directory not found' });
}
if (code === 'EACCES') {
return res.status(403).json({ error: 'Access to directory denied' });
}
}
res.status(500).json({ error: (error && error.message) || 'Failed to list directory' });
}
});
app.get('/api/fs/search', async (req, res) => {
const rawRoot = typeof req.query.root === 'string' && req.query.root.trim().length > 0
? req.query.root.trim()
: typeof req.query.directory === 'string' && req.query.directory.trim().length > 0
? req.query.directory.trim()
: os.homedir();
const rawQuery = typeof req.query.q === 'string' ? req.query.q : '';
const limitParam = typeof req.query.limit === 'string' ? Number.parseInt(req.query.limit, 10) : undefined;
const parsedLimit = Number.isFinite(limitParam) ? Number(limitParam) : DEFAULT_FILE_SEARCH_LIMIT;
const limit = Math.max(1, Math.min(parsedLimit, MAX_FILE_SEARCH_LIMIT));
try {
const resolvedRoot = path.resolve(normalizeDirectoryPath(rawRoot));
2025-12-07 19:32:53 +02:00
const stats = await fsPromises.stat(resolvedRoot);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Specified root is not a directory' });
}
const files = await searchFilesystemFiles(resolvedRoot, { limit, query: rawQuery || '' });
res.json({
root: resolvedRoot,
count: files.length,
files
});
} catch (error) {
console.error('Failed to search filesystem:', error);
const err = error;
if (err && typeof err === 'object' && 'code' in err) {
const code = err.code;
if (code === 'ENOENT') {
return res.status(404).json({ error: 'Directory not found' });
}
if (code === 'EACCES') {
return res.status(403).json({ error: 'Access to directory denied' });
}
}
res.status(500).json({ error: (error && error.message) || 'Failed to search files' });
}
});
let ptyLib = null;
let ptyLoadError = null;
const getPtyLib = async () => {
if (ptyLib) return ptyLib;
if (ptyLoadError) throw ptyLoadError;
try {
ptyLib = await import('node-pty');
console.log('node-pty loaded successfully');
return ptyLib;
} catch (error) {
ptyLoadError = error;
console.error('Failed to load node-pty:', error.message);
console.error('Terminal functionality will not be available.');
console.error('To fix: run "npm rebuild node-pty" or "npm install"');
throw new Error('node-pty is not available. Run: npm rebuild node-pty');
}
};
const terminalSessions = new Map();
const MAX_TERMINAL_SESSIONS = 20;
const TERMINAL_IDLE_TIMEOUT = 30 * 60 * 1000;
setInterval(() => {
const now = Date.now();
for (const [sessionId, session] of terminalSessions.entries()) {
if (now - session.lastActivity > TERMINAL_IDLE_TIMEOUT) {
console.log(`Cleaning up idle terminal session: ${sessionId}`);
try {
session.ptyProcess.kill();
} catch (error) {
}
terminalSessions.delete(sessionId);
}
}
}, 5 * 60 * 1000);
app.post('/api/terminal/create', async (req, res) => {
try {
if (terminalSessions.size >= MAX_TERMINAL_SESSIONS) {
return res.status(429).json({ error: 'Maximum terminal sessions reached' });
}
const { cwd, cols, rows } = req.body;
if (!cwd) {
return res.status(400).json({ error: 'cwd is required' });
}
if (!fs.existsSync(cwd)) {
return res.status(400).json({ error: 'Invalid working directory' });
}
const pty = await getPtyLib();
const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh');
const sessionId = Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
const envPath = buildAugmentedPath();
const resolvedEnv = { ...process.env, PATH: envPath };
const ptyProcess = pty.spawn(shell, [], {
name: 'xterm-256color',
cols: cols || 80,
rows: rows || 24,
cwd: cwd,
env: {
...resolvedEnv,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
},
});
const session = {
ptyProcess,
cwd,
lastActivity: Date.now(),
clients: new Set(),
};
terminalSessions.set(sessionId, session);
ptyProcess.onExit(({ exitCode, signal }) => {
console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`);
terminalSessions.delete(sessionId);
});
console.log(`Created terminal session: ${sessionId} in ${cwd}`);
res.json({ sessionId, cols: cols || 80, rows: rows || 24 });
} catch (error) {
console.error('Failed to create terminal session:', error);
res.status(500).json({ error: error.message || 'Failed to create terminal session' });
}
});
app.get('/api/terminal/:sessionId/stream', (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.write('data: {"type":"connected"}\n\n');
const clientId = Math.random().toString(36).substring(7);
session.clients.add(clientId);
session.lastActivity = Date.now();
const heartbeatInterval = setInterval(() => {
try {
res.write(': heartbeat\n\n');
} catch (error) {
console.error(`Heartbeat failed for client ${clientId}:`, error);
clearInterval(heartbeatInterval);
}
}, 15000);
const dataHandler = (data) => {
try {
session.lastActivity = Date.now();
const ok = res.write(`data: ${JSON.stringify({ type: 'data', data })}\n\n`);
if (!ok && session.ptyProcess && typeof session.ptyProcess.pause === 'function') {
session.ptyProcess.pause();
res.once('drain', () => {
if (session.ptyProcess && typeof session.ptyProcess.resume === 'function') {
session.ptyProcess.resume();
}
});
}
} catch (error) {
console.error(`Error sending data to client ${clientId}:`, error);
cleanup();
}
};
const exitHandler = ({ exitCode, signal }) => {
try {
res.write(`data: ${JSON.stringify({ type: 'exit', exitCode, signal })}\n\n`);
res.end();
} catch (error) {
}
cleanup();
};
const dataDisposable = session.ptyProcess.onData(dataHandler);
const exitDisposable = session.ptyProcess.onExit(exitHandler);
const cleanup = () => {
clearInterval(heartbeatInterval);
session.clients.delete(clientId);
if (dataDisposable && typeof dataDisposable.dispose === 'function') {
dataDisposable.dispose();
}
if (exitDisposable && typeof exitDisposable.dispose === 'function') {
exitDisposable.dispose();
}
try {
res.end();
} catch (error) {
}
console.log(`Client ${clientId} disconnected from terminal session ${sessionId}`);
};
req.on('close', cleanup);
req.on('error', cleanup);
console.log(`Client ${clientId} connected to terminal session ${sessionId}`);
});
app.post('/api/terminal/:sessionId/input', express.text({ type: '*/*' }), (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
const data = typeof req.body === 'string' ? req.body : '';
try {
session.ptyProcess.write(data);
session.lastActivity = Date.now();
res.json({ success: true });
} catch (error) {
console.error('Failed to write to terminal:', error);
res.status(500).json({ error: error.message || 'Failed to write to terminal' });
}
});
app.post('/api/terminal/:sessionId/resize', (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
const { cols, rows } = req.body;
if (!cols || !rows) {
return res.status(400).json({ error: 'cols and rows are required' });
}
try {
session.ptyProcess.resize(cols, rows);
session.lastActivity = Date.now();
res.json({ success: true, cols, rows });
} catch (error) {
console.error('Failed to resize terminal:', error);
res.status(500).json({ error: error.message || 'Failed to resize terminal' });
}
});
app.delete('/api/terminal/:sessionId', (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
try {
session.ptyProcess.kill();
terminalSessions.delete(sessionId);
console.log(`Closed terminal session: ${sessionId}`);
res.json({ success: true });
} catch (error) {
console.error('Failed to close terminal:', error);
res.status(500).json({ error: error.message || 'Failed to close terminal' });
}
});
app.post('/api/terminal/:sessionId/restart', async (req, res) => {
const { sessionId } = req.params;
const { cwd, cols, rows } = req.body;
if (!cwd) {
return res.status(400).json({ error: 'cwd is required' });
}
const existingSession = terminalSessions.get(sessionId);
if (existingSession) {
try {
existingSession.ptyProcess.kill();
} catch (error) {
}
terminalSessions.delete(sessionId);
}
try {
if (!fs.existsSync(cwd)) {
return res.status(400).json({ error: 'Invalid working directory' });
}
const pty = await getPtyLib();
const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh');
const newSessionId = Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
const envPath = buildAugmentedPath();
const resolvedEnv = { ...process.env, PATH: envPath };
const ptyProcess = pty.spawn(shell, [], {
name: 'xterm-256color',
cols: cols || 80,
rows: rows || 24,
cwd: cwd,
env: {
...resolvedEnv,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
},
});
const session = {
ptyProcess,
cwd,
lastActivity: Date.now(),
clients: new Set(),
};
terminalSessions.set(newSessionId, session);
ptyProcess.onExit(({ exitCode, signal }) => {
console.log(`Terminal session ${newSessionId} exited with code ${exitCode}, signal ${signal}`);
terminalSessions.delete(newSessionId);
});
console.log(`Restarted terminal session: ${sessionId} -> ${newSessionId} in ${cwd}`);
res.json({ sessionId: newSessionId, cols: cols || 80, rows: rows || 24 });
} catch (error) {
console.error('Failed to restart terminal session:', error);
res.status(500).json({ error: error.message || 'Failed to restart terminal session' });
}
});
app.post('/api/terminal/force-kill', (req, res) => {
const { sessionId, cwd } = req.body;
let killedCount = 0;
if (sessionId) {
const session = terminalSessions.get(sessionId);
if (session) {
try {
session.ptyProcess.kill();
} catch (error) {
}
terminalSessions.delete(sessionId);
killedCount++;
}
} else if (cwd) {
for (const [id, session] of terminalSessions) {
if (session.cwd === cwd) {
try {
session.ptyProcess.kill();
} catch (error) {
}
terminalSessions.delete(id);
killedCount++;
}
}
} else {
for (const [id, session] of terminalSessions) {
try {
session.ptyProcess.kill();
} catch (error) {
}
terminalSessions.delete(id);
killedCount++;
}
}
console.log(`Force killed ${killedCount} terminal session(s)`);
res.json({ success: true, killedCount });
});
2025-12-07 19:32:53 +02:00
try {
if (ENV_CONFIGURED_OPENCODE_PORT) {
console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`);
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT);
} else {
openCodePort = null;
}
lastOpenCodeError = null;
openCodeProcess = await startOpenCode();
await waitForOpenCodePort();
try {
await waitForOpenCodeReady();
} catch (error) {
console.error(`OpenCode readiness check failed: ${error.message}`);
scheduleOpenCodeApiDetection();
}
setupProxy(app);
scheduleOpenCodeApiDetection();
startHealthMonitoring();
} catch (error) {
console.error(`Failed to start OpenCode: ${error.message}`);
console.log('Continuing without OpenCode integration...');
lastOpenCodeError = error.message;
setupProxy(app);
scheduleOpenCodeApiDetection();
}
const distPath = path.join(__dirname, '..', 'dist');
if (fs.existsSync(distPath)) {
console.log(`Serving static files from ${distPath}`);
app.use(express.static(distPath));
app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => {
res.sendFile(path.join(distPath, 'index.html'));
});
} else {
console.warn(`Warning: ${distPath} not found, static files will not be served`);
app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => {
res.status(404).send('Static files not found. Please build the application first.');
});
}
let activePort = port;
await new Promise((resolve, reject) => {
const onError = (error) => {
server.off('error', onError);
reject(error);
};
server.once('error', onError);
server.listen(port, () => {
server.off('error', onError);
const addressInfo = server.address();
activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port;
console.log(`OpenChamber server running on port ${activePort}`);
console.log(`Health check: http://localhost:${activePort}/health`);
console.log(`Web interface: http://localhost:${activePort}`);
resolve();
});
});
if (attachSignals && !signalsAttached) {
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);
process.on('SIGQUIT', gracefulShutdown);
signalsAttached = true;
}
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
gracefulShutdown();
});
return {
expressApp: app,
httpServer: server,
getPort: () => activePort,
getOpenCodePort: () => openCodePort,
isReady: () => isOpenCodeReady,
restartOpenCode: () => restartOpenCode(),
stop: (shutdownOptions = {}) =>
gracefulShutdown({ exitProcess: shutdownOptions.exitProcess ?? false })
};
}
const isCliExecution = process.argv[1] === __filename;
if (isCliExecution) {
const cliOptions = parseArgs();
exitOnShutdown = true;
main({
port: cliOptions.port,
attachSignals: true,
exitOnShutdown: true,
uiPassword: cliOptions.uiPassword
}).catch(error => {
console.error('Failed to start server:', error);
process.exit(1);
});
}
export { gracefulShutdown, setupProxy, restartOpenCode, main as startWebUiServer, parseArgs };