diff --git a/README.md b/README.md index 6acf304b..f7eab730 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # HAPI -Run official Claude Code / Codex / Gemini sessions locally and control them remotely through a Web / PWA / Telegram Mini App. +Run official Claude Code / Codex / Gemini / OpenCode sessions locally and control them remotely through a Web / PWA / Telegram Mini App. > **Why HAPI?** HAPI is a local-first alternative to Happy. See [Why Not Happy?](docs/guide/why-hapi.md) for the key differences. @@ -9,7 +9,7 @@ Run official Claude Code / Codex / Gemini sessions locally and control them remo - **Seamless Handoff** - Work locally, switch to remote when needed, switch back anytime. No context loss, no session restart. - **Native First** - HAPI wraps your AI agent instead of replacing it. Same terminal, same experience, same muscle memory. - **AFK Without Stopping** - Step away from your desk? Approve AI requests from your phone with one tap. -- **Your AI, Your Choice** - Claude Code, Codex, Gemini—different models, one unified workflow. +- **Your AI, Your Choice** - Claude Code, Codex, Gemini, OpenCode—different models, one unified workflow. - **Terminal Anywhere** - Run commands from your phone or browser, directly connected to the working machine. - **Voice Control** - Talk to your AI agent hands-free using the built-in voice assistant. diff --git a/cli/README.md b/cli/README.md index d1f5b833..006fb22e 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,12 +1,13 @@ # hapi CLI -Run Claude Code, Codex, or Gemini sessions from your terminal and control them remotely through the hapi hub. +Run Claude Code, Codex, Gemini, or OpenCode sessions from your terminal and control them remotely through the hapi hub. ## What it does - Starts Claude Code sessions and registers them with hapi-hub. - Starts Codex mode for OpenAI-based sessions. - Starts Gemini mode via ACP (Anthropic Code Plugins). +- Starts OpenCode mode via ACP and its plugin hook system. - Provides an MCP stdio bridge for external tools. - Manages a background runner for long-running sessions. - Includes diagnostics and auth helpers. @@ -27,6 +28,8 @@ Run Claude Code, Codex, or Gemini sessions from your terminal and control them r - `hapi codex resume ` - Resume existing Codex session. - `hapi gemini` - Start Gemini mode via ACP. See `src/agent/runners/runAgentSession.ts`. Note: Gemini runs in remote mode only; it waits for messages from the hub UI/Telegram. +- `hapi opencode` - Start OpenCode mode via ACP. See `src/opencode/runOpencode.ts`. + Note: OpenCode supports local and remote modes; local mode streams via OpenCode plugins. ### Authentication @@ -100,6 +103,7 @@ Data is stored in `~/.hapi/` (or `$HAPI_HOME`): ## Requirements - Claude CLI installed and logged in (`claude` on PATH). +- OpenCode CLI installed (`opencode` on PATH). - Bun for building from source. ## Build from source @@ -124,6 +128,7 @@ bun run build:single-exe - `src/claude/` - Claude Code integration. - `src/codex/` - Codex mode integration. - `src/agent/` - Multi-agent support (Gemini via ACP). +- `src/opencode/` - OpenCode ACP + hook integration. - `src/runner/` - Background service. - `src/commands/` - CLI command handlers. - `src/ui/` - User interface and diagnostics. diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.ts b/cli/src/agent/backends/acp/AcpMessageHandler.ts index 0f744da2..de469a45 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.ts @@ -47,9 +47,39 @@ function normalizePlanEntries(entries: unknown): PlanItem[] { export class AcpMessageHandler { private readonly toolCalls = new Map(); + private bufferedText = ''; constructor(private readonly onMessage: (message: AgentMessage) => void) {} + flushText(): void { + if (!this.bufferedText) { + return; + } + this.onMessage({ type: 'text', text: this.bufferedText }); + this.bufferedText = ''; + } + + private appendTextChunk(text: string): void { + if (!text) { + return; + } + if (!this.bufferedText) { + this.bufferedText = text; + return; + } + if (text === this.bufferedText) { + return; + } + if (text.startsWith(this.bufferedText)) { + this.bufferedText = text; + return; + } + if (this.bufferedText.startsWith(text)) { + return; + } + this.bufferedText += text; + } + handleUpdate(update: unknown): void { if (!isObject(update)) return; const updateType = asString(update.sessionUpdate); @@ -59,7 +89,7 @@ export class AcpMessageHandler { const content = update.content; const text = extractTextContent(content); if (text) { - this.onMessage({ type: 'text', text }); + this.appendTextChunk(text); } return; } @@ -69,16 +99,19 @@ export class AcpMessageHandler { } if (updateType === ACP_SESSION_UPDATE_TYPES.toolCall) { + this.flushText(); this.handleToolCall(update); return; } if (updateType === ACP_SESSION_UPDATE_TYPES.toolCallUpdate) { + this.flushText(); this.handleToolCallUpdate(update); return; } if (updateType === ACP_SESSION_UPDATE_TYPES.plan) { + this.flushText(); const items = normalizePlanEntries(update.entries); if (items.length > 0) { this.onMessage({ type: 'plan', items }); diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index 973596ab..dac226aa 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -106,6 +106,31 @@ export class AcpSdkBackend implements AgentBackend { return sessionId; } + async loadSession(config: AgentSessionConfig & { sessionId: string }): Promise { + if (!this.transport) { + throw new Error('ACP transport not initialized'); + } + + const response = await withRetry( + () => this.transport!.sendRequest('session/load', { + sessionId: config.sessionId, + cwd: config.cwd, + mcpServers: config.mcpServers + }), + { + ...AcpSdkBackend.INIT_RETRY_OPTIONS, + onRetry: (error, attempt, nextDelayMs) => { + logger.debug(`[ACP] session/load attempt ${attempt} failed, retrying in ${nextDelayMs}ms`, error); + } + } + ); + + const loadedSessionId = isObject(response) ? asString(response.sessionId) : null; + const sessionId = loadedSessionId ?? config.sessionId; + this.activeSessionId = sessionId; + return sessionId; + } + async prompt( sessionId: string, content: PromptContent[], @@ -129,9 +154,11 @@ export class AcpSdkBackend implements AgentBackend { const stopReason = isObject(response) ? asString(response.stopReason) : null; if (stopReason) { + this.messageHandler?.flushText(); onUpdate({ type: 'turn_complete', stopReason }); } } finally { + this.messageHandler?.flushText(); this.messageHandler = null; this.isProcessingMessage = false; this.notifyResponseComplete(); diff --git a/cli/src/commands/claude.ts b/cli/src/commands/claude.ts index 67b2499a..951a6ffa 100644 --- a/cli/src/commands/claude.ts +++ b/cli/src/commands/claude.ts @@ -72,6 +72,7 @@ ${chalk.bold('Usage:')} hapi auth Manage authentication hapi codex Start Codex mode hapi gemini Start Gemini ACP mode + hapi opencode Start OpenCode ACP mode hapi mcp Start MCP stdio bridge hapi connect (not available in direct-connect mode) hapi notify (not available in direct-connect mode) diff --git a/cli/src/commands/opencode.ts b/cli/src/commands/opencode.ts new file mode 100644 index 00000000..86388c0b --- /dev/null +++ b/cli/src/commands/opencode.ts @@ -0,0 +1,56 @@ +import chalk from 'chalk' +import { authAndSetupMachineIfNeeded } from '@/ui/auth' +import { initializeToken } from '@/ui/tokenInit' +import { maybeAutoStartServer } from '@/utils/autoStartServer' +import type { CommandDefinition } from './types' +import type { OpencodePermissionMode } from '@hapi/protocol/types' + +export const opencodeCommand: CommandDefinition = { + name: 'opencode', + requiresRuntimeAssets: true, + run: async ({ commandArgs }) => { + try { + const options: { + startedBy?: 'runner' | 'terminal' + startingMode?: 'local' | 'remote' + permissionMode?: OpencodePermissionMode + resumeSessionId?: string + } = {} + + for (let i = 0; i < commandArgs.length; i++) { + const arg = commandArgs[i] + if (arg === '--started-by') { + options.startedBy = commandArgs[++i] as 'runner' | 'terminal' + } else if (arg === '--hapi-starting-mode') { + const value = commandArgs[++i] + if (value === 'local' || value === 'remote') { + options.startingMode = value + } else { + throw new Error('Invalid --hapi-starting-mode (expected local or remote)') + } + } else if (arg === '--yolo') { + options.permissionMode = 'yolo' + } else if (arg === '--resume') { + const sessionId = commandArgs[++i] + if (!sessionId) { + throw new Error('Missing --resume value') + } + options.resumeSessionId = sessionId + } + } + + await initializeToken() + await maybeAutoStartServer() + await authAndSetupMachineIfNeeded() + + const { runOpencode } = await import('@/opencode/runOpencode') + await runOpencode(options) + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + } +} diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index 268562a3..32a27bb8 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -5,6 +5,7 @@ import { connectCommand } from './connect' import { runnerCommand } from './runner' import { doctorCommand } from './doctor' import { geminiCommand } from './gemini' +import { opencodeCommand } from './opencode' import { hookForwarderCommand } from './hookForwarder' import { mcpCommand } from './mcp' import { notifyCommand } from './notify' @@ -16,6 +17,7 @@ const COMMANDS: CommandDefinition[] = [ connectCommand, codexCommand, geminiCommand, + opencodeCommand, mcpCommand, hubCommand, { ...hubCommand, name: 'server' }, diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index 53e6adf3..2c6f7294 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -4,7 +4,7 @@ export interface SpawnSessionOptions { sessionId?: string resumeSessionId?: string approvedNewDirectoryCreation?: boolean - agent?: 'claude' | 'codex' | 'gemini' + agent?: 'claude' | 'codex' | 'gemini' | 'opencode' model?: string yolo?: boolean token?: string diff --git a/cli/src/modules/common/slashCommands.ts b/cli/src/modules/common/slashCommands.ts index fe64da1c..c2850e03 100644 --- a/cli/src/modules/common/slashCommands.ts +++ b/cli/src/modules/common/slashCommands.ts @@ -42,6 +42,7 @@ const BUILTIN_COMMANDS: Record = { { name: 'clear', description: 'Clear conversation', source: 'builtin' }, { name: 'compress', description: 'Compress context', source: 'builtin' }, ], + opencode: [], }; /** diff --git a/cli/src/opencode/loop.ts b/cli/src/opencode/loop.ts new file mode 100644 index 00000000..79e99ed2 --- /dev/null +++ b/cli/src/opencode/loop.ts @@ -0,0 +1,60 @@ +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { logger } from '@/ui/logger'; +import { runLocalRemoteSession } from '@/agent/loopBase'; +import { OpencodeSession } from './session'; +import { opencodeLocalLauncher } from './opencodeLocalLauncher'; +import { opencodeRemoteLauncher } from './opencodeRemoteLauncher'; +import { ApiClient, ApiSessionClient } from '@/lib'; +import type { OpencodeMode, PermissionMode } from './types'; +import type { OpencodeHookServer } from './utils/startOpencodeHookServer'; + +interface OpencodeLoopOptions { + path: string; + startingMode?: 'local' | 'remote'; + startedBy?: 'runner' | 'terminal'; + onModeChange: (mode: 'local' | 'remote') => void; + messageQueue: MessageQueue2; + session: ApiSessionClient; + api: ApiClient; + permissionMode?: PermissionMode; + resumeSessionId?: string; + hookServer: OpencodeHookServer; + hookUrl: string; + onSessionReady?: (session: OpencodeSession) => void; +} + +export async function opencodeLoop(opts: OpencodeLoopOptions): Promise { + const logPath = logger.getLogPath(); + const startedBy = opts.startedBy ?? 'terminal'; + const startingMode = opts.startingMode ?? 'local'; + + const session = new OpencodeSession({ + api: opts.api, + client: opts.session, + path: opts.path, + sessionId: opts.resumeSessionId ?? null, + logPath, + messageQueue: opts.messageQueue, + onModeChange: opts.onModeChange, + mode: startingMode, + startedBy, + startingMode, + permissionMode: opts.permissionMode ?? 'default' + }); + + if (opts.resumeSessionId) { + session.onSessionFound(opts.resumeSessionId); + } + + await runLocalRemoteSession({ + session, + startingMode: opts.startingMode, + logTag: 'opencode-loop', + runLocal: (instance) => opencodeLocalLauncher(instance, { + hookServer: opts.hookServer, + hookUrl: opts.hookUrl + }), + runRemote: (instance) => opencodeRemoteLauncher(instance), + onSessionReady: opts.onSessionReady + }); +} diff --git a/cli/src/opencode/opencodeLocal.ts b/cli/src/opencode/opencodeLocal.ts new file mode 100644 index 00000000..7dc9d03c --- /dev/null +++ b/cli/src/opencode/opencodeLocal.ts @@ -0,0 +1,37 @@ +import { logger } from '@/ui/logger'; +import { restoreTerminalState } from '@/ui/terminalState'; +import { spawnWithAbort } from '@/utils/spawnWithAbort'; + +export async function opencodeLocal(opts: { + path: string; + abort: AbortSignal; + env: NodeJS.ProcessEnv; + sessionId?: string; +}): Promise { + const args: string[] = []; + if (opts.sessionId) { + args.push('--session', opts.sessionId); + } + + logger.debug(`[OpencodeLocal] Spawning opencode with args: ${JSON.stringify(args)}`); + + process.stdin.pause(); + try { + await spawnWithAbort({ + command: 'opencode', + args, + cwd: opts.path, + env: opts.env, + signal: opts.abort, + shell: process.platform === 'win32', + logLabel: 'OpencodeLocal', + spawnName: 'opencode', + installHint: 'OpenCode CLI', + includeCause: true, + logExit: true + }); + } finally { + process.stdin.resume(); + restoreTerminalState(); + } +} diff --git a/cli/src/opencode/opencodeLocalLauncher.ts b/cli/src/opencode/opencodeLocalLauncher.ts new file mode 100644 index 00000000..e41863c8 --- /dev/null +++ b/cli/src/opencode/opencodeLocalLauncher.ts @@ -0,0 +1,665 @@ +import { logger } from '@/ui/logger'; +import { opencodeLocal } from './opencodeLocal'; +import { OpencodeSession } from './session'; +import { Future } from '@/utils/future'; +import { getLocalLaunchExitReason } from '@/agent/localLaunchPolicy'; +import { ensureOpencodeHookPlugin } from './utils/hookPlugin'; +import { buildOpencodeEnv } from './utils/config'; +import type { OpencodeHookEvent } from './types'; +import type { OpencodeHookServer } from './utils/startOpencodeHookServer'; +import { createOpencodeStorageScanner, type OpencodeStorageScannerHandle } from './utils/opencodeStorageScanner'; +import { randomUUID } from 'node:crypto'; +import { isObject } from '@hapi/protocol'; +import { join } from 'node:path'; +import { configuration } from '@/configuration'; +import type { PermissionCompletion } from '@/modules/common/permission/BasePermissionHandler'; +import { hashObject } from '@/utils/deterministicJson'; + +type OpencodeLocalLauncherOptions = { + hookServer: OpencodeHookServer; + hookUrl: string; +}; + +type ParsedToolCall = { + callId: string; + name: string; + input: unknown; +}; + +type ParsedToolResult = { + callId: string; + output: unknown; +}; + +type PermissionDecision = PermissionCompletion['decision']; + +function getString(value: unknown): string | null { + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + return null; +} + +function parseMaybeJson(value: unknown): unknown { + if (typeof value !== 'string') { + return value; + } + const trimmed = value.trim(); + if (!trimmed) { + return value; + } + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { + return JSON.parse(trimmed); + } catch { + return value; + } + } + return value; +} + +function getNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + return null; +} + +function getTextDelta(payloadRecord: Record | null): string | null { + const delta = payloadRecord?.delta; + return typeof delta === 'string' && delta.length > 0 ? delta : null; +} + +function buildToolSignature(name: string, input: unknown): string { + return `${name}:${hashObject(input ?? null)}`; +} + +function pushQueue(map: Map, key: string, value: string): void { + const queue = map.get(key) ?? []; + queue.push(value); + map.set(key, queue); +} + +function shiftQueue(map: Map, key: string): string | null { + const queue = map.get(key); + if (!queue || queue.length === 0) { + return null; + } + const value = queue.shift() ?? null; + if (!queue.length) { + map.delete(key); + } else { + map.set(key, queue); + } + return value; +} + +function removeFromQueue(map: Map, key: string, value: string): void { + const queue = map.get(key); + if (!queue || queue.length === 0) { + return; + } + const nextQueue = queue.filter((entry) => entry !== value); + if (!nextQueue.length) { + map.delete(key); + } else { + map.set(key, nextQueue); + } +} + +function extractSessionId(value: unknown): string | null { + if (!isObject(value)) { + return null; + } + const record = value as Record; + const direct = getString(record.sessionId) + || getString(record.sessionID) + || getString(record.session_id) + || (isObject(record.session) ? getString((record.session as Record).id) : null); + if (direct) { + return direct; + } + if (isObject(record.part)) { + const nested = extractSessionId(record.part); + if (nested) { + return nested; + } + } + if (isObject(record.info)) { + const nested = extractSessionId(record.info); + if (nested) { + return nested; + } + } + return null; +} + +function unwrapMessage(payload: unknown): Record | null { + if (!isObject(payload)) { + return null; + } + const record = payload as Record; + if (isObject(record.message)) { + return record.message as Record; + } + if (isObject(record.info)) { + return record.info as Record; + } + return record; +} + +function unwrapPart(payload: unknown): Record | null { + if (isObject(payload)) { + const record = payload as Record; + if (isObject(record.part)) { + return record.part as Record; + } + return record; + } + return null; +} + +function parseToolCall(part: unknown): ParsedToolCall | null { + if (!isObject(part)) { + return null; + } + const record = part as Record; + const name = getString(record.tool) || getString(record.name); + const callId = getString(record.callID) + || getString(record.callId) + || getString(record.id) + || getString(record.tool_call_id) + || getString(record.toolCallId); + if (!name || !callId) { + return null; + } + if (getString(record.type) === 'tool' && isObject(record.state)) { + const state = record.state as Record; + const status = getString(state.status); + if (status !== 'pending' && status !== 'running') { + return null; + } + const input = parseMaybeJson(state.input ?? state.raw ?? record.input ?? record.args ?? record.arguments); + return { callId, name, input }; + } + const input = parseMaybeJson(record.input ?? record.args ?? record.arguments ?? record.raw); + return { callId, name, input }; +} + +function parseToolResult(part: unknown): ParsedToolResult | null { + if (!isObject(part)) { + return null; + } + const record = part as Record; + const callId = getString(record.callID) + || getString(record.callId) + || getString(record.tool_call_id) + || getString(record.toolCallId) + || getString(record.id); + if (!callId) { + return null; + } + if (getString(record.type) === 'tool' && isObject(record.state)) { + const state = record.state as Record; + const status = getString(state.status); + if (status === 'completed') { + const output = { + content: state.output ?? state.title, + metadata: state.metadata, + title: state.title, + attachments: state.attachments + }; + return { callId, output }; + } + if (status === 'error') { + const output = { + content: state.error, + isError: true + }; + return { callId, output }; + } + return null; + } + const output = { + content: record.content, + metadata: record.metadata, + isError: record.is_error + }; + return { callId, output }; +} + +function normalizeDecision(response: string | null, approved: boolean): PermissionDecision { + if (response === 'always' || response === 'approved_for_session') { + return 'approved_for_session'; + } + if (response === 'once' || response === 'approved') { + return 'approved'; + } + if (response === 'reject' || response === 'denied') { + return 'denied'; + } + if (response === 'abort' || response === 'cancel' || response === 'canceled') { + return 'abort'; + } + return approved ? 'approved' : 'denied'; +} + +function resolveOpencodeConfigDir(session: OpencodeSession): string { + if (process.env.OPENCODE_CONFIG_DIR) { + return process.env.OPENCODE_CONFIG_DIR; + } + return join(configuration.happyHomeDir, 'tmp', 'opencode', session.client.sessionId, '.opencode'); +} + +export async function opencodeLocalLauncher( + session: OpencodeSession, + opts: OpencodeLocalLauncherOptions +): Promise<'switch' | 'exit'> { + let exitReason: 'switch' | 'exit' | null = null; + const processAbortController = new AbortController(); + const exitFuture = new Future(); + const hookUrl = opts.hookUrl; + + const opencodeConfigDir = resolveOpencodeConfigDir(session); + ensureOpencodeHookPlugin(opencodeConfigDir, hookUrl, opts.hookServer.token); + + let storageScanner: OpencodeStorageScannerHandle | null = null; + const messageRoles = new Map(); + const sentTextParts = new Set(); + const sentToolCalls = new Set(); + const sentToolResults = new Set(); + const textBuffers = new Map(); + const toolExecutionQueues = new Map(); + + const handleHookEvent = (event: OpencodeHookEvent) => { + const payload = event.payload; + const eventType = event.event; + const payloadRecord = isObject(payload) ? payload as Record : null; + const sessionId = event.sessionId + || extractSessionId(payload) + || (payloadRecord ? extractSessionId(payloadRecord.info) : null); + if (sessionId) { + session.onSessionFound(sessionId); + storageScanner?.onNewSession(sessionId); + } + + if (eventType === 'session.created' || eventType === 'session.updated') { + if (payloadRecord) { + const info = isObject(payloadRecord.info) ? payloadRecord.info as Record : payloadRecord; + const sessionIdValue = extractSessionId(info) || getString(info.id); + if (sessionIdValue) { + session.onSessionFound(sessionIdValue); + storageScanner?.onNewSession(sessionIdValue); + } + } + return; + } + + if (eventType === 'message.updated') { + const message = unwrapMessage(payload); + if (!message) { + return; + } + const messageId = getString(message.id) || getString(message.messageId); + const role = getString(message.role); + if (messageId && role) { + messageRoles.set(messageId, role); + } + return; + } + + if (eventType === 'message.part.updated') { + const part = unwrapPart(payload); + if (!part) { + return; + } + const partType = getString(part.type); + const partId = getString(part.id); + const messageId = getString(part.messageID) + || getString(part.messageId) + || (payloadRecord ? getString(payloadRecord.messageID) || getString(payloadRecord.messageId) : null); + const delta = getTextDelta(payloadRecord); + + if (partType === 'text') { + if (partId && sentTextParts.has(partId)) { + return; + } + const role = (messageId && messageRoles.get(messageId)) ?? 'assistant'; + const key = partId ?? messageId; + const bufferValue = key ? textBuffers.get(key) ?? '' : ''; + const textFromPart = getString(part.text); + const nextBuffer = delta ? bufferValue + delta : bufferValue; + + if (key && (delta || textFromPart)) { + textBuffers.set(key, textFromPart ?? nextBuffer); + } + + const time = isObject(part.time) ? part.time as Record : null; + const hasEnd = time ? getNumber(time.end) !== null : false; + const shouldFlush = role === 'user' + || part.synthetic === true + || hasEnd + || (!delta && Boolean(textFromPart)); + const text = textFromPart ?? (key ? textBuffers.get(key) : null); + if (shouldFlush && text) { + if (role === 'user') { + session.sendUserMessage(text); + } else { + session.sendCodexMessage({ type: 'message', message: text }); + } + if (partId) { + sentTextParts.add(partId); + } + if (key) { + textBuffers.delete(key); + } + } + return; + } + + const toolCall = parseToolCall(part); + if (toolCall && !sentToolCalls.has(toolCall.callId)) { + sentToolCalls.add(toolCall.callId); + session.sendCodexMessage({ + type: 'tool-call', + name: toolCall.name, + callId: toolCall.callId, + input: toolCall.input + }); + } + + const toolResult = parseToolResult(part); + if (toolResult && !sentToolResults.has(toolResult.callId)) { + sentToolResults.add(toolResult.callId); + session.sendCodexMessage({ + type: 'tool-call-result', + callId: toolResult.callId, + output: toolResult.output + }); + } + return; + } + + if (eventType === 'tool.execute.before' || eventType === 'tool.execute.after') { + if (!isObject(payload)) { + return; + } + const record = payload as Record; + const tool = isObject(record.tool) ? record.tool as Record : record; + const name = getString(tool.name) || getString(record.name); + if (!name) { + return; + } + const toolInput = parseMaybeJson(tool.input ?? tool.args ?? record.input ?? record.args); + const signature = buildToolSignature(name, toolInput); + const fallbackSignature = buildToolSignature(name, null); + const existingId = getString(tool.id) + || getString(tool.tool_call_id) + || getString(tool.toolCallId); + const isBefore = eventType === 'tool.execute.before'; + let callId = existingId; + + if (!callId) { + callId = isBefore + ? randomUUID() + : shiftQueue(toolExecutionQueues, signature) + ?? shiftQueue(toolExecutionQueues, fallbackSignature) + ?? randomUUID(); + } + + if (isBefore) { + pushQueue(toolExecutionQueues, signature, callId); + if (fallbackSignature !== signature) { + pushQueue(toolExecutionQueues, fallbackSignature, callId); + } + } else { + removeFromQueue(toolExecutionQueues, signature, callId); + if (fallbackSignature !== signature) { + removeFromQueue(toolExecutionQueues, fallbackSignature, callId); + } + } + if (eventType === 'tool.execute.before' && !sentToolCalls.has(callId)) { + sentToolCalls.add(callId); + session.sendCodexMessage({ + type: 'tool-call', + name, + callId, + input: toolInput + }); + return; + } + if (eventType === 'tool.execute.after' && !sentToolResults.has(callId)) { + sentToolResults.add(callId); + session.sendCodexMessage({ + type: 'tool-call-result', + callId, + output: { + content: tool.content ?? record.content ?? record.output, + metadata: tool.metadata ?? record.metadata, + isError: tool.is_error ?? record.is_error + } + }); + return; + } + } + + if (eventType === 'permission.updated' || eventType === 'permission.asked') { + if (!isObject(payload)) { + return; + } + const record = payload as Record; + const metadata = isObject(record.metadata) ? record.metadata as Record : undefined; + const id = getString(record.id) + || getString(record.permissionID) + || getString(record.permissionId) + || getString(record.requestID) + || getString(record.requestId); + if (!id) { + return; + } + + const toolName = getString(record.permission) + || getString(record.type) + || (metadata ? getString(metadata.tool) : null) + || 'Permission'; + + const toolInput = metadata?.input + ?? record.pattern + ?? record.message + ?? record.metadata; + + session.client.updateAgentState((currentState) => ({ + ...currentState, + requests: { + ...currentState.requests, + [id]: { + tool: toolName, + arguments: toolInput, + createdAt: Date.now() + } + } + })); + return; + } + + if (eventType === 'permission.replied') { + if (!isObject(payload)) { + return; + } + const record = payload as Record; + const metadata = isObject(record.metadata) ? record.metadata as Record : undefined; + const id = getString(record.permissionID) + || getString(record.permissionId) + || getString(record.requestID) + || getString(record.requestId) + || getString(record.id); + if (!id) { + return; + } + + const toolName = getString(record.permission) + || getString(record.type) + || (metadata ? getString(metadata.tool) : null) + || 'Permission'; + + const toolInput = metadata?.input + ?? record.pattern + ?? record.message + ?? record.metadata; + + const response = getString(record.response) + || getString(record.reply) + || getString(record.decision); + const approved = record.approved === true + || response === 'once' + || response === 'always' + || response === 'approved'; + const decision = normalizeDecision(response, approved); + const status = decision === 'approved' || decision === 'approved_for_session' + ? 'approved' + : decision === 'abort' + ? 'canceled' + : 'denied'; + const reason = getString(record.reason) ?? undefined; + const allowTools = Array.isArray(record.allowTools) ? record.allowTools : undefined; + + session.client.updateAgentState((currentState) => { + const request = currentState.requests?.[id] ?? { + tool: toolName, + arguments: toolInput, + createdAt: Date.now() + }; + const nextRequests = { ...(currentState.requests || {}) }; + delete nextRequests[id]; + return { + ...currentState, + requests: nextRequests, + completedRequests: { + ...currentState.completedRequests, + [id]: { + ...request, + completedAt: Date.now(), + status, + decision, + reason, + allowTools + } + } + }; + }); + return; + } + }; + + session.addHookEventHandler(handleHookEvent); + + try { + try { + storageScanner = await createOpencodeStorageScanner({ + sessionId: session.sessionId, + cwd: session.path, + onEvent: (event) => session.emitHookEvent(event), + onSessionFound: (sessionId) => { + session.onSessionFound(sessionId); + }, + onSessionMatchFailed: (message) => { + session.sendSessionEvent({ type: 'message', message }); + } + }); + } catch (error) { + logger.debug('[opencode-local]: Failed to start storage scanner', error); + } + + const abortProcess = async () => { + if (!processAbortController.signal.aborted) { + processAbortController.abort(); + } + await exitFuture.promise; + }; + + const doAbort = async () => { + logger.debug('[opencode-local]: abort requested'); + if (!exitReason) { + exitReason = 'switch'; + } + session.queue.reset(); + await abortProcess(); + }; + + const doSwitch = async () => { + logger.debug('[opencode-local]: switch requested'); + if (!exitReason) { + exitReason = 'switch'; + } + await abortProcess(); + }; + + session.client.rpcHandlerManager.registerHandler('abort', doAbort); + session.client.rpcHandlerManager.registerHandler('switch', doSwitch); + session.queue.setOnMessage(() => { + void doSwitch(); + }); + + if (session.queue.size() > 0) { + return 'switch'; + } + + while (true) { + if (exitReason) { + return exitReason; + } + + logger.debug('[opencode-local]: launch'); + try { + const env = buildOpencodeEnv(); + env.HAPI_OPENCODE_HOOK_URL = hookUrl; + env.HAPI_OPENCODE_HOOK_TOKEN = opts.hookServer.token; + if (!env.OPENCODE_CONFIG_DIR) { + env.OPENCODE_CONFIG_DIR = opencodeConfigDir; + } + + await opencodeLocal({ + path: session.path, + abort: processAbortController.signal, + env, + sessionId: session.sessionId ?? undefined + }); + + if (!exitReason) { + exitReason = 'exit'; + break; + } + } catch (error) { + logger.debug('[opencode-local]: launch error', error); + const message = error instanceof Error ? error.message : String(error); + session.sendSessionEvent({ + type: 'message', + message: `Local OpenCode process failed: ${message}` + }); + const failureExitReason = exitReason ?? getLocalLaunchExitReason({ + startedBy: session.startedBy, + startingMode: session.startingMode + }); + session.recordLocalLaunchFailure(message, failureExitReason); + if (!exitReason) { + exitReason = failureExitReason; + } + if (failureExitReason === 'exit') { + logger.warn(`[opencode-local]: Local OpenCode process failed: ${message}`); + } + break; + } + } + } finally { + exitFuture.resolve(undefined); + session.client.rpcHandlerManager.registerHandler('abort', async () => {}); + session.client.rpcHandlerManager.registerHandler('switch', async () => {}); + session.queue.setOnMessage(null); + session.removeHookEventHandler(handleHookEvent); + if (storageScanner) { + await storageScanner.cleanup(); + } + } + + return exitReason || 'exit'; +} diff --git a/cli/src/opencode/opencodeRemoteLauncher.ts b/cli/src/opencode/opencodeRemoteLauncher.ts new file mode 100644 index 00000000..1a3addcb --- /dev/null +++ b/cli/src/opencode/opencodeRemoteLauncher.ts @@ -0,0 +1,241 @@ +import React from 'react'; +import { logger } from '@/ui/logger'; +import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge'; +import { convertAgentMessage } from '@/agent/messageConverter'; +import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types'; +import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from '@/modules/common/remote/RemoteLauncherBase'; +import { OpencodeDisplay } from '@/ui/ink/OpencodeDisplay'; +import type { OpencodeSession } from './session'; +import type { PermissionMode } from './types'; +import { createOpencodeBackend } from './utils/opencodeBackend'; +import { OpencodePermissionHandler } from './utils/permissionHandler'; + +class OpencodeRemoteLauncher extends RemoteLauncherBase { + private readonly session: OpencodeSession; + private backend: ReturnType | null = null; + private permissionHandler: OpencodePermissionHandler | null = null; + private happyServer: { stop: () => void } | null = null; + private abortController = new AbortController(); + private displayPermissionMode: PermissionMode | null = null; + + constructor(session: OpencodeSession) { + super(process.env.DEBUG ? session.logPath : undefined); + this.session = session; + } + + public async launch(): Promise { + return this.start({ + onExit: () => this.handleExitFromUi(), + onSwitchToLocal: () => this.handleSwitchFromUi() + }); + } + + protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement { + return React.createElement(OpencodeDisplay, context); + } + + protected async runMainLoop(): Promise { + const session = this.session; + const messageBuffer = this.messageBuffer; + + const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client); + this.happyServer = happyServer; + + const backend = createOpencodeBackend({ + cwd: session.path + }); + this.backend = backend; + + backend.onStderrError((error) => { + logger.debug('[opencode-remote] stderr error', error); + session.sendSessionEvent({ type: 'message', message: error.message }); + messageBuffer.addMessage(error.message, 'status'); + }); + + await backend.initialize(); + + const resumeSessionId = session.sessionId; + const mcpServerList = toAcpMcpServers(mcpServers); + let acpSessionId: string; + if (resumeSessionId) { + try { + acpSessionId = await backend.loadSession({ + sessionId: resumeSessionId, + cwd: session.path, + mcpServers: mcpServerList + }); + } catch (error) { + logger.warn('[opencode-remote] resume failed, starting new session', error); + session.sendSessionEvent({ + type: 'message', + message: 'OpenCode resume failed; starting a new session.' + }); + acpSessionId = await backend.newSession({ + cwd: session.path, + mcpServers: mcpServerList + }); + } + } else { + acpSessionId = await backend.newSession({ + cwd: session.path, + mcpServers: mcpServerList + }); + } + session.onSessionFound(acpSessionId); + + this.permissionHandler = new OpencodePermissionHandler( + session.client, + backend, + () => session.getPermissionMode() as PermissionMode | undefined + ); + this.applyDisplayMode(session.getPermissionMode() as PermissionMode); + + this.setupAbortHandlers(session.client.rpcHandlerManager, { + onAbort: () => this.handleAbort(), + onSwitch: () => this.handleSwitchRequest() + }); + + const sendReady = () => { + session.sendSessionEvent({ type: 'ready' }); + }; + + while (!this.shouldExit) { + const waitSignal = this.abortController.signal; + const batch = await session.queue.waitForMessagesAndGetAsString(waitSignal); + if (!batch) { + if (waitSignal.aborted && !this.shouldExit) { + continue; + } + break; + } + + this.applyDisplayMode(batch.mode.permissionMode); + messageBuffer.addMessage(batch.message, 'user'); + + const promptContent: PromptContent[] = [{ + type: 'text', + text: batch.message + }]; + + session.onThinkingChange(true); + + try { + await backend.prompt(acpSessionId, promptContent, (message: AgentMessage) => { + this.handleAgentMessage(message); + }); + } catch (error) { + logger.warn('[opencode-remote] prompt failed', error); + session.sendSessionEvent({ + type: 'message', + message: 'OpenCode prompt failed. Check logs for details.' + }); + messageBuffer.addMessage('OpenCode prompt failed', 'status'); + } finally { + session.onThinkingChange(false); + await this.permissionHandler?.cancelAll('Prompt finished'); + if (session.queue.size() === 0 && !this.shouldExit) { + sendReady(); + } + } + } + } + + protected async cleanup(): Promise { + this.clearAbortHandlers(this.session.client.rpcHandlerManager); + + if (this.permissionHandler) { + await this.permissionHandler.cancelAll('Session ended'); + this.permissionHandler = null; + } + + if (this.backend) { + await this.backend.disconnect(); + this.backend = null; + } + + if (this.happyServer) { + this.happyServer.stop(); + this.happyServer = null; + } + } + + private handleAgentMessage(message: AgentMessage): void { + const converted = convertAgentMessage(message); + if (converted) { + this.session.sendCodexMessage(converted); + } + + switch (message.type) { + case 'text': + this.messageBuffer.addMessage(message.text, 'assistant'); + break; + case 'tool_call': + this.messageBuffer.addMessage(`Tool call: ${message.name}`, 'tool'); + break; + case 'tool_result': + this.messageBuffer.addMessage('Tool result received', 'result'); + break; + case 'plan': + this.messageBuffer.addMessage('Plan updated', 'status'); + break; + case 'error': + this.messageBuffer.addMessage(message.message, 'status'); + break; + case 'turn_complete': + this.messageBuffer.addMessage('Turn complete', 'status'); + break; + default: { + const _exhaustive: never = message; + return _exhaustive; + } + } + } + + private applyDisplayMode(permissionMode: PermissionMode | undefined): void { + if (permissionMode && permissionMode !== this.displayPermissionMode) { + this.displayPermissionMode = permissionMode; + this.messageBuffer.addMessage(`[MODE:${permissionMode}]`, 'system'); + } + } + + private async handleAbort(): Promise { + const backend = this.backend; + if (backend && this.session.sessionId) { + await backend.cancelPrompt(this.session.sessionId); + } + await this.permissionHandler?.cancelAll('User aborted'); + this.session.queue.reset(); + this.session.onThinkingChange(false); + this.abortController.abort(); + this.abortController = new AbortController(); + this.messageBuffer.addMessage('Turn aborted', 'status'); + } + + private async handleExitFromUi(): Promise { + await this.requestExit('exit', () => this.handleAbort()); + } + + private async handleSwitchFromUi(): Promise { + await this.requestExit('switch', () => this.handleAbort()); + } + + private async handleSwitchRequest(): Promise { + await this.requestExit('switch', () => this.handleAbort()); + } +} + +function toAcpMcpServers(config: Record): McpServerStdio[] { + return Object.entries(config).map(([name, entry]) => ({ + name, + command: entry.command, + args: entry.args, + env: [] + })); +} + +export async function opencodeRemoteLauncher( + session: OpencodeSession +): Promise<'switch' | 'exit'> { + const launcher = new OpencodeRemoteLauncher(session); + return launcher.launch(); +} diff --git a/cli/src/opencode/runOpencode.ts b/cli/src/opencode/runOpencode.ts new file mode 100644 index 00000000..6888c36d --- /dev/null +++ b/cli/src/opencode/runOpencode.ts @@ -0,0 +1,145 @@ +import { logger } from '@/ui/logger'; +import { opencodeLoop } from './loop'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { hashObject } from '@/utils/deterministicJson'; +import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; +import type { AgentState } from '@/api/types'; +import type { OpencodeSession } from './session'; +import type { OpencodeMode, PermissionMode } from './types'; +import { bootstrapSession } from '@/agent/sessionFactory'; +import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; +import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; +import { PermissionModeSchema } from '@hapi/protocol/schemas'; +import { startOpencodeHookServer } from './utils/startOpencodeHookServer'; +import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; + +export async function runOpencode(opts: { + startedBy?: 'runner' | 'terminal'; + startingMode?: 'local' | 'remote'; + permissionMode?: PermissionMode; + resumeSessionId?: string; +} = {}): Promise { + const workingDirectory = process.cwd(); + const startedBy = opts.startedBy ?? 'terminal'; + + logger.debug(`[opencode] Starting with options: startedBy=${startedBy}, startingMode=${opts.startingMode}`); + + if (startedBy === 'runner' && opts.startingMode === 'local') { + logger.debug('[opencode] Runner spawn requested with local mode; forcing remote mode'); + opts.startingMode = 'remote'; + } + + const initialState: AgentState = { + controlledByUser: false + }; + + const { api, session } = await bootstrapSession({ + flavor: 'opencode', + startedBy, + workingDirectory, + agentState: initialState + }); + + const startingMode: 'local' | 'remote' = opts.startingMode + ?? (startedBy === 'runner' ? 'remote' : 'local'); + + setControlledByUser(session, startingMode); + + const messageQueue = new MessageQueue2((mode) => hashObject({ + permissionMode: mode.permissionMode + })); + + const sessionWrapperRef: { current: OpencodeSession | null } = { current: null }; + let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; + const hookServer = await startOpencodeHookServer({ + onEvent: (event) => { + const currentSession = sessionWrapperRef.current; + if (!currentSession) { + return; + } + currentSession.emitHookEvent(event); + } + }); + const hookUrl = `http://127.0.0.1:${hookServer.port}/hook/opencode`; + + const lifecycle = createRunnerLifecycle({ + session, + logTag: 'opencode', + stopKeepAlive: () => sessionWrapperRef.current?.stopKeepAlive(), + onAfterClose: () => { + hookServer.stop(); + } + }); + + lifecycle.registerProcessHandlers(); + registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); + + const syncSessionMode = () => { + const sessionInstance = sessionWrapperRef.current; + if (!sessionInstance) { + return; + } + sessionInstance.setPermissionMode(currentPermissionMode); + logger.debug(`[opencode] Synced session permission mode for keepalive: ${currentPermissionMode}`); + }; + + session.onUserMessage((message) => { + const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); + const mode: OpencodeMode = { + permissionMode: currentPermissionMode + }; + messageQueue.push(formattedText, mode); + }); + + const resolvePermissionMode = (value: unknown): PermissionMode => { + const parsed = PermissionModeSchema.safeParse(value); + if (!parsed.success || !isPermissionModeAllowedForFlavor(parsed.data, 'opencode')) { + throw new Error('Invalid permission mode'); + } + return parsed.data as PermissionMode; + }; + + session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => { + if (!payload || typeof payload !== 'object') { + throw new Error('Invalid session config payload'); + } + const config = payload as { permissionMode?: unknown }; + + if (config.permissionMode !== undefined) { + currentPermissionMode = resolvePermissionMode(config.permissionMode); + } + + syncSessionMode(); + return { applied: { permissionMode: currentPermissionMode } }; + }); + + try { + await opencodeLoop({ + path: workingDirectory, + startingMode, + startedBy, + messageQueue, + session, + api, + permissionMode: currentPermissionMode, + resumeSessionId: opts.resumeSessionId, + hookServer, + hookUrl, + onModeChange: createModeChangeHandler(session), + onSessionReady: (instance) => { + sessionWrapperRef.current = instance; + syncSessionMode(); + } + }); + } catch (error) { + lifecycle.markCrash(error); + logger.debug('[opencode] Loop error:', error); + } finally { + const localFailure = sessionWrapperRef.current?.localLaunchFailure; + if (localFailure?.exitReason === 'exit') { + lifecycle.setExitCode(1); + lifecycle.setArchiveReason(`Local launch failed: ${localFailure.message.slice(0, 200)}`); + } + await lifecycle.cleanupAndExit(); + } +} diff --git a/cli/src/opencode/session.ts b/cli/src/opencode/session.ts new file mode 100644 index 00000000..bafc9908 --- /dev/null +++ b/cli/src/opencode/session.ts @@ -0,0 +1,91 @@ +import { ApiClient, ApiSessionClient } from '@/lib'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { AgentSessionBase } from '@/agent/sessionBase'; +import type { OpencodeHookEvent, OpencodeMode, PermissionMode } from './types'; +import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; + +type LocalLaunchFailure = { + message: string; + exitReason: LocalLaunchExitReason; +}; + +export class OpencodeSession extends AgentSessionBase { + readonly startedBy: 'runner' | 'terminal'; + readonly startingMode: 'local' | 'remote'; + localLaunchFailure: LocalLaunchFailure | null = null; + + private hookEventHandlers: Array<(event: OpencodeHookEvent) => void> = []; + + constructor(opts: { + api: ApiClient; + client: ApiSessionClient; + path: string; + logPath: string; + sessionId: string | null; + messageQueue: MessageQueue2; + onModeChange: (mode: 'local' | 'remote') => void; + mode?: 'local' | 'remote'; + startedBy: 'runner' | 'terminal'; + startingMode: 'local' | 'remote'; + permissionMode?: PermissionMode; + }) { + super({ + api: opts.api, + client: opts.client, + path: opts.path, + logPath: opts.logPath, + sessionId: opts.sessionId, + messageQueue: opts.messageQueue, + onModeChange: opts.onModeChange, + mode: opts.mode, + sessionLabel: 'OpencodeSession', + sessionIdLabel: 'OpenCode', + applySessionIdToMetadata: (metadata, sessionId) => ({ + ...metadata, + opencodeSessionId: sessionId + }), + permissionMode: opts.permissionMode + }); + + this.startedBy = opts.startedBy; + this.startingMode = opts.startingMode; + this.permissionMode = opts.permissionMode; + } + + addHookEventHandler(cb: (event: OpencodeHookEvent) => void): void { + this.hookEventHandlers.push(cb); + } + + removeHookEventHandler(cb: (event: OpencodeHookEvent) => void): void { + const index = this.hookEventHandlers.indexOf(cb); + if (index !== -1) { + this.hookEventHandlers.splice(index, 1); + } + } + + emitHookEvent(event: OpencodeHookEvent): void { + for (const handler of this.hookEventHandlers) { + handler(event); + } + } + + setPermissionMode = (mode: PermissionMode): void => { + this.permissionMode = mode; + }; + + recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { + this.localLaunchFailure = { message, exitReason }; + }; + + sendCodexMessage = (message: unknown): void => { + this.client.sendCodexMessage(message); + }; + + sendUserMessage = (text: string): void => { + this.client.sendUserMessage(text); + }; + + sendSessionEvent = (event: Parameters[0]): void => { + this.client.sendSessionEvent(event); + }; +} diff --git a/cli/src/opencode/types.ts b/cli/src/opencode/types.ts new file mode 100644 index 00000000..a91f2292 --- /dev/null +++ b/cli/src/opencode/types.ts @@ -0,0 +1,13 @@ +import type { OpencodePermissionMode } from '@hapi/protocol/types'; + +export type PermissionMode = OpencodePermissionMode; + +export interface OpencodeMode { + permissionMode: PermissionMode; +} + +export type OpencodeHookEvent = { + event: string; + payload: unknown; + sessionId?: string; +}; diff --git a/cli/src/opencode/utils/config.ts b/cli/src/opencode/utils/config.ts new file mode 100644 index 00000000..dae5cb51 --- /dev/null +++ b/cli/src/opencode/utils/config.ts @@ -0,0 +1,5 @@ +export function buildOpencodeEnv(): NodeJS.ProcessEnv { + return { + ...process.env + }; +} diff --git a/cli/src/opencode/utils/hookPlugin.ts b/cli/src/opencode/utils/hookPlugin.ts new file mode 100644 index 00000000..5916dd1a --- /dev/null +++ b/cli/src/opencode/utils/hookPlugin.ts @@ -0,0 +1,135 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const PLUGIN_FILENAME = 'hapi-hook.ts'; + +function buildPluginSource(hookUrl: string, token: string): string { + const escapedUrl = JSON.stringify(hookUrl); + const escapedToken = JSON.stringify(token); + + return [ + '// Generated by HAPI. Do not edit manually.', + '', + `const DEFAULT_HOOK_URL = ${escapedUrl};`, + `const DEFAULT_HOOK_TOKEN = ${escapedToken};`, + 'const HOOK_URL = process.env.HAPI_OPENCODE_HOOK_URL || DEFAULT_HOOK_URL;', + 'const HOOK_TOKEN = process.env.HAPI_OPENCODE_HOOK_TOKEN || DEFAULT_HOOK_TOKEN;', + '', + 'const EVENT_NAMES = new Set([', + " 'message.updated',", + " 'message.part.updated',", + " 'permission.updated',", + " 'permission.asked',", + " 'permission.replied',", + " 'session.created',", + " 'session.updated',", + " 'tool.execute.before',", + " 'tool.execute.after',", + ']);', + '', + 'function pickString(value) {', + " return typeof value === 'string' && value.length > 0 ? value : null;", + '}', + '', + 'function extractSessionId(value) {', + " if (!value || typeof value !== 'object') return null;", + ' const record = value;', + ' const direct = (', + ' pickString(record.sessionId)', + ' || pickString(record.sessionID)', + ' || pickString(record.session_id)', + ' || (record.session && pickString(record.session.id))', + ' );', + ' if (direct) return direct;', + ' if (record.part && typeof record.part === \'object\') {', + ' const nested = extractSessionId(record.part);', + ' if (nested) return nested;', + ' }', + ' if (record.info && typeof record.info === \'object\') {', + ' const nested = extractSessionId(record.info);', + ' if (nested) return nested;', + ' }', + ' return null;', + '}', + '', + 'function extractSessionIdFromEvent(eventName, payload) {', + ' const direct = extractSessionId(payload);', + ' if (direct) return direct;', + ' if (!payload || typeof payload !== \'object\') return null;', + ' const record = payload;', + ' if (record.info && typeof record.info === \'object\') {', + ' const fromInfo = extractSessionId(record.info);', + ' if (fromInfo) return fromInfo;', + ' if (eventName.startsWith(\'session.\')) {', + ' return pickString(record.info.id);', + ' }', + ' }', + ' return null;', + '}', + '', + 'async function sendHook(eventName, payload, sessionId) {', + ' if (!HOOK_URL || !HOOK_TOKEN) {', + ' return;', + ' }', + '', + ' const body = JSON.stringify({', + ' event: eventName,', + ' payload,', + ' sessionId', + ' });', + '', + ' try {', + ' await fetch(HOOK_URL, {', + " method: 'POST',", + ' headers: {', + " 'Content-Type': 'application/json',", + " 'x-hapi-hook-token': HOOK_TOKEN", + ' },', + ' body', + ' });', + ' } catch {', + ' // Ignore hook errors to avoid disrupting OpenCode', + ' }', + '}', + '', + 'export const HapiHookPlugin = async () => {', + ' return {', + ' event: async ({ event }) => {', + ' if (!event || typeof event.type !== \'string\') {', + ' return;', + ' }', + ' if (EVENT_NAMES.size > 0 && !EVENT_NAMES.has(event.type)) {', + ' return;', + ' }', + ' const sessionId = extractSessionIdFromEvent(event.type, event.properties);', + ' await sendHook(event.type, event.properties, sessionId);', + ' }', + ' };', + '};', + '' + ].join('\\n'); +} + +function resolvePluginDir(rootPath: string): string { + return join(rootPath, 'plugins'); +} + +export function ensureOpencodeHookPlugin(rootPath: string, hookUrl: string, token: string): string { + const pluginDir = resolvePluginDir(rootPath); + mkdirSync(pluginDir, { recursive: true }); + + const pluginPath = join(pluginDir, PLUGIN_FILENAME); + const nextSource = buildPluginSource(hookUrl, token); + + try { + const current = readFileSync(pluginPath, 'utf-8'); + if (current === nextSource) { + return pluginPath; + } + } catch { + // Ignore missing or unreadable file. + } + + writeFileSync(pluginPath, nextSource, 'utf-8'); + return pluginPath; +} diff --git a/cli/src/opencode/utils/opencodeBackend.ts b/cli/src/opencode/utils/opencodeBackend.ts new file mode 100644 index 00000000..ea0da22a --- /dev/null +++ b/cli/src/opencode/utils/opencodeBackend.ts @@ -0,0 +1,25 @@ +import { AcpSdkBackend } from '@/agent/backends/acp'; +import { buildOpencodeEnv } from './config'; + +function filterEnv(env: NodeJS.ProcessEnv): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + result[key] = value; + } + } + return result; +} + +export function createOpencodeBackend(opts: { + cwd?: string; +}): AcpSdkBackend { + const env = buildOpencodeEnv(); + const args = ['acp', '--cwd', opts.cwd ?? process.cwd()]; + + return new AcpSdkBackend({ + command: 'opencode', + args, + env: filterEnv(env) + }); +} diff --git a/cli/src/opencode/utils/opencodeStorageScanner.ts b/cli/src/opencode/utils/opencodeStorageScanner.ts new file mode 100644 index 00000000..0d2010f6 --- /dev/null +++ b/cli/src/opencode/utils/opencodeStorageScanner.ts @@ -0,0 +1,522 @@ +import { logger } from '@/ui/logger'; +import { readdir, readFile, stat } from 'node:fs/promises'; +import type { Dirent } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { isObject } from '@hapi/protocol'; +import type { OpencodeHookEvent } from '../types'; + +export type OpencodeStorageScannerHandle = { + cleanup: () => Promise; + onNewSession: (sessionId: string) => void; +}; + +type OpencodeStorageScannerOptions = { + sessionId: string | null; + cwd: string; + onEvent: (event: OpencodeHookEvent) => void; + onSessionFound?: (sessionId: string) => void; + onSessionMatchFailed?: (message: string) => void; + storageDir?: string; + intervalMs?: number; + sessionStartWindowMs?: number; + startupTimestampMs?: number; +}; + +type SessionCandidate = { + sessionId: string; + score: number; +}; + +const DEFAULT_SESSION_START_WINDOW_MS = 2 * 60 * 1000; +const DEFAULT_SCAN_INTERVAL_MS = 2000; +const REPLAY_CLOCK_SKEW_MS = 2000; + +export async function createOpencodeStorageScanner( + opts: OpencodeStorageScannerOptions +): Promise { + const scanner = new OpencodeStorageScanner(opts); + await scanner.start(); + + return { + cleanup: async () => { + await scanner.cleanup(); + }, + onNewSession: (sessionId: string) => { + void scanner.onNewSession(sessionId); + } + }; +} + +class OpencodeStorageScanner { + private readonly storageDir: string; + private readonly targetCwd: string | null; + private readonly onEvent: (event: OpencodeHookEvent) => void; + private readonly onSessionFound?: (sessionId: string) => void; + private readonly onSessionMatchFailed?: (message: string) => void; + private readonly referenceTimestampMs: number; + private readonly sessionStartWindowMs: number; + private readonly matchDeadlineMs: number; + private readonly intervalMs: number; + private readonly seedSessionId: string | null; + + private intervalId: ReturnType | null = null; + private activeSessionId: string | null = null; + private matchFailed = false; + private warnedMissingStorage = false; + private scanning = false; + + private readonly messageRoles = new Map(); + private readonly messageFileMtime = new Map(); + private readonly partFileMtime = new Map(); + + constructor(opts: OpencodeStorageScannerOptions) { + this.storageDir = opts.storageDir ?? resolveOpencodeStorageDir(); + this.targetCwd = opts.cwd ? normalizePath(opts.cwd) : null; + this.onEvent = opts.onEvent; + this.onSessionFound = opts.onSessionFound; + this.onSessionMatchFailed = opts.onSessionMatchFailed; + this.referenceTimestampMs = opts.startupTimestampMs ?? Date.now(); + this.sessionStartWindowMs = opts.sessionStartWindowMs ?? DEFAULT_SESSION_START_WINDOW_MS; + this.matchDeadlineMs = this.referenceTimestampMs + this.sessionStartWindowMs; + this.intervalMs = opts.intervalMs ?? DEFAULT_SCAN_INTERVAL_MS; + this.seedSessionId = opts.sessionId; + this.activeSessionId = opts.sessionId; + + if (!this.targetCwd && !this.seedSessionId) { + const message = 'No cwd/sessionId available for OpenCode storage matching; scanner disabled.'; + logger.warn(`[opencode-storage] ${message}`); + this.matchFailed = true; + this.onSessionMatchFailed?.(message); + } + } + + async start(): Promise { + if (this.matchFailed) { + return; + } + await this.scan(); + this.intervalId = setInterval(() => { + void this.scan(); + }, this.intervalMs); + } + + async cleanup(): Promise { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + async onNewSession(sessionId: string): Promise { + if (!sessionId || sessionId === this.activeSessionId) { + return; + } + await this.setActiveSession(sessionId); + } + + private async scan(): Promise { + if (this.scanning || this.matchFailed) { + return; + } + this.scanning = true; + try { + const storageReady = await this.ensureStorageDir(); + if (!storageReady) { + return; + } + + if (!this.activeSessionId) { + await this.discoverSessionId(); + } + + if (this.activeSessionId) { + await this.scanMessagesAndParts(this.activeSessionId); + } + } finally { + this.scanning = false; + } + } + + private async ensureStorageDir(): Promise { + try { + const stats = await stat(this.storageDir); + if (!stats.isDirectory()) { + if (!this.warnedMissingStorage) { + this.warnedMissingStorage = true; + logger.debug(`[opencode-storage] Storage path is not a directory: ${this.storageDir}`); + } + return false; + } + } catch { + if (!this.warnedMissingStorage) { + this.warnedMissingStorage = true; + logger.debug(`[opencode-storage] Storage path missing: ${this.storageDir}`); + } + return false; + } + + if (this.warnedMissingStorage) { + logger.debug(`[opencode-storage] Storage path ready: ${this.storageDir}`); + this.warnedMissingStorage = false; + } + return true; + } + + private async discoverSessionId(): Promise { + if (this.activeSessionId || this.matchFailed) { + return; + } + + if (this.seedSessionId) { + await this.setActiveSession(this.seedSessionId); + return; + } + + if (!this.targetCwd) { + const message = 'Missing cwd for OpenCode storage matching; refusing to guess session.'; + logger.warn(`[opencode-storage] ${message}`); + this.matchFailed = true; + this.onSessionMatchFailed?.(message); + return; + } + + const sessionFiles = await listSessionInfoFiles(this.storageDir); + let best: SessionCandidate | null = null; + + for (const filePath of sessionFiles) { + const info = await readSessionInfo(filePath); + if (!info || !info.id || !info.directory || info.timeCreated === null) { + continue; + } + + if (normalizePath(info.directory) !== this.targetCwd) { + continue; + } + + if (info.timeCreated < this.referenceTimestampMs) { + continue; + } + + const diff = info.timeCreated - this.referenceTimestampMs; + if (diff > this.sessionStartWindowMs) { + continue; + } + + if (!best || diff < best.score) { + best = { sessionId: info.id, score: diff }; + } + } + + if (best) { + await this.setActiveSession(best.sessionId); + return; + } + + if (Date.now() > this.matchDeadlineMs) { + const message = `No OpenCode session found within ${this.sessionStartWindowMs}ms for cwd ${this.targetCwd}`; + logger.warn(`[opencode-storage] ${message}`); + this.matchFailed = true; + this.onSessionMatchFailed?.(message); + } + } + + private async setActiveSession(sessionId: string): Promise { + if (this.activeSessionId === sessionId) { + return; + } + this.activeSessionId = sessionId; + this.messageRoles.clear(); + this.messageFileMtime.clear(); + this.partFileMtime.clear(); + await this.primeSessionFiles(sessionId); + this.onSessionFound?.(sessionId); + logger.debug(`[opencode-storage] Tracking session ${sessionId}`); + } + + private async primeSessionFiles(sessionId: string): Promise { + const messageDir = join(this.storageDir, 'message', sessionId); + const messageFiles = await listJsonFiles(messageDir); + const messageIds: string[] = []; + const replayMessageIds = new Set(); + const replayThresholdMs = this.referenceTimestampMs - REPLAY_CLOCK_SKEW_MS; + + for (const filePath of messageFiles) { + const mtime = await readMtime(filePath); + if (mtime !== null) { + this.messageFileMtime.set(filePath, mtime); + } + const info = await readJsonRecord(filePath); + const messageId = getString(info?.id) ?? filenameToId(filePath); + if (messageId) { + messageIds.push(messageId); + const role = getString(info?.role); + if (role) { + this.messageRoles.set(messageId, role); + } + } + const timestamp = getMessageTimestamp(info, mtime); + if (messageId && info && timestamp !== null && timestamp >= replayThresholdMs) { + replayMessageIds.add(messageId); + const eventSessionId = getString(info.sessionID) ?? sessionId; + this.onEvent({ + event: 'message.updated', + payload: { info }, + sessionId: eventSessionId || undefined + }); + } + } + + for (const messageId of messageIds) { + const partDir = join(this.storageDir, 'part', messageId); + const partFiles = await listJsonFiles(partDir); + for (const partPath of partFiles) { + const mtime = await readMtime(partPath); + if (mtime !== null) { + this.partFileMtime.set(partPath, mtime); + } + if (!replayMessageIds.has(messageId)) { + continue; + } + const part = await readJsonRecord(partPath); + if (!part) { + continue; + } + if (!this.shouldEmitPart(part, messageId)) { + continue; + } + const eventSessionId = getString(part.sessionID) ?? sessionId; + this.onEvent({ + event: 'message.part.updated', + payload: { part }, + sessionId: eventSessionId || undefined + }); + } + } + } + + private async scanMessagesAndParts(sessionId: string): Promise { + const messageDir = join(this.storageDir, 'message', sessionId); + const messageFiles = await listJsonFiles(messageDir); + const messageIds: string[] = []; + + for (const filePath of messageFiles) { + const messageIdFromPath = filenameToId(filePath); + if (messageIdFromPath) { + messageIds.push(messageIdFromPath); + } + + const mtime = await readMtime(filePath); + if (mtime === null) { + continue; + } + const previous = this.messageFileMtime.get(filePath) ?? 0; + if (mtime <= previous) { + continue; + } + + const info = await readJsonRecord(filePath); + this.messageFileMtime.set(filePath, mtime); + if (!info) { + continue; + } + + const messageId = getString(info.id) ?? messageIdFromPath; + if (messageId) { + const role = getString(info.role); + if (role) { + this.messageRoles.set(messageId, role); + } + } + + const eventSessionId = getString(info.sessionID) ?? sessionId; + this.onEvent({ + event: 'message.updated', + payload: { info }, + sessionId: eventSessionId || undefined + }); + } + + for (const messageId of messageIds) { + const partDir = join(this.storageDir, 'part', messageId); + const partFiles = await listJsonFiles(partDir); + + for (const partPath of partFiles) { + const mtime = await readMtime(partPath); + if (mtime === null) { + continue; + } + const previous = this.partFileMtime.get(partPath) ?? 0; + if (mtime <= previous) { + continue; + } + + const part = await readJsonRecord(partPath); + this.partFileMtime.set(partPath, mtime); + if (!part) { + continue; + } + + if (!this.shouldEmitPart(part, messageId)) { + continue; + } + + const eventSessionId = getString(part.sessionID) ?? sessionId; + this.onEvent({ + event: 'message.part.updated', + payload: { part }, + sessionId: eventSessionId || undefined + }); + } + } + } + + private shouldEmitPart(part: Record, messageId: string): boolean { + const partType = getString(part.type); + if (!partType) { + return false; + } + + if (partType === 'text') { + const text = getString(part.text); + if (!text) { + return false; + } + const role = this.messageRoles.get(messageId); + if (role === 'user') { + return true; + } + if (part.synthetic === true) { + return true; + } + const time = isObject(part.time) ? part.time as Record : null; + const end = time ? getNumber(time.end) : null; + return end !== null; + } + + if (partType === 'tool') { + return true; + } + + return false; + } +} + +type ParsedSessionInfo = { + id: string | null; + directory: string | null; + timeCreated: number | null; +}; + +async function readSessionInfo(filePath: string): Promise { + const record = await readJsonRecord(filePath); + if (!record) { + return null; + } + const time = isObject(record.time) ? record.time as Record : null; + + return { + id: getString(record.id), + directory: getString(record.directory), + timeCreated: time ? getNumber(time.created) : null + }; +} + +async function listSessionInfoFiles(storageDir: string): Promise { + const sessionRoot = join(storageDir, 'session'); + const entries = await safeReadDir(sessionRoot); + const results: string[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const projectDir = join(sessionRoot, entry.name); + const files = await listJsonFiles(projectDir); + results.push(...files); + } + + return results; +} + +async function listJsonFiles(dirPath: string): Promise { + const entries = await safeReadDir(dirPath); + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => join(dirPath, entry.name)); +} + +async function safeReadDir(dirPath: string): Promise { + try { + return await readdir(dirPath, { withFileTypes: true }); + } catch { + return [] as Dirent[]; + } +} + +async function readJsonRecord(filePath: string): Promise | null> { + try { + const raw = await readFile(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') { + return null; + } + return parsed as Record; + } catch (error) { + logger.debug(`[opencode-storage] Failed to read ${filePath}: ${error}`); + return null; + } +} + +async function readMtime(filePath: string): Promise { + try { + const stats = await stat(filePath); + return stats.mtimeMs; + } catch { + return null; + } +} + +function resolveOpencodeStorageDir(): string { + const base = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'); + return join(base, 'opencode', 'storage'); +} + +function normalizePath(value: string): string { + const resolved = resolve(value); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +} + +function filenameToId(filePath: string): string | null { + if (!filePath.endsWith('.json')) { + return null; + } + const lastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')); + const name = lastSlash >= 0 ? filePath.slice(lastSlash + 1) : filePath; + return name.slice(0, -5) || null; +} + +function getString(value: unknown): string | null { + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + return null; +} + +function getNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + return null; +} + +function getMessageTimestamp(info: Record | null, mtime: number | null): number | null { + if (info) { + const time = isObject(info.time) ? info.time as Record : null; + const createdAt = time ? getNumber(time.created) : null; + if (createdAt !== null) { + return createdAt; + } + } + return mtime; +} diff --git a/cli/src/opencode/utils/permissionHandler.ts b/cli/src/opencode/utils/permissionHandler.ts new file mode 100644 index 00000000..615da77a --- /dev/null +++ b/cli/src/opencode/utils/permissionHandler.ts @@ -0,0 +1,170 @@ +import type { ApiSessionClient } from '@/api/apiSession'; +import type { AgentBackend, PermissionRequest, PermissionResponse } from '@/agent/types'; +import type { OpencodePermissionMode } from '@hapi/protocol/types'; +import { deriveToolName } from '@/agent/utils'; +import { logger } from '@/ui/logger'; +import { + BasePermissionHandler, + type AutoApprovalDecision, + type PendingPermissionRequest, + type PermissionCompletion +} from '@/modules/common/permission/BasePermissionHandler'; + +interface PermissionResponseMessage { + id: string; + approved: boolean; + decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; + reason?: string; +} + +function deriveToolInput(request: PermissionRequest): unknown { + if (request.rawInput !== undefined) { + return request.rawInput; + } + return request.rawOutput; +} + +function pickOptionId(request: PermissionRequest, preferredKinds: string[]): string | null { + for (const kind of preferredKinds) { + const match = request.options.find((option) => option.kind === kind); + if (match) { + return match.optionId; + } + } + return request.options.length > 0 ? request.options[0].optionId : null; +} + +function mapDecisionToOutcome(request: PermissionRequest, decision: PermissionResponseMessage['decision']): PermissionResponse { + if (decision === 'abort') { + return { outcome: 'cancelled' }; + } + + if (decision === 'approved_for_session') { + const optionId = pickOptionId(request, ['allow_always', 'allow_once']); + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; + } + + if (decision === 'approved') { + const optionId = pickOptionId(request, ['allow_once', 'allow_always']); + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; + } + + const optionId = pickOptionId(request, ['reject_once', 'reject_always']); + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; +} + +export class OpencodePermissionHandler extends BasePermissionHandler { + private readonly pendingBackendRequests = new Map(); + + constructor( + session: ApiSessionClient, + private readonly backend: AgentBackend, + private readonly getPermissionMode: () => OpencodePermissionMode | undefined + ) { + super(session); + this.backend.onPermissionRequest((request) => this.handlePermissionRequest(request)); + } + + private handlePermissionRequest(request: PermissionRequest): void { + const toolName = deriveToolName({ + title: request.title, + kind: request.kind, + rawInput: request.rawInput + }); + const toolInput = deriveToolInput(request); + const mode = this.getPermissionMode() ?? 'default'; + + const autoDecision = this.resolveAutoApprovalDecision(mode, toolName, request.toolCallId); + if (autoDecision) { + void this.autoApprove(request, toolName, toolInput, autoDecision); + return; + } + + this.pendingBackendRequests.set(request.id, request); + this.addPendingRequest(request.id, toolName, toolInput, { + resolve: () => {}, + reject: () => {} + }); + + logger.debug(`[Opencode] Permission request queued for ${toolName} (${request.id})`); + } + + private async autoApprove( + request: PermissionRequest, + toolName: string, + toolInput: unknown, + decision: AutoApprovalDecision + ): Promise { + const outcome = mapDecisionToOutcome(request, decision); + await this.backend.respondToPermission(request.sessionId, request, outcome); + + this.client.updateAgentState((currentState) => ({ + ...currentState, + completedRequests: { + ...currentState.completedRequests, + [request.id]: { + tool: toolName, + arguments: toolInput, + createdAt: Date.now(), + completedAt: Date.now(), + status: 'approved', + decision + } + } + })); + + logger.debug(`[Opencode] Auto-approved ${toolName} (${request.id}) mode=${decision}`); + } + + protected async handlePermissionResponse( + response: PermissionResponseMessage, + pending: PendingPermissionRequest + ): Promise { + const pendingRequest = this.pendingBackendRequests.get(response.id); + if (pendingRequest) { + this.pendingBackendRequests.delete(response.id); + } else { + logger.debug('[Opencode] Permission response missing backend request', response.id); + } + + const decision = response.decision ?? (response.approved ? 'approved' : 'denied'); + + if (decision === 'abort' && pendingRequest) { + await this.backend.cancelPrompt(pendingRequest.sessionId); + } + + if (pendingRequest) { + const outcome = mapDecisionToOutcome(pendingRequest, decision); + await this.backend.respondToPermission(pendingRequest.sessionId, pendingRequest, outcome); + } + + pending.resolve(); + + logger.debug(`[Opencode] Permission ${response.approved ? 'approved' : 'denied'} for ${pending.toolName}`); + + return { + status: response.approved ? 'approved' : 'denied', + decision, + reason: response.reason + }; + } + + protected handleMissingPendingResponse(response: PermissionResponseMessage): void { + logger.debug('[Opencode] Permission response received for unknown request', response.id); + } + + async cancelAll(reason: string): Promise { + const pending = Array.from(this.pendingBackendRequests.values()); + this.pendingBackendRequests.clear(); + + for (const request of pending) { + await this.backend.respondToPermission(request.sessionId, request, { outcome: 'cancelled' }); + } + + this.cancelPendingRequests({ + completedReason: reason, + rejectMessage: reason, + decision: 'abort' + }); + } +} diff --git a/cli/src/opencode/utils/startOpencodeHookServer.test.ts b/cli/src/opencode/utils/startOpencodeHookServer.test.ts new file mode 100644 index 00000000..1315fc23 --- /dev/null +++ b/cli/src/opencode/utils/startOpencodeHookServer.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest' +import { request } from 'node:http' +import { startOpencodeHookServer } from './startOpencodeHookServer' + +const sendHookRequest = async ( + port: number, + body: string, + token?: string +): Promise<{ statusCode?: number; body: string }> => { + return await new Promise((resolve, reject) => { + const headers: Record = { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) + } + if (token) { + headers['x-hapi-hook-token'] = token + } + + const req = request({ + host: '127.0.0.1', + port, + path: '/hook/opencode', + method: 'POST', + headers + }, (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk) => chunks.push(chunk as Buffer)) + res.on('error', reject) + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + body: Buffer.concat(chunks).toString('utf-8') + }) + }) + }) + + req.on('error', reject) + req.end(body) + }) +} + +describe('startOpencodeHookServer', () => { + it('forwards hook payload to callback', async () => { + let received: { event?: string; payload?: unknown; sessionId?: string } = {} + const server = await startOpencodeHookServer({ + onEvent: (event) => { + received = event + } + }) + + try { + const body = JSON.stringify({ + event: 'message.updated', + payload: { message: 'ok' }, + sessionId: 'session-123' + }) + const response = await sendHookRequest(server.port, body, server.token) + expect(response.statusCode).toBe(200) + } finally { + server.stop() + } + + expect(received.event).toBe('message.updated') + expect(received.sessionId).toBe('session-123') + expect(received.payload).toEqual({ message: 'ok' }) + }) + + it('returns 400 for invalid JSON payloads', async () => { + let hookCalled = false + const server = await startOpencodeHookServer({ + onEvent: () => { + hookCalled = true + } + }) + + try { + const response = await sendHookRequest(server.port, '{"event":', server.token) + expect(response.statusCode).toBe(400) + expect(response.body).toBe('invalid json') + } finally { + server.stop() + } + + expect(hookCalled).toBe(false) + }) + + it('returns 422 when event is missing', async () => { + let hookCalled = false + const server = await startOpencodeHookServer({ + onEvent: () => { + hookCalled = true + } + }) + + try { + const body = JSON.stringify({ payload: { ok: true } }) + const response = await sendHookRequest(server.port, body, server.token) + expect(response.statusCode).toBe(422) + expect(response.body).toBe('missing event') + } finally { + server.stop() + } + + expect(hookCalled).toBe(false) + }) + + it('returns 401 when hook token is missing', async () => { + let hookCalled = false + const server = await startOpencodeHookServer({ + onEvent: () => { + hookCalled = true + } + }) + + try { + const body = JSON.stringify({ event: 'message.updated', payload: { ok: true } }) + const response = await sendHookRequest(server.port, body) + expect(response.statusCode).toBe(401) + expect(response.body).toBe('unauthorized') + } finally { + server.stop() + } + + expect(hookCalled).toBe(false) + }) +}) diff --git a/cli/src/opencode/utils/startOpencodeHookServer.ts b/cli/src/opencode/utils/startOpencodeHookServer.ts new file mode 100644 index 00000000..cc056135 --- /dev/null +++ b/cli/src/opencode/utils/startOpencodeHookServer.ts @@ -0,0 +1,133 @@ +import { createServer, type IncomingMessage, type ServerResponse, type Server } from 'node:http'; +import { randomBytes } from 'node:crypto'; +import { logger } from '@/ui/logger'; +import type { OpencodeHookEvent } from '../types'; + +export interface OpencodeHookServerOptions { + onEvent: (event: OpencodeHookEvent) => void; + token?: string; +} + +export interface OpencodeHookServer { + port: number; + token: string; + stop: () => void; +} + +function readHookToken(req: IncomingMessage): string | null { + const header = req.headers['x-hapi-hook-token']; + if (Array.isArray(header)) { + return header[0] ?? null; + } + return header ?? null; +} + +export async function startOpencodeHookServer(options: OpencodeHookServerOptions): Promise { + const hookToken = options.token || randomBytes(16).toString('hex'); + + return new Promise((resolve, reject) => { + const server: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const requestPath = req.url?.split('?')[0]; + if (req.method === 'POST' && requestPath === '/hook/opencode') { + const providedToken = readHookToken(req); + if (providedToken !== hookToken) { + logger.debug('[opencode-hook] Unauthorized hook request'); + res.writeHead(401, { 'Content-Type': 'text/plain' }).end('unauthorized'); + req.resume(); + return; + } + + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + if (!res.headersSent) { + logger.debug('[opencode-hook] Request timeout'); + res.writeHead(408).end('timeout'); + } + req.destroy(new Error('Request timeout')); + }, 5000); + + try { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + clearTimeout(timeout); + + if (timedOut || res.headersSent || res.writableEnded) { + return; + } + + const body = Buffer.concat(chunks).toString('utf-8'); + logger.debug('[opencode-hook] Received hook:', body); + + let data: Record = {}; + try { + const parsed = JSON.parse(body); + if (!parsed || typeof parsed !== 'object') { + logger.debug('[opencode-hook] Parsed hook data is not an object'); + res.writeHead(400, { 'Content-Type': 'text/plain' }).end('invalid json'); + return; + } + data = parsed as Record; + } catch (parseError) { + logger.debug('[opencode-hook] Failed to parse hook data as JSON:', parseError); + res.writeHead(400, { 'Content-Type': 'text/plain' }).end('invalid json'); + return; + } + + const eventValue = data.event; + if (typeof eventValue !== 'string' || eventValue.length === 0) { + res.writeHead(422, { 'Content-Type': 'text/plain' }).end('missing event'); + return; + } + + const payload = data.payload; + const sessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined; + options.onEvent({ event: eventValue, payload, sessionId }); + + if (!res.headersSent && !res.writableEnded) { + res.writeHead(200, { 'Content-Type': 'text/plain' }).end('ok'); + } + } catch (error) { + clearTimeout(timeout); + if (timedOut) { + return; + } + logger.debug('[opencode-hook] Error handling hook:', error); + if (!res.headersSent && !res.writableEnded) { + res.writeHead(500).end('error'); + } + } + return; + } + + res.writeHead(404).end('not found'); + }); + + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to get server address')); + return; + } + + const port = address.port; + logger.debug(`[opencode-hook] Started on port ${port}`); + + resolve({ + port, + token: hookToken, + stop: () => { + server.close(); + logger.debug('[opencode-hook] Stopped'); + } + }); + }); + + server.on('error', (err) => { + logger.debug('[opencode-hook] Server error:', err); + reject(err); + }); + }); +} diff --git a/cli/src/runner/README.md b/cli/src/runner/README.md index 0cba44ce..ba782dab 100644 --- a/cli/src/runner/README.md +++ b/cli/src/runner/README.md @@ -85,12 +85,14 @@ The runner supports spawning sessions with different AI agents: | `claude` (default) | `hapi claude` | `CLAUDE_CODE_OAUTH_TOKEN` | | `codex` | `hapi codex` | `CODEX_HOME` (temp directory with `auth.json`) | | `gemini` | `hapi gemini` | - | +| `opencode` | `hapi opencode` | OpenCode config (no token injection) | ### Token Authentication When spawning a session with a token: - **Claude**: Sets `CLAUDE_CODE_OAUTH_TOKEN` environment variable - **Codex**: Creates temp directory at `os.tmpdir()/hapi-codex-*`, writes token to `auth.json`, sets `CODEX_HOME` +- **OpenCode**: No token injection; relies on OpenCode's own configuration ## 3. Session Management diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 88f21048..b6494077 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -327,7 +327,9 @@ export async function startRunner(): Promise { ? 'codex' : agent === 'gemini' ? 'gemini' - : 'claude'; + : agent === 'opencode' + ? 'opencode' + : 'claude'; const args = [agentCommand]; if (options.resumeSessionId) { if (agent === 'codex') { @@ -337,7 +339,7 @@ export async function startRunner(): Promise { } } args.push('--hapi-starting-mode', 'remote', '--started-by', 'runner'); - if (options.model) { + if (options.model && agent !== 'opencode') { args.push('--model', options.model); } if (yolo) { diff --git a/cli/src/ui/ink/OpencodeDisplay.tsx b/cli/src/ui/ink/OpencodeDisplay.tsx new file mode 100644 index 00000000..0ddc57c3 --- /dev/null +++ b/cli/src/ui/ink/OpencodeDisplay.tsx @@ -0,0 +1,188 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Text, useStdout } from 'ink'; +import { MessageBuffer, type BufferedMessage } from './messageBuffer'; +import { useSwitchControls } from './useSwitchControls'; + +interface OpencodeDisplayProps { + messageBuffer: MessageBuffer; + logPath?: string; + onExit?: () => void; + onSwitchToLocal?: () => void; +} + +function extractTag(messages: BufferedMessage[], tag: 'MODEL' | 'MODE'): string | null { + const prefix = `[${tag}:`; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.type !== 'system') { + continue; + } + if (!message.content.startsWith(prefix)) { + continue; + } + const match = message.content.match(/\[\w+:(.+?)\]/); + if (match && match[1]) { + return match[1]; + } + } + return null; +} + +export const OpencodeDisplay: React.FC = ({ + messageBuffer, + logPath, + onExit, + onSwitchToLocal +}) => { + const [messages, setMessages] = useState([]); + const [model, setModel] = useState(null); + const [permissionMode, setPermissionMode] = useState(null); + const { confirmationMode, actionInProgress } = useSwitchControls({ + onExit, + onSwitch: onSwitchToLocal + }); + const { stdout } = useStdout(); + const terminalWidth = stdout.columns || 80; + const terminalHeight = stdout.rows || 24; + + useEffect(() => { + setMessages(messageBuffer.getMessages()); + + const unsubscribe = messageBuffer.onUpdate((newMessages) => { + setMessages(newMessages); + const nextModel = extractTag(newMessages, 'MODEL'); + if (nextModel) { + setModel(nextModel); + } + const nextMode = extractTag(newMessages, 'MODE'); + if (nextMode) { + setPermissionMode(nextMode); + } + }); + + return () => { + unsubscribe(); + }; + }, [messageBuffer]); + + 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 = Math.max(1, 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'); + }; + + const visibleMessages = messages.filter((msg) => { + if (msg.type === 'system' && msg.content.startsWith('[MODEL:')) { + return false; + } + if (msg.type === 'system' && msg.content.startsWith('[MODE:')) { + return false; + } + return true; + }); + + return ( + + + + OpenCode Agent Messages + {'-'.repeat(Math.min(terminalWidth - 4, 60))} + + + + {visibleMessages.length === 0 ? ( + Waiting for messages... + ) : ( + visibleMessages + .slice(-Math.max(1, terminalHeight - 10)) + .map((msg) => ( + + + {formatMessage(msg)} + + + )) + )} + + + + + + {actionInProgress === 'exiting' ? ( + + Exiting agent... + + ) : actionInProgress === 'switching' ? ( + + Switching to local mode... + + ) : confirmationMode === 'exit' ? ( + + Press Ctrl-C again to exit the agent + + ) : confirmationMode === 'switch' ? ( + + Press space again to switch to local mode + + ) : ( + + OpenCode running {onSwitchToLocal ? '(Space to switch to local, Ctrl-C to exit)' : '(Ctrl-C to exit)'} + + )} + {(model || permissionMode) && ( + + {[model ? `Model: ${model}` : null, permissionMode ? `Permission: ${permissionMode}` : null] + .filter(Boolean) + .join(' | ')} + + )} + {process.env.DEBUG && logPath && ( + + Debug logs: {logPath} + + )} + + + + ); +}; diff --git a/docs/guide/faq.md b/docs/guide/faq.md index f9396b80..7cac778b 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -4,7 +4,7 @@ ### What is HAPI? -HAPI is a local-first, self-hosted platform for running and controlling AI coding agents (Claude Code, Codex, Gemini) remotely. It lets you start coding sessions on your computer and monitor/control them from your phone. +HAPI is a local-first, self-hosted platform for running and controlling AI coding agents (Claude Code, Codex, Gemini, OpenCode) remotely. It lets you start coding sessions on your computer and monitor/control them from your phone. ### What does HAPI stand for? @@ -19,6 +19,7 @@ Yes, HAPI is open source and free to use under the AGPL-3.0-only license. - **Claude Code** (recommended) - **OpenAI Codex** - **Google Gemini** +- **OpenCode** ## Setup & Installation diff --git a/docs/guide/how-it-works.md b/docs/guide/how-it-works.md index c26d9d0f..e49c11e5 100644 --- a/docs/guide/how-it-works.md +++ b/docs/guide/how-it-works.md @@ -47,7 +47,7 @@ HAPI consists of three interconnected components that work together to provide r ### HAPI CLI -The CLI is a wrapper around AI coding agents (Claude Code, Codex, Gemini). It: +The CLI is a wrapper around AI coding agents (Claude Code, Codex, Gemini, OpenCode). It: - Starts and manages coding sessions - Registers sessions with the HAPI hub @@ -59,6 +59,7 @@ The CLI is a wrapper around AI coding agents (Claude Code, Codex, Gemini). It: hapi # Start Claude Code session hapi codex # Start OpenAI Codex session hapi gemini # Start Google Gemini session +hapi opencode # Start OpenCode session hapi runner start # Run background service for remote session spawning ``` @@ -173,7 +174,7 @@ HAPI's defining feature is the ability to seamlessly hand off control between lo ### Local Mode -When working in local mode, you have the full terminal experience — it is native Claude Code or Codex: +When working in local mode, you have the full terminal experience — it is native Claude Code, Codex, or OpenCode: - Direct keyboard input with instant response - Full terminal UI with syntax highlighting diff --git a/docs/guide/installation.md b/docs/guide/installation.md index ca0f5766..ecdeb5e5 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -4,7 +4,7 @@ Install the HAPI CLI and set up the hub. ## Prerequisites -- Claude Code, OpenAI Codex CLI, or Google Gemini CLI installed +- Claude Code, OpenAI Codex CLI, Google Gemini CLI, or OpenCode CLI installed Verify your CLI is installed: @@ -17,6 +17,9 @@ codex --version # For Google Gemini CLI gemini --version + +# For OpenCode CLI +opencode --version ``` ## Architecture @@ -25,7 +28,7 @@ HAPI has three components: | Component | Role | Required | |-----------|------|----------| -| **CLI** | Wraps AI agents (Claude/Codex/Gemini), runs sessions | Yes | +| **CLI** | Wraps AI agents (Claude/Codex/Gemini/OpenCode), runs sessions | Yes | | **Hub** | Central coordinator: persistence, real-time sync, remote access | Yes | | **Runner** | Background service for remote session spawning | Optional | diff --git a/docs/guide/voice-assistant.md b/docs/guide/voice-assistant.md index 7787d773..bca0f9be 100644 --- a/docs/guide/voice-assistant.md +++ b/docs/guide/voice-assistant.md @@ -10,7 +10,7 @@ The voice assistant lets you: - **Approve permissions by voice** - Say "yes" or "no" to approve or deny permission requests - **Monitor progress** - Receive spoken updates when tasks complete or errors occur -The assistant bridges voice communication with your active coding agent (Claude Code, Codex, or Gemini), relaying your requests and summarizing responses in natural speech. +The assistant bridges voice communication with your active coding agent (Claude Code, Codex, Gemini, or OpenCode), relaying your requests and summarizing responses in natural speech. ## Prerequisites diff --git a/hub/src/notifications/sessionInfo.ts b/hub/src/notifications/sessionInfo.ts index 39721de8..7094f208 100644 --- a/hub/src/notifications/sessionInfo.ts +++ b/hub/src/notifications/sessionInfo.ts @@ -15,5 +15,6 @@ export function getAgentName(session: Session): string { if (flavor === 'claude') return 'Claude' if (flavor === 'codex') return 'Codex' if (flavor === 'gemini') return 'Gemini' + if (flavor === 'opencode') return 'OpenCode' return 'Agent' } diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 0a92e5ec..094baeaf 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -93,7 +93,7 @@ export class RpcGateway { async spawnSession( machineId: string, directory: string, - agent: 'claude' | 'codex' | 'gemini' = 'claude', + agent: 'claude' | 'codex' | 'gemini' | 'opencode' = 'claude', model?: string, yolo?: boolean, sessionType?: 'simple' | 'worktree', diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index e81cd68f..ea2e15b9 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -285,7 +285,7 @@ export class SyncEngine { async spawnSession( machineId: string, directory: string, - agent: 'claude' | 'codex' | 'gemini' = 'claude', + agent: 'claude' | 'codex' | 'gemini' | 'opencode' = 'claude', model?: string, yolo?: boolean, sessionType?: 'simple' | 'worktree', @@ -315,14 +315,16 @@ export class SyncEngine { return { type: 'error', message: 'Session metadata missing path', code: 'resume_unavailable' } } - const flavor = metadata.flavor === 'codex' || metadata.flavor === 'gemini' + const flavor = metadata.flavor === 'codex' || metadata.flavor === 'gemini' || metadata.flavor === 'opencode' ? metadata.flavor : 'claude' const resumeToken = flavor === 'codex' ? metadata.codexSessionId : flavor === 'gemini' ? metadata.geminiSessionId - : metadata.claudeSessionId + : flavor === 'opencode' + ? metadata.opencodeSessionId + : metadata.claudeSessionId if (!resumeToken) { return { type: 'error', message: 'Resume session ID unavailable', code: 'resume_unavailable' } diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index e7b80c02..5749d0b8 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -6,7 +6,7 @@ import { requireMachine } from './guards' const spawnBodySchema = z.object({ directory: z.string().min(1), - agent: z.enum(['claude', 'codex', 'gemini']).optional(), + agent: z.enum(['claude', 'codex', 'gemini', 'opencode']).optional(), model: z.string().optional(), yolo: z.boolean().optional(), sessionType: z.enum(['simple', 'worktree']).optional(), diff --git a/shared/src/modes.ts b/shared/src/modes.ts index 5cd2566b..a07f317b 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -7,6 +7,9 @@ export type CodexPermissionMode = typeof CODEX_PERMISSION_MODES[number] export const GEMINI_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const export type GeminiPermissionMode = typeof GEMINI_PERMISSION_MODES[number] +export const OPENCODE_PERMISSION_MODES = ['default', 'yolo'] as const +export type OpencodePermissionMode = typeof OPENCODE_PERMISSION_MODES[number] + export const PERMISSION_MODES = [ 'default', 'acceptEdits', @@ -21,7 +24,7 @@ export type PermissionMode = typeof PERMISSION_MODES[number] export const MODEL_MODES = ['default', 'sonnet', 'opus'] as const export type ModelMode = typeof MODEL_MODES[number] -export type AgentFlavor = 'claude' | 'codex' | 'gemini' +export type AgentFlavor = 'claude' | 'codex' | 'gemini' | 'opencode' export const PERMISSION_MODE_LABELS: Record = { default: 'Default', @@ -72,6 +75,9 @@ export function getPermissionModesForFlavor(flavor?: string | null): readonly Pe if (flavor === 'gemini') { return GEMINI_PERMISSION_MODES } + if (flavor === 'opencode') { + return OPENCODE_PERMISSION_MODES + } return CLAUDE_PERMISSION_MODES } @@ -88,7 +94,7 @@ export function isPermissionModeAllowedForFlavor(mode: PermissionMode, flavor?: } export function getModelModesForFlavor(flavor?: string | null): readonly ModelMode[] { - if (flavor === 'codex' || flavor === 'gemini') { + if (flavor === 'codex' || flavor === 'gemini' || flavor === 'opencode') { return [] } return MODEL_MODES diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index bae6bba1..30e96dc4 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -30,6 +30,7 @@ export const MetadataSchema = z.object({ claudeSessionId: z.string().optional(), codexSessionId: z.string().optional(), geminiSessionId: z.string().optional(), + opencodeSessionId: z.string().optional(), tools: z.array(z.string()).optional(), slashCommands: z.array(z.string()).optional(), homeDir: z.string().optional(), diff --git a/shared/src/types.ts b/shared/src/types.ts index 8499d9fe..1a885f1a 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -18,6 +18,7 @@ export type { ClaudePermissionMode, CodexPermissionMode, GeminiPermissionMode, + OpencodePermissionMode, ModelMode, PermissionMode, PermissionModeOption, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 0a71c02e..f93b847e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -359,7 +359,7 @@ export class ApiClient { async spawnSession( machineId: string, directory: string, - agent?: 'claude' | 'codex' | 'gemini', + agent?: 'claude' | 'codex' | 'gemini' | 'opencode', model?: string, yolo?: boolean, sessionType?: 'simple' | 'worktree', diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 5ccfdb53..2c179a74 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -312,7 +312,7 @@ export function HappyComposer(props: { useEffect(() => { const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => { - if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelModeChange && agentFlavor !== 'codex' && agentFlavor !== 'gemini') { + if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelModeChange && agentFlavor !== 'codex' && agentFlavor !== 'gemini' && agentFlavor !== 'opencode') { e.preventDefault() const currentIndex = MODEL_MODES.indexOf(modelMode as typeof MODEL_MODES[number]) const nextIndex = (currentIndex + 1) % MODEL_MODES.length @@ -386,7 +386,7 @@ export function HappyComposer(props: { }, [onModelModeChange, controlsDisabled, haptic]) const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0) - const showModelSettings = Boolean(onModelModeChange && agentFlavor !== 'codex' && agentFlavor !== 'gemini') + const showModelSettings = Boolean(onModelModeChange && agentFlavor !== 'codex' && agentFlavor !== 'gemini' && agentFlavor !== 'opencode') const showSettingsButton = Boolean(showPermissionSettings || showModelSettings) const showAbortButton = true const voiceEnabled = Boolean(onVoiceToggle) diff --git a/web/src/components/NewSession/AgentSelector.tsx b/web/src/components/NewSession/AgentSelector.tsx index aa3fbd98..2e31c95f 100644 --- a/web/src/components/NewSession/AgentSelector.tsx +++ b/web/src/components/NewSession/AgentSelector.tsx @@ -14,7 +14,7 @@ export function AgentSelector(props: { {t('newSession.agent')}
- {(['claude', 'codex', 'gemini'] as const).map((agentType) => ( + {(['claude', 'codex', 'gemini', 'opencode'] as const).map((agentType) => (