aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs42
-rw-r--r--src/cli/agent.rs6
-rw-r--r--src/detect/manifest.rs27
-rw-r--r--src/detect/manifest/tests.rs20
-rw-r--r--src/detect/manifests/codex.toml16
-rw-r--r--src/detect/mod.rs4
-rw-r--r--src/events.rs2
-rw-r--r--src/pane.rs211
-rw-r--r--src/pane/agent_detection.rs54
-rw-r--r--src/terminal/state.rs82
10 files changed, 384 insertions, 80 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index e1bedd26..26bdf0e4 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -1442,6 +1442,12 @@ impl AppState {
})
.into_iter()
.collect(),
+ AppEvent::CodexPromptObserved { pane_id, ready } => self
+ .update_terminal_state(pane_id, |terminal| {
+ terminal.observe_codex_prompt_ready(ready)
+ })
+ .into_iter()
+ .collect(),
AppEvent::StateChanged {
pane_id,
agent,
@@ -3124,6 +3130,42 @@ mod tests {
}
#[test]
+ fn codex_prompt_observation_changes_readiness_without_completing_a_turn() {
+ let mut app = app_with_workspaces(&["active", "background"]);
+ let pane_id = app.workspaces[1].tabs[0].root_pane;
+ let terminal_id = app.workspaces[1].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ app.terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .begin_managed_agent(
+ "reviewer".into(),
+ Agent::Codex,
+ Instant::now(),
+ std::time::Duration::ZERO,
+ std::time::Duration::from_secs(60),
+ );
+ app.handle_app_event(AppEvent::StateChanged {
+ pane_id,
+ agent: Some(Agent::Codex),
+ state: AgentState::Unknown,
+ visible_blocker: false,
+ visible_working: false,
+ process_exited: false,
+ observed_at: Instant::now(),
+ });
+ app.handle_app_event(AppEvent::CodexPromptObserved {
+ pane_id,
+ ready: true,
+ });
+ let terminal = &app.terminals[&terminal_id];
+ assert!(terminal.managed_agent_interactive_ready());
+ assert_eq!(terminal.state, AgentState::Unknown);
+ assert!(terminal.last_agent_completion_seq.is_none());
+ }
+
+ #[test]
fn completion_guard_same_state_agent_replacement_clears_old_work() {
let mut app = app_with_workspaces(&["active", "background"]);
app.active = Some(0);
diff --git a/src/cli/agent.rs b/src/cli/agent.rs
index 1af3e0d6..09043460 100644
--- a/src/cli/agent.rs
+++ b/src/cli/agent.rs
@@ -610,6 +610,12 @@ fn wait_for_named_agent(
"agent_not_ready",
format!("agent {name} is blocked during startup and is not ready for prompts"),
))),
+ Some("unknown")
+ if expected_kind == "codex"
+ && agent["interactive_ready"].as_bool() == Some(true) =>
+ {
+ Some(Ok(agent.clone()))
+ }
Some("working" | "unknown") => None,
Some("idle" | "done") if agent["interactive_ready"].as_bool() == Some(true) => {
Some(Ok(agent.clone()))
diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs
index acd6b8f9..e224adfe 100644
--- a/src/detect/manifest.rs
+++ b/src/detect/manifest.rs
@@ -416,23 +416,6 @@ pub fn explain_for_label(agent_label: &str, screen_content: &str) -> DetectionEx
explain(agent, screen_content)
}
-pub fn should_skip_state_update(agent: Agent, screen_content: &str) -> bool {
- let Some(loaded) = load_manifest(agent) else {
- return false;
- };
- evaluate_loaded_manifest(
- agent,
- DetectionInput {
- screen: screen_content,
- osc_title: "",
- osc_progress: "",
- },
- loaded,
- false,
- )
- .skip_state_update
-}
-
impl DetectionExplain {
fn into_detection(self) -> AgentDetection {
AgentDetection {
@@ -557,14 +540,14 @@ fn fallback_explain(
)
})
.unwrap_or((None, Vec::new(), None, None, None, false));
- let known_agent = agent.is_some();
+ let assume_idle = agent.is_some_and(|agent| agent != Agent::Codex);
let remote_update_status = include_update_status
.then(|| agent.and_then(remote_update_status))
.flatten();
DetectionExplain {
agent: agent.map(|agent| agent_label(agent).to_string()),
- state: if known_agent {
+ state: if assume_idle {
AgentState::Idle
} else {
AgentState::Unknown
@@ -577,7 +560,11 @@ fn fallback_explain(
visible_working: false,
skip_state_update: false,
skipped_update_reason: None,
- fallback_reason: known_agent.then(|| DEFAULT_KNOWN_AGENT_IDLE_FALLBACK.to_string()),
+ fallback_reason: match agent {
+ Some(Agent::Codex) => Some("codex_state_ambiguous".to_string()),
+ Some(_) => Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK.to_string()),
+ None => None,
+ },
evaluated_rules,
warning,
manifest_version,
diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs
index 07e9aa38..1e6b1a85 100644
--- a/src/detect/manifest/tests.rs
+++ b/src/detect/manifest/tests.rs
@@ -89,15 +89,21 @@ fn write_local_codex(content: &str) {
}
#[test]
-fn known_agent_no_match_defaults_to_idle_fallback() {
+fn codex_no_match_is_unknown_without_changing_other_agents() {
with_manifest_dirs("no-match", || {
write_local_codex(&local_manifest("working", "active-marker"));
let explain = explain(Agent::Codex, "unmatched-marker");
- assert_eq!(explain.state, AgentState::Idle);
+ assert_eq!(explain.state, AgentState::Unknown);
assert!(!explain.visible_idle);
assert_eq!(
explain.fallback_reason.as_deref(),
+ Some("codex_state_ambiguous")
+ );
+ let other = fallback_explain(Some(Agent::Pi), None, false);
+ assert_eq!(other.state, AgentState::Idle);
+ assert_eq!(
+ other.fallback_reason.as_deref(),
Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
);
});
@@ -184,10 +190,10 @@ fn fallback_explain_preserves_active_manifest_version() {
let explain = explain(Agent::Codex, "ordinary prompt text");
- assert_eq!(explain.state, AgentState::Idle);
+ assert_eq!(explain.state, AgentState::Unknown);
assert_eq!(
explain.fallback_reason.as_deref(),
- Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
+ Some("codex_state_ambiguous")
);
assert_eq!(explain.manifest_version.as_deref(), Some("9999.01.01.1"));
assert!(matches!(
@@ -267,10 +273,10 @@ fn detection_uses_cached_manifest_until_explicit_reload() {
write_remote_codex_without_reload(&remote_manifest("9999.01.01.2", "working", "new-ready"));
let unchanged = explain(Agent::Codex, "new-ready");
- assert_eq!(unchanged.state, AgentState::Idle);
+ assert_eq!(unchanged.state, AgentState::Unknown);
assert_eq!(
unchanged.fallback_reason.as_deref(),
- Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
+ Some("codex_state_ambiguous")
);
assert_eq!(
unchanged.cached_remote_version.as_deref(),
@@ -479,8 +485,6 @@ contains = ["overlay-marker"]
assert!(!result.visible_working);
assert!(!result.visible_blocker);
assert!(detect(Agent::Codex, screen).skip_state_update);
- assert!(should_skip_state_update(Agent::Codex, screen));
- assert!(!should_skip_state_update(Agent::Codex, "activity-marker"));
});
}
diff --git a/src/detect/manifests/codex.toml b/src/detect/manifests/codex.toml
index ecef12b7..dd275758 100644
--- a/src/detect/manifests/codex.toml
+++ b/src/detect/manifests/codex.toml
@@ -1,7 +1,7 @@
id = "codex"
-version = "2026.09.22.1"
+version = "2026.09.23.1"
min_engine_version = 3
-updated_at = "2026-09-22T00:00:00Z"
+updated_at = "2026-09-23T22:12:00Z"
[[rules]]
id = "osc_title_blocked"
@@ -92,15 +92,3 @@ any = [{ contains = [" to interrupt)"] }, { contains = ["s)"] }]
regex = ['(?m)^(?:[•◦][ \t]+)?[^\s›•◦■✗✓─][^\r\n]* \((?:[0-9]+[hm] )*[0-9]+s(?: • [^\r\n]+? to interrupt)?\)(?: · [^\r\n]*)?(?:\r?\n(?:[^•◦›■✗✓─\r\n][^\r\n]*|•[ \t]+(?:Queued\s+follow-up\s+inputs|Messages\s+to\s+be\s+submitted\s+after\s+next\s+tool\s+call(?:\s+\(press\s+[^\r\n]+?\s+to\s+interrupt\s+and\s+send\s+immediately\))?|Messages\s+to\s+be\s+submitted\s+at\s+end\s+of\s+turn)|›[⠁⠂⠄⠈⠐⠠⡀⢀][^\r\n]*)?)*\s*\z']
# A failed reconnect keeps its final elapsed timer but is no longer working.
not = [{ line_regex = ['^(?:[•◦][ \t]+)?Reconnect failed — check the endpoint, then relaunch \([0-9hms ]+\)$'] }]
-
-[[rules]]
-id = "osc_title_idle"
-state = "idle"
-priority = 100
-region = "osc_title"
-visible_idle = true
-regex = ['\S']
-not = [
- { regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] },
- { contains = ["Action Required"] },
-]
diff --git a/src/detect/mod.rs b/src/detect/mod.rs
index 3dae571b..408ee669 100644
--- a/src/detect/mod.rs
+++ b/src/detect/mod.rs
@@ -320,10 +320,6 @@ pub fn detect_agent_with_osc(
)
}
-pub fn should_skip_state_update(agent: Option<Agent>, screen_content: &str) -> bool {
- agent.is_some_and(|agent| manifest::should_skip_state_update(agent, screen_content))
-}
-
pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> bool {
matches!(
(source, agent_label),
diff --git a/src/events.rs b/src/events.rs
index a614ee0c..0e61f5f8 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -89,6 +89,8 @@ pub enum AppEvent {
agent: Agent,
observed_at: Instant,
},
+ /// The current Codex input screen is visible during managed startup.
+ CodexPromptObserved { pane_id: PaneId, ready: bool },
/// Fallback detector state changed in a pane.
StateChanged {
pane_id: PaneId,
diff --git a/src/pane.rs b/src/pane.rs
index 9bc35eca..bfeec638 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -34,7 +34,7 @@ mod terminal;
mod xtgettcap;
use self::agent_detection::{
- decide_detection_screen_read, decide_screen_detection_publish,
+ codex_prompt_ready, decide_detection_screen_read, decide_screen_detection_publish,
detection_update_for_publish_with_osc, mark_detection_content_changed,
observe_detection_content_change, DetectionPublishDecision, DetectionScreenReadDecision,
DetectionScreenReadInput, PendingIdleConfirmation, ScreenDetectionPublishInput,
@@ -290,6 +290,31 @@ async fn publish_agent_process_detected_event(
}
}
+async fn publish_codex_prompt_observation(
+ state_events: &mpsc::Sender<AppEvent>,
+ pane_id: PaneId,
+ agent: Option<Agent>,
+ content: &str,
+ detection: Option<&crate::detect::AgentDetection>,
+ process_exited: bool,
+ last_ready: &mut bool,
+) {
+ let ready = agent == Some(Agent::Codex)
+ && !process_exited
+ && detection.is_some_and(|detection| detection.state == AgentState::Unknown)
+ && codex_prompt_ready(content);
+ if ready == *last_ready {
+ return;
+ }
+ *last_ready = ready;
+ if let Err(err) = state_events
+ .send(AppEvent::CodexPromptObserved { pane_id, ready })
+ .await
+ {
+ warn!(pane = pane_id.raw(), %err, "failed to deliver Codex prompt observation");
+ }
+}
+
#[derive(Debug, Clone, Copy)]
struct AgentDetectionPublishUpdate {
state: AgentState,
@@ -771,6 +796,7 @@ fn spawn_basic_detection_task(
let mut last_screen_scan_detection_content_seq = None;
let mut agent_startup_grace_until = None;
let mut pending_idle = PendingIdleConfirmation::default();
+ let mut last_codex_prompt_ready = false;
loop {
let sleep_duration = if pending_idle.active() {
@@ -781,6 +807,10 @@ fn spawn_basic_detection_task(
tokio::select! {
_ = tokio::time::sleep(sleep_duration) => {}
_ = detect_reset.notified() => {
+ publish_codex_prompt_observation(
+ &state_events, pane_id, Some(Agent::Codex), "", None, false,
+ &mut last_codex_prompt_ready,
+ ).await;
agent_presence = AgentDetectionPresence::from_agent(None);
state = AgentState::Unknown;
last_visible_idle = false;
@@ -888,6 +918,7 @@ fn spawn_basic_detection_task(
== ForegroundShellAgentAction::ReportReplacementProcess;
if agent_changed {
pending_idle.clear();
+ last_codex_prompt_ready = false;
last_screen_scan_detection_content_seq = None;
// A replacement agent must not inherit OSC evidence
// from the previous process; a first acquisition keeps
@@ -961,10 +992,29 @@ fn spawn_basic_detection_task(
last_screen_scan_detection_content_seq = current_detection_content_seq;
let content_changed = content != last_detection_text;
last_detection_text.clone_from(&content);
- if !process_exited && crate::detect::should_skip_state_update(agent, &content) {
+ let osc_title = terminal.agent_osc_title();
+ let osc_progress = terminal.agent_osc_progress();
+ let screen_detection = detection_update_for_publish_with_osc(
+ agent,
+ &content,
+ &osc_title,
+ &osc_progress,
+ process_exited,
+ );
+ publish_codex_prompt_observation(
+ &state_events,
+ pane_id,
+ agent,
+ &content,
+ screen_detection.as_ref(),
+ process_exited,
+ &mut last_codex_prompt_ready,
+ )
+ .await;
+ let Some(screen_detection) = screen_detection else {
pending_idle.clear();
continue;
- }
+ };
sync_content_change_acquisition(
agent_presence.current_agent(),
suppressed_agent,
@@ -974,19 +1024,6 @@ fn spawn_basic_detection_task(
&mut acquisition_started_at,
&mut last_content_change_at,
);
-
- let osc_title = terminal.agent_osc_title();
- let osc_progress = terminal.agent_osc_progress();
- let Some(screen_detection) = detection_update_for_publish_with_osc(
- agent,
- &content,
- &osc_title,
- &osc_progress,
- process_exited,
- ) else {
- pending_idle.clear();
- continue;
- };
match decide_screen_detection_publish(
ScreenDetectionPublishInput {
screen_detection,
@@ -2660,6 +2697,7 @@ impl PaneRuntime {
let mut last_screen_scan_detection_content_seq = None;
let mut agent_startup_grace_until = None;
let mut pending_idle = PendingIdleConfirmation::default();
+ let mut last_codex_prompt_ready = false;
tokio::time::sleep(Duration::from_millis(50)).await;
@@ -2680,6 +2718,10 @@ impl PaneRuntime {
tokio::select! {
_ = tokio::time::sleep(tick) => {}
_ = detect_reset.notified() => {
+ publish_codex_prompt_observation(
+ &state_events, pane_id, Some(Agent::Codex), "", None, false,
+ &mut last_codex_prompt_ready,
+ ).await;
agent_presence = AgentDetectionPresence::from_agent(None);
state = AgentState::Unknown;
last_visible_idle = false;
@@ -2823,6 +2865,7 @@ impl PaneRuntime {
== ForegroundShellAgentAction::ReportReplacementProcess
{
pending_idle.clear();
+ last_codex_prompt_ready = false;
last_screen_scan_detection_content_seq = None;
// A replacement agent must not inherit OSC
// evidence from the previous process; a first
@@ -2930,10 +2973,29 @@ impl PaneRuntime {
last_screen_scan_detection_content_seq = current_detection_content_seq;
let content_changed = content != last_detection_text;
last_detection_text.clone_from(&content);
- if detect::should_skip_state_update(agent, &content) {
+ let osc_title = terminal.agent_osc_title();
+ let osc_progress = terminal.agent_osc_progress();
+ let screen_detection = detection_update_for_publish_with_osc(
+ agent,
+ &content,
+ &osc_title,
+ &osc_progress,
+ process_exited,
+ );
+ publish_codex_prompt_observation(
+ &state_events,
+ pane_id,
+ agent,
+ &content,
+ screen_detection.as_ref(),
+ process_exited,
+ &mut last_codex_prompt_ready,
+ )
+ .await;
+ let Some(screen_detection) = screen_detection else {
pending_idle.clear();
continue;
- }
+ };
sync_content_change_acquisition(
agent_presence.current_agent(),
suppressed_agent,
@@ -2943,19 +3005,6 @@ impl PaneRuntime {
&mut acquisition_started_at,
&mut last_content_change_at,
);
-
- let osc_title = terminal.agent_osc_title();
- let osc_progress = terminal.agent_osc_progress();
- let Some(screen_detection) = detection_update_for_publish_with_osc(
- agent,
- &content,
- &osc_title,
- &osc_progress,
- process_exited,
- ) else {
- pending_idle.clear();
- continue;
- };
match decide_screen_detection_publish(
ScreenDetectionPublishInput {
screen_detection,
@@ -5804,4 +5853,104 @@ mod tests {
} if delivered_pane == pane_id
));
}
+
+ #[tokio::test]
+ async fn codex_prompt_observation_revokes_on_working_or_skipped_screen() {
+ let (tx, mut rx) = mpsc::channel(4);
+ let pane_id = PaneId::from_raw(42);
+ let detection = crate::detect::AgentDetection {
+ state: AgentState::Unknown,
+ skip_state_update: false,
+ visible_idle: false,
+ visible_blocker: false,
+ visible_working: false,
+ };
+ let mut last_ready = false;
+ let prompt = "› Ask Codex to do anything";
+ publish_codex_prompt_observation(
+ &tx,
+ pane_id,
+ Some(Agent::Codex),
+ prompt,
+ Some(&detection),
+ false,
+ &mut last_ready,
+ )
+ .await;
+ assert!(matches!(
+ rx.recv().await,
+ Some(AppEvent::CodexPromptObserved { ready: true, .. })
+ ));
+ publish_codex_prompt_observation(
+ &tx,
+ pane_id,
+ Some(Agent::Codex),
+ prompt,
+ Some(&detection),
+ false,
+ &mut last_ready,
+ )
+ .await;
+ assert!(rx.try_recv().is_err());
+ let working = crate::detect::AgentDetection {
+ state: AgentState::Working,
+ ..detection
+ };
+ publish_codex_prompt_observation(
+ &tx,
+ pane_id,
+ Some(Agent::Codex),
+ prompt,
+ Some(&working),
+ false,
+ &mut last_ready,
+ )
+ .await;
+ assert!(matches!(
+ rx.recv().await,
+ Some(AppEvent::CodexPromptObserved { ready: false, .. })
+ ));
+ publish_codex_prompt_observation(
+ &tx,
+ pane_id,
+ Some(Agent::Codex),
+ prompt,
+ Some(&detection),
+ false,
+ &mut last_ready,
+ )
+ .await;
+ assert!(matches!(
+ rx.recv().await,
+ Some(AppEvent::CodexPromptObserved { ready: true, .. })
+ ));
+ publish_codex_prompt_observation(
+ &tx,
+ pane_id,
+ Some(Agent::Codex),
+ prompt,
+ None,
+ false,
+ &mut last_ready,
+ )
+ .await;
+ assert!(matches!(
+ rx.recv().await,
+ Some(AppEvent::CodexPromptObserved { ready: false, .. })
+ ));
+ publish_codex_prompt_observation(
+ &tx,
+ pane_id,
+ Some(Agent::Codex),
+ prompt,
+ Some(&detection),
+ false,
+ &mut last_ready,
+ )
+ .await;
+ assert!(matches!(
+ rx.recv().await,
+ Some(AppEvent::CodexPromptObserved { ready: true, .. })
+ ));
+ }
}
diff --git a/src/pane/agent_detection.rs b/src/pane/agent_detection.rs
index 9ef10dfe..e37aec3b 100644
--- a/src/pane/agent_detection.rs
+++ b/src/pane/agent_detection.rs
@@ -89,7 +89,9 @@ pub(super) struct IdleScreenScanSkipInput {
}
pub(super) fn should_skip_idle_screen_scan(input: IdleScreenScanSkipInput) -> bool {
- if input.state != AgentState::Idle
+ let stable_state = input.state == AgentState::Idle
+ || (input.state == AgentState::Unknown && input.agent == Some(Agent::Codex));
+ if !stable_state
|| input.agent.is_none()
|| input.pending_idle_active
|| input.agent_changed
@@ -316,6 +318,23 @@ pub(super) fn detection_update_for_publish_with_osc(
(!detection.skip_state_update).then_some(detection)
}
+pub(super) fn codex_prompt_ready(content: &str) -> bool {
+ // The composer can remain visible during a turn; this is startup evidence only.
+ let recent: String = content
+ .lines()
+ .rev()
+ .take(12)
+ .collect::<Vec<_>>()
+ .into_iter()
+ .rev()
+ .flat_map(str::chars)
+ .filter(|ch| !ch.is_whitespace())
+ .collect();
+ recent.contains("›AskCodextodoanything")
+ && !recent.contains("model:loading")
+ && !recent.contains("Resumingsession")
+}
+
pub(super) fn observe_detection_content_change(bytes: &[u8], detection_content_seq: &AtomicU64) {
if !bytes.is_empty() {
detection_content_seq.fetch_add(1, Ordering::Relaxed);
@@ -380,6 +399,13 @@ mod tests {
}
#[test]
+ fn codex_startup_prompt_survives_terminal_wraps() {
+ let wrapped = "header\n› Ask Codex to do\nanything\nfooter";
+ assert!(codex_prompt_ready(wrapped));
+ assert!(!codex_prompt_ready(&format!("model: load\ning\n{wrapped}")));
+ }
+
+ #[test]
fn screen_read_skips_unchanged_idle_bottom_buffer() {
assert_eq!(
decide_detection_screen_read(screen_read_input(AgentState::Idle, 10)),
@@ -388,6 +414,32 @@ mod tests {
}
#[test]
+ fn screen_read_skips_unchanged_ambiguous_codex_but_not_new_content_or_replacement() {
+ let mut input = screen_read_input(AgentState::Unknown, 10);
+ assert_eq!(
+ decide_detection_screen_read(input),
+ DetectionScreenReadDecision::Skip
+ );
+ input.current_detection_content_seq = Some(11);
+ assert_eq!(
+ decide_detection_screen_read(input),
+ DetectionScreenReadDecision::Read
+ );
+ input.current_detection_content_seq = Some(10);
+ input.agent_changed = true;
+ assert_eq!(
+ decide_detection_screen_read(input),
+ DetectionScreenReadDecision::Read
+ );
+ input.agent_changed = false;
+ input.agent = Some(Agent::Pi);
+ assert_eq!(
+ decide_detection_screen_read(input),
+ DetectionScreenReadDecision::Read
+ );
+ }
+
+ #[test]
fn screen_read_reads_when_idle_bottom_buffer_changes() {
assert_eq!(
decide_detection_screen_read(screen_read_input(AgentState::Idle, 11)),
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index fadd6e10..4a5abb12 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -142,6 +142,7 @@ pub struct TerminalState {
pub agent_name: Option<String>,
agent_name_owner: Option<AgentNameOwner>,
managed_agent: Option<ManagedAgent>,
+ codex_prompt_ready: bool,
managed_agent_launch_session: Option<crate::agent_resume::PersistedAgentSession>,
hook_report_sequences: HashMap<String, u64>,
suppressed_full_lifecycle_hook_reports: HashMap<String, SuppressedFullLifecycleHookReport>,
@@ -179,6 +180,7 @@ impl TerminalState {
agent_name: None,
agent_name_owner: None,
managed_agent: None,
+ codex_prompt_ready: false,
managed_agent_launch_session: None,
hook_report_sequences: HashMap::new(),
suppressed_full_lifecycle_hook_reports: HashMap::new(),
@@ -217,6 +219,7 @@ impl TerminalState {
now,
);
if starts_acquisition {
+ self.codex_prompt_ready = false;
self.agent_process_acquisition_pending = true;
}
mutation
@@ -419,6 +422,9 @@ impl TerminalState {
};
}
self.detected_agent = agent;
+ if process_exited || agent != Some(Agent::Codex) || fallback_state == AgentState::Blocked {
+ self.codex_prompt_ready = false;
+ }
if let Some(agent) = agent {
let agent_label = crate::detect::agent_label(agent);
self.reconcile_agent_name_owner(agent_label, None);
@@ -1972,6 +1978,7 @@ impl TerminalState {
settle_delay: Duration,
timeout: Duration,
) {
+ self.codex_prompt_ready = false;
self.set_agent_name(name);
self.agent_process_acquisition_pending = true;
self.agent_name_owner = Some(AgentNameOwner {
@@ -2002,6 +2009,20 @@ impl TerminalState {
.is_some_and(|managed| matches!(managed.phase, ManagedAgentPhase::Active))
}
+ pub fn observe_codex_prompt_ready(&mut self, ready: bool) -> Option<TerminalStateMutation> {
+ if self.detected_agent != Some(Agent::Codex)
+ || self.recent_agent_process_exit.is_some()
+ || !self.managed_agent.is_some_and(|managed| {
+ managed.kind == Agent::Codex && managed.phase != ManagedAgentPhase::Active
+ })
+ || self.codex_prompt_ready == ready
+ {
+ return None;
+ }
+ self.codex_prompt_ready = ready;
+ Some(TerminalStateMutation::default())
+ }
+
pub fn managed_agent_kind(&self) -> Option<Agent> {
self.managed_agent.map(|managed| managed.kind)
}
@@ -2039,7 +2060,12 @@ impl TerminalState {
return true;
}
if managed.phase == ManagedAgentPhase::Blocked {
- if known_agent == Some(managed.kind) && self.state == AgentState::Idle {
+ if known_agent == Some(managed.kind)
+ && (self.state == AgentState::Idle
+ || managed.kind == Agent::Codex
+ && self.state == AgentState::Unknown
+ && self.codex_prompt_ready)
+ {
self.managed_agent = Some(ManagedAgent {
kind: managed.kind,
phase: ManagedAgentPhase::Active,
@@ -2067,7 +2093,12 @@ impl TerminalState {
return true;
}
if ready_after.is_none_or(|ready_after| now >= ready_after) {
- if known_agent == Some(managed.kind) && self.state == AgentState::Idle {
+ if known_agent == Some(managed.kind)
+ && (self.state == AgentState::Idle
+ || managed.kind == Agent::Codex
+ && self.state == AgentState::Unknown
+ && self.codex_prompt_ready)
+ {
self.managed_agent = Some(ManagedAgent {
kind: managed.kind,
phase: ManagedAgentPhase::Active,
@@ -2115,6 +2146,7 @@ impl TerminalState {
}
pub fn clear_agent_name(&mut self) {
+ self.codex_prompt_ready = false;
if self
.managed_agent_launch_session
.take()
@@ -2329,6 +2361,52 @@ mod tests {
}
#[test]
+ fn codex_managed_readiness_requires_current_prompt_without_idle() {
+ let now = Instant::now();
+ let mut terminal = test_terminal();
+ terminal.begin_managed_agent(
+ "reviewer".into(),
+ Agent::Codex,
+ now,
+ Duration::from_millis(100),
+ Duration::from_secs(1),
+ );
+ terminal.set_detected_state(Some(Agent::Codex), AgentState::Unknown);
+ terminal.observe_codex_prompt_ready(true);
+ terminal.reconcile_managed_agent_at(now, false);
+ assert!(!terminal.managed_agent_interactive_ready());
+ terminal.observe_codex_prompt_ready(false);
+ assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(100), false));
+ assert!(!terminal.managed_agent_interactive_ready());
+
+ terminal.observe_codex_prompt_ready(true);
+ assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(101), false));
+ assert!(terminal.managed_agent_interactive_ready());
+ assert_eq!(terminal.state, AgentState::Unknown);
+ terminal.observe_codex_prompt_ready(false);
+ assert!(terminal.managed_agent_interactive_ready());
+
+ terminal.begin_managed_agent(
+ "next".into(),
+ Agent::Codex,
+ now,
+ Duration::ZERO,
+ Duration::from_secs(1),
+ );
+ terminal.reconcile_managed_agent_at(now, false);
+ assert!(!terminal.managed_agent_interactive_ready());
+
+ terminal.set_detected_state(Some(Agent::Codex), AgentState::Blocked);
+ assert!(terminal.reconcile_managed_agent_at(now, false));
+ terminal.observe_codex_prompt_ready(false);
+ terminal.set_detected_state(Some(Agent::Codex), AgentState::Unknown);
+ assert!(!terminal.reconcile_managed_agent_at(now, false));
+ terminal.observe_codex_prompt_ready(true);
+ assert!(terminal.reconcile_managed_agent_at(now, false));
+ assert!(terminal.managed_agent_interactive_ready());
+ }
+
+ #[test]
fn managed_agent_mismatch_and_timeout_release_name() {
let now = Instant::now();
let mut mismatch = test_terminal();