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

4996 lines
152 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 crypto from 'crypto';
import { createUiAuth } from './lib/ui-auth.js';
import { startCloudflareTunnel, printTunnelWarning, checkCloudflaredAvailable } from './lib/cloudflare-tunnel.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;
};
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 [];
}
};
/**
* 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;
};
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]);
// Collect more candidates for fuzzy matching, then sort and trim
const collectLimit = matchAll ? limit : Math.max(limit * 3, 200);
const candidates = [];
while (queue.length > 0 && candidates.length < collectLimit) {
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);
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
});
}
}
if (candidates.length >= collectLimit) {
queue.length = 0;
break;
}
}
if (candidates.length >= collectLimit) {
break;
}
}
}
// 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
}));
};
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 resolveDirectoryCandidate = (value) => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const normalized = normalizeDirectoryPath(trimmed);
return path.resolve(normalized);
};
const validateDirectoryPath = async (candidate) => {
const resolved = resolveDirectoryCandidate(candidate);
if (!resolved) {
return { ok: false, error: 'Directory parameter is required' };
}
try {
const stats = await fsPromises.stat(resolved);
if (!stats.isDirectory()) {
return { ok: false, error: 'Specified path is not a directory' };
}
return { ok: true, directory: resolved };
} catch (error) {
const err = error;
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return { ok: false, error: 'Directory not found' };
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return { ok: false, error: 'Access to directory denied' };
}
return { ok: false, error: 'Failed to validate directory' };
}
};
const resolveProjectDirectory = async (req) => {
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requested = headerDirectory || queryDirectory || null;
if (requested) {
const validated = await validateDirectoryPath(requested);
if (!validated.ok) {
return { directory: null, error: validated.error };
}
return { directory: validated.directory, error: null };
}
const settings = await readSettingsFromDiskMigrated();
const projects = sanitizeProjects(settings.projects) || [];
if (projects.length === 0) {
return { directory: null, error: 'Directory parameter or active project is required' };
}
const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : '';
const active = projects.find((project) => project.id === activeId) || projects[0];
if (!active || !active.path) {
return { directory: null, error: 'Directory parameter or active project is required' };
}
const validated = await validateDirectoryPath(active.path);
if (!validated.ok) {
return { directory: null, error: validated.error };
}
return { directory: validated.directory, error: null };
};
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 sanitizeSkillCatalogs = (input) => {
if (!Array.isArray(input)) {
return undefined;
}
const result = [];
const seen = new Set();
for (const entry of input) {
if (!entry || typeof entry !== 'object') continue;
const id = typeof entry.id === 'string' ? entry.id.trim() : '';
const label = typeof entry.label === 'string' ? entry.label.trim() : '';
const source = typeof entry.source === 'string' ? entry.source.trim() : '';
const subpath = typeof entry.subpath === 'string' ? entry.subpath.trim() : '';
const gitIdentityId = typeof entry.gitIdentityId === 'string' ? entry.gitIdentityId.trim() : '';
if (!id || !label || !source) continue;
if (seen.has(id)) continue;
seen.add(id);
result.push({
id,
label,
source,
...(subpath ? { subpath } : {}),
...(gitIdentityId ? { gitIdentityId } : {}),
});
}
return result;
};
const sanitizeProjects = (input) => {
if (!Array.isArray(input)) {
return undefined;
}
const result = [];
const seenIds = new Set();
const seenPaths = new Set();
for (const entry of input) {
if (!entry || typeof entry !== 'object') continue;
const candidate = entry;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
const normalizedPath = rawPath ? path.resolve(normalizeDirectoryPath(rawPath)) : '';
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null;
const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt)
? Number(candidate.lastOpenedAt)
: null;
if (!id || !normalizedPath) continue;
if (seenIds.has(id)) continue;
if (seenPaths.has(normalizedPath)) continue;
seenIds.add(id);
seenPaths.add(normalizedPath);
const project = {
id,
path: normalizedPath,
...(label ? { label } : {}),
...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}),
...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
};
// Preserve worktreeDefaults
if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') {
const wt = candidate.worktreeDefaults;
const defaults = {};
if (typeof wt.branchPrefix === 'string' && wt.branchPrefix.trim()) {
defaults.branchPrefix = wt.branchPrefix.trim();
}
if (typeof wt.baseBranch === 'string' && wt.baseBranch.trim()) {
defaults.baseBranch = wt.baseBranch.trim();
}
if (typeof wt.autoCreateWorktree === 'boolean') {
defaults.autoCreateWorktree = wt.autoCreateWorktree;
}
if (Object.keys(defaults).length > 0) {
project.worktreeDefaults = defaults;
}
}
result.push(project);
}
return result;
};
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.projects)) {
const projects = sanitizeProjects(candidate.projects);
if (projects) {
result.projects = projects;
}
}
if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) {
result.activeProjectId = candidate.activeProjectId;
}
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;
}
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;
}
if (typeof candidate.queueModeEnabled === 'boolean') {
result.queueModeEnabled = candidate.queueModeEnabled;
}
if (typeof candidate.autoCreateWorktree === 'boolean') {
result.autoCreateWorktree = candidate.autoCreateWorktree;
}
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
if (skillCatalogs) {
result.skillCatalogs = skillCatalogs;
}
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 projectEntries = Array.isArray(changes.projects)
? changes.projects
: Array.isArray(current.projects)
? current.projects
: [];
projectEntries.forEach((project) => {
if (project && typeof project.path === 'string' && project.path.length > 0) {
additionalApproved.push(project.path);
}
});
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 validateProjectEntries = async (projects) => {
if (!Array.isArray(projects)) {
return [];
}
const results = [];
for (const project of projects) {
if (!project || typeof project.path !== 'string' || project.path.length === 0) {
continue;
}
try {
const stats = await fsPromises.stat(project.path);
if (!stats.isDirectory()) {
continue;
}
results.push(project);
} catch (error) {
const err = error;
if (err && typeof err === 'object' && err.code === 'ENOENT') {
continue;
}
continue;
}
}
return results;
};
const migrateSettingsFromLegacyLastDirectory = async (current) => {
const settings = current && typeof current === 'object' ? current : {};
const now = Date.now();
const sanitizedProjects = sanitizeProjects(settings.projects) || [];
let nextProjects = sanitizedProjects;
let nextActiveProjectId =
typeof settings.activeProjectId === 'string' ? settings.activeProjectId : undefined;
let changed = false;
if (nextProjects.length === 0) {
const legacy = typeof settings.lastDirectory === 'string' ? settings.lastDirectory.trim() : '';
const candidate = legacy ? resolveDirectoryCandidate(legacy) : null;
if (candidate) {
try {
const stats = await fsPromises.stat(candidate);
if (stats.isDirectory()) {
const id = crypto.randomUUID();
nextProjects = [
{
id,
path: candidate,
addedAt: now,
lastOpenedAt: now,
},
];
nextActiveProjectId = id;
changed = true;
}
} catch {
// ignore invalid lastDirectory
}
}
}
if (nextProjects.length > 0) {
const active = nextProjects.find((project) => project.id === nextActiveProjectId) || null;
if (!active) {
nextActiveProjectId = nextProjects[0].id;
changed = true;
}
} else if (nextActiveProjectId) {
nextActiveProjectId = undefined;
changed = true;
}
if (!changed) {
return { settings, changed: false };
}
const merged = mergePersistedSettings(settings, {
...settings,
projects: nextProjects,
...(nextActiveProjectId ? { activeProjectId: nextActiveProjectId } : { activeProjectId: undefined }),
});
return { settings: merged, changed: true };
};
const readSettingsFromDiskMigrated = async () => {
const current = await readSettingsFromDisk();
const { settings, changed } = await migrateSettingsFromLegacyLastDirectory(current);
if (changed) {
await writeSettingsToDisk(settings);
}
return settings;
};
const persistSettings = async (changes) => {
const current = await readSettingsFromDisk();
const sanitized = sanitizeSettingsUpdate(changes);
let next = mergePersistedSettings(current, sanitized);
if (Array.isArray(next.projects)) {
const validated = await validateProjectEntries(next.projects);
next = { ...next, projects: validated };
}
if (Array.isArray(next.projects) && next.projects.length > 0) {
const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : '';
const active = next.projects.find((project) => project.id === activeId) || null;
if (!active) {
next = { ...next, activeProjectId: next.projects[0].id };
}
} else if (next.activeProjectId) {
next = { ...next, activeProjectId: undefined };
}
await writeSettingsToDisk(next);
return formatSettingsResponse(next);
};
// HMR-persistent state via globalThis
// These values survive Vite HMR reloads to prevent zombie OpenCode processes
const HMR_STATE_KEY = '__openchamberHmrState';
const getHmrState = () => {
if (!globalThis[HMR_STATE_KEY]) {
globalThis[HMR_STATE_KEY] = {
openCodeProcess: null,
openCodePort: null,
openCodeWorkingDirectory: os.homedir(),
isShuttingDown: false,
signalsAttached: false,
};
}
return globalThis[HMR_STATE_KEY];
};
const hmrState = getHmrState();
// Non-HMR state (safe to reset on reload)
let healthCheckInterval = null;
let server = null;
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 uiAuthController = null;
let cloudflareTunnelController = null;
// Sync helper - call after modifying any HMR state variable
const syncToHmrState = () => {
hmrState.openCodeProcess = openCodeProcess;
hmrState.openCodePort = openCodePort;
hmrState.isShuttingDown = isShuttingDown;
hmrState.signalsAttached = signalsAttached;
hmrState.openCodeWorkingDirectory = openCodeWorkingDirectory;
};
// Sync helper - call to restore state from HMR (e.g., on module reload)
const syncFromHmrState = () => {
openCodeProcess = hmrState.openCodeProcess;
openCodePort = hmrState.openCodePort;
isShuttingDown = hmrState.isShuttingDown;
signalsAttached = hmrState.signalsAttached;
openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory;
};
// Module-level variables that shadow HMR state
// These are synced to/from hmrState to survive HMR reloads
let openCodeProcess = hmrState.openCodeProcess;
let openCodePort = hmrState.openCodePort;
let isShuttingDown = hmrState.isShuttingDown;
let signalsAttached = hmrState.signalsAttached;
let openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory;
/**
* Check if an existing OpenCode process is still alive and responding
* Used to reuse process across HMR reloads
*/
async function isOpenCodeProcessHealthy() {
if (!openCodeProcess || !openCodePort) {
return false;
}
// Check if process is still running
if (openCodeProcess.exitCode !== null || openCodeProcess.signalCode !== null) {
return false;
}
// Health check via HTTP
try {
const response = await fetch(`http://127.0.0.1:${openCodePort}/session`, {
method: 'GET',
signal: AbortSignal.timeout(2000),
});
return response.ok;
} catch {
return false;
}
}
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';
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"'];
try {
const result = spawnSync(shell, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
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() {
if (OPENCODE_BINARY_ENV) {
const explicit = resolveBinaryFromPath(OPENCODE_BINARY_ENV, process.env.PATH);
if (explicit) {
console.log(`Using OpenCode binary from OPENCODE_BINARY: ${explicit}`);
return { command: explicit, env: undefined };
}
console.warn(
`OPENCODE_BINARY path "${OPENCODE_BINARY_ENV}" not found. Falling back to search.`
);
}
return { command: 'opencode', env: undefined };
}
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;
syncToHmrState();
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 {
const parsed = JSON.parse(payloadText);
if (
parsed &&
typeof parsed === 'object' &&
typeof parsed.payload === 'object' &&
parsed.payload !== null
) {
return parsed.payload;
}
return parsed;
} 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 ?? 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 ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.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 === 'message.part.updated') {
const info = payload.properties?.info;
const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.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 ?? 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`);
}
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 envCfTunnel = process.env.OPENCHAMBER_TRY_CF_TUNNEL === 'true';
const options = { port: DEFAULT_PORT, uiPassword: envPassword, tryCfTunnel: envCfTunnel };
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;
}
if (optionName === 'try-cf-tunnel') {
options.tryCfTunnel = true;
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;
syncToHmrState();
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;
syncToHmrState();
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;
syncToHmrState();
}
openCodeApiPrefixDetected = false;
if (openCodeApiDetectionTimer) {
clearTimeout(openCodeApiDetectionTimer);
openCodeApiDetectionTimer = null;
}
openCodeApiDetectionPromise = null;
lastOpenCodeError = null;
openCodeProcess = await startOpenCode();
syncToHmrState();
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;
syncToHmrState();
}
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), {
method: 'GET',
headers: { Accept: 'application/json' }
}).catch((error) => error);
const agentPromise = fetch(buildOpenCodeUrl('/agent', normalizedPrefix), {
method: 'GET',
headers: { Accept: 'application/json' }
}).catch((error) => error);
const [configResult, agentResult] = await Promise.all([configPromise, agentPromise]);
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;
continue;
}
if (!agentResult.ok) {
lastError = new Error(`Agent endpoint responded with status ${agentResult.status}`);
continue;
}
await agentResult.json().catch(() => []);
const effectivePrefix = detectedPrefix !== null ? detectedPrefix : normalizedPrefix;
if (detectedPrefix === null) {
const agentPrefix = extractApiPrefixFromUrl(agentResult.url, '/agent');
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 === '/config/reload' ||
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;
syncToHmrState();
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;
}
if (cloudflareTunnelController) {
console.log('Stopping Cloudflare tunnel...');
cloudflareTunnelController.stop();
cloudflareTunnelController = 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 tryCfTunnel = options.tryCfTunnel === true;
const attachSignals = options.attachSignals !== false;
const onTunnelReady = typeof options.onTunnelReady === 'function' ? options.onTunnelReady : null;
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/config/skills') ||
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',
});
}
});
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/global/event', async (req, res) => {
if (!openCodeApiPrefixDetected) {
try {
await detectOpenCodeApiPrefix();
} catch {
// ignore detection failures
}
}
let targetUrl;
try {
const prefix = openCodeApiPrefixDetected ? openCodeApiPrefix : '';
targetUrl = new URL(buildOpenCodeUrl('/global/event', prefix));
} catch (error) {
return res.status(503).json({ error: 'OpenCode service unavailable' });
}
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 heartbeatInterval = setInterval(() => {
writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() });
}, 30000);
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 {
clearInterval(heartbeatInterval);
cleanup();
try {
res.end();
} catch {
// ignore
}
}
});
app.get('/api/event', async (req, res) => {
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 headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const directoryParam = Array.isArray(req.query.directory)
? req.query.directory[0]
: req.query.directory;
const resolvedDirectory = headerDirectory || directoryParam || null;
if (typeof resolvedDirectory === 'string' && resolvedDirectory.trim().length > 0) {
targetUrl.searchParams.set('directory', resolvedDirectory.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 heartbeatInterval = setInterval(() => {
writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() });
}, 30000);
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 {
clearInterval(heartbeatInterval);
cleanup();
try {
res.end();
} catch {
// ignore
}
}
});
app.get('/api/config/settings', async (_req, res) => {
try {
const settings = await readSettingsFromDiskMigrated();
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,
getAgentScope,
getAgentConfig,
createAgent,
updateAgent,
deleteAgent,
getCommandSources,
getCommandScope,
createCommand,
updateCommand,
deleteCommand,
AGENT_SCOPE,
COMMAND_SCOPE
} = await import('./lib/opencode-config.js');
app.get('/api/config/agents/:name', async (req, res) => {
try {
const agentName = req.params.name;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
const sources = getAgentSources(agentName, directory);
const scope = sources.md.exists
? sources.md.scope
: (sources.json.exists ? sources.json.scope : null);
res.json({
name: agentName,
sources: sources,
scope,
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.get('/api/config/agents/:name/config', async (req, res) => {
try {
const agentName = req.params.name;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
const configInfo = getAgentConfig(agentName, directory);
res.json(configInfo);
} catch (error) {
console.error('Failed to get agent config:', error);
res.status(500).json({ error: 'Failed to get agent configuration' });
}
});
app.post('/api/config/agents/:name', async (req, res) => {
try {
const agentName = req.params.name;
const { scope, ...config } = req.body;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
console.log('[Server] Creating agent:', agentName);
console.log('[Server] Config received:', JSON.stringify(config, null, 2));
console.log('[Server] Scope:', scope, 'Working directory:', directory);
createAgent(agentName, config, directory, scope);
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;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
console.log(`[Server] Updating agent: ${agentName}`);
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
console.log('[Server] Working directory:', directory);
updateAgent(agentName, updates, directory);
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;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
deleteAgent(agentName, directory);
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', async (req, res) => {
try {
const commandName = req.params.name;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
const sources = getCommandSources(commandName, directory);
const scope = sources.md.exists
? sources.md.scope
: (sources.json.exists ? sources.json.scope : null);
res.json({
name: commandName,
sources: sources,
scope,
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 { scope, ...config } = req.body;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
console.log('[Server] Creating command:', commandName);
console.log('[Server] Config received:', JSON.stringify(config, null, 2));
console.log('[Server] Scope:', scope, 'Working directory:', directory);
createCommand(commandName, config, directory, scope);
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;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
console.log(`[Server] Updating command: ${commandName}`);
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
console.log('[Server] Working directory:', directory);
updateCommand(commandName, updates, directory);
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;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
deleteCommand(commandName, directory);
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' });
}
});
// ============== SKILL ENDPOINTS ==============
const {
getSkillSources,
discoverSkills,
createSkill,
updateSkill,
deleteSkill,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
SKILL_SCOPE,
SKILL_DIR,
} = await import('./lib/opencode-config.js');
// List all discovered skills
app.get('/api/config/skills', async (req, res) => {
try {
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
const skills = discoverSkills(directory);
// Enrich with full sources info
const enrichedSkills = skills.map(skill => {
const sources = getSkillSources(skill.name, directory);
return {
...skill,
sources
};
});
res.json({ skills: enrichedSkills });
} catch (error) {
console.error('Failed to list skills:', error);
res.status(500).json({ error: 'Failed to list skills' });
}
});
// ============== SKILLS CATALOG + INSTALL ENDPOINTS ==============
const { getCuratedSkillsSources } = await import('./lib/skills-catalog/curated-sources.js');
const { getCacheKey, getCachedScan, setCachedScan } = await import('./lib/skills-catalog/cache.js');
const { parseSkillRepoSource } = await import('./lib/skills-catalog/source.js');
const { scanSkillsRepository } = await import('./lib/skills-catalog/scan.js');
const { installSkillsFromRepository } = await import('./lib/skills-catalog/install.js');
const { getProfiles, getProfile } = await import('./lib/git-identity-storage.js');
const listGitIdentitiesForResponse = () => {
try {
const profiles = getProfiles();
return profiles.map((p) => ({ id: p.id, name: p.name }));
} catch {
return [];
}
};
const resolveGitIdentity = (profileId) => {
if (!profileId) {
return null;
}
try {
const profile = getProfile(profileId);
const sshKey = profile?.sshKey;
if (typeof sshKey === 'string' && sshKey.trim()) {
return { sshKey: sshKey.trim() };
}
} catch {
// ignore
}
return null;
};
app.get('/api/config/skills/catalog', async (req, res) => {
try {
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
const refresh = String(req.query.refresh || '').toLowerCase() === 'true';
const curatedSources = getCuratedSkillsSources();
const settings = await readSettingsFromDisk();
const customSourcesRaw = sanitizeSkillCatalogs(settings.skillCatalogs) || [];
const customSources = customSourcesRaw.map((entry) => ({
id: entry.id,
label: entry.label,
description: entry.source,
source: entry.source,
defaultSubpath: entry.subpath,
gitIdentityId: entry.gitIdentityId,
}));
const sources = [...curatedSources, ...customSources];
const discovered = discoverSkills(directory);
const installedByName = new Map(discovered.map((s) => [s.name, s]));
const itemsBySource = {};
for (const src of sources) {
const parsed = parseSkillRepoSource(src.source);
if (!parsed.ok) {
itemsBySource[src.id] = [];
continue;
}
const effectiveSubpath = src.defaultSubpath || parsed.effectiveSubpath || null;
const cacheKey = getCacheKey({
normalizedRepo: parsed.normalizedRepo,
subpath: effectiveSubpath || '',
identityId: src.gitIdentityId || '',
});
let scanResult = !refresh ? getCachedScan(cacheKey) : null;
if (!scanResult) {
const scanned = await scanSkillsRepository({
source: src.source,
subpath: src.defaultSubpath,
defaultSubpath: src.defaultSubpath,
identity: resolveGitIdentity(src.gitIdentityId),
});
if (!scanned.ok) {
itemsBySource[src.id] = [];
continue;
}
scanResult = scanned;
setCachedScan(cacheKey, scanResult);
}
const items = (scanResult.items || []).map((item) => {
const installed = installedByName.get(item.skillName);
return {
sourceId: src.id,
...item,
gitIdentityId: src.gitIdentityId,
installed: installed
? { isInstalled: true, scope: installed.scope }
: { isInstalled: false },
};
});
itemsBySource[src.id] = items;
}
const sourcesForUi = sources.map(({ gitIdentityId, ...rest }) => rest);
res.json({ ok: true, sources: sourcesForUi, itemsBySource });
} catch (error) {
console.error('Failed to load skills catalog:', error);
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to load catalog' } });
}
});
app.post('/api/config/skills/scan', async (req, res) => {
try {
const { source, subpath, gitIdentityId } = req.body || {};
const identity = resolveGitIdentity(gitIdentityId);
const result = await scanSkillsRepository({
source,
subpath,
identity,
});
if (!result.ok) {
if (result.error?.kind === 'authRequired') {
return res.status(401).json({
ok: false,
error: {
...result.error,
identities: listGitIdentitiesForResponse(),
},
});
}
return res.status(400).json({ ok: false, error: result.error });
}
res.json({ ok: true, items: result.items });
} catch (error) {
console.error('Failed to scan skills repository:', error);
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to scan repository' } });
}
});
app.post('/api/config/skills/install', async (req, res) => {
try {
const {
source,
subpath,
gitIdentityId,
scope,
selections,
conflictPolicy,
conflictDecisions,
} = req.body || {};
let workingDirectory = null;
if (scope === 'project') {
const resolved = await resolveProjectDirectory(req);
if (!resolved.directory) {
return res.status(400).json({
ok: false,
error: { kind: 'invalidSource', message: resolved.error || 'Project installs require a directory parameter' },
});
}
workingDirectory = resolved.directory;
}
const identity = resolveGitIdentity(gitIdentityId);
const result = await installSkillsFromRepository({
source,
subpath,
identity,
scope,
workingDirectory,
userSkillDir: SKILL_DIR,
selections,
conflictPolicy,
conflictDecisions,
});
if (!result.ok) {
if (result.error?.kind === 'conflicts') {
return res.status(409).json({ ok: false, error: result.error });
}
if (result.error?.kind === 'authRequired') {
return res.status(401).json({
ok: false,
error: {
...result.error,
identities: listGitIdentitiesForResponse(),
},
});
}
return res.status(400).json({ ok: false, error: result.error });
}
res.json({ ok: true, installed: result.installed || [], skipped: result.skipped || [] });
} catch (error) {
console.error('Failed to install skills:', error);
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to install skills' } });
}
});
// Get single skill sources
app.get('/api/config/skills/:name', async (req, res) => {
try {
const skillName = req.params.name;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
const sources = getSkillSources(skillName, directory);
res.json({
name: skillName,
sources: sources,
scope: sources.md.scope,
source: sources.md.source,
exists: sources.md.exists
});
} catch (error) {
console.error('Failed to get skill sources:', error);
res.status(500).json({ error: 'Failed to get skill configuration metadata' });
}
});
// Get skill supporting file content
app.get('/api/config/skills/:name/files/*filePath', async (req, res) => {
try {
const skillName = req.params.name;
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
const sources = getSkillSources(skillName, directory);
if (!sources.md.exists || !sources.md.dir) {
return res.status(404).json({ error: 'Skill not found' });
}
const content = readSkillSupportingFile(sources.md.dir, filePath);
if (content === null) {
return res.status(404).json({ error: 'File not found' });
}
res.json({ path: filePath, content });
} catch (error) {
console.error('Failed to read skill file:', error);
res.status(500).json({ error: 'Failed to read skill file' });
}
});
// Create new skill
app.post('/api/config/skills/:name', async (req, res) => {
try {
const skillName = req.params.name;
const { scope, ...config } = req.body;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
console.log('[Server] Creating skill:', skillName);
console.log('[Server] Scope:', scope, 'Working directory:', directory);
createSkill(skillName, config, directory, scope);
// Skills are just files - OpenCode loads them on-demand, no restart needed
res.json({
success: true,
requiresReload: false,
message: `Skill ${skillName} created successfully`,
});
} catch (error) {
console.error('Failed to create skill:', error);
res.status(500).json({ error: error.message || 'Failed to create skill' });
}
});
// Update existing skill
app.patch('/api/config/skills/:name', async (req, res) => {
try {
const skillName = req.params.name;
const updates = req.body;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
console.log(`[Server] Updating skill: ${skillName}`);
console.log('[Server] Working directory:', directory);
updateSkill(skillName, updates, directory);
// Skills are just files - OpenCode loads them on-demand, no restart needed
res.json({
success: true,
requiresReload: false,
message: `Skill ${skillName} updated successfully`,
});
} catch (error) {
console.error('[Server] Failed to update skill:', error);
res.status(500).json({ error: error.message || 'Failed to update skill' });
}
});
// Update/create supporting file
app.put('/api/config/skills/:name/files/*filePath', async (req, res) => {
try {
const skillName = req.params.name;
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
const { content } = req.body;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
const sources = getSkillSources(skillName, directory);
if (!sources.md.exists || !sources.md.dir) {
return res.status(404).json({ error: 'Skill not found' });
}
writeSkillSupportingFile(sources.md.dir, filePath, content || '');
res.json({
success: true,
message: `File ${filePath} saved successfully`,
});
} catch (error) {
console.error('Failed to write skill file:', error);
res.status(500).json({ error: error.message || 'Failed to write skill file' });
}
});
// Delete supporting file
app.delete('/api/config/skills/:name/files/*filePath', async (req, res) => {
try {
const skillName = req.params.name;
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
const sources = getSkillSources(skillName, directory);
if (!sources.md.exists || !sources.md.dir) {
return res.status(404).json({ error: 'Skill not found' });
}
deleteSkillSupportingFile(sources.md.dir, filePath);
res.json({
success: true,
message: `File ${filePath} deleted successfully`,
});
} catch (error) {
console.error('Failed to delete skill file:', error);
res.status(500).json({ error: error.message || 'Failed to delete skill file' });
}
});
// Delete skill
app.delete('/api/config/skills/:name', async (req, res) => {
try {
const skillName = req.params.name;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
deleteSkill(skillName, directory);
// Skills are just files - OpenCode loads them on-demand, no restart needed
res.json({
success: true,
requiresReload: false,
message: `Skill ${skillName} deleted successfully`,
});
} catch (error) {
console.error('Failed to delete skill:', error);
res.status(500).json({ error: error.message || 'Failed to delete skill' });
}
});
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' });
}
});
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.put('/api/git/branches/rename', async (req, res) => {
const { renameBranch } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { oldName, newName } = req.body;
if (!oldName) {
return res.status(400).json({ error: 'oldName is required' });
}
if (!newName) {
return res.status(400).json({ error: 'newName is required' });
}
const result = await renameBranch(directory, oldName, newName);
res.json(result);
} catch (error) {
console.error('Failed to rename branch:', error);
res.status(500).json({ error: error.message || 'Failed to rename 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([]);
}
});
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, startPoint } = req.body;
if (!path || !branch) {
return res.status(400).json({ error: 'path and branch are required' });
}
const result = await addWorktree(directory, path, branch, { createBranch, startPoint });
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);
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 });
res.json({ success: true, path: resolvedPath });
} catch (error) {
console.error('Failed to create directory:', error);
res.status(500).json({ error: error.message || 'Failed to create directory' });
}
});
// Read file contents
app.get('/api/fs/read', async (req, res) => {
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
if (!filePath) {
return res.status(400).json({ error: 'Path is required' });
}
try {
const resolvedPath = path.resolve(normalizeDirectoryPath(filePath));
if (resolvedPath.includes('..')) {
return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
}
const stats = await fsPromises.stat(resolvedPath);
if (!stats.isFile()) {
return res.status(400).json({ error: 'Specified path is not a file' });
}
const content = await fsPromises.readFile(resolvedPath, 'utf8');
res.type('text/plain').send(content);
} catch (error) {
const err = error;
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return res.status(404).json({ error: 'File not found' });
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access to file denied' });
}
console.error('Failed to read file:', error);
res.status(500).json({ error: (error && error.message) || 'Failed to read file' });
}
});
// Write file contents
app.post('/api/fs/write', async (req, res) => {
const { path: filePath, content } = req.body || {};
if (!filePath || typeof filePath !== 'string') {
return res.status(400).json({ error: 'Path is required' });
}
if (typeof content !== 'string') {
return res.status(400).json({ error: 'Content is required' });
}
try {
const resolvedPath = path.resolve(normalizeDirectoryPath(filePath));
if (resolvedPath.includes('..')) {
return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
}
// Ensure parent directory exists
await fsPromises.mkdir(path.dirname(resolvedPath), { recursive: true });
await fsPromises.writeFile(resolvedPath, content, 'utf8');
res.json({ success: true, path: resolvedPath });
} catch (error) {
const err = error;
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access denied' });
}
console.error('Failed to write file:', error);
res.status(500).json({ error: (error && error.message) || 'Failed to write file' });
}
});
// Execute shell commands in a directory (for worktree setup)
// NOTE: This route supports background execution to avoid tying up browser connections.
const execJobs = new Map();
const EXEC_JOB_TTL_MS = 30 * 60 * 1000;
const COMMAND_TIMEOUT_MS = 60000;
const pruneExecJobs = () => {
const now = Date.now();
for (const [jobId, job] of execJobs.entries()) {
if (!job || typeof job !== 'object') {
execJobs.delete(jobId);
continue;
}
const updatedAt = typeof job.updatedAt === 'number' ? job.updatedAt : 0;
if (updatedAt && now - updatedAt > EXEC_JOB_TTL_MS) {
execJobs.delete(jobId);
}
}
};
const runCommandInDirectory = (shell, shellFlag, command, resolvedCwd) => {
return new Promise((resolve) => {
let stdout = '';
let stderr = '';
let timedOut = false;
const child = spawn(shell, [shellFlag, command], {
cwd: resolvedCwd,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
const timeout = setTimeout(() => {
timedOut = true;
try {
child.kill('SIGKILL');
} catch {
// ignore
}
}, COMMAND_TIMEOUT_MS);
child.stdout?.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
clearTimeout(timeout);
resolve({
command,
success: false,
exitCode: undefined,
stdout: stdout.trim(),
stderr: stderr.trim(),
error: (error && error.message) || 'Command execution failed',
});
});
child.on('close', (code, signal) => {
clearTimeout(timeout);
const exitCode = typeof code === 'number' ? code : undefined;
const base = {
command,
success: exitCode === 0 && !timedOut,
exitCode,
stdout: stdout.trim(),
stderr: stderr.trim(),
};
if (timedOut) {
resolve({
...base,
success: false,
error: `Command timed out after ${COMMAND_TIMEOUT_MS}ms` + (signal ? ` (${signal})` : ''),
});
return;
}
resolve(base);
});
});
};
const runExecJob = async (job) => {
job.status = 'running';
job.updatedAt = Date.now();
const results = [];
for (const command of job.commands) {
if (typeof command !== 'string' || !command.trim()) {
results.push({ command, success: false, error: 'Invalid command' });
continue;
}
try {
const result = await runCommandInDirectory(job.shell, job.shellFlag, command, job.resolvedCwd);
results.push(result);
} catch (error) {
results.push({
command,
success: false,
error: (error && error.message) || 'Command execution failed',
});
}
job.results = results;
job.updatedAt = Date.now();
}
job.results = results;
job.success = results.every((r) => r.success);
job.status = 'done';
job.finishedAt = Date.now();
job.updatedAt = Date.now();
};
app.post('/api/fs/exec', async (req, res) => {
const { commands, cwd, background } = req.body || {};
if (!Array.isArray(commands) || commands.length === 0) {
return res.status(400).json({ error: 'Commands array is required' });
}
if (!cwd || typeof cwd !== 'string') {
return res.status(400).json({ error: 'Working directory (cwd) is required' });
}
pruneExecJobs();
try {
const resolvedCwd = path.resolve(normalizeDirectoryPath(cwd));
const stats = await fsPromises.stat(resolvedCwd);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Specified cwd is not a directory' });
}
const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh');
const shellFlag = process.platform === 'win32' ? '/c' : '-c';
const jobId = crypto.randomUUID();
const job = {
jobId,
status: 'queued',
success: null,
commands,
resolvedCwd,
shell,
shellFlag,
results: [],
startedAt: Date.now(),
finishedAt: null,
updatedAt: Date.now(),
};
execJobs.set(jobId, job);
const isBackground = background === true;
if (isBackground) {
void runExecJob(job).catch((error) => {
job.status = 'done';
job.success = false;
job.results = Array.isArray(job.results) ? job.results : [];
job.results.push({
command: '',
success: false,
error: (error && error.message) || 'Command execution failed',
});
job.finishedAt = Date.now();
job.updatedAt = Date.now();
});
return res.status(202).json({
jobId,
status: 'running',
});
}
await runExecJob(job);
res.json({
jobId,
status: job.status,
success: job.success === true,
results: job.results,
});
} catch (error) {
console.error('Failed to execute commands:', error);
res.status(500).json({ error: (error && error.message) || 'Failed to execute commands' });
}
});
app.get('/api/fs/exec/:jobId', (req, res) => {
const jobId = typeof req.params?.jobId === 'string' ? req.params.jobId : '';
if (!jobId) {
return res.status(400).json({ error: 'Job id is required' });
}
pruneExecJobs();
const job = execJobs.get(jobId);
if (!job) {
return res.status(404).json({ error: 'Job not found' });
}
job.updatedAt = Date.now();
return res.json({
jobId: job.jobId,
status: job.status,
success: job.success === true,
results: Array.isArray(job.results) ? job.results : [],
});
});
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 validated = await validateDirectoryPath(requestedPath);
if (!validated.ok) {
return res.status(400).json({ error: validated.error });
}
const resolvedPath = validated.directory;
const currentSettings = await readSettingsFromDisk();
const existingProjects = sanitizeProjects(currentSettings.projects) || [];
const existing = existingProjects.find((project) => project.path === resolvedPath) || null;
const nextProjects = existing
? existingProjects
: [
...existingProjects,
{
id: crypto.randomUUID(),
path: resolvedPath,
addedAt: Date.now(),
lastOpenedAt: Date.now(),
},
];
const activeProjectId = existing ? existing.id : nextProjects[nextProjects.length - 1].id;
const updated = await persistSettings({
projects: nextProjects,
activeProjectId,
lastDirectory: resolvedPath,
});
res.json({
success: true,
restarted: false,
path: resolvedPath,
settings: updated,
});
} 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));
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));
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 ptyProviderPromise = null;
const getPtyProvider = async () => {
if (ptyProviderPromise) {
return ptyProviderPromise;
}
ptyProviderPromise = (async () => {
const isBunRuntime = typeof globalThis.Bun !== 'undefined';
if (isBunRuntime) {
try {
const bunPty = await import('bun-pty');
console.log('Using bun-pty for terminal sessions');
return { spawn: bunPty.spawn, backend: 'bun-pty' };
} catch (error) {
console.warn('bun-pty unavailable, falling back to node-pty');
}
}
try {
const nodePty = await import('node-pty');
console.log('Using node-pty for terminal sessions');
return { spawn: nodePty.spawn, backend: 'node-pty' };
} catch (error) {
console.error('Failed to load node-pty:', error && error.message ? error.message : error);
if (isBunRuntime) {
throw new Error('No PTY backend available. Install bun-pty or node-pty.');
}
throw new Error('node-pty is not available. Run: npm rebuild node-pty (or install Bun for bun-pty)');
}
})();
return ptyProviderPromise;
};
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 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 pty = await getPtyProvider();
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,
ptyBackend: pty.backend,
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');
const clientId = Math.random().toString(36).substring(7);
session.clients.add(clientId);
session.lastActivity = Date.now();
const runtime = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun';
const ptyBackend = session.ptyBackend || 'unknown';
res.write(`data: ${JSON.stringify({ type: 'connected', runtime, ptyBackend })}\n\n`);
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(`Terminal connected: session=${sessionId} client=${clientId} runtime=${runtime} pty=${ptyBackend}`);
});
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 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 pty = await getPtyProvider();
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,
ptyBackend: pty.backend,
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 });
});
try {
// Check if we can reuse an existing OpenCode process from a previous HMR cycle
syncFromHmrState();
if (await isOpenCodeProcessHealthy()) {
console.log(`[HMR] Reusing existing OpenCode process on port ${openCodePort}`);
} else {
// No healthy process, start fresh
if (ENV_CONFIGURED_OPENCODE_PORT) {
console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`);
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT);
} else {
openCodePort = null;
syncToHmrState();
}
lastOpenCodeError = null;
openCodeProcess = await startOpenCode();
syncToHmrState();
}
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, async () => {
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}`);
if (tryCfTunnel) {
console.log('\nInitializing Cloudflare Quick Tunnel...');
const cfCheck = await checkCloudflaredAvailable();
if (cfCheck.available) {
try {
const originUrl = `http://localhost:${activePort}`;
cloudflareTunnelController = await startCloudflareTunnel({ originUrl, port: activePort });
printTunnelWarning();
if (onTunnelReady) {
const tunnelUrl = cloudflareTunnelController.getPublicUrl();
if (tunnelUrl) {
onTunnelReady(tunnelUrl);
}
}
} catch (error) {
console.error(`Failed to start Cloudflare tunnel: ${error.message}`);
console.log('Continuing without tunnel...');
}
}
}
resolve();
});
});
if (attachSignals && !signalsAttached) {
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);
process.on('SIGQUIT', gracefulShutdown);
signalsAttached = true;
syncToHmrState();
}
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,
getTunnelUrl: () => cloudflareTunnelController?.getPublicUrl() ?? null,
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,
tryCfTunnel: cliOptions.tryCfTunnel,
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 };