# zeph-acp
[](https://crates.io/crates/zeph-acp)
[](https://docs.rs/zeph-acp)
[](../../LICENSE)
[](https://www.rust-lang.org)
ACP (Agent Client Protocol) server adapter for embedding Zeph in IDE environments.
## Overview
Implements the [Agent Client Protocol](https://agentclientprotocol.org) server side, allowing IDEs and editors to drive the Zeph agent loop over stdio, HTTP+SSE, or WebSocket transports. The crate wires IDE-proxied capabilities — file system access, terminal execution, and permission gates — into the agent loop via `AcpContext`, exposes `AgentSpawner` as the integration point for the host application, and supports runtime model switching via `ProviderFactory` and MCP server management via `ext_method`. Built on the `agent-client-protocol` SDK v0.10.
## Installation
```toml
[dependencies]
zeph-acp = "0.18.3"
# With HTTP+SSE transport
zeph-acp = { version = "0.18.3", features = ["acp-http"] }
```
**Important:**
> Requires Rust 1.88 or later.
## Features
| Feature | Description | Default |
|---------|-------------|---------|
| `acp-http` | HTTP+SSE transport via axum (`AcpHttpState`, `acp_router`, `post_handler`, `get_handler`) | No |
**Tip:**
> Enable `acp-http` only when deploying Zeph as a network-accessible ACP endpoint. The default stdio transport is sufficient for local IDE integrations.
## Key modules
| Module | Description |
|--------|-------------|
| `agent` | `AcpContext` — IDE-proxied capabilities (file executor, shell executor, permission gate, cancel signal) wired into the agent loop per session; `SessionContext` — per-session identity carrying `session_id`, `conversation_id`, and `working_dir` so each ACP session maps to exactly one Zeph conversation in SQLite; `AgentSpawner` factory type; `ZephAcpAgent` ACP protocol handler with multi-session isolation (each session gets its own `ConversationId`), LRU eviction, idle reaper, SQLite persistence, rich content support (images, embedded resources, tool locations), runtime model switching via `ProviderFactory`, and MCP server management via `ext_method` |
| `transport` | `serve_stdio` / `serve_connection` (stdio), HTTP+SSE handlers (`post_handler`, `get_handler`), WebSocket handler (`ws_upgrade_handler`), duplex bridge, axum router; `AcpServerConfig` |
| `fs` | `AcpFileExecutor` — file system executor backed by IDE-proxied ACP file operations |
| `terminal` | `AcpShellExecutor` — shell executor backed by IDE-proxied ACP terminal; configurable command timeout with `kill_terminal` on expiry; deferred `terminal/release` ensures terminal remains alive until IDE receives `ToolCallContent::Terminal` |
| `permission` | `AcpPermissionGate` — forwards tool permission requests to the IDE for user approval; persists "always allow/deny" decisions to TOML file |
| `mcp_bridge` | `acp_mcp_servers_to_entries` — converts ACP-advertised MCP servers (Stdio, Http, Sse) into `McpServerEntry` configs |
| `error` | `AcpError` typed error enum (includes `ResourceLink` variant for resolution failures) |
**Re-exports:** `AcpContext`, `AgentSpawner`, `ProviderFactory`, `SessionContext`, `AcpError`, `AcpFileExecutor`, `AcpPermissionGate`, `AcpShellExecutor`, `AcpServerConfig`, `serve_connection`, `serve_stdio`, `acp_mcp_servers_to_entries`
**Re-exports (feature `acp-http`):** `SendAgentSpawner`, `AcpHttpState`, `acp_router`
## AcpContext
`AcpContext` carries per-session IDE capabilities into the agent loop. Each field is `None` when the IDE did not advertise the corresponding capability:
```rust
pub struct AcpContext {
pub file_executor: Option<AcpFileExecutor>,
pub shell_executor: Option<AcpShellExecutor>,
pub permission_gate: Option<AcpPermissionGate>,
/// Notify to interrupt the running agent operation.
pub cancel_signal: Arc<Notify>,
}
```
The `cancel_signal` is shared with the agent's `LoopbackHandle` so that an IDE cancel request immediately interrupts the running inference loop.
## SessionContext
`SessionContext` carries per-session identity into the agent spawner, ensuring each ACP session maps to exactly one Zeph conversation:
```rust
pub struct SessionContext {
pub session_id: acp::SessionId,
pub conversation_id: ConversationId,
pub working_dir: PathBuf,
}
```
The `conversation_id` is pre-created in SQLite before the agent loop starts. This guarantees that the session-to-conversation mapping is persisted atomically, enabling:
- **Session isolation** — concurrent sessions maintain independent conversation histories.
- **Session fork** — `fork_session` copies the source conversation's messages into a new conversation via `copy_conversation`.
- **Session resume** — `load_session` and `resume_session` look up the existing `conversation_id` from the `acp_sessions` table; legacy sessions (pre-migration 026) get a new conversation created on demand.
**Note:**
> When the SQLite store is unavailable, `ConversationId(0)` is used as a sentinel. The agent's `SemanticMemory` will create its own conversation if needed.
## ResourceLink resolution
`ZephAcpAgent` resolves `ResourceLink` content blocks in prompts, as required by the ACP spec. Two URI schemes are supported:
| Scheme | Behavior | Security |
|--------|----------|----------|
| `file://` | Read local file via `tokio::fs` | Canonicalized path must be inside session `cwd`; blocked path components (`/proc`, `.ssh`, `.gnupg`, etc.); size cap (1 MiB); binary rejection; 10 s timeout |
| `http://` / `https://` | Fetch via `reqwest` with `Accept: text/*` | No-redirect policy; post-fetch SSRF check (private IP + CGNAT rejection, fail-closed on missing `remote_addr`); response size cap (1 MiB); 10 s timeout |
Unsupported schemes produce a warning and the block is skipped. Resolution failures are non-fatal.
## StopReason mapping
The agent loop emits `StopHint` values that `ZephAcpAgent` maps to protocol-level `StopReason` variants:
| Agent condition | StopHint | ACP StopReason |
|----------------|----------|----------------|
| Normal completion | _(none)_ | `EndTurn` |
| LLM response truncated by token limit | `MaxTokens` | `MaxTokens` |
| Turn count >= `max_turns` | `MaxTurnRequests` | `MaxTurnRequests` |
| IDE cancel request | _(cancel signal)_ | `Cancelled` |
**Note:**
> `StopHint` is defined in `zeph-core::channel` and carried via `LoopbackEvent::Stop`. The ACP layer consumes it in `prompt()` to produce the final `StopReason`.
## Tool call lifecycle
`ZephAcpAgent` emits ACP session notifications following the protocol-specified two-step lifecycle:
1. **Before execution** — `SessionUpdate::ToolCall` with `status: InProgress` is sent as soon as tool invocation begins, enabling the IDE to display a running indicator.
2. **After execution** — `SessionUpdate::ToolCallUpdate` with `status: Completed` (or `Failed` on error) carries the output content and optional file locations.
Each tool call is identified by a UUID generated per invocation. The UUID is threaded through `LoopbackEvent::ToolStart` / `LoopbackEvent::ToolOutput` so the update correctly references the original announcement. Both the fenced-block execution path (`handle_tool_result`) and the structured parallel tool-call path emit this full two-step sequence unconditionally — output content always appears inside a tool call block in the IDE regardless of which path handled the tool.
**Terminal release ordering** — when a shell tool call embeds a terminal via `ToolCallContent::Terminal`, the ACP spec requires the terminal to remain alive until the IDE has processed the `tool_call_update` notification. `ZephAcpAgent` defers `terminal/release` until after all notifications for that event are dispatched. The deferred release is triggered from the `prompt()` event loop via `AcpShellExecutor::release_terminal()`, which is retained in `SessionEntry` for exactly this purpose.
**Note:**
> Prior to #1003 the fenced-block path did not generate a UUID or emit `ToolStart`. Prior to #1013 the terminal was released inside `execute_in_terminal` before `tool_call_update` was sent, preventing IDEs from displaying terminal output. Both issues are now resolved.
## Subagent IDE visibility
Three `_meta` extensions give IDEs (Zed, VS Code ACP) structured visibility into sub-agent execution:
### Parent tool use ID propagation (#1008)
Every `session_update` emitted by a sub-agent carries `_meta.claudeCode.parentToolUseId` set to the tool call ID that spawned it. IDEs use this to nest sub-agent output under the originating tool card, so multi-agent runs render as collapsible trees rather than interleaved flat messages.
### Terminal streaming (#1009)
`AcpShellExecutor` streams shell output in real time instead of buffering until completion:
- Each chunk of stdout/stderr is emitted as a `session_update` with `_meta.terminal_output`.
- When the command exits, a final `session_update` with `_meta.terminal_exit` carries the exit code.
- IDEs display the output inside the tool card as it arrives.
### Tool call location (#1010)
`ToolCall.location` carries a `filePath` for file read/write tool calls. IDEs move the editor cursor to the referenced file as the agent works, so the user always sees which file is being modified without manually switching tabs.
**Tip:**
> All three extensions are additive `_meta` fields — they are ignored by clients that do not recognize them and require no feature flag.
### Terminal command timeout
`AcpShellExecutor` enforces a configurable wall-clock timeout on every IDE-proxied shell command (default: 120 seconds, controlled via `acp.terminal_timeout_secs`). When the timeout expires:
1. `kill_terminal` is called to terminate the running process.
2. Partial output collected so far is returned as an error result.
3. The terminal is released and `AcpError::TerminalTimeout` is propagated to the agent loop.
```toml
[acp]
terminal_timeout_secs = 120 # set to 0 to disable (wait indefinitely)
```
## Protocol methods
### AgentCapabilities (G3)
The `initialize` response advertises enriched capabilities:
```rust
acp::AgentCapabilities::new()
.load_session(true)
.mcp_capabilities(acp::McpCapabilities::new().http(true).sse(true)) // when McpManager is available
.meta({
cap_meta.insert("config_options", json!(true));
cap_meta.insert("ext_methods", json!(true));
cap_meta
})
```
This signals to the IDE that the agent supports session config options (`session/configure`), custom `ext_method` extensions, and MCP server management. `McpCapabilities` is only advertised when a `McpManager` is configured.
### set_session_mode (G2)
`ZephAcpAgent` implements `set_session_mode` to handle IDE-driven mode switches per session:
- Validates that the target session exists; returns `invalid_request` error if not found.
- Logs the `session_id` and `mode_id` at debug level.
- Currently a no-op acknowledgement — mode semantics are handled by the IDE.
### ext_notification (G4)
`ZephAcpAgent` implements `ext_notification` to accept IDE-originated fire-and-forget notifications:
- Logs the notification method name at debug level.
- Returns `Ok(())` for all known and unknown methods — unrecognized notifications are silently accepted.
## MCP transport support (G8)
`acp_mcp_servers_to_entries` converts ACP-advertised MCP servers into `zeph-mcp` `ServerEntry` configs. Three transport types are supported:
| ACP variant | Mapped transport | Notes |
|-------------|-----------------|-------|
| `McpServer::Stdio` | `McpTransport::Stdio` | Env vars forwarded as-is to child process |
| `McpServer::Http` | `McpTransport::Http` | Streamable HTTP via rmcp |
| `McpServer::Sse` | `McpTransport::Http` | Legacy SSE mapped to streamable HTTP (backward-compatible) |
**Note:**
> SSE is a legacy MCP transport. rmcp's `StreamableHttpClientTransport` handles both SSE and streamable HTTP endpoints, so both variants map to `McpTransport::Http`.
```rust
use zeph_acp::acp_mcp_servers_to_entries;
let entries = acp_mcp_servers_to_entries(&initialize_request.mcp_servers);
// entries: Vec<ServerEntry> ready for McpManager::start_all
```
## HTTP+SSE transport (feature `acp-http`)
Enable the `acp-http` feature to expose Zeph over HTTP with Server-Sent Events:
```rust
use zeph_acp::{AcpHttpState, AcpServerConfig, acp_router};
let state = AcpHttpState::new(spawner, AcpServerConfig::default());
state.start_reaper(); // prune idle connections every 60 s
let app = acp_router(state);
// mount app into your axum Router
```
Endpoints:
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/acp` | Send a JSON-RPC request; stream responses as SSE. Creates a new connection when `Acp-Session-Id` header is absent. |
| `GET` | `/acp` | Reconnect to an existing connection's SSE stream. Requires `Acp-Session-Id` header. |
| `GET` | `/acp/ws` | WebSocket upgrade for bidirectional streaming. |
Session IDs are UUIDs returned in the `Acp-Session-Id` response header. Idle connections (beyond `session_idle_timeout_secs`) are reaped by a background task.
**Tip:**
> Use `SendAgentSpawner` (the `Send`-safe variant of `AgentSpawner`) when constructing `AcpHttpState`. This satisfies axum's `State` requirement for `Send + Sync`.
## Rich content
ACP prompts can carry multi-modal content blocks beyond plain text:
- **Images** — base64-encoded image blocks (`image/jpeg`, `image/png`, `image/gif`, `image/webp`) are decoded and forwarded to the LLM provider as inline attachments. Oversized payloads and unsupported MIME types are skipped with a warning.
- **Embedded resources** — `TextResourceContents` blocks are injected into the prompt text wrapped in `<resource>` markers.
- **Tool locations** — tool call results can include file path locations (`ToolCallLocation`) that the IDE uses for source navigation.
- **Thinking chunks** — intermediate reasoning status events are streamed back to the IDE as `session/update` events.
## Model switching
The IDE can switch the active LLM model at runtime via `session/configure` with `config_id = "model"`. `ZephAcpAgent` uses a `ProviderFactory` closure that resolves a `"provider:model"` key to an `AnyProvider`, and an `available_models` allowlist that populates the IDE dropdown. The resolved provider is stored in a shared `Arc<RwLock<Option<AnyProvider>>>` (`provider_override`) that the agent loop checks on each turn.
## ConfigOptionUpdate notifications
When a config option changes via `set_session_config_option`, `ZephAcpAgent` emits a `SessionUpdate::ConfigOptionUpdate` notification containing only the changed option. This keeps the IDE config UI in sync without requiring a full config poll.
## Config option categories
Config options include `SessionConfigOptionCategory` for IDE grouping:
| Config option | Category |
|---------------|----------|
| `model` | `Model` |
| `thinking` | `Model` |
| `auto_approve` | `Other` |
## MCP server management
`ext_method` handles custom JSON-RPC extensions for managing MCP servers at runtime:
| Method | Description |
|--------|-------------|
| `_agent/mcp/list` | List active MCP servers |
| `_agent/mcp/add` | Register a new MCP server |
| `_agent/mcp/remove` | Remove a running MCP server |
Requires a shared `McpManager` reference set via `AcpServerConfig::mcp_manager`.
## Session lifecycle
`ZephAcpAgent` manages multiple concurrent sessions with the following capabilities:
- **Per-session conversation isolation** — each session is assigned its own `ConversationId` at creation time (migration 026). Concurrent sessions maintain fully independent conversation histories in SQLite, preventing cross-session message leakage.
- **LRU eviction** — when the number of active sessions reaches `max_sessions`, the least-recently-used session is evicted to free resources.
- **SQLite persistence** — session events are persisted to `acp_sessions` and `acp_session_events` tables (migrations 013, 026) via `zeph-memory`. The `acp_sessions` table carries a `conversation_id` foreign key linking each session to its conversation.
- **Session resume** — `load_session` replays persisted history as `session/update` events, restoring the conversation state. The existing `conversation_id` is looked up from the store; legacy sessions (pre-026) get a new conversation created on demand.
- **Session fork** — `fork_session` creates a new conversation and copies the source session's messages via `copy_conversation`, preserving history while isolating future turns.
- **Idle reaper** — a background task periodically removes sessions that have been idle longer than `session_idle_timeout_secs`.
### Configuration
| Config field | Type | Default | Env override |
|-------------|------|---------|--------------|
| `acp.max_sessions` | usize | `16` | `ZEPH_ACP_MAX_SESSIONS` |
| `acp.session_idle_timeout_secs` | u64 | `1800` | `ZEPH_ACP_SESSION_IDLE_TIMEOUT_SECS` |
| `acp.permission_file` | PathBuf | `~/.config/zeph/acp-permissions.toml` | `ZEPH_ACP_PERMISSION_FILE` |
| `acp.terminal_timeout_secs` | u64 | `120` | `ZEPH_ACP_TERMINAL_TIMEOUT_SECS` |
| `acp.available_models` | `Vec<String>` | `[]` | — |
## Permission persistence
When the IDE user selects "always allow" or "always deny" for a tool, `AcpPermissionGate` persists the decision to a TOML file (`~/.config/zeph/acp-permissions.toml` by default). On next session startup the gate pre-populates its cache from this file, skipping redundant IDE prompts.
- Atomic write via temp file + rename to prevent corruption.
- File permissions set to `0o600` (owner-only).
- Graceful fallback: if the file is missing or malformed, the gate starts with an empty cache.
## AgentSpawner
`AgentSpawner` is the integration contract between `zeph-acp` and the host application:
```rust
pub type AgentSpawner = Arc<
dyn Fn(LoopbackChannel, Option<AcpContext>, SessionContext) -> Pin<Box<dyn Future<Output = ()> + 'static>>
+ 'static,
>;
```
The host constructs an `AgentSpawner` closure that wires `AcpContext` capabilities and `SessionContext` (session identity + pre-created `ConversationId`) into `Agent` via the builder, then passes the closure to `serve_stdio` or `serve_connection`.
For HTTP transport, use `SendAgentSpawner` which requires `Send + Sync`:
```rust
pub type SendAgentSpawner = Arc<
dyn Fn(LoopbackChannel, Option<AcpContext>, SessionContext) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>>
+ Send + Sync + 'static,
>;
```
## Custom methods
`ZephAcpAgent` exposes vendor-specific extensions via `ExtRequest` dispatch. The `custom` module matches on `req.method` and routes to the appropriate handler. Unrecognized methods return `None`, allowing the ACP runtime to respond with "method not found".
| Method | Description |
|--------|-------------|
| `_session/list` | List all sessions (in-memory + persisted via `SqliteStore::list_acp_sessions`) |
| `_session/get` | Get session details and event history |
| `_session/delete` | Remove session from memory and SQLite |
| `_session/export` | Export session events as a portable JSON payload |
| `_session/import` | Import events into a new session (UUID assigned server-side) |
| `_agent/tools` | Return the list of tools available to the agent |
| `_agent/working_dir/update` | Change the working directory for a session |
### Security guards
- **Session ID validation** — IDs must be at most 128 characters, restricted to `[a-zA-Z0-9_-]`. Rejects control characters, slashes, and whitespace.
- **Path traversal protection** — `_agent/working_dir/update` rejects any path containing `..` (`Component::ParentDir`).
- **Import size cap** — `_session/import` rejects payloads exceeding 10,000 events.
### Auth hints in `initialize`
The `initialize` response includes an `auth_hint` key in its metadata map. For stdio transport (trusted local client) this is a generic `"authentication required"` string. IDEs can use this hint to prompt the user for credentials before issuing further requests.
## Feature flags
| Feature | Status | Description |
|---------|--------|-------------|
| `acp-http` | stable | Enables the HTTP+SSE and WebSocket transports (axum-based). Required for `post_handler`, `get_handler`, `ws_upgrade_handler`, and `router`. |
| `unstable-session-fork` | unstable | Enables the `fork_session` ACP method. See below. |
| `unstable-session-resume` | unstable | Enables the `resume_session` ACP method. See below. |
| `unstable-session-usage` | unstable | Enables `UsageUpdate` events — token counts (input, output, cache) sent to the IDE after each turn. See below. |
| `unstable-session-model` | unstable | Enables `SetSessionModel` — IDE-driven model switching via a native picker without `session/configure`. See below. |
| `unstable-session-info-update` | unstable | Enables `SessionInfoUpdate` — agent-generated session title emitted to the IDE after the first turn. See below. |
| `unstable-elicitation` | unstable | Exposes elicitation schema types (`ElicitationRequest`, etc.) for future agent-loop integration. SDK methods not yet available in 0.10.3. |
| `unstable-logout` | unstable | Enables the `logout` ACP method and advertises `auth.logout` capability. Zeph logout is a no-op (vault-based auth). |
**Warning:**
> All `unstable-*` features have wire protocol that is not yet finalized. Expect breaking changes before these features graduate to stable.
To opt in, add the desired features in your `Cargo.toml`:
```toml
[dependencies]
zeph-acp = { version = "*", features = [
"unstable-session-fork",
"unstable-session-resume",
"unstable-session-usage",
"unstable-session-model",
"unstable-session-info-update",
"unstable-elicitation",
"unstable-logout",
] }
```
All flags are independent and can be combined freely.
### `session/list` (stable)
The `list_sessions` method on `ZephAcpAgent` is stable as of `agent-client-protocol` 0.10.3 — no feature flag required. Returns a snapshot of all active in-memory sessions as `SessionInfo` records (session ID, working directory, last-updated timestamp). Supports an optional `cwd` filter — when provided, only sessions whose working directory matches the given path are returned.
The `initialize` response always advertises `SessionListCapabilities` in the `session` capabilities block, signalling to the IDE that the server supports session enumeration.
### `unstable-session-fork`
Enables the `fork_session` method. Branches an existing conversation into a new session by:
1. Verifying the source session exists in memory or SQLite.
2. Assigning a fresh UUID as the new session ID.
3. Asynchronously copying all persisted events from the source into the new session via `import_acp_events`.
4. Spawning a new agent loop for the forked session with the supplied `cwd`.
The forked session is immediately available for new turns. The event copy is fire-and-forget — if the store write fails, a warning is logged but the session is still created. Model config options are forwarded to the fork response when `available_models` is non-empty.
### `unstable-session-resume`
Enables the `resume_session` method. Restores a persisted session to an active in-memory state without replaying history as `session/update` events:
- If the session is already active in memory, returns success immediately (no-op).
- Otherwise, verifies existence in SQLite and hydrates a new `SessionEntry`, making the session available for new turns with lower latency than the default `load_session` replay path.
- Requires a configured `SqliteStore`; returns an error if no store is present.
### `unstable-session-usage`
Enables `UsageUpdate` session events. After each agent turn `ZephAcpAgent` emits a `SessionUpdate::UsageUpdate` carrying token counts for the turn:
- `input_tokens` — tokens consumed from the prompt.
- `output_tokens` — tokens produced by the model.
- `cache_read_tokens` / `cache_write_tokens` — cache activity when the provider supports prompt caching.
The IDE can use this data to display running cost estimates or token budgets without polling a separate endpoint.
### `unstable-session-model`
Enables `SetSessionModel` handling. When the IDE sends a `set_session_model` request (e.g., from a native model-picker dropdown), `ZephAcpAgent`:
1. Resolves the requested `"provider:model"` key via `ProviderFactory`.
2. Stores the resolved provider in the session-scoped `provider_override`.
3. Returns a confirmation to the IDE so the picker reflects the active selection.
This avoids the need to wrap model selection in a `session/configure` call and maps directly to the Zed AI model picker interaction.
### `unstable-session-info-update`
Enables `SessionInfoUpdate` events. After the first completed turn in a new session, `ZephAcpAgent` emits a `SessionUpdate::SessionInfoUpdate` containing a short, LLM-generated title derived from the opening message. The IDE can use this title to label the session in its sidebar or tab bar.
## Plan updates
During orchestrator runs `ZephAcpAgent` emits `SessionUpdate::Plan` events as the agent formulates its execution plan. The IDE receives these events in real time and can render a collapsible plan view alongside the conversation, giving users visibility into multi-step reasoning before tool calls begin.
## Slash command dispatch
`ZephAcpAgent` sends an `AvailableCommandsUpdate` to the IDE during session initialization listing the built-in slash commands:
| Command | Description |
|---------|-------------|
| `/help` | Show available slash commands |
| `/model` | Switch the active model |
| `/mode` | Change the session mode (`ask` / `code` / `architect`) |
| `/clear` | Clear the current conversation history |
| `/compact` | Trigger a manual context compaction |
User input that begins with `/` is matched against this list and dispatched to the corresponding handler before the message reaches the agent loop.
## Documentation
Full documentation: <https://bug-ops.github.io/zeph/>
## License
MIT