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:

  1. Extracts tool name, input, output, status
  2. Creates synthetic text message for models without text output
  3. Emits multiple events for different UI concerns
  4. 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:

  1. Isolation: Each task gets its own adapter and PTY
  2. Reliability: Proper cleanup and error handling
  3. Cross-Platform: Adaptive shell and quoting strategies
  4. Performance: Efficient stream processing
  5. Debugging: Comprehensive logging

Back to top

OpenWork Documentation - Community documentation for accomplish-ai/openwork