Skip to content
Open
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
411 changes: 399 additions & 12 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ json5 = "0.4"
schemars = "1.0"
anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] }
agent-client-protocol = { version = "=2.0.0", features = ["unstable_session_fork"] }
ignore = "0.4"
copypasta = "0.10"
async-trait = "0.1"
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ It works (almost) exactly like OpenCode. Just opens faster, with some intuitive
- Multiworkspace by default, can run like 3+ sessions in the same instance, just works like a webapp.
- Ollama Local CLI connections works out-of-the-box.
- My own remote implementation. Probably worse.
- **ACP editor integration** - Run `crabcode acp` from compatible editors. See the [ACP capability matrix](_docs/acp.mdx).
- My own UX preferences:
- Can click on `[Image #1]` tags to open them.
- Themes has no background, all tranluscent (don't really care right now).
Expand Down Expand Up @@ -145,7 +146,7 @@ This project was inspired by [anomalyco/opencode](https://github.com/anomalyco/o
- [x] Copy chat contents, copy the chat input
- [x] Image inputs
- [x] Personal remote usage + Browser client equivalent.
- [ ] ACP w/ Zed? (very far, idk how to do that)
- [x] ACP integration for compatible editors; see the [capability matrix](_docs/acp.mdx).
- [x] No Claude Code oauth spoofing.
- [x] No plugin ecosystem (If I think it's worth building, just make it built-in and configurable i.e. sounds)
- [x] No desktop app
Expand Down
62 changes: 62 additions & 0 deletions _docs/acp.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
title: ACP Integration
description: Run Crabcode as an Agent Client Protocol server for compatible editors.
---

Run Crabcode from an ACP-compatible editor or IDE with a stdio subprocess. ACP keeps the workspace, credentials, tools, permissions, and session history on the machine running Crabcode.

## Start the server

```bash
crabcode acp
```

Use `--cwd` when the editor should start Crabcode for a specific workspace:

```bash
crabcode acp --cwd /absolute/path/to/project
```

ACP uses stdin and stdout for JSON-RPC traffic. Do not wrap the command with a program that writes banners or logs to stdout.

## Editor configuration

For Zed, add an agent server to `~/.config/zed/settings.json`:

```json
{
"agent_servers": {
"Crabcode": {
"command": "crabcode",
"args": ["acp"]
}
}
}
```

Configure a provider in the Crabcode TUI with `/connect` before starting an ACP session. Credentials remain in Crabcode's normal state directory; the editor does not receive API keys or OAuth tokens.

## Capability Matrix

| Area | Supported behavior | Current limitations | Planned follow-up |
| --- | --- | --- | --- |
| Transport | JSON-RPC over stdio through `crabcode acp`; clean stdin EOF shutdown. | stdout must remain protocol-only. | Add protocol-level subprocess integration coverage. |
| Sessions | Create, list, load, resume, close, and fork persisted root sessions. | Replay message IDs are deterministic per load, not durable message IDs. | Persist stable message IDs across streaming snapshots and reloads. |
| Prompts | Text and embedded text resources; assistant text and reasoning stream back to the editor. | Image and audio prompt blocks are rejected. | Store ACP attachments and convert supported image MIME types into Crabcode image inputs. |
| Modes | Visible primary Crabcode agents are available as ACP modes; changes are session-local. | Model and reasoning-effort selectors are not exposed yet. | Add validated provider/model and effort config options per session. |
| Tools | Tool calls and completed or failed results stream with ACP tool kinds, titles, raw input, and preview output. | No normalized locations, diffs, full outputs, or result images yet. | Preserve structured tool results, locations, patches, and image content in runtime events. |
| Permissions | Existing Crabcode permission prompts are forwarded with allow once, always allow, and reject choices. | Permission requests use a generated ACP call ID because the current internal prompt lacks the originating tool-call ID. | Carry the real tool-call ID and edit patch metadata through permission preflight. |
| Cancellation | `session/cancel` cancels the active turn and keeps the session reusable. | Provider stop reasons are currently reduced to normal completion, cancellation, or a safe failure. | Preserve output-limit and refusal stop reasons from the model runtime. |
| Commands and skills | Existing tools, project rules, configured agents, and normal prompt execution are reused. | ACP available-command updates and slash-command execution are not exposed yet. | Publish safe commands and skills, then handle `/compact` and custom commands. |
| MCP and terminals | Project-configured MCP tools continue to use Crabcode's normal runtime. | Client-supplied MCP servers and ACP terminal embedding are not supported. | Add per-session MCP overlays and a client-terminal adapter. |
| Usage | Session and model configuration remain visible to Crabcode. | ACP usage/cost updates are not emitted because provider token accounting is incomplete. | Retain provider input, output, cache, context, and cost data for usage updates. |

## Session behavior

`session/close` detaches the editor and cancels any active turn. It does not delete Crabcode session history. Load replays the stored transcript; resume restores the session configuration without replaying prior content. Fork creates a new persisted session with a copied transcript.

## Safety notes

Crabcode applies the same configured permission rules in ACP as it does in the TUI. When a tool needs approval, the editor receives an ACP permission request. If the editor cannot respond or disconnects, Crabcode denies the request rather than continuing unattended.

The capability matrix is intentionally conservative: Crabcode only advertises protocol features that the ACP server currently handles.
1 change: 1 addition & 0 deletions _docs/gittydocs.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"items": [
{ "label": "Overview", "path": "/" },
{ "label": "Quickstart", "path": "/quickstart" },
{ "label": "ACP Integration", "path": "/acp" },
{ "label": "Remote Usage", "path": "/remote-usage" },
],
},
Expand Down
1 change: 1 addition & 0 deletions _docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,6 @@ crabcode is a focused, terminal-only coding agent—just the TUI, built in Rust
## Where to next

- [Quickstart](/quickstart) – Get up and running in 5 minutes
- [ACP Integration](/acp) – Use crabcode from an ACP-compatible editor
- [Configuration](/config) – OpenCode-compatible config, notifications, and themes
- [GitHub](https://github.com/blankeos/crabcode) – Source code and issues
10 changes: 10 additions & 0 deletions _docs/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ crabcode reads up to four files (highest priority wins):

Set your defaults globally, override per-project when needed. See the [full configuration reference](/config).

## Use an editor through ACP

Start an ACP-compatible editor integration with:

```bash
crabcode acp
```

See [ACP Integration](/acp) for editor configuration and the current capability matrix.

---

## Common commands
Expand Down
3 changes: 2 additions & 1 deletion npm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ It works (almost) exactly like OpenCode. Just opens faster, with some intuitive
- Multiworkspace by default, can run like 3+ sessions in the same instance, just works like a webapp.
- Ollama Local CLI connections works out-of-the-box.
- My own remote implementation. Probably worse.
- **ACP editor integration** - Run `crabcode acp` from compatible editors. See the [ACP capability matrix](_docs/acp.mdx).
- My own UX preferences:
- Can click on `[Image #1]` tags to open them.
- Themes has no background, all tranluscent (don't really care right now).
Expand Down Expand Up @@ -145,7 +146,7 @@ This project was inspired by [anomalyco/opencode](https://github.com/anomalyco/o
- [x] Copy chat contents, copy the chat input
- [x] Image inputs
- [x] Personal remote usage + Browser client equivalent.
- [ ] ACP w/ Zed? (very far, idk how to do that)
- [x] ACP integration for compatible editors; see the [capability matrix](_docs/acp.mdx).
- [x] No Claude Code oauth spoofing.
- [x] No plugin ecosystem (If I think it's worth building, just make it built-in and configurable i.e. sounds)
- [x] No desktop app
Expand Down
15 changes: 15 additions & 0 deletions src/acp/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//! Agent Client Protocol support.
//!
//! ACP uses JSON-RPC over standard input/output. This module intentionally
//! avoids constructing the Ratatui application so editors can launch Crabcode
//! as a normal subprocess.

mod server;
mod service;

use anyhow::Result;
use std::path::PathBuf;

pub async fn run(cwd: Option<PathBuf>) -> Result<()> {
server::run(cwd).await
}
197 changes: 197 additions & 0 deletions src/acp/server.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
use agent_client_protocol::schema::v1::{
AgentCapabilities, CancelNotification, CloseSessionRequest, CloseSessionResponse,
ForkSessionRequest, ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse,
ListSessionsRequest, LoadSessionRequest, NewSessionRequest, PromptCapabilities, PromptRequest,
ResumeSessionRequest, SessionCapabilities, SessionCloseCapabilities, SessionForkCapabilities,
SessionListCapabilities, SessionResumeCapabilities, SetSessionConfigOptionRequest,
SetSessionModeRequest, SetSessionModeResponse,
};
use agent_client_protocol::{Agent, Stdio};
use anyhow::{Context, Result};
use std::path::PathBuf;

pub async fn run(cwd: Option<PathBuf>) -> Result<()> {
let workspace = match cwd {
Some(path) => path,
None => std::env::current_dir().context("failed to determine current directory")?,
};
let workspace = workspace
.canonicalize()
.with_context(|| format!("invalid ACP workspace: {}", workspace.display()))?;

// Validate workspace configuration before accepting editor requests. ACP
// protocol bytes are written by the SDK; diagnostics must never use stdout.
crate::config::ConfigLoader::load_for(&workspace).with_context(|| {
format!(
"failed to load Crabcode configuration for ACP workspace {}",
workspace.display()
)
})?;
let service = crate::acp::service::AcpService::new(&workspace)
.map_err(|_| anyhow::anyhow!("failed to initialize ACP session storage"))?;

Agent
.builder()
.name("crabcode-acp")
.on_receive_request(
async move |request: InitializeRequest, responder, _connection| {
let response = InitializeResponse::new(request.protocol_version)
.agent_capabilities(capabilities())
.agent_info(Implementation::new("crabcode", env!("CARGO_PKG_VERSION")));
responder.respond(response)
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let service = service.clone();
async move |request: ForkSessionRequest, responder, _connection| {
let result = service
.fork_session(request.session_id.to_string(), request.cwd)
.await
.map(|response| {
ForkSessionResponse::new(response.session_id)
.modes(response.modes)
.config_options(response.config_options)
});
responder.respond_with_result(result)
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let service = service.clone();
async move |request: SetSessionModeRequest, responder, _connection| {
let result = service
.set_mode(
&request.session_id.to_string(),
&request.mode_id.to_string(),
)
.await
.map(|_| SetSessionModeResponse::new());
responder.respond_with_result(result)
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let service = service.clone();
async move |request: SetSessionConfigOptionRequest, responder, _connection| {
let result = if request.config_id.to_string() != "mode" {
Err(agent_client_protocol::Error::invalid_params()
.data("unknown config option"))
} else if let Some(mode) = request.value.as_value_id() {
service
.set_mode(&request.session_id.to_string(), &mode.to_string())
.await
} else {
Err(agent_client_protocol::Error::invalid_params()
.data("mode must be a string"))
};
responder.respond_with_result(result)
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let service = service.clone();
async move |request: LoadSessionRequest, responder, connection| {
responder.respond_with_result(
service
.load_session(request.session_id.to_string(), request.cwd, connection)
.await,
)
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let service = service.clone();
async move |request: ResumeSessionRequest, responder, _connection| {
responder.respond_with_result(
service
.resume_session(request.session_id.to_string(), request.cwd)
.await,
)
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let service = service.clone();
async move |request: NewSessionRequest, responder, _connection| {
responder.respond_with_result(service.new_session(request.cwd).await)
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let service = service.clone();
async move |request: ListSessionsRequest, responder, _connection| {
responder.respond_with_result(service.list_sessions(request.cwd).await)
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let service = service.clone();
async move |request: CloseSessionRequest, responder, _connection| {
service.close_session(&request.session_id.to_string()).await;
responder.respond(CloseSessionResponse::new())
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let service = service.clone();
async move |request: PromptRequest, responder, connection| {
let session_id = request.session_id.to_string();
let service = service.clone();
let prompt_connection = connection.clone();
connection.spawn(async move {
let result = service
.prompt(session_id, request.prompt, prompt_connection)
.await;
responder.respond_with_result(result)
})
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_notification(
{
let service = service.clone();
async move |notification: CancelNotification, _connection| {
service
.cancel_session(&notification.session_id.to_string())
.await;
Ok(())
}
},
agent_client_protocol::on_receive_notification!(),
)
.connect_to(Stdio::new())
.await
.map_err(|error| anyhow::anyhow!("ACP stdio server failed: {error}"))
}

fn capabilities() -> AgentCapabilities {
AgentCapabilities::new()
.load_session(true)
.prompt_capabilities(PromptCapabilities::new().embedded_context(true))
.session_capabilities(
SessionCapabilities::new()
.list(SessionListCapabilities::new())
.resume(SessionResumeCapabilities::new())
.fork(SessionForkCapabilities::new())
.close(SessionCloseCapabilities::new()),
)
}
Loading