# OpenCode Integration Plan for Codeman
> **Author**: Claude Opus 4.6 | **Date**: 2026-02-26
> **Status**: Draft — Re-reviewed, MVP scope finalized. NOT pushed to GitHub
> **Saved at**: `docs/opencode-integration.md`
> **Related**: `plan.json` (48-task TDD breakdown, also not pushed)
> **Reviewed**: 2026-02-26 — 4-agent team review (arch, parsing, respawn, API). See [Section 21: Review Findings](#21-review-findings)
> **Re-reviewed**: 2026-02-26 — 4-agent team re-review (types, parsing, respawn, API). See [Section 22: Re-Review Findings](#22-re-review-findings)
---
## Table of Contents
1. [Executive Summary](#1-executive-summary)
2. [What is OpenCode?](#2-what-is-opencode)
3. [Architecture Comparison: Claude Code vs OpenCode](#3-architecture-comparison-claude-code-vs-opencode)
4. [Integration Strategy Overview](#4-integration-strategy-overview)
5. [Phase 0: Prerequisites & Manual Validation](#5-phase-0-prerequisites--manual-validation)
6. [Phase 1: Type System & Backend Abstraction](#6-phase-1-type-system--backend-abstraction)
7. [Phase 2: OpenCode CLI Resolution](#7-phase-2-opencode-cli-resolution)
8. [Phase 3: Tmux Spawn Integration](#8-phase-3-tmux-spawn-integration)
9. [Phase 4: Output Parsing & Idle Detection](#9-phase-4-output-parsing--idle-detection)
10. [Phase 5: API Routes & Frontend UI](#10-phase-5-api-routes--frontend-ui)
11. [Phase 6: Hooks & Plugin Bridge](#11-phase-6-hooks--plugin-bridge) *(was Phase 7 — reordered, see review)*
12. [Phase 7: Respawn & Ralph Loop Adaptation](#12-phase-7-respawn--ralph-loop-adaptation) *(was Phase 6 — reordered, see review)*
13. [Phase 8: OpenCode Server API Integration (Advanced)](#13-phase-8-opencode-server-api-integration-advanced)
14. [Files to Modify](#14-files-to-modify)
15. [Files to Create](#15-files-to-create)
16. [Existing plan.json Task Breakdown](#16-existing-planjson-task-breakdown)
17. [Risk Assessment](#17-risk-assessment)
18. [Testing Strategy](#18-testing-strategy)
19. [Open Questions & Decisions](#19-open-questions--decisions)
20. [Implementation Order](#20-implementation-order)
21. [Review Findings](#21-review-findings)
22. [Re-Review Findings](#22-re-review-findings)
23. [Appendix A: OpenCode CLI Reference](#appendix-a-opencode-cli-reference)
24. [Appendix B: OpenCode Plugin Events](#appendix-b-opencode-plugin-events)
25. [Appendix C: OpenCode Permission Config](#appendix-c-opencode-permission-config)
26. [Appendix D: Current Codeman Session Spawn Flow (Annotated)](#appendix-d-current-codeman-session-spawn-flow-annotated)
---
## 1. Executive Summary
This plan details how to integrate [OpenCode](https://opencode.ai) — the popular open-source AI coding CLI (111k+ GitHub stars, 75+ model providers) — into Codeman as a first-class session type alongside Claude Code and shell sessions.
### Core Approach
Extend the existing `SessionMode` type from `'claude' | 'shell'` to `'claude' | 'shell' | 'opencode'`, and propagate this new mode through the tmux manager, session class, API routes, and frontend UI. OpenCode will be spawned inside tmux exactly like Claude Code, with its TUI rendered in xterm.js.
### Two Integration Strategies (Incremental)
| Strategy | Approach | Complexity | Value |
|----------|----------|------------|-------|
| **A: TUI-in-tmux** | Spawn `opencode` CLI in tmux, render in xterm.js | Medium | Full visual parity with Claude Code |
| **B: Server API bridge** | Run `opencode serve` + proxy its API through Codeman | High | Structured data, session control, token tracking |
**Recommended path**: Start with Strategy A (TUI-in-tmux) since it mirrors the existing Claude Code pattern exactly. Then layer Strategy B on top for advanced features like structured token tracking and model switching.
### Scope Decision: MVP First, Claude-Coupled Systems Later
> **Decision (2026-02-26)**: The first integration ships **spawn + render + basic UI only**. The following Claude-coupled systems are **explicitly out of scope** for the initial integration and should NOT be attempted until OpenCode sessions are stable and we have real PTY output data to calibrate against:
>
> | System | Why Excluded | Prerequisite |
> |--------|-------------|--------------|
> | **Token tracking** | Claude-specific status line format. OpenCode uses different format inside Bubble Tea TUI. 75+ model providers = different cost/token semantics per model. | Phase 8 server API or verified TUI regex |
> | **Respawn controller** | 3,500 lines, 13 states, deeply coupled to Claude's output patterns (`Worked for Xm Xs`, spinner chars, `❯` prompt, `/clear`/`/init` commands). Entire cycle logic is Claude-specific. | Plugin bridge `session.idle` event verified + Phase 0 PTY data |
> | **Ralph Loop** | Depends entirely on `PHRASE` tags and `---RALPH_STATUS---` blocks — custom Claude protocols that don't exist in OpenCode. Would be a broken timeout-based prompt repeater. | Alternative completion signaling mechanism |
> | **Ralph Tracker** | Parses Claude-specific output: `` tags, `TodoWrite` tool detection, `RALPH_STATUS` blocks, `@fix_plan.md` workflow. None exist in OpenCode. | OpenCode-native equivalent signals |
> | **Circuit breaker** | Signals (`consecutiveNoProgress`, `consecutiveTestsFailure`, `BLOCKED`) all come from `RALPH_STATUS` blocks. No OpenCode equivalent. | Ralph Tracker adaptation |
> | **Hooks plugin bridge** | Plugin event names are speculative/unverified. Requires Phase 0 validation that hasn't happened. | Phase 0 plugin verification |
> | **AI idle checker** | Spawns `claude -p` for analysis. Won't work if only OpenCode is installed. | Claude CLI availability or `opencode run` fallback |
>
> **What ships in the MVP**: Phases 0-3 (spawn in tmux, render in xterm.js) + Phase 5 (API routes, mode selector, tab badges, create/kill sessions). Users can interact with OpenCode manually — type prompts, see output, manage sessions from the Codeman web UI. This alone is the core value: multi-model AI sessions in one management interface.
### Why OpenCode?
- **Multi-model**: Access Claude, GPT, Gemini, Ollama (local), and 75+ other models through one tool
- **Privacy-first**: Can run fully local via Ollama — no code ever leaves the machine
- **Open source**: MIT licensed, active community (700+ contributors)
- **Client/server**: Built-in `opencode serve` mode enables richer programmatic integration
- **Plugin system**: JS/TS plugins with rich event hooks (including `session.idle` — perfect for Codeman)
---
## 2. What is OpenCode?
- **GitHub**: https://github.com/opencode-ai/opencode (originally `sst/opencode`, now `anomalyco/opencode`)
- **Website**: https://opencode.ai
- **License**: MIT
- **Language**: Go (binary), with TypeScript plugin/config system
- **TUI Framework**: Bubble Tea (Go) — *not* `@opentui/solid` as earlier versions used
- **Install**: `curl -fsSL https://raw.githubusercontent.com/opencode-ai/opencode/refs/heads/main/install | bash` or `brew install opencode-ai/tap/opencode` or `go install github.com/opencode-ai/opencode@latest`
### Key Differences from Claude Code
| Feature | Claude Code | OpenCode |
|---------|-------------|----------|
| **Models** | Anthropic only | 75+ providers (Anthropic, OpenAI, Google, Ollama, etc.) |
| **Architecture** | Single CLI process | Client/server (TUI + optional API server) |
| **TUI framework** | Ink (React for terminals) | Bubble Tea (Go) |
| **Headless mode** | `claude -p` | `opencode run` (JSON output) + `opencode serve` (HTTP API) |
| **Permissions** | `--dangerously-skip-permissions` CLI flag | Config-based `"permission": {"*": "allow"}` in `opencode.json` |
| **Hooks system** | `.claude/settings.local.json` with shell command hooks | JS/TS plugin system with 25+ event types |
| **Session ID** | `--session-id ` | `--session ` or `-s ` |
| **Continue** | `/resume` command | `--continue` or `-c` flag |
| **Config format** | CLAUDE.md (markdown) + settings.json | `opencode.json` (JSON/JSONC) |
| **Data storage** | File-based (transcripts, state) | SQLite database |
| **Leader key** | None (direct shortcuts) | `Ctrl+X` as leader key for TUI |
| **Token display** | Status bar: `123.4k tokens` | TUI status area: `~27s · 275.9k tokens` |
### OpenCode CLI Flags (Relevant for Spawning)
```bash
# Interactive TUI (default)
opencode [project-path]
# With specific model
opencode --model anthropic/claude-sonnet-4-5
opencode --model openai/gpt-5.2
opencode --model ollama/codellama
# Continue existing session
opencode --continue # Continue last session
opencode --session # Resume specific session
opencode --fork # Branch when continuing
# Non-interactive (pipe mode)
opencode run "prompt here"
opencode run --format json "prompt" # Structured JSON output
opencode run --continue # Continue last session in pipe mode
opencode run --file path.ts "prompt" # Attach file context
opencode run --attach http://host:4096 "prompt" # Run against remote server
# Headless server
opencode serve --port 4096
opencode serve --cors "http://localhost:3000"
opencode serve --mdns # Enable mDNS discovery
# Attach TUI to remote server
opencode attach http://host:4096
# Session management
opencode session list # List all sessions
opencode export [sessionID] # Export as JSON
opencode import # Import from file/URL
# Model management
opencode models [provider] # List available models
opencode models --refresh # Update model cache
# Global flags
--help, --version, --debug, --cwd , --log-level, --print-logs
```
### OpenCode Config File (`opencode.json`)
Located in project root (or `~/.config/opencode/opencode.json` for global), configures model, tools, agents:
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"model": "anthropic/claude-sonnet-4-5",
"small_model": "anthropic/claude-haiku-4-5",
// Auto-approve all tool executions (like --dangerously-skip-permissions)
"permission": {
"*": "allow"
},
// Or granular permissions
"permission": {
"*": "ask",
"bash": { "*": "ask", "git *": "allow", "rm *": "deny" },
"edit": "allow"
},
"tools": { "bash": { "mode": "allow" } },
"agents": {
"build": { "model": "anthropic/claude-sonnet-4-5" }
},
"mcp": { "servers": {} },
"server": {
"port": 4096,
"hostname": "0.0.0.0",
"cors": ["http://localhost:3000"]
},
"compaction": { "auto": true },
"autoupdate": false
}
```
### Config File Precedence
1. Remote config (`.well-known/opencode` endpoint)
2. Global config (`~/.config/opencode/opencode.json`)
3. Custom config (`OPENCODE_CONFIG` env var)
4. Project config (`opencode.json` in project root)
5. `.opencode/` directory (agents, commands, plugins)
6. Inline config (`OPENCODE_CONFIG_CONTENT` env var)
### OpenCode Environment Variables
```bash
ANTHROPIC_API_KEY=... # For Anthropic models
OPENAI_API_KEY=... # For OpenAI models
GOOGLE_API_KEY=... # For Google AI models
OPENCODE_MODEL=... # Default model override
OPENCODE_CONFIG=... # Custom config file path
OPENCODE_CONFIG_DIR=... # Custom config directory
OPENCODE_CONFIG_CONTENT=... # Inline JSON config (highest priority)
OPENCODE_SERVER_PASSWORD=... # Auth for serve mode (username: "opencode")
OPENCODE_PERMISSION=... # Inline JSON permission config
OPENCODE_CLIENT=... # Client identifier (default: "cli")
```
---
## 3. Architecture Comparison: Claude Code vs OpenCode
### Current Codeman Session Flow (Claude Code)
```
POST /api/sessions → new Session({mode: 'claude', mux: TmuxManager})
POST /api/sessions/:id/interactive → session.startInteractive()
↓
TmuxManager.createSession()
↓
tmux new-session -ds "codeman-"
tmux respawn-pane -k -t ... "claude --dangerously-skip-permissions --session-id "
↓
pty.spawn('tmux', ['attach-session', '-t', 'codeman-'])
↓
ptyProcess.onData() → emit('terminal') → SSE broadcast → xterm.js
```
### Proposed OpenCode Session Flow (Strategy A: TUI-in-tmux)
```
POST /api/sessions → new Session({mode: 'opencode', mux: TmuxManager})
POST /api/sessions/:id/interactive → session.startInteractive()
↓
TmuxManager.createSession()
↓
tmux new-session -ds "codeman-"
tmux respawn-pane -k -t ... "opencode --model "
↓
pty.spawn('tmux', ['attach-session', '-t', 'codeman-'])
↓
ptyProcess.onData() → emit('terminal') → SSE broadcast → xterm.js
```
**Identical pipeline!** The only differences are:
1. The command spawned inside tmux (`opencode` vs `claude`)
2. The CLI arguments (`--model` vs `--dangerously-skip-permissions --session-id`)
3. The environment variables passed to the process
4. Output parsing patterns (idle detection, prompt character, token tracking)
5. Permission handling (config file vs CLI flag)
### Proposed OpenCode Session Flow (Strategy B: Server API Bridge)
```
Strategy A (TUI-in-tmux) for terminal rendering
+
opencode serve (background, port 4096+N)
↓
Codeman proxy routes → GET /session/current, POST /session/message, SSE /events
↓
Structured data for: token tracking, session management, model switching
```
---
## 4. Integration Strategy Overview
### Phase Breakdown
| Phase | Scope | Effort | Dependencies | MVP? |
|-------|-------|--------|-------------|------|
| **0** | Install OpenCode, manual tmux validation | 30 min | None | **YES** |
| **1** | Type system extension + (optional) backend abstraction | Small | None | **YES** |
| **2** | OpenCode CLI resolver | Small | Phase 1 | **YES** |
| **3** | TmuxManager: spawn `opencode` in tmux | Medium | Phase 2 | **YES** |
| **4** | Output parsing, idle detection, prompt detection | Medium | Phase 3 | **DEFERRED** — only basic ready detection needed for MVP |
| **5** | API routes + frontend UI (mode selector, badges) | Medium | Phase 3 | **YES** |
| **6** | Hooks & plugin bridge | Medium | Phase 5 | **DEFERRED** — unverified plugin API |
| **7** | Respawn controller + Ralph Loop adaptation | Medium | Phase 6 | **DEFERRED** — Claude-coupled, needs real PTY data |
| **8** | OpenCode server API bridge (optional, advanced) | Large | Phase 5 | **DEFERRED** — future enhancement |
> **MVP scope**: Phases 0, 1, 2, 3, 5 only. Phase 4 reduced to basic `waitForOpenCodeReady()` only (no idle/working/token detection). Phases 6-8 deferred until MVP is stable.
### What Stays Exactly the Same (Zero Changes)
These systems work identically for OpenCode sessions:
- tmux session creation/lifecycle mechanics
- PTY attachment via `tmux attach-session`
- Terminal data streaming via `ptyProcess.onData()`
- SSE event broadcasting via `broadcast()`
- xterm.js terminal rendering in the browser
- State persistence to `~/.codeman/state.json`
- Session CRUD API routes (create, get, delete)
- Tab management in frontend
- Session kill/cleanup logic (`TmuxManager.killSession()`)
- Nice priority wrapping
- Terminal resize (SIGWINCH propagation through tmux)
- `writeViaMux()` — sending text input via tmux `send-keys`
### What Needs Adaptation
| System | Current (Claude-specific) | OpenCode Equivalent | MVP? |
|--------|---------------------------|---------------------|------|
| CLI binary | `claude` | `opencode` | **YES** |
| CLI args | `--dangerously-skip-permissions --session-id ` | `--model ` + `opencode.json` for permissions | **YES** |
| Prompt marker | `❯` (U+276F) | Bubble Tea TUI prompt (different rendering) | DEFERRED |
| Working indicator | Spinner + "Thinking...", "Writing..." keywords | Bubble Tea spinner (different characters) | DEFERRED |
| Completion message | `"Worked for Xm Xs"` | Different format (needs empirical testing) | DEFERRED |
| Token display | Status line: `123.4k tokens` | TUI status: `~27s · 275.9k tokens` | DEFERRED |
| Slash commands | `/clear`, `/compact`, `/init`, `/update` | `/clear`, `/model`, `/sessions`, `/compact` | DEFERRED |
| Hooks | `.claude/settings.local.json` shell commands | JS/TS plugin system in `.opencode/plugins/` | DEFERRED |
| Subagent detection | `BashToolParser` + `SubagentWatcher` | Different tool output format | DEFERRED |
| Ralph completion | `PHRASE` tags | Not applicable (needs alternative) | DEFERRED |
| Hooks events | `permission_prompt`, `idle_prompt`, `stop` | `permission.asked`, `session.idle`, `session.status` | DEFERRED |
| Auto-compact | Codeman sends `/compact` at token threshold | OpenCode has built-in `compaction.auto: true` | DEFERRED |
---
## 5. Phase 0: Prerequisites & Manual Validation
### Goal
Install OpenCode and validate it works inside tmux before writing any code.
### Steps
```bash
# 1. Install OpenCode
curl -fsSL https://raw.githubusercontent.com/opencode-ai/opencode/refs/heads/main/install | bash
# 2. Verify installation
which opencode
opencode --version
# 3. Test interactive TUI
opencode
# 4. Test in tmux (simulating Codeman's spawn pattern)
tmux new-session -ds "test-opencode" -c /tmp -x 120 -y 40
tmux set-option -t "test-opencode" remain-on-exit on
tmux respawn-pane -k -t "test-opencode" 'opencode --model anthropic/claude-sonnet-4-5'
# 5. Attach and verify rendering
tmux attach-session -t "test-opencode"
# → Verify: TUI renders, accepts input, produces output
# → Test: Ctrl+B D to detach, reattach — does TUI restore?
# → Test: Send keys via: tmux send-keys -t "test-opencode" -l "Hello" && tmux send-keys -t "test-opencode" Enter
# 6. Test with auto-allow permissions (via inline config)
tmux respawn-pane -k -t "test-opencode" 'OPENCODE_CONFIG_CONTENT='"'"'{"permission":{"*":"allow"}}'"'"' opencode --model anthropic/claude-sonnet-4-5'
# 7. Test non-interactive mode
opencode run -q --format json "What is 2+2?"
# 8. Cleanup
tmux kill-session -t "test-opencode"
```
### Validation Checklist
- [ ] OpenCode binary found and version verified
- [ ] TUI renders correctly inside tmux
- [ ] tmux `send-keys -l` sends text to OpenCode's TUI correctly
- [ ] Separate `send-keys Enter` triggers prompt submission
- [ ] TUI survives tmux detach/reattach
- [ ] `remain-on-exit` keeps session alive after OpenCode exits
- [ ] Terminal resize works (try different tmux dimensions)
- [ ] Permission auto-allow works via `OPENCODE_CONFIG_CONTENT`
- [ ] Non-interactive `opencode run` produces JSON output
- [ ] xterm.js renders the TUI (manually test by piping PTY output)
### Additional Validation Items (from review)
> **[REVIEW]** Multiple reviewers emphasized that Phase 0 findings are critical prerequisites for Phases 4-7. Do not write any code until these pass.
- [ ] **Capture raw PTY output** from an OpenCode session for idle detection calibration — record 5+ minutes of working vs idle output to measure actual patterns
- [ ] **Verify `writeViaMux()` input delivery** — Bubble Tea (Go) may handle `\r` (Enter) differently than Ink (React). Test `tmux send-keys -l "Hello"` followed by `tmux send-keys Enter` and confirm prompt submission works
- [ ] **Measure TUI redraw frequency** during idle state — does Bubble Tea emit cursor/timer redraws when the AI model is idle? This determines whether output-silence detection is viable at all
- [ ] **Test mouse protocol output** — Bubble Tea can enable mouse reporting (`CSI ?1000h`/`CSI ?1006h`), generating PTY output on mouse movements. This would defeat silence-based idle detection
- [ ] **Install a test OpenCode plugin** — write a minimal `.opencode/plugins/test.js` plugin, verify which events actually fire, confirm exact event names and callback signatures (the event names in Appendix B are speculative)
- [ ] **Test OpenCode auto-compaction behavior** — trigger compaction during a session, observe TUI output pattern (could appear as "working" to idle detector)
- [ ] **Check `opencode --version`** — record version for compatibility tracking. OpenCode is rapidly evolving; CLI flags and plugin API may change between versions
### Critical Finding: xterm.js Compatibility
OpenCode uses Bubble Tea (Go's charmbracelet framework), which renders using:
- Alternate screen buffer (`\x1b[?1049h`)
- Mouse events (`\x1b[?1000h`)
- Bracketed paste mode (`\x1b[?2004h`)
- True color (24-bit) sequences
These should all work with xterm.js, but **manual testing is essential** before coding.
---
## 6. Phase 1: Type System & Backend Abstraction
### Goal
Extend the type system to support `'opencode'` as a session mode.
### Approach Decision: Simple Extension vs. Backend Abstraction
There are two paths (both represented in the codebase):
**Option A: Simple mode extension** (recommended for Phase 1)
- Add `'opencode'` to existing `SessionMode` union type
- Use `if/else` branches in existing code
- Less refactoring, faster to ship
**Option B: Full backend abstraction** (from `plan.json`)
- Create `LLMBackend` interface + `ClaudeBackend` + `OpenCodeBackend` classes
- Dependency injection into Session class
- More elegant, but larger scope
**Recommendation**: Start with Option A, refactor to Option B later if a third backend is ever needed.
### Prerequisite Refactors (from review)
> **[REVIEW C5]** `SessionMode` is a named type export in `session.ts:204` (`export type SessionMode = 'claude' | 'shell'`). `types.ts:162` has an **inline anonymous union** (`mode?: 'claude' | 'shell'`) inside `SessionState` — NOT a named `SessionMode` type. Additionally, `mux-interface.ts` has 3 inline `'claude' | 'shell'` unions (MuxSession.mode, createSession param, respawnPane param). All 5 locations must stay in sync. **Fix**: Create `SessionMode` in `types.ts`, import and use it in `session.ts` and `mux-interface.ts`.
> **[REVIEW M6]** `createSession()` already has 8 positional parameters. Adding `openCodeConfig` as the 9th is a code smell. **Fix**: Refactor `createSession()` and `respawnPane()` in `TerminalMultiplexer` to accept an options object before adding OpenCode. This also eliminates the need to keep parameter order in sync.
```typescript
// BEFORE (8 positional params):
createSession(sessionId, workingDir, mode, name, niceConfig, model, claudeMode, allowedTools)
// AFTER (options object):
interface CreateSessionOptions {
sessionId: string;
workingDir: string;
mode: SessionMode;
name?: string;
niceConfig?: NiceConfig;
model?: string;
claudeMode?: ClaudeMode;
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
}
createSession(options: CreateSessionOptions): Promise;
```
### Changes
#### `src/types.ts` — Unify `SessionMode` and add `OpenCodeConfig`
```typescript
// Move SessionMode here (single source of truth):
export type SessionMode = 'claude' | 'shell' | 'opencode';
```
#### `src/session.ts` (line 204)
```typescript
// BEFORE (delete this):
export type SessionMode = 'claude' | 'shell';
// AFTER (import from types.ts):
import type { SessionMode } from './types.js';
```
#### `src/types.ts` — Add `OpenCodeConfig` interface
```typescript
/** 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;
/** Port for OpenCode's built-in server (Strategy B, Phase 8) */
serverPort?: number;
/** Custom inline config JSON (passed via OPENCODE_CONFIG_CONTENT) */
configContent?: string;
}
```
Add `openCodeConfig` to `SessionState`:
```typescript
// In SessionState interface:
/** OpenCode-specific configuration (only for mode === 'opencode') */
openCodeConfig?: OpenCodeConfig;
```
#### `src/mux-interface.ts` — Update mode types and refactor to options object
```typescript
// Import unified SessionMode from types.ts:
import type { SessionMode, OpenCodeConfig } from './types.js';
// In MuxSession type (line 27):
mode: SessionMode;
// Refactor to options object (per review M6):
interface CreateSessionOptions {
sessionId: string;
workingDir: string;
mode: SessionMode;
name?: string;
niceConfig?: NiceConfig;
model?: string;
claudeMode?: ClaudeMode;
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
}
interface RespawnPaneOptions {
sessionId: string;
workingDir: string;
mode: SessionMode;
niceConfig?: NiceConfig;
model?: string;
claudeMode?: ClaudeMode;
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
}
// In TerminalMultiplexer interface:
createSession(options: CreateSessionOptions): Promise;
respawnPane(options: RespawnPaneOptions): Promise;
```
> **Note**: `mux-factory.ts` does NOT need changes — it just instantiates `TmuxManager` with no parameters.
#### `src/web/schemas.ts` — Update Zod schemas
```typescript
// Session mode enum
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
// Add OpenCode config schema
const OpenCodeConfigSchema = z.object({
model: z.string().max(100).regex(/^[a-zA-Z0-9._\-/]+$/).optional(),
autoAllowTools: z.boolean().optional(),
continueSession: z.string().max(100).regex(/^[a-zA-Z0-9_-]+$/).optional(),
forkSession: z.boolean().optional(),
serverPort: z.number().int().min(1024).max(65535).optional(),
configContent: z.string().max(10000).optional(),
}).optional();
// Add to CreateSessionSchema:
openCodeConfig: OpenCodeConfigSchema,
```
---
## 7. Phase 2: OpenCode CLI Resolution
### Goal
Create a resolver for the `opencode` binary, mirroring `claude-cli-resolver.ts`.
### New File: `src/utils/opencode-cli-resolver.ts`
```typescript
/**
* @fileoverview Resolve the OpenCode CLI binary across common install paths.
* Mirrors claude-cli-resolver.ts pattern.
*/
import { existsSync } from 'node:fs';
import { join, dirname, delimiter } from 'node:path';
import { execSync } from 'node:child_process';
import { homedir } from 'node:os';
let _openCodeDir: string | null = null;
let _resolved = false;
const COMMON_DIRS = [
join(homedir(), '.local', 'bin'), // Default install location
'/usr/local/bin', // Homebrew / system
join(homedir(), '.bun', 'bin'), // Bun global
join(homedir(), '.npm-global', 'bin'), // npm global
join(homedir(), 'go', 'bin'), // Go install
join(homedir(), 'bin'), // User bin
];
/**
* Resolve the directory containing the `opencode` binary.
* Result is cached after first call.
*/
export function resolveOpenCodeDir(): string | null {
if (_resolved) return _openCodeDir;
_resolved = true;
// Try which first
try {
const path = execSync('which opencode', {
encoding: 'utf8',
timeout: 3000,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
if (path) {
// [REVIEW] Use dirname() like claude-cli-resolver.ts, not regex
_openCodeDir = dirname(path);
return _openCodeDir;
}
} catch { /* not in PATH */ }
// Check common directories
for (const dir of COMMON_DIRS) {
if (existsSync(join(dir, 'opencode'))) {
_openCodeDir = dir;
return _openCodeDir;
}
}
return null;
}
/**
* Check if OpenCode CLI is available on the system.
*/
export function isOpenCodeAvailable(): boolean {
return resolveOpenCodeDir() !== null;
}
/**
* Get augmented PATH with OpenCode directory prepended.
*/
export function getOpenCodeAugmentedPath(): string {
const dir = resolveOpenCodeDir();
if (!dir) return process.env.PATH || '';
const current = process.env.PATH || '';
if (current.includes(dir)) return current;
// [REVIEW] Use delimiter from node:path, not hardcoded ':'
return `${dir}${delimiter}${current}`;
}
/**
* Reset cached resolution (for testing).
*/
export function resetOpenCodeCache(): void {
_openCodeDir = null;
_resolved = false;
}
```
### Update: `src/utils/index.ts`
```typescript
export { resolveOpenCodeDir, isOpenCodeAvailable, getOpenCodeAugmentedPath } from './opencode-cli-resolver.js';
```
---
## 8. Phase 3: Tmux Spawn Integration
### Goal
Make `TmuxManager.createSession()` and `respawnPane()` support `mode: 'opencode'`.
This is the **critical integration point** — once OpenCode runs in tmux, everything downstream (PTY attachment, SSE streaming, xterm.js rendering) works automatically.
### Prerequisite Refactor: Extract Shared Command Builder (from review)
> **[REVIEW]** `createSession()` and `respawnPane()` already duplicate Claude command construction (lines 261-311 vs 426-454). Adding OpenCode creates a 3rd branch in both. **Fix**: Extract a shared `buildSpawnCommand(mode, options)` helper that both methods call.
```typescript
// New shared helper in tmux-manager.ts:
interface SpawnCommandOptions {
mode: SessionMode;
sessionId: string;
model?: string;
claudeMode?: ClaudeMode;
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
}
function buildSpawnCommand(options: SpawnCommandOptions): string {
if (options.mode === 'claude') {
const modelFlag = options.model ? ` --model ${options.model}` : '';
return `claude${buildClaudePermissionFlags(options.claudeMode, options.allowedTools)} --session-id "${options.sessionId}"${modelFlag}`;
}
if (options.mode === 'opencode') {
return buildOpenCodeCommand(options.openCodeConfig);
}
return '$SHELL';
}
```
### Changes to `src/tmux-manager.ts`
#### New Helper: `buildOpenCodeCommand()`
```typescript
import { resolveOpenCodeDir } from './utils/opencode-cli-resolver.js';
import type { OpenCodeConfig } from './types.js';
/**
* Build the opencode CLI command with appropriate flags.
* Similar to buildClaudePermissionFlags() but for OpenCode.
*/
function buildOpenCodeCommand(config?: OpenCodeConfig): string {
const parts = ['opencode'];
// Model selection
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(' ');
}
```
#### `createSession()` — Extend command construction (around line 280)
```typescript
// CURRENT:
const modelFlag = (mode === 'claude' && safeModel) ? ` --model ${safeModel}` : '';
const baseCmd = mode === 'claude'
? `claude${buildClaudePermissionFlags(claudeMode, allowedTools)} --session-id "${sessionId}"${modelFlag}`
: '$SHELL';
// PROPOSED:
let baseCmd: string;
if (mode === 'claude') {
const modelFlag = safeModel ? ` --model ${safeModel}` : '';
baseCmd = `claude${buildClaudePermissionFlags(claudeMode, allowedTools)} --session-id "${sessionId}"${modelFlag}`;
} else if (mode === 'opencode') {
baseCmd = buildOpenCodeCommand(openCodeConfig);
} else {
baseCmd = '$SHELL';
}
```
#### PATH Augmentation for OpenCode
```typescript
// In createSession(), where PATH is exported:
let pathExport: string;
if (mode === 'claude') {
const claudeDir = findClaudeDir();
if (!claudeDir) throw new Error('Claude CLI not found');
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"`;
} else {
pathExport = ''; // shell mode uses system PATH
}
```
#### Environment Variables for OpenCode
OpenCode needs different env vars than Claude Code:
```typescript
// Build environment exports based on mode
function buildEnvExports(mode: string, sessionId: string, muxName: string, openCodeConfig?: OpenCodeConfig): string {
const common = [
`export LANG=en_US.UTF-8`,
`export LC_ALL=en_US.UTF-8`,
`unset COLORTERM`, // Prevent color issues in tmux
`export CODEMAN_MUX=1`,
`export CODEMAN_SESSION_ID=${sessionId}`,
`export CODEMAN_MUX_NAME=${muxName}`,
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
];
if (mode === 'opencode') {
// [REVIEW M7] Use tmux setenv for API keys instead of inline export.
// Inline `export KEY=val` in the tmux command is visible in `ps` output and tmux history.
// tmux setenv sets environment variables on the session, inherited by all panes.
// Call this BEFORE respawnPane():
// tmux setenv -t ANTHROPIC_API_KEY
// tmux setenv -t OPENAI_API_KEY
// etc.
// See new helper: setOpenCodeEnvVars() below.
const configExports: string[] = []; // Non-secret env vars only
// [RE-REVIEW] SECURITY: configContent is user-supplied JSON. NEVER embed it
// directly in a shell command string — shell metacharacters (;, &&, $()) execute.
// Instead: (1) validate it is parseable JSON, (2) pass via tmux setenv, which
// does NOT interpret shell metacharacters.
// Inline config for permission auto-allow
if (openCodeConfig?.autoAllowTools) {
const permConfig = { permission: { '*': 'allow' } };
let merged = permConfig;
if (openCodeConfig.configContent) {
try {
const existing = JSON.parse(openCodeConfig.configContent);
merged = { ...existing, permission: { '*': 'allow' } };
} catch { /* invalid JSON, use default permConfig */ }
}
// Pass via tmux setenv (called BEFORE respawnPane), NOT inline export
// setOpenCodeConfigContent(muxName, JSON.stringify(merged));
configExports.push(`# OPENCODE_CONFIG_CONTENT set via tmux setenv — see setOpenCodeConfigContent()`);
} else if (openCodeConfig?.configContent) {
// Validate JSON first — reject if unparseable
try {
JSON.parse(openCodeConfig.configContent);
// Pass via tmux setenv (called BEFORE respawnPane)
// setOpenCodeConfigContent(muxName, openCodeConfig.configContent);
} catch {
throw new Error('Invalid JSON in openCodeConfig.configContent');
}
configExports.push(`# OPENCODE_CONFIG_CONTENT set via tmux setenv — see setOpenCodeConfigContent()`);
}
return [...common, ...configExports].join(' && ');
}
if (mode === 'claude') {
return [...common, `unset CLAUDECODE`].join(' && ');
}
return common.join(' && ');
}
```
#### New Helper: `setOpenCodeEnvVars()` (from review)
> **[REVIEW M7]** API keys passed via inline `export` in the tmux command are visible in `ps` output and tmux history. Use `tmux setenv` to set sensitive vars on the session instead.
```typescript
/**
* Set sensitive environment variables on a tmux session via setenv.
* These are inherited by panes but not visible in ps or tmux history.
*
* [RE-REVIEW] API keys may contain ", $, or other shell metacharacters.
* Use single-quote wrapping with escaped inner single quotes to prevent injection.
*/
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, "'\\''");
execSync(`tmux setenv -t '${muxName}' ${key} '${escaped}'`, {
encoding: 'utf8',
timeout: 3000,
stdio: ['pipe', 'pipe', 'pipe'],
});
}
}
}
```
#### New Helper: `setOpenCodeConfigContent()` (from re-review)
> **[RE-REVIEW]** `OPENCODE_CONFIG_CONTENT` must NEVER be embedded in a shell command string — user-supplied JSON can contain shell metacharacters (`$()`, `;`, `&&`). Use `tmux setenv` instead, which treats the value as a raw string.
```typescript
/**
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
* Call BEFORE respawnPane().
*/
function setOpenCodeConfigContent(muxName: string, jsonContent: string): void {
// Validate JSON to prevent garbage config
JSON.parse(jsonContent); // throws if invalid
// Shell-escape for the tmux command
const escaped = jsonContent.replace(/'/g, "'\\''");
execSync(`tmux setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, {
encoding: 'utf8',
timeout: 3000,
stdio: ['pipe', 'pipe', 'pipe'],
});
}
```
#### `respawnPane()` — Same Changes (around line 426)
Apply identical command construction logic in `respawnPane()`.
#### Session Discovery — Handle OpenCode sessions
In `reconcileSessions()` (line 700+), discovered unknown sessions currently default to `mode: 'claude'`. Need to detect OpenCode sessions:
```typescript
// In reconcileSessions(), when examining running process in tmux pane:
// Check what command is running: tmux display-message -p '#{pane_current_command}'
// If it's 'opencode', set mode accordingly
const cmd = execSync(`tmux display-message -t "${muxName}" -p '#{pane_current_command}'`, ...).trim();
const mode = cmd.includes('opencode') ? 'opencode' : 'claude';
// [REVIEW] For recovered sessions, prefer the persisted MuxSession.mode over command sniffing.
// #{pane_current_command} only shows the foreground binary name — if OpenCode spawns a child
// process (e.g., bash), it will show 'bash', not 'opencode'.
```
### Known Limitations (from review)
> **[REVIEW P1-6]** OpenCode does not have `--session-id ` for initial session creation (only `--session ` for continuing existing sessions). This means:
> - `_claudeSessionId = this.id` correlation (session.ts:405) won't work for OpenCode
> - Subagent-session correlation via Session ID matching won't work
> - Transcript watching via Claude session ID path won't work
>
> **Mitigation**: Let OpenCode manage its own sessions. Store the OpenCode session ID mapping in `SessionState` after the first session is created.
> **[REVIEW P1-7]** The `toState()` method in `session.ts:782-816` must be updated to include `openCodeConfig` in the serialized state. Without this, state persistence silently drops the config and OpenCode sessions can't be restored after server restart.
---
## 9. Phase 4: Output Parsing & Idle Detection *(DEFERRED — not in MVP)*
> **DEFERRED**: This entire phase (except `waitForOpenCodeReady()`) is out of MVP scope. Idle detection, ANSI content filter, working/busy state tracking, and token parsing are all deferred until we have real PTY output data from stable OpenCode sessions. Only the basic TUI ready detection from `waitForOpenCodeReady()` is needed for the MVP and is included in Phase 3.
### Goal
Detect OpenCode's state from terminal output (idle, working, ready).
### Challenge: Bubble Tea TUI
Unlike Claude Code (Ink), OpenCode uses Bubble Tea (Go), which:
- Uses alternate screen buffer (`\x1b[?1049h`)
- Redraws the entire screen on each update — Bubble Tea does full `View()` redraws, not differential (cursor movements + clear sequences)
- Has a different visual structure (sidebar, message area, input area)
- May enable mouse event reporting (`CSI ?1000h`/`CSI ?1006h`), generating PTY output on mouse movements
**Key insight**: We don't need to parse the TUI visually. We just need to detect:
1. When the TUI is ready (initial render complete)
2. When OpenCode is working vs idle (for respawn)
3. When output has stopped changing (for completion detection)
### CRITICAL: ANSI Content Filter Required (from review)
> **[REVIEW C1]** Bubble Tea redraws the **entire screen** on every update. Even when the AI model is idle, Bubble Tea may emit cursor blinking/repositioning sequences, timer-based redraws (e.g., clock display updates), and mouse protocol output. This means `lastActivityAt` will be constantly reset by TUI maintenance redraws, and the silence threshold will **never trigger**.
>
> **Mandatory fix**: Add an ANSI content-change filter between the PTY and idle detection. Only treat actual text content changes (not cursor movements, screen redraws, or mouse protocol output) as "real output".
```typescript
// New utility: src/utils/ansi-content-filter.ts
// Strips non-content ANSI sequences so idle detection only sees real text changes
const CURSOR_MOVEMENT = /\x1b\[\??[\d;]*[HJKfABCDEFGnsurlm]/g;
const SCREEN_MODE = /\x1b\[\?[\d;]*[hlst]/g; // alternate screen, mouse, etc.
const MOUSE_EVENT = /\x1b\[M.../g; // mouse button events
const MOUSE_SGR = /\x1b\[<[\d;]+[mM]/g; // SGR mouse events
const ERASE_DISPLAY = /\x1b\[[\d]*J/g; // clear screen variants
const ERASE_LINE = /\x1b\[[\d]*K/g; // clear line variants
const CURSOR_POSITION = /\x1b\[\d+;\d+H/g; // absolute cursor positioning
const CURSOR_SAVE_RESTORE = /\x1b[78]/g; // save/restore cursor
/**
* Extract only meaningful text content changes from PTY output.
* Returns empty string if the output was purely cosmetic (redraws, cursor moves).
*/
export function extractContentChanges(data: string): string {
return data
.replace(CURSOR_MOVEMENT, '')
.replace(SCREEN_MODE, '')
.replace(MOUSE_EVENT, '')
.replace(MOUSE_SGR, '')
.replace(ERASE_DISPLAY, '')
.replace(ERASE_LINE, '')
.replace(CURSOR_POSITION, '')
.replace(CURSOR_SAVE_RESTORE, '')
.replace(/\x1b\[[\d;]*m/g, '') // SGR color/style
.trim();
}
```
```typescript
// In session.ts onData handler for OpenCode mode:
// Only update lastActivityAt when there are REAL content changes
if (this.mode === 'opencode') {
const content = extractContentChanges(data);
if (content.length > 0) {
this._lastActivityAt = Date.now();
}
// Don't update for cosmetic-only redraws
}
```
### `_idleConfig` Property Initialization (from re-review)
> **[RE-REVIEW]** The code snippets below reference `this._idleConfig.silenceThresholdMs` but `_idleConfig` does not exist on `Session`. The `getIdleDetectionConfig()` helper's return value must be stored as a private property, initialized in the constructor:
```typescript
// In session.ts constructor:
private _idleConfig: ReturnType;
constructor(options: SessionOptions) {
// ...existing init...
this._idleConfig = this.getIdleDetectionConfig();
}
```
### Gate `_processExpensiveParsers()` for OpenCode (from re-review)
> **[RE-REVIEW]** The 150ms-throttled `_processExpensiveParsers()` path in session.ts handles Ralph tracking, bash tool parsing, and token parsing — all Claude-specific. For OpenCode sessions, these must be gated:
```typescript
// In _processExpensiveParsers():
private _processExpensiveParsers(strippedData: string): void {
if (this.mode === 'opencode') {
// Skip Claude-specific parsers: Ralph tracker, BashToolParser, token parsing
// These depend on Claude's output format and would produce false positives
return;
}
// ...existing Claude parsing logic...
}
```
### Prompt Detection — `waitForOpenCodeReady()`
OpenCode's TUI takes longer to initialize than Claude's prompt:
> **[REVIEW M5]** 500ms silence threshold is too short — Bubble Tea may pause between component renders, triggering a false "ready" signal. Increased to 2000ms. Also: do NOT clear the terminal after ready detection — unlike Claude Code, the OpenCode TUI's initial render IS the useful content.
```typescript
// In session.ts, add OpenCode-specific ready detection
private async waitForOpenCodeReady(): Promise {
// OpenCode's Bubble Tea TUI renders asynchronously.
// Wait for output to stabilize (no new data for 2s after initial burst).
const maxWait = 15000; // OpenCode TUI can take up to 15s
const stabilityThreshold = 2000; // [REVIEW] 2s stability, not 500ms — TUI pauses between component renders
const checkInterval = 100;
let elapsed = 0;
let lastContentTime = Date.now();
const onOutput = (_data: string) => {
// [REVIEW C1] Only track content changes, not cosmetic redraws
const content = extractContentChanges(_data);
if (content.length > 0) {
lastContentTime = Date.now();
}
};
this.on('terminal', onOutput);
try {
while (elapsed < maxWait) {
await new Promise(r => setTimeout(r, checkInterval));
elapsed += checkInterval;
const silentMs = Date.now() - lastContentTime;
if (silentMs >= stabilityThreshold && this._terminalBuffer.length > 200) {
// TUI has rendered and stabilized
break;
}
}
} finally {
this.off('terminal', onOutput);
}
// [REVIEW] Do NOT clear the terminal — OpenCode's TUI initial render IS the useful content.
// Unlike Claude Code where initialization junk should be cleared, the Bubble Tea TUI
// continuously redraws, so clearing would just cause a flash.
}
```
### Idle Detection Strategy
**Multi-layer approach (matching Claude's pattern):**
1. **Output silence** (primary) — No terminal output for N seconds → likely idle
2. **OpenCode plugin** (advanced, Phase 7) — `session.idle` event fires → definitive signal
3. **AI checker** (fallback) — Same AI-powered idle check, but only if Claude CLI is also available
```typescript
// In session.ts, add mode-aware idle detection configuration
private getIdleDetectionConfig() {
if (this.mode === 'opencode') {
return {
// OpenCode idle detection is primarily output-silence based
silenceThresholdMs: 5000, // 5s silence = likely idle
promptPattern: null, // No regex prompt detection for Bubble Tea TUI
workingKeywords: null, // Don't scan for Claude-specific keywords
useAIChecker: false, // AI checker requires Claude CLI (enable in Phase 7 if desired)
completionPattern: null, // OpenCode completion format TBD (needs empirical testing)
};
}
// Existing Claude defaults
// [RE-REVIEW] Corrected: actual Claude idle detection uses:
// - Prompt char ❯ detection → _awaitingIdleConfirmation → 2s debounce → emit 'idle'
// - Spinner chars (Braille ⠋⠙⠹⠸⠼⠴⠦⠧) → immediately sets _isWorking=true, _status='busy'
// - Keywords (Thinking, Writing, Reading, Running) in throttled 150ms parser
// - NO completionPattern in session.ts (that's in respawn-controller.ts)
// This config object is for the NEW OpenCode-compatible detection system.
return {
silenceThresholdMs: 3000,
promptPattern: /[❯\u276f]/,
workingKeywords: ['Thinking', 'Writing', 'Reading', 'Running', 'Searching'],
useAIChecker: true,
completionPattern: null, // completion detection lives in respawn-controller, not session
};
}
```
### Token Tracking
**Phase 1**: Skip token tracking for OpenCode sessions.
**Phase 8**: Use OpenCode's server API to poll session stats for structured token/cost data.
```typescript
// In session.ts, mode-aware token parsing
private parseTokens(data: string): void {
if (this.mode === 'opencode') {
// OpenCode displays tokens in format: "~27s · 275.9k tokens"
// But this is inside the Bubble Tea TUI, making regex extraction unreliable
// Skip for now — Phase 8 adds API-based tracking
return;
}
// Existing Claude token parsing...
}
```
### Working/Idle State Tracking
For Claude, Codeman uses spinner characters and keywords. For OpenCode:
> **[REVIEW]** The original logic had a bug: `Date.now() - this._lastActivityAt > 100` is checked AFTER setting `_lastActivityAt = Date.now()`, so the condition would never be true (0 > 100 = false). Fixed below.
> **[REVIEW M1]** The `session.isWorking` property is used by the respawn controller as a final safety check. For Claude, it's set by detecting JSON messages and working patterns. For OpenCode, `isWorking` must be driven by the ANSI content filter — mark working when real content appears after a period of silence.
```typescript
// In session.ts onData handler:
if (this.mode === 'opencode') {
// [REVIEW C1] Only track content changes, not cosmetic TUI redraws
const content = extractContentChanges(data);
if (content.length === 0) return; // Cosmetic-only redraw, ignore
const timeSinceLastOutput = Date.now() - this._lastActivityAt;
this._lastActivityAt = Date.now();
// If we had no real content for >silenceThreshold and now get content → mark busy
if (this._status === 'idle' && timeSinceLastOutput > this._idleConfig.silenceThresholdMs) {
this._status = 'busy';
this._isWorking = true;
this.emit('working');
}
} else {
// Existing Claude spinner/keyword detection
}
```
---
## 10. Phase 5: API Routes & Frontend UI
### API Changes
#### `src/web/server.ts`
**Session creation route** (around line 810):
```typescript
// In POST /api/sessions handler, add OpenCode check:
if (body.mode === 'opencode') {
if (!isOpenCodeAvailable()) {
return reply.status(400).send(
createErrorResponse('OpenCode CLI not found. Install: curl -fsSL https://opencode.ai/install | bash')
);
}
}
// Pass openCodeConfig to Session constructor:
const session = new Session({
workingDir,
mode: body.mode || 'claude',
name: body.name || '',
mux: this.mux,
useMux: true,
niceConfig: globalNice,
model: body.mode === 'opencode' ? body.openCodeConfig?.model : model,
openCodeConfig: body.mode === 'opencode' ? body.openCodeConfig : undefined,
// ...existing params
});
```
**New route — OpenCode availability check:**
```typescript
// GET /api/opencode/status
server.get('/api/opencode/status', async () => ({
available: isOpenCodeAvailable(),
path: resolveOpenCodeDir(),
}));
```
**Extend interactive start for OpenCode mode:**
```typescript
// POST /api/sessions/:id/interactive — already the generic start route
// Just need to ensure it works for all modes:
await session.startInteractive(); // mode determines what command spawns
getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: session.mode });
this.broadcast('session:interactive', { id, mode: session.mode });
```
**Quick-start for OpenCode:**
> **[RE-REVIEW]** `writeHooksConfig(casePath)` is called unconditionally at line 2625 in quick-start. Must guard with `mode !== 'opencode'` — Claude hooks are irrelevant for OpenCode sessions.
> Also: lifecycle logs at lines 1657 and 2675 hardcode `mode: 'claude'` — should use `session.mode`.
```typescript
// POST /api/quick-start — extend to handle opencode mode:
if (mode === 'opencode') {
// Skip Claude-specific setup (hooks, CLAUDE.md generation)
// [RE-REVIEW] writeHooksConfig() must NOT be called for OpenCode sessions
// But do set up opencode.json permission config if autoAllowTools
await session.startInteractive();
} else if (mode === 'shell') {
await session.startShell();
} else {
// Claude mode — write hooks config as before
writeHooksConfig(casePath);
await session.startInteractive();
}
// [RE-REVIEW] Use session.mode in lifecycle log, NOT hardcoded 'claude':
getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: session.mode });
```
#### `src/web/schemas.ts`
> **[REVIEW C4]** Also update `ALLOWED_ENV_PREFIXES` to include `'OPENCODE_'`, and add `OPENCODE_SERVER_PASSWORD` to `BLOCKED_ENV_KEYS` (security-sensitive). Also update `QuickStartSchema` to include `'opencode'` mode and `openCodeConfig` field.
>
> **[RE-REVIEW]** These MUST be an atomic change — shipping the `OPENCODE_` prefix addition without simultaneously blocking `OPENCODE_SERVER_PASSWORD` creates a security window where the server password can be set via API.
Update `CreateSessionSchema` and `QuickStartSchema`:
```typescript
export const CreateSessionSchema = z.object({
prompt: z.string().optional(),
workingDir: WorkingDirSchema.optional(),
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
name: z.string().max(100).optional(),
// ...existing fields...
openCodeConfig: z.object({
model: z.string().max(100).regex(/^[a-zA-Z0-9._\-/]+$/).optional(),
autoAllowTools: z.boolean().optional(),
continueSession: z.string().max(100).regex(/^[a-zA-Z0-9_-]+$/).optional(),
forkSession: z.boolean().optional(),
serverPort: z.number().int().min(1024).max(65535).optional(),
configContent: z.string().max(10000).optional(),
}).optional(),
});
```
### Frontend Changes
#### `src/web/public/app.js`
**Session creation UI** — Add OpenCode option:
```javascript
// In quick-start or new session dialog, add a mode selector:
// Three-way toggle: [Claude Code] [OpenCode] [Shell]
function createModeSelector(container, defaultMode) {
const modes = [
{ value: 'claude', label: 'Claude Code', desc: 'Anthropic Claude AI' },
{ value: 'opencode', label: 'OpenCode', desc: 'Multi-model AI agent' },
{ value: 'shell', label: 'Shell', desc: 'Plain terminal' },
];
// Check OpenCode availability
fetch('/api/opencode/status').then(r => r.json()).then(status => {
if (!status.available) {
// Gray out OpenCode option, show install hint
opencodeBtn.disabled = true;
opencodeBtn.title = 'OpenCode not installed';
}
});
// When OpenCode selected, show model input:
// - Text input with datalist of common models
// - Checkbox for "Auto-allow tools" (equivalent to --dangerously-skip-permissions)
}
```
**Model selector for OpenCode:**
```javascript
function createOpenCodeModelInput() {
const commonModels = [
'anthropic/claude-sonnet-4-5',
'anthropic/claude-opus-4-5',
'openai/gpt-5.2',
'openai/gpt-5.2-mini',
'google/gemini-3-pro',
'ollama/codellama',
'ollama/llama3',
'ollama/deepseek-coder',
];
// Render as with