diff --git a/src/index.ts b/src/index.ts index 94c01c0f..b77ca81b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -238,6 +238,84 @@ import { execFileSync } from 'node:child_process' process.exit(1) } return; + } else if (subcommand === 'opencode') { + try { + const { runOpenCode } = await import('@/opencode/runOpenCode'); + + let startedBy: 'daemon' | 'terminal' | undefined = undefined; + let model: string | undefined = undefined; + let provider: string | undefined = undefined; + let permissionMode: 'default' | 'acceptEdits' | 'bypassPermissions' = 'default'; + let baseUrl: string | undefined = undefined; + + for (let i = 1; i < args.length; i++) { + if (args[i] === '--started-by') { + startedBy = args[++i] as 'daemon' | 'terminal'; + } else if (args[i] === '--model' || args[i] === '-m') { + model = args[++i]; + } else if (args[i] === '--provider' || args[i] === '-p') { + provider = args[++i]; + } else if (args[i] === '--yolo' || args[i] === '--dangerously-skip-permissions') { + permissionMode = 'bypassPermissions'; + } else if (args[i] === '--accept-edits') { + permissionMode = 'acceptEdits'; + } else if (args[i] === '--base-url') { + baseUrl = args[++i]; + } else if (args[i] === '-h' || args[i] === '--help') { + console.log(` +${chalk.bold('happy opencode')} - OpenCode integration + +${chalk.bold('Usage:')} + happy opencode [options] + +${chalk.bold('Options:')} + -m, --model Model to use (e.g., claude-3-5-sonnet) + -p, --provider Provider ID (e.g., anthropic, openrouter) + --yolo Bypass all permissions + --accept-edits Auto-accept file edit permissions + --base-url OpenCode API URL (default: http://localhost:4096) + -h, --help Show this help + +${chalk.bold('Examples:')} + happy opencode Start with default settings + happy opencode --yolo Start with all permissions bypassed + happy opencode -m gpt-4o Use specific model + +${chalk.bold('Note:')} OpenCode must be running locally (opencode --server) +`); + process.exit(0); + } + } + + const { credentials } = await authAndSetupMachineIfNeeded(); + + logger.debug('Ensuring Happy background service is running & matches our version...'); + if (!(await isDaemonRunningCurrentlyInstalledHappyVersion())) { + logger.debug('Starting Happy background service...'); + const daemonProcess = spawnHappyCLI(['daemon', 'start-sync'], { + detached: true, + stdio: 'ignore', + env: process.env + }); + daemonProcess.unref(); + await new Promise(resolve => setTimeout(resolve, 200)); + } + + await runOpenCode(credentials, { + model, + provider, + permissionMode, + startedBy, + baseUrl + }); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + return; } else if (subcommand === 'logout') { // Keep for backward compatibility - redirect to auth logout console.log(chalk.yellow('Note: "happy logout" is deprecated. Use "happy auth logout" instead.\n')); @@ -444,6 +522,7 @@ ${chalk.bold('Usage:')} happy auth Manage authentication happy codex Start Codex mode happy gemini Start Gemini mode (ACP) + happy opencode Start OpenCode mode (HTTP API) happy connect Connect AI vendor API keys happy notify Send push notification happy daemon Manage background service that allows diff --git a/src/opencode/index.ts b/src/opencode/index.ts new file mode 100644 index 00000000..b9108d6b --- /dev/null +++ b/src/opencode/index.ts @@ -0,0 +1,5 @@ +export { runOpenCode } from './runOpenCode'; +export { OpenCodeClient } from './openCodeClient'; +export { OpenCodePermissionHandler } from './utils/permissionHandler'; +export * from './types'; +export * from './messageMapper'; diff --git a/src/opencode/messageMapper.ts b/src/opencode/messageMapper.ts new file mode 100644 index 00000000..04006002 --- /dev/null +++ b/src/opencode/messageMapper.ts @@ -0,0 +1,159 @@ +import { randomUUID } from 'node:crypto'; +import type { + OpenCodeMessageInfo, + OpenCodeMessagePart, + OpenCodeMessage, + OpenCodeTodo +} from './types'; + +export interface HappyMessage { + type: string; + id: string; + [key: string]: unknown; +} + +export interface HappyToolCall extends HappyMessage { + type: 'tool-call'; + name: string; + callId: string; + input: Record; +} + +export interface HappyToolResult extends HappyMessage { + type: 'tool-call-result'; + callId: string; + output: unknown; +} + +export interface HappyTextMessage extends HappyMessage { + type: 'message'; + message: string; +} + +export interface HappyReasoningMessage extends HappyMessage { + type: 'reasoning'; + text: string; +} + +export interface HappyTodoMessage extends HappyMessage { + type: 'todo'; + todos: Array<{ + id: string; + content: string; + status: string; + priority?: string; + }>; +} + +export function mapOpenCodePartToHappyMessage(part: OpenCodeMessagePart): HappyMessage | null { + switch (part.type) { + case 'text': + if (!part.text) return null; + return { + type: 'message', + id: part.id, + message: part.text + } as HappyTextMessage; + + case 'tool-invocation': + if (!part.toolInvocation) return null; + const inv = part.toolInvocation; + + if (inv.state === 'pending' || inv.state === 'running') { + return { + type: 'tool-call', + id: part.id, + name: inv.toolName, + callId: inv.toolCallID, + input: inv.args || {}, + metadata: inv.metadata + } as HappyToolCall; + } + + if (inv.state === 'completed' || inv.state === 'failed') { + return { + type: 'tool-call-result', + id: part.id, + callId: inv.toolCallID, + output: inv.error ? { error: inv.error } : inv.result, + success: inv.state === 'completed' + } as HappyToolResult; + } + return null; + + case 'reasoning': + if (!part.text) return null; + return { + type: 'reasoning', + id: part.id, + text: part.text + } as HappyReasoningMessage; + + case 'step-start': + return { + type: 'step-start', + id: part.id, + text: part.text || '' + }; + + case 'file': + if (!part.file) return null; + return { + type: 'file', + id: part.id, + path: part.file.path, + content: part.file.content + }; + + default: + return null; + } +} + +export function mapOpenCodeTodosToHappyMessage(todos: OpenCodeTodo[]): HappyTodoMessage { + return { + type: 'todo', + id: randomUUID(), + todos: todos.map(t => ({ + id: t.id, + content: t.content, + status: t.status, + priority: t.priority + })) + }; +} + +export function mapOpenCodeMessageInfoToStatus(info: OpenCodeMessageInfo): HappyMessage { + const hasError = !!info.error; + const isComplete = !!info.time.completed; + + return { + type: 'message-status', + id: info.id, + messageId: info.id, + sessionId: info.sessionID, + role: info.role, + status: hasError ? 'error' : (isComplete ? 'complete' : 'in-progress'), + error: info.error, + tokens: info.tokens, + cost: info.cost, + model: info.modelID, + provider: info.providerID + }; +} + +export function createHappyEventFromOpenCodePart( + part: OpenCodeMessagePart, + info?: OpenCodeMessageInfo +): HappyMessage | null { + const message = mapOpenCodePartToHappyMessage(part); + if (!message) return null; + + if (info) { + message.role = info.role; + message.model = info.modelID; + message.provider = info.providerID; + } + + return message; +} diff --git a/src/opencode/openCodeClient.ts b/src/opencode/openCodeClient.ts new file mode 100644 index 00000000..b749eacf --- /dev/null +++ b/src/opencode/openCodeClient.ts @@ -0,0 +1,526 @@ +import { EventEmitter } from 'node:events'; +import { logger } from '@/ui/logger'; +import type { + OpenCodeSession, + OpenCodeMessage, + OpenCodeMessageInfo, + OpenCodeMessagePart, + OpenCodePermissionRequest, + OpenCodePermissionReply, + OpenCodeEvent, + OpenCodeSessionStatus, + OpenCodeHealthResponse, + OpenCodePromptInput, + OpenCodeModel, + OpenCodeProvider, + OpenCodeTodo +} from './types'; + +export interface OpenCodeClientOptions { + baseUrl?: string; + timeout?: number; +} + +export interface OpenCodeClientEvents { + 'session:created': (session: OpenCodeSession) => void; + 'session:updated': (session: OpenCodeSession) => void; + 'session:status': (status: OpenCodeSessionStatus) => void; + 'message:info': (info: OpenCodeMessageInfo) => void; + 'message:part': (part: OpenCodeMessagePart) => void; + 'message:complete': (message: OpenCodeMessage) => void; + 'permission:request': (request: OpenCodePermissionRequest) => void; + 'todo:updated': (todos: OpenCodeTodo[]) => void; + 'error': (error: Error) => void; + 'connected': () => void; + 'disconnected': () => void; +} + +/** + * OpenCode HTTP/SSE Client + * + * Communicates with OpenCode's native HTTP API (port 4096). + * Unlike Claude/Codex which use PTY spawning, OpenCode provides a clean REST API. + */ +export class OpenCodeClient extends EventEmitter { + private baseUrl: string; + private timeout: number; + private eventSource: EventSource | null = null; + private abortController: AbortController | null = null; + private currentSessionId: string | null = null; + private isConnected: boolean = false; + private reconnectAttempts: number = 0; + private maxReconnectAttempts: number = 5; + private reconnectDelay: number = 1000; + + constructor(options: OpenCodeClientOptions = {}) { + super(); + this.baseUrl = options.baseUrl || 'http://localhost:4096'; + this.timeout = options.timeout || 30000; + } + + // ========== Health & Connection ========== + + /** + * Check if OpenCode server is healthy + */ + async checkHealth(): Promise { + const response = await this.fetch('/global/health'); + return response.json() as Promise; + } + + /** + * Connect to OpenCode SSE event stream + */ + async connect(): Promise { + if (this.isConnected) { + logger.debug('[OpenCodeClient] Already connected'); + return; + } + + try { + // Verify server health first + const health = await this.checkHealth(); + if (!health.healthy) { + throw new Error('OpenCode server is not healthy'); + } + logger.debug(`[OpenCodeClient] Server healthy, version: ${health.version}`); + + // Start SSE connection + await this.connectSSE(); + this.isConnected = true; + this.reconnectAttempts = 0; + this.emit('connected'); + logger.debug('[OpenCodeClient] Connected to OpenCode'); + } catch (error) { + logger.warn('[OpenCodeClient] Connection failed:', error); + throw error; + } + } + + /** + * Disconnect from OpenCode + */ + async disconnect(): Promise { + this.isConnected = false; + + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = null; + } + + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + + this.emit('disconnected'); + logger.debug('[OpenCodeClient] Disconnected from OpenCode'); + } + + /** + * Connect to SSE event stream using fetch (Node.js compatible) + */ + private async connectSSE(): Promise { + const sseUrl = `${this.baseUrl}/global/event`; + logger.debug(`[OpenCodeClient] Connecting to SSE: ${sseUrl}`); + + this.abortController = new AbortController(); + + try { + const response = await fetch(sseUrl, { + method: 'GET', + headers: { + 'Accept': 'text/event-stream', + 'Cache-Control': 'no-cache', + }, + signal: this.abortController.signal, + }); + + if (!response.ok) { + throw new Error(`SSE connection failed: ${response.status}`); + } + + if (!response.body) { + throw new Error('SSE response has no body'); + } + + // Process SSE stream + this.processSSEStream(response.body); + } catch (error) { + if ((error as Error).name !== 'AbortError') { + logger.warn('[OpenCodeClient] SSE connection error:', error); + this.handleReconnect(); + } + } + } + + /** + * Process SSE stream + */ + private async processSSEStream(body: ReadableStream): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (this.isConnected) { + const { done, value } = await reader.read(); + + if (done) { + logger.debug('[OpenCodeClient] SSE stream ended'); + break; + } + + buffer += decoder.decode(value, { stream: true }); + + // Process complete SSE messages + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // Keep incomplete line in buffer + + let eventType = 'message'; + let eventData = ''; + + for (const line of lines) { + if (line.startsWith('event:')) { + eventType = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + eventData = line.slice(5).trim(); + } else if (line === '' && eventData) { + // Empty line marks end of event + this.handleSSEEvent(eventType, eventData); + eventType = 'message'; + eventData = ''; + } + } + } + } catch (error) { + if ((error as Error).name !== 'AbortError') { + logger.warn('[OpenCodeClient] SSE stream error:', error); + this.handleReconnect(); + } + } finally { + reader.releaseLock(); + } + } + + /** + * Handle SSE event + */ + private handleSSEEvent(eventType: string, data: string): void { + try { + const parsed: OpenCodeEvent = JSON.parse(data); + const payload = parsed.payload; + + logger.debug(`[OpenCodeClient] SSE event: ${payload.type}`, payload.properties); + + switch (payload.type) { + case 'session.created': + case 'session.updated': + this.emit('session:updated', payload.properties as unknown as OpenCodeSession); + break; + + case 'session.status': + this.emit('session:status', payload.properties as unknown as OpenCodeSessionStatus); + break; + + case 'message.info.created': + case 'message.info.updated': + this.emit('message:info', payload.properties as unknown as OpenCodeMessageInfo); + break; + + case 'message.part.created': + case 'message.part.updated': + this.emit('message:part', payload.properties as unknown as OpenCodeMessagePart); + break; + + case 'permission.created': + this.emit('permission:request', payload.properties as unknown as OpenCodePermissionRequest); + break; + + case 'todo.updated': + this.emit('todo:updated', payload.properties as unknown as OpenCodeTodo[]); + break; + + default: + logger.debug(`[OpenCodeClient] Unhandled event type: ${payload.type}`); + } + } catch (error) { + logger.warn('[OpenCodeClient] Failed to parse SSE event:', error, data); + } + } + + /** + * Handle reconnection logic + */ + private handleReconnect(): void { + if (!this.isConnected || this.reconnectAttempts >= this.maxReconnectAttempts) { + logger.warn('[OpenCodeClient] Max reconnect attempts reached'); + this.emit('error', new Error('Max reconnect attempts reached')); + return; + } + + this.reconnectAttempts++; + const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); + + logger.debug(`[OpenCodeClient] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`); + + setTimeout(() => { + if (this.isConnected) { + this.connectSSE(); + } + }, delay); + } + + // ========== Session Management ========== + + /** + * List all sessions + */ + async listSessions(): Promise { + const response = await this.fetch('/session'); + return response.json() as Promise; + } + + /** + * Get session by ID + */ + async getSession(sessionId: string): Promise { + const response = await this.fetch(`/session/${sessionId}`); + return response.json() as Promise; + } + + /** + * Create a new session + */ + async createSession(directory?: string): Promise { + const body = directory ? { directory } : {}; + const response = await this.fetch('/session', { + method: 'POST', + body: JSON.stringify(body), + }); + const session = await response.json() as OpenCodeSession; + this.currentSessionId = session.id; + this.emit('session:created', session); + return session; + } + + /** + * Get or create session for current directory + */ + async getOrCreateSession(directory?: string): Promise { + // Try to find existing session for this directory + const sessions = await this.listSessions(); + const dir = directory || process.cwd(); + + const existing = sessions.find(s => s.directory === dir); + if (existing) { + this.currentSessionId = existing.id; + return existing; + } + + return this.createSession(dir); + } + + /** + * Get current session ID + */ + getSessionId(): string | null { + return this.currentSessionId; + } + + /** + * Check if there's an active session + */ + hasActiveSession(): boolean { + return this.currentSessionId !== null; + } + + /** + * Clear current session + */ + clearSession(): void { + this.currentSessionId = null; + } + + // ========== Message Operations ========== + + /** + * Get messages for a session + */ + async getMessages(sessionId?: string): Promise { + const sid = sessionId || this.currentSessionId; + if (!sid) throw new Error('No session ID provided'); + + const response = await this.fetch(`/session/${sid}/message`); + return response.json() as Promise; + } + + /** + * Send a message to the session + */ + async sendMessage( + text: string, + options: { + sessionId?: string; + providerID?: string; + modelID?: string; + agent?: string; + } = {} + ): Promise { + const sid = options.sessionId || this.currentSessionId; + if (!sid) throw new Error('No session ID provided'); + + const input: OpenCodePromptInput = { + parts: [{ type: 'text', text }], + }; + + if (options.providerID) input.providerID = options.providerID; + if (options.modelID) input.modelID = options.modelID; + if (options.agent) input.agent = options.agent; + + await this.fetch(`/session/${sid}/message`, { + method: 'POST', + body: JSON.stringify(input), + }); + } + + /** + * Abort current operation + */ + async abort(sessionId?: string): Promise { + const sid = sessionId || this.currentSessionId; + if (!sid) throw new Error('No session ID provided'); + + await this.fetch(`/session/${sid}/abort`, { + method: 'POST', + }); + logger.debug(`[OpenCodeClient] Aborted session: ${sid}`); + } + + // ========== Permission Operations ========== + + /** + * Get pending permission requests + */ + async getPermissions(): Promise { + const response = await this.fetch('/permission'); + return response.json() as Promise; + } + + /** + * Reply to a permission request + */ + async replyPermission(permissionId: string, reply: OpenCodePermissionReply): Promise { + await this.fetch(`/permission/${permissionId}/reply`, { + method: 'POST', + body: JSON.stringify({ reply }), + }); + logger.debug(`[OpenCodeClient] Permission ${permissionId} replied: ${reply}`); + } + + // ========== Model & Provider Operations ========== + + /** + * Get available providers and models + */ + async getProviders(): Promise { + const response = await this.fetch('/provider'); + return response.json() as Promise; + } + + /** + * Get models for a specific provider + */ + async getModels(providerID?: string): Promise { + const providers = await this.getProviders(); + const models: OpenCodeModel[] = []; + + for (const provider of providers) { + if (providerID && provider.id !== providerID) continue; + models.push(...Object.values(provider.models)); + } + + return models; + } + + // ========== Session Status ========== + + /** + * Get session status (idle, running, waiting) + */ + async getSessionStatus(sessionId?: string): Promise { + const sid = sessionId || this.currentSessionId; + if (!sid) throw new Error('No session ID provided'); + + const response = await this.fetch(`/session/${sid}/status`); + return response.json() as Promise; + } + + /** + * Wait for session to become idle + */ + async waitForIdle(sessionId?: string, pollInterval: number = 500): Promise { + const sid = sessionId || this.currentSessionId; + if (!sid) throw new Error('No session ID provided'); + + while (true) { + const status = await this.getSessionStatus(sid); + if (status.status === 'idle') { + return; + } + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + } + + // ========== File Operations ========== + + /** + * Get file content + */ + async getFile(path: string): Promise { + const response = await this.fetch(`/file?path=${encodeURIComponent(path)}`); + const data = await response.json() as { content: string }; + return data.content; + } + + // ========== Utility Methods ========== + + /** + * Internal fetch wrapper + */ + private async fetch(path: string, options: RequestInit = {}): Promise { + const url = `${this.baseUrl}${path}`; + + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + signal: options.signal || AbortSignal.timeout(this.timeout), + }); + + if (!response.ok) { + const error = await response.text().catch(() => 'Unknown error'); + throw new Error(`OpenCode API error: ${response.status} - ${error}`); + } + + return response; + } + + // ========== Type-safe Event Emitter ========== + + on( + event: K, + listener: OpenCodeClientEvents[K] + ): this { + return super.on(event, listener); + } + + emit( + event: K, + ...args: Parameters + ): boolean { + return super.emit(event, ...args); + } +} diff --git a/src/opencode/runOpenCode.ts b/src/opencode/runOpenCode.ts new file mode 100644 index 00000000..82176796 --- /dev/null +++ b/src/opencode/runOpenCode.ts @@ -0,0 +1,334 @@ +import os from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { ApiClient } from '@/api/api'; +import { logger } from '@/ui/logger'; +import { Credentials, readSettings } from '@/persistence'; +import { AgentState, Metadata } from '@/api/types'; +import { configuration } from '@/configuration'; +import { projectPath } from '@/projectPath'; +import { resolve } from 'node:path'; +import { initialMachineMetadata } from '@/daemon/run'; +import { notifyDaemonSessionStarted } from '@/daemon/controlClient'; +import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; +import { startCaffeinate, stopCaffeinate } from '@/utils/caffeinate'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { hashObject } from '@/utils/deterministicJson'; +import { startHappyServer } from '@/claude/utils/startHappyServer'; +import packageJson from '../../package.json'; + +import { OpenCodeClient } from './openCodeClient'; +import { OpenCodePermissionHandler, PermissionMode } from './utils/permissionHandler'; +import { + mapOpenCodePartToHappyMessage, + mapOpenCodeTodosToHappyMessage, + mapOpenCodeMessageInfoToStatus +} from './messageMapper'; +import type { OpenCodeMessageInfo, OpenCodeMessagePart, OpenCodeSessionStatus } from './types'; + +export interface OpenCodeStartOptions { + model?: string; + provider?: string; + permissionMode?: PermissionMode; + startedBy?: 'daemon' | 'terminal'; + baseUrl?: string; +} + +interface EnhancedMode { + permissionMode: PermissionMode; + model?: string; + provider?: string; +} + +export async function runOpenCode( + credentials: Credentials, + options: OpenCodeStartOptions = {} +): Promise { + logger.debug('[OpenCode] ===== OPENCODE MODE STARTING ====='); + + const workingDirectory = process.cwd(); + const sessionTag = randomUUID(); + + const api = await ApiClient.create(credentials); + + const settings = await readSettings(); + let machineId = settings?.machineId; + if (!machineId) { + console.error('[OpenCode] No machine ID found. Please run happy auth first.'); + process.exit(1); + } + + await api.getOrCreateMachine({ + machineId, + metadata: initialMachineMetadata + }); + + const metadata: Metadata = { + path: workingDirectory, + host: os.hostname(), + version: packageJson.version, + os: os.platform(), + machineId, + homeDir: os.homedir(), + happyHomeDir: configuration.happyHomeDir, + happyLibDir: projectPath(), + happyToolsDir: resolve(projectPath(), 'tools', 'unpacked'), + startedFromDaemon: options.startedBy === 'daemon', + hostPid: process.pid, + startedBy: options.startedBy || 'terminal', + lifecycleState: 'running', + lifecycleStateSince: Date.now(), + flavor: 'opencode' + }; + + const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state: {} }); + logger.debug(`[OpenCode] Session created: ${response.id}`); + + try { + const result = await notifyDaemonSessionStarted(response.id, metadata); + if (result.error) { + logger.debug(`[OpenCode] Failed to report to daemon:`, result.error); + } + } catch (error) { + logger.debug('[OpenCode] Daemon notification failed:', error); + } + + const session = api.sessionSyncClient(response); + + const happyServer = await startHappyServer(session); + logger.debug(`[OpenCode] Happy MCP server started at ${happyServer.url}`); + + const client = new OpenCodeClient({ + baseUrl: options.baseUrl || 'http://localhost:4096' + }); + + const permissionHandler = new OpenCodePermissionHandler(client, session); + if (options.permissionMode) { + permissionHandler.setMode(options.permissionMode); + } + + let thinking = false; + let shouldExit = false; + let currentMessageInfo: OpenCodeMessageInfo | null = null; + + const sendReady = () => { + session.sendSessionEvent({ type: 'ready' }); + try { + api.push().sendToAllDevices( + "Ready!", + 'OpenCode is waiting for your command', + { sessionId: session.sessionId } + ); + } catch (pushError) { + logger.debug('[OpenCode] Failed to send ready push', pushError); + } + }; + + client.on('session:status', (status: OpenCodeSessionStatus) => { + const wasThinking = thinking; + thinking = status.status === 'running'; + + if (wasThinking !== thinking) { + session.keepAlive(thinking, 'remote'); + } + + if (status.status === 'idle' && wasThinking) { + sendReady(); + } + }); + + client.on('message:info', (info: OpenCodeMessageInfo) => { + currentMessageInfo = info; + + const statusMsg = mapOpenCodeMessageInfoToStatus(info); + session.sendAgentMessage('opencode', statusMsg); + + if (info.role === 'assistant' && info.time.completed) { + logger.debug('[OpenCode] Assistant message completed'); + } + }); + + client.on('message:part', (part: OpenCodeMessagePart) => { + const happyMsg = mapOpenCodePartToHappyMessage(part); + if (happyMsg) { + if (currentMessageInfo) { + happyMsg.role = currentMessageInfo.role; + happyMsg.model = currentMessageInfo.modelID; + } + session.sendAgentMessage('opencode', happyMsg); + } + }); + + client.on('todo:updated', (todos) => { + const todoMsg = mapOpenCodeTodosToHappyMessage(todos); + session.sendAgentMessage('opencode', todoMsg); + }); + + client.on('error', (error) => { + logger.warn('[OpenCode] Client error:', error); + session.sendSessionEvent({ type: 'message', message: `Error: ${error.message}` }); + }); + + const messageQueue = new MessageQueue2(mode => hashObject({ + permissionMode: mode.permissionMode, + model: mode.model, + provider: mode.provider + })); + + let currentPermissionMode = options.permissionMode || 'default'; + let currentModel = options.model; + let currentProvider = options.provider; + + session.onUserMessage((message) => { + let messagePermissionMode = currentPermissionMode; + if (message.meta?.permissionMode) { + const validModes: PermissionMode[] = ['default', 'acceptEdits', 'bypassPermissions']; + if (validModes.includes(message.meta.permissionMode as PermissionMode)) { + messagePermissionMode = message.meta.permissionMode as PermissionMode; + currentPermissionMode = messagePermissionMode; + } + } + + let messageModel = currentModel; + if (message.meta?.hasOwnProperty('model')) { + messageModel = message.meta.model || undefined; + currentModel = messageModel; + } + + let messageProvider = currentProvider; + if (message.meta?.hasOwnProperty('provider')) { + messageProvider = (message.meta as { provider?: string }).provider || undefined; + currentProvider = messageProvider; + } + + const enhancedMode: EnhancedMode = { + permissionMode: messagePermissionMode, + model: messageModel, + provider: messageProvider + }; + messageQueue.push(message.content.text, enhancedMode); + }); + + session.keepAlive(thinking, 'remote'); + const keepAliveInterval = setInterval(() => { + session.keepAlive(thinking, 'remote'); + }, 2000); + + const handleAbort = async () => { + logger.debug('[OpenCode] Abort requested'); + try { + if (client.hasActiveSession()) { + await client.abort(); + } + messageQueue.reset(); + permissionHandler.reset(); + } catch (error) { + logger.debug('[OpenCode] Error during abort:', error); + } + }; + + const handleKillSession = async () => { + logger.debug('[OpenCode] Kill session requested'); + shouldExit = true; + await handleAbort(); + + try { + session.updateMetadata((current) => ({ + ...current, + lifecycleState: 'archived', + lifecycleStateSince: Date.now(), + archivedBy: 'cli', + archiveReason: 'User terminated' + })); + + session.sendSessionDeath(); + await session.flush(); + await session.close(); + + stopCaffeinate(); + happyServer.stop(); + clearInterval(keepAliveInterval); + await client.disconnect(); + + process.exit(0); + } catch (error) { + logger.debug('[OpenCode] Error during session termination:', error); + process.exit(1); + } + }; + + session.rpcHandlerManager.registerHandler('abort', handleAbort); + registerKillSessionHandler(session.rpcHandlerManager, handleKillSession); + + const caffeinateStarted = startCaffeinate(); + if (caffeinateStarted) { + logger.infoDeveloper('Sleep prevention enabled (macOS)'); + } + + logger.infoDeveloper(`Session: ${response.id}`); + logger.infoDeveloper(`Logs: ${logger.logFilePath}`); + + try { + await client.connect(); + logger.debug('[OpenCode] Connected to OpenCode server'); + + const openCodeSession = await client.getOrCreateSession(workingDirectory); + logger.debug(`[OpenCode] Using OpenCode session: ${openCodeSession.id}`); + + session.updateMetadata((current) => ({ + ...current, + openCodeSessionId: openCodeSession.id + })); + + sendReady(); + + while (!shouldExit) { + const batch = await messageQueue.waitForMessagesAndGetAsString(); + if (!batch) { + logger.debug('[OpenCode] Message queue closed'); + break; + } + + const { message, mode } = batch; + + permissionHandler.setMode(mode.permissionMode); + + try { + thinking = true; + session.keepAlive(thinking, 'remote'); + + await client.sendMessage(message, { + modelID: mode.model, + providerID: mode.provider + }); + + await client.waitForIdle(); + + } catch (error) { + logger.warn('[OpenCode] Error processing message:', error); + session.sendSessionEvent({ + type: 'message', + message: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` + }); + } finally { + thinking = false; + session.keepAlive(thinking, 'remote'); + permissionHandler.clearSessionPermissions(); + } + } + + } finally { + logger.debug('[OpenCode] Cleanup starting'); + + clearInterval(keepAliveInterval); + + session.sendSessionDeath(); + await session.flush(); + await session.close(); + + await client.disconnect(); + happyServer.stop(); + stopCaffeinate(); + + logger.debug('[OpenCode] Cleanup complete'); + } +} diff --git a/src/opencode/types.ts b/src/opencode/types.ts new file mode 100644 index 00000000..e1f9f374 --- /dev/null +++ b/src/opencode/types.ts @@ -0,0 +1,205 @@ +export interface OpenCodeSession { + id: string; + version: string; + projectID: string; + directory: string; + title: string; + parentID?: string; + share?: { + id: string; + time: number; + }; + time: { + created: number; + updated: number; + archived?: number; + }; + summary?: { + additions: number; + deletions: number; + files: number; + }; +} + +export interface OpenCodeMessageInfo { + id: string; + sessionID: string; + role: 'user' | 'assistant'; + time: { + created: number; + completed?: number; + }; + error?: { + name: string; + data: Record; + }; + parentID?: string; + modelID?: string; + providerID?: string; + mode?: string; + agent?: string; + path?: { + cwd: string; + root: string; + }; + cost?: number; + tokens?: { + input: number; + output: number; + reasoning: number; + cache: { + read: number; + write: number; + }; + }; +} + +export interface OpenCodeMessagePart { + id: string; + sessionID: string; + messageID: string; + type: 'text' | 'tool-invocation' | 'tool-result' | 'step-start' | 'reasoning' | 'file' | 'source-url'; + time: { + created: number; + updated: number; + }; + text?: string; + toolInvocation?: { + state: 'pending' | 'running' | 'completed' | 'failed'; + toolCallID: string; + toolName: string; + args?: Record; + result?: unknown; + error?: string; + time?: { + start: number; + end?: number; + }; + metadata?: { + title?: string; + description?: string; + }; + }; + file?: { + path: string; + content?: string; + }; +} + +export interface OpenCodeMessage { + info: OpenCodeMessageInfo; + parts: OpenCodeMessagePart[]; +} + +export interface OpenCodePermissionRequest { + id: string; + sessionID: string; + messageID: string; + partID: string; + time: { + created: number; + }; + metadata: { + title: string; + description?: string; + toolName: string; + args?: Record; + }; +} + +export type OpenCodePermissionReply = + | 'allow' + | 'allowSession' + | 'allowForever' + | 'deny' + | 'denySession' + | 'denyForever'; + +export interface OpenCodeEvent { + directory?: string; + payload: { + type: string; + properties: Record; + }; +} + +export interface OpenCodeSessionStatus { + status: 'idle' | 'running' | 'waiting'; + time: { + started?: number; + }; + tokens?: { + input: number; + output: number; + }; +} + +export interface OpenCodeHealthResponse { + healthy: boolean; + version: string; +} + +export interface OpenCodePromptInput { + parts: Array<{ + type: 'text'; + text: string; + }>; + providerID?: string; + modelID?: string; + agent?: string; +} + +export interface OpenCodeTodo { + id: string; + content: string; + status: 'pending' | 'in_progress' | 'completed' | 'cancelled'; + priority?: 'high' | 'medium' | 'low'; +} + +export interface OpenCodeModel { + id: string; + providerID: string; + name: string; + family?: string; + status: string; + cost?: { + input: number; + output: number; + cache?: { + read: number; + write: number; + }; + }; + limit?: { + context: number; + output: number; + }; + capabilities?: { + temperature: boolean; + reasoning: boolean; + attachment: boolean; + toolcall: boolean; + input: { + text: boolean; + audio: boolean; + image: boolean; + video: boolean; + pdf: boolean; + }; + output: { + text: boolean; + audio: boolean; + image: boolean; + video: boolean; + pdf: boolean; + }; + }; +} + +export interface OpenCodeProvider { + id: string; + name: string; + source?: string; + env?: string[]; + models: Record; +} diff --git a/src/opencode/utils/permissionHandler.ts b/src/opencode/utils/permissionHandler.ts new file mode 100644 index 00000000..bfa4fe8b --- /dev/null +++ b/src/opencode/utils/permissionHandler.ts @@ -0,0 +1,145 @@ +import { logger } from '@/ui/logger'; +import type { OpenCodePermissionRequest, OpenCodePermissionReply } from '../types'; +import type { OpenCodeClient } from '../openCodeClient'; +import type { ApiSessionClient } from '@/api/apiSession'; + +export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions'; + +interface PendingPermission { + request: OpenCodePermissionRequest; + resolver: (reply: OpenCodePermissionReply) => void; +} + +export class OpenCodePermissionHandler { + private client: OpenCodeClient; + private session: ApiSessionClient; + private mode: PermissionMode = 'default'; + private pendingPermissions: Map = new Map(); + private sessionPermissions: Map = new Map(); + + constructor(client: OpenCodeClient, session: ApiSessionClient) { + this.client = client; + this.session = session; + this.setupEventHandlers(); + } + + private setupEventHandlers(): void { + this.client.on('permission:request', (request) => { + this.handlePermissionRequest(request); + }); + + this.session.rpcHandlerManager.registerHandler('permissionResponse', + async (params: { permissionId: string; response: string }) => { + await this.handleUserResponse(params.permissionId, params.response as OpenCodePermissionReply); + } + ); + } + + setMode(mode: PermissionMode): void { + this.mode = mode; + logger.debug(`[PermissionHandler] Mode set to: ${mode}`); + } + + private async handlePermissionRequest(request: OpenCodePermissionRequest): Promise { + logger.debug(`[PermissionHandler] Permission request: ${request.id}`, request.metadata); + + const cachedReply = this.sessionPermissions.get(this.getPermissionKey(request)); + if (cachedReply) { + logger.debug(`[PermissionHandler] Using cached reply: ${cachedReply}`); + await this.client.replyPermission(request.id, cachedReply); + return; + } + + if (this.mode === 'bypassPermissions') { + logger.debug('[PermissionHandler] Bypassing permission (bypassPermissions mode)'); + await this.client.replyPermission(request.id, 'allowForever'); + return; + } + + if (this.mode === 'acceptEdits' && this.isEditPermission(request)) { + logger.debug('[PermissionHandler] Auto-accepting edit (acceptEdits mode)'); + await this.client.replyPermission(request.id, 'allowSession'); + this.sessionPermissions.set(this.getPermissionKey(request), 'allowSession'); + return; + } + + this.sendPermissionToMobile(request); + + const reply = await this.waitForUserResponse(request); + + await this.client.replyPermission(request.id, reply); + + if (reply === 'allowSession' || reply === 'denySession') { + this.sessionPermissions.set(this.getPermissionKey(request), reply); + } + } + + private isEditPermission(request: OpenCodePermissionRequest): boolean { + const editTools = ['edit', 'write', 'Edit', 'Write', 'MultiEdit', 'patch']; + return editTools.some(tool => + request.metadata.toolName.toLowerCase().includes(tool.toLowerCase()) + ); + } + + private getPermissionKey(request: OpenCodePermissionRequest): string { + return `${request.metadata.toolName}:${JSON.stringify(request.metadata.args || {})}`; + } + + private sendPermissionToMobile(request: OpenCodePermissionRequest): void { + this.session.sendAgentMessage('opencode', { + type: 'permission-request', + id: request.id, + sessionId: request.sessionID, + toolName: request.metadata.toolName, + title: request.metadata.title, + description: request.metadata.description, + args: request.metadata.args, + options: [ + { value: 'allow', label: 'Allow Once' }, + { value: 'allowSession', label: 'Allow for Session' }, + { value: 'allowForever', label: 'Always Allow' }, + { value: 'deny', label: 'Deny Once' }, + { value: 'denySession', label: 'Deny for Session' }, + { value: 'denyForever', label: 'Always Deny' } + ] + }); + } + + private waitForUserResponse(request: OpenCodePermissionRequest): Promise { + return new Promise((resolve) => { + this.pendingPermissions.set(request.id, { + request, + resolver: resolve + }); + + setTimeout(() => { + if (this.pendingPermissions.has(request.id)) { + logger.debug(`[PermissionHandler] Permission timeout, denying: ${request.id}`); + this.pendingPermissions.delete(request.id); + resolve('deny'); + } + }, 300000); + }); + } + + private async handleUserResponse(permissionId: string, response: OpenCodePermissionReply): Promise { + const pending = this.pendingPermissions.get(permissionId); + if (!pending) { + logger.debug(`[PermissionHandler] No pending permission for: ${permissionId}`); + return; + } + + this.pendingPermissions.delete(permissionId); + pending.resolver(response); + } + + reset(): void { + this.pendingPermissions.clear(); + this.sessionPermissions.clear(); + logger.debug('[PermissionHandler] Reset'); + } + + clearSessionPermissions(): void { + this.sessionPermissions.clear(); + } +}