diff --git a/src/web/public/app.js b/src/web/public/app.js index 195a4c43..8e846c18 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -5440,7 +5440,7 @@ class ClaudemanApp { - ${mode === 'shell' ? 'sh' : ''} + ${mode === 'shell' ? 'sh' : mode === 'opencode' ? 'oc' : ''} ${this.escapeHtml(name)} ${showFolder ? `\u{1F4C1} ${this.escapeHtml(folderName)}` : ''} @@ -9507,6 +9507,48 @@ class ClaudemanApp { } } + async runOpenCode() { + const caseName = document.getElementById('quickStartCase').value || 'testcase'; + + this.terminal.clear(); + this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`); + this.terminal.writeln(''); + + try { + // Check if OpenCode is available + const statusRes = await fetch('/api/opencode/status'); + const status = await statusRes.json(); + if (!status.available) { + this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m'); + this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m'); + return; + } + + // Quick-start with opencode mode (auto-allow tools by default) + const res = await fetch('/api/quick-start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + caseName, + mode: 'opencode', + openCodeConfig: { autoAllowTools: true }, + }) + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to start OpenCode'); + + // Switch to the new session + if (data.sessionId) { + this.activeSessionId = data.sessionId; + await this.selectSession(data.sessionId); + } + + this.terminal.focus(); + } catch (err) { + this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); + } + } + // ========== Directory Input ========== toggleDirInput() { @@ -10263,8 +10305,8 @@ class ClaudemanApp { this.editingSessionId = sessionId; - // Reset to Respawn tab - this.switchOptionsTab('respawn'); + // Reset to an appropriate tab — Summary for OpenCode (Respawn/Ralph are Claude-only) + this.switchOptionsTab(session.mode === 'opencode' ? 'summary' : 'respawn'); // Update respawn status display and buttons const respawnStatus = document.getElementById('sessionRespawnStatus'); @@ -10292,6 +10334,11 @@ class ClaudemanApp { respawnSection.style.display = 'none'; } + // Hide Claude-specific options for OpenCode sessions + const isOpenCode = session.mode === 'opencode'; + const claudeOnlyEls = document.querySelectorAll('[data-claude-only]'); + claudeOnlyEls.forEach(el => { el.style.display = isOpenCode ? 'none' : ''; }); + // Reset duration presets to default (unlimited) this.selectDurationPreset(''); diff --git a/src/web/public/index.html b/src/web/public/index.html index f8cffcb6..50e56dca 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -238,6 +238,10 @@ Run Claude + + + Run OpenCode + Start Ralph Loop Wizard @@ -317,6 +321,9 @@ + + + Run OC + testcase @@ -496,9 +503,9 @@ - Respawn - Context - Ralph / Todo + Respawn + Context + Ralph / Todo Summary diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 61373b3b..908b8b3a 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -616,6 +616,11 @@ body { color: var(--yellow); } +.session-tab .tab-mode.opencode { + background: rgba(16, 185, 129, 0.2); + color: #10b981; +} + /* Timer Banner - Compact */ .timer-banner { display: flex; @@ -1727,6 +1732,19 @@ body { color: #fff; } +.welcome-btn-opencode { + background: linear-gradient(135deg, #0a2e2a 0%, #0d4a40 100%); + border-color: #10b981; + color: #6ee7b7; +} + +.welcome-btn-opencode:hover { + background: linear-gradient(135deg, #0d4a40 0%, #116a58 100%); + border-color: #34d399; + box-shadow: 0 0 12px rgba(16, 185, 129, 0.4); + color: #a7f3d0; +} + .welcome-btn-ralph { background: linear-gradient(135deg, #3d3415 0%, #4a3f1a 100%); border-color: #6b5a1e; @@ -1876,6 +1894,20 @@ body { color: #a8d8a8; } +.btn-toolbar.btn-opencode { + background: linear-gradient(135deg, #0a2e2a 0%, #0d4a40 100%); + border-color: #10b981; + color: #6ee7b7; + font-weight: 500; +} + +.btn-toolbar.btn-opencode:hover { + background: linear-gradient(135deg, #0d4a40 0%, #116a58 100%); + border-color: #34d399; + box-shadow: 0 0 8px rgba(16, 185, 129, 0.4); + color: #a7f3d0; +} + /* Stop button - hidden on desktop, shown on mobile */ .btn-toolbar.btn-stop { display: none; diff --git a/src/web/schemas.ts b/src/web/schemas.ts index a6c10aaf..8ef87ba6 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -34,12 +34,13 @@ const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, { // ========== Env Var Allowlist ========== /** Allowlisted env var key prefixes */ -const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_']; +const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_']; /** Env var keys that are always blocked (security-sensitive) */ const BLOCKED_ENV_KEYS = new Set([ 'PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'NODE_OPTIONS', 'CLAUDEMAN_MUX_NAME', 'CLAUDEMAN_TMUX', + 'OPENCODE_SERVER_PASSWORD', // Security-sensitive: server auth password ]); /** Validate that an env var key is allowed */ @@ -54,7 +55,7 @@ const safeEnvOverridesSchema = z.record(z.string(), z.string()).optional().refin if (!val) return true; return Object.keys(val).every(isAllowedEnvKey); }, - { message: 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_* keys are allowed.' }, + { message: 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_* and OPENCODE_* keys are allowed.' }, ); // ========== Session Routes ========== @@ -63,11 +64,21 @@ const safeEnvOverridesSchema = z.record(z.string(), z.string()).optional().refin * Schema for POST /api/sessions * Creates a new session with optional working directory, mode, and name. */ +/** Schema for OpenCode-specific configuration */ +const OpenCodeConfigSchema = z.object({ + model: z.string().max(100).regex(/^[a-zA-Z0-9._\-/]+$/).optional(), + autoAllowTools: z.boolean().optional(), + continueSession: z.string().max(100).regex(/^[a-zA-Z0-9_-]+$/).optional(), + forkSession: z.boolean().optional(), + configContent: z.string().max(10000).optional(), +}).optional(); + export const CreateSessionSchema = z.object({ workingDir: safePathSchema.optional(), - mode: z.enum(['claude', 'shell']).optional(), + mode: z.enum(['claude', 'shell', 'opencode']).optional(), name: z.string().max(100).optional(), envOverrides: safeEnvOverridesSchema, + openCodeConfig: OpenCodeConfigSchema, }); /** @@ -106,7 +117,8 @@ export const CreateCaseSchema = z.object({ */ export const QuickStartSchema = z.object({ caseName: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.').optional(), - mode: z.enum(['claude', 'shell']).optional(), + mode: z.enum(['claude', 'shell', 'opencode']).optional(), + openCodeConfig: OpenCodeConfigSchema, }); // ========== Hook Events ========== diff --git a/src/web/server.ts b/src/web/server.ts index 73e1eb3f..34108feb 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -667,6 +667,15 @@ export class WebServer extends EventEmitter { // API Routes this.app.get('/api/status', async () => this.getLightState()); + // OpenCode CLI availability check + this.app.get('/api/opencode/status', async () => { + const { isOpenCodeAvailable, resolveOpenCodeDir } = await import('../utils/opencode-cli-resolver.js'); + return { + available: isOpenCodeAvailable(), + path: resolveOpenCodeDir(), + }; + }); + // Cleanup stale sessions from state file this.app.post('/api/cleanup-state', async () => { const cleaned = this.cleanupStaleSessions(); @@ -837,13 +846,24 @@ export class WebServer extends EventEmitter { await updateCaseEnvVars(workingDir, body.envOverrides); } + // Check OpenCode availability if requested + if (body.mode === 'opencode') { + const { isOpenCodeAvailable } = await import('../utils/opencode-cli-resolver.js'); + if (!isOpenCodeAvailable()) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'); + } + } + const globalNice = await this.getGlobalNiceConfig(); const modelConfig = await this.getModelConfig(); - const model = (body.mode !== 'shell') ? modelConfig?.defaultModel : undefined; + const mode = body.mode || 'claude'; + const model = mode === 'opencode' + ? body.openCodeConfig?.model + : (mode !== 'shell' ? modelConfig?.defaultModel : undefined); const claudeModeConfig = await this.getClaudeModeConfig(); const session = new Session({ workingDir, - mode: body.mode || 'claude', + mode, name: body.name || '', mux: this.mux, useMux: true, @@ -851,6 +871,7 @@ export class WebServer extends EventEmitter { model, claudeMode: claudeModeConfig.claudeMode, allowedTools: claudeModeConfig.allowedTools, + openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined, }); this.sessions.set(session.id, session); @@ -1654,7 +1675,7 @@ export class WebServer extends EventEmitter { } await session.startInteractive(); - getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: 'claude' }); + getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: session.mode }); this.broadcast('session:interactive', { id }); this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); @@ -2004,7 +2025,7 @@ export class WebServer extends EventEmitter { // Start interactive session await session.startInteractive(); - getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: 'claude', reason: 'interactive_respawn' }); + getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: session.mode, reason: 'interactive_respawn' }); this.broadcast('session:interactive', { id }); this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); @@ -2598,7 +2619,15 @@ export class WebServer extends EventEmitter { if (!result.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); } - const { caseName = 'testcase', mode = 'claude' } = result.data; + const { caseName = 'testcase', mode = 'claude', openCodeConfig } = result.data; + + // Check OpenCode availability if requested + if (mode === 'opencode') { + const { isOpenCodeAvailable } = await import('../utils/opencode-cli-resolver.js'); + if (!isOpenCodeAvailable()) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'); + } + } const casePath = join(casesDir, caseName); @@ -2622,7 +2651,10 @@ export class WebServer extends EventEmitter { writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); // Write .claude/settings.local.json with hooks for desktop notifications - await writeHooksConfig(casePath); + // (Claude-specific — OpenCode uses its own plugin system) + if (mode !== 'opencode') { + await writeHooksConfig(casePath); + } this.broadcast('case:created', { name: caseName, path: casePath }); } catch (err) { @@ -2634,7 +2666,9 @@ export class WebServer extends EventEmitter { // Apply global Nice priority config and model config from settings const niceConfig = await this.getGlobalNiceConfig(); const qsModelConfig = await this.getModelConfig(); - const qsModel = (mode !== 'shell') ? qsModelConfig?.defaultModel : undefined; + const qsModel = mode === 'opencode' + ? openCodeConfig?.model + : (mode !== 'shell' ? qsModelConfig?.defaultModel : undefined); const qsClaudeModeConfig = await this.getClaudeModeConfig(); const session = new Session({ workingDir: casePath, @@ -2645,6 +2679,7 @@ export class WebServer extends EventEmitter { model: qsModel, claudeMode: qsClaudeModeConfig.claudeMode, allowedTools: qsClaudeModeConfig.allowedTools, + openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined, }); // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting @@ -2671,9 +2706,10 @@ export class WebServer extends EventEmitter { getLifecycleLog().log({ event: 'started', sessionId: session.id, name: session.name, mode: 'shell' }); this.broadcast('session:interactive', { id: session.id, mode: 'shell' }); } else { + // Both 'claude' and 'opencode' modes use startInteractive() await session.startInteractive(); - getLifecycleLog().log({ event: 'started', sessionId: session.id, name: session.name, mode: 'claude' }); - this.broadcast('session:interactive', { id: session.id }); + getLifecycleLog().log({ event: 'started', sessionId: session.id, name: session.name, mode }); + this.broadcast('session:interactive', { id: session.id, mode }); } this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });