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.mdRelated: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 Re-reviewed: 2026-02-26 — 4-agent team re-review (types, parsing, respawn, API). See Section 22: Re-Review Findings
- Executive Summary
- What is OpenCode?
- Architecture Comparison: Claude Code vs OpenCode
- Integration Strategy Overview
- Phase 0: Prerequisites & Manual Validation
- Phase 1: Type System & Backend Abstraction
- Phase 2: OpenCode CLI Resolution
- Phase 3: Tmux Spawn Integration
- Phase 4: Output Parsing & Idle Detection
- Phase 5: API Routes & Frontend UI
- Phase 6: Hooks & Plugin Bridge (was Phase 7 — reordered, see review)
- Phase 7: Respawn & Ralph Loop Adaptation (was Phase 6 — reordered, see review)
- Phase 8: OpenCode Server API Integration (Advanced)
- Files to Modify
- Files to Create
- Existing plan.json Task Breakdown
- Risk Assessment
- Testing Strategy
- Open Questions & Decisions
- Implementation Order
- Review Findings
- Re-Review Findings
- Appendix A: OpenCode CLI Reference
- Appendix B: OpenCode Plugin Events
- Appendix C: OpenCode Permission Config
- Appendix D: Current Codeman Session Spawn Flow (Annotated)
This plan details how to integrate OpenCode — 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.
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.
| 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.
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//initcommands). Entire cycle logic is Claude-specific.Plugin bridge session.idleevent verified + Phase 0 PTY dataRalph Loop Depends entirely on <promise>PHRASE</promise>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: <promise>tags,TodoWritetool detection,RALPH_STATUSblocks,@fix_plan.mdworkflow. None exist in OpenCode.OpenCode-native equivalent signals Circuit breaker Signals ( consecutiveNoProgress,consecutiveTestsFailure,BLOCKED) all come fromRALPH_STATUSblocks. 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 -pfor analysis. Won't work if only OpenCode is installed.Claude CLI availability or opencode runfallbackWhat 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.
- 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 servemode enables richer programmatic integration - Plugin system: JS/TS plugins with rich event hooks (including
session.idle— perfect for Codeman)
- GitHub: https://github.com/opencode-ai/opencode (originally
sst/opencode, nowanomalyco/opencode) - Website: https://opencode.ai
- License: MIT
- Language: Go (binary), with TypeScript plugin/config system
- TUI Framework: Bubble Tea (Go) — not
@opentui/solidas earlier versions used - Install:
curl -fsSL https://raw.githubusercontent.com/opencode-ai/opencode/refs/heads/main/install | bashorbrew install opencode-ai/tap/opencodeorgo install github.com/opencode-ai/opencode@latest
| 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 <id> |
--session <id> or -s <id> |
| 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 |
# 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 <id> # 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 <file> # Import from file/URL
# Model management
opencode models [provider] # List available models
opencode models --refresh # Update model cache
# Global flags
--help, --version, --debug, --cwd <dir>, --log-level, --print-logsLocated in project root (or ~/.config/opencode/opencode.json for global), configures model, tools, agents:
- Remote config (
.well-known/opencodeendpoint) - Global config (
~/.config/opencode/opencode.json) - Custom config (
OPENCODE_CONFIGenv var) - Project config (
opencode.jsonin project root) .opencode/directory (agents, commands, plugins)- Inline config (
OPENCODE_CONFIG_CONTENTenv var)
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")POST /api/sessions → new Session({mode: 'claude', mux: TmuxManager})
POST /api/sessions/:id/interactive → session.startInteractive()
↓
TmuxManager.createSession()
↓
tmux new-session -ds "codeman-<id>"
tmux respawn-pane -k -t ... "claude --dangerously-skip-permissions --session-id <id>"
↓
pty.spawn('tmux', ['attach-session', '-t', 'codeman-<id>'])
↓
ptyProcess.onData() → emit('terminal') → SSE broadcast → xterm.js
POST /api/sessions → new Session({mode: 'opencode', mux: TmuxManager})
POST /api/sessions/:id/interactive → session.startInteractive()
↓
TmuxManager.createSession()
↓
tmux new-session -ds "codeman-<id>"
tmux respawn-pane -k -t ... "opencode --model <model>"
↓
pty.spawn('tmux', ['attach-session', '-t', 'codeman-<id>'])
↓
ptyProcess.onData() → emit('terminal') → SSE broadcast → xterm.js
Identical pipeline! The only differences are:
- The command spawned inside tmux (
opencodevsclaude) - The CLI arguments (
--modelvs--dangerously-skip-permissions --session-id) - The environment variables passed to the process
- Output parsing patterns (idle detection, prompt character, token tracking)
- Permission handling (config file vs CLI flag)
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
| 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.
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 tmuxsend-keys
| System | Current (Claude-specific) | OpenCode Equivalent | MVP? |
|---|---|---|---|
| CLI binary | claude |
opencode |
YES |
| CLI args | --dangerously-skip-permissions --session-id <id> |
--model <m> + 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 | <promise>PHRASE</promise> 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 |
Install OpenCode and validate it works inside tmux before writing any code.
# 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"- OpenCode binary found and version verified
- TUI renders correctly inside tmux
- tmux
send-keys -lsends text to OpenCode's TUI correctly - Separate
send-keys Entertriggers prompt submission - TUI survives tmux detach/reattach
-
remain-on-exitkeeps session alive after OpenCode exits - Terminal resize works (try different tmux dimensions)
- Permission auto-allow works via
OPENCODE_CONFIG_CONTENT - Non-interactive
opencode runproduces JSON output - xterm.js renders the TUI (manually test by piping PTY output)
[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). Testtmux send-keys -l "Hello"followed bytmux send-keys Enterand 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.jsplugin, 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
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.
Extend the type system to support 'opencode' as a session mode.
There are two paths (both represented in the codebase):
Option A: Simple mode extension (recommended for Phase 1)
- Add
'opencode'to existingSessionModeunion type - Use
if/elsebranches in existing code - Less refactoring, faster to ship
Option B: Full backend abstraction (from plan.json)
- Create
LLMBackendinterface +ClaudeBackend+OpenCodeBackendclasses - 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.
[REVIEW C5]
SessionModeis a named type export insession.ts:204(export type SessionMode = 'claude' | 'shell').types.ts:162has an inline anonymous union (mode?: 'claude' | 'shell') insideSessionState— NOT a namedSessionModetype. Additionally,mux-interface.tshas 3 inline'claude' | 'shell'unions (MuxSession.mode, createSession param, respawnPane param). All 5 locations must stay in sync. Fix: CreateSessionModeintypes.ts, import and use it insession.tsandmux-interface.ts.
[REVIEW M6]
createSession()already has 8 positional parameters. AddingopenCodeConfigas the 9th is a code smell. Fix: RefactorcreateSession()andrespawnPane()inTerminalMultiplexerto accept an options object before adding OpenCode. This also eliminates the need to keep parameter order in sync.
// 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<MuxSession>;// Move SessionMode here (single source of truth):
export type SessionMode = 'claude' | 'shell' | 'opencode';// BEFORE (delete this):
export type SessionMode = 'claude' | 'shell';
// AFTER (import from types.ts):
import type { SessionMode } from './types.js';/** 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:
// In SessionState interface:
/** OpenCode-specific configuration (only for mode === 'opencode') */
openCodeConfig?: OpenCodeConfig;// 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<MuxSession>;
respawnPane(options: RespawnPaneOptions): Promise<number | null>;Note:
mux-factory.tsdoes NOT need changes — it just instantiatesTmuxManagerwith no parameters.
// 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,Create a resolver for the opencode binary, mirroring claude-cli-resolver.ts.
/**
* @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;
}export { resolveOpenCodeDir, isOpenCodeAvailable, getOpenCodeAugmentedPath } from './opencode-cli-resolver.js';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.
[REVIEW]
createSession()andrespawnPane()already duplicate Claude command construction (lines 261-311 vs 426-454). Adding OpenCode creates a 3rd branch in both. Fix: Extract a sharedbuildSpawnCommand(mode, options)helper that both methods call.
// 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';
}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(' ');
}// 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';
}// 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
}OpenCode needs different env vars than Claude Code:
// 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 <session> ANTHROPIC_API_KEY <value>
// tmux setenv -t <session> OPENAI_API_KEY <value>
// 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(' && ');
}[REVIEW M7] API keys passed via inline
exportin the tmux command are visible inpsoutput and tmux history. Usetmux setenvto set sensitive vars on the session instead.
/**
* 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'],
});
}
}
}[RE-REVIEW]
OPENCODE_CONFIG_CONTENTmust NEVER be embedded in a shell command string — user-supplied JSON can contain shell metacharacters ($(),;,&&). Usetmux setenvinstead, which treats the value as a raw string.
/**
* 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'],
});
}Apply identical command construction logic in respawnPane().
In reconcileSessions() (line 700+), discovered unknown sessions currently default to mode: 'claude'. Need to detect OpenCode sessions:
// 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'.[REVIEW P1-6] OpenCode does not have
--session-id <codeman-id>for initial session creation (only--session <id>for continuing existing sessions). This means:
_claudeSessionId = this.idcorrelation (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
SessionStateafter the first session is created.
[REVIEW P1-7] The
toState()method insession.ts:782-816must be updated to includeopenCodeConfigin the serialized state. Without this, state persistence silently drops the config and OpenCode sessions can't be restored after server restart.
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 fromwaitForOpenCodeReady()is needed for the MVP and is included in Phase 3.
Detect OpenCode's state from terminal output (idle, working, ready).
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:
- When the TUI is ready (initial render complete)
- When OpenCode is working vs idle (for respawn)
- When output has stopped changing (for completion detection)
[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
lastActivityAtwill 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".
// 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();
}// 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
}[RE-REVIEW] The code snippets below reference
this._idleConfig.silenceThresholdMsbut_idleConfigdoes not exist onSession. ThegetIdleDetectionConfig()helper's return value must be stored as a private property, initialized in the constructor:
// In session.ts constructor:
private _idleConfig: ReturnType<Session['getIdleDetectionConfig']>;
constructor(options: SessionOptions) {
// ...existing init...
this._idleConfig = this.getIdleDetectionConfig();
}[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:
// 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...
}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.
// In session.ts, add OpenCode-specific ready detection
private async waitForOpenCodeReady(): Promise<void> {
// 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.
}Multi-layer approach (matching Claude's pattern):
- Output silence (primary) — No terminal output for N seconds → likely idle
- OpenCode plugin (advanced, Phase 7) —
session.idleevent fires → definitive signal - AI checker (fallback) — Same AI-powered idle check, but only if Claude CLI is also available
// 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
};
}Phase 1: Skip token tracking for OpenCode sessions. Phase 8: Use OpenCode's server API to poll session stats for structured token/cost data.
// 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...
}For Claude, Codeman uses spinner characters and keywords. For OpenCode:
[REVIEW] The original logic had a bug:
Date.now() - this._lastActivityAt > 100is checked AFTER setting_lastActivityAt = Date.now(), so the condition would never be true (0 > 100 = false). Fixed below.
[REVIEW M1] The
session.isWorkingproperty 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,isWorkingmust be driven by the ANSI content filter — mark working when real content appears after a period of silence.
// 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
}Session creation route (around line 810):
// 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:
// GET /api/opencode/status
server.get('/api/opencode/status', async () => ({
available: isOpenCodeAvailable(),
path: resolveOpenCodeDir(),
}));Extend interactive start for OpenCode mode:
// 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 withmode !== 'opencode'— Claude hooks are irrelevant for OpenCode sessions. Also: lifecycle logs at lines 1657 and 2675 hardcodemode: 'claude'— should usesession.mode.
// 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 });[REVIEW C4] Also update
ALLOWED_ENV_PREFIXESto include'OPENCODE_', and addOPENCODE_SERVER_PASSWORDtoBLOCKED_ENV_KEYS(security-sensitive). Also updateQuickStartSchemato include'opencode'mode andopenCodeConfigfield.[RE-REVIEW] These MUST be an atomic change — shipping the
OPENCODE_prefix addition without simultaneously blockingOPENCODE_SERVER_PASSWORDcreates a security window where the server password can be set via API.
Update CreateSessionSchema and QuickStartSchema:
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(),
});Session creation UI — Add OpenCode option:
// 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:
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 <input> with <datalist> for autocomplete
// Default to 'anthropic/claude-sonnet-4-5'
}Tab badge — Show mode indicator:
// In tab rendering (search for shell tab-mode class):
// CURRENT:
// ${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : ''}
// PROPOSED:
function getModeBadge(mode) {
if (mode === 'opencode') return '<span class="tab-mode opencode" aria-hidden="true">oc</span>';
if (mode === 'shell') return '<span class="tab-mode shell" aria-hidden="true">sh</span>';
return ''; // Claude = no badge (default)
}CSS for OpenCode badge:
.tab-mode.opencode {
background: #10b981; /* Green - OpenCode brand */
color: white;
}Feature gating — Disable Claude-specific features for OpenCode (MVP):
// Functions to check session capabilities:
function isClaudeSession(session) { return session.mode === 'claude'; }
function isOpenCodeSession(session) { return session.mode === 'opencode'; }
function isAgentSession(session) { return session.mode === 'claude' || session.mode === 'opencode'; }
// UI sections to gate for MVP:
// - Hooks panel: HIDE for OpenCode (no plugin bridge yet)
// - Auto-compact button: HIDE for OpenCode (has built-in compaction)
// - Ralph tracker settings: HIDE for OpenCode (no <promise> tags, no RALPH_STATUS)
// - Subagent panel: HIDE for OpenCode (no BashToolParser/SubagentWatcher integration)
// - Token display: HIDE for OpenCode (no token parsing)
// - Respawn: HIDE for OpenCode (respawn controller is deferred)
// - Circuit breaker: HIDE for OpenCode (depends on Ralph Tracker)
// - Input/resize/kill: SHOW for all modes ✓
// - Tab badges: SHOW for all modes ✓
// - Terminal rendering: SHOW for all modes ✓Respawn section visibility (MVP — hide for OpenCode):
// CURRENT (line ~10289):
if (session.mode === 'claude' && session.pid) {
respawnSection.style.display = '';
}
// MVP: No change needed — already gated to 'claude' only.
// When respawn is eventually supported for OpenCode (Phase 7), update to:
// if ((session.mode === 'claude' || session.mode === 'opencode') && session.pid) {
// respawnSection.style.display = '';
// }DEFERRED: Plugin event names are speculative and unverified. This phase requires Phase 0 plugin verification first. The entire hooks/plugin system is out of MVP scope — OpenCode sessions in the MVP are manual-interaction only.
[REVIEW C3] Phase reordered: This was originally Phase 7 but has been moved before Respawn. The plugin bridge provides the reliable
session.idleevent that the respawn controller needs. Without it, respawn relies on output-silence-only detection, which is unreliable with Bubble Tea TUIs (see Review C1). Do not implement respawn (Phase 7) until this phase is complete and tested.
Bridge Codeman's hook system with OpenCode's plugin system for rich event forwarding.
| Feature | Claude Code Hooks | OpenCode Plugins |
|---|---|---|
| Format | Shell commands in settings.local.json |
JS/TS modules in .opencode/plugins/ |
| Trigger | Hook name matches event type | Event name subscription |
| Key events | stop, idle_prompt, permission_prompt, elicitation_dialog |
session.idle, permission.asked, session.status |
| Communication | Exit codes + environment variables | Function context + return values |
| Install | Auto-generated by Codeman | Must be placed in .opencode/plugins/ |
Create a Codeman plugin that OpenCode loads, which communicates back to Codeman's API:
File: .opencode/plugins/codeman-bridge.js (generated per session)
// This plugin bridges OpenCode events to Codeman's API
export const codemanBridge = async ({ project, $ }) => {
const apiUrl = process.env.CODEMAN_API_URL || 'http://localhost:3000';
const sessionId = process.env.CODEMAN_SESSION_ID;
if (!sessionId) return {};
async function notifyClademan(event, data = {}) {
try {
await fetch(`${apiUrl}/api/hook-event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, event, data }),
});
} catch { /* ignore errors */ }
}
return {
'session.idle': async () => {
await notifyClademan('idle_prompt', { timestamp: Date.now() });
},
'session.status': async (status) => {
await notifyClademan('session_status', { status });
},
'permission.asked': async (permission) => {
await notifyClademan('permission_prompt', { permission });
},
'permission.replied': async (reply) => {
await notifyClademan('permission_reply', { reply });
},
'session.error': async (error) => {
await notifyClademan('session_error', { error });
},
'tool.execute.before': async (tool) => {
await notifyClademan('tool_start', { tool: tool.name });
},
'tool.execute.after': async (tool) => {
await notifyClademan('tool_end', { tool: tool.name });
},
'todo.updated': async (todos) => {
await notifyClademan('todo_update', { todos });
},
};
};When creating an OpenCode session, Codeman generates this plugin in the project's .opencode/plugins/ directory:
// In session.ts or a new opencode-hooks.ts:
async function installOpenCodePlugin(workingDir: string, sessionId: string): Promise<void> {
const pluginDir = join(workingDir, '.opencode', 'plugins');
await mkdirp(pluginDir);
const pluginContent = generateCodemanBridgePlugin(sessionId);
await writeFile(join(pluginDir, 'codeman-bridge.js'), pluginContent);
}[REVIEW] Add
session.compactedto the plugin bridge — it's free and useful for tracking when OpenCode auto-compacts (relevant for token tracking and respawn timing).
// Add to codeman-bridge.js return object:
'session.compacted': async (info) => {
await notifyClademan('session_compacted', { info });
},[REVIEW M3] The event names in this section and Appendix B are speculative — they have not been verified against OpenCode's actual plugin API. Event names, callback signatures, and firing timing may differ from what's documented here.
HARD PREREQUISITE: Before implementing this phase, complete Phase 0's plugin verification checklist — install a test plugin and confirm which events actually fire and when. The
session.idleevent is particularly critical: does it fire when the model finishes generating, or when the TUI returns to the input prompt? This distinction matters enormously for idle detection timing.
Once installed, Codeman receives structured events from OpenCode:
session.idle→ Definitive idle detection (replaces output-silence guessing)permission.asked→ Show permission prompts in Codeman UItool.execute.*→ Tool call tracking (similar to BashToolParser for Claude)todo.updated→ OpenCode's built-in todo system → Codeman can display itsession.error→ Error surfacing in Codeman UIsession.compacted→ Track auto-compaction events
DEFERRED: Respawn controller (3,500 lines, 13 states), Ralph Loop, and Ralph Tracker are all deeply coupled to Claude's output protocols (
Worked for Xm Xs,<promise>tags,---RALPH_STATUS---blocks, spinner chars,❯prompt). With 75+ model providers in OpenCode producing different output formats, these systems will break in unpredictable ways. Deferring until we have: (1) stable OpenCode sessions with real PTY data, (2) verified plugin bridge from Phase 6, (3) empirical idle detection calibration.[REVIEW C3] Phase reordered: This was originally Phase 6 but has been moved after Plugin Bridge. The plugin bridge's
session.idleevent is the primary reliable idle signal for OpenCode sessions. Implementing respawn before the bridge exists means relying on output-silence-only detection, which is unreliable.[REVIEW] Respawn should ship disabled by default for OpenCode sessions. Enable only after the plugin bridge (Phase 6) is proven to work reliably.
[REVIEW C2] The RespawnController is ~3,500 lines with 13 states,
assertNever()exhaustive switches in multiple places, and dozens of methods. Sprinklingif (mode === 'opencode')throughout would be unmaintainable. Fix: Create aCompletionDetectorinterface.
// New interface in src/types.ts or src/completion-detector.ts:
interface CompletionDetector {
/** Check if terminal output indicates the AI has finished */
isCompletionMessage(data: string): boolean;
/** Check if terminal output indicates the AI is currently working */
hasWorkingPattern(data: string): boolean;
/** Get the idle timeout for this backend */
getIdleTimeoutMs(): number;
/** Get the completion silence threshold */
getCompletionSilenceMs(): number;
/** Whether this detector supports AI-powered idle checking */
supportsAIChecker(): boolean;
}
class ClaudeCompletionDetector implements CompletionDetector {
isCompletionMessage(data: string): boolean {
return /\bWorked\s+for\s+\d+[hms](\s*\d+[hms])*/i.test(data);
}
hasWorkingPattern(data: string): boolean {
return /Thinking|Writing|Reading|Running|Searching|Editing|Creating/.test(data);
}
getIdleTimeoutMs(): number { return 3000; }
getCompletionSilenceMs(): number { return 5000; }
supportsAIChecker(): boolean { return true; }
}
class OpenCodeCompletionDetector implements CompletionDetector {
isCompletionMessage(data: string): boolean {
// OpenCode doesn't emit "Worked for Xm Xs" — rely on plugin bridge's session.idle event
// or fall back to output silence (via ANSI content filter)
return false; // Never matches — idle detection is event-driven
}
hasWorkingPattern(data: string): boolean {
// OpenCode working patterns TBD — needs Phase 0 empirical data
// Bubble Tea TUI uses different status indicators than Claude
return false;
}
getIdleTimeoutMs(): number { return 8000; } // Conservative for TUI redraws
getCompletionSilenceMs(): number { return 10000; }
supportsAIChecker(): boolean { return false; } // AI checker requires Claude CLI
}// In RespawnController constructor:
this._completionDetector = session.mode === 'opencode'
? new OpenCodeCompletionDetector()
: new ClaudeCompletionDetector();
// Then use throughout instead of mode checks:
if (this._completionDetector.isCompletionMessage(data)) { ... }
if (this._completionDetector.hasWorkingPattern(data)) { ... }The respawn controller sends Claude CLI commands (/clear, /compact, /init, /update) which are Claude-specific. For OpenCode, we need alternative commands.
| Claude Code | OpenCode Equivalent | Notes |
|---|---|---|
/clear |
/clear |
Same command! Clears conversation |
/compact |
Ctrl+X c |
Or opencode handles auto-compaction |
/init |
N/A | OpenCode doesn't have this |
/update prompt |
Just type the prompt | Direct text input |
/resume |
--continue flag on restart |
Handled at spawn time |
[REVIEW M4]
writeViaMux()sends\rfor Enter — it is untested whether Bubble Tea handles this correctly. This is a Phase 0 validation item. If Bubble Tea requires a different key sequence, the entire respawn command delivery mechanism fails.
[REVIEW] Use the
CompletionDetectorinterface instead of inline mode checks.
// In the respawn cycle method:
private async startRespawnCycle(): Promise<void> {
if (this.session.mode === 'opencode') {
// OpenCode respawn: simpler cycle
// 1. Wait for idle (plugin bridge session.idle event OR output silence via content filter)
// 2. Optionally compact: skip (OpenCode manages this internally via compaction.auto)
// 3. Send new prompt via writeViaMux()
// 4. Wait for completion (plugin bridge session.idle event again)
// Send prompt directly — OpenCode TUI accepts typed text
await this.session.writeViaMux(this.config.prompt);
return;
}
// Existing Claude respawn logic...
}
// Completion detection — now uses CompletionDetector:
private isCompletionDetected(): boolean {
if (this._completionDetector.isCompletionMessage(this._lastOutput)) {
return true;
}
// Fallback: silence-based detection (for both backends)
return Date.now() - this.session.lastActivityAt > this._completionDetector.getCompletionSilenceMs();
}[REVIEW] These items are NOT addressed above and need design work:
Circuit breaker signals for OpenCode: The circuit breaker tracks
no-progress,same-error-repeated, andtests-failing-too-long. These signals come from Ralph tracker'sRALPH_STATUSblocks, which are Claude-specific output patterns. Need alternative signal sources for OpenCode (possibly via plugin bridgesession.errorevents).
/compactequivalent: The plan says "OpenCode handles this internally viacompaction.auto" — but the respawn controller may need to trigger compaction at specific points in the cycle. No programmatic compaction control is available without Phase 8's server API.
sendUpdateDocs()equivalent: The respawn cycle starts by sending a progress-summary prompt that writes to CLAUDE.md. For OpenCode, what prompt makes sense? OpenCode usesopencode.json, not CLAUDE.md.No
elicitation_dialogequivalent: Claude Code has hooks that signal when Claude asks the user a question. The respawn controller uses this to skip auto-accept. OpenCode may not have an equivalent — if auto-accept is enabled, it could accept prompts that are actually questions.
[REVIEW] Ralph Loop for OpenCode should be marked experimental and deferred to a later phase. Without
<promise>PHRASE</promise>tags, Ralph loses its primary completion signal and becomes a timeout-based prompt repeater — a significantly degraded experience.
The Ralph Loop's polling/assignment mechanism is CLI-agnostic (it uses session.sendInput() and writeViaMux()). However, its completion signaling is Claude-specific: sessionCompletion events are only emitted when ralph-tracker detects <promise>PHRASE</promise> tags in output, which is a custom Claude protocol. The main adaptation needed:
// In ralph-loop.ts:
// Completion detection for OpenCode:
if (session.mode === 'opencode') {
// Use plugin bridge session.idle event (preferred) or silence-based detection (fallback)
// OpenCode doesn't emit <promise>PHRASE</promise> tags
// Ralph tracker's completion phrase detection is skipped
// WARNING: This makes Ralph Loop significantly less reliable for OpenCode
}These presets are deferred with the rest of Phase 7. Values below are speculative — they need calibration against real PTY output data from stable OpenCode sessions.
// DEFERRED — add to app.js when Phase 7 ships:
const OPENCODE_RESPAWN_PRESETS = {
'opencode-solo': {
label: 'OpenCode Solo Work',
idleTimeoutSec: 8, // Longer than Claude (TUI rendering creates brief output bursts)
maxDurationMin: 60,
completionSilenceMs: 8000,
},
'opencode-autonomous': {
label: 'OpenCode Autonomous',
idleTimeoutSec: 15,
maxDurationMin: 480,
completionSilenceMs: 10000,
},
};DEFERRED: Advanced feature. Requires stable MVP sessions first, plus resolution of shared SQLite state problem (TUI + serve dual-process).
For each OpenCode session, optionally run opencode serve alongside the TUI for structured API access.
tmux session ─── opencode TUI (interactive, port N/A)
│
├── xterm.js (terminal rendering, Strategy A)
│
Codeman ──── opencode serve (port 4096+N, background process)
│
├── GET /session/* → structured session data
├── POST /session/message → send prompt programmatically
├── GET /session/messages → conversation history
├── SSE /global/event → real-time events
└── GET /config/* → model/provider info
[REVIEW M8] Running both
opencode(TUI) andopencode servein the same project directory creates a shared SQLite state problem. The TUI creates sessions in SQLite; the server reads from the same SQLite. But they're separate processes — there's no guarantee of session ID consistency between them.Consider alternatives:
- (a) Use
opencode serveINSTEAD of the TUI (not alongside it) — server becomes the single backend, Codeman renders its own UI- (b) Use
opencode attachto connect the TUI to the server, making the server the single source of truth- (c) Accept the dual-process model with explicit documentation of limitations
Also: In containerized/firewalled environments,
opencode servebinding to ports may be blocked.
[REVIEW] The original
Array.from(this.sessions.keys()).indexOf(this.id)is fragile — session deletion creates gaps, concurrent creation can collide. Use an incrementing counter or port pool instead.
// Each OpenCode session gets a unique port for its server
// Use a port pool with allocation/release
private static _nextPort = 4100;
private static _releasedPorts: number[] = [];
private allocateOpenCodePort(): number {
if (OpenCodePortAllocator._releasedPorts.length > 0) {
return OpenCodePortAllocator._releasedPorts.pop()!;
}
return OpenCodePortAllocator._nextPort++;
}Start opencode serve when session starts, kill when session stops:
// In session.startInteractive() for opencode mode:
if (this.mode === 'opencode' && this._openCodeConfig?.serverPort) {
const { spawn } = require('child_process');
this._openCodeServer = spawn('opencode', [
'serve',
'--port', String(this._openCodeConfig.serverPort),
'--cors', `http://localhost:${process.env.PORT || 3000}`,
], {
cwd: this.workingDir,
env: {
...process.env,
OPENCODE_SERVER_PASSWORD: crypto.randomUUID() /* generateSessionToken() does not exist — use crypto.randomUUID() */,
},
stdio: 'ignore',
detached: true,
});
// [REVIEW] Register with CleanupManager for centralized disposal.
// The once('exit') handler alone is insufficient — if the server crashes
// without triggering exit, the opencode serve process becomes a zombie.
// [RE-REVIEW] API is registerCleanup(type, fn, description), NOT addCustom().
this.cleanup.registerCleanup('stream', () => {
if (this._openCodeServer && !this._openCodeServer.killed) {
this._openCodeServer.kill('SIGTERM');
}
}, 'opencode serve process');
// Also add auth headers for proxy routes:
// [REVIEW] OpenCode uses HTTP Basic Auth (username: "opencode").
// The proxy needs to inject: Authorization: Basic base64("opencode:" + password)
this._openCodeServerAuth = Buffer.from(`opencode:${crypto.randomUUID() /* generateSessionToken() does not exist — use crypto.randomUUID() */}`).toString('base64');
}// GET /api/sessions/:id/opencode/session → proxies to opencode serve
// GET /api/sessions/:id/opencode/messages → conversation history
// POST /api/sessions/:id/opencode/message → send message
// GET /api/sessions/:id/opencode/models → available models- Structured token/cost data without TUI parsing
- Conversation history as structured messages
- Model switching mid-session via API
- Session forking — create conversation branches
- Tool call visibility — see what tools OpenCode is executing
- Definitive idle/completion state via API polling
| File | Changes | Phase |
|---|---|---|
src/types.ts |
Add SessionMode (unified), OpenCodeConfig, extend SessionState |
1 |
src/session.ts |
Import SessionMode from types (remove duplicate), add OpenCode startup + basic waitForOpenCodeReady(), toState() update |
1, 3 |
src/mux-interface.ts |
Import SessionMode, refactor to options objects (CreateSessionOptions, RespawnPaneOptions) |
1 |
src/tmux-manager.ts |
Extract buildSpawnCommand(), add buildOpenCodeCommand(), setOpenCodeEnvVars(), setOpenCodeConfigContent(), env var exports, PATH resolution. Refactor createSession()/respawnPane() to match new options-object signatures. |
1, 3 |
src/web/schemas.ts |
Update mode enum, add OpenCodeConfigSchema, add OPENCODE_ to ALLOWED_ENV_PREFIXES (atomic with OPENCODE_SERVER_PASSWORD block), update QuickStartSchema |
1, 5 |
src/web/server.ts |
New routes, mode checks, OpenCode availability endpoint, writeHooksConfig guard |
5 |
src/web/public/app.js |
Mode selector, tab badges, model picker, feature gating (hide respawn/Ralph for OpenCode) | 5 |
src/state-store.ts |
Persist openCodeConfig in session state |
1 |
src/utils/index.ts |
Re-export OpenCode resolver | 2 |
src/session-lifecycle-log.ts |
Log OpenCode-specific events, fix hardcoded mode: 'claude' |
3 |
| File | Changes | Phase | Status |
|---|---|---|---|
src/respawn-controller.ts |
CompletionDetector interface, OpenCode respawn cycle |
7 | DEFERRED |
src/ralph-loop.ts |
Mode-aware completion detection | 7 | DEFERRED |
src/ralph-tracker.ts |
Skip Claude-specific patterns for OpenCode | 7 | DEFERRED |
src/hooks-config.ts |
Skip Claude hook generation for OpenCode | 6 | DEFERRED |
[REVIEW]
mux-factory.tsdoes NOT need changes — it just instantiatesTmuxManagerwith no parameters.
| File | Purpose | Phase |
|---|---|---|
src/utils/opencode-cli-resolver.ts |
Resolve opencode binary location |
2 |
test/opencode-resolver.test.ts |
Tests for binary resolution | 2 |
test/opencode-session.test.ts |
Tests for OpenCode session spawning (port 3155) | 3 |
| File | Purpose | Phase | Status |
|---|---|---|---|
src/utils/ansi-content-filter.ts |
Strip cosmetic ANSI sequences for idle detection | 4 | DEFERRED |
src/completion-detector.ts |
CompletionDetector interface + implementations |
7 | DEFERRED |
src/opencode-plugin-generator.ts |
Generate .opencode/plugins/codeman-bridge.js |
6 | DEFERRED |
src/opencode-api-client.ts |
Client for OpenCode's REST API (Strategy B) | 8 | DEFERRED |
test/opencode-respawn.test.ts |
Tests for OpenCode respawn cycle (port 3156) | 7 | DEFERRED |
test/ansi-content-filter.test.ts |
Tests for ANSI content filter | 4 | DEFERRED |
The file plan.json at the project root contains a 48-task TDD breakdown for this integration, organized as:
- P0 (14 tasks): Core backend abstraction —
LLMBackendinterface,ClaudeBackend,OpenCodeBackend,BackendFactory, Session refactoring - P1 (38 tasks): Full integration — CLI resolver, API routes, Zod schemas, config management, respawn adaptation, Ralph tracker, BashToolParser, hooks, frontend UI, subagent watcher, env vars, integration tests, documentation
- P2 (8 tasks): Advanced —
opencode serveintegration, API client, Ollama model management, cost tracking, mixed-backend Ralph Loop
The plan.json approach is more heavyweight (full backend abstraction with DI), suitable if we plan to add more backends. This document recommends the simpler "mode extension" approach for initial implementation.
[REVIEW]
plan.jsonand this document are somewhat contradictory — plan.json recommends Option B (full DI abstraction withLLMBackend,ClaudeBackend,OpenCodeBackend,BackendFactory) while this document recommends Option A (simple mode extension). The P0 tasks in plan.json (14 tasks for backend abstraction) should be marked "skip for now" or removed. Some P1 tasks referenceBackendFactorywhile others just extendSessionMode. Reconcile before implementation begins.
- Type system changes (Phase 1) — Additive, no breaking changes, all defaults remain 'claude'
- CLI resolver (Phase 2) — Isolated utility, well-tested pattern from
claude-cli-resolver.ts - Tab badges (Phase 5) — Cosmetic only
- Feature gating (Phase 5) — Just
ifchecks onsession.mode
- Tmux command construction (Phase 3) — Must handle: missing binary, bad model string, env var escaping, shell metacharacter injection. Mitigated by following the exact Claude pattern and input validation.
- Idle detection (Phase 4) — Output silence is less precise than prompt detection. [REVIEW C1] Bubble Tea's continuous screen redraws mean raw output silence will never trigger — the ANSI content filter (mandatory) mitigates this but needs Phase 0 calibration.
configContentfield (Phase 3) — Arbitrary JSON passed asOPENCODE_CONFIG_CONTENT. A malicious client could inject any OpenCode config, including custom tool definitions or MCP servers. Mitigated by shell escaping, but consider validating JSON structure (whitelist allowed keys) or marking as admin-only.
- OpenCode TUI in xterm.js — Bubble Tea uses alternate screen buffer, mouse events, and complex cursor manipulation. Must validate manually in Phase 0 before any coding.
- Respawn for OpenCode (Phase 7) — [REVIEW C2, C3] The respawn controller is 3,500 lines with 13 states. Without the plugin bridge's
session.idleevent, idle detection relies solely on output silence through the ANSI filter — the weakest possible detection. Mitigated by: shipping respawn disabled by default for OpenCode, requiring Phase 6 plugin bridge first, usingCompletionDetectorinterface instead of mode-check spaghetti. - OpenCode server API (Phase 8) — [REVIEW M8] Running TUI + serve alongside creates a shared SQLite state problem. Port allocation races, zombie processes, auth header injection needed. Mitigated by making it optional and considering serve-only alternative.
- Plugin bridge reliability (Phase 6) — [REVIEW M3] Plugin event names are speculative. The generated plugin depends on OpenCode loading it correctly and the fetch calls not failing silently. Need Phase 0 verification of actual event names and signatures.
- Ralph Loop for OpenCode — [REVIEW] Without
<promise>tags, Ralph loses its primary completion signal. Becomes a timeout-based prompt repeater — significantly degraded experience. Should be marked experimental.
- OpenCode's TUI escape sequences — Does xterm.js render them correctly?
- OpenCode's SIGWINCH handling — Does resize work through tmux?
- OpenCode's stdin behavior — Does
tmux send-keys -lwork for typing? Doessend-keys Entertrigger prompt submission? remain-on-exitbehavior — Does the tmux session stay alive when OpenCode exits?opencode.jsonconflicts — If one exists in the project, do CLI flags override it?- [REVIEW] Bubble Tea TUI redraw frequency during idle state — does it emit maintenance redraws?
- [REVIEW] Mouse protocol output — does Bubble Tea enable mouse reporting?
- [REVIEW] Plugin event names and callback signatures — are the names in Appendix B correct?
- [REVIEW]
writeViaMux()\rhandling — does Bubble Tea handle Enter the same way as Ink? - [REVIEW] OpenCode auto-compaction behavior — what does TUI output look like during compaction?
- [REVIEW] OpenCode version compatibility — CLI flags and plugin API may change between versions
- API key exposure — [REVIEW M7] Passing API keys via inline
exportin tmux commands exposes them inpsoutput and tmux history. Mitigated by usingtmux setenvinstead. - Network firewall risk — [REVIEW] OpenCode's
opencode servebinds to ports — in containerized/firewalled environments, this may be blocked. - OpenCode version compatibility — [REVIEW] No version pinning or check. OpenCode is rapidly evolving; CLI flags, plugin API, and serve endpoints could change between versions. Add version detection in CLI resolver and document minimum supported version.
- No
elicitation_dialogequivalent — [REVIEW] Claude Code has hooks that signal when Claude asks a question. OpenCode may not have equivalent. If auto-accept is enabled for OpenCode, it could accept prompts that are actually questions to the user.
# Test OpenCode CLI resolver
npx vitest run test/opencode-resolver.test.ts
# Test session with mocked OpenCode
npx vitest run test/opencode-session.test.ts
# Test respawn with OpenCode backend
npx vitest run test/opencode-respawn.test.tsopencode-resolver.test.ts— No port needed (pure unit test)opencode-session.test.ts— Port 3155opencode-respawn.test.ts— Port 3156opencode-integration.test.ts— Port 3157 (future)
- Manual smoke test: Install OpenCode → create session via API → verify TUI renders in xterm.js
- Playwright test: Automate session creation → verify terminal has content → send input → verify response
- Respawn test: Start respawn → verify prompt sending → verify completion detection
- Never run OpenCode tests that spawn real tmux sessions inside Codeman (same safety rule as Claude tests)
- Use MockSession from
test/respawn-test-utils.tsfor respawn testing - Mock the opencode binary for unit tests (
jest.mockor stub) - Use unique test ports (3155+) — never port 3000
| Question | Decision |
|---|---|
| Simple extension vs. backend abstraction? | Simple extension first (add 'opencode' to SessionMode), refactor later if needed |
| Should OpenCode sessions share same tab UI? | Yes, same UI with "oc" mode badge |
| How to handle permissions? | OPENCODE_CONFIG_CONTENT env var with "permission": {"*": "allow"} |
| How to handle model selection? | Pass via --model CLI flag + store in openCodeConfig |
-
Should we support
opencode run(non-interactive pipe mode)?- Could be useful for one-shot prompts and AI checker. Lower priority than TUI mode.
- Recommendation: Defer to Phase 8.
-
Should Ralph Loop work with OpenCode?
- Technically possible (send prompts via tmux, detect completion by silence + plugin events)
- [REVIEW] Without
<promise>tags, Ralph becomes a timeout-based prompt repeater — significantly degraded - Recommendation: Mark as experimental for OpenCode. Support with longer timeouts but clearly warn users about reduced reliability
-
What about OpenCode's built-in agent system?
- OpenCode has "build", "task", "title" agents plus custom agents
- Recommendation: Ignore initially, add agent selection dropdown in Phase 8
-
Should AI checkers use Claude or OpenCode for analysis?
- AI checkers currently always spawn
claude -pfor analysis - [REVIEW] If only OpenCode is installed (no Claude CLI), AI checkers silently fail — removing two important safety layers
- [REVIEW]
opencode run --format jsoncould serve as an alternative AI checker backend - Recommendation: Always use Claude for AI checks if available. If Claude CLI is not available but OpenCode is, consider using
opencode runas a fallback. Document this limitation.
- AI checkers currently always spawn
-
Should we auto-generate
opencode.jsonin the working directory?- Option A: Let OpenCode use existing project config (respect user settings)
- Option B: Generate a temporary one with Codeman's settings
- Recommendation: Option A (use
OPENCODE_CONFIG_CONTENTenv var for Codeman-specific overrides, don't modify project files)
-
How should OpenCode session IDs map to Codeman session IDs?
- OpenCode manages its own sessions (SQLite DB)
- We could pass
--session <codeman-id>but OpenCode IDs have different format - Recommendation: Let OpenCode manage its own sessions, store the mapping in SessionState
[SCOPE DECISION] Token tracking, respawn controller, Ralph Loop/Tracker, circuit breaker, hooks plugin bridge, and AI idle checker are all deferred. These systems are deeply coupled to Claude's output format and protocols. With 75+ model providers in OpenCode, they will break in unpredictable ways. Ship spawn + render + basic UI first, then layer intelligence on top with real PTY data.
═══════════════════════════════════════════════════════
MVP SCOPE (ship first)
═══════════════════════════════════════════════════════
Phase 0: Manual validation (1-2 hours)
↓
Phase 1: Type system + refactors (2-3 hours)
↓
Phase 2: CLI resolver (30 min)
↓
Phase 3: Tmux spawn (2-3 hours) ← CRITICAL INTEGRATION POINT
↓
Phase 5: API + frontend (2-3 hours) ← FIRST USER-VISIBLE RESULT
↓
✅ MVP COMPLETE — OpenCode sessions managed from Codeman web UI
═══════════════════════════════════════════════════════
DEFERRED (requires real PTY data + verified APIs)
═══════════════════════════════════════════════════════
Phase 4: Idle detection + ANSI filter — DEFERRED until PTY behavior characterized
↓
Phase 6: Plugin bridge — DEFERRED until plugin API verified in Phase 0
↓
Phase 7: Respawn adaptation — DEFERRED until Phase 6 proven reliable
↓
Phase 8: Server API — DEFERRED, optional advanced feature
Note on Phase 4: Only
waitForOpenCodeReady()(basic TUI ready detection) is needed for the MVP. The full idle detection, ANSI content filter, working/busy state tracking, and token parsing are all deferred. The MVP treats OpenCode sessions as "manual interaction only" — no automated idle/completion detection.
| Milestone | Phase | What You Can Do | Scope |
|---|---|---|---|
| M1: "It renders" | 0-3 | OpenCode TUI visible in xterm.js via Codeman | MVP |
| M2: "It's usable" | 5 | Create OpenCode sessions from web UI, type and interact, manage tabs | MVP |
| M3: "It's observable" | 4 | Idle/working state detection, ANSI content filter | Deferred |
| M4: "It's smart" | 6 | Plugin bridge provides definitive idle/permission/tool events | Deferred |
| M5: "It's autonomous" | 7 | Respawn works with OpenCode. Ralph Loop experimental. | Deferred |
| M6: "It's rich" | 8 | Token tracking, conversation history, model switching via API | Deferred |
- Phases 0-3, 5 (MVP): ~6-9 hours
- Phase 4 (Idle detection): ~2-3 hours (deferred)
- Phase 6 (Plugin bridge): ~2-3 hours (deferred)
- Phase 7 (Respawn): ~3-4 hours (deferred)
- Phase 8 (Server API): ~4-6 hours (deferred)
- MVP total: ~6-9 hours
- Full integration total: ~17-25 hours
Reviewed: 2026-02-26 by a 4-agent team. Each agent reviewed a different area of the plan against the actual Codeman codebase.
| Agent | Focus Area | Key Findings |
|---|---|---|
| arch-reviewer | Type system, backend abstraction, CLI resolution (Phases 1-3) | Duplicate SessionMode types, parameter explosion, CLI resolver inconsistencies |
| parsing-reviewer | Output parsing, idle detection, OpenCode TUI behavior (Phase 4) | Alternate screen buffer breaks ALL silence-based detection, waitForOpenCodeReady too simplistic, working/idle logic bug |
| respawn-reviewer | Respawn state machine, Ralph Loop, hooks (Phases 6-7) | Respawn complexity massively underestimated, phase ordering wrong, CompletionDetector needed |
| api-reviewer | API routes, frontend, task breakdown, security (Phases 5, 8) | Env var allowlist blocks OpenCode, API key exposure, Phase 8 shared-state problem |
| ID | Issue | Resolution |
|---|---|---|
| C1 | Bubble Tea alternate screen buffer redraws break output-silence idle detection | Added mandatory ANSI content filter (ansi-content-filter.ts) in Phase 4 |
| C2 | Respawn controller is ~3,500 lines with 13 states (watching, confirming_idle, ai_checking, sending_update, waiting_update, sending_clear, waiting_clear, sending_init, waiting_init, monitoring_init, sending_kickstart, waiting_kickstart, stopped) — can't just add if (mode === 'opencode') |
Added CompletionDetector interface pattern in Phase 7 |
| C3 | Phase 6 (Respawn) depends on Phase 7 (Plugin Bridge) for reliable idle detection | Swapped phases: Plugin Bridge is now Phase 6, Respawn is Phase 7 |
| C4 | schemas.ts env var allowlist blocks OPENCODE_* vars |
Added OPENCODE_ to ALLOWED_ENV_PREFIXES, OPENCODE_SERVER_PASSWORD to BLOCKED_ENV_KEYS |
| C5 | SessionMode defined separately in session.ts and types.ts — will drift |
Unified: move to types.ts, import in session.ts |
| ID | Issue | Resolution |
|---|---|---|
| M1 | No session.isWorking strategy for OpenCode |
Added ANSI content filter-driven isWorking logic in Phase 4 |
| M2 | Working pattern detection is Claude-specific | Deferred to Phase 0 empirical data + OpenCodeCompletionDetector |
| M3 | Plugin event names are speculative / unverified | Added hard prerequisite: Phase 0 must verify events via test plugin |
| M4 | writeViaMux() \r handling untested with Bubble Tea |
Added to Phase 0 validation checklist |
| M5 | waitForOpenCodeReady() 500ms threshold too short |
Increased to 2000ms, added content-change-only tracking |
| M6 | createSession()/respawnPane() have 8 positional params |
Refactored to options objects (CreateSessionOptions, RespawnPaneOptions) |
| M7 | API keys exposed in ps output via inline export |
Use tmux setenv for sensitive vars instead |
| M8 | Phase 8 TUI + serve dual-process has shared SQLite problem | Documented alternatives (serve-only, attach pattern), port pool allocation |
- Simple extension (Option A) confirmed — correct YAGNI call over full DI abstraction
CompletionDetectorinterface — clean abstraction boundary between respawn controller and backend-specific detection- ANSI content filter — mandatory new component, not a nice-to-have
- Respawn disabled by default for OpenCode — opt-in only after plugin bridge proven
- Ralph Loop marked experimental for OpenCode — without
<promise>tags, it's a degraded experience tmux setenvfor API keys — security improvement over inline exports- Phase 0 expanded — additional validation items make it 1-2 hours instead of 30 minutes
- plan.json needs reconciliation — contradicts this document's approach (DI vs simple extension)
Re-reviewed: 2026-02-26 by a 4-agent team (types-reviewer, parsing-reviewer, respawn-reviewer, api-reviewer). Each agent cross-referenced specific plan sections against the actual codebase.
| Agent | Focus Area | Key Findings |
|---|---|---|
| types-reviewer | Types, CLI resolver, tmux spawn (Phases 1-3) | SessionMode duplicate claim imprecise, setOpenCodeEnvVars injection risk, mux-interface has 3 inline unions |
| parsing-reviewer | Output parsing, idle detection (Phase 4) | 6 bugs in code snippets: wrong property names, invalid status value, missing property init, misdescribed Claude detection |
| respawn-reviewer | Respawn, Ralph Loop, hooks (Phases 6-7) | 13 states not 11, Ralph Loop "agnostic" overstated, CompletionDetector feasible but scope underestimated |
| api-reviewer | API routes, frontend, schemas, security (Phases 5, 8) | configContent shell injection, missing writeHooksConfig guard, wrong CleanupManager API, missing generateSessionToken |
| ID | Issue | Resolution |
|---|---|---|
| RC1 | _lastOutputTime property does not exist — actual is _lastActivityAt. All Phase 4 snippets used wrong name. |
Find-and-replace applied throughout |
| RC2 | _status = 'working' is invalid — SessionStatus is 'idle' | 'busy' | 'stopped' | 'error'. Must be 'busy'. |
Fixed in code snippets |
| RC3 | configContent shell injection — user-supplied JSON embedded in bash -c string allows ;, &&, $() execution |
Replaced with tmux setenv approach + JSON validation. Added setOpenCodeConfigContent() helper |
| RC4 | OPENCODE_SERVER_PASSWORD must be blocked simultaneously with adding OPENCODE_ to ALLOWED_ENV_PREFIXES |
Added atomic-change note to schemas section |
| ID | Issue | Resolution |
|---|---|---|
| RH1 | 13 respawn states, not 11 — plan undercounted by 2 (monitoring_init, sending_kickstart) |
Fixed all references to 13 |
| RH2 | Missing _idleConfig property — referenced but never initialized |
Added constructor initialization code |
| RH3 | _processExpensiveParsers() not addressed — Ralph/bash/token parsers fire for all modes |
Added mode-gating section to Phase 4 |
| RH4 | this.cleanup.addCustom() doesn't exist — real API is registerCleanup(type, fn, description) |
Fixed in Phase 8 code |
| RH5 | generateSessionToken() doesn't exist anywhere in codebase |
Replaced with crypto.randomUUID() |
| RH6 | setOpenCodeEnvVars() — API keys with " or $ break the tmux setenv command |
Added single-quote escaping with inner quote escape |
| RH7 | writeHooksConfig() called unconditionally in quick-start — generates Claude hooks for OpenCode sessions |
Added mode !== 'opencode' guard |
| ID | Issue | Resolution |
|---|---|---|
| RM1 | "Duplicate SessionMode" (C5) imprecise — types.ts has inline union, not named export. mux-interface.ts has 3 locations, not 2. | Corrected description in C5 note |
| RM2 | Claude idle detection misdescribed — uses prompt char + debounce + spinner, NOT silence threshold. No completionPattern in session.ts. |
Added correction comment to getIdleDetectionConfig() |
| RM3 | Ralph Loop "CLI-agnostic" overstated — polling is agnostic but completion signaling depends on Claude-specific <promise> tags |
Reworded in Phase 7 |
| RM4 | Lifecycle logs hardcode mode: 'claude' at 2 locations in server.ts |
Added note + fix in Phase 5 quick-start section |
| RM5 | Respawn section line number wrong (10254 → ~10289) | Fixed |
| RM6 | completionPattern: /Worked for \d+[ms]/ shown in session.ts config but actually lives in respawn-controller.ts |
Fixed — set to null in session config with comment |
All items confirmed correct by the original review remain valid. Additionally confirmed:
createSession()has 8 positional params,respawnPane()has 7 (nonameparam)mux-factory.tsneeds no changes- CLI resolver correctly mirrors
claude-cli-resolver.ts assertNever()at 2 locations in respawn-controller (lines ~1461, ~1563)- Plugin bridge approach is sound; Phase 0 plugin verification is essential prerequisite
- Phase ordering (plugin bridge before respawn) confirmed critical
- Circuit breaker signals are fully Claude-specific (RALPH_STATUS blocks)
- All schema additions (mode enum, OpenCodeConfigSchema, env prefix) necessary
Usage: opencode [options] [path]
Commands:
(default) Start interactive TUI
run <prompt> Execute prompt non-interactively
serve Start headless API server
web Start server with web UI
attach <url> Connect TUI to remote server
session list List all sessions
export [id] Export session as JSON
import <file> Import session from file/URL
models [provider] List available models
agent create Create custom agent
agent list List agents
Global Flags:
-m, --model <model> Model (provider/model format)
-c, --continue Continue last session
-s, --session <id> Resume specific session
--fork Branch when continuing
--cwd <dir> Working directory
-d, --debug Enable debug logging
--log-level <level> Set log level
--print-logs Print logs to stdout
-v, --version Show version
-h, --help Show help
Run Flags:
--format <fmt> Output format (default, json)
--file <path> Attach file(s) to prompt
--title <name> Custom session title
--attach <url> Use remote server
--port <port> Local server port
--command <cmd> Custom executable
--share Enable session sharing
Serve Flags:
--port <port> Listen port (default: auto)
--hostname <host> Bind hostname
--mdns Enable mDNS discovery
--cors <origins> CORS origins
Environment Variables:
ANTHROPIC_API_KEY Anthropic API key
OPENAI_API_KEY OpenAI API key
GOOGLE_API_KEY Google AI API key
OPENCODE_MODEL Default model
OPENCODE_CONFIG Custom config file path
OPENCODE_CONFIG_DIR Custom config directory
OPENCODE_CONFIG_CONTENT Inline JSON config
OPENCODE_PERMISSION Inline JSON permission config
OPENCODE_SERVER_PASSWORD Server auth password
OPENCODE_CLIENT Client identifier (default: "cli")
Full list of subscribable events in OpenCode's plugin system:
| Category | Event | Description | Codeman Relevance |
|---|---|---|---|
| Command | command.executed |
Slash command run | Low |
| Files | file.edited |
File modified | Medium (track changes) |
file.watcher.updated |
File watcher trigger | Low | |
| Installation | installation.updated |
Config/deps changed | Low |
| LSP | lsp.client.diagnostics |
Lint/type errors | Medium (show in UI) |
lsp.updated |
LSP state change | Low | |
| Messages | message.part.updated |
Streaming token | High (progress tracking) |
message.updated |
Complete message | High (completion detection) | |
message.removed |
Message deleted | Low | |
message.part.removed |
Part deleted | Low | |
| Permissions | permission.asked |
Tool approval needed | Critical (show in Codeman) |
permission.replied |
User responded | High (track approvals) | |
| Server | server.connected |
Server started | Medium |
| Sessions | session.idle |
Agent finished working | Critical (idle detection!) |
session.status |
Status change | High (working/idle state) | |
session.created |
New session | Medium | |
session.updated |
Session modified | Medium | |
session.deleted |
Session removed | Medium | |
session.compacted |
Context compacted | Medium (track compactions) | |
session.diff |
Code changes | Medium | |
session.error |
Error occurred | Critical (error surfacing) | |
| Todo | todo.updated |
Todo list changed | High (Ralph integration) |
| Shell | shell.env |
Env var injection | Low |
| Tools | tool.execute.before |
Tool about to run | High (tool tracking) |
tool.execute.after |
Tool completed | High (tool tracking) | |
| TUI | tui.prompt.append |
Text added to prompt | Low |
tui.command.execute |
TUI command run | Low | |
tui.toast.show |
Notification shown | Low | |
| Experimental | experimental.session.compacting |
Custom compaction | Low |
{
"permission": {
"*": "allow"
}
}{
"permission": {
"*": "ask",
"bash": {
"*": "ask",
"git *": "allow",
"npm *": "allow",
"rm *": "deny",
"sudo *": "deny"
},
"edit": "allow",
"write": "allow",
"read": "allow"
}
}"allow"— Execute without approval"ask"— Prompt user for approval"deny"— Block the action
Use OPENCODE_CONFIG_CONTENT environment variable to inject permissions without modifying project files:
export OPENCODE_CONFIG_CONTENT='{"permission":{"*":"allow"}}'
opencode --model anthropic/claude-sonnet-4-51. POST /api/sessions (server.ts:810)
→ Validate body with Zod
→ new Session({mode, workingDir, mux: TmuxManager, ...})
→ sessions.set(id, session)
→ setupSessionListeners(session)
→ broadcast('session:created')
2. POST /api/sessions/:id/interactive (server.ts:1635)
→ session.startInteractive()
3. session.startInteractive() (session.ts:892)
→ Check for existing muxSession
→ If none: this._mux.createSession(id, workingDir, 'claude', ...)
4. TmuxManager.createSession() (tmux-manager.ts:225)
→ tmux new-session -ds "codeman-<shortId>" -c <workingDir> -x 120 -y 40
→ tmux set-option -t "codeman-<shortId>" remain-on-exit on
→ Build command: "export PATH=... && export LANG=... && claude --dangerously-skip-permissions --session-id <id>"
→ tmux respawn-pane -k -t "codeman-<shortId>" '<command>'
→ Wait 100ms, configure tmux, get PID
→ Return MuxSession { muxName, pid, mode }
5. Back in startInteractive() (session.ts:~950)
→ pty.spawn('tmux', ['attach-session', '-t', 'codeman-<shortId>'], {
name: 'xterm-256color',
cols: 120, rows: 40,
env: { LANG, LC_ALL, TERM }
})
→ ptyProcess.onData(rawData => {
// Filter focus escape sequences
// Append to terminal buffer
// Emit 'terminal' event → SSE broadcast → xterm.js
// Throttled: ANSI strip, Ralph tracker, bash parser, token parsing
})
→ ptyProcess.onExit(...)
→ Wait for prompt (poll for ❯ character)
6. SSE Pipeline (server.ts)
→ setupSessionListeners.terminal handler
→ batchTerminalData(sessionId, data)
→ flushSessionTerminalBatch() (16-50ms timer)
→ DEC 2026 sync wrapping
→ broadcast('session:terminal', { id, data })
→ sendSSEPreformatted() to all clients
7. Frontend (app.js)
→ EventSource at /api/events
→ addListener('session:terminal', ...)
→ batchTerminalWrite() → requestAnimationFrame → terminal.write()
Only steps 3-5 change:
- Step 3:
session.startInteractive()callscreateSession(..., 'opencode', ...)instead of'claude' - Step 4:
TmuxManager.createSession()buildsopencode --model ...instead ofclaude --dangerously-skip-permissions ... - Step 5:
waitForOpenCodeReady()instead of polling for❯prompt
Everything else (steps 1, 2, 6, 7) is completely unchanged.
{ "$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 }