aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-10 15:24:24 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-10 15:33:45 +0300
commit36a1b7f86444871f0c38ff36510a6e3c2eec48c6 (patch)
tree4a2d100eef7f26af7feaa93ce661423c3994c54e /src
parent8d13492926829c5cf0ea42c8613344cd65ff59fa (diff)
feat: distribute agent detection manifests
Diffstat (limited to 'src')
-rw-r--r--src/agent_detection_policy.rs293
-rw-r--r--src/api/mod.rs1
-rw-r--r--src/api/schema.rs87
-rw-r--r--src/api/server.rs3
-rw-r--r--src/app/actions.rs70
-rw-r--r--src/app/agents.rs1
-rw-r--r--src/app/api.rs344
-rw-r--r--src/app/api/agents.rs63
-rw-r--r--src/app/api/panes.rs2
-rw-r--r--src/app/input/settings.rs6
-rw-r--r--src/app/mod.rs20
-rw-r--r--src/app/runtime.rs20
-rw-r--r--src/app/state.rs11
-rw-r--r--src/cli.rs1
-rw-r--r--src/cli/agent.rs216
-rw-r--r--src/cli/server.rs83
-rw-r--r--src/detect/agents/amp.rs46
-rw-r--r--src/detect/agents/antigravity.rs76
-rw-r--r--src/detect/agents/claude_code.rs434
-rw-r--r--src/detect/agents/cline.rs19
-rw-r--r--src/detect/agents/codex.rs258
-rw-r--r--src/detect/agents/cursor.rs79
-rw-r--r--src/detect/agents/droid.rs39
-rw-r--r--src/detect/agents/gemini.rs31
-rw-r--r--src/detect/agents/github_copilot.rs28
-rw-r--r--src/detect/agents/grok.rs37
-rw-r--r--src/detect/agents/hermes.rs30
-rw-r--r--src/detect/agents/kilo.rs13
-rw-r--r--src/detect/agents/kimi.rs97
-rw-r--r--src/detect/agents/kiro.rs60
-rw-r--r--src/detect/agents/mod.rs105
-rw-r--r--src/detect/agents/opencode.rs58
-rw-r--r--src/detect/agents/pi.rs9
-rw-r--r--src/detect/agents/qodercli.rs86
-rw-r--r--src/detect/manifest.rs1411
-rw-r--r--src/detect/manifest/tests.rs498
-rw-r--r--src/detect/manifest_update.rs774
-rw-r--r--src/detect/manifests/amp.toml29
-rw-r--r--src/detect/manifests/antigravity.toml33
-rw-r--r--src/detect/manifests/claude.toml179
-rw-r--r--src/detect/manifests/cline.toml26
-rw-r--r--src/detect/manifests/codex.toml105
-rw-r--r--src/detect/manifests/cursor.toml57
-rw-r--r--src/detect/manifests/droid.toml48
-rw-r--r--src/detect/manifests/gemini.toml25
-rw-r--r--src/detect/manifests/github-copilot.toml30
-rw-r--r--src/detect/manifests/grok.toml28
-rw-r--r--src/detect/manifests/hermes.toml30
-rw-r--r--src/detect/manifests/kilo.toml24
-rw-r--r--src/detect/manifests/kimi.toml77
-rw-r--r--src/detect/manifests/kiro.toml51
-rw-r--r--src/detect/manifests/opencode.toml37
-rw-r--r--src/detect/manifests/pi.toml13
-rw-r--r--src/detect/manifests/qodercli.toml38
-rw-r--r--src/detect/mod.rs2104
-rw-r--r--src/events.rs5
-rw-r--r--src/main.rs1
-rw-r--r--src/pane.rs218
-rw-r--r--src/pane/agent_detection.rs412
-rw-r--r--src/server/headless.rs18
-rw-r--r--src/terminal/runtime.rs19
-rw-r--r--src/terminal/state.rs128
-rw-r--r--src/ui/settings.rs8
63 files changed, 4812 insertions, 4340 deletions
diff --git a/src/agent_detection_policy.rs b/src/agent_detection_policy.rs
deleted file mode 100644
index 7a2677a2..00000000
--- a/src/agent_detection_policy.rs
+++ /dev/null
@@ -1,293 +0,0 @@
-use crate::detect::{Agent, AgentDetection, AgentState};
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(crate) struct PtySignal {
- pub active: bool,
- pub tainted: bool,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(crate) struct DetectionPolicyInput {
- pub agent: Option<Agent>,
- pub screen_detection: AgentDetection,
- pub process_exited: bool,
- pub startup_grace_active: bool,
- pub pty_signal: Option<PtySignal>,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(crate) enum DetectionPolicyDecision {
- Publish(AgentDetection),
- Freeze,
-}
-
-fn screen_blocked_or_idle_fallback(detection: AgentDetection) -> AgentDetection {
- if detection.visible_blocker {
- return AgentDetection {
- state: AgentState::Blocked,
- skip_state_update: false,
- visible_blocker: true,
- visible_working: false,
- };
- }
-
- AgentDetection {
- state: AgentState::Idle,
- skip_state_update: false,
- visible_blocker: false,
- visible_working: false,
- }
-}
-
-#[cfg(test)]
-pub(crate) fn full_lifecycle_detected_agent(agent: Agent) -> bool {
- matches!(
- agent,
- Agent::Pi | Agent::Hermes | Agent::OpenCode | Agent::Kilo
- )
-}
-
-pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> bool {
- matches!(
- (source, agent_label),
- ("herdr:pi", "pi")
- | ("herdr:omp", "omp")
- | ("herdr:hermes", "hermes")
- | ("herdr:opencode", "opencode")
- | ("herdr:kilo", "kilo")
- )
-}
-
-pub(crate) fn apply_detection_policy(input: DetectionPolicyInput) -> DetectionPolicyDecision {
- if input.process_exited {
- return DetectionPolicyDecision::Publish(input.screen_detection);
- }
-
- if input.startup_grace_active {
- return DetectionPolicyDecision::Freeze;
- }
-
- if input.agent.is_none() {
- return DetectionPolicyDecision::Publish(input.screen_detection);
- };
-
- let Some(pty_signal) = input.pty_signal else {
- return DetectionPolicyDecision::Publish(input.screen_detection);
- };
-
- if pty_signal.tainted {
- return DetectionPolicyDecision::Freeze;
- }
-
- if pty_signal.active {
- return DetectionPolicyDecision::Publish(AgentDetection {
- state: AgentState::Working,
- skip_state_update: false,
- visible_blocker: false,
- visible_working: false,
- });
- }
-
- DetectionPolicyDecision::Publish(screen_blocked_or_idle_fallback(input.screen_detection))
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn detection(state: AgentState) -> AgentDetection {
- AgentDetection {
- state,
- skip_state_update: false,
- visible_blocker: false,
- visible_working: state == AgentState::Working,
- }
- }
-
- fn input(screen_detection: AgentDetection) -> DetectionPolicyInput {
- DetectionPolicyInput {
- agent: Some(Agent::Codex),
- screen_detection,
- process_exited: false,
- startup_grace_active: false,
- pty_signal: Some(PtySignal {
- active: false,
- tainted: false,
- }),
- }
- }
-
- #[test]
- fn classifies_full_lifecycle_hook_sources() {
- assert!(full_lifecycle_hook_authority("herdr:pi", "pi"));
- assert!(full_lifecycle_hook_authority("herdr:omp", "omp"));
- assert!(full_lifecycle_hook_authority("herdr:hermes", "hermes"));
- assert!(full_lifecycle_hook_authority("herdr:opencode", "opencode"));
- assert!(full_lifecycle_hook_authority("herdr:kilo", "kilo"));
- assert!(!full_lifecycle_hook_authority("herdr:copilot", "copilot"));
- assert!(!full_lifecycle_hook_authority("herdr:codex", "codex"));
- assert!(!full_lifecycle_hook_authority("herdr:claude", "claude"));
- assert!(!full_lifecycle_hook_authority("herdr:cursor", "cursor"));
- assert!(!full_lifecycle_hook_authority("herdr:kimi", "kimi"));
- assert!(!full_lifecycle_hook_authority("herdr:droid", "droid"));
- assert!(!full_lifecycle_hook_authority("herdr:qodercli", "qodercli"));
- assert!(!full_lifecycle_hook_authority("custom", "pi"));
- }
-
- #[test]
- fn classifies_full_lifecycle_detected_agents_without_omp_variant() {
- assert!(full_lifecycle_detected_agent(Agent::Pi));
- assert!(full_lifecycle_detected_agent(Agent::Hermes));
- assert!(full_lifecycle_detected_agent(Agent::OpenCode));
- assert!(full_lifecycle_detected_agent(Agent::Kilo));
- assert!(!full_lifecycle_detected_agent(Agent::GithubCopilot));
- assert!(!full_lifecycle_detected_agent(Agent::Kimi));
- assert!(!full_lifecycle_detected_agent(Agent::Droid));
- assert!(!full_lifecycle_detected_agent(Agent::Qodercli));
- assert!(!full_lifecycle_detected_agent(Agent::Codex));
- assert!(!full_lifecycle_detected_agent(Agent::Claude));
- }
-
- #[test]
- fn startup_grace_freezes_publish() {
- let mut input = input(detection(AgentState::Working));
- input.startup_grace_active = true;
-
- assert_eq!(
- apply_detection_policy(input),
- DetectionPolicyDecision::Freeze
- );
- }
-
- #[test]
- fn taint_freezes_weak_publish() {
- let mut input = input(detection(AgentState::Idle));
- input.pty_signal = Some(PtySignal {
- active: false,
- tainted: true,
- });
-
- assert_eq!(
- apply_detection_policy(input),
- DetectionPolicyDecision::Freeze
- );
- }
-
- #[test]
- fn taint_freezes_visible_blocker_until_pty_is_quiet() {
- let mut blocker = detection(AgentState::Blocked);
- blocker.visible_blocker = true;
- let mut input = input(blocker);
- input.pty_signal = Some(PtySignal {
- active: false,
- tainted: true,
- });
-
- assert_eq!(
- apply_detection_policy(input),
- DetectionPolicyDecision::Freeze
- );
- }
-
- #[test]
- fn process_exit_publishes_even_during_taint() {
- let mut input = input(detection(AgentState::Idle));
- input.process_exited = true;
- input.pty_signal = Some(PtySignal {
- active: true,
- tainted: true,
- });
-
- assert_eq!(
- apply_detection_policy(input),
- DetectionPolicyDecision::Publish(detection(AgentState::Idle))
- );
- }
-
- #[test]
- fn pty_activity_publishes_working_without_inventing_visible_working() {
- let mut input = input(detection(AgentState::Idle));
- input.pty_signal = Some(PtySignal {
- active: true,
- tainted: false,
- });
-
- assert_eq!(
- apply_detection_policy(input),
- DetectionPolicyDecision::Publish(AgentDetection {
- state: AgentState::Working,
- skip_state_update: false,
- visible_blocker: false,
- visible_working: false,
- })
- );
- }
-
- #[test]
- fn active_pty_wins_over_visible_blocker() {
- let mut blocker = detection(AgentState::Blocked);
- blocker.visible_blocker = true;
- let mut input = input(blocker);
- input.pty_signal = Some(PtySignal {
- active: true,
- tainted: false,
- });
-
- assert_eq!(
- apply_detection_policy(input),
- DetectionPolicyDecision::Publish(AgentDetection {
- state: AgentState::Working,
- skip_state_update: false,
- visible_blocker: false,
- visible_working: false,
- })
- );
- }
-
- #[test]
- fn silent_pty_with_visible_blocker_publishes_blocked() {
- let mut screen = detection(AgentState::Blocked);
- screen.visible_blocker = true;
- let input = input(screen);
-
- assert_eq!(
- apply_detection_policy(input),
- DetectionPolicyDecision::Publish(AgentDetection {
- state: AgentState::Blocked,
- skip_state_update: false,
- visible_blocker: true,
- visible_working: false,
- })
- );
- }
-
- #[test]
- fn silent_pty_downgrades_screen_working_to_idle() {
- let input = input(detection(AgentState::Working));
-
- assert_eq!(
- apply_detection_policy(input),
- DetectionPolicyDecision::Publish(AgentDetection {
- state: AgentState::Idle,
- skip_state_update: false,
- visible_blocker: false,
- visible_working: false,
- })
- );
- }
-
- #[test]
- fn silent_pty_downgrades_weak_screen_blocked_to_idle() {
- let input = input(detection(AgentState::Blocked));
-
- assert_eq!(
- apply_detection_policy(input),
- DetectionPolicyDecision::Publish(AgentDetection {
- state: AgentState::Idle,
- skip_state_update: false,
- visible_blocker: false,
- visible_working: false,
- })
- );
- }
-}
diff --git a/src/api/mod.rs b/src/api/mod.rs
index c168313b..a9a8e25b 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -22,6 +22,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
matches!(
&request.method,
Method::ServerReloadConfig(_)
+ | Method::ServerReloadAgentManifests(_)
| Method::NotificationShow(_)
| Method::WorkspaceCreate(_)
| Method::WorkspaceFocus(_)
diff --git a/src/api/schema.rs b/src/api/schema.rs
index 33040248..0eb2d8e2 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -6,6 +6,10 @@ pub mod panes;
pub use panes::*;
+fn is_false(value: &bool) -> bool {
+ !*value
+}
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Request {
pub id: String,
@@ -24,6 +28,10 @@ pub enum Method {
ServerLiveHandoff(ServerLiveHandoffParams),
#[serde(rename = "server.reload_config")]
ServerReloadConfig(EmptyParams),
+ #[serde(rename = "server.agent_manifests")]
+ ServerAgentManifests(EmptyParams),
+ #[serde(rename = "server.reload_agent_manifests")]
+ ServerReloadAgentManifests(EmptyParams),
#[serde(rename = "notification.show")]
NotificationShow(NotificationShowParams),
#[serde(rename = "workspace.create")]
@@ -64,6 +72,8 @@ pub enum Method {
AgentGet(AgentTarget),
#[serde(rename = "agent.read")]
AgentRead(AgentReadParams),
+ #[serde(rename = "agent.explain")]
+ AgentExplain(AgentTarget),
#[serde(rename = "agent.send")]
AgentSend(AgentSendParams),
#[serde(rename = "agent.rename")]
@@ -342,6 +352,7 @@ pub enum ReadSource {
Visible,
Recent,
RecentUnwrapped,
+ Detection,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -665,6 +676,9 @@ pub enum ResponseResult {
PaneRead {
read: PaneReadResult,
},
+ AgentExplain {
+ explain: serde_json::Value,
+ },
SubscriptionStarted {},
WaitMatched {
event: EventEnvelope,
@@ -687,6 +701,16 @@ pub enum ResponseResult {
target: IntegrationTarget,
details: IntegrationUninstallResult,
},
+ AgentManifestReload {
+ manifests: Vec<AgentManifestInfo>,
+ },
+ AgentManifestStatus {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ last_check_unix: Option<u64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ last_result: Option<String>,
+ manifests: Vec<AgentManifestInfo>,
+ },
ConfigReload {
status: crate::config::ConfigReloadStatus,
diagnostics: Vec<String>,
@@ -695,6 +719,26 @@ pub enum ResponseResult {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentManifestInfo {
+ pub agent: String,
+ pub source: String,
+ pub source_kind: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub active_version: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cached_remote_version: Option<String>,
+ pub local_override_shadowing_remote: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub remote_update_result: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub remote_update_error: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub remote_last_checked_unix: Option<u64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub warning: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceInfo {
pub workspace_id: String,
pub number: usize,
@@ -764,6 +808,8 @@ pub struct AgentInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_agent: Option<String>,
pub agent_status: AgentStatus,
+ #[serde(default, skip_serializing_if = "is_false")]
+ pub screen_detection_skipped: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_status: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
@@ -1116,6 +1162,47 @@ mod tests {
}
#[test]
+ fn request_round_trips_for_server_reload_agent_manifests() {
+ let request = Request {
+ id: "req_reload_agent_manifests".into(),
+ method: Method::ServerReloadAgentManifests(EmptyParams::default()),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "server.reload_agent_manifests");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+ }
+
+ #[test]
+ fn request_round_trips_for_server_agent_manifests() {
+ let request = Request {
+ id: "req_agent_manifests".into(),
+ method: Method::ServerAgentManifests(EmptyParams::default()),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "server.agent_manifests");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+ }
+
+ #[test]
+ fn request_round_trips_for_agent_explain() {
+ let request = Request {
+ id: "req_agent_explain".into(),
+ method: Method::AgentExplain(AgentTarget {
+ target: "agent-1".into(),
+ }),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "agent.explain");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+ }
+
+ #[test]
fn notification_show_request_parses() {
let json = r#"{"id":"req_1","method":"notification.show","params":{"title":"build failed","body":"api workspace","position":"top-left","sound":"request"}}"#;
let request: Request = serde_json::from_str(json).unwrap();
diff --git a/src/api/server.rs b/src/api/server.rs
index 94e8c725..0d49d21d 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -276,6 +276,8 @@ fn api_method_name(method: &Method) -> &'static str {
Method::ServerStop(_) => "server.stop",
Method::ServerLiveHandoff(_) => "server.live_handoff",
Method::ServerReloadConfig(_) => "server.reload_config",
+ Method::ServerAgentManifests(_) => "server.agent_manifests",
+ Method::ServerReloadAgentManifests(_) => "server.reload_agent_manifests",
Method::NotificationShow(_) => "notification.show",
Method::WorkspaceCreate(_) => "workspace.create",
Method::WorkspaceList(_) => "workspace.list",
@@ -296,6 +298,7 @@ fn api_method_name(method: &Method) -> &'static str {
Method::AgentList(_) => "agent.list",
Method::AgentGet(_) => "agent.get",
Method::AgentRead(_) => "agent.read",
+ Method::AgentExplain(_) => "agent.explain",
Method::AgentSend(_) => "agent.send",
Method::AgentRename(_) => "agent.rename",
Method::AgentFocus(_) => "agent.focus",
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 249d0e64..b218e53c 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -2312,6 +2312,36 @@ impl AppState {
}
Vec::new()
}
+ AppEvent::AgentDetectionManifestsUpdated { updated, status } => {
+ self.agent_manifest_update_status = status;
+ self.refresh_agent_manifest_summaries();
+ if !updated.is_empty()
+ && matches!(
+ self.toast_config.delivery,
+ crate::config::ToastDelivery::Herdr
+ )
+ {
+ let agent_list = updated
+ .iter()
+ .map(|item| {
+ format!(
+ "{} {}",
+ crate::detect::agent_label(item.agent),
+ item.version
+ )
+ })
+ .collect::<Vec<_>>()
+ .join(", ");
+ self.toast = Some(ToastNotification {
+ kind: ToastKind::UpdateInstalled,
+ title: "Agent detection rules updated".to_string(),
+ context: agent_list,
+ position: None,
+ target: None,
+ });
+ }
+ Vec::new()
+ }
AppEvent::StateChanged {
pane_id,
agent,
@@ -2520,14 +2550,15 @@ impl AppState {
) -> Option<PaneStateUpdate> {
let observed_at = std::time::Instant::now();
self.update_terminal_state(pane_id, |terminal| {
- let agent = terminal
- .effective_known_agent()
- .or(terminal.detected_agent)?;
+ let agent = terminal.effective_known_agent().or(terminal.detected_agent);
+ if agent.is_none() && !terminal.full_lifecycle_hook_authority_active() {
+ return None;
+ }
Some(terminal.set_detected_state_with_screen_signals_at(
- Some(agent),
+ agent,
AgentState::Idle,
false,
- false,
+ true,
false,
true,
observed_at,
@@ -4684,6 +4715,35 @@ mod tests {
}
#[test]
+ fn agent_detection_manifest_update_event_updates_status_and_toast() {
+ let mut state = AppState::test_new();
+ state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
+ let status = crate::detect::manifest_update::ManifestUpdateStatus {
+ last_result: Some("checked".to_string()),
+ ..Default::default()
+ };
+
+ let updates = state.handle_app_event(AppEvent::AgentDetectionManifestsUpdated {
+ updated: vec![crate::detect::manifest_update::ManifestUpdateCommit {
+ agent: Agent::Codex,
+ version: crate::detect::manifest_update::ManifestVersion::parse("2026.06.10.1")
+ .unwrap(),
+ }],
+ status,
+ });
+
+ assert!(updates.is_empty());
+ assert_eq!(
+ state.agent_manifest_update_status.last_result.as_deref(),
+ Some("checked")
+ );
+ let toast = state.toast.as_ref().expect("manifest update toast");
+ assert_eq!(toast.kind, ToastKind::UpdateInstalled);
+ assert_eq!(toast.title, "Agent detection rules updated");
+ assert_eq!(toast.context, "codex 2026.06.10.1");
+ }
+
+ #[test]
fn toggle_zoom_works() {
let mut state = app_with_workspaces(&["test"]);
state.workspaces[0].test_split(Direction::Horizontal);
diff --git a/src/app/agents.rs b/src/app/agents.rs
index 6d463b28..ea828eae 100644
--- a/src/app/agents.rs
+++ b/src/app/agents.rs
@@ -415,6 +415,7 @@ impl App {
title: pane.title,
display_agent: pane.display_agent,
agent_status: pane.agent_status,
+ screen_detection_skipped: terminal.full_lifecycle_hook_authority_active(),
custom_status: pane.custom_status,
state_labels: pane.state_labels,
agent_session: pane.agent_session,
diff --git a/src/app/api.rs b/src/app/api.rs
index 76d9c537..ea4ea378 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -69,6 +69,7 @@ impl App {
if let AppEvent::PaneDied { pane_id } = &ev {
let previous_toast = self.state.toast.clone();
if let Some(update) = self.state.publish_pane_process_exit_if_agent(*pane_id) {
+ self.sync_full_lifecycle_authority_detection_pauses();
self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast);
self.emit_pane_state_update(&update);
self.emit_terminal_or_system_agent_notifications(std::slice::from_ref(&update));
@@ -123,17 +124,18 @@ impl App {
} else {
None
};
+ let manifest_update_agents =
+ if let AppEvent::AgentDetectionManifestsUpdated { updated, .. } = &ev {
+ Some(updated.iter().map(|item| item.agent).collect::<Vec<_>>())
+ } else {
+ None
+ };
let terminal_cwd_reported = matches!(ev, AppEvent::TerminalCwdReported { .. });
let previous_toast = self.state.toast.clone();
let pane_updates = self.state.handle_app_event(ev);
- if terminal_cwd_reported {
- self.mark_git_status_refresh_due(Instant::now());
- }
- for update in &pane_updates {
- self.refresh_new_herdr_toast_context_for_update(update, &previous_toast);
- self.emit_pane_state_update(update);
+ if let Some(agents) = manifest_update_agents {
+ self.reset_agent_detection_for_agents(&agents);
}
- self.sync_agent_metadata_deadline();
if let Some((pane_id, agent)) = released_agent {
if pane_updates.iter().any(|update| update.pane_id == pane_id) {
if let Some((ws_idx, _)) = self.find_pane(pane_id) {
@@ -147,6 +149,15 @@ impl App {
}
}
}
+ self.sync_full_lifecycle_authority_detection_pauses();
+ if terminal_cwd_reported {
+ self.mark_git_status_refresh_due(Instant::now());
+ }
+ for update in &pane_updates {
+ self.refresh_new_herdr_toast_context_for_update(update, &previous_toast);
+ self.emit_pane_state_update(update);
+ }
+ self.sync_agent_metadata_deadline();
if let Some(overlay) = overlay_state {
self.restore_overlay_after_exit(overlay);
}
@@ -175,6 +186,29 @@ impl App {
self.shutdown_detached_terminal_runtimes();
}
+ fn reset_agent_detection_for_agents(&self, agents: &[crate::detect::Agent]) {
+ if agents.is_empty() {
+ return;
+ }
+ for (terminal_id, terminal) in &self.state.terminals {
+ let Some(agent) = terminal.effective_known_agent().or(terminal.detected_agent) else {
+ continue;
+ };
+ if !agents.contains(&agent) {
+ continue;
+ }
+ if let Some(runtime) = self.terminal_runtimes.get(terminal_id) {
+ runtime.reset_agent_detection();
+ }
+ }
+ }
+
+ fn reset_all_agent_detection_runtimes(&self) {
+ for runtime in self.terminal_runtimes.values() {
+ runtime.reset_agent_detection();
+ }
+ }
+
pub(crate) fn refresh_new_herdr_toast_context_for_update(
&mut self,
update: &crate::app::actions::PaneStateUpdate,
@@ -218,6 +252,26 @@ impl App {
}
}
+ fn sync_full_lifecycle_authority_detection_pauses(&self) {
+ for workspace in &self.state.workspaces {
+ for tab in &workspace.tabs {
+ for pane in tab.panes.values() {
+ let Some(terminal) = self.state.terminals.get(&pane.attached_terminal_id)
+ else {
+ continue;
+ };
+ let Some(runtime) = self.terminal_runtimes.get(&pane.attached_terminal_id)
+ else {
+ continue;
+ };
+ runtime.set_full_lifecycle_authority_active(
+ terminal.full_lifecycle_hook_authority_active(),
+ );
+ }
+ }
+ }
+ }
+
pub(crate) fn show_clipboard_feedback(&mut self) {
if !self.state.toast_config.clipboard.enabled {
self.state.copy_feedback = None;
@@ -624,6 +678,39 @@ impl App {
},
}
}
+ Method::ServerAgentManifests(_) => {
+ self.state.refresh_agent_manifest_summaries();
+ let update_status = crate::detect::manifest_update::load_status();
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::AgentManifestStatus {
+ last_check_unix: update_status.last_check_unix,
+ last_result: update_status.last_result.clone(),
+ manifests: self
+ .state
+ .agent_manifest_summaries
+ .clone()
+ .into_iter()
+ .map(|summary| agent_manifest_info(summary, &update_status))
+ .collect(),
+ },
+ }
+ }
+ Method::ServerReloadAgentManifests(_) => {
+ let summaries = crate::detect::manifest::reload_manifests();
+ self.state.agent_manifest_summaries = summaries.clone();
+ let update_status = crate::detect::manifest_update::load_status();
+ self.reset_all_agent_detection_runtimes();
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::AgentManifestReload {
+ manifests: summaries
+ .into_iter()
+ .map(|summary| agent_manifest_info(summary, &update_status))
+ .collect(),
+ },
+ }
+ }
Method::NotificationShow(params) => {
return self.handle_notification_show(request.id, params);
}
@@ -661,6 +748,7 @@ impl App {
Method::AgentRename(params) => return self.handle_agent_rename(request.id, params),
Method::AgentStart(params) => return self.handle_agent_start(request.id, params),
Method::AgentRead(params) => return self.handle_agent_read(request.id, params),
+ Method::AgentExplain(target) => return self.handle_agent_explain(request.id, target),
Method::AgentSend(params) => return self.handle_agent_send(request.id, params),
Method::PaneSplit(params) => return self.handle_pane_split(request.id, params),
Method::PaneSwap(params) => return self.handle_pane_swap(request.id, params),
@@ -838,6 +926,25 @@ fn sanitized_notification_text(value: &str, max_chars: usize) -> Option<String>
(!sanitized.is_empty()).then_some(sanitized)
}
+fn agent_manifest_info(
+ summary: crate::detect::manifest::AgentManifestSummary,
+ update_status: &crate::detect::manifest_update::ManifestUpdateStatus,
+) -> crate::api::schema::AgentManifestInfo {
+ let remote = update_status.agent_status(summary.agent);
+ crate::api::schema::AgentManifestInfo {
+ agent: crate::detect::agent_label(summary.agent).to_string(),
+ source: summary.active_source.label(),
+ source_kind: summary.active_source.kind().to_string(),
+ active_version: summary.active_version,
+ cached_remote_version: summary.cached_remote_version,
+ local_override_shadowing_remote: summary.local_override_shadowing_remote,
+ remote_update_result: remote.as_ref().map(|status| status.last_result.clone()),
+ remote_update_error: remote.as_ref().and_then(|status| status.last_error.clone()),
+ remote_last_checked_unix: remote.and_then(|status| status.last_checked_unix),
+ warning: summary.warning,
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -853,6 +960,229 @@ mod tests {
assert!(status.success(), "git init failed for {}", path.display());
}
+ #[tokio::test]
+ async fn manifest_update_event_resets_matching_agent_detection_runtime() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("manifest-reset")];
+ app.state.ensure_test_terminals();
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .detected_agent = Some(Agent::Codex);
+ let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24);
+ let reset_notify = runtime.agent_detection_reset_notify_for_test();
+ app.terminal_runtimes.insert(terminal_id, runtime);
+
+ app.handle_internal_event(AppEvent::AgentDetectionManifestsUpdated {
+ updated: vec![crate::detect::manifest_update::ManifestUpdateCommit {
+ agent: Agent::Codex,
+ version: crate::detect::manifest_update::ManifestVersion::parse("2026.06.10.1")
+ .unwrap(),
+ }],
+ status: crate::detect::manifest_update::ManifestUpdateStatus::default(),
+ });
+
+ tokio::time::timeout(
+ std::time::Duration::from_millis(50),
+ reset_notify.notified(),
+ )
+ .await
+ .expect("matching agent detection runtime should be reset");
+ }
+
+ #[tokio::test]
+ async fn server_reload_agent_manifests_resets_detection_runtimes() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("manifest-reload")];
+ app.state.ensure_test_terminals();
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24);
+ let reset_notify = runtime.agent_detection_reset_notify_for_test();
+ app.terminal_runtimes.insert(terminal_id, runtime);
+
+ let response = app.handle_api_request(crate::api::schema::Request {
+ id: "reload_manifests".into(),
+ method: crate::api::schema::Method::ServerReloadAgentManifests(
+ crate::api::schema::EmptyParams::default(),
+ ),
+ });
+ let response: serde_json::Value = serde_json::from_str(&response).unwrap();
+ assert_eq!(response["result"]["type"], "agent_manifest_reload");
+ assert!(!response["result"]["manifests"]
+ .as_array()
+ .unwrap()
+ .is_empty());
+
+ tokio::time::timeout(
+ std::time::Duration::from_millis(50),
+ reset_notify.notified(),
+ )
+ .await
+ .expect("manual manifest reload should reset detection runtimes");
+ }
+
+ #[tokio::test]
+ async fn server_agent_manifests_reports_status_without_resetting_runtimes() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("manifest-status")];
+ app.state.ensure_test_terminals();
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24);
+ let reset_notify = runtime.agent_detection_reset_notify_for_test();
+ app.terminal_runtimes.insert(terminal_id, runtime);
+
+ let response = app.handle_api_request(crate::api::schema::Request {
+ id: "manifest_status".into(),
+ method: crate::api::schema::Method::ServerAgentManifests(
+ crate::api::schema::EmptyParams::default(),
+ ),
+ });
+ let response: serde_json::Value = serde_json::from_str(&response).unwrap();
+ assert_eq!(response["result"]["type"], "agent_manifest_status");
+ assert!(!response["result"]["manifests"]
+ .as_array()
+ .unwrap()
+ .is_empty());
+ assert!(
+ tokio::time::timeout(
+ std::time::Duration::from_millis(10),
+ reset_notify.notified(),
+ )
+ .await
+ .is_err(),
+ "status request should not reset detection runtimes"
+ );
+ }
+
+ #[tokio::test]
+ async fn agent_explain_evaluates_with_server_manifest_cache() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("agent-explain")];
+ app.state.ensure_test_terminals();
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .detected_agent = Some(Agent::Codex);
+ let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(
+ 80,
+ 24,
+ b"press enter to confirm or esc to cancel",
+ );
+ app.terminal_runtimes.insert(terminal_id, runtime);
+ let target = app.public_pane_id(0, pane_id).unwrap();
+
+ let response = app.handle_api_request(crate::api::schema::Request {
+ id: "agent_explain".into(),
+ method: crate::api::schema::Method::AgentExplain(crate::api::schema::AgentTarget {
+ target,
+ }),
+ });
+ let response: serde_json::Value = serde_json::from_str(&response).unwrap();
+
+ assert_eq!(response["result"]["type"], "agent_explain");
+ assert_eq!(response["result"]["explain"]["state"], "blocked");
+ assert_eq!(
+ response["result"]["explain"]["matched_rule"]["id"],
+ "live_strong_blocker"
+ );
+ }
+
+ #[tokio::test]
+ async fn agent_explain_reports_hook_only_full_lifecycle_authority() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("agent-explain-omp")];
+ app.state.ensure_test_terminals();
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .set_hook_authority(
+ "herdr:omp".to_string(),
+ "omp".to_string(),
+ AgentState::Working,
+ None,
+ Some(1),
+ );
+ let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"");
+ app.terminal_runtimes.insert(terminal_id, runtime);
+ let target = app.public_pane_id(0, pane_id).unwrap();
+
+ let response = app.handle_api_request(crate::api::schema::Request {
+ id: "agent_explain_omp".into(),
+ method: crate::api::schema::Method::AgentExplain(crate::api::schema::AgentTarget {
+ target,
+ }),
+ });
+ let response: serde_json::Value = serde_json::from_str(&response).unwrap();
+
+ assert_eq!(response["result"]["type"], "agent_explain");
+ assert_eq!(response["result"]["explain"]["agent"], "omp");
+ assert_eq!(response["result"]["explain"]["state"], "working");
+ assert_eq!(
+ response["result"]["explain"]["screen_detection_skip_reason"],
+ "full_lifecycle_hook_authority"
+ );
+ assert_eq!(
+ response["result"]["explain"]["matched_rule"],
+ serde_json::Value::Null
+ );
+ }
+
#[cfg(unix)]
#[tokio::test]
async fn herdr_toast_context_uses_live_root_runtime_cwd_label() {
diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs
index 4077b38c..f89be0ce 100644
--- a/src/app/api/agents.rs
+++ b/src/app/api/agents.rs
@@ -73,11 +73,13 @@ impl App {
ReadSource::Visible => pane.visible_text(),
ReadSource::Recent => pane.recent_text(requested_lines),
ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines),
+ ReadSource::Detection => pane.detection_text(),
},
ReadFormat::Ansi => match params.source {
ReadSource::Visible => pane.visible_ansi(),
ReadSource::Recent => pane.recent_ansi(requested_lines),
ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines),
+ ReadSource::Detection => pane.detection_text(),
},
};
@@ -102,6 +104,67 @@ impl App {
)
}
+ pub(super) fn handle_agent_explain(&mut self, id: String, target: AgentTarget) -> String {
+ let resolved = match self.resolve_terminal_target(&target.target) {
+ Ok(resolved) => resolved,
+ Err(err) => return encode_error_body(id, self.agent_target_error_body(err)),
+ };
+ let Some((pane, _workspace_id)) = self.lookup_runtime(resolved.ws_idx, resolved.pane_id)
+ else {
+ return agent_not_found(id, &target.target);
+ };
+ let Some(terminal_id) = self
+ .state
+ .workspaces
+ .get(resolved.ws_idx)
+ .and_then(|workspace| workspace.terminal_id(resolved.pane_id))
+ else {
+ return agent_not_found(id, &target.target);
+ };
+ let Some(terminal) = self.state.terminals.get(terminal_id) else {
+ return agent_not_found(id, &target.target);
+ };
+ if terminal.full_lifecycle_hook_authority_active() {
+ let explain = serde_json::json!({
+ "agent": terminal.effective_agent_label().unwrap_or("unknown"),
+ "state": crate::detect::manifest::agent_state_label(terminal.state),
+ "manifest_source": null,
+ "manifest_version": null,
+ "cached_remote_version": null,
+ "local_override_shadowing_remote": false,
+ "remote_update_status": null,
+ "remote_update_error": null,
+ "matched_rule": null,
+ "visible_idle": false,
+ "visible_blocker": false,
+ "visible_working": false,
+ "screen_detection_skipped": true,
+ "screen_detection_skip_reason": "full_lifecycle_hook_authority",
+ "skip_state_update": false,
+ "skipped_update_reason": null,
+ "fallback_reason": null,
+ "warning": null,
+ "evaluated_rules": [],
+ });
+ return encode_success(id, ResponseResult::AgentExplain { explain });
+ }
+ let Some(agent) = terminal.effective_known_agent().or(terminal.detected_agent) else {
+ return encode_error(
+ id,
+ "agent_explain_unavailable",
+ format!(
+ "agent target {} does not have a detected agent label",
+ target.target
+ ),
+ );
+ };
+
+ let explain = crate::detect::manifest::explain(agent, &pane.detection_text());
+ let value = crate::detect::manifest::explain_to_json_value(&explain);
+
+ encode_success(id, ResponseResult::AgentExplain { explain: value })
+ }
+
pub(super) fn handle_agent_send(&mut self, id: String, params: AgentSendParams) -> String {
let resolved = match self.resolve_terminal_target(&params.target) {
Ok(resolved) => resolved,
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index d824d26b..5afac850 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -616,11 +616,13 @@ impl App {
ReadSource::Visible => pane.visible_text(),
ReadSource::Recent => pane.recent_text(requested_lines),
ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines),
+ ReadSource::Detection => pane.detection_text(),
},
ReadFormat::Ansi => match params.source {
ReadSource::Visible => pane.visible_ansi(),
ReadSource::Recent => pane.recent_ansi(requested_lines),
ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines),
+ ReadSource::Detection => pane.detection_text(),
},
};
diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs
index 66ec3226..0e7c0d81 100644
--- a/src/app/input/settings.rs
+++ b/src/app/input/settings.rs
@@ -603,6 +603,12 @@ mod tests {
KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()),
);
assert_eq!(state.settings.section, SettingsSection::Integrations);
+
+ update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()),
+ );
+ assert_eq!(state.settings.section, SettingsSection::PaneLabels);
}
#[test]
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 35ab92b1..c0af6754 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -107,6 +107,7 @@ pub struct App {
pub(crate) next_resize_poll: Instant,
pub(crate) next_animation_tick: Option<Instant>,
pub(crate) next_auto_update_check: Option<Instant>,
+ pub(crate) next_agent_manifest_update_check: Option<Instant>,
pub(crate) agent_metadata_deadline: Option<Instant>,
pub(crate) pending_agent_resume_deadline: Option<Instant>,
pub(crate) selection_autoscroll_deadline: Option<Instant>,
@@ -398,6 +399,8 @@ impl App {
state::Mode::Navigate
};
+ let agent_manifest_summaries = crate::detect::manifest::reload_manifests();
+
let mut state = AppState {
terminals: std::collections::HashMap::new(),
direct_attach_resize_locks: std::collections::HashSet::new(),
@@ -535,6 +538,8 @@ impl App {
original_theme: None,
},
integration_recommendations: crate::integration::integration_recommendations(),
+ agent_manifest_summaries,
+ agent_manifest_update_status: crate::detect::manifest_update::load_status(),
integration_install_messages: Vec::new(),
global_menu: state::MenuListState::new(0),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
@@ -557,6 +562,10 @@ impl App {
if auto_updates_enabled(no_session) {
let update_tx = event_tx.clone();
std::thread::spawn(move || crate::update::auto_update(update_tx));
+ let manifest_update_tx = event_tx.clone();
+ std::thread::spawn(move || {
+ crate::detect::manifest_update::auto_update(manifest_update_tx)
+ });
}
let last_focus = state.active.and_then(|idx| {
@@ -585,6 +594,8 @@ impl App {
next_animation_tick: None,
next_auto_update_check: auto_updates_enabled(no_session)
.then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL),
+ next_agent_manifest_update_check: auto_updates_enabled(no_session)
+ .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL),
agent_metadata_deadline: None,
pending_agent_resume_deadline: None,
session_save_deadline: None,
@@ -635,6 +646,15 @@ impl App {
let pane_id_aliases = crate::persist::handoff_pane_aliases(snapshot, &workspaces);
app.no_session = false;
+ if auto_updates_enabled(app.no_session) {
+ let now = Instant::now();
+ app.next_auto_update_check = app
+ .state
+ .update_available
+ .is_none()
+ .then_some(now + AUTO_UPDATE_CHECK_INTERVAL);
+ app.next_agent_manifest_update_check = Some(now + AUTO_UPDATE_CHECK_INTERVAL);
+ }
app.state.detach_exits = false;
app.state.pane_id_aliases = pane_id_aliases;
app.state.workspaces = workspaces;
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index ca780360..cb2e5712 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -257,6 +257,13 @@ impl App {
}
if self
+ .next_agent_manifest_update_check
+ .is_some_and(|deadline| now >= deadline)
+ {
+ self.run_agent_manifest_update_check();
+ }
+
+ if self
.session_save_deadline
.is_some_and(|deadline| now >= deadline)
{
@@ -448,6 +455,18 @@ impl App {
std::thread::spawn(move || crate::update::auto_update(update_tx));
}
+ pub(crate) fn run_agent_manifest_update_check(&mut self) {
+ if !auto_updates_enabled(self.no_session) {
+ self.next_agent_manifest_update_check = None;
+ return;
+ }
+
+ self.next_agent_manifest_update_check = Some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL);
+
+ let manifest_update_tx = self.event_tx.clone();
+ std::thread::spawn(move || crate::detect::manifest_update::auto_update(manifest_update_tx));
+ }
+
pub(crate) fn start_git_status_refresh_if_due(&mut self, now: Instant) {
let Some(deadline) = self.git_refresh_deadline() else {
return;
@@ -531,6 +550,7 @@ impl App {
.then(|| self.git_refresh_deadline())
.flatten(),
self.next_auto_update_check,
+ self.next_agent_manifest_update_check,
self.agent_metadata_deadline,
self.pending_agent_resume_deadline,
self.session_save_deadline,
diff --git a/src/app/state.rs b/src/app/state.rs
index 01f3d9c8..f37ccb22 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1377,6 +1377,10 @@ pub struct AppState {
pub settings: SettingsState,
/// Cached integration recommendations for onboarding/settings UI.
pub integration_recommendations: Vec<crate::integration::IntegrationRecommendation>,
+ /// Cached detection manifest source/version summaries for runtime/API status.
+ pub agent_manifest_summaries: Vec<crate::detect::manifest::AgentManifestSummary>,
+ /// Cached remote detection manifest update diagnostics for runtime/API status.
+ pub agent_manifest_update_status: crate::detect::manifest_update::ManifestUpdateStatus,
/// Result messages from the latest integration install action.
pub integration_install_messages: Vec<String>,
/// Highlight state for the bottom-right global launcher menu.
@@ -1433,6 +1437,10 @@ impl AppState {
.any(|item| item.state == crate::integration::IntegrationStatusKind::Outdated)
}
+ pub(crate) fn refresh_agent_manifest_summaries(&mut self) {
+ self.agent_manifest_summaries = crate::detect::manifest::manifest_summaries();
+ }
+
pub(crate) fn global_menu_attention_badge_visible(&self) -> bool {
self.update_available.is_some() || self.integration_updates_available()
}
@@ -1700,6 +1708,9 @@ impl AppState {
original_theme: None,
},
integration_recommendations: Vec::new(),
+ agent_manifest_summaries: Vec::new(),
+ agent_manifest_update_status:
+ crate::detect::manifest_update::ManifestUpdateStatus::default(),
integration_install_messages: Vec::new(),
global_menu: MenuListState::new(0),
host_terminal_theme: TerminalTheme::default(),
diff --git a/src/cli.rs b/src/cli.rs
index 88408fc8..276462b7 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -737,6 +737,7 @@ pub(super) fn parse_read_source(value: &str) -> std::io::Result<ReadSource> {
"visible" => Ok(ReadSource::Visible),
"recent" => Ok(ReadSource::Recent),
"recent-unwrapped" | "recent_unwrapped" => Ok(ReadSource::RecentUnwrapped),
+ "detection" => Ok(ReadSource::Detection),
_ => Err(std::io::Error::other(format!(
"invalid read source: {value}"
))),
diff --git a/src/cli/agent.rs b/src/cli/agent.rs
index 5005eda1..24f1880d 100644
--- a/src/cli/agent.rs
+++ b/src/cli/agent.rs
@@ -19,6 +19,7 @@ pub(super) fn run_agent_command(args: &[String]) -> std::io::Result<i32> {
"wait" => agent_wait(&args[1..]),
"attach" => agent_attach(&args[1..]),
"start" => agent_start(&args[1..]),
+ "explain" => agent_explain(&args[1..]),
"help" | "--help" | "-h" => {
print_agent_help();
Ok(0)
@@ -30,6 +31,219 @@ pub(super) fn run_agent_command(args: &[String]) -> std::io::Result<i32> {
}
}
+fn agent_explain(args: &[String]) -> std::io::Result<i32> {
+ let mut file = None;
+ let mut agent = None;
+ let mut json = false;
+ let mut target = None;
+
+ let mut index = 0;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--file" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --file");
+ return Ok(2);
+ };
+ file = Some(value.clone());
+ index += 2;
+ }
+ "--agent" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --agent");
+ return Ok(2);
+ };
+ agent = Some(value.clone());
+ index += 2;
+ }
+ "--json" => {
+ json = true;
+ index += 1;
+ }
+ "--format" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --format");
+ return Ok(2);
+ };
+ match value.as_str() {
+ "json" => json = true,
+ "text" => json = false,
+ other => {
+ eprintln!("invalid --format: {other} (expected text or json)");
+ return Ok(2);
+ }
+ }
+ index += 2;
+ }
+ "help" | "--help" | "-h" => {
+ eprintln!("usage: herdr agent explain <target> [--json]");
+ eprintln!("usage: herdr agent explain --file PATH --agent LABEL [--json]");
+ return Ok(0);
+ }
+ value if value.starts_with('-') => {
+ eprintln!("unknown option: {value}");
+ return Ok(2);
+ }
+ value => {
+ if target.is_some() {
+ eprintln!("usage: herdr agent explain <target> [--json]");
+ return Ok(2);
+ }
+ target = Some(value.to_string());
+ index += 1;
+ }
+ }
+ }
+
+ let explain = if let Some(path) = file {
+ if target.is_some() {
+ eprintln!("usage: herdr agent explain --file PATH --agent LABEL [--json]");
+ return Ok(2);
+ }
+ let Some(agent_label) = agent else {
+ eprintln!("herdr agent explain --file requires --agent LABEL");
+ return Ok(2);
+ };
+ let content = std::fs::read_to_string(path)?;
+ crate::detect::manifest::explain_to_json_value(&crate::detect::manifest::explain_for_label(
+ &agent_label,
+ &content,
+ ))
+ } else {
+ let Some(target) = target else {
+ eprintln!("usage: herdr agent explain <target> [--json]");
+ eprintln!("usage: herdr agent explain --file PATH --agent LABEL [--json]");
+ return Ok(2);
+ };
+ if agent.is_some() {
+ eprintln!("--agent is only valid with --file");
+ return Ok(2);
+ }
+
+ let response = super::send_request(&Request {
+ id: "cli:agent:explain".into(),
+ method: Method::AgentExplain(AgentTarget {
+ target: target.to_owned(),
+ }),
+ })?;
+ if response.get("error").is_some() {
+ eprintln!("{}", serde_json::to_string(&response).unwrap());
+ return Ok(1);
+ }
+ response["result"]["explain"].clone()
+ };
+
+ if json {
+ println!("{explain}");
+ } else {
+ print_agent_explain_text(&explain);
+ }
+ Ok(0)
+}
+
+fn print_agent_explain_text(explain: &serde_json::Value) {
+ println!("agent: {}", explain["agent"].as_str().unwrap_or("unknown"));
+ println!("state: {}", explain["state"].as_str().unwrap_or("unknown"));
+ println!(
+ "screen_detection_skipped: {}",
+ explain["screen_detection_skipped"]
+ .as_bool()
+ .unwrap_or(false)
+ );
+ if let Some(reason) = explain["screen_detection_skip_reason"].as_str() {
+ println!("screen_detection_skip_reason: {reason}");
+ }
+ println!(
+ "manifest: {}",
+ explain["manifest_source"].as_str().unwrap_or("none")
+ );
+ println!(
+ "manifest_version: {}",
+ explain["manifest_version"].as_str().unwrap_or("unknown")
+ );
+ println!(
+ "cached_remote_version: {}",
+ explain["cached_remote_version"].as_str().unwrap_or("none")
+ );
+ println!(
+ "local_override_shadowing_remote: {}",
+ explain["local_override_shadowing_remote"]
+ .as_bool()
+ .unwrap_or(false)
+ );
+ if let Some(status) = explain["remote_update_status"].as_str() {
+ println!("remote_update_status: {status}");
+ }
+ if let Some(error) = explain["remote_update_error"].as_str() {
+ println!("remote_update_error: {error}");
+ }
+ if let Some(rule) = explain["matched_rule"].as_object() {
+ println!(
+ "matched_rule: {} priority={} region={} state={}",
+ rule.get("id")
+ .and_then(|value| value.as_str())
+ .unwrap_or("-"),
+ rule.get("priority")
+ .and_then(|value| value.as_i64())
+ .unwrap_or(0),
+ rule.get("region")
+ .and_then(|value| value.as_str())
+ .unwrap_or("-"),
+ rule.get("state")
+ .and_then(|value| value.as_str())
+ .unwrap_or("unknown")
+ );
+ } else {
+ println!("matched_rule: none");
+ }
+ println!(
+ "visible: idle={} blocker={} working={}",
+ explain["visible_idle"].as_bool().unwrap_or(false),
+ explain["visible_blocker"].as_bool().unwrap_or(false),
+ explain["visible_working"].as_bool().unwrap_or(false)
+ );
+ if let Some(reason) = explain["fallback_reason"].as_str() {
+ println!("fallback_reason: {reason}");
+ }
+ if let Some(reason) = explain["skipped_update_reason"].as_str() {
+ println!("skipped_update_reason: {reason}");
+ }
+ if let Some(warning) = explain["warning"].as_str() {
+ println!("warning: {warning}");
+ }
+ if let Some(evaluated_rules) = explain["evaluated_rules"]
+ .as_array()
+ .filter(|rules| !rules.is_empty())
+ {
+ println!("evaluated_rules:");
+ for rule in evaluated_rules {
+ println!(
+ " {} matched={} priority={} region={} state={}",
+ rule["id"].as_str().unwrap_or("-"),
+ rule["matched"].as_bool().unwrap_or(false),
+ rule["priority"].as_i64().unwrap_or(0),
+ rule["region"].as_str().unwrap_or("-"),
+ rule["state"].as_str().unwrap_or("unknown")
+ );
+ let evidence = &rule["evidence"];
+ println!(
+ " matchers: contains={:?} regex={:?} line_regex={:?} all={} any={} not={}",
+ evidence["contains"],
+ evidence["regex"],
+ evidence["line_regex"],
+ evidence["all_count"].as_u64().unwrap_or(0),
+ evidence["any_count"].as_u64().unwrap_or(0),
+ evidence["not_count"].as_u64().unwrap_or(0)
+ );
+ println!(
+ " region: bytes={} preview={:?}",
+ evidence["region_bytes"].as_u64().unwrap_or(0),
+ evidence["region_preview"].as_str().unwrap_or("")
+ );
+ }
+ }
+}
+
fn agent_start(args: &[String]) -> std::io::Result<i32> {
let Some(name) = args.first() else {
eprintln!("usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>");
@@ -422,6 +636,8 @@ fn print_agent_help() {
eprintln!(" herdr agent wait <target> --status <idle|working|blocked|unknown> [--timeout MS]");
eprintln!(" herdr agent attach <target> [--takeover]");
eprintln!(" herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--focus|--no-focus] -- <argv...>");
+ eprintln!(" herdr agent explain <target> [--json]");
+ eprintln!(" herdr agent explain --file PATH --agent LABEL [--json]");
eprintln!(" targets accept terminal ids, unique agent names, detected/reported agent labels, and legacy pane ids");
eprintln!(
" agent send writes literal text; use pane run when you want command text plus Enter"
diff --git a/src/cli/server.rs b/src/cli/server.rs
index 0102b2d4..f6cf7e54 100644
--- a/src/cli/server.rs
+++ b/src/cli/server.rs
@@ -10,6 +10,8 @@ pub(super) fn run_server_command(args: &[String]) -> std::io::Result<Option<i32>
"live-handoff" => server_live_handoff(&args[1..]).map(Some),
"--handoff-import" => Ok(None),
"reload-config" => server_reload_config(&args[1..]).map(Some),
+ "agent-manifests" => server_agent_manifests(&args[1..]).map(Some),
+ "reload-agent-manifests" => server_reload_agent_manifests(&args[1..]).map(Some),
"help" | "--help" | "-h" => {
print_server_help();
Ok(Some(0))
@@ -42,6 +44,85 @@ fn server_reload_config(args: &[String]) -> std::io::Result<i32> {
})?)
}
+fn server_agent_manifests(args: &[String]) -> std::io::Result<i32> {
+ let json = match args {
+ [] => false,
+ [flag] if flag == "--json" => true,
+ _ => {
+ eprintln!("usage: herdr server agent-manifests [--json]");
+ return Ok(2);
+ }
+ };
+
+ let response = super::send_request(&Request {
+ id: "cli:server:agent-manifests".into(),
+ method: Method::ServerAgentManifests(EmptyParams::default()),
+ })?;
+ if json || response.get("error").is_some() {
+ return super::print_response(&response);
+ }
+
+ print_agent_manifest_status(&response);
+ Ok(0)
+}
+
+fn server_reload_agent_manifests(args: &[String]) -> std::io::Result<i32> {
+ if !args.is_empty() {
+ eprintln!("usage: herdr server reload-agent-manifests");
+ return Ok(2);
+ }
+
+ super::print_response(&super::send_request(&Request {
+ id: "cli:server:reload-agent-manifests".into(),
+ method: Method::ServerReloadAgentManifests(EmptyParams::default()),
+ })?)
+}
+
+fn print_agent_manifest_status(response: &serde_json::Value) {
+ let result = &response["result"];
+ let last_check = result["last_check_unix"]
+ .as_u64()
+ .map(|value| value.to_string())
+ .unwrap_or_else(|| "never".to_string());
+ let last_result = result["last_result"].as_str().unwrap_or("not checked");
+ println!("last check: {last_check}");
+ println!("result: {last_result}");
+ println!();
+
+ let Some(manifests) = result["manifests"].as_array() else {
+ return;
+ };
+ for manifest in manifests {
+ let agent = manifest["agent"].as_str().unwrap_or("-");
+ let source = manifest["source_kind"].as_str().unwrap_or("-");
+ let active_version = manifest["active_version"].as_str().unwrap_or("-");
+ let remote_version = manifest["cached_remote_version"].as_str().unwrap_or("-");
+ let remote_result = manifest["remote_update_result"]
+ .as_str()
+ .unwrap_or("not checked");
+ let local_override_shadowing_remote = manifest["local_override_shadowing_remote"]
+ .as_bool()
+ .unwrap_or(false);
+ let marker = if local_override_shadowing_remote {
+ "!"
+ } else if manifest["remote_update_error"].as_str().is_some() {
+ "x"
+ } else {
+ " "
+ };
+ println!(
+ "{marker} {agent:<9} {source:<14} active {active_version:<14} remote {remote_version:<14} {remote_result}"
+ );
+ if let Some(error) = manifest["remote_update_error"].as_str() {
+ println!(" {error}");
+ } else if local_override_shadowing_remote {
+ println!(" local override shadows cached remote rules");
+ } else if let Some(warning) = manifest["warning"].as_str() {
+ println!(" {warning}");
+ }
+ }
+}
+
fn server_live_handoff(args: &[String]) -> std::io::Result<i32> {
let Some(params) = parse_live_handoff_params(args) else {
eprintln!(
@@ -105,6 +186,8 @@ fn print_server_help() {
eprintln!(" herdr server stop stop the running server via the API socket");
eprintln!(" herdr server live-handoff hand off live panes to a new local server");
eprintln!(" herdr server reload-config reload config.toml in the running server");
+ eprintln!(" herdr server agent-manifests [--json] show agent detection manifest status");
+ eprintln!(" herdr server reload-agent-manifests reload agent detection manifests in the running server");
}
#[cfg(test)]
diff --git a/src/detect/agents/amp.rs b/src/detect/agents/amp.rs
deleted file mode 100644
index b1fcba4d..00000000
--- a/src/detect/agents/amp.rs
+++ /dev/null
@@ -1,46 +0,0 @@
-use super::super::AgentState;
-
-/// Amp (Sourcegraph) detection.
-///
-/// Blocked approval prompts use a shared footer with options like
-/// "Approve", "Allow All for This Session", "Allow All for Every Session",
-/// "Allow File for Every Session", and "Deny with feedback". The header varies
-/// by approval type, for example "Invoke tool ...?", "Run this command?",
-/// "Allow editing file:", or "Allow creating file:".
-///
-/// Working layout:
-/// ```text
-/// ✓ Search Map the core runtime architecture...
-/// ⋯ Oracle ▼
-/// ≈ Running tools... Esc to cancel
-/// ```
-pub(super) fn detect(content: &str) -> AgentState {
- let lower = content.to_lowercase();
-
- if has_visible_blocker(content) {
- return AgentState::Blocked;
- }
-
- if lower.contains("esc to cancel") {
- return AgentState::Working;
- }
-
- AgentState::Idle
-}
-
-pub(super) fn has_visible_blocker(content: &str) -> bool {
- let lower = content.to_lowercase();
- let has_waiting_for_approval = lower.contains("waiting for approval");
- let has_approval_header = lower.contains("invoke tool")
- || lower.contains("run this command?")
- || lower.contains("allow editing file:")
- || lower.contains("allow creating file:")
- || lower.contains("confirm tool call");
- let has_approval_actions = lower.contains("approve")
- && (lower.contains("allow all for this session")
- || lower.contains("allow all for every session")
- || lower.contains("allow file for every session")
- || lower.contains("deny with feedback"));
-
- has_approval_actions && (has_waiting_for_approval || has_approval_header)
-}
diff --git a/src/detect/agents/antigravity.rs b/src/detect/agents/antigravity.rs
deleted file mode 100644
index 9790c5a4..00000000
--- a/src/detect/agents/antigravity.rs
+++ /dev/null
@@ -1,76 +0,0 @@
-use super::super::AgentState;
-
-pub(super) fn detect(content: &str) -> AgentState {
- if has_visible_blocker(content) {
- return AgentState::Blocked;
- }
-
- if has_antigravity_spinner(content) || has_antigravity_background_tasks(content) {
- return AgentState::Working;
- }
-
- AgentState::Idle
-}
-
-pub(super) fn has_visible_blocker(content: &str) -> bool {
- let lower = content.to_lowercase();
- let has_permission_request = lower.contains("requesting permission for:");
- let has_permission_question = lower.contains("do you want to proceed?");
- let has_permission_controls = lower.contains("tab amend") && lower.contains("edit command");
- has_permission_request && (has_permission_question || has_permission_controls)
-}
-
-fn has_antigravity_spinner(content: &str) -> bool {
- content.lines().any(|line| {
- let trimmed = line.trim_start();
- let mut chars = trimmed.chars();
- let Some(first) = chars.next() else {
- return false;
- };
- if !('\u{2800}'..='\u{28FF}').contains(&first) {
- return false;
- }
-
- let rest = chars
- .as_str()
- .trim_start_matches(|c| ('\u{2800}'..='\u{28FF}').contains(&c))
- .trim_start();
- status_word_is_active(rest)
- })
-}
-
-fn has_antigravity_background_tasks(content: &str) -> bool {
- let bottom_lines: Vec<&str> = content
- .lines()
- .rev()
- .filter(|line| !line.trim().is_empty())
- .take(5)
- .collect();
-
- bottom_lines.into_iter().any(|line| {
- let line = line.trim().to_lowercase();
- line.contains("/tasks") && antigravity_task_count(&line).is_some_and(|count| count > 0)
- })
-}
-
-fn antigravity_task_count(line: &str) -> Option<u32> {
- for marker in [" task(s)", " tasks", " task"] {
- let Some((before, _)) = line.split_once(marker) else {
- continue;
- };
- let raw_count = before.split_whitespace().last()?.trim_matches(|c| c == '·');
- if let Ok(count) = raw_count.parse() {
- return Some(count);
- }
- }
- None
-}
-
-fn status_word_is_active(rest: &str) -> bool {
- let Some(word) = rest.split_whitespace().next() else {
- return false;
- };
- word.trim_end_matches(|c: char| !c.is_alphabetic())
- .to_ascii_lowercase()
- .ends_with("ing")
-}
diff --git a/src/detect/agents/claude_code.rs b/src/detect/agents/claude_code.rs
deleted file mode 100644
index b159fede..00000000
--- a/src/detect/agents/claude_code.rs
+++ /dev/null
@@ -1,434 +0,0 @@
-use super::super::{has_confirmation_prompt, has_selection_prompt, AgentState};
-
-/// Claude Code detection. The most complex — it has a structured prompt box UI.
-///
-/// Screen layout:
-/// ```text
-/// (agent output / tool results)
-/// ───────────────────────── (top border)
-/// ❯ _ (prompt line)
-/// ───────────────────────── (bottom border)
-/// ```
-pub(super) fn detect(content: &str) -> AgentState {
- let lower = content.to_lowercase();
-
- if has_live_blocked_form(content) {
- return AgentState::Blocked;
- }
-
- if has_dynamic_workflow_prompt(&lower) {
- return AgentState::Blocked;
- }
-
- if has_working_chrome(content) {
- return AgentState::Working;
- }
-
- if !has_prompt_box(content) && has_claude_blocked_prompt(content, &lower) {
- return AgentState::Blocked;
- }
-
- AgentState::Idle
-}
-
-pub(super) fn has_visible_blocker(content: &str) -> bool {
- if has_live_input_prompt_box(content) {
- return false;
- }
-
- let lower = content.to_lowercase();
- has_live_blocked_form(content)
- || has_dynamic_workflow_prompt(&lower)
- || lower.contains("do you want to proceed?")
- && has_claude_yes_no_choice(content)
- && (lower.contains("bash command")
- || lower.contains("bash(")
- || lower.contains("contains expansion")
- || lower.contains("tab to amend")
- || lower.contains("ctrl+e to explain"))
-}
-
-pub(in crate::detect) fn has_idle_recap_notice(content: &str) -> bool {
- if !has_prompt_box(content) || has_visible_blocker(content) {
- return false;
- }
-
- let above_prompt = content_above_prompt_box(content);
- let bottom_lines = bottom_non_empty_lines(above_prompt, 8);
- let Some(last_line) = bottom_lines.last() else {
- return false;
- };
- if !last_line
- .to_ascii_lowercase()
- .contains("(disable recaps in /config)")
- {
- return false;
- }
- let bottom = normalize_lines(&bottom_lines).to_ascii_lowercase();
-
- bottom.contains("※ recap:")
- && !bottom.contains("esc to interrupt")
- && !bottom.contains("ctrl+c to interrupt")
-}
-
-pub(super) fn has_working_chrome(content: &str) -> bool {
- let above = content_above_prompt_box(content);
- let above_lower = above.to_lowercase();
- above_lower.contains("esc to interrupt")
- || above_lower.contains("ctrl+c to interrupt")
- || has_running_status_line(above)
- || has_spinner_activity(above)
-}
-
-pub(super) fn is_transcript_viewer(content: &str) -> bool {
- let bottom_lines = bottom_non_empty_lines(content, 3);
- let Some(last_line) = bottom_lines.last() else {
- return false;
- };
- let bottom_text = normalize_lines(&bottom_lines);
-
- bottom_text.contains("showing detailed transcript")
- && bottom_text.contains("ctrl+o to toggle")
- && (bottom_text.contains("ctrl+e to show all")
- || bottom_text.contains("ctrl+e to collapse"))
- && transcript_control_tail(last_line)
-}
-
-pub(super) fn has_prompt_box(content: &str) -> bool {
- prompt_box_body_lines(content)
- .is_some_and(|lines| lines.iter().any(|line| line.trim_start().starts_with('❯')))
-}
-
-fn has_live_input_prompt_box(content: &str) -> bool {
- prompt_box_body_lines(content).is_some_and(|lines| {
- let non_empty: Vec<&str> = lines
- .iter()
- .map(|line| line.trim())
- .filter(|line| !line.is_empty())
- .collect();
- let Some(first) = non_empty.first() else {
- return false;
- };
- first.trim_start().starts_with('❯')
- && !non_empty
- .iter()
- .any(|line| claude_prompt_box_line_is_selector_chrome(line))
- })
-}
-
-fn claude_prompt_box_line_is_selector_chrome(line: &str) -> bool {
- let trimmed = line.trim().trim_start_matches('❯').trim_start();
- let lower = trimmed.to_ascii_lowercase();
-
- lower.contains("enter to select")
- || lower.contains("enter to confirm")
- || lower.contains("enter to submit")
- || lower.contains("esc to cancel")
- || lower.contains("tab/arrow")
- || lower.contains("arrow keys")
- || lower.contains("↑/↓")
- || lower.contains("↑↓")
- || lower.contains("ctrl+g to edit")
- || lower.contains("ctrl+e to explain")
- || trimmed
- .split_once('.')
- .is_some_and(|(prefix, _)| prefix.trim().parse::<u32>().is_ok())
-}
-
-fn prompt_box_body_lines(content: &str) -> Option<Vec<&str>> {
- let lines: Vec<&str> = content.lines().collect();
- let top_border_index = claude_prompt_box_top_border_index(&lines)?;
-
- Some(
- lines[top_border_index + 1..]
- .iter()
- .take_while(|line| !is_horizontal_rule(line))
- .copied()
- .collect(),
- )
-}
-
-/// Claude uses the same generic Select and Dialog widgets for both
-/// permission flows and ordinary slash/settings menus. Match only the
-/// permission and interview prompts that actually need user input.
-fn has_claude_blocked_prompt(content: &str, lower_content: &str) -> bool {
- has_confirmation_prompt(lower_content)
- || lower_content.contains("do you want to proceed?")
- || lower_content.contains("would you like to proceed?")
- || has_dynamic_workflow_prompt(lower_content)
- || lower_content.contains("waiting for permission")
- || lower_content.contains("do you want to allow this connection?")
- || lower_content.contains("tab to amend")
- || lower_content.contains("ctrl+e to explain")
- || lower_content.contains("review your answers")
- || lower_content.contains("skip interview and plan immediately")
- || (has_selection_prompt(content) && has_claude_yes_no_choice(content))
-}
-
-fn has_dynamic_workflow_prompt(lower_content: &str) -> bool {
- lower_content.contains("run a dynamic workflow?") && lower_content.contains("esc to cancel")
-}
-
-fn has_live_blocked_form(content: &str) -> bool {
- let region = content_after_last_horizontal_rule(content);
- region.lines().any(|line| {
- let lower = line.to_lowercase();
- lower.contains("enter to select")
- && lower.contains("esc to cancel")
- && (lower.contains("tab/arrow keys to navigate")
- || lower.contains("arrow keys to navigate")
- || lower.contains("arrows to navigate")
- || lower.contains("↑/↓ to navigate")
- || lower.contains("↑↓ to navigate"))
- })
-}
-
-fn has_running_status_line(content_above_prompt: &str) -> bool {
- let Some(line) = content_above_prompt
- .lines()
- .rev()
- .find(|line| !line.trim().is_empty())
- else {
- return false;
- };
-
- is_background_agent_wait_line(line) || is_still_running_status_line(line)
-}
-
-fn is_background_agent_wait_line(line: &str) -> bool {
- let mut text = line.trim();
- if !text.starts_with("Waiting for ") && !text.starts_with("waiting for ") {
- let mut chars = text.chars();
- let Some(first) = chars.next() else {
- return false;
- };
- if first.is_alphanumeric() {
- return false;
- }
- text = chars.as_str().trim_start();
- }
-
- let lower = text.to_ascii_lowercase();
- let Some(rest) = lower.strip_prefix("waiting for ") else {
- return false;
- };
- let Some((count, rest)) = rest.split_once(' ') else {
- return false;
- };
- if count.parse::<u32>().ok().is_none_or(|count| count == 0) {
- return false;
- }
-
- rest == "background agent to finish" || rest == "background agents to finish"
-}
-
-fn is_still_running_status_line(line: &str) -> bool {
- let lower = line.to_ascii_lowercase();
- let words: Vec<&str> = lower.split_whitespace().collect();
-
- for (index, word) in words.iter().enumerate() {
- let Ok(count) = word.parse::<u32>() else {
- continue;
- };
- if count == 0 {
- continue;
- }
-
- if matches!(
- words.get(index + 1..index + 4),
- Some(["shell" | "shells", "still", "running"])
- ) {
- return true;
- }
-
- if matches!(
- words.get(index + 1..index + 5),
- Some(["local", "agent" | "agents", "still", "running"])
- ) {
- return true;
- }
- }
-
- false
-}
-
-fn has_claude_yes_no_choice(content: &str) -> bool {
- content.lines().any(|line| {
- let trimmed = line
- .trim()
- .trim_start_matches('❯')
- .trim_start()
- .to_lowercase();
- trimmed == "yes"
- || trimmed == "no"
- || trimmed.starts_with("1. yes")
- || trimmed.starts_with("2. no")
- || trimmed.starts_with("yes, and ")
- || trimmed.starts_with("no, and tell claude")
- })
-}
-
-/// Claude Code spinner characters + activity label.
-/// The verb changes frequently ("Processing…", "Pouncing…", etc.), so rely
-/// on the spinner glyph + trailing ellipsis rather than specific wording.
-/// Include Claude's narrow-pane middle-dot frame too.
-pub(in crate::detect) fn has_spinner_activity(content: &str) -> bool {
- const SPINNER_CHARS: &str = "·✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆";
- for line in content.lines() {
- let trimmed = line.trim();
- let mut chars = trimmed.chars();
- if let Some(first) = chars.next() {
- if SPINNER_CHARS.contains(first) {
- let rest: String = chars.collect();
- if rest.starts_with(' ')
- && rest.contains('\u{2026}')
- && rest.chars().any(|c| c.is_alphanumeric())
- {
- return true;
- }
- }
- }
- }
- false
-}
-
-/// Extract content above Claude's prompt box.
-/// The prompt box is two ─── border lines with ❯ between them.
-pub(in crate::detect) fn content_above_prompt_box(content: &str) -> &str {
- let lines: Vec<&str> = content.lines().collect();
-
- if let Some(i) = claude_prompt_box_top_border_index(&lines) {
- let byte_offset: usize = lines[..i].iter().map(|l| l.len() + 1).sum();
- return &content[..byte_offset.min(content.len())];
- }
-
- // No prompt box found, return all content
- content
-}
-
-fn content_after_last_horizontal_rule(content: &str) -> &str {
- let mut last_rule_end = 0usize;
- let mut offset = 0usize;
- for line in content.lines() {
- let next_offset = offset + line.len() + 1;
- if is_horizontal_rule(line) {
- last_rule_end = next_offset.min(content.len());
- }
- offset = next_offset;
- }
-
- &content[last_rule_end..]
-}
-
-fn claude_prompt_box_top_border_index(lines: &[&str]) -> Option<usize> {
- let mut border_count = 0;
-
- for i in (0..lines.len()).rev() {
- if is_horizontal_rule(lines[i]) {
- border_count += 1;
- if border_count == 2 {
- return Some(i);
- }
- }
- }
-
- None
-}
-
-fn is_horizontal_rule(line: &str) -> bool {
- let trimmed = line.trim();
- if trimmed.is_empty() {
- return false;
- }
-
- let rule_chars = trimmed.chars().take_while(|&c| c == '─').count();
- if rule_chars == 0 {
- return false;
- }
-
- let rule_bytes = trimmed
- .char_indices()
- .nth(rule_chars)
- .map(|(index, _)| index)
- .unwrap_or(trimmed.len());
- let suffix = trimmed[rule_bytes..].trim_start();
-
- suffix.is_empty() || rule_chars >= 3
-}
-
-fn bottom_non_empty_lines(content: &str, max_lines: usize) -> Vec<&str> {
- let mut lines: Vec<&str> = content
- .lines()
- .rev()
- .filter(|line| !line.trim().is_empty())
- .take(max_lines)
- .collect();
- lines.reverse();
- lines
-}
-
-fn normalize_lines(lines: &[&str]) -> String {
- lines
- .iter()
- .flat_map(|line| line.split_whitespace())
- .collect::<Vec<_>>()
- .join(" ")
- .to_lowercase()
-}
-
-fn transcript_control_tail(line: &str) -> bool {
- let lower = line.to_lowercase();
- lower.contains("ctrl+e")
- || lower.contains("show all")
- || lower.contains("collapse")
- || lower.contains("verbose")
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn prompt_box_below(content_above_prompt: &str) -> String {
- format!(
- "{content_above_prompt}\n────────────────────────────────\n❯ \n────────────────────────────────\n"
- )
- }
-
- #[test]
- fn shell_still_running_status_line_is_working() {
- let content = prompt_box_below(
- "● Started. I'll tell you when it finishes.\n\n✻ Crunched for 7s · 1 shell still running",
- );
-
- assert_eq!(detect(&content), AgentState::Working);
- assert!(has_working_chrome(&content));
- }
-
- #[test]
- fn local_agent_still_running_status_line_is_working() {
- let content = prompt_box_below(
- "● Hey. What do you want to work on?\n\n✻ Worked for 4s · 2 local agents still running",
- );
-
- assert_eq!(detect(&content), AgentState::Working);
- assert!(has_working_chrome(&content));
- }
-
- #[test]
- fn lower_agent_picker_shell_count_is_not_working_chrome() {
- let content = prompt_box_below(" ~/P/herdr ⎇ master ▱▱▱▱▱ 0%\n 1 shell · ← for agents");
-
- assert_eq!(detect(&content), AgentState::Idle);
- assert!(!has_working_chrome(&content));
- }
-
- #[test]
- fn stale_shell_running_line_above_newer_output_is_not_working_chrome() {
- let content = prompt_box_below(
- "● Started. I'll tell you when it finishes.\n\n✻ Crunched for 7s · 1 shell still running\n\n● hi",
- );
-
- assert_eq!(detect(&content), AgentState::Idle);
- assert!(!has_working_chrome(&content));
- }
-}
diff --git a/src/detect/agents/cline.rs b/src/detect/agents/cline.rs
deleted file mode 100644
index c0ce6f6e..00000000
--- a/src/detect/agents/cline.rs
+++ /dev/null
@@ -1,19 +0,0 @@
-use super::super::AgentState;
-
-pub(super) fn detect(content: &str) -> AgentState {
- // Blocked
- if has_visible_blocker(content) {
- return AgentState::Blocked;
- }
-
- // Cline defaults to working (unlike most agents that default to idle)
- AgentState::Working
-}
-
-pub(super) fn has_visible_blocker(content: &str) -> bool {
- let lower = content.to_lowercase();
- lower.contains("let cline use this tool")
- || ((lower.contains("[act mode]") || lower.contains("[plan mode]"))
- && (lower.contains("execute command?") || lower.contains("use this tool?"))
- && lower.contains("yes"))
-}
diff --git a/src/detect/agents/codex.rs b/src/detect/agents/codex.rs
deleted file mode 100644
index 93d2998c..00000000
--- a/src/detect/agents/codex.rs
+++ /dev/null
@@ -1,258 +0,0 @@
-use super::super::{has_confirmation_prompt, has_interrupt_pattern, AgentState};
-
-pub(super) fn detect(content: &str) -> AgentState {
- // Strong blocked patterns are structural Codex UI chrome, so they can win
- // even when the prompt region is visible.
- if has_codex_strong_blocked_prompt(content) {
- return AgentState::Blocked;
- }
-
- let lower = content.to_lowercase();
-
- // Working
- if has_codex_working_status_at_current_prompt(content) {
- return AgentState::Working;
- }
-
- // Weak blocked patterns are too broad to become visible blockers, but the
- // legacy state detector still reports them as blocked for compatibility.
- if has_codex_weak_blocked_prompt(&lower) {
- return AgentState::Blocked;
- }
-
- // Fallback working signals for narrow captures where the footer scrolled
- // out or the working row is the only Codex chrome visible.
- if has_interrupt_pattern(&lower) || has_codex_working_header(content) {
- return AgentState::Working;
- }
-
- AgentState::Idle
-}
-
-pub(super) fn has_visible_blocker(content: &str) -> bool {
- has_codex_strong_blocked_prompt(content)
-}
-
-pub(super) fn has_visible_working(content: &str) -> bool {
- has_codex_live_working_at_current_prompt(content)
- || (!has_codex_current_prompt(content) && has_codex_visible_working_without_prompt(content))
-}
-
-pub(super) fn is_transcript_viewer(content: &str) -> bool {
- let bottom_lines = bottom_non_empty_lines(content, 3);
- let Some(last_line) = bottom_lines.last() else {
- return false;
- };
- let bottom_text = normalize_lines(&bottom_lines);
-
- bottom_text.contains("↑/↓ to scroll")
- && bottom_text.contains("pgup/pgdn to page")
- && bottom_text.contains("home/end to jump")
- && bottom_text.contains("q to quit")
- && has_codex_edit_prev_controls(&bottom_text)
- && transcript_control_tail(last_line)
-}
-
-fn has_codex_edit_prev_controls(bottom_text: &str) -> bool {
- bottom_text.contains("esc to edit prev") || bottom_text.contains("esc/← to edit prev")
-}
-
-fn has_codex_visible_working_without_prompt(content: &str) -> bool {
- let mut recent_lines = content.lines().rev().filter(|line| !line.trim().is_empty());
- let Some(last_line) = recent_lines.next() else {
- return false;
- };
-
- if codex_live_working_line(last_line) {
- return true;
- }
-
- codex_status_detail_line(last_line)
- && recent_lines
- .take(4)
- .find(|line| codex_block_marker_line(line))
- .is_some_and(codex_live_working_line)
-}
-
-fn has_codex_strong_blocked_prompt(content: &str) -> bool {
- let lines: Vec<&str> = content.lines().collect();
- let live_region = lines
- .iter()
- .rposition(|line| codex_prompt_line(line))
- .map(|prompt_index| lines[prompt_index + 1..].join("\n"))
- .unwrap_or_else(|| content.to_string());
- let lower_content = live_region.to_lowercase();
-
- lower_content.contains("press enter to confirm or esc to cancel")
- || lower_content.contains("enter to submit answer")
- || lower_content.contains("enter to submit all")
- || lower_content.contains("allow command?")
-}
-
-fn has_codex_weak_blocked_prompt(lower_content: &str) -> bool {
- lower_content.contains("[y/n]")
- || lower_content.contains("yes (y)")
- || has_confirmation_prompt(lower_content)
-}
-
-fn has_codex_live_working_at_current_prompt(content: &str) -> bool {
- codex_last_block_marker_before_current_prompt(content).is_some_and(codex_live_working_line)
-}
-
-fn has_codex_working_status_at_current_prompt(content: &str) -> bool {
- codex_last_block_marker_before_current_prompt(content).is_some_and(codex_working_status_line)
-}
-
-fn codex_last_block_marker_before_current_prompt(content: &str) -> Option<&str> {
- let (lines, prompt_index) = codex_current_prompt_region(content)?;
-
- lines[..prompt_index]
- .iter()
- .rev()
- .find(|line| codex_block_marker_line(line))
- .copied()
-}
-
-fn has_codex_working_header(content: &str) -> bool {
- content.lines().any(codex_working_status_line)
-}
-
-fn codex_live_working_line(line: &str) -> bool {
- if codex_queued_input_header_line(line) {
- return true;
- }
-
- let trimmed = line.trim_start();
- let lower = trimmed.to_lowercase();
- codex_working_status_line(line)
- && (trimmed.contains("Waiting for background terminal")
- || has_codex_status_interrupt_hint(&lower)
- || lower.contains("background terminal running")
- || lower.contains("/ps to view")
- || lower.contains("/stop to close"))
-}
-
-fn codex_working_status_line(line: &str) -> bool {
- if codex_queued_input_header_line(line) {
- return true;
- }
-
- let trimmed = line.trim_start();
- let lower = trimmed.to_lowercase();
- trimmed.starts_with('•')
- && (trimmed.contains("Working (")
- || trimmed.contains("Waiting for background terminal (")
- || has_codex_status_interrupt_hint(&lower)
- || lower.contains("reviewing approval request (")
- || (lower.contains("reviewing ") && lower.contains(" approval requests ("))
- || trimmed.contains("Booting MCP server:"))
-}
-
-fn has_codex_status_interrupt_hint(lower_line: &str) -> bool {
- let Some((before_escape, after_escape)) = lower_line.split_once(" • esc") else {
- return false;
- };
-
- has_codex_status_elapsed(before_escape) && has_codex_status_escape_suffix(after_escape)
-}
-
-fn has_codex_status_elapsed(before_escape: &str) -> bool {
- let Some((_, elapsed)) = before_escape.rsplit_once('(') else {
- return false;
- };
-
- let parts: Vec<&str> = elapsed.split_whitespace().collect();
- (1..=3).contains(&parts.len())
- && parts.iter().all(|part| {
- part.len() >= 2
- && matches!(part.as_bytes().last(), Some(b'h' | b'm' | b's'))
- && part[..part.len() - 1].chars().all(|ch| ch.is_ascii_digit())
- })
-}
-
-fn has_codex_status_escape_suffix(after_escape: &str) -> bool {
- let suffix = after_escape.trim_start();
- if suffix.starts_with('…') || suffix.starts_with("to interrupt") {
- return true;
- }
-
- let Some(rest) = suffix.strip_prefix("to ") else {
- return false;
- };
- if rest.starts_with('…') {
- return true;
- }
-
- rest.split_once('…')
- .map(|(fragment, _)| !fragment.is_empty() && "interrupt".starts_with(fragment.trim_end()))
- .unwrap_or(false)
-}
-
-fn has_codex_current_prompt(content: &str) -> bool {
- codex_current_prompt_region(content).is_some()
-}
-
-fn codex_current_prompt_region(content: &str) -> Option<(Vec<&str>, usize)> {
- let lines: Vec<&str> = content.lines().collect();
- let prompt_index = lines.iter().rposition(|line| codex_prompt_line(line))?;
-
- if lines[prompt_index + 1..]
- .iter()
- .any(|line| codex_block_marker_line(line))
- {
- return None;
- }
-
- Some((lines, prompt_index))
-}
-
-fn codex_prompt_line(line: &str) -> bool {
- line == "›" || line.starts_with("› ")
-}
-
-fn codex_block_marker_line(line: &str) -> bool {
- line.starts_with('•') || line.starts_with('■') || line.starts_with('✗') || line.starts_with('✓')
-}
-
-fn codex_status_detail_line(line: &str) -> bool {
- line.trim_start().starts_with('└')
-}
-
-fn codex_queued_input_header_line(line: &str) -> bool {
- let trimmed = line.trim_start();
- if !trimmed.starts_with('•') {
- return false;
- }
-
- let lower = trimmed.to_lowercase();
- lower.starts_with("• queued follow-up inputs")
- || lower.starts_with("• messages to be submitted after next tool call")
-}
-
-fn bottom_non_empty_lines(content: &str, max_lines: usize) -> Vec<&str> {
- let mut lines: Vec<&str> = content
- .lines()
- .rev()
- .filter(|line| !line.trim().is_empty())
- .take(max_lines)
- .collect();
- lines.reverse();
- lines
-}
-
-fn normalize_lines(lines: &[&str]) -> String {
- lines
- .iter()
- .flat_map(|line| line.split_whitespace())
- .collect::<Vec<_>>()
- .join(" ")
- .to_lowercase()
-}
-
-fn transcript_control_tail(line: &str) -> bool {
- let lower = line.to_lowercase();
- lower.contains("q to quit")
- || lower.contains("esc to edit")
- || lower.contains("esc/← to edit")
- || lower.contains("edit message")
-}
diff --git a/src/detect/agents/cursor.rs b/src/detect/agents/cursor.rs
deleted file mode 100644
index f0f00b9b..00000000
--- a/src/detect/agents/cursor.rs
+++ /dev/null
@@ -1,79 +0,0 @@
-use super::super::AgentState;
-
-pub(super) fn detect(content: &str) -> AgentState {
- let lower = content.to_lowercase();
-
- // Blocked
- if has_visible_blocker(content) {
- return AgentState::Blocked;
- }
-
- // Working
- if lower.contains("ctrl+c to stop") {
- return AgentState::Working;
- }
- if has_cursor_spinner(content) {
- return AgentState::Working;
- }
-
- AgentState::Idle
-}
-
-pub(super) fn has_visible_blocker(content: &str) -> bool {
- let lower = content.to_lowercase();
- if lower.contains("waiting for approval")
- && lower.contains("run this command?")
- && (lower.contains("run (once) (y)") || lower.contains("skip (esc or n)"))
- {
- return true;
- }
-
- if lower.contains("(y) (enter)")
- || lower.contains("keep (n)")
- || lower.contains("skip (esc or n)")
- {
- return true;
- }
-
- content.lines().any(|line| {
- let line = line.trim().to_lowercase();
- let has_yes_action = line.contains("(y)");
- has_yes_action
- && (line.contains("allow")
- || line.contains("run (once)")
- || line.contains("→ run")
- || line.starts_with("run "))
- })
-}
-
-/// Cursor status line: spinner glyphs followed by a live action label.
-pub(in crate::detect) fn has_cursor_spinner(content: &str) -> bool {
- content.lines().any(|line| {
- let trimmed = line.trim_start();
- let mut chars = trimmed.chars();
- let Some(first) = chars.next() else {
- return false;
- };
- let rest = chars.as_str().trim_start();
-
- if matches!(first, '⬡' | '⬢') {
- return cursor_status_word_is_active(rest);
- }
-
- if ('\u{2800}'..='\u{28FF}').contains(&first) {
- let rest = rest.trim_start_matches(|c| ('\u{2800}'..='\u{28FF}').contains(&c));
- return cursor_status_word_is_active(rest.trim_start());
- }
-
- false
- })
-}
-
-fn cursor_status_word_is_active(rest: &str) -> bool {
- let Some(word) = rest.split_whitespace().next() else {
- return false;
- };
- word.trim_end_matches(|c: char| !c.is_alphabetic())
- .to_ascii_lowercase()
- .ends_with("ing")
-}
diff --git a/src/detect/agents/droid.rs b/src/detect/agents/droid.rs
deleted file mode 100644
index 78dc811e..00000000
--- a/src/detect/agents/droid.rs
+++ /dev/null
@@ -1,39 +0,0 @@
-use super::super::{has_braille_spinner, AgentState};
-
-/// Droid detection.
-///
-/// Working: braille spinner line (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) + "Thinking..." + "(Press ESC to stop)"
-/// Blocked: EXECUTE prompt with selection box ("Yes, allow" / "No, cancel") +
-/// "Use ↑↓ to navigate, Enter to select"
-pub(super) fn detect(content: &str) -> AgentState {
- let lower = content.to_lowercase();
-
- if has_visible_blocker(content) {
- return AgentState::Blocked;
- }
-
- // Working: braille spinner character at start of a line + "Thinking..."
- // The braille chars (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) are very specific — won't appear in normal content
- if has_braille_spinner(content) && lower.contains("esc to stop") {
- return AgentState::Working;
- }
- // Fallback: "ESC to stop" alone is still a strong signal (it's UI chrome)
- if lower.contains("esc to stop") {
- return AgentState::Working;
- }
-
- AgentState::Idle
-}
-
-pub(super) fn has_visible_blocker(content: &str) -> bool {
- let lower = content.to_lowercase();
- // Primary (AND): structural keyword + chrome text = certain
- let has_execute = content.contains("EXECUTE");
- let has_selection_chrome = lower.contains("enter to select")
- || lower.contains("↑↓ to navigate")
- || lower.contains("esc to cancel");
- let has_selection_options = lower.contains("> yes, allow") || lower.contains("> no, cancel");
-
- (has_execute && (has_selection_chrome || has_selection_options))
- || (has_selection_chrome && has_selection_options)
-}
diff --git a/src/detect/agents/gemini.rs b/src/detect/agents/gemini.rs
deleted file mode 100644
index 86cf3bca..00000000
--- a/src/detect/agents/gemini.rs
+++ /dev/null
@@ -1,31 +0,0 @@
-use super::super::AgentState;
-
-pub(super) fn detect(content: &str) -> AgentState {
- if has_visible_blocker(content) {
- return AgentState::Blocked;
- }
-
- let lower = content.to_lowercase();
- // Working
- if lower.contains("esc to cancel") {
- return AgentState::Working;
- }
-
- AgentState::Idle
-}
-
-pub(super) fn has_visible_blocker(content: &str) -> bool {
- let lower = content.to_lowercase();
- let has_choice = lower.contains("yes") || lower.contains("no");
-
- content.contains("│ Apply this change")
- || content.contains("│ Allow execution")
- || (has_choice
- && (lower.contains("waiting for user confirmation")
- || content.contains("│ Do you want to proceed")
- || lower.contains("do you want to proceed?")))
- || content.lines().any(|line| {
- let line = line.trim().to_ascii_lowercase();
- line.starts_with("❯") && (line.contains("yes") || line.contains("allow"))
- })
-}
diff --git a/src/detect/agents/github_copilot.rs b/src/detect/agents/github_copilot.rs
deleted file mode 100644
index 0eece95b..00000000
--- a/src/detect/agents/github_copilot.rs
+++ /dev/null
@@ -1,28 +0,0 @@
-use super::super::AgentState;
-
-pub(super) fn detect(content: &str) -> AgentState {
- let lower = content.to_lowercase();
-
- // Blocked
- if has_visible_blocker(content) {
- return AgentState::Blocked;
- }
-
- // Working
- if lower.contains("esc to cancel")
- || lower.contains("esc cancel")
- || lower.contains("esc again to cancel")
- {
- return AgentState::Working;
- }
-
- AgentState::Idle
-}
-
-pub(super) fn has_visible_blocker(content: &str) -> bool {
- let lower = content.to_lowercase();
- lower.contains("esc to cancel")
- && (lower.contains("enter to select")
- || lower.contains("enter to confirm")
- || lower.contains("enter to submit"))
-}
diff --git a/src/detect/agents/grok.rs b/src/detect/agents/grok.rs
deleted file mode 100644
index 8a5834a2..00000000
--- a/src/detect/agents/grok.rs
+++ /dev/null
@@ -1,37 +0,0 @@
-use super::super::{has_braille_spinner, AgentState};
-
-/// Grok Build detection.
-///
-/// Blocked permission prompts display a whitelist scope selector with choices
-/// like "Yes, proceed" and "No, reject". Working turns show a braille spinner
-/// status line such as "⠋ Waiting… 1.8s" plus live controls like
-/// "Ctrl+c:cancel" and "Ctrl+Enter:interject".
-pub(super) fn detect(content: &str) -> AgentState {
- if has_visible_blocker(content) {
- return AgentState::Blocked;
- }
-
- let lower = content.to_lowercase();
- if has_braille_spinner(content)
- && (lower.contains("waiting")
- || lower.contains("run ")
- || lower.contains("read ")
- || lower.contains("search ")
- || lower.contains("list "))
- {
- return AgentState::Working;
- }
-
- if lower.contains("ctrl+c:cancel") && lower.contains("ctrl+enter:interject") {
- return AgentState::Working;
- }
-
- AgentState::Idle
-}
-
-pub(super) fn has_visible_blocker(content: &str) -> bool {
- let lower = content.to_lowercase();
- let has_scope_selector = lower.contains("use ← → to choose permission whitelist scope")
- || lower.contains("←/→:scope");
- has_scope_selector && lower.contains("yes, proceed") && lower.contains("no, reject")
-}
diff --git a/src/detect/agents/hermes.rs b/src/detect/agents/hermes.rs
deleted file mode 100644
index 474dda5c..00000000
--- a/src/detect/agents/hermes.rs
+++ /dev/null
@@ -1,30 +0,0 @@
-use super::super::AgentState;
-
-/// Hermes Agent detection.
-///
-/// Hermes shows a bottom status bar while turns are active and modal approval
-/// dialogs for dangerous terminal commands. Prefer the modal controls for
-/// blocked detection, then the live interrupt/status controls for working.
-pub(super) fn detect(content: &str) -> AgentState {
- if has_visible_blocker(content) {
- return AgentState::Blocked;
- }
-
- let lower = content.to_lowercase();
- if lower.contains("msg=interrupt") || lower.contains("ctrl+c cancel") {
- return AgentState::Working;
- }
-
- AgentState::Idle
-}
-
-pub(super) fn has_visible_blocker(content: &str) -> bool {
- let lower = content.to_lowercase();
- let has_approval_options = lower.contains("allow once")
- && lower.contains("allow for this session")
- && lower.contains("deny");
- let has_approval_controls = lower.contains("enter to confirm")
- || lower.contains("↑/↓ to select")
- || lower.contains("show full command");
- (lower.contains("dangerous command") || has_approval_options) && has_approval_controls
-}
diff --git a/src/detect/agents/kilo.rs b/src/detect/agents/kilo.rs
deleted file mode 100644
index 46a5601a..00000000
--- a/src/detect/agents/kilo.rs
+++ /dev/null
@@ -1,13 +0,0 @@
-use super::super::AgentState;
-
-pub(super) fn detect(content: &str) -> AgentState {
- if content.to_lowercase().contains("esc interrupt") {
- return AgentState::Working;
- }
-
- super::opencode::detect(content)
-}
-
-pub(super) fn has_visible_blocker(content: &str) -> bool {
- super::opencode::has_visible_blocker(content)
-}
diff --git a/src/detect/agents/kimi.rs b/src/detect/agents/kimi.rs
deleted file mode 100644
index 9d3821ed..00000000
--- a/src/detect/agents/kimi.rs
+++ /dev/null
@@ -1,97 +0,0 @@
-use super::super::AgentState;
-
-pub(super) fn detect(content: &str) -> AgentState {
- if has_kimi_blocked_prompt(content) {
- return AgentState::Blocked;
- }
-
- if has_kimi_working_status(content) {
- return AgentState::Working;
- }
-
- AgentState::Idle
-}
-
-pub(super) fn has_visible_blocker(content: &str) -> bool {
- has_current_approval_panel(content) || has_question_panel(content)
-}
-
-pub(super) fn has_visible_working(content: &str) -> bool {
- has_kimi_working_status(content)
-}
-
-fn has_kimi_blocked_prompt(content: &str) -> bool {
- if has_visible_blocker(content) {
- return true;
- }
-
- let lower = content.to_lowercase();
- lower.contains("requesting approval")
- && (lower.contains("approve once") || lower.contains("approve for this session"))
- && lower.contains("reject")
- && (lower.contains("1/2/3/4 choose") || lower.contains("↵ confirm"))
-}
-
-fn has_kimi_working_status(content: &str) -> bool {
- content.lines().any(|line| {
- let trimmed = line.trim();
- if matches!(
- trimmed,
- "🌕" | "🌖" | "🌗" | "🌘" | "🌑" | "🌒" | "🌓" | "🌔"
- ) {
- return true;
- }
-
- let mut chars = trimmed.chars();
- let Some(first) = chars.next() else {
- return false;
- };
- if !('\u{2800}'..='\u{28FF}').contains(&first) {
- return false;
- }
-
- let rest = chars
- .as_str()
- .trim_start_matches(|c| ('\u{2800}'..='\u{28FF}').contains(&c))
- .trim_start()
- .to_lowercase();
- rest.starts_with("thinking...")
- || rest.starts_with("working...")
- || rest.starts_with("using ")
- })
-}
-
-fn has_current_approval_panel(content: &str) -> bool {
- let lower = content.to_lowercase();
- has_approval_title(&lower)
- && has_numeric_choose_hint(&lower)
- && lower.contains("↵ confirm")
- && (lower.contains("approve") || lower.contains("reject") || lower.contains("revise"))
-}
-
-fn has_approval_title(lower_content: &str) -> bool {
- lower_content.contains("run this command?")
- || lower_content.contains("write this file?")
- || lower_content.contains("apply these edits?")
- || lower_content.contains("stop this task?")
- || lower_content.contains("ready to build with this plan?")
- || lower_content.lines().any(|line| {
- let trimmed = line.trim_start_matches(|c: char| c == '▶' || c.is_whitespace());
- trimmed.starts_with("approve ") && trimmed.ends_with('?')
- })
-}
-
-fn has_question_panel(content: &str) -> bool {
- let lower = content.to_lowercase();
- content.lines().any(|line| line.trim() == "question")
- && content
- .lines()
- .any(|line| line.trim_start().starts_with("? "))
- && lower.contains("↑↓ select")
- && (lower.contains("↵ choose") || lower.contains("↵ toggle") || lower.contains("↵ save"))
- && lower.contains("esc cancel")
-}
-
-fn has_numeric_choose_hint(lower_content: &str) -> bool {
- lower_content.contains(" choose") && lower_content.contains('1') && lower_content.contains('2')
-}
diff --git a/src/detect/agents/kiro.rs b/src/detect/agents/kiro.rs
deleted file mode 100644
index 889f5315..00000000
--- a/src/detect/agents/kiro.rs
+++ /dev/null
@@ -1,60 +0,0 @@
-use super::super::AgentState;
-
-/// Kiro CLI detection.
-///
-/// Kiro exposes reliable working and idle terminal markers. Tool approval
-/// prompts render with stable approval wording and an action menu.
-pub(super) fn detect(content: &str) -> AgentState {
- let lower = content.to_lowercase();
-
- if has_visible_blocker(content) {
- return AgentState::Blocked;
- }
-
- if lower.contains("kiro is working")
- || (lower.contains("esc to cancel") && has_kiro_tool_spinner(content))
- {
- return AgentState::Working;
- }
-
- AgentState::Idle
-}
-
-pub(super) fn has_visible_blocker(content: &str) -> bool {
- let lower_content = content.to_lowercase();
- has_tool_approval_prompt(&lower_content) || has_subagent_approval_prompt(&lower_content)
-}
-
-fn has_tool_approval_prompt(lower_content: &str) -> bool {
- let has_approval_request = lower_content.contains("requires approval");
- let has_approval_actions = lower_content.contains("yes, single permission")
- || lower_content.contains("trust, always allow")
- || lower_content.contains("no (tab to edit)")
- || lower_content.contains("esc to close");
- has_approval_request && has_approval_actions
-}
-
-fn has_subagent_approval_prompt(lower_content: &str) -> bool {
- let has_approval_request = (lower_content.contains("tool approval")
- || lower_content.contains("tool approvals"))
- && lower_content.contains("pending from subagents");
- let has_approval_actions = lower_content.contains("approve all pending")
- || lower_content.contains("configure individually")
- || lower_content.contains("exit (cancel subagents)");
- has_approval_request && has_approval_actions
-}
-
-fn has_kiro_tool_spinner(content: &str) -> bool {
- content.lines().any(|line| {
- let trimmed = line.trim_start();
- let mut chars = trimmed.chars();
- let Some(first) = chars.next() else {
- return false;
- };
- if !matches!(first, '◔' | '◑' | '◕' | '●') {
- return false;
- }
- let rest = chars.as_str().trim_start();
- rest.chars().next().is_some_and(char::is_alphabetic)
- })
-}
diff --git a/src/detect/agents/mod.rs b/src/detect/agents/mod.rs
deleted file mode 100644
index 72aaf674..00000000
--- a/src/detect/agents/mod.rs
+++ /dev/null
@@ -1,105 +0,0 @@
-pub(super) mod amp;
-pub(super) mod antigravity;
-pub(super) mod claude_code;
-pub(super) mod cline;
-pub(super) mod codex;
-pub(super) mod cursor;
-pub(super) mod droid;
-pub(super) mod gemini;
-pub(super) mod github_copilot;
-pub(super) mod grok;
-pub(super) mod hermes;
-pub(super) mod kilo;
-pub(super) mod kimi;
-pub(super) mod kiro;
-pub(super) mod opencode;
-pub(super) mod pi;
-pub(super) mod qodercli;
-
-use super::{Agent, AgentDetection, AgentState};
-
-pub(super) fn detect(agent: Agent, screen_content: &str) -> AgentDetection {
- let state = match agent {
- Agent::Pi => pi::detect(screen_content),
- Agent::Claude => claude_code::detect(screen_content),
- Agent::Codex => codex::detect(screen_content),
- Agent::Gemini => gemini::detect(screen_content),
- Agent::Cursor => cursor::detect(screen_content),
- Agent::Antigravity => antigravity::detect(screen_content),
- Agent::Cline => cline::detect(screen_content),
- Agent::OpenCode => opencode::detect(screen_content),
- Agent::GithubCopilot => github_copilot::detect(screen_content),
- Agent::Kimi => kimi::detect(screen_content),
- Agent::Kiro => kiro::detect(screen_content),
- Agent::Droid => droid::detect(screen_content),
- Agent::Amp => amp::detect(screen_content),
- Agent::Grok => grok::detect(screen_content),
- Agent::Hermes => hermes::detect(screen_content),
- Agent::Kilo => kilo::detect(screen_content),
- Agent::Qodercli => qodercli::detect(screen_content),
- };
-
- let skip_state_update = should_skip_state_update(agent, screen_content);
- if skip_state_update {
- return AgentDetection {
- state,
- skip_state_update: true,
- visible_blocker: false,
- visible_working: false,
- };
- }
-
- AgentDetection {
- state,
- skip_state_update: false,
- visible_blocker: has_visible_blocker(agent, screen_content),
- visible_working: has_visible_working(agent, screen_content, state),
- }
-}
-
-pub(super) fn should_skip_state_update(agent: Agent, content: &str) -> bool {
- match agent {
- Agent::Claude => claude_code::is_transcript_viewer(content),
- Agent::Codex => codex::is_transcript_viewer(content),
- _ => false,
- }
-}
-
-fn has_visible_blocker(agent: Agent, content: &str) -> bool {
- match agent {
- // Strong visible blockers are opt-in because this flag can override
- // hook authority. Plain blocked heuristics remain valid fallback state,
- // but they must not become hook overrides unless the current UI chrome
- // is known to be structural and live.
- Agent::Claude => claude_code::has_visible_blocker(content),
- Agent::Codex => codex::has_visible_blocker(content),
- Agent::Gemini => gemini::has_visible_blocker(content),
- Agent::Cursor => cursor::has_visible_blocker(content),
- Agent::Antigravity => antigravity::has_visible_blocker(content),
- Agent::Cline => cline::has_visible_blocker(content),
- Agent::OpenCode => opencode::has_visible_blocker(content),
- Agent::GithubCopilot => github_copilot::has_visible_blocker(content),
- Agent::Kimi => kimi::has_visible_blocker(content),
- Agent::Kiro => kiro::has_visible_blocker(content),
- Agent::Droid => droid::has_visible_blocker(content),
- Agent::Amp => amp::has_visible_blocker(content),
- Agent::Grok => grok::has_visible_blocker(content),
- Agent::Hermes => hermes::has_visible_blocker(content),
- Agent::Kilo => kilo::has_visible_blocker(content),
- Agent::Qodercli => qodercli::has_visible_blocker(content),
- _ => false,
- }
-}
-
-fn has_visible_working(agent: Agent, content: &str, state: AgentState) -> bool {
- if state != AgentState::Working {
- return false;
- }
-
- match agent {
- Agent::Claude => claude_code::has_working_chrome(content),
- Agent::Codex => codex::has_visible_working(content),
- Agent::Kimi => kimi::has_visible_working(content),
- _ => false,
- }
-}
diff --git a/src/detect/agents/opencode.rs b/src/detect/agents/opencode.rs
deleted file mode 100644
index ab8a424f..00000000
--- a/src/detect/agents/opencode.rs
+++ /dev/null
@@ -1,58 +0,0 @@
-use super::super::{has_interrupt_pattern, AgentState};
-
-pub(super) fn detect(content: &str) -> AgentState {
- // Blocked
- if has_visible_blocker(content) {
- return AgentState::Blocked;
- }
-
- // Working
- if has_interrupt_pattern(&content.to_lowercase())
- || has_opencode_interrupt_footer(content)
- || has_opencode_progress_run(content)
- {
- return AgentState::Working;
- }
-
- AgentState::Idle
-}
-
-pub(super) fn has_visible_blocker(content: &str) -> bool {
- content.contains("△ Permission required") || has_opencode_question_prompt(content)
-}
-
-fn has_opencode_question_prompt(content: &str) -> bool {
- let lower = content.to_lowercase();
- let has_enter_action = lower.contains("enter confirm")
- || lower.contains("enter submit")
- || lower.contains("enter toggle");
- let has_question_nav = content.contains("↑↓ select") || content.contains("⇆ tab");
-
- lower.contains("esc dismiss") && has_enter_action && has_question_nav
-}
-
-fn has_opencode_interrupt_footer(content: &str) -> bool {
- content.lines().any(|line| {
- let lower = line.to_lowercase();
- if !(lower.contains("esc interrupt") || lower.contains("esc again to interrupt")) {
- return false;
- }
-
- lower.contains("opencode")
- })
-}
-
-fn has_opencode_progress_run(line: &str) -> bool {
- let mut run = 0usize;
- for ch in line.chars() {
- if matches!(ch, '■' | '⬝') {
- run += 1;
- if run >= 4 {
- return true;
- }
- } else {
- run = 0;
- }
- }
- false
-}
diff --git a/src/detect/agents/pi.rs b/src/detect/agents/pi.rs
deleted file mode 100644
index 272231f5..00000000
--- a/src/detect/agents/pi.rs
+++ /dev/null
@@ -1,9 +0,0 @@
-use super::super::AgentState;
-
-pub(super) fn detect(content: &str) -> AgentState {
- // pi shows "Working..." when the agent is processing
- if content.contains("Working...") {
- return AgentState::Working;
- }
- AgentState::Idle
-}
diff --git a/src/detect/agents/qodercli.rs b/src/detect/agents/qodercli.rs
deleted file mode 100644
index d2802464..00000000
--- a/src/detect/agents/qodercli.rs
+++ /dev/null
@@ -1,86 +0,0 @@
-use super::super::AgentState;
-
-/// Qodercli detection.
-///
-/// Qodercli is a Node.js coding-agent CLI. It surfaces a confirmation prompt
-/// while awaiting tool approval and a braille spinner while working.
-pub(super) fn detect(content: &str) -> AgentState {
- let lower = content.to_lowercase();
-
- if has_visible_blocker(content) {
- return AgentState::Blocked;
- }
-
- // Working: explicit "(esc to cancel, …)" hint or an active spinner row.
- if has_qodercli_working_hint(&lower) || has_qodercli_spinner_row(content) {
- return AgentState::Working;
- }
-
- AgentState::Idle
-}
-
-/// Working hints qodercli prints alongside the spinner while the model is
-/// responding. The "(esc to cancel, …)" suffix is unique to qodercli's loading
-/// indicator and survives even when the spinner glyph is masked (e.g. by
-/// a hook icon).
-fn has_qodercli_working_hint(lower_content: &str) -> bool {
- lower_content.contains("(esc to cancel,")
-}
-
-/// Strict spinner-row detection for qodercli.
-///
-/// Matches a line whose first non-whitespace glyph is a braille pattern
-/// (U+2800–U+28FF, the cli-spinners "dots" set qodercli renders), followed by
-/// a space and at least one alphabetic character on the same line. This avoids
-/// flagging the pane as Working when the scrollback merely contains a stale
-/// braille glyph from an earlier frame.
-fn has_qodercli_spinner_row(content: &str) -> bool {
- for line in content.lines() {
- let trimmed = line.trim_start();
- let mut chars = trimmed.chars();
- let Some(first) = chars.next() else {
- continue;
- };
- if !('\u{2800}'..='\u{28FF}').contains(&first) {
- continue;
- }
- let rest: String = chars.collect();
- if rest.starts_with(' ') && rest.chars().any(|c| c.is_alphabetic()) {
- return true;
- }
- }
- false
-}
-
-/// Blocked patterns specific to qodercli.
-///
-/// Mirrors the helper structure used by Claude blocked prompt matching so the
-/// pattern surface stays a single, easy-to-extend list.
-///
-/// Covered states:
-/// * Tool-call confirmation banners ("Waiting for user confirmation",
-/// "Awaiting approval").
-/// * The "Permission Required / Allow once or always?" approval dialog.
-/// * The `ask-user` tool's interactive prompt. "Asking User" is the dialog's
-/// stable BaseTabDialog title and covers every form (single-select,
-/// multi-select, free-form input, review tab). The "Enter your response"
-/// placeholder and "Review your answers:" review heading are kept as
-/// defensive fallbacks in case the title row scrolls off-screen.
-/// * The interactive shell waiting hint emitted by qodercli when an agent
-/// spawns a shell that is now parked for user keystrokes.
-pub(super) fn has_visible_blocker(content: &str) -> bool {
- let lower_content = content.to_lowercase();
- (lower_content.contains("waiting for user confirmation")
- && (lower_content.contains("yes")
- || lower_content.contains("no")
- || lower_content.contains("allow")
- || lower_content.contains("reject")))
- || (lower_content.contains("awaiting approval")
- && (lower_content.contains("allow") || lower_content.contains("reject")))
- || lower_content.contains("permission required")
- || lower_content.contains("allow once or always?")
- || lower_content.contains("asking user")
- || lower_content.contains("enter your response")
- || lower_content.contains("review your answers:")
- || lower_content.contains("shell awaiting input")
-}
diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs
new file mode 100644
index 00000000..6f660367
--- /dev/null
+++ b/src/detect/manifest.rs
@@ -0,0 +1,1411 @@
+use std::{
+ path::{Path, PathBuf},
+ sync::{Mutex, OnceLock, RwLock},
+};
+
+use regex::Regex;
+use serde::Deserialize;
+
+use super::{
+ agent_label, manifest_update::ManifestVersion, parse_agent_label, Agent, AgentDetection,
+ AgentState,
+};
+
+pub const DEFAULT_KNOWN_AGENT_IDLE_FALLBACK: &str = "default_known_agent_idle_fallback";
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DetectionExplain {
+ pub agent: Option<String>,
+ pub state: AgentState,
+ pub source: Option<ManifestSource>,
+ pub matched_rule: Option<MatchedRule>,
+ pub screen_detection_skipped: bool,
+ pub visible_idle: bool,
+ pub visible_blocker: bool,
+ pub visible_working: bool,
+ pub skip_state_update: bool,
+ pub skipped_update_reason: Option<String>,
+ pub fallback_reason: Option<String>,
+ pub evaluated_rules: Vec<EvaluatedRule>,
+ pub warning: Option<String>,
+ pub manifest_version: Option<String>,
+ pub cached_remote_version: Option<String>,
+ pub local_override_shadowing_remote: bool,
+ pub remote_update_status: Option<String>,
+ pub remote_update_error: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ManifestSource {
+ Bundled,
+ Remote { path: PathBuf, version: String },
+ Override(PathBuf),
+}
+
+impl ManifestSource {
+ pub fn label(&self) -> String {
+ match self {
+ Self::Bundled => "bundled".to_string(),
+ Self::Remote { path, .. } => format!("remote:{}", path.display()),
+ Self::Override(path) => path.display().to_string(),
+ }
+ }
+
+ pub fn kind(&self) -> &'static str {
+ match self {
+ Self::Bundled => "bundled",
+ Self::Remote { .. } => "remote",
+ Self::Override(_) => "local override",
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct AgentManifestSummary {
+ pub(crate) agent: Agent,
+ pub(crate) active_source: ManifestSource,
+ pub(crate) active_version: Option<String>,
+ pub(crate) cached_remote_version: Option<String>,
+ pub(crate) local_override_shadowing_remote: bool,
+ pub(crate) warning: Option<String>,
+}
+
+pub(crate) fn manifest_summaries() -> Vec<AgentManifestSummary> {
+ let lock = manifest_cache();
+ let guard = match lock.read() {
+ Ok(guard) => guard,
+ Err(poisoned) => poisoned.into_inner(),
+ };
+ manifest_summaries_from_cache(&guard)
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MatchedRule {
+ pub id: String,
+ pub priority: i32,
+ pub region: String,
+ pub state: AgentState,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct EvaluatedRule {
+ pub id: String,
+ pub priority: i32,
+ pub region: String,
+ pub evidence: RuleEvidence,
+ pub state: AgentState,
+ pub matched: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RuleEvidence {
+ pub contains: Vec<String>,
+ pub regex: Vec<String>,
+ pub line_regex: Vec<String>,
+ pub all_count: usize,
+ pub any_count: usize,
+ pub not_count: usize,
+ pub region_bytes: usize,
+ pub region_preview: String,
+}
+
+#[derive(Debug, Clone)]
+struct LoadedManifest {
+ manifest: AgentManifest,
+ compiled_rules: Vec<CompiledRule>,
+ source: ManifestSource,
+ warning: Option<String>,
+ cached_remote_version: Option<String>,
+ local_override_shadowing_remote: bool,
+}
+
+#[derive(Debug, Clone)]
+struct ManifestCache {
+ manifests: Vec<(Agent, Option<LoadedManifest>)>,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct AgentManifest {
+ id: String,
+ version: Option<ManifestVersion>,
+ min_engine_version: Option<u32>,
+ #[serde(rename = "updated_at")]
+ _updated_at: Option<String>,
+ #[serde(default)]
+ aliases: Vec<String>,
+ #[serde(default)]
+ rules: Vec<ManifestRule>,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+#[serde(deny_unknown_fields)]
+struct ManifestRule {
+ id: String,
+ state: Option<ManifestState>,
+ #[serde(default)]
+ priority: i32,
+ #[serde(default = "default_region")]
+ region: String,
+ #[serde(default)]
+ visible_idle: bool,
+ #[serde(default)]
+ visible_blocker: bool,
+ #[serde(default)]
+ visible_working: bool,
+ #[serde(default)]
+ skip_state_update: bool,
+ #[serde(default)]
+ all: Vec<ManifestGate>,
+ #[serde(default)]
+ any: Vec<ManifestGate>,
+ #[serde(default, rename = "not")]
+ not_gate: Vec<ManifestGate>,
+ #[serde(default)]
+ contains: Vec<String>,
+ #[serde(default)]
+ regex: Vec<String>,
+ #[serde(default)]
+ line_regex: Vec<String>,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+#[serde(deny_unknown_fields)]
+struct ManifestGate {
+ #[serde(default)]
+ all: Vec<ManifestGate>,
+ #[serde(default)]
+ any: Vec<ManifestGate>,
+ #[serde(default, rename = "not")]
+ not_gate: Vec<ManifestGate>,
+ #[serde(default)]
+ contains: Vec<String>,
+ #[serde(default)]
+ regex: Vec<String>,
+ #[serde(default)]
+ line_regex: Vec<String>,
+}
+
+#[derive(Debug, Clone)]
+struct CompiledRule {
+ gate: CompiledGate,
+}
+
+#[derive(Debug, Clone)]
+struct CompiledGate {
+ all: Vec<CompiledGate>,
+ any: Vec<CompiledGate>,
+ not_gate: Vec<CompiledGate>,
+ contains: Vec<String>,
+ regex: Vec<Regex>,
+ line_regex: Vec<Regex>,
+}
+
+#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+enum ManifestState {
+ Idle,
+ Working,
+ Blocked,
+ Unknown,
+}
+
+impl From<ManifestState> for AgentState {
+ fn from(value: ManifestState) -> Self {
+ match value {
+ ManifestState::Idle => AgentState::Idle,
+ ManifestState::Working => AgentState::Working,
+ ManifestState::Blocked => AgentState::Blocked,
+ ManifestState::Unknown => AgentState::Unknown,
+ }
+ }
+}
+
+fn default_region() -> String {
+ "whole_recent".to_string()
+}
+
+const BUNDLED_MANIFESTS: &[(&str, &str)] = &[
+ ("amp", include_str!("manifests/amp.toml")),
+ ("agy", include_str!("manifests/antigravity.toml")),
+ ("claude", include_str!("manifests/claude.toml")),
+ ("cline", include_str!("manifests/cline.toml")),
+ ("codex", include_str!("manifests/codex.toml")),
+ ("cursor", include_str!("manifests/cursor.toml")),
+ ("droid", include_str!("manifests/droid.toml")),
+ ("gemini", include_str!("manifests/gemini.toml")),
+ ("grok", include_str!("manifests/grok.toml")),
+ ("hermes", include_str!("manifests/hermes.toml")),
+ ("kilo", include_str!("manifests/kilo.toml")),
+ ("kimi", include_str!("manifests/kimi.toml")),
+ ("kiro", include_str!("manifests/kiro.toml")),
+ ("opencode", include_str!("manifests/opencode.toml")),
+ ("pi", include_str!("manifests/pi.toml")),
+ ("qodercli", include_str!("manifests/qodercli.toml")),
+ ("copilot", include_str!("manifests/github-copilot.toml")),
+];
+
+static MANIFEST_CACHE: OnceLock<RwLock<ManifestCache>> = OnceLock::new();
+static MANIFEST_RELOAD_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+
+const MAX_RULES_PER_MANIFEST: usize = 128;
+const MAX_GATE_DEPTH: usize = 8;
+const MAX_TOTAL_GATES: usize = 512;
+const MAX_MATCHERS_PER_GATE: usize = 32;
+const MAX_TOTAL_MATCHERS: usize = 1024;
+const MAX_MATCHER_CHARS: usize = 512;
+
+pub(crate) fn reload_manifests() -> Vec<AgentManifestSummary> {
+ let _reload_guard = MANIFEST_RELOAD_LOCK
+ .get_or_init(|| Mutex::new(()))
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ let cache = build_manifest_cache();
+ let summaries = manifest_summaries_from_cache(&cache);
+ let lock = MANIFEST_CACHE.get_or_init(|| RwLock::new(cache.clone()));
+ match lock.write() {
+ Ok(mut guard) => *guard = cache,
+ Err(poisoned) => *poisoned.into_inner() = cache,
+ }
+ summaries
+}
+
+fn manifest_cache() -> &'static RwLock<ManifestCache> {
+ MANIFEST_CACHE.get_or_init(|| RwLock::new(build_manifest_cache()))
+}
+
+fn build_manifest_cache() -> ManifestCache {
+ ManifestCache {
+ manifests: Agent::ALL
+ .into_iter()
+ .map(|agent| (agent, load_manifest_uncached(agent)))
+ .collect(),
+ }
+}
+
+fn manifest_summaries_from_cache(cache: &ManifestCache) -> Vec<AgentManifestSummary> {
+ cache
+ .manifests
+ .iter()
+ .filter_map(|(agent, loaded)| {
+ loaded
+ .clone()
+ .map(|loaded| manifest_summary_from_loaded(*agent, loaded))
+ })
+ .collect()
+}
+
+fn manifest_summary_from_loaded(agent: Agent, loaded: LoadedManifest) -> AgentManifestSummary {
+ AgentManifestSummary {
+ agent,
+ active_version: loaded.manifest.version.as_ref().map(ToString::to_string),
+ active_source: loaded.source,
+ cached_remote_version: loaded.cached_remote_version,
+ local_override_shadowing_remote: loaded.local_override_shadowing_remote,
+ warning: loaded.warning,
+ }
+}
+
+pub fn detect(agent: Agent, screen_content: &str) -> AgentDetection {
+ let Some(loaded) = load_manifest(agent) else {
+ return fallback_explain(Some(agent), None, false).into_detection();
+ };
+ evaluate_loaded_manifest(agent, screen_content, loaded, false).into_detection()
+}
+
+pub fn explain(agent: Agent, screen_content: &str) -> DetectionExplain {
+ let Some(loaded) = load_manifest(agent) else {
+ return fallback_explain(Some(agent), None, true);
+ };
+ evaluate_loaded_manifest(agent, screen_content, loaded, true)
+}
+
+pub fn explain_for_label(agent_label: &str, screen_content: &str) -> DetectionExplain {
+ let Some(agent) = parse_agent_label(agent_label) else {
+ return DetectionExplain {
+ agent: Some(agent_label.to_string()),
+ state: AgentState::Unknown,
+ source: None,
+ matched_rule: None,
+ screen_detection_skipped: false,
+ visible_idle: false,
+ visible_blocker: false,
+ visible_working: false,
+ skip_state_update: false,
+ skipped_update_reason: None,
+ fallback_reason: Some("unknown_agent".to_string()),
+ evaluated_rules: Vec::new(),
+ warning: None,
+ manifest_version: None,
+ cached_remote_version: None,
+ local_override_shadowing_remote: false,
+ remote_update_status: None,
+ remote_update_error: None,
+ };
+ };
+ 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, screen_content, loaded, false).skip_state_update
+}
+
+impl DetectionExplain {
+ fn into_detection(self) -> AgentDetection {
+ AgentDetection {
+ state: self.state,
+ skip_state_update: self.skip_state_update,
+ visible_idle: self.visible_idle,
+ visible_blocker: self.visible_blocker,
+ visible_working: self.visible_working,
+ }
+ }
+}
+
+fn evaluate_loaded_manifest(
+ agent: Agent,
+ screen_content: &str,
+ loaded: LoadedManifest,
+ include_update_status: bool,
+) -> DetectionExplain {
+ let mut matched: Option<(&ManifestRule, String)> = None;
+ let mut evaluated_rules = Vec::new();
+
+ for (rule, compiled_rule) in loaded.manifest.rules.iter().zip(&loaded.compiled_rules) {
+ let region_text = region(screen_content, &rule.region);
+ let matched_rule = compiled_rule_matches(compiled_rule, region_text);
+ evaluated_rules.push(EvaluatedRule {
+ id: rule.id.clone(),
+ priority: rule.priority,
+ region: rule.region.clone(),
+ evidence: rule_evidence(rule, region_text),
+ state: rule
+ .state
+ .map(AgentState::from)
+ .unwrap_or(AgentState::Unknown),
+ matched: matched_rule,
+ });
+
+ if !matched_rule {
+ continue;
+ }
+
+ match matched {
+ Some((previous, _)) if previous.priority >= rule.priority => {}
+ _ => matched = Some((rule, rule.region.clone())),
+ }
+ }
+
+ let Some((rule, region_name)) = matched else {
+ return fallback_explain(
+ Some(agent),
+ Some((loaded, evaluated_rules)),
+ include_update_status,
+ );
+ };
+
+ let state = rule
+ .state
+ .map(AgentState::from)
+ .unwrap_or(AgentState::Unknown);
+ let skipped_update_reason = rule
+ .skip_state_update
+ .then(|| format!("matched_rule:{}", rule.id));
+
+ let remote_update_status = include_update_status
+ .then(|| remote_update_status(agent))
+ .flatten();
+
+ DetectionExplain {
+ agent: Some(agent_label(agent).to_string()),
+ state,
+ source: Some(loaded.source),
+ matched_rule: Some(MatchedRule {
+ id: rule.id.clone(),
+ priority: rule.priority,
+ region: region_name,
+ state,
+ }),
+ screen_detection_skipped: false,
+ visible_idle: rule.visible_idle && state == AgentState::Idle,
+ visible_blocker: rule.visible_blocker && state == AgentState::Blocked,
+ visible_working: rule.visible_working && state == AgentState::Working,
+ skip_state_update: rule.skip_state_update,
+ skipped_update_reason,
+ fallback_reason: None,
+ evaluated_rules,
+ warning: loaded.warning,
+ manifest_version: loaded.manifest.version.as_ref().map(ToString::to_string),
+ cached_remote_version: loaded.cached_remote_version,
+ local_override_shadowing_remote: loaded.local_override_shadowing_remote,
+ remote_update_status: remote_update_status
+ .as_ref()
+ .map(|status| status.last_result.clone()),
+ remote_update_error: remote_update_status.and_then(|status| status.last_error),
+ }
+}
+
+fn fallback_explain(
+ agent: Option<Agent>,
+ context: Option<(LoadedManifest, Vec<EvaluatedRule>)>,
+ include_update_status: bool,
+) -> DetectionExplain {
+ let (
+ source,
+ evaluated_rules,
+ warning,
+ manifest_version,
+ cached_remote_version,
+ local_override_shadowing_remote,
+ ) = context
+ .map(|(loaded, evaluated)| {
+ (
+ Some(loaded.source),
+ evaluated,
+ loaded.warning,
+ loaded.manifest.version.as_ref().map(ToString::to_string),
+ loaded.cached_remote_version,
+ loaded.local_override_shadowing_remote,
+ )
+ })
+ .unwrap_or((None, Vec::new(), None, None, None, false));
+ let known_agent = agent.is_some();
+ 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 {
+ AgentState::Idle
+ } else {
+ AgentState::Unknown
+ },
+ source,
+ matched_rule: None,
+ screen_detection_skipped: false,
+ visible_idle: false,
+ visible_blocker: false,
+ visible_working: false,
+ skip_state_update: false,
+ skipped_update_reason: None,
+ fallback_reason: known_agent.then(|| DEFAULT_KNOWN_AGENT_IDLE_FALLBACK.to_string()),
+ evaluated_rules,
+ warning,
+ manifest_version,
+ cached_remote_version,
+ local_override_shadowing_remote,
+ remote_update_status: remote_update_status
+ .as_ref()
+ .map(|status| status.last_result.clone()),
+ remote_update_error: remote_update_status.and_then(|status| status.last_error),
+ }
+}
+
+fn load_manifest(agent: Agent) -> Option<LoadedManifest> {
+ let lock = manifest_cache();
+ let guard = match lock.read() {
+ Ok(guard) => guard,
+ Err(poisoned) => poisoned.into_inner(),
+ };
+ guard
+ .manifests
+ .iter()
+ .find(|(cached_agent, _)| *cached_agent == agent)
+ .and_then(|(_, loaded)| loaded.clone())
+}
+
+fn load_manifest_uncached(agent: Agent) -> Option<LoadedManifest> {
+ let bundled = bundled_manifest(agent)?;
+ let mut remote = read_remote_manifest(agent, &bundled);
+ let cached_remote_version = remote.as_ref().and_then(|loaded| match &loaded.source {
+ _ if loaded.cached_remote_version.is_some() => loaded.cached_remote_version.clone(),
+ ManifestSource::Remote { version, .. } => Some(version.clone()),
+ _ => None,
+ });
+ let Some(path) = override_path(agent) else {
+ if let Some(loaded) = remote.as_mut() {
+ loaded.cached_remote_version = cached_remote_version.clone();
+ }
+ return Some(remote.unwrap_or_else(|| {
+ bundled_loaded_manifest(agent, bundled, None, cached_remote_version, false)
+ }));
+ };
+ let local_override_shadowing_remote = path.exists() && cached_remote_version.is_some();
+ if let Some(loaded) = remote.as_mut() {
+ loaded.cached_remote_version = cached_remote_version.clone();
+ loaded.local_override_shadowing_remote = local_override_shadowing_remote;
+ }
+
+ if !path.exists() {
+ return Some(remote.unwrap_or_else(|| {
+ bundled_loaded_manifest(
+ agent,
+ bundled,
+ None,
+ cached_remote_version,
+ local_override_shadowing_remote,
+ )
+ }));
+ }
+
+ match read_override_manifest(&path) {
+ Ok(manifest) if manifest_matches_agent(&manifest, agent) => {
+ match loaded_manifest(
+ manifest,
+ ManifestSource::Override(path.clone()),
+ None,
+ cached_remote_version.clone(),
+ local_override_shadowing_remote,
+ ) {
+ Ok(loaded) => Some(loaded),
+ Err(err) => {
+ let mut loaded = remote.unwrap_or_else(|| {
+ bundled_loaded_manifest(
+ agent,
+ bundled,
+ None,
+ cached_remote_version,
+ local_override_shadowing_remote,
+ )
+ });
+ loaded.warning = Some(format!(
+ "ignored override {} because it could not be compiled: {err}",
+ path.display()
+ ));
+ Some(loaded)
+ }
+ }
+ }
+ Ok(manifest) => {
+ let mut loaded = remote.unwrap_or_else(|| {
+ bundled_loaded_manifest(
+ agent,
+ bundled,
+ None,
+ cached_remote_version,
+ local_override_shadowing_remote,
+ )
+ });
+ loaded.warning = Some(format!(
+ "ignored override {} because manifest id {} does not match {}",
+ path.display(),
+ manifest.id,
+ agent_label(agent)
+ ));
+ Some(loaded)
+ }
+ Err(err) => {
+ let mut loaded = remote.unwrap_or_else(|| {
+ bundled_loaded_manifest(
+ agent,
+ bundled,
+ None,
+ cached_remote_version,
+ local_override_shadowing_remote,
+ )
+ });
+ loaded.warning = Some(format!(
+ "ignored override {} because it could not be loaded: {err}",
+ path.display()
+ ));
+ Some(loaded)
+ }
+ }
+}
+
+fn loaded_manifest(
+ manifest: AgentManifest,
+ source: ManifestSource,
+ warning: Option<String>,
+ cached_remote_version: Option<String>,
+ local_override_shadowing_remote: bool,
+) -> Result<LoadedManifest, String> {
+ let compiled_rules = compile_manifest(&manifest)?;
+ Ok(LoadedManifest {
+ manifest,
+ compiled_rules,
+ source,
+ warning,
+ cached_remote_version,
+ local_override_shadowing_remote,
+ })
+}
+
+fn bundled_loaded_manifest(
+ agent: Agent,
+ manifest: AgentManifest,
+ warning: Option<String>,
+ cached_remote_version: Option<String>,
+ local_override_shadowing_remote: bool,
+) -> LoadedManifest {
+ loaded_manifest(
+ manifest,
+ ManifestSource::Bundled,
+ warning,
+ cached_remote_version,
+ local_override_shadowing_remote,
+ )
+ .unwrap_or_else(|err| {
+ panic!(
+ "bundled {} manifest could not be compiled: {err}",
+ agent_label(agent)
+ )
+ })
+}
+
+fn bundled_manifest(agent: Agent) -> Option<AgentManifest> {
+ let id = agent_label(agent);
+ BUNDLED_MANIFESTS
+ .iter()
+ .find(|(manifest_id, _)| *manifest_id == id)
+ .map(|(_, content)| {
+ parse_manifest(content)
+ .unwrap_or_else(|err| panic!("bundled {id} manifest is invalid: {err}"))
+ })
+}
+
+fn read_override_manifest(path: &Path) -> Result<AgentManifest, String> {
+ let content = std::fs::read_to_string(path).map_err(|err| err.to_string())?;
+ parse_manifest(&content)
+}
+
+fn read_remote_manifest(agent: Agent, bundled: &AgentManifest) -> Option<LoadedManifest> {
+ let path = super::manifest_update::remote_manifest_path(agent);
+ if !path.exists() {
+ return None;
+ }
+ match std::fs::read_to_string(&path)
+ .map_err(|err| err.to_string())
+ .and_then(|content| {
+ parse_remote_manifest_for_agent(agent, &content).map(|parsed| parsed.manifest)
+ }) {
+ Ok(manifest) => {
+ let version = manifest
+ .version
+ .as_ref()
+ .map(ToString::to_string)
+ .unwrap_or_else(|| "unknown".to_string());
+ if let (Some(remote_version), Some(bundled_version)) =
+ (manifest.version.as_ref(), bundled.version.as_ref())
+ {
+ if remote_version < bundled_version {
+ return Some(bundled_loaded_manifest(
+ agent,
+ bundled.clone(),
+ Some(format!(
+ "ignored remote manifest {} because cached version {remote_version} is older than bundled {bundled_version}",
+ path.display()
+ )),
+ Some(remote_version.to_string()),
+ false,
+ ));
+ }
+ }
+ match loaded_manifest(
+ manifest,
+ ManifestSource::Remote {
+ path: path.clone(),
+ version,
+ },
+ None,
+ None,
+ false,
+ ) {
+ Ok(loaded) => Some(loaded),
+ Err(err) => Some(bundled_loaded_manifest(
+ agent,
+ bundled.clone(),
+ Some(format!(
+ "ignored remote manifest {} because it could not be compiled: {err}",
+ path.display()
+ )),
+ None,
+ false,
+ )),
+ }
+ }
+ Err(err) => Some(bundled_loaded_manifest(
+ agent,
+ bundled.clone(),
+ Some(format!(
+ "ignored remote manifest {} because it could not be loaded: {err}",
+ path.display()
+ )),
+ None,
+ false,
+ )),
+ }
+}
+
+pub fn agent_state_label(state: AgentState) -> &'static str {
+ match state {
+ AgentState::Idle => "idle",
+ AgentState::Working => "working",
+ AgentState::Blocked => "blocked",
+ AgentState::Unknown => "unknown",
+ }
+}
+
+pub fn explain_to_json_value(explain: &DetectionExplain) -> serde_json::Value {
+ let matched_rule = explain.matched_rule.as_ref().map(|rule| {
+ serde_json::json!({
+ "id": rule.id,
+ "priority": rule.priority,
+ "region": rule.region,
+ "state": agent_state_label(rule.state),
+ })
+ });
+ let evaluated_rules: Vec<_> = explain
+ .evaluated_rules
+ .iter()
+ .map(|rule| {
+ serde_json::json!({
+ "id": rule.id,
+ "priority": rule.priority,
+ "region": rule.region,
+ "state": agent_state_label(rule.state),
+ "matched": rule.matched,
+ "evidence": {
+ "contains": &rule.evidence.contains,
+ "regex": &rule.evidence.regex,
+ "line_regex": &rule.evidence.line_regex,
+ "all_count": rule.evidence.all_count,
+ "any_count": rule.evidence.any_count,
+ "not_count": rule.evidence.not_count,
+ "region_bytes": rule.evidence.region_bytes,
+ "region_preview": &rule.evidence.region_preview,
+ },
+ })
+ })
+ .collect();
+
+ serde_json::json!({
+ "agent": explain.agent,
+ "state": agent_state_label(explain.state),
+ "manifest_source": explain.source.as_ref().map(|source| source.label()),
+ "manifest_version": &explain.manifest_version,
+ "cached_remote_version": &explain.cached_remote_version,
+ "local_override_shadowing_remote": explain.local_override_shadowing_remote,
+ "remote_update_status": &explain.remote_update_status,
+ "remote_update_error": &explain.remote_update_error,
+ "matched_rule": matched_rule,
+ "visible_idle": explain.visible_idle,
+ "visible_blocker": explain.visible_blocker,
+ "visible_working": explain.visible_working,
+ "screen_detection_skipped": explain.screen_detection_skipped,
+ "skip_state_update": explain.skip_state_update,
+ "skipped_update_reason": explain.skipped_update_reason,
+ "fallback_reason": explain.fallback_reason,
+ "warning": explain.warning,
+ "evaluated_rules": evaluated_rules,
+ })
+}
+
+pub(crate) struct ParsedRemoteManifest {
+ pub(crate) manifest: AgentManifest,
+ pub(crate) version: ManifestVersion,
+}
+
+pub(crate) fn parse_manifest(content: &str) -> Result<AgentManifest, String> {
+ let manifest = toml::from_str::<AgentManifest>(content).map_err(|err| err.to_string())?;
+ validate_manifest(&manifest)?;
+ Ok(manifest)
+}
+
+pub(crate) fn parse_remote_manifest_for_agent(
+ agent: Agent,
+ content: &str,
+) -> Result<ParsedRemoteManifest, String> {
+ let manifest = parse_manifest(content)?;
+ if !manifest_matches_agent(&manifest, agent) {
+ return Err(format!(
+ "manifest id {} does not match {}",
+ manifest.id,
+ agent_label(agent)
+ ));
+ }
+ let version = manifest
+ .version
+ .clone()
+ .ok_or("remote manifest must include version")?;
+ let min_engine_version = manifest
+ .min_engine_version
+ .ok_or("remote manifest must include min_engine_version")?;
+ if min_engine_version > super::manifest_update::MANIFEST_ENGINE_VERSION {
+ return Err(format!(
+ "manifest requires engine {min_engine_version}, current engine is {}",
+ super::manifest_update::MANIFEST_ENGINE_VERSION
+ ));
+ }
+ Ok(ParsedRemoteManifest { manifest, version })
+}
+
+fn validate_manifest(manifest: &AgentManifest) -> Result<(), String> {
+ if manifest.rules.is_empty() {
+ return Err("manifest must contain at least one rule".to_string());
+ }
+ if manifest.rules.len() > MAX_RULES_PER_MANIFEST {
+ return Err(format!(
+ "manifest contains {} rules, max is {MAX_RULES_PER_MANIFEST}",
+ manifest.rules.len()
+ ));
+ }
+
+ let mut complexity = ManifestComplexity::default();
+ for rule in &manifest.rules {
+ if rule.id.trim().is_empty() {
+ return Err("manifest rule id must not be empty".to_string());
+ }
+ if rule.skip_state_update {
+ if rule.state != Some(ManifestState::Unknown) {
+ return Err(format!(
+ "rule {} uses skip_state_update without state = \"unknown\"",
+ rule.id
+ ));
+ }
+ if rule.visible_idle || rule.visible_blocker || rule.visible_working {
+ return Err(format!(
+ "rule {} uses skip_state_update with visible state evidence",
+ rule.id
+ ));
+ }
+ }
+ validate_region_name(&rule.region)
+ .map_err(|err| format!("rule {} uses invalid region: {err}", rule.id))?;
+ validate_rule_gate(rule, &mut complexity)
+ .map_err(|err| format!("rule {} has invalid matcher gates: {err}", rule.id))?;
+ }
+
+ Ok(())
+}
+
+#[derive(Default)]
+struct ManifestComplexity {
+ total_gates: usize,
+ total_matchers: usize,
+}
+
+fn validate_rule_gate(
+ rule: &ManifestRule,
+ complexity: &mut ManifestComplexity,
+) -> Result<(), String> {
+ validate_gate(&manifest_gate_from_rule(rule), "rule", 0, complexity)
+}
+
+fn validate_gate(
+ gate: &ManifestGate,
+ context: &str,
+ depth: usize,
+ complexity: &mut ManifestComplexity,
+) -> Result<(), String> {
+ if depth > MAX_GATE_DEPTH {
+ return Err(format!("{context} exceeds max gate depth {MAX_GATE_DEPTH}"));
+ }
+ complexity.total_gates += 1;
+ if complexity.total_gates > MAX_TOTAL_GATES {
+ return Err(format!("manifest exceeds max gate count {MAX_TOTAL_GATES}"));
+ }
+ validate_matcher_limits(gate, context, complexity)?;
+ if !gate_has_positive_matcher(gate) {
+ return Err(format!("{context} must contain a positive matcher"));
+ }
+ validate_regex_patterns(&gate.regex, context, "regex")?;
+ validate_regex_patterns(&gate.line_regex, context, "line_regex")?;
+ for nested in &gate.all {
+ validate_gate(nested, "all gate", depth + 1, complexity)?;
+ }
+ for nested in &gate.any {
+ validate_gate(nested, "any gate", depth + 1, complexity)?;
+ }
+ for nested in &gate.not_gate {
+ if !gate_has_any_matcher(nested) {
+ return Err(format!("{context} contains an empty not gate"));
+ }
+ validate_not_gate(nested, depth + 1, complexity)?;
+ }
+ Ok(())
+}
+
+fn validate_not_gate(
+ gate: &ManifestGate,
+ depth: usize,
+ complexity: &mut ManifestComplexity,
+) -> Result<(), String> {
+ if depth > MAX_GATE_DEPTH {
+ return Err(format!("not gate exceeds max gate depth {MAX_GATE_DEPTH}"));
+ }
+ complexity.total_gates += 1;
+ if complexity.total_gates > MAX_TOTAL_GATES {
+ return Err(format!("manifest exceeds max gate count {MAX_TOTAL_GATES}"));
+ }
+ validate_matcher_limits(gate, "not gate", complexity)?;
+ if !gate_has_any_matcher(gate) {
+ return Err("not gate must contain a matcher".to_string());
+ }
+ validate_regex_patterns(&gate.regex, "not gate", "regex")?;
+ validate_regex_patterns(&gate.line_regex, "not gate", "line_regex")?;
+ for nested in &gate.all {
+ validate_gate(nested, "not all gate", depth + 1, complexity)?;
+ }
+ for nested in &gate.any {
+ validate_gate(nested, "not any gate", depth + 1, complexity)?;
+ }
+ for nested in &gate.not_gate {
+ validate_not_gate(nested, depth + 1, complexity)?;
+ }
+ Ok(())
+}
+
+fn validate_matcher_limits(
+ gate: &ManifestGate,
+ context: &str,
+ complexity: &mut ManifestComplexity,
+) -> Result<(), String> {
+ let matcher_count = gate.contains.len() + gate.regex.len() + gate.line_regex.len();
+ if matcher_count > MAX_MATCHERS_PER_GATE {
+ return Err(format!(
+ "{context} has {matcher_count} direct matchers, max is {MAX_MATCHERS_PER_GATE}"
+ ));
+ }
+ complexity.total_matchers += matcher_count;
+ if complexity.total_matchers > MAX_TOTAL_MATCHERS {
+ return Err(format!(
+ "manifest exceeds max matcher count {MAX_TOTAL_MATCHERS}"
+ ));
+ }
+ for value in gate
+ .contains
+ .iter()
+ .chain(gate.regex.iter())
+ .chain(gate.line_regex.iter())
+ {
+ if value.chars().count() > MAX_MATCHER_CHARS {
+ return Err(format!(
+ "{context} matcher exceeds max length {MAX_MATCHER_CHARS}"
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn validate_regex_patterns(patterns: &[String], context: &str, field: &str) -> Result<(), String> {
+ for pattern in patterns {
+ Regex::new(pattern).map_err(|err| {
+ format!("{context} contains invalid {field} pattern {pattern:?}: {err}")
+ })?;
+ }
+ Ok(())
+}
+
+fn gate_has_positive_matcher(gate: &ManifestGate) -> bool {
+ !gate.contains.is_empty()
+ || !gate.regex.is_empty()
+ || !gate.line_regex.is_empty()
+ || !gate.all.is_empty()
+ || !gate.any.is_empty()
+}
+
+fn gate_has_any_matcher(gate: &ManifestGate) -> bool {
+ gate_has_positive_matcher(gate) || !gate.not_gate.is_empty()
+}
+
+fn validate_region_name(spec: &str) -> Result<(), String> {
+ let trimmed = spec.trim();
+ match trimmed {
+ "whole_recent"
+ | "after_last_prompt_marker"
+ | "before_current_prompt_marker"
+ | "whole_recent_without_current_prompt_marker"
+ | "current_prompt_block_marker"
+ | "after_current_prompt_block_marker"
+ | "prompt_box_body"
+ | "above_prompt_box"
+ | "last_non_empty_above_prompt_box"
+ | "after_last_horizontal_rule" => Ok(()),
+ _ if region_count(trimmed, "bottom_lines").is_some()
+ || region_count(trimmed, "bottom_non_empty_lines").is_some() =>
+ {
+ Ok(())
+ }
+ _ => Err(trimmed.to_string()),
+ }
+}
+
+fn override_path(agent: Agent) -> Option<PathBuf> {
+ Some(
+ crate::config::config_dir()
+ .join("agent-detection")
+ .join(format!("{}.toml", agent_label(agent))),
+ )
+}
+
+fn remote_update_status(agent: Agent) -> Option<super::manifest_update::AgentRemoteStatus> {
+ super::manifest_update::load_status().agent_status(agent)
+}
+
+fn manifest_matches_agent(manifest: &AgentManifest, agent: Agent) -> bool {
+ let id = agent_label(agent);
+ manifest.id == id
+ || manifest.aliases.iter().any(|alias| alias == id)
+ || parse_agent_label(&manifest.id) == Some(agent)
+ || manifest
+ .aliases
+ .iter()
+ .any(|alias| parse_agent_label(alias) == Some(agent))
+}
+
+fn manifest_gate_from_rule(rule: &ManifestRule) -> ManifestGate {
+ ManifestGate {
+ all: rule.all.clone(),
+ any: rule.any.clone(),
+ not_gate: rule.not_gate.clone(),
+ contains: rule.contains.clone(),
+ regex: rule.regex.clone(),
+ line_regex: rule.line_regex.clone(),
+ }
+}
+
+fn compile_manifest(manifest: &AgentManifest) -> Result<Vec<CompiledRule>, String> {
+ manifest
+ .rules
+ .iter()
+ .map(|rule| {
+ compile_gate(&manifest_gate_from_rule(rule))
+ .map(|gate| CompiledRule { gate })
+ .map_err(|err| format!("rule {} could not be compiled: {err}", rule.id))
+ })
+ .collect()
+}
+
+fn compile_gate(gate: &ManifestGate) -> Result<CompiledGate, String> {
+ Ok(CompiledGate {
+ all: gate
+ .all
+ .iter()
+ .map(compile_gate)
+ .collect::<Result<_, _>>()?,
+ any: gate
+ .any
+ .iter()
+ .map(compile_gate)
+ .collect::<Result<_, _>>()?,
+ not_gate: gate
+ .not_gate
+ .iter()
+ .map(compile_gate)
+ .collect::<Result<_, _>>()?,
+ contains: gate
+ .contains
+ .iter()
+ .map(|needle| needle.to_lowercase())
+ .collect(),
+ regex: gate
+ .regex
+ .iter()
+ .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string()))
+ .collect::<Result<_, _>>()?,
+ line_regex: gate
+ .line_regex
+ .iter()
+ .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string()))
+ .collect::<Result<_, _>>()?,
+ })
+}
+
+fn compiled_rule_matches(rule: &CompiledRule, text: &str) -> bool {
+ let lower_text = text.to_lowercase();
+ compiled_gate_matches(&rule.gate, text, &lower_text)
+}
+
+fn rule_evidence(rule: &ManifestRule, region_text: &str) -> RuleEvidence {
+ RuleEvidence {
+ contains: rule.contains.clone(),
+ regex: rule.regex.clone(),
+ line_regex: rule.line_regex.clone(),
+ all_count: rule.all.len(),
+ any_count: rule.any.len(),
+ not_count: rule.not_gate.len(),
+ region_bytes: region_text.len(),
+ region_preview: bounded_preview(region_text),
+ }
+}
+
+fn bounded_preview(text: &str) -> String {
+ const MAX_CHARS: usize = 240;
+ let mut preview: String = text.chars().take(MAX_CHARS).collect();
+ if text.chars().count() > MAX_CHARS {
+ preview.push_str("...");
+ }
+ preview
+}
+
+fn compiled_gate_matches(gate: &CompiledGate, text: &str, lower_text: &str) -> bool {
+ if !gate
+ .contains
+ .iter()
+ .all(|needle| lower_text.contains(needle))
+ {
+ return false;
+ }
+
+ if !gate.regex.iter().all(|regex| regex.is_match(text)) {
+ return false;
+ }
+
+ if !gate
+ .line_regex
+ .iter()
+ .all(|regex| text.lines().any(|line| regex.is_match(line)))
+ {
+ return false;
+ }
+
+ if !gate
+ .all
+ .iter()
+ .all(|nested| compiled_gate_matches(nested, text, lower_text))
+ {
+ return false;
+ }
+
+ if !gate.any.is_empty()
+ && !gate
+ .any
+ .iter()
+ .any(|nested| compiled_gate_matches(nested, text, lower_text))
+ {
+ return false;
+ }
+
+ if gate
+ .not_gate
+ .iter()
+ .any(|nested| compiled_gate_matches(nested, text, lower_text))
+ {
+ return false;
+ }
+
+ true
+}
+
+fn region<'a>(content: &'a str, spec: &str) -> &'a str {
+ let trimmed = spec.trim();
+ match trimmed {
+ "whole_recent" => content,
+ "after_last_prompt_marker" => after_last_prompt_marker(content),
+ "before_current_prompt_marker" => before_current_prompt_marker(content),
+ "whole_recent_without_current_prompt_marker" => {
+ whole_recent_without_current_prompt_marker(content)
+ }
+ "current_prompt_block_marker" => current_prompt_block_marker(content).unwrap_or(""),
+ "after_current_prompt_block_marker" => {
+ after_current_prompt_block_marker(content).unwrap_or("")
+ }
+ "prompt_box_body" => prompt_box_body(content).unwrap_or(""),
+ "above_prompt_box" => above_prompt_box(content),
+ "last_non_empty_above_prompt_box" => last_non_empty_line(above_prompt_box(content)),
+ "after_last_horizontal_rule" => after_last_horizontal_rule(content),
+ _ => {
+ if let Some(count) = region_count(trimmed, "bottom_lines") {
+ return bottom_lines(content, count);
+ }
+ if let Some(count) = region_count(trimmed, "bottom_non_empty_lines") {
+ return bottom_non_empty_lines(content, count);
+ }
+ ""
+ }
+ }
+}
+
+fn region_count(spec: &str, name: &str) -> Option<usize> {
+ spec.strip_prefix(name)
+ .and_then(|rest| rest.strip_prefix('('))
+ .and_then(|rest| rest.strip_suffix(')'))
+ .and_then(|count| count.parse::<usize>().ok())
+}
+
+fn bottom_lines(content: &str, count: usize) -> &str {
+ let lines: Vec<&str> = content.lines().collect();
+ let start = lines.len().saturating_sub(count);
+ slice_from_line_index(content, &lines, start)
+}
+
+fn bottom_non_empty_lines(content: &str, count: usize) -> &str {
+ let lines: Vec<&str> = content.lines().collect();
+ let Some(start_index) = lines
+ .iter()
+ .enumerate()
+ .rev()
+ .filter(|(_, line)| !line.trim().is_empty())
+ .take(count)
+ .last()
+ .map(|(index, _)| index)
+ else {
+ return "";
+ };
+ slice_from_line_index(content, &lines, start_index)
+}
+
+fn after_last_prompt_marker(content: &str) -> &str {
+ let lines: Vec<&str> = content.lines().collect();
+ let Some(index) = lines.iter().rposition(|line| codex_prompt_line(line)) else {
+ return content;
+ };
+ slice_from_line_index(content, &lines, index + 1)
+}
+
+fn before_current_prompt_marker(content: &str) -> &str {
+ let lines: Vec<&str> = content.lines().collect();
+ let Some(index) = current_codex_prompt_index(&lines) else {
+ return content;
+ };
+ let byte_offset = lines[..index]
+ .iter()
+ .map(|line| line.len() + 1)
+ .sum::<usize>();
+ &content[..byte_offset.min(content.len())]
+}
+
+fn whole_recent_without_current_prompt_marker(content: &str) -> &str {
+ let lines: Vec<&str> = content.lines().collect();
+ if current_codex_prompt_index(&lines).is_some() {
+ ""
+ } else {
+ content
+ }
+}
+
+fn current_prompt_block_marker(content: &str) -> Option<&str> {
+ let lines: Vec<&str> = content.lines().collect();
+ let prompt_index = current_codex_prompt_index(&lines)?;
+ lines[..prompt_index]
+ .iter()
+ .rev()
+ .find(|line| codex_block_marker_line(line))
+ .copied()
+}
+
+fn after_current_prompt_block_marker(content: &str) -> Option<&str> {
+ let lines: Vec<&str> = content.lines().collect();
+ let prompt_index = current_codex_prompt_index(&lines)?;
+ let block_index = lines[..prompt_index]
+ .iter()
+ .rposition(|line| codex_block_marker_line(line))?;
+ Some(slice_from_line_index(content, &lines, block_index))
+}
+
+fn current_codex_prompt_index(lines: &[&str]) -> Option<usize> {
+ let prompt_index = lines.iter().rposition(|line| codex_prompt_line(line))?;
+ if lines[prompt_index + 1..]
+ .iter()
+ .any(|line| codex_block_marker_line(line))
+ {
+ return None;
+ }
+ Some(prompt_index)
+}
+
+fn codex_prompt_line(line: &str) -> bool {
+ line == "›" || line.starts_with("› ")
+}
+
+fn codex_block_marker_line(line: &str) -> bool {
+ line.starts_with('•') || line.starts_with('■') || line.starts_with('✗') || line.starts_with('✓')
+}
+
+fn prompt_box_body(content: &str) -> Option<&str> {
+ let lines: Vec<&str> = content.lines().collect();
+ let top = prompt_box_top_border_index(&lines)?;
+ let start = line_start_offset(content, &lines, top + 1);
+ let end_index = lines[top + 1..]
+ .iter()
+ .position(|line| is_horizontal_rule(line))
+ .map(|relative| top + 1 + relative)
+ .unwrap_or(lines.len());
+ let end = line_start_offset(content, &lines, end_index);
+ Some(&content[start.min(content.len())..end.min(content.len())])
+}
+
+fn above_prompt_box(content: &str) -> &str {
+ let lines: Vec<&str> = content.lines().collect();
+ let Some(top) = prompt_box_top_border_index(&lines) else {
+ return content;
+ };
+ let end = line_start_offset(content, &lines, top);
+ &content[..end.min(content.len())]
+}
+
+fn after_last_horizontal_rule(content: &str) -> &str {
+ let mut last_rule_end = 0usize;
+ let mut offset = 0usize;
+ for line in content.lines() {
+ let next_offset = offset + line.len() + 1;
+ if is_horizontal_rule(line) {
+ last_rule_end = next_offset.min(content.len());
+ }
+ offset = next_offset;
+ }
+ &content[last_rule_end..]
+}
+
+fn last_non_empty_line(content: &str) -> &str {
+ content
+ .lines()
+ .rev()
+ .find(|line| !line.trim().is_empty())
+ .unwrap_or("")
+}
+
+fn prompt_box_top_border_index(lines: &[&str]) -> Option<usize> {
+ let mut border_count = 0;
+ for index in (0..lines.len()).rev() {
+ if is_horizontal_rule(lines[index]) {
+ border_count += 1;
+ if border_count == 2 {
+ return Some(index);
+ }
+ }
+ }
+ None
+}
+
+fn is_horizontal_rule(line: &str) -> bool {
+ let trimmed = line.trim();
+ if trimmed.is_empty() {
+ return false;
+ }
+
+ let rule_chars = trimmed.chars().take_while(|&ch| ch == '─').count();
+ if rule_chars == 0 {
+ return false;
+ }
+
+ let rule_bytes = trimmed
+ .char_indices()
+ .nth(rule_chars)
+ .map(|(index, _)| index)
+ .unwrap_or(trimmed.len());
+ let suffix = trimmed[rule_bytes..].trim_start();
+
+ suffix.is_empty() || rule_chars >= 3
+}
+
+fn slice_from_line_index<'a>(content: &'a str, lines: &[&str], index: usize) -> &'a str {
+ let byte_offset = line_start_offset(content, lines, index);
+ &content[byte_offset.min(content.len())..]
+}
+
+fn line_start_offset(content: &str, lines: &[&str], index: usize) -> usize {
+ lines[..index.min(lines.len())]
+ .iter()
+ .map(|line| line.len() + 1)
+ .sum::<usize>()
+ .min(content.len())
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs
new file mode 100644
index 00000000..e5b03c0b
--- /dev/null
+++ b/src/detect/manifest/tests.rs
@@ -0,0 +1,498 @@
+use super::*;
+
+fn remote_manifest(version: &str, state: &str, contains: &str) -> String {
+ format!(
+ r#"
+id = "codex"
+version = "{version}"
+min_engine_version = 1
+updated_at = "2026-06-10T12:00:00Z"
+
+[[rules]]
+id = "test"
+state = "{state}"
+contains = ["{contains}"]
+"#
+ )
+}
+
+fn local_manifest(state: &str, contains: &str) -> String {
+ format!(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "test"
+state = "{state}"
+contains = ["{contains}"]
+"#
+ )
+}
+
+fn rules_manifest(rules: &str) -> String {
+ format!(
+ r#"
+id = "codex"
+
+{rules}
+"#
+ )
+}
+
+fn with_manifest_dirs<T>(name: &str, f: impl FnOnce() -> T) -> T {
+ let _guard = crate::config::test_config_env_lock().lock().unwrap();
+ let old_config = std::env::var_os("XDG_CONFIG_HOME");
+ let old_state = std::env::var_os("XDG_STATE_HOME");
+ let base = std::env::temp_dir().join(format!(
+ "herdr-manifest-loader-{name}-{}",
+ std::process::id()
+ ));
+ let config_dir = base.join("config");
+ let state_dir = base.join("state");
+ let _ = std::fs::remove_dir_all(&base);
+ std::env::set_var("XDG_CONFIG_HOME", &config_dir);
+ std::env::set_var("XDG_STATE_HOME", &state_dir);
+ reload_manifests();
+ let result = f();
+ match old_config {
+ Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
+ None => std::env::remove_var("XDG_CONFIG_HOME"),
+ }
+ match old_state {
+ Some(value) => std::env::set_var("XDG_STATE_HOME", value),
+ None => std::env::remove_var("XDG_STATE_HOME"),
+ }
+ reload_manifests();
+ let _ = std::fs::remove_dir_all(&base);
+ result
+}
+
+fn write_remote_codex(content: &str) {
+ let path = crate::detect::manifest_update::remote_manifest_path(Agent::Codex);
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(path, content).unwrap();
+ reload_manifests();
+}
+
+fn write_remote_codex_without_reload(content: &str) {
+ let path = crate::detect::manifest_update::remote_manifest_path(Agent::Codex);
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(path, content).unwrap();
+}
+
+fn write_local_codex(content: &str) {
+ let path = override_path(Agent::Codex).unwrap();
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(path, content).unwrap();
+ reload_manifests();
+}
+
+#[test]
+fn known_agent_no_match_defaults_to_idle_fallback() {
+ let explain = explain(Agent::Codex, "ordinary prompt text");
+
+ assert_eq!(explain.state, AgentState::Idle);
+ assert!(!explain.visible_idle);
+ assert_eq!(
+ explain.fallback_reason.as_deref(),
+ Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
+ );
+}
+
+#[test]
+fn rule_semantics_apply_gates_priority_and_line_regex() {
+ with_manifest_dirs("rule-semantics", || {
+ write_local_codex(&rules_manifest(
+ r#"
+[[rules]]
+id = "low_contains"
+state = "idle"
+priority = 1
+contains = ["match"]
+
+[[rules]]
+id = "high_nested_gates"
+state = "working"
+priority = 10
+contains = ["match"]
+all = [
+ { any = [{ regex = ["w[io]n"] }, { contains = ["fallback"] }] },
+]
+not = [
+ { contains = ["blocked"] },
+]
+
+[[rules]]
+id = "line_regex"
+state = "blocked"
+priority = 20
+line_regex = ["^exact line$"]
+"#,
+ ));
+
+ let high = explain(Agent::Codex, "match win");
+ assert_eq!(high.state, AgentState::Working);
+ assert_eq!(
+ high.matched_rule.as_ref().map(|rule| rule.id.as_str()),
+ Some("high_nested_gates")
+ );
+
+ let not_gate = explain(Agent::Codex, "match win blocked");
+ assert_eq!(not_gate.state, AgentState::Idle);
+ assert_eq!(
+ not_gate.matched_rule.as_ref().map(|rule| rule.id.as_str()),
+ Some("low_contains")
+ );
+
+ let line = explain(Agent::Codex, "before\nexact line\nafter");
+ assert_eq!(line.state, AgentState::Blocked);
+ assert_eq!(
+ line.matched_rule.as_ref().map(|rule| rule.id.as_str()),
+ Some("line_regex")
+ );
+ });
+}
+
+#[test]
+fn remote_manifest_loads_between_local_override_and_bundled() {
+ with_manifest_dirs("remote-source", || {
+ write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready"));
+
+ let explain = explain(Agent::Codex, "remote-ready");
+
+ assert_eq!(explain.state, AgentState::Blocked);
+ assert!(matches!(
+ explain.source,
+ Some(ManifestSource::Remote { .. })
+ ));
+ assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.1"));
+ assert_eq!(
+ explain.cached_remote_version.as_deref(),
+ Some("2026.06.10.1")
+ );
+ });
+}
+
+#[test]
+fn fallback_explain_preserves_active_manifest_version() {
+ with_manifest_dirs("fallback-version", || {
+ write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready"));
+
+ let explain = explain(Agent::Codex, "ordinary prompt text");
+
+ assert_eq!(explain.state, AgentState::Idle);
+ assert_eq!(
+ explain.fallback_reason.as_deref(),
+ Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
+ );
+ assert_eq!(explain.manifest_version.as_deref(), Some("2026.06.10.1"));
+ assert!(matches!(
+ explain.source,
+ Some(ManifestSource::Remote { .. })
+ ));
+ });
+}
+
+#[test]
+fn older_cached_remote_manifest_does_not_shadow_newer_bundled_manifest() {
+ with_manifest_dirs("older-remote-bundled-fallback", || {
+ write_remote_codex(&remote_manifest("2026.06.10.0", "blocked", "remote-ready"));
+
+ let explain = explain(Agent::Codex, "remote-ready");
+
+ assert_eq!(explain.state, AgentState::Idle);
+ assert!(matches!(explain.source, Some(ManifestSource::Bundled)));
+ assert_eq!(
+ explain.cached_remote_version.as_deref(),
+ Some("2026.06.10.0")
+ );
+ assert!(explain
+ .warning
+ .as_deref()
+ .is_some_and(|warning| warning.contains("older than bundled")));
+ });
+}
+
+#[test]
+fn local_override_shadows_cached_remote_manifest() {
+ with_manifest_dirs("local-shadows-remote", || {
+ write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready"));
+ write_local_codex(&local_manifest("idle", "local-ready"));
+
+ let explain = explain(Agent::Codex, "local-ready");
+
+ assert_eq!(explain.state, AgentState::Idle);
+ assert!(matches!(explain.source, Some(ManifestSource::Override(_))));
+ assert!(explain.local_override_shadowing_remote);
+ assert_eq!(
+ explain.cached_remote_version.as_deref(),
+ Some("2026.06.10.1")
+ );
+ });
+}
+
+#[test]
+fn invalid_local_override_falls_back_to_cached_remote_manifest() {
+ with_manifest_dirs("invalid-local-remote-fallback", || {
+ write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "remote-ready"));
+ write_local_codex("id = ");
+
+ let explain = explain(Agent::Codex, "remote-ready");
+
+ assert_eq!(explain.state, AgentState::Blocked);
+ assert!(matches!(
+ explain.source,
+ Some(ManifestSource::Remote { .. })
+ ));
+ assert!(explain.warning.is_some());
+ });
+}
+
+#[test]
+fn detection_uses_cached_manifest_until_explicit_reload() {
+ with_manifest_dirs("cache-boundary", || {
+ write_remote_codex(&remote_manifest("2026.06.10.1", "blocked", "cached-ready"));
+
+ let cached = explain(Agent::Codex, "cached-ready");
+ assert_eq!(cached.state, AgentState::Blocked);
+ assert!(matches!(cached.source, Some(ManifestSource::Remote { .. })));
+ assert_eq!(
+ cached.matched_rule.as_ref().map(|rule| rule.id.as_str()),
+ Some("test")
+ );
+
+ write_remote_codex_without_reload(&remote_manifest("2026.06.10.2", "working", "new-ready"));
+
+ let unchanged = explain(Agent::Codex, "new-ready");
+ assert_eq!(unchanged.state, AgentState::Idle);
+ assert_eq!(
+ unchanged.fallback_reason.as_deref(),
+ Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
+ );
+ assert_eq!(
+ unchanged.cached_remote_version.as_deref(),
+ Some("2026.06.10.1")
+ );
+
+ reload_manifests();
+
+ let reloaded = explain(Agent::Codex, "new-ready");
+ assert_eq!(reloaded.state, AgentState::Working);
+ assert_eq!(
+ reloaded.cached_remote_version.as_deref(),
+ Some("2026.06.10.2")
+ );
+ assert_eq!(
+ reloaded.matched_rule.as_ref().map(|rule| rule.id.as_str()),
+ Some("test")
+ );
+ });
+}
+
+#[test]
+fn all_bundled_manifests_parse_and_validate() {
+ let agents = [
+ Agent::Pi,
+ Agent::Claude,
+ Agent::Codex,
+ Agent::Gemini,
+ Agent::Cursor,
+ Agent::Antigravity,
+ Agent::Cline,
+ Agent::OpenCode,
+ Agent::GithubCopilot,
+ Agent::Kimi,
+ Agent::Kiro,
+ Agent::Droid,
+ Agent::Amp,
+ Agent::Grok,
+ Agent::Hermes,
+ Agent::Kilo,
+ Agent::Qodercli,
+ ];
+
+ for agent in agents {
+ assert!(
+ bundled_manifest(agent).is_some(),
+ "missing bundled manifest for {}",
+ agent_label(agent)
+ );
+ }
+}
+
+#[test]
+fn manifest_validation_rejects_unknown_fields_empty_rules_invalid_regions_and_regexes() {
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "typo"
+state = "working"
+contain = ["Working"]
+"#
+ )
+ .is_err());
+
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "empty"
+state = "working"
+"#
+ )
+ .is_err());
+
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "bad_region"
+state = "working"
+region = "after_last_promt_marker"
+contains = ["Working"]
+"#
+ )
+ .is_err());
+
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "bad_regex"
+state = "working"
+regex = ["["]
+"#
+ )
+ .is_err());
+
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "bad_nested_regex"
+state = "working"
+any = [{ line_regex = ["["] }]
+"#
+ )
+ .is_err());
+}
+
+#[test]
+fn manifest_validation_keeps_skip_rules_neutral() {
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "bad_skip_state"
+state = "idle"
+skip_state_update = true
+contains = ["menu"]
+"#
+ )
+ .is_err());
+
+ assert!(parse_manifest(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "bad_skip_visible"
+state = "unknown"
+skip_state_update = true
+visible_blocker = true
+contains = ["menu"]
+"#
+ )
+ .is_err());
+}
+
+#[test]
+fn manifest_validation_rejects_excessive_rule_count() {
+ let mut manifest = String::from(
+ r#"
+id = "codex"
+"#,
+ );
+ for index in 0..129 {
+ manifest.push_str(&format!(
+ r#"
+[[rules]]
+id = "rule_{index}"
+state = "idle"
+contains = ["ready"]
+"#
+ ));
+ }
+
+ assert!(parse_manifest(&manifest).is_err());
+}
+
+#[test]
+fn manifest_validation_rejects_excessive_gate_depth() {
+ let manifest = r#"
+id = "codex"
+
+[[rules]]
+id = "deep"
+state = "idle"
+contains = ["ready"]
+all = [
+ { contains = ["1"], all = [
+ { contains = ["2"], all = [
+ { contains = ["3"], all = [
+ { contains = ["4"], all = [
+ { contains = ["5"], all = [
+ { contains = ["6"], all = [
+ { contains = ["7"], all = [
+ { contains = ["8"], all = [
+ { contains = ["9"] },
+ ] },
+ ] },
+ ] },
+ ] },
+ ] },
+ ] },
+ ] },
+ ] },
+]
+"#;
+
+ assert!(parse_manifest(manifest).is_err());
+}
+
+#[test]
+fn manifest_validation_rejects_excessive_matchers() {
+ let matchers = (0..33)
+ .map(|index| format!(r#""m{index}""#))
+ .collect::<Vec<_>>()
+ .join(", ");
+ let manifest = format!(
+ r#"
+id = "codex"
+
+[[rules]]
+id = "many"
+state = "idle"
+contains = [{matchers}]
+"#
+ );
+
+ assert!(parse_manifest(&manifest).is_err());
+}
+
+#[test]
+fn bottom_non_empty_lines_uses_bottom_occurrence_for_repeated_text() {
+ let content = "marker\nold\n\nmiddle\nmarker\nnew\n";
+
+ assert_eq!(
+ region(content, "bottom_non_empty_lines(2)"),
+ "marker\nnew\n"
+ );
+}
diff --git a/src/detect/manifest_update.rs b/src/detect/manifest_update.rs
new file mode 100644
index 00000000..6534b885
--- /dev/null
+++ b/src/detect/manifest_update.rs
@@ -0,0 +1,774 @@
+use std::{
+ cmp::Ordering,
+ collections::{BTreeMap, BTreeSet},
+ fmt, fs,
+ io::{Read, Write},
+ path::{Path, PathBuf},
+ process::{Command, Stdio},
+ time::{SystemTime, UNIX_EPOCH},
+};
+
+use serde::{Deserialize, Serialize};
+
+use super::{agent_label, parse_agent_label, Agent};
+
+pub(crate) const MANIFEST_ENGINE_VERSION: u32 = 1;
+const DEFAULT_CATALOG_URL: &str = "https://herdr.dev/agent-detection/index.toml";
+const CATALOG_URL_ENV: &str = "HERDR_AGENT_DETECTION_MANIFEST_CATALOG_URL";
+const MAX_FETCH_BYTES: usize = 256 * 1024;
+
+#[derive(Debug, Clone)]
+pub(crate) struct ManifestVersion(String);
+
+impl ManifestVersion {
+ pub(crate) fn parse(value: &str) -> Result<Self, String> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ return Err("version must not be empty".to_string());
+ }
+ for segment in trimmed.split('.') {
+ if segment.is_empty() {
+ return Err(format!("version {trimmed:?} contains an empty segment"));
+ }
+ if !segment.chars().all(|ch| ch.is_ascii_digit()) {
+ return Err(format!("version {trimmed:?} must be dotted numeric"));
+ }
+ segment
+ .parse::<u64>()
+ .map_err(|_| format!("version {trimmed:?} contains an oversized segment"))?;
+ }
+ Ok(Self(trimmed.to_string()))
+ }
+}
+
+impl fmt::Display for ManifestVersion {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&self.0)
+ }
+}
+
+impl<'de> Deserialize<'de> for ManifestVersion {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let value = String::deserialize(deserializer)?;
+ Self::parse(&value).map_err(serde::de::Error::custom)
+ }
+}
+
+impl Serialize for ManifestVersion {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(&self.0)
+ }
+}
+
+impl Ord for ManifestVersion {
+ fn cmp(&self, other: &Self) -> Ordering {
+ let mut left = self.0.split('.');
+ let mut right = other.0.split('.');
+
+ loop {
+ match (left.next(), right.next()) {
+ (Some(left), Some(right)) => {
+ let left = left.parse::<u64>().unwrap_or(0);
+ let right = right.parse::<u64>().unwrap_or(0);
+ match left.cmp(&right) {
+ Ordering::Equal => {}
+ ordering => return ordering,
+ }
+ }
+ (Some(left), None) => {
+ let left = left.parse::<u64>().unwrap_or(0);
+ if left == 0 {
+ continue;
+ }
+ return Ordering::Greater;
+ }
+ (None, Some(right)) => {
+ let right = right.parse::<u64>().unwrap_or(0);
+ if right == 0 {
+ continue;
+ }
+ return Ordering::Less;
+ }
+ (None, None) => return Ordering::Equal,
+ }
+ }
+ }
+}
+
+impl PartialOrd for ManifestVersion {
+ fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+ Some(self.cmp(other))
+ }
+}
+
+impl PartialEq for ManifestVersion {
+ fn eq(&self, other: &Self) -> bool {
+ self.cmp(other) == Ordering::Equal
+ }
+}
+
+impl Eq for ManifestVersion {}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct ManifestUpdateCommit {
+ pub(crate) agent: Agent,
+ pub(crate) version: ManifestVersion,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub(crate) struct ManifestUpdateStatus {
+ pub(crate) last_check_unix: Option<u64>,
+ pub(crate) last_result: Option<String>,
+ #[serde(default)]
+ pub(crate) agents: BTreeMap<String, AgentRemoteStatus>,
+}
+
+impl ManifestUpdateStatus {
+ pub(crate) fn agent_status(&self, agent: Agent) -> Option<AgentRemoteStatus> {
+ self.agents.get(agent_label(agent)).cloned()
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub(crate) struct AgentRemoteStatus {
+ pub(crate) cached_version: Option<String>,
+ pub(crate) attempted_version: Option<String>,
+ pub(crate) last_checked_unix: Option<u64>,
+ pub(crate) last_result: String,
+ pub(crate) last_error: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ManifestCatalog {
+ schema_version: u32,
+ #[serde(default)]
+ agents: Vec<ManifestCatalogAgent>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ManifestCatalogAgent {
+ id: String,
+ path: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct CatalogAgent {
+ agent: Agent,
+ path: String,
+}
+
+pub(crate) fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
+ let result = check_and_update();
+ let status = match result {
+ Ok(output) => {
+ if !output.updated.is_empty() {
+ super::manifest::reload_manifests();
+ }
+ let _ = events.blocking_send(crate::events::AppEvent::AgentDetectionManifestsUpdated {
+ updated: output.updated,
+ status: output.status,
+ });
+ return;
+ }
+ Err(err) => {
+ tracing::warn!("agent detection manifest update failed: {err}");
+ let mut status = load_status();
+ status.last_check_unix = Some(now_unix());
+ status.last_result = Some(format!("failed: {err}"));
+ let _ = save_status(&status);
+ status
+ }
+ };
+ let _ = events.blocking_send(crate::events::AppEvent::AgentDetectionManifestsUpdated {
+ updated: Vec::new(),
+ status,
+ });
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct ManifestUpdateOutput {
+ pub(crate) updated: Vec<ManifestUpdateCommit>,
+ pub(crate) status: ManifestUpdateStatus,
+}
+
+pub(crate) fn check_and_update() -> Result<ManifestUpdateOutput, String> {
+ check_and_update_from_url(&catalog_url())
+}
+
+fn check_and_update_from_url(url: &str) -> Result<ManifestUpdateOutput, String> {
+ let catalog = parse_catalog(&fetch_text(url)?)?;
+ let base_url = base_url(url)?;
+ let mut status = load_status();
+ let check_time = now_unix();
+ status.last_check_unix = Some(check_time);
+ status.last_result = Some("checked".to_string());
+
+ let mut updated = Vec::new();
+ for entry in catalog {
+ let agent_id = agent_label(entry.agent).to_string();
+ let manifest_url = join_url(&base_url, &entry.path)?;
+ match fetch_text(&manifest_url)
+ .map_err(|err| format!("fetch failed: {err}"))
+ .and_then(|content| process_agent_manifest(entry.agent, &content, check_time))
+ {
+ Ok(Some(commit)) => {
+ status.agents.insert(
+ agent_id,
+ AgentRemoteStatus {
+ cached_version: Some(commit.version.to_string()),
+ attempted_version: Some(commit.version.to_string()),
+ last_checked_unix: Some(check_time),
+ last_result: "updated".to_string(),
+ last_error: None,
+ },
+ );
+ updated.push(commit);
+ }
+ Ok(None) => {
+ let cached_version = cached_remote_version(entry.agent);
+ status.agents.insert(
+ agent_id,
+ AgentRemoteStatus {
+ cached_version: cached_version.map(|version| version.to_string()),
+ attempted_version: None,
+ last_checked_unix: Some(check_time),
+ last_result: "current".to_string(),
+ last_error: None,
+ },
+ );
+ }
+ Err(err) => {
+ tracing::warn!(
+ agent = agent_label(entry.agent),
+ error = %err,
+ "agent detection manifest update failed for agent"
+ );
+ let cached_version = cached_remote_version(entry.agent);
+ status.agents.insert(
+ agent_id,
+ AgentRemoteStatus {
+ cached_version: cached_version.map(|version| version.to_string()),
+ attempted_version: None,
+ last_checked_unix: Some(check_time),
+ last_result: "failed".to_string(),
+ last_error: Some(err),
+ },
+ );
+ }
+ }
+ }
+
+ if let Err(err) = save_status(&status) {
+ tracing::warn!("failed to save agent detection manifest update status: {err}");
+ status.last_result = Some(format!("failed_to_save_status: {err}"));
+ }
+ Ok(ManifestUpdateOutput { updated, status })
+}
+
+fn process_agent_manifest(
+ agent: Agent,
+ content: &str,
+ _check_time: u64,
+) -> Result<Option<ManifestUpdateCommit>, String> {
+ let parsed = super::manifest::parse_remote_manifest_for_agent(agent, content)?;
+ if let Some(current) = cached_remote_version(agent) {
+ match parsed.version.cmp(&current) {
+ Ordering::Less => {
+ return Err(format!(
+ "remote version {} is older than cached {current}",
+ parsed.version
+ ))
+ }
+ Ordering::Equal => {
+ let committed = fs::read_to_string(remote_manifest_path(agent)).unwrap_or_default();
+ if committed != content {
+ return Err(format!(
+ "remote version {} changed content without a version bump",
+ parsed.version
+ ));
+ }
+ return Ok(None);
+ }
+ Ordering::Greater => {}
+ }
+ }
+
+ commit_remote_manifest(agent, content)?;
+ Ok(Some(ManifestUpdateCommit {
+ agent,
+ version: parsed.version,
+ }))
+}
+
+fn parse_catalog(content: &str) -> Result<Vec<CatalogAgent>, String> {
+ let catalog: ManifestCatalog =
+ toml::from_str(content).map_err(|err| format!("failed to parse catalog TOML: {err}"))?;
+ if catalog.schema_version != 1 {
+ return Err(format!(
+ "unsupported catalog schema_version {}",
+ catalog.schema_version
+ ));
+ }
+
+ let mut seen = BTreeSet::new();
+ let mut agents = Vec::new();
+ for entry in catalog.agents {
+ let Some(agent) = parse_agent_label(&entry.id) else {
+ tracing::warn!(agent = entry.id, "skipping unknown remote manifest agent");
+ continue;
+ };
+ if entry.path.trim().is_empty() {
+ return Err(format!("catalog entry {} has an empty path", entry.id));
+ }
+ if entry.path.contains("://")
+ || entry.path.starts_with('/')
+ || entry.path.split('/').any(|part| part == "..")
+ {
+ return Err(format!(
+ "catalog entry {} has an unsafe path {}",
+ entry.id, entry.path
+ ));
+ }
+ if !seen.insert(agent_label(agent).to_string()) {
+ return Err(format!("catalog contains duplicate agent {}", entry.id));
+ }
+ agents.push(CatalogAgent {
+ agent,
+ path: entry.path,
+ });
+ }
+ Ok(agents)
+}
+
+pub(crate) fn load_status() -> ManifestUpdateStatus {
+ let path = status_path();
+ let Ok(content) = fs::read_to_string(&path) else {
+ return ManifestUpdateStatus::default();
+ };
+ toml::from_str(&content).unwrap_or_else(|err| {
+ tracing::warn!(
+ path = %path.display(),
+ "failed to parse agent detection manifest status: {err}"
+ );
+ ManifestUpdateStatus::default()
+ })
+}
+
+fn save_status(status: &ManifestUpdateStatus) -> Result<(), String> {
+ let path = status_path();
+ let parent = path
+ .parent()
+ .ok_or_else(|| format!("status path {} has no parent", path.display()))?;
+ fs::create_dir_all(parent).map_err(|err| err.to_string())?;
+ let content = toml::to_string_pretty(status).map_err(|err| err.to_string())?;
+ atomic_write(&path, content.as_bytes())
+}
+
+pub(crate) fn status_path() -> PathBuf {
+ state_root().join("status.toml")
+}
+
+pub(crate) fn remote_manifest_path(agent: Agent) -> PathBuf {
+ state_root()
+ .join("remote")
+ .join(format!("{}.toml", agent_label(agent)))
+}
+
+pub(crate) fn cached_remote_version(agent: Agent) -> Option<ManifestVersion> {
+ let content = fs::read_to_string(remote_manifest_path(agent)).ok()?;
+ super::manifest::parse_remote_manifest_for_agent(agent, &content)
+ .ok()
+ .map(|parsed| parsed.version)
+}
+
+fn commit_remote_manifest(agent: Agent, content: &str) -> Result<(), String> {
+ let path = remote_manifest_path(agent);
+ let parent = path
+ .parent()
+ .ok_or_else(|| format!("remote manifest path {} has no parent", path.display()))?;
+ fs::create_dir_all(parent).map_err(|err| err.to_string())?;
+ atomic_write(&path, content.as_bytes())
+}
+
+fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> {
+ let parent = path
+ .parent()
+ .ok_or_else(|| format!("path {} has no parent", path.display()))?;
+ fs::create_dir_all(parent).map_err(|err| err.to_string())?;
+ let tmp_path = parent.join(format!(
+ ".{}.{}.{}.tmp",
+ path.file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or("manifest"),
+ std::process::id(),
+ now_nanos()
+ ));
+ {
+ let mut file = fs::File::create(&tmp_path).map_err(|err| err.to_string())?;
+ if let Err(err) = file.write_all(bytes).and_then(|_| file.sync_all()) {
+ let _ = fs::remove_file(&tmp_path);
+ return Err(err.to_string());
+ }
+ }
+ fs::rename(&tmp_path, path).map_err(|err| {
+ let _ = fs::remove_file(&tmp_path);
+ err.to_string()
+ })?;
+ if let Err(err) = sync_parent_dir(parent) {
+ tracing::warn!(
+ path = %path.display(),
+ error = %err,
+ "agent detection manifest committed but parent directory sync failed"
+ );
+ }
+ Ok(())
+}
+
+fn sync_parent_dir(parent: &Path) -> Result<(), String> {
+ let dir = match fs::File::open(parent) {
+ Ok(dir) => dir,
+ Err(err) if directory_sync_unsupported(&err) => return Ok(()),
+ Err(err) => return Err(format!("failed to open parent directory for sync: {err}")),
+ };
+ match dir.sync_all() {
+ Ok(()) => Ok(()),
+ Err(err) if directory_sync_unsupported(&err) => Ok(()),
+ Err(err) => Err(format!("failed to sync parent directory: {err}")),
+ }
+}
+
+fn directory_sync_unsupported(err: &std::io::Error) -> bool {
+ matches!(
+ err.kind(),
+ std::io::ErrorKind::Unsupported | std::io::ErrorKind::InvalidInput
+ )
+}
+
+fn state_root() -> PathBuf {
+ crate::config::state_dir().join("agent-detection")
+}
+
+fn catalog_url() -> String {
+ std::env::var(CATALOG_URL_ENV)
+ .ok()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty())
+ .unwrap_or_else(|| DEFAULT_CATALOG_URL.to_string())
+}
+
+fn fetch_text(url: &str) -> Result<String, String> {
+ let max_fetch_bytes = MAX_FETCH_BYTES.to_string();
+ let mut child = Command::new("curl")
+ .args([
+ "-sfL",
+ "--retry",
+ "2",
+ "--connect-timeout",
+ "5",
+ "--max-time",
+ "15",
+ "--max-filesize",
+ &max_fetch_bytes,
+ url,
+ ])
+ .stdout(Stdio::piped())
+ .spawn()
+ .map_err(|err| format!("curl failed: {err}"))?;
+
+ let mut bytes = Vec::new();
+ let Some(stdout) = child.stdout.as_mut() else {
+ let _ = child.kill();
+ let _ = child.wait();
+ return Err("curl stdout was not captured".to_string());
+ };
+ stdout
+ .take((MAX_FETCH_BYTES + 1) as u64)
+ .read_to_end(&mut bytes)
+ .map_err(|err| {
+ let _ = child.kill();
+ let _ = child.wait();
+ format!("failed to read curl response: {err}")
+ })?;
+ if bytes.len() > MAX_FETCH_BYTES {
+ let _ = child.kill();
+ let _ = child.wait();
+ return Err(format!(
+ "response from {url} exceeded {MAX_FETCH_BYTES} bytes"
+ ));
+ }
+
+ let status = child
+ .wait()
+ .map_err(|err| format!("curl wait failed: {err}"))?;
+ if !status.success() {
+ return Err(format!("failed to fetch {url}"));
+ }
+ String::from_utf8(bytes).map_err(|err| format!("response was not UTF-8: {err}"))
+}
+
+fn base_url(url: &str) -> Result<String, String> {
+ let Some((base, _)) = url.rsplit_once('/') else {
+ return Err(format!("catalog URL {url} has no base path"));
+ };
+ Ok(base.to_string())
+}
+
+fn join_url(base: &str, path: &str) -> Result<String, String> {
+ if path.contains("://") || path.starts_with('/') || path.split('/').any(|part| part == "..") {
+ return Err(format!("unsafe manifest path {path}"));
+ }
+ Ok(format!("{}/{}", base.trim_end_matches('/'), path))
+}
+
+fn now_unix() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_secs())
+ .unwrap_or(0)
+}
+
+fn now_nanos() -> u128 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_nanos())
+ .unwrap_or(0)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ fn remote_manifest(version: &str, contains: &str) -> String {
+ format!(
+ r#"
+id = "codex"
+version = "{version}"
+min_engine_version = 1
+updated_at = "2026-06-10T12:00:00Z"
+
+[[rules]]
+id = "idle"
+state = "idle"
+contains = ["{contains}"]
+"#
+ )
+ }
+
+ fn with_state_dir<T>(name: &str, f: impl FnOnce() -> T) -> T {
+ let _guard = crate::config::test_config_env_lock().lock().unwrap();
+ let old_config = std::env::var_os("XDG_CONFIG_HOME");
+ let old_state = std::env::var_os("XDG_STATE_HOME");
+ let dir = std::env::temp_dir().join(format!(
+ "herdr-manifest-update-{name}-{}",
+ std::process::id()
+ ));
+ let config_dir = dir.join("config");
+ let state_dir = dir.join("state");
+ let _ = fs::remove_dir_all(&dir);
+ std::env::set_var("XDG_CONFIG_HOME", &config_dir);
+ std::env::set_var("XDG_STATE_HOME", &state_dir);
+ crate::detect::manifest::reload_manifests();
+ let result = f();
+ match old_config {
+ Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
+ None => std::env::remove_var("XDG_CONFIG_HOME"),
+ }
+ match old_state {
+ Some(value) => std::env::set_var("XDG_STATE_HOME", value),
+ None => std::env::remove_var("XDG_STATE_HOME"),
+ }
+ crate::detect::manifest::reload_manifests();
+ let _ = fs::remove_dir_all(&dir);
+ result
+ }
+
+ #[test]
+ fn manifest_version_compares_dotted_numeric_segments() {
+ assert!(
+ ManifestVersion::parse("2026.6.10.1").unwrap()
+ > ManifestVersion::parse("2026.6.9.9").unwrap()
+ );
+ assert!(ManifestVersion::parse("1.2.0").unwrap() == ManifestVersion::parse("1.2").unwrap());
+ assert!(ManifestVersion::parse("1.2.1").unwrap() > ManifestVersion::parse("1.2").unwrap());
+ }
+
+ #[test]
+ fn manifest_version_rejects_non_numeric_segments() {
+ assert!(ManifestVersion::parse("").is_err());
+ assert!(ManifestVersion::parse("2026.06.alpha").is_err());
+ assert!(ManifestVersion::parse("2026..06").is_err());
+ assert!(ManifestVersion::parse("2026.999999999999999999999999999999").is_err());
+ }
+
+ #[test]
+ fn process_agent_manifest_commits_newer_manifest_atomically() {
+ with_state_dir("commit-newer", || {
+ let content = remote_manifest("2026.06.10.1", "ready");
+ let commit = process_agent_manifest(Agent::Codex, &content, 1)
+ .unwrap()
+ .unwrap();
+
+ assert_eq!(commit.agent, Agent::Codex);
+ assert_eq!(
+ commit.version,
+ ManifestVersion::parse("2026.06.10.1").unwrap()
+ );
+ assert_eq!(
+ fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(),
+ content
+ );
+ });
+ }
+
+ #[test]
+ fn auto_update_reloads_manifest_cache_after_remote_commit() {
+ with_state_dir("auto-update-reloads-cache", || {
+ let old_catalog_url = std::env::var_os(CATALOG_URL_ENV);
+ let web_dir = std::env::temp_dir()
+ .join(format!("herdr-manifest-update-web-{}", std::process::id()));
+ let _ = fs::remove_dir_all(&web_dir);
+ fs::create_dir_all(&web_dir).unwrap();
+ fs::write(
+ web_dir.join("index.toml"),
+ r#"
+schema_version = 1
+
+[[agents]]
+id = "codex"
+path = "codex.toml"
+"#,
+ )
+ .unwrap();
+ fs::write(
+ web_dir.join("codex.toml"),
+ remote_manifest("2026.06.10.1", "auto-update-ready"),
+ )
+ .unwrap();
+ std::env::set_var(
+ CATALOG_URL_ENV,
+ format!("file://{}", web_dir.join("index.toml").display()),
+ );
+
+ let (tx, mut rx) = tokio::sync::mpsc::channel(1);
+ auto_update(tx);
+
+ let event = rx.try_recv().expect("manifest update event");
+ let crate::events::AppEvent::AgentDetectionManifestsUpdated { updated, .. } = event
+ else {
+ panic!("unexpected event");
+ };
+ assert_eq!(updated.len(), 1);
+ assert_eq!(updated[0].agent, Agent::Codex);
+
+ let explain = crate::detect::manifest::explain(Agent::Codex, "auto-update-ready");
+ assert_eq!(explain.state, crate::detect::AgentState::Idle);
+ assert!(matches!(
+ explain.source,
+ Some(crate::detect::manifest::ManifestSource::Remote { .. })
+ ));
+ assert_eq!(
+ explain.matched_rule.as_ref().map(|rule| rule.id.as_str()),
+ Some("idle")
+ );
+
+ match old_catalog_url {
+ Some(value) => std::env::set_var(CATALOG_URL_ENV, value),
+ None => std::env::remove_var(CATALOG_URL_ENV),
+ }
+ let _ = fs::remove_dir_all(&web_dir);
+ });
+ }
+
+ #[test]
+ fn process_agent_manifest_rejects_downgrade_and_keeps_cached_manifest() {
+ with_state_dir("reject-downgrade", || {
+ let current = remote_manifest("2026.06.10.2", "current");
+ process_agent_manifest(Agent::Codex, &current, 1).unwrap();
+
+ let older = remote_manifest("2026.06.10.1", "older");
+ assert!(process_agent_manifest(Agent::Codex, &older, 2).is_err());
+ assert_eq!(
+ fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(),
+ current
+ );
+ });
+ }
+
+ #[test]
+ fn process_agent_manifest_rejects_equal_version_content_change() {
+ with_state_dir("reject-equal-change", || {
+ let current = remote_manifest("2026.06.10.1", "current");
+ process_agent_manifest(Agent::Codex, &current, 1).unwrap();
+
+ let changed = remote_manifest("2026.06.10.1", "changed");
+ assert!(process_agent_manifest(Agent::Codex, &changed, 2).is_err());
+ assert_eq!(
+ fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(),
+ current
+ );
+ });
+ }
+
+ #[test]
+ fn process_agent_manifest_skips_same_version_same_content() {
+ with_state_dir("skip-same", || {
+ let current = remote_manifest("2026.06.10.1", "current");
+ process_agent_manifest(Agent::Codex, &current, 1).unwrap();
+
+ let result = process_agent_manifest(Agent::Codex, &current, 2).unwrap();
+ assert!(result.is_none());
+ });
+ }
+
+ #[test]
+ fn catalog_parses_known_agents_and_rejects_duplicates() {
+ let catalog = parse_catalog(
+ r#"
+schema_version = 1
+
+[[agents]]
+id = "codex"
+path = "codex.toml"
+"#,
+ )
+ .unwrap();
+ assert_eq!(catalog[0].agent, Agent::Codex);
+ assert_eq!(catalog[0].path, "codex.toml");
+
+ assert!(parse_catalog(
+ r#"
+schema_version = 1
+
+[[agents]]
+id = "codex"
+path = "codex.toml"
+
+[[agents]]
+id = "codex"
+path = "codex-2.toml"
+"#
+ )
+ .is_err());
+ }
+
+ #[test]
+ fn catalog_rejects_unsafe_paths() {
+ assert!(parse_catalog(
+ r#"
+schema_version = 1
+
+[[agents]]
+id = "codex"
+path = "../codex.toml"
+"#
+ )
+ .is_err());
+ }
+}
diff --git a/src/detect/manifests/amp.toml b/src/detect/manifests/amp.toml
new file mode 100644
index 00000000..97ac99e0
--- /dev/null
+++ b/src/detect/manifests/amp.toml
@@ -0,0 +1,29 @@
+id = "amp"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+aliases = ["amp-local"]
+
+[[rules]]
+id = "approval_footer"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+any = [
+ { contains = ["waiting for approval"] },
+ { contains = ["invoke tool"] },
+ { contains = ["run this command?"] },
+ { contains = ["allow editing file:"] },
+ { contains = ["allow creating file:"] },
+ { contains = ["confirm tool call"] },
+ { contains = ["approve"], any = [{ contains = ["allow all for this session"] }, { contains = ["allow all for every session"] }, { contains = ["allow file for every session"] }, { contains = ["deny with feedback"] }] },
+]
+
+[[rules]]
+id = "esc_cancel_working"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+contains = ["esc to cancel"]
diff --git a/src/detect/manifests/antigravity.toml b/src/detect/manifests/antigravity.toml
new file mode 100644
index 00000000..4e6e70b5
--- /dev/null
+++ b/src/detect/manifests/antigravity.toml
@@ -0,0 +1,33 @@
+id = "agy"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+aliases = ["antigravity", "antigravity-cli"]
+
+[[rules]]
+id = "permission_prompt"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+contains = ["requesting permission for:"]
+any = [
+ { contains = ["do you want to proceed?"] },
+ { contains = ["tab amend", "edit command"] },
+]
+
+[[rules]]
+id = "spinner_working"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+line_regex = ['^\s*[\u2800-\u28FF]+\s+\p{Alphabetic}+\w*ing\b']
+
+[[rules]]
+id = "background_tasks_working"
+state = "working"
+priority = 90
+region = "bottom_non_empty_lines(5)"
+visible_working = true
+line_regex = ['(?i).*[1-9][0-9]*\s*·?\s+tasks?.*/tasks']
diff --git a/src/detect/manifests/claude.toml b/src/detect/manifests/claude.toml
new file mode 100644
index 00000000..84177af6
--- /dev/null
+++ b/src/detect/manifests/claude.toml
@@ -0,0 +1,179 @@
+id = "claude"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+aliases = ["claude-code"]
+
+[[rules]]
+id = "transcript_viewer"
+state = "unknown"
+priority = 1000
+region = "bottom_non_empty_lines(3)"
+skip_state_update = true
+contains = ["showing detailed transcript"]
+any = [
+ { contains = ["ctrl+o", "to toggle"] },
+ { contains = ["ctrl+e", "show all"] },
+ { contains = ["ctrl+e", "collapse"] },
+ { contains = ["↑↓ scroll"] },
+ { contains = ["? for shortcuts"] },
+]
+
+[[rules]]
+id = "live_prompt_box"
+state = "idle"
+priority = 950
+region = "prompt_box_body"
+visible_idle = true
+line_regex = ['^\s*❯']
+not = [
+ { contains = ["enter to select"] },
+ { contains = ["esc to cancel"] },
+ { contains = ["tab/arrow keys"] },
+ { contains = ["arrow keys to navigate"] },
+ { contains = ["↑/↓ to navigate"] },
+]
+
+[[rules]]
+id = "model_picker_menu"
+state = "unknown"
+priority = 900
+region = "whole_recent"
+skip_state_update = true
+contains = ["select model", "enter to set as default", "esc to cancel"]
+not = [
+ { contains = ["do you want to proceed?"] },
+ { contains = ["enter to select"] },
+]
+
+[[rules]]
+id = "live_blocked_form"
+state = "blocked"
+priority = 980
+region = "after_last_horizontal_rule"
+visible_blocker = true
+contains = ["enter to select", "esc to cancel"]
+any = [
+ { contains = ["tab/arrow keys to navigate"] },
+ { contains = ["arrow keys to navigate"] },
+ { contains = ["arrows to navigate"] },
+ { contains = ["↑/↓ to navigate"] },
+ { contains = ["↑↓ to navigate"] },
+]
+
+[[rules]]
+id = "dynamic_workflow_prompt"
+state = "blocked"
+priority = 980
+region = "whole_recent"
+visible_blocker = true
+contains = ["run a dynamic workflow?", "esc to cancel"]
+
+[[rules]]
+id = "generic_permission_prompt"
+state = "blocked"
+priority = 840
+region = "after_last_horizontal_rule"
+visible_blocker = true
+contains = ["do you want to proceed?", "esc to cancel"]
+all = [
+ { any = [
+ { line_regex = ['(?i)^\s*❯?\s*1\.\s*yes\b'] },
+ { line_regex = ['(?i)^\s*2\.\s*yes\b'] },
+ { line_regex = ['(?i)^\s*2\.\s*no\b'] },
+ { line_regex = ['(?i)^\s*3\.\s*no\b'] },
+ ] },
+]
+
+[[rules]]
+id = "bash_permission_prompt"
+state = "blocked"
+priority = 850
+region = "whole_recent"
+visible_blocker = true
+contains = ["do you want to proceed?"]
+any = [
+ { contains = ["bash command"] },
+ { contains = ["bash("] },
+ { contains = ["contains expansion"] },
+ { contains = ["tab to amend"] },
+ { contains = ["ctrl+e to explain"] },
+]
+all = [
+ { any = [{ line_regex = ['(?i)^\s*❯?\s*yes\b'] }, { line_regex = ['(?i)^\s*1\.\s*yes\b'] }, { line_regex = ['(?i)^\s*2\.\s*no\b'] }] },
+]
+
+[[rules]]
+id = "interrupt_chrome_working"
+state = "working"
+priority = 970
+region = "above_prompt_box"
+visible_working = true
+any = [
+ { contains = ["esc to interrupt"] },
+ { contains = ["ctrl+c to interrupt"] },
+]
+
+[[rules]]
+id = "spinner_activity_working"
+state = "working"
+priority = 960
+region = "above_prompt_box"
+visible_working = true
+line_regex = ['^\s*[·✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆]\s+.*\p{Alphabetic}.*…']
+
+[[rules]]
+id = "ascii_spinner_activity_working"
+state = "working"
+priority = 960
+region = "above_prompt_box"
+visible_working = true
+line_regex = ['^\s*\*\s+\p{Alphabetic}.*…\s*\([^)]*\b[1-9][0-9]*s\b[^)]*\)']
+
+[[rules]]
+id = "latest_background_status_working"
+state = "working"
+priority = 960
+region = "last_non_empty_above_prompt_box"
+visible_working = true
+any = [
+ { regex = ['(?i)^\W*waiting for [1-9][0-9]* background agents? to finish$'] },
+ { regex = ['(?i).*\b[1-9][0-9]* shells? still running\b'] },
+ { regex = ['(?i).*\b[1-9][0-9]* local agents? still running\b'] },
+]
+
+[[rules]]
+id = "bottom_agent_status_working"
+state = "working"
+priority = 955
+region = "bottom_non_empty_lines(10)"
+visible_working = true
+line_regex = ['^\s*◯\s+(?:Explore|Agent)\b.*\b[1-9][0-9]*s\b(?:\s*·.*)?$']
+
+[[rules]]
+id = "dynamic_workflow_progress_working"
+state = "working"
+priority = 965
+region = "bottom_non_empty_lines(10)"
+visible_working = true
+line_regex = ['^\s*[◯●]\s+.*\b(?:[0-9]+/[1-9][0-9]*) agents done\b.*\b[1-9][0-9]*s\b']
+
+[[rules]]
+id = "legacy_no_prompt_blocker"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+any = [
+ { contains = ["do you want to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] },
+ { contains = ["would you like to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] },
+ { contains = ["waiting for permission"] },
+ { contains = ["do you want to allow this connection?"] },
+ { contains = ["tab to amend"] },
+ { contains = ["ctrl+e to explain"] },
+ { contains = ["do you want to proceed?", "esc to cancel"] },
+ { contains = ["review your answers"] },
+ { contains = ["skip interview and plan immediately"] },
+]
+not = [
+ { regex = ['(?m)^\s*❯\s*$'] },
+]
diff --git a/src/detect/manifests/cline.toml b/src/detect/manifests/cline.toml
new file mode 100644
index 00000000..b30f4417
--- /dev/null
+++ b/src/detect/manifests/cline.toml
@@ -0,0 +1,26 @@
+id = "cline"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+
+[[rules]]
+id = "tool_permission"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+any = [
+ { contains = ["let cline use this tool"] },
+ { contains = ["[act mode]", "execute command?", "yes"] },
+ { contains = ["[act mode]", "use this tool?", "yes"] },
+ { contains = ["[plan mode]", "execute command?", "yes"] },
+ { contains = ["[plan mode]", "use this tool?", "yes"] },
+]
+
+[[rules]]
+id = "default_cline_working"
+state = "working"
+priority = -10
+region = "whole_recent"
+visible_working = true
+regex = ['(?s).+']
diff --git a/src/detect/manifests/codex.toml b/src/detect/manifests/codex.toml
new file mode 100644
index 00000000..cd09e4b0
--- /dev/null
+++ b/src/detect/manifests/codex.toml
@@ -0,0 +1,105 @@
+id = "codex"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+
+[[rules]]
+id = "transcript_viewer"
+state = "unknown"
+priority = 1000
+region = "after_last_prompt_marker"
+skip_state_update = true
+contains = ["↑/↓ to scroll", "pgup/pgdn to", "home/end to jump", "q to quit"]
+any = [
+ { contains = ["esc to edit prev"] },
+ { contains = ["esc/← to edit prev"] },
+]
+
+[[rules]]
+id = "live_strong_blocker"
+state = "blocked"
+priority = 900
+region = "after_last_prompt_marker"
+visible_blocker = true
+any = [
+ { contains = ["press enter to confirm or esc to cancel"] },
+ { contains = ["enter to submit answer"] },
+ { contains = ["enter to submit all"] },
+ { contains = ["allow command?"] },
+]
+
+[[rules]]
+id = "current_live_working"
+state = "working"
+priority = 710
+region = "current_prompt_block_marker"
+visible_working = true
+any = [
+ { line_regex = ['(?i)^\s*• queued follow-up inputs'] },
+ { line_regex = ['(?i)^\s*• messages to be submitted after next tool call'] },
+ { contains = ["Waiting for background terminal"] },
+ { contains = ["background terminal running"] },
+ { contains = ["/ps to view"] },
+ { contains = ["/stop to close"] },
+ { regex = ['(?i)^\s*•.*\([0-9]+[hms](?: [0-9]+[hms]){0,2}.*esc\s*(…|to interrupt|to .+…)'] },
+]
+
+[[rules]]
+id = "current_working_status"
+state = "working"
+priority = 700
+region = "current_prompt_block_marker"
+any = [
+ { line_regex = ['(?i)^\s*• queued follow-up inputs'] },
+ { line_regex = ['(?i)^\s*• messages to be submitted after next tool call'] },
+ { contains = ["Working ("] },
+ { contains = ["Waiting for background terminal ("] },
+ { contains = ["Booting MCP server:"] },
+ { contains = ["reviewing approval request ("] },
+ { regex = ['(?i)reviewing .* approval requests \('] },
+ { regex = ['(?i)^\s*•.*\([0-9]+[hms](?: [0-9]+[hms]){0,2}\).* • esc(…| to interrupt| to .+…)'] },
+]
+
+[[rules]]
+id = "current_working_status_with_detail"
+state = "working"
+priority = 690
+region = "after_current_prompt_block_marker"
+any = [
+ { contains = ["Queued follow-up inputs"] },
+ { contains = ["Messages to be submitted after next tool call"] },
+]
+
+[[rules]]
+id = "weak_blocker"
+state = "blocked"
+priority = 600
+region = "whole_recent"
+any = [
+ { contains = ["[y/n]"] },
+ { contains = ["yes (y)"] },
+ { contains = ["do you want to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] },
+ { contains = ["would you like to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] },
+]
+
+[[rules]]
+id = "fallback_working"
+state = "working"
+priority = 500
+region = "whole_recent_without_current_prompt_marker"
+visible_working = true
+any = [
+ { line_regex = ['(?i)^\s*•.*\([0-9]+[hms](?: [0-9]+[hms]){0,2}.*esc\s*(…|to interrupt|to .+…)'] },
+ { contains = ["esc to interrupt"] },
+ { all = [{ contains = ["ctrl+c to interrupt"] }, { line_regex = ['(?i)^\s*•'] }] },
+ { all = [{ contains = ["press esc to interrupt"] }, { line_regex = ['(?i)^\s*•'] }] },
+]
+
+[[rules]]
+id = "fallback_status_working"
+state = "working"
+priority = 490
+region = "whole_recent_without_current_prompt_marker"
+any = [
+ { line_regex = ['(?i)^\s*•.*(Working \(|Waiting for background terminal \(|Booting MCP server:|reviewing approval request \(|reviewing .* approval requests \()'] },
+]
diff --git a/src/detect/manifests/cursor.toml b/src/detect/manifests/cursor.toml
new file mode 100644
index 00000000..c97c03d8
--- /dev/null
+++ b/src/detect/manifests/cursor.toml
@@ -0,0 +1,57 @@
+id = "cursor"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+aliases = ["cursor-agent"]
+
+[[rules]]
+id = "write_file_approval"
+state = "blocked"
+priority = 320
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+contains = ["write to this file?", "proceed (y)"]
+any = [
+ { contains = ["reject & propose changes"] },
+ { contains = ["esc or n or p"] },
+ { contains = ["add write("] },
+]
+
+[[rules]]
+id = "approval_prompt"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+any = [
+ { contains = ["waiting for approval", "run this command?"], any = [{ contains = ["run (once) (y)"] }, { contains = ["skip (esc or n)"] }] },
+ { contains = ["(y) (enter)"] },
+ { line_regex = ['(?i)^\s*allow .*\(y\)'] },
+ { contains = ["keep (n)"] },
+ { contains = ["skip (esc or n)"] },
+ { line_regex = ['(?i)^\s*(run |.*\(y\).*(allow|run \(once\)|→ run))'] },
+]
+
+[[rules]]
+id = "stop_hint_working"
+state = "working"
+priority = 100
+region = "bottom_non_empty_lines(6)"
+visible_working = true
+contains = ["ctrl+c to stop"]
+
+[[rules]]
+id = "background_task_status_working"
+state = "working"
+priority = 95
+region = "bottom_non_empty_lines(5)"
+visible_working = true
+line_regex = ['(?i)\b[1-9][0-9]*\s+background\s+tasks?\b']
+
+[[rules]]
+id = "spinner_working"
+state = "working"
+priority = 90
+region = "bottom_non_empty_lines(8)"
+visible_working = true
+line_regex = ['^\s*(⬡|⬢|[\u2800-\u28FF]+)\s+\p{Alphabetic}+\w*ing\b']
diff --git a/src/detect/manifests/droid.toml b/src/detect/manifests/droid.toml
new file mode 100644
index 00000000..c41d71b4
--- /dev/null
+++ b/src/detect/manifests/droid.toml
@@ -0,0 +1,48 @@
+id = "droid"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+
+[[rules]]
+id = "execute_selection_blocker"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+contains = ["enter to select", "esc to cancel"]
+any = [
+ { contains = ["↑↓ to navigate"] },
+ { contains = ["use ↑↓ to navigate"] },
+]
+all = [
+ { any = [{ contains = ["> yes, allow"] }, { contains = ["> no, cancel"] }] },
+]
+
+[[rules]]
+id = "selection_menu_blocker"
+state = "blocked"
+priority = 290
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+contains = ["enter select", "esc cancel"]
+any = [
+ { contains = ["↑/↓ navigate"] },
+ { contains = ["↑↓ navigate"] },
+]
+
+[[rules]]
+id = "spinner_stop_working"
+state = "working"
+priority = 110
+region = "whole_recent"
+visible_working = true
+contains = ["esc to stop"]
+line_regex = ['^\s*[\u2800-\u28FF]']
+
+[[rules]]
+id = "stop_hint_working"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+contains = ["esc to stop"]
diff --git a/src/detect/manifests/gemini.toml b/src/detect/manifests/gemini.toml
new file mode 100644
index 00000000..9d7a28e1
--- /dev/null
+++ b/src/detect/manifests/gemini.toml
@@ -0,0 +1,25 @@
+id = "gemini"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+
+[[rules]]
+id = "apply_or_allow_change"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+any = [
+ { contains = ["│ Apply this change"] },
+ { contains = ["│ Allow execution"] },
+ { all = [{ contains = ["yes"] }, { any = [{ contains = ["waiting for user confirmation"] }, { contains = ["│ Do you want to proceed"] }, { contains = ["do you want to proceed?"] }] }] },
+ { line_regex = ['(?i)^\s*❯.*(yes|allow)'] },
+]
+
+[[rules]]
+id = "esc_cancel_working"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+contains = ["esc to cancel"]
diff --git a/src/detect/manifests/github-copilot.toml b/src/detect/manifests/github-copilot.toml
new file mode 100644
index 00000000..dcd8e2ee
--- /dev/null
+++ b/src/detect/manifests/github-copilot.toml
@@ -0,0 +1,30 @@
+id = "copilot"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+aliases = ["github-copilot", "ghcs"]
+
+[[rules]]
+id = "selection_blocker"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+contains = ["esc to cancel"]
+any = [
+ { contains = ["enter to select"] },
+ { contains = ["enter to confirm"] },
+ { contains = ["enter to submit"] },
+]
+
+[[rules]]
+id = "working_cancel_hint"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+any = [
+ { contains = ["esc to cancel"] },
+ { contains = ["esc cancel"] },
+ { contains = ["esc again to cancel"] },
+]
diff --git a/src/detect/manifests/grok.toml b/src/detect/manifests/grok.toml
new file mode 100644
index 00000000..f4b6537f
--- /dev/null
+++ b/src/detect/manifests/grok.toml
@@ -0,0 +1,28 @@
+id = "grok"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+aliases = ["grok-build"]
+
+[[rules]]
+id = "permission_scope_selector"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+contains = ["yes, proceed", "no, reject"]
+any = [
+ { contains = ["use ← → to choose permission whitelist scope"] },
+ { contains = ["←/→:scope"] },
+]
+
+[[rules]]
+id = "waiting_tool_working"
+state = "working"
+priority = 120
+region = "whole_recent"
+visible_working = true
+any = [
+ { all = [{ contains = ["ctrl+c:cancel", "ctrl+enter:interject"] }, { contains = ["waiting"] }] },
+ { line_regex = ['^\s*[\u2800-\u28FF]\s+(Run|Read|Search|List)\b'] },
+]
diff --git a/src/detect/manifests/hermes.toml b/src/detect/manifests/hermes.toml
new file mode 100644
index 00000000..d45fe32a
--- /dev/null
+++ b/src/detect/manifests/hermes.toml
@@ -0,0 +1,30 @@
+id = "hermes"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+aliases = ["hermes-agent"]
+
+[[rules]]
+id = "dangerous_command_approval"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+any = [
+ { contains = ["dangerous command"] },
+ { contains = ["allow once", "allow for this session", "deny"] },
+]
+all = [
+ { any = [{ contains = ["enter to confirm"] }, { contains = ["↑/↓ to select"] }, { contains = ["show full command"] }] },
+]
+
+[[rules]]
+id = "interrupt_status_working"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+any = [
+ { contains = ["msg=interrupt"] },
+ { contains = ["ctrl+c cancel"] },
+]
diff --git a/src/detect/manifests/kilo.toml b/src/detect/manifests/kilo.toml
new file mode 100644
index 00000000..4ef004e1
--- /dev/null
+++ b/src/detect/manifests/kilo.toml
@@ -0,0 +1,24 @@
+id = "kilo"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+aliases = ["kilo-code", "kilo code", "herdr:kilo"]
+
+[[rules]]
+id = "opencode_permission"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+any = [
+ { contains = ["△ Permission required"] },
+ { contains = ["esc dismiss"], any = [{ contains = ["enter confirm"] }, { contains = ["enter submit"] }, { contains = ["enter toggle"] }], all = [{ any = [{ contains = ["↑↓ select"] }, { contains = ["⇆ tab"] }] }] },
+]
+
+[[rules]]
+id = "esc_interrupt_working"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+contains = ["esc interrupt"]
diff --git a/src/detect/manifests/kimi.toml b/src/detect/manifests/kimi.toml
new file mode 100644
index 00000000..b4d0100f
--- /dev/null
+++ b/src/detect/manifests/kimi.toml
@@ -0,0 +1,77 @@
+id = "kimi"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+aliases = ["kimi-code", "kimi code"]
+
+[[rules]]
+id = "current_approval_panel"
+state = "blocked"
+priority = 400
+region = "whole_recent"
+visible_blocker = true
+contains = ["↵ confirm"]
+any = [
+ { contains = ["run this command?"] },
+ { contains = ["write this file?"] },
+ { contains = ["apply these edits?"] },
+ { contains = ["stop this task?"] },
+ { contains = ["ready to build with this plan?"] },
+ { line_regex = ['(?i)^\s*▶?\s*approve .*\?$'] },
+]
+all = [
+ { contains = [" choose"] },
+ { any = [{ contains = ["approve"] }, { contains = ["reject"] }, { contains = ["revise"] }] },
+]
+
+[[rules]]
+id = "question_panel"
+state = "blocked"
+priority = 390
+region = "whole_recent"
+visible_blocker = true
+contains = ["↑↓ select", "esc cancel"]
+line_regex = ['^\s*question\s*$', '^\s*\? ']
+any = [
+ { contains = ["↵ choose"] },
+ { contains = ["↵ toggle"] },
+ { contains = ["↵ save"] },
+]
+
+[[rules]]
+id = "legacy_approval_panel"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+contains = ["requesting approval", "reject"]
+any = [
+ { contains = ["approve once"] },
+ { contains = ["approve for this session"] },
+]
+all = [
+ { any = [{ contains = ["1/2/3/4 choose"] }, { contains = ["↵ confirm"] }] },
+]
+
+[[rules]]
+id = "background_agent_status_working"
+state = "working"
+priority = 120
+region = "bottom_non_empty_lines(3)"
+visible_working = true
+line_regex = ['(?i)\bkimi[-\w.]*\s+thinking\b.*\[[1-9][0-9]*\s+agents?\s+running\]']
+
+[[rules]]
+id = "moon_spinner_working"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+line_regex = ['^\s*(🌕|🌖|🌗|🌘|🌑|🌒|🌓|🌔)\s*$']
+
+[[rules]]
+id = "braille_spinner_working"
+state = "working"
+priority = 90
+region = "whole_recent"
+visible_working = true
+line_regex = ['(?i)^\s*[\u2800-\u28FF]+\s*(thinking\.\.\.|working\.\.\.|using )']
diff --git a/src/detect/manifests/kiro.toml b/src/detect/manifests/kiro.toml
new file mode 100644
index 00000000..9b0fb3d0
--- /dev/null
+++ b/src/detect/manifests/kiro.toml
@@ -0,0 +1,51 @@
+id = "kiro"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+aliases = ["kiro-cli"]
+
+[[rules]]
+id = "tool_approval"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+contains = ["requires approval"]
+any = [
+ { contains = ["yes, single permission"] },
+ { contains = ["trust, always allow"] },
+ { contains = ["no (tab to edit)"] },
+ { contains = ["esc to close"] },
+]
+
+[[rules]]
+id = "subagent_approval"
+state = "blocked"
+priority = 290
+region = "whole_recent"
+visible_blocker = true
+contains = ["pending from subagents"]
+any = [
+ { contains = ["tool approval"] },
+ { contains = ["tool approvals"] },
+]
+all = [
+ { any = [{ contains = ["approve all pending"] }, { contains = ["configure individually"] }, { contains = ["exit (cancel subagents)"] }] },
+]
+
+[[rules]]
+id = "kiro_working_marker"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+contains = ["kiro is working"]
+
+[[rules]]
+id = "tool_spinner_working"
+state = "working"
+priority = 90
+region = "whole_recent"
+visible_working = true
+contains = ["esc to cancel"]
+line_regex = ['^\s*(◔|◑|◕|●)\s+\p{Alphabetic}']
diff --git a/src/detect/manifests/opencode.toml b/src/detect/manifests/opencode.toml
new file mode 100644
index 00000000..52452383
--- /dev/null
+++ b/src/detect/manifests/opencode.toml
@@ -0,0 +1,37 @@
+id = "opencode"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+aliases = ["open-code", "herdr:opencode"]
+
+[[rules]]
+id = "permission_required"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+any = [
+ { contains = ["△ Permission required"] },
+ { contains = ["esc dismiss"], any = [{ contains = ["enter confirm"] }, { contains = ["enter submit"] }, { contains = ["enter toggle"] }], all = [{ any = [{ contains = ["↑↓ select"] }, { contains = ["⇆ tab"] }] }] },
+]
+
+[[rules]]
+id = "interrupt_hint_working"
+state = "working"
+priority = 110
+region = "whole_recent"
+visible_working = true
+any = [
+ { contains = ["esc to interrupt"] },
+ { contains = ["ctrl+c to interrupt"] },
+ { contains = ["press esc to interrupt"] },
+ { line_regex = ['(?i).*opencode.*esc (again to )?interrupt'] },
+]
+
+[[rules]]
+id = "progress_bar_working"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+regex = ['(■|⬝){4,}']
diff --git a/src/detect/manifests/pi.toml b/src/detect/manifests/pi.toml
new file mode 100644
index 00000000..a58d30e9
--- /dev/null
+++ b/src/detect/manifests/pi.toml
@@ -0,0 +1,13 @@
+id = "pi"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+aliases = ["herdr:pi"]
+
+[[rules]]
+id = "working_literal"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+contains = ["Working..."]
diff --git a/src/detect/manifests/qodercli.toml b/src/detect/manifests/qodercli.toml
new file mode 100644
index 00000000..51ca805e
--- /dev/null
+++ b/src/detect/manifests/qodercli.toml
@@ -0,0 +1,38 @@
+id = "qodercli"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+aliases = ["qoderclicn", "qoder", "qodercn"]
+
+[[rules]]
+id = "confirmation_or_input_blocker"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+any = [
+ { contains = ["waiting for user confirmation"], any = [{ contains = ["yes"] }, { contains = ["no"] }, { contains = ["allow"] }, { contains = ["reject"] }] },
+ { contains = ["awaiting approval"], any = [{ contains = ["allow"] }, { contains = ["reject"] }] },
+ { contains = ["permission required"] },
+ { contains = ["allow once or always?"] },
+ { contains = ["asking user"] },
+ { contains = ["enter your response"] },
+ { contains = ["review your answers:"] },
+ { contains = ["shell awaiting input"] },
+]
+
+[[rules]]
+id = "cancel_hint_working"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+contains = ["(esc to cancel,"]
+
+[[rules]]
+id = "spinner_working"
+state = "working"
+priority = 90
+region = "whole_recent"
+visible_working = true
+line_regex = ['^\s*[\u2800-\u28FF]\s+.*\p{Alphabetic}']
diff --git a/src/detect/mod.rs b/src/detect/mod.rs
index f2def663..62dc3d05 100644
--- a/src/detect/mod.rs
+++ b/src/detect/mod.rs
@@ -3,7 +3,8 @@
//! Each pane's live bottom-of-buffer text is read periodically and matched
//! against known agent output patterns to determine state.
-mod agents;
+pub mod manifest;
+pub mod manifest_update;
/// The detected state of a terminal pane.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -25,6 +26,8 @@ pub struct AgentDetection {
/// True when the current screen is an agent-owned viewer that shows
/// transcript/history instead of the live prompt state.
pub skip_state_update: bool,
+ /// True when the current screen visibly shows live idle chrome.
+ pub visible_idle: bool,
/// True when the current screen visibly shows live UI chrome that needs
/// human input. This is stronger than arbitrary prompt-like text in the
/// scrollback and may override a non-blocked integration state.
@@ -35,12 +38,6 @@ pub struct AgentDetection {
pub visible_working: bool,
}
-/// A narrow screen-derived exception that can veto PTY activity as semantic work.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum AgentActivityVeto {
- ClaudeIdleRecap,
-}
-
/// Which agent we detected running in a pane.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Agent {
@@ -63,6 +60,28 @@ pub enum Agent {
Qodercli,
}
+impl Agent {
+ pub const ALL: [Self; 17] = [
+ Self::Pi,
+ Self::Claude,
+ Self::Codex,
+ Self::Gemini,
+ Self::Cursor,
+ Self::Antigravity,
+ Self::Cline,
+ Self::OpenCode,
+ Self::GithubCopilot,
+ Self::Kimi,
+ Self::Kiro,
+ Self::Droid,
+ Self::Amp,
+ Self::Grok,
+ Self::Hermes,
+ Self::Kilo,
+ Self::Qodercli,
+ ];
+}
+
pub fn agent_label(agent: Agent) -> &'static str {
match agent {
Agent::Pi => "pi",
@@ -179,162 +198,27 @@ pub fn detect_agent(agent: Option<Agent>, screen_content: &str) -> AgentDetectio
return AgentDetection {
state: AgentState::Unknown,
skip_state_update: false,
+ visible_idle: false,
visible_blocker: false,
visible_working: false,
};
};
- agents::detect(agent, screen_content)
-}
-
-pub(crate) fn agent_activity_veto(
- agent: Option<Agent>,
- screen_content: &str,
-) -> Option<AgentActivityVeto> {
- match agent {
- Some(Agent::Claude) if agents::claude_code::has_idle_recap_notice(screen_content) => {
- Some(AgentActivityVeto::ClaudeIdleRecap)
- }
- _ => None,
- }
+ manifest::detect(agent, screen_content)
}
pub fn should_skip_state_update(agent: Option<Agent>, screen_content: &str) -> bool {
- agent.is_some_and(|agent| agents::should_skip_state_update(agent, screen_content))
-}
-
-// ---------------------------------------------------------------------------
-// Per-agent detectors
-// ---------------------------------------------------------------------------
-
-#[cfg(test)]
-fn detect_pi(content: &str) -> AgentState {
- detect_state(Some(Agent::Pi), content)
-}
-
-#[cfg(test)]
-fn detect_claude(content: &str) -> AgentState {
- detect_state(Some(Agent::Claude), content)
-}
-
-#[cfg(test)]
-fn detect_codex(content: &str) -> AgentState {
- detect_state(Some(Agent::Codex), content)
-}
-
-#[cfg(test)]
-fn detect_gemini(content: &str) -> AgentState {
- detect_state(Some(Agent::Gemini), content)
-}
-
-#[cfg(test)]
-fn detect_cursor(content: &str) -> AgentState {
- detect_state(Some(Agent::Cursor), content)
-}
-
-#[cfg(test)]
-fn detect_antigravity(content: &str) -> AgentState {
- detect_state(Some(Agent::Antigravity), content)
-}
-
-#[cfg(test)]
-fn detect_cline(content: &str) -> AgentState {
- detect_state(Some(Agent::Cline), content)
-}
-
-#[cfg(test)]
-fn detect_opencode(content: &str) -> AgentState {
- detect_state(Some(Agent::OpenCode), content)
-}
-
-#[cfg(test)]
-fn detect_github_copilot(content: &str) -> AgentState {
- detect_state(Some(Agent::GithubCopilot), content)
-}
-
-#[cfg(test)]
-fn detect_kimi(content: &str) -> AgentState {
- detect_state(Some(Agent::Kimi), content)
-}
-
-#[cfg(test)]
-fn detect_droid(content: &str) -> AgentState {
- detect_state(Some(Agent::Droid), content)
-}
-
-/// Check for braille spinner characters at the start of a line.
-/// These are the Unicode braille pattern dots used by CLI spinners.
-fn has_braille_spinner(content: &str) -> bool {
- for line in content.lines() {
- let trimmed = line.trim();
- if let Some(c) = trimmed.chars().next() {
- if ('\u{2800}'..='\u{28FF}').contains(&c) {
- return true;
- }
- }
- }
- false
-}
-
-#[cfg(test)]
-fn detect_qodercli(content: &str) -> AgentState {
- detect_state(Some(Agent::Qodercli), content)
-}
-
-#[cfg(test)]
-fn detect_kilo(content: &str) -> AgentState {
- detect_state(Some(Agent::Kilo), content)
-}
-
-// ---------------------------------------------------------------------------
-// Shared helpers
-// ---------------------------------------------------------------------------
-
-/// Check for action confirmations followed by "yes" or "❯".
-fn has_confirmation_prompt(lower_content: &str) -> bool {
- if let Some(pos) = lower_content
- .find("do you want to")
- .or_else(|| lower_content.find("would you like to"))
- {
- let after = &lower_content[pos..];
- return after.contains("yes") || after.contains('❯');
- }
- false
-}
-
-/// Check for "❯" followed by numbered options like "1."
-fn has_selection_prompt(content: &str) -> bool {
- for line in content.lines() {
- let trimmed = line.trim();
- if trimmed.starts_with('❯') {
- // Check if there's a digit followed by a dot nearby
- if trimmed.chars().any(|c| c.is_ascii_digit()) && trimmed.contains('.') {
- return true;
- }
- }
- }
- false
-}
-
-/// Check for real interrupt hints, not unrelated "esc" and "interrupt" text.
-fn has_interrupt_pattern(lower_content: &str) -> bool {
- lower_content.contains("esc to interrupt")
- || lower_content.contains("ctrl+c to interrupt")
- || lower_content.contains("press esc to interrupt")
-}
-
-#[cfg(test)]
-fn has_cursor_spinner(content: &str) -> bool {
- agents::cursor::has_cursor_spinner(content)
-}
-
-#[cfg(test)]
-fn content_above_prompt_box(content: &str) -> &str {
- agents::claude_code::content_above_prompt_box(content)
+ agent.is_some_and(|agent| manifest::should_skip_state_update(agent, screen_content))
}
-#[cfg(test)]
-fn has_spinner_activity(content: &str) -> bool {
- agents::claude_code::has_spinner_activity(content)
+pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> bool {
+ matches!(
+ (source, agent_label),
+ ("herdr:pi", "pi")
+ | ("herdr:omp", "omp")
+ | ("herdr:hermes", "hermes")
+ | ("herdr:opencode", "opencode")
+ | ("herdr:kilo", "kilo")
+ )
}
// ---------------------------------------------------------------------------
@@ -662,57 +546,10 @@ mod tests {
#[test]
fn moved_agent_detection_routes_through_production_dispatch() {
- let cases = [
- (Agent::Pi, "Working...", AgentState::Working),
- (
- Agent::Claude,
- "✽ Writing…\nesc to interrupt\n──────\n❯ \n──────",
- AgentState::Working,
- ),
- (
- Agent::Codex,
- "generating code\nesc to interrupt",
- AgentState::Working,
- ),
- (Agent::Gemini, "Esc to cancel", AgentState::Working),
- (Agent::Cursor, "Ctrl+C to stop", AgentState::Working),
- (
- Agent::Antigravity,
- "⠋ Thinking through the request",
- AgentState::Working,
- ),
- (Agent::Cline, "Reading project files", AgentState::Working),
- (
- Agent::OpenCode,
- "running tool\nesc to interrupt",
- AgentState::Working,
- ),
- (Agent::GithubCopilot, "Esc cancel", AgentState::Working),
- (Agent::Kimi, "🌕", AgentState::Working),
- (Agent::Kiro, "Kiro is working", AgentState::Working),
- (Agent::Droid, "Press ESC to stop", AgentState::Working),
- (Agent::Amp, "Esc to cancel", AgentState::Working),
- (
- Agent::Grok,
- "⠋ Waiting… 1.8s\nCtrl+c:cancel Ctrl+Enter:interject",
- AgentState::Working,
- ),
- (Agent::Hermes, "msg=interrupt", AgentState::Working),
- (
- Agent::Kilo,
- "Ask · DeepSeek V4 Pro\nesc interrupt",
- AgentState::Working,
- ),
- (Agent::Qodercli, "\u{280B} Thinking...", AgentState::Working),
- ];
-
- for (agent, screen, expected) in cases {
- assert_eq!(
- detect_agent(Some(agent), screen).state,
- expected,
- "production dispatch for {agent:?}"
- );
- }
+ let detection = detect_agent(Some(Agent::Pi), "Working...");
+
+ assert_eq!(detection.state, AgentState::Working);
+ assert!(detection.visible_working);
}
// ---- Agent identification ----
@@ -1079,1852 +916,13 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
- // ---- Workspace state rollup ----
-
- // ---- No agent → Unknown ----
+ // ---- Screen detection routing ----
#[test]
fn no_agent_returns_unknown() {
assert_eq!(detect_state(None, "anything"), AgentState::Unknown);
}
- // ---- Pi ----
-
- #[test]
- fn pi_working_when_working() {
- assert_eq!(detect_pi("some output\nWorking..."), AgentState::Working);
- }
-
- #[test]
- fn pi_working_working_in_middle() {
- assert_eq!(detect_pi("line1\nWorking...\nline3"), AgentState::Working);
- }
-
- #[test]
- fn pi_idle_at_prompt() {
- assert_eq!(detect_pi("❯ "), AgentState::Idle);
- }
-
- #[test]
- fn pi_idle_no_working_text() {
- assert_eq!(detect_pi("some output\n\n> ready"), AgentState::Idle);
- }
-
- // ---- Claude Code ----
-
- #[test]
- fn claude_working_esc_to_interrupt() {
- let screen = "Reading file src/main.rs\nesc to interrupt\n─────────\n❯ \n─────────";
- assert_eq!(detect_claude(screen), AgentState::Working);
- }
-
- #[test]
- fn claude_working_ctrl_c_to_interrupt() {
- let screen = "Editing code\nctrl+c to interrupt\n─────────\n❯ \n─────────";
- assert_eq!(detect_claude(screen), AgentState::Working);
- }
-
- #[test]
- fn claude_working_spinner() {
- let screen = "✽ Tempering…\n─────────\n❯ \n─────────";
- assert_eq!(detect_claude(screen), AgentState::Working);
- }
-
- #[test]
- fn claude_working_middle_dot_spinner() {
- let screen = "· Thinking…\n─────────\n❯ \n─────────";
- assert_eq!(detect_claude(screen), AgentState::Working);
- }
-
- #[test]
- fn claude_working_spinner_with_detail() {
- let screen = "✳ Simplifying recompute_tangents…\n─────────\n❯ \n─────────";
- assert_eq!(detect_claude(screen), AgentState::Working);
- }
-
- #[test]
- fn claude_detailed_transcript_skips_state_update() {
- let screen = "● I read the root and README.md.\n\n✻ Cogitated for 14s\n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Showing detailed transcript · ctrl+o to toggle · ctrl+e to show all verbose";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert!(detection.skip_state_update);
- assert!(!detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn claude_detailed_transcript_collapse_skips_state_update() {
- let screen = "● Running tool\n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Showing detailed transcript · ctrl+o to toggle · ctrl+e to collapse";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert!(detection.skip_state_update);
- }
-
- #[test]
- fn claude_wrapped_detailed_transcript_controls_skip_state_update() {
- let screen = "● Running tool\n\n────────────────────────\n Showing detailed transcript · ctrl+o\n to toggle · ctrl+e to\n collapse";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert!(detection.skip_state_update);
- }
-
- #[test]
- fn claude_transcript_text_above_prompt_does_not_skip_state_update() {
- let screen = "Docs mention: Showing detailed transcript · ctrl+o to toggle · ctrl+e to show all\n\n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n❯ \n───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert!(!detection.skip_state_update);
- }
-
- #[test]
- fn claude_waiting_do_you_want() {
- let screen = "Do you want to run this command?\n\nYes No";
- assert_eq!(detect_claude(screen), AgentState::Blocked);
- }
-
- #[test]
- fn claude_waiting_would_you_like() {
- let screen = "Would you like to apply these changes?\n\n❯ Yes";
- assert_eq!(detect_claude(screen), AgentState::Blocked);
- }
-
- #[test]
- fn claude_waiting_selection_prompt() {
- let screen = "Do you want to proceed?\n❯ 1. Yes\n 2. No\n\nEsc to cancel · Tab to amend";
- assert_eq!(detect_claude(screen), AgentState::Blocked);
- }
-
- #[test]
- fn claude_waiting_esc_to_cancel() {
- let screen = "Allow bash: rm -rf /tmp/test?\n\nDo you want to proceed?\n\nesc to cancel";
- assert_eq!(detect_claude(screen), AgentState::Blocked);
- }
-
- #[test]
- fn claude_bash_permission_modal_is_visible_blocker() {
- let screen = "● Bash(mkdir -p /tmp/herdr-claude-detector-test && for i in 1 2 3; do dd if=/dev/urandom)\n ⎿ Waiting…\n\n────────────────────────\n Bash command\n\n mkdir -p /tmp/herdr-claude-detector-test && ls -la /tmp/herdr-claude-detector-test\n Create random files in temporary detector directory\n\n Contains expansion\n\n Do you want to proceed?\n ❯ 1. Yes\n 2. No\n\n Esc to cancel · Tab to amend · ctrl+e to explain";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn claude_cropped_bash_permission_modal_is_visible_blocker() {
- let screen = "● Bash(mkdir -p /tmp/herdr-claude-detector-test && ls -la /tmp/herdr-claude-detector-test)\n ⎿ Waiting…\n\nDo you want to proceed?\n❯ 1. Yes\n 2. No";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn claude_waiting_ask_user_question_menu() {
- let screen =
- "Which approach should I take?\n❯ 1. Minimal change\n 2. Bigger refactor\n3. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel";
- assert_eq!(detect_claude(screen), AgentState::Blocked);
- }
-
- #[test]
- fn claude_dynamic_workflow_prompt_is_visible_blocker() {
- let screen = "● Workflow(Review the current worktree diff and verify the resulting findings)\n\n─────────────────────────────────────────────────────────────────────────────────────────\n Run a dynamic workflow?\n\n Review the current worktree diff and verify the resulting findings\n\n This dynamic workflow will spin up multiple subagents across the following phases:\n 1. Overview — map the diff and identify coupled subsystems\n 2. Review — review each changed file in parallel\n 3. Verify — adversarially verify each finding\n 4. Synthesize — summarize confirmed issues and residual risk\n\n Dynamic workflows can use a lot of tokens quickly by running many subagents in parallel — which counts against your usage limit. Stop a running workflow at any time with\n /workflows, or disable dynamic workflows in /config.\n\n ❯ 1. Yes, run it\n 2. View raw script\n 3. No\n\n Esc to cancel · Tab to amend\n ctrl+g to edit script in $EDITOR";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- assert!(!detection.visible_working);
- }
-
- #[test]
- fn claude_dynamic_workflow_prompt_uses_stable_modal_gate() {
- let screen = "● Workflow(Evaluate changes)\n\n─────────────────────────────────────────────────────────────────────────────────────────\n Run a dynamic workflow?\n\n 1. Scope\n 2. Review\n 3. Synthesize\n\n Esc to cancel · Tab to amend";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn claude_question_form_selected_top_is_visible_blocker() {
- let screen = "❯ ask again\n─────────────────────────────────────────────────────────────────────────────────────────\n← ☐ Subject ☐ Tone ✔ Submit →\n\nWhat should I ask you about?\n\n❯ 1. Today\n Your current plan or priority.\n 2. Project\n A codebase, feature, bug, or PR.\n 3. Preference\n How you want me to work with you.\n 4. Random\n A casual question with no work context.\n 5. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────\n 6. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn claude_question_form_with_arrow_glyph_footer_is_visible_blocker() {
- let screen = "❯ this is a test can you use question otol to ask questiosns\n\n Thought for 16s (ctrl+o to expand)\n─────────────────────────────────────────────────────────────────────────────────────────\n ☐ Test type\n\nWhich kind of test question should I ask you next?\n\n❯ 1. Single choice\n Ask one multiple-choice question.\n 2. Multi choice\n Ask one question that allows several answers.\n 3. With preview\n Ask with side-by-side previews.\n 4. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────\n 5. Chat about this\n\nEnter to select · ↑/↓ to navigate · Esc to cancel";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn claude_question_form_selected_bottom_is_visible_blocker() {
- let screen = "❯ ask again\n─────────────────────────────────────────────────────────────────────────────────────────\n← ☐ Subject ☐ Tone ✔ Submit →\n\nWhat should I ask you about?\n\n 1. Today\n Your current plan or priority.\n 2. Project\n A codebase, feature, bug, or PR.\n 3. Preference\n How you want me to work with you.\n 4. Random\n A casual question with no work context.\n 5. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ 6. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · ctrl+g to edit in Zed · Esc to cancel";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn claude_idle_hooks_menu() {
- let screen = "Hooks\n0 hooks configured\nℹ This menu is read-only. To add or modify hooks, edit settings.json directly or ask Claude. Learn more\n\n❯ 1. PreToolUse\n 2. PostToolUse\n 3. PostToolUseFailure\n\nEnter to confirm · Esc to cancel";
- assert_eq!(detect_claude(screen), AgentState::Idle);
- }
-
- #[test]
- fn claude_idle_theme_menu() {
- let screen = "Theme\nChoose the text style that looks best with your terminal\n\n❯ 1. Dark mode ✔\n 2. Light mode\n 3. Dark mode (colorblind-friendly)\n\nSyntax theme: Monokai Extended (ctrl+t to disable)\n\nEnter to select · Esc to cancel";
- assert_eq!(detect_claude(screen), AgentState::Idle);
- }
-
- #[test]
- fn claude_idle_prompt_box() {
- let screen = "Task complete.\n─────────────\n❯ \n─────────────";
- assert_eq!(detect_claude(screen), AgentState::Idle);
- }
-
- #[test]
- fn claude_idle_recap_notice_is_activity_veto() {
- let screen = "✻ Churned for 9s\n\n※ recap: We’re debugging issue 494: kitty graphics render in shell panes but not inside Neovim. Next, I’m tracing the snacks.nvim inline-image path against Herdr’s placeholder\n handling to confirm the exact failure point. (disable recaps in /config)\n\n────────────────────────────────────────────────────────────────────────────────\n❯ \n────────────────────────────────────────────────────────────────────────────────";
-
- assert_eq!(
- agent_activity_veto(Some(Agent::Claude), screen),
- Some(AgentActivityVeto::ClaudeIdleRecap)
- );
- }
-
- #[test]
- fn wrapped_claude_idle_recap_notice_is_activity_veto() {
- let screen = "※ recap: We’re debugging issue 494: kitty graphics render in shell panes but\n not inside Neovim. Next, I’m tracing the snacks.nvim inline-image path\n against Herdr’s placeholder handling to confirm the exact failure point…\n and then I will inspect the redraw cache. (disable recaps in /config)\n\n────────────────────────────────────────────────────────────────────────────────\n❯ \n────────────────────────────────────────────────────────────────────────────────";
-
- assert_eq!(
- agent_activity_veto(Some(Agent::Claude), screen),
- Some(AgentActivityVeto::ClaudeIdleRecap)
- );
- }
-
- #[test]
- fn stale_claude_recap_above_new_work_does_not_veto_activity() {
- let screen = "※ recap: Old recap. (disable recaps in /config)\n\n● Reading src/pane.rs\n\n✻ Crunching…\n\n────────────────────────────────────────────────────────────────────────────────\n❯ \n────────────────────────────────────────────────────────────────────────────────";
-
- assert_eq!(agent_activity_veto(Some(Agent::Claude), screen), None);
- }
-
- #[test]
- fn claude_prompt_box_does_not_set_screen_idle_signal() {
- let screen = "Interrupted.\n─────────────\n❯ \n─────────────";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- }
-
- #[test]
- fn claude_prompt_box_with_status_text_and_custom_status_does_not_set_screen_idle_signal() {
- let screen = "──────────────────────────────────────────── ◐ medium · /effort\n❯ \n────────────────────────────────────────────\n thommie-backend | refactor/cleanup-codebase | Opus 4.8 (1M context)\nIppy Tippy\n/coach-dive to chat about this\n▸▸ auto mode on (shift+tab to cycle) · ← for agents";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn claude_interrupted_permission_prompt_box_does_not_set_screen_idle_signal() {
- let screen = "❯ this is a test, create some dummy files on /tmp and -rm rf them i wanna test\n permissions\n\n Thought for 7s (ctrl+o to expand)\n\n● Bash(tmpdir=$(mktemp -d /tmp/claude-perm-test.XXXXXX) && touch \"$tmpdir/file1.txt\"\n \"$tmpdir/file2.log\" && mkdir \"$tmpdir/subdir\" && touch\n \"$tmpdir/subdir/nested.txt\"…)\n ⎿ Interrupted · What should Claude do instead?\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn claude_declined_questions_in_scrollback_with_prompt_box_are_idle() {
- let screen = "● User declined to answer questions\n ⎿ · What do you want help with? (Code task / PR review / Research / Claude setup)\n · How detailed should I be? (Short / Medium / Detailed)\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~ ⊘ no git ▱▱▱▱▱ 0%";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn claude_old_permission_prompt_with_live_prompt_box_is_idle() {
- let screen = "● Bash(rm -rf /tmp/test)\n ⎿ Waiting…\n\nDo you want to proceed?\n❯ 1. Yes\n 2. No\n\nEsc to cancel · Tab to amend · ctrl+e to explain\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn claude_old_permission_prompt_above_wrapped_live_prompt_is_not_visible_blocker() {
- let screen = "● Bash(rm -rf /tmp/test)\n ⎿ Waiting…\n\nDo you want to proceed?\n❯ 1. Yes\n 2. No\n\nEsc to cancel · Tab to amend · ctrl+e to explain\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ explain why this stale approval prompt should not be considered live anymore because\n my current prompt wrapped onto a second line\n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert!(!detection.visible_blocker);
- assert_eq!(
- crate::agent_detection_policy::apply_detection_policy(
- crate::agent_detection_policy::DetectionPolicyInput {
- agent: Some(Agent::Claude),
- screen_detection: detection,
- process_exited: false,
- startup_grace_active: false,
- pty_signal: Some(crate::agent_detection_policy::PtySignal {
- active: false,
- tainted: false,
- }),
- },
- ),
- crate::agent_detection_policy::DetectionPolicyDecision::Publish(AgentDetection {
- state: AgentState::Idle,
- skip_state_update: false,
- visible_blocker: false,
- visible_working: false,
- })
- );
- }
-
- #[test]
- fn claude_spinner_after_interrupted_permission_is_visible_working() {
- let screen = "❯ this is a test, create some dummy files on /tmp and -rm rf them i wanna test\n permissions\n\n Thought for 7s (ctrl+o to expand)\n\n● Bash(tmpdir=$(mktemp -d /tmp/claude-perm-test.XXXXXX) && touch \"$tmpdir/file1.txt\"\n \"$tmpdir/file2.log\" && mkdir \"$tmpdir/subdir\" && touch\n \"$tmpdir/subdir/nested.txt\"…)\n ⎿ Interrupted · What should Claude do instead?\n\n❯ test\n\n✢ Garnishing… (1s · thinking with high effort)\n ⎿ Tip: Run claude --continue or claude --resume to resume a conversation\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn claude_separators_without_prompt_do_not_set_screen_idle_signal() {
- let screen = "Task complete.\n─────────────\nplain text\n─────────────";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- }
-
- #[test]
- fn claude_spinner_above_prompt_box_is_working() {
- let screen = "✢ Imagining… (3s · thinking with high effort)\n ⎿ Tip: Run /terminal-setup\n\n─────────────\n❯ \n─────────────\n~/project";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn claude_waiting_for_background_agent_is_working() {
- let screen = "● Done. I’ve delegated a read-only repo investigation to a subagent, and it will come back with a detailed report.\n\n✻ Waiting for 1 background agent to finish\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/llm-proxy ⎇ master ▱▱▱▱▱ 0%\n\n ● main ↑/↓ to select · Enter to view\n ◯ Explore Investigate repo and report 33s · ↓ 225 tokens";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn claude_waiting_for_multiple_background_agents_is_working() {
- let screen = "● Done. I’ve delegated two investigations.\n\n✻ Waiting for 2 background agents to finish\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn claude_completed_background_agent_wait_in_scrollback_is_idle() {
- let screen = "❯ please create a background agent that sleeps 15 sec then say hi\n\n Thought for 8s (ctrl+o to expand)\n\n● claude(Sleep then say hi)\n ⎿ Backgrounded agent (↓ to manage · ctrl+o to expand)\n\n● Done. I launched a background agent that will wait 15 seconds and then reply with hi.\n\n✻ Waiting for 1 background agent to finish\n\n● Agent \"Sleep then say hi\" completed · 17s\n\n● hi\n\n✻ Brewed for 28s\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn claude_completed_background_agent_with_prompt_box_is_idle() {
- let screen = "● Done. I’ve started a background agent.\n\n✻ Waiting for 1 background agent to finish\n\n⏺ Agent \"Sleep then say hi\" completed · 31s\n\n⏺ hi\n\n✻ Baked for 42s\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%\n\n ● main ↑/↓ to select · Enter to view\n ◯ general-purpose Sleep then say hi 31s";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn claude_zero_background_agent_wait_with_prompt_box_is_idle() {
- let screen = "✻ Waiting for 0 background agents to finish\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_working);
- }
-
- #[test]
- fn claude_background_agent_wait_phrase_in_prose_is_idle() {
- let screen = "Claude mentioned: Waiting for 1 background agent to finish\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%";
- let detection = detect_agent(Some(Agent::Claude), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_working);
- }
-
- #[test]
- fn claude_working_not_confused_by_old_prompt() {
- // The "esc to interrupt" is ABOVE the prompt box — should be working
- let screen = "✽ Writing…\nesc to interrupt\n──────\n❯ \n──────";
- assert_eq!(detect_claude(screen), AgentState::Working);
- }
-
- // ---- Codex ----
-
- #[test]
- fn codex_waiting_confirm() {
- assert_eq!(
- detect_codex("press enter to confirm or esc to cancel"),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn codex_waiting_allow_command() {
- assert_eq!(detect_codex("allow command?\n[y/n]"), AgentState::Blocked);
- }
-
- #[test]
- fn codex_waiting_submit_answer() {
- assert_eq!(
- detect_codex("Question about approach\n| enter to submit answer"),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn codex_question_ui_is_visible_blocker() {
- let screen = "Question 1/1 (1 unanswered)\nWhat kind of code improvement do you want?\n› 1. Reduce complexity\n 2. Improve reliability\n\ntab to add notes | enter to submit answer | esc to interrupt";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn codex_bare_yes_no_hint_is_not_visible_blocker() {
- let detection = detect_agent(Some(Agent::Codex), "The docs mention [y/n] prompts.");
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn codex_replayed_transcript_weak_blocked_text_above_prompt_is_not_visible_blocker() {
- let screen = "Codex\nBlocked signals in src/detect/agents/codex.rs:6: confirm footer, submit answer/all, allow command, [y/n], yes (y), or generic confirmation.\n\nLikely false positives: [y/n] and generic confirmation prose still mark Blocked.\n\n• Agent thread 019e7670-ba31-7641-b6e0-545c101de8c3 is closed. Replaying saved transcript.\n\n\n› Summarize recent commits\n\n ~/Projects/herdr · master · gpt-5.5 default · Context 37% used";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn codex_replayed_allow_command_text_above_prompt_is_idle() {
- let screen = "• Ran grep for blockers\n └ The phrase allow command? appears in our discussion above.\n\n■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.\n\n\n› Write tests for @filename\n\n gpt-5.5 high · ~/.herdr/worktrees/herdr/plugin-v1 · plugin-v1 · Context 18% used · 5h 93% left · weekly 71% left";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn codex_generic_confirmation_prompt_is_not_visible_blocker() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "Earlier output asked: do you want to continue? The answer was yes.",
- );
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn gemini_confirmation_prompt_is_visible_blocker() {
- let detection = detect_agent(Some(Agent::Gemini), "Do you want to proceed?\n\nYes No");
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn codex_waiting_submit_answer_wrapped_footer() {
- assert_eq!(
- detect_codex(
- "Question 1/2\nChoose an option.\nenter to submit answer\nesc to interrupt"
- ),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn codex_waiting_submit_all_multi_question_footer() {
- let screen = "Question 2/2 (1 unanswered)\nAt a high level, what is issue 249 supposed to fix?\n› 1. State arbitration (Recommended)\n 2. UI behavior\n 3. Test reliability\n 4. None of the above\n\ntab to add notes | enter to submit all | ←/→ to navigate questions | esc to interrupt";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn codex_interrupted_prompt_does_not_set_screen_idle_signal() {
- let screen = "■ Conversation interrupted - tell the model what to do differently. Something went\nwrong? Hit `/feedback` to report the issue.\n\n\n› Run /review on my current changes\n\n gpt-5.5 high · ~/Projects/herdr-worktrees/issue-249-state-arbitration";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- }
-
- #[test]
- fn codex_working_interrupt() {
- assert_eq!(
- detect_codex("generating code\nesc to interrupt"),
- AgentState::Working
- );
- }
-
- #[test]
- fn codex_working_truncated_status_header() {
- assert_eq!(detect_codex("• Working (0s • esc…"), AgentState::Working);
- }
-
- #[test]
- fn codex_status_line_is_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Ran git status --short\n └ M src/detect.rs\n\n• Working (17s • esc to interrupt)\n\n\n› Implement {feature}",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn codex_live_working_status_above_current_prompt_stays_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Ran git diff --name-status v0.6.3..HEAD\n └ A website/src/pages/releases/index.astro\n\n• Working (28s • esc to interrupt)\n\n\n› Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 7% used · 5h 96% left",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn codex_generic_interrupt_status_above_current_prompt_stays_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• I need to inspect more code first.\n\n• Investigating code output (44s • esc to interrupt)\n\n\n› Summarize recent commits\n\n gpt-5.5 high · ~/Codes/herdr",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn codex_truncated_status_with_arbitrary_label_is_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Ran git diff -- src/detect/mod.rs\n └ diff output...\n\n• Considering patch updates (2m 26s • esc …\n\n\n› Fix Codex detection\n\n ~/Projects/herdr · master",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn codex_truncated_status_without_prompt_is_working() {
- assert_eq!(
- detect_codex("• Considering patch updates (2m 26s • esc …"),
- AgentState::Working
- );
- }
-
- #[test]
- fn codex_bullet_with_esc_text_without_timer_is_not_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Notes mention esc to interrupt as a phrase\n\n› Fix Codex detection\n\n ~/Projects/herdr · master",
- );
-
- assert!(!detection.visible_blocker);
- assert!(!detection.visible_working);
- }
-
- #[test]
- fn codex_background_terminal_status_above_current_prompt_stays_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Ran just check\n └ test output...\n\n• Working (1m 36s) · 1 background terminal running · /ps to view · /stop to close\n\n\n› Run /review on my current changes",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn codex_reviewing_approval_request_is_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Reviewing approval request (22s • esc to interrupt)\n └ /bin/zsh -lc 'rm -rf /tmp/codex-rm-test'\n\n\n› Summarize recent commits\n\n ~/Projects/herdr · master",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn codex_reviewing_approval_request_without_prompt_is_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Running git push --force-with-lease origin codex/ci-flake-diagnostics\n\n• Reviewing approval request (2m 53s • esc to interrupt)\n └ /bin/zsh -lc 'git push --force-with-lease origin codex/ci-flake-diagnostics'",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn codex_reviewing_multiple_approval_requests_is_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Reviewing 2 approval requests (0s • esc to interrupt)\n\n\n› Summarize recent commits\n\n ~/Projects/herdr · master",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn codex_booting_mcp_server_is_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Booting MCP server: codex_apps (7s • esc to interrupt)\n\n\n› ok spawn one again please\n\n ~/Projects/herdr · master",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn codex_stale_working_status_above_current_prompt_is_not_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "■ Conversation interrupted - tell the model what to do differently. Something went wrong?\nHit `/feedback` to report the issue.\n\n\n› `/feedback` to report the issue.\n\n\n › Working\n\n\n • Working (2s • esc to interrupt)\n\n\n › Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 7% used\n\n\n■ Conversation interrupted - tell the model what to do differently. Something went wrong?\nHit `/feedback` to report the issue.\n\n\n› Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 7% used · 5h 95% left",
- );
-
- assert!(!detection.visible_working);
- }
-
- #[test]
- fn codex_pasted_transcript_in_current_prompt_does_not_trigger_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "› so there is a problem\n\n\n\n\n • Working (2s • esc to interrupt)\n\n\n › Run /review on my current changes\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 0% used · 5h 94% left · weekly\n 36% …\n\n wdyt\n\n\n\n ~/Projects/herdr · master · gpt-5.5 high · Context 0% used · 5h 94% left · weekly 36% …",
- );
-
- assert!(!detection.visible_working);
- }
-
- #[test]
- fn codex_background_terminal_wait_is_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Waiting for background terminal (0s • esc to …\n └ cargo test -p codex-core -- --exact…\n\n\n› Ask Codex to do anything",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn codex_working_header_without_interrupt_is_not_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Working (17s)\n\n› Implement {feature}",
- );
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(!detection.visible_working);
- }
-
- #[test]
- fn codex_old_working_line_before_later_block_is_not_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Working (17s • esc to interrupt)\n\n• Ran git status --short\n └ M src/detect.rs\n\n› Implement {feature}",
- );
-
- assert!(!detection.visible_working);
- }
-
- #[test]
- fn codex_old_background_terminal_wait_before_later_block_is_not_visible_working() {
- let detection = detect_agent(
- Some(Agent::Codex),
- "• Waiting for background terminal (0s • esc to …\n └ cargo test -p codex-core\n\n• Ran git status --short\n └ M src/detect.rs\n\n› Implement {feature}",
- );
-
- assert!(!detection.visible_working);
- }
-
- #[test]
- fn codex_queued_follow_up_keeps_active_working_status() {
- let cases = [
- "• Working (9m 24s • esc to interrupt)\n\n • Queued follow-up inputs\n ↳ also a new bug we can talk about later\n alt + ↑ edit last queued message\n\n› Summarize recent commits\n\n ~/Projects/herdr · master",
- "• Working (9m 24s • esc to interrupt)\n\n• Queued follow-up inputs\n ↳ also a new bug we can talk about later\n alt + ↑ edit last queued message\n\n› Summarize recent commits\n\n ~/Projects/herdr · master",
- "• Working (4s • esc to interrupt)\n\n • Messages to be submitted after next tool call (press esc to interrupt and send immediately)\n ↳ you mean that\n\n› Summarize recent commits\n\n ~/Projects/herdr · master",
- "• Working (4s • esc to interrupt)\n\n• Messages to be submitted after next tool call (press esc to interrupt and send immediately)\n ↳ you mean that\n\n› Summarize recent commits\n\n ~/Projects/herdr · master",
- ];
-
- for screen in cases {
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
- }
-
- #[test]
- fn codex_idle() {
- assert_eq!(detect_codex("❯ "), AgentState::Idle);
- }
-
- #[test]
- fn codex_transcript_viewer_skips_state_update() {
- let screen = "/ T R A N S C R I P T / / / / / / / / / / / / / / / /\n\n› i did thats why our latest commit is also a claude fix D\n\n• Yes, then I would release.\n──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc to edit prev";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert!(detection.skip_state_update);
- assert!(!detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn codex_wrapped_transcript_controls_skip_state_update() {
- let screen = "/ T R A N S C R I P T /\n\n› yeah go ahead\n──────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to\n page home/end to jump\n q to quit esc to edit prev";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert!(detection.skip_state_update);
- }
-
- #[test]
- fn codex_esc_transcript_controls_skip_state_update() {
- let screen = "/ T R A N S C R I P T /\n\n› yeah go ahead\n──────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc/← to edit prev → to edit next enter to edit message";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert!(detection.skip_state_update);
- }
-
- #[test]
- fn codex_transcript_controls_above_prompt_do_not_skip_state_update() {
- let screen = "Old output:\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc to edit prev\n\n› ";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert!(!detection.skip_state_update);
- }
-
- #[test]
- fn codex_exit_control_without_scroll_control_does_not_skip_state_update() {
- let screen = "Some output\n\n q to quit esc to edit prev";
- let detection = detect_agent(Some(Agent::Codex), screen);
-
- assert!(!detection.skip_state_update);
- }
-
- // ---- Gemini ----
-
- #[test]
- fn gemini_waiting_confirmation() {
- assert_eq!(
- detect_gemini("waiting for user confirmation\nYes No"),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn gemini_waiting_text_without_choices_is_idle() {
- assert_eq!(
- detect_gemini("waiting for user confirmation"),
- AgentState::Idle
- );
- }
-
- #[test]
- fn gemini_waiting_apply() {
- assert_eq!(
- detect_gemini("│ Apply this change\n│ Yes │ No"),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn gemini_waiting_allow_execution() {
- assert_eq!(
- detect_gemini("│ Allow execution of: rm test.txt"),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn gemini_working() {
- assert_eq!(
- detect_gemini("thinking...\nesc to cancel"),
- AgentState::Working
- );
- }
-
- #[test]
- fn gemini_idle() {
- assert_eq!(detect_gemini("❯ "), AgentState::Idle);
- }
-
- // ---- Cursor ----
-
- #[test]
- fn cursor_waiting_accept() {
- assert_eq!(
- detect_cursor("Apply changes? (y) (enter) or keep (n)"),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn cursor_waiting_allow() {
- assert_eq!(detect_cursor("allow file edit (y)"), AgentState::Blocked);
- }
-
- #[test]
- fn cursor_working_spinner() {
- assert_eq!(detect_cursor("⬡ Grepping.."), AgentState::Working);
- }
-
- #[test]
- fn cursor_working_braille_status() {
- assert_eq!(
- detect_cursor("⠠⠜ Running 5.52k tokens"),
- AgentState::Working
- );
- assert_eq!(
- detect_cursor("⠞ Working 5.62k tokens"),
- AgentState::Working
- );
- assert_eq!(
- detect_cursor("⠛ Grepping 1.2k tokens"),
- AgentState::Working
- );
- }
-
- #[test]
- fn cursor_blocked_command_approval() {
- let screen =
- "Waiting for approval...\nRun this command?\n→ Run (once) (y)\nSkip (esc or n)";
- assert_eq!(detect_cursor(screen), AgentState::Blocked);
- }
-
- #[test]
- fn cursor_approval_words_without_visible_controls_are_idle() {
- assert_eq!(
- detect_cursor("previous output: waiting for approval"),
- AgentState::Idle
- );
- }
-
- #[test]
- fn cursor_running_text_with_unrelated_yes_is_not_blocked() {
- let screen = "previous answer mentioned (y)\n⠠⠜ Running 5.52k tokens";
- assert_eq!(detect_cursor(screen), AgentState::Working);
- }
-
- #[test]
- fn cursor_working_ctrl_c() {
- assert_eq!(
- detect_cursor("processing\nctrl+c to stop"),
- AgentState::Working
- );
- }
-
- #[test]
- fn cursor_idle() {
- assert_eq!(detect_cursor("> "), AgentState::Idle);
- }
-
- // ---- Antigravity ----
-
- #[test]
- fn antigravity_blocked_permission_prompt() {
- let screen = "Requesting permission for: git log -n 50\nDo you want to proceed?\n> 1. Yes\n↑/↓ Navigate · tab Amend · e edit command";
- assert_eq!(detect_antigravity(screen), AgentState::Blocked);
- }
-
- #[test]
- fn antigravity_question_without_permission_request_stays_idle() {
- assert_eq!(
- detect_antigravity("Do you want to proceed?\n>"),
- AgentState::Idle
- );
- }
-
- #[test]
- fn antigravity_working_spinner() {
- assert_eq!(detect_antigravity("⡿ Working..."), AgentState::Working);
- assert_eq!(detect_antigravity("⣯ Loading..."), AgentState::Working);
- assert_eq!(detect_antigravity("⢿ Generating..."), AgentState::Working);
- assert_eq!(detect_antigravity("⣷ Running..."), AgentState::Working);
- }
-
- #[test]
- fn antigravity_background_task_footer_is_working() {
- let screen = "? for shortcuts Gemini 3.5 Flash (High) · 1 task(s) · /tasks";
- assert_eq!(detect_antigravity(screen), AgentState::Working);
- }
-
- #[test]
- fn antigravity_background_task_footer_parses_plural_variants() {
- assert_eq!(
- detect_antigravity("Gemini 3.5 Flash (High) · 10 task(s) · /tasks"),
- AgentState::Working
- );
- assert_eq!(
- detect_antigravity("model · 2 tasks · /tasks"),
- AgentState::Working
- );
- assert_eq!(
- detect_antigravity("model · 1 task · /tasks"),
- AgentState::Working
- );
- }
-
- #[test]
- fn antigravity_zero_background_tasks_is_idle() {
- let screen = "Gemini 3.5 Flash (High) · 0 task(s) · /tasks";
- assert_eq!(detect_antigravity(screen), AgentState::Idle);
- }
-
- #[test]
- fn antigravity_task_text_outside_bottom_footer_is_idle() {
- let screen =
- "User said the footer had /tasks and 1 task(s)\nline 1\nline 2\nline 3\nline 4\nline 5";
- assert_eq!(detect_antigravity(screen), AgentState::Idle);
- }
-
- #[test]
- fn antigravity_idle_prompt() {
- let screen = "Antigravity CLI\n────────────────\n>\n────────────────\n? for shortcuts";
- assert_eq!(detect_antigravity(screen), AgentState::Idle);
- }
-
- // ---- Cline ----
-
- #[test]
- fn cline_waiting_tool_use() {
- assert_eq!(detect_cline("let cline use this tool"), AgentState::Blocked);
- }
-
- #[test]
- fn cline_waiting_act_mode() {
- assert_eq!(
- detect_cline("[act mode] execute command?\nyes"),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn cline_mode_text_with_unrelated_yes_is_not_blocked() {
- assert_eq!(
- detect_cline("[act mode]\nPrevious answer was yes."),
- AgentState::Working
- );
- }
-
- #[test]
- fn cline_ready_text_is_not_visible_blocker() {
- let detection = detect_agent(Some(Agent::Cline), "cline is ready for your message");
-
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn cline_defaults_to_working() {
- // Cline's default is working (unlike other agents)
- assert_eq!(detect_cline("some random output"), AgentState::Working);
- }
-
- // ---- OpenCode ----
-
- #[test]
- fn opencode_waiting_permission() {
- assert_eq!(
- detect_opencode("△ Permission required"),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn opencode_working() {
- assert_eq!(
- detect_opencode("running tool\nesc to interrupt"),
- AgentState::Working
- );
- }
-
- #[test]
- fn opencode_working_on_footer_interrupt() {
- let screen = "\
- ▣ Build · MiniMax M3 Free\n\
-\n\
- ┃\n\
- ┃ Build · MiniMax M3 Free OpenCode Zen ~/Projects/llm-proxy:master\n\
- ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\n\
- ⬝⬝⬝■■■■■ esc interrupt 24.4K (12%) ctrl+p commands • OpenCode 1.15.13";
- assert_eq!(detect_opencode(screen), AgentState::Working);
- }
-
- #[test]
- fn opencode_working_on_progress_footer_without_product_text() {
- assert_eq!(
- detect_opencode("■■■■■■⬝⬝ esc interrupt"),
- AgentState::Working
- );
- }
-
- #[test]
- fn opencode_working_on_escape_again_footer() {
- assert_eq!(
- detect_opencode(
- "⬝⬝■■■■■■ esc again to interrupt 14.3K (7%) ctrl+p commands • OpenCode 1.15.13"
- ),
- AgentState::Working
- );
- }
-
- #[test]
- fn opencode_progress_row_alone_is_working() {
- assert_eq!(detect_opencode("■■■■■■⬝⬝"), AgentState::Working);
- }
-
- #[test]
- fn opencode_ctrl_p_commands_alone_is_not_working() {
- assert_eq!(
- detect_opencode("esc interrupt 24.4K (12%) ctrl+p commands"),
- AgentState::Idle
- );
- }
-
- #[test]
- fn opencode_waiting_question_prompt() {
- assert_eq!(
- detect_opencode(
- "Goal Detail Confirm\n\
- What do you want help with right now?\n\
- 1. Code change\n\
- 5. Type your own answer\n\
- ⇆ tab ↑↓ select enter confirm esc dismiss",
- ),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn opencode_waiting_question_confirm_tab() {
- assert_eq!(
- detect_opencode("Goal Detail Confirm\nReview\n⇆ tab enter submit esc dismiss"),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn opencode_idle() {
- assert_eq!(detect_opencode("> "), AgentState::Idle);
- }
-
- // ---- Kilo ----
-
- #[test]
- fn kilo_waiting_question_prompt() {
- assert_eq!(
- detect_kilo(
- "Write the following joke to ~/joke.md?\n\
- 1. Yes, write it\n\
- 2. No, thanks\n\
- 3. Type your own answer\n\
- ↑↓ select enter submit esc dismiss",
- ),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn kilo_working() {
- assert_eq!(
- detect_kilo("Ask · DeepSeek V4 Pro\nesc interrupt\n12.8K (1%) · $0.01"),
- AgentState::Working
- );
- }
-
- #[test]
- fn kilo_idle() {
- assert_eq!(
- detect_kilo("Ask anything... \"Fix broken tests\"\nCode · DeepSeek V4 Pro"),
- AgentState::Idle
- );
- }
-
- // ---- GitHub Copilot ----
-
- #[test]
- fn copilot_waiting_fetch_approval() {
- let content = "\
-╭───────────────────────────────────────────────────────────────────╮
-│ Fetch web content │
-│ ───────────────────────────────────────────────────────────────── │
-│ Copilot is attempting to access the following URL: │
-│ │
-│ ╭───────────────────────────────────────────────────────────────╮ │
-│ │ https://www.google.com/ │ │
-│ ╰───────────────────────────────────────────────────────────────╯ │
-│ │
-│ Do you want to allow this access? │
-│ │
-│ ❯ 1. Yes │
-│ 2. No │
-│ │
-│ ↑/↓ to navigate · enter to select · esc to cancel │
-╰───────────────────────────────────────────────────────────────────╯";
- assert_eq!(detect_github_copilot(content), AgentState::Blocked);
- }
-
- #[test]
- fn copilot_waiting_allow_directory_access() {
- let content = "\
-╭──────────────────────────────────────────────────────────────────╮
-│ Allow directory access │
-│ ──────────────────────────────────────────────────────────────── │
-│ This action may read or write the following path outside your │
-│ allowed directory list. │
-│ │
-│ ╭──────────────────────────────────────────────────────────────╮ │
-│ │ /Users/user/Dev/workspace │ │
-│ ╰──────────────────────────────────────────────────────────────╯ │
-│ │
-│ Do you want to allow this? │
-│ │
-│ 1. Yes │
-│ ❯ 2. Yes, and add these directories to the allowed list │
-│ 3. No (Esc) │
-│ │
-│ ↑/↓ to navigate · enter to select · esc to cancel │
-╰──────────────────────────────────────────────────────────────────╯";
- assert_eq!(detect_github_copilot(content), AgentState::Blocked);
- }
-
- #[test]
- fn copilot_waiting_directory_permission() {
- let content = "\
-○ Asking user Requesting permission to access directory 'src/' for r…
-
-╭───────────────────────────────────────────────────────────────────╮
-│ Question │
-│ ───────────────────────────────────────────────────────────────── │
-│ Requesting permission to access directory 'src/' for reading │
-│ files. Allow? │
-│ │
-│ ❯ 1. Yes (Allow) │
-│ 2. No (Deny) │
-│ │
-│ ↑/↓ to select · enter to confirm · esc to cancel │
-╰───────────────────────────────────────────────────────────────────╯";
- assert_eq!(detect_github_copilot(content), AgentState::Blocked);
- }
-
- #[test]
- fn copilot_waiting_action_confirmation() {
- let content = "\
-○ Asking user Confirm action: 'Reset local changes in working tree' …
-
-╭───────────────────────────────────────────────────────────────────╮
-│ Question │
-│ ───────────────────────────────────────────────────────────────── │
-│ Confirm action: 'Reset local changes in working tree' — proceed? │
-│ │
-│ ❯ 1. Yes, reset changes │
-│ 2. No, cancel │
-│ │
-│ ↑/↓ to select · enter to confirm · esc to cancel │
-╰───────────────────────────────────────────────────────────────────╯";
- assert_eq!(detect_github_copilot(content), AgentState::Blocked);
- }
-
- #[test]
- fn copilot_waiting_db_choice() {
- let content = "\
-○ Asking user Choose a database for the project:
-
-╭───────────────────────────────────────────────────────────────────╮
-│ Question │
-│ ───────────────────────────────────────────────────────────────── │
-│ Choose a database for the project: │
-│ │
-│ ❯ 1. PostgreSQL (Recommended) │
-│ 2. SQLite │
-│ 3. MySQL │
-│ 4. MongoDB │
-│ │
-│ ↑/↓ to select · enter to confirm · esc to cancel │
-╰───────────────────────────────────────────────────────────────────╯";
- assert_eq!(detect_github_copilot(content), AgentState::Blocked);
- }
-
- #[test]
- fn copilot_waiting_freeform_input() {
- let content = "\
-○ Asking user Enter the name for the new branch:
-
-╭───────────────────────────────────────────────────────────────────╮
-│ Question │
-│ ───────────────────────────────────────────────────────────────── │
-│ Enter the name for the new branch: │
-│ │
-│ ❯ Type your answer... │
-│ │
-│ enter to submit · esc to cancel │
-╰───────────────────────────────────────────────────────────────────╯";
- assert_eq!(detect_github_copilot(content), AgentState::Blocked);
- }
-
- #[test]
- fn copilot_waiting_plan_review() {
- let content = "\
-○ Plan ready for review - Create a 200-word inspirational poem. - Fi…
-
-╭───────────────────────────────────────────────────────────────────╮
-│ Plan Ready for Review │
-│ ───────────────────────────────────────────────────────────────── │
-│ - Create a 200-word inspirational poem. │
-│ - Files/changes: none (deliver poem in chat). │
-│ - Steps: draft poem, verify ~200 words, offer up to 2 revisions. │
-│ - Decision: Inspirational tone, exact length target ~200 words. │
-│ │
-│ ❯ 1. Accept plan and build on default permissions (recommended) │
-│ 2. Exit plan mode and I will prompt myself │
-│ 3. Suggest changes │
-│ │
-│ ↑/↓ to navigate · enter to select · ctrl+e to show full plan · │
-│ esc to cancel │
-╰───────────────────────────────────────────────────────────────────╯";
- assert_eq!(detect_github_copilot(content), AgentState::Blocked);
- }
-
- #[test]
- fn copilot_waiting_enable_autopilot() {
- let content = "\
-╭───────────────────────────────────────────────────────────────────╮
-│ Enable autopilot mode │
-│ ───────────────────────────────────────────────────────────────── │
-│ Autopilot mode works best with all permissions enabled. Without │
-│ them, permission requests will be auto-denied and the agent may │
-│ not complete tasks requiring file edits or shell commands. │
-│ │
-│ You can also enable permissions later with /allow-all │
-│ │
-│ ❯ 1. Enable all permissions (recommended) │
-│ 2. Continue with limited permissions │
-│ 3. Cancel (Esc) │
-│ │
-│ ↑/↓ to navigate · enter to select · esc to cancel │
-╰───────────────────────────────────────────────────────────────────╯";
- assert_eq!(detect_github_copilot(content), AgentState::Blocked);
- }
-
- #[test]
- fn copilot_working_thinking_spinner() {
- assert_eq!(
- detect_github_copilot("○ Thinking esc cancel"),
- AgentState::Working
- );
- assert_eq!(
- detect_github_copilot("◎ Thinking esc cancel"),
- AgentState::Working
- );
- assert_eq!(
- detect_github_copilot("◉ Thinking esc cancel"),
- AgentState::Working
- );
- }
-
- // ---- Kimi ----
-
- #[test]
- fn kimi_blocked_approval_prompt_wins_over_spinner() {
- let screen = "⠋ Using Shell (git log --oneline -10)\n╭─ approval ─╮\nShell is requesting approval to run command:\ngit log --oneline -10\n→ [1] Approve once\n[2] Approve for this session\n[3] Reject\n[4] Reject, tell the model what to do instead\n▲/▼ select 1/2/3/4 choose ↵ confirm";
- assert_eq!(detect_kimi(screen), AgentState::Blocked);
- }
-
- #[test]
- fn kimi_current_approval_panel_is_visible_blocker() {
- let screen = "────────────────────────────────────────────────────\n ▶ Run this command?\n\n $ git status --short\n\n ▶ 1. Approve once\n 2. Approve for this session\n 3. Reject\n 4. Reject with feedback\n\n ↑/↓ select · 1/2/3/4 choose · ↵ confirm\n────────────────────────────────────────────────────";
- let detection = detect_agent(Some(Agent::Kimi), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn kimi_question_panel_is_visible_blocker() {
- let screen = "────────────────────────────────────────────────────\n question\n\n (○) Destination Submit\n\n ? Which destination should I use?\n\n → [1] Local checkout\n [2] Remote branch\n [3] Other\n\n ↑↓ select 1-3 / ↵ choose ←/→/tab switch esc cancel\n────────────────────────────────────────────────────";
- let detection = detect_agent(Some(Agent::Kimi), screen);
-
- assert_eq!(detection.state, AgentState::Blocked);
- assert!(detection.visible_blocker);
- }
-
- #[test]
- fn kimi_approval_words_without_prompt_stay_idle() {
- assert_eq!(detect_kimi("approve?"), AgentState::Idle);
- assert_eq!(detect_kimi("continue? [y/n]"), AgentState::Idle);
- }
-
- #[test]
- fn kimi_working_braille_thinking() {
- assert_eq!(
- detect_kimi("⠦ Thinking... <1s · 19 tokens"),
- AgentState::Working
- );
- }
-
- #[test]
- fn kimi_working_braille_using_tool() {
- assert_eq!(
- detect_kimi("⠹ Using Shell (git log -20 --name-status)"),
- AgentState::Working
- );
- }
-
- #[test]
- fn kimi_working_braille_working_status_is_visible_working() {
- let detection = detect_agent(Some(Agent::Kimi), "⠋ working...");
-
- assert_eq!(detection.state, AgentState::Working);
- assert!(detection.visible_working);
- }
-
- #[test]
- fn kimi_working_moon_spinner() {
- assert_eq!(detect_kimi("🌕"), AgentState::Working);
- assert_eq!(detect_kimi("🌗"), AgentState::Working);
- assert_eq!(detect_kimi("🌘"), AgentState::Working);
- }
-
- #[test]
- fn kimi_working_moon_spinner_above_input_box() {
- let screen = "✨ yo\n\n🌗\n\n── input ─────────────────────────";
- assert_eq!(detect_kimi(screen), AgentState::Working);
- }
-
- #[test]
- fn kimi_old_transcript_words_stay_idle() {
- assert_eq!(detect_kimi("thinking"), AgentState::Idle);
- assert_eq!(detect_kimi("generating code"), AgentState::Idle);
- assert_eq!(
- detect_kimi("Used Shell (git log --oneline -10)"),
- AgentState::Idle
- );
- assert_eq!(detect_kimi("some 🌕 in prose"), AgentState::Idle);
- }
-
- #[test]
- fn kimi_idle() {
- let screen = "Welcome to Kimi Code CLI!\n── input ─\n────────────────\nagent (Kimi-k2.6 ●) ~/Projects/herdr";
- assert_eq!(detect_kimi(screen), AgentState::Idle);
- }
-
- #[test]
- fn kimi_current_editor_box_does_not_set_screen_idle_signal() {
- let screen = "╭──────────────────────────────────────────────────╮\n│ > │\n╰──────────────────────────────────────────────────╯\nk2 ~/Projects/herdr /help: show commands\n context: 0.0%";
- let detection = detect_agent(Some(Agent::Kimi), screen);
-
- assert_eq!(detection.state, AgentState::Idle);
- assert!(!detection.visible_working);
- assert!(!detection.visible_blocker);
- }
-
- // ---- Kiro ----
-
- #[test]
- fn kiro_working_on_status_bar() {
- let screen = "◕ Shell\n esc to cancel\n● 1 MCP failure — see /mcp\n─────────────────────────────────────────────────────\nKiro · auto · ◔ 6% ~\n\n Kiro is working · type to queue a message";
- assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Working);
- }
-
- #[test]
- fn kiro_working_on_tool_spinner_and_cancel_hint() {
- let screen = "◕ Shell\n esc to cancel\n─────────────────────────────────────────────────────\nKiro · auto · ◔ 6%";
- assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Working);
- }
-
- #[test]
- fn kiro_idle_at_prompt() {
- let screen = "● 1 MCP failure — see /mcp\n──────────────────────────────────────────────────────────────────────────────────────\nKiro · auto · ◔ 6% ~\n\n ask a question or describe a task ↵\n /copy to clipboard";
- assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Idle);
- }
-
- #[test]
- fn kiro_blocked_on_tool_approval_prompt() {
- let screen = "↓ Shell mkdir -p /tmp/test-kiro-{a,b,c} && ls /tmp/test-kiro-*\n\n─────────────────────────────────────────────────────────────────────────────────────────\n shell requires approval\n ❯ Yes, single permission\n Trust, always allow in this session\n No (Tab to edit)\n─────────────────────────────────────────────────────────────────────────────────────────\n ESC to close | Tab to edit";
- assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Blocked);
- }
-
- #[test]
- fn kiro_blocked_on_subagent_tool_approval_prompt() {
- let screen = " Please delegate to a sub-agent to search the web\n\n● Orchestrating (1 agent)\n esc to cancel\n ctrl+g open agent monitor\n ● web-research kiro_default ⚠ tool approval needed\n\n ◐ Tasks · 1 done · 2 remaining ctrl+x to expand\n──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n ⚠ 3 tool approvals pending from subagents\n ❯ (a) Approve all pending\n (f) Approve all pending and auto-approve all future requests\n (c) Configure individually (agent monitor)\n (x) Exit (cancel subagents) %";
- assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Blocked);
- }
-
- #[test]
- fn kiro_does_not_treat_stale_failure_spinner_as_working() {
- let screen = "● 1 MCP failure — see /mcp\n─────────────────────────────────────────────────────\nKiro · auto · ◔ 6%\n\n ask a question or describe a task ↵";
- assert_eq!(detect_state(Some(Agent::Kiro), screen), AgentState::Idle);
- }
-
- #[test]
- fn kiro_identified_by_process_name() {
- assert_eq!(identify_agent("kiro"), Some(Agent::Kiro));
- assert_eq!(identify_agent("kiro-cli"), Some(Agent::Kiro));
- }
-
- // ---- Droid ----
-
- #[test]
- fn droid_working_thinking_with_spinner() {
- let screen = "> how u doin\n\n⠴ Thinking... (Press ESC to stop)\n\nAuto (Off)";
- assert_eq!(detect_droid(screen), AgentState::Working);
- }
-
- #[test]
- fn droid_working_esc_to_stop_alone() {
- // ESC to stop without spinner is still working (UI chrome)
- let screen = "Processing\n(Press ESC to stop)";
- assert_eq!(detect_droid(screen), AgentState::Working);
- }
-
- #[test]
- fn droid_waiting_execute_approval() {
- let screen = concat!(
- "⛬ I'll create some folders.\n\n",
- " EXECUTE (mkdir -p /tmp/test, impact: medium)\n\n",
- "╭────────────────────╮\n",
- "│ > Yes, allow │\n",
- "│ Yes, always allow │\n",
- "│ No, cancel │\n",
- "╰────────────────────╯\n",
- " Use ↑↓ to navigate, Enter to select, Esc to cancel\n",
- );
- assert_eq!(detect_droid(screen), AgentState::Blocked);
- }
-
- #[test]
- fn droid_waiting_selection_with_chrome() {
- let screen = "│ > Yes, allow │\n│ No, cancel │\n Use ↑↓ to navigate, Enter to select, Esc to cancel";
- assert_eq!(detect_droid(screen), AgentState::Blocked);
- }
-
- #[test]
- fn droid_not_waiting_on_options_text_alone() {
- // "Yes, allow" in normal conversation should NOT trigger blocked
- let screen = "The user said > Yes, allow the changes";
- assert_eq!(detect_droid(screen), AgentState::Idle);
- }
-
- #[test]
- fn droid_idle_prompt() {
- let screen =
- "╭──────────────────╮\n│ > Try something │\n╰──────────────────╯\n? for help";
- assert_eq!(detect_droid(screen), AgentState::Idle);
- }
-
- #[test]
- fn droid_idle_after_response() {
- let screen =
- "⛬ Doing well, thanks!\n\nAuto (Off)\n╭──────────╮\n│ > │\n╰──────────╯";
- assert_eq!(detect_droid(screen), AgentState::Idle);
- }
-
- #[test]
- fn droid_braille_spinner_detected() {
- assert!(has_braille_spinner("⠴ Thinking..."));
- assert!(has_braille_spinner(" ⠧ Loading..."));
- assert!(has_braille_spinner("text\n⠋ Working\nmore"));
- }
-
- #[test]
- fn droid_braille_spinner_no_false_positive() {
- assert!(!has_braille_spinner("normal text"));
- assert!(!has_braille_spinner("Thinking..."));
- assert!(!has_braille_spinner("some ⠴ in middle of text"));
- }
-
- #[test]
- fn droid_identified_by_process_name() {
- assert_eq!(identify_agent("droid"), Some(Agent::Droid));
- }
-
- // ---- Amp ----
-
- #[test]
- fn amp_blocked_waiting_for_approval() {
- let screen = "Invoke tool shell_command?\n▸● Approve [Alt+1]\n ○ Allow All for This Session [Alt+2]\n ○ Allow All for Every Session [Alt+3]\n ○ Deny with feedback [Alt+4]\nWaiting for approval...";
- assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked);
- }
-
- #[test]
- fn amp_blocked_run_this_command() {
- let screen = "Run this command?\nrg --files\n▸● Approve [Alt+1]\n ○ Allow All for This Session [Alt+2]\n ○ Allow All for Every Session [Alt+3]\n ○ Deny with feedback [Alt+4]";
- assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked);
- }
-
- #[test]
- fn amp_blocked_allow_editing_file() {
- let screen = "Allow editing file:\nsrc/detect.rs\n▸● Approve [Alt+1]\n ○ Allow File for Every Session [Alt+2]\n ○ Allow All for This Session [Alt+3]\n ○ Deny with feedback [Alt+4]";
- assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked);
- }
-
- #[test]
- fn amp_blocked_allow_creating_file() {
- let screen = "Allow creating file:\nsrc/new_file.rs\n▸● Approve [Alt+1]\n ○ Allow File for Every Session [Alt+2]\n ○ Allow All for This Session [Alt+3]\n ○ Deny with feedback [Alt+4]";
- assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Blocked);
- }
-
- #[test]
- fn amp_working_running_tools() {
- let screen = " ✓ Search Map the core runtime architecture\n ⋯ Oracle ▼\n ≈ Running tools... Esc to cancel";
- assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Working);
- }
-
- #[test]
- fn amp_idle() {
- let screen = " Response complete.\n\n╭─100% of 272k · $1.20─────────────────────────╮\n│ │\n╰───────────────────────~/Projects/herdr (master)╯";
- assert_eq!(detect_state(Some(Agent::Amp), screen), AgentState::Idle);
- }
-
- #[test]
- fn amp_identified_by_process_name() {
- assert_eq!(identify_agent("amp"), Some(Agent::Amp));
- assert_eq!(identify_agent("amp-local"), Some(Agent::Amp));
- }
-
- // ---- Grok ----
-
- #[test]
- fn grok_blocked_on_permission_prompt() {
- let screen = "Show recent commit history for analysis\n\
- git -C /home/can/Projects/herdr log --oneline --decorate -n 12\n\
- Use ← → to choose permission whitelist scope\n\n\
- 1 (○) Always allow: git -C\n\
- 2 (●) Yes, proceed\n\
- 3 (○) No, reject (type to add feedback)\n\n\
- 1/3:select │ ←/→:scope │ Ctrl+o:yolo │ Ctrl+c:cancel";
- assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Blocked);
- }
-
- #[test]
- fn grok_blocked_wins_over_spinner() {
- let screen = "⠹ Run git 30s\nUse ← → to choose permission whitelist scope\nYes, proceed\nNo, reject (type to add feedback)";
- assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Blocked);
- }
-
- #[test]
- fn grok_choice_words_without_scope_selector_are_not_blocked() {
- let screen = "Yes, proceed\nNo, reject (type to add feedback)";
- assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Idle);
- }
-
- #[test]
- fn grok_working_on_waiting_spinner() {
- let screen = "⠋ Waiting… 1.8s\nCtrl+c:cancel │ Ctrl+Enter:interject";
- assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Working);
- }
-
- #[test]
- fn grok_working_on_tool_spinner() {
- let screen = "⠼ Run git -C /home/can/Projects/herdr log --oneline 1.0s";
- assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Working);
- }
-
- #[test]
- fn grok_idle_after_turn_completed() {
- let screen = "yo\n\nTurn completed in 1.7s.\n\n╭────╮\n│ ❯ │\n╰─ gpt-5.4 ─╯";
- assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Idle);
- }
-
- // ---- Hermes ----
-
- #[test]
- fn hermes_identified_by_process_name() {
- assert_eq!(identify_agent("hermes"), Some(Agent::Hermes));
- assert_eq!(identify_agent("hermes-agent"), Some(Agent::Hermes));
- }
-
- #[test]
- fn hermes_working_on_interrupt_footer() {
- let screen = " (⌐■_■) computing...\n\n ⚕ gpt-5.5 │ 15.5K/272K │ [█░░░░░░░░░] 6% │ 2m │ ⏱ 3s\n─────────────────────────────────────────────────────────────────────────────────────────\n⚕ ❯ msg=interrupt · /queue · /bg · /steer · Ctrl+C cancel";
- assert_eq!(
- detect_state(Some(Agent::Hermes), screen),
- AgentState::Working
- );
- }
-
- #[test]
- fn hermes_idle_ignores_stale_initializing_agent_text() {
- let screen = "● say exactly READY and stop\nInitializing agent...\n\n╭─ ⚕ Hermes ────────────────────────────────────────────────────────────────────────────╮\n READY\n╰───────────────────────────────────────────────────────────────────────────────────────╯\n ⚕ gpt-5.5 │ 15.5K/272K │ [█░░░░░░░░░] 6% │ 15s │ ⏲ 2s\n─────────────────────────────────────────────────────────────────────────────────────────\n❯";
- assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle);
- }
-
- #[test]
- fn hermes_blocked_on_dangerous_command_prompt() {
- let screen = "╭────────────────────────────────────────────────────────────╮\n│ ⚠️ Dangerous Command │\n│ mkdir -p /tmp/herdr-hermes-block-test/subdir && touch │\n│ ❯ 1. Allow once │\n│ 2. Allow for this session │\n│ 3. Add to permanent allowlist │\n│ 4. Deny │\n│ 5. Show full command │\n╰────────────────────────────────────────────────────────────╯\n ↑/↓ to select, Enter to confirm\n⚠ ❯";
- assert_eq!(
- detect_state(Some(Agent::Hermes), screen),
- AgentState::Blocked
- );
- }
-
- #[test]
- fn hermes_idle_at_prompt_after_response() {
- let screen = "╭─ ⚕ Hermes ────────────────────────────────────────────────────────────────────────────╮\n READY\n╰───────────────────────────────────────────────────────────────────────────────────────╯\n ⚕ gpt-5.5 │ 15.5K/272K │ [█░░░░░░░░░] 6% │ 15s │ ⏲ 2s\n─────────────────────────────────────────────────────────────────────────────────────────\n❯\n─────────────────────────────────────────────────────────────────────────────────────────";
- assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle);
- }
-
- #[test]
- fn hermes_denied_message_is_idle() {
- let screen = "╭─ ⚕ Hermes ────────────────────────────────────────────────────────────────────────────╮\n Command was blocked/denied by the safety layer. I did not retry.\n╰───────────────────────────────────────────────────────────────────────────────────────╯\n ⚕ gpt-5.5 │ 15.4K/272K │ [█░░░░░░░░░] 6% │ 2m │ ⏲ 11s\n─────────────────────────────────────────────────────────────────────────────────────────\n❯";
- assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle);
- }
-
- // ---- Qodercli ----
-
- #[test]
- fn qodercli_identified_by_process_name() {
- assert_eq!(identify_agent("qodercli"), Some(Agent::Qodercli));
- assert_eq!(identify_agent("qoderclicn"), Some(Agent::Qodercli));
- assert_eq!(identify_agent("qoder"), Some(Agent::Qodercli));
- assert_eq!(identify_agent("qodercn"), Some(Agent::Qodercli));
- }
-
- #[test]
- fn qodercli_blocked_on_confirmation() {
- assert_eq!(
- detect_qodercli("Waiting for user confirmation...\nAllow\nReject"),
- AgentState::Blocked,
- );
- }
-
- #[test]
- fn qodercli_waiting_confirmation_without_choices_is_idle() {
- assert_eq!(
- detect_qodercli("Waiting for user confirmation..."),
- AgentState::Idle,
- );
- }
-
- #[test]
- fn qodercli_working_on_spinner() {
- assert_eq!(detect_qodercli("\u{280B} Thinking..."), AgentState::Working);
- }
-
- #[test]
- fn qodercli_idle_on_prompt() {
- assert_eq!(detect_qodercli("> "), AgentState::Idle);
- }
-
- #[test]
- fn qodercli_idle_when_only_stale_braille_glyph_in_scrollback() {
- // A single stray braille character in a previous output line must not
- // flip the pane to Working — only an actual spinner *row* should.
- let screen = "\
-agent finished a previous task.\n\
-\u{280B}\n\
-> \n";
- assert_eq!(detect_qodercli(screen), AgentState::Idle);
- }
-
- #[test]
- fn qodercli_working_on_full_spinner_row() {
- // Real spinner row: braille glyph + space + alphabetic phrase.
- let screen = "\u{280B} Thinking...\n";
- assert_eq!(detect_qodercli(screen), AgentState::Working);
- }
-
- #[test]
- fn qodercli_working_on_esc_to_cancel_hint() {
- // The "(esc to cancel, …)" suffix is qodercli's explicit working
- // marker. It must trigger Working even if a hook icon replaced the
- // spinner glyph in this frame.
- let screen = "Thinking... (esc to cancel, 5s)\n";
- assert_eq!(detect_qodercli(screen), AgentState::Working);
- }
-
- #[test]
- fn qodercli_idle_when_text_mentions_working_in_prose() {
- // The previous heuristic treated the bare word "working" as Working,
- // which produced false positives for narrative output (commits, logs,
- // Markdown). The pane should remain Idle until a real working signal
- // appears.
- let screen = "\
-fix: keep working set warm across reloads\n\
-\n\
-> \n";
- assert_eq!(detect_qodercli(screen), AgentState::Idle);
- }
-
- #[test]
- fn qodercli_exit_hint_is_not_visible_blocker() {
- let screen = "\
-\u{280B} Thinking...\n\
-Press Ctrl+C again to exit.\n";
- let detection = detect_agent(Some(Agent::Qodercli), screen);
-
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn qodercli_esc_rewind_is_not_visible_blocker() {
- let screen = "Press Esc again to rewind.\n";
- let detection = detect_agent(Some(Agent::Qodercli), screen);
-
- assert!(!detection.visible_blocker);
- }
-
- #[test]
- fn qodercli_blocked_on_permission_required_dialog() {
- // qodercli renders this dialog when a tool call needs user approval.
- let screen = "\
-Permission Required\n\
-Caller: test\n\
-Command: mkdir -p /root/example\n\
-Allow once or always?\n\
- \u{276F} 1. Allow Once - allow `mkdir` for one\n\
- 2. Always allow `mkdir` for future sessions\n\
- 3. Reject and tell qodercli something\n";
- assert_eq!(detect_qodercli(screen), AgentState::Blocked);
- }
-
- #[test]
- fn qodercli_blocked_on_permission_required_alone() {
- // Even when the prompt copy gets truncated by the viewport, the title
- // alone should be enough to flip the pane to blocked.
- assert_eq!(detect_qodercli("Permission Required"), AgentState::Blocked,);
- }
-
- #[test]
- fn qodercli_blocked_on_askuser_enter_response_placeholder() {
- // qodercli's ask-user tool renders an input box with this placeholder
- // when waiting for the user to type a response.
- let screen = "\
-What kind of project are you working on?\n\
-> \n\
- Enter your response\n";
- assert_eq!(detect_qodercli(screen), AgentState::Blocked);
- }
-
- #[test]
- fn qodercli_blocked_on_askuser_review_tab() {
- // The multi-question/multi-select review tab heading is unique to the
- // ask-user dialog and means the agent is parked waiting on user input.
- let screen = "\
-Review your answers:\n\
-\n\
-Project type \u{2192} Web app\n\
-Stack \u{2192} (not answered)\n";
- assert_eq!(detect_qodercli(screen), AgentState::Blocked);
- }
-
- #[test]
- fn qodercli_blocked_on_interactive_shell_waiting() {
- // When qodercli spawns an interactive shell, the loading row turns
- // into a "Shell awaiting input" hint until the user takes focus.
- let screen = "! Shell awaiting input (Tab to focus)\n";
- assert_eq!(detect_qodercli(screen), AgentState::Blocked);
- }
-
- #[test]
- fn qodercli_blocked_on_askuser_single_choice_dialog() {
- // Single-select ask-user has no "Enter your response" placeholder and
- // no "Review your answers:" heading. The BaseTabDialog title
- // "Asking User" is the only stable signal across every ask-user form.
- let screen = "\
-Asking User\n\
-\n\
-Which framework should we use?\n\
- React\n\
- Vue\n\
- Svelte\n";
- assert_eq!(detect_qodercli(screen), AgentState::Blocked);
- }
-
- // ---- Helpers ----
-
- #[test]
- fn content_above_prompt_box_extracts_correctly() {
- let screen = "line1\nline2\n──────\n❯ \n──────";
- let above = content_above_prompt_box(screen);
- assert!(above.contains("line1"));
- assert!(above.contains("line2"));
- assert!(!above.contains('❯'));
- }
-
- #[test]
- fn content_above_prompt_box_no_box() {
- let screen = "just some text\nno borders here";
- let above = content_above_prompt_box(screen);
- assert_eq!(above, screen);
- }
-
- #[test]
- fn spinner_activity_detected() {
- assert!(has_spinner_activity("· Thinking…"));
- assert!(has_spinner_activity("✽ Tempering…"));
- assert!(has_spinner_activity("✳ Simplifying recompute_tangents…"));
- assert!(has_spinner_activity(" ✶ Reading…")); // with leading whitespace
- assert!(has_spinner_activity("✻ Pouncing…"));
- assert!(has_spinner_activity("✽ Processing…"));
- }
-
- #[test]
- fn spinner_activity_not_false_positive() {
- assert!(!has_spinner_activity("normal text"));
- assert!(!has_spinner_activity("✽ no ellipsis here"));
- assert!(!has_spinner_activity("✽ …"));
- assert!(!has_spinner_activity("some ✽ in the middle"));
- }
-
- #[test]
- fn cursor_spinner_detected() {
- assert!(has_cursor_spinner("⬡ Grepping.."));
- assert!(has_cursor_spinner("⬢ Reading…"));
- assert!(has_cursor_spinner("⠠⠜ Running 5.52k tokens"));
- assert!(has_cursor_spinner("⠞ Working 5.62k tokens"));
- assert!(has_cursor_spinner("⠛ Grepping 1.2k tokens"));
- assert!(has_cursor_spinner("⠛ Analyzing 1.2k tokens"));
- }
-
- #[test]
- fn cursor_spinner_not_false_positive() {
- assert!(!has_cursor_spinner("normal text"));
- assert!(!has_cursor_spinner("some ⬡ in middle"));
- assert!(!has_cursor_spinner("⠛ Read notes"));
- }
-
// ---- Process identification (real PTY) ----
#[cfg(target_os = "linux")]
@@ -3043,20 +1041,4 @@ Which framework should we use?\n\
// In CI/test environments without a terminal, tpgid is typically -1
let _ = tpgid;
}
-
- #[test]
- fn terminal_tail_content_works_with_detection() {
- assert_eq!(detect_pi("Working..."), AgentState::Working);
- }
-
- #[test]
- fn ansi_colored_content_still_detects_working() {
- assert_eq!(detect_pi("\x1b[31mWorking...\x1b[0m"), AgentState::Working);
- }
-
- #[test]
- fn visible_claude_prompt_box_is_idle() {
- let screen = "Task complete.\n─────────────\n❯ \n─────────────";
- assert_eq!(detect_claude(screen), AgentState::Idle);
- }
}
diff --git a/src/events.rs b/src/events.rs
index cf6a208e..07660c71 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -92,6 +92,11 @@ pub enum AppEvent {
version: String,
install_command: String,
},
+ /// Remote agent detection manifest update check finished.
+ AgentDetectionManifestsUpdated {
+ updated: Vec<crate::detect::manifest_update::ManifestUpdateCommit>,
+ status: crate::detect::manifest_update::ManifestUpdateStatus,
+ },
/// A pane child emitted a valid OSC 52 clipboard write. The main loop
/// re-emits it through herdr's own clipboard writer.
ClipboardWrite { content: Vec<u8> },
diff --git a/src/main.rs b/src/main.rs
index d2a1767b..d2beb114 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -42,7 +42,6 @@ fn pop_keyboard_enhancement_flags() -> io::Result<()> {
Ok(())
}
-mod agent_detection_policy;
mod agent_resume;
mod api;
mod app;
diff --git a/src/pane.rs b/src/pane.rs
index 1dd641f4..b880ff32 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -33,11 +33,10 @@ mod xtgettcap;
use self::agent_detection::{
agent_caused_pty_activity_active, baseline_pty_causality, decide_detection_screen_read,
- decide_pty_working_publish_without_screen, decide_screen_detection_publish,
- detection_update_for_publish, handle_skipped_detection_update, observe_pty_output_activity,
- DetectionPublishDecision, DetectionScreenReadDecision, DetectionScreenReadInput,
- PendingIdleConfirmation, PendingWorkingConfirmation, PostTaintWorkingLease,
- PtyCausalityTracker, PtyWorkingPublishInput, ScreenDetectionPublishInput,
+ decide_screen_detection_publish, detection_update_for_publish, handle_skipped_detection_update,
+ observe_pty_output_activity, DetectionPublishDecision, DetectionScreenReadDecision,
+ DetectionScreenReadInput, PendingIdleConfirmation, PendingWorkingConfirmation,
+ PostTaintWorkingLease, PtyCausalityTracker, ScreenDetectionPublishInput,
AGENT_PENDING_IDLE_RECHECK, AGENT_STARTUP_GRACE_WINDOW,
};
use self::terminal::{GhosttyPaneTerminal, PaneTerminal};
@@ -123,6 +122,7 @@ async fn publish_state_changed_event(
#[derive(Debug, Clone, Copy)]
struct AgentDetectionPublishUpdate {
state: AgentState,
+ visible_idle: bool,
visible_blocker: bool,
visible_working: bool,
process_exited: bool,
@@ -135,12 +135,14 @@ async fn apply_agent_detection_publish_update(
update: AgentDetectionPublishUpdate,
observed_at: std::time::Instant,
state: &mut AgentState,
+ last_visible_idle: &mut bool,
last_visible_blocker: &mut bool,
last_visible_working: &mut bool,
last_visible_signal_refresh: &mut Option<std::time::Instant>,
foreground_shell_exit_reported: &mut bool,
) {
*state = update.state;
+ *last_visible_idle = update.visible_idle;
*last_visible_blocker = update.visible_blocker;
*last_visible_working = update.visible_working;
*last_visible_signal_refresh = if update.visible_blocker || update.visible_working {
@@ -384,6 +386,7 @@ fn spawn_basic_detection_task(
terminal: Arc<PaneTerminal>,
pty_output_seq: Arc<AtomicU64>,
input_write_seq: Arc<AtomicU64>,
+ full_lifecycle_authority_active: Arc<AtomicBool>,
state_events: mpsc::Sender<AppEvent>,
) -> (
tokio::task::AbortHandle,
@@ -398,6 +401,7 @@ fn spawn_basic_detection_task(
let handle = tokio::spawn(async move {
let mut agent_presence = AgentDetectionPresence::from_agent(None);
let mut state = AgentState::Unknown;
+ let mut last_visible_idle = false;
let mut last_visible_blocker = false;
let mut last_visible_working = false;
let mut last_visible_signal_refresh = None;
@@ -431,6 +435,7 @@ fn spawn_basic_detection_task(
_ = detect_reset.notified() => {
agent_presence = AgentDetectionPresence::from_agent(None);
state = AgentState::Unknown;
+ last_visible_idle = false;
last_visible_blocker = false;
last_visible_working = false;
last_visible_signal_refresh = None;
@@ -547,6 +552,7 @@ fn spawn_basic_detection_task(
input_write_seq.load(Ordering::Relaxed),
);
state = AgentState::Idle;
+ last_visible_idle = true;
last_visible_blocker = false;
last_visible_working = false;
last_visible_signal_refresh = None;
@@ -572,6 +578,13 @@ fn spawn_basic_detection_task(
&& agent.is_some()
&& !foreground_shell_exit_reported;
+ if full_lifecycle_authority_active.load(Ordering::Acquire) && !process_exited {
+ pending_idle.clear();
+ pending_working.clear();
+ post_taint_working.clear();
+ continue;
+ }
+
if let Some(until) = agent_startup_grace_until {
if process_exited {
agent_startup_grace_until = None;
@@ -622,76 +635,6 @@ fn spawn_basic_detection_task(
}) {
DetectionScreenReadDecision::Read => {}
DetectionScreenReadDecision::Skip => continue,
- DetectionScreenReadDecision::EvaluatePtyWorking => {
- match decide_pty_working_publish_without_screen(
- PtyWorkingPublishInput {
- agent,
- current_state: state,
- last_visible_blocker,
- last_visible_working,
- last_visible_signal_refresh,
- pty_activity,
- now,
- },
- &mut pending_idle,
- &mut pending_working,
- &mut post_taint_working,
- ) {
- DetectionPublishDecision::NoPublish => {}
- DetectionPublishDecision::Publish {
- state: new_state,
- visible_blocker,
- visible_working,
- process_exited: publish_process_exited,
- } => {
- apply_agent_detection_publish_update(
- state_events.clone(),
- pane_id,
- agent,
- AgentDetectionPublishUpdate {
- state: new_state,
- visible_blocker,
- visible_working,
- process_exited: publish_process_exited,
- },
- now,
- &mut state,
- &mut last_visible_blocker,
- &mut last_visible_working,
- &mut last_visible_signal_refresh,
- &mut foreground_shell_exit_reported,
- )
- .await;
- }
- }
- continue;
- }
- DetectionScreenReadDecision::Publish {
- state: new_state,
- visible_blocker,
- visible_working,
- process_exited: publish_process_exited,
- } => {
- apply_agent_detection_publish_update(
- state_events.clone(),
- pane_id,
- agent,
- AgentDetectionPublishUpdate {
- state: new_state,
- visible_blocker,
- visible_working,
- process_exited: publish_process_exited,
- },
- now,
- &mut state,
- &mut last_visible_blocker,
- &mut last_visible_working,
- &mut last_visible_signal_refresh,
- &mut foreground_shell_exit_reported,
- )
- .await;
- continue;
- }
}
let content = terminal.detection_text();
@@ -740,16 +683,15 @@ fn spawn_basic_detection_task(
};
match decide_screen_detection_publish(
ScreenDetectionPublishInput {
- agent,
screen_detection,
current_state: state,
+ last_visible_idle,
last_visible_blocker,
last_visible_working,
last_visible_signal_refresh,
process_exited,
agent_changed,
pty_activity,
- content: &content,
now,
},
&mut pending_idle,
@@ -759,6 +701,7 @@ fn spawn_basic_detection_task(
DetectionPublishDecision::NoPublish => {}
DetectionPublishDecision::Publish {
state: new_state,
+ visible_idle,
visible_blocker,
visible_working,
process_exited: publish_process_exited,
@@ -769,12 +712,14 @@ fn spawn_basic_detection_task(
agent,
AgentDetectionPublishUpdate {
state: new_state,
+ visible_idle,
visible_blocker,
visible_working,
process_exited: publish_process_exited,
},
now,
&mut state,
+ &mut last_visible_idle,
&mut last_visible_blocker,
&mut last_visible_working,
&mut last_visible_signal_refresh,
@@ -854,6 +799,7 @@ pub struct PaneRuntime {
child_wait_completed: Option<Arc<AtomicBool>>,
kitty_keyboard_flags: Arc<AtomicU16>,
input_write_seq: Arc<AtomicU64>,
+ full_lifecycle_authority_active: Arc<AtomicBool>,
detect_reset_notify: Arc<Notify>,
pending_release: Arc<Mutex<Option<PendingAgentRelease>>>,
preserve_processes_on_drop: bool,
@@ -1658,12 +1604,14 @@ impl PaneRuntime {
})?)
};
+ let full_lifecycle_authority_active = Arc::new(AtomicBool::new(false));
let (detect_handle, detect_reset_notify, pending_release) = spawn_basic_detection_task(
pane_id,
child_pid.clone(),
terminal.clone(),
pty_output_seq,
input_write_seq.clone(),
+ full_lifecycle_authority_active.clone(),
events,
);
@@ -1677,6 +1625,7 @@ impl PaneRuntime {
child_wait_completed: None,
kitty_keyboard_flags,
input_write_seq,
+ full_lifecycle_authority_active,
detect_reset_notify,
pending_release,
preserve_processes_on_drop: true,
@@ -1727,6 +1676,7 @@ impl PaneRuntime {
let child_wait_completed = Arc::new(AtomicBool::new(false));
let input_write_seq = Arc::new(AtomicU64::new(0));
let pty_output_seq = Arc::new(AtomicU64::new(0));
+ let full_lifecycle_authority_active = Arc::new(AtomicBool::new(false));
{
let child_pid = child_pid.clone();
let child_wait_completed = child_wait_completed.clone();
@@ -1823,6 +1773,7 @@ impl PaneRuntime {
let state_events = events.clone();
let pty_output_seq = pty_output_seq.clone();
let input_write_seq_for_task = input_write_seq.clone();
+ let full_lifecycle_authority_active_for_task = full_lifecycle_authority_active.clone();
let render_notify = render_notify.clone();
let render_dirty = render_dirty.clone();
let detect_reset_notify = Arc::new(Notify::new());
@@ -1834,6 +1785,7 @@ impl PaneRuntime {
let mut agent_presence =
AgentDetectionPresence::from_agent(initial_state.detected_agent);
let mut state = AgentState::Idle;
+ let mut last_visible_idle = initial_state.detected_agent.is_some();
let mut last_process_check = Instant::now();
let mut last_foreground_pgid = None;
let mut has_process_probe = false;
@@ -1877,6 +1829,7 @@ impl PaneRuntime {
_ = detect_reset.notified() => {
agent_presence = AgentDetectionPresence::from_agent(None);
state = AgentState::Unknown;
+ last_visible_idle = false;
last_foreground_pgid = None;
has_process_probe = false;
acquisition_started_at = None;
@@ -2001,6 +1954,7 @@ impl PaneRuntime {
input_write_seq_for_task.load(Ordering::Relaxed),
);
state = AgentState::Idle;
+ last_visible_idle = true;
last_visible_blocker = false;
last_visible_working = false;
last_visible_signal_refresh = None;
@@ -2055,6 +2009,15 @@ impl PaneRuntime {
&& agent.is_some()
&& !foreground_shell_exit_reported;
+ if full_lifecycle_authority_active_for_task.load(Ordering::Acquire)
+ && !process_exited
+ {
+ pending_idle.clear();
+ pending_working.clear();
+ post_taint_working.clear();
+ continue;
+ }
+
if let Some(until) = agent_startup_grace_until {
if process_exited {
agent_startup_grace_until = None;
@@ -2105,76 +2068,6 @@ impl PaneRuntime {
}) {
DetectionScreenReadDecision::Read => {}
DetectionScreenReadDecision::Skip => continue,
- DetectionScreenReadDecision::EvaluatePtyWorking => {
- match decide_pty_working_publish_without_screen(
- PtyWorkingPublishInput {
- agent,
- current_state: state,
- last_visible_blocker,
- last_visible_working,
- last_visible_signal_refresh,
- pty_activity,
- now,
- },
- &mut pending_idle,
- &mut pending_working,
- &mut post_taint_working,
- ) {
- DetectionPublishDecision::NoPublish => {}
- DetectionPublishDecision::Publish {
- state: new_state,
- visible_blocker,
- visible_working,
- process_exited: publish_process_exited,
- } => {
- apply_agent_detection_publish_update(
- state_events.clone(),
- pane_id,
- agent,
- AgentDetectionPublishUpdate {
- state: new_state,
- visible_blocker,
- visible_working,
- process_exited: publish_process_exited,
- },
- now,
- &mut state,
- &mut last_visible_blocker,
- &mut last_visible_working,
- &mut last_visible_signal_refresh,
- &mut foreground_shell_exit_reported,
- )
- .await;
- }
- }
- continue;
- }
- DetectionScreenReadDecision::Publish {
- state: new_state,
- visible_blocker,
- visible_working,
- process_exited: publish_process_exited,
- } => {
- apply_agent_detection_publish_update(
- state_events.clone(),
- pane_id,
- agent,
- AgentDetectionPublishUpdate {
- state: new_state,
- visible_blocker,
- visible_working,
- process_exited: publish_process_exited,
- },
- now,
- &mut state,
- &mut last_visible_blocker,
- &mut last_visible_working,
- &mut last_visible_signal_refresh,
- &mut foreground_shell_exit_reported,
- )
- .await;
- continue;
- }
}
let content = terminal.detection_text();
@@ -2223,16 +2116,15 @@ impl PaneRuntime {
};
match decide_screen_detection_publish(
ScreenDetectionPublishInput {
- agent,
screen_detection,
current_state: state,
+ last_visible_idle,
last_visible_blocker,
last_visible_working,
last_visible_signal_refresh,
process_exited,
agent_changed,
pty_activity,
- content: &content,
now,
},
&mut pending_idle,
@@ -2242,6 +2134,7 @@ impl PaneRuntime {
DetectionPublishDecision::NoPublish => {}
DetectionPublishDecision::Publish {
state: new_state,
+ visible_idle,
visible_blocker,
visible_working,
process_exited: publish_process_exited,
@@ -2252,12 +2145,14 @@ impl PaneRuntime {
agent,
AgentDetectionPublishUpdate {
state: new_state,
+ visible_idle,
visible_blocker,
visible_working,
process_exited: publish_process_exited,
},
now,
&mut state,
+ &mut last_visible_idle,
&mut last_visible_blocker,
&mut last_visible_working,
&mut last_visible_signal_refresh,
@@ -2281,6 +2176,7 @@ impl PaneRuntime {
child_wait_completed: Some(child_wait_completed),
kitty_keyboard_flags,
input_write_seq,
+ full_lifecycle_authority_active,
detect_reset_notify,
pending_release,
preserve_processes_on_drop: false,
@@ -2298,6 +2194,23 @@ impl PaneRuntime {
self.detect_reset_notify.notify_one();
}
+ pub fn reset_agent_detection(&self) {
+ self.detect_reset_notify.notify_one();
+ }
+
+ #[cfg(test)]
+ pub(crate) fn agent_detection_reset_notify_for_test(&self) -> Arc<Notify> {
+ self.detect_reset_notify.clone()
+ }
+
+ pub fn set_full_lifecycle_authority_active(&self, active: bool) {
+ self.full_lifecycle_authority_active
+ .store(active, Ordering::Release);
+ if active {
+ self.detect_reset_notify.notify_one();
+ }
+ }
+
pub(crate) fn current_size(&self) -> (u16, u16) {
let (rows, cols, _, _) = self.current_size.get();
(rows, cols)
@@ -2389,6 +2302,10 @@ impl PaneRuntime {
self.terminal.visible_ansi()
}
+ pub fn detection_text(&self) -> String {
+ self.terminal.detection_text()
+ }
+
pub fn recent_text(&self, lines: usize) -> String {
self.terminal.recent_text(lines)
}
@@ -2665,6 +2582,7 @@ impl PaneRuntime {
child_wait_completed: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
input_write_seq: Arc::new(AtomicU64::new(0)),
+ full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)),
detect_reset_notify: Arc::new(Notify::new()),
pending_release: Arc::new(Mutex::new(None)),
preserve_processes_on_drop: true,
@@ -3038,6 +2956,7 @@ mod tests {
child_wait_completed: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
input_write_seq: Arc::new(AtomicU64::new(0)),
+ full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)),
detect_reset_notify: Arc::new(Notify::new()),
pending_release: Arc::new(Mutex::new(None)),
preserve_processes_on_drop: true,
@@ -3068,6 +2987,7 @@ mod tests {
child_wait_completed: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
input_write_seq: Arc::new(AtomicU64::new(0)),
+ full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)),
detect_reset_notify: Arc::new(Notify::new()),
pending_release: Arc::new(Mutex::new(None)),
preserve_processes_on_drop: true,
diff --git a/src/pane/agent_detection.rs b/src/pane/agent_detection.rs
index 53e2ea31..e6afd368 100644
--- a/src/pane/agent_detection.rs
+++ b/src/pane/agent_detection.rs
@@ -6,7 +6,6 @@ pub(super) const AGENT_PTY_ACTIVITY_WINDOW: std::time::Duration =
std::time::Duration::from_millis(1800);
pub(super) const AGENT_INPUT_TAINT_WINDOW: std::time::Duration =
std::time::Duration::from_millis(1200);
-pub(super) const AGENT_POST_TAINT_WORKING_LEASE: std::time::Duration = AGENT_PTY_ACTIVITY_WINDOW;
pub(super) const AGENT_PENDING_IDLE_RECHECK: std::time::Duration =
std::time::Duration::from_millis(100);
const AGENT_PENDING_IDLE_CONFIRMATIONS: u8 = 3;
@@ -29,6 +28,7 @@ pub(super) const AGENT_STARTUP_GRACE_WINDOW: std::time::Duration =
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct DetectionPublishState {
pub(super) state: AgentState,
+ pub(super) visible_idle: bool,
pub(super) visible_blocker: bool,
pub(super) visible_working: bool,
}
@@ -55,11 +55,12 @@ impl PendingIdleConfirmation {
next: DetectionPublishState,
agent_changed: bool,
process_exited: bool,
- pty_signal: Option<PtyActivitySignal>,
+ _pty_signal: Option<PtyActivitySignal>,
now: std::time::Instant,
) -> bool {
let is_working_to_plain_idle = previous.state == AgentState::Working
&& next.state == AgentState::Idle
+ && !next.visible_idle
&& !next.visible_blocker
&& !agent_changed
&& !process_exited;
@@ -69,11 +70,6 @@ impl PendingIdleConfirmation {
return false;
}
- if pty_signal.is_some_and(|signal| !signal.active && !signal.tainted) {
- self.clear();
- return false;
- }
-
let Some(started_at) = self.started_at else {
self.started_at = Some(now);
self.confirmations = 0;
@@ -142,6 +138,11 @@ impl PendingWorkingConfirmation {
return false;
}
+ if next.visible_working {
+ self.clear();
+ return false;
+ }
+
let Some(pty_signal) = pty_signal else {
self.clear();
return false;
@@ -202,57 +203,19 @@ impl PostTaintWorkingLease {
self.until.is_some()
}
- pub(super) fn start(&mut self, now: std::time::Instant) {
- self.until = Some(now + AGENT_POST_TAINT_WORKING_LEASE);
- }
-
pub(super) fn clear(&mut self) {
self.until = None;
}
pub(super) fn should_hold_working_to_idle(
&mut self,
- previous: DetectionPublishState,
- next: DetectionPublishState,
- agent_changed: bool,
- process_exited: bool,
- pty_signal: Option<PtyActivitySignal>,
- now: std::time::Instant,
+ _previous: DetectionPublishState,
+ _next: DetectionPublishState,
+ _agent_changed: bool,
+ _process_exited: bool,
+ _pty_signal: Option<PtyActivitySignal>,
+ _now: std::time::Instant,
) -> bool {
- let is_working_to_plain_idle = previous.state == AgentState::Working
- && next.state == AgentState::Idle
- && !next.visible_blocker
- && !agent_changed
- && !process_exited;
-
- if !is_working_to_plain_idle {
- self.clear();
- return false;
- }
-
- let Some(pty_signal) = pty_signal else {
- self.clear();
- return false;
- };
-
- if pty_signal.active || pty_signal.tainted {
- self.clear();
- return false;
- }
-
- if pty_signal.taint_just_ended {
- self.start(now);
- return true;
- }
-
- let Some(until) = self.until else {
- return false;
- };
-
- if now < until {
- return true;
- }
-
self.clear();
false
}
@@ -297,13 +260,6 @@ pub(super) fn should_skip_idle_screen_scan(input: IdleScreenScanSkipInput) -> bo
pub(super) enum DetectionScreenReadDecision {
Read,
Skip,
- EvaluatePtyWorking,
- Publish {
- state: AgentState,
- visible_blocker: bool,
- visible_working: bool,
- process_exited: bool,
- },
}
#[derive(Debug, Clone, Copy)]
@@ -319,42 +275,9 @@ pub(super) struct DetectionScreenReadInput {
pub(super) last_screen_scan_pty_output_seq: Option<u64>,
}
-fn agent_activity_veto_requires_screen(agent: Option<Agent>) -> bool {
- matches!(agent, Some(Agent::Claude))
-}
-
pub(super) fn decide_detection_screen_read(
input: DetectionScreenReadInput,
) -> DetectionScreenReadDecision {
- if !input.agent_changed
- && !input.process_exited
- && !input.pending_idle_active
- && !input.post_taint_working_active
- && input.agent.is_some()
- && input
- .pty_activity
- .is_some_and(|signal| signal.active && !signal.tainted)
- {
- return match input.state {
- AgentState::Working => DetectionScreenReadDecision::Skip,
- AgentState::Blocked => DetectionScreenReadDecision::Publish {
- state: AgentState::Working,
- visible_blocker: false,
- visible_working: false,
- process_exited: false,
- },
- AgentState::Idle | AgentState::Unknown
- if agent_activity_veto_requires_screen(input.agent)
- && !input.pending_working_active =>
- {
- DetectionScreenReadDecision::Read
- }
- AgentState::Idle | AgentState::Unknown => {
- DetectionScreenReadDecision::EvaluatePtyWorking
- }
- };
- }
-
if should_skip_idle_screen_scan(IdleScreenScanSkipInput {
state: input.state,
agent: input.agent,
@@ -372,18 +295,6 @@ pub(super) fn decide_detection_screen_read(
}
}
-pub(super) fn pty_working_transition_is_vetoed(
- agent: Option<Agent>,
- previous: DetectionPublishState,
- next: DetectionPublishState,
- content: &str,
-) -> bool {
- previous.state == AgentState::Idle
- && next.state == AgentState::Working
- && !next.visible_blocker
- && crate::detect::agent_activity_veto(agent, content).is_some()
-}
-
pub(super) fn should_publish_detection_update(
previous: DetectionPublishState,
next: DetectionPublishState,
@@ -392,6 +303,7 @@ pub(super) fn should_publish_detection_update(
stable_visible_signal_refresh_due: bool,
) -> bool {
next.state != previous.state
+ || next.visible_idle != previous.visible_idle
|| next.visible_blocker != previous.visible_blocker
|| next.visible_working != previous.visible_working
|| agent_changed
@@ -421,36 +333,22 @@ pub(super) enum DetectionTransitionDecision {
}
#[derive(Debug, Clone, Copy)]
-pub(super) struct DetectionTransitionInput<'a> {
- pub(super) agent: Option<Agent>,
+pub(super) struct DetectionTransitionInput {
pub(super) previous_publish: DetectionPublishState,
pub(super) next_publish: DetectionPublishState,
pub(super) agent_changed: bool,
pub(super) process_exited: bool,
pub(super) pty_activity: Option<PtyActivitySignal>,
pub(super) stable_refresh_due: bool,
- pub(super) content: &'a str,
pub(super) now: std::time::Instant,
}
pub(super) fn decide_detection_transition(
- input: DetectionTransitionInput<'_>,
+ input: DetectionTransitionInput,
pending_idle: &mut PendingIdleConfirmation,
pending_working: &mut PendingWorkingConfirmation,
post_taint_working: &mut PostTaintWorkingLease,
) -> DetectionTransitionDecision {
- if pty_working_transition_is_vetoed(
- input.agent,
- input.previous_publish,
- input.next_publish,
- input.content,
- ) {
- pending_idle.clear();
- pending_working.clear();
- post_taint_working.clear();
- return DetectionTransitionDecision::NoPublish;
- }
-
if pending_working.should_publish_held_working_before_exit(
input.previous_publish,
input.next_publish,
@@ -518,6 +416,7 @@ pub(super) enum DetectionPublishDecision {
NoPublish,
Publish {
state: AgentState,
+ visible_idle: bool,
visible_blocker: bool,
visible_working: bool,
process_exited: bool,
@@ -525,9 +424,9 @@ pub(super) enum DetectionPublishDecision {
}
#[derive(Debug, Clone, Copy)]
-pub(super) struct ScreenDetectionPublishInput<'a> {
- pub(super) agent: Option<Agent>,
+pub(super) struct ScreenDetectionPublishInput {
pub(super) current_state: AgentState,
+ pub(super) last_visible_idle: bool,
pub(super) last_visible_blocker: bool,
pub(super) last_visible_working: bool,
pub(super) last_visible_signal_refresh: Option<std::time::Instant>,
@@ -535,118 +434,40 @@ pub(super) struct ScreenDetectionPublishInput<'a> {
pub(super) process_exited: bool,
pub(super) agent_changed: bool,
pub(super) pty_activity: Option<PtyActivitySignal>,
- pub(super) content: &'a str,
- pub(super) now: std::time::Instant,
-}
-
-#[derive(Debug, Clone, Copy)]
-pub(super) struct PtyWorkingPublishInput {
- pub(super) agent: Option<Agent>,
- pub(super) current_state: AgentState,
- pub(super) last_visible_blocker: bool,
- pub(super) last_visible_working: bool,
- pub(super) last_visible_signal_refresh: Option<std::time::Instant>,
- pub(super) pty_activity: Option<PtyActivitySignal>,
pub(super) now: std::time::Instant,
}
-pub(super) fn decide_pty_working_publish_without_screen(
- input: PtyWorkingPublishInput,
- pending_idle: &mut PendingIdleConfirmation,
- pending_working: &mut PendingWorkingConfirmation,
- post_taint_working: &mut PostTaintWorkingLease,
-) -> DetectionPublishDecision {
- let previous_publish = DetectionPublishState {
- state: input.current_state,
- visible_blocker: input.last_visible_blocker,
- visible_working: input.last_visible_working,
- };
- let next_publish = DetectionPublishState {
- state: AgentState::Working,
- visible_blocker: false,
- visible_working: false,
- };
- let stable_refresh_due = stable_visible_signal_refresh_due(
- previous_publish,
- next_publish,
- input.last_visible_signal_refresh,
- input.now,
- );
-
- match decide_detection_transition(
- DetectionTransitionInput {
- agent: input.agent,
- previous_publish,
- next_publish,
- agent_changed: false,
- process_exited: false,
- pty_activity: input.pty_activity,
- stable_refresh_due,
- content: "",
- now: input.now,
- },
- pending_idle,
- pending_working,
- post_taint_working,
- ) {
- DetectionTransitionDecision::NoPublish => DetectionPublishDecision::NoPublish,
- DetectionTransitionDecision::PublishHeldWorkingBeforeExit => {
- DetectionPublishDecision::Publish {
- state: AgentState::Working,
- visible_blocker: false,
- visible_working: false,
- process_exited: false,
- }
- }
- DetectionTransitionDecision::PublishNext => DetectionPublishDecision::Publish {
- state: AgentState::Working,
- visible_blocker: false,
- visible_working: false,
- process_exited: false,
- },
- }
-}
-
pub(super) fn decide_screen_detection_publish(
- input: ScreenDetectionPublishInput<'_>,
+ input: ScreenDetectionPublishInput,
pending_idle: &mut PendingIdleConfirmation,
pending_working: &mut PendingWorkingConfirmation,
post_taint_working: &mut PostTaintWorkingLease,
) -> DetectionPublishDecision {
- let pty_signal = input
- .pty_activity
- .map(|signal| crate::agent_detection_policy::PtySignal {
- active: signal.active,
- tainted: signal.tainted,
- });
- let detection = match crate::agent_detection_policy::apply_detection_policy(
- crate::agent_detection_policy::DetectionPolicyInput {
- agent: input.agent,
- screen_detection: input.screen_detection,
- process_exited: input.process_exited,
- startup_grace_active: false,
- pty_signal,
- },
- ) {
- crate::agent_detection_policy::DetectionPolicyDecision::Publish(detection) => detection,
- crate::agent_detection_policy::DetectionPolicyDecision::Freeze => {
- pending_idle.clear();
- pending_working.clear();
- post_taint_working.clear();
- return DetectionPublishDecision::NoPublish;
- }
- };
+ let detection = input.screen_detection;
let new_state = crate::terminal::state::stabilize_agent_detection(detection);
+ let visible_idle = detection.visible_idle && new_state == AgentState::Idle;
let visible_blocker = detection.visible_blocker && new_state == AgentState::Blocked;
let visible_working = detection.visible_working && new_state == AgentState::Working;
+ if input.pty_activity.is_some_and(|signal| signal.tainted)
+ && new_state == AgentState::Idle
+ && !input.process_exited
+ {
+ pending_idle.clear();
+ pending_working.clear();
+ post_taint_working.clear();
+ return DetectionPublishDecision::NoPublish;
+ }
+
let previous_publish = DetectionPublishState {
state: input.current_state,
+ visible_idle: input.last_visible_idle,
visible_blocker: input.last_visible_blocker,
visible_working: input.last_visible_working,
};
let next_publish = DetectionPublishState {
state: new_state,
+ visible_idle,
visible_blocker,
visible_working,
};
@@ -659,14 +480,12 @@ pub(super) fn decide_screen_detection_publish(
match decide_detection_transition(
DetectionTransitionInput {
- agent: input.agent,
previous_publish,
next_publish,
agent_changed: input.agent_changed,
process_exited: input.process_exited,
pty_activity: input.pty_activity,
stable_refresh_due,
- content: input.content,
now: input.now,
},
pending_idle,
@@ -677,6 +496,7 @@ pub(super) fn decide_screen_detection_publish(
DetectionTransitionDecision::PublishHeldWorkingBeforeExit => {
DetectionPublishDecision::Publish {
state: AgentState::Working,
+ visible_idle: false,
visible_blocker: false,
visible_working: false,
process_exited: false,
@@ -684,6 +504,7 @@ pub(super) fn decide_screen_detection_publish(
}
DetectionTransitionDecision::PublishNext => DetectionPublishDecision::Publish {
state: new_state,
+ visible_idle,
visible_blocker,
visible_working,
process_exited: input.process_exited,
@@ -696,14 +517,11 @@ pub(super) fn detection_update_for_publish(
content: &str,
process_exited: bool,
) -> Option<crate::detect::AgentDetection> {
- if crate::detect::should_skip_state_update(agent, content) {
- return None;
- }
-
if process_exited {
return Some(crate::detect::AgentDetection {
state: AgentState::Idle,
skip_state_update: false,
+ visible_idle: true,
visible_blocker: false,
visible_working: false,
});
@@ -811,17 +629,14 @@ fn consume_skipped_pty_causality(
pub(super) fn handle_skipped_detection_update(
state: AgentState,
- pty_signal: Option<PtyActivitySignal>,
+ _pty_signal: Option<PtyActivitySignal>,
post_taint_working: &mut PostTaintWorkingLease,
tracker: &mut PtyCausalityTracker,
pty_output_seq: u64,
input_seq: u64,
- now: std::time::Instant,
+ _now: std::time::Instant,
) {
if state == AgentState::Working {
- if pty_signal.is_some_and(|signal| signal.taint_just_ended) {
- post_taint_working.start(now);
- }
return;
}
@@ -836,6 +651,7 @@ mod tests {
fn publish_state(state: AgentState) -> DetectionPublishState {
DetectionPublishState {
state,
+ visible_idle: false,
visible_blocker: false,
visible_working: false,
}
@@ -876,16 +692,14 @@ mod tests {
next_publish: DetectionPublishState,
pty_activity: Option<PtyActivitySignal>,
now: std::time::Instant,
- ) -> DetectionTransitionInput<'static> {
+ ) -> DetectionTransitionInput {
DetectionTransitionInput {
- agent: Some(Agent::Codex),
previous_publish,
next_publish,
agent_changed: false,
process_exited: false,
pty_activity,
stable_refresh_due: false,
- content: "",
now,
}
}
@@ -894,6 +708,7 @@ mod tests {
AgentDetection {
state,
skip_state_update: false,
+ visible_idle: state == AgentState::Idle,
visible_blocker: false,
visible_working: state == AgentState::Working,
}
@@ -904,10 +719,10 @@ mod tests {
screen_detection: AgentDetection,
pty_activity: Option<PtyActivitySignal>,
now: std::time::Instant,
- ) -> ScreenDetectionPublishInput<'static> {
+ ) -> ScreenDetectionPublishInput {
ScreenDetectionPublishInput {
- agent: Some(Agent::Codex),
current_state,
+ last_visible_idle: false,
last_visible_blocker: false,
last_visible_working: false,
last_visible_signal_refresh: None,
@@ -915,7 +730,6 @@ mod tests {
process_exited: false,
agent_changed: false,
pty_activity,
- content: "",
now,
}
}
@@ -967,7 +781,7 @@ mod tests {
AgentState::Idle,
pty_activity(true, true, 10),
)),
- DetectionScreenReadDecision::EvaluatePtyWorking
+ DetectionScreenReadDecision::Read
);
assert_eq!(
decide_detection_screen_read(screen_read_input(
@@ -979,13 +793,13 @@ mod tests {
}
#[test]
- fn screen_read_decision_handles_active_pty_without_screen_for_non_idle_states() {
+ fn screen_read_decision_reads_screen_for_active_pty() {
assert_eq!(
decide_detection_screen_read(screen_read_input(
AgentState::Working,
pty_activity(true, true, 11),
)),
- DetectionScreenReadDecision::Skip
+ DetectionScreenReadDecision::Read
);
assert_eq!(
@@ -993,12 +807,7 @@ mod tests {
AgentState::Blocked,
pty_activity(true, true, 11),
)),
- DetectionScreenReadDecision::Publish {
- state: AgentState::Working,
- visible_blocker: false,
- visible_working: false,
- process_exited: false,
- }
+ DetectionScreenReadDecision::Read
);
assert_eq!(
@@ -1006,7 +815,7 @@ mod tests {
AgentState::Idle,
pty_activity(true, true, 11),
)),
- DetectionScreenReadDecision::EvaluatePtyWorking
+ DetectionScreenReadDecision::Read
);
assert_eq!(
@@ -1044,12 +853,12 @@ mod tests {
}
#[test]
- fn screen_read_decision_keeps_active_pty_pending_working_screenless() {
+ fn screen_read_decision_reads_during_active_pty_pending_working() {
let mut input = screen_read_input(AgentState::Idle, pty_activity(true, true, 11));
input.pending_working_active = true;
assert_eq!(
decide_detection_screen_read(input),
- DetectionScreenReadDecision::EvaluatePtyWorking
+ DetectionScreenReadDecision::Read
);
let mut input = screen_read_input_for_agent(
@@ -1060,7 +869,7 @@ mod tests {
input.pending_working_active = true;
assert_eq!(
decide_detection_screen_read(input),
- DetectionScreenReadDecision::EvaluatePtyWorking
+ DetectionScreenReadDecision::Read
);
}
@@ -1072,10 +881,14 @@ mod tests {
}
#[test]
- fn codex_transcript_viewer_suppresses_process_exit_idle_publish() {
+ fn process_exit_overrides_transcript_viewer_skip() {
let content = "/ T R A N S C R I P T /\n\n› yeah go ahead\n────────────────────────────────────────────────────────────────────────────────── 100% ─\n ↑/↓ to scroll pgup/pgdn to page home/end to jump\n q to quit esc to edit prev";
- assert!(detection_update_for_publish(Some(Agent::Codex), content, true).is_none());
+ let detection = detection_update_for_publish(Some(Agent::Codex), content, true)
+ .expect("process exit should publish idle even inside transcript viewer");
+ assert_eq!(detection.state, AgentState::Idle);
+ assert!(detection.visible_idle);
+ assert!(!detection.skip_state_update);
}
#[test]
@@ -1093,6 +906,7 @@ mod tests {
let now = std::time::Instant::now();
let previous = DetectionPublishState {
state: AgentState::Idle,
+ visible_idle: false,
visible_blocker: false,
visible_working: false,
};
@@ -1117,6 +931,7 @@ mod tests {
let now = std::time::Instant::now();
let previous = DetectionPublishState {
state: AgentState::Working,
+ visible_idle: false,
visible_blocker: false,
visible_working: true,
};
@@ -1141,6 +956,7 @@ mod tests {
let now = std::time::Instant::now();
let previous = DetectionPublishState {
state: AgentState::Blocked,
+ visible_idle: false,
visible_blocker: true,
visible_working: false,
};
@@ -1233,13 +1049,13 @@ mod tests {
}
#[test]
- fn pending_idle_does_not_extend_pty_quiet_lease() {
+ fn pending_idle_holds_plain_idle_fallback_even_when_pty_is_quiet() {
let now = std::time::Instant::now();
let previous = publish_state(AgentState::Working);
let idle = publish_state(AgentState::Idle);
let mut pending = PendingIdleConfirmation::default();
- assert!(!pending.should_hold_working_to_idle(
+ assert!(pending.should_hold_working_to_idle(
previous,
idle,
false,
@@ -1247,17 +1063,17 @@ mod tests {
Some(pty_activity(false, false, 10)),
now
));
- assert!(!pending.active());
+ assert!(pending.active());
}
#[test]
- fn post_taint_lease_holds_existing_working_before_idle_fallback() {
+ fn post_taint_lease_never_holds_working_to_idle() {
let now = std::time::Instant::now();
let previous = publish_state(AgentState::Working);
let idle = publish_state(AgentState::Idle);
let mut lease = PostTaintWorkingLease::default();
- assert!(lease.should_hold_working_to_idle(
+ assert!(!lease.should_hold_working_to_idle(
previous,
idle,
false,
@@ -1265,22 +1081,7 @@ mod tests {
Some(pty_activity_after_taint(10)),
now
));
- assert!(lease.should_hold_working_to_idle(
- previous,
- idle,
- false,
- false,
- Some(pty_activity(false, false, 10)),
- now + AGENT_POST_TAINT_WORKING_LEASE - std::time::Duration::from_millis(1)
- ));
- assert!(!lease.should_hold_working_to_idle(
- previous,
- idle,
- false,
- false,
- Some(pty_activity(false, false, 10)),
- now + AGENT_POST_TAINT_WORKING_LEASE + std::time::Duration::from_millis(1)
- ));
+ assert!(!lease.active());
}
#[test]
@@ -1628,33 +1429,6 @@ mod tests {
}
#[test]
- fn claude_recap_veto_only_applies_to_idle_to_working() {
- let screen =
- "※ recap: Done. (disable recaps in /config)\n\n─────────────\n❯ \n─────────────";
- let idle = publish_state(AgentState::Idle);
- let working = publish_state(AgentState::Working);
-
- assert!(pty_working_transition_is_vetoed(
- Some(Agent::Claude),
- idle,
- working,
- screen
- ));
- assert!(!pty_working_transition_is_vetoed(
- Some(Agent::Claude),
- working,
- idle,
- screen
- ));
- assert!(!pty_working_transition_is_vetoed(
- Some(Agent::Codex),
- idle,
- working,
- screen
- ));
- }
-
- #[test]
fn pty_activity_outside_taint_reports_active_until_hold_expires() {
let now = std::time::Instant::now();
let mut tracker = PtyCausalityTracker::default();
@@ -1773,7 +1547,8 @@ mod tests {
let previous = publish_state(AgentState::Working);
let idle = publish_state(AgentState::Idle);
- assert!(lease.should_hold_working_to_idle(
+ assert!(!lease.active());
+ assert!(!lease.should_hold_working_to_idle(
previous,
idle,
false,
@@ -1912,11 +1687,11 @@ mod tests {
),
DetectionTransitionDecision::NoPublish
);
- assert!(post_taint_working.active());
+ assert!(!post_taint_working.active());
}
#[test]
- fn screen_publish_prefers_active_pty_working_over_screen_blocker() {
+ fn screen_publish_keeps_visible_blocker_during_active_pty() {
let now = std::time::Instant::now();
let mut pending_idle = PendingIdleConfirmation::default();
let mut pending_working = PendingWorkingConfirmation::default();
@@ -1937,8 +1712,9 @@ mod tests {
&mut post_taint_working,
),
DetectionPublishDecision::Publish {
- state: AgentState::Working,
- visible_blocker: false,
+ state: AgentState::Blocked,
+ visible_idle: false,
+ visible_blocker: true,
visible_working: false,
process_exited: false,
}
@@ -1946,7 +1722,7 @@ mod tests {
}
#[test]
- fn screen_publish_downgrades_visible_working_to_idle_when_pty_is_quiet() {
+ fn screen_publish_keeps_visible_working_when_pty_is_quiet() {
let now = std::time::Instant::now();
let mut pending_idle = PendingIdleConfirmation::default();
let mut pending_working = PendingWorkingConfirmation::default();
@@ -1965,16 +1741,46 @@ mod tests {
&mut post_taint_working,
),
DetectionPublishDecision::Publish {
- state: AgentState::Idle,
+ state: AgentState::Working,
+ visible_idle: false,
visible_blocker: false,
- visible_working: false,
+ visible_working: true,
+ process_exited: false,
+ }
+ );
+ }
+
+ #[test]
+ fn screen_publish_keeps_visible_working_during_active_pty() {
+ let now = std::time::Instant::now();
+ let mut pending_idle = PendingIdleConfirmation::default();
+ let mut pending_working = PendingWorkingConfirmation::default();
+ let mut post_taint_working = PostTaintWorkingLease::default();
+
+ assert_eq!(
+ decide_screen_detection_publish(
+ screen_publish_input(
+ AgentState::Idle,
+ screen_detection(AgentState::Working),
+ Some(pty_activity(true, true, 10)),
+ now,
+ ),
+ &mut pending_idle,
+ &mut pending_working,
+ &mut post_taint_working,
+ ),
+ DetectionPublishDecision::Publish {
+ state: AgentState::Working,
+ visible_idle: false,
+ visible_blocker: false,
+ visible_working: true,
process_exited: false,
}
);
}
#[test]
- fn screen_publish_freezes_during_taint() {
+ fn screen_publish_freezes_idle_during_taint() {
let now = std::time::Instant::now();
let mut pending_idle = PendingIdleConfirmation::default();
let mut pending_working = PendingWorkingConfirmation::default();
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 806cce4b..faf43c10 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -3361,6 +3361,14 @@ impl HeadlessServer {
if self
.app
+ .next_agent_manifest_update_check
+ .is_some_and(|deadline| now >= deadline)
+ {
+ self.app.run_agent_manifest_update_check();
+ }
+
+ if self
+ .app
.session_save_deadline
.is_some_and(|deadline| now >= deadline)
{
@@ -4766,6 +4774,16 @@ next_tab = ""
}));
}
+ #[test]
+ fn headless_scheduled_tasks_clears_disabled_agent_manifest_update_deadline() {
+ let mut server = test_headless_server();
+ let now = Instant::now();
+ server.app.next_agent_manifest_update_check = Some(now - Duration::from_millis(1));
+
+ assert!(!server.handle_scheduled_tasks_headless(now, false));
+ assert_eq!(server.app.next_agent_manifest_update_check, None);
+ }
+
#[tokio::test]
async fn headless_scheduled_tasks_do_not_start_pending_agent_resume_when_geometry_dirty() {
let mut server = test_headless_server();
diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs
index 5d1ce1d7..727e9929 100644
--- a/src/terminal/runtime.rs
+++ b/src/terminal/runtime.rs
@@ -197,6 +197,21 @@ impl TerminalRuntime {
self.0.begin_graceful_release(agent);
}
+ pub fn reset_agent_detection(&self) {
+ self.0.reset_agent_detection();
+ }
+
+ #[cfg(test)]
+ pub(crate) fn agent_detection_reset_notify_for_test(
+ &self,
+ ) -> std::sync::Arc<tokio::sync::Notify> {
+ self.0.agent_detection_reset_notify_for_test()
+ }
+
+ pub fn set_full_lifecycle_authority_active(&self, active: bool) {
+ self.0.set_full_lifecycle_authority_active(active);
+ }
+
pub fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) {
self.0.resize(rows, cols, cell_width_px, cell_height_px);
}
@@ -250,6 +265,10 @@ impl TerminalRuntime {
self.0.visible_ansi()
}
+ pub fn detection_text(&self) -> String {
+ self.0.detection_text()
+ }
+
pub fn recent_text(&self, lines: usize) -> String {
self.0.recent_text(lines)
}
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index 74b7973d..25a48e5f 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -31,6 +31,7 @@ pub struct HookAuthority {
struct SuppressedFullLifecycleHookReport {
agent_label: String,
session_ref: Option<crate::agent_resume::AgentSessionRef>,
+ observed_at: Instant,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -173,7 +174,7 @@ impl TerminalState {
agent: Option<Agent>,
fallback_state: AgentState,
visible_blocker: bool,
- _ignored_screen_idle: bool,
+ _visible_idle: bool,
_visible_working: bool,
process_exited: bool,
now: Instant,
@@ -205,6 +206,19 @@ impl TerminalState {
!= self.current_session_identity_for_persistence(),
};
}
+ if !process_exited && self.detected_state_observed_before_release_suppression(agent, now) {
+ return TerminalStateMutation {
+ effective_state_change: self.recompute_effective_state(
+ previous_agent_label,
+ previous_known_agent,
+ previous_state,
+ previous_presentation,
+ now,
+ ),
+ session_ref_changed: previous_session
+ != self.current_session_identity_for_persistence(),
+ };
+ }
self.detected_agent = agent;
if !process_exited {
self.clear_full_lifecycle_hook_suppression_for_detected_agent(
@@ -448,7 +462,7 @@ impl TerminalState {
.hook_authority
.as_ref()
.filter(|authority| {
- crate::agent_detection_policy::full_lifecycle_hook_authority(
+ crate::detect::full_lifecycle_hook_authority(
&authority.source,
&authority.agent_label,
)
@@ -464,13 +478,14 @@ impl TerminalState {
SuppressedFullLifecycleHookReport {
agent_label,
session_ref,
+ observed_at: Instant::now(),
},
);
}
}
fn suppress_full_lifecycle_hook_report(&mut self, source: &str, agent_label: &str) {
- if crate::agent_detection_policy::full_lifecycle_hook_authority(source, agent_label) {
+ if crate::detect::full_lifecycle_hook_authority(source, agent_label) {
self.suppressed_full_lifecycle_hook_reports.insert(
source.to_string(),
SuppressedFullLifecycleHookReport {
@@ -479,6 +494,7 @@ impl TerminalState {
.hook_authority
.as_ref()
.and_then(|authority| authority.session_ref.clone()),
+ observed_at: Instant::now(),
},
);
}
@@ -490,7 +506,7 @@ impl TerminalState {
agent_label: &str,
session_ref: &Option<crate::agent_resume::AgentSessionRef>,
) -> bool {
- if !crate::agent_detection_policy::full_lifecycle_hook_authority(source, agent_label) {
+ if !crate::detect::full_lifecycle_hook_authority(source, agent_label) {
return false;
}
self.suppressed_full_lifecycle_hook_reports
@@ -517,10 +533,8 @@ impl TerminalState {
let Some(authority) = self.hook_authority.as_ref() else {
return false;
};
- if !crate::agent_detection_policy::full_lifecycle_hook_authority(
- &authority.source,
- &authority.agent_label,
- ) {
+ if !crate::detect::full_lifecycle_hook_authority(&authority.source, &authority.agent_label)
+ {
return false;
}
if authority.source != source || authority.agent_label != agent_label {
@@ -550,6 +564,22 @@ impl TerminalState {
});
}
+ fn detected_state_observed_before_release_suppression(
+ &self,
+ detected_agent: Option<Agent>,
+ observed_at: Instant,
+ ) -> bool {
+ let Some(detected_agent) = detected_agent else {
+ return false;
+ };
+ self.suppressed_full_lifecycle_hook_reports
+ .values()
+ .any(|suppressed| {
+ crate::detect::parse_agent_label(&suppressed.agent_label) == Some(detected_agent)
+ && observed_at <= suppressed.observed_at
+ })
+ }
+
fn current_session_identity_for_persistence(
&self,
) -> Option<(
@@ -791,6 +821,10 @@ impl TerminalState {
self.detected_agent
}
+ pub fn full_lifecycle_hook_authority_active(&self) -> bool {
+ self.live_full_lifecycle_hook_authority()
+ }
+
fn visible_blocker_overrides_hook(&self) -> bool {
if self.live_full_lifecycle_hook_authority() {
return false;
@@ -806,10 +840,7 @@ impl TerminalState {
fn live_full_lifecycle_hook_authority(&self) -> bool {
self.hook_authority.as_ref().is_some_and(|authority| {
- crate::agent_detection_policy::full_lifecycle_hook_authority(
- &authority.source,
- &authority.agent_label,
- )
+ crate::detect::full_lifecycle_hook_authority(&authority.source, &authority.agent_label)
})
}
@@ -935,6 +966,7 @@ mod tests {
let detection = AgentDetection {
state: AgentState::Idle,
skip_state_update: false,
+ visible_idle: false,
visible_blocker: false,
visible_working: false,
};
@@ -1085,6 +1117,39 @@ mod tests {
}
#[test]
+ fn process_exit_clears_omp_full_lifecycle_hook_authority_without_known_agent() {
+ let now = Instant::now();
+ let mut terminal = test_terminal();
+ terminal.set_hook_authority_with_custom_status_at(
+ "herdr:omp".into(),
+ "omp".into(),
+ AgentState::Working,
+ None,
+ None,
+ None,
+ Some(10),
+ now,
+ );
+
+ let change = terminal.set_detected_state_with_screen_signals_at(
+ None,
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ true,
+ now + Duration::from_millis(1),
+ );
+
+ assert!(terminal.hook_authority.is_none());
+ assert_eq!(terminal.state, AgentState::Idle);
+ assert_eq!(
+ change.effective_state_change.unwrap().previous_state,
+ AgentState::Working
+ );
+ }
+
+ #[test]
fn late_full_lifecycle_hook_after_process_exit_does_not_reacquire_authority() {
let now = Instant::now();
let mut terminal = test_terminal();
@@ -1291,7 +1356,6 @@ mod tests {
#[test]
fn fresh_detected_process_allows_full_lifecycle_hook_after_suppression() {
- let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
terminal.set_hook_authority(
@@ -1302,6 +1366,7 @@ mod tests {
Some(20),
);
terminal.release_agent("herdr:pi", "pi", Some(21));
+ let now = Instant::now();
terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Pi),
@@ -1326,6 +1391,43 @@ mod tests {
}
#[test]
+ fn release_suppression_ignores_same_agent_idle_publish() {
+ let now = Instant::now();
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ terminal.set_hook_authority(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ Some(20),
+ );
+ terminal.release_agent("herdr:pi", "pi", Some(21));
+
+ let change = terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ false,
+ now,
+ );
+ let late = terminal.set_hook_authority(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ Some(22),
+ );
+
+ assert!(change.effective_state_change.is_none());
+ assert!(late.is_none());
+ assert_eq!(terminal.detected_agent, None);
+ assert_eq!(terminal.state, AgentState::Unknown);
+ }
+
+ #[test]
fn fresh_session_ref_allows_full_lifecycle_hook_after_suppression() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
diff --git a/src/ui/settings.rs b/src/ui/settings.rs
index b7104ea6..d17ba4ed 100644
--- a/src/ui/settings.rs
+++ b/src/ui/settings.rs
@@ -194,11 +194,13 @@ pub(crate) fn settings_primary_button_label(
}
pub(crate) fn settings_show_primary_action(app: &AppState) -> bool {
- app.settings.section != crate::app::state::SettingsSection::Integrations
- || app
+ match app.settings.section {
+ crate::app::state::SettingsSection::Integrations => app
.integration_recommendations
.iter()
- .any(crate::integration::IntegrationRecommendation::needs_install)
+ .any(crate::integration::IntegrationRecommendation::needs_install),
+ _ => true,
+ }
}
pub(crate) fn settings_button_rects(