first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
@@ -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<String>,
pub project: Option<String>,
pub payload: CLIAgentEventPayload,
pub source: CLIAgentEventSource,
}
/// Version-specific parsers, indexed by (version - 1).
@@ -1,9 +1,8 @@
use serde::Deserialize;
use super::{CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType};
use crate::terminal::CLIAgent;
use super::{CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType};
/// Resolves a CLI agent from the `"agent"` string in a CLI agent event.
/// Returns `None` if the string doesn't match any known agent.
fn resolve_agent(agent: &str) -> Option<CLIAgent> {
@@ -55,6 +54,7 @@ pub(super) fn parse(body: &str) -> Option<CLIAgentEvent> {
tool_input_preview,
plugin_version: raw.plugin_version,
},
source: CLIAgentEventSource::RichPlugin,
})
}
@@ -1,8 +1,10 @@
use galaxyui::{EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::{CLIAgentEvent, CLIAgentSessionsModel};
use crate::terminal::cli_agent_sessions::event::parse_event;
use crate::terminal::cli_agent_sessions::event::{CLIAgentEventPayload, CLIAgentEventType};
use crate::features::FeatureFlag;
use crate::terminal::cli_agent_sessions::event::{
parse_event, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType,
};
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::CLIAgent;
@@ -14,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<CLIAgentEvent> {
///
/// `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<CLIAgentEvent> {
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<CLIAgentEvent>;
/// 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.
@@ -46,25 +44,35 @@ pub fn is_agent_supported(agent: &CLIAgent) -> bool {
| CLIAgent::Codex
| CLIAgent::Gemini
| CLIAgent::Auggie
| CLIAgent::Droid
| CLIAgent::Pi
)
}
/// Creates the appropriate handler for the given CLI agent.
fn create_handler(agent: &CLIAgent) -> Option<Box<dyn CLIAgentSessionHandler>> {
match agent {
// Auggie is supported via the community-maintained auggie-warp plugin
// (https://github.com/augmentmoogi/auggie-warp), which emits the same
// Auggie and Pi are supported via community-maintained plugins
// (https://github.com/augmentmoogi/auggie-warp,
// https://github.com/badlogic/pi-mono), which emit the same
// structured OSC 777 events as the first-party Claude/OpenCode/Gemini
// plugins. We don't ship an install flow for it — we just listen.
CLIAgent::Claude | CLIAgent::OpenCode | CLIAgent::Gemini | CLIAgent::Auggie => {
Some(Box::new(DefaultSessionListener))
}
CLIAgent::Codex => Some(Box::new(CodexSessionHandler)),
CLIAgent::Amp
// plugins. Droid can be supported by user-configured hooks or future
// integrations that emit the same structured OSC 777 events. We don't
// ship install flows for these agents here — we just listen.
CLIAgent::Claude
| CLIAgent::OpenCode
| CLIAgent::Gemini
| CLIAgent::Auggie
| CLIAgent::Droid
| CLIAgent::Pi => Some(Box::new(DefaultSessionListener)),
CLIAgent::Codex => Some(Box::new(CodexSessionHandler)),
CLIAgent::Hermes
| CLIAgent::Amp
| CLIAgent::Copilot
| CLIAgent::Pi
| CLIAgent::CursorCli
| CLIAgent::Goose
| CLIAgent::Vibe
| CLIAgent::Antigravity
| CLIAgent::Unknown => None,
}
}
@@ -84,14 +92,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 {
@@ -114,22 +119,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<CLIAgentEvent> {
// 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<CLIAgentEvent> {
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)
@@ -138,10 +155,6 @@ impl CLIAgentSessionHandler for CodexSessionHandler {
fn handle_event(&mut self, event: CLIAgentEvent) -> Option<CLIAgentEvent> {
Some(event)
}
fn supports_rich_status(&self) -> bool {
false
}
}
/// Per-agent listener that subscribes to PTY events and forwards them to the
@@ -169,14 +182,21 @@ impl CLIAgentSessionListener {
// Subscribe to subsequent OSC events from this terminal's PTY.
// Parsing is delegated to the handler's `try_parse`; the handler's
// `handle_event` then filters/transforms the result.
ctx.subscribe_to_model(model_event_dispatcher, move |me, event, ctx| {
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);
});
}
}
@@ -190,100 +210,5 @@ impl CLIAgentSessionListener {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::cli_agent_sessions::event::CLIAgentEventType;
#[test]
fn codex_parses_any_text_as_stop() {
let event = CodexSessionHandler::parse_osc9_text("Agent turn complete").unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
assert_eq!(event.agent, CLIAgent::Codex);
assert_eq!(event.payload.query.as_deref(), Some("Agent turn complete"));
}
#[test]
fn codex_body_becomes_query() {
let event = CodexSessionHandler::parse_osc9_text(
"I've updated the README with the new instructions.",
)
.unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
assert_eq!(
event.payload.query.as_deref(),
Some("I've updated the README with the new instructions.")
);
}
#[test]
fn codex_approval_text_still_becomes_stop() {
let event =
CodexSessionHandler::parse_osc9_text("Approval requested: rm -rf /tmp/foo").unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
assert_eq!(
event.payload.query.as_deref(),
Some("Approval requested: rm -rf /tmp/foo")
);
}
#[test]
fn codex_ignores_empty_body() {
assert!(CodexSessionHandler::parse_osc9_text("").is_none());
assert!(CodexSessionHandler::parse_osc9_text(" ").is_none());
}
#[test]
fn codex_try_parse_ignores_titled_notifications() {
let handler = CodexSessionHandler;
assert!(handler
.try_parse(Some("some-title"), "Agent turn complete")
.is_none());
}
#[test]
fn codex_try_parse_handles_osc9() {
let handler = CodexSessionHandler;
let event = handler.try_parse(None, "Agent turn complete").unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
}
#[test]
fn auggie_is_supported() {
assert!(is_agent_supported(&CLIAgent::Auggie));
}
#[test]
fn auggie_uses_default_handler_with_rich_status() {
assert!(agent_supports_rich_status(&CLIAgent::Auggie));
}
#[test]
fn auggie_default_handler_skips_session_start() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
v: 1,
agent: CLIAgent::Auggie,
event: CLIAgentEventType::SessionStart,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_none());
}
#[test]
fn auggie_default_handler_forwards_stop() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
v: 1,
agent: CLIAgent::Auggie,
event: CLIAgentEventType::Stop,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_some());
}
}
#[path = "mod_tests.rs"]
mod tests;
@@ -0,0 +1,229 @@
use super::*;
use crate::terminal::cli_agent_sessions::event::{
CLIAgentEventSource, CLIAgentEventType, CLI_AGENT_NOTIFICATION_SENTINEL,
};
#[test]
fn codex_parses_any_text_as_stop() {
let event = CodexSessionHandler::parse_osc9_text("Agent turn complete").unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
assert_eq!(event.agent, CLIAgent::Codex);
assert_eq!(event.payload.query.as_deref(), Some("Agent turn complete"));
}
#[test]
fn codex_body_becomes_query() {
let event =
CodexSessionHandler::parse_osc9_text("I've updated the README with the new instructions.")
.unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
assert_eq!(
event.payload.query.as_deref(),
Some("I've updated the README with the new instructions.")
);
}
#[test]
fn codex_approval_text_still_becomes_stop() {
let event =
CodexSessionHandler::parse_osc9_text("Approval requested: rm -rf /tmp/foo").unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
assert_eq!(
event.payload.query.as_deref(),
Some("Approval requested: rm -rf /tmp/foo")
);
}
#[test]
fn codex_ignores_empty_body() {
assert!(CodexSessionHandler::parse_osc9_text("").is_none());
assert!(CodexSessionHandler::parse_osc9_text(" ").is_none());
}
#[test]
fn codex_try_parse_ignores_titled_notifications() {
let mut handler = CodexSessionHandler;
assert!(handler
.try_parse(Some("some-title"), "Agent turn complete", false)
.is_none());
}
#[test]
fn codex_try_parse_handles_osc9() {
let mut handler = CodexSessionHandler;
let event = handler
.try_parse(None, "Agent turn complete", false)
.unwrap();
assert_eq!(event.event, CLIAgentEventType::Stop);
}
#[test]
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 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,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_none());
}
#[test]
fn auggie_default_handler_forwards_stop() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Auggie,
event: CLIAgentEventType::Stop,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_some());
}
#[test]
fn pi_is_supported() {
assert!(is_agent_supported(&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,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_none());
}
#[test]
fn pi_default_handler_forwards_stop() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Pi,
event: CLIAgentEventType::Stop,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_some());
}
#[test]
fn droid_is_supported() {
assert!(is_agent_supported(&CLIAgent::Droid));
}
#[test]
fn droid_default_handler_skips_session_start() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Droid,
event: CLIAgentEventType::SessionStart,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_none());
}
#[test]
fn droid_default_handler_forwards_stop() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Droid,
event: CLIAgentEventType::Stop,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_some());
}
#[test]
fn droid_default_handler_forwards_permission_request() {
let mut handler = DefaultSessionListener;
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Droid,
event: CLIAgentEventType::PermissionRequest,
session_id: None,
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
assert!(handler.handle_event(event).is_some());
}
+43 -7
View File
@@ -5,13 +5,12 @@ pub(crate) mod plugin_manager;
use std::collections::{HashMap, HashSet};
use event::{CLIAgentEvent, CLIAgentEventSource, CLIAgentEventType};
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use crate::ai::blocklist::InputConfig;
use self::listener::CLIAgentSessionListener;
use super::CLIAgent;
use event::{CLIAgentEvent, CLIAgentEventType};
use crate::ai::blocklist::InputConfig;
/// Status of a tracked CLI agent session.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -123,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<ModelHandle<CLIAgentSessionListener>>,
/// 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<String>,
/// `None` when the session is local.
/// `Some("user@hostname")` when running over SSH (warpified or legacy).
@@ -141,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<String>,
/// 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 {
@@ -148,6 +151,28 @@ 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 blocking tool
/// completed, a new prompt is submitted, or the session ends successfully)
/// so the permission summary doesn't leak into later UI surfaces — most
/// visibly the tab title, which can fall back to `summary` when `query`
/// is unset.
fn clear_permission_scoped_state(&mut self) {
self.session_context.summary = None;
self.session_context.tool_name = None;
self.session_context.tool_input_preview = None;
}
/// Applies an event to this session, updating context and status.
/// Returns the new status if it changed, or `None` if the event was irrelevant.
fn apply_event(&mut self, event: &CLIAgentEvent) -> Option<CLIAgentSessionStatus> {
@@ -165,17 +190,20 @@ impl CLIAgentSession {
CLIAgentEventType::PromptSubmit => {
self.session_context.query = event.payload.query.clone();
self.session_context.response = None;
self.clear_permission_scoped_state();
CLIAgentSessionStatus::InProgress
}
CLIAgentEventType::ToolComplete => {
if !matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) {
return None;
}
self.clear_permission_scoped_state();
CLIAgentSessionStatus::InProgress
}
CLIAgentEventType::Stop => {
self.session_context.query = event.payload.query.clone();
self.session_context.response = event.payload.response.clone();
self.clear_permission_scoped_state();
CLIAgentSessionStatus::Success
}
CLIAgentEventType::PermissionRequest => {
@@ -197,6 +225,7 @@ impl CLIAgentSession {
if !matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) {
return None;
}
self.clear_permission_scoped_state();
CLIAgentSessionStatus::InProgress
}
// IdlePrompt means the agent is sitting at its prompt waiting for input.
@@ -364,6 +393,7 @@ impl CLIAgentSessionsModel {
remote_host,
draft_text: None,
custom_command_prefix: None,
received_rich_notification: false,
},
ctx,
);
@@ -379,6 +409,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,
@@ -389,6 +421,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;
@@ -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,
@@ -222,6 +224,34 @@ fn parse_auggie_stop_notification() {
assert_eq!(notif.payload.response.as_deref(), Some("Memory is safe"));
}
#[test]
fn parse_pi_stop_notification() {
// Mirrors what the community pi-mono plugin emits on the Stop hook —
// matches the Auggie shape and uses `"agent":"pi"`, which `resolve_agent`
// already maps to `CLIAgent::Pi` via `command_prefix()`.
let body = r#"{"v":1,"agent":"pi","event":"stop","session_id":"abc","cwd":"/tmp/proj","project":"proj","query":"write a haiku","response":"Memory is safe"}"#;
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
assert_eq!(notif.agent, CLIAgent::Pi);
assert_eq!(notif.event, CLIAgentEventType::Stop);
assert_eq!(notif.payload.query.as_deref(), Some("write a haiku"));
assert_eq!(notif.payload.response.as_deref(), Some("Memory is safe"));
}
#[test]
fn parse_droid_stop_notification() {
// Droid is already a known CLI agent, so structured OSC 777 events using
// `"agent":"droid"` should resolve through the existing command prefix
// parser without any Droid-specific parser logic.
let body = r#"{"v":1,"agent":"droid","event":"stop","session_id":"abc","cwd":"/tmp/proj","project":"proj","query":"write a haiku","response":"Memory is safe"}"#;
let notif = parse_event(Some("warp://cli-agent"), body).unwrap();
assert_eq!(notif.agent, CLIAgent::Droid);
assert_eq!(notif.event, CLIAgentEventType::Stop);
assert_eq!(notif.payload.query.as_deref(), Some("write a haiku"));
assert_eq!(notif.payload.response.as_deref(), Some("Memory is safe"));
}
#[test]
fn apply_event_preserves_input_session() {
let input_state = CLIAgentInputState::Open {
@@ -243,9 +273,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,
@@ -276,6 +308,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());
}
@@ -293,6 +326,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());
}
@@ -361,9 +395,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,
@@ -393,9 +429,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,
@@ -408,3 +446,254 @@ fn session_start_without_plugin_version_leaves_none() {
session.apply_event(&event);
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.
fn blocked_claude_session_with_permission_state() -> CLIAgentSession {
CLIAgentSession {
agent: CLIAgent::Claude,
status: CLIAgentSessionStatus::Blocked {
message: Some("Wants to run bash: rm -rf /tmp".to_owned()),
},
session_context: CLIAgentSessionContext {
summary: Some("Wants to run bash: rm -rf /tmp".to_owned()),
tool_name: Some("Bash".to_owned()),
tool_input_preview: Some("rm -rf /tmp".to_owned()),
..Default::default()
},
input_state: CLIAgentInputState::Closed,
should_auto_toggle_input: false,
listener: None,
plugin_version: None,
draft_text: None,
remote_host: None,
custom_command_prefix: None,
received_rich_notification: false,
}
}
#[test]
fn stop_clears_permission_scoped_state() {
// GH-9525: after a PermissionRequest sets `summary`, the Stop event must
// clear it. Otherwise the tab title falls back to the stale permission
// text instead of reflecting the now-completed session.
let mut session = blocked_claude_session_with_permission_state();
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::Stop,
session_id: Some("abc".to_owned()),
cwd: None,
project: None,
payload: CLIAgentEventPayload {
query: Some("write a haiku".to_owned()),
response: Some("Memory is safe".to_owned()),
..Default::default()
},
};
session.apply_event(&event);
assert_eq!(session.session_context.summary, None);
assert_eq!(session.session_context.tool_name, None);
assert_eq!(session.session_context.tool_input_preview, None);
assert_eq!(
session.session_context.query.as_deref(),
Some("write a haiku"),
);
assert_eq!(
session.session_context.response.as_deref(),
Some("Memory is safe"),
);
assert!(matches!(session.status, CLIAgentSessionStatus::Success));
}
#[test]
fn permission_replied_clears_permission_scoped_state() {
// When the user replies to a permission prompt the agent transitions back
// to InProgress; the now-stale summary/tool fields must be cleared so they
// don't leak into UI surfaces during the next turn.
let mut session = blocked_claude_session_with_permission_state();
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::PermissionReplied,
session_id: Some("abc".to_owned()),
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
session.apply_event(&event);
assert_eq!(session.session_context.summary, None);
assert_eq!(session.session_context.tool_name, None);
assert_eq!(session.session_context.tool_input_preview, None);
assert!(matches!(session.status, CLIAgentSessionStatus::InProgress));
}
#[test]
fn prompt_submit_clears_permission_scoped_state() {
// PromptSubmit already clears `response`; clearing the permission-scoped
// fields keeps the same hygiene if the user manages to start a new turn
// while permission state is still populated (e.g. an abandoned permission
// flow that was not closed by an explicit PermissionReplied).
let mut session = blocked_claude_session_with_permission_state();
session.session_context.response = Some("stale response".to_owned());
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::PromptSubmit,
session_id: Some("abc".to_owned()),
cwd: None,
project: None,
payload: CLIAgentEventPayload {
query: Some("next prompt".to_owned()),
..Default::default()
},
};
session.apply_event(&event);
assert_eq!(session.session_context.summary, None);
assert_eq!(session.session_context.tool_name, None);
assert_eq!(session.session_context.tool_input_preview, None);
assert_eq!(session.session_context.response, None);
assert_eq!(
session.session_context.query.as_deref(),
Some("next prompt")
);
assert!(matches!(session.status, CLIAgentSessionStatus::InProgress));
}
#[test]
fn tool_complete_clears_permission_scoped_state() {
// GH-11082: answering an AskUserQuestion emits only ToolComplete (the
// plugin sends no PermissionReplied for it), so the Blocked -> InProgress
// transition here must also clear the stale summary. Otherwise the tab
// title keeps showing "Wants to run AskUserQuestion: ..." until the next
// prompt or Stop.
let mut session = blocked_claude_session_with_permission_state();
let event = CLIAgentEvent {
source: CLIAgentEventSource::RichPlugin,
v: 1,
agent: CLIAgent::Claude,
event: CLIAgentEventType::ToolComplete,
session_id: Some("abc".to_owned()),
cwd: None,
project: None,
payload: CLIAgentEventPayload::default(),
};
session.apply_event(&event);
assert_eq!(session.session_context.summary, None);
assert_eq!(session.session_context.tool_name, None);
assert_eq!(session.session_context.tool_input_preview, None);
assert!(matches!(session.status, CLIAgentSessionStatus::InProgress));
}
#[test]
fn permission_request_still_populates_summary_and_tool_fields() {
// Sanity: clearing permission-scoped state on Stop/Reply/Submit must not
// also break the PermissionRequest path that initially populates them.
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,
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,
session_id: Some("abc".to_owned()),
cwd: None,
project: None,
payload: CLIAgentEventPayload {
summary: Some("Wants to run bash: rm -rf /tmp".to_owned()),
tool_name: Some("Bash".to_owned()),
tool_input_preview: Some("rm -rf /tmp".to_owned()),
..Default::default()
},
};
session.apply_event(&event);
assert_eq!(
session.session_context.summary.as_deref(),
Some("Wants to run bash: rm -rf /tmp"),
);
assert_eq!(session.session_context.tool_name.as_deref(), Some("Bash"));
assert_eq!(
session.session_context.tool_input_preview.as_deref(),
Some("rm -rf /tmp"),
);
assert!(matches!(
session.status,
CLIAgentSessionStatus::Blocked { .. },
));
}
@@ -1,9 +1,7 @@
use std::collections::HashMap;
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::{env, fs, io};
use async_trait::async_trait;
use serde_json::Value;
@@ -16,16 +14,16 @@ use crate::terminal::model::session::LocalCommandExecutor;
use crate::terminal::shell::ShellType;
const PLUGIN_KEY: &str = "warp@claude-code-warp";
const PLATFORM_PLUGIN_KEY: &str = "oz-harness-support@claude-code-warp";
const MARKETPLACE_REPO: &str = "warpdotdev/claude-code-warp";
const MARKETPLACE_NAME: &str = "claude-code-warp";
const PLATFORM_PLUGIN_KEY: &str = "oz-harness-support@claude-code-warp";
// Note: we will eventually publish this to the same marketplace repo, but are using the internal one as we build out multi-harness.
const PLATFORM_MARKETPLACE_REPO: &str = "warpdotdev/claude-code-warp-internal";
// Keep in sync with the plugin version in warpdotdev/claude-code-warp.
// (See the Versioning section of that repo's README.)
const MINIMUM_PLUGIN_VERSION: &str = "2.0.0";
const MINIMUM_PLUGIN_VERSION: &str = "2.1.0";
// Keep in sync with the oz-harness-support plugin version in warpdotdev/claude-code-warp.
const MINIMUM_PLATFORM_PLUGIN_VERSION: &str = "1.1.2";
pub(super) struct ClaudeCodePluginManager {
executor: LocalCommandExecutor,
@@ -71,6 +69,31 @@ impl CliAgentPluginManager for ClaudeCodePluginManager {
check_installed(&claude_dir)
}
fn is_platform_plugin_installed(&self) -> bool {
let Ok(claude_dir) = claude_home_dir() else {
return false;
};
check_platform_plugin_installed(&claude_dir)
}
fn platform_plugin_needs_update(&self) -> bool {
let Ok(claude_dir) = claude_home_dir() else {
return false;
};
match installed_platform_plugin_version(&claude_dir) {
Some(v) => compare_versions(&v, MINIMUM_PLATFORM_PLUGIN_VERSION).is_lt(),
// No version field means very old plugin.
None => check_platform_plugin_installed(&claude_dir),
}
}
fn has_local_marketplace_override(&self) -> bool {
let Ok(claude_dir) = claude_home_dir() else {
return false;
};
claude_code_marketplace_has_local_override(&claude_dir)
}
/// Runs `claude plugin` CLI commands via the session shell.
async fn install(&self) -> Result<(), PluginInstallError> {
let mut log = String::new();
@@ -151,7 +174,7 @@ impl CliAgentPluginManager for ClaudeCodePluginManager {
async fn install_platform_plugin(&self) -> Result<(), PluginInstallError> {
let mut log = String::new();
self.run_logged(
&["plugin", "marketplace", "add", PLATFORM_MARKETPLACE_REPO],
&["plugin", "marketplace", "add", MARKETPLACE_REPO],
&mut log,
)
.await?;
@@ -159,6 +182,31 @@ impl CliAgentPluginManager for ClaudeCodePluginManager {
.await?;
Ok(())
}
async fn update_platform_plugin(&self) -> Result<(), PluginInstallError> {
let mut log = String::new();
self.run_logged(
&["plugin", "marketplace", "add", MARKETPLACE_REPO],
&mut log,
)
.await?;
self.run_logged(&["plugin", "install", PLATFORM_PLUGIN_KEY], &mut log)
.await?;
let still_outdated = claude_home_dir()
.ok()
.and_then(|dir| installed_platform_plugin_version(&dir))
.map(|v| compare_versions(&v, MINIMUM_PLATFORM_PLUGIN_VERSION).is_lt())
.unwrap_or(true);
if still_outdated {
log.push_str("Post-update version check: platform plugin is still outdated\n");
return Err(PluginInstallError {
message: "Platform plugin update did not take effect".to_owned(),
log,
});
}
Ok(())
}
}
static INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
@@ -214,6 +262,14 @@ static UPDATE_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| Plug
});
fn check_installed(claude_dir: &Path) -> bool {
check_plugin_installed(claude_dir, PLUGIN_KEY)
}
fn check_platform_plugin_installed(claude_dir: &Path) -> bool {
check_plugin_installed(claude_dir, PLATFORM_PLUGIN_KEY)
}
fn check_plugin_installed(claude_dir: &Path, plugin_key: &str) -> bool {
let plugins_path = claude_dir.join("plugins").join("installed_plugins.json");
let Ok(contents) = fs::read_to_string(plugins_path) else {
return false;
@@ -223,7 +279,7 @@ fn check_installed(claude_dir: &Path) -> bool {
};
parsed
.get("plugins")
.and_then(|p| p.get(PLUGIN_KEY))
.and_then(|p| p.get(plugin_key))
.and_then(|v| v.as_array())
.map(|arr| !arr.is_empty())
.unwrap_or(false)
@@ -231,12 +287,21 @@ fn check_installed(claude_dir: &Path) -> bool {
/// Reads the installed version string for the Warp plugin, if present.
fn installed_version(claude_dir: &Path) -> Option<String> {
installed_plugin_version(claude_dir, PLUGIN_KEY)
}
/// Reads the installed version string for the Oz platform plugin, if present.
fn installed_platform_plugin_version(claude_dir: &Path) -> Option<String> {
installed_plugin_version(claude_dir, PLATFORM_PLUGIN_KEY)
}
fn installed_plugin_version(claude_dir: &Path, plugin_key: &str) -> Option<String> {
let plugins_path = claude_dir.join("plugins").join("installed_plugins.json");
let contents = fs::read_to_string(plugins_path).ok()?;
let parsed: Value = serde_json::from_str(&contents).ok()?;
parsed
.get("plugins")?
.get(PLUGIN_KEY)?
.get(plugin_key)?
.as_array()?
.first()?
.get("version")?
@@ -244,10 +309,55 @@ fn installed_version(claude_dir: &Path) -> Option<String> {
.map(|s| s.to_owned())
}
/// Checks `CLAUDE_HOME` env var first, falls back to `~/.claude`.
fn claude_code_marketplace_has_local_override(claude_dir: &Path) -> bool {
let settings_path = claude_dir.join("settings.json");
let Ok(contents) = fs::read_to_string(settings_path) else {
return false;
};
let Ok(settings) = serde_json::from_str::<Value>(&contents) else {
return false;
};
settings
.get("extraKnownMarketplaces")
.and_then(|marketplaces| marketplaces.get(MARKETPLACE_NAME))
.map(marketplace_entry_has_local_path)
.unwrap_or(false)
}
fn marketplace_entry_has_local_path(entry: &Value) -> bool {
let Some(source) = entry.get("source") else {
return false;
};
match source {
Value::Object(source) => {
let source_kind = source.get("source").and_then(Value::as_str);
let path = source.get("path").and_then(Value::as_str);
source_kind == Some("directory") && path.map(is_local_marketplace_path).unwrap_or(false)
}
Value::String(source) => is_local_marketplace_path(source),
_ => false,
}
}
fn is_local_marketplace_path(source: &str) -> bool {
source.starts_with('/')
|| source.starts_with("~/")
|| source.starts_with("./")
|| source.starts_with("../")
|| source.starts_with("file://")
}
/// Resolves the dir the Claude CLI reads/writes its state from.
///
/// Honors `CLAUDE_CONFIG_DIR` (respected by the Claude CLI, and set by the Oz
/// worker to a per-task dir), falling back to `~/.claude`. Must match where
/// `claude plugin install` writes, else install/verify checks read the wrong dir.
fn claude_home_dir() -> io::Result<PathBuf> {
if let Ok(claude_home) = env::var("CLAUDE_HOME") {
return Ok(PathBuf::from(claude_home));
if let Ok(dir) = env::var("CLAUDE_CONFIG_DIR") {
if !dir.is_empty() {
return Ok(PathBuf::from(dir));
}
}
dirs::home_dir()
.map(|home| home.join(".claude"))
@@ -1,6 +1,27 @@
use std::fs;
use super::{check_installed, installed_version, ClaudeCodePluginManager, CliAgentPluginManager};
use super::{
check_installed, check_platform_plugin_installed, claude_code_marketplace_has_local_override,
installed_platform_plugin_version, installed_version, ClaudeCodePluginManager,
CliAgentPluginManager, MINIMUM_PLATFORM_PLUGIN_VERSION,
};
/// A version strictly below `version`, so below-minimum tests track the
/// constant instead of a hardcoded literal. Assumes `version` > "0.0.0".
fn version_below(version: &str) -> String {
let mut parts: Vec<u64> = version.split('.').map(|p| p.parse().unwrap_or(0)).collect();
for part in parts.iter_mut().rev() {
if *part > 0 {
*part -= 1;
break;
}
}
parts
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(".")
}
#[test]
fn installed_when_plugin_present() {
@@ -22,6 +43,212 @@ fn installed_when_plugin_present() {
assert!(check_installed(dir.path()));
}
#[test]
fn local_marketplace_override_detects_directory_source() {
let dir = tempfile::tempdir().unwrap();
let settings = serde_json::json!({
"extraKnownMarketplaces": {
"claude-code-warp": {
"source": {
"path": "/Users/example/Developer/claude-code-warp-internal",
"source": "directory"
}
}
}
});
fs::write(
dir.path().join("settings.json"),
serde_json::to_string(&settings).unwrap(),
)
.unwrap();
assert!(claude_code_marketplace_has_local_override(dir.path()));
}
#[test]
fn local_marketplace_override_ignores_repo_source() {
let dir = tempfile::tempdir().unwrap();
let settings = serde_json::json!({
"extraKnownMarketplaces": {
"claude-code-warp": {
"source": "warpdotdev/claude-code-warp"
}
}
});
fs::write(
dir.path().join("settings.json"),
serde_json::to_string(&settings).unwrap(),
)
.unwrap();
assert!(!claude_code_marketplace_has_local_override(dir.path()));
}
#[test]
#[serial_test::serial]
fn local_marketplace_override_via_trait_uses_claude_config_dir() {
let dir = tempfile::tempdir().unwrap();
let settings = serde_json::json!({
"extraKnownMarketplaces": {
"claude-code-warp": {
"source": {
"path": "../claude-code-warp-internal",
"source": "directory"
}
}
}
});
fs::write(
dir.path().join("settings.json"),
serde_json::to_string(&settings).unwrap(),
)
.unwrap();
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
let result = ClaudeCodePluginManager::new(None, None, None).has_local_marketplace_override();
std::env::remove_var("CLAUDE_CONFIG_DIR");
assert!(result);
}
#[test]
fn installed_platform_plugin_version_returns_version_when_present() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({
"plugins": {
"oz-harness-support@claude-code-warp": [{"version": MINIMUM_PLATFORM_PLUGIN_VERSION}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert_eq!(
installed_platform_plugin_version(dir.path()).as_deref(),
Some(MINIMUM_PLATFORM_PLUGIN_VERSION)
);
}
#[test]
fn platform_plugin_installed_when_platform_plugin_present() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({
"plugins": {
"oz-harness-support@claude-code-warp": [{"version": MINIMUM_PLATFORM_PLUGIN_VERSION}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert!(check_platform_plugin_installed(dir.path()));
}
#[test]
#[serial_test::serial]
fn platform_plugin_needs_update_via_trait_when_version_below_minimum() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({
"plugins": {
"oz-harness-support@claude-code-warp": [{"version": version_below(MINIMUM_PLATFORM_PLUGIN_VERSION)}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
let result = ClaudeCodePluginManager::new(None, None, None).platform_plugin_needs_update();
std::env::remove_var("CLAUDE_CONFIG_DIR");
assert!(result);
}
#[test]
#[serial_test::serial]
fn platform_plugin_does_not_need_update_via_trait_when_current() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({
"plugins": {
"oz-harness-support@claude-code-warp": [{"version": MINIMUM_PLATFORM_PLUGIN_VERSION}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
let result = ClaudeCodePluginManager::new(None, None, None).platform_plugin_needs_update();
std::env::remove_var("CLAUDE_CONFIG_DIR");
assert!(!result);
}
#[test]
#[serial_test::serial]
fn platform_plugin_needs_update_via_trait_when_installed_without_version() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({
"plugins": {
"oz-harness-support@claude-code-warp": [{"scope": "user"}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
let result = ClaudeCodePluginManager::new(None, None, None).platform_plugin_needs_update();
std::env::remove_var("CLAUDE_CONFIG_DIR");
assert!(result);
}
#[test]
fn platform_plugin_not_installed_when_only_notification_plugin_present() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
let json = serde_json::json!({
"plugins": {
"warp@claude-code-warp": [{"version": "1.0.0"}]
}
});
fs::write(
plugins_dir.join("installed_plugins.json"),
serde_json::to_string(&json).unwrap(),
)
.unwrap();
assert!(!check_platform_plugin_installed(dir.path()));
}
#[test]
fn not_installed_when_plugin_key_absent() {
let dir = tempfile::tempdir().unwrap();
@@ -95,10 +322,10 @@ fn not_installed_when_plugins_key_missing() {
}
/// Tests `ClaudeCodePluginManager::is_installed` end-to-end by pointing
/// `CLAUDE_HOME` at a temp directory with a valid installed_plugins.json.
/// `CLAUDE_CONFIG_DIR` at a temp directory with a valid installed_plugins.json.
#[test]
#[serial_test::serial]
fn is_installed_via_trait_with_claude_home_env() {
fn is_installed_via_trait_with_claude_config_dir_env() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join("plugins");
fs::create_dir_all(&plugins_dir).unwrap();
@@ -114,38 +341,25 @@ fn is_installed_via_trait_with_claude_home_env() {
)
.unwrap();
std::env::set_var("CLAUDE_HOME", dir.path());
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
let result = ClaudeCodePluginManager::new(None, None, None).is_installed();
std::env::remove_var("CLAUDE_HOME");
std::env::remove_var("CLAUDE_CONFIG_DIR");
assert!(result);
}
#[test]
#[serial_test::serial]
fn not_installed_via_trait_when_claude_home_empty() {
fn not_installed_via_trait_when_claude_config_dir_empty() {
let dir = tempfile::tempdir().unwrap();
std::env::set_var("CLAUDE_HOME", dir.path());
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path());
let result = ClaudeCodePluginManager::new(None, None, None).is_installed();
std::env::remove_var("CLAUDE_HOME");
std::env::remove_var("CLAUDE_CONFIG_DIR");
assert!(!result);
}
#[test]
fn can_auto_install_is_true() {
assert!(ClaudeCodePluginManager::new(None, None, None).can_auto_install());
}
#[test]
fn minimum_version() {
assert_eq!(
ClaudeCodePluginManager::new(None, None, None).minimum_plugin_version(),
"2.0.0"
);
}
#[test]
fn installed_version_returns_version_when_present() {
let dir = tempfile::tempdir().unwrap();
@@ -1,54 +1,317 @@
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_NAME: &str = "warp";
const PLUGIN_KEY: &str = "warp@codex-warp";
const MARKETPLACE_REPO: &str = "warpdotdev/codex-warp";
const MARKETPLACE_NAME: &str = "codex-warp";
const PLATFORM_PLUGIN_NAME: &str = "orchestration";
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";
// Keep in sync with the orchestration plugin version in warpdotdev/codex-warp.
const MINIMUM_PLATFORM_PLUGIN_VERSION: &str = "0.4.0";
pub(super) struct CodexPluginManager {
executor: LocalCommandExecutor,
path_env_var: Option<String>,
}
impl CodexPluginManager {
pub(super) fn new(
shell_path: Option<PathBuf>,
shell_type: Option<ShellType>,
path_env_var: Option<String>,
) -> 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
}
/// Ensures the codex-warp marketplace is registered, while preserving a
/// non-Git/local marketplace override. If the marketplace is already a
/// Git repo, upgrade it; if it is a non-Git source, leave it alone; otherwise
/// add it from the canonical repository.
async fn ensure_marketplace(&self, log: &mut String) -> Result<(), PluginInstallError> {
match codex_home_dir()
.ok()
.and_then(|dir| codex_warp_marketplace_config(&dir))
{
Some(config) if config.is_git() => {
self.run_logged(&["plugin", "marketplace", "upgrade", MARKETPLACE_NAME], log)
.await
}
Some(_) => Ok(()),
None => {
self.run_logged(&["plugin", "marketplace", "add", MARKETPLACE_REPO], 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;
};
if codex_warp_marketplace_config(&codex_dir).is_some_and(|config| !config.is_git()) {
return false;
}
plugin_needs_update(&codex_dir, PLUGIN_NAME, PLUGIN_KEY, MINIMUM_PLUGIN_VERSION)
}
fn is_platform_plugin_installed(&self) -> bool {
if !FeatureFlag::CodexPlugin.is_enabled() {
return false;
}
let Ok(codex_dir) = codex_home_dir() else {
return false;
};
check_platform_plugin_installed(&codex_dir)
}
fn platform_plugin_needs_update(&self) -> bool {
if !FeatureFlag::CodexPlugin.is_enabled() {
return false;
}
let Ok(codex_dir) = codex_home_dir() else {
return false;
};
if codex_warp_marketplace_config(&codex_dir).is_some_and(|config| !config.is_git()) {
return false;
}
plugin_needs_update(
&codex_dir,
PLATFORM_PLUGIN_NAME,
PLATFORM_PLUGIN_KEY,
MINIMUM_PLATFORM_PLUGIN_VERSION,
)
}
fn has_local_marketplace_override(&self) -> bool {
let Ok(codex_dir) = codex_home_dir() else {
return false;
};
codex_warp_marketplace_config(&codex_dir).is_some_and(|config| !config.is_git())
}
async fn install(&self) -> Result<(), PluginInstallError> {
if !FeatureFlag::CodexPlugin.is_enabled() {
return Ok(());
}
log::info!("[PLUGIN_INSTALL] updating codex plugin");
let mut log = String::new();
ensure_codex_home_dir()?;
self.ensure_marketplace(&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();
ensure_codex_home_dir()?;
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();
ensure_codex_home_dir()?;
self.ensure_marketplace(&mut log).await?;
self.run_logged(&["plugin", "add", PLATFORM_PLUGIN_KEY], &mut log)
.await?;
let updated = codex_home_dir()
.ok()
.map(|dir| platform_plugin_version_is_current(&dir))
.unwrap_or(false);
if !updated {
log.push_str("Post-install version check: platform plugin is still outdated\n");
return Err(PluginInstallError {
message: "Platform plugin installation did not take effect".to_owned(),
log,
});
}
Ok(())
}
async fn update_platform_plugin(&self) -> Result<(), PluginInstallError> {
if !FeatureFlag::CodexPlugin.is_enabled() {
return Ok(());
}
let mut log = String::new();
ensure_codex_home_dir()?;
self.run_logged(
&["plugin", "marketplace", "upgrade", MARKETPLACE_NAME],
&mut log,
)
.await?;
self.run_logged(&["plugin", "add", PLATFORM_PLUGIN_KEY], &mut log)
.await?;
let updated = codex_home_dir()
.ok()
.map(|dir| platform_plugin_version_is_current(&dir))
.unwrap_or(false);
if !updated {
log.push_str("Post-update version check: platform plugin is still outdated\n");
return Err(PluginInstallError {
message: "Platform plugin update did not take effect".to_owned(),
log,
});
}
Ok(())
}
}
static INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| {
static PLUGIN_INSTALL_INSTRUCTIONS: LazyLock<PluginInstructions> =
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<PluginInstructions> = LazyLock::new(|| {
PluginInstructions {
title: "Enable Galaxy Notifications for Codex",
subtitle: "Update Codex to the latest version, then enable in-focus notifications so Galaxy 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<PluginInstructions> = LazyLock::new(|| PluginInstructions {
@@ -58,6 +321,172 @@ static EMPTY_INSTRUCTIONS: LazyLock<PluginInstructions> = LazyLock::new(|| Plugi
post_install_notes: &[],
});
static PLUGIN_UPDATE_INSTRUCTIONS: LazyLock<PluginInstructions> = 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.",
"If this fails because codex-warp is not configured as a Git marketplace, remove and re-add the marketplace.",
],
}
});
fn check_installed(codex_dir: &Path) -> bool {
check_plugin_enabled(codex_dir, PLUGIN_KEY)
}
fn check_platform_plugin_installed(codex_dir: &Path) -> bool {
check_plugin_enabled(codex_dir, PLATFORM_PLUGIN_KEY)
}
/// Whether `config.toml` marks the given plugin key as enabled.
fn check_plugin_enabled(codex_dir: &Path, plugin_key: &str) -> 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::<toml_edit::DocumentMut>() 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<String> {
installed_plugin_version(codex_dir, PLUGIN_NAME)
}
/// Reads the latest cached orchestration plugin version, if present.
fn installed_platform_plugin_version(codex_dir: &Path) -> Option<String> {
installed_plugin_version(codex_dir, PLATFORM_PLUGIN_NAME)
}
fn platform_plugin_version_is_current(codex_dir: &Path) -> bool {
installed_platform_plugin_version(codex_dir)
.map(|v| !compare_versions(&v, MINIMUM_PLATFORM_PLUGIN_VERSION).is_lt())
.unwrap_or(false)
}
/// Reads the latest cached version for `plugin_name` from
/// `plugins/cache/codex-warp/<plugin_name>/<version>/.codex-plugin/plugin.json`.
fn installed_plugin_version(codex_dir: &Path, plugin_name: &str) -> Option<String> {
let cache_dir = codex_dir
.join("plugins")
.join("cache")
.join(MARKETPLACE_NAME)
.join(plugin_name);
let entries = fs::read_dir(cache_dir).ok()?;
let mut latest: Option<String> = None;
for entry in entries.flatten() {
let manifest_path = entry.path().join(".codex-plugin").join("plugin.json");
let Some(version) = plugin_manifest_version(manifest_path) else {
continue;
};
if latest
.as_deref()
.map(|current| compare_versions(&version, current).is_gt())
.unwrap_or(true)
{
latest = Some(version);
}
}
latest
}
fn plugin_manifest_version(manifest_path: impl AsRef<Path>) -> Option<String> {
let contents = fs::read_to_string(manifest_path).ok()?;
let parsed = serde_json::from_str::<Value>(&contents).ok()?;
parsed
.get("version")
.and_then(|v| v.as_str())
.map(str::to_owned)
}
fn plugin_needs_update(
codex_dir: &Path,
plugin_name: &str,
plugin_key: &str,
minimum_version: &str,
) -> bool {
if !check_plugin_enabled(codex_dir, plugin_key) {
return false;
}
match installed_plugin_version(codex_dir, plugin_name) {
Some(v) => compare_versions(&v, minimum_version).is_lt(),
// No version field means very old plugin.
None => true,
}
}
struct CodexWarpMarketplaceConfig {
source_type: Option<String>,
}
impl CodexWarpMarketplaceConfig {
fn is_git(&self) -> bool {
self.source_type.as_deref() == Some("git")
}
}
fn codex_warp_marketplace_config(codex_dir: &Path) -> Option<CodexWarpMarketplaceConfig> {
let config_path = codex_dir.join("config.toml");
let contents = fs::read_to_string(config_path).ok()?;
let parsed = contents.parse::<toml_edit::DocumentMut>().ok()?;
let marketplace = parsed.get("marketplaces")?.get(MARKETPLACE_NAME)?;
Some(CodexWarpMarketplaceConfig {
source_type: marketplace
.get("source_type")
.and_then(|source_type| source_type.as_str())
.map(str::to_owned),
})
}
/// Checks `CODEX_HOME` first, falls back to `~/.codex`.
fn codex_home_dir() -> io::Result<PathBuf> {
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",
)
})
}
/// Creates the resolved Codex home directory if it does not yet exist.
/// The Codex CLI expects `CODEX_HOME` to exist before running plugin commands, we need
/// this for self-hosted direct backend workers.
fn ensure_codex_home_dir() -> io::Result<PathBuf> {
let dir = codex_home_dir()?;
fs::create_dir_all(&dir)?;
Ok(dir)
}
#[cfg(test)]
#[path = "codex_tests.rs"]
mod tests;
@@ -1,19 +1,483 @@
use std::fs;
use std::path::Path;
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 does_not_support_update() {
assert!(!CodexPluginManager.supports_update());
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 install_instructions_has_steps() {
let instructions = CodexPluginManager.install_instructions();
assert!(!instructions.steps.is_empty());
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_marketplace_and_plugin_add_steps() {
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_eq!(instructions.steps.len(), 2);
assert!(!instructions.title.is_empty());
}
#[test]
fn update_instructions_has_marketplace_and_plugin_add_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_eq!(instructions.steps.len(), 2);
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_plugin_enabled_in_config() {
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
assert!(super::check_installed(dir.path()));
}
#[test]
fn not_installed_when_plugin_disabled_in_config() {
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLUGIN_KEY, false);
assert!(!super::check_installed(dir.path()));
}
#[test]
fn not_installed_when_only_marketplace_present() {
// Marketplace cloned but the plugin was never enabled.
let dir = tempfile::tempdir().unwrap();
write_marketplace_config(dir.path(), "git");
assert!(!super::check_installed(dir.path()));
}
#[test]
fn platform_plugin_installed_when_enabled_in_config() {
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLATFORM_PLUGIN_KEY, true);
assert!(super::check_platform_plugin_installed(dir.path()));
}
#[test]
fn platform_plugin_not_installed_when_disabled_in_config() {
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLATFORM_PLUGIN_KEY, false);
assert!(!super::check_platform_plugin_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_reads_cache_manifest_version() {
let dir = tempfile::tempdir().unwrap();
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.4.0");
assert_eq!(
super::installed_version(dir.path()).as_deref(),
Some("0.4.0")
);
}
#[test]
fn installed_platform_plugin_version_reads_cache_manifest_version() {
let dir = tempfile::tempdir().unwrap();
write_cache_manifest(dir.path(), super::PLATFORM_PLUGIN_NAME, "0.4.0");
assert_eq!(
super::installed_platform_plugin_version(dir.path()).as_deref(),
Some("0.4.0")
);
}
#[test]
fn installed_version_picks_latest_cached() {
let dir = tempfile::tempdir().unwrap();
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.3.0");
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.5.0");
assert_eq!(
super::installed_version(dir.path()).as_deref(),
Some("0.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_cache_manifest_has_no_version() {
let dir = tempfile::tempdir().unwrap();
write_cache_manifest_without_version(dir.path(), super::PLUGIN_NAME, "0.4.0");
assert_eq!(super::installed_version(dir.path()), None);
}
#[test]
fn platform_plugin_version_is_current_when_cache_current() {
let dir = tempfile::tempdir().unwrap();
write_cache_manifest(dir.path(), super::PLATFORM_PLUGIN_NAME, "0.4.0");
assert!(super::platform_plugin_version_is_current(dir.path()));
}
#[test]
fn platform_plugin_version_is_not_current_when_cache_outdated() {
let dir = tempfile::tempdir().unwrap();
write_cache_manifest(dir.path(), super::PLATFORM_PLUGIN_NAME, "0.2.0");
assert!(!super::platform_plugin_version_is_current(dir.path()));
}
#[test]
fn needs_update_true_when_enabled_and_version_outdated() {
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.2.0");
assert!(super::plugin_needs_update(
dir.path(),
super::PLUGIN_NAME,
super::PLUGIN_KEY,
"0.4.0"
));
}
#[test]
fn needs_update_false_when_enabled_and_version_current() {
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.4.0");
assert!(!super::plugin_needs_update(
dir.path(),
super::PLUGIN_NAME,
super::PLUGIN_KEY,
"0.4.0"
));
}
#[test]
fn needs_update_false_when_not_enabled() {
let dir = tempfile::tempdir().unwrap();
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "0.2.0");
assert!(!super::plugin_needs_update(
dir.path(),
super::PLUGIN_NAME,
super::PLUGIN_KEY,
"0.4.0"
));
}
#[test]
fn needs_update_true_when_enabled_without_cached_version() {
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
assert!(super::plugin_needs_update(
dir.path(),
super::PLUGIN_NAME,
super::PLUGIN_KEY,
"0.4.0"
));
}
#[test]
fn platform_plugin_needs_update_true_when_enabled_and_outdated() {
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLATFORM_PLUGIN_KEY, true);
write_cache_manifest(dir.path(), super::PLATFORM_PLUGIN_NAME, "0.2.0");
assert!(super::plugin_needs_update(
dir.path(),
super::PLATFORM_PLUGIN_NAME,
super::PLATFORM_PLUGIN_KEY,
super::MINIMUM_PLATFORM_PLUGIN_VERSION
));
}
#[test]
fn platform_plugin_needs_update_false_when_current() {
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLATFORM_PLUGIN_KEY, true);
write_cache_manifest(dir.path(), super::PLATFORM_PLUGIN_NAME, "0.4.0");
assert!(!super::plugin_needs_update(
dir.path(),
super::PLATFORM_PLUGIN_NAME,
super::PLATFORM_PLUGIN_KEY,
super::MINIMUM_PLATFORM_PLUGIN_VERSION
));
}
#[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_plugin_config(dir.path(), super::PLUGIN_KEY, true);
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_plugin_config(dir.path(), super::PLUGIN_KEY, true);
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_platform_plugin_installed_via_trait_with_codex_home_env() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLATFORM_PLUGIN_KEY, true);
std::env::set_var("CODEX_HOME", dir.path());
let result = CodexPluginManager::new(None, None, None).is_platform_plugin_installed();
std::env::remove_var("CODEX_HOME");
assert!(result);
}
#[test]
#[serial_test::serial]
fn is_platform_plugin_not_installed_via_trait_without_codex_plugin() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLATFORM_PLUGIN_KEY, true);
std::env::set_var("CODEX_HOME", dir.path());
let result = CodexPluginManager::new(None, None, None).is_platform_plugin_installed();
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_plugin_config(dir.path(), super::PLUGIN_KEY, true);
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "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_plugin_config(dir.path(), super::PLUGIN_KEY, true);
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "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 does_not_need_update_without_codex_plugin() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(false);
let dir = tempfile::tempdir().unwrap();
write_plugin_config(dir.path(), super::PLUGIN_KEY, true);
write_cache_manifest(dir.path(), super::PLUGIN_NAME, "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_when_not_enabled() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
let dir = tempfile::tempdir().unwrap();
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_for_non_git_marketplace_override() {
let _guard = FeatureFlag::CodexPlugin.override_enabled(true);
let dir = tempfile::tempdir().unwrap();
write_marketplace_config(dir.path(), "directory");
std::env::set_var("CODEX_HOME", dir.path());
let result = CodexPluginManager::new(None, None, None).needs_update();
let has_override = CodexPluginManager::new(None, None, None).has_local_marketplace_override();
std::env::remove_var("CODEX_HOME");
assert!(!result);
assert!(has_override);
}
fn write_plugin_config(dir: &Path, plugin_key: &str, enabled: bool) {
fs::write(
dir.join("config.toml"),
format!("[plugins.\"{plugin_key}\"]\nenabled = {enabled}\n"),
)
.unwrap();
}
fn write_marketplace_config(dir: &Path, source_type: &str) {
fs::write(
dir.join("config.toml"),
format!(
"[marketplaces.codex-warp]\nsource_type = \"{source_type}\"\nsource = \"/tmp/codex-warp\"\n"
),
)
.unwrap();
}
fn write_cache_manifest(dir: &Path, plugin_name: &str, version: &str) {
write_cache_manifest_json(
dir,
plugin_name,
version,
serde_json::json!({ "name": plugin_name, "version": version }),
);
}
fn write_cache_manifest_without_version(dir: &Path, plugin_name: &str, version_dir: &str) {
write_cache_manifest_json(
dir,
plugin_name,
version_dir,
serde_json::json!({ "name": plugin_name }),
);
}
fn write_cache_manifest_json(
dir: &Path,
plugin_name: &str,
version_dir: &str,
manifest: serde_json::Value,
) {
let manifest_dir = dir
.join("plugins")
.join("cache")
.join("codex-warp")
.join(plugin_name)
.join(version_dir)
.join(".codex-plugin");
fs::create_dir_all(&manifest_dir).unwrap();
fs::write(manifest_dir.join("plugin.json"), manifest.to_string()).unwrap();
}
@@ -1,8 +1,7 @@
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::{fs, io};
use async_trait::async_trait;
use serde_json::Value;
@@ -5,20 +5,19 @@ pub(crate) mod opencode;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::fmt;
use std::io;
use std::path::PathBuf;
use std::{fmt, io};
use async_trait::async_trait;
use claude::ClaudeCodePluginManager;
use codex::CodexPluginManager;
use gemini::GeminiPluginManager;
use opencode::OpenCodePluginManager;
use crate::features::FeatureFlag;
use crate::terminal::model::session::LocalCommandExecutor;
use crate::terminal::shell::ShellType;
use crate::terminal::CLIAgent;
use claude::ClaudeCodePluginManager;
use codex::CodexPluginManager;
use gemini::GeminiPluginManager;
use opencode::OpenCodePluginManager;
/// Distinguishes whether the plugin instructions modal should show install or update steps.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -52,6 +51,7 @@ pub(crate) struct PluginInstructions {
/// Error returned when plugin installation fails.
/// Carries both a short user-facing message (for the toast) and a detailed
/// command log (for the log file the user can inspect).
#[derive(Debug)]
pub(crate) struct PluginInstallError {
/// Short description shown in the toast notification.
pub message: String,
@@ -65,6 +65,8 @@ impl fmt::Display for PluginInstallError {
}
}
impl std::error::Error for PluginInstallError {}
impl From<io::Error> for PluginInstallError {
fn from(err: io::Error) -> Self {
let msg = err.to_string();
@@ -160,6 +162,24 @@ pub(crate) trait CliAgentPluginManager: Send + Sync {
false
}
/// Whether this agent's Oz platform plugin is already installed.
/// Default returns `true` because most agents do not have a platform plugin.
fn is_platform_plugin_installed(&self) -> bool {
true
}
/// Whether this agent's Oz platform plugin is below the minimum required version.
/// Default returns `false` because most agents do not have a platform plugin.
fn platform_plugin_needs_update(&self) -> bool {
false
}
/// Whether the agent's plugin marketplace is currently overridden to a
/// local filesystem path. This is used by local test flows to avoid
/// clobbering a developer's marketplace override while still preserving
/// normal install/update behavior in staging and production.
fn has_local_marketplace_override(&self) -> bool {
false
}
/// Install the Warp notification plugin.
/// Default returns an error — only agents with `can_auto_install() == true` should override.
async fn install(&self) -> Result<(), PluginInstallError> {
@@ -207,6 +227,13 @@ pub(crate) trait CliAgentPluginManager: Send + Sync {
async fn install_platform_plugin(&self) -> Result<(), PluginInstallError> {
Ok(())
}
/// Update the Oz platform plugin for this CLI agent, if one exists.
/// Default reuses the install path because most agents do not have a
/// platform plugin or need distinct update behavior.
async fn update_platform_plugin(&self) -> Result<(), PluginInstallError> {
self.install_platform_plugin().await
}
}
/// Returns a plugin manager for the given CLI agent, or `None` if the agent
@@ -242,7 +269,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()
@@ -263,6 +294,10 @@ pub(crate) fn plugin_manager_for_with_shell(
| CLIAgent::Pi
| CLIAgent::Auggie
| CLIAgent::CursorCli
| CLIAgent::Hermes
| CLIAgent::Goose
| CLIAgent::Vibe
| CLIAgent::Antigravity
| CLIAgent::Unknown => None,
}
}