Files
kandev/apps/backend/docs/opencode_gemini_plan.md
T
2026-01-24 19:03:31 +00:00

24 KiB

Multi-Agent Support Plan: OpenCode and Gemini

Summary

Add support for two additional AI coding agents to kandev-2:

  1. OpenCode - REST/SSE-based protocol with HTTP server
  2. Gemini - ACP-based protocol (reuses existing ACP adapter)

Architecture Overview

Protocol Layer              Adapter Layer              Normalized Events
-------------------        -------------------        -------------------
ACP (JSON-RPC)        -->  ACPAdapter            -->
Codex (JSON-RPC)      -->  CodexAdapter          -->  AgentEvent
Claude (stream-json)  -->  ClaudeCodeAdapter     -->  (protocol-agnostic)
OpenCode (REST/SSE)   -->  OpenCodeAdapter       -->  (NEW)
Gemini (ACP)          -->  ACPAdapter (reuse)    -->  (uses existing)

Agent 1: OpenCode

Protocol Overview

OpenCode uses a REST API + Server-Sent Events (SSE) pattern:

  1. Spawn HTTP Server: Run npx -y opencode-ai@<version> serve --hostname 127.0.0.1 --port 0
  2. Wait for Server URL: Parse stdout for opencode server listening on <url>
  3. Health Check: Poll GET /global/health until healthy
  4. Session Management:
    • Create: POST /session?directory=<dir>
    • Fork (resume): POST /session/{id}/fork?directory=<dir>
  5. Send Prompt: POST /session/{id}/message?directory=<dir>
  6. Event Stream: GET /event?directory=<dir> (SSE)
  7. Permission Handling: POST /permission/{id}/reply?directory=<dir>
  8. Abort: POST /session/{id}/abort?directory=<dir>

Key Features

  • Password Auth: Server uses OPENCODE_SERVER_PASSWORD env var for basic auth
  • Session Persistence: Sessions can be forked for follow-up prompts
  • Permission System: permission.asked events require reply with once/reject
  • Token Tracking: Events include token usage info

Event Types from SSE Stream

Event Type Description
message.updated Message metadata updated (tokens, model info)
message.part.updated Text/reasoning/tool content streaming
permission.asked Tool needs approval
permission.replied Permission response sent
session.idle Agent finished processing
session.error Error occurred
todo.updated Task list updated

Event Mapping: OpenCode --> AgentEvent

OpenCode Event AgentEvent Type Field Mapping
session_start session_status SessionID
message.part.updated (text) message_chunk Text
message.part.updated (reasoning) reasoning ReasoningText
message.part.updated (tool) tool_call / tool_update ToolCallID, ToolName, ToolStatus
permission.asked permission_request PendingID, PermissionTitle
message.updated (tokens) context_window ContextWindowUsed
session.idle + done complete -
session.error error Error

Agent 2: Gemini (Google)

Protocol Overview

Gemini uses the ACP (Agent Communication Protocol) - same as Auggie:

  1. Spawn CLI: npx -y @google/gemini-cli@<version> --experimental-acp
  2. ACP Initialization: JSON-RPC over stdin/stdout
  3. Session Management: Via ACP session/load protocol
  4. Prompts: Via ACP prompt method

Key Insight

Gemini CLI supports --experimental-acp flag, making it fully compatible with our existing ACPAdapter. No new adapter needed - just configuration.

Command Flags

npx -y @google/gemini-cli@0.23.0 \
  --model <model> \
  --experimental-acp \
  [--yolo]  # Auto-approve (optional)

Implementation Phases

Phase 1: Add Protocol Constants

File: pkg/agent/protocol.go

const (
    // ... existing protocols
    // ProtocolOpenCode is the OpenCode CLI protocol (REST/SSE over HTTP).
    ProtocolOpenCode Protocol = "opencode"
)

func (p Protocol) IsValid() bool {
    switch p {
    case ProtocolACP, ProtocolREST, ProtocolMCP, ProtocolCodex,
         ProtocolClaudeCode, ProtocolOpenCode:
        return true
    }
    return false
}

Note: Gemini uses ProtocolACP (existing).


Phase 2: OpenCode Protocol Types

New File: pkg/opencode/types.go

package opencode

// ExecutorEvent represents events written to stdout by the adapter
type ExecutorEvent struct {
    Type string `json:"type"`
    // Variants based on type
}

type SessionStartEvent struct {
    Type      string `json:"type"` // "session_start"
    SessionID string `json:"session_id"`
}

type SDKEvent struct {
    Type  string          `json:"type"` // "sdk_event"
    Event json.RawMessage `json:"event"`
}

type TokenUsageEvent struct {
    Type               string `json:"type"` // "token_usage"
    TotalTokens        int    `json:"total_tokens"`
    ModelContextWindow int    `json:"model_context_window"`
}

type ApprovalResponseEvent struct {
    Type       string `json:"type"` // "approval_response"
    ToolCallID string `json:"tool_call_id"`
    Status     string `json:"status"` // "approved", "denied", etc.
}

type DoneEvent struct {
    Type string `json:"type"` // "done"
}

type ErrorEvent struct {
    Type    string `json:"type"` // "error"
    Message string `json:"message"`
}

// SDK Event types (from /event SSE stream)
type MessageUpdatedEvent struct {
    Type       string      `json:"type"` // "message.updated"
    Properties MessageInfo `json:"properties"`
}

type MessagePartUpdatedEvent struct {
    Type       string         `json:"type"` // "message.part.updated"
    Properties MessagePartInfo `json:"properties"`
    Delta      string         `json:"delta,omitempty"` // For streaming text
}

type MessageInfo struct {
    ID        string           `json:"id"`
    SessionID string           `json:"sessionID"`
    Role      string           `json:"role"` // "user", "assistant"
    Model     *MessageModelInfo `json:"model,omitempty"`
    Tokens    *MessageTokens   `json:"tokens,omitempty"`
}

type MessagePartInfo struct {
    Type      string                 `json:"type"` // "text", "reasoning", "tool"
    MessageID string                 `json:"messageID"`
    SessionID string                 `json:"sessionID"`
    Text      string                 `json:"text,omitempty"`
    CallID    string                 `json:"callID,omitempty"` // For tool
    Tool      string                 `json:"tool,omitempty"`
    State     *ToolStateUpdate       `json:"state,omitempty"`
}

type ToolStateUpdate struct {
    Status   string          `json:"status"` // "pending", "running", "completed", "error"
    Input    json.RawMessage `json:"input,omitempty"`
    Output   string          `json:"output,omitempty"`
    Title    string          `json:"title,omitempty"`
    Error    string          `json:"error,omitempty"`
    Metadata json.RawMessage `json:"metadata,omitempty"`
}

type PermissionAskedEvent struct {
    Type       string `json:"type"` // "permission.asked"
    Properties struct {
        ID         string                 `json:"id"`
        SessionID  string                 `json:"sessionID"`
        Permission string                 `json:"permission"`
        Tool       *PermissionToolInfo    `json:"tool,omitempty"`
        Metadata   map[string]interface{} `json:"metadata,omitempty"`
    } `json:"properties"`
}

type PermissionToolInfo struct {
    CallID string `json:"callID"`
}

type SessionIdleEvent struct {
    Type       string `json:"type"` // "session.idle"
    Properties struct {
        SessionID string `json:"sessionID"`
    } `json:"properties"`
}

type SessionErrorEvent struct {
    Type       string `json:"type"` // "session.error"
    Properties struct {
        SessionID string      `json:"sessionID"`
        Error     interface{} `json:"error"`
    } `json:"properties"`
}

Phase 3: OpenCode HTTP Client

New File: pkg/opencode/client.go

package opencode

import (
    "context"
    "encoding/base64"
    "fmt"
    "io"
    "net/http"
    "sync"
    "time"

    "github.com/kandev/kandev/internal/common/logger"
)

// Client manages HTTP communication with OpenCode server
type Client struct {
    baseURL     string
    directory   string
    password    string
    httpClient  *http.Client
    logger      *logger.Logger

    // Event handling
    eventHandler  EventHandler
    controlCh     chan ControlEvent

    // Session state
    sessionID   string
    mu          sync.RWMutex

    ctx         context.Context
    cancel      context.CancelFunc
}

type EventHandler func(event *SDKEvent)

type ControlEvent struct {
    Type    string // "idle", "auth_required", "session_error", "disconnected"
    Message string
}

func NewClient(baseURL, directory, password string, log *logger.Logger) *Client

// Core operations
func (c *Client) WaitForHealth(ctx context.Context) error
func (c *Client) CreateSession(ctx context.Context) (string, error)
func (c *Client) ForkSession(ctx context.Context, sessionID string) (string, error)
func (c *Client) SendPrompt(ctx context.Context, sessionID, prompt string, model *ModelSpec) error
func (c *Client) Abort(ctx context.Context, sessionID string) error
func (c *Client) ReplyPermission(ctx context.Context, requestID, reply string, message *string) error

// Event stream
func (c *Client) StartEventStream(ctx context.Context) error
func (c *Client) SetEventHandler(handler EventHandler)
func (c *Client) ControlChannel() <-chan ControlEvent

// Helpers
func (c *Client) buildAuthHeader() string

Phase 4: OpenCode Adapter

New File: internal/agentctl/server/adapter/opencode_adapter.go

package adapter

import (
    "context"
    "io"
    "os/exec"
    "sync"

    "github.com/kandev/kandev/internal/common/logger"
    "github.com/kandev/kandev/pkg/opencode"
)

type OpenCodeAdapter struct {
    cfg       *Config
    logger    *logger.Logger

    // Process management
    cmd       *exec.Cmd
    stdout    io.ReadCloser

    // HTTP client (created after server starts)
    client    *opencode.Client

    // Session state
    sessionID string
    password  string

    // Event channel
    updatesCh chan AgentEvent

    // Permission handling
    permissionHandler PermissionHandler

    mu     sync.RWMutex
    closed bool

    ctx    context.Context
    cancel context.CancelFunc
}

func NewOpenCodeAdapter(cfg *Config, log *logger.Logger) *OpenCodeAdapter

// AgentAdapter interface implementation
func (a *OpenCodeAdapter) PrepareEnvironment() error
func (a *OpenCodeAdapter) Connect(stdin io.Writer, stdout io.Reader) error
func (a *OpenCodeAdapter) Initialize(ctx context.Context) error
func (a *OpenCodeAdapter) Prompt(ctx context.Context, message string) error
func (a *OpenCodeAdapter) Cancel(ctx context.Context) error
func (a *OpenCodeAdapter) NewSession(ctx context.Context, mcpServers []types.McpServer) (string, error)
func (a *OpenCodeAdapter) LoadSession(ctx context.Context, sessionID string) error
func (a *OpenCodeAdapter) Updates() <-chan AgentEvent
func (a *OpenCodeAdapter) Close() error
func (a *OpenCodeAdapter) GetAgentInfo() *AgentInfo
func (a *OpenCodeAdapter) SetPermissionHandler(handler PermissionHandler)
func (a *OpenCodeAdapter) RespondToPermission(ctx context.Context, requestID string, response *PermissionResponse) error

// Internal methods
func (a *OpenCodeAdapter) waitForServerURL(stdout io.Reader) (string, error)
func (a *OpenCodeAdapter) handleSDKEvent(event *opencode.SDKEvent)
func (a *OpenCodeAdapter) parseAndEmitEvent(eventType string, data map[string]interface{})
func (a *OpenCodeAdapter) sendUpdate(event AgentEvent)

Key Implementation Details

Server Startup Flow:

  1. Spawn npx -y opencode-ai@<version> serve --hostname 127.0.0.1 --port 0
  2. Parse stdout for opencode server listening on <url>
  3. Create HTTP client with parsed URL
  4. Call WaitForHealth()
  5. Create session via CreateSession() or ForkSession()
  6. Start SSE event stream in background goroutine

Event Processing:

func (a *OpenCodeAdapter) handleSDKEvent(event *opencode.SDKEvent) {
    switch event.Type {
    case "message.part.updated":
        part := parseMessagePart(event)
        switch part.Type {
        case "text":
            a.sendUpdate(AgentEvent{
                Type: EventTypeMessageChunk,
                Text: part.Delta,
            })
        case "reasoning":
            a.sendUpdate(AgentEvent{
                Type:          EventTypeReasoning,
                ReasoningText: part.Delta,
            })
        case "tool":
            a.handleToolUpdate(part)
        }
    case "permission.asked":
        a.handlePermissionRequest(event)
    case "session.idle":
        a.sendUpdate(AgentEvent{
            Type: EventTypeComplete,
        })
    case "session.error":
        a.sendUpdate(AgentEvent{
            Type:  EventTypeError,
            Error: extractErrorMessage(event),
        })
    }
}

Phase 5: Update Adapter Factory

File: internal/agentctl/server/adapter/factory.go

func NewAdapter(protocol agent.Protocol, cfg *Config, log *logger.Logger) (AgentAdapter, error) {
    switch protocol {
    case agent.ProtocolACP:
        return NewACPAdapter(cfg, log), nil
    case agent.ProtocolCodex:
        return NewCodexAdapter(cfg, log), nil
    case agent.ProtocolClaudeCode:
        return NewClaudeCodeAdapter(cfg, log), nil
    case agent.ProtocolOpenCode:
        return NewOpenCodeAdapter(cfg, log), nil
    default:
        return nil, fmt.Errorf("unsupported protocol: %s", protocol)
    }
}

Phase 6: Update Registry Protocol Parser

File: internal/agent/registry/registry.go

func parseProtocol(p string) agent.Protocol {
    switch p {
    case "acp":
        return agent.ProtocolACP
    case "codex":
        return agent.ProtocolCodex
    case "claude-code":
        return agent.ProtocolClaudeCode
    case "opencode":
        return agent.ProtocolOpenCode
    default:
        return agent.ProtocolACP
    }
}

Phase 7: Agent Configurations

File: internal/agent/registry/agents.json

Add these entries to the agents array:

{
  "id": "opencode",
  "name": "OpenCode AI Agent",
  "display_name": "OpenCode",
  "description": "OpenCode CLI-powered autonomous coding agent using REST/SSE protocol.",
  "enabled": true,
  "image": "",
  "tag": "",
  "cmd": [
    "npx",
    "-y",
    "opencode-ai@1.1.25",
    "serve",
    "--hostname",
    "127.0.0.1",
    "--port",
    "0"
  ],
  "working_dir": "{workspace}",
  "required_env": [],
  "env": {},
  "mounts": [],
  "resource_limits": {
    "memory_mb": 4096,
    "cpu_cores": 2.0,
    "timeout_seconds": 3600
  },
  "capabilities": [
    "code_generation",
    "code_review",
    "refactoring",
    "testing",
    "shell_execution"
  ],
  "protocol": "opencode",
  "protocol_config": {},
  "model_flag": "",
  "workspace_flag": "",
  "session_config": {
    "resume_via_acp": false,
    "resume_flag": "",
    "can_recover": true,
    "session_dir_template": "{home}/.opencode",
    "session_dir_target": "",
    "reports_status_via_stream": true
  },
  "permission_config": {
    "permission_flag": "",
    "tools_requiring_permission": ["edit", "bash", "webfetch"]
  },
  "permission_settings": {
    "auto_approve": {
      "supported": true,
      "default": true,
      "label": "Auto-approve",
      "description": "Automatically approve tool calls",
      "apply_method": "env",
      "cli_flag": ""
    }
  },
  "discovery": {
    "supports_mcp": true,
    "mcp_config_path": {
      "linux": ["~/.config/opencode/opencode.json", "~/.config/opencode/opencode.jsonc"],
      "windows": [],
      "macos": ["~/Library/Application Support/opencode/opencode.json"]
    },
    "installation_path": {
      "linux": ["~/.opencode", "~/.config/opencode"],
      "windows": [],
      "macos": ["~/.opencode", "~/Library/Application Support/ai.opencode.desktop"]
    },
    "discovery_capabilities": {
      "supports_session_resume": true,
      "supports_shell": false,
      "supports_workspace_only": false
    }
  },
  "model_config": {
    "default_model": "anthropic/claude-sonnet-4-20250514",
    "available_models": [
      {
        "id": "anthropic/claude-sonnet-4-20250514",
        "name": "Claude Sonnet 4",
        "description": "Anthropic Claude Sonnet 4",
        "provider": "anthropic",
        "context_window": 200000,
        "is_default": true
      },
      {
        "id": "anthropic/claude-opus-4-20250514",
        "name": "Claude Opus 4",
        "description": "Anthropic Claude Opus 4",
        "provider": "anthropic",
        "context_window": 200000,
        "is_default": false
      },
      {
        "id": "openai/gpt-5.2",
        "name": "GPT-5.2",
        "description": "OpenAI GPT-5.2",
        "provider": "openai",
        "context_window": 200000,
        "is_default": false
      },
      {
        "id": "google/gemini-2.5-pro",
        "name": "Gemini 2.5 Pro",
        "description": "Google Gemini 2.5 Pro",
        "provider": "google",
        "context_window": 2000000,
        "is_default": false
      }
    ]
  }
},
{
  "id": "gemini",
  "name": "Google Gemini CLI Agent",
  "display_name": "Gemini",
  "description": "Google Gemini CLI-powered autonomous coding agent using ACP protocol.",
  "enabled": true,
  "image": "",
  "tag": "",
  "cmd": [
    "npx",
    "-y",
    "@google/gemini-cli@0.23.0",
    "--experimental-acp"
  ],
  "working_dir": "{workspace}",
  "required_env": [],
  "env": {},
  "mounts": [],
  "resource_limits": {
    "memory_mb": 4096,
    "cpu_cores": 2.0,
    "timeout_seconds": 3600
  },
  "capabilities": [
    "code_generation",
    "code_review",
    "refactoring",
    "testing",
    "shell_execution"
  ],
  "protocol": "acp",
  "protocol_config": {},
  "model_flag": "--model {model}",
  "workspace_flag": "",
  "session_config": {
    "resume_via_acp": true,
    "resume_flag": "",
    "can_recover": true,
    "session_dir_template": "{home}/.gemini",
    "session_dir_target": "",
    "reports_status_via_stream": false
  },
  "permission_config": {
    "permission_flag": "",
    "tools_requiring_permission": ["run_shell_command", "write_file", "edit_file"]
  },
  "permission_settings": {
    "auto_approve": {
      "supported": true,
      "default": false,
      "label": "Auto-approve (YOLO mode)",
      "description": "Automatically approve all tool calls",
      "apply_method": "cli_flag",
      "cli_flag": "--yolo --allowed-tools run_shell_command"
    }
  },
  "discovery": {
    "supports_mcp": true,
    "mcp_config_path": {
      "linux": ["~/.gemini/settings.json"],
      "windows": [],
      "macos": ["~/.gemini/settings.json"]
    },
    "installation_path": {
      "linux": ["~/.gemini/oauth_creds.json", "~/.gemini/installation_id"],
      "windows": [],
      "macos": ["~/.gemini/oauth_creds.json", "~/.gemini/installation_id"]
    },
    "discovery_capabilities": {
      "supports_session_resume": true,
      "supports_shell": false,
      "supports_workspace_only": false
    }
  },
  "model_config": {
    "default_model": "gemini-2.5-flash",
    "available_models": [
      {
        "id": "gemini-2.5-flash",
        "name": "Gemini 2.5 Flash",
        "description": "Fast and efficient model",
        "provider": "google",
        "context_window": 1000000,
        "is_default": true
      },
      {
        "id": "gemini-2.5-pro",
        "name": "Gemini 2.5 Pro",
        "description": "Most capable model with 2M context",
        "provider": "google",
        "context_window": 2000000,
        "is_default": false
      }
    ]
  }
}

Files to Create

File Description
pkg/opencode/types.go OpenCode protocol type definitions
pkg/opencode/client.go HTTP/SSE client for OpenCode server
pkg/opencode/client_test.go Unit tests for client
internal/agentctl/server/adapter/opencode_adapter.go Adapter implementation
internal/agentctl/server/adapter/opencode_adapter_test.go Adapter tests

Files to Modify

File Change
pkg/agent/protocol.go Add ProtocolOpenCode constant
internal/agent/registry/registry.go Add "opencode" case to parseProtocol()
internal/agent/registry/agents.json Add opencode and gemini agent configs
internal/agentctl/server/adapter/factory.go Add OpenCode case to factory

Key Differences Between Agents

Aspect OpenCode Gemini
Protocol REST/SSE over HTTP ACP (JSON-RPC over stdin/stdout)
Server Model Spawns HTTP server Uses ACP directly
Session Resume HTTP fork endpoint ACP session/load
Permission Handling HTTP reply endpoint ACP callback
New Adapter Needed Yes (OpenCodeAdapter) No (reuses ACPAdapter)
Auth Password-based Basic Auth OAuth (stored in ~/.gemini)

Permission Handling

OpenCode

// In opencode_adapter.go
func (a *OpenCodeAdapter) handlePermissionRequest(event *opencode.PermissionAskedEvent) {
    requestID := event.Properties.ID
    toolCallID := event.Properties.Tool.CallID
    permission := event.Properties.Permission

    a.sendUpdate(AgentEvent{
        Type:            EventTypePermissionRequest,
        SessionID:       a.sessionID,
        PendingID:       requestID,
        ToolCallID:      toolCallID,
        PermissionTitle: formatPermissionTitle(permission, event.Properties.Metadata),
        ActionType:      permission,
        ActionDetails:   event.Properties.Metadata,
    })
}

func (a *OpenCodeAdapter) RespondToPermission(ctx context.Context, requestID string, response *PermissionResponse) error {
    reply := "reject"
    var message *string

    if response.Approved {
        reply = "once"
    } else {
        msg := response.Message
        if msg == "" {
            msg = "User denied this tool use request"
        }
        message = &msg
    }

    return a.client.ReplyPermission(ctx, requestID, reply, message)
}

Gemini (uses existing ACPAdapter permission handling)


Session Management

OpenCode

func (a *OpenCodeAdapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) {
    sessionID, err := a.client.CreateSession(ctx)
    if err != nil {
        return "", err
    }

    a.mu.Lock()
    a.sessionID = sessionID
    a.mu.Unlock()

    // Emit session status event for resume token storage
    a.sendUpdate(AgentEvent{
        Type:      EventTypeSessionStatus,
        SessionID: sessionID,
        Data: map[string]interface{}{
            "session_status": "active",
            "init":           true,
        },
    })

    return sessionID, nil
}

func (a *OpenCodeAdapter) LoadSession(ctx context.Context, sessionID string) error {
    // Fork existing session (creates new session with history)
    newSessionID, err := a.client.ForkSession(ctx, sessionID)
    if err != nil {
        return err
    }

    a.mu.Lock()
    a.sessionID = newSessionID
    a.mu.Unlock()

    return nil
}

Gemini (uses existing ACPAdapter session handling via ACP protocol)


Testing Checklist

OpenCode

  • Server starts and health check passes
  • Session creation works
  • Prompt sends and response streams
  • Text content displays correctly
  • Reasoning content displays correctly
  • Tool calls display with status updates
  • Permission requests trigger UI
  • Permission responses (approve/deny) work
  • Session resume (fork) works
  • Token usage tracked
  • Abort works
  • Error handling works

Gemini

  • Agent appears when oauth_creds.json exists
  • ACP initialization works
  • Prompt sends and response streams
  • Tool calls work
  • Permission requests work (without --yolo)
  • --yolo mode auto-approves
  • Session resume works via ACP
  • Model selection works (--model flag)

Implementation Priority

  1. Gemini First - Simpler, reuses ACPAdapter, just needs config
  2. OpenCode Second - Requires new adapter with HTTP/SSE handling

Gemini Implementation Steps

  1. Add agent config to agents.json
  2. Test with existing ACPAdapter
  3. Verify model flag and auto-approve work

OpenCode Implementation Steps

  1. Add protocol constant
  2. Create types package
  3. Create HTTP/SSE client
  4. Create adapter
  5. Add to factory
  6. Add agent config
  7. Test end-to-end