import 'reflect-metadata'; import express from 'express'; import compression from 'compression'; import path from 'path'; import { spawn, spawnSync } from 'child_process'; import fs from 'fs'; import http from 'http'; import net from 'net'; import { fileURLToPath } from 'url'; import os from 'os'; import crypto from 'crypto'; import http2 from 'node:http2'; import { createUiAuth } from './lib/ui-auth/ui-auth.js'; import { createTunnelAuth } from './lib/opencode/tunnel-auth.js'; import { createManagedTunnelConfigRuntime } from './lib/tunnels/managed-config.js'; import { createTunnelProviderRegistry } from './lib/tunnels/registry.js'; import { createCloudflareTunnelProvider } from './lib/tunnels/providers/cloudflare.js'; import { createNgrokTunnelProvider } from './lib/tunnels/providers/ngrok.js'; import { createRequestSecurityRuntime } from './lib/security/request-security.js'; import { getUnauthenticatedLanErrorMessage, isNetworkExposedBindHost, isUnsafeUnauthenticatedLanAllowed, } from './lib/security/bind-host.js'; import { TUNNEL_MODE_MANAGED_LOCAL, TUNNEL_MODE_MANAGED_REMOTE, TUNNEL_MODE_QUICK, TUNNEL_PROVIDER_CLOUDFLARE, TunnelServiceError, isSupportedTunnelMode, normalizeOptionalPath, normalizeTunnelStartRequest, normalizeTunnelMode, normalizeTunnelProvider, } from './lib/tunnels/types.js'; import { prepareNotificationLastMessage } from './lib/notifications/index.js'; import { registerTtsRoutes } from './lib/tts/routes.js'; import { detectSayTtsCapability } from './lib/tts/capability-runtime.js'; import { createTerminalRuntime } from './lib/terminal/runtime.js'; import { createDictationRuntime } from './lib/dictation/runtime.js'; import { createGlobalUiEventBroadcaster, createGlobalMessageStreamHub, createMessageStreamWsRuntime, DEFAULT_UPSTREAM_STALL_TIMEOUT_MS, UPSTREAM_STALL_TIMEOUT_CONCURRENT_MS, } from './lib/event-stream/index.js'; import { createFsSearchRuntime as createFsSearchRuntimeFactory } from './lib/fs/search.js'; import { createOpenCodeLifecycleRuntime } from './lib/opencode/lifecycle.js'; import { createOpenCodeEnvRuntime } from './lib/opencode/env-runtime.js'; import { resolveOpenCodeEnvConfig } from './lib/opencode/env-config.js'; import { createHmrStateRuntime } from './lib/opencode/hmr-state-runtime.js'; import { createOpenCodeNetworkRuntime } from './lib/opencode/network-runtime.js'; import { createOpenCodeAuthStateRuntime } from './lib/opencode/auth-state-runtime.js'; import { createProjectDirectoryRuntime } from './lib/opencode/project-directory-runtime.js'; import { createSettingsNormalizationRuntime } from './lib/opencode/settings-normalization-runtime.js'; import { createSettingsHelpers } from './lib/opencode/settings-helpers.js'; import { createThemeRuntime } from './lib/opencode/theme-runtime.js'; import { createFeatureRoutesRuntime } from './lib/opencode/feature-routes-runtime.js'; import { parseServeCliOptions } from './lib/opencode/cli-options.js'; import { registerAuthAndAccessRoutes, registerCommonRequestMiddleware, registerServerStatusRoutes, } from './lib/opencode/core-routes.js'; import { registerOpenChamberRoutes } from './lib/opencode/openchamber-routes.js'; import { createServerUtilsRuntime } from './lib/opencode/server-utils-runtime.js'; import { createStaticRoutesRuntime } from './lib/opencode/static-routes-runtime.js'; import { createSettingsRuntime } from './lib/opencode/settings-runtime.js'; import { createOpenCodeResolutionRuntime } from './lib/opencode/opencode-resolution-runtime.js'; import { resolveOpenCodeUpgradeCapability } from './lib/opencode/upgrade-capability.js'; import { createBootstrapRuntime } from './lib/opencode/bootstrap-runtime.js'; import { createSessionRuntime } from './lib/opencode/session-runtime.js'; import { configureOpenCodeRuntimeProviders, resetOpenCodeRuntimeProviders } from './lib/small-model/runtime-providers.js'; import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js'; import { createSessionAssistRuntime } from './lib/session-assist/runtime.js'; import { createSessionGoalRuntime } from './lib/session-goal/runtime.js'; import { createContextObligatoryRuntime } from './lib/context-obligatory/runtime.js'; import { createLinearSessionStatusRuntime } from './lib/linear/status-runtime.js'; import { createSessionKnowledgeRuntime } from './lib/session-knowledge/runtime.js'; import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js'; import { createServerStartupRuntime } from './lib/opencode/server-startup-runtime.js'; import { createTunnelWiringRuntime } from './lib/opencode/tunnel-wiring-runtime.js'; import { createStartupPipelineRuntime } from './lib/opencode/startup-pipeline-runtime.js'; import { runCliEntryIfMain } from './lib/opencode/cli-entry-runtime.js'; import { registerNotificationRoutes } from './lib/notifications/routes.js'; import { createNotificationEmitterRuntime } from './lib/notifications/emitter-runtime.js'; import { createNotificationTriggerRuntime } from './lib/notifications/runtime.js'; import { createPushRuntime } from './lib/notifications/push-runtime.js'; import { createApnsRuntime } from './lib/notifications/apns-runtime.js'; import { createNotificationTemplateRuntime } from './lib/notifications/template-runtime.js'; import { createPermissionAutoAcceptRuntime } from './lib/permission-auto-accept/runtime.js'; import { createMessageQueueRuntime } from './lib/message-queue/runtime.js'; import { createAgentActivityRuntime, registerAgentActivityRoutes } from './lib/agent-activity/runtime.js'; import { createSessionSteerRuntime, registerSessionSteerRoutes } from './lib/session-steer/runtime.js'; import { createPlanGateRuntime, registerPlanGateRoutes } from './lib/plan-gate/runtime.js'; import { createHermesIntegrationRuntime } from './lib/hermes-integration/runtime.js'; import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js'; import { createProjectConfigRuntime } from './lib/projects/project-config.js'; import { migrateLegacyUserDirs } from './lib/data-dir-migration.js'; import { createProjectContextRuntime } from './lib/project-context/runtime.js'; import { createAgentMemoryRuntime } from './lib/agent-memory/runtime.js'; import { createAgentMemoryActions } from './lib/agent-memory/actions.js'; import { createMemoryProjectResolver } from './lib/agent-memory/project-resolution.js'; import { isAgentMemoryFeatureAvailable } from './lib/agent-memory/feature-flag.js'; import { resolvePrimaryWorktreeRoot } from './lib/git/service.js'; import { createRemoteClientAuthRuntime } from './lib/client-auth/remote-clients.js'; import { createClientPairingRuntime } from './lib/client-auth/pairing.js'; import { attachRealtimeProxy } from './lib/realtime-proxy.js'; import { createRelayService } from './lib/relay/service.js'; import { createRelayHostLock } from './lib/relay/host-lock.js'; import { createAgentToolRuntime } from './lib/agent-tool/runtime.js'; import { createBrowserControlBroker } from './lib/browser-control/broker.js'; import { createDevServerScanner } from './lib/dev-servers/routes.js'; import { createDevTunnelRuntime } from './lib/dev-tunnel/runtime.js'; import { registerBrowserControlRoutes } from './lib/browser-control/routes.js'; import { createSystemPromptRuntime } from './lib/system-prompt/runtime.js'; import { createMcpReconnectRuntime } from './lib/mcp-reconnect/runtime.js'; import { createOpenChamberSessionService } from './lib/openchamber-sessions/routes.js'; import { createScheduledTaskService } from './lib/scheduled-tasks/service.js'; import { createOpenChamberControlService } from './lib/openchamber-control/service.js'; import { OpenChamberControlError } from './lib/openchamber-control/error.js'; import webPush from 'web-push'; import { applyConnectAttemptTimeout } from './lib/network-defaults.js'; // Background CLI launches enter here in a fresh process, without CLI defaults. applyConnectAttemptTimeout(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const DEFAULT_PORT = 3000; const DESKTOP_NOTIFY_PREFIX = '[OpenChamberDesktopNotify] '; const uiNotificationClients = new Set(); const uiNotificationWsClients = new Set(); const uiOpenChamberEventClients = new Set(); const HEALTH_CHECK_INTERVAL = 15000; 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 TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS = 30 * 60 * 1000; const TUNNEL_BOOTSTRAP_TTL_MIN_MS = 60 * 1000; const TUNNEL_BOOTSTRAP_TTL_MAX_MS = 24 * 60 * 60 * 1000; const TUNNEL_SESSION_TTL_DEFAULT_MS = 8 * 60 * 60 * 1000; const TUNNEL_SESSION_TTL_MIN_MS = 5 * 60 * 1000; const TUNNEL_SESSION_TTL_MAX_MS = 30 * 24 * 60 * 60 * 1000; function headerIncludesEventStream(value) { if (typeof value === 'string') { return value.toLowerCase().includes('text/event-stream'); } if (Array.isArray(value)) { return value.some((entry) => typeof entry === 'string' && entry.toLowerCase().includes('text/event-stream')); } return false; } /** * SSE endpoint paths that must never be compressed by the compression middleware. * * The compression middleware filter runs before route handlers, so * `res.getHeader('Content-Type')` is still undefined at that point. * This means the Accept-header check alone is not sufficient for * non-standard clients (e.g. curl, fetch) that omit Accept. * Path-based exclusion acts as a deterministic fallback. */ const SSE_PATH_PREFIXES = [ '/api/event', '/api/global/event', '/api/notifications/stream', '/api/openchamber/events', '/api/openchamber/realtime-proxy/sse', '/api/openchamber/agent-activity', ]; function shouldSkipCompression(req, res) { if (process.env.OPENCHAMBER_RUNTIME === 'desktop') { return true; } if (headerIncludesEventStream(req.headers.accept)) { return true; } const pathname = req.path || req.url || ''; if ((pathname === '/api' || pathname.startsWith('/api/')) && shouldSkipApiCompression()) { return true; } for (const prefix of SSE_PATH_PREFIXES) { if (pathname === prefix) { return true; } } return headerIncludesEventStream(res.getHeader('Content-Type')); } const OPENCHAMBER_VERSION = (() => { try { const packagePath = path.resolve(__dirname, '..', 'package.json'); const raw = fs.readFileSync(packagePath, 'utf8'); const pkg = JSON.parse(raw); if (pkg && typeof pkg.version === 'string' && pkg.version.trim().length > 0) { return pkg.version.trim(); } } catch { } return 'unknown'; })(); const isEnvFlagEnabled = (value) => { if (value === true || value === 1) return true; if (typeof value !== 'string') return false; const normalized = value.trim().toLowerCase(); return normalized === '1' || normalized === 'true'; }; const isEnvFlagDisabled = (value) => { if (value === false || value === 0) return true; if (typeof value !== 'string') return false; const normalized = value.trim().toLowerCase(); return normalized === '0' || normalized === 'false'; }; const shouldSkipApiCompression = () => { if (isEnvFlagEnabled(process.env.OPENCHAMBER_SKIP_API_COMPRESSION)) return true; if (isEnvFlagEnabled(process.env.OPENCHAMBER_COMPRESS_API)) return false; if (isEnvFlagDisabled(process.env.OPENCHAMBER_COMPRESS_API)) return true; return process.env.OPENCHAMBER_RUNTIME === 'desktop'; }; const OPENCHAMBER_VERBOSE_REQUEST_LOGS = isEnvFlagEnabled(process.env.OPENCHAMBER_VERBOSE_REQUEST_LOGS); const PLAN_MODE_EXPERIMENT_ENABLED = isEnvFlagEnabled(process.env.OPENCODE_EXPERIMENTAL_PLAN_MODE) || isEnvFlagEnabled(process.env.OPENCODE_EXPERIMENTAL); const fsPromises = fs.promises; const settingsNormalizationRuntime = createSettingsNormalizationRuntime({ os, path, processLike: process, realpathSync: fs.realpathSync, tunnelBootstrapTtlDefaultMs: TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS, tunnelBootstrapTtlMinMs: TUNNEL_BOOTSTRAP_TTL_MIN_MS, tunnelBootstrapTtlMaxMs: TUNNEL_BOOTSTRAP_TTL_MAX_MS, tunnelSessionTtlDefaultMs: TUNNEL_SESSION_TTL_DEFAULT_MS, tunnelSessionTtlMinMs: TUNNEL_SESSION_TTL_MIN_MS, tunnelSessionTtlMaxMs: TUNNEL_SESSION_TTL_MAX_MS, }); const normalizeDirectoryPath = (...args) => settingsNormalizationRuntime.normalizeDirectoryPath(...args); const normalizePathForPersistence = (...args) => settingsNormalizationRuntime.normalizePathForPersistence(...args); const normalizeSettingsPaths = (...args) => settingsNormalizationRuntime.normalizeSettingsPaths(...args); const normalizeTunnelBootstrapTtlMs = (...args) => settingsNormalizationRuntime.normalizeTunnelBootstrapTtlMs(...args); const normalizeTunnelSessionTtlMs = (...args) => settingsNormalizationRuntime.normalizeTunnelSessionTtlMs(...args); const normalizeManagedRemoteTunnelHostname = (...args) => settingsNormalizationRuntime.normalizeManagedRemoteTunnelHostname(...args); const normalizeManagedRemoteTunnelPresets = (...args) => settingsNormalizationRuntime.normalizeManagedRemoteTunnelPresets(...args); const normalizeManagedRemoteTunnelPresetTokens = (...args) => settingsNormalizationRuntime.normalizeManagedRemoteTunnelPresetTokens(...args); const isUnsafeSkillRelativePath = (...args) => settingsNormalizationRuntime.isUnsafeSkillRelativePath(...args); const normalizeStringArray = (...args) => settingsNormalizationRuntime.normalizeStringArray(...args); const sanitizeModelRefs = (...args) => settingsNormalizationRuntime.sanitizeModelRefs(...args); const sanitizeSkillCatalogs = (...args) => settingsNormalizationRuntime.sanitizeSkillCatalogs(...args); const sanitizeProjects = (...args) => settingsNormalizationRuntime.sanitizeProjects(...args); // Every OpenChamber-owned file and folder hangs off one root: the default // `~/.config/openchamber`, or `OPENCHAMBER_DATA_DIR` when set. The user // folders (`projects/`, `themes/`, `speech-models/`) are copied into a custom // root once at startup (`migrateLegacyUserDirs`), because they used to ignore // the variable. const OPENCHAMBER_DEFAULT_CONFIG_ROOT = path.join(os.homedir(), '.config', 'openchamber'); const OPENCHAMBER_USER_CONFIG_ROOT = process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) : OPENCHAMBER_DEFAULT_CONFIG_ROOT; const OPENCHAMBER_USER_THEMES_DIR = path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'themes'); const OPENCHAMBER_PROJECTS_CONFIG_DIR = path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'projects'); // OPENCHAMBER_CHATS_DIR relocates managed chat worktrees — needed when the // OpenCode server runs as a separate user that cannot traverse $HOME. const OPENCHAMBER_CHATS_DIR = process.env.OPENCHAMBER_CHATS_DIR && process.env.OPENCHAMBER_CHATS_DIR.trim() ? path.resolve(process.env.OPENCHAMBER_CHATS_DIR.trim()) : path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'chats'); const MAX_THEME_JSON_BYTES = 512 * 1024; const themeRuntime = createThemeRuntime({ fsPromises, path, themesDir: OPENCHAMBER_USER_THEMES_DIR, maxThemeJsonBytes: MAX_THEME_JSON_BYTES, logger: console, }); const readCustomThemesFromDisk = (...args) => themeRuntime.readCustomThemesFromDisk(...args); let notificationTemplateRuntime = null; let agentToolRuntime = null; let systemPromptRuntime = null; let mcpReconnectRuntime = null; const createTimeoutSignal = (...args) => notificationTemplateRuntime.createTimeoutSignal(...args); const formatProjectLabel = (...args) => notificationTemplateRuntime.formatProjectLabel(...args); const resolveNotificationTemplate = (...args) => notificationTemplateRuntime.resolveNotificationTemplate(...args); const shouldApplyResolvedTemplateMessage = (...args) => notificationTemplateRuntime.shouldApplyResolvedTemplateMessage(...args); const fetchFreeZenModels = (...args) => notificationTemplateRuntime.fetchFreeZenModels(...args); const extractTextFromParts = (...args) => notificationTemplateRuntime.extractTextFromParts(...args); const extractLastMessageText = (...args) => notificationTemplateRuntime.extractLastMessageText(...args); const fetchLastAssistantMessageText = (...args) => notificationTemplateRuntime.fetchLastAssistantMessageText(...args); const maybeCacheSessionInfoFromEvent = (...args) => notificationTemplateRuntime.maybeCacheSessionInfoFromEvent(...args); const buildTemplateVariables = (...args) => notificationTemplateRuntime.buildTemplateVariables(...args); const getCachedZenModels = (...args) => notificationTemplateRuntime.getCachedZenModels(...args); const OPENCHAMBER_DATA_DIR = OPENCHAMBER_USER_CONFIG_ROOT; const SETTINGS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'settings.json'); const PUSH_SUBSCRIPTIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'push-subscriptions.json'); const APNS_TOKENS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'apns-tokens.json'); const REMOTE_CLIENTS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'remote-clients.json'); const CLIENT_PAIRING_SESSIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'client-pairing-sessions.json'); const CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-managed-remote-tunnels.json'); const CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-named-tunnels.json'); const CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION = 1; const managedTunnelConfigRuntime = createManagedTunnelConfigRuntime({ fsPromises, path, normalizeManagedRemoteTunnelHostname, normalizeManagedRemoteTunnelPresets, constants: { CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH, CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH, CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, }, }); const readManagedRemoteTunnelConfigFromDisk = (...args) => managedTunnelConfigRuntime.readManagedRemoteTunnelConfigFromDisk(...args); const syncManagedRemoteTunnelConfigWithPresets = (...args) => managedTunnelConfigRuntime.syncManagedRemoteTunnelConfigWithPresets(...args); const upsertManagedRemoteTunnelToken = (...args) => managedTunnelConfigRuntime.upsertManagedRemoteTunnelToken(...args); const resolveManagedRemoteTunnelToken = (...args) => managedTunnelConfigRuntime.resolveManagedRemoteTunnelToken(...args); const settingsHelpers = createSettingsHelpers({ normalizePathForPersistence, normalizeDirectoryPath, normalizeTunnelBootstrapTtlMs, normalizeTunnelSessionTtlMs, normalizeTunnelProvider, normalizeTunnelMode, normalizeOptionalPath, normalizeManagedRemoteTunnelHostname, normalizeManagedRemoteTunnelPresets, normalizeManagedRemoteTunnelPresetTokens, normalizeStringArray, sanitizeModelRefs, sanitizeSkillCatalogs, sanitizeProjects, }); const normalizePwaAppName = (...args) => settingsHelpers.normalizePwaAppName(...args); const normalizePwaOrientation = (...args) => settingsHelpers.normalizePwaOrientation(...args); const sanitizeSettingsUpdate = (...args) => settingsHelpers.sanitizeSettingsUpdate(...args); const mergePersistedSettings = (...args) => settingsHelpers.mergePersistedSettings(...args); const formatSettingsResponse = (...args) => settingsHelpers.formatSettingsResponse(...args); const projectDirectoryRuntime = createProjectDirectoryRuntime({ fsPromises, path, normalizeDirectoryPath, getReadSettingsFromDiskMigrated: () => readSettingsFromDiskMigrated, sanitizeProjects, }); const resolveDirectoryCandidate = (...args) => projectDirectoryRuntime.resolveDirectoryCandidate(...args); const validateDirectoryPath = (...args) => projectDirectoryRuntime.validateDirectoryPath(...args); const resolveProjectDirectory = (...args) => projectDirectoryRuntime.resolveProjectDirectory(...args); const resolveOptionalProjectDirectory = (...args) => projectDirectoryRuntime.resolveOptionalProjectDirectory(...args); const settingsRuntime = createSettingsRuntime({ fsPromises, path, crypto, SETTINGS_FILE_PATH, sanitizeProjects, sanitizeSettingsUpdate, mergePersistedSettings, normalizeSettingsPaths, normalizeStringArray, formatSettingsResponse, resolveDirectoryCandidate, normalizeManagedRemoteTunnelHostname, normalizeManagedRemoteTunnelPresets, normalizeManagedRemoteTunnelPresetTokens, syncManagedRemoteTunnelConfigWithPresets, upsertManagedRemoteTunnelToken, }); const readSettingsFromDiskMigrated = (...args) => settingsRuntime.readSettingsFromDiskMigrated(...args); const readSettingsFromDisk = (...args) => settingsRuntime.readSettingsFromDisk(...args); const readSettingsFromDiskStrict = (...args) => settingsRuntime.readSettingsFromDiskStrict(...args); const writeSettingsToDisk = (...args) => settingsRuntime.writeSettingsToDisk(...args); const persistSettings = (...args) => settingsRuntime.persistSettings(...args); const requestSecurityRuntime = createRequestSecurityRuntime({ readSettingsFromDiskMigrated, }); const getUiSessionTokenFromRequest = (...args) => requestSecurityRuntime.getUiSessionTokenFromRequest(...args); const pushRuntime = createPushRuntime({ fsPromises, path, webPush, PUSH_SUBSCRIPTIONS_FILE_PATH, readSettingsFromDiskMigrated, writeSettingsToDisk, }); const getOrCreateVapidKeys = (...args) => pushRuntime.getOrCreateVapidKeys(...args); const addOrUpdatePushSubscription = (...args) => pushRuntime.addOrUpdatePushSubscription(...args); const removePushSubscription = (...args) => pushRuntime.removePushSubscription(...args); const sendPushToAllUiSessions = (...args) => pushRuntime.sendPushToAllUiSessions(...args); // Set once the notification trigger runtime exists (declared later). When a UI // client reports it became visible, reset the native push badge set — the same // moment the device zeroes its icon badge on becomeActive, keeping them in sync. let clearPendingPushBadge = () => {}; const updateUiVisibility = (token, visible, platform) => { if (visible === true) clearPendingPushBadge(); return pushRuntime.updateUiVisibility(token, visible, platform); }; const isAnyUiVisible = (...args) => pushRuntime.isAnyUiVisible(...args); const isAnyInteractiveClientVisible = (...args) => pushRuntime.isAnyInteractiveClientVisible(...args); const isUiVisible = (...args) => pushRuntime.isUiVisible(...args); const ensurePushInitialized = (...args) => pushRuntime.ensurePushInitialized(...args); const setPushInitialized = (...args) => pushRuntime.setPushInitialized(...args); const apnsRuntime = createApnsRuntime({ fsPromises, path, crypto, http2, APNS_TOKENS_FILE_PATH, readSettingsFromDiskMigrated, writeSettingsToDisk, readSettingsStrict: readSettingsFromDiskStrict, }); const addOrUpdateApnsToken = (...args) => apnsRuntime.addOrUpdateApnsToken(...args); const removeApnsToken = (...args) => apnsRuntime.removeApnsToken(...args); const sendApnsToAllUiSessions = (...args) => apnsRuntime.sendApnsToAllUiSessions(...args); const TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW = 128; const TERMINAL_INPUT_WS_REBIND_WINDOW_MS = 60 * 1000; const TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS = 15 * 1000; const rejectWebSocketUpgrade = (...args) => requestSecurityRuntime.rejectWebSocketUpgrade(...args); const isRequestOriginAllowed = (...args) => requestSecurityRuntime.isRequestOriginAllowed(...args); const notificationEmitterRuntime = createNotificationEmitterRuntime({ process, getDesktopNotifyEnabled: () => ENV_DESKTOP_NOTIFY, desktopNotifyPrefix: DESKTOP_NOTIFY_PREFIX, getUiNotificationClients: () => uiNotificationClients, getBroadcastGlobalUiEvent: () => broadcastGlobalUiEvent, }); const writeSseEvent = (...args) => notificationEmitterRuntime.writeSseEvent(...args); const emitDesktopNotification = (...args) => notificationEmitterRuntime.emitDesktopNotification(...args); const broadcastGlobalUiEvent = createGlobalUiEventBroadcaster({ sseClients: uiNotificationClients, wsClients: uiNotificationWsClients, writeSseEvent, }); const broadcastUiNotification = (...args) => notificationEmitterRuntime.broadcastUiNotification(...args); const sessionRuntime = createSessionRuntime({ writeSseEvent, getNotificationClients: () => uiNotificationClients, broadcastEvent: broadcastGlobalUiEvent, }); const getActiveSessionCount = () => sessionRuntime.getActiveSessionCount(); const getUpstreamStallTimeoutMs = () => ( getActiveSessionCount() > 1 ? UPSTREAM_STALL_TIMEOUT_CONCURRENT_MS : DEFAULT_UPSTREAM_STALL_TIMEOUT_MS ); const movedUserDirs = await migrateLegacyUserDirs({ fsPromises, path, dataDir: OPENCHAMBER_USER_CONFIG_ROOT, legacyRoot: OPENCHAMBER_DEFAULT_CONFIG_ROOT, warn: (message) => console.warn(`[data-dir] ${message}`), }); if (movedUserDirs.length > 0) { console.log(`[data-dir] Copied ${movedUserDirs.join(', ')} into ${OPENCHAMBER_USER_CONFIG_ROOT}`); } const projectConfigRuntime = createProjectConfigRuntime({ fsPromises, path, projectsDirPath: OPENCHAMBER_PROJECTS_CONFIG_DIR, }); const projectContextRuntime = createProjectContextRuntime({ fsPromises, path, projectsDirPath: OPENCHAMBER_PROJECTS_CONFIG_DIR, resolveSharedPlansDir: (projectId) => projectConfigRuntime.resolveSharedPlansDir(projectId), }); const agentMemoryRuntime = createAgentMemoryRuntime({ fsPromises, path, projectsDirPath: OPENCHAMBER_PROJECTS_CONFIG_DIR, userConfigRoot: OPENCHAMBER_USER_CONFIG_ROOT, }); /** * One switch for everything memory-related. It gates the tool, these routes, * and the session index alike, so turning memory off leaves nothing behind * that still reads or writes the store. */ const isAgentMemoryEnabled = async () => { // The feature gate comes first: unreleased means absent, not merely switched // off, so no stored setting can bring it back. if (!isAgentMemoryFeatureAvailable()) { return false; } const settings = await readSettingsFromDiskMigrated().catch(() => null); return settings?.agentMemoryToolEnabled === true; }; // HMR-persistent state via globalThis // These values survive Vite HMR reloads to prevent zombie OpenCode processes const hmrStateRuntime = createHmrStateRuntime({ globalThisLike: globalThis, os, processLike: process, stateKey: '__openchamberHmrState', }); const hmrState = hmrStateRuntime.getOrCreateHmrState(); hmrStateRuntime.ensureUserProvidedOpenCodePassword(hmrState); // Non-HMR state (safe to reset on reload) let healthCheckInterval = null; let server = null; let expressApp = null; let currentRestartPromise = null; let isRestartingOpenCode = false; let openCodeApiPrefix = ''; let openCodeApiPrefixDetected = true; let openCodeApiDetectionTimer = null; let lastOpenCodeError = null; let lastOpenCodeLaunchDiagnostics = null; let lastOpenCodeHealthFailure = null; let lastManagedOpenCodeProcess = null; let lastOpenCodeRestartDiagnostics = null; let isOpenCodeReady = false; let openCodeNotReadySince = 0; let isExternalOpenCode = false; let exitOnShutdown = true; let uiAuthController = null; let activeTunnelController = null; let globalWatcherStartPromise = null; const tunnelProviderRegistry = createTunnelProviderRegistry([ createCloudflareTunnelProvider(), createNgrokTunnelProvider(), ]); tunnelProviderRegistry.seal(); const tunnelAuthController = createTunnelAuth(); let runtimeManagedRemoteTunnelToken = ''; let runtimeManagedRemoteTunnelHostname = ''; let terminalRuntime = null; let dictationRuntime = null; let messageStreamRuntime = null; const userProvidedOpenCodePassword = hmrStateRuntime.getUserProvidedOpenCodePassword(hmrState); const initialOpenCodeAuthState = hmrStateRuntime.resolveOpenCodeAuthFromState({ hmrState, userProvidedOpenCodePassword, }); let openCodeAuthPassword = initialOpenCodeAuthState.openCodeAuthPassword; let openCodeAuthSource = initialOpenCodeAuthState.openCodeAuthSource; // Sync helper - call after modifying any HMR state variable const syncToHmrState = () => { hmrStateRuntime.syncStateFromRuntime(hmrState, { openCodeProcess, openCodePort, openCodeBaseUrl, isShuttingDown, signalsAttached, openCodeWorkingDirectory, openCodeAuthPassword, openCodeAuthSource, }); }; // Sync helper - call to restore state from HMR (e.g., on module reload) const syncFromHmrState = () => { const restored = hmrStateRuntime.restoreRuntimeFromState({ hmrState, userProvidedOpenCodePassword, }); openCodeProcess = restored.openCodeProcess; openCodePort = restored.openCodePort; openCodeBaseUrl = restored.openCodeBaseUrl; isShuttingDown = restored.isShuttingDown; signalsAttached = restored.signalsAttached; openCodeWorkingDirectory = restored.openCodeWorkingDirectory; openCodeAuthPassword = restored.openCodeAuthPassword; openCodeAuthSource = restored.openCodeAuthSource; }; // 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 openCodeBaseUrl = hmrState.openCodeBaseUrl ?? null; let isShuttingDown = hmrState.isShuttingDown; let signalsAttached = hmrState.signalsAttached; let openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory; const { configuredOpenCodePort: ENV_CONFIGURED_OPENCODE_PORT, configuredOpenCodeHost: ENV_CONFIGURED_OPENCODE_HOST, effectivePort: ENV_EFFECTIVE_PORT, configuredOpenCodeHostname: ENV_CONFIGURED_OPENCODE_HOSTNAME, } = resolveOpenCodeEnvConfig({ env: process.env, logger: console, }); const ENV_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START === 'true' || process.env.OPENCHAMBER_SKIP_OPENCODE_START === 'true'; const ENV_DESKTOP_NOTIFY = (() => { if (process.env.OPENCHAMBER_DESKTOP_NOTIFY === 'true') { return true; } if (process.env.OPENCHAMBER_RUNTIME === 'desktop') { return true; } const argv0 = typeof process.argv?.[0] === 'string' ? process.argv[0] : ''; const argv1 = typeof process.argv?.[1] === 'string' ? process.argv[1] : ''; return /openchamber-server/i.test(argv0) || /openchamber-server/i.test(argv1); })(); const openCodeAuthStateRuntime = createOpenCodeAuthStateRuntime({ crypto, process, getAuthPassword: () => openCodeAuthPassword, setAuthPassword: (value) => { openCodeAuthPassword = value; }, getAuthSource: () => openCodeAuthSource, setAuthSource: (value) => { openCodeAuthSource = value; }, getUserProvidedPassword: () => userProvidedOpenCodePassword, syncToHmrState, }); const getOpenCodeAuthHeaders = (...args) => openCodeAuthStateRuntime.getOpenCodeAuthHeaders(...args); const isOpenCodeConnectionSecure = (...args) => openCodeAuthStateRuntime.isOpenCodeConnectionSecure(...args); const ensureLocalOpenCodeServerPassword = (...args) => openCodeAuthStateRuntime.ensureLocalOpenCodeServerPassword(...args); const openCodeNetworkState = {}; Object.defineProperties(openCodeNetworkState, { openCodePort: { get: () => openCodePort, set: (value) => { openCodePort = value; } }, openCodeBaseUrl: { get: () => openCodeBaseUrl, set: (value) => { openCodeBaseUrl = value; } }, openCodeApiPrefix: { get: () => openCodeApiPrefix, set: (value) => { openCodeApiPrefix = value; } }, openCodeApiPrefixDetected: { get: () => openCodeApiPrefixDetected, set: (value) => { openCodeApiPrefixDetected = value; } }, openCodeApiDetectionTimer: { get: () => openCodeApiDetectionTimer, set: (value) => { openCodeApiDetectionTimer = value; } }, }); const openCodeNetworkRuntime = createOpenCodeNetworkRuntime({ state: openCodeNetworkState, getOpenCodeAuthHeaders, configuredOpenCodeHostname: ENV_CONFIGURED_OPENCODE_HOSTNAME, }); const waitForReady = (...args) => openCodeNetworkRuntime.waitForReady(...args); const normalizeApiPrefix = (...args) => openCodeNetworkRuntime.normalizeApiPrefix(...args); const setDetectedOpenCodeApiPrefix = (...args) => openCodeNetworkRuntime.setDetectedOpenCodeApiPrefix(...args); const buildOpenCodeUrl = (...args) => openCodeNetworkRuntime.buildOpenCodeUrl(...args); const ensureOpenCodeApiPrefix = (...args) => openCodeNetworkRuntime.ensureOpenCodeApiPrefix(...args); const scheduleOpenCodeApiDetection = (...args) => openCodeNetworkRuntime.scheduleOpenCodeApiDetection(...args); // Plugin-registered providers exist only inside the running OpenCode process. // Small-model callers resolve them through this connection; without it they // stay on the file-based resolution and plugin models remain unreachable. configureOpenCodeRuntimeProviders({ buildOpenCodeUrl, getOpenCodeAuthHeaders }); const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix( process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || '' ); if (ENV_CONFIGURED_API_PREFIX && ENV_CONFIGURED_API_PREFIX !== '') { console.warn('Ignoring configured OpenCode API prefix; API runs at root.'); } let cachedLoginShellEnvSnapshot; let resolvedOpencodeBinary = null; let resolvedOpencodeBinarySource = null; let resolvedNodeBinary = null; let resolvedBunBinary = null; let resolvedGitBinary = null; let useWslForOpencode = false; let resolvedWslBinary = null; let resolvedWslOpencodePath = null; let resolvedWslDistro = null; const openCodeEnvState = {}; Object.defineProperties(openCodeEnvState, { cachedLoginShellEnvSnapshot: { get: () => cachedLoginShellEnvSnapshot, set: (value) => { cachedLoginShellEnvSnapshot = value; } }, resolvedOpencodeBinary: { get: () => resolvedOpencodeBinary, set: (value) => { resolvedOpencodeBinary = value; } }, resolvedOpencodeBinarySource: { get: () => resolvedOpencodeBinarySource, set: (value) => { resolvedOpencodeBinarySource = value; } }, resolvedNodeBinary: { get: () => resolvedNodeBinary, set: (value) => { resolvedNodeBinary = value; } }, resolvedBunBinary: { get: () => resolvedBunBinary, set: (value) => { resolvedBunBinary = value; } }, resolvedGitBinary: { get: () => resolvedGitBinary, set: (value) => { resolvedGitBinary = value; } }, useWslForOpencode: { get: () => useWslForOpencode, set: (value) => { useWslForOpencode = value; } }, resolvedWslBinary: { get: () => resolvedWslBinary, set: (value) => { resolvedWslBinary = value; } }, resolvedWslOpencodePath: { get: () => resolvedWslOpencodePath, set: (value) => { resolvedWslOpencodePath = value; } }, resolvedWslDistro: { get: () => resolvedWslDistro, set: (value) => { resolvedWslDistro = value; } }, }); const openCodeEnvRuntime = createOpenCodeEnvRuntime({ state: openCodeEnvState, normalizeDirectoryPath, readSettingsFromDiskMigrated, }); const applyLoginShellEnvSnapshot = (...args) => openCodeEnvRuntime.applyLoginShellEnvSnapshot(...args); const getLoginShellEnvSnapshot = (...args) => openCodeEnvRuntime.getLoginShellEnvSnapshot(...args); const ensureOpencodeCliEnv = (...args) => openCodeEnvRuntime.ensureOpencodeCliEnv(...args); const applyOpencodeBinaryFromSettings = (...args) => openCodeEnvRuntime.applyOpencodeBinaryFromSettings(...args); const resolveOpencodeCliPath = (...args) => openCodeEnvRuntime.resolveOpencodeCliPath(...args); const isBundledOpenCodeCliPath = (...args) => openCodeEnvRuntime.isBundledOpenCodeCliPath(...args); const isExecutable = (...args) => openCodeEnvRuntime.isExecutable(...args); const searchPathFor = (...args) => openCodeEnvRuntime.searchPathFor(...args); const resolveGitBinaryForSpawn = (...args) => openCodeEnvRuntime.resolveGitBinaryForSpawn(...args); const resolveManagedOpenCodeLaunchSpec = (...args) => openCodeEnvRuntime.resolveManagedOpenCodeLaunchSpec(...args); const clearResolvedOpenCodeBinary = (...args) => openCodeEnvRuntime.clearResolvedOpenCodeBinary(...args); const openCodeResolutionRuntime = createOpenCodeResolutionRuntime({ path, resolveOpencodeCliPath, applyOpencodeBinaryFromSettings, ensureOpencodeCliEnv, resolveManagedOpenCodeLaunchSpec, getResolvedState: () => ({ resolvedOpencodeBinary, resolvedOpencodeBinarySource, useWslForOpencode, resolvedWslBinary, resolvedWslOpencodePath, resolvedWslDistro, resolvedNodeBinary, resolvedBunBinary, }), setResolvedOpencodeBinarySource: (value) => { resolvedOpencodeBinarySource = value; }, }); const getOpenCodeResolutionSnapshot = (...args) => openCodeResolutionRuntime.getOpenCodeResolutionSnapshot(...args); applyLoginShellEnvSnapshot(); notificationTemplateRuntime = createNotificationTemplateRuntime({ readSettingsFromDisk, persistSettings, buildOpenCodeUrl, getOpenCodeAuthHeaders, resolveGitBinaryForSpawn, }); const notificationTriggerRuntime = createNotificationTriggerRuntime({ readSettingsFromDisk, prepareNotificationLastMessage, buildTemplateVariables, extractLastMessageText, fetchLastAssistantMessageText, resolveNotificationTemplate, shouldApplyResolvedTemplateMessage, emitDesktopNotification, broadcastUiNotification, sendPushToAllUiSessions, sendApnsToAllUiSessions, isAnyInteractiveClientVisible, buildOpenCodeUrl, getOpenCodeAuthHeaders, }); const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSendPushForTrigger(...args); const setAutoAcceptSession = (sessionId, enabled) => permissionAutoAcceptRuntime.setSessionPolicy(sessionId, enabled); clearPendingPushBadge = () => notificationTriggerRuntime.clearPendingPushBadge(); const sessionAssistRuntime = createSessionAssistRuntime({ buildOpenCodeUrl, getOpenCodeAuthHeaders, getSmallModelService: async () => import('./lib/small-model/index.js'), }); const sessionGoalRuntime = createSessionGoalRuntime({ buildOpenCodeUrl, getOpenCodeAuthHeaders, getSmallModelService: async () => import('./lib/small-model/index.js'), emitGoalNotification: async ({ sessionId, directory, status, goal }) => { // The goal settle notification replaces the per-turn ready notifications // (suppressed while the goal is active) — so it obeys the same toggle. const settings = await readSettingsFromDisk(); if (settings.notifyOnCompletion === false) { return; } const title = status === 'complete' ? 'Goal complete' : (status === 'budgetLimited' ? 'Goal reached its token budget' : 'Goal blocked'); const detail = goal?.statusReason && goal.statusReason !== 'verified by audit' && goal.statusReason !== 'reported by agent' ? goal.statusReason : (goal?.note || ''); const objective = typeof goal?.objective === 'string' ? goal.objective.slice(0, 140) : ''; const notificationPayload = { title, body: [objective, detail].filter(Boolean).join(' — ').slice(0, 240), tag: `goal-${sessionId}`, kind: 'goal', sessionId, directory, }; const desktopNotificationDelivered = emitDesktopNotification(notificationPayload); broadcastUiNotification(notificationPayload, { desktopNotificationDelivered }); void notificationTriggerRuntime.sendGoalSettlePush({ sessionId, directory, status, title, body: notificationPayload.body, }).catch((error) => { console.warn('[session-goal] push fanout failed:', error?.message || error); }); }, }); /** * Owns what a session must be told about the project's knowledge. Every sender * asks it — the UI over HTTP, scheduled tasks and agent-dispatched sessions in * process — so the answer cannot differ between them. */ const sessionKnowledgeRuntime = createSessionKnowledgeRuntime({ projectContextRuntime, agentMemoryRuntime, // Called, not captured: the resolver is declared further down, and taking a // reference here would read it before it exists. resolveProjectId: (directory) => resolveMemoryProjectId(directory), isAgentMemoryEnabled, openCodeFetch: async (fetchPath, { directory, method = 'GET', body } = {}) => { const params = new URLSearchParams(); if (directory) params.set('directory', directory); const search = params.toString(); const response = await fetch(`${buildOpenCodeUrl(fetchPath, '')}${search ? `?${search}` : ''}`, { method, headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...getOpenCodeAuthHeaders(), }, ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(15_000), }); if (!response.ok) throw new Error(`OpenCode ${method} ${fetchPath} failed with ${response.status}`); return response.json().catch(() => null); }, }); const contextObligatoryRuntime = createContextObligatoryRuntime({ buildOpenCodeUrl, getOpenCodeAuthHeaders, sessionKnowledgeRuntime, }); const linearSessionStatusRuntime = createLinearSessionStatusRuntime(); const globalMessageStreamHub = createGlobalMessageStreamHub({ buildOpenCodeUrl, getOpenCodeAuthHeaders, upstreamStallTimeoutMs: getUpstreamStallTimeoutMs, }); const permissionAutoAcceptRuntime = createPermissionAutoAcceptRuntime({ globalEventHub: globalMessageStreamHub, buildOpenCodeUrl, getOpenCodeAuthHeaders, readSettingsFromDiskMigrated, persistSettings, broadcastGlobalUiEvent, }); permissionAutoAcceptRuntime.start(); notificationTriggerRuntime.setGetIsSessionAutoAccepting( (sessionId, directory) => permissionAutoAcceptRuntime.isSessionAutoAccepting(sessionId, directory), ); // Queued follow-up messages are delivered by the server so a closed tab or a // dropped connection no longer strands them (VS Code keeps its UI-side queue). const messageQueueRuntime = createMessageQueueRuntime({ globalEventHub: globalMessageStreamHub, buildOpenCodeUrl, getOpenCodeAuthHeaders, sessionKnowledgeRuntime, // OpenCode's /global/event SSE proxy cannot carry OpenChamber-owned events. // Use the shared control stream for SSE clients and the existing WS fan-out. broadcastGlobalUiEvent: createGlobalUiEventBroadcaster({ sseClients: uiOpenChamberEventClients, wsClients: uiNotificationWsClients, writeSseEvent, }), onPromptSent: (sessionId) => sessionRuntime.markUserMessageSent(sessionId), dataDir: OPENCHAMBER_DATA_DIR, }); messageQueueRuntime.start(); const agentActivityRuntime = createAgentActivityRuntime({ globalEventHub: globalMessageStreamHub, broadcastGlobalUiEvent, }); agentActivityRuntime.start(); const sessionSteerRuntime = createSessionSteerRuntime({ globalEventHub: globalMessageStreamHub, buildOpenCodeUrl, getOpenCodeAuthHeaders, broadcastGlobalUiEvent, }); sessionSteerRuntime.start(); const planGateRuntime = createPlanGateRuntime({ globalEventHub: globalMessageStreamHub, buildOpenCodeUrl, getOpenCodeAuthHeaders, broadcastGlobalUiEvent, }); planGateRuntime.start(); const hermesIntegrationRuntime = createHermesIntegrationRuntime({ agentActivityRuntime, sessionSteerRuntime, planGateRuntime, readSettingsFromDiskMigrated, startedAt: () => Date.now() - (Date.now() - process.uptime() * 1000), }); const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({ waitForOpenCodePort: (...args) => waitForOpenCodePort(...args), buildOpenCodeUrl, getOpenCodeAuthHeaders, parseSseDataPayload: (...args) => parseSseDataPayload(...args), globalEventHub: globalMessageStreamHub, onPayload: (payload) => { maybeCacheSessionInfoFromEvent(payload); void maybeSendPushForTrigger(payload); sessionRuntime.processOpenCodeSsePayload(payload); }, }); // Session-assist subscribes to the hub directly: it needs the envelope's // directory to route its own OpenCode calls to the right instance. console.log('[session-assist] listening for session events'); globalMessageStreamHub.subscribeEvent((event) => { const raw = event?.payload; const payload = raw?.payload && typeof raw.payload === 'object' ? raw.payload : raw; if (!payload || typeof payload !== 'object') return; const directory = typeof event?.directory === 'string' && event.directory && event.directory !== 'global' ? event.directory : ''; sessionAssistRuntime.processPayload(payload, directory); sessionGoalRuntime.processPayload(payload, directory); contextObligatoryRuntime.processPayload(payload, directory); linearSessionStatusRuntime.processPayload(payload); }); const processForwardedEventPayload = (payload, emitSyntheticEvent) => { if (!payload || typeof payload !== 'object' || typeof emitSyntheticEvent !== 'function') { return; } maybeCacheSessionInfoFromEvent(payload); if (payload.type !== 'session.status') { return; } const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {}; const statusInfo = properties.status && typeof properties.status === 'object' ? properties.status : {}; const info = properties.info && typeof properties.info === 'object' ? properties.info : {}; const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : ''; const status = typeof statusInfo.type === 'string' ? statusInfo.type.trim() : (typeof info.type === 'string' ? info.type.trim() : ''); if (!sessionId || !status) { return; } emitSyntheticEvent({ type: 'openchamber:session-status', properties: { sessionID: sessionId, status, timestamp: Date.now(), metadata: { attempt: typeof statusInfo.attempt === 'number' ? statusInfo.attempt : (typeof info.attempt === 'number' ? info.attempt : undefined), message: typeof statusInfo.message === 'string' ? statusInfo.message : (typeof info.message === 'string' ? info.message : undefined), next: typeof statusInfo.next === 'number' ? statusInfo.next : (typeof info.next === 'number' ? info.next : undefined), }, needsAttention: false, }, }); emitSyntheticEvent({ type: 'openchamber:session-activity', properties: { sessionId, phase: status === 'busy' || status === 'retry' ? 'busy' : 'idle', }, }); }; const serverUtilsRuntime = createServerUtilsRuntime({ fs, os, path, process, openCodeReadyGraceMs: OPEN_CODE_READY_GRACE_MS, longRequestTimeoutMs: LONG_REQUEST_TIMEOUT_MS, getRuntime: () => ({ openCodePort, openCodeBaseUrl, openCodeNotReadySince, isOpenCodeReady, isRestartingOpenCode, }), getOpenCodeAuthHeaders, buildOpenCodeUrl, ensureOpenCodeApiPrefix, getUpstreamStallTimeoutMs, getUiNotificationClients: () => uiNotificationClients, getOpenCodePort: () => openCodePort, setOpenCodePortState: (value) => { openCodePort = value; }, syncToHmrState, markOpenCodeNotReady: () => { isOpenCodeReady = false; }, setOpenCodeNotReadySince: (value) => { openCodeNotReadySince = value; }, clearLastOpenCodeError: () => { lastOpenCodeError = null; }, getLoginShellPath: () => { const snapshot = getLoginShellEnvSnapshot(); if (!snapshot || typeof snapshot.PATH !== 'string' || snapshot.PATH.length === 0) { return null; } return snapshot.PATH; }, }); const setOpenCodePort = (...args) => serverUtilsRuntime.setOpenCodePort(...args); const waitForOpenCodePort = (...args) => serverUtilsRuntime.waitForOpenCodePort(...args); const buildAugmentedPath = (...args) => serverUtilsRuntime.buildAugmentedPath(...args); const buildManagedOpenCodePath = (...args) => serverUtilsRuntime.buildManagedOpenCodePath(...args); const parseSseDataPayload = (...args) => serverUtilsRuntime.parseSseDataPayload(...args); const staticRoutesRuntime = createStaticRoutesRuntime({ fs, path, process, __dirname, express, resolveProjectDirectory, buildOpenCodeUrl, getOpenCodeAuthHeaders, readSettingsFromDiskMigrated, normalizePwaAppName, normalizePwaOrientation, }); const remoteClientAuthRuntime = createRemoteClientAuthRuntime({ fsPromises, path, crypto, storePath: REMOTE_CLIENTS_FILE_PATH, }); const clientPairingRuntime = createClientPairingRuntime({ fsPromises, path, crypto, storePath: CLIENT_PAIRING_SESSIONS_FILE_PATH, remoteClientAuthRuntime, }); const featureRoutesRuntime = createFeatureRoutesRuntime({ clientReloadDelayMs: CLIENT_RELOAD_DELAY_MS, }); const bootstrapRuntime = createBootstrapRuntime({ createUiAuth, registerServerStatusRoutes, registerCommonRequestMiddleware, registerAuthAndAccessRoutes, registerTtsRoutes, registerNotificationRoutes, registerOpenChamberRoutes, registerAgentToolRoutes: (app, options) => options.agentToolRuntime.registerRoutes(app, options.express), express, }); const tunnelWiringRuntime = createTunnelWiringRuntime({ crypto, URL, tunnelProviderRegistry, tunnelAuthController, readSettingsFromDiskMigrated, readManagedRemoteTunnelConfigFromDisk, normalizeTunnelProvider, normalizeTunnelMode, normalizeOptionalPath, normalizeManagedRemoteTunnelHostname, normalizeTunnelBootstrapTtlMs, normalizeTunnelSessionTtlMs, isSupportedTunnelMode, upsertManagedRemoteTunnelToken, resolveManagedRemoteTunnelToken, TUNNEL_MODE_QUICK, TUNNEL_MODE_MANAGED_LOCAL, TUNNEL_MODE_MANAGED_REMOTE, TUNNEL_PROVIDER_CLOUDFLARE, TunnelServiceError, getActiveTunnelController: () => activeTunnelController, setActiveTunnelController: (value) => { activeTunnelController = value; }, getRuntimeManagedRemoteTunnelHostname: () => runtimeManagedRemoteTunnelHostname, setRuntimeManagedRemoteTunnelHostname: (value) => { runtimeManagedRemoteTunnelHostname = value; }, getRuntimeManagedRemoteTunnelToken: () => runtimeManagedRemoteTunnelToken, setRuntimeManagedRemoteTunnelToken: (value) => { runtimeManagedRemoteTunnelToken = value; }, }); const startupPipelineRuntime = createStartupPipelineRuntime({ createTerminalRuntime, createDictationRuntime, createMessageStreamWsRuntime, createServerStartupRuntime, }); const openCodeLifecycleState = {}; Object.defineProperties(openCodeLifecycleState, { openCodeProcess: { get: () => openCodeProcess, set: (value) => { openCodeProcess = value; } }, openCodePort: { get: () => openCodePort, set: (value) => { openCodePort = value; } }, openCodeBaseUrl: { get: () => openCodeBaseUrl, set: (value) => { openCodeBaseUrl = value; } }, openCodeWorkingDirectory: { get: () => openCodeWorkingDirectory, set: (value) => { openCodeWorkingDirectory = value; } }, currentRestartPromise: { get: () => currentRestartPromise, set: (value) => { currentRestartPromise = value; } }, isRestartingOpenCode: { get: () => isRestartingOpenCode, set: (value) => { isRestartingOpenCode = value; } }, openCodeApiPrefix: { get: () => openCodeApiPrefix, set: (value) => { openCodeApiPrefix = value; } }, openCodeApiPrefixDetected: { get: () => openCodeApiPrefixDetected, set: (value) => { openCodeApiPrefixDetected = value; } }, openCodeApiDetectionTimer: { get: () => openCodeApiDetectionTimer, set: (value) => { openCodeApiDetectionTimer = value; } }, lastOpenCodeError: { get: () => lastOpenCodeError, set: (value) => { lastOpenCodeError = value; } }, lastOpenCodeLaunchDiagnostics: { get: () => lastOpenCodeLaunchDiagnostics, set: (value) => { lastOpenCodeLaunchDiagnostics = value; } }, lastOpenCodeHealthFailure: { get: () => lastOpenCodeHealthFailure, set: (value) => { lastOpenCodeHealthFailure = value; } }, lastManagedOpenCodeProcess: { get: () => lastManagedOpenCodeProcess, set: (value) => { lastManagedOpenCodeProcess = value; } }, lastOpenCodeRestartDiagnostics: { get: () => lastOpenCodeRestartDiagnostics, set: (value) => { lastOpenCodeRestartDiagnostics = value; } }, isOpenCodeReady: { get: () => isOpenCodeReady, set: (value) => { isOpenCodeReady = value; } }, openCodeNotReadySince: { get: () => openCodeNotReadySince, set: (value) => { openCodeNotReadySince = value; } }, isExternalOpenCode: { get: () => isExternalOpenCode, set: (value) => { isExternalOpenCode = value; } }, isShuttingDown: { get: () => isShuttingDown, set: (value) => { isShuttingDown = value; } }, healthCheckInterval: { get: () => healthCheckInterval, set: (value) => { healthCheckInterval = value; } }, expressApp: { get: () => expressApp, set: (value) => { expressApp = value; } }, useWslForOpencode: { get: () => useWslForOpencode, set: (value) => { useWslForOpencode = value; } }, resolvedWslBinary: { get: () => resolvedWslBinary, set: (value) => { resolvedWslBinary = value; } }, resolvedWslOpencodePath: { get: () => resolvedWslOpencodePath, set: (value) => { resolvedWslOpencodePath = value; } }, resolvedWslDistro: { get: () => resolvedWslDistro, set: (value) => { resolvedWslDistro = value; } }, }); const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({ state: openCodeLifecycleState, env: { ENV_CONFIGURED_OPENCODE_PORT, ENV_CONFIGURED_OPENCODE_HOST, ENV_EFFECTIVE_PORT, ENV_CONFIGURED_OPENCODE_HOSTNAME, ENV_SKIP_OPENCODE_START, }, syncToHmrState, syncFromHmrState, getOpenCodeAuthHeaders, buildOpenCodeUrl, waitForReady, normalizeApiPrefix, applyOpencodeBinaryFromSettings, ensureOpencodeCliEnv, ensureLocalOpenCodeServerPassword, resolveManagedOpenCodeLaunchSpec, setOpenCodePort, setDetectedOpenCodeApiPrefix, setupProxy: (...args) => setupProxy(...args), ensureOpenCodeApiPrefix, clearResolvedOpenCodeBinary, buildAugmentedPath, buildManagedOpenCodePath, getManagedOpenCodeShellEnvSnapshot: getLoginShellEnvSnapshot, getActiveSessionCount, // Most-recently-used directories first: OpenCode initializes each directory // lazily on first request (seconds on large session stores), so the // lifecycle warms these right after readiness — before the UI's first // interactive request would otherwise pay that cost. getWarmupDirectories: async () => { const settings = await readSettingsFromDiskMigrated().catch(() => null); if (!settings) return []; const directories = []; if (typeof settings.lastDirectory === 'string' && settings.lastDirectory) { directories.push(settings.lastDirectory); } const projects = Array.isArray(settings.projects) ? [...settings.projects] : []; projects.sort((a, b) => (b?.lastOpenedAt ?? 0) - (a?.lastOpenedAt ?? 0)); for (const project of projects) { if (typeof project?.path === 'string' && project.path) { directories.push(project.path); } } return [...new Set(directories)]; }, // A managed restart can move OpenCode to a NEW port (the old one may stay // occupied if killProcessOnPort/waitForPortRelease didn't free it in time, // on any platform). Rebind the message-stream upstream readers to the current port // so the UI keeps receiving events instead of staying pinned to the old // process (#2638). The runtime is created later by the startup pipeline; // by the time any restart runs, it is assigned. onOpenCodeRestarted: () => { // A restart reloads plugins: provider ports, credentials and the provider // list itself can all differ from what was cached. resetOpenCodeRuntimeProviders(); try { messageStreamRuntime?.rebindUpstream(); } catch (error) { console.warn('Failed to rebind message stream after OpenCode restart:', error?.message ?? error); } try { const { sessionIds } = sessionRuntime.interruptBusySessionsAfterRestart(); if (sessionIds.length > 0) { const multiple = sessionIds.length > 1; broadcastUiNotification({ title: multiple ? 'Chats interrupted' : 'Chat interrupted', body: multiple ? 'OpenCode restarted during running responses. Send a message in each chat to continue.' : 'OpenCode restarted during a running response. Send a message to continue.', tag: 'opencode-restart-interrupted', kind: 'opencode-restart-interrupted', sessionId: sessionIds[0], }); } } catch (error) { console.warn('Failed to reconcile sessions after OpenCode restart:', error?.message ?? error); } }, getManagedOpenCodeEnv: async () => { const settings = await readSettingsFromDiskMigrated().catch(() => null); // Each capability is its own tool and its own switch; the plugin is only // injected while at least one of them is on. const includeControl = settings?.agentControlToolEnabled !== false; const includeWeb = settings?.agentWebToolEnabled !== false; const includeMemory = isAgentMemoryFeatureAvailable() && settings?.agentMemoryToolEnabled === true; const managedEnv = includeControl || includeWeb || includeMemory ? await (agentToolRuntime?.prepareManagedOpenCodeEnv({ includeControl, includeWeb, includeMemory }) || {}) : {}; // Each managed plugin appends itself to the config the previous one produced. let configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT; if (settings?.optimizeSystemPrompt === true) { ({ OPENCODE_CONFIG_CONTENT: configContent } = await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent)); } // Always on for managed OpenCode: it only retries servers OpenCode gave up on. const mcpReconnectEnv = await mcpReconnectRuntime.prepareManagedOpenCodeEnv(configContent); return { ...managedEnv, ...mcpReconnectEnv }; }, }); const getOpenCodeUpgradeCapability = () => { const activeBinary = lastOpenCodeLaunchDiagnostics?.sourceBinary || lastOpenCodeLaunchDiagnostics?.binary || resolvedOpencodeBinary; return resolveOpenCodeUpgradeCapability({ isExternal: isExternalOpenCode, hasManagedProcess: Boolean(openCodeProcess), activeBinary, isBundledBinary: isBundledOpenCodeCliPath, }); }; const restartOpenCode = (...args) => openCodeLifecycleRuntime.restartOpenCode(...args); const waitForOpenCodeReady = (...args) => openCodeLifecycleRuntime.waitForOpenCodeReady(...args); const waitForAgentPresence = (...args) => openCodeLifecycleRuntime.waitForAgentPresence(...args); const refreshOpenCodeAfterConfigChange = (...args) => openCodeLifecycleRuntime.refreshOpenCodeAfterConfigChange(...args); const startHealthMonitoring = () => openCodeLifecycleRuntime.startHealthMonitoring(HEALTH_CHECK_INTERVAL); const triggerHealthCheck = () => openCodeLifecycleRuntime.triggerHealthCheck(); const scheduledTasksRuntime = createScheduledTasksRuntime({ projectConfigRuntime, listProjects: async () => { const settings = await readSettingsFromDiskMigrated(); return sanitizeProjects(settings?.projects || []); }, buildOpenCodeUrl, getOpenCodeAuthHeaders, waitForOpenCodeReady, sessionKnowledgeRuntime, setSessionAutoAccept: (sessionId, enabled, directory) => permissionAutoAcceptRuntime.setSessionPolicy(sessionId, enabled, directory), emitTaskRunEvent: (event) => { for (const client of uiOpenChamberEventClients) { try { writeSseEvent(client, { type: 'openchamber:scheduled-task-ran', properties: { projectId: event.projectID, taskId: event.taskID, ranAt: event.ranAt, status: event.status, ...(event.sessionID ? { sessionId: event.sessionID } : {}), }, }); } catch { uiOpenChamberEventClients.delete(client); } } }, logger: console, }); const emitSessionCreatedEvent = (event) => { for (const client of uiOpenChamberEventClients) { try { writeSseEvent(client, { type: 'openchamber:session-created', properties: { sessionId: event.sessionID, directory: event.directory, createdAt: event.createdAt, promptDispatched: event.promptDispatched === true, dispatchedAsCommand: event.dispatchedAsCommand === true, ...(event.projectID ? { projectId: event.projectID } : {}), ...(event.title ? { title: event.title } : {}), }, }); } catch { uiOpenChamberEventClients.delete(client); } } }; /** * Maps a session directory onto the project whose memory it belongs to, so a * session running in a worktree writes to the project the panel shows. */ const resolveMemoryProjectId = createMemoryProjectResolver({ listProjectPaths: async () => { const settings = await readSettingsFromDiskMigrated().catch(() => null); return sanitizeProjects(settings?.projects || []).map((project) => project.path); }, resolvePrimaryWorktreeRoot, managedProjectRoots: [...new Set([path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'chats'), OPENCHAMBER_CHATS_DIR])], }); /** * Tells open panels that the agent changed what it remembers, so what it just * stored is visible without reopening anything. */ const emitAgentMemoryChangedEvent = (event) => { for (const client of uiOpenChamberEventClients) { try { writeSseEvent(client, { type: 'openchamber:agent-memory-changed', properties: { scope: event.scope, ...(event.projectId ? { projectId: event.projectId } : {}), }, }); } catch { uiOpenChamberEventClients.delete(client); } } }; const scheduledTaskService = createScheduledTaskService({ readSettingsFromDiskMigrated, sanitizeProjects, projectConfigRuntime, scheduledTasksRuntime, }); const openChamberSessionService = createOpenChamberSessionService({ readSettingsFromDiskMigrated, sanitizeProjects, validateDirectoryPath, buildOpenCodeUrl, getOpenCodeAuthHeaders, waitForOpenCodeReady, emitSessionCreatedEvent, sessionKnowledgeRuntime, planGateRuntime, }); // Browser actions are published to whichever OpenChamber clients are connected; // the one owning the browser panel answers. `emitRequest` returns the number of // clients reached so the broker can fail fast when nobody is listening. const browserControlBroker = createBrowserControlBroker({ createId: () => `browser-${crypto.randomUUID()}`, emitRequest: (request) => { // Opening a page only needs a panel to open it in; everything else needs a // client that can actually drive one. Counting the right clients is what // lets the broker say "not here" instead of timing out. const needsBrowserView = request.action !== 'browser.open'; let delivered = 0; for (const client of uiOpenChamberEventClients) { if (needsBrowserView && client.openchamberBrowserCapable !== true) continue; try { writeSseEvent(client, { type: 'openchamber:browser-control-request', properties: { requestId: request.requestId, action: request.action, parameters: request.parameters, }, }); delivered += 1; } catch { uiOpenChamberEventClients.delete(client); } } return delivered; }, }); const openChamberControlService = createOpenChamberControlService({ readSettingsFromDiskMigrated, sanitizeProjects, buildOpenCodeUrl, getOpenCodeAuthHeaders, waitForOpenCodeReady, sessionService: openChamberSessionService, scheduledTaskService, browserControl: browserControlBroker, agentMemoryActions: createAgentMemoryActions({ agentMemoryRuntime, createError: (message, status) => new OpenChamberControlError(message, status), onMemoryChanged: emitAgentMemoryChangedEvent, isAgentMemoryEnabled, resolveProjectId: resolveMemoryProjectId, }), }); const ensureGlobalWatcherStarted = async () => { if (globalWatcherStartPromise) { return globalWatcherStartPromise; } globalWatcherStartPromise = openCodeWatcherRuntime.start().catch((error) => { globalWatcherStartPromise = null; throw error; }); return globalWatcherStartPromise; }; const bootstrapOpenCodeAtStartup = async (...args) => { await openCodeLifecycleRuntime.bootstrapOpenCodeAtStartup(...args); scheduleOpenCodeApiDetection(); if (openCodeLifecycleState.openCodeProcess && !openCodeLifecycleState.isExternalOpenCode) { startHealthMonitoring(); } // The global watcher used to start only for desktop notifications; the // session-assist runtime also rides its event hub, so it now starts // unconditionally once OpenCode is up. void ensureGlobalWatcherStarted().catch((error) => { console.warn(`Global event watcher startup failed: ${error?.message || error}`); }); }; const killProcessOnPort = (...args) => openCodeLifecycleRuntime.killProcessOnPort(...args); const waitForPortRelease = (...args) => openCodeLifecycleRuntime.waitForPortRelease(...args); const fetchAgentsSnapshot = (...args) => serverUtilsRuntime.fetchAgentsSnapshot(...args); const fetchProvidersSnapshot = (...args) => serverUtilsRuntime.fetchProvidersSnapshot(...args); const fetchModelsSnapshot = (...args) => serverUtilsRuntime.fetchModelsSnapshot(...args); const setupProxy = (...args) => serverUtilsRuntime.setupProxy(...args); const gracefulShutdownRuntime = createGracefulShutdownRuntime({ process, shutdownTimeoutMs: SHUTDOWN_TIMEOUT, getExitOnShutdown: () => exitOnShutdown, getIsShuttingDown: () => isShuttingDown, setIsShuttingDown: (value) => { isShuttingDown = value; }, syncToHmrState, openCodeWatcherRuntime, sessionAssistRuntime, sessionGoalRuntime, contextObligatoryRuntime, messageQueueRuntime, sessionRuntime, getHealthCheckInterval: () => healthCheckInterval, clearHealthCheckInterval: (value) => clearInterval(value), getTerminalRuntime: () => terminalRuntime, setTerminalRuntime: (value) => { terminalRuntime = value; }, getMessageStreamRuntime: () => messageStreamRuntime, setMessageStreamRuntime: (value) => { messageStreamRuntime = value; }, shouldSkipOpenCodeStop: () => ENV_SKIP_OPENCODE_START || isExternalOpenCode, getOpenCodePort: () => openCodePort, getOpenCodeProcess: () => openCodeProcess, setOpenCodeProcess: (value) => { openCodeProcess = value; }, killProcessOnPort, waitForPortRelease, getServer: () => server, getUiAuthController: () => uiAuthController, setUiAuthController: (value) => { uiAuthController = value; }, getActiveTunnelController: () => activeTunnelController, setActiveTunnelController: (value) => { activeTunnelController = value; }, tunnelAuthController, scheduledTasksRuntime, }); const gracefulShutdown = (...args) => gracefulShutdownRuntime.gracefulShutdown(...args); async function main(options = {}) { const port = Number.isFinite(options.port) && options.port >= 0 ? Math.trunc(options.port) : DEFAULT_PORT; const host = typeof options.host === 'string' && options.host.length > 0 ? options.host : undefined; const effectiveBindHost = host || (typeof process.env.OPENCHAMBER_HOST === 'string' && process.env.OPENCHAMBER_HOST.trim().length > 0 ? process.env.OPENCHAMBER_HOST.trim() : '127.0.0.1'); agentToolRuntime = createAgentToolRuntime({ crypto, fsPromises, path, dataDir: OPENCHAMBER_DATA_DIR, env: process.env, executeAction: (...args) => openChamberControlService.execute(...args), getActivePort: () => { const address = server?.address?.(); return typeof address === 'object' && address ? address.port : null; }, }); systemPromptRuntime = createSystemPromptRuntime({ fsPromises, path, dataDir: OPENCHAMBER_DATA_DIR, }); mcpReconnectRuntime = createMcpReconnectRuntime({ fsPromises, path, dataDir: OPENCHAMBER_DATA_DIR, }); // Pairing transports advertised to the create-device dialog. LAN reachability is // derived from the SERVER's actual bind (a wildcard bind → the machine's LAN IP; // a specific non-loopback host → that host), NOT from how the UI was opened — so // "Local network" works even when the UI is opened on localhost, and is absent // when the server is only bound to loopback (a LAN link would not connect). // The IPv4 the requesting client actually reached this server on (if any). // Strips the IPv6-mapped prefix; loopback means "not a LAN path". const requestReachedLanAddress = (req) => { const raw = typeof req?.socket?.localAddress === 'string' ? req.socket.localAddress : ''; const address = raw.startsWith('::ffff:') ? raw.slice(7) : raw; if (!/^\d+\.\d+\.\d+\.\d+$/.test(address)) return null; if (address.startsWith('127.')) return null; return address; }; const resolvePairingTransports = (req) => { const activePort = tunnelRuntimeContext.getActivePort() || port; const local = `http://127.0.0.1:${activePort}`; let lanHost = null; if (isNetworkExposedBindHost(effectiveBindHost)) { // Prefer the address the client is ALREADY talking to us on — it is the // one interface guaranteed to be routable from that client's network. // Interface scanning is only a fallback: on servers with virtual bridges // (docker0 etc.) the first non-internal IPv4 can be an address no other // machine can reach, which produced pairing links whose LAN candidate // silently failed and forced devices onto the relay. lanHost = requestReachedLanAddress(req); try { if (!lanHost) { for (const list of Object.values(os.networkInterfaces())) { for (const entry of (list || [])) { if (entry.family === 'IPv4' && !entry.internal) { lanHost = entry.address; break; } } if (lanHost) break; } } } catch { lanHost = null; } } else { const h = String(effectiveBindHost || '').toLowerCase(); if (h && h !== '127.0.0.1' && h !== 'localhost' && h !== '::1') lanHost = effectiveBindHost; } const lan = lanHost ? `http://${lanHost.includes(':') ? `[${lanHost}]` : lanHost}:${activePort}` : null; return { local, lan, relayAvailable: true }; }; // ALL direct LAN URLs this server is currently reachable on, for the // candidates-refresh endpoint: the address the requesting client already // reached us on first (guaranteed routable from its network — over the relay // tunnel this is loopback and yields nothing), then every non-internal IPv4 // interface. A client that paired while the machine had a different DHCP // lease uses this to replace its stale LAN candidate. const resolveDirectLanUrls = (req) => { const activePort = tunnelRuntimeContext.getActivePort() || port; const urls = []; const push = (host) => { if (typeof host !== 'string' || !host) return; const url = `http://${host.includes(':') ? `[${host}]` : host}:${activePort}`; if (!urls.includes(url)) urls.push(url); }; if (isNetworkExposedBindHost(effectiveBindHost)) { push(requestReachedLanAddress(req)); try { for (const list of Object.values(os.networkInterfaces())) { for (const entry of (list || [])) { if (entry.family === 'IPv4' && !entry.internal) push(entry.address); } } } catch { // interface scan failure → whatever we already collected } } else { const h = String(effectiveBindHost || '').toLowerCase(); if (h && h !== '127.0.0.1' && h !== 'localhost' && h !== '::1') push(effectiveBindHost); } return urls; }; const uiPassword = typeof options.uiPassword === 'string' ? options.uiPassword : (typeof process.env.OPENCHAMBER_UI_PASSWORD === 'string' ? process.env.OPENCHAMBER_UI_PASSWORD : null); if ( isNetworkExposedBindHost(effectiveBindHost) && !(typeof uiPassword === 'string' && uiPassword.trim().length > 0) && !isUnsafeUnauthenticatedLanAllowed(process.env) ) { throw new Error(getUnauthenticatedLanErrorMessage(effectiveBindHost)); } const tryCfTunnel = options.tryCfTunnel === true; const apiOnly = options.apiOnly === true || isEnvFlagEnabled(process.env.OPENCHAMBER_API_ONLY); const shouldUseCanonicalTunnelConfig = typeof options.tunnelMode === 'string' || typeof options.tunnelProvider === 'string' || options.tunnelConfigPath === null || typeof options.tunnelConfigPath === 'string' || typeof options.tunnelToken === 'string' || typeof options.tunnelHostname === 'string'; const startupTunnelRequest = shouldUseCanonicalTunnelConfig ? normalizeTunnelStartRequest({ provider: normalizeTunnelProvider(options.tunnelProvider), mode: options.tunnelMode, configPath: normalizeOptionalPath(options.tunnelConfigPath), token: typeof options.tunnelToken === 'string' ? options.tunnelToken.trim() : '', hostname: normalizeManagedRemoteTunnelHostname(options.tunnelHostname), }) : (tryCfTunnel ? { provider: TUNNEL_PROVIDER_CLOUDFLARE, mode: TUNNEL_MODE_QUICK, configPath: undefined, token: '', hostname: undefined, } : null); const attachSignals = options.attachSignals !== false; const onTunnelReady = typeof options.onTunnelReady === 'function' ? options.onTunnelReady : null; if (typeof options.exitOnShutdown === 'boolean') { exitOnShutdown = options.exitOnShutdown; } if (typeof options.onDesktopNotification === 'function') { notificationEmitterRuntime.setOnDesktopNotification(options.onDesktopNotification); } if (typeof options.getIsWindowFocused === 'function') { notificationTriggerRuntime.setGetIsWindowFocused(options.getIsWindowFocused); } const getDesktopRuntimeConfig = typeof options.getDesktopRuntimeConfig === 'function' ? options.getDesktopRuntimeConfig : null; const desktopUpdater = options.desktopUpdater && typeof options.desktopUpdater.check === 'function' && typeof options.desktopUpdater.install === 'function' && typeof options.desktopUpdater.restart === 'function' ? options.desktopUpdater : null; console.log(`Starting OpenChamber on port ${port === 0 ? 'auto' : port}`); // Voice enumeration is independent from route registration. Start it now, // but do not hold server listen or managed OpenCode startup on `say -v "?"`. const sayTTSCapability = detectSayTtsCapability(process); const app = express(); const serverStartedAt = new Date().toISOString(); const packagedClientOrigins = new Set([ 'openchamber-ui://app', 'capacitor://localhost', 'http://localhost', 'https://localhost', ]); const isLocalDevClientOrigin = (origin) => /^https?:\/\/(localhost|127\.0\.0\.1):\d+$/.test(origin); app.set('trust proxy', true); // Keep self-hosted instances out of search engines. The app shell is served // publicly (it loads before prompting for the UI password), so without this // even a password-protected instance gets crawled and indexed. Applies to // every response; the robots.txt route makes the intent explicit for crawlers. app.use((_req, res, next) => { res.setHeader('X-Robots-Tag', 'noindex, nofollow'); next(); }); app.get('/robots.txt', (_req, res) => { res.type('text/plain').send('User-agent: *\nDisallow: /\n'); }); app.use((req, res, next) => { const origin = typeof req.headers.origin === 'string' ? req.headers.origin : ''; if (packagedClientOrigins.has(origin) || isLocalDevClientOrigin(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); // The packaged desktop UI (openchamber-ui://) and the dev UI sit on a // different origin, so every custom request header must be listed here or // the browser refuses the request at preflight, before it reaches a route. res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,Accept,X-Requested-With,Cache-Control,X-OpenCode-Directory,X-OpenCode-Directory-Encoding,Ngrok-Skip-Browser-Warning,X-OpenChamber-Surface'); res.setHeader('Access-Control-Expose-Headers', 'x-next-cursor'); res.setHeader('Vary', 'Origin'); if (req.method === 'OPTIONS') { res.status(204).end(); return; } } next(); }); app.use(compression({ filter: (req, res) => { if (shouldSkipCompression(req, res)) return false; return compression.filter(req, res); }, threshold: 1024, })); expressApp = app; server = http.createServer(app); let realtimeProxyRuntime = { stop: () => {} }; // The relay service is constructed further below (it depends on the tunnel // runtime's active port). The pairing routes registered here only read the // relay candidate lazily at request time, so a late-bound holder is enough. let relayServiceInstance = null; // Same pattern for the tunnel runtime: created after the base routes so // /api/system/info resolves port + tunnel URL lazily at request time. let tunnelRuntimeContextHolder = null; const bootstrapResult = bootstrapRuntime.setupBaseRoutes(app, { process, openchamberVersion: OPENCHAMBER_VERSION, runtimeName: process.env.OPENCHAMBER_RUNTIME || 'web', serverStartedAt, gracefulShutdown, getHealthSnapshot: () => { const launchSpec = resolvedOpencodeBinary && !useWslForOpencode ? resolveManagedOpenCodeLaunchSpec(resolvedOpencodeBinary) : null; return { openCodePort, openCodeRunning: Boolean(openCodePort && isOpenCodeReady && !isRestartingOpenCode), openCodeSecureConnection: isOpenCodeConnectionSecure(), openCodeAuthSource: openCodeAuthSource || null, openCodeApiPrefix: '', openCodeApiPrefixDetected: true, isOpenCodeReady, lastOpenCodeError, lastOpenCodeLaunchDiagnostics, lastOpenCodeHealthFailure, lastManagedOpenCodeProcess, lastOpenCodeRestartDiagnostics, opencodeBinaryResolved: resolvedOpencodeBinary || null, opencodeBinarySource: resolvedOpencodeBinarySource || null, opencodeLaunchBinary: launchSpec?.binary || null, opencodeLaunchArgs: launchSpec?.args || [], opencodeLaunchWrapperType: launchSpec?.wrapperType || null, opencodeViaWsl: useWslForOpencode, opencodeWslBinary: resolvedWslBinary || null, opencodeWslPath: resolvedWslOpencodePath || null, opencodeWslDistro: resolvedWslDistro || null, nodeBinaryResolved: resolvedNodeBinary || null, bunBinaryResolved: resolvedBunBinary || null, desktopNotifyEnabled: ENV_DESKTOP_NOTIFY, planModeExperimentalEnabled: PLAN_MODE_EXPERIMENT_ENABLED, apiOnly, }; }, // Port this instance serves on and the active tunnel's public URL (if // any), for /api/system/info. Resolved lazily because the tunnel runtime // is created after these base routes are registered. getServerPort: () => { const activePort = tunnelRuntimeContextHolder?.getActivePort?.(); if (Number.isFinite(activePort) && activePort > 0) return activePort; return Number.isFinite(port) && port > 0 ? port : null; }, getTunnelUrl: () => tunnelRuntimeContextHolder?.tunnelService?.getPublicUrl?.() ?? null, verboseRequestLogs: OPENCHAMBER_VERBOSE_REQUEST_LOGS, uiPassword, tunnelAuthController, remoteClientAuthRuntime, clientPairingRuntime, getRelayPairingCandidate: (options) => { if (!relayServiceInstance) return null; // A relay pairing link enables the relay on demand; a plain link only // advertises relay when it is already on. return options?.ensureEnabled ? relayServiceInstance.ensureEnabledForPairing() : relayServiceInstance.getPairingCandidate(); }, // Re-evaluate the relay lifecycle after pairing/device changes (a revoked or // redeemed device can flip relay demand on or off). reconcileRelay: () => (relayServiceInstance ? relayServiceInstance.reconcile() : Promise.resolve()), getPairingTransports: resolvePairingTransports, getDirectCandidateUrls: resolveDirectLanUrls, // Stable server identity for client-side verification of learned addresses. // Lazily resolved: the relay service is constructed after these routes. getServerId: () => (relayServiceInstance ? relayServiceInstance.getServerId() : Promise.resolve(null)), // The display name a paired device shows for THIS server. Devices name the // connection by the issuing machine's hostname, not the per-device pairing // label typed by the operator. getServerLabel: () => { try { const name = os.hostname(); return typeof name === 'string' && name.trim().length > 0 ? name.trim() : 'OpenChamber'; } catch { return 'OpenChamber'; } }, readSettingsFromDiskMigrated, normalizeTunnelSessionTtlMs, sayTTSCapability, ensurePushInitialized, ensureGlobalWatcherStarted, getOrCreateVapidKeys, getUiSessionTokenFromRequest, writeSettingsToDisk, addOrUpdatePushSubscription, removePushSubscription, addOrUpdateApnsToken, removeApnsToken, updateUiVisibility, clearPendingPushBadge: () => clearPendingPushBadge(), isUiVisible, getUiNotificationClients: () => uiNotificationClients, writeSseEvent, sessionRuntime, setPushInitialized, fs, os, path, server, __dirname, openchamberDataDir: OPENCHAMBER_DATA_DIR, modelsDevApiUrl: MODELS_DEV_API_URL, modelsMetadataCacheTtl: MODELS_METADATA_CACHE_TTL, fetchFreeZenModels, getCachedZenModels, setAutoAcceptSession, agentToolRuntime, desktopUpdater, }); uiAuthController = bootstrapResult.uiAuthController; realtimeProxyRuntime = attachRealtimeProxy({ app, server, getDesktopRuntimeConfig, getUiAuthController: () => uiAuthController, isRequestOriginAllowed, }); const tunnelRuntimeContext = tunnelWiringRuntime.initialize(app, port); const { tunnelService, startTunnelWithNormalizedRequest } = tunnelRuntimeContext; tunnelRuntimeContextHolder = tunnelRuntimeContext; // Private relay host service: config + management routes + host client // lifecycle. Loopback port comes from the same source the tunnel uses so // relay-tunneled requests hit the local Express app on 127.0.0.1. const relayService = createRelayService({ crypto, os, readSettingsFromDiskMigrated, writeSettingsToDisk, readSettingsStrict: readSettingsFromDiskStrict, remoteClientAuthRuntime, getLocalPort: () => tunnelRuntimeContext.getActivePort(), // One relay host per machine: every instance sharing this data dir shares // the relay identity (serverId), so concurrent hosts evict each other at // the relay worker and devices land on a random local instance. hostLock: createRelayHostLock({ lockFilePath: path.join(OPENCHAMBER_DATA_DIR, 'relay-host.lock'), fs, process, }), // Dev/debug instances share the data dir (and thus the relay identity) with // the production instance, so they must not host the relay on their own — // paired devices would land on them. OPENCHAMBER_RELAY_HOST=off disables // passive hosting explicitly (dev scripts set it); the Electron dev shell is // covered via OPENCHAMBER_ELECTRON_DEV. OPENCHAMBER_RELAY_HOST=on overrides // both. Explicit enable/pairing on the instance still hosts regardless. allowPassiveHost: process.env.OPENCHAMBER_RELAY_HOST === 'on' || (process.env.OPENCHAMBER_RELAY_HOST !== 'off' && process.env.OPENCHAMBER_ELECTRON_DEV !== '1'), // Relay demand = any paired device or pending pairing session that uses the // relay transport. Drives the auto on/off lifecycle. hasRelayDemand: async () => { // A store read failure must NOT masquerade as "no demand": reconcile // persists enabled=false and severs paired devices. Any affirmative // answer wins; otherwise a failed check aborts reconcile (throw) so the // relay keeps its current state until a trustworthy read succeeds. const [pendingRelay, deviceRelay] = await Promise.allSettled([ clientPairingRuntime.hasActiveRelaySession(), remoteClientAuthRuntime.hasActiveRelayClients(), ]); if (pendingRelay.status === 'fulfilled' && pendingRelay.value) return true; if (deviceRelay.status === 'fulfilled' && deviceRelay.value) return true; if (pendingRelay.status === 'rejected') throw pendingRelay.reason; if (deviceRelay.status === 'rejected') throw deviceRelay.reason; return false; }, }); relayServiceInstance = relayService; relayService.registerRoutes(app); registerBrowserControlRoutes(app, { express, broker: browserControlBroker }); // One scanner backs both discovery and the tunnel allowlist, so a port the // user can see is exactly a port the tunnel will dial. const devServerScanner = createDevServerScanner({ spawn, platform: process.platform }); const listDevServers = () => devServerScanner.discover({ ownPorts: [port, openCodePort].filter((value) => Number.isInteger(value) && value > 0), }); createDevTunnelRuntime({ server, discoverDevServers: listDevServers, uiAuthController, isRequestOriginAllowed, rejectWebSocketUpgrade, logger: console, }); await featureRoutesRuntime.registerRoutes(app, { crypto, fs, os, path, fsPromises, spawn, resolveGitBinaryForSpawn, createFsSearchRuntime: createFsSearchRuntimeFactory, openchamberDataDir: OPENCHAMBER_DATA_DIR, openchamberUserConfigRoot: OPENCHAMBER_USER_CONFIG_ROOT, managedChatsRoot: OPENCHAMBER_CHATS_DIR, normalizeDirectoryPath, resolveProjectDirectory, resolveOptionalProjectDirectory, validateDirectoryPath, readCustomThemesFromDisk, refreshOpenCodeAfterConfigChange, getOpenCodeResolutionSnapshot, getOpenCodeUpgradeCapability, formatSettingsResponse, readSettingsFromDisk, readSettingsFromDiskMigrated, persistSettings, sanitizeProjects, sanitizeSkillCatalogs, isUnsafeSkillRelativePath, buildOpenCodeUrl, getOpenCodeAuthHeaders, getOpenCodePort: () => openCodePort, // Dev-server discovery must not offer OpenChamber's own listeners back to // the user as something to preview. getOwnPorts: () => [port, openCodePort].filter((value) => Number.isInteger(value) && value > 0), devServerScanner, buildAugmentedPath, projectConfigRuntime, projectContextRuntime, agentMemoryRuntime, isAgentMemoryEnabled, sessionKnowledgeRuntime, scheduledTasksRuntime, scheduledTaskService, openChamberSessionService, openChamberControlService, waitForOpenCodeReady, emitSessionCreatedEvent, getOpenChamberEventClients: () => uiOpenChamberEventClients, writeSseEvent, permissionAutoAcceptRuntime, messageQueueRuntime, agentActivityRuntime, sessionSteerRuntime, planGateRuntime, hermesIntegrationRuntime, }); const startupPipelineResult = await startupPipelineRuntime.run({ app, server, express, fs, path, uiAuthController, buildAugmentedPath, searchPathFor, isExecutable, isRequestOriginAllowed, rejectWebSocketUpgrade, buildOpenCodeUrl, getOpenCodeAuthHeaders, globalEventHub: globalMessageStreamHub, processForwardedEventPayload, messageStreamWsClients: uiNotificationWsClients, upstreamStallTimeoutMs: getUpstreamStallTimeoutMs, terminalHeartbeatIntervalMs: TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS, terminalRebindWindowMs: TERMINAL_INPUT_WS_REBIND_WINDOW_MS, terminalMaxRebindsPerWindow: TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW, setupProxy, scheduleOpenCodeApiDetection, bootstrapOpenCodeAtStartup, triggerHealthCheck, staticRoutesRuntime, process, crypto, normalizeTunnelBootstrapTtlMs, readSettingsFromDiskMigrated, tunnelAuthController, startTunnelWithNormalizedRequest, gracefulShutdown, getSignalsAttached: () => signalsAttached, setSignalsAttached: (value) => { signalsAttached = value; }, syncToHmrState, TUNNEL_MODE_QUICK, TUNNEL_MODE_MANAGED_LOCAL, TUNNEL_MODE_MANAGED_REMOTE, host, port, startupTunnelRequest, onTunnelReady, tunnelRuntimeContext, attachSignals, apiOnly, dictationModelsDir: path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'speech-models'), }); terminalRuntime = startupPipelineResult.terminalRuntime; dictationRuntime = startupPipelineResult.dictationRuntime; messageStreamRuntime = startupPipelineResult.messageStreamRuntime; try { await scheduledTasksRuntime.start(); } catch (error) { console.warn('[ScheduledTasks] Failed to start runtime:', error?.message || error); } // Only opens a relay control socket when the user opted in (config enabled). // Reconcile the relay lifecycle from demand on startup: run it if any relay // device/session exists, stop it (and clear a stale enabled flag) otherwise. void relayService.reconcile(); // Relay demand can change outside our routes: `openchamber connect-url // --relay` writes a pending relay session straight to the on-disk store, and // pending sessions expire without any request hitting us. Poll reconcile so a // headless instance picks the relay up (or drops it) within a minute. const relayReconcileTimer = setInterval(() => { void relayService.reconcile(); }, 60_000); relayReconcileTimer.unref?.(); return { expressApp: app, httpServer: server, getPort: () => tunnelRuntimeContext.getActivePort(), getOpenCodePort: () => openCodePort, getTunnelUrl: () => tunnelService.getPublicUrl(), getQuitRiskStatus: () => ({ tunnel: { active: Boolean(tunnelService.getPublicUrl()), }, scheduledTasks: scheduledTasksRuntime.getStatus(), }), isReady: () => isOpenCodeReady, restartOpenCode: () => restartOpenCode(), getOpenCodeProcessInfo: () => { const managed = Boolean((openCodeProcess || openCodePort) && !ENV_SKIP_OPENCODE_START && !isExternalOpenCode); // Only ever expose pid/port for a server WE manage. The Electron-side // killer kills by port (lsof + kill -KILL), so returning a port we don't // own — e.g. an external/desktop OpenCode on 4096 we attached to — would // let a single miscomputed `managed` flag take down the user's separate // server. Structurally withhold what isn't ours so the killer has no // target, instead of relying on the flag check alone. return { managed, pid: managed && typeof openCodeProcess?.pid === 'number' ? openCodeProcess.pid : null, port: managed ? openCodePort : null, }; }, stop: (shutdownOptions = {}) => { realtimeProxyRuntime.stop(); clearInterval(relayReconcileTimer); try { relayService.stop(); } catch { // best-effort teardown of the relay host client } try { dictationRuntime?.stop?.(); } catch { // best-effort shutdown of the dictation worker } return gracefulShutdown({ exitProcess: shutdownOptions.exitProcess ?? false }); } }; } runCliEntryIfMain({ process, currentFilename: __filename, parseServeCliOptions, defaultPort: DEFAULT_PORT, cloudflareProvider: TUNNEL_PROVIDER_CLOUDFLARE, managedLocalMode: TUNNEL_MODE_MANAGED_LOCAL, setExitOnShutdown: (value) => { exitOnShutdown = value; }, startServer: main, }); export { gracefulShutdown, setupProxy, restartOpenCode, main as startWebUiServer, parseServeCliOptions as parseArgs, };