Files
openchamber/packages/web/server/index.js
T
Alex Kutas 49f0a9e62f OPE-296: Add linear integration for starting sessions from issues (#3235)
* feat(linear): start sessions from Linear issues
Authorize a Linear workspace on this OpenChamber server, map teams to
projects, attach an issue from chat, start a session or worktree from an
issue, and post started/completed/failed comments that open the session.
Hidden in VS Code.

* feat(linear): connect more than one Linear workspace

Store each OAuth grant on this OpenChamber server and keep one current, so Settings can add and switch workspaces without dropping the others. Project mapping is per workspace. Remove the Linear button next to New Chat; start-from-issue stays on New Worktree.

* feat(linear): add a right-hand issues panel

Browse and filter issues in the rail, open a card to change status or start a session, and collapse search plus most filters to icons on a narrow panel.

* feat(linear): open issues in the rail and filter by Linear status

The rail icon only shows after Linear is connected. Clicking a Linear row on work status opens the panel. Status options match the card, including Done, Canceled, and Duplicate. The Integrations experimental warning sits under Third-party integrations.

* fix(linear): use stable OAuth callback broker

* fix(chat): preview Linear issue attachments

The context switch missed linear-issue, so tsc treated the preview helpers as incomplete.

* fix(ui): restore Linear i18n parity and the #2903 sync harness

Turkish was missing the Linear dictionaries, and the subagent test still wrapped only SyncContext after reads moved to SyncRuntimeContext.

* fix(linear): drop changelog hunks and close review races

Keep changelogs out of this PR, restore CodeMirror ranges, ignore stale Linear list pages, and leave a persisted Linear tab open until auth has actually resolved.

* fix(linear): tint active issue filters and clear them in one click

* fix(markdown): read escaped brackets as text, not display math

`\[...\]` is display math in LaTeX and an escaped bracket pair in
CommonMark. The block tokenizer claimed every `\[`, so prose like
`[title \[Bug\] more](url)` was handed to KaTeX: "Bug" rendered as a
centered formula and the block token split the paragraph, tearing the
link into three pieces. Linear, GitHub and any other source that escapes
brackets the way CommonMark requires hit this.

Display math now has to own its line — `\[` starts one and `\]` ends
one. A formula on its own line still renders; `\[` mid-sentence stays an
escape, which is what CommonMark says it is and what prose almost always
means. Inline `\(...\)` keeps the same ambiguity, but inline math is
legitimately mid-sentence, so there is no position to judge it by.

Covered by regression tests, including the verbatim comment body that
surfaced this.

* feat(linear): make session status comments opt-in and public-only

A status comment lands in a Linear workspace the whole team reads, and
the link it carried pointed at whatever origin started the session —
usually loopback or a LAN address. Everyone but its author got a dead
link, and nobody had agreed to the comments in the first place.

Comments are now off until the user turns them on in Settings ->
Integrations -> Linear, and the check lives on the server: the event hub
posts completed and failure without going through the interface, so a
client-side gate would not hold. When the resolved origin is not
publicly reachable the server posts nothing at all rather than a link
only its author can open; `isPublicSessionOrigin` rejects loopback,
private LAN, carrier-grade NAT, link-local and single-label hosts. The
desktop deep-link origin is gone with it, since no one else can follow
one either.

The comment body also dropped the session title. It repeated the issue
the comment already sits on, and issue titles routinely carry brackets
("[Bug] ...") that broke the markdown link. The body is now one short
link, and `sessionTitle` is gone from the route, client and types.

Also caps the dedupe file at the newest 500 sessions; it grew forever.

* fix(linear): match the pull request panel and clear review findings

Comments in the Linear panel now render as the same avatar timeline the
pull request panel uses, with the shared time-format preference instead
of a raw locale string. Comment authors carry `avatarUrl`, which the
GraphQL selection was not requesting.

Review findings from the same pass:

- `status-runtime.js` hand-rolled `typeof` narrowing and failed the
  vendored anti-slop lint; it now parses through `parse.js` like every
  other file in the module.
- `useLinearAuthStore` turned any failed request into `connected: false`
  with `hasChecked: true`. Since the rail icon, the composer entry and
  the worktree option all gate on `connected === true`, one network blip
  hid Linear for the rest of the session, and Settings only re-checked
  when it had never checked. It now keeps the last known status and
  leaves `hasChecked` false so the next caller retries.
- `LinearIssuesView` (1096 lines) was a static import in `ContextPanel`,
  shipping in the main bundle although its rail icon stays hidden until
  a workspace is connected. It is lazy now, like `GitView`.
- Dropped dead code: the unused port helpers left over from the loopback
  callback, two re-exported default values nothing read, and a redundant
  export in `linkedIssues`.
- Integrations is no longer badged beta.
2026-08-30 02:18:40 +03:00

2059 lines
84 KiB
JavaScript

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 { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js';
import { createProjectConfigRuntime } from './lib/projects/project-config.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 { 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';
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',
];
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 sanitizeTypographySizesPartial = (...args) =>
settingsNormalizationRuntime.sanitizeTypographySizesPartial(...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);
const OPENCHAMBER_USER_CONFIG_ROOT = path.join(os.homedir(), '.config', 'openchamber');
const OPENCHAMBER_USER_THEMES_DIR = path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'themes');
const OPENCHAMBER_PROJECTS_CONFIG_DIR = path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'projects');
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;
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 = 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 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,
sanitizeTypographySizesPartial,
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 projectConfigRuntime = createProjectConfigRuntime({
fsPromises,
path,
projectsDirPath: OPENCHAMBER_PROJECTS_CONFIG_DIR,
});
const projectContextRuntime = createProjectContextRuntime({
fsPromises,
path,
projectsDirPath: OPENCHAMBER_PROJECTS_CONFIG_DIR,
});
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),
);
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 }) || {})
: {};
if (settings?.optimizeSystemPrompt !== true) return managedEnv;
const configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT;
const systemPromptEnv = await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent);
return { ...managedEnv, ...systemPromptEnv };
},
});
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: [path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'chats')],
});
/**
* 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,
});
// 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,
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,
});
// 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;
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');
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');
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,
});
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,
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,
});
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,
};