OpenCode CLI Adapter Architecture
The OpenCode adapter is the bridge between the Electron main process and the OpenCode CLI. It uses node-pty to spawn a pseudo-terminal process, captures NDJSON output, and translates CLI events into application events.
Adapter Overview
graph TB
subgraph "Adapter Layer"
TM[TaskManager]
OA[OpenCodeAdapter]
SP[StreamParser]
end
subgraph "Process Layer"
PTY[node-pty<br/>Pseudo-terminal]
SHELL[Shell Process<br/>bin/sh or powershell]
CLI[OpenCode CLI<br/>opencode run]
end
subgraph "Event Transformation"
JSON[NDJSON Stream]
MSG[OpenCodeMessage]
CB[TaskCallbacks]
end
subgraph "Configuration"
ENV[Environment Variables<br/>API keys, paths]
CFG[OpenCode Config<br/>Agent, MCP servers]
end
TM --> OA
OA --> PTY
PTY --> SHELL
SHELL --> CLI
CLI -->|stdout/stderr| PTY
PTY -->|data event| SP
SP -->|message| MSG
MSG --> CB
OA --> ENV
OA --> CFG
style OA fill:#e3f2fd
style PTY fill:#fff3e0
style SP fill:#f3e5f5
OpenCodeAdapter Class
File: apps/desktop/src/main/opencode/adapter.ts
Class Structure
classDiagram
class OpenCodeAdapter {
-ptyProcess: IPty | null
-streamParser: StreamParser
-currentSessionId: string | null
-currentTaskId: string | null
-messages: TaskMessage[]
-hasCompleted: boolean
-isDisposed: boolean
-wasInterrupted: boolean
+startTask(config) Promise~Task~
+resumeSession(sid, prompt) Promise~Task~
+sendResponse(response) Promise~void~
+cancelTask() Promise~void~
+interruptTask() Promise~void~
+getSessionId() string | null
+getTaskId() string | null
+isAdapterDisposed() boolean
+dispose() void
-buildEnvironment() Promise~ProcessEnv~
-buildCliArgs(config) Promise~string[]~
-setupStreamParsing() void
-handleMessage(message) void
-handleAskUserQuestion(input) void
-handleProcessExit(code) void
-getPlatformShell() string
-getShellArgs(cmd) string[]
}
class EventEmitter {
<<interface>>
+on(event, listener)
+off(event, listener)
+emit(event, ...args)
+removeAllListeners()
}
class StreamParser {
+feed(chunk: string)
+reset()
+flush()
}
OpenCodeAdapter --|> EventEmitter
OpenCodeAdapter --> StreamParser
Task Initialization Flow
sequenceDiagram
participant TM as TaskManager
participant OA as OpenCodeAdapter
participant FS as File System
participant ENV as buildEnvironment
participant PTY as node-pty
participant CLI as OpenCode CLI
TM->>OA: new OpenCodeAdapter(taskId)
activate OA
OA->>OA: constructor(taskId)
OA->>OA: new StreamParser()
OA->>OA: setupStreamParsing()
TM->>OA: startTask({prompt})
OA->>OA: Check isDisposed
OA->>OA: Check CLI installed
OA->>FS: generateOpenCodeConfig()
FS-->>OA: configPath
OA->>ENV: buildEnvironment()
ENV->>ENV: Get API keys
ENV->>ENV: Add bundled Node to PATH
ENV->>ENV: Set task ID in env
ENV-->>OA: env object
OA->>OA: buildCliArgs(config)
OA->>PTY: pty.spawn(shell, [cmd], {env, cwd})
PTY-->>OA: ptyProcess
loop Data Stream
PTY->>OA: onData(chunk)
OA->>OA: streamParser.feed(chunk)
end
OA-->>TM: Task object
Constructor
Source: Lines 77-82
constructor(taskId?: string) {
super();
this.currentTaskId = taskId || null;
this.streamParser = new StreamParser();
this.setupStreamParsing();
}
Initialization:
- Sets task ID for logging
- Creates stream parser for NDJSON
- Sets up event listeners
CLI Availability Check
Source: Lines 94-97
const cliInstalled = await isOpenCodeCliInstalled();
if (!cliInstalled) {
throw new OpenCodeCliNotFoundError();
}
Error Definition (lines 29-36):
export class OpenCodeCliNotFoundError extends Error {
constructor() {
super(
'OpenCode CLI is not available. The bundled CLI may be missing or corrupted. Please reinstall the application.'
);
this.name = 'OpenCodeCliNotFoundError';
}
}
Environment Configuration
Build Environment
Source: Lines 330-397
graph TB
subgraph "Base Environment"
BASE[process.env<br/>Inherited environment]
end
subgraph "Packaged App"
ELECTRON[ELECTRON_RUN_AS_NODE=1]
BUNDLED[Bundled Node.js in PATH]
NODE_BIN[NODE_BIN_PATH set]
end
subgraph "macOS Extended"
EXTEND[Extended PATH<br/>Common Node locations]
end
subgraph "API Keys"
ANTH[ANTHROPIC_API_KEY]
OPENAI[OPENAI_API_KEY]
GOOGLE[GOOGLE_GENERATIVE_AI_API_KEY]
GROQ[GROQ_API_KEY]
end
subgraph "OpenCode Config"
CONFIG[OPENCODE_CONFIG env var]
end
subgraph "Task Context"
TASK_ID[ACCOMPLISH_TASK_ID]
end
BASE --> ELECTRON
ELECTRON --> BUNDLED
BUNDLED --> EXTEND
BUNDLED --> ANTH
BUNDLED --> OPENAI
BUNDLED --> GOOGLE
BUNDLED --> GROQ
BUNDLED --> CONFIG
BUNDLED --> TASK_ID
style BASE fill:#e3f2fd
style BUNDLED fill:#e8f5e9
style ANTH fill:#fff3e0
PATH Extension
Source: Lines 343-358
const bundledNode = getBundledNodePaths();
if (bundledNode) {
const delimiter = process.platform === 'win32' ? ';' : ':';
env.PATH = `${bundledNode.binDir}${delimiter}${env.PATH || ''}`;
env.NODE_BIN_PATH = bundledNode.binDir;
console.log('[OpenCode CLI] Added bundled Node.js to PATH:', bundledNode.binDir);
}
// macOS specific
if (process.platform === 'darwin') {
env.PATH = getExtendedNodePath(env.PATH);
console.log('[OpenCode CLI] Extended PATH for packaged app');
}
Purpose: Ensure bundled Node.js is found before system Node.js
API Key Injection
Source: Lines 362-379
const apiKeys = await getAllApiKeys();
if (apiKeys.anthropic) {
env.ANTHROPIC_API_KEY = apiKeys.anthropic;
console.log('[OpenCode CLI] Using Anthropic API key from settings');
}
if (apiKeys.openai) {
env.OPENAI_API_KEY = apiKeys.openai;
console.log('[OpenCode CLI] Using OpenAI API key from settings');
}
// ... google, groq
PTY Process Spawning
Shell Selection Strategy
Source: Lines 650-669
graph TB
subgraph "Platform Detection"
WIN{process.platform}
MAC{isPackaged && darwin}
DEV{Development mode}
end
subgraph "Shell Choice"
PWR[PowerShell<br/>powershell.exe]
SH[/bin/sh<br/>No profile loading]
USR[User's shell<br/>$SHELL]
FALL[Fallback chain<br/>bash -> zsh -> sh]
end
WIN -->|win32| PWR
MAC -->|Yes| SH
MAC -->|No| DEV
DEV -->|Has SHELL| USR
DEV -->|No SHELL| FALL
style PWR fill:#e3f2fd
style SH fill:#e8f5e9
style USR fill:#fff3e0
Rationale for /bin/sh on macOS:
// In packaged macOS apps, use /bin/sh to avoid loading user shell configs
// (zsh always loads ~/.zshenv, which may trigger TCC permissions)
return '/bin/sh';
Problem Solved: Prevents macOS TCC permission dialogs from shell config files accessing protected folders.
PTY Spawn Configuration
Source: Lines 171-181
this.ptyProcess = pty.spawn(shellCmd, shellArgs, {
name: 'xterm-256color',
cols: 200,
rows: 30,
cwd: safeCwd,
env: env as { [key: string]: string },
});
Parameters:
- name: Terminal type (xterm-256color for color support)
- cols/rows: Terminal dimensions
- cwd: Working directory (temp directory to avoid TCC)
- env: Environment variables with API keys and PATH
Command Quoting
Source: Lines 143-162
const fullCommand = [command, ...allArgs].map(arg => {
if (process.platform === 'win32') {
// Windows: use double quotes
if (arg.includes(' ') || arg.includes('"')) {
return `"${arg.replace(/"/g, '\\"')}"`;
}
return arg;
} else {
// Unix: use single quotes
if (arg.includes("'") || arg.includes(' ') || arg.includes('"')) {
return `'${arg.replace(/'/g, "'\\''")}'`;
}
return arg;
}
}).join(' ');
Purpose: Proper shell quoting for arguments with spaces
Stream Processing
Stream Parser
File: apps/desktop/src/main/opencode/stream-parser.ts
graph TB
subgraph "StreamParser"
BUFF[Buffer: string]
FEED[feed(chunk)]
PARSE[parseBuffer()]
LINE[parseLine()]
EMIT[emit('message')]
ERR[emit('error')]
end
subgraph "Processing"
SPLIT[Split by \n]
KEEP[Keep incomplete line]
FILTER[Filter decorations]
JSON[Parse JSON]
VALID[Validate message type]
end
BUFF --> FEED
FEED --> PARSE
PARSE --> SPLIT
SPLIT --> KEEP
KEEP --> LINE
LINE --> FILTER
FILTER --> JSON
JSON --> VALID
VALID --> EMIT
VALID -->|invalid| ERR
style BUFF fill:#e3f2fd
style EMIT fill:#e8f5e9
style ERR fill:#ffebee
Terminal Decoration Filtering
Source: Lines 54-67
private isTerminalDecoration(line: string): boolean {
const trimmed = line.trim();
const terminalChars = ['│', '┌', '┐', '└', '┘', '├', '┤', '┬', '┴', '┼', '─', '◆', '●', '○', '◇'];
if (terminalChars.some(char => trimmed.startsWith(char))) {
return true;
}
if (/^[\x00-\x1F\x7F]/.test(trimmed) || /^\x1b\[/.test(trimmed)) {
return true;
}
return false;
}
Purpose: Skip CLI interactive prompt decorations, only process JSON
Message Handling
Source: Lines 440-581
stateDiagram-v2
[*] --> Receive: OpenCodeMessage
Receive --> CheckType: message.type
CheckType --> StepStart: step_start
CheckType --> Text: text
CheckType --> ToolCall: tool_call
CheckType --> ToolUse: tool_use
CheckType --> ToolResult: tool_result
CheckType --> StepFinish: step_finish
CheckType --> Error: error
CheckType --> Unknown: other
StepStart --> SetSession: Set sessionId
SetSession --> EmitProgress: Emit 'progress'
EmitProgress --> [*]
Text --> CreateMsg: Create TaskMessage
CreateMsg --> EmitMessage: Emit 'message'
EmitMessage --> [*]
ToolCall --> EmitToolUse: Emit 'tool-use'
EmitToolUse --> EmitProgress: Emit 'progress'
ToolCall --> CheckQuestion: Is AskUserQuestion?
CheckQuestion -->|Yes| HandleQuestion: Handle user question
CheckQuestion -->|No| [*]
HandleQuestion --> [*]
ToolUse --> CheckStatus: Check status
CheckStatus -->|completed/error| EmitToolResult: Emit 'tool-result'
CheckStatus -->|pending| [*]
ToolUse --> EmitToolUse: Emit 'tool-use'
ToolUse --> CheckQuestion2: Is AskUserQuestion?
CheckQuestion2 -->|Yes| HandleQuestion
CheckQuestion2 -->|No| [*]
ToolResult --> EmitToolResult2: Emit 'tool-result'
EmitToolResult2 --> [*]
StepFinish --> CheckReason: Check reason
CheckReason -->|stop/end_turn| Complete: Emit 'complete'
CheckReason -->|error| ErrorEmit: Emit 'complete' with error
CheckReason -->|tool_use| [*]
Complete --> [*]
ErrorEmit --> [*]
Error --> ErrorEmit2: Emit 'complete' with error
ErrorEmit2 --> [*]
Unknown --> Log: Log unknown type
Log --> [*]
Tool Use Message Processing
Source: Lines 488-536
case 'tool_use':
const toolUseMessage = message as OpenCodeToolUseMessage;
const toolUseName = toolUseMessage.part.tool || 'unknown';
const toolUseInput = toolUseMessage.part.state?.input;
const toolUseOutput = toolUseMessage.part.state?.output || '';
const toolUseStatus = toolUseMessage.part.state?.status;
// For models without text messages, emit description as thinking
const toolDescription = (toolUseInput as { description?: string })?.description;
if (toolDescription) {
const syntheticTextMessage: OpenCodeMessage = {
type: 'text',
timestamp: message.timestamp,
sessionID: message.sessionID,
part: {
type: 'text',
text: toolDescription,
// ...
},
} as OpenCodeTextMessage;
this.emit('message', syntheticTextMessage);
}
this.emit('message', message);
this.emit('tool-use', toolUseName, toolUseInput);
this.emit('progress', { stage: 'tool-use', message: `Using ${toolName}` });
if (toolUseStatus === 'completed' || toolUseStatus === 'error') {
this.emit('tool-result', toolUseOutput);
}
if (toolUseName === 'AskUserQuestion') {
this.handleAskUserQuestion(toolUseInput as AskUserQuestionInput);
}
break;
Features:
- Extracts tool name, input, output, status
- Creates synthetic text message for models without text output
- Emits multiple events for different UI concerns
- Handles AskUserQuestion specially
Permission Handling
AskUserQuestion Handler
Source: Lines 583-601
sequenceDiagram
participant CLI as OpenCode CLI
participant Adapter as OpenCodeAdapter
participant Handlers as IPC Handlers
participant Renderer as Renderer UI
participant User as User
CLI->>Adapter: AskUserQuestion tool call
Adapter->>Adapter: handleAskUserQuestion()
Adapter->>Adapter: Create PermissionRequest
Adapter->>Handlers: Emit 'permission-request'
Handlers->>Renderer: IPC event
Renderer->>User: Show permission dialog
User->>Renderer: Select option
Renderer->>Handlers: permission:respond IPC
Handlers->>Adapter: sendResponse()
Adapter->>CLI: Write response to PTY
Permission Request Creation
Source: Lines 587-598
const permissionRequest: PermissionRequest = {
id: this.generateRequestId(),
taskId: this.currentTaskId || '',
type: 'question',
question: question.question,
options: question.options?.map((o) => ({
label: o.label,
description: o.description,
})),
multiSelect: question.multiSelect,
createdAt: new Date().toISOString(),
};
this.emit('permission-request', permissionRequest);
Task Control
Cancel (Hard Kill)
Source: Lines 242-248
async cancelTask(): Promise<void> {
if (this.ptyProcess) {
this.ptyProcess.kill();
this.ptyProcess = null;
}
}
Behavior: Immediately terminates PTY process
Interrupt (Graceful)
Source: Lines 255-267
async interruptTask(): Promise<void> {
if (!this.ptyProcess) {
console.log('[OpenCode CLI] No active process to interrupt');
return;
}
this.wasInterrupted = true;
this.ptyProcess.write('\x03');
console.log('[OpenCode CLI] Sent Ctrl+C interrupt signal');
}
Behavior: Sends Ctrl+C (ASCII 0x03) to interrupt current operation
Response Sending
Source: Lines 230-237
async sendResponse(response: string): Promise<void> {
if (!this.ptyProcess) {
throw new Error('No active process');
}
this.ptyProcess.write(response + '\n');
console.log('[OpenCode CLI] Response sent via PTY');
}
Purpose: Send user input back to CLI (for permissions, questions)
Process Exit Handling
Source: Lines 603-627
stateDiagram-v2
[*] --> Exit: PTY onExit event
Exit --> CheckComplete: Has completion event?
CheckComplete -->|Yes| Skip: Skip (already handled)
CheckComplete -->|No| CheckInterrupted: Was interrupted?
CheckInterrupted -->|Yes & code=0| Interrupted: Emit 'complete' interrupted
CheckInterrupted -->|No| CheckCode: Check exit code
CheckCode -->|code=0| Success: Emit 'complete' success
CheckCode -->|code!=0| Error: Emit 'error'
CheckCode -->|code=null| Done: No action
Interrupted --> [*]
Success --> [*]
Error --> [*]
Done --> [*]
Skip --> [*]
Exit Code Handling:
- 0 + interrupted: User interrupted, task can continue
- 0 (normal): Success, no result message received
- non-null: Error exit
Resource Cleanup
Source: Lines 294-325
graph TB
subgraph "Dispose Process"
CHECK{isDisposed?}
KILL[Kill PTY process]
CLEAR[Clear state]
RESET[Reset stream parser]
REMOVE[Remove listeners]
LOG[Log disposal]
end
CHECK -->|Yes| RETURN((Return))
CHECK -->|No| KILL
KILL --> CLEAR
CLEAR --> RESET
RESET --> REMOVE
REMOVE --> LOG
LOG --> RETURN
style CHECK fill:#fff3e0
style KILL fill:#ffebee
style REMOVE fill:#e8f5e9
dispose(): void {
if (this.isDisposed) {
return;
}
console.log(`[OpenCode Adapter] Disposing adapter for task ${this.currentTaskId}`);
this.isDisposed = true;
if (this.ptyProcess) {
try {
this.ptyProcess.kill();
} catch (error) {
console.error('[OpenCode Adapter] Error killing PTY process:', error);
}
this.ptyProcess = null;
}
this.currentSessionId = null;
this.currentTaskId = null;
this.messages = [];
this.hasCompleted = true;
this.streamParser.reset();
this.removeAllListeners();
console.log('[OpenCode Adapter] Adapter disposed');
}
Integration with TaskManager
sequenceDiagram
participant TM as TaskManager
participant OA as OpenCodeAdapter
participant CB as TaskCallbacks
TM->>OA: new OpenCodeAdapter(taskId)
TM->>OA: adapter.on('message', onMessage)
TM->>OA: adapter.on('progress', onProgress)
TM->>OA: adapter.on('permission-request', onPermissionRequest)
TM->>OA: adapter.on('complete', onComplete)
TM->>OA: adapter.on('error', onError)
TM->>OA: adapter.on('debug', onDebug)
TM->>OA: adapter.startTask(config)
loop Task Execution
OA->>CB: onMessage(message)
OA->>CB: onProgress(progress)
OA->>CB: onPermissionRequest(request)
end
OA->>CB: onComplete(result)
TM->>OA: cleanup()
OA->>OA: adapter.dispose()
Summary
The OpenCode adapter provides:
| Feature | Implementation | Benefit |
|---|---|---|
| Process Isolation | Per-task PTY processes | Parallel execution |
| Stream Parsing | NDJSON parser with filtering | Clean event extraction |
| Platform Support | Adaptive shell selection | Cross-platform compatibility |
| Permission Handling | Event-based user prompts | Interactive CLI features |
| Resource Management | Proper cleanup on dispose | No memory leaks |
| Error Handling | Graceful exit handling | Accurate status reporting |
Key Design Principles:
- Isolation: Each task gets its own adapter and PTY
- Reliability: Proper cleanup and error handling
- Cross-Platform: Adaptive shell and quoting strategies
- Performance: Efficient stream processing
- Debugging: Comprehensive logging