diff --git a/Cargo.lock b/Cargo.lock index 4989563..11dd3a5 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", "semver", "serde", "serde_json", @@ -802,6 +967,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" @@ -834,6 +1002,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" @@ -894,13 +1085,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]] @@ -961,6 +1152,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" @@ -1033,6 +1244,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" @@ -1119,6 +1336,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" @@ -1142,6 +1372,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" @@ -1297,7 +1540,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -1315,6 +1558,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" @@ -1378,6 +1627,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" @@ -1681,6 +1936,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" @@ -1689,6 +1955,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -2422,6 +2690,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" @@ -2499,12 +2773,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" @@ -2518,7 +2823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64", - "indexmap", + "indexmap 2.14.0", "quick-xml", "serde", "time", @@ -2631,7 +2936,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", @@ -3034,6 +3339,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" @@ -3132,6 +3443,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" @@ -3245,7 +3568,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", @@ -3265,13 +3588,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", @@ -3712,7 +4067,7 @@ dependencies = [ "fancy-regex", "lazy_static", "regex", - "rustc-hash", + "rustc-hash 1.1.0", ] [[package]] @@ -3755,6 +4110,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" @@ -3868,7 +4238,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", @@ -4093,6 +4463,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" @@ -4135,6 +4511,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 114af2d..e819d0a 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..9a1baae --- /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, embedded text resources, and PNG, JPEG, GIF, or WebP image attachments; assistant text and reasoning stream back to the editor. | Images require an image-capable selected model; audio prompt blocks are unsupported. | Store ACP attachments persistently and add audio input support. | +| Modes and models | Visible primary Crabcode agents, selectable `/models` catalog entries, and model-supported reasoning-effort selectors are available as ACP options; changes are session-local. | Reasoning options depend on the selected model catalog metadata. | Add richer provider-specific reasoning configuration. | +| 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 | Estimated context-window usage is emitted when the selected model exposes a context limit. | Provider-authoritative token and cost accounting is not complete; usage is omitted when no context limit is known. | Retain provider input, output, cache, context, and cost data for authoritative 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..2210452 100644 --- a/_docs/quickstart.mdx +++ b/_docs/quickstart.mdx @@ -80,6 +80,27 @@ 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. + +## List available models + +Print selectable models in `provider/model` format: + +```bash +crabcode models +crabcode models openai +``` + +The optional provider argument is an exact provider ID. This command uses the same local credentials, custom providers, free models, and runtime model catalog as crabcode. + --- ## 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..ebb6378 --- /dev/null +++ b/src/acp/server.rs @@ -0,0 +1,215 @@ +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 = match ( + request.config_id.to_string().as_str(), + request.value.as_value_id(), + ) { + ("mode", Some(mode)) => { + service + .set_mode(&request.session_id.to_string(), &mode.to_string()) + .await + } + ("model", Some(model)) => { + service + .set_model(&request.session_id.to_string(), &model.to_string()) + .await + } + ("reasoning_effort", Some(effort)) => { + service + .set_reasoning_effort( + &request.session_id.to_string(), + &effort.to_string(), + ) + .await + } + ("mode" | "model" | "reasoning_effort", None) => { + Err(agent_client_protocol::Error::invalid_params() + .data("config option value must be a string")) + } + _ => Err(agent_client_protocol::Error::invalid_params() + .data("unknown config option")), + }; + 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).image(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..2560ada --- /dev/null +++ b/src/acp/service.rs @@ -0,0 +1,1366 @@ +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, SessionConfigSelectGroup, + SessionConfigSelectOption, SessionInfo, SessionMode, SessionModeState, SessionNotification, + SessionUpdate, SetSessionConfigOptionResponse, StopReason, ToolCall, ToolCallContent, + ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UsageUpdate, +}; +use agent_client_protocol::{Client, ConnectionTo, Error}; +use base64::Engine as _; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +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, + models: Vec, + provider: String, + model: String, + agent: String, + reasoning: Option, + context_window: 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 models = crate::model::catalog::selectable_models(&config, None) + .await + .map_err(|_| internal_error())?; + let reasoning = model_reasoning(&config, &models, &provider, &model); + let context_window = model_context_window(&config, &provider, &model); + 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, + models, + provider, + model, + agent, + reasoning, + context_window, + 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, + ))) + } + + pub async fn set_model( + &self, + session_id: &str, + model_ref: &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"))?; + let model = find_selectable_model(&session.models, model_ref)?; + session.provider.clone_from(&model.provider_id); + session.model.clone_from(&model.id); + session.reasoning = model_reasoning( + &session.config, + &session.models, + &session.provider, + &session.model, + ); + session.context_window = + model_context_window(&session.config, &session.provider, &session.model); + Ok(SetSessionConfigOptionResponse::new(session_config_options( + session, + ))) + } + + pub async fn set_reasoning_effort( + &self, + session_id: &str, + value: &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"))?; + let capability = model_reasoning_capability( + &session.config, + &session.models, + &session.provider, + &session.model, + ) + .ok_or_else(|| Error::invalid_params().data("reasoning effort is unavailable"))?; + let requested = value + .parse::() + .map_err(|_| Error::invalid_params().data("invalid reasoning effort"))?; + if !capability.values().contains(&requested) { + return Err(Error::invalid_params().data("unsupported reasoning effort")); + } + session.reasoning = + (requested != crate::model::reasoning::ReasoningEffort::None).then_some(requested); + 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 models = crate::model::catalog::selectable_models(&config, None) + .await + .map_err(|_| internal_error())?; + + 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 reasoning = model_reasoning(&config, &models, &provider, &model); + let context_window = model_context_window(&config, &provider, &model); + let session = AcpSession { + cwd, + config, + models, + provider, + model, + agent, + reasoning, + context_window, + 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 session = self + .sessions + .lock() + .await + .get(&session_id) + .cloned() + .ok_or_else(|| Error::invalid_params().data("unknown session"))?; + let supports_images = session + .models + .iter() + .find(|model| model.provider_id == session.provider && model.id == session.model) + .is_some_and(|model| model.attachment); + let (prompt, local_image_paths) = prompt_content(prompt, supports_images, &session)?; + if prompt.trim().is_empty() { + return Err(Error::invalid_params().data("prompt must include text content")); + } + 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.local_image_paths = local_image_paths; + 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 base_context_tokens = crate::session::compaction::total_context_tokens(&messages); + send_usage(&connection, &session_id, &session, base_context_tokens)?; + + 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::Metrics { + token_count, + duration_ms, + } => { + assistant.token_count = Some(token_count); + assistant.duration_ms = Some(duration_ms); + send_usage( + &connection, + &session_id, + &session, + base_context_tokens.saturating_add(token_count), + )?; + } + 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 let Some(error) = failed { + return Err(internal_error_with(&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 mode_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::>(); + mode_options.sort_by(|left, right| left.name.cmp(&right.name)); + + let mut options = vec![ + SessionConfigOption::select("mode", "Mode", session.agent.clone(), mode_options) + .category(SessionConfigOptionCategory::Mode), + model_config_option(&session.models, &session.provider, &session.model), + ]; + if let Some(option) = reasoning_config_option(session) { + options.push(option); + } + options +} + +fn reasoning_config_option(session: &AcpSession) -> Option { + let capability = model_reasoning_capability( + &session.config, + &session.models, + &session.provider, + &session.model, + )?; + let options = capability + .values() + .iter() + .map(|effort| SessionConfigSelectOption::new(effort.as_str(), effort.as_str())) + .collect::>(); + let current = session + .reasoning + .map(|effort| effort.as_str()) + .unwrap_or("none"); + Some( + SessionConfigOption::select("reasoning_effort", "Reasoning effort", current, options) + .category(SessionConfigOptionCategory::ThoughtLevel), + ) +} + +fn model_config_option( + models: &[crate::model::types::Model], + provider: &str, + model_id: &str, +) -> SessionConfigOption { + let mut model_groups: Vec = Vec::new(); + for model in models { + let option = SessionConfigSelectOption::new(model_value(model), model.name.clone()) + .description(model.id.clone()); + if model_groups + .last() + .is_some_and(|group| group.group.to_string() == model.provider_id) + { + model_groups + .last_mut() + .expect("model group exists") + .options + .push(option); + } else { + model_groups.push(SessionConfigSelectGroup::new( + model.provider_id.clone(), + model.provider_name.clone(), + vec![option], + )); + } + } + + SessionConfigOption::select( + "model", + "Model", + format!("{provider}/{model_id}"), + model_groups, + ) + .category(SessionConfigOptionCategory::Model) +} + +fn model_value(model: &crate::model::types::Model) -> String { + crate::model::catalog::model_ref(model) +} + +fn find_selectable_model<'a>( + models: &'a [crate::model::types::Model], + model_ref: &str, +) -> Result<&'a crate::model::types::Model, Error> { + models + .iter() + .find(|model| model_value(model) == model_ref) + .ok_or_else(|| Error::invalid_params().data("unknown or unavailable ACP model")) +} + +fn model_reasoning( + config: &LoadedConfig, + models: &[crate::model::types::Model], + provider: &str, + model_id: &str, +) -> Option { + model_reasoning_capability(config, models, provider, model_id) + .and_then(|capability| capability.resolve(None)) +} + +fn model_reasoning_capability( + config: &LoadedConfig, + models: &[crate::model::types::Model], + provider: &str, + model_id: &str, +) -> Option { + if let Some(capability) = models + .iter() + .find(|model| model.provider_id == provider && model.id == model_id) + .and_then(|model| { + crate::model::reasoning::capability_from_options(&model.reasoning_options) + }) + { + return Some(capability); + } + crate::model::discovery::Discovery::new_with_custom(Some( + config.merged_config.custom_providers.clone(), + )) + .ok() + .and_then(|discovery| discovery.get_model_reasoning_capability(provider, model_id)) + .filter(|capability| !capability.values().is_empty()) +} + +fn model_context_window(config: &LoadedConfig, provider: &str, model: &str) -> Option { + let discovery = crate::model::discovery::Discovery::new_with_custom(Some( + config.merged_config.custom_providers.clone(), + )) + .ok(); + discovery + .as_ref() + .and_then(|discovery| discovery.get_model_limit(provider, model)) + .or_else(|| { + config + .merged_config + .custom_providers + .get(provider) + .and_then(|provider| provider.models.get(model)) + .and_then(|model| model.context_window) + }) +} + +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) +} + +static ACP_IMAGE_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +fn prompt_content( + parts: Vec, + supports_images: bool, + session: &AcpSession, +) -> Result<(String, Vec), Error> { + let mut text = String::new(); + let mut local_image_paths = Vec::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(image) => { + if !supports_images { + return Err(Error::invalid_params().data(format!( + "model {}/{} does not support image input", + session.provider, session.model + ))); + } + local_image_paths.push(write_prompt_image(&image)?); + } + ContentBlock::Audio(_) => { + return Err(Error::invalid_params().data("audio ACP prompts are not supported yet")); + } + _ => {} + } + } + if text.is_empty() && !local_image_paths.is_empty() { + text.push_str("[Image attached]"); + } + Ok((text, local_image_paths)) +} + +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("binary ACP prompt content is not supported here") + ); + } + _ => {} + } + } + Ok(text) +} + +fn write_prompt_image( + image: &agent_client_protocol::schema::v1::ImageContent, +) -> Result { + const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024; + + let extension = match image.mime_type.as_str() { + "image/png" => "png", + "image/jpeg" => "jpg", + "image/gif" => "gif", + "image/webp" => "webp", + mime_type => { + return Err( + Error::invalid_params().data(format!("unsupported image MIME type: {mime_type}")) + ); + } + }; + let data = base64::engine::general_purpose::STANDARD + .decode(&image.data) + .map_err(|error| Error::invalid_params().data(format!("invalid image data: {error}")))?; + if data.len() > MAX_IMAGE_BYTES { + return Err(Error::invalid_params().data("image exceeds the 20 MiB size limit")); + } + + let directory = std::env::temp_dir().join("crabcode").join("acp-images"); + std::fs::create_dir_all(&directory).map_err(|_| internal_error())?; + let sequence = ACP_IMAGE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let path = directory.join(format!("{}-{sequence}.{extension}", std::process::id())); + std::fs::write(&path, data).map_err(|_| internal_error())?; + Ok(path.to_string_lossy().into_owned()) +} + +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 send_usage( + connection: &ConnectionTo, + session_id: &str, + session: &AcpSession, + used: usize, +) -> Result<(), Error> { + let Some(size) = session.context_window else { + return Ok(()); + }; + let update = SessionUpdate::UsageUpdate(UsageUpdate::new(used as u64, size as u64)); + 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") +} + +fn internal_error_with(error: &str) -> Error { + Error::internal_error().data(format!("Crabcode ACP operation failed: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn model( + provider_id: &str, + provider_name: &str, + id: &str, + name: &str, + ) -> crate::model::types::Model { + crate::model::types::Model { + id: id.to_string(), + name: name.to_string(), + family: String::new(), + provider_id: provider_id.to_string(), + provider_name: provider_name.to_string(), + attachment: false, + structured_output: false, + free: false, + local: false, + reasoning_options: Vec::new(), + } + } + + fn reasoning_model() -> crate::model::types::Model { + let mut model = model("openai", "OpenAI", "o3", "o3"); + model.reasoning_options = vec![crate::model::reasoning::ReasoningOption { + kind: "effort".to_string(), + values: vec!["low".to_string(), "medium".to_string(), "high".to_string()], + }]; + model + } + + #[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 writes_supported_acp_image_to_temp_file() { + let image = agent_client_protocol::schema::v1::ImageContent::new("aGk=", "image/png"); + let path = write_prompt_image(&image).expect("image file"); + + assert_eq!(std::fs::read(&path).expect("image bytes"), b"hi"); + let _ = std::fs::remove_file(path); + } + + #[test] + fn rejects_unsupported_acp_image_mime_type() { + let image = agent_client_protocol::schema::v1::ImageContent::new("aGk=", "image/tiff"); + + assert!(write_prompt_image(&image).is_err()); + } + + #[test] + fn rejects_non_absolute_workspaces() { + assert!(workspace_path(Path::new("relative")).is_err()); + } + + #[test] + fn builds_grouped_model_config_option() { + let option = model_config_option( + &[ + model("anthropic", "Anthropic", "claude", "Claude"), + model("openai", "OpenAI", "gpt-5", "GPT-5"), + model("openai", "OpenAI", "gpt-5-mini", "GPT-5 Mini"), + ], + "openai", + "gpt-5", + ); + + assert_eq!(option.id.to_string(), "model"); + assert_eq!(option.category, Some(SessionConfigOptionCategory::Model)); + let agent_client_protocol::schema::v1::SessionConfigKind::Select(select) = option.kind + else { + panic!("model option should be a select"); + }; + assert_eq!(select.current_value.to_string(), "openai/gpt-5"); + let agent_client_protocol::schema::v1::SessionConfigSelectOptions::Grouped(groups) = + select.options + else { + panic!("model options should be grouped"); + }; + assert_eq!(groups.len(), 2); + assert_eq!(groups[1].name, "OpenAI"); + assert_eq!(groups[1].options[1].value.to_string(), "openai/gpt-5-mini"); + } + + #[test] + fn validates_model_refs_against_selectable_catalog() { + let models = [model("openai", "OpenAI", "gpt-5", "GPT-5")]; + + assert_eq!( + find_selectable_model(&models, "openai/gpt-5") + .expect("known model") + .id, + "gpt-5" + ); + assert!(find_selectable_model(&models, "openai/missing").is_err()); + assert!(find_selectable_model(&models, "other/gpt-5").is_err()); + } + + #[test] + fn exposes_reasoning_effort_config_for_supported_models() { + let model = reasoning_model(); + let session = AcpSession { + cwd: PathBuf::from("/tmp"), + config: crate::config::configuration::LoadedConfig { + merged_config: crate::config::configuration::MergedConfig::default(), + raw_merged: serde_json::Value::Null, + diagnostics: Default::default(), + inventory: Default::default(), + project_root: PathBuf::from("/tmp"), + cwd: PathBuf::from("/tmp"), + xdg_config_home: PathBuf::from("/tmp"), + }, + models: vec![model], + provider: "openai".to_string(), + model: "o3".to_string(), + agent: "Build".to_string(), + reasoning: Some(crate::model::reasoning::ReasoningEffort::Medium), + context_window: None, + cancellation: None, + }; + let option = reasoning_config_option(&session).expect("reasoning option"); + assert_eq!(option.id.to_string(), "reasoning_effort"); + assert_eq!( + option.category, + Some(SessionConfigOptionCategory::ThoughtLevel) + ); + } +} 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 68d1140..54b1587 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ #![allow(dead_code)] +mod acp; mod agent; mod aisdk; mod app; @@ -75,6 +76,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; @@ -641,6 +643,19 @@ struct Args { #[derive(Subcommand, Debug)] enum Command { + /// List available models, optionally filtered by provider + Models { + /// Exact provider ID to filter by + provider: Option, + }, + + /// 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 @@ -725,6 +740,24 @@ async fn main() -> Result<()> { } match &args.command { + Some(Command::Models { provider }) => { + let config = crate::config::ConfigLoader::load()?; + let models = + crate::model::catalog::selectable_models(&config, provider.as_deref()).await?; + if models.is_empty() { + if let Some(provider) = provider { + anyhow::bail!("no models found for provider: {provider}"); + } + anyhow::bail!("no models available"); + } + for model in models { + println!("{}", crate::model::catalog::model_ref(&model)); + } + return Ok(()); + } + 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(), @@ -938,6 +971,32 @@ 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_models_command_with_optional_provider() { + let args = Args::try_parse_from(["crabcode", "models", "openai"]).unwrap(); + + match args.command { + Some(Command::Models { provider }) => assert_eq!(provider.as_deref(), Some("openai")), + other => panic!("expected models command, got {other:?}"), + } + + let args = Args::try_parse_from(["crabcode", "models"]).unwrap(); + match args.command { + Some(Command::Models { provider }) => assert!(provider.is_none()), + other => panic!("expected models command, got {other:?}"), + } + } + #[test] fn parses_serve_paircode() { let args = Args::try_parse_from(["crabcode", "serve", "--paircode", "random"]).unwrap(); diff --git a/src/model/catalog.rs b/src/model/catalog.rs new file mode 100644 index 0000000..00931ff --- /dev/null +++ b/src/model/catalog.rs @@ -0,0 +1,129 @@ +use crate::config::configuration::LoadedConfig; +use crate::model::discovery::{is_model_selectable, merge_dialog_models, Discovery}; +use crate::model::extensions::ModelExtensions; +use crate::model::types::Model; +use crate::persistence::AuthDAO; +use anyhow::{bail, Context, Result}; +use std::collections::HashSet; + +pub async fn selectable_models( + config: &LoadedConfig, + provider_filter: Option<&str>, +) -> Result> { + let connected_providers = AuthDAO::new() + .context("failed to initialize auth storage")? + .load() + .context("failed to load providers")?; + let connected_provider_ids = connected_providers.keys().cloned().collect::>(); + let discovery = Discovery::new_with_custom(Some(config.merged_config.custom_providers.clone())) + .context("failed to initialize model discovery")?; + let configured_provider_ids = discovery.custom_provider_ids(); + + let filter_matches_runtime = provider_filter.is_some_and(|filter| { + ModelExtensions::runtime() + .iter() + .any(|integration| integration.provider_id() == filter) + }); + let has_runtime = ModelExtensions::runtime() + .iter() + .any(|integration| connected_providers.contains_key(integration.provider_id())) + || (connected_providers.is_empty() && provider_filter.is_none()) + || filter_matches_runtime; + let has_persistent = connected_providers + .keys() + .any(|provider_id| !ModelExtensions::is_runtime_provider(provider_id)) + || provider_filter.is_none() + || provider_filter.is_some_and(|filter| configured_provider_ids.contains(filter)) + || provider_filter + .is_some_and(|filter| ModelExtensions::is_unauthenticated_free_provider(filter)); + + let snapshot_models = crate::model::effective_catalog::models_for_dialog() + .context("failed to load effective model catalog")?; + let mut models = if let Some(models) = snapshot_models { + models + } else if has_persistent { + match discovery.fetch_models().await { + Ok(models) => models + .into_iter() + .filter(|model| !ModelExtensions::is_runtime_provider(&model.provider_id)) + .collect(), + Err(error) if has_runtime => { + crate::emit_log!("Skipped persistent model catalog: {}", error); + Vec::new() + } + Err(error) => return Err(error).context("failed to fetch models"), + } + } else { + Vec::new() + }; + discovery.apply_custom_models_to_dialog(&mut models); + + let mut runtime_errors = Vec::new(); + if has_runtime { + let runtime = ModelExtensions::runtime_models_for_dialog_cached().await; + merge_dialog_models(&mut models, runtime.models); + runtime_errors = runtime.errors; + } + + models.retain(|model| { + is_model_selectable(model, &connected_provider_ids, &configured_provider_ids) + && provider_matches(&model.provider_id, provider_filter) + }); + models.sort_by(|left, right| { + left.provider_id + .cmp(&right.provider_id) + .then_with(|| left.id.cmp(&right.id)) + }); + let mut seen = HashSet::new(); + models.retain(|model| seen.insert((model.provider_id.clone(), model.id.clone()))); + + if models.is_empty() && has_runtime && (filter_matches_runtime || provider_filter.is_none()) { + if let Some(error) = runtime_errors.first() { + bail!( + "failed to fetch {} models: {}", + error.provider_name, + error.error + ); + } + } + + Ok(models) +} + +pub fn model_ref(model: &Model) -> String { + format!("{}/{}", model.provider_id, model.id) +} + +fn provider_matches(provider_id: &str, provider_filter: Option<&str>) -> bool { + provider_filter.is_none_or(|filter| provider_id == filter) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_canonical_model_ref() { + let model = Model { + id: "gpt-5".into(), + name: "GPT-5".into(), + family: "gpt".into(), + provider_id: "openai".into(), + provider_name: "OpenAI".into(), + attachment: false, + structured_output: false, + free: false, + local: false, + reasoning_options: Vec::new(), + }; + + assert_eq!(model_ref(&model), "openai/gpt-5"); + } + + #[test] + fn provider_filter_is_exact() { + assert!(provider_matches("opencode", Some("opencode"))); + assert!(!provider_matches("opencode-go", Some("opencode"))); + assert!(provider_matches("opencode-go", None)); + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index f0354bf..b9e732c 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,3 +1,4 @@ +pub mod catalog; pub mod discovery; pub mod effective_catalog; pub mod extensions; 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();