Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
652b92b
feat(wta): promote Codex to first-class CliSource variant
May 28, 2026
70b5dff
feat(wta): enable Codex resume via 'codex resume <id>' subcommand
May 28, 2026
34491e3
feat(wta): scan ~/.codex/sessions for historical rollouts
May 28, 2026
86a040a
feat(wta): wire codex_title_for_key into lookup_title_for_session
May 28, 2026
111401b
feat(wta): codex lenient + strict resumability probes
May 28, 2026
c160795
refactor(wta): centralize CliSource -> cli_id via known_cli_id helper
May 28, 2026
76494cb
test(wta): pin codex wire-format round-trip through SessionHookCliSource
May 28, 2026
de1896e
test(wta): pin codex live-registry fan-in contract
May 28, 2026
cae8d74
test(wta): pin Codex Class A behavior in session_mgmt cells
May 28, 2026
5d2ed55
feat(wta): show 'codex' suffix on selected Codex rows in F2 list
May 28, 2026
61daca1
feat(wta): include Codex in display-name mapping
May 28, 2026
f3d37db
test(wta): add Codex row to populate_demo_data
May 28, 2026
cce37a3
docs(wta): add Codex layout to history_loader header
May 28, 2026
3572714
Merge remote-tracking branch 'origin/main' into dev/yuazha/codex-session
May 29, 2026
5532ca9
docs: add Codex hooks slice B design spec
May 29, 2026
4e2e847
docs: add Codex hooks slice B implementation plan
May 29, 2026
5c807d6
feat(wta): add CliKind::Codex variant
May 29, 2026
0695212
feat(wta): add Codex hooks bundle (marketplace + plugin + 4 hook events)
May 29, 2026
1ec076f
feat(wta): add install_for_codex + dispatch wiring + stub helpers
May 29, 2026
461fe8f
feat(wta): parse codex plugin/marketplace list text output
May 29, 2026
830b291
feat(wta): codex_status with CLI + filesystem fallback
May 29, 2026
5210d43
feat(wta): uninstall_for_codex + uninstall dispatch arm
May 29, 2026
0128096
test(wta): bundle resolver finds codex/ in dev tree
May 29, 2026
637dad1
docs(cascadia): mention codex in CliStatus.name comment
May 29, 2026
de8c93e
feat(settings): add Codex row + RemoveCodexHooks to AI Agents page
May 29, 2026
fd4069e
Merge remote-tracking branch 'origin/main' into dev/yuazha/codex-session
May 30, 2026
3f0021b
Merge remote-tracking branch 'origin/main' into dev/yeelam/pr-98-code…
yeelam-gordon May 31, 2026
43d7191
fix(wta): address PR #98 review comments
May 31, 2026
7dd2830
fix(codex-hooks): Settings remove + unify send-event.ps1
May 31, 2026
37e49dc
l10n(codex-hooks): translate AIAgents_HooksRemovingCodexSummary
May 31, 2026
f3d05f6
feat(codex-hooks): auto-upgrade plugin on IT version change
May 31, 2026
6620605
fix(codex-hooks): add Verify-AgentHooks codex support, send-event env…
May 31, 2026
315dbc8
Flatten Codex bundle layout to match Claude/Copilot/Gemini
May 31, 2026
9454563
Restore UTF-8 BOM on translated Resources.resw files
May 31, 2026
2ed5dfd
Merge remote-tracking branch 'origin/main' into dev/yuazha/codex-session
May 31, 2026
fc914d3
update version to 0.1.2 for wt-agent-hooks across all plugins and scr…
Jun 1, 2026
a1bdd19
wta(codex): scope plugin list to wt-local marketplace
Jun 1, 2026
5842412
l10n(codex-hooks): include Codex in translator comments for HooksInst…
Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion tools/wta/src/agent_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <session-id>` is a subcommand (not a flag);
// the command-synthesis template `format!("{cli} {flag} {key}")`
// produces `codex resume <uuid>` which Codex CLI accepts.
resume_flag: "resume",
auth_hint: "Run: codex auth (or set OPENAI_API_KEY)",
},
AgentProfile {
Expand Down Expand Up @@ -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 <id>` (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."
);
}
}
81 changes: 60 additions & 21 deletions tools/wta/src/agent_sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -43,22 +44,24 @@ 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()),
other => Self::Unknown(other.to_string()),
}
}

/// 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<Self> {
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,
}
Expand Down Expand Up @@ -1055,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;

Expand All @@ -1079,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,
Expand All @@ -1092,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,
Expand All @@ -1101,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,
Expand All @@ -1114,23 +1131,23 @@ 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,
pane_session_id: "55555555-5555-5555-5555-555555555555".to_string(),
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 {
key: "demo-claude-ended".to_string(),
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 {
Expand All @@ -1155,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); }
Expand Down Expand Up @@ -1832,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<AgentStatus> = sessions.iter().map(|s| s.status.clone()).collect();
for st in [
AgentStatus::Working,
AgentStatus::Attention,
AgentStatus::Idle,
AgentStatus::Error,
Expand All @@ -1846,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();
Expand Down Expand Up @@ -1981,14 +2003,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();
Expand Down
78 changes: 45 additions & 33 deletions tools/wta/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -2151,21 +2167,14 @@ 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).
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::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()
}
// resume-flag support is a per-CLI profile constant — true for
// Claude / Codex / Copilot / Gemini (all four CLIs accept some
// form of `--resume`/`resume <id>` re-attach surface).
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(),
Expand Down Expand Up @@ -2209,12 +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::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 \
Expand Down Expand Up @@ -2337,7 +2341,7 @@ impl App {
/// Open a new WT tab whose primary pane runs `<cli> <resume_flag>
/// <session_key>` 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
Expand All @@ -2357,11 +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::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,
Expand Down Expand Up @@ -2595,12 +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::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.",
Expand Down Expand Up @@ -11772,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);
}
}
Loading
Loading