feat: add opencode provider and refactor stream model to head/tail architecture
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
---
|
||||
id: 90e02e62
|
||||
title: Add OpenCode agent provider with feature parity to Claude/Codex
|
||||
status: open
|
||||
deps: []
|
||||
created: 2026-01-11T04:14:38.396Z
|
||||
---
|
||||
|
||||
# OpenCode Agent Provider Integration
|
||||
|
||||
## Overview
|
||||
|
||||
Add OpenCode as a third agent provider in Paseo alongside Claude and Codex. OpenCode is an open-source AI coding assistant by SST that supports multiple LLM providers (Anthropic, OpenAI, etc.) through user configuration.
|
||||
|
||||
## Repository & Architecture
|
||||
|
||||
- **Repository**: https://github.com/sst/opencode
|
||||
- **Packages**:
|
||||
- `packages/opencode` - Core TypeScript/Bun library (CLI + server)
|
||||
- `packages/sdk` - JavaScript SDK for programmatic access (`@opencode/sdk`)
|
||||
|
||||
### Server Architecture
|
||||
|
||||
OpenCode uses a **server-based architecture**:
|
||||
- HTTP server with SSE for events (Hono framework on Bun)
|
||||
- One server can handle **multiple directories** via \`x-opencode-directory\` header
|
||||
- One server can handle **multiple concurrent sessions** in the same directory
|
||||
- Storage is global at \`~/.local/share/opencode/\` (XDG data dir)
|
||||
- Sessions stored at: \`session/{projectID}/{sessionID}.json\` where projectID = git root commit hash
|
||||
|
||||
### Key Server Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| \`/session\` | POST | Create new session |
|
||||
| \`/session/:id/message\` | POST | Send prompt (blocking) |
|
||||
| \`/session/:id/prompt_async\` | POST | Send prompt (non-blocking) |
|
||||
| \`/session/:id/abort\` | POST | Cancel/interrupt session |
|
||||
| \`/event\` | GET (SSE) | Subscribe to all events |
|
||||
| \`/permission/:requestID/reply\` | POST | Respond to permission request |
|
||||
| \`/config\` | GET | Get configuration |
|
||||
| \`/instance/dispose\` | POST | Dispose instance (for config reload) |
|
||||
|
||||
### Directory Routing
|
||||
|
||||
Every API request specifies its working directory:
|
||||
\`\`\`typescript
|
||||
// Via header
|
||||
fetch('/session', { headers: { 'x-opencode-directory': '/path/to/project' } })
|
||||
|
||||
// Via query param
|
||||
fetch('/session?directory=/path/to/project')
|
||||
\`\`\`
|
||||
|
||||
The server maintains per-directory "Instance" contexts that are lazily created and cached.
|
||||
|
||||
### Port Selection
|
||||
|
||||
When starting with \`--port 0\`:
|
||||
1. Tries port 4096 first
|
||||
2. Falls back to random port if 4096 is busy
|
||||
3. Returns actual port via server URL
|
||||
|
||||
For Paseo: We should **choose our own random port** to avoid race conditions and have predictable control.
|
||||
|
||||
## SDK Usage
|
||||
|
||||
The JS SDK (\`@opencode/sdk\`) provides typed client:
|
||||
|
||||
\`\`\`typescript
|
||||
import { Server } from "@opencode/sdk/v2"
|
||||
|
||||
const client = new Server("http://localhost:PORT")
|
||||
|
||||
// Create session
|
||||
const session = await client.session.create({
|
||||
directory: "/path/to/project"
|
||||
}, {
|
||||
headers: { 'x-opencode-directory': '/path/to/project' }
|
||||
})
|
||||
|
||||
// Subscribe to events (SSE)
|
||||
for await (const event of client.event.subscribe()) {
|
||||
// Handle events
|
||||
}
|
||||
|
||||
// Send prompt
|
||||
await client.session.prompt_async({
|
||||
sessionID: session.id,
|
||||
parts: [{ type: "text", text: "Your prompt" }],
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-20250514",
|
||||
agent: "coder"
|
||||
})
|
||||
|
||||
// Respond to permission
|
||||
await client.permission.reply({
|
||||
requestID: "...",
|
||||
reply: "allow" // or "deny", "always", "session"
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
## Event Types
|
||||
|
||||
Events come via SSE at \`/event\`. Key event types:
|
||||
|
||||
- \`session.created\` / \`session.updated\` / \`session.deleted\`
|
||||
- \`message.updated\` - Message content updates
|
||||
- \`message.part.updated\` - Streaming part updates (text, tool calls, reasoning)
|
||||
- \`permission.request\` - Permission request from agent
|
||||
- \`session.error\` - Error events
|
||||
|
||||
### Message Part Types
|
||||
|
||||
\`\`\`typescript
|
||||
type Part =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "reasoning"; content: string }
|
||||
| { type: "tool-invocation"; toolName: string; input: object; state: "pending" | "running" | "complete" | "error"; output?: object }
|
||||
\`\`\`
|
||||
|
||||
## Permission System
|
||||
|
||||
OpenCode has a permission system similar to Claude/Codex:
|
||||
|
||||
### Permission Request
|
||||
\`\`\`typescript
|
||||
{
|
||||
id: string
|
||||
sessionID: string
|
||||
tool: string // e.g., "bash", "write", "edit"
|
||||
input: object // tool input parameters
|
||||
metadata?: object
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Permission Reply Options
|
||||
- \`"allow"\` - Allow this specific request
|
||||
- \`"deny"\` - Deny this specific request
|
||||
- \`"always"\` - Always allow this tool
|
||||
- \`"session"\` - Allow for this session
|
||||
|
||||
### Mapping to Paseo Types
|
||||
|
||||
| Paseo | OpenCode |
|
||||
|-------|----------|
|
||||
| \`AgentPermissionRequest.id\` | \`request.id\` |
|
||||
| \`AgentPermissionRequest.name\` | \`request.tool\` |
|
||||
| \`AgentPermissionRequest.kind\` | Derive from tool name |
|
||||
| \`AgentPermissionRequest.input\` | \`request.input\` |
|
||||
| \`AgentPermissionResponse.behavior: "allow"\` | \`reply: "allow"\` |
|
||||
| \`AgentPermissionResponse.behavior: "deny"\` | \`reply: "deny"\` |
|
||||
|
||||
## Config & Model Selection
|
||||
|
||||
Users configure OpenCode in \`~/.config/opencode/opencode.json\` or project \`opencode.json\`:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"model": "anthropic/claude-sonnet-4-20250514",
|
||||
"provider": {
|
||||
"anthropic": {},
|
||||
"openai": { "apiKey": "..." }
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**For Paseo**:
|
||||
- Default to user's configured model
|
||||
- Can override per-prompt via \`providerID\` + \`modelID\` in API call
|
||||
|
||||
### Config Reload
|
||||
|
||||
To reload config without restarting server:
|
||||
1. Send \`SIGUSR2\` to server process, OR
|
||||
2. \`POST /instance/dispose?directory=/path\` to dispose specific instance
|
||||
|
||||
## Implementation Requirements
|
||||
|
||||
### 1. Server Lifecycle Management
|
||||
|
||||
Create lazy singleton server manager:
|
||||
\`\`\`typescript
|
||||
class OpenCodeServerManager {
|
||||
private server: ChildProcess | null = null
|
||||
private port: number | null = null
|
||||
|
||||
async ensureRunning(): Promise<{ port: number; url: string }> {
|
||||
if (this.server && this.isHealthy()) {
|
||||
return { port: this.port!, url: \`http://localhost:\${this.port}\` }
|
||||
}
|
||||
|
||||
// Choose random available port
|
||||
this.port = await getRandomAvailablePort()
|
||||
|
||||
// Launch: opencode serve --port <port>
|
||||
this.server = spawn('opencode', ['serve', '--port', String(this.port)])
|
||||
|
||||
// Wait for health check
|
||||
await this.waitForHealth()
|
||||
|
||||
return { port: this.port, url: \`http://localhost:\${this.port}\` }
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
// Graceful shutdown
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 2. Agent Types Update
|
||||
|
||||
\`\`\`typescript
|
||||
// agent-sdk-types.ts
|
||||
export type AgentProvider = "codex" | "claude" | "opencode"
|
||||
\`\`\`
|
||||
|
||||
### 3. OpenCodeAgentClient Implementation
|
||||
|
||||
Implement \`AgentClient\` interface:
|
||||
- \`createSession(config)\` - Create new session via POST /session
|
||||
- \`resumeSession(handle)\` - Resume existing session
|
||||
- \`listPersistedAgents()\` - List sessions via GET /session
|
||||
|
||||
### 4. OpenCodeAgentSession Implementation
|
||||
|
||||
Implement \`AgentSession\` interface:
|
||||
|
||||
| Method | OpenCode Implementation |
|
||||
|--------|------------------------|
|
||||
| \`stream(prompt)\` | POST /session/:id/prompt_async + SSE /event |
|
||||
| \`run(prompt)\` | POST /session/:id/message (blocking) |
|
||||
| \`interrupt()\` | POST /session/:id/abort |
|
||||
| \`respondToPermission()\` | POST /permission/:id/reply |
|
||||
| \`getAvailableModes()\` | Return static modes or from config |
|
||||
| \`setMode()\` | Update session permission ruleset |
|
||||
| \`getRuntimeInfo()\` | Return provider/model from session |
|
||||
|
||||
### 5. Event Translation
|
||||
|
||||
Map OpenCode events to Paseo \`AgentStreamEvent\`:
|
||||
|
||||
| OpenCode Event | Paseo Event |
|
||||
|----------------|-------------|
|
||||
| \`session.created\` | \`thread_started\` |
|
||||
| \`message.part.updated\` (text) | \`timeline\` with \`assistant_message\` |
|
||||
| \`message.part.updated\` (reasoning) | \`timeline\` with \`reasoning\` |
|
||||
| \`message.part.updated\` (tool-invocation) | \`timeline\` with \`tool_call\` |
|
||||
| \`permission.request\` | \`permission_requested\` |
|
||||
| \`session.error\` | \`turn_failed\` |
|
||||
|
||||
### 6. Tool Call Mapping
|
||||
|
||||
Map OpenCode tool names to Paseo \`ToolCallKind\`:
|
||||
|
||||
| Tool Name Pattern | Kind |
|
||||
|-------------------|------|
|
||||
| \`read\`, \`glob\`, \`grep\` | \`read\` |
|
||||
| \`write\`, \`edit\` | \`edit\` |
|
||||
| \`bash\`, \`shell\` | \`execute\` |
|
||||
| \`web_search\`, \`web_fetch\` | \`search\` |
|
||||
| Other | \`other\` |
|
||||
|
||||
## Modes
|
||||
|
||||
OpenCode doesn't have the same mode system as Claude Code. Instead, it uses permission rulesets per-session. Map to equivalent behavior:
|
||||
|
||||
| Paseo Mode | OpenCode Equivalent |
|
||||
|------------|---------------------|
|
||||
| \`auto\` | Default permission rules |
|
||||
| \`acceptEdits\` | Allow write/edit tools |
|
||||
| \`plan\` | Limit to read-only tools |
|
||||
| \`bypassPermissions\` | Allow all tools without prompting |
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Server starts on first agent creation
|
||||
- [ ] Server reuses for subsequent agents
|
||||
- [ ] Multiple agents in same directory work
|
||||
- [ ] Multiple agents in different directories work
|
||||
- [ ] Tool calls display correctly with input/output
|
||||
- [ ] Permission requests show and can be approved/denied
|
||||
- [ ] Interrupting prompts works
|
||||
- [ ] Mode switching works
|
||||
- [ ] Model ID is correctly reported
|
||||
- [ ] Session resume works
|
||||
- [ ] Graceful server shutdown on Paseo exit
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
1. **New**: \`packages/server/src/server/agent/providers/opencode-agent.ts\`
|
||||
2. **Modify**: \`packages/server/src/server/agent/agent-sdk-types.ts\` - Add \`"opencode"\` to AgentProvider
|
||||
3. **Modify**: \`packages/server/src/server/agent/agent-registry.ts\` - Register OpenCode client
|
||||
4. **New**: \`packages/server/src/server/agent/providers/opencode-server-manager.ts\` - Server lifecycle
|
||||
5. **Modify**: \`package.json\` - Add \`@opencode/sdk\` dependency (if using SDK)
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should we use the SDK or make raw HTTP calls? SDK provides types but adds dependency.
|
||||
2. How to handle OpenCode version mismatches between Paseo's expectation and user's installed version?
|
||||
3. Should Paseo show OpenCode's configured model in UI, or let user override?
|
||||
|
||||
## Reference Files
|
||||
|
||||
- OpenCode server: \`/tmp/opencode-repo/packages/opencode/src/server/server.ts\`
|
||||
- OpenCode session: \`/tmp/opencode-repo/packages/opencode/src/session/index.ts\`
|
||||
- OpenCode permission: \`/tmp/opencode-repo/packages/opencode/src/permission/index.ts\`
|
||||
- OpenCode SDK: \`/tmp/opencode-repo/packages/sdk/js/src/v2/\`
|
||||
- Paseo Claude agent: \`packages/server/src/server/agent/providers/claude-agent.ts\`
|
||||
- Paseo Codex agent: \`packages/server/src/server/agent/providers/codex-mcp-agent.ts\`
|
||||
- Paseo agent types: \`packages/server/src/server/agent/agent-sdk-types.ts\`
|
||||
|
||||
## Notes
|
||||
|
||||
**2026-01-11T04:16:21.033Z**
|
||||
|
||||
OpenCode repository cloned to /tmp/opencode-repo for inspection and debugging. This contains the full source code including server, SDK, CLI, and all internal modules.
|
||||
|
||||
**2026-01-11T04:27:14.997Z**
|
||||
|
||||
OpenCode CLI and server API verified working (v1.1.12). Server endpoints tested: POST /session creates session, POST /session/:id/message sends prompt and returns response. Response includes parts array with text content. Ready to implement provider integration.
|
||||
|
||||
**2026-01-11T04:42:15.093Z**
|
||||
|
||||
OpenCode agent provider implementation complete with TDD approach. All 5 tests passing:
|
||||
1. Session creation
|
||||
2. Text prompting/response
|
||||
3. Streaming interruption
|
||||
4. Tool call events (file edits)
|
||||
5. Permission flow
|
||||
|
||||
Uses @opencode-ai/sdk v1.1.12 with GLM-4.7-free model by default.
|
||||
Reference in New Issue
Block a user