mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
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:
+31
-15
@@ -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
@@ -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
@@ -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
@@ -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 ==========
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user