diff --git a/Cargo.lock b/Cargo.lock index f525a7f..669bc43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,56 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "agent-client-protocol" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d87bc7769eba641753ba5dc52f73ec3765d51022c6753bf040967125ddc86a8" +dependencies = [ + "agent-client-protocol-derive", + "agent-client-protocol-schema", + "async-io", + "async-process", + "blocking", + "futures", + "futures-concurrency", + "rustc-hash 2.1.3", + "rustix 1.1.4", + "schemars 1.2.2", + "serde", + "serde_json", + "shell-words", + "tracing", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3abd4080f51e4f24f5042beb7fb7a66ede29a2dc1c2582c329532e1c27264ddc" +dependencies = [ + "quote", + "syn 3.0.3", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5c231915b4ab578c722eca2d1bd7df4d300bfd6cac3b8e9f0d1e3ddc95b187c" +dependencies = [ + "anyhow", + "derive_more", + "schemars 1.2.2", + "serde", + "serde_json", + "serde_with", + "strum 0.28.0", + "tracing", +] + [[package]] name = "ahash" version = "0.8.12" @@ -41,7 +91,7 @@ dependencies = [ "eventsource-stream", "futures", "reqwest 0.12.28", - "schemars", + "schemars 1.2.2", "serde", "serde_json", "thiserror 1.0.69", @@ -160,6 +210,89 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.91" @@ -252,6 +385,28 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.13.0" @@ -490,6 +645,15 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "copypasta" version = "0.10.2" @@ -552,6 +716,7 @@ dependencies = [ name = "crabcode" version = "0.0.9" dependencies = [ + "agent-client-protocol", "anyhow", "arboard", "async-trait", @@ -585,7 +750,7 @@ dependencies = [ "reqwest 0.12.28", "rmcp", "rusqlite", - "schemars", + "schemars 1.2.2", "serde", "serde_json", "serde_yaml", @@ -801,6 +966,9 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] [[package]] name = "derive_builder" @@ -833,6 +1001,29 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + [[package]] name = "diff" version = "0.1.13" @@ -893,13 +1084,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -960,6 +1151,26 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "eventsource-stream" version = "0.2.3" @@ -1032,6 +1243,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1118,6 +1335,19 @@ dependencies = [ "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.33" @@ -1141,6 +1371,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.33" @@ -1296,7 +1539,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -1314,6 +1557,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1377,6 +1626,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.4.2" @@ -1680,6 +1935,17 @@ dependencies = [ "quick-error", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1688,6 +1954,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -2421,6 +2689,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2498,12 +2772,43 @@ dependencies = [ "pest", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2517,7 +2822,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64", - "indexmap", + "indexmap 2.14.0", "quick-xml", "serde", "time", @@ -2630,7 +2935,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" dependencies = [ "futures", - "indexmap", + "indexmap 2.14.0", "nix 0.31.3", "tokio", "tracing", @@ -3033,6 +3338,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3131,6 +3442,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "1.2.2" @@ -3244,7 +3567,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -3264,13 +3587,45 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -3711,7 +4066,7 @@ dependencies = [ "fancy-regex", "lazy_static", "regex", - "rustc-hash", + "rustc-hash 1.1.0", ] [[package]] @@ -3754,6 +4109,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.53.1" @@ -3867,7 +4237,7 @@ version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime", "toml_parser", "winnow", @@ -4092,6 +4462,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -4134,6 +4510,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index 8946f4f..ac76a83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index ac85978..d519ba4 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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 diff --git a/_docs/acp.mdx b/_docs/acp.mdx new file mode 100644 index 0000000..7720c94 --- /dev/null +++ b/_docs/acp.mdx @@ -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. diff --git a/_docs/gittydocs.jsonc b/_docs/gittydocs.jsonc index 6b9e959..1be5569 100644 --- a/_docs/gittydocs.jsonc +++ b/_docs/gittydocs.jsonc @@ -19,6 +19,7 @@ "items": [ { "label": "Overview", "path": "/" }, { "label": "Quickstart", "path": "/quickstart" }, + { "label": "ACP Integration", "path": "/acp" }, { "label": "Remote Usage", "path": "/remote-usage" }, ], }, diff --git a/_docs/index.mdx b/_docs/index.mdx index ddce89f..3a53980 100644 --- a/_docs/index.mdx +++ b/_docs/index.mdx @@ -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 diff --git a/_docs/quickstart.mdx b/_docs/quickstart.mdx index 98c1bb5..91ac641 100644 --- a/_docs/quickstart.mdx +++ b/_docs/quickstart.mdx @@ -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 diff --git a/npm/README.md b/npm/README.md index ac85978..d519ba4 100644 --- a/npm/README.md +++ b/npm/README.md @@ -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). @@ -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 diff --git a/src/acp/mod.rs b/src/acp/mod.rs new file mode 100644 index 0000000..7e3387b --- /dev/null +++ b/src/acp/mod.rs @@ -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) -> Result<()> { + server::run(cwd).await +} diff --git a/src/acp/server.rs b/src/acp/server.rs new file mode 100644 index 0000000..f39272c --- /dev/null +++ b/src/acp/server.rs @@ -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) -> 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(¬ification.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()), + ) +} diff --git a/src/acp/service.rs b/src/acp/service.rs new file mode 100644 index 0000000..799bd6f --- /dev/null +++ b/src/acp/service.rs @@ -0,0 +1,933 @@ +use crate::config::configuration::LoadedConfig; +use crate::session::manager::SessionManager; +use agent_client_protocol::schema::v1::{ + ContentBlock, ContentChunk, EmbeddedResourceResource, ListSessionsResponse, + LoadSessionResponse, NewSessionResponse, PermissionOption, PermissionOptionKind, + PromptResponse, RequestPermissionOutcome, RequestPermissionRequest, ResumeSessionResponse, + SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption, SessionInfo, + SessionMode, SessionModeState, SessionNotification, SessionUpdate, + SetSessionConfigOptionResponse, StopReason, ToolCall, ToolCallContent, ToolCallStatus, + ToolCallUpdate, ToolCallUpdateFields, ToolKind, +}; +use agent_client_protocol::{Client, ConnectionTo, Error}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use tokio::sync::Mutex as AsyncMutex; +use tokio_util::sync::CancellationToken; + +#[derive(Clone)] +pub struct AcpService { + sessions: Arc>>, + session_manager: Arc>, +} + +#[derive(Clone)] +struct AcpSession { + cwd: PathBuf, + config: LoadedConfig, + provider: String, + model: String, + agent: String, + reasoning: Option, + cancellation: Option, +} + +impl AcpService { + pub fn new(initial_workspace: &Path) -> Result { + let session_manager = SessionManager::new() + .with_history_for_workspace(initial_workspace) + .map_err(|_| internal_error())?; + Ok(Self { + sessions: Arc::new(AsyncMutex::new(HashMap::new())), + session_manager: Arc::new(Mutex::new(session_manager)), + }) + } + + pub async fn new_session(&self, cwd: PathBuf) -> Result { + let cwd = workspace_path(&cwd)?; + let config = crate::config::ConfigLoader::load_for(&cwd).map_err(|_| internal_error())?; + crate::skill::init_skill_store(&config.xdg_config_home, &config.project_root); + let (provider, model) = resolve_model(&config); + let agent = config + .merged_config + .default_agent + .clone() + .unwrap_or_else(|| { + config + .merged_config + .agent_registry + .default_agent() + .to_string() + }); + + let session_id = { + let mut manager = self.session_manager.lock().map_err(|_| internal_error())?; + manager + .switch_current_workspace_path(&cwd.to_string_lossy()) + .map_err(|_| internal_error())?; + manager.create_session(Some("New session".to_string())) + }; + + self.sessions.lock().await.insert( + session_id.clone(), + AcpSession { + cwd, + config, + provider, + model, + agent, + reasoning: None, + cancellation: None, + }, + ); + + let session = self + .sessions + .lock() + .await + .get(&session_id) + .cloned() + .ok_or_else(internal_error)?; + Ok(NewSessionResponse::new(session_id) + .modes(session_modes(&session)) + .config_options(session_config_options(&session))) + } + + pub async fn list_sessions(&self, cwd: Option) -> Result { + let cwd = cwd.as_deref().map(workspace_path).transpose()?; + let manager = self.session_manager.lock().map_err(|_| internal_error())?; + let mut sessions = manager + .list_sessions() + .into_iter() + .filter(|session| session.parent_id.is_none() && session.archived_at.is_none()) + .filter(|session| { + cwd.as_ref() + .is_none_or(|cwd| session.workspace_path == cwd.to_string_lossy()) + }) + .collect::>(); + sessions.sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); + + Ok(ListSessionsResponse::new( + sessions + .into_iter() + .take(100) + .map(|session| { + SessionInfo::new(session.id, session.workspace_path) + .title(session.title) + .updated_at(system_time_to_iso8601(session.updated_at)) + }) + .collect(), + )) + } + + pub async fn load_session( + &self, + session_id: String, + cwd: PathBuf, + connection: ConnectionTo, + ) -> Result { + let (_session, messages) = self.attach_persisted_session(&session_id, cwd).await?; + replay_messages(&connection, &session_id, &messages)?; + Ok(LoadSessionResponse::new() + .modes(session_modes(&_session)) + .config_options(session_config_options(&_session))) + } + + pub async fn resume_session( + &self, + session_id: String, + cwd: PathBuf, + ) -> Result { + let (session, _) = self.attach_persisted_session(&session_id, cwd).await?; + Ok(ResumeSessionResponse::new() + .modes(session_modes(&session)) + .config_options(session_config_options(&session))) + } + + pub async fn fork_session( + &self, + session_id: String, + cwd: PathBuf, + ) -> Result { + let (source, messages) = self.attach_persisted_session(&session_id, cwd).await?; + let fork_id = { + let mut manager = self.session_manager.lock().map_err(|_| internal_error())?; + manager + .switch_current_workspace_path(&source.cwd.to_string_lossy()) + .map_err(|_| internal_error())?; + let fork_id = manager.create_session(Some(format!("{} (fork)", session_id))); + manager + .replace_session_messages(&fork_id, messages) + .map_err(|_| internal_error())?; + fork_id + }; + self.sessions + .lock() + .await + .insert(fork_id.clone(), source.clone()); + Ok(NewSessionResponse::new(fork_id) + .modes(session_modes(&source)) + .config_options(session_config_options(&source))) + } + + pub async fn close_session(&self, session_id: &str) { + if let Some(session) = self.sessions.lock().await.remove(session_id) { + if let Some(cancellation) = session.cancellation { + cancellation.cancel(); + } + } + } + + pub async fn cancel_session(&self, session_id: &str) { + if let Some(session) = self.sessions.lock().await.get(session_id) { + if let Some(cancellation) = &session.cancellation { + cancellation.cancel(); + } + } + } + + pub async fn set_mode( + &self, + session_id: &str, + mode_id: &str, + ) -> Result { + let mut sessions = self.sessions.lock().await; + let session = sessions + .get_mut(session_id) + .ok_or_else(|| Error::invalid_params().data("unknown session"))?; + if session + .config + .merged_config + .agent_registry + .primary_agent(mode_id) + .is_none_or(|agent| agent.hidden) + { + return Err(Error::invalid_params().data("unknown ACP mode")); + } + session.agent = mode_id.to_string(); + Ok(SetSessionConfigOptionResponse::new(session_config_options( + session, + ))) + } + + async fn attach_persisted_session( + &self, + session_id: &str, + cwd: PathBuf, + ) -> Result<(AcpSession, Vec), Error> { + let cwd = workspace_path(&cwd)?; + let config = crate::config::ConfigLoader::load_for(&cwd).map_err(|_| internal_error())?; + crate::skill::init_skill_store(&config.xdg_config_home, &config.project_root); + + let messages = { + let mut manager = self.session_manager.lock().map_err(|_| internal_error())?; + let stored = manager + .get_session(session_id) + .ok_or_else(|| Error::invalid_params().data("unknown session"))?; + if stored.workspace_path != cwd.to_string_lossy() { + return Err(Error::invalid_params().data("session does not belong to cwd")); + } + stored.messages.clone() + }; + + let (provider, model) = messages + .iter() + .rev() + .find_map(|message| match (&message.provider, &message.model) { + (Some(provider), Some(model)) => Some((provider.clone(), model.clone())), + _ => None, + }) + .unwrap_or_else(|| resolve_model(&config)); + let agent = messages + .iter() + .rev() + .find_map(|message| message.agent_mode.clone()) + .unwrap_or_else(|| { + config + .merged_config + .default_agent + .clone() + .unwrap_or_else(|| { + config + .merged_config + .agent_registry + .default_agent() + .to_string() + }) + }); + let session = AcpSession { + cwd, + config, + provider, + model, + agent, + reasoning: None, + cancellation: None, + }; + self.sessions + .lock() + .await + .insert(session_id.to_string(), session.clone()); + Ok((session, messages)) + } + + pub async fn prompt( + &self, + session_id: String, + prompt: Vec, + connection: ConnectionTo, + ) -> Result { + let prompt = prompt_text(prompt)?; + if prompt.trim().is_empty() { + return Err(Error::invalid_params().data("prompt must include text content")); + } + + let session = self + .sessions + .lock() + .await + .get(&session_id) + .cloned() + .ok_or_else(|| Error::invalid_params().data("unknown session"))?; + let cancellation = CancellationToken::new(); + { + let mut sessions = self.sessions.lock().await; + let Some(current) = sessions.get_mut(&session_id) else { + return Err(Error::invalid_params().data("unknown session")); + }; + if current.cancellation.is_some() { + return Err(Error::invalid_params().data("session already has an active prompt")); + } + current.cancellation = Some(cancellation.clone()); + } + + let mut messages = { + let mut manager = self.session_manager.lock().map_err(|_| internal_error())?; + let stored = manager + .get_session(&session_id) + .ok_or_else(|| Error::invalid_params().data("unknown session"))?; + stored.messages.clone() + }; + let mut user_message = crate::session::types::Message::user(&prompt); + user_message.provider = Some(session.provider.clone()); + user_message.model = Some(session.model.clone()); + user_message.agent_mode = Some(session.agent.clone()); + { + let mut manager = self.session_manager.lock().map_err(|_| internal_error())?; + manager + .add_message_to_session(&session_id, &user_message) + .map_err(|_| internal_error())?; + manager + .set_session_status( + &session_id, + crate::session::types::SessionStatus::Streaming, + None, + ) + .map_err(|_| internal_error())?; + } + messages.push(user_message); + + let prompt_registry = crate::tools::initialize_tool_registry_with_dynamic_config( + None, + tool_permissions(&session), + session.config.merged_config.agent_registry.clone(), + cancellation.clone(), + Some(&session.provider), + &session.config.merged_config.websearch, + &session.config.merged_config.mcp, + &session.cwd, + ) + .await; + let is_git_repo = + crate::utils::git::is_git_repo(&session.cwd.to_string_lossy()).unwrap_or(false); + let system_prompt = crate::prompt::SystemPromptComposer::new( + &session.model, + session.cwd.to_string_lossy(), + is_git_repo, + std::env::consts::OS, + ) + .with_tool_registry(prompt_registry) + .with_agent_registry(session.config.merged_config.agent_registry.clone()) + .with_active_agent(session.agent.clone()) + .compose() + .await; + messages.insert(0, crate::session::types::Message::system(system_prompt)); + + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel(); + let stream_session_id = session_id.clone(); + let stream_cancellation = cancellation.clone(); + let stream_sender = sender.clone(); + let stream_session = session.clone(); + tokio::spawn(async move { + let result = crate::llm::client::stream_llm_with_cancellation( + stream_cancellation, + stream_session_id, + stream_session.provider.clone(), + stream_session.model.clone(), + stream_session.reasoning, + stream_session.agent.clone(), + stream_session + .config + .merged_config + .agent_registry + .get(&stream_session.agent) + .and_then(|agent| agent.max_steps), + stream_session.config.merged_config.agent_registry.clone(), + tool_permissions(&stream_session), + stream_session.config.merged_config.websearch.clone(), + stream_session.config.merged_config.mcp.clone(), + stream_session.cwd.to_string_lossy().to_string(), + messages, + sender, + ) + .await; + if let Err(error) = result { + let _ = stream_sender.send(crate::llm::ChunkMessage::Failed(error.to_string())); + } + let _ = stream_sender.send(crate::llm::ChunkMessage::End); + }); + + let message_id = cuid2::create_id(); + let mut assistant = crate::session::types::Message::incomplete(""); + assistant.provider = Some(session.provider.clone()); + assistant.model = Some(session.model.clone()); + assistant.agent_mode = Some(session.agent.clone()); + let mut failed = None; + let mut cancelled = false; + + while let Some(chunk) = receiver.recv().await { + match chunk { + crate::llm::ChunkMessage::Text(text) => { + assistant.append(&text); + send_text(&connection, &session_id, &message_id, text, false)?; + } + crate::llm::ChunkMessage::Reasoning(text) => { + assistant.append_reasoning(&text); + send_text(&connection, &session_id, &message_id, text, true)?; + } + crate::llm::ChunkMessage::ToolCalls(tool_calls) => { + for tool_call in tool_calls { + send_tool_call(&connection, &session_id, tool_call)?; + } + } + crate::llm::ChunkMessage::ToolResult(result) => { + assistant.add_or_update_tool_result_part(serde_json::json!({ + "id": result.tool_call_id, + "name": result.name, + "content": result.content, + })); + send_tool_result(&connection, &session_id, result)?; + } + crate::llm::ChunkMessage::Cancelled => cancelled = true, + crate::llm::ChunkMessage::Failed(error) => failed = Some(error), + crate::llm::ChunkMessage::PermissionRequest(prompt) => { + let response = request_permission(&connection, &session_id, &prompt).await; + let _ = prompt.response_tx.send(response); + } + crate::llm::ChunkMessage::QuestionRequest { response_tx, .. } => { + let _ = response_tx.send(serde_json::json!({"skipped": true})); + } + crate::llm::ChunkMessage::TerminalSessionRequest(request) => { + let _ = request + .control_tx + .send(crate::tools::TerminalSessionControl::Stop); + } + crate::llm::ChunkMessage::End => break, + _ => {} + } + } + + assistant.is_complete = true; + assistant.was_interrupted = cancelled || cancellation.is_cancelled(); + { + let mut manager = self.session_manager.lock().map_err(|_| internal_error())?; + manager + .add_message_to_session(&session_id, &assistant) + .map_err(|_| internal_error())?; + let status = if assistant.was_interrupted { + crate::session::types::SessionStatus::Interrupted + } else if failed.is_some() { + crate::session::types::SessionStatus::Failed + } else { + crate::session::types::SessionStatus::Idle + }; + manager + .set_session_status(&session_id, status, failed.as_deref()) + .map_err(|_| internal_error())?; + } + if let Some(current) = self.sessions.lock().await.get_mut(&session_id) { + current.cancellation = None; + } + + if assistant.was_interrupted { + return Ok(PromptResponse::new(StopReason::Cancelled)); + } + if failed.is_some() { + return Err(internal_error()); + } + Ok(PromptResponse::new(StopReason::EndTurn)) + } +} + +fn resolve_model(config: &LoadedConfig) -> (String, String) { + if let Ok(Some((provider, model))) = + crate::persistence::PrefsDAO::new().and_then(|prefs| prefs.get_active_model()) + { + return (provider, model); + } + config + .merged_config + .model + .as_deref() + .map(crate::app::parse_model_ref) + .unwrap_or_else(|| ("opencode".to_string(), "big-pickle".to_string())) +} + +fn tool_permissions(session: &AcpSession) -> crate::tools::ToolPermissions { + let mut policies = crate::tools::AgentToolPolicies::default(); + for (mode, tools) in session + .config + .merged_config + .agent_registry + .tool_policy_map() + { + policies = policies.with_custom_tools(mode, tools); + } + crate::tools::ToolPermissions::new(&session.cwd) + .with_agent_policies(policies) + .with_permission_rules(session.config.merged_config.permission_rules.clone()) + .with_agent_permission_rules( + session + .config + .merged_config + .agent_registry + .permission_rules_map(), + ) +} + +fn session_modes(session: &AcpSession) -> SessionModeState { + let mut modes = session + .config + .merged_config + .agent_registry + .visible_primary_agents() + .into_iter() + .map(|agent| { + SessionMode::new(agent.name.clone(), agent.name.clone()) + .description(agent.description.clone()) + }) + .collect::>(); + modes.sort_by(|left, right| left.name.cmp(&right.name)); + SessionModeState::new(session.agent.clone(), modes) +} + +fn session_config_options(session: &AcpSession) -> Vec { + let mut options = session + .config + .merged_config + .agent_registry + .visible_primary_agents() + .into_iter() + .map(|agent| { + SessionConfigSelectOption::new(agent.name.clone(), agent.name.clone()) + .description(agent.description.clone()) + }) + .collect::>(); + options.sort_by(|left, right| left.name.cmp(&right.name)); + vec![ + SessionConfigOption::select("mode", "Mode", session.agent.clone(), options) + .category(SessionConfigOptionCategory::Mode), + ] +} + +fn workspace_path(path: &Path) -> Result { + if !path.is_absolute() { + return Err(Error::invalid_params().data("cwd must be an absolute path")); + } + let path = path + .canonicalize() + .map_err(|_| Error::resource_not_found(Some(path.to_string_lossy().to_string())))?; + if !path.is_dir() { + return Err(Error::invalid_params().data("cwd must be a directory")); + } + Ok(path) +} + +fn prompt_text(parts: Vec) -> Result { + let mut text = String::new(); + for part in parts { + match part { + ContentBlock::Text(content) => text.push_str(&content.text), + ContentBlock::ResourceLink(link) => { + text.push_str(&format!("[{}]", link.uri)); + } + ContentBlock::Resource(resource) => match resource.resource { + EmbeddedResourceResource::TextResourceContents(resource) => { + text.push_str(&format!("[{}]\n{}", resource.uri, resource.text)); + } + EmbeddedResourceResource::BlobResourceContents(resource) => { + text.push_str(&format!("[{}]", resource.uri)); + } + _ => {} + }, + ContentBlock::Image(_) | ContentBlock::Audio(_) => { + return Err(Error::invalid_params() + .data("image and audio ACP prompts are not supported yet")); + } + _ => {} + } + } + Ok(text) +} + +fn send_text( + connection: &ConnectionTo, + session_id: &str, + message_id: &str, + text: String, + thought: bool, +) -> Result<(), Error> { + let update = if thought { + SessionUpdate::AgentThoughtChunk(ContentChunk::new(text.into()).message_id(message_id)) + } else { + SessionUpdate::AgentMessageChunk(ContentChunk::new(text.into()).message_id(message_id)) + }; + connection + .send_notification(SessionNotification::new(session_id.to_string(), update)) + .map_err(|_| internal_error()) +} + +fn send_tool_call( + connection: &ConnectionTo, + session_id: &str, + tool_call: crate::llm::ToolCall, +) -> Result<(), Error> { + let raw_input = serde_json::from_str(&tool_call.function.arguments) + .unwrap_or_else(|_| serde_json::json!({ "arguments": tool_call.function.arguments })); + let title = tool_title(&tool_call.function.name, &raw_input); + let update = SessionUpdate::ToolCall( + ToolCall::new(tool_call.id, title) + .kind(tool_kind(&tool_call.function.name)) + .status(ToolCallStatus::Pending) + .raw_input(raw_input), + ); + connection + .send_notification(SessionNotification::new(session_id.to_string(), update)) + .map_err(|_| internal_error()) +} + +async fn request_permission( + connection: &ConnectionTo, + session_id: &str, + prompt: &crate::tools::PermissionPrompt, +) -> crate::tools::PermissionResponse { + let tool_call_id = format!("permission:{}", cuid2::create_id()); + let input = serde_json::json!({ + "tool": prompt.tool_id, + "permission": prompt.permission, + "patterns": prompt.patterns, + "target": prompt.target, + "command": prompt.command, + "workdir": prompt.workdir, + "reason": prompt.reason, + }); + let tool_call = ToolCallUpdate::new( + tool_call_id, + ToolCallUpdateFields::new() + .title(permission_title(prompt)) + .kind(tool_kind(&prompt.tool_id)) + .status(ToolCallStatus::Pending) + .raw_input(input), + ); + let request = RequestPermissionRequest::new( + session_id.to_string(), + tool_call, + vec![ + PermissionOption::new("once", "Allow once", PermissionOptionKind::AllowOnce), + PermissionOption::new("always", "Always allow", PermissionOptionKind::AllowAlways), + PermissionOption::new("reject", "Reject", PermissionOptionKind::RejectOnce), + ], + ); + let Ok(response) = connection.send_request(request).block_task().await else { + return crate::tools::PermissionResponse::Deny; + }; + match response.outcome { + RequestPermissionOutcome::Selected(selection) + if selection.option_id.to_string() == "once" => + { + crate::tools::PermissionResponse::AllowOnce + } + RequestPermissionOutcome::Selected(selection) + if selection.option_id.to_string() == "always" => + { + crate::tools::PermissionResponse::AllowAlways + } + RequestPermissionOutcome::Selected(_) | RequestPermissionOutcome::Cancelled => { + crate::tools::PermissionResponse::Deny + } + _ => crate::tools::PermissionResponse::Deny, + } +} + +fn permission_title(prompt: &crate::tools::PermissionPrompt) -> String { + prompt + .command + .as_deref() + .or(prompt.target.as_deref()) + .unwrap_or(&prompt.reason) + .to_string() +} + +fn send_tool_result( + connection: &ConnectionTo, + session_id: &str, + result: crate::llm::ToolCallResult, +) -> Result<(), Error> { + let payload = serde_json::from_str::(&result.content).unwrap_or_else( + |_| serde_json::json!({ "status": "error", "output_preview": result.content }), + ); + let status = match payload.get("status").and_then(serde_json::Value::as_str) { + Some("ok") => ToolCallStatus::Completed, + _ => ToolCallStatus::Failed, + }; + let text = payload + .get("output_preview") + .or_else(|| payload.get("error")) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let fields = ToolCallUpdateFields::new() + .status(status) + .content((!text.is_empty()).then(|| vec![ToolCallContent::from(text)])) + .raw_output(payload); + let update = SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(result.tool_call_id, fields)); + connection + .send_notification(SessionNotification::new(session_id.to_string(), update)) + .map_err(|_| internal_error()) +} + +fn replay_messages( + connection: &ConnectionTo, + session_id: &str, + messages: &[crate::session::types::Message], +) -> Result<(), Error> { + for (message_index, message) in messages.iter().enumerate() { + let message_id = format!("{session_id}:message:{message_index}"); + match message.role { + crate::session::types::MessageRole::User => { + if !message.content.is_empty() { + send_replay_text( + connection, + session_id, + &message_id, + &message.content, + true, + false, + )?; + } + } + crate::session::types::MessageRole::Assistant => { + for part in &message.parts { + match part.part_type.as_str() { + "text" => { + if let Some(text) = part.text_value() { + send_replay_text( + connection, + session_id, + &message_id, + text, + false, + false, + )?; + } + } + "reasoning" => { + if let Some(text) = part.text_value() { + send_replay_text( + connection, + session_id, + &message_id, + text, + false, + true, + )?; + } + } + "tool_call" => replay_tool_call(connection, session_id, part)?, + "tool_result" => replay_tool_result(connection, session_id, part)?, + _ => {} + } + } + } + crate::session::types::MessageRole::System + | crate::session::types::MessageRole::Tool => {} + } + } + Ok(()) +} + +fn send_replay_text( + connection: &ConnectionTo, + session_id: &str, + message_id: &str, + text: &str, + user: bool, + thought: bool, +) -> Result<(), Error> { + let chunk = ContentChunk::new(text.to_string().into()).message_id(message_id); + let update = if user { + SessionUpdate::UserMessageChunk(chunk) + } else if thought { + SessionUpdate::AgentThoughtChunk(chunk) + } else { + SessionUpdate::AgentMessageChunk(chunk) + }; + connection + .send_notification(SessionNotification::new(session_id.to_string(), update)) + .map_err(|_| internal_error()) +} + +fn replay_tool_call( + connection: &ConnectionTo, + session_id: &str, + part: &crate::session::types::MessagePart, +) -> Result<(), Error> { + let Some(tool_call_id) = part.tool_id() else { + return Ok(()); + }; + let name = part.tool_name().unwrap_or("tool"); + let input = part + .data + .get("args") + .cloned() + .unwrap_or(serde_json::Value::Null); + let status = match part.tool_status() { + Some("completed") | Some("ok") => ToolCallStatus::Completed, + Some("error") | Some("failed") => ToolCallStatus::Failed, + Some("running") => ToolCallStatus::InProgress, + _ => ToolCallStatus::Pending, + }; + let update = SessionUpdate::ToolCall( + ToolCall::new(tool_call_id.to_string(), tool_title(name, &input)) + .kind(tool_kind(name)) + .status(status) + .raw_input(input), + ); + connection + .send_notification(SessionNotification::new(session_id.to_string(), update)) + .map_err(|_| internal_error()) +} + +fn replay_tool_result( + connection: &ConnectionTo, + session_id: &str, + part: &crate::session::types::MessagePart, +) -> Result<(), Error> { + let Some(tool_call_id) = part.tool_id() else { + return Ok(()); + }; + let content = part + .data + .get("content") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + send_tool_result( + connection, + session_id, + crate::llm::ToolCallResult { + tool_call_id: tool_call_id.to_string(), + role: "tool".to_string(), + name: part.tool_name().unwrap_or("tool").to_string(), + content, + }, + ) +} + +fn tool_kind(tool_name: &str) -> ToolKind { + match tool_name { + "bash" | "terminal_session" => ToolKind::Execute, + "webfetch" => ToolKind::Fetch, + "grep" | "glob" | "context" => ToolKind::Search, + "read" | "view_image" => ToolKind::Read, + "edit" | "write" | "write_files" | "apply_patch" => ToolKind::Edit, + "task" => ToolKind::Think, + _ => ToolKind::Other, + } +} + +fn tool_title(tool_name: &str, input: &serde_json::Value) -> String { + match tool_name { + "bash" => input + .get("command") + .and_then(serde_json::Value::as_str) + .unwrap_or(tool_name) + .to_string(), + "read" | "edit" | "write" | "grep" | "glob" => input + .get("filePath") + .or_else(|| input.get("filepath")) + .or_else(|| input.get("path")) + .or_else(|| input.get("pattern")) + .and_then(serde_json::Value::as_str) + .unwrap_or(tool_name) + .to_string(), + _ => tool_name.to_string(), + } +} + +fn system_time_to_iso8601(value: std::time::SystemTime) -> String { + chrono::DateTime::::from(value).to_rfc3339() +} + +fn internal_error() -> Error { + Error::internal_error().data("Crabcode ACP operation failed") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_crabcode_tools_to_acp_kinds() { + assert_eq!(tool_kind("bash"), ToolKind::Execute); + assert_eq!(tool_kind("read"), ToolKind::Read); + assert_eq!(tool_kind("apply_patch"), ToolKind::Edit); + assert_eq!(tool_kind("unknown"), ToolKind::Other); + } + + #[test] + fn builds_titles_from_tool_input() { + assert_eq!( + tool_title("bash", &serde_json::json!({"command": "cargo test"})), + "cargo test" + ); + assert_eq!( + tool_title("read", &serde_json::json!({"filePath": "src/main.rs"})), + "src/main.rs" + ); + } + + #[test] + fn flattens_text_and_embedded_context() { + let text = prompt_text(vec![ + ContentBlock::from("Inspect this."), + ContentBlock::Resource(agent_client_protocol::schema::v1::EmbeddedResource::new( + EmbeddedResourceResource::TextResourceContents( + agent_client_protocol::schema::v1::TextResourceContents::new( + "fn main() {}", + "file:///tmp/main.rs", + ), + ), + )), + ]) + .expect("prompt text"); + + assert_eq!(text, "Inspect this.[file:///tmp/main.rs]\nfn main() {}"); + } + + #[test] + fn rejects_non_absolute_workspaces() { + assert!(workspace_path(Path::new("relative")).is_err()); + } +} diff --git a/src/config/configuration.rs b/src/config/configuration.rs index e6bce66..325b402 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -474,6 +474,11 @@ pub struct ConfigLoader; impl ConfigLoader { pub fn load() -> Result { let cwd = crate::utils::cwd::current_dir()?; + Self::load_for(&cwd) + } + + pub fn load_for(cwd: &Path) -> Result { + let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf()); let xdg_config_home = xdg_config_home(); let project_root = discover_project_root(&cwd); diff --git a/src/main.rs b/src/main.rs index 940f349..474c0f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ #![allow(dead_code)] +mod acp; mod agent; mod aisdk; mod app; @@ -74,6 +75,7 @@ use ratatui::crossterm::{ }; use ratatui::{backend::CrosstermBackend, buffer::Buffer, style::Color, Terminal}; use std::io::{self, IsTerminal, Read, Write}; +use std::path::PathBuf; use std::process::Command as ProcessCommand; use std::sync::Mutex; use std::time::Duration; @@ -640,6 +642,13 @@ struct Args { #[derive(Subcommand, Debug)] enum Command { + /// Start an Agent Client Protocol server over stdin/stdout + Acp { + /// Working directory used for the initial ACP workspace + #[arg(long)] + cwd: Option, + }, + /// Host the current workspace for browser and CLI clients Serve { /// Address to bind, for example 127.0.0.1:8421 or 0.0.0.0:8421 @@ -718,6 +727,9 @@ async fn main() -> Result<()> { } match &args.command { + Some(Command::Acp { cwd }) => { + return crate::acp::run(cwd.clone()).await; + } Some(Command::Serve { bind, pair_code }) => { return crate::remote::serve(crate::remote::ServeOptions { bind: bind.clone(), @@ -928,6 +940,16 @@ mod tests { } } + #[test] + fn parses_acp_command_with_workspace() { + let args = Args::try_parse_from(["crabcode", "acp", "--cwd", "/tmp/workspace"]).unwrap(); + + match args.command { + Some(Command::Acp { cwd }) => assert_eq!(cwd, Some(PathBuf::from("/tmp/workspace"))), + other => panic!("expected acp command, got {other:?}"), + } + } + #[test] fn parses_serve_paircode() { let args = Args::try_parse_from(["crabcode", "serve", "--paircode", "random"]).unwrap(); diff --git a/src/persistence/history.rs b/src/persistence/history.rs index 6803179..4ac270a 100644 --- a/src/persistence/history.rs +++ b/src/persistence/history.rs @@ -73,6 +73,10 @@ pub struct HistoryDAO { impl HistoryDAO { pub fn new() -> Result { + Self::new_for_workspace(crate::utils::cwd::current_dir_or_dot()) + } + + pub fn new_for_workspace(workspace: impl AsRef) -> Result { let data_dir = get_data_dir(); ensure_data_dir()?; let db_path = data_dir.join("data.db"); @@ -95,7 +99,10 @@ impl HistoryDAO { [], ); - let current_workspace_path = crate::utils::cwd::current_dir_or_dot() + let current_workspace_path = workspace + .as_ref() + .canonicalize() + .unwrap_or_else(|_| workspace.as_ref().to_path_buf()) .to_string_lossy() .to_string(); let current_workspace_name = workspace_display_name(¤t_workspace_path); diff --git a/src/session/manager.rs b/src/session/manager.rs index d5e1e84..16cfc2e 100644 --- a/src/session/manager.rs +++ b/src/session/manager.rs @@ -81,9 +81,16 @@ impl SessionManager { } } - pub fn with_history(mut self) -> Result { - let history_dao = - HistoryDAO::new().map_err(|e| SessionError::PersistenceError(e.to_string()))?; + pub fn with_history(self) -> Result { + self.with_history_for_workspace(crate::utils::cwd::current_dir_or_dot()) + } + + pub fn with_history_for_workspace( + mut self, + workspace: impl AsRef, + ) -> Result { + let history_dao = HistoryDAO::new_for_workspace(workspace) + .map_err(|e| SessionError::PersistenceError(e.to_string()))?; self.current_workspace_id = history_dao.current_workspace_id(); self.current_workspace_path = history_dao.current_workspace_path().to_string(); self.current_workspace_name = history_dao.current_workspace_name().to_string();