From f1e5221c5be2f18bb685575d8ece7510fb8337fe Mon Sep 17 00:00:00 2001 From: Dmitry Polishuk Date: Fri, 2 Jan 2026 21:36:23 +0300 Subject: [PATCH 01/53] docs: add OpenCode ACP support design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design document for integrating OpenCode as the second ACP-compatible agent, following the existing Gemini pattern with API key passthrough, MCP server merging, and mobile permission routing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../2026-01-02-opencode-acp-support-design.md | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 docs/plans/2026-01-02-opencode-acp-support-design.md diff --git a/docs/plans/2026-01-02-opencode-acp-support-design.md b/docs/plans/2026-01-02-opencode-acp-support-design.md new file mode 100644 index 00000000..acdcb739 --- /dev/null +++ b/docs/plans/2026-01-02-opencode-acp-support-design.md @@ -0,0 +1,192 @@ +# OpenCode ACP Support Design + +## Overview + +Add OpenCode as a second ACP-compatible agent in Happy CLI, following the existing Gemini integration pattern. + +## Decisions + +| Decision | Choice | +|----------|--------| +| Authentication | API key passthrough - user provides OPENAI_API_KEY, ANTHROPIC_API_KEY, etc. | +| Model selection | Passthrough only - user specifies via `--model` flag or env vars | +| Command structure | `happy opencode` - dedicated top-level command | +| MCP servers | Merge Happy + OpenCode configs, Happy takes precedence on conflicts | +| Permissions | Route to mobile via existing permission handler | +| Implementation | Minimal Gemini clone - proven pattern, avoid premature abstraction | + +## File Structure + +``` +src/ +├── agent/ +│ └── acp/ +│ ├── index.ts # Add: export opencode +│ ├── opencode.ts # NEW: OpenCode backend factory +│ └── ... +├── commands/ +│ └── opencode.ts # NEW: CLI command handler +├── opencode/ +│ ├── runOpenCode.ts # NEW: Main entry point +│ ├── constants.ts # NEW: Env var names, defaults +│ └── utils/ +│ └── config.ts # NEW: Config detection utilities +└── index.ts # Add: opencode command registration +``` + +## Component Details + +### OpenCode Backend Factory + +`src/agent/acp/opencode.ts`: + +```typescript +export interface OpenCodeBackendOptions extends AgentFactoryOptions { + model?: string; + mcpServers?: Record; + permissionHandler?: AcpPermissionHandler; +} + +export function createOpenCodeBackend(options: OpenCodeBackendOptions): AgentBackend { + const command = 'opencode'; + const args = ['acp']; + + if (options.model) { + args.push('--model', options.model); + } + + return new AcpSdkBackend({ + agentName: 'opencode', + cwd: options.cwd, + command, + args, + env: options.env, + mcpServers: options.mcpServers, + permissionHandler: options.permissionHandler, + }); +} + +export function registerOpenCodeAgent(): void { + agentRegistry.register('opencode', (opts) => createOpenCodeBackend(opts)); +} +``` + +### Run Entry Point + +`src/opencode/runOpenCode.ts`: + +```typescript +export interface RunOpenCodeOptions { + cwd: string; + model?: string; + initialPrompt?: string; +} + +export async function runOpenCode(options: RunOpenCodeOptions): Promise { + const { cwd, model, initialPrompt } = options; + + // 1. Create permission handler (routes to mobile via Happy server) + const permissionHandler = createRemotePermissionHandler(); + + // 2. Get MCP servers (merge Happy + OpenCode native configs) + const mcpServers = await getMergedMcpServers(cwd); + + // 3. Create backend + const backend = createOpenCodeBackend({ + cwd, + model, + mcpServers, + permissionHandler, + }); + + // 4. Connect to Happy server for remote control + const session = await connectToHappyServer(backend, 'opencode'); + + // 5. Start agent session + await backend.startSession(initialPrompt); + + // 6. Run control loop + await runAgentLoop(backend, session); +} +``` + +### MCP Server Merging + +`src/opencode/utils/config.ts`: + +```typescript +export async function readOpenCodeConfig(): Promise { + const configPath = join(homedir(), '.config', 'opencode', 'config.json'); + try { + const content = await readFile(configPath, 'utf-8'); + return JSON.parse(content); + } catch { + return {}; + } +} + +export async function getMergedMcpServers(cwd: string): Promise> { + const openCodeConfig = await readOpenCodeConfig(); + const openCodeServers = openCodeConfig.mcpServers ?? {}; + const happyServers = await getHappyMcpServers(cwd); + + return { + ...openCodeServers, + ...happyServers, // Happy takes precedence + }; +} +``` + +### CLI Command + +`src/commands/opencode.ts`: + +```typescript +export function createOpenCodeCommand(): Command { + return new Command('opencode') + .description('Run OpenCode agent with Happy remote control') + .option('-m, --model ', 'Model to use') + .option('-c, --cwd ', 'Working directory', process.cwd()) + .option('-p, --prompt ', 'Initial prompt') + .action(async (options) => { + await runOpenCode({ + cwd: options.cwd, + model: options.model, + initialPrompt: options.prompt, + }); + }); +} +``` + +## Usage + +```bash +# Basic - uses OpenCode's default model +happy opencode + +# With specific model +happy opencode --model gpt-4o + +# With initial prompt +happy opencode -p "fix the build errors" + +# In specific directory +happy opencode -c /path/to/project +``` + +## Error Handling + +| Scenario | Handling | +|----------|----------| +| OpenCode not installed | Clear error with install command | +| No API keys set | Let OpenCode handle (shows its own error) | +| ACP init timeout | Existing 2-minute timeout in AcpSdkBackend | +| Permission denied | Routes to mobile for approval | +| OpenCode crashes | Exit handler emits `status: stopped` | + +## Reused Components + +- `AcpSdkBackend` - Unchanged, handles ACP protocol +- Permission handler infrastructure - Routes to mobile +- Session management - Happy server integration +- UI components - Status display, QR codes From f928c5ba93fb6220542c420485e0c6d6805887d8 Mon Sep 17 00:00:00 2001 From: Dmitry Polishuk Date: Fri, 2 Jan 2026 21:40:16 +0300 Subject: [PATCH 02/53] chore: add .worktrees/ to gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepare for isolated development workspaces. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 44bfd564..0d615400 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,7 @@ pnpm-lock.yaml .release-notes-temp.md # npm auth token (never commit) -.npmrc \ No newline at end of file +.npmrc + +# Git worktrees +.worktrees/ \ No newline at end of file From f0088a0bd18eb39cbda4e2ba36756c672677b7d7 Mon Sep 17 00:00:00 2001 From: Dmitry Polishuk Date: Fri, 2 Jan 2026 21:43:37 +0300 Subject: [PATCH 03/53] docs: add OpenCode ACP implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detailed task-by-task implementation plan for adding OpenCode as the second ACP-compatible agent in Happy CLI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../2026-01-02-opencode-acp-implementation.md | 627 ++++++++++++++++++ 1 file changed, 627 insertions(+) create mode 100644 docs/plans/2026-01-02-opencode-acp-implementation.md diff --git a/docs/plans/2026-01-02-opencode-acp-implementation.md b/docs/plans/2026-01-02-opencode-acp-implementation.md new file mode 100644 index 00000000..a815c0fd --- /dev/null +++ b/docs/plans/2026-01-02-opencode-acp-implementation.md @@ -0,0 +1,627 @@ +# OpenCode ACP Support Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add OpenCode as the second ACP-compatible agent in Happy CLI, enabling remote control from the Happy mobile app. + +**Architecture:** Factory pattern matching existing Gemini implementation - `createOpenCodeBackend()` creates an `AcpSdkBackend` that spawns `opencode acp`, with MCP server merging from both Happy and OpenCode configs. + +**Tech Stack:** TypeScript, ACP SDK (@agentclientprotocol/sdk), Commander.js for CLI + +--- + +## Task 1: OpenCode Constants + +**Files:** +- Create: `src/opencode/constants.ts` + +**Step 1: Create constants file** + +```typescript +/** + * OpenCode constants - environment variables and defaults + */ + +/** OpenCode config directory (standard XDG location) */ +export const OPENCODE_CONFIG_DIR = '.config/opencode'; + +/** OpenCode config filename */ +export const OPENCODE_CONFIG_FILE = 'config.json'; + +/** Common API key environment variables that OpenCode supports */ +export const OPENCODE_API_KEY_ENVS = [ + 'OPENAI_API_KEY', + 'ANTHROPIC_API_KEY', + 'GOOGLE_API_KEY', + 'GEMINI_API_KEY', + 'GROQ_API_KEY', +] as const; +``` + +**Step 2: Verify TypeScript compiles** + +Run: `yarn build` +Expected: No errors related to constants.ts + +**Step 3: Commit** + +```bash +git add src/opencode/constants.ts +git commit -m "feat(opencode): add constants for OpenCode integration" +``` + +--- + +## Task 2: OpenCode Config Utilities + +**Files:** +- Create: `src/opencode/utils/config.ts` + +**Step 1: Create config utilities** + +```typescript +/** + * OpenCode configuration utilities + * + * Reads OpenCode's native config from ~/.config/opencode/config.json + * and provides MCP server merging with Happy's config. + */ + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { logger } from '@/ui/logger'; +import { OPENCODE_CONFIG_DIR, OPENCODE_CONFIG_FILE } from '../constants'; +import type { McpServerConfig } from '@/agent/AgentBackend'; + +/** + * OpenCode config.json structure (partial - only what we need) + */ +export interface OpenCodeConfig { + mcpServers?: Record; + }>; +} + +/** + * Read OpenCode's native config file + * + * @returns Parsed config or empty object if not found/invalid + */ +export async function readOpenCodeConfig(): Promise { + const configPath = join(homedir(), OPENCODE_CONFIG_DIR, OPENCODE_CONFIG_FILE); + + try { + const content = await readFile(configPath, 'utf-8'); + const config = JSON.parse(content) as OpenCodeConfig; + logger.debug('[OpenCode] Read config from:', configPath); + return config; + } catch (error) { + // Config not found or invalid - return empty + logger.debug('[OpenCode] No config found at:', configPath); + return {}; + } +} + +/** + * Convert OpenCode MCP server format to Happy's format + */ +export function convertOpenCodeMcpServers( + openCodeServers: OpenCodeConfig['mcpServers'] +): Record { + if (!openCodeServers) return {}; + + const result: Record = {}; + + for (const [name, config] of Object.entries(openCodeServers)) { + result[name] = { + command: config.command, + args: config.args, + env: config.env, + }; + } + + return result; +} + +/** + * Get merged MCP servers from OpenCode config and Happy config + * + * OpenCode's servers are loaded first, then Happy's overlay on top. + * If both define the same server name, Happy's version wins. + * + * @param happyServers - MCP servers from Happy's configuration + * @returns Merged MCP server configuration + */ +export async function getMergedMcpServers( + happyServers?: Record +): Promise> { + const openCodeConfig = await readOpenCodeConfig(); + const openCodeServers = convertOpenCodeMcpServers(openCodeConfig.mcpServers); + + const merged = { + ...openCodeServers, + ...(happyServers ?? {}), // Happy takes precedence + }; + + logger.debug('[OpenCode] Merged MCP servers:', { + fromOpenCode: Object.keys(openCodeServers), + fromHappy: Object.keys(happyServers ?? {}), + merged: Object.keys(merged), + }); + + return merged; +} +``` + +**Step 2: Verify TypeScript compiles** + +Run: `yarn build` +Expected: No errors + +**Step 3: Commit** + +```bash +git add src/opencode/utils/config.ts +git commit -m "feat(opencode): add config utilities for MCP server merging" +``` + +--- + +## Task 3: OpenCode Backend Factory + +**Files:** +- Create: `src/agent/acp/opencode.ts` +- Modify: `src/agent/acp/index.ts` + +**Step 1: Create OpenCode backend factory** + +```typescript +/** + * OpenCode ACP Backend - OpenCode agent via ACP + * + * This module provides a factory function for creating an OpenCode backend + * that communicates using the Agent Client Protocol (ACP). + * + * OpenCode supports ACP natively via the `opencode acp` command. + */ + +import { AcpSdkBackend, type AcpSdkBackendOptions, type AcpPermissionHandler } from './AcpSdkBackend'; +import type { AgentBackend, McpServerConfig } from '../AgentBackend'; +import { agentRegistry, type AgentFactoryOptions } from '../AgentRegistry'; +import { logger } from '@/ui/logger'; + +/** + * Options for creating an OpenCode ACP backend + */ +export interface OpenCodeBackendOptions extends AgentFactoryOptions { + /** Model to use (passed via --model flag) */ + model?: string; + + /** MCP servers to make available to the agent */ + mcpServers?: Record; + + /** Optional permission handler for tool approval */ + permissionHandler?: AcpPermissionHandler; +} + +/** + * Create an OpenCode backend using ACP. + * + * OpenCode must be installed and available in PATH. + * Uses the `opencode acp` command to enable ACP mode. + * + * @param options - Configuration options + * @returns AgentBackend instance for OpenCode + */ +export function createOpenCodeBackend(options: OpenCodeBackendOptions): AgentBackend { + const command = 'opencode'; + const args = ['acp']; + + // Add model flag if specified + if (options.model) { + args.push('--model', options.model); + } + + // Add working directory + if (options.cwd) { + args.push('--cwd', options.cwd); + } + + const backendOptions: AcpSdkBackendOptions = { + agentName: 'opencode', + cwd: options.cwd, + command, + args, + env: options.env, + mcpServers: options.mcpServers, + permissionHandler: options.permissionHandler, + }; + + logger.debug('[OpenCode] Creating ACP SDK backend with options:', { + cwd: backendOptions.cwd, + command: backendOptions.command, + args: backendOptions.args, + model: options.model, + mcpServerCount: options.mcpServers ? Object.keys(options.mcpServers).length : 0, + }); + + return new AcpSdkBackend(backendOptions); +} + +/** + * Register OpenCode backend with the global agent registry. + * + * This function should be called during application initialization + * to make the OpenCode agent available for use. + */ +export function registerOpenCodeAgent(): void { + agentRegistry.register('opencode', (opts) => createOpenCodeBackend(opts)); + logger.debug('[OpenCode] Registered with agent registry'); +} +``` + +**Step 2: Update ACP index exports** + +Modify `src/agent/acp/index.ts`: + +```typescript +/** + * ACP Module - Agent Client Protocol implementations + * + * This module exports all ACP-related functionality including + * the base AcpSdkBackend and agent-specific implementations. + * + * Uses the official @agentclientprotocol/sdk from Zed Industries. + */ + +export { AcpSdkBackend, type AcpSdkBackendOptions } from './AcpSdkBackend'; +export { createGeminiBackend, registerGeminiAgent, type GeminiBackendOptions } from './gemini'; +export { createOpenCodeBackend, registerOpenCodeAgent, type OpenCodeBackendOptions } from './opencode'; +``` + +**Step 3: Verify TypeScript compiles** + +Run: `yarn build` +Expected: No errors + +**Step 4: Commit** + +```bash +git add src/agent/acp/opencode.ts src/agent/acp/index.ts +git commit -m "feat(opencode): add OpenCode ACP backend factory" +``` + +--- + +## Task 4: Run OpenCode Entry Point + +**Files:** +- Create: `src/opencode/runOpenCode.ts` +- Create: `src/opencode/index.ts` + +**Step 1: Create runOpenCode entry point** + +Look at `src/gemini/runGemini.ts` for reference patterns, then create: + +```typescript +/** + * OpenCode Runner - Main entry point for running OpenCode with Happy + * + * Orchestrates OpenCode sessions with remote control from Happy mobile app. + */ + +import { createOpenCodeBackend } from '@/agent/acp/opencode'; +import { getMergedMcpServers } from './utils/config'; +import { logger } from '@/ui/logger'; +import type { AcpPermissionHandler } from '@/agent/acp/AcpSdkBackend'; +import type { McpServerConfig } from '@/agent/AgentBackend'; + +/** + * Options for running OpenCode + */ +export interface RunOpenCodeOptions { + /** Working directory */ + cwd: string; + + /** Model to use (e.g., 'claude-sonnet-4-20250514', 'gpt-4o') */ + model?: string; + + /** Initial prompt to send */ + initialPrompt?: string; + + /** MCP servers from Happy config */ + happyMcpServers?: Record; + + /** Permission handler for tool approval */ + permissionHandler?: AcpPermissionHandler; + + /** Environment variables to pass to OpenCode */ + env?: Record; +} + +/** + * Run OpenCode with Happy integration + * + * Creates an OpenCode backend via ACP and manages the session lifecycle. + * Merges MCP servers from both Happy and OpenCode's native config. + * + * @param options - Configuration options + * @returns Promise that resolves when the session ends + */ +export async function runOpenCode(options: RunOpenCodeOptions): Promise { + const { cwd, model, initialPrompt, happyMcpServers, permissionHandler, env } = options; + + logger.debug('[OpenCode] Starting with options:', { + cwd, + model, + hasInitialPrompt: !!initialPrompt, + happyMcpServerCount: happyMcpServers ? Object.keys(happyMcpServers).length : 0, + hasPermissionHandler: !!permissionHandler, + }); + + // Merge MCP servers from OpenCode config and Happy config + const mcpServers = await getMergedMcpServers(happyMcpServers); + + // Create OpenCode backend + const backend = createOpenCodeBackend({ + cwd, + model, + mcpServers, + permissionHandler, + env, + }); + + // Start the session + const { sessionId } = await backend.startSession(initialPrompt); + + logger.debug('[OpenCode] Session started:', sessionId); + + // Return the backend for external management (daemon integration) + // The caller (daemon or CLI) manages the session lifecycle + return; +} + +/** + * Check if OpenCode is installed and available + * + * @returns Promise - true if OpenCode is available + */ +export async function isOpenCodeInstalled(): Promise { + const { exec } = await import('node:child_process'); + const { promisify } = await import('node:util'); + const execAsync = promisify(exec); + + try { + await execAsync('opencode --version'); + return true; + } catch { + return false; + } +} +``` + +**Step 2: Create index export** + +```typescript +/** + * OpenCode module - OpenCode integration for Happy CLI + */ + +export { runOpenCode, isOpenCodeInstalled, type RunOpenCodeOptions } from './runOpenCode'; +export { readOpenCodeConfig, getMergedMcpServers } from './utils/config'; +export { OPENCODE_API_KEY_ENVS, OPENCODE_CONFIG_DIR, OPENCODE_CONFIG_FILE } from './constants'; +``` + +**Step 3: Verify TypeScript compiles** + +Run: `yarn build` +Expected: No errors + +**Step 4: Commit** + +```bash +git add src/opencode/runOpenCode.ts src/opencode/index.ts +git commit -m "feat(opencode): add runOpenCode entry point" +``` + +--- + +## Task 5: CLI Command + +**Files:** +- Create: `src/commands/opencode.ts` + +**Step 1: Look at existing command structure** + +Check `src/commands/gemini.ts` or `src/commands/connect.ts` for patterns. + +**Step 2: Create OpenCode command** + +```typescript +/** + * OpenCode CLI Command + * + * Provides the `happy opencode` command for running OpenCode + * with Happy remote control integration. + */ + +import { Command } from 'commander'; +import { runOpenCode, isOpenCodeInstalled } from '@/opencode'; +import { logger } from '@/ui/logger'; +import chalk from 'chalk'; + +/** + * Create the opencode command + */ +export function createOpenCodeCommand(): Command { + return new Command('opencode') + .description('Run OpenCode agent with Happy remote control') + .option('-m, --model ', 'Model to use (e.g., claude-sonnet-4-20250514, gpt-4o)') + .option('-c, --cwd ', 'Working directory', process.cwd()) + .option('-p, --prompt ', 'Initial prompt to send') + .action(async (options: { model?: string; cwd: string; prompt?: string }) => { + // Check if OpenCode is installed + const installed = await isOpenCodeInstalled(); + if (!installed) { + console.error(chalk.red('Error: OpenCode is not installed or not in PATH.')); + console.error(chalk.yellow('Install with: curl -fsSL https://opencode.ai/install | bash')); + process.exit(1); + } + + logger.debug('[CLI] Running opencode command with options:', options); + + try { + await runOpenCode({ + cwd: options.cwd, + model: options.model, + initialPrompt: options.prompt, + }); + } catch (error) { + logger.error('[CLI] OpenCode error:', error); + console.error(chalk.red('Error running OpenCode:'), error instanceof Error ? error.message : String(error)); + process.exit(1); + } + }); +} +``` + +**Step 3: Verify TypeScript compiles** + +Run: `yarn build` +Expected: No errors + +**Step 4: Commit** + +```bash +git add src/commands/opencode.ts +git commit -m "feat(opencode): add CLI command" +``` + +--- + +## Task 6: Register Command in Main Entry Point + +**Files:** +- Modify: `src/index.ts` + +**Step 1: Find where commands are registered** + +Look for pattern like `program.addCommand(...)` or similar in `src/index.ts`. + +**Step 2: Add OpenCode command registration** + +Add import at top: +```typescript +import { createOpenCodeCommand } from '@/commands/opencode'; +``` + +Add command registration (near other commands): +```typescript +program.addCommand(createOpenCodeCommand()); +``` + +**Step 3: Verify TypeScript compiles** + +Run: `yarn build` +Expected: No errors + +**Step 4: Test command appears in help** + +Run: `./bin/happy.mjs --help` +Expected: Should show `opencode` in command list + +**Step 5: Commit** + +```bash +git add src/index.ts +git commit -m "feat(opencode): register opencode command in CLI" +``` + +--- + +## Task 7: Register Agent in Daemon + +**Files:** +- Modify: `src/daemon/run.ts` or wherever agents are registered + +**Step 1: Find where Gemini agent is registered** + +Search for `registerGeminiAgent` in the codebase to find the pattern. + +**Step 2: Add OpenCode registration** + +Add import: +```typescript +import { registerOpenCodeAgent } from '@/agent/acp/opencode'; +``` + +Add registration call near `registerGeminiAgent()`: +```typescript +registerOpenCodeAgent(); +``` + +**Step 3: Verify TypeScript compiles** + +Run: `yarn build` +Expected: No errors + +**Step 4: Commit** + +```bash +git add src/daemon/run.ts +git commit -m "feat(opencode): register OpenCode agent in daemon" +``` + +--- + +## Task 8: Integration Test + +**Files:** +- Test manually (no new test file needed) + +**Step 1: Build the project** + +Run: `yarn build` +Expected: No errors + +**Step 2: Test CLI help** + +Run: `./bin/happy.mjs opencode --help` +Expected: Shows opencode command help with -m, -c, -p options + +**Step 3: Test without OpenCode installed (error case)** + +If OpenCode is NOT installed: +Run: `./bin/happy.mjs opencode` +Expected: Error message with install instructions + +**Step 4: Test with OpenCode installed (if available)** + +If OpenCode IS installed: +Run: `./bin/happy.mjs opencode --model gpt-4o -p "hello"` +Expected: OpenCode starts in ACP mode, processes prompt + +**Step 5: Final commit if needed** + +```bash +git add -A +git commit -m "feat(opencode): complete OpenCode ACP integration" +``` + +--- + +## Summary + +| Task | Description | Files | +|------|-------------|-------| +| 1 | Constants | `src/opencode/constants.ts` | +| 2 | Config utilities | `src/opencode/utils/config.ts` | +| 3 | Backend factory | `src/agent/acp/opencode.ts`, `src/agent/acp/index.ts` | +| 4 | Run entry point | `src/opencode/runOpenCode.ts`, `src/opencode/index.ts` | +| 5 | CLI command | `src/commands/opencode.ts` | +| 6 | Register in main | `src/index.ts` | +| 7 | Register in daemon | `src/daemon/run.ts` | +| 8 | Integration test | Manual testing | From 723500f4642d7b9bb250a94edbca51599c975c15 Mon Sep 17 00:00:00 2001 From: Dmitry Polishuk Date: Sat, 3 Jan 2026 06:28:51 +0300 Subject: [PATCH 04/53] feat(opencode): add OpenCode ACP support Add OpenCode as the second ACP-compatible agent in Happy CLI, enabling remote control from the Happy mobile app. Changes: - Add OpenCode constants and config utilities - Create OpenCode ACP backend factory using AcpSdkBackend - Add runOpenCode entry point with MCP server merging - Register 'happy opencode' command in CLI - Register OpenCode agent in daemon Usage: happy opencode [-m model] [-c cwd] [-p prompt] Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- src/agent/acp/index.ts | 1 + src/agent/acp/opencode.ts | 82 +++++++++++++++++++++++++++++++ src/agent/index.ts | 2 + src/index.ts | 62 ++++++++++++++++++++++++ src/opencode/constants.ts | 18 +++++++ src/opencode/index.ts | 7 +++ src/opencode/runOpenCode.ts | 94 ++++++++++++++++++++++++++++++++++++ src/opencode/utils/config.ts | 94 ++++++++++++++++++++++++++++++++++++ 8 files changed, 360 insertions(+) create mode 100644 src/agent/acp/opencode.ts create mode 100644 src/opencode/constants.ts create mode 100644 src/opencode/index.ts create mode 100644 src/opencode/runOpenCode.ts create mode 100644 src/opencode/utils/config.ts diff --git a/src/agent/acp/index.ts b/src/agent/acp/index.ts index 1b44cd26..0db4a106 100644 --- a/src/agent/acp/index.ts +++ b/src/agent/acp/index.ts @@ -9,4 +9,5 @@ export { AcpSdkBackend, type AcpSdkBackendOptions } from './AcpSdkBackend'; export { createGeminiBackend, registerGeminiAgent, type GeminiBackendOptions } from './gemini'; +export { createOpenCodeBackend, registerOpenCodeAgent, type OpenCodeBackendOptions } from './opencode'; diff --git a/src/agent/acp/opencode.ts b/src/agent/acp/opencode.ts new file mode 100644 index 00000000..b9e09b0f --- /dev/null +++ b/src/agent/acp/opencode.ts @@ -0,0 +1,82 @@ +/** + * OpenCode ACP Backend - OpenCode agent via ACP + * + * This module provides a factory function for creating an OpenCode backend + * that communicates using the Agent Client Protocol (ACP). + * + * OpenCode supports ACP natively via the `opencode acp` command. + */ + +import { AcpSdkBackend, type AcpSdkBackendOptions, type AcpPermissionHandler } from './AcpSdkBackend'; +import type { AgentBackend, McpServerConfig } from '../AgentBackend'; +import { agentRegistry, type AgentFactoryOptions } from '../AgentRegistry'; +import { logger } from '@/ui/logger'; + +/** + * Options for creating an OpenCode ACP backend + */ +export interface OpenCodeBackendOptions extends AgentFactoryOptions { + /** Model to use (passed via --model flag) */ + model?: string; + + /** MCP servers to make available to the agent */ + mcpServers?: Record; + + /** Optional permission handler for tool approval */ + permissionHandler?: AcpPermissionHandler; +} + +/** + * Create an OpenCode backend using ACP. + * + * OpenCode must be installed and available in PATH. + * Uses the `opencode acp` command to enable ACP mode. + * + * @param options - Configuration options + * @returns AgentBackend instance for OpenCode + */ +export function createOpenCodeBackend(options: OpenCodeBackendOptions): AgentBackend { + const command = 'opencode'; + const args = ['acp']; + + // Add model flag if specified + if (options.model) { + args.push('--model', options.model); + } + + // Add working directory + if (options.cwd) { + args.push('--cwd', options.cwd); + } + + const backendOptions: AcpSdkBackendOptions = { + agentName: 'opencode', + cwd: options.cwd, + command, + args, + env: options.env, + mcpServers: options.mcpServers, + permissionHandler: options.permissionHandler, + }; + + logger.debug('[OpenCode] Creating ACP SDK backend with options:', { + cwd: backendOptions.cwd, + command: backendOptions.command, + args: backendOptions.args, + model: options.model, + mcpServerCount: options.mcpServers ? Object.keys(options.mcpServers).length : 0, + }); + + return new AcpSdkBackend(backendOptions); +} + +/** + * Register OpenCode backend with the global agent registry. + * + * This function should be called during application initialization + * to make the OpenCode agent available for use. + */ +export function registerOpenCodeAgent(): void { + agentRegistry.register('opencode', (opts) => createOpenCodeBackend(opts)); + logger.debug('[OpenCode] Registered with agent registry'); +} diff --git a/src/agent/index.ts b/src/agent/index.ts index d3f3f9bc..f144f0c6 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -35,6 +35,8 @@ export * from './acp'; export function initializeAgents(): void { // Import and register agents const { registerGeminiAgent } = require('./acp/gemini'); + const { registerOpenCodeAgent } = require('./acp/opencode'); registerGeminiAgent(); + registerOpenCodeAgent(); } diff --git a/src/index.ts b/src/index.ts index 94c01c0f..01223b03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -238,6 +238,67 @@ import { execFileSync } from 'node:child_process' process.exit(1) } return; + } else if (subcommand === 'opencode') { + // Handle opencode command (ACP-based agent) + try { + const { runOpenCode, isOpenCodeInstalled } = await import('@/opencode'); + + // Check if OpenCode is installed + const installed = await isOpenCodeInstalled(); + if (!installed) { + console.error(chalk.red('Error: OpenCode is not installed or not in PATH.')); + console.error(chalk.yellow('Install with: curl -fsSL https://opencode.ai/install | bash')); + process.exit(1); + } + + // Parse arguments + let model: string | undefined = undefined; + let cwd: string = process.cwd(); + let initialPrompt: string | undefined = undefined; + let startedBy: 'daemon' | 'terminal' | undefined = undefined; + + for (let i = 1; i < args.length; i++) { + if ((args[i] === '-m' || args[i] === '--model') && args[i + 1]) { + model = args[++i]; + } else if ((args[i] === '-c' || args[i] === '--cwd') && args[i + 1]) { + cwd = args[++i]; + } else if ((args[i] === '-p' || args[i] === '--prompt') && args[i + 1]) { + initialPrompt = args[++i]; + } else if (args[i] === '--started-by') { + startedBy = args[++i] as 'daemon' | 'terminal'; + } + } + + const { + credentials + } = await authAndSetupMachineIfNeeded(); + + // Auto-start daemon for opencode (same as claude/gemini) + logger.debug('Ensuring Happy background service is running & matches our version...'); + if (!(await isDaemonRunningCurrentlyInstalledHappyVersion())) { + logger.debug('Starting Happy background service...'); + const daemonProcess = spawnHappyCLI(['daemon', 'start-sync'], { + detached: true, + stdio: 'ignore', + env: process.env + }); + daemonProcess.unref(); + await new Promise(resolve => setTimeout(resolve, 200)); + } + + await runOpenCode({ + cwd, + model, + initialPrompt, + }); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + return; } else if (subcommand === 'logout') { // Keep for backward compatibility - redirect to auth logout console.log(chalk.yellow('Note: "happy logout" is deprecated. Use "happy auth logout" instead.\n')); @@ -444,6 +505,7 @@ ${chalk.bold('Usage:')} happy auth Manage authentication happy codex Start Codex mode happy gemini Start Gemini mode (ACP) + happy opencode Start OpenCode mode (ACP) happy connect Connect AI vendor API keys happy notify Send push notification happy daemon Manage background service that allows diff --git a/src/opencode/constants.ts b/src/opencode/constants.ts new file mode 100644 index 00000000..dfe1b379 --- /dev/null +++ b/src/opencode/constants.ts @@ -0,0 +1,18 @@ +/** + * OpenCode constants - environment variables and defaults + */ + +/** OpenCode config directory (standard XDG location) */ +export const OPENCODE_CONFIG_DIR = '.config/opencode'; + +/** OpenCode config filename */ +export const OPENCODE_CONFIG_FILE = 'config.json'; + +/** Common API key environment variables that OpenCode supports */ +export const OPENCODE_API_KEY_ENVS = [ + 'OPENAI_API_KEY', + 'ANTHROPIC_API_KEY', + 'GOOGLE_API_KEY', + 'GEMINI_API_KEY', + 'GROQ_API_KEY', +] as const; diff --git a/src/opencode/index.ts b/src/opencode/index.ts new file mode 100644 index 00000000..b692bdb4 --- /dev/null +++ b/src/opencode/index.ts @@ -0,0 +1,7 @@ +/** + * OpenCode module - OpenCode integration for Happy CLI + */ + +export { runOpenCode, isOpenCodeInstalled, type RunOpenCodeOptions } from './runOpenCode'; +export { readOpenCodeConfig, getMergedMcpServers } from './utils/config'; +export { OPENCODE_API_KEY_ENVS, OPENCODE_CONFIG_DIR, OPENCODE_CONFIG_FILE } from './constants'; diff --git a/src/opencode/runOpenCode.ts b/src/opencode/runOpenCode.ts new file mode 100644 index 00000000..c4e8b214 --- /dev/null +++ b/src/opencode/runOpenCode.ts @@ -0,0 +1,94 @@ +/** + * OpenCode Runner - Main entry point for running OpenCode with Happy + * + * Orchestrates OpenCode sessions with remote control from Happy mobile app. + */ + +import { createOpenCodeBackend } from '@/agent/acp/opencode'; +import { getMergedMcpServers } from './utils/config'; +import { logger } from '@/ui/logger'; +import type { AcpPermissionHandler } from '@/agent/acp/AcpSdkBackend'; +import type { McpServerConfig } from '@/agent/AgentBackend'; + +/** + * Options for running OpenCode + */ +export interface RunOpenCodeOptions { + /** Working directory */ + cwd: string; + + /** Model to use (e.g., 'claude-sonnet-4-20250514', 'gpt-4o') */ + model?: string; + + /** Initial prompt to send */ + initialPrompt?: string; + + /** MCP servers from Happy config */ + happyMcpServers?: Record; + + /** Permission handler for tool approval */ + permissionHandler?: AcpPermissionHandler; + + /** Environment variables to pass to OpenCode */ + env?: Record; +} + +/** + * Run OpenCode with Happy integration + * + * Creates an OpenCode backend via ACP and manages the session lifecycle. + * Merges MCP servers from both Happy and OpenCode's native config. + * + * @param options - Configuration options + * @returns Promise that resolves when the session ends + */ +export async function runOpenCode(options: RunOpenCodeOptions): Promise { + const { cwd, model, initialPrompt, happyMcpServers, permissionHandler, env } = options; + + logger.debug('[OpenCode] Starting with options:', { + cwd, + model, + hasInitialPrompt: !!initialPrompt, + happyMcpServerCount: happyMcpServers ? Object.keys(happyMcpServers).length : 0, + hasPermissionHandler: !!permissionHandler, + }); + + // Merge MCP servers from OpenCode config and Happy config + const mcpServers = await getMergedMcpServers(happyMcpServers); + + // Create OpenCode backend + const backend = createOpenCodeBackend({ + cwd, + model, + mcpServers, + permissionHandler, + env, + }); + + // Start the session + const { sessionId } = await backend.startSession(initialPrompt); + + logger.debug('[OpenCode] Session started:', sessionId); + + // Return the backend for external management (daemon integration) + // The caller (daemon or CLI) manages the session lifecycle + return; +} + +/** + * Check if OpenCode is installed and available + * + * @returns Promise - true if OpenCode is available + */ +export async function isOpenCodeInstalled(): Promise { + const { exec } = await import('node:child_process'); + const { promisify } = await import('node:util'); + const execAsync = promisify(exec); + + try { + await execAsync('opencode --version'); + return true; + } catch { + return false; + } +} diff --git a/src/opencode/utils/config.ts b/src/opencode/utils/config.ts new file mode 100644 index 00000000..d31441dd --- /dev/null +++ b/src/opencode/utils/config.ts @@ -0,0 +1,94 @@ +/** + * OpenCode configuration utilities + * + * Reads OpenCode's native config from ~/.config/opencode/config.json + * and provides MCP server merging with Happy's config. + */ + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { logger } from '@/ui/logger'; +import { OPENCODE_CONFIG_DIR, OPENCODE_CONFIG_FILE } from '../constants'; +import type { McpServerConfig } from '@/agent/AgentBackend'; + +/** + * OpenCode config.json structure (partial - only what we need) + */ +export interface OpenCodeConfig { + mcpServers?: Record; + }>; +} + +/** + * Read OpenCode's native config file + * + * @returns Parsed config or empty object if not found/invalid + */ +export async function readOpenCodeConfig(): Promise { + const configPath = join(homedir(), OPENCODE_CONFIG_DIR, OPENCODE_CONFIG_FILE); + + try { + const content = await readFile(configPath, 'utf-8'); + const config = JSON.parse(content) as OpenCodeConfig; + logger.debug('[OpenCode] Read config from:', configPath); + return config; + } catch { + // Config not found or invalid - return empty + logger.debug('[OpenCode] No config found at:', configPath); + return {}; + } +} + +/** + * Convert OpenCode MCP server format to Happy's format + */ +export function convertOpenCodeMcpServers( + openCodeServers: OpenCodeConfig['mcpServers'] +): Record { + if (!openCodeServers) return {}; + + const result: Record = {}; + + for (const [name, config] of Object.entries(openCodeServers)) { + result[name] = { + command: config.command, + args: config.args, + env: config.env, + }; + } + + return result; +} + +/** + * Get merged MCP servers from OpenCode config and Happy config + * + * OpenCode's servers are loaded first, then Happy's overlay on top. + * If both define the same server name, Happy's version wins. + * + * @param happyServers - MCP servers from Happy's configuration + * @returns Merged MCP server configuration + */ +export async function getMergedMcpServers( + happyServers?: Record +): Promise> { + const openCodeConfig = await readOpenCodeConfig(); + const openCodeServers = convertOpenCodeMcpServers(openCodeConfig.mcpServers); + + const merged = { + ...openCodeServers, + ...(happyServers ?? {}), // Happy takes precedence + }; + + logger.debug('[OpenCode] Merged MCP servers:', { + fromOpenCode: Object.keys(openCodeServers), + fromHappy: Object.keys(happyServers ?? {}), + merged: Object.keys(merged), + }); + + return merged; +} From c180ab8430ae19ad8218a5185826d7f325aa75c4 Mon Sep 17 00:00:00 2001 From: Dmitry Polishuk Date: Sat, 3 Jan 2026 09:53:42 +0300 Subject: [PATCH 05/53] fix(opencode): handle model via config.json instead of --model flag The `opencode acp` command doesn't support --model flag. Instead, we write the model to ~/.config/opencode/config.json before spawning OpenCode, then restore the original model after the session. Changes: - Add model field to OpenCodeConfig interface - Add readOpenCodeModel/writeOpenCodeModel functions - Remove --model flag from opencode acp command args - Write model to config before spawning, restore after - Update documentation and comments Fixes issue where `happy opencode --model ` would fail because the flag was passed to a command that doesn't support it. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- src/agent/acp/opencode.ts | 17 ++++----- src/opencode/index.ts | 2 +- src/opencode/runOpenCode.ts | 74 ++++++++++++++++++++++++++---------- src/opencode/utils/config.ts | 48 ++++++++++++++++++++++- 4 files changed, 109 insertions(+), 32 deletions(-) diff --git a/src/agent/acp/opencode.ts b/src/agent/acp/opencode.ts index b9e09b0f..55872858 100644 --- a/src/agent/acp/opencode.ts +++ b/src/agent/acp/opencode.ts @@ -16,7 +16,7 @@ import { logger } from '@/ui/logger'; * Options for creating an OpenCode ACP backend */ export interface OpenCodeBackendOptions extends AgentFactoryOptions { - /** Model to use (passed via --model flag) */ + /** Model to use (written to config.json before spawning) */ model?: string; /** MCP servers to make available to the agent */ @@ -32,6 +32,9 @@ export interface OpenCodeBackendOptions extends AgentFactoryOptions { * OpenCode must be installed and available in PATH. * Uses the `opencode acp` command to enable ACP mode. * + * Note: Model is set via ~/.config/opencode/config.json, not via command line. + * The `opencode acp` command does not support --model flag. + * * @param options - Configuration options * @returns AgentBackend instance for OpenCode */ @@ -39,15 +42,9 @@ export function createOpenCodeBackend(options: OpenCodeBackendOptions): AgentBac const command = 'opencode'; const args = ['acp']; - // Add model flag if specified - if (options.model) { - args.push('--model', options.model); - } - - // Add working directory - if (options.cwd) { - args.push('--cwd', options.cwd); - } + // Note: We don't pass --model flag because `opencode acp` doesn't support it. + // Model should be set via ~/.config/opencode/config.json before spawning. + // The model option is kept for API compatibility but handling is done by the caller. const backendOptions: AcpSdkBackendOptions = { agentName: 'opencode', diff --git a/src/opencode/index.ts b/src/opencode/index.ts index b692bdb4..3de439d2 100644 --- a/src/opencode/index.ts +++ b/src/opencode/index.ts @@ -3,5 +3,5 @@ */ export { runOpenCode, isOpenCodeInstalled, type RunOpenCodeOptions } from './runOpenCode'; -export { readOpenCodeConfig, getMergedMcpServers } from './utils/config'; +export { readOpenCodeConfig, getMergedMcpServers, readOpenCodeModel, writeOpenCodeModel } from './utils/config'; export { OPENCODE_API_KEY_ENVS, OPENCODE_CONFIG_DIR, OPENCODE_CONFIG_FILE } from './constants'; diff --git a/src/opencode/runOpenCode.ts b/src/opencode/runOpenCode.ts index c4e8b214..c8eecd11 100644 --- a/src/opencode/runOpenCode.ts +++ b/src/opencode/runOpenCode.ts @@ -5,7 +5,7 @@ */ import { createOpenCodeBackend } from '@/agent/acp/opencode'; -import { getMergedMcpServers } from './utils/config'; +import { getMergedMcpServers, readOpenCodeModel, writeOpenCodeModel } from './utils/config'; import { logger } from '@/ui/logger'; import type { AcpPermissionHandler } from '@/agent/acp/AcpSdkBackend'; import type { McpServerConfig } from '@/agent/AgentBackend'; @@ -39,6 +39,9 @@ export interface RunOpenCodeOptions { * Creates an OpenCode backend via ACP and manages the session lifecycle. * Merges MCP servers from both Happy and OpenCode's native config. * + * Model handling: If a model is specified, it writes to ~/.config/opencode/config.json + * before spawning OpenCode, then restores the original model after the session ends. + * * @param options - Configuration options * @returns Promise that resolves when the session ends */ @@ -53,26 +56,57 @@ export async function runOpenCode(options: RunOpenCodeOptions): Promise { hasPermissionHandler: !!permissionHandler, }); - // Merge MCP servers from OpenCode config and Happy config - const mcpServers = await getMergedMcpServers(happyMcpServers); - - // Create OpenCode backend - const backend = createOpenCodeBackend({ - cwd, - model, - mcpServers, - permissionHandler, - env, - }); + // Save original model and set new model if specified + const originalModel = await readOpenCodeModel(); + let modelRestored = false; - // Start the session - const { sessionId } = await backend.startSession(initialPrompt); - - logger.debug('[OpenCode] Session started:', sessionId); - - // Return the backend for external management (daemon integration) - // The caller (daemon or CLI) manages the session lifecycle - return; + try { + if (model) { + logger.debug('[OpenCode] Setting model in config:', model); + await writeOpenCodeModel(model); + } + + // Merge MCP servers from OpenCode config and Happy config + const mcpServers = await getMergedMcpServers(happyMcpServers); + + // Create OpenCode backend + const backend = createOpenCodeBackend({ + cwd, + model, // Passed for logging but not used in command args + mcpServers, + permissionHandler, + env, + }); + + // Start the session + const { sessionId } = await backend.startSession(initialPrompt); + + logger.debug('[OpenCode] Session started:', sessionId); + + // Return the backend for external management (daemon integration) + // The caller (daemon or CLI) manages the session lifecycle + return; + } finally { + // Restore original model if we changed it + if (model && originalModel !== undefined) { + try { + await writeOpenCodeModel(originalModel); + modelRestored = true; + logger.debug('[OpenCode] Restored original model:', originalModel); + } catch (error) { + logger.warn('[OpenCode] Failed to restore original model:', error); + } + } else if (model && originalModel === undefined) { + // If there was no original model, try to remove the model key from config + try { + // This is best-effort - if we can't remove it, it's not critical + // The next run with --model will overwrite it anyway + logger.debug('[OpenCode] Model was newly set, leaving in config for future use'); + } catch { + // Ignore + } + } + } } /** diff --git a/src/opencode/utils/config.ts b/src/opencode/utils/config.ts index d31441dd..fbfe8ed3 100644 --- a/src/opencode/utils/config.ts +++ b/src/opencode/utils/config.ts @@ -5,7 +5,7 @@ * and provides MCP server merging with Happy's config. */ -import { readFile } from 'node:fs/promises'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { logger } from '@/ui/logger'; @@ -16,6 +16,7 @@ import type { McpServerConfig } from '@/agent/AgentBackend'; * OpenCode config.json structure (partial - only what we need) */ export interface OpenCodeConfig { + model?: string; mcpServers?: Record { + const config = await readOpenCodeConfig(); + return config.model; +} + +/** + * Write model to OpenCode config + * Creates config directory and file if they don't exist + * + * @param model - Model to set (e.g., 'anthropic/claude-sonnet-4-20250514') + */ +export async function writeOpenCodeModel(model: string): Promise { + const configDir = join(homedir(), OPENCODE_CONFIG_DIR); + const configPath = join(configDir, OPENCODE_CONFIG_FILE); + + try { + // Ensure config directory exists + await mkdir(configDir, { recursive: true }); + + // Read existing config or create new + let config: OpenCodeConfig = {}; + try { + const content = await readFile(configPath, 'utf-8'); + config = JSON.parse(content) as OpenCodeConfig; + } catch { + // Config doesn't exist or is invalid, start fresh + } + + // Update model + config.model = model; + + // Write back + await writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8'); + logger.debug('[OpenCode] Wrote model to config:', { model, configPath }); + } catch (error) { + logger.warn('[OpenCode] Failed to write model to config:', error); + throw error; + } +} From 0956b31cb996f41d31fe150bca6493d992add9d0 Mon Sep 17 00:00:00 2001 From: Dmitry Polishuk Date: Sat, 3 Jan 2026 11:26:28 +0300 Subject: [PATCH 06/53] feat: complete OpenCode ACP integration with full lifecycle management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implemented complete runOpenCode.ts based on runGemini.ts pattern (680 lines) - Added OpenCodeDisplay Ink UI component for terminal interface - Added OpenCodePermissionHandler for tool approval management - Added OpenCode types (PermissionMode, OpenCodeMode, CodexMessagePayload) - Fixed model handling to use config.json instead of CLI flag - Session lifecycle: main loop, abort handling, cleanup on exit - Message queue processing with mode hashing - Happy MCP server integration and startup - Mobile app integration (session events, codex messages) - TTY detection and Ink UI rendering - Permission mode switching (default, read-only, safe-yolo, yolo) 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- src/index.ts | 1 + src/opencode/index.ts | 2 + src/opencode/runOpenCode.ts | 680 +++++++++++++++++++++--- src/opencode/types.ts | 34 ++ src/opencode/utils/permissionHandler.ts | 116 ++++ src/ui/ink/OpenCodeDisplay.tsx | 234 ++++++++ 6 files changed, 986 insertions(+), 81 deletions(-) create mode 100644 src/opencode/types.ts create mode 100644 src/opencode/utils/permissionHandler.ts create mode 100644 src/ui/ink/OpenCodeDisplay.tsx diff --git a/src/index.ts b/src/index.ts index 01223b03..b5b88e37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -287,6 +287,7 @@ import { execFileSync } from 'node:child_process' } await runOpenCode({ + credentials, cwd, model, initialPrompt, diff --git a/src/opencode/index.ts b/src/opencode/index.ts index 3de439d2..e552b97e 100644 --- a/src/opencode/index.ts +++ b/src/opencode/index.ts @@ -5,3 +5,5 @@ export { runOpenCode, isOpenCodeInstalled, type RunOpenCodeOptions } from './runOpenCode'; export { readOpenCodeConfig, getMergedMcpServers, readOpenCodeModel, writeOpenCodeModel } from './utils/config'; export { OPENCODE_API_KEY_ENVS, OPENCODE_CONFIG_DIR, OPENCODE_CONFIG_FILE } from './constants'; +export { OpenCodePermissionHandler } from './utils/permissionHandler'; +export type { PermissionMode, OpenCodeMode, CodexMessagePayload } from './types'; diff --git a/src/opencode/runOpenCode.ts b/src/opencode/runOpenCode.ts index c8eecd11..9b752070 100644 --- a/src/opencode/runOpenCode.ts +++ b/src/opencode/runOpenCode.ts @@ -1,111 +1,615 @@ /** - * OpenCode Runner - Main entry point for running OpenCode with Happy + * OpenCode CLI Entry Point * - * Orchestrates OpenCode sessions with remote control from Happy mobile app. + * This module provides the main entry point for running the OpenCode agent + * through Happy CLI. It manages the agent lifecycle, session state, and + * communication with the Happy server and mobile app. + * + * Based on the Gemini implementation but simplified for OpenCode. */ -import { createOpenCodeBackend } from '@/agent/acp/opencode'; -import { getMergedMcpServers, readOpenCodeModel, writeOpenCodeModel } from './utils/config'; +import { render } from 'ink'; +import React from 'react'; +import { randomUUID } from 'node:crypto'; +import os from 'node:os'; +import { join, resolve } from 'node:path'; + +import { ApiClient } from '@/api/api'; import { logger } from '@/ui/logger'; -import type { AcpPermissionHandler } from '@/agent/acp/AcpSdkBackend'; -import type { McpServerConfig } from '@/agent/AgentBackend'; +import { Credentials, readSettings } from '@/persistence'; +import { AgentState, Metadata } from '@/api/types'; +import { initialMachineMetadata } from '@/daemon/run'; +import { configuration } from '@/configuration'; +import packageJson from '../../package.json'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { hashObject } from '@/utils/deterministicJson'; +import { projectPath } from '@/projectPath'; +import { startHappyServer } from '@/claude/utils/startHappyServer'; +import { MessageBuffer } from '@/ui/ink/messageBuffer'; +import { notifyDaemonSessionStarted } from '@/daemon/controlClient'; +import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; +import { stopCaffeinate } from '@/utils/caffeinate'; + +import { createOpenCodeBackend } from '@/agent/acp/opencode'; +import type { AgentBackend, AgentMessage } from '@/agent/AgentBackend'; +import { OpenCodeDisplay } from '@/ui/ink/OpenCodeDisplay'; +import { OpenCodePermissionHandler } from '@/opencode/utils/permissionHandler'; +import type { PermissionMode, OpenCodeMode, CodexMessagePayload } from '@/opencode/types'; +import { readOpenCodeModel, writeOpenCodeModel } from './utils/config'; /** - * Options for running OpenCode + * Main entry point for the opencode command with ink UI */ -export interface RunOpenCodeOptions { - /** Working directory */ - cwd: string; - - /** Model to use (e.g., 'claude-sonnet-4-20250514', 'gpt-4o') */ +export async function runOpenCode(opts: { + credentials: Credentials; + startedBy?: 'daemon' | 'terminal'; + cwd?: string; model?: string; - - /** Initial prompt to send */ initialPrompt?: string; +}): Promise { + // + // Define session + // - /** MCP servers from Happy config */ - happyMcpServers?: Record; + const sessionTag = randomUUID(); + const api = await ApiClient.create(opts.credentials); - /** Permission handler for tool approval */ - permissionHandler?: AcpPermissionHandler; - - /** Environment variables to pass to OpenCode */ - env?: Record; -} + // + // Machine + // -/** - * Run OpenCode with Happy integration - * - * Creates an OpenCode backend via ACP and manages the session lifecycle. - * Merges MCP servers from both Happy and OpenCode's native config. - * - * Model handling: If a model is specified, it writes to ~/.config/opencode/config.json - * before spawning OpenCode, then restores the original model after the session ends. - * - * @param options - Configuration options - * @returns Promise that resolves when the session ends - */ -export async function runOpenCode(options: RunOpenCodeOptions): Promise { - const { cwd, model, initialPrompt, happyMcpServers, permissionHandler, env } = options; - - logger.debug('[OpenCode] Starting with options:', { - cwd, - model, - hasInitialPrompt: !!initialPrompt, - happyMcpServerCount: happyMcpServers ? Object.keys(happyMcpServers).length : 0, - hasPermissionHandler: !!permissionHandler, + const settings = await readSettings(); + const machineId = settings?.machineId; + if (!machineId) { + console.error(`[START] No machine ID found in settings, which is unexpected since authAndSetupMachineIfNeeded should have created it. Please report this issue on https://github.com/slopus/happy-cli/issues`); + process.exit(1); + } + logger.debug(`Using machineId: ${machineId}`); + await api.getOrCreateMachine({ + machineId, + metadata: initialMachineMetadata }); - // Save original model and set new model if specified - const originalModel = await readOpenCodeModel(); - let modelRestored = false; + // + // Create session + // + const state: AgentState = { + controlledByUser: false, + }; + const metadata: Metadata = { + path: opts.cwd || process.cwd(), + host: os.hostname(), + version: packageJson.version, + os: os.platform(), + machineId: machineId, + homeDir: os.homedir(), + happyHomeDir: configuration.happyHomeDir, + happyLibDir: projectPath(), + happyToolsDir: resolve(projectPath(), 'tools', 'unpacked'), + startedFromDaemon: opts.startedBy === 'daemon', + hostPid: process.pid, + startedBy: opts.startedBy || 'terminal', + lifecycleState: 'running', + lifecycleStateSince: Date.now(), + flavor: 'opencode' + }; + const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); + const session = api.sessionSyncClient(response); + + // Report to daemon try { - if (model) { - logger.debug('[OpenCode] Setting model in config:', model); - await writeOpenCodeModel(model); + logger.debug(`[START] Reporting session ${response.id} to daemon`); + const result = await notifyDaemonSessionStarted(response.id, metadata); + if (result.error) { + logger.debug(`[START] Failed to report to daemon (may not be running):`, result.error); + } else { + logger.debug(`[START] Reported session ${response.id} to daemon`); + } + } catch (error) { + logger.debug('[START] Failed to report to daemon (may not be running):', error); + } + + const messageQueue = new MessageQueue2((mode) => hashObject({ + permissionMode: mode.permissionMode, + model: mode.model, + })); + + // Track current overrides to apply per message + let currentPermissionMode: PermissionMode | undefined = undefined; + let currentModel: string | undefined = opts.model; + + session.onUserMessage((message) => { + // Resolve permission mode + let messagePermissionMode = currentPermissionMode; + if (message.meta?.permissionMode) { + const validModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; + if (validModes.includes(message.meta.permissionMode as PermissionMode)) { + messagePermissionMode = message.meta.permissionMode as PermissionMode; + currentPermissionMode = messagePermissionMode; + updatePermissionMode(messagePermissionMode); + logger.debug(`[OpenCode] Permission mode updated from user message to: ${currentPermissionMode}`); + } + } + + if (currentPermissionMode === undefined) { + currentPermissionMode = 'default'; + updatePermissionMode('default'); + } + + // Resolve model + let messageModel = currentModel; + if (message.meta?.hasOwnProperty('model')) { + if (message.meta.model === null) { + messageModel = undefined; + currentModel = undefined; + } else if (message.meta.model) { + messageModel = message.meta.model; + currentModel = messageModel; + messageBuffer.addMessage(`Model changed to: ${messageModel}`, 'system'); + } + } + + const mode: OpenCodeMode = { + permissionMode: messagePermissionMode || 'default', + model: messageModel, + }; + messageQueue.push(message.content.text, mode); + }); + + let thinking = false; + session.keepAlive(thinking, 'remote'); + const keepAliveInterval = setInterval(() => { + session.keepAlive(thinking, 'remote'); + }, 2000); + + const sendReady = () => { + session.sendSessionEvent({ type: 'ready' }); + try { + api.push().sendToAllDevices( + "It's ready!", + 'OpenCode is waiting for your command', + { sessionId: session.sessionId } + ); + } catch (pushError) { + logger.debug('[OpenCode] Failed to send ready push', pushError); } + }; - // Merge MCP servers from OpenCode config and Happy config - const mcpServers = await getMergedMcpServers(happyMcpServers); + const emitReadyIfIdle = (): boolean => { + if (shouldExit) return false; + if (thinking) return false; + if (isResponseInProgress) return false; + if (messageQueue.size() > 0) return false; + + sendReady(); + return true; + }; - // Create OpenCode backend - const backend = createOpenCodeBackend({ - cwd, - model, // Passed for logging but not used in command args - mcpServers, - permissionHandler, - env, + // + // Abort handling + // + + let abortController = new AbortController(); + let shouldExit = false; + let opencodeBackend: AgentBackend | null = null; + let acpSessionId: string | null = null; + let wasSessionCreated = false; + + async function handleAbort() { + logger.debug('[OpenCode] Abort requested - stopping current task'); + + session.sendCodexMessage({ + type: 'turn_aborted', + id: randomUUID(), }); - // Start the session - const { sessionId } = await backend.startSession(initialPrompt); + try { + abortController.abort(); + messageQueue.reset(); + if (opencodeBackend && acpSessionId) { + await opencodeBackend.cancel(acpSessionId); + } + logger.debug('[OpenCode] Abort completed - session remains active'); + } catch (error) { + logger.debug('[OpenCode] Error during abort:', error); + } finally { + abortController = new AbortController(); + } + } - logger.debug('[OpenCode] Session started:', sessionId); + const handleKillSession = async () => { + logger.debug('[OpenCode] Kill session requested - terminating process'); + await handleAbort(); - // Return the backend for external management (daemon integration) - // The caller (daemon or CLI) manages the session lifecycle - return; - } finally { - // Restore original model if we changed it - if (model && originalModel !== undefined) { - try { - await writeOpenCodeModel(originalModel); - modelRestored = true; - logger.debug('[OpenCode] Restored original model:', originalModel); - } catch (error) { - logger.warn('[OpenCode] Failed to restore original model:', error); + try { + if (session) { + session.updateMetadata((currentMetadata) => ({ + ...currentMetadata, + lifecycleState: 'archived', + lifecycleStateSince: Date.now(), + archivedBy: 'cli', + archiveReason: 'User terminated' + })); + + session.sendSessionDeath(); + await session.flush(); + await session.close(); } - } else if (model && originalModel === undefined) { - // If there was no original model, try to remove the model key from config - try { - // This is best-effort - if we can't remove it, it's not critical - // The next run with --model will overwrite it anyway - logger.debug('[OpenCode] Model was newly set, leaving in config for future use'); - } catch { - // Ignore + + stopCaffeinate(); + happyServer.stop(); + + if (opencodeBackend) { + await opencodeBackend.dispose(); } + + logger.debug('[OpenCode] Session termination complete, exiting'); + process.exit(0); + } catch (error) { + logger.debug('[OpenCode] Error during session termination:', error); + process.exit(1); } + }; + + session.rpcHandlerManager.registerHandler('abort', handleAbort); + registerKillSessionHandler(session.rpcHandlerManager, handleKillSession); + + // + // Initialize Ink UI + // + + const messageBuffer = new MessageBuffer(); + const hasTTY = process.stdout.isTTY && process.stdin.isTTY; + let inkInstance: ReturnType | null = null; + + // Track current model for UI display + let displayedModel: string | undefined = currentModel; + + const updateDisplayedModel = (model: string | undefined) => { + if (model === undefined) return; + displayedModel = model; + if (hasTTY) { + messageBuffer.addMessage(`[MODEL:${model}]`, 'system'); + } + }; + + if (hasTTY) { + console.clear(); + const DisplayComponent = () => { + const currentModelValue = displayedModel || 'opencode'; + return React.createElement(OpenCodeDisplay, { + messageBuffer, + logPath: process.env.DEBUG ? logger.getLogPath() : undefined, + currentModel: currentModelValue, + onExit: async () => { + logger.debug('[opencode]: Exiting agent via Ctrl-C'); + shouldExit = true; + await handleAbort(); + } + }); + }; + + inkInstance = render(React.createElement(DisplayComponent), { + exitOnCtrlC: false, + patchConsole: false + }); + + const initialModelName = displayedModel || 'opencode'; + messageBuffer.addMessage(`[MODEL:${initialModelName}]`, 'system'); + } + + if (hasTTY) { + process.stdin.resume(); + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.setEncoding('utf8'); + } + + // + // Start Happy MCP server and create OpenCode backend + // + + const happyServer = await startHappyServer(session); + const bridgeCommand = join(projectPath(), 'bin', 'happy-mcp.mjs'); + const mcpServers = { + happy: { + command: bridgeCommand, + args: ['--url', happyServer.url] + } + }; + + // Create permission handler for tool approval + const permissionHandler = new OpenCodePermissionHandler(session); + + const updatePermissionMode = (mode: PermissionMode) => { + permissionHandler.setPermissionMode(mode); + }; + + // Accumulate OpenCode response text + let accumulatedResponse = ''; + let isResponseInProgress = false; + + /** + * Set up message handler for OpenCode backend + */ + function setupOpenCodeMessageHandler(backend: AgentBackend): void { + backend.onMessage((msg: AgentMessage) => { + switch (msg.type) { + case 'model-output': + if (msg.textDelta) { + if (!isResponseInProgress) { + messageBuffer.removeLastMessage('system'); + messageBuffer.addMessage(msg.textDelta, 'assistant'); + isResponseInProgress = true; + } else { + messageBuffer.updateLastMessage(msg.textDelta, 'assistant'); + } + accumulatedResponse += msg.textDelta; + } + break; + + case 'status': + logger.debug(`[opencode] Status changed: ${msg.status}${msg.detail ? ` - ${msg.detail}` : ''}`); + + if (msg.status === 'error') { + logger.debug(`[opencode] Error status received: ${msg.detail || 'Unknown error'}`); + session.sendCodexMessage({ + type: 'turn_aborted', + id: randomUUID(), + }); + } + + if (msg.status === 'running') { + thinking = true; + session.keepAlive(thinking, 'remote'); + session.sendCodexMessage({ + type: 'task_started', + id: randomUUID(), + }); + messageBuffer.addMessage('Thinking...', 'system'); + } else if (msg.status === 'idle' || msg.status === 'stopped') { + thinking = false; + session.keepAlive(thinking, 'remote'); + + if (isResponseInProgress && accumulatedResponse.trim()) { + const messageId = randomUUID(); + const messagePayload: CodexMessagePayload = { + type: 'message', + message: accumulatedResponse, + id: messageId, + }; + session.sendCodexMessage(messagePayload); + accumulatedResponse = ''; + isResponseInProgress = false; + } + } else if (msg.status === 'error') { + thinking = false; + session.keepAlive(thinking, 'remote'); + accumulatedResponse = ''; + isResponseInProgress = false; + + const errorMessage = msg.detail || 'Unknown error'; + messageBuffer.addMessage(`Error: ${errorMessage}`, 'status'); + session.sendCodexMessage({ + type: 'message', + message: `Error: ${errorMessage}`, + id: randomUUID(), + }); + } + break; + + case 'tool-call': + const toolArgs = msg.args ? JSON.stringify(msg.args).substring(0, 100) : ''; + logger.debug(`[opencode] Tool call received: ${msg.toolName} (${msg.callId})`); + messageBuffer.addMessage(`Executing: ${msg.toolName}${toolArgs ? ` ${toolArgs}...` : ''}`, 'tool'); + session.sendCodexMessage({ + type: 'tool-call', + name: msg.toolName, + callId: msg.callId, + input: msg.args, + id: randomUUID(), + }); + break; + + case 'tool-result': + const isError = msg.result && typeof msg.result === 'object' && 'error' in msg.result; + const resultText = typeof msg.result === 'string' + ? msg.result.substring(0, 200) + : JSON.stringify(msg.result).substring(0, 200); + const truncatedResult = resultText + (typeof msg.result === 'string' && msg.result.length > 200 ? '...' : ''); + + logger.debug(`[opencode] ${isError ? '❌' : '✅'} Tool result received: ${msg.toolName} (${msg.callId})`); + + if (isError) { + const errorMsg = (msg.result as any).error || 'Tool call failed'; + messageBuffer.addMessage(`Error: ${errorMsg}`, 'status'); + } else { + messageBuffer.addMessage(`Result: ${truncatedResult}`, 'result'); + } + + session.sendCodexMessage({ + type: 'tool-call-result', + callId: msg.callId, + output: msg.result, + id: randomUUID(), + }); + break; + + case 'permission-request': + session.sendCodexMessage({ + type: 'permission-request', + permissionId: msg.id, + reason: msg.reason, + payload: msg.payload, + id: randomUUID(), + }); + break; + + default: + // Handle other message types + break; + } + }); + } + + let first = true; + + try { + let currentModeHash: string | null = null; + let pending: { message: string; mode: OpenCodeMode; isolate: boolean; hash: string } | null = null; + + // Save original model and set new model if specified + const originalModel = await readOpenCodeModel(); + + try { + if (currentModel) { + logger.debug('[OpenCode] Setting model in config:', currentModel); + await writeOpenCodeModel(currentModel); + } + + // Main loop + while (!shouldExit) { + let message: { message: string; mode: OpenCodeMode; isolate: boolean; hash: string } | null = pending; + pending = null; + + if (!message) { + logger.debug('[opencode] Main loop: waiting for messages from queue...'); + const waitSignal = abortController.signal; + const batch = await messageQueue.waitForMessagesAndGetAsString(waitSignal); + if (!batch) { + if (waitSignal.aborted && !shouldExit) { + logger.debug('[opencode] Main loop: wait aborted, continuing...'); + continue; + } + logger.debug('[opencode] Main loop: no batch received, breaking...'); + break; + } + logger.debug(`[opencode] Main loop: received message from queue (length: ${batch.message.length})`); + message = batch; + } + + if (!message) { + break; + } + + currentModeHash = message.hash; + const userMessageToShow = message.message; + messageBuffer.addMessage(userMessageToShow, 'user'); + + try { + if (first || !wasSessionCreated) { + if (!opencodeBackend) { + opencodeBackend = createOpenCodeBackend({ + cwd: opts.cwd || process.cwd(), + mcpServers, + permissionHandler, + model: message.mode.model, + }); + + setupOpenCodeMessageHandler(opencodeBackend); + } + + if (!acpSessionId) { + logger.debug('[opencode] Starting ACP session...'); + updatePermissionMode(message.mode.permissionMode || 'default'); + const { sessionId } = await opencodeBackend.startSession(); + acpSessionId = sessionId; + logger.debug(`[opencode] ACP session started: ${acpSessionId}`); + wasSessionCreated = true; + currentModeHash = message.hash; + } + } + + if (!acpSessionId) { + throw new Error('ACP session not started'); + } + + accumulatedResponse = ''; + isResponseInProgress = false; + + if (!opencodeBackend || !acpSessionId) { + throw new Error('OpenCode backend or session not initialized'); + } + + const promptToSend = message.message; + logger.debug(`[opencode] Sending prompt (length: ${promptToSend.length})`); + await opencodeBackend.sendPrompt(acpSessionId, promptToSend); + logger.debug('[opencode] Prompt sent successfully'); + + if (first) { + first = false; + } + } catch (error) { + logger.debug('[opencode] Error in opencode session:', error); + const isAbortError = error instanceof Error && error.name === 'AbortError'; + + if (isAbortError) { + messageBuffer.addMessage('Aborted by user', 'status'); + session.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); + } else { + let errorMsg = 'Process error occurred'; + if (error instanceof Error) { + errorMsg = error.message; + } + messageBuffer.addMessage(errorMsg, 'status'); + session.sendCodexMessage({ + type: 'message', + message: errorMsg, + id: randomUUID(), + }); + } + } finally { + permissionHandler.reset(); + thinking = false; + session.keepAlive(thinking, 'remote'); + emitReadyIfIdle(); + } + } + } finally { + // Restore original model if we changed it + if (currentModel && originalModel !== undefined) { + try { + await writeOpenCodeModel(originalModel); + logger.debug('[OpenCode] Restored original model:', originalModel); + } catch (error) { + logger.warn('[OpenCode] Failed to restore original model:', error); + } + } + } + + } finally { + // Clean up resources + logger.debug('[opencode]: Final cleanup start'); + try { + session.sendSessionDeath(); + await session.flush(); + await session.close(); + } catch (e) { + logger.debug('[opencode]: Error while closing session', e); + } + + if (opencodeBackend) { + await opencodeBackend.dispose(); + } + + happyServer.stop(); + + if (process.stdin.isTTY) { + try { process.stdin.setRawMode(false); } catch { /* ignore */ } + } + if (hasTTY) { + try { process.stdin.pause(); } catch { /* ignore */ } + } + + clearInterval(keepAliveInterval); + if (inkInstance) { + inkInstance.unmount(); + } + messageBuffer.clear(); + + logger.debug('[opencode]: Final cleanup completed'); } } @@ -126,3 +630,17 @@ export async function isOpenCodeInstalled(): Promise { return false; } } + +/** + * Options for running OpenCode (legacy, for backward compatibility) + */ +export interface RunOpenCodeOptions { + /** Working directory */ + cwd: string; + + /** Model to use (e.g., 'anthropic/claude-sonnet-4-20250514', 'gpt-4o') */ + model?: string; + + /** Initial prompt to send */ + initialPrompt?: string; +} diff --git a/src/opencode/types.ts b/src/opencode/types.ts new file mode 100644 index 00000000..0e31d5d7 --- /dev/null +++ b/src/opencode/types.ts @@ -0,0 +1,34 @@ +/** + * OpenCode Types + * + * Type definitions for OpenCode agent integration + */ + +/** + * Permission modes for OpenCode tool approval + */ +export type PermissionMode = 'default' | 'read-only' | 'safe-yolo' | 'yolo'; + +/** + * OpenCode mode configuration for message queue + */ +export interface OpenCodeMode { + /** Permission mode for tool approval */ + permissionMode?: PermissionMode; + + /** Model to use (e.g., 'anthropic/claude-sonnet-4-20250514') */ + model?: string; +} + +/** + * Codex-compatible message payload for mobile app communication + */ +export interface CodexMessagePayload { + type: 'message'; + message: string; + id: string; + options?: Array<{ + optionId: string; + name: string; + }>; +} diff --git a/src/opencode/utils/permissionHandler.ts b/src/opencode/utils/permissionHandler.ts new file mode 100644 index 00000000..092b2c38 --- /dev/null +++ b/src/opencode/utils/permissionHandler.ts @@ -0,0 +1,116 @@ +/** + * OpenCode Permission Handler + * + * Handles tool permission requests for OpenCode agent. + * Based on GeminiPermissionHandler. + */ + +import { ApiSessionClient } from '@/api/apiSession'; +import type { PermissionMode } from '../types'; + +export class OpenCodePermissionHandler { + private permissionMode: PermissionMode = 'default'; + private pendingApprovals = new Map void; + toolName: string; + input: unknown; + }>(); + + constructor(private session: ApiSessionClient) {} + + setPermissionMode(mode: PermissionMode): void { + this.permissionMode = mode; + } + + getPermissionMode(): PermissionMode { + return this.permissionMode; + } + + /** + * Handle a tool permission request + */ + async handleToolCall( + toolCallId: string, + toolName: string, + input: unknown + ): Promise<{ decision: 'approved' | 'approved_for_session' | 'denied' | 'abort' }> { + // Determine approval based on permission mode + switch (this.permissionMode) { + case 'yolo': + case 'safe-yolo': + // Auto-approve all in yolo mode + return { decision: 'approved_for_session' }; + + case 'read-only': + // Deny any tool that modifies state + if (this.isWriteTool(toolName)) { + return { decision: 'denied' }; + } + return { decision: 'approved' }; + + case 'default': + default: + // Wait for mobile approval + return new Promise((resolve) => { + this.pendingApprovals.set(toolCallId, { + resolve, + toolName, + input, + }); + + // Send permission request to mobile + this.session.sendCodexMessage({ + type: 'permission-request', + permissionId: toolCallId, + reason: `Tool "${toolName}" requires approval`, + payload: input, + id: toolCallId, + }); + }); + } + } + + /** + * Handle permission response from mobile + */ + handlePermissionResponse( + toolCallId: string, + decision: 'approved' | 'denied' | 'abort' + ): void { + const pending = this.pendingApprovals.get(toolCallId); + if (pending) { + this.pendingApprovals.delete(toolCallId); + pending.resolve({ decision }); + } + } + + /** + * Reset pending approvals (e.g., after abort) + */ + reset(): void { + // Reject all pending approvals + for (const [toolCallId, pending] of this.pendingApprovals) { + pending.resolve({ decision: 'abort' }); + } + this.pendingApprovals.clear(); + } + + /** + * Check if a tool is a "write" tool (modifies state) + */ + private isWriteTool(toolName: string): boolean { + const readTools = [ + 'read_file', + 'list_directory', + 'search', + 'codebase_search', + 'diagnostics', + 'completions', + 'definition', + 'hover', + 'codebase_investigator', + ]; + + return !readTools.some(rt => toolName.includes(rt) || rt.includes(toolName)); + } +} diff --git a/src/ui/ink/OpenCodeDisplay.tsx b/src/ui/ink/OpenCodeDisplay.tsx new file mode 100644 index 00000000..deb4ee76 --- /dev/null +++ b/src/ui/ink/OpenCodeDisplay.tsx @@ -0,0 +1,234 @@ +/** + * OpenCodeDisplay - Ink UI component for OpenCode agent + * + * This component provides a terminal UI for the OpenCode agent, + * displaying messages, status, and handling user input. + * + * Based on GeminiDisplay but adapted for OpenCode. + */ + +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { Box, Text, useStdout, useInput } from 'ink'; +import { MessageBuffer, type BufferedMessage } from './messageBuffer'; + +interface OpenCodeDisplayProps { + messageBuffer: MessageBuffer; + logPath?: string; + currentModel?: string; + onExit?: () => void; +} + +export const OpenCodeDisplay: React.FC = ({ messageBuffer, logPath, currentModel, onExit }) => { + const [messages, setMessages] = useState([]); + const [confirmationMode, setConfirmationMode] = useState(false); + const [actionInProgress, setActionInProgress] = useState(false); + const [model, setModel] = useState(currentModel); + const confirmationTimeoutRef = useRef(null); + const { stdout } = useStdout(); + const terminalWidth = stdout.columns || 80; + const terminalHeight = stdout.rows || 24; + + // Update model when prop changes (only if different to avoid loops) + useEffect(() => { + if (currentModel !== undefined && currentModel !== model) { + setModel(currentModel); + } + }, [currentModel]); // Only depend on currentModel, not model, to avoid loops + + useEffect(() => { + setMessages(messageBuffer.getMessages()); + + const unsubscribe = messageBuffer.onUpdate((newMessages) => { + setMessages(newMessages); + + // Extract model from [MODEL:...] messages when messages update + const modelMessage = newMessages.find(msg => + msg.type === 'system' && msg.content.startsWith('[MODEL:') + ); + + if (modelMessage) { + const modelMatch = modelMessage.content.match(/\[MODEL:(.+?)\]/); + if (modelMatch && modelMatch[1]) { + const extractedModel = modelMatch[1]; + setModel(prevModel => { + // Only update if different to avoid unnecessary re-renders + if (extractedModel !== prevModel) { + return extractedModel; + } + return prevModel; + }); + } + } + }); + + return () => { + unsubscribe(); + if (confirmationTimeoutRef.current) { + clearTimeout(confirmationTimeoutRef.current); + } + }; + }, [messageBuffer]); + + const resetConfirmation = useCallback(() => { + setConfirmationMode(false); + if (confirmationTimeoutRef.current) { + clearTimeout(confirmationTimeoutRef.current); + confirmationTimeoutRef.current = null; + } + }, []); + + const setConfirmationWithTimeout = useCallback(() => { + setConfirmationMode(true); + if (confirmationTimeoutRef.current) { + clearTimeout(confirmationTimeoutRef.current); + } + confirmationTimeoutRef.current = setTimeout(() => { + resetConfirmation(); + }, 15000); // 15 seconds timeout + }, [resetConfirmation]); + + useInput(useCallback(async (input, key) => { + if (actionInProgress) return; + + // Handle Ctrl-C + if (key.ctrl && input === 'c') { + if (confirmationMode) { + // Second Ctrl-C, exit + resetConfirmation(); + setActionInProgress(true); + await new Promise(resolve => setTimeout(resolve, 100)); + onExit?.(); + } else { + // First Ctrl-C, show confirmation + setConfirmationWithTimeout(); + } + return; + } + + // Any other key cancels confirmation + if (confirmationMode) { + resetConfirmation(); + } + }, [confirmationMode, actionInProgress, onExit, setConfirmationWithTimeout, resetConfirmation])); + + const getMessageColor = (type: BufferedMessage['type']): string => { + switch (type) { + case 'user': return 'magenta'; + case 'assistant': return 'cyan'; + case 'system': return 'blue'; + case 'tool': return 'yellow'; + case 'result': return 'green'; + case 'status': return 'gray'; + default: return 'white'; + } + }; + + const formatMessage = (msg: BufferedMessage): string => { + const lines = msg.content.split('\n'); + const maxLineLength = terminalWidth - 10; + return lines.map(line => { + if (line.length <= maxLineLength) return line; + const chunks: string[] = []; + for (let i = 0; i < line.length; i += maxLineLength) { + chunks.push(line.slice(i, i + maxLineLength)); + } + return chunks.join('\n'); + }).join('\n'); + }; + + return ( + + {/* Main content area with logs */} + + + ✨ OpenCode Agent Messages + {'─'.repeat(Math.min(terminalWidth - 4, 60))} + + + + {messages.length === 0 ? ( + Waiting for messages... + ) : ( + messages + .filter(msg => { + // Filter out empty system messages (used for triggering re-renders) + if (msg.type === 'system' && !msg.content.trim()) { + return false; + } + // Filter out model update messages (model extraction happens in useEffect) + if (msg.type === 'system' && msg.content.startsWith('[MODEL:')) { + return false; // Don't show in UI + } + // Filter out status messages that are redundant (shown in status bar) + // But keep Thinking messages - they show agent's reasoning process + if (msg.type === 'system' && msg.content.startsWith('Using model:')) { + return false; // Don't show in UI - redundant with status bar + } + // Keep "Thinking..." and "[Thinking] ..." messages - they show agent's reasoning + return true; + }) + .slice(-Math.max(1, terminalHeight - 10)) + .map((msg, index, array) => ( + + + {formatMessage(msg)} + + + )) + )} + + + + {/* Status bar at the bottom */} + + + {actionInProgress ? ( + + Exiting agent... + + ) : confirmationMode ? ( + + ⚠️ Press Ctrl-C again to exit the agent + + ) : ( + <> + + ✨ OpenCode Agent Running • Ctrl-C to exit + + {model && ( + + Model: {model} + + )} + + )} + {process.env.DEBUG && logPath && ( + + Debug logs: {logPath} + + )} + + + + ); +}; From 7fdff87791dc88c3a6b85a1c55ff38ac1ab4e86d Mon Sep 17 00:00:00 2001 From: Dmitry Polishuk Date: Sat, 3 Jan 2026 11:40:57 +0300 Subject: [PATCH 07/53] test: add unit and integration tests for OpenCode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Unit tests for OpenCodePermissionHandler (23 tests) - Permission mode management - Tool approval in yolo/safe-yolo/read-only/default modes - Permission response handling - Reset functionality - Write tool detection - Unit tests for OpenCode config utilities (18 tests) - Config reading (with/without file) - MCP server conversion - MCP server merging - Model reading/writing - Config preservation - Integration tests for runOpenCode (18 tests) - OpenCode installation detection - Model handling - MCP server merging - Permission handling - Type definitions Total: 59 new tests, all passing 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- src/opencode/runOpenCode.integration.test.ts | 337 +++++++++++++++++++ src/opencode/utils/config.test.ts | 308 +++++++++++++++++ src/opencode/utils/permissionHandler.test.ts | 326 ++++++++++++++++++ 3 files changed, 971 insertions(+) create mode 100644 src/opencode/runOpenCode.integration.test.ts create mode 100644 src/opencode/utils/config.test.ts create mode 100644 src/opencode/utils/permissionHandler.test.ts diff --git a/src/opencode/runOpenCode.integration.test.ts b/src/opencode/runOpenCode.integration.test.ts new file mode 100644 index 00000000..a39a372e --- /dev/null +++ b/src/opencode/runOpenCode.integration.test.ts @@ -0,0 +1,337 @@ +/** + * OpenCode integration tests + * + * Tests the main runOpenCode entry point and its integration + * with various components. + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { isOpenCodeInstalled } from './runOpenCode'; +import { writeOpenCodeModel, readOpenCodeConfig, readOpenCodeModel } from './utils/config'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +// Mock fs functions +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), +})); + +// Mock logger +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + }, +})); + +// Mock child_process exec +vi.mock('node:child_process', () => ({ + exec: vi.fn((_cmd, callback) => { + // Default: opencode exists + (callback as any)(null, 'opencode 1.0.0', ''); + return {} as any; + }), +})); + +describe('OpenCode integration', () => { + const mockConfigPath = join(homedir(), '.config/opencode/config.json'); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(readFile).mockRejectedValue(new Error('ENOENT')); + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(writeFile).mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('isOpenCodeInstalled', () => { + it('should return true when opencode binary exists', async () => { + // Default mock returns true, so this should pass + const result = await isOpenCodeInstalled(); + expect(result).toBe(true); + }); + + it('should return false when opencode binary not found', async () => { + const { exec } = await import('node:child_process'); + // Override mock for this test + vi.mocked(exec).mockImplementationOnce((_cmd, callback) => { + (callback as any)(new Error('Command not found'), '', 'opencode: command not found'); + return {} as any; + }); + + const result = await isOpenCodeInstalled(); + expect(result).toBe(false); + }); + }); + + describe('runOpenCode model handling', () => { + it('should write model to new config file', async () => { + const model = 'anthropic/claude-sonnet-4-20250514'; + + await writeOpenCodeModel(model); + + expect(mkdir).toHaveBeenCalledWith( + expect.stringContaining('.config/opencode'), + { recursive: true } + ); + expect(writeFile).toHaveBeenCalledWith( + mockConfigPath, + JSON.stringify({ model }, null, 2), + 'utf-8' + ); + }); + + it('should update model in existing config', async () => { + const existingConfig = { + model: 'claude-3', + mcpServers: { + filesystem: { command: 'npx' }, + }, + }; + + vi.mocked(readFile).mockResolvedValue(JSON.stringify(existingConfig)); + + await writeOpenCodeModel('gpt-4o'); + + expect(writeFile).toHaveBeenCalledWith( + mockConfigPath, + JSON.stringify({ model: 'gpt-4o', mcpServers: { filesystem: { command: 'npx' } } }, null, 2), + 'utf-8' + ); + }); + + it('should throw error when write fails', async () => { + vi.mocked(writeFile).mockRejectedValue(new Error('EACCES')); + + await expect(writeOpenCodeModel('gpt-4o')).rejects.toThrow(); + }); + + it('should preserve existing config when updating model', async () => { + const existingConfig = { + model: 'old-model', + mcpServers: { + server1: { command: 'cmd1' }, + server2: { command: 'cmd2', args: ['--port', '8080'] }, + }, + someOtherField: 'value', + }; + + vi.mocked(readFile).mockResolvedValue(JSON.stringify(existingConfig)); + + await writeOpenCodeModel('new-model'); + + const writtenConfig = JSON.parse(vi.mocked(writeFile).mock.calls[0][1] as string); + expect(writtenConfig).toEqual({ + model: 'new-model', + mcpServers: { + server1: { command: 'cmd1' }, + server2: { command: 'cmd2', args: ['--port', '8080'] }, + }, + someOtherField: 'value', + }); + }); + + it('should read model from config', async () => { + const mockConfig = { + model: 'anthropic/claude-sonnet-4-20250514', + }; + + vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockConfig)); + + const result = await readOpenCodeModel(); + + expect(result).toBe('anthropic/claude-sonnet-4-20250514'); + }); + + it('should return undefined when model is not set', async () => { + const mockConfig = { + mcpServers: {}, + }; + + vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockConfig)); + + const result = await readOpenCodeModel(); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when config file does not exist', async () => { + vi.mocked(readFile).mockRejectedValue(new Error('ENOENT')); + + const result = await readOpenCodeModel(); + + expect(result).toBeUndefined(); + }); + }); + + describe('runOpenCode MCP server merging', () => { + it('should read OpenCode config with MCP servers', async () => { + const openCodeConfig = { + model: 'gpt-4o', + mcpServers: { + filesystem: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'], + }, + brave: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-brave-search'], + }, + }, + }; + + vi.mocked(readFile).mockResolvedValue(JSON.stringify(openCodeConfig)); + + const config = await readOpenCodeConfig(); + + expect(config.mcpServers).toBeDefined(); + expect(Object.keys(config.mcpServers || {})).toHaveLength(2); + }); + + it('should handle empty MCP servers config', async () => { + const openCodeConfig = { + model: 'gpt-4o', + }; + + vi.mocked(readFile).mockResolvedValue(JSON.stringify(openCodeConfig)); + + const config = await readOpenCodeConfig(); + + expect(config.model).toBe('gpt-4o'); + expect(config.mcpServers).toBeUndefined(); + }); + }); + + describe('runOpenCode permission handling', () => { + it('should support permission mode types', async () => { + const { OpenCodePermissionHandler } = await import('./utils/permissionHandler'); + + const mockSession = { + sendCodexMessage: vi.fn(), + }; + + const handler = new OpenCodePermissionHandler(mockSession as any); + + // Test all permission modes + const modes: Array<'default' | 'read-only' | 'safe-yolo' | 'yolo'> = + ['default', 'read-only', 'safe-yolo', 'yolo']; + + for (const mode of modes) { + handler.setPermissionMode(mode); + expect(handler.getPermissionMode()).toBe(mode); + } + }); + + it('should auto-approve in yolo mode', async () => { + const { OpenCodePermissionHandler } = await import('./utils/permissionHandler'); + + const mockSession = { + sendCodexMessage: vi.fn(), + }; + + const handler = new OpenCodePermissionHandler(mockSession as any); + handler.setPermissionMode('yolo'); + + const result = await handler.handleToolCall('call-1', 'write_file', { path: '/tmp/test' }); + + expect(result.decision).toBe('approved_for_session'); + }); + + it('should deny write tools in read-only mode', async () => { + const { OpenCodePermissionHandler } = await import('./utils/permissionHandler'); + + const mockSession = { + sendCodexMessage: vi.fn(), + }; + + const handler = new OpenCodePermissionHandler(mockSession as any); + handler.setPermissionMode('read-only'); + + const result = await handler.handleToolCall('call-1', 'write_file', { path: '/tmp/test' }); + + expect(result.decision).toBe('denied'); + }); + + it('should approve read tools in read-only mode', async () => { + const { OpenCodePermissionHandler } = await import('./utils/permissionHandler'); + + const mockSession = { + sendCodexMessage: vi.fn(), + }; + + const handler = new OpenCodePermissionHandler(mockSession as any); + handler.setPermissionMode('read-only'); + + const result = await handler.handleToolCall('call-1', 'read_file', { path: '/tmp/test' }); + + expect(result.decision).toBe('approved'); + }); + }); + + describe('OpenCode types', () => { + it('should support PermissionMode type', async () => { + type PermissionMode = 'default' | 'read-only' | 'safe-yolo' | 'yolo'; + + const modes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; + + expect(modes).toHaveLength(4); + expect(modes).toContain('default'); + expect(modes).toContain('read-only'); + expect(modes).toContain('safe-yolo'); + expect(modes).toContain('yolo'); + }); + + it('should support OpenCodeMode interface', async () => { + interface OpenCodeMode { + permissionMode?: 'default' | 'read-only' | 'safe-yolo' | 'yolo'; + model?: string; + } + + const mode1: OpenCodeMode = { + permissionMode: 'yolo', + model: 'gpt-4o', + }; + + const mode2: OpenCodeMode = { + model: 'claude-sonnet-4', + }; + + expect(mode1.permissionMode).toBe('yolo'); + expect(mode1.model).toBe('gpt-4o'); + expect(mode2.model).toBe('claude-sonnet-4'); + expect(mode2.permissionMode).toBeUndefined(); + }); + + it('should support CodexMessagePayload interface', async () => { + interface CodexMessagePayload { + type: 'message'; + message: string; + id: string; + options?: Array<{ + optionId: string; + name: string; + }>; + } + + const payload: CodexMessagePayload = { + type: 'message', + message: 'Test message', + id: 'test-id', + options: [ + { optionId: '1', name: 'Option 1' }, + { optionId: '2', name: 'Option 2' }, + ], + }; + + expect(payload.type).toBe('message'); + expect(payload.message).toBe('Test message'); + expect(payload.options).toHaveLength(2); + }); + }); +}); diff --git a/src/opencode/utils/config.test.ts b/src/opencode/utils/config.test.ts new file mode 100644 index 00000000..466d40f4 --- /dev/null +++ b/src/opencode/utils/config.test.ts @@ -0,0 +1,308 @@ +/** + * OpenCode config utilities tests + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { + readOpenCodeConfig, + convertOpenCodeMcpServers, + getMergedMcpServers, + readOpenCodeModel, + writeOpenCodeModel, +} from './config'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +// Mock fs functions +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), +})); + +// Mock logger +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + }, +})); + +describe('OpenCode config utilities', () => { + const mockConfigPath = join(homedir(), '.config/opencode/config.json'); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('readOpenCodeConfig', () => { + it('should read and parse valid config', async () => { + const mockConfig = { + model: 'anthropic/claude-sonnet-4-20250514', + mcpServers: { + filesystem: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'], + }, + }, + }; + + vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockConfig)); + + const result = await readOpenCodeConfig(); + + expect(readFile).toHaveBeenCalledWith(mockConfigPath, 'utf-8'); + expect(result).toEqual(mockConfig); + }); + + it('should return empty object when config file not found', async () => { + vi.mocked(readFile).mockRejectedValue(new Error('ENOENT')); + + const result = await readOpenCodeConfig(); + + expect(result).toEqual({}); + }); + + it('should return empty object when config is invalid JSON', async () => { + vi.mocked(readFile).mockResolvedValue('invalid json{'); + + const result = await readOpenCodeConfig(); + + expect(result).toEqual({}); + }); + }); + + describe('convertOpenCodeMcpServers', () => { + it('should convert OpenCode MCP server format to Happy format', () => { + const openCodeServers = { + filesystem: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'], + }, + brave: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-brave-search'], + env: { BRAVE_API_KEY: 'test-key' }, + }, + }; + + const result = convertOpenCodeMcpServers(openCodeServers); + + expect(result).toEqual({ + filesystem: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'], + }, + brave: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-brave-search'], + env: { BRAVE_API_KEY: 'test-key' }, + }, + }); + }); + + it('should return empty object when no servers provided', () => { + const result = convertOpenCodeMcpServers(undefined); + expect(result).toEqual({}); + }); + + it('should return empty object when servers is null', () => { + const result = convertOpenCodeMcpServers(null as any); + expect(result).toEqual({}); + }); + + it('should handle servers without args or env', () => { + const openCodeServers = { + minimal: { + command: 'node', + args: ['server.js'], + }, + }; + + const result = convertOpenCodeMcpServers(openCodeServers); + + expect(result).toEqual({ + minimal: { + command: 'node', + args: ['server.js'], + }, + }); + }); + }); + + describe('getMergedMcpServers', () => { + it('should merge OpenCode and Happy servers with Happy taking precedence', async () => { + const mockOpenCodeConfig = { + mcpServers: { + server1: { command: 'cmd1' }, + server2: { command: 'cmd2' }, + }, + }; + + const happyServers = { + server2: { command: 'happy-cmd2' }, // Should override OpenCode's server2 + server3: { command: 'cmd3' }, + }; + + vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockOpenCodeConfig)); + + const result = await getMergedMcpServers(happyServers); + + expect(result).toEqual({ + server1: { command: 'cmd1' }, // From OpenCode + server2: { command: 'happy-cmd2' }, // From Happy (override) + server3: { command: 'cmd3' }, // From Happy + }); + }); + + it('should return only Happy servers when OpenCode config is empty', async () => { + const happyServers = { + server1: { command: 'cmd1' }, + }; + + vi.mocked(readFile).mockRejectedValue(new Error('ENOENT')); + + const result = await getMergedMcpServers(happyServers); + + expect(result).toEqual({ + server1: { command: 'cmd1' }, + }); + }); + + it('should return only OpenCode servers when Happy servers is undefined', async () => { + const mockOpenCodeConfig = { + mcpServers: { + server1: { command: 'cmd1' }, + }, + }; + + vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockOpenCodeConfig)); + + const result = await getMergedMcpServers(undefined); + + expect(result).toEqual({ + server1: { command: 'cmd1' }, + }); + }); + + it('should return empty object when both sources are empty', async () => { + vi.mocked(readFile).mockRejectedValue(new Error('ENOENT')); + + const result = await getMergedMcpServers(undefined); + + expect(result).toEqual({}); + }); + }); + + describe('readOpenCodeModel', () => { + it('should return model from config', async () => { + const mockConfig = { + model: 'anthropic/claude-sonnet-4-20250514', + }; + + vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockConfig)); + + const result = await readOpenCodeModel(); + + expect(result).toBe('anthropic/claude-sonnet-4-20250514'); + }); + + it('should return undefined when model is not set', async () => { + const mockConfig = { + mcpServers: {}, + }; + + vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockConfig)); + + const result = await readOpenCodeModel(); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when config file does not exist', async () => { + vi.mocked(readFile).mockRejectedValue(new Error('ENOENT')); + + const result = await readOpenCodeModel(); + + expect(result).toBeUndefined(); + }); + }); + + describe('writeOpenCodeModel', () => { + it('should write model to new config file', async () => { + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(readFile).mockRejectedValue(new Error('ENOENT')); + vi.mocked(writeFile).mockResolvedValue(undefined); + + await writeOpenCodeModel('gpt-4o'); + + expect(mkdir).toHaveBeenCalledWith(expect.stringContaining('.config/opencode'), { recursive: true }); + expect(writeFile).toHaveBeenCalledWith( + expect.stringContaining('config.json'), + JSON.stringify({ model: 'gpt-4o' }, null, 2), + 'utf-8' + ); + }); + + it('should update model in existing config', async () => { + const existingConfig = { + model: 'claude-3', + mcpServers: { + filesystem: { command: 'npx' }, + }, + }; + + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(readFile).mockResolvedValue(JSON.stringify(existingConfig)); + vi.mocked(writeFile).mockResolvedValue(undefined); + + await writeOpenCodeModel('gpt-4o'); + + expect(writeFile).toHaveBeenCalledWith( + expect.stringContaining('config.json'), + JSON.stringify({ model: 'gpt-4o', mcpServers: { filesystem: { command: 'npx' } } }, null, 2), + 'utf-8' + ); + }); + + it('should throw error when write fails', async () => { + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(readFile).mockRejectedValue(new Error('ENOENT')); + vi.mocked(writeFile).mockRejectedValue(new Error('EACCES')); + + await expect(writeOpenCodeModel('gpt-4o')).rejects.toThrow(); + }); + + it('should preserve existing config when updating model', async () => { + const existingConfig = { + model: 'old-model', + mcpServers: { + server1: { command: 'cmd1' }, + server2: { command: 'cmd2', args: ['--port', '8080'] }, + }, + someOtherField: 'value', + }; + + vi.mocked(mkdir).mockResolvedValue(undefined); + vi.mocked(readFile).mockResolvedValue(JSON.stringify(existingConfig)); + vi.mocked(writeFile).mockResolvedValue(undefined); + + await writeOpenCodeModel('new-model'); + + const writtenConfig = JSON.parse(vi.mocked(writeFile).mock.calls[0][1] as string); + expect(writtenConfig).toEqual({ + model: 'new-model', + mcpServers: { + server1: { command: 'cmd1' }, + server2: { command: 'cmd2', args: ['--port', '8080'] }, + }, + someOtherField: 'value', + }); + }); + }); +}); diff --git a/src/opencode/utils/permissionHandler.test.ts b/src/opencode/utils/permissionHandler.test.ts new file mode 100644 index 00000000..d96b26e4 --- /dev/null +++ b/src/opencode/utils/permissionHandler.test.ts @@ -0,0 +1,326 @@ +/** + * OpenCode Permission Handler tests + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { OpenCodePermissionHandler } from './permissionHandler'; +import type { PermissionMode } from '../types'; +import { ApiSessionClient } from '@/api/apiSession'; + +// Mock ApiSessionClient +vi.mock('@/api/apiSession', () => ({ + ApiSessionClient: vi.fn(), +})); + +describe('OpenCodePermissionHandler', () => { + let handler: OpenCodePermissionHandler; + let mockSession: ApiSessionClient; + + beforeEach(() => { + // Create mock session + mockSession = { + sendCodexMessage: vi.fn(), + } as unknown as ApiSessionClient; + + handler = new OpenCodePermissionHandler(mockSession); + vi.clearAllMocks(); + }); + + describe('permission mode management', () => { + it('should have default permission mode', () => { + expect(handler.getPermissionMode()).toBe('default'); + }); + + it('should set permission mode', () => { + handler.setPermissionMode('yolo'); + expect(handler.getPermissionMode()).toBe('yolo'); + }); + + it('should support all permission modes', () => { + const modes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; + + for (const mode of modes) { + handler.setPermissionMode(mode); + expect(handler.getPermissionMode()).toBe(mode); + } + }); + }); + + describe('handleToolCall in yolo mode', () => { + beforeEach(() => { + handler.setPermissionMode('yolo'); + }); + + it('should auto-approve all tools', async () => { + const result = await handler.handleToolCall('call-1', 'write_file', { path: '/tmp/test' }); + + expect(result.decision).toBe('approved_for_session'); + }); + + it('should not send permission request to mobile', async () => { + await handler.handleToolCall('call-1', 'any_tool', {}); + + expect(mockSession.sendCodexMessage).not.toHaveBeenCalled(); + }); + }); + + describe('handleToolCall in safe-yolo mode', () => { + beforeEach(() => { + handler.setPermissionMode('safe-yolo'); + }); + + it('should auto-approve all tools', async () => { + const result = await handler.handleToolCall('call-1', 'write_file', { path: '/tmp/test' }); + + expect(result.decision).toBe('approved_for_session'); + }); + + it('should not send permission request to mobile', async () => { + await handler.handleToolCall('call-1', 'any_tool', {}); + + expect(mockSession.sendCodexMessage).not.toHaveBeenCalled(); + }); + }); + + describe('handleToolCall in read-only mode', () => { + beforeEach(() => { + handler.setPermissionMode('read-only'); + }); + + it('should approve read-only tools', async () => { + const readTools = [ + 'read_file', + 'list_directory', + 'search', + 'codebase_search', + 'diagnostics', + 'completions', + 'definition', + 'hover', + 'codebase_investigator', + ]; + + for (const toolName of readTools) { + const result = await handler.handleToolCall('call-1', toolName, {}); + expect(result.decision).toBe('approved'); + } + }); + + it('should deny write tools', async () => { + const writeTools = [ + 'write_file', + 'edit_file', + 'delete_file', + 'run_command', + 'bash', + 'execute', + ]; + + for (const toolName of writeTools) { + const result = await handler.handleToolCall('call-1', toolName, {}); + expect(result.decision).toBe('denied'); + } + }); + + it('should not send permission requests in read-only mode', async () => { + await handler.handleToolCall('call-1', 'read_file', { path: '/tmp/test' }); + + expect(mockSession.sendCodexMessage).not.toHaveBeenCalled(); + }); + }); + + describe('handleToolCall in default mode', () => { + beforeEach(() => { + handler.setPermissionMode('default'); + }); + + it('should send permission request to mobile for tool approval', async () => { + const promise = handler.handleToolCall('call-1', 'write_file', { path: '/tmp/test' }); + + // Should have sent codex message + expect(mockSession.sendCodexMessage).toHaveBeenCalledWith({ + type: 'permission-request', + permissionId: 'call-1', + reason: 'Tool "write_file" requires approval', + payload: { path: '/tmp/test' }, + id: 'call-1', + }); + + // Should be pending + expect(promise).toBeInstanceOf(Promise); + }); + + it('should return approved when mobile approves', async () => { + const promise = handler.handleToolCall('call-1', 'write_file', {}); + + // Simulate mobile approval + handler.handlePermissionResponse('call-1', 'approved'); + + const result = await promise; + expect(result.decision).toBe('approved'); + }); + + it('should return denied when mobile denies', async () => { + const promise = handler.handleToolCall('call-1', 'write_file', {}); + + // Simulate mobile denial + handler.handlePermissionResponse('call-1', 'denied'); + + const result = await promise; + expect(result.decision).toBe('denied'); + }); + + it('should return abort when mobile aborts', async () => { + const promise = handler.handleToolCall('call-1', 'write_file', {}); + + // Simulate mobile abort + handler.handlePermissionResponse('call-1', 'abort'); + + const result = await promise; + expect(result.decision).toBe('abort'); + }); + + it('should handle multiple concurrent permission requests', async () => { + const promise1 = handler.handleToolCall('call-1', 'write_file', {}); + const promise2 = handler.handleToolCall('call-2', 'read_file', {}); + const promise3 = handler.handleToolCall('call-3', 'bash', {}); + + // Approve in reverse order + handler.handlePermissionResponse('call-3', 'approved'); + handler.handlePermissionResponse('call-2', 'denied'); + handler.handlePermissionResponse('call-1', 'approved'); + + const result1 = await promise1; + const result2 = await promise2; + const result3 = await promise3; + + expect(result1.decision).toBe('approved'); + expect(result2.decision).toBe('denied'); + expect(result3.decision).toBe('approved'); + }); + }); + + describe('handlePermissionResponse', () => { + beforeEach(() => { + handler.setPermissionMode('default'); + }); + + it('should resolve pending approval when response received', async () => { + const promise = handler.handleToolCall('call-1', 'write_file', {}); + + handler.handlePermissionResponse('call-1', 'approved'); + + const result = await promise; + expect(result.decision).toBe('approved'); + }); + + it('should do nothing when no pending approval exists', () => { + // Should not throw + expect(() => { + handler.handlePermissionResponse('nonexistent', 'approved'); + }).not.toThrow(); + }); + + it('should remove pending approval after response', async () => { + const promise1 = handler.handleToolCall('call-1', 'write_file', {}); + + handler.handlePermissionResponse('call-1', 'approved'); + await promise1; + + // Second response should be ignored + expect(() => { + handler.handlePermissionResponse('call-1', 'denied'); + }).not.toThrow(); + }); + }); + + describe('reset', () => { + beforeEach(() => { + handler.setPermissionMode('default'); + }); + + it('should abort all pending approvals', async () => { + const promise1 = handler.handleToolCall('call-1', 'write_file', {}); + const promise2 = handler.handleToolCall('call-2', 'read_file', {}); + const promise3 = handler.handleToolCall('call-3', 'bash', {}); + + handler.reset(); + + const result1 = await promise1; + const result2 = await promise2; + const result3 = await promise3; + + expect(result1.decision).toBe('abort'); + expect(result2.decision).toBe('abort'); + expect(result3.decision).toBe('abort'); + }); + + it('should clear pending approvals map', async () => { + handler.handleToolCall('call-1', 'write_file', {}); + handler.handleToolCall('call-2', 'read_file', {}); + + handler.reset(); + + // After reset, responses should be no-ops + expect(() => { + handler.handlePermissionResponse('call-1', 'approved'); + handler.handlePermissionResponse('call-2', 'denied'); + }).not.toThrow(); + }); + }); + + describe('isWriteTool (private method behavior)', () => { + beforeEach(() => { + handler.setPermissionMode('read-only'); + }); + + it('should identify read tools correctly', async () => { + const readTools = [ + 'read_file', + 'list_directory', + 'search', + 'codebase_search', + 'diagnostics', + 'completions', + 'definition', + 'hover', + 'codebase_investigator', + ]; + + for (const toolName of readTools) { + const result = await handler.handleToolCall('call-1', toolName, {}); + expect(result.decision).toBe('approved'); + } + }); + + it('should identify write tools correctly', async () => { + const writeTools = [ + 'write_file', + 'edit_file', + 'delete_file', + 'create_file', + 'move_file', + 'copy_file', + 'run_command', + 'bash', + 'execute', + 'shell', + ]; + + for (const toolName of writeTools) { + const result = await handler.handleToolCall('call-1', toolName, {}); + expect(result.decision).toBe('denied'); + } + }); + + it('should handle tools with partial name matches', async () => { + // Tools containing read tool names should be approved + const result1 = await handler.handleToolCall('call-1', 'enhanced_read_file', {}); + expect(result1.decision).toBe('approved'); + + // Tools not containing read tool names should be denied + const result2 = await handler.handleToolCall('call-2', 'custom_tool', {}); + expect(result2.decision).toBe('denied'); + }); + }); +}); From 6feb322d5a7938b478de86b164b9b637764468b1 Mon Sep 17 00:00:00 2001 From: Dmitry Polishuk Date: Sat, 3 Jan 2026 12:10:48 +0300 Subject: [PATCH 08/53] feat(opencode): add options/suggestion buttons support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Port optionsParser utility from Gemini to OpenCode - Update CodexMessagePayload.options to use string[] (mobile app compatible) - Integrate options parsing into runOpenCode message handler - Add 19 new unit tests for options parser - Add 3 new integration tests for options flow - Update feature parity documentation Mobile app now displays clickable suggestion buttons when OpenCode agent generates responses with XML. Example: 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- docs/opencode-feature-parity.md | 236 +++++++++++++++ ...25-01-03-opencode-options-parity-design.md | 274 ++++++++++++++++++ src/opencode/runOpenCode.integration.test.ts | 51 +++- src/opencode/runOpenCode.ts | 20 +- src/opencode/types.ts | 8 +- src/opencode/utils/optionsParser.test.ts | 167 +++++++++++ src/opencode/utils/optionsParser.ts | 71 +++++ 7 files changed, 814 insertions(+), 13 deletions(-) create mode 100644 docs/opencode-feature-parity.md create mode 100644 docs/plans/2025-01-03-opencode-options-parity-design.md create mode 100644 src/opencode/utils/optionsParser.test.ts create mode 100644 src/opencode/utils/optionsParser.ts diff --git a/docs/opencode-feature-parity.md b/docs/opencode-feature-parity.md new file mode 100644 index 00000000..ee62125e --- /dev/null +++ b/docs/opencode-feature-parity.md @@ -0,0 +1,236 @@ +# OpenCode Feature Parity Analysis + +This document compares the OpenCode integration with Claude Code and Codex implementations to identify gaps and feature parity. + +## Summary + +OpenCode support is **~80% complete** compared to Claude/Codex implementations. The core functionality is in place, but some advanced features are missing. + +## Feature Comparison Matrix + +| Feature Category | Claude | Codex | OpenCode | Status | +|-----------------|--------|-------|----------|--------| +| **Core Integration** | +| ACP Backend Support | ✅ | ✅ | ✅ | Complete | +| Session Management | ✅ | ✅ | ✅ | Complete | +| Message Queue | ✅ | ✅ | ✅ | Complete | +| Model Selection | ✅ | ✅ | ✅ | Complete (via config) | +| MCP Server Merging | ✅ | ✅ | ✅ | Complete | +| **UI Components** | +| Ink Display | ✅ | ✅ | ✅ | Complete | +| Message Buffer | ✅ | ✅ | ✅ | Complete | +| Terminal Output | ✅ | ✅ | ✅ | Complete | +| Ctrl-C Handling | ✅ | ✅ | ✅ | Complete | +| **Permission Handling** | +| Permission Modes | ✅ | ✅ | ✅ | Complete | +| Yolo Mode | ✅ | ✅ | ✅ | Complete | +| Safe-Yolo Mode | ✅ | ✅ | ✅ | Complete | +| Read-Only Mode | ✅ | ✅ | ✅ | Complete | +| Default Mode (Mobile) | ✅ | ✅ | ✅ | Complete | +| Write Tool Detection | ✅ | ✅ | ✅ | Complete | +| **Lifecycle Management** | +| Session Initialization | ✅ | ✅ | ✅ | Complete | +| Abort Handling | ✅ | ✅ | ✅ | Complete | +| Kill Session Handler | ✅ | ✅ | ✅ | Complete | +| Cleanup on Exit | ✅ | ✅ | ✅ | Complete | +| Daemon Reporting | ✅ | ✅ | ✅ | Complete | +| Keep-Alive | ✅ | ✅ | ✅ | Complete | +| **Mobile Integration** | +| User Message Handler | ✅ | ✅ | ✅ | Complete | +| Codex Messages | ✅ | ✅ | ✅ | Complete | +| Session Events | ✅ | ✅ | ✅ | Complete | +| Ready Event | ✅ | ✅ | ✅ | Complete | +| Push Notifications | ✅ | ✅ | ✅ | Complete | +| **Advanced Features** | +| Reasoning Processor | ❌ | ✅ | ❌ | **MISSING** | +| Diff Processor | ❌ | ✅ | ❌ | **MISSING** | +| Special Commands | ✅ | ❌ | ❌ | **MISSING** | +| Hook Server | ✅ | ❌ | ❌ | **MISSING** | +| Caffeinate (prevent sleep) | ✅ | ✅ | Partial | **MISSING startCaffeinate** | +| **Message Processing** | +| Text Delta Streaming | ✅ | ✅ | ✅ | Complete | +| Tool Call Display | ✅ | ✅ | ✅ | Complete | +| Tool Result Display | ✅ | ✅ | ✅ | Complete | +| Status Changes | ✅ | ✅ | ✅ | Complete | +| Error Handling | ✅ | ✅ | ✅ | Complete | +| **Configuration** | +| Model via Config | ✅ | ✅ | ✅ | Complete | +| MCP Servers | ✅ | ✅ | ✅ | Complete | +| Custom Env Vars | ✅ | ❌ | ❌ | **MISSING** | +| Custom Args | ✅ | ❌ | ❌ | **MISSING** | +| **Testing** | +| Unit Tests | ✅ | ✅ | ✅ | Complete | +| Integration Tests | ✅ | ✅ | ✅ | Complete | +| Permission Tests | ✅ | ✅ | ✅ | Complete | +| Config Tests | ✅ | ✅ | ✅ | Complete | + +## Missing Features Detail + +### 1. Reasoning Processor +**Status:** NOT IMPLEMENTED + +**What it does:** +- Processes streaming reasoning deltas +- Identifies reasoning sections with `**[Title]**` format +- Sends `CodexReasoning` tool calls for titled reasoning +- Handles reasoning completion and abort + +**Impact:** Medium - Users won't see structured reasoning output in the mobile app + +**Implementation complexity:** Medium +- File: `src/codex/utils/reasoningProcessor.ts` (~260 lines) +- Integration point: In message handler, process `agent_reasoning_delta` events + +### 2. Diff Processor +**Status:** NOT IMPLEMENTED + +**What it does:** +- Tracks `unified_diff` field in `turn_diff` messages +- Sends `CodexDiff` tool calls when diff changes +- Marks diffs as completed + +**Impact:** Low-Medium - Mobile app won't see structured diff information + +**Implementation complexity:** Low +- File: `src/codex/utils/diffProcessor.ts` (~100 lines) +- Integration point: In message handler, process `turn_diff` events + +### 3. Special Commands +**Status:** NOT IMPLEMENTED + +**What it does:** +- Parses special commands like `/help`, `/status`, `/model` +- Allows runtime configuration changes + +**Impact:** Low - Users can still configure via mobile app + +**Implementation complexity:** Low +- File: `src/parsers/specialCommands.ts` +- Integration point: In message queue processing + +### 4. Hook Server +**Status:** NOT IMPLEMENTED + +**What it does:** +- Starts a local HTTP server for git/hooks integration +- Allows Claude to modify git hooks + +**Impact:** Low - Only needed for git hook modifications + +**Implementation complexity:** Medium +- File: `src/claude/utils/startHookServer.ts` +- Files: `src/claude/utils/generateHookSettings.ts` + +### 5. Custom Environment Variables & Arguments +**Status:** NOT IMPLEMENTED + +**What it does:** +- Allows passing custom env vars to the agent +- Allows passing custom CLI arguments + +**Impact:** Low - OpenCode uses its native config + +**Implementation complexity:** Low +- Add to `runOpenCode` options +- Pass to `createOpenCodeBackend` + +### 6. Caffeinate (prevent sleep) +**Status:** PARTIAL + +**What it does:** +- `stopCaffeinate()` is called +- `startCaffeinate()` is NOT called at startup + +**Impact:** Low - System might sleep during long-running tasks + +**Implementation complexity:** Very Low +- Add `startCaffeinate()` call at startup + +## Recommendations + +### Priority 1: High Impact, Low Complexity + +1. **Add startCaffeinate()** + - 1 line change + - Prevents system sleep during long tasks + +2. **Add Diff Processor** + - ~100 lines + - Better mobile app experience + +### Priority 2: Medium Impact, Medium Complexity + +3. **Add Reasoning Processor** + - ~260 lines + - Shows reasoning in mobile app + +### Priority 3: Low Impact + +4. **Add Special Commands** + - Nice to have for CLI users + +5. **Add Custom Env Vars/Args** + - Advanced users only + +6. **Add Hook Server** + - Git workflow integration + +## Implementation Order + +1. **Quick Win (5 minutes):** + - Add `startCaffeinate()` call in `runOpenCode.ts` + +2. **Short Task (1-2 hours):** + - Port `DiffProcessor` from Codex + - Integrate into message handler + +3. **Medium Task (2-4 hours):** + - Port `ReasoningProcessor` from Codex + - Integrate into message handler + +4. **Optional Enhancements:** + - Special commands parsing + - Custom env vars/args + - Hook server support + +## File Structure Comparison + +``` +src/ +├── claude/ +│ ├── runClaude.ts ✅ Full featured +│ ├── loop.ts ✅ Complex logic +│ ├── utils/ +│ │ ├── startHookServer.ts ❌ OpenCode missing +│ │ └── ... +│ └── sdk/ +│ └── ... +├── codex/ +│ ├── runCodex.ts ✅ Full featured +│ └── utils/ +│ ├── reasoningProcessor.ts ❌ OpenCode missing +│ └── diffProcessor.ts ❌ OpenCode missing +└── opencode/ + ├── runOpenCode.ts ✅ Core complete, missing extras + ├── utils/ + │ ├── permissionHandler.ts ✅ Complete + │ └── config.ts ✅ Complete + └── types.ts ✅ Complete +``` + +## Code Statistics + +| Agent | Main File | Utils Files | Total Lines | Test Coverage | +|-------|-----------|-------------|-------------|---------------| +| Claude | ~400 | ~2000 | ~2400 | ✅ Yes | +| Codex | ~600 | ~400 | ~1000 | ✅ Yes | +| OpenCode | ~650 | ~200 | ~850 | ✅ Yes (59 tests) | + +## Conclusion + +OpenCode integration is **functionally complete** for core use cases. The missing features are primarily: +1. Enhanced mobile app experience (reasoning/diff processors) +2. Advanced workflow integration (hooks, special commands) +3. System sleep prevention (easy fix) + +The implementation follows the same patterns as Codex, making it straightforward to add missing features when needed. diff --git a/docs/plans/2025-01-03-opencode-options-parity-design.md b/docs/plans/2025-01-03-opencode-options-parity-design.md new file mode 100644 index 00000000..b61e2766 --- /dev/null +++ b/docs/plans/2025-01-03-opencode-options-parity-design.md @@ -0,0 +1,274 @@ +# OpenCode Options/Suggestion Buttons Feature Parity Design + +**Date:** 2025-01-03 +**Status:** Design Complete +**Estimated Effort:** 2 hours + +## Overview + +Add support for suggestion buttons (clickable options) in OpenCode mobile app UI, achieving feature parity with Claude and Gemini agents. + +## What Are Options? + +Agents can present clickable action buttons to users in the mobile app by including XML in their responses: + +```xml +Here are some suggested actions: + + + + + +``` + +The mobile app parses this XML and displays each `