diff --git a/CLAUDE.md b/CLAUDE.md index f9a3de0d..d62e8638 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1645 (must match `package.json`) +**Version**: 0.1646 (must match `package.json`) ## Project Overview diff --git a/docs/opencode-integration.md b/docs/opencode-integration.md new file mode 100644 index 00000000..448204f6 --- /dev/null +++ b/docs/opencode-integration.md @@ -0,0 +1,1728 @@ +# OpenCode Integration Plan for Claudeman + +> **Author**: Claude Opus 4.6 | **Date**: 2026-02-26 +> **Status**: Draft — NOT pushed to GitHub +> **Saved at**: `docs/opencode-integration.md` +> **Related**: `plan.json` (48-task TDD breakdown, also not pushed) + +--- + +## 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: Respawn & Ralph Loop Adaptation](#11-phase-6-respawn--ralph-loop-adaptation) +12. [Phase 7: Hooks & Plugin Bridge](#12-phase-7-hooks--plugin-bridge) +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. [Appendix A: OpenCode CLI Reference](#appendix-a-opencode-cli-reference) +22. [Appendix B: OpenCode Plugin Events](#appendix-b-opencode-plugin-events) +23. [Appendix C: OpenCode Permission Config](#appendix-c-opencode-permission-config) +24. [Appendix D: Current Claudeman Session Spawn Flow (Annotated)](#appendix-d-current-claudeman-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 Claudeman 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 Claudeman | 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. + +### 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 Claudeman) + +--- + +## 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 Claudeman 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 "claudeman-" + tmux respawn-pane -k -t ... "claude --dangerously-skip-permissions --session-id " + ↓ + pty.spawn('tmux', ['attach-session', '-t', 'claudeman-']) + ↓ + 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 "claudeman-" + tmux respawn-pane -k -t ... "opencode --model " + ↓ + pty.spawn('tmux', ['attach-session', '-t', 'claudeman-']) + ↓ + 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) + ↓ +Claudeman 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 | +|-------|-------|--------|-------------| +| **0** | Install OpenCode, manual tmux validation | 30 min | None | +| **1** | Type system extension + (optional) backend abstraction | Small | None | +| **2** | OpenCode CLI resolver | Small | Phase 1 | +| **3** | TmuxManager: spawn `opencode` in tmux | Medium | Phase 2 | +| **4** | Output parsing, idle detection, prompt detection | Medium | Phase 3 | +| **5** | API routes + frontend UI (mode selector, badges) | Medium | Phase 4 | +| **6** | Respawn controller + Ralph Loop adaptation | Medium | Phase 5 | +| **7** | Hooks & plugin bridge | Medium | Phase 5 | +| **8** | OpenCode server API bridge (optional, advanced) | Large | Phase 5 | + +### 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 `~/.claudeman/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 | +|--------|---------------------------|---------------------| +| CLI binary | `claude` | `opencode` | +| CLI args | `--dangerously-skip-permissions --session-id ` | `--model ` + `opencode.json` for permissions | +| Prompt marker | `❯` (U+276F) | Bubble Tea TUI prompt (different rendering) | +| Working indicator | Spinner + "Thinking...", "Writing..." keywords | Bubble Tea spinner (different characters) | +| Completion message | `"Worked for Xm Xs"` | Different format (needs empirical testing) | +| Token display | Status line: `123.4k tokens` | TUI status: `~27s · 275.9k tokens` | +| Slash commands | `/clear`, `/compact`, `/init`, `/update` | `/clear`, `/model`, `/sessions`, `/compact` | +| Hooks | `.claude/settings.local.json` shell commands | JS/TS plugin system in `.opencode/plugins/` | +| Subagent detection | `BashToolParser` + `SubagentWatcher` | Different tool output format | +| Ralph completion | `PHRASE` tags | Not applicable (needs alternative) | +| Hooks events | `permission_prompt`, `idle_prompt`, `stop` | `permission.asked`, `session.idle`, `session.status` | +| Auto-compact | Claudeman sends `/compact` at token threshold | OpenCode has built-in `compaction.auto: true` | + +--- + +## 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 Claudeman'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) + +### 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. + +### Changes + +#### `src/session.ts` (line 204) + +```typescript +// BEFORE +export type SessionMode = 'claude' | 'shell'; + +// AFTER +export type SessionMode = 'claude' | 'shell' | 'opencode'; +``` + +#### `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 + +```typescript +// All occurrences of 'claude' | 'shell' → 'claude' | 'shell' | 'opencode' +// In MuxSession type (line 27): +mode: 'claude' | 'shell' | 'opencode'; + +// In TerminalMultiplexer interface methods (lines 70, 168): +// Add openCodeConfig parameter to createSession() and respawnPane() +createSession( + sessionId: string, + workingDir: string, + mode: 'claude' | 'shell' | 'opencode', + name?: string, + niceConfig?: NiceConfig, + model?: string, + claudeMode?: ClaudeMode, + allowedTools?: string, + openCodeConfig?: OpenCodeConfig, // NEW +): Promise; + +respawnPane( + sessionId: string, + workingDir: string, + mode: 'claude' | 'shell' | 'opencode', + niceConfig?: NiceConfig, + model?: string, + claudeMode?: ClaudeMode, + allowedTools?: string, + openCodeConfig?: OpenCodeConfig, // NEW +): Promise; +``` + +#### `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 } 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) { + _openCodeDir = path.replace(/\/opencode$/, ''); + 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; + return `${dir}:${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. + +### 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 CLAUDEMAN_MUX=1`, + `export CLAUDEMAN_SESSION_ID=${sessionId}`, + `export CLAUDEMAN_MUX_NAME=${muxName}`, + `export CLAUDEMAN_API_URL=${process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'}`, + ]; + + if (mode === 'opencode') { + // Pass through API keys from Claudeman's environment + const apiKeyExports = []; + if (process.env.ANTHROPIC_API_KEY) apiKeyExports.push(`export ANTHROPIC_API_KEY="${process.env.ANTHROPIC_API_KEY}"`); + if (process.env.OPENAI_API_KEY) apiKeyExports.push(`export OPENAI_API_KEY="${process.env.OPENAI_API_KEY}"`); + if (process.env.GOOGLE_API_KEY) apiKeyExports.push(`export GOOGLE_API_KEY="${process.env.GOOGLE_API_KEY}"`); + + // Inline config for permission auto-allow + if (openCodeConfig?.autoAllowTools) { + const permConfig = JSON.stringify({ permission: { '*': 'allow' } }); + // Merge with any existing configContent + if (openCodeConfig.configContent) { + try { + const existing = JSON.parse(openCodeConfig.configContent); + existing.permission = { '*': 'allow' }; + apiKeyExports.push(`export OPENCODE_CONFIG_CONTENT='${JSON.stringify(existing).replace(/'/g, "'\\''")}'`); + } catch { + apiKeyExports.push(`export OPENCODE_CONFIG_CONTENT='${permConfig.replace(/'/g, "'\\''")}'`); + } + } else { + apiKeyExports.push(`export OPENCODE_CONFIG_CONTENT='${permConfig.replace(/'/g, "'\\''")}'`); + } + } else if (openCodeConfig?.configContent) { + apiKeyExports.push(`export OPENCODE_CONFIG_CONTENT='${openCodeConfig.configContent.replace(/'/g, "'\\''")}'`); + } + + return [...common, ...apiKeyExports].join(' && '); + } + + if (mode === 'claude') { + return [...common, `unset CLAUDECODE`].join(' && '); + } + + return common.join(' && '); +} +``` + +#### `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'; +``` + +--- + +## 9. Phase 4: Output Parsing & Idle Detection + +### 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 (cursor movements + clear sequences) +- Has a different visual structure (sidebar, message area, input area) + +**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) + +### Prompt Detection — `waitForOpenCodeReady()` + +OpenCode's TUI takes longer to initialize than Claude's prompt: + +```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 500ms after initial burst). + const maxWait = 10000; // OpenCode TUI can take up to 10s + const stabilityThreshold = 500; // ms of silence = ready + const checkInterval = 50; + let elapsed = 0; + let lastOutputTime = Date.now(); + + const onOutput = () => { lastOutputTime = Date.now(); }; + this.on('terminal', onOutput); + + try { + while (elapsed < maxWait) { + await new Promise(r => setTimeout(r, checkInterval)); + elapsed += checkInterval; + + const silentMs = Date.now() - lastOutputTime; + if (silentMs >= stabilityThreshold && this._terminalBuffer.length > 200) { + // TUI has rendered and stabilized + break; + } + } + } finally { + this.off('terminal', onOutput); + } + + // Clear the terminal buffer of initialization junk + this.emit('clearTerminal'); +} +``` + +### 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 + return { + silenceThresholdMs: 3000, + promptPattern: /[❯\u276f]/, + workingKeywords: ['Thinking', 'Writing', 'Reading', 'Searching'], + useAIChecker: true, + completionPattern: /Worked for \d+[ms]/, + }; +} +``` + +### 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, Claudeman uses spinner characters and keywords. For OpenCode: + +```typescript +// In session.ts onData handler: +if (this.mode === 'opencode') { + // Simple: track last output time for silence-based idle detection + this._lastOutputTime = Date.now(); + + // If we had no output for >silenceThreshold and now get output → mark working + if (this._status === 'idle' && Date.now() - this._lastOutputTime > 100) { + this._status = 'working'; + 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:** + +```typescript +// POST /api/quick-start — extend to handle opencode mode: +if (mode === 'opencode') { + // Skip Claude-specific setup (hooks, CLAUDE.md generation) + // But do set up opencode.json permission config if autoAllowTools + await session.startInteractive(); +} else if (mode === 'shell') { + await session.startShell(); +} else { + await session.startInteractive(); +} +``` + +#### `src/web/schemas.ts` + +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 for autocomplete + // Default to 'anthropic/claude-sonnet-4-5' +} +``` + +**Tab badge** — Show mode indicator: + +```javascript +// In tab rendering (search for shell tab-mode class): +// CURRENT: +// ${mode === 'shell' ? '' : ''} + +// PROPOSED: +function getModeBadge(mode) { + if (mode === 'opencode') return ''; + if (mode === 'shell') return ''; + return ''; // Claude = no badge (default) +} +``` + +**CSS for OpenCode badge:** + +```css +.tab-mode.opencode { + background: #10b981; /* Green - OpenCode brand */ + color: white; +} +``` + +**Feature gating** — Disable Claude-specific features for OpenCode: + +```javascript +// 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: +// - Hooks panel: hide for OpenCode (Phase 7 adds plugin bridge) +// - Auto-compact button: hide for OpenCode (has built-in compaction) +// - Ralph tracker settings: show for both (works via tmux input) +// - Subagent panel: hide for OpenCode initially +// - Token display: hide for OpenCode (Phase 8 adds API tracking) +// - Respawn: show for both (with adapted detection) +// - Input/resize/kill: show for all modes +``` + +**Respawn section visibility:** + +```javascript +// CURRENT (line 10254): +if (session.mode === 'claude' && session.pid) { + respawnSection.style.display = ''; +} + +// PROPOSED: +if ((session.mode === 'claude' || session.mode === 'opencode') && session.pid) { + respawnSection.style.display = ''; +} +``` + +--- + +## 11. Phase 6: Respawn & Ralph Loop Adaptation + +### Respawn Controller + +The respawn controller sends Claude CLI commands (`/clear`, `/compact`, `/init`, `/update`) which are Claude-specific. For OpenCode, we need alternative commands. + +#### Command Mapping + +| 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 | + +#### Changes to `src/respawn-controller.ts` + +```typescript +// In the respawn cycle method: +private async startRespawnCycle(): Promise { + if (this.session.mode === 'opencode') { + // OpenCode respawn: simpler cycle + // 1. Wait for idle (output silence) + // 2. Optionally compact: skip (OpenCode manages this internally via compaction.auto) + // 3. Send new prompt via writeViaMux() + // 4. Wait for completion (output silence again) + + // Send prompt directly — OpenCode TUI accepts typed text + await this.session.writeViaMux(this.config.prompt); + return; + } + + // Existing Claude respawn logic... +} + +// Completion detection: +private isCompletionDetected(): boolean { + if (this.session.mode === 'opencode') { + // For OpenCode: rely on output silence + return Date.now() - this.session.lastOutputTime > (this.config.completionSilenceMs || 8000); + } + // Existing Claude completion pattern matching +} +``` + +### Ralph Loop + +The Ralph Loop mechanism (send prompt → detect completion → send next prompt) is fundamentally CLI-agnostic since it operates via `writeViaMux()`. The main adaptation needed: + +```typescript +// In ralph-loop.ts: +// Completion detection for OpenCode: +if (session.mode === 'opencode') { + // Use silence-based completion detection + // OpenCode doesn't emit PHRASE tags + // Ralph tracker's completion phrase detection is skipped +} +``` + +### Respawn Presets for OpenCode + +```javascript +// In app.js, add OpenCode-specific presets: +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, + }, +}; +``` + +--- + +## 12. Phase 7: Hooks & Plugin Bridge + +### Goal +Bridge Claudeman's hook system with OpenCode's plugin system for rich event forwarding. + +### Background: Two Different Approaches + +| 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 Claudeman | Must be placed in `.opencode/plugins/` | + +### Claudeman Plugin for OpenCode + +Create a Claudeman plugin that OpenCode loads, which communicates back to Claudeman's API: + +**File: `.opencode/plugins/claudeman-bridge.js`** (generated per session) + +```javascript +// This plugin bridges OpenCode events to Claudeman's API +export const claudemanBridge = async ({ project, $ }) => { + const apiUrl = process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'; + const sessionId = process.env.CLAUDEMAN_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 }); + }, + }; +}; +``` + +### Plugin Installation + +When creating an OpenCode session, Claudeman generates this plugin in the project's `.opencode/plugins/` directory: + +```typescript +// In session.ts or a new opencode-hooks.ts: +async function installOpenCodePlugin(workingDir: string, sessionId: string): Promise { + const pluginDir = join(workingDir, '.opencode', 'plugins'); + await mkdirp(pluginDir); + + const pluginContent = generateClaudemanBridgePlugin(sessionId); + await writeFile(join(pluginDir, 'claudeman-bridge.js'), pluginContent); +} +``` + +### Benefits of the Plugin Bridge + +Once installed, Claudeman receives structured events from OpenCode: +- **`session.idle`** → Definitive idle detection (replaces output-silence guessing) +- **`permission.asked`** → Show permission prompts in Claudeman UI +- **`tool.execute.*`** → Tool call tracking (similar to BashToolParser for Claude) +- **`todo.updated`** → OpenCode's built-in todo system → Claudeman can display it +- **`session.error`** → Error surfacing in Claudeman UI + +--- + +## 13. Phase 8: OpenCode Server API Integration (Advanced) + +### Goal +For each OpenCode session, optionally run `opencode serve` alongside the TUI for structured API access. + +### Architecture + +``` +tmux session ─── opencode TUI (interactive, port N/A) + │ + ├── xterm.js (terminal rendering, Strategy A) + │ +Claudeman ──── 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 +``` + +### Port Assignment + +```typescript +// Each OpenCode session gets a unique port for its server +// Start at 4100, increment per session +private allocateOpenCodePort(): number { + const basePort = 4100; + const sessionIndex = Array.from(this.sessions.keys()).indexOf(this.id); + return basePort + sessionIndex; +} +``` + +### Server Lifecycle + +Start `opencode serve` when session starts, kill when session stops: + +```typescript +// 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: generateSessionToken(), + }, + stdio: 'ignore', + detached: true, + }); + + // Clean up on session stop + this.once('exit', () => { + if (this._openCodeServer && !this._openCodeServer.killed) { + this._openCodeServer.kill('SIGTERM'); + } + }); +} +``` + +### Proxy Routes in `server.ts` + +```typescript +// 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 +``` + +### Benefits + +- **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 + +--- + +## 14. Files to Modify + +| File | Changes | Phase | +|------|---------|-------| +| `src/types.ts` | Add `OpenCodeConfig` interface, extend `SessionState` | 1 | +| `src/session.ts` | Extend `SessionMode`, add OpenCode startup/idle/ready logic | 1, 3, 4 | +| `src/mux-interface.ts` | Update mode type to 3-way union, add `openCodeConfig` params | 1 | +| `src/mux-factory.ts` | Pass through `openCodeConfig` | 1 | +| `src/tmux-manager.ts` | Add `buildOpenCodeCommand()`, env var exports, PATH resolution | 3 | +| `src/web/schemas.ts` | Update mode enum, add `OpenCodeConfigSchema` | 1, 5 | +| `src/web/server.ts` | New routes, mode checks, OpenCode availability endpoint | 5 | +| `src/web/public/app.js` | Mode selector, tab badges, model picker, feature gating | 5 | +| `src/respawn-controller.ts` | Mode-aware idle/completion detection, command adaptation | 6 | +| `src/ralph-loop.ts` | Mode-aware completion detection | 6 | +| `src/ralph-tracker.ts` | Skip Claude-specific patterns for OpenCode sessions | 6 | +| `src/state-store.ts` | Persist `openCodeConfig` in session state | 1 | +| `src/hooks-config.ts` | Skip Claude hook generation for OpenCode sessions | 7 | +| `src/utils/index.ts` | Re-export OpenCode resolver | 2 | +| `src/session-lifecycle-log.ts` | Log OpenCode-specific events | 3 | + +## 15. Files to Create + +| File | Purpose | Phase | +|------|---------|-------| +| `src/utils/opencode-cli-resolver.ts` | Resolve `opencode` binary location | 2 | +| `src/opencode-plugin-generator.ts` | Generate `.opencode/plugins/claudeman-bridge.js` | 7 | +| `src/opencode-api-client.ts` | Client for OpenCode's REST API (Strategy B) | 8 | +| `test/opencode-resolver.test.ts` | Tests for binary resolution | 2 | +| `test/opencode-session.test.ts` | Tests for OpenCode session spawning (port 3155) | 3 | +| `test/opencode-respawn.test.ts` | Tests for OpenCode respawn cycle (port 3156) | 6 | + +--- + +## 16. Existing plan.json Task Breakdown + +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 — `LLMBackend` interface, `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 serve` integration, 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. + +--- + +## 17. Risk Assessment + +### Low Risk +- **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 `if` checks on `session.mode` + +### Medium Risk +- **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. May trigger false positives (TUI redraws create brief output) or false negatives (slow model responses). Mitigated by conservative timeouts (5-8s). +- **Respawn loop** (Phase 6) — Without Claude-quality completion detection, respawn may be less reliable. Mitigated by longer idle timeouts and the plugin bridge (Phase 7) providing definitive `session.idle` events. + +### High Risk +- **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. +- **OpenCode server API** (Phase 8) — Running a second HTTP server per session increases complexity (port conflicts, zombie processes, resource usage). Mitigated by making it optional. +- **Plugin bridge reliability** (Phase 7) — The generated plugin depends on OpenCode loading it correctly and the fetch calls not failing silently. Need error handling and fallback. + +### Unknowns (Resolved by Phase 0 Manual Testing) +- 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 -l` work for typing? +- Separate `send-keys Enter` — Does it trigger prompt submission in OpenCode's TUI? +- `remain-on-exit` behavior — Does the tmux session stay alive when OpenCode exits? +- `opencode.json` conflicts — If one exists in the project, do CLI flags override it? + +--- + +## 18. Testing Strategy + +### Unit Tests + +```bash +# 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.ts +``` + +### Test Ports (Following Convention) + +- `opencode-resolver.test.ts` — No port needed (pure unit test) +- `opencode-session.test.ts` — Port **3155** +- `opencode-respawn.test.ts` — Port **3156** +- `opencode-integration.test.ts` — Port **3157** (future) + +### Integration Tests + +1. **Manual smoke test**: Install OpenCode → create session via API → verify TUI renders in xterm.js +2. **Playwright test**: Automate session creation → verify terminal has content → send input → verify response +3. **Respawn test**: Start respawn → verify prompt sending → verify completion detection + +### Safety Rules + +- **Never run OpenCode tests that spawn real tmux sessions inside Claudeman** (same safety rule as Claude tests) +- **Use MockSession** from `test/respawn-test-utils.ts` for respawn testing +- **Mock the opencode binary** for unit tests (`jest.mock` or stub) +- **Use unique test ports** (3155+) — never port 3000 + +--- + +## 19. Open Questions & Decisions + +### Resolved + +| 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` | + +### Open + +1. **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. + +2. **Should Ralph Loop work with OpenCode?** + - Technically possible (send prompts via tmux, detect completion by silence + plugin events) + - May be less reliable without completion phrase detection + - Recommendation: Support it with longer timeouts and clear documentation + +3. **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 + +4. **Should AI checkers use Claude or OpenCode for analysis?** + - AI checkers currently always spawn `claude -p` for analysis + - Recommendation: Always use Claude for AI checks (if available), since it's purpose-built for analysis + +5. **Should we auto-generate `opencode.json` in the working directory?** + - Option A: Let OpenCode use existing project config (respect user settings) + - Option B: Generate a temporary one with Claudeman's settings + - Recommendation: Option A (use `OPENCODE_CONFIG_CONTENT` env var for Claudeman-specific overrides, don't modify project files) + +6. **How should OpenCode session IDs map to Claudeman session IDs?** + - OpenCode manages its own sessions (SQLite DB) + - We could pass `--session ` but OpenCode IDs have different format + - Recommendation: Let OpenCode manage its own sessions, store the mapping in SessionState + +--- + +## 20. Implementation Order + +### Recommended Sequence + +``` +Phase 0: Manual validation (30 min) + ↓ +Phase 1: Type system (1 hour) + ↓ +Phase 2: CLI resolver (30 min) + ↓ +Phase 3: Tmux spawn (2-3 hours) ← CRITICAL INTEGRATION POINT + ↓ +Phase 4: Idle detection (1-2 hours) + ↓ +Phase 5: API + frontend (2-3 hours) ← FIRST USER-VISIBLE RESULT + ↓ +Phase 6: Respawn adaptation (2-3 hours) + ↓ +Phase 7: Plugin bridge (2-3 hours) ← QUALITY IMPROVEMENT + ↓ +Phase 8: Server API (4-6 hours) ← OPTIONAL ADVANCED +``` + +### Milestones + +| Milestone | Phase | What You Can Do | +|-----------|-------|-----------------| +| **M1: "It renders"** | 0-3 | OpenCode TUI visible in xterm.js via Claudeman | +| **M2: "It's usable"** | 4-5 | Create OpenCode sessions from web UI, type and interact | +| **M3: "It's autonomous"** | 6 | Respawn and Ralph Loop work with OpenCode | +| **M4: "It's smart"** | 7 | Plugin bridge provides definitive idle detection | +| **M5: "It's rich"** | 8 | Token tracking, conversation history, model switching via API | + +### Total Effort Estimate + +- **Phases 0-5** (MVP): ~8-10 hours +- **Phase 6** (Respawn): ~2-3 hours +- **Phase 7** (Plugins): ~2-3 hours +- **Phase 8** (Server API): ~4-6 hours +- **Total**: ~16-22 hours for full integration + +--- + +## Appendix A: OpenCode CLI Reference + +``` +Usage: opencode [options] [path] + +Commands: + (default) Start interactive TUI + run Execute prompt non-interactively + serve Start headless API server + web Start server with web UI + attach Connect TUI to remote server + session list List all sessions + export [id] Export session as JSON + import Import session from file/URL + models [provider] List available models + agent create Create custom agent + agent list List agents + +Global Flags: + -m, --model Model (provider/model format) + -c, --continue Continue last session + -s, --session Resume specific session + --fork Branch when continuing + --cwd Working directory + -d, --debug Enable debug logging + --log-level Set log level + --print-logs Print logs to stdout + -v, --version Show version + -h, --help Show help + +Run Flags: + --format Output format (default, json) + --file Attach file(s) to prompt + --title Custom session title + --attach Use remote server + --port Local server port + --command Custom executable + --share Enable session sharing + +Serve Flags: + --port Listen port (default: auto) + --hostname Bind hostname + --mdns Enable mDNS discovery + --cors 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") +``` + +--- + +## Appendix B: OpenCode Plugin Events + +Full list of subscribable events in OpenCode's plugin system: + +| Category | Event | Description | Claudeman 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 Claudeman) | +| | `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 | + +--- + +## Appendix C: OpenCode Permission Config + +### Auto-Allow Everything (Equivalent to `--dangerously-skip-permissions`) + +```json +{ + "permission": { + "*": "allow" + } +} +``` + +### Granular Permissions + +```json +{ + "permission": { + "*": "ask", + "bash": { + "*": "ask", + "git *": "allow", + "npm *": "allow", + "rm *": "deny", + "sudo *": "deny" + }, + "edit": "allow", + "write": "allow", + "read": "allow" + } +} +``` + +### Permission Levels + +- `"allow"` — Execute without approval +- `"ask"` — Prompt user for approval +- `"deny"` — Block the action + +### Delivery Method for Claudeman + +Use `OPENCODE_CONFIG_CONTENT` environment variable to inject permissions without modifying project files: + +```bash +export OPENCODE_CONFIG_CONTENT='{"permission":{"*":"allow"}}' +opencode --model anthropic/claude-sonnet-4-5 +``` + +--- + +## Appendix D: Current Claudeman Session Spawn Flow (Annotated) + +### Exact Code Path (for reference during implementation) + +``` +1. 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 "claudeman-" -c -x 120 -y 40 + → tmux set-option -t "claudeman-" remain-on-exit on + → Build command: "export PATH=... && export LANG=... && claude --dangerously-skip-permissions --session-id " + → tmux respawn-pane -k -t "claudeman-" '' + → Wait 100ms, configure tmux, get PID + → Return MuxSession { muxName, pid, mode } + +5. Back in startInteractive() (session.ts:~950) + → pty.spawn('tmux', ['attach-session', '-t', 'claudeman-'], { + 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() +``` + +### What Changes for OpenCode + +Only steps 3-5 change: +- **Step 3**: `session.startInteractive()` calls `createSession(..., 'opencode', ...)` instead of `'claude'` +- **Step 4**: `TmuxManager.createSession()` builds `opencode --model ...` instead of `claude --dangerously-skip-permissions ...` +- **Step 5**: `waitForOpenCodeReady()` instead of polling for `❯` prompt + +Everything else (steps 1, 2, 6, 7) is **completely unchanged**. diff --git a/package.json b/package.json index e56dbd1a..8e88c727 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1645", + "version": "0.1646", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/plan.json b/plan.json new file mode 100644 index 00000000..7026e899 --- /dev/null +++ b/plan.json @@ -0,0 +1,399 @@ +{ + "items": [ + { + "id": "P0-001", + "content": "Write failing tests for SessionMode type extension — verify 'opencode' is accepted as a valid mode in SessionMode, OpenCodeConfig interface exists with model/provider/autoAllowTools/continueSession/serverPort fields, and MuxSession.mode accepts 'opencode'", + "priority": "P0", + "tddPhase": "test", + "verificationCriteria": "test/opencode-types.test.ts exists, tests fail because SessionMode doesn't include 'opencode' and OpenCodeConfig doesn't exist", + "testCommand": "npx vitest run test/opencode-types.test.ts", + "dependencies": [] + }, + { + "id": "P0-002", + "content": "Implement SessionMode type extension — extend SessionMode from 'claude' | 'shell' to 'claude' | 'shell' | 'opencode' in types.ts (line 162), add OpenCodeConfig interface with fields: model?: string, provider?: string, autoAllowTools?: boolean, continueSession?: string, serverPort?: number. Update MuxSession.mode and createSession/respawnPane signatures in mux-interface.ts to accept 'opencode' and optional openCodeConfig param", + "priority": "P0", + "tddPhase": "impl", + "verificationCriteria": "npx vitest run test/opencode-types.test.ts passes, tsc --noEmit succeeds", + "pairedWith": "P0-001", + "dependencies": ["P0-001"] + }, + { + "id": "P0-003", + "content": "Review type system changes — verify OpenCodeConfig follows existing type conventions (optional fields, no Claude-specific coupling), SessionMode union is used consistently across types.ts/mux-interface.ts/session.ts, no breaking changes to existing code", + "priority": "P0", + "tddPhase": "review", + "verificationCriteria": "tsc --noEmit passes with no errors, grep confirms SessionMode used consistently, no 'claude' | 'shell' hardcoded literals remain in interface definitions", + "reviewChecklist": ["Type consistency across files", "No breaking changes to existing Claude/shell modes", "OpenCodeConfig fields match opencode CLI flags", "Optional fields properly typed"], + "pairedWith": "P0-002", + "dependencies": ["P0-002"] + }, + { + "id": "P0-004", + "content": "Write failing tests for OpenCode CLI resolver — test resolveOpenCodeDir() returns directory when opencode binary exists, returns null when not found, caches result after first call, isOpenCodeAvailable() returns boolean, getOpenCodeAugmentedPath() prepends directory to PATH. Mock filesystem and execSync. Port: none (pure unit test)", + "priority": "P0", + "tddPhase": "test", + "verificationCriteria": "test/opencode-resolver.test.ts exists with 5+ test cases, all fail because opencode-cli-resolver.ts doesn't exist", + "testCommand": "npx vitest run test/opencode-resolver.test.ts", + "dependencies": [] + }, + { + "id": "P0-005", + "content": "Implement opencode-cli-resolver.ts — create src/utils/opencode-cli-resolver.ts mirroring claude-cli-resolver.ts pattern. Functions: resolveOpenCodeDir() (checks which opencode, then ~/.local/bin, /usr/local/bin, ~/.bun/bin, ~/.npm-global/bin, ~/bin, ~/.opencode/bin), isOpenCodeAvailable(), getOpenCodeAugmentedPath(). Cache results. Add re-export in src/utils/index.ts", + "priority": "P0", + "tddPhase": "impl", + "verificationCriteria": "npx vitest run test/opencode-resolver.test.ts passes, tsc --noEmit succeeds", + "pairedWith": "P0-004", + "dependencies": ["P0-004"] + }, + { + "id": "P0-006", + "content": "Review CLI resolver implementation — verify no command injection in execSync('which opencode'), timeout set to 5s, graceful fallback when binary not found, caching works correctly (null vs empty string sentinel), PATH augmentation doesn't duplicate entries", + "priority": "P0", + "tddPhase": "review", + "verificationCriteria": "Code review passes: no shell injection, proper error handling, cache invalidation, consistent with claude-cli-resolver.ts patterns", + "reviewChecklist": ["No command injection in execSync", "Proper timeout handling", "Cache sentinel value for 'not found'", "PATH deduplication", "Re-export in utils/index.ts"], + "pairedWith": "P0-005", + "dependencies": ["P0-005"] + }, + { + "id": "P0-007", + "content": "Write failing tests for Zod schema validation — test CreateSessionSchema accepts mode: 'opencode', accepts openCodeConfig object with model/provider/autoAllowTools/continueSession fields, rejects invalid model strings (shell metacharacters), rejects overly long model names (>100 chars), validates provider field. Port: none (pure unit test)", + "priority": "P0", + "tddPhase": "test", + "verificationCriteria": "test/opencode-schema.test.ts exists, tests fail because schema doesn't accept 'opencode' mode or openCodeConfig field", + "testCommand": "npx vitest run test/opencode-schema.test.ts", + "dependencies": ["P0-002"] + }, + { + "id": "P0-008", + "content": "Implement schema validation changes — update CreateSessionSchema in src/web/schemas.ts: extend mode enum to include 'opencode', add openCodeConfig z.object with model (string, max 100, regex /^[a-zA-Z0-9._\\-/]+$/), provider (string, max 50), autoAllowTools (boolean), continueSession (string, max 100, regex /^[a-zA-Z0-9_-]+$/). All fields optional", + "priority": "P0", + "tddPhase": "impl", + "verificationCriteria": "npx vitest run test/opencode-schema.test.ts passes, tsc --noEmit succeeds", + "pairedWith": "P0-007", + "dependencies": ["P0-007"] + }, + { + "id": "P0-009", + "content": "Review schema validation — verify model regex blocks shell metacharacters (;|&$`), continueSession regex blocks path traversal, no overly permissive patterns, consistent with existing SAFE_PATH_PATTERN security approach in schemas.ts", + "priority": "P0", + "tddPhase": "review", + "verificationCriteria": "Schema rejects all dangerous inputs: model with semicolons, backticks, pipes; continueSession with ../; empty strings handled properly", + "reviewChecklist": ["Shell metacharacter blocking", "Path traversal prevention", "Consistent with existing security patterns", "Zod v4 API usage correct"], + "pairedWith": "P0-008", + "dependencies": ["P0-008"] + }, + { + "id": "P1-001", + "content": "Write failing tests for TmuxManager opencode command construction — test buildOpenCodeCommand() generates correct CLI: basic 'opencode' command, with --model flag, with --session flag for continue, validates model string safety, validates session ID safety. Test that createSession() with mode 'opencode' builds correct tmux respawn-pane command with opencode env vars (CLAUDEMAN_MUX, API keys passthrough). Mock execAsync. Port: none (unit test with mocks)", + "priority": "P1", + "tddPhase": "test", + "verificationCriteria": "test/opencode-tmux.test.ts exists with 8+ test cases covering command construction, env var setup, and PATH augmentation for opencode mode", + "testCommand": "npx vitest run test/opencode-tmux.test.ts", + "dependencies": ["P0-002", "P0-005"] + }, + { + "id": "P1-002", + "content": "Implement TmuxManager opencode support — in src/tmux-manager.ts: (1) import resolveOpenCodeDir from utils, (2) add buildOpenCodeCommand(sessionId, config?) helper that constructs 'opencode [--model X] [--session Y]' with input validation, (3) extend createSession() command construction to handle mode === 'opencode' alongside existing claude/shell branches, (4) add opencode-specific env exports (CLAUDEMAN_MUX, CLAUDEMAN_SESSION_ID, API key passthrough for ANTHROPIC/OPENAI/GOOGLE_API_KEY, optional OPENCODE_CONFIG_CONTENT), (5) use resolveOpenCodeDir() for PATH augmentation when mode is opencode, (6) throw clear error if opencode binary not found, (7) apply same changes to respawnPane()", + "priority": "P1", + "tddPhase": "impl", + "verificationCriteria": "npx vitest run test/opencode-tmux.test.ts passes, tsc --noEmit succeeds", + "pairedWith": "P1-001", + "dependencies": ["P1-001"] + }, + { + "id": "P1-003", + "content": "Review TmuxManager opencode integration — verify command injection prevention in buildOpenCodeCommand (model and sessionId validated before interpolation), env var escaping for OPENCODE_CONFIG_CONTENT (single quotes properly escaped), API key passthrough doesn't leak other env vars, respawnPane changes mirror createSession exactly, error messages are actionable", + "priority": "P1", + "tddPhase": "review", + "verificationCriteria": "No command injection vectors, env vars properly escaped, consistent with existing Claude command construction security, error messages guide user to install opencode", + "reviewChecklist": ["Command injection prevention", "Env var escaping (single quotes in config content)", "API key passthrough scope limited", "respawnPane mirrors createSession", "Error message includes install instructions"], + "pairedWith": "P1-002", + "dependencies": ["P1-002"] + }, + { + "id": "P1-004", + "content": "Write failing tests for Session class opencode mode — test that Session constructor accepts mode: 'opencode' with openCodeConfig, test startInteractive() dispatches to opencode-specific startup (not Claude CLI), test that Claude-specific features are disabled for opencode sessions (hooks config skipped, subagent watcher Claude patterns skipped), test that writeViaMux() works identically for opencode mode (tmux send-keys is mode-agnostic). Port: 3161", + "priority": "P1", + "tddPhase": "test", + "verificationCriteria": "test/opencode-session.test.ts exists with 6+ test cases, tests fail because Session doesn't support opencode mode", + "testCommand": "npx vitest run test/opencode-session.test.ts", + "dependencies": ["P0-002", "P1-002"] + }, + { + "id": "P1-005", + "content": "Implement Session class opencode support — in src/session.ts: (1) accept openCodeConfig in constructor options and store as private field, (2) in startInteractive(), when mode is 'opencode', pass openCodeConfig to mux.createSession(), (3) skip hooks config generation for opencode sessions (no .claude/settings.local.json hooks), (4) add waitForOpenCodeReady() method using output-silence detection (wait for TUI paint: 500ms of stable output after initial burst, max 8s), (5) skip Claude-specific status line parsing for opencode mode, (6) add mode getter to Session for downstream consumers", + "priority": "P1", + "tddPhase": "impl", + "verificationCriteria": "npx vitest run test/opencode-session.test.ts passes, tsc --noEmit succeeds", + "pairedWith": "P1-004", + "dependencies": ["P1-004"] + }, + { + "id": "P1-006", + "content": "Review Session class opencode integration — verify opencode mode doesn't break existing Claude/shell sessions (no regressions), waitForOpenCodeReady timeout is reasonable (8s), hooks are properly skipped without breaking Claude hooks, no memory leaks from new fields, toState() includes opencode-relevant state", + "priority": "P1", + "tddPhase": "review", + "verificationCriteria": "Existing session tests still pass, opencode-specific logic properly guarded with mode checks, no side effects on Claude sessions", + "reviewChecklist": ["No regression on Claude/shell modes", "waitForOpenCodeReady timeout appropriate", "Hooks skipped cleanly", "toState() serialization works", "No memory leak from new fields"], + "pairedWith": "P1-005", + "dependencies": ["P1-005"] + }, + { + "id": "P1-007", + "content": "Write failing tests for opencode idle detection — test getIdleDetectionConfig() returns opencode-specific config (silenceThresholdMs: 5000, no prompt pattern, no AI checker), test that session emits 'idle' after 5s of output silence in opencode mode, test that session emits 'working' when output resumes, test that idle detection works during respawn cycles. Port: 3162", + "priority": "P1", + "tddPhase": "test", + "verificationCriteria": "test/opencode-idle.test.ts exists with 4+ test cases testing silence-based idle detection for opencode mode", + "testCommand": "npx vitest run test/opencode-idle.test.ts", + "dependencies": ["P1-005"] + }, + { + "id": "P1-008", + "content": "Implement opencode idle detection — in src/session.ts: (1) add getIdleDetectionConfig() method returning mode-specific config: for opencode — silenceThresholdMs: 5000, promptPattern: null, useAIChecker: false; for claude — existing values, (2) modify idle detection logic to use silence-based detection when promptPattern is null (track lastOutputTime, timer-based idle check), (3) ensure 'working' event fires on any new output after idle state, (4) make IDLE_DETECTION_DELAY_MS configurable per mode", + "priority": "P1", + "tddPhase": "impl", + "verificationCriteria": "npx vitest run test/opencode-idle.test.ts passes, opencode sessions correctly transition idle→working→idle based on output silence", + "pairedWith": "P1-007", + "dependencies": ["P1-007"] + }, + { + "id": "P1-009", + "content": "Review idle detection implementation — verify silence timer is properly cleaned up on session stop/exit (no leaked timers), timer doesn't fire after session disposal, idle threshold is tunable per session, working→idle transition doesn't spam events, existing Claude idle detection unchanged", + "priority": "P1", + "tddPhase": "review", + "verificationCriteria": "Timer cleanup verified, no event spam, Claude idle detection regression-free, CleanupManager tracks new timer", + "reviewChecklist": ["Timer cleanup on session.stop()", "No event spam on rapid output", "Claude idle detection unchanged", "CleanupManager integration", "Configurable threshold"], + "pairedWith": "P1-008", + "dependencies": ["P1-008"] + }, + { + "id": "P1-010", + "content": "Write failing tests for API routes — test POST /api/sessions creates opencode session when mode: 'opencode', test returns 400 when opencode not installed, test GET /api/opencode/status returns availability info, test POST /api/sessions/:id/interactive works for opencode sessions, test openCodeConfig is persisted in session state. Port: 3163", + "priority": "P1", + "tddPhase": "test", + "verificationCriteria": "test/opencode-api.test.ts exists with 5+ test cases, tests fail because server doesn't handle opencode mode", + "testCommand": "npx vitest run test/opencode-api.test.ts", + "dependencies": ["P0-008", "P1-005"] + }, + { + "id": "P1-011", + "content": "Implement API routes for opencode — in src/web/server.ts: (1) in POST /api/sessions handler, check isOpenCodeAvailable() when mode is 'opencode' and return 400 if not installed, pass openCodeConfig from request body to Session constructor, (2) add GET /api/opencode/status route returning { available: boolean, path: string | null }, (3) ensure POST /api/sessions/:id/interactive works for opencode mode (startInteractive already handles mode dispatch), (4) include mode in session state broadcast events, (5) persist openCodeConfig in state store", + "priority": "P1", + "tddPhase": "impl", + "verificationCriteria": "npx vitest run test/opencode-api.test.ts passes, curl GET /api/opencode/status returns valid JSON", + "pairedWith": "P1-010", + "dependencies": ["P1-010"] + }, + { + "id": "P1-012", + "content": "Review API routes — verify /api/opencode/status doesn't expose sensitive info (only binary path, not env vars), session creation validates all openCodeConfig fields before passing to Session, error messages don't leak internal paths, SSE events include mode for frontend routing, state persistence includes openCodeConfig", + "priority": "P1", + "tddPhase": "review", + "verificationCriteria": "No info leakage, proper validation, error messages user-friendly, SSE events tagged with mode", + "reviewChecklist": ["No sensitive info in /api/opencode/status", "openCodeConfig validated before use", "Error messages actionable", "SSE events include mode", "State persistence round-trips correctly"], + "pairedWith": "P1-011", + "dependencies": ["P1-011"] + }, + { + "id": "P1-013", + "content": "Write failing tests for frontend mode selector — Playwright test: load app, verify session creation dialog includes 'OpenCode' option alongside 'Claude Code' and 'Shell', verify selecting OpenCode shows model input field, verify OpenCode option is disabled when /api/opencode/status returns available: false, verify tab shows 'OC' badge for opencode sessions. Port: 3164", + "priority": "P1", + "tddPhase": "test", + "verificationCriteria": "test/opencode-frontend.test.ts (Playwright) exists with 4+ assertions, tests fail because UI doesn't have OpenCode option", + "testCommand": "npx vitest run test/opencode-frontend.test.ts", + "dependencies": ["P1-011"] + }, + { + "id": "P1-014", + "content": "Implement frontend UI changes — in src/web/public/app.js: (1) add OpenCode to session creation mode selector (in quick-start modal and/or new session dialog), with icon and 'Multi-model AI agent' description, (2) when opencode mode selected, show model text input with autocomplete for common models (anthropic/claude-sonnet-4-5, openai/gpt-5.2, google/gemini-3-pro, ollama/codellama), (3) check /api/opencode/status on load and disable option if unavailable, (4) add tab badge rendering: 'OC' badge with green (#10b981) background for opencode sessions, 'SH' for shell, none for claude, (5) gate Claude-specific UI panels for opencode sessions (disable hooks panel, auto-compact button)", + "priority": "P1", + "tddPhase": "impl", + "verificationCriteria": "npx vitest run test/opencode-frontend.test.ts passes, manual verification: mode selector shows OpenCode option, tab badge renders", + "pairedWith": "P1-013", + "dependencies": ["P1-013"] + }, + { + "id": "P1-015", + "content": "Review frontend implementation — verify mode selector accessibility (keyboard navigable, ARIA labels), model autocomplete doesn't make excessive API calls, tab badge CSS follows existing z-index layering, disabled state has clear visual indicator, no XSS from model name rendering (text content, not innerHTML), feature gating doesn't break Claude session UI", + "priority": "P1", + "tddPhase": "review", + "verificationCriteria": "Accessible UI, no XSS vectors, existing Claude UI unchanged, CSS consistent with design system", + "reviewChecklist": ["Keyboard accessibility", "No XSS from model names", "CSS z-index consistent", "Claude UI regression-free", "Disabled state visual clarity", "Mobile layout compatibility"], + "pairedWith": "P1-014", + "dependencies": ["P1-014"] + }, + { + "id": "P1-016", + "content": "Write failing tests for respawn controller opencode adaptation — using MockSession from test/respawn-test-utils.ts: test respawn controller accepts opencode sessions, test completion detection uses output silence (not 'Worked for' pattern) for opencode, test prompt sending via writeViaMux works for opencode, test circuit breaker works identically for opencode sessions. Port: none (uses MockSession)", + "priority": "P1", + "tddPhase": "test", + "verificationCriteria": "test/opencode-respawn.test.ts exists with 4+ test cases using MockSession, tests fail because respawn controller doesn't handle opencode idle detection", + "testCommand": "npx vitest run test/opencode-respawn.test.ts", + "dependencies": ["P1-008"] + }, + { + "id": "P1-017", + "content": "Implement respawn controller opencode adaptation — in src/respawn-controller.ts: (1) add mode-aware completion detection: for opencode, use output silence threshold instead of COMPLETION_TIME_PATTERN regex, (2) skip plan mode detection patterns for opencode (OpenCode has different plan mode), (3) skip AI idle checker for opencode sessions initially (prompt is Claude-specific), (4) ensure prompt sending via writeViaMux works without modification (it's mode-agnostic), (5) keep circuit breaker, health scoring, and cycle metrics unchanged (they're mode-agnostic)", + "priority": "P1", + "tddPhase": "impl", + "verificationCriteria": "npx vitest run test/opencode-respawn.test.ts passes, respawn cycles work with silence-based completion detection", + "pairedWith": "P1-016", + "dependencies": ["P1-016"] + }, + { + "id": "P1-018", + "content": "Review respawn controller changes — verify existing Claude respawn behavior unchanged (run existing respawn tests), silence-based detection has reasonable timeout (matches idle detection config), no race conditions between idle detection and respawn timer, circuit breaker still functions correctly for opencode sessions", + "priority": "P1", + "tddPhase": "review", + "verificationCriteria": "Existing respawn tests pass, no regressions, silence timeout configurable, race conditions addressed", + "reviewChecklist": ["Existing Claude respawn tests pass", "Silence timeout reasonable", "No race conditions", "Circuit breaker works for opencode", "Respawn config serialization includes mode"], + "pairedWith": "P1-017", + "dependencies": ["P1-017"] + }, + { + "id": "P1-019", + "content": "Write failing tests for state persistence round-trip — test that opencode sessions are correctly serialized to ~/.claudeman/state.json (mode, openCodeConfig preserved), test that sessions are restored on server restart with correct mode and config, test that session lifecycle log records opencode mode. Port: none (unit test)", + "priority": "P1", + "tddPhase": "test", + "verificationCriteria": "test/opencode-state.test.ts exists with 3+ test cases, tests verify serialization/deserialization of opencode session state", + "testCommand": "npx vitest run test/opencode-state.test.ts", + "dependencies": ["P1-005"] + }, + { + "id": "P1-020", + "content": "Implement state persistence for opencode sessions — in src/state-store.ts: ensure openCodeConfig is included in SessionState serialization, in session.ts toState() method include openCodeConfig, in server.ts restoreSession() handle mode: 'opencode' and pass openCodeConfig through, update session-lifecycle-log.ts to record opencode mode in lifecycle entries", + "priority": "P1", + "tddPhase": "impl", + "verificationCriteria": "npx vitest run test/opencode-state.test.ts passes, state.json correctly stores and restores opencode sessions", + "pairedWith": "P1-019", + "dependencies": ["P1-019"] + }, + { + "id": "P1-021", + "content": "Review state persistence — verify openCodeConfig doesn't contain sensitive data (API keys not serialized to disk), state.json schema is backward compatible (existing sessions load fine without openCodeConfig), lifecycle log entries are queryable by mode", + "priority": "P1", + "tddPhase": "review", + "verificationCriteria": "No API keys in state.json, backward compatibility verified, lifecycle log works", + "reviewChecklist": ["No secrets in state.json", "Backward compatibility", "Lifecycle log queryable by mode", "toState() round-trip fidelity"], + "pairedWith": "P1-020", + "dependencies": ["P1-020"] + }, + { + "id": "P2-001", + "content": "Write failing tests for feature gating — test that hooks config is NOT generated for opencode sessions, test that subagent watcher skips Claude-specific transcript patterns for opencode, test that auto-compact sends correct slash command per mode (/compact for claude, /clear for opencode), test that token tracking is disabled for opencode sessions (no status line parsing). Port: none (unit tests)", + "priority": "P2", + "tddPhase": "test", + "verificationCriteria": "test/opencode-feature-gating.test.ts exists with 4+ test cases verifying mode-aware feature behavior", + "testCommand": "npx vitest run test/opencode-feature-gating.test.ts", + "dependencies": ["P1-005"] + }, + { + "id": "P2-002", + "content": "Implement feature gating for opencode sessions — (1) in hooks-config.ts: guard generateHooksConfig() to skip when session mode is 'opencode', (2) in session.ts: skip token tracking / status line parsing for opencode mode, (3) in session.ts: make auto-compact command mode-aware (Claude: /compact, OpenCode: skip or /clear), (4) in subagent-watcher.ts: skip Claude-specific transcript parsing for opencode sessions but still watch for generic agent activity patterns", + "priority": "P2", + "tddPhase": "impl", + "verificationCriteria": "npx vitest run test/opencode-feature-gating.test.ts passes, Claude features properly disabled for opencode sessions", + "pairedWith": "P2-001", + "dependencies": ["P2-001"] + }, + { + "id": "P2-003", + "content": "Review feature gating — verify all mode checks are consistent (use session.mode, not hardcoded string comparisons scattered everywhere), no Claude features accidentally leak into opencode sessions, no opencode-specific code paths break Claude sessions", + "priority": "P2", + "tddPhase": "review", + "verificationCriteria": "Consistent mode checking pattern, no feature leakage, no Claude regressions", + "reviewChecklist": ["Consistent mode check pattern", "No feature leakage", "No Claude regressions", "Auto-compact behavior correct per mode"], + "pairedWith": "P2-002", + "dependencies": ["P2-002"] + }, + { + "id": "P2-004", + "content": "Write end-to-end integration test — Playwright test that creates an opencode session via the web UI (if opencode is installed) or via API with mocked binary, verifies terminal renders, sends input, receives output, verifies tab badge shows 'OC', verifies session appears in /api/sessions with mode: 'opencode', cleans up session. Port: 3165", + "priority": "P2", + "tddPhase": "test", + "verificationCriteria": "test/opencode-e2e.test.ts exists with full session lifecycle test, skips gracefully if opencode binary not installed", + "testCommand": "npx vitest run test/opencode-e2e.test.ts", + "dependencies": ["P1-014", "P1-011"] + }, + { + "id": "P2-005", + "content": "Fix any issues found during E2E testing — address xterm.js rendering issues with OpenCode's Bubble Tea TUI (if any), fix signal handling (SIGWINCH for resize), resolve any env var passthrough issues, ensure cleanup on session delete works correctly for opencode sessions", + "priority": "P2", + "tddPhase": "impl", + "verificationCriteria": "npx vitest run test/opencode-e2e.test.ts passes end-to-end, manual smoke test confirms TUI renders correctly in browser", + "pairedWith": "P2-004", + "dependencies": ["P2-004"] + }, + { + "id": "P2-006", + "content": "Review E2E integration — verify session cleanup doesn't leave orphaned tmux sessions, no zombie processes, state.json is clean after deletion, SSE events fire correctly for all lifecycle phases (created, interactive, idle, working, exit, deleted)", + "priority": "P2", + "tddPhase": "review", + "verificationCriteria": "No orphaned tmux sessions, no zombies, clean state.json after delete, all SSE events fire", + "reviewChecklist": ["No orphaned tmux sessions", "No zombie processes", "State cleanup complete", "SSE event lifecycle complete", "Resource usage reasonable"], + "pairedWith": "P2-005", + "dependencies": ["P2-005"] + }, + { + "id": "P2-007", + "content": "Write tests for Ralph Loop opencode compatibility — test that Ralph Loop can send prompts to opencode sessions via writeViaMux, test that completion detection uses silence-based approach, test that todo tracking is disabled for opencode (no TodoWrite tool parsing), test that Ralph queue processes tasks for opencode sessions", + "priority": "P2", + "tddPhase": "test", + "verificationCriteria": "test/opencode-ralph.test.ts exists with 4+ test cases using MockSession configured in opencode mode", + "testCommand": "npx vitest run test/opencode-ralph.test.ts", + "dependencies": ["P1-017"] + }, + { + "id": "P2-008", + "content": "Implement Ralph Loop opencode compatibility — in ralph-loop.ts: (1) allow opencode sessions to be Ralph Loop targets, (2) skip promise phrase detection for opencode (no tags), (3) use silence-based completion detection matching respawn controller approach, (4) skip TodoWrite parsing for opencode sessions, (5) keep task queue and priority logic unchanged (mode-agnostic)", + "priority": "P2", + "tddPhase": "impl", + "verificationCriteria": "npx vitest run test/opencode-ralph.test.ts passes, Ralph Loop can drive opencode sessions through task queues", + "pairedWith": "P2-007", + "dependencies": ["P2-007"] + }, + { + "id": "P2-009", + "content": "Review Ralph Loop adaptation — verify Ralph Loop reliability for opencode (silence detection may be less precise than completion phrase), verify no Ralph features accidentally break for Claude sessions, verify circuit breaker properly handles opencode-specific failure modes", + "priority": "P2", + "tddPhase": "review", + "verificationCriteria": "Ralph Loop works reliably for both modes, no Claude regressions, clear documentation of opencode limitations", + "reviewChecklist": ["Silence detection reliability", "No Claude regressions in Ralph", "Circuit breaker handles opencode failures", "Task queue mode-agnostic"], + "pairedWith": "P2-008", + "dependencies": ["P2-008"] + }, + { + "id": "P2-010", + "content": "Final typecheck and comprehensive review — run tsc --noEmit across entire project, verify all new files follow import conventions (utils from ./utils, types via type imports), verify no unused imports/variables (noUnusedLocals/noUnusedParameters), run existing test files individually to confirm no regressions, update CLAUDE.md with opencode session documentation", + "priority": "P2", + "tddPhase": "review", + "verificationCriteria": "tsc --noEmit passes with zero errors, existing tests pass, CLAUDE.md updated with opencode section", + "reviewChecklist": ["tsc --noEmit clean", "Import conventions followed", "No unused variables", "Existing tests pass", "CLAUDE.md updated", "No TODO/FIXME left unaddressed"], + "dependencies": ["P2-003", "P2-006", "P2-009"] + } + ], + "gaps": [ + "OpenCode binary not currently installed on this machine — manual installation required before integration testing", + "OpenCode's exact TUI escape sequence behavior with xterm.js is unknown — may need rendering fixes", + "OpenCode's signal handling (SIGWINCH for resize, SIGTERM for graceful shutdown) needs empirical testing", + "OpenCode's stdin behavior for pasted/programmatic text input is unverified — writeViaMux may need adaptation", + "Token/cost tracking for opencode sessions is deferred — no structured way to get this from TUI output", + "OpenCode's 'opencode serve' API integration (Strategy B) is not included in this plan — it's a separate follow-up project", + "OpenCode was archived in September 2025 and moved to 'Crush' — long-term maintenance risk", + "Multi-line input compatibility with OpenCode's TUI is unknown (Claudeman sends single-line via writeViaMux)", + "AI idle checker prompt needs OpenCode-specific variant if AI-based idle detection is desired later", + "Agent Teams integration with OpenCode sessions is not covered — teams are Claude Code-specific" + ], + "warnings": [ + "OpenCode's GitHub repository was archived (Sep 2025) and the project moved to 'Crush' — consider whether to target opencode or crush", + "Silence-based idle detection is less precise than Claude's prompt marker detection — may cause false positives (TUI animation) or false negatives (long-running quiet operations)", + "The respawn controller's AI idle checker uses a Claude-specific prompt — it will be disabled for opencode, reducing idle detection reliability", + "OpenCode's Bubble Tea TUI uses alternate screen buffer which may interact poorly with xterm.js buffer capture", + "API key passthrough in tmux env exports means keys appear in tmux's process tree — security consideration for shared machines", + "opencode.json in the project root may conflict with Claudeman's CLI flag overrides — need clear precedence documentation", + "Ralph Loop with opencode may be less reliable without completion phrase detection — silence-based detection has higher error margin", + "Test port allocation: this plan uses ports 3161-3165 — verify no conflicts with existing tests before implementation" + ] +} diff --git a/src/web/public/app.js b/src/web/public/app.js index b8fcead4..195a4c43 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -789,20 +789,31 @@ const DeepgramProvider = { clearInterval(this._keepAliveInterval); this._keepAliveInterval = null; this._stopRecording(); - if (this._ws && this._ws.readyState === WebSocket.OPEN) { - try { this._ws.close(1000); } catch (_e) { /* ignore */ } + // Detach WS handlers before closing to prevent stale onclose from + // killing a subsequent recording that starts before the close completes + if (this._ws) { + this._ws.onclose = null; + this._ws.onmessage = null; + this._ws.onerror = null; + if (this._ws.readyState === WebSocket.OPEN) { + try { this._ws.close(1000); } catch (_e) { /* ignore */ } + } + this._ws = null; } + // Save onEnd before nulling — must notify VoiceInput when silence timeout + // triggers stop internally (VoiceInput.onEnd guards with isRecording check) + const onEnd = this._onEnd; + this._onResult = null; + this._onError = null; + this._onEnd = null; + onEnd?.(); }, _cleanup() { this.stop(); - this._ws = null; this._mediaRecorder = null; this._stream = null; this._selectedMime = null; - this._onResult = null; - this._onError = null; - this._onEnd = null; } }; @@ -1121,7 +1132,15 @@ const VoiceInput = { const mode = this._getDeepgramConfig().insertMode || 'direct'; if (mode === 'compose') { - this._showComposeOverlay(trimmed); + // If a compose overlay is already open, populate its textarea instead of recreating + const existingTextarea = document.querySelector('.voice-compose-overlay .paste-textarea'); + if (existingTextarea) { + existingTextarea.value = trimmed; + existingTextarea.focus(); + existingTextarea.selectionStart = existingTextarea.selectionEnd = trimmed.length; + } else { + this._showComposeOverlay(trimmed); + } } else { // Direct mode: inject into local echo overlay if available, else send to PTY if (app._localEchoEnabled && app._localEchoOverlay) { @@ -1215,7 +1234,7 @@ const VoiceInput = { const cancel = () => overlay.remove(); const newInput = () => { textarea.value = ''; - textarea.focus(); + textarea.blur(); this.start(); }; overlay.querySelector('.paste-cancel').addEventListener('click', cancel); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 5fa662ac..61373b3b 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2040,7 +2040,7 @@ body { max-width: 80%; max-height: 3.6em; overflow: hidden; - z-index: 100; + z-index: 10001; pointer-events: none; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);