From 652b92b8319af808fce8c74320a8bdab735d9a36 Mon Sep 17 00:00:00 2001 From: "Yuandi Zhang (from Dev Box)" Date: Thu, 28 May 2026 14:50:57 +0800 Subject: [PATCH 01/34] feat(wta): promote Codex to first-class CliSource variant Replaces the dead 'codex' => None arm in from_agent_id with 'codex' => Some(Self::Codex), and threads the new variant through parse and all exhaustive matches in agent_sessions.rs, app.rs, history_loader.rs, session_registry.rs, and ui/agents_view.rs. history_loader: Codex returns true (conservative/Unknown semantics) until a dedicated codex probe is added in a later slice-A task. Subsequent commits in this slice wire downstream call-sites (history_loader, app, session_mgmt, session_registry, ui). Refs #80 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/wta/src/agent_sessions.rs | 30 +++++++++++++++++++++++++----- tools/wta/src/app.rs | 4 ++++ tools/wta/src/history_loader.rs | 2 ++ tools/wta/src/session_registry.rs | 2 ++ tools/wta/src/ui/agents_view.rs | 1 + 5 files changed, 34 insertions(+), 5 deletions(-) diff --git a/tools/wta/src/agent_sessions.rs b/tools/wta/src/agent_sessions.rs index f7431269c..d4db19acc 100644 --- a/tools/wta/src/agent_sessions.rs +++ b/tools/wta/src/agent_sessions.rs @@ -34,6 +34,7 @@ pub type AgentKey = String; #[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum CliSource { Claude, + Codex, Copilot, Gemini, Unknown(String), @@ -43,6 +44,7 @@ impl CliSource { pub fn parse(s: Option<&str>) -> Self { match s.unwrap_or("").to_ascii_lowercase().as_str() { "claude" => Self::Claude, + "codex" => Self::Codex, "copilot" => Self::Copilot, "gemini" => Self::Gemini, "" => Self::Unknown(String::new()), @@ -50,15 +52,16 @@ impl CliSource { } } - /// Map an `agent_registry` agent id (`"copilot"`, `"claude"`, `"gemini"`, + /// Map an `agent_registry` agent id (`"copilot"`, `"claude"`, `"codex"`, `"gemini"`, /// ...) to the matching `CliSource` variant. Returns `None` for agents - /// the session registry does not track (e.g. `"codex"`, `"unknown"`, or + /// the session registry does not track (e.g. `"unknown"`, or /// an empty string), which the session-management view treats as /// "no filter — show all rows". pub fn from_agent_id(agent_id: &str) -> Option { match agent_id.to_ascii_lowercase().as_str() { - "copilot" => Some(Self::Copilot), "claude" => Some(Self::Claude), + "codex" => Some(Self::Codex), + "copilot" => Some(Self::Copilot), "gemini" => Some(Self::Gemini), _ => None, } @@ -1981,14 +1984,31 @@ mod tests { #[test] fn from_agent_id_returns_none_for_untracked_or_empty() { - // Empty / unknown / codex are all "no filter" — the F2 view will + // Empty / unknown are "no filter" — the F2 view will // fall back to showing every row. assert_eq!(CliSource::from_agent_id(""), None); - assert_eq!(CliSource::from_agent_id("codex"), None); assert_eq!(CliSource::from_agent_id("unknown"), None); assert_eq!(CliSource::from_agent_id("bogus"), None); } + #[test] + fn cli_source_from_agent_id_recognizes_codex() { + assert_eq!( + CliSource::from_agent_id("codex"), + Some(CliSource::Codex), + ); + } + + #[test] + fn cli_source_parse_round_trips_codex() { + // Wire format used by SessionHookCliSource::Known("Codex" | "codex") + // must parse back to the typed variant — otherwise Codex hook events + // would degrade to CliSource::Unknown after a serde round-trip. + // Note: CliSource has `pub fn parse(Option<&str>) -> Self` (not FromStr). + assert_eq!(CliSource::parse(Some("Codex")), CliSource::Codex); + assert_eq!(CliSource::parse(Some("codex")), CliSource::Codex); + } + #[test] fn iter_sorted_filtered_keeps_only_matching_cli_source() { let mut reg = AgentSessionRegistry::new(); diff --git a/tools/wta/src/app.rs b/tools/wta/src/app.rs index edabb51f0..c9cb1d491 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -2158,6 +2158,7 @@ impl App { ref known => { let id = match known { crate::agent_sessions::CliSource::Claude => "claude", + crate::agent_sessions::CliSource::Codex => "codex", crate::agent_sessions::CliSource::Copilot => "copilot", crate::agent_sessions::CliSource::Gemini => "gemini", crate::agent_sessions::CliSource::Unknown(_) => unreachable!(), @@ -2211,6 +2212,7 @@ impl App { // Agents view (which is rendered in-tab). let cli_id = match s.cli_source { crate::agent_sessions::CliSource::Claude => "claude", + crate::agent_sessions::CliSource::Codex => "codex", crate::agent_sessions::CliSource::Copilot => "copilot", crate::agent_sessions::CliSource::Gemini => "gemini", crate::agent_sessions::CliSource::Unknown(_) => "this CLI", @@ -2359,6 +2361,7 @@ impl App { fn dispatch_resume(&mut self, s: &crate::agent_sessions::AgentSession) { let cli_id = match s.cli_source { crate::agent_sessions::CliSource::Claude => "claude", + crate::agent_sessions::CliSource::Codex => "codex", crate::agent_sessions::CliSource::Copilot => "copilot", crate::agent_sessions::CliSource::Gemini => "gemini", crate::agent_sessions::CliSource::Unknown(_) => { @@ -2597,6 +2600,7 @@ impl App { let short_key: String = s.key.chars().take(8).collect(); let cli_id = match s.cli_source { crate::agent_sessions::CliSource::Claude => "claude", + crate::agent_sessions::CliSource::Codex => "codex", crate::agent_sessions::CliSource::Copilot => "copilot", crate::agent_sessions::CliSource::Gemini => "gemini", crate::agent_sessions::CliSource::Unknown(_) => "this CLI", diff --git a/tools/wta/src/history_loader.rs b/tools/wta/src/history_loader.rs index 7be4c522c..805d17e77 100644 --- a/tools/wta/src/history_loader.rs +++ b/tools/wta/src/history_loader.rs @@ -217,6 +217,7 @@ pub(crate) fn key_is_resumable_on_disk_in( use crate::agent_sessions::CliSource; match cli { CliSource::Claude => claude_key_is_resumable_on_disk_in(home, key), + CliSource::Codex => true, CliSource::Copilot => copilot_key_is_resumable_on_disk_in(home, key), CliSource::Gemini => gemini_key_is_resumable_on_disk_in(home, key), CliSource::Unknown(_) => true, @@ -261,6 +262,7 @@ pub(crate) fn key_has_definite_resumable_content_in( use crate::agent_sessions::CliSource; match cli { CliSource::Claude => claude_key_has_definite_resumable_content_in(home, key), + CliSource::Codex => true, CliSource::Copilot => copilot_key_has_definite_resumable_content_in(home, key), CliSource::Gemini => gemini_key_has_definite_resumable_content_in(home, key), CliSource::Unknown(_) => true, diff --git a/tools/wta/src/session_registry.rs b/tools/wta/src/session_registry.rs index 53221fe3b..60cd3b658 100644 --- a/tools/wta/src/session_registry.rs +++ b/tools/wta/src/session_registry.rs @@ -439,6 +439,7 @@ impl From<&crate::agent_sessions::CliSource> for SessionHookCliSource { fn from(value: &crate::agent_sessions::CliSource) -> Self { match value { crate::agent_sessions::CliSource::Claude => Self::Known("Claude".to_string()), + crate::agent_sessions::CliSource::Codex => Self::Known("Codex".to_string()), crate::agent_sessions::CliSource::Copilot => Self::Known("Copilot".to_string()), crate::agent_sessions::CliSource::Gemini => Self::Known("Gemini".to_string()), crate::agent_sessions::CliSource::Unknown(value) => Self::Unknown { @@ -453,6 +454,7 @@ impl From for crate::agent_sessions::CliSource { match value { SessionHookCliSource::Known(value) => match value.as_str() { "Claude" | "claude" => Self::Claude, + "Codex" | "codex" => Self::Codex, "Copilot" | "copilot" => Self::Copilot, "Gemini" | "gemini" => Self::Gemini, other => Self::Unknown(other.to_string()), diff --git a/tools/wta/src/ui/agents_view.rs b/tools/wta/src/ui/agents_view.rs index 2ea48a4b7..05deb6a48 100644 --- a/tools/wta/src/ui/agents_view.rs +++ b/tools/wta/src/ui/agents_view.rs @@ -414,6 +414,7 @@ fn cli_suffix_for(s: &AgentSession, selected: bool) -> String { } let label = match s.cli_source { CliSource::Claude => "claude", + CliSource::Codex => "codex", CliSource::Copilot => "copilot", CliSource::Gemini => "gemini", CliSource::Unknown(_) => return String::new(), From 70b5dff346c8e5876f568e95862f7fe7bf01a2cf Mon Sep 17 00:00:00 2001 From: "Yuandi Zhang (from Dev Box)" Date: Thu, 28 May 2026 15:02:37 +0800 Subject: [PATCH 02/34] feat(wta): enable Codex resume via 'codex resume ' subcommand Verified that 'codex resume ' accepts a bare UUID and re-attaches to the on-disk rollout file. Flipping the profile's resume_flag from '' to 'resume' lets the existing command-synthesis template produce a valid command without further changes. Updates docstring + comments that asserted 'false for Codex today' in session_mgmt.rs and app.rs. This commit is intentionally separable from the rest of slice A so it can be reverted independently if downstream issues surface. Refs #80 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/wta/src/agent_registry.rs | 16 +++++++++++++++- tools/wta/src/app.rs | 7 ++++--- tools/wta/src/session_mgmt.rs | 6 +++--- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/tools/wta/src/agent_registry.rs b/tools/wta/src/agent_registry.rs index cc52a0b78..afb35b3b0 100644 --- a/tools/wta/src/agent_registry.rs +++ b/tools/wta/src/agent_registry.rs @@ -123,7 +123,10 @@ pub const KNOWN_AGENTS: &[AgentProfile] = &[ install_hint: "npm install -g @openai/codex", install_url: "https://github.com/openai/codex", auth_check_command: "", - resume_flag: "", + // `codex resume ` is a subcommand (not a flag); + // the command-synthesis template `format!("{cli} {flag} {key}")` + // produces `codex resume ` which Codex CLI accepts. + resume_flag: "resume", auth_hint: "Run: codex auth (or set OPENAI_API_KEY)", }, AgentProfile { @@ -502,4 +505,15 @@ mod tests { assert_eq!(resolve_agent_id_from_cmd("npx"), "unknown"); assert_eq!(resolve_agent_id_from_cmd("my-bot --x"), "unknown"); } + + #[test] + fn codex_profile_advertises_resume_support() { + let profile = lookup_profile_by_id("codex"); + assert_eq!( + profile.resume_flag, "resume", + "Codex CLI uses `codex resume ` (subcommand form, no dash). \ + An empty resume_flag would make session_mgmt classify Codex rows \ + as Class B (not-resumable) and silently break F2 Enter." + ); + } } diff --git a/tools/wta/src/app.rs b/tools/wta/src/app.rs index c9cb1d491..3d8713a94 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -2151,8 +2151,9 @@ impl App { decide_enter_action, liveness_from_status, EnterAction, NotResumableReason, RowSnapshot, }; // Ambient: load_session capability is set during ACP init; - // resume-flag support is a per-CLI profile constant (false for - // Codex today; true for Claude/Copilot/Gemini). + // resume-flag support is a per-CLI profile constant — true for + // Claude / Codex / Copilot / Gemini (all four CLIs accept some + // form of `--resume`/`resume ` re-attach surface). let cli_supports_resume_flag = match s.cli_source { crate::agent_sessions::CliSource::Unknown(_) => false, ref known => { @@ -2339,7 +2340,7 @@ impl App { /// Open a new WT tab whose primary pane runs ` /// ` to rehydrate a Historical/Ended agent session from /// the CLI's on-disk session store. Silent no-op for CLIs without a - /// resume flag (Codex today) or unknown CLI sources. + /// resume flag or unknown CLI sources. /// /// Flow: /// 1. Apply `ResumeDispatched` synchronously so a rapid second Enter diff --git a/tools/wta/src/session_mgmt.rs b/tools/wta/src/session_mgmt.rs index 487661916..0fd91052a 100644 --- a/tools/wta/src/session_mgmt.rs +++ b/tools/wta/src/session_mgmt.rs @@ -78,8 +78,7 @@ pub enum NotResumableReason { /// Wanted `ResumeInAgentPane` but the connected agent didn't /// advertise the `loadSession` capability. LoadSessionNotSupported, - /// Wanted `ResumeCliFlag` but the CLI has no `--resume`-style flag - /// (Codex today). + /// Wanted `ResumeCliFlag` but the CLI has no `--resume`-style flag. CliHasNoResumeFlag, /// `CliSource::Unknown(_)` — we don't know how to spawn the CLI, so /// neither dead-row path applies. @@ -123,7 +122,8 @@ pub struct RowSnapshot { /// ACP) advertised the `loadSession` capability at initialize. pub load_session_supported: bool, /// Whether the CLI has a `--resume`-style flag. True for - /// Claude/Copilot/Gemini, false for Codex. + /// Claude/Copilot/Codex/Gemini (all four CLIs accept some form of + /// `--resume`/`resume ` re-attach surface). pub cli_supports_resume_flag: bool, } From 34491e330eee930cae2026c3c5182287cfe6731b Mon Sep 17 00:00:00 2001 From: "Yuandi Zhang (from Dev Box)" Date: Thu, 28 May 2026 15:18:24 +0800 Subject: [PATCH 03/34] feat(wta): scan ~/.codex/sessions for historical rollouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks YYYY/MM/DD/rollout-*.jsonl, parses session_meta header for session id + cwd + payload.timestamp (used as last_activity_at for deterministic sort), extracts the first event_msg/user_message payload — with a response_item user-role fallback that skips the synthetic wrapper — as the title, and skips phantom files (header-only sessions or sessions with no user/assistant exchange past the env-context boilerplate). Phantom scanner uses stream_jsonl_lines so it's both capped and conservative on IO error (true preserves the row). Wired into load_all so F2 picks Codex history up alongside Claude/Copilot/Gemini. Refs #80 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/wta/src/history_loader.rs | 433 ++++++++++++++++++++++++++++++++ 1 file changed, 433 insertions(+) diff --git a/tools/wta/src/history_loader.rs b/tools/wta/src/history_loader.rs index 805d17e77..37472c0db 100644 --- a/tools/wta/src/history_loader.rs +++ b/tools/wta/src/history_loader.rs @@ -71,6 +71,7 @@ pub fn load_all() -> Vec { out.extend(take_n(load_copilot(&home), MAX_PER_CLI)); out.extend(take_n(load_claude(&home), MAX_PER_CLI)); out.extend(take_n(load_gemini(&home), MAX_PER_CLI)); + out.extend(take_n(load_codex(&home), MAX_PER_CLI)); // Stamp `origin: AgentPane` on rows whose session id was recorded in // the local agent-pane index. Loaded once and applied as a join so the // per-CLI scanners stay agnostic of how the index is shaped or where @@ -618,6 +619,254 @@ fn is_gemini_session_file(p: &Path) -> bool { name.ends_with(".jsonl") } +// ─── Codex ────────────────────────────────────────────────────────────── + +fn load_codex(home: &Path) -> Vec { + let root = home.join(".codex").join("sessions"); + let mut out: Vec = Vec::new(); + let Ok(years) = fs::read_dir(&root) else { return out }; + for y in years.flatten() { + let Ok(months) = fs::read_dir(y.path()) else { continue }; + for m in months.flatten() { + let Ok(days) = fs::read_dir(m.path()) else { continue }; + for d in days.flatten() { + let Ok(files) = fs::read_dir(d.path()) else { continue }; + for f in files.flatten() { + let path = f.path(); + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { continue }; + if !name.starts_with("rollout-") || !name.ends_with(".jsonl") { continue; } + if !codex_session_has_real_content(&path) { continue; } + let Some(meta) = read_codex_session_meta(&path) else { continue; }; + let title = codex_title_from_file(&path) + .unwrap_or_else(|| short_id(&meta.id, "codex")); + let last_activity_at = meta.timestamp + .or_else(|| fs::metadata(&path).and_then(|m| m.modified()).ok()) + .unwrap_or_else(SystemTime::now); + out.push(AgentSession { + key: meta.id, + cli_source: CliSource::Codex, + pane_session_id: None, + window_id: None, + tab_id: None, + title, + cwd: meta.cwd, + started_at: last_activity_at, + last_activity_at, + status: AgentStatus::Historical, + last_error: None, + current_tool: None, + attention_reason: None, + log_path: Some(path), + origin: crate::agent_sessions::SessionOrigin::default(), + }); + } + } + } + } + out.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at)); + out +} + +struct CodexSessionMeta { + id: String, + cwd: PathBuf, + timestamp: Option, +} + +fn read_codex_session_meta(path: &Path) -> Option { + use std::io::BufRead; + let f = fs::File::open(path).ok()?; + let mut reader = std::io::BufReader::new(f); + let mut line = String::new(); + reader.read_line(&mut line).ok()?; + let v: serde_json::Value = serde_json::from_str(line.trim()).ok()?; + if v.get("type")?.as_str()? != "session_meta" { return None; } + let payload = v.get("payload")?; + let ts_str = payload.get("timestamp").and_then(|s| s.as_str()); + Some(CodexSessionMeta { + id: payload.get("id")?.as_str()?.to_string(), + cwd: PathBuf::from(payload.get("cwd")?.as_str()?), + timestamp: ts_str.and_then(parse_iso_to_systemtime), + }) +} + +fn codex_session_has_real_content(path: &Path) -> bool { + let Some(lines) = stream_jsonl_lines(path, CLASSIFY_SCAN_BYTES_CAP) else { + return true; // conservative on IO error + }; + for line in lines { + let Ok(v) = serde_json::from_str::(&line) else { continue }; + let ty = v.get("type").and_then(|s| s.as_str()).unwrap_or(""); + match ty { + "event_msg" => { + let pty = v.get("payload") + .and_then(|p| p.get("type")) + .and_then(|s| s.as_str()) + .unwrap_or(""); + if matches!(pty, "user_message" | "agent_message") { return true; } + } + "response_item" => { + let Some(payload) = v.get("payload") else { continue }; + let role = payload.get("role").and_then(|s| s.as_str()).unwrap_or(""); + if role == "assistant" { return true; } + if role == "user" { + let text = payload.get("content") + .and_then(|c| c.get(0)) + .and_then(|c0| c0.get("text")) + .and_then(|s| s.as_str()) + .unwrap_or(""); + if !text.starts_with("") { return true; } + } + } + _ => {} + } + } + false +} + +fn codex_title_from_file(path: &Path) -> Option { + let lines = stream_jsonl_lines(path, CLASSIFY_SCAN_BYTES_CAP)?; + for line in lines { + let Ok(v) = serde_json::from_str::(&line) else { continue }; + let ty = v.get("type").and_then(|s| s.as_str()).unwrap_or(""); + match ty { + "event_msg" => { + let Some(payload) = v.get("payload") else { continue }; + let pty = payload.get("type").and_then(|s| s.as_str()).unwrap_or(""); + if pty == "user_message" { + let msg = payload.get("message").and_then(|s| s.as_str()).unwrap_or(""); + let title = first_nonblank_line(msg); + if !title.is_empty() { return Some(title); } + } + } + "response_item" => { + let Some(payload) = v.get("payload") else { continue }; + let role = payload.get("role").and_then(|s| s.as_str()).unwrap_or(""); + if role == "user" { + let text = payload.get("content") + .and_then(|c| c.get(0)) + .and_then(|c0| c0.get("text")) + .and_then(|s| s.as_str()) + .unwrap_or(""); + if !text.starts_with("") { + let title = first_nonblank_line(text); + if !title.is_empty() { return Some(title); } + } + } + } + _ => {} + } + } + None +} + +fn first_nonblank_line(raw: &str) -> String { + raw.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().to_string() +} + +/// Parse a subset of ISO 8601 timestamps into `SystemTime`. +/// Handles: `YYYY-MM-DDTHH:MM:SSZ` and `YYYY-MM-DDTHH:MM:SS.fffZ` +/// (the shapes Codex session_meta emits). +fn parse_iso_to_systemtime(s: &str) -> Option { + let s = s.trim(); + + // Detect and parse timezone offset (+HH:MM or -HH:MM, or Z for UTC) + let offset_seconds = if s.ends_with('Z') { + 0 + } else if s.len() >= 25 { + // Check if last 6 characters match ±HH:MM pattern + let offset_part = s.get(s.len()-6..)?; + if let Some(sign_idx) = offset_part.rfind(|c| c == '+' || c == '-') { + if sign_idx == 0 { + // Parse HH:MM + let hm = offset_part.get(1..)?; + if hm.len() == 5 && hm.chars().nth(2) == Some(':') { + let hh: i32 = hm.get(..2)?.parse().ok()?; + let mm: i32 = hm.get(3..)?.parse().ok()?; + let total_seconds = hh * 3600 + mm * 60; + if offset_part.starts_with('-') { -total_seconds } else { total_seconds } + } else { + return None; + } + } else { + 0 + } + } else { + 0 + } + } else { + 0 + }; + + // Determine the core portion to parse (strip Z or offset) + let core = if s.ends_with('Z') { + s.strip_suffix('Z')? + } else if offset_seconds != 0 && s.len() >= 6 { + s.get(..s.len()-6)? + } else { + s.get(..19)? + }; + + // Split at 'T' → date + time + let (date_part, time_part) = core.split_once('T')?; + let mut date_iter = date_part.split('-'); + let year: u64 = date_iter.next()?.parse().ok()?; + let month: u64 = date_iter.next()?.parse().ok()?; + let day: u64 = date_iter.next()?.parse().ok()?; + let time_no_frac = time_part.split('.').next().unwrap_or(time_part); + let mut time_iter = time_no_frac.split(':'); + let hour: u64 = time_iter.next()?.parse().ok()?; + let min: u64 = time_iter.next()?.parse().ok()?; + let sec: u64 = time_iter.next()?.parse().ok()?; + + // Pre-1970 underflow check + if year < 1970 { + return None; + } + + // Validate hour/min/sec bounds + if hour > 23 || min > 59 || sec > 59 { + return None; + } + + // Convert to Unix timestamp (simplified — no leap seconds). + // Days from year 0 to start of `year`, then add months+day. + fn days_before_year(y: u64) -> u64 { + let y = y - 1; + 365 * y + y / 4 - y / 100 + y / 400 + } + fn is_leap(y: u64) -> bool { + y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) + } + let days_in_month: [u64; 12] = [31, if is_leap(year) { 29 } else { 28 }, + 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + + // Validate month bounds + if month < 1 || month > 12 { + return None; + } + + // Validate day bounds + let days_in_current_month = days_in_month[(month - 1) as usize]; + if day < 1 || day > days_in_current_month { + return None; + } + + let mut total_days = days_before_year(year) - days_before_year(1970); + for i in 0..(month - 1) as usize { + total_days += days_in_month[i]; + } + total_days += day - 1; + let mut secs = (total_days * 86400 + hour * 3600 + min * 60 + sec) as i64; + // Subtract offset to convert from local time to UTC + secs -= offset_seconds as i64; + + if secs < 0 { + return None; + } + Some(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs as u64)) +} + // ─── Helpers ──────────────────────────────────────────────────────────── fn short_id(id: &str, cli: &str) -> String { @@ -1864,4 +2113,188 @@ mod tests { assert!(v[1].last_activity_at >= v[2].last_activity_at); let _ = fs::remove_dir_all(&home); } + + // ─── Codex tests ──────────────────────────────────────────────────── + + fn codex_session_path(home: &Path, yyyy: &str, mm: &str, dd: &str, iso: &str, id: &str) -> PathBuf { + let dir = home.join(".codex").join("sessions").join(yyyy).join(mm).join(dd); + fs::create_dir_all(&dir).unwrap(); + dir.join(format!("rollout-{}-{}.jsonl", iso, id)) + } + + fn codex_meta_line(id: &str, ts: &str, cwd: &str) -> String { + format!( + "{{\"timestamp\":\"{ts}\",\"type\":\"session_meta\",\ +\"payload\":{{\"id\":\"{id}\",\"timestamp\":\"{ts}\",\"cwd\":\"{cwd}\",\ +\"originator\":\"codex-tui\",\"cli_version\":\"0.1.0\",\"source\":\"cli\"}}}}\n") + } + + fn codex_user_msg_line(ts: &str, text: &str) -> String { + format!( + "{{\"timestamp\":\"{ts}\",\"type\":\"event_msg\",\ +\"payload\":{{\"type\":\"user_message\",\"message\":\"{text}\"}}}}\n") + } + + #[test] + fn load_codex_returns_one_row_per_real_rollout_file() { + let home = tmp_root("load-codex-basic"); + let id = "11111111-2222-3333-4444-555555555555"; + let path = codex_session_path(&home, "2026", "05", "28", "2026-05-28T10-30-00", id); + let body = codex_meta_line(id, "2026-05-28T10:30:00Z", "C:/work/proj") + + &codex_user_msg_line("2026-05-28T10:30:05Z", "summarize this repo"); + write_file(&path, &body); + let rows = load_codex(&home); + assert_eq!(rows.len(), 1, "expected one row, got {:?}", rows); + let row = &rows[0]; + assert_eq!(row.cli_source, crate::agent_sessions::CliSource::Codex); + assert_eq!(row.key, id, "key must be the rollout UUID"); + assert_eq!(row.cwd, PathBuf::from("C:/work/proj")); + assert!(row.title.contains("summarize this repo")); + let _ = fs::remove_dir_all(&home); + } + + #[test] + fn load_codex_skips_phantom_meta_only_files() { + let home = tmp_root("load-codex-phantom"); + let id = "deadbeef-2222-3333-4444-555555555555"; + let path = codex_session_path(&home, "2026", "05", "28", "2026-05-28T11-00-00", id); + write_file(&path, &codex_meta_line(id, "2026-05-28T11:00:00Z", "C:/x")); + assert_eq!(load_codex(&home).len(), 0, "phantom (meta-only) must be filtered out"); + let _ = fs::remove_dir_all(&home); + } + + #[test] + fn load_codex_skips_phantom_meta_plus_env_context_only() { + let home = tmp_root("load-codex-env-only"); + let id = "deadbeef-3333-3333-3333-333333333333"; + let path = codex_session_path(&home, "2026", "05", "28", "2026-05-28T11-30-00", id); + let env_line = format!( + "{{\"type\":\"response_item\",\"payload\":{{\"role\":\"user\",\ +\"content\":[{{\"text\":\"cwd=C:/x\"}}]}}}}\n"); + write_file(&path, &(codex_meta_line(id, "2026-05-28T11:30:00Z", "C:/x") + &env_line)); + assert_eq!(load_codex(&home).len(), 0, + "meta + environment_context wrapper alone must be classified phantom"); + let _ = fs::remove_dir_all(&home); + } + + #[test] + fn load_codex_orders_newest_first_by_payload_timestamp() { + let home = tmp_root("load-codex-order"); + for (i, ts) in [ + (0u32, "2026-05-28T10:00:00Z"), + (1u32, "2026-05-28T10:05:00Z"), + (2u32, "2026-05-28T10:10:00Z"), + ] { + let id = format!("aaaaaaaa-{:04}-3333-4444-555555555555", i); + let iso = ts.replace(':', "-").trim_end_matches('Z').to_string(); + let path = codex_session_path(&home, "2026", "05", "28", &iso, &id); + write_file(&path, + &(codex_meta_line(&id, ts, "C:/x") + + &codex_user_msg_line(ts, &format!("prompt {i}")))); + } + let rows = load_codex(&home); + assert_eq!(rows.len(), 3); + assert!(rows[0].title.contains("prompt 2"), + "newest first; got titles {:?}", + rows.iter().map(|r| &r.title).collect::>()); + let _ = fs::remove_dir_all(&home); + } + + #[test] + fn codex_session_has_real_content_is_conservative_on_io_error() { + let nowhere = PathBuf::from("Z:/definitely/does/not/exist.jsonl"); + assert!(codex_session_has_real_content(&nowhere), + "must default to true when the file can't be opened"); + } + + #[test] + fn codex_session_has_real_content_detects_user_message() { + let home = tmp_root("codex-scan-user"); + let id = "abcd0001-2222-3333-4444-555555555555"; + let path = codex_session_path(&home, "2026", "05", "28", "2026-05-28T12-00-00", id); + write_file(&path, + &(codex_meta_line(id, "2026-05-28T12:00:00Z", "C:/x") + + &codex_user_msg_line("2026-05-28T12:00:05Z", "hi"))); + assert!(codex_session_has_real_content(&path)); + let _ = fs::remove_dir_all(&home); + } + + #[test] + fn codex_session_has_real_content_detects_agent_message() { + let home = tmp_root("codex-scan-agent"); + let id = "abcd0002-2222-3333-4444-555555555555"; + let path = codex_session_path(&home, "2026", "05", "28", "2026-05-28T12-30-00", id); + let agent_line = "{\"type\":\"event_msg\",\"payload\":{\"type\":\"agent_message\",\"message\":\"ok\"}}\n"; + write_file(&path, + &(codex_meta_line(id, "2026-05-28T12:30:00Z", "C:/x") + agent_line)); + assert!(codex_session_has_real_content(&path)); + let _ = fs::remove_dir_all(&home); + } + + #[test] + fn codex_title_falls_back_to_response_item_user_skipping_env_context() { + let home = tmp_root("codex-title-fallback"); + let id = "abcdef00-3333-3333-3333-333333333333"; + let path = codex_session_path(&home, "2026", "05", "28", "2026-05-28T13-00-00", id); + let env = format!( + "{{\"type\":\"response_item\",\"payload\":{{\"role\":\"user\",\ +\"content\":[{{\"text\":\"cwd=C:/x\"}}]}}}}\n"); + let real = format!( + "{{\"type\":\"response_item\",\"payload\":{{\"role\":\"user\",\ +\"content\":[{{\"text\":\"refactor the parser\"}}]}}}}\n"); + write_file(&path, &(codex_meta_line(id, "2026-05-28T13:00:00Z", "C:/x") + &env + &real)); + let rows = load_codex(&home); + assert_eq!(rows.len(), 1); + assert!(rows[0].title.contains("refactor the parser"), + "got title: {:?}", rows[0].title); + let _ = fs::remove_dir_all(&home); + } + + #[test] + fn parse_iso_handles_positive_offset() { + // 2026-05-27T10:53:09+08:00 is 2026-05-27T02:53:09Z + let t1 = parse_iso_to_systemtime("2026-05-27T10:53:09+08:00").unwrap(); + let t2 = parse_iso_to_systemtime("2026-05-27T02:53:09Z").unwrap(); + assert_eq!(t1, t2); + } + + #[test] + fn parse_iso_handles_negative_offset() { + // 2026-05-27T02:53:09-05:00 is 2026-05-27T07:53:09Z + let t1 = parse_iso_to_systemtime("2026-05-27T02:53:09-05:00").unwrap(); + let t2 = parse_iso_to_systemtime("2026-05-27T07:53:09Z").unwrap(); + assert_eq!(t1, t2); + } + + #[test] + fn parse_iso_rejects_pre_1970_years() { + assert!(parse_iso_to_systemtime("1969-12-31T23:59:59Z").is_none()); + } + + #[test] + fn parse_iso_rejects_invalid_month() { + assert!(parse_iso_to_systemtime("2026-13-01T00:00:00Z").is_none()); + assert!(parse_iso_to_systemtime("2026-00-01T00:00:00Z").is_none()); + } + + #[test] + fn parse_iso_rejects_invalid_day_for_month() { + assert!(parse_iso_to_systemtime("2026-02-30T00:00:00Z").is_none()); + assert!(parse_iso_to_systemtime("2026-05-32T00:00:00Z").is_none()); + assert!(parse_iso_to_systemtime("2026-04-31T00:00:00Z").is_none()); // April has 30 + } + + #[test] + fn parse_iso_rejects_invalid_time_components() { + assert!(parse_iso_to_systemtime("2026-05-28T25:30:00Z").is_none()); + assert!(parse_iso_to_systemtime("2026-05-28T10:60:00Z").is_none()); + assert!(parse_iso_to_systemtime("2026-05-28T10:30:60Z").is_none()); + } + + #[test] + fn parse_iso_accepts_feb_29_leap_year() { + // 2024 IS a leap year; 2023 is not. + assert!(parse_iso_to_systemtime("2024-02-29T00:00:00Z").is_some()); + assert!(parse_iso_to_systemtime("2023-02-29T00:00:00Z").is_none()); + } } From 86a040adfb058de3df92a34ecc0f1ed43e6c8308 Mon Sep 17 00:00:00 2001 From: "Yuandi Zhang (from Dev Box)" Date: Thu, 28 May 2026 15:38:15 +0800 Subject: [PATCH 04/34] feat(wta): wire codex_title_for_key into lookup_title_for_session Lets live Codex rows whose title arrives later (e.g. via a SetTitle hook event in a future slice, or a re-scan after restart) pick up the same first-user-message extraction the historical loader uses. Refs #80 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/wta/src/history_loader.rs | 69 ++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/tools/wta/src/history_loader.rs b/tools/wta/src/history_loader.rs index 37472c0db..ca56abb3d 100644 --- a/tools/wta/src/history_loader.rs +++ b/tools/wta/src/history_loader.rs @@ -100,7 +100,8 @@ pub fn lookup_title_for_session(cli: CliSource, key: &str) -> Option { CliSource::Copilot => copilot_title_for_key(&home, key), CliSource::Claude => claude_title_for_key(&home, key), CliSource::Gemini => gemini_title_for_key(&home, key), - _ => None, + CliSource::Codex => codex_title_for_key(&home, key), + CliSource::Unknown(_) => None, } } @@ -764,6 +765,48 @@ fn first_nonblank_line(raw: &str) -> String { raw.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().to_string() } +pub fn codex_title_for_key(home: &Path, key: &str) -> Option { + let path = find_codex_rollout_by_id(home, key)?; + codex_title_from_file(&path) +} + +/// Locate the rollout file for a given session UUID. +/// +/// Defensive walking: only an unreadable ROOT (`~/.codex/sessions`) returns +/// None. Subtree errors (an unreadable year / month / day directory) +/// `continue` so the search proceeds across siblings — same contract as +/// `load_codex`. +/// +/// The filename suffix `.jsonl` is a fast pre-filter; we still verify +/// `payload.id == id` to guard against renamed files or UUID-prefix +/// collisions. +fn find_codex_rollout_by_id(home: &Path, id: &str) -> Option { + let root = home.join(".codex").join("sessions"); + let Ok(years) = fs::read_dir(&root) else { return None }; + for y in years.flatten() { + let Ok(months) = fs::read_dir(y.path()) else { continue }; + for m in months.flatten() { + let Ok(days) = fs::read_dir(m.path()) else { continue }; + for d in days.flatten() { + let Ok(files) = fs::read_dir(d.path()) else { continue }; + for f in files.flatten() { + let p = f.path(); + let Some(name) = p.file_name().and_then(|s| s.to_str()) else { continue }; + if !(name.starts_with("rollout-") && name.ends_with(&format!("-{}.jsonl", id))) { + continue; + } + if let Some(meta) = read_codex_session_meta(&p) { + if meta.id == id { + return Some(p); + } + } + } + } + } + } + None +} + /// Parse a subset of ISO 8601 timestamps into `SystemTime`. /// Handles: `YYYY-MM-DDTHH:MM:SSZ` and `YYYY-MM-DDTHH:MM:SS.fffZ` /// (the shapes Codex session_meta emits). @@ -2250,6 +2293,30 @@ mod tests { let _ = fs::remove_dir_all(&home); } + #[test] + fn codex_title_for_key_finds_user_message() { + let home = tmp_root("codex-title-by-key"); + let dir = home.join(".codex").join("sessions").join("2026").join("05").join("28"); + fs::create_dir_all(&dir).unwrap(); + let id = "cafebabe-1111-2222-3333-444444444444"; + let path = dir.join(format!("rollout-2026-05-28T12-00-00-{}.jsonl", id)); + write_file(&path, + &format!("{{\"timestamp\":\"2026-05-28T12:00:00Z\",\"type\":\"session_meta\",\ +\"payload\":{{\"id\":\"{id}\",\"timestamp\":\"2026-05-28T12:00:00Z\",\ +\"cwd\":\"C:/x\",\"originator\":\"codex-tui\",\"cli_version\":\"0.1.0\",\"source\":\"cli\"}}}}\n\ +{{\"timestamp\":\"2026-05-28T12:00:05Z\",\"type\":\"event_msg\",\ +\"payload\":{{\"type\":\"user_message\",\"message\":\"refactor the parser\"}}}}\n")); + assert_eq!(codex_title_for_key(&home, id).as_deref(), Some("refactor the parser")); + fs::remove_dir_all(&home).ok(); + } + + #[test] + fn codex_title_for_key_returns_none_for_unknown_id() { + let home = tmp_root("codex-title-missing"); + assert_eq!(codex_title_for_key(&home, "no-such-id"), None); + fs::remove_dir_all(&home).ok(); + } + #[test] fn parse_iso_handles_positive_offset() { // 2026-05-27T10:53:09+08:00 is 2026-05-27T02:53:09Z From 111401bd6f9d5f7fa1f475f1b41caec40bb9575e Mon Sep 17 00:00:00 2001 From: "Yuandi Zhang (from Dev Box)" Date: Thu, 28 May 2026 15:46:08 +0800 Subject: [PATCH 05/34] feat(wta): codex lenient + strict resumability probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires codex_key_is_resumable_on_disk_in (lenient — missing artefact defers to CLI) and codex_key_has_definite_resumable_content_in (strict — missing artefact reports phantom) into the public match-based dispatchers. The shared codex_session_has_real_content scanner landed in the previous commit. Refs #80 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/wta/src/history_loader.rs | 74 +++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/tools/wta/src/history_loader.rs b/tools/wta/src/history_loader.rs index ca56abb3d..68e1200bf 100644 --- a/tools/wta/src/history_loader.rs +++ b/tools/wta/src/history_loader.rs @@ -219,7 +219,7 @@ pub(crate) fn key_is_resumable_on_disk_in( use crate::agent_sessions::CliSource; match cli { CliSource::Claude => claude_key_is_resumable_on_disk_in(home, key), - CliSource::Codex => true, + CliSource::Codex => codex_key_is_resumable_on_disk_in(home, key), CliSource::Copilot => copilot_key_is_resumable_on_disk_in(home, key), CliSource::Gemini => gemini_key_is_resumable_on_disk_in(home, key), CliSource::Unknown(_) => true, @@ -264,7 +264,7 @@ pub(crate) fn key_has_definite_resumable_content_in( use crate::agent_sessions::CliSource; match cli { CliSource::Claude => claude_key_has_definite_resumable_content_in(home, key), - CliSource::Codex => true, + CliSource::Codex => codex_key_has_definite_resumable_content_in(home, key), CliSource::Copilot => copilot_key_has_definite_resumable_content_in(home, key), CliSource::Gemini => gemini_key_has_definite_resumable_content_in(home, key), CliSource::Unknown(_) => true, @@ -401,6 +401,22 @@ pub(crate) fn gemini_jsonl_has_real_content(path: &Path) -> bool { false } +// ─── Codex per-key helpers ────────────────────────────────────────────── + +fn codex_key_is_resumable_on_disk_in(home: &Path, id: &str) -> bool { + match find_codex_rollout_by_id(home, id) { + None => true, + Some(path) => codex_session_has_real_content(&path), + } +} + +fn codex_key_has_definite_resumable_content_in(home: &Path, id: &str) -> bool { + match find_codex_rollout_by_id(home, id) { + None => false, + Some(path) => codex_session_has_real_content(&path), + } +} + // ─── Copilot ──────────────────────────────────────────────────────────── fn load_copilot(home: &Path) -> Vec { @@ -1786,7 +1802,7 @@ mod tests { // in-memory rows / test fixtures aren't blocked preemptively. use crate::agent_sessions::CliSource; let home = tmp_root("resumable-missing-all-clis"); - for cli in [CliSource::Claude, CliSource::Copilot, CliSource::Gemini] { + for cli in [CliSource::Claude, CliSource::Codex, CliSource::Copilot, CliSource::Gemini] { assert!( key_is_resumable_on_disk_in(&home, &cli, "no-such-id"), "{:?} should defer to CLI when on-disk artefact is missing", @@ -1927,7 +1943,7 @@ mod tests { // row stuck Ended in F2. use crate::agent_sessions::CliSource; let home = tmp_root("strict-probe-missing"); - for cli in [CliSource::Claude, CliSource::Copilot, CliSource::Gemini] { + for cli in [CliSource::Claude, CliSource::Codex, CliSource::Copilot, CliSource::Gemini] { assert!( !key_has_definite_resumable_content_in(&home, &cli, "no-such-id"), "{:?} strict probe must report phantom when artefact is missing", @@ -2293,6 +2309,56 @@ mod tests { let _ = fs::remove_dir_all(&home); } + #[test] + fn codex_key_resumable_returns_true_when_artefact_missing() { + use crate::agent_sessions::CliSource; + let home = tmp_root("codex-resumable-missing"); + // Lenient probe: missing on-disk artefact defers to CLI (true) + // so fresh in-memory rows aren't blocked preemptively. + assert!(key_is_resumable_on_disk_in(&home, &CliSource::Codex, "no-such-id")); + let _ = fs::remove_dir_all(&home); + } + + #[test] + fn codex_key_resumable_returns_false_for_meta_only_jsonl() { + use crate::agent_sessions::CliSource; + let home = tmp_root("codex-resumable-phantom"); + let id = "ffffffff-2222-3333-4444-555555555555"; + // Build the meta-only file inline. The path shape is: + // home/.codex/sessions/2026/05/28/rollout-2026-05-28T10-00-00-.jsonl + let dir = home.join(".codex").join("sessions").join("2026").join("05").join("28"); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("rollout-2026-05-28T10-00-00-{}.jsonl", id)); + let meta = format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"timestamp\":\"2026-05-28T10:00:00Z\",\"cwd\":\"C:/x\",\"originator\":\"codex-tui\",\"cli_version\":\"0.1.0\",\"source\":\"cli\"}}}}\n"); + fs::write(&path, meta).unwrap(); + assert!(!key_is_resumable_on_disk_in(&home, &CliSource::Codex, id)); + let _ = fs::remove_dir_all(&home); + } + + #[test] + fn codex_key_resumable_returns_true_for_jsonl_with_user_message() { + use crate::agent_sessions::CliSource; + let home = tmp_root("codex-resumable-real"); + let id = "abcdef00-2222-3333-4444-555555555555"; + let dir = home.join(".codex").join("sessions").join("2026").join("05").join("28"); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("rollout-2026-05-28T10-30-00-{}.jsonl", id)); + let content = format!( + "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"timestamp\":\"2026-05-28T10:30:00Z\",\"cwd\":\"C:/x\",\"originator\":\"codex-tui\",\"cli_version\":\"0.1.0\",\"source\":\"cli\"}}}}\n\ +{{\"type\":\"event_msg\",\"payload\":{{\"type\":\"user_message\",\"message\":\"hi\"}}}}\n"); + fs::write(&path, content).unwrap(); + assert!(key_is_resumable_on_disk_in(&home, &CliSource::Codex, id)); + let _ = fs::remove_dir_all(&home); + } + + #[test] + fn codex_strict_probe_returns_false_when_artefact_missing() { + use crate::agent_sessions::CliSource; + let home = tmp_root("codex-strict-missing"); + assert!(!key_has_definite_resumable_content_in(&home, &CliSource::Codex, "no-id")); + let _ = fs::remove_dir_all(&home); + } + #[test] fn codex_title_for_key_finds_user_message() { let home = tmp_root("codex-title-by-key"); From c160795bc5dd1a7b6b0ade94b19395bfd2dc1a86 Mon Sep 17 00:00:00 2001 From: "Yuandi Zhang (from Dev Box)" Date: Thu, 28 May 2026 15:54:40 +0800 Subject: [PATCH 06/34] refactor(wta): centralize CliSource -> cli_id via known_cli_id helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces four near-identical inline matches in app.rs with a single known_cli_id(&CliSource) -> Option<&'static str> helper that covers Codex (added in this slice). Each call-site continues to handle the None case with its own current semantics — bool false at the resume_flag site, the literal 'this CLI' string at both user-visible-message sites, and the existing log-and-early-return at dispatch_resume. Refs #80 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/wta/src/app.rs | 75 ++++++++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 34 deletions(-) diff --git a/tools/wta/src/app.rs b/tools/wta/src/app.rs index 3d8713a94..4b3f71154 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -1803,6 +1803,22 @@ impl Default for HistoryLoadState { } } +/// Reverse of `CliSource::from_agent_id` — yields the lowercase CLI id +/// used by the command-synthesis template and dispatch routing. +/// Returns `None` for `CliSource::Unknown(_)` so each call-site retains +/// its current Unknown-handling semantics (display fallback / bool +/// false / early return — they differ). +pub(crate) fn known_cli_id(src: &crate::agent_sessions::CliSource) -> Option<&'static str> { + use crate::agent_sessions::CliSource; + match src { + CliSource::Claude => Some("claude"), + CliSource::Codex => Some("codex"), + CliSource::Copilot => Some("copilot"), + CliSource::Gemini => Some("gemini"), + CliSource::Unknown(_) => None, + } +} + pub(crate) fn session_info_to_agent_session( info: &crate::session_registry::SessionInfo, ) -> crate::agent_sessions::AgentSession { @@ -2154,20 +2170,11 @@ impl App { // resume-flag support is a per-CLI profile constant — true for // Claude / Codex / Copilot / Gemini (all four CLIs accept some // form of `--resume`/`resume ` re-attach surface). - let cli_supports_resume_flag = match s.cli_source { - crate::agent_sessions::CliSource::Unknown(_) => false, - ref known => { - let id = match known { - crate::agent_sessions::CliSource::Claude => "claude", - crate::agent_sessions::CliSource::Codex => "codex", - crate::agent_sessions::CliSource::Copilot => "copilot", - crate::agent_sessions::CliSource::Gemini => "gemini", - crate::agent_sessions::CliSource::Unknown(_) => unreachable!(), - }; - !crate::agent_registry::lookup_profile_by_id(id) - .resume_flag - .is_empty() - } + let cli_supports_resume_flag = match known_cli_id(&s.cli_source) { + Some(id) => !crate::agent_registry::lookup_profile_by_id(id) + .resume_flag + .is_empty(), + None => false, }; let row = RowSnapshot { origin: s.origin.clone(), @@ -2211,13 +2218,7 @@ impl App { // Surface a user-visible system message scoped to the // current tab so the user can read it from the // Agents view (which is rendered in-tab). - let cli_id = match s.cli_source { - crate::agent_sessions::CliSource::Claude => "claude", - crate::agent_sessions::CliSource::Codex => "codex", - crate::agent_sessions::CliSource::Copilot => "copilot", - crate::agent_sessions::CliSource::Gemini => "gemini", - crate::agent_sessions::CliSource::Unknown(_) => "this CLI", - }; + let cli_id = known_cli_id(&s.cli_source).unwrap_or("this CLI"); let msg = match reason { NotResumableReason::LiveWithoutPane => format!( "Cannot focus session {}: it appears live but no \ @@ -2360,12 +2361,9 @@ impl App { /// (Gemini), allowing a later `PaneClosed` to transition the /// row back to Ended. fn dispatch_resume(&mut self, s: &crate::agent_sessions::AgentSession) { - let cli_id = match s.cli_source { - crate::agent_sessions::CliSource::Claude => "claude", - crate::agent_sessions::CliSource::Codex => "codex", - crate::agent_sessions::CliSource::Copilot => "copilot", - crate::agent_sessions::CliSource::Gemini => "gemini", - crate::agent_sessions::CliSource::Unknown(_) => { + let cli_id = match known_cli_id(&s.cli_source) { + Some(id) => id, + None => { tracing::debug!( target: "agents_view", key = %s.key, @@ -2599,13 +2597,7 @@ impl App { "dispatch_resume_in_agent_pane: refusing to load phantom session; pruning row", ); let short_key: String = s.key.chars().take(8).collect(); - let cli_id = match s.cli_source { - crate::agent_sessions::CliSource::Claude => "claude", - crate::agent_sessions::CliSource::Codex => "codex", - crate::agent_sessions::CliSource::Copilot => "copilot", - crate::agent_sessions::CliSource::Gemini => "gemini", - crate::agent_sessions::CliSource::Unknown(_) => "this CLI", - }; + let cli_id = known_cli_id(&s.cli_source).unwrap_or("this CLI"); let msg = format!( "Cannot resume {} session {}: it was started but never accumulated any \ conversation, so {} would reject the load. Removing the row.", @@ -11777,4 +11769,19 @@ mod tests { None, ); } + + #[test] + fn known_cli_id_returns_some_for_all_first_party_clis() { + use crate::agent_sessions::CliSource; + assert_eq!(known_cli_id(&CliSource::Claude), Some("claude")); + assert_eq!(known_cli_id(&CliSource::Codex), Some("codex")); + assert_eq!(known_cli_id(&CliSource::Copilot), Some("copilot")); + assert_eq!(known_cli_id(&CliSource::Gemini), Some("gemini")); + } + + #[test] + fn known_cli_id_returns_none_for_unknown_variant() { + use crate::agent_sessions::CliSource; + assert_eq!(known_cli_id(&CliSource::Unknown("anything".to_string())), None); + } } From 76494cb5ec7a4b8af893cff8f69f0bcb087e852f Mon Sep 17 00:00:00 2001 From: "Yuandi Zhang (from Dev Box)" Date: Thu, 28 May 2026 16:02:51 +0800 Subject: [PATCH 07/34] test(wta): pin codex wire-format round-trip through SessionHookCliSource Regression tests that CliSource::Codex serializes to SessionHookCliSource::Known("Codex") and that the reverse direction accepts both "Codex" and "codex". Refs #80 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/wta/src/session_registry.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tools/wta/src/session_registry.rs b/tools/wta/src/session_registry.rs index 60cd3b658..8f4ad1a17 100644 --- a/tools/wta/src/session_registry.rs +++ b/tools/wta/src/session_registry.rs @@ -2184,6 +2184,25 @@ mod tests { assert_eq!(parsed, event); } + #[test] + fn session_hook_cli_source_round_trips_codex() { + use crate::agent_sessions::CliSource; + let typed = CliSource::Codex; + let wire: SessionHookCliSource = (&typed).into(); + assert!(matches!(wire, SessionHookCliSource::Known(ref s) if s == "Codex"), + "Codex must serialize to Known(\"Codex\"), got {:?}", wire); + let back: CliSource = wire.into(); + assert_eq!(back, CliSource::Codex); + } + + #[test] + fn session_hook_cli_source_accepts_lowercase_codex() { + use crate::agent_sessions::CliSource; + let wire = SessionHookCliSource::Known("codex".to_string()); + let typed: CliSource = wire.into(); + assert_eq!(typed, CliSource::Codex); + } + #[test] fn parse_session_hook_params_rejects_garbage() { let raw = serde_json::value::RawValue::from_string(r#"{"wrong":"shape"}"#.into()).unwrap(); From de1896e029066baf3bd49031a6840bed90143c10 Mon Sep 17 00:00:00 2001 From: "Yuandi Zhang (from Dev Box)" Date: Thu, 28 May 2026 16:04:44 +0800 Subject: [PATCH 08/34] test(wta): pin codex live-registry fan-in contract Direct regression test that from_agent_id('codex') flowing into the in-memory registry surfaces as CliSource::Codex (not None or Unknown). Catches regressions in either layer. Refs #80 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/wta/src/session_registry.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tools/wta/src/session_registry.rs b/tools/wta/src/session_registry.rs index 8f4ad1a17..4544a03d4 100644 --- a/tools/wta/src/session_registry.rs +++ b/tools/wta/src/session_registry.rs @@ -1998,6 +1998,24 @@ mod tests { "pane binding must stay None after handoff"); } + #[tokio::test] + async fn registry_assigns_codex_cli_source_when_session_started_via_agent_id() { + use crate::agent_sessions::{CliSource, SessionEvent}; + let reg = InMemoryRegistry::new(); + let cli = CliSource::from_agent_id("codex") + .expect("from_agent_id('codex') must yield Some(Codex)"); + let event = SessionEvent::SessionStarted { + key: "codex-fanin-test".to_string(), + cli_source: cli.clone(), + pane_session_id: "p1".to_string(), + cwd: PathBuf::from(r#"C:\x"#), + title: "fan-in test".to_string(), + }; + reg.apply_event(event).await; + let row = reg.lookup(&acp::SessionId::new("codex-fanin-test")).await.expect("row inserted"); + assert_eq!(row.cli_source, Some(CliSource::Codex)); + } + #[test] fn sessions_list_response_round_trips_session_info_with_typed_fields() { let mut info = SessionInfo::new(acp::SessionId::new("sid-1"), PathBuf::from("/repo")); From cae8d7452e9e291f190b02ba66169b767bc00e8a Mon Sep 17 00:00:00 2001 From: "Yuandi Zhang (from Dev Box)" Date: Thu, 28 May 2026 16:12:01 +0800 Subject: [PATCH 09/34] test(wta): pin Codex Class A behavior in session_mgmt cells Adds Codex equivalents of the Claude live/ended/historical Enter + Shift+Enter cells so a regression in either resume_flag or the CliSource exhaustive matches fails this module directly. Refs #80 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/wta/src/session_mgmt.rs | 121 ++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/tools/wta/src/session_mgmt.rs b/tools/wta/src/session_mgmt.rs index 0fd91052a..160007961 100644 --- a/tools/wta/src/session_mgmt.rs +++ b/tools/wta/src/session_mgmt.rs @@ -266,6 +266,39 @@ mod tests { assert_eq!(decide_enter_action(&r, true), decide_enter_action(&r, false)); } + #[test] + fn codex_class_a_live_with_pane_enter_focuses() { + let r = row( + SessionOrigin::AgentPane, + Liveness::Live { + pane_session_id: Some("pane-A".into()), + }, + CliSource::Codex, + true, + true, + ); + assert_eq!( + decide_enter_action(&r, false), + EnterAction::Focus { + pane_session_id: "pane-A".into() + } + ); + } + + #[test] + fn codex_class_a_live_with_pane_shift_same_as_enter() { + let r = row( + SessionOrigin::AgentPane, + Liveness::Live { + pane_session_id: Some("pane-A".into()), + }, + CliSource::Codex, + true, + true, + ); + assert_eq!(decide_enter_action(&r, true), decide_enter_action(&r, false)); + } + #[test] fn class_b_live_with_pane_enter_focuses() { let r = row( @@ -359,6 +392,24 @@ mod tests { ); } + #[test] + fn codex_class_a_ended_enter_resumes_in_agent_pane_when_supported() { + let r = row( + SessionOrigin::AgentPane, + Liveness::Ended, + CliSource::Codex, + true, + true, + ); + assert_eq!( + decide_enter_action(&r, false), + EnterAction::ResumeInAgentPane { + key: "k".into(), + cli: CliSource::Codex + } + ); + } + #[test] fn class_a_ended_enter_not_resumable_when_load_unsupported() { let r = row( @@ -376,6 +427,23 @@ mod tests { ); } + #[test] + fn codex_class_a_ended_enter_not_resumable_when_load_unsupported() { + let r = row( + SessionOrigin::AgentPane, + Liveness::Ended, + CliSource::Codex, + false, // load_session not supported + true, + ); + assert_eq!( + decide_enter_action(&r, false), + EnterAction::NotResumable { + reason: NotResumableReason::LoadSessionNotSupported + } + ); + } + #[test] fn class_a_ended_shift_resumes_via_cli_flag() { let r = row( @@ -394,6 +462,24 @@ mod tests { ); } + #[test] + fn codex_class_a_ended_shift_resumes_via_cli_flag() { + let r = row( + SessionOrigin::AgentPane, + Liveness::Ended, + CliSource::Codex, + true, + true, + ); + assert_eq!( + decide_enter_action(&r, true), + EnterAction::ResumeCliFlag { + key: "k".into(), + cli: CliSource::Codex + } + ); + } + #[test] fn class_a_ended_shift_not_resumable_when_cli_has_no_flag() { let r = row( @@ -411,6 +497,23 @@ mod tests { ); } + #[test] + fn codex_class_a_ended_shift_not_resumable_when_cli_has_no_flag() { + let r = row( + SessionOrigin::AgentPane, + Liveness::Ended, + CliSource::Codex, + true, + false, // no --resume flag + ); + assert_eq!( + decide_enter_action(&r, true), + EnterAction::NotResumable { + reason: NotResumableReason::CliHasNoResumeFlag + } + ); + } + #[test] fn class_a_historical_enter_routes_like_ended() { let r = row( @@ -429,6 +532,24 @@ mod tests { ); } + #[test] + fn codex_class_a_historical_enter_routes_like_ended() { + let r = row( + SessionOrigin::AgentPane, + Liveness::Historical, + CliSource::Codex, + true, + true, + ); + assert_eq!( + decide_enter_action(&r, false), + EnterAction::ResumeInAgentPane { + key: "k".into(), + cli: CliSource::Codex + } + ); + } + // --- Dead rows: Class B ------------------------------------------ #[test] From 5d2ed558a52d3f5c2b49985e380e205f5a12307a Mon Sep 17 00:00:00 2001 From: "Yuandi Zhang (from Dev Box)" Date: Thu, 28 May 2026 16:24:07 +0800 Subject: [PATCH 10/34] feat(wta): show 'codex' suffix on selected Codex rows in F2 list Refs #80 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/wta/src/ui/agents_view.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tools/wta/src/ui/agents_view.rs b/tools/wta/src/ui/agents_view.rs index 05deb6a48..e5f3e4872 100644 --- a/tools/wta/src/ui/agents_view.rs +++ b/tools/wta/src/ui/agents_view.rs @@ -403,7 +403,7 @@ fn badge_style(s: &AgentSession) -> Style { } } -/// Show the CLI provider (`copilot`, `claude`, `gemini`) only on the +/// Show the CLI provider (`claude`, `codex`, `copilot`, `gemini`) only on the /// active row or the keyboard-selected row — matches the Figma where the /// agent icon appears only on the currently-engaged session and avoids /// cluttering the historical list. @@ -805,4 +805,27 @@ mod tests { assert!(s.contains("20"), "expected day in {:?}", s); assert!(s.contains("2026"), "expected year in {:?}", s); } + + #[test] + fn cli_suffix_renders_codex_label_on_selected_row() { + let s = AgentSession { + key: "k".to_string(), + cli_source: CliSource::Codex, + pane_session_id: None, + window_id: None, + tab_id: None, + title: "codex — test".to_string(), + cwd: std::path::PathBuf::from("."), + started_at: SystemTime::now(), + last_activity_at: SystemTime::now(), + status: AgentStatus::Idle, + last_error: None, + current_tool: None, + attention_reason: None, + log_path: None, + origin: SessionOrigin::default(), + }; + assert_eq!(cli_suffix_for(&s, true), "· codex"); + assert_eq!(cli_suffix_for(&s, false), String::new()); + } } From 61daca1b7f897152979b1d8f5693b3a15dabcf86 Mon Sep 17 00:00:00 2001 From: "Yuandi Zhang (from Dev Box)" Date: Thu, 28 May 2026 16:24:49 +0800 Subject: [PATCH 11/34] feat(wta): include Codex in display-name mapping main.rs is a binary target with no test module of its own; this mapping is exhaustive so the compiler enforces correctness when the CliSource enum gains a variant. Refs #80 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/wta/src/main.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/wta/src/main.rs b/tools/wta/src/main.rs index d3d962a32..861e2216d 100644 --- a/tools/wta/src/main.rs +++ b/tools/wta/src/main.rs @@ -1183,9 +1183,10 @@ fn status_label(status: Option<&agent_sessions::AgentStatus>) -> String { fn cli_source_label(source: Option<&agent_sessions::CliSource>) -> String { match source { - Some(agent_sessions::CliSource::Claude) => "Claude".to_string(), + Some(agent_sessions::CliSource::Claude) => "Claude".to_string(), + Some(agent_sessions::CliSource::Codex) => "Codex".to_string(), Some(agent_sessions::CliSource::Copilot) => "Copilot".to_string(), - Some(agent_sessions::CliSource::Gemini) => "Gemini".to_string(), + Some(agent_sessions::CliSource::Gemini) => "Gemini".to_string(), Some(agent_sessions::CliSource::Unknown(s)) if !s.is_empty() => s.clone(), _ => "-".to_string(), } From f3d37dbb2a4c837c13f1ecf905bced28baa8c497 Mon Sep 17 00:00:00 2001 From: "Yuandi Zhang (from Dev Box)" Date: Thu, 28 May 2026 16:27:48 +0800 Subject: [PATCH 12/34] test(wta): add Codex row to populate_demo_data Refs #80 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/wta/src/agent_sessions.rs | 51 ++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/tools/wta/src/agent_sessions.rs b/tools/wta/src/agent_sessions.rs index d4db19acc..d8976e241 100644 --- a/tools/wta/src/agent_sessions.rs +++ b/tools/wta/src/agent_sessions.rs @@ -1058,11 +1058,12 @@ impl AgentSessionRegistry { /// /// Layout (sorted by last_activity_at desc, newest first): /// 1. copilot WORKING — currently running a tool - /// 2. claude ATTENTION — needs user approval - /// 3. gemini IDLE — sitting waiting for input - /// 4. copilot ERROR — connection failed - /// 5. claude ENDED — exited normally a moment ago - /// 6. gemini HISTORICAL — loaded from an old log (no live pane) + /// 2. codex WORKING — running a tool (second active session) + /// 3. claude ATTENTION — needs user approval + /// 4. gemini IDLE — sitting waiting for input + /// 5. copilot ERROR — connection failed + /// 6. claude ENDED — exited normally a moment ago + /// 7. gemini HISTORICAL — loaded from an old log (no live pane) pub fn populate_demo_data(&mut self) { use std::time::Duration; @@ -1082,7 +1083,20 @@ impl AgentSessionRegistry { tool_name: "shell".to_string(), }); - // 2. Attention — claude waiting for tool approval + // 2. Working — codex running a tool concurrently + self.apply(SessionEvent::SessionStarted { + key: "demo-codex-working".to_string(), + cli_source: CliSource::Codex, + pane_session_id: "77777777-7777-7777-7777-777777777777".to_string(), + cwd: cwd.clone(), + title: "codex — implement refactor parser".to_string(), + }); + self.apply(SessionEvent::ToolStarting { + key: "demo-codex-working".to_string(), + tool_name: "shell".to_string(), + }); + + // 3. Attention — claude waiting for tool approval self.apply(SessionEvent::SessionStarted { key: "demo-claude-attention".to_string(), cli_source: CliSource::Claude, @@ -1095,7 +1109,7 @@ impl AgentSessionRegistry { message: "Allow tool: write_file ./src/lib.rs?".to_string(), }); - // 3. Idle — gemini waiting for next prompt + // 4. Idle — gemini waiting for next prompt self.apply(SessionEvent::SessionStarted { key: "demo-gemini-idle".to_string(), cli_source: CliSource::Gemini, @@ -1104,7 +1118,7 @@ impl AgentSessionRegistry { title: "gemini — explain build system".to_string(), }); - // 4. Error — copilot lost network + // 5. Error — copilot lost network self.apply(SessionEvent::SessionStarted { key: "demo-copilot-error".to_string(), cli_source: CliSource::Copilot, @@ -1117,7 +1131,7 @@ impl AgentSessionRegistry { reason: "API request failed: 503 Service Unavailable".to_string(), }); - // 5. Ended — claude finished cleanly a moment ago + // 6. Ended — claude finished cleanly a moment ago self.apply(SessionEvent::SessionStarted { key: "demo-claude-ended".to_string(), cli_source: CliSource::Claude, @@ -1125,7 +1139,7 @@ impl AgentSessionRegistry { cwd: cwd.clone(), title: "claude — review PR diff".to_string(), }); - // 5. Ended — claude finished cleanly a moment ago. Origin is the + // 6. Ended — claude finished cleanly a moment ago. Origin is the // default (Unknown), so SessionStopped takes the original // immediate-Ended path — no PaneClosed needed. self.apply(SessionEvent::SessionStopped { @@ -1133,7 +1147,7 @@ impl AgentSessionRegistry { reason: "end_turn".to_string(), }); - // 6. Historical — loaded from old log, no live pane + // 7. Historical — loaded from old log, no live pane let two_hours_ago = now - Duration::from_secs(2 * 60 * 60); let key = "demo-gemini-historical".to_string(); self.sessions.insert(key.clone(), AgentSession { @@ -1158,6 +1172,7 @@ impl AgentSessionRegistry { // narrative (working newest, historical oldest). let stagger = |secs: u64| now - Duration::from_secs(secs); if let Some(s) = self.sessions.get_mut("demo-copilot-working") { s.last_activity_at = stagger(2); } + if let Some(s) = self.sessions.get_mut("demo-codex-working") { s.last_activity_at = stagger(5); } if let Some(s) = self.sessions.get_mut("demo-claude-attention") { s.last_activity_at = stagger(15); } if let Some(s) = self.sessions.get_mut("demo-gemini-idle") { s.last_activity_at = stagger(45); } if let Some(s) = self.sessions.get_mut("demo-copilot-error") { s.last_activity_at = stagger(120); } @@ -1835,12 +1850,12 @@ mod tests { let mut reg = AgentSessionRegistry::new(); reg.populate_demo_data(); let sessions = reg.iter_sorted(); - assert_eq!(sessions.len(), 6, "demo data should yield exactly 6 sessions"); + assert_eq!(sessions.len(), 7, "demo data should yield exactly 7 sessions"); - // Verify each status appears exactly once. + // Verify each non-Working status appears exactly once; Working appears + // twice (copilot + codex are both running tools concurrently). let statuses: Vec = sessions.iter().map(|s| s.status.clone()).collect(); for st in [ - AgentStatus::Working, AgentStatus::Attention, AgentStatus::Idle, AgentStatus::Error, @@ -1849,12 +1864,16 @@ mod tests { ] { assert_eq!(statuses.iter().filter(|s| **s == st).count(), 1, "expected exactly one {:?}", st); } + assert_eq!( + statuses.iter().filter(|s| **s == AgentStatus::Working).count(), 2, + "expected exactly two Working sessions (copilot + codex)", + ); // Working session must come first (most recent activity). assert_eq!(sessions[0].status, AgentStatus::Working); // Historical session must be last and have no live pane binding. - assert_eq!(sessions[5].status, AgentStatus::Historical); - assert!(sessions[5].pane_session_id.is_none()); + assert_eq!(sessions[6].status, AgentStatus::Historical); + assert!(sessions[6].pane_session_id.is_none()); // Error session must carry the failure reason. let err = sessions.iter().find(|s| s.status == AgentStatus::Error).unwrap(); From cce37a31cbc28745685a4425779f2950e5ad32bc Mon Sep 17 00:00:00 2001 From: "Yuandi Zhang (from Dev Box)" Date: Thu, 28 May 2026 16:37:36 +0800 Subject: [PATCH 13/34] docs(wta): add Codex layout to history_loader header The module-level docstring listed on-disk layouts for Copilot, Claude, and Gemini but omitted Codex. Mirror the existing entries (id, cwd, title, last_activity, phantom rules) so future maintainers see the full set of supported CLIs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/wta/src/history_loader.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tools/wta/src/history_loader.rs b/tools/wta/src/history_loader.rs index 68e1200bf..75a6a6756 100644 --- a/tools/wta/src/history_loader.rs +++ b/tools/wta/src/history_loader.rs @@ -37,6 +37,20 @@ // the row would launch `gemini --resume ` and // dead-end on a similar "no session" rejection. // +// Codex: ~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl +// - session id = first JSONL line `session_meta` payload.id +// - cwd = `session_meta` payload.cwd +// - title = first `event_msg` payload.user_message, +// else first `response_item` role=user content +// (skipping synthetic `` +// prefixes injected by the CLI) +// - last_activity= `session_meta` payload.timestamp (fallback file mtime) +// - skip "phantom" sessions whose jsonl contains only the +// `session_meta` header and/or synthetic +// `` response_items (no real user +// turn). `codex resume ` would reject these as having +// no conversation to resume. +// // (Note: per-subagent JSONL files may live in nested `/` subdirs of // `chats/`. Top-level Gemini sessions are flat files named `session-*.jsonl`. // under `/.json`. We only pick up `session-*.json` at the From 5532ca9df0c6417565c1df7ffbedc2cbc89b7cd0 Mon Sep 17 00:00:00 2001 From: "Yuandi Zhang (from Dev Box)" Date: Fri, 29 May 2026 16:57:22 +0800 Subject: [PATCH 14/34] docs: add Codex hooks slice B design spec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../2026-05-29-codex-hooks-slice-b-design.md | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-29-codex-hooks-slice-b-design.md diff --git a/docs/superpowers/specs/2026-05-29-codex-hooks-slice-b-design.md b/docs/superpowers/specs/2026-05-29-codex-hooks-slice-b-design.md new file mode 100644 index 000000000..d98f7ac72 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-codex-hooks-slice-b-design.md @@ -0,0 +1,223 @@ +# Codex hooks — slice B design + +**Date:** 2026-05-29 +**Branch:** `dev/yuazha/codex-session` +**Builds on:** slice A (read-only Codex session discovery, merged into PR #98) +**Successor:** slice C (l10n, docs, ut_app fixtures, ACP `loadSession`) + +## Goal + +Extend `wta hooks {install,status,uninstall}` to register a **wt-agent-hooks** plugin with the Codex CLI so live Codex hook events flow into the Windows Terminal session-management UI (Ctrl+Shift+/), bringing Codex to parity with Copilot / Claude / Gemini for hook plumbing. Add a fourth "Codex CLI" row to the **AI Agents** settings page so users can install / remove hooks from the GUI. + +## Non-goals (slice C) + +- l10n (.resw) strings for "Codex CLI" display name / status messages +- README.md / CLAUDE.md doc updates mentioning Codex +- `agent_check::install()` adding `winget install OpenAI.Codex` +- ACP `loadSession` support for Shift+Enter in-pane resume +- C++ `ut_app/AgentHooksStatusTests.cpp` fixture rows for Codex +- `STATUS_SCHEMA_VERSION` bump (would require coordinated C++ change) +- Trust-status field in `CliStatus` (Codex requires interactive `/hooks` trust step; surfaced via README in slice C) + +## Architecture + +### Rust: `tools/wta/src/agent_hooks_installer.rs` + +| Existing symbol | Change | +| --- | --- | +| `CliKind` enum | Add `Codex` variant | +| `CliKind::ALL` | Append `CliKind::Codex` | +| `CliKind::name()` | `Codex => "codex"` | +| `CliKind::from_name()` | `"codex" => Codex` | +| `CliKind::dir_name()` | `Codex => "codex"` | +| `install_for_*` dispatch | New `install_for_codex` (mirror of `install_for_claude`) | +| `status_for_*` dispatch | New `status_for_codex` | +| `uninstall_for_*` dispatch | New `uninstall_for_codex` | +| `maybe_stage_bundle_for_claude` | Sibling `maybe_stage_bundle_for_codex` (or parameterize on `CliKind`) | +| `STATUS_SCHEMA_VERSION` | **unchanged (3)** — Codex row uses existing `CliStatus` shape | +| `PLUGIN_NAME` / `MARKETPLACE_NAME` | reused (`wt-agent-hooks` / `wt-local`) | + +#### `install_for_codex(home, opts)` + +1. Skip cleanly when `~/.codex/` is absent (CLI never used on this machine). +2. Resolve bundle dir via `bundle::resolve_cli_dir(CliKind::Codex)` — same lookup chain (env var → exe-sibling → dev-tree). +3. If the bundle resolves under `WindowsApps`, restage to `LOCALAPPDATA\Microsoft\IntelligentTerminal\hook-bundle-staging\codex` (mirror existing Claude workaround in case `codex plugin add` Rust-side copy has any similar issue; harmless if not). +4. Spawn `codex plugin marketplace add ` — registers the `wt-local` marketplace. +5. Spawn `codex plugin add wt-agent-hooks@wt-local` — installs the plugin into Codex's config. + +Both commands run with stdin closed and 30-second timeout. Output captured to tracing logs at `target: "agent_hooks"`. + +#### `status_for_codex(home)` + +**Primary path** (CLI on PATH): +- `codex plugin marketplace list` → text-parse columns `MARKETPLACE ROOT`, look for row whose name == `wt-local`. Set `marketplace_registered` and `marketplace_path` from the `ROOT` column. Compute `marketplace_path_valid` by stat'ing the path (`directory` exists check, same logic as Claude/Copilot). +- `codex plugin list` → text-parse for a row whose `PLUGIN` column == `wt-agent-hooks`. Set `plugin_installed`. Codex has no enable/disable distinction in `plugin list` output → `plugin_enabled := plugin_installed`. + +**Filesystem fallback** (CLI not on PATH or commands fail): +- Read `~/.codex/config.toml`. Parse TOML for plugin/marketplace entries (exact key names verified during plan-task probe). Set `detection_fallback = "fs"` on the returned `CliStatus`. + +`binary_on_path` / `binary_path` come from the standard `which`-lookup helper that the other CLIs use. + +#### `uninstall_for_codex(home)` + +1. `codex plugin remove wt-agent-hooks@wt-local` — remove plugin first. +2. `codex plugin marketplace remove wt-local` — then remove the marketplace registration. +3. Best-effort cleanup of any LOCALAPPDATA staging dir created by step 3 of install. +4. Populate `CliUninstallResult.messages` with command outcomes. + +### Bundle: `tools/wta/wt-agent-hooks/codex/` + +``` +codex/ + .agents/plugins/marketplace.json ← per developers.openai.com/codex/plugins/build + plugins/wt-agent-hooks/ + .codex-plugin/plugin.json ← Codex plugin manifest + hooks/hooks.json ← 4 events + hooks/send-event.ps1 ← byte-identical copy of claude/wt-agent-hooks/hooks/send-event.ps1 +``` + +**`marketplace.json`** (required Codex schema): + +```json +{ + "$schema": "https://developers.openai.com/codex/marketplace.schema.json", + "name": "wt-local", + "displayName": "Windows Terminal (local)", + "plugins": { + "wt-agent-hooks": { + "source": { "source": "local", "path": "./plugins/wt-agent-hooks" }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + } +} +``` + +(Exact key names — including whether `plugins` is an object map or array, and the precise shape of `source` / `policy` — will be cross-checked against the live `~/.codex/.tmp/plugins/openai-curated` reference during the bundle-creation task. The shape above is the working hypothesis from the discovery probe; the task will lock it in and add a unit test that round-trips through `codex plugin marketplace add`.) + +**`plugin.json`** (minimal Codex plugin manifest): + +```json +{ + "$schema": "https://developers.openai.com/codex/plugin.schema.json", + "name": "wt-agent-hooks", + "version": "0.1.0", + "displayName": "WT Agent Hooks", + "description": "Forward Codex hook events to Windows Terminal for session-management UI." +} +``` + +**`hooks.json`** (4 events; Codex env var `${PLUGIN_ROOT}`): + +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { "type": "command", + "command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}\\hooks\\send-event.ps1\" -CliSource codex -EventName SessionStart" } + ] + } + ], + "PermissionRequest": [ + { "hooks": [ + { "type": "command", + "command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}\\hooks\\send-event.ps1\" -CliSource codex -EventName PermissionRequest" } + ] } + ], + "UserPromptSubmit": [ + { "hooks": [ + { "type": "command", + "command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}\\hooks\\send-event.ps1\" -CliSource codex -EventName UserPromptSubmit" } + ] } + ], + "Stop": [ + { "hooks": [ + { "type": "command", + "command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}\\hooks\\send-event.ps1\" -CliSource codex -EventName Stop" } + ] } + ] + } +} +``` + +The stable `powershell ... -File ` wrapper means trust-on-hash survives `send-event.ps1` content updates — same trick used for Claude. + +**`send-event.ps1`** — byte-for-byte copy of `claude/wt-agent-hooks/hooks/send-event.ps1`. The script accepts `-CliSource codex` from the installer-baked command line, so its env-var fallback chain (which knows about `CLAUDE_SESSION_ID`/`COPILOT_SESSION_ID`/`GEMINI_SESSION_ID` but not `CODEX_SESSION_ID`) never fires in this code path. Slice C may add `CODEX_SESSION_ID` to that chain for defensive correctness. + +### C++ / XAML side + +`src/cascadia/TerminalSettingsEditor/`: + +| File | Change | +| --- | --- | +| `AIAgentsViewModel.idl` | Add `CodexHooksSubtitle`, `ShowCodexHooksSubtitle`, `RemoveCodexHooks` | +| `AIAgentsViewModel.h` | Add `_codexHooksSubtitle` field; mirror three Claude getter methods | +| `AIAgentsViewModel.cpp` | Populate `_codexHooksSubtitle` from the `clis[]` entry whose `name == "codex"`; add `RemoveCodexHooks` body; add `L"CodexHooksSubtitle"` + `L"ShowCodexHooksSubtitle"` to the property-change broadcast list (lines 905–910) | +| `AIAgents.xaml` | New `` row for "Codex CLI" — ~30 lines mirroring the Gemini block at lines ~327–346 | + +`src/cascadia/inc/AgentHooksStatus.h`: + +- Line 42 doc comment: extend `"copilot" \| "claude" \| "gemini"` → `"copilot" \| "claude" \| "gemini" \| "codex"`. + +**No changes to:** +- `AgentHooksStatus.h` parser logic — already CLI-name-agnostic. +- `ut_app/AgentHooksStatusTests.cpp` — fixture rows are illustrative only; parser test coverage stays equivalent. +- `CascadiaPackage.wapproj` — content glob `tools\wta\wt-agent-hooks\**` auto-picks up new `codex/` subtree. + +## Data flow + +``` +Codex CLI runs hook → powershell -File send-event.ps1 -CliSource codex -EventName ... + → send-event.ps1 POSTs to wta IPC endpoint + → wta receives event, tags with cli=codex, persists to history_loader + → Settings UI / Ctrl+Shift+/ list pick up via StatusReport / session enumeration (slice A) +``` + +## Error handling + +- `~/.codex/` missing → `install_for_codex` returns `Skipped` with reason. Status reports `binary_on_path: false`, all plugin fields `false`. +- `codex` not on PATH → status falls back to filesystem (`detection_fallback = "fs"`); install errors with clear log. +- `codex plugin add` non-zero exit → captured to log + `messages`; partial state surfaced via existing `marketplace_registered: true, plugin_installed: false` ("partially installed") C++ formatter. +- Trust step (user must run `/hooks`) is **outside** wta's control — surfaced via slice-C README; slice B's `install` returns success on registration even though events won't fire until trusted. + +## Testing strategy + +### Rust unit tests (`agent_hooks_installer.rs` tests module) + +1. `CliKind::Codex` round-trips: `from_name("codex")` and `Codex.name() == "codex"`; appears in `CliKind::ALL`. +2. `bundle::resolve_cli_dir(CliKind::Codex)` finds `codex/` via env-var override / exe-sibling / dev-tree fixtures. +3. `install_for_codex` skips cleanly when `~/.codex/` missing. +4. `install_for_codex` invokes the two expected commands in order when `~/.codex/` present (mock executor verifies args). +5. `parse_codex_marketplace_list` extracts the `wt-local` row from a golden text sample. +6. `parse_codex_plugin_list` extracts the `wt-agent-hooks` row from a golden text sample. +7. Filesystem fallback parses a fixture `config.toml`. +8. `uninstall_for_codex` issues `plugin remove` then `marketplace remove` in order. +9. Existing parameterized tests (e.g. `installer_skips_when_home_missing`) get a `CliKind::Codex` arm if they iterate over `CliKind::ALL`. + +### Build verification + +- `cargo test --manifest-path tools/wta/Cargo.toml` — expect 585 (current) → ~605+ passing. +- Visual Studio build of `CascadiaPackage` solution — verifies XAML / IDL compile and the new ViewModel members link. + +### Manual smoke test (documented in PR body, not gated) + +1. Install Codex CLI (`winget install OpenAI.Codex` or download). +2. `wta hooks install` → expect successful Codex registration in command output. +3. Open Codex, run `/hooks`, trust the **wt-agent-hooks** plugin. +4. Start a session, submit a prompt → confirm the session appears live in Ctrl+Shift+/ with cli=codex. +5. Open **Settings → AI Agents** → verify the new "Codex CLI" row shows "hooks installed". +6. Click **Remove hooks** → verify `Codex CLI — hooks not installed`. +7. `wta hooks uninstall` → idempotent cleanup. + +## Open verification items (resolved during task execution, not blocking design) + +- Exact `marketplace.json` / `plugin.json` key spelling against live `~/.codex/.tmp/plugins/openai-curated` (probe in the bundle-creation task). +- `codex plugin add` non-interactive behavior under WindowsApps subtree (verified during install task; staging fallback already accounts for it). +- Empirical `Stop` event reliability (verified during manual smoke; if poor, fallback fix is slice C). +- TOML key names for `[[plugin.marketplaces]]` in `~/.codex/config.toml` (verified during filesystem-fallback task). From 4e2e84720bdc33f7fd61a1f24c3c3ea34d7676b4 Mon Sep 17 00:00:00 2001 From: "Yuandi Zhang (from Dev Box)" Date: Fri, 29 May 2026 17:04:54 +0800 Subject: [PATCH 15/34] docs: add Codex hooks slice B implementation plan Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../plans/2026-05-29-codex-hooks-slice-b.md | 1313 +++++++++++++++++ 1 file changed, 1313 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-29-codex-hooks-slice-b.md diff --git a/docs/superpowers/plans/2026-05-29-codex-hooks-slice-b.md b/docs/superpowers/plans/2026-05-29-codex-hooks-slice-b.md new file mode 100644 index 000000000..5fa3cdcea --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-codex-hooks-slice-b.md @@ -0,0 +1,1313 @@ +# Codex hooks slice B — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Register a `wt-agent-hooks` plugin with the Codex CLI through `wta hooks {install,status,uninstall}` and surface a "Codex CLI" row in the AI Agents settings page so live Codex hook events flow into the Windows Terminal session-management UI (parity with Copilot/Claude/Gemini). + +**Architecture:** Direct mirror of the Claude implementation pattern in `tools/wta/src/agent_hooks_installer.rs`. New `CliKind::Codex` variant + per-CLI `install_for_codex` / `codex_status` / `uninstall_for_codex` functions + new `tools/wta/wt-agent-hooks/codex/` bundle. C++/XAML side gets a new `CodexHooksSubtitle` ViewModel triplet and a 4th row in `AIAgents.xaml`. + +**Tech Stack:** Rust (cargo, serde, serde_json — already wired), C++17 with WinRT (CascadiaPackage / TerminalSettingsEditor), XAML, PowerShell. Codex CLI 0.135.0 (verified). `RUSTUP_TOOLCHAIN=stable` required before every cargo invocation (the repo pins `ms-prod-1.93` which isn't installed locally). + +**Reference spec:** `docs/superpowers/specs/2026-05-29-codex-hooks-slice-b-design.md` + +**Worktree:** `C:\yuazha\GitRepo\intelligent-terminal\.worktree\codex-session` (branch `dev/yuazha/codex-session`, PR #98). + +--- + +## File map + +**New files:** +- `tools/wta/wt-agent-hooks/codex/.agents/plugins/marketplace.json` +- `tools/wta/wt-agent-hooks/codex/plugins/wt-agent-hooks/.codex-plugin/plugin.json` +- `tools/wta/wt-agent-hooks/codex/plugins/wt-agent-hooks/hooks/hooks.json` +- `tools/wta/wt-agent-hooks/codex/plugins/wt-agent-hooks/hooks/send-event.ps1` (copy of Claude's) + +**Modified files:** +- `tools/wta/src/agent_hooks_installer.rs` — main installer/status/uninstall changes +- `src/cascadia/inc/AgentHooksStatus.h` — one-line doc comment update +- `src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.idl` — add 3 members +- `src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.h` — add field + 3 method declarations +- `src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.cpp` — add bodies + status mapping + property-change list +- `src/cascadia/TerminalSettingsEditor/AIAgents.xaml` — add a 4th `` row + +**Unchanged:** +- `src/cascadia/CascadiaPackage/CascadiaPackage.wapproj` — content glob auto-picks up the new bundle subtree +- `src/cascadia/ut_app/AgentHooksStatusTests.cpp` — parser is CLI-name-agnostic; ut_app additions deferred to slice C + +--- + +## Task ordering rationale + +Tasks 1→8 are pure Rust and can be developed/tested in `tools/wta/` without touching the C++ build. Tasks 9–12 wire the new row into the Settings UI (requires Visual Studio build to verify). Task 13 is the final cross-cutting verification. + +The bundle files (Task 2) are created early so later Rust tests can stat real paths instead of mocking the filesystem layout. + +--- + +## Test command (use everywhere) + +```powershell +$env:RUSTUP_TOOLCHAIN="stable" +cd C:\yuazha\GitRepo\intelligent-terminal\.worktree\codex-session +cargo test --manifest-path tools/wta/Cargo.toml +``` + +Baseline before any slice-B work: **585 tests pass**. + +--- + +### Task 1: Extend `CliKind` enum with `Codex` + +**Files:** +- Modify: `tools/wta/src/agent_hooks_installer.rs` (lines 161–198 — `CliKind` enum + impls) + +**Existing tests to update:** any test that iterates `CliKind::ALL` and asserts a count — search for `CliKind::ALL.len()` and `CliKind::ALL.iter()`. + +- [ ] **Step 1: Write the failing test** + +Add at the bottom of the existing `#[cfg(test)] mod tests` block (around line 2238+): + +```rust +#[test] +fn cli_kind_codex_roundtrips() { + assert_eq!(CliKind::from_name("codex"), Some(CliKind::Codex)); + assert_eq!(CliKind::from_name("CODEX"), Some(CliKind::Codex)); + assert_eq!(CliKind::Codex.name(), "codex"); + assert_eq!(CliKind::Codex.dir_name(), "codex"); + assert!(CliKind::ALL.contains(&CliKind::Codex)); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```powershell +$env:RUSTUP_TOOLCHAIN="stable" +cargo test --manifest-path tools/wta/Cargo.toml cli_kind_codex_roundtrips +``` + +Expected: **FAIL** — `no variant or associated item named Codex found for enum CliKind`. + +- [ ] **Step 3: Add the `Codex` variant** + +Edit lines 161–198 to: + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CliKind { + Copilot, + Claude, + Gemini, + Codex, +} + +impl CliKind { + pub const ALL: &'static [CliKind] = &[ + CliKind::Copilot, + CliKind::Claude, + CliKind::Gemini, + CliKind::Codex, + ]; + + pub fn name(self) -> &'static str { + match self { + Self::Copilot => "copilot", + Self::Claude => "claude", + Self::Gemini => "gemini", + Self::Codex => "codex", + } + } + + pub fn from_name(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "copilot" => Some(Self::Copilot), + "claude" => Some(Self::Claude), + "gemini" => Some(Self::Gemini), + "codex" => Some(Self::Codex), + _ => None, + } + } + + fn dir_name(self) -> &'static str { + match self { + Self::Claude => "claude", + Self::Copilot => "copilot", + Self::Gemini => "gemini-extension", + Self::Codex => "codex", + } + } +} +``` + +- [ ] **Step 4: Run the new test plus the full suite** + +```powershell +$env:RUSTUP_TOOLCHAIN="stable" +cargo test --manifest-path tools/wta/Cargo.toml cli_kind_codex_roundtrips +cargo test --manifest-path tools/wta/Cargo.toml +``` + +Expected: new test PASSES. Full suite has compile errors at every `match cli { ... }` site that doesn't yet cover `Codex` — that's expected; we'll fix them as we add per-CLI functions. **Note the exact non-exhaustive match errors** so the next tasks can reference them. + +If a counting test fails (e.g. `assert_eq!(CliKind::ALL.len(), 3)`), update its expected value to `4`. + +- [ ] **Step 5: Commit** + +```powershell +git add tools/wta/src/agent_hooks_installer.rs +git commit -m "feat(wta): add CliKind::Codex variant`n`nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +--- + +### Task 2: Create the Codex bundle skeleton + +**Files:** +- Create: `tools/wta/wt-agent-hooks/codex/.agents/plugins/marketplace.json` +- Create: `tools/wta/wt-agent-hooks/codex/plugins/wt-agent-hooks/.codex-plugin/plugin.json` +- Create: `tools/wta/wt-agent-hooks/codex/plugins/wt-agent-hooks/hooks/hooks.json` +- Create: `tools/wta/wt-agent-hooks/codex/plugins/wt-agent-hooks/hooks/send-event.ps1` (byte-identical copy of `tools/wta/wt-agent-hooks/claude/wt-agent-hooks/hooks/send-event.ps1`) + +**Probe first:** inspect the live reference marketplace at `~/.codex/.tmp/plugins/.agents/plugins/marketplace.json` and any plugin under `~/.codex/.tmp/plugins/plugins/*/.codex-plugin/plugin.json`. The working hypothesis below should be cross-checked against the actual keys. + +- [ ] **Step 1: Inspect the reference marketplace** + +```powershell +Get-Content $HOME\.codex\.tmp\plugins\.agents\plugins\marketplace.json +Get-ChildItem $HOME\.codex\.tmp\plugins\plugins\*\.codex-plugin\plugin.json | Select-Object -First 1 | ForEach-Object { Get-Content $_.FullName } +``` + +Expected: JSON with `plugins` field (either object map or array), per-plugin `source`/`policy`/`category` fields. **Note the exact shape** and adjust the marketplace.json below if it differs. + +- [ ] **Step 2: Create `marketplace.json`** + +Working content (adjust if Step-1 probe shows a different shape): + +```json +{ + "name": "wt-local", + "displayName": "Windows Terminal (local)", + "description": "Local marketplace populated by wta", + "owner": { "name": "Agentic Terminal" }, + "plugins": { + "wt-agent-hooks": { + "source": { "source": "local", "path": "./plugins/wt-agent-hooks" }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity", + "interface": { + "displayName": "WT Agent Hooks", + "description": "Forward Codex hook events to Windows Terminal." + } + } + } +} +``` + +- [ ] **Step 3: Create `plugin.json`** + +```json +{ + "name": "wt-agent-hooks", + "version": "0.1.0", + "displayName": "WT Agent Hooks", + "description": "Forward Codex hook events to Windows Terminal for session-management UI.", + "author": { "name": "Agentic Terminal" } +} +``` + +- [ ] **Step 4: Create `hooks/hooks.json`** + +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}\\hooks\\send-event.ps1\" -CliSource codex -EventName SessionStart" + } + ] + } + ], + "PermissionRequest": [ + { + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}\\hooks\\send-event.ps1\" -CliSource codex -EventName PermissionRequest" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}\\hooks\\send-event.ps1\" -CliSource codex -EventName UserPromptSubmit" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}\\hooks\\send-event.ps1\" -CliSource codex -EventName Stop" + } + ] + } + ] + } +} +``` + +- [ ] **Step 5: Copy `send-event.ps1` byte-identically from Claude** + +```powershell +Copy-Item ` + tools\wta\wt-agent-hooks\claude\wt-agent-hooks\hooks\send-event.ps1 ` + tools\wta\wt-agent-hooks\codex\plugins\wt-agent-hooks\hooks\send-event.ps1 +``` + +Verify byte-identical: + +```powershell +(Get-FileHash tools\wta\wt-agent-hooks\claude\wt-agent-hooks\hooks\send-event.ps1).Hash ` + -eq (Get-FileHash tools\wta\wt-agent-hooks\codex\plugins\wt-agent-hooks\hooks\send-event.ps1).Hash +``` + +Expected: `True`. + +- [ ] **Step 6: Smoke-test the bundle with the live Codex CLI** + +```powershell +codex plugin marketplace add (Resolve-Path tools\wta\wt-agent-hooks\codex).Path +codex plugin marketplace list +codex plugin add wt-agent-hooks@wt-local +codex plugin list +``` + +Expected: marketplace `wt-local` appears, plugin `wt-agent-hooks` shows as installed. If a JSON-schema validation error occurs, fix the offending key in `marketplace.json` / `plugin.json` and retry. + +Then clean up: + +```powershell +codex plugin remove wt-agent-hooks@wt-local +codex plugin marketplace remove wt-local +``` + +- [ ] **Step 7: Commit** + +```powershell +git add tools/wta/wt-agent-hooks/codex/ +git commit -m "feat(wta): add Codex hooks bundle (marketplace + plugin + 4 hook events)`n`nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +--- + +### Task 3: Bundle resolver test for `CliKind::Codex` + +**Files:** +- Modify: `tools/wta/src/agent_hooks_installer.rs` — tests module + +The existing `bundle` module (line ~314) joins `dir_name()` onto each candidate root, so once `dir_name()` returns `"codex"` and the directory exists on disk (Task 2), resolution should work automatically. This task adds an assertion to prove it. + +- [ ] **Step 1: Add the test** + +Inside the existing tests module, alongside any other `bundle::` tests: + +```rust +#[test] +fn bundle_resolves_codex_dir_in_dev_tree() { + // Dev-tree lookup walks up from CARGO_MANIFEST_DIR to find + // tools/wta/wt-agent-hooks//. Task 2 puts a real + // directory at that path, so this should resolve. + let resolved = bundle::resolve_cli_dir(CliKind::Codex) + .expect("codex bundle should resolve in dev tree"); + assert!( + resolved.join(".agents").join("plugins").join("marketplace.json").is_file(), + "resolved codex bundle should contain marketplace.json (got {})", + resolved.display(), + ); +} +``` + +- [ ] **Step 2: Run test** + +```powershell +$env:RUSTUP_TOOLCHAIN="stable" +cargo test --manifest-path tools/wta/Cargo.toml bundle_resolves_codex_dir_in_dev_tree +``` + +Expected: **PASS** (Task 1 added `dir_name() == "codex"`; Task 2 created the on-disk files). + +- [ ] **Step 3: Commit** + +```powershell +git add tools/wta/src/agent_hooks_installer.rs +git commit -m "test(wta): bundle resolver finds codex/ in dev tree`n`nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +--- + +### Task 4: `install_for_codex` — skip when `~/.codex/` absent + happy path + +**Files:** +- Modify: `tools/wta/src/agent_hooks_installer.rs` — add new function after `install_for_claude` (around line 606) + +- [ ] **Step 1: Write the failing test** + +```rust +#[test] +fn install_for_codex_skips_when_home_absent() { + let tmp = tempfile::tempdir().unwrap(); + // No ~/.codex created. Function should return cleanly without panic + // and without spawning `codex` (which may or may not be on PATH on CI). + install_for_codex(tmp.path()); +} +``` + +- [ ] **Step 2: Run test, expect compile error** + +```powershell +$env:RUSTUP_TOOLCHAIN="stable" +cargo test --manifest-path tools/wta/Cargo.toml install_for_codex_skips_when_home_absent +``` + +Expected: **FAIL** — `cannot find function install_for_codex`. + +- [ ] **Step 3: Add the function (and its staging helper)** + +Insert after `install_for_claude` (after its closing brace around line 606): + +```rust +/// Install hooks for Codex CLI by spawning `codex plugin marketplace add` +/// followed by `codex plugin add`. Mirrors `install_for_claude` in shape. +/// +/// Subcommand differences vs Claude: +/// * `codex plugin add` (not `install`) +/// * `codex plugin remove` (not `uninstall`) — used by `uninstall_for_codex` +/// * Marketplace metadata lives in `.agents/plugins/marketplace.json` +/// under the bundle root (not `.claude-plugin/marketplace.json`) +/// +/// Trust step: after install, the user must run `/hooks` inside Codex +/// to trust the plugin before any events fire. That's documented in +/// the slice-C README; this function returns success on registration. +fn install_for_codex(home: &Path) { + let codex_dir = home.join(".codex"); + if !codex_dir.is_dir() { + tracing::debug!(target: "agent_hooks", "no ~/.codex dir; Codex not present"); + return; + } + + let bundle_dir = match bundle::resolve_cli_dir(CliKind::Codex) { + Some(p) => p, + None => { + tracing::warn!( + target: "agent_hooks", + "no wt-agent-hooks/codex bundle found next to wta.exe or in dev tree; \ + skipping Codex plugin install (set WTA_HOOKS_BUNDLE_DIR to override)", + ); + return; + } + }; + + // Stage out of WindowsApps if necessary — Codex is Rust-native so it + // shouldn't hit the cpSync EPERM that bites Claude, but staging is + // cheap insurance and keeps the per-CLI install flow uniform. + let staged_dir = maybe_stage_bundle_for_codex(&bundle_dir); + let bundle_dir = staged_dir.as_deref().unwrap_or(&bundle_dir); + + let bundle_path = bundle_dir.to_string_lossy().into_owned(); + if let Err(e) = run_plugin_cli( + "codex", + &["plugin", "marketplace", "add", &bundle_path], + "agent_hooks", + &["already registered"], + ) { + tracing::warn!( + target: "agent_hooks", + err = %e, + "codex plugin marketplace add failed; aborting plugin install", + ); + return; + } + + let plugin_ref = format!("{}@{}", PLUGIN_NAME, MARKETPLACE_NAME); + if let Err(e) = run_plugin_cli( + "codex", + &["plugin", "add", &plugin_ref], + "agent_hooks", + &[], + ) { + tracing::warn!( + target: "agent_hooks", + err = %e, + plugin = %plugin_ref, + "codex plugin add failed", + ); + } +} + +/// WindowsApps -> LOCALAPPDATA staging for Codex bundles. Mirrors +/// `maybe_stage_bundle_for_claude`; see that function's comment for +/// rationale. +fn maybe_stage_bundle_for_codex(source: &Path) -> Option { + if !is_under_windows_apps(source) { + return None; + } + let root = crate::runtime_paths::intelligent_terminal_root()?; + let staged = root.join(STAGING_SUBDIR).join(CliKind::Codex.dir_name()); + match restage_bundle_dir(source, &staged) { + Ok(()) => { + tracing::info!( + target: "agent_hooks", + source = %source.display(), + staged = %staged.display(), + "restaged codex bundle out of WindowsApps", + ); + Some(staged) + } + Err(e) => { + tracing::warn!( + target: "agent_hooks", + err = %e, + source = %source.display(), + staged = %staged.display(), + "failed to restage codex bundle out of WindowsApps; using original path", + ); + None + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```powershell +$env:RUSTUP_TOOLCHAIN="stable" +cargo test --manifest-path tools/wta/Cargo.toml install_for_codex_skips_when_home_absent +``` + +Expected: **PASS**. + +- [ ] **Step 5: Commit** + +```powershell +git add tools/wta/src/agent_hooks_installer.rs +git commit -m "feat(wta): add install_for_codex + WindowsApps staging helper`n`nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +--- + +### Task 5: Wire `Codex` into the top-level `install` dispatch + +**Files:** +- Modify: `tools/wta/src/agent_hooks_installer.rs` — the public `install` function (search for `install_for_claude(`) + +This task makes `wta hooks install` actually call `install_for_codex` for the new `CliKind`. + +- [ ] **Step 1: Locate the dispatch** + +```powershell +Select-String -Path tools/wta/src/agent_hooks_installer.rs -Pattern "install_for_claude\(" -SimpleMatch +``` + +The dispatch site lives in a public `install` (or similarly named) function that already calls `install_for_copilot`, `install_for_claude`, and `install_for_gemini`. Note the line number. + +- [ ] **Step 2: Write the failing test** + +```rust +#[test] +fn install_dispatches_codex() { + // Smoke: install on an empty HOME shouldn't panic when CliKind::Codex + // is in CliKind::ALL but ~/.codex doesn't exist. Failures here usually + // mean a `match cli { ... }` site forgot the Codex arm. + let tmp = tempfile::tempdir().unwrap(); + install_with_home(tmp.path(), CliScope::One(CliKind::Codex)); +} +``` + +(If the existing test harness uses a different entry point name, use that; search: `Select-String -Path tools/wta/src/agent_hooks_installer.rs -Pattern "fn install_with_home"`.) + +- [ ] **Step 3: Add the `Codex` arm** + +In the dispatch function, add `CliKind::Codex => install_for_codex(home)` alongside the existing three CLIs. + +If the dispatch is a `match` on a single `CliKind`, the arm is one line. If it's an iterator over `CliKind::ALL`, no change is needed beyond Task 1 (the arm comes for free) — verify by re-running the suite. + +- [ ] **Step 4: Run the test and full suite** + +```powershell +$env:RUSTUP_TOOLCHAIN="stable" +cargo test --manifest-path tools/wta/Cargo.toml +``` + +Expected: the new test passes; full suite has fewer non-exhaustive-match errors than after Task 1. + +- [ ] **Step 5: Commit** + +```powershell +git add tools/wta/src/agent_hooks_installer.rs +git commit -m "feat(wta): dispatch CliKind::Codex through hooks install`n`nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +--- + +### Task 6: Codex text parsers (`parse_codex_marketplace_list`, `parse_codex_plugin_list`) + +**Files:** +- Modify: `tools/wta/src/agent_hooks_installer.rs` — add functions near the existing `parse_copilot_*` (line 1271) and `parse_claude_*` (line 1316) parsers + +Sample text from local `codex 0.135.0`: + +``` +> codex plugin marketplace list +MARKETPLACE ROOT +openai-curated https://github.com/openai/codex-marketplace +wt-local C:\some\path\to\codex + +> codex plugin list +PLUGIN STATUS VERSION PATH +github not installed - - +wt-agent-hooks installed 0.1.0 C:\...\wt-agent-hooks +``` + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn parse_codex_marketplace_list_finds_wt_local() { + let sample = "MARKETPLACE ROOT\n\ + openai-curated https://github.com/openai/codex-marketplace\n\ + wt-local C:\\some\\path\\to\\codex\n"; + let (registered, path) = parse_codex_marketplace_list(sample); + assert!(registered); + assert_eq!(path.as_deref(), Some("C:\\some\\path\\to\\codex")); +} + +#[test] +fn parse_codex_marketplace_list_absent() { + let sample = "MARKETPLACE ROOT\n\ + openai-curated https://github.com/openai/codex-marketplace\n"; + let (registered, path) = parse_codex_marketplace_list(sample); + assert!(!registered); + assert!(path.is_none()); +} + +#[test] +fn parse_codex_plugin_list_finds_wt_agent_hooks() { + let sample = "PLUGIN STATUS VERSION PATH\n\ + github not installed - -\n\ + wt-agent-hooks installed 0.1.0 C:\\some\\path\n"; + assert!(parse_codex_plugin_list(sample)); +} + +#[test] +fn parse_codex_plugin_list_not_installed() { + let sample = "PLUGIN STATUS VERSION PATH\n\ + wt-agent-hooks not installed - -\n"; + assert!(!parse_codex_plugin_list(sample)); +} + +#[test] +fn parse_codex_plugin_list_absent_row() { + let sample = "PLUGIN STATUS VERSION PATH\n\ + github not installed - -\n"; + assert!(!parse_codex_plugin_list(sample)); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```powershell +$env:RUSTUP_TOOLCHAIN="stable" +cargo test --manifest-path tools/wta/Cargo.toml parse_codex +``` + +Expected: **FAIL** — `cannot find function parse_codex_marketplace_list`. + +- [ ] **Step 3: Implement the parsers** + +Add after the `parse_claude_*` block (around line 1370): + +```rust +/// Parse `codex plugin marketplace list` plain-text output. +/// Returns `(registered, root_path)` where `registered` is true when a +/// row whose first whitespace-delimited column equals `wt-local` +/// exists, and `root_path` is the remainder of that row trimmed. +fn parse_codex_marketplace_list(stdout: &str) -> (bool, Option) { + for line in stdout.lines() { + let line = line.trim_end(); + // Skip header and blank lines. + if line.is_empty() || line.starts_with("MARKETPLACE") { + continue; + } + let mut split = line.splitn(2, char::is_whitespace); + let name = match split.next() { + Some(s) => s.trim(), + None => continue, + }; + if name == MARKETPLACE_NAME { + let rest = split.next().unwrap_or("").trim(); + let path = if rest.is_empty() { None } else { Some(rest.to_string()) }; + return (true, path); + } + } + (false, None) +} + +/// Parse `codex plugin list` plain-text output. Returns true when a row +/// for `wt-agent-hooks` exists AND its STATUS column starts with +/// "installed" (not "not installed", "available", etc.). +fn parse_codex_plugin_list(stdout: &str) -> bool { + for line in stdout.lines() { + let line = line.trim_end(); + if line.is_empty() || line.starts_with("PLUGIN") { + continue; + } + let mut cols = line.split_whitespace(); + let name = match cols.next() { + Some(s) => s, + None => continue, + }; + if name != PLUGIN_NAME { + continue; + } + let rest: Vec<&str> = cols.collect(); + if rest.is_empty() { + return false; + } + // Status starts at rest[0]. "not installed" → not installed. + return rest[0] != "not"; + } + false +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```powershell +$env:RUSTUP_TOOLCHAIN="stable" +cargo test --manifest-path tools/wta/Cargo.toml parse_codex +``` + +Expected: all 5 PASS. + +- [ ] **Step 5: Commit** + +```powershell +git add tools/wta/src/agent_hooks_installer.rs +git commit -m "feat(wta): parse codex plugin/marketplace list text output`n`nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +--- + +### Task 7: `codex_status` + filesystem fallback + dispatch + +**Files:** +- Modify: `tools/wta/src/agent_hooks_installer.rs` — add `codex_status` and `codex_fs_fallback` near `claude_status` (line 992), wire into `status` dispatch, update `populate_marketplace_path` if it has per-CLI match arms + +**Probe first:** check what `~/.codex/config.toml` looks like after a real install to identify the TOML keys for plugin/marketplace state. + +- [ ] **Step 1: Probe `~/.codex/config.toml`** + +```powershell +codex plugin marketplace add (Resolve-Path tools\wta\wt-agent-hooks\codex).Path +codex plugin add wt-agent-hooks@wt-local +Get-Content $HOME\.codex\config.toml +``` + +Note the section names that contain `wt-local` / `wt-agent-hooks`. Most likely candidates: +- `[plugins.marketplaces.wt-local]` table with `path = "..."` and `source = "local"` +- `[plugins.installed.wt-agent-hooks]` or `[[plugins.installed]]` with `marketplace = "wt-local"` + +Then clean up: + +```powershell +codex plugin remove wt-agent-hooks@wt-local +codex plugin marketplace remove wt-local +``` + +- [ ] **Step 2: Write the failing tests** + +```rust +#[test] +fn codex_status_falls_back_when_binary_missing() { + let tmp = tempfile::tempdir().unwrap(); + let s = codex_status(false, None, Some(tmp.path())); + assert_eq!(s.name, "codex"); + assert!(!s.binary_on_path); + assert_eq!(s.detection_fallback, Some("fs")); +} + +#[test] +fn codex_fs_fallback_reads_config_toml() { + let tmp = tempfile::tempdir().unwrap(); + let codex_dir = tmp.path().join(".codex"); + std::fs::create_dir_all(&codex_dir).unwrap(); + // Adjust to whatever Step-1 probe revealed. + let toml = r#" +[plugins.marketplaces.wt-local] +source = "local" +path = "C:\\some\\codex\\bundle" + +[plugins.installed.wt-agent-hooks] +marketplace = "wt-local" +version = "0.1.0" +"#; + std::fs::write(codex_dir.join("config.toml"), toml).unwrap(); + + let mut s = CliStatus { + name: "codex", + binary_on_path: false, + binary_path: None, + marketplace_registered: false, + marketplace_path: None, + marketplace_path_valid: false, + plugin_installed: false, + plugin_enabled: false, + detection_fallback: None, + }; + codex_fs_fallback(&mut s, Some(tmp.path())); + assert!(s.marketplace_registered); + assert!(s.plugin_installed); + assert!(s.plugin_enabled); // mirrors plugin_installed for Codex + assert_eq!(s.detection_fallback, Some("fs")); +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +```powershell +$env:RUSTUP_TOOLCHAIN="stable" +cargo test --manifest-path tools/wta/Cargo.toml codex_status codex_fs_fallback +``` + +Expected: **FAIL** — `cannot find function codex_status` / `codex_fs_fallback`. + +- [ ] **Step 4: Implement `codex_status` + `codex_fs_fallback`** + +Add after `claude_fs_fallback` (around line 1060): + +```rust +fn codex_status(on_path: bool, bin_path: Option, home: Option<&Path>) -> CliStatus { + let mut out = CliStatus { + name: CliKind::Codex.name(), + binary_on_path: on_path, + binary_path: bin_path, + marketplace_registered: false, + marketplace_path: None, + marketplace_path_valid: false, + plugin_installed: false, + plugin_enabled: false, + detection_fallback: None, + }; + if !on_path { + codex_fs_fallback(&mut out, home); + populate_marketplace_path(&mut out, CliKind::Codex, home); + return out; + } + + let mkt = match run_plugin_cli_capture("codex", &["plugin", "marketplace", "list"]) { + Ok(o) if o.success => Some(parse_codex_marketplace_list(&o.stdout)), + Ok(_) | Err(_) => None, + }; + let plugin = match run_plugin_cli_capture("codex", &["plugin", "list"]) { + Ok(o) if o.success => Some(parse_codex_plugin_list(&o.stdout)), + Ok(_) | Err(_) => None, + }; + + if let (Some((registered, path)), Some(installed)) = (mkt, plugin) { + out.marketplace_registered = registered; + if path.is_some() { + out.marketplace_path = path; + } + out.plugin_installed = installed; + out.plugin_enabled = installed; // Codex has no per-plugin enable flag + } else { + codex_fs_fallback(&mut out, home); + } + populate_marketplace_path(&mut out, CliKind::Codex, home); + out +} + +fn codex_fs_fallback(out: &mut CliStatus, home: Option<&Path>) { + out.detection_fallback = Some("fs"); + let Some(home) = home else { return }; + let config_path = home.join(".codex").join("config.toml"); + let text = match fs::read_to_string(&config_path) { + Ok(t) => t, + Err(_) => return, + }; + + // Cheap substring match — we don't need full TOML parsing to detect + // presence, and bringing in a different TOML parser would inflate + // the dep graph. Adjust the literal patterns to match Step-1 probe. + let mkt = text.contains("plugins.marketplaces.wt-local") + || text.contains("[plugins.marketplaces.\"wt-local\"]"); + let plugin = text.contains("plugins.installed.wt-agent-hooks") + || text.contains("[plugins.installed.\"wt-agent-hooks\"]"); + out.marketplace_registered = mkt; + out.plugin_installed = plugin; + out.plugin_enabled = plugin; +} +``` + +If `populate_marketplace_path` doesn't yet know about `CliKind::Codex`, add a Codex arm to its match (mirror Claude's branch — read marketplace path from `~/.codex/config.toml`). + +- [ ] **Step 5: Wire `Codex` into the `status` dispatch** + +Find the top-level `status` function (search for `claude_status(`). Add a `CliKind::Codex => codex_status(on_path, bin_path, home)` arm. + +- [ ] **Step 6: Run tests + full suite** + +```powershell +$env:RUSTUP_TOOLCHAIN="stable" +cargo test --manifest-path tools/wta/Cargo.toml codex_status codex_fs_fallback +cargo test --manifest-path tools/wta/Cargo.toml +``` + +Expected: new tests PASS. Remaining non-exhaustive match errors decrease. + +- [ ] **Step 7: Commit** + +```powershell +git add tools/wta/src/agent_hooks_installer.rs +git commit -m "feat(wta): codex_status with CLI + filesystem fallback`n`nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +--- + +### Task 8: `uninstall_for_codex` + dispatch + +**Files:** +- Modify: `tools/wta/src/agent_hooks_installer.rs` — add after the existing `uninstall_for_claude` and wire into the `uninstall` dispatch + +- [ ] **Step 1: Find and read `uninstall_for_claude`** + +```powershell +Select-String -Path tools/wta/src/agent_hooks_installer.rs -Pattern "^fn uninstall_for_claude" -Context 0,60 +``` + +Use the function as a template — note how it builds a `CliUninstallResult`, sets `attempted`, sweeps legacy dirs, and returns messages. + +- [ ] **Step 2: Write the failing test** + +```rust +#[test] +fn uninstall_for_codex_skips_when_home_absent() { + let tmp = tempfile::tempdir().unwrap(); + let result = uninstall_for_codex(tmp.path()); + assert_eq!(result.name, "codex"); + assert!(!result.attempted); + assert!(result.plugin_uninstalled.is_none()); + assert!(result.marketplace_removed.is_none()); +} +``` + +- [ ] **Step 3: Run to verify it fails** + +```powershell +$env:RUSTUP_TOOLCHAIN="stable" +cargo test --manifest-path tools/wta/Cargo.toml uninstall_for_codex +``` + +Expected: **FAIL** — `cannot find function uninstall_for_codex`. + +- [ ] **Step 4: Implement `uninstall_for_codex`** + +Add following the shape of `uninstall_for_claude`: + +```rust +fn uninstall_for_codex(home: &Path) -> CliUninstallResult { + let mut result = CliUninstallResult { + name: CliKind::Codex.name(), + attempted: false, + plugin_uninstalled: None, + marketplace_removed: None, + staging_dir_removed: true, + messages: Vec::new(), + }; + + let codex_dir = home.join(".codex"); + if !codex_dir.is_dir() { + result.messages.push("skipped: no ~/.codex directory".to_string()); + return result; + } + result.attempted = true; + + let plugin_ref = format!("{}@{}", PLUGIN_NAME, MARKETPLACE_NAME); + let plugin_outcome = run_plugin_cli( + "codex", + &["plugin", "remove", &plugin_ref], + "agent_hooks", + &["not installed"], + ); + match plugin_outcome { + Ok(()) => { + result.plugin_uninstalled = Some(true); + result.messages.push("codex plugin remove succeeded".to_string()); + } + Err(e) => { + result.plugin_uninstalled = Some(false); + result.messages.push(format!("codex plugin remove failed: {e}")); + } + } + + let mkt_outcome = run_plugin_cli( + "codex", + &["plugin", "marketplace", "remove", MARKETPLACE_NAME], + "agent_hooks", + &["not registered", "not found"], + ); + match mkt_outcome { + Ok(()) => { + result.marketplace_removed = Some(true); + result.messages.push("codex plugin marketplace remove succeeded".to_string()); + } + Err(e) => { + result.marketplace_removed = Some(false); + result.messages.push(format!("codex plugin marketplace remove failed: {e}")); + } + } + + // Sweep staging dir (mirrors uninstall_for_claude). + if let Some(root) = crate::runtime_paths::intelligent_terminal_root() { + let staged = root.join(STAGING_SUBDIR).join(CliKind::Codex.dir_name()); + if staged.is_dir() { + match fs::remove_dir_all(&staged) { + Ok(()) => result.messages.push(format!("removed staging dir {}", staged.display())), + Err(e) => { + result.staging_dir_removed = false; + result.messages.push(format!("failed to remove staging dir: {e}")); + } + } + } + } + + result +} +``` + +- [ ] **Step 5: Wire `Codex` into the `uninstall` dispatch** + +Find the top-level `uninstall` function and add a `CliKind::Codex => uninstall_for_codex(home)` arm. + +- [ ] **Step 6: Run tests + full suite** + +```powershell +$env:RUSTUP_TOOLCHAIN="stable" +cargo test --manifest-path tools/wta/Cargo.toml uninstall_for_codex +cargo test --manifest-path tools/wta/Cargo.toml +``` + +Expected: full suite passes (no more non-exhaustive match errors). Test count: ~595+. + +- [ ] **Step 7: Commit** + +```powershell +git add tools/wta/src/agent_hooks_installer.rs +git commit -m "feat(wta): uninstall_for_codex + uninstall dispatch arm`n`nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +--- + +### Task 9: Update stale doc comment in `AgentHooksStatus.h` + +**Files:** +- Modify: `src/cascadia/inc/AgentHooksStatus.h` line 42 + +- [ ] **Step 1: Make the edit** + +Change line 42 from: +```cpp + std::string name; // "copilot" | "claude" | "gemini" +``` +to: +```cpp + std::string name; // "copilot" | "claude" | "gemini" | "codex" +``` + +- [ ] **Step 2: Commit** + +```powershell +git add src/cascadia/inc/AgentHooksStatus.h +git commit -m "docs(cascadia): mention codex in CliStatus.name comment`n`nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +--- + +### Task 10: Add Codex members to `AIAgentsViewModel` (IDL + header) + +**Files:** +- Modify: `src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.idl` (lines 120–137) +- Modify: `src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.h` (lines 142–160) + +- [ ] **Step 1: IDL additions** + +In `AIAgentsViewModel.idl`, alongside the Copilot/Claude/Gemini triples: + +```idl + String CodexHooksSubtitle { get; }; + Boolean ShowCodexHooksSubtitle { get; }; + void RemoveCodexHooks(); +``` + +Insert each near its counterparts (Codex grouped with Gemini, in the existing visual order in the file). + +- [ ] **Step 2: Header additions** + +In `AIAgentsViewModel.h`: + +```cpp + winrt::hstring CodexHooksSubtitle() const { return _codexHooksSubtitle; } + bool ShowCodexHooksSubtitle() const noexcept { return !_codexHooksSubtitle.empty(); } + void RemoveCodexHooks(); +``` + +And add the private member field (mirroring `_claudeHooksSubtitle`): + +```cpp + winrt::hstring _codexHooksSubtitle; +``` + +- [ ] **Step 3: Commit** + +```powershell +git add src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.idl src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.h +git commit -m "feat(settings): expose CodexHooksSubtitle in AIAgentsViewModel IDL/header`n`nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +--- + +### Task 11: Wire Codex into `AIAgentsViewModel.cpp` (status mapping + RemoveCodexHooks + change broadcast) + +**Files:** +- Modify: `src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.cpp` (around lines 905–910 for property list; around line 949–970 for Remove methods; plus wherever `_claudeHooksSubtitle` is assigned from the StatusReport JSON) + +- [ ] **Step 1: Find the status-mapping site** + +```powershell +Select-String -Path src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.cpp -Pattern "_claudeHooksSubtitle\s*=" -Context 0,5 +``` + +The site reads a `CliStatus` row whose `name == "claude"` and formats a subtitle. Mirror it for `"codex"`. + +- [ ] **Step 2: Add Codex status mapping** + +In the same block that builds `_copilotHooksSubtitle` / `_claudeHooksSubtitle` / `_geminiHooksSubtitle`, append (adapting helper/namespace names to whatever the existing block uses): + +```cpp +if (const auto* codex = AgentHooks::FindCli(report, "codex")) +{ + _codexHooksSubtitle = winrt::hstring{ AgentHooks::FormatCliStatusLine(*codex, L"Codex CLI") }; +} +else +{ + _codexHooksSubtitle = winrt::hstring{}; +} +``` + +- [ ] **Step 3: Add `RemoveCodexHooks` body** + +After `RemoveGeminiHooks` (around line 967), mirror its body: + +```cpp + void AIAgentsViewModel::RemoveCodexHooks() + { + if (_installingAgentHooks) return; + _RemoveAgentHooksForCli(L"codex"); + } +``` + +(Adjust the helper name to whatever the existing `RemoveClaudeHooks` / `RemoveGeminiHooks` call.) + +- [ ] **Step 4: Append to the property-change broadcast list** + +At lines 905–910, the broadcast list currently reads: + +```cpp + L"CopilotHooksSubtitle", + L"ClaudeHooksSubtitle", + L"GeminiHooksSubtitle", + L"ShowCopilotHooksSubtitle", + L"ShowClaudeHooksSubtitle", + L"ShowGeminiHooksSubtitle"); +``` + +Add Codex entries: + +```cpp + L"CopilotHooksSubtitle", + L"ClaudeHooksSubtitle", + L"GeminiHooksSubtitle", + L"CodexHooksSubtitle", + L"ShowCopilotHooksSubtitle", + L"ShowClaudeHooksSubtitle", + L"ShowGeminiHooksSubtitle", + L"ShowCodexHooksSubtitle"); +``` + +- [ ] **Step 5: Commit** + +```powershell +git add src/cascadia/TerminalSettingsEditor/AIAgentsViewModel.cpp +git commit -m "feat(settings): populate Codex hooks subtitle + RemoveCodexHooks`n`nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +--- + +### Task 12: Add Codex row to `AIAgents.xaml` + +**Files:** +- Modify: `src/cascadia/TerminalSettingsEditor/AIAgents.xaml` (after the Gemini block, around line 346) + +- [ ] **Step 1: Find the Gemini block as template** + +```powershell +Select-String -Path src/cascadia/TerminalSettingsEditor/AIAgents.xaml -Pattern "GeminiHooksSubtitle" -Context 8,12 +``` + +Copy the full `` block that hosts the "Gemini CLI" row (header TextBlock + subtitle TextBlock + Remove button + Install button if present). + +- [ ] **Step 2: Paste a Codex copy directly below** + +Substitute substrings: +- `Gemini CLI` → `Codex CLI` +- `GeminiHooksSubtitle` → `CodexHooksSubtitle` +- `ShowGeminiHooksSubtitle` → `ShowCodexHooksSubtitle` +- `RemoveGeminiHooks` → `RemoveCodexHooks` + +(Plus any `_geminiHooks` install-button bindings that the existing block references — mirror those too if they exist.) + +Example block (verify field names match existing pattern): + +```xml + + + + + + + + + +