diff --git a/app/Cargo.toml b/app/Cargo.toml index 61ac931b54..ef71ec49d0 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -997,6 +997,7 @@ hoa_onboarding_flow = [] git_operations_in_code_review = [] hoa_remote_control = [] codex_notifications = [] +codex_plugin = [] cloud_mode_setup_v2 = ["cloud_mode"] cloud_mode_input_v2 = ["cloud_mode"] configurable_context_window = [] diff --git a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs index e07da2a85a..fc2402cc2e 100644 --- a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs +++ b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs @@ -76,7 +76,6 @@ use crate::settings::{ AISettings, AISettingsChangedEvent, PrivacySettings, PrivacySettingsChangedEvent, }; use crate::settings_view::SettingsSection; -use crate::terminal::cli_agent_sessions::listener::agent_supports_rich_status; #[cfg(not(target_family = "wasm"))] use crate::terminal::cli_agent_sessions::plugin_manager::{ compare_versions, plugin_manager_for, plugin_manager_for_with_shell, CliAgentPluginManager, @@ -507,12 +506,12 @@ impl AgentInputFooter { me.plugin_chip_ready = false; } - // When a listener connects for an agent with rich status, - // the plugin is verified installed — hide the chip. - // (Codex always has a listener but no actual plugin to install.) + // When a structured plugin connects, the plugin is verified + // installed — hide the chip. Codex's OSC 9 fallback is not a + // structured plugin, so its chip stays until the plugin connects. if CLIAgentSessionsModel::as_ref(ctx) .session(me.terminal_view_id) - .is_some_and(|s| s.listener.is_some() && agent_supports_rich_status(&s.agent)) + .is_some_and(|s| s.supports_rich_status()) { me.plugin_chip_ready = false; } @@ -533,10 +532,7 @@ impl AgentInputFooter { |me, _, ctx: &mut ViewContext| { let suppress = CLIAgentSessionsModel::as_ref(ctx) .session(me.terminal_view_id) - .is_some_and(|s| { - s.listener.is_some() - && agent_supports_rich_status(&s.agent) - }); + .is_some_and(|s| s.supports_rich_status()); if !suppress { me.plugin_chip_ready = true; ctx.notify(); @@ -1084,10 +1080,9 @@ impl AgentInputFooter { let manager = plugin_manager_for(session.agent)?; let min_version = manager.minimum_plugin_version(); let chip_key = plugin_chip_key(session.agent.command_prefix(), &session.remote_host); - - // If the plugin is connected (listener present) and this agent supports + // If a structured plugin is connected and this agent supports // version-based updates, check the reported version. - if session.listener.is_some() && manager.supports_update() { + if session.supports_rich_status() && manager.supports_update() { let needs_update = match &session.plugin_version { // No version reported = pre-versioning plugin, definitely outdated. None => true, diff --git a/app/src/features.rs b/app/src/features.rs index 4cd1956fe8..28764b72e0 100644 --- a/app/src/features.rs +++ b/app/src/features.rs @@ -479,6 +479,8 @@ fn enabled_features() -> HashSet { FeatureFlag::HOARemoteControl, #[cfg(feature = "codex_notifications")] FeatureFlag::CodexNotifications, + #[cfg(feature = "codex_plugin")] + FeatureFlag::CodexPlugin, #[cfg(feature = "trim_trailing_blank_lines")] FeatureFlag::TrimTrailingBlankLines, #[cfg(feature = "cloud_mode_setup_v2")] diff --git a/app/src/terminal/cli_agent_sessions/event/mod.rs b/app/src/terminal/cli_agent_sessions/event/mod.rs index 1c7a7bdd33..768f30101e 100644 --- a/app/src/terminal/cli_agent_sessions/event/mod.rs +++ b/app/src/terminal/cli_agent_sessions/event/mod.rs @@ -25,6 +25,15 @@ pub enum CLIAgentEventType { Unknown(String), } +/// How a CLI agent event reached Warp. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CLIAgentEventSource { + /// Structured OSC 777 notification from a rich plugin. + RichPlugin, + /// Native Codex OSC 9 fallback notification. + CodexOsc9Fallback, +} + /// Event-specific fields that vary by event type. #[allow(dead_code)] #[derive(Debug, Clone, Default)] @@ -49,6 +58,7 @@ pub struct CLIAgentEvent { pub cwd: Option, pub project: Option, pub payload: CLIAgentEventPayload, + pub source: CLIAgentEventSource, } /// Version-specific parsers, indexed by (version - 1). diff --git a/app/src/terminal/cli_agent_sessions/event/v1.rs b/app/src/terminal/cli_agent_sessions/event/v1.rs index a1441a767c..ae7077b491 100644 --- a/app/src/terminal/cli_agent_sessions/event/v1.rs +++ b/app/src/terminal/cli_agent_sessions/event/v1.rs @@ -1,6 +1,6 @@ use serde::Deserialize; -use super::{CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType}; +use super::{CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType}; use crate::terminal::CLIAgent; /// Resolves a CLI agent from the `"agent"` string in a CLI agent event. @@ -54,6 +54,7 @@ pub(super) fn parse(body: &str) -> Option { tool_input_preview, plugin_version: raw.plugin_version, }, + source: CLIAgentEventSource::RichPlugin, }) } diff --git a/app/src/terminal/cli_agent_sessions/listener/mod.rs b/app/src/terminal/cli_agent_sessions/listener/mod.rs index c5c0642845..267cad8611 100644 --- a/app/src/terminal/cli_agent_sessions/listener/mod.rs +++ b/app/src/terminal/cli_agent_sessions/listener/mod.rs @@ -1,8 +1,9 @@ use warpui::{EntityId, ModelContext, ModelHandle, SingletonEntity}; use super::{CLIAgentEvent, CLIAgentSessionsModel}; +use crate::features::FeatureFlag; use crate::terminal::cli_agent_sessions::event::{ - parse_event, CLIAgentEventPayload, CLIAgentEventType, + parse_event, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType, }; use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher}; use crate::terminal::CLIAgent; @@ -15,27 +16,23 @@ trait CLIAgentSessionHandler { /// The default implementation delegates to the structured JSON parser /// (`parse_event`); agents with non-JSON notification formats (e.g. Codex /// OSC 9 plain text) should override this. - fn try_parse(&self, title: Option<&str>, body: &str) -> Option { + /// + /// `plugin_already_active` is true when the session has already received a + /// structured OSC 777 notification; Codex uses it to drop OSC 9 fallback + /// once the rich plugin is active. Other handlers ignore it. + fn try_parse( + &mut self, + title: Option<&str>, + body: &str, + plugin_already_active: bool, + ) -> Option { + let _ = plugin_already_active; parse_event(title, body) } /// Decide whether a parsed event should be forwarded to the sessions model. /// Returns the event (possibly transformed) if it should be processed. fn handle_event(&mut self, event: CLIAgentEvent) -> Option; - - /// Whether this handler provides meaningful, fine-grained status - /// (e.g. in-progress / blocked / success) that should be shown in the UI. - /// Handlers backed by the structured plugin protocol report rich status; - /// handlers that only forward opaque OS notifications (e.g. Codex) do not. - fn supports_rich_status(&self) -> bool { - true - } -} - -/// Whether the listener for the given agent provides rich status. -/// Returns `false` for agents without a handler or whose handler opts out. -pub fn agent_supports_rich_status(agent: &CLIAgent) -> bool { - create_handler(agent).is_some_and(|h| h.supports_rich_status()) } /// Returns `true` if the given CLI agent has a supported session handler. @@ -91,14 +88,11 @@ impl CLIAgentSessionHandler for DefaultSessionListener { } } -/// Codex-specific handler that parses plain-text OSC 9 desktop notifications -/// into CLI agent events. +/// Codex-specific handler that supports both native OSC 9 fallback and structured plugin events. /// /// Codex sends notifications via OSC 9 (`\x1b]9;message\x07`) with -/// human-readable text. Since there's no way to distinguish notification types -/// from the raw text, all OSC 9 notifications are treated as `Stop` (success). -/// The notification body becomes the event's `query` so it surfaces as the -/// notification title in the UI. +/// human-readable text. Since there's no way to distinguish notification types from the raw text, +/// OSC 9 fallback notifications are treated as `Stop` (success). struct CodexSessionHandler; impl CodexSessionHandler { @@ -121,22 +115,34 @@ impl CodexSessionHandler { query: Some(body.to_owned()), ..Default::default() }, + source: CLIAgentEventSource::CodexOsc9Fallback, }) } } impl CLIAgentSessionHandler for CodexSessionHandler { - /// Codex sends plain-text OSC 9 notifications (title = `None`) instead of - /// the structured OSC 777 JSON used by Claude Code / OpenCode. - fn try_parse(&self, title: Option<&str>, body: &str) -> Option { - // If the notification carries the structured sentinel, try the normal - // JSON parser first (future-proofing in case Codex adds plugin - // support later). - if let Some(parsed) = parse_event(title, body) { - return Some(parsed); + /// Before Codex enabled support for hooks, we relied on OSC 9 to trigger notifications in Warp. + /// Here, we try to parse an OSC 777 event if we can, and remember when we've seen one. + /// This lets us ignore OSC 9 notifications if we are working with a client that is using + /// the new plugin, but keeps them intact for legacy clients. + fn try_parse( + &mut self, + title: Option<&str>, + body: &str, + plugin_already_active: bool, + ) -> Option { + if let Some(event) = parse_event(title, body) { + if event.agent == CLIAgent::Codex { + if !FeatureFlag::CodexPlugin.is_enabled() { + return None; + } + return Some(event); + } + return None; } - // OSC 9 notifications have no title. - if title.is_some() { + // OSC 9 notifications have no title. Skip OSC 9 once the rich plugin is + // active, otherwise we'd process both OSC 777 and OSC 9 notifications. + if title.is_some() || plugin_already_active { return None; } Self::parse_osc9_text(body) @@ -145,10 +151,6 @@ impl CLIAgentSessionHandler for CodexSessionHandler { fn handle_event(&mut self, event: CLIAgentEvent) -> Option { Some(event) } - - fn supports_rich_status(&self) -> bool { - false - } } /// Per-agent listener that subscribes to PTY events and forwards them to the @@ -178,12 +180,19 @@ impl CLIAgentSessionListener { // `handle_event` then filters/transforms the result. ctx.subscribe_to_model(model_event_dispatcher, move |me, event, ctx| { if let ModelEvent::PluggableNotification { title, body } = event { - let Some(parsed) = me.inner.try_parse(title.as_deref(), body) else { + let view_id = me.terminal_view_id; + let plugin_already_active = CLIAgentSessionsModel::as_ref(ctx) + .session(view_id) + .is_some_and(|session| session.received_rich_notification); + let Some(parsed) = + me.inner + .try_parse(title.as_deref(), body, plugin_already_active) + else { return; }; if let Some(event) = me.inner.handle_event(parsed) { CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, ctx| { - sessions_model.update_from_event(me.terminal_view_id, &event, ctx); + sessions_model.update_from_event(view_id, &event, ctx); }); } } diff --git a/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs b/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs index cbeb2fec4d..23fecf78d2 100644 --- a/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs +++ b/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs @@ -1,5 +1,7 @@ use super::*; -use crate::terminal::cli_agent_sessions::event::CLIAgentEventType; +use crate::terminal::cli_agent_sessions::event::{ + CLIAgentEventSource, CLIAgentEventType, CLI_AGENT_NOTIFICATION_SENTINEL, +}; #[test] fn codex_parses_any_text_as_stop() { @@ -40,33 +42,75 @@ fn codex_ignores_empty_body() { #[test] fn codex_try_parse_ignores_titled_notifications() { - let handler = CodexSessionHandler; + let mut handler = CodexSessionHandler; assert!(handler - .try_parse(Some("some-title"), "Agent turn complete") + .try_parse(Some("some-title"), "Agent turn complete", false) .is_none()); } #[test] fn codex_try_parse_handles_osc9() { - let handler = CodexSessionHandler; - let event = handler.try_parse(None, "Agent turn complete").unwrap(); + let mut handler = CodexSessionHandler; + let event = handler + .try_parse(None, "Agent turn complete", false) + .unwrap(); assert_eq!(event.event, CLIAgentEventType::Stop); } #[test] -fn auggie_is_supported() { - assert!(is_agent_supported(&CLIAgent::Auggie)); +fn codex_try_parse_ignores_osc9_when_plugin_already_active() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); + let mut handler = CodexSessionHandler; + let body = r#"{"v":1,"agent":"codex","event":"permission_request","summary":"Approve?","tool_name":"Bash"}"#; + + let event = handler + .try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body, false) + .unwrap(); + + assert_eq!(event.event, CLIAgentEventType::PermissionRequest); + // Once the session is rich, OSC 9 fallback is dropped. + assert!(handler + .try_parse(None, "Agent turn complete", true) + .is_none()); } #[test] -fn auggie_uses_default_handler_with_rich_status() { - assert!(agent_supports_rich_status(&CLIAgent::Auggie)); +fn codex_try_parse_ignores_structured_event_without_codex_plugin() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(false); + let mut handler = CodexSessionHandler; + let body = r#"{"v":1,"agent":"codex","event":"permission_request","summary":"Approve?","tool_name":"Bash"}"#; + + assert!(handler + .try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body, false) + .is_none()); + assert!(handler + .try_parse(None, "Agent turn complete", false) + .is_some()); +} + +#[test] +fn codex_try_parse_ignores_other_structured_agents() { + let mut handler = CodexSessionHandler; + let body = r#"{"v":1,"agent":"claude","event":"stop"}"#; + + assert!(handler + .try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body, false) + .is_none()); + assert!(handler + .try_parse(None, "Agent turn complete", false) + .is_some()); +} + +#[test] +fn auggie_is_supported() { + assert!(is_agent_supported(&CLIAgent::Auggie)); } #[test] fn auggie_default_handler_skips_session_start() { let mut handler = DefaultSessionListener; let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Auggie, event: CLIAgentEventType::SessionStart, @@ -82,6 +126,7 @@ fn auggie_default_handler_skips_session_start() { fn auggie_default_handler_forwards_stop() { let mut handler = DefaultSessionListener; let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Auggie, event: CLIAgentEventType::Stop, @@ -98,15 +143,11 @@ fn pi_is_supported() { assert!(is_agent_supported(&CLIAgent::Pi)); } -#[test] -fn pi_uses_default_handler_with_rich_status() { - assert!(agent_supports_rich_status(&CLIAgent::Pi)); -} - #[test] fn pi_default_handler_skips_session_start() { let mut handler = DefaultSessionListener; let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Pi, event: CLIAgentEventType::SessionStart, @@ -122,6 +163,7 @@ fn pi_default_handler_skips_session_start() { fn pi_default_handler_forwards_stop() { let mut handler = DefaultSessionListener; let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Pi, event: CLIAgentEventType::Stop, diff --git a/app/src/terminal/cli_agent_sessions/mod.rs b/app/src/terminal/cli_agent_sessions/mod.rs index b1d89e2da6..13a4663651 100644 --- a/app/src/terminal/cli_agent_sessions/mod.rs +++ b/app/src/terminal/cli_agent_sessions/mod.rs @@ -5,7 +5,7 @@ pub(crate) mod plugin_manager; use std::collections::{HashMap, HashSet}; -use event::{CLIAgentEvent, CLIAgentEventType}; +use event::{CLIAgentEvent, CLIAgentEventSource, CLIAgentEventType}; use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; use self::listener::CLIAgentSessionListener; @@ -122,12 +122,12 @@ pub struct CLIAgentSession { pub input_state: CLIAgentInputState, /// Whether status-driven auto-toggle is enabled for this session. pub should_auto_toggle_input: bool, - /// Plugin-backed event listener, if the CLI agent plugin is installed. - /// `None` for sessions created by command detection alone. + /// Event listener for plugin-backed sessions or Codex OSC9 fallback. + /// `None` for non-Codex sessions created by command detection alone. /// Dropping this handle cleans up the listener's PTY event subscription. pub listener: Option>, - /// The plugin version reported by the `SessionStart` event. - /// `None` if the plugin predates version reporting or hasn't connected yet. + /// The plugin version reported by structured plugin events. + /// `None` if the plugin predates version reporting or Codex is using OSC9 fallback. pub plugin_version: Option, /// `None` when the session is local. /// `Some("user@hostname")` when running over SSH (warpified or legacy). @@ -140,6 +140,10 @@ pub struct CLIAgentSession { /// the first word of the command (the binary/alias the user typed). /// Used to customize plugin instructions and force manual install mode. pub custom_command_prefix: Option, + /// Set once the session has received any structured OSC 777 (rich) + /// notification. Codex's OSC 9 fallback never sets it, so this is the + /// single source of truth for whether the session is plugin-backed. + pub received_rich_notification: bool, } impl CLIAgentSession { @@ -147,6 +151,16 @@ impl CLIAgentSession { self.remote_host.is_some() } + /// Whether the session surfaces trustworthy fine-grained status + /// (in-progress / blocked / success). True only after receiving a rich OSC + /// 777 notification. Codex's OSC 9 fallback emits only opaque `Stop` + /// notifications and never sets `received_rich_notification`, so it does + /// not qualify. Synthetic listener registration also does not qualify until + /// an actual rich notification arrives. + pub fn supports_rich_status(&self) -> bool { + self.received_rich_notification + } + /// Clears state populated by `PermissionRequest`. Called whenever the /// session leaves the permission flow (the user replied, a new prompt /// is submitted, or the session ends successfully) so the permission @@ -377,6 +391,7 @@ impl CLIAgentSessionsModel { remote_host, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -392,6 +407,8 @@ impl CLIAgentSessionsModel { } /// Updates the session's status and context from a parsed CLI agent event. + /// Rich plugin events latch `received_rich_notification` so rich-status + /// surfaces stay consistent even if the first event was not SessionStart. pub fn update_from_event( &mut self, terminal_view_id: EntityId, @@ -402,6 +419,10 @@ impl CLIAgentSessionsModel { return; }; + if event.source == CLIAgentEventSource::RichPlugin { + session.received_rich_notification = true; + } + let event_type = &event.event; if let Some(new_status) = session.apply_event(event) { let agent = session.agent; diff --git a/app/src/terminal/cli_agent_sessions/mod_tests.rs b/app/src/terminal/cli_agent_sessions/mod_tests.rs index 2535893b27..20da25ad5c 100644 --- a/app/src/terminal/cli_agent_sessions/mod_tests.rs +++ b/app/src/terminal/cli_agent_sessions/mod_tests.rs @@ -1,4 +1,6 @@ -use super::event::{parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType}; +use super::event::{ + parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType, +}; use super::{ CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentSession, CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel, @@ -257,9 +259,11 @@ fn apply_event_preserves_input_session() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }; let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionRequest, @@ -290,6 +294,7 @@ fn is_remote_returns_true_when_remote_host_is_set() { draft_text: None, remote_host: Some("user@devbox".to_owned()), custom_command_prefix: None, + received_rich_notification: false, }; assert!(session.is_remote()); } @@ -307,6 +312,7 @@ fn is_remote_returns_false_when_remote_host_is_none() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }; assert!(!session.is_remote()); } @@ -375,9 +381,11 @@ fn session_start_sets_plugin_version() { draft_text: None, remote_host: None, custom_command_prefix: None, + received_rich_notification: false, }; let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::SessionStart, @@ -407,9 +415,11 @@ fn session_start_without_plugin_version_leaves_none() { draft_text: None, remote_host: None, custom_command_prefix: None, + received_rich_notification: false, }; let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::SessionStart, @@ -423,6 +433,52 @@ fn session_start_without_plugin_version_leaves_none() { assert_eq!(session.plugin_version, None); } +#[test] +fn codex_session_not_rich_until_rich_notification() { + // Codex's OSC 9 fallback never sets `received_rich_notification`, so the + // session must not claim rich status even when a fallback listener exists. + let mut session = CLIAgentSession { + agent: CLIAgent::Codex, + status: CLIAgentSessionStatus::InProgress, + session_context: CLIAgentSessionContext::default(), + input_state: CLIAgentInputState::Closed, + should_auto_toggle_input: false, + listener: None, + plugin_version: None, + remote_host: None, + draft_text: None, + custom_command_prefix: None, + received_rich_notification: false, + }; + assert!(!session.supports_rich_status()); + + // A structured OSC 777 notification latches the flag -> rich status. + session.received_rich_notification = true; + assert!(session.supports_rich_status()); +} + +#[test] +fn non_codex_session_rich_after_rich_notification() { + let mut session = CLIAgentSession { + agent: CLIAgent::Claude, + status: CLIAgentSessionStatus::InProgress, + session_context: CLIAgentSessionContext::default(), + input_state: CLIAgentInputState::Closed, + should_auto_toggle_input: false, + listener: None, + plugin_version: None, + remote_host: None, + draft_text: None, + custom_command_prefix: None, + received_rich_notification: false, + }; + // No listener and no rich notification yet. + assert!(!session.supports_rich_status()); + + session.received_rich_notification = true; + assert!(session.supports_rich_status()); +} + /// Constructs a session with permission-scoped state already populated, as if /// a `PermissionRequest` had just been received and the agent is now Blocked. /// Used by the GH-9525 regression tests below. @@ -445,6 +501,7 @@ fn blocked_claude_session_with_permission_state() -> CLIAgentSession { draft_text: None, remote_host: None, custom_command_prefix: None, + received_rich_notification: false, } } @@ -456,6 +513,7 @@ fn stop_clears_permission_scoped_state() { let mut session = blocked_claude_session_with_permission_state(); let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::Stop, @@ -493,6 +551,7 @@ fn permission_replied_clears_permission_scoped_state() { let mut session = blocked_claude_session_with_permission_state(); let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionReplied, @@ -520,6 +579,7 @@ fn prompt_submit_clears_permission_scoped_state() { session.session_context.response = Some("stale response".to_owned()); let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PromptSubmit, @@ -560,9 +620,11 @@ fn permission_request_still_populates_summary_and_tool_fields() { draft_text: None, remote_host: None, custom_command_prefix: None, + received_rich_notification: false, }; let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionRequest, diff --git a/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs b/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs index 57ecd6312d..fe19556183 100644 --- a/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs +++ b/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs @@ -1,54 +1,223 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; use std::sync::LazyLock; +use std::{env, fs, io}; use async_trait::async_trait; +use serde_json::Value; -use super::{CliAgentPluginManager, PluginInstructionStep, PluginInstructions}; +use super::{ + compare_versions, run_cli_command_logged, CliAgentPluginManager, PluginInstallError, + PluginInstructionStep, PluginInstructions, +}; +use crate::features::FeatureFlag; +use crate::terminal::model::session::LocalCommandExecutor; +use crate::terminal::shell::ShellType; -pub(super) struct CodexPluginManager; +const PLUGIN_KEY: &str = "warp@codex-warp"; +const MARKETPLACE_REPO: &str = "warpdotdev/codex-warp"; +const MARKETPLACE_NAME: &str = "codex-warp"; + +const PLATFORM_PLUGIN_KEY: &str = "orchestration@codex-warp"; + +const CODEX_CONFIG_DIR: &str = ".codex"; +const CODEX_HOME_ENV: &str = "CODEX_HOME"; + +// Keep in sync with the plugin version in warpdotdev/codex-warp. +const MINIMUM_PLUGIN_VERSION: &str = "0.4.0"; + +pub(super) struct CodexPluginManager { + executor: LocalCommandExecutor, + path_env_var: Option, +} + +impl CodexPluginManager { + pub(super) fn new( + shell_path: Option, + shell_type: Option, + path_env_var: Option, + ) -> Self { + let shell_type = shell_type.unwrap_or(ShellType::Bash); + Self { + executor: LocalCommandExecutor::new(shell_path, shell_type), + path_env_var, + } + } + + async fn run_logged(&self, args: &[&str], log: &mut String) -> Result<(), PluginInstallError> { + let env_vars = self + .path_env_var + .as_deref() + .map(|path| HashMap::from([("PATH".to_owned(), path.to_owned())])); + run_cli_command_logged("codex", args, &self.executor, env_vars, log).await + } +} #[async_trait] impl CliAgentPluginManager for CodexPluginManager { fn minimum_plugin_version(&self) -> &'static str { - "0.0.0" + if FeatureFlag::CodexPlugin.is_enabled() { + MINIMUM_PLUGIN_VERSION + } else { + "0.0.0" + } } fn can_auto_install(&self) -> bool { - false + FeatureFlag::CodexPlugin.is_enabled() } - fn supports_update(&self) -> bool { - false + fn is_installed(&self) -> bool { + if !FeatureFlag::CodexPlugin.is_enabled() { + return false; + } + let Ok(codex_dir) = codex_home_dir() else { + return false; + }; + check_installed(&codex_dir) + } + + fn needs_update(&self) -> bool { + if !FeatureFlag::CodexPlugin.is_enabled() { + return false; + } + let Ok(codex_dir) = codex_home_dir() else { + return false; + }; + match installed_version(&codex_dir) { + Some(v) => compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt(), + None => check_installed(&codex_dir), + } + } + + async fn install(&self) -> Result<(), PluginInstallError> { + if !FeatureFlag::CodexPlugin.is_enabled() { + return Ok(()); + } + let mut log = String::new(); + self.run_logged( + &["plugin", "marketplace", "add", MARKETPLACE_REPO], + &mut log, + ) + .await?; + self.run_logged(&["plugin", "add", PLUGIN_KEY], &mut log) + .await?; + Ok(()) + } + + async fn update(&self) -> Result<(), PluginInstallError> { + if !FeatureFlag::CodexPlugin.is_enabled() { + return Ok(()); + } + let mut log = String::new(); + self.run_logged( + &["plugin", "marketplace", "upgrade", MARKETPLACE_NAME], + &mut log, + ) + .await?; + self.run_logged(&["plugin", "add", PLUGIN_KEY], &mut log) + .await?; + + let still_outdated = codex_home_dir() + .ok() + .and_then(|dir| installed_version(&dir)) + .map(|v| compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt()) + .unwrap_or(true); + if still_outdated { + log.push_str("Post-update version check: plugin is still outdated\n"); + return Err(PluginInstallError { + message: "Plugin update did not take effect".to_owned(), + log, + }); + } + Ok(()) + } + + fn install_success_message(&self) -> &'static str { + "Warp plugin installed. Please restart Codex to activate." + } + + fn update_success_message(&self) -> &'static str { + "Warp plugin updated. Please restart Codex to activate." } fn install_instructions(&self) -> &'static PluginInstructions { - &INSTALL_INSTRUCTIONS + if FeatureFlag::CodexPlugin.is_enabled() { + &PLUGIN_INSTALL_INSTRUCTIONS + } else { + &NATIVE_INSTALL_INSTRUCTIONS + } } fn update_instructions(&self) -> &'static PluginInstructions { - &EMPTY_INSTRUCTIONS + if FeatureFlag::CodexPlugin.is_enabled() { + &PLUGIN_UPDATE_INSTRUCTIONS + } else { + &EMPTY_INSTRUCTIONS + } + } + + fn supports_update(&self) -> bool { + FeatureFlag::CodexPlugin.is_enabled() + } + + async fn install_platform_plugin(&self) -> Result<(), PluginInstallError> { + if !FeatureFlag::CodexPlugin.is_enabled() { + return Ok(()); + } + let mut log = String::new(); + self.run_logged( + &["plugin", "marketplace", "add", MARKETPLACE_REPO], + &mut log, + ) + .await?; + self.run_logged(&["plugin", "add", PLATFORM_PLUGIN_KEY], &mut log) + .await?; + Ok(()) } } -static INSTALL_INSTRUCTIONS: LazyLock = LazyLock::new(|| { +static PLUGIN_INSTALL_INSTRUCTIONS: LazyLock = + LazyLock::new(|| PluginInstructions { + title: "Install Warp Plugin for Codex", + subtitle: "Run the following commands, then restart Codex.", + steps: &[ + PluginInstructionStep { + description: "Add the Warp plugin marketplace repository", + command: "codex plugin marketplace add warpdotdev/codex-warp", + executable: true, + link: None, + }, + PluginInstructionStep { + description: "Install the Warp plugin", + command: "codex plugin add warp@codex-warp", + executable: true, + link: None, + }, + ], + post_install_notes: &["Restart Codex to activate the plugin."], + }); + +static NATIVE_INSTALL_INSTRUCTIONS: LazyLock = LazyLock::new(|| { PluginInstructions { - title: "Enable Warp Notifications for Codex", - subtitle: "Update Codex to the latest version, then enable in-focus notifications so Warp can display them while you work.", - steps: &[ - PluginInstructionStep { - description: "Update Codex to the latest version.", - command: "", - executable: false, - link: Some("https://developers.openai.com/codex/cli#upgrade"), - }, - PluginInstructionStep { - description: "Set the notification condition to \"always\" in your Codex config. Open or create ~/.codex/config.toml and add:", - command: "[tui]\nnotification_condition = \"always\"", - executable: false, - link: None, - }, - ], - post_install_notes: &["Restart Codex to apply the changes."], -} + title: "Enable Warp Notifications for Codex", + subtitle: "Update Codex to the latest version, then enable in-focus notifications so Warp can display them while you work.", + steps: &[ + PluginInstructionStep { + description: "Update Codex to the latest version.", + command: "", + executable: false, + link: Some("https://developers.openai.com/codex/cli#upgrade"), + }, + PluginInstructionStep { + description: "Set the notification condition to \"always\" in your Codex config. Open or create ~/.codex/config.toml and add:", + command: "[tui]\nnotification_condition = \"always\"", + executable: false, + link: None, + }, + ], + post_install_notes: &["Restart Codex to apply the changes."], + } }); static EMPTY_INSTRUCTIONS: LazyLock = LazyLock::new(|| PluginInstructions { @@ -58,6 +227,91 @@ static EMPTY_INSTRUCTIONS: LazyLock = LazyLock::new(|| Plugi post_install_notes: &[], }); +static PLUGIN_UPDATE_INSTRUCTIONS: LazyLock = + LazyLock::new(|| PluginInstructions { + title: "Update Warp Plugin for Codex", + subtitle: "Run the following commands, then restart Codex.", + steps: &[ + PluginInstructionStep { + description: "Upgrade the marketplace", + command: "codex plugin marketplace upgrade codex-warp", + executable: true, + link: None, + }, + PluginInstructionStep { + description: "Reinstall the Warp plugin", + command: "codex plugin add warp@codex-warp", + executable: true, + link: None, + }, + ], + post_install_notes: &["Restart Codex to activate the update."], + }); + +fn check_installed(codex_dir: &Path) -> bool { + let config_path = codex_dir.join("config.toml"); + let Ok(contents) = fs::read_to_string(config_path) else { + return false; + }; + let Ok(parsed) = contents.parse::() else { + return false; + }; + parsed + .get("plugins") + .and_then(|plugins| plugins.get(PLUGIN_KEY)) + .and_then(|plugin| plugin.get("enabled")) + .and_then(|enabled| enabled.as_bool()) + .unwrap_or(false) +} + +/// Reads the latest cached Warp plugin version, if present. +fn installed_version(codex_dir: &Path) -> Option { + let cache_dir = codex_dir + .join("plugins") + .join("cache") + .join(MARKETPLACE_NAME) + .join("warp"); + let entries = fs::read_dir(cache_dir).ok()?; + let mut latest: Option = None; + for entry in entries.flatten() { + let manifest_path = entry.path().join(".codex-plugin").join("plugin.json"); + let Ok(contents) = fs::read_to_string(manifest_path) else { + continue; + }; + let Ok(parsed) = serde_json::from_str::(&contents) else { + continue; + }; + let Some(version) = parsed.get("version").and_then(|v| v.as_str()) else { + continue; + }; + if latest + .as_deref() + .map(|current| compare_versions(version, current).is_gt()) + .unwrap_or(true) + { + latest = Some(version.to_owned()); + } + } + latest +} + +/// Checks `CODEX_HOME` first, falls back to `~/.codex`. +fn codex_home_dir() -> io::Result { + if let Ok(codex_home) = env::var(CODEX_HOME_ENV) { + if !codex_home.is_empty() { + return Ok(PathBuf::from(codex_home)); + } + } + dirs::home_dir() + .map(|home| home.join(CODEX_CONFIG_DIR)) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "could not determine home directory", + ) + }) +} + #[cfg(test)] #[path = "codex_tests.rs"] mod tests; diff --git a/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs b/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs index 13e740b5f4..e6822a16b1 100644 --- a/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs +++ b/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs @@ -1,19 +1,282 @@ +use std::fs; + use super::CodexPluginManager; +use crate::features::FeatureFlag; use crate::terminal::cli_agent_sessions::plugin_manager::CliAgentPluginManager; #[test] -fn can_auto_install_is_false() { - assert!(!CodexPluginManager.can_auto_install()); +fn can_auto_install_is_true() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); + assert!(CodexPluginManager::new(None, None, None).can_auto_install()); +} + +#[test] +fn can_auto_install_is_false_without_codex_plugin() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(false); + assert!(!CodexPluginManager::new(None, None, None).can_auto_install()); } #[test] -fn does_not_support_update() { - assert!(!CodexPluginManager.supports_update()); +fn install_instructions_are_native_without_codex_plugin() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(false); + let instructions = CodexPluginManager::new(None, None, None).install_instructions(); + assert_eq!(instructions.title, "Enable Warp Notifications for Codex"); + assert_eq!( + instructions.steps[1].command, + "[tui]\nnotification_condition = \"always\"" + ); +} + +#[test] +fn supports_update() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); + assert!(CodexPluginManager::new(None, None, None).supports_update()); +} + +#[test] +fn does_not_support_update_without_codex_plugin() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(false); + assert!(!CodexPluginManager::new(None, None, None).supports_update()); +} + +#[test] +fn minimum_version() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); + assert_eq!( + CodexPluginManager::new(None, None, None).minimum_plugin_version(), + "0.4.0" + ); +} + +#[test] +fn minimum_version_is_zero_without_codex_plugin() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(false); + assert_eq!( + CodexPluginManager::new(None, None, None).minimum_plugin_version(), + "0.0.0" + ); } #[test] fn install_instructions_has_steps() { - let instructions = CodexPluginManager.install_instructions(); + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); + let instructions = CodexPluginManager::new(None, None, None).install_instructions(); + assert_eq!( + instructions.steps[0].command, + "codex plugin marketplace add warpdotdev/codex-warp" + ); + assert_eq!( + instructions.steps[1].command, + "codex plugin add warp@codex-warp" + ); + assert!(!instructions.steps.is_empty()); + assert!(!instructions.title.is_empty()); +} + +#[test] +fn update_instructions_has_steps() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); + let instructions = CodexPluginManager::new(None, None, None).update_instructions(); + assert_eq!( + instructions.steps[0].command, + "codex plugin marketplace upgrade codex-warp" + ); + assert_eq!( + instructions.steps[1].command, + "codex plugin add warp@codex-warp" + ); assert!(!instructions.steps.is_empty()); assert!(!instructions.title.is_empty()); } + +#[test] +fn update_instructions_are_empty_without_codex_plugin() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(false); + let instructions = CodexPluginManager::new(None, None, None).update_instructions(); + assert!(instructions.steps.is_empty()); + assert!(instructions.title.is_empty()); +} + +#[test] +fn installed_when_config_enabled() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("config.toml"), + "[plugins.\"warp@codex-warp\"]\nenabled = true\n", + ) + .unwrap(); + + assert!(super::check_installed(dir.path())); +} + +#[test] +fn not_installed_when_config_disabled() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("config.toml"), + "[plugins.\"warp@codex-warp\"]\nenabled = false\n", + ) + .unwrap(); + + assert!(!super::check_installed(dir.path())); +} + +#[test] +fn not_installed_when_config_missing() { + let dir = tempfile::tempdir().unwrap(); + assert!(!super::check_installed(dir.path())); +} + +#[test] +fn not_installed_when_config_invalid() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("config.toml"), "not toml").unwrap(); + + assert!(!super::check_installed(dir.path())); +} + +#[test] +fn installed_version_returns_latest_manifest_version() { + let dir = tempfile::tempdir().unwrap(); + write_manifest(dir.path(), "0.9.0"); + write_manifest(dir.path(), "1.5.0"); + write_manifest(dir.path(), "1.2.0"); + + assert_eq!( + super::installed_version(dir.path()).as_deref(), + Some("1.5.0") + ); +} + +#[test] +fn installed_version_returns_none_when_cache_missing() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(super::installed_version(dir.path()), None); +} + +#[test] +fn installed_version_returns_none_when_manifest_has_no_version() { + let dir = tempfile::tempdir().unwrap(); + let manifest_dir = dir + .path() + .join("plugins/cache/codex-warp/warp/1.0.0/.codex-plugin"); + fs::create_dir_all(&manifest_dir).unwrap(); + fs::write(manifest_dir.join("plugin.json"), "{\"name\":\"warp\"}").unwrap(); + + assert_eq!(super::installed_version(dir.path()), None); +} + +#[test] +#[serial_test::serial] +fn is_not_installed_via_trait_without_codex_plugin() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(false); + let dir = tempfile::tempdir().unwrap(); + write_enabled_config(dir.path()); + + std::env::set_var("CODEX_HOME", dir.path()); + let result = CodexPluginManager::new(None, None, None).is_installed(); + std::env::remove_var("CODEX_HOME"); + + assert!(!result); +} + +#[test] +#[serial_test::serial] +fn is_installed_via_trait_with_codex_home_env() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); + let dir = tempfile::tempdir().unwrap(); + write_enabled_config(dir.path()); + + std::env::set_var("CODEX_HOME", dir.path()); + let result = CodexPluginManager::new(None, None, None).is_installed(); + std::env::remove_var("CODEX_HOME"); + + assert!(result); +} + +#[test] +#[serial_test::serial] +fn does_not_need_update_without_codex_plugin() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(false); + let dir = tempfile::tempdir().unwrap(); + write_enabled_config(dir.path()); + write_manifest(dir.path(), "0.2.0"); + + std::env::set_var("CODEX_HOME", dir.path()); + let result = CodexPluginManager::new(None, None, None).needs_update(); + std::env::remove_var("CODEX_HOME"); + + assert!(!result); +} + +#[test] +#[serial_test::serial] +fn needs_update_via_trait_with_codex_home_env() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); + let dir = tempfile::tempdir().unwrap(); + write_enabled_config(dir.path()); + write_manifest(dir.path(), "0.2.0"); + + std::env::set_var("CODEX_HOME", dir.path()); + let result = CodexPluginManager::new(None, None, None).needs_update(); + std::env::remove_var("CODEX_HOME"); + + assert!(result); +} + +#[test] +#[serial_test::serial] +fn does_not_need_update_via_trait_when_version_current() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); + let dir = tempfile::tempdir().unwrap(); + write_enabled_config(dir.path()); + write_manifest(dir.path(), "0.4.0"); + + std::env::set_var("CODEX_HOME", dir.path()); + let result = CodexPluginManager::new(None, None, None).needs_update(); + std::env::remove_var("CODEX_HOME"); + + assert!(!result); +} + +#[test] +#[serial_test::serial] +fn needs_update_via_trait_when_installed_without_manifest() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); + let dir = tempfile::tempdir().unwrap(); + write_enabled_config(dir.path()); + + std::env::set_var("CODEX_HOME", dir.path()); + let result = CodexPluginManager::new(None, None, None).needs_update(); + std::env::remove_var("CODEX_HOME"); + + assert!(result); +} + +fn write_enabled_config(dir: &std::path::Path) { + fs::write( + dir.join("config.toml"), + "[plugins.\"warp@codex-warp\"]\nenabled = true\n", + ) + .unwrap(); +} + +fn write_manifest(dir: &std::path::Path, version: &str) { + let manifest_dir = dir + .join("plugins") + .join("cache") + .join("codex-warp") + .join("warp") + .join(version) + .join(".codex-plugin"); + fs::create_dir_all(&manifest_dir).unwrap(); + fs::write( + manifest_dir.join("plugin.json"), + serde_json::json!({ + "name": "warp", + "version": version + }) + .to_string(), + ) + .unwrap(); +} diff --git a/app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs b/app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs index fe5974a723..6df73c8240 100644 --- a/app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs +++ b/app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs @@ -266,7 +266,11 @@ pub(crate) fn plugin_manager_for_with_shell( if FeatureFlag::CodexNotifications.is_enabled() && FeatureFlag::HOANotifications.is_enabled() => { - Some(Box::new(CodexPluginManager)) + Some(Box::new(CodexPluginManager::new( + shell_path, + shell_type, + path_env_var, + ))) } CLIAgent::Gemini if FeatureFlag::GeminiNotifications.is_enabled() diff --git a/app/src/terminal/shared_session/shared_handlers.rs b/app/src/terminal/shared_session/shared_handlers.rs index bce693096f..25f4f12dd0 100644 --- a/app/src/terminal/shared_session/shared_handlers.rs +++ b/app/src/terminal/shared_session/shared_handlers.rs @@ -388,6 +388,7 @@ pub(crate) fn apply_cli_agent_state_update( remote_host: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, // Viewer input is managed by the sync protocol, // not local status-change auto-toggle. should_auto_toggle_input: false, diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 5ab758738f..6c1a8932c4 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -399,12 +399,10 @@ use crate::terminal::block_list_viewport::{ }; use crate::terminal::bootstrap::init_subshell_command; use crate::terminal::cli_agent_sessions::event::{ - parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType, + parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType, CLI_AGENT_NOTIFICATION_SENTINEL, }; -use crate::terminal::cli_agent_sessions::listener::{ - agent_supports_rich_status, is_agent_supported, CLIAgentSessionListener, -}; +use crate::terminal::cli_agent_sessions::listener::{is_agent_supported, CLIAgentSessionListener}; #[cfg(not(target_family = "wasm"))] use crate::terminal::cli_agent_sessions::plugin_manager::{plugin_manager_for, PluginModalKind}; use crate::terminal::cli_agent_sessions::{ @@ -11806,6 +11804,7 @@ impl TerminalView { remote_host, draft_text: None, custom_command_prefix: custom_command_prefix.clone(), + received_rich_notification: false, }, ctx, ); @@ -12893,6 +12892,11 @@ impl TerminalView { if !is_agent_supported(¬ification.agent) { return; } + + if notification.agent == CLIAgent::Codex && !FeatureFlag::CodexPlugin.is_enabled() { + return; + } + if !self.register_cli_agent_listener_from_event(¬ification, ctx) { return; } @@ -12961,15 +12965,21 @@ impl TerminalView { agent: CLIAgent, ctx: &mut ViewContext, ) { - // No SessionStart event in this path (mid-session install/update). - // Assume the just-installed plugin meets the minimum version for this agent - // so the update chip doesn't flash before the user runs /reload-plugins. #[cfg(not(target_family = "wasm"))] - let plugin_version = - plugin_manager_for(agent).map(|m| m.minimum_plugin_version().to_owned()); + let plugin_version = if matches!(agent, CLIAgent::Codex) { + // We use the lack of a plugin version for codex to differentiate between + // OSC 9 notification fallback and real plugin. + None + } else { + // No SessionStart event in this path (mid-session install/update). + // Assume the just-installed plugin meets the minimum version for this agent + // so the update chip doesn't flash before the user runs /reload-plugins. + plugin_manager_for(agent).map(|m| m.minimum_plugin_version().to_owned()) + }; #[cfg(target_family = "wasm")] let plugin_version = None; let notification = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent, event: CLIAgentEventType::SessionStart, @@ -13122,11 +13132,7 @@ impl TerminalView { { let should_auto_toggle_input = CLIAgentSessionsModel::as_ref(ctx) .session(self.view_id) - .is_some_and(|s| { - s.listener.is_some() - && s.should_auto_toggle_input - && agent_supports_rich_status(&s.agent) - }); + .is_some_and(|s| s.supports_rich_status() && s.should_auto_toggle_input); if should_auto_toggle_input { match status { CLIAgentSessionStatus::Blocked { .. } => { diff --git a/app/src/terminal/view/use_agent_footer/mod.rs b/app/src/terminal/view/use_agent_footer/mod.rs index d302c851a0..4a8e032439 100644 --- a/app/src/terminal/view/use_agent_footer/mod.rs +++ b/app/src/terminal/view/use_agent_footer/mod.rs @@ -11,7 +11,6 @@ use crate::ai::agent::ImageContext; use crate::ai::blocklist::agent_view::agent_input_footer::{ AgentInputFooter, AgentInputFooterEvent, }; -use crate::terminal::cli_agent_sessions::listener::agent_supports_rich_status; use crate::terminal::cli_agent_sessions::{CLIAgentInputEntrypoint, CLIAgentSessionsModel}; use crate::terminal::shared_session::{ SharedSessionActionSource, SharedSessionScrollbackType, SharedSessionSource, @@ -613,11 +612,9 @@ impl TerminalView { /// instead). Otherwise, respects the auto-dismiss-after-submit setting. fn maybe_close_rich_input_after_submit(&mut self, ctx: &mut ViewContext) { let session = CLIAgentSessionsModel::as_ref(ctx).session(self.view_id); - let has_plugin = session.as_ref().is_some_and(|s| { - s.listener.is_some() - && s.should_auto_toggle_input - && agent_supports_rich_status(&s.agent) - }); + let has_plugin = session + .as_ref() + .is_some_and(|s| s.supports_rich_status() && s.should_auto_toggle_input); let ai_settings = AISettings::as_ref(ctx); let should_close = if has_plugin && *ai_settings.auto_toggle_rich_input { diff --git a/app/src/terminal/view/use_agent_footer/mod_tests.rs b/app/src/terminal/view/use_agent_footer/mod_tests.rs index 14b4f32169..19c50f2f60 100644 --- a/app/src/terminal/view/use_agent_footer/mod_tests.rs +++ b/app/src/terminal/view/use_agent_footer/mod_tests.rs @@ -364,6 +364,7 @@ fn cli_agent_footer_renders_for_viewer_of_shared_cloud_agent_session() { remote_host: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, should_auto_toggle_input: false, }, ctx, diff --git a/app/src/terminal/view_tests.rs b/app/src/terminal/view_tests.rs index ae479ff8be..e6bbd4b2f3 100644 --- a/app/src/terminal/view_tests.rs +++ b/app/src/terminal/view_tests.rs @@ -51,7 +51,7 @@ use crate::terminal::alt_screen::should_intercept_mouse; use crate::terminal::block_list_element::{SnackbarPoint, SnackbarTranslationMode}; use crate::terminal::block_list_viewport::{ClampingMode, ScrollLines}; use crate::terminal::cli_agent_sessions::event::{ - CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType, + CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType, }; use crate::terminal::cli_agent_sessions::listener::CLIAgentSessionListener; use crate::terminal::cli_agent_sessions::{ @@ -422,6 +422,7 @@ fn submit_cli_agent_rich_input_restores_unlocked_input_config() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -488,6 +489,7 @@ fn unregister_cli_agent_session_restores_unlocked_input_config() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5129,6 +5131,7 @@ fn submit_rich_input_and_collect_pty_writes( plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5167,6 +5170,7 @@ fn open_cli_agent_rich_input_for_agent_with_window_id( plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5489,6 +5493,7 @@ fn drag_drop_image_in_cli_agent_long_running_command_pastes_via_clipboard() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5564,6 +5569,7 @@ fn paste_raw_image_clipboard_in_cli_agent_sends_correct_bytes() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5641,6 +5647,7 @@ fn submit_without_auto_dismiss_keeps_rich_input_open() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5703,6 +5710,7 @@ fn submit_with_plugin_and_auto_toggle_keeps_rich_input_open() { plugin_version: Some("1.0.0".to_owned()), draft_text: None, custom_command_prefix: None, + received_rich_notification: true, }, ctx, ); @@ -5757,6 +5765,7 @@ fn submit_with_plugin_but_auto_toggle_off_respects_auto_dismiss() { plugin_version: Some("1.0.0".to_owned()), draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5811,6 +5820,7 @@ fn status_blocked_auto_closes_rich_input() { plugin_version: Some("1.0.0".to_owned()), draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5824,6 +5834,7 @@ fn status_blocked_auto_closes_rich_input() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionRequest, @@ -5886,6 +5897,7 @@ fn status_in_progress_auto_opens_rich_input_after_blocked() { plugin_version: Some("1.0.0".to_owned()), draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5897,6 +5909,7 @@ fn status_in_progress_auto_opens_rich_input_after_blocked() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionRequest, @@ -5924,6 +5937,7 @@ fn status_in_progress_auto_opens_rich_input_after_blocked() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionReplied, @@ -5982,6 +5996,7 @@ fn codex_status_change_does_not_auto_open_rich_input() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5995,6 +6010,7 @@ fn codex_status_change_does_not_auto_open_rich_input() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::CodexOsc9Fallback, v: 1, agent: CLIAgent::Codex, event: CLIAgentEventType::Stop, @@ -6062,6 +6078,7 @@ fn cli_session_status_updates_active_child_conversation() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -6082,6 +6099,7 @@ fn cli_session_status_updates_active_child_conversation() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionRequest, @@ -6115,6 +6133,7 @@ fn cli_session_status_updates_active_child_conversation() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionReplied, @@ -6140,6 +6159,7 @@ fn cli_session_status_updates_active_child_conversation() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::Stop, @@ -6203,6 +6223,7 @@ fn cli_session_status_updates_single_child_conversation_without_agent_view() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -6223,6 +6244,7 @@ fn cli_session_status_updates_single_child_conversation_without_agent_view() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::Stop, @@ -6280,6 +6302,7 @@ fn manual_dismiss_disables_auto_toggle_for_session() { plugin_version: Some("1.0.0".to_owned()), draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -6306,6 +6329,7 @@ fn manual_dismiss_disables_auto_toggle_for_session() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionRequest, @@ -6324,6 +6348,7 @@ fn manual_dismiss_disables_auto_toggle_for_session() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionReplied, diff --git a/app/src/ui_components/agent_icon.rs b/app/src/ui_components/agent_icon.rs index b90229695a..b817bf5de9 100644 --- a/app/src/ui_components/agent_icon.rs +++ b/app/src/ui_components/agent_icon.rs @@ -16,7 +16,6 @@ use crate::ai::agent_conversations_model::{ AgentConversationEntry, AgentConversationProvenance, AgentConversationsModel, AgentRunDisplayStatus, }; -use crate::terminal::cli_agent_sessions::listener::agent_supports_rich_status; use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; use crate::terminal::view::TerminalView; use crate::terminal::CLIAgent; @@ -72,7 +71,7 @@ pub(crate) fn terminal_view_agent_icon_variant( agent: session.agent, has_listener: session.listener.is_some(), status: session.status.to_conversation_status(), - supports_rich_status: agent_supports_rich_status(&session.agent), + supports_rich_status: session.supports_rich_status(), }), selected_third_party_cli_agent: terminal_view .ambient_agent_view_model() diff --git a/app/src/workspace/view/vertical_tabs.rs b/app/src/workspace/view/vertical_tabs.rs index eb9284c3f4..f8d9f6059b 100644 --- a/app/src/workspace/view/vertical_tabs.rs +++ b/app/src/workspace/view/vertical_tabs.rs @@ -55,7 +55,6 @@ use crate::pane_group::{ }; use crate::safe_triangle::SafeTriangle; use crate::tab::{tab_position_id, SelectedTabColor, TabData}; -use crate::terminal::cli_agent_sessions::listener::agent_supports_rich_status; use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; use crate::terminal::session_settings::SessionSettings; use crate::terminal::view::TerminalViewState; @@ -925,9 +924,8 @@ fn summary_conversation_status_for_terminal( ) -> Option { let cli_agent_session = CLIAgentSessionsModel::as_ref(app).session(terminal_view.id()); if let Some(session) = cli_agent_session - .filter(|s| s.listener.is_some()) + .filter(|s| s.supports_rich_status()) .filter(|s| !matches!(s.agent, CLIAgent::Unknown)) - .filter(|s| agent_supports_rich_status(&s.agent)) { return Some(session.status.to_conversation_status()); } @@ -6172,9 +6170,7 @@ fn render_terminal_detail_section( let (conversation_display_title, cli_agent_title) = preferred_agent_tab_titles(&agent_text, agent_tab_text_preference(app)); let kind_label = terminal_kind_badge_label(agent_text.is_oz_agent, agent_text.cli_agent); - let status = if let Some(session) = - cli_agent_session.filter(|s| s.listener.is_some() && agent_supports_rich_status(&s.agent)) - { + let status = if let Some(session) = cli_agent_session.filter(|s| s.supports_rich_status()) { Some(session.status.to_conversation_status()) } else if agent_text.is_oz_agent { terminal_view.selected_conversation_status_for_display(app) diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index 4baf95eb43..6cd7a2765e 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -790,6 +790,10 @@ pub enum FeatureFlag { /// Requires HOANotifications to also be enabled. CodexNotifications, + /// Enables the Codex Warp plugin marketplace integration. + /// When disabled, Codex uses native OSC9 notifications. + CodexPlugin, + /// Enables the install/update chip for the Gemini CLI Warp extension. /// Requires HOANotifications to also be enabled. GeminiNotifications, @@ -942,6 +946,7 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::EditableMarkdownMermaid, FeatureFlag::CodeReviewScrollPreservation, FeatureFlag::RememberFastForwardState, + FeatureFlag::CodexPlugin, FeatureFlag::GeminiNotifications, FeatureFlag::LocalDockerSandbox, #[cfg(not(windows))] diff --git a/specs/codex-warp-plugin/TECH.md b/specs/codex-warp-plugin/TECH.md new file mode 100644 index 0000000000..0eaf006aee --- /dev/null +++ b/specs/codex-warp-plugin/TECH.md @@ -0,0 +1,122 @@ +# Codex Warp Plugin Tech Spec + +## Context +Warp already supports CLI agent status via structured OSC 777 plugin notifications. Codex is special because it previously only had native OSC 9 desktop notifications with plain text. This branch adds a feature-flagged Codex Warp plugin path while preserving OSC 9 as fallback. +The key invariant changes: +- `listener.is_some()` no longer means “structured plugin connected” for Codex. It can mean OSC 9 fallback listener. +- Structured Codex plugin events must win over OSC 9 once seen. Otherwise Warp would process both OSC 777 and OSC 9 and notify twice. +- UI that needs trustworthy state should use `CLIAgentSession::supports_rich_status()`, not `listener.is_some()`. +Relevant code: +- [`app/src/terminal/cli_agent_sessions/listener/mod.rs:14`](https://github.com/warpdotdev/warp/blob/68acdf7601cc1824d4f4dd250485aca8329efc17/app/src/terminal/cli_agent_sessions/listener/mod.rs#L14) — `CLIAgentSessionHandler` now mutably parses notifications so Codex can remember structured-plugin activation. +- [`app/src/terminal/cli_agent_sessions/listener/mod.rs:87`](https://github.com/warpdotdev/warp/blob/68acdf7601cc1824d4f4dd250485aca8329efc17/app/src/terminal/cli_agent_sessions/listener/mod.rs#L87) — `CodexSessionHandler` parses both OSC 9 fallback and structured OSC 777 Codex events. +- `app/src/terminal/cli_agent_sessions/mod.rs:155` — `has_structured_plugin()` distinguishes structured OSC 777 from Codex OSC 9 fallback. +- `app/src/terminal/cli_agent_sessions/mod.rs:164` — `supports_rich_status()` centralizes the “safe to show fine-grained status” check. +- [`app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs:17`](https://github.com/warpdotdev/warp/blob/68acdf7601cc1824d4f4dd250485aca8329efc17/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs#L17) — Codex plugin constants: marketplace, user plugin key, platform plugin key, config dirs, min version. +- [`app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs:57`](https://github.com/warpdotdev/warp/blob/68acdf7601cc1824d4f4dd250485aca8329efc17/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs#L57) — plugin manager gates install/update/inspection on `FeatureFlag::CodexPlugin`. +- [`app/src/terminal/cli_agent_sessions/plugin_manager/claude.rs:17`](https://github.com/warpdotdev/warp/blob/68acdf7601cc1824d4f4dd250485aca8329efc17/app/src/terminal/cli_agent_sessions/plugin_manager/claude.rs#L17) — Claude manager structure copied for Codex, with CLI/config differences. +- [`app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs:222`](https://github.com/warpdotdev/warp/blob/68acdf7601cc1824d4f4dd250485aca8329efc17/app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs#L222) — Codex manager now receives shell path/type/PATH like Claude/Gemini. +- `app/src/terminal/view.rs:11680` — command-detected Codex still proactively registers a listener, but without seeding a plugin version. +- `app/src/terminal/view.rs:12753` — OSC 777 Codex events are ignored when `CodexPlugin` is disabled. +- `app/src/terminal/view.rs:12825` — listener registration without `SessionStart` never seeds a plugin version. Codex remains OSC 9 fallback until a real structured plugin event reports version. +- [`crates/warp_features/src/lib.rs:789`](https://github.com/warpdotdev/warp/blob/68acdf7601cc1824d4f4dd250485aca8329efc17/crates/warp_features/src/lib.rs#L789) and [`app/src/features.rs:480`](https://github.com/warpdotdev/warp/blob/68acdf7601cc1824d4f4dd250485aca8329efc17/app/src/features.rs#L480) — new `CodexPlugin` flag is wired into shared/app feature plumbing. + +## Proposed changes +### 1. Add `CodexPlugin` flag +Add `FeatureFlag::CodexPlugin` and enable it for dogfood builds. +When disabled: +- Codex keeps the existing native OSC 9 behavior. +- structured Codex events are ignored. +- install instructions remain the old “enable native Codex notifications” steps. +- auto-install/update are disabled. +When enabled: +- Warp can install/update `warp@codex-warp`. +- structured OSC 777 events unlock rich status. +- OSC 9 remains fallback for older Codex clients. + +This means we can test this before releasing into the wild. + +### 2. Make Codex listener protocol-aware +`CLIAgentSessionHandler::try_parse` now takes `&mut self`. Codex uses that state to remember when it has seen a structured Codex plugin event. +Codex parsing rules: +- Try `parse_event(title, body)` first. +- If it is a Codex structured event and `CodexPlugin` is enabled, mark `structured_plugin_active = true` and forward it. +- If it is a Codex structured event but the flag is disabled, drop it and leave OSC 9 fallback active. +- If it is a structured event for another agent, drop it. +- If it is OSC 9 (`title == None`) and no structured plugin has been seen, convert text to `Stop`. +- If structured plugin is active, ignore later OSC 9 so Warp does not emit duplicate status/notifications. + +### 3. Move rich-status checks onto session state +The old `agent_supports_rich_status(agent)` helper was static and could not distinguish Codex plugin from Codex OSC 9 fallback. +`CLIAgentSession` now owns the distinction: +- `has_structured_plugin()` is true when a listener exists and, for Codex, a plugin version exists. +- `supports_rich_status()` delegates to `has_structured_plugin()`. +This works because the Codex structured plugin reports a version on `session_start`, while OSC 9 fallback does not. +`apply_event()` now records `payload.plugin_version` from any event, and `update_from_event()` emits `SessionUpdated` when the version changes. This lets footer/UI surfaces re-evaluate chip/status state after the structured plugin connects. + +### 4. Keep Codex command detection fallback +Codex command detection still registers a listener immediately, because OSC 9 has no `SessionStart` sentinel. +Important distinction: +- Command-detected Codex calls `register_cli_agent_listener_without_session_start_event(CLIAgent::Codex, ctx)`. +- `RegisterPluginListener` after install/update uses the same helper and also does not seed a plugin version. +- Installing the plugin mid-session does not make the running Codex process emit OSC 777. Codex must be restarted to load hooks and start structured notifications. +- Until restart, Warp should keep treating the session as OSC 9 fallback. Disk install state can drive install/update/restart UI, but not `has_structured_plugin()`. + +### 5. Update UI consumers to use rich-status semantics +All surfaces that previously treated `listener.is_some()` plus static agent support as rich-status now use `session.supports_rich_status()`: +- footer plugin-chip suppression and update-chip logic +- auto show/hide of rich input +- close-on-submit behavior +- terminal icon status +- vertical tabs summary/detail status +This prevents OSC 9 fallback Codex sessions from showing precise statuses or suppressing plugin install chips too early. + +### 6. Add Codex plugin manager +The Codex manager follows the Claude manager shape: +- shell-aware `LocalCommandExecutor` +- PATH override support for tools installed by shell managers +- marketplace add/remove/install/update flow +- local install detection +- cached manifest version detection +- platform plugin install hook + +Key Codex differences: +- CLI binary is `codex`, not `claude`. +- User plugin command is `codex plugin add warp@codex-warp`, not `claude plugin install warp@claude-code-warp`. +- Update removes marketplace `codex-warp`, re-adds `warpdotdev/codex-warp`, then runs `codex plugin add warp@codex-warp`. +- User plugin key is `warp@codex-warp`. +- Config root is `$CODEX_HOME` or `~/.codex`. +- Install state reads `[plugins."warp@codex-warp"].enabled = true` from `config.toml`. +- Version state reads cached manifests under `plugins/cache/codex-warp/warp/*/.codex-plugin/plugin.json`. +- Success copy says restart Codex, not `/reload-plugins`. + +## Testing and validation +Unit coverage added/updated: +- `CodexSessionHandler` parses OSC 9 text as `Stop`. +- empty OSC 9 bodies are ignored. +- titled non-structured notifications are ignored. +- structured Codex events are ignored when `CodexPlugin` is disabled. +- after one structured Codex event, later OSC 9 is ignored. +- structured events for other agents are ignored by the Codex handler. +- Codex plugin manager toggles install/update support by `CodexPlugin`. +- plugin install/update instruction commands match Codex command names. +- native fallback instructions remain when `CodexPlugin` is off. +- install detection reads `config.toml`. +- version detection picks latest cached plugin manifest version. +- `CODEX_HOME` overrides the default `~/.codex` path in install/update checks. + +Suggested local validation: +- `cargo test -p warp --features test-util codex` +- `cargo test -p warp --features test-util cli_agent_sessions` +- Run Warp with `CodexPlugin` disabled, start Codex with native notifications, verify one completion notification and no rich status. +- Run Warp with `CodexPlugin` enabled and plugin active after Codex restart, verify permission requests show rich blocked state. +- In plugin mode, verify Codex emitting both OSC 777 and OSC 9 does not produce duplicate notifications. +- Verify footer install/update/restart UI remains visible for OSC 9 fallback, then hides after structured plugin connects after restart. +- Verify vertical tabs and agent icon do not show rich status for OSC 9 fallback. + +## Parallelization +No sub-agents recommended for implementation. The change is tightly coupled around one invariant: whether a Codex session is OSC 9 fallback or structured OSC 777 plugin-backed. +Best review split: +- Protocol/session review: `listener/mod.rs`, `cli_agent_sessions/mod.rs`, `terminal/view.rs`. +- Plugin manager review: `plugin_manager/codex.rs`, `codex_tests.rs`, `plugin_manager/mod.rs`. +- UI surface review: footer, rich input, agent icon, vertical tabs. +These can be reviewed in parallel, but changes should land as one PR because the protocol invariant must stay consistent across all consumers.