Skip to main content

winx_code_agent/state/
turn_manifest.rs

1//! Data-driven turn detection: TOML rule manifests evaluated against the
2//! rendered screen plus OSC title/progress strings.
3//!
4//! This is a port of herdr's agent-detection engine
5//! (<https://github.com/herdrdev/herdr>, `src/detect/manifest.rs`, Apache-2.0;
6//! vendored at commit `0cbd1a5aa847ab767334938e3bc858c68e613d70`), trimmed to
7//! what winx needs: the bundled manifests in `agent_manifests/` (see the
8//! attribution README there), an optional local override directory, and the
9//! rule/region/gate evaluation semantics — no remote manifest updates.
10//!
11//! One deliberate deviation from herdr: when no rule matches, herdr assumes a
12//! known agent is *idle*; winx returns [`TurnState::Unknown`] instead so the
13//! caller's quiescence check (and any legacy recognizer fallback) stays the
14//! authority. herdr can afford the idle fallback because PTY activity is its
15//! working-state authority; in winx that role belongs to quiescence.
16//!
17//! Rule semantics (kept bit-compatible with herdr so manifests drop in
18//! unmodified):
19//!   * every `contains` needle (lowercased), `regex`, and `line_regex` in a
20//!     gate must match (AND); `line_regex` needs some line to match per
21//!     pattern; `any` needs at least one nested gate; `not` fails the gate if
22//!     any nested gate matches;
23//!   * rules are evaluated in file order and the highest-priority match wins
24//!     (first match kept on ties);
25//!   * a rule's `region` selects what text it sees: the whole screen, bottom /
26//!     top line windows, prompt-marker slices, the prompt box, or the OSC
27//!     title/progress strings.
28
29use std::collections::HashMap;
30use std::sync::{Mutex as StdMutex, OnceLock};
31use std::time::{Duration, Instant, SystemTime};
32
33use regex::Regex;
34use serde::Deserialize;
35
36use crate::state::turn::TurnState;
37
38/// The manifest engine feature level this port implements — mirrors herdr's
39/// `MANIFEST_ENGINE_VERSION` so bundled manifests declaring
40/// `min_engine_version` up to this value are accepted.
41pub const ENGINE_VERSION: u32 = 3;
42
43/// What the detector reads: the rendered, ANSI-stripped screen joined with
44/// `\n`, plus the latest OSC 0/2 title and OSC 9 progress payload captured
45/// from the raw PTY stream (empty strings when unavailable — behavior then
46/// matches a screen-only engine).
47#[derive(Debug, Clone, Copy)]
48pub struct DetectionInput<'a> {
49    pub screen: &'a str,
50    pub osc_title: &'a str,
51    pub osc_progress: &'a str,
52}
53
54/// Outcome of evaluating one manifest: the mapped turn state and, when a rule
55/// fired, which one (for the status footer / tracing).
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct Verdict {
58    pub state: TurnState,
59    /// `Some((rule_id, priority))` when a rule matched; `None` means no rule
60    /// fired and `state` is [`TurnState::Unknown`].
61    pub rule: Option<(&'static str, i32)>,
62}
63
64// --- manifest schema (TOML) --------------------------------------------------
65
66#[derive(Debug, Deserialize)]
67#[serde(deny_unknown_fields)]
68struct RawManifest {
69    id: String,
70    #[allow(dead_code)]
71    version: Option<String>,
72    min_engine_version: Option<u32>,
73    #[serde(rename = "updated_at")]
74    _updated_at: Option<String>,
75    #[serde(default)]
76    aliases: Vec<String>,
77    #[serde(default)]
78    rules: Vec<RawRule>,
79}
80
81// The four bools mirror herdr's manifest schema field-for-field so upstream
82// TOMLs drop in unmodified — collapsing them would break deserialization.
83#[allow(clippy::struct_excessive_bools)]
84#[derive(Debug, Deserialize)]
85#[serde(deny_unknown_fields)]
86struct RawRule {
87    id: String,
88    state: Option<RawState>,
89    #[serde(default)]
90    priority: i32,
91    #[serde(default = "default_region")]
92    region: String,
93    // Confidence metadata in herdr's arbitration; winx only needs the state,
94    // but the fields must parse so upstream manifests drop in unmodified.
95    #[serde(default)]
96    #[allow(dead_code)]
97    visible_idle: bool,
98    #[serde(default)]
99    #[allow(dead_code)]
100    visible_blocker: bool,
101    #[serde(default)]
102    #[allow(dead_code)]
103    visible_working: bool,
104    #[serde(default)]
105    skip_state_update: bool,
106    #[serde(default)]
107    all: Vec<RawGate>,
108    #[serde(default)]
109    any: Vec<RawGate>,
110    #[serde(default, rename = "not")]
111    not_gate: Vec<RawGate>,
112    #[serde(default)]
113    contains: Vec<String>,
114    #[serde(default)]
115    regex: Vec<String>,
116    #[serde(default)]
117    line_regex: Vec<String>,
118}
119
120#[derive(Debug, Deserialize, Clone)]
121#[serde(deny_unknown_fields)]
122struct RawGate {
123    #[serde(default)]
124    all: Vec<RawGate>,
125    #[serde(default)]
126    any: Vec<RawGate>,
127    #[serde(default, rename = "not")]
128    not_gate: Vec<RawGate>,
129    #[serde(default)]
130    contains: Vec<String>,
131    #[serde(default)]
132    regex: Vec<String>,
133    #[serde(default)]
134    line_regex: Vec<String>,
135}
136
137#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
138#[serde(rename_all = "snake_case")]
139enum RawState {
140    Idle,
141    Working,
142    Blocked,
143    Unknown,
144}
145
146impl From<RawState> for TurnState {
147    fn from(value: RawState) -> Self {
148        match value {
149            RawState::Idle => TurnState::AwaitingInput,
150            RawState::Working => TurnState::Busy,
151            RawState::Blocked => TurnState::AwaitingApproval,
152            RawState::Unknown => TurnState::Unknown,
153        }
154    }
155}
156
157fn default_region() -> String {
158    "whole_recent".to_string()
159}
160
161// --- compiled form -----------------------------------------------------------
162
163/// A parsed, validated, regex-compiled manifest ready for evaluation.
164#[derive(Debug)]
165pub struct CompiledManifest {
166    id: String,
167    aliases: Vec<String>,
168    rules: Vec<CompiledRule>,
169}
170
171#[derive(Debug)]
172struct CompiledRule {
173    id: String,
174    priority: i32,
175    region: String,
176    state: TurnState,
177    gate: CompiledGate,
178}
179
180#[derive(Debug)]
181struct CompiledGate {
182    all: Vec<CompiledGate>,
183    any: Vec<CompiledGate>,
184    not_gate: Vec<CompiledGate>,
185    /// Lowercased at compile time; matched against the lowercased region text.
186    contains: Vec<String>,
187    regex: Vec<Regex>,
188    line_regex: Vec<Regex>,
189}
190
191impl CompiledManifest {
192    /// The manifest's canonical id (`claude`, `codex`, `gemini`, ...).
193    pub fn id(&self) -> &str {
194        &self.id
195    }
196
197    /// Evaluate every rule against `input`; the highest-priority match wins
198    /// (first match kept on ties, matching herdr). No match → `Unknown`.
199    pub fn detect(&'static self, input: DetectionInput<'_>) -> Verdict {
200        let mut matched: Option<&'static CompiledRule> = None;
201        for rule in &self.rules {
202            let text = region(input, &rule.region);
203            if !gate_matches(&rule.gate, text, &text.to_lowercase()) {
204                continue;
205            }
206            match matched {
207                Some(previous) if previous.priority >= rule.priority => {}
208                _ => matched = Some(rule),
209            }
210        }
211        match matched {
212            Some(rule) => {
213                Verdict { state: rule.state, rule: Some((rule.id.as_str(), rule.priority)) }
214            }
215            None => Verdict { state: TurnState::Unknown, rule: None },
216        }
217    }
218}
219
220// --- bundled manifests + lookup ----------------------------------------------
221
222/// Every manifest shipped in the binary. Vendored from herdr — see
223/// `agent_manifests/README.md` for provenance and license.
224const BUNDLED_MANIFESTS: &[&str] = &[
225    include_str!("agent_manifests/amp.toml"),
226    include_str!("agent_manifests/antigravity.toml"),
227    include_str!("agent_manifests/claude.toml"),
228    include_str!("agent_manifests/cline.toml"),
229    include_str!("agent_manifests/codex.toml"),
230    include_str!("agent_manifests/cursor.toml"),
231    include_str!("agent_manifests/devin.toml"),
232    include_str!("agent_manifests/droid.toml"),
233    include_str!("agent_manifests/gemini.toml"),
234    include_str!("agent_manifests/github-copilot.toml"),
235    include_str!("agent_manifests/grok.toml"),
236    include_str!("agent_manifests/hermes.toml"),
237    include_str!("agent_manifests/kilo.toml"),
238    include_str!("agent_manifests/kimi.toml"),
239    include_str!("agent_manifests/kiro.toml"),
240    include_str!("agent_manifests/maki.toml"),
241    include_str!("agent_manifests/muse.toml"),
242    include_str!("agent_manifests/opencode.toml"),
243    include_str!("agent_manifests/pi.toml"),
244    include_str!("agent_manifests/qodercli.toml"),
245    include_str!("agent_manifests/qwen.toml"),
246];
247
248static MANIFESTS: OnceLock<Vec<CompiledManifest>> = OnceLock::new();
249
250/// All compiled bundled manifests, loading (once) on first use. A bundled
251/// manifest that fails to compile is logged and skipped rather than panicking —
252/// the caller then behaves as if that agent had no manifest (legacy
253/// recognizers / quiescence take over); the test suite asserts none actually
254/// fail. Local overrides are layered on top by [`manifest_for`], not here.
255pub fn manifests() -> &'static [CompiledManifest] {
256    MANIFESTS.get_or_init(|| {
257        BUNDLED_MANIFESTS
258            .iter()
259            .filter_map(|content| match compile_manifest(content) {
260                Ok(manifest) => Some(manifest),
261                Err(err) => {
262                    tracing::error!(error = %err, "skipping invalid bundled agent manifest");
263                    None
264                }
265            })
266            .collect()
267    })
268}
269
270/// Resolve a recognizer hint to a manifest by id or alias (case-insensitive):
271/// `claude`, `claude-code`, `codex`, `gemini`, `agy`, `cursor`, `opencode`,
272/// `copilot`, `grok`, ... When `WINX_AGENT_MANIFEST_DIR` holds a valid
273/// `<id>.toml` override it shadows the bundled manifest, and edits to that
274/// file are picked up while the process runs (see [`overridden_manifest`]) —
275/// a served long-lived winx can hot-fix detection without a restart.
276pub fn manifest_for(hint: &str) -> Option<&'static CompiledManifest> {
277    static LOOKUP: OnceLock<HashMap<String, usize>> = OnceLock::new();
278    let manifests = manifests();
279    let lookup = LOOKUP.get_or_init(|| {
280        let mut map = HashMap::new();
281        for (index, manifest) in manifests.iter().enumerate() {
282            map.entry(manifest.id.to_lowercase()).or_insert(index);
283            for alias in &manifest.aliases {
284                map.entry(alias.to_lowercase()).or_insert(index);
285            }
286        }
287        map
288    });
289    let bundled =
290        lookup.get(&hint.trim().to_lowercase()).and_then(|&index| manifests.get(index))?;
291    Some(overridden_manifest(&bundled.id).unwrap_or(bundled))
292}
293
294// --- hot-reloadable overrides -------------------------------------------------
295
296/// How long a cached override verdict stays fresh before the file's mtime is
297/// checked again. Bounds the stat cost on the `wait_for_turn` poll cadence
298/// while keeping manifest hot-fixes near-immediate.
299const OVERRIDE_RECHECK_INTERVAL: Duration = Duration::from_secs(2);
300
301/// Cached override state for one manifest id.
302struct OverrideSlot {
303    last_checked: Option<Instant>,
304    /// mtime of the last override file this slot reconciled against (`None` =
305    /// file absent). A failed compile still records the mtime — with the
306    /// previously compiled override kept — so a later fix (newer mtime)
307    /// recompiles.
308    mtime: Option<SystemTime>,
309    /// Compiled overrides are leaked to `'static` (the manifest API hands out
310    /// `&'static` references). Reloads are rare, operator-driven events, so
311    /// the leak is bounded by how often a human edits the file.
312    manifest: Option<&'static CompiledManifest>,
313}
314
315fn override_dir() -> Option<&'static std::path::Path> {
316    static DIR: OnceLock<Option<std::path::PathBuf>> = OnceLock::new();
317    DIR.get_or_init(|| std::env::var("WINX_AGENT_MANIFEST_DIR").ok().map(Into::into)).as_deref()
318}
319
320/// The active override for `id`, reconciling with disk when its recheck window
321/// elapsed. `None` means the bundled manifest is authoritative.
322fn overridden_manifest(id: &str) -> Option<&'static CompiledManifest> {
323    static SLOTS: OnceLock<StdMutex<HashMap<String, OverrideSlot>>> = OnceLock::new();
324    let dir = override_dir()?;
325    let mut slots = SLOTS
326        .get_or_init(|| StdMutex::new(HashMap::new()))
327        .lock()
328        .unwrap_or_else(std::sync::PoisonError::into_inner);
329    let slot = slots.entry(id.to_string()).or_insert(OverrideSlot {
330        last_checked: None,
331        mtime: None,
332        manifest: None,
333    });
334    if slot.last_checked.is_none_or(|at| at.elapsed() >= OVERRIDE_RECHECK_INTERVAL) {
335        refresh_override_slot(slot, &dir.join(format!("{id}.toml")), id);
336        slot.last_checked = Some(Instant::now());
337    }
338    slot.manifest
339}
340
341/// Reconcile one override slot with the file at `path`: absent → revert to
342/// bundled; unchanged mtime → keep; changed → recompile. A compile error or
343/// id mismatch keeps the previously compiled override rather than reverting
344/// detection to a state the operator already moved past.
345fn refresh_override_slot(slot: &mut OverrideSlot, path: &std::path::Path, id: &str) {
346    let Ok(mtime) = std::fs::metadata(path).and_then(|metadata| metadata.modified()) else {
347        if slot.manifest.is_some() {
348            tracing::info!(id, path = %path.display(), "agent manifest override removed; bundled manifest active again");
349        }
350        slot.mtime = None;
351        slot.manifest = None;
352        return;
353    };
354    if Some(mtime) == slot.mtime {
355        return;
356    }
357    slot.mtime = Some(mtime);
358    let compiled = std::fs::read_to_string(path)
359        .map_err(|err| err.to_string())
360        .and_then(|content| compile_manifest(&content));
361    match compiled {
362        Ok(manifest) if manifest.id == id => {
363            tracing::info!(id, path = %path.display(), "loaded agent manifest override");
364            slot.manifest = Some(Box::leak(Box::new(manifest)));
365        }
366        Ok(manifest) => {
367            tracing::warn!(
368                id, override_id = %manifest.id, path = %path.display(),
369                "ignoring agent manifest override: id mismatch"
370            );
371        }
372        Err(err) => {
373            tracing::warn!(
374                id, path = %path.display(), error = %err,
375                "ignoring agent manifest override: failed to compile"
376            );
377        }
378    }
379}
380
381// --- parsing + validation ----------------------------------------------------
382
383// Complexity caps ported from herdr: they bound override-supplied manifests so
384// a pathological file can't wedge the detector.
385const MAX_RULES_PER_MANIFEST: usize = 128;
386const MAX_GATE_DEPTH: usize = 8;
387const MAX_TOTAL_GATES: usize = 512;
388const MAX_MATCHERS_PER_GATE: usize = 32;
389const MAX_TOTAL_MATCHERS: usize = 1024;
390const MAX_MATCHER_CHARS: usize = 512;
391
392fn compile_manifest(content: &str) -> Result<CompiledManifest, String> {
393    let raw = toml::from_str::<RawManifest>(content).map_err(|err| err.to_string())?;
394    validate_manifest(&raw)?;
395    let rules = raw
396        .rules
397        .iter()
398        .map(|rule| {
399            compile_gate(&gate_from_rule(rule))
400                .map(|gate| CompiledRule {
401                    id: rule.id.clone(),
402                    priority: rule.priority,
403                    region: rule.region.trim().to_string(),
404                    state: rule.state.map_or(TurnState::Unknown, TurnState::from),
405                    gate,
406                })
407                .map_err(|err| format!("rule {} could not be compiled: {err}", rule.id))
408        })
409        .collect::<Result<Vec<_>, _>>()?;
410    Ok(CompiledManifest { id: raw.id, aliases: raw.aliases, rules })
411}
412
413fn validate_manifest(manifest: &RawManifest) -> Result<(), String> {
414    if manifest.id.trim().is_empty() {
415        return Err("manifest id must not be empty".to_string());
416    }
417    if let Some(version) = manifest.min_engine_version {
418        if version > ENGINE_VERSION {
419            return Err(format!(
420                "manifest requires engine {version}, this engine is {ENGINE_VERSION}"
421            ));
422        }
423    }
424    if manifest.rules.is_empty() {
425        return Err("manifest must contain at least one rule".to_string());
426    }
427    if manifest.rules.len() > MAX_RULES_PER_MANIFEST {
428        return Err(format!(
429            "manifest contains {} rules, max is {MAX_RULES_PER_MANIFEST}",
430            manifest.rules.len()
431        ));
432    }
433
434    let mut complexity = Complexity::default();
435    for rule in &manifest.rules {
436        if rule.id.trim().is_empty() {
437            return Err("manifest rule id must not be empty".to_string());
438        }
439        // herdr couples skip_state_update to state = "unknown"; the coupling is
440        // what lets winx treat those rules as plain Unknown (defer to
441        // quiescence) with no extra plumbing.
442        if rule.skip_state_update && rule.state != Some(RawState::Unknown) {
443            return Err(format!(
444                "rule {} uses skip_state_update without state = \"unknown\"",
445                rule.id
446            ));
447        }
448        validate_region_name(&rule.region)
449            .map_err(|err| format!("rule {} uses invalid region: {err}", rule.id))?;
450        validate_gate(&gate_from_rule(rule), "rule", 0, &mut complexity)
451            .map_err(|err| format!("rule {} has invalid matcher gates: {err}", rule.id))?;
452    }
453    Ok(())
454}
455
456#[derive(Default)]
457struct Complexity {
458    total_gates: usize,
459    total_matchers: usize,
460}
461
462fn validate_gate(
463    gate: &RawGate,
464    context: &str,
465    depth: usize,
466    complexity: &mut Complexity,
467) -> Result<(), String> {
468    if depth > MAX_GATE_DEPTH {
469        return Err(format!("{context} exceeds max gate depth {MAX_GATE_DEPTH}"));
470    }
471    complexity.total_gates += 1;
472    if complexity.total_gates > MAX_TOTAL_GATES {
473        return Err(format!("manifest exceeds max gate count {MAX_TOTAL_GATES}"));
474    }
475    validate_matcher_limits(gate, context, complexity)?;
476    if !gate_has_positive_matcher(gate) {
477        return Err(format!("{context} must contain a positive matcher"));
478    }
479    validate_regex_patterns(&gate.regex, context, "regex")?;
480    validate_regex_patterns(&gate.line_regex, context, "line_regex")?;
481    for nested in &gate.all {
482        validate_gate(nested, "all gate", depth + 1, complexity)?;
483    }
484    for nested in &gate.any {
485        validate_gate(nested, "any gate", depth + 1, complexity)?;
486    }
487    for nested in &gate.not_gate {
488        validate_not_gate(nested, depth + 1, complexity)?;
489    }
490    Ok(())
491}
492
493fn validate_not_gate(
494    gate: &RawGate,
495    depth: usize,
496    complexity: &mut Complexity,
497) -> Result<(), String> {
498    if depth > MAX_GATE_DEPTH {
499        return Err(format!("not gate exceeds max gate depth {MAX_GATE_DEPTH}"));
500    }
501    complexity.total_gates += 1;
502    if complexity.total_gates > MAX_TOTAL_GATES {
503        return Err(format!("manifest exceeds max gate count {MAX_TOTAL_GATES}"));
504    }
505    validate_matcher_limits(gate, "not gate", complexity)?;
506    if !gate_has_any_matcher(gate) {
507        return Err("not gate must contain a matcher".to_string());
508    }
509    validate_regex_patterns(&gate.regex, "not gate", "regex")?;
510    validate_regex_patterns(&gate.line_regex, "not gate", "line_regex")?;
511    for nested in &gate.all {
512        validate_gate(nested, "not all gate", depth + 1, complexity)?;
513    }
514    for nested in &gate.any {
515        validate_gate(nested, "not any gate", depth + 1, complexity)?;
516    }
517    for nested in &gate.not_gate {
518        validate_not_gate(nested, depth + 1, complexity)?;
519    }
520    Ok(())
521}
522
523fn validate_matcher_limits(
524    gate: &RawGate,
525    context: &str,
526    complexity: &mut Complexity,
527) -> Result<(), String> {
528    let matcher_count = gate.contains.len() + gate.regex.len() + gate.line_regex.len();
529    if matcher_count > MAX_MATCHERS_PER_GATE {
530        return Err(format!(
531            "{context} has {matcher_count} direct matchers, max is {MAX_MATCHERS_PER_GATE}"
532        ));
533    }
534    complexity.total_matchers += matcher_count;
535    if complexity.total_matchers > MAX_TOTAL_MATCHERS {
536        return Err(format!("manifest exceeds max matcher count {MAX_TOTAL_MATCHERS}"));
537    }
538    for value in gate.contains.iter().chain(gate.regex.iter()).chain(gate.line_regex.iter()) {
539        if value.chars().count() > MAX_MATCHER_CHARS {
540            return Err(format!("{context} matcher exceeds max length {MAX_MATCHER_CHARS}"));
541        }
542    }
543    Ok(())
544}
545
546fn validate_regex_patterns(patterns: &[String], context: &str, field: &str) -> Result<(), String> {
547    for pattern in patterns {
548        Regex::new(pattern).map_err(|err| {
549            format!("{context} contains invalid {field} pattern {pattern:?}: {err}")
550        })?;
551    }
552    Ok(())
553}
554
555fn gate_has_positive_matcher(gate: &RawGate) -> bool {
556    !gate.contains.is_empty()
557        || !gate.regex.is_empty()
558        || !gate.line_regex.is_empty()
559        || !gate.all.is_empty()
560        || !gate.any.is_empty()
561}
562
563fn gate_has_any_matcher(gate: &RawGate) -> bool {
564    gate_has_positive_matcher(gate) || !gate.not_gate.is_empty()
565}
566
567fn validate_region_name(spec: &str) -> Result<(), String> {
568    let trimmed = spec.trim();
569    match trimmed {
570        "whole_recent"
571        | "after_last_prompt_marker"
572        | "before_current_prompt_marker"
573        | "whole_recent_without_current_prompt_marker"
574        | "current_prompt_block_marker"
575        | "after_current_prompt_block_marker"
576        | "prompt_box_body"
577        | "above_prompt_box"
578        | "last_non_empty_above_prompt_box"
579        | "after_last_horizontal_rule"
580        | "osc_title"
581        | "osc_progress" => Ok(()),
582        _ if region_count(trimmed, "bottom_lines").is_some()
583            || region_count(trimmed, "bottom_non_empty_lines").is_some()
584            || top_region_count(trimmed).is_some() =>
585        {
586            Ok(())
587        }
588        _ => Err(trimmed.to_string()),
589    }
590}
591
592fn gate_from_rule(rule: &RawRule) -> RawGate {
593    RawGate {
594        all: rule.all.clone(),
595        any: rule.any.clone(),
596        not_gate: rule.not_gate.clone(),
597        contains: rule.contains.clone(),
598        regex: rule.regex.clone(),
599        line_regex: rule.line_regex.clone(),
600    }
601}
602
603fn compile_gate(gate: &RawGate) -> Result<CompiledGate, String> {
604    Ok(CompiledGate {
605        all: gate.all.iter().map(compile_gate).collect::<Result<_, _>>()?,
606        any: gate.any.iter().map(compile_gate).collect::<Result<_, _>>()?,
607        not_gate: gate.not_gate.iter().map(compile_gate).collect::<Result<_, _>>()?,
608        contains: gate.contains.iter().map(|needle| needle.to_lowercase()).collect(),
609        regex: gate
610            .regex
611            .iter()
612            .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string()))
613            .collect::<Result<_, _>>()?,
614        line_regex: gate
615            .line_regex
616            .iter()
617            .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string()))
618            .collect::<Result<_, _>>()?,
619    })
620}
621
622// --- evaluation --------------------------------------------------------------
623
624fn gate_matches(gate: &CompiledGate, text: &str, lower_text: &str) -> bool {
625    if !gate.contains.iter().all(|needle| lower_text.contains(needle)) {
626        return false;
627    }
628    if !gate.regex.iter().all(|regex| regex.is_match(text)) {
629        return false;
630    }
631    if !gate.line_regex.iter().all(|regex| text.lines().any(|line| regex.is_match(line))) {
632        return false;
633    }
634    if !gate.all.iter().all(|nested| gate_matches(nested, text, lower_text)) {
635        return false;
636    }
637    if !gate.any.is_empty() && !gate.any.iter().any(|nested| gate_matches(nested, text, lower_text))
638    {
639        return false;
640    }
641    if gate.not_gate.iter().any(|nested| gate_matches(nested, text, lower_text)) {
642        return false;
643    }
644    true
645}
646
647// --- regions -----------------------------------------------------------------
648
649fn region<'a>(input: DetectionInput<'a>, spec: &str) -> &'a str {
650    // OSC regions source from their dedicated fields, not the screen.
651    match spec {
652        "osc_title" => return input.osc_title,
653        "osc_progress" => return input.osc_progress,
654        _ => {}
655    }
656    let content = input.screen;
657    match spec {
658        "whole_recent" => content,
659        "after_last_prompt_marker" => after_last_prompt_marker(content),
660        "before_current_prompt_marker" => before_current_prompt_marker(content),
661        "whole_recent_without_current_prompt_marker" => {
662            whole_recent_without_current_prompt_marker(content)
663        }
664        "current_prompt_block_marker" => current_prompt_block_marker(content).unwrap_or(""),
665        "after_current_prompt_block_marker" => {
666            after_current_prompt_block_marker(content).unwrap_or("")
667        }
668        "prompt_box_body" => prompt_box_body(content).unwrap_or(""),
669        "above_prompt_box" => above_prompt_box(content),
670        "last_non_empty_above_prompt_box" => last_non_empty_line(above_prompt_box(content)),
671        "after_last_horizontal_rule" => after_last_horizontal_rule(content),
672        _ => {
673            if let Some(count) = region_count(spec, "bottom_lines") {
674                return bottom_lines(content, count);
675            }
676            if let Some(count) = region_count(spec, "bottom_non_empty_lines") {
677                return bottom_non_empty_lines(content, count);
678            }
679            if let Some(count) = top_region_count(spec) {
680                return top_non_empty_lines(content, count);
681            }
682            ""
683        }
684    }
685}
686
687fn region_count(spec: &str, name: &str) -> Option<usize> {
688    spec.strip_prefix(name)
689        .and_then(|rest| rest.strip_prefix('('))
690        .and_then(|rest| rest.strip_suffix(')'))
691        .and_then(|count| count.parse::<usize>().ok())
692}
693
694const MAX_TOP_REGION_LINE_COUNT: usize = u16::MAX as usize;
695
696fn top_region_count(spec: &str) -> Option<usize> {
697    let count = spec.strip_prefix("top_non_empty_lines")?.strip_prefix('(')?.strip_suffix(')')?;
698    if count.starts_with('0') || !count.bytes().all(|byte| byte.is_ascii_digit()) {
699        return None;
700    }
701    count.parse::<usize>().ok().filter(|count| *count <= MAX_TOP_REGION_LINE_COUNT)
702}
703
704fn bottom_lines(content: &str, count: usize) -> &str {
705    let lines: Vec<&str> = content.lines().collect();
706    let start = lines.len().saturating_sub(count);
707    slice_from_line_index(content, &lines, start)
708}
709
710fn bottom_non_empty_lines(content: &str, count: usize) -> &str {
711    let lines: Vec<&str> = content.lines().collect();
712    let Some(start_index) = lines
713        .iter()
714        .enumerate()
715        .rev()
716        .filter(|(_, line)| !line.trim().is_empty())
717        .take(count)
718        .last()
719        .map(|(index, _)| index)
720    else {
721        return "";
722    };
723    slice_from_line_index(content, &lines, start_index)
724}
725
726fn top_non_empty_lines(content: &str, count: usize) -> &str {
727    let lines: Vec<&str> = content.lines().collect();
728    let Some(end_index) = lines
729        .iter()
730        .enumerate()
731        .filter(|(_, line)| !line.trim().is_empty())
732        .take(count)
733        .last()
734        .map(|(index, _)| index)
735    else {
736        return "";
737    };
738    let byte_offset = line_start_offset(content, &lines, end_index + 1);
739    &content[..byte_offset]
740}
741
742fn after_last_prompt_marker(content: &str) -> &str {
743    let lines: Vec<&str> = content.lines().collect();
744    let Some(index) = lines.iter().rposition(|line| codex_prompt_line(line)) else {
745        return content;
746    };
747    slice_from_line_index(content, &lines, index + 1)
748}
749
750fn before_current_prompt_marker(content: &str) -> &str {
751    let lines: Vec<&str> = content.lines().collect();
752    let Some(index) = current_codex_prompt_index(&lines) else {
753        return content;
754    };
755    let byte_offset = lines[..index].iter().map(|line| line.len() + 1).sum::<usize>();
756    &content[..byte_offset.min(content.len())]
757}
758
759fn whole_recent_without_current_prompt_marker(content: &str) -> &str {
760    let lines: Vec<&str> = content.lines().collect();
761    if current_codex_prompt_index(&lines).is_some() {
762        ""
763    } else {
764        content
765    }
766}
767
768fn current_prompt_block_marker(content: &str) -> Option<&str> {
769    let lines: Vec<&str> = content.lines().collect();
770    let prompt_index = current_codex_prompt_index(&lines)?;
771    lines[..prompt_index].iter().rev().find(|line| codex_block_marker_line(line)).copied()
772}
773
774fn after_current_prompt_block_marker(content: &str) -> Option<&str> {
775    let lines: Vec<&str> = content.lines().collect();
776    let prompt_index = current_codex_prompt_index(&lines)?;
777    let block_index =
778        lines[..prompt_index].iter().rposition(|line| codex_block_marker_line(line))?;
779    Some(slice_from_line_index(content, &lines, block_index))
780}
781
782fn current_codex_prompt_index(lines: &[&str]) -> Option<usize> {
783    let prompt_index = lines.iter().rposition(|line| codex_prompt_line(line))?;
784    if lines[prompt_index + 1..].iter().any(|line| codex_block_marker_line(line)) {
785        return None;
786    }
787    Some(prompt_index)
788}
789
790fn codex_prompt_line(line: &str) -> bool {
791    line == "›" || line.starts_with("› ")
792}
793
794fn codex_block_marker_line(line: &str) -> bool {
795    line.starts_with('•') || line.starts_with('■') || line.starts_with('✗') || line.starts_with('✓')
796}
797
798fn prompt_box_body(content: &str) -> Option<&str> {
799    let lines: Vec<&str> = content.lines().collect();
800    let top = prompt_box_top_border_index(&lines)?;
801    let start = line_start_offset(content, &lines, top + 1);
802    let end_index = lines[top + 1..]
803        .iter()
804        .position(|line| is_horizontal_rule(line))
805        .map_or(lines.len(), |relative| top + 1 + relative);
806    let end = line_start_offset(content, &lines, end_index);
807    Some(&content[start.min(content.len())..end.min(content.len())])
808}
809
810fn above_prompt_box(content: &str) -> &str {
811    let lines: Vec<&str> = content.lines().collect();
812    let Some(top) = prompt_box_top_border_index(&lines) else {
813        return content;
814    };
815    let end = line_start_offset(content, &lines, top);
816    &content[..end.min(content.len())]
817}
818
819fn after_last_horizontal_rule(content: &str) -> &str {
820    let mut last_rule_end = 0usize;
821    let mut offset = 0usize;
822    for line in content.lines() {
823        let next_offset = offset + line.len() + 1;
824        if is_horizontal_rule(line) {
825            last_rule_end = next_offset.min(content.len());
826        }
827        offset = next_offset;
828    }
829    &content[last_rule_end..]
830}
831
832fn last_non_empty_line(content: &str) -> &str {
833    content.lines().rev().find(|line| !line.trim().is_empty()).unwrap_or("")
834}
835
836fn prompt_box_top_border_index(lines: &[&str]) -> Option<usize> {
837    let mut border_count = 0;
838    for index in (0..lines.len()).rev() {
839        if is_horizontal_rule(lines[index]) {
840            border_count += 1;
841            if border_count == 2 {
842                return Some(index);
843            }
844        }
845    }
846    None
847}
848
849fn is_horizontal_rule(line: &str) -> bool {
850    let trimmed = line.trim();
851    if trimmed.is_empty() {
852        return false;
853    }
854    let rule_chars = trimmed.chars().take_while(|&ch| ch == '─').count();
855    if rule_chars == 0 {
856        return false;
857    }
858    let rule_bytes =
859        trimmed.char_indices().nth(rule_chars).map_or(trimmed.len(), |(index, _)| index);
860    let suffix = trimmed[rule_bytes..].trim_start();
861    suffix.is_empty() || rule_chars >= 3
862}
863
864fn slice_from_line_index<'a>(content: &'a str, lines: &[&str], index: usize) -> &'a str {
865    let byte_offset = line_start_offset(content, lines, index);
866    &content[byte_offset.min(content.len())..]
867}
868
869fn line_start_offset(content: &str, lines: &[&str], index: usize) -> usize {
870    lines[..index.min(lines.len())]
871        .iter()
872        .map(|line| line.len() + 1)
873        .sum::<usize>()
874        .min(content.len())
875}
876
877#[cfg(test)]
878#[allow(clippy::expect_used, clippy::panic)]
879mod tests {
880    use super::*;
881
882    fn input(screen: &str) -> DetectionInput<'_> {
883        DetectionInput { screen, osc_title: "", osc_progress: "" }
884    }
885
886    #[test]
887    fn every_bundled_manifest_compiles() {
888        // Runtime skips invalid bundled manifests instead of panicking; this
889        // is the gate that keeps that path dead.
890        for content in BUNDLED_MANIFESTS {
891            if let Err(err) = compile_manifest(content) {
892                let id = content.lines().next().unwrap_or("").to_string();
893                panic!("bundled manifest failed to compile ({id}): {err}");
894            }
895        }
896        assert_eq!(manifests().len(), BUNDLED_MANIFESTS.len());
897    }
898
899    #[test]
900    fn lookup_resolves_ids_and_aliases() {
901        assert_eq!(manifest_for("claude").map(CompiledManifest::id), Some("claude"));
902        assert_eq!(manifest_for("claude-code").map(CompiledManifest::id), Some("claude"));
903        assert_eq!(manifest_for("CODEX").map(CompiledManifest::id), Some("codex"));
904        assert_eq!(manifest_for("antigravity").map(CompiledManifest::id), Some("agy"));
905        assert_eq!(manifest_for("gemini").map(CompiledManifest::id), Some("gemini"));
906        assert_eq!(manifest_for("ghcs").map(CompiledManifest::id), Some("copilot"));
907        assert_eq!(manifest_for("cursor-agent").map(CompiledManifest::id), Some("cursor"));
908        assert!(manifest_for("not-an-agent").is_none());
909    }
910
911    #[test]
912    fn claude_busy_via_footer_and_idle_prompt_box() {
913        let claude = manifest_for("claude").expect("claude manifest");
914        // Busy: the live-turn footer (herdr's live_turn_working rule).
915        let busy = "❯ prompt\n\n* Hatching…\n⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt";
916        let verdict = claude.detect(input(busy));
917        assert_eq!(verdict.state, TurnState::Busy);
918        assert_eq!(verdict.rule.map(|(id, _)| id), Some("live_turn_working"));
919
920        // Idle: the ❯ prompt box between two horizontal rules.
921        let idle = "●─PONG\n──────────\n❯ \n──────────\n  ⏵⏵ bypass permissions on";
922        let verdict = claude.detect(input(idle));
923        assert_eq!(verdict.state, TurnState::AwaitingInput);
924        assert_eq!(verdict.rule.map(|(id, _)| id), Some("live_prompt_box"));
925    }
926
927    #[test]
928    fn claude_tool_permission_dialog_is_approval() {
929        let claude = manifest_for("claude").expect("claude manifest");
930        let screen = "⏺ Bash(rm -rf /tmp/x)\nDo you want to proceed?\n❯ 1. Yes\n  2. No, and tell Claude what to do differently";
931        assert_eq!(claude.detect(input(screen)).state, TurnState::AwaitingApproval);
932    }
933
934    #[test]
935    fn claude_transcript_viewer_defers_to_quiescence() {
936        let claude = manifest_for("claude").expect("claude manifest");
937        let screen = "old transcript\nShowing detailed transcript\nctrl+o to toggle";
938        let verdict = claude.detect(input(screen));
939        assert_eq!(verdict.state, TurnState::Unknown);
940        assert_eq!(verdict.rule.map(|(id, _)| id), Some("transcript_viewer"));
941    }
942
943    #[test]
944    fn codex_working_footer_beats_stale_prompt() {
945        let codex = manifest_for("codex").expect("codex manifest");
946        let screen = "› an earlier prompt\n• Working (3s • esc to interrupt)";
947        let verdict = codex.detect(input(screen));
948        assert_eq!(verdict.state, TurnState::Busy);
949        assert_eq!(verdict.rule.map(|(id, _)| id), Some("screen_working_fallback"));
950    }
951
952    #[test]
953    fn codex_approval_dialog_after_prompt_marker() {
954        let codex = manifest_for("codex").expect("codex manifest");
955        let screen = "› run the command\n\n  $ touch /tmp/x.txt\n\n  Press enter to confirm or esc to cancel";
956        assert_eq!(codex.detect(input(screen)).state, TurnState::AwaitingApproval);
957    }
958
959    #[test]
960    fn osc_title_spinner_marks_codex_busy_and_plain_title_idle() {
961        let codex = manifest_for("codex").expect("codex manifest");
962        let screen = "› stale prompt from history";
963        // Braille spinner in the OSC title → working, even with an idle screen.
964        let busy = codex.detect(DetectionInput {
965            screen,
966            osc_title: "⠋ codex — thinking",
967            osc_progress: "",
968        });
969        assert_eq!(busy.state, TurnState::Busy);
970        assert_eq!(busy.rule.map(|(id, _)| id), Some("osc_title_working"));
971
972        // "Action Required" title → blocked at top priority.
973        let blocked = codex.detect(DetectionInput {
974            screen,
975            osc_title: "Action Required — codex",
976            osc_progress: "",
977        });
978        assert_eq!(blocked.state, TurnState::AwaitingApproval);
979
980        // Any other non-spinner title → idle.
981        let idle = codex.detect(DetectionInput { screen, osc_title: "codex", osc_progress: "" });
982        assert_eq!(idle.state, TurnState::AwaitingInput);
983        assert_eq!(idle.rule.map(|(id, _)| id), Some("osc_title_idle"));
984    }
985
986    #[test]
987    fn claude_osc_progress_zero_reads_idle() {
988        let claude = manifest_for("claude").expect("claude manifest");
989        let verdict =
990            claude.detect(DetectionInput { screen: "", osc_title: "", osc_progress: "4;0" });
991        assert_eq!(verdict.state, TurnState::AwaitingInput);
992        assert_eq!(verdict.rule.map(|(id, _)| id), Some("osc_progress_idle"));
993    }
994
995    #[test]
996    fn no_match_returns_unknown_not_idle() {
997        // Deviation from herdr documented in the module docs: a manifest miss
998        // defers to quiescence instead of assuming idle.
999        let claude = manifest_for("claude").expect("claude manifest");
1000        let verdict = claude.detect(input("$ ls\nfile.txt\n$"));
1001        assert_eq!(verdict.state, TurnState::Unknown);
1002        assert_eq!(verdict.rule, None);
1003    }
1004
1005    #[test]
1006    fn higher_priority_rule_wins_and_first_match_kept_on_tie() {
1007        let manifest = compile_manifest(
1008            r#"
1009id = "test"
1010[[rules]]
1011id = "low"
1012state = "idle"
1013priority = 10
1014contains = ["marker"]
1015[[rules]]
1016id = "tie-first"
1017state = "working"
1018priority = 50
1019contains = ["marker"]
1020[[rules]]
1021id = "tie-second"
1022state = "blocked"
1023priority = 50
1024contains = ["marker"]
1025"#,
1026        )
1027        .expect("test manifest");
1028        // Leak: tests need the 'static self the public API has.
1029        let manifest: &'static CompiledManifest = Box::leak(Box::new(manifest));
1030        let verdict = manifest.detect(input("marker"));
1031        assert_eq!(verdict.state, TurnState::Busy);
1032        assert_eq!(verdict.rule.map(|(id, _)| id), Some("tie-first"));
1033    }
1034
1035    #[test]
1036    fn gate_semantics_all_any_not_and_line_regex() {
1037        let manifest = compile_manifest(
1038            r#"
1039id = "test"
1040[[rules]]
1041id = "gated"
1042state = "blocked"
1043priority = 1
1044contains = ["always"]
1045any = [ { contains = ["option a"] }, { contains = ["option b"] } ]
1046not = [ { contains = ["veto"] } ]
1047line_regex = ['^\s*❯']
1048"#,
1049        )
1050        .expect("test manifest");
1051        let manifest: &'static CompiledManifest = Box::leak(Box::new(manifest));
1052        let hit = "ALWAYS shown\noption b\n  ❯ pick";
1053        assert_eq!(manifest.detect(input(hit)).state, TurnState::AwaitingApproval);
1054        // `not` vetoes.
1055        let vetoed = format!("{hit}\nveto");
1056        assert_eq!(manifest.detect(input(&vetoed)).state, TurnState::Unknown);
1057        // `any` needs at least one branch.
1058        let no_option = "always\n  ❯ pick";
1059        assert_eq!(manifest.detect(input(no_option)).state, TurnState::Unknown);
1060        // `line_regex` must match a whole line, not the joined text.
1061        let no_prompt_line = "always ❯ inline\noption a";
1062        assert_eq!(manifest.detect(input(no_prompt_line)).state, TurnState::Unknown);
1063    }
1064
1065    #[test]
1066    fn region_windows_slice_as_documented() {
1067        let content = "top\n\nmid\nlast1\n\nlast2";
1068        assert_eq!(bottom_non_empty_lines(content, 2), "last1\n\nlast2");
1069        assert_eq!(bottom_lines(content, 1), "last2");
1070        assert_eq!(top_non_empty_lines(content, 1), "top\n");
1071        assert_eq!(last_non_empty_line("a\nb\n\n"), "b");
1072    }
1073
1074    #[test]
1075    fn prompt_box_regions_find_the_boxed_prompt() {
1076        let content = "history\n──────────\n❯ type here\n──────────\nfooter";
1077        assert_eq!(prompt_box_body(content), Some("❯ type here\n"));
1078        assert_eq!(above_prompt_box(content), "history\n");
1079        assert_eq!(after_last_horizontal_rule(content), "footer");
1080    }
1081
1082    #[test]
1083    fn codex_prompt_marker_regions() {
1084        let content = "• block\n› current prompt\ntail";
1085        assert_eq!(after_last_prompt_marker(content), "tail");
1086        assert_eq!(before_current_prompt_marker(content), "• block\n");
1087        // A block marker after the prompt means the prompt is stale.
1088        let stale = "› old prompt\n• Working";
1089        assert_eq!(whole_recent_without_current_prompt_marker(stale), stale);
1090        assert_eq!(whole_recent_without_current_prompt_marker("› live prompt"), "");
1091    }
1092
1093    #[test]
1094    fn override_slot_hot_reloads_edits_and_reverts_on_removal() {
1095        let dir = tempfile::tempdir().expect("temp dir");
1096        let path = dir.path().join("claude.toml");
1097        let mut slot = OverrideSlot { last_checked: None, mtime: None, manifest: None };
1098
1099        // Absent file → bundled stays authoritative.
1100        refresh_override_slot(&mut slot, &path, "claude");
1101        assert!(slot.manifest.is_none());
1102
1103        // Valid override compiles and activates.
1104        std::fs::write(
1105            &path,
1106            "id = \"claude\"\n[[rules]]\nid = \"v1\"\nstate = \"working\"\ncontains = [\"override marker v1\"]\n",
1107        )
1108        .expect("write v1");
1109        refresh_override_slot(&mut slot, &path, "claude");
1110        let first = slot.manifest.expect("override should activate");
1111        assert_eq!(first.detect(input("override marker v1")).state, TurnState::Busy);
1112
1113        // Unchanged mtime → the same compiled manifest is kept.
1114        refresh_override_slot(&mut slot, &path, "claude");
1115        assert!(std::ptr::eq(first, slot.manifest.expect("override still active")));
1116
1117        // An edited file (newer mtime) recompiles — the hot-reload itself.
1118        std::fs::write(
1119            &path,
1120            "id = \"claude\"\n[[rules]]\nid = \"v2\"\nstate = \"blocked\"\ncontains = [\"override marker v2\"]\n",
1121        )
1122        .expect("write v2");
1123        bump_mtime(&path);
1124        refresh_override_slot(&mut slot, &path, "claude");
1125        let second = slot.manifest.expect("edited override should reload");
1126        assert_eq!(second.detect(input("override marker v2")).state, TurnState::AwaitingApproval);
1127        assert_eq!(second.detect(input("override marker v1")).state, TurnState::Unknown);
1128
1129        // A broken edit keeps the previous compiled override active.
1130        std::fs::write(&path, "not toml at all").expect("write broken");
1131        bump_mtime(&path);
1132        refresh_override_slot(&mut slot, &path, "claude");
1133        assert!(std::ptr::eq(second, slot.manifest.expect("broken edit keeps previous")));
1134
1135        // An id mismatch is refused the same way.
1136        std::fs::write(
1137            &path,
1138            "id = \"codex\"\n[[rules]]\nid = \"x\"\nstate = \"idle\"\ncontains = [\"y\"]\n",
1139        )
1140        .expect("write mismatched");
1141        bump_mtime(&path);
1142        refresh_override_slot(&mut slot, &path, "claude");
1143        assert!(std::ptr::eq(second, slot.manifest.expect("id mismatch keeps previous")));
1144
1145        // Removing the file reverts to the bundled manifest.
1146        std::fs::remove_file(&path).expect("remove override");
1147        refresh_override_slot(&mut slot, &path, "claude");
1148        assert!(slot.manifest.is_none());
1149    }
1150
1151    /// Filesystems with coarse mtime granularity could make two writes in the
1152    /// same instant look unchanged; force a strictly newer mtime.
1153    fn bump_mtime(path: &std::path::Path) {
1154        let file = std::fs::OpenOptions::new().write(true).open(path).expect("open for mtime");
1155        file.set_modified(SystemTime::now() + Duration::from_secs(2)).expect("bump mtime");
1156    }
1157
1158    #[test]
1159    fn engine_version_gate_rejects_future_manifests() {
1160        let err = compile_manifest(
1161            r#"
1162id = "future"
1163min_engine_version = 99
1164[[rules]]
1165id = "r"
1166state = "idle"
1167contains = ["x"]
1168"#,
1169        )
1170        .expect_err("future manifest must be rejected");
1171        assert!(err.contains("requires engine 99"), "unexpected error: {err}");
1172    }
1173
1174    #[test]
1175    fn skip_state_update_requires_unknown_state() {
1176        let err = compile_manifest(
1177            r#"
1178id = "bad"
1179[[rules]]
1180id = "r"
1181state = "idle"
1182skip_state_update = true
1183contains = ["x"]
1184"#,
1185        )
1186        .expect_err("skip_state_update with non-unknown state must be rejected");
1187        assert!(err.contains("skip_state_update"), "unexpected error: {err}");
1188    }
1189}