diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 485d17272..de4111c59 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,10 +97,11 @@ is derived from it. **Process** is how to build, check and submit work. ### Reporting bugs and asking for features Open an [issue](https://github.com/Tryanks/tcode/issues). For a bug, include -your OS, which agent (Claude Code / Codex / which ACP agent), what you did, what -happened and what you expected. A screenshot beats a paragraph for anything -visual. If you are unsure whether something is a bug or intended, open an issue -anyway — the answer is worth writing down either way. +your OS, which agent (Claude Code, Codex, pi, OpenCode, Cursor, Grok, or which +ACP agent), what you did, what happened and what you expected. A screenshot +beats a paragraph for anything visual. If you are unsure whether something is a +bug or intended, open an issue anyway — the answer is worth writing down either +way. ### Building @@ -121,8 +122,8 @@ Platform prerequisites: `libwayland-dev`, `libxcb*`, `libssl-dev`, `libasound2-dev`, `libfontconfig-dev`). The embedded preview browser is compiled out on Linux. -Provider CLIs are resolved from `PATH` (`claude`, `codex`, `pi`, `opencode`) and -can be overridden in **Settings → Providers**. +Provider CLIs are resolved from `PATH` (`claude`, `codex`, `pi`, `opencode`, +`cursor-agent`, `grok`) and can be overridden in **Settings → Providers**. ### Before you open a pull request @@ -275,7 +276,8 @@ crates/ios, crates/android, crates/web `tcode_ui::run_shell` crates/platform/* GPUI platform backends for iOS and Android crates/agent provider clients (no GPUI) — claude.rs, codex.rs, pi.rs, - opencode.rs, acp.rs + opencode.rs, cursor.rs, grok.rs, acp.rs; the ACP + session they share is acp_session.rs crates/term terminal implementation (PTY, host-side emulator) crates/mcp-host shared authenticated loopback host for in-process MCP servers crates/preview-mcp MCP server exposing the preview browser to the agent diff --git a/Cargo.lock b/Cargo.lock index 7664f492b..f3e76efe3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,6 +153,7 @@ dependencies = [ "log", "serde", "serde_json", + "sha2 0.11.0", "smol", "toml 1.1.6+spec-1.1.0", "ureq", diff --git a/README.md b/README.md index 685acfd17..84c71c142 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **A native GUI for the terminal coding agents you already use.** -Claude Code, Codex, pi, OpenCode, and any agent that speaks ACP — one window, +Claude Code, Codex, pi, OpenCode, Cursor, Grok, and any agent that speaks ACP — one window, on your desktop, your phone, or in a browser. [Download](https://github.com/Tryanks/tcode/releases) · @@ -78,6 +78,8 @@ and a live plan/task panel. | [Codex](https://developers.openai.com/codex/cli) | `codex` on your `PATH` | | [pi](https://github.com/earendil-works/pi) | `pi` on your `PATH` | | [OpenCode](https://opencode.ai) | `opencode` on your `PATH` | +| [Cursor](https://cursor.com/cli) | `cursor-agent` on your `PATH` | +| [Grok](https://x.ai) | `grok` on your `PATH` | **Everything else, over [ACP](https://agentclientprotocol.com).** Tcode ships a marketplace backed by the official ACP registry. @@ -179,7 +181,7 @@ also include a `SHA256SUMS.txt` file. | Browser | Embedded in the headless release; open the HTTP link it prints (HTTPS only through your own tunnel). No separate signed app package | **2. Have an agent installed.** Tcode drives the CLIs, it doesn't bundle them. -Make sure `claude`, `codex`, `pi` or `opencode` is on your `PATH` — or install an ACP agent from +Make sure `claude`, `codex`, `pi`, `opencode`, `cursor-agent` or `grok` is on your `PATH` — or install an ACP agent from the marketplace once Tcode is running. **3. Add a project and start a thread.** Point Tcode at a directory, type, send. diff --git a/assets/icons/cursor.svg b/assets/icons/cursor.svg new file mode 100644 index 000000000..9f1876a6e --- /dev/null +++ b/assets/icons/cursor.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/grok.svg b/assets/icons/grok.svg new file mode 100644 index 000000000..9fb5e2ea6 --- /dev/null +++ b/assets/icons/grok.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/puzzle.svg b/assets/icons/puzzle.svg new file mode 100644 index 000000000..bc7ea8e9e --- /dev/null +++ b/assets/icons/puzzle.svg @@ -0,0 +1,13 @@ + + + diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index e149771a2..f14022cbe 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -8,6 +8,7 @@ default = ["process"] process = [ "dep:agent-client-protocol", "dep:base64", + "dep:sha2", "dep:smol", "dep:ureq", "dep:uuid", @@ -22,6 +23,7 @@ log = "0.4" base64 = { version = "0.23", optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = { version = "0.11", optional = true } smol = { version = "2", optional = true } ureq = { version = "2", default-features = false, features = ["tls", "gzip"], optional = true } uuid = { version = "1", features = ["v4"], optional = true } diff --git a/crates/agent/examples/probe.rs b/crates/agent/examples/probe.rs index 308ab284e..52dd11d7b 100644 --- a/crates/agent/examples/probe.rs +++ b/crates/agent/examples/probe.rs @@ -1,17 +1,30 @@ //! Headless end-to-end probe for provider clients. //! -//! Catalog mode: `probe --list-models `. +//! Catalog mode: `probe --list-models [--binary ]`. +//! Plugin mode: `probe plugins [--home ] [--project ] [ …]` +//! lists the native plugin catalog after running the optional operation: +//! `install|update [--accept ]`, `uninstall|enable|disable `, +//! `add-marketplace ` or `remove-marketplace `. `--home` isolates +//! the provider's native state (`CODEX_HOME` for Codex, `GROK_HOME` for Grok; +//! for Claude Code `HOME` and `CLAUDE_CONFIG_DIR`). //! Turn mode: `probe [cwd] [approval] [acp-command args…] [flags]`. -//! Flags are `--mode plan`, `--effort `, `--interrupt-after `, -//! `--steer `, and `--image `. Only one of the last three may be used. +//! Flags are `--binary `, `--model `, `--mode plan`, `--effort `, +//! `--resume `, `--fork`, `--leave-questions` (user-input requests +//! stay unanswered), `--mcp ` (an HTTP MCP server registered +//! as tcode registers its own), `--linger ` (stay open until no turn has +//! run for that long, to see turns the agent starts itself), `--follow-up +//! ` (send another turn that long after the first completes, +//! running turn or not), `--interrupt-after `, `--steer `, and +//! `--image `. Only one of the last three may be used. use std::path::PathBuf; -use std::time::Duration; +use std::time::{Duration, Instant}; use agent::{ AcpAgent, AcpLaunch, AgentEvent, ApprovalDecision, ApprovalMode, Attachment, InteractionMode, - ItemContent, OptionSelection, ProviderKind, SessionCommand, SessionOptions, TurnOptions, - TurnStatus, list_models, start_session, + ItemContent, LaunchEnv, McpRegistration, OptionSelection, PluginContext, PluginOp, PluginScope, + ProviderKind, ResumeCursor, SessionCommand, SessionOptions, TurnOptions, TurnStatus, + list_models, list_plugins, run_plugin_op, start_session, }; use base64::Engine as _; @@ -29,19 +42,121 @@ enum ProbeMode { fn usage() -> ! { eprintln!( - "usage: probe [cwd] \ - [supervised|auto_edits|full_access] [acp-command args…] [flags]" + "usage: probe [cwd] \ + [supervised|read_only|auto_edits|full_access] [acp-command args…] [flags]" ); - eprintln!(" probe --list-models "); + eprintln!( + " probe --list-models [--binary ]" + ); + eprintln!(" probe plugins [--home ] [--project ] [ …]"); std::process::exit(2); } +fn parse_scope(arg: Option) -> PluginScope { + match arg.as_deref() { + Some("user") => PluginScope::User, + Some("project") => PluginScope::Project, + Some("local") => PluginScope::Local, + Some("managed") => PluginScope::Managed, + _ => usage(), + } +} + +fn plugins(mut args: impl Iterator) -> i32 { + let provider = parse_provider(args.next().as_deref()); + let mut home = None; + let mut project = None; + let mut accept = None; + let mut positional = Vec::new(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--home" => home = args.next().map(PathBuf::from), + "--project" => project = args.next().map(PathBuf::from), + "--accept" => accept = args.next(), + _ => positional.push(arg), + } + } + let mut env = Vec::new(); + if let (ProviderKind::ClaudeCode, Some(home)) = (provider, &home) { + env.push(( + "CLAUDE_CONFIG_DIR".to_string(), + home.join(".claude").display().to_string(), + )); + } + let context = PluginContext { + binary_path: None, + launch_env: LaunchEnv { + env, + home: home.clone(), + }, + cwd: project + .clone() + .or(home) + .unwrap_or_else(|| std::env::current_dir().unwrap()), + project: project.is_some(), + }; + let mut positional = positional.into_iter(); + let op = positional.next().map(|verb| { + let mut id = || positional.next().unwrap_or_else(|| usage()); + match verb.as_str() { + "install" => PluginOp::Install { + id: id(), + scope: parse_scope(positional.next()), + accept_command: accept.clone(), + }, + "update" => PluginOp::Update { + id: id(), + scope: parse_scope(positional.next()), + accept_command: accept.clone(), + }, + "uninstall" => PluginOp::Uninstall { + id: id(), + scope: parse_scope(positional.next()), + }, + "enable" | "disable" => PluginOp::SetEnabled { + id: id(), + scope: parse_scope(positional.next()), + enabled: verb == "enable", + }, + "add-marketplace" => PluginOp::AddMarketplace { source: id() }, + "remove-marketplace" => PluginOp::RemoveMarketplace { name: id() }, + _ => usage(), + } + }); + smol::block_on(async move { + if let Some(op) = op { + eprintln!("probe: {op:?} in {}", context.cwd.display()); + match run_plugin_op(provider, &context, &op).await { + Ok(outcome) => println!("outcome: {outcome:#?}"), + Err(error) => println!("outcome: error: {error}"), + } + } + match list_plugins(provider, &context).await { + Ok(listing) => { + let listing = serde_json::json!({ + "marketplaces": listing.marketplaces, + "marketplace_actions": listing.marketplace_actions, + "entries": listing.entries, + }); + println!("{}", serde_json::to_string_pretty(&listing).unwrap()); + 0 + } + Err(error) => { + eprintln!("list_plugins failed: {error}"); + 1 + } + } + }) +} + fn parse_provider(arg: Option<&str>) -> ProviderKind { match arg { Some("codex") => ProviderKind::Codex, Some("claude") => ProviderKind::ClaudeCode, Some("pi") => ProviderKind::Pi, Some("opencode") => ProviderKind::OpenCode, + Some("cursor") => ProviderKind::Cursor, + Some("grok") => ProviderKind::Grok, Some("acp") => ProviderKind::Acp, _ => usage(), } @@ -89,10 +204,18 @@ fn image_attachment(path: PathBuf) -> Attachment { fn main() { env_logger::init(); let args: Vec = std::env::args().skip(1).collect(); + if args.first().map(String::as_str) == Some("plugins") { + std::process::exit(plugins(args.into_iter().skip(1))); + } if args.first().map(String::as_str) == Some("--list-models") { let provider = parse_provider(args.get(1).map(String::as_str)); + let binary = match args.get(2).map(String::as_str) { + Some("--binary") => Some(args.get(3).map(PathBuf::from).unwrap_or_else(|| usage())), + Some(_) => usage(), + None => None, + }; let exit_code = smol::block_on(async move { - match list_models(provider, None, Default::default(), Default::default()).await { + match list_models(provider, binary, Default::default(), Default::default()).await { Ok(models) => { println!("{}", serde_json::to_string_pretty(&models).unwrap()); 0 @@ -107,7 +230,14 @@ fn main() { } let mut interaction_mode = InteractionMode::Build; + let mut binary = None; + let mut model = None; let mut effort = None; + let mut resume = None; + let mut fork = false; + let mut leave_questions = false; + let mut mcp_servers = Vec::new(); + let mut continuation = Continuation::default(); let mut probe_mode = ProbeMode::Standard; let mut positional = Vec::new(); let mut args = args.into_iter(); @@ -124,7 +254,38 @@ fn main() { None => usage(), }; } + "--binary" => binary = Some(args.next().map(PathBuf::from).unwrap_or_else(|| usage())), + "--model" => model = Some(args.next().unwrap_or_else(|| usage())), "--effort" => effort = Some(args.next().unwrap_or_else(|| usage())), + "--resume" => { + let cursor = args.next().unwrap_or_else(|| usage()); + resume = Some(ResumeCursor(serde_json::from_str(&cursor).unwrap_or_else( + |error| { + eprintln!("--resume takes the JSON of a resume cursor: {error}"); + std::process::exit(2); + }, + ))); + } + "--fork" => fork = true, + "--leave-questions" => leave_questions = true, + "--linger" => { + let seconds = args.next().and_then(|value| value.parse().ok()); + continuation.linger = Some(Duration::from_secs(seconds.unwrap_or_else(|| usage()))); + } + "--follow-up" => { + let seconds = args.next().and_then(|value| value.parse().ok()); + let seconds = seconds.unwrap_or_else(|| usage()); + let prompt = args.next().unwrap_or_else(|| usage()); + continuation.follow_up = Some((Duration::from_secs(seconds), prompt)); + } + "--mcp" => { + let mut next = || args.next().unwrap_or_else(|| usage()); + mcp_servers.push(McpRegistration { + name: next(), + url: next(), + bearer_token: next(), + }); + } "--interrupt-after" => { let seconds = args .next() @@ -162,15 +323,20 @@ fn main() { }); let mut remaining: Vec = positional.collect(); let approval_mode = match remaining.first().map(String::as_str) { - Some("supervised" | "auto_edits" | "full_access") => match remaining.remove(0).as_str() { - "supervised" => ApprovalMode::Supervised, - "auto_edits" => ApprovalMode::AutoAcceptEdits, - _ => ApprovalMode::FullAccess, - }, + Some("supervised" | "read_only" | "auto_edits" | "full_access") => { + match remaining.remove(0).as_str() { + "supervised" => ApprovalMode::Supervised, + "read_only" => ApprovalMode::ReadOnly, + "auto_edits" => ApprovalMode::AutoAcceptEdits, + _ => ApprovalMode::FullAccess, + } + } None => ApprovalMode::Supervised, Some(_) if provider == ProviderKind::Acp => ApprovalMode::Supervised, Some(other) => { - eprintln!("unknown approval mode {other:?}; use supervised|auto_edits|full_access"); + eprintln!( + "unknown approval mode {other:?}; use supervised|read_only|auto_edits|full_access" + ); std::process::exit(2); } }; @@ -210,10 +376,31 @@ fn main() { effort, probe_mode, acp, + mcp_servers, + continuation, + Resumption { + resume, + fork, + leave_questions, + }, + binary, + model, )); std::process::exit(exit_code); } +struct Resumption { + resume: Option, + fork: bool, + leave_questions: bool, +} + +#[derive(Default)] +struct Continuation { + linger: Option, + follow_up: Option<(Duration, String)>, +} + #[allow(clippy::too_many_arguments)] async fn run_probe( provider: ProviderKind, @@ -224,28 +411,38 @@ async fn run_probe( effort: Option, probe_mode: ProbeMode, acp: Option, + mcp_servers: Vec, + continuation: Continuation, + resumption: Resumption, + binary_path: Option, + model: Option, ) -> i32 { + // Grok's effort is its wire config option, persisted under its option id. + let effort_id = match provider { + ProviderKind::Grok => "acp:cfg:reasoning_effort", + _ => "reasoningEffort", + }; let option_selections = effort .iter() .map(|value| OptionSelection { - id: "reasoningEffort".into(), + id: effort_id.into(), value: serde_json::Value::String(value.clone()), }) .collect(); - let model = match (provider, effort.is_some()) { + let model = model.or_else(|| match (provider, effort.is_some()) { (ProviderKind::ClaudeCode, true) => Some("claude-opus-4-8".to_string()), _ => None, - }; + }); let opts = SessionOptions { cwd, model, - resume: None, - fork: false, - binary_path: None, + resume: resumption.resume, + fork: resumption.fork, + binary_path, approval_mode, option_selections, interaction_mode, - mcp_servers: Vec::new(), + mcp_servers, launch_env: Default::default(), extra_args: Vec::new(), acp, @@ -318,18 +515,46 @@ async fn run_probe( let mut turns_completed = 0; let mut first_status = None; let mut steer_accepted = false; + let lingering = continuation + .linger + .or(continuation.follow_up.as_ref().map(|_| PHANTOM_TURN_GRACE)); + let mut follow_up = continuation.follow_up; + let mut open_until = None; + let mut running = false; + let mut closing = false; loop { - let event = if matches!(probe_mode, ProbeMode::Steer(_)) && turns_completed > 0 { - smol::future::or(handle.events.recv(), async { - smol::Timer::after(PHANTOM_TURN_GRACE).await; - Err(smol::channel::RecvError) - }) - .await - .ok() - } else { - handle.events.recv().await.ok() + let quiet = match lingering { + Some(linger) if turns_completed > 0 && !running && !closing => Some(linger), + _ if matches!(probe_mode, ProbeMode::Steer(_)) && turns_completed > 0 => { + Some(PHANTOM_TURN_GRACE) + } + _ => None, + }; + let event = match quiet { + Some(quiet) => { + let event = smol::future::or(handle.events.recv(), async { + smol::Timer::after(quiet).await; + Err(smol::channel::RecvError) + }) + .await + .ok(); + if event.is_none() { + if open_until.is_none_or(|until| Instant::now() >= until) { + closing = true; + handle.commands.send(SessionCommand::Shutdown).await.ok(); + } + continue; + } + event + } + None => handle.events.recv().await.ok(), }; let Some(event) = event else { break }; + match &event { + AgentEvent::TurnStarted { .. } => running = true, + AgentEvent::TurnCompleted { .. } => running = false, + _ => {} + } if provider == ProviderKind::Acp { match &event { @@ -360,6 +585,9 @@ async fn run_probe( .await .ok(); } + AgentEvent::UserInputRequested { questions, .. } if resumption.leave_questions => { + eprintln!("probe: leaving {} question(s) unanswered", questions.len()); + } AgentEvent::UserInputRequested { request_id, questions, @@ -440,7 +668,25 @@ async fn run_probe( "probe: TurnCompleted {turn_id} status={status:?} (#{turns_completed})" ); } - if !matches!(probe_mode, ProbeMode::Steer(_)) { + if let Some((delay, text)) = follow_up.take() { + open_until = Some(Instant::now() + delay + Duration::from_secs(1)); + let commands = handle.commands.clone(); + smol::spawn(async move { + smol::Timer::after(delay).await; + eprintln!("probe: FOLLOW-UP -> {text:?}"); + commands + .send(SessionCommand::SendTurn { + delivery_id: 1, + text, + options: None, + attachments: Vec::new(), + }) + .await + .ok(); + }) + .detach(); + } + if !matches!(probe_mode, ProbeMode::Steer(_)) && lingering.is_none() { handle.commands.send(SessionCommand::Shutdown).await.ok(); } } diff --git a/crates/agent/src/acp.rs b/crates/agent/src/acp.rs index 8016cc189..e828b1a77 100644 --- a/crates/agent/src/acp.rs +++ b/crates/agent/src/acp.rs @@ -1,97 +1,32 @@ //! Agent Client Protocol provider: any agent from the ACP registry. //! //! Registry-managed agents share this adapter; native providers retain their -//! richer protocol-specific clients. -//! -//! Shape: one child process per session, JSON-RPC over its stdio, driven by an -//! `agent_client_protocol::Client` connection builder. The whole connection -//! lives on a dedicated thread running a `LocalExecutor`; the canonical -//! [`AgentEvent`] stream and the [`SessionCommand`] channel are the only things -//! that cross the thread boundary — exactly like `claude.rs` / `codex.rs`. +//! richer protocol-specific clients. The protocol machinery lives in +//! [`crate::acp_session`]; this module is the registry's dialect: launching a +//! registry recipe and the generic policies for agents tcode knows nothing +//! more about. -use std::collections::{HashMap, VecDeque}; -use std::fmt::Write as _; -use std::io; -use std::path::{Path, PathBuf}; -use std::pin::Pin; -use std::rc::Rc; -use std::sync::{ - Arc, Mutex, MutexGuard, - atomic::{AtomicBool, Ordering}, -}; -use std::task::{Context, Poll}; +use std::path::PathBuf; use std::time::Duration; -use agent_client_protocol::{self as sdk, schema::ProtocolVersion, schema::v1 as acp}; +use agent_client_protocol::schema::v1 as acp; use serde_json::{Value, json}; -use smol::channel::{Receiver, Sender}; use smol::future; -use smol::io::{AsyncRead, AsyncReadExt as _, AsyncWrite}; -use smol::prelude::*; +use crate::acp_session::{ + self, Dialect, Established, Launch, Session, Setup, State, TurnEnd, describe, is_auth_required, + prompt_status, +}; use crate::{ - AcpAgent, AcpLaunch, AgentError, AgentEvent, ApprovalDecision, ApprovalKind, ApprovalMode, - ApprovalOption, ApprovalOptionKind, ApprovalRequest, Attachment, DeltaKind, FileChange, - FileChangeKind, InteractionMode, ItemContent, ItemStatus, McpRegistration, OptionDescriptor, - OptionSelection, PlanStep, PlanStepStatus, ProviderCommand, ProviderCommandKind, ProviderKind, - ResumeCursor, SelectOption, SessionCommand, SessionHandle, SessionOptions, ThreadItem, - TokenUsage, TurnStatus, + AcpAgent, AcpLaunch, AgentError, AgentEvent, ApprovalMode, Attachment, InteractionMode, + ProviderKind, ResumeCursor, SessionHandle, SessionOptions, TokenUsage, }; -/// Option-descriptor ids. The composer renders an ACP agent's own -/// modes/models/config options through the existing traits picker, so each one -/// needs a stable id that routes back to the right ACP method. -const MODE_OPTION_ID: &str = "acp:mode"; -const MODEL_OPTION_ID: &str = "acp:model"; -const CONFIG_OPTION_PREFIX: &str = "acp:cfg:"; - -/// Cap on captured terminal output when the agent sets none (1 MiB). -const DEFAULT_TERMINAL_OUTPUT_LIMIT: u64 = 1 << 20; - -/// How long we wait for an agent to answer `initialize` before declaring it -/// broken. Generous: an `npm exec` recipe may have to fetch the package first. -const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(120); - /// How long we let an agent's `authenticate` run before giving up and telling /// the user to sign in with the agent's own CLI (Gemini's is a browser OAuth /// flow that otherwise blocks session startup for five minutes). const AUTH_TIMEOUT: Duration = Duration::from_secs(20); -type AcpConnection = sdk::ConnectionTo; - -trait MutexExt { - fn lock_recover(&self) -> MutexGuard<'_, T>; -} - -impl MutexExt for Mutex { - fn lock_recover(&self) -> MutexGuard<'_, T> { - self.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) - } -} - -macro_rules! request_handler { - ($client:expr, $request:ty, $method:ident) => {{ - let client = $client.clone(); - async move |args: $request, responder, connection| { - let client = client.clone(); - connection - .spawn(async move { responder.respond_with_result(client.$method(args).await) })?; - Ok(()) - } - }}; - ($client:expr, $request:ty, $method:ident, connection) => {{ - let client = $client.clone(); - async move |args: $request, responder, connection| { - let client = client.clone(); - let task_connection = connection.clone(); - connection.spawn(async move { - responder.respond_with_result(client.$method(args, &task_connection).await) - })?; - Ok(()) - } - }}; -} - /// Start (or resume) a session with an ACP agent. pub async fn start(opts: SessionOptions) -> Result { if opts.fork { @@ -99,723 +34,208 @@ pub async fn start(opts: SessionOptions) -> Result { "session fork is not supported for this provider".into(), )); } - let (commands_tx, commands_rx) = smol::channel::unbounded(); - let (events_tx, events_rx) = smol::channel::unbounded(); - let (ready_tx, ready_rx) = smol::channel::bounded(1); - - // Keep each ACP connection and all of its callbacks on one dedicated - // executor thread. The 1.2 SDK requires Send handlers internally, but the - // adapter still exposes only channels across this boundary. - std::thread::Builder::new() - .name("acp-session".into()) - .spawn(move || { - let executor = Rc::new(smol::LocalExecutor::new()); - let task = run_actor(executor.clone(), opts, commands_rx, events_tx, ready_tx); - smol::block_on(executor.run(task)); - }) - .map_err(|err| { - AgentError::Spawn(format!("could not start the ACP session thread: {err}")) - })?; - - ready_rx.recv().await.map_err(|_| { - AgentError::Protocol("ACP actor exited before reporting startup status".into()) - })??; - - Ok(SessionHandle { - provider: ProviderKind::Acp, - commands: commands_tx, - events: events_rx, - }) -} - -/// The resolved command line for a launch recipe. -/// -/// `Npx` becomes `npm exec --yes -- ` (the registry's own -/// contract, and what zed runs); `Binary` / `Custom` run as given. -fn launch_command(launch: &AcpLaunch) -> Result<(PathBuf, Vec), AgentError> { - match launch { - AcpLaunch::Npx { package, args, .. } => { - let npm = crate::resolve_binary(None, "npm")?; - let mut argv = vec![ - "exec".to_string(), - "--yes".to_string(), - "--".to_string(), - package.clone(), - ]; - argv.extend(args.iter().cloned()); - Ok((npm, argv)) - } - AcpLaunch::Binary { command, args, .. } => Ok((command.clone(), args.clone())), - AcpLaunch::Custom { command, args, .. } => { - let binary = crate::resolve_binary(None, command)?; - Ok((binary, args.clone())) - } - } -} - -fn spawn_agent( - agent: &AcpAgent, - opts: &SessionOptions, -) -> Result { - let (program, mut args) = launch_command(&agent.launch)?; - args.extend(opts.extra_args.iter().cloned()); - - let mut cmd = crate::process::async_command(&program); - cmd.args(&args) - .current_dir(&opts.cwd) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - // Recipe env first, the user's configured env last (the user always wins). - let recipe_env = match &agent.launch { - AcpLaunch::Npx { env, .. } - | AcpLaunch::Binary { env, .. } - | AcpLaunch::Custom { env, .. } => env, + let Some(agent) = opts.acp.clone() else { + return Err(AgentError::Protocol( + "no ACP agent selected for this session".into(), + )); }; - for (key, value) in recipe_env { - cmd.env(key, value); - } - for (key, value) in opts.launch_env.pairs(ProviderKind::Acp) { - cmd.env(key, value); - } - log::debug!( - "spawning ACP agent {}: {} {:?}", - agent.id, - program.display(), - args - ); - cmd.spawn().map_err(|err| { - AgentError::Spawn(format!( - "could not launch ACP agent `{}` ({}): {err}", - agent.name, - program.display() - )) - }) -} - -/// Reports an agent closing (or breaking) stdout while still handing the bytes -/// to the SDK transport. The 1.2 `ByteStreams` component treats a clean EOF as -/// a completed input stream, so observing it here preserves tcode's immediate -/// `SessionClosed` behavior instead of leaving the command loop waiting. -struct ObservedReader { - inner: smol::process::ChildStdout, - done: Sender, -} - -impl ObservedReader { - fn new(inner: smol::process::ChildStdout, done: Sender) -> Self { - Self { inner, done } - } -} - -impl AsyncRead for ObservedReader { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &mut [u8], - ) -> Poll> { - match Pin::new(&mut self.inner).poll_read(cx, buf) { - Poll::Ready(Ok(0)) => { - let _ = self - .done - .try_send("the ACP agent closed its stdio".to_string()); - Poll::Ready(Ok(0)) - } - Poll::Ready(Err(err)) => { - let _ = self.done.try_send(format!("ACP transport error: {err}")); - Poll::Ready(Err(err)) - } - other => other, - } - } + acp_session::start(ProviderKind::Acp, Registry { agent }, opts).await } -/// Reports a turn only after the SDK has written its complete JSON-RPC prompt -/// line to the child. `ConnectionTo::send_request` merely queues internally and -/// does not expose a synchronous enqueue failure, so it is not a delivery -/// boundary on its own. -struct ObservedWriter { - inner: W, - line: Vec, - pending_deliveries: Arc>>, - events: Sender, +/// An agent from the registry (or a custom command), driven by the generic +/// ACP policies. +struct Registry { + agent: AcpAgent, } -impl ObservedWriter { - fn new( - inner: W, - pending_deliveries: Arc>>, - events: Sender, - ) -> Self { - Self { - inner, - line: Vec::new(), - pending_deliveries, - events, - } - } - - fn finish_line(&mut self) { - let is_prompt = serde_json::from_slice::(&self.line) - .ok() - .is_some_and(|message| { - message.get("method").and_then(Value::as_str) == Some("session/prompt") - }); - self.line.clear(); - if !is_prompt { - return; - } - if let Some(delivery_id) = self.pending_deliveries.lock_recover().pop_front() { - let _ = self - .events - .try_send(AgentEvent::TurnAccepted { delivery_id }); - } +impl Dialect for Registry { + fn name(&self) -> &str { + &self.agent.name } -} -impl AsyncWrite for ObservedWriter { - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &[u8], - ) -> Poll> { - let written = match Pin::new(&mut self.inner).poll_write(cx, buf) { - Poll::Ready(Ok(written)) => written, - other => return other, + fn launch(&self, opts: &SessionOptions) -> Result { + let (program, mut args) = launch_command(&self.agent.launch)?; + args.extend(opts.extra_args.iter().cloned()); + let env = match &self.agent.launch { + AcpLaunch::Npx { env, .. } + | AcpLaunch::Binary { env, .. } + | AcpLaunch::Custom { env, .. } => env.clone(), }; - for byte in &buf[..written] { - if *byte == b'\n' { - self.finish_line(); - } else { - self.line.push(*byte); - } - } - Poll::Ready(Ok(written)) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_flush(cx) - } - - fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_close(cx) + Ok(Launch { + program, + args, + env, + client_meta: None, + }) } -} - -async fn run_actor( - executor: Rc>, - opts: SessionOptions, - commands: Receiver, - events: Sender, - ready: Sender>, -) { - let Some(agent) = opts.acp.clone() else { - let _ = ready - .send(Err(AgentError::Protocol( - "no ACP agent selected for this session".into(), - ))) - .await; - return; - }; - - let mut child = match spawn_agent(&agent, &opts) { - Ok(child) => child, - Err(err) => { - let _ = ready.send(Err(err)).await; - return; - } - }; - let Some(stdin) = child.stdin.take() else { - let _ = ready - .send(Err(AgentError::Spawn(format!( - "ACP agent `{}` started without piped stdin", - agent.name - )))) - .await; - return; - }; - let Some(stdout) = child.stdout.take() else { - let _ = ready - .send(Err(AgentError::Spawn(format!( - "ACP agent `{}` started without piped stdout", - agent.name - )))) - .await; - return; - }; - let Some(stderr) = child.stderr.take() else { - let _ = ready - .send(Err(AgentError::Spawn(format!( - "ACP agent `{}` started without piped stderr", - agent.name - )))) - .await; - return; - }; - // The agent's stderr is its log channel: keep the tail so a startup failure - // can be reported in the agent's own words. - let stderr_tail = crate::process::StderrTail::default(); - executor - .spawn({ - let tail = stderr_tail.clone(); - let id = agent.id.clone(); - async move { - let mut lines = smol::io::BufReader::new(stderr).lines(); - while let Some(Ok(line)) = lines.next().await { - log::debug!("acp[{id}] stderr: {line}"); - tail.push(line); + async fn establish(&self, setup: &Setup<'_>) -> Result { + let agent = &self.agent; + let opts = setup.opts; + let mcp_servers = setup.mcp_servers().await; + let resumed = opts + .resume + .as_ref() + .and_then(|cursor| cursor.0.get("acp_session_id")) + .and_then(Value::as_str) + .map(str::to_string) + .filter(|_| setup.init.agent_capabilities.load_session); + + let mut loaded_session = None; + if let Some(session_id) = resumed { + // `session/load` replays the whole conversation as `session/update` + // notifications. Our JSONL log is the authoritative history and the + // UI has already folded it, so the replay is swallowed (see + // `session_update`); we only want the session live again. + let session_id = acp::SessionId::new(session_id); + let loaded = setup + .load( + acp::LoadSessionRequest::new(session_id.clone(), opts.cwd.clone()) + .mcp_servers(mcp_servers.clone()), + ) + .await; + match loaded { + Ok(loaded) => { + loaded_session = Some((session_id, loaded.modes, loaded.config_options)); } - } - }) - .detach(); - - let state = Arc::new(Mutex::new(State::new(opts.cwd.clone()))); - let client = AcpClient { - events: events.clone(), - state: state.clone(), - }; - let (io_done_tx, io_done) = smol::channel::bounded::(1); - let pending_deliveries = Arc::new(Mutex::new(VecDeque::new())); - let transport = sdk::ByteStreams::new( - ObservedWriter::new(stdin, pending_deliveries.clone(), events.clone()), - ObservedReader::new(stdout, io_done_tx), - ); - let session_started = Arc::new(AtomicBool::new(false)); - let connection_result = sdk::Client - .builder() - .name(format!("tcode-acp-{}", agent.id)) - .on_receive_notification( - { - let client = client.clone(); - async move |args: acp::SessionNotification, _connection| { - client.session_notification(args).await + Err(err) => { + log::warn!( + "acp[{}]: session/load failed ({}); starting a fresh session", + agent.id, + describe(&err) + ); + setup + .warn(format!( + "{} could not resume the previous conversation; starting a new one", + agent.name + )) + .await; } - }, - sdk::on_receive_notification!(), - ) - .on_receive_request( - request_handler!(client, acp::RequestPermissionRequest, request_permission), - sdk::on_receive_request!(), - ) - .on_receive_request( - request_handler!(client, acp::ReadTextFileRequest, read_text_file), - sdk::on_receive_request!(), - ) - .on_receive_request( - request_handler!(client, acp::WriteTextFileRequest, write_text_file), - sdk::on_receive_request!(), - ) - .on_receive_request( - request_handler!( - client, - acp::CreateTerminalRequest, - create_terminal, - connection - ), - sdk::on_receive_request!(), - ) - .on_receive_request( - request_handler!(client, acp::TerminalOutputRequest, terminal_output), - sdk::on_receive_request!(), - ) - .on_receive_request( - request_handler!( - client, - acp::WaitForTerminalExitRequest, - wait_for_terminal_exit - ), - sdk::on_receive_request!(), - ) - .on_receive_request( - request_handler!(client, acp::KillTerminalRequest, kill_terminal), - sdk::on_receive_request!(), - ) - .on_receive_request( - request_handler!(client, acp::ReleaseTerminalRequest, release_terminal), - sdk::on_receive_request!(), - ) - .connect_with(transport, { - let session_started = session_started.clone(); - let stderr_tail = stderr_tail.clone(); - let actor_events = events.clone(); - let actor_ready = ready.clone(); - async move |connection| { - connected_actor( - &executor, - connection, - &agent, - &opts, - &commands, - &actor_events, - &actor_ready, - &state, - &stderr_tail, - &io_done, - &session_started, - &pending_deliveries, - ) - .await } - }) - .await; - - // The connection closure owns the protocol shutdown; this is the hard - // process boundary for both graceful shutdowns and broken transports. - let _ = child.kill(); - - if !session_started.load(Ordering::Acquire) { - if let Err(err) = connection_result { - let message = format!("ACP transport error: {}", describe(&err)); - let message = stderr_tail.append_to(message, "\n"); - let _ = ready.send(Err(AgentError::Protocol(message))).await; - } - return; - } - - let close_reason = match connection_result { - Ok(reason) => reason, - Err(err) => Some(format!("ACP transport error: {}", describe(&err))), - }; - let close_reason = close_reason.map(|reason| stderr_tail.append_to(reason, "\nstderr:\n")); - let _ = events - .send(AgentEvent::SessionClosed { - reason: close_reason, - }) - .await; -} - -#[allow(clippy::too_many_arguments)] -async fn connected_actor( - executor: &Rc>, - connection: AcpConnection, - agent: &AcpAgent, - opts: &SessionOptions, - commands: &Receiver, - events: &Sender, - ready: &Sender>, - state: &Arc>, - stderr_tail: &crate::process::StderrTail, - io_done: &Receiver, - session_started: &AtomicBool, - pending_deliveries: &Arc>>, -) -> Result, acp::Error> { - enum Startup { - Handshake(Result), - Io(String), - } - - let startup = future::or( - async { Startup::Handshake(handshake(&connection, agent, opts, state, events).await) }, - async { - Startup::Io( - io_done - .recv() - .await - .unwrap_or_else(|_| "the ACP agent closed its stdio".to_string()), - ) - }, - ) - .await; - let session = match startup { - Startup::Handshake(Ok(session)) => session, - Startup::Handshake(Err(err)) => { - let err = match err { - AgentError::Protocol(message) => { - AgentError::Protocol(stderr_tail.append_to(message, "\n")) - } - other => other, - }; - let _ = ready.send(Err(err)).await; - return Ok(None); } - Startup::Io(reason) => { - let message = stderr_tail.append_to(reason, "\n"); - let _ = ready.send(Err(AgentError::Protocol(message))).await; - return Ok(None); + let (session_id, modes, config_options) = match loaded_session { + Some(session) => session, + None => { + let new = new_session(agent, setup, &mcp_servers).await?; + (new.session_id, new.modes, new.config_options) + } + }; + setup.adopt(modes.as_ref(), config_options.as_deref()); + + let wants_plan = opts.approval_mode == ApprovalMode::ReadOnly + || opts.interaction_mode == InteractionMode::Plan; + if wants_plan || setup.in_plan_mode() { + setup + .apply_interaction_mode( + &session_id, + if wants_plan { + InteractionMode::Plan + } else { + InteractionMode::Build + }, + ) + .await; } - }; - - let _ = events - .send(AgentEvent::SessionStarted { - provider_session_id: session.session_id.0.to_string(), + Ok(Established { resume: ResumeCursor(json!({ - "acp_session_id": session.session_id.0.to_string(), + "acp_session_id": session_id.0.to_string(), "acp_agent_id": agent.id, })), - model: session.model.clone(), + session_id, }) - .await; - emit_provider_options(state, events, false).await; - if ready.send(Ok(())).await.is_err() { - return Ok(None); } - session_started.store(true, Ordering::Release); - - let (turn_tx, turn_done) = smol::channel::unbounded::(); - let mut turn_id: Option = None; - let mut turn_seq: u64 = 0; - let close_reason = loop { - enum Input { - Command(Result), - Turn(TurnOutcome), - Io(String), + fn session_update( + &self, + state: &mut State, + notification: acp::SessionNotification, + ) -> Vec { + // While `session/load` replays the conversation we already have on disk, + // swallow everything: our JSONL log is the source of truth and the + // timeline was folded from it before the process even started. + if state.loading() { + return Vec::new(); } - let input = future::or( - future::or(async { Input::Command(commands.recv().await) }, async { - match turn_done.recv().await { - Ok(outcome) => Input::Turn(outcome), - // This loop holds the sender, so the channel cannot close. - Err(_) => future::pending().await, - } - }), - async { - match io_done.recv().await { - Ok(reason) => Input::Io(reason), - Err(_) => future::pending().await, - } - }, - ) - .await; + state.apply_update(notification.update) + } - match input { - Input::Command(Ok(SessionCommand::Shutdown)) | Input::Command(Err(_)) => break None, - Input::Command(Ok(command)) => { - handle_command( - command, - executor, - &connection, - &session, - state, - events, - &turn_tx, - &mut turn_id, - &mut turn_seq, - pending_deliveries, - ) - .await; - } - Input::Turn(outcome) => { - let id = turn_id - .take() - .expect("turn outcome requires an active turn"); - finish_turn(state, events, &id, outcome).await; - } - Input::Io(reason) => break Some(reason), + fn turn_end( + &self, + state: &mut State, + result: Result, + ) -> TurnEnd { + let (status, message) = prompt_status(&result); + let usage = result + .ok() + .and_then(|response| response.usage) + .map(|usage| TokenUsage { + freshness: crate::ContextFreshness::Current, + total_processed_tokens: Some(usage.total_tokens), + input_tokens: Some(usage.input_tokens), + cached_input_tokens: usage.cached_read_tokens, + output_tokens: Some(usage.output_tokens), + ..TokenUsage::default() + }) + // Fall back to the live context-window figure from `usage_update`. + .or_else(|| state.usage()); + TurnEnd { + status, + message, + usage, } - }; - - // Only a live transport can acknowledge a graceful close. A dead stdout - // has already ended the session and must not leave us awaiting a response. - if close_reason.is_none() && session.can_close { - let _ = connection - .send_request(acp::CloseSessionRequest::new(session.session_id.clone())) - .block_task() - .await; } - Ok(close_reason) -} - -/// What `session/new` (or `session/load`) settled on. -struct Session { - session_id: acp::SessionId, - model: Option, - can_close: bool, -} - -struct TurnOutcome { - result: Result, -} - -async fn handshake( - connection: &AcpConnection, - agent: &AcpAgent, - opts: &SessionOptions, - state: &Arc>, - events: &Sender, -) -> Result { - // On a leash: an agent that starts but never answers `initialize` (cline - // 3.0.39 does exactly this) would otherwise hang session startup forever, - // with the UI stuck on "Starting…". - let init = future::or( - async { - Some( - connection - .send_request( - acp::InitializeRequest::new(ProtocolVersion::LATEST) - .client_capabilities( - acp::ClientCapabilities::new() - .fs(acp::FileSystemCapabilities::new() - .read_text_file(true) - .write_text_file(true)) - .terminal(true), - ) - .client_info( - acp::Implementation::new("tcode", env!("CARGO_PKG_VERSION")) - .title("Tcode"), - ), - ) - .block_task() - .await, - ) - }, - async { - smol::Timer::after(INITIALIZE_TIMEOUT).await; - None - }, - ) - .await; - let init = match init { - Some(Ok(init)) => init, - Some(Err(err)) => { - return Err(AgentError::Protocol(format!( - "`{}` failed to initialize: {}", - agent.name, - describe(&err) - ))); - } - None => { - return Err(AgentError::Protocol(format!( - "`{}` did not answer `initialize` within {}s — it may not speak ACP over stdio (check its launch arguments)", - agent.name, - INITIALIZE_TIMEOUT.as_secs() - ))); - } - }; + async fn steer( + &self, + _session: &Session, + _request_id: String, + _text: String, + _attachments: Vec, + ) { + } - let caps = init.agent_capabilities.clone(); - // Capability gate: tcode's MCP servers are loopback streamable-HTTP - // endpoints, so they may only be offered to agents that speak MCP over HTTP. - let registrations: Vec<_> = opts.mcp_servers.iter().collect(); - let mcp_servers = mcp_servers(®istrations, &caps); - if !registrations.is_empty() && mcp_servers.is_empty() { - log::info!( - "acp[{}]: no mcpCapabilities.http; Tcode MCP servers are not registered", - agent.id - ); - let _ = events - .send(AgentEvent::Warning { message: format!( - "{} does not support HTTP MCP servers; Tcode MCP tools are unavailable in this session", - agent.name - ) }) + async fn set_approval_mode(&self, session: &Session, _mode: ApprovalMode) { + session + .emit(AgentEvent::Warning { + message: + "ACP agents own their permission policy; use the agent's own mode selector" + .into(), + }) .await; } +} - let resumed = opts - .resume - .as_ref() - .and_then(|cursor| cursor.0.get("acp_session_id")) - .and_then(Value::as_str) - .map(str::to_string) - .filter(|_| caps.load_session); - - let mut loaded_session = None; - if let Some(session_id) = resumed { - // `session/load` replays the whole conversation as `session/update` - // notifications. Our JSONL log is the authoritative history and the - // UI has already folded it, so the replay is swallowed (see - // `State::replaying`); we only want the session live again. - state.lock_recover().replaying = true; - let session_id = acp::SessionId::new(session_id); - let loaded = connection - .send_request( - acp::LoadSessionRequest::new(session_id.clone(), opts.cwd.clone()) - .mcp_servers(mcp_servers.clone()), - ) - .block_task() - .await; - state.lock_recover().replaying = false; - match loaded { - Ok(loaded) => { - loaded_session = Some((session_id, loaded.modes, loaded.config_options)); - } - Err(err) => { - log::warn!( - "acp[{}]: session/load failed ({}); starting a fresh session", - agent.id, - describe(&err) - ); - let _ = events - .send(AgentEvent::Warning { - message: format!( - "{} could not resume the previous conversation; starting a new one", - agent.name - ), - }) - .await; - } +/// The resolved command line for a launch recipe. +/// +/// `Npx` becomes `npm exec --yes -- ` (the registry's own +/// contract); `Binary` / `Custom` run as given. +fn launch_command(launch: &AcpLaunch) -> Result<(PathBuf, Vec), AgentError> { + match launch { + AcpLaunch::Npx { package, args, .. } => { + let npm = crate::resolve_binary(None, "npm")?; + let mut argv = vec![ + "exec".to_string(), + "--yes".to_string(), + "--".to_string(), + package.clone(), + ]; + argv.extend(args.iter().cloned()); + Ok((npm, argv)) } - } - let (session_id, modes, config_options) = match loaded_session { - Some(session) => session, - None => { - let new = new_session(connection, agent, opts, &mcp_servers, &init).await?; - (new.session_id, new.modes, new.config_options) + AcpLaunch::Binary { command, args, .. } => Ok((command.clone(), args.clone())), + AcpLaunch::Custom { command, args, .. } => { + let binary = crate::resolve_binary(None, command)?; + Ok((binary, args.clone())) } - }; - - let wants_plan = opts.approval_mode == ApprovalMode::ReadOnly - || opts.interaction_mode == InteractionMode::Plan; - { - let mut state = state.lock_recover(); - state.ingest_modes(modes.as_ref()); - state.options.ingest(None, config_options.as_deref()); - } - let should_apply_mode = wants_plan - || state - .lock_recover() - .modes - .as_ref() - .is_some_and(|modes| acp_plan_mode(modes).as_ref() == Some(&modes.current_mode_id)); - if should_apply_mode { - apply_interaction_mode( - if wants_plan { - InteractionMode::Plan - } else { - InteractionMode::Build - }, - state, - events, - connection, - &session_id, - ) - .await; } - let model = state.lock_recover().options.current_model(); - - Ok(Session { - session_id, - model, - can_close: caps.session_capabilities.close.is_some(), - }) -} - -fn acp_plan_mode(modes: &acp::SessionModeState) -> Option { - modes - .available_modes - .iter() - .find(|mode| mode.id.0.eq_ignore_ascii_case("plan")) - .map(|mode| mode.id.clone()) -} - -fn first_non_plan_mode(modes: &acp::SessionModeState) -> Option { - let plan = acp_plan_mode(modes); - modes - .available_modes - .iter() - .find(|mode| Some(&mode.id) != plan.as_ref()) - .map(|mode| mode.id.clone()) } async fn new_session( - connection: &AcpConnection, agent: &AcpAgent, - opts: &SessionOptions, + setup: &Setup<'_>, mcp_servers: &[acp::McpServer], - init: &acp::InitializeResponse, ) -> Result { + let (connection, opts, init) = (setup.connection, setup.opts, setup.init); let request = || acp::NewSessionRequest::new(opts.cwd.clone()).mcp_servers(mcp_servers.to_vec()); match connection.send_request(request()).block_task().await { @@ -950,2339 +370,3 @@ fn auth_hint(agent: &AcpAgent, init: &acp::InitializeResponse) -> String { agent.name, agent.name ) } - -fn is_auth_required(err: &acp::Error) -> bool { - i32::from(err.code) == -32000 -} - -fn describe(err: &acp::Error) -> String { - match &err.data { - Some(data) => format!("{} ({data})", err.message), - None => err.message.clone(), - } -} - -/// The `mcpServers` array for `session/new`, gated on `mcpCapabilities.http`. -fn mcp_servers( - registrations: &[&McpRegistration], - caps: &acp::AgentCapabilities, -) -> Vec { - if !caps.mcp_capabilities.http { - return Vec::new(); - } - registrations - .iter() - .map(|mcp| { - acp::McpServer::Http( - acp::McpServerHttp::new(mcp.name.clone(), mcp.url.clone()).headers(vec![ - acp::HttpHeader::new("Authorization", format!("Bearer {}", mcp.bearer_token)), - ]), - ) - }) - .collect() -} - -#[allow(clippy::too_many_arguments)] -async fn handle_command( - command: SessionCommand, - executor: &Rc>, - connection: &AcpConnection, - session: &Session, - state: &Arc>, - events: &Sender, - turn_tx: &Sender, - turn_id: &mut Option, - turn_seq: &mut u64, - pending_deliveries: &Arc>>, -) { - match command { - SessionCommand::Steer { .. } => {} - SessionCommand::SendTurn { - delivery_id, - text, - attachments, - .. - } => { - *turn_seq += 1; - let id = format!("turn-{turn_seq}"); - *turn_id = Some(id.clone()); - state.lock_recover().turn = Some(id.clone()); - - let request = acp::PromptRequest::new( - session.session_id.clone(), - prompt_blocks(&text, &attachments), - ); - // The observed stdio writer consumes this id when the complete - // `session/prompt` JSON-RPC line reaches the child. - pending_deliveries.lock_recover().push_back(delivery_id); - let request = connection.send_request(request); - let _ = events - .send(AgentEvent::TurnStarted { - turn_id: id.clone(), - }) - .await; - let turn_tx = turn_tx.clone(); - executor - .spawn(async move { - let result = request.block_task().await; - let _ = turn_tx.send(TurnOutcome { result }).await; - }) - .detach(); - } - SessionCommand::Interrupt => { - if turn_id.is_none() { - return; - } - // Every in-flight permission request must be answered with - // `cancelled` first: the protocol requires it, and the agent will - // not settle the turn otherwise. - let pending: Vec> = state - .lock_recover() - .approvals - .drain() - .map(|(_, (responder, _))| responder) - .collect(); - for responder in pending { - let _ = responder - .send(acp::RequestPermissionOutcome::Cancelled) - .await; - } - let _ = connection - .send_notification(acp::CancelNotification::new(session.session_id.clone())); - // The agent still owes us the `session/prompt` response carrying - // `stopReason: cancelled`; the turn completes when it lands. - } - SessionCommand::RespondApproval { - request_id, - decision, - } => { - let approval = state.lock_recover().approvals.remove(&request_id); - let Some((responder, options)) = approval else { - log::warn!("acp: no pending approval {request_id}"); - return; - }; - let outcome = match approval_outcome(&decision, &options) { - Some(outcome) => outcome, - None => { - let _ = events - .send(AgentEvent::Warning { - message: - "the agent offered no matching permission option; cancelling instead" - .into(), - }) - .await; - acp::RequestPermissionOutcome::Cancelled - } - }; - let _ = responder.send(outcome).await; - let _ = events - .send(AgentEvent::ApprovalResolved { - request_id, - decision, - }) - .await; - } - SessionCommand::SetOption { id, value } => { - let Some(origin) = state.lock_recover().options.origin(&id) else { - log::warn!("acp: unknown option id `{id}`"); - return; - }; - match set_option(connection, session, &origin, &value).await { - Ok(config_options) => { - { - let mut state = state.lock_recover(); - if let Some(options) = config_options.as_deref() { - state.options.ingest(None, Some(options)); - } - if origin == OptionOrigin::Mode { - if let Some(mode) = value.as_str() { - state.select_mode(acp::SessionModeId::new(mode)); - } - } else { - state.options.select(&id, value); - } - } - emit_provider_options(state, events, false).await; - } - Err(err) => { - let _ = events - .send(AgentEvent::Warning { - message: format!("could not apply `{id}`: {}", describe(&err)), - }) - .await; - } - } - } - SessionCommand::RespondUserInput { .. } => { - // ACP has no structured-question equivalent (`session/elicitation` - // is not in the pinned schema), so we never raise one. - } - SessionCommand::SetApprovalMode(_) => { - let _ = events - .send(AgentEvent::Warning { - message: - "ACP agents own their permission policy; use the agent's own mode selector" - .into(), - }) - .await; - } - SessionCommand::SetInteractionMode(mode) => { - apply_interaction_mode(mode, state, events, connection, &session.session_id).await; - } - SessionCommand::Rewind { - checkpoint_id, - mode, - } => { - let _ = events - .send(AgentEvent::RewindFailed { - checkpoint_id, - mode, - error: "this ACP agent does not advertise a native rewind operation".into(), - }) - .await; - } - SessionCommand::Shutdown => unreachable!("handled by the caller"), - } -} - -async fn set_option( - connection: &AcpConnection, - session: &Session, - origin: &OptionOrigin, - value: &Value, -) -> Result>, acp::Error> { - match origin { - OptionOrigin::Mode => { - let Some(mode) = value.as_str() else { - return Err(acp::Error::invalid_params()); - }; - connection - .send_request(acp::SetSessionModeRequest::new( - session.session_id.clone(), - acp::SessionModeId::new(mode), - )) - .block_task() - .await?; - Ok(None) - } - OptionOrigin::Config(config_id) => { - let value = match value { - Value::Bool(value) => acp::SessionConfigOptionValue::boolean(*value), - Value::String(value) => acp::SessionConfigOptionValue::value_id( - acp::SessionConfigValueId::new(value.as_str()), - ), - _ => return Err(acp::Error::invalid_params()), - }; - let response = connection - .send_request(acp::SetSessionConfigOptionRequest::new( - session.session_id.clone(), - config_id.clone(), - value, - )) - .block_task() - .await?; - Ok(Some(response.config_options)) - } - } -} - -async fn apply_interaction_mode( - mode: InteractionMode, - state: &Arc>, - events: &Sender, - connection: &AcpConnection, - session_id: &acp::SessionId, -) { - let target = interaction_mode_target(mode, &state.lock_recover()); - let Some(target) = target else { - let _ = events.send(missing_mode_warning(mode)).await; - // Republish the actual selection so the runtime rolls back its - // optimistic Build/Plan toggle instead of leaving a lying chip. - emit_provider_options(state, events, true).await; - return; - }; - - if state - .lock_recover() - .modes - .as_ref() - .is_some_and(|modes| modes.current_mode_id == target) - { - return; - } - - match connection - .send_request(acp::SetSessionModeRequest::new( - session_id.clone(), - target.clone(), - )) - .block_task() - .await - { - Ok(_) => { - state.lock_recover().select_mode(target); - emit_provider_options(state, events, false).await; - } - Err(err) => { - let _ = events - .send(AgentEvent::Warning { - message: format!("could not switch this agent's mode: {}", describe(&err)), - }) - .await; - emit_provider_options(state, events, true).await; - } - } -} - -fn interaction_mode_target(mode: InteractionMode, state: &State) -> Option { - match mode { - InteractionMode::Plan => state.modes.as_ref().and_then(acp_plan_mode), - InteractionMode::Build => state.build_mode(), - } -} - -fn missing_mode_warning(mode: InteractionMode) -> AgentEvent { - let mode_name = match mode { - InteractionMode::Plan => "Plan", - InteractionMode::Build => "Build", - }; - AgentEvent::Warning { - message: format!( - "This agent does not advertise a {mode_name} mode; the mode switch was not applied." - ), - } -} - -/// Map our four fixed decisions onto the agent's own permission options (so the -/// existing approval UI keeps working), and pass `Option(id)` straight through. -fn approval_outcome( - decision: &ApprovalDecision, - options: &[ApprovalOption], -) -> Option { - let pick = |kinds: &[ApprovalOptionKind]| -> Option { - kinds.iter().find_map(|wanted| { - options - .iter() - .find(|option| option.kind == *wanted) - .map(|option| option.id.clone()) - }) - }; - let selected = match decision { - ApprovalDecision::Cancel => return Some(acp::RequestPermissionOutcome::Cancelled), - ApprovalDecision::Option(id) => Some(id.clone()), - ApprovalDecision::Approve => pick(&[ - ApprovalOptionKind::AllowOnce, - ApprovalOptionKind::AllowAlways, - ]), - ApprovalDecision::ApproveForSession => pick(&[ - ApprovalOptionKind::AllowAlways, - ApprovalOptionKind::AllowOnce, - ]), - ApprovalDecision::Deny => pick(&[ - ApprovalOptionKind::RejectOnce, - ApprovalOptionKind::RejectAlways, - ]), - }?; - Some(acp::RequestPermissionOutcome::Selected( - acp::SelectedPermissionOutcome::new(acp::PermissionOptionId::new(selected)), - )) -} - -async fn finish_turn( - state: &Arc>, - events: &Sender, - turn_id: &str, - outcome: TurnOutcome, -) { - let tail = state.lock_recover().flush_text(); - for event in tail { - let _ = events.send(event).await; - } - state.lock_recover().turn = None; - - let (status, message, usage) = match outcome.result { - Ok(response) => { - let usage = response.usage.as_ref().map(|usage| TokenUsage { - freshness: crate::ContextFreshness::Current, - total_processed_tokens: Some(usage.total_tokens), - input_tokens: Some(usage.input_tokens), - cached_input_tokens: usage.cached_read_tokens, - output_tokens: Some(usage.output_tokens), - ..TokenUsage::default() - }); - let (status, message) = stop_reason_status(response.stop_reason); - (status, message, usage) - } - // `-32800 request_cancelled`: the agent aborted the prompt outright - // instead of returning `stopReason: cancelled`. - Err(err) if i32::from(err.code) == -32800 => (TurnStatus::Interrupted, None, None), - Err(err) => (TurnStatus::Failed, Some(describe(&err)), None), - }; - if let Some(message) = message { - let _ = events - .send(AgentEvent::Error { - message, - fatal: false, - }) - .await; - } - // Fall back to the live context-window figure from `usage_update`. - let usage = usage.or_else(|| state.lock_recover().usage); - let _ = events - .send(AgentEvent::TurnCompleted { - turn_id: turn_id.to_string(), - status, - usage, - }) - .await; -} - -/// `stopReason` → canonical turn status, plus the message to surface (if any). -fn stop_reason_status(reason: acp::StopReason) -> (TurnStatus, Option) { - match reason { - acp::StopReason::EndTurn => (TurnStatus::Completed, None), - acp::StopReason::Cancelled => (TurnStatus::Interrupted, None), - acp::StopReason::Refusal => ( - TurnStatus::Failed, - Some("The agent refused to continue this turn.".into()), - ), - acp::StopReason::MaxTokens => ( - TurnStatus::Failed, - Some("The agent stopped: token limit reached.".into()), - ), - acp::StopReason::MaxTurnRequests => ( - TurnStatus::Failed, - Some("The agent stopped: too many model requests in one turn.".into()), - ), - other => ( - TurnStatus::Failed, - Some(format!("The agent stopped: {other:?}")), - ), - } -} - -fn prompt_blocks(text: &str, attachments: &[Attachment]) -> Vec { - let mut blocks = Vec::with_capacity(1 + attachments.len()); - blocks.push(acp::ContentBlock::Text(acp::TextContent::new( - text.to_string(), - ))); - for attachment in attachments { - blocks.push(acp::ContentBlock::Image(acp::ImageContent::new( - attachment.data_base64.clone(), - attachment.media_type.clone(), - ))); - } - blocks -} - -async fn emit_provider_options( - state: &Arc>, - events: &Sender, - emit_empty: bool, -) { - let event = state.lock_recover().provider_options(); - if matches!( - &event, - AgentEvent::ProviderOptions { descriptors, .. } if descriptors.is_empty() - ) && !emit_empty - { - return; - } - let _ = events.send(event).await; -} - -/// Where a canonical option id came from, so `SetOption` routes to the right -/// ACP method. -#[derive(Debug, Clone, PartialEq)] -enum OptionOrigin { - Mode, - Config(acp::SessionConfigId), -} - -/// The agent's self-described modes and config options, mapped onto our -/// [`OptionDescriptor`]s (which the composer's traits picker already renders). -#[derive(Default)] -struct OptionRegistry { - records: Vec, -} - -struct OptionRecord { - descriptor: OptionDescriptor, - selection: OptionSelection, - origin: OptionOrigin, -} - -impl OptionRegistry { - fn ingest( - &mut self, - modes: Option<&acp::SessionModeState>, - config: Option<&[acp::SessionConfigOption]>, - ) { - if let Some(modes) = modes { - self.upsert( - OptionDescriptor::Select { - id: MODE_OPTION_ID.to_string(), - label: "Mode".to_string(), - options: modes - .available_modes - .iter() - .map(|mode| SelectOption { - value: mode.id.0.to_string(), - label: mode.name.clone(), - description: mode.description.clone(), - }) - .collect(), - default_value: Some(modes.current_mode_id.0.to_string()), - }, - OptionOrigin::Mode, - Value::String(modes.current_mode_id.0.to_string()), - ); - } - for option in config.unwrap_or_default() { - // Protocol 1.2 replaced the standalone model state/set-model RPC - // with categorized config options. Preserve tcode's canonical ids - // while routing both model and config changes through the config RPC. - let id = match option.category.as_ref() { - Some(acp::SessionConfigOptionCategory::Mode) => MODE_OPTION_ID.to_string(), - Some(acp::SessionConfigOptionCategory::Model) => MODEL_OPTION_ID.to_string(), - _ => format!("{CONFIG_OPTION_PREFIX}{}", option.id.0), - }; - match &option.kind { - acp::SessionConfigKind::Select(select) => { - let options = match &select.options { - acp::SessionConfigSelectOptions::Ungrouped(flat) => flat - .iter() - .map(|option| SelectOption { - value: option.value.0.to_string(), - label: option.name.clone(), - description: option.description.clone(), - }) - .collect(), - acp::SessionConfigSelectOptions::Grouped(groups) => groups - .iter() - .flat_map(|group| { - group.options.iter().map(move |option| SelectOption { - value: option.value.0.to_string(), - label: format!("{} · {}", group.name, option.name), - description: option.description.clone(), - }) - }) - .collect(), - _ => Vec::new(), - }; - self.upsert( - OptionDescriptor::Select { - id, - label: option.name.clone(), - options, - default_value: Some(select.current_value.0.to_string()), - }, - OptionOrigin::Config(option.id.clone()), - Value::String(select.current_value.0.to_string()), - ); - } - acp::SessionConfigKind::Boolean(boolean) => self.upsert( - OptionDescriptor::Boolean { - id, - label: option.name.clone(), - default_value: boolean.current_value, - }, - OptionOrigin::Config(option.id.clone()), - Value::Bool(boolean.current_value), - ), - _ => log::warn!("acp: unsupported config-option kind for `{}`", option.id.0), - } - } - } - - fn upsert(&mut self, descriptor: OptionDescriptor, origin: OptionOrigin, value: Value) { - let id = descriptor_id(&descriptor).to_string(); - match self - .records - .iter_mut() - .find(|record| record.selection.id == id) - { - Some(record) => { - record.descriptor = descriptor; - record.selection.value = value; - record.origin = origin; - } - None => self.records.push(OptionRecord { - descriptor, - selection: OptionSelection { id, value }, - origin, - }), - } - } - - fn select(&mut self, id: &str, value: Value) { - if let Some(record) = self - .records - .iter_mut() - .find(|record| record.selection.id == id) - { - record.selection.value = value; - } - } - - fn origin(&self, id: &str) -> Option { - self.records - .iter() - .find(|record| record.selection.id == id) - .map(|record| record.origin.clone()) - } - - fn current_model(&self) -> Option { - self.records - .iter() - .find(|record| record.selection.id == MODEL_OPTION_ID) - .and_then(|record| record.selection.value.as_str().map(str::to_string)) - } -} - -fn descriptor_id(descriptor: &OptionDescriptor) -> &str { - match descriptor { - OptionDescriptor::Select { id, .. } | OptionDescriptor::Boolean { id, .. } => id, - } -} - -/// A tool call as last known. `tool_call_update` is a partial patch, so the -/// merged state lives here and every update re-renders the whole item. -#[derive(Debug, Clone, Default)] -struct ToolState { - title: String, - kind: acp::ToolKind, - status: acp::ToolCallStatus, - content: Vec, - locations: Vec, - raw_input: Option, - raw_output: Option, - announced: bool, -} - -/// The text block currently streaming (assistant prose or thinking). -struct TextStream { - id: String, - kind: DeltaKind, - text: String, -} - -struct State { - cwd: PathBuf, - turn: Option, - /// True while `session/load` replays history we already have on disk. - replaying: bool, - tools: HashMap, - text: Option, - approvals: HashMap, Vec)>, - approval_seq: u64, - text_seq: u64, - terminal_seq: u64, - terminals: HashMap>, - usage: Option, - options: OptionRegistry, - modes: Option, - previous_non_plan_mode: Option, -} - -impl State { - fn new(cwd: PathBuf) -> Self { - Self { - cwd, - turn: None, - replaying: false, - tools: HashMap::new(), - text: None, - approvals: HashMap::new(), - approval_seq: 0, - text_seq: 0, - terminal_seq: 0, - terminals: HashMap::new(), - usage: None, - options: OptionRegistry::default(), - modes: None, - previous_non_plan_mode: None, - } - } - - fn ingest_modes(&mut self, modes: Option<&acp::SessionModeState>) { - let Some(modes) = modes else { - return; - }; - self.modes = Some(modes.clone()); - self.select_mode(modes.current_mode_id.clone()); - self.options.ingest(Some(modes), None); - } - - fn select_mode(&mut self, mode: acp::SessionModeId) { - let is_plan = self - .modes - .as_ref() - .and_then(acp_plan_mode) - .is_some_and(|plan| plan == mode); - if !is_plan { - self.previous_non_plan_mode = Some(mode.clone()); - } - if let Some(modes) = self.modes.as_mut() { - modes.current_mode_id = mode.clone(); - } - self.options - .select(MODE_OPTION_ID, Value::String(mode.0.to_string())); - } - - fn build_mode(&self) -> Option { - let modes = self.modes.as_ref()?; - // ACP exposes no distinct default mode. Remember the last non-plan - // selection from before Plan; if the session began in Plan, fall back - // to the first advertised non-plan mode (the agent's ordering). - self.previous_non_plan_mode - .clone() - .or_else(|| first_non_plan_mode(modes)) - } - - fn provider_options(&self) -> AgentEvent { - AgentEvent::ProviderOptions { - descriptors: self - .options - .records - .iter() - .map(|record| record.descriptor.clone()) - .collect(), - selections: self - .options - .records - .iter() - .map(|record| record.selection.clone()) - .collect(), - } - } - - /// Close the open text block, emitting its final `ItemCompleted`. - fn flush_text(&mut self) -> Vec { - let Some(stream) = self.text.take() else { - return Vec::new(); - }; - if stream.text.is_empty() { - return Vec::new(); - } - let content = match stream.kind { - DeltaKind::ReasoningText => ItemContent::Reasoning { text: stream.text }, - _ => ItemContent::AssistantMessage { text: stream.text }, - }; - vec![AgentEvent::ItemCompleted(ThreadItem { - id: stream.id, - parent_item_id: None, - content, - })] - } - - /// Append a streaming chunk, opening a new item when the block changes. - fn push_text(&mut self, kind: DeltaKind, text: String) -> Vec { - let mut events = Vec::new(); - let same_block = self.text.as_ref().is_some_and(|stream| stream.kind == kind); - if !same_block { - events.extend(self.flush_text()); - self.text_seq += 1; - let prefix = match kind { - DeltaKind::ReasoningText => "thought", - _ => "msg", - }; - self.text = Some(TextStream { - id: format!("{prefix}-{}", self.text_seq), - kind, - text: String::new(), - }); - } - let stream = self.text.as_mut().expect("stream just opened"); - stream.text.push_str(&text); - events.push(AgentEvent::Delta { - item_id: stream.id.clone(), - kind, - text, - }); - events - } - - /// Map one `session/update` onto canonical events, merging our tool-call and - /// option state along the way. Pure w.r.t. the outside world — this is the - /// function the mapping tests drive. - fn apply_update(&mut self, update: acp::SessionUpdate) -> Vec { - match update { - // The app synthesizes the canonical user message at send time; - // rendering the agent's echo of it would double it. - acp::SessionUpdate::UserMessageChunk(_) => Vec::new(), - acp::SessionUpdate::AgentMessageChunk(chunk) => { - let text = content_text(&chunk.content); - if text.is_empty() { - return Vec::new(); - } - self.push_text(DeltaKind::AssistantText, text) - } - acp::SessionUpdate::AgentThoughtChunk(chunk) => { - let text = content_text(&chunk.content); - if text.is_empty() { - return Vec::new(); - } - self.push_text(DeltaKind::ReasoningText, text) - } - acp::SessionUpdate::ToolCall(call) => { - let mut events = self.flush_text(); - let id = call.tool_call_id.0.to_string(); - let tool = ToolState { - title: call.title, - kind: call.kind, - status: call.status, - content: call.content, - locations: call.locations, - raw_input: call.raw_input, - raw_output: call.raw_output, - announced: true, - }; - let item = self.tool_item(&id, &tool); - let existed = self - .tools - .insert(id, tool) - .is_some_and(|previous| previous.announced); - events.push(if existed { - AgentEvent::ItemUpdated(item) - } else { - AgentEvent::ItemStarted(item) - }); - events - } - acp::SessionUpdate::ToolCallUpdate(update) => { - let mut events = self.flush_text(); - let id = update.tool_call_id.0.to_string(); - let entry = self.tools.entry(id.clone()).or_default(); - let fields = update.fields; - if let Some(title) = fields.title { - entry.title = title; - } - if let Some(kind) = fields.kind { - entry.kind = kind; - } - if let Some(status) = fields.status { - entry.status = status; - } - // `content` and `locations` are whole-array replacements. - if let Some(content) = fields.content { - entry.content = content; - } - if let Some(locations) = fields.locations { - entry.locations = locations; - } - if let Some(raw_input) = fields.raw_input { - entry.raw_input = Some(raw_input); - } - if let Some(raw_output) = fields.raw_output { - entry.raw_output = Some(raw_output); - } - let announced = std::mem::replace(&mut entry.announced, true); - let status = entry.status; - let tool = entry.clone(); - let item = self.tool_item(&id, &tool); - events.push(match (announced, status) { - (false, _) => AgentEvent::ItemStarted(item), - (true, acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed) => { - AgentEvent::ItemCompleted(item) - } - _ => AgentEvent::ItemUpdated(item), - }); - events - } - acp::SessionUpdate::Plan(plan) => vec![AgentEvent::PlanUpdated { - turn_id: self.turn.clone(), - explanation: None, - steps: plan.entries.iter().map(plan_step).collect(), - }], - acp::SessionUpdate::AvailableCommandsUpdate(update) => { - vec![AgentEvent::ProviderCommands { - commands: update - .available_commands - .iter() - .map(|command| ProviderCommand { - name: command.name.clone(), - description: Some(command.description.clone()), - kind: ProviderCommandKind::Command, - }) - .collect(), - }] - } - acp::SessionUpdate::CurrentModeUpdate(update) => { - self.select_mode(update.current_mode_id.clone()); - vec![self.provider_options()] - } - acp::SessionUpdate::ConfigOptionUpdate(update) => { - self.options.ingest(None, Some(&update.config_options)); - vec![self.provider_options()] - } - acp::SessionUpdate::UsageUpdate(usage) => { - let usage = TokenUsage { - freshness: crate::ContextFreshness::Current, - used_tokens: Some(usage.used), - context_window: Some(usage.size), - cost_usd: usage - .cost - .as_ref() - .filter(|cost| cost.currency.eq_ignore_ascii_case("USD")) - .map(|cost| cost.amount), - ..Default::default() - }; - self.usage = Some(usage); - vec![AgentEvent::TokenUsage(usage)] - } - // Session titles are ours (the sidebar names sessions from the first - // user message), and unknown variants degrade to nothing. - _ => Vec::new(), - } - } - - /// The canonical item for a tool call, keyed by [`acp::ToolKind`]. - fn tool_item(&self, id: &str, tool: &ToolState) -> ThreadItem { - let status = map_status(tool.status); - let content = match tool.kind { - acp::ToolKind::Execute => ItemContent::CommandExecution { - command: command_of(tool), - output: self.tool_output(tool), - exit_code: self.exit_code_of(tool), - status, - }, - acp::ToolKind::Edit | acp::ToolKind::Delete | acp::ToolKind::Move => { - let changes = file_changes(tool); - if changes.is_empty() { - tool_call_content(tool, status, self.tool_output(tool)) - } else { - ItemContent::FileChange { changes, status } - } - } - acp::ToolKind::Think => { - let text = self.tool_output(tool); - ItemContent::Reasoning { - text: if text.is_empty() { - tool.title.clone() - } else { - text - }, - } - } - _ => tool_call_content(tool, status, self.tool_output(tool)), - }; - ThreadItem { - id: id.into(), - parent_item_id: None, - content, - } - } - - /// Everything the tool produced, as display text: its content blocks plus - /// the live output of any terminal it embedded. - fn tool_output(&self, tool: &ToolState) -> String { - let mut parts: Vec = Vec::new(); - for content in &tool.content { - match content { - acp::ToolCallContent::Content(block) => { - let text = content_text(&block.content); - if !text.is_empty() { - parts.push(text); - } - } - acp::ToolCallContent::Terminal(terminal) => { - if let Some(terminal) = self.terminals.get(terminal.terminal_id.0.as_ref()) { - let output = terminal.output.lock_recover(); - if !output.is_empty() { - parts.push(output.clone()); - } - } - } - // Diffs render as FileChange, not as text. - _ => {} - } - } - if parts.is_empty() - && let Some(raw) = &tool.raw_output - { - match raw.as_str() { - Some(text) => parts.push(text.to_string()), - None if !raw.is_null() => parts.push(raw.to_string()), - None => {} - } - } - parts.join("\n") - } - - fn exit_code_of(&self, tool: &ToolState) -> Option { - for content in &tool.content { - if let acp::ToolCallContent::Terminal(terminal) = content - && let Some(terminal) = self.terminals.get(terminal.terminal_id.0.as_ref()) - && let Some(status) = terminal.exit.lock_recover().as_ref() - { - return status.exit_code.map(|code| code as i32); - } - } - tool.raw_output - .as_ref() - .and_then(|raw| raw.get("exitCode").or_else(|| raw.get("exit_code"))) - .and_then(Value::as_i64) - .map(|code| code as i32) - } - - /// Reject any path the agent asks for that escapes the session's cwd. - fn resolve_path(&self, path: &Path) -> Result { - let absolute = if path.is_absolute() { - path.to_path_buf() - } else { - self.cwd.join(path) - }; - let normalized = normalize(&absolute); - if !normalized.starts_with(normalize(&self.cwd)) { - return Err(acp::Error::new( - -32602, - format!( - "path `{}` is outside the session working directory", - path.display() - ), - )); - } - Ok(normalized) - } -} - -/// Lexical path normalization (the file may not exist yet, so `canonicalize` is -/// not an option for writes). -fn normalize(path: &Path) -> PathBuf { - let mut out = PathBuf::new(); - for component in path.components() { - match component { - std::path::Component::ParentDir => { - out.pop(); - } - std::path::Component::CurDir => {} - other => out.push(other.as_os_str()), - } - } - out -} - -/// The shell command behind an `execute` tool call: the underlying tool's own -/// arguments when it exposed them, else the agent's title. -fn command_of(tool: &ToolState) -> String { - tool.raw_input - .as_ref() - .and_then(|input| input.get("command").or_else(|| input.get("cmd"))) - .and_then(|command| match command { - Value::String(command) => Some(command.clone()), - Value::Array(parts) => Some( - parts - .iter() - .filter_map(Value::as_str) - .collect::>() - .join(" "), - ), - _ => None, - }) - .unwrap_or_else(|| tool.title.clone()) -} - -fn tool_call_content(tool: &ToolState, status: ItemStatus, output: String) -> ItemContent { - ItemContent::ToolCall { - name: tool.title.clone(), - input: tool.raw_input.clone().unwrap_or(Value::Null), - output: (!output.is_empty()).then_some(output), - status, - } -} - -/// `diff` content blocks → canonical [`FileChange`]s (with a unified diff, so -/// the diff panel renders them like every other provider's edits). -fn file_changes(tool: &ToolState) -> Vec { - let mut changes: Vec = tool - .content - .iter() - .filter_map(|content| match content { - acp::ToolCallContent::Diff(diff) => { - let path = diff.path.to_string_lossy().into_owned(); - Some(FileChange { - kind: match (tool.kind, diff.old_text.as_deref()) { - (acp::ToolKind::Delete, _) => FileChangeKind::Delete, - (acp::ToolKind::Move, _) => FileChangeKind::Rename, - (_, None | Some("")) => FileChangeKind::Create, - _ => FileChangeKind::Modify, - }, - diff: Some(unified_diff( - &path, - diff.old_text.as_deref().unwrap_or(""), - &diff.new_text, - )), - path, - }) - } - _ => None, - }) - .collect(); - if changes.is_empty() && matches!(tool.kind, acp::ToolKind::Delete | acp::ToolKind::Move) { - // Deletes and renames carry no diff; the locations are all we get. - changes = tool - .locations - .iter() - .map(|location| FileChange { - path: location.path.to_string_lossy().into_owned(), - kind: match tool.kind { - acp::ToolKind::Delete => FileChangeKind::Delete, - _ => FileChangeKind::Rename, - }, - diff: None, - }) - .collect(); - } - changes -} - -/// A whole-file unified diff. ACP hands us before/after text rather than a -/// patch, and the diff panel wants `@@` hunks; a single hunk covering the file -/// is exactly what the panel's full-file path already renders. -fn unified_diff(path: &str, old: &str, new: &str) -> String { - let old_lines: Vec<&str> = old.lines().collect(); - let new_lines: Vec<&str> = new.lines().collect(); - let mut out = String::new(); - if old_lines.is_empty() { - out.push_str("--- /dev/null\n"); - } else { - let _ = writeln!(out, "--- a/{path}"); - } - if new_lines.is_empty() { - out.push_str("+++ /dev/null\n"); - } else { - let _ = writeln!(out, "+++ b/{path}"); - } - let _ = writeln!( - out, - "@@ -{},{} +{},{} @@", - usize::from(!old_lines.is_empty()), - old_lines.len(), - usize::from(!new_lines.is_empty()), - new_lines.len() - ); - // Trim the common prefix/suffix so the (very common) single-line edit does - // not render as a whole-file rewrite. - let prefix = old_lines - .iter() - .zip(new_lines.iter()) - .take_while(|(old, new)| old == new) - .count(); - let suffix = old_lines - .iter() - .rev() - .zip(new_lines.iter().rev()) - .take_while(|(old, new)| old == new) - .count() - .min(old_lines.len() - prefix) - .min(new_lines.len() - prefix); - for line in &old_lines[..prefix] { - let _ = writeln!(out, " {line}"); - } - for line in &old_lines[prefix..old_lines.len() - suffix] { - let _ = writeln!(out, "-{line}"); - } - for line in &new_lines[prefix..new_lines.len() - suffix] { - let _ = writeln!(out, "+{line}"); - } - for line in &old_lines[old_lines.len() - suffix..] { - let _ = writeln!(out, " {line}"); - } - out -} - -fn map_status(status: acp::ToolCallStatus) -> ItemStatus { - match status { - acp::ToolCallStatus::Pending | acp::ToolCallStatus::InProgress => ItemStatus::InProgress, - acp::ToolCallStatus::Completed => ItemStatus::Completed, - acp::ToolCallStatus::Failed => ItemStatus::Failed, - _ => ItemStatus::InProgress, - } -} - -fn plan_step(entry: &acp::PlanEntry) -> PlanStep { - PlanStep { - step: entry.content.clone(), - status: match entry.status { - acp::PlanEntryStatus::InProgress => PlanStepStatus::InProgress, - acp::PlanEntryStatus::Completed => PlanStepStatus::Completed, - _ => PlanStepStatus::Pending, - }, - } -} - -/// Displayable text for a content block (images/audio degrade to a marker). -fn content_text(block: &acp::ContentBlock) -> String { - match block { - acp::ContentBlock::Text(text) => text.text.clone(), - acp::ContentBlock::ResourceLink(link) => link.uri.clone(), - acp::ContentBlock::Resource(resource) => match &resource.resource { - acp::EmbeddedResourceResource::TextResourceContents(text) => text.text.clone(), - _ => String::new(), - }, - acp::ContentBlock::Image(_) => "[image]".to_string(), - acp::ContentBlock::Audio(_) => "[audio]".to_string(), - _ => String::new(), - } -} - -/// The approval an agent's `session/request_permission` becomes. -fn approval_request( - id: String, - turn_id: Option, - tool: &acp::ToolCallUpdate, - options: &[acp::PermissionOption], -) -> ApprovalRequest { - let fields = &tool.fields; - let title = fields.title.clone().unwrap_or_default(); - let kind = fields.kind.unwrap_or_default(); - let raw_input = fields.raw_input.clone().unwrap_or(Value::Null); - let approval_kind = match kind { - acp::ToolKind::Execute => ApprovalKind::ExecCommand { - command: command_of(&ToolState { - title: title.clone(), - raw_input: fields.raw_input.clone(), - ..Default::default() - }), - cwd: None, - reason: None, - }, - acp::ToolKind::Edit | acp::ToolKind::Delete | acp::ToolKind::Move => { - let changes = file_changes(&ToolState { - title: title.clone(), - kind, - content: fields.content.clone().unwrap_or_default(), - locations: fields.locations.clone().unwrap_or_default(), - raw_input: fields.raw_input.clone(), - ..Default::default() - }); - if changes.is_empty() { - ApprovalKind::ToolUse { - name: title.clone(), - input: raw_input, - detail: title, - } - } else { - ApprovalKind::FileChange { - changes, - reason: None, - } - } - } - acp::ToolKind::Read | acp::ToolKind::Search | acp::ToolKind::Fetch => { - ApprovalKind::FileRead { detail: title } - } - _ => ApprovalKind::ToolUse { - name: title.clone(), - input: raw_input, - detail: title, - }, - }; - ApprovalRequest { - id, - turn_id, - kind: approval_kind, - options: options - .iter() - .map(|option| ApprovalOption { - id: option.option_id.0.to_string(), - label: option.name.clone(), - kind: match option.kind { - acp::PermissionOptionKind::AllowOnce => ApprovalOptionKind::AllowOnce, - acp::PermissionOptionKind::AllowAlways => ApprovalOptionKind::AllowAlways, - acp::PermissionOptionKind::RejectAlways => ApprovalOptionKind::RejectAlways, - _ => ApprovalOptionKind::RejectOnce, - }, - }) - .collect(), - } -} - -#[derive(Clone)] -struct AcpClient { - events: Sender, - state: Arc>, -} - -impl AcpClient { - async fn session_notification(&self, args: acp::SessionNotification) -> Result<(), acp::Error> { - // While `session/load` replays the conversation we already have on disk, - // swallow everything: our JSONL log is the source of truth and the - // timeline was folded from it before the process even started. - if self.state.lock_recover().replaying { - return Ok(()); - } - let events = self.state.lock_recover().apply_update(args.update); - for event in events { - let _ = self.events.send(event).await; - } - Ok(()) - } - - async fn request_permission( - &self, - args: acp::RequestPermissionRequest, - ) -> Result { - let (request_id, turn) = { - let mut state = self.state.lock_recover(); - state.approval_seq += 1; - ( - format!("acp-approval-{}", state.approval_seq), - state.turn.clone(), - ) - }; - let request = approval_request(request_id.clone(), turn, &args.tool_call, &args.options); - let (responder, decided) = smol::channel::bounded(1); - { - let mut state = self.state.lock_recover(); - state - .approvals - .insert(request_id.clone(), (responder, request.options.clone())); - // Keep the tool card in step with what we are asking about. - let id = args.tool_call.tool_call_id.0.to_string(); - let entry = state.tools.entry(id).or_default(); - if let Some(title) = args.tool_call.fields.title.clone() { - entry.title = title; - } - if let Some(kind) = args.tool_call.fields.kind { - entry.kind = kind; - } - if let Some(raw_input) = args.tool_call.fields.raw_input.clone() { - entry.raw_input = Some(raw_input); - } - } - let _ = self - .events - .send(AgentEvent::ApprovalRequested(request)) - .await; - - let outcome = decided - .recv() - .await - .unwrap_or(acp::RequestPermissionOutcome::Cancelled); - self.state.lock_recover().approvals.remove(&request_id); - Ok(acp::RequestPermissionResponse::new(outcome)) - } - - async fn read_text_file( - &self, - args: acp::ReadTextFileRequest, - ) -> Result { - let path = self.state.lock_recover().resolve_path(&args.path)?; - let content = smol::fs::read_to_string(&path).await.map_err(|err| { - acp::Error::new(-32603, format!("could not read {}: {err}", path.display())) - })?; - // `line` is 1-based; `limit` counts lines from there. - let content = match (args.line, args.limit) { - (None, None) => content, - (line, limit) => { - let start = line.unwrap_or(1).saturating_sub(1) as usize; - let lines: Vec<&str> = content.lines().skip(start).collect(); - let end = limit.map_or(lines.len(), |limit| lines.len().min(limit as usize)); - lines[..end].join("\n") - } - }; - Ok(acp::ReadTextFileResponse::new(content)) - } - - async fn write_text_file( - &self, - args: acp::WriteTextFileRequest, - ) -> Result { - let path = self.state.lock_recover().resolve_path(&args.path)?; - if let Some(parent) = path.parent() { - smol::fs::create_dir_all(parent).await.map_err(|err| { - acp::Error::new( - -32603, - format!("could not create {}: {err}", parent.display()), - ) - })?; - } - smol::fs::write(&path, args.content).await.map_err(|err| { - acp::Error::new(-32603, format!("could not write {}: {err}", path.display())) - })?; - Ok(acp::WriteTextFileResponse::new()) - } - - async fn create_terminal( - &self, - args: acp::CreateTerminalRequest, - connection: &AcpConnection, - ) -> Result { - let cwd = match &args.cwd { - Some(cwd) => self.state.lock_recover().resolve_path(cwd)?, - None => self.state.lock_recover().cwd.clone(), - }; - let terminal = Terminal::spawn( - connection, - &args.command, - &args.args, - &args.env, - &cwd, - args.output_byte_limit - .unwrap_or(DEFAULT_TERMINAL_OUTPUT_LIMIT), - )?; - let id = { - let mut state = self.state.lock_recover(); - state.terminal_seq += 1; - let id = format!("term-{}", state.terminal_seq); - state.terminals.insert(id.clone(), terminal); - id - }; - Ok(acp::CreateTerminalResponse::new(acp::TerminalId::new(id))) - } - - async fn terminal_output( - &self, - args: acp::TerminalOutputRequest, - ) -> Result { - let terminal = self.terminal(&args.terminal_id)?; - let output = terminal.output.lock_recover().clone(); - let truncated = *terminal.truncated.lock_recover(); - let exit_status = terminal.exit.lock_recover().clone(); - Ok(acp::TerminalOutputResponse::new(output, truncated).exit_status(exit_status)) - } - - async fn wait_for_terminal_exit( - &self, - args: acp::WaitForTerminalExitRequest, - ) -> Result { - let terminal = self.terminal(&args.terminal_id)?; - // The sender is dropped once the process exits, closing the channel. - let _ = terminal.done.recv().await; - let exit_status = terminal - .exit - .lock_recover() - .clone() - .unwrap_or_else(acp::TerminalExitStatus::new); - Ok(acp::WaitForTerminalExitResponse::new(exit_status)) - } - - async fn kill_terminal( - &self, - args: acp::KillTerminalRequest, - ) -> Result { - self.terminal(&args.terminal_id)?.kill(); - Ok(acp::KillTerminalResponse::new()) - } - - async fn release_terminal( - &self, - args: acp::ReleaseTerminalRequest, - ) -> Result { - let terminal = self - .state - .lock_recover() - .terminals - .remove(args.terminal_id.0.as_ref()); - if let Some(terminal) = terminal { - terminal.kill(); - } - Ok(acp::ReleaseTerminalResponse::new()) - } - fn terminal(&self, id: &acp::TerminalId) -> Result, acp::Error> { - self.state - .lock_recover() - .terminals - .get(id.0.as_ref()) - .cloned() - .ok_or_else(|| acp::Error::new(-32002, format!("unknown terminal `{}`", id.0))) - } -} - -/// A command the agent asked us to run. Headless: we capture the output and -/// serve `terminal/output` / `terminal/wait_for_exit` from it, and the text is -/// folded into the owning tool card (`ToolCallContent::Terminal`). Wiring these -/// into the terminal drawer needs a canonical event the contract does not have. -struct Terminal { - child: Mutex, - output: Mutex, - truncated: Mutex, - exit: Mutex>, - /// Closed (never sent on) once the process has exited. - done: Receiver<()>, -} - -impl Terminal { - fn spawn( - connection: &AcpConnection, - command: &str, - args: &[String], - env: &[acp::EnvVariable], - cwd: &Path, - limit: u64, - ) -> Result, acp::Error> { - let program = crate::resolve_binary(None, command) - .map_err(|err| acp::Error::new(-32603, err.to_string()))?; - let mut cmd = crate::process::async_command(&program); - cmd.args(args) - .current_dir(cwd) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - for var in env { - cmd.env(&var.name, &var.value); - } - let mut child = cmd - .spawn() - .map_err(|err| acp::Error::new(-32603, format!("could not run `{command}`: {err}")))?; - let stdout = child.stdout.take().ok_or_else(|| { - acp::Error::new(-32603, format!("`{command}` started without piped stdout")) - })?; - let stderr = child.stderr.take().ok_or_else(|| { - acp::Error::new(-32603, format!("`{command}` started without piped stderr")) - })?; - let (done_tx, done) = smol::channel::bounded::<()>(1); - - let terminal = Arc::new(Terminal { - child: Mutex::new(child), - output: Mutex::new(String::new()), - truncated: Mutex::new(false), - exit: Mutex::new(None), - done, - }); - - // stdout and stderr interleave into one buffer, as they do in a terminal. - let streams: [Box; 2] = - [Box::new(stdout), Box::new(stderr)]; - for stream in streams { - connection.spawn({ - let terminal = terminal.clone(); - async move { - let mut stream = stream; - let mut buf = [0u8; 4096]; - loop { - match stream.read(&mut buf).await { - Ok(0) | Err(_) => break, - Ok(read) => terminal.append(&buf[..read], limit), - } - } - Ok(()) - } - })?; - } - - connection.spawn({ - let terminal = terminal.clone(); - async move { - loop { - let status = terminal.child.lock_recover().try_status(); - match status { - Ok(Some(status)) => { - *terminal.exit.lock_recover() = Some(exit_status(&status)); - break; - } - Err(err) => { - log::warn!("acp terminal: {err}"); - break; - } - Ok(None) => smol::Timer::after(Duration::from_millis(25)).await, - }; - } - // Closing the channel wakes every `wait_for_exit`. - drop(done_tx); - Ok(()) - } - })?; - - Ok(terminal) - } - - fn append(&self, bytes: &[u8], limit: u64) { - let mut output = self.output.lock_recover(); - output.push_str(&String::from_utf8_lossy(bytes)); - let limit = limit as usize; - if output.len() > limit { - // Keep the tail (what ACP asks for), cutting on a char boundary. - let mut cut = output.len() - limit; - while !output.is_char_boundary(cut) { - cut += 1; - } - output.drain(..cut); - *self.truncated.lock_recover() = true; - } - } - - fn kill(&self) { - let _ = self.child.lock_recover().kill(); - } -} - -fn exit_status(status: &std::process::ExitStatus) -> acp::TerminalExitStatus { - #[cfg(unix)] - let signal = { - use std::os::unix::process::ExitStatusExt as _; - status.signal().map(|signal| signal.to_string()) - }; - #[cfg(not(unix))] - let signal: Option = None; - acp::TerminalExitStatus::new() - .exit_code(status.code().map(|code| code as u32)) - .signal(signal) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn state() -> State { - State::new(PathBuf::from("/tmp/tcode-acp-test")) - } - - fn update(json: Value) -> acp::SessionUpdate { - serde_json::from_value(json).expect("valid session/update payload") - } - - fn modes(current: &str, available: &[&str]) -> acp::SessionModeState { - serde_json::from_value(json!({ - "currentModeId": current, - "availableModes": available - .iter() - .map(|id| json!({ "id": id, "name": id })) - .collect::>() - })) - .unwrap() - } - - #[test] - fn plan_mode_requires_advertisement_and_restores_the_previous_mode() { - for previous in ["build", "review"] { - let mut state = state(); - state.ingest_modes(Some(&modes(previous, &["build", "review", "plan"]))); - let plan = interaction_mode_target(InteractionMode::Plan, &state).unwrap(); - assert_eq!(plan, acp::SessionModeId::new("plan")); - state.select_mode(plan); - assert_eq!( - interaction_mode_target(InteractionMode::Build, &state), - Some(acp::SessionModeId::new(previous)) - ); - } - let mut state = state(); - state.ingest_modes(Some(&modes("build", &["build", "review"]))); - assert_eq!(interaction_mode_target(InteractionMode::Plan, &state), None); - assert!(matches!(missing_mode_warning(InteractionMode::Plan), - AgentEvent::Warning { message } if message.contains("does not advertise a Plan mode"))); - } - - #[test] - fn prompt_acceptance_follows_the_complete_stdio_write() { - smol::block_on(async { - use smol::prelude::*; - - let pending = Arc::new(Mutex::new(VecDeque::from([41]))); - let (events, received) = smol::channel::unbounded(); - let inner = smol::io::Cursor::new(Vec::new()); - let mut writer = ObservedWriter::new(inner, pending.clone(), events); - - writer - .write_all( - br#"{"jsonrpc":"2.0","method":"initialize"} -"#, - ) - .await - .unwrap(); - writer - .write_all(br#"{"jsonrpc":"2.0","method":"session/prompt"}"#) - .await - .unwrap(); - assert!( - received.try_recv().is_err(), - "an incomplete line must not ack" - ); - - writer.write_all(b"\n").await.unwrap(); - assert!(matches!( - received.recv().await.unwrap(), - AgentEvent::TurnAccepted { delivery_id: 41 } - )); - assert!(pending.lock_recover().is_empty()); - }); - } - - /// The preview MCP server is a loopback HTTP endpoint: it may only be handed - /// to agents that advertise `mcpCapabilities.http`. - #[test] - fn mcp_server_is_gated_on_the_http_capability() { - let registration = McpRegistration { - name: McpRegistration::SERVER_NAME_PREVIEW.into(), - url: "http://127.0.0.1:5321/mcp".into(), - bearer_token: "tok".into(), - }; - let mut caps = acp::AgentCapabilities::default(); - assert!( - mcp_servers(&[®istration], &caps).is_empty(), - "an agent without mcpCapabilities.http must not be sent the HTTP server" - ); - - caps.mcp_capabilities.http = true; - let servers = mcp_servers(&[®istration], &caps); - let value = serde_json::to_value(&servers[0]).unwrap(); - assert_eq!(value["type"], "http"); - assert_eq!(value["name"], "tcode_preview"); - assert_eq!(value["url"], "http://127.0.0.1:5321/mcp"); - assert_eq!(value["headers"][0]["name"], "Authorization"); - assert_eq!(value["headers"][0]["value"], "Bearer tok"); - - // No preview server running → nothing to send, capability or not. - assert!(mcp_servers(&[], &caps).is_empty()); - - let orchestrate = McpRegistration { - name: McpRegistration::SERVER_NAME_ORCHESTRATE.into(), - url: "http://127.0.0.1:5321/mcp".into(), - bearer_token: "other".into(), - }; - assert_eq!(mcp_servers(&[®istration, &orchestrate], &caps).len(), 2); - assert_eq!(mcp_servers(&[&orchestrate], &caps).len(), 1); - - let computer_use = McpRegistration { - name: McpRegistration::SERVER_NAME_COMPUTER_USE.into(), - url: "http://127.0.0.1:5322/mcp".into(), - bearer_token: "computer-token".into(), - }; - let servers = mcp_servers(&[®istration, &orchestrate, &computer_use], &caps); - assert_eq!(servers.len(), 3); - let value = serde_json::to_value(&servers[2]).unwrap(); - assert_eq!(value["name"], "tcode_computer_use"); - assert_eq!(value["headers"][0]["value"], "Bearer computer-token"); - } - - #[test] - fn streamed_text_keeps_block_identity_across_reasoning_and_ignores_user_echoes() { - let mut state = state(); - let mut previous = None; - for (kind, wire_kind, chunks, expected) in [ - ( - DeltaKind::AssistantText, - "agent_message_chunk", - ["Hel", "lo"], - "Hello", - ), - ( - DeltaKind::ReasoningText, - "agent_thought_chunk", - ["Think", "ing"], - "Thinking", - ), - ( - DeltaKind::AssistantText, - "agent_message_chunk", - ["An", "swer"], - "Answer", - ), - ] { - let mut id = None; - for chunk in chunks { - let mut events = state - .apply_update(update(json!({ - "sessionUpdate": wire_kind, "content": {"type":"text", "text":chunk} - }))) - .into_iter(); - if let Some((previous_id, previous_kind, previous_text)) = previous.take() { - let Some(AgentEvent::ItemCompleted(item)) = events.next() else { - panic!("changing text kind must complete the previous block"); - }; - assert_eq!(item.id, previous_id); - match (previous_kind, item.content) { - (DeltaKind::AssistantText, ItemContent::AssistantMessage { text }) - | (DeltaKind::ReasoningText, ItemContent::Reasoning { text }) => { - assert_eq!(text, previous_text) - } - other => panic!("wrong completed block: {other:?}"), - } - } - let Some(AgentEvent::Delta { - item_id, - kind: actual_kind, - text, - }) = events.next() - else { - panic!("text chunk must stream a delta"); - }; - assert_eq!(actual_kind, kind); - assert_eq!(text, chunk); - if let Some(id) = &id { - assert_eq!(&item_id, id); - } - id = Some(item_id); - assert!(events.next().is_none()); - assert!(state.apply_update(update(json!({ - "sessionUpdate":"user_message_chunk", "content":{"type":"text", "text":"prompt echo"} - }))).is_empty()); - } - previous = Some((id.unwrap(), kind, expected)); - } - let (id, _, expected) = previous.unwrap(); - assert!(matches!(state.flush_text().as_slice(), - [AgentEvent::ItemCompleted(ThreadItem { id: actual_id, content: ItemContent::AssistantMessage { text }, .. })] - if actual_id == &id && text == expected)); - assert!(state.flush_text().is_empty()); - } - - #[test] - fn execute_tool_call_maps_to_a_command_execution() { - let mut state = state(); - let events = state.apply_update(update(json!({ - "sessionUpdate": "tool_call", - "toolCallId": "t1", - "title": "Run tests", - "kind": "execute", - "status": "in_progress", - "rawInput": { "command": "cargo test" } - }))); - match &events[0] { - AgentEvent::ItemStarted(item) => { - assert_eq!(item.id, "t1"); - match &item.content { - ItemContent::CommandExecution { - command, status, .. - } => { - assert_eq!(command, "cargo test"); - assert_eq!(*status, ItemStatus::InProgress); - } - other => panic!("expected CommandExecution, got {other:?}"), - } - } - other => panic!("expected ItemStarted, got {other:?}"), - } - - // A partial patch merges into the merged state and completes the item. - let events = state.apply_update(update(json!({ - "sessionUpdate": "tool_call_update", - "toolCallId": "t1", - "status": "completed", - "content": [{ "type": "content", "content": { "type": "text", "text": "ok" } }], - "rawOutput": { "exitCode": 0 } - }))); - match &events[0] { - AgentEvent::ItemCompleted(item) => match &item.content { - ItemContent::CommandExecution { - command, - output, - exit_code, - status, - } => { - assert_eq!(command, "cargo test", "rawInput must survive the patch"); - assert_eq!(output, "ok"); - assert_eq!(*exit_code, Some(0)); - assert_eq!(*status, ItemStatus::Completed); - } - other => panic!("expected CommandExecution, got {other:?}"), - }, - other => panic!("expected ItemCompleted, got {other:?}"), - } - } - - #[test] - fn file_tools_preserve_operation_kind_and_render_available_change_evidence() { - for (kind, old, new, operation, diff) in [ - ( - "edit", - Some("same\nbefore\nend\n"), - "same\nafter\nend\n", - FileChangeKind::Modify, - "--- a/file.rs\n+++ b/file.rs\n@@ -1,3 +1,3 @@\n same\n-before\n+after\n end\n", - ), - ( - "edit", - None, - "new\n", - FileChangeKind::Create, - "--- /dev/null\n+++ b/file.rs\n@@ -0,0 +1,1 @@\n+new\n", - ), - ( - "edit", - Some(""), - "new\n", - FileChangeKind::Create, - "--- /dev/null\n+++ b/file.rs\n@@ -0,0 +1,1 @@\n+new\n", - ), - ( - "delete", - Some("old\n"), - "", - FileChangeKind::Delete, - "--- a/file.rs\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-old\n", - ), - ( - "move", - Some("old\n"), - "new\n", - FileChangeKind::Rename, - "--- a/file.rs\n+++ b/file.rs\n@@ -1,1 +1,1 @@\n-old\n+new\n", - ), - ] { - let events = state().apply_update(update(json!({ - "sessionUpdate":"tool_call", "toolCallId":"file", "title":"Change file", - "kind":kind, "status":"completed", - "content":[{"type":"diff","path":"file.rs","oldText":old,"newText":new}] - }))); - let [ - AgentEvent::ItemStarted(ThreadItem { - content: ItemContent::FileChange { changes, status }, - .. - }), - ] = events.as_slice() - else { - panic!("{kind}: expected file item, got {events:?}"); - }; - assert_eq!(*status, ItemStatus::Completed); - assert_eq!( - changes, - &[FileChange { - path: "file.rs".into(), - kind: operation, - diff: Some(diff.into()) - }] - ); - } - for (kind, operation) in [ - ("delete", FileChangeKind::Delete), - ("move", FileChangeKind::Rename), - ] { - let events = state().apply_update(update(json!({ - "sessionUpdate":"tool_call", "toolCallId":"file", "title":"Change file", - "kind":kind, "status":"completed", "locations":[{"path":"one.rs"},{"path":"two.rs"}] - }))); - let [ - AgentEvent::ItemStarted(ThreadItem { - content: ItemContent::FileChange { changes, .. }, - .. - }), - ] = events.as_slice() - else { - panic!("{kind}: expected file locations, got {events:?}"); - }; - assert_eq!( - changes, - &[ - FileChange { - path: "one.rs".into(), - kind: operation, - diff: None - }, - FileChange { - path: "two.rs".into(), - kind: operation, - diff: None - }, - ] - ); - } - let events = state().apply_update(update(json!({ - "sessionUpdate":"tool_call", "toolCallId":"file", "title":"Opaque edit", - "kind":"edit", "status":"completed", "rawInput":{"path":"file.rs"}, "rawOutput":"done" - }))); - assert!( - matches!(events.as_slice(), [AgentEvent::ItemStarted(ThreadItem { - content:ItemContent::ToolCall { name, input, output:Some(output), status:ItemStatus::Completed }, .. - })] if name == "Opaque edit" && input == &json!({"path":"file.rs"}) && output == "done") - ); - } - - #[test] - fn read_search_fetch_and_other_map_to_tool_cards_with_raw_payloads() { - for (kind, id) in [ - ("read", "r1"), - ("search", "r2"), - ("fetch", "r3"), - ("other", "r4"), - ("switch_mode", "r5"), - ] { - let mut state = state(); - let events = state.apply_update(update(json!({ - "sessionUpdate": "tool_call", - "toolCallId": id, - "title": "Read file", - "kind": kind, - "status": "completed", - "rawInput": { "path": "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/repo/x.rs" }, - "content": [{ "type": "content", "content": { "type": "text", "text": "body" } }] - }))); - match &events[0] { - AgentEvent::ItemStarted(item) => match &item.content { - ItemContent::ToolCall { - name, - input, - output, - status, - } => { - assert_eq!(name, "Read file"); - assert_eq!(input["path"], "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/repo/x.rs", "rawInput must ride along"); - assert_eq!(output.as_deref(), Some("body")); - assert_eq!(*status, ItemStatus::Completed); - } - other => panic!("{kind}: expected ToolCall, got {other:?}"), - }, - other => panic!("{kind}: expected ItemStarted, got {other:?}"), - } - } - } - - #[test] - fn think_tool_calls_become_reasoning() { - let mut state = state(); - let events = state.apply_update(update(json!({ - "sessionUpdate": "tool_call", - "toolCallId": "t4", - "title": "Thinking", - "kind": "think", - "status": "completed", - "content": [{ "type": "content", "content": { "type": "text", "text": "step 1" } }] - }))); - match &events[0] { - AgentEvent::ItemStarted(item) => match &item.content { - ItemContent::Reasoning { text } => assert_eq!(text, "step 1"), - other => panic!("expected Reasoning, got {other:?}"), - }, - other => panic!("expected ItemStarted, got {other:?}"), - } - } - - #[test] - fn tool_updates_recover_missing_starts_and_preserve_status_and_input() { - for (wire, expected) in [ - ("pending", ItemStatus::InProgress), - ("in_progress", ItemStatus::InProgress), - ("completed", ItemStatus::Completed), - ("failed", ItemStatus::Failed), - ] { - let mut state = state(); - let events = state.apply_update(update(json!({ - "sessionUpdate":"tool_call_update", "toolCallId":"late", "title":"Late tool", - "kind":"other", "status":"pending", "rawInput":{"path":"a.rs"} - }))); - assert!(matches!(events.as_slice(), [AgentEvent::ItemStarted(_)])); - let events = state.apply_update(update(json!({ - "sessionUpdate":"tool_call_update", "toolCallId":"late", "status":wire - }))); - let item = match events.as_slice() { - [AgentEvent::ItemUpdated(item)] if expected == ItemStatus::InProgress => item, - [AgentEvent::ItemCompleted(item)] if expected != ItemStatus::InProgress => item, - other => panic!("{wire}: incorrect lifecycle event {other:?}"), - }; - assert!( - matches!(&item.content, ItemContent::ToolCall { name, input, status, .. } - if name == "Late tool" && input == &json!({"path":"a.rs"}) && *status == expected) - ); - } - } - - #[test] - fn plan_update_preserves_turn_and_steps() { - let mut state = state(); - state.turn = Some("turn-1".into()); - let events = state.apply_update(update(json!({ - "sessionUpdate": "plan", - "entries": [ - { "content": "Read the code", "priority": "high", "status": "completed" }, - { "content": "Write the fix", "priority": "medium", "status": "in_progress" } - ] - }))); - match &events[0] { - AgentEvent::PlanUpdated { - turn_id, - steps, - explanation, - } => { - assert_eq!(turn_id.as_deref(), Some("turn-1")); - assert!(explanation.is_none()); - assert_eq!(steps.len(), 2); - assert_eq!(steps[0].step, "Read the code"); - assert_eq!(steps[0].status, PlanStepStatus::Completed); - assert_eq!(steps[1].status, PlanStepStatus::InProgress); - } - other => panic!("expected PlanUpdated, got {other:?}"), - } - } - - #[test] - fn available_commands_become_provider_commands() { - let mut state = state(); - let events = state.apply_update(update(json!({ - "sessionUpdate": "available_commands_update", - "availableCommands": [ - { "name": "review", "description": "Review the diff", "input": null } - ] - }))); - match &events[0] { - AgentEvent::ProviderCommands { commands } => { - assert_eq!(commands[0].name, "review"); - assert_eq!(commands[0].kind, ProviderCommandKind::Command); - } - other => panic!("expected ProviderCommands, got {other:?}"), - } - } - - #[test] - fn usage_update_is_the_context_window() { - let mut state = state(); - let events = state.apply_update(update(json!({ - "sessionUpdate": "usage_update", - "used": 1200, - "size": 200000 - }))); - match &events[0] { - AgentEvent::TokenUsage(usage) => { - assert_eq!(usage.used_tokens, Some(1200)); - assert_eq!(usage.context_window, Some(200_000)); - } - other => panic!("expected TokenUsage, got {other:?}"), - } - } - - #[test] - fn modes_and_categorized_config_become_provider_options() { - let mut state = state(); - let modes: acp::SessionModeState = serde_json::from_value(json!({ - "currentModeId": "build", - "availableModes": [ - { "id": "build", "name": "Build" }, - { "id": "plan", "name": "Plan", "description": "Read-only" } - ] - })) - .unwrap(); - let config: Vec = serde_json::from_value(json!([ - { - "id": "model", - "name": "Model", - "category": "model", - "type": "select", - "currentValue": "sonnet", - "options": [{ "value": "sonnet", "name": "Sonnet" }] - }, - { - "id": "thought_level", - "name": "Thinking", - "category": "thought_level", - "type": "select", - "currentValue": "medium", - "options": [ - { "value": "low", "name": "Low" }, - { "value": "medium", "name": "Medium" } - ] - }, - { "id": "web", "name": "Web search", "type": "boolean", "currentValue": true } - ])) - .unwrap(); - state.options.ingest(Some(&modes), Some(&config)); - assert_eq!(state.options.current_model(), Some("sonnet".to_string())); - - let descriptors: Vec<_> = state - .options - .records - .iter() - .map(|record| record.descriptor.clone()) - .collect(); - let ids: Vec<&str> = descriptors.iter().map(descriptor_id).collect(); - assert_eq!( - ids, - vec![ - "acp:mode", - "acp:model", - "acp:cfg:thought_level", - "acp:cfg:web" - ] - ); - assert_eq!(state.options.origin("acp:mode"), Some(OptionOrigin::Mode)); - assert_eq!( - state.options.origin("acp:model"), - Some(OptionOrigin::Config(acp::SessionConfigId::new("model"))) - ); - assert_eq!( - state.options.origin("acp:cfg:web"), - Some(OptionOrigin::Config(acp::SessionConfigId::new("web"))) - ); - assert!(matches!( - &descriptors[3], - OptionDescriptor::Boolean { - default_value: true, - .. - } - )); - - let selections: Vec<_> = state - .options - .records - .iter() - .map(|record| record.selection.clone()) - .collect(); - assert_eq!(selections[0].value, json!("build")); - assert_eq!(selections[1].value, json!("sonnet")); - assert_eq!(selections[2].value, json!("medium")); - assert_eq!(selections[3].value, json!(true)); - - // An agent-initiated mode switch re-publishes the options… - let events = state.apply_update(update(json!({ - "sessionUpdate": "current_mode_update", - "currentModeId": "plan" - }))); - match &events[0] { - AgentEvent::ProviderOptions { selections, .. } => { - assert_eq!(selections[0].value, json!("plan")); - } - other => panic!("expected ProviderOptions, got {other:?}"), - } - - // …and so does a config-option push. - let events = state.apply_update(update(json!({ - "sessionUpdate": "config_option_update", - "configOptions": [ - { "id": "web", "name": "Web search", "type": "boolean", "currentValue": false } - ] - }))); - match &events[0] { - AgentEvent::ProviderOptions { selections, .. } => { - let web = selections.iter().find(|s| s.id == "acp:cfg:web").unwrap(); - assert_eq!(web.value, json!(false)); - } - other => panic!("expected ProviderOptions, got {other:?}"), - } - } - - #[test] - fn permission_request_carries_the_agents_own_options() { - let request: acp::RequestPermissionRequest = serde_json::from_value(json!({ - "sessionId": "s1", - "toolCall": { - "toolCallId": "t9", - "title": "rm -rf build", - "kind": "execute", - "rawInput": { "command": "rm -rf build" } - }, - "options": [ - { "optionId": "yes", "name": "Allow", "kind": "allow_once" }, - { "optionId": "always", "name": "Always allow", "kind": "allow_always" }, - { "optionId": "no", "name": "Reject", "kind": "reject_once" } - ] - })) - .unwrap(); - let approval = approval_request( - "acp-approval-1".into(), - Some("turn-1".into()), - &request.tool_call, - &request.options, - ); - match &approval.kind { - ApprovalKind::ExecCommand { command, .. } => assert_eq!(command, "rm -rf build"), - other => panic!("expected ExecCommand, got {other:?}"), - } - assert_eq!(approval.options.len(), 3); - assert_eq!(approval.options[0].label, "Allow"); - assert_eq!(approval.options[1].kind, ApprovalOptionKind::AllowAlways); - - // Our fixed four map onto the agent's own options… - let selected = - |decision: ApprovalDecision| match approval_outcome(&decision, &approval.options) { - Some(acp::RequestPermissionOutcome::Selected(outcome)) => { - outcome.option_id.0.to_string() - } - other => panic!("expected a selection, got {other:?}"), - }; - assert_eq!(selected(ApprovalDecision::Approve), "yes"); - assert_eq!(selected(ApprovalDecision::ApproveForSession), "always"); - assert_eq!(selected(ApprovalDecision::Deny), "no"); - assert_eq!( - selected(ApprovalDecision::Option("always".into())), - "always" - ); - // …and Cancel is the protocol's own `cancelled` outcome. - assert!(matches!( - approval_outcome(&ApprovalDecision::Cancel, &approval.options), - Some(acp::RequestPermissionOutcome::Cancelled) - )); - - // The exact wire shape the agent expects back. - let response = acp::RequestPermissionResponse::new( - approval_outcome(&ApprovalDecision::Approve, &approval.options).unwrap(), - ); - let value = serde_json::to_value(&response).unwrap(); - assert_eq!(value["outcome"]["outcome"], "selected"); - assert_eq!(value["outcome"]["optionId"], "yes"); - let rejections_only = vec![approval.options[2].clone()]; - for decision in [ - ApprovalDecision::Approve, - ApprovalDecision::ApproveForSession, - ] { - assert!(approval_outcome(&decision, &rejections_only).is_none()); - } - } - - #[test] - fn approvals_classify_by_tool_kind() { - let build = |kind: &str| { - let tool: acp::ToolCallUpdate = serde_json::from_value(json!({ - "toolCallId": "t", - "title": "Read config", - "kind": kind, - "rawInput": { "path": "a.rs" } - })) - .unwrap(); - approval_request("a1".into(), None, &tool, &[]).kind - }; - assert!(matches!(build("read"), ApprovalKind::FileRead { .. })); - assert!(matches!(build("search"), ApprovalKind::FileRead { .. })); - assert!(matches!(build("fetch"), ApprovalKind::FileRead { .. })); - assert!(matches!(build("other"), ApprovalKind::ToolUse { .. })); - - let edit: acp::ToolCallUpdate = serde_json::from_value(json!({ - "toolCallId": "t", - "title": "Edit", - "kind": "edit", - "content": [{ "type": "diff", "path": "a.rs", "oldText": "a\n", "newText": "b\n" }] - })) - .unwrap(); - match approval_request("a2".into(), None, &edit, &[]).kind { - ApprovalKind::FileChange { changes, .. } => assert_eq!(changes[0].path, "a.rs"), - other => panic!("expected FileChange, got {other:?}"), - } - } - - #[test] - fn stop_reasons_map_to_turn_status() { - assert_eq!( - stop_reason_status(acp::StopReason::EndTurn).0, - TurnStatus::Completed - ); - assert_eq!( - stop_reason_status(acp::StopReason::Cancelled).0, - TurnStatus::Interrupted - ); - let (status, message) = stop_reason_status(acp::StopReason::Refusal); - assert_eq!(status, TurnStatus::Failed); - assert!(message.unwrap().contains("refused")); - let (status, message) = stop_reason_status(acp::StopReason::MaxTokens); - assert_eq!(status, TurnStatus::Failed); - assert!(message.unwrap().contains("token limit")); - } - - #[test] - fn paths_outside_the_session_cwd_are_rejected() { - let state = state(); - assert!(state.resolve_path(Path::new("src/main.rs")).is_ok()); - assert!( - state - .resolve_path(Path::new("/tmp/tcode-acp-test/../etc/passwd")) - .is_err() - ); - assert!(state.resolve_path(Path::new("/etc/passwd")).is_err()); - } - - #[test] - fn prompt_carries_text_and_image_blocks() { - let blocks = prompt_blocks( - "hello", - &[Attachment { - media_type: "image/png".into(), - data_base64: "AAAA".into(), - source_path: None, - }], - ); - let value = serde_json::to_value(&blocks).unwrap(); - assert_eq!(value[0]["type"], "text"); - assert_eq!(value[0]["text"], "hello"); - assert_eq!(value[1]["type"], "image"); - assert_eq!(value[1]["mimeType"], "image/png"); - assert_eq!(value[1]["data"], "AAAA"); - } -} diff --git a/crates/agent/src/acp_session.rs b/crates/agent/src/acp_session.rs new file mode 100644 index 000000000..a0569ec3b --- /dev/null +++ b/crates/agent/src/acp_session.rs @@ -0,0 +1,3583 @@ +//! The Agent Client Protocol session shared by every ACP-speaking client: the +//! registry client (`acp.rs`) and the native Cursor and Grok clients. +//! +//! One child process per session, JSON-RPC over its stdio, driven by an +//! `agent_client_protocol::Client` connection on a dedicated thread running a +//! `LocalExecutor`. This module owns the protocol machinery: child and +//! connection lifetime, stderr and EOF handling, prompt delivery +//! acknowledgement, permission settlement, the standard fs/terminal client +//! services, MCP and option mapping, the standard `session/update` mapping and +//! cancellation. What an agent family does differently is its [`Dialect`]. + +use std::collections::{HashMap, VecDeque}; +use std::fmt::Write as _; +use std::future::Future; +use std::io; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::rc::Rc; +use std::sync::{ + Arc, Mutex, MutexGuard, + atomic::{AtomicBool, Ordering}, +}; +use std::task::{Context, Poll}; +use std::time::Duration; + +use agent_client_protocol::{self as sdk, schema::ProtocolVersion, schema::v1 as acp}; +use serde::Deserialize as _; +use serde_json::{Map, Value}; +use smol::channel::{Receiver, Sender}; +use smol::future; +use smol::io::{AsyncRead, AsyncReadExt as _, AsyncWrite}; +use smol::prelude::*; + +use crate::{ + AgentError, AgentEvent, ApprovalDecision, ApprovalKind, ApprovalMode, ApprovalOption, + ApprovalOptionKind, ApprovalRequest, Attachment, DeltaKind, FileChange, FileChangeKind, + InteractionMode, ItemContent, ItemStatus, McpRegistration, OptionDescriptor, OptionSelection, + PlanStep, PlanStepStatus, ProviderCommand, ProviderCommandKind, ProviderKind, ResumeCursor, + SelectOption, SessionCommand, SessionHandle, SessionOptions, ThreadItem, TokenUsage, + TurnStatus, UserInputDelivery, UserInputQuestion, +}; + +/// Option-descriptor ids. The composer renders an ACP agent's own +/// modes/models/config options through the existing traits picker, so each one +/// needs a stable id that routes back to the right ACP method. +const MODE_OPTION_ID: &str = "acp:mode"; +const MODEL_OPTION_ID: &str = "acp:model"; +const CONFIG_OPTION_PREFIX: &str = "acp:cfg:"; + +/// The persisted selection for the session config option `config_id`, as +/// [`SessionOptions::option_selections`] carries it into a new process. +pub(crate) fn config_selection<'a>( + selections: &'a [OptionSelection], + config_id: &str, +) -> Option<&'a str> { + selections + .iter() + .find(|selection| { + selection + .id + .strip_prefix(CONFIG_OPTION_PREFIX) + .is_some_and(|id| id == config_id) + }) + .and_then(|selection| selection.value.as_str()) +} + +/// Cap on captured terminal output when the agent sets none (1 MiB). +const DEFAULT_TERMINAL_OUTPUT_LIMIT: u64 = 1 << 20; + +/// How long we wait for an agent to answer `initialize` before declaring it +/// broken. Generous: an `npm exec` recipe may have to fetch the package first. +const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(120); + +pub(crate) type Connection = sdk::ConnectionTo; + +trait MutexExt { + fn lock_recover(&self) -> MutexGuard<'_, T>; +} + +impl MutexExt for Mutex { + fn lock_recover(&self) -> MutexGuard<'_, T> { + self.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } +} + +macro_rules! request_handler { + ($client:expr, $request:ty, $method:ident) => {{ + let client = $client.clone(); + async move |args: $request, responder, connection| { + let client = client.clone(); + connection + .spawn(async move { responder.respond_with_result(client.$method(args).await) })?; + Ok(()) + } + }}; + ($client:expr, $request:ty, $method:ident, connection) => {{ + let client = $client.clone(); + async move |args: $request, responder, connection| { + let client = client.clone(); + let task_connection = connection.clone(); + connection.spawn(async move { + responder.respond_with_result(client.$method(args, &task_connection).await) + })?; + Ok(()) + } + }}; +} + +/// What one ACP agent family does on top of the shared protocol machinery. +/// +/// Each required method is a policy the dialect states for itself (launch, +/// authentication, session establishment, replay suppression, turn completion, +/// steering, approval-mode changes); only the extension routing defaults to +/// "no vendor methods", the client services to "offered", and option +/// ownership and automatic approval to "none". +pub(crate) trait Dialect: Send + Sync + 'static { + /// The agent's name in messages and logs. + fn name(&self) -> &str; + + /// The process to spawn and how the client introduces itself to it. + fn launch(&self, opts: &SessionOptions) -> Result; + + /// Authenticate when needed, then create, load, resume or fork the session + /// and apply its initial mode. Runs after `initialize` succeeded. + async fn establish(&self, setup: &Setup<'_>) -> Result; + + /// One `session/update`, including whether a replayed update is dropped. + /// [`State::apply_update`] is the standard mapping. + fn session_update( + &self, + state: &mut State, + notification: acp::SessionNotification, + ) -> Vec; + + /// How the `session/prompt` result ends the turn, with its usage. + fn turn_end( + &self, + state: &mut State, + result: Result, + ) -> TurnEnd; + + /// Deliver [`SessionCommand::Steer`] into the running turn. + async fn steer( + &self, + session: &Session, + request_id: String, + text: String, + attachments: Vec, + ); + + /// [`SessionCommand::SetApprovalMode`] on a live session. + async fn set_approval_mode(&self, session: &Session, mode: ApprovalMode); + + /// Whether an agent→client request outside the standard set belongs to + /// this dialect, by its literal method name. + fn handles_request(&self, _method: &str) -> bool { + false + } + + /// Answer a request [`Self::handles_request`] claimed, with its raw params. + /// It runs as its own task, so it may wait for the user without holding + /// up the connection. + fn request( + &self, + _session: Session, + method: String, + _params: Value, + ) -> impl Future> + Send { + async move { Err(acp::Error::method_not_found().data(method)) } + } + + /// An agent→client notification other than a `session/update` the schema + /// knows (a vendor `sessionUpdate` kind arrives here as `session/update`), + /// by its literal method name with raw params. Runs in arrival order with + /// the session updates. + fn notification(&self, _state: &mut State, _method: &str, _params: Value) -> Vec { + Vec::new() + } + + /// Whether `initialize` offers the client's `fs/*` and `terminal/*` + /// services. An agent offered them runs its file and shell tools through + /// tcode instead of its own implementation. + fn client_services(&self) -> bool { + true + } + + /// Session config options, by `configId`, that tcode already controls + /// elsewhere (the composer's model picker), so they are not surfaced again + /// in [`AgentEvent::ProviderOptions`]. + fn owned_config_options(&self) -> &'static [&'static str] { + &[] + } + + /// Whether a permission request for this tool call is approved without + /// asking the user: an approval mode the agent does not apply itself. + /// Only the request's allow-once option is chosen automatically; without + /// one the user is asked. + fn auto_approves(&self, _tool_call: &acp::ToolCallUpdate) -> bool { + false + } +} + +/// The process a dialect runs and its `initialize` introduction. +pub(crate) struct Launch { + pub(crate) program: PathBuf, + /// The complete argument list, including the user's launch arguments. + pub(crate) args: Vec, + /// Applied before the profile's configured environment, which wins. + pub(crate) env: Vec<(String, String)>, + /// `clientCapabilities._meta` sent with `initialize`. + pub(crate) client_meta: Option, +} + +/// The session a dialect settled on. +pub(crate) struct Established { + pub(crate) session_id: acp::SessionId, + pub(crate) resume: ResumeCursor, +} + +/// The canonical end of one prompt turn. +pub(crate) struct TurnEnd { + pub(crate) status: TurnStatus, + /// Surfaced as a non-fatal [`AgentEvent::Error`] before the completion. + pub(crate) message: Option, + pub(crate) usage: Option, +} + +/// What [`Dialect::establish`] works with. +pub(crate) struct Setup<'a> { + pub(crate) connection: &'a Connection, + pub(crate) opts: &'a SessionOptions, + pub(crate) init: &'a acp::InitializeResponse, + name: &'a str, + state: &'a Arc>, + events: &'a Sender, +} + +impl Setup<'_> { + pub(crate) async fn warn(&self, message: String) { + let _ = self.events.send(AgentEvent::Warning { message }).await; + } + + /// The `mcpServers` for `session/new`, `load` or `resume`. tcode's MCP + /// servers are loopback streamable-HTTP endpoints, so they are only + /// offered to agents that speak MCP over HTTP. + pub(crate) async fn mcp_servers(&self) -> Vec { + let registrations: Vec<_> = self.opts.mcp_servers.iter().collect(); + let servers = mcp_servers(®istrations, &self.init.agent_capabilities); + if !registrations.is_empty() && servers.is_empty() { + log::info!( + "acp[{}]: no mcpCapabilities.http; Tcode MCP servers are not registered", + self.name + ); + self.warn(format!( + "{} does not support HTTP MCP servers; Tcode MCP tools are unavailable in this session", + self.name + )) + .await; + } + servers + } + + /// `session/load`, with [`State::loading`] set until it answers: an agent + /// may replay the conversation as `session/update`s before it responds. + pub(crate) async fn load( + &self, + request: acp::LoadSessionRequest, + ) -> Result { + self.state.lock_recover().loading = true; + let (loaded_tx, loaded) = smol::channel::bounded(1); + let state = self.state.clone(); + // Cleared while the response holds the dispatch loop: an update the + // agent sends right after answering is live, and with `block_task` + // it could be read before the flag clears and dropped as replay. + let registered = + self.connection + .send_request(request) + .on_receiving_result(move |result| async move { + state.lock_recover().loading = false; + let _ = loaded_tx.send(result).await; + Ok(()) + }); + let loaded = match registered { + Ok(()) => loaded.recv().await.unwrap_or_else(|_| { + Err(acp::Error::internal_error().data("the agent closed before answering")) + }), + Err(err) => Err(err), + }; + self.state.lock_recover().loading = false; + loaded + } + + /// Take the modes and config options the session started with. + pub(crate) fn adopt( + &self, + modes: Option<&acp::SessionModeState>, + config_options: Option<&[acp::SessionConfigOption]>, + ) { + let mut state = self.state.lock_recover(); + state.ingest_modes(modes); + state.options.ingest(None, config_options); + } + + /// Whether the adopted session is currently in the agent's Plan mode. + pub(crate) fn in_plan_mode(&self) -> bool { + self.state + .lock_recover() + .modes + .as_ref() + .is_some_and(|modes| acp_plan_mode(modes).as_ref() == Some(&modes.current_mode_id)) + } + + pub(crate) async fn apply_interaction_mode( + &self, + session_id: &acp::SessionId, + mode: InteractionMode, + ) { + apply_interaction_mode(mode, self.state, self.events, self.connection, session_id).await; + } +} + +/// A live session as dialect hooks see it. +#[derive(Clone)] +pub(crate) struct Session { + pub(crate) connection: Connection, + events: Sender, + state: Arc>, +} + +impl Session { + /// The established session's id. + pub(crate) fn id(&self) -> Option { + self.with_state(|state| state.session_id.clone()) + } + + pub(crate) async fn emit(&self, event: AgentEvent) { + let _ = self.events.send(event).await; + } + + pub(crate) fn with_state(&self, f: impl FnOnce(&mut State) -> R) -> R { + f(&mut self.state.lock_recover()) + } + + /// Ask the user and wait for [`SessionCommand::RespondUserInput`]. `None` + /// when the request was cancelled by an interrupt or shutdown. + pub(crate) async fn ask_user( + &self, + questions: Vec, + ) -> Option> { + let (answer, answered) = smol::channel::bounded(1); + let request_id = self.with_state(|state| { + state.input_seq += 1; + let request_id = format!("acp-input-{}", state.input_seq); + state.inputs.insert(request_id.clone(), answer); + request_id + }); + self.emit(AgentEvent::UserInputRequested { + request_id, + questions, + delivery: UserInputDelivery::Blocking, + }) + .await; + answered.recv().await.ok() + } +} + +/// Start (or resume) a session with an ACP agent speaking `dialect`. +pub(crate) async fn start( + provider: ProviderKind, + dialect: D, + opts: SessionOptions, +) -> Result { + let (commands_tx, commands_rx) = smol::channel::unbounded(); + let (events_tx, events_rx) = smol::channel::unbounded(); + let (ready_tx, ready_rx) = smol::channel::bounded(1); + let dialect = Arc::new(dialect); + + // Keep each ACP connection and all of its callbacks on one dedicated + // executor thread. The SDK requires Send handlers internally, but the + // adapter still exposes only channels across this boundary. + std::thread::Builder::new() + .name("acp-session".into()) + .spawn(move || { + let executor = Rc::new(smol::LocalExecutor::new()); + let task = run_actor( + executor.clone(), + provider, + dialect, + opts, + commands_rx, + events_tx, + ready_tx, + ); + smol::block_on(executor.run(task)); + }) + .map_err(|err| { + AgentError::Spawn(format!("could not start the ACP session thread: {err}")) + })?; + + ready_rx.recv().await.map_err(|_| { + AgentError::Protocol("ACP actor exited before reporting startup status".into()) + })??; + + Ok(SessionHandle { + provider, + commands: commands_tx, + events: events_rx, + }) +} + +/// Run `query` against a freshly started and initialized agent outside any +/// session, then tear the agent down. Model catalogs use it. +pub(crate) async fn query( + provider: ProviderKind, + dialect: D, + opts: SessionOptions, + query: impl AsyncFnOnce(&Connection, &acp::InitializeResponse) -> Result + + Send + + 'static, +) -> Result +where + D: Dialect, + R: Send + 'static, +{ + let (result_tx, result) = smol::channel::bounded(1); + std::thread::Builder::new() + .name("acp-query".into()) + .spawn(move || { + let outcome = smol::block_on(async move { + let launch = dialect.launch(&opts)?; + let name = dialect.name().to_string(); + let client_services = dialect.client_services(); + let mut child = spawn_agent(&name, provider, &launch, &opts)?; + let (Some(stdin), Some(stdout), Some(stderr)) = + (child.stdin.take(), child.stdout.take(), child.stderr.take()) + else { + let _ = child.kill(); + return Err(AgentError::Spawn(format!( + "ACP agent `{name}` started without piped stdio" + ))); + }; + // Drained so a chatty agent never blocks on a full stderr pipe. + smol::spawn(async move { + let mut lines = smol::io::BufReader::new(stderr).lines(); + while let Some(Ok(_)) = lines.next().await {} + }) + .detach(); + let outcome = sdk::Client + .builder() + .name(format!("tcode-acp-query-{name}")) + .connect_with(sdk::ByteStreams::new(stdin, stdout), async |connection| { + Ok( + match initialize(&connection, &name, &launch, client_services).await { + Ok(init) => query(&connection, &init).await, + Err(err) => Err(err), + }, + ) + }) + .await; + let _ = child.kill(); + let _ = child.status().await; + outcome.unwrap_or_else(|err| { + Err(AgentError::Protocol(format!( + "ACP transport error: {}", + describe(&err) + ))) + }) + }); + let _ = result_tx.send_blocking(outcome); + }) + .map_err(|err| AgentError::Spawn(format!("could not start the ACP query thread: {err}")))?; + result + .recv() + .await + .map_err(|_| AgentError::Protocol("ACP query exited without a result".into()))? +} + +fn spawn_agent( + name: &str, + provider: ProviderKind, + launch: &Launch, + opts: &SessionOptions, +) -> Result { + let mut cmd = crate::process::async_command(&launch.program); + cmd.args(&launch.args) + .current_dir(&opts.cwd) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + // The dialect's env first, the user's configured env last (the user always wins). + for (key, value) in &launch.env { + cmd.env(key, value); + } + for (key, value) in opts.launch_env.pairs(provider) { + cmd.env(key, value); + } + log::debug!( + "spawning ACP agent {name}: {} {:?}", + launch.program.display(), + launch.args + ); + cmd.spawn().map_err(|err| { + AgentError::Spawn(format!( + "could not launch ACP agent `{name}` ({}): {err}", + launch.program.display() + )) + }) +} + +#[allow(clippy::too_many_arguments)] +async fn run_actor( + executor: Rc>, + provider: ProviderKind, + dialect: Arc, + opts: SessionOptions, + commands: Receiver, + events: Sender, + ready: Sender>, +) { + let launch = match dialect.launch(&opts) { + Ok(launch) => launch, + Err(err) => { + let _ = ready.send(Err(err)).await; + return; + } + }; + let name = dialect.name().to_string(); + let mut child = match spawn_agent(&name, provider, &launch, &opts) { + Ok(child) => child, + Err(err) => { + let _ = ready.send(Err(err)).await; + return; + } + }; + let Some(stdin) = child.stdin.take() else { + let _ = ready + .send(Err(AgentError::Spawn(format!( + "ACP agent `{name}` started without piped stdin" + )))) + .await; + return; + }; + let Some(stdout) = child.stdout.take() else { + let _ = ready + .send(Err(AgentError::Spawn(format!( + "ACP agent `{name}` started without piped stdout" + )))) + .await; + return; + }; + let Some(stderr) = child.stderr.take() else { + let _ = ready + .send(Err(AgentError::Spawn(format!( + "ACP agent `{name}` started without piped stderr" + )))) + .await; + return; + }; + + // The agent's stderr is its log channel: keep the tail so a startup failure + // can be reported in the agent's own words. + let stderr_tail = crate::process::StderrTail::default(); + executor + .spawn({ + let tail = stderr_tail.clone(); + let name = name.clone(); + async move { + let mut lines = smol::io::BufReader::new(stderr).lines(); + while let Some(Ok(line)) = lines.next().await { + log::debug!("acp[{name}] stderr: {line}"); + tail.push(line); + } + } + }) + .detach(); + + let mut state = State::new(opts.cwd.clone()); + state.owned_options = dialect.owned_config_options(); + let state = Arc::new(Mutex::new(state)); + let client = Client { + events: events.clone(), + state: state.clone(), + }; + let (io_done_tx, io_done) = smol::channel::bounded::(1); + let pending_deliveries = Arc::new(Mutex::new(VecDeque::new())); + let transport = sdk::ByteStreams::new( + ObservedWriter::new(stdin, pending_deliveries.clone(), events.clone()), + ObservedReader::new(stdout, io_done_tx), + ); + let session_started = Arc::new(AtomicBool::new(false)); + let connection_result = sdk::Client + .builder() + .name(format!("tcode-acp-{name}")) + // Untyped, so a `sessionUpdate` kind the schema does not know reaches + // the dialect instead of failing to parse and being dropped. + .on_receive_notification( + { + let client = client.clone(); + let dialect = dialect.clone(); + async move |notification: sdk::UntypedMessage, _connection| { + let (method, params) = notification.into_parts(); + let update = (method == "session/update") + .then(|| acp::SessionNotification::deserialize(¶ms).ok()) + .flatten(); + let events = { + let mut state = client.state.lock_recover(); + match update { + Some(update) => dialect.session_update(&mut state, update), + None => dialect.notification(&mut state, &method, params), + } + }; + client.emit_all(events).await; + Ok(()) + } + }, + sdk::on_receive_notification!(), + ) + .on_receive_request( + { + let client = client.clone(); + let dialect = dialect.clone(); + async move |args: acp::RequestPermissionRequest, responder, connection| { + let client = client.clone(); + let automatic = dialect + .auto_approves(&args.tool_call) + .then(|| allow_once(&args.options)) + .flatten(); + connection.spawn(async move { + responder.respond_with_result(match automatic { + Some(option) => Ok(acp::RequestPermissionResponse::new( + acp::RequestPermissionOutcome::Selected( + acp::SelectedPermissionOutcome::new(option), + ), + )), + None => client.request_permission(args).await, + }) + })?; + Ok(()) + } + }, + sdk::on_receive_request!(), + ) + .on_receive_request( + request_handler!(client, acp::ReadTextFileRequest, read_text_file), + sdk::on_receive_request!(), + ) + .on_receive_request( + request_handler!(client, acp::WriteTextFileRequest, write_text_file), + sdk::on_receive_request!(), + ) + .on_receive_request( + request_handler!( + client, + acp::CreateTerminalRequest, + create_terminal, + connection + ), + sdk::on_receive_request!(), + ) + .on_receive_request( + request_handler!(client, acp::TerminalOutputRequest, terminal_output), + sdk::on_receive_request!(), + ) + .on_receive_request( + request_handler!( + client, + acp::WaitForTerminalExitRequest, + wait_for_terminal_exit + ), + sdk::on_receive_request!(), + ) + .on_receive_request( + request_handler!(client, acp::KillTerminalRequest, kill_terminal), + sdk::on_receive_request!(), + ) + .on_receive_request( + request_handler!(client, acp::ReleaseTerminalRequest, release_terminal), + sdk::on_receive_request!(), + ) + // Vendor methods are routed by their literal names. The schema's own + // extension fallback only recognises `_`-prefixed methods and strips + // the prefix, so it cannot carry Cursor's `cursor/*` methods. + .on_receive_request( + { + let client = client.clone(); + let dialect = dialect.clone(); + async move |request: sdk::UntypedMessage, responder, connection| { + if !dialect.handles_request(&request.method) { + return Ok(sdk::Handled::No { + message: (request, responder), + retry: false, + }); + } + let session = client.session(connection.clone()); + let dialect = dialect.clone(); + let (method, params) = request.into_parts(); + connection.spawn(async move { + responder + .respond_with_result(dialect.request(session, method, params).await) + })?; + Ok(sdk::Handled::Yes) + } + }, + sdk::on_receive_request!(), + ) + .connect_with(transport, { + let session_started = session_started.clone(); + let stderr_tail = stderr_tail.clone(); + let actor_events = events.clone(); + let actor_ready = ready.clone(); + async move |connection| { + connected_actor( + &executor, + connection, + dialect.as_ref(), + &launch, + &opts, + &commands, + &actor_events, + &actor_ready, + &client, + &stderr_tail, + &io_done, + &session_started, + &pending_deliveries, + ) + .await + } + }) + .await; + + // The connection closure owns the protocol shutdown; this is the hard + // process boundary for both graceful shutdowns and broken transports. A + // startup failure is reported only once the agent is gone, so a caller + // that gives up on the error leaves no process behind. + let _ = child.kill(); + let _ = child.status().await; + + let connection_result = match connection_result { + Ok(Exit::StartFailed(err)) => { + let _ = ready.send(Err(err)).await; + return; + } + Ok(Exit::Closed(reason)) => Ok(reason), + Err(err) => Err(err), + }; + if !session_started.load(Ordering::Acquire) { + if let Err(err) = connection_result { + let message = format!("ACP transport error: {}", describe(&err)); + let message = stderr_tail.append_to(message, "\n"); + let _ = ready.send(Err(AgentError::Protocol(message))).await; + } + return; + } + + let close_reason = match connection_result { + Ok(reason) => reason, + Err(err) => Some(format!("ACP transport error: {}", describe(&err))), + }; + let close_reason = close_reason.map(|reason| stderr_tail.append_to(reason, "\nstderr:\n")); + let _ = events + .send(AgentEvent::SessionClosed { + reason: close_reason, + }) + .await; +} + +enum Exit { + StartFailed(AgentError), + /// The session ended, with a reason unless it was asked to. + Closed(Option), +} + +#[allow(clippy::too_many_arguments)] +async fn connected_actor( + executor: &Rc>, + connection: Connection, + dialect: &D, + launch: &Launch, + opts: &SessionOptions, + commands: &Receiver, + events: &Sender, + ready: &Sender>, + client: &Client, + stderr_tail: &crate::process::StderrTail, + io_done: &Receiver, + session_started: &AtomicBool, + pending_deliveries: &Arc>>, +) -> Result { + enum Startup { + Handshake(Result<(Established, bool), AgentError>), + Io(String), + } + + let state = &client.state; + let startup = future::or( + async { + Startup::Handshake(handshake(&connection, dialect, launch, opts, state, events).await) + }, + async { + Startup::Io( + io_done + .recv() + .await + .unwrap_or_else(|_| "the ACP agent closed its stdio".to_string()), + ) + }, + ) + .await; + let (established, can_close) = match startup { + Startup::Handshake(Ok(established)) => established, + Startup::Handshake(Err(err)) => { + let err = match err { + AgentError::Protocol(message) => { + AgentError::Protocol(stderr_tail.append_to(message, "\n")) + } + other => other, + }; + return Ok(Exit::StartFailed(err)); + } + Startup::Io(reason) => { + let message = stderr_tail.append_to(reason, "\n"); + return Ok(Exit::StartFailed(AgentError::Protocol(message))); + } + }; + let session_id = established.session_id; + let model = { + let mut state = state.lock_recover(); + state.session_id = Some(session_id.clone()); + state.options.current_model() + }; + + let _ = events + .send(AgentEvent::SessionStarted { + provider_session_id: session_id.0.to_string(), + resume: established.resume, + model, + }) + .await; + emit_provider_options(state, events, false).await; + if ready.send(Ok(())).await.is_err() { + return Ok(Exit::Closed(None)); + } + session_started.store(true, Ordering::Release); + + let session = client.session(connection.clone()); + let (turn_tx, turn_done) = smol::channel::unbounded::(); + let mut turn_seq: u64 = 0; + + let close_reason = loop { + enum Input { + Command(Result), + Turn(TurnOutcome), + Io(String), + } + let input = future::or( + future::or(async { Input::Command(commands.recv().await) }, async { + match turn_done.recv().await { + Ok(outcome) => Input::Turn(outcome), + // This loop holds the sender, so the channel cannot close. + Err(_) => future::pending().await, + } + }), + async { + match io_done.recv().await { + Ok(reason) => Input::Io(reason), + Err(_) => future::pending().await, + } + }, + ) + .await; + + match input { + Input::Command(Ok(SessionCommand::Shutdown)) | Input::Command(Err(_)) => break None, + Input::Command(Ok(command)) => { + handle_command( + command, + dialect, + executor, + &session, + &session_id, + &turn_tx, + &mut turn_seq, + pending_deliveries, + ) + .await; + } + Input::Turn(outcome) => finish_turn(dialect, state, events, outcome).await, + Input::Io(reason) => break Some(reason), + } + }; + + // Only a live transport can acknowledge a graceful close. A dead stdout + // has already ended the session and must not leave us awaiting a response. + if close_reason.is_none() { + // An agent blocked on one of our answers may not settle the close. + cancel_pending(state, events).await; + if can_close { + let _ = connection + .send_request(acp::CloseSessionRequest::new(session_id)) + .block_task() + .await; + } + } + + Ok(Exit::Closed(close_reason)) +} + +struct TurnOutcome { + turn_id: String, + result: Result, +} + +/// `initialize`, then the dialect's session establishment. Also reports +/// whether the agent can close sessions. +async fn handshake( + connection: &Connection, + dialect: &D, + launch: &Launch, + opts: &SessionOptions, + state: &Arc>, + events: &Sender, +) -> Result<(Established, bool), AgentError> { + let name = dialect.name(); + let init = initialize(connection, name, launch, dialect.client_services()).await?; + let setup = Setup { + connection, + opts, + init: &init, + name, + state, + events, + }; + let established = dialect.establish(&setup).await?; + let can_close = init.agent_capabilities.session_capabilities.close.is_some(); + Ok((established, can_close)) +} + +async fn initialize( + connection: &Connection, + name: &str, + launch: &Launch, + client_services: bool, +) -> Result { + let mut client_capabilities = acp::ClientCapabilities::new() + .fs(acp::FileSystemCapabilities::new() + .read_text_file(client_services) + .write_text_file(client_services)) + .terminal(client_services); + client_capabilities.meta = launch.client_meta.clone(); + // On a leash: an agent that starts but never answers `initialize` (cline + // 3.0.39 does exactly this) would otherwise hang session startup forever, + // with the UI stuck on "Starting…". + let init = future::or( + async { + Some( + connection + .send_request( + acp::InitializeRequest::new(ProtocolVersion::LATEST) + .client_capabilities(client_capabilities) + .client_info( + acp::Implementation::new("tcode", env!("CARGO_PKG_VERSION")) + .title("Tcode"), + ), + ) + .block_task() + .await, + ) + }, + async { + smol::Timer::after(INITIALIZE_TIMEOUT).await; + None + }, + ) + .await; + let init = match init { + Some(Ok(init)) => init, + Some(Err(err)) => { + return Err(AgentError::Protocol(format!( + "`{name}` failed to initialize: {}", + describe(&err) + ))); + } + None => { + return Err(AgentError::Protocol(format!( + "`{name}` did not answer `initialize` within {}s — it may not speak ACP over stdio (check its launch arguments)", + INITIALIZE_TIMEOUT.as_secs() + ))); + } + }; + Ok(init) +} + +#[allow(clippy::too_many_arguments)] +async fn handle_command( + command: SessionCommand, + dialect: &D, + executor: &Rc>, + session: &Session, + session_id: &acp::SessionId, + turn_tx: &Sender, + turn_seq: &mut u64, + pending_deliveries: &Arc>>, +) { + let connection = &session.connection; + let state = &session.state; + let events = &session.events; + match command { + SessionCommand::Steer { + request_id, + text, + attachments, + } => dialect.steer(session, request_id, text, attachments).await, + SessionCommand::SendTurn { + delivery_id, + text, + attachments, + .. + } => { + *turn_seq += 1; + let id = format!("turn-{turn_seq}"); + state.lock_recover().turn = Some(id.clone()); + + let request = + acp::PromptRequest::new(session_id.clone(), prompt_blocks(&text, &attachments)); + // The observed stdio writer consumes this id when the complete + // `session/prompt` JSON-RPC line reaches the child. + pending_deliveries.lock_recover().push_back(delivery_id); + let request = connection.send_request(request); + let _ = events + .send(AgentEvent::TurnStarted { + turn_id: id.clone(), + }) + .await; + let turn_tx = turn_tx.clone(); + executor + .spawn(async move { + let result = request.block_task().await; + let _ = turn_tx + .send(TurnOutcome { + turn_id: id, + result, + }) + .await; + }) + .detach(); + } + SessionCommand::Interrupt => { + // The open turn may be one the agent started itself. + if state.lock_recover().turn.is_none() { + return; + } + // Every in-flight permission request must be answered with + // `cancelled` first: the protocol requires it, and the agent will + // not settle the turn otherwise. + cancel_pending(state, events).await; + let _ = connection.send_notification(acp::CancelNotification::new(session_id.clone())); + // The agent still owes us the `session/prompt` response carrying + // `stopReason: cancelled`; the turn completes when it lands. + } + SessionCommand::RespondApproval { + request_id, + decision, + } => { + let approval = state.lock_recover().approvals.remove(&request_id); + let Some((responder, options)) = approval else { + log::warn!("acp: no pending approval {request_id}"); + return; + }; + let outcome = match approval_outcome(&decision, &options) { + Some(outcome) => outcome, + None => { + let _ = events + .send(AgentEvent::Warning { + message: + "the agent offered no matching permission option; cancelling instead" + .into(), + }) + .await; + acp::RequestPermissionOutcome::Cancelled + } + }; + let _ = responder.send(outcome).await; + let _ = events + .send(AgentEvent::ApprovalResolved { + request_id, + decision, + }) + .await; + } + SessionCommand::SetOption { id, value } => { + let Some(origin) = state.lock_recover().options.origin(&id) else { + log::warn!("acp: unknown option id `{id}`"); + return; + }; + match set_option(connection, session_id, &origin, &value).await { + Ok(config_options) => { + { + let mut state = state.lock_recover(); + if let Some(options) = config_options.as_deref() { + state.options.ingest(None, Some(options)); + } + if origin == OptionOrigin::Mode { + if let Some(mode) = value.as_str() { + state.select_mode(acp::SessionModeId::new(mode)); + } + } else { + state.options.select(&id, value); + } + } + emit_provider_options(state, events, false).await; + } + Err(err) => { + let _ = events + .send(AgentEvent::Warning { + message: format!("could not apply `{id}`: {}", describe(&err)), + }) + .await; + } + } + } + SessionCommand::RespondUserInput { + request_id, + answers, + } => { + let Some(answer) = state.lock_recover().inputs.remove(&request_id) else { + log::warn!("acp: no pending user input {request_id}"); + return; + }; + let _ = answer.send(answers.clone()).await; + let _ = events + .send(AgentEvent::UserInputResolved { + request_id, + answers, + }) + .await; + } + SessionCommand::SetApprovalMode(mode) => dialect.set_approval_mode(session, mode).await, + SessionCommand::SetInteractionMode(mode) => { + apply_interaction_mode(mode, state, events, connection, session_id).await; + } + SessionCommand::Rewind { + checkpoint_id, + mode, + } => { + let _ = events + .send(AgentEvent::RewindFailed { + checkpoint_id, + mode, + error: "this ACP agent does not advertise a native rewind operation".into(), + }) + .await; + } + SessionCommand::Shutdown => unreachable!("handled by the caller"), + } +} + +/// Settle every request waiting on the user: permissions answer `cancelled` +/// and questions resolve with no answers. +async fn cancel_pending(state: &Arc>, events: &Sender) { + let (approvals, inputs): (Vec<_>, Vec<_>) = { + let mut state = state.lock_recover(); + ( + state + .approvals + .drain() + .map(|(_, (responder, _))| responder) + .collect(), + state + .inputs + .drain() + .map(|(request_id, _)| request_id) + .collect(), + ) + }; + for responder in approvals { + let _ = responder + .send(acp::RequestPermissionOutcome::Cancelled) + .await; + } + for request_id in inputs { + let _ = events + .send(AgentEvent::UserInputResolved { + request_id, + answers: Map::new(), + }) + .await; + } +} + +/// Complete a sent turn with its `session/prompt` result, unless the dialect +/// already completed it from the agent's own signal ([`State::end_turn`]). +async fn finish_turn( + dialect: &D, + state: &Arc>, + events: &Sender, + outcome: TurnOutcome, +) { + let completion = { + let mut state = state.lock_recover(); + if state.turn.as_deref() != Some(outcome.turn_id.as_str()) { + return; + } + let end = dialect.turn_end(&mut state, outcome.result); + state.complete_turn(&outcome.turn_id, end) + }; + for event in completion { + let _ = events.send(event).await; + } +} + +/// The status and message of a `session/prompt` result. A `-32800 +/// request_cancelled` error is an agent aborting the prompt outright instead +/// of returning `stopReason: cancelled`. +pub(crate) fn prompt_status( + result: &Result, +) -> (TurnStatus, Option) { + match result { + Ok(response) => stop_reason_status(response.stop_reason), + Err(err) if i32::from(err.code) == -32800 => (TurnStatus::Interrupted, None), + Err(err) => (TurnStatus::Failed, Some(describe(err))), + } +} + +/// Reports an agent closing (or breaking) stdout while still handing the bytes +/// to the SDK transport. The SDK's `ByteStreams` treats a clean EOF as a +/// completed input stream, which does not end the command loop; observed here, +/// it closes the session at once. +struct ObservedReader { + inner: smol::process::ChildStdout, + done: Sender, +} + +impl ObservedReader { + fn new(inner: smol::process::ChildStdout, done: Sender) -> Self { + Self { inner, done } + } +} + +impl AsyncRead for ObservedReader { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut [u8], + ) -> Poll> { + match Pin::new(&mut self.inner).poll_read(cx, buf) { + Poll::Ready(Ok(0)) => { + let _ = self + .done + .try_send("the ACP agent closed its stdio".to_string()); + Poll::Ready(Ok(0)) + } + Poll::Ready(Err(err)) => { + let _ = self.done.try_send(format!("ACP transport error: {err}")); + Poll::Ready(Err(err)) + } + other => other, + } + } +} + +/// Reports a turn only after the SDK has written its complete JSON-RPC prompt +/// line to the child. `ConnectionTo::send_request` merely queues internally and +/// does not expose a synchronous enqueue failure, so it is not a delivery +/// boundary on its own. +struct ObservedWriter { + inner: W, + line: Vec, + pending_deliveries: Arc>>, + events: Sender, +} + +impl ObservedWriter { + fn new( + inner: W, + pending_deliveries: Arc>>, + events: Sender, + ) -> Self { + Self { + inner, + line: Vec::new(), + pending_deliveries, + events, + } + } + + fn finish_line(&mut self) { + let is_prompt = serde_json::from_slice::(&self.line) + .ok() + .is_some_and(|message| { + message.get("method").and_then(Value::as_str) == Some("session/prompt") + }); + self.line.clear(); + if !is_prompt { + return; + } + if let Some(delivery_id) = self.pending_deliveries.lock_recover().pop_front() { + let _ = self + .events + .try_send(AgentEvent::TurnAccepted { delivery_id }); + } + } +} + +impl AsyncWrite for ObservedWriter { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let written = match Pin::new(&mut self.inner).poll_write(cx, buf) { + Poll::Ready(Ok(written)) => written, + other => return other, + }; + for byte in &buf[..written] { + if *byte == b'\n' { + self.finish_line(); + } else { + self.line.push(*byte); + } + } + Poll::Ready(Ok(written)) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_close(cx) + } +} + +fn acp_plan_mode(modes: &acp::SessionModeState) -> Option { + modes + .available_modes + .iter() + .find(|mode| mode.id.0.eq_ignore_ascii_case("plan")) + .map(|mode| mode.id.clone()) +} + +fn first_non_plan_mode(modes: &acp::SessionModeState) -> Option { + let plan = acp_plan_mode(modes); + modes + .available_modes + .iter() + .find(|mode| Some(&mode.id) != plan.as_ref()) + .map(|mode| mode.id.clone()) +} + +/// ACP's `auth_required` error. +pub(crate) fn is_auth_required(err: &acp::Error) -> bool { + i32::from(err.code) == -32000 +} + +pub(crate) fn describe(err: &acp::Error) -> String { + match &err.data { + Some(data) => format!("{} ({data})", err.message), + None => err.message.clone(), + } +} + +/// The `mcpServers` array for `session/new`, gated on `mcpCapabilities.http`. +fn mcp_servers( + registrations: &[&McpRegistration], + caps: &acp::AgentCapabilities, +) -> Vec { + if !caps.mcp_capabilities.http { + return Vec::new(); + } + registrations + .iter() + .map(|mcp| { + acp::McpServer::Http( + acp::McpServerHttp::new(mcp.name.clone(), mcp.url.clone()).headers(vec![ + acp::HttpHeader::new("Authorization", format!("Bearer {}", mcp.bearer_token)), + ]), + ) + }) + .collect() +} + +async fn set_option( + connection: &Connection, + session_id: &acp::SessionId, + origin: &OptionOrigin, + value: &Value, +) -> Result>, acp::Error> { + match origin { + OptionOrigin::Mode => { + let Some(mode) = value.as_str() else { + return Err(acp::Error::invalid_params()); + }; + connection + .send_request(acp::SetSessionModeRequest::new( + session_id.clone(), + acp::SessionModeId::new(mode), + )) + .block_task() + .await?; + Ok(None) + } + OptionOrigin::Config(config_id) => { + let value = match value { + Value::Bool(value) => acp::SessionConfigOptionValue::boolean(*value), + Value::String(value) => acp::SessionConfigOptionValue::value_id( + acp::SessionConfigValueId::new(value.as_str()), + ), + _ => return Err(acp::Error::invalid_params()), + }; + let response = connection + .send_request(acp::SetSessionConfigOptionRequest::new( + session_id.clone(), + config_id.clone(), + value, + )) + .block_task() + .await?; + Ok(Some(response.config_options)) + } + } +} + +async fn apply_interaction_mode( + mode: InteractionMode, + state: &Arc>, + events: &Sender, + connection: &Connection, + session_id: &acp::SessionId, +) { + let target = interaction_mode_target(mode, &state.lock_recover()); + let Some(target) = target else { + let _ = events.send(missing_mode_warning(mode)).await; + // Republish the actual selection so the runtime rolls back its + // optimistic Build/Plan toggle instead of leaving a lying chip. + emit_provider_options(state, events, true).await; + return; + }; + + if state + .lock_recover() + .modes + .as_ref() + .is_some_and(|modes| modes.current_mode_id == target) + { + return; + } + + match connection + .send_request(acp::SetSessionModeRequest::new( + session_id.clone(), + target.clone(), + )) + .block_task() + .await + { + Ok(_) => { + state.lock_recover().select_mode(target); + emit_provider_options(state, events, false).await; + } + Err(err) => { + let _ = events + .send(AgentEvent::Warning { + message: format!("could not switch this agent's mode: {}", describe(&err)), + }) + .await; + emit_provider_options(state, events, true).await; + } + } +} + +fn interaction_mode_target(mode: InteractionMode, state: &State) -> Option { + match mode { + InteractionMode::Plan => state.modes.as_ref().and_then(acp_plan_mode), + InteractionMode::Build => state.build_mode(), + } +} + +fn missing_mode_warning(mode: InteractionMode) -> AgentEvent { + let mode_name = match mode { + InteractionMode::Plan => "Plan", + InteractionMode::Build => "Build", + }; + AgentEvent::Warning { + message: format!( + "This agent does not advertise a {mode_name} mode; the mode switch was not applied." + ), + } +} + +/// Map the approval UI's four fixed decisions onto the agent's own permission +/// options, and pass an offered `Option(id)` through. +/// +/// An agent's `allow_always`/`reject_always` options record a rule in its own +/// persistent allowlist, beyond this session. Only the user's explicit choice of +/// such an option may select one; every fixed decision, including the +/// session-scoped `ApproveForSession`, answers once. +fn approval_outcome( + decision: &ApprovalDecision, + options: &[ApprovalOption], +) -> Option { + let offered = |kind: ApprovalOptionKind| { + options + .iter() + .find(|option| option.kind == kind) + .map(|option| option.id.clone()) + }; + let selected = match decision { + ApprovalDecision::Cancel => return Some(acp::RequestPermissionOutcome::Cancelled), + ApprovalDecision::Option(id) => options + .iter() + .any(|option| &option.id == id) + .then(|| id.clone()), + ApprovalDecision::Approve | ApprovalDecision::ApproveForSession => { + offered(ApprovalOptionKind::AllowOnce) + } + ApprovalDecision::Deny => offered(ApprovalOptionKind::RejectOnce), + }?; + Some(acp::RequestPermissionOutcome::Selected( + acp::SelectedPermissionOutcome::new(acp::PermissionOptionId::new(selected)), + )) +} + +/// The option an automatic approval selects. Persistent options would record a +/// rule beyond this session that no one chose. +fn allow_once(options: &[acp::PermissionOption]) -> Option { + options + .iter() + .find(|option| option.kind == acp::PermissionOptionKind::AllowOnce) + .map(|option| option.option_id.clone()) +} + +/// `stopReason` → canonical turn status, plus the message to surface (if any). +pub(crate) fn stop_reason_status(reason: acp::StopReason) -> (TurnStatus, Option) { + match reason { + acp::StopReason::EndTurn => (TurnStatus::Completed, None), + acp::StopReason::Cancelled => (TurnStatus::Interrupted, None), + acp::StopReason::Refusal => ( + TurnStatus::Failed, + Some("The agent refused to continue this turn.".into()), + ), + acp::StopReason::MaxTokens => ( + TurnStatus::Failed, + Some("The agent stopped: token limit reached.".into()), + ), + acp::StopReason::MaxTurnRequests => ( + TurnStatus::Failed, + Some("The agent stopped: too many model requests in one turn.".into()), + ), + other => ( + TurnStatus::Failed, + Some(format!("The agent stopped: {other:?}")), + ), + } +} + +fn prompt_blocks(text: &str, attachments: &[Attachment]) -> Vec { + let mut blocks = Vec::with_capacity(1 + attachments.len()); + blocks.push(acp::ContentBlock::Text(acp::TextContent::new( + text.to_string(), + ))); + for attachment in attachments { + blocks.push(acp::ContentBlock::Image(acp::ImageContent::new( + attachment.data_base64.clone(), + attachment.media_type.clone(), + ))); + } + blocks +} + +async fn emit_provider_options( + state: &Arc>, + events: &Sender, + emit_empty: bool, +) { + let event = state.lock_recover().provider_options(); + if matches!( + &event, + AgentEvent::ProviderOptions { descriptors, .. } if descriptors.is_empty() + ) && !emit_empty + { + return; + } + let _ = events.send(event).await; +} + +/// Where a canonical option id came from, so `SetOption` routes to the right +/// ACP method. + +#[derive(Debug, Clone, PartialEq)] +enum OptionOrigin { + Mode, + Config(acp::SessionConfigId), +} + +/// The agent's self-described modes and config options, mapped onto our +/// [`OptionDescriptor`]s (which the composer's traits picker already renders). +#[derive(Default)] +struct OptionRegistry { + records: Vec, +} + +struct OptionRecord { + descriptor: OptionDescriptor, + selection: OptionSelection, + origin: OptionOrigin, +} + +impl OptionRegistry { + fn ingest( + &mut self, + modes: Option<&acp::SessionModeState>, + config: Option<&[acp::SessionConfigOption]>, + ) { + if let Some(modes) = modes { + self.upsert( + OptionDescriptor::Select { + id: MODE_OPTION_ID.to_string(), + label: "Mode".to_string(), + options: modes + .available_modes + .iter() + .map(|mode| SelectOption { + value: mode.id.0.to_string(), + label: mode.name.clone(), + description: mode.description.clone(), + }) + .collect(), + default_value: Some(modes.current_mode_id.0.to_string()), + }, + OptionOrigin::Mode, + Value::String(modes.current_mode_id.0.to_string()), + ); + } + // Each list is the agent's complete set: an option it no longer + // offers (a parameter of the previous model) goes. + if let Some(config) = config { + self.records.retain(|record| match &record.origin { + OptionOrigin::Config(id) => config.iter().any(|option| &option.id == id), + OptionOrigin::Mode => true, + }); + } + for option in config.unwrap_or_default() { + // The mode and model categories keep tcode's canonical ids (the + // runtime reads `acp:mode`); every option, whatever its category, + // is set through `session/set_config_option`. + let id = match option.category.as_ref() { + Some(acp::SessionConfigOptionCategory::Mode) => MODE_OPTION_ID.to_string(), + Some(acp::SessionConfigOptionCategory::Model) => MODEL_OPTION_ID.to_string(), + _ => format!("{CONFIG_OPTION_PREFIX}{}", option.id.0), + }; + match &option.kind { + acp::SessionConfigKind::Select(select) => { + let options = match &select.options { + acp::SessionConfigSelectOptions::Ungrouped(flat) => flat + .iter() + .map(|option| SelectOption { + value: option.value.0.to_string(), + label: option.name.clone(), + description: option.description.clone(), + }) + .collect(), + acp::SessionConfigSelectOptions::Grouped(groups) => groups + .iter() + .flat_map(|group| { + group.options.iter().map(move |option| SelectOption { + value: option.value.0.to_string(), + label: format!("{} · {}", group.name, option.name), + description: option.description.clone(), + }) + }) + .collect(), + _ => Vec::new(), + }; + self.upsert( + OptionDescriptor::Select { + id, + label: option.name.clone(), + options, + default_value: Some(select.current_value.0.to_string()), + }, + OptionOrigin::Config(option.id.clone()), + Value::String(select.current_value.0.to_string()), + ); + } + acp::SessionConfigKind::Boolean(boolean) => self.upsert( + OptionDescriptor::Boolean { + id, + label: option.name.clone(), + default_value: boolean.current_value, + }, + OptionOrigin::Config(option.id.clone()), + Value::Bool(boolean.current_value), + ), + _ => log::warn!("acp: unsupported config-option kind for `{}`", option.id.0), + } + } + } + + fn upsert(&mut self, descriptor: OptionDescriptor, origin: OptionOrigin, value: Value) { + let id = descriptor_id(&descriptor).to_string(); + match self + .records + .iter_mut() + .find(|record| record.selection.id == id) + { + Some(record) => { + record.descriptor = descriptor; + record.selection.value = value; + record.origin = origin; + } + None => self.records.push(OptionRecord { + descriptor, + selection: OptionSelection { id, value }, + origin, + }), + } + } + + fn select(&mut self, id: &str, value: Value) { + if let Some(record) = self + .records + .iter_mut() + .find(|record| record.selection.id == id) + { + record.selection.value = value; + } + } + + fn origin(&self, id: &str) -> Option { + self.records + .iter() + .find(|record| record.selection.id == id) + .map(|record| record.origin.clone()) + } + + fn current_model(&self) -> Option { + self.records + .iter() + .find(|record| record.selection.id == MODEL_OPTION_ID) + .and_then(|record| record.selection.value.as_str().map(str::to_string)) + } +} + +fn descriptor_id(descriptor: &OptionDescriptor) -> &str { + match descriptor { + OptionDescriptor::Select { id, .. } | OptionDescriptor::Boolean { id, .. } => id, + } +} + +/// A tool call as last known. `tool_call_update` is a partial patch, so the +/// merged state lives here and every update re-renders the whole item. +#[derive(Debug, Clone, Default)] +struct ToolState { + title: String, + kind: acp::ToolKind, + status: acp::ToolCallStatus, + content: Vec, + locations: Vec, + raw_input: Option, + raw_output: Option, + announced: bool, +} + +/// The text block currently streaming (assistant prose or thinking). +struct TextStream { + id: String, + kind: DeltaKind, + text: String, +} + +/// One live session's protocol state: the standard mapping's tool, text and +/// option state, the agent's terminals and the requests waiting on the user. +pub(crate) struct State { + cwd: PathBuf, + session_id: Option, + turn: Option, + /// True while `session/load` answers, which may replay history first. + loading: bool, + tools: HashMap, + text: Option, + approvals: HashMap, Vec)>, + approval_seq: u64, + inputs: HashMap>>, + input_seq: u64, + text_seq: u64, + terminal_seq: u64, + terminals: HashMap>, + usage: Option, + options: OptionRegistry, + /// [`Dialect::owned_config_options`]. + owned_options: &'static [&'static str], + /// A turn the agent started while another was still open. + next_turn: Option, + modes: Option, + previous_non_plan_mode: Option, +} + +impl State { + pub(crate) fn new(cwd: PathBuf) -> Self { + Self { + cwd, + session_id: None, + turn: None, + loading: false, + tools: HashMap::new(), + text: None, + approvals: HashMap::new(), + approval_seq: 0, + inputs: HashMap::new(), + input_seq: 0, + text_seq: 0, + terminal_seq: 0, + terminals: HashMap::new(), + usage: None, + options: OptionRegistry::default(), + owned_options: &[], + next_turn: None, + modes: None, + previous_non_plan_mode: None, + } + } + + /// Whether `session/load` is still answering. + pub(crate) fn loading(&self) -> bool { + self.loading + } + + /// The running turn's id. + pub(crate) fn turn(&self) -> Option<&str> { + self.turn.as_deref() + } + + /// The latest `usage_update` context figure. + pub(crate) fn usage(&self) -> Option { + self.usage + } + + /// Open a turn the agent started on its own, which no `session/prompt` + /// answers. While another turn is still open it opens as soon as that one + /// completes. + pub(crate) fn begin_agent_turn(&mut self, turn_id: &str) -> Vec { + if self.turn.is_some() { + self.next_turn = Some(turn_id.to_string()); + return Vec::new(); + } + self.turn = Some(turn_id.to_string()); + vec![AgentEvent::TurnStarted { + turn_id: turn_id.to_string(), + }] + } + + /// Complete `turn_id` from the agent's own signal if it is still the open + /// turn. A sent turn completed this way ignores its later `session/prompt` + /// result. A turn the client sent while an agent-started one ran has taken + /// its place and completes as sent turns do; only the ended turn's streamed + /// text is closed. + pub(crate) fn end_turn(&mut self, turn_id: &str, end: TurnEnd) -> Vec { + if self.next_turn.as_deref() == Some(turn_id) { + self.next_turn = None; + } + if self.turn.as_deref() != Some(turn_id) { + return self.flush_text(); + } + self.complete_turn(turn_id, end) + } + + /// Close the open turn: its streaming text, the end's message and its + /// completion, then open the agent-started turn waiting behind it. + fn complete_turn(&mut self, turn_id: &str, end: TurnEnd) -> Vec { + let mut events = self.flush_text(); + self.turn = None; + events.extend(end.message.map(|message| AgentEvent::Error { + message, + fatal: false, + })); + events.push(AgentEvent::TurnCompleted { + turn_id: turn_id.to_string(), + status: end.status, + usage: end.usage, + }); + if let Some(next) = self.next_turn.take() { + events.extend(self.begin_agent_turn(&next)); + } + events + } + + fn ingest_modes(&mut self, modes: Option<&acp::SessionModeState>) { + let Some(modes) = modes else { + return; + }; + self.modes = Some(modes.clone()); + self.select_mode(modes.current_mode_id.clone()); + self.options.ingest(Some(modes), None); + } + + fn select_mode(&mut self, mode: acp::SessionModeId) { + let is_plan = self + .modes + .as_ref() + .and_then(acp_plan_mode) + .is_some_and(|plan| plan == mode); + if !is_plan { + self.previous_non_plan_mode = Some(mode.clone()); + } + if let Some(modes) = self.modes.as_mut() { + modes.current_mode_id = mode.clone(); + } + self.options + .select(MODE_OPTION_ID, Value::String(mode.0.to_string())); + } + + fn build_mode(&self) -> Option { + let modes = self.modes.as_ref()?; + // ACP exposes no distinct default mode. Remember the last non-plan + // selection from before Plan; if the session began in Plan, fall back + // to the first advertised non-plan mode (the agent's ordering). + self.previous_non_plan_mode + .clone() + .or_else(|| first_non_plan_mode(modes)) + } + + fn provider_options(&self) -> AgentEvent { + let surfaced = || { + self.options.records.iter().filter(|record| { + !matches!(&record.origin, OptionOrigin::Config(id) + if self.owned_options.contains(&id.0.as_ref())) + }) + }; + AgentEvent::ProviderOptions { + descriptors: surfaced().map(|record| record.descriptor.clone()).collect(), + selections: surfaced().map(|record| record.selection.clone()).collect(), + } + } + + /// Close the open text block, emitting its final `ItemCompleted`. + pub(crate) fn flush_text(&mut self) -> Vec { + let Some(stream) = self.text.take() else { + return Vec::new(); + }; + if stream.text.is_empty() { + return Vec::new(); + } + let content = match stream.kind { + DeltaKind::ReasoningText => ItemContent::Reasoning { text: stream.text }, + _ => ItemContent::AssistantMessage { text: stream.text }, + }; + vec![AgentEvent::ItemCompleted(ThreadItem { + id: stream.id, + parent_item_id: None, + content, + })] + } + + /// Append a streaming chunk, opening a new item when the block changes. + fn push_text(&mut self, kind: DeltaKind, text: String) -> Vec { + let mut events = Vec::new(); + let same_block = self.text.as_ref().is_some_and(|stream| stream.kind == kind); + if !same_block { + events.extend(self.flush_text()); + self.text_seq += 1; + let prefix = match kind { + DeltaKind::ReasoningText => "thought", + _ => "msg", + }; + self.text = Some(TextStream { + id: format!("{prefix}-{}", self.text_seq), + kind, + text: String::new(), + }); + } + let stream = self.text.as_mut().expect("stream just opened"); + stream.text.push_str(&text); + events.push(AgentEvent::Delta { + item_id: stream.id.clone(), + kind, + text, + }); + events + } + + /// Map one `session/update` onto canonical events, merging the tool-call and + /// option state along the way. + pub(crate) fn apply_update(&mut self, update: acp::SessionUpdate) -> Vec { + match update { + // The app synthesizes the canonical user message at send time; + // rendering the agent's echo of it would double it. + acp::SessionUpdate::UserMessageChunk(_) => Vec::new(), + acp::SessionUpdate::AgentMessageChunk(chunk) => { + let text = content_text(&chunk.content); + if text.is_empty() { + return Vec::new(); + } + self.push_text(DeltaKind::AssistantText, text) + } + acp::SessionUpdate::AgentThoughtChunk(chunk) => { + let text = content_text(&chunk.content); + if text.is_empty() { + return Vec::new(); + } + self.push_text(DeltaKind::ReasoningText, text) + } + acp::SessionUpdate::ToolCall(call) => { + let mut events = self.flush_text(); + let id = call.tool_call_id.0.to_string(); + let tool = ToolState { + title: call.title, + kind: call.kind, + status: call.status, + content: call.content, + locations: call.locations, + raw_input: call.raw_input, + raw_output: call.raw_output, + announced: true, + }; + let item = self.tool_item(&id, &tool); + let existed = self + .tools + .insert(id, tool) + .is_some_and(|previous| previous.announced); + events.push(if existed { + AgentEvent::ItemUpdated(item) + } else { + AgentEvent::ItemStarted(item) + }); + events + } + acp::SessionUpdate::ToolCallUpdate(update) => { + let mut events = self.flush_text(); + let id = update.tool_call_id.0.to_string(); + let entry = self.tools.entry(id.clone()).or_default(); + let fields = update.fields; + if let Some(title) = fields.title { + entry.title = title; + } + if let Some(kind) = fields.kind { + entry.kind = kind; + } + if let Some(status) = fields.status { + entry.status = status; + } + // `content` and `locations` are whole-array replacements. + if let Some(content) = fields.content { + entry.content = content; + } + if let Some(locations) = fields.locations { + entry.locations = locations; + } + if let Some(raw_input) = fields.raw_input { + entry.raw_input = Some(raw_input); + } + if let Some(raw_output) = fields.raw_output { + entry.raw_output = Some(raw_output); + } + let announced = std::mem::replace(&mut entry.announced, true); + let status = entry.status; + let tool = entry.clone(); + let item = self.tool_item(&id, &tool); + events.push(match (announced, status) { + (false, _) => AgentEvent::ItemStarted(item), + (true, acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed) => { + AgentEvent::ItemCompleted(item) + } + _ => AgentEvent::ItemUpdated(item), + }); + events + } + acp::SessionUpdate::Plan(plan) => vec![AgentEvent::PlanUpdated { + turn_id: self.turn().map(str::to_owned), + explanation: None, + steps: plan.entries.iter().map(plan_step).collect(), + }], + acp::SessionUpdate::AvailableCommandsUpdate(update) => { + vec![AgentEvent::ProviderCommands { + commands: update + .available_commands + .iter() + .map(|command| ProviderCommand { + name: command.name.clone(), + description: Some(command.description.clone()), + kind: ProviderCommandKind::Command, + }) + .collect(), + }] + } + acp::SessionUpdate::CurrentModeUpdate(update) => { + self.select_mode(update.current_mode_id.clone()); + vec![self.provider_options()] + } + acp::SessionUpdate::ConfigOptionUpdate(update) => { + self.options.ingest(None, Some(&update.config_options)); + vec![self.provider_options()] + } + acp::SessionUpdate::UsageUpdate(usage) => { + let usage = TokenUsage { + freshness: crate::ContextFreshness::Current, + used_tokens: Some(usage.used), + context_window: Some(usage.size), + cost_usd: usage + .cost + .as_ref() + .filter(|cost| cost.currency.eq_ignore_ascii_case("USD")) + .map(|cost| cost.amount), + ..Default::default() + }; + self.usage = Some(usage); + vec![AgentEvent::TokenUsage(usage)] + } + // Session titles are ours (the sidebar names sessions from the first + // user message), and unknown variants degrade to nothing. + _ => Vec::new(), + } + } + + /// The canonical item for a tool call, keyed by [`acp::ToolKind`]. + fn tool_item(&self, id: &str, tool: &ToolState) -> ThreadItem { + let status = map_status(tool.status); + let content = match tool.kind { + acp::ToolKind::Execute => ItemContent::CommandExecution { + command: command_of(tool), + output: self.tool_output(tool), + exit_code: self.exit_code_of(tool), + status, + }, + acp::ToolKind::Edit | acp::ToolKind::Delete | acp::ToolKind::Move => { + let changes = file_changes(tool); + if changes.is_empty() { + tool_call_content(tool, status, self.tool_output(tool)) + } else { + ItemContent::FileChange { changes, status } + } + } + acp::ToolKind::Think => { + let text = self.tool_output(tool); + ItemContent::Reasoning { + text: if text.is_empty() { + tool.title.clone() + } else { + text + }, + } + } + _ => tool_call_content(tool, status, self.tool_output(tool)), + }; + ThreadItem { + id: id.into(), + parent_item_id: None, + content, + } + } + + /// Everything the tool produced, as display text: its content blocks plus + /// the live output of any terminal it embedded. + fn tool_output(&self, tool: &ToolState) -> String { + let mut parts: Vec = Vec::new(); + for content in &tool.content { + match content { + acp::ToolCallContent::Content(block) => { + let text = content_text(&block.content); + if !text.is_empty() { + parts.push(text); + } + } + acp::ToolCallContent::Terminal(terminal) => { + if let Some(terminal) = self.terminals.get(terminal.terminal_id.0.as_ref()) { + let output = terminal.output.lock_recover(); + if !output.is_empty() { + parts.push(output.clone()); + } + } + } + // Diffs render as FileChange, not as text. + _ => {} + } + } + if parts.is_empty() + && let Some(raw) = &tool.raw_output + { + match raw.as_str() { + Some(text) => parts.push(text.to_string()), + None if !raw.is_null() => parts.push(raw.to_string()), + None => {} + } + } + parts.join("\n") + } + + fn exit_code_of(&self, tool: &ToolState) -> Option { + for content in &tool.content { + if let acp::ToolCallContent::Terminal(terminal) = content + && let Some(terminal) = self.terminals.get(terminal.terminal_id.0.as_ref()) + && let Some(status) = terminal.exit.lock_recover().as_ref() + { + return status.exit_code.map(|code| code as i32); + } + } + tool.raw_output + .as_ref() + .and_then(|raw| raw.get("exitCode").or_else(|| raw.get("exit_code"))) + .and_then(Value::as_i64) + .map(|code| code as i32) + } + + /// Reject any path the agent asks for that escapes the session's cwd. + fn resolve_path(&self, path: &Path) -> Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + self.cwd.join(path) + }; + let normalized = normalize(&absolute); + if !normalized.starts_with(normalize(&self.cwd)) { + return Err(acp::Error::new( + -32602, + format!( + "path `{}` is outside the session working directory", + path.display() + ), + )); + } + Ok(normalized) + } +} + +/// Lexical path normalization (the file may not exist yet, so `canonicalize` is +/// not an option for writes). +fn normalize(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::ParentDir => { + out.pop(); + } + std::path::Component::CurDir => {} + other => out.push(other.as_os_str()), + } + } + out +} + +/// The shell command behind an `execute` tool call: the underlying tool's own +/// arguments when it exposed them, else the agent's title. +fn command_of(tool: &ToolState) -> String { + tool.raw_input + .as_ref() + .and_then(|input| input.get("command").or_else(|| input.get("cmd"))) + .and_then(|command| match command { + Value::String(command) => Some(command.clone()), + Value::Array(parts) => Some( + parts + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(" "), + ), + _ => None, + }) + .unwrap_or_else(|| tool.title.clone()) +} + +fn tool_call_content(tool: &ToolState, status: ItemStatus, output: String) -> ItemContent { + ItemContent::ToolCall { + name: tool.title.clone(), + input: tool.raw_input.clone().unwrap_or(Value::Null), + output: (!output.is_empty()).then_some(output), + status, + } +} + +/// `diff` content blocks → canonical [`FileChange`]s (with a unified diff, so +/// the diff panel renders them like every other provider's edits). +fn file_changes(tool: &ToolState) -> Vec { + let mut changes: Vec = tool + .content + .iter() + .filter_map(|content| match content { + acp::ToolCallContent::Diff(diff) => { + let path = diff.path.to_string_lossy().into_owned(); + Some(FileChange { + kind: match (tool.kind, diff.old_text.as_deref()) { + (acp::ToolKind::Delete, _) => FileChangeKind::Delete, + (acp::ToolKind::Move, _) => FileChangeKind::Rename, + (_, None | Some("")) => FileChangeKind::Create, + _ => FileChangeKind::Modify, + }, + diff: Some(unified_diff( + &path, + diff.old_text.as_deref().unwrap_or(""), + &diff.new_text, + )), + path, + }) + } + _ => None, + }) + .collect(); + if changes.is_empty() && matches!(tool.kind, acp::ToolKind::Delete | acp::ToolKind::Move) { + // Deletes and renames carry no diff; the locations are all we get. + changes = tool + .locations + .iter() + .map(|location| FileChange { + path: location.path.to_string_lossy().into_owned(), + kind: match tool.kind { + acp::ToolKind::Delete => FileChangeKind::Delete, + _ => FileChangeKind::Rename, + }, + diff: None, + }) + .collect(); + } + changes +} + +/// A whole-file unified diff. ACP hands us before/after text rather than a +/// patch, and the diff panel wants `@@` hunks; a single hunk covering the file +/// is exactly what the panel's full-file path already renders. +fn unified_diff(path: &str, old: &str, new: &str) -> String { + let old_lines: Vec<&str> = old.lines().collect(); + let new_lines: Vec<&str> = new.lines().collect(); + let mut out = String::new(); + if old_lines.is_empty() { + out.push_str("--- /dev/null\n"); + } else { + let _ = writeln!(out, "--- a/{path}"); + } + if new_lines.is_empty() { + out.push_str("+++ /dev/null\n"); + } else { + let _ = writeln!(out, "+++ b/{path}"); + } + let _ = writeln!( + out, + "@@ -{},{} +{},{} @@", + usize::from(!old_lines.is_empty()), + old_lines.len(), + usize::from(!new_lines.is_empty()), + new_lines.len() + ); + // Trim the common prefix/suffix so the (very common) single-line edit does + // not render as a whole-file rewrite. + let prefix = old_lines + .iter() + .zip(new_lines.iter()) + .take_while(|(old, new)| old == new) + .count(); + let suffix = old_lines + .iter() + .rev() + .zip(new_lines.iter().rev()) + .take_while(|(old, new)| old == new) + .count() + .min(old_lines.len() - prefix) + .min(new_lines.len() - prefix); + for line in &old_lines[..prefix] { + let _ = writeln!(out, " {line}"); + } + for line in &old_lines[prefix..old_lines.len() - suffix] { + let _ = writeln!(out, "-{line}"); + } + for line in &new_lines[prefix..new_lines.len() - suffix] { + let _ = writeln!(out, "+{line}"); + } + for line in &old_lines[old_lines.len() - suffix..] { + let _ = writeln!(out, " {line}"); + } + out +} + +fn map_status(status: acp::ToolCallStatus) -> ItemStatus { + match status { + acp::ToolCallStatus::Pending | acp::ToolCallStatus::InProgress => ItemStatus::InProgress, + acp::ToolCallStatus::Completed => ItemStatus::Completed, + acp::ToolCallStatus::Failed => ItemStatus::Failed, + _ => ItemStatus::InProgress, + } +} + +fn plan_step(entry: &acp::PlanEntry) -> PlanStep { + PlanStep { + step: entry.content.clone(), + status: match entry.status { + acp::PlanEntryStatus::InProgress => PlanStepStatus::InProgress, + acp::PlanEntryStatus::Completed => PlanStepStatus::Completed, + _ => PlanStepStatus::Pending, + }, + } +} + +/// Displayable text for a content block (images/audio degrade to a marker). +fn content_text(block: &acp::ContentBlock) -> String { + match block { + acp::ContentBlock::Text(text) => text.text.clone(), + acp::ContentBlock::ResourceLink(link) => link.uri.clone(), + acp::ContentBlock::Resource(resource) => match &resource.resource { + acp::EmbeddedResourceResource::TextResourceContents(text) => text.text.clone(), + _ => String::new(), + }, + acp::ContentBlock::Image(_) => "[image]".to_string(), + acp::ContentBlock::Audio(_) => "[audio]".to_string(), + _ => String::new(), + } +} + +/// The approval an agent's `session/request_permission` becomes. +fn approval_request( + id: String, + turn_id: Option, + tool: &acp::ToolCallUpdate, + options: &[acp::PermissionOption], +) -> ApprovalRequest { + let fields = &tool.fields; + let title = fields.title.clone().unwrap_or_default(); + let kind = fields.kind.unwrap_or_default(); + let raw_input = fields.raw_input.clone().unwrap_or(Value::Null); + let approval_kind = match kind { + acp::ToolKind::Execute => ApprovalKind::ExecCommand { + command: command_of(&ToolState { + title: title.clone(), + raw_input: fields.raw_input.clone(), + ..Default::default() + }), + cwd: None, + reason: None, + }, + acp::ToolKind::Edit | acp::ToolKind::Delete | acp::ToolKind::Move => { + let changes = file_changes(&ToolState { + title: title.clone(), + kind, + content: fields.content.clone().unwrap_or_default(), + locations: fields.locations.clone().unwrap_or_default(), + raw_input: fields.raw_input.clone(), + ..Default::default() + }); + if changes.is_empty() { + ApprovalKind::ToolUse { + name: title.clone(), + input: raw_input, + detail: title, + } + } else { + ApprovalKind::FileChange { + changes, + reason: None, + } + } + } + acp::ToolKind::Read | acp::ToolKind::Search | acp::ToolKind::Fetch => { + ApprovalKind::FileRead { detail: title } + } + _ => ApprovalKind::ToolUse { + name: title.clone(), + input: raw_input, + detail: title, + }, + }; + ApprovalRequest { + id, + turn_id, + kind: approval_kind, + options: options + .iter() + .map(|option| ApprovalOption { + id: option.option_id.0.to_string(), + label: option.name.clone(), + kind: match option.kind { + acp::PermissionOptionKind::AllowOnce => ApprovalOptionKind::AllowOnce, + acp::PermissionOptionKind::AllowAlways => ApprovalOptionKind::AllowAlways, + acp::PermissionOptionKind::RejectAlways => ApprovalOptionKind::RejectAlways, + _ => ApprovalOptionKind::RejectOnce, + }, + }) + .collect(), + } +} + +/// The standard client services the agent calls into. +#[derive(Clone)] +struct Client { + events: Sender, + state: Arc>, +} + +impl Client { + fn session(&self, connection: Connection) -> Session { + Session { + connection, + events: self.events.clone(), + state: self.state.clone(), + } + } + + async fn emit_all(&self, events: Vec) { + for event in events { + let _ = self.events.send(event).await; + } + } + + async fn request_permission( + &self, + args: acp::RequestPermissionRequest, + ) -> Result { + let (request_id, turn) = { + let mut state = self.state.lock_recover(); + state.approval_seq += 1; + ( + format!("acp-approval-{}", state.approval_seq), + state.turn().map(str::to_owned), + ) + }; + let request = approval_request(request_id.clone(), turn, &args.tool_call, &args.options); + let (responder, decided) = smol::channel::bounded(1); + { + let mut state = self.state.lock_recover(); + state + .approvals + .insert(request_id.clone(), (responder, request.options.clone())); + // Keep the tool card in step with what we are asking about. + let id = args.tool_call.tool_call_id.0.to_string(); + let entry = state.tools.entry(id).or_default(); + if let Some(title) = args.tool_call.fields.title.clone() { + entry.title = title; + } + if let Some(kind) = args.tool_call.fields.kind { + entry.kind = kind; + } + if let Some(raw_input) = args.tool_call.fields.raw_input.clone() { + entry.raw_input = Some(raw_input); + } + } + let _ = self + .events + .send(AgentEvent::ApprovalRequested(request)) + .await; + + let outcome = decided + .recv() + .await + .unwrap_or(acp::RequestPermissionOutcome::Cancelled); + self.state.lock_recover().approvals.remove(&request_id); + Ok(acp::RequestPermissionResponse::new(outcome)) + } + + async fn read_text_file( + &self, + args: acp::ReadTextFileRequest, + ) -> Result { + let path = self.state.lock_recover().resolve_path(&args.path)?; + let content = smol::fs::read_to_string(&path).await.map_err(|err| { + acp::Error::new(-32603, format!("could not read {}: {err}", path.display())) + })?; + // `line` is 1-based; `limit` counts lines from there. + let content = match (args.line, args.limit) { + (None, None) => content, + (line, limit) => { + let start = line.unwrap_or(1).saturating_sub(1) as usize; + let lines: Vec<&str> = content.lines().skip(start).collect(); + let end = limit.map_or(lines.len(), |limit| lines.len().min(limit as usize)); + lines[..end].join("\n") + } + }; + Ok(acp::ReadTextFileResponse::new(content)) + } + + async fn write_text_file( + &self, + args: acp::WriteTextFileRequest, + ) -> Result { + let path = self.state.lock_recover().resolve_path(&args.path)?; + if let Some(parent) = path.parent() { + smol::fs::create_dir_all(parent).await.map_err(|err| { + acp::Error::new( + -32603, + format!("could not create {}: {err}", parent.display()), + ) + })?; + } + smol::fs::write(&path, args.content).await.map_err(|err| { + acp::Error::new(-32603, format!("could not write {}: {err}", path.display())) + })?; + Ok(acp::WriteTextFileResponse::new()) + } + + async fn create_terminal( + &self, + args: acp::CreateTerminalRequest, + connection: &Connection, + ) -> Result { + let cwd = match &args.cwd { + Some(cwd) => self.state.lock_recover().resolve_path(cwd)?, + None => self.state.lock_recover().cwd.clone(), + }; + let terminal = Terminal::spawn( + connection, + &args.command, + &args.args, + &args.env, + &cwd, + args.output_byte_limit + .unwrap_or(DEFAULT_TERMINAL_OUTPUT_LIMIT), + )?; + let id = { + let mut state = self.state.lock_recover(); + state.terminal_seq += 1; + let id = format!("term-{}", state.terminal_seq); + state.terminals.insert(id.clone(), terminal); + id + }; + Ok(acp::CreateTerminalResponse::new(acp::TerminalId::new(id))) + } + + async fn terminal_output( + &self, + args: acp::TerminalOutputRequest, + ) -> Result { + let terminal = self.terminal(&args.terminal_id)?; + let output = terminal.output.lock_recover().clone(); + let truncated = *terminal.truncated.lock_recover(); + let exit_status = terminal.exit.lock_recover().clone(); + Ok(acp::TerminalOutputResponse::new(output, truncated).exit_status(exit_status)) + } + + async fn wait_for_terminal_exit( + &self, + args: acp::WaitForTerminalExitRequest, + ) -> Result { + let terminal = self.terminal(&args.terminal_id)?; + // The sender is dropped once the process exits, closing the channel. + let _ = terminal.done.recv().await; + let exit_status = terminal + .exit + .lock_recover() + .clone() + .unwrap_or_else(acp::TerminalExitStatus::new); + Ok(acp::WaitForTerminalExitResponse::new(exit_status)) + } + + async fn kill_terminal( + &self, + args: acp::KillTerminalRequest, + ) -> Result { + self.terminal(&args.terminal_id)?.kill(); + Ok(acp::KillTerminalResponse::new()) + } + + async fn release_terminal( + &self, + args: acp::ReleaseTerminalRequest, + ) -> Result { + let terminal = self + .state + .lock_recover() + .terminals + .remove(args.terminal_id.0.as_ref()); + if let Some(terminal) = terminal { + terminal.kill(); + } + Ok(acp::ReleaseTerminalResponse::new()) + } + fn terminal(&self, id: &acp::TerminalId) -> Result, acp::Error> { + self.state + .lock_recover() + .terminals + .get(id.0.as_ref()) + .cloned() + .ok_or_else(|| acp::Error::new(-32002, format!("unknown terminal `{}`", id.0))) + } +} + +/// A command the agent asked us to run. Headless: we capture the output and +/// serve `terminal/output` / `terminal/wait_for_exit` from it, and the text is +/// folded into the owning tool card (`ToolCallContent::Terminal`). Wiring these +/// into the terminal drawer needs a canonical event the contract does not have. +struct Terminal { + child: Mutex, + output: Mutex, + truncated: Mutex, + exit: Mutex>, + /// Closed (never sent on) once the process has exited. + done: Receiver<()>, +} + +impl Terminal { + fn spawn( + connection: &Connection, + command: &str, + args: &[String], + env: &[acp::EnvVariable], + cwd: &Path, + limit: u64, + ) -> Result, acp::Error> { + let program = crate::resolve_binary(None, command) + .map_err(|err| acp::Error::new(-32603, err.to_string()))?; + let mut cmd = crate::process::async_command(&program); + cmd.args(args) + .current_dir(cwd) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + for var in env { + cmd.env(&var.name, &var.value); + } + let mut child = cmd + .spawn() + .map_err(|err| acp::Error::new(-32603, format!("could not run `{command}`: {err}")))?; + let stdout = child.stdout.take().ok_or_else(|| { + acp::Error::new(-32603, format!("`{command}` started without piped stdout")) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + acp::Error::new(-32603, format!("`{command}` started without piped stderr")) + })?; + let (done_tx, done) = smol::channel::bounded::<()>(1); + + let terminal = Arc::new(Terminal { + child: Mutex::new(child), + output: Mutex::new(String::new()), + truncated: Mutex::new(false), + exit: Mutex::new(None), + done, + }); + + // stdout and stderr interleave into one buffer, as they do in a terminal. + let streams: [Box; 2] = + [Box::new(stdout), Box::new(stderr)]; + for stream in streams { + connection.spawn({ + let terminal = terminal.clone(); + async move { + let mut stream = stream; + let mut buf = [0u8; 4096]; + loop { + match stream.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(read) => terminal.append(&buf[..read], limit), + } + } + Ok(()) + } + })?; + } + + connection.spawn({ + let terminal = terminal.clone(); + async move { + loop { + let status = terminal.child.lock_recover().try_status(); + match status { + Ok(Some(status)) => { + *terminal.exit.lock_recover() = Some(exit_status(&status)); + break; + } + Err(err) => { + log::warn!("acp terminal: {err}"); + break; + } + Ok(None) => smol::Timer::after(Duration::from_millis(25)).await, + }; + } + // Closing the channel wakes every `wait_for_exit`. + drop(done_tx); + Ok(()) + } + })?; + + Ok(terminal) + } + + fn append(&self, bytes: &[u8], limit: u64) { + let mut output = self.output.lock_recover(); + output.push_str(&String::from_utf8_lossy(bytes)); + let limit = limit as usize; + if output.len() > limit { + // Keep the tail (what ACP asks for), cutting on a char boundary. + let mut cut = output.len() - limit; + while !output.is_char_boundary(cut) { + cut += 1; + } + output.drain(..cut); + *self.truncated.lock_recover() = true; + } + } + + fn kill(&self) { + let _ = self.child.lock_recover().kill(); + } +} + +fn exit_status(status: &std::process::ExitStatus) -> acp::TerminalExitStatus { + #[cfg(unix)] + let signal = { + use std::os::unix::process::ExitStatusExt as _; + status.signal().map(|signal| signal.to_string()) + }; + #[cfg(not(unix))] + let signal: Option = None; + acp::TerminalExitStatus::new() + .exit_code(status.code().map(|code| code as u32)) + .signal(signal) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn state() -> State { + State::new(PathBuf::from("/tmp/tcode-acp-test")) + } + + fn update(json: Value) -> acp::SessionUpdate { + serde_json::from_value(json).expect("valid session/update payload") + } + + fn modes(current: &str, available: &[&str]) -> acp::SessionModeState { + serde_json::from_value(json!({ + "currentModeId": current, + "availableModes": available + .iter() + .map(|id| json!({ "id": id, "name": id })) + .collect::>() + })) + .unwrap() + } + + #[test] + fn plan_mode_requires_advertisement_and_restores_the_previous_mode() { + for previous in ["build", "review"] { + let mut state = state(); + state.ingest_modes(Some(&modes(previous, &["build", "review", "plan"]))); + let plan = interaction_mode_target(InteractionMode::Plan, &state).unwrap(); + assert_eq!(plan, acp::SessionModeId::new("plan")); + state.select_mode(plan); + assert_eq!( + interaction_mode_target(InteractionMode::Build, &state), + Some(acp::SessionModeId::new(previous)) + ); + } + let mut state = state(); + state.ingest_modes(Some(&modes("build", &["build", "review"]))); + assert_eq!(interaction_mode_target(InteractionMode::Plan, &state), None); + assert!(matches!(missing_mode_warning(InteractionMode::Plan), + AgentEvent::Warning { message } if message.contains("does not advertise a Plan mode"))); + } + + #[test] + fn prompt_acceptance_follows_the_complete_stdio_write() { + smol::block_on(async { + use smol::prelude::*; + + let pending = Arc::new(Mutex::new(VecDeque::from([41]))); + let (events, received) = smol::channel::unbounded(); + let inner = smol::io::Cursor::new(Vec::new()); + let mut writer = ObservedWriter::new(inner, pending.clone(), events); + + writer + .write_all( + br#"{"jsonrpc":"2.0","method":"initialize"} +"#, + ) + .await + .unwrap(); + writer + .write_all(br#"{"jsonrpc":"2.0","method":"session/prompt"}"#) + .await + .unwrap(); + assert!( + received.try_recv().is_err(), + "an incomplete line must not ack" + ); + + writer.write_all(b"\n").await.unwrap(); + assert!(matches!( + received.recv().await.unwrap(), + AgentEvent::TurnAccepted { delivery_id: 41 } + )); + assert!(pending.lock_recover().is_empty()); + }); + } + + /// The preview MCP server is a loopback HTTP endpoint: it may only be handed + /// to agents that advertise `mcpCapabilities.http`. + #[test] + fn mcp_server_is_gated_on_the_http_capability() { + let registration = McpRegistration { + name: McpRegistration::SERVER_NAME_PREVIEW.into(), + url: "http://127.0.0.1:5321/mcp".into(), + bearer_token: "tok".into(), + }; + let mut caps = acp::AgentCapabilities::default(); + assert!( + mcp_servers(&[®istration], &caps).is_empty(), + "an agent without mcpCapabilities.http must not be sent the HTTP server" + ); + + caps.mcp_capabilities.http = true; + let servers = mcp_servers(&[®istration], &caps); + let value = serde_json::to_value(&servers[0]).unwrap(); + assert_eq!(value["type"], "http"); + assert_eq!(value["name"], "tcode_preview"); + assert_eq!(value["url"], "http://127.0.0.1:5321/mcp"); + assert_eq!(value["headers"][0]["name"], "Authorization"); + assert_eq!(value["headers"][0]["value"], "Bearer tok"); + + // No preview server running → nothing to send, capability or not. + assert!(mcp_servers(&[], &caps).is_empty()); + + let orchestrate = McpRegistration { + name: McpRegistration::SERVER_NAME_ORCHESTRATE.into(), + url: "http://127.0.0.1:5321/mcp".into(), + bearer_token: "other".into(), + }; + assert_eq!(mcp_servers(&[®istration, &orchestrate], &caps).len(), 2); + assert_eq!(mcp_servers(&[&orchestrate], &caps).len(), 1); + + let computer_use = McpRegistration { + name: McpRegistration::SERVER_NAME_COMPUTER_USE.into(), + url: "http://127.0.0.1:5322/mcp".into(), + bearer_token: "computer-token".into(), + }; + let servers = mcp_servers(&[®istration, &orchestrate, &computer_use], &caps); + assert_eq!(servers.len(), 3); + let value = serde_json::to_value(&servers[2]).unwrap(); + assert_eq!(value["name"], "tcode_computer_use"); + assert_eq!(value["headers"][0]["value"], "Bearer computer-token"); + } + + #[test] + fn streamed_text_keeps_block_identity_across_reasoning_and_ignores_user_echoes() { + let mut state = state(); + let mut previous = None; + for (kind, wire_kind, chunks, expected) in [ + ( + DeltaKind::AssistantText, + "agent_message_chunk", + ["Hel", "lo"], + "Hello", + ), + ( + DeltaKind::ReasoningText, + "agent_thought_chunk", + ["Think", "ing"], + "Thinking", + ), + ( + DeltaKind::AssistantText, + "agent_message_chunk", + ["An", "swer"], + "Answer", + ), + ] { + let mut id = None; + for chunk in chunks { + let mut events = state + .apply_update(update(json!({ + "sessionUpdate": wire_kind, "content": {"type":"text", "text":chunk} + }))) + .into_iter(); + if let Some((previous_id, previous_kind, previous_text)) = previous.take() { + let Some(AgentEvent::ItemCompleted(item)) = events.next() else { + panic!("changing text kind must complete the previous block"); + }; + assert_eq!(item.id, previous_id); + match (previous_kind, item.content) { + (DeltaKind::AssistantText, ItemContent::AssistantMessage { text }) + | (DeltaKind::ReasoningText, ItemContent::Reasoning { text }) => { + assert_eq!(text, previous_text) + } + other => panic!("wrong completed block: {other:?}"), + } + } + let Some(AgentEvent::Delta { + item_id, + kind: actual_kind, + text, + }) = events.next() + else { + panic!("text chunk must stream a delta"); + }; + assert_eq!(actual_kind, kind); + assert_eq!(text, chunk); + if let Some(id) = &id { + assert_eq!(&item_id, id); + } + id = Some(item_id); + assert!(events.next().is_none()); + assert!(state.apply_update(update(json!({ + "sessionUpdate":"user_message_chunk", "content":{"type":"text", "text":"prompt echo"} + }))).is_empty()); + } + previous = Some((id.unwrap(), kind, expected)); + } + let (id, _, expected) = previous.unwrap(); + assert!(matches!(state.flush_text().as_slice(), + [AgentEvent::ItemCompleted(ThreadItem { id: actual_id, content: ItemContent::AssistantMessage { text }, .. })] + if actual_id == &id && text == expected)); + assert!(state.flush_text().is_empty()); + } + + #[test] + fn execute_tool_call_maps_to_a_command_execution() { + let mut state = state(); + let events = state.apply_update(update(json!({ + "sessionUpdate": "tool_call", + "toolCallId": "t1", + "title": "Run tests", + "kind": "execute", + "status": "in_progress", + "rawInput": { "command": "cargo test" } + }))); + match &events[0] { + AgentEvent::ItemStarted(item) => { + assert_eq!(item.id, "t1"); + match &item.content { + ItemContent::CommandExecution { + command, status, .. + } => { + assert_eq!(command, "cargo test"); + assert_eq!(*status, ItemStatus::InProgress); + } + other => panic!("expected CommandExecution, got {other:?}"), + } + } + other => panic!("expected ItemStarted, got {other:?}"), + } + + // A partial patch merges into the merged state and completes the item. + let events = state.apply_update(update(json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": "t1", + "status": "completed", + "content": [{ "type": "content", "content": { "type": "text", "text": "ok" } }], + "rawOutput": { "exitCode": 0 } + }))); + match &events[0] { + AgentEvent::ItemCompleted(item) => match &item.content { + ItemContent::CommandExecution { + command, + output, + exit_code, + status, + } => { + assert_eq!(command, "cargo test", "rawInput must survive the patch"); + assert_eq!(output, "ok"); + assert_eq!(*exit_code, Some(0)); + assert_eq!(*status, ItemStatus::Completed); + } + other => panic!("expected CommandExecution, got {other:?}"), + }, + other => panic!("expected ItemCompleted, got {other:?}"), + } + } + + #[test] + fn file_tools_preserve_operation_kind_and_render_available_change_evidence() { + for (kind, old, new, operation, diff) in [ + ( + "edit", + Some("same\nbefore\nend\n"), + "same\nafter\nend\n", + FileChangeKind::Modify, + "--- a/file.rs\n+++ b/file.rs\n@@ -1,3 +1,3 @@\n same\n-before\n+after\n end\n", + ), + ( + "edit", + None, + "new\n", + FileChangeKind::Create, + "--- /dev/null\n+++ b/file.rs\n@@ -0,0 +1,1 @@\n+new\n", + ), + ( + "edit", + Some(""), + "new\n", + FileChangeKind::Create, + "--- /dev/null\n+++ b/file.rs\n@@ -0,0 +1,1 @@\n+new\n", + ), + ( + "delete", + Some("old\n"), + "", + FileChangeKind::Delete, + "--- a/file.rs\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-old\n", + ), + ( + "move", + Some("old\n"), + "new\n", + FileChangeKind::Rename, + "--- a/file.rs\n+++ b/file.rs\n@@ -1,1 +1,1 @@\n-old\n+new\n", + ), + ] { + let events = state().apply_update(update(json!({ + "sessionUpdate":"tool_call", "toolCallId":"file", "title":"Change file", + "kind":kind, "status":"completed", + "content":[{"type":"diff","path":"file.rs","oldText":old,"newText":new}] + }))); + let [ + AgentEvent::ItemStarted(ThreadItem { + content: ItemContent::FileChange { changes, status }, + .. + }), + ] = events.as_slice() + else { + panic!("{kind}: expected file item, got {events:?}"); + }; + assert_eq!(*status, ItemStatus::Completed); + assert_eq!( + changes, + &[FileChange { + path: "file.rs".into(), + kind: operation, + diff: Some(diff.into()) + }] + ); + } + for (kind, operation) in [ + ("delete", FileChangeKind::Delete), + ("move", FileChangeKind::Rename), + ] { + let events = state().apply_update(update(json!({ + "sessionUpdate":"tool_call", "toolCallId":"file", "title":"Change file", + "kind":kind, "status":"completed", "locations":[{"path":"one.rs"},{"path":"two.rs"}] + }))); + let [ + AgentEvent::ItemStarted(ThreadItem { + content: ItemContent::FileChange { changes, .. }, + .. + }), + ] = events.as_slice() + else { + panic!("{kind}: expected file locations, got {events:?}"); + }; + assert_eq!( + changes, + &[ + FileChange { + path: "one.rs".into(), + kind: operation, + diff: None + }, + FileChange { + path: "two.rs".into(), + kind: operation, + diff: None + }, + ] + ); + } + let events = state().apply_update(update(json!({ + "sessionUpdate":"tool_call", "toolCallId":"file", "title":"Opaque edit", + "kind":"edit", "status":"completed", "rawInput":{"path":"file.rs"}, "rawOutput":"done" + }))); + assert!( + matches!(events.as_slice(), [AgentEvent::ItemStarted(ThreadItem { + content:ItemContent::ToolCall { name, input, output:Some(output), status:ItemStatus::Completed }, .. + })] if name == "Opaque edit" && input == &json!({"path":"file.rs"}) && output == "done") + ); + } + + #[test] + fn read_search_fetch_and_other_map_to_tool_cards_with_raw_payloads() { + for (kind, id) in [ + ("read", "r1"), + ("search", "r2"), + ("fetch", "r3"), + ("other", "r4"), + ("switch_mode", "r5"), + ] { + let mut state = state(); + let events = state.apply_update(update(json!({ + "sessionUpdate": "tool_call", + "toolCallId": id, + "title": "Read file", + "kind": kind, + "status": "completed", + "rawInput": { "path": "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/repo/x.rs" }, + "content": [{ "type": "content", "content": { "type": "text", "text": "body" } }] + }))); + match &events[0] { + AgentEvent::ItemStarted(item) => match &item.content { + ItemContent::ToolCall { + name, + input, + output, + status, + } => { + assert_eq!(name, "Read file"); + assert_eq!(input["path"], "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/repo/x.rs", "rawInput must ride along"); + assert_eq!(output.as_deref(), Some("body")); + assert_eq!(*status, ItemStatus::Completed); + } + other => panic!("{kind}: expected ToolCall, got {other:?}"), + }, + other => panic!("{kind}: expected ItemStarted, got {other:?}"), + } + } + } + + #[test] + fn think_tool_calls_become_reasoning() { + let mut state = state(); + let events = state.apply_update(update(json!({ + "sessionUpdate": "tool_call", + "toolCallId": "t4", + "title": "Thinking", + "kind": "think", + "status": "completed", + "content": [{ "type": "content", "content": { "type": "text", "text": "step 1" } }] + }))); + match &events[0] { + AgentEvent::ItemStarted(item) => match &item.content { + ItemContent::Reasoning { text } => assert_eq!(text, "step 1"), + other => panic!("expected Reasoning, got {other:?}"), + }, + other => panic!("expected ItemStarted, got {other:?}"), + } + } + + #[test] + fn tool_updates_recover_missing_starts_and_preserve_status_and_input() { + for (wire, expected) in [ + ("pending", ItemStatus::InProgress), + ("in_progress", ItemStatus::InProgress), + ("completed", ItemStatus::Completed), + ("failed", ItemStatus::Failed), + ] { + let mut state = state(); + let events = state.apply_update(update(json!({ + "sessionUpdate":"tool_call_update", "toolCallId":"late", "title":"Late tool", + "kind":"other", "status":"pending", "rawInput":{"path":"a.rs"} + }))); + assert!(matches!(events.as_slice(), [AgentEvent::ItemStarted(_)])); + let events = state.apply_update(update(json!({ + "sessionUpdate":"tool_call_update", "toolCallId":"late", "status":wire + }))); + let item = match events.as_slice() { + [AgentEvent::ItemUpdated(item)] if expected == ItemStatus::InProgress => item, + [AgentEvent::ItemCompleted(item)] if expected != ItemStatus::InProgress => item, + other => panic!("{wire}: incorrect lifecycle event {other:?}"), + }; + assert!( + matches!(&item.content, ItemContent::ToolCall { name, input, status, .. } + if name == "Late tool" && input == &json!({"path":"a.rs"}) && *status == expected) + ); + } + } + + #[test] + fn plan_update_preserves_turn_and_steps() { + let mut state = state(); + state.turn = Some("turn-1".into()); + let events = state.apply_update(update(json!({ + "sessionUpdate": "plan", + "entries": [ + { "content": "Read the code", "priority": "high", "status": "completed" }, + { "content": "Write the fix", "priority": "medium", "status": "in_progress" } + ] + }))); + match &events[0] { + AgentEvent::PlanUpdated { + turn_id, + steps, + explanation, + } => { + assert_eq!(turn_id.as_deref(), Some("turn-1")); + assert!(explanation.is_none()); + assert_eq!(steps.len(), 2); + assert_eq!(steps[0].step, "Read the code"); + assert_eq!(steps[0].status, PlanStepStatus::Completed); + assert_eq!(steps[1].status, PlanStepStatus::InProgress); + } + other => panic!("expected PlanUpdated, got {other:?}"), + } + } + + #[test] + fn available_commands_become_provider_commands() { + let mut state = state(); + let events = state.apply_update(update(json!({ + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { "name": "review", "description": "Review the diff", "input": null } + ] + }))); + match &events[0] { + AgentEvent::ProviderCommands { commands } => { + assert_eq!(commands[0].name, "review"); + assert_eq!(commands[0].kind, ProviderCommandKind::Command); + } + other => panic!("expected ProviderCommands, got {other:?}"), + } + } + + #[test] + fn usage_update_is_the_context_window() { + let mut state = state(); + let events = state.apply_update(update(json!({ + "sessionUpdate": "usage_update", + "used": 1200, + "size": 200000 + }))); + match &events[0] { + AgentEvent::TokenUsage(usage) => { + assert_eq!(usage.used_tokens, Some(1200)); + assert_eq!(usage.context_window, Some(200_000)); + } + other => panic!("expected TokenUsage, got {other:?}"), + } + } + + #[test] + fn modes_and_categorized_config_become_provider_options() { + let mut state = state(); + let modes: acp::SessionModeState = serde_json::from_value(json!({ + "currentModeId": "build", + "availableModes": [ + { "id": "build", "name": "Build" }, + { "id": "plan", "name": "Plan", "description": "Read-only" } + ] + })) + .unwrap(); + let config: Vec = serde_json::from_value(json!([ + { + "id": "model", + "name": "Model", + "category": "model", + "type": "select", + "currentValue": "sonnet", + "options": [{ "value": "sonnet", "name": "Sonnet" }] + }, + { + "id": "thought_level", + "name": "Thinking", + "category": "thought_level", + "type": "select", + "currentValue": "medium", + "options": [ + { "value": "low", "name": "Low" }, + { "value": "medium", "name": "Medium" } + ] + }, + { "id": "web", "name": "Web search", "type": "boolean", "currentValue": true } + ])) + .unwrap(); + state.options.ingest(Some(&modes), Some(&config)); + assert_eq!(state.options.current_model(), Some("sonnet".to_string())); + + let descriptors: Vec<_> = state + .options + .records + .iter() + .map(|record| record.descriptor.clone()) + .collect(); + let ids: Vec<&str> = descriptors.iter().map(descriptor_id).collect(); + assert_eq!( + ids, + vec![ + "acp:mode", + "acp:model", + "acp:cfg:thought_level", + "acp:cfg:web" + ] + ); + assert_eq!(state.options.origin("acp:mode"), Some(OptionOrigin::Mode)); + assert_eq!( + state.options.origin("acp:model"), + Some(OptionOrigin::Config(acp::SessionConfigId::new("model"))) + ); + assert_eq!( + state.options.origin("acp:cfg:web"), + Some(OptionOrigin::Config(acp::SessionConfigId::new("web"))) + ); + assert!(matches!( + &descriptors[3], + OptionDescriptor::Boolean { + default_value: true, + .. + } + )); + + let selections: Vec<_> = state + .options + .records + .iter() + .map(|record| record.selection.clone()) + .collect(); + assert_eq!(selections[0].value, json!("build")); + assert_eq!(selections[1].value, json!("sonnet")); + assert_eq!(selections[2].value, json!("medium")); + assert_eq!(selections[3].value, json!(true)); + + // An agent-initiated mode switch re-publishes the options… + let events = state.apply_update(update(json!({ + "sessionUpdate": "current_mode_update", + "currentModeId": "plan" + }))); + match &events[0] { + AgentEvent::ProviderOptions { selections, .. } => { + assert_eq!(selections[0].value, json!("plan")); + } + other => panic!("expected ProviderOptions, got {other:?}"), + } + + // …and so does a config-option push. + let events = state.apply_update(update(json!({ + "sessionUpdate": "config_option_update", + "configOptions": [ + { "id": "web", "name": "Web search", "type": "boolean", "currentValue": false } + ] + }))); + match &events[0] { + AgentEvent::ProviderOptions { selections, .. } => { + let web = selections.iter().find(|s| s.id == "acp:cfg:web").unwrap(); + assert_eq!(web.value, json!(false)); + } + other => panic!("expected ProviderOptions, got {other:?}"), + } + } + + #[test] + fn permission_request_carries_the_agents_own_options() { + let request: acp::RequestPermissionRequest = serde_json::from_value(json!({ + "sessionId": "s1", + "toolCall": { + "toolCallId": "t9", + "title": "rm -rf build", + "kind": "execute", + "rawInput": { "command": "rm -rf build" } + }, + "options": [ + { "optionId": "yes", "name": "Allow", "kind": "allow_once" }, + { "optionId": "always", "name": "Always allow", "kind": "allow_always" }, + { "optionId": "no", "name": "Reject", "kind": "reject_once" } + ] + })) + .unwrap(); + let approval = approval_request( + "acp-approval-1".into(), + Some("turn-1".into()), + &request.tool_call, + &request.options, + ); + match &approval.kind { + ApprovalKind::ExecCommand { command, .. } => assert_eq!(command, "rm -rf build"), + other => panic!("expected ExecCommand, got {other:?}"), + } + assert_eq!(approval.options.len(), 3); + assert_eq!(approval.options[0].label, "Allow"); + assert_eq!(approval.options[1].kind, ApprovalOptionKind::AllowAlways); + + // Our fixed four map onto the agent's own options… + let selected = + |decision: ApprovalDecision| match approval_outcome(&decision, &approval.options) { + Some(acp::RequestPermissionOutcome::Selected(outcome)) => { + outcome.option_id.0.to_string() + } + other => panic!("expected a selection, got {other:?}"), + }; + assert_eq!(selected(ApprovalDecision::Approve), "yes"); + assert_eq!(selected(ApprovalDecision::Deny), "no"); + // A persistent native option is chosen only explicitly, and only one + // the agent offered. + assert_eq!(selected(ApprovalDecision::ApproveForSession), "yes"); + assert_eq!( + selected(ApprovalDecision::Option("always".into())), + "always" + ); + assert!( + approval_outcome(&ApprovalDecision::Option("other".into()), &approval.options) + .is_none() + ); + // …and Cancel is the protocol's own `cancelled` outcome. + assert!(matches!( + approval_outcome(&ApprovalDecision::Cancel, &approval.options), + Some(acp::RequestPermissionOutcome::Cancelled) + )); + + // The exact wire shape the agent expects back. + let response = acp::RequestPermissionResponse::new( + approval_outcome(&ApprovalDecision::Approve, &approval.options).unwrap(), + ); + let value = serde_json::to_value(&response).unwrap(); + assert_eq!(value["outcome"]["outcome"], "selected"); + assert_eq!(value["outcome"]["optionId"], "yes"); + // No fixed decision falls back to a persistent option. + let persistent_only: Vec<_> = [ + ApprovalOptionKind::AllowAlways, + ApprovalOptionKind::RejectAlways, + ] + .into_iter() + .map(|kind| ApprovalOption { + id: format!("{kind:?}"), + label: format!("{kind:?}"), + kind, + }) + .collect(); + for decision in [ + ApprovalDecision::Approve, + ApprovalDecision::ApproveForSession, + ApprovalDecision::Deny, + ] { + assert!(approval_outcome(&decision, &persistent_only).is_none()); + } + } + + #[test] + fn approvals_classify_by_tool_kind() { + let build = |kind: &str| { + let tool: acp::ToolCallUpdate = serde_json::from_value(json!({ + "toolCallId": "t", + "title": "Read config", + "kind": kind, + "rawInput": { "path": "a.rs" } + })) + .unwrap(); + approval_request("a1".into(), None, &tool, &[]).kind + }; + assert!(matches!(build("read"), ApprovalKind::FileRead { .. })); + assert!(matches!(build("search"), ApprovalKind::FileRead { .. })); + assert!(matches!(build("fetch"), ApprovalKind::FileRead { .. })); + assert!(matches!(build("other"), ApprovalKind::ToolUse { .. })); + + let edit: acp::ToolCallUpdate = serde_json::from_value(json!({ + "toolCallId": "t", + "title": "Edit", + "kind": "edit", + "content": [{ "type": "diff", "path": "a.rs", "oldText": "a\n", "newText": "b\n" }] + })) + .unwrap(); + match approval_request("a2".into(), None, &edit, &[]).kind { + ApprovalKind::FileChange { changes, .. } => assert_eq!(changes[0].path, "a.rs"), + other => panic!("expected FileChange, got {other:?}"), + } + } + + #[test] + fn stop_reasons_map_to_turn_status() { + assert_eq!( + stop_reason_status(acp::StopReason::EndTurn).0, + TurnStatus::Completed + ); + assert_eq!( + stop_reason_status(acp::StopReason::Cancelled).0, + TurnStatus::Interrupted + ); + let (status, message) = stop_reason_status(acp::StopReason::Refusal); + assert_eq!(status, TurnStatus::Failed); + assert!(message.unwrap().contains("refused")); + let (status, message) = stop_reason_status(acp::StopReason::MaxTokens); + assert_eq!(status, TurnStatus::Failed); + assert!(message.unwrap().contains("token limit")); + } + + #[test] + fn paths_outside_the_session_cwd_are_rejected() { + let state = state(); + assert!(state.resolve_path(Path::new("src/main.rs")).is_ok()); + assert!( + state + .resolve_path(Path::new("/tmp/tcode-acp-test/../etc/passwd")) + .is_err() + ); + assert!(state.resolve_path(Path::new("/etc/passwd")).is_err()); + } + + #[test] + fn prompt_carries_text_and_image_blocks() { + let blocks = prompt_blocks( + "hello", + &[Attachment { + media_type: "image/png".into(), + data_base64: "AAAA".into(), + source_path: None, + }], + ); + let value = serde_json::to_value(&blocks).unwrap(); + assert_eq!(value[0]["type"], "text"); + assert_eq!(value[0]["text"], "hello"); + assert_eq!(value[1]["type"], "image"); + assert_eq!(value[1]["mimeType"], "image/png"); + assert_eq!(value[1]["data"], "AAAA"); + } +} diff --git a/crates/agent/src/claude_plugins.rs b/crates/agent/src/claude_plugins.rs new file mode 100644 index 000000000..4193d34ca --- /dev/null +++ b/crates/agent/src/claude_plugins.rs @@ -0,0 +1,1130 @@ +//! Claude Code's native plugin management, driven through `claude plugin …` +//! with one bounded child process per command. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::Deserialize; +use serde_json::Value; + +use crate::{ + AgentError, CommandAcceptance, DeclaredComponents, MarketplaceAction, PluginAction, + PluginContext, PluginInstallation, PluginListing, PluginOp, PluginOpOutcome, PluginScope, + PluginSource, PluginSourceKind, ProviderKind, ProviderPluginEntry, ProviderPluginMarketplace, + Tri, native_message, +}; + +const LIST_TIMEOUT: Duration = Duration::from_secs(60); +/// Installs and marketplace adds may clone repositories. +const MUTATION_TIMEOUT: Duration = Duration::from_secs(300); +const DETAILS_CONCURRENCY: usize = 4; +const OFFICIAL_MARKETPLACE: &str = "claude-plugins-official"; +const OFFICIAL_MARKETPLACE_REPO: &str = "anthropics/claude-plugins-official"; + +/// `CLAUDECODE` and `CLAUDE_CODE_CHILD_SESSION` make the CLI ignore +/// `--accept-command` ("ignored inside a Claude Code session"), and Tcode is +/// often started from a shell that Claude Code spawned. `CLAUDE_CODE_ENTRYPOINT` +/// is stripped for the same nesting reason as sessions. +const NESTED_SESSION_VARS: [&str; 3] = [ + "CLAUDECODE", + "CLAUDE_CODE_ENTRYPOINT", + "CLAUDE_CODE_CHILD_SESSION", +]; + +pub(crate) async fn list(context: &PluginContext) -> Result { + let (plugins, marketplaces) = smol::future::zip( + run_cli( + context, + &args(&["plugin", "list", "--json", "--available"]), + LIST_TIMEOUT, + ), + run_cli( + context, + &args(&["plugin", "marketplace", "list", "--json"]), + LIST_TIMEOUT, + ), + ) + .await; + let plugins = parse_listed(&succeeded(plugins?)?)?; + let marketplaces = parse_marketplaces(&succeeded(marketplaces?)?)?; + let in_context = InContext::new(context); + let mut listing = listing(plugins, marketplaces, &in_context); + + let installed: Vec = listing + .entries + .iter() + .filter(|entry| !entry.installations.is_empty()) + .map(|entry| entry.id.clone()) + .collect(); + let mut details = HashMap::new(); + for chunk in installed.chunks(DETAILS_CONCURRENCY) { + let tasks: Vec<_> = chunk + .iter() + .map(|id| { + let context = context.clone(); + let id = id.clone(); + smol::spawn(async move { + let output = + run_cli(&context, &args(&["plugin", "details", &id]), LIST_TIMEOUT) + .await + .and_then(succeeded); + (id, output) + }) + }) + .collect(); + for task in tasks { + let (id, output) = task.await; + details.insert(id, output); + } + } + for entry in &mut listing.entries { + match details.remove(&entry.id) { + Some(Ok(text)) => apply_details(entry, &text), + Some(Err(error)) => entry + .diagnostics + .push(("details_error".into(), native_message(&error))), + None => {} + } + } + Ok(listing) +} + +pub(crate) async fn run( + context: &PluginContext, + op: &PluginOp, +) -> Result { + let output = run_cli(context, &op_args(op)?, MUTATION_TIMEOUT).await?; + match op { + PluginOp::AddMarketplace { .. } | PluginOp::RemoveMarketplace { .. } => { + let stdout = succeeded(output)?; + Ok(PluginOpOutcome::Done { + diagnostics: vec![("message".into(), stdout.trim().to_string())], + }) + } + _ => json_outcome(&output), + } +} + +fn args(parts: &[&str]) -> Vec { + parts.iter().map(|part| (*part).to_string()).collect() +} + +/// The argv for one mutation. `-y` is never passed: a marketplace-declared +/// command runs only with the `--accept-command` hash a person accepted. +/// Every plugin mutation names its scope, because the CLI's default +/// auto-detects one from the working directory. +fn op_args(op: &PluginOp) -> Result, AgentError> { + let args = match op { + PluginOp::Install { + id, + scope, + accept_command, + } + | PluginOp::Update { + id, + scope, + accept_command, + } => { + let verb = if matches!(op, PluginOp::Install { .. }) { + "install" + } else { + "update" + }; + let mut args = plugin_args(verb, id, *scope)?; + if let Some(sha256) = accept_command { + args.extend(["--accept-command".into(), operand(sha256)?]); + } + args + } + PluginOp::Uninstall { id, scope } => plugin_args("uninstall", id, *scope)?, + PluginOp::SetEnabled { id, scope, enabled } => { + plugin_args(if *enabled { "enable" } else { "disable" }, id, *scope)? + } + PluginOp::AddMarketplace { source } => vec![ + "plugin".into(), + "marketplace".into(), + "add".into(), + operand(source)?, + "--scope".into(), + "user".into(), + ], + PluginOp::RemoveMarketplace { name } => vec![ + "plugin".into(), + "marketplace".into(), + "remove".into(), + operand(name)?, + ], + }; + Ok(args) +} + +fn plugin_args(verb: &str, id: &str, scope: PluginScope) -> Result, AgentError> { + Ok(vec![ + "plugin".into(), + verb.into(), + operand(id)?, + "--scope".into(), + scope_arg(scope)?.into(), + "--json".into(), + ]) +} + +/// A value placed in a positional slot must not be read as an option. +fn operand(value: &str) -> Result { + if value.is_empty() || value.starts_with('-') { + return Err(AgentError::Protocol(format!( + "refusing to pass {value:?} to `claude plugin`" + ))); + } + Ok(value.to_string()) +} + +fn scope_arg(scope: PluginScope) -> Result<&'static str, AgentError> { + match scope { + PluginScope::User => Ok("user"), + PluginScope::Project => Ok("project"), + PluginScope::Local => Ok("local"), + PluginScope::Managed => Ok("managed"), + PluginScope::Session => Err(AgentError::Protocol( + "Claude Code session plugins have no management command".into(), + )), + } +} + +fn scope_from_native(scope: &str) -> Option { + match scope { + "user" => Some(PluginScope::User), + "project" => Some(PluginScope::Project), + "local" => Some(PluginScope::Local), + "managed" => Some(PluginScope::Managed), + _ => None, + } +} + +fn management_command( + context: &PluginContext, + args: &[String], +) -> Result { + let binary = crate::resolve_binary(context.binary_path.as_deref(), "claude")?; + let mut command = crate::process::command(binary); + command.args(args).current_dir(&context.cwd); + for var in NESTED_SESSION_VARS { + command.env_remove(var); + } + for (key, value) in context.launch_env.pairs(ProviderKind::ClaudeCode) { + command.env(key, value); + } + Ok(command) +} + +struct CliOutput { + success: bool, + status: String, + stdout: String, + stderr: String, +} + +async fn run_cli( + context: &PluginContext, + args: &[String], + timeout: Duration, +) -> Result { + let display = format!("claude {}", args.join(" ")); + let mut command = smol::process::Command::from(management_command(context, args)?); + // `output()` gives the child a null stdin, so a prompt can never wait on + // input, and dropping it on timeout kills the child. + command.kill_on_drop(true); + let output = smol::future::or(async { Some(command.output().await) }, async { + smol::Timer::after(timeout).await; + None + }) + .await + .ok_or_else(|| AgentError::Provider(format!("`{display}` timed out")))? + .map_err(|error| AgentError::Spawn(format!("spawning `{display}`: {error}")))?; + Ok(CliOutput { + success: output.status.success(), + status: output.status.to_string(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) +} + +fn succeeded(output: CliOutput) -> Result { + if output.success { + return Ok(output.stdout); + } + let message = [output.stderr.trim(), output.stdout.trim()] + .into_iter() + .find(|text| !text.is_empty()) + .map(str::to_string) + .unwrap_or(output.status); + Err(AgentError::Provider(message)) +} + +/// Mutations print one JSON result on the last stdout line; command-source +/// installs print the command and its mode as human text before it. +fn last_json_line(stdout: &str) -> Result<(Value, String), AgentError> { + let mut lines: Vec<&str> = stdout + .lines() + .filter(|line| !line.trim().is_empty()) + .collect(); + let last = lines + .pop() + .ok_or_else(|| AgentError::Protocol("`claude plugin` printed no result".into()))?; + let value = serde_json::from_str::(last.trim()) + .ok() + .filter(Value::is_object) + .ok_or_else(|| { + AgentError::Protocol(format!( + "`claude plugin` did not end with a JSON result: {last}" + )) + })?; + Ok((value, lines.join("\n"))) +} + +fn json_outcome(output: &CliOutput) -> Result { + let (result, preamble) = match last_json_line(&output.stdout) { + Ok(parsed) => parsed, + Err(error) if output.success => return Err(error), + Err(_) => { + let text = [output.stderr.trim(), output.stdout.trim()] + .into_iter() + .find(|text| !text.is_empty()) + .unwrap_or(&output.status); + return Err(AgentError::Provider(text.to_string())); + } + }; + let text = |key: &str| result.get(key).and_then(Value::as_str).map(str::to_string); + let message = text("message"); + if text("outcome").as_deref() == Some("ok") { + let mut diagnostics = Vec::new(); + if result.get("command").and_then(Value::as_str) == Some("update") { + for (key, native) in [ + ("update_outcome", "updateOutcome"), + ("old_version", "oldVersion"), + ("new_version", "newVersion"), + ("message", "message"), + ] { + if let Some(value) = text(native) { + diagnostics.push((key.to_string(), value)); + } + } + } + return Ok(PluginOpOutcome::Done { diagnostics }); + } + if text("failureCode").as_deref() == Some("command_source_refused") { + let shown = result.get("shownCommand"); + let shown_text = |key: &str| { + shown + .and_then(|shown| shown.get(key)) + .and_then(Value::as_str) + }; + if let (Some(command), Some(sha256)) = (shown_text("command"), shown_text("sha256")) { + let native_text = if preamble.trim().is_empty() { + message.unwrap_or_default() + } else { + preamble + }; + return Ok(PluginOpOutcome::AcceptCommand(CommandAcceptance { + command: command.to_string(), + sha256: sha256.to_string(), + mode: shown_text("mode").map(str::to_string), + native_text, + })); + } + } + Err(AgentError::Provider( + message + .or_else(|| text("failureCode")) + .unwrap_or_else(|| result.to_string()), + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListedInstallation { + id: String, + #[serde(default)] + version: Option, + scope: String, + #[serde(default)] + enabled: Option, + #[serde(default)] + install_path: Option, + #[serde(default)] + project_path: Option, + #[serde(default)] + mcp_servers: Option>, + #[serde(default)] + project_enabled: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListedAvailable { + plugin_id: String, + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + marketplace_name: Option, + #[serde(default)] + version: Option, + #[serde(default)] + source: Value, +} + +#[derive(Deserialize)] +struct Listed { + #[serde(default)] + installed: Vec, + #[serde(default)] + available: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListedMarketplace { + name: String, + #[serde(default)] + source: Option, + #[serde(default)] + repo: Option, + #[serde(default)] + url: Option, + #[serde(default)] + path: Option, + #[serde(default)] + install_location: Option, +} + +fn parse_listed(stdout: &str) -> Result { + serde_json::from_str(stdout.trim()).map_err(|error| { + AgentError::Protocol(format!("unexpected `claude plugin list` output: {error}")) + }) +} + +fn parse_marketplaces(stdout: &str) -> Result, AgentError> { + let listed: Vec = serde_json::from_str(stdout.trim()).map_err(|error| { + AgentError::Protocol(format!( + "unexpected `claude plugin marketplace list` output: {error}" + )) + })?; + Ok(listed + .into_iter() + .map(|marketplace| { + let native = marketplace.source.as_deref().unwrap_or_default(); + let kind = if marketplace.name == OFFICIAL_MARKETPLACE + || marketplace.repo.as_deref() == Some(OFFICIAL_MARKETPLACE_REPO) + { + PluginSourceKind::Official + } else { + match native { + "directory" | "file" => PluginSourceKind::LocalPath, + "github" | "git" => PluginSourceKind::Git, + "url" => PluginSourceKind::ThirdParty, + "npm" => PluginSourceKind::Npm, + _ => PluginSourceKind::Unknown, + } + }; + ProviderPluginMarketplace { + source: marketplace + .repo + .or(marketplace.url) + .or(marketplace.path) + .unwrap_or_else(|| native.to_string()), + name: marketplace.name, + kind, + location: marketplace.install_location, + } + }) + .collect()) +} + +/// Which installations the context directory's scopes address. `list` is +/// home-wide, so a project installation of another project is reported too, +/// but `--scope project` would act on this directory's project instead. +struct InContext { + project: Option, +} + +impl InContext { + fn new(context: &PluginContext) -> Self { + Self { + project: context.project.then(|| canonical(&context.cwd)), + } + } + + fn scopes(&self) -> &'static [PluginScope] { + if self.project.is_some() { + &[PluginScope::User, PluginScope::Project, PluginScope::Local] + } else { + &[PluginScope::User] + } + } + + fn contains(&self, scope: PluginScope, project_path: Option<&Path>) -> bool { + match scope { + PluginScope::User | PluginScope::Managed => true, + PluginScope::Project | PluginScope::Local => match (&self.project, project_path) { + (Some(project), Some(path)) => canonical(path) == *project, + _ => false, + }, + PluginScope::Session => false, + } + } +} + +fn canonical(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +fn listing( + listed: Listed, + marketplaces: Vec, + in_context: &InContext, +) -> PluginListing { + let marketplace_kind = |name: Option<&str>| { + name.and_then(|name| marketplaces.iter().find(|m| m.name == name)) + .map(|m| m.kind) + .unwrap_or(PluginSourceKind::Unknown) + }; + let mut entries: Vec = Vec::new(); + // Per entry: the in-context installed scopes. + let mut here: Vec> = Vec::new(); + for installed in listed.installed { + let Some(scope) = scope_from_native(&installed.scope) else { + log::warn!( + "claude plugin list: unknown scope {:?} for {}", + installed.scope, + installed.id + ); + continue; + }; + let in_this_context = in_context.contains(scope, installed.project_path.as_deref()); + let index = match entries.iter().position(|entry| entry.id == installed.id) { + Some(index) => index, + None => { + let (name, marketplace) = split_id(&installed.id); + entries.push(ProviderPluginEntry { + id: installed.id.clone(), + name, + version: None, + description: None, + // Installed entries carry no source; only an + // available listing of the same id names it. + source: PluginSource { + kind: PluginSourceKind::Unknown, + marketplace, + }, + installations: Vec::new(), + enabled: installed.enabled.map(Tri::from).unwrap_or_default(), + declared: Some(DeclaredComponents { + mcp_servers: Some(Vec::new()), + ..DeclaredComponents::default() + }), + errors: Vec::new(), + actions: Vec::new(), + diagnostics: Vec::new(), + }); + here.push(Vec::new()); + entries.len() - 1 + } + }; + let entry = &mut entries[index]; + if let (Some(servers), Some(declared)) = (&installed.mcp_servers, &mut entry.declared) { + let names = declared.mcp_servers.get_or_insert_with(Vec::new); + for name in servers.keys() { + if !names.contains(name) { + names.push(name.clone()); + } + } + } + entry.installations.push(PluginInstallation { + scope, + location: installed.install_path, + version: installed.version, + scope_enabled: (scope == PluginScope::Project && in_this_context) + .then_some(installed.project_enabled) + .flatten(), + }); + if in_this_context { + here[index].push(scope); + } + } + for entry in &mut entries { + let first = entry.installations[0].version.clone(); + if entry + .installations + .iter() + .all(|installation| installation.version == first) + { + entry.version = first; + } + } + for available in listed.available { + let kind = source_kind( + &available.source, + marketplace_kind(available.marketplace_name.as_deref()), + ); + if let Some(entry) = entries.iter_mut().find(|e| e.id == available.plugin_id) { + entry.description = available.description; + entry.source.kind = kind; + continue; + } + entries.push(ProviderPluginEntry { + id: available.plugin_id, + name: available.name, + version: available.version, + description: available.description, + source: PluginSource { + marketplace: available.marketplace_name, + kind, + }, + installations: Vec::new(), + enabled: Tri::Unknown, + declared: None, + errors: Vec::new(), + actions: Vec::new(), + diagnostics: Vec::new(), + }); + here.push(Vec::new()); + } + for (entry, here) in entries.iter_mut().zip(&here) { + entry.actions = actions(entry.enabled, here, in_context); + } + + let mut marketplace_actions = vec![MarketplaceAction::Add]; + for marketplace in &marketplaces { + marketplace_actions.push(MarketplaceAction::Remove { + marketplace: marketplace.name.clone(), + uninstalls: entries + .iter() + .filter(|entry| { + !entry.installations.is_empty() + && entry.source.marketplace.as_deref() == Some(marketplace.name.as_str()) + }) + .map(|entry| entry.id.clone()) + .collect(), + }); + } + PluginListing { + entries, + marketplaces, + marketplace_actions, + errors: Vec::new(), + } +} + +fn split_id(id: &str) -> (String, Option) { + match id.rsplit_once('@') { + Some((name, marketplace)) if !name.is_empty() => { + (name.to_string(), Some(marketplace.to_string())) + } + _ => (id.to_string(), None), + } +} + +fn source_kind(source: &Value, marketplace: PluginSourceKind) -> PluginSourceKind { + match source { + Value::String(_) => match marketplace { + PluginSourceKind::Official | PluginSourceKind::LocalPath => marketplace, + PluginSourceKind::Unknown => PluginSourceKind::Unknown, + _ => PluginSourceKind::ThirdParty, + }, + Value::Object(object) => match object.get("source").and_then(Value::as_str) { + Some("command") => PluginSourceKind::Command, + Some("github" | "git" | "git-subdir" | "url") => PluginSourceKind::Git, + Some("npm") => PluginSourceKind::Npm, + Some("directory" | "file") => PluginSourceKind::LocalPath, + _ => PluginSourceKind::Unknown, + }, + _ => PluginSourceKind::Unknown, + } +} + +/// Native operations offered for one entry in this context. Enablement is +/// reported only as the effective value, so both directions are offered +/// when it is unknown. +fn actions(enabled: Tri, here: &[PluginScope], in_context: &InContext) -> Vec { + let mut actions = Vec::new(); + for &scope in in_context.scopes() { + if !here.contains(&scope) { + actions.push(PluginAction::Install { scope }); + } + } + for &scope in here { + actions.push(PluginAction::Update { scope }); + if scope != PluginScope::Managed { + actions.push(PluginAction::Uninstall { scope }); + } + } + if !here.is_empty() { + for &scope in in_context.scopes() { + if enabled != Tri::Yes { + actions.push(PluginAction::Enable { scope }); + } + if enabled != Tri::No { + actions.push(PluginAction::Disable { scope }); + } + } + } + actions +} + +/// `plugin details` has no `--json`; its "Component inventory" section lists +/// `Label (count) name, name (note)` rows. +fn apply_details(entry: &mut ProviderPluginEntry, text: &str) { + let mut complete = true; + let mut declared = entry.declared.clone().unwrap_or_default(); + let mut in_inventory = false; + for line in text.lines() { + let trimmed = line.trim(); + if let Some(description) = trimmed.strip_prefix("Description:") { + if entry.description.is_none() { + entry.description = Some(description.trim().to_string()); + } + continue; + } + if trimmed == "Component inventory" { + in_inventory = true; + continue; + } + if !in_inventory { + continue; + } + if trimmed.is_empty() { + break; + } + let field = match trimmed.split(" (").next() { + Some("Skills") => &mut declared.skills, + Some("Agents") => &mut declared.agents, + Some("Hooks") => &mut declared.hooks, + Some("MCP servers") => &mut declared.mcp_servers, + Some("LSP servers") => &mut declared.lsp_servers, + _ => { + complete = false; + continue; + } + }; + match inventory_names(trimmed) { + Some(names) => *field = Some(names), + None => complete = false, + } + } + if !in_inventory { + complete = false; + } + entry.declared = Some(declared); + if !complete { + entry + .diagnostics + .push(("details".into(), text.trim_end().to_string())); + } +} + +fn inventory_names(row: &str) -> Option> { + let (_, rest) = row.split_once(" (")?; + let (count, rest) = rest.split_once(')')?; + let count: usize = count.parse().ok()?; + let names = rest.trim().split(" (").next().unwrap_or_default(); + let names: Vec = names + .split(", ") + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect(); + (names.len() == count).then_some(names) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(name: &str) -> String { + let path = format!( + "{}/tests/fixtures/plugins/claude/{name}", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("{path}: {error}")) + } + + fn output(stdout: &str, success: bool) -> CliOutput { + CliOutput { + success, + status: if success { + "exit status: 0" + } else { + "exit status: 1" + } + .into(), + stdout: stdout.into(), + stderr: String::new(), + } + } + + fn context(cwd: &Path, project: bool) -> PluginContext { + PluginContext { + binary_path: Some(PathBuf::from("/opt/claude/bin/claude")), + launch_env: crate::LaunchEnv { + env: vec![("CLAUDE_CONFIG_DIR".into(), "/tmp/isolated/.claude".into())], + home: Some(PathBuf::from("/tmp/isolated")), + }, + cwd: cwd.to_path_buf(), + project, + } + } + + /// The probe's project was `/tmp/tcode-probe/proj`, reported as its + /// realpath; a context outside it must not act on its installations. + #[test] + fn listing_groups_installations_by_id_and_offers_scopes_of_the_context_only() { + let listed = parse_listed(&fixture("plugin-list-available-after-install.json")).unwrap(); + let marketplaces = parse_marketplaces(&fixture("marketplace-list.json")).unwrap(); + let project = InContext { + project: Some(PathBuf::from("/private/tmp/tcode-probe/proj")), + }; + let inside = listing(listed, marketplaces.clone(), &project); + let ids: Vec<&str> = inside.entries.iter().map(|e| e.id.as_str()).collect(); + assert_eq!( + ids, + ["alpha@tcode-probe", "beta@tcode-probe", "gamma@tcode-probe"] + ); + + let alpha = &inside.entries[0]; + assert_eq!(alpha.name, "alpha"); + assert_eq!(alpha.version.as_deref(), Some("1.0.0")); + assert_eq!(alpha.enabled, Tri::Yes); + assert_eq!( + alpha.source, + PluginSource { + marketplace: Some("tcode-probe".into()), + kind: PluginSourceKind::Unknown, + } + ); + assert_eq!( + alpha.installations, + [ + PluginInstallation { + scope: PluginScope::User, + location: Some(PathBuf::from( + "/tmp/tcode-probe/claude-home/.claude/plugins/cache/tcode-probe/alpha/1.0.0" + )), + version: Some("1.0.0".into()), + scope_enabled: None, + }, + PluginInstallation { + scope: PluginScope::Project, + location: Some(PathBuf::from( + "/tmp/tcode-probe/claude-home/.claude/plugins/cache/tcode-probe/alpha/1.0.0" + )), + version: Some("1.0.0".into()), + scope_enabled: Some(true), + }, + ] + ); + assert_eq!( + alpha.declared.as_ref().unwrap().mcp_servers.as_deref(), + Some(&["alpha-dead".to_string()][..]) + ); + assert_eq!(alpha.declared.as_ref().unwrap().skills, None); + use PluginScope::*; + assert_eq!( + alpha.actions, + [ + PluginAction::Install { scope: Local }, + PluginAction::Update { scope: User }, + PluginAction::Uninstall { scope: User }, + PluginAction::Update { scope: Project }, + PluginAction::Uninstall { scope: Project }, + PluginAction::Disable { scope: User }, + PluginAction::Disable { scope: Project }, + PluginAction::Disable { scope: Local }, + ] + ); + assert_eq!( + inside.entries[1].declared.as_ref().unwrap().mcp_servers, + Some(Vec::new()) + ); + + let gamma = &inside.entries[2]; + assert!(gamma.installations.is_empty()); + assert_eq!(gamma.version, None); + assert_eq!(gamma.description.as_deref(), Some("Command-source plugin")); + assert_eq!(gamma.source.kind, PluginSourceKind::Command); + assert_eq!(gamma.enabled, Tri::Unknown); + assert_eq!(gamma.declared, None); + assert_eq!( + gamma.actions, + [ + PluginAction::Install { scope: User }, + PluginAction::Install { scope: Project }, + PluginAction::Install { scope: Local }, + ] + ); + assert_eq!( + inside.marketplace_actions, + [ + MarketplaceAction::Add, + MarketplaceAction::Remove { + marketplace: "tcode-probe".into(), + uninstalls: vec!["alpha@tcode-probe".into(), "beta@tcode-probe".into()], + }, + ] + ); + + let listed = parse_listed(&fixture("plugin-list-available-after-install.json")).unwrap(); + let outside = listing(listed, marketplaces, &InContext { project: None }); + let alpha = &outside.entries[0]; + assert_eq!(alpha.installations[1].scope_enabled, None); + assert_eq!( + alpha.actions, + [ + PluginAction::Update { scope: User }, + PluginAction::Uninstall { scope: User }, + PluginAction::Disable { scope: User }, + ] + ); + let beta = &outside.entries[1]; + assert_eq!(beta.actions, [PluginAction::Install { scope: User }]); + } + + #[test] + fn marketplaces_keep_their_native_source_and_location() { + assert_eq!( + parse_marketplaces(&fixture("real-home-marketplace-list.json")).unwrap(), + [ProviderPluginMarketplace { + name: "claude-plugins-official".into(), + source: "anthropics/claude-plugins-official".into(), + kind: PluginSourceKind::Official, + location: Some(PathBuf::from( + "/Users/tryanks/.claude/plugins/marketplaces/claude-plugins-official" + )), + }] + ); + } + + #[test] + fn details_inventory_becomes_declared_components() { + let listed = parse_listed(&fixture("plugin-list-available-after-install.json")).unwrap(); + let mut alpha = listing(listed, Vec::new(), &InContext { project: None }) + .entries + .remove(0); + alpha.description = None; + apply_details(&mut alpha, &fixture("details.txt")); + assert_eq!( + alpha.description.as_deref(), + Some("Skill, command, SessionStart hook and an unreachable HTTP MCP server") + ); + let names = |names: &[&str]| Some(names.iter().map(|n| n.to_string()).collect()); + assert_eq!( + alpha.declared, + Some(DeclaredComponents { + skills: names(&["greet", "hello"]), + agents: names(&[]), + hooks: names(&["SessionStart"]), + mcp_servers: names(&["alpha-dead"]), + untrusted_hooks: None, + lsp_servers: names(&[]), + apps: None, + }) + ); + assert!(alpha.diagnostics.is_empty()); + + apply_details(&mut alpha, "alpha 1.0.0\n Source: alpha@tcode-probe\n"); + assert_eq!( + alpha.diagnostics, + [( + "details".to_string(), + "alpha 1.0.0\n Source: alpha@tcode-probe".to_string() + )] + ); + } + + #[test] + fn command_source_refusal_becomes_a_challenge_from_the_last_json_line() { + let challenge = json_outcome(&output( + &fixture("install-command-source-challenge.txt"), + false, + )) + .unwrap(); + let PluginOpOutcome::AcceptCommand(acceptance) = challenge else { + panic!("expected a challenge, got {challenge:?}"); + }; + assert_eq!(acceptance.command, "echo /tmp/tcode-probe/gamma-src"); + assert_eq!( + acceptance.sha256, + "b9c02c85b17261fa1fce010664bb16cbe866f14c7deeaf6cb6c37c2736bbe269" + ); + assert_eq!(acceptance.mode.as_deref(), Some("copy")); + assert!( + acceptance + .native_text + .starts_with("\"gamma\" is installed by running a command"), + "{}", + acceptance.native_text + ); + + // The preamble is printed on success too; the result is still the last line. + assert_eq!( + json_outcome(&output( + &fixture("install-command-source-accepted.json"), + true + )) + .unwrap(), + PluginOpOutcome::Done { + diagnostics: Vec::new() + } + ); + // A hash that no longer names the shown command is shown again. + let PluginOpOutcome::AcceptCommand(again) = json_outcome(&output( + &fixture("install-command-source-wrong-hash.json"), + false, + )) + .unwrap() else { + panic!("a mismatched hash must surface the command again"); + }; + assert!( + again + .native_text + .contains("does not name the command shown above") + ); + + // Only the last line counts: a JSON line followed by text is not a result. + let trailing = format!("{}\nNot JSON\n", fixture("install-user.json").trim()); + assert!(matches!( + json_outcome(&output(&trailing, true)), + Err(AgentError::Protocol(_)) + )); + } + + #[test] + fn mutation_results_map_native_outcomes() { + for name in [ + "install-user.json", + "enable.json", + "disable.json", + "uninstall.json", + ] { + assert_eq!( + json_outcome(&output(&fixture(name), true)).unwrap(), + PluginOpOutcome::Done { + diagnostics: Vec::new() + }, + "{name}" + ); + } + let PluginOpOutcome::Done { diagnostics } = + json_outcome(&output(&fixture("update.json"), true)).unwrap() + else { + panic!("update must succeed"); + }; + assert_eq!( + diagnostics, + [ + ("update_outcome".to_string(), "updated".to_string()), + ("old_version".to_string(), "1.0.0".to_string()), + ("new_version".to_string(), "1.0.1".to_string()), + ( + "message".to_string(), + "Plugin \"alpha\" updated from 1.0.0 to 1.0.1 for scope project (/private/tmp/tcode-probe/proj). Restart to apply changes.".to_string() + ), + ] + ); + let failed = r#"{"command":"uninstall","outcome":"failed","plugin":"beta@tcode-probe","scope":"user","message":"Plugin \"beta@tcode-probe\" not found in installed plugins","failureCode":"not_installed"}"#; + assert_eq!( + native_message(&json_outcome(&output(failed, false)).unwrap_err()), + "Plugin \"beta@tcode-probe\" not found in installed plugins" + ); + } + + #[test] + fn management_argv_names_its_scope_never_auto_accepts_and_leaves_the_nested_session() { + let sha = "b9c02c85b17261fa1fce010664bb16cbe866f14c7deeaf6cb6c37c2736bbe269"; + let id = "gamma@tcode-probe".to_string(); + let scope = PluginScope::Project; + let ops = [ + PluginOp::Install { + id: id.clone(), + scope, + accept_command: None, + }, + PluginOp::Install { + id: id.clone(), + scope, + accept_command: Some(sha.into()), + }, + PluginOp::Update { + id: id.clone(), + scope, + accept_command: None, + }, + PluginOp::Update { + id: id.clone(), + scope, + accept_command: Some(sha.into()), + }, + PluginOp::Uninstall { + id: id.clone(), + scope, + }, + PluginOp::SetEnabled { + id: id.clone(), + scope, + enabled: true, + }, + PluginOp::SetEnabled { + id: id.clone(), + scope, + enabled: false, + }, + PluginOp::AddMarketplace { + source: "/tmp/tcode-probe/mkt".into(), + }, + PluginOp::RemoveMarketplace { + name: "tcode-probe".into(), + }, + ]; + let cwd = std::env::temp_dir(); + for op in &ops { + let argv = op_args(op).unwrap(); + assert!( + !argv.iter().any(|arg| arg == "-y" || arg == "--yes"), + "{argv:?}" + ); + let accepted = match op { + PluginOp::Install { accept_command, .. } + | PluginOp::Update { accept_command, .. } => accept_command.as_deref(), + _ => None, + }; + let accept_at = argv.iter().position(|arg| arg == "--accept-command"); + assert_eq!( + accept_at.map(|index| argv[index + 1].as_str()), + accepted, + "{argv:?}" + ); + if !matches!(op, PluginOp::RemoveMarketplace { .. }) { + let scope_at = argv.iter().position(|arg| arg == "--scope").unwrap(); + assert!(["user", "project"].contains(&argv[scope_at + 1].as_str())); + } + + let command = management_command(&context(&cwd, true), &argv).unwrap(); + assert_eq!(command.get_current_dir(), Some(cwd.as_path())); + let envs: Vec<_> = command.get_envs().collect(); + for var in NESTED_SESSION_VARS { + assert!( + envs.contains(&(var.as_ref(), None)), + "{var} must be removed" + ); + } + assert!(envs.contains(&("HOME".as_ref(), Some("/tmp/isolated".as_ref())))); + } + assert!( + op_args(&PluginOp::AddMarketplace { + source: "--help".into() + }) + .is_err() + ); + } +} diff --git a/crates/agent/src/codex.rs b/crates/agent/src/codex.rs index 33f5bb314..6b6afff76 100644 --- a/crates/agent/src/codex.rs +++ b/crates/agent/src/codex.rs @@ -24,6 +24,7 @@ use crate::{ }; mod developer_instructions; +pub(crate) mod plugins; use developer_instructions::{DEFAULT_MODE_INSTRUCTIONS, PLAN_MODE_INSTRUCTIONS}; /// Fallback model slug for `collaborationMode.settings.model` when the session diff --git a/crates/agent/src/codex/plugins.rs b/crates/agent/src/codex/plugins.rs new file mode 100644 index 000000000..a98462e8d --- /dev/null +++ b/crates/agent/src/codex/plugins.rs @@ -0,0 +1,1140 @@ +//! Codex's native plugin management, driven through one temporary +//! `codex app-server` per listing or mutation. +//! +//! Every discovery request names the context directory in `cwds`: repo +//! marketplaces and the plugins installed from them are found only through +//! `cwds`, and `spawn_server` gives the server no working directory of its own. + +use std::io::BufWriter; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin}; +use std::time::Duration; + +use serde::Deserialize; +use serde_json::{Value, json}; +use smol::channel::Receiver; + +use super::{ + describe_rpc_error, enrich_startup_error, initialize, send_json, settle_child_exit, + spawn_server, stop_child, +}; +use crate::process::{ChildOutput, StderrTail}; +use crate::{ + AgentError, DeclaredComponents, MarketplaceAction, PluginAction, PluginContext, + PluginInstallation, PluginListing, PluginOp, PluginOpOutcome, PluginScope, PluginSource, + PluginSourceKind, ProviderPluginEntry, ProviderPluginMarketplace, Tri, native_message, +}; + +/// Reading a remote plugin takes the server about two seconds. +const LIST_TIMEOUT: Duration = Duration::from_secs(120); +/// Installs and marketplace adds may clone repositories. +const MUTATION_TIMEOUT: Duration = Duration::from_secs(300); +/// `plugin/read` requests answered concurrently by one server. +const DETAILS_CONCURRENCY: usize = 4; +/// Marketplaces Codex itself ships or syncs from OpenAI. +const OFFICIAL_MARKETPLACES: [&str; 5] = [ + "openai-curated", + "openai-curated-remote", + "openai-api-curated", + "openai-bundled", + "openai-primary-runtime", +]; +/// An upstream race in Codex 0.159.3: servers started at the same moment on +/// a `CODEX_HOME` that was never initialized can exit with this error, as when +/// a session and a plugin listing start together on a new shadow home. It did +/// not recur on an initialized home, so one later start succeeds. +const SQLITE_RACE: &str = "failed to initialize sqlite state runtime"; +const SQLITE_RACE_RETRY_DELAY: Duration = Duration::from_millis(500); + +pub(crate) async fn list(context: &PluginContext) -> Result { + with_server(context, LIST_TIMEOUT, async |server| { + read_listing(server, &context.cwd).await + }) + .await +} + +pub(crate) async fn run( + context: &PluginContext, + op: &PluginOp, +) -> Result { + with_server(context, MUTATION_TIMEOUT, async |server| { + let offered = if matches!(op, PluginOp::Install { .. }) { + let listed = server + .request("plugin/list", json!({ "cwds": [context.cwd] })) + .await?; + parse::("plugin/list", listed)?.marketplaces + } else { + Vec::new() + }; + let (method, params) = op_request(op, &offered)?; + let result = server.request(method, params).await?; + Ok(outcome(op, &result)) + }) + .await +} + +async fn with_server( + context: &PluginContext, + timeout: Duration, + work: impl AsyncFnOnce(&mut Server) -> Result, +) -> Result { + let session = async { + let mut server = Server::start(context).await?; + let result = work(&mut server).await; + server.close(result) + }; + // Dropping the session on timeout drops the server, which kills it. + smol::future::or(async { Some(session.await) }, async { + smol::Timer::after(timeout).await; + None + }) + .await + .unwrap_or_else(|| Err(AgentError::Provider("`codex app-server` timed out".into()))) +} + +struct Server { + child: Child, + stdin: Option>, + lines: Receiver, + stderr_tail: StderrTail, + next_id: i64, +} + +impl Server { + async fn start(context: &PluginContext) -> Result { + match Self::start_once(context).await { + Err(AgentError::Protocol(message)) if message.contains(SQLITE_RACE) => { + log::warn!("codex app-server lost the first-start sqlite race; retrying once"); + smol::Timer::after(SQLITE_RACE_RETRY_DELAY).await; + Self::start_once(context).await + } + started => started, + } + } + + async fn start_once(context: &PluginContext) -> Result { + let (child, stdin, lines, stderr_tail) = + spawn_server(context.binary_path.as_deref(), &[], &context.launch_env)?; + let mut server = Self { + child, + stdin: Some(stdin), + lines, + stderr_tail, + next_id: 2, + }; + let stdin = server.stdin.as_mut().expect("the server has not stopped"); + match initialize(stdin, &server.lines).await { + Ok(()) => Ok(server), + Err(error) => server.close(Err(error)), + } + } + + async fn request(&mut self, method: &str, params: Value) -> Result { + self.requests(vec![(method, params)]) + .await? + .pop() + .expect("one reply per request") + } + + /// Sends the whole batch before reading; the server answers concurrently + /// and in any order. The outer error is the connection's, the inner ones + /// are each request's native error. + async fn requests( + &mut self, + batch: Vec<(&str, Value)>, + ) -> Result>, AgentError> { + let first = self.next_id; + let stdin = self.stdin.as_mut().expect("the server has not stopped"); + for (method, params) in &batch { + send_json( + stdin, + &json!({ "id": self.next_id, "method": method, "params": params }), + )?; + self.next_id += 1; + } + let mut replies: Vec>> = + batch.iter().map(|_| None).collect(); + let mut pending = replies.len(); + while pending > 0 { + let line = match self.lines.recv().await { + Ok(ChildOutput::Line(line)) => line, + Ok(ChildOutput::Eof) | Err(_) => { + return Err(AgentError::Protocol("codex exited before answering".into())); + } + Ok(ChildOutput::Error(error)) => return Err(AgentError::Protocol(error)), + }; + let value: Value = serde_json::from_str(&line).map_err(|error| { + AgentError::Protocol(format!("invalid JSON from codex: {error}: {line}")) + })?; + if value.get("method").is_some() { + continue; + } + let Some(slot) = value + .get("id") + .and_then(Value::as_i64) + .and_then(|id| usize::try_from(id - first).ok()) + .and_then(|index| replies.get_mut(index)) + .filter(|slot| slot.is_none()) + else { + continue; + }; + *slot = Some(match value.get("error") { + Some(error) => Err(AgentError::Provider(describe_rpc_error(error))), + None => value.get("result").cloned().ok_or_else(|| { + AgentError::Protocol(format!("response omitted result: {line}")) + }), + }); + pending -= 1; + } + Ok(replies.into_iter().flatten().collect()) + } + + /// A broken connection is explained by the process: its exit status and + /// stderr. Native errors pass through. + fn close(mut self, result: Result) -> Result { + match result { + Err(error @ (AgentError::Protocol(_) | AgentError::Io(_))) => { + let status = settle_child_exit(&mut self.child); + // The stderr tail joins its reader, so the child must be gone. + self.stop(); + Err(enrich_startup_error(error, status, &mut self.stderr_tail)) + } + other => other, + } + } + + fn stop(&mut self) { + if let Some(stdin) = self.stdin.take() { + stop_child(&mut self.child, stdin); + } + } +} + +impl Drop for Server { + fn drop(&mut self) { + self.stop(); + } +} + +fn parse(method: &str, value: Value) -> Result { + serde_json::from_value(value) + .map_err(|error| AgentError::Protocol(format!("unexpected `{method}` result: {error}"))) +} + +fn discovery_requests(cwd: &Path) -> Vec<(&'static str, Value)> { + let cwds = json!({ "cwds": [cwd] }); + vec![ + ("plugin/list", cwds.clone()), + ("plugin/installed", cwds.clone()), + // Marketplace sources live in the user layer `marketplace/add` writes. + ("config/read", json!({})), + ("skills/list", cwds.clone()), + ("hooks/list", cwds), + ] +} + +async fn read_listing(server: &mut Server, cwd: &Path) -> Result { + let mut replies = server.requests(discovery_requests(cwd)).await?.into_iter(); + let mut next = || replies.next().expect("one reply per request"); + let listed: Catalog = parse("plugin/list", next()?)?; + let installed: Catalog = parse("plugin/installed", next()?)?; + let config = next()?; + let skills = next()?; + let hooks = next()?; + let catalog = merge(listed, installed); + let mut listing = listing(&catalog, &config, &skills, &hooks); + + let reads = detail_requests(&catalog); + for chunk in reads.chunks(DETAILS_CONCURRENCY) { + let batch = chunk + .iter() + .map(|(_, params)| ("plugin/read", params.clone())) + .collect(); + let replies = server.requests(batch).await?; + for ((id, _), reply) in chunk.iter().zip(replies) { + let Some(entry) = listing.entries.iter_mut().find(|entry| entry.id == *id) else { + continue; + }; + match reply.and_then(|result| parse::("plugin/read", result)) { + Ok(read) => apply_detail(entry, read.plugin), + Err(error) => entry + .diagnostics + .push(("details_error".into(), native_message(&error))), + } + } + } + Ok(listing) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Catalog { + marketplaces: Vec, + #[serde(default)] + marketplace_load_errors: Vec, +} + +#[derive(Deserialize)] +struct Marketplace { + name: String, + /// The marketplace manifest; remote catalogs have none. + #[serde(default)] + path: Option, + #[serde(default)] + plugins: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LoadError { + marketplace_path: String, + message: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Summary { + id: String, + name: String, + #[serde(default)] + version: Option, + /// The manifest version of a local plugin, whose `version` is null. + #[serde(default)] + local_version: Option, + #[serde(default)] + source: Value, + installed: bool, + enabled: bool, + #[serde(default)] + install_policy: Option, + #[serde(default)] + auth_policy: Option, + #[serde(default)] + availability: Option, + #[serde(default)] + disabled_reason: Option, + #[serde(default)] + interface: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Interface { + #[serde(default)] + short_description: Option, +} + +#[derive(Deserialize)] +struct Read { + plugin: Detail, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Detail { + #[serde(default)] + description: Option, + #[serde(default)] + skills: Vec, + #[serde(default)] + hooks: Vec, + #[serde(default)] + mcp_servers: Vec, + #[serde(default)] + apps: Vec, +} + +#[derive(Deserialize)] +struct Named { + name: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeclaredHook { + event_name: String, +} + +/// `plugin/installed` can report plugins `plugin/list` omits (an installed +/// plugin of a local catalog the remote catalog replaces in the listing), so +/// the catalog is their union; the installed summary wins. +fn merge(listed: Catalog, installed: Catalog) -> Catalog { + let mut merged = listed; + for marketplace in installed.marketplaces { + let Some(known) = merged + .marketplaces + .iter_mut() + .find(|known| known.name == marketplace.name) + else { + merged.marketplaces.push(marketplace); + continue; + }; + if known.path.is_none() { + known.path = marketplace.path; + } + for plugin in marketplace.plugins { + match known.plugins.iter_mut().find(|known| known.id == plugin.id) { + Some(known) => *known = plugin, + None => known.plugins.push(plugin), + } + } + } + for error in installed.marketplace_load_errors { + if !merged.marketplace_load_errors.iter().any(|known| { + known.marketplace_path == error.marketplace_path && known.message == error.message + }) { + merged.marketplace_load_errors.push(error); + } + } + merged +} + +fn listing(catalog: &Catalog, config: &Value, skills: &Value, hooks: &Value) -> PluginListing { + let configured = config + .pointer("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/config/marketplaces") + .and_then(Value::as_object); + let configured_field = |name: &str, field: &str| { + configured + .and_then(|marketplaces| marketplaces.get(name)) + .and_then(|marketplace| marketplace.get(field)) + .and_then(Value::as_str) + .map(str::to_string) + }; + let mut marketplaces = Vec::new(); + let mut entries = Vec::new(); + for marketplace in &catalog.marketplaces { + let kind = marketplace_kind( + &marketplace.name, + marketplace.path.is_some(), + configured_field(&marketplace.name, "source_type").as_deref(), + ); + let location = marketplace.path.as_deref().and_then(marketplace_root); + marketplaces.push(ProviderPluginMarketplace { + name: marketplace.name.clone(), + source: configured_field(&marketplace.name, "source") + .or_else(|| location.as_ref().map(|root| root.display().to_string())) + .or_else(|| { + marketplace + .path + .as_ref() + .map(|path| path.display().to_string()) + }) + .unwrap_or_else(|| marketplace.name.clone()), + kind, + location, + }); + for plugin in &marketplace.plugins { + entries.push(entry(plugin, &marketplace.name, kind, skills, hooks)); + } + } + // A configured marketplace that failed to load is still removable. + for name in configured + .into_iter() + .flat_map(|marketplaces| marketplaces.keys()) + { + if !marketplaces + .iter() + .any(|marketplace| marketplace.name == *name) + { + marketplaces.push(ProviderPluginMarketplace { + name: name.clone(), + source: configured_field(name, "source").unwrap_or_default(), + kind: marketplace_kind( + name, + true, + configured_field(name, "source_type").as_deref(), + ), + location: None, + }); + } + } + + // `marketplace/remove` leaves the marketplace's installed plugins + // installed and active but no longer listed, so it is offered only once + // none are installed. + let mut marketplace_actions = vec![MarketplaceAction::Add]; + for marketplace in &marketplaces { + let removable = configured + .is_some_and(|configured| configured.contains_key(&marketplace.name)) + && marketplace.kind != PluginSourceKind::Official + && !entries.iter().any(|entry| { + !entry.installations.is_empty() + && entry.source.marketplace.as_deref() == Some(marketplace.name.as_str()) + }); + if removable { + marketplace_actions.push(MarketplaceAction::Remove { + marketplace: marketplace.name.clone(), + uninstalls: Vec::new(), + }); + } + } + PluginListing { + entries, + marketplaces, + marketplace_actions, + errors: catalog + .marketplace_load_errors + .iter() + .map(|error| format!("{}: {}", error.marketplace_path, error.message)) + .collect(), + } +} + +fn marketplace_kind(name: &str, local: bool, source_type: Option<&str>) -> PluginSourceKind { + if OFFICIAL_MARKETPLACES.contains(&name) { + return PluginSourceKind::Official; + } + match source_type { + Some("git") => PluginSourceKind::Git, + Some("local") => PluginSourceKind::LocalPath, + Some(_) => PluginSourceKind::Unknown, + // Unconfigured marketplaces are files found through `cwds` or HOME. + None if local => PluginSourceKind::LocalPath, + None => PluginSourceKind::Unknown, + } +} + +/// The directory holding `.agents/plugins/.json`. +fn marketplace_root(manifest: &Path) -> Option { + let plugins = manifest.parent()?; + let agents = plugins.parent()?; + (plugins.file_name()? == "plugins" && agents.file_name()? == ".agents") + .then(|| agents.parent().map(Path::to_path_buf)) + .flatten() +} + +fn entry( + plugin: &Summary, + marketplace: &str, + marketplace_kind: PluginSourceKind, + skills: &Value, + hooks: &Value, +) -> ProviderPluginEntry { + let kind = match plugin.source.get("type").and_then(Value::as_str) { + Some("git") => PluginSourceKind::Git, + Some("npm") => PluginSourceKind::Npm, + _ => marketplace_kind, + }; + let installations = if plugin.installed { + vec![PluginInstallation { + scope: PluginScope::User, + location: cache_root(plugin, marketplace, skills, hooks), + version: plugin.local_version.clone().or(plugin.version.clone()), + scope_enabled: Some(plugin.enabled), + }] + } else { + Vec::new() + }; + let mut diagnostics = Vec::new(); + for (key, value) in [ + ("install_policy", &plugin.install_policy), + ("auth_policy", &plugin.auth_policy), + ("availability", &plugin.availability), + ("disabled_reason", &plugin.disabled_reason), + ] { + if let Some(value) = value { + diagnostics.push((key.to_string(), value.clone())); + } + } + ProviderPluginEntry { + id: plugin.id.clone(), + name: plugin.name.clone(), + version: plugin.version.clone().or(plugin.local_version.clone()), + description: plugin + .interface + .as_ref() + .and_then(|interface| interface.short_description.clone()), + source: PluginSource { + marketplace: Some(marketplace.to_string()), + kind, + }, + installations, + enabled: if plugin.installed { + Tri::from(plugin.enabled) + } else { + Tri::Unknown + }, + declared: plugin.installed.then(|| DeclaredComponents { + // A disabled plugin's hooks are not listed at all. + untrusted_hooks: plugin.enabled.then(|| untrusted_hooks(&plugin.id, hooks)), + ..DeclaredComponents::default() + }), + errors: Vec::new(), + actions: actions(plugin), + diagnostics, + } +} + +fn listed<'a>(reply: &'a Value, field: &'a str) -> impl Iterator { + reply + .get("data") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(move |cwd| cwd.get(field).and_then(Value::as_array)) + .flatten() +} + +fn of_plugin<'a>(item: &'a Value, id: &str) -> Option<&'a Value> { + (item.get("pluginId").and_then(Value::as_str) == Some(id)).then_some(item) +} + +/// Codex runs a plugin hook only once it is reviewed natively; `untrusted` +/// and `modified` hooks are skipped until then. +fn untrusted_hooks(id: &str, hooks: &Value) -> Vec { + let mut names: Vec = Vec::new(); + for hook in listed(hooks, "hooks").filter_map(|hook| of_plugin(hook, id)) { + let untrusted = matches!( + hook.get("trustStatus").and_then(Value::as_str), + Some("untrusted" | "modified") + ); + if let Some(event) = hook.get("eventName").and_then(Value::as_str) + && untrusted + && !names.iter().any(|name| name == event) + { + names.push(event.to_string()); + } + } + names +} + +/// The installed copy, `…/plugins/cache///`, +/// as the plugin's listed skills and hooks locate it. +fn cache_root( + plugin: &Summary, + marketplace: &str, + skills: &Value, + hooks: &Value, +) -> Option { + let paths = listed(skills, "skills") + .filter_map(|skill| of_plugin(skill, &plugin.id)?.get("path")) + .chain( + listed(hooks, "hooks") + .filter_map(|hook| of_plugin(hook, &plugin.id)?.get("sourcePath")), + ) + .filter_map(Value::as_str); + for path in paths { + let components: Vec<_> = Path::new(path).components().collect(); + let Some(at) = components.windows(4).position(|window| { + window[0].as_os_str() == "plugins" + && window[1].as_os_str() == "cache" + && window[2].as_os_str() == marketplace + && window[3].as_os_str() == plugin.name.as_str() + }) else { + continue; + }; + if components.len() > at + 4 { + return Some(components[..at + 5].iter().collect()); + } + } + None +} + +fn actions(plugin: &Summary) -> Vec { + let scope = PluginScope::User; + if !plugin.installed { + let installable = plugin.install_policy.as_deref() != Some("NOT_AVAILABLE") + && plugin.availability.as_deref().unwrap_or("AVAILABLE") == "AVAILABLE"; + return if installable { + vec![PluginAction::Install { scope }] + } else { + Vec::new() + }; + } + vec![ + PluginAction::Uninstall { scope }, + if plugin.enabled { + PluginAction::Disable { scope } + } else { + PluginAction::Enable { scope } + }, + ] +} + +/// Local catalogs are read in full so components are disclosed before an +/// install; a remote catalog only for its installed plugins. +fn detail_requests(catalog: &Catalog) -> Vec<(String, Value)> { + let mut requests = Vec::new(); + for marketplace in &catalog.marketplaces { + for plugin in &marketplace.plugins { + let params = match &marketplace.path { + Some(path) => json!({ "marketplacePath": path, "pluginName": plugin.name }), + None if plugin.installed => { + json!({ "remoteMarketplaceName": marketplace.name, "pluginName": plugin.name }) + } + None => continue, + }; + requests.push((plugin.id.clone(), params)); + } + } + requests +} + +fn apply_detail(entry: &mut ProviderPluginEntry, detail: Detail) { + if let Some(description) = detail.description.filter(|text| !text.trim().is_empty()) { + entry.description = Some(description); + } + let mut hooks: Vec = Vec::new(); + for hook in detail.hooks { + if !hooks.contains(&hook.event_name) { + hooks.push(hook.event_name); + } + } + let declared = entry + .declared + .get_or_insert_with(DeclaredComponents::default); + declared.skills = Some(detail.skills.into_iter().map(|skill| skill.name).collect()); + declared.hooks = Some(hooks); + declared.mcp_servers = Some(detail.mcp_servers); + declared.apps = Some(detail.apps.into_iter().map(|app| app.name).collect()); +} + +/// The request for one mutation. `offered` is the current `plugin/list`, +/// which names the marketplace an install reads from. Hook trust is never +/// bypassed: it stays a native review. +fn op_request(op: &PluginOp, offered: &[Marketplace]) -> Result<(&'static str, Value), AgentError> { + let request = match op { + PluginOp::Install { id, scope, .. } => { + user_scope(*scope)?; + let (marketplace, plugin) = offered + .iter() + .find_map(|marketplace| { + let plugin = marketplace.plugins.iter().find(|plugin| plugin.id == *id)?; + Some((marketplace, plugin)) + }) + .ok_or_else(|| AgentError::Provider(format!("no marketplace offers {id}")))?; + let params = match &marketplace.path { + Some(path) => json!({ "marketplacePath": path, "pluginName": plugin.name }), + None => { + json!({ "remoteMarketplaceName": marketplace.name, "pluginName": plugin.name }) + } + }; + ("plugin/install", params) + } + PluginOp::Uninstall { id, scope } => { + user_scope(*scope)?; + ("plugin/uninstall", json!({ "pluginId": id })) + } + PluginOp::SetEnabled { id, scope, enabled } => { + user_scope(*scope)?; + ( + "config/value/write", + json!({ + "keyPath": enabled_key_path(id)?, + "value": enabled, + "mergeStrategy": "upsert", + }), + ) + } + PluginOp::Update { .. } => { + return Err(AgentError::Provider( + "Codex has no per-plugin update".into(), + )); + } + PluginOp::AddMarketplace { source } => ("marketplace/add", json!({ "source": source })), + PluginOp::RemoveMarketplace { name } => { + ("marketplace/remove", json!({ "marketplaceName": name })) + } + }; + Ok(request) +} + +/// Codex installs and enables plugins only in the user's `config.toml`. +fn user_scope(scope: PluginScope) -> Result<(), AgentError> { + match scope { + PluginScope::User => Ok(()), + other => Err(AgentError::Protocol(format!( + "Codex plugins have no {other:?} scope" + ))), + } +} + +/// `config/value/write` splits the key path on dots outside quotes. +fn enabled_key_path(id: &str) -> Result { + if id.contains(['"', '\\']) { + return Err(AgentError::Protocol(format!( + "refusing to write a config key for {id:?}" + ))); + } + Ok(if id.contains('.') { + format!("plugins.\"{id}\".enabled") + } else { + format!("plugins.{id}.enabled") + }) +} + +fn outcome(op: &PluginOp, result: &Value) -> PluginOpOutcome { + let mut diagnostics = Vec::new(); + match op { + PluginOp::Install { .. } => { + let apps: Vec = result + .get("appsNeedingAuth") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|app| { + let name = app.get("name").and_then(Value::as_str)?; + Some(match app.get("installUrl").and_then(Value::as_str) { + Some(url) => format!("{name} ({url})"), + None => name.to_string(), + }) + }) + .collect(); + if !apps.is_empty() { + diagnostics.push(("apps_needing_auth".into(), apps.join(", "))); + } + } + PluginOp::AddMarketplace { .. } + if result.get("alreadyAdded").and_then(Value::as_bool) == Some(true) => + { + diagnostics.push(("already_added".into(), "true".into())); + } + _ => {} + } + PluginOpOutcome::Done { diagnostics } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The frame with JSON-RPC `id` in a recording: the request when + /// `request`, otherwise its response's `result`. + fn frame(name: &str, id: i64, request: bool) -> Value { + let path = format!( + "{}/tests/fixtures/plugins/codex/{name}", + env!("CARGO_MANIFEST_DIR") + ); + let text = std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("{path}: {error}")); + let frame = text + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .find(|frame| frame["id"] == id && frame.get("method").is_some() == request) + .unwrap_or_else(|| panic!("{name} has no frame {id}")); + if request { + frame + } else { + frame["result"].clone() + } + } + + fn result(name: &str, id: i64) -> Value { + frame(name, id, false) + } + + fn recorded(name: &str, id: i64) -> Catalog { + parse("recorded", result(name, id)).unwrap() + } + + const ALPHA: &str = "alpha@tcode-probe-mkt"; + + fn names(names: &[&str]) -> Option> { + Some(names.iter().map(|name| name.to_string()).collect()) + } + + fn policies(auth: &str) -> Vec<(String, String)> { + [ + ("install_policy", "AVAILABLE"), + ("auth_policy", auth), + ("availability", "AVAILABLE"), + ] + .map(|(key, value)| (key.to_string(), value.to_string())) + .to_vec() + } + + /// Recordings: `plugin/list {cwds:[proj]}` before the install (A#4), then + /// `plugin/installed`, `skills/list` and `hooks/list` once alpha was + /// installed and enabled (A#10–12), `plugin/read alpha` (A#5). + #[test] + fn listing_unions_installed_state_with_details_and_hook_trust() { + let catalog = merge( + recorded("plugin-list-with-cwds.jsonl", 4), + recorded("plugin-installed.jsonl", 10), + ); + let config = result("config-read.jsonl", 3); + let mut listing = listing( + &catalog, + &config, + &result("skills-list.jsonl", 11), + &result("hooks-list.jsonl", 12), + ); + let reads = detail_requests(&catalog); + assert_eq!( + reads.iter().map(|(id, _)| id.as_str()).collect::>(), + [ALPHA, "beta@tcode-probe-mkt", "gamma@tcode-probe-repo"] + ); + assert_eq!(reads[0].1, frame("plugin-read.jsonl", 5, true)["params"]); + let read: Read = parse("plugin/read", result("plugin-read.jsonl", 5)).unwrap(); + apply_detail(&mut listing.entries[0], read.plugin); + + let user = PluginScope::User; + assert_eq!( + listing.entries[0], + ProviderPluginEntry { + id: ALPHA.into(), + name: "alpha".into(), + version: Some("0.1.0".into()), + description: Some( + "Tcode probe plugin alpha: one skill, one MCP server, one hook.".into() + ), + source: PluginSource { + marketplace: Some("tcode-probe-mkt".into()), + kind: PluginSourceKind::LocalPath, + }, + installations: vec![PluginInstallation { + scope: user, + location: Some(PathBuf::from( + "/private/tmp/tcode-probe/codex-home/plugins/cache/tcode-probe-mkt/alpha/0.1.0" + )), + version: Some("0.1.0".into()), + scope_enabled: Some(true), + }], + enabled: Tri::Yes, + declared: Some(DeclaredComponents { + skills: names(&["alpha:alpha-hello"]), + agents: None, + hooks: names(&["sessionStart"]), + mcp_servers: names(&["alpha-docs"]), + untrusted_hooks: names(&["sessionStart"]), + lsp_servers: None, + apps: names(&[]), + }), + errors: Vec::new(), + actions: vec![ + PluginAction::Uninstall { scope: user }, + PluginAction::Disable { scope: user }, + ], + diagnostics: policies("ON_INSTALL"), + } + ); + let beta = &listing.entries[1]; + assert!(beta.installations.is_empty()); + assert_eq!(beta.version.as_deref(), Some("0.2.0")); + assert_eq!(beta.description.as_deref(), Some("Skill-only probe plugin")); + assert_eq!(beta.enabled, Tri::Unknown); + assert_eq!(beta.declared, None); + assert_eq!(beta.actions, [PluginAction::Install { scope: user }]); + assert_eq!(beta.diagnostics, policies("ON_USE")); + assert_eq!( + listing.entries[2].source, + PluginSource { + marketplace: Some("tcode-probe-repo".into()), + kind: PluginSourceKind::LocalPath, + } + ); + + assert_eq!( + listing.marketplaces, + [ + ProviderPluginMarketplace { + name: "tcode-probe-mkt".into(), + source: "/private/tmp/tcode-probe/codex-mkt".into(), + kind: PluginSourceKind::LocalPath, + location: Some(PathBuf::from("/private/tmp/tcode-probe/codex-mkt")), + }, + ProviderPluginMarketplace { + name: "tcode-probe-repo".into(), + source: "/tmp/tcode-probe/codex-proj".into(), + kind: PluginSourceKind::LocalPath, + location: Some(PathBuf::from("/tmp/tcode-probe/codex-proj")), + }, + ] + ); + // alpha is installed, and the repo marketplace is not configured. + assert_eq!(listing.marketplace_actions, [MarketplaceAction::Add]); + assert!(listing.errors.is_empty()); + + // After `plugin/uninstall` (A#25) nothing from the marketplace is installed. + let uninstalled = merge( + recorded("plugin-list-with-cwds.jsonl", 4), + recorded("plugin-installed.jsonl", 25), + ); + let after = super::listing( + &uninstalled, + &config, + &result("skills-list.jsonl", 16), + &result("hooks-list.jsonl", 18), + ); + assert_eq!( + after.marketplace_actions, + [ + MarketplaceAction::Add, + MarketplaceAction::Remove { + marketplace: "tcode-probe-mkt".into(), + uninstalls: Vec::new(), + }, + ] + ); + } + + /// Recordings after `config/value/write enabled=false`: `plugin/installed` + /// (A#14), `skills/list` (A#16) and `hooks/list` (A#18), from which the + /// disabled plugin is absent. + #[test] + fn disabled_plugin_offers_enable_and_claims_no_hook_trust() { + let catalog = merge( + recorded("plugin-list-with-cwds.jsonl", 4), + recorded("plugin-installed-after-disable.jsonl", 14), + ); + let listing = listing( + &catalog, + &result("config-read.jsonl", 3), + &result("skills-list.jsonl", 16), + &result("hooks-list.jsonl", 18), + ); + let alpha = &listing.entries[0]; + assert_eq!(alpha.enabled, Tri::No); + assert_eq!( + alpha.installations, + [PluginInstallation { + scope: PluginScope::User, + location: None, + version: Some("0.1.0".into()), + scope_enabled: Some(false), + }] + ); + assert_eq!(alpha.declared.as_ref().unwrap().untrusted_hooks, None); + assert_eq!( + alpha.actions, + [ + PluginAction::Uninstall { + scope: PluginScope::User + }, + PluginAction::Enable { + scope: PluginScope::User + }, + ] + ); + } + + #[test] + fn requests_match_the_recorded_frames_name_the_context_and_never_bypass_trust() { + let cwd = Path::new("/tmp/tcode-probe/codex-proj"); + for (method, params) in discovery_requests(cwd) { + if method != "config/read" { + assert_eq!(params["cwds"], json!([cwd]), "{method}"); + } + } + + let offered = recorded("plugin-list-with-cwds.jsonl", 4).marketplaces; + let user = PluginScope::User; + let cases = [ + ( + PluginOp::Install { + id: ALPHA.into(), + scope: user, + accept_command: Some("not a Codex concept".into()), + }, + "plugin-install.jsonl", + 8, + ), + ( + PluginOp::SetEnabled { + id: ALPHA.into(), + scope: user, + enabled: false, + }, + "config-value-write-enabled.jsonl", + 13, + ), + ( + PluginOp::Uninstall { + id: ALPHA.into(), + scope: user, + }, + "plugin-uninstall.jsonl", + 23, + ), + ( + PluginOp::AddMarketplace { + source: "/tmp/tcode-probe/codex-mkt".into(), + }, + "marketplace-add.jsonl", + 2, + ), + ( + PluginOp::RemoveMarketplace { + name: "tcode-probe-mkt".into(), + }, + "marketplace-remove.jsonl", + 28, + ), + ]; + for (op, name, id) in cases { + let (method, params) = op_request(&op, &offered).unwrap(); + let recorded = frame(name, id, true); + assert_eq!(method, recorded["method"], "{op:?}"); + assert_eq!(params, recorded["params"], "{op:?}"); + let wire = params.to_string(); + assert!( + !wire.contains("dangerously") && !wire.contains("bypass"), + "{wire}" + ); + } + // A dotted id stays one key segment (written live as `[plugins."a.b@m"]`). + assert_eq!( + enabled_key_path("a.b@m").unwrap(), + "plugins.\"a.b@m\".enabled" + ); + } + + /// A `codex` that records each start and dies at startup with `$FAILURE` + /// on stderr, as 0.159.3 does when it loses the first-start sqlite race. + #[cfg(unix)] + #[test] + fn only_the_first_start_sqlite_race_is_retried_once() { + use std::os::unix::fs::PermissionsExt; + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("agent-codex-plugins-{nonce}")); + std::fs::create_dir_all(&dir).unwrap(); + let starts = dir.join("starts"); + let bin = dir.join("codex"); + std::fs::write( + &bin, + format!( + "#!/bin/sh\necho start >> '{}'\necho \"Error: $FAILURE\" >&2\nexit 1\n", + starts.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let attempt = |failure: &str| { + let context = PluginContext { + binary_path: Some(bin.clone()), + launch_env: crate::LaunchEnv { + env: vec![("FAILURE".into(), failure.into())], + home: None, + }, + cwd: dir.clone(), + project: false, + }; + // Concurrently forked test children can hold the fresh script + // open, so exec fails with ETXTBSY until they exit. + for _ in 0..20 { + let _ = std::fs::remove_file(&starts); + let message = smol::block_on(list(&context)).unwrap_err().to_string(); + if !message.contains("Text file busy") { + let count = std::fs::read_to_string(&starts).unwrap().lines().count(); + return (message, count); + } + std::thread::sleep(Duration::from_millis(25)); + } + panic!("the fake codex never started"); + }; + + let (message, starts_made) = + attempt("failed to initialize sqlite state runtime under /tmp/fresh-home"); + assert_eq!(starts_made, 2); + assert!(message.contains(SQLITE_RACE), "{message}"); + assert!(message.contains("exit status: 1"), "{message}"); + + let (message, starts_made) = attempt("Cannot find module './dist/cli.js'"); + assert_eq!(starts_made, 1); + assert!(message.contains("Cannot find module"), "{message}"); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/agent/src/cursor.rs b/crates/agent/src/cursor.rs new file mode 100644 index 000000000..fc5db88f4 --- /dev/null +++ b/crates/agent/src/cursor.rs @@ -0,0 +1,718 @@ +//! Cursor: `cursor-agent [--force] [flags] acp`, an ACP agent with Cursor's +//! `cursor/*` dialect. +//! +//! The protocol machinery is [`crate::acp_session`]; this module is Cursor's +//! dialect. Its `session/update` mapping beyond the standard one lives in +//! [`updates`]. + +mod updates; + +#[cfg(all(test, unix))] +mod tests; + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, MutexGuard}; + +use agent_client_protocol::{UntypedMessage, schema::v1 as acp}; +use serde_json::{Map, Value, json}; + +use crate::acp_session::{ + self, Dialect, Established, Launch, Session, Setup, State, TurnEnd, describe, is_auth_required, + prompt_status, +}; +use crate::{ + AgentError, AgentEvent, ApprovalMode, Attachment, InteractionMode, LaunchEnv, ModelSpec, + PlanStep, PlanStepStatus, ProviderKind, ResumeCursor, SessionHandle, SessionOptions, + TurnStatus, UserInputOption, UserInputQuestion, +}; + +use updates::Updates; + +const ASK_QUESTION: &str = "cursor/ask_question"; +const CREATE_PLAN: &str = "cursor/create_plan"; +const UPDATE_TODOS: &str = "cursor/update_todos"; +const TASK: &str = "cursor/task"; +const GENERATE_IMAGE: &str = "cursor/generate_image"; +const LIST_AVAILABLE_MODELS: &str = "cursor/list_available_models"; + +/// The `configOptions` id of Cursor's model selector. The composer's model +/// picker owns the model ([`SessionOptions::model`]); this option is how the +/// session is put on it. +const MODEL_CONFIG_ID: &str = "model"; + +/// The assistant text Cursor's ACP server sends, as the turn's last output, +/// when its agent run throws; the turn then ends with `end_turn`. +const RUN_ERROR_PREFIX: &str = "\n\nError: "; +/// The same, for a run the backend refused until the account acts. +const RUN_ACTION_REQUIRED: [&str; 4] = [ + "\n\nPlease sign in to continue", + "\n\nUpgrade your plan to continue", + "\n\nAdd a payment method to continue", + "\n\nCheck your settings to continue", +]; + +/// Start (or resume) a Cursor session. +pub async fn start(opts: SessionOptions) -> Result { + let cursor = Cursor::new(&opts); + acp_session::start(ProviderKind::Cursor, cursor, opts).await +} + +/// The models `cursor/list_available_models` offers the signed-in account. +/// Each model's parameters arrive over the wire once a session runs it +/// ([`crate::OptionDescriptors::Wire`]); the catalog marks no default, so a +/// session without a model runs the one Cursor is set to. +pub async fn list_models( + binary_path: Option, + launch_env: LaunchEnv, +) -> Result, AgentError> { + let opts = SessionOptions { + cwd: std::env::temp_dir(), + model: None, + resume: None, + fork: false, + binary_path, + approval_mode: ApprovalMode::Supervised, + option_selections: Vec::new(), + interaction_mode: InteractionMode::default(), + mcp_servers: Vec::new(), + launch_env, + extra_args: Vec::new(), + acp: None, + }; + let cursor = Cursor::new(&opts); + acp_session::query( + ProviderKind::Cursor, + cursor, + opts, + async |connection, _init| { + let listed = connection + .send_request(UntypedMessage { + method: LIST_AVAILABLE_MODELS.into(), + params: json!({}), + }) + .block_task() + .await + .map_err(|err| { + if is_auth_required(&err) { + signed_out() + } else { + AgentError::Protocol(format!( + "Cursor could not list its models: {}", + describe(&err) + )) + } + })?; + Ok(catalog(&listed)) + }, + ) + .await +} + +/// `{models: [{value, name, configOptions}]}`. +fn catalog(listed: &Value) -> Vec { + listed + .get("models") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|model| { + let id = model.get("value")?.as_str()?; + Some(ModelSpec { + id: id.to_string(), + display_name: model + .get("name") + .and_then(Value::as_str) + .unwrap_or(id) + .to_string(), + is_default: false, + options: Vec::new(), + }) + }) + .collect() +} + +fn signed_out() -> AgentError { + AgentError::Provider( + "Cursor is not signed in. Run `cursor-agent login`, or set CURSOR_API_KEY in Settings → Providers → Cursor, then try again." + .into(), + ) +} + +struct Cursor { + approval_mode: ApprovalMode, + updates: Mutex, + /// Cursor's todo list as `cursor/update_todos` last left it. + todos: Mutex>, + /// Whether the running turn's latest output is Cursor's run-failure text. + run_failed: AtomicBool, +} + +impl Cursor { + fn new(opts: &SessionOptions) -> Self { + Self { + approval_mode: opts.approval_mode, + updates: Mutex::new(Updates::new(opts.cwd.clone())), + todos: Mutex::new(Vec::new()), + run_failed: AtomicBool::new(false), + } + } + + fn updates(&self) -> MutexGuard<'_, Updates> { + self.updates + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn track_run_failure(&self, update: &acp::SessionUpdate) { + let failed = match update { + acp::SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content { + acp::ContentBlock::Text(text) => { + text.text.starts_with(RUN_ERROR_PREFIX) + || RUN_ACTION_REQUIRED.contains(&text.text.as_str()) + } + _ => false, + }, + acp::SessionUpdate::AgentThoughtChunk(_) + | acp::SessionUpdate::ToolCall(_) + | acp::SessionUpdate::ToolCallUpdate(_) + | acp::SessionUpdate::Plan(_) => false, + _ => return, + }; + self.run_failed.store(failed, Ordering::Relaxed); + } + + /// `cursor/update_todos`: the list replaces Cursor's todos, or with + /// `merge` updates them by id. + fn update_todos(&self, params: &Value) -> Vec { + let incoming: Vec = params + .get("todos") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Todo::parse) + .collect(); + let merge = params + .get("merge") + .and_then(Value::as_bool) + .unwrap_or(false); + let mut todos = self + .todos + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if merge { + for todo in incoming { + match todos.iter_mut().find(|known| known.id == todo.id) { + Some(known) => *known = todo, + None => todos.push(todo), + } + } + } else { + *todos = incoming; + } + todos.iter().filter_map(Todo::step).collect() + } +} + +impl Dialect for Cursor { + fn name(&self) -> &str { + "Cursor" + } + + fn launch(&self, opts: &SessionOptions) -> Result { + // Not the generic `agent` name Cursor also installs: that name is too + // common to resolve from PATH with confidence. + let program = crate::resolve_binary(opts.binary_path.as_deref(), "cursor-agent")?; + let mut args = Vec::new(); + if opts.approval_mode == ApprovalMode::FullAccess { + args.push("--force".to_string()); + } + args.extend(opts.extra_args.iter().cloned()); + args.push("acp".to_string()); + let client_meta = json!({ "parameterizedModelPicker": true, "subagents": true }); + Ok(Launch { + program, + args, + env: Vec::new(), + client_meta: client_meta.as_object().cloned(), + }) + } + + async fn establish(&self, setup: &Setup<'_>) -> Result { + let (connection, opts) = (setup.connection, setup.opts); + if opts.fork { + return Err(AgentError::Protocol( + "session fork is not supported for this provider".into(), + )); + } + let mcp_servers = setup.mcp_servers().await; + let resumed = opts + .resume + .as_ref() + .and_then(|cursor| cursor.str_field(&["session_id"])) + .map(str::to_string); + // `authenticate` opens a browser when Cursor is signed out, so it is + // never driven from here: a missing login is reported instead. + let (session_id, modes, config_options) = match resumed { + Some(session_id) => { + let loaded = setup + .load( + acp::LoadSessionRequest::new(session_id.clone(), opts.cwd.clone()) + .mcp_servers(mcp_servers), + ) + .await + .map_err(|err| { + start_failure(&err, &format!("could not resume session {session_id}")) + })?; + ( + acp::SessionId::new(session_id), + loaded.modes, + loaded.config_options, + ) + } + None => { + let created = connection + .send_request( + acp::NewSessionRequest::new(opts.cwd.clone()).mcp_servers(mcp_servers), + ) + .block_task() + .await + .map_err(|err| start_failure(&err, "could not start a session"))?; + (created.session_id, created.modes, created.config_options) + } + }; + setup.adopt(modes.as_ref(), config_options.as_deref()); + let mut config_options = config_options.unwrap_or_default(); + if let Some(model) = &opts.model + && let Some(selected) = select_model(setup, &session_id, model, &config_options).await? + { + config_options = selected; + } + restore_parameters(setup, &session_id, &config_options).await; + if opts.interaction_mode == InteractionMode::Plan || setup.in_plan_mode() { + setup + .apply_interaction_mode(&session_id, opts.interaction_mode) + .await; + } + Ok(Established { + resume: ResumeCursor(json!({ "session_id": session_id.0.to_string() })), + session_id, + }) + } + + fn session_update( + &self, + state: &mut State, + notification: acp::SessionNotification, + ) -> Vec { + // `session/load` replays the whole conversation before it answers; + // tcode's own log is the conversation of record. + if state.loading() { + return Vec::new(); + } + let mut updates = self.updates(); + if !updates.is_subagent(¬ification.session_id) { + self.track_run_failure(¬ification.update); + } + updates.session_update(state, notification) + } + + fn turn_end( + &self, + _state: &mut State, + result: Result, + ) -> TurnEnd { + let (mut status, message) = prompt_status(&result); + // The failure text stays in the transcript as Cursor wrote it. + if self.run_failed.swap(false, Ordering::Relaxed) && status == TurnStatus::Completed { + status = TurnStatus::Failed; + } + // Cursor reports no usage over ACP. + TurnEnd { + status, + message, + usage: None, + } + } + + async fn steer( + &self, + _session: &Session, + _request_id: String, + _text: String, + _attachments: Vec, + ) { + } + + async fn set_approval_mode(&self, _session: &Session, _mode: ApprovalMode) { + // Applied at launch when the runtime restarts the session for the next + // turn. + } + + fn handles_request(&self, method: &str) -> bool { + matches!( + method, + ASK_QUESTION | CREATE_PLAN | UPDATE_TODOS | TASK | GENERATE_IMAGE + ) + } + + async fn request( + &self, + session: Session, + method: String, + params: Value, + ) -> Result { + // None of these is sent while `session/load` replays, but a replay + // must never put a question to the user. + let replaying = session.with_state(|state| state.loading()); + match method.as_str() { + ASK_QUESTION => { + let questions = Questions::parse(¶ms)?; + if replaying { + return Ok(json!({ "outcome": { "outcome": "cancelled" } })); + } + let answers = session.ask_user(questions.canonical()).await; + let (reply, unsent) = questions.reply(answers); + if !unsent.is_empty() { + session + .emit(AgentEvent::Warning { + message: format!( + "Cursor takes only the offered answers; the typed answer to {} was not sent.", + unsent.join(", ") + ), + }) + .await; + } + Ok(reply) + } + CREATE_PLAN => { + // Cursor's own CLI takes a created plan without asking: it + // saves the plan, and the user builds it in a later turn. An + // `accepted` outcome without a `planUri` has Cursor save it + // the same way; the user's decision is the proposed plan's. + let markdown = params.get("plan").and_then(Value::as_str).unwrap_or(""); + if !replaying && !markdown.trim().is_empty() { + let item_id = params + .get("toolCallId") + .and_then(Value::as_str) + .unwrap_or(CREATE_PLAN); + session + .emit(AgentEvent::ProposedPlan { + item_id: item_id.to_string(), + markdown: markdown.to_string(), + }) + .await; + } + Ok(json!({ "outcome": { "outcome": "accepted" } })) + } + UPDATE_TODOS => { + if !replaying { + let steps = self.update_todos(¶ms); + let turn_id = session.with_state(|state| state.turn().map(str::to_owned)); + session + .emit(AgentEvent::PlanUpdated { + turn_id, + explanation: None, + steps, + }) + .await; + } + Ok(json!({})) + } + // Sent once the task or image tool has finished; Cursor ignores + // the reply, and the tool's own updates carry its result. + _ => Ok(json!({})), + } + } + + fn notification(&self, state: &mut State, method: &str, params: Value) -> Vec { + if method != "session/update" { + log::trace!("cursor: {method} {params}"); + return Vec::new(); + } + if state.loading() { + return Vec::new(); + } + self.updates().subagent_update(¶ms) + } + + /// Cursor's file and shell tools are its own: it calls none of these + /// services, and not offering them keeps it that way. + fn client_services(&self) -> bool { + false + } + + fn owned_config_options(&self) -> &'static [&'static str] { + &[MODEL_CONFIG_ID] + } + + fn auto_approves(&self, tool_call: &acp::ToolCallUpdate) -> bool { + let Some(kind) = tool_call.fields.kind else { + return false; + }; + match self.approval_mode { + // A web fetch changes nothing locally but can carry data out, so + // it is asked about like any other action. + ApprovalMode::ReadOnly => matches!(kind, acp::ToolKind::Read | acp::ToolKind::Search), + ApprovalMode::AutoAcceptEdits => matches!( + kind, + acp::ToolKind::Edit | acp::ToolKind::Delete | acp::ToolKind::Move + ), + ApprovalMode::Supervised | ApprovalMode::FullAccess => false, + } + } +} + +fn start_failure(err: &acp::Error, context: &str) -> AgentError { + if is_auth_required(err) { + signed_out() + } else { + AgentError::Protocol(format!("Cursor {context}: {}", describe(err))) + } +} + +/// Put the session on the composer's model, returning Cursor's options for +/// it. Cursor resumes a loaded conversation on the model it last used, so +/// this follows `session/load` as well as `session/new`. +async fn select_model( + setup: &Setup<'_>, + session_id: &acp::SessionId, + model: &str, + config_options: &[acp::SessionConfigOption], +) -> Result>, AgentError> { + let current = config_options + .iter() + .find(|option| option.id.0.as_ref() == MODEL_CONFIG_ID) + .and_then(select_value); + if current == Some(model) { + return Ok(None); + } + let selected = set_config_option(setup, session_id, MODEL_CONFIG_ID, model) + .await + .map_err(|err| { + AgentError::Provider(format!( + "Cursor did not accept the model `{model}`: {}", + describe(&err) + )) + })?; + Ok(Some(selected)) +} + +/// Re-select the model parameters this session chose before: a new process +/// starts on the parameters Cursor last saved for the model, which another +/// session may have changed. A choice the model no longer offers is dropped. +async fn restore_parameters( + setup: &Setup<'_>, + session_id: &acp::SessionId, + config_options: &[acp::SessionConfigOption], +) { + for option in config_options { + let id = option.id.0.as_ref(); + let acp::SessionConfigKind::Select(select) = &option.kind else { + continue; + }; + let Some(chosen) = acp_session::config_selection(&setup.opts.option_selections, id) else { + continue; + }; + let offered = match &select.options { + acp::SessionConfigSelectOptions::Ungrouped(options) => options + .iter() + .any(|option| option.value.0.as_ref() == chosen), + acp::SessionConfigSelectOptions::Grouped(groups) => groups + .iter() + .flat_map(|group| &group.options) + .any(|option| option.value.0.as_ref() == chosen), + _ => false, + }; + if id == MODEL_CONFIG_ID || select_value(option) == Some(chosen) || !offered { + continue; + } + if let Err(err) = set_config_option(setup, session_id, id, chosen).await { + setup + .warn(format!( + "Cursor did not restore {} `{chosen}`: {}", + option.name, + describe(&err) + )) + .await; + } + } +} + +fn select_value(option: &acp::SessionConfigOption) -> Option<&str> { + match &option.kind { + acp::SessionConfigKind::Select(select) => Some(select.current_value.0.as_ref()), + _ => None, + } +} + +/// `session/set_config_option`, adopting the refreshed options it answers +/// with. +async fn set_config_option( + setup: &Setup<'_>, + session_id: &acp::SessionId, + config_id: &str, + value: &str, +) -> Result, acp::Error> { + let response = setup + .connection + .send_request(acp::SetSessionConfigOptionRequest::new( + session_id.clone(), + acp::SessionConfigId::new(config_id), + acp::SessionConfigOptionValue::value_id(acp::SessionConfigValueId::new(value)), + )) + .block_task() + .await?; + setup.adopt(None, Some(&response.config_options)); + Ok(response.config_options) +} + +/// The questions of a `cursor/ask_question` request. Canonical answers are +/// option labels; Cursor takes option ids. +struct Questions { + title: String, + questions: Vec, +} + +struct Question { + id: String, + prompt: String, + /// `(label, id)` in Cursor's order. + options: Vec<(String, String)>, + allow_multiple: bool, +} + +impl Questions { + fn parse(params: &Value) -> Result { + let questions = params + .get("questions") + .and_then(Value::as_array) + .ok_or_else(acp::Error::invalid_params)?; + Ok(Self { + title: params + .get("title") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + questions: questions + .iter() + .filter_map(|question| { + Some(Question { + id: question.get("id")?.as_str()?.to_string(), + prompt: question.get("prompt")?.as_str()?.to_string(), + options: question + .get("options") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|option| { + Some(( + option.get("label")?.as_str()?.to_string(), + option.get("id")?.as_str()?.to_string(), + )) + }) + .collect(), + allow_multiple: question + .get("allowMultiple") + .and_then(Value::as_bool) + .unwrap_or(false), + }) + }) + .collect(), + }) + } + + fn canonical(&self) -> Vec { + self.questions + .iter() + .map(|question| UserInputQuestion { + id: question.id.clone(), + header: self.title.clone(), + question: question.prompt.clone(), + options: question + .options + .iter() + .map(|(label, _)| UserInputOption { + label: label.clone(), + description: String::new(), + }) + .collect(), + multi_select: question.allow_multiple, + prefill: None, + }) + .collect() + } + + /// The reply to Cursor, and the prompts whose typed answer matched no + /// option (Cursor's answers carry option ids only). `None` is a request + /// cancelled by an interrupt or shutdown. + fn reply(&self, answers: Option>) -> (Value, Vec) { + let Some(answers) = answers else { + return (json!({ "outcome": { "outcome": "cancelled" } }), Vec::new()); + }; + let mut unsent = Vec::new(); + let answered: Vec = self + .questions + .iter() + .filter_map(|question| { + let labels: Vec<&str> = match answers.get(&question.id)? { + Value::String(label) => vec![label.as_str()], + Value::Array(labels) => labels.iter().filter_map(Value::as_str).collect(), + _ => Vec::new(), + }; + let mut selected = Vec::new(); + for label in labels.into_iter().filter(|label| !label.trim().is_empty()) { + match question.options.iter().find(|(known, _)| known == label) { + Some((_, id)) => selected.push(id.as_str()), + None => unsent.push(format!("“{}”", question.prompt)), + } + } + (!selected.is_empty()) + .then(|| json!({ "questionId": question.id, "selectedOptionIds": selected })) + }) + .collect(); + let reply = if answered.is_empty() { + json!({ "outcome": { "outcome": "skipped" } }) + } else { + json!({ "outcome": { "outcome": "answered", "answers": answered } }) + }; + (reply, unsent) + } +} + +/// One entry of Cursor's todo list. +#[derive(Clone)] +struct Todo { + id: String, + content: String, + status: String, +} + +impl Todo { + fn parse(todo: &Value) -> Option { + Some(Self { + id: todo.get("id")?.as_str()?.to_string(), + content: todo.get("content")?.as_str()?.to_string(), + status: todo + .get("status") + .and_then(Value::as_str) + .unwrap_or("pending") + .to_string(), + }) + } + + /// A cancelled todo is no longer part of the plan; the canonical plan + /// has no cancelled step. + fn step(&self) -> Option { + let status = match self.status.as_str() { + "in_progress" => PlanStepStatus::InProgress, + "completed" => PlanStepStatus::Completed, + "cancelled" => return None, + _ => PlanStepStatus::Pending, + }; + Some(PlanStep { + step: self.content.clone(), + status, + }) + } +} diff --git a/crates/agent/src/cursor/tests.rs b/crates/agent/src/cursor/tests.rs new file mode 100644 index 000000000..7cd093e78 --- /dev/null +++ b/crates/agent/src/cursor/tests.rs @@ -0,0 +1,956 @@ +//! The Cursor dialect driven as the app drives it: [`super::start`] and +//! [`super::list_models`] spawn a stand-in `cursor-agent` that relays its +//! stdio to the test, which answers as Cursor's ACP server does from +//! `tests/fixtures/cursor` (see its `PROVENANCE.md`). + +use std::collections::VecDeque; +use std::path::PathBuf; +use std::time::Duration; + +use serde_json::{Map, Value, json}; +use smol::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}; +use smol::net::{TcpListener, TcpStream}; + +use crate::{ + AgentEvent, ApprovalDecision, ApprovalKind, ApprovalMode, InteractionMode, ItemContent, + ItemStatus, LaunchEnv, OptionSelection, PlanStepStatus, ResumeCursor, SessionCommand, + SessionHandle, SessionOptions, ThreadItem, TurnStatus, +}; + +const RECORDED: &str = include_str!("../../tests/fixtures/cursor/recorded_signed_out.jsonl"); +const SOURCE: &str = include_str!("../../tests/fixtures/cursor/source_derived.json"); +const SESSION: &str = "4f1b6a52-8e0c-4c1d-9a57-2d3b1e7c9a10"; + +fn source(name: &str) -> Value { + let fixtures: Value = serde_json::from_str(SOURCE).unwrap(); + let value = fixtures[name].clone(); + assert!(!value.is_null(), "no source-derived fixture {name}"); + value +} + +/// A line `cursor-agent` 2026.10.01-e373342 wrote while signed out. +fn recorded(line: usize) -> Value { + serde_json::from_str(RECORDED.lines().nth(line).unwrap()).unwrap() +} + +fn recorded_initialize() -> Value { + recorded(0)["result"].clone() +} + +fn recorded_auth_required() -> Value { + let error = recorded(2)["error"].clone(); + assert_eq!(recorded(1)["error"], error); + error +} + +/// Fails a test that would otherwise wait forever on a broken exchange. +async fn bounded(future: impl Future) -> T { + smol::future::or(future, async { + smol::Timer::after(Duration::from_secs(30)).await; + panic!("the Cursor exchange stalled"); + }) + .await +} + +fn stand_in() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cursor/cursor-agent") +} + +/// The far end of the stand-in: Cursor's ACP server, as this test plays it. +struct Agent { + listener: TcpListener, + lines: Option>, + out: Option, + /// The stand-in's arguments. + argv: String, + /// Client messages read while waiting for something else. + pending: VecDeque, + /// Every method the client sent. + methods: Vec, + request_seq: u64, +} + +impl Agent { + async fn new() -> Self { + Self { + listener: TcpListener::bind("127.0.0.1:0").await.unwrap(), + lines: None, + out: None, + argv: String::new(), + pending: VecDeque::new(), + methods: Vec::new(), + request_seq: 0, + } + } + + /// The launch environment that points the stand-in at this test. + fn launch_env(&self) -> LaunchEnv { + let port = self.listener.local_addr().unwrap().port(); + LaunchEnv { + env: vec![("TCODE_STAND_IN_PORT".into(), port.to_string())], + home: None, + } + } + + fn options(&self) -> SessionOptions { + SessionOptions { + cwd: std::env::temp_dir(), + model: None, + resume: None, + fork: false, + binary_path: Some(stand_in()), + approval_mode: ApprovalMode::Supervised, + option_selections: Vec::new(), + interaction_mode: InteractionMode::Build, + mcp_servers: Vec::new(), + launch_env: self.launch_env(), + extra_args: Vec::new(), + acp: None, + } + } + + /// Take the stand-in's connection, then answer `initialize` as the + /// signed-out binary did. + async fn connect(&mut self, client: &mut smol::Task>) { + let (stream, _) = + smol::future::or(async { self.listener.accept().await.unwrap() }, async { + let error = client.await.err(); + panic!("the client ended before the stand-in connected: {error:?}") + }) + .await; + self.out = Some(stream.clone()); + let mut lines = BufReader::new(stream); + let mut argv = String::new(); + lines.read_line(&mut argv).await.unwrap(); + self.argv = argv.trim_end().to_string(); + self.lines = Some(lines); + let initialize = self.expect("initialize").await; + assert_eq!( + initialize["params"]["clientCapabilities"]["_meta"], + json!({ "parameterizedModelPicker": true, "subagents": true }) + ); + self.reply(&initialize, recorded_initialize()).await; + } + + async fn read(&mut self) -> Value { + let mut line = String::new(); + let lines = self.lines.as_mut().unwrap(); + assert!( + lines.read_line(&mut line).await.unwrap() > 0, + "the client closed the connection" + ); + let message: Value = serde_json::from_str(&line).unwrap(); + if let Some(method) = message.get("method").and_then(Value::as_str) { + self.methods.push(method.to_string()); + } + message + } + + async fn recv(&mut self) -> Value { + match self.pending.pop_front() { + Some(message) => message, + None => self.read().await, + } + } + + async fn expect(&mut self, method: &str) -> Value { + let message = self.recv().await; + assert_eq!(message["method"], method, "{message}"); + message + } + + async fn send(&mut self, message: Value) { + self.send_all([message]).await; + } + + /// Messages in one write, so the client reads them together. + async fn send_all(&mut self, messages: [Value; N]) { + let mut lines = String::new(); + for message in messages { + lines.push_str(&message.to_string()); + lines.push('\n'); + } + let out = self.out.as_mut().unwrap(); + out.write_all(lines.as_bytes()).await.unwrap(); + out.flush().await.unwrap(); + } + + async fn reply(&mut self, request: &Value, result: Value) { + self.send(json!({ "jsonrpc": "2.0", "id": request["id"], "result": result })) + .await; + } + + async fn fail(&mut self, request: &Value, error: Value) { + self.send(json!({ "jsonrpc": "2.0", "id": request["id"], "error": error })) + .await; + } + + async fn update(&mut self, session: &str, update: Value) { + self.send(json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { "sessionId": session, "update": update }, + })) + .await; + } + + /// Send an agent→client request without waiting for its answer. + async fn request(&mut self, method: &str, params: Value) -> String { + self.request_seq += 1; + let id = format!("agent-{}", self.request_seq); + self.send(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })) + .await; + id + } + + /// The client's answer to request `id`, leaving other messages queued. + async fn answer(&mut self, id: &str) -> Value { + if let Some(position) = self.pending.iter().position(|message| message["id"] == id) { + return self.pending.remove(position).unwrap(); + } + loop { + let message = self.read().await; + if message["id"] == id && message.get("method").is_none() { + return message; + } + self.pending.push_back(message); + } + } + + async fn new_session(&mut self) -> Value { + let new = self.expect("session/new").await; + self.reply(&new, source("session_new")).await; + new + } + + /// Shut the session down, then wait until every stand-in process is + /// gone (each holds its end of the socket until it exits). Returns every + /// method the client sent. + async fn finish(mut self, handle: Option) -> Vec { + if let Some(handle) = handle { + let _ = handle.commands.send(SessionCommand::Shutdown).await; + while handle.events.recv().await.is_ok() {} + } + if let Some(out) = self.out.take() { + out.shutdown(std::net::Shutdown::Write).unwrap(); + } + if let Some(mut lines) = self.lines.take() { + let mut rest = String::new(); + lines.read_to_string(&mut rest).await.unwrap(); + for line in rest.lines() { + let message: Value = serde_json::from_str(line).unwrap(); + if let Some(method) = message.get("method").and_then(Value::as_str) { + self.methods.push(method.to_string()); + } + } + } + std::mem::take(&mut self.methods) + } + + async fn start(&mut self, opts: SessionOptions) -> SessionHandle { + let mut starting = smol::spawn(super::start(opts)); + self.connect(&mut starting).await; + self.new_session().await; + starting.await.unwrap() + } +} + +async fn send_turn(handle: &SessionHandle, text: &str) { + handle + .commands + .send(SessionCommand::SendTurn { + delivery_id: 1, + text: text.to_string(), + options: None, + attachments: Vec::new(), + }) + .await + .unwrap(); +} + +/// Events up to and including the first that `last` accepts. +async fn events_until( + handle: &SessionHandle, + last: impl Fn(&AgentEvent) -> bool, +) -> Vec { + let mut events = Vec::new(); + loop { + let event = handle.events.recv().await.unwrap(); + let done = last(&event); + events.push(event); + if done { + return events; + } + } +} + +fn turn_completed(event: &AgentEvent) -> bool { + matches!(event, AgentEvent::TurnCompleted { .. }) +} + +fn items(events: &[AgentEvent]) -> Vec<&ThreadItem> { + events + .iter() + .filter_map(|event| match event { + AgentEvent::ItemStarted(item) + | AgentEvent::ItemUpdated(item) + | AgentEvent::ItemCompleted(item) => Some(item), + _ => None, + }) + .collect() +} + +/// The last state of each item, by id. +fn last_item<'a>(events: &'a [AgentEvent], id: &str) -> &'a ThreadItem { + items(events) + .into_iter() + .rev() + .find(|item| item.id == id) + .unwrap_or_else(|| panic!("no item {id} in {events:#?}")) +} + +fn turn_status(events: &[AgentEvent]) -> TurnStatus { + events + .iter() + .find_map(|event| match event { + AgentEvent::TurnCompleted { status, usage, .. } => { + assert_eq!(*usage, None, "Cursor reports no usage"); + Some(*status) + } + _ => None, + }) + .unwrap() +} + +#[test] +fn signed_out_cursor_fails_fast_with_sign_in_guidance() { + smol::block_on(bounded(async { + let mut agent = Agent::new().await; + let mut starting = smol::spawn(super::start(agent.options())); + agent.connect(&mut starting).await; + let new = agent.expect("session/new").await; + agent.fail(&new, recorded_auth_required()).await; + let error = starting.await.err().expect("a signed-out start fails"); + assert!( + matches!(&error, crate::AgentError::Provider(message) if message.contains("Cursor is not signed in") && message.contains("cursor-agent login")), + "{error}" + ); + + let mut catalog = Agent::new().await; + let mut listing = smol::spawn(super::list_models(Some(stand_in()), catalog.launch_env())); + catalog.connect(&mut listing).await; + let list = catalog.expect("cursor/list_available_models").await; + catalog.fail(&list, recorded_auth_required()).await; + let error = listing.await.expect_err("a signed-out catalog fails"); + assert!( + error.to_string().contains("Cursor is not signed in"), + "{error}" + ); + + for methods in [agent.finish(None).await, catalog.finish(None).await] { + assert!( + !methods.iter().any(|method| method == "authenticate"), + "`authenticate` starts a browser login: {methods:?}" + ); + } + })); +} + +#[test] +fn catalog_ids_are_the_model_values_cursor_accepts() { + smol::block_on(bounded(async { + let mut agent = Agent::new().await; + let mut listing = smol::spawn(super::list_models(Some(stand_in()), agent.launch_env())); + agent.connect(&mut listing).await; + let list = agent.expect("cursor/list_available_models").await; + agent.reply(&list, source("list_available_models")).await; + let models = listing.await.unwrap(); + let ids: Vec<_> = models + .iter() + .map(|model| (model.id.as_str(), model.display_name.as_str())) + .collect(); + assert_eq!(ids, [("composer-1", "Composer 1"), ("gpt-5", "GPT-5")]); + agent.finish(None).await; + })); +} + +#[test] +fn the_composers_model_and_the_sessions_parameters_are_selected_at_start() { + smol::block_on(bounded(async { + let mut agent = Agent::new().await; + let mut opts = agent.options(); + opts.model = Some("gpt-5".into()); + opts.option_selections = [("acp:cfg:reasoning", "high"), ("acp:cfg:fast", "true")] + .map(|(id, value)| OptionSelection { + id: id.into(), + value: json!(value), + }) + .into(); + let mut starting = smol::spawn(super::start(opts)); + agent.connect(&mut starting).await; + agent.new_session().await; + let select = agent.expect("session/set_config_option").await; + assert_eq!( + select["params"], + json!({ "sessionId": SESSION, "configId": "model", "value": "gpt-5" }) + ); + agent.reply(&select, source("set_model_gpt5")).await; + // The new model has no `fast`: only its own parameter is restored. + let restore = agent.expect("session/set_config_option").await; + assert_eq!( + restore["params"], + json!({ "sessionId": SESSION, "configId": "reasoning", "value": "high" }) + ); + agent.reply(&restore, source("set_reasoning_high")).await; + let handle = starting.await.unwrap(); + let events = events_until(&handle, |event| { + matches!(event, AgentEvent::ProviderOptions { .. }) + }) + .await; + let Some(AgentEvent::SessionStarted { model, .. }) = events.first() else { + panic!("{events:#?}"); + }; + assert_eq!(model.as_deref(), Some("gpt-5")); + let Some(AgentEvent::ProviderOptions { selections, .. }) = events.last() else { + unreachable!(); + }; + let selections: Vec<_> = selections + .iter() + .map(|selection| (selection.id.as_str(), selection.value.as_str().unwrap())) + .collect(); + assert_eq!( + selections, + [("acp:mode", "agent"), ("acp:cfg:reasoning", "high")], + "the model is the composer's, and the last model's parameter is gone" + ); + agent.finish(Some(handle)).await; + + // Already on the model: nothing to select. + let mut agent = Agent::new().await; + let mut opts = agent.options(); + opts.model = Some("composer-1".into()); + let handle = agent.start(opts).await; + let methods = agent.finish(Some(handle)).await; + assert!( + !methods + .iter() + .any(|method| method == "session/set_config_option"), + "{methods:?}" + ); + })); +} + +#[test] +fn tool_results_are_judged_by_their_raw_output_and_cursors_failure_text_fails_the_turn() { + smol::block_on(bounded(async { + let mut agent = Agent::new().await; + let handle = agent.start(agent.options()).await; + + send_turn(&handle, "Fix the build").await; + let prompt = agent.expect("session/prompt").await; + for update in source("turn_tools").as_array().unwrap().clone() { + agent.update(SESSION, update).await; + } + agent + .reply(&prompt, json!({ "stopReason": "end_turn" })) + .await; + let events = events_until(&handle, turn_completed).await; + + match &last_item(&events, "call_shell_1").content { + ItemContent::CommandExecution { + command, + output, + exit_code, + status, + } => { + assert_eq!(command, "cargo build"); + assert_eq!(output, "error[E0425]: cannot find value `x`"); + assert_eq!(*exit_code, Some(101)); + assert_eq!(*status, ItemStatus::Failed); + } + other => panic!("{other:?}"), + } + match &last_item(&events, "call_edit_1").content { + ItemContent::FileChange { changes, status } => { + assert_eq!(*status, ItemStatus::Completed); + assert_eq!(changes[0].path, "src/lib.rs"); + } + other => panic!("{other:?}"), + } + match &last_item(&events, "call_mcp_1").content { + ItemContent::ToolCall { status, .. } => assert_eq!(*status, ItemStatus::Declined), + other => panic!("{other:?}"), + } + match &last_item(&events, "call_grep_1").content { + ItemContent::ToolCall { status, .. } => assert_eq!(*status, ItemStatus::Completed), + other => panic!("{other:?}"), + } + // Prose that mentions an error is the model's, not a failed run. + assert_eq!(turn_status(&events), TurnStatus::Completed); + + send_turn(&handle, "Try again").await; + let prompt = agent.expect("session/prompt").await; + agent + .update( + SESSION, + json!({ "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "Retrying." } }), + ) + .await; + agent.update(SESSION, source("run_error")).await; + agent + .reply(&prompt, json!({ "stopReason": "end_turn" })) + .await; + let events = events_until(&handle, turn_completed).await; + assert_eq!(turn_status(&events), TurnStatus::Failed); + let text: String = events + .iter() + .filter_map(|event| match event { + AgentEvent::ItemCompleted(ThreadItem { + content: ItemContent::AssistantMessage { text }, + .. + }) => Some(text.as_str()), + _ => None, + }) + .collect(); + assert_eq!( + text, + "Retrying.\n\nError: ConnectError: [unavailable] upstream connect error" + ); + assert!( + !events + .iter() + .any(|event| matches!(event, AgentEvent::Error { .. })), + "the failure is already in the transcript: {events:#?}" + ); + agent.finish(Some(handle)).await; + })); +} + +#[test] +fn questions_and_plans_are_answered_in_cursors_nested_shapes() { + smol::block_on(bounded(async { + let mut agent = Agent::new().await; + let handle = agent.start(agent.options()).await; + send_turn(&handle, "Plan the storage move").await; + let prompt = agent.expect("session/prompt").await; + + let asked = agent + .request("cursor/ask_question", source("ask_question")) + .await; + let events = events_until(&handle, |event| { + matches!(event, AgentEvent::UserInputRequested { .. }) + }) + .await; + let Some(AgentEvent::UserInputRequested { + request_id, + questions, + .. + }) = events.last() + else { + unreachable!(); + }; + let asked_questions: Vec<_> = questions + .iter() + .map(|question| { + let labels: Vec<_> = question + .options + .iter() + .map(|option| option.label.as_str()) + .collect(); + ( + question.id.as_str(), + question.question.as_str(), + labels, + question.multi_select, + ) + }) + .collect(); + assert_eq!( + asked_questions, + [ + ("db", "Which database?", vec!["Postgres", "SQLite"], false), + ( + "extras", + "Which extras?", + vec!["Cache", "Queue", "Search"], + true + ), + ] + ); + let mut answers = Map::new(); + answers.insert("db".into(), json!("Postgres")); + answers.insert("extras".into(), json!(["Cache", "Search"])); + handle + .commands + .send(SessionCommand::RespondUserInput { + request_id: request_id.clone(), + answers, + }) + .await + .unwrap(); + assert_eq!( + agent.answer(&asked).await["result"], + json!({ "outcome": { + "outcome": "answered", + "answers": [ + { "questionId": "db", "selectedOptionIds": ["pg"] }, + { "questionId": "extras", "selectedOptionIds": ["cache", "search"] }, + ], + } }) + ); + + agent.update(SESSION, source("create_plan_entries")).await; + let planned = agent + .request("cursor/create_plan", source("create_plan")) + .await; + assert_eq!( + agent.answer(&planned).await["result"], + json!({ "outcome": { "outcome": "accepted" } }) + ); + let events = events_until(&handle, |event| { + matches!(event, AgentEvent::ProposedPlan { .. }) + }) + .await; + assert!(matches!( + events.last(), + Some(AgentEvent::ProposedPlan { item_id, markdown }) + if item_id == "call_plan_1" && markdown == "# Storage\n\n1. Add the migration\n2. Wire the repository\n" + )); + + let todos = agent + .request("cursor/update_todos", source("update_todos")) + .await; + assert_eq!(agent.answer(&todos).await["result"], json!({})); + let merged = agent + .request("cursor/update_todos", source("update_todos_merge")) + .await; + assert_eq!(agent.answer(&merged).await["result"], json!({})); + let task = agent.request("cursor/task", source("task")).await; + assert_eq!(agent.answer(&task).await["result"], json!({})); + agent + .reply(&prompt, json!({ "stopReason": "end_turn" })) + .await; + + let events = events_until(&handle, turn_completed).await; + let plans: Vec> = events + .iter() + .filter_map(|event| match event { + AgentEvent::PlanUpdated { steps, .. } => Some( + steps + .iter() + .map(|step| (step.step.as_str(), step.status)) + .collect(), + ), + _ => None, + }) + .collect(); + assert_eq!( + plans.last().unwrap(), + &[ + ("Add the migration", PlanStepStatus::Completed), + ("Wire the repository", PlanStepStatus::Pending), + ], + "a merge updates by id and a cancelled todo leaves the plan: {plans:?}" + ); + assert_eq!(turn_status(&events), TurnStatus::Completed); + agent.finish(Some(handle)).await; + })); +} + +#[test] +fn an_interrupt_cancels_a_pending_question() { + smol::block_on(bounded(async { + let mut agent = Agent::new().await; + let handle = agent.start(agent.options()).await; + send_turn(&handle, "Plan the storage move").await; + let prompt = agent.expect("session/prompt").await; + let asked = agent + .request("cursor/ask_question", source("ask_question")) + .await; + events_until(&handle, |event| { + matches!(event, AgentEvent::UserInputRequested { .. }) + }) + .await; + handle + .commands + .send(SessionCommand::Interrupt) + .await + .unwrap(); + assert_eq!( + agent.answer(&asked).await["result"], + json!({ "outcome": { "outcome": "cancelled" } }) + ); + let cancel = agent.expect("session/cancel").await; + assert_eq!(cancel["params"]["sessionId"], SESSION); + agent + .reply(&prompt, json!({ "stopReason": "cancelled" })) + .await; + let events = events_until(&handle, turn_completed).await; + assert_eq!(turn_status(&events), TurnStatus::Interrupted); + agent.finish(Some(handle)).await; + })); +} + +#[test] +fn permissions_are_granted_once_by_kind_and_otherwise_asked() { + smol::block_on(bounded(async { + for (mode, granted, asked) in [ + (ApprovalMode::ReadOnly, "permission_read", "permission_edit"), + ( + ApprovalMode::AutoAcceptEdits, + "permission_edit", + "permission_shell", + ), + (ApprovalMode::Supervised, "", "permission_read"), + ] { + let mut agent = Agent::new().await; + let mut opts = agent.options(); + opts.approval_mode = mode; + let handle = agent.start(opts).await; + assert_eq!(agent.argv, "acp", "{mode:?}"); + send_turn(&handle, "Go").await; + let prompt = agent.expect("session/prompt").await; + + if !granted.is_empty() { + let mut params = source(granted); + params["sessionId"] = json!(SESSION); + let id = agent.request("session/request_permission", params).await; + assert_eq!( + agent.answer(&id).await["result"], + json!({ "outcome": { "outcome": "selected", "optionId": "allow-once" } }), + "{mode:?}" + ); + } + + let mut params = source(asked); + params["sessionId"] = json!(SESSION); + let id = agent.request("session/request_permission", params).await; + let events = events_until(&handle, |event| { + matches!(event, AgentEvent::ApprovalRequested(_)) + }) + .await; + let approvals: Vec<_> = events + .iter() + .filter_map(|event| match event { + AgentEvent::ApprovalRequested(request) => Some(request), + _ => None, + }) + .collect(); + assert_eq!( + approvals.len(), + 1, + "{mode:?}: only {asked} is asked: {events:#?}" + ); + let request = approvals[0]; + let expected_kind = match asked { + "permission_edit" => matches!(request.kind, ApprovalKind::FileChange { .. }), + "permission_shell" => matches!(request.kind, ApprovalKind::ExecCommand { .. }), + _ => matches!(request.kind, ApprovalKind::FileRead { .. }), + }; + assert!(expected_kind, "{mode:?}: {request:?}"); + handle + .commands + .send(SessionCommand::RespondApproval { + request_id: request.id.clone(), + decision: ApprovalDecision::ApproveForSession, + }) + .await + .unwrap(); + assert_eq!( + agent.answer(&id).await["result"], + json!({ "outcome": { "outcome": "selected", "optionId": "allow-once" } }), + "{mode:?}: a session-wide approval never writes Cursor's allowlist" + ); + agent + .reply(&prompt, json!({ "stopReason": "end_turn" })) + .await; + events_until(&handle, turn_completed).await; + agent.finish(Some(handle)).await; + } + + let mut agent = Agent::new().await; + let mut opts = agent.options(); + opts.approval_mode = ApprovalMode::FullAccess; + opts.extra_args = vec!["--sandbox".into(), "disabled".into()]; + let handle = agent.start(opts).await; + assert_eq!(agent.argv, "--force --sandbox disabled acp"); + agent.finish(Some(handle)).await; + })); +} + +#[test] +fn a_loaded_session_replays_nothing_and_a_failed_load_is_an_error() { + smol::block_on(bounded(async { + let mut agent = Agent::new().await; + let mut opts = agent.options(); + opts.resume = Some(ResumeCursor(json!({ "session_id": SESSION }))); + let cwd = opts.cwd.clone(); + let mut starting = smol::spawn(super::start(opts)); + agent.connect(&mut starting).await; + let load = agent.expect("session/load").await; + assert_eq!( + load["params"], + json!({ "sessionId": SESSION, "cwd": cwd, "mcpServers": [] }) + ); + for update in source("load_replay").as_array().unwrap().clone() { + agent.update(SESSION, update).await; + } + // A blocking request mid-replay is answered without the user. + let asked = agent + .request("cursor/ask_question", source("ask_question")) + .await; + assert_eq!( + agent.answer(&asked).await["result"], + json!({ "outcome": { "outcome": "cancelled" } }) + ); + // Cursor sends its slash commands as soon as the load has answered. + agent + .send_all([ + json!({ "jsonrpc": "2.0", "id": load["id"], "result": source("session_load") }), + json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { "sessionId": SESSION, "update": source("available_commands") }, + }), + ]) + .await; + let handle = starting.await.unwrap(); + let events = events_until(&handle, |event| { + matches!(event, AgentEvent::ProviderCommands { .. }) + }) + .await; + assert!( + events.iter().all(|event| matches!( + event, + AgentEvent::SessionStarted { .. } + | AgentEvent::ProviderOptions { .. } + | AgentEvent::ProviderCommands { .. } + )), + "{events:#?}" + ); + let Some(AgentEvent::SessionStarted { resume, .. }) = events.first() else { + panic!("{events:#?}"); + }; + assert_eq!(resume.0, json!({ "session_id": SESSION })); + agent.finish(Some(handle)).await; + + let mut agent = Agent::new().await; + let mut opts = agent.options(); + opts.resume = Some(ResumeCursor(json!({ "session_id": "gone" }))); + let mut starting = smol::spawn(super::start(opts)); + agent.connect(&mut starting).await; + let load = agent.expect("session/load").await; + agent + .fail( + &load, + json!({ "code": -32602, "message": "Invalid params", "data": { "message": "Session \"gone\" not found" } }), + ) + .await; + let error = starting.await.err().expect("a failed load fails the start"); + assert!( + error.to_string().contains("could not resume session gone"), + "{error}" + ); + let methods = agent.finish(None).await; + assert!( + !methods.iter().any(|method| method == "session/new"), + "never a fresh session in its place: {methods:?}" + ); + })); +} + +#[test] +fn subagent_sessions_become_subagent_items_with_their_own_activity() { + smol::block_on(bounded(async { + let mut agent = Agent::new().await; + let handle = agent.start(agent.options()).await; + send_turn(&handle, "Survey the schema").await; + let prompt = agent.expect("session/prompt").await; + for notification in source("subagent_turn").as_array().unwrap().clone() { + agent + .update( + notification["sessionId"].as_str().unwrap(), + notification["update"].clone(), + ) + .await; + } + agent + .reply(&prompt, json!({ "stopReason": "end_turn" })) + .await; + let events = events_until(&handle, turn_completed).await; + + let child = "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4"; + let mut subagent = Vec::new(); + let mut children = Vec::new(); + for event in &events { + match event { + AgentEvent::ItemStarted(item) + | AgentEvent::ItemUpdated(item) + | AgentEvent::ItemCompleted(item) => match (&item.content, &item.parent_item_id) { + ( + ItemContent::Subagent { + agent_type, + description, + status, + model, + .. + }, + None, + ) => { + assert_eq!(item.id, "call_task_1"); + assert_eq!( + (agent_type.as_str(), description.as_str()), + ("explore", "Survey the schema") + ); + subagent.push((*status, model.clone())); + } + (content, Some(parent)) => { + assert_eq!(parent, "call_task_1"); + children.push((item.id.clone(), content.clone())); + } + (content, None) => assert!( + matches!(content, ItemContent::AssistantMessage { text } if text == "The schema has one table."), + "only the parent's own text stays in the parent: {content:?}" + ), + }, + AgentEvent::Delta { item_id, .. } => { + assert!( + !item_id.starts_with(child), + "a subagent's stream stays inside it" + ) + } + _ => {} + } + } + assert_eq!( + subagent.first(), + Some(&(ItemStatus::InProgress, None)), + "{subagent:?}" + ); + assert_eq!( + subagent.last(), + Some(&(ItemStatus::Completed, Some("composer-1".to_string()))), + "{subagent:?}" + ); + let texts: Vec<_> = children + .iter() + .filter_map(|(_, content)| match content { + ItemContent::AssistantMessage { text } => Some(text.as_str()), + _ => None, + }) + .collect(); + assert_eq!(texts, ["Looking at the migrations.", "One table: users."]); + assert!( + children.iter().any(|(id, content)| id == &format!("{child}/call_shell_1") + && matches!(content, ItemContent::CommandExecution { output, status: ItemStatus::Completed, .. } if output == "0001_init.sql\n")), + "{children:#?}" + ); + assert!( + children + .iter() + .all(|(id, _)| id.starts_with(&format!("{child}/"))), + "{children:#?}" + ); + agent.finish(Some(handle)).await; + })); +} diff --git a/crates/agent/src/cursor/updates.rs b/crates/agent/src/cursor/updates.rs new file mode 100644 index 000000000..6be1a75dd --- /dev/null +++ b/crates/agent/src/cursor/updates.rs @@ -0,0 +1,384 @@ +//! Cursor's `session/update`s on top of the standard mapping: tool results +//! judged by their `rawOutput`, and task tools with the subagent sessions +//! they run as canonical Subagent items and their children. + +use std::collections::HashMap; +use std::path::PathBuf; + +use agent_client_protocol::schema::v1 as acp; +use serde_json::Value; + +use crate::acp_session::State; +use crate::{AgentEvent, ItemContent, ItemStatus, ThreadItem}; + +/// One Cursor session and the subagent sessions under it. +pub(super) struct Updates { + cwd: PathBuf, + /// Subagent items by item id. + spawns: HashMap, + /// Subagent sessions by Cursor session id. + children: HashMap, +} + +struct Spawn { + parent_item_id: Option, + agent_type: String, + description: String, + model: Option, + status: ItemStatus, + /// `subagent_state_update` reported how the run ended; it outranks the + /// task tool's own completion. + settled: bool, + announced: bool, +} + +struct Child { + /// The Subagent item this session's activity belongs to. + spawn_item_id: String, + state: State, +} + +/// Where a subagent session's items go: ids namespaced by its Cursor session +/// id (a child numbers its text blocks from one as the parent does), parented +/// to its Subagent item. +struct Scope<'a> { + session: &'a str, + spawn_item_id: &'a str, +} + +impl Updates { + pub(super) fn new(cwd: PathBuf) -> Self { + Self { + cwd, + spawns: HashMap::new(), + children: HashMap::new(), + } + } + + pub(super) fn is_subagent(&self, session: &acp::SessionId) -> bool { + self.children.contains_key(session.0.as_ref()) + } + + pub(super) fn session_update( + &mut self, + state: &mut State, + notification: acp::SessionNotification, + ) -> Vec { + let mut update = notification.update; + let declined = judge_tool_result(&mut update); + let session = notification.session_id.0.to_string(); + match self.children.get_mut(&session) { + None => map(&mut self.spawns, None, state, update, declined), + Some(child) => { + let scope = Scope { + session: &session, + spawn_item_id: &child.spawn_item_id, + }; + map( + &mut self.spawns, + Some(&scope), + &mut child.state, + update, + declined, + ) + } + } + } + + /// `subagent_spawned` and `subagent_state_update`, which Cursor sends + /// when the client negotiates `_meta.subagents`. + pub(super) fn subagent_update(&mut self, params: &Value) -> Vec { + let (Some(session), Some(update)) = ( + params.get("sessionId").and_then(Value::as_str), + params.get("update"), + ) else { + return Vec::new(); + }; + let Some(subagent) = update.get("subagentSessionId").and_then(Value::as_str) else { + return Vec::new(); + }; + match update.get("sessionUpdate").and_then(Value::as_str) { + Some("subagent_spawned") => { + let meta = update.pointer("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/_meta/cursor"); + let Some(tool_call_id) = meta + .and_then(|meta| meta.get("toolCallId")) + .and_then(Value::as_str) + else { + return Vec::new(); + }; + let parent = self.children.get(session); + let scope = parent.map(|parent| Scope { + session, + spawn_item_id: &parent.spawn_item_id, + }); + let id = item_id(scope.as_ref(), tool_call_id); + let parent_item_id = scope.map(|scope| scope.spawn_item_id.to_string()); + let spawn = self + .spawns + .entry(id.clone()) + .or_insert_with(|| Spawn::new(parent_item_id)); + if spawn.agent_type.is_empty() + && let Some(name) = update.get("name").and_then(Value::as_str) + { + spawn.agent_type = name.to_string(); + } + if spawn.description.is_empty() + && let Some(task) = update.get("task").and_then(Value::as_str) + { + spawn.description = task.to_string(); + } + if let Some(model) = meta + .and_then(|meta| meta.get("model")) + .and_then(Value::as_str) + { + spawn.model = Some(model.to_string()); + } + let event = spawn.event(&id); + self.children.insert( + subagent.to_string(), + Child { + spawn_item_id: id, + state: State::new(self.cwd.clone()), + }, + ); + vec![event] + } + Some("subagent_state_update") => { + let status = match update.get("state").and_then(Value::as_str) { + Some("completed") => ItemStatus::Completed, + Some("failed") => ItemStatus::Failed, + Some("cancelled" | "disconnected") => ItemStatus::Interrupted, + _ => return Vec::new(), + }; + let Some(child) = self.children.get_mut(subagent) else { + return Vec::new(); + }; + let scope = Scope { + session: subagent, + spawn_item_id: &child.spawn_item_id, + }; + let mut events: Vec = child + .state + .flush_text() + .into_iter() + .filter_map(|event| child_event(&scope, event)) + .collect(); + if let Some(spawn) = self.spawns.get_mut(&child.spawn_item_id) { + spawn.status = status; + spawn.settled = true; + events.push(spawn.event(&child.spawn_item_id)); + } + events + } + _ => Vec::new(), + } + } +} + +/// One update of the session `scope` names (the session itself when `None`). +fn map( + spawns: &mut HashMap, + scope: Option<&Scope<'_>>, + state: &mut State, + update: acp::SessionUpdate, + declined: Option, +) -> Vec { + match &update { + acp::SessionUpdate::ToolCall(call) if is_task(call.raw_input.as_ref()) => { + let id = item_id(scope, &call.tool_call_id.0); + let spawn = spawns + .entry(id.clone()) + .or_insert_with(|| Spawn::new(scope.map(|scope| scope.spawn_item_id.to_string()))); + spawn.describe(call.raw_input.as_ref()); + return vec![spawn.event(&id)]; + } + acp::SessionUpdate::ToolCallUpdate(task) => { + let id = item_id(scope, &task.tool_call_id.0); + if let Some(spawn) = spawns.get_mut(&id) { + spawn.describe(task.fields.raw_input.as_ref()); + if !spawn.settled { + match task.fields.status { + Some(acp::ToolCallStatus::Failed) => spawn.status = ItemStatus::Failed, + // A background task returns while its subagent runs on. + Some(acp::ToolCallStatus::Completed) + if !task + .fields + .raw_output + .as_ref() + .and_then(|output| output.get("isBackground")) + .and_then(Value::as_bool) + .unwrap_or(false) => + { + spawn.status = ItemStatus::Completed; + } + _ => {} + } + } + return vec![spawn.event(&id)]; + } + } + _ => {} + } + let mut events = state.apply_update(update); + if let Some(tool) = declined { + for event in &mut events { + if let AgentEvent::ItemUpdated(item) | AgentEvent::ItemCompleted(item) = event + && item.id == tool + { + decline(&mut item.content); + } + } + } + match scope { + None => events, + Some(scope) => events + .into_iter() + .filter_map(|event| child_event(scope, event)) + .collect(), + } +} + +/// A subagent session's item, as a child of its Subagent item. Its streamed +/// text, plans, commands, options and usage stay inside it: the item +/// lifecycle is what reaches the subagent's own view. +fn child_event(scope: &Scope<'_>, event: AgentEvent) -> Option { + let adopt = |mut item: ThreadItem| { + item.id = item_id(Some(scope), &item.id); + item.parent_item_id = Some(scope.spawn_item_id.to_string()); + item + }; + match event { + AgentEvent::ItemStarted(item) => Some(AgentEvent::ItemStarted(adopt(item))), + AgentEvent::ItemUpdated(item) => Some(AgentEvent::ItemUpdated(adopt(item))), + AgentEvent::ItemCompleted(item) => Some(AgentEvent::ItemCompleted(adopt(item))), + _ => None, + } +} + +fn item_id(scope: Option<&Scope<'_>>, id: &str) -> String { + match scope { + None => id.to_string(), + Some(scope) => format!("{}/{id}", scope.session), + } +} + +/// Cursor's task tool, which runs a subagent: its `rawInput` names itself. +fn is_task(raw_input: Option<&Value>) -> bool { + raw_input + .and_then(|input| input.get("_toolName")) + .and_then(Value::as_str) + == Some("task") +} + +impl Spawn { + fn new(parent_item_id: Option) -> Self { + Self { + parent_item_id, + agent_type: String::new(), + description: String::new(), + model: None, + status: ItemStatus::InProgress, + settled: false, + announced: false, + } + } + + /// The task tool's `rawInput`: `{prompt, description, subagentType}`. + fn describe(&mut self, raw_input: Option<&Value>) { + let field = |key: &str| { + raw_input + .and_then(|input| input.get(key)) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + }; + if let Some(agent_type) = field("subagentType") { + self.agent_type = agent_type.to_string(); + } + if let Some(description) = field("description") { + self.description = description.to_string(); + } + } + + fn event(&mut self, id: &str) -> AgentEvent { + let item = ThreadItem { + id: id.to_string(), + parent_item_id: self.parent_item_id.clone(), + content: ItemContent::Subagent { + agent_type: if self.agent_type.is_empty() { + "subagent".to_string() + } else { + self.agent_type.clone() + }, + description: self.description.clone(), + status: self.status, + summary: None, + model: self.model.clone(), + effort: None, + }, + }; + if !std::mem::replace(&mut self.announced, true) { + AgentEvent::ItemStarted(item) + } else if self.status == ItemStatus::InProgress { + AgentEvent::ItemUpdated(item) + } else { + AgentEvent::ItemCompleted(item) + } + } +} + +/// Cursor reports every finished tool `completed`; how it went is only in +/// its `rawOutput`: a shell's `exitCode`, a tool's `error`, or a call the +/// user `rejected` or the permissions denied. Also gives a shell result its +/// output as text. Returns the id of a tool that was declined. +fn judge_tool_result(update: &mut acp::SessionUpdate) -> Option { + let acp::SessionUpdate::ToolCallUpdate(update) = update else { + return None; + }; + let fields = &mut update.fields; + let raw = fields.raw_output.as_ref()?; + if fields.content.is_none() + && let Some(output) = shell_output(raw) + { + fields.content = Some(vec![acp::ToolCallContent::from(acp::ContentBlock::from( + output, + ))]); + } + if fields.status != Some(acp::ToolCallStatus::Completed) { + return None; + } + let flag = |key: &str| raw.get(key).and_then(Value::as_bool) == Some(true); + if flag("rejected") || flag("permissionDenied") { + fields.status = Some(acp::ToolCallStatus::Failed); + return Some(update.tool_call_id.0.to_string()); + } + let errored = raw.get("error").is_some_and(|error| !error.is_null()); + let exit_code = raw.get("exitCode").and_then(Value::as_i64); + if errored || exit_code.is_some_and(|code| code != 0) { + fields.status = Some(acp::ToolCallStatus::Failed); + } + None +} + +/// A shell result's `stdout` and `stderr`, when it printed anything. +fn shell_output(raw: &Value) -> Option { + let stream = |key: &str| raw.get(key).and_then(Value::as_str); + if stream("stdout").is_none() && stream("stderr").is_none() { + return None; + } + let output = [stream("stdout"), stream("stderr")] + .into_iter() + .flatten() + .filter(|text| !text.is_empty()) + .collect::>() + .join("\n"); + (!output.is_empty()).then_some(output) +} + +fn decline(content: &mut ItemContent) { + match content { + ItemContent::CommandExecution { status, .. } + | ItemContent::FileChange { status, .. } + | ItemContent::ToolCall { status, .. } => *status = ItemStatus::Declined, + _ => {} + } +} diff --git a/crates/agent/src/grok.rs b/crates/agent/src/grok.rs new file mode 100644 index 000000000..559a8ee3e --- /dev/null +++ b/crates/agent/src/grok.rs @@ -0,0 +1,1432 @@ +//! Grok Build: `grok [--permission-mode M] agent [flags] stdio`, an ACP agent +//! with xAI's `_x.ai/*` dialect. +//! +//! The protocol machinery is [`crate::acp_session`]; this module is Grok's +//! dialect. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::PathBuf; +use std::sync::{Mutex, MutexGuard}; + +use agent_client_protocol::{UntypedMessage, schema::v1 as acp}; +use serde_json::{Map, Value, json}; + +use crate::acp_session::{ + self, Dialect, Established, Launch, Session, Setup, State, TurnEnd, describe, is_auth_required, + prompt_status, stop_reason_status, +}; +use crate::{ + AgentError, AgentEvent, ApprovalMode, Attachment, Compaction, InteractionMode, ItemContent, + LaunchEnv, ModelSpec, ProviderKind, ResumeCursor, SessionHandle, SessionOptions, TokenUsage, + TurnStatus, UserInputOption, UserInputQuestion, +}; + +/// The non-interactive auth method: it validates `XAI_API_KEY` (or the key in +/// Grok's `config.toml`). The other advertised method signs in through a +/// browser and is never driven from here. +const API_KEY_AUTH_METHOD: &str = "xai.api_key"; + +const ASK_USER_QUESTION: &str = "_x.ai/ask_user_question"; +const EXIT_PLAN_MODE: &str = "_x.ai/exit_plan_mode"; +const INTERJECT: &str = "_x.ai/interject"; +const INTERJECTION: &str = "_x.ai/session/interjection"; +const QUEUE_CHANGED: &str = "_x.ai/queue/changed"; +const TASK_BACKGROUNDED: &str = "_x.ai/task_backgrounded"; +const SESSION_NOTIFICATION: &str = "_x.ai/session_notification"; +const FORK: &str = "_x.ai/session/fork"; + +/// Grok's session modes, by the ids `session/set_mode` takes and +/// `current_mode_update` reports. Grok does not list them in `modes`. +const DEFAULT_MODE: &str = "default"; +const PLAN_MODE: &str = "plan"; + +pub(crate) mod plugins; + +/// Start (or resume, or fork) a Grok session. +pub async fn start(opts: SessionOptions) -> Result { + let grok = Grok { + approval_mode: opts.approval_mode, + ..Grok::default() + }; + acp_session::start(ProviderKind::Grok, grok, opts).await +} + +/// The models Grok's `initialize` advertises. Their options arrive over the +/// wire once a session starts ([`crate::OptionDescriptors::Wire`]). +pub async fn list_models( + binary_path: Option, + launch_env: LaunchEnv, +) -> Result, AgentError> { + let opts = SessionOptions { + cwd: std::env::temp_dir(), + model: None, + resume: None, + fork: false, + binary_path, + approval_mode: ApprovalMode::default(), + option_selections: Vec::new(), + interaction_mode: InteractionMode::default(), + mcp_servers: Vec::new(), + launch_env, + extra_args: Vec::new(), + acp: None, + }; + acp_session::query( + ProviderKind::Grok, + Grok::default(), + opts, + async |_connection, init| Ok(catalog(init)), + ) + .await +} + +/// `initialize`'s `_meta.modelState`. +fn catalog(init: &acp::InitializeResponse) -> Vec { + let Some(state) = model_state(init) else { + return Vec::new(); + }; + let current = state.get("currentModelId").and_then(Value::as_str); + available_models(state) + .filter_map(|model| { + let id = model.get("modelId")?.as_str()?; + Some(ModelSpec { + id: id.to_string(), + display_name: model + .get("name") + .and_then(Value::as_str) + .unwrap_or(id) + .to_string(), + is_default: Some(id) == current, + options: Vec::new(), + }) + }) + .collect() +} + +fn model_state(init: &acp::InitializeResponse) -> Option<&Value> { + init.meta.as_ref()?.get("modelState") +} + +fn available_models(state: &Value) -> impl Iterator { + state + .get("availableModels") + .and_then(Value::as_array) + .into_iter() + .flatten() +} + +#[derive(Default)] +struct Grok { + approval_mode: ApprovalMode, + /// Steers sent with `_x.ai/interject`, oldest first, until Grok echoes + /// them back as consumed. + steers: Mutex>, + models: Mutex, + activity: Mutex, +} + +/// What Grok runs: its prompts, as `_x.ai/queue/changed` reports them, and +/// its background tasks. Grok runs prompts one at a time and starts some +/// itself: when a background task finishes it runs a prompt reporting it, +/// which no `session/prompt` answers. +#[derive(Default)] +struct Activity { + /// Prompts the client queued, until they run. + queued: HashSet, + /// The running prompt the client sent. + client: Option, + /// The running prompt Grok started itself; its turn has its id. + agent: Option, + /// Shell tool calls whose command went on in the background. Grok streams + /// their later output as running updates and never completes them again. + backgrounded: HashSet, + background_tasks: usize, + /// A drop to no background tasks, withheld while a sent turn is open: + /// Grok follows that turn with one reporting the finished task, and the + /// runtime must not take the process for idle in between. + drained: bool, +} + +impl Activity { + fn sent_turn_open(&self, state: &State) -> bool { + state.turn().is_some() && state.turn() != self.agent.as_deref() + } +} + +/// What usage reporting needs to know about the session's models. +#[derive(Default)] +struct Models { + /// `totalContextTokens` by model id. + windows: HashMap, + /// The model serving the session. + current: Option, + /// The latest model call's usage. + last_call: Option, +} + +impl Grok { + fn models(&self) -> MutexGuard<'_, Models> { + self.models + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn activity(&self) -> MutexGuard<'_, Activity> { + self.activity + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn steers(&self) -> MutexGuard<'_, VecDeque<(String, String)>> { + self.steers + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// One model call's `response_completed` usage, whose `input_tokens` + /// excludes the cached prompt. + fn call_usage(&self, usage: &Value) -> TokenUsage { + let count = |key: &str| usage.get(key).and_then(Value::as_u64).unwrap_or(0); + let prompt = count("input_tokens") + + count("cache_read_input_tokens") + + count("cache_creation_input_tokens"); + let mut models = self.models(); + let usage = TokenUsage { + freshness: crate::ContextFreshness::Current, + input_tokens: Some(prompt), + cached_input_tokens: Some(count("cache_read_input_tokens")), + output_tokens: Some(count("output_tokens")), + used_tokens: Some(prompt + count("output_tokens")), + context_window: models.window(), + ..TokenUsage::default() + }; + models.last_call = Some(usage); + usage + } + + /// A turn's end as `turn_completed` reports it: its stop reason, and the + /// turn's usage on top of its last model call's. + fn reported_turn_end(&self, update: &Value) -> TurnEnd { + let (status, message) = match update.get("stop_reason").and_then(Value::as_str) { + Some("error") => ( + TurnStatus::Failed, + Some( + update + .get("agent_result") + .and_then(Value::as_str) + .unwrap_or("Grok's turn failed") + .to_string(), + ), + ), + Some(reason) => serde_json::from_value::(json!(reason)) + .map(stop_reason_status) + .unwrap_or_else(|_| { + ( + TurnStatus::Failed, + Some(format!("The agent stopped: {reason}")), + ) + }), + None => (TurnStatus::Completed, None), + }; + let models = self.models(); + let usage = update.get("usage").map(|usage| TokenUsage { + freshness: crate::ContextFreshness::Current, + turn_processed_tokens: usage.get("totalTokens").and_then(Value::as_u64), + context_window: models.window(), + ..models.last_call.unwrap_or_default() + }); + TurnEnd { + status, + message, + usage, + } + } + + /// A running prompt the client did not queue opens a turn of its own. + fn queue_changed(&self, state: &mut State, params: &Value) -> Vec { + let mut activity = self.activity(); + for entry in params + .get("entries") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + if let Some(id) = entry.get("id").and_then(Value::as_str) { + activity.queued.insert(id.to_string()); + } + } + let Some(running) = params.get("runningPromptId").and_then(Value::as_str) else { + return Vec::new(); + }; + if [&activity.client, &activity.agent] + .iter() + .any(|prompt| prompt.as_deref() == Some(running)) + { + return Vec::new(); + } + if activity.queued.remove(running) { + activity.client = Some(running.to_string()); + return Vec::new(); + } + activity.agent = Some(running.to_string()); + state.begin_agent_turn(running) + } + + /// `turn_completed` ends the turn of the prompt it names, in order with + /// everything else Grok sends; a sent turn's later `session/prompt` + /// result then completes nothing. + fn turn_completed(&self, state: &mut State, update: &Value) -> Vec { + let Some(prompt) = update.get("prompt_id").and_then(Value::as_str) else { + return Vec::new(); + }; + let end = self.reported_turn_end(update); + let mut activity = self.activity(); + if activity.agent.as_deref() == Some(prompt) { + activity.agent = None; + return state.end_turn(prompt, end); + } + if activity.client.as_deref() == Some(prompt) { + activity.client = None; + if let Some(turn) = state.turn().map(str::to_string) + && activity.sent_turn_open(state) + { + return state.end_turn(&turn, end); + } + } + Vec::new() + } + + /// The `session/prompt` result's `_meta`: the last model call's figures + /// (whose `totalTokens` is the context in use) and the turn's `usage`. + fn turn_usage(&self, meta: &acp::Meta) -> TokenUsage { + let count = |value: Option<&Value>| value.and_then(Value::as_u64); + let mut models = self.models(); + if let Some(model) = meta.get("modelId").and_then(Value::as_str) { + models.current = Some(model.to_string()); + } + TokenUsage { + freshness: crate::ContextFreshness::Current, + turn_processed_tokens: count( + meta.get("usage").and_then(|usage| usage.get("totalTokens")), + ), + input_tokens: count(meta.get("inputTokens")), + cached_input_tokens: count(meta.get("cachedReadTokens")), + output_tokens: count(meta.get("outputTokens")), + // `/compact` reports 0: nothing has been measured since. + used_tokens: count(meta.get("totalTokens")).filter(|tokens| *tokens > 0), + context_window: models.window(), + ..TokenUsage::default() + } + } + + /// One `_x.ai/session_notification` update. + fn session_notification(&self, state: &mut State, update: &Value) -> Vec { + let count = |key: &str| update.get(key).and_then(Value::as_u64); + match update.get("sessionUpdate").and_then(Value::as_str) { + Some("response_completed") => update + .get("usage") + .map(|usage| vec![AgentEvent::TokenUsage(self.call_usage(usage))]) + .unwrap_or_default(), + // A request that failed for good ends the turn, which reports it. + Some("retry_state") + if update.get("type").and_then(Value::as_str) == Some("retrying") => + { + vec![AgentEvent::Error { + message: retry_message(update), + fatal: false, + }] + } + Some("image_dropped") => { + let notes: Vec<&str> = update + .get("notes") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); + if notes.is_empty() { + return Vec::new(); + } + vec![AgentEvent::Warning { + message: notes.join("\n"), + }] + } + Some("background_tasks") => { + let running = update + .get("tasks") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|task| task.get("status").and_then(Value::as_str) == Some("running")) + .count(); + let mut activity = self.activity(); + let drained = running == 0 && activity.background_tasks > 0; + activity.background_tasks = running; + activity.drained = drained && activity.sent_turn_open(state); + if activity.drained { + return Vec::new(); + } + vec![AgentEvent::BackgroundTasksChanged { count: running }] + } + Some("turn_completed") => self.turn_completed(state, update), + Some("auto_compact_started") => vec![AgentEvent::ContextCompacted(Compaction { + in_progress: true, + trigger: Some("auto".into()), + pre_tokens: count("tokens_used"), + ..Compaction::default() + })], + Some("auto_compact_completed") => vec![AgentEvent::ContextCompacted(Compaction { + in_progress: false, + pre_tokens: count("tokens_before"), + post_tokens: count("tokens_after"), + duration_ms: count("elapsed_ms"), + ..Compaction::default() + })], + _ => Vec::new(), + } + } + + fn steer_accepted(&self, params: &Value) -> Vec { + let text = params.get("text").and_then(Value::as_str); + let mut steers = self.steers(); + let Some(position) = steers + .iter() + .position(|(sent, _)| Some(sent.as_str()) == text) + else { + return Vec::new(); + }; + let (_, request_id) = steers.remove(position).expect("position is in range"); + vec![AgentEvent::SteerAccepted { request_id }] + } +} + +impl Models { + fn window(&self) -> Option { + self.windows.get(self.current.as_deref()?).copied() + } +} + +impl Dialect for Grok { + fn name(&self) -> &str { + "Grok" + } + + fn launch(&self, opts: &SessionOptions) -> Result { + let program = crate::resolve_binary(opts.binary_path.as_deref(), "grok")?; + // `acceptEdits` at launch still asks before every edit, so + // AutoAcceptEdits runs in `default` and approves edits itself + // (`auto_approves`). `default` already runs reads unprompted. + let permission_mode = match opts.approval_mode { + ApprovalMode::Supervised | ApprovalMode::ReadOnly | ApprovalMode::AutoAcceptEdits => { + "default" + } + ApprovalMode::FullAccess => "bypassPermissions", + }; + let mut args = vec![ + "--permission-mode".to_string(), + permission_mode.to_string(), + "agent".to_string(), + ]; + if let Some(model) = &opts.model { + args.extend(["--model".to_string(), model.clone()]); + } + if let Some(effort) = + acp_session::config_selection(&opts.option_selections, "reasoning_effort") + { + args.extend(["--reasoning-effort".to_string(), effort.to_string()]); + } + args.extend(opts.extra_args.iter().cloned()); + args.push("stdio".to_string()); + Ok(Launch { + program, + args, + env: Vec::new(), + client_meta: None, + }) + } + + async fn establish(&self, setup: &Setup<'_>) -> Result { + let (connection, opts) = (setup.connection, setup.opts); + if let Some(state) = model_state(setup.init) { + let mut models = self.models(); + models.windows = available_models(state) + .filter_map(|model| { + let id = model.get("modelId")?.as_str()?; + let window = model.pointer("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/_meta/totalContextTokens")?.as_u64()?; + Some((id.to_string(), window)) + }) + .collect(); + models.current = opts.model.clone().or_else(|| { + state + .get("currentModelId") + .and_then(Value::as_str) + .map(str::to_string) + }); + } + let offers_api_key = setup.init.auth_methods.iter().any(|method| { + let id = match method { + acp::AuthMethod::Agent(method) => &method.id, + acp::AuthMethod::EnvVar(method) => &method.id, + acp::AuthMethod::Terminal(method) => &method.id, + _ => return false, + }; + id.0.as_ref() == API_KEY_AUTH_METHOD + }); + let authenticated = if offers_api_key { + connection + .send_request(acp::AuthenticateRequest::new(API_KEY_AUTH_METHOD)) + .block_task() + .await + .map(|_| ()) + } else { + Ok(()) + }; + let auth_failure = |err: &acp::Error| { + let detail = match &authenticated { + Err(auth) => describe(auth), + Ok(()) => describe(err), + }; + AgentError::Provider(format!( + "Grok requires authentication: {detail}. Set XAI_API_KEY in Settings → Providers → Grok, or sign in with `grok login`." + )) + }; + + let mcp_servers = setup.mcp_servers().await; + let resumed = opts + .resume + .as_ref() + .and_then(|cursor| cursor.str_field(&["session_id"])) + .map(str::to_string); + let (session_id, modes, config_options, existing) = match resumed { + Some(source) => { + let session_id = if opts.fork { + fork(connection, &source, opts).await? + } else { + source + }; + let resumed = connection + .send_request( + acp::ResumeSessionRequest::new(session_id.clone(), opts.cwd.clone()) + .mcp_servers(mcp_servers), + ) + .block_task() + .await + .map_err(|err| { + if is_auth_required(&err) { + auth_failure(&err) + } else { + AgentError::Provider(format!( + "Grok could not resume session {session_id}: {}", + describe(&err) + )) + } + })?; + ( + acp::SessionId::new(session_id), + resumed.modes, + resumed.config_options, + true, + ) + } + None => { + let created = connection + .send_request( + acp::NewSessionRequest::new(opts.cwd.clone()).mcp_servers(mcp_servers), + ) + .block_task() + .await + .map_err(|err| { + if is_auth_required(&err) { + auth_failure(&err) + } else { + AgentError::Protocol(format!( + "Grok could not start a session: {}", + describe(&err) + )) + } + })?; + ( + created.session_id, + created.modes, + created.config_options, + false, + ) + } + }; + + let mut mode = match opts.interaction_mode { + InteractionMode::Plan => PLAN_MODE, + InteractionMode::Build => DEFAULT_MODE, + }; + // Grok keeps a session's plan mode across processes without reporting + // it, so a continued session is always told its mode. + if existing || mode == PLAN_MODE { + let applied = connection + .send_request(acp::SetSessionModeRequest::new( + session_id.clone(), + acp::SessionModeId::new(mode), + )) + .block_task() + .await; + if let Err(err) = applied { + setup + .warn(format!( + "Grok did not switch to its {mode} mode: {}", + describe(&err) + )) + .await; + mode = DEFAULT_MODE; + } + } + let modes = modes.unwrap_or_else(|| { + acp::SessionModeState::new( + mode, + vec![ + acp::SessionMode::new(DEFAULT_MODE, "Default"), + acp::SessionMode::new(PLAN_MODE, "Plan"), + ], + ) + }); + setup.adopt(Some(&modes), config_options.as_deref()); + Ok(Established { + resume: ResumeCursor(json!({ "session_id": session_id.0.to_string() })), + session_id, + }) + } + + fn session_update( + &self, + state: &mut State, + notification: acp::SessionNotification, + ) -> Vec { + // `session/load` replays history flagged as such; tcode's own log is + // the conversation of record. + let replayed = notification + .meta + .as_ref() + .and_then(|meta| meta.get("isReplay")) + .and_then(Value::as_bool) + .unwrap_or(false); + if replayed { + return Vec::new(); + } + let mut update = notification.update; + let shell = match &mut update { + acp::SessionUpdate::ToolCallUpdate(update) => { + let output = update.fields.raw_output.as_ref(); + if output.and_then(|raw| raw.get("type")) == Some(&json!("Bash")) + && self + .activity() + .backgrounded + .contains(update.tool_call_id.0.as_ref()) + { + update.fields.status = None; + } + prepare_tool_update(update) + } + _ => None, + }; + let mut events = state.apply_update(update); + if let Some(shell) = shell { + events.iter_mut().for_each(|event| shell.render(event)); + } + events + } + + fn turn_end( + &self, + _state: &mut State, + result: Result, + ) -> TurnEnd { + let (status, message) = match (prompt_status(&result), &result) { + ((TurnStatus::Failed, _), Err(err)) => (TurnStatus::Failed, Some(failure_message(err))), + (outcome, _) => outcome, + }; + TurnEnd { + status, + message, + usage: result + .as_ref() + .ok() + .and_then(|response| response.meta.as_ref()) + .map(|meta| self.turn_usage(meta)), + } + } + + async fn steer( + &self, + session: &Session, + request_id: String, + text: String, + _attachments: Vec, + ) { + let Some(session_id) = session.id() else { + return; + }; + self.steers().push_back((text.clone(), request_id.clone())); + let sent = session + .connection + .send_request(UntypedMessage { + method: INTERJECT.into(), + params: json!({ "sessionId": session_id.0.to_string(), "text": text }), + }) + .block_task() + .await; + if let Err(err) = sent { + self.steers().retain(|(_, pending)| pending != &request_id); + session + .emit(AgentEvent::Warning { + message: format!("Grok did not accept the steer: {}", describe(&err)), + }) + .await; + } + } + + async fn set_approval_mode(&self, _session: &Session, _mode: ApprovalMode) { + // Applied as `--permission-mode` when the runtime restarts the session + // for the next turn. + } + + /// Offered `fs/*` and `terminal/*`, Grok runs every read, write and + /// command through them instead of its own tools. + fn client_services(&self) -> bool { + false + } + + fn owned_config_options(&self) -> &'static [&'static str] { + &["model"] + } + + fn auto_approves(&self, tool_call: &acp::ToolCallUpdate) -> bool { + self.approval_mode == ApprovalMode::AutoAcceptEdits + && matches!( + tool_call.fields.kind, + Some(acp::ToolKind::Edit | acp::ToolKind::Delete | acp::ToolKind::Move) + ) + } + + fn handles_request(&self, method: &str) -> bool { + matches!(method, ASK_USER_QUESTION | EXIT_PLAN_MODE) + } + + async fn request( + &self, + session: Session, + method: String, + params: Value, + ) -> Result { + if method == EXIT_PLAN_MODE { + let item_id = params.get("toolCallId").and_then(Value::as_str); + let markdown = params + .get("planContent") + .and_then(Value::as_str) + .filter(|plan| !plan.trim().is_empty()); + if let (Some(item_id), Some(markdown)) = (item_id, markdown) { + session + .emit(AgentEvent::ProposedPlan { + item_id: item_id.to_string(), + markdown: markdown.to_string(), + }) + .await; + } + // The decision is the user's, through tcode's plan flow, which + // implements in a later Build-mode turn. Approving here would let + // Grok implement before that, and holding the request would keep + // the turn open under it; every outcome but approval and + // abandonment keeps Grok planning. + return Ok(json!({ "outcome": "request_changes" })); + } + let questions = questions(¶ms)?; + Ok(match session.ask_user(questions).await { + Some(answers) => json!({ "outcome": "accepted", "answers": answer_lists(answers) }), + None => json!({ "outcome": "cancelled" }), + }) + } + + fn notification(&self, state: &mut State, method: &str, params: Value) -> Vec { + let mut events = match method { + INTERJECTION => self.steer_accepted(¶ms), + QUEUE_CHANGED => self.queue_changed(state, ¶ms), + TASK_BACKGROUNDED => { + if let Some(id) = params + .pointer("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/update/tool_call_id") + .and_then(Value::as_str) + { + self.activity().backgrounded.insert(id.to_string()); + } + Vec::new() + } + SESSION_NOTIFICATION => params + .get("update") + .map(|update| self.session_notification(state, update)) + .unwrap_or_default(), + _ => Vec::new(), + }; + let mut activity = self.activity(); + if activity.drained && !activity.sent_turn_open(state) { + activity.drained = false; + events.push(AgentEvent::BackgroundTasksChanged { count: 0 }); + } + events + } +} + +/// Grok fails a turn with `-32603 Internal error`, its own words in `data`. +fn failure_message(err: &acp::Error) -> String { + err.data + .as_ref() + .and_then(|data| data.get("message")) + .and_then(Value::as_str) + .map_or_else(|| describe(err), str::to_string) +} + +fn retry_message(update: &Value) -> String { + let reason = update + .get("reason") + .and_then(Value::as_str) + .unwrap_or("Grok's model request failed"); + match ( + update.get("attempt").and_then(Value::as_u64), + update.get("max_retries").and_then(Value::as_u64), + ) { + (Some(attempt), Some(max)) => format!("{reason} (retry {attempt} of {max})"), + _ => reason.to_string(), + } +} + +/// Grok's shell tool resends the command's whole output with every update. +struct ShellResult { + item_id: String, + output: String, + exit_code: Option, +} + +impl ShellResult { + fn render(&self, event: &mut AgentEvent) { + if let AgentEvent::ItemStarted(item) + | AgentEvent::ItemUpdated(item) + | AgentEvent::ItemCompleted(item) = event + && item.id == self.item_id + && let ItemContent::CommandExecution { + output, exit_code, .. + } = &mut item.content + { + output.clone_from(&self.output); + *exit_code = self.exit_code; + } + } +} + +/// Bring a `tool_call_update` into the shape the standard mapping renders +/// faithfully. Grok's `rawOutput` is its typed tool result, not display text, +/// so it must never become an item's output. Returns the shell result the +/// mapped item shows instead. +fn prepare_tool_update(update: &mut acp::ToolCallUpdate) -> Option { + let fields = &mut update.fields; + let raw_type = fields + .raw_output + .as_ref() + .and_then(|raw| raw.get("type")) + .and_then(Value::as_str); + match raw_type { + Some("Bash") => { + let finished = matches!( + fields.status, + Some(acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed) + ); + // Running updates carry a placeholder exit code. + let exit_code = fields + .raw_output + .as_ref() + .and_then(|raw| raw.get("exit_code")) + .and_then(Value::as_i64) + .filter(|_| finished) + .map(|code| code as i32); + Some(ShellResult { + item_id: update.tool_call_id.0.to_string(), + output: text_of(fields.content.as_deref().unwrap_or_default()), + exit_code, + }) + } + Some("MCP") => { + let text = fields + .raw_output + .as_ref() + .and_then(|raw| raw.pointer("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/output/OkayOutput")) + .map(|output| match output { + Value::String(text) => text.clone(), + other => other.to_string(), + }); + if fields.content.is_none() + && let Some(text) = text + { + fields.content = Some(vec![text.into()]); + } + None + } + // Before a command runs, its content is the model's description of it. + None if fields.kind == Some(acp::ToolKind::Execute) => { + fields.content = None; + None + } + _ => None, + } +} + +fn text_of(content: &[acp::ToolCallContent]) -> String { + content + .iter() + .filter_map(|content| match content { + acp::ToolCallContent::Content(block) => match &block.content { + acp::ContentBlock::Text(text) => Some(text.text.as_str()), + _ => None, + }, + _ => None, + }) + .collect() +} + +/// Fork `source` into a new Grok session in the same directory. +async fn fork( + connection: &acp_session::Connection, + source: &str, + opts: &SessionOptions, +) -> Result { + let cwd = opts.cwd.to_string_lossy(); + let forked = connection + .send_request(UntypedMessage { + method: FORK.into(), + params: json!({ "sourceSessionId": source, "sourceCwd": cwd, "newCwd": cwd }), + }) + .block_task() + .await + .map_err(|err| { + AgentError::Provider(format!( + "Grok could not fork session {source}: {}", + describe(&err) + )) + })?; + forked + .get("newSessionId") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| AgentError::Protocol(format!("Grok's fork returned no session: {forked}"))) +} + +/// The questions of an `_x.ai/ask_user_question` request. Grok keys answers +/// by question text. +fn questions(params: &Value) -> Result, acp::Error> { + let questions = params + .get("questions") + .and_then(Value::as_array) + .ok_or_else(acp::Error::invalid_params)?; + Ok(questions + .iter() + .filter_map(|question| { + let text = question.get("question")?.as_str()?.to_string(); + Some(UserInputQuestion { + id: text.clone(), + header: question + .get("header") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + question: text, + options: question + .get("options") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|option| { + Some(UserInputOption { + label: option.get("label")?.as_str()?.to_string(), + description: option + .get("description") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }) + }) + .collect(), + multi_select: question + .get("multiSelect") + .and_then(Value::as_bool) + .unwrap_or(false), + prefill: None, + }) + }) + .collect()) +} + +/// Grok takes every answer as a list of selected labels. +fn answer_lists(answers: Map) -> Map { + answers + .into_iter() + .map(|(question, answer)| { + let answer = match answer { + Value::Array(_) => answer, + other => Value::Array(vec![other]), + }; + (question, answer) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::io::{BufRead as _, Write as _}; + use std::time::Duration; + + use super::*; + use crate::{ + ApprovalDecision, ApprovalKind, ContextFreshness, FileChangeKind, ItemStatus, + McpRegistration, OptionSelection, SessionCommand, ThreadItem, + }; + + // Grok 1.0.46 sessions recorded on the wire in both directions + // (provenance: `tests/fixtures/grok/README.md`). + /// A resumed session under AutoAcceptEdits. + const RESUMED_TURN: &str = include_str!("../tests/fixtures/grok/resumed_turn.jsonl"); + /// A background task finishing during a sent turn, the turn Grok starts + /// to report it, then another sent turn. + const AGENT_STARTED_TURN: &str = + include_str!("../tests/fixtures/grok/agent_started_turn.jsonl"); + const REPLAY_AGENT: &str = "TCODE_GROK_REPLAY_AGENT"; + + /// Stand in for `grok agent stdio`: answer the client with the recorded + /// agent messages, and require the client to send what Tcode sent, with + /// the same answers to Grok's own requests. + fn replay_agent(fixture: &str) { + let mut client = std::io::stdin().lock().lines(); + let mut agent = std::io::stdout().lock(); + let mut live_ids: HashMap = HashMap::new(); + for record in fixture.lines() { + let record: Value = serde_json::from_str(record).unwrap(); + let recorded = &record["message"]; + if record["from"] == "agent" { + let mut message = recorded.clone(); + if message.get("method").is_none() { + message["id"] = live_ids[&message["id"].to_string()].clone(); + } + writeln!(agent, "{message}").unwrap(); + agent.flush().unwrap(); + continue; + } + let live = loop { + let line = client.next().expect("the client hung up early").unwrap(); + // The client's protocol errors about the test harness's own + // non-JSON output carry no id. + if let Ok(live) = serde_json::from_str::(&line) + && !live.get("id").is_some_and(Value::is_null) + { + break live; + } + }; + assert_eq!(live.get("method"), recorded.get("method"), "{live}"); + if recorded["method"] == "initialize" { + let capabilities = + |message: &Value| message["params"]["clientCapabilities"].clone(); + assert_eq!(capabilities(&live), capabilities(recorded), "{live}"); + } + match recorded.get("id") { + Some(id) if recorded.get("method").is_some() => { + live_ids.insert(id.to_string(), live["id"].clone()); + } + _ => assert_eq!(live["result"], recorded["result"], "{live}"), + } + } + for _ in client {} + } + + /// A stand-in `grok` that runs the test `test` as [`replay_agent`]; + /// whatever arguments Tcode launches Grok with are left unread. + fn stand_in_binary(dir: &std::path::Path, test: &str) -> PathBuf { + let test = format!("grok::tests::{test}"); + let exe = std::env::current_exe().unwrap(); + let exe = exe.display(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let path = dir.join("grok"); + let script = format!("#!/bin/sh\nexec '{exe}' --exact {test} --nocapture\n"); + std::fs::write(&path, script).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } + #[cfg(windows)] + { + let path = dir.join("grok.cmd"); + std::fs::write(&path, format!("@\"{exe}\" --exact {test} --nocapture\r\n")).unwrap(); + path + } + } + + fn command_outputs<'a>(events: &'a [AgentEvent], id: &str) -> Vec<(&'a str, Option)> { + events + .iter() + .filter_map(|event| match event { + AgentEvent::ItemStarted(item) + | AgentEvent::ItemUpdated(item) + | AgentEvent::ItemCompleted(item) + if item.id == id => + { + match &item.content { + ItemContent::CommandExecution { + output, exit_code, .. + } => Some((output.as_str(), *exit_code)), + _ => None, + } + } + _ => None, + }) + .collect() + } + + fn completed<'a>(events: &'a [AgentEvent], id: &str) -> &'a ItemContent { + events + .iter() + .find_map(|event| match event { + AgentEvent::ItemCompleted(ThreadItem { + id: item, content, .. + }) if item == id => Some(content), + _ => None, + }) + .unwrap_or_else(|| panic!("{id} never completed: {events:#?}")) + } + + fn completed_last<'a>(events: &'a [AgentEvent], id: &str) -> &'a ItemContent { + events + .iter() + .rev() + .find_map(|event| match event { + AgentEvent::ItemCompleted(ThreadItem { + id: item, content, .. + }) if item == id => Some(content), + _ => None, + }) + .unwrap_or_else(|| panic!("{id} never completed: {events:#?}")) + } + + fn streamed(events: &[AgentEvent], id: &str) -> String { + events + .iter() + .filter_map(|event| match event { + AgentEvent::Delta { item_id, text, .. } if item_id == id => Some(text.as_str()), + _ => None, + }) + .collect() + } + + /// Options for a session against a stand-in Grok running `test`, in a + /// fresh directory. + fn stand_in_options(test: &str) -> SessionOptions { + let dir = + std::env::temp_dir().join(format!("tcode-grok-replay-{}-{test}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + SessionOptions { + binary_path: Some(stand_in_binary(&dir, test)), + cwd: dir, + model: None, + resume: None, + fork: false, + approval_mode: ApprovalMode::FullAccess, + option_selections: Vec::new(), + interaction_mode: InteractionMode::Build, + mcp_servers: Vec::new(), + launch_env: LaunchEnv { + env: vec![(REPLAY_AGENT.into(), "1".into())], + home: None, + }, + extra_args: Vec::new(), + acp: None, + } + } + + /// What the person driving a replayed session does after a turn completes. + enum Next { + Wait, + Send(&'static str), + Close, + } + + /// Run a session as the app does: send `first`, approve once and answer + /// questions with their first option, and after the `n`th completed turn + /// do `next(n)`. Returns every event through the close. + fn replay(opts: SessionOptions, first: &str, next: impl Fn(usize) -> Next) -> Vec { + let dir = opts.cwd.clone(); + let events = smol::block_on(smol::future::or( + async { + let handle = start(opts).await.unwrap(); + let send = |delivery_id, text: &str| SessionCommand::SendTurn { + delivery_id, + text: text.into(), + options: None, + attachments: Vec::new(), + }; + handle.commands.send(send(1, first)).await.unwrap(); + let (mut events, mut completed) = (Vec::new(), 0); + while let Ok(event) = handle.events.recv().await { + let command = match &event { + AgentEvent::ApprovalRequested(request) => { + Some(SessionCommand::RespondApproval { + request_id: request.id.clone(), + decision: ApprovalDecision::Approve, + }) + } + AgentEvent::UserInputRequested { + request_id, + questions, + .. + } => Some(SessionCommand::RespondUserInput { + request_id: request_id.clone(), + answers: questions + .iter() + .map(|question| { + (question.id.clone(), json!(question.options[0].label)) + }) + .collect(), + }), + AgentEvent::TurnCompleted { .. } => { + completed += 1; + match next(completed) { + Next::Wait => None, + Next::Send(text) => Some(send(completed as u64 + 1, text)), + Next::Close => Some(SessionCommand::Shutdown), + } + } + _ => None, + }; + let closed = matches!(event, AgentEvent::SessionClosed { .. }); + events.push(event); + if let Some(command) = command { + handle.commands.send(command).await.unwrap(); + } + if closed { + break; + } + } + events + }, + async { + smol::Timer::after(Duration::from_secs(60)).await; + panic!("the replayed session did not finish"); + }, + )); + let _ = std::fs::remove_dir_all(&dir); + // Every client message and answer matched the recording, or the + // stand-in would have hung up before the close. + assert!( + matches!( + events.last(), + Some(AgentEvent::SessionClosed { reason: None }) + ), + "{events:#?}" + ); + events + } + + #[test] + fn recorded_resumed_turn_maps_to_the_canonical_stream() { + if std::env::var_os(REPLAY_AGENT).is_some() { + return replay_agent(RESUMED_TURN); + } + let opts = SessionOptions { + resume: Some(ResumeCursor( + json!({ "session_id": "01a10095-e65c-7983-a817-6884b010f1ae" }), + )), + approval_mode: ApprovalMode::AutoAcceptEdits, + option_selections: vec![OptionSelection { + id: "acp:cfg:reasoning_effort".into(), + value: json!("low"), + }], + mcp_servers: vec![McpRegistration { + name: "tcode_probe".into(), + url: "http://127.0.0.1:18433/mcp".into(), + bearer_token: "tcode-secret".into(), + }], + ..stand_in_options("recorded_resumed_turn_maps_to_the_canonical_stream") + }; + let events = replay(opts, "Count, edit, ask and echo", |_| Next::Close); + + let started = events + .iter() + .position(|event| matches!(event, AgentEvent::TurnStarted { .. })) + .unwrap(); + assert!( + !events[..started].iter().any(|event| matches!( + event, + AgentEvent::Delta { .. } + | AgentEvent::ItemStarted(_) + | AgentEvent::ItemUpdated(_) + | AgentEvent::ItemCompleted(_) + )), + "a resumed session replays no history: {:#?}", + &events[..started] + ); + + assert_eq!(streamed(&events, "thought-1"), "Counting first."); + assert!(matches!(completed(&events, "thought-1"), + ItemContent::Reasoning { text } if text == "Counting first.")); + + // Each shell update carries the whole output so far; the item shows + // it, never the command's description or Grok's typed result. + let shell = command_outputs(&events, "call_33_1"); + assert_eq!( + shell.last(), + Some(&("line1\nline2\nline3\n", Some(0))), + "{shell:?}" + ); + assert!( + shell + .windows(2) + .all(|pair| pair[1].0.starts_with(pair[0].0) && pair[0].1.is_none()), + "{shell:?}" + ); + assert!(shell.iter().any(|(output, _)| *output == "line1\n")); + assert_eq!( + command_outputs(&events, "call_34_0").last(), + Some(&("", Some(0))) + ); + + assert!(matches!(completed(&events, "call_35_0"), + ItemContent::FileChange { changes, status: ItemStatus::Completed } + if changes.len() == 1 && changes[0].kind == FileChangeKind::Modify + && changes[0].path.ends_with("/in.txt"))); + let approvals: Vec<_> = events + .iter() + .filter_map(|event| match event { + AgentEvent::ApprovalRequested(request) => Some(&request.kind), + _ => None, + }) + .collect(); + assert!( + matches!(approvals.as_slice(), [ + ApprovalKind::ExecCommand { command, .. }, + ApprovalKind::ToolUse { name, .. }, + ] if command.starts_with("for i in") && name == "tcode_probe__echo_upper"), + "AutoAcceptEdits approves the edit itself: {approvals:#?}" + ); + assert!(events.iter().any(|event| matches!(event, + AgentEvent::UserInputRequested { questions, .. } + if questions[0].id == "Which greeting should I use?"))); + assert!(matches!(completed(&events, "call_37_0"), + ItemContent::ToolCall { name, output: Some(output), status: ItemStatus::Completed, .. } + if name == "tcode_probe__echo_upper" && output == "HELLO MCP")); + + assert_eq!( + streamed(&events, "msg-2"), + "Counted, edited, asked and echoed." + ); + assert!(matches!(completed(&events, "msg-2"), + ItemContent::AssistantMessage { text } if text == "Counted, edited, asked and echoed.")); + + assert!(events.iter().any(|event| matches!(event, + AgentEvent::TokenUsage(usage) + if usage.context_window == Some(500_000) && usage.used_tokens == Some(1234)))); + let completions: Vec<_> = events + .iter() + .filter_map(|event| match event { + AgentEvent::TurnCompleted { status, usage, .. } => Some((*status, *usage)), + _ => None, + }) + .collect(); + assert_eq!(completions.len(), 1, "{completions:?}"); + let (status, usage) = completions[0]; + assert_eq!(status, TurnStatus::Completed); + let usage = usage.expect("the turn reports its usage"); + assert_eq!(usage.freshness, ContextFreshness::Current); + assert_eq!(usage.turn_processed_tokens, Some(7404)); + assert_eq!(usage.used_tokens, Some(1234)); + assert_eq!(usage.context_window, Some(500_000)); + } + + /// Grok reports a background task that finished during a sent turn with + /// a turn of its own, which no `session/prompt` answers. It starts while + /// the sent turn's result is still on its way. + #[test] + fn a_turn_grok_starts_itself_runs_between_the_sent_turns() { + if std::env::var_os(REPLAY_AGENT).is_some() { + return replay_agent(AGENT_STARTED_TURN); + } + let opts = stand_in_options("a_turn_grok_starts_itself_runs_between_the_sent_turns"); + let events = replay( + opts, + "Background then foreground", + |completed| match completed { + 1 => Next::Wait, + 2 => Next::Send("And now?"), + _ => Next::Close, + }, + ); + + let agent = "task-completed-01a100c2-37c5-7fc2-b92a-c0f8b2c3659b"; + let at = |wanted: &dyn Fn(&AgentEvent) -> bool| { + events + .iter() + .position(wanted) + .unwrap_or_else(|| panic!("{events:#?}")) + }; + let lifecycle: Vec<(&str, &str, Option)> = events + .iter() + .filter_map(|event| match event { + AgentEvent::TurnStarted { turn_id } => Some(("started", turn_id.as_str(), None)), + AgentEvent::TurnCompleted { turn_id, usage, .. } => Some(( + "completed", + turn_id.as_str(), + usage.and_then(|usage| usage.turn_processed_tokens), + )), + _ => None, + }) + .collect(); + assert_eq!( + lifecycle, + [ + ("started", "turn-1", None), + ("completed", "turn-1", Some(3702)), + ("started", agent, None), + ("completed", agent, Some(1234)), + ("started", "turn-2", None), + ("completed", "turn-2", Some(1234)), + ] + ); + + let reply = at(&|event| { + matches!(event, AgentEvent::ItemCompleted(ThreadItem { + content: ItemContent::AssistantMessage { text }, .. + }) if text == "The background task printed bgdone.") + }); + let agent_started = + at(&|event| matches!(event, AgentEvent::TurnStarted { turn_id } if turn_id == agent)); + let agent_completed = at( + &|event| matches!(event, AgentEvent::TurnCompleted { turn_id, .. } if turn_id == agent), + ); + assert!(agent_started < reply && reply < agent_completed); + + // The runtime must not take the process for idle before Grok's turn. + let sent_completed = at( + &|event| matches!(event, AgentEvent::TurnCompleted { turn_id, .. } if turn_id == "turn-1"), + ); + let drained = at(&|event| matches!(event, AgentEvent::BackgroundTasksChanged { count: 0 })); + assert!(sent_completed < drained, "{events:#?}"); + + // The backgrounded command's later output leaves its card completed. + let background = events + .iter() + .filter(|event| { + matches!(event, AgentEvent::ItemUpdated(item) | AgentEvent::ItemCompleted(item) + if item.id == "call_68_0") + }) + .skip_while(|event| !matches!(event, AgentEvent::ItemCompleted(_))) + .collect::>(); + assert!( + background + .iter() + .all(|event| matches!(event, AgentEvent::ItemCompleted(_))), + "{background:#?}" + ); + assert!(matches!(completed_last(&events, "call_68_0"), + ItemContent::CommandExecution { output, status: ItemStatus::Completed, .. } + if output == "bgdone\n")); + } +} diff --git a/crates/agent/src/grok/plugins.rs b/crates/agent/src/grok/plugins.rs new file mode 100644 index 000000000..0702a51a3 --- /dev/null +++ b/crates/agent/src/grok/plugins.rs @@ -0,0 +1,781 @@ +//! Grok Build's native plugin management, driven through `grok plugin …` +//! with one bounded child process per command. +//! +//! Grok installs plugins for the user only, so every installation is in the +//! user scope. Enablement is left out: no `grok plugin` read reports it, and +//! `grok inspect` reports a disabled plugin as enabled. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::Deserialize; +use sha2::{Digest as _, Sha256}; + +use crate::{ + AgentError, CommandAcceptance, MarketplaceAction, PluginAction, PluginContext, + PluginInstallation, PluginListing, PluginOp, PluginOpOutcome, PluginScope, PluginSource, + PluginSourceKind, ProviderKind, ProviderPluginEntry, ProviderPluginMarketplace, Tri, + native_message, +}; + +const LIST_TIMEOUT: Duration = Duration::from_secs(60); +/// Installs, updates and marketplace adds may clone repositories. +const MUTATION_TIMEOUT: Duration = Duration::from_secs(300); +const DETAILS_CONCURRENCY: usize = 4; + +pub(crate) async fn list(context: &PluginContext) -> Result { + let (plugins, marketplaces) = smol::future::zip( + run_cli( + context, + &args(&["plugin", "list", "--json", "--available"]), + LIST_TIMEOUT, + ), + run_cli( + context, + &args(&["plugin", "marketplace", "list", "--json"]), + LIST_TIMEOUT, + ), + ) + .await; + let plugins = parse_listed(&succeeded(plugins?)?)?; + let marketplaces = parse_marketplaces(&succeeded(marketplaces?)?)?; + let mut listing = listing(plugins, marketplaces); + + let installed: Vec<(usize, String)> = listing + .entries + .iter() + .enumerate() + .filter(|(_, entry)| !entry.installations.is_empty()) + .map(|(index, entry)| (index, entry.name.clone())) + .collect(); + for chunk in installed.chunks(DETAILS_CONCURRENCY) { + let tasks: Vec<_> = chunk + .iter() + .map(|(index, name)| { + let context = context.clone(); + let (index, name) = (*index, name.clone()); + smol::spawn(async move { + let details = match operand(&name) { + Ok(name) => run_cli( + &context, + &["plugin".into(), "details".into(), name], + LIST_TIMEOUT, + ) + .await + .and_then(succeeded), + Err(error) => Err(error), + }; + (index, details) + }) + }) + .collect(); + for task in tasks { + let (index, details) = task.await; + let entry = &mut listing.entries[index]; + match details { + Ok(text) => apply_details(entry, &text), + Err(error) => entry + .diagnostics + .push(("details_error".into(), native_message(&error))), + } + } + } + Ok(listing) +} + +pub(crate) async fn run( + context: &PluginContext, + op: &PluginOp, +) -> Result { + let mut argv = op_args(op)?; + match op { + PluginOp::Install { accept_command, .. } => { + let untrusted = run_cli(context, &argv, MUTATION_TIMEOUT).await?; + match install_step(untrusted, accept_command.as_deref())? { + InstallStep::Finished(outcome) => Ok(outcome), + InstallStep::Trust => { + argv.push("--trust".into()); + message_outcome(run_cli(context, &argv, MUTATION_TIMEOUT).await?) + } + } + } + PluginOp::Update { .. } => { + let stdout = succeeded(run_cli(context, &argv, MUTATION_TIMEOUT).await?)?; + Ok(update_outcome(&stdout)) + } + _ => message_outcome(run_cli(context, &argv, MUTATION_TIMEOUT).await?), + } +} + +fn args(parts: &[&str]) -> Vec { + parts.iter().map(|part| (*part).to_string()).collect() +} + +/// The argv for one mutation, without consent: `--trust` is added only for an +/// install whose challenge a person accepted ([`install_step`]), and +/// `--confirm`, which uninstalls every plugin of a multi-plugin repository, is +/// never passed. Plugins are named as `grok plugin list` names them; installs +/// take the `name@marketplace` id. +fn op_args(op: &PluginOp) -> Result, AgentError> { + let plugin = |verb: &str, target: String, scope: PluginScope| match scope { + PluginScope::User => Ok(vec!["plugin".into(), verb.into(), target]), + other => Err(AgentError::Protocol(format!( + "Grok installs plugins for the user only, not in the {other:?} scope" + ))), + }; + let marketplace = |verb: &str, target: &str| { + Ok(vec![ + "plugin".into(), + "marketplace".into(), + verb.into(), + operand(target)?, + ]) + }; + match op { + PluginOp::Install { id, scope, .. } => plugin("install", operand(id)?, *scope), + PluginOp::Update { id, scope, .. } => plugin("update", name(id)?, *scope), + PluginOp::Uninstall { id, scope } => plugin("uninstall", name(id)?, *scope), + PluginOp::SetEnabled { .. } => Err(AgentError::Protocol( + "Grok plugin enablement cannot be read back through its CLI".into(), + )), + PluginOp::AddMarketplace { source } => marketplace("add", source), + PluginOp::RemoveMarketplace { name } => marketplace("remove", name), + } +} + +fn name(id: &str) -> Result { + operand(split_id(id).0) +} + +/// A value placed in a positional slot must not be read as an option. +fn operand(value: &str) -> Result { + if value.is_empty() || value.starts_with('-') { + return Err(AgentError::Protocol(format!( + "refusing to pass {value:?} to `grok plugin`" + ))); + } + Ok(value.to_string()) +} + +fn split_id(id: &str) -> (&str, Option<&str>) { + match id.rsplit_once('@') { + Some((name, marketplace)) if !name.is_empty() => (name, Some(marketplace)), + _ => (id, None), + } +} + +fn management_command( + context: &PluginContext, + args: &[String], +) -> Result { + let binary = crate::resolve_binary(context.binary_path.as_deref(), "grok")?; + let mut command = crate::process::command(binary); + command.args(args).current_dir(&context.cwd); + for (key, value) in context.launch_env.pairs(ProviderKind::Grok) { + command.env(key, value); + } + Ok(command) +} + +struct CliOutput { + success: bool, + status: String, + stdout: String, + stderr: String, +} + +async fn run_cli( + context: &PluginContext, + args: &[String], + timeout: Duration, +) -> Result { + let display = format!("grok {}", args.join(" ")); + let mut command = smol::process::Command::from(management_command(context, args)?); + // `output()` gives the child a null stdin, so a prompt can never wait on + // input, and dropping it on timeout kills the child. + command.kill_on_drop(true); + let output = smol::future::or(async { Some(command.output().await) }, async { + smol::Timer::after(timeout).await; + None + }) + .await + .ok_or_else(|| AgentError::Provider(format!("`{display}` timed out")))? + .map_err(|error| AgentError::Spawn(format!("spawning `{display}`: {error}")))?; + Ok(CliOutput { + success: output.status.success(), + status: output.status.to_string(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) +} + +fn succeeded(output: CliOutput) -> Result { + if output.success { + Ok(output.stdout) + } else { + Err(failure(&output)) + } +} + +/// A failed command in the CLI's own words. +fn failure(output: &CliOutput) -> AgentError { + let message = [output.stderr.trim(), output.stdout.trim()] + .into_iter() + .find(|text| !text.is_empty()) + .map_or_else(|| output.status.clone(), str::to_string); + AgentError::Provider(message) +} + +fn message_outcome(output: CliOutput) -> Result { + let stdout = succeeded(output)?; + let message = stdout.trim(); + Ok(PluginOpOutcome::Done { + diagnostics: if message.is_empty() { + Vec::new() + } else { + vec![("message".into(), message.to_string())] + }, + }) +} + +enum InstallStep { + Finished(PluginOpOutcome), + /// Re-run with `--trust`: a person accepted exactly the challenge Grok + /// shows now. + Trust, +} + +/// What an install run without `--trust` leaves to do. +fn install_step(untrusted: CliOutput, accepted: Option<&str>) -> Result { + if untrusted.success { + return message_outcome(untrusted).map(InstallStep::Finished); + } + match trust_challenge(&untrusted.stderr) { + Some(challenge) if accepted == Some(challenge.sha256.as_str()) => Ok(InstallStep::Trust), + Some(challenge) => Ok(InstallStep::Finished(PluginOpOutcome::AcceptCommand( + challenge, + ))), + None => Err(failure(&untrusted)), + } +} + +/// Without `--trust`, `install` explains what installing activates and prints +/// the command that proceeds. That command is what a person accepts, bound to +/// the whole explanation: if any of it changes, it is shown again. +fn trust_challenge(stderr: &str) -> Option { + let native_text = stderr.trim(); + let (_, proceed) = native_text.split_once("re-run with --trust:")?; + let command = proceed + .lines() + .map(str::trim) + .find(|line| !line.is_empty())?; + command.ends_with(" --trust").then(|| CommandAcceptance { + command: command.to_string(), + sha256: Sha256::digest(native_text.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(), + mode: None, + native_text: native_text.to_string(), + }) +} + +/// `: updated ( -> )`, one line per updated repository. +fn update_outcome(stdout: &str) -> PluginOpOutcome { + let message = stdout.trim(); + let parsed = message + .split_once(": ") + .and_then(|(_, result)| result.split_once(" (")) + .and_then(|(outcome, versions)| { + let (old, new) = versions.strip_suffix(')')?.split_once(" -> ")?; + Some([ + ("update_outcome".to_string(), outcome.to_string()), + ("old_version".to_string(), old.to_string()), + ("new_version".to_string(), new.to_string()), + ]) + }) + .filter(|_| message.lines().count() == 1); + PluginOpOutcome::Done { + diagnostics: match parsed { + Some(diagnostics) => diagnostics.into(), + None if message.is_empty() => Vec::new(), + None => vec![("message".into(), message.to_string())], + }, + } +} + +/// One row of `grok plugin list --json --available`. +#[derive(Deserialize)] +struct Listed { + status: String, + name: String, + #[serde(default)] + version: Option, + #[serde(default)] + description: Option, + #[serde(default)] + marketplace: Option, + /// The installed repository; one repository can hold several plugins. + #[serde(default)] + repo_key: Option, + #[serde(default)] + path: Option, + #[serde(default)] + source: Option, +} + +#[derive(Deserialize)] +struct ListedMarketplace { + name: String, + kind: String, + #[serde(default)] + source: ListedMarketplaceSource, +} + +#[derive(Deserialize, Default)] +struct ListedMarketplaceSource { + #[serde(default)] + path: Option, + #[serde(default)] + url: Option, +} + +fn parse_listed(stdout: &str) -> Result, AgentError> { + serde_json::from_str(stdout.trim()).map_err(|error| { + AgentError::Protocol(format!("unexpected `grok plugin list` output: {error}")) + }) +} + +fn parse_marketplaces(stdout: &str) -> Result, AgentError> { + let listed: Vec = serde_json::from_str(stdout.trim()).map_err(|error| { + AgentError::Protocol(format!( + "unexpected `grok plugin marketplace list` output: {error}" + )) + })?; + Ok(listed + .into_iter() + .map(|marketplace| ProviderPluginMarketplace { + kind: match marketplace.kind.as_str() { + "local" => PluginSourceKind::LocalPath, + "git" => PluginSourceKind::Git, + _ => PluginSourceKind::Unknown, + }, + source: marketplace + .source + .path + .or(marketplace.source.url) + .unwrap_or(marketplace.kind), + name: marketplace.name, + location: None, + }) + .collect()) +} + +fn listing(listed: Vec, marketplaces: Vec) -> PluginListing { + let source_kind = |plugin: &Listed| match &plugin.marketplace { + Some(name) => marketplaces + .iter() + .find(|marketplace| marketplace.name == *name) + .map_or(PluginSourceKind::Unknown, |marketplace| marketplace.kind), + None => match plugin.source.as_deref() { + Some(source) if Path::new(source).is_absolute() => PluginSourceKind::LocalPath, + Some(_) => PluginSourceKind::Git, + None => PluginSourceKind::Unknown, + }, + }; + let repositories: Vec<&str> = listed + .iter() + .filter(|plugin| plugin.status == "installed") + .filter_map(|plugin| plugin.repo_key.as_deref()) + .collect(); + let mut entries = Vec::new(); + for plugin in &listed { + let installed = match plugin.status.as_str() { + "installed" => true, + "available" => false, + other => { + log::warn!( + "grok plugin list: unknown status {other:?} for {}", + plugin.name + ); + continue; + } + }; + let mut actions = Vec::new(); + let scope = PluginScope::User; + if !installed { + actions.push(PluginAction::Install { scope }); + } else { + // A path install is a live link that `update` leaves as it is. + if plugin.marketplace.is_some() { + actions.push(PluginAction::Update { scope }); + } + // Uninstalling one plugin of a multi-plugin repository needs + // `--confirm` and removes them all. + let shared = plugin.repo_key.as_deref().is_some_and(|repository| { + repositories + .iter() + .filter(|key| **key == repository) + .count() + > 1 + }); + if !shared { + actions.push(PluginAction::Uninstall { scope }); + } + } + entries.push(ProviderPluginEntry { + id: match &plugin.marketplace { + Some(marketplace) => format!("{}@{marketplace}", plugin.name), + None => plugin.name.clone(), + }, + name: plugin.name.clone(), + version: plugin.version.clone(), + description: plugin.description.clone(), + source: PluginSource { + marketplace: plugin.marketplace.clone(), + kind: source_kind(plugin), + }, + installations: if installed { + vec![PluginInstallation { + scope, + location: plugin.path.clone(), + version: plugin.version.clone(), + scope_enabled: None, + }] + } else { + Vec::new() + }, + enabled: Tri::Unknown, + declared: None, + errors: Vec::new(), + actions, + diagnostics: Vec::new(), + }); + } + + let mut marketplace_actions = vec![MarketplaceAction::Add]; + for marketplace in &marketplaces { + marketplace_actions.push(MarketplaceAction::Remove { + marketplace: marketplace.name.clone(), + uninstalls: entries + .iter() + .filter(|entry| { + !entry.installations.is_empty() + && entry.source.marketplace.as_deref() == Some(marketplace.name.as_str()) + }) + .map(|entry| entry.id.clone()) + .collect(), + }); + } + PluginListing { + entries, + marketplaces, + marketplace_actions, + errors: Vec::new(), + } +} + +/// `plugin details` has no `--json`, and it counts component directories +/// rather than naming components, so its text stays a native diagnostic. +fn apply_details(entry: &mut ProviderPluginEntry, text: &str) { + if entry.description.is_none() { + entry.description = text + .lines() + .find_map(|line| line.trim().strip_prefix("description:")) + .map(|description| description.trim().to_string()); + } + entry + .diagnostics + .push(("details".into(), text.trim_end().to_string())); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(name: &str) -> String { + let path = format!( + "{}/tests/fixtures/plugins/grok/{name}", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("{path}: {error}")) + } + + fn output(stdout: &str, stderr: &str, success: bool) -> CliOutput { + CliOutput { + success, + status: if success { + "exit status: 0" + } else { + "exit status: 1" + } + .into(), + stdout: stdout.into(), + stderr: stderr.into(), + } + } + + /// Recorded after installing `alpha@mkt` (local marketplace), `zeta@tools` + /// (git marketplace) and the two-plugin repository `multi` from a path. + #[test] + fn listing_names_plugins_as_the_cli_takes_them_and_offers_what_it_can_carry_out() { + let listed = parse_listed(&fixture("plugin-list-available.json")).unwrap(); + let marketplaces = parse_marketplaces(&fixture("marketplace-list.json")).unwrap(); + assert_eq!( + marketplaces, + [ + ProviderPluginMarketplace { + name: "mkt".into(), + source: "/tmp/tcode-probe-grok/mkt".into(), + kind: PluginSourceKind::LocalPath, + location: None, + }, + ProviderPluginMarketplace { + name: "tools".into(), + source: "file:///tmp/tcode-probe-grok/tools".into(), + kind: PluginSourceKind::Git, + location: None, + }, + ] + ); + let mut listing = listing(listed, marketplaces); + let ids: Vec<&str> = listing.entries.iter().map(|e| e.id.as_str()).collect(); + assert_eq!( + ids, + ["alpha@mkt", "epsilon", "delta", "zeta@tools", "beta@mkt"] + ); + let user = PluginScope::User; + + let alpha = &listing.entries[0]; + assert_eq!( + alpha.installations, + [PluginInstallation { + scope: user, + location: Some( + "/tmp/tcode-probe-grok/home/.grok/installed-plugins/alpha-e84495d9".into() + ), + version: Some("1.0.0".into()), + scope_enabled: None, + }] + ); + assert_eq!( + alpha.source, + PluginSource { + marketplace: Some("mkt".into()), + kind: PluginSourceKind::LocalPath, + } + ); + assert_eq!(alpha.enabled, Tri::Unknown); + assert_eq!( + alpha.actions, + [ + PluginAction::Update { scope: user }, + PluginAction::Uninstall { scope: user } + ] + ); + assert_eq!(listing.entries[3].source.kind, PluginSourceKind::Git); + + // Each plugin of a path-installed repository is a live link that + // `update` leaves alone, and uninstalling one needs `--confirm`. + for multi in &listing.entries[1..3] { + assert_eq!(multi.source.kind, PluginSourceKind::LocalPath); + assert!(multi.actions.is_empty(), "{multi:?}"); + } + + let beta = &listing.entries[4]; + assert!(beta.installations.is_empty()); + assert_eq!(beta.version.as_deref(), Some("0.1.0")); + assert_eq!(beta.description.as_deref(), Some("Skill only")); + assert_eq!(beta.actions, [PluginAction::Install { scope: user }]); + + // Removing a marketplace uninstalls its plugins, which the host asks + // about first. + assert_eq!( + listing.marketplace_actions, + [ + MarketplaceAction::Add, + MarketplaceAction::Remove { + marketplace: "mkt".into(), + uninstalls: vec!["alpha@mkt".into()], + }, + MarketplaceAction::Remove { + marketplace: "tools".into(), + uninstalls: vec!["zeta@tools".into()], + }, + ] + ); + + apply_details(&mut listing.entries[0], &fixture("details-alpha.txt")); + let alpha = &listing.entries[0]; + assert_eq!( + alpha.description.as_deref(), + Some("Skill, command, SessionStart hook and an unreachable HTTP MCP server") + ); + assert!(alpha.declared.is_none()); + assert!(matches!(alpha.diagnostics.as_slice(), + [(key, text)] if key == "details" + && text.contains("components: 1 skill dir(s), 1 command dir(s)"))); + apply_details(&mut listing.entries[2], &fixture("details-delta.txt")); + assert_eq!(listing.entries[2].description, None); + } + + #[test] + fn trust_is_passed_only_for_the_challenge_a_person_accepted() { + let refused = || output("", &fixture("install-untrusted.stderr.txt"), false); + let InstallStep::Finished(PluginOpOutcome::AcceptCommand(challenge)) = + install_step(refused(), None).unwrap() + else { + panic!("an untrusted install must ask first"); + }; + assert_eq!(challenge.command, "grok plugin install alpha@mkt --trust"); + assert_eq!(challenge.mode, None); + assert!( + challenge.native_text.starts_with( + "Installing \"alpha\" from marketplace \"mkt\" requires confirmation." + ), + "{}", + challenge.native_text + ); + assert_eq!( + challenge.sha256, + "73bbb8b21e0f3eccd8a8aa4b0e57361ff136482a4d41390cc332bfe3314293bc" + ); + + assert!(matches!( + install_step(refused(), Some(&challenge.sha256)).unwrap(), + InstallStep::Trust + )); + // An acceptance of anything other than what Grok shows now is asked again. + let stale = "0".repeat(64); + assert!(matches!( + install_step(refused(), Some(&stale)).unwrap(), + InstallStep::Finished(PluginOpOutcome::AcceptCommand(again)) if again == challenge + )); + assert_eq!( + native_message( + &install_step( + output("", &fixture("install-unknown.stderr.txt"), false), + Some(&challenge.sha256) + ) + .err() + .unwrap() + ), + "Error: No marketplace plugin named \"nosuch\" in \"tools\"." + ); + let InstallStep::Finished(installed) = + install_step(output(&fixture("install-trusted.txt"), "", true), None).unwrap() + else { + panic!("an install Grok did not refuse is finished"); + }; + assert_eq!( + installed, + PluginOpOutcome::Done { + diagnostics: vec![( + "message".into(), + "Installed 1 plugin(s) from mkt: alpha".into() + )], + } + ); + } + + #[test] + fn management_argv_never_consents_and_targets_plugins_by_their_listed_name() { + let user = PluginScope::User; + let cases = [ + ( + PluginOp::Install { + id: "alpha@mkt".into(), + scope: user, + accept_command: Some("0".repeat(64)), + }, + vec!["plugin", "install", "alpha@mkt"], + ), + ( + PluginOp::Update { + id: "alpha@mkt".into(), + scope: user, + accept_command: None, + }, + vec!["plugin", "update", "alpha"], + ), + ( + PluginOp::Uninstall { + id: "alpha@mkt".into(), + scope: user, + }, + vec!["plugin", "uninstall", "alpha"], + ), + ( + PluginOp::AddMarketplace { + source: "file:///tmp/tcode-probe-grok/tools".into(), + }, + vec![ + "plugin", + "marketplace", + "add", + "file:///tmp/tcode-probe-grok/tools", + ], + ), + ( + PluginOp::RemoveMarketplace { name: "mkt".into() }, + vec!["plugin", "marketplace", "remove", "mkt"], + ), + ]; + let context = PluginContext { + binary_path: Some(PathBuf::from("/opt/grok/bin/grok")), + launch_env: crate::LaunchEnv { + env: Vec::new(), + home: Some(PathBuf::from("/tmp/isolated/.grok")), + }, + cwd: std::env::temp_dir(), + project: false, + }; + for (op, expected) in cases { + let argv = op_args(&op).unwrap(); + assert_eq!(argv, expected); + let command = management_command(&context, &argv).unwrap(); + assert!( + command + .get_envs() + .any(|env| env == ("GROK_HOME".as_ref(), Some("/tmp/isolated/.grok".as_ref()))) + ); + } + assert!( + op_args(&PluginOp::Install { + id: "alpha@mkt".into(), + scope: PluginScope::Project, + accept_command: None, + }) + .is_err() + ); + assert!( + op_args(&PluginOp::AddMarketplace { + source: "--force".into() + }) + .is_err() + ); + } + + #[test] + fn update_reports_the_version_change_grok_prints() { + assert_eq!( + update_outcome(&fixture("update.txt")), + PluginOpOutcome::Done { + diagnostics: vec![ + ("update_outcome".into(), "updated".into()), + ("old_version".into(), "1.0.0".into()), + ("new_version".into(), "1.1.0".into()), + ], + } + ); + assert_eq!( + update_outcome(&fixture("update-path-plugin.txt")), + PluginOpOutcome::Done { + diagnostics: vec![( + "message".into(), + "multi-0b1623cc: local symlink, already live".into() + )], + } + ); + } +} diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index ccccbca13..132c405db 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -9,11 +9,15 @@ #[cfg(feature = "process")] pub mod acp; #[cfg(feature = "process")] +mod acp_session; +#[cfg(feature = "process")] mod actor; #[cfg(feature = "process")] pub mod claude; mod claude_context; mod claude_manifest; +#[cfg(feature = "process")] +mod claude_plugins; #[cfg(not(feature = "process"))] pub mod claude { pub use crate::claude_context::*; @@ -21,6 +25,10 @@ pub mod claude { #[cfg(feature = "process")] pub mod codex; #[cfg(feature = "process")] +pub mod cursor; +#[cfg(feature = "process")] +pub mod grok; +#[cfg(feature = "process")] pub mod opencode; #[cfg(feature = "process")] pub mod pi; @@ -42,10 +50,12 @@ pub enum ProviderKind { ClaudeCode, Pi, OpenCode, + Cursor, + Grok, /// Any agent speaking the Agent Client Protocol. Which one is carried - /// separately ([`SessionOptions::acp`]) so this stays `Copy`: Codex and - /// Claude Code keep their richer native clients, and ACP covers the rest - /// of the ecosystem. + /// separately ([`SessionOptions::acp`]) so this stays `Copy`: the native + /// providers keep their own clients, and ACP covers the rest of the + /// ecosystem. Acp, } @@ -66,6 +76,7 @@ pub struct Caps { pub option_descriptors: OptionDescriptors, pub home_path: bool, pub trust_project_extensions: bool, + pub plugin_management: PluginManagement, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -92,6 +103,17 @@ pub enum OptionDescriptors { } impl ProviderKind { + /// The natively maintained providers, in the order provider lists present + /// them. + pub const NATIVE: [ProviderKind; 6] = [ + ProviderKind::ClaudeCode, + ProviderKind::Codex, + ProviderKind::Pi, + ProviderKind::OpenCode, + ProviderKind::Cursor, + ProviderKind::Grok, + ]; + /// Provider behavior consumed by runtime and UI policy. /// /// This is deliberately one exhaustive table so adding a provider forces a @@ -112,6 +134,17 @@ impl ProviderKind { option_descriptors: OptionDescriptors::Catalog, home_path: true, trust_project_extensions: false, + plugin_management: PluginManagement { + actions: &[ + PluginActionKind::Install, + PluginActionKind::Uninstall, + PluginActionKind::Enable, + PluginActionKind::Disable, + PluginActionKind::Update, + ], + marketplaces: true, + apply: ApplyNote::ReloadOrRestart, + }, }, ProviderKind::Codex => Caps { supports_steering: true, @@ -127,6 +160,16 @@ impl ProviderKind { option_descriptors: OptionDescriptors::Catalog, home_path: true, trust_project_extensions: false, + plugin_management: PluginManagement { + actions: &[ + PluginActionKind::Install, + PluginActionKind::Uninstall, + PluginActionKind::Enable, + PluginActionKind::Disable, + ], + marketplaces: true, + apply: ApplyNote::NextSession, + }, }, ProviderKind::Acp => Caps { supports_steering: false, @@ -144,6 +187,7 @@ impl ProviderKind { option_descriptors: OptionDescriptors::Wire, home_path: true, trust_project_extensions: false, + plugin_management: PluginManagement::NONE, }, ProviderKind::Pi => Caps { supports_steering: true, @@ -159,6 +203,7 @@ impl ProviderKind { option_descriptors: OptionDescriptors::Catalog, home_path: true, trust_project_extensions: true, + plugin_management: PluginManagement::NONE, }, ProviderKind::OpenCode => Caps { supports_steering: false, @@ -174,6 +219,47 @@ impl ProviderKind { option_descriptors: OptionDescriptors::Catalog, home_path: false, trust_project_extensions: false, + plugin_management: PluginManagement::NONE, + }, + ProviderKind::Cursor => Caps { + supports_steering: false, + supports_fork: false, + native_rewind: false, + per_turn_effort: false, + options_apply_live: true, + live_approval_mode_switch: false, + live_option_push: LiveOptionPush::All, + mcp_servers: true, + launch_args: true, + downgrade_approval_without_native_approvals: false, + option_descriptors: OptionDescriptors::Wire, + home_path: true, + trust_project_extensions: false, + plugin_management: PluginManagement::NONE, + }, + ProviderKind::Grok => Caps { + supports_steering: true, + supports_fork: true, + native_rewind: false, + per_turn_effort: false, + options_apply_live: true, + live_approval_mode_switch: false, + live_option_push: LiveOptionPush::All, + mcp_servers: true, + launch_args: true, + downgrade_approval_without_native_approvals: false, + option_descriptors: OptionDescriptors::Wire, + home_path: true, + trust_project_extensions: false, + plugin_management: PluginManagement { + actions: &[ + PluginActionKind::Install, + PluginActionKind::Uninstall, + PluginActionKind::Update, + ], + marketplaces: true, + apply: ApplyNote::ReloadOrNextSession, + }, }, } } @@ -184,14 +270,367 @@ impl ProviderKind { ProviderKind::ClaudeCode => "Claude Code", ProviderKind::Pi => "pi", ProviderKind::OpenCode => "OpenCode", + ProviderKind::Cursor => "Cursor", + ProviderKind::Grok => "Grok", ProviderKind::Acp => "ACP agent", } } } -/// Registry agents we never surface because they duplicate richer native -/// integrations. -pub const HIDDEN_ACP_AGENT_IDS: [&str; 4] = ["claude-acp", "codex-acp", "pi-acp", "opencode"]; +/// Which native plugin operations an adapter drives. Whether one is offered +/// for a given entry is computed per catalog ([`ProviderPluginEntry::actions`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PluginManagement { + pub actions: &'static [PluginActionKind], + pub marketplaces: bool, + pub apply: ApplyNote, +} + +impl PluginManagement { + pub const NONE: Self = Self { + actions: &[], + marketplaces: false, + apply: ApplyNote::Unverified, + }; + + pub fn supports(&self, action: PluginActionKind) -> bool { + self.actions.contains(&action) + } +} + +/// When a plugin change made through the native CLI reaches the agent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApplyNote { + /// Skills, commands and hooks apply after `/reload-plugins` in a running + /// session or in the next session; MCP servers only in the next session; + /// updates need a restart. + ReloadOrRestart, + /// Every change, MCP servers and updates included, applies after + /// `/reload-plugins` in a running session or in the next session. + ReloadOrNextSession, + /// Sessions started afterwards see the change; what a running session + /// picks up is not established. + NextSession, + /// No evidence yet of what a running session picks up. + Unverified, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginActionKind { + Install, + Uninstall, + Enable, + Disable, + Update, +} + +/// A native settings scope a plugin is installed or enabled in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginScope { + User, + Project, + Local, + Managed, + Session, +} + +/// A yes/no fact the native CLI may not report. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Tri { + Yes, + No, + #[default] + Unknown, +} + +impl From for Tri { + fn from(value: bool) -> Self { + if value { Tri::Yes } else { Tri::No } + } +} + +/// One native operation the host computed as available for an entry in the +/// catalog's context. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PluginAction { + Install { scope: PluginScope }, + Uninstall { scope: PluginScope }, + Enable { scope: PluginScope }, + Disable { scope: PluginScope }, + Update { scope: PluginScope }, +} + +impl PluginAction { + pub fn kind(self) -> PluginActionKind { + match self { + Self::Install { .. } => PluginActionKind::Install, + Self::Uninstall { .. } => PluginActionKind::Uninstall, + Self::Enable { .. } => PluginActionKind::Enable, + Self::Disable { .. } => PluginActionKind::Disable, + Self::Update { .. } => PluginActionKind::Update, + } + } + + pub fn scope(self) -> PluginScope { + match self { + Self::Install { scope } + | Self::Uninstall { scope } + | Self::Enable { scope } + | Self::Disable { scope } + | Self::Update { scope } => scope, + } + } +} + +/// A marketplace-level native operation available in the catalog's context. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum MarketplaceAction { + Add, + /// Removing the marketplace also uninstalls these installed plugins in + /// every scope, so the host asks before running it. + Remove { + marketplace: String, + uninstalls: Vec, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginSourceKind { + Official, + ThirdParty, + LocalPath, + Git, + Npm, + Command, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginSource { + #[serde(default)] + pub marketplace: Option, + pub kind: PluginSourceKind, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginInstallation { + pub scope: PluginScope, + /// Where the installed copy lives on the host. + #[serde(default)] + pub location: Option, + #[serde(default)] + pub version: Option, + /// The scope's own enablement where the native CLI reports it; the + /// effective value is [`ProviderPluginEntry::enabled`]. + #[serde(default)] + pub scope_enabled: Option, +} + +/// Components a plugin declares, by name. A `None` field was not enumerated, +/// which is not the same as declaring none. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeclaredComponents { + #[serde(default)] + pub skills: Option>, + #[serde(default)] + pub agents: Option>, + /// Hook event names. + #[serde(default)] + pub hooks: Option>, + #[serde(default)] + pub mcp_servers: Option>, + /// Hook event names the native CLI reports as not yet trusted (Codex + /// skips such hooks until they are reviewed natively). `None` when the + /// provider has no hook-trust state. + #[serde(default)] + pub untrusted_hooks: Option>, + #[serde(default)] + pub lsp_servers: Option>, + #[serde(default)] + pub apps: Option>, +} + +/// One plugin in a provider's native catalog: installed in any scope, offered +/// by a marketplace, or both. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderPluginEntry { + /// The native id the CLI accepts, `name@marketplace`. + pub id: String, + pub name: String, + #[serde(default)] + pub version: Option, + #[serde(default)] + pub description: Option, + pub source: PluginSource, + #[serde(default)] + pub installations: Vec, + /// Effective enablement for the catalog's context directory. + #[serde(default)] + pub enabled: Tri, + #[serde(default)] + pub declared: Option, + #[serde(default)] + pub errors: Vec, + #[serde(default)] + pub actions: Vec, + /// Read-only `(key, native text)` facts, keyed by stable identifiers the + /// client localizes. + #[serde(default)] + pub diagnostics: Vec<(String, String)>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderPluginMarketplace { + pub name: String, + /// The repository, URL or path the marketplace was added from. + pub source: String, + pub kind: PluginSourceKind, + #[serde(default)] + pub location: Option, +} + +/// A provider's native plugin catalog as seen from one directory. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PluginListing { + pub entries: Vec, + pub marketplaces: Vec, + pub marketplace_actions: Vec, + /// Native errors for parts of the catalog that could not be read, such + /// as a marketplace that failed to load. + pub errors: Vec, +} + +/// Where and as whom a plugin-management command runs: the profile's CLI and +/// environment, and the directory whose project scopes it addresses. +#[derive(Debug, Clone)] +pub struct PluginContext { + pub binary_path: Option, + pub launch_env: LaunchEnv, + pub cwd: PathBuf, + /// False when `cwd` is only a neutral directory, so project and local + /// scopes are not offered. + pub project: bool, +} + +/// One native plugin mutation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginOp { + Install { + id: String, + scope: PluginScope, + /// The `sha256` of a command the user accepted from an earlier + /// [`PluginOpOutcome::AcceptCommand`]. + accept_command: Option, + }, + Uninstall { + id: String, + scope: PluginScope, + }, + SetEnabled { + id: String, + scope: PluginScope, + enabled: bool, + }, + Update { + id: String, + scope: PluginScope, + accept_command: Option, + }, + AddMarketplace { + source: String, + }, + RemoveMarketplace { + name: String, + }, +} + +/// What a native plugin mutation reported. Native failures are errors. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginOpOutcome { + Done { + diagnostics: Vec<(String, String)>, + }, + /// The CLI refused to run a marketplace-declared command until a person + /// accepts exactly this command. + AcceptCommand(CommandAcceptance), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandAcceptance { + pub command: String, + pub sha256: String, + #[serde(default)] + pub mode: Option, + /// The CLI's own explanation. + pub native_text: String, +} + +/// List a provider's native plugins and marketplaces as seen from `context`. +#[cfg(feature = "process")] +pub async fn list_plugins( + provider: ProviderKind, + context: &PluginContext, +) -> Result { + match provider { + ProviderKind::ClaudeCode => claude_plugins::list(context).await, + ProviderKind::Codex => codex::plugins::list(context).await, + ProviderKind::Grok => grok::plugins::list(context).await, + ProviderKind::Pi | ProviderKind::OpenCode | ProviderKind::Cursor | ProviderKind::Acp => { + Err(no_plugin_management(provider)) + } + } +} + +/// Run one native plugin mutation in `context`. +#[cfg(feature = "process")] +pub async fn run_plugin_op( + provider: ProviderKind, + context: &PluginContext, + op: &PluginOp, +) -> Result { + match provider { + ProviderKind::ClaudeCode => claude_plugins::run(context, op).await, + ProviderKind::Codex => codex::plugins::run(context, op).await, + ProviderKind::Grok => grok::plugins::run(context, op).await, + ProviderKind::Pi | ProviderKind::OpenCode | ProviderKind::Cursor | ProviderKind::Acp => { + Err(no_plugin_management(provider)) + } + } +} + +/// A native failure in the CLI's own words. +#[cfg(feature = "process")] +fn native_message(error: &AgentError) -> String { + match error { + AgentError::Provider(message) => message.clone(), + other => other.to_string(), + } +} + +#[cfg(feature = "process")] +fn no_plugin_management(provider: ProviderKind) -> AgentError { + AgentError::Provider(format!( + "{} has no plugin management", + provider.display_name() + )) +} + +/// Registry agents never offered for new sessions because they duplicate a +/// native integration. +pub const HIDDEN_ACP_AGENT_IDS: [&str; 6] = [ + "claude-acp", + "codex-acp", + "pi-acp", + "opencode", + "cursor", + "grok-build", +]; /// One ACP agent a session can run: its registry identity plus how to launch it. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -344,7 +783,8 @@ pub struct LaunchEnv { /// entries win, and these override anything inherited from the parent. pub env: Vec<(String, String)>, /// Home-directory override. Provider-specific: Claude gets `HOME`, Codex - /// gets `CODEX_HOME`, and pi gets `PI_CODING_AGENT_DIR`. OpenCode has no + /// gets `CODEX_HOME`, pi gets `PI_CODING_AGENT_DIR`, Cursor gets + /// `CURSOR_CONFIG_DIR` and Grok gets `GROK_HOME`. OpenCode has no /// supported single-directory override, so its value is ignored. pub home: Option, } @@ -360,6 +800,8 @@ impl LaunchEnv { ProviderKind::ClaudeCode => Some("HOME"), ProviderKind::Codex => Some("CODEX_HOME"), ProviderKind::Pi => Some("PI_CODING_AGENT_DIR"), + ProviderKind::Cursor => Some("CURSOR_CONFIG_DIR"), + ProviderKind::Grok => Some("GROK_HOME"), ProviderKind::OpenCode => None, // ACP agents carry their own env in the launch recipe; there is // no protocol-level home concept to override. @@ -640,8 +1082,8 @@ impl UserInputDelivery { /// free-text) prompt; answers ride back through [`SessionCommand::RespondUserInput`]. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct UserInputQuestion { - /// The answer key. Claude: the complete question text (the SDK indexes - /// answers by question text). Codex: the native question id. + /// The answer key. Claude and Grok: the complete question text (both index + /// answers by question text). Codex and Cursor: the native question id. pub id: String, pub header: String, pub question: String, @@ -738,6 +1180,8 @@ pub async fn list_models( ProviderKind::ClaudeCode => claude::list_models(binary_path, launch_env, refresh).await, ProviderKind::Pi => pi::list_models(binary_path, launch_env).await, ProviderKind::OpenCode => opencode::list_models(binary_path, launch_env).await, + ProviderKind::Cursor => cursor::list_models(binary_path, launch_env).await, + ProviderKind::Grok => grok::list_models(binary_path, launch_env).await, // ACP agents advertise their models over the wire at session start // (`AgentEvent::ProviderOptions`), so there is no catalog to pre-fetch. ProviderKind::Acp => Ok(Vec::new()), @@ -753,6 +1197,11 @@ pub async fn list_models( /// (mid-session switch may require a resume-restart). /// - pi: a bundled fail-closed tool-call extension in front of RPC extension UI. /// - OpenCode: `OPENCODE_PERMISSION` rules plus permission reply endpoints. +/// - Grok: `--permission-mode` default / bypassPermissions at launch; under +/// AutoAcceptEdits tcode approves Grok's edit permission requests once. +/// - Cursor: `--force` at launch for FullAccess; otherwise its own permission +/// requests, of which tcode approves reads and searches once under +/// ReadOnly, and file changes once under AutoAcceptEdits. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ApprovalMode { @@ -856,9 +1305,9 @@ pub enum SessionCommand { /// native Plan mode. SetInteractionMode(InteractionMode), /// Set one of the agent's self-described options (see - /// [`AgentEvent::ProviderOptions`]). ACP routes it to `session/set_mode`, - /// `session/set_model` or `session/set_config_option` by the descriptor's - /// origin; the native providers ignore ids they do not know. + /// [`AgentEvent::ProviderOptions`]). An agent speaking ACP gets + /// `session/set_mode` or `session/set_config_option` by the descriptor's + /// origin; the other providers ignore ids they do not know. SetOption { id: String, value: serde_json::Value, @@ -924,6 +1373,8 @@ pub async fn start_session( ProviderKind::ClaudeCode => claude::start(opts).await, ProviderKind::Pi => pi::start(opts).await, ProviderKind::OpenCode => opencode::start(opts).await, + ProviderKind::Cursor => cursor::start(opts).await, + ProviderKind::Grok => grok::start(opts).await, ProviderKind::Acp => acp::start(opts).await, } } @@ -1421,10 +1872,10 @@ pub struct ApprovalRequest { pub id: String, pub turn_id: Option, pub kind: ApprovalKind, - /// Agent-supplied choices. ACP agents send their own option list - /// (`session/request_permission`), so the UI renders exactly those buttons. - /// Empty for the native providers, whose four fixed decisions - /// ([`ApprovalDecision`]) apply instead. + /// Agent-supplied choices. An agent speaking ACP (a registry agent, Cursor + /// or Grok) sends its own option list (`session/request_permission`), so the + /// UI renders exactly those buttons. Empty for the other providers, whose + /// four fixed decisions ([`ApprovalDecision`]) apply instead. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub options: Vec, } @@ -1484,7 +1935,7 @@ pub enum ApprovalDecision { /// `"User cancelled tool execution."` (no interrupt); Codex maps it to the /// protocol `{decision:"cancel"}` (deny + immediate turn interruption). Cancel, - /// Pick one of the agent's own [`ApprovalOption`]s (ACP only). + /// Pick one of the agent's own [`ApprovalOption`]s (agents speaking ACP). Option(String), } @@ -1820,6 +2271,17 @@ mod turn_diff_tests { mod thread_item_serde_tests { use super::*; + #[test] + fn cursor_and_grok_use_their_wire_names() { + for (kind, wire) in [ + (ProviderKind::Cursor, r#""cursor""#), + (ProviderKind::Grok, r#""grok""#), + ] { + assert_eq!(serde_json::to_string(&kind).unwrap(), wire); + assert_eq!(serde_json::from_str::(wire).unwrap(), kind); + } + } + #[test] fn provider_failure_wire_tag_and_legacy_error_remain_compatible() { let event = AgentEvent::ProviderStartFailed { diff --git a/crates/agent/tests/fixtures/cursor/PROVENANCE.md b/crates/agent/tests/fixtures/cursor/PROVENANCE.md new file mode 100644 index 000000000..d1d261165 --- /dev/null +++ b/crates/agent/tests/fixtures/cursor/PROVENANCE.md @@ -0,0 +1,82 @@ +# Cursor fixtures + +All of these concern `cursor-agent` 2026.10.01-e373342 (macOS arm64). Two +kinds live here, and only the first is a recording. + +## Recorded + +Captured on 2026-10-03 from the real binary while signed out, through Tcode's +production path (`cargo run -p agent --example probe -- --list-models cursor` +and `probe cursor …`, with a wrapper that copied the stdio), in a sandbox +(`HOME` redirected, `AGENT_CLI_CREDENTIAL_STORE=file`, `NO_OPEN_BROWSER=1`). +Nothing was edited. + +- `recorded_signed_out.jsonl` — what the binary wrote: the `initialize` result + (line 1), then `-32000 Authentication required` for + `cursor/list_available_models` (line 2) and for `session/new` (line 3). +- `status_signed_out.json` — `cursor-agent status --format json`. + +No signed-in session has been recorded. + +## Source-derived + +Hand-built from the shipped JavaScript of the same build, not recorded: +they protect Tcode's side of the shapes as read, not that Cursor sends them. +Line numbers refer to the build's `3351.index.js` (the ACP server) after +`prettier --parser babel` 3.9.9, unless named otherwise. Model names, model +parameters, paths and texts are illustrative: Cursor's catalog comes from its +backend. + +- `status_signed_in.json` — the `status` command's signed-in report + (`8657.index.js`, `status:"authenticated"`, `userInfo`). +- `cursor-agent` — the stand-in the tests launch: `--version` and `status` + answer from these fixtures, anything else relays stdio to the test. + +`source_derived.json`, by key: + +- `session_new` — the `session/new` result (1257-1262): modes (1910-1932), + models (1845-1856) and, with `parameterizedModelPicker`, `configOptions` + (1821-1844): the `mode` and `model` selects (1682-1704) and one select per + model parameter (1649-1671), booleans as `"false"`/`"true"` (1518-1540), + categorised `thought_level` or `model_config` (1504-1517, 1656-1658, 2011). +- `set_model_gpt5`, `set_reasoning_high` — `session/set_config_option` + answers with the full refreshed `configOptions` (1412-1446); a new model + brings its own parameters (1705-1731), a parameter keeps the model + (1733-1756). +- `list_available_models` — `cursor/list_available_models` (1457-1458, + 1575-1592). +- `session_load` — the `session/load` result (1354-1358), sent after the + replay (1359-1366). +- `load_replay` — the replay `session/load` sends first (276-377): the user + message, thoughts, each tool call as `tool_call` plus a completed + `tool_call_update` with id `replay--` (348-356, 3667-3684), a + question as its tool card (4443-4446, 4578-4585, `think` 4940), and a + subagent's lifecycle (361-374, 3012-3027). +- `available_commands` — `available_commands_update` (257-275), sent as soon + as `session/new` or `session/load` has answered (1264-1268, 1361-1365). +- `turn_tools` — a live turn: `tool_call` (3630-3646, 3784-3801), then + `in_progress` (3746-3749) and `completed` with `content`, `rawOutput`, + `locations` (3753-3773). Shell: title, kind, input and + `rawOutput {exitCode, stdout, stderr}` (4309-4311, 4922, 4502-4503, + 4757-4765). Edit: diff content (4345-4348, 4929, 4511-4515, 4638-4641, + 4679-4731). MCP: `{rejected: true}` (4403-4406, 4932, 4524-4530, + 4836-4846). Grep: `{totalMatches, truncated}` (4313-4340, 4925, + 4786-4805). Text and thoughts (3624-3629, 3686-3689, 3777-3781). +- `run_error` — a thrown agent run becomes the assistant chunk + `"\n\nError: " + String(e)` (714-734), and the prompt still answers + `end_turn` (409-426). +- `ask_question` — `cursor/ask_question` params (2039-2049); the reply shapes + are read at 2053-2075. +- `create_plan_entries`, `create_plan` — the `plan` update sent first + (2265-2288) and `cursor/create_plan` params (2331-2353); replies read at + 2353-2377. +- `update_todos`, `update_todos_merge` — `cursor/update_todos`, sent once the + todo tool completes (3840-3856), statuses from 3902-3915. +- `permission_read`, `permission_edit`, `permission_shell` — + `session/request_permission` with `allow-once`, `allow-always`, + `reject-once` (2634-2663); title and kind per operation (2684-2753). +- `subagent_turn` — a task tool (4433-4438, 4936, 4552-4559, 4868-4877), its + `subagent_spawned` and `subagent_state_update` sent under the parent session + (3158-3189), and the subagent's own updates under its session id + (2956-2981, 3114-3131). +- `task` — `cursor/task` (3858-3873). diff --git a/crates/agent/tests/fixtures/cursor/cursor-agent b/crates/agent/tests/fixtures/cursor/cursor-agent new file mode 100755 index 000000000..46fa0fa6f --- /dev/null +++ b/crates/agent/tests/fixtures/cursor/cursor-agent @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Stands in for `cursor-agent` in tests. `--version` and `status` answer from +# fixtures; anything else relays stdio to the test listening on +# 127.0.0.1:$TCODE_STAND_IN_PORT, after a first line with the arguments. +case "$1" in +--version) + echo "2026.10.01-e373342" + exit 0 + ;; +status) + cat "$TCODE_STAND_IN_STATUS" + exit 0 + ;; +esac +exec 3<>"/dev/tcp/127.0.0.1/$TCODE_STAND_IN_PORT" +printf '%s\n' "$*" >&3 +cat <&3 & +exec cat >&3 diff --git a/crates/agent/tests/fixtures/cursor/recorded_signed_out.jsonl b/crates/agent/tests/fixtures/cursor/recorded_signed_out.jsonl new file mode 100644 index 000000000..42c25e1e5 --- /dev/null +++ b/crates/agent/tests/fixtures/cursor/recorded_signed_out.jsonl @@ -0,0 +1,3 @@ +{"jsonrpc":"2.0","id":"e486b50a-de8b-4496-ae1b-dfdb370e48c8","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"audio":false,"embeddedContext":false,"image":true},"sessionCapabilities":{"list":{},"subagents":{}}},"authMethods":[{"id":"cursor_login","name":"Cursor Login","description":"Authenticate using existing Cursor login credentials. Run 'agent login' first if not logged in."}]}} +{"jsonrpc":"2.0","id":"08359b68-7d30-4638-b691-dcea54f0f4cf","error":{"code":-32000,"message":"Authentication required","data":{"message":"Authentication required. Please run 'agent login' first, then call authenticate() with methodId 'cursor_login'."}}} +{"jsonrpc":"2.0","id":"d6caf204-8307-4f35-b81a-1df88359477c","error":{"code":-32000,"message":"Authentication required","data":{"message":"Authentication required. Please run 'agent login' first, then call authenticate() with methodId 'cursor_login'."}}} diff --git a/crates/agent/tests/fixtures/cursor/source_derived.json b/crates/agent/tests/fixtures/cursor/source_derived.json new file mode 100644 index 000000000..68e6be11f --- /dev/null +++ b/crates/agent/tests/fixtures/cursor/source_derived.json @@ -0,0 +1,529 @@ +{ + "session_new": { + "sessionId": "4f1b6a52-8e0c-4c1d-9a57-2d3b1e7c9a10", + "modes": { + "currentModeId": "agent", + "availableModes": [ + { "id": "agent", "name": "Agent", "description": "Full agent capabilities with tool access" }, + { "id": "plan", "name": "Plan", "description": "Read-only mode for planning and designing before implementation" }, + { "id": "ask", "name": "Ask", "description": "Q&A mode - no edits or command execution" } + ] + }, + "models": { + "currentModelId": "composer-1", + "availableModels": [ + { "modelId": "composer-1", "name": "Composer 1" }, + { "modelId": "gpt-5", "name": "GPT-5" } + ] + }, + "configOptions": [ + { + "id": "mode", + "name": "Mode", + "description": "Controls how the agent executes tasks", + "category": "mode", + "type": "select", + "currentValue": "agent", + "options": [ + { "value": "agent", "name": "Agent", "description": "Full agent capabilities with tool access" }, + { "value": "plan", "name": "Plan", "description": "Read-only mode for planning and designing before implementation" }, + { "value": "ask", "name": "Ask", "description": "Q&A mode - no edits or command execution" } + ] + }, + { + "id": "model", + "name": "Model", + "description": "Controls which model is used for responses", + "category": "model", + "type": "select", + "currentValue": "composer-1", + "options": [ + { "value": "composer-1", "name": "Composer 1" }, + { "value": "gpt-5", "name": "GPT-5" } + ] + }, + { + "id": "fast", + "name": "Fast", + "category": "model_config", + "type": "select", + "currentValue": "false", + "options": [ + { "value": "false", "name": "Off" }, + { "value": "true", "name": "On" } + ] + } + ] + }, + "set_model_gpt5": { + "configOptions": [ + { + "id": "mode", + "name": "Mode", + "description": "Controls how the agent executes tasks", + "category": "mode", + "type": "select", + "currentValue": "agent", + "options": [ + { "value": "agent", "name": "Agent", "description": "Full agent capabilities with tool access" }, + { "value": "plan", "name": "Plan", "description": "Read-only mode for planning and designing before implementation" }, + { "value": "ask", "name": "Ask", "description": "Q&A mode - no edits or command execution" } + ] + }, + { + "id": "model", + "name": "Model", + "description": "Controls which model is used for responses", + "category": "model", + "type": "select", + "currentValue": "gpt-5", + "options": [ + { "value": "composer-1", "name": "Composer 1" }, + { "value": "gpt-5", "name": "GPT-5" } + ] + }, + { + "id": "reasoning", + "name": "Reasoning", + "category": "thought_level", + "type": "select", + "currentValue": "medium", + "options": [ + { "value": "low", "name": "Low" }, + { "value": "medium", "name": "Medium" }, + { "value": "high", "name": "High" } + ] + } + ] + }, + "set_reasoning_high": { + "configOptions": [ + { + "id": "mode", + "name": "Mode", + "description": "Controls how the agent executes tasks", + "category": "mode", + "type": "select", + "currentValue": "agent", + "options": [ + { "value": "agent", "name": "Agent", "description": "Full agent capabilities with tool access" }, + { "value": "plan", "name": "Plan", "description": "Read-only mode for planning and designing before implementation" }, + { "value": "ask", "name": "Ask", "description": "Q&A mode - no edits or command execution" } + ] + }, + { + "id": "model", + "name": "Model", + "description": "Controls which model is used for responses", + "category": "model", + "type": "select", + "currentValue": "gpt-5", + "options": [ + { "value": "composer-1", "name": "Composer 1" }, + { "value": "gpt-5", "name": "GPT-5" } + ] + }, + { + "id": "reasoning", + "name": "Reasoning", + "category": "thought_level", + "type": "select", + "currentValue": "high", + "options": [ + { "value": "low", "name": "Low" }, + { "value": "medium", "name": "Medium" }, + { "value": "high", "name": "High" } + ] + } + ] + }, + "list_available_models": { + "models": [ + { + "value": "composer-1", + "name": "Composer 1", + "configOptions": [ + { + "id": "fast", + "name": "Fast", + "category": "model_config", + "type": "select", + "currentValue": "false", + "options": [ + { "value": "false", "name": "Off" }, + { "value": "true", "name": "On" } + ] + } + ] + }, + { + "value": "gpt-5", + "name": "GPT-5", + "configOptions": [ + { + "id": "reasoning", + "name": "Reasoning", + "category": "thought_level", + "type": "select", + "currentValue": "medium", + "options": [ + { "value": "low", "name": "Low" }, + { "value": "medium", "name": "Medium" }, + { "value": "high", "name": "High" } + ] + } + ] + } + ] + }, + "session_load": { + "modes": { + "currentModeId": "agent", + "availableModes": [ + { "id": "agent", "name": "Agent", "description": "Full agent capabilities with tool access" }, + { "id": "plan", "name": "Plan", "description": "Read-only mode for planning and designing before implementation" }, + { "id": "ask", "name": "Ask", "description": "Q&A mode - no edits or command execution" } + ] + }, + "models": { + "currentModelId": "composer-1", + "availableModels": [ + { "modelId": "composer-1", "name": "Composer 1" }, + { "modelId": "gpt-5", "name": "GPT-5" } + ] + }, + "configOptions": [ + { + "id": "mode", + "name": "Mode", + "description": "Controls how the agent executes tasks", + "category": "mode", + "type": "select", + "currentValue": "agent", + "options": [ + { "value": "agent", "name": "Agent", "description": "Full agent capabilities with tool access" }, + { "value": "plan", "name": "Plan", "description": "Read-only mode for planning and designing before implementation" }, + { "value": "ask", "name": "Ask", "description": "Q&A mode - no edits or command execution" } + ] + }, + { + "id": "model", + "name": "Model", + "description": "Controls which model is used for responses", + "category": "model", + "type": "select", + "currentValue": "composer-1", + "options": [ + { "value": "composer-1", "name": "Composer 1" }, + { "value": "gpt-5", "name": "GPT-5" } + ] + } + ] + }, + "load_replay": [ + { "sessionUpdate": "user_message_chunk", "content": { "type": "text", "text": "Which database should we use?" } }, + { "sessionUpdate": "agent_thought_chunk", "content": { "type": "text", "text": "The user has not said." } }, + { + "sessionUpdate": "tool_call", + "toolCallId": "replay-0-1", + "title": "Storage", + "kind": "think", + "status": "pending", + "rawInput": { + "_toolName": "askQuestion", + "title": "Storage", + "questions": [ + { "id": "db", "prompt": "Which database?", "options": [ { "id": "pg", "label": "Postgres" }, { "id": "lite", "label": "SQLite" } ], "allowMultiple": false } + ] + } + }, + { "sessionUpdate": "tool_call_update", "toolCallId": "replay-0-1", "status": "completed" }, + { + "sessionUpdate": "tool_call", + "toolCallId": "replay-0-2", + "title": "`ls`", + "kind": "execute", + "status": "pending", + "rawInput": { "command": "ls" } + }, + { + "sessionUpdate": "tool_call_update", + "toolCallId": "replay-0-2", + "status": "completed", + "rawOutput": { "exitCode": 0, "stdout": "Cargo.toml\n", "stderr": "" } + }, + { + "sessionUpdate": "tool_call", + "toolCallId": "replay-0-3", + "title": "Task: Survey the schema", + "kind": "other", + "status": "pending", + "rawInput": { "_toolName": "task", "prompt": "List every table.", "description": "Survey the schema", "subagentType": "explore" } + }, + { "sessionUpdate": "tool_call_update", "toolCallId": "replay-0-3", "status": "completed", "rawOutput": { "durationMs": 1200, "isBackground": false } }, + { + "sessionUpdate": "subagent_spawned", + "subagentSessionId": "7d3c0f6e-2b1a-4e8f-9c5d-6a4b3e2f1d0c", + "name": "explore", + "task": "Survey the schema", + "capabilities": {}, + "_meta": { "cursor": { "toolCallId": "replay-0-3", "agentId": "7d3c0f6e-2b1a-4e8f-9c5d-6a4b3e2f1d0c" } } + }, + { + "sessionUpdate": "subagent_state_update", + "subagentSessionId": "7d3c0f6e-2b1a-4e8f-9c5d-6a4b3e2f1d0c", + "state": "completed", + "_meta": { "cursor": { "toolCallId": "replay-0-3", "agentId": "7d3c0f6e-2b1a-4e8f-9c5d-6a4b3e2f1d0c" } } + }, + { "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "Postgres it is." } } + ], + "available_commands": { + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { "name": "copy-request-id", "description": "Copy the last request ID to clipboard" }, + { "name": "review", "description": "Review the current diff" } + ] + }, + "turn_tools": [ + { "sessionUpdate": "agent_thought_chunk", "content": { "type": "text", "text": "Check the build first." } }, + { + "sessionUpdate": "tool_call", + "toolCallId": "call_shell_1", + "title": "`cargo build`", + "kind": "execute", + "status": "pending", + "rawInput": { "command": "cargo build" } + }, + { "sessionUpdate": "tool_call_update", "toolCallId": "call_shell_1", "status": "in_progress" }, + { + "sessionUpdate": "tool_call_update", + "toolCallId": "call_shell_1", + "status": "completed", + "rawOutput": { "exitCode": 101, "stdout": "", "stderr": "error[E0425]: cannot find value `x`" } + }, + { + "sessionUpdate": "tool_call", + "toolCallId": "call_edit_1", + "title": "Edit `src/lib.rs`", + "kind": "edit", + "status": "pending", + "rawInput": { "path": "src/lib.rs" }, + "locations": [ { "path": "src/lib.rs" } ] + }, + { "sessionUpdate": "tool_call_update", "toolCallId": "call_edit_1", "status": "in_progress" }, + { + "sessionUpdate": "tool_call_update", + "toolCallId": "call_edit_1", + "status": "completed", + "content": [ { "type": "diff", "path": "src/lib.rs", "oldText": "let y = x;\n", "newText": "let x = 1;\nlet y = x;\n" } ] + }, + { + "sessionUpdate": "tool_call", + "toolCallId": "call_mcp_1", + "title": "github: create_issue", + "kind": "other", + "status": "pending", + "rawInput": { "providerIdentifier": "github", "toolName": "create_issue", "args": { "title": "Build broke" } } + }, + { "sessionUpdate": "tool_call_update", "toolCallId": "call_mcp_1", "status": "in_progress" }, + { "sessionUpdate": "tool_call_update", "toolCallId": "call_mcp_1", "status": "completed", "rawOutput": { "rejected": true } }, + { + "sessionUpdate": "tool_call", + "toolCallId": "call_grep_1", + "title": "grep \"fn main\"", + "kind": "search", + "status": "pending", + "rawInput": { "pattern": "fn main", "path": "." } + }, + { "sessionUpdate": "tool_call_update", "toolCallId": "call_grep_1", "status": "completed", "rawOutput": { "totalMatches": 1, "truncated": false } }, + { "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "The build said \"Error: cannot find value\"; " } }, + { "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "I declared it." } } + ], + "run_error": { + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": "\n\nError: ConnectError: [unavailable] upstream connect error" } + }, + "ask_question": { + "toolCallId": "call_ask_1", + "title": "Storage", + "questions": [ + { + "id": "db", + "prompt": "Which database?", + "options": [ { "id": "pg", "label": "Postgres" }, { "id": "lite", "label": "SQLite" } ], + "allowMultiple": false + }, + { + "id": "extras", + "prompt": "Which extras?", + "options": [ { "id": "cache", "label": "Cache" }, { "id": "queue", "label": "Queue" }, { "id": "search", "label": "Search" } ], + "allowMultiple": true + } + ] + }, + "create_plan_entries": { + "sessionUpdate": "plan", + "entries": [ + { "content": "Add the migration", "priority": "medium", "status": "pending" }, + { "content": "Wire the repository", "priority": "medium", "status": "pending" } + ] + }, + "create_plan": { + "toolCallId": "call_plan_1", + "name": "Storage", + "overview": "Move persistence to Postgres.", + "plan": "# Storage\n\n1. Add the migration\n2. Wire the repository\n", + "todos": [ + { "id": "t1", "content": "Add the migration", "status": "pending" }, + { "id": "t2", "content": "Wire the repository", "status": "pending" } + ] + }, + "update_todos": { + "toolCallId": "call_todos_1", + "todos": [ + { "id": "t1", "content": "Add the migration", "status": "in_progress" }, + { "id": "t2", "content": "Wire the repository", "status": "pending" }, + { "id": "t3", "content": "Drop SQLite", "status": "pending" } + ], + "merge": false + }, + "update_todos_merge": { + "toolCallId": "call_todos_2", + "todos": [ + { "id": "t1", "content": "Add the migration", "status": "completed" }, + { "id": "t3", "content": "Drop SQLite", "status": "cancelled" } + ], + "merge": true + }, + "permission_read": { + "toolCall": { + "toolCallId": "call_read_1", + "title": "Read `/etc/hosts`", + "kind": "read", + "status": "pending" + }, + "options": [ + { "optionId": "allow-once", "name": "Allow once", "kind": "allow_once" }, + { "optionId": "allow-always", "name": "Allow always", "kind": "allow_always" }, + { "optionId": "reject-once", "name": "Reject", "kind": "reject_once" } + ] + }, + "permission_edit": { + "toolCall": { + "toolCallId": "call_write_1", + "title": "Edit `src/lib.rs`", + "kind": "edit", + "status": "pending", + "content": [ { "type": "diff", "path": "src/lib.rs", "oldText": "a\n", "newText": "b\n" } ] + }, + "options": [ + { "optionId": "allow-once", "name": "Allow once", "kind": "allow_once" }, + { "optionId": "allow-always", "name": "Allow always", "kind": "allow_always" }, + { "optionId": "reject-once", "name": "Reject", "kind": "reject_once" } + ] + }, + "permission_shell": { + "toolCall": { + "toolCallId": "call_rm_1", + "title": "`rm -rf target`", + "kind": "execute", + "status": "pending" + }, + "options": [ + { "optionId": "allow-once", "name": "Allow once", "kind": "allow_once" }, + { "optionId": "allow-always", "name": "Allow always", "kind": "allow_always" }, + { "optionId": "reject-once", "name": "Reject", "kind": "reject_once" } + ] + }, + "subagent_turn": [ + { + "sessionId": "4f1b6a52-8e0c-4c1d-9a57-2d3b1e7c9a10", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "call_task_1", + "title": "Task: Survey the schema", + "kind": "other", + "status": "pending", + "rawInput": { "_toolName": "task", "prompt": "List every table.", "description": "Survey the schema", "subagentType": "explore" } + } + }, + { + "sessionId": "4f1b6a52-8e0c-4c1d-9a57-2d3b1e7c9a10", + "update": { "sessionUpdate": "tool_call_update", "toolCallId": "call_task_1", "status": "in_progress" } + }, + { + "sessionId": "4f1b6a52-8e0c-4c1d-9a57-2d3b1e7c9a10", + "update": { + "sessionUpdate": "subagent_spawned", + "subagentSessionId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", + "name": "explore", + "task": "List every table.", + "capabilities": {}, + "_meta": { "cursor": { "toolCallId": "call_task_1", "agentId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", "model": "composer-1" } } + } + }, + { + "sessionId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", + "update": { "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "Looking at " } } + }, + { + "sessionId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", + "update": { "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "the migrations." } } + }, + { + "sessionId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "call_shell_1", + "title": "`ls migrations`", + "kind": "execute", + "status": "pending", + "rawInput": { "command": "ls migrations" } + } + }, + { + "sessionId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call_shell_1", + "status": "completed", + "rawOutput": { "exitCode": 0, "stdout": "0001_init.sql\n", "stderr": "" } + } + }, + { + "sessionId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", + "update": { "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "One table: users." } } + }, + { + "sessionId": "4f1b6a52-8e0c-4c1d-9a57-2d3b1e7c9a10", + "update": { + "sessionUpdate": "subagent_state_update", + "subagentSessionId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", + "state": "completed", + "_meta": { "cursor": { "toolCallId": "call_task_1", "agentId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", "model": "composer-1" } } + } + }, + { + "sessionId": "4f1b6a52-8e0c-4c1d-9a57-2d3b1e7c9a10", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call_task_1", + "status": "completed", + "rawOutput": { "durationMs": 4200, "isBackground": false } + } + }, + { + "sessionId": "4f1b6a52-8e0c-4c1d-9a57-2d3b1e7c9a10", + "update": { "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "The schema has one table." } } + } + ], + "task": { + "toolCallId": "call_task_1", + "description": "Survey the schema", + "prompt": "List every table.", + "subagentType": "explore", + "model": "composer-1", + "agentId": "9e8d7c6b-5a49-4382-a1b0-c9d8e7f6a5b4", + "durationMs": 4200 + } +} diff --git a/crates/agent/tests/fixtures/cursor/status_signed_in.json b/crates/agent/tests/fixtures/cursor/status_signed_in.json new file mode 100644 index 000000000..a0d180298 --- /dev/null +++ b/crates/agent/tests/fixtures/cursor/status_signed_in.json @@ -0,0 +1,10 @@ +{ + "status": "authenticated", + "isAuthenticated": true, + "hasAccessToken": true, + "hasRefreshToken": true, + "userInfo": { + "email": "dev@example.com", + "userId": 4242 + } +} diff --git a/crates/agent/tests/fixtures/cursor/status_signed_out.json b/crates/agent/tests/fixtures/cursor/status_signed_out.json new file mode 100644 index 000000000..5c99c62bc --- /dev/null +++ b/crates/agent/tests/fixtures/cursor/status_signed_out.json @@ -0,0 +1,7 @@ +{ + "status": "unauthenticated", + "isAuthenticated": false, + "hasAccessToken": false, + "hasRefreshToken": false, + "message": "Not logged in" +} diff --git a/crates/agent/tests/fixtures/grok/README.md b/crates/agent/tests/fixtures/grok/README.md new file mode 100644 index 000000000..9d9ea7192 --- /dev/null +++ b/crates/agent/tests/fixtures/grok/README.md @@ -0,0 +1,75 @@ +# Grok session fixtures + +Each fixture is one Grok Build session recorded on the wire in both +directions, in wire order, untruncated: one JSON object per line, +`{"from": "client" | "agent", "message": }`. + +## `resumed_turn.jsonl` + +### Provenance + +- CLI: `grok 1.0.46 (2765805b9442)`, darwin-arm64, in a throwaway `HOME` and + `GROK_HOME`. +- Model: a local scripted stand-in for the xAI Responses API + (`GROK_XAI_API_BASE_URL` / `GROK_CLI_CHAT_PROXY_BASE_URL` pointing at it, + `XAI_API_KEY` a placeholder). No xAI account or live model took part; every + model turn below was scripted, and each model call reports 1,200 input + tokens (1,000 cached) and 34 output tokens. +- Client: Tcode's production session path, through the probe: + + ```sh + cargo run -p agent --example probe -- grok "Count, edit, ask and echo" auto_edits \ + --resume '{"session_id":"01a10095-e65c-7983-a817-6884b010f1ae"}' --effort low \ + --mcp tcode_probe http://127.0.0.1:18433/mcp tcode-secret + ``` + + with a pass-through recorder as `grok` on `PATH`. Grok was launched as + `grok --permission-mode default agent --reasoning-effort low stdio`. + +### Scenario + +An earlier process created the session with one turn; it is not recorded. +This process resumes it (`session/resume`, then `session/set_mode default`) +and runs one turn whose scripted model calls are: + +1. a reasoning summary, then `run_terminal_command` printing three lines one + second apart. Grok asks permission; the probe approves once. +2. `run_terminal_command` `touch quiet.txt`, which Grok runs unprompted and + which prints nothing. +3. `search_replace` in `in.txt`. Grok asks permission; Tcode approves it once + itself (AutoAcceptEdits). +4. `ask_user_question` with two options; the probe answers with the first. +5. `use_tool` calling `echo_upper` on a local HTTP MCP server registered as + `tcode_probe` with a bearer header. Grok asks permission; the probe + approves once. +6. A final message. + +The probe then shuts the session down (`session/close`). + +### Sanitization + +The `initialize` result's `hostname` is `fixture-host` and its `agentId` and +`agentInstanceId` are zero UUIDs; the scratch directory is renamed to +`/tmp/grok-fixture` in all its forms (`/private/tmp/…`, URL-encoded). Nothing +else was changed. The `/Users/admin/actions-runner/…` paths are Grok's own +built-in workflow metadata. + +## `agent_started_turn.jsonl` + +Same CLI, backend and recorder as above, through the probe: + +```sh +cargo run -p agent --example probe -- grok "Background then foreground" full_access \ + --linger 3 --follow-up 4 "And now?" +``` + +Grok was launched as `grok --permission-mode bypassPermissions agent stdio`. +A new session runs one sent turn whose scripted model calls start +`sleep 1; echo bgdone` in the background (`block_until_ms: 0`), run +`sleep 3; echo fg` in the foreground, and answer. The background task +finishes during that turn, so as soon as the turn ends Grok runs a prompt of +its own, `task-completed-` (`_x.ai/queue/changed` reports it running +before the sent turn's `session/prompt` result arrives), and the scripted +model answers it. Four seconds after the first turn completed, the probe sends +a second turn, which the scripted model answers; the probe then closes the +session. Sanitized as above. diff --git a/crates/agent/tests/fixtures/grok/agent_started_turn.jsonl b/crates/agent/tests/fixtures/grok/agent_started_turn.jsonl new file mode 100644 index 000000000..6908dcfa3 --- /dev/null +++ b/crates/agent/tests/fixtures/grok/agent_started_turn.jsonl @@ -0,0 +1,89 @@ +{"from":"client","message":{"jsonrpc":"2.0","id":"11ba930d-a59e-4559-bc51-0ad12db9e1e3","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"auth":{"terminal":false}},"clientInfo":{"name":"tcode","title":"Tcode","version":"0.1.0"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"11ba930d-a59e-4559-bc51-0ad12db9e1e3","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true},"sessionCapabilities":{"list":{},"resume":{},"close":{}},"auth":{},"_meta":{"x.ai/fs_notify":true,"x.ai/hooks":{"blockingEvents":["pre_tool_use","stop","subagent_stop"],"decisions":["deny","block"],"stopSignals":["continue","stopReason","additionalContext"]},"x.ai/capabilities":{"toolOverrides":{"x_keyword_search":true,"x_semantic_search":true,"x_user_search":false,"x_thread_fetch":false}}}},"authMethods":[{"id":"xai.api_key","name":"xai.api_key","description":"XAI_API_KEY or api_key/env_key in config.toml"},{"id":"grok.com","name":"Grok","description":"Sign in with Grok"}],"_meta":{"grokShell":true,"defaultAuthMethodId":"xai.api_key","x.ai/mcp/sdk":true,"x.ai/pluginDirs":true,"currentWorkingDirectory":"/private/tmp/grok-fixture/cwork","agentVersion":"1.0.46","agentId":"00000000-0000-0000-0000-000000000000","agentInstanceId":"00000000-0000-0000-0000-000000000000","hostname":"fixture-host","modelState":{"currentModelId":"grok-4.6","availableModels":[{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High Effort","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High Effort","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium Effort","description":"Balanced effort","default":false},{"id":"low","value":"low","label":"Low Effort","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan"}}]},"mcpServers":[],"mcpApps":false,"metadata":null,"availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}}],"cancelRewind":true,"sessionRecap":true,"feedbackTraceOffer":false,"voiceMode":true}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/mcp/servers_updated","params":{"mcpServers":[]}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"2232a326-e5f6-41d0-9a1a-ffc78d098230","method":"authenticate","params":{"methodId":"xai.api_key"}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"2232a326-e5f6-41d0-9a1a-ffc78d098230","result":{}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"68966399-2fd4-49a6-bb17-57e9e932c5cf","method":"session/new","params":{"cwd":"/tmp/grok-fixture/cwork","mcpServers":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"auth","sessionId":null}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"resolve_workspace","sessionId":null}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"folder_trust","sessionId":null}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"plugin_registry","sessionId":null}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"mcp_merge","sessionId":null}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"persistence_init","sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"spawn_session_actor","sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"agent_build","sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155"}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"68966399-2fd4-49a6-bb17-57e9e932c5cf","result":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","models":{"currentModelId":"grok-4.6","availableModels":[{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High Effort","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High Effort","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium Effort","description":"Balanced effort","default":false},{"id":"low","value":"low","label":"Low Effort","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan"}}]},"configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"grok-4.6","options":[{"value":"grok-4.6","name":"Grok 4.6"},{"value":"grok-4.5","name":"Grok 4.5"}]},{"id":"reasoning_effort","name":"Reasoning Effort","category":"thought_level","type":"select","currentValue":"high","options":[{"value":"xhigh","name":"Extra High Effort","description":"Highest effort and reasoning level"},{"value":"high","name":"High Effort","description":"Higher implementation quality with extensive reasoning"},{"value":"medium","name":"Medium Effort","description":"Balanced effort"},{"value":"low","name":"Low Effort","description":"Quick, fast implementations"}]}],"_meta":{"currentWorkingDirectory":"/tmp/grok-fixture/cwork","codebaseIndexed":[],"isGitRepo":false,"gitRoot":null,"showNonGitWarning":false,"feedbackEnabled":true,"x.ai/sessionConfig":{"options":[{"id":"grok-4.6","category":"model","label":"Grok 4.6","selected":true},{"id":"grok-4.5","category":"model","label":"Grok 4.5","selected":false},{"id":"xhigh","category":"mode","label":"Extra High Effort","description":"Highest effort and reasoning level","selected":false},{"id":"high","category":"mode","label":"High Effort","description":"Higher implementation quality with extensive reasoning","selected":true},{"id":"medium","category":"mode","label":"Medium Effort","description":"Balanced effort","selected":false},{"id":"low","category":"mode","label":"Low Effort","description":"Quick, fast implementations","selected":false}]},"x.ai/sessionDetail":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","kind":"build","cwd":"/tmp/grok-fixture/cwork","currentModelId":"grok-4.6"},"x.ai/memoryMode":"legacy"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"git_discovery","sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"finalize_response","sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"tool_overrides","sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/setup","params":{"method":"session/new","phase":"response_ready","sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/mcp_initialized","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","mcpToolCount":0,"elapsedMs":0}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"f0494724-593c-4d5c-847d-882a6a7952ff","method":"session/prompt","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","prompt":[{"type":"text","text":"Background then foreground"}]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1358,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-0","agentTimestampMs":1791014090576,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1358,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-1","agentTimestampMs":1791014090580,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","title":null,"cwd":"/tmp/grok-fixture/cwork","isWorktree":false,"modelId":"grok-4.6","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1791014090580,"origin":{"kind":"local"}}],"removed":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","entries":[{"id":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","version":0,"kind":"prompt","text":"Background then foreground","position":0}]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","entries":[],"runningPromptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","runningText":"Background then foreground","runningKind":"prompt"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"session_summary_generated","session_summary":"Mock title"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"session_info_update","title":"Mock title"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call_68_0","tool_index":0,"name":"run_terminal_command"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":0,"arguments_delta":"{\"command\":\"sleep 1; echo bgdone\",\"description\":\"Background sleep\",\"block_until_ms\":0}"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-4","agentTimestampMs":1791014090679,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014090677,"turnStartMs":1791014090656,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call","toolCallId":"call_68_0","title":"run_terminal_command","rawInput":{"command":"sleep 1; echo bgdone","description":"Background sleep","block_until_ms":0},"_meta":{"x.ai/tool":{"version":1,"name":"run_terminal_command","kind":"execute","namespace":"grok_build","label":"Run Command","read_only":false}}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-5","agentTimestampMs":1791014090679,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014090677,"turnStartMs":1791014090656,"updateType":"ToolCall","updateParams":{"toolCallId":"call_68_0","title":"run_terminal_command","kind":"Other","status":"Pending"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call_68_0","kind":"permission"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_68_0","kind":"execute","title":"Execute `sleep 1; echo bgdone`","content":[{"type":"content","content":{"type":"text","text":"Background sleep"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 1; echo bgdone","description":"Background sleep","is_background":false,"block_until_ms":0},"_meta":{"x.ai/tool":{"version":1,"name":"run_terminal_command","kind":"execute","namespace":"grok_build","label":"Run Command","read_only":false,"input":{"command":"sleep 1; echo bgdone","description":"Background sleep"}}}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-6","agentTimestampMs":1791014090679,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014090677,"turnStartMs":1791014090656,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_68_0","status":null}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call_68_0"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/task_backgrounded","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"task_backgrounded","tool_call_id":"call_68_0","task_id":"01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","command":"sleep 1; echo bgdone","cwd":"/tmp/grok-fixture/cwork","output_file":"/tmp/grok-fixture/ng/home/.grok/sessions/%2Ftmp%2Fgrok-fixture%2Fcwork/01a100c2-3711-75f1-93b7-1d55dd741155/terminal/call_68_0.log","description":"Background sleep"},"_meta":{"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-7","agentTimestampMs":1791014090693}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"background_tasks","tasks":[{"task_id":"01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","command":"sleep 1; echo bgdone","description":"Background sleep","cwd":"/tmp/grok-fixture/cwork","kind":"bash","status":"running","started_at":"2026-10-03T07:54:50.693430+00:00","output_file":"/tmp/grok-fixture/ng/home/.grok/sessions/%2Ftmp%2Fgrok-fixture%2Fcwork/01a100c2-3711-75f1-93b7-1d55dd741155/terminal/call_68_0.log"}]},"_meta":{"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-9","agentTimestampMs":1791014090693}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_68_0","status":"completed","title":"[bg] sleep 1; echo bgdone (01a100c2)","content":[{"type":"content","content":{"type":"text","text":"Background task 01a100c2-37c5-7fc2-b92a-c0f8b2c3659b started"}}],"rawOutput":{"type":"BackgroundTaskStarted","task_id":"01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","task_type":"bash","output_file":"/tmp/grok-fixture/ng/home/.grok/sessions/%2Ftmp%2Fgrok-fixture%2Fcwork/01a100c2-3711-75f1-93b7-1d55dd741155/terminal/call_68_0.log","status":"running","command":"sleep 1; echo bgdone","summary":"Background task 01a100c2-37c5-7fc2-b92a-c0f8b2c3659b started","retrieval_hint":"Use get_command_or_subagent_output with task_ids=[\"01a100c2-37c5-7fc2-b92a-c0f8b2c3659b\"] when you need the output.","pid":67305}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-10","agentTimestampMs":1791014090697,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014090677,"turnStartMs":1791014090656,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_68_0","status":"Completed"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call_69_0","tool_index":0,"name":"run_terminal_command"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":0,"arguments_delta":"{\"command\":\"sleep 3; echo fg\",\"description\":\"Foreground sleep\"}"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-13","agentTimestampMs":1791014090712,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014090709,"turnStartMs":1791014090656,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call","toolCallId":"call_69_0","title":"run_terminal_command","rawInput":{"command":"sleep 3; echo fg","description":"Foreground sleep"},"_meta":{"x.ai/tool":{"version":1,"name":"run_terminal_command","kind":"execute","namespace":"grok_build","label":"Run Command","read_only":false}}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-14","agentTimestampMs":1791014090715,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014090709,"turnStartMs":1791014090656,"updateType":"ToolCall","updateParams":{"toolCallId":"call_69_0","title":"run_terminal_command","kind":"Other","status":"Pending"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call_69_0","kind":"permission"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_69_0","kind":"execute","title":"Execute `sleep 3; echo fg`","content":[{"type":"content","content":{"type":"text","text":"Foreground sleep"}}],"locations":[],"rawInput":{"variant":"Bash","command":"sleep 3; echo fg","description":"Foreground sleep","is_background":false},"_meta":{"x.ai/tool":{"version":1,"name":"run_terminal_command","kind":"execute","namespace":"grok_build","label":"Run Command","read_only":false,"input":{"command":"sleep 3; echo fg","description":"Foreground sleep"}}}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-15","agentTimestampMs":1791014090715,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014090709,"turnStartMs":1791014090656,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_69_0","status":null}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call_69_0"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_69_0","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":""}}],"rawOutput":{"type":"Bash","output":[],"output_for_prompt":"","exit_code":0,"command":"sleep 3; echo fg","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/tmp/grok-fixture/cwork","output_file":"","total_bytes":0}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_68_0","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":"bgdone\n"}}],"rawOutput":{"type":"Bash","output":[98,103,100,111,110,101,10],"output_for_prompt":"bgdone\n","exit_code":0,"command":"sleep 1; echo bgdone","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/tmp/grok-fixture/cwork","output_file":"","total_bytes":7}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","entries":[],"runningPromptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","runningText":"Background then foreground","runningKind":"prompt"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/task_completed","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"task_completed","task_snapshot":{"task_id":"01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","command":"sleep 1; echo bgdone","cwd":"/tmp/grok-fixture/cwork","start_time":{"secs_since_epoch":1791014090,"nanos_since_epoch":693430000},"end_time":{"secs_since_epoch":1791014091,"nanos_since_epoch":713158000},"output":"bgdone\n","output_file":"/tmp/grok-fixture/ng/home/.grok/sessions/%2Ftmp%2Fgrok-fixture%2Fcwork/01a100c2-3711-75f1-93b7-1d55dd741155/terminal/call_68_0.log","truncated":false,"output_total_bytes":7,"exit_code":0,"signal":null,"completed":true,"kind":"bash","block_waited":false,"explicitly_killed":false,"kill_result_delivered":false,"owner_session_id":"01a100c2-3711-75f1-93b7-1d55dd741155","description":"Background sleep","is_backgrounded":true},"will_wake":true},"_meta":{"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-16","agentTimestampMs":1791014091757}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"background_tasks","tasks":[{"task_id":"01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","command":"sleep 1; echo bgdone","description":"Background sleep","cwd":"/tmp/grok-fixture/cwork","kind":"bash","status":"completed","started_at":"2026-10-03T07:54:50.693430+00:00","ended_at":"2026-10-03T07:54:51.713158+00:00","output_file":"/tmp/grok-fixture/ng/home/.grok/sessions/%2Ftmp%2Fgrok-fixture%2Fcwork/01a100c2-3711-75f1-93b7-1d55dd741155/terminal/call_68_0.log","exit_code":0}]},"_meta":{"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-18","agentTimestampMs":1791014091758}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_69_0","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":"fg\n"}}],"rawOutput":{"type":"Bash","output":[102,103,10],"output_for_prompt":"fg\n","exit_code":0,"command":"sleep 3; echo fg","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/tmp/grok-fixture/cwork","output_file":"","total_bytes":3}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_69_0","status":"completed","content":[{"type":"content","content":{"type":"text","text":"fg\n"}}],"rawOutput":{"type":"Bash","output":[102,103,10],"output_for_prompt":"exit: 0\nfg\n","exit_code":0,"command":"sleep 3; echo fg","truncated":false,"signal":null,"timed_out":false,"description":"Foreground sleep","current_dir":"/tmp/grok-fixture/cwork","output_file":"/tmp/grok-fixture/ng/home/.grok/sessions/%2Ftmp%2Fgrok-fixture%2Fcwork/01a100c2-3711-75f1-93b7-1d55dd741155/terminal/call_69_0.log","total_bytes":3}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-19","agentTimestampMs":1791014093746,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014090709,"turnStartMs":1791014090656,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_69_0","status":"Completed"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"User tu"}},"_meta":{"totalTokens":1236,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-22","agentTimestampMs":1791014093763,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014093763,"turnStartMs":1791014090656,"updateType":"AgentMessageChunk","chunkId":1}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"rn done."}},"_meta":{"totalTokens":1236,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-23","agentTimestampMs":1791014093823,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014093763,"turnStartMs":1791014090656,"updateType":"AgentMessageChunk","chunkId":2}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-24","agentTimestampMs":1791014093880,"promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","streamStartMs":1791014093763,"turnStartMs":1791014090656,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","entries":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"turn_completed","prompt_id":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","stop_reason":"end_turn","usage":{"inputTokens":3600,"outputTokens":102,"totalTokens":3702,"cachedReadTokens":3000,"cacheCreationTokens":0,"reasoningTokens":36,"modelCalls":3,"apiDurationMs":148,"modelUsage":{"grok-4.6":{"inputTokens":3600,"outputTokens":102,"totalTokens":3702,"cachedReadTokens":3000,"cacheCreationTokens":0,"reasoningTokens":36,"modelCalls":3,"apiDurationMs":148}},"numTurns":3},"elapsed_ms":3358},"_meta":{"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-27","agentTimestampMs":1791014093942}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","entries":[],"runningPromptId":"task-completed-01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","runningText":"\nBackground task \"01a100c2-37c5-7fc2-b92a-c0f8b2c3659b\" completed (exit code: 0).\nDescription: Background sleep | Duration: 1.0s\nUse get_command_or_subagent_output(\"01a100c2-37c5-7fc2-b92a-c0f8b2c3659b\") to see the full output.\n","runningKind":"prompt"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/prompt_complete","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","stopReason":"end_turn","agentResult":null}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","title":null,"cwd":"/tmp/grok-fixture/cwork","isWorktree":false,"modelId":"grok-4.6","reasoningEffort":"high","yolo":true,"activity":"idle","resident":true,"lastChangeUnixMs":1791014093942,"origin":{"kind":"local"}}],"removed":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"f0494724-593c-4d5c-847d-882a6a7952ff","result":{"stopReason":"end_turn","_meta":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","requestId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","promptId":"a2b0d8eb-6e7f-4ed4-a968-11b9486f5c20","totalTokens":1234,"modelId":"grok-4.6","inputTokens":1200,"outputTokens":34,"cachedReadTokens":1000,"reasoningTokens":12,"usage":{"inputTokens":3600,"outputTokens":102,"totalTokens":3702,"cachedReadTokens":3000,"cacheCreationTokens":0,"reasoningTokens":36,"modelCalls":3,"apiDurationMs":148,"modelUsage":{"grok-4.6":{"inputTokens":3600,"outputTokens":102,"totalTokens":3702,"cachedReadTokens":3000,"cacheCreationTokens":0,"reasoningTokens":36,"modelCalls":3,"apiDurationMs":148}},"numTurns":3}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The background ta"}},"_meta":{"totalTokens":1299,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-30","agentTimestampMs":1791014093987,"promptId":"task-completed-01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","streamStartMs":1791014093987,"turnStartMs":1791014093983,"updateType":"AgentMessageChunk","chunkId":1}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"sk printed bgdone."}},"_meta":{"totalTokens":1299,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-31","agentTimestampMs":1791014094045,"promptId":"task-completed-01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","streamStartMs":1791014093987,"turnStartMs":1791014093983,"updateType":"AgentMessageChunk","chunkId":2}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-32","agentTimestampMs":1791014094105,"promptId":"task-completed-01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","streamStartMs":1791014093987,"turnStartMs":1791014093983,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"turn_completed","prompt_id":"task-completed-01a100c2-37c5-7fc2-b92a-c0f8b2c3659b","stop_reason":"end_turn","usage":{"inputTokens":1200,"outputTokens":34,"totalTokens":1234,"cachedReadTokens":1000,"cacheCreationTokens":0,"reasoningTokens":12,"modelCalls":1,"apiDurationMs":120,"modelUsage":{"grok-4.6":{"inputTokens":1200,"outputTokens":34,"totalTokens":1234,"cachedReadTokens":1000,"cacheCreationTokens":0,"reasoningTokens":12,"modelCalls":1,"apiDurationMs":120}},"numTurns":1},"elapsed_ms":227},"_meta":{"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-35","agentTimestampMs":1791014094169}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"last_turn_summary","summary":"Aux title","prompt_id":"task-completed-01a100c2-37c5-7fc2-b92a-c0f8b2c3659b"},"_meta":{"agentTimestampMs":1791014094286}}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"e32888c3-3a4a-4751-8a2c-f5081ea45849","method":"session/prompt","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","prompt":[{"type":"text","text":"And now?"}]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","title":null,"cwd":"/tmp/grok-fixture/cwork","isWorktree":false,"modelId":"grok-4.6","reasoningEffort":"high","yolo":true,"activity":"working","resident":true,"lastChangeUnixMs":1791014097953,"origin":{"kind":"local"}}],"removed":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","entries":[{"id":"76953359-ff4d-4396-97ac-696acb050772","version":0,"kind":"prompt","text":"And now?","position":0}]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","entries":[],"runningPromptId":"76953359-ff4d-4396-97ac-696acb050772","runningText":"And now?","runningKind":"prompt"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Follow-u"}},"_meta":{"totalTokens":1242,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-38","agentTimestampMs":1791014097987,"promptId":"76953359-ff4d-4396-97ac-696acb050772","streamStartMs":1791014097987,"turnStartMs":1791014097982,"updateType":"AgentMessageChunk","chunkId":1}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"p reply."}},"_meta":{"totalTokens":1242,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-39","agentTimestampMs":1791014098047,"promptId":"76953359-ff4d-4396-97ac-696acb050772","streamStartMs":1791014097987,"turnStartMs":1791014097982,"updateType":"AgentMessageChunk","chunkId":2}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":1234,"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-40","agentTimestampMs":1791014098107,"promptId":"76953359-ff4d-4396-97ac-696acb050772","streamStartMs":1791014097987,"turnStartMs":1791014097982,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","entries":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"turn_completed","prompt_id":"76953359-ff4d-4396-97ac-696acb050772","stop_reason":"end_turn","usage":{"inputTokens":1200,"outputTokens":34,"totalTokens":1234,"cachedReadTokens":1000,"cacheCreationTokens":0,"reasoningTokens":12,"modelCalls":1,"apiDurationMs":123,"modelUsage":{"grok-4.6":{"inputTokens":1200,"outputTokens":34,"totalTokens":1234,"cachedReadTokens":1000,"cacheCreationTokens":0,"reasoningTokens":12,"modelCalls":1,"apiDurationMs":123}},"numTurns":1},"elapsed_ms":214},"_meta":{"eventId":"01a100c2-3711-75f1-93b7-1d55dd741155-43","agentTimestampMs":1791014098168}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/prompt_complete","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","promptId":"76953359-ff4d-4396-97ac-696acb050772","stopReason":"end_turn","agentResult":null}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","title":null,"cwd":"/tmp/grok-fixture/cwork","isWorktree":false,"modelId":"grok-4.6","reasoningEffort":"high","yolo":true,"activity":"idle","resident":true,"lastChangeUnixMs":1791014098169,"origin":{"kind":"local"}}],"removed":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"e32888c3-3a4a-4751-8a2c-f5081ea45849","result":{"stopReason":"end_turn","_meta":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","requestId":"76953359-ff4d-4396-97ac-696acb050772","promptId":"76953359-ff4d-4396-97ac-696acb050772","totalTokens":1234,"modelId":"grok-4.6","inputTokens":1200,"outputTokens":34,"cachedReadTokens":1000,"reasoningTokens":12,"usage":{"inputTokens":1200,"outputTokens":34,"totalTokens":1234,"cachedReadTokens":1000,"cacheCreationTokens":0,"reasoningTokens":12,"modelCalls":1,"apiDurationMs":123,"modelUsage":{"grok-4.6":{"inputTokens":1200,"outputTokens":34,"totalTokens":1234,"cachedReadTokens":1000,"cacheCreationTokens":0,"reasoningTokens":12,"modelCalls":1,"apiDurationMs":123}},"numTurns":1}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"last_turn_summary","summary":"Aux title","prompt_id":"76953359-ff4d-4396-97ac-696acb050772"},"_meta":{"agentTimestampMs":1791014098280}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"session_summary_generated","session_summary":"Aux title"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155","update":{"sessionUpdate":"session_info_update","title":"Aux title"}}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"1c82d47f-06e3-4730-82d2-139c1995e8e7","method":"session/close","params":{"sessionId":"01a100c2-3711-75f1-93b7-1d55dd741155"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[],"removed":["01a100c2-3711-75f1-93b7-1d55dd741155"]}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"1c82d47f-06e3-4730-82d2-139c1995e8e7","result":{"_meta":{"x.ai/closeOutcome":"closed"}}}} diff --git a/crates/agent/tests/fixtures/grok/resumed_turn.jsonl b/crates/agent/tests/fixtures/grok/resumed_turn.jsonl new file mode 100644 index 000000000..a68abc465 --- /dev/null +++ b/crates/agent/tests/fixtures/grok/resumed_turn.jsonl @@ -0,0 +1,95 @@ +{"from":"client","message":{"jsonrpc":"2.0","id":"bfa3a825-2220-4cfa-8da8-d43ddd241a0b","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"auth":{"terminal":false}},"clientInfo":{"name":"tcode","title":"Tcode","version":"0.1.0"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"bfa3a825-2220-4cfa-8da8-d43ddd241a0b","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true},"sessionCapabilities":{"list":{},"resume":{},"close":{}},"auth":{},"_meta":{"x.ai/fs_notify":true,"x.ai/hooks":{"blockingEvents":["pre_tool_use","stop","subagent_stop"],"decisions":["deny","block"],"stopSignals":["continue","stopReason","additionalContext"]},"x.ai/capabilities":{"toolOverrides":{"x_keyword_search":true,"x_semantic_search":true,"x_user_search":false,"x_thread_fetch":false}}}},"authMethods":[{"id":"xai.api_key","name":"xai.api_key","description":"XAI_API_KEY or api_key/env_key in config.toml"},{"id":"grok.com","name":"Grok","description":"Sign in with Grok"}],"_meta":{"grokShell":true,"defaultAuthMethodId":"xai.api_key","x.ai/mcp/sdk":true,"x.ai/pluginDirs":true,"currentWorkingDirectory":"/private/tmp/grok-fixture/cwork","agentVersion":"1.0.46","agentId":"00000000-0000-0000-0000-000000000000","agentInstanceId":"00000000-0000-0000-0000-000000000000","hostname":"fixture-host","modelState":{"currentModelId":"grok-4.6","availableModels":[{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"low","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High Effort","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High Effort","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium Effort","description":"Balanced effort","default":false},{"id":"low","value":"low","label":"Low Effort","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan"}}]},"mcpServers":[],"mcpApps":false,"metadata":null,"availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}}],"cancelRewind":true,"sessionRecap":true,"feedbackTraceOffer":false,"voiceMode":true}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/mcp/servers_updated","params":{"mcpServers":[]}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"c4c00abd-59ac-4ec0-88f4-5961604d10d9","method":"authenticate","params":{"methodId":"xai.api_key"}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"c4c00abd-59ac-4ec0-88f4-5961604d10d9","result":{}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"719e6cdf-86e3-45e8-9956-04007a0b87a0","method":"session/resume","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","cwd":"/tmp/grok-fixture/cwork","mcpServers":[{"type":"http","name":"tcode_probe","url":"http://127.0.0.1:18433/mcp","headers":[{"name":"Authorization","value":"Bearer tcode-secret"}]}]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/mcp/init_progress","params":{"total":1,"connected":0,"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write"]}},"_meta":{"totalTokens":2012,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-0","agentTimestampMs":1791011186992,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/mcp/init_progress","params":{"total":1,"connected":1,"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/mcp_initialized","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","mcpToolCount":1,"elapsedMs":15}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"background_tasks","tasks":[]},"_meta":{"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-2","agentTimestampMs":1791011187007}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"model_changed","model_id":"grok-4.6","reasoning_effort":"high"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"719e6cdf-86e3-45e8-9956-04007a0b87a0","result":{"models":{"currentModelId":"grok-4.6","availableModels":[{"modelId":"grok-4.6","name":"Grok 4.6","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"xhigh","value":"xhigh","label":"Extra High Effort","description":"Highest effort and reasoning level","default":false},{"id":"high","value":"high","label":"High Effort","description":"Higher implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium Effort","description":"Balanced effort","default":false},{"id":"low","value":"low","label":"Low Effort","description":"Quick, fast implementations","default":false}]}},{"modelId":"grok-4.5","name":"Grok 4.5","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan"}}]},"configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"grok-4.6","options":[{"value":"grok-4.6","name":"Grok 4.6"},{"value":"grok-4.5","name":"Grok 4.5"}]},{"id":"reasoning_effort","name":"Reasoning Effort","category":"thought_level","type":"select","currentValue":"high","options":[{"value":"xhigh","name":"Extra High Effort","description":"Highest effort and reasoning level"},{"value":"high","name":"High Effort","description":"Higher implementation quality with extensive reasoning"},{"value":"medium","name":"Medium Effort","description":"Balanced effort"},{"value":"low","name":"Low Effort","description":"Quick, fast implementations"}]}],"_meta":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","codebaseIndexed":[],"x.ai/sessionConfig":{"options":[{"id":"grok-4.6","category":"model","label":"Grok 4.6","selected":true},{"id":"grok-4.5","category":"model","label":"Grok 4.5","selected":false},{"id":"xhigh","category":"mode","label":"Extra High Effort","description":"Highest effort and reasoning level","selected":false},{"id":"high","category":"mode","label":"High Effort","description":"Higher implementation quality with extensive reasoning","selected":true},{"id":"medium","category":"mode","label":"Medium Effort","description":"Balanced effort","selected":false},{"id":"low","category":"mode","label":"Low Effort","description":"Quick, fast implementations","selected":false}]},"x.ai/sessionDetail":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","kind":"build","cwd":"/tmp/grok-fixture/cwork","currentModelId":"grok-4.6","title":"Mock title"},"x.ai/memoryMode":"legacy"}}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"56e9049d-5a19-49d1-98f8-948541cd67c2","method":"session/set_mode","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","modeId":"default"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write","tcode_probe__echo_upper"]}},"_meta":{"totalTokens":2012,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-3","agentTimestampMs":1791011187011,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"56e9049d-5a19-49d1-98f8-948541cd67c2","result":{}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"440d270b-6902-4b29-9ab9-572241521340","method":"session/prompt","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","prompt":[{"type":"text","text":"Count, edit, ask and echo"}]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","title":null,"cwd":"/tmp/grok-fixture/cwork","isWorktree":false,"modelId":"grok-4.6","reasoningEffort":"high","yolo":false,"activity":"working","resident":true,"lastChangeUnixMs":1791011187018,"origin":{"kind":"local"}}],"removed":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","entries":[{"id":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","version":0,"kind":"prompt","text":"Count, edit, ask and echo","position":0}]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","entries":[],"runningPromptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","runningText":"Count, edit, ask and echo","runningKind":"prompt"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/mcp/server_status","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","name":"tcode_probe","source":"local","status":"ready","reason":"initialized","tools":null}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Countin"}},"_meta":{"totalTokens":2139,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-6","agentTimestampMs":1791011187099,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011187099,"turnStartMs":1791011187089,"updateType":"AgentThoughtChunk","chunkId":1}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"g first."}},"_meta":{"totalTokens":2139,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-7","agentTimestampMs":1791011187159,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011187099,"turnStartMs":1791011187089,"updateType":"AgentThoughtChunk","chunkId":2}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call_33_1","tool_index":0,"name":"run_terminal_command"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":0,"arguments_delta":"{\"command\":\"for i in 1 2 3; do echo line$i; sleep 1; done\",\"description\":\"Print three lines slowly\"}"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write","tcode_probe__echo_upper"]}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-8","agentTimestampMs":1791011187215,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011187099,"turnStartMs":1791011187089,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call","toolCallId":"call_33_1","title":"run_terminal_command","rawInput":{"command":"for i in 1 2 3; do echo line$i; sleep 1; done","description":"Print three lines slowly"},"_meta":{"x.ai/tool":{"version":1,"name":"run_terminal_command","kind":"execute","namespace":"grok_build","label":"Run Command","read_only":false}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-9","agentTimestampMs":1791011187215,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011187099,"turnStartMs":1791011187089,"updateType":"ToolCall","updateParams":{"toolCallId":"call_33_1","title":"run_terminal_command","kind":"Other","status":"Pending"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call_33_1","kind":"permission"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_33_1","kind":"execute","title":"Execute `for i in 1 2 3; do echo line$i; sleep 1; done`","content":[{"type":"content","content":{"type":"text","text":"Print three lines slowly"}}],"locations":[],"rawInput":{"variant":"Bash","command":"for i in 1 2 3; do echo line$i; sleep 1; done","description":"Print three lines slowly","is_background":false},"_meta":{"x.ai/tool":{"version":1,"name":"run_terminal_command","kind":"execute","namespace":"grok_build","label":"Run Command","read_only":false,"input":{"command":"for i in 1 2 3; do echo line$i; sleep 1; done","description":"Print three lines slowly"}}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-10","agentTimestampMs":1791011187215,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011187099,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_33_1","status":null}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","toolCall":{"toolCallId":"call_33_1","kind":"execute","title":"Execute `for i in 1 2 3; do echo line$i; sleep 1; done`","rawInput":{"variant":"Bash","command":"for i in 1 2 3; do echo line$i; sleep 1; done","description":"Print three lines slowly","is_background":false},"_meta":{"x.ai/tool":{"version":1,"name":"run_terminal_command","kind":"execute","namespace":"grok_build","label":"Run Command","read_only":false,"input":{"command":"for i in 1 2 3; do echo line$i; sleep 1; done","description":"Print three lines slowly"}}}},"options":[{"optionId":"always-allow","name":"Yes, and don't ask again for bash commands","kind":"allow_always"},{"optionId":"allow-once","name":"Yes, proceed","kind":"allow_once"},{"optionId":"reject-once","name":"No, and tell Grok what to do differently","kind":"reject_once"},{"optionId":"reject-always","name":"No, and don't ask again for this command","kind":"reject_always"}]}}} +{"from":"client","message":{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call_33_1"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_33_1","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":""}}],"rawOutput":{"type":"Bash","output":[],"output_for_prompt":"","exit_code":0,"command":"for i in 1 2 3; do echo line$i; sleep 1; done","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/tmp/grok-fixture/cwork","output_file":"","total_bytes":0}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_33_1","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":"line1\n"}}],"rawOutput":{"type":"Bash","output":[108,105,110,101,49,10],"output_for_prompt":"line1\n","exit_code":0,"command":"for i in 1 2 3; do echo line$i; sleep 1; done","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/tmp/grok-fixture/cwork","output_file":"","total_bytes":6}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_33_1","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":"line1\nline2\n"}}],"rawOutput":{"type":"Bash","output":[108,105,110,101,49,10,108,105,110,101,50,10],"output_for_prompt":"line1\nline2\n","exit_code":0,"command":"for i in 1 2 3; do echo line$i; sleep 1; done","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/tmp/grok-fixture/cwork","output_file":"","total_bytes":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_33_1","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":"line1\nline2\nline3\n"}}],"rawOutput":{"type":"Bash","output":[108,105,110,101,49,10,108,105,110,101,50,10,108,105,110,101,51,10],"output_for_prompt":"line1\nline2\nline3\n","exit_code":0,"command":"for i in 1 2 3; do echo line$i; sleep 1; done","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/tmp/grok-fixture/cwork","output_file":"","total_bytes":18}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_33_1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"line1\nline2\nline3\n"}}],"rawOutput":{"type":"Bash","output":[108,105,110,101,49,10,108,105,110,101,50,10,108,105,110,101,51,10],"output_for_prompt":"exit: 0\nline1\nline2\nline3\n","exit_code":0,"command":"for i in 1 2 3; do echo line$i; sleep 1; done","truncated":false,"signal":null,"timed_out":false,"description":"Print three lines slowly","current_dir":"/tmp/grok-fixture/cwork","output_file":"/tmp/grok-fixture/ng/home/.grok/sessions/%2Ftmp%2Fgrok-fixture%2Fcwork/01a10095-e65c-7983-a817-6884b010f1ae/terminal/call_33_1.log","total_bytes":18}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-11","agentTimestampMs":1791011190293,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011187099,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_33_1","status":"Completed"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call_34_0","tool_index":0,"name":"run_terminal_command"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":0,"arguments_delta":"{\"command\":\"touch quiet.txt\",\"description\":\"Create an empty file\"}"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write","tcode_probe__echo_upper"]}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-14","agentTimestampMs":1791011190306,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190304,"turnStartMs":1791011187089,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call","toolCallId":"call_34_0","title":"run_terminal_command","rawInput":{"command":"touch quiet.txt","description":"Create an empty file"},"_meta":{"x.ai/tool":{"version":1,"name":"run_terminal_command","kind":"execute","namespace":"grok_build","label":"Run Command","read_only":false}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-15","agentTimestampMs":1791011190306,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190304,"turnStartMs":1791011187089,"updateType":"ToolCall","updateParams":{"toolCallId":"call_34_0","title":"run_terminal_command","kind":"Other","status":"Pending"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call_34_0","kind":"permission"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_34_0","kind":"execute","title":"Execute `touch quiet.txt`","content":[{"type":"content","content":{"type":"text","text":"Create an empty file"}}],"locations":[],"rawInput":{"variant":"Bash","command":"touch quiet.txt","description":"Create an empty file","is_background":false},"_meta":{"x.ai/tool":{"version":1,"name":"run_terminal_command","kind":"execute","namespace":"grok_build","label":"Run Command","read_only":false,"input":{"command":"touch quiet.txt","description":"Create an empty file"}}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-16","agentTimestampMs":1791011190306,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190304,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_34_0","status":null}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call_34_0"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_34_0","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":""}}],"rawOutput":{"type":"Bash","output":[],"output_for_prompt":"","exit_code":0,"command":"touch quiet.txt","truncated":false,"signal":null,"timed_out":false,"description":null,"current_dir":"/tmp/grok-fixture/cwork","output_file":"","total_bytes":0}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_34_0","status":"completed","content":[{"type":"content","content":{"type":"text","text":""}}],"rawOutput":{"type":"Bash","output":[],"output_for_prompt":"exit: 0\n","exit_code":0,"command":"touch quiet.txt","truncated":false,"signal":null,"timed_out":false,"description":"Create an empty file","current_dir":"/tmp/grok-fixture/cwork","output_file":"/tmp/grok-fixture/ng/home/.grok/sessions/%2Ftmp%2Fgrok-fixture%2Fcwork/01a10095-e65c-7983-a817-6884b010f1ae/terminal/call_34_0.log","total_bytes":0}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-17","agentTimestampMs":1791011190336,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190304,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_34_0","status":"Completed"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call_35_0","tool_index":0,"name":"search_replace"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":0,"arguments_delta":"{\"file_path\":\"in.txt\",\"old_string\":\"hello\",\"new_string\":\"bye\"}"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write","tcode_probe__echo_upper"]}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-20","agentTimestampMs":1791011190354,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190352,"turnStartMs":1791011187089,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call","toolCallId":"call_35_0","title":"search_replace","rawInput":{"file_path":"in.txt","old_string":"hello","new_string":"bye"},"_meta":{"x.ai/tool":{"version":1,"name":"search_replace","kind":"edit","namespace":"grok_build","label":"Edit","read_only":false}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-21","agentTimestampMs":1791011190356,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190352,"turnStartMs":1791011187089,"updateType":"ToolCall","updateParams":{"toolCallId":"call_35_0","title":"search_replace","kind":"Other","status":"Pending"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call_35_0","kind":"permission"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_35_0","kind":"edit","title":"Edit `in.txt`","content":[{"type":"diff","path":"/tmp/grok-fixture/cwork/in.txt","oldText":"hello","newText":"bye","_meta":{"old_line":1,"new_line":1}}],"locations":[{"path":"in.txt"}],"rawInput":{"variant":"SearchReplace","file_path":"in.txt","old_string":"hello","new_string":"bye","replace_all":false},"_meta":{"x.ai/tool":{"version":1,"name":"search_replace","kind":"edit","namespace":"grok_build","label":"Edit","read_only":false,"input":{"path":"in.txt"}}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-22","agentTimestampMs":1791011190356,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190352,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_35_0","status":null}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","toolCall":{"toolCallId":"call_35_0","kind":"edit","title":"Edit `in.txt`","rawInput":{"variant":"SearchReplace","file_path":"in.txt","old_string":"hello","new_string":"bye","replace_all":false},"_meta":{"x.ai/tool":{"version":1,"name":"search_replace","kind":"edit","namespace":"grok_build","label":"Edit","read_only":false,"input":{"path":"in.txt"}}}},"options":[{"optionId":"allow-edits-session","name":"Yes, allow all edits during this session","kind":"allow_always"},{"optionId":"allow-once","name":"Yes","kind":"allow_once"},{"optionId":"reject-once","name":"No, and tell Grok what to do differently","kind":"reject_once"}]}}} +{"from":"client","message":{"jsonrpc":"2.0","id":1,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call_35_0"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_35_0","status":"completed","content":[{"type":"diff","path":"/private/tmp/grok-fixture/cwork/in.txt","oldText":"hello","newText":"bye","_meta":{"details":[{"old_string":"hello","old_line":1,"new_string":"bye","new_line":1,"context_before":"","context_after":"","line_prefix":""}]}}],"rawOutput":{"type":"SearchReplace","EditsApplied":{"old_string":"hello","new_string":"bye","tool_output_for_prompt":"The file in.txt has been updated successfully.","tool_output_for_prompt_concise":"The file in.txt has been updated.","absolute_path":"/private/tmp/grok-fixture/cwork/in.txt","edits":{"details":[{"old_string":"hello","old_line":1,"new_string":"bye","new_line":1,"context_before":"","context_after":"","line_prefix":""}]}}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-23","agentTimestampMs":1791011190380,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190352,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_35_0","status":"Completed"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call_36_0","tool_index":0,"name":"ask_user_question"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":0,"arguments_delta":"{\"questions\":[{\"question\":\"Which greeting should I use?\",\"options\":[{\"label\":\"Hello\",\"description\":\"Friendly\"},{\"label\":\"Hi\",\"description\":\"Short\"}]}]}"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write","tcode_probe__echo_upper"]}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-26","agentTimestampMs":1791011190395,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190394,"turnStartMs":1791011187089,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call","toolCallId":"call_36_0","title":"ask_user_question","rawInput":{"questions":[{"question":"Which greeting should I use?","options":[{"label":"Hello","description":"Friendly"},{"label":"Hi","description":"Short"}]}]},"_meta":{"x.ai/tool":{"version":1,"name":"ask_user_question","kind":"ask_user","namespace":"grok_build","label":"Ask User","read_only":true}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-27","agentTimestampMs":1791011190398,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190394,"turnStartMs":1791011187089,"updateType":"ToolCall","updateParams":{"toolCallId":"call_36_0","title":"ask_user_question","kind":"Other","status":"Pending"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call_36_0","kind":"permission"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_36_0","kind":"other","title":"Ask: Which greeting should I use?","locations":[],"rawInput":{"variant":"AskUserQuestion","questions":[{"question":"Which greeting should I use?","options":[{"label":"Hello","description":"Friendly"},{"label":"Hi","description":"Short"}],"multiSelect":null}]},"_meta":{"x.ai/tool":{"version":1,"name":"ask_user_question","kind":"ask_user","namespace":"grok_build","label":"Ask User","read_only":true}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-28","agentTimestampMs":1791011190398,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190394,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_36_0","status":null}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call_36_0"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call_36_0","kind":"question"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":2,"method":"_x.ai/ask_user_question","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","toolCallId":"call_36_0","questions":[{"question":"Which greeting should I use?","options":[{"label":"Hello","description":"Friendly"},{"label":"Hi","description":"Short"}],"multiSelect":null}],"mode":"default"}}} +{"from":"client","message":{"jsonrpc":"2.0","id":2,"result":{"outcome":"accepted","answers":{"Which greeting should I use?":["Hello"]}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call_36_0"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_36_0","status":"completed","content":[{"type":"content","content":{"type":"text","text":"User has answered your questions: \"Which greeting should I use?\"=\"Hello\". You can now continue with the user's answers in mind."}}],"rawOutput":{"type":"AskUserQuestion","UserAnswered":{"message":"User has answered your questions: \"Which greeting should I use?\"=\"Hello\". You can now continue with the user's answers in mind."}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-29","agentTimestampMs":1791011190415,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190394,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_36_0","status":"Completed"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_call_id":"call_37_0","tool_index":0,"name":"use_tool"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_delta_chunk","tool_index":0,"arguments_delta":"{\"tool_name\":\"tcode_probe__echo_upper\",\"tool_input\":{\"text\":\"hello mcp\"}}"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write","tcode_probe__echo_upper"]}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-32","agentTimestampMs":1791011190438,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190436,"turnStartMs":1791011187089,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call","toolCallId":"call_37_0","title":"use_tool","rawInput":{"tool_name":"tcode_probe__echo_upper","tool_input":{"text":"hello mcp"}},"_meta":{"x.ai/tool":{"version":1,"name":"use_tool","kind":"use_tool","namespace":"grok_build","label":"Use Tool","read_only":false}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-33","agentTimestampMs":1791011190440,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190436,"turnStartMs":1791011187089,"updateType":"ToolCall","updateParams":{"toolCallId":"call_37_0","title":"use_tool","kind":"Other","status":"Pending"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"pending_interaction","tool_call_id":"call_37_0","kind":"permission"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_37_0","kind":"other","title":"tcode_probe__echo_upper","locations":[],"rawInput":{"variant":"UseTool","tool_name":"tcode_probe__echo_upper","tool_input":{"text":"hello mcp"}},"_meta":{"x.ai/tool":{"version":1,"name":"use_tool","kind":"use_tool","namespace":"grok_build","label":"Use Tool","read_only":false}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-34","agentTimestampMs":1791011190440,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190436,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_37_0","status":null}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":3,"method":"session/request_permission","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","toolCall":{"toolCallId":"call_37_0","kind":"other","title":"tcode_probe__echo_upper","rawInput":{"variant":"UseTool","tool_name":"tcode_probe__echo_upper","tool_input":{"text":"hello mcp"}},"_meta":{"x.ai/tool":{"version":1,"name":"use_tool","kind":"use_tool","namespace":"grok_build","label":"Use Tool","read_only":false}}},"options":[{"optionId":"always-allow","name":"always allow","kind":"allow_always"},{"optionId":"allow-once","name":"allow once","kind":"allow_once"},{"optionId":"reject-once","name":"reject once","kind":"reject_once"}]}}} +{"from":"client","message":{"jsonrpc":"2.0","id":3,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"interaction_resolved","tool_call_id":"call_37_0"}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_37_0","status":"completed","rawOutput":{"type":"MCP","tool_name":"echo_upper","server_name":"tcode_probe","output":{"OkayOutput":"HELLO MCP"}}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-35","agentTimestampMs":1791011190464,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190436,"turnStartMs":1791011187089,"updateType":"ToolCallUpdate","updateParams":{"toolCallId":"call_37_0","status":"Completed"}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Counted, edited, "}},"_meta":{"totalTokens":1236,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-38","agentTimestampMs":1791011190482,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190479,"turnStartMs":1791011187089,"updateType":"AgentMessageChunk","chunkId":1}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"asked and echoed."}},"_meta":{"totalTokens":1236,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-39","agentTimestampMs":1791011190540,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190479,"turnStartMs":1791011187089,"updateType":"AgentMessageChunk","chunkId":2}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":null},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"feedback","description":"Send feedback about the current session","input":{"hint":"feedback text"}},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""},"_meta":{"workflowSource":"builtin","workflowPath":"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/Users/admin/actions-runner/_work/xai/xai/crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai"}},{"name":"workflow","description":"Launch a saved workflow, list runs, or manage a run (pause, resume, stop, save)","input":{"hint":" [--agent-budget N] [--effort LEVEL] [args] | runs | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}},{"name":"loop","description":"Run a prompt on a recurring interval","input":{"hint":"[interval] "}}],"_meta":{"tools":["run_terminal_command","read_file","search_replace","list_dir","grep","kill_command_or_subagent","todo_write","get_command_or_subagent_output","spawn_subagent","scheduler_create","scheduler_delete","scheduler_list","monitor","search_tool","use_tool","workflow","enter_plan_mode","exit_plan_mode","ask_user_question","send_feedback","web_search","image_gen","image_edit","image_to_video","reference_to_video","write","tcode_probe__echo_upper"]}},"_meta":{"totalTokens":1234,"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-40","agentTimestampMs":1791011190601,"promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","streamStartMs":1791011190479,"turnStartMs":1791011187089,"updateType":"AvailableCommandsUpdate","updateParams":{"commandsCount":9}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"response_completed","usage":{"input_tokens":200,"output_tokens":34,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0,"reasoning_tokens":12}}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/queue/changed","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","entries":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session_notification","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","update":{"sessionUpdate":"turn_completed","prompt_id":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","stop_reason":"end_turn","usage":{"inputTokens":7200,"outputTokens":204,"totalTokens":7404,"cachedReadTokens":6000,"cacheCreationTokens":0,"reasoningTokens":72,"modelCalls":6,"apiDurationMs":296,"modelUsage":{"grok-4.6":{"inputTokens":7200,"outputTokens":204,"totalTokens":7404,"cachedReadTokens":6000,"cacheCreationTokens":0,"reasoningTokens":72,"modelCalls":6,"apiDurationMs":296}},"numTurns":6},"elapsed_ms":3645},"_meta":{"eventId":"01a10095-e65c-7983-a817-6884b010f1ae-43","agentTimestampMs":1791011190666}}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/session/prompt_complete","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","stopReason":"end_turn","agentResult":null}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","title":null,"cwd":"/tmp/grok-fixture/cwork","isWorktree":false,"modelId":"grok-4.6","reasoningEffort":"high","yolo":false,"activity":"idle","resident":true,"lastChangeUnixMs":1791011190667,"origin":{"kind":"local"}}],"removed":[]}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"440d270b-6902-4b29-9ab9-572241521340","result":{"stopReason":"end_turn","_meta":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae","requestId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","promptId":"15ffdbf1-b911-4441-be1c-8656bfba3c0f","totalTokens":1234,"modelId":"grok-4.6","inputTokens":1200,"outputTokens":34,"cachedReadTokens":1000,"reasoningTokens":12,"usage":{"inputTokens":7200,"outputTokens":204,"totalTokens":7404,"cachedReadTokens":6000,"cacheCreationTokens":0,"reasoningTokens":72,"modelCalls":6,"apiDurationMs":296,"modelUsage":{"grok-4.6":{"inputTokens":7200,"outputTokens":204,"totalTokens":7404,"cachedReadTokens":6000,"cacheCreationTokens":0,"reasoningTokens":72,"modelCalls":6,"apiDurationMs":296}},"numTurns":6}}}}} +{"from":"client","message":{"jsonrpc":"2.0","id":"14412b6d-c268-436a-aad6-192c6628cb49","method":"session/close","params":{"sessionId":"01a10095-e65c-7983-a817-6884b010f1ae"}}} +{"from":"agent","message":{"jsonrpc":"2.0","method":"_x.ai/sessions/changed","params":{"upserted":[],"removed":["01a10095-e65c-7983-a817-6884b010f1ae"]}}} +{"from":"agent","message":{"jsonrpc":"2.0","id":"14412b6d-c268-436a-aad6-192c6628cb49","result":{"_meta":{"x.ai/closeOutcome":"closed"}}}} diff --git a/crates/agent/tests/fixtures/plugins/claude/COMMANDS.md b/crates/agent/tests/fixtures/plugins/claude/COMMANDS.md new file mode 100644 index 000000000..4e8766791 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/COMMANDS.md @@ -0,0 +1,613 @@ +# Claude Code plugin probe — commands + +- CLI: `2.1.285 (Claude Code)` (`claude --version`), binary `/Users/tryanks/.local/share/mise/installs/claude/latest/claude` → `2.1.285/claude`. Date 2026-10-03, macOS (Darwin 27.0.0, arm64). +- Driver: `/tmp/tcode-claude-run/probe.py` + `probe_lib.py` (not in the repo). Every command runs with stdin = `/dev/null`, stdout and stderr captured separately. +- **Env** (all runs): a clean base of `PATH USER LOGNAME SHELL TMPDIR LANG` only. The probe itself runs inside a Tcode-launched Claude Code session; its `CLAUDECODE`/`CLAUDE_CODE_*` variables are deliberately *not* inherited. Adding them back one at a time to an otherwise accepted `--accept-command` install showed that `CLAUDECODE` and `CLAUDE_CODE_CHILD_SESSION` each make the CLI refuse it ("--accept-command is ignored inside a Claude Code session"); `CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_CODE_SESSION_ID`, `CLAUDE_PID`, `CLAUDE_CODE_EXECPATH`, `AI_AGENT`, `CLAUDE_CODE_SESSION_ATTENDED` and `CLAUDE_CODE_MESSAGING_SOCKET` do not. Those recordings are not kept. + - `iso` = base + `HOME=/tmp/tcode-probe/claude-home` + `CLAUDE_CONFIG_DIR=/tmp/tcode-probe/claude-home/.claude` (CLAUDE_CONFIG_DIR is not in `claude --help`; it is honoured — all state landed under it). + - `real` = base + `HOME=/Users/tryanks` (read-only commands and the one `--plugin-dir` session only). +- Paths: marketplace `/tmp/tcode-probe/mkt` (the `manifests/mkt` files below), command-source output dir `/tmp/tcode-probe/gamma-src` (the `manifests/gamma-src` files below), project `/tmp/tcode-probe/proj` (macOS realpath `/private/tmp/tcode-probe/proj`), non-project cwd `/tmp/tcode-probe/outside`. + +## Authored manifests + +### `manifests/gamma-src/.claude-plugin/plugin.json` + +```json +{ + "name": "gamma", + "version": "0.0.1", + "description": "Plugin directory produced by the gamma command source" +} +``` + +### `manifests/gamma-src/skills/gamma-note/SKILL.md` + +```markdown +--- +name: gamma-note +description: Writes a short note from gamma. Use when the user asks for a gamma note. +--- + +Reply with "gamma note". +``` + +### `manifests/mkt/.claude-plugin/marketplace.json` + +```json +{ + "name": "tcode-probe", + "description": "Throwaway marketplace for the Tcode plugin probe", + "owner": { "name": "Tcode probe" }, + "plugins": [ + { + "name": "alpha", + "source": "./plugins/alpha", + "description": "Skill, command, SessionStart hook and an unreachable HTTP MCP server", + "version": "1.0.0" + }, + { + "name": "beta", + "source": "./plugins/beta", + "description": "Skill only", + "version": "0.1.0" + }, + { + "name": "gamma", + "source": { + "source": "command", + "command": "echo /tmp/tcode-probe/gamma-src", + "timeout": 30 + }, + "description": "Command-source plugin" + } + ] +} +``` + +### `manifests/mkt/plugins/alpha/.claude-plugin/plugin.json` + +```json +{ + "name": "alpha", + "version": "1.0.0", + "description": "Skill, command, SessionStart hook and an unreachable HTTP MCP server" +} +``` + +### `manifests/mkt/plugins/alpha/.mcp.json` + +```json +{ + "mcpServers": { + "alpha-dead": { + "type": "http", + "url": "http://127.0.0.1:9/mcp" + } + } +} +``` + +### `manifests/mkt/plugins/alpha/commands/hello.md` + +```markdown +--- +description: Print a hello line from the alpha plugin +--- + +Reply with exactly: hello from alpha command +``` + +### `manifests/mkt/plugins/alpha/hooks/hooks.json` + +```json +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { "type": "command", "command": "echo alpha-session-start" } + ] + } + ] + } +} +``` + +### `manifests/mkt/plugins/alpha/skills/greet/SKILL.md` + +```markdown +--- +name: greet +description: Greets the user by name. Use when the user asks to be greeted. +--- + +Say "Hello from alpha" followed by the user's name. +``` + +### `manifests/mkt/plugins/beta/.claude-plugin/plugin.json` + +```json +{ + "name": "beta", + "version": "0.1.0", + "description": "Skill only" +} +``` + +### `manifests/mkt/plugins/beta/skills/beta-note/SKILL.md` + +```markdown +--- +name: beta-note +description: Writes a short note from beta. Use when the user asks for a beta note. +--- + +Reply with "beta note". +``` + +Before step `16-update` the driver rewrote `"version": "1.0.0"` → `"1.0.1"` in `mkt/.claude-plugin/marketplace.json` and `mkt/plugins/alpha/.claude-plugin/plugin.json`. + +## Commands, in execution order + +Only the runs whose output is kept here are listed; the numbering is the probe's. + +| # | step | argv (after the binary) | cwd | env | exit | stdout | stderr | +|---|---|---|---|---|---|---|---| +| 2 | `real-marketplace-list` | `plugin marketplace list --json` | `/tmp/tcode-probe/outside` | real | 0 | `real-home-marketplace-list.json` | (empty) | +| 9 | `04-marketplace-list` | `plugin marketplace list --json` | `/tmp/tcode-probe/proj` | iso | 0 | `marketplace-list.json` | (empty) | +| 11 | `06-install-alpha-user` | `plugin install alpha@tcode-probe --scope user --json` | `/tmp/tcode-probe/proj` | iso | 0 | `install-user.json` | (empty) | +| 17 | `09c-plugin-list-available-after-install` | `plugin list --json --available` | `/tmp/tcode-probe/proj` | iso | 0 | `plugin-list-available-after-install.json` | (empty) | +| 19 | `10-details` | `plugin details alpha` | `/tmp/tcode-probe/proj` | iso | 0 | `details.txt` | (empty) | +| 21 | `11-disable` | `plugin disable alpha --json` | `/tmp/tcode-probe/proj` | iso | 0 | `disable.json` | (empty) | +| 23 | `12-enable` | `plugin enable alpha --json` | `/tmp/tcode-probe/proj` | iso | 0 | `enable.json` | (empty) | +| 24 | `13-disable-user-scope` | `plugin disable alpha --scope user --json` | `/tmp/tcode-probe/proj` | iso | 0 | `disable-scope-user.json` | (empty) | +| 32 | `16-update` | `plugin update alpha --json` | `/tmp/tcode-probe/proj` | iso | 0 | `update.json` | (empty) | +| 35 | `17-uninstall-beta-project` | `plugin uninstall beta --scope project --json` | `/tmp/tcode-probe/proj` | iso | 0 | `uninstall.json` | (empty) | +| 36 | `18-install-gamma-challenge` | `plugin install gamma@tcode-probe --json` | `/tmp/tcode-probe/proj` | iso | 1 | `install-command-source-challenge.txt` | (not kept) | +| 38 | `19-install-gamma-wrong-hash` | `plugin install gamma@tcode-probe --json --accept-command 0000000000000000000000000000000000000000000000000000000000000000` | `/tmp/tcode-probe/proj` | iso | 1 | `install-command-source-wrong-hash.json` | (not kept) | +| 56 | `20-install-gamma-accepted` | `plugin install gamma@tcode-probe --json --accept-command b9c02c85b17261fa1fce010664bb16cbe866f14c7deeaf6cb6c37c2736bbe269` | `/tmp/tcode-probe/proj` | iso | 0 | `install-command-source-accepted.json` | (empty) | + +Notes: + +- 2 `real-marketplace-list`: REAL home, read-only. +- 21 `11-disable`: No --scope: auto-detect. +- 23 `12-enable`: No --scope: auto-detect. +- 32 `16-update`: Before this run alpha version was bumped 1.0.0 -> 1.0.1 in marketplace.json and plugin.json. No --scope. +- 56 `20-install-gamma-accepted`: --accept-command = shownCommand.sha256 from step 18 (b9c02c85b17261fa1fce010664bb16cbe866f14c7deeaf6cb6c37c2736bbe269); clean env. + +## `claude plugin --help` (exit 0) + +```text +Usage: claude plugin|plugins [options] [command] + +Manage Claude Code plugins + +Options: + -h, --help Display help for command + +Commands: + configure [options] Show a plugin's options and which are + unset, or save values from stdin with + --values-stdin + details [options] Show a plugin's component inventory and + projected token cost + disable [options] [plugin] Disable an enabled plugin + enable [options] Enable a disabled plugin + eval [options] [target] Run eval cases (/**/case.yaml + or prompt.md + graders/*.md; the eval dir + is evals/ unless --eval-dir or the + manifest says otherwise) against a plugin + and report scored results. Target is a + path, a plugin name, or a + `plugin@marketplace` id — installed and + skills-dir plugins both resolve (and add + a no-plugin baseline arm). It loads the + plugin and runs its eval suite (prompts, + graders; scaffold scripts and real MCP + servers only when you opt in) on your + machine, as you: only evaluate plugins + you trust — the run's sandboxing limits + what a malicious plugin can reach but is + not a guarantee, and a bundled suite + passing is not a security vetting. The + first run in an untrusted plugin + directory asks you to confirm + (--trust-plugin answers for CI) + help [command] display help for command + init|new [options] Scaffold a new plugin at + ~/.claude/skills// (auto-loads next + session as @skills-dir) + install|i [options] Install a plugin from available + marketplaces (use plugin@marketplace for + specific marketplace) + list [options] List installed plugins + marketplace Manage Claude Code marketplaces + prune|autoremove [options] Remove auto-installed dependencies that + are no longer needed + tag [options] [path] Create a {name}--v{version} git tag for a + plugin release, validating that + plugin.json and any enclosing marketplace + entry agree + uninstall|remove [options] Uninstall an installed plugin + update [options] Update a plugin to the latest version + (restart required to apply) + validate [options] Validate a plugin or marketplace + manifest, or the skills, agents, and + commands in a directory +``` + +## Every subcommand `--help` (`bash -c 'claude plugin $s --help'`, exit codes inline) + +```text +=== claude plugin list --help +Usage: claude plugin list [options] + +List installed plugins + +Options: + --available Include available plugins from marketplaces (requires + --json) + --data-size [plugin] Measure each installed plugin's saved data directory, or + only the named plugin's (requires --json) + -h, --help Display help for command + --json Output as JSON +[exit 0] + +=== claude plugin install --help +Usage: claude plugin install|i [options] + +Install a plugin from available marketplaces (use plugin@marketplace for +specific marketplace) + +Options: + --accept-command Accept the marketplace-declared command (a + command-source install, or the headersHelper that + fetches the archive) whose sha256 a previous --json + run reported as shownCommand.sha256; counts as -y + for exactly that command, for that plugin and + marketplace catalog, and nothing else. If either + changed (a refresh that moved the catalog counts), + the run refuses and reports the command again, to + be shown to a person again + --config Set a userConfig option declared in the plugin's + manifest, or a bundled .mcpb server's own + user_config field as .= (a bare + key works when only one bundled server declares + it). Repeatable. Values are validated against the + schema and stored via the same path as the + interactive /plugin configure flow. + -h, --help Display help for command + --json Print one machine-readable result line on stdout + instead of the human message (same exit codes; a + marketplace-declared command is still shown and + must be confirmed — pass -y when not interactive) + --registry For a @npm install: resolve and download + from this npm registry instead of the one your npm + configuration selects + -s, --scope Installation scope: user, project, or local + (default: "user") + -y, --yes Accept the displayed marketplace-declared command + without the confirmation prompt — a plugin + installed by running a command, or one whose + archive is fetched through a headersHelper command + (required when stdin or stdout is not a TTY) +[exit 0] + +=== claude plugin uninstall --help +Usage: claude plugin uninstall|remove [options] + +Uninstall an installed plugin + +Options: + -h, --help Display help for command + --json Print one machine-readable result line on stdout instead + of the human message (same exit codes; not with --prune) + --keep-data Preserve the plugin's persistent data directory + (~/.claude/plugins/data/{id}/) + --prune Also remove auto-installed dependencies that are no + longer needed (requires -y in non-interactive contexts) + -s, --scope Uninstall from scope: user, project, or local (default: + "user") + -y, --yes Skip the --prune confirmation prompt (required when stdin + or stdout is not a TTY) +[exit 0] + +=== claude plugin enable --help +Usage: claude plugin enable [options] + +Enable a disabled plugin + +Options: + -h, --help Display help for command + --json Print one machine-readable result line on stdout instead + of the human message (same exit codes) + -s, --scope Installation scope: user, project, local (default: + auto-detect) +[exit 0] + +=== claude plugin disable --help +Usage: claude plugin disable [options] [plugin] + +Disable an enabled plugin + +Options: + -a, --all Disable all enabled plugins + -h, --help Display help for command + --json Print one machine-readable result line on stdout instead + of the human message (same exit codes) + -s, --scope Installation scope: user, project, local (default: + auto-detect) +[exit 0] + +=== claude plugin update --help +Usage: claude plugin update [options] + +Update a plugin to the latest version (restart required to apply) + +Options: + --accept-command Accept the marketplace-declared command (a + command-source install, or the headersHelper that + fetches the archive) whose sha256 a previous --json + run reported as shownCommand.sha256; counts as -y + for exactly that command, for that plugin and + marketplace catalog, and nothing else. If either + changed (a refresh that moved the catalog counts), + the run refuses and reports the command again, to + be shown to a person again + -h, --help Display help for command + --json Print one machine-readable result line on stdout + instead of the human message (same exit codes; a + marketplace-declared command is still shown and + must be confirmed — pass -y when not interactive) + -s, --scope Installation scope: user, project, local, managed + (default: auto-detect) + -y, --yes Accept the displayed marketplace-declared command + without the confirmation prompt — a changed install + command, or the headersHelper command that fetches + its archive (required when stdin or stdout is not a + TTY) +[exit 0] + +=== claude plugin details --help +Usage: claude plugin details [options] + +Show a plugin's component inventory and projected token cost + +Options: + -h, --help Display help for command +[exit 0] + +=== claude plugin validate --help +Usage: claude plugin validate [options] + +Validate a plugin or marketplace manifest, or the skills, agents, and commands +in a directory + +Options: + -h, --help Display help for command + --json Output the validation report as JSON (same exit codes) + --strict Treat warnings as errors (exit 1). Use in CI to fail on + unrecognized fields, missing metadata, and other issues that the + runtime tolerates. +[exit 0] + +=== claude plugin init --help +Usage: claude plugin init|new [options] + +Scaffold a new plugin at ~/.claude/skills// (auto-loads next session as +@skills-dir) + +Options: + --author Author name (default: git config user.name) + --author-email Author email (default: git config user.email) + --description Manifest description + -f, --force Overwrite an existing .claude-plugin/ at the target + -h, --help Display help for command + --with Also scaffold: skills, agents, hooks, mcp, lsp, + output-style, channel +[exit 0] + +=== claude plugin configure --help +Usage: claude plugin configure [options] + +Show a plugin's options and which are unset, or save values from stdin with +--values-stdin + +Options: + -h, --help Display help for command + --json Output as JSON + --values-stdin Read option values from stdin as a JSON object of single-line + strings; options left out keep their values +[exit 0] + +=== claude plugin prune --help +Usage: claude plugin prune|autoremove [options] + +Remove auto-installed dependencies that are no longer needed + +Options: + --dry-run List what would be removed without removing + -h, --help Display help for command + -s, --scope Prune at scope: user, project, or local (default: "user") + -y, --yes Skip the confirmation prompt (required when stdin or + stdout is not a TTY) +[exit 0] + +=== claude plugin tag --help +Usage: claude plugin tag [options] [path] + +Create a {name}--v{version} git tag for a plugin release, validating that +plugin.json and any enclosing marketplace entry agree + +Options: + --dry-run Print what would be tagged without creating it + -f, --force Skip the dirty-working-tree and tag-already-exists checks + -h, --help Display help for command + -m, --message Tag annotation message (use %s for the version) + --push Push the tag to --remote after creating it + --remote Remote to push to with --push (default: "origin") +[exit 0] + +=== claude plugin test --help +claude plugin test: hooks modules are not turned on in this build yet (early access); set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 to run a plugin's tests +[exit 1] + +=== claude plugin eval --help +Usage: claude plugin eval [options] [command] [target] + +Run eval cases (/**/case.yaml or prompt.md + graders/*.md; the eval +dir is evals/ unless --eval-dir or the manifest says otherwise) against a plugin +and report scored results. Target is a path, a plugin name, or a +`plugin@marketplace` id — installed and skills-dir plugins both resolve (and add +a no-plugin baseline arm). It loads the plugin and runs its eval suite (prompts, +graders; scaffold scripts and real MCP servers only when you opt in) on your +machine, as you: only evaluate plugins you trust — the run's sandboxing limits +what a malicious plugin can reach but is not a guarantee, and a bundled suite +passing is not a security vetting. The first run in an untrusted plugin +directory asks you to confirm (--trust-plugin answers for CI) + +Options: + --ablation Run a no-plugin baseline arm and report the score + delta (none | with-without; default: with-without + whenever a plugin resolves — by name, or from the + target path — and none when nothing does; under + with-without, graders marked with-only, incl. + `tool_used: Skill`, are a plugin-fired indicator + rather than part of the score) + --allow-real-servers With --mocks record: also start the plugin's REAL + MCP server processes for servers that have no mock + (they run as you, outside the OS sandbox that + confines shell tools; use only on plugins you trust) + --allow-tools Operator grant for gated tools (Bash, Write, Edit, + WebFetch, mcp__*). Supports Tool(pattern:*) syntax + --case Filter cases by name glob + -j, --concurrency Run up to agent runs at once (1-8; default 1). + Each run is a full claude child on your own + credential, so they share one rate limit; results + and the report keep case order + --eval-dir Directory name (below the plugin) that holds the + eval cases; results go to //results/ — + for an installed-plugin target, .//results/ + with this flag, else ./evals/results/ (default dir: + the manifest's experimental.evals value, else + evals/) + -h, --help Display help for command + --json [path] Print the full run result (prompts, graders, per-run + scores) as JSON to stdout, or write it to this .json + file + --judge-model Override LLM-grader model (default: haiku) + --keep-temp Preserve scaffold dirs for debugging + --max-cost-usd Optional hard cost ceiling; abort and report partial + results if hit (exit 2). The ceiling is checked + before each run launches, so overrun is bounded to + the runs in flight (one, or up to --concurrency) — + when a run breaches, paid graders (llm/baseline) are + skipped while free graders still score it. Runs are + already bounded by max_turns and timeout_seconds — + only set this when you need a strict budget + --mocks Mock stand-ins for MCP servers, from /mocks/ (record | off; default: record). record: + a plugin server with no mock is NOT started (see + --allow-real-servers); off: no stand-ins, every real + server starts (as you, outside the OS sandbox), its + tools gated by --allow-tools + --model Override model for all cases + --no-publish Keep the HTML report local only; skip publishing it + to claude.ai + --no-scaffold Explicitly skip scaffold_script + --output-dir Directory for aggregate-result.json (default: + .//results//) + --publish-report Also require publishing the report to claude.ai + (already the default when your account supports it); + explains why if unavailable + --report Write the self-contained HTML report (scores, + prompts, grader verdicts) to instead of the + results dir + --runs Override per-case runs (default: case.runs ?? 3) + --scaffold Run each case's scaffold_script (runs + author-supplied bash as you; off by default — only + use on case files you authored) + --tag Filter cases by tag (repeatable) + --threshold <0..1> Exit 1 if any case score is below this threshold + (default: 1.0) + --trust-plugin Assert that you trust this plugin's code and eval + suite, and skip the first-run trust prompt (for CI; + like --dangerously-skip-permissions, only pass it + for plugins you would run yourself). Does not imply + --scaffold, --allow-tools or --mocks off + --verbose Log per-message trace events to the debug log (use + --debug-file to read them) + +Commands: + init [options] [name] Author an eval suite under the eval dir (evals/ + unless --eval-dir or the manifest says otherwise) + via an interview that sources inputs and designs + graders. Use --bare for a blank single-case + template. +[exit 0] + +=== claude plugin marketplace --help +Usage: claude plugin marketplace [options] [command] + +Manage Claude Code marketplaces + +Options: + -h, --help Display help for command + +Commands: + add [options] Add a marketplace from a URL, path, or GitHub repo + help [command] display help for command + list [options] List all configured marketplaces + remove|rm [options] Remove a configured marketplace + update [options] [name] Update marketplace(s) from their source - updates + all if no name specified +[exit 0] + +=== claude plugin marketplace add --help +Usage: claude plugin marketplace add [options] + +Add a marketplace from a URL, path, or GitHub repo + +Options: + --claudeai Add the marketplace of this name that claude.ai hosts for + you, by its listed name or its local name (see: claude + plugin marketplace list) + -h, --help Display help for command + --scope Where to declare the marketplace: user (default), + project, or local + --sparse Limit checkout to specific directories via git + sparse-checkout (for monorepos). Example: --sparse + .claude-plugin plugins +[exit 0] + +=== claude plugin marketplace list --help +Usage: claude plugin marketplace list [options] + +List all configured marketplaces + +Options: + -h, --help Display help for command + --json Output as JSON +[exit 0] + +=== claude plugin marketplace remove --help +Usage: claude plugin marketplace remove|rm [options] + +Remove a configured marketplace + +Options: + -h, --help Display help for command + --scope Remove the marketplace declaration from a specific settings + scope: user, project, or local. Omit to remove it from every + scope. +[exit 0] + +=== claude plugin marketplace update --help +Usage: claude plugin marketplace update [options] [name] + +Update marketplace(s) from their source - updates all if no name specified + +Options: + -h, --help Display help for command +[exit 0] +``` + diff --git a/crates/agent/tests/fixtures/plugins/claude/details.txt b/crates/agent/tests/fixtures/plugins/claude/details.txt new file mode 100644 index 000000000..3f08e5470 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/details.txt @@ -0,0 +1,21 @@ +alpha 1.0.0 + Description: Skill, command, SessionStart hook and an unreachable HTTP MCP server + Source: alpha@tcode-probe + +Component inventory + Skills (2) greet, hello + Agents (0) + Hooks (1) SessionStart (harness-only — no model context cost) + MCP servers (1) alpha-dead (tool schemas resolved at runtime; not counted) + LSP servers (0) + +Projected token cost + Always-on: ~32 tok added to every session + +Per-component (rounded) + component always-on on-invoke + greet < 20 < 20 + hello < 20 < 20 + + On-invoke cost is paid each time a skill or agent fires. + Token counts are estimates and may differ from actual usage. diff --git a/crates/agent/tests/fixtures/plugins/claude/disable-scope-user.json b/crates/agent/tests/fixtures/plugins/claude/disable-scope-user.json new file mode 100644 index 000000000..12c2473fb --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/disable-scope-user.json @@ -0,0 +1 @@ +{"command":"disable","outcome":"ok","plugin":"alpha","pluginId":"alpha@tcode-probe","scope":"user","message":"Successfully disabled plugin: alpha (scope: user)"} diff --git a/crates/agent/tests/fixtures/plugins/claude/disable.json b/crates/agent/tests/fixtures/plugins/claude/disable.json new file mode 100644 index 000000000..85145a0c6 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/disable.json @@ -0,0 +1 @@ +{"command":"disable","outcome":"ok","plugin":"alpha","pluginId":"alpha@tcode-probe","scope":"project","message":"Successfully disabled plugin: alpha (scope: project)"} diff --git a/crates/agent/tests/fixtures/plugins/claude/enable.json b/crates/agent/tests/fixtures/plugins/claude/enable.json new file mode 100644 index 000000000..bf7d18c24 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/enable.json @@ -0,0 +1 @@ +{"command":"enable","outcome":"ok","plugin":"alpha","pluginId":"alpha@tcode-probe","scope":"project","message":"Successfully enabled plugin: alpha (scope: project)"} diff --git a/crates/agent/tests/fixtures/plugins/claude/install-command-source-accepted.json b/crates/agent/tests/fixtures/plugins/claude/install-command-source-accepted.json new file mode 100644 index 000000000..93b86b79a --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/install-command-source-accepted.json @@ -0,0 +1,4 @@ +"gamma" is installed by running a command from marketplace "tcode-probe" on this machine: + echo /tmp/tcode-probe/gamma-src + (mode "copy": its output directory is copied into the plugin cache) +{"command":"install","outcome":"ok","plugin":"gamma@tcode-probe","pluginId":"gamma@tcode-probe","scope":"user","message":"Successfully installed plugin: gamma@tcode-probe (scope: user)"} diff --git a/crates/agent/tests/fixtures/plugins/claude/install-command-source-challenge.txt b/crates/agent/tests/fixtures/plugins/claude/install-command-source-challenge.txt new file mode 100644 index 000000000..a079a23c6 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/install-command-source-challenge.txt @@ -0,0 +1,5 @@ +"gamma" is installed by running a command from marketplace "tcode-probe" on this machine: + echo /tmp/tcode-probe/gamma-src + (mode "copy": its output directory is copied into the plugin cache) +Not an interactive terminal, so the command was only displayed, not accepted. Re-run in a terminal to confirm it, or pass -y/--yes to accept the command shown above. +{"command":"install","outcome":"failed","plugin":"gamma@tcode-probe","scope":"user","message":"gamma@tcode-probe is installed by running a command on this machine (`echo /tmp/tcode-probe/gamma-src`) that has not been reviewed yet, so it was not run. Review and accept it from its /plugin details pane, or in a terminal: run `claude plugin install gamma@tcode-probe`.","failureCode":"command_source_refused","shownCommand":{"kind":"command_source","pluginId":"gamma@tcode-probe","command":"echo /tmp/tcode-probe/gamma-src","mode":"copy","catalogRevision":"sha256:cab213ed98e09aae33c15cf3ff6a1617518d74aa0543466d5987b04050bcb5bb","sha256":"b9c02c85b17261fa1fce010664bb16cbe866f14c7deeaf6cb6c37c2736bbe269"}} diff --git a/crates/agent/tests/fixtures/plugins/claude/install-command-source-wrong-hash.json b/crates/agent/tests/fixtures/plugins/claude/install-command-source-wrong-hash.json new file mode 100644 index 000000000..5f33073de --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/install-command-source-wrong-hash.json @@ -0,0 +1,5 @@ +"gamma" is installed by running a command from marketplace "tcode-probe" on this machine: + echo /tmp/tcode-probe/gamma-src + (mode "copy": its output directory is copied into the plugin cache) +--accept-command does not name the command shown above (it may have changed since it was shown), so it was not run. Show it to the person again before accepting it. +{"command":"install","outcome":"failed","plugin":"gamma@tcode-probe","scope":"user","message":"gamma@tcode-probe is installed by running a command on this machine (`echo /tmp/tcode-probe/gamma-src`) that has not been reviewed yet, so it was not run. Review and accept it from its /plugin details pane, or in a terminal: run `claude plugin install gamma@tcode-probe`.","failureCode":"command_source_refused","shownCommand":{"kind":"command_source","pluginId":"gamma@tcode-probe","command":"echo /tmp/tcode-probe/gamma-src","mode":"copy","catalogRevision":"sha256:cab213ed98e09aae33c15cf3ff6a1617518d74aa0543466d5987b04050bcb5bb","acceptCommandMatched":false,"sha256":"b9c02c85b17261fa1fce010664bb16cbe866f14c7deeaf6cb6c37c2736bbe269"}} diff --git a/crates/agent/tests/fixtures/plugins/claude/install-user.json b/crates/agent/tests/fixtures/plugins/claude/install-user.json new file mode 100644 index 000000000..5898e35ed --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/install-user.json @@ -0,0 +1 @@ +{"command":"install","outcome":"ok","plugin":"alpha@tcode-probe","pluginId":"alpha@tcode-probe","scope":"user","message":"Successfully installed plugin: alpha@tcode-probe (scope: user)"} diff --git a/crates/agent/tests/fixtures/plugins/claude/marketplace-list.json b/crates/agent/tests/fixtures/plugins/claude/marketplace-list.json new file mode 100644 index 000000000..f3efbfed3 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/marketplace-list.json @@ -0,0 +1,8 @@ +[ + { + "name": "tcode-probe", + "source": "directory", + "path": "/tmp/tcode-probe/mkt", + "installLocation": "/tmp/tcode-probe/mkt" + } +] diff --git a/crates/agent/tests/fixtures/plugins/claude/plugin-list-available-after-install.json b/crates/agent/tests/fixtures/plugins/claude/plugin-list-available-after-install.json new file mode 100644 index 000000000..4ec638ae8 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/plugin-list-available-after-install.json @@ -0,0 +1,61 @@ +{ + "installed": [ + { + "id": "alpha@tcode-probe", + "version": "1.0.0", + "scope": "user", + "enabled": true, + "installPath": "/tmp/tcode-probe/claude-home/.claude/plugins/cache/tcode-probe/alpha/1.0.0", + "installedAt": "2026-10-03T02:13:48.097Z", + "lastUpdated": "2026-10-03T02:13:48.097Z", + "mcpServers": { + "alpha-dead": { + "type": "http", + "url": "http://127.0.0.1:9/mcp" + } + }, + "projectEnabled": true + }, + { + "id": "alpha@tcode-probe", + "version": "1.0.0", + "scope": "project", + "enabled": true, + "installPath": "/tmp/tcode-probe/claude-home/.claude/plugins/cache/tcode-probe/alpha/1.0.0", + "installedAt": "2026-10-03T02:13:50.068Z", + "lastUpdated": "2026-10-03T02:13:50.068Z", + "projectPath": "/private/tmp/tcode-probe/proj", + "mcpServers": { + "alpha-dead": { + "type": "http", + "url": "http://127.0.0.1:9/mcp" + } + }, + "projectEnabled": true + }, + { + "id": "beta@tcode-probe", + "version": "0.1.0", + "scope": "project", + "enabled": true, + "installPath": "/tmp/tcode-probe/claude-home/.claude/plugins/cache/tcode-probe/beta/0.1.0", + "installedAt": "2026-10-03T02:13:49.458Z", + "lastUpdated": "2026-10-03T02:13:49.458Z", + "projectPath": "/private/tmp/tcode-probe/proj", + "projectEnabled": true + } + ], + "available": [ + { + "pluginId": "gamma@tcode-probe", + "name": "gamma", + "description": "Command-source plugin", + "marketplaceName": "tcode-probe", + "source": { + "source": "command", + "command": "echo /tmp/tcode-probe/gamma-src", + "timeout": 30 + } + } + ] +} diff --git a/crates/agent/tests/fixtures/plugins/claude/real-home-marketplace-list.json b/crates/agent/tests/fixtures/plugins/claude/real-home-marketplace-list.json new file mode 100644 index 000000000..7f345b028 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/real-home-marketplace-list.json @@ -0,0 +1,8 @@ +[ + { + "name": "claude-plugins-official", + "source": "github", + "repo": "anthropics/claude-plugins-official", + "installLocation": "/Users/tryanks/.claude/plugins/marketplaces/claude-plugins-official" + } +] diff --git a/crates/agent/tests/fixtures/plugins/claude/uninstall.json b/crates/agent/tests/fixtures/plugins/claude/uninstall.json new file mode 100644 index 000000000..939ba53d5 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/uninstall.json @@ -0,0 +1 @@ +{"command":"uninstall","outcome":"ok","plugin":"beta","pluginId":"beta@tcode-probe","scope":"project","keptData":false,"message":"Successfully uninstalled plugin: beta (scope: project)"} diff --git a/crates/agent/tests/fixtures/plugins/claude/update.json b/crates/agent/tests/fixtures/plugins/claude/update.json new file mode 100644 index 000000000..b0928ebd3 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/claude/update.json @@ -0,0 +1 @@ +{"command":"update","outcome":"ok","plugin":"alpha","pluginId":"alpha@tcode-probe","scope":"project","message":"Plugin \"alpha\" updated from 1.0.0 to 1.0.1 for scope project (/private/tmp/tcode-probe/proj). Restart to apply changes.","updateOutcome":"updated","oldVersion":"1.0.0","newVersion":"1.0.1"} diff --git a/crates/agent/tests/fixtures/plugins/codex/COMMANDS.md b/crates/agent/tests/fixtures/plugins/codex/COMMANDS.md new file mode 100644 index 000000000..ca1f62e39 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/COMMANDS.md @@ -0,0 +1,427 @@ +# Codex plugin probe: commands and state + +Recorded 2026-10-03 against `codex-cli 0.159.3` (`codex --version`; npm +package `@openai/codex` installed through mise at +`~/.local/share/mise/installs/node/26.8.1/lib/node_modules/@openai/codex`), +macOS arm64. Findings that cite these files: `tmp/probe-codex-plugins.md`. + +## Isolation + +- `CODEX_HOME=/tmp/tcode-probe/codex-home` for every recorded run. It started + empty. No `auth.json` was copied: no request needed authentication (see the + findings, question 6). +- `HOME=/tmp/tcode-probe/codex-fakehome` (empty) for every recorded run. + `CODEX_HOME` does not isolate `$HOME/.agents/skills`: an exploratory run + without this override listed the user's own skills + (`/Users//.agents/skills/*`, scope `user`) in `skills/list`. +- Project directory: `/tmp/tcode-probe/codex-proj` (a git repo with one + commit), not `/tmp/tcode-probe/proj`. A concurrent Claude-plugin probe uses + `/tmp/tcode-probe/proj` and wiped it in the middle of an exploratory run, so + this probe moved to `codex-`-prefixed directories: + `codex-proj`, `codex-elsewhere` (empty), `codex-nongit` (copy of + `codex-proj` without `.git`, marketplace renamed `tcode-probe-nongit`), + `codex-fakehome`. +- The no-network run used a separate fresh home + `/tmp/tcode-probe/codex-home-nonet` so the startup sync started from + nothing. +- `~/.codex` was never read or written. + +## app-server invocation + +Every server is `codex app-server` (argv exactly that, nothing else) with the +environment above plus `RUST_LOG=info` (stderr only; stdout frames are not +affected), stdin/stdout piped, and the process cwd stated per run. Framing is +the one in `crates/agent/src/codex.rs`: one JSON object per line, no +`"jsonrpc"` member, `initialize` with id 1 and + +```json +{"clientInfo":{"name":"tcode","title":"Tcode","version":"0.1.0"},"capabilities":{"experimentalApi":true}} +``` + +then the `{"method":"initialized"}` notification. Requests are sent one at a +time; each waits for its response. Teardown is closing stdin; every server +exited 0. + +The driver and scenario scripts stay outside the repository in +`/tmp/tcode-probe/` (`driver.py`, `scenario_main.py`, `scenario_cwd.py`, +`scenario_forms.py`, `scenario_nonet.py`, `scenario_reconcile.py`, +`race2.py`, `race3.py`, `package.py`); stderr logs are in +`/tmp/tcode-probe/logs/`. + +## File formats + +- Per-method files (`marketplace-add.jsonl` … `marketplace-remove.jsonl`): + every line is a literal frame of server A in the main run, request then + response. Only the frames the Codex adapter's tests read are kept; the + id → step mapping below marks the others "(not kept)". The observer, + notification, cwd-discovery, manifest-form, no-network and + external-reconcile runs are described for provenance; their recordings, + the CLI outputs, the generated schemas and the authored marketplaces are + not kept. +- `config-read.jsonl`: recorded later (2026-10-03, same binary) with the same + driver and framing against a fresh `CODEX_HOME=/tmp/tcode-plugins-live/rec-home`, + process cwd `/tmp`: `marketplace/add {"source":"/tmp/tcode-probe/codex-mkt"}` + (id 2, not kept), then `config/read {}` (id 3). Its `marketplaces` table is + the one the main run's `config.toml` snapshot shows after id 2. + +## Marketplace layout + +`/tmp/tcode-probe/codex-mkt` (marketplace `tcode-probe-mkt`, added by local +path): + +``` +.agents/plugins/marketplace.json name tcode-probe-mkt; alpha AVAILABLE/ON_INSTALL, beta AVAILABLE/ON_USE +plugins/alpha/.codex-plugin/plugin.json name, version 0.1.0, description, keywords, skills "./skills/", mcpServers "./mcp.json", interface +plugins/alpha/mcp.json mcpServers.alpha-docs: streamable-http http://127.0.0.1:9/mcp (nothing listens) +plugins/alpha/hooks/hooks.json SessionStart command "echo tcode-probe-alpha-hook" (found by default discovery) +plugins/alpha/skills/alpha-hello/SKILL.md +plugins/beta/.codex-plugin/plugin.json name, version 0.2.0, description, skills, interface +plugins/beta/skills/beta-hello/SKILL.md +``` + +alpha uses the `.codex-plugin/plugin.json` form because 0.159.3 loads plugin +hooks only from that form (manifest-form run). The first draft of alpha +used a root portable `plugin.json`; its hook never appeared. + +`/tmp/tcode-probe/codex-proj` (repo marketplace `tcode-probe-repo`): +`.agents/plugins/marketplace.json` with one +plugin `gamma` (`.codex-plugin/plugin.json`, one skill). + +Manifest-form matrix (`/tmp/tcode-probe/codex-formtest`, `codex-hooktest`, +`codex-mcptest`; the recording is not kept): + +| plugin | layout | +|---|---| +| rootonly | root `plugin.json` without `$schema` | +| compatonly | `.codex-plugin/plugin.json` only | +| both | root `plugin.json` without `$schema` + `.codex-plugin/plugin.json` | +| none | no manifest, `skills/` only | +| rootschema | root `plugin.json` with Agent Plugins `$schema` | +| hcompatfield | compat manifest, `"hooks": "./hooks/hooks.json"` | +| hcompatdefault | compat manifest, no `hooks` field, `hooks/hooks.json` present | +| hcompatinline | compat manifest, inline `hooks` object | +| hrootdefault | root `$schema` manifest, `hooks/hooks.json` present | +| hrootext | root `$schema` manifest, `extensions."com.openai".hooks` path | +| mcompatmcpjson | compat manifest, portable `mcp.json`, no `mcpServers` field | +| mcompatdotmcp | compat manifest, `"mcpServers": "./.mcp.json"` | +| mcompatptr | compat manifest, `"mcpServers": "./mcp.json"` (portable file) | +| aboth | root `$schema` manifest + compat overlay, `hooks/hooks.json`, `mcp.json` | +| aboth2 | as aboth, overlay with explicit `"hooks": "./hooks/hooks.json"` | +| aroothookext | root `$schema` manifest with `extensions."com.openai"` hooks + interface, no overlay | + +## Main run (`scenario_main.py`) + +Process cwd `/tmp/tcode-probe/codex-proj` for all five servers. + +- **A**: the management server; all per-method files. +- **B1-passive**: starts after A initialized; `initialize` only. +- **B2-reader**: after every A mutation, sends `plugin/installed {}`, + `skills/list {cwds:[proj]}` and `hooks/list {cwds:[proj]}` itself. +- **B3-session**: `thread/start {"cwd":proj,"approvalPolicy":"untrusted","sandbox":"read-only"}` + (the shape `codex.rs` sends), then idle. +- **B4-session-after-install**: same `thread/start`, started after A's + install, then idle. + +The observer frames were recorded but are not kept. B1, B3 and B4 start only +after A has initialized the home: starting several servers at the same moment +on a fresh `CODEX_HOME` makes some exit before replying with +`Error: failed to initialize sqlite state runtime under ` (12 of 36 +starts in `race2.py`, 3 servers × 12 fresh homes; 0 of 36 on an already +initialized home in `race3.py`). + +Server A, in order (`t` = seconds since start): + +| id | request | file | +|---|---|---| +| 1 | `initialize`, then `initialized` | (not kept) | +| 2 | `marketplace/add {"source":"/tmp/tcode-probe/codex-mkt"}` | marketplace-add.jsonl | +| 3 | `plugin/list {}` | (not kept) | +| 4 | `plugin/list {"cwds":[proj]}` | plugin-list-with-cwds.jsonl | +| 5 | `plugin/read {"pluginName":"alpha","marketplacePath":}` | plugin-read.jsonl | +| 6 | `skills/list {"cwds":[proj]}` before install | (not kept) | +| 7 | `hooks/list {"cwds":[proj]}` before install | (not kept) | +| 8 | `plugin/install {"pluginName":"alpha","marketplacePath":…}` | plugin-install.jsonl | +| 9 | `plugin/installed {}` | (not kept) | +| 10 | `plugin/installed {"cwds":[proj]}` | plugin-installed.jsonl | +| 11 | `skills/list` (enabled) | skills-list.jsonl | +| 12 | `hooks/list` (enabled) | hooks-list.jsonl | +| — | CLI `codex plugin list --json`, `… --json --available`, `codex plugin marketplace list --json` | (not kept) | +| 13 | `config/value/write {"keyPath":"plugins.alpha@tcode-probe-mkt.enabled","value":false,"mergeStrategy":"upsert"}` | config-value-write-enabled.jsonl | +| 14 | `plugin/installed {}` (disabled) | plugin-installed-after-disable.jsonl | +| 15 | `plugin/reconcile {"reason":…}` | (not kept) | +| 16 | `skills/list` (disabled) | skills-list.jsonl | +| 17 | `skills/list {"cwds":[proj],"forceReload":true}` (disabled) | (not kept) | +| 18 | `hooks/list` (disabled) | hooks-list.jsonl | +| — | CLI `codex plugin list --json` (disabled) | (not kept) | +| 19 | `config/value/write … "value":true …` | (not kept) | +| 20 | `plugin/installed {}` (re-enabled) | (not kept) | +| 21 | `skills/list` (re-enabled) | (not kept) | +| 22 | `hooks/list` (re-enabled) | (not kept) | +| 23 | `plugin/uninstall {"pluginId":"alpha@tcode-probe-mkt"}` | plugin-uninstall.jsonl | +| 24 | `plugin/reconcile` (after uninstall) | (not kept) | +| 25 | `plugin/installed {}` (after uninstall) | plugin-installed.jsonl | +| 26 | `skills/list` (after uninstall) | (not kept) | +| 27 | `hooks/list` (after uninstall) | (not kept) | +| 28 | `marketplace/remove {"marketplaceName":"tcode-probe-mkt"}` | marketplace-remove.jsonl | +| 29 | `plugin/list {"cwds":[proj]}` (after remove; curated catalog has synced by now) | (not kept) | + +After the lifecycle, the watcher control wrote +`$HOME/.agents/skills/probe-control/SKILL.md`, then +`/.agents/skills/probe-repo-control/SKILL.md`, then removed both, with a +4 s quiet window after each step (recording not kept). + +CLI commands (cwd `codex-proj`, same environment, no `RUST_LOG`): + +``` +codex plugin list --json +codex plugin list --json --available +codex plugin marketplace list --json +codex plugin list --json # after id 13 +``` + +## Other runs + +- `scenario_cwd.py` (cwd discovery). Server `D-cwd-proj` (cwd + `codex-proj`): `plugin/list` with `{}`, `cwds:[proj]`, `cwds:[proj/plugins]` + and `cwds:[codex-nongit]`; `skills/list {}` and `hooks/list {}`; + `plugin/install gamma` from the repo marketplace; `plugin/installed` with + `{}` and with `cwds:[proj]`; `skills/list` with `cwds:[codex-elsewhere]` and + with `cwds:[proj]`. Server `D-cwd-elsewhere` (cwd `codex-elsewhere`): + `plugin/list` and `plugin/installed`, each with `{}` and with `cwds:[proj]`; + `skills/list` with `{}` and with `cwds:[proj]`; + `plugin/uninstall gamma@tcode-probe-repo`; `plugin/installed cwds:[proj]`. +- `scenario_forms.py` (manifest forms). cwd `codex-proj`; for each of + the three test marketplaces: `marketplace/add`, `plugin/read` for every + plugin, then `marketplace/remove`. +- `scenario_nonet.py` (no network). `sandbox-exec -f nonet.sb codex + app-server`, where `nonet.sb` is + `(version 1) (allow default) (deny network-outbound (remote ip))`, with + `CODEX_HOME=/tmp/tcode-probe/codex-home-nonet` (fresh), cwd `codex-proj`: + the whole local lifecycle, plus `marketplace/add {"source":"openai/plugins"}` + and `plugin/list {"forceRefetch":true}`. +- `scenario_reconcile.py` (external reconcile). Server + `C-reconciler`: `marketplace/add`, then `plugin/reconcile` as a baseline, + then again after each change made outside it: CLI + `codex plugin add alpha@tcode-probe-mkt` (stdout + ``Added plugin `alpha` from marketplace `tcode-probe-mkt`.`` and + `Installed plugin root: /private/tmp/tcode-probe/codex-home/plugins/cache/tcode-probe-mkt/alpha/0.1.0`), + a second server `D-writer` sending `config/value/write enabled=false`, and + CLI `codex plugin remove alpha@tcode-probe-mkt` (stdout + ``Removed plugin `alpha` from marketplace `tcode-probe-mkt`.``). Ends with + `marketplace/remove`. + +## `CODEX_HOME` state after each mutating step (main run) + +Besides `config.toml` and `plugins/`, the first server start created the +state databases (`state_5.sqlite`, `logs_2.sqlite`, …), `installation_id`, +`skills/.system/` (five bundled system skills) and `.tmp/plugins/`. That last +directory is a checkout of the curated marketplace (`plugins.sha` +`5fd93af4cd0c623e020d0cc7e9ce178b4ac1f70f`); it shows up as marketplace +`openai-api-curated` once the background sync finishes, as in id 29 but not +yet at id 3. + +#### before any request + +`config.toml`: + +```toml + +``` + +`plugins/`: + +``` + +``` + +#### after marketplace/add + +`config.toml`: + +```toml +[marketplaces.tcode-probe-mkt] +source_type = "local" +source = "/private/tmp/tcode-probe/codex-mkt" +``` + +`plugins/`: + +``` + +``` + +#### after plugin/install alpha + +`config.toml`: + +```toml +[marketplaces.tcode-probe-mkt] +source_type = "local" +source = "/private/tmp/tcode-probe/codex-mkt" + +[plugins."alpha@tcode-probe-mkt"] +enabled = true +``` + +`plugins/`: + +``` +$CODEX_HOME/plugins +$CODEX_HOME/plugins/cache +$CODEX_HOME/plugins/cache/tcode-probe-mkt +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0 +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/hooks +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/hooks/hooks.json +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills/alpha-hello +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills/alpha-hello/SKILL.md +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/.codex-plugin +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/.codex-plugin/plugin.json +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/mcp.json +``` + +#### after config/value/write enabled=false + +`config.toml`: + +```toml +[marketplaces.tcode-probe-mkt] +source_type = "local" +source = "/private/tmp/tcode-probe/codex-mkt" + +[plugins."alpha@tcode-probe-mkt"] +enabled = false +``` + +`plugins/`: + +``` +$CODEX_HOME/plugins +$CODEX_HOME/plugins/cache +$CODEX_HOME/plugins/cache/tcode-probe-mkt +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0 +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/hooks +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/hooks/hooks.json +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills/alpha-hello +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills/alpha-hello/SKILL.md +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/.codex-plugin +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/.codex-plugin/plugin.json +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/mcp.json +``` + +#### after plugin/reconcile (after disable) + +`config.toml`: + +```toml +[marketplaces.tcode-probe-mkt] +source_type = "local" +source = "/private/tmp/tcode-probe/codex-mkt" + +[plugins."alpha@tcode-probe-mkt"] +enabled = false +``` + +`plugins/`: + +``` +$CODEX_HOME/plugins +$CODEX_HOME/plugins/cache +$CODEX_HOME/plugins/cache/tcode-probe-mkt +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0 +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/hooks +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/hooks/hooks.json +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills/alpha-hello +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills/alpha-hello/SKILL.md +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/.codex-plugin +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/.codex-plugin/plugin.json +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/mcp.json +``` + +#### after config/value/write enabled=true + +`config.toml`: + +```toml +[marketplaces.tcode-probe-mkt] +source_type = "local" +source = "/private/tmp/tcode-probe/codex-mkt" + +[plugins."alpha@tcode-probe-mkt"] +enabled = true +``` + +`plugins/`: + +``` +$CODEX_HOME/plugins +$CODEX_HOME/plugins/cache +$CODEX_HOME/plugins/cache/tcode-probe-mkt +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0 +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/hooks +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/hooks/hooks.json +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills/alpha-hello +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills/alpha-hello/SKILL.md +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/.codex-plugin +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/.codex-plugin/plugin.json +$CODEX_HOME/plugins/cache/tcode-probe-mkt/alpha/0.1.0/mcp.json +``` + +#### after plugin/uninstall alpha + +`config.toml`: + +```toml +[marketplaces.tcode-probe-mkt] +source_type = "local" +source = "/private/tmp/tcode-probe/codex-mkt" +``` + +`plugins/`: + +``` +$CODEX_HOME/plugins +$CODEX_HOME/plugins/cache +$CODEX_HOME/plugins/cache/tcode-probe-mkt +``` + +#### after plugin/reconcile (after uninstall) + +`config.toml`: + +```toml +[marketplaces.tcode-probe-mkt] +source_type = "local" +source = "/private/tmp/tcode-probe/codex-mkt" +``` + +`plugins/`: + +``` +$CODEX_HOME/plugins +$CODEX_HOME/plugins/cache +$CODEX_HOME/plugins/cache/tcode-probe-mkt +``` + +#### after marketplace/remove + +`config.toml`: + +```toml +(empty file) +``` + +`plugins/`: + +``` +$CODEX_HOME/plugins +$CODEX_HOME/plugins/cache +$CODEX_HOME/plugins/cache/tcode-probe-mkt +``` diff --git a/crates/agent/tests/fixtures/plugins/codex/config-read.jsonl b/crates/agent/tests/fixtures/plugins/codex/config-read.jsonl new file mode 100644 index 000000000..3c4358bdf --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/config-read.jsonl @@ -0,0 +1,2 @@ +{"id":3,"method":"config/read","params":{}} +{"id":3,"result":{"config":{"model":null,"review_model":null,"model_context_window":null,"model_auto_compact_token_limit":null,"model_auto_compact_token_limit_scope":null,"model_provider":null,"approval_policy":null,"approvals_reviewer":null,"sandbox_mode":null,"sandbox_workspace_write":null,"forced_chatgpt_workspace_id":null,"forced_login_method":null,"web_search":null,"tools":null,"instructions":null,"developer_instructions":null,"compact_prompt":null,"model_reasoning_effort":null,"model_reasoning_summary":null,"model_verbosity":null,"service_tier":null,"analytics":null,"apps":null,"browser_use":null,"computer_use":null,"desktop":null,"project_doc_fallback_filenames":[],"tui":null,"sqlite_home":null,"experimental_realtime_ws_startup_context":null,"include_environment_context":true,"permissions":null,"features":{"api_key_model_discovery":false,"auth_elicitation":true,"background_paginated_rollout_migration":false,"codex_apps_mcp_2026_07_28":false,"mcp_2026_07_28":false,"memories":false,"mentions_v2":true,"remote_control":false,"remote_plugin":true,"tool_suggest":true,"windows_sandbox_service":false},"experimental_realtime_start_instructions":null,"experimental_realtime_ws_model":null,"responses_api_metadata":null,"hide_agent_reasoning":false,"plugins":{},"include_permissions_instructions":true,"suppress_unstable_features_warning":null,"model_post_turn_compact_threshold_percent":null,"memories":null,"feedback":null,"personality":null,"experimental_thread_store":null,"agents":null,"skills":null,"project_root_markers":[".git"],"js_repl_node_path":null,"file_opener":"vscode","check_for_update_on_startup":null,"experimental_compact_prompt_file":null,"show_raw_agent_reasoning":null,"mcp_oauth_callback_url":null,"orchestrator":null,"mcp_optional_startup_grace_ms":null,"mcp_oauth_callback_port":null,"default_permissions":null,"js_repl_node_module_dirs":null,"experimental_use_unified_exec_tool":null,"include_apps_instructions":true,"model_catalog_json":null,"tool_output_token_limit":null,"cli_auth_credentials_store":"file","model_instructions_file":null,"mcp_servers":{},"experimental_realtime_ws_base_url":null,"mcp_oauth_credentials_store":"auto","history":{"persistence":"save-all","max_bytes":null},"auto_review":null,"allow_login_shell":true,"plan_mode_reasoning_effort":null,"ghost_snapshot":null,"profiles":{},"include_collaboration_mode_instructions":true,"experimental_thread_store_endpoint":null,"notify":null,"hooks":null,"notice":null,"marketplaces":{"tcode-probe-mkt":{"last_updated":null,"last_revision":null,"source_type":"local","source":"/private/tmp/tcode-probe/codex-mkt","ref":null,"sparse_paths":null}},"project_doc_max_bytes":32768,"thread_unload_delay_secs":null,"profile":null,"otel":null,"mcp_enterprise_managed_auth":null,"apps_mcp_product_sku":null,"allow_symlinked_codex_home":null,"audio":null,"model_providers":{},"windows":null,"background_terminal_max_timeout":300000,"log_dir":null,"shell_environment_policy":{"inherit":null,"ignore_default_excludes":null,"exclude":null,"set":null,"include_only":null,"filters":null,"experimental_use_profile":null},"realtime":null,"experimental_realtime_ws_backend_prompt":null,"goals":null,"experimental_realtime_webrtc_call_base_url":null,"tool_suggest":null,"cloud":null,"projects":null,"disable_paste_burst":null,"oss_provider":null,"chatgpt_base_url":"https://chatgpt.com/backend-api/","openai_base_url":null},"origins":{"marketplaces.tcode-probe-mkt.source":{"name":{"type":"user","file":"/private/tmp/tcode-plugins-live/rec-home/config.toml","profile":null},"version":"sha256:537636f0796f5eb8ed3546ae0ab7d7b0858dbe69f8c0555ed1c2f9043ff0dd7c"},"marketplaces.tcode-probe-mkt.source_type":{"name":{"type":"user","file":"/private/tmp/tcode-plugins-live/rec-home/config.toml","profile":null},"version":"sha256:537636f0796f5eb8ed3546ae0ab7d7b0858dbe69f8c0555ed1c2f9043ff0dd7c"}}}} diff --git a/crates/agent/tests/fixtures/plugins/codex/config-value-write-enabled.jsonl b/crates/agent/tests/fixtures/plugins/codex/config-value-write-enabled.jsonl new file mode 100644 index 000000000..b53d4e946 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/config-value-write-enabled.jsonl @@ -0,0 +1,2 @@ +{"id":13,"method":"config/value/write","params":{"keyPath":"plugins.alpha@tcode-probe-mkt.enabled","value":false,"mergeStrategy":"upsert"}} +{"id":13,"result":{"status":"ok","version":"sha256:f2be58cf32b904ebdd370e6f824091b0f16b18977506d313e63350b26151f846","filePath":"/private/tmp/tcode-probe/codex-home/config.toml","overriddenMetadata":null}} diff --git a/crates/agent/tests/fixtures/plugins/codex/hooks-list.jsonl b/crates/agent/tests/fixtures/plugins/codex/hooks-list.jsonl new file mode 100644 index 000000000..c63f5eee9 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/hooks-list.jsonl @@ -0,0 +1,4 @@ +{"id":12,"method":"hooks/list","params":{"cwds":["/tmp/tcode-probe/codex-proj"]}} +{"id":12,"result":{"data":[{"cwd":"/tmp/tcode-probe/codex-proj","hooks":[{"key":"alpha@tcode-probe-mkt:hooks/hooks.json:session_start:0:0","eventName":"sessionStart","handlerType":"command","command":"echo tcode-probe-alpha-hook","async":false,"matcher":null,"timeoutSec":600,"statusMessage":"tcode probe alpha hook","additionalContextLimit":null,"sourcePath":"/private/tmp/tcode-probe/codex-home/plugins/cache/tcode-probe-mkt/alpha/0.1.0/hooks/hooks.json","source":"plugin","pluginId":"alpha@tcode-probe-mkt","displayOrder":0,"enabled":true,"isManaged":false,"currentHash":"sha256:d628fcaca98205d5b4dc811ab48aea01b3e8fd22a09c76192e72228d94939dc8","trustStatus":"untrusted"}],"warnings":[],"errors":[]}]}} +{"id":18,"method":"hooks/list","params":{"cwds":["/tmp/tcode-probe/codex-proj"]}} +{"id":18,"result":{"data":[{"cwd":"/tmp/tcode-probe/codex-proj","hooks":[],"warnings":[],"errors":[]}]}} diff --git a/crates/agent/tests/fixtures/plugins/codex/marketplace-add.jsonl b/crates/agent/tests/fixtures/plugins/codex/marketplace-add.jsonl new file mode 100644 index 000000000..7328163b5 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/marketplace-add.jsonl @@ -0,0 +1,2 @@ +{"id":2,"method":"marketplace/add","params":{"source":"/tmp/tcode-probe/codex-mkt"}} +{"id":2,"result":{"marketplaceName":"tcode-probe-mkt","installedRoot":"/private/tmp/tcode-probe/codex-mkt","alreadyAdded":false}} diff --git a/crates/agent/tests/fixtures/plugins/codex/marketplace-remove.jsonl b/crates/agent/tests/fixtures/plugins/codex/marketplace-remove.jsonl new file mode 100644 index 000000000..859a6afa7 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/marketplace-remove.jsonl @@ -0,0 +1,2 @@ +{"id":28,"method":"marketplace/remove","params":{"marketplaceName":"tcode-probe-mkt"}} +{"id":28,"result":{"marketplaceName":"tcode-probe-mkt","installedRoot":null}} diff --git a/crates/agent/tests/fixtures/plugins/codex/plugin-install.jsonl b/crates/agent/tests/fixtures/plugins/codex/plugin-install.jsonl new file mode 100644 index 000000000..d4640e5f2 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/plugin-install.jsonl @@ -0,0 +1,2 @@ +{"id":8,"method":"plugin/install","params":{"pluginName":"alpha","marketplacePath":"/private/tmp/tcode-probe/codex-mkt/.agents/plugins/marketplace.json"}} +{"id":8,"result":{"authPolicy":"ON_INSTALL","appsNeedingAuth":[]}} diff --git a/crates/agent/tests/fixtures/plugins/codex/plugin-installed-after-disable.jsonl b/crates/agent/tests/fixtures/plugins/codex/plugin-installed-after-disable.jsonl new file mode 100644 index 000000000..1f148b3c7 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/plugin-installed-after-disable.jsonl @@ -0,0 +1,2 @@ +{"id":14,"method":"plugin/installed","params":{}} +{"id":14,"result":{"marketplaces":[{"name":"tcode-probe-mkt","path":"/private/tmp/tcode-probe/codex-mkt/.agents/plugins/marketplace.json","interface":{"displayName":"Tcode Probe Marketplace"},"plugins":[{"id":"alpha@tcode-probe-mkt","remotePluginId":null,"version":null,"localVersion":"0.1.0","name":"alpha","shareContext":null,"source":{"type":"local","path":"/private/tmp/tcode-probe/codex-mkt/plugins/alpha"},"installed":true,"installedAt":null,"enabled":false,"installPolicy":"AVAILABLE","installPolicySource":null,"mustShowInstallationInterstitial":null,"authPolicy":"ON_INSTALL","availability":"AVAILABLE","disabledReason":null,"eligiblePlanTypes":null,"interface":{"displayName":"Alpha Probe","shortDescription":"Skill + MCP + hook probe plugin","longDescription":null,"developerName":"Tcode probe","category":"Productivity","capabilities":[],"websiteUrl":null,"privacyPolicyUrl":null,"termsOfServiceUrl":null,"defaultPrompt":null,"brandColor":null,"composerIcon":null,"composerIconUrl":null,"logo":null,"logoDark":null,"logoUrl":null,"logoUrlDark":null,"screenshots":[],"screenshotUrls":[]},"keywords":["tcode","probe"]}]}],"marketplaceLoadErrors":[]}} diff --git a/crates/agent/tests/fixtures/plugins/codex/plugin-installed.jsonl b/crates/agent/tests/fixtures/plugins/codex/plugin-installed.jsonl new file mode 100644 index 000000000..02d9b80b1 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/plugin-installed.jsonl @@ -0,0 +1,4 @@ +{"id":10,"method":"plugin/installed","params":{"cwds":["/tmp/tcode-probe/codex-proj"]}} +{"id":10,"result":{"marketplaces":[{"name":"tcode-probe-mkt","path":"/private/tmp/tcode-probe/codex-mkt/.agents/plugins/marketplace.json","interface":{"displayName":"Tcode Probe Marketplace"},"plugins":[{"id":"alpha@tcode-probe-mkt","remotePluginId":null,"version":null,"localVersion":"0.1.0","name":"alpha","shareContext":null,"source":{"type":"local","path":"/private/tmp/tcode-probe/codex-mkt/plugins/alpha"},"installed":true,"installedAt":null,"enabled":true,"installPolicy":"AVAILABLE","installPolicySource":null,"mustShowInstallationInterstitial":null,"authPolicy":"ON_INSTALL","availability":"AVAILABLE","disabledReason":null,"eligiblePlanTypes":null,"interface":{"displayName":"Alpha Probe","shortDescription":"Skill + MCP + hook probe plugin","longDescription":null,"developerName":"Tcode probe","category":"Productivity","capabilities":[],"websiteUrl":null,"privacyPolicyUrl":null,"termsOfServiceUrl":null,"defaultPrompt":null,"brandColor":null,"composerIcon":null,"composerIconUrl":null,"logo":null,"logoDark":null,"logoUrl":null,"logoUrlDark":null,"screenshots":[],"screenshotUrls":[]},"keywords":["tcode","probe"]}]}],"marketplaceLoadErrors":[]}} +{"id":25,"method":"plugin/installed","params":{}} +{"id":25,"result":{"marketplaces":[],"marketplaceLoadErrors":[]}} diff --git a/crates/agent/tests/fixtures/plugins/codex/plugin-list-with-cwds.jsonl b/crates/agent/tests/fixtures/plugins/codex/plugin-list-with-cwds.jsonl new file mode 100644 index 000000000..b3c4e4041 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/plugin-list-with-cwds.jsonl @@ -0,0 +1,2 @@ +{"id":4,"method":"plugin/list","params":{"cwds":["/tmp/tcode-probe/codex-proj"]}} +{"id":4,"result":{"marketplaces":[{"name":"tcode-probe-mkt","path":"/private/tmp/tcode-probe/codex-mkt/.agents/plugins/marketplace.json","interface":{"displayName":"Tcode Probe Marketplace"},"plugins":[{"id":"alpha@tcode-probe-mkt","remotePluginId":null,"version":null,"localVersion":"0.1.0","name":"alpha","shareContext":null,"source":{"type":"local","path":"/private/tmp/tcode-probe/codex-mkt/plugins/alpha"},"installed":false,"installedAt":null,"enabled":false,"installPolicy":"AVAILABLE","installPolicySource":null,"mustShowInstallationInterstitial":null,"authPolicy":"ON_INSTALL","availability":"AVAILABLE","disabledReason":null,"eligiblePlanTypes":null,"interface":{"displayName":"Alpha Probe","shortDescription":"Skill + MCP + hook probe plugin","longDescription":null,"developerName":"Tcode probe","category":"Productivity","capabilities":[],"websiteUrl":null,"privacyPolicyUrl":null,"termsOfServiceUrl":null,"defaultPrompt":null,"brandColor":null,"composerIcon":null,"composerIconUrl":null,"logo":null,"logoDark":null,"logoUrl":null,"logoUrlDark":null,"screenshots":[],"screenshotUrls":[]},"keywords":["tcode","probe"]},{"id":"beta@tcode-probe-mkt","remotePluginId":null,"version":null,"localVersion":"0.2.0","name":"beta","shareContext":null,"source":{"type":"local","path":"/private/tmp/tcode-probe/codex-mkt/plugins/beta"},"installed":false,"installedAt":null,"enabled":false,"installPolicy":"AVAILABLE","installPolicySource":null,"mustShowInstallationInterstitial":null,"authPolicy":"ON_USE","availability":"AVAILABLE","disabledReason":null,"eligiblePlanTypes":null,"interface":{"displayName":"Beta Probe","shortDescription":"Skill-only probe plugin","longDescription":null,"developerName":"Tcode probe","category":"Productivity","capabilities":[],"websiteUrl":null,"privacyPolicyUrl":null,"termsOfServiceUrl":null,"defaultPrompt":null,"brandColor":null,"composerIcon":null,"composerIconUrl":null,"logo":null,"logoDark":null,"logoUrl":null,"logoUrlDark":null,"screenshots":[],"screenshotUrls":[]},"keywords":[]}]},{"name":"tcode-probe-repo","path":"/tmp/tcode-probe/codex-proj/.agents/plugins/marketplace.json","interface":null,"plugins":[{"id":"gamma@tcode-probe-repo","remotePluginId":null,"version":null,"localVersion":"0.3.0","name":"gamma","shareContext":null,"source":{"type":"local","path":"/tmp/tcode-probe/codex-proj/plugins/gamma"},"installed":false,"installedAt":null,"enabled":false,"installPolicy":"AVAILABLE","installPolicySource":null,"mustShowInstallationInterstitial":null,"authPolicy":"ON_INSTALL","availability":"AVAILABLE","disabledReason":null,"eligiblePlanTypes":null,"interface":{"displayName":null,"shortDescription":null,"longDescription":null,"developerName":null,"category":"Productivity","capabilities":[],"websiteUrl":null,"privacyPolicyUrl":null,"termsOfServiceUrl":null,"defaultPrompt":null,"brandColor":null,"composerIcon":null,"composerIconUrl":null,"logo":null,"logoDark":null,"logoUrl":null,"logoUrlDark":null,"screenshots":[],"screenshotUrls":[]},"keywords":[]}]}],"marketplaceLoadErrors":[],"featuredPluginIds":[]}} diff --git a/crates/agent/tests/fixtures/plugins/codex/plugin-read.jsonl b/crates/agent/tests/fixtures/plugins/codex/plugin-read.jsonl new file mode 100644 index 000000000..6dc694f97 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/plugin-read.jsonl @@ -0,0 +1,2 @@ +{"id":5,"method":"plugin/read","params":{"pluginName":"alpha","marketplacePath":"/private/tmp/tcode-probe/codex-mkt/.agents/plugins/marketplace.json"}} +{"id":5,"result":{"plugin":{"marketplaceName":"tcode-probe-mkt","marketplacePath":"/private/tmp/tcode-probe/codex-mkt/.agents/plugins/marketplace.json","summary":{"id":"alpha@tcode-probe-mkt","remotePluginId":null,"version":null,"localVersion":"0.1.0","name":"alpha","shareContext":null,"source":{"type":"local","path":"/private/tmp/tcode-probe/codex-mkt/plugins/alpha"},"installed":false,"installedAt":null,"enabled":false,"installPolicy":"AVAILABLE","installPolicySource":null,"mustShowInstallationInterstitial":null,"authPolicy":"ON_INSTALL","availability":"AVAILABLE","disabledReason":null,"eligiblePlanTypes":null,"interface":{"displayName":"Alpha Probe","shortDescription":"Skill + MCP + hook probe plugin","longDescription":null,"developerName":"Tcode probe","category":"Productivity","capabilities":[],"websiteUrl":null,"privacyPolicyUrl":null,"termsOfServiceUrl":null,"defaultPrompt":null,"brandColor":null,"composerIcon":null,"composerIconUrl":null,"logo":null,"logoDark":null,"logoUrl":null,"logoUrlDark":null,"screenshots":[],"screenshotUrls":[]},"keywords":["tcode","probe"]},"shareUrl":null,"description":"Tcode probe plugin alpha: one skill, one MCP server, one hook.","skills":[{"name":"alpha:alpha-hello","description":"Tcode probe skill from plugin alpha. Replies with the word alpha.","shortDescription":null,"interface":null,"path":"/private/tmp/tcode-probe/codex-mkt/plugins/alpha/skills/alpha-hello/SKILL.md","enabled":true}],"onboardingSkill":null,"hooks":[{"key":"alpha@tcode-probe-mkt:hooks/hooks.json:session_start:0:0","eventName":"sessionStart"}],"apps":[],"appTemplates":[],"mcpServers":["alpha-docs"],"scheduledTasks":null}}} diff --git a/crates/agent/tests/fixtures/plugins/codex/plugin-uninstall.jsonl b/crates/agent/tests/fixtures/plugins/codex/plugin-uninstall.jsonl new file mode 100644 index 000000000..50e6b0c9f --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/plugin-uninstall.jsonl @@ -0,0 +1,2 @@ +{"id":23,"method":"plugin/uninstall","params":{"pluginId":"alpha@tcode-probe-mkt"}} +{"id":23,"result":{}} diff --git a/crates/agent/tests/fixtures/plugins/codex/skills-list.jsonl b/crates/agent/tests/fixtures/plugins/codex/skills-list.jsonl new file mode 100644 index 000000000..8b56da7bc --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/codex/skills-list.jsonl @@ -0,0 +1,4 @@ +{"id":11,"method":"skills/list","params":{"cwds":["/tmp/tcode-probe/codex-proj"]}} +{"id":11,"result":{"data":[{"cwd":"/tmp/tcode-probe/codex-proj","skills":[{"name":"alpha:alpha-hello","description":"Tcode probe skill from plugin alpha. Replies with the word alpha.","path":"/private/tmp/tcode-probe/codex-home/plugins/cache/tcode-probe-mkt/alpha/0.1.0/skills/alpha-hello/SKILL.md","scope":"user","enabled":true,"pluginId":"alpha@tcode-probe-mkt"},{"name":"imagegen","description":"Generate or edit raster images when the task benefits from AI-created bitmap visuals such as photos, illustrations, textures, sprites, mockups, or transparent-background cutouts. Use when Codex should create a brand-new image, transform an existing image, or derive visual variants from references, and the output should be a bitmap asset rather than repo-native code or vector. Do not use when the task is better handled by editing existing SVG/vector/code-native assets, extending an established icon or logo system, or building the visual directly in HTML/CSS/canvas.","interface":{"displayName":"Image Gen","shortDescription":"Generate or edit images for websites, games, and more","iconSmall":"/private/tmp/tcode-probe/codex-home/skills/.system/imagegen/assets/imagegen-small.svg","iconLarge":"/private/tmp/tcode-probe/codex-home/skills/.system/imagegen/assets/imagegen.png","iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":"Use $imagegen to make or edit an image for this project."},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/imagegen/SKILL.md","scope":"system","enabled":true,"pluginId":null},{"name":"openai-docs","description":"Use for Codex models/pricing, scheduled tasks, skills, settings, setup, troubleshooting, customization, automations, and self-knowledge—including 'you,' 'your,' 'this app,' or 'this coding agent' when they refer to Codex—and for OpenAI APIs/products and ChatGPT Work. Also use for model choice/migration, prompting, SDKs, Responses, Realtime, agents, evals, and Chat/Work/Codex comparisons. Do not use for generic app/software tasks that merely mention Codex.","shortDescription":"Codex models/pricing, scheduled tasks, skills, settings, setup, troubleshooting, and self-knowledge; OpenAI APIs and ChatGPT Work. 'You'/'this app' means Codex only.","interface":{"displayName":"OpenAI Docs","shortDescription":"OpenAI and Codex docs for models, skills, tasks, and setup","iconSmall":"/private/tmp/tcode-probe/codex-home/skills/.system/openai-docs/assets/openai-small.svg","iconLarge":"/private/tmp/tcode-probe/codex-home/skills/.system/openai-docs/assets/openai.png","iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":"Use OpenAI Docs for official docs lookup, questions about Codex itself or Codex surfaces, model selection, model migration, and prompt-upgrade work."},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/openai-docs/SKILL.md","scope":"system","enabled":true,"pluginId":null},{"name":"review-agent","description":"Perform a read-only, defect-first review of a specified code change and return every actionable finding. Use when another agent delegates review of uncommitted changes, a base-branch diff, a commit, or custom review instructions.","interface":{"displayName":"Review Agent","shortDescription":"Find actionable bugs in code changes","iconSmall":null,"iconLarge":null,"iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":"Use $review-agent to review the requested code changes and return actionable findings."},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/review-agent/SKILL.md","scope":"system","enabled":true,"pluginId":null},{"name":"skill-creator","description":"Create or update a Codex skill with appropriately scoped instructions and any needed supporting resources.","shortDescription":"Create or update a skill","interface":{"displayName":"Skill Creator","shortDescription":"Create or update a skill","iconSmall":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-creator/assets/skill-creator-small.svg","iconLarge":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-creator/assets/skill-creator.png","iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":null},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-creator/SKILL.md","scope":"system","enabled":true,"pluginId":null},{"name":"skill-installer","description":"Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos).","shortDescription":"Install curated skills from openai/skills or other repos","interface":{"displayName":"Skill Installer","shortDescription":"Install curated skills from openai/skills or other repos","iconSmall":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-installer/assets/skill-installer-small.svg","iconLarge":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-installer/assets/skill-installer.png","iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":null},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-installer/SKILL.md","scope":"system","enabled":true,"pluginId":null}],"errors":[]}]}} +{"id":16,"method":"skills/list","params":{"cwds":["/tmp/tcode-probe/codex-proj"]}} +{"id":16,"result":{"data":[{"cwd":"/tmp/tcode-probe/codex-proj","skills":[{"name":"imagegen","description":"Generate or edit raster images when the task benefits from AI-created bitmap visuals such as photos, illustrations, textures, sprites, mockups, or transparent-background cutouts. Use when Codex should create a brand-new image, transform an existing image, or derive visual variants from references, and the output should be a bitmap asset rather than repo-native code or vector. Do not use when the task is better handled by editing existing SVG/vector/code-native assets, extending an established icon or logo system, or building the visual directly in HTML/CSS/canvas.","interface":{"displayName":"Image Gen","shortDescription":"Generate or edit images for websites, games, and more","iconSmall":"/private/tmp/tcode-probe/codex-home/skills/.system/imagegen/assets/imagegen-small.svg","iconLarge":"/private/tmp/tcode-probe/codex-home/skills/.system/imagegen/assets/imagegen.png","iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":"Use $imagegen to make or edit an image for this project."},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/imagegen/SKILL.md","scope":"system","enabled":true,"pluginId":null},{"name":"openai-docs","description":"Use for Codex models/pricing, scheduled tasks, skills, settings, setup, troubleshooting, customization, automations, and self-knowledge—including 'you,' 'your,' 'this app,' or 'this coding agent' when they refer to Codex—and for OpenAI APIs/products and ChatGPT Work. Also use for model choice/migration, prompting, SDKs, Responses, Realtime, agents, evals, and Chat/Work/Codex comparisons. Do not use for generic app/software tasks that merely mention Codex.","shortDescription":"Codex models/pricing, scheduled tasks, skills, settings, setup, troubleshooting, and self-knowledge; OpenAI APIs and ChatGPT Work. 'You'/'this app' means Codex only.","interface":{"displayName":"OpenAI Docs","shortDescription":"OpenAI and Codex docs for models, skills, tasks, and setup","iconSmall":"/private/tmp/tcode-probe/codex-home/skills/.system/openai-docs/assets/openai-small.svg","iconLarge":"/private/tmp/tcode-probe/codex-home/skills/.system/openai-docs/assets/openai.png","iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":"Use OpenAI Docs for official docs lookup, questions about Codex itself or Codex surfaces, model selection, model migration, and prompt-upgrade work."},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/openai-docs/SKILL.md","scope":"system","enabled":true,"pluginId":null},{"name":"review-agent","description":"Perform a read-only, defect-first review of a specified code change and return every actionable finding. Use when another agent delegates review of uncommitted changes, a base-branch diff, a commit, or custom review instructions.","interface":{"displayName":"Review Agent","shortDescription":"Find actionable bugs in code changes","iconSmall":null,"iconLarge":null,"iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":"Use $review-agent to review the requested code changes and return actionable findings."},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/review-agent/SKILL.md","scope":"system","enabled":true,"pluginId":null},{"name":"skill-creator","description":"Create or update a Codex skill with appropriately scoped instructions and any needed supporting resources.","shortDescription":"Create or update a skill","interface":{"displayName":"Skill Creator","shortDescription":"Create or update a skill","iconSmall":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-creator/assets/skill-creator-small.svg","iconLarge":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-creator/assets/skill-creator.png","iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":null},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-creator/SKILL.md","scope":"system","enabled":true,"pluginId":null},{"name":"skill-installer","description":"Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos).","shortDescription":"Install curated skills from openai/skills or other repos","interface":{"displayName":"Skill Installer","shortDescription":"Install curated skills from openai/skills or other repos","iconSmall":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-installer/assets/skill-installer-small.svg","iconLarge":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-installer/assets/skill-installer.png","iconSmallUrl":null,"iconLargeUrl":null,"brandColor":null,"defaultPrompt":null},"path":"/private/tmp/tcode-probe/codex-home/skills/.system/skill-installer/SKILL.md","scope":"system","enabled":true,"pluginId":null}],"errors":[]}]}} diff --git a/crates/agent/tests/fixtures/plugins/grok/COMMANDS.md b/crates/agent/tests/fixtures/plugins/grok/COMMANDS.md new file mode 100644 index 000000000..489896bc1 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/COMMANDS.md @@ -0,0 +1,89 @@ +# Grok Build plugin probe — commands + +- CLI: `grok 1.0.46 (2765805b9442)`, darwin-arm64, binary + `/tmp/grok-inspect.D1FK/bin/grok`. Date 2026-10-03, macOS (Darwin 27.0.0, + arm64). +- Every command ran with stdin = `/dev/null` (with an inherited non-terminal + stdin, `grok plugin` fails with `Device not configured (os error 6)`), from + the cwd `/tmp/tcode-probe-grok/outside`, stdout and stderr captured + separately. +- Env: `PATH USER LOGNAME SHELL TMPDIR LANG` plus `HOME=/tmp/tcode-probe-grok/home` + and `GROK_HOME=/tmp/tcode-probe-grok/home/.grok`. No `XAI_API_KEY`; plugin + management needs none. With the real `HOME`, Grok also reads the user's + Claude Code settings and lists their marketplaces, so `HOME` is isolated too. +- Paths: local marketplace `/tmp/tcode-probe-grok/mkt` (plugins `alpha`: + skill, command, SessionStart hook and an HTTP MCP server; `beta`: skill), + git marketplace `/tmp/tcode-probe-grok/tools` added as a `file://` URL + (plugin `zeta`: skill and an HTTP MCP server), and the two-plugin repository + `/tmp/tcode-probe-grok/multi` (`delta`, `epsilon`, one directory each, each + with `.grok-plugin/plugin.json`). All three are git repositories. + Marketplace indexes are `.grok-plugin/marketplace.json`; Grok names a + source after its directory or repository (`mkt`, `tools`), not after the + index's `name`. + +## Recorded commands + +| # | Command | Exit | Fixture | +|---|---------|------|---------| +| 01 | `grok plugin list --json --available` | 0 | (`[]`) | +| 02 | `grok plugin marketplace add /tmp/tcode-probe-grok/mkt` | 0 | `Added marketplace source: mkt (…)` | +| 03 | `grok plugin marketplace add file:///tmp/tcode-probe-grok/tools` | 0 | `Added marketplace source: tools (…)` | +| 04 | `grok plugin marketplace list --json` | 0 | `marketplace-list.json` | +| 05 | `grok plugin list --json --available` | 0 | three available plugins | +| 06 | `grok plugin install alpha@mkt` | 1 | `install-untrusted.stderr.txt` | +| 07 | `grok plugin install alpha@mkt --trust` | 0 | `install-trusted.txt` | +| 08 | `grok plugin install zeta@tools --trust` | 0 | `Installed 1 plugin(s) from tools: zeta` | +| 09 | `grok plugin install /tmp/tcode-probe-grok/multi --trust` | 0 | `Installed 2 plugin(s) from …: epsilon, delta` | +| 10 | `grok plugin list --json --available` | 0 | `plugin-list-available.json` | +| 11 | `grok plugin details alpha` | 0 | `details-alpha.txt` | +| 13 | `grok plugin details delta` | 0 | `details-delta.txt` | +| 14 | `grok plugin update alpha` (after alpha → 1.1.0 in `mkt`) | 0 | `update.txt` | +| 16 | `grok plugin uninstall delta` | 1 | `Plugin "delta" belongs to repo "multi-0b1623cc" which also contains: - epsilon … To proceed: grok plugin uninstall delta --confirm` | +| 17 | `grok plugin uninstall zeta` | 0 | `Uninstalled 1 plugin(s): zeta` | +| 19 | `grok plugin marketplace remove mkt` | 0 | `Removed marketplace source and uninstalled 1 plugin(s): alpha-e84495d9` | +| 22 | `grok plugin install nosuch@tools --trust` | 1 | `install-unknown.stderr.txt` | +| 23 | `grok plugin marketplace add file:///tmp/tcode-probe-grok/tools` | 1 | `Error: Marketplace source already configured: …` | +| 24 | `grok plugin disable delta` | 0 | `Disabled plugin: delta`; `config.toml` `[plugins] disabled = ["delta"]` | +| 25 | `grok inspect --json` | 0 | `plugins: [{"name": "delta", "enabled": true, …}, …]` | +| 26 | `grok plugin enable delta` | 0 | `Enabled plugin: delta` | +| 27 | `grok plugin update delta` (after delta → 1.0.1 in `multi`) | 0 | `update-path-plugin.txt`; the list still reports 1.0.0 | + +Each mutation was followed by a `plugin list --json --available` or +`plugin marketplace list --json` showing its effect (05, 10, 15, 18, 20, 21, +28 in the run). + +## Findings the implementation relies on + +- Installing without `--trust` exits 1 and prints, on stderr, what installing + activates and the exact command that proceeds (06). That command and text + are the challenge a person accepts; `--trust` is passed only on a re-run + whose challenge text hashes to the accepted SHA-256. +- `plugin update` refreshes a git marketplace's checkout itself (verified + 1.1.0 → 1.2.0 without `marketplace update`). For a path install it reports + `local symlink, already live` and changes nothing (27), so Update is offered + for marketplace installs only. A plugin added to a git marketplace after it + was added is not installable until `grok plugin marketplace update`, which + the plugin contract has no action for. +- Uninstalling one plugin of a multi-plugin repository needs `--confirm` and + removes them all (16). The contract's uninstall carries no consent, so + Uninstall is not offered for those plugins. +- `marketplace remove` uninstalls the marketplace's plugins without asking + (19); the listing reports them so the host confirms first. +- Enablement: `install --trust` adds the plugin to `[plugins] enabled` in + `config.toml`; `disable`/`enable` move it between `enabled` and `disabled`. + No `grok plugin` read reports it, and `grok inspect` (text and `--json`) + reports a disabled plugin as enabled (25). A session started while `alpha` + was disabled listed none of its commands; after `enable`, a new session + listed `alpha-cmd` and `alpha-note`. Enable and Disable are therefore not + offered. + +## When a change applies + +Checked against a running `grok agent stdio` session (scripted model backend): +after `grok plugin install zeta@tools --trust` ran outside the session, +nothing changed until the prompt `/reload-plugins`, which answered `Plugin +registry rebuilt: 4 plugin(s), 1 hook(s) reloaded, MCP refreshed, 6 skill(s) +refreshed.`, listed `zeta-note`, and connected zeta's MCP server (`zeta-http` +ready, with its bearer header). After `grok plugin update beta` added a skill, +`/reload-plugins` listed `beta-extra`. A session started afterwards had both. +Hence `ApplyNote::ReloadOrNextSession`. diff --git a/crates/agent/tests/fixtures/plugins/grok/details-alpha.txt b/crates/agent/tests/fixtures/plugins/grok/details-alpha.txt new file mode 100644 index 000000000..3b2e3fc72 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/details-alpha.txt @@ -0,0 +1,10 @@ +alpha-e84495d9 + path: /tmp/tcode-probe-grok/home/.grok/installed-plugins/alpha-e84495d9 + kind: local: /private/tmp/tcode-probe-grok/mkt/plugins/alpha + source: mkt + installed: 2026-10-03T07:22:24.112923+00:00 + updated: 2026-10-03T07:22:24.112923+00:00 + plugins (1): + alpha v1.0.0 + description: Skill, command, SessionStart hook and an unreachable HTTP MCP server + components: 1 skill dir(s), 1 command dir(s), 0 agent dir(s), hooks, MCP servers diff --git a/crates/agent/tests/fixtures/plugins/grok/details-delta.txt b/crates/agent/tests/fixtures/plugins/grok/details-delta.txt new file mode 100644 index 000000000..1aeeaff9b --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/details-delta.txt @@ -0,0 +1,8 @@ +multi-0b1623cc + path: /tmp/tcode-probe-grok/home/.grok/installed-plugins/multi-0b1623cc + kind: local: /tmp/tcode-probe-grok/multi + installed: 2026-10-03T07:22:24.557732+00:00 + updated: 2026-10-03T07:22:24.557732+00:00 + plugins (2): + epsilon v0.1.0 (subdir: epsilon) + delta v1.0.0 (subdir: delta) diff --git a/crates/agent/tests/fixtures/plugins/grok/install-trusted.txt b/crates/agent/tests/fixtures/plugins/grok/install-trusted.txt new file mode 100644 index 000000000..0beca35ca --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/install-trusted.txt @@ -0,0 +1 @@ +Installed 1 plugin(s) from mkt: alpha diff --git a/crates/agent/tests/fixtures/plugins/grok/install-unknown.stderr.txt b/crates/agent/tests/fixtures/plugins/grok/install-unknown.stderr.txt new file mode 100644 index 000000000..79bc3bf78 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/install-unknown.stderr.txt @@ -0,0 +1 @@ +Error: No marketplace plugin named "nosuch" in "tools". diff --git a/crates/agent/tests/fixtures/plugins/grok/install-untrusted.stderr.txt b/crates/agent/tests/fixtures/plugins/grok/install-untrusted.stderr.txt new file mode 100644 index 000000000..d0362e616 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/install-untrusted.stderr.txt @@ -0,0 +1,5 @@ +Installing "alpha" from marketplace "mkt" requires confirmation. +Plugins can run hooks, MCP servers, and skills on your machine, so installation needs explicit trust. + +To proceed, re-run with --trust: + grok plugin install alpha@mkt --trust diff --git a/crates/agent/tests/fixtures/plugins/grok/marketplace-list.json b/crates/agent/tests/fixtures/plugins/grok/marketplace-list.json new file mode 100644 index 000000000..103a2b0b4 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/marketplace-list.json @@ -0,0 +1,17 @@ +[ + { + "name": "mkt", + "kind": "local", + "source": { + "path": "/tmp/tcode-probe-grok/mkt" + } + }, + { + "name": "tools", + "kind": "git", + "source": { + "url": "file:///tmp/tcode-probe-grok/tools", + "branch": null + } + } +] diff --git a/crates/agent/tests/fixtures/plugins/grok/plugin-list-available.json b/crates/agent/tests/fixtures/plugins/grok/plugin-list-available.json new file mode 100644 index 000000000..d5c028fd7 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/plugin-list-available.json @@ -0,0 +1,49 @@ +[ + { + "status": "installed", + "name": "alpha", + "repo_key": "alpha-e84495d9", + "version": "1.0.0", + "path": "/tmp/tcode-probe-grok/home/.grok/installed-plugins/alpha-e84495d9", + "source": "/private/tmp/tcode-probe-grok/mkt/plugins/alpha", + "marketplace": "mkt" + }, + { + "status": "installed", + "name": "epsilon", + "repo_key": "multi-0b1623cc", + "version": "0.1.0", + "path": "/tmp/tcode-probe-grok/home/.grok/installed-plugins/multi-0b1623cc", + "source": "/tmp/tcode-probe-grok/multi", + "marketplace": null + }, + { + "status": "installed", + "name": "delta", + "repo_key": "multi-0b1623cc", + "version": "1.0.0", + "path": "/tmp/tcode-probe-grok/home/.grok/installed-plugins/multi-0b1623cc", + "source": "/tmp/tcode-probe-grok/multi", + "marketplace": null + }, + { + "status": "installed", + "name": "zeta", + "repo_key": "zeta-49137176", + "version": "0.1.0", + "path": "/tmp/tcode-probe-grok/home/.grok/installed-plugins/zeta-49137176", + "source": "/private/tmp/tcode-probe-grok/home/.grok/marketplace-cache/f86a7344487eb091/plugins/zeta", + "marketplace": "tools" + }, + { + "status": "available", + "name": "beta", + "version": "0.1.0", + "description": "Skill only", + "marketplace": "mkt", + "skill_count": 1, + "has_hooks": false, + "has_agents": false, + "has_mcp": false + } +] diff --git a/crates/agent/tests/fixtures/plugins/grok/update-path-plugin.txt b/crates/agent/tests/fixtures/plugins/grok/update-path-plugin.txt new file mode 100644 index 000000000..c4aef1a3c --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/update-path-plugin.txt @@ -0,0 +1 @@ +multi-0b1623cc: local symlink, already live diff --git a/crates/agent/tests/fixtures/plugins/grok/update.txt b/crates/agent/tests/fixtures/plugins/grok/update.txt new file mode 100644 index 000000000..efe2428ca --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/update.txt @@ -0,0 +1 @@ +alpha-e84495d9: updated (1.0.0 -> 1.1.0) diff --git a/crates/core/src/acp.rs b/crates/core/src/acp.rs index 206b10d48..27da07e01 100644 --- a/crates/core/src/acp.rs +++ b/crates/core/src/acp.rs @@ -42,6 +42,13 @@ fn default_true() -> bool { } impl InstalledAcpAgent { + /// Whether new sessions may use this agent. An agent that duplicates a + /// native integration stays installed, so its existing sessions resume and + /// it can be removed, but new sessions use the native provider. + pub fn offered_for_new_sessions(&self) -> bool { + !agent::HIDDEN_ACP_AGENT_IDS.contains(&self.id.as_str()) + } + /// The whitespace-split launch arguments (mirrors Claude's "Launch arguments"). pub fn extra_args(&self) -> Vec { self.launch_args diff --git a/crates/core/src/project.rs b/crates/core/src/project.rs index 797ead890..e6b4ad2a8 100644 --- a/crates/core/src/project.rs +++ b/crates/core/src/project.rs @@ -8,7 +8,7 @@ use std::{ use agent::{ApprovalMode, InteractionMode, OptionSelection, ProviderKind, ResumeCursor}; use serde::{Deserialize, Serialize}; -use crate::settings::{ProjectSort, Settings, acp_color_key, provider_key}; +use crate::settings::{ProjectSort, Settings, acp_color_key}; /// A project groups sessions (threads) that share a working-directory root. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -156,8 +156,8 @@ impl SessionMeta { } /// The key [`Settings::provider_color`] resolves this thread's color from: - /// a user profile id, an ACP agent (`acp:`), or the built-in - /// [`provider_key`]. A user profile is its own provider to the user even + /// a user profile id, an ACP agent (`acp:`), or the built-in profile + /// id. A user profile is its own provider to the user even /// when it drives a built-in protocol, so it never inherits the brand color. pub fn provider_color_key(&self) -> String { if let Some(profile_id) = self @@ -172,7 +172,7 @@ impl SessionMeta { { return acp_color_key(agent_id); } - provider_key(self.provider).to_string() + Settings::builtin_profile_id(self.provider).to_string() } #[cfg(feature = "process")] diff --git a/crates/core/src/provider_colors.rs b/crates/core/src/provider_colors.rs index 60f1eddda..823610c31 100644 --- a/crates/core/src/provider_colors.rs +++ b/crates/core/src/provider_colors.rs @@ -16,12 +16,14 @@ pub fn builtin_provider_color(kind: ProviderKind) -> Option { ProviderKind::Codex => Some(0x8B5CF6), ProviderKind::Pi => Some(0x4D9ABF), ProviderKind::OpenCode => Some(0x22A06B), + ProviderKind::Cursor => Some(0x9CA3AF), + ProviderKind::Grok => Some(0x71717A), ProviderKind::Acp => None, } } -/// Hues for user-added providers (`0xRRGGBB`). They stay clear of the four -/// brand colors above so a custom endpoint never masquerades as a built-in. +/// Hues for user-added providers (`0xRRGGBB`). They stay clear of the brand +/// colors above so a custom endpoint never masquerades as a built-in. pub const PROVIDER_COLOR_PALETTE: [u32; 10] = [ 0xF59E0B, // amber 0x84CC16, // lime diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 2b13ed01a..0cc99b505 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -50,14 +50,17 @@ impl ProjectSort { } } -/// The stable settings-file key for a provider. It keys -/// both `settings.json`'s `providers` map and `secrets.json`. +/// The stable settings-file key for a provider: its entry in `settings.json`'s +/// `providers` map. Profile-keyed data such as `secrets.json` uses +/// [`Settings::builtin_profile_id`]. pub fn provider_key(provider: ProviderKind) -> &'static str { match provider { ProviderKind::Codex => "codex", ProviderKind::ClaudeCode => "claude", ProviderKind::Pi => "pi", ProviderKind::OpenCode => "opencode", + ProviderKind::Cursor => "cursor", + ProviderKind::Grok => "grok", // ACP agents are not one provider but many: their per-agent settings // live in `Settings::acp_agents`, keyed by registry id. This bucket only // ever holds the shared fallbacks (it is never written by the ACP card). @@ -78,6 +81,8 @@ pub fn provider_label(provider: ProviderKind) -> &'static str { ProviderKind::ClaudeCode => "Claude", ProviderKind::Pi => "pi", ProviderKind::OpenCode => "OpenCode", + ProviderKind::Cursor => "Cursor", + ProviderKind::Grok => "Grok", ProviderKind::Acp => "ACP", } } @@ -115,8 +120,8 @@ pub struct ProviderSettings { /// Override for the CLI binary (`None` = resolve from PATH). #[serde(default, skip_serializing_if = "Option::is_none")] pub binary_path: Option, - /// Claude: `HOME`; Codex: `CODEX_HOME`; pi: `PI_CODING_AGENT_DIR`. - /// OpenCode ignores this field because it has no single-home override. + /// The provider's home variable ([`agent::LaunchEnv::home`]). OpenCode + /// ignores this field because it has no single-home override. #[serde(default, skip_serializing_if = "Option::is_none")] pub home_path: Option, /// Native-provider CLI arguments appended on session start (ignored for Codex). @@ -221,8 +226,7 @@ impl ProviderSettings { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ProviderProfile { /// The protocol this profile drives. Determines which native adapter - /// (`claude` / `codex` / `pi` / `opencode`) spawns it and how its native - /// protocol is normalized. + /// spawns it and how its native protocol is normalized. pub kind: ProviderKind, /// The card configuration (env, binary, home, models, display name, …). /// Flattened so a profile's JSON is a superset of a provider card's. @@ -1270,11 +1274,23 @@ impl Settings { .or_default() } - /// The built-in profile id for a native protocol (its [`provider_key`]). - /// This is the id a session carries when it uses the default, non-custom - /// configuration for its kind. + /// The built-in profile id for a native protocol. This is the id a session + /// carries when it uses the default, non-custom configuration for its kind. + /// + /// Built-in ids resolve before user profiles, whose ids are slugs of ASCII + /// alphanumerics and hyphens. Providers added after user profiles existed + /// therefore take a `native:` id no slug can produce, so a user profile + /// that already has the provider's name keeps resolving to itself. pub fn builtin_profile_id(kind: ProviderKind) -> &'static str { - provider_key(kind) + match kind { + ProviderKind::Cursor => "native:cursor", + ProviderKind::Grok => "native:grok", + ProviderKind::ClaudeCode + | ProviderKind::Codex + | ProviderKind::Pi + | ProviderKind::OpenCode + | ProviderKind::Acp => provider_key(kind), + } } /// Whether `id` names a built-in provider profile. @@ -1285,14 +1301,10 @@ impl Settings { /// The protocol kind of a built-in profile id, if it is one. Used to route a /// mutation of a built-in profile back to its `providers` card. pub fn builtin_kind_from_id(id: &str) -> Option { - match id { - "claude" => Some(ProviderKind::ClaudeCode), - "codex" => Some(ProviderKind::Codex), - "pi" => Some(ProviderKind::Pi), - "opencode" => Some(ProviderKind::OpenCode), - "acp" => Some(ProviderKind::Acp), - _ => None, - } + ProviderKind::NATIVE + .into_iter() + .chain([ProviderKind::Acp]) + .find(|kind| Self::builtin_profile_id(*kind) == id) } /// Resolve a profile id to its protocol kind and effective card settings. @@ -1318,7 +1330,7 @@ impl Settings { /// picker iterates. pub fn profiles_for_kind(&self, kind: ProviderKind) -> Vec { let mut out = vec![ResolvedProfile { - id: provider_key(kind).to_string(), + id: Self::builtin_profile_id(kind).to_string(), kind, settings: self.provider(kind), }]; @@ -2073,6 +2085,36 @@ mod tests { assert_eq!(settings.allocate_profile_id("Klaude Kode"), "klaude-kode-2"); } + #[test] + fn a_user_profile_named_after_a_new_native_provider_keeps_its_identity() { + // Saved before Cursor was native: a Claude profile the user named "Cursor". + let settings: Settings = serde_json::from_str( + r##"{"profiles":{"cursor":{"kind":"claude_code","display_name":"Cursor","accent_color":"#123456"}}}"##, + ) + .unwrap(); + assert_eq!( + settings.resolved_profile("cursor").unwrap().kind, + ProviderKind::ClaudeCode + ); + let [builtin] = settings + .profiles_for_kind(ProviderKind::Cursor) + .try_into() + .unwrap(); + assert_eq!(builtin.kind, ProviderKind::Cursor); + assert_eq!( + settings.resolved_profile(&builtin.id).unwrap().kind, + ProviderKind::Cursor + ); + assert!(!Settings::is_builtin_profile_id( + &settings.allocate_profile_id(&builtin.id) + )); + let thread = crate::project::SessionMeta::new(ProviderKind::Cursor, "/x".into(), None); + assert_ne!( + settings.provider_color(&thread.provider_color_key()), + 0x123456 + ); + } + /// A build that predates a field must not destroy it: unknown keys survive a /// load → save round trip. (We hit this for real: an older binary dropped /// `acp_agents` and the next save wiped the installed agents.) diff --git a/crates/protocol/src/command.rs b/crates/protocol/src/command.rs index 179afabe5..02dc0a2d0 100644 --- a/crates/protocol/src/command.rs +++ b/crates/protocol/src/command.rs @@ -1,6 +1,8 @@ use std::path::PathBuf; -use agent::{ApprovalDecision, ApprovalMode, InteractionMode, ProviderKind, RewindMode}; +use agent::{ + ApprovalDecision, ApprovalMode, InteractionMode, PluginScope, ProviderKind, RewindMode, +}; use serde::{Deserialize, Serialize}; use tcode_core::{ acp::AcpAgentPatch, @@ -12,7 +14,7 @@ use tcode_core::{ pub use tcode_core::settings::SettingsPatch; -use crate::ExternalThread; +use crate::{ExternalThread, RuntimeOperationId}; /// Fallback cell metrics matching the host emulator's own defaults, used when /// a client resizes without knowing its physical cell size. @@ -144,6 +146,58 @@ pub enum Command { session_id: String, id: String, }, + /// List a profile's native plugins as seen from `cwd` (a project + /// directory), or from outside any project when `cwd` is `None`. + RefreshProviderPlugins { + profile_id: String, + #[serde(default)] + cwd: Option, + }, + InstallProviderPlugin { + profile_id: String, + entry_id: String, + scope: PluginScope, + #[serde(default)] + cwd: Option, + }, + UninstallProviderPlugin { + profile_id: String, + entry_id: String, + scope: PluginScope, + #[serde(default)] + cwd: Option, + }, + SetProviderPluginEnabled { + profile_id: String, + entry_id: String, + scope: PluginScope, + enabled: bool, + #[serde(default)] + cwd: Option, + }, + UpdateProviderPlugin { + profile_id: String, + entry_id: String, + scope: PluginScope, + #[serde(default)] + cwd: Option, + }, + AddProviderMarketplace { + profile_id: String, + source: String, + #[serde(default)] + cwd: Option, + }, + RemoveProviderMarketplace { + profile_id: String, + name: String, + #[serde(default)] + cwd: Option, + }, + ResolvePluginChallenge { + op_id: RuntimeOperationId, + accept: bool, + }, ResetSettings, WriteRelaunchMarker { session_id: String, @@ -476,6 +530,7 @@ impl Command { | Self::RefreshProviderUsage | Self::CheckProviderVersions | Self::RefreshAcpRegistry + | Self::RefreshProviderPlugins { .. } | Self::LoadBranches { .. } | Self::CopyPlan { .. } | Self::DownloadPlan { .. } diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs index 80fb782a6..4d032fcda 100644 --- a/crates/protocol/src/event.rs +++ b/crates/protocol/src/event.rs @@ -190,6 +190,104 @@ pub struct ProvidersStatus { /// Environment-variable names present for each profile. Values never cross /// the protocol boundary. pub secret_names: HashMap>, + /// Native plugin catalogs of the profiles a client asked about. + #[serde(default)] + pub plugins: Vec, +} + +/// One provider profile's native plugin catalog. The native CLI's own files +/// are the truth; the host only keeps the last listing in memory. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderPluginCatalog { + pub profile_id: String, + /// The project directory the catalog was listed from; `None` lists from + /// outside any project, so only user scope is offered. + #[serde(default)] + pub context_cwd: Option, + #[serde(default)] + pub marketplaces: Vec, + #[serde(default)] + pub marketplace_actions: Vec, + #[serde(default)] + pub entries: Vec, + /// Native errors for parts of the catalog that could not be read; the + /// rest of the listing still stands. + #[serde(default)] + pub errors: Vec, + pub state: PluginCatalogState, + #[serde(default)] + pub loading: bool, + /// Plugin ids or marketplace names with an operation in flight. + #[serde(default)] + pub pending: Vec, + #[serde(default)] + pub challenges: Vec, +} + +/// How far the entries can be trusted. A failed listing keeps the last +/// known entries rather than clearing them. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum PluginCatalogState { + Fresh, + Stale { reason: PluginStaleReason }, + Error { message: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginStaleReason { + NotLoaded, + /// Listed for another directory or profile configuration. + ContextChanged, + /// A native change went through and has not been listed yet. + Changed, +} + +/// A native confirmation an operation is waiting on. It is answered with +/// [`crate::Command::ResolvePluginChallenge`]; a command the CLI shows again +/// arrives as a new challenge with a new `op_id`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginChallenge { + pub op_id: RuntimeOperationId, + pub profile_id: String, + /// The plugin id or marketplace name the operation targets. + pub entry_id: String, + pub kind: PluginChallengeKind, + #[serde(default)] + pub native_text: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum PluginChallengeKind { + /// Run this marketplace-declared command. + AcceptCommand { + command: String, + sha256: String, + #[serde(default)] + mode: Option, + }, + /// The native operation has no confirmation of its own but removes more + /// than its target: these installed plugins, in every scope. + ConfirmDestructive { uninstalls: Vec }, +} + +/// The native operation a plugin toast reports on. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum PluginOperationTarget { + Plugin { + action: agent::PluginActionKind, + scope: agent::PluginScope, + plugin: String, + }, + AddMarketplace { + source: String, + }, + RemoveMarketplace { + name: String, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -435,6 +533,22 @@ pub enum RuntimeToast { name: String, detail: String, }, + PluginOperationStarted { + operation: RuntimeOperationId, + profile_id: String, + target: PluginOperationTarget, + }, + PluginOperationSucceeded { + operation: RuntimeOperationId, + profile_id: String, + target: PluginOperationTarget, + }, + PluginOperationFailed { + operation: RuntimeOperationId, + profile_id: String, + target: PluginOperationTarget, + detail: String, + }, } #[non_exhaustive] diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 3deab44d3..ef6e1798d 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -16,10 +16,11 @@ pub use command::{Command, CommandResponse, SettingsPatch, TerminalSelection, Th pub use event::{ AcpMarketplaceItem, EventEnvelope, ExternalImportState, ExternalImportStatus, GitActionRequest, GitStatusStatus, IndexSnapshot, IndexSummary, MergeWorktreeFailure, NoticeSeverity, - ProviderVersionStatus, ProvidersStatus, QueuedMessageStatus, RuntimeEffect, RuntimeError, - RuntimeNotice, RuntimeNotification, RuntimeOperationId, RuntimeToast, ServerEvent, - SessionEventRecord, SessionStatus, TcodeUpdateStatus, TerminalContextStatus, - TerminalSplitStatus, TerminalStatus, Topic, + PluginCatalogState, PluginChallenge, PluginChallengeKind, PluginOperationTarget, + PluginStaleReason, ProviderPluginCatalog, ProviderVersionStatus, ProvidersStatus, + QueuedMessageStatus, RuntimeEffect, RuntimeError, RuntimeNotice, RuntimeNotification, + RuntimeOperationId, RuntimeToast, ServerEvent, SessionEventRecord, SessionStatus, + TcodeUpdateStatus, TerminalContextStatus, TerminalSplitStatus, TerminalStatus, Topic, }; pub use query::{ ExternalThread, GitDiffResult, GitDiffScope, GitFileText, HostedDevice, HostingAction, @@ -38,8 +39,11 @@ pub use wire::{ // authentication into the transport, so hello carries no token; version 6 // sends index and history changes instead of whole replacements and // compresses the native transport; version 7 carries the running turn and -// the requests it waits on in the session status. -pub const PROTOCOL_VERSION: u32 = 7; +// the requests it waits on in the session status; version 8 adds provider +// plugin catalogs, their commands and challenges; version 9 adds the Cursor and +// Grok provider kinds, which an older peer cannot decode because `ProviderKind` +// has no unknown fallback. +pub const PROTOCOL_VERSION: u32 = 9; #[cfg(test)] mod tests; diff --git a/crates/protocol/src/tests.rs b/crates/protocol/src/tests.rs index d93e37552..5f78080c0 100644 --- a/crates/protocol/src/tests.rs +++ b/crates/protocol/src/tests.rs @@ -1033,3 +1033,133 @@ fn path_info_kinds_read_older_peers_and_spell_the_flags() { ); assert!(!relayed.probing_direct); } + +/// A provider-plugin catalog and a plugin command are read by clients that +/// render the actions the host computed, so their literal shapes are the +/// contract; an older providers snapshot still decodes with no catalogs. +#[test] +fn provider_plugin_catalog_and_commands_use_their_documented_wire_shapes() { + let older: ProvidersStatus = serde_json::from_value(json!({ + "model_catalogs": {}, "models_loading": {}, "provider_versions": {}, + "tcode_update": {"current": "1.0.0", "latest": null, "release_url": null, + "update_available": false, "checking": false}, + "provider_snapshots": {}, "acp_marketplace_items": [], "acp_registry_loading": false, + "acp_registry_error": null, "acp_installing": [], "providers_checked_at": null, + "providers_checking": false, "secret_names": {} + })) + .unwrap(); + assert!(older.plugins.is_empty()); + + let catalog: ProviderPluginCatalog = serde_json::from_value(json!({ + "profile_id": "claude", + "context_cwd": "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/work/proj", + "marketplaces": [{"name": "tcode-probe", "source": "/tmp/tcode-probe/mkt", + "kind": "local_path", "location": "/tmp/tcode-probe/mkt"}], + "marketplace_actions": [ + {"type": "add"}, + {"type": "remove", "marketplace": "tcode-probe", "uninstalls": ["alpha@tcode-probe"]} + ], + "entries": [{ + "id": "alpha@tcode-probe", + "name": "alpha", + "version": "1.0.0", + "source": {"marketplace": "tcode-probe", "kind": "local_path"}, + "installations": [{"scope": "project", "location": "/cache/alpha/1.0.0", + "version": "1.0.0", "scope_enabled": true}], + "enabled": "yes", + "declared": {"skills": ["greet", "hello"], "hooks": ["SessionStart"], + "mcp_servers": ["alpha-dead"]}, + "actions": [{"type": "disable", "scope": "project"}, + {"type": "update", "scope": "project"}], + "diagnostics": [["message", "Restart to apply changes."]] + }], + "errors": ["failed to load marketplace /work/proj/.agents/plugins/marketplace.json"], + "state": {"type": "stale", "content": {"reason": "changed"}}, + "loading": true, + "pending": ["gamma@tcode-probe"], + "challenges": [{ + "op_id": 12, + "profile_id": "claude", + "entry_id": "gamma@tcode-probe", + "kind": {"type": "accept_command", "content": { + "command": "echo /tmp/tcode-probe/gamma-src", "sha256": "b9c02c85", "mode": "copy"}}, + "native_text": "\"gamma\" is installed by running a command" + }] + })) + .unwrap(); + assert_eq!( + catalog.errors, + ["failed to load marketplace /work/proj/.agents/plugins/marketplace.json"] + ); + let alpha = &catalog.entries[0]; + assert_eq!(alpha.enabled, agent::Tri::Yes); + assert_eq!(alpha.description, None); + assert!(alpha.errors.is_empty()); + assert_eq!( + alpha.installations[0], + agent::PluginInstallation { + scope: agent::PluginScope::Project, + location: Some(PathBuf::from("/cache/alpha/1.0.0")), + version: Some("1.0.0".into()), + scope_enabled: Some(true), + } + ); + let declared = alpha.declared.as_ref().unwrap(); + assert_eq!(declared.agents, None); + assert_eq!( + declared.mcp_servers.as_deref(), + Some(&["alpha-dead".to_string()][..]) + ); + assert_eq!( + alpha.actions, + [ + agent::PluginAction::Disable { + scope: agent::PluginScope::Project + }, + agent::PluginAction::Update { + scope: agent::PluginScope::Project + }, + ] + ); + assert_eq!( + catalog.marketplace_actions[1], + agent::MarketplaceAction::Remove { + marketplace: "tcode-probe".into(), + uninstalls: vec!["alpha@tcode-probe".into()], + } + ); + assert_eq!( + catalog.state, + PluginCatalogState::Stale { + reason: PluginStaleReason::Changed + } + ); + assert_eq!( + catalog.challenges[0].kind, + PluginChallengeKind::AcceptCommand { + command: "echo /tmp/tcode-probe/gamma-src".into(), + sha256: "b9c02c85".into(), + mode: Some("copy".into()), + } + ); + assert_eq!(catalog.challenges[0].op_id, RuntimeOperationId(12)); + + let command = decode_client_line(r#"{"id":4,"payload":{"type":"command","content":{"type":"install_provider_plugin","content":{"profile_id":"claude","entry_id":"gamma@tcode-probe","scope":"project","cwd":"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/work/proj"}}}}"#).unwrap(); + assert_eq!( + command.payload, + ClientPayload::Command(Command::InstallProviderPlugin { + profile_id: "claude".into(), + entry_id: "gamma@tcode-probe".into(), + scope: agent::PluginScope::Project, + cwd: Some(PathBuf::from("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/work/proj")), + }) + ); + let resolve = decode_client_line(r#"{"id":5,"payload":{"type":"command","content":{"type":"resolve_plugin_challenge","content":{"op_id":12,"accept":true}}}}"#).unwrap(); + assert_eq!( + resolve.payload, + ClientPayload::Command(Command::ResolvePluginChallenge { + op_id: RuntimeOperationId(12), + accept: true, + }) + ); +} diff --git a/crates/runtime/src/app/acp.rs b/crates/runtime/src/app/acp.rs index 0fabf7346..4197ecae2 100644 --- a/crates/runtime/src/app/acp.rs +++ b/crates/runtime/src/app/acp.rs @@ -200,7 +200,7 @@ impl AppState { /// provider rail). ACP agents have no model catalog: the agent publishes its /// models over the wire once the session starts. pub fn set_active_acp_agent(&mut self, target_id: &str, id: &str, cx: &mut HostCx) { - let provider_commands = self.cached_provider_commands(ProviderKind::Acp, Some(id)); + let provider_commands = self.cached_provider_commands(ProviderKind::Acp, None, Some(id)); let Some(active) = self.resident_mut(target_id) else { return; }; diff --git a/crates/runtime/src/app/command_validation.rs b/crates/runtime/src/app/command_validation.rs index 3cbb2afbb..cd8844011 100644 --- a/crates/runtime/src/app/command_validation.rs +++ b/crates/runtime/src/app/command_validation.rs @@ -11,6 +11,7 @@ impl AppState { code: code.into(), message: message.into(), }; + self.validate_plugin_command(command)?; match command { Command::DeleteProfile { profile_id } if Settings::is_builtin_profile_id(profile_id) => @@ -83,6 +84,21 @@ impl AppState { { return Err(error("unknown_acp_agent", "This agent is not installed.")); } + Command::SetActiveAcpAgent { session_id, id } + if self + .settings + .acp_agents + .get(id) + .is_some_and(|agent| !agent.offered_for_new_sessions()) + && self.resident(session_id).is_none_or(|session| { + session.meta.acp_agent_id.as_deref() != Some(id.as_str()) + }) => + { + return Err(error( + "native_acp_agent", + "This agent duplicates a native provider; use that provider for new sessions.", + )); + } Command::MergeWorktree { session_id } => { return match self.find_meta(session_id) { None => Err(error( diff --git a/crates/runtime/src/app/events.rs b/crates/runtime/src/app/events.rs index 31abff373..65dbcced1 100644 --- a/crates/runtime/src/app/events.rs +++ b/crates/runtime/src/app/events.rs @@ -98,15 +98,25 @@ impl AppState { // into the timeline or the persisted JSONL log. Parked sessions still // receive provider updates, so update/cache those too. AgentEvent::ProviderCommands { commands } => { - let cache_key = self.resident_mut(session_id).map(|resident| { + let owner = self.resident_mut(session_id).map(|resident| { resident.provider_commands.clone_from(commands); - (resident.meta.provider, resident.meta.acp_agent_id.clone()) + ( + resident.meta.provider, + resident.meta.profile_id.clone(), + resident.meta.acp_agent_id.clone(), + ) }); - if let Some((provider, acp_agent_id)) = cache_key { + let key = owner.and_then(|(provider, profile_id, acp_agent_id)| { + self.commands_cache_key( + provider, + profile_id.as_deref(), + acp_agent_id.as_deref(), + ) + }); + if let Some(key) = key { self.enqueue_store_write( StoreWrite::SaveCommands { - provider, - acp_agent_id, + key, commands: commands.clone(), }, cx, @@ -165,7 +175,12 @@ impl AppState { apply(resident); resident.meta.clone() }); - if let Some(meta) = meta.filter(|meta| meta.acp_agent_id.is_some()) { + if let Some(meta) = meta.filter(|meta| { + matches!( + meta.provider.caps().option_descriptors, + OptionDescriptors::Wire + ) + }) { self.persist_meta(&meta, cx); } return; diff --git a/crates/runtime/src/app/mod.rs b/crates/runtime/src/app/mod.rs index a689a4d06..ee5785705 100644 --- a/crates/runtime/src/app/mod.rs +++ b/crates/runtime/src/app/mod.rs @@ -71,7 +71,7 @@ use tcode_services::import::{ use tcode_services::provider_probe::{default_program, probe_provider, run_capture_env}; use tcode_services::session_search::SessionSearch; use tcode_services::settings::SettingsStore; -use tcode_services::store::{SessionStore, now_millis, now_secs}; +use tcode_services::store::{CommandsCacheKey, SessionStore, now_millis, now_secs}; use tcode_services::user_files; use tcode_services::version_check::provider_updates::{ self, CheckInput as ProviderCheckInput, Installation, @@ -93,12 +93,6 @@ const RESIDENT_IDLE_GRACE: Duration = Duration::from_secs(60 * 60); /// the primary bound, while this comfortably preserves typical orchestrate /// fleets whose idle children may be re-messaged. const MAX_IDLE_RESIDENTS: usize = 16; -const NATIVE_PROVIDER_KINDS: [ProviderKind; 4] = [ - ProviderKind::ClaudeCode, - ProviderKind::Codex, - ProviderKind::Pi, - ProviderKind::OpenCode, -]; type ProviderLaunchFuture = Pin> + Send + 'static>>; @@ -240,6 +234,7 @@ use history::SessionLog; mod lifecycle; mod options; mod orchestrate; +mod plugins; mod providers; mod send; mod sessions; @@ -261,8 +256,8 @@ use active_session::{ use orchestrate::McpWiring; pub use providers::ProviderCatalog; use providers::{ - effort_selection, normalized_selections, provider_secret_names, session_launch_env, - session_options, + effort_selection, launch_env_for_profile, normalized_selections, provider_secret_names, + session_launch_env, session_options, }; pub use sessions::ResidentSessions; pub(crate) use snapshots::DomainDiff; @@ -366,6 +361,8 @@ pub struct AppState { pub acp_registry_error: Option, /// Registry ids currently downloading (their marketplace row shows a spinner). pub acp_installing: std::collections::HashSet, + /// Native plugin catalogs by profile id, listed on demand. + plugin_catalogs: HashMap, mcp: McpWiring, callback_last_turn: HashMap, callback_approval_requests: HashSet<(String, String)>, @@ -479,7 +476,7 @@ impl AppState { // works offline; a background refresh (see `refresh_model_catalogs`) // updates it once the providers respond. let mut model_catalogs = HashMap::new(); - for provider in NATIVE_PROVIDER_KINDS { + for provider in ProviderKind::NATIVE { let cached = store.load_models(provider); if !cached.is_empty() { model_catalogs.insert(provider, cached); @@ -529,6 +526,7 @@ impl AppState { acp_registry_loading: false, acp_registry_error: None, acp_installing: std::collections::HashSet::new(), + plugin_catalogs: HashMap::new(), mcp: McpWiring::default(), callback_last_turn: HashMap::new(), callback_approval_requests: HashSet::new(), diff --git a/crates/runtime/src/app/options.rs b/crates/runtime/src/app/options.rs index 67d9efffd..e6bd567cf 100644 --- a/crates/runtime/src/app/options.rs +++ b/crates/runtime/src/app/options.rs @@ -20,7 +20,8 @@ impl AppState { ) { let profile_id = profile_id.filter(|id| !Settings::is_builtin_profile_id(id)); let remembered_effort = self.remembered_effort(provider, model.as_deref()); - let store = self.store.clone(); + let provider_commands = + self.cached_provider_commands(provider, profile_id.as_deref(), None); let Some(active) = self.resident_mut(target_id) else { return; }; @@ -42,7 +43,7 @@ impl AppState { // selections, then restore the effort last used with this model. active.meta.option_selections.clear(); active.meta.option_selections.extend(remembered_effort); - active.provider_commands = store.load_commands(active.meta.provider, None); + active.provider_commands = provider_commands; active.pending_ultrathink = false; return; } @@ -72,7 +73,7 @@ impl AppState { active.meta.model = model; active.meta.option_selections.clear(); active.meta.option_selections.extend(remembered_effort); - active.provider_commands = store.load_commands(provider, None); + active.provider_commands = provider_commands; active.provider_options.clear(); active.pending_ultrathink = false; if active.pending_relay.is_some() { @@ -283,8 +284,7 @@ impl AppState { self.upsert_session_in_memory(meta.clone()); let session_id = meta.id.clone(); let cwd = meta.cwd.clone(); - let provider_commands = - self.cached_provider_commands(meta.provider, meta.acp_agent_id.as_deref()); + let provider_commands = self.cached_provider_commands_for(&meta); self.residents.live.insert( session_id.clone(), ActiveSession::new(meta, false, provider_commands), diff --git a/crates/runtime/src/app/orchestrate.rs b/crates/runtime/src/app/orchestrate.rs index d514de101..a97b3c22b 100644 --- a/crates/runtime/src/app/orchestrate.rs +++ b/crates/runtime/src/app/orchestrate.rs @@ -393,8 +393,7 @@ impl AppState { ); self.upsert_session_in_memory(meta.clone()); let id = meta.id.clone(); - let provider_commands = - self.cached_provider_commands(meta.provider, meta.acp_agent_id.as_deref()); + let provider_commands = self.cached_provider_commands_for(&meta); let mut child = Self::build_draft_session( meta.project_id.clone().unwrap_or_default(), meta.cwd.clone(), @@ -823,7 +822,7 @@ impl AppState { pub(super) fn load_background_session(&mut self, meta: SessionMeta, cx: &mut HostCx) { let thread_id = meta.id.clone(); - let commands = self.cached_provider_commands(meta.provider, meta.acp_agent_id.as_deref()); + let commands = self.cached_provider_commands_for(&meta); let mut child = Self::build_draft_session( meta.project_id.clone().unwrap_or_default(), meta.cwd.clone(), @@ -1435,6 +1434,8 @@ fn resolve_orchestrate_profiles( "codex" => ProviderKind::Codex, "pi" => ProviderKind::Pi, "opencode" | "open_code" | "open-code" => ProviderKind::OpenCode, + "cursor" => ProviderKind::Cursor, + "grok" => ProviderKind::Grok, "acp" => { return Err( "ACP child dispatch is not available yet; configure a native-provider child model" @@ -1580,6 +1581,8 @@ pub(super) fn provider_name(provider: ProviderKind) -> &'static str { ProviderKind::ClaudeCode => "claude", ProviderKind::Pi => "pi", ProviderKind::OpenCode => "opencode", + ProviderKind::Cursor => "cursor", + ProviderKind::Grok => "grok", ProviderKind::Acp => "acp", } } diff --git a/crates/runtime/src/app/plugins.rs b/crates/runtime/src/app/plugins.rs new file mode 100644 index 000000000..0d64dbb3d --- /dev/null +++ b/crates/runtime/src/app/plugins.rs @@ -0,0 +1,954 @@ +use super::*; + +use agent::{ + MarketplaceAction, PluginAction, PluginActionKind, PluginContext, PluginListing, PluginOp, + PluginOpOutcome, PluginScope, list_plugins, run_plugin_op, +}; +use tcode_protocol::{ + Command, PluginCatalogState, PluginChallenge, PluginChallengeKind, PluginOperationTarget, + PluginStaleReason, ProviderPluginCatalog, +}; + +/// One profile's last native plugin listing. Nothing here is persisted: the +/// CLI's own files are the truth and are listed again on demand. +pub(super) struct PluginCatalog { + context_cwd: Option, + launch: PluginLaunch, + listing: PluginListing, + state: PluginCatalogState, + loading: bool, + /// Bumped when the context or the profile's CLI/home changes; listings + /// and challenges from an older generation are discarded. + generation: u64, + /// Only the newest listing request may land. + list_request: u64, + /// The one mutation of this profile in flight or waiting on its user. + operation: Option, + /// Native facts a finished operation reported, shown on its entry until + /// the entry is changed again or the context changes. + notes: HashMap>, +} + +/// The native installation a profile's plugin commands address. +#[derive(Debug, Clone, PartialEq, Eq)] +struct PluginLaunch { + provider: ProviderKind, + binary_path: Option, + home: Option, +} + +struct PluginOperation { + id: RuntimeOperationId, + subject: String, + target: PluginOperationTarget, + op: PluginOp, + cwd: Option, + generation: u64, + started: bool, + challenge: Option, +} + +fn error(code: &str, message: &str) -> ProtocolError { + ProtocolError { + code: code.into(), + message: message.into(), + } +} + +fn native_message(error: AgentError) -> String { + match error { + AgentError::Provider(message) => message, + other => other.to_string(), + } +} + +impl AppState { + fn plugin_launch(&self, profile_id: &str) -> Option { + let profile = self.settings.resolved_profile(profile_id)?; + Some(PluginLaunch { + provider: profile.kind, + binary_path: profile.settings.binary_path, + home: profile.settings.home_path, + }) + } + + pub fn refresh_provider_plugins( + &mut self, + profile_id: &str, + cwd: Option, + cx: &mut HostCx, + ) { + let Some(launch) = self.plugin_launch(profile_id) else { + return; + }; + let catalog = self + .plugin_catalogs + .entry(profile_id.to_string()) + .or_insert_with(|| PluginCatalog { + context_cwd: cwd.clone(), + launch: launch.clone(), + listing: PluginListing::default(), + state: PluginCatalogState::Stale { + reason: PluginStaleReason::NotLoaded, + }, + loading: false, + generation: 0, + list_request: 0, + operation: None, + notes: HashMap::new(), + }); + if catalog.context_cwd != cwd || catalog.launch != launch { + catalog.context_cwd = cwd; + catalog.launch = launch; + catalog.generation += 1; + catalog.notes.clear(); + if matches!(catalog.state, PluginCatalogState::Fresh) { + catalog.state = PluginCatalogState::Stale { + reason: PluginStaleReason::ContextChanged, + }; + } + if let Some(operation) = catalog + .operation + .take_if(|operation| operation.challenge.is_some()) + { + Self::abandon_challenge(profile_id, operation, cx); + } + } + self.list_plugins(profile_id, cx); + } + + /// A challenge that can no longer be answered ends its operation with + /// the CLI's own refusal, which is what happened natively. + fn abandon_challenge(profile_id: &str, operation: PluginOperation, cx: &mut HostCx) { + if !operation.started { + return; + } + let detail = operation + .challenge + .and_then(|challenge| challenge.native_text) + .unwrap_or_default(); + emit_runtime( + cx, + RuntimeEvent::Toast(RuntimeToast::PluginOperationFailed { + operation: operation.id, + profile_id: profile_id.to_string(), + target: operation.target, + detail, + }), + ); + } + + fn list_plugins(&mut self, profile_id: &str, cx: &mut HostCx) { + let settings = self.settings.clone(); + let settings_store = self.settings_store.clone(); + let Some(catalog) = self.plugin_catalogs.get_mut(profile_id) else { + return; + }; + catalog.list_request += 1; + catalog.loading = true; + let request = catalog.list_request; + let provider = catalog.launch.provider; + let cwd = catalog.context_cwd.clone(); + let profile_id = profile_id.to_string(); + let host_cx = cx.clone(); + HostCx::spawn_detached(cx, async move { + let context = + plugin_context(&host_cx, settings, settings_store, &profile_id, cwd).await; + let result = list_plugins(provider, &context).await; + host_cx.enqueue(move |state, _cx| { + let Some(catalog) = state.plugin_catalogs.get_mut(&profile_id) else { + return; + }; + if catalog.list_request != request { + return; + } + catalog.loading = false; + match result { + Ok(listing) => { + catalog.listing = listing; + catalog.state = PluginCatalogState::Fresh; + } + Err(error) => { + catalog.state = PluginCatalogState::Error { + message: native_message(error), + } + } + } + }); + }); + } + + /// Check a plugin command against the catalog it was offered from, so a + /// client acting on an old snapshot cannot run what the host no longer + /// offers. + pub(super) fn validate_plugin_command(&self, command: &Command) -> Result<(), ProtocolError> { + let (profile_id, cwd) = match command { + Command::RefreshProviderPlugins { profile_id, .. } => (profile_id, None), + Command::InstallProviderPlugin { + profile_id, cwd, .. + } + | Command::UninstallProviderPlugin { + profile_id, cwd, .. + } + | Command::SetProviderPluginEnabled { + profile_id, cwd, .. + } + | Command::UpdateProviderPlugin { + profile_id, cwd, .. + } + | Command::AddProviderMarketplace { + profile_id, cwd, .. + } + | Command::RemoveProviderMarketplace { + profile_id, cwd, .. + } => (profile_id, Some(cwd)), + Command::ResolvePluginChallenge { op_id, .. } => { + return if self.plugin_catalogs.values().any(|catalog| { + catalog + .operation + .as_ref() + .and_then(|operation| operation.challenge.as_ref()) + .is_some_and(|challenge| challenge.op_id == *op_id) + }) { + Ok(()) + } else { + Err(error( + "unknown_plugin_challenge", + "This confirmation is no longer pending.", + )) + }; + } + _ => return Ok(()), + }; + let Some(launch) = self.plugin_launch(profile_id) else { + return Err(error( + "unknown_profile", + "This provider profile is no longer available.", + )); + }; + let management = launch.provider.caps().plugin_management; + if management.actions.is_empty() && !management.marketplaces { + return Err(error( + "plugin_management_unsupported", + "This provider's plugins cannot be managed from Tcode.", + )); + } + let Some(cwd) = cwd else { + return Ok(()); + }; + let catalog = self + .plugin_catalogs + .get(profile_id) + .filter(|catalog| catalog.context_cwd == *cwd && catalog.launch == launch) + .ok_or_else(|| { + error( + "plugin_catalog_stale", + "List this provider's plugins again before changing them.", + ) + })?; + if catalog.operation.is_some() { + return Err(error( + "plugin_operation_in_flight", + "Another plugin change for this provider is still running.", + )); + } + let offered = |entry_id: &str, action: PluginAction| { + management.supports(action.kind()) + && catalog + .listing + .entries + .iter() + .any(|entry| entry.id == entry_id && entry.actions.contains(&action)) + }; + let available = match command { + Command::InstallProviderPlugin { + entry_id, scope, .. + } => offered(entry_id, PluginAction::Install { scope: *scope }), + Command::UninstallProviderPlugin { + entry_id, scope, .. + } => offered(entry_id, PluginAction::Uninstall { scope: *scope }), + Command::SetProviderPluginEnabled { + entry_id, + scope, + enabled: true, + .. + } => offered(entry_id, PluginAction::Enable { scope: *scope }), + Command::SetProviderPluginEnabled { + entry_id, + scope, + enabled: false, + .. + } => offered(entry_id, PluginAction::Disable { scope: *scope }), + Command::UpdateProviderPlugin { + entry_id, scope, .. + } => offered(entry_id, PluginAction::Update { scope: *scope }), + Command::AddProviderMarketplace { source, .. } => { + management.marketplaces + && !source.trim().is_empty() + && catalog + .listing + .marketplace_actions + .contains(&MarketplaceAction::Add) + } + Command::RemoveProviderMarketplace { name, .. } => { + management.marketplaces && removal(&catalog.listing, name).is_some() + } + _ => true, + }; + if available { + Ok(()) + } else { + Err(error( + "plugin_action_unavailable", + "This plugin action is not available here.", + )) + } + } + + pub fn start_plugin_operation( + &mut self, + profile_id: &str, + cwd: Option, + op: PluginOp, + cx: &mut HostCx, + ) { + let (subject, target) = match &op { + PluginOp::Install { id, scope, .. } => { + plugin_target(id, *scope, PluginActionKind::Install) + } + PluginOp::Uninstall { id, scope } => { + plugin_target(id, *scope, PluginActionKind::Uninstall) + } + PluginOp::SetEnabled { id, scope, enabled } => plugin_target( + id, + *scope, + if *enabled { + PluginActionKind::Enable + } else { + PluginActionKind::Disable + }, + ), + PluginOp::Update { id, scope, .. } => { + plugin_target(id, *scope, PluginActionKind::Update) + } + PluginOp::AddMarketplace { source } => ( + source.clone(), + PluginOperationTarget::AddMarketplace { + source: source.clone(), + }, + ), + PluginOp::RemoveMarketplace { name } => ( + name.clone(), + PluginOperationTarget::RemoveMarketplace { name: name.clone() }, + ), + }; + let id = self.next_operation_id(); + // Native removal has no confirmation of its own, so ask first. + let challenge = match &op { + PluginOp::RemoveMarketplace { name } => { + let uninstalls = self + .plugin_catalogs + .get(profile_id) + .and_then(|catalog| removal(&catalog.listing, name)) + .unwrap_or_default(); + Some(PluginChallenge { + op_id: self.next_operation_id(), + profile_id: profile_id.to_string(), + entry_id: name.clone(), + kind: PluginChallengeKind::ConfirmDestructive { uninstalls }, + native_text: None, + }) + } + _ => None, + }; + let Some(catalog) = self.plugin_catalogs.get_mut(profile_id) else { + return; + }; + let wait = challenge.is_some(); + catalog.operation = Some(PluginOperation { + id, + subject, + target, + op, + cwd, + generation: catalog.generation, + started: false, + challenge, + }); + if !wait { + self.execute_plugin_operation(profile_id, cx); + } + } + + fn execute_plugin_operation(&mut self, profile_id: &str, cx: &mut HostCx) { + let settings = self.settings.clone(); + let settings_store = self.settings_store.clone(); + let Some(catalog) = self.plugin_catalogs.get_mut(profile_id) else { + return; + }; + let provider = catalog.launch.provider; + let Some(operation) = catalog.operation.as_mut() else { + return; + }; + if !operation.started { + operation.started = true; + emit_runtime( + cx, + RuntimeEvent::Toast(RuntimeToast::PluginOperationStarted { + operation: operation.id, + profile_id: profile_id.to_string(), + target: operation.target.clone(), + }), + ); + } + let id = operation.id; + let op = operation.op.clone(); + let cwd = operation.cwd.clone(); + let profile_id = profile_id.to_string(); + let host_cx = cx.clone(); + HostCx::spawn_detached(cx, async move { + let context = + plugin_context(&host_cx, settings, settings_store, &profile_id, cwd).await; + let result = run_plugin_op(provider, &context, &op).await; + host_cx.enqueue(move |state, cx| { + state.finish_plugin_operation(&profile_id, id, result, cx); + }); + }); + } + + fn finish_plugin_operation( + &mut self, + profile_id: &str, + id: RuntimeOperationId, + result: Result, + cx: &mut HostCx, + ) { + let challenge_id = self.next_operation_id(); + let Some(catalog) = self.plugin_catalogs.get_mut(profile_id) else { + return; + }; + let Some(mut operation) = catalog + .operation + .take_if(|operation| operation.id == id && operation.challenge.is_none()) + else { + return; + }; + let target = operation.target.clone(); + let toast = match result { + Ok(PluginOpOutcome::AcceptCommand(acceptance)) + if operation.generation == catalog.generation => + { + operation.challenge = Some(PluginChallenge { + op_id: challenge_id, + profile_id: profile_id.to_string(), + entry_id: operation.subject.clone(), + kind: PluginChallengeKind::AcceptCommand { + command: acceptance.command, + sha256: acceptance.sha256, + mode: acceptance.mode, + }, + native_text: Some(acceptance.native_text), + }); + catalog.operation = Some(operation); + return; + } + Ok(PluginOpOutcome::AcceptCommand(acceptance)) => RuntimeToast::PluginOperationFailed { + operation: id, + profile_id: profile_id.to_string(), + target, + detail: acceptance.native_text, + }, + Ok(PluginOpOutcome::Done { diagnostics }) => { + if diagnostics.is_empty() { + catalog.notes.remove(&operation.subject); + } else { + catalog.notes.insert(operation.subject.clone(), diagnostics); + } + RuntimeToast::PluginOperationSucceeded { + operation: id, + profile_id: profile_id.to_string(), + target, + } + } + Err(error) => { + catalog.notes.remove(&operation.subject); + RuntimeToast::PluginOperationFailed { + operation: id, + profile_id: profile_id.to_string(), + target, + detail: native_message(error), + } + } + }; + emit_runtime(cx, RuntimeEvent::Toast(toast)); + self.after_plugin_change(profile_id, cx); + } + + /// A finished native change: menus cached for the same home no longer + /// describe it, and every catalog listing that home is out of date. + fn after_plugin_change(&mut self, profile_id: &str, cx: &mut HostCx) { + let Some(launch) = self + .plugin_catalogs + .get(profile_id) + .map(|catalog| catalog.launch.clone()) + else { + return; + }; + self.enqueue_store_write( + StoreWrite::InvalidateCommands(CommandsCacheKey::Native { + provider: launch.provider, + home: launch.home.clone(), + }), + cx, + ); + let affected: Vec = self + .plugin_catalogs + .iter_mut() + .filter(|(_, catalog)| { + catalog.launch.provider == launch.provider && catalog.launch.home == launch.home + }) + .map(|(id, catalog)| { + if !matches!(catalog.state, PluginCatalogState::Error { .. }) { + catalog.state = PluginCatalogState::Stale { + reason: PluginStaleReason::Changed, + }; + } + id.clone() + }) + .collect(); + for id in affected { + self.list_plugins(&id, cx); + } + } + + /// Answer a pending challenge. Acceptance applies only to the command or + /// removal this challenge showed; the hash never comes from the client. + pub fn resolve_plugin_challenge( + &mut self, + op_id: RuntimeOperationId, + accept: bool, + cx: &mut HostCx, + ) -> Result<(), ProtocolError> { + let unknown = || { + error( + "unknown_plugin_challenge", + "This confirmation is no longer pending.", + ) + }; + let profile_id = self + .plugin_catalogs + .iter() + .find(|(_, catalog)| { + catalog + .operation + .as_ref() + .and_then(|operation| operation.challenge.as_ref()) + .is_some_and(|challenge| challenge.op_id == op_id) + }) + .map(|(id, _)| id.clone()) + .ok_or_else(unknown)?; + let current = self.plugin_launch(&profile_id); + let next_challenge_id = self.next_operation_id(); + let catalog = self + .plugin_catalogs + .get_mut(&profile_id) + .ok_or_else(unknown)?; + let mut operation = catalog.operation.take().ok_or_else(unknown)?; + if operation.generation != catalog.generation || current.as_ref() != Some(&catalog.launch) { + Self::abandon_challenge(&profile_id, operation, cx); + return Err(unknown()); + } + if !accept { + Self::abandon_challenge(&profile_id, operation, cx); + return Ok(()); + } + let challenge = operation.challenge.take().ok_or_else(unknown)?; + match challenge.kind { + PluginChallengeKind::AcceptCommand { sha256, .. } => match &mut operation.op { + PluginOp::Install { accept_command, .. } + | PluginOp::Update { accept_command, .. } => *accept_command = Some(sha256), + _ => return Err(unknown()), + }, + PluginChallengeKind::ConfirmDestructive { uninstalls } => { + let current = removal(&catalog.listing, &operation.subject).unwrap_or_default(); + if current != uninstalls { + operation.challenge = Some(PluginChallenge { + op_id: next_challenge_id, + kind: PluginChallengeKind::ConfirmDestructive { + uninstalls: current, + }, + ..challenge + }); + catalog.operation = Some(operation); + return Err(error( + "plugin_challenge_changed", + "What this removes has changed; confirm it again.", + )); + } + } + } + catalog.operation = Some(operation); + self.execute_plugin_operation(&profile_id, cx); + Ok(()) + } + + pub(crate) fn provider_plugin_catalogs(&self) -> Vec { + let mut catalogs: Vec = self + .plugin_catalogs + .iter() + .filter(|(profile_id, _)| self.settings.resolved_profile(profile_id).is_some()) + .map(|(profile_id, catalog)| ProviderPluginCatalog { + profile_id: profile_id.clone(), + context_cwd: catalog.context_cwd.clone(), + marketplaces: catalog.listing.marketplaces.clone(), + marketplace_actions: catalog.listing.marketplace_actions.clone(), + errors: catalog.listing.errors.clone(), + entries: catalog + .listing + .entries + .iter() + .map(|entry| { + let mut entry = entry.clone(); + if let Some(notes) = catalog.notes.get(&entry.id) { + entry.diagnostics.extend(notes.iter().cloned()); + } + entry + }) + .collect(), + state: if self.plugin_launch(profile_id).as_ref() == Some(&catalog.launch) { + catalog.state.clone() + } else { + PluginCatalogState::Stale { + reason: PluginStaleReason::ContextChanged, + } + }, + loading: catalog.loading, + pending: catalog + .operation + .iter() + .map(|operation| operation.subject.clone()) + .collect(), + challenges: catalog + .operation + .iter() + .filter_map(|operation| operation.challenge.clone()) + .collect(), + }) + .collect(); + catalogs.sort_by(|a, b| a.profile_id.cmp(&b.profile_id)); + catalogs + } +} + +fn plugin_target( + id: &str, + scope: PluginScope, + action: PluginActionKind, +) -> (String, PluginOperationTarget) { + ( + id.to_string(), + PluginOperationTarget::Plugin { + action, + scope, + plugin: id.to_string(), + }, + ) +} + +/// What removing `name` uninstalls, when the listing offers its removal. +fn removal(listing: &PluginListing, name: &str) -> Option> { + listing + .marketplace_actions + .iter() + .find_map(|action| match action { + MarketplaceAction::Remove { + marketplace, + uninstalls, + } if marketplace == name => Some(uninstalls.clone()), + _ => None, + }) +} + +/// The profile's CLI and environment, as a session launch resolves them but +/// without session arguments or Tcode's MCP servers. Without a project the +/// command runs from the provider's home, outside any project. +async fn plugin_context( + host_cx: &HostCx, + settings: Settings, + settings_store: SettingsStore, + profile_id: &str, + cwd: Option, +) -> PluginContext { + let binary_path = settings + .resolved_profile(profile_id) + .and_then(|profile| profile.settings.binary_path); + let id = profile_id.to_string(); + let launch_env = host_cx + .unblock(move || { + let secrets = settings_store.profile_secrets(&id); + launch_env_for_profile(&settings, &id, secrets) + }) + .await; + let project = cwd.is_some(); + let cwd = cwd + .or_else(|| launch_env.home.clone()) + .or_else(std::env::home_dir) + .unwrap_or_else(std::env::temp_dir); + PluginContext { + binary_path, + launch_env, + cwd, + project, + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use crate::app::test_support::*; + use tcode_protocol::{CommandResponse, HostMessage}; + + const GAMMA_SHA: &str = "b9c02c85b17261fa1fce010664bb16cbe866f14c7deeaf6cb6c37c2736bbe269"; + + /// A `claude` that answers the recorded probe outputs and logs each + /// invocation as ` `. + struct FakeClaude { + dir: PathBuf, + binary: PathBuf, + log: PathBuf, + home: PathBuf, + project: PathBuf, + } + + impl FakeClaude { + fn new() -> Self { + use std::os::unix::fs::PermissionsExt as _; + let dir = + std::env::temp_dir().join(format!("tcode-fake-claude-{}", uuid::Uuid::new_v4())); + let home = dir.join("home"); + let project = dir.join("project"); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&project).unwrap(); + let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../agent/tests/fixtures/plugins/claude"); + let log = dir.join("argv.log"); + let binary = dir.join("claude"); + fs::write( + &binary, + format!( + "#!/bin/sh\n\ + F='{fixtures}'\n\ + printf '%s %s\\n' \"$PWD\" \"$*\" >> '{log}'\n\ + case \"$*\" in\n\ + 'plugin list --json --available') cat \"$F/plugin-list-available-after-install.json\" ;;\n\ + 'plugin marketplace list --json') cat \"$F/marketplace-list.json\" ;;\n\ + 'plugin details '*) cat \"$F/details.txt\" ;;\n\ + *'--accept-command {GAMMA_SHA}') cat \"$F/install-command-source-accepted.json\" ;;\n\ + 'plugin install gamma@tcode-probe '*) cat \"$F/install-command-source-challenge.txt\"; exit 1 ;;\n\ + 'plugin disable alpha@tcode-probe --scope user --json') cat \"$F/disable-scope-user.json\" ;;\n\ + *) echo \"unexpected: $*\" >&2; exit 2 ;;\n\ + esac\n", + fixtures = fixtures.display(), + log = log.display(), + ), + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o755)).unwrap(); + Self { + dir, + binary, + log, + home, + project, + } + } + + fn calls(&self, verb: &str) -> Vec { + fs::read_to_string(&self.log) + .unwrap_or_default() + .lines() + .filter(|line| line.contains(verb)) + .map(str::to_string) + .collect() + } + } + + impl Drop for FakeClaude { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } + } + + fn host_with(fake: &FakeClaude, store: &TestStore) -> TestClientState { + let mut state = TestClientState::new((**store).clone()); + let claude = state.settings.provider_mut(ProviderKind::ClaudeCode); + claude.binary_path = Some(fake.binary.clone()); + claude.home_path = Some(fake.home.clone()); + state + } + + fn catalog(state: &TestClientState) -> Option { + state + .provider_plugin_catalogs() + .into_iter() + .find(|catalog| catalog.profile_id == "claude") + } + + fn settled(state: &TestClientState) -> bool { + catalog(state).is_some_and(|catalog| { + !catalog.loading + && catalog.pending.is_empty() + && matches!(catalog.state, PluginCatalogState::Fresh) + }) + } + + fn challenged(state: &TestClientState) -> bool { + catalog(state).is_some_and(|catalog| !catalog.challenges.is_empty()) + } + + fn ack(cx: &mut TestAppContext, id: u64) -> Result { + cx.drain_outgoing() + .into_iter() + .find_map(|message| match message { + HostMessage::Ack { id: acked, result } if acked == id => Some(result), + _ => None, + }) + .expect("command acknowledged") + } + + #[test] + fn accepting_a_command_challenge_reruns_with_its_hash_and_a_stale_one_is_refused() { + let fake = FakeClaude::new(); + let store = TestStore::new("tcode-plugin-challenge"); + let cx = &mut TestAppContext::default(); + let state = cx.new_entity(host_with(&fake, &store)); + let project = Some(fake.project.clone()); + let refresh = |cwd: Option| Command::RefreshProviderPlugins { + profile_id: "claude".into(), + cwd, + }; + let install = Command::InstallProviderPlugin { + profile_id: "claude".into(), + entry_id: "gamma@tcode-probe".into(), + scope: PluginScope::User, + cwd: project.clone(), + }; + + state.dispatch_command(cx, 1, refresh(project.clone())); + cx.run_until(settled); + state.dispatch_command(cx, 2, install.clone()); + cx.run_until(challenged); + let challenge = state.read(|state| catalog(state).unwrap().challenges.remove(0)); + assert_eq!( + challenge.kind, + PluginChallengeKind::AcceptCommand { + command: "echo /tmp/tcode-probe/gamma-src".into(), + sha256: GAMMA_SHA.into(), + mode: Some("copy".into()), + } + ); + + // Listing from another directory abandons the challenge it showed. + state.dispatch_command(cx, 3, refresh(None)); + cx.run_until(settled); + state.dispatch_command( + cx, + 4, + Command::ResolvePluginChallenge { + op_id: challenge.op_id, + accept: true, + }, + ); + cx.run_until_parked(); + assert_eq!(ack(cx, 4).unwrap_err().code, "unknown_plugin_challenge"); + assert_eq!(fake.calls("plugin install").len(), 1); + + state.dispatch_command(cx, 5, refresh(project.clone())); + cx.run_until(settled); + state.dispatch_command(cx, 6, install); + cx.run_until(challenged); + let challenge = state.read(|state| catalog(state).unwrap().challenges.remove(0)); + state.dispatch_command( + cx, + 7, + Command::ResolvePluginChallenge { + op_id: challenge.op_id, + accept: true, + }, + ); + cx.run_until(|state| settled(state) && fake.calls("--accept-command").len() == 1); + assert_eq!(ack(cx, 7).unwrap(), CommandResponse::Unit); + let installs = fake.calls("plugin install"); + let accepted = installs.last().unwrap(); + assert!( + accepted.ends_with(&format!( + "plugin install gamma@tcode-probe --scope user --json --accept-command {GAMMA_SHA}" + )), + "{installs:?}" + ); + let project = fs::canonicalize(&fake.project).unwrap(); + assert!( + accepted.starts_with(&format!("{} ", project.display())), + "{accepted}" + ); + } + + #[test] + fn a_plugin_change_invalidates_only_the_commands_cached_for_its_home() { + let fake = FakeClaude::new(); + let store = TestStore::new("tcode-plugin-cache"); + let commands = vec![ProviderCommand { + name: "alpha:greet".into(), + description: None, + kind: agent::ProviderCommandKind::Skill, + }]; + let key = |home: Option| CommandsCacheKey::Native { + provider: ProviderKind::ClaudeCode, + home, + }; + let other_home = key(Some(fake.dir.join("other-home"))); + for home in [key(Some(fake.home.clone())), key(None), other_home.clone()] { + store.save_commands(&home, &commands).unwrap(); + } + let cx = &mut TestAppContext::default(); + let state = cx.new_entity(host_with(&fake, &store)); + assert_eq!( + state.read(|state| state.cached_provider_commands( + ProviderKind::ClaudeCode, + None, + None + )), + commands + ); + + state.dispatch_command( + cx, + 1, + Command::RefreshProviderPlugins { + profile_id: "claude".into(), + cwd: None, + }, + ); + cx.run_until(settled); + state.dispatch_command( + cx, + 2, + Command::SetProviderPluginEnabled { + profile_id: "claude".into(), + entry_id: "alpha@tcode-probe".into(), + scope: PluginScope::User, + enabled: false, + cwd: None, + }, + ); + cx.run_until(|state| settled(state) && fake.calls("plugin disable").len() == 1); + + assert!( + state + .read(|state| state.cached_provider_commands(ProviderKind::ClaudeCode, None, None)) + .is_empty() + ); + assert_eq!(store.load_commands(&key(None)), commands); + assert_eq!(store.load_commands(&other_home), commands); + } +} diff --git a/crates/runtime/src/app/providers.rs b/crates/runtime/src/app/providers.rs index a5e48e660..ab53726ae 100644 --- a/crates/runtime/src/app/providers.rs +++ b/crates/runtime/src/app/providers.rs @@ -93,6 +93,7 @@ impl ProviderCatalog { acp_registry_loading: bool, acp_registry_error: Option, acp_installing: HashSet, + plugins: Vec, ) -> ProvidersStatus { ProvidersStatus { model_catalogs: self.model_catalogs.clone(), @@ -150,6 +151,7 @@ impl ProviderCatalog { .any(|status| status.checking) || self.tcode_update.checking, secret_names: self.provider_secret_names.clone(), + plugins, } } } @@ -159,7 +161,7 @@ impl AppState { /// at app start and after a binary-path change). Results update /// `model_catalogs` and are persisted so the next launch is instant. pub fn refresh_model_catalogs(&mut self, cx: &mut HostCx) { - for provider in NATIVE_PROVIDER_KINDS { + for provider in ProviderKind::NATIVE { let binary = self.settings.provider(provider).binary_path; let settings = self.settings.clone(); let settings_store = self.settings_store.clone(); @@ -229,8 +231,8 @@ impl AppState { } // A *profile* is a named configuration on top of a protocol `ProviderKind`. - // The built-in native-provider cards are profiles too (with stable ids such - // as "claude", "codex", "pi", and "opencode"). + // The built-in native-provider cards are profiles too, with the stable ids + // of `Settings::builtin_profile_id`. // The model catalog and update-check version stay keyed by kind; status // probes and card config (env, binary, home, accent, custom/hidden models) // are profile-specific, as are secrets. @@ -238,7 +240,7 @@ impl AppState { /// Every selectable native profile, grouped by kind. ACP is handled /// separately through the installed-agent list. pub(super) fn all_profiles(&self) -> Vec { - NATIVE_PROVIDER_KINDS + ProviderKind::NATIVE .iter() .flat_map(|kind| self.settings.profiles_for_kind(*kind)) .collect() @@ -530,7 +532,7 @@ impl AppState { /// Check every provider and the running tcode build in the background, /// storing results and toasting once for each newly available update. pub fn check_provider_versions(&mut self, cx: &mut HostCx) { - for provider in NATIVE_PROVIDER_KINDS { + for provider in ProviderKind::NATIVE { let binary = self.resolve_provider_binary(provider); let status = self .providers @@ -721,12 +723,48 @@ impl AppState { }); } + /// The installation a session's command menus come from: a native + /// profile's home, or one ACP agent. + pub(super) fn commands_cache_key( + &self, + provider: ProviderKind, + profile_id: Option<&str>, + acp_agent_id: Option<&str>, + ) -> Option { + let home = match provider { + ProviderKind::Acp => None, + ProviderKind::Codex + | ProviderKind::ClaudeCode + | ProviderKind::Pi + | ProviderKind::OpenCode + | ProviderKind::Cursor + | ProviderKind::Grok => self + .settings + .resolved_profile( + profile_id.unwrap_or_else(|| Settings::builtin_profile_id(provider)), + ) + .and_then(|profile| profile.settings.home_path), + }; + CommandsCacheKey::new(provider, home, acp_agent_id) + } + pub(super) fn cached_provider_commands( &self, provider: ProviderKind, + profile_id: Option<&str>, acp_agent_id: Option<&str>, ) -> Vec { - self.store.load_commands(provider, acp_agent_id) + self.commands_cache_key(provider, profile_id, acp_agent_id) + .map(|key| self.store.load_commands(&key)) + .unwrap_or_default() + } + + pub(super) fn cached_provider_commands_for(&self, meta: &SessionMeta) -> Vec { + self.cached_provider_commands( + meta.provider, + meta.profile_id.as_deref(), + meta.acp_agent_id.as_deref(), + ) } /// The cached model catalog for `provider` (empty when never fetched). @@ -803,24 +841,19 @@ pub(super) fn provider_secret_names( settings: &Settings, settings_store: &SettingsStore, ) -> HashMap> { - [ - ProviderKind::Codex, - ProviderKind::ClaudeCode, - ProviderKind::Pi, - ProviderKind::OpenCode, - ] - .into_iter() - .flat_map(|kind| settings.profiles_for_kind(kind)) - .map(|profile| { - let id = profile.id; - let names = launch_env_for_profile(settings, &id, settings_store.profile_secrets(&id)) - .env - .into_iter() - .map(|(name, _)| name) - .collect(); - (id, names) - }) - .collect() + ProviderKind::NATIVE + .into_iter() + .flat_map(|kind| settings.profiles_for_kind(kind)) + .map(|profile| { + let id = profile.id; + let names = launch_env_for_profile(settings, &id, settings_store.profile_secrets(&id)) + .env + .into_iter() + .map(|(name, _)| name) + .collect(); + (id, names) + }) + .collect() } pub(super) fn session_launch_env( @@ -833,7 +866,9 @@ pub(super) fn session_launch_env( ProviderKind::Codex | ProviderKind::ClaudeCode | ProviderKind::Pi - | ProviderKind::OpenCode => { + | ProviderKind::OpenCode + | ProviderKind::Cursor + | ProviderKind::Grok => { let profile_id = meta .profile_id .clone() @@ -926,7 +961,10 @@ pub(super) fn session_options( // an ACP agent carries its own from the installed-agent card. extra_args: if meta.provider.caps().launch_args { match meta.provider { - ProviderKind::ClaudeCode | ProviderKind::OpenCode => provider_settings.extra_args(), + ProviderKind::ClaudeCode + | ProviderKind::OpenCode + | ProviderKind::Cursor + | ProviderKind::Grok => provider_settings.extra_args(), ProviderKind::Pi => { let mut extra_args = provider_settings.extra_args(); if provider_settings.pi.trust_project_extensions { diff --git a/crates/runtime/src/app/sessions.rs b/crates/runtime/src/app/sessions.rs index 7fa3da62f..75543096c 100644 --- a/crates/runtime/src/app/sessions.rs +++ b/crates/runtime/src/app/sessions.rs @@ -595,7 +595,7 @@ impl AppState { pub fn update_settings(&mut self, settings: Settings, cx: &mut HostCx) { self.enqueue_settings(&settings, cx); - if NATIVE_PROVIDER_KINDS + if ProviderKind::NATIVE .iter() .any(|&provider| self.settings.provider(provider) != settings.provider(provider)) { @@ -1353,7 +1353,8 @@ impl AppState { } let (provider, model, acp_agent_id, profile_id, reasoning_effort) = self.draft_defaults(&project_id); - let provider_commands = self.cached_provider_commands(provider, acp_agent_id.as_deref()); + let provider_commands = + self.cached_provider_commands(provider, profile_id.as_deref(), acp_agent_id.as_deref()); let mut draft = Self::build_draft_session( project_id, cwd, @@ -1578,8 +1579,7 @@ impl AppState { meta.resume_cursor.is_some() ); let session_id = meta.id.clone(); - let provider_commands = - self.cached_provider_commands(meta.provider, meta.acp_agent_id.as_deref()); + let provider_commands = self.cached_provider_commands_for(&meta); let mut active = ActiveSession::new(meta, false, provider_commands); let terminal_preferences = self.terminal_preferences_for(&active); let restored_terminal = self.restore_terminal_workspace(&mut active); diff --git a/crates/runtime/src/app/snapshots.rs b/crates/runtime/src/app/snapshots.rs index 975e02a35..135cecaf0 100644 --- a/crates/runtime/src/app/snapshots.rs +++ b/crates/runtime/src/app/snapshots.rs @@ -234,6 +234,7 @@ impl AppState { self.acp_registry_loading, self.acp_registry_error.clone(), self.acp_installing.clone(), + self.provider_plugin_catalogs(), ) } diff --git a/crates/runtime/src/app/store_write.rs b/crates/runtime/src/app/store_write.rs index 9d16c1c81..cb9f1be4f 100644 --- a/crates/runtime/src/app/store_write.rs +++ b/crates/runtime/src/app/store_write.rs @@ -19,10 +19,10 @@ pub(super) enum StoreWrite { completion: smol::channel::Sender>, }, SaveCommands { - provider: ProviderKind, - acp_agent_id: Option, + key: CommandsCacheKey, commands: Vec, }, + InvalidateCommands(CommandsCacheKey), WriteTerminalUi(Vec), WriteSettings(Vec), SetProfileSecret { @@ -91,18 +91,14 @@ pub(super) fn run_store_write( let _ = completion.try_send(result); None } - StoreWrite::SaveCommands { - provider, - acp_agent_id, - commands, - } => store - .save_commands(provider, acp_agent_id.as_deref(), &commands) + StoreWrite::SaveCommands { key, commands } => store + .save_commands(&key, &commands) .err() - .map(|err| { - Err(format!( - "failed to persist {provider:?} command cache: {err}" - )) - }), + .map(|err| Err(format!("failed to persist {key:?} command cache: {err}"))), + StoreWrite::InvalidateCommands(key) => store + .invalidate_commands(&key) + .err() + .map(|err| Err(format!("failed to invalidate {key:?} command cache: {err}"))), StoreWrite::WriteTerminalUi(bytes) => { atomic_write(terminal_preferences_path.to_path_buf(), bytes) .err() diff --git a/crates/runtime/src/app/subagents.rs b/crates/runtime/src/app/subagents.rs index dbc852a83..841aaab68 100644 --- a/crates/runtime/src/app/subagents.rs +++ b/crates/runtime/src/app/subagents.rs @@ -296,7 +296,7 @@ impl AppState { self.upsert_session_in_memory(meta.clone()); let id = meta.id.clone(); - let commands = self.cached_provider_commands(meta.provider, meta.acp_agent_id.as_deref()); + let commands = self.cached_provider_commands_for(&meta); let mut mirror = Self::build_draft_session( meta.project_id.clone().unwrap_or_default(), meta.cwd.clone(), diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index 3e6a8bcf7..20566e83c 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -1811,6 +1811,70 @@ fn title_regeneration_context_keeps_the_original_goal_and_recent_messages() { ); } +#[test] +fn installed_acp_duplicates_of_native_providers_only_serve_their_existing_sessions() { + let cx = &mut TestAppContext::default(); + let test_store = TestStore::new("tcode-native-acp-duplicates"); + let state = cx.new_entity(TestClientState::new((*test_store).clone())); + let installed = |id: &str| InstalledAgent { + id: id.into(), + name: id.into(), + version: String::new(), + icon: None, + launch: agent::AcpLaunch::Custom { + command: id.into(), + args: Vec::new(), + env: Vec::new(), + }, + enabled: true, + env: Vec::new(), + launch_args: None, + }; + let draft = state.update(cx, |state, cx| { + for id in ["cursor", "grok-build", "gemini"] { + state.settings.acp_agents.insert(id.into(), installed(id)); + } + state.start_draft("project".into(), PathBuf::from("/tmp/project"), cx); + state.selected.clone().unwrap() + }); + for (request, id, selected) in [ + (1, "cursor", None), + (2, "grok-build", None), + (3, "gemini", Some("gemini")), + ] { + state.dispatch_command( + cx, + request, + Command::SetActiveAcpAgent { + session_id: draft.clone(), + id: id.into(), + }, + ); + state.read(|state| { + assert_eq!( + state.resident(&draft).unwrap().meta.acp_agent_id.as_deref(), + selected, + "{id}" + ); + }); + } + + let mut existing = SessionMeta::new(ProviderKind::Acp, PathBuf::from("/tmp/project"), None); + existing.acp_agent_id = Some("cursor".into()); + state.read(|state| { + let opts = session_options( + &existing, + &state.settings, + LaunchEnv::default(), + None, + None, + None, + None, + ); + assert_eq!(opts.acp.map(|agent| agent.id).as_deref(), Some("cursor")); + }); +} + #[test] fn marketplace_items_are_runtime_owned_views() { let test_store = TestStore::new("tcode-marketplace-view-test"); @@ -2953,11 +3017,17 @@ fn reopened_command_cache_seeds_a_draft_before_provider_start() { kind: agent::ProviderCommandKind::Command, }]; store - .save_commands(ProviderKind::ClaudeCode, None, &commands) + .save_commands( + &CommandsCacheKey::Native { + provider: ProviderKind::ClaudeCode, + home: None, + }, + &commands, + ) .unwrap(); let state = TestClientState::new(SessionStore::open_at(root.clone()).unwrap()); - let seeded = state.cached_provider_commands(ProviderKind::ClaudeCode, None); + let seeded = state.cached_provider_commands(ProviderKind::ClaudeCode, None, None); let draft = AppState::build_draft_session( "project".into(), PathBuf::from("/tmp/project"), diff --git a/crates/runtime/src/pipe.rs b/crates/runtime/src/pipe.rs index 1f90717fc..6a86f0b52 100644 --- a/crates/runtime/src/pipe.rs +++ b/crates/runtime/src/pipe.rs @@ -382,6 +382,97 @@ fn dispatch_command(app: &mut AppState, cx: &mut HostCx, command: Command) -> Co Command::SetActiveAcpAgent { session_id, id } => { app.set_active_acp_agent(&session_id, &id, cx) } + Command::RefreshProviderPlugins { profile_id, cwd } => { + app.refresh_provider_plugins(&profile_id, cwd, cx) + } + Command::InstallProviderPlugin { + profile_id, + entry_id, + scope, + cwd, + } => app.start_plugin_operation( + &profile_id, + cwd, + agent::PluginOp::Install { + id: entry_id, + scope, + accept_command: None, + }, + cx, + ), + Command::UninstallProviderPlugin { + profile_id, + entry_id, + scope, + cwd, + } => app.start_plugin_operation( + &profile_id, + cwd, + agent::PluginOp::Uninstall { + id: entry_id, + scope, + }, + cx, + ), + Command::SetProviderPluginEnabled { + profile_id, + entry_id, + scope, + enabled, + cwd, + } => app.start_plugin_operation( + &profile_id, + cwd, + agent::PluginOp::SetEnabled { + id: entry_id, + scope, + enabled, + }, + cx, + ), + Command::UpdateProviderPlugin { + profile_id, + entry_id, + scope, + cwd, + } => app.start_plugin_operation( + &profile_id, + cwd, + agent::PluginOp::Update { + id: entry_id, + scope, + accept_command: None, + }, + cx, + ), + Command::AddProviderMarketplace { + profile_id, + source, + cwd, + } => app.start_plugin_operation( + &profile_id, + cwd, + agent::PluginOp::AddMarketplace { + source: source.trim().to_string(), + }, + cx, + ), + Command::RemoveProviderMarketplace { + profile_id, + name, + cwd, + } => app.start_plugin_operation( + &profile_id, + cwd, + agent::PluginOp::RemoveMarketplace { name }, + cx, + ), + Command::ResolvePluginChallenge { op_id, accept } => { + return CommandOutcome::Immediate( + app.resolve_plugin_challenge(op_id, accept, cx) + .map(|()| CommandResponse::Unit), + ); + } Command::ResetSettings => app.reset_settings(cx), Command::WriteRelaunchMarker { session_id, diff --git a/crates/services/src/provider_probe.rs b/crates/services/src/provider_probe.rs index f0c3b0010..52f2bc0fa 100644 --- a/crates/services/src/provider_probe.rs +++ b/crates/services/src/provider_probe.rs @@ -16,6 +16,8 @@ pub fn default_program(provider: ProviderKind) -> String { ProviderKind::ClaudeCode => "claude".into(), ProviderKind::Pi => "pi".into(), ProviderKind::OpenCode => "opencode".into(), + ProviderKind::Cursor => "cursor-agent".into(), + ProviderKind::Grok => "grok".into(), // ACP agents carry their own registry launch recipe. ProviderKind::Acp => String::new(), } @@ -122,13 +124,73 @@ pub async fn probe_provider( let json = path.and_then(|path| std::fs::read_to_string(path).ok()); json.as_deref().and_then(parse_aggregator_auth) } - // ACP authentication is surfaced by its session protocol. + ProviderKind::Cursor => run_capture_env(&program, &["status", "--format", "json"], &env) + .await + .as_deref() + .and_then(|json| parse_cursor_auth(json, cursor_key_configured(&env))), + ProviderKind::Grok => run_capture_env(&program, &["models"], &env) + .await + .as_deref() + .and_then(parse_grok_models_auth), + // Authentication over ACP is surfaced by the session protocol. ProviderKind::Acp => None, }; finalize_probe(checked_at, version, auth) } +/// `cursor-agent status --format json` reports only the stored login: it says +/// `unauthenticated` while CURSOR_API_KEY or CURSOR_AUTH_TOKEN signs the CLI +/// in, so it counts as signed out only when neither is set. +fn parse_cursor_auth(json: &str, key_configured: bool) -> Option { + let status: serde_json::Value = serde_json::from_str(json).ok()?; + if status.get("isAuthenticated") == Some(&serde_json::Value::Bool(true)) { + return Some(ProviderAuth { + status: AuthStatus::Authenticated, + label: None, + email: status + .pointer("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/userInfo/email") + .and_then(serde_json::Value::as_str) + .filter(|email| !email.is_empty()) + .map(str::to_string), + }); + } + (status.get("status").and_then(serde_json::Value::as_str) == Some("unauthenticated") + && !key_configured) + .then_some(ProviderAuth { + status: AuthStatus::Unauthenticated, + label: None, + email: None, + }) +} + +/// Whether Cursor's child sees an API key or auth token, from the profile's +/// environment or the one it inherits. +fn cursor_key_configured(env: &[(String, String)]) -> bool { + ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"].iter().any(|key| { + match env.iter().rev().find(|(name, _)| name == key) { + Some((_, value)) => !value.is_empty(), + None => std::env::var_os(key).is_some_and(|value| !value.is_empty()), + } + }) +} + +/// The first line of `grok models`. Only the states observed from Grok 1.0.46 +/// are recognized; the API-key line reports a configured key, which Grok does +/// not validate there. +fn parse_grok_models_auth(output: &str) -> Option { + let (status, label) = match output.lines().next()?.trim() { + "You are not authenticated." => (AuthStatus::Unauthenticated, None), + "You are using XAI_API_KEY." => (AuthStatus::Authenticated, Some("xAI API Key")), + _ => return None, + }; + Some(ProviderAuth { + status, + label: label.map(str::to_string), + email: None, + }) +} + fn finalize_probe( checked_at: Option, version: Option, @@ -180,6 +242,49 @@ mod tests { assert_eq!(result.diagnostic, Some(ProviderProbeDiagnostic::MissingCli)); } + /// `cursor-agent status --format json` from the agent crate's stand-in, + /// which prints the fixture it is pointed at. + #[cfg(unix)] + #[test] + fn cursor_is_signed_out_only_without_an_api_key_or_auth_token() { + let fixtures = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../agent/tests/fixtures/cursor"); + let probe = |status: &str, api_key: &str, auth_token: &str| { + let env = vec![ + ( + "TCODE_STAND_IN_STATUS".to_string(), + fixtures.join(status).display().to_string(), + ), + ("CURSOR_API_KEY".to_string(), api_key.to_string()), + ("CURSOR_AUTH_TOKEN".to_string(), auth_token.to_string()), + ]; + smol::block_on(probe_provider( + ProviderKind::Cursor, + Some(fixtures.join("cursor-agent")), + LaunchEnv { env, home: None }, + )) + }; + + let signed_out = probe("status_signed_out.json", "", ""); + assert_eq!( + signed_out.diagnostic, + Some(ProviderProbeDiagnostic::Unauthenticated) + ); + for (api_key, auth_token) in [("key", ""), ("", "token")] { + assert_eq!( + probe("status_signed_out.json", api_key, auth_token).diagnostic, + Some(ProviderProbeDiagnostic::IndeterminateAuth), + "a key or token signs Cursor in without a stored login" + ); + } + + let signed_in = probe("status_signed_in.json", "", ""); + assert_eq!(signed_in.status, Some(ProviderStatusKind::Ready)); + let auth = signed_in.auth.unwrap(); + assert_eq!(auth.status, AuthStatus::Authenticated); + assert_eq!(auth.email.as_deref(), Some("dev@example.com")); + } + #[test] fn auth_outcomes_are_semantic_and_unlocalized() { let authenticated = finalize_probe( diff --git a/crates/services/src/provider_usage.rs b/crates/services/src/provider_usage.rs index de9774049..b4d356eee 100644 --- a/crates/services/src/provider_usage.rs +++ b/crates/services/src/provider_usage.rs @@ -177,7 +177,11 @@ pub async fn fetch_provider_usage( ProviderKind::ClaudeCode => agent::claude::read_usage(binary, launch_env) .await .map(|value| parse_claude_usage(&value, fetched_at)), - ProviderKind::Pi | ProviderKind::OpenCode | ProviderKind::Acp => return None, + ProviderKind::Pi + | ProviderKind::OpenCode + | ProviderKind::Cursor + | ProviderKind::Grok + | ProviderKind::Acp => return None, }; Some(result.unwrap_or_else(|error| error_usage(fetched_at, error.to_string()))) } diff --git a/crates/services/src/settings.rs b/crates/services/src/settings.rs index d9cfa5d27..6246889ed 100644 --- a/crates/services/src/settings.rs +++ b/crates/services/src/settings.rs @@ -8,8 +8,6 @@ use std::path::PathBuf; use agent::ProviderKind; use tcode_core::settings::Settings; #[cfg(test)] -use tcode_core::settings::provider_key; -#[cfg(test)] use tcode_core::settings::{EnvVar, ThemeMode, TraverseSetting}; #[derive(Debug, Clone)] @@ -50,9 +48,8 @@ impl SettingsStore { .unwrap_or_default() } - /// The sensitive env values for one profile id. Built-in profiles use their - /// [`provider_key`] as id, so this also covers them; user-created profiles - /// are keyed by their slug id. + /// The sensitive env values for one profile id: a built-in profile's + /// [`Settings::builtin_profile_id`] or a user profile's slug. pub fn profile_secrets(&self, profile_id: &str) -> BTreeMap { self.load_secrets().remove(profile_id).unwrap_or_default() } @@ -187,7 +184,7 @@ mod tests { store.save(&settings).unwrap(); store .set_profile_secret( - provider_key(ProviderKind::ClaudeCode), + Settings::builtin_profile_id(ProviderKind::ClaudeCode), "ANTHROPIC_API_KEY", Some("sk-live"), ) @@ -224,20 +221,20 @@ mod tests { // Clearing removes the entry (and the now-empty provider bucket). store .set_profile_secret( - provider_key(ProviderKind::ClaudeCode), + Settings::builtin_profile_id(ProviderKind::ClaudeCode), "ANTHROPIC_API_KEY", None, ) .unwrap(); assert!(store.profile_secrets("claude").is_empty()); // A user profile "klaude-kode" stores its own key, isolated from the - // built-in "claude" profile (which shares the provider_key id). + // built-in "claude" profile. store .set_profile_secret("klaude-kode", "ANTHROPIC_API_KEY", Some("sk-kimi-xyz")) .unwrap(); store .set_profile_secret( - provider_key(ProviderKind::ClaudeCode), + Settings::builtin_profile_id(ProviderKind::ClaudeCode), "ANTHROPIC_API_KEY", Some("sk-official"), ) @@ -250,7 +247,7 @@ mod tests { .map(String::as_str), Some("sk-kimi-xyz") ); - // Built-in profile id == provider_key. + // The built-in Claude profile keeps its `claude` bucket. assert_eq!( store .profile_secrets("claude") diff --git a/crates/services/src/store.rs b/crates/services/src/store.rs index ec028c2e0..c3b7a2114 100644 --- a/crates/services/src/store.rs +++ b/crates/services/src/store.rs @@ -25,6 +25,44 @@ struct EventEnvelope { event: AgentEvent, } +/// Which installation a cached command list belongs to. Native commands +/// depend on the provider's home (its plugins, skills and settings), so two +/// profiles with different homes never seed each other's menus. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandsCacheKey { + /// `home` is the profile's home override; `None` is the CLI's default. + Native { + provider: ProviderKind, + home: Option, + }, + Acp { + agent_id: String, + }, +} + +impl CommandsCacheKey { + /// `None` for an ACP session without an agent id. + pub fn new( + provider: ProviderKind, + home: Option, + acp_agent_id: Option<&str>, + ) -> Option { + match provider { + ProviderKind::Acp => acp_agent_id.map(|agent_id| Self::Acp { + agent_id: agent_id.to_string(), + }), + provider => Some(Self::Native { provider, home }), + } + } +} + +/// 64-bit FNV-1a: a short, stable file-name segment for a home path. +fn fnv1a(bytes: &[u8]) -> u64 { + bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3) + }) +} + /// Cheap, cloneable handle to the on-disk data directory. #[derive(Debug, Clone)] pub struct SessionStore { @@ -100,6 +138,8 @@ impl SessionStore { ProviderKind::ClaudeCode => "claude", ProviderKind::Pi => "pi", ProviderKind::OpenCode => "opencode", + ProviderKind::Cursor => "cursor", + ProviderKind::Grok => "grok", // ACP agents publish their models over the wire at session start // (`AgentEvent::ProviderOptions`), so there is no catalog to cache. ProviderKind::Acp => "acp", @@ -107,18 +147,29 @@ impl SessionStore { self.root.join(format!("models-{name}.json")) } - fn commands_path(&self, provider: ProviderKind, acp_agent_id: Option<&str>) -> Option { - let name = match provider { - ProviderKind::Codex => "codex".to_string(), - ProviderKind::ClaudeCode => "claude".to_string(), - ProviderKind::Pi => "pi".to_string(), - ProviderKind::OpenCode => "opencode".to_string(), - ProviderKind::Acp => { - let id = acp_agent_id?; + fn commands_path(&self, key: &CommandsCacheKey) -> PathBuf { + let name = match key { + CommandsCacheKey::Native { provider, home } => { + let provider = match provider { + ProviderKind::Codex => "codex", + ProviderKind::ClaudeCode => "claude", + ProviderKind::Pi => "pi", + ProviderKind::OpenCode => "opencode", + ProviderKind::Cursor => "cursor", + ProviderKind::Grok => "grok", + ProviderKind::Acp => "acp", + }; + let home = home.as_deref().map_or_else( + || "default".to_string(), + |home| format!("{:016x}", fnv1a(home.as_os_str().as_encoded_bytes())), + ); + format!("{provider}-{home}") + } + CommandsCacheKey::Acp { agent_id } => { // Registry ids are external input and may contain path separators. // Hex keeps the filename reversible and collision-free without // allowing an id to escape the data directory. - let encoded = id + let encoded = agent_id .as_bytes() .iter() .map(|byte| format!("{byte:02x}")) @@ -126,7 +177,7 @@ impl SessionStore { format!("acp-{encoded}") } }; - Some(self.root.join(format!("commands-{name}.json"))) + self.root.join(format!("commands-{name}.json")) } /// Load the last-fetched model catalog for `provider` so the picker is @@ -148,38 +199,24 @@ impl SessionStore { fs::rename(&tmp, path) } - /// Load the most recently reported command/skill list for a native provider - /// or one specific ACP agent. Empty when missing, unreadable, or when an ACP - /// agent id was not supplied. - pub fn load_commands( - &self, - provider: ProviderKind, - acp_agent_id: Option<&str>, - ) -> Vec { - let Some(path) = self.commands_path(provider, acp_agent_id) else { - return Vec::new(); - }; - let Ok(bytes) = fs::read(path) else { + /// Load the most recently reported command/skill list for one native + /// installation or ACP agent. Empty when missing or unreadable. + pub fn load_commands(&self, key: &CommandsCacheKey) -> Vec { + let Ok(bytes) = fs::read(self.commands_path(key)) else { return Vec::new(); }; serde_json::from_slice(&bytes).unwrap_or_default() } - /// Atomically persist the complete command/skill list reported by a native - /// provider or one specific ACP agent. Empty lists are meaningful: they + /// Atomically persist the complete command/skill list reported by one + /// native installation or ACP agent. Empty lists are meaningful: they /// replace a stale non-empty cache. pub fn save_commands( &self, - provider: ProviderKind, - acp_agent_id: Option<&str>, + key: &CommandsCacheKey, commands: &[ProviderCommand], ) -> std::io::Result<()> { - let path = self.commands_path(provider, acp_agent_id).ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "ACP command cache requires an agent id", - ) - })?; + let path = self.commands_path(key); let tmp = path.with_extension("json.tmp"); let data = serde_json::to_vec_pretty(commands) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; @@ -187,6 +224,14 @@ impl SessionStore { fs::rename(&tmp, path) } + /// Forget a cached command list after the installation changed under it. + pub fn invalidate_commands(&self, key: &CommandsCacheKey) -> std::io::Result<()> { + match fs::remove_file(self.commands_path(key)) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(error), + _ => Ok(()), + } + } + /// Load the whole index file (projects + sessions), tolerating the old /// bare-array schema and deriving implicit projects for orphan sessions. pub fn read_file(&self) -> IndexFile { @@ -505,7 +550,7 @@ mod tests { } #[test] - fn command_cache_roundtrips_per_provider_and_acp_agent() { + fn command_cache_is_kept_per_native_home_and_acp_agent() { let root = temp_root(); let store = SessionStore::open_at(root.clone()).unwrap(); let native = vec![ProviderCommand { @@ -518,33 +563,42 @@ mod tests { description: None, kind: ProviderCommandKind::Skill, }]; - store - .save_commands(ProviderKind::ClaudeCode, None, &native) - .unwrap(); - store - .save_commands(ProviderKind::Acp, Some("vendor/agent"), &acp) - .unwrap(); + let default_home = CommandsCacheKey::Native { + provider: ProviderKind::ClaudeCode, + home: None, + }; + let shadow_home = CommandsCacheKey::Native { + provider: ProviderKind::ClaudeCode, + home: Some(PathBuf::from("/tmp/claude-shadow")), + }; + let agent = CommandsCacheKey::Acp { + agent_id: "vendor/agent".into(), + }; + store.save_commands(&default_home, &native).unwrap(); + store.save_commands(&agent, &acp).unwrap(); // Reopen the store to prove the values come from disk, not memory. let reopened = SessionStore::open_at(root.clone()).unwrap(); - assert_eq!( - reopened.load_commands(ProviderKind::ClaudeCode, None), - native - ); - assert_eq!( - reopened.load_commands(ProviderKind::Acp, Some("vendor/agent")), - acp - ); + assert_eq!(reopened.load_commands(&default_home), native); + assert!(reopened.load_commands(&shadow_home).is_empty()); + assert_eq!(reopened.load_commands(&agent), acp); assert!( reopened - .load_commands(ProviderKind::Acp, Some("different-agent")) + .load_commands(&CommandsCacheKey::Acp { + agent_id: "different-agent".into() + }) .is_empty() ); - assert!(root.join("commands-claude.json").is_file()); assert!( root.join("commands-acp-76656e646f722f6167656e74.json") .is_file() ); + + reopened.save_commands(&shadow_home, &native).unwrap(); + reopened.invalidate_commands(&default_home).unwrap(); + reopened.invalidate_commands(&default_home).unwrap(); + assert!(reopened.load_commands(&default_home).is_empty()); + assert_eq!(reopened.load_commands(&shadow_home), native); let _ = fs::remove_dir_all(root); } diff --git a/crates/services/src/version_check/provider_updates.rs b/crates/services/src/version_check/provider_updates.rs index 9be38b97b..5aac156e2 100644 --- a/crates/services/src/version_check/provider_updates.rs +++ b/crates/services/src/version_check/provider_updates.rs @@ -63,7 +63,9 @@ pub fn npm_package(provider: ProviderKind) -> &'static str { ProviderKind::Codex => "@openai/codex", ProviderKind::Pi => "@earendil-works/pi-coding-agent", ProviderKind::OpenCode => "opencode-ai", - ProviderKind::Acp => "", + ProviderKind::Grok => "@xai-official/grok", + // Cursor's CLI is not published to npm. + ProviderKind::Cursor | ProviderKind::Acp => "", } } diff --git a/crates/services/src/version_check/provider_updates/installation.rs b/crates/services/src/version_check/provider_updates/installation.rs index c29a5327b..cad6a4da2 100644 --- a/crates/services/src/version_check/provider_updates/installation.rs +++ b/crates/services/src/version_check/provider_updates/installation.rs @@ -495,6 +495,8 @@ fn binary_name(provider: ProviderKind) -> &'static str { ProviderKind::Codex => "codex", ProviderKind::Pi => "pi", ProviderKind::OpenCode => "opencode", + ProviderKind::Cursor => "cursor-agent", + ProviderKind::Grok => "grok", ProviderKind::Acp => "", } } @@ -814,7 +816,7 @@ fn provider_brew_package(provider: ProviderKind, name: &str) -> bool { ProviderKind::Codex => matches!(name, "codex" | "codex@alpha"), ProviderKind::Pi => name == "pi-coding-agent", ProviderKind::OpenCode => matches!(name, "opencode" | "opencode-v2"), - ProviderKind::Acp => false, + ProviderKind::Cursor | ProviderKind::Grok | ProviderKind::Acp => false, } } @@ -1086,7 +1088,8 @@ async fn javascript_windows(context: &Context<'_, P>) -> Option &["opencode-ai", "@opencode/cli"], - ProviderKind::Acp => return None, + ProviderKind::Grok => &["@xai-official/grok"], + ProviderKind::Cursor | ProviderKind::Acp => return None, }; for (manager, source, args, bin_args) in [ ( diff --git a/crates/ui/src/assets.rs b/crates/ui/src/assets.rs index 94d7b3446..fbfd37559 100644 --- a/crates/ui/src/assets.rs +++ b/crates/ui/src/assets.rs @@ -49,6 +49,10 @@ const EXTRA_ICONS: &[(&str, &[u8])] = &[ "icons/box.svg", include_bytes!("../../../assets/icons/box.svg"), ), + ( + "icons/puzzle.svg", + include_bytes!("../../../assets/icons/puzzle.svg"), + ), ( "icons/ruler.svg", include_bytes!("../../../assets/icons/ruler.svg"), @@ -81,6 +85,14 @@ const EXTRA_ICONS: &[(&str, &[u8])] = &[ "icons/opencode.svg", include_bytes!("../../../assets/icons/opencode.svg"), ), + ( + "icons/cursor.svg", + include_bytes!("../../../assets/icons/cursor.svg"), + ), + ( + "icons/grok.svg", + include_bytes!("../../../assets/icons/grok.svg"), + ), ( "icons/wrench.svg", include_bytes!("../../../assets/icons/wrench.svg"), diff --git a/crates/ui/src/composer/components/pickers.rs b/crates/ui/src/composer/components/pickers.rs index 587a21d01..cf669d173 100644 --- a/crates/ui/src/composer/components/pickers.rs +++ b/crates/ui/src/composer/components/pickers.rs @@ -94,7 +94,7 @@ impl Composer { let query = self.model_search.read(cx).value().to_lowercase(); let fav_profiles: Vec<(String, ProviderKind)> = { let profiles = store.enabled_profiles(); - PICKER_PROVIDER_KINDS + ProviderKind::NATIVE .into_iter() .flat_map(|kind| { profiles @@ -195,7 +195,7 @@ impl Composer { let acp_rail_agents: Vec<(String, String)> = store .settings_installed_acp_agents() .into_iter() - .filter(|agent| agent.enabled) + .filter(|agent| agent.enabled && agent.offered_for_new_sessions()) .map(|agent| (agent.id.clone(), agent.name.clone())) .collect(); @@ -723,7 +723,7 @@ fn render_model_pane( let profile_ids: Vec = { let store = store_entity.read(cx); let profiles = store.enabled_profiles(); - PICKER_PROVIDER_KINDS + ProviderKind::NATIVE .into_iter() .flat_map(|kind| { profiles diff --git a/crates/ui/src/composer/mod.rs b/crates/ui/src/composer/mod.rs index 886296ec5..5407a0bdb 100644 --- a/crates/ui/src/composer/mod.rs +++ b/crates/ui/src/composer/mod.rs @@ -81,12 +81,6 @@ use crate::usage::{METER_BLUE, METER_RED}; /// File mentions are potentially unbounded; command and skill feeds are not /// capped and instead use the trigger menu's scrolling viewport. const FILE_MENU_ROW_CAP: usize = 50; -const PICKER_PROVIDER_KINDS: [ProviderKind; 4] = [ - ProviderKind::ClaudeCode, - ProviderKind::Codex, - ProviderKind::Pi, - ProviderKind::OpenCode, -]; /// Stop-button red-orange. const STOP_TINT: u32 = 0xF4562E; diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index a3255293b..d3184f11b 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -35,6 +35,7 @@ pub mod pairing; pub mod palette; mod pasteboard; mod plan_panel; +mod plugins_settings; mod preview_panel; mod project_icon; pub(crate) mod provider_card; diff --git a/crates/ui/src/plugins_settings.rs b/crates/ui/src/plugins_settings.rs new file mode 100644 index 000000000..281d2de9d --- /dev/null +++ b/crates/ui/src/plugins_settings.rs @@ -0,0 +1,1845 @@ +//! Settings → Plugins: one section per enabled provider profile, showing the +//! native plugin catalog the host listed for it. Every action, marketplace +//! operation and confirmation comes from the host's catalog; a control the +//! host did not offer is never drawn. + +use std::collections::HashSet; +use std::path::PathBuf; +use std::rc::Rc; + +use agent::{ + ApplyNote, DeclaredComponents, MarketplaceAction, PluginAction, PluginActionKind, + PluginManagement, PluginScope, PluginSourceKind, ProviderPluginEntry, Tri, +}; +use gpui::{ + AnyElement, App, AppContext as _, Context, Entity, Hsla, InteractiveElement as _, IntoElement, + ListAlignment, ListState, ParentElement as _, Render, ScrollHandle, SharedString, Styled as _, + Subscription, Window, div, list, point, prelude::FluentBuilder as _, px, +}; +use gpui_base::{Collapsible, Scrollbar, StyledExt as _, h_flex, v_flex}; +use tcode_core::settings::ResolvedProfile; +use tcode_protocol::{ + PluginCatalogState, PluginChallenge, PluginChallengeKind, PluginStaleReason, + ProviderPluginCatalog, RuntimeOperationId, +}; + +use crate::icon::{Icon, IconName}; +use crate::material; +use crate::overlay::{DialogActions, OverlayExt as _}; +use crate::scroll::ScrollableElement as _; +use crate::sizing::Sizable as _; +use crate::store::{StoreChange, TopicKind, WorkspaceStore, observe_store_topics}; +use crate::theme::ActiveTheme as _; +use crate::widgets::Spinner; +use crate::widgets::button::{Button, ButtonVariants as _}; +use crate::widgets::input::{Input, InputState}; +use crate::window_state::WindowState; + +const SCOPES: [PluginScope; 5] = [ + PluginScope::User, + PluginScope::Project, + PluginScope::Local, + PluginScope::Managed, + PluginScope::Session, +]; + +pub struct PluginsSettingsPanel { + store: Entity, + window_state: Entity, + /// The settings page's content scroll, which a focused section is + /// brought into view in. + page_scroll: ScrollHandle, + /// Whether the page is on screen. Entering it lists every managed + /// profile again, and challenges are only presented while it shows. + shown: bool, + /// `(profile id, entry id)` rows whose details disclosure is open. + expanded: HashSet<(String, String)>, + /// Profiles whose marketplace disclosure is open. + marketplaces_open: HashSet, + /// A profile section to bring into view once it is laid out. + focus: Option, + /// The challenge whose dialog is open. + challenge: Option, + /// Challenges this client already answered; the replica keeps showing + /// them until the host's answer arrives. + answered: HashSet, + _subscriptions: Vec, +} + +impl PluginsSettingsPanel { + pub fn new( + store: Entity, + window_state: Entity, + page_scroll: ScrollHandle, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let subscriptions = vec![ + cx.subscribe_in( + &store, + window, + |this, _, change: &StoreChange, window, cx| match change.topic { + TopicKind::Providers | TopicKind::Settings => { + if this.shown { + this.sync_challenge(window, cx); + } + cx.notify(); + } + TopicKind::ActiveSession | TopicKind::SessionStatus => cx.notify(), + _ => {} + }, + ), + cx.observe(&window_state, |_, _, cx| cx.notify()), + ]; + Self { + store, + window_state, + page_scroll, + shown: false, + expanded: HashSet::new(), + marketplaces_open: HashSet::new(), + focus: None, + challenge: None, + answered: HashSet::new(), + _subscriptions: subscriptions, + } + } + + /// The page left the screen; entering it again lists the catalogs anew. + pub fn hide(&mut self) { + self.shown = false; + } + + /// Bring `profile_id`'s section into view once it is laid out. + pub fn focus_profile(&mut self, profile_id: String, cx: &mut Context) { + self.focus = Some(profile_id); + cx.notify(); + } + + fn managed_profiles(&self, cx: &App) -> Vec { + self.store + .read(cx) + .enabled_profiles() + .into_iter() + .filter(|profile| manages(profile.kind.caps().plugin_management)) + .collect() + } + + /// List a profile's catalog from the open thread's directory, or from + /// outside any project when no thread is open. + fn refresh(&self, profile_id: &str, cx: &mut App) { + let profile_id = profile_id.to_string(); + self.store.update(cx, |store, _| { + let cwd = store.active_session_cwd(); + store.refresh_provider_plugins(profile_id, cwd); + }); + } + + fn pending_challenges(&self, cx: &App) -> Vec { + let store = self.store.read(cx); + store + .enabled_profiles() + .iter() + .filter_map(|profile| store.provider_plugin_catalog(&profile.id)) + .flat_map(|catalog| catalog.challenges.iter().cloned()) + .collect() + } + + /// Keep one dialog open for the first unanswered challenge in the + /// replica, and close it once the host no longer waits on it. + fn sync_challenge(&mut self, window: &mut Window, cx: &mut Context) { + let challenges = self.pending_challenges(cx); + if let Some(open) = self.challenge { + if challenges.iter().any(|challenge| challenge.op_id == open) { + return; + } + self.challenge = None; + window.close_dialog(cx); + } + if !self.shown { + return; + } + let Some(next) = challenges + .into_iter() + .find(|challenge| !self.answered.contains(&challenge.op_id)) + else { + return; + }; + self.challenge = Some(next.op_id); + self.open_challenge(next, window, cx); + } + + fn open_challenge( + &self, + challenge: PluginChallenge, + window: &mut Window, + cx: &mut Context, + ) { + let store = self.store.clone(); + let panel = cx.entity().downgrade(); + let op_id = challenge.op_id; + let answer = Rc::new(move |accept: bool, cx: &mut App| { + store.update(cx, |store, _| store.resolve_plugin_challenge(op_id, accept)); + _ = panel.update(cx, |panel: &mut Self, _| { + panel.answered.insert(op_id); + if panel.challenge == Some(op_id) { + panel.challenge = None; + } + }); + }); + window.open_alert_dialog(cx, move |alert, _, cx| { + let (title, accept_label, decline_label, danger) = match &challenge.kind { + PluginChallengeKind::AcceptCommand { .. } => ( + crate::tr!( + "providers.plugins.challenge.accept_command_title", + name = challenge.entry_id + ), + crate::tr!("providers.plugins.challenge.accept"), + crate::tr!("providers.plugins.challenge.decline"), + false, + ), + PluginChallengeKind::ConfirmDestructive { .. } => ( + crate::tr!( + "providers.plugins.challenge.destructive_title", + name = challenge.entry_id + ), + crate::tr!("providers.plugins.challenge.remove"), + crate::tr!("providers.plugins.challenge.cancel"), + true, + ), + }; + let decline = answer.clone(); + let accept = answer.clone(); + let escape = answer.clone(); + let accept_button = Button::new("plugin-challenge-accept") + .debug_selector(|| "plugin-challenge-accept".into()) + .label(accept_label.into_owned()) + .on_click(move |_, window, cx| { + accept(true, cx); + window.close_dialog(cx); + }); + alert + .bg(cx.theme().popover) + .width(px(560.)) + .title(SharedString::from(title.into_owned())) + .description(challenge_body(&challenge, cx)) + .on_cancel(move |_, _, cx| { + escape(false, cx); + true + }) + .footer( + DialogActions::new() + .flex_wrap() + .child( + Button::new("plugin-challenge-decline") + .debug_selector(|| "plugin-challenge-decline".into()) + .label(decline_label.into_owned()) + .on_click(move |_, window, cx| { + decline(false, cx); + window.close_dialog(cx); + }), + ) + .child(if danger { + accept_button.danger() + } else { + accept_button.primary() + }), + ) + }); + } + + fn open_add_plugin(&self, profile_id: String, window: &mut Window, cx: &mut Context) { + let title = crate::tr!( + "providers.plugins.add_plugin_title", + name = self + .store + .read(cx) + .provider_profile_display_name(&profile_id) + ) + .into_owned(); + let store = self.store.clone(); + let picker = cx.new(|cx| AddPluginPicker::new(store, profile_id, window, cx)); + window.open_dialog(cx, move |dialog, window, cx| { + let picker = picker.clone(); + let height = crate::sizing::fit_viewport(456., window.viewport_size().height * 0.7); + dialog + .w(px(620.)) + .bg(cx.theme().popover) + .shadow_xl() + .title(title.clone()) + .content(move |content, _, _| { + content.h(height).child( + div() + .debug_selector(|| "add-plugin-body".into()) + .size_full() + .child(picker.clone()), + ) + }) + }); + } + + fn open_add_marketplace( + &self, + profile_id: String, + cwd: Option, + window: &mut Window, + cx: &mut Context, + ) { + let input = cx.new(|cx| { + InputState::new(window, cx).placeholder(crate::tr!( + "providers.plugins.marketplace_source_placeholder" + )) + }); + let store = self.store.clone(); + window.open_dialog(cx, move |dialog, _, cx| { + let field = input.clone(); + let submit_input = input.clone(); + let store = store.clone(); + let profile_id = profile_id.clone(); + let cwd = cwd.clone(); + dialog + .w(px(520.)) + .bg(cx.theme().popover) + .shadow_xl() + .title(crate::tr!("providers.plugins.add_marketplace_title").into_owned()) + .content(move |content, _, cx| { + content.child( + v_flex() + .w_full() + .gap_1p5() + .child( + div() + .text_size(px(13.)) + .font_medium() + .child(crate::tr!("providers.plugins.marketplace_source")), + ) + .child(Input::new(&field).small()) + .child( + div() + .text_size(px(12.)) + .text_color(cx.theme().muted_foreground) + .child(crate::tr!("providers.plugins.marketplace_source_help")), + ), + ) + }) + .footer( + DialogActions::new() + .child( + Button::new("plugin-marketplace-cancel") + .ghost() + .label(crate::tr!("settings.cancel").into_owned()) + .on_click(|_, window, cx| window.close_dialog(cx)), + ) + .child( + Button::new("plugin-marketplace-submit") + .debug_selector(|| "plugin-marketplace-submit".into()) + .primary() + .label(crate::tr!("providers.plugins.add").into_owned()) + .on_click(move |_, window, cx| { + let source = submit_input.read(cx).value().trim().to_string(); + if source.is_empty() { + return; + } + let (profile_id, cwd) = (profile_id.clone(), cwd.clone()); + store.update(cx, |store, _| { + store.add_provider_marketplace(profile_id, source, cwd) + }); + window.close_dialog(cx); + }), + ), + ) + }); + } + + fn render_profile(&self, profile: &ResolvedProfile, cx: &mut Context) -> AnyElement { + let management = profile.kind.caps().plugin_management; + let store = self.store.read(cx); + let name = store.provider_profile_display_name(&profile.id); + let catalog = store.provider_plugin_catalog(&profile.id).cloned(); + let requested_cwd = store.active_session_cwd(); + let muted = cx.theme().muted_foreground; + let profile_id = profile.id.clone(); + + let mut header = self + .row(cx) + .flex_row() + .items_center() + .gap_2() + .child( + div() + .flex_none() + .size(px(16.)) + .child(crate::provider_card::provider_glyph(profile.kind).small()), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_size(px(13.)) + .font_medium() + .child(name.clone()), + ); + let section_id = SharedString::from(format!("plugins-section-{profile_id}")); + let mut section = div().id(section_id.clone()).w_full().debug_selector({ + let section_id = section_id.clone(); + move || section_id.to_string() + }); + if self.focus.as_ref() == Some(&profile_id) { + section = section.child(self.scroll_into_view()); + } + + if !manages(management) { + let rows = vec![ + header.into_any_element(), + self.note_row( + crate::tr!("providers.plugins.unmanaged").into_owned(), + muted, + cx, + ), + ]; + return section + .child(material::grouped(rows, cx)) + .into_any_element(); + } + + let loading = catalog.as_ref().is_none_or(|catalog| catalog.loading); + let refresh_id = format!("plugins-refresh-{profile_id}"); + header = header + .when(loading, |row| { + row.child(Spinner::new().xsmall().color(muted)) + }) + .child( + Button::new(SharedString::from(refresh_id.clone())) + .debug_selector(move || refresh_id.clone()) + .ghost() + .small() + .text_size(px(12.)) + .icon(Icon::empty().path("icons/rotate-ccw.svg")) + .tooltip(crate::tr!("providers.plugins.refresh", name = name)) + .on_click(cx.listener({ + let profile_id = profile_id.clone(); + move |this, _, _, cx| this.refresh(&profile_id, cx) + })), + ); + + let context_cwd = catalog + .as_ref() + .map_or(requested_cwd, |catalog| catalog.context_cwd.clone()); + let context = match &context_cwd { + Some(path) => crate::tr!("providers.plugins.project_context", path = path.display()), + None => crate::tr!("providers.plugins.no_project_context"), + }; + let mut rows = vec![ + header.into_any_element(), + self.row(cx) + .text_size(px(12.)) + .text_color(muted) + .child(context.into_owned()) + .into_any_element(), + ]; + rows.extend(self.status_rows(catalog.as_ref(), cx)); + + let mut after_card: Vec = Vec::new(); + if let Some(catalog) = &catalog { + let busy = !catalog.pending.is_empty(); + let installed: Vec<&ProviderPluginEntry> = catalog + .entries + .iter() + .filter(|entry| !entry.installations.is_empty()) + .collect(); + if installed.is_empty() { + if !matches!( + catalog.state, + PluginCatalogState::Error { .. } + | PluginCatalogState::Stale { + reason: PluginStaleReason::NotLoaded + } + ) { + rows.push(self.note_row( + crate::tr!("providers.plugins.no_installed").into_owned(), + muted, + cx, + )); + } + } else { + for entry in installed { + rows.push(self.installed_row(&profile_id, catalog, entry, busy, cx)); + } + } + after_card.push(self.section_actions(&profile_id, catalog, management, cx)); + if management.marketplaces { + after_card.push(self.marketplaces(&profile_id, catalog, busy, cx)); + } + } + after_card.push( + div() + .pl_3() + .text_size(px(12.)) + .text_color(muted) + .child(apply_note(management.apply)) + .into_any_element(), + ); + + section + .child( + v_flex() + .w_full() + .gap_2() + .child(material::grouped(rows, cx)) + .children(after_card), + ) + .into_any_element() + } + + fn status_rows( + &self, + catalog: Option<&ProviderPluginCatalog>, + cx: &mut Context, + ) -> Vec { + let muted = cx.theme().muted_foreground; + let danger = cx.theme().danger; + let warning = cx.theme().warning; + let Some(catalog) = catalog else { + return vec![self.note_row( + crate::tr!("providers.plugins.listing").into_owned(), + muted, + cx, + )]; + }; + let mut rows = Vec::new(); + if catalog.loading { + rows.push(self.note_row( + crate::tr!("providers.plugins.listing").into_owned(), + muted, + cx, + )); + } + match &catalog.state { + PluginCatalogState::Fresh => {} + PluginCatalogState::Stale { reason } => { + let text = match reason { + PluginStaleReason::NotLoaded => crate::tr!("providers.plugins.not_listed"), + PluginStaleReason::ContextChanged => { + crate::tr!("providers.plugins.stale_context") + } + PluginStaleReason::Changed => crate::tr!("providers.plugins.stale_changed"), + }; + if !catalog.loading { + rows.push(self.note_row(text.into_owned(), muted, cx)); + } + } + PluginCatalogState::Error { message } => rows.push( + self.row(cx) + .debug_selector(|| "plugins-list-error".into()) + .gap_1() + .text_size(px(12.)) + .text_color(danger) + .child(crate::tr!("providers.plugins.list_failed").into_owned()) + .child(verbatim(message.clone(), cx)) + .into_any_element(), + ), + } + for error in &catalog.errors { + rows.push( + self.row(cx) + .text_size(px(12.)) + .text_color(warning) + .child(verbatim(error.clone(), cx)) + .into_any_element(), + ); + } + rows + } + + fn installed_row( + &self, + profile_id: &str, + catalog: &ProviderPluginCatalog, + entry: &ProviderPluginEntry, + busy: bool, + cx: &mut Context, + ) -> AnyElement { + let muted = cx.theme().muted_foreground; + let pending = catalog.pending.contains(&entry.id); + let key = (profile_id.to_string(), entry.id.clone()); + let expanded = self.expanded.contains(&key); + let has_details = entry.declared.is_some() || !entry.diagnostics.is_empty(); + let (enabled_label, enabled_bg, enabled_fg) = match entry.enabled { + Tri::Yes => ( + crate::tr!("providers.plugins.effective_enabled"), + cx.theme().success.opacity(0.12), + cx.theme().success_foreground, + ), + Tri::No => ( + crate::tr!("providers.plugins.effective_disabled"), + cx.theme().muted, + muted, + ), + Tri::Unknown => ( + crate::tr!("providers.plugins.effective_unknown"), + cx.theme().muted, + muted, + ), + }; + let row_id = format!("plugin-row-{profile_id}-{}", entry.id); + let toggle_id = format!("plugin-details-{profile_id}-{}", entry.id); + let title = h_flex() + .w_full() + .flex_wrap() + .items_center() + .gap_2() + .child( + div() + .text_size(px(13.)) + .font_medium() + .child(entry.name.clone()), + ) + .when_some(entry.version.clone(), |row, version| { + row.child( + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_size(px(12.)) + .text_color(muted) + .child(version), + ) + }) + .child(material::semantic_chip( + enabled_label.into_owned(), + enabled_bg, + enabled_fg, + cx, + )) + .when(pending, |row| { + row.child(Spinner::new().xsmall().color(muted)) + }) + .child(div().flex_1()) + .when(has_details, |row| { + row.child( + Button::new(SharedString::from(toggle_id.clone())) + .debug_selector(move || toggle_id.clone()) + .ghost() + .small() + .text_size(px(12.)) + .icon(if expanded { + IconName::ChevronUp + } else { + IconName::ChevronDown + }) + .label(crate::tr!("providers.plugins.details").into_owned()) + .tooltip(crate::tr!( + "providers.plugins.toggle_details", + name = entry.name + )) + .on_click(cx.listener(move |this, _, _, cx| { + if !this.expanded.remove(&key) { + this.expanded.insert(key.clone()); + } + cx.notify(); + })), + ) + }); + + let scopes = SCOPES.into_iter().filter(|scope| { + entry + .installations + .iter() + .any(|installation| installation.scope == *scope) + || entry.actions.iter().any(|action| action.scope() == *scope) + }); + let scope_rows: Vec = scopes + .map(|scope| { + let installation = entry + .installations + .iter() + .find(|installation| installation.scope == scope); + let facts = h_flex() + .w_full() + .flex_wrap() + .items_center() + .gap_x_2() + .text_size(px(12.)) + .child(div().font_medium().child(scope_label(scope))) + .map(|row| match installation { + None => row.child( + div() + .text_color(muted) + .child(crate::tr!("providers.plugins.not_installed_in_scope")), + ), + Some(installation) => row + .when_some(installation.version.clone(), |row, version| { + row.child( + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_color(muted) + .child(version), + ) + }) + .when_some(installation.scope_enabled, |row, enabled| { + row.child(div().text_color(muted).child(if enabled { + crate::tr!("providers.plugins.scope_enabled") + } else { + crate::tr!("providers.plugins.scope_disabled") + })) + }), + }); + let location = installation + .and_then(|installation| installation.location.as_ref()) + .map(|path| { + div() + .w_full() + .font_family(cx.theme().mono_font_family.clone()) + .text_size(px(11.)) + .text_color(muted) + .child(path.display().to_string()) + }); + let actions: Vec = entry + .actions + .iter() + .copied() + .filter(|action| action.scope() == scope) + .collect(); + v_flex() + .w_full() + .pl_3() + .py_1() + .gap_1() + .border_l_1() + .border_color(cx.theme().border) + .child(facts) + .children(location) + .when(!actions.is_empty(), |col| { + col.child(h_flex().w_full().flex_wrap().gap_1().children( + ordered(actions).into_iter().map(|action| { + action_button( + &self.store, + profile_id, + &entry.id, + action, + catalog.context_cwd.clone(), + busy, + ) + .into_any_element() + }), + )) + }) + .into_any_element() + }) + .collect(); + + let source = source_line(entry, cx); + self.row(cx) + .id(SharedString::from(row_id.clone())) + .debug_selector(move || row_id.clone()) + .gap_1p5() + .child(title) + .when_some(entry.description.clone(), |col, description| { + col.child( + div() + .text_size(px(12.)) + .text_color(muted) + .child(description), + ) + }) + .child(source) + .children(scope_rows) + .children(entry.errors.iter().map(|error| { + div() + .text_size(px(12.)) + .text_color(cx.theme().danger) + .child(verbatim(error.clone(), cx)) + })) + .child( + Collapsible::new() + .w_full() + .open(expanded) + .content(details(entry, cx)), + ) + .into_any_element() + } + + fn section_actions( + &self, + profile_id: &str, + catalog: &ProviderPluginCatalog, + management: PluginManagement, + cx: &mut Context, + ) -> AnyElement { + let add_id = format!("plugins-add-{profile_id}"); + let marketplaces_id = format!("plugins-marketplaces-{profile_id}"); + let open = self.marketplaces_open.contains(profile_id); + h_flex() + .w_full() + .flex_wrap() + .gap_2() + .when(management.supports(PluginActionKind::Install), |row| { + row.child( + Button::new(SharedString::from(add_id.clone())) + .debug_selector(move || add_id.clone()) + .outline() + .small() + .text_size(px(12.)) + .icon(IconName::Plus) + .label(crate::tr!("providers.plugins.add_plugin").into_owned()) + .on_click(cx.listener({ + let profile_id = profile_id.to_string(); + move |this, _, window, cx| { + this.open_add_plugin(profile_id.clone(), window, cx) + } + })), + ) + }) + .when(management.marketplaces, |row| { + row.child( + Button::new(SharedString::from(marketplaces_id.clone())) + .debug_selector(move || marketplaces_id.clone()) + .ghost() + .small() + .text_size(px(12.)) + .icon(if open { + IconName::ChevronUp + } else { + IconName::ChevronDown + }) + .label( + crate::tr!( + "providers.plugins.marketplaces", + count = catalog.marketplaces.len() + ) + .into_owned(), + ) + .on_click(cx.listener({ + let profile_id = profile_id.to_string(); + move |this, _, _, cx| { + if !this.marketplaces_open.remove(&profile_id) { + this.marketplaces_open.insert(profile_id.clone()); + } + cx.notify(); + } + })), + ) + }) + .into_any_element() + } + + fn marketplaces( + &self, + profile_id: &str, + catalog: &ProviderPluginCatalog, + busy: bool, + cx: &mut Context, + ) -> AnyElement { + let muted = cx.theme().muted_foreground; + let mut rows: Vec = catalog + .marketplaces + .iter() + .map(|marketplace| { + let removable = catalog.marketplace_actions.iter().any(|action| { + matches!(action, MarketplaceAction::Remove { marketplace: name, .. } if *name == marketplace.name) + }); + let pending = catalog.pending.contains(&marketplace.name); + let remove_id = format!("plugin-marketplace-remove-{profile_id}-{}", marketplace.name); + self.row(cx) + .flex_row() + .items_start() + .gap_3() + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .child( + h_flex() + .flex_wrap() + .items_center() + .gap_2() + .child( + div() + .text_size(px(13.)) + .font_medium() + .child(marketplace.name.clone()), + ) + .child(material::semantic_chip( + source_kind_label(marketplace.kind), + cx.theme().muted, + muted, + cx, + )) + .when(pending, |row| { + row.child(Spinner::new().xsmall().color(muted)) + }), + ) + .child( + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_size(px(11.)) + .text_color(muted) + .child(marketplace.source.clone()), + ) + .when_some(marketplace.location.as_ref(), |col, location| { + col.child( + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_size(px(11.)) + .text_color(muted) + .child(location.display().to_string()), + ) + }), + ) + .when(removable, |row| { + let name = marketplace.name.clone(); + let profile_id = profile_id.to_string(); + let cwd = catalog.context_cwd.clone(); + let store = self.store.clone(); + row.child( + Button::new(SharedString::from(remove_id.clone())) + .debug_selector(move || remove_id.clone()) + .outline() + .danger() + .small() + .text_size(px(12.)) + .disabled(busy) + .label(crate::tr!("providers.plugins.remove").into_owned()) + .on_click(move |_, _, cx| { + let (profile_id, name, cwd) = + (profile_id.clone(), name.clone(), cwd.clone()); + store.update(cx, |store, _| { + store.remove_provider_marketplace(profile_id, name, cwd) + }); + }), + ) + }) + .into_any_element() + }) + .collect(); + if rows.is_empty() { + rows.push(self.note_row( + crate::tr!("providers.plugins.no_marketplaces").into_owned(), + muted, + cx, + )); + } + let can_add = catalog + .marketplace_actions + .contains(&MarketplaceAction::Add); + let add_id = format!("plugin-marketplace-add-{profile_id}"); + Collapsible::new() + .w_full() + .open(self.marketplaces_open.contains(profile_id)) + .content( + v_flex() + .w_full() + .gap_2() + .child(material::grouped(rows, cx)) + .when(can_add, |col| { + col.child( + h_flex().child( + Button::new(SharedString::from(add_id.clone())) + .debug_selector(move || add_id.clone()) + .outline() + .small() + .text_size(px(12.)) + .icon(IconName::Plus) + .disabled(busy) + .label( + crate::tr!("providers.plugins.add_marketplace") + .into_owned(), + ) + .on_click(cx.listener({ + let profile_id = profile_id.to_string(); + let cwd = catalog.context_cwd.clone(); + move |this, _, window, cx| { + this.open_add_marketplace( + profile_id.clone(), + cwd.clone(), + window, + cx, + ) + } + })), + ), + ) + }), + ) + .into_any_element() + } + + /// Scroll the settings page so the element this is placed in starts at + /// the top of the viewport, once this frame has placed it. + fn scroll_into_view(&self) -> impl IntoElement { + let handle = self.page_scroll.clone(); + gpui::canvas( + move |bounds, window, _| { + window.on_next_frame(move |window, _| { + let viewport = handle.bounds(); + let offset = handle.offset(); + let top = (offset.y + viewport.origin.y - bounds.origin.y) + .clamp(-handle.max_offset().y, px(0.)); + handle.set_offset(point(offset.x, top)); + window.refresh(); + }); + }, + |_, _, _, _| {}, + ) + .absolute() + .size_0() + } + + /// One row of a grouped card. + fn row(&self, _cx: &Context) -> gpui::Div { + v_flex() + .w_full() + .min_h(px(44.)) + .px_3() + .py_2p5() + .justify_center() + } + + fn note_row(&self, text: String, color: Hsla, cx: &Context) -> AnyElement { + self.row(cx) + .text_size(px(12.)) + .text_color(color) + .child(text) + .into_any_element() + } +} + +impl Render for PluginsSettingsPanel { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.shown && self.store.read(cx).settings_hydrated() { + self.shown = true; + for profile in self.managed_profiles(cx) { + self.refresh(&profile.id, cx); + } + cx.defer_in(window, |this, window, cx| this.sync_challenge(window, cx)); + } + let profiles = self.store.read(cx).enabled_profiles(); + let compact = self.window_state.read(cx).compact; + let page = v_flex() + .w_full() + .gap(if compact { px(16.) } else { px(24.) }) + .child( + div() + .pl_3() + .text_size(px(11.)) + .font_medium() + .text_color(cx.theme().muted_foreground) + .child(crate::tr!("settings.plugins_section")), + ) + .children( + profiles + .iter() + .map(|profile| self.render_profile(profile, cx)), + ); + self.focus = None; + page + } +} + +/// The searchable list of plugins the catalog offers but has not installed. +struct AddPluginPicker { + store: Entity, + profile_id: String, + search: Entity, + list: ListState, + ids: Vec, + _subscriptions: Vec, +} + +impl AddPluginPicker { + fn new( + store: Entity, + profile_id: String, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let search = cx.new(|cx| { + InputState::new(window, cx).placeholder(crate::tr!("providers.plugins.search")) + }); + let subscriptions = vec![ + observe_store_topics(&store, &[TopicKind::Providers], cx), + cx.observe(&search, |_, _, cx| cx.notify()), + ]; + Self { + store, + profile_id, + search, + list: ListState::new(0, ListAlignment::Top, px(120.)).measure_all(), + ids: Vec::new(), + _subscriptions: subscriptions, + } + } + + fn render_row( + &self, + catalog: &ProviderPluginCatalog, + entry: &ProviderPluginEntry, + cx: &mut Context, + ) -> AnyElement { + let muted = cx.theme().muted_foreground; + let busy = !catalog.pending.is_empty(); + let pending = catalog.pending.contains(&entry.id); + let installs: Vec = entry + .actions + .iter() + .copied() + .filter(|action| action.kind() == PluginActionKind::Install) + .collect(); + let row_id = format!("plugin-available-{}-{}", self.profile_id, entry.id); + let install = match installs.as_slice() { + [] => None, + [only] => Some( + h_flex() + .child(action_button( + &self.store, + &self.profile_id, + &entry.id, + *only, + catalog.context_cwd.clone(), + busy, + )) + .into_any_element(), + ), + several => Some( + h_flex() + .flex_wrap() + .items_center() + .gap_1() + .child( + div() + .text_size(px(12.)) + .text_color(muted) + .child(crate::tr!("providers.plugins.install_to")), + ) + .children(several.iter().map(|action| { + action_button( + &self.store, + &self.profile_id, + &entry.id, + *action, + catalog.context_cwd.clone(), + busy, + ) + .label(scope_label(action.scope())) + })) + .into_any_element(), + ), + }; + v_flex() + .id(SharedString::from(row_id.clone())) + .debug_selector(move || row_id.clone()) + .w_full() + .p_3() + .gap_1() + .hover(|row| row.bg(cx.theme().list_hover)) + .child( + h_flex() + .w_full() + .flex_wrap() + .items_center() + .gap_2() + .child( + div() + .text_size(px(14.)) + .font_medium() + .child(entry.name.clone()), + ) + .when_some(entry.version.clone(), |row, version| { + row.child( + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_size(px(12.)) + .text_color(muted) + .child(version), + ) + }) + .when(pending, |row| { + row.child(Spinner::new().xsmall().color(muted)) + }), + ) + .when_some(entry.description.clone(), |col, description| { + col.child( + div() + .text_size(px(12.)) + .text_color(muted) + .child(description), + ) + }) + .child(source_line(entry, cx)) + .when(entry.source.kind == PluginSourceKind::Command, |col| { + col.child( + div() + .text_size(px(12.)) + .text_color(cx.theme().warning) + .child(crate::tr!("providers.plugins.command_source")), + ) + }) + .when_some(entry.declared.as_ref(), |col, declared| { + col.child(declared_chips(declared, cx)) + }) + .children(entry.errors.iter().map(|error| { + div() + .text_size(px(12.)) + .text_color(cx.theme().danger) + .child(verbatim(error.clone(), cx)) + })) + .children(install) + .into_any_element() + } +} + +impl Render for AddPluginPicker { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let query = self.search.read(cx).value().trim().to_lowercase(); + let catalog = self + .store + .read(cx) + .provider_plugin_catalog(&self.profile_id) + .cloned(); + let available: Vec = catalog + .iter() + .flat_map(|catalog| catalog.entries.iter()) + .filter(|entry| entry.installations.is_empty()) + .cloned() + .collect(); + let none_available = available.is_empty(); + let matches: Rc> = Rc::new( + available + .into_iter() + .filter(|entry| { + query.is_empty() + || entry.name.to_lowercase().contains(&query) + || entry + .description + .as_deref() + .is_some_and(|text| text.to_lowercase().contains(&query)) + }) + .collect(), + ); + if !matches.iter().map(|entry| &entry.id).eq(&self.ids) { + self.ids = matches.iter().map(|entry| entry.id.clone()).collect(); + self.list.reset(matches.len()); + } + let muted = cx.theme().muted_foreground; + let body = match catalog { + Some(catalog) if !matches.is_empty() => { + let catalog = Rc::new(catalog); + crate::scroll::page_viewport( + "add-plugin-bounce", + crate::wheel_easing::Handle::List(self.list.clone()), + list( + self.list.clone(), + cx.processor(move |this, ix: usize, _, cx| { + this.render_row(&catalog, &matches[ix], cx) + }), + ) + .size_full(), + ) + .into_any_element() + } + catalog => div() + .p_3() + .text_size(px(13.)) + .text_color(muted) + .child( + if catalog.is_none_or(|catalog| catalog.loading) && none_available { + crate::tr!("providers.plugins.listing") + } else if none_available { + crate::tr!("providers.plugins.no_available") + } else { + crate::tr!("providers.plugins.no_matches") + }, + ) + .into_any_element(), + }; + v_flex() + .size_full() + .gap_3() + .child(Input::new(&self.search).small()) + .child( + v_flex() + .w_full() + .flex_1() + .min_h_0() + .relative() + .rounded(material::radius_card(cx)) + .bg(cx.theme().muted) + .child(body) + .when(!window.is_inspector_picking(cx), |col| { + col.child(Scrollbar::vertical(&self.list).id("add-plugin-scrollbar")) + }), + ) + } +} + +fn manages(management: PluginManagement) -> bool { + !management.actions.is_empty() || management.marketplaces +} + +/// Enable and Disable side by side first, then Update, then Uninstall. +fn ordered(mut actions: Vec) -> Vec { + actions.sort_by_key(|action| match action.kind() { + PluginActionKind::Install => 0, + PluginActionKind::Enable => 1, + PluginActionKind::Disable => 2, + PluginActionKind::Update => 3, + PluginActionKind::Uninstall => 4, + }); + actions +} + +fn action_button( + store: &Entity, + profile_id: &str, + entry_id: &str, + action: PluginAction, + cwd: Option, + busy: bool, +) -> Button { + let (verb, label) = match action.kind() { + PluginActionKind::Install => ("install", crate::tr!("providers.plugins.action.install")), + PluginActionKind::Uninstall => ( + "uninstall", + crate::tr!("providers.plugins.action.uninstall"), + ), + PluginActionKind::Enable => ("enable", crate::tr!("providers.plugins.action.enable")), + PluginActionKind::Disable => ("disable", crate::tr!("providers.plugins.action.disable")), + PluginActionKind::Update => ("update", crate::tr!("providers.plugins.action.update")), + }; + let id = format!( + "plugin-{verb}-{profile_id}-{entry_id}-{}", + scope_key(action.scope()) + ); + let store = store.clone(); + let (profile_id, entry_id) = (profile_id.to_string(), entry_id.to_string()); + let button = Button::new(SharedString::from(id.clone())) + .debug_selector(move || id.clone()) + .outline() + .small() + .text_size(px(12.)) + .disabled(busy) + .label(label.into_owned()) + .on_click(move |_, _, cx| { + let (profile_id, entry_id, cwd) = (profile_id.clone(), entry_id.clone(), cwd.clone()); + store.update(cx, |store, _| { + store.run_provider_plugin_action(profile_id, entry_id, action, cwd) + }); + }); + if action.kind() == PluginActionKind::Uninstall { + button.danger() + } else { + button + } +} + +fn source_line(entry: &ProviderPluginEntry, cx: &App) -> AnyElement { + let muted = cx.theme().muted_foreground; + h_flex() + .w_full() + .flex_wrap() + .items_center() + .gap_2() + .text_size(px(11.)) + .text_color(muted) + .when_some(entry.source.marketplace.as_ref(), |row, marketplace| { + row.child( + crate::tr!( + "providers.plugins.from_marketplace", + marketplace = marketplace + ) + .into_owned(), + ) + }) + .child(material::semantic_chip( + source_kind_label(entry.source.kind), + cx.theme().muted, + muted, + cx, + )) + .into_any_element() +} + +/// The declared components and the host's read-only facts for an entry. +fn details(entry: &ProviderPluginEntry, cx: &App) -> AnyElement { + let muted = cx.theme().muted_foreground; + v_flex() + .w_full() + .pt_1() + .gap_2() + .when_some(entry.declared.as_ref(), |col, declared| { + col.child(declared_chips(declared, cx)) + }) + .children(entry.diagnostics.iter().map(|(key, text)| { + v_flex() + .w_full() + .gap_0p5() + .child( + div() + .text_size(px(12.)) + .font_medium() + .text_color(muted) + .child(diagnostic_label(key)), + ) + .child( + div() + .w_full() + .p_2() + .rounded(material::radius_input(cx)) + .bg(cx.theme().muted) + .text_size(px(12.)) + .child(verbatim(text.clone(), cx)), + ) + })) + .into_any_element() +} + +fn declared_chips(declared: &DeclaredComponents, cx: &App) -> AnyElement { + let muted = cx.theme().muted_foreground; + let groups = [ + ("skills", &declared.skills, false), + ("agents", &declared.agents, false), + ("hooks", &declared.hooks, false), + ("untrusted_hooks", &declared.untrusted_hooks, true), + ("mcp_servers", &declared.mcp_servers, false), + ("lsp_servers", &declared.lsp_servers, false), + ("apps", &declared.apps, false), + ]; + v_flex() + .w_full() + .gap_1() + .children(groups.into_iter().filter_map(|(key, names, warn)| { + let names = names.as_ref()?; + let (bg, fg) = if warn { + ( + cx.theme().warning.opacity(0.14), + cx.theme().warning_foreground, + ) + } else { + (cx.theme().muted, muted) + }; + Some( + h_flex() + .w_full() + .flex_wrap() + .items_center() + .gap_1() + .child(div().text_size(px(12.)).font_medium().child(format!( + "{} · {}", + crate::tr!(format!("providers.plugins.declared.{key}")), + names.len() + ))) + .when(names.is_empty(), |row| { + row.child( + div() + .text_size(px(12.)) + .text_color(muted) + .child(crate::tr!("providers.plugins.none_declared")), + ) + }) + .children( + names + .iter() + .map(|name| material::semantic_chip(name.clone(), bg, fg, cx)), + ), + ) + })) + .into_any_element() +} + +fn challenge_body(challenge: &PluginChallenge, cx: &App) -> AnyElement { + let muted = cx.theme().muted_foreground; + let field = |label: SharedString, value: String| { + v_flex() + .w_full() + .gap_0p5() + .child( + div() + .text_size(px(12.)) + .font_medium() + .text_color(muted) + .child(label), + ) + .child(div().w_full().text_size(px(12.)).child(verbatim(value, cx))) + }; + let body = match &challenge.kind { + PluginChallengeKind::AcceptCommand { + command, + sha256, + mode, + } => v_flex() + .w_full() + .gap_3() + .when_some(challenge.native_text.clone(), |col, text| { + col.child( + div() + .w_full() + .p_2() + .rounded(material::radius_input(cx)) + .bg(cx.theme().muted) + .text_size(px(12.)) + .child(verbatim(text, cx)), + ) + }) + .child(field( + crate::tr!("providers.plugins.challenge.command") + .into_owned() + .into(), + command.clone(), + )) + .child(field( + crate::tr!("providers.plugins.challenge.sha256") + .into_owned() + .into(), + sha256.clone(), + )) + .when_some(mode.clone(), |col, mode| { + col.child(field( + crate::tr!("providers.plugins.challenge.mode") + .into_owned() + .into(), + mode, + )) + }), + PluginChallengeKind::ConfirmDestructive { uninstalls } => v_flex() + .w_full() + .gap_2() + .text_size(px(13.)) + .child(if uninstalls.is_empty() { + crate::tr!("providers.plugins.challenge.destructive_none") + } else { + crate::tr!("providers.plugins.challenge.destructive_body") + }) + .children(uninstalls.iter().map(|id| { + let selector = format!("plugin-challenge-uninstall-{id}"); + div() + .debug_selector(move || selector.clone()) + .pl_3() + .child(verbatim(id.clone(), cx)) + })) + .when_some(challenge.native_text.clone(), |col, text| { + col.child(div().text_color(muted).child(verbatim(text, cx))) + }), + }; + div() + .id("plugin-challenge-body") + .w_full() + .max_h(px(360.)) + .overflow_y_scroll_area() + .child(body) + .into_any_element() +} + +/// Native text exactly as the CLI printed it, in the monospace face. +fn verbatim(text: String, cx: &App) -> AnyElement { + div() + .w_full() + .font_family(cx.theme().mono_font_family.clone()) + .child(text) + .into_any_element() +} + +fn apply_note(apply: ApplyNote) -> String { + match apply { + ApplyNote::ReloadOrRestart => crate::tr!("providers.plugins.apply_reload_or_restart"), + ApplyNote::ReloadOrNextSession => { + crate::tr!("providers.plugins.apply_reload_or_next_session") + } + ApplyNote::NextSession => crate::tr!("providers.plugins.apply_next_session"), + ApplyNote::Unverified => crate::tr!("providers.plugins.apply_unverified"), + } + .into_owned() +} + +fn scope_key(scope: PluginScope) -> &'static str { + match scope { + PluginScope::User => "user", + PluginScope::Project => "project", + PluginScope::Local => "local", + PluginScope::Managed => "managed", + PluginScope::Session => "session", + } +} + +fn scope_label(scope: PluginScope) -> String { + crate::tr!(format!("providers.plugins.scope.{}", scope_key(scope))).into_owned() +} + +fn source_kind_label(kind: PluginSourceKind) -> String { + match kind { + PluginSourceKind::Official => crate::tr!("providers.plugins.source.official"), + PluginSourceKind::ThirdParty => crate::tr!("providers.plugins.source.third_party"), + PluginSourceKind::LocalPath => crate::tr!("providers.plugins.source.local_path"), + PluginSourceKind::Git => crate::tr!("providers.plugins.source.git"), + PluginSourceKind::Npm => crate::tr!("providers.plugins.source.npm"), + PluginSourceKind::Command => crate::tr!("providers.plugins.source.command"), + PluginSourceKind::Unknown => crate::tr!("providers.plugins.source.unknown"), + } + .into_owned() +} + +/// Diagnostics are keyed by stable identifiers; one this client does not +/// know yet is shown as its key. +fn diagnostic_label(key: &str) -> String { + match key { + "details" | "details_error" | "message" | "update_outcome" | "old_version" + | "new_version" => crate::tr!(format!("providers.plugins.diagnostic.{key}")).into_owned(), + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use agent::{PluginInstallation, PluginSource}; + use gpui::{TestAppContext, VisualTestContext, size}; + use tcode_core::settings::Settings; + use tcode_protocol::{ + ClientPayload, Command, CommandResponse, EventEnvelope, HostMessage, ProvidersStatus, + ServerEvent, Topic, decode_client_line, encode_line, + }; + + use super::*; + use crate::settings_page::SettingsPage; + use crate::store::WorkspaceAttachment; + + struct Host { + outgoing: async_channel::Receiver, + incoming: async_channel::Sender, + store: Entity, + _pump: gpui::Task<()>, + } + + impl Host { + fn replicate(&self, catalog: ProviderPluginCatalog, cx: &mut VisualTestContext) { + send_providers(&self.incoming, catalog); + cx.run_until_parked(); + self.store + .update(cx, |store, cx| store.drain_host_events_for_test(cx)); + draw(cx); + } + + /// Play the host: acknowledge every command and return them. + fn commands(&self, cx: &mut VisualTestContext) -> Vec { + let mut commands = Vec::new(); + loop { + cx.run_until_parked(); + let Ok(line) = self.outgoing.try_recv() else { + break; + }; + let Ok(message) = decode_client_line(&line) else { + continue; + }; + if let ClientPayload::Command(command) = message.payload { + commands.push(command); + self.incoming + .try_send( + encode_line(&HostMessage::Ack { + id: message.id, + result: Ok(CommandResponse::Unit), + }) + .unwrap(), + ) + .unwrap(); + } + } + commands + } + } + + fn catalog( + entries: Vec, + challenges: Vec, + ) -> ProviderPluginCatalog { + ProviderPluginCatalog { + profile_id: "claude".into(), + context_cwd: Some("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/work/project".into()), + marketplaces: Vec::new(), + marketplace_actions: Vec::new(), + entries, + errors: Vec::new(), + state: PluginCatalogState::Fresh, + loading: false, + pending: Vec::new(), + challenges, + } + } + + /// Open Settings → Plugins the way navigation does, against a host + /// whose replicated state holds `catalog`. + fn open_plugins( + catalog: ProviderPluginCatalog, + cx: &mut TestAppContext, + ) -> (Host, &mut VisualTestContext) { + cx.update(crate::theme::init); + let (to_host, outgoing) = async_channel::unbounded(); + let (incoming, from_host) = async_channel::unbounded(); + incoming + .try_send( + encode_line(&HostMessage::Event(EventEnvelope { + request_id: None, + topic: Topic::Settings, + event: ServerEvent::SettingsSnapshot(Settings::default()), + })) + .unwrap(), + ) + .unwrap(); + send_providers(&incoming, catalog); + let link = tcode_client::HostLink::new(to_host, from_host); + let pump_link = link.clone(); + let executor = cx.background_executor.clone(); + let pump = cx.background_executor.spawn(async move { + pump_link + .pump_with_timer(|| executor.timer(std::time::Duration::from_millis(25))) + .await; + }); + let store = cx.new(|cx| { + WorkspaceStore::new_attached(link, WorkspaceAttachment::Local, None, None, false, cx) + }); + let window_state = cx.new(|_| { + let mut state = WindowState::new(false); + state.pending_settings_section = Some("plugins".into()); + state + }); + let page_store = store.clone(); + let (_, cx) = cx.add_window_view(|window, cx| { + let page = cx.new(|cx| SettingsPage::new(page_store, window_state, window, cx)); + gpui_base::Root::new(page, window, cx) + }); + cx.simulate_resize(size(px(1000.), px(1400.))); + cx.run_until_parked(); + store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); + draw(cx); + ( + Host { + outgoing, + incoming, + store, + _pump: pump, + }, + cx, + ) + } + + fn send_providers(incoming: &async_channel::Sender, catalog: ProviderPluginCatalog) { + incoming + .try_send( + encode_line(&HostMessage::Event(EventEnvelope { + request_id: None, + topic: Topic::Providers, + event: ServerEvent::ProvidersReplaced(ProvidersStatus { + plugins: vec![catalog], + ..Default::default() + }), + })) + .unwrap(), + ) + .unwrap(); + } + + fn draw(cx: &mut VisualTestContext) { + for _ in 0..2 { + cx.run_until_parked(); + cx.update(|window, cx| { + window.refresh(); + _ = window.draw(cx); + }); + } + } + + /// The host decides which actions an entry offers in its context; the + /// page draws exactly those and no control the host did not compute. + #[gpui::test] + fn only_host_computed_plugin_actions_are_rendered(cx: &mut TestAppContext) { + let entry = ProviderPluginEntry { + id: "alpha@mkt".into(), + name: "alpha".into(), + version: Some("1.0.0".into()), + description: Some("Alpha plugin".into()), + source: PluginSource { + marketplace: Some("mkt".into()), + kind: PluginSourceKind::LocalPath, + }, + installations: vec![PluginInstallation { + scope: PluginScope::Project, + location: Some("/work/project/.claude/plugins/alpha".into()), + version: Some("1.0.0".into()), + scope_enabled: Some(true), + }], + enabled: Tri::Yes, + declared: None, + errors: Vec::new(), + actions: vec![ + PluginAction::Install { + scope: PluginScope::User, + }, + PluginAction::Disable { + scope: PluginScope::Project, + }, + ], + diagnostics: Vec::new(), + }; + let (host, cx) = open_plugins(catalog(vec![entry], Vec::new()), cx); + assert!(cx.debug_bounds("plugin-row-claude-alpha@mkt").is_some()); + + let offered = [ + "plugin-install-claude-alpha@mkt-user", + "plugin-disable-claude-alpha@mkt-project", + ]; + for verb in ["install", "uninstall", "enable", "disable", "update"] { + for scope in SCOPES { + // Debug selectors are looked up by `'static` name. + let id: &'static str = + format!("plugin-{verb}-claude-alpha@mkt-{}", scope_key(scope)).leak(); + assert_eq!(cx.debug_bounds(id).is_some(), offered.contains(&id), "{id}"); + } + } + drop(host); + } + + /// A marketplace removal waits on the person: the dialog lists what it + /// uninstalls, and Remove answers exactly that challenge, once. + #[gpui::test] + fn destructive_challenge_lists_uninstalls_and_accept_answers_it(cx: &mut TestAppContext) { + let challenge = PluginChallenge { + op_id: RuntimeOperationId(7), + profile_id: "claude".into(), + entry_id: "mkt".into(), + kind: PluginChallengeKind::ConfirmDestructive { + uninstalls: vec!["alpha@mkt".into(), "beta@mkt".into()], + }, + native_text: None, + }; + let pending = catalog(Vec::new(), vec![challenge]); + let (host, cx) = open_plugins(pending.clone(), cx); + assert!( + cx.debug_bounds("plugin-challenge-uninstall-alpha@mkt") + .is_some() + ); + assert!( + cx.debug_bounds("plugin-challenge-uninstall-beta@mkt") + .is_some() + ); + host.commands(cx); + + let accept = cx.debug_bounds("plugin-challenge-accept").unwrap(); + cx.simulate_click(accept.center(), gpui::Modifiers::default()); + draw(cx); + let answers: Vec = host + .commands(cx) + .into_iter() + .filter(|command| matches!(command, Command::ResolvePluginChallenge { .. })) + .collect(); + assert!( + matches!( + answers.as_slice(), + [Command::ResolvePluginChallenge { + op_id: RuntimeOperationId(7), + accept: true + }] + ), + "{answers:?}" + ); + // The replica still shows the challenge until the host has acted on + // the answer; the person is not asked again meanwhile. + host.replicate(pending, cx); + assert!(cx.debug_bounds("plugin-challenge-accept").is_none()); + } +} diff --git a/crates/ui/src/provider_card.rs b/crates/ui/src/provider_card.rs index aac2b562a..d4d60ec5e 100644 --- a/crates/ui/src/provider_card.rs +++ b/crates/ui/src/provider_card.rs @@ -31,6 +31,9 @@ use gpui_base::{StyledExt as _, h_flex, v_flex}; use agent::ProviderKind; use tcode_core::settings::builtin_provider_color; +/// The card's dialog asked to manage this profile's plugins. +pub struct ManagePlugins(pub String); + pub struct ProviderCard { store: Entity, /// The protocol this card's profile drives (glyph, shared model catalog / @@ -44,6 +47,8 @@ pub struct ProviderCard { _subscription: Subscription, } +impl gpui::EventEmitter for ProviderCard {} + impl ProviderCard { pub fn new( store: Entity, @@ -71,8 +76,21 @@ impl ProviderCard { .store .read(cx) .provider_profile_display_name(&profile_id); - let dialog = cx - .new(|cx| ProviderDialog::new(store.clone(), provider, profile_id.clone(), window, cx)); + let card = cx.entity().downgrade(); + let manage_id = profile_id.clone(); + let manage_plugins: std::rc::Rc = std::rc::Rc::new(move |cx| { + _ = card.update(cx, |_, cx| cx.emit(ManagePlugins(manage_id.clone()))); + }); + let dialog = cx.new(|cx| { + ProviderDialog::new( + store.clone(), + provider, + profile_id.clone(), + manage_plugins, + window, + cx, + ) + }); window.open_dialog(cx, move |dlg, window, cx| { let content = dialog.clone(); dlg.title(title.clone()) @@ -431,6 +449,8 @@ pub fn provider_glyph(provider: ProviderKind) -> Icon { ProviderKind::Codex => ("icons/openai.svg", None), ProviderKind::Pi => ("icons/pi.svg", None), ProviderKind::OpenCode => ("icons/opencode.svg", None), + ProviderKind::Cursor => ("icons/cursor.svg", None), + ProviderKind::Grok => ("icons/grok.svg", None), ProviderKind::Acp => return Icon::empty(), }; Icon::empty() diff --git a/crates/ui/src/provider_dialog.rs b/crates/ui/src/provider_dialog.rs index 1c7ee4f24..601befd4b 100644 --- a/crates/ui/src/provider_dialog.rs +++ b/crates/ui/src/provider_dialog.rs @@ -8,6 +8,7 @@ use crate::scroll::ScrollableElement as _; use std::collections::HashSet; +use std::rc::Rc; use crate::overlay::{DialogButtons, OverlayExt as _}; use crate::theme::ActiveTheme as _; @@ -72,6 +73,8 @@ pub struct ProviderDialog { original_secret_names: Vec, custom_models: Vec, hidden_models: Vec, + /// Leaves the dialog for this profile's section of Settings → Plugins. + manage_plugins: Rc, _subscriptions: Vec, } @@ -80,6 +83,7 @@ impl ProviderDialog { store: Entity, provider: ProviderKind, profile_id: String, + manage_plugins: Rc, window: &mut Window, cx: &mut Context, ) -> Self { @@ -105,7 +109,7 @@ impl ProviderDialog { window, cx, ); - let &[ + let [ binary_name, home_placeholder, _, @@ -190,6 +194,7 @@ impl ProviderDialog { original_secret_names, custom_models: settings.custom_models.clone(), hidden_models: settings.hidden_models.clone(), + manage_plugins, _subscriptions: subscriptions, }; dialog.rebuild_env_rows(&seeds, window, cx); @@ -538,7 +543,7 @@ impl ProviderDialog { ), ]; if provider.caps().home_path { - let &[_, _, home_label, home_help, _, _] = provider_copy(provider); + let [_, _, home_label, home_help, _, _] = provider_copy(provider); blocks.push( self.field_block( crate::tr!(home_label).into_owned().into(), @@ -549,6 +554,8 @@ impl ProviderDialog { cx, ), ); + } + if provider.caps().downgrade_approval_without_native_approvals { blocks.push( self.field_block( crate::tr!("providers.pi_native_approvals") @@ -903,13 +910,28 @@ pub fn render_footer( _window: &mut Window, cx: &mut App, ) -> AnyElement { - let (is_user, profile_id, store) = { + let (is_user, profile_id, store, manage_plugins) = { let d = dialog.read(cx); - (d.is_user_profile(), d.profile_id.clone(), d.store.clone()) + ( + d.is_user_profile(), + d.profile_id.clone(), + d.store.clone(), + d.manage_plugins.clone(), + ) }; let save_dialog = dialog.clone(); - let mut left = div().flex_1(); + let mut left = h_flex().flex_1().flex_wrap().gap_2().child( + Button::new("provider-manage-plugins") + .debug_selector(|| "provider-manage-plugins".into()) + .ghost() + .icon(IconName::Puzzle) + .label(crate::tr!("providers.plugins.manage").into_owned()) + .on_click(move |_, window, cx| { + window.close_dialog(cx); + manage_plugins(cx); + }), + ); if is_user { let delete_id = profile_id.clone(); let delete_store = store.clone(); @@ -974,19 +996,16 @@ pub fn render_footer( type ProviderCopy = [&'static str; 6]; #[rustfmt::skip] -const PROVIDER_COPY: [(ProviderKind, ProviderCopy); 5] = [ - (ProviderKind::Codex, ["codex", "~/.codex", "providers.codex_home", "providers.codex_home_help", "", "gpt-6.7-codex-ultra-preview"]), - (ProviderKind::ClaudeCode, ["claude", "~", "providers.claude_home", "providers.claude_home_help", "e.g. --chrome", "claude-sonnet-5"]), - (ProviderKind::Pi, ["pi", "~/.pi/agent", "providers.pi_home", "providers.pi_home_help", "e.g. --provider openai-codex", "openai-codex/gpt-5.5"]), - (ProviderKind::OpenCode, ["opencode", "", "providers.home", "providers.home_help", "e.g. --print-logs", "openai/gpt-5.1-codex"]), - (ProviderKind::Acp, ["", "", "providers.home", "providers.home_help", "", ""]), -]; - -fn provider_copy(provider: ProviderKind) -> &'static ProviderCopy { - PROVIDER_COPY - .iter() - .find_map(|(kind, copy)| (*kind == provider).then_some(copy)) - .expect("every provider has dialog copy") +fn provider_copy(provider: ProviderKind) -> ProviderCopy { + match provider { + ProviderKind::Codex => ["codex", "~/.codex", "providers.codex_home", "providers.codex_home_help", "", "gpt-6.7-codex-ultra-preview"], + ProviderKind::ClaudeCode => ["claude", "~", "providers.claude_home", "providers.claude_home_help", "e.g. --chrome", "claude-sonnet-5"], + ProviderKind::Pi => ["pi", "~/.pi/agent", "providers.pi_home", "providers.pi_home_help", "e.g. --provider openai-codex", "openai-codex/gpt-5.5"], + ProviderKind::OpenCode => ["opencode", "", "providers.home", "providers.home_help", "e.g. --print-logs", "openai/gpt-5.1-codex"], + ProviderKind::Cursor => ["cursor-agent", "~/.cursor", "providers.cursor_home", "providers.cursor_home_help", "e.g. --sandbox enabled", ""], + ProviderKind::Grok => ["grok", "~/.grok", "providers.grok_home", "providers.grok_home_help", "e.g. --no-leader", ""], + ProviderKind::Acp => ["", "", "providers.home", "providers.home_help", "", ""], + } } fn path_string(path: &Option) -> String { diff --git a/crates/ui/src/provider_status.rs b/crates/ui/src/provider_status.rs index 871d1b36b..2bce071f9 100644 --- a/crates/ui/src/provider_status.rs +++ b/crates/ui/src/provider_status.rs @@ -178,6 +178,8 @@ pub fn probe_diagnostic_message( ProviderKind::ClaudeCode => crate::tr!("providers.probe.claude_missing").into_owned(), ProviderKind::Pi => crate::tr!("providers.probe.pi_missing").into_owned(), ProviderKind::OpenCode => crate::tr!("providers.probe.opencode_missing").into_owned(), + ProviderKind::Cursor => crate::tr!("providers.probe.cursor_missing").into_owned(), + ProviderKind::Grok => crate::tr!("providers.probe.grok_missing").into_owned(), ProviderKind::Acp => String::new(), }, ProviderProbeDiagnostic::FailedCli => crate::tr!( @@ -194,6 +196,8 @@ pub fn probe_diagnostic_message( ProviderKind::OpenCode => { crate::tr!("providers.probe.opencode_signed_out").into_owned() } + ProviderKind::Cursor => crate::tr!("providers.probe.cursor_signed_out").into_owned(), + ProviderKind::Grok => crate::tr!("providers.probe.grok_signed_out").into_owned(), ProviderKind::Acp => String::new(), }, ProviderProbeDiagnostic::IndeterminateAuth => crate::tr!( diff --git a/crates/ui/src/runtime_event.rs b/crates/ui/src/runtime_event.rs index 386343b99..5d0b43cfc 100644 --- a/crates/ui/src/runtime_event.rs +++ b/crates/ui/src/runtime_event.rs @@ -1,8 +1,9 @@ use agent::RewindMode; use tcode_core::git::GitAction; use tcode_protocol::{ - GitActionRequest, MergeWorktreeFailure, NoticeSeverity, RuntimeEffect, RuntimeError, - RuntimeNotice, RuntimeNotification as RuntimeEvent, RuntimeOperationId, RuntimeToast, + GitActionRequest, MergeWorktreeFailure, NoticeSeverity, PluginOperationTarget, RuntimeEffect, + RuntimeError, RuntimeNotice, RuntimeNotification as RuntimeEvent, RuntimeOperationId, + RuntimeToast, }; use crate::toast::ToastKind; @@ -251,6 +252,60 @@ fn git_action_toast_titles(action: GitAction) -> (String, String) { } } +/// Started, succeeded and failed titles for a native plugin operation. +fn plugin_operation_toast_titles(target: &PluginOperationTarget) -> [String; 3] { + use agent::PluginActionKind; + let (name, keys) = match target { + PluginOperationTarget::Plugin { action, plugin, .. } => ( + plugin.as_str(), + match action { + PluginActionKind::Install => [ + "providers.plugins.toast.installing", + "providers.plugins.toast.installed", + "providers.plugins.toast.install_failed", + ], + PluginActionKind::Uninstall => [ + "providers.plugins.toast.uninstalling", + "providers.plugins.toast.uninstalled", + "providers.plugins.toast.uninstall_failed", + ], + PluginActionKind::Enable => [ + "providers.plugins.toast.enabling", + "providers.plugins.toast.enabled", + "providers.plugins.toast.enable_failed", + ], + PluginActionKind::Disable => [ + "providers.plugins.toast.disabling", + "providers.plugins.toast.disabled", + "providers.plugins.toast.disable_failed", + ], + PluginActionKind::Update => [ + "providers.plugins.toast.updating", + "providers.plugins.toast.updated", + "providers.plugins.toast.update_failed", + ], + }, + ), + PluginOperationTarget::AddMarketplace { source } => ( + source.as_str(), + [ + "providers.plugins.toast.adding_marketplace", + "providers.plugins.toast.added_marketplace", + "providers.plugins.toast.add_marketplace_failed", + ], + ), + PluginOperationTarget::RemoveMarketplace { name } => ( + name.as_str(), + [ + "providers.plugins.toast.removing_marketplace", + "providers.plugins.toast.removed_marketplace", + "providers.plugins.toast.remove_marketplace_failed", + ], + ), + }; + keys.map(|key| crate::tr!(key, name = name).into_owned()) +} + pub(super) fn present_runtime_toast(toast: &RuntimeToast) -> PresentedRuntimeToast { let (disposition, kind, title, detail, retry) = match toast { RuntimeToast::GitBusy => ( @@ -324,6 +379,45 @@ pub(super) fn present_runtime_toast(toast: &RuntimeToast) -> PresentedRuntimeToa Some(detail.clone()), None, ), + RuntimeToast::PluginOperationStarted { + operation, target, .. + } => { + let [title, _, _] = plugin_operation_toast_titles(target); + ( + RuntimeToastDisposition::Start(*operation), + ToastKind::Loading, + title, + None, + None, + ) + } + RuntimeToast::PluginOperationSucceeded { + operation, target, .. + } => { + let [_, title, _] = plugin_operation_toast_titles(target); + ( + RuntimeToastDisposition::Finish(*operation), + ToastKind::Success, + title, + None, + None, + ) + } + RuntimeToast::PluginOperationFailed { + operation, + target, + detail, + .. + } => { + let [_, _, title] = plugin_operation_toast_titles(target); + ( + RuntimeToastDisposition::Finish(*operation), + ToastKind::Error, + title, + Some(detail.clone()), + None, + ) + } _ => ( RuntimeToastDisposition::Push, ToastKind::Warning, @@ -368,6 +462,11 @@ mod tests { #[test] fn toast_lifecycle_and_raw_diagnostics_survive_localization() { let _locale_guard = crate::settings::TestLocaleGuard::acquire(); + let plugin_target = PluginOperationTarget::Plugin { + action: agent::PluginActionKind::Install, + scope: agent::PluginScope::User, + plugin: "gamma@tcode-probe".into(), + }; let retry = GitActionRequest { session_id: "session-1".into(), action: GitAction::CommitPush, @@ -409,6 +508,22 @@ mod tests { name: "Agent".into(), detail: "acp raw\0detail".into(), }, + RuntimeToast::PluginOperationStarted { + operation: RuntimeOperationId(3), + profile_id: "claude".into(), + target: plugin_target.clone(), + }, + RuntimeToast::PluginOperationSucceeded { + operation: RuntimeOperationId(3), + profile_id: "claude".into(), + target: plugin_target.clone(), + }, + RuntimeToast::PluginOperationFailed { + operation: RuntimeOperationId(3), + profile_id: "claude".into(), + target: plugin_target.clone(), + detail: "plugin raw\0detail".into(), + }, ]; for locale in [crate::LANGUAGE_ENGLISH, crate::LANGUAGE_SIMPLIFIED_CHINESE] { @@ -417,7 +532,8 @@ mod tests { let presented = present_runtime_toast(toast); match toast { RuntimeToast::GitStarted { operation, .. } - | RuntimeToast::AcpInstallStarted { operation, .. } => { + | RuntimeToast::AcpInstallStarted { operation, .. } + | RuntimeToast::PluginOperationStarted { operation, .. } => { assert_eq!( presented.disposition, RuntimeToastDisposition::Start(*operation) @@ -425,7 +541,8 @@ mod tests { assert_eq!(presented.kind, ToastKind::Loading); } RuntimeToast::GitSucceeded { operation, .. } - | RuntimeToast::AcpInstallSucceeded { operation, .. } => { + | RuntimeToast::AcpInstallSucceeded { operation, .. } + | RuntimeToast::PluginOperationSucceeded { operation, .. } => { assert_eq!( presented.disposition, RuntimeToastDisposition::Finish(*operation) @@ -433,7 +550,8 @@ mod tests { assert_eq!(presented.kind, ToastKind::Success); } RuntimeToast::GitFailed { operation, .. } - | RuntimeToast::AcpInstallFailed { operation, .. } => { + | RuntimeToast::AcpInstallFailed { operation, .. } + | RuntimeToast::PluginOperationFailed { operation, .. } => { assert_eq!( presented.disposition, RuntimeToastDisposition::Finish(*operation) @@ -535,6 +653,14 @@ mod tests { .as_deref(), Some("acp raw\0detail") ); + let plugin_failed = present_runtime_toast(&RuntimeToast::PluginOperationFailed { + operation: RuntimeOperationId(3), + profile_id: "claude".into(), + target: plugin_target.clone(), + detail: "plugin raw\0detail".into(), + }); + assert_eq!(plugin_failed.detail.as_deref(), Some("plugin raw\0detail")); + assert!(plugin_failed.title.contains("gamma@tcode-probe")); for (event, severity, raw_message) in [ ( diff --git a/crates/ui/src/settings_page.rs b/crates/ui/src/settings_page.rs index 14fab0940..f71b716ad 100644 --- a/crates/ui/src/settings_page.rs +++ b/crates/ui/src/settings_page.rs @@ -21,7 +21,8 @@ use gpui_base::{InteractiveElementExt as _, StyledExt as _, v_flex}; use crate::acp_panel::{AcpAgentCard, AcpPanel}; use crate::orchestrate_settings::OrchestrateSettingsPanel; -use crate::provider_card::ProviderCard; +use crate::plugins_settings::PluginsSettingsPanel; +use crate::provider_card::{ManagePlugins, ProviderCard}; use crate::provider_model_picker::ProviderModelPicker; use crate::settings::{ImageMode, LANGUAGE_ENGLISH, LANGUAGE_SIMPLIFIED_CHINESE, ThemeMode}; use crate::store::WorkspaceStore; @@ -68,6 +69,7 @@ enum Section { Browser, ComputerUse, Orchestrate, + Plugins, #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] Remote, Archived, @@ -79,22 +81,24 @@ enum Section { /// headless listener in a browser. Choosing a machine, and the invitation other /// devices pair with, live in `crate::remote`. #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] -const SECTIONS: [Section; 8] = [ +const SECTIONS: [Section; 9] = [ Section::General, Section::Remote, Section::Providers, Section::Usage, Section::Orchestrate, + Section::Plugins, Section::ComputerUse, Section::Browser, Section::Archived, ]; #[cfg(not(any(feature = "remote-hosting", target_family = "wasm")))] -const SECTIONS: [Section; 7] = [ +const SECTIONS: [Section; 8] = [ Section::General, Section::Providers, Section::Usage, Section::Orchestrate, + Section::Plugins, Section::ComputerUse, Section::Browser, Section::Archived, @@ -157,6 +161,7 @@ impl Section { | Self::Browser | Self::ComputerUse | Self::Orchestrate + | Self::Plugins | Self::Archived => SectionGroup::Machine, } } @@ -180,6 +185,7 @@ impl Section { | Self::Usage | Self::ComputerUse | Self::Orchestrate + | Self::Plugins | Self::Archived => true, } } @@ -192,6 +198,7 @@ impl Section { Self::Browser => "settings-nav-browser", Self::ComputerUse => "settings-nav-computer-use", Self::Orchestrate => "settings-nav-orchestrate", + Self::Plugins => "settings-nav-plugins", #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] Self::Remote => "settings-nav-remote", Self::Archived => "settings-nav-archived", @@ -206,6 +213,7 @@ impl Section { Self::Browser => IconName::Globe, Self::ComputerUse => IconName::LayoutDashboard, Self::Orchestrate => IconName::Map, + Self::Plugins => IconName::Puzzle, #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] Self::Remote => IconName::Network, Self::Archived => IconName::Inbox, @@ -220,6 +228,7 @@ impl Section { Self::Browser => crate::tr!("settings.browser"), Self::ComputerUse => crate::tr!("settings.computer_use"), Self::Orchestrate => crate::tr!("settings.orchestrate"), + Self::Plugins => crate::tr!("settings.plugins"), #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] Self::Remote => crate::tr!("settings.remote"), Self::Archived => crate::tr!("settings.archived"), @@ -295,10 +304,13 @@ pub struct SettingsPage { window_state: Entity, /// One card per native profile, keyed by profile id (built-in + user). provider_cards: Vec<(String, Entity)>, + provider_card_subscriptions: Vec, /// Long-lived state for the modal ACP marketplace and custom form. acp_panel: Entity, /// Editable main-model identities and child-model routing matrix. orchestrate_panel: Entity, + /// Each enabled profile's native plugin catalog. + plugins_panel: Entity, /// Hosting this machine. Absent where the client cannot listen at all. #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] #[cfg(not(target_family = "wasm"))] @@ -361,6 +373,7 @@ impl SettingsPage { "browser" => Section::Browser, "computer_use" => Section::ComputerUse, "orchestrate" => Section::Orchestrate, + "plugins" => Section::Plugins, #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] "remote" => Section::Remote, "archived" => Section::Archived, @@ -480,6 +493,16 @@ impl SettingsPage { let acp_panel = cx.new(|cx| AcpPanel::new(store.clone(), window, cx)); let orchestrate_panel = cx.new(|cx| OrchestrateSettingsPanel::new(store.clone(), window, cx)); + let content_scroll = ScrollHandle::new(); + let plugins_panel = cx.new(|cx| { + PluginsSettingsPanel::new( + store.clone(), + window_state.clone(), + content_scroll.clone(), + window, + cx, + ) + }); #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] #[cfg(not(target_family = "wasm"))] let hosting_panel = cx.new(|cx| crate::remote::HostingPanel::new(window, cx)); @@ -511,8 +534,10 @@ impl SettingsPage { store, window_state, provider_cards: Vec::new(), + provider_card_subscriptions: Vec::new(), acp_panel, orchestrate_panel, + plugins_panel, #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] hosting_panel, title_model_picker, @@ -542,7 +567,7 @@ impl SettingsPage { host_permissions: None, toggle_focus: HashMap::new(), section_scroll: ScrollHandle::new(), - content_scroll: ScrollHandle::new(), + content_scroll, _subscriptions: subscriptions, }; page._subscriptions @@ -723,6 +748,18 @@ impl SettingsPage { (profile.id, card) }) .collect(); + self.provider_card_subscriptions = self + .provider_cards + .iter() + .map(|(_, card)| { + cx.subscribe(card, |this, _, ManagePlugins(profile_id), cx| { + this.select_section(Section::Plugins, cx); + let profile_id = profile_id.clone(); + this.plugins_panel + .update(cx, |panel, cx| panel.focus_profile(profile_id, cx)); + }) + }) + .collect(); } /// Reconcile card entities by installed id, preserving editors for agents @@ -1247,6 +1284,9 @@ impl SettingsPage { if self.section != Section::ComputerUse { self.host_permissions = None; } + if self.section != Section::Plugins { + self.plugins_panel.update(cx, |panel, _| panel.hide()); + } let column = match self.section { Section::General => self.render_general(cx), Section::Providers => self.render_providers(window, cx), @@ -1254,6 +1294,7 @@ impl SettingsPage { Section::Browser => self.render_browser(cx), Section::ComputerUse => self.render_computer_use(cx), Section::Orchestrate => v_flex().child(self.orchestrate_panel.clone()), + Section::Plugins => v_flex().child(self.plugins_panel.clone()), #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] Section::Remote => v_flex().child(self.hosting_panel.clone()), Section::Archived => self.render_archived(cx), diff --git a/crates/ui/src/store/intents.rs b/crates/ui/src/store/intents.rs index 53f18cdc8..d21b27e1e 100644 --- a/crates/ui/src/store/intents.rs +++ b/crates/ui/src/store/intents.rs @@ -1,6 +1,8 @@ use std::path::PathBuf; -use agent::{ApprovalDecision, ApprovalMode, InteractionMode, ProviderKind, RewindMode}; +use agent::{ + ApprovalDecision, ApprovalMode, InteractionMode, PluginAction, ProviderKind, RewindMode, +}; use gpui::{App, Context, Task}; use tcode_core::{ acp::AcpAgentPatch, @@ -11,7 +13,7 @@ use tcode_core::{ }, ui::{TerminalSplitDirection, WorkspaceMode}, }; -use tcode_protocol::{Command, CommandResponse, ProtocolError, SettingsPatch}; +use tcode_protocol::{Command, CommandResponse, ProtocolError, RuntimeOperationId, SettingsPatch}; use super::{StoreChange, TopicKind, WorkspaceStore}; @@ -772,6 +774,79 @@ impl WorkspaceStore { } } +impl WorkspaceStore { + pub fn refresh_provider_plugins(&mut self, profile_id: String, cwd: Option) { + self.dispatch(Command::RefreshProviderPlugins { profile_id, cwd }); + } + /// Run one action the host offered for `entry_id` in the catalog listed + /// from `cwd`. + pub fn run_provider_plugin_action( + &mut self, + profile_id: String, + entry_id: String, + action: PluginAction, + cwd: Option, + ) { + let scope = action.scope(); + self.dispatch(match action { + PluginAction::Install { .. } => Command::InstallProviderPlugin { + profile_id, + entry_id, + scope, + cwd, + }, + PluginAction::Uninstall { .. } => Command::UninstallProviderPlugin { + profile_id, + entry_id, + scope, + cwd, + }, + PluginAction::Enable { .. } | PluginAction::Disable { .. } => { + Command::SetProviderPluginEnabled { + profile_id, + entry_id, + scope, + enabled: matches!(action, PluginAction::Enable { .. }), + cwd, + } + } + PluginAction::Update { .. } => Command::UpdateProviderPlugin { + profile_id, + entry_id, + scope, + cwd, + }, + }); + } + pub fn add_provider_marketplace( + &mut self, + profile_id: String, + source: String, + cwd: Option, + ) { + self.dispatch(Command::AddProviderMarketplace { + profile_id, + source, + cwd, + }); + } + pub fn remove_provider_marketplace( + &mut self, + profile_id: String, + name: String, + cwd: Option, + ) { + self.dispatch(Command::RemoveProviderMarketplace { + profile_id, + name, + cwd, + }); + } + pub fn resolve_plugin_challenge(&mut self, op_id: RuntimeOperationId, accept: bool) { + self.dispatch(Command::ResolvePluginChallenge { op_id, accept }); + } +} + impl WorkspaceStore { pub fn set_interaction_mode(&mut self, mode: InteractionMode) { self.dispatch(Command::SetInteractionMode { diff --git a/crates/ui/src/store/mod.rs b/crates/ui/src/store/mod.rs index 23bf88c45..5dc4730e8 100644 --- a/crates/ui/src/store/mod.rs +++ b/crates/ui/src/store/mod.rs @@ -1254,16 +1254,10 @@ impl WorkspaceStore { } pub fn all_provider_profiles(&self) -> Vec { - let mut profiles = Vec::new(); - for kind in [ - agent::ProviderKind::Codex, - agent::ProviderKind::ClaudeCode, - agent::ProviderKind::Pi, - agent::ProviderKind::OpenCode, - ] { - profiles.extend(self.settings_replica.profiles_for_kind(kind)); - } - profiles + agent::ProviderKind::NATIVE + .into_iter() + .flat_map(|kind| self.settings_replica.profiles_for_kind(kind)) + .collect() } pub fn enabled_profiles(&self) -> Vec { @@ -2133,6 +2127,23 @@ impl WorkspaceStore { items } + pub fn provider_plugin_catalog( + &self, + profile_id: &str, + ) -> Option<&tcode_protocol::ProviderPluginCatalog> { + self.providers_replica + .plugins + .iter() + .find(|catalog| catalog.profile_id == profile_id) + } + + /// The working directory of the thread or draft the window has open. + pub fn active_session_cwd(&self) -> Option { + self.session_status_replica + .as_ref() + .map(|status| status.cwd.clone()) + } + pub fn acp_registry_loading(&self) -> bool { self.providers_replica.acp_registry_loading } diff --git a/locales/en.yml b/locales/en.yml index 4401db28c..14a614fcb 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -231,6 +231,8 @@ settings: browser: "Browser" computer_use: "Computer Use" orchestrate: "Orchestrate" + plugins: "Plugins" + plugins_section: "PLUGINS" remote: "Remote" archived: "Archived Threads" archived_section: "ARCHIVED THREADS" @@ -468,12 +470,16 @@ providers: claude_missing: "Claude Agent CLI (`claude`) is not installed or not on PATH." pi_missing: "pi CLI (`pi`) is not installed or not on PATH." opencode_missing: "OpenCode CLI (`opencode`) is not installed or not on PATH." + cursor_missing: "Cursor CLI (`cursor-agent`) is not installed or not on PATH." + grok_missing: "Grok CLI (`grok`) is not installed or not on PATH." failed_run: "%{provider} CLI is installed but failed to run." indeterminate_auth: "Could not verify %{provider} authentication status." codex_signed_out: "Codex CLI is not authenticated. Run `codex login` and try again." claude_signed_out: "Claude CLI is not authenticated. Run `claude auth login` and try again." pi_signed_out: "pi has no authenticated model provider. Configure one in pi and try again." opencode_signed_out: "OpenCode has no authenticated model provider. Run `opencode auth login` and try again." + cursor_signed_out: "Cursor CLI is not signed in. Run `cursor-agent login` and try again." + grok_signed_out: "Grok is not signed in. Set XAI_API_KEY or run `grok login`, then try again." # Update popover (T3 §3) update_aria: "Update available — view details" update_title: "Update available" @@ -537,6 +543,10 @@ providers: claude_home_help: "Custom HOME used when running this Claude instance. Keeps .claude.json and .claude separate." pi_home: "PI_CODING_AGENT_DIR path" pi_home_help: "Custom pi agent directory. Keeps sessions, auth, models, prompts, and extensions separate." + cursor_home: "CURSOR_CONFIG_DIR path" + cursor_home_help: "Custom Cursor CLI configuration directory." + grok_home: "GROK_HOME path" + grok_home_help: "Custom Grok home directory. Keeps sessions, configuration, and credentials separate." home: "Provider home path" home_help: "Custom home or data directory used by this provider instance." launch_args: "Launch arguments" @@ -587,6 +597,119 @@ providers: args_hint: "e.g. --debug" env: "Environment variables" env_hint: "KEY=value KEY2=value2" + # Native provider plugins (claude plugin …), managed on the host. + plugins: + unmanaged: "Plugin management for this provider is not available in Tcode yet; use the provider's own interface." + project_context: "Project: %{path}" + no_project_context: "No project context: user scope only" + refresh: "List %{name} plugins again" + listing: "Listing plugins…" + not_listed: "Not listed yet." + stale_context: "Listed for another project or configuration; listing again shows the current one." + stale_changed: "Plugins changed since the last listing." + list_failed: "Could not list plugins:" + no_installed: "No plugins installed." + installed_section: "Installed" + add_plugin: "Add plugin…" + add_plugin_title: "Add a %{name} plugin" + search: "Search plugins" + no_available: "No plugins available from the added marketplaces." + no_matches: "No plugins match the search." + install_to: "Install to" + command_source: "Installing runs a command declared by the marketplace; you will be asked to accept it first." + from_marketplace: "From %{marketplace}" + effective_enabled: "Enabled" + effective_disabled: "Disabled" + effective_unknown: "Enablement unknown" + scope_enabled: "Enabled in this scope" + scope_disabled: "Disabled in this scope" + not_installed_in_scope: "Not installed" + details: "Details" + toggle_details: "Show or hide details of %{name}" + none_declared: "none" + action: + install: "Install" + uninstall: "Uninstall" + enable: "Enable" + disable: "Disable" + update: "Update" + scope: + user: "User" + project: "Project" + local: "Local" + managed: "Managed" + session: "Session" + source: + official: "Official" + third_party: "Third party" + local_path: "Local path" + git: "Git" + npm: "npm" + command: "Command" + unknown: "Unknown source" + declared: + skills: "Skills" + agents: "Agents" + hooks: "Hooks" + mcp_servers: "MCP servers" + untrusted_hooks: "Untrusted hooks" + lsp_servers: "LSP servers" + apps: "Apps" + diagnostic: + details: "Native details" + details_error: "Could not read details" + message: "Message" + update_outcome: "Update outcome" + old_version: "Previous version" + new_version: "New version" + marketplaces: "Marketplaces (%{count})" + no_marketplaces: "No marketplaces added." + add_marketplace: "Add marketplace…" + add_marketplace_title: "Add a marketplace" + marketplace_source: "Source" + marketplace_source_help: "A local path, a GitHub repository (owner/repo) or a URL." + marketplace_source_placeholder: "owner/repo, https://… or /path/to/marketplace" + add: "Add" + remove: "Remove" + apply_reload_or_restart: "Skills, commands and hooks apply after /reload-plugins in a running session or in the next session; MCP servers apply in the next session; updates need a restart." + apply_reload_or_next_session: "Changes, MCP servers and updates included, apply after /reload-plugins in a running session or in the next session." + apply_next_session: "Changes apply to sessions started afterwards; whether a running session picks them up is not verified." + apply_unverified: "Whether a running session picks up changes is not verified; start a new session." + manage: "Manage plugins" + challenge: + accept_command_title: "Accept the command for %{name}?" + command: "Command" + sha256: "SHA-256" + mode: "Mode" + accept: "Accept" + decline: "Decline" + destructive_title: "Remove marketplace %{name}?" + destructive_body: "Removing the marketplace also uninstalls these plugins in every scope:" + destructive_none: "No installed plugins come from this marketplace." + remove: "Remove" + cancel: "Cancel" + toast: + installing: "Installing %{name}…" + installed: "%{name} installed" + install_failed: "Could not install %{name}" + uninstalling: "Uninstalling %{name}…" + uninstalled: "%{name} uninstalled" + uninstall_failed: "Could not uninstall %{name}" + enabling: "Enabling %{name}…" + enabled: "%{name} enabled" + enable_failed: "Could not enable %{name}" + disabling: "Disabling %{name}…" + disabled: "%{name} disabled" + disable_failed: "Could not disable %{name}" + updating: "Updating %{name}…" + updated: "%{name} updated" + update_failed: "Could not update %{name}" + adding_marketplace: "Adding marketplace %{name}…" + added_marketplace: "Marketplace %{name} added" + add_marketplace_failed: "Could not add marketplace %{name}" + removing_marketplace: "Removing marketplace %{name}…" + removed_marketplace: "Marketplace %{name} removed" + remove_marketplace_failed: "Could not remove marketplace %{name}" palette: placeholder: "Search commands, projects, and threads…" new_thread: "New thread in %{project}" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 5c7cc8f2a..fa9d78c6f 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -231,6 +231,8 @@ settings: browser: "浏览器" computer_use: "电脑操作" orchestrate: "Orchestrate" + plugins: "插件" + plugins_section: "插件" remote: "远程连接" archived: "已归档对话" archived_section: "已归档对话" @@ -465,12 +467,16 @@ providers: claude_missing: "未安装 Claude Agent CLI(`claude`),或其不在 PATH 中。" pi_missing: "未安装 pi CLI(`pi`),或其不在 PATH 中。" opencode_missing: "未安装 OpenCode CLI(`opencode`),或其不在 PATH 中。" + cursor_missing: "未安装 Cursor CLI(`cursor-agent`),或其不在 PATH 中。" + grok_missing: "未安装 Grok CLI(`grok`),或其不在 PATH 中。" failed_run: "%{provider} CLI 已安装,但运行失败。" indeterminate_auth: "无法验证 %{provider} 的认证状态。" codex_signed_out: "Codex CLI 未认证。请运行 `codex login` 后重试。" claude_signed_out: "Claude CLI 未认证。请运行 `claude auth login` 后重试。" pi_signed_out: "pi 尚未配置已认证的模型提供方。请在 pi 中完成配置后重试。" opencode_signed_out: "OpenCode 尚未配置已认证的模型提供方。请运行 `opencode auth login` 后重试。" + cursor_signed_out: "Cursor CLI 尚未登录。请运行 `cursor-agent login` 后重试。" + grok_signed_out: "Grok 尚未登录。请设置 XAI_API_KEY 或运行 `grok login` 后重试。" update_aria: "有可用更新 — 查看详情" update_title: "有可用更新" update_message: "使用以下命令更新当前安装。" @@ -533,6 +539,10 @@ providers: claude_home_help: "运行该 Claude 实例时使用的自定义 HOME,可隔离 .claude.json 与 .claude。" pi_home: "PI_CODING_AGENT_DIR 路径" pi_home_help: "自定义 pi 智能体目录,可隔离会话、认证、模型、提示词与扩展。" + cursor_home: "CURSOR_CONFIG_DIR 路径" + cursor_home_help: "自定义的 Cursor CLI 配置目录。" + grok_home: "GROK_HOME 路径" + grok_home_help: "自定义的 Grok 主目录,可隔离会话、配置与凭据。" home: "提供方主目录路径" home_help: "该提供方实例使用的自定义主目录或数据目录。" launch_args: "启动参数" @@ -581,6 +591,118 @@ providers: args_hint: "例如 --debug" env: "环境变量" env_hint: "KEY=value KEY2=value2" + plugins: + unmanaged: "Tcode 暂不支持管理此提供商的插件,请使用提供商自己的界面。" + project_context: "项目:%{path}" + no_project_context: "无项目上下文:仅用户作用域" + refresh: "重新列出 %{name} 插件" + listing: "正在列出插件…" + not_listed: "尚未列出。" + stale_context: "列表来自其他项目或配置;重新列出可查看当前状态。" + stale_changed: "上次列出后插件已有变更。" + list_failed: "无法列出插件:" + no_installed: "未安装插件。" + installed_section: "已安装" + add_plugin: "添加插件…" + add_plugin_title: "添加 %{name} 插件" + search: "搜索插件" + no_available: "已添加的插件市场中没有可安装的插件。" + no_matches: "没有匹配的插件。" + install_to: "安装到" + command_source: "安装会运行插件市场声明的命令;运行前会请你确认。" + from_marketplace: "来自 %{marketplace}" + effective_enabled: "已启用" + effective_disabled: "已停用" + effective_unknown: "启用状态未知" + scope_enabled: "在此作用域启用" + scope_disabled: "在此作用域停用" + not_installed_in_scope: "未安装" + details: "详情" + toggle_details: "显示或隐藏 %{name} 的详情" + none_declared: "无" + action: + install: "安装" + uninstall: "卸载" + enable: "启用" + disable: "停用" + update: "更新" + scope: + user: "用户" + project: "项目" + local: "本地" + managed: "托管" + session: "会话" + source: + official: "官方" + third_party: "第三方" + local_path: "本地路径" + git: "Git" + npm: "npm" + command: "命令" + unknown: "来源未知" + declared: + skills: "技能" + agents: "代理" + hooks: "钩子" + mcp_servers: "MCP 服务器" + untrusted_hooks: "未受信任的钩子" + lsp_servers: "LSP 服务器" + apps: "应用" + diagnostic: + details: "原生详情" + details_error: "无法读取详情" + message: "消息" + update_outcome: "更新结果" + old_version: "原版本" + new_version: "新版本" + marketplaces: "插件市场(%{count})" + no_marketplaces: "尚未添加插件市场。" + add_marketplace: "添加插件市场…" + add_marketplace_title: "添加插件市场" + marketplace_source: "来源" + marketplace_source_help: "本地路径、GitHub 仓库(owner/repo)或 URL。" + marketplace_source_placeholder: "owner/repo、https://… 或 /path/to/marketplace" + add: "添加" + remove: "移除" + apply_reload_or_restart: "技能、命令和钩子在运行中的会话执行 /reload-plugins 后或在下一个会话中生效;MCP 服务器在下一个会话中生效;更新需要重启。" + apply_reload_or_next_session: "所有变更(包括 MCP 服务器和更新)在运行中的会话执行 /reload-plugins 后或在下一个会话中生效。" + apply_next_session: "变更对之后开始的会话生效;尚未验证运行中的会话是否会应用。" + apply_unverified: "尚未验证运行中的会话是否会应用变更;请开始新会话。" + manage: "管理插件" + challenge: + accept_command_title: "接受 %{name} 的命令?" + command: "命令" + sha256: "SHA-256" + mode: "模式" + accept: "接受" + decline: "拒绝" + destructive_title: "移除插件市场 %{name}?" + destructive_body: "移除插件市场会在所有作用域中一并卸载以下插件:" + destructive_none: "没有已安装的插件来自此插件市场。" + remove: "移除" + cancel: "取消" + toast: + installing: "正在安装 %{name}…" + installed: "%{name} 安装完成" + install_failed: "无法安装 %{name}" + uninstalling: "正在卸载 %{name}…" + uninstalled: "%{name} 已卸载" + uninstall_failed: "无法卸载 %{name}" + enabling: "正在启用 %{name}…" + enabled: "%{name} 已启用" + enable_failed: "无法启用 %{name}" + disabling: "正在停用 %{name}…" + disabled: "%{name} 已停用" + disable_failed: "无法停用 %{name}" + updating: "正在更新 %{name}…" + updated: "%{name} 已更新" + update_failed: "无法更新 %{name}" + adding_marketplace: "正在添加插件市场 %{name}…" + added_marketplace: "插件市场 %{name} 已添加" + add_marketplace_failed: "无法添加插件市场 %{name}" + removing_marketplace: "正在移除插件市场 %{name}…" + removed_marketplace: "插件市场 %{name} 已移除" + remove_marketplace_failed: "无法移除插件市场 %{name}" palette: placeholder: "搜索命令、项目和对话…" new_thread: "在 %{project} 中新建对话"