From 0b60f2691f8fc410f173285a6e542de9d7deef95 Mon Sep 17 00:00:00 2001 From: arkon Date: Thu, 26 Feb 2026 10:07:43 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20OpenCode=20integration=20Phases=201-3?= =?UTF-8?q?=20=E2=80=94=20types,=20CLI=20resolver,=20tmux=20spawn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: Type system - Add SessionMode = 'claude' | 'shell' | 'opencode' to types.ts (single source of truth) - Add OpenCodeConfig interface (model, autoAllowTools, continueSession, etc.) - Add openCodeConfig to SessionState - Import SessionMode in session.ts, mux-interface.ts (remove duplicates) - Refactor createSession/respawnPane to options objects (CreateSessionOptions, RespawnPaneOptions) Phase 2: CLI resolver - New opencode-cli-resolver.ts mirroring claude-cli-resolver.ts - Searches ~/.opencode/bin, ~/.local/bin, /usr/local/bin, ~/go/bin, etc. - Exported via utils/index.ts Phase 3: Tmux spawn - Extract buildSpawnCommand() shared helper (eliminates duplication) - Add buildOpenCodeCommand() for opencode CLI flags - Add setOpenCodeEnvVars() — API keys via tmux setenv (not visible in ps) - Add setOpenCodeConfigContent() — JSON config via tmux setenv (prevents shell injection) - Gate _processExpensiveParsers() for opencode mode (skip Claude-specific parsers) - OpenCode-specific TUI ready detection (timeout-based, no ❯ prompt scanning) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/mux-interface.ts | 46 ++++--- src/session.ts | 111 ++++++++++------ src/tmux-manager.ts | 203 +++++++++++++++++++++++------ src/types.ts | 23 +++- src/utils/index.ts | 1 + src/utils/opencode-cli-resolver.ts | 95 ++++++++++++++ 6 files changed, 386 insertions(+), 93 deletions(-) create mode 100644 src/utils/opencode-cli-resolver.ts diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 456ad7bd..fd475b5f 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -7,7 +7,7 @@ */ import type { EventEmitter } from 'node:events'; -import type { ProcessStats, PersistedRespawnConfig, NiceConfig, ClaudeMode } from './types.js'; +import type { ProcessStats, PersistedRespawnConfig, NiceConfig, ClaudeMode, SessionMode, OpenCodeConfig } from './types.js'; /** * Multiplexer session metadata. @@ -23,8 +23,8 @@ export interface MuxSession { createdAt: number; /** Working directory */ workingDir: string; - /** Session mode: claude or shell */ - mode: 'claude' | 'shell'; + /** Session mode */ + mode: SessionMode; /** Whether webserver is attached to this session */ attached: boolean; /** Session display name (tab name) */ @@ -43,6 +43,31 @@ export interface MuxSessionWithStats extends MuxSession { stats?: ProcessStats; } +/** Options for creating a new multiplexer session. */ +export interface CreateSessionOptions { + sessionId: string; + workingDir: string; + mode: SessionMode; + name?: string; + niceConfig?: NiceConfig; + model?: string; + claudeMode?: ClaudeMode; + allowedTools?: string; + openCodeConfig?: OpenCodeConfig; +} + +/** Options for respawning a dead pane. */ +export interface RespawnPaneOptions { + sessionId: string; + workingDir: string; + mode: SessionMode; + niceConfig?: NiceConfig; + model?: string; + claudeMode?: ClaudeMode; + allowedTools?: string; + openCodeConfig?: OpenCodeConfig; +} + /** * Terminal multiplexer interface. * @@ -62,18 +87,9 @@ export interface TerminalMultiplexer extends EventEmitter { /** * Create a new multiplexer session. - * The session runs the appropriate command (claude or shell) in detached mode. + * The session runs the appropriate command (claude, opencode, or shell) in detached mode. */ - createSession( - sessionId: string, - workingDir: string, - mode: 'claude' | 'shell', - name?: string, - niceConfig?: NiceConfig, - model?: string, - claudeMode?: ClaudeMode, - allowedTools?: string, - ): Promise; + createSession(options: CreateSessionOptions): Promise; /** * Kill a session and all its child processes. @@ -165,5 +181,5 @@ export interface TerminalMultiplexer extends EventEmitter { isPaneDead(muxName: string): boolean; /** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */ - respawnPane(sessionId: string, workingDir: string, mode: 'claude' | 'shell', niceConfig?: NiceConfig, model?: string, claudeMode?: ClaudeMode, allowedTools?: string): Promise; + respawnPane(options: RespawnPaneOptions): Promise; } diff --git a/src/session.ts b/src/session.ts index 015a1a86..06ca2c6f 100644 --- a/src/session.ts +++ b/src/session.ts @@ -18,7 +18,7 @@ import { EventEmitter } from 'node:events'; import { v4 as uuidv4 } from 'uuid'; import * as pty from 'node-pty'; -import { SessionState, SessionStatus, SessionConfig, RalphTrackerState, RalphTodoItem, ActiveBashTool, NiceConfig, DEFAULT_NICE_CONFIG, type ClaudeMode } from './types.js'; +import { SessionState, SessionStatus, SessionConfig, RalphTrackerState, RalphTodoItem, ActiveBashTool, NiceConfig, DEFAULT_NICE_CONFIG, type ClaudeMode, type SessionMode, type OpenCodeConfig } from './types.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { RalphTracker } from './ralph-tracker.js'; @@ -196,12 +196,9 @@ export interface SessionEvents { cliInfoUpdated: (info: { version: string | null; model: string | null; accountType: string | null; latestVersion: string | null }) => void; } -/** - * Session operation mode. - * - `'claude'`: Runs Claude CLI for AI interactions (default) - * - `'shell'`: Runs a plain bash shell for debugging/testing - */ -export type SessionMode = 'claude' | 'shell'; +// SessionMode is imported from types.ts (single source of truth) +// Re-export for backwards compatibility with any external consumers +export type { SessionMode } from './types.js'; /** * Core session class that wraps a PTY process running Claude CLI or a shell. @@ -325,6 +322,9 @@ export class Session extends EventEmitter { private _claudeMode: ClaudeMode = 'dangerously-skip-permissions'; private _allowedTools: string | undefined; + // OpenCode configuration (only for mode === 'opencode') + private _openCodeConfig: OpenCodeConfig | undefined; + // Session color for visual differentiation private _color: import('./types.js').SessionColor = 'default'; @@ -382,6 +382,8 @@ export class Session extends EventEmitter { claudeMode?: ClaudeMode; /** Comma-separated allowed tools (for 'allowedTools' mode) */ allowedTools?: string; + /** OpenCode configuration (only for mode === 'opencode') */ + openCodeConfig?: OpenCodeConfig; }) { super(); this.setMaxListeners(25); @@ -425,6 +427,11 @@ export class Session extends EventEmitter { this._allowedTools = config.allowedTools; } + // Apply OpenCode configuration + if (config.openCodeConfig) { + this._openCodeConfig = config.openCodeConfig; + } + // Initialize task tracker and forward events (store handlers for cleanup) this._taskTracker = new TaskTracker(); this._taskTrackerHandlers = { @@ -812,6 +819,7 @@ export class Session extends EventEmitter { cliModel: this._cliModel || undefined, cliAccountType: this._cliAccountType || undefined, cliLatestVersion: this._cliLatestVersion || undefined, + openCodeConfig: this._openCodeConfig, }; } @@ -902,7 +910,8 @@ export class Session extends EventEmitter { this._lineBuffer = ''; this._lastActivityAt = Date.now(); - console.log('[Session] Starting interactive Claude session' + (this._useMux ? ` (with ${this._mux!.backend})` : '')); + const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude'; + console.log(`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')); // If mux wrapping is enabled, create or attach to a mux session if (this._useMux && this._mux) { @@ -918,7 +927,11 @@ export class Session extends EventEmitter { let needsNewSession = false; if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) { console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName); - const newPid = await this._mux.respawnPane(this.id, this.workingDir, 'claude', this._niceConfig, this._model, this._claudeMode, this._allowedTools); + const newPid = await this._mux.respawnPane({ + sessionId: this.id, workingDir: this.workingDir, mode: this.mode, + niceConfig: this._niceConfig, model: this._model, claudeMode: this._claudeMode, + allowedTools: this._allowedTools, openCodeConfig: this._openCodeConfig, + }); if (!newPid) { console.error('[Session] Failed to respawn pane, will create new session'); needsNewSession = true; @@ -934,7 +947,12 @@ export class Session extends EventEmitter { console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName); } else { // Create a new mux session - this._muxSession = await this._mux.createSession(this.id, this.workingDir, 'claude', this._name, this._niceConfig, this._model, this._claudeMode, this._allowedTools); + this._muxSession = await this._mux.createSession({ + sessionId: this.id, workingDir: this.workingDir, mode: this.mode, + name: this._name, niceConfig: this._niceConfig, model: this._model, + claudeMode: this._claudeMode, allowedTools: this._allowedTools, + openCodeConfig: this._openCodeConfig, + }); console.log('[Session] Created mux session:', this._muxSession.muxName); // No extra sleep — createSession() already waits for tmux readiness } @@ -961,39 +979,50 @@ export class Session extends EventEmitter { throw spawnErr; } - // For NEW mux sessions: wait for prompt to appear then clean buffer + // For NEW mux sessions: wait for readiness then clean buffer // For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch if (!isRestoredSession) { - this._promptCheckInterval = setInterval(() => { - // Wait for the prompt character (❯) which means Claude is fully initialized - const bufferValue = this._terminalBuffer.value; - if (bufferValue.includes('❯') || bufferValue.includes('\u276f')) { + if (this.mode === 'opencode') { + // OpenCode uses Bubble Tea TUI — no ❯ prompt to detect. + // Wait for TUI to stabilize (output stops changing), then mark ready. + // Don't clear the buffer — the TUI's initial render IS the useful content. + this._promptCheckTimeout = setTimeout(() => { + this._promptCheckTimeout = null; + this._status = 'idle'; + }, 3000); + } else { + // Claude mode: wait for ❯ prompt + this._promptCheckInterval = setInterval(() => { + // Wait for the prompt character (❯) which means Claude is fully initialized + const bufferValue = this._terminalBuffer.value; + if (bufferValue.includes('❯') || bufferValue.includes('\u276f')) { + if (this._promptCheckInterval) { + clearInterval(this._promptCheckInterval); + this._promptCheckInterval = null; + } + if (this._promptCheckTimeout) { + clearTimeout(this._promptCheckTimeout); + this._promptCheckTimeout = null; + } + // Clean the buffer - remove mux init junk before actual content + // Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH), + // clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace + this._terminalBuffer.set( + bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, '') + ); + // Signal client to refresh + this.emit('clearTerminal'); + } + }, 50); + // Timeout after 5 seconds if prompt not found + this._promptCheckTimeout = setTimeout(() => { if (this._promptCheckInterval) { clearInterval(this._promptCheckInterval); this._promptCheckInterval = null; } - if (this._promptCheckTimeout) { - clearTimeout(this._promptCheckTimeout); - this._promptCheckTimeout = null; - } - // Clean the buffer - remove mux init junk before actual content - // Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH), - // clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace - this._terminalBuffer.set( - bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, '') - ); - // Signal client to refresh - this.emit('clearTerminal'); - } - }, 50); - // Timeout after 5 seconds if prompt not found - this._promptCheckTimeout = setTimeout(() => { - if (this._promptCheckInterval) { - clearInterval(this._promptCheckInterval); - this._promptCheckInterval = null; - } - this._promptCheckTimeout = null; - }, 5000); + this._promptCheckTimeout = null; + }, 5000); + } } } catch (err) { console.error('[Session] Failed to create mux session, falling back to direct PTY:', err); @@ -1173,6 +1202,10 @@ export class Session extends EventEmitter { * PTY data chunk. Receives accumulated raw data to process in one batch. */ private _processExpensiveParsers(rawData: string): void { + // Skip Claude-specific parsers for OpenCode sessions — Ralph tracker, BashToolParser, + // token parsing, and CLI info parsing all depend on Claude's output format. + if (this.mode === 'opencode') return; + // Lazy ANSI strip: only compute cleanData when a consumer actually needs it. let _cleanData: string | null = null; const getCleanData = (): string => { @@ -1267,7 +1300,7 @@ export class Session extends EventEmitter { let needsNewSession = false; if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) { console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName); - const newPid = await this._mux.respawnPane(this.id, this.workingDir, 'shell', this._niceConfig); + const newPid = await this._mux.respawnPane({ sessionId: this.id, workingDir: this.workingDir, mode: 'shell', niceConfig: this._niceConfig }); if (!newPid) { console.error('[Session] Failed to respawn pane, will create new session'); needsNewSession = true; @@ -1282,7 +1315,7 @@ export class Session extends EventEmitter { console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName); } else { // Create a new mux session - this._muxSession = await this._mux.createSession(this.id, this.workingDir, 'shell', this._name, this._niceConfig); + this._muxSession = await this._mux.createSession({ sessionId: this.id, workingDir: this.workingDir, mode: 'shell', name: this._name, niceConfig: this._niceConfig }); console.log('[Session] Created mux session:', this._muxSession.muxName); // No extra sleep — createSession() already waits for tmux readiness } diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index bcd6470a..647919de 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -30,13 +30,15 @@ import { existsSync, readFileSync, mkdirSync } from 'node:fs'; import { writeFile, rename } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; -import { ProcessStats, PersistedRespawnConfig, getErrorMessage, NiceConfig, DEFAULT_NICE_CONFIG, type PaneInfo, type ClaudeMode } from './types.js'; +import { ProcessStats, PersistedRespawnConfig, getErrorMessage, DEFAULT_NICE_CONFIG, type PaneInfo, type ClaudeMode, type SessionMode, type OpenCodeConfig } from './types.js'; import { wrapWithNice } from './utils/nice-wrapper.js'; import { SAFE_PATH_PATTERN } from './utils/regex-patterns.js'; -import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats } from './mux-interface.js'; +import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats, CreateSessionOptions, RespawnPaneOptions } from './mux-interface.js'; // Claude CLI PATH resolution — shared utility import { findClaudeDir } from './utils/claude-cli-resolver.js'; +// OpenCode CLI PATH resolution +import { resolveOpenCodeDir } from './utils/opencode-cli-resolver.js'; // ============================================================================ // Timing Constants @@ -136,6 +138,116 @@ function buildClaudePermissionFlags(claudeMode?: ClaudeMode, allowedTools?: stri } } +/** + * Build the opencode CLI command with appropriate flags. + */ +function buildOpenCodeCommand(config?: OpenCodeConfig): string { + const parts = ['opencode']; + + // Model selection — allow provider/model format (alphanumeric, dots, hyphens, slashes) + if (config?.model) { + const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; + if (safeModel) parts.push('--model', safeModel); + } + + // Continue existing session + if (config?.continueSession) { + const safeId = /^[a-zA-Z0-9_-]+$/.test(config.continueSession) ? config.continueSession : undefined; + if (safeId) parts.push('--session', safeId); + if (config.forkSession) parts.push('--fork'); + } + + return parts.join(' '); +} + +/** + * Build the spawn command for any session mode. + * Shared by createSession() and respawnPane() to avoid duplication. + */ +function buildSpawnCommand(options: { + mode: SessionMode; + sessionId: string; + model?: string; + claudeMode?: ClaudeMode; + allowedTools?: string; + openCodeConfig?: OpenCodeConfig; +}): string { + if (options.mode === 'claude') { + // Validate model to prevent command injection + const safeModel = (options.model && /^[a-zA-Z0-9._-]+$/.test(options.model)) ? options.model : undefined; + const modelFlag = safeModel ? ` --model ${safeModel}` : ''; + return `claude${buildClaudePermissionFlags(options.claudeMode, options.allowedTools)} --session-id "${options.sessionId}"${modelFlag}`; + } + if (options.mode === 'opencode') { + return buildOpenCodeCommand(options.openCodeConfig); + } + return '$SHELL'; +} + +/** + * Set sensitive environment variables on a tmux session via setenv. + * These are inherited by panes but not visible in ps output or tmux history. + */ +function setOpenCodeEnvVars(muxName: string): void { + const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY']; + for (const key of sensitiveVars) { + const val = process.env[key]; + if (val) { + // Shell-escape: wrap in single quotes, escape any inner single quotes + const escaped = val.replace(/'/g, "'\\''"); + try { + execSync(`tmux setenv -t '${muxName}' ${key} '${escaped}'`, { + encoding: 'utf8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch { /* Non-critical — key may not be needed */ } + } + } +} + +/** + * Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv. + * Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON. + */ +function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): void { + if (!config) return; + + let jsonContent: string | undefined; + + if (config.autoAllowTools) { + const permConfig: Record = { permission: { '*': 'allow' } }; + if (config.configContent) { + try { + const existing = JSON.parse(config.configContent) as Record; + Object.assign(permConfig, existing); + permConfig.permission = { '*': 'allow' }; + } catch { /* invalid JSON, use default permConfig */ } + } + jsonContent = JSON.stringify(permConfig); + } else if (config.configContent) { + // Validate JSON to prevent garbage config + try { + JSON.parse(config.configContent); + jsonContent = config.configContent; + } catch { + console.error('[TmuxManager] Invalid JSON in openCodeConfig.configContent, skipping'); + return; + } + } + + if (jsonContent) { + const escaped = jsonContent.replace(/'/g, "'\\''"); + try { + execSync(`tmux setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, { + encoding: 'utf8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch { /* Non-critical */ } + } +} + /** * Manages tmux sessions that wrap Claude CLI or shell processes. * @@ -222,16 +334,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * Creates a new tmux session wrapping Claude CLI or a shell. * In test mode: creates an in-memory session only (no real tmux session). */ - async createSession( - sessionId: string, - workingDir: string, - mode: 'claude' | 'shell', - name?: string, - niceConfig?: NiceConfig, - model?: string, - claudeMode?: ClaudeMode, - allowedTools?: string, - ): Promise { + async createSession(options: CreateSessionOptions): Promise { + const { sessionId, workingDir, mode, name, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options; const muxName = `claudeman-${sessionId.slice(0, 8)}`; if (!isValidMuxName(muxName)) { @@ -258,36 +362,43 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return session; } - const claudeDir = findClaudeDir(); - if (!claudeDir && mode === 'claude') { - throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash'); + // Resolve CLI binary directory based on mode + let pathExport = ''; + if (mode === 'claude') { + const claudeDir = findClaudeDir(); + if (!claudeDir) { + throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash'); + } + pathExport = `export PATH="${claudeDir}:$PATH" && `; + } else if (mode === 'opencode') { + const openCodeDir = resolveOpenCodeDir(); + if (!openCodeDir) { + throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'); + } + pathExport = `export PATH="${openCodeDir}:$PATH" && `; } - const pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : ''; const envExports = [ 'export LANG=en_US.UTF-8', 'export LC_ALL=en_US.UTF-8', - 'unset CLAUDECODE', 'unset COLORTERM', 'export CLAUDEMAN_MUX=1', `export CLAUDEMAN_SESSION_ID=${sessionId}`, `export CLAUDEMAN_MUX_NAME=${muxName}`, `export CLAUDEMAN_API_URL=${process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'}`, - ].join(' && '); + ]; + // Only unset CLAUDECODE for Claude sessions + if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE'); + const envExportsStr = envExports.join(' && '); - // Validate model to prevent command injection (only allow alphanumeric, hyphens, dots) - const safeModel = (model && /^[a-zA-Z0-9._-]+$/.test(model)) ? model : undefined; - const modelFlag = (mode === 'claude' && safeModel) ? ` --model ${safeModel}` : ''; - const baseCmd = mode === 'claude' - ? `claude${buildClaudePermissionFlags(claudeMode, allowedTools)} --session-id "${sessionId}"${modelFlag}` - : '$SHELL'; + const baseCmd = buildSpawnCommand({ mode, sessionId, model, claudeMode, allowedTools, openCodeConfig }); const config = niceConfig || DEFAULT_NICE_CONFIG; const cmd = wrapWithNice(baseCmd, config); try { // Build the full command to run inside tmux - const fullCmd = `${pathExport}${envExports} && ${cmd}`; + const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`; // Create tmux session in three steps to handle cold-start (no server running) // and avoid the race where the command exits before remain-on-exit is set: @@ -304,6 +415,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS, stdio: 'ignore' }); } catch { /* Non-critical */ } + // For OpenCode: set sensitive env vars and config via tmux setenv + // (not visible in ps output or tmux history, inherited by panes) + if (mode === 'opencode') { + setOpenCodeEnvVars(muxName); + setOpenCodeConfigContent(muxName, openCodeConfig); + } + // Replace the shell with the actual command (no echo in terminal) execSync( `tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, @@ -423,37 +541,48 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * Uses `tmux respawn-pane -k` to restart the command in the same pane, * preserving the session and its scrollback buffer. */ - async respawnPane(sessionId: string, workingDir: string, mode: 'claude' | 'shell', niceConfig?: NiceConfig, model?: string, claudeMode?: ClaudeMode, allowedTools?: string): Promise { + async respawnPane(options: RespawnPaneOptions): Promise { + const { sessionId, workingDir, mode, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options; const session = this.sessions.get(sessionId); if (!session) return null; const muxName = session.muxName; if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null; - const claudeDir = findClaudeDir(); - const pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : ''; + // Resolve CLI binary directory based on mode + let pathExport = ''; + if (mode === 'claude') { + const claudeDir = findClaudeDir(); + pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : ''; + } else if (mode === 'opencode') { + const openCodeDir = resolveOpenCodeDir(); + pathExport = openCodeDir ? `export PATH="${openCodeDir}:$PATH" && ` : ''; + } + const envExports = [ 'export LANG=en_US.UTF-8', 'export LC_ALL=en_US.UTF-8', - 'unset CLAUDECODE', 'unset COLORTERM', 'export CLAUDEMAN_MUX=1', `export CLAUDEMAN_SESSION_ID=${sessionId}`, `export CLAUDEMAN_MUX_NAME=${muxName}`, `export CLAUDEMAN_API_URL=${process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'}`, - ].join(' && '); - - const safeModel = (model && /^[a-zA-Z0-9._-]+$/.test(model)) ? model : undefined; - const modelFlag = (mode === 'claude' && safeModel) ? ` --model ${safeModel}` : ''; - const baseCmd = mode === 'claude' - ? `claude${buildClaudePermissionFlags(claudeMode, allowedTools)} --session-id "${sessionId}"${modelFlag}` - : '$SHELL'; + ]; + if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE'); + const envExportsStr = envExports.join(' && '); + const baseCmd = buildSpawnCommand({ mode, sessionId, model, claudeMode, allowedTools, openCodeConfig }); const config = niceConfig || DEFAULT_NICE_CONFIG; const cmd = wrapWithNice(baseCmd, config); - const fullCmd = `${pathExport}${envExports} && ${cmd}`; + const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`; try { + // For OpenCode: set sensitive env vars via tmux setenv before respawn + if (mode === 'opencode') { + setOpenCodeEnvVars(muxName); + setOpenCodeConfigContent(muxName, openCodeConfig); + } + await execAsync( `tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { timeout: EXEC_TIMEOUT_MS } diff --git a/src/types.ts b/src/types.ts index 4804b926..69db2d49 100644 --- a/src/types.ts +++ b/src/types.ts @@ -113,6 +113,23 @@ export interface LifecycleEntry { */ export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools'; +/** Session mode: which CLI backend a session runs */ +export type SessionMode = 'claude' | 'shell' | 'opencode'; + +/** OpenCode session configuration */ +export interface OpenCodeConfig { + /** Model identifier (e.g., "anthropic/claude-sonnet-4-5", "openai/gpt-5.2", "ollama/codellama") */ + model?: string; + /** Whether to auto-allow all tool executions (sets permission.* = allow) */ + autoAllowTools?: boolean; + /** Session ID to continue from */ + continueSession?: string; + /** Whether to fork when continuing (branch the conversation) */ + forkSession?: boolean; + /** Custom inline config JSON (passed via OPENCODE_CONFIG_CONTENT) */ + configContent?: string; +} + /** * Configuration for creating a new session */ @@ -158,8 +175,8 @@ export interface SessionState { lastActivityAt: number; /** Session display name */ name?: string; - /** Session mode: 'claude' or 'shell' */ - mode?: 'claude' | 'shell'; + /** Session mode */ + mode?: SessionMode; /** Auto-clear enabled */ autoClearEnabled?: boolean; /** Auto-clear token threshold */ @@ -208,6 +225,8 @@ export interface SessionState { cliAccountType?: string; /** Latest CLI version available (parsed from version check) */ cliLatestVersion?: string; + /** OpenCode-specific configuration (only for mode === 'opencode') */ + openCodeConfig?: OpenCodeConfig; } // ========== Global Stats Types ========== diff --git a/src/utils/index.ts b/src/utils/index.ts index cf186a0b..aaf790c0 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -33,3 +33,4 @@ export { export { assertNever } from './type-safety.js'; export { wrapWithNice } from './nice-wrapper.js'; export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js'; +export { resolveOpenCodeDir, isOpenCodeAvailable, getOpenCodeAugmentedPath } from './opencode-cli-resolver.js'; diff --git a/src/utils/opencode-cli-resolver.ts b/src/utils/opencode-cli-resolver.ts new file mode 100644 index 00000000..4a223d58 --- /dev/null +++ b/src/utils/opencode-cli-resolver.ts @@ -0,0 +1,95 @@ +/** + * @fileoverview Resolve the OpenCode CLI binary across common install paths. + * + * Mirrors claude-cli-resolver.ts pattern. Finds the `opencode` binary + * and provides an augmented PATH string for tmux sessions. + * + * @module utils/opencode-cli-resolver + */ + +import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { delimiter, dirname, join } from 'node:path'; +import { homedir } from 'node:os'; + +/** Timeout for exec commands (5 seconds) */ +const EXEC_TIMEOUT_MS = 5000; + +/** Common directories where the OpenCode CLI binary may be installed */ +const OPENCODE_SEARCH_DIRS = [ + join(homedir(), '.opencode', 'bin'), // Default install location + join(homedir(), '.local', 'bin'), // Alternative install location + '/usr/local/bin', // Homebrew / system + join(homedir(), 'go', 'bin'), // Go install + join(homedir(), '.bun', 'bin'), // Bun global + join(homedir(), '.npm-global', 'bin'), // npm global + join(homedir(), 'bin'), // User bin +]; + +/** Cached directory containing the opencode binary (empty string = searched but not found) */ +let _openCodeDir: string | null = null; + +/** + * Finds the directory containing the `opencode` binary. + * Checks `which opencode` first, then falls back to common install locations. + * Result is cached for subsequent calls. + * + * @returns Directory path, or null if not found + */ +export function resolveOpenCodeDir(): string | null { + if (_openCodeDir !== null) return _openCodeDir || null; + + // Try `which` first (respects current PATH) + try { + const result = execSync('which opencode', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim(); + if (result && existsSync(result)) { + _openCodeDir = dirname(result); + return _openCodeDir; + } + } catch { + // OpenCode not in PATH, will check common locations + } + + // Fallback: check common installation directories + for (const dir of OPENCODE_SEARCH_DIRS) { + if (existsSync(join(dir, 'opencode'))) { + _openCodeDir = dir; + return _openCodeDir; + } + } + + _openCodeDir = ''; // mark as searched, not found + return null; +} + +/** + * Check if OpenCode CLI is available on the system. + */ +export function isOpenCodeAvailable(): boolean { + return resolveOpenCodeDir() !== null; +} + +/** + * Returns a PATH string that includes the directory containing `opencode`. + * + * Finds the opencode binary (via `which` or common install locations), then + * prepends its directory to the current PATH if not already present. + * Result is cached for subsequent calls. + */ +export function getOpenCodeAugmentedPath(): string { + const currentPath = process.env.PATH || ''; + const dir = resolveOpenCodeDir(); + + if (dir && !currentPath.split(delimiter).includes(dir)) { + return `${dir}${delimiter}${currentPath}`; + } + + return currentPath; +} + +/** + * Reset cached resolution (for testing). + */ +export function resetOpenCodeCache(): void { + _openCodeDir = null; +}