From 540a3025b151e7b3b694eb8393707a3e2f5371d3 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Fri, 4 Sep 2026 20:13:30 +0100 Subject: [PATCH] feat(maestro): add maestro-acp, the ACP client per structured session (#134) New crate wrapping agent-client-protocol 2.1 in the client role. spawn() resolves claude-code-acp on the PATH the child would run with, starts it in the session cwd with the session env, runs initialize + session/new, and returns an AcpSession holding the connection, the ACP session id, and an unbounded channel of raw session/update notifications for #135. initialize declares fs.readTextFile, fs.writeTextFile and terminal as false so the adapter uses Claude Code's own tools. request_permission is a stub that declines until #139. terminate() sends session/cancel, waits, SIGTERMs, then SIGKILLs. The child stays in the daemon's process group and gets SIGTERM on daemon death via PR_SET_PDEATHSIG. CLAUDECODE is stripped from the child env: the adapter refuses to start when it believes it is nested in another Claude Code session, which is how a daemon launched from one behaves. Tests run against tests/fake_acp_agent.py, a JSON-RPC-over-stdio stand-in that logs what it receives; real_adapter_handshake is #[ignore]d. --- Cargo.lock | 802 +++++++++++++++++++-- Cargo.toml | 3 + crates/maestro-acp/Cargo.toml | 20 + crates/maestro-acp/src/lib.rs | 468 ++++++++++++ crates/maestro-acp/tests/fake_acp_agent.py | 98 +++ crates/maestro-acp/tests/spawn.rs | 292 ++++++++ 6 files changed, 1626 insertions(+), 57 deletions(-) create mode 100644 crates/maestro-acp/Cargo.toml create mode 100644 crates/maestro-acp/src/lib.rs create mode 100755 crates/maestro-acp/tests/fake_acp_agent.py create mode 100644 crates/maestro-acp/tests/spawn.rs diff --git a/Cargo.lock b/Cargo.lock index 12ea8dd..4a07411 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,56 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "agent-client-protocol" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6395d81d91fd2ee93f48ea31cfc511356e75b9061ca0389cefd0308507cc11ba" +dependencies = [ + "agent-client-protocol-derive", + "agent-client-protocol-schema", + "async-io", + "async-process", + "blocking", + "futures", + "futures-concurrency", + "rustc-hash", + "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.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8d5f32208cdc11238004bec4e4229d68fb937598f900e153468ca084865fa5" +dependencies = [ + "quote", + "syn 3.0.4", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca98360c7bb8cc97d7acd49e2a8a851c3f7bee6b2f0535036d8ab86b5fcd223d" +dependencies = [ + "anyhow", + "derive_more", + "schemars 1.2.2", + "serde", + "serde_json", + "serde_with", + "strum 0.28.0", + "tracing", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -88,6 +138,89 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[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.89" @@ -96,7 +229,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -175,6 +308,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -190,6 +329,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +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 = "bumpalo" version = "3.20.3" @@ -288,7 +449,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -317,6 +478,24 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[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 = "core-foundation-sys" version = "0.8.7" @@ -332,13 +511,19 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crossterm" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.11.1", "crossterm_winapi", "futures-core", "mio", @@ -388,7 +573,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -399,7 +584,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -408,6 +593,69 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[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.117", + "unicode-xid", +] + [[package]] name = "digest" version = "0.10.7" @@ -447,9 +695,15 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.16.0" @@ -472,6 +726,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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 = "fastrand" version = "2.4.1" @@ -484,6 +758,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 = "foldhash" version = "0.1.5" @@ -500,55 +780,109 @@ dependencies = [ ] [[package]] -name = "futures-channel" -version = "0.3.32" +name = "futures" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "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.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[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.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -609,6 +943,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.5" @@ -632,6 +972,18 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[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.0" @@ -895,6 +1247,17 @@ dependencies = [ "quote", ] +[[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" @@ -926,7 +1289,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -956,6 +1319,59 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "js-sys" version = "0.3.99" @@ -1055,6 +1471,20 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "maestro-acp" +version = "0.1.0" +dependencies = [ + "agent-client-protocol", + "libc", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "maestro-cli" version = "0.1.0" @@ -1271,7 +1701,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", @@ -1286,6 +1716,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -1313,6 +1749,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" @@ -1348,12 +1790,72 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[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.117", +] + [[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 = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1363,6 +1865,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1379,7 +1887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -1532,7 +2040,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cassowary", "compact_str", "crossterm", @@ -1540,8 +2048,8 @@ dependencies = [ "itertools", "lru", "paste", - "strum", - "strum_macros", + "strum 0.26.3", + "strum_macros 0.26.4", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -1553,7 +2061,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -1567,6 +2075,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1645,13 +2173,22 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -1664,7 +2201,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -1743,6 +2280,43 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.4", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1782,7 +2356,18 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", ] [[package]] @@ -1791,6 +2376,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -1830,6 +2416,39 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sha1" version = "0.10.6" @@ -1850,6 +2469,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -1933,7 +2558,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -1946,7 +2580,19 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1966,6 +2612,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -1983,7 +2640,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2025,7 +2682,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2036,7 +2693,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2048,6 +2705,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2097,7 +2784,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2157,6 +2844,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -2189,7 +2877,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_spanned", "toml_datetime", @@ -2225,7 +2913,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-util", "http", @@ -2269,7 +2957,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2577,7 +3265,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2607,7 +3295,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -2631,9 +3319,9 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -2718,7 +3406,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2729,7 +3417,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2947,9 +3635,9 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -2965,7 +3653,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2977,8 +3665,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", - "indexmap", + "bitflags 2.11.1", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -2997,7 +3685,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -3032,7 +3720,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3053,7 +3741,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3073,7 +3761,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3113,7 +3801,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 426dc2a..ebdb7ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ repository = "https://git.naps.pt/naps62/maestro" publish = false [workspace.dependencies] +agent-client-protocol = "2.1" axum = { version = "0.7", features = ["ws"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } clap = { version = "4.5", features = ["derive", "env"] } @@ -17,6 +18,7 @@ crossterm = { version = "0.28", features = ["event-stream"] } dirs = "5" futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } libc = "0.2" +maestro-acp = { path = "crates/maestro-acp" } maestro-config = { path = "crates/maestro-config" } # `default-features = false` so each consumer opts into `haiku-autoname` # through its own feature of the same name (#131); a workspace entry that @@ -38,6 +40,7 @@ tempfile = "3" thiserror = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] } tokio-tungstenite = "0.21" +tokio-util = { version = "0.7", features = ["compat"] } toml = "0.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/maestro-acp/Cargo.toml b/crates/maestro-acp/Cargo.toml new file mode 100644 index 0000000..8c33de0 --- /dev/null +++ b/crates/maestro-acp/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "maestro-acp" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true +description = "ACP client role: one claude-code-acp child per structured session" + +[dependencies] +agent-client-protocol.workspace = true +libc.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +tokio-util.workspace = true +tracing.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/maestro-acp/src/lib.rs b/crates/maestro-acp/src/lib.rs new file mode 100644 index 0000000..6916481 --- /dev/null +++ b/crates/maestro-acp/src/lib.rs @@ -0,0 +1,468 @@ +//! ACP client role for the daemon (#134): one `claude-code-acp` child per +//! structured session. +//! +//! The daemon is the client; the adapter is the agent. [`spawn`] starts the +//! adapter as a child process with stdio pipes, in the session's `cwd` with +//! the session's env, runs the `initialize` + `session/new` handshake, and +//! hands back an [`AcpSession`] that owns the connection for the life of the +//! session. +//! +//! **The handshake declares almost nothing.** `initialize` reports +//! `fs.readTextFile`, `fs.writeTextFile` and `terminal` as `false`, so the +//! adapter uses Claude Code's own Read / Write / Bash instead of asking the +//! daemon to act on its behalf — exactly what already happens in the +//! terminal view. `session/request_permission` is the one client method that +//! gets a real implementation, in #139; here it is a stub that declines. +//! +//! Every `session/update` notification the adapter sends lands on an +//! unbounded channel the session owns ([`AcpSession::take_updates`]). Nothing +//! is dropped while nobody reads: #135 takes the receiver and persists each +//! notification to the per-session event log before broadcasting it. +//! +//! The child is a child of the daemon, full stop. It shares the daemon's +//! process group, is marked to receive `SIGTERM` when the daemon dies +//! (`PR_SET_PDEATHSIG` on Linux), and [`AcpSession::terminate`] is what both +//! `DELETE /api/sessions/:id` and daemon shutdown call: `session/cancel`, a +//! short wait, then `SIGTERM`, then `SIGKILL` as the last resort. + +#![deny(missing_debug_implementations)] + +use std::collections::{HashMap, VecDeque}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::{ExitStatus, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use agent_client_protocol::schema::v1::{ + CancelNotification, ClientCapabilities, FileSystemCapabilities, Implementation, + InitializeRequest, NewSessionRequest, RequestPermissionOutcome, RequestPermissionRequest, + RequestPermissionResponse, SessionId, SessionNotification, +}; +use agent_client_protocol::schema::ProtocolVersion; +use agent_client_protocol::{ByteStreams, Client}; +use tokio::io::AsyncBufReadExt; +use tokio::sync::{mpsc, oneshot, watch}; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +pub use agent_client_protocol::schema::v1 as schema; + +/// The adapter binary, resolved on `PATH`. +pub const ADAPTER_BIN: &str = "claude-code-acp"; + +/// How to install the adapter when it is missing. Named in the error so an +/// operator staring at a failed session knows what to run. +pub const INSTALL_COMMAND: &str = + "npm install -g --prefix ~/.local @zed-industries/claude-code-acp"; + +/// How long `initialize` + `session/new` may take before the spawn is +/// declared failed. The real adapter boots a node process and the Claude +/// Code SDK behind it; a slow machine takes seconds, never a minute. +pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60); + +/// How long [`AcpSession::terminate`] waits after `session/cancel` before +/// sending `SIGTERM`. Cancel is a courtesy that lets the adapter finish the +/// turn it is on; the signal is what actually ends it. +pub const CANCEL_GRACE: Duration = Duration::from_millis(500); + +/// How long [`AcpSession::terminate`] waits after `SIGTERM` before `SIGKILL`. +pub const TERM_GRACE: Duration = Duration::from_secs(2); + +/// Bytes of the child's stderr kept for the failure message. +const STDERR_TAIL_BYTES: usize = 4096; + +/// What [`spawn`] needs to start one adapter. +#[derive(Debug, Clone)] +pub struct SpawnConfig { + /// Working directory of the child and `cwd` of `session/new`. + pub cwd: PathBuf, + /// Environment laid over the daemon's own for the child. A `PATH` in + /// here is also what the adapter is resolved against, since that is the + /// `PATH` the child would see. + pub env: HashMap, + /// Upper bound on the handshake; [`HANDSHAKE_TIMEOUT`] by default. + pub handshake_timeout: Duration, +} + +impl SpawnConfig { + pub fn new(cwd: impl Into, env: HashMap) -> Self { + Self { + cwd: cwd.into(), + env, + handshake_timeout: HANDSHAKE_TIMEOUT, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum AcpError { + /// The adapter is not on `PATH`. The message names the install command + /// so the failed session is actionable rather than silently dead. + #[error("{ADAPTER_BIN} not found on PATH; install it with: {INSTALL_COMMAND}")] + AdapterMissing, + #[error("failed to start {ADAPTER_BIN}: {0}")] + Spawn(#[source] std::io::Error), + /// The adapter answered the handshake with an error. + #[error("ACP handshake failed: {0}")] + Handshake(String), + /// The adapter went away before the handshake finished. Carries the + /// tail of its stderr, which is where a missing Claude login shows up. + #[error("{ADAPTER_BIN} exited during the ACP handshake ({status}){stderr}")] + ExitedEarly { status: String, stderr: String }, + #[error("ACP handshake timed out after {0:?}")] + HandshakeTimeout(Duration), +} + +/// Resolve [`ADAPTER_BIN`] against the `PATH` the child would run with: the +/// session env's `PATH` when it sets one, the daemon's own otherwise. +pub fn resolve_adapter(env: &HashMap) -> Option { + let path: OsString = match env.get("PATH") { + Some(p) => OsString::from(p), + None => std::env::var_os("PATH")?, + }; + std::env::split_paths(&path) + .filter(|dir| !dir.as_os_str().is_empty()) + .map(|dir| dir.join(ADAPTER_BIN)) + .find(|candidate| is_executable(candidate)) +} + +fn is_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path) + .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +enum Command { + Cancel, + Close, +} + +/// Why the handshake did not produce a session id. +enum ReadyError { + /// The adapter answered with an error. + Handshake(String), + /// The transport closed under the request: the child is gone. + Closed, +} + +fn ready_error(e: agent_client_protocol::Error) -> ReadyError { + if agent_client_protocol::is_incoming_transport_closed(&e) { + ReadyError::Closed + } else { + ReadyError::Handshake(e.to_string()) + } +} + +/// A live ACP connection to one adapter child. Dropping it sends `SIGTERM` +/// if the child is still running; [`AcpSession::terminate`] is the polite +/// version. +#[derive(Debug)] +pub struct AcpSession { + session_id: String, + pid: u32, + commands: mpsc::UnboundedSender, + updates: Mutex>>, + exit: watch::Receiver>, +} + +impl AcpSession { + /// The id `session/new` returned. What a respawn passes to + /// `session/load` (#135). + pub fn session_id(&self) -> &str { + &self.session_id + } + + /// The adapter's pid. + pub fn pid(&self) -> u32 { + self.pid + } + + /// Whether the child has been reaped. + pub fn has_exited(&self) -> bool { + self.exit.borrow().is_some() + } + + /// Wait for the child to exit. + pub async fn wait(&self) -> Option { + wait_exit(&self.exit).await + } + + /// The stream of raw `session/update` notifications, handed out once. + /// Notifications that arrived before the take are still queued on it. + pub fn take_updates(&self) -> Option> { + self.updates.lock().expect("updates mutex").take() + } + + /// Send `session/cancel`. Fire-and-forget: a closed connection means the + /// child is already gone, which is the outcome cancel was after. + pub fn cancel(&self) { + let _ = self.commands.send(Command::Cancel); + } + + /// End the child: `session/cancel`, wait `cancel_grace`, `SIGTERM`, wait + /// `term_grace`, `SIGKILL`. Returns once the child has been reaped. + pub async fn terminate(&self, cancel_grace: Duration, term_grace: Duration) { + if self.has_exited() { + return; + } + self.cancel(); + if tokio::time::timeout(cancel_grace, self.wait()) + .await + .is_ok() + { + return; + } + self.signal(libc::SIGTERM); + if tokio::time::timeout(term_grace, self.wait()).await.is_ok() { + return; + } + tracing::warn!( + pid = self.pid, + "{ADAPTER_BIN} ignored SIGTERM; sending SIGKILL" + ); + self.signal(libc::SIGKILL); + let _ = self.commands.send(Command::Close); + self.wait().await; + } + + fn signal(&self, sig: libc::c_int) { + signal_pid(self.pid, sig); + } +} + +impl Drop for AcpSession { + fn drop(&mut self) { + let _ = self.commands.send(Command::Close); + if !self.has_exited() { + self.signal(libc::SIGTERM); + } + } +} + +/// Start the adapter for one session and run the handshake. +/// +/// Fails without a running child: on any error the process is already +/// signalled, so a failed session never leaves a stray adapter behind. +pub async fn spawn(config: SpawnConfig) -> Result { + let adapter = resolve_adapter(&config.env).ok_or(AcpError::AdapterMissing)?; + + let mut cmd = tokio::process::Command::new(&adapter); + cmd.current_dir(&config.cwd) + .envs(&config.env) + // Claude Code refuses to start when it thinks it is nested inside + // another Claude Code session; a daemon launched from one would + // otherwise never get a structured session up. + .env_remove("CLAUDECODE") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + // If the daemon is dropped without a graceful shutdown the runtime + // still tears the task holding this `Child` down, and that kills it. + .kill_on_drop(true); + #[cfg(target_os = "linux")] + { + // Same process group as the daemon (no setpgid), and SIGTERM when the + // daemon dies however it dies: an agent that outlives its daemon is + // exactly the orphan #134 rules out. + // SAFETY: prctl is async-signal-safe and touches no shared state. + unsafe { + cmd.pre_exec(|| { + libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM); + Ok(()) + }); + } + } + let mut child = cmd.spawn().map_err(AcpError::Spawn)?; + let pid = child.id().expect("child pid is known before wait"); + let stdin = child.stdin.take().expect("piped stdin"); + let stdout = child.stdout.take().expect("piped stdout"); + let stderr = child.stderr.take().expect("piped stderr"); + + let stderr_tail = Arc::new(Mutex::new(VecDeque::::new())); + tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone())); + + let (exit_tx, exit_rx) = watch::channel(None); + tokio::spawn(async move { + let status = child.wait().await.ok(); + tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited"); + let _ = exit_tx.send(status); + }); + + let (updates_tx, updates_rx) = mpsc::unbounded_channel(); + let (commands_tx, commands_rx) = mpsc::unbounded_channel(); + let (ready_tx, ready_rx) = oneshot::channel(); + let transport = ByteStreams::new(stdin.compat_write(), stdout.compat()); + tokio::spawn(run_connection( + transport, + config.cwd.clone(), + updates_tx, + commands_rx, + ready_tx, + )); + + let outcome = tokio::time::timeout(config.handshake_timeout, ready_rx).await; + let failure = match outcome { + Ok(Ok(Ok(session_id))) => { + return Ok(AcpSession { + session_id, + pid, + commands: commands_tx, + updates: Mutex::new(Some(updates_rx)), + exit: exit_rx, + }); + } + Ok(Ok(Err(ReadyError::Handshake(message)))) => AcpError::Handshake(message), + Ok(Ok(Err(ReadyError::Closed))) | Ok(Err(_)) => { + // The connection ended before the handshake did: the child went + // away. Reap it so the status is real, then report. + let status = wait_exit(&exit_rx).await; + let stderr = { + let mut tail = stderr_tail.lock().expect("stderr tail"); + tail.make_contiguous(); + String::from_utf8_lossy(tail.as_slices().0) + .trim() + .to_string() + }; + AcpError::ExitedEarly { + status: status.map_or_else(|| "unknown status".into(), |s| s.to_string()), + stderr: if stderr.is_empty() { + String::new() + } else { + format!(": {stderr}") + }, + } + } + Err(_) => AcpError::HandshakeTimeout(config.handshake_timeout), + }; + // No half-started adapter survives a failed spawn. + let _ = commands_tx.send(Command::Close); + signal_pid(pid, libc::SIGTERM); + if tokio::time::timeout(TERM_GRACE, wait_exit(&exit_rx)) + .await + .is_err() + { + signal_pid(pid, libc::SIGKILL); + wait_exit(&exit_rx).await; + } + Err(failure) +} + +async fn wait_exit(exit: &watch::Receiver>) -> Option { + let mut exit = exit.clone(); + let status = exit.wait_for(|s| s.is_some()).await.ok()?; + *status +} + +fn signal_pid(pid: u32, sig: libc::c_int) { + // ESRCH just means the child is already gone. + // SAFETY: plain syscall on a pid we spawned and have not reaped. + unsafe { + libc::kill(pid as libc::pid_t, sig); + } +} + +/// The capabilities the daemon declares: none of the filesystem or terminal +/// surface, so the adapter falls back to Claude Code's own tools. +fn client_capabilities() -> ClientCapabilities { + ClientCapabilities::new() + .fs(FileSystemCapabilities::new() + .read_text_file(false) + .write_text_file(false)) + .terminal(false) +} + +async fn run_connection( + transport: T, + cwd: PathBuf, + updates_tx: mpsc::UnboundedSender, + mut commands_rx: mpsc::UnboundedReceiver, + ready_tx: oneshot::Sender>, +) where + T: agent_client_protocol::ConnectTo + 'static, +{ + let result = Client + .builder() + .name("maestro") + .on_receive_notification( + async move |notification: SessionNotification, _cx| { + // A dropped receiver only means nobody wants updates any more. + let _ = updates_tx.send(notification); + Ok(()) + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_request( + async move |request: RequestPermissionRequest, responder, _cx| { + // Stub until #139: decline rather than grant something the + // operator never saw. + tracing::warn!( + session = %request.session_id, + "session/request_permission declined: not implemented yet (#139)" + ); + responder.respond(RequestPermissionResponse::new( + RequestPermissionOutcome::Cancelled, + )) + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_with(transport, async move |cx| { + let init = InitializeRequest::new(ProtocolVersion::V1) + .client_capabilities(client_capabilities()) + .client_info(Implementation::new("maestro", env!("CARGO_PKG_VERSION"))); + if let Err(e) = cx.send_request(init).block_task().await { + let _ = ready_tx.send(Err(ready_error(e))); + return Ok(()); + } + let created = match cx + .send_request(NewSessionRequest::new(cwd)) + .block_task() + .await + { + Ok(r) => r, + Err(e) => { + let _ = ready_tx.send(Err(ready_error(e))); + return Ok(()); + } + }; + let session_id = created.session_id.0.to_string(); + let _ = ready_tx.send(Ok(session_id.clone())); + + loop { + tokio::select! { + cmd = commands_rx.recv() => match cmd { + Some(Command::Cancel) => { + let _ = cx.send_notification(CancelNotification::new( + SessionId::new(session_id.as_str()), + )); + } + Some(Command::Close) | None => break, + }, + _ = cx.incoming_closed() => break, + } + } + Ok(()) + }) + .await; + if let Err(e) = result { + tracing::debug!(error = %e, "ACP connection ended with an error"); + } +} + +async fn drain_stderr( + stderr: tokio::process::ChildStderr, + pid: u32, + tail: Arc>>, +) { + let mut lines = tokio::io::BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + tracing::debug!(pid, "{ADAPTER_BIN}: {line}"); + let mut tail = tail.lock().expect("stderr tail"); + tail.extend(line.as_bytes()); + tail.push_back(b'\n'); + while tail.len() > STDERR_TAIL_BYTES { + tail.pop_front(); + } + tail.make_contiguous(); + } +} diff --git a/crates/maestro-acp/tests/fake_acp_agent.py b/crates/maestro-acp/tests/fake_acp_agent.py new file mode 100755 index 0000000..34d603d --- /dev/null +++ b/crates/maestro-acp/tests/fake_acp_agent.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""A deterministic stand-in for `claude-code-acp` (#134). + +Speaks just enough JSON-RPC over stdio for the daemon's handshake: +answers `initialize` and `session/new`, logs every method it receives, and +exits on SIGTERM. Tests point `PATH` at a directory holding this file under +the adapter's name, so the real adapter (which needs Claude auth and is +slow) never runs in the deterministic suite. + +Environment knobs, all optional: + + FAKE_ACP_LOG append one line per event to this file: + start pid= cwd= + env = (for FAKE_ACP_ECHO_ENV) + caps + recv + signal SIGTERM + FAKE_ACP_ECHO_ENV name of one env var to log at start + FAKE_ACP_INIT_FAIL=1 answer `initialize` with a JSON-RPC error + FAKE_ACP_EXIT_AFTER_NEW=1 exit 0 right after answering `session/new` + FAKE_ACP_EXIT_ON_CANCEL=1 exit 0 on `session/cancel` (default: keep running + so the SIGTERM path is exercised) +""" +import json +import os +import signal +import sys + +LOG = os.environ.get("FAKE_ACP_LOG") + + +def log(line): + if not LOG: + return + with open(LOG, "a") as f: + f.write(line + "\n") + f.flush() + + +def send(obj): + sys.stdout.write(json.dumps(obj) + "\n") + sys.stdout.flush() + + +def on_sigterm(_signum, _frame): + log("signal SIGTERM") + sys.exit(0) + + +signal.signal(signal.SIGTERM, on_sigterm) + +log(f"start pid={os.getpid()} cwd={os.getcwd()}") +echo = os.environ.get("FAKE_ACP_ECHO_ENV") +if echo: + log(f"env {echo}={os.environ.get(echo, '')}") + +session_id = f"fake-{os.getpid()}" + +for raw in sys.stdin: + raw = raw.strip() + if not raw: + continue + msg = json.loads(raw) + method = msg.get("method") + if method is None: + # A response to something we sent; nothing to do. + continue + log(f"recv {method}") + rid = msg.get("id") + params = msg.get("params") or {} + + if method == "initialize": + log("caps " + json.dumps(params.get("clientCapabilities", {}), sort_keys=True)) + if os.environ.get("FAKE_ACP_INIT_FAIL") == "1": + send({"jsonrpc": "2.0", "id": rid, + "error": {"code": -32000, "message": "fake init failure"}}) + continue + send({"jsonrpc": "2.0", "id": rid, "result": { + "protocolVersion": params.get("protocolVersion", 1), + "agentCapabilities": {"loadSession": True}, + "agentInfo": {"name": "fake-acp-agent", "version": "0.0.0"}, + "authMethods": [], + }}) + elif method == "session/new": + send({"jsonrpc": "2.0", "id": rid, "result": {"sessionId": session_id}}) + send({"jsonrpc": "2.0", "method": "session/update", "params": { + "sessionId": session_id, + "update": {"sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": "hello from fake"}}, + }}) + if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1": + sys.exit(0) + elif method == "session/cancel": + if os.environ.get("FAKE_ACP_EXIT_ON_CANCEL") == "1": + sys.exit(0) + elif rid is not None: + send({"jsonrpc": "2.0", "id": rid, + "error": {"code": -32601, "message": f"method not found: {method}"}}) diff --git a/crates/maestro-acp/tests/spawn.rs b/crates/maestro-acp/tests/spawn.rs new file mode 100644 index 0000000..3118592 --- /dev/null +++ b/crates/maestro-acp/tests/spawn.rs @@ -0,0 +1,292 @@ +//! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus +//! one ignored test against the real `claude-code-acp`. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use maestro_acp::schema::{ContentBlock, SessionUpdate}; +use maestro_acp::{AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND}; +use tempfile::TempDir; + +fn fake_agent() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fake_acp_agent.py") +} + +/// A temp `bin/` holding the fake agent under the adapter's name, plus a +/// log file the agent appends to. `PATH` for the spawn is just that dir. +struct Fake { + dir: TempDir, +} + +impl Fake { + fn new() -> Self { + let dir = TempDir::new().unwrap(); + let bin = dir.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap(); + Self { dir } + } + + fn log_path(&self) -> PathBuf { + self.dir.path().join("agent.log") + } + + fn log(&self) -> String { + std::fs::read_to_string(self.log_path()).unwrap_or_default() + } + + /// The fake's `bin/` first, so it shadows any real adapter, then the + /// process `PATH` so the script's `python3` still resolves. + fn env(&self) -> HashMap { + let path = format!( + "{}:{}", + self.dir.path().join("bin").display(), + std::env::var("PATH").unwrap_or_default() + ); + HashMap::from([ + ("PATH".to_string(), path), + ( + "FAKE_ACP_LOG".to_string(), + self.log_path().to_string_lossy().into_owned(), + ), + ]) + } + + fn config(&self, cwd: &Path) -> SpawnConfig { + let mut config = SpawnConfig::new(cwd, self.env()); + config.handshake_timeout = Duration::from_secs(10); + config + } + + async fn wait_for_log(&self, needle: &str) { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if self.log().contains(needle) { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("log never contained {needle:?}:\n{}", self.log()); + } +} + +fn pid_alive(pid: u32) -> bool { + Path::new(&format!("/proc/{pid}")).exists() + && std::fs::read_to_string(format!("/proc/{pid}/stat")) + .map(|s| !s.contains(") Z ")) + .unwrap_or(false) +} + +#[tokio::test] +async fn spawn_runs_adapter_in_cwd_with_env_and_restrictive_caps() { + let fake = Fake::new(); + let cwd = TempDir::new().unwrap(); + let mut config = fake.config(cwd.path()); + config + .env + .insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into()); + config + .env + .insert("MAESTRO_SESSION_ID".into(), "sess-123".into()); + + let session = maestro_acp::spawn(config).await.expect("spawn"); + assert!(session.session_id().starts_with("fake-")); + assert!(pid_alive(session.pid())); + + let log = fake.log(); + let cwd_canon = cwd.path().canonicalize().unwrap(); + assert!( + log.contains(&format!("cwd={}", cwd_canon.display())), + "child cwd missing from log:\n{log}" + ); + assert!(log.contains("env MAESTRO_SESSION_ID=sess-123"), "{log}"); + assert!(log.contains("recv initialize"), "{log}"); + assert!(log.contains("recv session/new"), "{log}"); + let caps = log + .lines() + .find_map(|l| l.strip_prefix("caps ")) + .expect("caps line"); + let caps: serde_json::Value = serde_json::from_str(caps).unwrap(); + assert_eq!(caps["fs"]["readTextFile"], false); + assert_eq!(caps["fs"]["writeTextFile"], false); + assert_eq!(caps["terminal"], false); + + session + .terminate(Duration::from_millis(100), Duration::from_secs(2)) + .await; +} + +#[tokio::test] +async fn missing_adapter_names_the_install_command() { + let empty = TempDir::new().unwrap(); + let cwd = TempDir::new().unwrap(); + let env = HashMap::from([( + "PATH".to_string(), + empty.path().to_string_lossy().into_owned(), + )]); + let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env)) + .await + .expect_err("no adapter on PATH"); + assert!(matches!(err, AcpError::AdapterMissing)); + let msg = err.to_string(); + assert!(msg.contains(ADAPTER_BIN), "{msg}"); + assert!(msg.contains(INSTALL_COMMAND), "{msg}"); +} + +#[tokio::test] +async fn session_updates_reach_the_taken_receiver() { + let fake = Fake::new(); + let cwd = TempDir::new().unwrap(); + let session = maestro_acp::spawn(fake.config(cwd.path())) + .await + .expect("spawn"); + + let mut updates = session.take_updates().expect("first take"); + assert!(session.take_updates().is_none(), "handed out once"); + let first = tokio::time::timeout(Duration::from_secs(5), updates.recv()) + .await + .expect("update within 5s") + .expect("channel open"); + assert_eq!(first.session_id.0.as_ref(), session.session_id()); + match first.update { + SessionUpdate::AgentMessageChunk(chunk) => match chunk.content { + ContentBlock::Text(t) => assert_eq!(t.text, "hello from fake"), + other => panic!("unexpected content {other:?}"), + }, + other => panic!("unexpected update {other:?}"), + } + + session + .terminate(Duration::from_millis(100), Duration::from_secs(2)) + .await; +} + +#[tokio::test] +async fn terminate_sends_cancel_then_sigterm() { + let fake = Fake::new(); + let cwd = TempDir::new().unwrap(); + let session = maestro_acp::spawn(fake.config(cwd.path())) + .await + .expect("spawn"); + let pid = session.pid(); + + session + .terminate(Duration::from_millis(200), Duration::from_secs(2)) + .await; + assert!(session.has_exited()); + fake.wait_for_log("signal SIGTERM").await; + + let log = fake.log(); + let cancel = log.find("recv session/cancel").expect("cancel logged"); + let term = log.find("signal SIGTERM").expect("SIGTERM logged"); + assert!(cancel < term, "cancel must precede SIGTERM:\n{log}"); + assert!(!pid_alive(pid), "child {pid} still alive"); +} + +#[tokio::test] +async fn terminate_is_satisfied_by_an_exit_on_cancel() { + let fake = Fake::new(); + let cwd = TempDir::new().unwrap(); + let mut config = fake.config(cwd.path()); + config + .env + .insert("FAKE_ACP_EXIT_ON_CANCEL".into(), "1".into()); + let session = maestro_acp::spawn(config).await.expect("spawn"); + + session + .terminate(Duration::from_secs(2), Duration::from_secs(2)) + .await; + assert!(session.has_exited()); + let log = fake.log(); + assert!(log.contains("recv session/cancel"), "{log}"); + assert!(!log.contains("signal SIGTERM"), "cancel was enough:\n{log}"); +} + +#[tokio::test] +async fn handshake_error_fails_the_spawn_and_reaps_the_child() { + let fake = Fake::new(); + let cwd = TempDir::new().unwrap(); + let mut config = fake.config(cwd.path()); + config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into()); + + let err = maestro_acp::spawn(config).await.expect_err("init fails"); + assert!(matches!(err, AcpError::Handshake(_)), "{err}"); + assert!(err.to_string().contains("fake init failure"), "{err}"); + // Closing the connection gives the fake EOF on stdin; the SIGTERM that + // follows may find it already gone. Either way it must not survive. + let pid: u32 = fake + .log() + .lines() + .find_map(|l| l.strip_prefix("start pid=")) + .and_then(|rest| rest.split(' ').next()) + .and_then(|p| p.parse().ok()) + .expect("pid in log"); + let deadline = Instant::now() + Duration::from_secs(5); + while pid_alive(pid) && Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!(!pid_alive(pid), "child {pid} outlived the failed spawn"); +} + +#[tokio::test] +async fn early_exit_is_observed_after_a_successful_handshake() { + let fake = Fake::new(); + let cwd = TempDir::new().unwrap(); + let mut config = fake.config(cwd.path()); + config + .env + .insert("FAKE_ACP_EXIT_AFTER_NEW".into(), "1".into()); + let session = maestro_acp::spawn(config).await.expect("spawn"); + + let status = tokio::time::timeout(Duration::from_secs(5), session.wait()) + .await + .expect("exit within 5s") + .expect("status"); + assert!(status.success(), "{status}"); + assert!(session.has_exited()); +} + +#[tokio::test] +async fn child_exit_before_handshake_reports_exited_early() { + // A "adapter" that dies before answering anything. + let dir = TempDir::new().unwrap(); + let bin = dir.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let script = bin.join(ADAPTER_BIN); + std::fs::write(&script, "#!/bin/sh\necho 'not logged in' >&2\nexit 3\n").unwrap(); + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let cwd = TempDir::new().unwrap(); + let env = HashMap::from([("PATH".to_string(), bin.to_string_lossy().into_owned())]); + let mut config = SpawnConfig::new(cwd.path(), env); + config.handshake_timeout = Duration::from_secs(10); + + let err = maestro_acp::spawn(config).await.expect_err("child died"); + match &err { + AcpError::ExitedEarly { status, stderr } => { + assert!(status.contains('3'), "{status}"); + assert!(stderr.contains("not logged in"), "{stderr}"); + } + other => panic!("unexpected {other:?}"), + } +} + +/// End to end against the installed `claude-code-acp`. Needs Claude auth, +/// takes seconds, so it is opt-in: `cargo test -p maestro-acp -- --ignored`. +#[tokio::test] +#[ignore] +async fn real_adapter_handshake() { + let cwd = TempDir::new().unwrap(); + let session = maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new())) + .await + .expect("real claude-code-acp handshake"); + assert!(!session.session_id().is_empty()); + assert!(pid_alive(session.pid())); + session + .terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE) + .await; + assert!(session.has_exited()); +}