Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
305 changes: 157 additions & 148 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@opencode-ai/sdk": "workspace:*",
"@parcel/watcher": "2.5.1",
"@standard-schema/spec": "1.0.0",
"@zed-industries/agent-client-protocol": "0.4.5",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"chokidar": "4.0.3",
Expand Down
164 changes: 164 additions & 0 deletions packages/opencode/src/acp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# ACP (Agent Client Protocol) Implementation

This directory contains a clean, protocol-compliant implementation of the [Agent Client Protocol](https://agentclientprotocol.com/) for opencode.

## Architecture

The implementation follows a clean separation of concerns:

### Core Components

- **`agent.ts`** - Implements the `Agent` interface from `@zed-industries/agent-client-protocol`
- Handles initialization and capability negotiation
- Manages session lifecycle (`session/new`, `session/load`)
- Processes prompts and returns responses
- Properly implements ACP protocol v1

- **`client.ts`** - Implements the `Client` interface for client-side capabilities
- File operations (`readTextFile`, `writeTextFile`)
- Permission requests (auto-approves for now)
- Terminal support (stub implementation)

- **`session.ts`** - Session state management
- Creates and tracks ACP sessions
- Maps ACP sessions to internal opencode sessions
- Maintains working directory context
- Handles MCP server configurations

- **`server.ts`** - ACP server startup and lifecycle
- Sets up JSON-RPC over stdio using the official library
- Manages graceful shutdown on SIGTERM/SIGINT
- Provides Instance context for the agent

- **`types.ts`** - Type definitions for internal use

## Usage

### Command Line

```bash
# Start the ACP server in the current directory
opencode acp

# Start in a specific directory
opencode acp --cwd /path/to/project
```

### Programmatic

```typescript
import { ACPServer } from "./acp/server"

await ACPServer.start()
```

### Integration with Zed

Add to your Zed configuration (`~/.config/zed/settings.json`):

```json
{
"agent_servers": {
"OpenCode": {
"command": "opencode",
"args": ["acp"]
}
}
}
```

## Protocol Compliance

This implementation follows the ACP specification v1:

✅ **Initialization**

- Proper `initialize` request/response with protocol version negotiation
- Capability advertisement (`agentCapabilities`)
- Authentication support (stub)

✅ **Session Management**

- `session/new` - Create new conversation sessions
- `session/load` - Resume existing sessions (basic support)
- Working directory context (`cwd`)
- MCP server configuration support

✅ **Prompting**

- `session/prompt` - Process user messages
- Content block handling (text, resources)
- Response with stop reasons

✅ **Client Capabilities**

- File read/write operations
- Permission requests
- Terminal support (stub for future)

## Current Limitations

### Not Yet Implemented

1. **Streaming Responses** - Currently returns complete responses instead of streaming via `session/update` notifications
2. **Tool Call Reporting** - Doesn't report tool execution progress
3. **Session Modes** - No mode switching support yet
4. **Authentication** - No actual auth implementation
5. **Terminal Support** - Placeholder only
6. **Session Persistence** - `session/load` doesn't restore actual conversation history

### Future Enhancements

- **Real-time Streaming**: Implement `session/update` notifications for progressive responses
- **Tool Call Visibility**: Report tool executions as they happen
- **Session Persistence**: Save and restore full conversation history
- **Mode Support**: Implement different operational modes (ask, code, etc.)
- **Enhanced Permissions**: More sophisticated permission handling
- **Terminal Integration**: Full terminal support via opencode's bash tool

## Testing

```bash
# Run ACP tests
bun test test/acp.test.ts

# Test manually with stdio
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}' | opencode acp
```

## Design Decisions

### Why the Official Library?

We use `@zed-industries/agent-client-protocol` instead of implementing JSON-RPC ourselves because:

- Ensures protocol compliance
- Handles edge cases and future protocol versions
- Reduces maintenance burden
- Works with other ACP clients automatically

### Clean Architecture

Each component has a single responsibility:

- **Agent** = Protocol interface
- **Client** = Client-side operations
- **Session** = State management
- **Server** = Lifecycle and I/O

This makes the codebase maintainable and testable.

### Mapping to OpenCode

ACP sessions map cleanly to opencode's internal session model:

- ACP `session/new` → creates internal Session
- ACP `session/prompt` → uses SessionPrompt.prompt()
- Working directory context preserved per-session
- Tool execution uses existing ToolRegistry

## References

- [ACP Specification](https://agentclientprotocol.com/)
- [TypeScript Library](https://github.com/zed-industries/agent-client-protocol/tree/main/typescript)
- [Protocol Examples](https://github.com/zed-industries/agent-client-protocol/tree/main/typescript/examples)
141 changes: 141 additions & 0 deletions packages/opencode/src/acp/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type {
Agent,
AgentSideConnection,
AuthenticateRequest,
AuthenticateResponse,
CancelNotification,
InitializeRequest,
InitializeResponse,
LoadSessionRequest,
LoadSessionResponse,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
} from "@zed-industries/agent-client-protocol"
import { Log } from "../util/log"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider"
import { SessionPrompt } from "../session/prompt"
import { Identifier } from "../id/id"

export class OpenCodeAgent implements Agent {
private log = Log.create({ service: "acp-agent" })
private sessionManager = new ACPSessionManager()
private connection: AgentSideConnection
private config: ACPConfig

constructor(connection: AgentSideConnection, config: ACPConfig = {}) {
this.connection = connection
this.config = config
}

async initialize(params: InitializeRequest): Promise<InitializeResponse> {
this.log.info("initialize", { protocolVersion: params.protocolVersion })

return {
protocolVersion: 1,
agentCapabilities: {
loadSession: false,
},
_meta: {
opencode: {
version: await import("../installation").then((m) => m.Installation.VERSION),
},
},
}
}

async authenticate(params: AuthenticateRequest): Promise<void | AuthenticateResponse> {
this.log.info("authenticate", { methodId: params.methodId })
throw new Error("Authentication not yet implemented")
}

async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
this.log.info("newSession", { cwd: params.cwd, mcpServers: params.mcpServers.length })

const session = await this.sessionManager.create(params.cwd, params.mcpServers)

return {
sessionId: session.id,
_meta: {},
}
}

async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
this.log.info("loadSession", { sessionId: params.sessionId, cwd: params.cwd })

await this.sessionManager.load(params.sessionId, params.cwd, params.mcpServers)

return {
_meta: {},
}
}

async prompt(params: PromptRequest): Promise<PromptResponse> {
this.log.info("prompt", {
sessionId: params.sessionId,
promptLength: params.prompt.length,
})

const acpSession = this.sessionManager.get(params.sessionId)
if (!acpSession) {
throw new Error(`Session not found: ${params.sessionId}`)
}

const model = this.config.defaultModel || (await Provider.defaultModel())

const parts = params.prompt.map((content) => {
if (content.type === "text") {
return {
type: "text" as const,
text: content.text,
}
}
if (content.type === "resource") {
const resource = content.resource
let text = ""
if ("text" in resource && typeof resource.text === "string") {
text = resource.text
}
return {
type: "text" as const,
text,
}
}
return {
type: "text" as const,
text: JSON.stringify(content),
}
})

await SessionPrompt.prompt({
sessionID: acpSession.openCodeSessionId,
messageID: Identifier.ascending("message"),
model: {
providerID: model.providerID,
modelID: model.modelID,
},
parts,
acpConnection: {
connection: this.connection,
sessionId: params.sessionId,
},
})

this.log.debug("prompt response completed")

// Streaming notifications are now handled during prompt execution
// No need to send final text chunk here

return {
stopReason: "end_turn",
_meta: {},
}
}

async cancel(params: CancelNotification): Promise<void> {
this.log.info("cancel", { sessionId: params.sessionId })
}
}
Loading