дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
OpenCode handles /compact through its ACP session but does not list it in availableCommands, so the composer never offered it. Agent builtins the agent implements without advertising are now merged into slash-command completion without duplicating listed commands; the table holds only commands verified against the agent (OpenCode /compact). Claude Code already advertises /compact. Executable lookup keeps using the shared PATHEXT-aware resolution on main, with a test that the newest install across PATH entries is chosen. Co-authored-by: a-rookie-of-C-language <[email protected]>
148 рядки
6.6 KiB
Rust
148 рядки
6.6 KiB
Rust
//! Agents driven over their own protocol rather than ACP: `codex app-server` and Claude
|
|
//! Code's stream-json mode. Each runs behind an in-process ACP endpoint (a shim), so a
|
|
//! task drives every agent the same way and reads what it can do from `initialize`.
|
|
//!
|
|
//! A shim speaks ACP v1 as the agent side, plus:
|
|
//! - `agentCapabilities.sessionCapabilities.fork` and `session/fork`
|
|
//! (`{ sessionId, cwd, mcpServers }` → a `session/new` result for the copy);
|
|
//! - `agentCapabilities._meta.openidea.rollback` and `_openidea/rollback`
|
|
//! (`{ sessionId, turns }`: drops the last `turns` user turns from the session);
|
|
//! - `agentCapabilities._meta.openidea.steer` and `_openidea/steer`
|
|
//! (`{ sessionId, prompt }`: adds ACP content blocks to the running turn; fails when
|
|
//! no turn runs).
|
|
|
|
pub(crate) mod claude;
|
|
pub(crate) mod codex;
|
|
|
|
use ide_jsonrpc::{Framing, Handler, Peer};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
/// The newest install of `command` on the `PATH` a child sees, by its `--version`.
|
|
/// Several installs are common (npm, Homebrew, the vendor's own installer), and the first
|
|
/// on `PATH` is often an old one that the service no longer serves the current models to.
|
|
/// A path, or a name not found on `PATH`, is used as it is; a single match returns its
|
|
/// resolved path so Windows `PATHEXT` shims (`claude.cmd`, `codex.cmd`) start.
|
|
pub(crate) fn newest_install(command: &str) -> String {
|
|
let path = ide_core::process::host_var_os("PATH")
|
|
.or_else(|| ide_core::process::shell_env().find(|(k, _)| *k == "PATH").map(|(_, v)| v.to_owned()))
|
|
.unwrap_or_default();
|
|
newest_install_in(command, &path)
|
|
}
|
|
|
|
fn newest_install_in(command: &str, search: &std::ffi::OsStr) -> String {
|
|
if Path::new(command).components().count() > 1 || Path::new(command).is_absolute() {
|
|
return command.to_owned();
|
|
}
|
|
let pathext = ide_core::process::host_var_os("PATHEXT");
|
|
let names =
|
|
ide_core::process::executable_names(command, cfg!(windows), pathext.as_deref().and_then(|p| p.to_str()));
|
|
let mut seen = Vec::new();
|
|
let candidates: Vec<PathBuf> = std::env::split_paths(search)
|
|
.filter(|d| !d.as_os_str().is_empty())
|
|
.flat_map(|dir| names.iter().map(move |n| dir.join(n)))
|
|
.filter(|p| p.is_file())
|
|
.filter(|p| {
|
|
let real = dunce::canonicalize(p).unwrap_or_else(|_| p.clone());
|
|
let new = !seen.contains(&real);
|
|
seen.push(real);
|
|
new
|
|
})
|
|
.collect();
|
|
if candidates.len() < 2 {
|
|
return candidates.into_iter().next().map_or_else(|| command.to_owned(), |p| p.to_string_lossy().into_owned());
|
|
}
|
|
candidates
|
|
.into_iter()
|
|
.filter_map(|p| version_of(&p).map(|v| (v, p)))
|
|
.max_by(|a, b| a.0.cmp(&b.0))
|
|
.map_or_else(|| command.to_owned(), |(_, p)| p.to_string_lossy().into_owned())
|
|
}
|
|
|
|
/// `x.y.z` from `<program> --version` ("codex-cli 0.164.0", "2.1.312 (Claude Code)").
|
|
fn version_of(program: &Path) -> Option<Vec<u64>> {
|
|
let mut cmd = std::process::Command::new(program);
|
|
ide_core::process::prepare(&mut cmd);
|
|
let out = cmd.arg("--version").envs(ide_core::process::shell_env()).output().ok()?;
|
|
parse_version(&ide_core::output_encoding::decode_tool_output(&out.stdout))
|
|
}
|
|
|
|
fn parse_version(text: &str) -> Option<Vec<u64>> {
|
|
text.split_whitespace().find_map(|word| {
|
|
let parts: Vec<u64> = word.trim_start_matches('v').split('.').map_while(|p| p.parse().ok()).collect();
|
|
(parts.len() >= 2).then_some(parts)
|
|
})
|
|
}
|
|
|
|
/// The agent side of an in-process ACP connection.
|
|
pub(crate) trait Shim: Handler {
|
|
/// Called once, before any message arrives: the connection to the client, for
|
|
/// `session/update` notifications and `session/request_permission` requests.
|
|
fn attach(&self, client: Peer);
|
|
}
|
|
|
|
/// Connects `client` to `shim`; returns the client's side. When the client goes away the
|
|
/// shim's connection closes (`Peer::closed`); a shim closes the client's by killing its
|
|
/// own side (`Peer::kill`) when its process exits.
|
|
pub(crate) fn serve<S: Shim>(shim: Arc<S>, client: Arc<dyn Handler>) -> Peer {
|
|
let (ours, theirs) = tokio::io::duplex(1 << 20);
|
|
let (or, ow) = tokio::io::split(ours);
|
|
let (tr, tw) = tokio::io::split(theirs);
|
|
let to_client = Peer::connect_framed(tr, tw, shim.clone(), Framing::Lines);
|
|
shim.attach(to_client);
|
|
Peer::connect_framed(or, ow, client, Framing::Lines)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn versions_are_read_from_either_side_of_the_name() {
|
|
assert_eq!(parse_version("codex-cli 0.164.0\n"), Some(vec![0, 164, 0]));
|
|
assert_eq!(parse_version("2.1.312 (Claude Code)"), Some(vec![2, 1, 312]));
|
|
assert_eq!(parse_version("v1.2"), Some(vec![1, 2]));
|
|
assert_eq!(parse_version("no version here"), None);
|
|
assert!(parse_version("codex-cli 0.164.0") > parse_version("codex-cli 0.147.0"));
|
|
}
|
|
|
|
#[test]
|
|
fn newest_install_resolves_single_candidate_and_keeps_missing_or_paths() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let a = dir.path().join("a");
|
|
let b = dir.path().join("b");
|
|
std::fs::create_dir_all(&a).unwrap();
|
|
std::fs::create_dir_all(&b).unwrap();
|
|
std::fs::write(a.join("claude"), "#!/bin/sh\n").unwrap();
|
|
std::fs::write(b.join("claude.cmd"), "@echo off\n").unwrap();
|
|
let search = std::env::join_paths([&a, &b]).unwrap();
|
|
if cfg!(windows) {
|
|
assert_eq!(newest_install_in("claude", &search), b.join("claude.cmd").to_string_lossy());
|
|
} else {
|
|
assert_eq!(newest_install_in("claude", &search), a.join("claude").to_string_lossy());
|
|
}
|
|
assert_eq!(newest_install_in("nope", &search), "nope");
|
|
assert_eq!(newest_install_in("bin/claude", &search), "bin/claude");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn newest_install_compares_versions_across_path_entries() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let old = dir.path().join("old");
|
|
let new = dir.path().join("new");
|
|
for (path, version) in [(&old, "2.9.0"), (&new, "2.10.0")] {
|
|
std::fs::create_dir_all(path).unwrap();
|
|
let executable = path.join("agent");
|
|
std::fs::write(&executable, format!("#!/bin/sh\necho {version}\n")).unwrap();
|
|
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
}
|
|
for paths in [[&old, &new], [&new, &old]] {
|
|
let search = std::env::join_paths(paths).unwrap();
|
|
assert_eq!(newest_install_in("agent", &search), new.join("agent").to_string_lossy());
|
|
}
|
|
}
|
|
}
|