Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
002f1b466b | ||
|
|
bc27bffbae | ||
|
|
bbf622ed9b |
@@ -58,11 +58,40 @@ 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";
|
||||
/// ACP adapter selected from the structured session's resolved command.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AcpAdapter {
|
||||
ClaudeCode,
|
||||
Codex,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
impl AcpAdapter {
|
||||
pub const fn binary(self) -> &'static str {
|
||||
match self {
|
||||
Self::ClaudeCode => "claude-code-acp",
|
||||
Self::Codex => "codex-acp",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn install_command(self) -> &'static str {
|
||||
match self {
|
||||
Self::ClaudeCode => "npm install -g --prefix ~/.local @zed-industries/claude-code-acp",
|
||||
Self::Codex => "npm install -g --prefix ~/.local @agentclientprotocol/codex-acp",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AcpAdapter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::ClaudeCode => "Claude Code",
|
||||
Self::Codex => "Codex",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Compatibility aliases for Claude Code callers.
|
||||
pub const ADAPTER_BIN: &str = "claude-code-acp";
|
||||
pub const INSTALL_COMMAND: &str =
|
||||
"npm install -g --prefix ~/.local @zed-industries/claude-code-acp";
|
||||
|
||||
@@ -166,6 +195,8 @@ pub struct SpawnConfig {
|
||||
/// instead of opening a fresh one. The adapter must advertise
|
||||
/// `loadSession`; the handshake fails otherwise.
|
||||
pub load_session: Option<String>,
|
||||
/// The provider-specific adapter to launch.
|
||||
pub adapter: AcpAdapter,
|
||||
}
|
||||
|
||||
impl SpawnConfig {
|
||||
@@ -175,6 +206,7 @@ impl SpawnConfig {
|
||||
env,
|
||||
handshake_timeout: HANDSHAKE_TIMEOUT,
|
||||
load_session: None,
|
||||
adapter: AcpAdapter::ClaudeCode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +215,11 @@ impl SpawnConfig {
|
||||
self.load_session = Some(session_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn adapter(mut self, adapter: AcpAdapter) -> Self {
|
||||
self.adapter = adapter;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A blocked ACP permission request delivered to the daemon.
|
||||
@@ -198,31 +235,39 @@ pub struct PermissionRequest {
|
||||
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),
|
||||
#[error("{adapter} ACP adapter ({bin}) not found on PATH; install it with: {install_command}", bin = adapter.binary(), install_command = adapter.install_command())]
|
||||
AdapterMissing { adapter: AcpAdapter },
|
||||
#[error("failed to start {adapter} ACP adapter ({bin}): {source}", bin = adapter.binary())]
|
||||
Spawn {
|
||||
adapter: AcpAdapter,
|
||||
#[source]
|
||||
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("{adapter} ACP adapter ({bin}) exited during the ACP handshake ({status}){stderr}", bin = adapter.binary())]
|
||||
ExitedEarly {
|
||||
adapter: AcpAdapter,
|
||||
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<String, String>) -> Option<PathBuf> {
|
||||
pub fn resolve_adapter(adapter: AcpAdapter, env: &HashMap<String, String>) -> Option<PathBuf> {
|
||||
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))
|
||||
.map(|dir| dir.join(adapter.binary()))
|
||||
.find(|candidate| is_executable(candidate))
|
||||
}
|
||||
|
||||
@@ -248,6 +293,12 @@ enum Command {
|
||||
Close,
|
||||
}
|
||||
|
||||
struct ConnectionConfig {
|
||||
cwd: PathBuf,
|
||||
load_session: Option<String>,
|
||||
adapter: AcpAdapter,
|
||||
}
|
||||
|
||||
enum ChildCommand {
|
||||
Signal(libc::c_int),
|
||||
Reap(oneshot::Sender<bool>),
|
||||
@@ -435,7 +486,9 @@ impl Drop for AcpSession {
|
||||
/// 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<AcpSession, AcpError> {
|
||||
let adapter = resolve_adapter(&config.env).ok_or(AcpError::AdapterMissing)?;
|
||||
let provider = config.adapter;
|
||||
let adapter = resolve_adapter(provider, &config.env)
|
||||
.ok_or(AcpError::AdapterMissing { adapter: provider })?;
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&adapter);
|
||||
cmd.current_dir(&config.cwd)
|
||||
@@ -463,18 +516,27 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut child = cmd.spawn().map_err(AcpError::Spawn)?;
|
||||
let mut child = cmd.spawn().map_err(|source| AcpError::Spawn {
|
||||
adapter: provider,
|
||||
source,
|
||||
})?;
|
||||
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::<u8>::new()));
|
||||
tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone()));
|
||||
tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone(), provider));
|
||||
|
||||
let (exit_tx, exit_rx) = watch::channel(None);
|
||||
let (child_commands_tx, child_commands_rx) = mpsc::unbounded_channel();
|
||||
tokio::spawn(supervise_child(child, pid, child_commands_rx, exit_tx));
|
||||
tokio::spawn(supervise_child(
|
||||
child,
|
||||
pid,
|
||||
child_commands_rx,
|
||||
exit_tx,
|
||||
provider,
|
||||
));
|
||||
|
||||
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
|
||||
let (permission_tx, permission_rx) = mpsc::unbounded_channel();
|
||||
@@ -483,8 +545,11 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
||||
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
|
||||
tokio::spawn(run_connection(
|
||||
transport,
|
||||
config.cwd.clone(),
|
||||
config.load_session.clone(),
|
||||
ConnectionConfig {
|
||||
cwd: config.cwd.clone(),
|
||||
load_session: config.load_session.clone(),
|
||||
adapter: provider,
|
||||
},
|
||||
updates_tx,
|
||||
permission_tx,
|
||||
commands_rx,
|
||||
@@ -517,6 +582,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
|
||||
.to_string()
|
||||
};
|
||||
AcpError::ExitedEarly {
|
||||
adapter: provider,
|
||||
status: status.map_or_else(|| "unknown status".into(), |s| s.to_string()),
|
||||
stderr: if stderr.is_empty() {
|
||||
String::new()
|
||||
@@ -551,12 +617,13 @@ async fn supervise_child(
|
||||
pid: u32,
|
||||
mut commands: mpsc::UnboundedReceiver<ChildCommand>,
|
||||
exit_tx: watch::Sender<Option<ExitStatus>>,
|
||||
adapter: AcpAdapter,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
status = child.wait() => {
|
||||
let status = status.ok();
|
||||
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
|
||||
tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
|
||||
let _ = exit_tx.send(status);
|
||||
return;
|
||||
}
|
||||
@@ -570,7 +637,7 @@ async fn supervise_child(
|
||||
Some(ChildCommand::Reap(done)) => match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let _ = done.send(true);
|
||||
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
|
||||
tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
|
||||
let _ = exit_tx.send(Some(status));
|
||||
return;
|
||||
}
|
||||
@@ -578,7 +645,7 @@ async fn supervise_child(
|
||||
let _ = done.send(false);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(pid, %error, "could not reap {ADAPTER_BIN}");
|
||||
tracing::warn!(pid, %error, provider = %adapter, "could not reap ACP adapter");
|
||||
let _ = done.send(false);
|
||||
}
|
||||
},
|
||||
@@ -588,7 +655,7 @@ async fn supervise_child(
|
||||
// a reused PID.
|
||||
unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
|
||||
let status = child.wait().await.ok();
|
||||
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
|
||||
tracing::debug!(pid, ?status, provider = %adapter, "ACP adapter exited");
|
||||
let _ = exit_tx.send(status);
|
||||
return;
|
||||
}
|
||||
@@ -704,8 +771,7 @@ fn config_notification(session_id: &str, options: Vec<SessionConfigOption>) -> S
|
||||
|
||||
async fn run_connection<T>(
|
||||
transport: T,
|
||||
cwd: PathBuf,
|
||||
load_session: Option<String>,
|
||||
config: ConnectionConfig,
|
||||
updates_tx: mpsc::UnboundedSender<SessionNotification>,
|
||||
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
|
||||
mut commands_rx: mpsc::UnboundedReceiver<Command>,
|
||||
@@ -779,11 +845,13 @@ async fn run_connection<T>(
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let (session_id, modes, initial_options, models) = match load_session {
|
||||
let (session_id, modes, initial_options, models) = match config.load_session {
|
||||
Some(existing) => {
|
||||
if !initialized.agent_capabilities.load_session {
|
||||
let _ = ready_tx.send(Err(ReadyError::Handshake(format!(
|
||||
"{ADAPTER_BIN} does not advertise loadSession; cannot resume session {existing}"
|
||||
"{} ACP adapter ({}) does not advertise loadSession; cannot resume session {existing}",
|
||||
config.adapter,
|
||||
config.adapter.binary()
|
||||
))));
|
||||
return Ok(());
|
||||
}
|
||||
@@ -793,7 +861,7 @@ async fn run_connection<T>(
|
||||
if let Err(e) = cx
|
||||
.send_request(ExtendedLoadSessionRequest {
|
||||
session_id: SessionId::new(existing.as_str()),
|
||||
cwd,
|
||||
cwd: config.cwd,
|
||||
mcp_servers: vec![],
|
||||
})
|
||||
.on_receiving_result(move |result| {
|
||||
@@ -824,7 +892,7 @@ async fn run_connection<T>(
|
||||
}
|
||||
None => match cx
|
||||
.send_request(ExtendedNewSessionRequest {
|
||||
cwd,
|
||||
cwd: config.cwd,
|
||||
mcp_servers: vec![],
|
||||
})
|
||||
.block_task()
|
||||
@@ -1004,10 +1072,11 @@ async fn drain_stderr(
|
||||
stderr: tokio::process::ChildStderr,
|
||||
pid: u32,
|
||||
tail: Arc<Mutex<VecDeque<u8>>>,
|
||||
adapter: AcpAdapter,
|
||||
) {
|
||||
let mut lines = tokio::io::BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::debug!(pid, "{ADAPTER_BIN}: {line}");
|
||||
tracing::debug!(pid, provider = %adapter, "ACP adapter: {line}");
|
||||
let mut tail = tail.lock().expect("stderr tail");
|
||||
tail.extend(line.as_bytes());
|
||||
tail.push_back(b'\n');
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus
|
||||
//! one ignored test against the real `claude-code-acp`.
|
||||
//! ignored smoke tests against each maintained adapter.
|
||||
|
||||
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 maestro_acp::{AcpAdapter, AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn fake_agent() -> PathBuf {
|
||||
@@ -25,6 +25,7 @@ impl Fake {
|
||||
let bin = dir.path().join("bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
|
||||
std::fs::copy(fake_agent(), bin.join(AcpAdapter::Codex.binary())).unwrap();
|
||||
Self { dir }
|
||||
}
|
||||
|
||||
@@ -146,12 +147,38 @@ async fn missing_adapter_names_the_install_command() {
|
||||
let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env))
|
||||
.await
|
||||
.expect_err("no adapter on PATH");
|
||||
assert!(matches!(err, AcpError::AdapterMissing));
|
||||
assert!(matches!(
|
||||
err,
|
||||
AcpError::AdapterMissing {
|
||||
adapter: AcpAdapter::ClaudeCode
|
||||
}
|
||||
));
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains(ADAPTER_BIN), "{msg}");
|
||||
assert!(msg.contains(INSTALL_COMMAND), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_adapter_has_its_own_binary_and_install_guidance() {
|
||||
assert_eq!(AcpAdapter::Codex.binary(), "codex-acp");
|
||||
assert!(AcpAdapter::Codex
|
||||
.install_command()
|
||||
.contains("@agentclientprotocol/codex-acp"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_selects_the_codex_adapter() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
|
||||
.await
|
||||
.expect("spawn Codex adapter");
|
||||
assert!(fake.log().contains("recv session/new"));
|
||||
session
|
||||
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_updates_reach_the_taken_receiver() {
|
||||
let fake = Fake::new();
|
||||
@@ -241,7 +268,7 @@ async fn advertised_settings_are_normalized_and_mutable() {
|
||||
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()))
|
||||
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
|
||||
.await
|
||||
.expect("spawn");
|
||||
let pid = session.pid();
|
||||
@@ -282,7 +309,7 @@ async fn terminate_is_satisfied_by_an_exit_on_cancel() {
|
||||
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());
|
||||
let mut config = fake.config(cwd.path()).adapter(AcpAdapter::Codex);
|
||||
config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into());
|
||||
|
||||
let err = maestro_acp::spawn(config).await.expect_err("init fails");
|
||||
@@ -341,7 +368,7 @@ async fn child_exit_before_handshake_reports_exited_early() {
|
||||
|
||||
let err = maestro_acp::spawn(config).await.expect_err("child died");
|
||||
match &err {
|
||||
AcpError::ExitedEarly { status, stderr } => {
|
||||
AcpError::ExitedEarly { status, stderr, .. } => {
|
||||
assert!(status.contains('3'), "{status}");
|
||||
assert!(stderr.contains("not logged in"), "{stderr}");
|
||||
}
|
||||
@@ -377,7 +404,10 @@ fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
|
||||
async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut config = fake.config(cwd.path()).load_session("fake-old");
|
||||
let mut config = fake
|
||||
.config(cwd.path())
|
||||
.adapter(AcpAdapter::Codex)
|
||||
.load_session("fake-old");
|
||||
config.env.insert("FAKE_ACP_LOAD_REPLAY".into(), "3".into());
|
||||
|
||||
let session = maestro_acp::spawn(config).await.expect("spawn");
|
||||
@@ -410,7 +440,10 @@ async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
|
||||
async fn load_session_requires_the_load_capability() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut config = fake.config(cwd.path()).load_session("fake-old");
|
||||
let mut config = fake
|
||||
.config(cwd.path())
|
||||
.adapter(AcpAdapter::Codex)
|
||||
.load_session("fake-old");
|
||||
config.env.insert("FAKE_ACP_NO_LOAD".into(), "1".into());
|
||||
|
||||
let err = maestro_acp::spawn(config)
|
||||
@@ -425,7 +458,10 @@ async fn load_session_requires_the_load_capability() {
|
||||
async fn load_session_error_fails_the_spawn() {
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut config = fake.config(cwd.path()).load_session("fake-old");
|
||||
let mut config = fake
|
||||
.config(cwd.path())
|
||||
.adapter(AcpAdapter::Codex)
|
||||
.load_session("fake-old");
|
||||
config.env.insert("FAKE_ACP_LOAD_FAIL".into(), "1".into());
|
||||
|
||||
let err = maestro_acp::spawn(config).await.expect_err("load fails");
|
||||
@@ -449,3 +485,21 @@ async fn real_adapter_handshake() {
|
||||
.await;
|
||||
assert!(session.has_exited());
|
||||
}
|
||||
|
||||
/// End to end against the maintained Codex adapter. It needs a locally
|
||||
/// authenticated Codex CLI, so CI runs it only when explicitly requested.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn real_codex_adapter_handshake() {
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let session =
|
||||
maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new()).adapter(AcpAdapter::Codex))
|
||||
.await
|
||||
.expect("real codex-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());
|
||||
}
|
||||
|
||||
@@ -573,6 +573,58 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_the_codex_adapter_fixture_without_provider_specific_rows() {
|
||||
let fixture = include_str!("../tests/fixtures/codex-acp-session.jsonl");
|
||||
let events = fixture
|
||||
.lines()
|
||||
.map(serde_json::from_str::<SessionNotification>)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.expect("Codex ACP fixture follows the supported schema");
|
||||
let mut projection = Projection::default();
|
||||
|
||||
for (seq, event) in events.iter().enumerate() {
|
||||
projection.apply((seq + 1) as u64, event);
|
||||
}
|
||||
|
||||
let rows = projection.snapshot(events.len() as u64).rows;
|
||||
assert_eq!(
|
||||
rows.iter().map(|row| row.kind).collect::<Vec<_>>(),
|
||||
[
|
||||
TranscriptRowKind::User,
|
||||
TranscriptRowKind::AgentThought,
|
||||
TranscriptRowKind::ToolCall,
|
||||
TranscriptRowKind::ToolDiff,
|
||||
TranscriptRowKind::ToolCall,
|
||||
TranscriptRowKind::ToolCall,
|
||||
TranscriptRowKind::ToolCall,
|
||||
TranscriptRowKind::ToolCall,
|
||||
TranscriptRowKind::Plan,
|
||||
TranscriptRowKind::Notice,
|
||||
TranscriptRowKind::Notice,
|
||||
TranscriptRowKind::AgentText,
|
||||
]
|
||||
);
|
||||
assert_eq!(rows[2].data["rawInput"]["command"], "rg TODO");
|
||||
assert_eq!(rows[2].data["rawOutput"]["exit_code"], 0);
|
||||
assert_eq!(rows[4].data["_meta"]["is_mcp_tool_call"], true);
|
||||
assert_eq!(rows[5].data["rawInput"]["query"], "Agent Client Protocol");
|
||||
assert_eq!(
|
||||
rows[7].data["content"][0]["content"]["mimeType"],
|
||||
"image/png"
|
||||
);
|
||||
|
||||
let state = projection.state();
|
||||
assert_eq!(state.model.as_deref(), Some("gpt-5.3-codex"));
|
||||
assert_eq!(state.mode.as_deref(), Some("agent"));
|
||||
assert_eq!(state.usage.as_ref().unwrap()["used"], 1200);
|
||||
assert_eq!(state.context.as_ref().unwrap()["size"], 258400);
|
||||
assert_eq!(
|
||||
state.config_options.as_ref().unwrap()["configOptions"][2]["currentValue"],
|
||||
"high"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesces_anonymous_stream_chunks_without_joining_turns() {
|
||||
let mut projection = Projection::default();
|
||||
|
||||
@@ -15,7 +15,8 @@ use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use maestro_acp::{AcpSession, PermissionRequest, SpawnConfig};
|
||||
use maestro_acp::{AcpAdapter, AcpSession, PermissionRequest, SpawnConfig};
|
||||
use maestro_protocol::display::command_tool;
|
||||
use maestro_protocol::{
|
||||
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, NameSource, Session,
|
||||
SessionStatus, SessionView, StructuredReplayEvent, TranscriptDelta, TranscriptRow,
|
||||
@@ -59,6 +60,16 @@ pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
|
||||
/// actually go.
|
||||
const SUBMIT_DELAY: Duration = Duration::from_millis(150);
|
||||
|
||||
fn structured_adapter(command: &str) -> Result<AcpAdapter, String> {
|
||||
match command_tool(command) {
|
||||
Some("claude") => Ok(AcpAdapter::ClaudeCode),
|
||||
Some("codex") => Ok(AcpAdapter::Codex),
|
||||
_ => Err(format!(
|
||||
"structured view supports Claude Code or Codex commands; cannot select an ACP adapter for {command:?}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-session daemon-local bookkeeping that complements the public `Session`.
|
||||
#[derive(Debug)]
|
||||
pub struct SessionRecord {
|
||||
@@ -1250,14 +1261,18 @@ impl SessionManager {
|
||||
// up still yields a session — Dead, with the reason on it — so the
|
||||
// operator sees what to fix instead of a silently missing session.
|
||||
let (acp, acp_failure) = match req.view {
|
||||
SessionView::Structured => {
|
||||
match maestro_acp::spawn(SpawnConfig::new(
|
||||
&effective_cwd,
|
||||
session_env
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect(),
|
||||
))
|
||||
SessionView::Structured => match structured_adapter(&req.command) {
|
||||
Err(error) => (None, Some(error)),
|
||||
Ok(adapter) => match maestro_acp::spawn(
|
||||
SpawnConfig::new(
|
||||
&effective_cwd,
|
||||
session_env
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect(),
|
||||
)
|
||||
.adapter(adapter),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(acp) => (Some(Arc::new(acp)), None),
|
||||
@@ -1265,8 +1280,8 @@ impl SessionManager {
|
||||
tracing::warn!(%id, error = %e, "structured session: adapter failed to start");
|
||||
(None, Some(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
SessionView::Terminal => {
|
||||
let spawned = {
|
||||
let tmux = self.inner.tmux.clone();
|
||||
@@ -1674,23 +1689,30 @@ impl SessionManager {
|
||||
|
||||
let (acp, failure) = if recoverable {
|
||||
match session.acp_session_id.clone() {
|
||||
Some(acp_session_id) => {
|
||||
let config = SpawnConfig::new(
|
||||
&session.cwd,
|
||||
session_env
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect(),
|
||||
)
|
||||
.load_session(acp_session_id);
|
||||
match maestro_acp::spawn(config).await {
|
||||
Ok(acp) => (Some(Arc::new(acp)), None),
|
||||
Err(error) => {
|
||||
tracing::warn!(%id, %error, "structured session: adapter failed to resume");
|
||||
(None, Some(error.to_string()))
|
||||
Some(acp_session_id) => match structured_adapter(&session.command) {
|
||||
Err(error) => {
|
||||
tracing::warn!(%id, %error, "structured session: unsupported command during recovery");
|
||||
(None, Some(error))
|
||||
}
|
||||
Ok(adapter) => {
|
||||
let config = SpawnConfig::new(
|
||||
&session.cwd,
|
||||
session_env
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect(),
|
||||
)
|
||||
.load_session(acp_session_id)
|
||||
.adapter(adapter);
|
||||
match maestro_acp::spawn(config).await {
|
||||
Ok(acp) => (Some(Arc::new(acp)), None),
|
||||
Err(error) => {
|
||||
tracing::warn!(%id, %error, "structured session: adapter failed to resume");
|
||||
(None, Some(error.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
None => (
|
||||
None,
|
||||
Some("structured session has no stored ACP session id".into()),
|
||||
@@ -2969,15 +2991,18 @@ mod tests {
|
||||
use std::task::Poll;
|
||||
use std::time::Duration;
|
||||
|
||||
use maestro_acp::schema::{
|
||||
PermissionOption, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest,
|
||||
ToolCallUpdate, ToolCallUpdateFields,
|
||||
use maestro_acp::{
|
||||
schema::{
|
||||
PermissionOption, PermissionOptionKind, RequestPermissionOutcome,
|
||||
RequestPermissionRequest, ToolCallUpdate, ToolCallUpdateFields,
|
||||
},
|
||||
AcpAdapter,
|
||||
};
|
||||
use maestro_protocol::{ActivityState, CatalogEvent, CreateSessionRequest, SessionView};
|
||||
|
||||
use super::{
|
||||
apply_structured_activity, cancel_pending_approvals, permission_outcome,
|
||||
record_permission_request, run_structured_activity_pass,
|
||||
record_permission_request, run_structured_activity_pass, structured_adapter,
|
||||
sync_structured_activity_from_stream, AcpDispatchState, AcpEventStream, ApprovalDecision,
|
||||
RecordOutcome, SessionManager,
|
||||
};
|
||||
@@ -3001,6 +3026,17 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_adapter_uses_the_shared_command_resolution() {
|
||||
assert_eq!(structured_adapter("claude"), Ok(AcpAdapter::ClaudeCode));
|
||||
assert_eq!(
|
||||
structured_adapter("sudo -u agent -- /opt/bin/codex"),
|
||||
Ok(AcpAdapter::Codex)
|
||||
);
|
||||
assert!(structured_adapter("opencode").is_err());
|
||||
assert!(structured_adapter("claude && codex").is_err());
|
||||
}
|
||||
|
||||
fn stream(log: EventLog) -> AcpEventStream {
|
||||
let (live, _live_rx) = tokio::sync::broadcast::channel(16);
|
||||
let (projected_live, _projected_rx) = tokio::sync::broadcast::channel(16);
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
|
||||
use maestro_acp::schema::{ContentBlock, SessionUpdate};
|
||||
use maestro_acp::{ADAPTER_BIN, INSTALL_COMMAND};
|
||||
use maestro_acp::{AcpAdapter, ADAPTER_BIN};
|
||||
use maestro_daemon::projection::TurnLifecycle;
|
||||
use maestro_daemon::sidecar::{EventCursor, SidecarStore};
|
||||
use maestro_daemon::worktree::WorktreeManager;
|
||||
@@ -38,6 +38,7 @@ impl Fake {
|
||||
let bin = dir.path().join("bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
|
||||
std::fs::copy(fake_agent(), bin.join(AcpAdapter::Codex.binary())).unwrap();
|
||||
Self { dir }
|
||||
}
|
||||
|
||||
@@ -96,10 +97,18 @@ fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
|
||||
}
|
||||
|
||||
fn structured(cwd: &Path, env: HashMap<String, String>) -> CreateSessionRequest {
|
||||
structured_command(cwd, env, "claude")
|
||||
}
|
||||
|
||||
fn structured_command(
|
||||
cwd: &Path,
|
||||
env: HashMap<String, String>,
|
||||
command: &str,
|
||||
) -> CreateSessionRequest {
|
||||
CreateSessionRequest {
|
||||
view: SessionView::Structured,
|
||||
cwd: cwd.to_path_buf(),
|
||||
command: "claude".into(),
|
||||
command: command.into(),
|
||||
env,
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
@@ -130,7 +139,11 @@ async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
|
||||
|
||||
let session = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), env))
|
||||
.create(structured_command(
|
||||
cwd.path(),
|
||||
env,
|
||||
"codex --dangerously-bypass-approvals-and-sandbox",
|
||||
))
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
@@ -215,7 +228,7 @@ async fn structured_activity_tracks_turn_completion_and_failure() {
|
||||
env.insert("FAKE_ACP_PROMPT_FAIL_ONCE".into(), "1".into());
|
||||
let session = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), env))
|
||||
.create(structured_command(cwd.path(), env, "codex"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -570,7 +583,7 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
|
||||
|
||||
let session = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), env))
|
||||
.create(structured_command(cwd.path(), env, "codex"))
|
||||
.await
|
||||
.expect("create");
|
||||
let acp_id = session.acp_session_id.clone().expect("acp id");
|
||||
@@ -684,7 +697,7 @@ async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
|
||||
.create(CreateSessionRequest {
|
||||
view: SessionView::Structured,
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "claude".into(),
|
||||
command: "codex".into(),
|
||||
env: fake.env(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
@@ -779,15 +792,18 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
|
||||
|
||||
let session = fx
|
||||
.manager
|
||||
.create(structured(cwd.path(), env))
|
||||
.create(structured_command(cwd.path(), env, "codex"))
|
||||
.await
|
||||
.expect("a failed session, not an error");
|
||||
|
||||
assert_eq!(session.status, SessionStatus::Dead);
|
||||
assert_eq!(session.activity, ActivityState::Error);
|
||||
let detail = session.activity_detail.clone().expect("actionable detail");
|
||||
assert!(detail.contains(ADAPTER_BIN), "{detail}");
|
||||
assert!(detail.contains(INSTALL_COMMAND), "{detail}");
|
||||
assert!(detail.contains(AcpAdapter::Codex.binary()), "{detail}");
|
||||
assert!(
|
||||
detail.contains(AcpAdapter::Codex.install_command()),
|
||||
"{detail}"
|
||||
);
|
||||
assert_eq!(session.acp_session_id, None);
|
||||
|
||||
// It is a real session: listed, on disk, and dismissable like any other
|
||||
@@ -801,6 +817,28 @@ async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
|
||||
assert!(!fx.manager.has(session.id).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsupported_structured_command_fails_without_launching_claude() {
|
||||
let fx = DaemonFixture::new();
|
||||
let fake = Fake::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
let session = fx
|
||||
.manager
|
||||
.create(structured_command(cwd.path(), fake.env(), "opencode"))
|
||||
.await
|
||||
.expect("failed structured session is persisted");
|
||||
|
||||
assert_eq!(session.status, SessionStatus::Dead);
|
||||
let detail = session.activity_detail.expect("selection error");
|
||||
assert!(detail.contains("Claude Code or Codex"), "{detail}");
|
||||
assert!(
|
||||
fake.log().is_empty(),
|
||||
"unsupported command launched an adapter"
|
||||
);
|
||||
fx.manager.kill(session.id, false).await.expect("dismiss");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() {
|
||||
let fx = DaemonFixture::new();
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"user_message_chunk","messageId":"user-1","content":{"type":"text","text":"Inspect the repository"}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"agent_thought_chunk","messageId":"thought-1","content":{"type":"text","text":"I should inspect the files first."}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"command-1","kind":"execute","title":"rg TODO","status":"in_progress","rawInput":{"command":"rg TODO","cwd":"/workspace"}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"command-1","status":"completed","rawOutput":{"formatted_output":"src/lib.rs: TODO","exit_code":0}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"change-1","kind":"edit","title":"Editing files","status":"completed","content":[{"type":"diff","path":"src/lib.rs","oldText":"old","newText":"new"}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"mcp-1","kind":"execute","title":"mcp.github.search","status":"completed","rawInput":{"server":"github","tool":"search","arguments":{"query":"ACP"}},"rawOutput":{"result":"found"},"_meta":{"is_mcp_tool_call":true}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"web-1","kind":"search","title":"Search the web for ACP","status":"completed","rawInput":{"type":"webSearch","query":"Agent Client Protocol"}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"image-view-1","kind":"read","title":"View Image /workspace/diagram.png","status":"completed","content":[{"type":"content","content":{"type":"resource_link","name":"/workspace/diagram.png","uri":"/workspace/diagram.png"}}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"image-gen-1","title":"Image generation","status":"completed","content":[{"type":"content","content":{"type":"image","data":"aW1hZ2U=","mimeType":"image/png"}}],"rawOutput":{"path":"/workspace/generated.png"}}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"plan","entries":[{"content":"Implement compatibility fixture","priority":"medium","status":"in_progress"}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"usage_update","used":1200,"size":258400}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"current_mode_update","currentModeId":"agent"}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"config_option_update","configOptions":[{"id":"agent-mode","name":"Agent mode","category":"mode","type":"select","currentValue":"agent","options":[{"value":"read-only","name":"Read-only"},{"value":"agent","name":"Agent"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-5.3-codex","options":[{"value":"gpt-5.3-codex","name":"GPT-5.3-Codex"}]},{"id":"reasoning_effort","name":"Reasoning effort","category":"thought_level","type":"select","currentValue":"high","options":[{"value":"high","name":"High"}]},{"id":"fast-mode","name":"Fast mode","category":"model_config","type":"select","currentValue":"off","options":[{"value":"off","name":"Off"}]}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"status","description":"Show session status"}]}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"session_info_update","title":"Codex session"}}
|
||||
{"sessionId":"codex-session","update":{"sessionUpdate":"agent_message_chunk","messageId":"assistant-1","content":{"type":"text","text":"Done."}}}
|
||||
+33
-29
@@ -16,10 +16,10 @@ does not exist yet, and nothing here describes it.
|
||||
|
||||
A terminal session's agent runs inside a tmux pane; a structured session's
|
||||
agent runs as a subprocess of the daemon itself, speaking ACP over stdio. The
|
||||
daemon is the ACP **client**; the adapter process, `claude-code-acp`, is the
|
||||
ACP **agent** — it wraps Claude Code's own SDK and translates the model's
|
||||
tool calls into ACP `session/update` notifications instead of drawing them to
|
||||
a pane. `crates/maestro-acp` owns the daemon's side of that connection;
|
||||
daemon is the ACP **client**; the provider adapter process is the ACP
|
||||
**agent**. It translates the provider's events into ACP `session/update`
|
||||
notifications instead of drawing them to a pane. `crates/maestro-acp` owns the
|
||||
daemon's side of that connection;
|
||||
`crates/maestro-daemon/src/projection.rs` folds what it receives into the two
|
||||
projections below.
|
||||
|
||||
@@ -37,30 +37,26 @@ ACP handle instead of a tmux pane).
|
||||
|
||||
## The adapter
|
||||
|
||||
`ADAPTER_BIN` (`claude-code-acp`) is resolved by walking `PATH` — the
|
||||
session's own `PATH` env var if the spawn request set one, the daemon's
|
||||
otherwise — and taking the first executable file named `claude-code-acp` on
|
||||
it. Install it once per host:
|
||||
The resolved structured command selects a provider adapter. Its binary is
|
||||
resolved by walking the session's `PATH` (when set on the spawn request), then
|
||||
the daemon's `PATH`, and taking the first executable with that name.
|
||||
|
||||
```sh
|
||||
npm install -g --prefix ~/.local @zed-industries/claude-code-acp
|
||||
```
|
||||
| Command | Adapter binary | Install command | Provider detail |
|
||||
| --- | --- | --- | --- |
|
||||
| `claude` | `claude-code-acp` | `npm install -g --prefix ~/.local @zed-industries/claude-code-acp` | Needs a logged-in Claude Code. |
|
||||
| `codex` | `codex-acp` | `npm install -g --prefix ~/.local @agentclientprotocol/codex-acp` | Uses Codex CLI or adapter-configured authentication; it advertises model, reasoning-effort, agent-mode, and optional fast-mode configuration. |
|
||||
|
||||
That is `maestro_acp::INSTALL_COMMAND`, and it is not just documentation: it
|
||||
is baked into `AcpError::AdapterMissing`'s message. When the adapter cannot
|
||||
be found, spawn still creates the session — **Dead**, with that install
|
||||
command as its `activity_detail` — so the sidebar names the fix instead of
|
||||
the session silently never appearing (see `docs/INSTALL.md` for the full
|
||||
install writeup, including the systemd `PATH` caveat). The adapter also needs
|
||||
a logged-in Claude Code (`claude /login`); a missing login surfaces the same
|
||||
way, via the adapter's own stderr tail in the failure message.
|
||||
The selected install command is baked into `AcpError::AdapterMissing`. When an
|
||||
adapter cannot be found, spawn still creates a **Dead** session with that
|
||||
command as `activity_detail` (see `docs/INSTALL.md` for the systemd `PATH`
|
||||
caveat). Provider authentication failures surface through the adapter's stderr
|
||||
tail in the same field.
|
||||
|
||||
The handshake is bounded by `HANDSHAKE_TIMEOUT` (60s) — long enough for a
|
||||
slow machine to boot a node process and the Claude Code SDK behind it, never
|
||||
long enough to hang a spawn indefinitely. Any handshake failure — missing
|
||||
adapter, early exit, a protocol error, or the timeout — leaves no orphaned
|
||||
process: `spawn` closes and reaps the child itself before returning the
|
||||
error.
|
||||
slow machine to boot an adapter and its provider process, never long enough
|
||||
to hang a spawn indefinitely. Any handshake failure — missing adapter, early
|
||||
exit, a protocol error, or the timeout — leaves no orphaned process: `spawn`
|
||||
closes and reaps the child itself before returning the error.
|
||||
|
||||
## The ACP handshake
|
||||
|
||||
@@ -69,17 +65,17 @@ declares, deliberately, almost nothing:
|
||||
|
||||
| Capability | Declared | Why |
|
||||
| --- | --- | --- |
|
||||
| `fs.readTextFile` | `false` | The adapter uses Claude Code's own Read tool instead of asking the daemon to read a file on its behalf — the same tool a terminal-view Claude Code session uses. |
|
||||
| `fs.writeTextFile` | `false` | Same reasoning, for Write. |
|
||||
| `terminal` | `false` | The adapter runs Claude Code's own Bash tool rather than asking the daemon to open a terminal for it. |
|
||||
| `fs.readTextFile` | `false` | Providers read the session workspace through their own tool surface. |
|
||||
| `fs.writeTextFile` | `false` | Providers write the session workspace through their own tool surface. |
|
||||
| `terminal` | `false` | Providers run commands through their own tool surface. |
|
||||
|
||||
Declining all three keeps the daemon out of the file/terminal surface
|
||||
entirely — the adapter already has direct access to the session's `cwd`, and
|
||||
routing every read, write, and shell command through the daemon would only
|
||||
add a hop with no new capability. Every other capability namespace ACP
|
||||
defines (session extensions, auth, elicitation) is simply left at its
|
||||
default; the daemon does not advertise support because `claude-code-acp` has
|
||||
never asked for it, not because it was weighed and refused.
|
||||
default; the daemon does not advertise support because neither supported
|
||||
adapter needs it, not because it was weighed and refused.
|
||||
|
||||
The one client method that *is* implemented for real is
|
||||
`session/request_permission` (#139) — see [Approvals](#approvals) below.
|
||||
@@ -96,6 +92,14 @@ flight is dropped on arrival, deliberately: the daemon's own event log
|
||||
already holds that history, and forwarding it too would duplicate every row
|
||||
in the projections below.
|
||||
|
||||
Codex and Claude differ in what they emit, not in this lifecycle. Codex maps
|
||||
shell commands, file changes, MCP calls, web search, image generation/view,
|
||||
reasoning, plans, and token usage into standard ACP chunks and tool calls;
|
||||
those fold into the same eight rows. Codex's `available_commands_update` and
|
||||
`session_info_update`, like equivalent Claude updates, remain opaque
|
||||
`Notice` rows. Its `session/load` capability is required for daemon recovery
|
||||
just as it is for Claude.
|
||||
|
||||
## The eight transcript row kinds
|
||||
|
||||
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
|
||||
|
||||
Reference in New Issue
Block a user