perf: harden sync architecture and modularize runtimes (#803)

* fix: added desktop app background throttling

* perf: add streaming debug metrics panel

- Show streaming performance metrics in the debug panel
- Auto-enable stream profiling while the panel is open
- Add JSON export for sharing UI and VS Code metrics

* perf: batch streaming updates more aggressively

- Buffer message deltas and metadata updates to cut render churn
- Skip no-op part updates before they touch the message store
- Fix the desktop debug panel shortcut binding

* perf: split streaming event handling and coalesce deltas

- Move streaming content events onto a dedicated fast path
- Defer non-critical stream side effects off the hot path
- Merge repeated message delta events before they reach the UI

* perf: isolate streaming rows from chat rerenders

- Memoize chat rows against render-relevant message changes only
- Read live assistant text directly from store to narrow streaming updates
- Split the active streaming entry from the stable message list path

* perf: streamline chat streaming and SSE proxying

- Reduce chat rerenders around the active streaming path
- Simplify server SSE forwarding to avoid duplicate proxy work

* fix: preserve the first streaming text chunk

- Show the initial text chunk immediately before batched deltas arrive
- Bypass batching for the first text or reasoning part update
- Keep later streaming updates buffered for performance

* perf: align streaming/render hot paths with opencode parity

* perf: harden turn/cache stability and stale delta suppression

* fix: stabilize chat rendering and disable timeline interactions

- Disabled timeline dialog access from shortcuts, commands, and chat input
- Reduced chat render churn by simplifying message list and turn staging behavior
- Improved session-switch stability to prevent update-depth crashes

* perf: track static message rerenders during streaming

* perf: reduce sorted-mode activity rerender fanout

* perf: reduce chat rerender fanout and add active-turn metrics

- Reduced sorted-mode rerender coupling by tightening turn context propagation
- Added a metric for static rerenders outside the active turn during streaming
- Exposed new chat render counters in the debug panel for parity tracking

* fix: keep sorted activity mounted while stream grows

* fix: stabilize session and history scroll rendering

* refactor: decouple server routes from index

* refactor: extract fs module from server index

* refactor: move opencode route ownership into module

* refactor: extract notification route registration

* refactor: extract opencode and notification runtimes from index

* refactor: extract settings runtime and complete server modularization pass

* refactor: modularize server config, skills, icons, and tunnel routes

* refactor: extract server modules from monolithic index.js

Split proxy, routes, runtime helpers, and notification emitter
into dedicated modules under packages/web/server/lib/.

* refactor: replace session/message stores with SSE-driven sync layer

Delete ~9200 lines of old architecture (useEventStream, messageStore,
sessionStore, useSessionStore, questionStore, useTodoStore, client SSE).

New sync layer: event pipeline with coalescing + 16ms flush, pure event
reducer, per-directory child stores with LRU eviction, cursor pagination,
optimistic updates, deferred timeline staging, text throttle.

Migrate all UI consumers to sync hooks (useSessionMessages,
useSessionMessageRecords, useSessionStatus, useSessionPermissions, etc).

Strip session-ui-store to UI-only state, delegate SDK ops to
session-actions with abort-if-busy, optimistic store updates, and
response merging for revert/fork/archive/delete.

Add notification-store for SSE-driven session attention tracking,
cross-directory GlobalSessionStatusStore for sidebar indicators,
client-side diff snapshot sanitization to prevent memory bloat,
and revert message filtering via useVisibleSessionMessages.

* feat: notification store, session actions, activity detection

Add notification-store.ts for SSE-driven attention tracking.
Add sanitize.ts to strip diff snapshot memory bloat.
Add session-actions.ts with optimistic revert/fork/archive/delete.
Improve useSessionActivity with incomplete-message fallback.
Delete useServerSessionStatus polling hook.

* fix: add directory param to all SDK calls, fix command/shell/abort routing

All SDK calls in session-actions.ts now pass directory parameter —
required by OpenCode server to scope session operations. Without it,
abort, commands, revert, fork, and other operations returned 500.

Add routeMessage() in session-ui-store for shell mode (session.shell),
slash commands (session.command), and normal prompts. Command lookup
checks both sync child store and useCommandsStore. Handle /compact
locally via session.summarize().

Implement getContextUsage() to restore header context usage display —
reads token counts from last assistant message in sync store.

* refactor: replace custom API proxy with http-proxy-middleware

Remove ~280 lines of custom proxy code: forwardSseRequest,
forwardGenericApiRequest, collectRequestBodyBuffer, header
manipulation, hop-by-hop filtering, SSE block buffering.

Replace with single createProxyMiddleware() call that handles
SSE streaming, large bodies, and timeouts out of the box.
Dynamic router for OpenCode port changes after restarts.
Auth headers injected via proxyReq hook.

Keep: readiness gate, Windows session merge, API prefix detection.

* perf: targeted event draft cloning to fix streaming render cascade

Event handler was eagerly cloning all state slices on every event,
breaking Zustand selector referential equality. During streaming
(~60 events/sec), this caused every subscriber to re-render regardless
of which slice actually changed.

Now only clones fields the specific event type mutates. Also extracts
StatusRowContainer to isolate high-frequency useAssistantStatus
subscription, removes dead messageStreamStatesMap subscription from
ChatContainer, and narrows useAssistantStatus to only track last
assistant message parts.

MessageList renders: 1972 → 296 per streaming session (-85%).

* fix: null safety for sync state slices

Add defensive ?? {} guards on permission, question, session_status,
and message record access. Prevents crashes when child store state
is partially initialized during bootstrap.

* perf: dedup inflight SDK calls, extract concurrency util, delay PR tracking

Extract mapWithConcurrency to shared lib/concurrency.ts. Add in-flight
dedup for loadProviders/loadAgents to prevent concurrent duplicate SDK
calls. Delay initial PR background tracking by 5s to reduce startup
CPU burst.

* fix: header session lookup across all child stores

Session title and context panel click failed when session belonged to
a different directory than the current child store. Fall back to
getAllSyncSessions() to search all initialized stores.

* chore: bump @opencode-ai/sdk to 1.3.5

* docs: add sync event handling guide

* Optimize session prefetch and improve delete/archive UX

- Add settlement delay to session prefetch to avoid race conditions on
  rapid session switches
- Reduce git diff prefetch and session cache limits for better performance
- Implement optimistic UI updates for session delete/archive operations
  with proper rollback on failure
- Wire session prefetch hook into SessionSidebar with sync integration

* Add file content cache and sync optimizations

- Wrap FilesAPI with in-memory LRU cache for file content with dual
  constraints (entry count and byte size)
- Optimize chat timeline scroll restoration using useLayoutEffect
- Preserve React references in message and part arrays to prevent
  unnecessary re-renders when prepending history
- Add session prefetch TTL cache to prevent redundant fetches
- Integrate session prefetch cache clearing with eviction flow

* Improve session sidebar error handling and add diff prefetch filtering

Load active and archived sessions independently using Promise.allSettled
to prevent one failure from blocking the other. Add retry logic to session
API calls and skip large files during diff prefetch to improve performance.

* Replace sendMessage with optimisticSend wrapper

Introduces optimistic UI updates for normal chat messages to provide
instant feedback. Messages appear immediately in the UI while the API
call executes in the background, with automatic rollback on errors.

* perf: split stores, proper optimistic send, fix revert/directory bugs

- split session-ui-store into voice/input/selection/viewport stores
  to reduce subscriber re-evaluation during streaming
- wire optimisticSend through useSync shadow Map infrastructure
  matching OpenCode's pattern (no heuristic part detection)
- port OpenCode Identifier.ascending ID format for correct sorting
- pass messageID to promptAsync to prevent duplicate messages
- fix worktree directory not propagating to session actions
  (dynamic dir() via opencodeClient.getDirectory)
- fix setCurrentSession accepting directoryHint for new sessions
- fix revert not hiding messages (session limit was 5, bumped to match loaded count)
- fix revert optimistic message removal from store
- fix load-more flicker (useLayoutEffect scroll compensation)
- add prefetch TTL cache, file content LRU cache
- add session prefetch for adjacent sessions
- add instant archive/delete (optimistic before SDK call)
- migrate legacy window.__zustand_session_store__ to session-ui-store
- add retry + independent error handling for archived sessions
- add AGENTS.md performance rules

* perf: startup optimization — dedup, caching, light git status, diff rendering gates

- defer diff prefetch to git tab open, reduce concurrency 4→2, skip >500 changed lines
- cap project git checks concurrency (2), directory status probe (3)
- dedup provider/agent loading, github auth, worktree list (in-flight + TTL caches)
- delay PR tracking 5s, cache 403 search failures per-repo
- coalesce settings PUT (200ms debounce), cache settings GET (2s TTL)
- cache canonical directory resolution (60s TTL)
- persist missing directory status to localStorage (10min TTL)
- light/heavy git status: polling skips numstat+line counting+rev-list
- large diff rendering gate (>500 lines → "render anyway" button)
- tokenization degradation for >500KB files in Pierre
- parallelize main.tsx pre-render awaits
- batch sidebar file tree expanded paths restoration (3 at a time)
- remove bare useConfigStore() subscription in AgentsPage
- sync worktree sandboxes to OpenCode SQLite DB
- fix RightSidebarTabs ternary → explicit tab matching
- defensive guards on sync state (session_status, permission, question, message)

* fix: add defensive guards on remaining sync state field accesses

guard session_status, permission, message, todo, part, config with ?? {}
in useDirectorySync selectors, session-cache, and bootstrap

* fix: add missing directory dep to useCallback in use-sync.ts

* fix: preserve diffStats when light-mode polling overwrites status

* perf: optimize startup git status polling and diff rendering

Preserves diff stats when lightweight polling updates repository status
Reduces startup overhead with smarter git polling and store updates
Adds detailed optimization and migration docs for next performance steps

* fix: keep chat diff stats stable during git status updates

Prevents lightweight git polling from dropping diff statistics
Keeps MessageList diff indicators consistent while status refreshes
Improves reliability of git-aware chat rendering

* fix: user animation replay, queued message variant, startup provider loading

- consume animation ID after first play to prevent re-animation
  on neighbor assistant message completion
- capture send config (model/agent/variant) at queue time matching
  OpenCode's FollowupDraft pattern instead of re-resolving at send time
- replace one-shot startup recovery effect with polling interval
  that retries every 2s until providers and agents load
- fix optimistic bridge to avoid re-render loop (stable ref wrappers)

* chore: update tauri to 2.10.3 and all plugins to latest

- tauri 2.9.4 → 2.10.3
- tauri-build 2.5.3 → 2.5.6
- tauri-plugin-dialog 2.4.2 → 2.6.0
- tauri-plugin-log 2.7.1 → 2.8.0
- tauri-plugin-shell 2.3.3 → 2.3.5
- tauri-plugin-updater 2 (floating) → 2.10.0 (pinned)
- @tauri-apps/api ^2.9.0 → ^2.10.1
- wry 0.53.5 → 0.54.4 (transitive)

* refactor: decouple web server index orchestration runtimes

* fix: align VS Code runtime behavior with web and reduce draft view CPU load

- Queue VS Code bridge and SSE startup requests until API readiness to avoid false bootstrap failures
- Make agent manager actions directory-aware and remove real worktrees with safer partial-failure handling
- Replace heavy logo animation path with a lightweight pulse to cut draft-session CPU usage

* fix: restore auto-selected file sending in chat input

- Send server-selected files as proper file URLs in the message payload
- Include server-backed attachments in submit flow instead of dropping them
- Restore queued-message attachments through the refactored input store

* fix: restore session model selection consistently on session switch

- Restore agent, model, and variant from the latest loaded user message for each session
- Wait for session messages before applying restored selections to avoid stale or missing state
- Remove legacy session-choice inference paths that caused overlap and instability

* fix: restore permission replies and auto-accept across sessions

- Scope permission and question replies to the target session directory so answers take effect reliably
- Make permission auto-accept immediately handle pending requests and react to new permission prompts
- Keep parent-session handling working for child-session requests through the shared response path

* feat: add reusable fuzzy branch fuzzy-search helper and dialog integration (#798)

* feat: add reusable fuzzy branch search for worktrees

* chore: drop planning docs from feature branch

* feat: make worktree branch refresh manual

* feat: add configurable session retention action

* refactor: centralize global session state in ui store

* fix: cancel debounced permission push after reply

* docs: clarify global and directory session store architecture

* docs: refine agent development rules and session activity guidance

- Clarify agent code of conduct and durable development patterns
- Add explicit shared-store rerender and live-state guidance
- Narrow session activity fallback to avoid stale working state

* chore: updated .gitignore

---------

Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
This commit is contained in:
Bohdan Triapitsyn
2026-03-31 18:47:00 +03:00
committed by GitHub
co-authored by Iuliia Ivashko
parent 8dfe833faf
commit c9e31a0e6c
245 changed files with 31986 additions and 32683 deletions
+733 -14154
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
# FS Module Documentation
## Purpose
Own filesystem API behavior for the web server runtime, including workspace-bound file operations, directory listing, reveal, and background command execution jobs.
## Entrypoints and structure
- `packages/web/server/lib/fs/routes.js`: route registration and runtime-owned state for `/api/fs/*` endpoints.
- `packages/web/server/lib/fs/search.js`: fuzzy filesystem search runtime used by non-FS routes (for example project icon discovery).
## Public exports
- `registerFsRoutes(app, dependencies)` from `routes.js`
- Registers all filesystem routes:
- `GET /api/fs/home`
- `POST /api/fs/mkdir`
- `GET /api/fs/read`
- `GET /api/fs/raw`
- `POST /api/fs/write`
- `POST /api/fs/delete`
- `POST /api/fs/rename`
- `POST /api/fs/reveal`
- `POST /api/fs/exec`
- `GET /api/fs/exec/:jobId`
- `GET /api/fs/list`
- Owns exec job queue state (`execJobs`) and lifecycle/TTL pruning.
- Enforces workspace boundary checks with active project + worktree fallback support.
- `createFsSearchRuntime({ fsPromises, path, spawn, resolveGitBinaryForSpawn })` from `search.js`
- Returns `{ searchFilesystemFiles(rootPath, options) }`.
- Supports fuzzy matching, hidden-file handling, and optional `git check-ignore` filtering.
## Composition contract with `index.js`
- `index.js` provides composition-time dependencies only (platform primitives + callbacks such as `resolveProjectDirectory`, `normalizeDirectoryPath`, and `buildAugmentedPath`).
- `index.js` no longer owns FS route handlers or FS exec job state.
## Notes for contributors
- Keep filesystem policy (workspace root checks, error mapping, exec timeout behavior) inside this module, not in the composition root.
- If adding new `/api/fs/*` endpoints, add them in `routes.js` and extend this document.
+760
View File
@@ -0,0 +1,760 @@
const EXEC_JOB_TTL_MS = 30 * 60 * 1000;
const createCommandTimeoutMs = () => {
const raw = Number(process.env.OPENCHAMBER_FS_EXEC_TIMEOUT_MS);
if (Number.isFinite(raw) && raw > 0) return raw;
return 5 * 60 * 1000;
};
const isPathWithinRoot = (resolvedPath, rootPath, path, os) => {
const resolvedRoot = path.resolve(rootPath || os.homedir());
const relative = path.relative(resolvedRoot, resolvedPath);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
return false;
}
return true;
};
const resolveWorkspacePath = ({ targetPath, baseDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
const normalized = normalizeDirectoryPath(targetPath);
if (!normalized || typeof normalized !== 'string') {
return { ok: false, error: 'Path is required' };
}
const resolved = path.resolve(normalized);
const resolvedBase = path.resolve(baseDirectory || os.homedir());
if (isPathWithinRoot(resolved, resolvedBase, path, os)) {
return { ok: true, base: resolvedBase, resolved };
}
if (isPathWithinRoot(resolved, openchamberUserConfigRoot, path, os)) {
return { ok: true, base: path.resolve(openchamberUserConfigRoot), resolved };
}
return { ok: false, error: 'Path is outside of active workspace' };
};
const resolveWorkspacePathFromWorktrees = async ({ targetPath, baseDirectory, path, os, normalizeDirectoryPath }) => {
const normalized = normalizeDirectoryPath(targetPath);
if (!normalized || typeof normalized !== 'string') {
return { ok: false, error: 'Path is required' };
}
const resolved = path.resolve(normalized);
const resolvedBase = path.resolve(baseDirectory || os.homedir());
try {
const { getWorktrees } = await import('../git/index.js');
const worktrees = await getWorktrees(resolvedBase);
for (const worktree of worktrees) {
const candidatePath = typeof worktree?.path === 'string'
? worktree.path
: (typeof worktree?.worktree === 'string' ? worktree.worktree : '');
const candidate = normalizeDirectoryPath(candidatePath);
if (!candidate) {
continue;
}
const candidateResolved = path.resolve(candidate);
if (isPathWithinRoot(resolved, candidateResolved, path, os)) {
return { ok: true, base: candidateResolved, resolved };
}
}
} catch (error) {
console.warn('Failed to resolve worktree roots:', error);
}
return { ok: false, error: 'Path is outside of active workspace' };
};
const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProjectDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
const resolvedProject = await resolveProjectDirectory(req);
if (!resolvedProject.directory) {
return { ok: false, error: resolvedProject.error || 'Active workspace is required' };
}
const resolved = resolveWorkspacePath({
targetPath,
baseDirectory: resolvedProject.directory,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
if (resolved.ok || resolved.error !== 'Path is outside of active workspace') {
return resolved;
}
return resolveWorkspacePathFromWorktrees({
targetPath,
baseDirectory: resolvedProject.directory,
path,
os,
normalizeDirectoryPath,
});
};
const runCommandInDirectory = ({ shell, shellFlag, command, resolvedCwd, spawn, buildAugmentedPath, commandTimeoutMs }) => {
return new Promise((resolve) => {
let stdout = '';
let stderr = '';
let timedOut = false;
const envPath = buildAugmentedPath();
const execEnv = { ...process.env, PATH: envPath };
const child = spawn(shell, [shellFlag, command], {
cwd: resolvedCwd,
env: execEnv,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
const timeout = setTimeout(() => {
timedOut = true;
try {
child.kill('SIGKILL');
} catch {
}
}, commandTimeoutMs);
child.stdout?.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
clearTimeout(timeout);
resolve({
command,
success: false,
exitCode: undefined,
stdout: stdout.trim(),
stderr: stderr.trim(),
error: (error && error.message) || 'Command execution failed',
});
});
child.on('close', (code, signal) => {
clearTimeout(timeout);
const exitCode = typeof code === 'number' ? code : undefined;
const base = {
command,
success: exitCode === 0 && !timedOut,
exitCode,
stdout: stdout.trim(),
stderr: stderr.trim(),
};
if (timedOut) {
resolve({
...base,
success: false,
error: `Command timed out after ${commandTimeoutMs}ms` + (signal ? ` (${signal})` : ''),
});
return;
}
resolve(base);
});
});
};
export const registerFsRoutes = (app, dependencies) => {
const {
os,
path,
fsPromises,
spawn,
crypto,
normalizeDirectoryPath,
resolveProjectDirectory,
buildAugmentedPath,
resolveGitBinaryForSpawn,
openchamberUserConfigRoot,
} = dependencies;
const execJobs = new Map();
const commandTimeoutMs = createCommandTimeoutMs();
const pruneExecJobs = () => {
const now = Date.now();
for (const [jobId, job] of execJobs.entries()) {
if (!job || typeof job !== 'object') {
execJobs.delete(jobId);
continue;
}
const updatedAt = typeof job.updatedAt === 'number' ? job.updatedAt : 0;
if (updatedAt && now - updatedAt > EXEC_JOB_TTL_MS) {
execJobs.delete(jobId);
}
}
};
const runExecJob = async (job) => {
job.status = 'running';
job.updatedAt = Date.now();
const results = [];
for (const command of job.commands) {
if (typeof command !== 'string' || !command.trim()) {
results.push({ command, success: false, error: 'Invalid command' });
continue;
}
try {
const result = await runCommandInDirectory({
shell: job.shell,
shellFlag: job.shellFlag,
command,
resolvedCwd: job.resolvedCwd,
spawn,
buildAugmentedPath,
commandTimeoutMs,
});
results.push(result);
} catch (error) {
results.push({
command,
success: false,
error: (error && error.message) || 'Command execution failed',
});
}
job.results = results;
job.updatedAt = Date.now();
}
job.results = results;
job.success = results.every((r) => r.success);
job.status = 'done';
job.finishedAt = Date.now();
job.updatedAt = Date.now();
};
app.get('/api/fs/home', (_req, res) => {
try {
const home = os.homedir();
if (!home || typeof home !== 'string' || home.length === 0) {
return res.status(500).json({ error: 'Failed to resolve home directory' });
}
return res.json({ home });
} catch (error) {
console.error('Failed to resolve home directory:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to resolve home directory' });
}
});
app.post('/api/fs/mkdir', async (req, res) => {
try {
const { path: dirPath, allowOutsideWorkspace } = req.body ?? {};
if (typeof dirPath !== 'string' || !dirPath.trim()) {
return res.status(400).json({ error: 'Path is required' });
}
let resolvedPath = '';
if (allowOutsideWorkspace) {
resolvedPath = path.resolve(normalizeDirectoryPath(dirPath));
} else {
const resolved = await resolveWorkspacePathFromContext({
req,
targetPath: dirPath,
resolveProjectDirectory,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
if (!resolved.ok) {
return res.status(400).json({ error: resolved.error });
}
resolvedPath = resolved.resolved;
}
await fsPromises.mkdir(resolvedPath, { recursive: true });
return res.json({ success: true, path: resolvedPath });
} catch (error) {
console.error('Failed to create directory:', error);
return res.status(500).json({ error: error.message || 'Failed to create directory' });
}
});
app.get('/api/fs/read', async (req, res) => {
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
if (!filePath) {
return res.status(400).json({ error: 'Path is required' });
}
try {
const resolved = await resolveWorkspacePathFromContext({
req,
targetPath: filePath,
resolveProjectDirectory,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
if (!resolved.ok) {
return res.status(400).json({ error: resolved.error });
}
const [canonicalPath, canonicalBase] = await Promise.all([
fsPromises.realpath(resolved.resolved),
fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)),
]);
if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) {
return res.status(403).json({ error: 'Access to file denied' });
}
const stats = await fsPromises.stat(canonicalPath);
if (!stats.isFile()) {
return res.status(400).json({ error: 'Specified path is not a file' });
}
const content = await fsPromises.readFile(canonicalPath, 'utf8');
return res.type('text/plain').send(content);
} catch (error) {
const err = error;
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return res.status(404).json({ error: 'File not found' });
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access to file denied' });
}
console.error('Failed to read file:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to read file' });
}
});
app.get('/api/fs/raw', async (req, res) => {
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
if (!filePath) {
return res.status(400).json({ error: 'Path is required' });
}
try {
const resolved = await resolveWorkspacePathFromContext({
req,
targetPath: filePath,
resolveProjectDirectory,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
if (!resolved.ok) {
return res.status(400).json({ error: resolved.error });
}
const [canonicalPath, canonicalBase] = await Promise.all([
fsPromises.realpath(resolved.resolved),
fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)),
]);
if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) {
return res.status(403).json({ error: 'Access to file denied' });
}
const stats = await fsPromises.stat(canonicalPath);
if (!stats.isFile()) {
return res.status(400).json({ error: 'Specified path is not a file' });
}
const ext = path.extname(canonicalPath).toLowerCase();
const mimeMap = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
'.bmp': 'image/bmp',
'.avif': 'image/avif',
};
const mimeType = mimeMap[ext] || 'application/octet-stream';
const content = await fsPromises.readFile(canonicalPath);
res.setHeader('Cache-Control', 'no-store');
return res.type(mimeType).send(content);
} catch (error) {
const err = error;
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return res.status(404).json({ error: 'File not found' });
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access to file denied' });
}
console.error('Failed to read raw file:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to read file' });
}
});
app.post('/api/fs/write', async (req, res) => {
const { path: filePath, content } = req.body || {};
if (!filePath || typeof filePath !== 'string') {
return res.status(400).json({ error: 'Path is required' });
}
if (typeof content !== 'string') {
return res.status(400).json({ error: 'Content is required' });
}
try {
const resolved = await resolveWorkspacePathFromContext({
req,
targetPath: filePath,
resolveProjectDirectory,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
if (!resolved.ok) {
return res.status(400).json({ error: resolved.error });
}
await fsPromises.mkdir(path.dirname(resolved.resolved), { recursive: true });
await fsPromises.writeFile(resolved.resolved, content, 'utf8');
return res.json({ success: true, path: resolved.resolved });
} catch (error) {
const err = error;
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access denied' });
}
console.error('Failed to write file:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to write file' });
}
});
app.post('/api/fs/delete', async (req, res) => {
const { path: targetPath } = req.body || {};
if (!targetPath || typeof targetPath !== 'string') {
return res.status(400).json({ error: 'Path is required' });
}
try {
const resolved = await resolveWorkspacePathFromContext({
req,
targetPath,
resolveProjectDirectory,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
if (!resolved.ok) {
return res.status(400).json({ error: resolved.error });
}
await fsPromises.rm(resolved.resolved, { recursive: true, force: true });
return res.json({ success: true, path: resolved.resolved });
} catch (error) {
const err = error;
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return res.status(404).json({ error: 'File or directory not found' });
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access denied' });
}
console.error('Failed to delete path:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to delete path' });
}
});
app.post('/api/fs/rename', async (req, res) => {
const { oldPath, newPath } = req.body || {};
if (!oldPath || typeof oldPath !== 'string') {
return res.status(400).json({ error: 'oldPath is required' });
}
if (!newPath || typeof newPath !== 'string') {
return res.status(400).json({ error: 'newPath is required' });
}
try {
const resolvedOld = await resolveWorkspacePathFromContext({
req,
targetPath: oldPath,
resolveProjectDirectory,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
if (!resolvedOld.ok) {
return res.status(400).json({ error: resolvedOld.error });
}
const resolvedNew = await resolveWorkspacePathFromContext({
req,
targetPath: newPath,
resolveProjectDirectory,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
if (!resolvedNew.ok) {
return res.status(400).json({ error: resolvedNew.error });
}
if (resolvedOld.base !== resolvedNew.base) {
return res.status(400).json({ error: 'Source and destination must share the same workspace root' });
}
await fsPromises.rename(resolvedOld.resolved, resolvedNew.resolved);
return res.json({ success: true, path: resolvedNew.resolved });
} catch (error) {
const err = error;
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return res.status(404).json({ error: 'Source path not found' });
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return res.status(403).json({ error: 'Access denied' });
}
console.error('Failed to rename path:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to rename path' });
}
});
app.post('/api/fs/reveal', async (req, res) => {
const { path: targetPath } = req.body || {};
if (!targetPath || typeof targetPath !== 'string') {
return res.status(400).json({ error: 'Path is required' });
}
try {
const resolved = path.resolve(targetPath.trim());
await fsPromises.access(resolved);
const platform = process.platform;
if (platform === 'darwin') {
const stat = await fsPromises.stat(resolved);
if (stat.isDirectory()) {
spawn('open', [resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
} else {
spawn('open', ['-R', resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
}
} else if (platform === 'win32') {
spawn('explorer', ['/select,', resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
} else {
const stat = await fsPromises.stat(resolved);
const dir = stat.isDirectory() ? resolved : path.dirname(resolved);
spawn('xdg-open', [dir], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
}
return res.json({ success: true, path: resolved });
} catch (error) {
const err = error;
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return res.status(404).json({ error: 'Path not found' });
}
console.error('Failed to reveal path:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to reveal path' });
}
});
app.post('/api/fs/exec', async (req, res) => {
const { commands, cwd, background } = req.body || {};
if (!Array.isArray(commands) || commands.length === 0) {
return res.status(400).json({ error: 'Commands array is required' });
}
if (!cwd || typeof cwd !== 'string') {
return res.status(400).json({ error: 'Working directory (cwd) is required' });
}
pruneExecJobs();
try {
const resolvedCwd = path.resolve(normalizeDirectoryPath(cwd));
const stats = await fsPromises.stat(resolvedCwd);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Specified cwd is not a directory' });
}
const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh');
const shellFlag = process.platform === 'win32' ? '/c' : '-c';
const jobId = crypto.randomUUID();
const job = {
jobId,
status: 'queued',
success: null,
commands,
resolvedCwd,
shell,
shellFlag,
results: [],
startedAt: Date.now(),
finishedAt: null,
updatedAt: Date.now(),
};
execJobs.set(jobId, job);
const isBackground = background === true;
if (isBackground) {
void runExecJob(job).catch((error) => {
job.status = 'done';
job.success = false;
job.results = Array.isArray(job.results) ? job.results : [];
job.results.push({
command: '',
success: false,
error: (error && error.message) || 'Command execution failed',
});
job.finishedAt = Date.now();
job.updatedAt = Date.now();
});
return res.status(202).json({
jobId,
status: 'running',
});
}
await runExecJob(job);
return res.json({
jobId,
status: job.status,
success: job.success === true,
results: job.results,
});
} catch (error) {
console.error('Failed to execute commands:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to execute commands' });
}
});
app.get('/api/fs/exec/:jobId', (req, res) => {
const jobId = typeof req.params?.jobId === 'string' ? req.params.jobId : '';
if (!jobId) {
return res.status(400).json({ error: 'Job id is required' });
}
pruneExecJobs();
const job = execJobs.get(jobId);
if (!job) {
return res.status(404).json({ error: 'Job not found' });
}
job.updatedAt = Date.now();
return res.json({
jobId: job.jobId,
status: job.status,
success: job.success === true,
results: Array.isArray(job.results) ? job.results : [],
});
});
app.get('/api/fs/list', async (req, res) => {
const rawPath = typeof req.query.path === 'string' && req.query.path.trim().length > 0
? req.query.path.trim()
: os.homedir();
const respectGitignore = req.query.respectGitignore === 'true';
let resolvedPath = '';
const isPlansDirectory = (value) => {
if (!value || typeof value !== 'string') return false;
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
return normalized.endsWith('/.opencode/plans') || normalized.endsWith('.opencode/plans');
};
try {
resolvedPath = path.resolve(normalizeDirectoryPath(rawPath));
const stats = await fsPromises.stat(resolvedPath);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Specified path is not a directory' });
}
const dirents = await fsPromises.readdir(resolvedPath, { withFileTypes: true });
let ignoredPaths = new Set();
if (respectGitignore) {
try {
const pathsToCheck = dirents.map((d) => d.name);
if (pathsToCheck.length > 0) {
try {
const result = await new Promise((resolve) => {
const child = spawn(resolveGitBinaryForSpawn(), ['check-ignore', '--', ...pathsToCheck], {
cwd: resolvedPath,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
child.stdout.on('data', (data) => { stdout += data.toString(); });
child.on('close', () => resolve(stdout));
child.on('error', () => resolve(''));
});
result.split('\n').filter(Boolean).forEach((name) => {
const fullPath = path.join(resolvedPath, name.trim());
ignoredPaths.add(fullPath);
});
} catch {
}
}
} catch {
}
}
const entries = await Promise.all(
dirents.map(async (dirent) => {
const entryPath = path.join(resolvedPath, dirent.name);
if (respectGitignore && ignoredPaths.has(entryPath)) {
return null;
}
let isDirectory = dirent.isDirectory();
const isSymbolicLink = dirent.isSymbolicLink();
if (!isDirectory && isSymbolicLink) {
try {
const linkStats = await fsPromises.stat(entryPath);
isDirectory = linkStats.isDirectory();
} catch {
isDirectory = false;
}
}
return {
name: dirent.name,
path: entryPath,
isDirectory,
isFile: dirent.isFile(),
isSymbolicLink,
};
})
);
return res.json({
path: resolvedPath,
entries: entries.filter(Boolean),
});
} catch (error) {
const err = error;
const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
const isPlansPath = code === 'ENOENT' && (isPlansDirectory(resolvedPath) || isPlansDirectory(rawPath));
if (!isPlansPath) {
console.error('Failed to list directory:', error);
}
if (code === 'ENOENT') {
if (isPlansPath) {
return res.json({ path: resolvedPath || rawPath, entries: [] });
}
return res.status(404).json({ error: 'Directory not found' });
}
if (code === 'EACCES') {
return res.status(403).json({ error: 'Access to directory denied' });
}
return res.status(500).json({ error: (error && error.message) || 'Failed to list directory' });
}
});
};
+238
View File
@@ -0,0 +1,238 @@
const FILE_SEARCH_MAX_CONCURRENCY = 5;
const FILE_SEARCH_EXCLUDED_DIRS = new Set([
'node_modules',
'.git',
'dist',
'build',
'.next',
'.turbo',
'.cache',
'coverage',
'tmp',
'logs',
]);
const normalizeRelativeSearchPath = (rootPath, targetPath, path) => {
const relative = path.relative(rootPath, targetPath) || path.basename(targetPath);
return relative.split(path.sep).join('/') || targetPath;
};
const shouldSkipSearchDirectory = (name, includeHidden) => {
if (!name) {
return false;
}
if (!includeHidden && name.startsWith('.')) {
return true;
}
return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase());
};
const listDirectoryEntries = async (dirPath, fsPromises) => {
try {
return await fsPromises.readdir(dirPath, { withFileTypes: true });
} catch {
return [];
}
};
const fuzzyMatchScoreNormalized = (normalizedQuery, candidate) => {
if (!normalizedQuery) return 0;
const q = normalizedQuery;
const c = candidate.toLowerCase();
if (c.includes(q)) {
const idx = c.indexOf(q);
let bonus = 0;
if (idx === 0) {
bonus = 20;
} else {
const prev = c[idx - 1];
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
bonus = 15;
}
}
return 100 + bonus - Math.min(idx, 20) - Math.floor(c.length / 5);
}
let score = 0;
let lastIndex = -1;
let consecutive = 0;
for (let i = 0; i < q.length; i += 1) {
const ch = q[i];
if (!ch || ch === ' ') continue;
const idx = c.indexOf(ch, lastIndex + 1);
if (idx === -1) {
return null;
}
const gap = idx - lastIndex - 1;
if (gap === 0) {
consecutive += 1;
} else {
consecutive = 0;
}
score += 10;
score += Math.max(0, 18 - idx);
score -= Math.min(gap, 10);
if (idx === 0) {
score += 12;
} else {
const prev = c[idx - 1];
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
score += 10;
}
}
score += consecutive > 0 ? 12 : 0;
lastIndex = idx;
}
score += Math.max(0, 24 - Math.floor(c.length / 3));
return score;
};
export const createFsSearchRuntime = ({ fsPromises, path, spawn, resolveGitBinaryForSpawn }) => {
const searchFilesystemFiles = async (rootPath, options) => {
const { limit, query, includeHidden, respectGitignore } = options;
const includeHiddenEntries = Boolean(includeHidden);
const normalizedQuery = query.trim().toLowerCase();
const matchAll = normalizedQuery.length === 0;
const queue = [rootPath];
const visited = new Set([rootPath]);
const shouldRespectGitignore = respectGitignore !== false;
const collectLimit = matchAll ? limit : Math.max(limit * 3, 200);
const candidates = [];
while (queue.length > 0 && candidates.length < collectLimit) {
const batch = queue.splice(0, FILE_SEARCH_MAX_CONCURRENCY);
const dirResults = await Promise.all(
batch.map(async (dir) => {
if (!shouldRespectGitignore) {
return { dir, dirents: await listDirectoryEntries(dir, fsPromises), ignoredPaths: new Set() };
}
try {
const dirents = await listDirectoryEntries(dir, fsPromises);
const pathsToCheck = dirents.map((dirent) => dirent.name).filter(Boolean);
if (pathsToCheck.length === 0) {
return { dir, dirents, ignoredPaths: new Set() };
}
const result = await new Promise((resolve) => {
const child = spawn(resolveGitBinaryForSpawn(), ['check-ignore', '--', ...pathsToCheck], {
cwd: dir,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
child.stdout.on('data', (data) => { stdout += data.toString(); });
child.on('close', () => resolve(stdout));
child.on('error', () => resolve(''));
});
const ignoredNames = new Set(
String(result)
.split('\n')
.map((name) => name.trim())
.filter(Boolean)
);
return { dir, dirents, ignoredPaths: ignoredNames };
} catch {
return { dir, dirents: await listDirectoryEntries(dir, fsPromises), ignoredPaths: new Set() };
}
})
);
for (const { dir: currentDir, dirents, ignoredPaths } of dirResults) {
for (const dirent of dirents) {
const entryName = dirent.name;
if (!entryName || (!includeHiddenEntries && entryName.startsWith('.'))) {
continue;
}
if (shouldRespectGitignore && ignoredPaths.has(entryName)) {
continue;
}
const entryPath = path.join(currentDir, entryName);
if (dirent.isDirectory()) {
if (shouldSkipSearchDirectory(entryName, includeHiddenEntries)) {
continue;
}
if (!visited.has(entryPath)) {
visited.add(entryPath);
queue.push(entryPath);
}
continue;
}
if (!dirent.isFile()) {
continue;
}
const relativePath = normalizeRelativeSearchPath(rootPath, entryPath, path);
const extension = entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined;
if (matchAll) {
candidates.push({
name: entryName,
path: entryPath,
relativePath,
extension,
score: 0,
});
} else {
const score = fuzzyMatchScoreNormalized(normalizedQuery, relativePath);
if (score !== null) {
candidates.push({
name: entryName,
path: entryPath,
relativePath,
extension,
score,
});
}
}
if (candidates.length >= collectLimit) {
queue.length = 0;
break;
}
}
if (candidates.length >= collectLimit) {
break;
}
}
}
if (!matchAll) {
candidates.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (a.relativePath.length !== b.relativePath.length) {
return a.relativePath.length - b.relativePath.length;
}
return a.relativePath.localeCompare(b.relativePath);
});
}
return candidates.slice(0, limit).map(({ name, path: filePath, relativePath, extension }) => ({
name,
path: filePath,
relativePath,
extension,
}));
};
return {
searchFilesystemFiles,
};
};
@@ -6,6 +6,7 @@ This module provides Git repository operations for the web server runtime, inclu
## Entrypoints and structure
- `packages/web/server/lib/git/`: Git module directory containing all Git-related functionality.
- `index.js`: Public API entry point imported by `packages/web/server/index.js`.
- `routes.js`: Express route registration for `/api/git/*` endpoints.
- `service.js`: Core Git operations (repository, branch, worktree, commit, merge/rebase, status/diff, log).
- `credentials.js`: Git credentials management.
- `identity-storage.js`: Git identity (user.name, user.email) storage.
+867
View File
@@ -0,0 +1,867 @@
export function registerGitRoutes(app) {
let gitLibraries = null;
const getGitLibraries = async () => {
if (!gitLibraries) {
gitLibraries = await import('./index.js');
}
return gitLibraries;
};
app.get('/api/git/identities', async (req, res) => {
const { getProfiles } = await getGitLibraries();
try {
const profiles = getProfiles();
res.json(profiles);
} catch (error) {
console.error('Failed to list git identity profiles:', error);
res.status(500).json({ error: 'Failed to list git identity profiles' });
}
});
app.post('/api/git/identities', async (req, res) => {
const { createProfile } = await getGitLibraries();
try {
const profile = createProfile(req.body);
console.log(`Created git identity profile: ${profile.name} (${profile.id})`);
res.json(profile);
} catch (error) {
console.error('Failed to create git identity profile:', error);
res.status(400).json({ error: error.message || 'Failed to create git identity profile' });
}
});
app.put('/api/git/identities/:id', async (req, res) => {
const { updateProfile } = await getGitLibraries();
try {
const profile = updateProfile(req.params.id, req.body);
console.log(`Updated git identity profile: ${profile.name} (${profile.id})`);
res.json(profile);
} catch (error) {
console.error('Failed to update git identity profile:', error);
res.status(400).json({ error: error.message || 'Failed to update git identity profile' });
}
});
app.delete('/api/git/identities/:id', async (req, res) => {
const { deleteProfile } = await getGitLibraries();
try {
deleteProfile(req.params.id);
console.log(`Deleted git identity profile: ${req.params.id}`);
res.json({ success: true });
} catch (error) {
console.error('Failed to delete git identity profile:', error);
res.status(400).json({ error: error.message || 'Failed to delete git identity profile' });
}
});
app.get('/api/git/global-identity', async (req, res) => {
const { getGlobalIdentity } = await getGitLibraries();
try {
const identity = await getGlobalIdentity();
res.json(identity);
} catch (error) {
console.error('Failed to get global git identity:', error);
res.status(500).json({ error: 'Failed to get global git identity' });
}
});
app.get('/api/git/discover-credentials', async (req, res) => {
try {
const { discoverGitCredentials } = await import('./index.js');
const credentials = discoverGitCredentials();
res.json(credentials);
} catch (error) {
console.error('Failed to discover git credentials:', error);
res.status(500).json({ error: 'Failed to discover git credentials' });
}
});
app.get('/api/git/check', async (req, res) => {
const { isGitRepository } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const isRepo = await isGitRepository(directory);
res.json({ isGitRepository: isRepo });
} catch (error) {
console.error('Failed to check git repository:', error);
res.status(500).json({ error: 'Failed to check git repository' });
}
});
app.get('/api/git/remote-url', async (req, res) => {
const { getRemoteUrl } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const remote = req.query.remote || 'origin';
const url = await getRemoteUrl(directory, remote);
res.json({ url });
} catch (error) {
console.error('Failed to get remote url:', error);
res.status(500).json({ error: 'Failed to get remote url' });
}
});
app.get('/api/git/current-identity', async (req, res) => {
const { getCurrentIdentity } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const identity = await getCurrentIdentity(directory);
res.json(identity);
} catch (error) {
console.error('Failed to get current git identity:', error);
res.status(500).json({ error: 'Failed to get current git identity' });
}
});
app.get('/api/git/has-local-identity', async (req, res) => {
const { hasLocalIdentity } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const hasLocal = await hasLocalIdentity(directory);
res.json({ hasLocalIdentity: hasLocal });
} catch (error) {
console.error('Failed to check local git identity:', error);
res.status(500).json({ error: 'Failed to check local git identity' });
}
});
app.post('/api/git/set-identity', async (req, res) => {
const { getProfile, setLocalIdentity, getGlobalIdentity } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { profileId } = req.body;
if (!profileId) {
return res.status(400).json({ error: 'profileId is required' });
}
let profile = null;
if (profileId === 'global') {
const globalIdentity = await getGlobalIdentity();
if (!globalIdentity?.userName || !globalIdentity?.userEmail) {
return res.status(404).json({ error: 'Global identity is not configured' });
}
profile = {
id: 'global',
name: 'Global Identity',
userName: globalIdentity.userName,
userEmail: globalIdentity.userEmail,
sshKey: globalIdentity.sshCommand
? globalIdentity.sshCommand.replace('ssh -i ', '')
: null,
};
} else {
profile = getProfile(profileId);
if (!profile) {
return res.status(404).json({ error: 'Profile not found' });
}
}
await setLocalIdentity(directory, profile);
res.json({ success: true, profile });
} catch (error) {
console.error('Failed to set git identity:', error);
res.status(500).json({ error: error.message || 'Failed to set git identity' });
}
});
app.get('/api/git/status', async (req, res) => {
const { getStatus, isGitRepository } = await getGitLibraries();
const extractGitErrorText = (error) => {
const message = typeof error?.message === 'string' ? error.message : '';
const stderr = typeof error?.stderr === 'string' ? error.stderr : '';
const stdout = typeof error?.stdout === 'string' ? error.stdout : '';
return [message, stderr, stdout]
.map((value) => String(value || '').trim())
.filter(Boolean)
.join('\n');
};
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const isRepo = await isGitRepository(directory);
if (!isRepo) {
return res.json({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0 });
}
const mode = req.query.mode === 'light' ? 'light' : undefined;
const status = await getStatus(directory, { mode });
res.json(status);
} catch (error) {
const errorText = extractGitErrorText(error);
if (/not a git repository/i.test(errorText)) {
return res.json({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0 });
}
console.error('Failed to get git status:', error);
res.status(500).json({ error: error.message || 'Failed to get git status' });
}
});
app.get('/api/git/diff', async (req, res) => {
const { getDiff } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const path = req.query.path;
if (!path || typeof path !== 'string') {
return res.status(400).json({ error: 'path parameter is required' });
}
const staged = req.query.staged === 'true';
const context = req.query.context ? parseInt(String(req.query.context), 10) : undefined;
const diff = await getDiff(directory, {
path,
staged,
contextLines: Number.isFinite(context) ? context : 3,
});
res.json({ diff });
} catch (error) {
console.error('Failed to get git diff:', error);
res.status(500).json({ error: error.message || 'Failed to get git diff' });
}
});
app.get('/api/git/file-diff', async (req, res) => {
const { getFileDiff } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const pathParam = req.query.path;
if (!pathParam || typeof pathParam !== 'string') {
return res.status(400).json({ error: 'path parameter is required' });
}
const staged = req.query.staged === 'true';
const result = await getFileDiff(directory, {
path: pathParam,
staged,
});
res.json({
original: result.original,
modified: result.modified,
path: result.path,
isBinary: Boolean(result.isBinary),
});
} catch (error) {
console.error('Failed to get git file diff:', error);
res.status(500).json({ error: error.message || 'Failed to get git file diff' });
}
});
app.post('/api/git/revert', async (req, res) => {
const { revertFile } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { path } = req.body || {};
if (!path || typeof path !== 'string') {
return res.status(400).json({ error: 'path parameter is required' });
}
await revertFile(directory, path);
res.json({ success: true });
} catch (error) {
console.error('Failed to revert git file:', error);
res.status(500).json({ error: error.message || 'Failed to revert git file' });
}
});
app.post('/api/git/pull', async (req, res) => {
const { pull } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const result = await pull(directory, req.body);
res.json(result);
} catch (error) {
console.error('Failed to pull:', error);
res.status(500).json({ error: error.message || 'Failed to pull from remote' });
}
});
app.post('/api/git/push', async (req, res) => {
const { push } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const result = await push(directory, req.body);
res.json(result);
} catch (error) {
console.error('Failed to push:', error);
res.status(500).json({ error: error.message || 'Failed to push to remote' });
}
});
app.post('/api/git/fetch', async (req, res) => {
const { fetch: gitFetch } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const result = await gitFetch(directory, req.body);
res.json(result);
} catch (error) {
console.error('Failed to fetch:', error);
res.status(500).json({ error: error.message || 'Failed to fetch from remote' });
}
});
app.get('/api/git/remotes', async (req, res) => {
const { getRemotes } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const remotes = await getRemotes(directory);
res.json(remotes);
} catch (error) {
console.error('Failed to get remotes:', error);
res.status(500).json({ error: error.message || 'Failed to get remotes' });
}
});
app.delete('/api/git/remotes', async (req, res) => {
const { removeRemote } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const remote = String(req.body?.remote || '').trim();
if (!remote) {
return res.status(400).json({ error: 'remote is required' });
}
const result = await removeRemote(directory, { remote });
res.json(result);
} catch (error) {
console.error('Failed to remove remote:', error);
res.status(500).json({ error: error.message || 'Failed to remove remote' });
}
});
app.post('/api/git/rebase', async (req, res) => {
const { rebase } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const result = await rebase(directory, req.body);
res.json(result);
} catch (error) {
console.error('Failed to rebase:', error);
res.status(500).json({ error: error.message || 'Failed to rebase' });
}
});
app.post('/api/git/rebase/abort', async (req, res) => {
const { abortRebase } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const result = await abortRebase(directory);
res.json(result);
} catch (error) {
console.error('Failed to abort rebase:', error);
res.status(500).json({ error: error.message || 'Failed to abort rebase' });
}
});
app.post('/api/git/merge', async (req, res) => {
const { merge } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const result = await merge(directory, req.body);
res.json(result);
} catch (error) {
console.error('Failed to merge:', error);
res.status(500).json({ error: error.message || 'Failed to merge' });
}
});
app.post('/api/git/merge/abort', async (req, res) => {
const { abortMerge } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const result = await abortMerge(directory);
res.json(result);
} catch (error) {
console.error('Failed to abort merge:', error);
res.status(500).json({ error: error.message || 'Failed to abort merge' });
}
});
app.post('/api/git/rebase/continue', async (req, res) => {
const { continueRebase } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const result = await continueRebase(directory);
res.json(result);
} catch (error) {
console.error('Failed to continue rebase:', error);
res.status(500).json({ error: error.message || 'Failed to continue rebase' });
}
});
app.post('/api/git/merge/continue', async (req, res) => {
const { continueMerge } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const result = await continueMerge(directory);
res.json(result);
} catch (error) {
console.error('Failed to continue merge:', error);
res.status(500).json({ error: error.message || 'Failed to continue merge' });
}
});
app.get('/api/git/conflict-details', async (req, res) => {
const { getConflictDetails } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const result = await getConflictDetails(directory);
res.json(result);
} catch (error) {
console.error('Failed to get conflict details:', error);
res.status(500).json({ error: error.message || 'Failed to get conflict details' });
}
});
app.post('/api/git/stash', async (req, res) => {
const { stash } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const result = await stash(directory, req.body);
res.json(result);
} catch (error) {
console.error('Failed to stash:', error);
res.status(500).json({ error: error.message || 'Failed to stash' });
}
});
app.post('/api/git/stash/pop', async (req, res) => {
const { stashPop } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const result = await stashPop(directory);
res.json(result);
} catch (error) {
console.error('Failed to pop stash:', error);
res.status(500).json({ error: error.message || 'Failed to pop stash' });
}
});
app.post('/api/git/commit', async (req, res) => {
const { commit } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { message, addAll, files } = req.body;
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
const result = await commit(directory, message, {
addAll,
files,
});
res.json(result);
} catch (error) {
console.error('Failed to commit:', error);
res.status(500).json({ error: error.message || 'Failed to create commit' });
}
});
app.get('/api/git/branches', async (req, res) => {
const { getBranches } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const branches = await getBranches(directory);
res.json(branches);
} catch (error) {
console.error('Failed to get branches:', error);
res.status(500).json({ error: error.message || 'Failed to get branches' });
}
});
app.post('/api/git/branches', async (req, res) => {
const { createBranch } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { name, startPoint } = req.body;
if (!name) {
return res.status(400).json({ error: 'name is required' });
}
const result = await createBranch(directory, name, { startPoint });
res.json(result);
} catch (error) {
console.error('Failed to create branch:', error);
res.status(500).json({ error: error.message || 'Failed to create branch' });
}
});
app.delete('/api/git/branches', async (req, res) => {
const { deleteBranch } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { branch, force } = req.body;
if (!branch) {
return res.status(400).json({ error: 'branch is required' });
}
const result = await deleteBranch(directory, branch, { force });
res.json(result);
} catch (error) {
console.error('Failed to delete branch:', error);
res.status(500).json({ error: error.message || 'Failed to delete branch' });
}
});
app.put('/api/git/branches/rename', async (req, res) => {
const { renameBranch } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { oldName, newName } = req.body;
if (!oldName) {
return res.status(400).json({ error: 'oldName is required' });
}
if (!newName) {
return res.status(400).json({ error: 'newName is required' });
}
const result = await renameBranch(directory, oldName, newName);
res.json(result);
} catch (error) {
console.error('Failed to rename branch:', error);
res.status(500).json({ error: error.message || 'Failed to rename branch' });
}
});
app.delete('/api/git/remote-branches', async (req, res) => {
const { deleteRemoteBranch } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { branch, remote } = req.body;
if (!branch) {
return res.status(400).json({ error: 'branch is required' });
}
const result = await deleteRemoteBranch(directory, { branch, remote });
res.json(result);
} catch (error) {
console.error('Failed to delete remote branch:', error);
res.status(500).json({ error: error.message || 'Failed to delete remote branch' });
}
});
app.post('/api/git/checkout', async (req, res) => {
const { checkoutBranch } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { branch } = req.body;
if (!branch) {
return res.status(400).json({ error: 'branch is required' });
}
const result = await checkoutBranch(directory, branch);
res.json(result);
} catch (error) {
console.error('Failed to checkout branch:', error);
res.status(500).json({ error: error.message || 'Failed to checkout branch' });
}
});
app.get('/api/git/worktrees', async (req, res) => {
const { getWorktrees } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const worktrees = await getWorktrees(directory);
res.json(worktrees);
} catch (error) {
// Worktrees are an optional feature. Avoid repeated 500s (and repeated client retries)
// when the directory isn't a git repo or uses shell shorthand like "~/".
console.warn('Failed to get worktrees, returning empty list:', error?.message || error);
res.setHeader('X-OpenChamber-Warning', 'git worktrees unavailable');
res.json([]);
}
});
app.post('/api/git/worktrees/validate', async (req, res) => {
const { validateWorktreeCreate } = await getGitLibraries();
if (typeof validateWorktreeCreate !== 'function') {
return res.status(501).json({ error: 'Worktree validation is not available' });
}
try {
const directory = req.query.directory;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const result = await validateWorktreeCreate(directory, req.body || {});
res.json(result);
} catch (error) {
console.error('Failed to validate worktree creation:', error);
res.status(500).json({ error: error.message || 'Failed to validate worktree creation' });
}
});
app.post('/api/git/worktrees', async (req, res) => {
const { createWorktree } = await getGitLibraries();
if (typeof createWorktree !== 'function') {
return res.status(501).json({ error: 'Worktree creation is not available' });
}
try {
const directory = req.query.directory;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const created = await createWorktree(directory, req.body || {});
res.json(created);
} catch (error) {
console.error('Failed to create worktree:', error);
res.status(500).json({ error: error.message || 'Failed to create worktree' });
}
});
app.post('/api/git/worktrees/preview', async (req, res) => {
const { previewWorktreeCreate } = await getGitLibraries();
if (typeof previewWorktreeCreate !== 'function') {
return res.status(501).json({ error: 'Worktree preview is not available' });
}
try {
const directory = req.query.directory;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const preview = await previewWorktreeCreate(directory, req.body || {});
res.json(preview);
} catch (error) {
console.error('Failed to preview worktree:', error);
res.status(500).json({ error: error.message || 'Failed to preview worktree' });
}
});
app.get('/api/git/worktrees/bootstrap-status', async (req, res) => {
const { getWorktreeBootstrapStatus } = await getGitLibraries();
if (typeof getWorktreeBootstrapStatus !== 'function') {
return res.status(501).json({ error: 'Worktree bootstrap status is not available' });
}
try {
const directory = req.query.directory;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const status = await getWorktreeBootstrapStatus(directory);
res.json(status);
} catch (error) {
console.error('Failed to get worktree bootstrap status:', error);
res.status(500).json({ error: error.message || 'Failed to get worktree bootstrap status' });
}
});
app.delete('/api/git/worktrees', async (req, res) => {
const { removeWorktree } = await getGitLibraries();
if (typeof removeWorktree !== 'function') {
return res.status(501).json({ error: 'Worktree removal is not available' });
}
try {
const directory = req.query.directory;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const worktreeDirectory = typeof req.body?.directory === 'string' ? req.body.directory : '';
if (!worktreeDirectory) {
return res.status(400).json({ error: 'worktree directory is required' });
}
const result = await removeWorktree(directory, {
directory: worktreeDirectory,
deleteLocalBranch: req.body?.deleteLocalBranch === true,
});
res.json({ success: Boolean(result) });
} catch (error) {
console.error('Failed to remove worktree:', error);
res.status(500).json({ error: error.message || 'Failed to remove worktree' });
}
});
app.get('/api/git/worktree-type', async (req, res) => {
const { isLinkedWorktree } = await getGitLibraries();
try {
const { directory } = req.query;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const linked = await isLinkedWorktree(directory);
res.json({ linked });
} catch (error) {
console.error('Failed to determine worktree type:', error);
res.status(500).json({ error: error.message || 'Failed to determine worktree type' });
}
});
app.get('/api/git/log', async (req, res) => {
const { getLog } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { maxCount, from, to, file } = req.query;
const log = await getLog(directory, {
maxCount: maxCount ? parseInt(maxCount) : undefined,
from,
to,
file
});
res.json(log);
} catch (error) {
console.error('Failed to get log:', error);
res.status(500).json({ error: error.message || 'Failed to get commit log' });
}
});
app.get('/api/git/commit-files', async (req, res) => {
const { getCommitFiles } = await getGitLibraries();
try {
const { directory, hash } = req.query;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
if (!hash) {
return res.status(400).json({ error: 'hash parameter is required' });
}
const result = await getCommitFiles(directory, hash);
res.json(result);
} catch (error) {
console.error('Failed to get commit files:', error);
res.status(500).json({ error: error.message || 'Failed to get commit files' });
}
});
}
+35 -8
View File
@@ -820,6 +820,25 @@ const getProjectStoragePath = (projectID) => {
return path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`);
};
const syncSandboxesToOpenCodeDb = (projectID, sandboxes) => {
try {
const { Database } = require('bun:sqlite');
const dbPath = path.join(getOpenCodeDataPath(), 'opencode.db');
if (!fs.existsSync(dbPath)) return;
const db = new Database(dbPath);
try {
const row = db.query('SELECT sandboxes FROM project WHERE id = ?').get(projectID);
if (!row) return;
const json = JSON.stringify(sandboxes);
db.query('UPDATE project SET sandboxes = ?, time_updated = ? WHERE id = ?').run(json, Date.now(), projectID);
} finally {
db.close();
}
} catch (error) {
console.warn('Failed to sync sandboxes to OpenCode DB:', error instanceof Error ? error.message : String(error));
}
};
const updateProjectSandboxes = async (projectID, primaryWorktree, updater) => {
const storagePath = getProjectStoragePath(projectID);
await fsp.mkdir(path.dirname(storagePath), { recursive: true });
@@ -859,6 +878,9 @@ const updateProjectSandboxes = async (projectID, primaryWorktree, updater) => {
)];
await fsp.writeFile(storagePath, `${JSON.stringify(current, null, 2)}\n`, 'utf8');
// Sync to OpenCode's SQLite database so project.sandboxes is visible via the SDK
syncSandboxesToOpenCodeDb(projectID, current.sandboxes);
};
const syncProjectSandboxAdd = async (projectID, primaryWorktree, sandboxPath) => {
@@ -1187,18 +1209,22 @@ export async function setLocalIdentity(directory, profile) {
}
}
export async function getStatus(directory) {
export async function getStatus(directory, options = {}) {
const directoryPath = normalizeDirectoryPath(directory);
const git = await createGit(directoryPath);
const lightMode = options.mode === 'light';
try {
// Use -uall to show all untracked files individually, not just directories
const status = await git.status(['-uall']);
const [stagedStatsRaw, workingStatsRaw] = await Promise.all([
git.raw(['diff', '--cached', '--numstat']).catch(() => ''),
git.raw(['diff', '--numstat']).catch(() => ''),
]);
// Light mode: skip numstat + new-file line counting for faster response
const [stagedStatsRaw, workingStatsRaw] = lightMode
? ['', '']
: await Promise.all([
git.raw(['diff', '--cached', '--numstat']).catch(() => ''),
git.raw(['diff', '--numstat']).catch(() => ''),
]);
const diffStatsMap = new Map();
@@ -1234,7 +1260,7 @@ export async function getStatus(directory) {
const diffStats = Object.fromEntries(diffStatsMap.entries());
const newFileStats = await Promise.all(
const newFileStats = lightMode ? [] : await Promise.all(
status.files.map(async (file) => {
const working = (file.working_dir || '').trim();
const indexStatus = (file.index || '').trim();
@@ -1333,7 +1359,8 @@ export async function getStatus(directory) {
// When no upstream is configured (common for new worktree branches), Git doesn't report ahead/behind.
// We still want to show the number of unpublished commits to the user.
if (!tracking && status.current) {
// Light mode skips this — the basic ahead/behind from git status is sufficient for polling.
if (!lightMode && !tracking && status.current) {
const baseRef = await selectBaseRefForUnpublished();
if (baseRef) {
const countRaw = await git
@@ -1411,7 +1438,7 @@ export async function getStatus(directory) {
working_dir: f.working_dir,
})),
isClean: status.isClean(),
diffStats,
diffStats: lightMode ? undefined : diffStats,
mergeInProgress,
rebaseInProgress,
};
@@ -8,6 +8,7 @@
## Entrypoints and structure
- `packages/web/server/lib/github/index.js`: public server entrypoint.
- `packages/web/server/lib/github/routes.js`: Express route registration for `/api/github/*` endpoints.
- `packages/web/server/lib/github/auth.js`: auth storage, multi-account support, client id, scope config.
- `packages/web/server/lib/github/device-flow.js`: OAuth device flow.
- `packages/web/server/lib/github/octokit.js`: Octokit factory for the current auth.
+20 -1
View File
@@ -296,7 +296,20 @@ const parseRepoFromApiUrl = (value) => {
}
};
// Track repos where the GitHub Search API returned 403 (token lacks scope for that org)
const _searchApiDisabledRepos = new Map();
const SEARCH_API_RETRY_MS = 5 * 60 * 1000; // retry after 5 minutes
const searchFallbackPr = async ({ octokit, branch, repoNames }) => {
// Build a repo key to check/store 403 status per-repo
const repoKey = [...repoNames].sort().join(',').toLowerCase();
// Skip if this repo set returned 403 recently
const disabledAt = _searchApiDisabledRepos.get(repoKey);
if (disabledAt && Date.now() - disabledAt < SEARCH_API_RETRY_MS) {
return null;
}
const normalizedRepoNames = new Set(repoNames.map((name) => normalizeLower(name)).filter(Boolean));
for (const state of ['open', 'closed']) {
@@ -306,8 +319,14 @@ const searchFallbackPr = async ({ octokit, branch, repoNames }) => {
q: `is:pr state:${state} head:${branch}`,
per_page: 20,
});
// If we get here, search API works for this repo — clear the disabled flag
_searchApiDisabledRepos.delete(repoKey);
} catch (error) {
if (error?.status === 403 || error?.status === 404) {
if (error?.status === 403) {
_searchApiDisabledRepos.set(repoKey, Date.now());
return null;
}
if (error?.status === 404) {
continue;
}
throw error;
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,11 @@ This module provides notification message preparation utilities for the web serv
## Entrypoints and structure
- `packages/web/server/lib/notifications/index.js`: public entrypoint imported by `packages/web/server/index.js`.
- `packages/web/server/lib/notifications/routes.js`: route registration for push, visibility, and session status/attention endpoints.
- `packages/web/server/lib/notifications/push-runtime.js`: push subscription persistence, VAPID initialization, and UI visibility runtime.
- `packages/web/server/lib/notifications/emitter-runtime.js`: desktop/stdout + UI SSE notification emission runtime.
- `packages/web/server/lib/notifications/runtime.js`: trigger runtime for OpenCode event-driven notification fanout.
- `packages/web/server/lib/notifications/template-runtime.js`: notification template variables, zen-model helpers, and session text/title enrichment runtime.
- `packages/web/server/lib/notifications/message.js`: helper implementation module.
- `packages/web/server/lib/notifications/message.test.js`: unit tests for notification message helpers.
@@ -14,6 +19,68 @@ This module provides notification message preparation utilities for the web serv
- `truncateNotificationText(text, maxLength)`: Truncates text to specified max length, appending `...` if truncated.
- `prepareNotificationLastMessage({ message, settings, summarize })`: Prepares the last message for notification display, with optional summarization support.
### Route registration API (routes.js)
- `registerNotificationRoutes(app, dependencies)`: Registers notification-owned endpoints:
- `GET /api/push/vapid-public-key`
- `POST /api/push/subscribe`
- `DELETE /api/push/subscribe`
- `POST /api/push/visibility`
- `GET /api/push/visibility`
- `GET /api/session-activity`
- `GET /api/sessions/snapshot`
- `GET /api/sessions/status`
- `GET /api/sessions/:id/status`
- `GET /api/sessions/attention`
- `GET /api/sessions/:id/attention`
- `POST /api/sessions/:id/view`
- `POST /api/sessions/:id/unview`
- `POST /api/sessions/:id/message-sent`
### Trigger runtime API (runtime.js)
- `createNotificationTriggerRuntime(dependencies)`: creates runtime-owned debounced trigger handling for OpenCode events.
- Returned API:
- `maybeSendPushForTrigger(payload)`
- Owns:
- completion/error/question/permission trigger routing
- session parent cache for subtask suppression
- template resolution and fallback behavior
- native notification fanout and web push payload fanout
### Push runtime API (push-runtime.js)
- `createPushRuntime(dependencies)`: creates runtime for web push and UI visibility state.
- Returned API:
- `getOrCreateVapidKeys()`
- `ensurePushInitialized()`
- `setPushInitialized(value)`
- `addOrUpdatePushSubscription(uiSessionToken, subscription, userAgent)`
- `removePushSubscription(uiSessionToken, endpoint)`
- `sendPushToAllUiSessions(payload, options?)`
- `updateUiVisibility(token, visible)`
- `isAnyUiVisible()`
- `isUiVisible(token)`
### Emitter runtime API (emitter-runtime.js)
- `createNotificationEmitterRuntime(dependencies)`: creates runtime for unified notification emission channels.
- Returned API:
- `writeSseEvent(res, payload)`
- `emitDesktopNotification(payload)`
- `broadcastUiNotification(payload)`
### Template runtime API (template-runtime.js)
- `createNotificationTemplateRuntime(dependencies)`: creates shared notification/template + zen helper runtime.
- Returned API:
- `resolveNotificationTemplate(template, variables)`
- `shouldApplyResolvedTemplateMessage(template, resolved, variables)`
- `fetchFreeZenModels()`
- `resolveZenModel(override)`
- `validateZenModelAtStartup()`
- `summarizeText(text, targetLength, zenModel)`
- `extractLastMessageText(payload, maxLength?)`
- `fetchLastAssistantMessageText(sessionId, messageId, maxLength?)`
- `maybeCacheSessionInfoFromEvent(payload)`
- `buildTemplateVariables(payload, sessionId)`
- `getCachedZenModels()`
## Constants
### Default values
@@ -0,0 +1,65 @@
export const createNotificationEmitterRuntime = (dependencies) => {
const {
process,
getDesktopNotifyEnabled,
desktopNotifyPrefix,
getUiNotificationClients,
} = dependencies;
const writeSseEvent = (res, payload) => {
res.write(`data: ${JSON.stringify(payload)}\n\n`);
};
const emitDesktopNotification = (payload) => {
const desktopNotifyEnabled = getDesktopNotifyEnabled();
if (!desktopNotifyEnabled) {
return;
}
if (!payload || typeof payload !== 'object') {
return;
}
try {
// One-line protocol consumed by the Tauri shell.
process.stdout.write(`${desktopNotifyPrefix}${JSON.stringify(payload)}\n`);
} catch {
// ignore
}
};
const broadcastUiNotification = (payload) => {
const desktopNotifyEnabled = getDesktopNotifyEnabled();
if (!payload || typeof payload !== 'object') {
return;
}
const clients = getUiNotificationClients();
if (clients.size === 0) {
return;
}
for (const res of clients) {
try {
writeSseEvent(res, {
type: 'openchamber:notification',
properties: {
...payload,
// Tell the UI whether the sidecar stdout notification channel is active.
// When true, the desktop UI should skip this SSE notification to avoid duplicates.
// When false (e.g. tauri dev), the UI must handle this SSE notification itself.
desktopStdoutActive: desktopNotifyEnabled,
},
});
} catch {
// ignore
}
}
};
return {
writeSseEvent,
emitDesktopNotification,
broadcastUiNotification,
};
};
@@ -1 +1,4 @@
export { truncateNotificationText, prepareNotificationLastMessage } from './message.js';
export { createNotificationTriggerRuntime } from './runtime.js';
export { createPushRuntime } from './push-runtime.js';
export { createNotificationTemplateRuntime } from './template-runtime.js';
@@ -0,0 +1,294 @@
const PUSH_SUBSCRIPTIONS_VERSION = 1;
export const createPushRuntime = (deps) => {
const {
fsPromises,
path,
webPush,
PUSH_SUBSCRIPTIONS_FILE_PATH,
readSettingsFromDiskMigrated,
writeSettingsToDisk,
} = deps;
let persistPushSubscriptionsLock = Promise.resolve();
let pushInitialized = false;
const uiVisibilityByToken = new Map();
let globalVisibilityState = false;
const readPushSubscriptionsFromDisk = async () => {
try {
const raw = await fsPromises.readFile(PUSH_SUBSCRIPTIONS_FILE_PATH, 'utf8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
}
if (typeof parsed.version !== 'number' || parsed.version !== PUSH_SUBSCRIPTIONS_VERSION) {
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
}
const subscriptionsBySession =
parsed.subscriptionsBySession && typeof parsed.subscriptionsBySession === 'object'
? parsed.subscriptionsBySession
: {};
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession };
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
}
console.warn('Failed to read push subscriptions file:', error);
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
}
};
const writePushSubscriptionsToDisk = async (data) => {
await fsPromises.mkdir(path.dirname(PUSH_SUBSCRIPTIONS_FILE_PATH), { recursive: true });
await fsPromises.writeFile(PUSH_SUBSCRIPTIONS_FILE_PATH, JSON.stringify(data, null, 2), 'utf8');
};
const persistPushSubscriptionUpdate = async (mutate) => {
persistPushSubscriptionsLock = persistPushSubscriptionsLock.then(async () => {
await fsPromises.mkdir(path.dirname(PUSH_SUBSCRIPTIONS_FILE_PATH), { recursive: true });
const current = await readPushSubscriptionsFromDisk();
const next = mutate({
version: PUSH_SUBSCRIPTIONS_VERSION,
subscriptionsBySession: current.subscriptionsBySession || {},
});
await writePushSubscriptionsToDisk(next);
return next;
});
return persistPushSubscriptionsLock;
};
const getOrCreateVapidKeys = async () => {
const settings = await readSettingsFromDiskMigrated();
const existing = settings?.vapidKeys;
if (existing && typeof existing.publicKey === 'string' && typeof existing.privateKey === 'string') {
return { publicKey: existing.publicKey, privateKey: existing.privateKey };
}
const generated = webPush.generateVAPIDKeys();
const next = {
...settings,
vapidKeys: {
publicKey: generated.publicKey,
privateKey: generated.privateKey,
},
};
await writeSettingsToDisk(next);
return { publicKey: generated.publicKey, privateKey: generated.privateKey };
};
const normalizePushSubscriptions = (record) => {
if (!Array.isArray(record)) return [];
return record
.map((entry) => {
if (!entry || typeof entry !== 'object') return null;
const endpoint = entry.endpoint;
const p256dh = entry.p256dh;
const auth = entry.auth;
if (typeof endpoint !== 'string' || typeof p256dh !== 'string' || typeof auth !== 'string') {
return null;
}
return {
endpoint,
p256dh,
auth,
createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
};
})
.filter(Boolean);
};
const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent) => {
if (!uiSessionToken) {
return;
}
await ensurePushInitialized();
const now = Date.now();
await persistPushSubscriptionUpdate((current) => {
const subsBySession = { ...(current.subscriptionsBySession || {}) };
const existing = Array.isArray(subsBySession[uiSessionToken]) ? subsBySession[uiSessionToken] : [];
const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== subscription.endpoint);
filtered.unshift({
endpoint: subscription.endpoint,
p256dh: subscription.p256dh,
auth: subscription.auth,
createdAt: now,
lastSeenAt: now,
userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
});
subsBySession[uiSessionToken] = filtered.slice(0, 10);
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession };
});
};
const removePushSubscription = async (uiSessionToken, endpoint) => {
if (!uiSessionToken || !endpoint) return;
await ensurePushInitialized();
await persistPushSubscriptionUpdate((current) => {
const subsBySession = { ...(current.subscriptionsBySession || {}) };
const existing = Array.isArray(subsBySession[uiSessionToken]) ? subsBySession[uiSessionToken] : [];
const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== endpoint);
if (filtered.length === 0) {
delete subsBySession[uiSessionToken];
} else {
subsBySession[uiSessionToken] = filtered;
}
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession };
});
};
const removePushSubscriptionFromAllSessions = async (endpoint) => {
if (!endpoint) return;
await ensurePushInitialized();
await persistPushSubscriptionUpdate((current) => {
const subsBySession = { ...(current.subscriptionsBySession || {}) };
for (const [token, entries] of Object.entries(subsBySession)) {
if (!Array.isArray(entries)) continue;
const filtered = entries.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== endpoint);
if (filtered.length === 0) {
delete subsBySession[token];
} else {
subsBySession[token] = filtered;
}
}
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession };
});
};
const sendPushToSubscription = async (sub, payload) => {
await ensurePushInitialized();
const body = JSON.stringify(payload);
const pushSubscription = {
endpoint: sub.endpoint,
keys: {
p256dh: sub.p256dh,
auth: sub.auth,
},
};
try {
await webPush.sendNotification(pushSubscription, body);
} catch (error) {
const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : null;
if (statusCode === 410 || statusCode === 404) {
await removePushSubscriptionFromAllSessions(sub.endpoint);
return;
}
console.warn('[Push] Failed to send notification:', error);
}
};
const sendPushToAllUiSessions = async (payload, options = {}) => {
const requireNoSse = options.requireNoSse === true;
const store = await readPushSubscriptionsFromDisk();
const sessions = store.subscriptionsBySession || {};
const subscriptionsByEndpoint = new Map();
for (const record of Object.values(sessions)) {
const subscriptions = normalizePushSubscriptions(record);
if (subscriptions.length === 0) continue;
for (const sub of subscriptions) {
if (!subscriptionsByEndpoint.has(sub.endpoint)) {
subscriptionsByEndpoint.set(sub.endpoint, sub);
}
}
}
await Promise.all(Array.from(subscriptionsByEndpoint.values()).map(async (sub) => {
if (requireNoSse && isAnyUiVisible()) {
return;
}
await sendPushToSubscription(sub, payload);
}));
};
const updateUiVisibility = (token, visible) => {
if (!token) return;
const now = Date.now();
const nextVisible = Boolean(visible);
uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now });
globalVisibilityState = nextVisible;
};
const isAnyUiVisible = () => globalVisibilityState === true;
const isUiVisible = (token) => uiVisibilityByToken.get(token)?.visible === true;
const resolveVapidSubject = async () => {
const configured = process.env.OPENCHAMBER_VAPID_SUBJECT;
if (typeof configured === 'string' && configured.trim().length > 0) {
return configured.trim();
}
const originEnv = process.env.OPENCHAMBER_PUBLIC_ORIGIN;
if (typeof originEnv === 'string' && originEnv.trim().length > 0) {
const trimmed = originEnv.trim();
if (trimmed.startsWith('http://localhost')) {
return 'mailto:openchamber@localhost';
}
return trimmed;
}
try {
const settings = await readSettingsFromDiskMigrated();
const stored = settings?.publicOrigin;
if (typeof stored === 'string' && stored.trim().length > 0) {
const trimmed = stored.trim();
if (trimmed.startsWith('http://localhost')) {
return 'mailto:openchamber@localhost';
}
return trimmed;
}
} catch {
}
return 'mailto:openchamber@localhost';
};
const ensurePushInitialized = async () => {
if (pushInitialized) return;
const keys = await getOrCreateVapidKeys();
const subject = await resolveVapidSubject();
if (subject === 'mailto:openchamber@localhost') {
console.warn('[Push] No public origin configured for VAPID; set OPENCHAMBER_VAPID_SUBJECT or enable push once from a real origin.');
}
webPush.setVapidDetails(subject, keys.publicKey, keys.privateKey);
pushInitialized = true;
};
const setPushInitialized = (value) => {
pushInitialized = value === true;
};
return {
getOrCreateVapidKeys,
addOrUpdatePushSubscription,
removePushSubscription,
sendPushToAllUiSessions,
updateUiVisibility,
isAnyUiVisible,
isUiVisible,
ensurePushInitialized,
setPushInitialized,
};
};
@@ -0,0 +1,247 @@
const parsePushSubscribeBody = (body) => {
if (!body || typeof body !== 'object') return null;
const endpoint = body.endpoint;
const keys = body.keys;
const p256dh = keys?.p256dh;
const auth = keys?.auth;
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null;
if (typeof p256dh !== 'string' || p256dh.trim().length === 0) return null;
if (typeof auth !== 'string' || auth.trim().length === 0) return null;
return {
endpoint: endpoint.trim(),
keys: { p256dh: p256dh.trim(), auth: auth.trim() },
};
};
const parsePushUnsubscribeBody = (body) => {
if (!body || typeof body !== 'object') return null;
const endpoint = body.endpoint;
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null;
return { endpoint: endpoint.trim() };
};
export const registerNotificationRoutes = (app, dependencies) => {
const {
uiAuthController,
ensurePushInitialized,
getOrCreateVapidKeys,
getUiSessionTokenFromRequest,
readSettingsFromDiskMigrated,
writeSettingsToDisk,
addOrUpdatePushSubscription,
removePushSubscription,
updateUiVisibility,
isUiVisible,
getSessionActivitySnapshot,
getSessionStateSnapshot,
getSessionAttentionSnapshot,
getSessionState,
getSessionAttentionState,
markSessionViewed,
markSessionUnviewed,
markUserMessageSent,
setPushInitialized,
} = dependencies;
app.get('/api/push/vapid-public-key', async (_req, res) => {
try {
await ensurePushInitialized();
const keys = await getOrCreateVapidKeys();
res.json({ publicKey: keys.publicKey });
} catch (error) {
console.warn('[Push] Failed to load VAPID key:', error);
res.status(500).json({ error: 'Failed to load push key' });
}
});
app.post('/api/push/subscribe', async (req, res) => {
await ensurePushInitialized();
const uiToken = uiAuthController?.ensureSessionToken
? await uiAuthController.ensureSessionToken(req, res)
: getUiSessionTokenFromRequest(req);
if (!uiToken) {
return res.status(401).json({ error: 'UI session missing' });
}
const parsed = parsePushSubscribeBody(req.body);
if (!parsed) {
return res.status(400).json({ error: 'Invalid body' });
}
const { endpoint, keys } = parsed;
const origin = typeof req.body?.origin === 'string' ? req.body.origin.trim() : '';
if (origin.startsWith('http://') || origin.startsWith('https://')) {
try {
const settings = await readSettingsFromDiskMigrated();
if (typeof settings?.publicOrigin !== 'string' || settings.publicOrigin.trim().length === 0) {
await writeSettingsToDisk({
...settings,
publicOrigin: origin,
});
setPushInitialized(false);
}
} catch {
}
}
await addOrUpdatePushSubscription(
uiToken,
{
endpoint,
p256dh: keys.p256dh,
auth: keys.auth,
},
req.headers['user-agent']
);
return res.json({ ok: true });
});
app.delete('/api/push/subscribe', async (req, res) => {
await ensurePushInitialized();
const uiToken = uiAuthController?.ensureSessionToken
? await uiAuthController.ensureSessionToken(req, res)
: getUiSessionTokenFromRequest(req);
if (!uiToken) {
return res.status(401).json({ error: 'UI session missing' });
}
const parsed = parsePushUnsubscribeBody(req.body);
if (!parsed) {
return res.status(400).json({ error: 'Invalid body' });
}
await removePushSubscription(uiToken, parsed.endpoint);
return res.json({ ok: true });
});
app.post('/api/push/visibility', async (req, res) => {
const uiToken = uiAuthController?.ensureSessionToken
? await uiAuthController.ensureSessionToken(req, res)
: getUiSessionTokenFromRequest(req);
if (!uiToken) {
return res.status(401).json({ error: 'UI session missing' });
}
const visible = req.body && typeof req.body === 'object' ? req.body.visible : null;
updateUiVisibility(uiToken, visible === true);
return res.json({ ok: true });
});
app.get('/api/push/visibility', (req, res) => {
const uiToken = getUiSessionTokenFromRequest(req);
if (!uiToken) {
return res.status(401).json({ error: 'UI session missing' });
}
return res.json({
ok: true,
visible: isUiVisible(uiToken),
});
});
app.get('/api/session-activity', (_req, res) => {
res.json(getSessionActivitySnapshot());
});
app.get('/api/sessions/snapshot', (_req, res) => {
res.json({
statusSessions: getSessionStateSnapshot(),
attentionSessions: getSessionAttentionSnapshot(),
serverTime: Date.now(),
});
});
app.get('/api/sessions/status', (_req, res) => {
const snapshot = getSessionStateSnapshot();
res.json({
sessions: snapshot,
serverTime: Date.now(),
});
});
app.get('/api/sessions/:id/status', (req, res) => {
const sessionId = req.params.id;
const state = getSessionState(sessionId);
if (!state) {
return res.status(404).json({
error: 'Session not found or no state available',
sessionId,
});
}
return res.json({
sessionId,
...state,
});
});
app.get('/api/sessions/attention', (_req, res) => {
const snapshot = getSessionAttentionSnapshot();
res.json({
sessions: snapshot,
serverTime: Date.now(),
});
});
app.get('/api/sessions/:id/attention', (req, res) => {
const sessionId = req.params.id;
const state = getSessionAttentionState(sessionId);
if (!state) {
return res.status(404).json({
error: 'Session not found or no attention state available',
sessionId,
});
}
return res.json({
sessionId,
...state,
});
});
app.post('/api/sessions/:id/view', (req, res) => {
const sessionId = req.params.id;
const clientId = req.headers['x-client-id'] || req.ip || 'anonymous';
markSessionViewed(sessionId, clientId);
return res.json({
success: true,
sessionId,
viewed: true,
});
});
app.post('/api/sessions/:id/unview', (req, res) => {
const sessionId = req.params.id;
const clientId = req.headers['x-client-id'] || req.ip || 'anonymous';
markSessionUnviewed(sessionId, clientId);
return res.json({
success: true,
sessionId,
viewed: false,
});
});
app.post('/api/sessions/:id/message-sent', (req, res) => {
const sessionId = req.params.id;
markUserMessageSent(sessionId);
return res.json({
success: true,
sessionId,
messageSent: true,
});
});
};
@@ -0,0 +1,471 @@
export const createNotificationTriggerRuntime = (deps) => {
const {
readSettingsFromDisk,
prepareNotificationLastMessage,
summarizeText,
resolveZenModel,
buildTemplateVariables,
extractLastMessageText,
fetchLastAssistantMessageText,
resolveNotificationTemplate,
shouldApplyResolvedTemplateMessage,
emitDesktopNotification,
broadcastUiNotification,
sendPushToAllUiSessions,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
} = deps;
const PUSH_READY_COOLDOWN_MS = 5000;
const PUSH_QUESTION_DEBOUNCE_MS = 500;
const PUSH_PERMISSION_DEBOUNCE_MS = 500;
const pushQuestionDebounceTimers = new Map();
const pushPermissionDebounceTimers = new Map();
const notifiedPermissionRequests = new Set();
const lastReadyNotificationAt = new Map();
const sessionParentIdCache = new Map();
const SESSION_PARENT_CACHE_TTL_MS = 60 * 1000;
const buildSessionDeepLinkUrl = (sessionId) => {
if (!sessionId || typeof sessionId !== 'string') {
return '/';
}
return `/?session=${encodeURIComponent(sessionId)}`;
};
const getCachedSessionParentId = (sessionId) => {
const entry = sessionParentIdCache.get(sessionId);
if (!entry) return undefined;
if (Date.now() - entry.at > SESSION_PARENT_CACHE_TTL_MS) {
sessionParentIdCache.delete(sessionId);
return undefined;
}
return entry.parentID;
};
const setCachedSessionParentId = (sessionId, parentID) => {
sessionParentIdCache.set(sessionId, { parentID: parentID ?? null, at: Date.now() });
};
const fetchSessionParentId = async (sessionId) => {
if (!sessionId) return undefined;
const cached = getCachedSessionParentId(sessionId);
if (cached !== undefined) return cached;
try {
const response = await fetch(buildOpenCodeUrl('/session', ''), {
method: 'GET',
headers: {
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
signal: AbortSignal.timeout(2000),
});
if (!response.ok) {
return undefined;
}
const data = await response.json().catch(() => null);
if (!Array.isArray(data)) {
return undefined;
}
const match = data.find((session) => session && typeof session === 'object' && session.id === sessionId);
const parentID = match?.parentID ? match.parentID : null;
setCachedSessionParentId(sessionId, parentID);
return parentID;
} catch {
return undefined;
}
};
const extractSessionIdFromPayload = (payload) => {
if (!payload || typeof payload !== 'object') return null;
const props = payload.properties;
const info = props?.info;
const sessionId =
info?.sessionID ??
info?.sessionId ??
props?.sessionID ??
props?.sessionId ??
props?.session ??
null;
return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
};
const formatMode = (raw) => {
const value = typeof raw === 'string' ? raw.trim() : '';
const normalized = value.length > 0 ? value : 'agent';
return normalized
.split(/[-_\s]+/)
.filter(Boolean)
.map((token) => token.charAt(0).toUpperCase() + token.slice(1))
.join(' ');
};
const formatModelId = (raw) => {
const value = typeof raw === 'string' ? raw.trim() : '';
if (!value) {
return 'Assistant';
}
const tokens = value.split(/[-_]+/).filter(Boolean);
const result = [];
for (let i = 0; i < tokens.length; i += 1) {
const current = tokens[i];
const next = tokens[i + 1];
if (/^\d+$/.test(current) && next && /^\d+$/.test(next)) {
result.push(`${current}.${next}`);
i += 1;
continue;
}
result.push(current);
}
return result
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
};
const maybeSendPushForTrigger = async (payload) => {
if (!payload || typeof payload !== 'object') {
return;
}
const sessionId = extractSessionIdFromPayload(payload);
if (payload.type === 'message.updated') {
const info = payload.properties?.info;
if (info?.role === 'assistant' && info?.finish === 'stop' && sessionId) {
const settings = await readSettingsFromDisk();
if (settings.notifyOnSubtasks === false) {
const sessionInfo = payload.properties?.session;
const parentIDFromPayload = sessionInfo?.parentID ?? payload.properties?.parentID;
const parentID = parentIDFromPayload
? parentIDFromPayload
: await fetchSessionParentId(sessionId);
if (parentID) {
return;
}
}
if (settings.notifyOnCompletion === false) {
return;
}
const now = Date.now();
const lastAt = lastReadyNotificationAt.get(sessionId) ?? 0;
if (now - lastAt < PUSH_READY_COOLDOWN_MS) {
return;
}
lastReadyNotificationAt.set(sessionId, now);
let title = `${formatMode(info?.mode)} agent is ready`;
let body = `${formatModelId(info?.modelID)} completed the task`;
try {
const templates = settings.notificationTemplates || {};
const isSubtask = await fetchSessionParentId(sessionId);
const completionTemplate = isSubtask && settings.notifyOnSubtasks !== false
? (templates.subtask || templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' })
: (templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' });
const variables = await buildTemplateVariables(payload, sessionId);
const messageId = info?.id;
let lastMessage = extractLastMessageText(payload);
if (!lastMessage) {
lastMessage = await fetchLastAssistantMessageText(sessionId, messageId);
}
const notifZenModel = await resolveZenModel(settings?.zenModel);
variables.last_message = await prepareNotificationLastMessage({
message: lastMessage,
settings,
summarize: (text, len) => summarizeText(text, len, notifZenModel),
});
const resolvedTitle = resolveNotificationTemplate(completionTemplate.title, variables);
const resolvedBody = resolveNotificationTemplate(completionTemplate.message, variables);
if (resolvedTitle) title = resolvedTitle;
if (shouldApplyResolvedTemplateMessage(completionTemplate.message, resolvedBody, variables)) body = resolvedBody;
} catch (error) {
console.warn('[Notification] Template resolution failed, using defaults:', error?.message || error);
}
if (settings.nativeNotificationsEnabled) {
const notificationPayload = {
title,
body,
tag: `ready-${sessionId}`,
kind: 'ready',
sessionId,
requireHidden: settings.notificationMode !== 'always',
};
emitDesktopNotification(notificationPayload);
broadcastUiNotification(notificationPayload);
}
await sendPushToAllUiSessions(
{
title,
body,
tag: `ready-${sessionId}`,
data: {
url: buildSessionDeepLinkUrl(sessionId),
sessionId,
type: 'ready',
},
},
{ requireNoSse: true },
);
}
if (info?.role === 'assistant' && info?.finish === 'error' && sessionId) {
const settings = await readSettingsFromDisk();
if (settings.notifyOnError === false) return;
let title = 'Tool error';
let body = 'An error occurred';
try {
const variables = await buildTemplateVariables(payload, sessionId);
const errorMessageId = info?.id;
let lastMessage = extractLastMessageText(payload);
if (!lastMessage) {
lastMessage = await fetchLastAssistantMessageText(sessionId, errorMessageId);
}
const errZenModel = await resolveZenModel(settings?.zenModel);
variables.last_message = await prepareNotificationLastMessage({
message: lastMessage,
settings,
summarize: (text, len) => summarizeText(text, len, errZenModel),
});
const errorTemplate = (settings.notificationTemplates || {}).error || { title: 'Tool error', message: '{last_message}' };
const resolvedTitle = resolveNotificationTemplate(errorTemplate.title, variables);
const resolvedBody = resolveNotificationTemplate(errorTemplate.message, variables);
if (resolvedTitle) title = resolvedTitle;
if (shouldApplyResolvedTemplateMessage(errorTemplate.message, resolvedBody, variables)) body = resolvedBody;
} catch (error) {
console.warn('[Notification] Error template resolution failed, using defaults:', error?.message || error);
}
if (settings.nativeNotificationsEnabled) {
const notificationPayload = {
title,
body,
tag: `error-${sessionId}`,
kind: 'error',
sessionId,
requireHidden: settings.notificationMode !== 'always',
};
emitDesktopNotification(notificationPayload);
broadcastUiNotification(notificationPayload);
}
await sendPushToAllUiSessions(
{
title,
body,
tag: `error-${sessionId}`,
data: {
url: buildSessionDeepLinkUrl(sessionId),
sessionId,
type: 'error',
},
},
{ requireNoSse: true },
);
}
return;
}
if (payload.type === 'question.asked' && sessionId) {
const existingTimer = pushQuestionDebounceTimers.get(sessionId);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(async () => {
pushQuestionDebounceTimers.delete(sessionId);
const settings = await readSettingsFromDisk();
if (settings.notifyOnQuestion === false) {
return;
}
const firstQuestion = payload.properties?.questions?.[0];
const header = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : '';
const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : '';
let title = /plan\s*mode/i.test(header)
? 'Switch to plan mode'
: /build\s*agent/i.test(header)
? 'Switch to build mode'
: header || 'Input needed';
let body = questionText || 'Agent is waiting for your response';
try {
const variables = await buildTemplateVariables(payload, sessionId);
variables.last_message = questionText || header || '';
const templates = settings.notificationTemplates || {};
const questionTemplate = templates.question || { title: 'Input needed', message: '{last_message}' };
const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables);
const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables);
if (resolvedTitle) title = resolvedTitle;
if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody;
} catch (error) {
console.warn('[Notification] Question template resolution failed, using defaults:', error?.message || error);
}
if (settings.nativeNotificationsEnabled) {
emitDesktopNotification({
kind: 'question',
title,
body,
tag: `question-${sessionId}`,
sessionId,
requireHidden: settings.notificationMode !== 'always',
});
broadcastUiNotification({
kind: 'question',
title,
body,
tag: `question-${sessionId}`,
sessionId,
requireHidden: settings.notificationMode !== 'always',
});
}
void sendPushToAllUiSessions(
{
title,
body,
tag: `question-${sessionId}`,
data: {
url: buildSessionDeepLinkUrl(sessionId),
sessionId,
type: 'question',
},
},
{ requireNoSse: true },
);
}, PUSH_QUESTION_DEBOUNCE_MS);
pushQuestionDebounceTimers.set(sessionId, timer);
return;
}
if (payload.type === 'permission.replied' && sessionId) {
const requestId = payload.properties?.requestID;
const requestKey = typeof requestId === 'string' ? `${sessionId}:${requestId}` : null;
const pendingNotification = pushPermissionDebounceTimers.get(sessionId);
if (requestKey && pendingNotification?.requestKey === requestKey) {
clearTimeout(pendingNotification.timer);
pushPermissionDebounceTimers.delete(sessionId);
}
return;
}
if (payload.type === 'permission.asked' && sessionId) {
const requestId = payload.properties?.id;
const permission = payload.properties?.permission;
const requestKey = typeof requestId === 'string' ? `${sessionId}:${requestId}` : null;
if (requestKey && notifiedPermissionRequests.has(requestKey)) {
return;
}
const existingTimer = pushPermissionDebounceTimers.get(sessionId);
if (existingTimer) {
clearTimeout(existingTimer.timer);
}
const timer = setTimeout(async () => {
pushPermissionDebounceTimers.delete(sessionId);
const settings = await readSettingsFromDisk();
if (settings.notifyOnQuestion === false) {
return;
}
const sessionTitle = payload.properties?.sessionTitle;
const permissionText = typeof permission === 'string' && permission.length > 0 ? permission : '';
const fallbackMessage = typeof sessionTitle === 'string' && sessionTitle.trim().length > 0
? sessionTitle.trim()
: permissionText || 'Agent is waiting for your approval';
let title = 'Permission required';
let body = fallbackMessage;
try {
const variables = await buildTemplateVariables(payload, sessionId);
variables.last_message = fallbackMessage;
const templates = settings.notificationTemplates || {};
const questionTemplate = templates.question || { title: 'Permission required', message: '{last_message}' };
const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables);
const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables);
if (resolvedTitle) title = resolvedTitle;
if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody;
} catch (error) {
console.warn('[Notification] Permission template resolution failed, using defaults:', error?.message || error);
}
if (settings.nativeNotificationsEnabled) {
emitDesktopNotification({
kind: 'permission',
title,
body,
tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`,
sessionId,
requireHidden: settings.notificationMode !== 'always',
});
broadcastUiNotification({
kind: 'permission',
title,
body,
tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`,
sessionId,
requireHidden: settings.notificationMode !== 'always',
});
}
if (requestKey) {
notifiedPermissionRequests.add(requestKey);
}
void sendPushToAllUiSessions(
{
title,
body,
tag: `permission-${sessionId}`,
data: {
url: buildSessionDeepLinkUrl(sessionId),
sessionId,
type: 'permission',
},
},
{ requireNoSse: true },
);
}, PUSH_PERMISSION_DEBOUNCE_MS);
pushPermissionDebounceTimers.set(sessionId, { timer, requestKey });
}
};
return {
maybeSendPushForTrigger,
};
};
@@ -0,0 +1,436 @@
export const createNotificationTemplateRuntime = (deps) => {
const {
readSettingsFromDisk,
persistSettings,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
resolveGitBinaryForSpawn,
} = deps;
const NOTIFICATION_BODY_MAX_CHARS = 1000;
const ZEN_DEFAULT_MODEL = 'gpt-5-nano';
const ZEN_MODELS_CACHE_TTL = 5 * 60 * 1000;
const SESSION_INFO_CACHE_TTL_MS = 60 * 1000;
let validatedZenFallback = null;
let cachedZenModels = null;
let cachedZenModelsTimestamp = 0;
const sessionTitleCache = new Map();
const sessionInfoCache = new Map();
const createTimeoutSignal = (timeoutMs) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
return {
signal: controller.signal,
cleanup: () => clearTimeout(timer),
};
};
const formatProjectLabel = (label) => {
if (!label || typeof label !== 'string') return '';
return label
.replace(/[-_]/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase());
};
const resolveNotificationTemplate = (template, variables) => {
if (!template || typeof template !== 'string') return '';
return template.replace(/\{(\w+)\}/g, (_match, key) => {
const value = variables[key];
if (value === undefined || value === null) return '';
return String(value);
});
};
const shouldApplyResolvedTemplateMessage = (template, resolved, variables) => {
if (!resolved) {
return false;
}
if (typeof template !== 'string') {
return true;
}
if (template.includes('{last_message}')) {
return typeof variables?.last_message === 'string' && variables.last_message.trim().length > 0;
}
return true;
};
const fetchFreeZenModels = async () => {
const now = Date.now();
if (cachedZenModels && now - cachedZenModelsTimestamp < ZEN_MODELS_CACHE_TTL) {
return cachedZenModels.models;
}
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null;
try {
const response = await fetch('https://opencode.ai/zen/v1/models', {
signal: controller?.signal,
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`zen/v1/models responded with status ${response.status}`);
}
const data = await response.json();
const allModels = Array.isArray(data?.data) ? data.data : [];
const freeModels = allModels
.filter((model) => typeof model?.id === 'string' && model.id.endsWith('-free'))
.map((model) => ({ id: model.id, owned_by: model.owned_by }));
cachedZenModels = { models: freeModels };
cachedZenModelsTimestamp = Date.now();
return freeModels;
} finally {
if (timeout) clearTimeout(timeout);
}
};
const resolveZenModel = async (override) => {
if (typeof override === 'string' && override.trim().length > 0) {
return override.trim();
}
try {
const settings = await readSettingsFromDisk();
if (typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0) {
return settings.zenModel.trim();
}
} catch {
}
return validatedZenFallback || ZEN_DEFAULT_MODEL;
};
const validateZenModelAtStartup = async () => {
try {
const freeModels = await fetchFreeZenModels();
const freeModelIds = freeModels.map((model) => model.id);
if (freeModelIds.length > 0) {
validatedZenFallback = freeModelIds[0];
const settings = await readSettingsFromDisk();
const storedModel = typeof settings?.zenModel === 'string' ? settings.zenModel.trim() : '';
if (!storedModel || !freeModelIds.includes(storedModel)) {
const fallback = freeModelIds[0];
console.log(
storedModel
? `[zen] Stored model "${storedModel}" not found in free models, falling back to "${fallback}"`
: `[zen] No model configured, setting default to "${fallback}"`
);
await persistSettings({ zenModel: fallback });
} else {
console.log(`[zen] Stored model "${storedModel}" verified as available`);
}
} else {
console.warn('[zen] No free models returned from API, skipping validation');
}
} catch (error) {
console.warn('[zen] Startup model validation failed (non-blocking):', error?.message || error);
}
};
const summarizeText = async (text, targetLength, zenModel) => {
if (!text || typeof text !== 'string' || text.trim().length === 0) return text;
try {
const prompt = `Summarize the following text in approximately ${targetLength} characters. Be concise and capture the key point. Output ONLY the summary text, nothing else.\n\nText:\n${text}`;
const completionTimeout = createTimeoutSignal(15000);
let response;
try {
response = await fetch('https://opencode.ai/zen/v1/responses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: zenModel || ZEN_DEFAULT_MODEL,
input: [{ role: 'user', content: prompt }],
max_output_tokens: 1000,
stream: false,
reasoning: { effort: 'low' },
}),
signal: completionTimeout.signal,
});
} finally {
completionTimeout.cleanup();
}
if (!response.ok) return text;
const data = await response.json();
const summary = data?.output?.find((item) => item?.type === 'message')
?.content?.find((item) => item?.type === 'output_text')?.text?.trim();
return summary || text;
} catch {
return text;
}
};
const extractTextFromParts = (parts, maxLength = NOTIFICATION_BODY_MAX_CHARS) => {
if (!Array.isArray(parts) || parts.length === 0) return '';
const textParts = parts
.filter((part) => part && (part.type === 'text' || typeof part.text === 'string' || typeof part.content === 'string'))
.map((part) => part.text || part.content || '')
.filter(Boolean);
let text = textParts.length > 0 ? textParts.join('\n').trim() : '';
if (maxLength > 0 && text.length > maxLength) {
text = text.slice(0, maxLength);
}
return text;
};
const extractLastMessageText = (payload, maxLength = NOTIFICATION_BODY_MAX_CHARS) => {
const info = payload?.properties?.info;
if (!info) return '';
const parts = info.parts || payload?.properties?.parts;
const text = extractTextFromParts(parts, maxLength);
if (text) return text;
const content = info.content;
if (Array.isArray(content)) {
const textContent = content
.filter((entry) => entry && (entry.type === 'text' || typeof entry.text === 'string'))
.map((entry) => entry.text || '')
.filter(Boolean);
if (textContent.length > 0) {
let result = textContent.join('\n').trim();
if (maxLength > 0 && result.length > maxLength) {
result = result.slice(0, maxLength);
}
return result;
}
}
return '';
};
const fetchLastAssistantMessageText = async (sessionId, messageId, maxLength = NOTIFICATION_BODY_MAX_CHARS) => {
if (!sessionId) return '';
try {
const url = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, '');
const response = await fetch(`${url}?limit=5`, {
method: 'GET',
headers: {
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
signal: AbortSignal.timeout(3000),
});
if (!response.ok) return '';
const messages = await response.json().catch(() => null);
if (!Array.isArray(messages)) return '';
let target = null;
if (messageId) {
target = messages.find((message) => message?.info?.id === messageId && message?.info?.role === 'assistant');
}
if (!target) {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message?.info?.role === 'assistant' && message?.info?.finish === 'stop') {
target = message;
break;
}
}
}
if (!target || !Array.isArray(target.parts)) return '';
return extractTextFromParts(target.parts, maxLength);
} catch {
return '';
}
};
const cacheSessionTitle = (sessionId, title) => {
if (typeof sessionId === 'string' && sessionId.length > 0 && typeof title === 'string' && title.length > 0) {
sessionTitleCache.set(sessionId, title);
}
};
const getCachedSessionTitle = (sessionId) => {
return sessionTitleCache.get(sessionId) ?? null;
};
const maybeCacheSessionInfoFromEvent = (payload) => {
if (!payload || typeof payload !== 'object') return;
const type = payload.type;
if (type !== 'session.updated' && type !== 'session.created') return;
const info = payload.properties?.info;
if (!info || typeof info !== 'object') return;
cacheSessionTitle(info.id, info.title);
};
const fetchSessionInfo = async (sessionId) => {
if (!sessionId) return null;
const cached = sessionInfoCache.get(sessionId);
if (cached && Date.now() - cached.at < SESSION_INFO_CACHE_TTL_MS) {
return cached.data;
}
try {
const url = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, '');
const response = await fetch(url, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(2000),
});
if (!response.ok) {
console.warn(`[Notification] fetchSessionInfo: ${response.status} for session ${sessionId}`);
return null;
}
const data = await response.json().catch(() => null);
if (data && typeof data === 'object') {
sessionInfoCache.set(sessionId, { data, at: Date.now() });
return data;
}
return null;
} catch (error) {
console.warn(`[Notification] fetchSessionInfo failed for ${sessionId}:`, error?.message || error);
return null;
}
};
const buildTemplateVariables = async (payload, sessionId) => {
const info = payload?.properties?.info || {};
let sessionTitle = payload?.properties?.sessionTitle || payload?.properties?.session?.title || (typeof info.sessionTitle === 'string' ? info.sessionTitle : '') || '';
if (!sessionTitle && sessionId) {
const cached = getCachedSessionTitle(sessionId);
if (cached) {
sessionTitle = cached;
}
}
let sessionInfo = null;
if (!sessionTitle && sessionId) {
sessionInfo = await fetchSessionInfo(sessionId);
if (sessionInfo && typeof sessionInfo.title === 'string') {
sessionTitle = sessionInfo.title;
cacheSessionTitle(sessionId, sessionTitle);
}
}
const agentName = (() => {
const mode = typeof info.agent === 'string' && info.agent.trim().length > 0
? info.agent.trim()
: (typeof info.mode === 'string' ? info.mode.trim() : '');
if (!mode) return 'Agent';
return mode.split(/[-_\s]+/).filter(Boolean)
.map((token) => token.charAt(0).toUpperCase() + token.slice(1)).join(' ');
})();
const modelName = (() => {
const raw = typeof info.modelID === 'string' ? info.modelID.trim()
: (typeof info.model?.modelID === 'string' ? info.model.modelID.trim() : '');
if (!raw) return 'Assistant';
return raw.split(/[-_]+/).filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' ');
})();
let projectName = '';
let branch = '';
let worktreeDir = '';
const infoPath = info.path;
if (typeof infoPath?.root === 'string' && infoPath.root.length > 0) {
worktreeDir = infoPath.root;
} else if (typeof infoPath?.cwd === 'string' && infoPath.cwd.length > 0) {
worktreeDir = infoPath.cwd;
}
try {
const settings = await readSettingsFromDisk();
const projects = Array.isArray(settings.projects) ? settings.projects : [];
if (worktreeDir) {
const normalizedDir = worktreeDir.replace(/\/+$/, '');
const matchedProject = projects.find((project) => {
if (!project || typeof project.path !== 'string') return false;
return project.path.replace(/\/+$/, '') === normalizedDir;
});
if (matchedProject && typeof matchedProject.label === 'string' && matchedProject.label.trim().length > 0) {
projectName = matchedProject.label.trim();
} else {
projectName = normalizedDir.split('/').filter(Boolean).pop() || '';
}
} else {
const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : '';
const activeProject = activeId ? projects.find((project) => project && project.id === activeId) : projects[0];
if (activeProject) {
projectName = typeof activeProject.label === 'string' && activeProject.label.trim().length > 0
? activeProject.label.trim()
: typeof activeProject.path === 'string'
? activeProject.path.split('/').pop() || ''
: '';
worktreeDir = typeof activeProject.path === 'string' ? activeProject.path : '';
}
}
} catch {
if (worktreeDir && !projectName) {
projectName = worktreeDir.split('/').filter(Boolean).pop() || '';
}
}
if (worktreeDir) {
try {
const { simpleGit } = await import('simple-git');
const git = simpleGit({
baseDir: worktreeDir,
spawnOptions: { windowsHide: true },
binary: resolveGitBinaryForSpawn(),
});
branch = await Promise.race([
git.revparse(['--abbrev-ref', 'HEAD']),
new Promise((_, reject) => setTimeout(() => reject(new Error('git timeout')), 3000)),
]).catch(() => '');
} catch {
}
}
return {
project_name: formatProjectLabel(projectName),
worktree: worktreeDir,
branch: typeof branch === 'string' ? branch.trim() : '',
session_name: sessionTitle,
agent_name: agentName,
model_name: modelName,
last_message: '',
session_id: sessionId || '',
};
};
const getCachedZenModels = () => cachedZenModels;
return {
createTimeoutSignal,
formatProjectLabel,
resolveNotificationTemplate,
shouldApplyResolvedTemplateMessage,
fetchFreeZenModels,
resolveZenModel,
validateZenModelAtStartup,
summarizeText,
extractTextFromParts,
extractLastMessageText,
fetchLastAssistantMessageText,
maybeCacheSessionInfoFromEvent,
buildTemplateVariables,
getCachedZenModels,
};
};
@@ -6,6 +6,39 @@ This module provides OpenCode server integration utilities for the web server ru
## Entrypoints and structure
- `packages/web/server/lib/opencode/index.js`: public entrypoint (currently baseline placeholder).
- `packages/web/server/lib/opencode/auth.js`: provider authentication file operations.
- `packages/web/server/lib/opencode/auth-state-runtime.js`: managed OpenCode server auth password/header runtime.
- `packages/web/server/lib/opencode/cli-options.js`: CLI/environment option parsing for server startup arguments.
- `packages/web/server/lib/opencode/cli-entry-runtime.js`: CLI entrypoint runtime that detects direct execution, parses CLI options, and starts server bootstrap.
- `packages/web/server/lib/opencode/routes.js`: OpenCode/provider settings and auth-related route registration.
- `packages/web/server/lib/opencode/lifecycle.js`: OpenCode process lifecycle runtime (startup, restart, readiness, health monitoring).
- `packages/web/server/lib/opencode/env-runtime.js`: OpenCode CLI/binary resolution and shell environment runtime.
- `packages/web/server/lib/opencode/env-config.js`: OpenCode-related environment variable parsing and validation (host/port/hostname).
- `packages/web/server/lib/opencode/hmr-state-runtime.js`: HMR-persistent runtime state initialization, auth-state bootstrap, and HMR sync helpers.
- `packages/web/server/lib/opencode/bootstrap-runtime.js`: base app bootstrap runtime for status/auth/tts/notification/OpenChamber route wiring.
- `packages/web/server/lib/opencode/network-runtime.js`: OpenCode URL construction, health-probe readiness checks, and API prefix runtime.
- `packages/web/server/lib/opencode/project-directory-runtime.js`: request-scoped and settings-backed project directory resolution/validation runtime.
- `packages/web/server/lib/opencode/config-entity-routes.js`: route registration for agent/command/MCP config orchestration and reload semantics.
- `packages/web/server/lib/opencode/cli-options.js`: CLI/environment option parsing for server startup arguments.
- `packages/web/server/lib/opencode/core-routes.js`: server status/system routes, auth/access guard routes, and settings utility route registration.
- `packages/web/server/lib/opencode/shutdown-runtime.js`: graceful shutdown orchestration runtime for watcher/session/terminal/process/server teardown.
- `packages/web/server/lib/opencode/server-startup-runtime.js`: server listen/startup tunnel flow and process/signal handler orchestration runtime.
- `packages/web/server/lib/opencode/static-routes-runtime.js`: static asset/SPA fallback route registration and manifest route wiring.
- `packages/web/server/lib/opencode/feature-routes-runtime.js`: feature route composition runtime for dynamic import-backed config/skill/provider route registration.
- `packages/web/server/lib/opencode/opencode-resolution-runtime.js`: OpenCode binary resolution snapshot runtime for settings routes and diagnostics.
- `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: tunnel service/routes composition runtime and active-port wiring for main server startup.
- `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: server startup tail orchestration runtime for terminal/proxy/static/start-listen flow.
- `packages/web/server/lib/opencode/server-utils-runtime.js`: shared server runtime utilities for OpenCode proxy wiring, OpenCode port/readiness helpers, and snapshot fetchers.
- `packages/web/server/lib/opencode/openchamber-routes.js`: OpenChamber update and models metadata route registration.
- `packages/web/server/lib/opencode/pwa-manifest-routes.js`: PWA manifest route registration with recent-session shortcut resolution and short-lived caching.
- `packages/web/server/lib/opencode/project-icon-routes.js`: project icon upload/read/discovery route registration and icon storage orchestration.
- `packages/web/server/lib/opencode/skill-routes.js`: route registration for skill config CRUD, supporting files, and skills catalog scan/install flows.
- `packages/web/server/lib/opencode/settings-runtime.js`: Settings persistence runtime (disk IO, migrations, normalization, project validation, and persisted update serialization).
- `packages/web/server/lib/opencode/settings-helpers.js`: Settings payload sanitization/format helpers runtime for response shaping and persisted merge prep.
- `packages/web/server/lib/opencode/settings-normalization-runtime.js`: path/settings/tunnel normalization and sanitization helpers runtime used by settings/routes/config wiring.
- `packages/web/server/lib/opencode/theme-runtime.js`: custom theme JSON validation and theme directory loading runtime for settings utility routes.
- `packages/web/server/lib/opencode/proxy.js`: OpenCode API/SSE forwarding and readiness-gate route registration.
- `packages/web/server/lib/opencode/session-runtime.js`: session status/attention/activity runtime for OpenCode SSE events.
- `packages/web/server/lib/opencode/watcher.js`: global SSE watcher runtime for push/session event fanout.
- `packages/web/server/lib/opencode/shared.js`: shared utilities for config, markdown, skills, and git helpers.
- `packages/web/server/lib/opencode/ui-auth.js`: UI session authentication with rate limiting.
@@ -43,6 +76,269 @@ This module provides OpenCode server integration utilities for the web server ru
- `ensureSessionToken(req, res)`: Returns or creates session token.
- `dispose()`: Cleans up timers and state.
## Public exports (routes.js)
- `registerOpenCodeRoutes(app, dependencies)`: Registers OpenCode-owned HTTP routes and internal module runtime:
- `GET /api/config/settings`
- `PUT /api/config/settings`
- `GET /api/config/opencode-resolution`
- `POST /api/opencode/directory`
- `GET /api/provider/:providerId/source`
- `DELETE /api/provider/:providerId/auth`
- Owns lazy auth library loading for provider auth checks/removal.
- Keeps route behavior independent from composition root; `index.js` now supplies dependencies only.
## Public exports (session-runtime.js)
- `createSessionRuntime({ writeSseEvent, getNotificationClients })`: creates runtime-owned state machine and APIs for session status.
- Returned API:
- `processOpenCodeSsePayload(payload)`
- `getSessionActivitySnapshot()`
- `getSessionStateSnapshot()`
- `getSessionAttentionSnapshot()`
- `getSessionState(sessionId)`
- `getSessionAttentionState(sessionId)`
- `markSessionViewed(sessionId, clientId)`
- `markSessionUnviewed(sessionId, clientId)`
- `markUserMessageSent(sessionId)`
- `resetAllSessionActivityToIdle()`
- `dispose()`
## Public exports (lifecycle.js)
- `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration.
- Returned API:
- `startOpenCode()`
- `restartOpenCode()`
- `waitForOpenCodeReady(timeoutMs?, intervalMs?)`
- `waitForAgentPresence(agentName, timeoutMs?, intervalMs?)`
- `refreshOpenCodeAfterConfigChange(reason, options?)`
- `bootstrapOpenCodeAtStartup()`
- `startHealthMonitoring(healthCheckIntervalMs)`
- `killProcessOnPort(port)`
## Public exports (env-runtime.js)
- `createOpenCodeEnvRuntime(dependencies)`: creates runtime that owns OpenCode CLI environment and binary discovery state.
- Returned API:
- `applyLoginShellEnvSnapshot()`
- `getLoginShellEnvSnapshot()`
- `ensureOpencodeCliEnv()`
- `applyOpencodeBinaryFromSettings()`
- `resolveOpencodeCliPath()`
- `resolveGitBinaryForSpawn()`
- `resolveWslExecutablePath()`
- `buildWslExecArgs(execArgs, distroOverride?)`
- `opencodeShimInterpreter(opencodePath)`
- `isExecutable(filePath)`
- `searchPathFor(binaryName)`
- `clearResolvedOpenCodeBinary()`
## Public exports (env-config.js)
- `resolveOpenCodeEnvConfig(options?)`: resolves and validates OpenCode host/port/hostname environment configuration.
- Returned object fields:
- `configuredOpenCodePort`
- `configuredOpenCodeHost`
- `effectivePort`
- `configuredOpenCodeHostname`
## Public exports (hmr-state-runtime.js)
- `createHmrStateRuntime(dependencies)`: creates runtime for HMR state container initialization and runtime<->HMR state synchronization.
- Returned API:
- `getOrCreateHmrState()`
- `ensureUserProvidedOpenCodePassword(hmrState)`
- `getUserProvidedOpenCodePassword(hmrState)`
- `resolveOpenCodeAuthFromState({ hmrState, userProvidedOpenCodePassword })`
- `syncStateFromRuntime(hmrState, runtime)`
- `restoreRuntimeFromState({ hmrState, userProvidedOpenCodePassword })`
## Public exports (bootstrap-runtime.js)
- `createBootstrapRuntime(dependencies)`: creates runtime for base app route bootstrap and UI auth controller initialization.
- Returned API:
- `setupBaseRoutes(app, options)`
## Public exports (network-runtime.js)
- `createOpenCodeNetworkRuntime(dependencies)`: creates runtime for OpenCode network and URL concerns.
- Returned API:
- `waitForReady(url, timeoutMs?)`
- `normalizeApiPrefix(prefix)`
- `setDetectedOpenCodeApiPrefix()`
- `buildOpenCodeUrl(path, prefixOverride?)`
- `ensureOpenCodeApiPrefix()`
- `scheduleOpenCodeApiDetection()`
## Public exports (settings-runtime.js)
- `createSettingsRuntime(dependencies)`: creates settings lifecycle runtime for read/migrate/persist concerns.
- Returned API:
- `readSettingsFromDisk()`
- `readSettingsFromDiskMigrated()`
- `writeSettingsToDisk(settings)`
- `persistSettings(changes)`
## Public exports (settings-helpers.js)
- `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping.
- Returned API:
- `normalizePwaAppName(value, fallback?)`
- `sanitizeSettingsUpdate(payload)`
- `mergePersistedSettings(current, changes)`
- `formatSettingsResponse(settings)`
## Public exports (settings-normalization-runtime.js)
- `createSettingsNormalizationRuntime(dependencies)`: creates normalization/sanitization runtime for shared settings and tunnel helper logic.
- Returned API:
- `normalizeDirectoryPath(value)`
- `normalizePathForPersistence(value)`
- `normalizeSettingsPaths(input)`
- `normalizeTunnelBootstrapTtlMs(value)`
- `normalizeTunnelSessionTtlMs(value)`
- `normalizeManagedRemoteTunnelHostname(value)`
- `normalizeManagedRemoteTunnelPresets(value)`
- `normalizeManagedRemoteTunnelPresetTokens(value)`
- `isUnsafeSkillRelativePath(value)`
- `sanitizeTypographySizesPartial(input)`
- `normalizeStringArray(input)`
- `sanitizeModelRefs(input, limit)`
- `sanitizeSkillCatalogs(input)`
- `sanitizeProjects(input)`
## Public exports (theme-runtime.js)
- `createThemeRuntime(dependencies)`: creates custom theme runtime for on-disk theme discovery and JSON normalization/validation.
- Returned API:
- `normalizeThemeJson(raw)`
- `readCustomThemesFromDisk()`
## Public exports (project-directory-runtime.js)
- `createProjectDirectoryRuntime(dependencies)`: creates runtime for request/project directory candidate normalization and validation.
- Returned API:
- `resolveDirectoryCandidate(value)`
- `validateDirectoryPath(candidate)`
- `resolveProjectDirectory(req)`
- `resolveOptionalProjectDirectory(req)`
## Public exports (config-entity-routes.js)
- `registerConfigEntityRoutes(app, dependencies)`: registers configuration entity routes:
- Agents: `/api/config/agents/:name` and `/api/config/agents/:name/config`
- Commands: `/api/config/commands/:name`
- MCP servers: `/api/config/mcp` and `/api/config/mcp/:name`
## Public exports (auth-state-runtime.js)
- `createOpenCodeAuthStateRuntime(dependencies)`: creates runtime for managed OpenCode auth password state and request headers.
- Returned API:
- `getOpenCodeAuthHeaders()`
- `isOpenCodeConnectionSecure()`
- `ensureLocalOpenCodeServerPassword(options?)`
## Public exports (core-routes.js)
- `registerServerStatusRoutes(app, dependencies)`: registers status/system endpoints:
- `GET /health`
- `POST /api/system/shutdown`
- `GET /api/system/info`
- `registerAuthAndAccessRoutes(app, dependencies)`: registers browser auth/session exchange and API access middleware:
- `GET /auth/session`
- `POST /auth/session`
- `GET /connect`
- `app.use('/api', ...)` auth/tunnel guard
- `registerSettingsUtilityRoutes(app, dependencies)`: registers small settings utility endpoints:
- `GET /api/config/themes`
- `POST /api/config/reload`
- `registerCommonRequestMiddleware(app, dependencies)`: registers shared request middleware stack:
- conditional JSON body parser behavior for `/api/*` vs non-API requests
- URL-encoded parser setup
- request logging middleware
## Public exports (cli-options.js)
- `parseServeCliOptions(options)`: parses serve CLI flags and environment-derived defaults:
- Port/host/ui-password
- Tunnel provider/mode/config/token/hostname
- Legacy `--tunnel` shorthand normalization
## Public exports (cli-entry-runtime.js)
- `runCliEntryIfMain(dependencies)`: detects direct CLI execution and runs server startup with parsed CLI options.
## Public exports (server-utils-runtime.js)
- `createServerUtilsRuntime(dependencies)`: creates server utility runtime for OpenCode orchestration helpers.
- Returned API:
- `setOpenCodePort(port)`
- `waitForOpenCodePort(timeoutMs?)`
- `buildAugmentedPath()`
- `parseSseDataPayload(block)`
- `fetchAgentsSnapshot()`
- `fetchProvidersSnapshot()`
- `fetchModelsSnapshot()`
- `setupProxy(app)`
## Public exports (shutdown-runtime.js)
- `createGracefulShutdownRuntime(dependencies)`: creates graceful shutdown runtime for managed OpenCode and web server teardown sequencing.
- Returned API:
- `gracefulShutdown(options?)`
## Public exports (server-startup-runtime.js)
- `createServerStartupRuntime(dependencies)`: creates runtime for server bind/startup tunnel and process handler wiring.
- Returned API:
- `resolveBindHost(host)`
- `startListeningAndMaybeTunnel(options)`
- `attachProcessHandlers(options)`
## Public exports (static-routes-runtime.js)
- `createStaticRoutesRuntime(dependencies)`: creates runtime for static dist resolution and static route registration.
- Returned API:
- `registerStaticRoutes(app)`
## Public exports (feature-routes-runtime.js)
- `createFeatureRoutesRuntime(dependencies)`: creates runtime for main feature route registration orchestration.
- Returned API:
- `registerRoutes(app, routeDependencies)`
## Public exports (opencode-resolution-runtime.js)
- `createOpenCodeResolutionRuntime(dependencies)`: creates runtime for OpenCode binary/source snapshot resolution.
- Returned API:
- `getOpenCodeResolutionSnapshot(settings)`
## Public exports (tunnel-wiring-runtime.js)
- `createTunnelWiringRuntime(dependencies)`: creates runtime for tunnel service construction and tunnel route registration.
- Returned API:
- `initialize(app, initialPort)`
## Public exports (startup-pipeline-runtime.js)
- `createStartupPipelineRuntime(dependencies)`: creates runtime for terminal wiring, proxy/bootstrap scheduling, static route registration, and server startup/listen flow.
- Returned API:
- `run(options)`
## Public exports (openchamber-routes.js)
- `registerOpenChamberRoutes(app, dependencies)`: registers OpenChamber endpoints:
- `GET /api/openchamber/update-check`
- `POST /api/openchamber/update-install`
- `GET /api/openchamber/models-metadata`
- `GET /api/zen/models`
## Public exports (pwa-manifest-routes.js)
- `registerPwaManifestRoute(app, dependencies)`: registers PWA manifest endpoint with dynamic app-name resolution and recent-session shortcuts:
- `GET /manifest.webmanifest`
## Public exports (project-icon-routes.js)
- `registerProjectIconRoutes(app, dependencies)`: registers project icon routes and owns icon storage/discovery flow:
- `GET /api/projects/:projectId/icon`
- `PUT /api/projects/:projectId/icon`
- `DELETE /api/projects/:projectId/icon`
- `POST /api/projects/:projectId/icon/discover`
## Public exports (skill-routes.js)
- `registerSkillRoutes(app, dependencies)`: registers skills-related routes:
- Skills config CRUD and metadata under `/api/config/skills*`
- Skills catalog listing/source pagination, scan, and install routes
- Supporting skill file read/write/delete routes
## Public exports (proxy.js)
- `registerOpenCodeProxy(app, dependencies)`: registers OpenCode proxy routes and middleware.
- Owns:
- SSE forwarders: `GET /api/global/event`, `GET /api/event`
- Session message forwarder: `POST /api/session/:sessionId/message`
- Generic `/api/*` forwarding with hop-by-hop header filtering
- Windows `/session` merge fallback path behavior
- OpenCode readiness gate for proxied `/api` requests
## Public exports (watcher.js)
- `createOpenCodeWatcherRuntime(dependencies)`: creates global event watcher runtime.
- Returned API:
- `start()`
- `stop()`
## Storage and configuration
- Provider auth: `~/.local/share/opencode/auth.json`.
- User config: `~/.config/opencode/opencode.json`.
@@ -52,7 +348,7 @@ This module provides OpenCode server integration utilities for the web server ru
## Notes for contributors
- This module serves as foundation for OpenCode-related server utilities.
- Index.js is currently a baseline placeholder; direct imports use submodule paths.
- Route ownership moved to module-level `routes.js`; `index.js` wires dependencies only.
- All file writes include automatic backup before modification.
- Config merging follows priority: custom > project > user.
- UI auth uses scrypt for password hashing with constant-time comparison.
@@ -0,0 +1,88 @@
export const createOpenCodeAuthStateRuntime = (dependencies) => {
const {
crypto,
process,
getAuthPassword,
setAuthPassword,
getAuthSource,
setAuthSource,
getUserProvidedPassword,
syncToHmrState,
} = dependencies;
const normalizeOpenCodePassword = (value) => {
if (typeof value !== 'string') {
return '';
}
return value.trim();
};
const isValidOpenCodePassword = (password) => typeof password === 'string' && password.trim().length > 0;
const generateSecureOpenCodePassword = () =>
crypto
.randomBytes(32)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
const setOpenCodeAuthState = (password, source) => {
const normalized = normalizeOpenCodePassword(password);
if (!isValidOpenCodePassword(normalized)) {
setAuthPassword(null);
setAuthSource(null);
delete process.env.OPENCODE_SERVER_PASSWORD;
syncToHmrState();
return null;
}
setAuthPassword(normalized);
setAuthSource(source);
process.env.OPENCODE_SERVER_PASSWORD = normalized;
syncToHmrState();
return normalized;
};
const getOpenCodeAuthHeaders = () => {
const password = normalizeOpenCodePassword(getAuthPassword() || process.env.OPENCODE_SERVER_PASSWORD || '');
if (!password) {
return {};
}
const credentials = Buffer.from(`opencode:${password}`).toString('base64');
return { Authorization: `Basic ${credentials}` };
};
const isOpenCodeConnectionSecure = () => Object.prototype.hasOwnProperty.call(getOpenCodeAuthHeaders(), 'Authorization');
const ensureLocalOpenCodeServerPassword = async ({ rotateManaged = false } = {}) => {
const userProvidedPassword = getUserProvidedPassword();
if (isValidOpenCodePassword(userProvidedPassword)) {
return setOpenCodeAuthState(userProvidedPassword, 'user-env');
}
if (rotateManaged) {
const rotatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'rotated');
console.log('Rotated secure password for managed local OpenCode instance');
return rotatedPassword;
}
const currentPassword = getAuthPassword();
const currentSource = getAuthSource();
if (isValidOpenCodePassword(currentPassword)) {
return setOpenCodeAuthState(currentPassword, currentSource || 'generated');
}
const generatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'generated');
console.log('Generated secure password for managed local OpenCode instance');
return generatedPassword;
};
return {
getOpenCodeAuthHeaders,
isOpenCodeConnectionSecure,
ensureLocalOpenCodeServerPassword,
};
};
+119
View File
@@ -0,0 +1,119 @@
export const createBootstrapRuntime = (dependencies) => {
const {
createUiAuth,
registerServerStatusRoutes,
registerCommonRequestMiddleware,
registerAuthAndAccessRoutes,
registerTtsRoutes,
registerNotificationRoutes,
registerOpenChamberRoutes,
express,
} = dependencies;
const setupBaseRoutes = (app, options) => {
const {
process,
openchamberVersion,
runtimeName,
serverStartedAt,
gracefulShutdown,
getHealthSnapshot,
uiPassword,
tunnelAuthController,
readSettingsFromDiskMigrated,
normalizeTunnelSessionTtlMs,
resolveZenModel,
sayTTSCapability,
ensurePushInitialized,
getOrCreateVapidKeys,
getUiSessionTokenFromRequest,
writeSettingsToDisk,
addOrUpdatePushSubscription,
removePushSubscription,
updateUiVisibility,
isUiVisible,
sessionRuntime,
setPushInitialized,
fs,
os,
path,
server,
__dirname,
openchamberDataDir,
modelsDevApiUrl,
modelsMetadataCacheTtl,
fetchFreeZenModels,
getCachedZenModels,
} = options;
registerServerStatusRoutes(app, {
process,
openchamberVersion,
runtimeName,
serverStartedAt,
gracefulShutdown,
getHealthSnapshot,
});
registerCommonRequestMiddleware(app, { express });
const uiAuthController = createUiAuth({ password: uiPassword });
if (uiAuthController.enabled) {
console.log('UI password protection enabled for browser sessions');
}
registerAuthAndAccessRoutes(app, {
tunnelAuthController,
uiAuthController,
readSettingsFromDiskMigrated,
normalizeTunnelSessionTtlMs,
});
registerTtsRoutes(app, { resolveZenModel, sayTTSCapability });
registerNotificationRoutes(app, {
uiAuthController,
ensurePushInitialized,
getOrCreateVapidKeys,
getUiSessionTokenFromRequest,
readSettingsFromDiskMigrated,
writeSettingsToDisk,
addOrUpdatePushSubscription,
removePushSubscription,
updateUiVisibility,
isUiVisible,
getSessionActivitySnapshot: sessionRuntime.getSessionActivitySnapshot,
getSessionStateSnapshot: sessionRuntime.getSessionStateSnapshot,
getSessionAttentionSnapshot: sessionRuntime.getSessionAttentionSnapshot,
getSessionState: sessionRuntime.getSessionState,
getSessionAttentionState: sessionRuntime.getSessionAttentionState,
markSessionViewed: sessionRuntime.markSessionViewed,
markSessionUnviewed: sessionRuntime.markSessionUnviewed,
markUserMessageSent: sessionRuntime.markUserMessageSent,
setPushInitialized,
});
registerOpenChamberRoutes(app, {
fs,
os,
path,
process,
server,
__dirname,
openchamberDataDir,
modelsDevApiUrl,
modelsMetadataCacheTtl,
readSettingsFromDiskMigrated,
fetchFreeZenModels,
getCachedZenModels,
});
return {
uiAuthController,
};
};
return {
setupBaseRoutes,
};
};
@@ -0,0 +1,43 @@
export const runCliEntryIfMain = (dependencies) => {
const {
process,
currentFilename,
parseServeCliOptions,
defaultPort,
cloudflareProvider,
managedLocalMode,
setExitOnShutdown,
startServer,
} = dependencies;
const isCliExecution = process.argv[1] === currentFilename;
if (!isCliExecution) {
return;
}
const cliOptions = parseServeCliOptions({
argv: process.argv.slice(2),
env: process.env,
defaultPort,
cloudflareProvider,
managedLocalMode,
});
setExitOnShutdown(true);
startServer({
port: cliOptions.port,
host: cliOptions.host,
tryCfTunnel: cliOptions.tryCfTunnel,
tunnelProvider: cliOptions.tunnelProvider,
tunnelMode: cliOptions.tunnelMode,
tunnelConfigPath: cliOptions.tunnelConfigPath,
tunnelToken: cliOptions.tunnelToken,
tunnelHostname: cliOptions.tunnelHostname,
attachSignals: true,
exitOnShutdown: true,
uiPassword: cliOptions.uiPassword,
}).catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});
};
@@ -0,0 +1,128 @@
export const parseServeCliOptions = ({
argv = [],
env = {},
defaultPort,
cloudflareProvider,
managedLocalMode,
}) => {
const args = Array.isArray(argv) ? [...argv] : [];
const envPassword =
env.OPENCHAMBER_UI_PASSWORD ||
env.OPENCODE_UI_PASSWORD ||
null;
const envCfTunnel = env.OPENCHAMBER_TRY_CF_TUNNEL === 'true';
const envTunnelProvider = env.OPENCHAMBER_TUNNEL_PROVIDER || undefined;
const envTunnelMode = env.OPENCHAMBER_TUNNEL_MODE || undefined;
const envTunnelConfigRaw = env.OPENCHAMBER_TUNNEL_CONFIG;
const envTunnelConfig = typeof envTunnelConfigRaw === 'string'
? (envTunnelConfigRaw.trim().length > 0 ? envTunnelConfigRaw.trim() : null)
: undefined;
const envTunnelToken = env.OPENCHAMBER_TUNNEL_TOKEN || undefined;
const envTunnelHostname = env.OPENCHAMBER_TUNNEL_HOSTNAME || undefined;
const options = {
port: defaultPort,
host: undefined,
uiPassword: envPassword,
tryCfTunnel: envCfTunnel,
tunnelProvider: envTunnelProvider,
tunnelMode: envTunnelMode,
tunnelConfigPath: envTunnelConfig,
tunnelToken: envTunnelToken,
tunnelHostname: envTunnelHostname,
};
const consumeValue = (currentIndex, inlineValue) => {
if (typeof inlineValue === 'string') {
return { value: inlineValue, nextIndex: currentIndex };
}
const nextArg = args[currentIndex + 1];
if (typeof nextArg === 'string' && !nextArg.startsWith('--')) {
return { value: nextArg, nextIndex: currentIndex + 1 };
}
return { value: undefined, nextIndex: currentIndex };
};
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (!arg.startsWith('--')) {
continue;
}
const eqIndex = arg.indexOf('=');
const optionName = eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2);
const inlineValue = eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined;
if (optionName === 'port' || optionName === 'p') {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
const parsedPort = parseInt(value ?? '', 10);
options.port = Number.isFinite(parsedPort) ? parsedPort : defaultPort;
continue;
}
if (optionName === 'host') {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.host = typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
continue;
}
if (optionName === 'ui-password') {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.uiPassword = typeof value === 'string' ? value : '';
continue;
}
if (optionName === 'try-cf-tunnel') {
options.tryCfTunnel = true;
continue;
}
if (optionName === 'tunnel-provider') {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.tunnelProvider = typeof value === 'string' ? value : options.tunnelProvider;
continue;
}
if (optionName === 'tunnel-mode') {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.tunnelMode = typeof value === 'string' ? value : options.tunnelMode;
continue;
}
if (optionName === 'tunnel-config') {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.tunnelConfigPath = typeof value === 'string' ? value : null;
continue;
}
if (optionName === 'tunnel-token') {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.tunnelToken = typeof value === 'string' ? value : options.tunnelToken;
continue;
}
if (optionName === 'tunnel-hostname') {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.tunnelHostname = typeof value === 'string' ? value : options.tunnelHostname;
continue;
}
if (optionName === 'tunnel') {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.tunnelProvider = cloudflareProvider;
options.tunnelMode = managedLocalMode;
options.tunnelConfigPath = typeof value === 'string' ? value : null;
}
}
return options;
};
@@ -0,0 +1,362 @@
export const registerConfigEntityRoutes = (app, dependencies) => {
const {
resolveProjectDirectory,
resolveOptionalProjectDirectory,
refreshOpenCodeAfterConfigChange,
clientReloadDelayMs,
getAgentSources,
getAgentConfig,
createAgent,
updateAgent,
deleteAgent,
getCommandSources,
createCommand,
updateCommand,
deleteCommand,
listMcpConfigs,
getMcpConfig,
createMcpConfig,
updateMcpConfig,
deleteMcpConfig,
} = dependencies;
app.get('/api/config/agents/:name', async (req, res) => {
try {
const agentName = req.params.name;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
const sources = getAgentSources(agentName, directory);
const scope = sources.md.exists
? sources.md.scope
: (sources.json.exists ? sources.json.scope : null);
res.json({
name: agentName,
sources: sources,
scope,
isBuiltIn: !sources.md.exists && !sources.json.exists
});
} catch (error) {
console.error('Failed to get agent sources:', error);
res.status(500).json({ error: 'Failed to get agent configuration metadata' });
}
});
app.get('/api/config/agents/:name/config', async (req, res) => {
try {
const agentName = req.params.name;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
const configInfo = getAgentConfig(agentName, directory);
res.json(configInfo);
} catch (error) {
console.error('Failed to get agent config:', error);
res.status(500).json({ error: 'Failed to get agent configuration' });
}
});
app.post('/api/config/agents/:name', async (req, res) => {
try {
const agentName = req.params.name;
const { scope, ...config } = req.body;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
console.log('[Server] Creating agent:', agentName);
console.log('[Server] Config received:', JSON.stringify(config, null, 2));
console.log('[Server] Scope:', scope, 'Working directory:', directory);
createAgent(agentName, config, directory, scope);
await refreshOpenCodeAfterConfigChange('agent creation', {
agentName
});
res.json({
success: true,
requiresReload: true,
message: `Agent ${agentName} created successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
} catch (error) {
console.error('Failed to create agent:', error);
res.status(500).json({ error: error.message || 'Failed to create agent' });
}
});
app.patch('/api/config/agents/:name', async (req, res) => {
try {
const agentName = req.params.name;
const updates = req.body;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
console.log(`[Server] Updating agent: ${agentName}`);
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
console.log('[Server] Working directory:', directory);
updateAgent(agentName, updates, directory);
await refreshOpenCodeAfterConfigChange('agent update');
console.log(`[Server] Agent ${agentName} updated successfully`);
res.json({
success: true,
requiresReload: true,
message: `Agent ${agentName} updated successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
} catch (error) {
console.error('[Server] Failed to update agent:', error);
console.error('[Server] Error stack:', error.stack);
res.status(500).json({ error: error.message || 'Failed to update agent' });
}
});
app.delete('/api/config/agents/:name', async (req, res) => {
try {
const agentName = req.params.name;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
deleteAgent(agentName, directory);
await refreshOpenCodeAfterConfigChange('agent deletion');
res.json({
success: true,
requiresReload: true,
message: `Agent ${agentName} deleted successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
} catch (error) {
console.error('Failed to delete agent:', error);
res.status(500).json({ error: error.message || 'Failed to delete agent' });
}
});
app.get('/api/config/mcp', async (req, res) => {
try {
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
const configs = listMcpConfigs(directory);
res.json(configs);
} catch (error) {
console.error('[API:GET /api/config/mcp] Failed:', error);
res.status(500).json({ error: error.message || 'Failed to list MCP configs' });
}
});
app.get('/api/config/mcp/:name', async (req, res) => {
try {
const name = req.params.name;
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
const config = getMcpConfig(name, directory);
if (!config) {
return res.status(404).json({ error: `MCP server "${name}" not found` });
}
res.json(config);
} catch (error) {
console.error('[API:GET /api/config/mcp/:name] Failed:', error);
res.status(500).json({ error: error.message || 'Failed to get MCP config' });
}
});
app.post('/api/config/mcp/:name', async (req, res) => {
try {
const name = req.params.name;
const { scope, ...config } = req.body || {};
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
console.log(`[API:POST /api/config/mcp] Creating MCP server: ${name}`);
createMcpConfig(name, config, directory, scope);
await refreshOpenCodeAfterConfigChange('mcp creation', { mcpName: name });
res.json({
success: true,
requiresReload: true,
message: `MCP server "${name}" created. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
} catch (error) {
console.error('[API:POST /api/config/mcp/:name] Failed:', error);
res.status(500).json({ error: error.message || 'Failed to create MCP server' });
}
});
app.patch('/api/config/mcp/:name', async (req, res) => {
try {
const name = req.params.name;
const updates = req.body;
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
console.log(`[API:PATCH /api/config/mcp] Updating MCP server: ${name}`);
updateMcpConfig(name, updates, directory);
await refreshOpenCodeAfterConfigChange('mcp update');
res.json({
success: true,
requiresReload: true,
message: `MCP server "${name}" updated. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
} catch (error) {
console.error('[API:PATCH /api/config/mcp/:name] Failed:', error);
res.status(500).json({ error: error.message || 'Failed to update MCP server' });
}
});
app.delete('/api/config/mcp/:name', async (req, res) => {
try {
const name = req.params.name;
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
console.log(`[API:DELETE /api/config/mcp] Deleting MCP server: ${name}`);
deleteMcpConfig(name, directory);
await refreshOpenCodeAfterConfigChange('mcp deletion');
res.json({
success: true,
requiresReload: true,
message: `MCP server "${name}" deleted. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
} catch (error) {
console.error('[API:DELETE /api/config/mcp/:name] Failed:', error);
res.status(500).json({ error: error.message || 'Failed to delete MCP server' });
}
});
app.get('/api/config/commands/:name', async (req, res) => {
try {
const commandName = req.params.name;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
const sources = getCommandSources(commandName, directory);
const scope = sources.md.exists
? sources.md.scope
: (sources.json.exists ? sources.json.scope : null);
res.json({
name: commandName,
sources: sources,
scope,
isBuiltIn: !sources.md.exists && !sources.json.exists
});
} catch (error) {
console.error('Failed to get command sources:', error);
res.status(500).json({ error: 'Failed to get command configuration metadata' });
}
});
app.post('/api/config/commands/:name', async (req, res) => {
try {
const commandName = req.params.name;
const { scope, ...config } = req.body;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
console.log('[Server] Creating command:', commandName);
console.log('[Server] Config received:', JSON.stringify(config, null, 2));
console.log('[Server] Scope:', scope, 'Working directory:', directory);
createCommand(commandName, config, directory, scope);
await refreshOpenCodeAfterConfigChange('command creation', {
commandName
});
res.json({
success: true,
requiresReload: true,
message: `Command ${commandName} created successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
} catch (error) {
console.error('Failed to create command:', error);
res.status(500).json({ error: error.message || 'Failed to create command' });
}
});
app.patch('/api/config/commands/:name', async (req, res) => {
try {
const commandName = req.params.name;
const updates = req.body;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
console.log(`[Server] Updating command: ${commandName}`);
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
console.log('[Server] Working directory:', directory);
updateCommand(commandName, updates, directory);
await refreshOpenCodeAfterConfigChange('command update');
console.log(`[Server] Command ${commandName} updated successfully`);
res.json({
success: true,
requiresReload: true,
message: `Command ${commandName} updated successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
} catch (error) {
console.error('[Server] Failed to update command:', error);
console.error('[Server] Error stack:', error.stack);
res.status(500).json({ error: error.message || 'Failed to update command' });
}
});
app.delete('/api/config/commands/:name', async (req, res) => {
try {
const commandName = req.params.name;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
deleteCommand(commandName, directory);
await refreshOpenCodeAfterConfigChange('command deletion');
res.json({
success: true,
requiresReload: true,
message: `Command ${commandName} deleted successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
} catch (error) {
console.error('Failed to delete command:', error);
res.status(500).json({ error: error.message || 'Failed to delete command' });
}
});
};
@@ -0,0 +1,186 @@
export const registerServerStatusRoutes = (app, dependencies) => {
const {
process,
openchamberVersion,
runtimeName,
serverStartedAt,
gracefulShutdown,
getHealthSnapshot,
} = dependencies;
app.get('/health', (_req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
...getHealthSnapshot(),
});
});
app.post('/api/system/shutdown', (_req, res) => {
res.json({ ok: true });
gracefulShutdown({ exitProcess: false }).catch((error) => {
console.error('Shutdown request failed:', error?.message || error);
});
});
app.get('/api/system/info', (_req, res) => {
res.json({
openchamberVersion,
runtime: runtimeName,
pid: process.pid,
startedAt: serverStartedAt,
});
});
};
export const registerAuthAndAccessRoutes = (app, dependencies) => {
const {
tunnelAuthController,
uiAuthController,
readSettingsFromDiskMigrated,
normalizeTunnelSessionTtlMs,
} = dependencies;
app.get('/auth/session', async (req, res) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
const tunnelSession = tunnelAuthController.getTunnelSessionFromRequest(req);
if (tunnelSession) {
return res.json({ authenticated: true, scope: 'tunnel' });
}
tunnelAuthController.clearTunnelSessionCookie(req, res);
return res.status(401).json({ authenticated: false, locked: true, tunnelLocked: true });
}
try {
await uiAuthController.handleSessionStatus(req, res);
} catch {
res.status(500).json({ error: 'Internal server error' });
}
});
app.post('/auth/session', (req, res) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return res.status(403).json({ error: 'Password login is disabled for tunnel scope', tunnelLocked: true });
}
return uiAuthController.handleSessionCreate(req, res);
});
app.get('/connect', async (req, res) => {
try {
const token = typeof req.query?.t === 'string' ? req.query.t : '';
const settings = await readSettingsFromDiskMigrated();
const tunnelSessionTtlMs = normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs);
const exchange = tunnelAuthController.exchangeBootstrapToken({
req,
res,
token,
sessionTtlMs: tunnelSessionTtlMs,
});
res.setHeader('Cache-Control', 'no-store');
if (!exchange.ok) {
if (exchange.reason === 'rate-limited') {
res.setHeader('Retry-After', String(exchange.retryAfter || 60));
return res.status(429).type('text/plain').send('Too many attempts. Please try again later.');
}
return res.status(401).type('text/plain').send('Connection link is invalid or expired.');
}
return res.redirect(302, '/');
} catch {
return res.status(500).type('text/plain').send('Failed to process connect request.');
}
});
app.use('/api', async (req, res, next) => {
try {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return tunnelAuthController.requireTunnelSession(req, res, next);
}
await uiAuthController.requireAuth(req, res, next);
} catch (err) {
next(err);
}
});
};
export const registerSettingsUtilityRoutes = (app, dependencies) => {
const {
readCustomThemesFromDisk,
refreshOpenCodeAfterConfigChange,
clientReloadDelayMs,
} = dependencies;
app.get('/api/config/themes', async (_req, res) => {
try {
const customThemes = await readCustomThemesFromDisk();
res.json({ themes: customThemes });
} catch (error) {
console.error('Failed to load custom themes:', error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load custom themes' });
}
});
app.post('/api/config/reload', async (_req, res) => {
try {
console.log('[Server] Manual configuration reload requested');
await refreshOpenCodeAfterConfigChange('manual configuration reload');
res.json({
success: true,
requiresReload: true,
message: 'Configuration reloaded successfully. Refreshing interface…',
reloadDelayMs: clientReloadDelayMs,
});
} catch (error) {
console.error('[Server] Failed to reload configuration:', error);
res.status(500).json({
error: error.message || 'Failed to reload configuration',
success: false,
});
}
});
};
export const registerCommonRequestMiddleware = (app, dependencies) => {
const { express } = dependencies;
app.use((req, res, next) => {
if (
req.path.startsWith('/api/config/agents') ||
req.path.startsWith('/api/config/commands') ||
req.path.startsWith('/api/config/mcp') ||
req.path.startsWith('/api/config/settings') ||
req.path.startsWith('/api/config/skills') ||
req.path.startsWith('/api/projects') ||
req.path.startsWith('/api/fs') ||
req.path.startsWith('/api/git') ||
req.path.startsWith('/api/prompts') ||
req.path.startsWith('/api/terminal') ||
req.path.startsWith('/api/opencode') ||
req.path.startsWith('/api/push') ||
req.path.startsWith('/api/voice') ||
req.path.startsWith('/api/tts') ||
req.path.startsWith('/api/openchamber/tunnel')
) {
express.json({ limit: '50mb' })(req, res, next);
} else if (req.path.startsWith('/api')) {
next();
} else {
express.json({ limit: '50mb' })(req, res, next);
}
});
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
app.use((req, _res, next) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
next();
});
};
@@ -0,0 +1,72 @@
export const resolveOpenCodeEnvConfig = (options = {}) => {
const env = options.env && typeof options.env === 'object' ? options.env : {};
const logger = options.logger ?? console;
const configuredOpenCodePort = (() => {
const raw =
env.OPENCODE_PORT ||
env.OPENCHAMBER_OPENCODE_PORT ||
env.OPENCHAMBER_INTERNAL_PORT;
if (!raw) {
return null;
}
const parsed = parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
})();
const configuredOpenCodeHost = (() => {
const raw = typeof env.OPENCODE_HOST === 'string' ? env.OPENCODE_HOST.trim() : '';
if (!raw) return null;
const warnInvalidHost = (reason) => {
logger.warn(`[config] Ignoring OPENCODE_HOST=${JSON.stringify(raw)}: ${reason}`);
};
let url;
try {
url = new URL(raw);
} catch {
warnInvalidHost('not a valid URL');
return null;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
warnInvalidHost(`must use http or https scheme (got ${JSON.stringify(url.protocol)})`);
return null;
}
const port = parseInt(url.port, 10);
if (!Number.isFinite(port) || port <= 0) {
warnInvalidHost('must include an explicit port (example: http://hostname:4096)');
return null;
}
if (url.pathname !== '/' || url.search || url.hash) {
warnInvalidHost('must not include path, query, or hash');
return null;
}
return { origin: url.origin, port };
})();
// OPENCODE_HOST takes precedence over OPENCODE_PORT when both are set
const effectivePort = configuredOpenCodeHost?.port ?? configuredOpenCodePort;
const configuredOpenCodeHostname = (() => {
const raw = env.OPENCHAMBER_OPENCODE_HOSTNAME;
if (typeof raw !== 'string') {
return '127.0.0.1';
}
const trimmed = raw.trim();
if (!trimmed) {
logger.warn(
`[config] Ignoring OPENCHAMBER_OPENCODE_HOSTNAME=${JSON.stringify(raw)}: empty after trimming`,
);
return '127.0.0.1';
}
return trimmed;
})();
return {
configuredOpenCodePort,
configuredOpenCodeHost,
effectivePort,
configuredOpenCodeHostname,
};
};
@@ -0,0 +1,908 @@
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
export const createOpenCodeEnvRuntime = (deps) => {
const {
state,
normalizeDirectoryPath,
readSettingsFromDiskMigrated,
ENV_CONFIGURED_OPENCODE_WSL_DISTRO,
} = deps;
const parseNullSeparatedEnvSnapshot = (raw) => {
if (typeof raw !== 'string' || raw.length === 0) {
return null;
}
const result = {};
const entries = raw.split('\0');
for (const entry of entries) {
if (!entry) {
continue;
}
const idx = entry.indexOf('=');
if (idx <= 0) {
continue;
}
const key = entry.slice(0, idx);
const value = entry.slice(idx + 1);
result[key] = value;
}
return Object.keys(result).length > 0 ? result : null;
};
const isExecutable = (filePath) => {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) return false;
if (process.platform === 'win32') {
const ext = path.extname(filePath).toLowerCase();
if (!ext) return true;
return ['.exe', '.cmd', '.bat', '.com'].includes(ext);
}
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
};
const searchPathFor = (binaryName) => {
const current = process.env.PATH || '';
const parts = current.split(path.delimiter).filter(Boolean);
for (const dir of parts) {
const candidate = path.join(dir, binaryName);
if (isExecutable(candidate)) {
return candidate;
}
}
return null;
};
const prependToPath = (dir) => {
const trimmed = typeof dir === 'string' ? dir.trim() : '';
if (!trimmed) return;
const current = process.env.PATH || '';
const parts = current.split(path.delimiter).filter(Boolean);
if (parts.includes(trimmed)) return;
process.env.PATH = [trimmed, ...parts].join(path.delimiter);
};
const getWindowsShellEnvSnapshot = () => {
const parseResult = (stdout) => parseNullSeparatedEnvSnapshot(typeof stdout === 'string' ? stdout : '');
const psScript =
"Get-ChildItem Env: | ForEach-Object { [Console]::Out.Write($_.Name); [Console]::Out.Write('='); [Console]::Out.Write($_.Value); [Console]::Out.Write([char]0) }";
const powershellCandidates = [
'pwsh.exe',
'powershell.exe',
path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
];
for (const shellPath of powershellCandidates) {
try {
const result = spawnSync(shellPath, ['-NoLogo', '-Command', psScript], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 10 * 1024 * 1024,
windowsHide: true,
});
if (result.status !== 0) {
continue;
}
const parsed = parseResult(result.stdout);
if (parsed) {
return parsed;
}
} catch {
}
}
const comspec = process.env.ComSpec || 'cmd.exe';
try {
const result = spawnSync(comspec, ['/d', '/s', '/c', 'set'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 10 * 1024 * 1024,
windowsHide: true,
});
if (result.status === 0 && typeof result.stdout === 'string' && result.stdout.length > 0) {
return parseNullSeparatedEnvSnapshot(result.stdout.replace(/\r?\n/g, '\0'));
}
} catch {
}
return null;
};
const getLoginShellEnvSnapshot = () => {
if (state.cachedLoginShellEnvSnapshot !== undefined) {
return state.cachedLoginShellEnvSnapshot;
}
if (process.platform === 'win32') {
const windowsSnapshot = getWindowsShellEnvSnapshot();
state.cachedLoginShellEnvSnapshot = windowsSnapshot;
return windowsSnapshot;
}
const shellCandidates = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean);
for (const shellPath of shellCandidates) {
if (!isExecutable(shellPath)) {
continue;
}
try {
const result = spawnSync(shellPath, ['-lic', 'env -0'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 10 * 1024 * 1024,
windowsHide: true,
});
if (result.status !== 0) {
continue;
}
const parsed = parseNullSeparatedEnvSnapshot(result.stdout || '');
if (parsed) {
state.cachedLoginShellEnvSnapshot = parsed;
return parsed;
}
} catch {
}
}
state.cachedLoginShellEnvSnapshot = null;
return null;
};
const mergePathValues = (preferred, fallback) => {
const merged = new Set();
const addSegments = (value) => {
if (typeof value !== 'string' || !value) {
return;
}
for (const segment of value.split(path.delimiter)) {
if (segment) {
merged.add(segment);
}
}
};
addSegments(preferred);
addSegments(fallback);
return Array.from(merged).join(path.delimiter);
};
const applyLoginShellEnvSnapshot = () => {
const snapshot = getLoginShellEnvSnapshot();
if (!snapshot) {
return;
}
const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_']);
for (const [key, value] of Object.entries(snapshot)) {
if (skipKeys.has(key)) {
continue;
}
const existing = process.env[key];
if (typeof existing === 'string' && existing.length > 0) {
continue;
}
process.env[key] = value;
}
process.env.PATH = mergePathValues(snapshot.PATH || '', process.env.PATH || '');
};
const isWslExecutableValue = (value) => {
if (typeof value !== 'string') return false;
const trimmed = value.trim();
if (!trimmed) return false;
return /(^|[\\/])wsl(\.exe)?$/i.test(trimmed);
};
const clearWslOpencodeResolution = () => {
state.useWslForOpencode = false;
state.resolvedWslBinary = null;
state.resolvedWslOpencodePath = null;
state.resolvedWslDistro = null;
};
const resolveWslExecutablePath = () => {
if (process.platform !== 'win32') {
return null;
}
const explicit = [process.env.WSL_BINARY, process.env.OPENCHAMBER_WSL_BINARY]
.map((v) => (typeof v === 'string' ? v.trim() : ''))
.filter(Boolean);
for (const candidate of explicit) {
if (isExecutable(candidate)) {
return candidate;
}
}
try {
const result = spawnSync('where', ['wsl'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status === 0) {
const lines = (result.stdout || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const found = lines.find((line) => isExecutable(line));
if (found) {
return found;
}
}
} catch {
}
const systemRoot = process.env.SystemRoot || 'C:\\Windows';
const fallback = path.join(systemRoot, 'System32', 'wsl.exe');
if (isExecutable(fallback)) {
return fallback;
}
return null;
};
const buildWslExecArgs = (execArgs, distroOverride = null) => {
const distro = typeof distroOverride === 'string' && distroOverride.trim().length > 0
? distroOverride.trim()
: ENV_CONFIGURED_OPENCODE_WSL_DISTRO;
const prefix = distro ? ['-d', distro] : [];
return [...prefix, '--exec', ...execArgs];
};
const probeWslForOpencode = () => {
if (process.platform !== 'win32') {
return null;
}
const wslBinary = resolveWslExecutablePath();
if (!wslBinary) {
return null;
}
try {
const result = spawnSync(
wslBinary,
buildWslExecArgs(['sh', '-lc', 'command -v opencode']),
{
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 6000,
windowsHide: true,
}
);
if (result.status !== 0) {
return null;
}
const lines = (result.stdout || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const found = lines[0] || '';
if (!found) {
return null;
}
return {
wslBinary,
opencodePath: found,
distro: ENV_CONFIGURED_OPENCODE_WSL_DISTRO,
};
} catch {
return null;
}
};
const applyWslOpencodeResolution = ({ wslBinary, opencodePath, source = 'wsl', distro = null } = {}) => {
const resolvedWsl = wslBinary || resolveWslExecutablePath();
if (!resolvedWsl) {
return null;
}
state.useWslForOpencode = true;
state.resolvedWslBinary = resolvedWsl;
state.resolvedWslOpencodePath = typeof opencodePath === 'string' && opencodePath.trim().length > 0
? opencodePath.trim()
: 'opencode';
state.resolvedWslDistro = typeof distro === 'string' && distro.trim().length > 0 ? distro.trim() : ENV_CONFIGURED_OPENCODE_WSL_DISTRO;
state.resolvedOpencodeBinary = `wsl:${state.resolvedWslOpencodePath}`;
state.resolvedOpencodeBinarySource = source;
delete process.env.OPENCODE_BINARY;
return state.resolvedOpencodeBinary;
};
const resolveOpencodeCliPath = () => {
const explicit = [
process.env.OPENCODE_BINARY,
process.env.OPENCODE_PATH,
process.env.OPENCHAMBER_OPENCODE_PATH,
process.env.OPENCHAMBER_OPENCODE_BIN,
]
.map((v) => (typeof v === 'string' ? v.trim() : ''))
.filter(Boolean);
for (const candidate of explicit) {
if (isExecutable(candidate)) {
clearWslOpencodeResolution();
state.resolvedOpencodeBinarySource = 'env';
return candidate;
}
}
const resolvedFromPath = searchPathFor('opencode');
if (resolvedFromPath) {
clearWslOpencodeResolution();
state.resolvedOpencodeBinarySource = 'path';
return resolvedFromPath;
}
const home = os.homedir();
const unixFallbacks = [
path.join(home, '.opencode', 'bin', 'opencode'),
path.join(home, '.bun', 'bin', 'opencode'),
path.join(home, '.local', 'bin', 'opencode'),
path.join(home, 'bin', 'opencode'),
'/opt/homebrew/bin/opencode',
'/usr/local/bin/opencode',
'/usr/bin/opencode',
'/bin/opencode',
];
const winFallbacks = (() => {
const userProfile = process.env.USERPROFILE || home;
const appData = process.env.APPDATA || '';
const localAppData = process.env.LOCALAPPDATA || '';
const programData = process.env.ProgramData || 'C:\\ProgramData';
return [
path.join(userProfile, '.opencode', 'bin', 'opencode.exe'),
path.join(userProfile, '.opencode', 'bin', 'opencode.cmd'),
path.join(appData, 'npm', 'opencode.cmd'),
path.join(userProfile, 'scoop', 'shims', 'opencode.cmd'),
path.join(programData, 'chocolatey', 'bin', 'opencode.exe'),
path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'),
path.join(userProfile, '.bun', 'bin', 'opencode.exe'),
path.join(userProfile, '.bun', 'bin', 'opencode.cmd'),
localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '',
].filter(Boolean);
})();
const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks;
for (const candidate of fallbacks) {
if (isExecutable(candidate)) {
clearWslOpencodeResolution();
state.resolvedOpencodeBinarySource = 'fallback';
return candidate;
}
}
if (process.platform === 'win32') {
try {
const result = spawnSync('where', ['opencode'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status === 0) {
const lines = (result.stdout || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const found = lines.find((line) => isExecutable(line));
if (found) {
clearWslOpencodeResolution();
state.resolvedOpencodeBinarySource = 'where';
return found;
}
}
} catch {
}
const wsl = probeWslForOpencode();
if (wsl) {
return applyWslOpencodeResolution({
wslBinary: wsl.wslBinary,
opencodePath: wsl.opencodePath,
source: 'wsl',
distro: wsl.distro,
});
}
return null;
}
const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean);
for (const shell of shells) {
if (!isExecutable(shell)) continue;
try {
const result = spawnSync(shell, ['-lic', 'command -v opencode'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status === 0) {
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
if (found && isExecutable(found)) {
clearWslOpencodeResolution();
state.resolvedOpencodeBinarySource = 'shell';
return found;
}
}
} catch {
}
}
return null;
};
const resolveNodeCliPath = () => {
const explicit = [process.env.NODE_BINARY, process.env.OPENCHAMBER_NODE_BINARY]
.map((v) => (typeof v === 'string' ? v.trim() : ''))
.filter(Boolean);
for (const candidate of explicit) {
if (isExecutable(candidate)) {
return candidate;
}
}
const resolvedFromPath = searchPathFor('node');
if (resolvedFromPath) {
return resolvedFromPath;
}
const unixFallbacks = ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node', '/bin/node'];
for (const candidate of unixFallbacks) {
if (isExecutable(candidate)) {
return candidate;
}
}
if (process.platform === 'win32') {
try {
const result = spawnSync('where', ['node'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status === 0) {
const lines = (result.stdout || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const found = lines.find((line) => isExecutable(line));
if (found) return found;
}
} catch {
}
return null;
}
const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean);
for (const shell of shells) {
if (!isExecutable(shell)) continue;
try {
const result = spawnSync(shell, ['-lic', 'command -v node'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status === 0) {
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
if (found && isExecutable(found)) {
return found;
}
}
} catch {
}
}
return null;
};
const resolveBunCliPath = () => {
const explicit = [process.env.BUN_BINARY, process.env.OPENCHAMBER_BUN_BINARY]
.map((v) => (typeof v === 'string' ? v.trim() : ''))
.filter(Boolean);
for (const candidate of explicit) {
if (isExecutable(candidate)) {
return candidate;
}
}
const resolvedFromPath = searchPathFor('bun');
if (resolvedFromPath) {
return resolvedFromPath;
}
const home = os.homedir();
const unixFallbacks = [
path.join(home, '.bun', 'bin', 'bun'),
'/opt/homebrew/bin/bun',
'/usr/local/bin/bun',
'/usr/bin/bun',
'/bin/bun',
];
for (const candidate of unixFallbacks) {
if (isExecutable(candidate)) {
return candidate;
}
}
if (process.platform === 'win32') {
const userProfile = process.env.USERPROFILE || home;
const winFallbacks = [
path.join(userProfile, '.bun', 'bin', 'bun.exe'),
path.join(userProfile, '.bun', 'bin', 'bun.cmd'),
];
for (const candidate of winFallbacks) {
if (isExecutable(candidate)) return candidate;
}
try {
const result = spawnSync('where', ['bun'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status === 0) {
const lines = (result.stdout || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const found = lines.find((line) => isExecutable(line));
if (found) return found;
}
} catch {
}
return null;
}
const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean);
for (const shell of shells) {
if (!isExecutable(shell)) continue;
try {
const result = spawnSync(shell, ['-lic', 'command -v bun'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status === 0) {
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
if (found && isExecutable(found)) {
return found;
}
}
} catch {
}
}
return null;
};
const ensureBunCliEnv = () => {
if (state.resolvedBunBinary) {
return state.resolvedBunBinary;
}
const resolved = resolveBunCliPath();
if (resolved) {
prependToPath(path.dirname(resolved));
state.resolvedBunBinary = resolved;
return resolved;
}
return null;
};
const ensureNodeCliEnv = () => {
if (state.resolvedNodeBinary) {
return state.resolvedNodeBinary;
}
const resolved = resolveNodeCliPath();
if (resolved) {
prependToPath(path.dirname(resolved));
state.resolvedNodeBinary = resolved;
return resolved;
}
return null;
};
const readShebang = (opencodePath) => {
if (!opencodePath || typeof opencodePath !== 'string') {
return null;
}
try {
const fd = fs.openSync(opencodePath, 'r');
try {
const buf = Buffer.alloc(256);
const bytes = fs.readSync(fd, buf, 0, buf.length, 0);
const head = buf.subarray(0, bytes).toString('utf8');
const firstLine = head.split(/\r?\n/, 1)[0] || '';
if (!firstLine.startsWith('#!')) {
return null;
}
const shebang = firstLine.slice(2).trim();
if (!shebang) {
return null;
}
return shebang;
} finally {
try {
fs.closeSync(fd);
} catch {
}
}
} catch {
return null;
}
};
const opencodeShimInterpreter = (opencodePath) => {
const shebang = readShebang(opencodePath);
if (!shebang) return null;
if (/\bnode\b/i.test(shebang)) return 'node';
if (/\bbun\b/i.test(shebang)) return 'bun';
return null;
};
const ensureOpencodeShimRuntime = (opencodePath) => {
const runtime = opencodeShimInterpreter(opencodePath);
if (runtime === 'node') {
ensureNodeCliEnv();
}
if (runtime === 'bun') {
ensureBunCliEnv();
}
};
const normalizeOpencodeBinarySetting = (raw) => {
if (typeof raw !== 'string') {
return null;
}
const trimmed = normalizeDirectoryPath(raw).trim();
if (!trimmed) {
return '';
}
try {
const stat = fs.statSync(trimmed);
if (stat.isDirectory()) {
const bin = process.platform === 'win32' ? 'opencode.exe' : 'opencode';
return path.join(trimmed, bin);
}
} catch {
}
return trimmed;
};
const applyOpencodeBinaryFromSettings = async () => {
try {
const settings = await readSettingsFromDiskMigrated();
if (!settings || typeof settings !== 'object') {
return null;
}
if (!Object.prototype.hasOwnProperty.call(settings, 'opencodeBinary')) {
return null;
}
const normalized = normalizeOpencodeBinarySetting(settings.opencodeBinary);
if (normalized === '') {
delete process.env.OPENCODE_BINARY;
state.resolvedOpencodeBinary = null;
state.resolvedOpencodeBinarySource = null;
clearWslOpencodeResolution();
return null;
}
const raw = typeof settings.opencodeBinary === 'string' ? settings.opencodeBinary.trim() : '';
const explicitWslPath = process.platform === 'win32' && typeof raw === 'string'
? raw.match(/^wsl:\s*(.+)$/i)
: null;
if (explicitWslPath && explicitWslPath[1] && explicitWslPath[1].trim().length > 0) {
const probe = probeWslForOpencode();
const applied = applyWslOpencodeResolution({
wslBinary: probe?.wslBinary || resolveWslExecutablePath(),
opencodePath: explicitWslPath[1].trim(),
source: 'settings-wsl-path',
distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO,
});
if (applied) {
return applied;
}
}
if (process.platform === 'win32' && (isWslExecutableValue(raw) || isWslExecutableValue(normalized || ''))) {
const probe = probeWslForOpencode();
const applied = applyWslOpencodeResolution({
wslBinary: probe?.wslBinary || normalized || raw || null,
opencodePath: probe?.opencodePath || 'opencode',
source: 'settings-wsl',
distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO,
});
if (applied) {
return applied;
}
}
if (normalized && isExecutable(normalized)) {
clearWslOpencodeResolution();
process.env.OPENCODE_BINARY = normalized;
prependToPath(path.dirname(normalized));
state.resolvedOpencodeBinary = normalized;
state.resolvedOpencodeBinarySource = 'settings';
ensureOpencodeShimRuntime(normalized);
return normalized;
}
if (raw) {
console.warn(`Configured settings.opencodeBinary is not executable: ${raw}`);
}
} catch {
}
return null;
};
const ensureOpencodeCliEnv = () => {
if (state.resolvedOpencodeBinary) {
if (state.useWslForOpencode) {
return state.resolvedOpencodeBinary;
}
ensureOpencodeShimRuntime(state.resolvedOpencodeBinary);
return state.resolvedOpencodeBinary;
}
const existing = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : '';
if (existing && isExecutable(existing)) {
clearWslOpencodeResolution();
state.resolvedOpencodeBinary = existing;
state.resolvedOpencodeBinarySource = state.resolvedOpencodeBinarySource || 'env';
prependToPath(path.dirname(existing));
ensureOpencodeShimRuntime(existing);
return state.resolvedOpencodeBinary;
}
const resolved = resolveOpencodeCliPath();
if (resolved) {
if (state.useWslForOpencode) {
state.resolvedOpencodeBinary = resolved;
state.resolvedOpencodeBinarySource = state.resolvedOpencodeBinarySource || 'wsl';
console.log(`Resolved opencode CLI via WSL: ${state.resolvedWslOpencodePath || 'opencode'}`);
return resolved;
}
process.env.OPENCODE_BINARY = resolved;
prependToPath(path.dirname(resolved));
ensureOpencodeShimRuntime(resolved);
state.resolvedOpencodeBinary = resolved;
state.resolvedOpencodeBinarySource = state.resolvedOpencodeBinarySource || 'unknown';
console.log(`Resolved opencode CLI: ${resolved}`);
return resolved;
}
clearWslOpencodeResolution();
return null;
};
const resolveGitBinaryForSpawn = () => {
if (process.platform !== 'win32') {
return 'git';
}
if (state.resolvedGitBinary) {
return state.resolvedGitBinary;
}
const explicit = [process.env.GIT_BINARY, process.env.OPENCHAMBER_GIT_BINARY]
.map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter(Boolean);
for (const candidate of explicit) {
if (isExecutable(candidate)) {
state.resolvedGitBinary = candidate;
return state.resolvedGitBinary;
}
}
const candidates = [];
const normalizeGitCandidate = (candidate) => {
if (typeof candidate !== 'string') {
return '';
}
const trimmed = candidate.trim();
if (!trimmed) {
return '';
}
const ext = path.extname(trimmed).toLowerCase();
if (ext === '.cmd' || ext === '.bat' || ext === '.com') {
const exeCandidate = trimmed.slice(0, -ext.length) + '.exe';
if (isExecutable(exeCandidate)) {
return exeCandidate;
}
}
return trimmed;
};
const pathCandidate = normalizeGitCandidate(searchPathFor('git'));
if (pathCandidate && isExecutable(pathCandidate)) {
candidates.push(pathCandidate);
}
const pathExeCandidate = normalizeGitCandidate(searchPathFor('git.exe'));
if (pathExeCandidate && isExecutable(pathExeCandidate)) {
candidates.push(pathExeCandidate);
}
const programRoots = [
process.env.ProgramFiles,
process.env['ProgramFiles(x86)'],
process.env.LocalAppData,
]
.map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter(Boolean);
for (const root of programRoots) {
const installCandidates = [
path.join(root, 'Git', 'cmd', 'git.exe'),
path.join(root, 'Git', 'bin', 'git.exe'),
path.join(root, 'Git', 'mingw64', 'bin', 'git.exe'),
path.join(root, 'Programs', 'Git', 'cmd', 'git.exe'),
path.join(root, 'Programs', 'Git', 'bin', 'git.exe'),
];
for (const candidate of installCandidates) {
const normalized = normalizeGitCandidate(candidate);
if (normalized && isExecutable(normalized)) {
candidates.push(normalized);
}
}
}
const preferredExe = candidates.find((candidate) => candidate.toLowerCase().endsWith('.exe'));
state.resolvedGitBinary = preferredExe || candidates[0] || 'git.exe';
return state.resolvedGitBinary;
};
const clearResolvedOpenCodeBinary = () => {
state.resolvedOpencodeBinary = null;
};
return {
applyLoginShellEnvSnapshot,
ensureOpencodeCliEnv,
applyOpencodeBinaryFromSettings,
getLoginShellEnvSnapshot,
resolveOpencodeCliPath,
isExecutable,
searchPathFor,
resolveGitBinaryForSpawn,
resolveWslExecutablePath,
buildWslExecArgs,
opencodeShimInterpreter,
clearResolvedOpenCodeBinary,
};
};
@@ -0,0 +1,216 @@
import { registerFsRoutes } from '../fs/routes.js';
import { registerQuotaRoutes } from '../quota/routes.js';
import { registerGitHubRoutes } from '../github/routes.js';
import { registerGitRoutes } from '../git/routes.js';
import { registerConfigEntityRoutes } from './config-entity-routes.js';
import { registerSettingsUtilityRoutes } from './core-routes.js';
import { registerProjectIconRoutes } from './project-icon-routes.js';
import { registerSkillRoutes } from './skill-routes.js';
import { registerOpenCodeRoutes } from './routes.js';
export const createFeatureRoutesRuntime = (dependencies) => {
const {
clientReloadDelayMs,
} = dependencies;
let quotaProviders = null;
const getQuotaProviders = async () => {
if (!quotaProviders) {
quotaProviders = await import('../quota/index.js');
}
return quotaProviders;
};
const registerRoutes = async (app, routeDependencies) => {
const {
crypto,
fs,
os,
path,
fsPromises,
spawn,
resolveGitBinaryForSpawn,
createFsSearchRuntime,
openchamberDataDir,
openchamberUserConfigRoot,
normalizeDirectoryPath,
resolveProjectDirectory,
resolveOptionalProjectDirectory,
validateDirectoryPath,
readCustomThemesFromDisk,
refreshOpenCodeAfterConfigChange,
getOpenCodeResolutionSnapshot,
formatSettingsResponse,
readSettingsFromDisk,
readSettingsFromDiskMigrated,
persistSettings,
sanitizeProjects,
sanitizeSkillCatalogs,
isUnsafeSkillRelativePath,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
getOpenCodePort,
buildAugmentedPath,
} = routeDependencies;
const { getProviderSources, removeProviderConfig } = await import('./index.js');
registerSettingsUtilityRoutes(app, {
readCustomThemesFromDisk,
refreshOpenCodeAfterConfigChange,
clientReloadDelayMs,
});
registerOpenCodeRoutes(app, {
crypto,
clientReloadDelayMs,
getOpenCodeResolutionSnapshot,
formatSettingsResponse,
readSettingsFromDisk,
readSettingsFromDiskMigrated,
persistSettings,
sanitizeProjects,
validateDirectoryPath,
resolveProjectDirectory,
getProviderSources,
removeProviderConfig,
refreshOpenCodeAfterConfigChange,
});
registerProjectIconRoutes(app, {
fsPromises,
path,
crypto,
openchamberDataDir,
sanitizeProjects,
readSettingsFromDiskMigrated,
persistSettings,
createFsSearchRuntime,
spawn,
resolveGitBinaryForSpawn,
});
const {
getAgentSources,
getAgentConfig,
createAgent,
updateAgent,
deleteAgent,
getCommandSources,
createCommand,
updateCommand,
deleteCommand,
listMcpConfigs,
getMcpConfig,
createMcpConfig,
updateMcpConfig,
deleteMcpConfig,
} = await import('./index.js');
registerConfigEntityRoutes(app, {
resolveProjectDirectory,
resolveOptionalProjectDirectory,
refreshOpenCodeAfterConfigChange,
clientReloadDelayMs,
getAgentSources,
getAgentConfig,
createAgent,
updateAgent,
deleteAgent,
getCommandSources,
createCommand,
updateCommand,
deleteCommand,
listMcpConfigs,
getMcpConfig,
createMcpConfig,
updateMcpConfig,
deleteMcpConfig,
});
const {
getSkillSources,
discoverSkills,
createSkill,
updateSkill,
deleteSkill,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
SKILL_SCOPE,
SKILL_DIR,
} = await import('./index.js');
const {
getCuratedSkillsSources,
getCacheKey,
getCachedScan,
setCachedScan,
parseSkillRepoSource,
scanSkillsRepository,
installSkillsFromRepository,
scanClawdHubPage,
installSkillsFromClawdHub,
isClawdHubSource,
} = await import('../skills-catalog/index.js');
const { getProfiles, getProfile } = await import('../git/index.js');
registerSkillRoutes(app, {
fs,
path,
os,
resolveProjectDirectory,
resolveOptionalProjectDirectory,
readSettingsFromDisk,
sanitizeSkillCatalogs,
isUnsafeSkillRelativePath,
refreshOpenCodeAfterConfigChange,
clientReloadDelayMs,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
getOpenCodePort,
getSkillSources,
discoverSkills,
createSkill,
updateSkill,
deleteSkill,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
SKILL_SCOPE,
SKILL_DIR,
getCuratedSkillsSources,
getCacheKey,
getCachedScan,
setCachedScan,
parseSkillRepoSource,
scanSkillsRepository,
installSkillsFromRepository,
scanClawdHubPage,
installSkillsFromClawdHub,
isClawdHubSource,
getProfiles,
getProfile,
});
registerQuotaRoutes(app, { getQuotaProviders });
registerGitHubRoutes(app);
registerGitRoutes(app);
registerFsRoutes(app, {
os,
path,
fsPromises,
spawn,
crypto,
normalizeDirectoryPath,
resolveProjectDirectory,
buildAugmentedPath,
resolveGitBinaryForSpawn,
openchamberUserConfigRoot,
});
};
return {
registerRoutes,
};
};
@@ -0,0 +1,85 @@
export const createHmrStateRuntime = (dependencies) => {
const {
globalThisLike,
os,
processLike,
stateKey,
} = dependencies;
const getOrCreateHmrState = () => {
if (!globalThisLike[stateKey]) {
globalThisLike[stateKey] = {
openCodeProcess: null,
openCodePort: null,
openCodeWorkingDirectory: os.homedir(),
isShuttingDown: false,
signalsAttached: false,
userProvidedOpenCodePassword: undefined,
openCodeAuthPassword: null,
openCodeAuthSource: null,
};
}
return globalThisLike[stateKey];
};
const ensureUserProvidedOpenCodePassword = (hmrState) => {
if (typeof hmrState.userProvidedOpenCodePassword !== 'undefined') {
return;
}
const initialPassword = typeof processLike.env.OPENCODE_SERVER_PASSWORD === 'string'
? processLike.env.OPENCODE_SERVER_PASSWORD.trim()
: '';
hmrState.userProvidedOpenCodePassword = initialPassword || null;
};
const getUserProvidedOpenCodePassword = (hmrState) => (
typeof hmrState.userProvidedOpenCodePassword === 'string' && hmrState.userProvidedOpenCodePassword.length > 0
? hmrState.userProvidedOpenCodePassword
: null
);
const resolveOpenCodeAuthFromState = ({ hmrState, userProvidedOpenCodePassword }) => ({
openCodeAuthPassword:
typeof hmrState.openCodeAuthPassword === 'string' && hmrState.openCodeAuthPassword.length > 0
? hmrState.openCodeAuthPassword
: userProvidedOpenCodePassword,
openCodeAuthSource:
typeof hmrState.openCodeAuthSource === 'string' && hmrState.openCodeAuthSource.length > 0
? hmrState.openCodeAuthSource
: (userProvidedOpenCodePassword ? 'user-env' : null),
});
const syncStateFromRuntime = (hmrState, runtime) => {
hmrState.openCodeProcess = runtime.openCodeProcess;
hmrState.openCodePort = runtime.openCodePort;
hmrState.openCodeBaseUrl = runtime.openCodeBaseUrl;
hmrState.isShuttingDown = runtime.isShuttingDown;
hmrState.signalsAttached = runtime.signalsAttached;
hmrState.openCodeWorkingDirectory = runtime.openCodeWorkingDirectory;
hmrState.openCodeAuthPassword = runtime.openCodeAuthPassword;
hmrState.openCodeAuthSource = runtime.openCodeAuthSource;
};
const restoreRuntimeFromState = ({ hmrState, userProvidedOpenCodePassword }) => {
const auth = resolveOpenCodeAuthFromState({ hmrState, userProvidedOpenCodePassword });
return {
openCodeProcess: hmrState.openCodeProcess,
openCodePort: hmrState.openCodePort,
openCodeBaseUrl: hmrState.openCodeBaseUrl ?? null,
isShuttingDown: hmrState.isShuttingDown,
signalsAttached: hmrState.signalsAttached,
openCodeWorkingDirectory: hmrState.openCodeWorkingDirectory,
openCodeAuthPassword: auth.openCodeAuthPassword,
openCodeAuthSource: auth.openCodeAuthSource,
};
};
return {
getOrCreateHmrState,
ensureUserProvidedOpenCodePassword,
getUserProvidedOpenCodePassword,
resolveOpenCodeAuthFromState,
syncStateFromRuntime,
restoreRuntimeFromState,
};
};
@@ -0,0 +1,630 @@
import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
export const createOpenCodeLifecycleRuntime = (deps) => {
const {
state,
env,
syncToHmrState,
syncFromHmrState,
getOpenCodeAuthHeaders,
buildOpenCodeUrl,
waitForReady,
normalizeApiPrefix,
applyOpencodeBinaryFromSettings,
ensureOpencodeCliEnv,
ensureLocalOpenCodeServerPassword,
buildWslExecArgs,
resolveWslExecutablePath,
opencodeShimInterpreter,
setOpenCodePort,
setDetectedOpenCodeApiPrefix,
setupProxy,
ensureOpenCodeApiPrefix,
clearResolvedOpenCodeBinary,
} = deps;
const killProcessOnPort = (port) => {
if (!port) return;
try {
const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000, windowsHide: true });
const output = result.stdout || '';
const myPid = process.pid;
for (const pidStr of output.split(/\s+/)) {
const pid = parseInt(pidStr.trim(), 10);
if (pid && pid !== myPid) {
try {
spawnSync('kill', ['-9', String(pid)], { stdio: 'ignore', timeout: 2000 });
} catch {
}
}
}
} catch {
}
};
const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv }) => {
let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
let args = ['serve', '--hostname', hostname, '--port', String(port)];
if (process.platform === 'win32' && state.useWslForOpencode) {
const wslBinary = state.resolvedWslBinary || resolveWslExecutablePath();
if (!wslBinary) {
throw new Error('WSL executable not found while attempting to launch OpenCode from WSL');
}
const wslOpencode = state.resolvedWslOpencodePath && state.resolvedWslOpencodePath.trim().length > 0
? state.resolvedWslOpencodePath.trim()
: 'opencode';
const serveHost = hostname === '127.0.0.1' ? '0.0.0.0' : hostname;
binary = wslBinary;
args = buildWslExecArgs([
wslOpencode,
'serve',
'--hostname',
serveHost,
'--port',
String(port),
], state.resolvedWslDistro);
}
if (process.platform === 'win32' && !state.useWslForOpencode) {
const interpreter = opencodeShimInterpreter(binary);
if (interpreter) {
args.unshift(binary);
binary = interpreter;
} else {
try {
const shimContent = fs.readFileSync(binary, 'utf8');
const jsMatch = shimContent.match(/node_modules[\\/]opencode[^\s"']*/);
if (jsMatch) {
const candidate = path.resolve(path.dirname(binary), jsMatch[0]);
if (fs.existsSync(candidate)) {
const realInterp = opencodeShimInterpreter(candidate);
if (realInterp) {
args.unshift(candidate);
binary = realInterp;
}
}
}
} catch {
}
}
}
const child = spawn(binary, args, {
cwd,
env: processEnv,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
const url = await new Promise((resolve, reject) => {
let output = '';
let done = false;
const finish = (handler, value) => {
if (done) return;
done = true;
clearTimeout(timer);
child.stdout?.off('data', onStdout);
child.stderr?.off('data', onStderr);
child.off('exit', onExit);
child.off('error', onError);
handler(value);
};
const onStdout = (chunk) => {
output += chunk.toString();
const lines = output.split('\n');
for (const line of lines) {
if (!line.startsWith('opencode server listening')) continue;
const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
if (!match) {
finish(reject, new Error(`Failed to parse server url from output: ${line}`));
return;
}
finish(resolve, match[1]);
return;
}
};
const onStderr = (chunk) => {
output += chunk.toString();
};
const onExit = (code) => {
finish(reject, new Error(`OpenCode exited with code ${code}. Output: ${output}`));
};
const onError = (error) => {
finish(reject, error);
};
const timer = setTimeout(() => {
finish(reject, new Error(`Timeout waiting for OpenCode to start after ${timeout}ms`));
}, timeout);
child.stdout?.on('data', onStdout);
child.stderr?.on('data', onStderr);
child.on('exit', onExit);
child.on('error', onError);
});
return {
url,
close() {
try {
child.kill('SIGTERM');
} catch {
}
},
};
};
const resolveManagedOpenCodePort = async (requestedPort, hostname = '127.0.0.1') => {
if (typeof requestedPort === 'number' && Number.isFinite(requestedPort) && requestedPort > 0) {
return requestedPort;
}
return await new Promise((resolve, reject) => {
const server = net.createServer();
const cleanup = () => {
server.removeAllListeners('error');
server.removeAllListeners('listening');
};
server.once('error', (error) => {
cleanup();
reject(error);
});
server.once('listening', () => {
const address = server.address();
const port = address && typeof address === 'object' ? address.port : 0;
server.close(() => {
cleanup();
if (port > 0) {
resolve(port);
return;
}
reject(new Error('Failed to allocate OpenCode port'));
});
});
server.listen(0, hostname);
});
};
const isOpenCodeProcessHealthy = async () => {
if (!state.openCodeProcess || !state.openCodePort) {
return false;
}
try {
const response = await fetch(`http://127.0.0.1:${state.openCodePort}/session`, {
method: 'GET',
headers: getOpenCodeAuthHeaders(),
signal: AbortSignal.timeout(2000),
});
return response.ok;
} catch {
return false;
}
};
const probeExternalOpenCode = async (port, origin) => {
if (!port || port <= 0) {
return false;
}
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const base = origin ?? `http://127.0.0.1:${port}`;
const response = await fetch(`${base}/global/health`, {
method: 'GET',
headers: {
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) return false;
const body = await response.json().catch(() => null);
return body?.healthy === true;
} catch {
return false;
}
};
const waitForOpenCodePort = async (timeoutMs = 15000) => {
if (state.openCodePort !== null) {
return state.openCodePort;
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 50));
if (state.openCodePort !== null) {
return state.openCodePort;
}
}
throw new Error('Timed out waiting for OpenCode port');
};
const startOpenCode = async () => {
const desiredPort = env.ENV_CONFIGURED_OPENCODE_PORT ?? 0;
const spawnPort = await resolveManagedOpenCodePort(desiredPort, env.ENV_CONFIGURED_OPENCODE_HOSTNAME);
console.log(
desiredPort > 0
? `Starting OpenCode on requested port ${desiredPort}...`
: `Starting OpenCode on allocated port ${spawnPort}...`
);
await applyOpencodeBinaryFromSettings();
ensureOpencodeCliEnv();
const openCodePassword = await ensureLocalOpenCodeServerPassword({ rotateManaged: true });
try {
const serverInstance = await createManagedOpenCodeServerProcess({
hostname: env.ENV_CONFIGURED_OPENCODE_HOSTNAME,
port: spawnPort,
timeout: 30000,
cwd: state.openCodeWorkingDirectory,
env: {
...process.env,
OPENCODE_SERVER_PASSWORD: openCodePassword,
},
});
if (!serverInstance || !serverInstance.url) {
throw new Error('OpenCode server started but URL is missing');
}
const url = new URL(serverInstance.url);
const port = parseInt(url.port, 10);
const prefix = normalizeApiPrefix(url.pathname);
if (await waitForReady(serverInstance.url, 10000)) {
setOpenCodePort(port);
setDetectedOpenCodeApiPrefix(prefix);
state.isOpenCodeReady = true;
state.lastOpenCodeError = null;
state.openCodeNotReadySince = 0;
return serverInstance;
}
try {
serverInstance.close();
} catch {
}
throw new Error('Server started but health check failed (timeout)');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
state.lastOpenCodeError = message;
state.openCodePort = null;
syncToHmrState();
console.error(`Failed to start OpenCode: ${message}`);
throw error;
}
};
const restartOpenCode = async () => {
if (state.isShuttingDown) return;
if (state.currentRestartPromise) {
await state.currentRestartPromise;
return;
}
state.currentRestartPromise = (async () => {
state.isRestartingOpenCode = true;
state.isOpenCodeReady = false;
state.openCodeNotReadySince = Date.now();
console.log('Restarting OpenCode process...');
if (state.isExternalOpenCode) {
console.log('Re-probing external OpenCode server...');
const probePort = state.openCodePort || env.ENV_CONFIGURED_OPENCODE_PORT || 4096;
const probeOrigin = state.openCodeBaseUrl ?? env.ENV_CONFIGURED_OPENCODE_HOST?.origin;
const healthy = await probeExternalOpenCode(probePort, probeOrigin);
if (healthy) {
console.log(`External OpenCode server on port ${probePort} is healthy`);
setOpenCodePort(probePort);
state.isOpenCodeReady = true;
state.lastOpenCodeError = null;
state.openCodeNotReadySince = 0;
syncToHmrState();
} else {
state.lastOpenCodeError = `External OpenCode server on port ${probePort} is not responding`;
console.error(state.lastOpenCodeError);
throw new Error(state.lastOpenCodeError);
}
if (state.expressApp) {
setupProxy(state.expressApp);
ensureOpenCodeApiPrefix();
}
return;
}
const portToKill = state.openCodePort;
if (state.openCodeProcess) {
console.log('Stopping existing OpenCode process...');
try {
state.openCodeProcess.close();
} catch (error) {
console.warn('Error closing OpenCode process:', error);
}
state.openCodeProcess = null;
syncToHmrState();
}
killProcessOnPort(portToKill);
await new Promise((resolve) => setTimeout(resolve, 250));
if (env.ENV_CONFIGURED_OPENCODE_PORT) {
console.log(`Using OpenCode port from environment: ${env.ENV_CONFIGURED_OPENCODE_PORT}`);
setOpenCodePort(env.ENV_CONFIGURED_OPENCODE_PORT);
} else {
state.openCodePort = null;
syncToHmrState();
}
state.openCodeApiPrefixDetected = true;
state.openCodeApiPrefix = '';
if (state.openCodeApiDetectionTimer) {
clearTimeout(state.openCodeApiDetectionTimer);
state.openCodeApiDetectionTimer = null;
}
state.lastOpenCodeError = null;
state.openCodeProcess = await startOpenCode();
syncToHmrState();
if (state.expressApp) {
setupProxy(state.expressApp);
ensureOpenCodeApiPrefix();
}
})();
try {
await state.currentRestartPromise;
} catch (error) {
console.error(`Failed to restart OpenCode: ${error.message}`);
state.lastOpenCodeError = error.message;
if (!env.ENV_CONFIGURED_OPENCODE_PORT) {
state.openCodePort = null;
syncToHmrState();
}
state.openCodeApiPrefixDetected = true;
state.openCodeApiPrefix = '';
throw error;
} finally {
state.currentRestartPromise = null;
state.isRestartingOpenCode = false;
}
};
const waitForOpenCodeReady = async (timeoutMs = 20000, intervalMs = 400) => {
if (!state.openCodePort) {
throw new Error('OpenCode port is not available');
}
const deadline = Date.now() + timeoutMs;
let lastError = null;
while (Date.now() < deadline) {
try {
const [configResult, agentResult] = await Promise.all([
fetch(buildOpenCodeUrl('/config', ''), {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
}).catch((error) => error),
fetch(buildOpenCodeUrl('/agent', ''), {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
}).catch((error) => error),
]);
if (configResult instanceof Error) {
lastError = configResult;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
continue;
}
if (!configResult.ok) {
lastError = new Error(`OpenCode config endpoint responded with status ${configResult.status}`);
await new Promise((resolve) => setTimeout(resolve, intervalMs));
continue;
}
await configResult.json().catch(() => null);
if (agentResult instanceof Error) {
lastError = agentResult;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
continue;
}
if (!agentResult.ok) {
lastError = new Error(`Agent endpoint responded with status ${agentResult.status}`);
await new Promise((resolve) => setTimeout(resolve, intervalMs));
continue;
}
await agentResult.json().catch(() => []);
state.isOpenCodeReady = true;
state.lastOpenCodeError = null;
return;
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
if (lastError) {
state.lastOpenCodeError = lastError.message || String(lastError);
throw lastError;
}
const timeoutError = new Error('Timed out waiting for OpenCode to become ready');
state.lastOpenCodeError = timeoutError.message;
throw timeoutError;
};
const waitForAgentPresence = async (agentName, timeoutMs = 15000, intervalMs = 300) => {
if (!state.openCodePort) {
throw new Error('OpenCode port is not available');
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(buildOpenCodeUrl('/agent'), {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
});
if (response.ok) {
const agents = await response.json();
if (Array.isArray(agents) && agents.some((agent) => agent?.name === agentName)) {
return;
}
}
} catch {
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`Agent "${agentName}" not available after OpenCode restart`);
};
const refreshOpenCodeAfterConfigChange = async (reason, options = {}) => {
const { agentName } = options;
console.log(`Refreshing OpenCode after ${reason}`);
clearResolvedOpenCodeBinary();
await applyOpencodeBinaryFromSettings();
await restartOpenCode();
try {
await waitForOpenCodeReady();
state.isOpenCodeReady = true;
state.openCodeNotReadySince = 0;
if (agentName) {
await waitForAgentPresence(agentName);
}
state.isOpenCodeReady = true;
state.openCodeNotReadySince = 0;
} catch (error) {
state.isOpenCodeReady = false;
state.openCodeNotReadySince = Date.now();
console.error(`Failed to refresh OpenCode after ${reason}:`, error.message);
throw error;
}
};
const bootstrapOpenCodeAtStartup = async () => {
try {
syncFromHmrState();
if (await isOpenCodeProcessHealthy()) {
console.log(`[HMR] Reusing existing OpenCode process on port ${state.openCodePort}`);
} else if (env.ENV_SKIP_OPENCODE_START && env.ENV_EFFECTIVE_PORT) {
const label = env.ENV_CONFIGURED_OPENCODE_HOST ? env.ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${env.ENV_EFFECTIVE_PORT}`;
console.log(`Using external OpenCode server at ${label} (skip-start mode)`);
state.openCodeBaseUrl = env.ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null;
setOpenCodePort(env.ENV_EFFECTIVE_PORT);
state.isOpenCodeReady = true;
state.isExternalOpenCode = true;
state.lastOpenCodeError = null;
state.openCodeNotReadySince = 0;
syncToHmrState();
} else if (env.ENV_EFFECTIVE_PORT && await probeExternalOpenCode(env.ENV_EFFECTIVE_PORT, env.ENV_CONFIGURED_OPENCODE_HOST?.origin)) {
const label = env.ENV_CONFIGURED_OPENCODE_HOST ? env.ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${env.ENV_EFFECTIVE_PORT}`;
console.log(`Auto-detected existing OpenCode server at ${label}`);
state.openCodeBaseUrl = env.ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null;
setOpenCodePort(env.ENV_EFFECTIVE_PORT);
state.isOpenCodeReady = true;
state.isExternalOpenCode = true;
state.lastOpenCodeError = null;
state.openCodeNotReadySince = 0;
syncToHmrState();
} else if (!env.ENV_EFFECTIVE_PORT && await probeExternalOpenCode(4096)) {
console.log('Auto-detected existing OpenCode server on default port 4096');
setOpenCodePort(4096);
state.isOpenCodeReady = true;
state.isExternalOpenCode = true;
state.lastOpenCodeError = null;
state.openCodeNotReadySince = 0;
syncToHmrState();
} else {
if (env.ENV_EFFECTIVE_PORT) {
console.log(`Using OpenCode port from environment: ${env.ENV_EFFECTIVE_PORT}`);
setOpenCodePort(env.ENV_EFFECTIVE_PORT);
} else {
state.openCodePort = null;
syncToHmrState();
}
state.lastOpenCodeError = null;
state.openCodeProcess = await startOpenCode();
syncToHmrState();
}
await waitForOpenCodePort();
try {
await waitForOpenCodeReady();
} catch (error) {
console.error(`OpenCode readiness check failed: ${error.message}`);
}
} catch (error) {
console.error(`Failed to start OpenCode: ${error.message}`);
console.log('Continuing without OpenCode integration...');
state.lastOpenCodeError = error.message;
}
};
const startHealthMonitoring = (healthCheckIntervalMs) => {
if (state.healthCheckInterval) {
clearInterval(state.healthCheckInterval);
}
state.healthCheckInterval = setInterval(async () => {
if (!state.openCodeProcess || state.isShuttingDown || state.isRestartingOpenCode) return;
try {
const healthy = await isOpenCodeProcessHealthy();
if (!healthy) {
console.log('OpenCode process not running, restarting...');
await restartOpenCode();
}
} catch (error) {
console.error(`Health check error: ${error.message}`);
}
}, healthCheckIntervalMs);
};
return {
killProcessOnPort,
startOpenCode,
restartOpenCode,
waitForOpenCodeReady,
waitForAgentPresence,
refreshOpenCodeAfterConfigChange,
bootstrapOpenCodeAtStartup,
startHealthMonitoring,
};
};
@@ -0,0 +1,98 @@
export const createOpenCodeNetworkRuntime = (deps) => {
const {
state,
getOpenCodeAuthHeaders,
} = deps;
const normalizeApiPrefix = (prefix) => {
if (!prefix) {
return '';
}
if (prefix.includes('://')) {
try {
const parsed = new URL(prefix);
return normalizeApiPrefix(parsed.pathname);
} catch {
return '';
}
}
const trimmed = prefix.trim();
if (!trimmed || trimmed === '/') {
return '';
}
const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
return withLeading.endsWith('/') ? withLeading.slice(0, -1) : withLeading;
};
const waitForReady = async (url, timeoutMs = 10000) => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`${url.replace(/\/+$/, '')}/global/health`, {
method: 'GET',
headers: {
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
signal: controller.signal,
});
clearTimeout(timeout);
if (response.ok) {
const body = await response.json().catch(() => null);
if (body?.healthy === true) {
return true;
}
}
} catch {
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
return false;
};
const setDetectedOpenCodeApiPrefix = () => {
state.openCodeApiPrefix = '';
state.openCodeApiPrefixDetected = true;
if (state.openCodeApiDetectionTimer) {
clearTimeout(state.openCodeApiDetectionTimer);
state.openCodeApiDetectionTimer = null;
}
};
const buildOpenCodeUrl = (path, prefixOverride) => {
if (!state.openCodePort) {
throw new Error('OpenCode port is not available');
}
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : '');
const fullPath = `${prefix}${normalizedPath}`;
const base = state.openCodeBaseUrl ?? `http://localhost:${state.openCodePort}`;
return `${base}${fullPath}`;
};
const detectOpenCodeApiPrefix = () => {
state.openCodeApiPrefixDetected = true;
state.openCodeApiPrefix = '';
return true;
};
const ensureOpenCodeApiPrefix = () => detectOpenCodeApiPrefix();
const scheduleOpenCodeApiDetection = () => {
return;
};
return {
waitForReady,
normalizeApiPrefix,
setDetectedOpenCodeApiPrefix,
buildOpenCodeUrl,
ensureOpenCodeApiPrefix,
scheduleOpenCodeApiDetection,
};
};
@@ -0,0 +1,284 @@
export const registerOpenChamberRoutes = (app, dependencies) => {
const {
fs,
os,
path,
process,
server,
__dirname,
openchamberDataDir,
modelsDevApiUrl,
modelsMetadataCacheTtl,
readSettingsFromDiskMigrated,
fetchFreeZenModels,
getCachedZenModels,
} = dependencies;
let cachedModelsMetadata = null;
let cachedModelsMetadataTimestamp = 0;
app.get('/api/openchamber/update-check', async (req, res) => {
try {
const { checkForUpdates } = await import('../package-manager.js');
const parseString = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined);
const parseReportUsage = (value) => {
if (typeof value !== 'string') return true;
const normalized = value.trim().toLowerCase();
if (normalized === 'false' || normalized === '0' || normalized === 'no') return false;
return true;
};
const inferDeviceClass = (ua) => {
const value = (ua || '').toLowerCase();
if (!value) return 'unknown';
if (value.includes('ipad') || value.includes('tablet')) return 'tablet';
if (value.includes('mobi') || value.includes('android') || value.includes('iphone')) return 'mobile';
return 'desktop';
};
const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : '';
const updateInfo = await checkForUpdates({
appType: parseString(req.query.appType),
deviceClass: parseString(req.query.deviceClass) || inferDeviceClass(userAgent),
platform: parseString(req.query.platform),
arch: parseString(req.query.arch),
instanceMode: parseString(req.query.instanceMode),
currentVersion: parseString(req.query.currentVersion),
reportUsage: parseReportUsage(parseString(req.query.reportUsage)),
});
res.json(updateInfo);
} catch (error) {
console.error('Failed to check for updates:', error);
res.status(500).json({
available: false,
error: error instanceof Error ? error.message : 'Failed to check for updates',
});
}
});
app.post('/api/openchamber/update-install', async (_req, res) => {
try {
const { spawn: spawnChild } = await import('child_process');
const {
checkForUpdates,
getUpdateCommand,
detectPackageManager,
} = await import('../package-manager.js');
const updateInfo = await checkForUpdates();
if (!updateInfo.available) {
return res.status(400).json({ error: 'No update available' });
}
const pm = detectPackageManager();
const updateCmd = getUpdateCommand(pm);
const isContainer =
fs.existsSync('/.dockerenv') ||
Boolean(process.env.CONTAINER) ||
process.env.container === 'docker';
if (isContainer) {
res.json({
success: true,
message: 'Update starting, server will stay online',
version: updateInfo.version,
packageManager: pm,
autoRestart: false,
});
setTimeout(() => {
console.log(`\nInstalling update using ${pm} (container mode)...`);
console.log(`Running: ${updateCmd}`);
const shell = process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : 'sh';
const shellFlag = process.platform === 'win32' ? '/c' : '-c';
const child = spawnChild(shell, [shellFlag, updateCmd], {
detached: true,
stdio: 'ignore',
env: process.env,
});
child.unref();
}, 500);
return;
}
const currentPort = server.address()?.port || 3000;
const tmpDir = os.tmpdir();
const instanceFilePath = path.join(tmpDir, `openchamber-${currentPort}.json`);
let storedOptions = { port: currentPort, daemon: true };
try {
const content = await fs.promises.readFile(instanceFilePath, 'utf8');
storedOptions = JSON.parse(content);
} catch {
}
const isWindows = process.platform === 'win32';
const quotePosix = (value) => `'${String(value).replace(/'/g, "'\\''")}'`;
const quoteCmd = (value) => {
const stringValue = String(value);
return `"${stringValue.replace(/"/g, '""')}"`;
};
const cliPath = path.resolve(__dirname, '..', '..', 'bin', 'cli.js');
const restartParts = [
isWindows ? quoteCmd(process.execPath) : quotePosix(process.execPath),
isWindows ? quoteCmd(cliPath) : quotePosix(cliPath),
'serve',
'--port',
String(storedOptions.port),
'--daemon',
];
let restartCmdPrimary = restartParts.join(' ');
let restartCmdFallback = `openchamber serve --port ${storedOptions.port} --daemon`;
if (storedOptions.uiPassword) {
if (isWindows) {
const escapedPw = storedOptions.uiPassword.replace(/"/g, '""');
restartCmdPrimary += ` --ui-password "${escapedPw}"`;
restartCmdFallback += ` --ui-password "${escapedPw}"`;
} else {
const escapedPw = storedOptions.uiPassword.replace(/'/g, "'\\''");
restartCmdPrimary += ` --ui-password '${escapedPw}'`;
restartCmdFallback += ` --ui-password '${escapedPw}'`;
}
}
const restartCmd = `(${restartCmdPrimary}) || (${restartCmdFallback})`;
res.json({
success: true,
message: 'Update starting, server will restart shortly',
version: updateInfo.version,
packageManager: pm,
autoRestart: true,
});
setTimeout(() => {
console.log(`\nInstalling update using ${pm}...`);
console.log(`Running: ${updateCmd}`);
const shell = isWindows ? (process.env.ComSpec || 'cmd.exe') : 'sh';
const shellFlag = isWindows ? '/c' : '-c';
const script = isWindows
? `
timeout /t 2 /nobreak >nul
${updateCmd}
if %ERRORLEVEL% EQU 0 (
echo Update successful, restarting OpenChamber...
${restartCmd}
) else (
echo Update failed
exit /b 1
)
`
: `
sleep 2
${updateCmd}
if [ $? -eq 0 ]; then
echo "Update successful, restarting OpenChamber..."
${restartCmd}
else
echo "Update failed"
exit 1
fi
`;
const updateLogPath = path.join(openchamberDataDir, 'update-install.log');
let logFd = null;
try {
fs.mkdirSync(path.dirname(updateLogPath), { recursive: true });
logFd = fs.openSync(updateLogPath, 'a');
} catch (logError) {
console.warn('Failed to open update log file, continuing without log capture:', logError);
}
const child = spawnChild(shell, [shellFlag, script], {
detached: true,
stdio: logFd !== null ? ['ignore', logFd, logFd] : 'ignore',
env: process.env,
});
child.unref();
if (logFd !== null) {
try {
fs.closeSync(logFd);
} catch {
}
}
console.log('Update process spawned, shutting down server...');
setTimeout(() => {
process.exit(0);
}, 500);
}, 500);
} catch (error) {
console.error('Failed to install update:', error);
res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to install update',
});
}
});
app.get('/api/openchamber/models-metadata', async (_req, res) => {
const now = Date.now();
if (cachedModelsMetadata && now - cachedModelsMetadataTimestamp < modelsMetadataCacheTtl) {
res.setHeader('Cache-Control', 'public, max-age=60');
return res.json(cachedModelsMetadata);
}
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null;
try {
const response = await fetch(modelsDevApiUrl, {
signal: controller?.signal,
headers: {
Accept: 'application/json'
}
});
if (!response.ok) {
throw new Error(`models.dev responded with status ${response.status}`);
}
const metadata = await response.json();
cachedModelsMetadata = metadata;
cachedModelsMetadataTimestamp = Date.now();
res.setHeader('Cache-Control', 'public, max-age=300');
res.json(metadata);
} catch (error) {
console.warn('Failed to fetch models.dev metadata via server:', error);
if (cachedModelsMetadata) {
res.setHeader('Cache-Control', 'public, max-age=60');
res.json(cachedModelsMetadata);
} else {
const statusCode = error?.name === 'AbortError' ? 504 : 502;
res.status(statusCode).json({ error: 'Failed to retrieve model metadata' });
}
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
});
app.get('/api/zen/models', async (_req, res) => {
try {
const models = await fetchFreeZenModels();
res.setHeader('Cache-Control', 'public, max-age=300');
res.json({ models });
} catch (error) {
console.warn('Failed to fetch zen models:', error);
const cachedZenModels = getCachedZenModels();
if (cachedZenModels) {
res.setHeader('Cache-Control', 'public, max-age=60');
res.json(cachedZenModels);
} else {
const statusCode = error?.name === 'AbortError' ? 504 : 502;
res.status(statusCode).json({ error: 'Failed to retrieve zen models' });
}
}
});
};
@@ -0,0 +1,67 @@
export const createOpenCodeResolutionRuntime = (dependencies) => {
const {
path,
resolveOpencodeCliPath,
applyOpencodeBinaryFromSettings,
ensureOpencodeCliEnv,
opencodeShimInterpreter,
getResolvedState,
setResolvedOpencodeBinarySource,
} = dependencies;
const getOpenCodeResolutionSnapshot = async (settings) => {
const configured = typeof settings?.opencodeBinary === 'string' ? settings.opencodeBinary : null;
const { resolvedOpencodeBinarySource: previousSource } = getResolvedState();
const detectedNow = resolveOpencodeCliPath();
const { resolvedOpencodeBinarySource: rawDetectedSourceNow } = getResolvedState();
setResolvedOpencodeBinarySource(previousSource);
await applyOpencodeBinaryFromSettings();
ensureOpencodeCliEnv();
const {
resolvedOpencodeBinary,
resolvedOpencodeBinarySource,
useWslForOpencode,
resolvedWslBinary,
resolvedWslOpencodePath,
resolvedWslDistro,
resolvedNodeBinary,
resolvedBunBinary,
} = getResolvedState();
const resolved = resolvedOpencodeBinary || null;
const source = resolvedOpencodeBinarySource || null;
const detectedSourceNow =
detectedNow &&
resolved &&
detectedNow === resolved &&
rawDetectedSourceNow === 'env' &&
source &&
source !== 'env'
? source
: rawDetectedSourceNow;
const shim = resolved ? opencodeShimInterpreter(resolved) : null;
return {
configured,
resolved,
resolvedDir: resolved ? path.dirname(resolved) : null,
source,
detectedNow,
detectedSourceNow,
shim,
viaWsl: useWslForOpencode,
wslBinary: resolvedWslBinary || null,
wslPath: resolvedWslOpencodePath || null,
wslDistro: resolvedWslDistro || null,
node: resolvedNodeBinary || null,
bun: resolvedBunBinary || null,
};
};
return {
getOpenCodeResolutionSnapshot,
};
};
@@ -0,0 +1,109 @@
export const createProjectDirectoryRuntime = (dependencies) => {
const {
fsPromises,
path,
normalizeDirectoryPath,
readSettingsFromDiskMigrated,
getReadSettingsFromDiskMigrated,
sanitizeProjects,
} = dependencies;
const resolveDirectoryCandidate = (value) => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const normalized = normalizeDirectoryPath(trimmed);
return path.resolve(normalized);
};
const validateDirectoryPath = async (candidate) => {
const resolved = resolveDirectoryCandidate(candidate);
if (!resolved) {
return { ok: false, error: 'Directory parameter is required' };
}
try {
const stats = await fsPromises.stat(resolved);
if (!stats.isDirectory()) {
return { ok: false, error: 'Specified path is not a directory' };
}
return { ok: true, directory: resolved };
} catch (error) {
const err = error;
if (err && typeof err === 'object' && err.code === 'ENOENT') {
return { ok: false, error: 'Directory not found' };
}
if (err && typeof err === 'object' && err.code === 'EACCES') {
return { ok: false, error: 'Access to directory denied' };
}
return { ok: false, error: 'Failed to validate directory' };
}
};
const resolveProjectDirectory = async (req) => {
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requested = headerDirectory || queryDirectory || null;
if (requested) {
const validated = await validateDirectoryPath(requested);
if (!validated.ok) {
return { directory: null, error: validated.error };
}
return { directory: validated.directory, error: null };
}
const readSettings = typeof getReadSettingsFromDiskMigrated === 'function'
? getReadSettingsFromDiskMigrated()
: readSettingsFromDiskMigrated;
const settings = await readSettings();
const projects = sanitizeProjects(settings.projects) || [];
if (projects.length === 0) {
return { directory: null, error: 'Directory parameter or active project is required' };
}
const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : '';
const active = projects.find((project) => project.id === activeId) || projects[0];
if (!active || !active.path) {
return { directory: null, error: 'Directory parameter or active project is required' };
}
const validated = await validateDirectoryPath(active.path);
if (!validated.ok) {
return { directory: null, error: validated.error };
}
return { directory: validated.directory, error: null };
};
const resolveOptionalProjectDirectory = async (req) => {
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requested = headerDirectory || queryDirectory || null;
if (!requested) {
return { directory: null, error: null };
}
const validated = await validateDirectoryPath(requested);
if (!validated.ok) {
return { directory: null, error: validated.error };
}
return { directory: validated.directory, error: null };
};
return {
resolveDirectoryCandidate,
validateDirectoryPath,
resolveProjectDirectory,
resolveOptionalProjectDirectory,
};
};
@@ -0,0 +1,397 @@
export const registerProjectIconRoutes = (app, dependencies) => {
const {
fsPromises,
path,
crypto,
openchamberDataDir,
sanitizeProjects,
readSettingsFromDiskMigrated,
persistSettings,
createFsSearchRuntime,
spawn,
resolveGitBinaryForSpawn,
} = dependencies;
const projectIconsDirPath = path.join(openchamberDataDir, 'project-icons');
const projectIconMimeToExtension = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/svg+xml': 'svg',
'image/webp': 'webp',
'image/x-icon': 'ico',
};
const projectIconExtensionToMime = Object.fromEntries(
Object.entries(projectIconMimeToExtension).map(([mime, ext]) => [ext, mime])
);
const projectIconSupportedMimes = new Set(Object.keys(projectIconMimeToExtension));
const projectIconMaxBytes = 5 * 1024 * 1024;
const projectIconThemeColors = {
light: '#111111',
dark: '#f5f5f5',
};
const projectIconHexColorPattern = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{4}|[\da-fA-F]{6}|[\da-fA-F]{8})$/;
const normalizeProjectIconMime = (value) => {
if (typeof value !== 'string') {
return null;
}
const normalized = value.trim().toLowerCase();
if (normalized === 'image/jpg') {
return 'image/jpeg';
}
if (projectIconSupportedMimes.has(normalized)) {
return normalized;
}
return null;
};
const projectIconBaseName = (projectId) => {
const hash = crypto.createHash('sha1').update(projectId).digest('hex');
return `project-${hash}`;
};
const projectIconPathForMime = (projectId, mime) => {
const normalizedMime = normalizeProjectIconMime(mime);
if (!normalizedMime) {
return null;
}
const ext = projectIconMimeToExtension[normalizedMime];
return path.join(projectIconsDirPath, `${projectIconBaseName(projectId)}.${ext}`);
};
const projectIconPathCandidates = (projectId) => {
const base = projectIconBaseName(projectId);
return Object.values(projectIconMimeToExtension).map((ext) => path.join(projectIconsDirPath, `${base}.${ext}`));
};
const removeProjectIconFiles = async (projectId, keepPath) => {
const candidates = projectIconPathCandidates(projectId);
await Promise.all(candidates.map(async (candidatePath) => {
if (keepPath && candidatePath === keepPath) {
return;
}
try {
await fsPromises.unlink(candidatePath);
} catch (error) {
if (!error || typeof error !== 'object' || error.code !== 'ENOENT') {
throw error;
}
}
}));
};
const parseProjectIconDataUrl = (value) => {
if (typeof value !== 'string') {
return { ok: false, error: 'dataUrl is required' };
}
const trimmed = value.trim();
const match = trimmed.match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/i);
if (!match) {
return { ok: false, error: 'Invalid dataUrl format' };
}
const mime = normalizeProjectIconMime(match[1]);
if (!mime || !['image/png', 'image/jpeg', 'image/svg+xml'].includes(mime)) {
return { ok: false, error: 'Icon must be PNG, JPEG, or SVG' };
}
try {
const base64 = match[2].replace(/\s+/g, '');
const bytes = Buffer.from(base64, 'base64');
if (bytes.length === 0) {
return { ok: false, error: 'Icon content is empty' };
}
if (bytes.length > projectIconMaxBytes) {
return { ok: false, error: 'Icon exceeds size limit (5 MB)' };
}
return { ok: true, mime, bytes };
} catch {
return { ok: false, error: 'Failed to decode icon data' };
}
};
const normalizeProjectIconThemeVariant = (value) => {
if (typeof value !== 'string') {
return null;
}
const normalized = value.trim().toLowerCase();
if (normalized === 'light' || normalized === 'dark') {
return normalized;
}
return null;
};
const normalizeProjectIconColor = (value) => {
if (typeof value !== 'string') {
return null;
}
const normalized = value.trim();
if (!projectIconHexColorPattern.test(normalized)) {
return null;
}
return normalized;
};
const applyProjectIconSvgTheme = (svgMarkup, themeVariant, iconColor) => {
if (typeof svgMarkup !== 'string') {
return svgMarkup;
}
const color = iconColor || projectIconThemeColors[themeVariant];
if (!color) {
return svgMarkup;
}
const svgTagIndex = svgMarkup.search(/<svg\b/i);
if (svgTagIndex === -1) {
return svgMarkup;
}
const svgOpenTagEndIndex = svgMarkup.indexOf('>', svgTagIndex);
if (svgOpenTagEndIndex === -1) {
return svgMarkup;
}
const overrideStyle = `<style data-openchamber-theme-icon="1">:root{color:${color}!important;}</style>`;
return `${svgMarkup.slice(0, svgOpenTagEndIndex + 1)}${overrideStyle}${svgMarkup.slice(svgOpenTagEndIndex + 1)}`;
};
const findProjectById = (settings, projectId) => {
const projects = sanitizeProjects(settings?.projects) || [];
const index = projects.findIndex((project) => project.id === projectId);
if (index === -1) {
return { projects, index: -1, project: null };
}
return { projects, index, project: projects[index] };
};
const fsSearchRuntime = createFsSearchRuntime({
fsPromises,
path,
spawn,
resolveGitBinaryForSpawn,
});
app.get('/api/projects/:projectId/icon', async (req, res) => {
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : '';
if (!projectId) {
return res.status(400).json({ error: 'projectId is required' });
}
try {
const settings = await readSettingsFromDiskMigrated();
const { project } = findProjectById(settings, projectId);
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
const metadataMime = normalizeProjectIconMime(project.iconImage?.mime);
const preferredPath = metadataMime ? projectIconPathForMime(projectId, metadataMime) : null;
const candidates = preferredPath
? [preferredPath, ...projectIconPathCandidates(projectId).filter((candidate) => candidate !== preferredPath)]
: projectIconPathCandidates(projectId);
const themeQuery = Array.isArray(req.query?.theme) ? req.query.theme[0] : req.query?.theme;
const requestedThemeVariant = normalizeProjectIconThemeVariant(themeQuery);
const iconColorQuery = Array.isArray(req.query?.iconColor) ? req.query.iconColor[0] : req.query?.iconColor;
const requestedIconColor = normalizeProjectIconColor(iconColorQuery);
for (const iconPath of candidates) {
try {
const data = await fsPromises.readFile(iconPath);
const ext = path.extname(iconPath).slice(1).toLowerCase();
const resolvedMime = metadataMime || projectIconExtensionToMime[ext] || 'application/octet-stream';
const contentType = resolvedMime === 'image/svg+xml' ? 'image/svg+xml; charset=utf-8' : resolvedMime;
if (resolvedMime === 'image/svg+xml' && requestedThemeVariant) {
const svgMarkup = data.toString('utf8');
const themedSvgMarkup = applyProjectIconSvgTheme(svgMarkup, requestedThemeVariant, requestedIconColor);
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
return res.send(themedSvgMarkup);
}
if (resolvedMime === 'image/svg+xml' && requestedIconColor) {
const svgMarkup = data.toString('utf8');
const themedSvgMarkup = applyProjectIconSvgTheme(svgMarkup, requestedThemeVariant, requestedIconColor);
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
return res.send(themedSvgMarkup);
}
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
return res.send(data);
} catch (error) {
if (!error || typeof error !== 'object' || error.code !== 'ENOENT') {
console.warn('Failed to read project icon:', error);
return res.status(500).json({ error: 'Failed to read project icon' });
}
}
}
return res.status(404).json({ error: 'Project icon not found' });
} catch (error) {
console.warn('Failed to load project icon:', error);
return res.status(500).json({ error: 'Failed to load project icon' });
}
});
app.put('/api/projects/:projectId/icon', async (req, res) => {
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : '';
if (!projectId) {
return res.status(400).json({ error: 'projectId is required' });
}
const parsed = parseProjectIconDataUrl(req.body?.dataUrl);
if (!parsed.ok) {
return res.status(400).json({ error: parsed.error });
}
try {
const settings = await readSettingsFromDiskMigrated();
const { projects, project } = findProjectById(settings, projectId);
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
const iconPath = projectIconPathForMime(projectId, parsed.mime);
if (!iconPath) {
return res.status(400).json({ error: 'Unsupported icon format' });
}
await fsPromises.mkdir(projectIconsDirPath, { recursive: true });
await fsPromises.writeFile(iconPath, parsed.bytes);
await removeProjectIconFiles(projectId, iconPath);
const updatedAt = Date.now();
const nextProjects = projects.map((entry) => (
entry.id === projectId
? { ...entry, iconImage: { mime: parsed.mime, updatedAt, source: 'custom' } }
: entry
));
const updatedSettings = await persistSettings({ projects: nextProjects });
const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null;
return res.json({ project: updatedProject, settings: updatedSettings });
} catch (error) {
console.warn('Failed to upload project icon:', error);
return res.status(500).json({ error: 'Failed to upload project icon' });
}
});
app.delete('/api/projects/:projectId/icon', async (req, res) => {
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : '';
if (!projectId) {
return res.status(400).json({ error: 'projectId is required' });
}
try {
const settings = await readSettingsFromDiskMigrated();
const { projects, project } = findProjectById(settings, projectId);
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
await removeProjectIconFiles(projectId);
const nextProjects = projects.map((entry) => (
entry.id === projectId
? { ...entry, iconImage: null }
: entry
));
const updatedSettings = await persistSettings({ projects: nextProjects });
const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null;
return res.json({ project: updatedProject, settings: updatedSettings });
} catch (error) {
console.warn('Failed to remove project icon:', error);
return res.status(500).json({ error: 'Failed to remove project icon' });
}
});
app.post('/api/projects/:projectId/icon/discover', async (req, res) => {
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : '';
if (!projectId) {
return res.status(400).json({ error: 'projectId is required' });
}
try {
const settings = await readSettingsFromDiskMigrated();
const { projects, project } = findProjectById(settings, projectId);
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
const force = req.body?.force === true;
if (project.iconImage?.source === 'custom' && !force) {
return res.json({
project,
skipped: true,
reason: 'custom-icon-present',
});
}
const faviconCandidates = await fsSearchRuntime.searchFilesystemFiles(project.path, {
limit: 200,
query: 'favicon',
includeHidden: true,
respectGitignore: false,
});
const filtered = faviconCandidates
.filter((entry) => /(^|\/)favicon\.(ico|png|svg|jpg|jpeg|webp)$/i.test(entry.path))
.sort((a, b) => a.path.length - b.path.length);
const selected = filtered[0];
if (!selected) {
return res.status(404).json({ error: 'No favicon found in project' });
}
const ext = path.extname(selected.path).slice(1).toLowerCase();
const mime = projectIconExtensionToMime[ext] || null;
if (!mime) {
return res.status(415).json({ error: 'Unsupported favicon format' });
}
const bytes = await fsPromises.readFile(selected.path);
if (bytes.length === 0) {
return res.status(400).json({ error: 'Discovered icon is empty' });
}
if (bytes.length > projectIconMaxBytes) {
return res.status(400).json({ error: 'Discovered icon exceeds size limit (5 MB)' });
}
const iconPath = projectIconPathForMime(projectId, mime);
if (!iconPath) {
return res.status(415).json({ error: 'Unsupported favicon format' });
}
await fsPromises.mkdir(projectIconsDirPath, { recursive: true });
await fsPromises.writeFile(iconPath, bytes);
await removeProjectIconFiles(projectId, iconPath);
const updatedAt = Date.now();
const nextProjects = projects.map((entry) => (
entry.id === projectId
? { ...entry, iconImage: { mime, updatedAt, source: 'auto' } }
: entry
));
const updatedSettings = await persistSettings({ projects: nextProjects });
const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null;
return res.json({
project: updatedProject,
settings: updatedSettings,
discoveredPath: selected.path,
});
} catch (error) {
console.warn('Failed to discover project icon:', error);
return res.status(500).json({ error: 'Failed to discover project icon' });
}
});
};
+169
View File
@@ -0,0 +1,169 @@
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
export const registerOpenCodeProxy = (app, deps) => {
const {
fs,
os,
path,
OPEN_CODE_READY_GRACE_MS,
getRuntime,
getOpenCodeAuthHeaders,
buildOpenCodeUrl,
ensureOpenCodeApiPrefix,
} = deps;
if (app.get('opencodeProxyConfigured')) {
return;
}
const runtime = getRuntime();
if (runtime.openCodePort) {
console.log(`Setting up proxy to OpenCode on port ${runtime.openCodePort}`);
} else {
console.log('Setting up OpenCode API gate (OpenCode not started yet)');
}
app.set('opencodeProxyConfigured', true);
// Ensure API prefix is detected before proxying
app.use('/api', (_req, _res, next) => {
ensureOpenCodeApiPrefix();
next();
});
// Readiness gate — return 503 while OpenCode is starting/restarting
app.use('/api', (req, res, next) => {
if (
req.path.startsWith('/themes/custom') ||
req.path.startsWith('/push') ||
req.path.startsWith('/config/agents') ||
req.path.startsWith('/config/opencode-resolution') ||
req.path.startsWith('/config/settings') ||
req.path.startsWith('/config/skills') ||
req.path === '/config/reload' ||
req.path === '/health'
) {
return next();
}
const runtimeState = getRuntime();
const waitElapsed = runtimeState.openCodeNotReadySince === 0 ? 0 : Date.now() - runtimeState.openCodeNotReadySince;
const stillWaiting =
(!runtimeState.isOpenCodeReady && (runtimeState.openCodeNotReadySince === 0 || waitElapsed < OPEN_CODE_READY_GRACE_MS)) ||
runtimeState.isRestartingOpenCode ||
!runtimeState.openCodePort;
if (stillWaiting) {
return res.status(503).json({
error: 'OpenCode is restarting',
restarting: true,
});
}
next();
});
// Windows: session merge for cross-directory session listing
if (process.platform === 'win32') {
app.get('/api/session', async (req, res, next) => {
const rawUrl = req.originalUrl || req.url || '';
if (rawUrl.includes('directory=')) return next();
try {
const authHeaders = getOpenCodeAuthHeaders();
const fetchOpts = {
method: 'GET',
headers: { Accept: 'application/json', ...authHeaders },
signal: AbortSignal.timeout(10000),
};
const globalRes = await fetch(buildOpenCodeUrl('/session', ''), fetchOpts);
const globalPayload = globalRes.ok ? await globalRes.json().catch(() => []) : [];
const globalSessions = Array.isArray(globalPayload) ? globalPayload : [];
const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
let projectDirs = [];
try {
const settingsRaw = fs.readFileSync(settingsPath, 'utf8');
const settings = JSON.parse(settingsRaw);
projectDirs = (settings.projects || [])
.map((project) => (typeof project?.path === 'string' ? project.path.trim() : ''))
.filter(Boolean);
} catch {
}
const seen = new Set(
globalSessions
.map((session) => (session && typeof session.id === 'string' ? session.id : null))
.filter((id) => typeof id === 'string')
);
const extraSessions = [];
for (const dir of projectDirs) {
const candidates = Array.from(new Set([
dir,
dir.replace(/\\/g, '/'),
dir.replace(/\//g, '\\'),
]));
for (const candidateDir of candidates) {
const encoded = encodeURIComponent(candidateDir);
try {
const dirRes = await fetch(buildOpenCodeUrl(`/session?directory=${encoded}`, ''), fetchOpts);
if (dirRes.ok) {
const dirPayload = await dirRes.json().catch(() => []);
const dirSessions = Array.isArray(dirPayload) ? dirPayload : [];
for (const session of dirSessions) {
const id = session && typeof session.id === 'string' ? session.id : null;
if (id && !seen.has(id)) {
seen.add(id);
extraSessions.push(session);
}
}
}
} catch {
}
}
}
const merged = [...globalSessions, ...extraSessions];
merged.sort((a, b) => {
const aTime = a && typeof a.time_updated === 'number' ? a.time_updated : 0;
const bTime = b && typeof b.time_updated === 'number' ? b.time_updated : 0;
return bTime - aTime;
});
console.log(`[SessionMerge] ${globalSessions.length} global + ${extraSessions.length} extra = ${merged.length} total`);
return res.json(merged);
} catch (error) {
console.log(`[SessionMerge] Error: ${error.message}, falling through`);
next();
}
});
}
// http-proxy-middleware handles SSE, large bodies, timeouts correctly
const apiProxy = createProxyMiddleware({
target: `http://127.0.0.1:${runtime.openCodePort || 3902}`,
changeOrigin: true,
pathRewrite: { '^/api': '' },
// Dynamic target — port can change after restart
router: () => {
const rt = getRuntime();
return `http://127.0.0.1:${rt.openCodePort || 3902}`;
},
on: {
proxyReq: (proxyReq) => {
// Inject OpenCode auth headers
const authHeaders = getOpenCodeAuthHeaders();
if (authHeaders.Authorization) {
proxyReq.setHeader('Authorization', authHeaders.Authorization);
}
},
error: (err, _req, res) => {
console.error('[proxy] OpenCode proxy error:', err.message);
if (res && !res.headersSent && typeof res.status === 'function') {
res.status(503).json({ error: 'OpenCode service unavailable' });
}
},
},
});
app.use('/api', apiProxy);
};
@@ -0,0 +1,238 @@
const DEFAULT_PWA_APP_NAME = 'OpenChamber - AI Coding Assistant';
export const registerPwaManifestRoute = (app, dependencies) => {
const {
process,
resolveProjectDirectory,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
readSettingsFromDiskMigrated,
normalizePwaAppName,
} = dependencies;
const recentPwaSessionsCache = new Map();
const getRecentPwaSessionShortcuts = async (req) => {
const now = Date.now();
const resolvedDirectoryResult = await resolveProjectDirectory(req).catch(() => ({ directory: null }));
const preferredDirectory = typeof resolvedDirectoryResult?.directory === 'string'
? resolvedDirectoryResult.directory
: null;
const cacheKey = preferredDirectory ? `dir:${preferredDirectory}` : 'global';
const cached = recentPwaSessionsCache.get(cacheKey);
if (cached && now - cached.at < 5000) {
return cached.data;
}
const normalizeShortcutTitle = (value, fallback) => {
const normalized = normalizePwaAppName(value, fallback);
return normalized.length > 48 ? normalized.slice(0, 48) : normalized;
};
const toFiniteNumber = (value) => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim().length > 0) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
};
const normalizeDirectory = (value) => {
if (typeof value !== 'string') {
return '';
}
const trimmed = value.trim();
if (!trimmed) {
return '';
}
const normalized = trimmed.replace(/\\/g, '/');
if (normalized === '/') {
return '/';
}
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
};
const sessionUpdatedAt = (session) => {
const time = session && typeof session.time === 'object' ? session.time : null;
return toFiniteNumber(time?.updated) ?? toFiniteNumber(time?.created) ?? 0;
};
const filterSessionsByDirectory = (sessions, directory) => {
const normalizedDirectory = normalizeDirectory(directory);
if (!normalizedDirectory) {
return sessions;
}
const prefix = normalizedDirectory === '/' ? '/' : `${normalizedDirectory}/`;
return sessions.filter((session) => {
const sessionDirectory = normalizeDirectory(session?.directory);
if (!sessionDirectory) {
return false;
}
return sessionDirectory === normalizedDirectory || (prefix !== '/' && sessionDirectory.startsWith(prefix));
});
};
const listSessions = async (directory) => {
const query = (() => {
if (typeof directory !== 'string' || directory.length === 0) {
return '';
}
const preparedDirectory = process.platform === 'win32'
? directory.replace(/\//g, '\\\\')
: directory;
return `?directory=${encodeURIComponent(preparedDirectory)}`;
})();
const response = await fetch(buildOpenCodeUrl(`/session${query}`, ''), {
method: 'GET',
headers: {
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
signal: AbortSignal.timeout(2500),
});
if (!response.ok) {
return [];
}
const payload = await response.json().catch(() => null);
return Array.isArray(payload) ? payload : [];
};
try {
let payload = [];
if (preferredDirectory) {
const scopedPayload = await listSessions(preferredDirectory);
const filteredScopedPayload = filterSessionsByDirectory(scopedPayload, preferredDirectory);
if (filteredScopedPayload.length > 0) {
payload = filteredScopedPayload;
} else {
const globalPayload = await listSessions(null);
const filteredGlobalPayload = filterSessionsByDirectory(globalPayload, preferredDirectory);
payload = filteredGlobalPayload.length > 0 ? filteredGlobalPayload : globalPayload;
}
} else {
payload = await listSessions(null);
}
const seen = new Set();
const rows = [];
for (const item of payload) {
if (!item || typeof item !== 'object') {
continue;
}
const id = typeof item.id === 'string' ? item.id.trim().slice(0, 160) : '';
if (!id || seen.has(id)) {
continue;
}
seen.add(id);
const title = normalizeShortcutTitle(item.title, `Session ${rows.length + 1}`);
const updatedAt = sessionUpdatedAt(item);
rows.push({ id, title, updatedAt });
}
rows.sort((a, b) => b.updatedAt - a.updatedAt);
const shortcuts = rows.slice(0, 3).map((session) => ({
name: session.title,
short_name: session.title.length > 32 ? session.title.slice(0, 32) : session.title,
description: 'Open recent session',
url: `/?session=${encodeURIComponent(session.id)}`,
icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }],
}));
recentPwaSessionsCache.set(cacheKey, { at: now, data: shortcuts });
return shortcuts;
} catch {
recentPwaSessionsCache.set(cacheKey, { at: now, data: [] });
return [];
}
};
app.get('/manifest.webmanifest', async (req, res) => {
const hasQueryOverride =
typeof req.query?.pwa_name === 'string'
|| typeof req.query?.app_name === 'string'
|| typeof req.query?.appName === 'string';
let queryValueRaw = '';
if (typeof req.query?.pwa_name === 'string') {
queryValueRaw = req.query.pwa_name;
} else if (typeof req.query?.app_name === 'string') {
queryValueRaw = req.query.app_name;
} else if (typeof req.query?.appName === 'string') {
queryValueRaw = req.query.appName;
}
const queryOverrideName = normalizePwaAppName(queryValueRaw, '');
let storedName = '';
try {
const settings = await readSettingsFromDiskMigrated();
storedName = normalizePwaAppName(settings?.pwaAppName, '');
} catch {
storedName = '';
}
const appName = hasQueryOverride
? (queryOverrideName || DEFAULT_PWA_APP_NAME)
: (storedName || DEFAULT_PWA_APP_NAME);
const shortName = appName.length > 30 ? appName.slice(0, 30) : appName;
const recentSessionShortcuts = await getRecentPwaSessionShortcuts(req);
const manifest = {
name: appName,
short_name: shortName,
description: 'Web interface companion for OpenCode AI coding agent',
id: '/',
start_url: '/',
scope: '/',
display: 'standalone',
background_color: '#151313',
theme_color: '#edb449',
orientation: 'any',
icons: [
{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: '/pwa-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'any maskable' },
{ src: '/pwa-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
{ src: '/apple-touch-icon-180x180.png', sizes: '180x180', type: 'image/png', purpose: 'any' },
{ src: '/apple-touch-icon-152x152.png', sizes: '152x152', type: 'image/png', purpose: 'any' },
{ src: '/favicon-32.png', sizes: '32x32', type: 'image/png' },
{ src: '/favicon-16.png', sizes: '16x16', type: 'image/png' },
],
shortcuts: [
{
name: 'Appearance Settings',
short_name: 'Settings',
description: 'Open appearance settings',
url: '/?settings=appearance',
icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }],
},
...recentSessionShortcuts,
],
categories: ['developer', 'tools', 'productivity'],
lang: 'en',
};
res.setHeader('Cache-Control', 'no-store, must-revalidate');
res.type('application/manifest+json');
res.send(JSON.stringify(manifest));
});
};
+206
View File
@@ -0,0 +1,206 @@
export const registerOpenCodeRoutes = (app, dependencies) => {
const {
crypto,
clientReloadDelayMs,
getOpenCodeResolutionSnapshot,
formatSettingsResponse,
readSettingsFromDisk,
readSettingsFromDiskMigrated,
persistSettings,
sanitizeProjects,
validateDirectoryPath,
resolveProjectDirectory,
getProviderSources,
removeProviderConfig,
refreshOpenCodeAfterConfigChange,
} = dependencies;
let authLibrary = null;
const getAuthLibrary = async () => {
if (!authLibrary) {
authLibrary = await import('./auth.js');
}
return authLibrary;
};
app.get('/api/config/settings', async (_req, res) => {
try {
const settings = await readSettingsFromDiskMigrated();
res.json(formatSettingsResponse(settings));
} catch (error) {
console.error('Failed to read settings:', error);
res.status(500).json({ error: 'Failed to read settings' });
}
});
app.get('/api/config/opencode-resolution', async (_req, res) => {
try {
const settings = await readSettingsFromDiskMigrated();
const resolution = await getOpenCodeResolutionSnapshot(settings);
res.json(resolution);
} catch (error) {
console.error('Failed to resolve OpenCode binary:', error);
res.status(500).json({ error: 'Failed to resolve OpenCode binary' });
}
});
app.put('/api/config/settings', async (req, res) => {
console.log('[API:PUT /api/config/settings] Received request');
try {
const updated = await persistSettings(req.body ?? {});
console.log(`[API:PUT /api/config/settings] Success, returning ${updated.projects?.length || 0} projects`);
res.json(updated);
} catch (error) {
console.error('[API:PUT /api/config/settings] Failed to save settings:', error);
console.error('[API:PUT /api/config/settings] Error stack:', error.stack);
res.status(500).json({ error: 'Failed to save settings' });
}
});
app.get('/api/provider/:providerId/source', async (req, res) => {
try {
const { providerId } = req.params;
if (!providerId) {
return res.status(400).json({ error: 'Provider ID is required' });
}
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requestedDirectory = headerDirectory || queryDirectory || null;
let directory = null;
const resolved = await resolveProjectDirectory(req);
if (resolved.directory) {
directory = resolved.directory;
} else if (requestedDirectory) {
return res.status(400).json({ error: resolved.error });
}
const sources = getProviderSources(providerId, directory);
const { getProviderAuth } = await getAuthLibrary();
const auth = getProviderAuth(providerId);
sources.sources.auth.exists = Boolean(auth);
return res.json({
providerId,
sources: sources.sources,
});
} catch (error) {
console.error('Failed to get provider sources:', error);
return res.status(500).json({ error: error.message || 'Failed to get provider sources' });
}
});
app.delete('/api/provider/:providerId/auth', async (req, res) => {
try {
const { providerId } = req.params;
if (!providerId) {
return res.status(400).json({ error: 'Provider ID is required' });
}
const scope = typeof req.query?.scope === 'string' ? req.query.scope : 'auth';
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requestedDirectory = headerDirectory || queryDirectory || null;
let directory = null;
if (scope === 'project' || requestedDirectory) {
const resolved = await resolveProjectDirectory(req);
if (!resolved.directory) {
return res.status(400).json({ error: resolved.error });
}
directory = resolved.directory;
} else {
const resolved = await resolveProjectDirectory(req);
if (resolved.directory) {
directory = resolved.directory;
}
}
let removed = false;
if (scope === 'auth') {
const { removeProviderAuth } = await getAuthLibrary();
removed = removeProviderAuth(providerId);
} else if (scope === 'user' || scope === 'project' || scope === 'custom') {
removed = removeProviderConfig(providerId, directory, scope);
} else if (scope === 'all') {
const { removeProviderAuth } = await getAuthLibrary();
const authRemoved = removeProviderAuth(providerId);
const userRemoved = removeProviderConfig(providerId, directory, 'user');
const projectRemoved = directory ? removeProviderConfig(providerId, directory, 'project') : false;
const customRemoved = removeProviderConfig(providerId, directory, 'custom');
removed = authRemoved || userRemoved || projectRemoved || customRemoved;
} else {
return res.status(400).json({ error: 'Invalid scope' });
}
if (removed) {
await refreshOpenCodeAfterConfigChange(`provider ${providerId} disconnected (${scope})`);
}
return res.json({
success: true,
removed,
requiresReload: removed,
message: removed ? 'Provider disconnected successfully' : 'Provider was not connected',
reloadDelayMs: removed ? clientReloadDelayMs : undefined,
});
} catch (error) {
console.error('Failed to disconnect provider:', error);
return res.status(500).json({ error: error.message || 'Failed to disconnect provider' });
}
});
app.post('/api/opencode/directory', async (req, res) => {
try {
const requestedPath = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
if (!requestedPath) {
return res.status(400).json({ error: 'Path is required' });
}
const validated = await validateDirectoryPath(requestedPath);
if (!validated.ok) {
return res.status(400).json({ error: validated.error });
}
const resolvedPath = validated.directory;
const currentSettings = await readSettingsFromDisk();
const existingProjects = sanitizeProjects(currentSettings.projects) || [];
const existing = existingProjects.find((project) => project.path === resolvedPath) || null;
const nextProjects = existing
? existingProjects
: [
...existingProjects,
{
id: crypto.randomUUID(),
path: resolvedPath,
addedAt: Date.now(),
lastOpenedAt: Date.now(),
},
];
const activeProjectId = existing ? existing.id : nextProjects[nextProjects.length - 1].id;
const updated = await persistSettings({
projects: nextProjects,
activeProjectId,
lastDirectory: resolvedPath,
});
return res.json({
success: true,
restarted: false,
path: resolvedPath,
settings: updated,
});
} catch (error) {
console.error('Failed to update OpenCode working directory:', error);
return res.status(500).json({ error: error.message || 'Failed to update working directory' });
}
});
};
@@ -0,0 +1,138 @@
export const createServerStartupRuntime = (dependencies) => {
const {
process,
crypto,
server,
normalizeTunnelBootstrapTtlMs,
readSettingsFromDiskMigrated,
tunnelAuthController,
startTunnelWithNormalizedRequest,
gracefulShutdown,
getSignalsAttached,
setSignalsAttached,
syncToHmrState,
TUNNEL_MODE_QUICK,
TUNNEL_MODE_MANAGED_LOCAL,
TUNNEL_MODE_MANAGED_REMOTE,
} = dependencies;
const resolveBindHost = (host) =>
host
|| (typeof process.env.OPENCHAMBER_HOST === 'string' && process.env.OPENCHAMBER_HOST.trim().length > 0
? process.env.OPENCHAMBER_HOST.trim()
: '127.0.0.1');
const startListeningAndMaybeTunnel = async ({
port,
bindHost,
startupTunnelRequest,
onTunnelReady,
}) => {
let activePort = port;
await new Promise((resolve, reject) => {
const onError = (error) => {
server.off('error', onError);
reject(error);
};
server.once('error', onError);
const onListening = async () => {
server.off('error', onError);
const addressInfo = server.address();
activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port;
try {
process.send?.({ type: 'openchamber:ready', port: activePort });
} catch {
// ignore
}
const displayHost = (bindHost === '0.0.0.0' || bindHost === '::' || bindHost === '[::]')
? 'localhost'
: (bindHost.includes(':') ? `[${bindHost}]` : bindHost);
console.log(`OpenChamber server listening on ${bindHost}:${activePort}`);
console.log(`Health check: http://${displayHost}:${activePort}/health`);
console.log(`Web interface: http://${displayHost}:${activePort}`);
if (startupTunnelRequest) {
const startupModeLabel = startupTunnelRequest.mode === TUNNEL_MODE_QUICK
? 'Quick Tunnel'
: (startupTunnelRequest.mode === TUNNEL_MODE_MANAGED_LOCAL
? 'Managed Local Tunnel'
: (startupTunnelRequest.mode === TUNNEL_MODE_MANAGED_REMOTE ? 'Managed Remote Tunnel' : 'Tunnel'));
console.log(`\nInitializing ${startupModeLabel} for provider '${startupTunnelRequest.provider}'...`);
try {
const { publicUrl, mode } = await startTunnelWithNormalizedRequest({
provider: startupTunnelRequest.provider,
mode: startupTunnelRequest.mode,
intent: startupTunnelRequest.intent,
hostname: startupTunnelRequest.hostname,
token: startupTunnelRequest.token,
configPath: startupTunnelRequest.configPath,
selectedPresetId: '',
selectedPresetName: '',
});
if (publicUrl) {
tunnelAuthController.setActiveTunnel({
tunnelId: crypto.randomUUID(),
publicUrl,
mode,
});
const settings = await readSettingsFromDiskMigrated();
const bootstrapTtlMs = settings?.tunnelBootstrapTtlMs === null
? null
: normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs);
const bootstrapToken = tunnelAuthController.issueBootstrapToken({ ttlMs: bootstrapTtlMs });
const connectUrl = `${publicUrl.replace(/\/$/, '')}/connect?t=${encodeURIComponent(bootstrapToken.token)}`;
if (onTunnelReady) {
onTunnelReady(publicUrl, connectUrl);
} else {
console.log(`\n🌐 Tunnel URL: ${connectUrl}`);
console.log('🔑 One-time connect link (expires after first use)\n');
}
} else if (onTunnelReady) {
onTunnelReady(publicUrl, null);
}
} catch (error) {
console.error(`Failed to start tunnel: ${error.message}`);
console.log('Continuing without tunnel...');
}
}
resolve();
};
server.listen(port, bindHost, onListening);
});
return { activePort };
};
const attachProcessHandlers = ({ attachSignals }) => {
if (attachSignals && !getSignalsAttached()) {
const handleSignal = async () => {
await gracefulShutdown();
};
process.on('SIGTERM', handleSignal);
process.on('SIGINT', handleSignal);
process.on('SIGQUIT', handleSignal);
setSignalsAttached(true);
syncToHmrState();
}
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
gracefulShutdown();
});
};
return {
resolveBindHost,
startListeningAndMaybeTunnel,
attachProcessHandlers,
};
};
@@ -0,0 +1,168 @@
import { registerOpenCodeProxy } from './proxy.js';
export const createServerUtilsRuntime = (dependencies) => {
const {
fs,
os,
path,
process,
openCodeReadyGraceMs,
longRequestTimeoutMs,
getRuntime,
getOpenCodeAuthHeaders,
buildOpenCodeUrl,
ensureOpenCodeApiPrefix,
getUiNotificationClients,
getOpenCodePort,
setOpenCodePortState,
syncToHmrState,
markOpenCodeNotReady,
setOpenCodeNotReadySince,
clearLastOpenCodeError,
getLoginShellPath,
} = dependencies;
const setOpenCodePort = (port) => {
if (!Number.isFinite(port) || port <= 0) {
return;
}
const numericPort = Math.trunc(port);
const currentPort = getOpenCodePort();
const portChanged = currentPort !== numericPort;
if (portChanged || currentPort === null) {
setOpenCodePortState(numericPort);
syncToHmrState();
console.log(`Detected OpenCode port: ${numericPort}`);
if (portChanged) {
markOpenCodeNotReady();
}
setOpenCodeNotReadySince(Date.now());
}
clearLastOpenCodeError();
};
const waitForOpenCodePort = async (timeoutMs = 15000) => {
if (getOpenCodePort() !== null) {
return getOpenCodePort();
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 50));
if (getOpenCodePort() !== null) {
return getOpenCodePort();
}
}
throw new Error('Timed out waiting for OpenCode port');
};
const buildAugmentedPath = () => {
const augmented = new Set();
const loginShellPath = getLoginShellPath();
if (loginShellPath) {
for (const segment of loginShellPath.split(path.delimiter)) {
if (segment) {
augmented.add(segment);
}
}
}
const current = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
for (const segment of current) {
augmented.add(segment);
}
return Array.from(augmented).join(path.delimiter);
};
const parseSseDataPayload = (block) => {
if (!block || typeof block !== 'string') {
return null;
}
const dataLines = block
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).replace(/^\s/, ''));
if (dataLines.length === 0) {
return null;
}
const payloadText = dataLines.join('\n').trim();
if (!payloadText) {
return null;
}
try {
const parsed = JSON.parse(payloadText);
if (
parsed &&
typeof parsed === 'object' &&
typeof parsed.payload === 'object' &&
parsed.payload !== null
) {
return parsed.payload;
}
return parsed;
} catch {
return null;
}
};
const fetchArraySnapshot = async (route, invalidMessage) => {
if (!getOpenCodePort()) {
throw new Error('OpenCode port is not available');
}
const response = await fetch(buildOpenCodeUrl(route), {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
});
if (!response.ok) {
throw new Error(`Failed to fetch ${invalidMessage} (status ${response.status})`);
}
const payload = await response.json().catch(() => null);
if (!Array.isArray(payload)) {
throw new Error(`Invalid ${invalidMessage} payload from OpenCode`);
}
return payload;
};
const fetchAgentsSnapshot = () => fetchArraySnapshot('/agent', 'agents snapshot');
const fetchProvidersSnapshot = () => fetchArraySnapshot('/provider', 'providers snapshot');
const fetchModelsSnapshot = () => fetchArraySnapshot('/model', 'models snapshot');
const setupProxy = (app) => {
registerOpenCodeProxy(app, {
fs,
os,
path,
OPEN_CODE_READY_GRACE_MS: openCodeReadyGraceMs,
LONG_REQUEST_TIMEOUT_MS: longRequestTimeoutMs,
getRuntime,
getOpenCodeAuthHeaders,
buildOpenCodeUrl,
ensureOpenCodeApiPrefix,
getUiNotificationClients,
});
};
return {
setOpenCodePort,
waitForOpenCodePort,
buildAugmentedPath,
parseSseDataPayload,
fetchAgentsSnapshot,
fetchProvidersSnapshot,
fetchModelsSnapshot,
setupProxy,
};
};
@@ -0,0 +1,320 @@
const SESSION_COOLDOWN_DURATION_MS = 2000;
const SESSION_STATE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
const SESSION_ATTENTION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
const SESSION_STATE_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
const extractSessionStatusUpdate = (payload) => {
if (!payload || payload.type !== 'session.status') {
return null;
}
const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
const info = properties.info && typeof properties.info === 'object' ? properties.info : {};
const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : '';
const type = typeof info.type === 'string' ? info.type.trim() : '';
if (!sessionId || !type) {
return null;
}
return {
sessionId,
type,
eventId: typeof payload.id === 'string' ? payload.id : '',
attempt: typeof info.attempt === 'number' ? info.attempt : undefined,
message: typeof info.message === 'string' ? info.message : undefined,
next: typeof info.next === 'number' ? info.next : undefined,
};
};
const deriveSessionActivityTransitions = (payload) => {
const update = extractSessionStatusUpdate(payload);
if (!update) {
return [];
}
if (update.type === 'busy' || update.type === 'retry') {
return [{ sessionId: update.sessionId, phase: 'busy' }];
}
if (update.type === 'idle') {
return [{ sessionId: update.sessionId, phase: 'cooldown' }];
}
return [];
};
export const createSessionRuntime = ({ writeSseEvent, getNotificationClients }) => {
const sessionActivityPhases = new Map();
const sessionActivityCooldowns = new Map();
const sessionStates = new Map();
const sessionAttentionStates = new Map();
const getOrCreateAttentionState = (sessionId) => {
if (!sessionId || typeof sessionId !== 'string') return null;
let state = sessionAttentionStates.get(sessionId);
if (!state) {
state = {
needsAttention: false,
lastUserMessageAt: null,
lastStatusChangeAt: Date.now(),
viewedByClients: new Set(),
status: 'idle',
};
sessionAttentionStates.set(sessionId, state);
}
return state;
};
const setSessionActivityPhase = (sessionId, phase) => {
if (!sessionId || typeof sessionId !== 'string') return false;
const current = sessionActivityPhases.get(sessionId);
if (current?.phase === phase) return false;
if (phase === 'cooldown' && current?.phase !== 'busy') {
return false;
}
const existingTimer = sessionActivityCooldowns.get(sessionId);
if (existingTimer) {
clearTimeout(existingTimer);
sessionActivityCooldowns.delete(sessionId);
}
sessionActivityPhases.set(sessionId, { phase, updatedAt: Date.now() });
if (phase === 'cooldown') {
const timer = setTimeout(() => {
const now = sessionActivityPhases.get(sessionId);
if (now?.phase === 'cooldown') {
sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: Date.now() });
}
sessionActivityCooldowns.delete(sessionId);
}, SESSION_COOLDOWN_DURATION_MS);
sessionActivityCooldowns.set(sessionId, timer);
}
return true;
};
const updateSessionAttentionStatus = (sessionId, status) => {
const state = getOrCreateAttentionState(sessionId);
if (!state) return;
const prevStatus = state.status;
state.status = status;
state.lastStatusChangeAt = Date.now();
if ((prevStatus === 'busy' || prevStatus === 'retry') && status === 'idle') {
if (state.lastUserMessageAt && state.viewedByClients.size === 0) {
state.needsAttention = true;
}
}
};
const updateSessionState = (sessionId, status, eventId, metadata = {}) => {
if (!sessionId || typeof sessionId !== 'string') return;
const now = Date.now();
const existing = sessionStates.get(sessionId);
const existingAttentionState = sessionAttentionStates.get(sessionId);
if (existing && existing.lastUpdateAt > now - 5000 && status === existing.status) {
return;
}
sessionStates.set(sessionId, {
status,
lastUpdateAt: now,
lastEventId: eventId || `server-${now}`,
metadata: { ...existing?.metadata, ...metadata },
});
updateSessionAttentionStatus(sessionId, status);
const attentionState = sessionAttentionStates.get(sessionId);
const attentionChanged = !!attentionState && existingAttentionState?.needsAttention !== attentionState.needsAttention;
const clients = getNotificationClients();
if (clients.size > 0 && (!existing || existing.status !== status || attentionChanged)) {
const state = sessionStates.get(sessionId);
for (const res of clients) {
try {
writeSseEvent(res, {
type: 'openchamber:session-status',
properties: {
sessionId,
status: state.status,
timestamp: state.lastUpdateAt,
metadata: state.metadata,
needsAttention: attentionState?.needsAttention ?? false,
},
});
} catch {
}
}
}
const phase = status === 'busy' || status === 'retry' ? 'busy' : 'idle';
setSessionActivityPhase(sessionId, phase);
};
const getSessionStateSnapshot = () => {
const result = {};
const now = Date.now();
for (const [sessionId, data] of sessionStates) {
if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) continue;
result[sessionId] = {
status: data.status,
lastUpdateAt: data.lastUpdateAt,
metadata: data.metadata,
};
}
return result;
};
const getSessionState = (sessionId) => {
if (!sessionId) return null;
return sessionStates.get(sessionId) || null;
};
const markSessionViewed = (sessionId, clientId) => {
const state = getOrCreateAttentionState(sessionId);
if (!state) return;
const wasNeedsAttention = state.needsAttention;
state.viewedByClients.add(clientId);
if (wasNeedsAttention) {
state.needsAttention = false;
const clients = getNotificationClients();
for (const res of clients) {
try {
writeSseEvent(res, {
type: 'openchamber:session-status',
properties: {
sessionId,
status: state.status,
timestamp: Date.now(),
metadata: {},
needsAttention: false,
},
});
} catch {
}
}
}
};
const markSessionUnviewed = (sessionId, clientId) => {
const state = sessionAttentionStates.get(sessionId);
if (!state) return;
state.viewedByClients.delete(clientId);
};
const markUserMessageSent = (sessionId) => {
const state = getOrCreateAttentionState(sessionId);
if (!state) return;
state.lastUserMessageAt = Date.now();
};
const getSessionAttentionSnapshot = () => {
const result = {};
const now = Date.now();
for (const [sessionId, state] of sessionAttentionStates) {
if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) continue;
result[sessionId] = {
needsAttention: state.needsAttention,
lastUserMessageAt: state.lastUserMessageAt,
lastStatusChangeAt: state.lastStatusChangeAt,
status: state.status,
isViewed: state.viewedByClients.size > 0,
};
}
return result;
};
const getSessionAttentionState = (sessionId) => {
if (!sessionId) return null;
const state = sessionAttentionStates.get(sessionId);
if (!state) return null;
return {
needsAttention: state.needsAttention,
lastUserMessageAt: state.lastUserMessageAt,
lastStatusChangeAt: state.lastStatusChangeAt,
status: state.status,
isViewed: state.viewedByClients.size > 0,
};
};
const getSessionActivitySnapshot = () => {
const result = {};
for (const [sessionId, data] of sessionActivityPhases) {
result[sessionId] = { type: data.phase };
}
return result;
};
const resetAllSessionActivityToIdle = () => {
for (const timer of sessionActivityCooldowns.values()) {
clearTimeout(timer);
}
sessionActivityCooldowns.clear();
const now = Date.now();
for (const [sessionId] of sessionActivityPhases) {
sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: now });
}
};
const cleanupOldSessionStates = () => {
const now = Date.now();
for (const [sessionId, data] of sessionStates) {
if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) {
sessionStates.delete(sessionId);
}
}
for (const [sessionId, state] of sessionAttentionStates) {
if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) {
sessionAttentionStates.delete(sessionId);
}
}
};
const cleanupInterval = setInterval(cleanupOldSessionStates, SESSION_STATE_CLEANUP_INTERVAL_MS);
const processOpenCodeSsePayload = (payload) => {
const transitions = deriveSessionActivityTransitions(payload);
for (const activity of transitions) {
setSessionActivityPhase(activity.sessionId, activity.phase);
}
if (payload && payload.type === 'session.status') {
const update = extractSessionStatusUpdate(payload);
if (update) {
updateSessionState(update.sessionId, update.type, update.eventId || `sse-${Date.now()}`, {
attempt: update.attempt,
message: update.message,
next: update.next,
});
}
}
};
const dispose = () => {
clearInterval(cleanupInterval);
for (const timer of sessionActivityCooldowns.values()) {
clearTimeout(timer);
}
sessionActivityCooldowns.clear();
};
return {
processOpenCodeSsePayload,
getSessionActivitySnapshot,
getSessionStateSnapshot,
getSessionAttentionSnapshot,
getSessionState,
getSessionAttentionState,
markSessionViewed,
markSessionUnviewed,
markUserMessageSent,
resetAllSessionActivityToIdle,
dispose,
};
};
@@ -0,0 +1,602 @@
export const createSettingsHelpers = (dependencies) => {
const {
normalizePathForPersistence,
normalizeDirectoryPath,
normalizeTunnelBootstrapTtlMs,
normalizeTunnelSessionTtlMs,
normalizeTunnelProvider,
normalizeTunnelMode,
normalizeOptionalPath,
normalizeManagedRemoteTunnelHostname,
normalizeManagedRemoteTunnelPresets,
normalizeManagedRemoteTunnelPresetTokens,
sanitizeTypographySizesPartial,
normalizeStringArray,
sanitizeModelRefs,
sanitizeSkillCatalogs,
sanitizeProjects,
} = dependencies;
const PWA_APP_NAME_MAX_LENGTH = 64;
const normalizePwaAppName = (value, fallback = '') => {
if (typeof value !== 'string') {
return fallback;
}
const normalized = value.trim().replace(/\s+/g, ' ');
if (!normalized) {
return fallback;
}
return normalized.slice(0, PWA_APP_NAME_MAX_LENGTH);
};
const sanitizeSettingsUpdate = (payload) => {
if (!payload || typeof payload !== 'object') {
return {};
}
const candidate = payload;
const result = {};
if (typeof candidate.themeId === 'string' && candidate.themeId.length > 0) {
result.themeId = candidate.themeId;
}
if (typeof candidate.themeVariant === 'string' && (candidate.themeVariant === 'light' || candidate.themeVariant === 'dark')) {
result.themeVariant = candidate.themeVariant;
}
if (typeof candidate.useSystemTheme === 'boolean') {
result.useSystemTheme = candidate.useSystemTheme;
}
if (typeof candidate.lightThemeId === 'string' && candidate.lightThemeId.length > 0) {
result.lightThemeId = candidate.lightThemeId;
}
if (typeof candidate.darkThemeId === 'string' && candidate.darkThemeId.length > 0) {
result.darkThemeId = candidate.darkThemeId;
}
if (typeof candidate.splashBgLight === 'string' && candidate.splashBgLight.trim().length > 0) {
result.splashBgLight = candidate.splashBgLight.trim();
}
if (typeof candidate.splashFgLight === 'string' && candidate.splashFgLight.trim().length > 0) {
result.splashFgLight = candidate.splashFgLight.trim();
}
if (typeof candidate.splashBgDark === 'string' && candidate.splashBgDark.trim().length > 0) {
result.splashBgDark = candidate.splashBgDark.trim();
}
if (typeof candidate.splashFgDark === 'string' && candidate.splashFgDark.trim().length > 0) {
result.splashFgDark = candidate.splashFgDark.trim();
}
if (typeof candidate.lastDirectory === 'string' && candidate.lastDirectory.length > 0) {
const normalized = normalizePathForPersistence(candidate.lastDirectory);
if (typeof normalized === 'string' && normalized.length > 0) {
result.lastDirectory = normalized;
}
}
if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) {
const normalized = normalizePathForPersistence(candidate.homeDirectory);
if (typeof normalized === 'string' && normalized.length > 0) {
result.homeDirectory = normalized;
}
}
// Absolute path to the opencode CLI binary (optional override).
// Accept empty-string to clear (we persist an empty string sentinel so the running
// process can reliably drop a previously applied OPENCODE_BINARY override).
if (typeof candidate.opencodeBinary === 'string') {
const normalized = normalizeDirectoryPath(candidate.opencodeBinary).trim();
result.opencodeBinary = normalized;
}
if (Array.isArray(candidate.projects)) {
const projects = sanitizeProjects(candidate.projects);
if (projects) {
result.projects = projects;
}
}
if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) {
result.activeProjectId = candidate.activeProjectId;
}
if (Array.isArray(candidate.approvedDirectories)) {
result.approvedDirectories = normalizeStringArray(
candidate.approvedDirectories
.map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry))
.filter((entry) => typeof entry === 'string' && entry.length > 0)
);
}
if (Array.isArray(candidate.securityScopedBookmarks)) {
result.securityScopedBookmarks = normalizeStringArray(candidate.securityScopedBookmarks);
}
if (Array.isArray(candidate.pinnedDirectories)) {
result.pinnedDirectories = normalizeStringArray(
candidate.pinnedDirectories
.map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry))
.filter((entry) => typeof entry === 'string' && entry.length > 0)
);
}
if (typeof candidate.uiFont === 'string' && candidate.uiFont.length > 0) {
result.uiFont = candidate.uiFont;
}
if (typeof candidate.monoFont === 'string' && candidate.monoFont.length > 0) {
result.monoFont = candidate.monoFont;
}
if (typeof candidate.markdownDisplayMode === 'string' && candidate.markdownDisplayMode.length > 0) {
result.markdownDisplayMode = candidate.markdownDisplayMode;
}
if (typeof candidate.githubClientId === 'string') {
const trimmed = candidate.githubClientId.trim();
if (trimmed.length > 0) {
result.githubClientId = trimmed;
}
}
if (typeof candidate.githubScopes === 'string') {
const trimmed = candidate.githubScopes.trim();
if (trimmed.length > 0) {
result.githubScopes = trimmed;
}
}
if (typeof candidate.showReasoningTraces === 'boolean') {
result.showReasoningTraces = candidate.showReasoningTraces;
}
if (typeof candidate.showTextJustificationActivity === 'boolean') {
result.showTextJustificationActivity = candidate.showTextJustificationActivity;
}
if (typeof candidate.showDeletionDialog === 'boolean') {
result.showDeletionDialog = candidate.showDeletionDialog;
}
if (typeof candidate.nativeNotificationsEnabled === 'boolean') {
result.nativeNotificationsEnabled = candidate.nativeNotificationsEnabled;
}
if (typeof candidate.notificationMode === 'string') {
const mode = candidate.notificationMode.trim();
if (mode === 'always' || mode === 'hidden-only') {
result.notificationMode = mode;
}
}
if (typeof candidate.notifyOnSubtasks === 'boolean') {
result.notifyOnSubtasks = candidate.notifyOnSubtasks;
}
if (typeof candidate.notifyOnCompletion === 'boolean') {
result.notifyOnCompletion = candidate.notifyOnCompletion;
}
if (typeof candidate.notifyOnError === 'boolean') {
result.notifyOnError = candidate.notifyOnError;
}
if (typeof candidate.notifyOnQuestion === 'boolean') {
result.notifyOnQuestion = candidate.notifyOnQuestion;
}
if (candidate.notificationTemplates && typeof candidate.notificationTemplates === 'object') {
result.notificationTemplates = candidate.notificationTemplates;
}
if (typeof candidate.summarizeLastMessage === 'boolean') {
result.summarizeLastMessage = candidate.summarizeLastMessage;
}
if (typeof candidate.summaryThreshold === 'number' && Number.isFinite(candidate.summaryThreshold)) {
result.summaryThreshold = Math.max(0, Math.round(candidate.summaryThreshold));
}
if (typeof candidate.summaryLength === 'number' && Number.isFinite(candidate.summaryLength)) {
result.summaryLength = Math.max(10, Math.round(candidate.summaryLength));
}
if (typeof candidate.maxLastMessageLength === 'number' && Number.isFinite(candidate.maxLastMessageLength)) {
result.maxLastMessageLength = Math.max(10, Math.round(candidate.maxLastMessageLength));
}
if (typeof candidate.usageAutoRefresh === 'boolean') {
result.usageAutoRefresh = candidate.usageAutoRefresh;
}
if (typeof candidate.usageRefreshIntervalMs === 'number' && Number.isFinite(candidate.usageRefreshIntervalMs)) {
result.usageRefreshIntervalMs = Math.max(30000, Math.min(300000, Math.round(candidate.usageRefreshIntervalMs)));
}
if (candidate.usageDisplayMode === 'usage' || candidate.usageDisplayMode === 'remaining') {
result.usageDisplayMode = candidate.usageDisplayMode;
}
if (Array.isArray(candidate.usageDropdownProviders)) {
result.usageDropdownProviders = normalizeStringArray(candidate.usageDropdownProviders);
}
if (typeof candidate.autoDeleteEnabled === 'boolean') {
result.autoDeleteEnabled = candidate.autoDeleteEnabled;
}
if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) {
const normalizedDays = Math.max(1, Math.min(365, Math.round(candidate.autoDeleteAfterDays)));
result.autoDeleteAfterDays = normalizedDays;
}
if (candidate.tunnelBootstrapTtlMs === null) {
result.tunnelBootstrapTtlMs = null;
} else if (typeof candidate.tunnelBootstrapTtlMs === 'number' && Number.isFinite(candidate.tunnelBootstrapTtlMs)) {
result.tunnelBootstrapTtlMs = normalizeTunnelBootstrapTtlMs(candidate.tunnelBootstrapTtlMs);
}
if (typeof candidate.tunnelSessionTtlMs === 'number' && Number.isFinite(candidate.tunnelSessionTtlMs)) {
result.tunnelSessionTtlMs = normalizeTunnelSessionTtlMs(candidate.tunnelSessionTtlMs);
}
if (typeof candidate.tunnelProvider === 'string') {
const provider = normalizeTunnelProvider(candidate.tunnelProvider);
if (provider) {
result.tunnelProvider = provider;
}
}
if (typeof candidate.tunnelMode === 'string') {
result.tunnelMode = normalizeTunnelMode(candidate.tunnelMode);
}
if (candidate.managedLocalTunnelConfigPath === null) {
result.managedLocalTunnelConfigPath = null;
} else if (typeof candidate.managedLocalTunnelConfigPath === 'string') {
const trimmed = candidate.managedLocalTunnelConfigPath.trim();
result.managedLocalTunnelConfigPath = trimmed.length > 0 ? normalizeOptionalPath(trimmed) : null;
}
if (typeof candidate.managedRemoteTunnelHostname === 'string') {
const hostname = normalizeManagedRemoteTunnelHostname(candidate.managedRemoteTunnelHostname);
result.managedRemoteTunnelHostname = hostname;
}
if (candidate.managedRemoteTunnelToken === null) {
result.managedRemoteTunnelToken = null;
} else if (typeof candidate.managedRemoteTunnelToken === 'string') {
result.managedRemoteTunnelToken = candidate.managedRemoteTunnelToken.trim();
}
const managedRemoteTunnelPresets = normalizeManagedRemoteTunnelPresets(candidate.managedRemoteTunnelPresets);
if (managedRemoteTunnelPresets) {
result.managedRemoteTunnelPresets = managedRemoteTunnelPresets;
}
const managedRemoteTunnelPresetTokens = normalizeManagedRemoteTunnelPresetTokens(candidate.managedRemoteTunnelPresetTokens);
if (managedRemoteTunnelPresetTokens) {
result.managedRemoteTunnelPresetTokens = managedRemoteTunnelPresetTokens;
}
if (typeof candidate.managedRemoteTunnelSelectedPresetId === 'string') {
const id = candidate.managedRemoteTunnelSelectedPresetId.trim();
result.managedRemoteTunnelSelectedPresetId = id || undefined;
}
const typography = sanitizeTypographySizesPartial(candidate.typographySizes);
if (typography) {
result.typographySizes = typography;
}
if (typeof candidate.defaultModel === 'string') {
const trimmed = candidate.defaultModel.trim();
result.defaultModel = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.defaultVariant === 'string') {
const trimmed = candidate.defaultVariant.trim();
result.defaultVariant = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.defaultAgent === 'string') {
const trimmed = candidate.defaultAgent.trim();
result.defaultAgent = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.defaultGitIdentityId === 'string') {
const trimmed = candidate.defaultGitIdentityId.trim();
result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.queueModeEnabled === 'boolean') {
result.queueModeEnabled = candidate.queueModeEnabled;
}
if (typeof candidate.autoCreateWorktree === 'boolean') {
result.autoCreateWorktree = candidate.autoCreateWorktree;
}
if (typeof candidate.gitmojiEnabled === 'boolean') {
result.gitmojiEnabled = candidate.gitmojiEnabled;
}
if (typeof candidate.zenModel === 'string') {
const trimmed = candidate.zenModel.trim();
result.zenModel = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.gitProviderId === 'string') {
const trimmed = candidate.gitProviderId.trim();
result.gitProviderId = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.gitModelId === 'string') {
const trimmed = candidate.gitModelId.trim();
result.gitModelId = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.pwaAppName === 'string') {
result.pwaAppName = normalizePwaAppName(candidate.pwaAppName, undefined);
}
if (typeof candidate.toolCallExpansion === 'string') {
const mode = candidate.toolCallExpansion.trim();
if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed' || mode === 'changes') {
result.toolCallExpansion = mode;
}
}
if (typeof candidate.inputSpellcheckEnabled === 'boolean') {
result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled;
}
if (typeof candidate.showToolFileIcons === 'boolean') {
result.showToolFileIcons = candidate.showToolFileIcons;
}
if (typeof candidate.showExpandedBashTools === 'boolean') {
result.showExpandedBashTools = candidate.showExpandedBashTools;
}
if (typeof candidate.showExpandedEditTools === 'boolean') {
result.showExpandedEditTools = candidate.showExpandedEditTools;
}
if (typeof candidate.chatRenderMode === 'string') {
const mode = candidate.chatRenderMode.trim();
if (mode === 'sorted' || mode === 'live') {
result.chatRenderMode = mode;
}
}
if (typeof candidate.activityRenderMode === 'string') {
const mode = candidate.activityRenderMode.trim();
if (mode === 'collapsed' || mode === 'summary') {
result.activityRenderMode = mode;
}
}
if (typeof candidate.mermaidRenderingMode === 'string') {
const mode = candidate.mermaidRenderingMode.trim();
if (mode === 'svg' || mode === 'ascii') {
result.mermaidRenderingMode = mode;
}
}
if (typeof candidate.userMessageRenderingMode === 'string') {
const mode = candidate.userMessageRenderingMode.trim();
if (mode === 'markdown' || mode === 'plain') {
result.userMessageRenderingMode = mode;
}
}
if (typeof candidate.stickyUserHeader === 'boolean') {
result.stickyUserHeader = candidate.stickyUserHeader;
}
if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) {
result.fontSize = Math.max(50, Math.min(200, Math.round(candidate.fontSize)));
}
if (typeof candidate.terminalFontSize === 'number' && Number.isFinite(candidate.terminalFontSize)) {
result.terminalFontSize = Math.max(9, Math.min(52, Math.round(candidate.terminalFontSize)));
}
if (typeof candidate.padding === 'number' && Number.isFinite(candidate.padding)) {
result.padding = Math.max(50, Math.min(200, Math.round(candidate.padding)));
}
if (typeof candidate.cornerRadius === 'number' && Number.isFinite(candidate.cornerRadius)) {
result.cornerRadius = Math.max(0, Math.min(32, Math.round(candidate.cornerRadius)));
}
if (typeof candidate.inputBarOffset === 'number' && Number.isFinite(candidate.inputBarOffset)) {
result.inputBarOffset = Math.max(0, Math.min(100, Math.round(candidate.inputBarOffset)));
}
const favoriteModels = sanitizeModelRefs(candidate.favoriteModels, 64);
if (favoriteModels) {
result.favoriteModels = favoriteModels;
}
const recentModels = sanitizeModelRefs(candidate.recentModels, 16);
if (recentModels) {
result.recentModels = recentModels;
}
if (typeof candidate.diffLayoutPreference === 'string') {
const mode = candidate.diffLayoutPreference.trim();
if (mode === 'dynamic' || mode === 'inline' || mode === 'side-by-side') {
result.diffLayoutPreference = mode;
}
}
if (typeof candidate.diffViewMode === 'string') {
const mode = candidate.diffViewMode.trim();
if (mode === 'single' || mode === 'stacked') {
result.diffViewMode = mode;
}
}
if (typeof candidate.directoryShowHidden === 'boolean') {
result.directoryShowHidden = candidate.directoryShowHidden;
}
if (typeof candidate.filesViewShowGitignored === 'boolean') {
result.filesViewShowGitignored = candidate.filesViewShowGitignored;
}
if (typeof candidate.openInAppId === 'string') {
const trimmed = candidate.openInAppId.trim();
if (trimmed.length > 0) {
result.openInAppId = trimmed;
}
}
// Message limit — single setting for fetch / trim / Load More chunk
if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) {
result.messageLimit = Math.max(10, Math.min(500, Math.round(candidate.messageLimit)));
}
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
if (skillCatalogs) {
result.skillCatalogs = skillCatalogs;
}
// Usage model selections - which models appear in dropdown
if (candidate.usageSelectedModels && typeof candidate.usageSelectedModels === 'object') {
const sanitized = {};
for (const [providerId, models] of Object.entries(candidate.usageSelectedModels)) {
if (typeof providerId === 'string' && Array.isArray(models)) {
const validModels = models.filter((m) => typeof m === 'string' && m.length > 0);
if (validModels.length > 0) {
sanitized[providerId] = validModels;
}
}
}
if (Object.keys(sanitized).length > 0) {
result.usageSelectedModels = sanitized;
}
}
// Usage page collapsed families - for "Other Models" section
if (candidate.usageCollapsedFamilies && typeof candidate.usageCollapsedFamilies === 'object') {
const sanitized = {};
for (const [providerId, families] of Object.entries(candidate.usageCollapsedFamilies)) {
if (typeof providerId === 'string' && Array.isArray(families)) {
const validFamilies = families.filter((f) => typeof f === 'string' && f.length > 0);
if (validFamilies.length > 0) {
sanitized[providerId] = validFamilies;
}
}
}
if (Object.keys(sanitized).length > 0) {
result.usageCollapsedFamilies = sanitized;
}
}
// Header dropdown expanded families (inverted - stores EXPANDED, default all collapsed)
if (candidate.usageExpandedFamilies && typeof candidate.usageExpandedFamilies === 'object') {
const sanitized = {};
for (const [providerId, families] of Object.entries(candidate.usageExpandedFamilies)) {
if (typeof providerId === 'string' && Array.isArray(families)) {
const validFamilies = families.filter((f) => typeof f === 'string' && f.length > 0);
if (validFamilies.length > 0) {
sanitized[providerId] = validFamilies;
}
}
}
if (Object.keys(sanitized).length > 0) {
result.usageExpandedFamilies = sanitized;
}
}
// Custom model groups configuration
if (candidate.usageModelGroups && typeof candidate.usageModelGroups === 'object') {
const sanitized = {};
for (const [providerId, config] of Object.entries(candidate.usageModelGroups)) {
if (typeof providerId !== 'string') continue;
const providerConfig = {};
// customGroups: array of {id, label, models, order}
if (Array.isArray(config.customGroups)) {
const validGroups = config.customGroups
.filter((g) => g && typeof g.id === 'string' && typeof g.label === 'string')
.map((g) => ({
id: g.id.slice(0, 64),
label: g.label.slice(0, 128),
models: Array.isArray(g.models)
? g.models.filter((m) => typeof m === 'string').slice(0, 500)
: [],
order: typeof g.order === 'number' ? g.order : 0,
}));
if (validGroups.length > 0) {
providerConfig.customGroups = validGroups;
}
}
// modelAssignments: Record<modelName, groupId>
if (config.modelAssignments && typeof config.modelAssignments === 'object') {
const assignments = {};
for (const [model, groupId] of Object.entries(config.modelAssignments)) {
if (typeof model === 'string' && typeof groupId === 'string') {
assignments[model] = groupId;
}
}
if (Object.keys(assignments).length > 0) {
providerConfig.modelAssignments = assignments;
}
}
// renamedGroups: Record<groupId, label>
if (config.renamedGroups && typeof config.renamedGroups === 'object') {
const renamed = {};
for (const [groupId, label] of Object.entries(config.renamedGroups)) {
if (typeof groupId === 'string' && typeof label === 'string') {
renamed[groupId] = label.slice(0, 128);
}
}
if (Object.keys(renamed).length > 0) {
providerConfig.renamedGroups = renamed;
}
}
if (Object.keys(providerConfig).length > 0) {
sanitized[providerId] = providerConfig;
}
}
if (Object.keys(sanitized).length > 0) {
result.usageModelGroups = sanitized;
}
}
// Usage reporting opt-out (default: true/enabled)
if (typeof candidate.reportUsage === 'boolean') {
result.reportUsage = candidate.reportUsage;
}
return result;
};
const mergePersistedSettings = (current, changes) => {
const baseApproved = Array.isArray(changes.approvedDirectories)
? changes.approvedDirectories
: Array.isArray(current.approvedDirectories)
? current.approvedDirectories
: [];
const additionalApproved = [];
if (typeof changes.lastDirectory === 'string' && changes.lastDirectory.length > 0) {
additionalApproved.push(changes.lastDirectory);
}
if (typeof changes.homeDirectory === 'string' && changes.homeDirectory.length > 0) {
additionalApproved.push(changes.homeDirectory);
}
const projectEntries = Array.isArray(changes.projects)
? changes.projects
: Array.isArray(current.projects)
? current.projects
: [];
projectEntries.forEach((project) => {
if (project && typeof project.path === 'string' && project.path.length > 0) {
additionalApproved.push(project.path);
}
});
const approvedSource = [...baseApproved, ...additionalApproved];
const baseBookmarks = Array.isArray(changes.securityScopedBookmarks)
? changes.securityScopedBookmarks
: Array.isArray(current.securityScopedBookmarks)
? current.securityScopedBookmarks
: [];
const nextTypographySizes = changes.typographySizes
? {
...(current.typographySizes || {}),
...changes.typographySizes
}
: current.typographySizes;
const next = {
...current,
...changes,
approvedDirectories: Array.from(
new Set(
approvedSource.filter((entry) => typeof entry === 'string' && entry.length > 0)
)
),
securityScopedBookmarks: Array.from(
new Set(
baseBookmarks.filter((entry) => typeof entry === 'string' && entry.length > 0)
)
),
typographySizes: nextTypographySizes
};
return next;
};
const formatSettingsResponse = (settings) => {
const sanitized = sanitizeSettingsUpdate(settings);
delete sanitized.managedRemoteTunnelToken;
const approved = normalizeStringArray(settings.approvedDirectories);
const bookmarks = normalizeStringArray(settings.securityScopedBookmarks);
const hasManagedRemoteTunnelToken = typeof settings?.managedRemoteTunnelToken === 'string' && settings.managedRemoteTunnelToken.trim().length > 0;
const pwaAppName = normalizePwaAppName(settings?.pwaAppName, '');
return {
...sanitized,
hasManagedRemoteTunnelToken,
...(pwaAppName ? { pwaAppName } : {}),
approvedDirectories: approved,
securityScopedBookmarks: bookmarks,
pinnedDirectories: normalizeStringArray(settings.pinnedDirectories),
typographySizes: sanitizeTypographySizesPartial(settings.typographySizes),
showReasoningTraces:
typeof settings.showReasoningTraces === 'boolean'
? settings.showReasoningTraces
: typeof sanitized.showReasoningTraces === 'boolean'
? sanitized.showReasoningTraces
: false
};
};
return {
normalizePwaAppName,
sanitizeSettingsUpdate,
mergePersistedSettings,
formatSettingsResponse,
};
};
@@ -0,0 +1,428 @@
export const createSettingsNormalizationRuntime = (dependencies) => {
const {
os,
path,
processLike,
tunnelBootstrapTtlDefaultMs,
tunnelBootstrapTtlMinMs,
tunnelBootstrapTtlMaxMs,
tunnelSessionTtlDefaultMs,
tunnelSessionTtlMinMs,
tunnelSessionTtlMaxMs,
} = dependencies;
const normalizeDirectoryPath = (value) => {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
if (!trimmed) {
return trimmed;
}
if (trimmed === '~') {
return os.homedir();
}
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
return path.join(os.homedir(), trimmed.slice(2));
}
return trimmed;
};
const normalizePathForPersistence = (value) => {
if (typeof value !== 'string') {
return value;
}
const normalized = normalizeDirectoryPath(value);
if (typeof normalized !== 'string') {
return normalized;
}
const trimmed = normalized.trim();
if (!trimmed) {
return trimmed;
}
if (processLike.platform !== 'win32') {
return trimmed;
}
return trimmed.replace(/\//g, '\\');
};
const areStringArraysEqual = (a, b) => {
if (!Array.isArray(a) || !Array.isArray(b)) {
return false;
}
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
};
const normalizeStringArray = (input) => {
if (!Array.isArray(input)) {
return [];
}
return Array.from(
new Set(
input.filter((entry) => typeof entry === 'string' && entry.length > 0)
)
);
};
const sanitizeProjects = (input) => {
if (!Array.isArray(input)) {
return undefined;
}
const hexColorPattern = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
const normalizeIconBackground = (value) => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
return hexColorPattern.test(trimmed) ? trimmed.toLowerCase() : null;
};
const result = [];
const seenIds = new Set();
const seenPaths = new Set();
for (const entry of input) {
if (!entry || typeof entry !== 'object') continue;
const candidate = entry;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
const resolvedPath = rawPath ? path.resolve(normalizeDirectoryPath(rawPath)) : '';
const normalizedPath = resolvedPath ? normalizePathForPersistence(resolvedPath) : '';
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
const icon = typeof candidate.icon === 'string' ? candidate.icon.trim() : '';
const iconImage = candidate.iconImage && typeof candidate.iconImage === 'object'
? candidate.iconImage
: null;
const iconBackground = normalizeIconBackground(candidate.iconBackground);
const color = typeof candidate.color === 'string' ? candidate.color.trim() : '';
const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null;
const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt)
? Number(candidate.lastOpenedAt)
: null;
if (!id || !normalizedPath) continue;
if (seenIds.has(id)) continue;
if (seenPaths.has(normalizedPath)) continue;
seenIds.add(id);
seenPaths.add(normalizedPath);
const project = {
id,
path: normalizedPath,
...(label ? { label } : {}),
...(icon ? { icon } : {}),
...(iconBackground ? { iconBackground } : {}),
...(color ? { color } : {}),
...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}),
...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
};
if (candidate.iconImage === null) {
project.iconImage = null;
} else if (iconImage) {
const mime = typeof iconImage.mime === 'string' ? iconImage.mime.trim() : '';
const updatedAt = typeof iconImage.updatedAt === 'number' && Number.isFinite(iconImage.updatedAt)
? Math.max(0, Math.round(iconImage.updatedAt))
: 0;
const source = iconImage.source === 'custom' || iconImage.source === 'auto'
? iconImage.source
: null;
if (mime && updatedAt > 0 && source) {
project.iconImage = { mime, updatedAt, source };
}
}
if (candidate.iconBackground === null) {
project.iconBackground = null;
}
if (typeof candidate.sidebarCollapsed === 'boolean') {
project.sidebarCollapsed = candidate.sidebarCollapsed;
}
result.push(project);
}
return result;
};
const normalizeSettingsPaths = (input) => {
const settings = input && typeof input === 'object' ? input : {};
let next = settings;
let changed = false;
const ensureNext = () => {
if (next === settings) {
next = { ...settings };
}
};
const normalizePathField = (key) => {
if (typeof settings[key] !== 'string' || settings[key].length === 0) {
return;
}
const normalized = normalizePathForPersistence(settings[key]);
if (normalized !== settings[key]) {
ensureNext();
next[key] = normalized;
changed = true;
}
};
const normalizePathArrayField = (key) => {
if (!Array.isArray(settings[key])) {
return;
}
const normalized = normalizeStringArray(
settings[key]
.map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry))
.filter((entry) => typeof entry === 'string' && entry.length > 0)
);
if (!areStringArraysEqual(normalized, settings[key])) {
ensureNext();
next[key] = normalized;
changed = true;
}
};
normalizePathField('lastDirectory');
normalizePathField('homeDirectory');
normalizePathArrayField('approvedDirectories');
normalizePathArrayField('pinnedDirectories');
if (Array.isArray(settings.projects)) {
const normalizedProjects = sanitizeProjects(settings.projects) || [];
if (JSON.stringify(normalizedProjects) !== JSON.stringify(settings.projects)) {
ensureNext();
next.projects = normalizedProjects;
changed = true;
}
}
return { settings: next, changed };
};
const clampNumber = (value, min, max) => Math.max(min, Math.min(max, value));
const normalizeTunnelBootstrapTtlMs = (value) => {
if (value === null) {
return null;
}
if (!Number.isFinite(value)) {
return tunnelBootstrapTtlDefaultMs;
}
return clampNumber(Math.round(value), tunnelBootstrapTtlMinMs, tunnelBootstrapTtlMaxMs);
};
const normalizeTunnelSessionTtlMs = (value) => {
if (!Number.isFinite(value)) {
return tunnelSessionTtlDefaultMs;
}
return clampNumber(Math.round(value), tunnelSessionTtlMinMs, tunnelSessionTtlMaxMs);
};
const normalizeManagedRemoteTunnelHostname = (value) => {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const parsed = (() => {
try {
if (trimmed.includes('://')) {
return new URL(trimmed);
}
return new URL(`https://${trimmed}`);
} catch {
return null;
}
})();
const hostname = parsed?.hostname?.trim().toLowerCase() || '';
if (!hostname) {
return undefined;
}
return hostname;
};
const normalizeManagedRemoteTunnelPresets = (value) => {
if (!Array.isArray(value)) {
return undefined;
}
const result = [];
const seenIds = new Set();
const seenHostnames = new Set();
for (const entry of value) {
if (!entry || typeof entry !== 'object') continue;
const candidate = entry;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const name = typeof candidate.name === 'string' ? candidate.name.trim() : '';
const hostname = normalizeManagedRemoteTunnelHostname(candidate.hostname);
if (!id || !name || !hostname) continue;
if (seenIds.has(id) || seenHostnames.has(hostname)) continue;
seenIds.add(id);
seenHostnames.add(hostname);
result.push({ id, name, hostname });
}
return result;
};
const normalizeManagedRemoteTunnelPresetTokens = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const result = {};
for (const [rawId, rawToken] of Object.entries(value)) {
const id = typeof rawId === 'string' ? rawId.trim() : '';
const token = typeof rawToken === 'string' ? rawToken.trim() : '';
if (!id || !token) {
continue;
}
result[id] = token;
}
return Object.keys(result).length > 0 ? result : undefined;
};
const isUnsafeSkillRelativePath = (value) => {
if (typeof value !== 'string' || value.length === 0) {
return true;
}
const normalized = value.replace(/\\/g, '/');
if (path.posix.isAbsolute(normalized)) {
return true;
}
return normalized.split('/').some((segment) => segment === '..');
};
const sanitizeTypographySizesPartial = (input) => {
if (!input || typeof input !== 'object') {
return undefined;
}
const candidate = input;
const result = {};
let populated = false;
const assign = (key) => {
if (typeof candidate[key] === 'string' && candidate[key].length > 0) {
result[key] = candidate[key];
populated = true;
}
};
assign('markdown');
assign('code');
assign('uiHeader');
assign('uiLabel');
assign('meta');
assign('micro');
return populated ? result : undefined;
};
const sanitizeModelRefs = (input, limit) => {
if (!Array.isArray(input)) {
return undefined;
}
const result = [];
const seen = new Set();
for (const entry of input) {
if (!entry || typeof entry !== 'object') continue;
const providerID = typeof entry.providerID === 'string' ? entry.providerID.trim() : '';
const modelID = typeof entry.modelID === 'string' ? entry.modelID.trim() : '';
if (!providerID || !modelID) continue;
const key = `${providerID}/${modelID}`;
if (seen.has(key)) continue;
seen.add(key);
result.push({ providerID, modelID });
if (result.length >= limit) break;
}
return result;
};
const sanitizeSkillCatalogs = (input) => {
if (!Array.isArray(input)) {
return undefined;
}
const result = [];
const seen = new Set();
for (const entry of input) {
if (!entry || typeof entry !== 'object') continue;
const id = typeof entry.id === 'string' ? entry.id.trim() : '';
const label = typeof entry.label === 'string' ? entry.label.trim() : '';
const source = typeof entry.source === 'string' ? entry.source.trim() : '';
const subpath = typeof entry.subpath === 'string' ? entry.subpath.trim() : '';
const gitIdentityId = typeof entry.gitIdentityId === 'string' ? entry.gitIdentityId.trim() : '';
if (!id || !label || !source) continue;
if (seen.has(id)) continue;
seen.add(id);
result.push({
id,
label,
source,
...(subpath ? { subpath } : {}),
...(gitIdentityId ? { gitIdentityId } : {}),
});
}
return result;
};
return {
normalizeDirectoryPath,
normalizePathForPersistence,
normalizeSettingsPaths,
normalizeTunnelBootstrapTtlMs,
normalizeTunnelSessionTtlMs,
normalizeManagedRemoteTunnelHostname,
normalizeManagedRemoteTunnelPresets,
normalizeManagedRemoteTunnelPresetTokens,
isUnsafeSkillRelativePath,
sanitizeTypographySizesPartial,
normalizeStringArray,
sanitizeModelRefs,
sanitizeSkillCatalogs,
sanitizeProjects,
};
};
@@ -0,0 +1,439 @@
const DEFAULT_NOTIFICATION_TEMPLATES = {
completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
error: { title: 'Tool error', message: '{last_message}' },
question: { title: 'Input needed', message: '{last_message}' },
subtask: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
};
const ensureNotificationTemplateShape = (templates) => {
const input = templates && typeof templates === 'object' ? templates : {};
let changed = false;
const next = {};
for (const event of Object.keys(DEFAULT_NOTIFICATION_TEMPLATES)) {
const currentEntry = input[event];
const base = DEFAULT_NOTIFICATION_TEMPLATES[event];
const currentTitle = typeof currentEntry?.title === 'string' ? currentEntry.title : base.title;
const currentMessage = typeof currentEntry?.message === 'string' ? currentEntry.message : base.message;
if (!currentEntry || typeof currentEntry.title !== 'string' || typeof currentEntry.message !== 'string') {
changed = true;
}
next[event] = { title: currentTitle, message: currentMessage };
}
return { templates: next, changed };
};
export const createSettingsRuntime = (deps) => {
const {
fsPromises,
path,
crypto,
SETTINGS_FILE_PATH,
sanitizeProjects,
sanitizeSettingsUpdate,
mergePersistedSettings,
normalizeSettingsPaths,
normalizeStringArray,
formatSettingsResponse,
resolveDirectoryCandidate,
normalizeManagedRemoteTunnelHostname,
normalizeManagedRemoteTunnelPresets,
normalizeManagedRemoteTunnelPresetTokens,
syncManagedRemoteTunnelConfigWithPresets,
upsertManagedRemoteTunnelToken,
} = deps;
let persistSettingsLock = Promise.resolve();
const readSettingsFromDisk = async () => {
try {
const raw = await fsPromises.readFile(SETTINGS_FILE_PATH, 'utf8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return parsed;
}
return {};
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return {};
}
console.warn('Failed to read settings file:', error);
return {};
}
};
const writeSettingsToDisk = async (settings) => {
try {
await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true });
await fsPromises.writeFile(SETTINGS_FILE_PATH, JSON.stringify(settings, null, 2), 'utf8');
} catch (error) {
console.warn('Failed to write settings file:', error);
throw error;
}
};
const validateProjectEntries = async (projects) => {
console.log(`[validateProjectEntries] Starting validation for ${projects.length} projects`);
if (!Array.isArray(projects)) {
console.warn('[validateProjectEntries] Input is not an array, returning empty');
return [];
}
const validations = projects.map(async (project) => {
if (!project || typeof project.path !== 'string' || project.path.length === 0) {
console.error('[validateProjectEntries] Invalid project entry: missing or empty path', project);
return null;
}
try {
const stats = await fsPromises.stat(project.path);
if (!stats.isDirectory()) {
console.error(`[validateProjectEntries] Project path is not a directory: ${project.path}`);
return null;
}
return project;
} catch (error) {
const err = error;
console.error(`[validateProjectEntries] Failed to validate project "${project.path}": ${err.code || err.message || err}`);
if (err && typeof err === 'object' && err.code === 'ENOENT') {
console.log(`[validateProjectEntries] Removing project with ENOENT: ${project.path}`);
return null;
}
console.log(`[validateProjectEntries] Keeping project despite non-ENOENT error: ${project.path}`);
return project;
}
});
const results = (await Promise.all(validations)).filter((p) => p !== null);
console.log(`[validateProjectEntries] Validation complete: ${results.length}/${projects.length} projects valid`);
return results;
};
const migrateSettingsFromLegacyLastDirectory = async (current) => {
const settings = current && typeof current === 'object' ? current : {};
const now = Date.now();
const sanitizedProjects = sanitizeProjects(settings.projects) || [];
let nextProjects = sanitizedProjects;
let nextActiveProjectId =
typeof settings.activeProjectId === 'string' ? settings.activeProjectId : undefined;
let changed = false;
if (nextProjects.length === 0) {
const legacy = typeof settings.lastDirectory === 'string' ? settings.lastDirectory.trim() : '';
const candidate = legacy ? resolveDirectoryCandidate(legacy) : null;
if (candidate) {
try {
const stats = await fsPromises.stat(candidate);
if (stats.isDirectory()) {
const id = crypto.randomUUID();
nextProjects = [
{
id,
path: candidate,
addedAt: now,
lastOpenedAt: now,
},
];
nextActiveProjectId = id;
changed = true;
}
} catch {
// ignore invalid lastDirectory
}
}
}
if (nextProjects.length > 0) {
const active = nextProjects.find((project) => project.id === nextActiveProjectId) || null;
if (!active) {
nextActiveProjectId = nextProjects[0].id;
changed = true;
}
} else if (nextActiveProjectId) {
nextActiveProjectId = undefined;
changed = true;
}
if (!changed) {
return { settings, changed: false };
}
const merged = mergePersistedSettings(settings, {
...settings,
projects: nextProjects,
...(nextActiveProjectId ? { activeProjectId: nextActiveProjectId } : { activeProjectId: undefined }),
});
return { settings: merged, changed: true };
};
const migrateSettingsFromLegacyThemePreferences = async (current) => {
const settings = current && typeof current === 'object' ? current : {};
const themeId = typeof settings.themeId === 'string' ? settings.themeId.trim() : '';
const themeVariant = typeof settings.themeVariant === 'string' ? settings.themeVariant.trim() : '';
const hasLight = typeof settings.lightThemeId === 'string' && settings.lightThemeId.trim().length > 0;
const hasDark = typeof settings.darkThemeId === 'string' && settings.darkThemeId.trim().length > 0;
if (hasLight && hasDark) {
return { settings, changed: false };
}
const defaultLight = 'flexoki-light';
const defaultDark = 'flexoki-dark';
let nextLightThemeId = hasLight ? settings.lightThemeId : undefined;
let nextDarkThemeId = hasDark ? settings.darkThemeId : undefined;
if (!hasLight) {
if (themeId && themeVariant === 'light') {
nextLightThemeId = themeId;
} else {
nextLightThemeId = defaultLight;
}
}
if (!hasDark) {
if (themeId && themeVariant === 'dark') {
nextDarkThemeId = themeId;
} else {
nextDarkThemeId = defaultDark;
}
}
const merged = mergePersistedSettings(settings, {
...settings,
...(nextLightThemeId ? { lightThemeId: nextLightThemeId } : {}),
...(nextDarkThemeId ? { darkThemeId: nextDarkThemeId } : {}),
});
return { settings: merged, changed: true };
};
const migrateSettingsFromLegacyCollapsedProjects = async (current) => {
const settings = current && typeof current === 'object' ? current : {};
const collapsed = Array.isArray(settings.collapsedProjects)
? normalizeStringArray(settings.collapsedProjects)
: [];
if (collapsed.length === 0 || !Array.isArray(settings.projects)) {
if (collapsed.length === 0) {
return { settings, changed: false };
}
const next = { ...settings };
delete next.collapsedProjects;
return { settings: next, changed: true };
}
const set = new Set(collapsed);
const projects = sanitizeProjects(settings.projects) || [];
let changed = false;
const nextProjects = projects.map((project) => {
const shouldCollapse = set.has(project.id);
if (project.sidebarCollapsed !== shouldCollapse) {
changed = true;
return { ...project, sidebarCollapsed: shouldCollapse };
}
return project;
});
if (!changed) {
if (Object.prototype.hasOwnProperty.call(settings, 'collapsedProjects')) {
const next = { ...settings };
delete next.collapsedProjects;
return { settings: next, changed: true };
}
return { settings, changed: false };
}
const next = { ...settings, projects: nextProjects };
delete next.collapsedProjects;
return { settings: next, changed: true };
};
const migrateSettingsNotificationDefaults = async (current) => {
const settings = current && typeof current === 'object' ? current : {};
let changed = false;
const next = { ...settings };
if (typeof settings.notifyOnSubtasks !== 'boolean') {
next.notifyOnSubtasks = true;
changed = true;
}
if (typeof settings.notifyOnCompletion !== 'boolean') {
next.notifyOnCompletion = true;
changed = true;
}
if (typeof settings.notifyOnError !== 'boolean') {
next.notifyOnError = true;
changed = true;
}
if (typeof settings.notifyOnQuestion !== 'boolean') {
next.notifyOnQuestion = true;
changed = true;
}
const { templates, changed: templatesChanged } = ensureNotificationTemplateShape(settings.notificationTemplates);
if (templatesChanged || !settings.notificationTemplates || typeof settings.notificationTemplates !== 'object') {
next.notificationTemplates = templates;
changed = true;
}
return { settings: changed ? next : settings, changed };
};
const migrateSettingsFromLegacyNamedTunnelKeys = async (current) => {
const settings = current && typeof current === 'object' ? current : {};
const next = { ...settings };
let changed = false;
if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelHostname')
&& Object.prototype.hasOwnProperty.call(next, 'namedTunnelHostname')) {
next.managedRemoteTunnelHostname = normalizeManagedRemoteTunnelHostname(next.namedTunnelHostname);
changed = true;
}
if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelToken')
&& Object.prototype.hasOwnProperty.call(next, 'namedTunnelToken')) {
if (next.namedTunnelToken === null) {
next.managedRemoteTunnelToken = null;
} else if (typeof next.namedTunnelToken === 'string') {
next.managedRemoteTunnelToken = next.namedTunnelToken.trim();
}
changed = true;
}
if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelPresets')
&& Object.prototype.hasOwnProperty.call(next, 'namedTunnelPresets')) {
next.managedRemoteTunnelPresets = normalizeManagedRemoteTunnelPresets(next.namedTunnelPresets);
changed = true;
}
if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelPresetTokens')
&& Object.prototype.hasOwnProperty.call(next, 'namedTunnelPresetTokens')) {
next.managedRemoteTunnelPresetTokens = normalizeManagedRemoteTunnelPresetTokens(next.namedTunnelPresetTokens);
changed = true;
}
if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelSelectedPresetId')
&& Object.prototype.hasOwnProperty.call(next, 'namedTunnelSelectedPresetId')) {
const selectedPresetId = typeof next.namedTunnelSelectedPresetId === 'string'
? next.namedTunnelSelectedPresetId.trim()
: '';
if (selectedPresetId) {
next.managedRemoteTunnelSelectedPresetId = selectedPresetId;
}
changed = true;
}
const legacyKeys = [
'namedTunnelHostname',
'namedTunnelToken',
'namedTunnelPresets',
'namedTunnelPresetTokens',
'namedTunnelSelectedPresetId',
];
for (const key of legacyKeys) {
if (Object.prototype.hasOwnProperty.call(next, key)) {
delete next[key];
changed = true;
}
}
return { settings: changed ? next : settings, changed };
};
const readSettingsFromDiskMigrated = async () => {
const current = await readSettingsFromDisk();
const migration1 = await migrateSettingsFromLegacyLastDirectory(current);
const migration2 = await migrateSettingsFromLegacyThemePreferences(migration1.settings);
const migration3 = await migrateSettingsFromLegacyCollapsedProjects(migration2.settings);
const migration4 = await migrateSettingsNotificationDefaults(migration3.settings);
const migration5 = await migrateSettingsFromLegacyNamedTunnelKeys(migration4.settings);
const migration6 = normalizeSettingsPaths(migration5.settings);
if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed) {
await writeSettingsToDisk(migration6.settings);
}
return migration6.settings;
};
const persistSettings = async (changes) => {
persistSettingsLock = persistSettingsLock.then(async () => {
console.log('[persistSettings] Called with changes:', JSON.stringify(changes, null, 2));
const current = await readSettingsFromDisk();
console.log('[persistSettings] Current projects count:', Array.isArray(current.projects) ? current.projects.length : 'N/A');
const sanitized = sanitizeSettingsUpdate(changes);
let next = mergePersistedSettings(current, sanitized);
const normalizedState = normalizeSettingsPaths(next);
if (normalizedState.changed) {
next = normalizedState.settings;
}
if (Array.isArray(next.projects)) {
console.log(`[persistSettings] Validating ${next.projects.length} projects...`);
const validated = await validateProjectEntries(next.projects);
console.log(`[persistSettings] After validation: ${validated.length} projects remain`);
next = { ...next, projects: validated };
}
if (Array.isArray(next.projects) && next.projects.length > 0) {
const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : '';
const active = next.projects.find((project) => project.id === activeId) || null;
if (!active) {
console.log(`[persistSettings] Active project ID ${activeId} not found, switching to ${next.projects[0].id}`);
next = { ...next, activeProjectId: next.projects[0].id };
}
} else if (next.activeProjectId) {
console.log(`[persistSettings] No projects found, clearing activeProjectId ${next.activeProjectId}`);
next = { ...next, activeProjectId: undefined };
}
if (Object.prototype.hasOwnProperty.call(sanitized, 'managedRemoteTunnelPresets')) {
await syncManagedRemoteTunnelConfigWithPresets(next.managedRemoteTunnelPresets);
}
if (Object.prototype.hasOwnProperty.call(sanitized, 'managedRemoteTunnelPresetTokens') && sanitized.managedRemoteTunnelPresetTokens) {
const presetsById = new Map((next.managedRemoteTunnelPresets || []).map((entry) => [entry.id, entry]));
const updates = Object.entries(sanitized.managedRemoteTunnelPresetTokens)
.map(([presetId, token]) => {
const preset = presetsById.get(presetId);
if (!preset || typeof token !== 'string' || token.trim().length === 0) {
return null;
}
return {
id: preset.id,
name: preset.name,
hostname: preset.hostname,
token: token.trim(),
};
})
.filter(Boolean);
for (const update of updates) {
await upsertManagedRemoteTunnelToken(update);
}
}
await writeSettingsToDisk(next);
console.log(`[persistSettings] Successfully saved ${next.projects?.length || 0} projects to disk`);
return formatSettingsResponse(next);
});
return persistSettingsLock;
};
return {
readSettingsFromDisk,
readSettingsFromDiskMigrated,
writeSettingsToDisk,
persistSettings,
};
};
@@ -0,0 +1,114 @@
export const createGracefulShutdownRuntime = (dependencies) => {
const {
process,
shutdownTimeoutMs,
getExitOnShutdown,
getIsShuttingDown,
setIsShuttingDown,
syncToHmrState,
openCodeWatcherRuntime,
sessionRuntime,
getHealthCheckInterval,
clearHealthCheckInterval,
getTerminalRuntime,
setTerminalRuntime,
shouldSkipOpenCodeStop,
getOpenCodePort,
getOpenCodeProcess,
setOpenCodeProcess,
killProcessOnPort,
getServer,
getUiAuthController,
setUiAuthController,
getActiveTunnelController,
setActiveTunnelController,
tunnelAuthController,
} = dependencies;
const gracefulShutdown = async (options = {}) => {
if (getIsShuttingDown()) return;
setIsShuttingDown(true);
syncToHmrState();
console.log('Starting graceful shutdown...');
const exitProcess = typeof options.exitProcess === 'boolean' ? options.exitProcess : getExitOnShutdown();
openCodeWatcherRuntime.stop();
sessionRuntime.dispose();
const healthCheckInterval = getHealthCheckInterval();
if (healthCheckInterval) {
clearHealthCheckInterval(healthCheckInterval);
}
const terminalRuntime = getTerminalRuntime();
if (terminalRuntime) {
try {
await terminalRuntime.shutdown();
} catch {
} finally {
setTerminalRuntime(null);
}
}
if (!shouldSkipOpenCodeStop()) {
const portToKill = getOpenCodePort();
const openCodeProcess = getOpenCodeProcess();
if (openCodeProcess) {
console.log('Stopping OpenCode process...');
try {
openCodeProcess.close();
} catch (error) {
console.warn('Error closing OpenCode process:', error);
}
setOpenCodeProcess(null);
}
killProcessOnPort(portToKill);
} else {
console.log('Skipping OpenCode shutdown (external server)');
}
const server = getServer();
if (server) {
await Promise.race([
new Promise((resolve) => {
server.close(() => {
console.log('HTTP server closed');
resolve();
});
}),
new Promise((resolve) => {
setTimeout(() => {
console.warn('Server close timeout reached, forcing shutdown');
resolve();
}, shutdownTimeoutMs);
}),
]);
}
const uiAuthController = getUiAuthController();
if (uiAuthController) {
uiAuthController.dispose();
setUiAuthController(null);
}
const activeTunnelController = getActiveTunnelController();
if (activeTunnelController) {
console.log('Stopping active tunnel...');
activeTunnelController.stop();
setActiveTunnelController(null);
tunnelAuthController.clearActiveTunnel();
}
console.log('Graceful shutdown complete');
if (exitProcess) {
process.exit(0);
}
};
return {
gracefulShutdown,
};
};
@@ -0,0 +1,707 @@
export const registerSkillRoutes = (app, dependencies) => {
const {
fs,
path,
os,
resolveProjectDirectory,
resolveOptionalProjectDirectory,
readSettingsFromDisk,
sanitizeSkillCatalogs,
isUnsafeSkillRelativePath,
refreshOpenCodeAfterConfigChange,
clientReloadDelayMs,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
getOpenCodePort,
getSkillSources,
discoverSkills,
createSkill,
updateSkill,
deleteSkill,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
SKILL_SCOPE,
SKILL_DIR,
getCuratedSkillsSources,
getCacheKey,
getCachedScan,
setCachedScan,
parseSkillRepoSource,
scanSkillsRepository,
installSkillsFromRepository,
scanClawdHubPage,
installSkillsFromClawdHub,
isClawdHubSource,
getProfiles,
getProfile,
} = dependencies;
const findWorktreeRootForSkills = (workingDirectory) => {
if (!workingDirectory) return null;
let current = path.resolve(workingDirectory);
while (true) {
if (fs.existsSync(path.join(current, '.git'))) {
return current;
}
const parent = path.dirname(current);
if (parent === current) {
return null;
}
current = parent;
}
};
const getSkillProjectAncestors = (workingDirectory) => {
if (!workingDirectory) return [];
const result = [];
let current = path.resolve(workingDirectory);
const stop = findWorktreeRootForSkills(workingDirectory) || current;
while (true) {
result.push(current);
if (current === stop) break;
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return result;
};
const isPathInside = (candidatePath, parentPath) => {
if (!candidatePath || !parentPath) return false;
const normalizedCandidate = path.resolve(candidatePath);
const normalizedParent = path.resolve(parentPath);
return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`);
};
const inferSkillScopeAndSourceFromPath = (skillPath, workingDirectory) => {
const resolvedPath = typeof skillPath === 'string' ? path.resolve(skillPath) : '';
const home = os.homedir();
const source = resolvedPath.includes(`${path.sep}.agents${path.sep}skills${path.sep}`)
? 'agents'
: resolvedPath.includes(`${path.sep}.claude${path.sep}skills${path.sep}`)
? 'claude'
: 'opencode';
const projectAncestors = getSkillProjectAncestors(workingDirectory);
const isProjectScoped = projectAncestors.some((ancestor) => {
const candidates = [
path.join(ancestor, '.opencode'),
path.join(ancestor, '.claude', 'skills'),
path.join(ancestor, '.agents', 'skills'),
];
return candidates.some((candidate) => isPathInside(resolvedPath, candidate));
});
if (isProjectScoped) {
return { scope: SKILL_SCOPE.PROJECT, source };
}
const userRoots = [
path.join(home, '.config', 'opencode'),
path.join(home, '.opencode'),
path.join(home, '.claude', 'skills'),
path.join(home, '.agents', 'skills'),
process.env.OPENCODE_CONFIG_DIR ? path.resolve(process.env.OPENCODE_CONFIG_DIR) : null,
].filter(Boolean);
if (userRoots.some((root) => isPathInside(resolvedPath, root))) {
return { scope: SKILL_SCOPE.USER, source };
}
return { scope: SKILL_SCOPE.USER, source };
};
const fetchOpenCodeDiscoveredSkills = async (workingDirectory) => {
if (!getOpenCodePort()) {
return null;
}
try {
const url = new URL(buildOpenCodeUrl('/skill', ''));
if (workingDirectory) {
url.searchParams.set('directory', workingDirectory);
}
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) {
return null;
}
const payload = await response.json();
if (!Array.isArray(payload)) {
return null;
}
return payload
.map((item) => {
const name = typeof item?.name === 'string' ? item.name.trim() : '';
const location = typeof item?.location === 'string' ? item.location : '';
const description = typeof item?.description === 'string' ? item.description : '';
if (!name || !location) {
return null;
}
const inferred = inferSkillScopeAndSourceFromPath(location, workingDirectory);
return {
name,
path: location,
scope: inferred.scope,
source: inferred.source,
description,
};
})
.filter(Boolean);
} catch {
return null;
}
};
const listGitIdentitiesForResponse = () => {
try {
const profiles = getProfiles();
return profiles.map((p) => ({ id: p.id, name: p.name }));
} catch {
return [];
}
};
const resolveGitIdentity = (profileId) => {
if (!profileId) {
return null;
}
try {
const profile = getProfile(profileId);
const sshKey = profile?.sshKey;
if (typeof sshKey === 'string' && sshKey.trim()) {
return { sshKey: sshKey.trim() };
}
} catch {
// ignore
}
return null;
};
app.get('/api/config/skills', async (req, res) => {
try {
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
const skills = (await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory);
const enrichedSkills = skills.map((skill) => {
const sources = getSkillSources(skill.name, directory, skill);
return {
...skill,
sources
};
});
res.json({ skills: enrichedSkills });
} catch (error) {
console.error('Failed to list skills:', error);
res.status(500).json({ error: 'Failed to list skills' });
}
});
app.get('/api/config/skills/catalog', async (req, res) => {
try {
const { error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
const curatedSources = getCuratedSkillsSources();
const settings = await readSettingsFromDisk();
const customSourcesRaw = sanitizeSkillCatalogs(settings.skillCatalogs) || [];
const customSources = customSourcesRaw.map((entry) => ({
id: entry.id,
label: entry.label,
description: entry.source,
source: entry.source,
defaultSubpath: entry.subpath,
gitIdentityId: entry.gitIdentityId,
}));
const sources = [...curatedSources, ...customSources];
const sourcesForUi = sources.map(({ gitIdentityId, ...rest }) => rest);
res.json({ ok: true, sources: sourcesForUi, itemsBySource: {}, pageInfoBySource: {} });
} catch (error) {
console.error('Failed to load skills catalog:', error);
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to load catalog' } });
}
});
app.get('/api/config/skills/catalog/source', async (req, res) => {
try {
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: error } });
}
const sourceId = typeof req.query.sourceId === 'string' ? req.query.sourceId : null;
if (!sourceId) {
return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: 'Missing sourceId' } });
}
const refresh = String(req.query.refresh || '').toLowerCase() === 'true';
const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : null;
const curatedSources = getCuratedSkillsSources();
const settings = await readSettingsFromDisk();
const customSourcesRaw = sanitizeSkillCatalogs(settings.skillCatalogs) || [];
const customSources = customSourcesRaw.map((entry) => ({
id: entry.id,
label: entry.label,
description: entry.source,
source: entry.source,
defaultSubpath: entry.subpath,
gitIdentityId: entry.gitIdentityId,
}));
const sources = [...curatedSources, ...customSources];
const src = sources.find((entry) => entry.id === sourceId);
if (!src) {
return res.status(404).json({ ok: false, error: { kind: 'invalidSource', message: 'Unknown source' } });
}
const discovered = directory
? ((await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory))
: [];
const installedByName = new Map(discovered.map((s) => [s.name, s]));
if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) {
const scanned = await scanClawdHubPage({ cursor: cursor || null });
if (!scanned.ok) {
return res.status(500).json({ ok: false, error: scanned.error });
}
const items = (scanned.items || []).map((item) => {
const installed = installedByName.get(item.skillName);
return {
...item,
sourceId: src.id,
installed: installed
? { isInstalled: true, scope: installed.scope, source: installed.source }
: { isInstalled: false },
};
});
return res.json({ ok: true, items, nextCursor: scanned.nextCursor || null });
}
const parsed = parseSkillRepoSource(src.source);
if (!parsed.ok) {
return res.status(400).json({ ok: false, error: parsed.error });
}
const effectiveSubpath = src.defaultSubpath || parsed.effectiveSubpath || null;
const cacheKey = getCacheKey({
normalizedRepo: parsed.normalizedRepo,
subpath: effectiveSubpath || '',
identityId: src.gitIdentityId || '',
});
let scanResult = !refresh ? getCachedScan(cacheKey) : null;
if (!scanResult) {
const scanned = await scanSkillsRepository({
source: src.source,
subpath: src.defaultSubpath,
defaultSubpath: src.defaultSubpath,
identity: resolveGitIdentity(src.gitIdentityId),
});
if (!scanned.ok) {
return res.status(500).json({ ok: false, error: scanned.error });
}
scanResult = scanned;
setCachedScan(cacheKey, scanResult);
}
const items = (scanResult.items || []).map((item) => {
const installed = installedByName.get(item.skillName);
return {
sourceId: src.id,
...item,
gitIdentityId: src.gitIdentityId,
installed: installed
? { isInstalled: true, scope: installed.scope, source: installed.source }
: { isInstalled: false },
};
});
return res.json({ ok: true, items });
} catch (error) {
console.error('Failed to load catalog source:', error);
return res.status(500).json({
ok: false,
error: { kind: 'unknown', message: error.message || 'Failed to load catalog source' },
});
}
});
app.post('/api/config/skills/scan', async (req, res) => {
try {
const { source, subpath, gitIdentityId } = req.body || {};
const identity = resolveGitIdentity(gitIdentityId);
const result = await scanSkillsRepository({
source,
subpath,
identity,
});
if (!result.ok) {
if (result.error?.kind === 'authRequired') {
return res.status(401).json({
ok: false,
error: {
...result.error,
identities: listGitIdentitiesForResponse(),
},
});
}
return res.status(400).json({ ok: false, error: result.error });
}
res.json({ ok: true, items: result.items });
} catch (error) {
console.error('Failed to scan skills repository:', error);
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to scan repository' } });
}
});
app.post('/api/config/skills/install', async (req, res) => {
try {
const {
source,
subpath,
gitIdentityId,
scope,
targetSource,
selections,
conflictPolicy,
conflictDecisions,
} = req.body || {};
let workingDirectory = null;
if (scope === 'project') {
const resolved = await resolveProjectDirectory(req);
if (!resolved.directory) {
return res.status(400).json({
ok: false,
error: { kind: 'invalidSource', message: resolved.error || 'Project installs require a directory parameter' },
});
}
workingDirectory = resolved.directory;
}
if (isClawdHubSource(source)) {
const result = await installSkillsFromClawdHub({
scope,
targetSource,
workingDirectory,
userSkillDir: SKILL_DIR,
selections,
conflictPolicy,
conflictDecisions,
});
if (!result.ok) {
if (result.error?.kind === 'conflicts') {
return res.status(409).json({ ok: false, error: result.error });
}
return res.status(400).json({ ok: false, error: result.error });
}
const installed = result.installed || [];
const skipped = result.skipped || [];
const requiresReload = installed.length > 0;
if (requiresReload) {
await refreshOpenCodeAfterConfigChange('skills install');
}
return res.json({
ok: true,
installed,
skipped,
requiresReload,
message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed',
reloadDelayMs: requiresReload ? clientReloadDelayMs : undefined,
});
}
const identity = resolveGitIdentity(gitIdentityId);
const result = await installSkillsFromRepository({
source,
subpath,
identity,
scope,
targetSource,
workingDirectory,
userSkillDir: SKILL_DIR,
selections,
conflictPolicy,
conflictDecisions,
});
if (!result.ok) {
if (result.error?.kind === 'conflicts') {
return res.status(409).json({ ok: false, error: result.error });
}
if (result.error?.kind === 'authRequired') {
return res.status(401).json({
ok: false,
error: {
...result.error,
identities: listGitIdentitiesForResponse(),
},
});
}
return res.status(400).json({ ok: false, error: result.error });
}
const installed = result.installed || [];
const skipped = result.skipped || [];
const requiresReload = installed.length > 0;
if (requiresReload) {
await refreshOpenCodeAfterConfigChange('skills install');
}
res.json({
ok: true,
installed,
skipped,
requiresReload,
message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed',
reloadDelayMs: requiresReload ? clientReloadDelayMs : undefined,
});
} catch (error) {
console.error('Failed to install skills:', error);
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to install skills' } });
}
});
app.get('/api/config/skills/:name', async (req, res) => {
try {
const skillName = req.params.name;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
.find((skill) => skill.name === skillName) || null;
const sources = getSkillSources(skillName, directory, discoveredSkill);
res.json({
name: skillName,
sources: sources,
scope: sources.md.scope,
source: sources.md.source,
exists: sources.md.exists
});
} catch (error) {
console.error('Failed to get skill sources:', error);
res.status(500).json({ error: 'Failed to get skill configuration metadata' });
}
});
app.get('/api/config/skills/:name/files/*filePath', async (req, res) => {
try {
const skillName = req.params.name;
const filePath = decodeURIComponent(req.params.filePath);
if (isUnsafeSkillRelativePath(filePath)) {
return res.status(400).json({ error: 'Invalid file path' });
}
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
.find((skill) => skill.name === skillName) || null;
const sources = getSkillSources(skillName, directory, discoveredSkill);
if (!sources.md.exists || !sources.md.dir) {
return res.status(404).json({ error: 'Skill not found' });
}
const content = readSkillSupportingFile(sources.md.dir, filePath);
if (content === null) {
return res.status(404).json({ error: 'File not found' });
}
res.json({ path: filePath, content });
} catch (error) {
if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) {
return res.status(403).json({ error: 'Access to file denied' });
}
console.error('Failed to read skill file:', error);
res.status(500).json({ error: 'Failed to read skill file' });
}
});
app.post('/api/config/skills/:name', async (req, res) => {
try {
const skillName = req.params.name;
const { scope, source: skillSource, ...config } = req.body;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
console.log('[Server] Creating skill:', skillName);
console.log('[Server] Scope:', scope, 'Working directory:', directory);
createSkill(skillName, { ...config, source: skillSource }, directory, scope);
await refreshOpenCodeAfterConfigChange('skill creation');
res.json({
success: true,
requiresReload: true,
message: `Skill ${skillName} created successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
} catch (error) {
console.error('Failed to create skill:', error);
res.status(500).json({ error: error.message || 'Failed to create skill' });
}
});
app.patch('/api/config/skills/:name', async (req, res) => {
try {
const skillName = req.params.name;
const updates = req.body;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
console.log(`[Server] Updating skill: ${skillName}`);
console.log('[Server] Working directory:', directory);
updateSkill(skillName, updates, directory);
await refreshOpenCodeAfterConfigChange('skill update');
res.json({
success: true,
requiresReload: true,
message: `Skill ${skillName} updated successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
} catch (error) {
console.error('[Server] Failed to update skill:', error);
res.status(500).json({ error: error.message || 'Failed to update skill' });
}
});
app.put('/api/config/skills/:name/files/*filePath', async (req, res) => {
try {
const skillName = req.params.name;
const filePath = decodeURIComponent(req.params.filePath);
if (isUnsafeSkillRelativePath(filePath)) {
return res.status(400).json({ error: 'Invalid file path' });
}
const { content } = req.body;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
.find((skill) => skill.name === skillName) || null;
const sources = getSkillSources(skillName, directory, discoveredSkill);
if (!sources.md.exists || !sources.md.dir) {
return res.status(404).json({ error: 'Skill not found' });
}
writeSkillSupportingFile(sources.md.dir, filePath, content || '');
res.json({
success: true,
message: `File ${filePath} saved successfully`,
});
} catch (error) {
if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) {
return res.status(403).json({ error: 'Access to file denied' });
}
console.error('Failed to write skill file:', error);
res.status(500).json({ error: error.message || 'Failed to write skill file' });
}
});
app.delete('/api/config/skills/:name/files/*filePath', async (req, res) => {
try {
const skillName = req.params.name;
const filePath = decodeURIComponent(req.params.filePath);
if (isUnsafeSkillRelativePath(filePath)) {
return res.status(400).json({ error: 'Invalid file path' });
}
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
.find((skill) => skill.name === skillName) || null;
const sources = getSkillSources(skillName, directory, discoveredSkill);
if (!sources.md.exists || !sources.md.dir) {
return res.status(404).json({ error: 'Skill not found' });
}
deleteSkillSupportingFile(sources.md.dir, filePath);
res.json({
success: true,
message: `File ${filePath} deleted successfully`,
});
} catch (error) {
if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) {
return res.status(403).json({ error: 'Access to file denied' });
}
console.error('Failed to delete skill file:', error);
res.status(500).json({ error: error.message || 'Failed to delete skill file' });
}
});
app.delete('/api/config/skills/:name', async (req, res) => {
try {
const skillName = req.params.name;
const { directory, error } = await resolveProjectDirectory(req);
if (!directory) {
return res.status(400).json({ error });
}
deleteSkill(skillName, directory);
await refreshOpenCodeAfterConfigChange('skill deletion');
res.json({
success: true,
requiresReload: true,
message: `Skill ${skillName} deleted successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
} catch (error) {
console.error('Failed to delete skill:', error);
res.status(500).json({ error: error.message || 'Failed to delete skill' });
}
});
};
@@ -0,0 +1,107 @@
export const createStartupPipelineRuntime = (dependencies) => {
const {
createTerminalRuntime,
createServerStartupRuntime,
} = dependencies;
const run = async (options) => {
const {
app,
server,
express,
fs,
path,
uiAuthController,
buildAugmentedPath,
searchPathFor,
isExecutable,
isRequestOriginAllowed,
rejectWebSocketUpgrade,
terminalHeartbeatIntervalMs,
terminalRebindWindowMs,
terminalMaxRebindsPerWindow,
setupProxy,
scheduleOpenCodeApiDetection,
bootstrapOpenCodeAtStartup,
staticRoutesRuntime,
process,
crypto,
normalizeTunnelBootstrapTtlMs,
readSettingsFromDiskMigrated,
tunnelAuthController,
startTunnelWithNormalizedRequest,
gracefulShutdown,
getSignalsAttached,
setSignalsAttached,
syncToHmrState,
TUNNEL_MODE_QUICK,
TUNNEL_MODE_MANAGED_LOCAL,
TUNNEL_MODE_MANAGED_REMOTE,
host,
port,
startupTunnelRequest,
onTunnelReady,
tunnelRuntimeContext,
attachSignals,
} = options;
const terminalRuntime = createTerminalRuntime({
app,
server,
express,
fs,
path,
uiAuthController,
buildAugmentedPath,
searchPathFor,
isExecutable,
isRequestOriginAllowed,
rejectWebSocketUpgrade,
TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS: terminalHeartbeatIntervalMs,
TERMINAL_INPUT_WS_REBIND_WINDOW_MS: terminalRebindWindowMs,
TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW: terminalMaxRebindsPerWindow,
});
setupProxy(app);
scheduleOpenCodeApiDetection();
void bootstrapOpenCodeAtStartup();
staticRoutesRuntime.registerStaticRoutes(app);
const serverStartupRuntime = createServerStartupRuntime({
process,
crypto,
server,
normalizeTunnelBootstrapTtlMs,
readSettingsFromDiskMigrated,
tunnelAuthController,
startTunnelWithNormalizedRequest,
gracefulShutdown,
getSignalsAttached,
setSignalsAttached,
syncToHmrState,
TUNNEL_MODE_QUICK,
TUNNEL_MODE_MANAGED_LOCAL,
TUNNEL_MODE_MANAGED_REMOTE,
});
const bindHost = serverStartupRuntime.resolveBindHost(host);
const startupResult = await serverStartupRuntime.startListeningAndMaybeTunnel({
port,
bindHost,
startupTunnelRequest,
onTunnelReady,
});
tunnelRuntimeContext.setActivePort(startupResult.activePort);
serverStartupRuntime.attachProcessHandlers({ attachSignals });
return {
terminalRuntime,
};
};
return {
run,
};
};
@@ -0,0 +1,63 @@
import { registerPwaManifestRoute } from './pwa-manifest-routes.js';
export const createStaticRoutesRuntime = (dependencies) => {
const {
fs,
path,
process,
__dirname,
express,
resolveProjectDirectory,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
readSettingsFromDiskMigrated,
normalizePwaAppName,
} = dependencies;
const resolveDistPath = () => {
const env = typeof process.env.OPENCHAMBER_DIST_DIR === 'string' ? process.env.OPENCHAMBER_DIST_DIR.trim() : '';
if (env) {
return path.resolve(env);
}
return path.join(__dirname, '..', 'dist');
};
const registerStaticRoutes = (app) => {
const distPath = resolveDistPath();
if (fs.existsSync(distPath)) {
console.log(`Serving static files from ${distPath}`);
app.use(express.static(distPath, {
setHeaders(res, filePath) {
// Service workers should never be long-cached; iOS is especially sensitive.
if (typeof filePath === 'string' && filePath.endsWith(`${path.sep}sw.js`)) {
res.setHeader('Cache-Control', 'no-store');
}
},
}));
registerPwaManifestRoute(app, {
process,
resolveProjectDirectory,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
readSettingsFromDiskMigrated,
normalizePwaAppName,
});
app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => {
res.sendFile(path.join(distPath, 'index.html'));
});
return;
}
console.warn(`Warning: ${distPath} not found, static files will not be served`);
app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => {
res.status(404).send('Static files not found. Please build the application first.');
});
};
return {
registerStaticRoutes,
};
};
@@ -0,0 +1,167 @@
export const createThemeRuntime = (dependencies) => {
const {
fsPromises,
path,
themesDir,
maxThemeJsonBytes,
logger,
} = dependencies;
const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0;
const isValidThemeColor = (value) => isNonEmptyString(value);
const normalizeThemeJson = (raw) => {
if (!raw || typeof raw !== 'object') {
return null;
}
const metadata = raw.metadata && typeof raw.metadata === 'object' ? raw.metadata : null;
const colors = raw.colors && typeof raw.colors === 'object' ? raw.colors : null;
if (!metadata || !colors) {
return null;
}
const id = metadata.id;
const name = metadata.name;
const variant = metadata.variant;
if (!isNonEmptyString(id) || !isNonEmptyString(name) || (variant !== 'light' && variant !== 'dark')) {
return null;
}
const primary = colors.primary;
const surface = colors.surface;
const interactive = colors.interactive;
const status = colors.status;
const syntax = colors.syntax;
const syntaxBase = syntax && typeof syntax === 'object' ? syntax.base : null;
const syntaxHighlights = syntax && typeof syntax === 'object' ? syntax.highlights : null;
if (!primary || !surface || !interactive || !status || !syntaxBase || !syntaxHighlights) {
return null;
}
// Minimal fields required by CSSVariableGenerator and diff/syntax rendering.
const required = [
primary.base,
primary.foreground,
surface.background,
surface.foreground,
surface.muted,
surface.mutedForeground,
surface.elevated,
surface.elevatedForeground,
surface.subtle,
interactive.border,
interactive.selection,
interactive.selectionForeground,
interactive.focusRing,
interactive.hover,
status.error,
status.errorForeground,
status.errorBackground,
status.errorBorder,
status.warning,
status.warningForeground,
status.warningBackground,
status.warningBorder,
status.success,
status.successForeground,
status.successBackground,
status.successBorder,
status.info,
status.infoForeground,
status.infoBackground,
status.infoBorder,
syntaxBase.background,
syntaxBase.foreground,
syntaxBase.keyword,
syntaxBase.string,
syntaxBase.number,
syntaxBase.function,
syntaxBase.variable,
syntaxBase.type,
syntaxBase.comment,
syntaxBase.operator,
syntaxHighlights.diffAdded,
syntaxHighlights.diffRemoved,
syntaxHighlights.lineNumber,
];
if (!required.every(isValidThemeColor)) {
return null;
}
const tags = Array.isArray(metadata.tags)
? metadata.tags.filter((tag) => typeof tag === 'string' && tag.trim().length > 0)
: [];
return {
...raw,
metadata: {
...metadata,
id: id.trim(),
name: name.trim(),
description: typeof metadata.description === 'string' ? metadata.description : '',
version: typeof metadata.version === 'string' && metadata.version.trim().length > 0 ? metadata.version : '1.0.0',
variant,
tags,
},
};
};
const readCustomThemesFromDisk = async () => {
try {
const entries = await fsPromises.readdir(themesDir, { withFileTypes: true });
const themes = [];
const seen = new Set();
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.toLowerCase().endsWith('.json')) continue;
const filePath = path.join(themesDir, entry.name);
try {
const stat = await fsPromises.stat(filePath);
if (!stat.isFile()) continue;
if (stat.size > maxThemeJsonBytes) {
logger.warn(`[themes] Skip ${entry.name}: too large (${stat.size} bytes)`);
continue;
}
const rawText = await fsPromises.readFile(filePath, 'utf8');
const parsed = JSON.parse(rawText);
const normalized = normalizeThemeJson(parsed);
if (!normalized) {
logger.warn(`[themes] Skip ${entry.name}: invalid theme JSON`);
continue;
}
const id = normalized.metadata.id;
if (seen.has(id)) {
logger.warn(`[themes] Skip ${entry.name}: duplicate theme id "${id}"`);
continue;
}
seen.add(id);
themes.push(normalized);
} catch (error) {
logger.warn(`[themes] Failed to read ${entry.name}:`, error);
}
}
return themes;
} catch (error) {
// Missing dir is fine.
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return [];
}
logger.warn('[themes] Failed to list custom themes dir:', error);
return [];
}
};
return {
normalizeThemeJson,
readCustomThemesFromDisk,
};
};
@@ -0,0 +1,94 @@
import { printTunnelWarning } from '../cloudflare-tunnel.js';
import { createTunnelService } from '../tunnels/index.js';
import { createTunnelRoutesRuntime } from '../tunnels/routes.js';
export const createTunnelWiringRuntime = (dependencies) => {
const {
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,
setActiveTunnelController,
getRuntimeManagedRemoteTunnelHostname,
setRuntimeManagedRemoteTunnelHostname,
getRuntimeManagedRemoteTunnelToken,
setRuntimeManagedRemoteTunnelToken,
} = dependencies;
const initialize = (app, initialPort) => {
let activePort = initialPort;
const tunnelService = createTunnelService({
registry: tunnelProviderRegistry,
getController: getActiveTunnelController,
setController: setActiveTunnelController,
getActivePort: () => activePort,
onQuickTunnelWarning: () => {
printTunnelWarning();
},
});
const tunnelRoutesRuntime = createTunnelRoutesRuntime({
crypto,
URL,
tunnelService,
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,
getActivePort: () => activePort,
getRuntimeManagedRemoteTunnelHostname,
setRuntimeManagedRemoteTunnelHostname,
getRuntimeManagedRemoteTunnelToken,
setRuntimeManagedRemoteTunnelToken,
getActiveTunnelController,
setActiveTunnelController,
});
tunnelRoutesRuntime.registerRoutes(app);
return {
tunnelService,
startTunnelWithNormalizedRequest: (...args) => tunnelRoutesRuntime.startTunnelWithNormalizedRequest(...args),
getActivePort: () => activePort,
setActivePort: (value) => {
activePort = value;
},
};
};
return {
initialize,
};
};
+107
View File
@@ -0,0 +1,107 @@
export const createOpenCodeWatcherRuntime = (deps) => {
const {
waitForOpenCodePort,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
parseSseDataPayload,
onPayload,
} = deps;
let abortController = null;
const start = async () => {
if (abortController) {
return;
}
await waitForOpenCodePort();
abortController = new AbortController();
const signal = abortController.signal;
let attempt = 0;
const run = async () => {
while (!signal.aborted) {
attempt += 1;
let upstream;
let reader;
try {
const url = buildOpenCodeUrl('/global/event', '');
upstream = await fetch(url, {
headers: {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
...getOpenCodeAuthHeaders(),
},
signal,
});
if (!upstream.ok || !upstream.body) {
throw new Error(`bad status ${upstream.status}`);
}
console.log('[PushWatcher] connected');
const decoder = new TextDecoder();
reader = upstream.body.getReader();
let buffer = '';
while (!signal.aborted) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
let separatorIndex = buffer.indexOf('\n\n');
while (separatorIndex !== -1) {
const block = buffer.slice(0, separatorIndex);
buffer = buffer.slice(separatorIndex + 2);
separatorIndex = buffer.indexOf('\n\n');
const payload = parseSseDataPayload(block);
onPayload(payload);
}
}
} catch (error) {
if (signal.aborted) {
return;
}
console.warn('[PushWatcher] disconnected', error?.message ?? error);
} finally {
try {
if (reader) {
await reader.cancel();
reader.releaseLock();
} else if (upstream?.body && !upstream.body.locked) {
await upstream.body.cancel();
}
} catch {
}
}
const backoffMs = Math.min(1000 * Math.pow(2, Math.min(attempt, 5)), 30000);
await new Promise((resolve) => setTimeout(resolve, backoffMs));
}
};
void run();
};
const stop = () => {
if (!abortController) {
return;
}
try {
abortController.abort();
} catch {
}
abortController = null;
};
return {
start,
stop,
};
};
@@ -5,6 +5,7 @@ This module fetches quota and usage signals for supported providers in the web s
## Entrypoints and structure
- `packages/web/server/lib/quota/index.js`: public entrypoint imported by `packages/web/server/index.js`.
- `packages/web/server/lib/quota/routes.js`: Express route registration for quota endpoints.
- `packages/web/server/lib/quota/providers/index.js`: provider registry, configured-provider list, and provider dispatcher.
- `packages/web/server/lib/quota/providers/interface.js`: JSDoc provider contract used as implementation reference.
- `packages/web/server/lib/quota/providers/google/`: Google-specific auth, API, and transform modules.
+27
View File
@@ -0,0 +1,27 @@
export function registerQuotaRoutes(app, { getQuotaProviders }) {
app.get('/api/quota/providers', async (_req, res) => {
try {
const { listConfiguredQuotaProviders } = await getQuotaProviders();
const providers = listConfiguredQuotaProviders();
res.json({ providers });
} catch (error) {
console.error('Failed to list quota providers:', error);
res.status(500).json({ error: error.message || 'Failed to list quota providers' });
}
});
app.get('/api/quota/:providerId', async (req, res) => {
try {
const { providerId } = req.params;
if (!providerId) {
return res.status(400).json({ error: 'Provider ID is required' });
}
const { fetchQuotaForProvider } = await getQuotaProviders();
const result = await fetchQuotaForProvider(providerId);
res.json(result);
} catch (error) {
console.error('Failed to fetch quota:', error);
res.status(500).json({ error: error.message || 'Failed to fetch quota' });
}
});
}
@@ -0,0 +1,115 @@
export const createRequestSecurityRuntime = (deps) => {
const { readSettingsFromDiskMigrated } = deps;
const getUiSessionTokenFromRequest = (req) => {
const cookieHeader = req?.headers?.cookie;
if (!cookieHeader || typeof cookieHeader !== 'string') {
return null;
}
const segments = cookieHeader.split(';');
for (const segment of segments) {
const [rawName, ...rest] = segment.split('=');
const name = rawName?.trim();
if (!name) continue;
if (name !== 'oc_ui_session') continue;
const value = rest.join('=').trim();
try {
return decodeURIComponent(value || '');
} catch {
return value || null;
}
}
return null;
};
const rejectWebSocketUpgrade = (socket, statusCode, reason) => {
if (!socket || socket.destroyed) {
return;
}
const message = typeof reason === 'string' && reason.trim().length > 0 ? reason.trim() : 'Bad Request';
const body = Buffer.from(message, 'utf8');
const statusText = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
500: 'Internal Server Error',
}[statusCode] || 'Bad Request';
try {
socket.write(
`HTTP/1.1 ${statusCode} ${statusText}\r\n` +
'Connection: close\r\n' +
'Content-Type: text/plain; charset=utf-8\r\n' +
`Content-Length: ${body.length}\r\n\r\n`
);
socket.write(body);
} catch {
}
try {
socket.destroy();
} catch {
}
};
const getRequestOriginCandidates = async (req) => {
const origins = new Set();
const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
? req.headers['x-forwarded-proto'].split(',')[0].trim().toLowerCase()
: '';
const protocol = forwardedProto || (req.socket?.encrypted ? 'https' : 'http');
const forwardedHost = typeof req.headers['x-forwarded-host'] === 'string'
? req.headers['x-forwarded-host'].split(',')[0].trim()
: '';
const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : '');
if (host) {
origins.add(`${protocol}://${host}`);
const [hostname, port] = host.split(':');
const normalizedHost = typeof hostname === 'string' ? hostname.toLowerCase() : '';
const portSuffix = typeof port === 'string' && port.length > 0 ? `:${port}` : '';
if (normalizedHost === 'localhost') {
origins.add(`${protocol}://127.0.0.1${portSuffix}`);
origins.add(`${protocol}://[::1]${portSuffix}`);
} else if (normalizedHost === '127.0.0.1' || normalizedHost === '[::1]') {
origins.add(`${protocol}://localhost${portSuffix}`);
}
}
try {
const settings = await readSettingsFromDiskMigrated();
if (typeof settings?.publicOrigin === 'string' && settings.publicOrigin.trim().length > 0) {
origins.add(new URL(settings.publicOrigin.trim()).origin);
}
} catch {
}
return origins;
};
const isRequestOriginAllowed = async (req) => {
const originHeader = typeof req.headers.origin === 'string' ? req.headers.origin.trim() : '';
if (!originHeader) {
return false;
}
let normalizedOrigin = '';
try {
normalizedOrigin = new URL(originHeader).origin;
} catch {
return false;
}
const allowedOrigins = await getRequestOriginCandidates(req);
return allowedOrigins.has(normalizedOrigin);
};
return {
getUiSessionTokenFromRequest,
rejectWebSocketUpgrade,
isRequestOriginAllowed,
};
};
@@ -6,6 +6,7 @@ This module provides WebSocket protocol utilities for terminal input handling in
## Entrypoints and structure
- `packages/web/server/lib/terminal/`: Terminal module directory.
- `index.js`: Stable module entrypoint that re-exports protocol helpers/constants.
- `runtime.js`: Runtime module that owns terminal session state, WS server setup, and `/api/terminal/*` route registration.
- `input-ws-protocol.js`: Single-file module containing all terminal input WebSocket protocol utilities.
- `packages/web/server/lib/terminal/input-ws-protocol.test.js`: Test file for protocol utilities.
+708
View File
@@ -0,0 +1,708 @@
import { WebSocketServer } from 'ws';
import {
TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES,
TERMINAL_INPUT_WS_PATH,
createTerminalInputWsControlFrame,
isRebindRateLimited,
normalizeTerminalInputWsMessageToText,
parseRequestPathname,
pruneRebindTimestamps,
readTerminalInputWsControlFrame,
} from './index.js';
export function createTerminalRuntime({
app,
server,
express,
fs,
path,
uiAuthController,
buildAugmentedPath,
searchPathFor,
isExecutable,
isRequestOriginAllowed,
rejectWebSocketUpgrade,
TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS,
TERMINAL_INPUT_WS_REBIND_WINDOW_MS,
TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW,
}) {
let ptyProviderPromise = null;
const getPtyProvider = async () => {
if (ptyProviderPromise) {
return ptyProviderPromise;
}
ptyProviderPromise = (async () => {
const isBunRuntime = typeof globalThis.Bun !== 'undefined';
if (isBunRuntime) {
try {
const bunPty = await import('bun-pty');
console.log('Using bun-pty for terminal sessions');
return { spawn: bunPty.spawn, backend: 'bun-pty' };
} catch (error) {
console.warn('bun-pty unavailable, falling back to node-pty');
}
}
try {
const nodePty = await import('node-pty');
console.log('Using node-pty for terminal sessions');
return { spawn: nodePty.spawn, backend: 'node-pty' };
} catch (error) {
console.error('Failed to load node-pty:', error && error.message ? error.message : error);
if (isBunRuntime) {
throw new Error('No PTY backend available. Install bun-pty or node-pty.');
}
throw new Error('node-pty is not available. Run: npm rebuild node-pty (or install Bun for bun-pty)');
}
})();
return ptyProviderPromise;
};
const getTerminalShellCandidates = () => {
if (process.platform === 'win32') {
const windowsCandidates = [
process.env.OPENCHAMBER_TERMINAL_SHELL,
process.env.SHELL,
process.env.ComSpec,
path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
'pwsh.exe',
'powershell.exe',
'cmd.exe',
].filter(Boolean);
const resolved = [];
const seen = new Set();
for (const candidateRaw of windowsCandidates) {
const candidate = String(candidateRaw).trim();
if (!candidate) continue;
const lookedUp = candidate.includes('\\') || candidate.includes('/')
? candidate
: searchPathFor(candidate);
const executable = lookedUp && isExecutable(lookedUp) ? lookedUp : (isExecutable(candidate) ? candidate : null);
if (!executable || seen.has(executable)) continue;
seen.add(executable);
resolved.push(executable);
}
return resolved;
}
const unixCandidates = [
process.env.OPENCHAMBER_TERMINAL_SHELL,
process.env.SHELL,
'/bin/zsh',
'/bin/bash',
'/bin/sh',
'zsh',
'bash',
'sh',
].filter(Boolean);
const resolved = [];
const seen = new Set();
for (const candidateRaw of unixCandidates) {
const candidate = String(candidateRaw).trim();
if (!candidate) continue;
const lookedUp = candidate.includes('/') ? candidate : searchPathFor(candidate);
const executable = lookedUp && isExecutable(lookedUp) ? lookedUp : (isExecutable(candidate) ? candidate : null);
if (!executable || seen.has(executable)) continue;
seen.add(executable);
resolved.push(executable);
}
return resolved;
};
const spawnTerminalPtyWithFallback = (pty, { cols, rows, cwd, env }) => {
const shellCandidates = getTerminalShellCandidates();
if (shellCandidates.length === 0) {
throw new Error('No executable shell found for terminal session');
}
let lastError = null;
for (const shell of shellCandidates) {
try {
const ptyProcess = pty.spawn(shell, [], {
name: 'xterm-256color',
cols: cols || 80,
rows: rows || 24,
cwd,
env: {
...env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
},
});
return { ptyProcess, shell };
} catch (error) {
lastError = error;
console.warn(`Failed to spawn PTY using shell ${shell}:`, error && error.message ? error.message : error);
}
}
const baseMessage = lastError && lastError.message ? lastError.message : 'PTY spawn failed';
throw new Error(`Failed to spawn terminal PTY with available shells (${shellCandidates.join(', ')}): ${baseMessage}`);
};
const terminalSessions = new Map();
const MAX_TERMINAL_SESSIONS = 20;
const TERMINAL_IDLE_TIMEOUT = 30 * 60 * 1000;
const sanitizeTerminalEnv = (env) => {
const next = { ...env };
delete next.BASH_XTRACEFD;
delete next.BASH_ENV;
delete next.ENV;
return next;
};
const terminalInputCapabilities = {
input: {
preferred: 'ws',
transports: ['http', 'ws'],
ws: {
path: TERMINAL_INPUT_WS_PATH,
v: 1,
enc: 'text+json-bin-control',
},
},
};
const sendTerminalInputWsControl = (socket, payload) => {
if (!socket || socket.readyState !== 1) {
return;
}
try {
socket.send(createTerminalInputWsControlFrame(payload), { binary: true });
} catch {
}
};
let terminalInputWsServer = new WebSocketServer({
noServer: true,
maxPayload: TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES,
});
terminalInputWsServer.on('connection', (socket) => {
const connectionState = {
boundSessionId: null,
invalidFrames: 0,
rebindTimestamps: [],
lastActivityAt: Date.now(),
};
sendTerminalInputWsControl(socket, { t: 'ok', v: 1 });
const heartbeatInterval = setInterval(() => {
if (socket.readyState !== 1) {
return;
}
try {
socket.ping();
} catch {
}
}, TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS);
socket.on('pong', () => {
connectionState.lastActivityAt = Date.now();
});
socket.on('message', (message, isBinary) => {
connectionState.lastActivityAt = Date.now();
if (isBinary) {
const controlMessage = readTerminalInputWsControlFrame(message);
if (!controlMessage || typeof controlMessage.t !== 'string') {
connectionState.invalidFrames += 1;
sendTerminalInputWsControl(socket, {
t: 'e',
c: 'BAD_FRAME',
f: connectionState.invalidFrames >= 10,
});
if (connectionState.invalidFrames >= 10) {
socket.close(1008, 'protocol violation');
}
return;
}
if (controlMessage.t === 'p') {
sendTerminalInputWsControl(socket, { t: 'po', v: 1 });
return;
}
if (controlMessage.t !== 'b' || typeof controlMessage.s !== 'string') {
connectionState.invalidFrames += 1;
sendTerminalInputWsControl(socket, {
t: 'e',
c: 'BAD_FRAME',
f: connectionState.invalidFrames >= 10,
});
if (connectionState.invalidFrames >= 10) {
socket.close(1008, 'protocol violation');
}
return;
}
const now = Date.now();
connectionState.rebindTimestamps = pruneRebindTimestamps(
connectionState.rebindTimestamps,
now,
TERMINAL_INPUT_WS_REBIND_WINDOW_MS
);
if (isRebindRateLimited(connectionState.rebindTimestamps, TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW)) {
sendTerminalInputWsControl(socket, { t: 'e', c: 'RATE_LIMIT', f: false });
return;
}
const nextSessionId = controlMessage.s.trim();
const targetSession = terminalSessions.get(nextSessionId);
if (!targetSession) {
connectionState.boundSessionId = null;
sendTerminalInputWsControl(socket, { t: 'e', c: 'SESSION_NOT_FOUND', f: false });
return;
}
connectionState.rebindTimestamps.push(now);
connectionState.boundSessionId = nextSessionId;
sendTerminalInputWsControl(socket, { t: 'bok', v: 1 });
return;
}
const payload = normalizeTerminalInputWsMessageToText(message);
if (payload.length === 0) {
return;
}
if (!connectionState.boundSessionId) {
sendTerminalInputWsControl(socket, { t: 'e', c: 'NOT_BOUND', f: false });
return;
}
const session = terminalSessions.get(connectionState.boundSessionId);
if (!session) {
connectionState.boundSessionId = null;
sendTerminalInputWsControl(socket, { t: 'e', c: 'SESSION_NOT_FOUND', f: false });
return;
}
try {
session.ptyProcess.write(payload);
session.lastActivity = Date.now();
} catch {
sendTerminalInputWsControl(socket, { t: 'e', c: 'WRITE_FAIL', f: false });
}
});
socket.on('close', () => {
clearInterval(heartbeatInterval);
});
socket.on('error', (error) => {
void error;
});
});
server.on('upgrade', (req, socket, head) => {
const pathname = parseRequestPathname(req.url);
if (pathname !== TERMINAL_INPUT_WS_PATH) {
return;
}
const handleUpgrade = async () => {
try {
if (uiAuthController?.enabled) {
// Must be awaited: this call performs async token verification.
const sessionToken = await uiAuthController?.ensureSessionToken?.(req, null);
if (!sessionToken) {
rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
return;
}
const originAllowed = await isRequestOriginAllowed(req);
if (!originAllowed) {
rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
return;
}
}
if (!terminalInputWsServer) {
rejectWebSocketUpgrade(socket, 500, 'Terminal WebSocket unavailable');
return;
}
terminalInputWsServer.handleUpgrade(req, socket, head, (ws) => {
terminalInputWsServer.emit('connection', ws, req);
});
} catch {
rejectWebSocketUpgrade(socket, 500, 'Upgrade failed');
}
};
void handleUpgrade();
});
const idleSweepInterval = setInterval(() => {
const now = Date.now();
for (const [sessionId, session] of terminalSessions.entries()) {
if (now - session.lastActivity > TERMINAL_IDLE_TIMEOUT) {
console.log(`Cleaning up idle terminal session: ${sessionId}`);
try {
session.ptyProcess.kill();
} catch (error) {
}
terminalSessions.delete(sessionId);
}
}
}, 5 * 60 * 1000);
app.post('/api/terminal/create', async (req, res) => {
try {
if (terminalSessions.size >= MAX_TERMINAL_SESSIONS) {
return res.status(429).json({ error: 'Maximum terminal sessions reached' });
}
const { cwd, cols, rows } = req.body;
if (!cwd) {
return res.status(400).json({ error: 'cwd is required' });
}
try {
await fs.promises.access(cwd);
} catch {
return res.status(400).json({ error: 'Invalid working directory' });
}
const sessionId = Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
const envPath = buildAugmentedPath();
const resolvedEnv = sanitizeTerminalEnv({ ...process.env, PATH: envPath });
const pty = await getPtyProvider();
const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, {
cols,
rows,
cwd,
env: resolvedEnv,
});
const session = {
ptyProcess,
ptyBackend: pty.backend,
cwd,
lastActivity: Date.now(),
clients: new Set(),
};
terminalSessions.set(sessionId, session);
ptyProcess.onExit(({ exitCode, signal }) => {
console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`);
terminalSessions.delete(sessionId);
});
console.log(`Created terminal session: ${sessionId} in ${cwd} using shell ${shell}`);
res.json({ sessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities });
} catch (error) {
console.error('Failed to create terminal session:', error);
res.status(500).json({ error: error.message || 'Failed to create terminal session' });
}
});
app.get('/api/terminal/:sessionId/stream', (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
const clientId = Math.random().toString(36).substring(7);
session.clients.add(clientId);
session.lastActivity = Date.now();
const runtime = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun';
const ptyBackend = session.ptyBackend || 'unknown';
res.write(`data: ${JSON.stringify({ type: 'connected', runtime, ptyBackend })}\n\n`);
const heartbeatInterval = setInterval(() => {
try {
res.write(': heartbeat\n\n');
} catch (error) {
console.error(`Heartbeat failed for client ${clientId}:`, error);
clearInterval(heartbeatInterval);
}
}, 15000);
const dataHandler = (data) => {
try {
session.lastActivity = Date.now();
const ok = res.write(`data: ${JSON.stringify({ type: 'data', data })}\n\n`);
if (!ok && session.ptyProcess && typeof session.ptyProcess.pause === 'function') {
session.ptyProcess.pause();
res.once('drain', () => {
if (session.ptyProcess && typeof session.ptyProcess.resume === 'function') {
session.ptyProcess.resume();
}
});
}
} catch (error) {
console.error(`Error sending data to client ${clientId}:`, error);
cleanup();
}
};
const exitHandler = ({ exitCode, signal }) => {
try {
res.write(`data: ${JSON.stringify({ type: 'exit', exitCode, signal })}\n\n`);
res.end();
} catch (error) {
}
cleanup();
};
const dataDisposable = session.ptyProcess.onData(dataHandler);
const exitDisposable = session.ptyProcess.onExit(exitHandler);
const cleanup = () => {
clearInterval(heartbeatInterval);
session.clients.delete(clientId);
if (dataDisposable && typeof dataDisposable.dispose === 'function') {
dataDisposable.dispose();
}
if (exitDisposable && typeof exitDisposable.dispose === 'function') {
exitDisposable.dispose();
}
try {
res.end();
} catch (error) {
}
console.log(`Client ${clientId} disconnected from terminal session ${sessionId}`);
};
req.on('close', cleanup);
req.on('error', cleanup);
console.log(`Terminal connected: session=${sessionId} client=${clientId} runtime=${runtime} pty=${ptyBackend}`);
});
app.post('/api/terminal/:sessionId/input', express.text({ type: '*/*' }), (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
const data = typeof req.body === 'string' ? req.body : '';
try {
session.ptyProcess.write(data);
session.lastActivity = Date.now();
res.json({ success: true });
} catch (error) {
console.error('Failed to write to terminal:', error);
res.status(500).json({ error: error.message || 'Failed to write to terminal' });
}
});
app.post('/api/terminal/:sessionId/resize', (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
const { cols, rows } = req.body;
if (!cols || !rows) {
return res.status(400).json({ error: 'cols and rows are required' });
}
try {
session.ptyProcess.resize(cols, rows);
session.lastActivity = Date.now();
res.json({ success: true, cols, rows });
} catch (error) {
console.error('Failed to resize terminal:', error);
res.status(500).json({ error: error.message || 'Failed to resize terminal' });
}
});
app.delete('/api/terminal/:sessionId', (req, res) => {
const { sessionId } = req.params;
const session = terminalSessions.get(sessionId);
if (!session) {
return res.status(404).json({ error: 'Terminal session not found' });
}
try {
session.ptyProcess.kill();
terminalSessions.delete(sessionId);
console.log(`Closed terminal session: ${sessionId}`);
res.json({ success: true });
} catch (error) {
console.error('Failed to close terminal:', error);
res.status(500).json({ error: error.message || 'Failed to close terminal' });
}
});
app.post('/api/terminal/:sessionId/restart', async (req, res) => {
const { sessionId } = req.params;
const { cwd, cols, rows } = req.body;
if (!cwd) {
return res.status(400).json({ error: 'cwd is required' });
}
const existingSession = terminalSessions.get(sessionId);
if (existingSession) {
try {
existingSession.ptyProcess.kill();
} catch (error) {
}
terminalSessions.delete(sessionId);
}
try {
try {
const stats = await fs.promises.stat(cwd);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Invalid working directory: not a directory' });
}
} catch (error) {
return res.status(400).json({ error: 'Invalid working directory: not accessible' });
}
const newSessionId = Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
const envPath = buildAugmentedPath();
const resolvedEnv = sanitizeTerminalEnv({ ...process.env, PATH: envPath });
const pty = await getPtyProvider();
const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, {
cols,
rows,
cwd,
env: resolvedEnv,
});
const session = {
ptyProcess,
ptyBackend: pty.backend,
cwd,
lastActivity: Date.now(),
clients: new Set(),
};
terminalSessions.set(newSessionId, session);
ptyProcess.onExit(({ exitCode, signal }) => {
console.log(`Terminal session ${newSessionId} exited with code ${exitCode}, signal ${signal}`);
terminalSessions.delete(newSessionId);
});
console.log(`Restarted terminal session: ${sessionId} -> ${newSessionId} in ${cwd} using shell ${shell}`);
res.json({ sessionId: newSessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities });
} catch (error) {
console.error('Failed to restart terminal session:', error);
res.status(500).json({ error: error.message || 'Failed to restart terminal session' });
}
});
app.post('/api/terminal/force-kill', (req, res) => {
const { sessionId, cwd } = req.body;
let killedCount = 0;
if (sessionId) {
const session = terminalSessions.get(sessionId);
if (session) {
try {
session.ptyProcess.kill();
} catch (error) {
}
terminalSessions.delete(sessionId);
killedCount++;
}
} else if (cwd) {
for (const [id, session] of terminalSessions) {
if (session.cwd === cwd) {
try {
session.ptyProcess.kill();
} catch (error) {
}
terminalSessions.delete(id);
killedCount++;
}
}
} else {
for (const [id, session] of terminalSessions) {
try {
session.ptyProcess.kill();
} catch (error) {
}
terminalSessions.delete(id);
killedCount++;
}
}
console.log(`Force killed ${killedCount} terminal session(s)`);
res.json({ success: true, killedCount });
});
const shutdown = async () => {
if (idleSweepInterval) {
clearInterval(idleSweepInterval);
}
for (const [sessionId, session] of terminalSessions.entries()) {
try {
session.ptyProcess.kill();
} catch {
}
terminalSessions.delete(sessionId);
}
if (!terminalInputWsServer) {
return;
}
try {
for (const client of terminalInputWsServer.clients) {
try {
client.terminate();
} catch {
}
}
await new Promise((resolve) => {
terminalInputWsServer.close(() => resolve());
});
} catch {
} finally {
terminalInputWsServer = null;
}
};
return { shutdown };
}
@@ -5,6 +5,8 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API,
## Entrypoints and structure
- `packages/web/server/lib/tts/index.js`: Public entrypoint imported by `packages/web/server/index.js`.
- `packages/web/server/lib/tts/routes.js`: Express route registration for `/api/voice/*` and `/api/tts/*` endpoints.
- `packages/web/server/lib/tts/capability-runtime.js`: runtime helper for probing local macOS `say` TTS voice capability.
- `packages/web/server/lib/tts/service.js`: TTS service implementation with OpenAI integration.
- `packages/web/server/lib/tts/summarization.js`: Text summarization and sanitization utilities using opencode.ai zen API.
@@ -19,6 +21,9 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API,
- `summarizeText({ text, threshold, maxLength, zenModel })`: Summarizes text for TTS output using opencode.ai zen API.
- `sanitizeForTTS(text)`: Sanitizes text by removing markdown, URLs, file paths, and other non-speakable content.
### Capability runtime (capability-runtime.js)
- `detectSayTtsCapability(processLike)`: probes local `say -v "?"` support and returns `{ available, voices, reason }`.
## Constants
### Voice identifiers
@@ -0,0 +1,31 @@
export const detectSayTtsCapability = async (processLike) => {
let sayTTSCapability = { available: false, voices: [], reason: 'Not checked' };
if (processLike.platform === 'darwin') {
try {
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
const { stdout } = await execAsync('say -v "?"');
const voices = stdout.split('\n')
.filter((line) => line.trim())
.map((line) => {
const match = line.match(/^(.+?)\s+([a-zA-Z]{2}_[a-zA-Z]{2,3})\s+#/);
if (match) {
return { name: match[1].trim(), locale: match[2] };
}
return null;
})
.filter(Boolean);
sayTTSCapability = { available: true, voices };
console.log(`macOS Say TTS available with ${voices.length} voices`);
} catch (error) {
sayTTSCapability = { available: false, voices: [], reason: 'say command not available' };
console.log('macOS Say TTS not available:', error.message);
}
} else {
sayTTSCapability = { available: false, voices: [], reason: 'Not macOS' };
}
return sayTTSCapability;
};
+225
View File
@@ -0,0 +1,225 @@
export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
let ttsModulePromise = null;
const getTtsModule = async () => {
if (!ttsModulePromise) {
ttsModulePromise = import('./index.js');
}
return ttsModulePromise;
};
app.post('/api/voice/token', async (req, res) => {
console.log('[Voice] Token request received:', {
contentType: req.headers['content-type'] || null,
});
try {
const openaiApiKey = process.env.OPENAI_API_KEY;
console.log('[Voice] OpenAI API Key present:', !!openaiApiKey);
if (!openaiApiKey) {
return res.status(503).json({
allowed: false,
error: 'OpenAI voice service not configured. Set OPENAI_API_KEY environment variable.'
});
}
// Return success - OpenAI TTS is available
res.json({
allowed: true,
provider: 'openai',
message: 'OpenAI TTS is available'
});
} catch (error) {
console.error('[Voice] Token generation error:', error);
res.status(500).json({
allowed: false,
error: 'Voice service error'
});
}
});
// Server-side TTS endpoint - streams audio from OpenAI TTS API
app.post('/api/tts/speak', async (req, res) => {
try {
const { text, voice = 'nova', model = 'gpt-4o-mini-tts', speed = 0.9, instructions, summarize = false, providerId, modelId, threshold = 200, maxLength = 500, apiKey } = req.body || {};
console.log('[TTS] Request received:', { voice, model, speed, textLength: text?.length, hasApiKey: !!apiKey });
if (!text || typeof text !== 'string' || !text.trim()) {
return res.status(400).json({ error: 'Text is required' });
}
// Dynamically import the TTS service (ESM)
const { ttsService } = await getTtsModule();
// Check availability - either server-configured or client-provided API key
const hasServerKey = ttsService.isAvailable();
const hasClientKey = apiKey && typeof apiKey === 'string' && apiKey.trim().length > 0;
if (!hasServerKey && !hasClientKey) {
return res.status(503).json({
error: 'TTS service not available. Please configure OpenAI in OpenCode or provide an API key in settings.'
});
}
let textToSpeak = text.trim();
// Optionally summarize long text before speaking using zen API
if (summarize && textToSpeak.length > threshold) {
try {
const { summarizeText } = await getTtsModule();
const speakZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined);
const result = await summarizeText({ text: textToSpeak, threshold, maxLength, zenModel: speakZenModel });
if (result.summarized && result.summary) {
textToSpeak = result.summary;
}
} catch (summarizeError) {
console.error('[TTS/speak] Summarization failed:', summarizeError);
// Continue with original text if summarization fails
}
}
const result = await ttsService.generateSpeechStream({
text: textToSpeak,
voice,
model,
speed,
instructions,
apiKey: hasClientKey ? apiKey.trim() : undefined
});
// Set headers for audio streaming
// Note: Don't set Transfer-Encoding manually - Express handles it automatically
res.setHeader('Content-Type', result.contentType);
res.setHeader('Cache-Control', 'no-cache');
// Collect the full audio buffer and send it
// This avoids chunked encoding issues with proxies
const reader = result.stream.getReader();
const chunks = [];
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(Buffer.from(value));
}
const audioBuffer = Buffer.concat(chunks);
res.setHeader('Content-Length', audioBuffer.length);
res.send(audioBuffer);
} catch (streamError) {
console.error('[TTS] Stream error:', streamError);
if (!res.headersSent) {
res.status(500).json({ error: 'Stream error' });
} else {
res.end();
}
}
} catch (error) {
console.error('[TTS] Error:', error);
if (!res.headersSent) {
res.status(500).json({
error: error instanceof Error ? error.message : 'TTS generation failed'
});
}
}
});
app.post('/api/tts/summarize', async (req, res) => {
try {
const { summarizeText } = await getTtsModule();
const { text, threshold = 200, maxLength = 500 } = req.body || {};
if (!text || typeof text !== 'string' || !text.trim()) {
return res.status(400).json({ error: 'Text is required' });
}
const sumZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined);
const result = await summarizeText({ text, threshold, maxLength, zenModel: sumZenModel });
return res.json(result);
} catch (error) {
console.error('[Summarize] Error:', error);
const { sanitizeForTTS } = await getTtsModule();
const sanitized = sanitizeForTTS(req.body?.text || '');
return res.json({ summary: sanitized, summarized: false, reason: error.message });
}
});
// TTS status endpoint
app.get('/api/tts/status', async (_req, res) => {
try {
const { ttsService } = await getTtsModule();
res.json({
available: ttsService.isAvailable(),
voices: [
'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable',
'nova', 'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar'
]
});
} catch (error) {
res.status(500).json({ error: 'Failed to check TTS status' });
}
});
// macOS 'say' command TTS status endpoint - returns cached capability from startup
app.get('/api/tts/say/status', (_req, res) => {
res.json(sayTTSCapability);
});
// macOS 'say' command TTS speak endpoint
app.post('/api/tts/say/speak', async (req, res) => {
try {
const { text, voice = 'Samantha', rate = 200 } = req.body || {};
if (!text || typeof text !== 'string' || !text.trim()) {
return res.status(400).json({ error: 'Text is required' });
}
// Check if we're on macOS
if (process.platform !== 'darwin') {
return res.status(503).json({ error: 'macOS say command not available on this platform' });
}
const { exec } = await import('child_process');
const { promisify } = await import('util');
const fs = await import('fs');
const os = await import('os');
const path = await import('path');
const execAsync = promisify(exec);
// Create temp file for audio output (use m4a for browser compatibility)
const tempDir = os.tmpdir();
const tempFile = path.join(tempDir, `say-${Date.now()}.m4a`);
// Escape text for shell - escape both single quotes and double quotes
const escapedText = text.trim().replace(/'/g, "'\\''").replace(/"/g, '\\"');
// Generate audio file using 'say' command
// -o outputs to file, -r sets rate (words per minute)
// --data-format=aac outputs as m4a which browsers can decode
const cmd = `say -v "${voice}" -r ${rate} -o "${tempFile}" --data-format=aac '${escapedText}'`;
console.log('[TTS-Say] Generating speech:', { textLength: text.length, voice, rate });
await execAsync(cmd);
// Read the generated audio file
const audioBuffer = await fs.promises.readFile(tempFile);
// Clean up temp file
fs.promises.unlink(tempFile).catch(() => {});
// Send audio response
res.setHeader('Content-Type', 'audio/mp4');
res.setHeader('Content-Length', audioBuffer.length);
res.send(audioBuffer);
} catch (error) {
console.error('[TTS-Say] Error:', error);
res.status(500).json({
error: error instanceof Error ? error.message : 'Say command failed'
});
}
});
}
@@ -0,0 +1,18 @@
# Tunnels Module Documentation
## Purpose
This module contains tunnel provider orchestration for OpenChamber, including provider registry/service wiring, managed remote token config lifecycle, and tunnel HTTP route registration.
## Entrypoints and structure
- `packages/web/server/lib/tunnels/index.js`: tunnel service orchestration.
- `packages/web/server/lib/tunnels/registry.js`: provider registry.
- `packages/web/server/lib/tunnels/managed-config.js`: managed remote tunnel token/preset persistence runtime.
- `packages/web/server/lib/tunnels/routes.js`: tunnel API route registration and request orchestration runtime.
- `packages/web/server/lib/tunnels/types.js`: tunnel constants, normalization, and shared type helpers.
- `packages/web/server/lib/tunnels/providers/cloudflare.js`: Cloudflare tunnel provider implementation.
## Public exports (routes.js)
- `createTunnelRoutesRuntime(dependencies)`: creates tunnel routes runtime and helpers.
- Returned API:
- `registerRoutes(app)`
- `startTunnelWithNormalizedRequest(request)`
@@ -0,0 +1,201 @@
export const createManagedTunnelConfigRuntime = (deps) => {
const {
fsPromises,
path,
normalizeManagedRemoteTunnelHostname,
normalizeManagedRemoteTunnelPresets,
constants,
} = deps;
const {
CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH,
CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH,
CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION,
} = constants;
let persistManagedRemoteTunnelConfigLock = Promise.resolve();
const sanitizeManagedRemoteTunnelConfigEntries = (value) => {
if (!Array.isArray(value)) {
return [];
}
const result = [];
const seenIds = new Set();
const seenHostnames = new Set();
for (const entry of value) {
if (!entry || typeof entry !== 'object') {
continue;
}
const id = typeof entry.id === 'string' ? entry.id.trim() : '';
const name = typeof entry.name === 'string' ? entry.name.trim() : '';
const hostname = normalizeManagedRemoteTunnelHostname(entry.hostname);
const token = typeof entry.token === 'string' ? entry.token.trim() : '';
const updatedAt = Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now();
if (!id || !name || !hostname || !token) {
continue;
}
if (seenIds.has(id) || seenHostnames.has(hostname)) {
continue;
}
seenIds.add(id);
seenHostnames.add(hostname);
result.push({ id, name, hostname, token, updatedAt });
}
return result;
};
const writeManagedRemoteTunnelConfigToDisk = async (data) => {
await fsPromises.mkdir(path.dirname(CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH), { recursive: true });
await fsPromises.writeFile(CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
};
const migrateManagedRemoteTunnelConfigFromLegacyFile = async () => {
try {
const legacyRaw = await fsPromises.readFile(CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH, 'utf8');
const parsed = JSON.parse(legacyRaw);
const tunnels = sanitizeManagedRemoteTunnelConfigEntries(parsed?.tunnels);
const migrated = {
version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION,
tunnels,
};
await writeManagedRemoteTunnelConfigToDisk(migrated);
return migrated;
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] };
}
console.warn('Failed to migrate legacy named tunnel config file:', error);
return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] };
}
};
const readManagedRemoteTunnelConfigFromDisk = async () => {
try {
const raw = await fsPromises.readFile(CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH, 'utf8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] };
}
return {
version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION,
tunnels: sanitizeManagedRemoteTunnelConfigEntries(parsed.tunnels),
};
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return migrateManagedRemoteTunnelConfigFromLegacyFile();
}
console.warn('Failed to read managed remote tunnel config file:', error);
return { version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION, tunnels: [] };
}
};
const updateManagedRemoteTunnelConfig = async (mutate) => {
persistManagedRemoteTunnelConfigLock = persistManagedRemoteTunnelConfigLock.then(async () => {
const current = await readManagedRemoteTunnelConfigFromDisk();
const next = mutate({
version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION,
tunnels: sanitizeManagedRemoteTunnelConfigEntries(current.tunnels),
});
await writeManagedRemoteTunnelConfigToDisk({
version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION,
tunnels: sanitizeManagedRemoteTunnelConfigEntries(next?.tunnels),
});
});
return persistManagedRemoteTunnelConfigLock;
};
const syncManagedRemoteTunnelConfigWithPresets = async (presets) => {
const sanitizedPresets = normalizeManagedRemoteTunnelPresets(presets) || [];
await updateManagedRemoteTunnelConfig((current) => {
const byId = new Map(current.tunnels.map((entry) => [entry.id, entry]));
const byHostname = new Map(current.tunnels.map((entry) => [entry.hostname, entry]));
const nextTunnels = [];
for (const preset of sanitizedPresets) {
const existing = byId.get(preset.id) || byHostname.get(preset.hostname) || null;
if (!existing) {
continue;
}
nextTunnels.push({
...existing,
id: preset.id,
name: preset.name,
hostname: preset.hostname,
});
}
return {
version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION,
tunnels: nextTunnels,
};
});
};
const upsertManagedRemoteTunnelToken = async ({ id, name, hostname, token }) => {
if (typeof id !== 'string' || typeof name !== 'string' || typeof hostname !== 'string' || typeof token !== 'string') {
return;
}
const normalizedId = id.trim();
const normalizedName = name.trim();
const normalizedHostname = normalizeManagedRemoteTunnelHostname(hostname);
const normalizedToken = token.trim();
if (!normalizedId || !normalizedName || !normalizedHostname || !normalizedToken) {
return;
}
await updateManagedRemoteTunnelConfig((current) => {
const withoutConflicts = current.tunnels.filter((entry) => entry.id !== normalizedId && entry.hostname !== normalizedHostname);
withoutConflicts.push({
id: normalizedId,
name: normalizedName,
hostname: normalizedHostname,
token: normalizedToken,
updatedAt: Date.now(),
});
return {
version: CLOUDFLARE_MANAGED_REMOTE_TUNNELS_VERSION,
tunnels: withoutConflicts,
};
});
};
const resolveManagedRemoteTunnelToken = async ({ presetId, hostname }) => {
const normalizedPresetId = typeof presetId === 'string' ? presetId.trim() : '';
const normalizedHostname = normalizeManagedRemoteTunnelHostname(hostname);
const config = await readManagedRemoteTunnelConfigFromDisk();
if (normalizedPresetId) {
const byId = config.tunnels.find((entry) => entry.id === normalizedPresetId);
if (byId?.token) {
return byId.token;
}
}
if (normalizedHostname) {
const byHostname = config.tunnels.find((entry) => entry.hostname === normalizedHostname);
if (byHostname?.token) {
return byHostname.token;
}
}
return '';
};
return {
readManagedRemoteTunnelConfigFromDisk,
syncManagedRemoteTunnelConfigWithPresets,
upsertManagedRemoteTunnelToken,
resolveManagedRemoteTunnelToken,
};
};
+605
View File
@@ -0,0 +1,605 @@
export const createTunnelRoutesRuntime = (dependencies) => {
const {
crypto,
URL,
tunnelService,
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,
getActivePort,
getRuntimeManagedRemoteTunnelHostname,
setRuntimeManagedRemoteTunnelHostname,
getRuntimeManagedRemoteTunnelToken,
setRuntimeManagedRemoteTunnelToken,
getActiveTunnelController,
setActiveTunnelController,
} = dependencies;
const resolveActiveNormalizedTunnelMode = () => {
const mode = tunnelService.resolveActiveMode();
if (mode === TUNNEL_MODE_MANAGED_LOCAL) {
return TUNNEL_MODE_MANAGED_LOCAL;
}
if (mode === TUNNEL_MODE_MANAGED_REMOTE) {
return TUNNEL_MODE_MANAGED_REMOTE;
}
return TUNNEL_MODE_QUICK;
};
const resolveNormalizedTunnelHost = (publicUrl) => {
if (typeof publicUrl !== 'string' || publicUrl.trim().length === 0) {
return null;
}
try {
return new URL(publicUrl).hostname.toLowerCase();
} catch {
return null;
}
};
const resolvePreferredTunnelProvider = async (reqBody = null) => {
if (typeof reqBody?.provider === 'string' && reqBody.provider.trim().length > 0) {
return normalizeTunnelProvider(reqBody.provider);
}
const activeProvider = tunnelService.resolveActiveProvider();
if (activeProvider) {
return normalizeTunnelProvider(activeProvider);
}
const settings = await readSettingsFromDiskMigrated();
return normalizeTunnelProvider(settings?.tunnelProvider);
};
const startTunnelWithNormalizedRequest = async ({
provider,
mode,
intent,
hostname,
token,
configPath,
selectedPresetId,
selectedPresetName,
}) => {
if (provider === TUNNEL_PROVIDER_CLOUDFLARE && mode === TUNNEL_MODE_MANAGED_REMOTE) {
setRuntimeManagedRemoteTunnelHostname(hostname);
setRuntimeManagedRemoteTunnelToken(token);
if (token && hostname) {
await upsertManagedRemoteTunnelToken({
id: selectedPresetId || hostname,
name: selectedPresetName || hostname,
hostname,
token,
});
}
}
const result = await tunnelService.start({
provider,
mode,
intent,
configPath,
token,
hostname,
});
console.log(`Tunnel active (${result.provider}): ${result.publicUrl}`);
return {
publicUrl: result.publicUrl,
mode: result.activeMode,
provider: result.provider,
providerMetadata: result.providerMetadata,
};
};
const createGenericModeChecks = ({ modeKey, requiredFields, doctorRequest, startupReady }) => {
const checks = [
{
id: 'startup_readiness',
label: 'Provider startup readiness',
status: startupReady ? 'pass' : 'fail',
detail: startupReady
? 'Provider dependency checks passed.'
: 'Resolve provider checks before starting tunnels.',
},
];
for (const field of requiredFields) {
const value = doctorRequest?.[field];
const present = typeof value === 'string' ? value.trim().length > 0 : Boolean(value);
checks.push({
id: `requirement_${field}`,
label: `Required: ${field}`,
status: present ? 'pass' : 'fail',
detail: present
? `${field} is configured.`
: `${field} is required for ${modeKey}.`,
});
}
const failures = checks.filter((entry) => entry.status === 'fail').length;
const warnings = checks.filter((entry) => entry.status === 'warn').length;
return {
mode: modeKey,
checks,
summary: {
ready: failures === 0,
failures,
warnings,
},
ready: failures === 0,
blockers: checks
.filter((entry) => entry.status === 'fail' && entry.id !== 'startup_readiness')
.map((entry) => entry.detail || entry.label || entry.id),
};
};
const runTunnelDoctor = async ({ providerId, modeFilter, doctorRequest }) => {
const provider = tunnelProviderRegistry.get(providerId);
if (!provider) {
throw new TunnelServiceError('provider_unsupported', `Unsupported tunnel provider: ${providerId}`);
}
const capabilities = provider.capabilities || {};
const modeKeys = Array.isArray(capabilities.modes)
? capabilities.modes.map((entry) => entry?.key).filter((key) => typeof key === 'string' && key.length > 0)
: [];
if (modeFilter && !modeKeys.includes(modeFilter)) {
throw new TunnelServiceError('mode_unsupported', `Provider '${providerId}' does not support mode '${modeFilter}'`);
}
if (typeof provider.diagnose === 'function') {
const diagnosed = await provider.diagnose({
...doctorRequest,
mode: modeFilter || doctorRequest?.mode,
}, {
capabilities,
});
const providerChecks = Array.isArray(diagnosed?.providerChecks) ? diagnosed.providerChecks : [];
const allModes = Array.isArray(diagnosed?.modes) ? diagnosed.modes : [];
const modes = modeFilter ? allModes.filter((entry) => entry?.mode === modeFilter) : allModes;
return {
ok: true,
provider: providerId,
providerChecks,
modes,
};
}
const availability = await tunnelService.checkAvailability(providerId);
const dependencyAvailable = Boolean(availability?.available);
const providerChecks = [{
id: 'dependency',
label: 'Provider dependency',
status: dependencyAvailable ? 'pass' : 'fail',
detail: dependencyAvailable
? (availability?.version || 'available')
: (availability?.message || 'Required provider dependency is unavailable.'),
}];
const targetModes = (Array.isArray(capabilities.modes) ? capabilities.modes : [])
.filter((entry) => !modeFilter || entry?.key === modeFilter);
const modes = targetModes.map((entry) => createGenericModeChecks({
modeKey: entry.key,
requiredFields: Array.isArray(entry?.requires) ? entry.requires : [],
doctorRequest,
startupReady: dependencyAvailable,
}));
return {
ok: true,
provider: providerId,
providerChecks,
modes,
};
};
const registerRoutes = (app) => {
app.get('/api/openchamber/tunnel/check', async (req, res) => {
try {
const requestedProvider = typeof req?.query?.provider === 'string' && req.query.provider.trim().length > 0
? normalizeTunnelProvider(req.query.provider)
: await resolvePreferredTunnelProvider();
const result = await tunnelService.checkAvailability(requestedProvider);
res.json({
available: result.available,
provider: requestedProvider,
version: result.version || null,
});
} catch (error) {
console.warn('Tunnel dependency check failed:', error);
res.json({ available: false, provider: null, version: null });
}
});
const handleTunnelDoctor = async (req, res) => {
try {
const params = req.query || {};
const body = req.body || {};
const providerId = typeof params.provider === 'string' && params.provider.trim().length > 0
? normalizeTunnelProvider(params.provider)
: await resolvePreferredTunnelProvider();
const modeFilter = typeof params.mode === 'string' && params.mode.trim().length > 0
? params.mode.trim().toLowerCase()
: null;
const settings = await readSettingsFromDiskMigrated();
const selectedPresetId = typeof params.managedRemoteTunnelPresetId === 'string'
? params.managedRemoteTunnelPresetId.trim()
: '';
const requestConfigPath = normalizeOptionalPath(params.configPath)
?? normalizeOptionalPath(settings?.managedLocalTunnelConfigPath);
const requestManagedRemoteHostname = normalizeManagedRemoteTunnelHostname(params.managedRemoteTunnelHostname);
const requestTunnelHostname = normalizeManagedRemoteTunnelHostname(params.tunnelHostname);
const requestHostname = normalizeManagedRemoteTunnelHostname(params.hostname);
const hostnameFromSettings = normalizeManagedRemoteTunnelHostname(settings?.managedRemoteTunnelHostname);
const hostname = requestHostname || requestTunnelHostname || requestManagedRemoteHostname || hostnameFromSettings;
const requestManagedRemoteToken = typeof body.managedRemoteTunnelToken === 'string'
? body.managedRemoteTunnelToken.trim()
: '';
const requestTunnelToken = typeof body.tunnelToken === 'string'
? body.tunnelToken.trim()
: '';
const requestToken = typeof body.token === 'string'
? body.token.trim()
: '';
const requestTokenProvided = body.managedRemoteTunnelTokenProvided === true
|| body.tunnelTokenProvided === true
|| body.tokenProvided === true;
const requestHostnameProvided = body.managedRemoteTunnelHostnameProvided === true
|| body.tunnelHostnameProvided === true
|| body.hostnameProvided === true;
const storedManagedRemoteToken = typeof settings?.managedRemoteTunnelToken === 'string'
? settings.managedRemoteTunnelToken.trim()
: '';
const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk();
const serverHasSavedManagedRemoteProfile = managedRemoteTunnelConfig.tunnels.some((entry) => {
const savedHostname = normalizeManagedRemoteTunnelHostname(entry?.hostname);
const savedToken = typeof entry?.token === 'string' ? entry.token.trim() : '';
return Boolean(savedHostname && savedToken);
});
const cliHasSavedManagedRemoteProfile = params.hasSavedManagedRemoteProfile === '1';
const hasSavedManagedRemoteProfile = serverHasSavedManagedRemoteProfile || cliHasSavedManagedRemoteProfile;
const configManagedRemoteToken = providerId === TUNNEL_PROVIDER_CLOUDFLARE
? await resolveManagedRemoteTunnelToken({ presetId: selectedPresetId, hostname })
: '';
const runtimeHostname = getRuntimeManagedRemoteTunnelHostname();
const runtimeToken = getRuntimeManagedRemoteTunnelToken();
const token = requestToken
|| requestTunnelToken
|| requestManagedRemoteToken
|| ((runtimeHostname && hostname && runtimeHostname === hostname) ? runtimeToken : '')
|| configManagedRemoteToken
|| storedManagedRemoteToken;
const doctorRequest = {
mode: modeFilter,
hostname,
token,
tokenProvided: requestTokenProvided,
hostnameProvided: requestHostnameProvided,
configPath: requestConfigPath,
hasSavedManagedRemoteProfile,
};
const result = await runTunnelDoctor({
providerId,
modeFilter,
doctorRequest,
});
return res.json(result);
} catch (error) {
if (error instanceof TunnelServiceError) {
return res.status(400).json({ ok: false, error: error.message, code: error.code });
}
console.warn('Tunnel doctor failed:', error);
return res.status(500).json({ ok: false, error: 'Failed to run tunnel doctor' });
}
};
app.post('/api/openchamber/tunnel/doctor', handleTunnelDoctor);
app.get('/api/openchamber/tunnel/doctor', handleTunnelDoctor);
app.get('/api/openchamber/tunnel/providers', (_req, res) => {
const providers = tunnelProviderRegistry.listCapabilities();
return res.json({ providers });
});
app.get('/api/openchamber/tunnel/status', async (_req, res) => {
try {
const settings = await readSettingsFromDiskMigrated();
const normalizedMode = normalizeTunnelMode(settings?.tunnelMode);
const managedRemoteHostname = normalizeManagedRemoteTunnelHostname(settings?.managedRemoteTunnelHostname);
const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk();
const managedRemoteTunnelPresetSummaries = managedRemoteTunnelConfig.tunnels.map((entry) => ({
id: entry.id,
name: entry.name,
hostname: entry.hostname,
}));
const hasStoredManagedRemoteToken = typeof settings?.managedRemoteTunnelToken === 'string' && settings.managedRemoteTunnelToken.trim().length > 0;
const hasManagedRemoteTunnelToken = getRuntimeManagedRemoteTunnelToken().length > 0 || managedRemoteTunnelConfig.tunnels.length > 0 || hasStoredManagedRemoteToken;
const bootstrapTtlMs = settings?.tunnelBootstrapTtlMs === null
? null
: normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs);
const sessionTtlMs = normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs);
const activeSessions = tunnelAuthController.listTunnelSessions();
const activeProvider = tunnelService.resolveActiveProvider();
const provider = activeProvider || normalizeTunnelProvider(settings?.tunnelProvider);
const publicUrl = tunnelService.getPublicUrl();
if (!publicUrl) {
return res.json({
active: false,
url: null,
mode: normalizedMode,
provider,
providerMetadata: null,
hasManagedRemoteTunnelToken,
managedRemoteTunnelHostname: managedRemoteHostname || null,
managedRemoteTunnelPresets: managedRemoteTunnelPresetSummaries,
managedRemoteTunnelTokenPresetIds: managedRemoteTunnelConfig.tunnels.map((entry) => entry.id),
hasBootstrapToken: false,
bootstrapExpiresAt: null,
policy: 'tunnel-gated',
activeTunnelMode: tunnelAuthController.getActiveTunnelMode() || null,
activeSessions,
localPort: getActivePort(),
ttlConfig: {
bootstrapTtlMs,
sessionTtlMs,
},
});
}
const activeNormalizedMode = resolveActiveNormalizedTunnelMode();
const activeTunnelId = tunnelAuthController.getActiveTunnelId();
const activeTunnelHost = tunnelAuthController.getActiveTunnelHost();
const resolvedTunnelHost = resolveNormalizedTunnelHost(publicUrl);
const activeTunnelMode = tunnelAuthController.getActiveTunnelMode();
const needsActiveTunnelSync = !activeTunnelId
|| !activeTunnelHost
|| !resolvedTunnelHost
|| activeTunnelHost !== resolvedTunnelHost
|| activeTunnelMode !== activeNormalizedMode;
if (needsActiveTunnelSync) {
tunnelAuthController.setActiveTunnel({
tunnelId: activeTunnelId || crypto.randomUUID(),
publicUrl,
mode: activeNormalizedMode,
});
}
const bootstrapStatus = tunnelAuthController.getBootstrapStatus();
const providerMetadata = tunnelService.getProviderMetadata();
return res.json({
active: true,
url: publicUrl,
mode: activeNormalizedMode,
provider,
providerMetadata,
hasManagedRemoteTunnelToken,
managedRemoteTunnelHostname: managedRemoteHostname || null,
managedRemoteTunnelPresets: managedRemoteTunnelPresetSummaries,
managedRemoteTunnelTokenPresetIds: managedRemoteTunnelConfig.tunnels.map((entry) => entry.id),
hasBootstrapToken: bootstrapStatus.hasBootstrapToken,
bootstrapExpiresAt: bootstrapStatus.bootstrapExpiresAt,
policy: 'tunnel-gated',
activeTunnelMode: activeNormalizedMode,
activeSessions: tunnelAuthController.listTunnelSessions(),
localPort: getActivePort(),
ttlConfig: {
bootstrapTtlMs,
sessionTtlMs,
},
});
} catch (error) {
return res.status(500).json({ error: 'Failed to get tunnel status' });
}
});
app.put('/api/openchamber/tunnel/managed-remote-token', async (req, res) => {
try {
const presetId = typeof req?.body?.presetId === 'string' ? req.body.presetId.trim() : '';
const presetName = typeof req?.body?.presetName === 'string' ? req.body.presetName.trim() : '';
const managedRemoteTunnelHostname = normalizeManagedRemoteTunnelHostname(req?.body?.managedRemoteTunnelHostname);
const managedRemoteTunnelToken = typeof req?.body?.managedRemoteTunnelToken === 'string' ? req.body.managedRemoteTunnelToken.trim() : '';
if (!presetId || !presetName || !managedRemoteTunnelHostname || !managedRemoteTunnelToken) {
return res.status(400).json({ ok: false, error: 'presetId, presetName, managedRemoteTunnelHostname and managedRemoteTunnelToken are required' });
}
await upsertManagedRemoteTunnelToken({
id: presetId,
name: presetName,
hostname: managedRemoteTunnelHostname,
token: managedRemoteTunnelToken,
});
const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk();
return res.json({ ok: true, managedRemoteTunnelTokenPresetIds: managedRemoteTunnelConfig.tunnels.map((entry) => entry.id) });
} catch (error) {
return res.status(500).json({ ok: false, error: 'Failed to save managed remote tunnel token' });
}
});
app.post('/api/openchamber/tunnel/start', async (_req, res) => {
try {
const settings = await readSettingsFromDiskMigrated();
if (typeof _req?.body?.provider === 'string' && _req.body.provider.trim().length > 0) {
const rawProvider = _req.body.provider.trim().toLowerCase();
if (!tunnelProviderRegistry.get(rawProvider)) {
return res.status(422).json({ ok: false, error: `Unsupported tunnel provider: ${rawProvider}`, code: 'provider_unsupported' });
}
}
const provider = normalizeTunnelProvider(_req?.body?.provider ?? settings?.tunnelProvider);
const modeInput = _req?.body?.mode ?? settings?.tunnelMode;
const intent = typeof _req?.body?.intent === 'string' ? _req.body.intent.trim().toLowerCase() : undefined;
const mode = typeof modeInput === 'string'
? modeInput.trim().toLowerCase()
: normalizeTunnelMode(modeInput);
if (typeof _req?.body?.mode === 'string' && _req.body.mode.trim().length > 0 && !isSupportedTunnelMode(mode)) {
return res.status(422).json({ ok: false, error: `Unsupported tunnel mode: ${mode}`, code: 'mode_unsupported' });
}
const selectedPresetId = typeof _req?.body?.managedRemoteTunnelPresetId === 'string' ? _req.body.managedRemoteTunnelPresetId.trim() : '';
const selectedPresetName = typeof _req?.body?.managedRemoteTunnelPresetName === 'string' ? _req.body.managedRemoteTunnelPresetName.trim() : '';
const requestConfigPath = normalizeOptionalPath(_req?.body?.configPath)
?? normalizeOptionalPath(settings?.managedLocalTunnelConfigPath);
const requestManagedRemoteHostname = normalizeManagedRemoteTunnelHostname(_req?.body?.managedRemoteTunnelHostname);
const requestTunnelHostname = normalizeManagedRemoteTunnelHostname(_req?.body?.tunnelHostname);
const requestHostname = normalizeManagedRemoteTunnelHostname(_req?.body?.hostname);
const hostnameFromSettings = normalizeManagedRemoteTunnelHostname(settings?.managedRemoteTunnelHostname);
const hostname = requestHostname || requestTunnelHostname || requestManagedRemoteHostname || hostnameFromSettings;
const requestManagedRemoteToken = typeof _req?.body?.managedRemoteTunnelToken === 'string' ? _req.body.managedRemoteTunnelToken.trim() : '';
const requestTunnelToken = typeof _req?.body?.tunnelToken === 'string' ? _req.body.tunnelToken.trim() : '';
const requestToken = typeof _req?.body?.token === 'string' ? _req.body.token.trim() : '';
const storedManagedRemoteToken = typeof settings?.managedRemoteTunnelToken === 'string' ? settings.managedRemoteTunnelToken.trim() : '';
const configManagedRemoteToken = provider === TUNNEL_PROVIDER_CLOUDFLARE
? await resolveManagedRemoteTunnelToken({ presetId: selectedPresetId, hostname })
: '';
const runtimeHostname = getRuntimeManagedRemoteTunnelHostname();
const runtimeToken = getRuntimeManagedRemoteTunnelToken();
const token = requestToken
|| requestTunnelToken
|| requestManagedRemoteToken
|| ((runtimeHostname && hostname && runtimeHostname === hostname) ? runtimeToken : '')
|| configManagedRemoteToken
|| storedManagedRemoteToken;
const requestConnectTtlMs = typeof _req?.body?.connectTtlMs === 'number' && Number.isFinite(_req.body.connectTtlMs)
? normalizeTunnelBootstrapTtlMs(_req.body.connectTtlMs)
: undefined;
const requestSessionTtlMs = typeof _req?.body?.sessionTtlMs === 'number' && Number.isFinite(_req.body.sessionTtlMs)
? normalizeTunnelSessionTtlMs(_req.body.sessionTtlMs)
: undefined;
const bootstrapTtlMs = requestConnectTtlMs ?? (settings?.tunnelBootstrapTtlMs === null
? null
: normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs));
const sessionTtlMs = requestSessionTtlMs ?? normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs);
const previousTunnelId = tunnelAuthController.getActiveTunnelId();
const previousMode = tunnelAuthController.getActiveTunnelMode();
const previousProvider = tunnelService.resolveActiveProvider();
const previousUrl = tunnelService.getPublicUrl();
const { publicUrl, provider: activeProvider, providerMetadata } = await startTunnelWithNormalizedRequest({
provider,
mode,
intent,
hostname,
token,
configPath: requestConfigPath,
selectedPresetId,
selectedPresetName,
});
const replacedTunnel = Boolean(previousTunnelId) && (
previousMode !== mode
|| previousProvider !== activeProvider
|| previousUrl !== publicUrl
);
let revokedBootstrapCount = 0;
let invalidatedSessionCount = 0;
if (replacedTunnel && previousTunnelId) {
const revoked = tunnelAuthController.revokeTunnelArtifacts(previousTunnelId);
revokedBootstrapCount = revoked.revokedBootstrapCount;
invalidatedSessionCount = revoked.invalidatedSessionCount;
}
tunnelAuthController.setActiveTunnel({
tunnelId: replacedTunnel || !previousTunnelId ? crypto.randomUUID() : previousTunnelId,
publicUrl,
mode,
});
const bootstrapToken = tunnelAuthController.issueBootstrapToken({ ttlMs: bootstrapTtlMs });
const connectUrl = `${publicUrl.replace(/\/$/, '')}/connect?t=${encodeURIComponent(bootstrapToken.token)}`;
const managedRemoteTunnelConfig = await readManagedRemoteTunnelConfigFromDisk();
const isCloudflareProvider = activeProvider === TUNNEL_PROVIDER_CLOUDFLARE;
return res.json({
ok: true,
url: publicUrl,
mode,
provider: activeProvider,
providerMetadata,
managedRemoteTunnelHostname: isCloudflareProvider ? (hostname || null) : null,
managedRemoteTunnelTokenPresetIds: isCloudflareProvider ? managedRemoteTunnelConfig.tunnels.map((entry) => entry.id) : [],
connectUrl,
bootstrapExpiresAt: bootstrapToken.expiresAt,
replacedTunnel,
replaced: replacedTunnel
? {
mode: previousMode,
provider: previousProvider,
url: previousUrl,
}
: null,
revokedBootstrapCount,
invalidatedSessionCount,
policy: 'tunnel-gated',
activeTunnelMode: mode,
activeSessions: tunnelAuthController.listTunnelSessions(),
localPort: getActivePort(),
ttlConfig: {
bootstrapTtlMs,
sessionTtlMs,
},
});
} catch (error) {
console.error('Failed to start tunnel:', error);
setActiveTunnelController(null);
tunnelAuthController.clearActiveTunnel();
if (error instanceof TunnelServiceError) {
const status = error.code === 'missing_dependency'
? 400
: (error.code === 'validation_error' || error.code === 'provider_unsupported' || error.code === 'mode_unsupported'
? 422
: 500);
return res.status(status).json({ ok: false, error: error.message, code: error.code });
}
return res.status(500).json({ ok: false, error: 'Failed to start tunnel', code: 'startup_failed' });
}
});
app.post('/api/openchamber/tunnel/stop', (_req, res) => {
let revokedBootstrapCount = 0;
let invalidatedSessionCount = 0;
const activeTunnelId = tunnelAuthController.getActiveTunnelId();
if (activeTunnelId) {
const revoked = tunnelAuthController.revokeTunnelArtifacts(activeTunnelId);
revokedBootstrapCount = revoked.revokedBootstrapCount;
invalidatedSessionCount = revoked.invalidatedSessionCount;
}
if (getActiveTunnelController()) {
console.log('Stopping active tunnel (user requested)...');
tunnelService.stop();
}
tunnelAuthController.clearActiveTunnel();
res.json({ ok: true, revokedBootstrapCount, invalidatedSessionCount });
});
};
return {
registerRoutes,
startTunnelWithNormalizedRequest,
};
};