Initial public release

This commit is contained in:
Bohdan Triapitsyn
2025-12-07 19:32:53 +02:00
commit 4b2edf7318
319 changed files with 81600 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
server/prompt-templates.js
prompt-enhancer-config.json
+34
View File
@@ -0,0 +1,34 @@
# @openchamber/web
Web interface for the [OpenCode](https://opencode.ai) AI coding agent.
## Installation
```bash
npm add -g @openchamber/web
openchamber # Start on port 3000
openchamber --port 8080 # Custom port
openchamber --daemon # Background mode
openchamber --ui-password secret # Password-protect UI
openchamber stop # Stop server
```
## Prerequisites
- [OpenCode CLI](https://opencode.ai) installed and running (`opencode serve`)
- Node.js 20+
## Features
- Integrated terminal
- Git operations with identity management and AI commit message generation
- Beautiful themes (Flexoki Light/Dark)
- Mobile-optimized with edge-swipe gestures
- Rich permission cards with syntax-highlighted operation previews
- Smart tool visualization (inline diffs, file trees, results highlighting)
- Per-agent permission mode control
## License
MIT
+561
View File
@@ -0,0 +1,561 @@
#!/usr/bin/env node
import path from 'path';
import fs from 'fs';
import { spawn, spawnSync } from 'child_process';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DEFAULT_PORT = 3000;
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
function parseArgs() {
const args = process.argv.slice(2);
const envPassword = process.env.OPENCHAMBER_UI_PASSWORD || undefined;
const options = { port: DEFAULT_PORT, daemon: false, uiPassword: envPassword };
let command = 'serve';
const consumeValue = (currentIndex, inlineValue) => {
if (typeof inlineValue === 'string' && inlineValue.length > 0) {
return { value: inlineValue, nextIndex: currentIndex };
}
const candidate = args[currentIndex + 1];
if (typeof candidate === 'string' && !candidate.startsWith('-')) {
return { value: candidate, nextIndex: currentIndex + 1 };
}
return { value: undefined, nextIndex: currentIndex };
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('-')) {
let optionName;
let inlineValue;
if (arg.startsWith('--')) {
const eqIndex = arg.indexOf('=');
optionName = eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2);
inlineValue = eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined;
} else {
optionName = arg.slice(1);
inlineValue = undefined;
}
switch (optionName) {
case 'port':
case 'p': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
const parsed = parseInt(value ?? '', 10);
options.port = Number.isFinite(parsed) ? parsed : DEFAULT_PORT;
break;
}
case 'daemon':
case 'd':
options.daemon = true;
break;
case 'ui-password': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.uiPassword = typeof value === 'string' ? value : '';
break;
}
case 'help':
case 'h':
showHelp();
process.exit(0);
break;
case 'version':
case 'v':
console.log(PACKAGE_JSON.version);
process.exit(0);
break;
}
} else {
command = arg;
}
}
return { command, options };
}
function showHelp() {
console.log(`
OpenChamber - Web interface for the OpenCode AI coding agent
USAGE:
openchamber [COMMAND] [OPTIONS]
COMMANDS:
serve Start the web server (default)
stop Stop running instance(s)
restart Stop and start the server
status Show server status
OPTIONS:
-p, --port Web server port (default: ${DEFAULT_PORT})
--ui-password Protect browser UI with single password
-d, --daemon Run in background (serve command)
-h, --help Show help
-v, --version Show version
ENVIRONMENT:
OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag
EXAMPLES:
openchamber # Start on default port 3000
openchamber --port 8080 # Start on port 8080
openchamber serve --daemon # Start in background
openchamber stop # Stop all running instances
openchamber stop --port 3000 # Stop specific instance
openchamber status # Check status
`);
}
const WINDOWS_EXTENSIONS = process.platform === 'win32'
? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
.split(';')
.map((ext) => ext.trim().toLowerCase())
.filter(Boolean)
.map((ext) => (ext.startsWith('.') ? ext : `.${ext}`))
: [''];
function isExecutable(filePath) {
try {
const stats = fs.statSync(filePath);
if (!stats.isFile()) {
return false;
}
if (process.platform === 'win32') {
return true;
}
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} catch (error) {
return false;
}
}
function resolveExplicitBinary(candidate) {
if (!candidate) {
return null;
}
if (candidate.includes(path.sep) || path.isAbsolute(candidate)) {
const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(candidate);
return isExecutable(resolved) ? resolved : null;
}
return null;
}
function searchPathFor(command) {
const pathValue = process.env.PATH || '';
const segments = pathValue.split(path.delimiter).filter(Boolean);
for (const dir of segments) {
for (const ext of WINDOWS_EXTENSIONS) {
const fileName = process.platform === 'win32' ? `${command}${ext}` : command;
const candidate = path.join(dir, fileName);
if (isExecutable(candidate)) {
return candidate;
}
}
}
return null;
}
async function checkOpenCodeCLI() {
if (process.env.OPENCODE_BINARY) {
const override = resolveExplicitBinary(process.env.OPENCODE_BINARY);
if (override) {
process.env.OPENCODE_BINARY = override;
return override;
}
console.warn(`Warning: OPENCODE_BINARY="${process.env.OPENCODE_BINARY}" is not an executable file. Falling back to PATH lookup.`);
}
const resolvedFromPath = searchPathFor('opencode');
if (resolvedFromPath) {
process.env.OPENCODE_BINARY = resolvedFromPath;
return resolvedFromPath;
}
if (process.platform !== 'win32') {
const shellCandidates = [];
if (process.env.SHELL) {
shellCandidates.push(process.env.SHELL);
}
shellCandidates.push('/bin/bash', '/bin/zsh', '/bin/sh');
for (const shellPath of shellCandidates) {
if (!shellPath || !isExecutable(shellPath)) {
continue;
}
try {
const result = spawnSync(shellPath, ['-lic', 'command -v opencode'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
if (result.status === 0) {
const candidate = result.stdout.trim().split(/\s+/).pop();
if (candidate && isExecutable(candidate)) {
const dir = path.dirname(candidate);
const currentPath = process.env.PATH || '';
const segments = currentPath.split(path.delimiter).filter(Boolean);
if (!segments.includes(dir)) {
segments.unshift(dir);
process.env.PATH = segments.join(path.delimiter);
}
process.env.OPENCODE_BINARY = candidate;
return candidate;
}
}
} catch (error) {
}
}
} else {
try {
const result = spawnSync('where', ['opencode'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
if (result.status === 0) {
const candidate = result.stdout.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0);
if (candidate && isExecutable(candidate)) {
process.env.OPENCODE_BINARY = candidate;
return candidate;
}
}
} catch (error) {
}
}
console.error('Error: Unable to locate the opencode CLI on PATH.');
console.error(`Current PATH: ${process.env.PATH || '<empty>'}`);
console.error('Ensure the CLI is installed and reachable, or set OPENCODE_BINARY to its full path.');
process.exit(1);
}
async function getPidFilePath(port) {
const os = await import('os');
const tmpDir = os.tmpdir();
return path.join(tmpDir, `openchamber-${port}.pid`);
}
function readPidFile(pidFilePath) {
try {
const content = fs.readFileSync(pidFilePath, 'utf8').trim();
const pid = parseInt(content);
if (isNaN(pid)) {
return null;
}
return pid;
} catch (error) {
return null;
}
}
function writePidFile(pidFilePath, pid) {
try {
fs.writeFileSync(pidFilePath, pid.toString());
} catch (error) {
console.warn(`Warning: Could not write PID file: ${error.message}`);
}
}
function removePidFile(pidFilePath) {
try {
if (fs.existsSync(pidFilePath)) {
fs.unlinkSync(pidFilePath);
}
} catch (error) {
console.warn(`Warning: Could not remove PID file: ${error.message}`);
}
}
function isProcessRunning(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return false;
}
}
const commands = {
async serve(options) {
const pidFilePath = await getPidFilePath(options.port);
const existingPid = readPidFile(pidFilePath);
if (existingPid && isProcessRunning(existingPid)) {
console.error(`Error: OpenChamber is already running on port ${options.port} (PID: ${existingPid})`);
console.error('Use "openchamber stop" to stop the existing instance');
process.exit(1);
}
const opencodeBinary = await checkOpenCodeCLI();
const serverPath = path.join(__dirname, '..', 'server', 'index.js');
const serverArgs = [serverPath, '--port', options.port.toString()];
if (typeof options.uiPassword === 'string') {
serverArgs.push('--ui-password', options.uiPassword);
}
if (options.daemon) {
const child = spawn(process.execPath, serverArgs, {
detached: true,
stdio: 'ignore',
env: {
...process.env,
OPENCHAMBER_PORT: options.port.toString(),
OPENCODE_BINARY: opencodeBinary,
...(typeof options.uiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: options.uiPassword } : {})
}
});
child.unref();
setTimeout(() => {
if (isProcessRunning(child.pid)) {
writePidFile(pidFilePath, child.pid);
console.log(`OpenChamber started in daemon mode on port ${options.port}`);
console.log(`PID: ${child.pid}`);
console.log(`Visit: http://localhost:${options.port}`);
} else {
console.error('Failed to start server in daemon mode');
process.exit(1);
}
}, 1000);
} else {
process.env.OPENCODE_BINARY = opencodeBinary;
if (typeof options.uiPassword === 'string') {
process.env.OPENCHAMBER_UI_PASSWORD = options.uiPassword;
}
const { startWebUiServer } = await import(serverPath);
await startWebUiServer({
port: options.port,
attachSignals: true,
exitOnShutdown: true,
uiPassword: typeof options.uiPassword === 'string' ? options.uiPassword : null
});
}
},
async stop(options) {
const os = await import('os');
const tmpDir = os.tmpdir();
let runningInstances = [];
try {
const files = fs.readdirSync(tmpDir);
const pidFiles = files.filter(file => file.startsWith('openchamber-') && file.endsWith('.pid'));
for (const file of pidFiles) {
const port = parseInt(file.replace('openchamber-', '').replace('.pid', ''));
if (!isNaN(port)) {
const pidFilePath = path.join(tmpDir, file);
const pid = readPidFile(pidFilePath);
if (pid && isProcessRunning(pid)) {
runningInstances.push({ port, pid, pidFilePath });
} else {
removePidFile(pidFilePath);
}
}
}
} catch (error) {
}
if (runningInstances.length === 0) {
console.log('No running OpenChamber instances found');
return;
}
const portWasSpecified = process.argv.includes('--port') || process.argv.includes('-p');
if (portWasSpecified) {
const targetInstance = runningInstances.find(inst => inst.port === options.port);
if (!targetInstance) {
console.log(`No OpenChamber instance found running on port ${options.port}`);
return;
}
console.log(`Stopping OpenChamber (PID: ${targetInstance.pid}, Port: ${targetInstance.port})...`);
try {
process.kill(targetInstance.pid, 'SIGTERM');
let attempts = 0;
const maxAttempts = 10;
const checkShutdown = setInterval(() => {
attempts++;
if (!isProcessRunning(targetInstance.pid)) {
clearInterval(checkShutdown);
removePidFile(targetInstance.pidFilePath);
console.log('OpenChamber stopped successfully');
} else if (attempts >= maxAttempts) {
clearInterval(checkShutdown);
console.log('Force killing process...');
process.kill(targetInstance.pid, 'SIGKILL');
removePidFile(targetInstance.pidFilePath);
console.log('OpenChamber force stopped');
}
}, 500);
} catch (error) {
console.error(`Error stopping process: ${error.message}`);
process.exit(1);
}
} else {
console.log(`Stopping all OpenChamber instances (${runningInstances.length} found)...`);
for (const instance of runningInstances) {
console.log(` Stopping instance on port ${instance.port} (PID: ${instance.pid})...`);
try {
process.kill(instance.pid, 'SIGTERM');
let attempts = 0;
const maxAttempts = 10;
await new Promise((resolve) => {
const checkShutdown = setInterval(() => {
attempts++;
if (!isProcessRunning(instance.pid)) {
clearInterval(checkShutdown);
removePidFile(instance.pidFilePath);
console.log(` Port ${instance.port} stopped successfully`);
resolve(true);
} else if (attempts >= maxAttempts) {
clearInterval(checkShutdown);
console.log(` Force killing port ${instance.port}...`);
try {
process.kill(instance.pid, 'SIGKILL');
removePidFile(instance.pidFilePath);
console.log(` Port ${instance.port} force stopped`);
} catch (e) {
}
resolve(true);
}
}, 500);
});
} catch (error) {
console.error(` Error stopping port ${instance.port}: ${error.message}`);
}
}
console.log('\nAll OpenChamber instances stopped');
}
},
async restart(options) {
await commands.stop(options);
await commands.serve(options);
},
async status(options) {
const os = await import('os');
const tmpDir = os.tmpdir();
let runningInstances = [];
let stoppedInstances = [];
try {
const files = fs.readdirSync(tmpDir);
const pidFiles = files.filter(file => file.startsWith('openchamber-') && file.endsWith('.pid'));
for (const file of pidFiles) {
const port = parseInt(file.replace('openchamber-', '').replace('.pid', ''));
if (!isNaN(port)) {
const pidFilePath = path.join(tmpDir, file);
const pid = readPidFile(pidFilePath);
if (pid && isProcessRunning(pid)) {
runningInstances.push({ port, pid, pidFilePath });
} else {
removePidFile(pidFilePath);
stoppedInstances.push({ port });
}
}
}
} catch (error) {
}
if (runningInstances.length === 0) {
console.log('OpenChamber Status:');
console.log(' Status: Stopped');
if (stoppedInstances.length > 0) {
console.log(` Previously used ports: ${stoppedInstances.map(s => s.port).join(', ')}`);
}
return;
}
console.log('OpenChamber Status:');
for (const [index, instance] of runningInstances.entries()) {
if (runningInstances.length > 1) {
console.log(`\nInstance ${index + 1}:`);
}
console.log(' Status: Running');
console.log(` PID: ${instance.pid}`);
console.log(` Port: ${instance.port}`);
console.log(` Visit: http://localhost:${instance.port}`);
try {
const { execSync } = await import('child_process');
const startTime = execSync(`ps -o lstart= -p ${instance.pid}`, { encoding: 'utf8' }).trim();
console.log(` Start Time: ${startTime}`);
} catch (error) {
}
}
},
};
async function main() {
const { command, options } = parseArgs();
if (!commands[command]) {
console.error(`Error: Unknown command '${command}'`);
console.error('Use --help to see available commands');
process.exit(1);
}
try {
await commands[command](options);
} catch (error) {
console.error(`Error executing command '${command}': ${error.message}`);
process.exit(1);
}
}
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
process.exit(1);
});
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
process.exit(1);
});
main();
export { commands, parseArgs, getPidFilePath };
+194
View File
@@ -0,0 +1,194 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/logo-dark.svg" />
<link rel="icon" type="image/svg+xml" href="/logo-light.svg" media="(prefers-color-scheme: dark)" />
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon-16.png" sizes="16x16" />
<link rel="mask-icon" href="/logo-dark.svg" color="#edb449" />
<!-- Apple touch icon - PNG format required for iOS PWA support -->
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-180x180.png" />
<link rel="apple-touch-icon" sizes="167x167" href="/apple-touch-icon-167x167.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/apple-touch-icon-152x152.png" />
<!-- Web app manifest (data URL to avoid nginx auth issues) -->
<script>
const baseUrl = location.origin;
const manifest = {
"name": "OpenChamber - AI Coding Assistant",
"short_name": "OpenChamber",
"description": "Web interface companion for OpenCode AI coding agent",
"start_url": baseUrl + "/",
"display": "standalone",
"background_color": "#151313",
"theme_color": "#edb449",
"orientation": "portrait-primary",
"icons": [
{
"src": baseUrl + "/logo-dark.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": baseUrl + "/favicon-16.png",
"sizes": "16x16",
"type": "image/png"
},
{
"src": baseUrl + "/favicon-32.png",
"sizes": "32x32",
"type": "image/png"
}
],
"categories": ["developer", "tools", "productivity"],
"lang": "en"
};
const manifestBlob = new Blob([JSON.stringify(manifest)], {type: 'application/manifest+json'});
const manifestURL = URL.createObjectURL(manifestBlob);
const link = document.createElement('link');
link.rel = 'manifest';
link.href = manifestURL;
document.head.appendChild(link);
</script>
<script>
(function() {
try {
var variant = localStorage.getItem('selectedThemeVariant');
var useSystem = localStorage.getItem('useSystemTheme');
if (!variant || (variant !== 'light' && variant !== 'dark')) {
if (useSystem === null || useSystem === 'true') {
variant = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} else {
variant = 'dark';
}
}
document.documentElement.setAttribute('data-splash-variant', variant === 'light' ? 'light' : 'dark');
} catch (error) {
console.warn('Failed to apply splash variant:', error);
}
})();
</script>
<!-- Theme color - Safari iOS 26+ prioritizes CSS background-color over this, but keep as fallback -->
<meta name="theme-color" content="#151313" />
<meta name="theme-color" content="#151313" media="(prefers-color-scheme: dark)" />
<!-- iOS Safari PWA styling -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="OpenChamber" />
<title>OpenChamber - AI Coding Assistant</title>
<meta name="description" content="Web interface companion for OpenCode AI coding agent" />
<meta name="application-name" content="OpenChamber" />
<meta name="apple-mobile-web-app-title" content="OpenChamber" />
<!-- Inline CSS for loading screen (before Tailwind loads) -->
<style>
:root {
--splash-background: #151313;
--splash-foreground: #cdccc3;
--splash-rect: #4B4646;
--splash-grad-stop1: #F8F8F8;
--splash-grad-stop2: #DAD6D0;
--splash-grad-stop3: #BAB4AF;
--splash-stroke1: rgba(255, 255, 255, 0.08);
--splash-stroke2: rgba(0, 0, 0, 0.15);
--splash-stroke3: rgba(0, 0, 0, 0.2);
}
html[data-splash-variant='light'] {
--splash-background: #F6F4EF;
--splash-foreground: #453f37;
--splash-rect: #CFCDCD;
--splash-grad-stop1: #B3AEA6;
--splash-grad-stop2: #928E86;
--splash-grad-stop3: #6E6A63;
--splash-stroke1: rgba(255, 255, 255, 0.22);
--splash-stroke2: rgba(60, 56, 47, 0.25);
--splash-stroke3: rgba(43, 39, 34, 0.4);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.loading-pulse {
animation: pulse 2s ease-in-out infinite;
}
#initial-loading {
background-color: var(--splash-background);
color: var(--splash-foreground);
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-family: system-ui, -apple-system, sans-serif;
transition: opacity 0.3s ease-out;
position: absolute;
width: 100%;
z-index: 9999;
}
#initial-loading.fade-out {
opacity: 0;
pointer-events: none;
}
</style>
</head>
<body class="h-full bg-background text-foreground">
<div id="root" class="h-full">
<!-- Loading fallback while React initializes -->
<div id="initial-loading">
<div style="display: flex; align-items: center; justify-content: center;">
<svg class="loading-pulse" width="96" height="96" viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OpenChamber loading icon">
<defs>
<linearGradient id="loadingGlyphGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--splash-grad-stop1)"/>
<stop offset="55%" stop-color="var(--splash-grad-stop2)"/>
<stop offset="100%" stop-color="var(--splash-grad-stop3)"/>
</linearGradient>
<linearGradient id="loadingGlyphStroke" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--splash-stroke1)"/>
<stop offset="45%" stop-color="var(--splash-stroke2)"/>
<stop offset="100%" stop-color="var(--splash-stroke3)"/>
</linearGradient>
</defs>
<rect x="8.75" y="31" width="17.5" height="20.5" fill="var(--splash-rect)" />
<path d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z" fill="url(#loadingGlyphGradient)" stroke="url(#loadingGlyphStroke)" stroke-width="1.1" stroke-linejoin="round" />
<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z" fill="url(#loadingGlyphGradient)" stroke="url(#loadingGlyphStroke)" stroke-width="1.1" stroke-linejoin="round" />
</svg>
</div>
</div>
</div>
<script>
// Fallback: hide loading screen after 10 seconds if React fails to load
setTimeout(function() {
const loading = document.getElementById('initial-loading');
if (loading) {
console.warn('Loading screen timeout - forcing hide after 10s');
loading.classList.add('fade-out');
setTimeout(function() {
loading.remove();
}, 300);
}
}, 10000);
</script>
<!-- Polyfill for process before loading React -->
<script>
if (typeof process === 'undefined') {
window.process = { env: {} };
}
</script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+92
View File
@@ -0,0 +1,92 @@
{
"name": "@openchamber/web",
"version": "1.0.0",
"private": false,
"type": "module",
"main": "./server/index.js",
"types": "./server/index.d.ts",
"bin": {
"openchamber": "./bin/cli.js"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "pnpm run build:watch",
"dev:server": "node server/index.js --port 3001",
"dev:server:watch": "nodemon --watch server --ext js --exec \"node server/index.js --port 3001\"",
"build": "vite build",
"build:watch": "vite build --watch",
"type-check": "tsc --noEmit",
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js",
"start": "node server/index.js"
},
"dependencies": {
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1",
"@opencode-ai/sdk": "^1.0.65",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.7.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"express": "^5.1.0",
"http-proxy-middleware": "^3.0.5",
"next-themes": "^0.4.6",
"node-pty": "^1.0.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.6",
"remark-gfm": "^4.0.1",
"simple-git": "^3.28.0",
"sonner": "^2.0.7",
"strip-json-comments": "^5.0.3",
"tailwind-merge": "^3.3.1",
"yaml": "^2.8.1",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^24.3.1",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.21",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"cross-env": "^7.0.3",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"nodemon": "^3.1.7",
"tailwindcss": "^4.0.0",
"tsx": "^4.20.6",
"tw-animate-css": "^1.3.8",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.1",
"vite": "^7.1.2"
},
"files": [
"dist",
"server",
"bin",
"public",
"package.json",
"README.md"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 858 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 999 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 B

+18
View File
@@ -0,0 +1,18 @@
<svg width="180" height="180" viewBox="0 0 70 70" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(8.75, 8.75) scale(0.75)">
<!-- Letter O with white fill and thin black stroke -->
<path fill-rule="evenodd" clip-rule="evenodd"
d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z"
fill="white"
stroke="black"
stroke-width="1.1"
stroke-linejoin="round"/>
<!-- Letter C with white fill and thin black stroke -->
<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z"
fill="white"
stroke="black"
stroke-width="1.1"
stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 689 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="70" height="70" viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z" fill="black"/>
<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 306 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="70" height="70" viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z" fill="white"/>
<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 306 B

+36
View File
@@ -0,0 +1,36 @@
{
"name": "OpenChamber - AI Coding Companion",
"short_name": "OpenChamber",
"description": "OpenChamber desktop companion for the OpenCode AI coding agent",
"start_url": "/",
"display": "standalone",
"background_color": "#151313",
"theme_color": "#edb449",
"orientation": "portrait-primary",
"icons": [
{
"src": "/logo-dark.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "/logo-light.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "/favicon-16.png",
"sizes": "16x16",
"type": "image/png"
},
{
"src": "/favicon-32.png",
"sizes": "32x32",
"type": "image/png"
}
],
"categories": ["developer", "tools", "productivity"],
"lang": "en"
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+28
View File
@@ -0,0 +1,28 @@
import type { Express } from "express";
import type { Server } from "http";
export interface WebUiServerController {
expressApp: Express;
httpServer: Server;
getPort: () => number | null;
getOpenCodePort: () => number | null;
isReady: () => boolean;
restartOpenCode: () => Promise<void>;
stop: (options?: { exitProcess?: boolean }) => Promise<void>;
}
export interface StartWebUiServerOptions {
port?: number;
attachSignals?: boolean;
exitOnShutdown?: boolean;
uiPassword?: string | null;
}
export declare function startWebUiServer(
options?: StartWebUiServerOptions
): Promise<WebUiServerController>;
export declare function gracefulShutdown(options?: { exitProcess?: boolean }): Promise<void>;
export declare function setupProxy(app: Express): void;
export declare function restartOpenCode(): Promise<void>;
export declare function parseArgs(argv?: string[]): { port: number; uiPassword: string | null };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,108 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
const STORAGE_DIR = path.join(os.homedir(), '.config', 'openchamber');
const STORAGE_FILE = path.join(STORAGE_DIR, 'git-identities.json');
function ensureStorageDir() {
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
}
export function loadProfiles() {
ensureStorageDir();
if (!fs.existsSync(STORAGE_FILE)) {
return { profiles: [] };
}
try {
const content = fs.readFileSync(STORAGE_FILE, 'utf8');
const data = JSON.parse(content);
return data;
} catch (error) {
console.error('Failed to load git identity profiles:', error);
return { profiles: [] };
}
}
export function saveProfiles(data) {
ensureStorageDir();
try {
fs.writeFileSync(STORAGE_FILE, JSON.stringify(data, null, 2), 'utf8');
return true;
} catch (error) {
console.error('Failed to save git identity profiles:', error);
throw error;
}
}
export function getProfiles() {
const data = loadProfiles();
return data.profiles || [];
}
export function getProfile(id) {
const profiles = getProfiles();
return profiles.find(p => p.id === id) || null;
}
export function createProfile(profileData) {
const profiles = getProfiles();
if (profiles.some(p => p.id === profileData.id)) {
throw new Error(`Profile with ID "${profileData.id}" already exists`);
}
if (!profileData.id || !profileData.userName || !profileData.userEmail) {
throw new Error('Profile must have id, userName, and userEmail');
}
const newProfile = {
id: profileData.id,
name: profileData.name || profileData.userName,
userName: profileData.userName,
userEmail: profileData.userEmail,
sshKey: profileData.sshKey || null,
color: profileData.color || 'keyword',
icon: profileData.icon || 'branch'
};
profiles.push(newProfile);
saveProfiles({ profiles });
return newProfile;
}
export function updateProfile(id, updates) {
const profiles = getProfiles();
const index = profiles.findIndex(p => p.id === id);
if (index === -1) {
throw new Error(`Profile with ID "${id}" not found`);
}
profiles[index] = {
...profiles[index],
...updates,
id: profiles[index].id
};
saveProfiles({ profiles });
return profiles[index];
}
export function deleteProfile(id) {
const profiles = getProfiles();
const filteredProfiles = profiles.filter(p => p.id !== id);
if (filteredProfiles.length === profiles.length) {
throw new Error(`Profile with ID "${id}" not found`);
}
saveProfiles({ profiles: filteredProfiles });
return true;
}
+899
View File
@@ -0,0 +1,899 @@
import simpleGit from 'simple-git';
import fs from 'fs';
import path from 'path';
const fsp = fs.promises;
export async function isGitRepository(directory) {
if (!directory || !fs.existsSync(directory)) {
return false;
}
const gitDir = path.join(directory, '.git');
return fs.existsSync(gitDir);
}
export async function ensureOpenChamberIgnored(directory) {
if (!directory || !fs.existsSync(directory)) {
return false;
}
const gitDir = path.join(directory, '.git');
if (!fs.existsSync(gitDir)) {
return false;
}
const infoDir = path.join(gitDir, 'info');
const excludePath = path.join(infoDir, 'exclude');
const entry = '/.openchamber/';
try {
await fsp.mkdir(infoDir, { recursive: true });
let contents = '';
try {
contents = await fsp.readFile(excludePath, 'utf8');
} catch (readError) {
if (readError && readError.code !== 'ENOENT') {
throw readError;
}
}
const lines = contents.split(/\r?\n/).map((line) => line.trim());
if (!lines.includes(entry)) {
const prefix = contents.length > 0 && !contents.endsWith('\n') ? '\n' : '';
await fsp.appendFile(excludePath, `${prefix}${entry}\n`, 'utf8');
}
return true;
} catch (error) {
console.error('Failed to ensure .openchamber ignore:', error);
throw error;
}
}
export async function getGlobalIdentity() {
const git = simpleGit();
try {
const userName = await git.getConfig('user.name', 'global').catch(() => null);
const userEmail = await git.getConfig('user.email', 'global').catch(() => null);
const sshCommand = await git.getConfig('core.sshCommand', 'global').catch(() => null);
return {
userName: userName?.value || null,
userEmail: userEmail?.value || null,
sshCommand: sshCommand?.value || null
};
} catch (error) {
console.error('Failed to get global Git identity:', error);
return {
userName: null,
userEmail: null,
sshCommand: null
};
}
}
export async function getCurrentIdentity(directory) {
const git = simpleGit(directory);
try {
const userName = await git.getConfig('user.name', 'local').catch(() =>
git.getConfig('user.name', 'global')
);
const userEmail = await git.getConfig('user.email', 'local').catch(() =>
git.getConfig('user.email', 'global')
);
const sshCommand = await git.getConfig('core.sshCommand', 'local').catch(() =>
git.getConfig('core.sshCommand', 'global')
);
return {
userName: userName?.value || null,
userEmail: userEmail?.value || null,
sshCommand: sshCommand?.value || null
};
} catch (error) {
console.error('Failed to get current Git identity:', error);
return {
userName: null,
userEmail: null,
sshCommand: null
};
}
}
export async function setLocalIdentity(directory, profile) {
const git = simpleGit(directory);
try {
await git.addConfig('user.name', profile.userName, false, 'local');
await git.addConfig('user.email', profile.userEmail, false, 'local');
if (profile.sshKey) {
await git.addConfig(
'core.sshCommand',
`ssh -i ${profile.sshKey}`,
false,
'local'
);
}
return true;
} catch (error) {
console.error('Failed to set Git identity:', error);
throw error;
}
}
export async function getStatus(directory) {
const git = simpleGit(directory);
try {
const status = await git.status();
const [stagedStatsRaw, workingStatsRaw] = await Promise.all([
git.raw(['diff', '--cached', '--numstat']).catch(() => ''),
git.raw(['diff', '--numstat']).catch(() => ''),
]);
const diffStatsMap = new Map();
const accumulateStats = (raw) => {
if (!raw) return;
raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.forEach((line) => {
const parts = line.split('\t');
if (parts.length < 3) {
return;
}
const [insertionsRaw, deletionsRaw, ...pathParts] = parts;
const path = pathParts.join('\t');
if (!path) {
return;
}
const insertions = insertionsRaw === '-' ? 0 : parseInt(insertionsRaw, 10) || 0;
const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0;
const existing = diffStatsMap.get(path) || { insertions: 0, deletions: 0 };
diffStatsMap.set(path, {
insertions: existing.insertions + insertions,
deletions: existing.deletions + deletions,
});
});
};
accumulateStats(stagedStatsRaw);
accumulateStats(workingStatsRaw);
const diffStats = Object.fromEntries(diffStatsMap.entries());
const newFileStats = await Promise.all(
status.files.map(async (file) => {
const working = (file.working_dir || '').trim();
const indexStatus = (file.index || '').trim();
const statusCode = working || indexStatus;
if (statusCode !== '?' && statusCode !== 'A') {
return null;
}
const existing = diffStats[file.path];
if (existing && existing.insertions > 0) {
return null;
}
const absolutePath = path.join(directory, file.path);
try {
const stat = await fsp.stat(absolutePath);
if (!stat.isFile()) {
return null;
}
const buffer = await fsp.readFile(absolutePath);
if (buffer.indexOf(0) !== -1) {
return {
path: file.path,
insertions: existing?.insertions ?? 0,
deletions: existing?.deletions ?? 0,
};
}
const normalized = buffer.toString('utf8').replace(/\r\n/g, '\n');
if (!normalized.length) {
return {
path: file.path,
insertions: 0,
deletions: 0,
};
}
const segments = normalized.split('\n');
if (normalized.endsWith('\n')) {
segments.pop();
}
const lineCount = segments.length;
return {
path: file.path,
insertions: lineCount,
deletions: 0,
};
} catch (error) {
console.warn('Failed to estimate diff stats for new file', file.path, error);
return null;
}
})
);
for (const entry of newFileStats) {
if (!entry) continue;
diffStats[entry.path] = {
insertions: entry.insertions,
deletions: entry.deletions,
};
}
return {
current: status.current,
tracking: status.tracking,
ahead: status.ahead,
behind: status.behind,
files: status.files.map(f => ({
path: f.path,
index: f.index,
working_dir: f.working_dir
})),
isClean: status.isClean(),
diffStats,
};
} catch (error) {
console.error('Failed to get Git status:', error);
throw error;
}
}
export async function getDiff(directory, { path, staged = false, contextLines = 3 } = {}) {
const git = simpleGit(directory);
try {
const args = ['diff', '--no-color'];
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
args.push(`-U${Math.max(0, contextLines)}`);
}
if (staged) {
args.push('--cached');
}
if (path) {
args.push('--', path);
}
const diff = await git.raw(args);
if (diff && diff.trim().length > 0) {
return diff;
}
if (staged) {
return diff;
}
try {
await git.raw(['ls-files', '--error-unmatch', path]);
return diff;
} catch {
const noIndexArgs = ['diff', '--no-color'];
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
noIndexArgs.push(`-U${Math.max(0, contextLines)}`);
}
noIndexArgs.push('--no-index', '--', '/dev/null', path);
const noIndexDiff = await git.raw(noIndexArgs);
return noIndexDiff;
}
} catch (error) {
console.error('Failed to get Git diff:', error);
throw error;
}
}
export async function getFileDiff(directory, { path: filePath, staged = false } = {}) {
if (!directory || !filePath) {
throw new Error('directory and path are required for getFileDiff');
}
const git = simpleGit(directory);
let original = '';
try {
original = await git.show([`HEAD:${filePath}`]);
} catch {
original = '';
}
const fullPath = path.join(directory, filePath);
let modified = '';
try {
const stat = await fsp.stat(fullPath);
if (stat.isFile()) {
modified = await fsp.readFile(fullPath, 'utf8');
}
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
modified = '';
} else {
console.error('Failed to read modified file contents for diff:', error);
throw error;
}
}
return {
original,
modified,
path: filePath,
};
}
export async function revertFile(directory, filePath) {
const git = simpleGit(directory);
const repoRoot = path.resolve(directory);
const absoluteTarget = path.resolve(repoRoot, filePath);
if (!absoluteTarget.startsWith(repoRoot + path.sep) && absoluteTarget !== repoRoot) {
throw new Error('Invalid file path');
}
const isTracked = await git
.raw(['ls-files', '--error-unmatch', filePath])
.then(() => true)
.catch(() => false);
if (!isTracked) {
try {
await git.raw(['clean', '-f', '-d', '--', filePath]);
return;
} catch (cleanError) {
try {
await fsp.rm(absoluteTarget, { recursive: true, force: true });
return;
} catch (fsError) {
if (fsError && typeof fsError === 'object' && fsError.code === 'ENOENT') {
return;
}
console.error('Failed to remove untracked file during revert:', fsError);
throw fsError;
}
}
}
try {
await git.raw(['restore', '--staged', filePath]);
} catch (error) {
await git.raw(['reset', 'HEAD', '--', filePath]).catch(() => {});
}
try {
await git.raw(['restore', filePath]);
} catch (error) {
try {
await git.raw(['checkout', '--', filePath]);
} catch (fallbackError) {
console.error('Failed to revert git file:', fallbackError);
throw fallbackError;
}
}
}
export async function collectDiffs(directory, files = []) {
const results = [];
for (const filePath of files) {
try {
const diff = await getDiff(directory, { path: filePath });
if (diff && diff.trim().length > 0) {
results.push({ path: filePath, diff });
}
} catch (error) {
console.error(`Failed to diff ${filePath}:`, error);
}
}
return results;
}
export async function pull(directory, options = {}) {
const git = simpleGit(directory);
try {
const result = await git.pull(
options.remote || 'origin',
options.branch,
options.options || {}
);
return {
success: true,
summary: result.summary,
files: result.files,
insertions: result.insertions,
deletions: result.deletions
};
} catch (error) {
console.error('Failed to pull:', error);
throw error;
}
}
export async function push(directory, options = {}) {
const git = simpleGit(directory);
try {
const result = await git.push(
options.remote || 'origin',
options.branch,
options.options || {}
);
return {
success: true,
pushed: result.pushed,
repo: result.repo,
ref: result.ref
};
} catch (error) {
console.error('Failed to push:', error);
throw error;
}
}
export async function deleteRemoteBranch(directory, options = {}) {
const { branch, remote } = options;
if (!branch) {
throw new Error('branch is required to delete remote branch');
}
const git = simpleGit(directory);
const targetBranch = branch.startsWith('refs/heads/')
? branch.substring('refs/heads/'.length)
: branch;
const remoteName = remote || 'origin';
try {
await git.push(remoteName, `:${targetBranch}`);
return { success: true };
} catch (error) {
console.error('Failed to delete remote branch:', error);
throw error;
}
}
export async function fetch(directory, options = {}) {
const git = simpleGit(directory);
try {
await git.fetch(
options.remote || 'origin',
options.branch,
options.options || {}
);
return { success: true };
} catch (error) {
console.error('Failed to fetch:', error);
throw error;
}
}
export async function commit(directory, message, options = {}) {
const git = simpleGit(directory);
try {
if (options.addAll) {
await git.add('.');
} else if (Array.isArray(options.files) && options.files.length > 0) {
await git.add(options.files);
}
const commitArgs =
!options.addAll && Array.isArray(options.files) && options.files.length > 0
? options.files
: undefined;
const result = await git.commit(message, commitArgs);
return {
success: true,
commit: result.commit,
branch: result.branch,
summary: result.summary
};
} catch (error) {
console.error('Failed to commit:', error);
throw error;
}
}
export async function getBranches(directory) {
const git = simpleGit(directory);
try {
const result = await git.branch();
const allBranches = result.all;
const remoteBranches = allBranches.filter(branch => branch.startsWith('remotes/'));
const activeRemoteBranches = await filterActiveRemoteBranches(git, remoteBranches);
const filteredAll = [
...allBranches.filter(branch => !branch.startsWith('remotes/')),
...activeRemoteBranches
];
return {
all: filteredAll,
current: result.current,
branches: result.branches
};
} catch (error) {
console.error('Failed to get branches:', error);
throw error;
}
}
async function filterActiveRemoteBranches(git, remoteBranches) {
try {
const lsRemoteResult = await git.raw(['ls-remote', '--heads', 'origin']);
const actualRemoteBranches = new Set();
const lines = lsRemoteResult.trim().split('\n');
for (const line of lines) {
if (line.includes('\trefs/heads/')) {
const branchName = line.split('\t')[1].replace('refs/heads/', '');
actualRemoteBranches.add(branchName);
}
}
return remoteBranches.filter(remoteBranch => {
const match = remoteBranch.match(/^remotes\/[^\/]+\/(.+)$/);
if (!match) return false;
const branchName = match[1];
return actualRemoteBranches.has(branchName);
});
} catch (error) {
console.warn('Failed to filter active remote branches, returning all:', error.message);
return remoteBranches;
}
}
export async function createBranch(directory, branchName, options = {}) {
const git = simpleGit(directory);
try {
await git.checkoutBranch(branchName, options.startPoint || 'HEAD');
return { success: true, branch: branchName };
} catch (error) {
console.error('Failed to create branch:', error);
throw error;
}
}
export async function checkoutBranch(directory, branchName) {
const git = simpleGit(directory);
try {
await git.checkout(branchName);
return { success: true, branch: branchName };
} catch (error) {
console.error('Failed to checkout branch:', error);
throw error;
}
}
export async function getWorktrees(directory) {
const git = simpleGit(directory);
try {
const result = await git.raw(['worktree', 'list', '--porcelain']);
const worktrees = [];
const lines = result.split('\n');
let current = {};
for (const line of lines) {
if (line.startsWith('worktree ')) {
if (current.worktree) {
worktrees.push(current);
}
current = { worktree: line.substring(9) };
} else if (line.startsWith('HEAD ')) {
current.head = line.substring(5);
} else if (line.startsWith('branch ')) {
current.branch = line.substring(7);
} else if (line === '') {
if (current.worktree) {
worktrees.push(current);
current = {};
}
}
}
if (current.worktree) {
worktrees.push(current);
}
return worktrees;
} catch (error) {
console.error('Failed to list worktrees:', error);
throw error;
}
}
export async function addWorktree(directory, worktreePath, branch, options = {}) {
const git = simpleGit(directory);
try {
const args = ['worktree', 'add'];
if (options.createBranch) {
args.push('-b', branch);
}
args.push(worktreePath);
if (!options.createBranch) {
args.push(branch);
}
await git.raw(args);
return {
success: true,
path: worktreePath,
branch
};
} catch (error) {
console.error('Failed to add worktree:', error);
throw error;
}
}
export async function removeWorktree(directory, worktreePath, options = {}) {
const git = simpleGit(directory);
try {
const args = ['worktree', 'remove', worktreePath];
if (options.force) {
args.push('--force');
}
await git.raw(args);
return { success: true };
} catch (error) {
console.error('Failed to remove worktree:', error);
throw error;
}
}
export async function deleteBranch(directory, branch, options = {}) {
const git = simpleGit(directory);
try {
const branchName = branch.startsWith('refs/heads/')
? branch.substring('refs/heads/'.length)
: branch;
const args = ['branch', options.force ? '-D' : '-d', branchName];
await git.raw(args);
return { success: true };
} catch (error) {
console.error('Failed to delete branch:', error);
throw error;
}
}
export async function getLog(directory, options = {}) {
const git = simpleGit(directory);
try {
const maxCount = options.maxCount || 50;
const baseLog = await git.log({
maxCount,
from: options.from,
to: options.to,
file: options.file
});
const logArgs = [
'log',
`--max-count=${maxCount}`,
'--date=iso',
'--pretty=format:%H%x1f%an%x1f%ae%x1f%ad%x1f%s%x1e',
'--shortstat'
];
if (options.from && options.to) {
logArgs.push(`${options.from}..${options.to}`);
} else if (options.from) {
logArgs.push(`${options.from}..HEAD`);
} else if (options.to) {
logArgs.push(options.to);
}
if (options.file) {
logArgs.push('--', options.file);
}
const rawLog = await git.raw(logArgs);
const records = rawLog
.split('\x1e')
.map((entry) => entry.trim())
.filter(Boolean);
const statsMap = new Map();
records.forEach((record) => {
const lines = record.split('\n').filter((line) => line.trim().length > 0);
const header = lines.shift() || '';
const [hash] = header.split('\x1f');
if (!hash) {
return;
}
let filesChanged = 0;
let insertions = 0;
let deletions = 0;
lines.forEach((line) => {
const filesMatch = line.match(/(\d+)\s+files?\s+changed/);
const insertMatch = line.match(/(\d+)\s+insertions?\(\+\)/);
const deleteMatch = line.match(/(\d+)\s+deletions?\(-\)/);
if (filesMatch) {
filesChanged = parseInt(filesMatch[1], 10);
}
if (insertMatch) {
insertions = parseInt(insertMatch[1], 10);
}
if (deleteMatch) {
deletions = parseInt(deleteMatch[1], 10);
}
});
statsMap.set(hash, { filesChanged, insertions, deletions });
});
const merged = baseLog.all.map((entry) => {
const stats = statsMap.get(entry.hash) || { filesChanged: 0, insertions: 0, deletions: 0 };
return {
hash: entry.hash,
date: entry.date,
message: entry.message,
refs: entry.refs || '',
body: entry.body || '',
author_name: entry.author_name,
author_email: entry.author_email,
filesChanged: stats.filesChanged,
insertions: stats.insertions,
deletions: stats.deletions
};
});
return {
all: merged,
latest: merged[0] || null,
total: baseLog.total
};
} catch (error) {
console.error('Failed to get log:', error);
throw error;
}
}
export async function isLinkedWorktree(directory) {
const git = simpleGit(directory);
try {
const [gitDir, gitCommonDir] = await Promise.all([
git.raw(['rev-parse', '--git-dir']).then((output) => output.trim()),
git.raw(['rev-parse', '--git-common-dir']).then((output) => output.trim())
]);
return gitDir !== gitCommonDir;
} catch (error) {
console.error('Failed to determine worktree type:', error);
return false;
}
}
export async function getCommitFiles(directory, commitHash) {
const git = simpleGit(directory);
try {
const numstatRaw = await git.raw([
'show',
'--numstat',
'--format=',
commitHash
]);
const files = [];
const lines = numstatRaw.trim().split('\n').filter(Boolean);
for (const line of lines) {
const parts = line.split('\t');
if (parts.length < 3) continue;
const [insertionsRaw, deletionsRaw, ...pathParts] = parts;
const filePath = pathParts.join('\t');
if (!filePath) continue;
const insertions = insertionsRaw === '-' ? 0 : parseInt(insertionsRaw, 10) || 0;
const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0;
const isBinary = insertionsRaw === '-' && deletionsRaw === '-';
let changeType = 'M';
let displayPath = filePath;
if (filePath.includes(' => ')) {
changeType = 'R';
const match = filePath.match(/(?:\{[^}]*\s=>\s[^}]*\}|.*\s=>\s.*)/);
if (match) {
displayPath = filePath;
}
}
files.push({
path: displayPath,
insertions,
deletions,
isBinary,
changeType
});
}
const nameStatusRaw = await git.raw([
'show',
'--name-status',
'--format=',
commitHash
]).catch(() => '');
const statusMap = new Map();
const statusLines = nameStatusRaw.trim().split('\n').filter(Boolean);
for (const line of statusLines) {
const match = line.match(/^([AMDRC])\d*\t(.+)$/);
if (match) {
const [, status, path] = match;
statusMap.set(path, status);
}
}
for (const file of files) {
const basePath = file.path.includes(' => ')
? file.path.split(' => ').pop()?.replace(/[{}]/g, '') || file.path
: file.path;
const status = statusMap.get(basePath) || statusMap.get(file.path);
if (status) {
file.changeType = status;
}
}
return { files };
} catch (error) {
console.error('Failed to get commit files:', error);
throw error;
}
}
+471
View File
@@ -0,0 +1,471 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import yaml from 'yaml';
import stripJsonComments from 'strip-json-comments';
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agent');
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'command');
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json');
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
function ensureDirs() {
if (!fs.existsSync(OPENCODE_CONFIG_DIR)) {
fs.mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true });
}
if (!fs.existsSync(AGENT_DIR)) {
fs.mkdirSync(AGENT_DIR, { recursive: true });
}
if (!fs.existsSync(COMMAND_DIR)) {
fs.mkdirSync(COMMAND_DIR, { recursive: true });
}
}
function isPromptFileReference(value) {
if (typeof value !== 'string') {
return false;
}
return PROMPT_FILE_PATTERN.test(value.trim());
}
function resolvePromptFilePath(reference) {
const match = typeof reference === 'string' ? reference.trim().match(PROMPT_FILE_PATTERN) : null;
if (!match) {
return null;
}
let target = match[1].trim();
if (!target) {
return null;
}
if (target.startsWith('./')) {
target = target.slice(2);
target = path.join(OPENCODE_CONFIG_DIR, target);
} else if (!path.isAbsolute(target)) {
target = path.join(OPENCODE_CONFIG_DIR, target);
}
return target;
}
function writePromptFile(filePath, content) {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, content ?? '', 'utf8');
console.log(`Updated prompt file: ${filePath}`);
}
function readConfig() {
if (!fs.existsSync(CONFIG_FILE)) {
return {};
}
try {
const content = fs.readFileSync(CONFIG_FILE, 'utf8');
const normalized = stripJsonComments(content).trim();
if (!normalized) {
return {};
}
return JSON.parse(normalized);
} catch (error) {
console.error('Failed to read config file:', error);
throw new Error('Failed to read OpenCode configuration');
}
}
function writeConfig(config) {
try {
if (fs.existsSync(CONFIG_FILE)) {
const backupFile = `${CONFIG_FILE}.openchamber.backup`;
fs.copyFileSync(CONFIG_FILE, backupFile);
console.log(`Created config backup: ${backupFile}`);
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
console.log('Successfully wrote config file');
} catch (error) {
console.error('Failed to write config file:', error);
throw new Error('Failed to write OpenCode configuration');
}
}
function parseMdFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) {
return { frontmatter: {}, body: content.trim() };
}
const frontmatter = yaml.parse(match[1]) || {};
const body = match[2].trim();
return { frontmatter, body };
} catch (error) {
console.error(`Failed to parse markdown file ${filePath}:`, error);
throw new Error('Failed to parse agent markdown file');
}
}
function writeMdFile(filePath, frontmatter, body) {
try {
const yamlStr = yaml.stringify(frontmatter);
const content = `---\n${yamlStr}---\n\n${body}`;
fs.writeFileSync(filePath, content, 'utf8');
console.log(`Successfully wrote markdown file: ${filePath}`);
} catch (error) {
console.error(`Failed to write markdown file ${filePath}:`, error);
throw new Error('Failed to write agent markdown file');
}
}
function getAgentSources(agentName) {
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
const mdExists = fs.existsSync(mdPath);
const config = readConfig();
const jsonSection = config.agent?.[agentName];
const sources = {
md: {
exists: mdExists,
path: mdExists ? mdPath : null,
fields: []
},
json: {
exists: !!jsonSection,
path: CONFIG_FILE,
fields: []
}
};
if (mdExists) {
const { frontmatter, body } = parseMdFile(mdPath);
sources.md.fields = Object.keys(frontmatter);
if (body) {
sources.md.fields.push('prompt');
}
}
if (jsonSection) {
sources.json.fields = Object.keys(jsonSection);
}
return sources;
}
function createAgent(agentName, config) {
ensureDirs();
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
if (fs.existsSync(mdPath)) {
throw new Error(`Agent ${agentName} already exists as .md file`);
}
const existingConfig = readConfig();
if (existingConfig.agent?.[agentName]) {
throw new Error(`Agent ${agentName} already exists in opencode.json`);
}
const { prompt, ...frontmatter } = config;
writeMdFile(mdPath, frontmatter, prompt || '');
console.log(`Created new agent: ${agentName}`);
}
function updateAgent(agentName, updates) {
ensureDirs();
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
const mdExists = fs.existsSync(mdPath);
let mdData = mdExists ? parseMdFile(mdPath) : null;
let config = readConfig();
const jsonSection = config.agent?.[agentName];
let mdModified = false;
let jsonModified = false;
for (const [field, value] of Object.entries(updates)) {
if (field === 'prompt') {
const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
if (mdExists) {
mdData.body = normalizedValue;
mdModified = true;
} else if (isPromptFileReference(jsonSection?.prompt)) {
const promptFilePath = resolvePromptFilePath(jsonSection.prompt);
if (!promptFilePath) {
throw new Error(`Invalid prompt file reference for agent ${agentName}`);
}
writePromptFile(promptFilePath, normalizedValue);
} else if (isPromptFileReference(normalizedValue)) {
if (!config.agent) config.agent = {};
if (!config.agent[agentName]) config.agent[agentName] = {};
config.agent[agentName].prompt = normalizedValue;
jsonModified = true;
} else {
if (!config.agent) config.agent = {};
if (!config.agent[agentName]) config.agent[agentName] = {};
config.agent[agentName].prompt = normalizedValue;
jsonModified = true;
}
continue;
}
const inMd = mdData?.frontmatter?.[field] !== undefined;
const inJson = jsonSection?.[field] !== undefined;
if (inMd) {
mdData.frontmatter[field] = value;
mdModified = true;
} else if (inJson) {
if (!config.agent) config.agent = {};
if (!config.agent[agentName]) config.agent[agentName] = {};
config.agent[agentName][field] = value;
jsonModified = true;
} else {
if (mdExists && jsonSection) {
if (!config.agent) config.agent = {};
if (!config.agent[agentName]) config.agent[agentName] = {};
config.agent[agentName][field] = value;
jsonModified = true;
} else if (mdExists) {
mdData.frontmatter[field] = value;
mdModified = true;
} else {
if (!config.agent) config.agent = {};
if (!config.agent[agentName]) config.agent[agentName] = {};
config.agent[agentName][field] = value;
jsonModified = true;
}
}
}
if (mdModified) {
writeMdFile(mdPath, mdData.frontmatter, mdData.body);
}
if (jsonModified) {
writeConfig(config);
}
console.log(`Updated agent: ${agentName} (md: ${mdModified}, json: ${jsonModified})`);
}
function deleteAgent(agentName) {
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
let deleted = false;
if (fs.existsSync(mdPath)) {
fs.unlinkSync(mdPath);
console.log(`Deleted agent .md file: ${mdPath}`);
deleted = true;
}
const config = readConfig();
if (config.agent?.[agentName]) {
delete config.agent[agentName];
writeConfig(config);
console.log(`Removed agent from opencode.json: ${agentName}`);
deleted = true;
}
if (!deleted) {
if (!config.agent) config.agent = {};
config.agent[agentName] = { disable: true };
writeConfig(config);
console.log(`Disabled built-in agent: ${agentName}`);
}
}
function getCommandSources(commandName) {
const mdPath = path.join(COMMAND_DIR, `${commandName}.md`);
const mdExists = fs.existsSync(mdPath);
const config = readConfig();
const jsonSection = config.command?.[commandName];
const sources = {
md: {
exists: mdExists,
path: mdExists ? mdPath : null,
fields: []
},
json: {
exists: !!jsonSection,
path: CONFIG_FILE,
fields: []
}
};
if (mdExists) {
const { frontmatter, body } = parseMdFile(mdPath);
sources.md.fields = Object.keys(frontmatter);
if (body) {
sources.md.fields.push('template');
}
}
if (jsonSection) {
sources.json.fields = Object.keys(jsonSection);
}
return sources;
}
function createCommand(commandName, config) {
ensureDirs();
const mdPath = path.join(COMMAND_DIR, `${commandName}.md`);
if (fs.existsSync(mdPath)) {
throw new Error(`Command ${commandName} already exists as .md file`);
}
const existingConfig = readConfig();
if (existingConfig.command?.[commandName]) {
throw new Error(`Command ${commandName} already exists in opencode.json`);
}
const { template, ...frontmatter } = config;
writeMdFile(mdPath, frontmatter, template || '');
console.log(`Created new command: ${commandName}`);
}
function updateCommand(commandName, updates) {
ensureDirs();
const mdPath = path.join(COMMAND_DIR, `${commandName}.md`);
const mdExists = fs.existsSync(mdPath);
let mdData = mdExists ? parseMdFile(mdPath) : null;
let config = readConfig();
const jsonSection = config.command?.[commandName];
let mdModified = false;
let jsonModified = false;
for (const [field, value] of Object.entries(updates)) {
if (field === 'template') {
const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
if (mdExists) {
mdData.body = normalizedValue;
mdModified = true;
} else if (isPromptFileReference(jsonSection?.template)) {
const templateFilePath = resolvePromptFilePath(jsonSection.template);
if (!templateFilePath) {
throw new Error(`Invalid template file reference for command ${commandName}`);
}
writePromptFile(templateFilePath, normalizedValue);
} else if (isPromptFileReference(normalizedValue)) {
if (!config.command) config.command = {};
if (!config.command[commandName]) config.command[commandName] = {};
config.command[commandName].template = normalizedValue;
jsonModified = true;
} else {
if (!config.command) config.command = {};
if (!config.command[commandName]) config.command[commandName] = {};
config.command[commandName].template = normalizedValue;
jsonModified = true;
}
continue;
}
const inMd = mdData?.frontmatter?.[field] !== undefined;
const inJson = jsonSection?.[field] !== undefined;
if (inMd) {
mdData.frontmatter[field] = value;
mdModified = true;
} else if (inJson) {
if (!config.command) config.command = {};
if (!config.command[commandName]) config.command[commandName] = {};
config.command[commandName][field] = value;
jsonModified = true;
} else {
if (mdExists && jsonSection) {
if (!config.command) config.command = {};
if (!config.command[commandName]) config.command[commandName] = {};
config.command[commandName][field] = value;
jsonModified = true;
} else if (mdExists) {
mdData.frontmatter[field] = value;
mdModified = true;
} else {
if (!config.command) config.command = {};
if (!config.command[commandName]) config.command[commandName] = {};
config.command[commandName][field] = value;
jsonModified = true;
}
}
}
if (mdModified) {
writeMdFile(mdPath, mdData.frontmatter, mdData.body);
}
if (jsonModified) {
writeConfig(config);
}
console.log(`Updated command: ${commandName} (md: ${mdModified}, json: ${jsonModified})`);
}
function deleteCommand(commandName) {
const mdPath = path.join(COMMAND_DIR, `${commandName}.md`);
let deleted = false;
if (fs.existsSync(mdPath)) {
fs.unlinkSync(mdPath);
console.log(`Deleted command .md file: ${mdPath}`);
deleted = true;
}
const config = readConfig();
if (config.command?.[commandName]) {
delete config.command[commandName];
writeConfig(config);
console.log(`Removed command from opencode.json: ${commandName}`);
deleted = true;
}
if (!deleted) {
throw new Error(`Command "${commandName}" not found`);
}
}
export {
getAgentSources,
createAgent,
updateAgent,
deleteAgent,
getCommandSources,
createCommand,
updateCommand,
deleteCommand,
readConfig,
writeConfig,
AGENT_DIR,
COMMAND_DIR,
CONFIG_FILE
};
+12
View File
@@ -0,0 +1,12 @@
declare module "../server/lib/opencode-config.js" {
export function getAgentSources(agentName: string): {
md: { exists: boolean; path: string | null; fields: string[] };
json: { exists: boolean; path: string | null; fields: string[] };
};
export function createAgent(agentName: string, config: Record<string, unknown>): void;
export function updateAgent(agentName: string, updates: Record<string, unknown>): void;
export function deleteAgent(agentName: string): void;
}
+266
View File
@@ -0,0 +1,266 @@
import crypto from 'crypto';
const SESSION_COOKIE_NAME = 'oc_ui_session';
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
const isSecureRequest = (req) => {
if (req.secure) {
return true;
}
const forwardedProto = req.headers['x-forwarded-proto'];
if (typeof forwardedProto === 'string') {
const firstProto = forwardedProto.split(',')[0]?.trim().toLowerCase();
return firstProto === 'https';
}
return false;
};
const parseCookies = (cookieHeader) => {
if (!cookieHeader || typeof cookieHeader !== 'string') {
return {};
}
return cookieHeader.split(';').reduce((acc, segment) => {
const [name, ...rest] = segment.split('=');
if (!name) {
return acc;
}
const key = name.trim();
if (!key) {
return acc;
}
const value = rest.join('=').trim();
acc[key] = decodeURIComponent(value || '');
return acc;
}, {});
};
const buildCookie = ({
name,
value,
maxAge,
secure,
}) => {
const attributes = [
`${name}=${value}`,
'Path=/',
'HttpOnly',
'SameSite=Strict',
];
if (typeof maxAge === 'number') {
attributes.push(`Max-Age=${Math.max(0, Math.floor(maxAge))}`);
}
const expires = maxAge === 0
? 'Thu, 01 Jan 1970 00:00:00 GMT'
: new Date(Date.now() + maxAge * 1000).toUTCString();
attributes.push(`Expires=${expires}`);
if (secure) {
attributes.push('Secure');
}
return attributes.join('; ');
};
const normalizePassword = (candidate) => {
if (typeof candidate !== 'string') {
return '';
}
return candidate.normalize().trim();
};
export const createUiAuth = ({
password,
cookieName = SESSION_COOKIE_NAME,
sessionTtlMs = SESSION_TTL_MS,
} = {}) => {
const normalizedPassword = normalizePassword(password);
if (!normalizedPassword) {
return {
enabled: false,
requireAuth: (_req, _res, next) => next(),
handleSessionStatus: (_req, res) => {
res.json({ authenticated: true, disabled: true });
},
handleSessionCreate: (_req, res) => {
res.status(400).json({ error: 'UI password not configured' });
},
dispose: () => {
},
};
}
const salt = crypto.randomBytes(16);
const expectedHash = crypto.scryptSync(normalizedPassword, salt, 64);
const sessions = new Map();
let cleanupTimer = null;
const getTokenFromRequest = (req) => {
const cookies = parseCookies(req.headers.cookie);
if (cookies[cookieName]) {
return cookies[cookieName];
}
return null;
};
const dropSession = (token) => {
if (token) {
sessions.delete(token);
}
};
const setSessionCookie = (req, res, token) => {
const secure = isSecureRequest(req);
const maxAgeSeconds = Math.floor(sessionTtlMs / 1000);
const header = buildCookie({
name: cookieName,
value: encodeURIComponent(token),
maxAge: maxAgeSeconds,
secure,
});
res.setHeader('Set-Cookie', header);
};
const clearSessionCookie = (req, res) => {
const secure = isSecureRequest(req);
const header = buildCookie({
name: cookieName,
value: '',
maxAge: 0,
secure,
});
res.setHeader('Set-Cookie', header);
};
const verifyPassword = (candidate) => {
if (!candidate) {
return false;
}
const normalizedCandidate = normalizePassword(candidate);
if (!normalizedCandidate) {
return false;
}
try {
const candidateHash = crypto.scryptSync(normalizedCandidate, salt, 64);
return crypto.timingSafeEqual(candidateHash, expectedHash);
} catch {
return false;
}
};
const isSessionValid = (token) => {
if (!token) {
return false;
}
const record = sessions.get(token);
if (!record) {
return false;
}
if (Date.now() - record.lastSeen > sessionTtlMs) {
sessions.delete(token);
return false;
}
record.lastSeen = Date.now();
return true;
};
const issueSession = (req, res) => {
const token = crypto.randomBytes(32).toString('base64url');
const now = Date.now();
sessions.set(token, { createdAt: now, lastSeen: now });
setSessionCookie(req, res, token);
return token;
};
const cleanupStaleSessions = () => {
const now = Date.now();
for (const [token, record] of sessions.entries()) {
if (now - record.lastSeen > sessionTtlMs) {
sessions.delete(token);
}
}
};
const startCleanup = () => {
if (!cleanupTimer) {
cleanupTimer = setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
if (cleanupTimer && typeof cleanupTimer.unref === 'function') {
cleanupTimer.unref();
}
}
};
startCleanup();
const respondUnauthorized = (req, res) => {
res.status(401);
const acceptsJson = req.headers.accept?.includes('application/json');
if (acceptsJson || req.path.startsWith('/api')) {
res.json({ error: 'UI authentication required', locked: true });
} else {
res.type('text/plain').send('Authentication required');
}
};
const requireAuth = (req, res, next) => {
if (req.method === 'OPTIONS') {
return next();
}
const token = getTokenFromRequest(req);
if (isSessionValid(token)) {
return next();
}
clearSessionCookie(req, res);
return respondUnauthorized(req, res);
};
const handleSessionStatus = (req, res) => {
const token = getTokenFromRequest(req);
if (isSessionValid(token)) {
res.json({ authenticated: true });
return;
}
clearSessionCookie(req, res);
res.status(401).json({ authenticated: false, locked: true });
};
const handleSessionCreate = (req, res) => {
const candidate = typeof req.body?.password === 'string' ? req.body.password : '';
if (!verifyPassword(candidate)) {
clearSessionCookie(req, res);
res.status(401).json({ error: 'Invalid password', locked: true });
return;
}
const previousToken = getTokenFromRequest(req);
if (previousToken) {
dropSession(previousToken);
}
issueSession(req, res);
res.json({ authenticated: true });
};
const dispose = () => {
if (cleanupTimer) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
sessions.clear();
};
return {
enabled: true,
requireAuth,
handleSessionStatus,
handleSessionCreate,
dispose,
};
};
+70
View File
@@ -0,0 +1,70 @@
import type { DirectoryListResult, FileSearchQuery, FileSearchResult, FilesAPI } from '@openchamber/ui/lib/api/types';
const normalizePath = (path: string): string => path.replace(/\\/g, '/');
export const createWebFilesAPI = (): FilesAPI => ({
async listDirectory(path: string): Promise<DirectoryListResult> {
const target = normalizePath(path);
const response = await fetch('/api/fs/list', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: target }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to list directory');
}
return response.json();
},
async search(payload: FileSearchQuery): Promise<FileSearchResult[]> {
const response = await fetch('/api/fs/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
directory: normalizePath(payload.directory),
query: payload.query,
maxResults: payload.maxResults,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to search files');
}
const results = (await response.json()) as unknown;
if (!Array.isArray(results)) {
return [];
}
return results
.filter((item): item is FileSearchResult => !!item && typeof item === 'object' && typeof (item as { path?: string }).path === 'string')
.map((item) => ({
path: normalizePath((item as FileSearchResult).path),
score: (item as FileSearchResult).score,
preview: (item as FileSearchResult).preview,
}));
},
async createDirectory(path: string): Promise<{ success: boolean; path: string }> {
const target = normalizePath(path);
const response = await fetch('/api/fs/mkdir', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: target }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to create directory');
}
const result = await response.json();
return {
success: Boolean(result?.success),
path: typeof result?.path === 'string' ? normalizePath(result.path) : target,
};
},
});
+41
View File
@@ -0,0 +1,41 @@
import * as gitApiHttp from '@openchamber/ui/lib/gitApiHttp';
import type {
GitAPI,
CreateGitCommitOptions,
GitLogOptions,
} from '@openchamber/ui/lib/api/types';
export const createWebGitAPI = (): GitAPI => ({
checkIsGitRepository: gitApiHttp.checkIsGitRepository,
getGitStatus: gitApiHttp.getGitStatus,
getGitDiff: gitApiHttp.getGitDiff,
getGitFileDiff: gitApiHttp.getGitFileDiff,
revertGitFile: gitApiHttp.revertGitFile,
isLinkedWorktree: gitApiHttp.isLinkedWorktree,
getGitBranches: gitApiHttp.getGitBranches,
deleteGitBranch: gitApiHttp.deleteGitBranch as GitAPI['deleteGitBranch'],
deleteRemoteBranch: gitApiHttp.deleteRemoteBranch as GitAPI['deleteRemoteBranch'],
generateCommitMessage: gitApiHttp.generateCommitMessage,
listGitWorktrees: gitApiHttp.listGitWorktrees,
addGitWorktree: gitApiHttp.addGitWorktree as GitAPI['addGitWorktree'],
removeGitWorktree: gitApiHttp.removeGitWorktree as GitAPI['removeGitWorktree'],
ensureOpenChamberIgnored: gitApiHttp.ensureOpenChamberIgnored,
createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions) {
return gitApiHttp.createGitCommit(directory, message, options);
},
gitPush: gitApiHttp.gitPush,
gitPull: gitApiHttp.gitPull,
gitFetch: gitApiHttp.gitFetch,
checkoutBranch: gitApiHttp.checkoutBranch,
createBranch: gitApiHttp.createBranch,
getGitLog(directory: string, options?: GitLogOptions) {
return gitApiHttp.getGitLog(directory, options);
},
getCommitFiles: gitApiHttp.getCommitFiles,
getCurrentGitIdentity: gitApiHttp.getCurrentGitIdentity,
setGitIdentity: gitApiHttp.setGitIdentity,
getGitIdentities: gitApiHttp.getGitIdentities,
createGitIdentity: gitApiHttp.createGitIdentity,
updateGitIdentity: gitApiHttp.updateGitIdentity,
deleteGitIdentity: gitApiHttp.deleteGitIdentity,
});
+19
View File
@@ -0,0 +1,19 @@
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
import { createWebTerminalAPI } from './terminal';
import { createWebGitAPI } from './git';
import { createWebFilesAPI } from './files';
import { createWebSettingsAPI } from './settings';
import { createWebPermissionsAPI } from './permissions';
import { createWebNotificationsAPI } from './notifications';
import { createWebToolsAPI } from './tools';
export const createWebAPIs = (): RuntimeAPIs => ({
runtime: { platform: 'web', isDesktop: false, label: 'web' },
terminal: createWebTerminalAPI(),
git: createWebGitAPI(),
files: createWebFilesAPI(),
settings: createWebSettingsAPI(),
permissions: createWebPermissionsAPI(),
notifications: createWebNotificationsAPI(),
tools: createWebToolsAPI(),
});
+32
View File
@@ -0,0 +1,32 @@
import type { NotificationPayload, NotificationsAPI } from '@openchamber/ui/lib/api/types';
const notifyWithWebAPI = async (payload?: NotificationPayload): Promise<boolean> => {
if (typeof Notification === 'undefined') {
console.info('Notifications not supported in this environment', payload);
return false;
}
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.warn('Notification permission not granted');
return false;
}
try {
new Notification(payload?.title ?? 'OpenChamber', {
body: payload?.body,
tag: payload?.tag,
});
return true;
} catch (error) {
console.warn('Failed to send notification', error);
return false;
}
};
export const createWebNotificationsAPI = (): NotificationsAPI => ({
async notifyAgentCompletion(payload?: NotificationPayload): Promise<boolean> {
return notifyWithWebAPI(payload);
},
canNotify: () => (typeof Notification !== 'undefined' ? Notification.permission === 'granted' : false),
});
+15
View File
@@ -0,0 +1,15 @@
import type { DirectoryPermissionRequest, PermissionsAPI, StartAccessingResult } from '@openchamber/ui/lib/api/types';
export const createWebPermissionsAPI = (): PermissionsAPI => ({
async requestDirectoryAccess(request: DirectoryPermissionRequest) {
return { success: true, path: request.path };
},
async startAccessingDirectory(path: string): Promise<StartAccessingResult> {
void path;
return { success: true };
},
async stopAccessingDirectory(path: string): Promise<StartAccessingResult> {
void path;
return { success: true };
},
});
+58
View File
@@ -0,0 +1,58 @@
import type { SettingsAPI, SettingsLoadResult, SettingsPayload } from '@openchamber/ui/lib/api/types';
const SETTINGS_ENDPOINT = '/api/config/settings';
const RELOAD_ENDPOINT = '/api/config/reload';
const sanitizePayload = (data: unknown): SettingsPayload => {
if (!data || typeof data !== 'object') {
return {};
}
return data as SettingsPayload;
};
export const createWebSettingsAPI = (): SettingsAPI => ({
async load(): Promise<SettingsLoadResult> {
const response = await fetch(SETTINGS_ENDPOINT, {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Failed to load settings: ${response.statusText}`);
}
const payload = sanitizePayload(await response.json().catch(() => ({})));
return {
settings: payload,
source: 'web',
};
},
async save(changes: Partial<SettingsPayload>): Promise<SettingsPayload> {
const response = await fetch(SETTINGS_ENDPOINT, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(changes),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to save settings');
}
const payload = sanitizePayload(await response.json().catch(() => ({})));
return payload;
},
async restartOpenCode(): Promise<{ restarted: boolean }> {
const response = await fetch(RELOAD_ENDPOINT, { method: 'POST' });
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to restart OpenCode');
}
return { restarted: true };
},
});
+56
View File
@@ -0,0 +1,56 @@
import {
connectTerminalStream,
createTerminalSession,
resizeTerminal,
sendTerminalInput,
closeTerminal,
} from '@openchamber/ui/lib/terminalApi';
import type {
TerminalAPI,
TerminalHandlers,
TerminalStreamOptions,
CreateTerminalOptions,
ResizeTerminalPayload,
TerminalSession,
} from '@openchamber/ui/lib/api/types';
const getRetryPolicy = (options?: TerminalStreamOptions) => {
const retry = options?.retry;
return {
maxRetries: retry?.maxRetries ?? 3,
initialRetryDelay: retry?.initialDelayMs ?? 1000,
maxRetryDelay: retry?.maxDelayMs ?? 8000,
connectionTimeout: options?.connectionTimeoutMs ?? 10000,
};
};
export const createWebTerminalAPI = (): TerminalAPI => ({
async createSession(options: CreateTerminalOptions): Promise<TerminalSession> {
return createTerminalSession(options);
},
connect(sessionId: string, handlers: TerminalHandlers, options?: TerminalStreamOptions) {
const unsubscribe = connectTerminalStream(
sessionId,
handlers.onEvent,
handlers.onError,
getRetryPolicy(options)
);
return {
close: () => unsubscribe(),
};
},
async sendInput(sessionId: string, input: string): Promise<void> {
await sendTerminalInput(sessionId, input);
},
async resize(payload: ResizeTerminalPayload): Promise<void> {
await resizeTerminal(payload.sessionId, payload.cols, payload.rows);
},
async close(sessionId: string): Promise<void> {
await closeTerminal(sessionId);
},
});
+22
View File
@@ -0,0 +1,22 @@
import type { ToolsAPI } from '@openchamber/ui/lib/api/types';
export const createWebToolsAPI = (): ToolsAPI => ({
async getAvailableTools(): Promise<string[]> {
const response = await fetch('/api/experimental/tool/ids');
if (!response.ok) {
throw new Error(`Tools API returned ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (!Array.isArray(data)) {
throw new Error('Tools API returned invalid data format');
}
return data
.filter((tool: unknown): tool is string => typeof tool === 'string' && tool !== 'invalid')
.sort();
},
});
+13
View File
@@ -0,0 +1,13 @@
import { createWebAPIs } from './api';
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
import '@openchamber/ui/index.css';
import '@openchamber/ui/styles/fonts';
declare global {
interface Window {
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
}
}
window.__OPENCHAMBER_RUNTIME_APIS__ = createWebAPIs();
import('@openchamber/ui/main');
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"baseUrl": ".",
"types": ["vite/client"],
"paths": {
"@/*": ["../ui/src/*", "./src/*"],
"@web/*": ["./src/*"],
"@openchamber/ui/*": ["../ui/src/*"],
"@openchamber/web/*": ["./src/*"]
}
},
"include": ["src", "../ui/src", "../ui/src/types/**/*"]
}
+69
View File
@@ -0,0 +1,69 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { themeStoragePlugin } from '../../vite-theme-plugin';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
root: path.resolve(__dirname, '.'),
plugins: [
react(),
themeStoragePlugin(),
],
resolve: {
alias: {
'@': path.resolve(__dirname, '../ui/src'),
'@web': path.resolve(__dirname, './src'),
'@openchamber/ui': path.resolve(__dirname, '../ui/src'),
'@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'),
},
},
define: {
'process.env': {},
global: 'globalThis',
},
optimizeDeps: {
include: ['@opencode-ai/sdk'],
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:3001',
changeOrigin: true,
},
},
},
build: {
outDir: path.resolve(__dirname, 'dist'),
emptyOutDir: true,
chunkSizeWarningLimit: 1200,
rollupOptions: {
external: ['node:child_process', 'node:fs', 'node:path', 'node:url'],
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return undefined;
const match = id.split('node_modules/')[1];
if (!match) return undefined;
const segments = match.split('/');
const packageName = match.startsWith('@') ? `${segments[0]}/${segments[1]}` : segments[0];
if (packageName === 'react' || packageName === 'react-dom') return 'vendor-react';
if (packageName === 'zustand' || packageName === 'zustand/middleware') return 'vendor-zustand';
if (packageName === '@opencode-ai/sdk') return 'vendor-opencode-sdk';
if (packageName.includes('remark') || packageName.includes('rehype') || packageName === 'react-markdown') return 'vendor-markdown';
if (packageName.startsWith('@radix-ui')) return 'vendor-radix';
if (packageName.includes('react-syntax-highlighter') || packageName.includes('highlight.js')) return 'vendor-syntax';
const sanitized = packageName.replace(/^@/, '').replace(/\//g, '-');
return `vendor-${sanitized}`;
},
},
},
},
});