Files
kandev/apps/backend/docs/opencode_gemini_plan.md
T

864 lines
24 KiB
Markdown

# 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
```bash
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`**
```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`**
```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`**
```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`**
```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:**
```go
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`**
```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`**
```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:
```json
{
"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": {
"native_session_resume": 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": {
"native_session_resume": 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
```go
// 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
```go
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