feat: OpenCode integration Phases 1-3 — types, CLI resolver, tmux spawn

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) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-26 10:07:43 +01:00
co-authored by Claude Opus 4.6
parent 1adcb7656b
commit 0b60f2691f
6 changed files with 386 additions and 93 deletions
+31 -15
View File
@@ -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<MuxSession>;
createSession(options: CreateSessionOptions): Promise<MuxSession>;
/**
* 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<number | null>;
respawnPane(options: RespawnPaneOptions): Promise<number | null>;
}
+72 -39
View File
@@ -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
}
+166 -37
View File
@@ -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<string, unknown> = { permission: { '*': 'allow' } };
if (config.configContent) {
try {
const existing = JSON.parse(config.configContent) as Record<string, unknown>;
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<MuxSession> {
async createSession(options: CreateSessionOptions): Promise<MuxSession> {
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<number | null> {
async respawnPane(options: RespawnPaneOptions): Promise<number | null> {
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 }
+21 -2
View File
@@ -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 ==========
+1
View File
@@ -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';
+95
View File
@@ -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;
}