Skip to main content

fno_agents/
scrape.rs

1//! Screen-manifest scrape sweep: the fallback rung of the badge lattice
2//! (pane-exit > inside-leg hook > **screen-manifest** > liveness).
3//!
4//! G4 deleted grid screen-scraping, so an agent that never emits the per-turn
5//! inside-leg hook (foreign CLIs, partial-lifecycle agents) dropped to bare
6//! alive/dead. This module restores the herdr-style fallback: for each
7//! registry row that is mux-hosted AND hook-less, the daemon reads the pane's
8//! rendered screen through the mux script API (`fno mux pane ls/read --json`,
9//! the same one-shot control verbs scripts use - no mux proto change, no
10//! fno-agents<->fno crate dependency in either direction), evaluates the
11//! provider's detection manifest ([`crate::manifest`]) against it, and stores
12//! the verdict as [`state::ScreenStateReport`] on the row.
13//!
14//! Arbitration is per-capability, not per-moment (herdr's "no two competing
15//! sources of truth"): a row that carries ANY `inside_leg` report - live or
16//! TTL-lapsed - is never scraped; its TTL lapse degrades to liveness-only
17//! exactly as before. The inside-leg store path clears `screen_state` on the
18//! capability flip, and the sweep's write closure re-checks under the
19//! registry lock, so a scrape verdict can never shadow a hook.
20//!
21//! Registry writes are change-gated: a verdict is written when the detected
22//! state differs from the stored one, or when the stored stamp is due a
23//! freshness refresh (half the reader TTL) - never per sweep per pane. A pane
24//! that vanishes (or a mux that stops answering: panes live in the server
25//! process, an unreachable server means no panes) clears the stored verdict,
26//! so the badge degrades to liveness rather than pinning a stale state; a
27//! dead daemon's last verdict ages out via the reader-side TTL.
28
29use std::collections::BTreeMap;
30use std::process::Command;
31
32use serde_json::json;
33
34use crate::events::EventEmitter;
35use crate::manifest::{load_manifest, AnswerablePrompt, Manifest, Verdict};
36use crate::paths::AgentsHome;
37use crate::readiness::ScreenView;
38use crate::state::{self, Registry, ScreenStateReport};
39use crate::AgentStatus;
40
41/// Reader-trust TTL stamped into every verdict (`ScreenStateReport::ttl_ms`).
42/// Generous next to the sweep cadence (the daemon's 5s idle tick) so a live
43/// daemon always refreshes well before it lapses; tight next to a human
44/// glancing at a sideline after the daemon died.
45pub const SCREEN_STATE_TTL_MS: u64 = 120_000;
46
47/// Refresh a steady (unchanged) verdict's stamp once it is older than this -
48/// half the TTL, so freshness never races the reader's aging gate.
49const REFRESH_AFTER_SECS: u64 = SCREEN_STATE_TTL_MS / 2 / 1000;
50
51/// One row the sweep will scrape: mux-hosted, hook-less, not terminal.
52#[derive(Debug, Clone, PartialEq)]
53pub struct ScrapeTarget {
54    pub name: String,
55    pub provider: String,
56    pub session: String,
57    pub pane_id: u64,
58    pub last: Option<ScreenStateReport>,
59}
60
61/// The eligibility filter (per-capability arbitration). Pure over a loaded
62/// registry so the gate is unit-testable without a daemon or a mux.
63pub fn scrape_targets(reg: &Registry) -> Vec<ScrapeTarget> {
64    reg.entries
65        .iter()
66        .filter(|e| {
67            // ANY inside-leg report (live or lapsed) marks the row
68            // hook-capable: the hook owns the signal, TTL lapse degrades to
69            // liveness-only, never to a scrape (no authority flapping).
70            // Non-live statuses are excluded too: Orphaned (failed
71            // reachability probe) and Failed (panicked task) already say the
72            // backend is not live, so scraping their last screen would badge
73            // a dead pane (codex P2).
74            e.inside_leg.is_none()
75                && !matches!(
76                    e.status,
77                    AgentStatus::Exited
78                        | AgentStatus::PermanentDead
79                        | AgentStatus::Orphaned
80                        | AgentStatus::Failed
81                )
82        })
83        .filter_map(|e| {
84            e.mux.as_ref().map(|m| ScrapeTarget {
85                name: e.name.clone(),
86                provider: e.harness_name().to_string(),
87                session: m.session.clone(),
88                pane_id: m.pane_id,
89                last: e.screen_state.clone(),
90            })
91        })
92        .collect()
93}
94
95/// What the sweep decided for one target.
96#[derive(Debug, Clone, PartialEq)]
97pub enum Decision {
98    /// Nothing to write (verdict unchanged and fresh, or no evidence yet).
99    Hold,
100    /// Clear the stored verdict (pane gone / unreadable).
101    Clear,
102    /// Store this verdict.
103    Write(ScreenStateReport),
104}
105
106/// True when a stored verdict's stamp is due a freshness refresh. An
107/// unparseable stamp counts as due (rewriting it repairs the row).
108fn stamp_due_refresh(last: &ScreenStateReport, now_secs: u64) -> bool {
109    match state::rfc3339_like_to_secs(&last.at) {
110        Some(at) => now_secs.saturating_sub(at) > REFRESH_AFTER_SECS,
111        None => true,
112    }
113}
114
115/// The write-on-change core: fold an evaluation outcome into a [`Decision`].
116/// Pure so every branch is unit-testable.
117///
118/// - No rule matched: hold. The engine never guesses (readiness Open Question
119///   #9); the stored verdict stays and ages out via its TTL if the screen
120///   never matches again.
121/// - `skip_state_update` rule matched (e.g. claude's ctrl+o pager): hold the
122///   current state, refreshing its stamp if due so a held state does not age
123///   out mid-pager.
124/// - State changed: write. State unchanged: write only when the stamp is due
125///   a refresh.
126pub fn decide(
127    last: Option<&ScreenStateReport>,
128    verdict: Option<Verdict<'_>>,
129    answerable: Option<AnswerablePrompt>,
130    now_secs: u64,
131    now_stamp: &str,
132) -> Decision {
133    let Some(v) = verdict else {
134        return Decision::Hold;
135    };
136    if v.skip_state_update {
137        return match last {
138            // Hold: `..l.clone()` carries the prior answerable through a pager
139            // hold, so the queue keeps the last-good payload while state is held.
140            Some(l) if stamp_due_refresh(l, now_secs) => Decision::Write(ScreenStateReport {
141                at: now_stamp.to_string(),
142                seq: l.seq + 1,
143                ..l.clone()
144            }),
145            _ => Decision::Hold,
146        };
147    }
148    match last {
149        // A changed answer payload (same blocked state, different options)
150        // rewrites too, so the queue reflects a re-prompt without waiting for the
151        // stamp refresh. The send-time fingerprint is still the safety authority.
152        Some(l)
153            if l.state == v.state
154                && l.answerable == answerable
155                && !stamp_due_refresh(l, now_secs) =>
156        {
157            Decision::Hold
158        }
159        _ => Decision::Write(ScreenStateReport {
160            state: v.state.to_string(),
161            rule: v.rule_id.to_string(),
162            seq: last.map_or(1, |l| l.seq + 1),
163            at: now_stamp.to_string(),
164            ttl_ms: Some(SCREEN_STATE_TTL_MS),
165            answerable,
166        }),
167    }
168}
169
170/// The `fno` front-door binary (the Rust mux owner), same resolution as the
171/// active-backlog supervisor and the Python spawn back half: `FNO_BIN`
172/// overrides for tests and non-PATH installs. `var_os` (not `var`) so a path
173/// with non-UTF-8 bytes passes through to `Command` unmangled (gemini MEDIUM).
174fn fno_bin() -> std::ffi::OsString {
175    std::env::var_os("FNO_BIN").unwrap_or_else(|| std::ffi::OsString::from("fno"))
176}
177
178/// `fno mux pane ls --session <s> --json` -> pane_id -> OSC title. `None`
179/// when the session is unreachable (no server, skewed binary, bad output) -
180/// the caller treats that as "no panes", which clears verdicts: panes live in
181/// the server process, so an unanswerable server has no live panes to badge.
182///
183/// ponytail: no subprocess timeout - the mux CLI bounds its own socket
184/// reads/writes, so a wedged server errors instead of hanging; a hung
185/// FNO_BIN stalls only this sweep thread (the in-flight gate skips further
186/// sweeps rather than piling them up).
187fn mux_pane_ls(bin: &std::ffi::OsStr, session: &str) -> Option<BTreeMap<u64, Option<String>>> {
188    let out = Command::new(bin)
189        .args(["mux", "pane", "ls", "--session", session, "--json"])
190        .output()
191        .ok()?;
192    if !out.status.success() {
193        return None;
194    }
195    let panes: Vec<serde_json::Value> = serde_json::from_slice(&out.stdout).ok()?;
196    Some(
197        panes
198            .iter()
199            .filter_map(|p| {
200                Some((
201                    p.get("pane_id")?.as_u64()?,
202                    p.get("title").and_then(|t| t.as_str()).map(String::from),
203                ))
204            })
205            .collect(),
206    )
207}
208
209/// `fno mux pane read <pane> --session <s> --json` -> the pane's rendered
210/// grid text. `None` on any failure (dead pane, unreachable server).
211fn mux_pane_read(bin: &std::ffi::OsStr, session: &str, pane: u64) -> Option<String> {
212    let out = Command::new(bin)
213        .args([
214            "mux",
215            "pane",
216            "read",
217            &pane.to_string(),
218            "--session",
219            session,
220            "--json",
221        ])
222        .output()
223        .ok()?;
224    if !out.status.success() {
225        return None;
226    }
227    let reply: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
228    reply.get("text")?.as_str().map(String::from)
229}
230
231/// What the locked write should do for one row, re-checked under the registry
232/// lock against the state the row is in NOW (not the snapshot the sweep read).
233#[derive(Debug, Clone, PartialEq, Eq)]
234enum WriteDisposition {
235    /// A capability flip landed since the snapshot: the hook is senior, clear
236    /// any scrape verdict so it can never shadow the hook.
237    HookFlip,
238    /// The row still points at the pane we scraped: apply the verdict.
239    Apply,
240    /// The row was re-homed or removed+recreated with the same name since the
241    /// snapshot (its mux ref no longer matches what we scraped): skip, so the
242    /// old pane's verdict never lands on the current pane (codex P2).
243    Skip,
244}
245
246/// Decide the locked-write disposition for one row. Pure so the arbitration
247/// re-checks (hook flip, mux-ref match) are unit-testable without a daemon.
248fn write_disposition(e: &state::RegistryEntry, expect_ref: &(String, u64)) -> WriteDisposition {
249    if e.inside_leg.is_some() {
250        return WriteDisposition::HookFlip;
251    }
252    let cur_ref = e.mux.as_ref().map(|m| (m.session.clone(), m.pane_id));
253    if cur_ref.as_ref() == Some(expect_ref) {
254        WriteDisposition::Apply
255    } else {
256        WriteDisposition::Skip
257    }
258}
259
260/// One sweep pass: load -> filter -> read screens -> evaluate -> batch the
261/// changed rows into one locked registry write. Synchronous by design (file
262/// IO + subprocesses); the daemon runs it under `spawn_blocking` off the
263/// idle tick, gated so at most one sweep is in flight.
264///
265/// `notify_on_blocked` (config.mux.notify_on_blocked, x-dd84) fires one OS
266/// notification when a scraped verdict ENTERS `blocked`; the manifest path has
267/// no `done`, so notify_on_done is not plumbed here.
268pub fn scrape_sweep(home: &AgentsHome, emitter: &EventEmitter, notify_on_blocked: bool) {
269    let Ok(reg) = state::load_registry(&home.registry_json()) else {
270        return;
271    };
272    let targets = scrape_targets(&reg);
273    if targets.is_empty() {
274        return;
275    }
276    let bin = fno_bin();
277    let override_dir = home.manifests_dir();
278    let now_secs = std::time::SystemTime::now()
279        .duration_since(std::time::UNIX_EPOCH)
280        .unwrap_or_default()
281        .as_secs();
282    let now_stamp = crate::daemon::now_rfc3339_like();
283
284    // Per-provider manifest cache (a parse-bad manifest logs once per sweep,
285    // not once per pane) and per-session pane listing (one `ls` per session).
286    let mut manifests: BTreeMap<String, Option<Manifest>> = BTreeMap::new();
287    let mut sessions: BTreeMap<String, Option<BTreeMap<u64, Option<String>>>> = BTreeMap::new();
288
289    // (name, expected-mux-ref, verdict). The ref is re-verified under the
290    // lock so a row removed+recreated (or re-homed to a new pane) with the
291    // same name since the snapshot never gets the old pane's verdict (codex P2).
292    let mut changes: Vec<(String, (String, u64), Option<ScreenStateReport>)> = Vec::new();
293    for t in &targets {
294        let manifest = manifests.entry(t.provider.clone()).or_insert_with(|| {
295            match load_manifest(&t.provider, Some(&override_dir)) {
296                Some(Ok(m)) => Some(m),
297                Some(Err(e)) => {
298                    // Present-but-malformed (a bad hand-authored override)
299                    // fails loud in the event log, never silently falls back.
300                    let _ = emitter.emit(
301                        "screen_state_change",
302                        &json!({"provider": t.provider, "error": e.to_string()}),
303                    );
304                    None
305                }
306                // Unknown provider: no manifest, never scraped (liveness-only).
307                None => None,
308            }
309        });
310        let Some(manifest) = manifest else {
311            continue;
312        };
313        let panes = sessions
314            .entry(t.session.clone())
315            .or_insert_with(|| mux_pane_ls(&bin, &t.session));
316        let evidence = panes
317            .as_ref()
318            .and_then(|p| p.get(&t.pane_id))
319            .map(|title| (title.clone(), mux_pane_read(&bin, &t.session, t.pane_id)));
320        let decision = match evidence {
321            // Pane absent from the listing, or its read failed: no screen to
322            // trust. Clear a stored verdict; a never-badged row stays silent.
323            None | Some((_, None)) => {
324                if t.last.is_some() {
325                    Decision::Clear
326                } else {
327                    Decision::Hold
328                }
329            }
330            Some((title, Some(text))) => {
331                let view = ScreenView {
332                    visible_text: &text,
333                    // The manifest engine reads regions of text + OSC strings;
334                    // cursor position is a readiness-detector concern.
335                    cursor_row: 0,
336                    cursor_col: 0,
337                    osc_title: title.as_deref(),
338                    // The mux surfaces titles (PaneInfo.title) but not OSC 9;4
339                    // progress; no bundled rule reads osc_progress today.
340                    osc_progress: None,
341                };
342                let (verdict, answerable) = match manifest.evaluate_answerable(&view) {
343                    Some((v, a)) => (Some(v), a),
344                    None => (None, None),
345                };
346                decide(t.last.as_ref(), verdict, answerable, now_secs, &now_stamp)
347            }
348        };
349        let expect_ref = (t.session.clone(), t.pane_id);
350        match decision {
351            Decision::Hold => {}
352            Decision::Clear => changes.push((t.name.clone(), expect_ref, None)),
353            Decision::Write(rep) => changes.push((t.name.clone(), expect_ref, Some(rep))),
354        }
355    }
356    if changes.is_empty() {
357        return;
358    }
359    // Badge-transition notify intents (x-dd84): (agent name, matched rule).
360    // Captured under the flock from prev-vs-new screen_state; fired after the
361    // write so a slow notifier can never stall the sweep.
362    let mut blocked_notifs: Vec<(String, String)> = Vec::new();
363    let write = state::update_registry(&home.registry_json(), |r| {
364        for (name, expect_ref, rep) in &changes {
365            if let Some(e) = r.find_mut(name) {
366                match write_disposition(e, expect_ref) {
367                    WriteDisposition::HookFlip => e.screen_state = None,
368                    WriteDisposition::Apply => {
369                        if notify_on_blocked {
370                            if let Some(new_rep) = rep {
371                                let prev_blocked = e
372                                    .screen_state
373                                    .as_ref()
374                                    .is_some_and(|s| s.state == "blocked");
375                                if new_rep.state == "blocked" && !prev_blocked {
376                                    blocked_notifs.push((name.clone(), new_rep.rule.clone()));
377                                }
378                            }
379                        }
380                        e.screen_state = rep.clone();
381                    }
382                    WriteDisposition::Skip => {}
383                }
384            }
385        }
386    });
387    if write.is_err() {
388        return; // nothing published; next sweep retries
389    }
390    for (name, rule) in blocked_notifs {
391        crate::daemon::notify_transition(name, rule);
392    }
393    for (name, _, rep) in &changes {
394        let _ = emitter.emit(
395            "screen_state_change",
396            &json!({
397                "name": name,
398                "state": rep.as_ref().map(|r| r.state.clone()),
399                "rule": rep.as_ref().map(|r| r.rule.clone()),
400                "seq": rep.as_ref().map(|r| r.seq),
401                "cleared": rep.is_none(),
402            }),
403        );
404    }
405}
406
407/// The hidden `fno-agents detect` debug verb (precedent: the hidden `claim`
408/// verb - matched with `matches!` in bin/client.rs, out of CLIENT_VERB_USAGE
409/// and the routable-verb parity guard). `detect explain <agent>` prints which
410/// authority currently badges the agent and, for screen-manifest, the matched
411/// rule + stored verdict + age. Read-only over the registry; no live
412/// re-evaluation in v1.
413pub fn run_detect(args: &[String]) -> i32 {
414    let (Some("explain"), Some(name)) = (args.first().map(String::as_str), args.get(1)) else {
415        eprintln!("usage: fno-agents detect explain <agent>");
416        return 2;
417    };
418    let home = AgentsHome::from_env();
419    let reg = match state::load_registry(&home.registry_json()) {
420        Ok(r) => r,
421        Err(e) => {
422            eprintln!("fno-agents: detect: registry read failed: {e}");
423            return 1;
424        }
425    };
426    let Some(entry) = reg.find(name) else {
427        eprintln!("fno-agents: detect: no such agent: {name}");
428        return 1;
429    };
430    let now_secs = std::time::SystemTime::now()
431        .duration_since(std::time::UNIX_EPOCH)
432        .unwrap_or_default()
433        .as_secs();
434    let age = |stamp: &str| -> String {
435        match state::rfc3339_like_to_secs(stamp) {
436            Some(at) => format!("{}s", now_secs.saturating_sub(at)),
437            None => format!("unparseable stamp {stamp:?}"),
438        }
439    };
440    println!("agent: {} (provider {})", entry.name, entry.harness_name());
441    // Mirrors the reader lattice: pane-exit > hook (capability) >
442    // screen-manifest > liveness.
443    if matches!(
444        entry.status,
445        AgentStatus::Exited | AgentStatus::PermanentDead
446    ) {
447        println!("authority: pane-exit (status {:?})", entry.status);
448    } else if let Some(leg) = &entry.inside_leg {
449        if leg.is_live_at(now_secs) {
450            println!(
451                "authority: hook (inside-leg report: state {:?}, seq {}, age {})",
452                leg.state,
453                leg.seq,
454                age(&leg.received_at)
455            );
456        } else {
457            println!(
458                "authority: liveness (hook report lapsed: seq {}, age {}; \
459                 row is hook-capable so the screen-manifest rung stays off)",
460                leg.seq,
461                age(&leg.received_at)
462            );
463        }
464    } else if let Some(ss) = &entry.screen_state {
465        if ss.is_live_at(now_secs) {
466            println!(
467                "authority: screen-manifest (rule {:?} -> state {:?}, seq {}, age {}, ttl {:?}ms)",
468                ss.rule,
469                ss.state,
470                ss.seq,
471                age(&ss.at),
472                ss.ttl_ms
473            );
474        } else {
475            println!(
476                "authority: liveness (scrape verdict lapsed: rule {:?}, age {})",
477                ss.rule,
478                age(&ss.at)
479            );
480        }
481    } else {
482        println!("authority: liveness (no hook report, no scrape verdict)");
483    }
484    0
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use crate::manifest::Manifest;
491
492    fn rep(state: &str, at: &str, seq: u64) -> ScreenStateReport {
493        ScreenStateReport {
494            state: state.into(),
495            rule: "r".into(),
496            seq,
497            at: at.into(),
498            ttl_ms: Some(SCREEN_STATE_TTL_MS),
499            answerable: None,
500        }
501    }
502
503    fn entry(name: &str, provider: &str) -> state::RegistryEntry {
504        state::RegistryEntry {
505            name: name.into(),
506            short_id: String::new(),
507            legacy_provider: provider.into(),
508            harness: None,
509            harness_session_id: None,
510            cwd: "/tmp/x".into(),
511            project_root: String::new(),
512            session_id: None,
513            legacy_claude_short_id: None,
514            claude_session_uuid: None,
515            messaging_socket_path: None,
516            codex_session_id: None,
517            gemini_session_id: None,
518            mcp_channel_id: None,
519            host_mode: None,
520            cc_session_id: None,
521            status: AgentStatus::Live,
522            last_message_at: None,
523            created_at: "2026-07-02T00:00:00Z".into(),
524            pid: None,
525            pid_start_time: None,
526            log_path: None,
527            last_reconciled_at: None,
528            inside_leg: None,
529            exited_at: None,
530            mux: None,
531            screen_state: None,
532            crown_level: None,
533            crown_scope: None,
534            crown_grantor: None,
535        }
536    }
537
538    const NOW_STAMP: &str = "2026-07-02T00:10:00Z";
539    fn now_secs() -> u64 {
540        state::rfc3339_like_to_secs(NOW_STAMP).unwrap()
541    }
542
543    // -- eligibility ------------------------------------------------------
544
545    #[test]
546    fn scrape_targets_selects_only_hookless_live_mux_rows() {
547        let mut reg = Registry::default();
548        // Eligible: mux-hosted, no inside_leg, live.
549        let mut ok = entry("scrapeme", "codex");
550        ok.mux = Some(state::MuxRef {
551            session: "main".into(),
552            pane_id: 7,
553        });
554        reg.entries.push(ok);
555        // Hook-capable (even a lapsed report): never scraped.
556        let mut hooked = entry("hooked", "claude");
557        hooked.mux = Some(state::MuxRef {
558            session: "main".into(),
559            pane_id: 8,
560        });
561        hooked.inside_leg = Some(state::InsideLegReport {
562            state: state::InsideLegState::Working,
563            seq: 1,
564            reason: None,
565            received_at: "2020-01-01T00:00:00Z".into(),
566            ttl_ms: Some(1),
567        });
568        reg.entries.push(hooked);
569        // Not mux-hosted: nothing to read.
570        reg.entries.push(entry("worker", "codex"));
571        // Non-live statuses are excluded: Exited/PermanentDead (pane-exit
572        // fact) and Orphaned/Failed (backend not live - codex P2).
573        for (i, status) in [
574            AgentStatus::Exited,
575            AgentStatus::PermanentDead,
576            AgentStatus::Orphaned,
577            AgentStatus::Failed,
578        ]
579        .into_iter()
580        .enumerate()
581        {
582            let mut dead = entry(&format!("dead{i}"), "codex");
583            dead.mux = Some(state::MuxRef {
584                session: "main".into(),
585                pane_id: 90 + i as u64,
586            });
587            dead.status = status;
588            reg.entries.push(dead);
589        }
590
591        let targets = scrape_targets(&reg);
592        assert_eq!(targets.len(), 1);
593        assert_eq!(targets[0].name, "scrapeme");
594        assert_eq!(targets[0].session, "main");
595        assert_eq!(targets[0].pane_id, 7);
596    }
597
598    #[test]
599    fn write_disposition_rechecks_hook_flip_and_mux_ref() {
600        // The locked-write re-checks against the row's CURRENT state, not the
601        // snapshot the sweep read.
602        let scraped = ("main".to_string(), 7u64);
603
604        // Row still on the scraped pane -> Apply.
605        let mut row = entry("r", "codex");
606        row.mux = Some(state::MuxRef {
607            session: "main".into(),
608            pane_id: 7,
609        });
610        assert_eq!(write_disposition(&row, &scraped), WriteDisposition::Apply);
611
612        // A capability flip landed since the snapshot -> HookFlip (clear).
613        row.inside_leg = Some(state::InsideLegReport {
614            state: state::InsideLegState::Working,
615            seq: 1,
616            reason: None,
617            received_at: NOW_STAMP.into(),
618            ttl_ms: None,
619        });
620        assert_eq!(
621            write_disposition(&row, &scraped),
622            WriteDisposition::HookFlip
623        );
624
625        // Row re-homed to a new pane since the snapshot -> Skip (codex P2:
626        // never stamp the old pane's verdict onto the new pane).
627        let mut rehomed = entry("r", "codex");
628        rehomed.mux = Some(state::MuxRef {
629            session: "main".into(),
630            pane_id: 8,
631        });
632        assert_eq!(
633            write_disposition(&rehomed, &scraped),
634            WriteDisposition::Skip
635        );
636
637        // Row lost its mux ref entirely -> Skip (not this pane anymore).
638        let mut unhosted = entry("r", "codex");
639        unhosted.mux = None;
640        assert_eq!(
641            write_disposition(&unhosted, &scraped),
642            WriteDisposition::Skip
643        );
644    }
645
646    // -- decide: write-on-change ------------------------------------------
647
648    fn verdict_from<'m>(manifest: &'m Manifest, text: &str) -> Option<Verdict<'m>> {
649        let view = ScreenView {
650            visible_text: text,
651            cursor_row: 0,
652            cursor_col: 0,
653            osc_title: None,
654            osc_progress: None,
655        };
656        manifest.evaluate(&view)
657    }
658
659    fn one_rule_manifest(state: &str, needle: &str, skip: bool) -> Manifest {
660        Manifest::parse(&format!(
661            "[[rule]]\nid = \"r\"\nstate = \"{state}\"\npriority = 100\n\
662             region = \"whole_recent\"\nskip_state_update = {skip}\n\
663             gate = {{ contains = \"{needle}\" }}\n"
664        ))
665        .unwrap()
666    }
667
668    #[test]
669    fn decide_first_verdict_writes_seq_one_with_ttl() {
670        let m = one_rule_manifest("working", "esc to interrupt", false);
671        let d = decide(
672            None,
673            verdict_from(&m, "esc to interrupt"),
674            None,
675            now_secs(),
676            NOW_STAMP,
677        );
678        let Decision::Write(rep) = d else {
679            panic!("expected Write, got {d:?}");
680        };
681        assert_eq!(rep.state, "working");
682        assert_eq!(rep.rule, "r");
683        assert_eq!(rep.seq, 1);
684        assert_eq!(rep.at, NOW_STAMP);
685        assert_eq!(rep.ttl_ms, Some(SCREEN_STATE_TTL_MS));
686    }
687
688    #[test]
689    fn decide_unchanged_fresh_verdict_holds_no_churn() {
690        // Same state, stamp 10s old (< refresh threshold): no write.
691        let m = one_rule_manifest("working", "busy", false);
692        let last = rep("working", "2026-07-02T00:09:50Z", 4);
693        let d = decide(
694            Some(&last),
695            verdict_from(&m, "busy"),
696            None,
697            now_secs(),
698            NOW_STAMP,
699        );
700        assert_eq!(d, Decision::Hold);
701    }
702
703    #[test]
704    fn decide_unchanged_stale_stamp_refreshes() {
705        // Same state but the stamp is past the refresh threshold: rewrite so
706        // the reader-side TTL never lapses under a live daemon.
707        let m = one_rule_manifest("working", "busy", false);
708        let last = rep("working", "2026-07-02T00:00:00Z", 4);
709        let d = decide(
710            Some(&last),
711            verdict_from(&m, "busy"),
712            None,
713            now_secs(),
714            NOW_STAMP,
715        );
716        let Decision::Write(new) = d else {
717            panic!("expected refresh Write, got {d:?}");
718        };
719        assert_eq!(new.state, "working");
720        assert_eq!(new.seq, 5);
721        assert_eq!(new.at, NOW_STAMP);
722    }
723
724    #[test]
725    fn decide_state_change_writes_immediately() {
726        let m = one_rule_manifest("blocked", "Do you want to proceed?", false);
727        let last = rep("working", NOW_STAMP, 2);
728        let d = decide(
729            Some(&last),
730            verdict_from(&m, "Do you want to proceed?"),
731            None,
732            now_secs(),
733            NOW_STAMP,
734        );
735        let Decision::Write(new) = d else {
736            panic!("expected Write, got {d:?}");
737        };
738        assert_eq!(new.state, "blocked");
739        assert_eq!(new.seq, 3);
740    }
741
742    #[test]
743    fn decide_no_match_holds_engine_never_guesses() {
744        let m = one_rule_manifest("working", "busy", false);
745        let last = rep("idle", NOW_STAMP, 1);
746        assert_eq!(
747            decide(
748                Some(&last),
749                verdict_from(&m, "nothing here"),
750                None,
751                now_secs(),
752                NOW_STAMP
753            ),
754            Decision::Hold
755        );
756        assert_eq!(
757            decide(
758                None,
759                verdict_from(&m, "nothing here"),
760                None,
761                now_secs(),
762                NOW_STAMP
763            ),
764            Decision::Hold
765        );
766    }
767
768    // -- detect explain (hidden debug verb) --------------------------------
769
770    /// AC: bad usage exits 2; an unknown agent exits 1 with a one-line error;
771    /// a known agent explains and exits 0. Takes the crate-wide env lock
772    /// (FNO_AGENTS_HOME mutation).
773    #[test]
774    fn run_detect_explain_exit_codes() {
775        let _guard = crate::claims::test_env_lock()
776            .lock()
777            .unwrap_or_else(|p| p.into_inner());
778        let dir = std::env::temp_dir().join(format!("fno-detect-{}", std::process::id()));
779        let _ = std::fs::remove_dir_all(&dir);
780        std::fs::create_dir_all(&dir).unwrap();
781        std::env::set_var("FNO_AGENTS_HOME", &dir);
782        let home = AgentsHome::from_env();
783        let mut scraped = entry("scrapee", "codex");
784        scraped.screen_state = Some(rep("idle", "2026-07-02T00:00:00Z", 1));
785        state::update_registry(&home.registry_json(), |r| r.entries.push(scraped)).unwrap();
786
787        let s = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
788        assert_eq!(run_detect(&s(&[])), 2, "missing op is a usage error");
789        assert_eq!(run_detect(&s(&["explain"])), 2, "missing agent name");
790        assert_eq!(run_detect(&s(&["explain", "ghost"])), 1, "unknown agent");
791        assert_eq!(run_detect(&s(&["explain", "scrapee"])), 0);
792
793        std::env::remove_var("FNO_AGENTS_HOME");
794        let _ = std::fs::remove_dir_all(&dir);
795    }
796
797    // -- sweep end-to-end over a stubbed mux CLI ---------------------------
798
799    /// Full sweep pass against a stub FNO_BIN: first sweep writes the verdict,
800    /// an unchanged screen holds (no churn), a changed screen rewrites, and a
801    /// vanished pane clears. Takes the crate-wide env lock (FNO_BIN mutation).
802    #[test]
803    fn scrape_sweep_writes_updates_and_clears_via_stub_mux() {
804        let _guard = crate::claims::test_env_lock()
805            .lock()
806            .unwrap_or_else(|p| p.into_inner());
807        let dir = std::env::temp_dir().join(format!("fno-scrape-sweep-{}", std::process::id()));
808        let _ = std::fs::remove_dir_all(&dir);
809        std::fs::create_dir_all(&dir).unwrap();
810        let home = AgentsHome::at(dir.join("agents"));
811        home.ensure_root().unwrap();
812
813        // One hook-less codex pane in session "main".
814        let mut row = entry("scrapee", "codex");
815        row.mux = Some(state::MuxRef {
816            session: "main".into(),
817            pane_id: 7,
818        });
819        state::update_registry(&home.registry_json(), |r| r.entries.push(row)).unwrap();
820
821        // Stub mux CLI: `pane ls` and `pane read` answer from files the test
822        // rewrites between sweeps.
823        let ls_path = dir.join("ls.json");
824        let read_path = dir.join("read.json");
825        let stub = dir.join("fno-stub.sh");
826        std::fs::write(
827            &stub,
828            format!(
829                "#!/bin/sh\ncase \"$3\" in\nls) cat {} ;;\nread) cat {} ;;\nesac\n",
830                ls_path.display(),
831                read_path.display()
832            ),
833        )
834        .unwrap();
835        #[cfg(unix)]
836        {
837            use std::os::unix::fs::PermissionsExt;
838            std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
839        }
840        std::env::set_var("FNO_BIN", &stub);
841
842        let live_pane =
843            r#"[{"pane_id":7,"squad_id":1,"tab_id":1,"cwd":"/w","child_pid":42,"title":null}]"#;
844        // codex.toml idle rule: a lone composer prompt on the last line.
845        std::fs::write(&ls_path, live_pane).unwrap();
846        std::fs::write(&read_path, r#"{"pane_id":7,"text":"some scrollback\n› "}"#).unwrap();
847        let emitter = EventEmitter::new(home.events_jsonl(), "test");
848
849        scrape_sweep(&home, &emitter, false);
850        let reg = state::load_registry(&home.registry_json()).unwrap();
851        let v = reg.entries[0].screen_state.clone().expect("verdict stored");
852        assert_eq!(v.state, "idle");
853        assert_eq!(v.seq, 1);
854
855        // Unchanged screen, fresh stamp: no write (seq stays).
856        scrape_sweep(&home, &emitter, false);
857        let reg = state::load_registry(&home.registry_json()).unwrap();
858        assert_eq!(reg.entries[0].screen_state.as_ref().unwrap().seq, 1);
859
860        // Screen flips to working (codex busy line): rewrite.
861        std::fs::write(
862            &read_path,
863            r#"{"pane_id":7,"text":"Working (3s • esc to interrupt)"}"#,
864        )
865        .unwrap();
866        scrape_sweep(&home, &emitter, false);
867        let reg = state::load_registry(&home.registry_json()).unwrap();
868        let v = reg.entries[0].screen_state.clone().expect("verdict kept");
869        assert_eq!(v.state, "working");
870        assert_eq!(v.seq, 2);
871
872        // Pane vanishes from the listing: verdict cleared (degrade to
873        // liveness, never a stale badge).
874        std::fs::write(&ls_path, "[]").unwrap();
875        scrape_sweep(&home, &emitter, false);
876        let reg = state::load_registry(&home.registry_json()).unwrap();
877        assert_eq!(reg.entries[0].screen_state, None);
878
879        std::env::remove_var("FNO_BIN");
880        let _ = std::fs::remove_dir_all(&dir);
881    }
882
883    #[test]
884    fn decide_skip_state_update_holds_current_and_keeps_it_fresh() {
885        // A pager-style rule must not flip the state; with a stale stamp it
886        // refreshes the HELD state (not the rule's own `state`).
887        let m = one_rule_manifest("idle", "Showing detailed transcript", true);
888        let held = rep("working", "2026-07-02T00:00:00Z", 6);
889        let d = decide(
890            Some(&held),
891            verdict_from(&m, "Showing detailed transcript"),
892            None,
893            now_secs(),
894            NOW_STAMP,
895        );
896        let Decision::Write(new) = d else {
897            panic!("expected refresh Write, got {d:?}");
898        };
899        assert_eq!(new.state, "working", "held state, not the rule's");
900        assert_eq!(new.seq, 7);
901        // Fresh stamp -> pure hold. No prior state -> nothing to hold.
902        let fresh = rep("working", NOW_STAMP, 6);
903        assert_eq!(
904            decide(
905                Some(&fresh),
906                verdict_from(&m, "Showing detailed transcript"),
907                None,
908                now_secs(),
909                NOW_STAMP
910            ),
911            Decision::Hold
912        );
913        assert_eq!(
914            decide(
915                None,
916                verdict_from(&m, "Showing detailed transcript"),
917                None,
918                now_secs(),
919                NOW_STAMP
920            ),
921            Decision::Hold
922        );
923    }
924}