diff --git a/crates/adapter-antigravity/src/main.rs b/crates/adapter-antigravity/src/main.rs index 27903b5c..1b9edf90 100644 --- a/crates/adapter-antigravity/src/main.rs +++ b/crates/adapter-antigravity/src/main.rs @@ -44,7 +44,8 @@ //! (antigravity currently nests under the gemini dir); override with //! `AGENTD_ANTIGRAVITY_HOME`. //! -//! Env overrides: `AGENTD_ANTIGRAVITY_BIN` (binary, default `agy`), +//! Env overrides: `AGENTD_ANTIGRAVITY_CMD` (full command prefix), +//! `AGENTD_ANTIGRAVITY_BIN` (binary, default `agy`), //! `AGENTD_ANTIGRAVITY_MODE` (`interactive`|`headless`). use agentd_protocol::adapter::pty::{run_session as run_pty, PtySpec}; @@ -106,8 +107,12 @@ fn resolve_mode(params: &SessionStartParams) -> Mode { } } -fn bin() -> String { - std::env::var("AGENTD_ANTIGRAVITY_BIN").unwrap_or_else(|_| "agy".into()) +fn command_override() -> agentd_protocol::adapter::CommandOverride { + agentd_protocol::adapter::resolve_command_override( + "AGENTD_ANTIGRAVITY_CMD", + "AGENTD_ANTIGRAVITY_BIN", + "agy", + ) } fn session_data_dir() -> Option { @@ -181,8 +186,9 @@ fn parse_conversation_id(log_text: &str) -> Option { } async fn run_interactive(params: SessionStartParams, ctx: AdapterContext) { - let bin = bin(); - let mut args = params.args.clone(); + let command = command_override(); + let mut args = command.args.clone(); + args.extend(params.args.clone()); let resuming = std::env::var("AGENTD_RESUME").as_deref() == Ok("1"); let log_path = session_data_dir().map(|d| d.join("agy.log")); @@ -226,7 +232,8 @@ async fn run_interactive(params: SessionStartParams, ctx: AdapterContext) { .collect(); env.push(("AGENTD_SESSION_ID".into(), ctx.session_id.clone())); - let label = bin.clone(); + let label = command.argv_preview(); + let bin = command.bin; let spec = PtySpec { bin, args, @@ -262,7 +269,7 @@ async fn run_session(params: SessionStartParams, ctx: AdapterContext) { mut inbox, } = ctx; - let bin = bin(); + let command_override = command_override(); let cwd = PathBuf::from(¶ms.cwd); let extra_args = params.args.clone(); let mut env: Vec<(String, String)> = params @@ -325,7 +332,7 @@ async fn run_session(params: SessionStartParams, ctx: AdapterContext) { .map(|d| d.join("agy-headless.log")) .unwrap_or_else(|| PathBuf::from("agy-headless.log")); - let mut child_args: Vec = Vec::new(); + let mut child_args: Vec = command_override.args.clone(); child_args.push("-p".into()); child_args.push(user_text.clone()); child_args.push("--dangerously-skip-permissions".into()); @@ -339,7 +346,7 @@ async fn run_session(params: SessionStartParams, ctx: AdapterContext) { child_args.push(a.clone()); } - let mut command = Command::new(&bin); + let mut command = Command::new(&command_override.bin); for a in &child_args { command.arg(a); } @@ -357,7 +364,10 @@ async fn run_session(params: SessionStartParams, ctx: AdapterContext) { Ok(c) => c, Err(e) => { emit.emit(SessionEvent::Error { - message: agentd_protocol::adapter::missing_bin_hint(&bin, &e), + message: agentd_protocol::adapter::missing_bin_hint( + &command_override.argv_preview(), + &e, + ), }); break 127; } diff --git a/crates/adapter-claude/src/main.rs b/crates/adapter-claude/src/main.rs index 93034863..d60bb8d2 100644 --- a/crates/adapter-claude/src/main.rs +++ b/crates/adapter-claude/src/main.rs @@ -16,7 +16,8 @@ //! `AGENTD_CLAUDE_MODE=interactive|headless`. Default is interactive when the //! client supplies a PTY size (the TUI always does); otherwise headless. //! -//! Honors `AGENTD_CLAUDE_BIN` for the binary path. +//! Honors `AGENTD_CLAUDE_CMD` for a full command prefix, falling back to +//! `AGENTD_CLAUDE_BIN` for a binary path. use agentd_protocol::adapter::pty::{run_session as run_pty, PtySpec}; use agentd_protocol::adapter::{run, AdapterContext, AdapterInboxMsg, EventEmitter}; @@ -78,8 +79,13 @@ fn resolve_mode(params: &SessionStartParams) -> Mode { } async fn run_interactive(params: SessionStartParams, ctx: AdapterContext) { - let bin = std::env::var("AGENTD_CLAUDE_BIN").unwrap_or_else(|_| "claude".into()); - let mut args = params.args.clone(); + let command = agentd_protocol::adapter::resolve_command_override( + "AGENTD_CLAUDE_CMD", + "AGENTD_CLAUDE_BIN", + "claude", + ); + let mut args = command.args.clone(); + args.extend(params.args.clone()); if let Some(m) = params.model.as_ref() { args.push("--model".into()); args.push(m.clone()); @@ -97,10 +103,9 @@ async fn run_interactive(params: SessionStartParams, ctx: AdapterContext) { // daemon respawns us after a restart. claude's own session-persistence // makes the conversation pick up where it left off. let resuming = std::env::var("AGENTD_RESUME").as_deref() == Ok("1"); - let sid_file = - std::env::var("AGENTD_SESSION_DATA_DIR").ok().map(|d| { - std::path::PathBuf::from(d).join("claude_session_id.txt") - }); + let sid_file = std::env::var("AGENTD_SESSION_DATA_DIR") + .ok() + .map(|d| std::path::PathBuf::from(d).join("claude_session_id.txt")); let claude_session_id = match (resuming, sid_file.as_ref()) { (true, Some(p)) if p.exists() => std::fs::read_to_string(p) .ok() @@ -133,13 +138,17 @@ async fn run_interactive(params: SessionStartParams, ctx: AdapterContext) { .map(|(k, v)| (k.clone(), v.clone())) .collect(); env.push(("AGENTD_SESSION_ID".into(), ctx.session_id.clone())); - let label = bin.clone(); + let label = command.argv_preview(); + let bin = command.bin; let spec = PtySpec { bin, args, cwd: std::path::PathBuf::from(¶ms.cwd), env, - size: params.pty_size.unwrap_or(PtySize { cols: 100, rows: 30 }), + size: params.pty_size.unwrap_or(PtySize { + cols: 100, + rows: 30, + }), status_detail: Some(format!("{label} (interactive)")), }; let _ = run_pty(spec, ctx).await; @@ -152,7 +161,11 @@ async fn run_session(params: SessionStartParams, ctx: AdapterContext) { mut inbox, } = ctx; - let bin = std::env::var("AGENTD_CLAUDE_BIN").unwrap_or_else(|_| "claude".into()); + let command_override = agentd_protocol::adapter::resolve_command_override( + "AGENTD_CLAUDE_CMD", + "AGENTD_CLAUDE_BIN", + "claude", + ); let cwd = PathBuf::from(¶ms.cwd); let model = params.model.clone(); let extra_args = params.args.clone(); @@ -198,7 +211,7 @@ async fn run_session(params: SessionStartParams, ctx: AdapterContext) { }); // Build the per-turn child command args. - let mut child_args: Vec = Vec::new(); + let mut child_args: Vec = command_override.args.clone(); child_args.push("-p".into()); child_args.push("--input-format".into()); child_args.push("stream-json".into()); @@ -220,7 +233,7 @@ async fn run_session(params: SessionStartParams, ctx: AdapterContext) { for a in &extra_args { child_args.push(a.clone()); } - let mut command = Command::new(&bin); + let mut command = Command::new(&command_override.bin); for a in &child_args { command.arg(a); } @@ -239,7 +252,10 @@ async fn run_session(params: SessionStartParams, ctx: AdapterContext) { Ok(c) => c, Err(e) => { emit.emit(SessionEvent::Error { - message: agentd_protocol::adapter::missing_bin_hint(&bin, &e), + message: agentd_protocol::adapter::missing_bin_hint( + &command_override.argv_preview(), + &e, + ), }); break 127; } @@ -253,8 +269,7 @@ async fn run_session(params: SessionStartParams, ctx: AdapterContext) { let writer_task = spawn_writer(child_stdin, user_text.clone()); let stderr_task = spawn_stderr_log(child_stderr, emit.clone()); let captured_sid = Arc::new(StdMutex::new(None::)); - let parser_task = - spawn_parser(child_stdout, emit.clone(), captured_sid.clone()); + let parser_task = spawn_parser(child_stdout, emit.clone(), captured_sid.clone()); // Drive the child: queue mid-turn inputs, honor stop/interrupt. let outcome = drive_turn(&mut child, &mut inbox, &emit, &mut pending).await; @@ -333,7 +348,10 @@ async fn drive_turn( } } -fn spawn_writer(mut stdin: tokio::process::ChildStdin, user_text: String) -> tokio::task::JoinHandle<()> { +fn spawn_writer( + mut stdin: tokio::process::ChildStdin, + user_text: String, +) -> tokio::task::JoinHandle<()> { let msg = serde_json::json!({ "type": "user", "message": { @@ -486,7 +504,10 @@ fn extract_message_text(msg: Option<&Value>) -> String { } fn forward_tool_uses(emit: &EventEmitter, msg: Option<&Value>) { - let Some(arr) = msg.and_then(|m| m.get("content")).and_then(|c| c.as_array()) else { + let Some(arr) = msg + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + else { return; }; for block in arr { @@ -506,7 +527,10 @@ fn forward_tool_uses(emit: &EventEmitter, msg: Option<&Value>) { } fn forward_tool_results(emit: &EventEmitter, msg: Option<&Value>) { - let Some(arr) = msg.and_then(|m| m.get("content")).and_then(|c| c.as_array()) else { + let Some(arr) = msg + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + else { return; }; for block in arr { diff --git a/crates/adapter-codex/src/main.rs b/crates/adapter-codex/src/main.rs index 383fd891..4380c59d 100644 --- a/crates/adapter-codex/src/main.rs +++ b/crates/adapter-codex/src/main.rs @@ -12,8 +12,8 @@ //! `session_id` back in for subsequent turns. //! //! Pick mode via `--mode interactive|headless` on `agent new`, or via -//! `AGENTD_CODEX_MODE=interactive|headless`. Honors `AGENTD_CODEX_BIN` for -//! the binary path. +//! `AGENTD_CODEX_MODE=interactive|headless`. Honors `AGENTD_CODEX_CMD` for a +//! full command prefix, falling back to `AGENTD_CODEX_BIN` for a binary path. use agentd_protocol::adapter::pty::{run_session as run_pty, PtySpec}; use agentd_protocol::adapter::{run, AdapterContext, AdapterInboxMsg, EventEmitter}; @@ -75,8 +75,13 @@ fn resolve_mode(params: &SessionStartParams) -> Mode { } async fn run_interactive(params: SessionStartParams, ctx: AdapterContext) { - let bin = std::env::var("AGENTD_CODEX_BIN").unwrap_or_else(|_| "codex".into()); - let mut args = params.args.clone(); + let command = agentd_protocol::adapter::resolve_command_override( + "AGENTD_CODEX_CMD", + "AGENTD_CODEX_BIN", + "codex", + ); + let mut args = command.args.clone(); + args.extend(params.args.clone()); // Resume support: codex doesn't let the client assign a session id, so // we tag each spawn with a unique `originator` (via codex's internal // env override) and watch its rollouts dir for one bearing that tag. @@ -92,9 +97,9 @@ async fn run_interactive(params: SessionStartParams, ctx: AdapterContext) { // identical PTY content. Starting a fresh codex loses one session's // conversation but never conflates two of them. let resuming = std::env::var("AGENTD_RESUME").as_deref() == Ok("1"); - let sid_file = std::env::var("AGENTD_SESSION_DATA_DIR").ok().map(|d| { - std::path::PathBuf::from(d).join("codex_session_id.txt") - }); + let sid_file = std::env::var("AGENTD_SESSION_DATA_DIR") + .ok() + .map(|d| std::path::PathBuf::from(d).join("codex_session_id.txt")); let mut captured_id: Option = None; if resuming { let explicit = std::env::var("AGENTD_CODEX_RESUME_ID").ok(); @@ -169,13 +174,17 @@ async fn run_interactive(params: SessionStartParams, ctx: AdapterContext) { ); } } - let label = bin.clone(); + let label = command.argv_preview(); + let bin = command.bin; let spec = PtySpec { bin, args, cwd: std::path::PathBuf::from(¶ms.cwd), env, - size: params.pty_size.unwrap_or(PtySize { cols: 100, rows: 30 }), + size: params.pty_size.unwrap_or(PtySize { + cols: 100, + rows: 30, + }), status_detail: Some(format!("{label} (interactive)")), }; let _ = run_pty(spec, ctx).await; @@ -207,9 +216,7 @@ fn spawn_session_id_watcher( } } let Some(sessions_root) = codex_sessions_root(&session_env) else { - emit.log( - "codex: no CODEX_HOME or HOME — cannot watch sessions dir for session-id capture", - ); + emit.log("codex: no CODEX_HOME or HOME — cannot watch sessions dir for session-id capture"); return; }; tokio::spawn(async move { @@ -234,11 +241,7 @@ fn spawn_session_id_watcher( not_ours.insert(name); continue; } - let uuid = match meta - .id - .clone() - .or_else(|| uuid_from_rollout_name(&name)) - { + let uuid = match meta.id.clone().or_else(|| uuid_from_rollout_name(&name)) { Some(u) => u, None => { emit.log(format!( @@ -363,7 +366,11 @@ async fn run_session(params: SessionStartParams, ctx: AdapterContext) { mut inbox, } = ctx; - let bin = std::env::var("AGENTD_CODEX_BIN").unwrap_or_else(|_| "codex".into()); + let command_override = agentd_protocol::adapter::resolve_command_override( + "AGENTD_CODEX_CMD", + "AGENTD_CODEX_BIN", + "codex", + ); let resume_flag = std::env::var("AGENTD_CODEX_RESUME_FLAG").ok(); let cwd = PathBuf::from(¶ms.cwd); let model = params.model.clone(); @@ -408,7 +415,7 @@ async fn run_session(params: SessionStartParams, ctx: AdapterContext) { detail: None, }); - let mut child_args: Vec = Vec::new(); + let mut child_args: Vec = command_override.args.clone(); child_args.push("exec".into()); if let (Some(flag), Some(sid)) = (resume_flag.as_ref(), codex_session_id.as_ref()) { child_args.push(flag.clone()); @@ -425,7 +432,7 @@ async fn run_session(params: SessionStartParams, ctx: AdapterContext) { child_args.push(a.clone()); } child_args.push(user_text.clone()); - let mut command = Command::new(&bin); + let mut command = Command::new(&command_override.bin); for a in &child_args { command.arg(a); } @@ -444,7 +451,10 @@ async fn run_session(params: SessionStartParams, ctx: AdapterContext) { Ok(c) => c, Err(e) => { emit.emit(SessionEvent::Error { - message: agentd_protocol::adapter::missing_bin_hint(&bin, &e), + message: agentd_protocol::adapter::missing_bin_hint( + &command_override.argv_preview(), + &e, + ), }); break 127; } @@ -621,10 +631,7 @@ fn try_emit_structured(emit: &EventEmitter, v: &Value) -> bool { .and_then(|n| n.as_str()) .unwrap_or("?") .to_string(); - let ok = !v - .get("is_error") - .and_then(|b| b.as_bool()) - .unwrap_or(false); + let ok = !v.get("is_error").and_then(|b| b.as_bool()).unwrap_or(false); let output = match v.get("output").or_else(|| v.get("content")) { Some(Value::String(s)) => s.clone(), Some(other) => serde_json::to_string(other).unwrap_or_default(), @@ -685,18 +692,16 @@ mod tests { ) .is_none()); // Right length, non-hex characters. - assert!(uuid_from_rollout_name( - "rollout-zzz-zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz.jsonl" - ) - .is_none()); + assert!( + uuid_from_rollout_name("rollout-zzz-zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz.jsonl") + .is_none() + ); } #[test] fn read_session_meta_extracts_id_and_originator() { - let tmp = std::env::temp_dir().join(format!( - "agentd-codex-meta-test-{}", - std::process::id() - )); + let tmp = + std::env::temp_dir().join(format!("agentd-codex-meta-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).unwrap(); @@ -709,7 +714,10 @@ mod tests { ) .unwrap(); let meta = read_session_meta(&mine).unwrap(); - assert_eq!(meta.id.as_deref(), Some("019e32aa-014a-7ff0-9a3f-7ae773961a37")); + assert_eq!( + meta.id.as_deref(), + Some("019e32aa-014a-7ff0-9a3f-7ae773961a37") + ); assert_eq!(meta.originator.as_deref(), Some("agentd:sess-abc")); // Default codex originator stays distinct. diff --git a/crates/adapter-shell/src/main.rs b/crates/adapter-shell/src/main.rs index 1da4a0a9..af08d110 100644 --- a/crates/adapter-shell/src/main.rs +++ b/crates/adapter-shell/src/main.rs @@ -7,7 +7,8 @@ //! - Empty prompt → `$SHELL -il` (interactive login shell). //! - Non-empty prompt → `$SHELL -lc ` (one-shot login shell). //! -//! Honors `AGENTD_SHELL_BIN`, then `$SHELL`, falling back to `/bin/bash`. +//! Honors `AGENTD_SHELL_CMD` for a full command prefix, falling back to +//! `AGENTD_SHELL_BIN`, then `$SHELL`, then `/bin/bash`. use agentd_protocol::adapter::pty::{run_session, PtySpec}; use agentd_protocol::adapter::run; @@ -27,30 +28,42 @@ async fn main() -> anyhow::Result<()> { }, }; run(metadata, |params, ctx| async move { - let shell = std::env::var("AGENTD_SHELL_BIN") - .ok() - .or_else(|| std::env::var("SHELL").ok()) - .unwrap_or_else(|| "/bin/bash".to_string()); + let default_shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); + let command = agentd_protocol::adapter::resolve_command_override( + "AGENTD_SHELL_CMD", + "AGENTD_SHELL_BIN", + &default_shell, + ); // On daemon-restart resume: ignore the original one-shot prompt // (it already ran in the previous incarnation). Re-spawn a fresh // interactive login shell in the same cwd so the user can keep // working. let resuming = std::env::var("AGENTD_RESUME").as_deref() == Ok("1"); - let args: Vec = match params.prompt.as_deref() { + let mut args: Vec = command.args.clone(); + match params.prompt.as_deref() { Some(p) if !p.trim().is_empty() && !resuming => { - vec!["-lc".to_string(), p.to_string()] + args.extend(["-lc".to_string(), p.to_string()]); } - _ => vec!["-il".to_string()], - }; + _ => args.push("-il".to_string()), + } + let label = command.argv_preview(); + let bin = command.bin; let spec = PtySpec { - bin: shell.clone(), + bin, args, cwd: PathBuf::from(¶ms.cwd), - env: params.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), - size: params.pty_size.unwrap_or(PtySize { cols: 100, rows: 30 }), - status_detail: Some(shell), + env: params + .env + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + size: params.pty_size.unwrap_or(PtySize { + cols: 100, + rows: 30, + }), + status_detail: Some(label), }; let _ = SessionState::Running; // silence dead-import lint if any let _ = run_session(spec, ctx).await; diff --git a/crates/protocol/src/adapter.rs b/crates/protocol/src/adapter.rs index 1a4a5c86..75d2ea3b 100644 --- a/crates/protocol/src/adapter.rs +++ b/crates/protocol/src/adapter.rs @@ -51,6 +51,125 @@ use tokio::sync::mpsc; pub mod pty; use crate::paths; + +/// A command prefix supplied through an adapter's `AGENTD_*_CMD` override. +/// +/// The first token is the executable to spawn; remaining tokens are prepended +/// before the adapter's generated CLI arguments. This lets users configure +/// commands such as `exec codex` or `mise exec -- codex` without writing a +/// wrapper script. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandOverride { + pub bin: String, + pub args: Vec, +} + +impl CommandOverride { + pub fn argv_preview(&self) -> String { + std::iter::once(self.bin.as_str()) + .chain(self.args.iter().map(String::as_str)) + .collect::>() + .join(" ") + } +} + +/// Resolve a child CLI command from either a full command override env var +/// (for example `AGENTD_CODEX_CMD=exec codex`) or a binary-only fallback env +/// var (for example `AGENTD_CODEX_BIN=/opt/bin/codex`). +/// +/// The parser intentionally implements simple shell-like quoting for spaces, +/// single quotes, double quotes, and backslash escapes without evaluating a +/// shell. Invalid command overrides are ignored and the binary fallback is +/// used instead. +pub fn resolve_command_override( + command_env: &str, + binary_env: &str, + default_bin: &str, +) -> CommandOverride { + if let Ok(raw) = std::env::var(command_env) { + if let Some(cmd) = parse_command_words(&raw) { + return cmd; + } + eprintln!( + "agentd adapter: ignoring invalid {command_env}; falling back to {binary_env}/{default_bin}" + ); + } + CommandOverride { + bin: std::env::var(binary_env).unwrap_or_else(|_| default_bin.to_string()), + args: Vec::new(), + } +} + +fn parse_command_words(raw: &str) -> Option { + let mut words = Vec::new(); + let mut cur = String::new(); + let mut chars = raw.chars().peekable(); + let mut quote: Option = None; + let mut in_word = false; + + while let Some(c) = chars.next() { + match quote { + Some(q) if c == q => { + quote = None; + in_word = true; + } + Some('\'') => { + cur.push(c); + in_word = true; + } + Some(_) if c == '\\' => { + if let Some(next) = chars.next() { + cur.push(next); + } else { + cur.push(c); + } + in_word = true; + } + Some(_) => { + cur.push(c); + in_word = true; + } + None if c == '\'' || c == '"' => { + quote = Some(c); + in_word = true; + } + None if c == '\\' => { + if let Some(next) = chars.next() { + cur.push(next); + } else { + cur.push(c); + } + in_word = true; + } + None if c.is_whitespace() => { + if in_word { + words.push(std::mem::take(&mut cur)); + in_word = false; + } + } + None => { + cur.push(c); + in_word = true; + } + } + } + if quote.is_some() { + return None; + } + if in_word { + words.push(cur); + } + let mut iter = words.into_iter(); + let bin = iter.next()?; + if bin.is_empty() { + return None; + } + Some(CommandOverride { + bin, + args: iter.collect(), + }) +} + /// Build a friendly "failed to start binary" message for adapters to emit /// when spawning the agent CLI fails (e.g. binary not on PATH). Adapters /// trust the user's shell to provide PATH; if that doesn't work, this @@ -104,10 +223,7 @@ fn mcp_env_toml(session_id: &str) -> String { mcp_env_toml_from(session_id, |name| std::env::var(name).ok()) } -fn mcp_env_toml_from( - session_id: &str, - lookup: impl Fn(&str) -> Option, -) -> String { +fn mcp_env_toml_from(session_id: &str, lookup: impl Fn(&str) -> Option) -> String { let mut pairs = vec![format!( "{} = {}", agent_context::ENV_SESSION_ID, @@ -932,6 +1048,28 @@ mod tests { } } + #[test] + fn parse_command_words_handles_shell_like_quotes() { + let cmd = parse_command_words(r#"mise exec -- "codex beta" --flag='two words'"#) + .expect("valid command"); + + assert_eq!(cmd.bin, "mise"); + assert_eq!( + cmd.args, + vec!["exec", "--", "codex beta", "--flag=two words"] + ); + assert_eq!( + cmd.argv_preview(), + "mise exec -- codex beta --flag=two words" + ); + } + + #[test] + fn parse_command_words_rejects_unclosed_quotes() { + assert!(parse_command_words("exec 'codex").is_none()); + assert!(parse_command_words(" ").is_none()); + } + #[test] fn mcp_env_toml_includes_memory_context_env() { let got = mcp_env_toml_from("s123", |name| match name { diff --git a/crates/protocol/src/adapter/pty.rs b/crates/protocol/src/adapter/pty.rs index 30e4f153..c5ed8d05 100644 --- a/crates/protocol/src/adapter/pty.rs +++ b/crates/protocol/src/adapter/pty.rs @@ -73,7 +73,10 @@ pub async fn run_session(spec: PtySpec, ctx: AdapterContext) -> i32 { Err(e) => { let io_err = std::io::Error::other(e.to_string()); emit.emit(SessionEvent::Error { - message: super::missing_bin_hint(&spec.bin, &io_err), + message: super::missing_bin_hint( + &spec.status_detail.as_deref().unwrap_or(&spec.bin), + &io_err, + ), }); emit.emit(SessionEvent::Done { exit_code: 127 }); return 127; diff --git a/docs/configuration.md b/docs/configuration.md index a6fc6f46..8161c9f3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,6 +32,35 @@ projects// Legacy `groups/.json` files are migrated to `projects//meta.json` when loaded. +## Built-in harness child command overrides + +Built-in adapters spawn their underlying CLI directly (no shell). For a +binary-only override, set the existing `AGENTD_*_BIN` env var in +`config.toml`: + +```toml +[adapters.codex] +env = { AGENTD_CODEX_BIN = "/opt/homebrew/bin/codex" } +``` + +When the command needs a prefix or extra executable before the real CLI, use +`AGENTD_*_CMD` instead. It is split shell-style for whitespace, quotes, and +backslashes, but is still executed directly without shell expansion: + +```toml +[adapters.codex] +env = { AGENTD_CODEX_CMD = "exec codex" } +``` + +`AGENTD_*_CMD` wins over `AGENTD_*_BIN`. Supported names: + +| Harness | Full command override | Binary-only fallback | +|---|---|---| +| `codex` | `AGENTD_CODEX_CMD` | `AGENTD_CODEX_BIN` | +| `claude` | `AGENTD_CLAUDE_CMD` | `AGENTD_CLAUDE_BIN` | +| `antigravity` | `AGENTD_ANTIGRAVITY_CMD` | `AGENTD_ANTIGRAVITY_BIN` | +| `shell` | `AGENTD_SHELL_CMD` | `AGENTD_SHELL_BIN` | + ## TUI Theme The TUI uses a built-in Matrix theme by default. Override any color slot in diff --git a/examples/config.toml b/examples/config.toml index bc5f9f1f..99ea4707 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -14,6 +14,17 @@ # binary = "/opt/agentd/agentd-adapter-claude" # description = "Claude Code" +# Override the child command a built-in adapter launches without writing a +# wrapper script. `AGENTD_*_CMD` is split shell-style (quotes/backslashes only; +# no shell expansion) and prepended before the adapter's generated arguments. +# This wins over the older binary-only `AGENTD_*_BIN` env var. +# +# [adapters.codex] +# env = { AGENTD_CODEX_CMD = "exec codex" } +# +# Other built-ins use AGENTD_CLAUDE_CMD, AGENTD_ANTIGRAVITY_CMD, and +# AGENTD_SHELL_CMD. + # Add a custom adapter — just point to any binary that speaks the AHP # protocol. See `crates/protocol/src/adapter.rs` for the SDK helper. #