From 9f6284ddc7782597c67dac9c627ac00bdc39483c Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 15 Aug 2026 15:45:06 -0700 Subject: [PATCH 01/11] feat(harness): recognize omp and read its exit hint --- src/harness/mod.rs | 10 + src/harness/omp.rs | 157 +++++++++++++++ src/harness/summary.rs | 17 ++ tests/corpus/README.md | 11 +- tests/corpus/omp_resume.bin | 372 ++++++++++++++++++++++++++++++++++++ 5 files changed, 562 insertions(+), 5 deletions(-) create mode 100644 src/harness/omp.rs create mode 100644 tests/corpus/omp_resume.bin diff --git a/src/harness/mod.rs b/src/harness/mod.rs index a6689e1..057f4d3 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -18,6 +18,7 @@ pub mod assets; mod claude; mod codex; mod grok; +mod omp; pub mod summary; use std::{ @@ -33,6 +34,7 @@ pub use codex::Codex; pub use grok::Grok; #[cfg(test)] pub(crate) use grok::encode_cwd; +pub use omp::Omp; /// Environment variable naming the capture file used by injected assets. pub const CAPTURE_ENV: &str = "FLEETCOM_CAPTURE_FILE"; @@ -149,6 +151,10 @@ static AGENTS: &[Agent] = &[ harness: &Grok, summary: &summary::GrokSummary, }, + Agent { + harness: &Omp, + summary: &summary::OmpSummary, + }, ]; /// Return the first harness that recognizes `cmd`. @@ -614,9 +620,13 @@ mod tests { assert_eq!(Claude.home_env_var(), "CLAUDE_CONFIG_DIR"); assert_eq!(Codex.home_env_var(), "CODEX_HOME"); assert_eq!(Grok.home_env_var(), "GROK_HOME"); + assert_eq!(Omp.home_env_var(), "PI_CODING_AGENT_DIR"); assert_eq!(Claude.home_dot_dir(), ".claude"); assert_eq!(Codex.home_dot_dir(), ".codex"); assert_eq!(Grok.home_dot_dir(), ".grok"); + // omp's store sits one level below its config root; both components + // reach `home_root`'s join. + assert_eq!(Omp.home_dot_dir(), ".omp/agent"); } #[test] diff --git a/src/harness/omp.rs b/src/harness/omp.rs new file mode 100644 index 0000000..dce5554 --- /dev/null +++ b/src/harness/omp.rs @@ -0,0 +1,157 @@ +//! omp cannot pin a session ID at launch: it has no `--session-id` flag, and +//! `--resume` rejects an ID that does not already exist, so a pinned UUID would +//! name a session the resume command could never reach. Capture therefore has +//! to come from omp itself — instrumentation in a later phase, and meanwhile +//! the hint it prints to stderr on exit, `Resume this session with omp +//! --resume `. A crash repeats the same command inside a `[Recovery]` +//! block as `Main: omp --resume `; both carry the command substring, so +//! one matcher reads both. +//! +//! omp's IDs are UUIDv7. [`is_uuid`](super::is_uuid) validates the 8-4-4-4-12 +//! lowercase-hex shape and not the version field, so they pass unchanged. +//! +//! `-r`, `--session`, and `-c` resume as well, but detection stays on the +//! canonical pair: a command fleetcom cannot rewrite exactly is left verbatim. + +use std::{path::Path, time::SystemTime}; + +use super::{CapturePaths, Harness, Invocation, SpawnPlan, last_hint}; + +pub struct Omp; + +impl Harness for Omp { + fn home_env_var(&self) -> &'static str { + "PI_CODING_AGENT_DIR" + } + + /// Two components: omp's session store sits one level below its config + /// root. `home_root`'s `join` keeps both. + fn home_dot_dir(&self) -> &'static str { + ".omp/agent" + } + + fn shape(&self) -> (&'static str, &'static str) { + ("omp", "--resume") + } + + fn instrument( + &self, + // Nothing distinguishes the two accepted shapes yet: omp cannot pin an + // ID at launch, and no capture channel is injected. + _inv: &Invocation, + _capture: &CapturePaths, + _home: Option<&Path>, + ) -> SpawnPlan { + // Capture injection lands in a later phase. Until then the command + // runs unmodified and `scrape_exit` is the only channel. + SpawnPlan::default() + } + + /// No capture channel is injected yet, so no payload is ever trusted. + fn parse_capture(&self, _payload: &str) -> Option { + None + } + + fn scrape_exit(&self, text: &str) -> Option { + // The last valid hint names the session at exit. The exit line and the + // crash recovery block print the same command. + last_hint(text, &["omp --resume "]) + } + + fn correlate_fs( + &self, + _cwd: &Path, + _spawned: SystemTime, + _home: Option<&Path>, + ) -> Option { + // The `/sessions//` scan lands in a later + // phase; refusing beats guessing until it exists. + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::harness::fixtures::{ID, OTHER, assert_all_opaque, assert_corpus_scrape, paths}; + + /// omp-specific opaque shapes: the `-r`/`--session` resume aliases, the + /// `-c`/`--continue` most-recent shortcut, a truncated ID, a prompt flag, + /// and a neighbouring program word. The syntax shared by every harness is + /// covered by the table test in `harness::tests`. + #[test] + fn everything_else_is_opaque_and_never_rewritten() { + let opaque: Vec = ["omp -c", "omp --continue", "omp --resume", "ompx"] + .iter() + .map(|s| s.to_string()) + .chain([ + format!("omp -r {ID}"), + format!("omp --session {ID}"), + format!("omp --resume {}", &ID[..8]), + "omp -p 'fix the tests'".to_string(), + ]) + .collect(); + assert_all_opaque(&Omp, ID, &opaque); + } + + /// Neither accepted shape gains an ID: omp has no `--session-id`, so a + /// pinned UUID would name a session `--resume` cannot reach. + #[test] + fn instrument_pins_no_id_for_either_accepted_shape() { + for cmd in ["omp".to_string(), format!("omp --resume {ID}")] { + let inv = Omp.detect(&cmd).unwrap(); + let plan = Omp.instrument(&inv, &paths(), None); + assert!(plan.injected_id.is_none(), "{cmd}"); + assert_eq!(plan, SpawnPlan::default(), "{cmd}"); + } + } + + #[test] + fn parse_capture_is_unconditionally_none() { + // No injected channel exists, so no payload is ever trusted. + let payload = format!(r#"{{"session_id":"{ID}"}}"#); + assert_eq!(Omp.parse_capture(&payload), None); + assert_eq!(Omp.parse_capture(""), None); + } + + #[test] + fn scrape_exit_reads_the_hint_and_takes_the_last() { + let hint = format!("Resume this session with omp --resume {ID}"); + assert_eq!(Omp.scrape_exit(&hint).as_deref(), Some(ID)); + + // The last hint by position wins. + let both = format!( + "Resume this session with omp --resume {OTHER}\n...\n\ + Resume this session with omp --resume {ID}\n" + ); + assert_eq!(Omp.scrape_exit(&both).as_deref(), Some(ID)); + + // A crash prints the same command indented under `[Recovery]`. + let crash = format!("[Recovery]\n Main: omp --resume {ID}\n"); + assert_eq!(Omp.scrape_exit(&crash).as_deref(), Some(ID)); + + assert_eq!(Omp.scrape_exit("no hint here"), None); + // A hint whose ID fails validation returns nothing. + assert_eq!(Omp.scrape_exit("omp --resume NOT-A-UUID"), None); + // A longer hexadecimal run is not an ID. + assert_eq!(Omp.scrape_exit(&format!("omp --resume {ID}ff")), None); + } + + #[test] + fn correlate_fs_is_unconditionally_none() { + assert_eq!( + Omp.correlate_fs(Path::new("/work"), SystemTime::now(), None), + None + ); + } + + /// The scraper recovers the exit-hint ID from the corpus terminal bytes. + #[test] + fn corpus_scrape_recovers_the_exit_hint_id() { + assert_corpus_scrape( + &Omp, + include_bytes!("../../tests/corpus/omp_resume.bin"), + "01a0078a-7714-7000-9927-f167df9b6476", + ); + } +} diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 2be7d7e..077479e 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -676,6 +676,23 @@ fn grok_border_label(row: &str) -> Option { (!label.is_empty()).then(|| label.to_string()) } +// -------------------------------------------------------------------- omp -- + +/// omp placeholder. The row matchers land in a later phase; returning `None` +/// from both probes keeps [`select`] resolving for `omp` while its tasks fall +/// through to the existing preview tiers. +pub struct OmpSummary; + +impl SummaryAdapter for OmpSummary { + fn live_preview(&self, _rows: &[String]) -> Option<(String, &'static str)> { + None + } + + fn model_label(&self, _rows: &[String]) -> Option { + None + } +} + #[cfg(test)] #[path = "summary_tests.rs"] mod tests; diff --git a/tests/corpus/README.md b/tests/corpus/README.md index d8c7449..dbccc83 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -18,6 +18,7 @@ feed the bytes to the emulator verbatim. | `claude_resume.bin` | `claude --session-id`: one prompt, reply, `/exit` | alternate-screen exit followed by the primary-screen resume hint (`claude --resume `); the scrape target for harness exit capture | | `codex_resume.bin` | `codex resume` | top-anchored DECSTBM scroll regions (`CSI 1;N r`), reverse index, inline-TUI history insertion, and an SGR-split resume hint for harness exit capture | | `grok_resume.bin` | `grok --session-id`: one prompt, reply, `/exit` | primary-screen exit followed by the resume hint (`grok --resume `); the scrape target for harness exit capture | +| `omp_resume.bin` | `omp`: one launch, `/exit` | primary-screen exit followed by the resume hint (`omp --resume `); the scrape target for harness exit capture. At 143 KB it dwarfs its neighbours: omp's welcome box paints a per-character truecolor gradient logo | | `tmux_split.bin` | `tmux` session with two splits and one command per pane | scroll regions, pane borders, full redraws | | `vim_session.bin` | `vim -u NONE`: insert, navigate, `:set number`, `:q!` | alternate screen, cursor addressing, line editing | | `less_altscreen.bin` | `less` over `/usr/share/dict/words`: page, `G`, `g`, `q` | alternate-screen entry and exit, full-screen paging | @@ -93,10 +94,10 @@ The fixtures provide evidence for three distinct boundaries: - `codex_resume`, `wide_emoji`, `dec_scrollregion`, and `topregion_scroll` pin parser semantics: scrollback retention, intensity stacking, charset translation, and VS16 width. -- `claude_resume`, `codex_resume`, and `grok_resume` verify that retained - terminal text preserves the exit hints consumed by their harnesses. +- `claude_resume`, `codex_resume`, `grok_resume`, and `omp_resume` verify that + retained terminal text preserves the exit hints consumed by their harnesses. `src/golden.rs` contains the absolute display and parser expectations. -`src/harness/claude.rs`, `src/harness/codex.rs`, and `src/harness/grok.rs` -contain the agent-resume scrape expectations. `src/harness/summary.rs` -contains the preview-fixture expectations. +`src/harness/claude.rs`, `src/harness/codex.rs`, `src/harness/grok.rs`, and +`src/harness/omp.rs` contain the agent-resume scrape expectations. +`src/harness/summary.rs` contains the preview-fixture expectations. diff --git a/tests/corpus/omp_resume.bin b/tests/corpus/omp_resume.bin new file mode 100644 index 0000000..a376c1f --- /dev/null +++ b/tests/corpus/omp_resume.bin @@ -0,0 +1,372 @@ +[?2004h[?1l>[?u]11;?[?2031h[?2026$p[?2048$p[?2031$p[?1010$p[?1011$p[?5522h[?25l]0;π >]0;π > work3[?25l[?2026h[?7l +╭─── omp v17.3.4 ──────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Welcome back! │ +│ │ +│ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │ +│ │ +│ /Users/chris/Documents/Models/Qwen/Qwen3.8-27B-UD-Q8_K_XL.gguf │ +│ llama.cpp │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + Tip: `/copy code` grabs the last code block to your clipboard — `/copy cmd` grabs the last +  shell/python command + + Connecting to MCP servers: node_repl…  + +╭── π  > ⬢ /Users/chris/Documents/Models/Qwen/Qwen3.8-27B-UD-Q8_K_XL.gguf · ◒ high > 🗑 ]8;id=8327217c;file:///tmp/claude-501/-Users-chris-Documents-Code-Rust-fleetcom/195801f2-9949-4130-addc-4289c1cde424/scratchpad/work3\…hpad/work3]8;;\ > ◫ 11.8%/131K ⟲ ▶──╮]8;; +╰─   ─╯ + + + + + + + + + + + + + + + + + + +[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[>4;2m[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │ +│ │ +│ /Users/chris/Documents/Models/Qwen/Qwen3.8-27B-UD-Q8_K_XL.gguf │ +│ llama.cpp │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + Tip: `/copy code` grabs the last code block to your clipboard — `/copy cmd` grabs the last +  shell/python command + + xdev: xd://: mounted mcp__node_repl_js, mcp__node_repl_js_add_node_module_dir, mcp__node_repl_js_reset  + +╭── π  > ⬢ /Users/chris/Documents/Models/Qwen/Qwen3.8-27B-UD-Q8_K_XL.gguf · ◒ high > 🗑 ]8;id=8327217c;file:///tmp/claude-501/-Users-chris-Documents-Code-Rust-fleetcom/195801f2-9949-4130-addc-4289c1cde424/scratchpad/work3\…hpad/work3]8;;\ > ◫ 12.2%/131K ⟲ ▶──╮]8;;[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[?25l[?2026h[?7l │ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │[?25h[?7h[?2026l[>4;0m[?25l[?2026h[?7l  Closing session… [?25h[?7h[?2026l [?25h[?2026l[?7h[?1l>[?2004l[?5522l[?1006l[?1003l[?1000l[?2031l +Resume this session with omp --resume 01a0078a-7714-7000-9927-f167df9b6476 +[?2026l[?7h[?1l>[?2004l[?2031l[?2048l[?5522l[?1006l[?1003l[?1000l[?25h \ No newline at end of file From ae991c501343327051256dba55cde7dd4a922577 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 15 Aug 2026 15:56:20 -0700 Subject: [PATCH 02/11] feat(harness): resolve omp's store root and correlate its sessions --- src/harness/mod.rs | 21 +- src/harness/omp.rs | 471 +++++++++++++++++++++++++++++++++++++++++++-- src/supervisor.rs | 9 +- 3 files changed, 474 insertions(+), 27 deletions(-) diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 057f4d3..cbecd59 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -56,6 +56,17 @@ pub trait Harness: Sync { /// The tool's directory name under the launched process's `$HOME`. fn home_dot_dir(&self) -> &'static str; + /// Resolve the tool's store root from the launch environment. `env` reads + /// one variable from that environment and returns `None` when it is unset; + /// an unset value and an empty one stay distinguishable, which omp's + /// resolution depends on. The default is the two-step rule every harness + /// but omp follows: the tool-specific override, else `$HOME` joined with + /// the dot directory. Neither step reaching a value returns `None` so the + /// harness can still apply its platform-home fallback in `home_root`. + fn resolve_home(&self, env: &dyn Fn(&str) -> Option) -> Option { + env(self.home_env_var()).or_else(|| Some(env("HOME")?.join(self.home_dot_dir()))) + } + /// Resolve the tool's home root. `home` follows the `instrument` contract: /// falling back to this process's home happens only when the launch /// environment supplied neither the tool-specific override nor `HOME`. @@ -620,13 +631,15 @@ mod tests { assert_eq!(Claude.home_env_var(), "CLAUDE_CONFIG_DIR"); assert_eq!(Codex.home_env_var(), "CODEX_HOME"); assert_eq!(Grok.home_env_var(), "GROK_HOME"); - assert_eq!(Omp.home_env_var(), "PI_CODING_AGENT_DIR"); + // omp's "home" is its sessions root, and `PI_CODING_AGENT_SESSION_DIR` + // is the one variable that names it verbatim. + assert_eq!(Omp.home_env_var(), "PI_CODING_AGENT_SESSION_DIR"); assert_eq!(Claude.home_dot_dir(), ".claude"); assert_eq!(Codex.home_dot_dir(), ".codex"); assert_eq!(Grok.home_dot_dir(), ".grok"); - // omp's store sits one level below its config root; both components - // reach `home_root`'s join. - assert_eq!(Omp.home_dot_dir(), ".omp/agent"); + // omp resolves to its sessions root, two levels below the config + // root; every component reaches `home_root`'s join. + assert_eq!(Omp.home_dot_dir(), ".omp/agent/sessions"); } #[test] diff --git a/src/harness/omp.rs b/src/harness/omp.rs index dce5554..c1433ff 100644 --- a/src/harness/omp.rs +++ b/src/harness/omp.rs @@ -12,22 +12,104 @@ //! //! `-r`, `--session`, and `-c` resume as well, but detection stays on the //! canonical pair: a command fleetcom cannot rewrite exactly is left verbatim. +//! +//! # The store +//! +//! Sessions live at `//_.jsonl`. +//! The harness home *is* the sessions root: `PI_CODING_AGENT_SESSION_DIR` +//! names a sessions directory outright, so no agent-dir value can express it. +//! +//! The bucket name is deliberately not reproduced. omp encodes a cwd through +//! three scopes — under `$HOME`, under `os.tmpdir()`, otherwise absolute — +//! after realpath-canonicalising cwd, home, and `$TMPDIR`, and the scheme +//! changed three times inside the 17.2.x line, each change shipping an on-disk +//! migration. Reimplementing it would mean tracking those revisions forever. +//! Correlation therefore enumerates the buckets and confirms the cwd from the +//! file's own header, unlike `grok.rs`, which computes its group name. -use std::{path::Path, time::SystemTime}; +use std::{ + fs, + io::{BufRead, BufReader, Read}, + path::{Path, PathBuf}, + time::SystemTime, +}; -use super::{CapturePaths, Harness, Invocation, SpawnPlan, last_hint}; +use super::{CapturePaths, Harness, Invocation, SpawnPlan, is_uuid, last_hint, within_window_ms}; pub struct Omp; impl Harness for Omp { + /// The only variable that names the sessions root outright. The rest of + /// omp's chain builds that path instead of naming it, so it lives in + /// [`Omp::resolve_home`]. fn home_env_var(&self) -> &'static str { - "PI_CODING_AGENT_DIR" + "PI_CODING_AGENT_SESSION_DIR" } - /// Two components: omp's session store sits one level below its config - /// root. `home_root`'s `join` keeps both. + /// Three components: the store sits two levels below the config root. + /// `home_root`'s `join` keeps all of them. fn home_dot_dir(&self) -> &'static str { - ".omp/agent" + ".omp/agent/sessions" + } + + /// Resolve omp's **sessions root** — not its agent directory. + /// `PI_CODING_AGENT_SESSION_DIR` names a sessions directory directly, so + /// no single agent-dir value could express every outcome of this chain, + /// and the sessions root is the only level all four branches agree on. + /// + /// Precedence: the session-dir override wins verbatim; otherwise the agent + /// directory is `$HOME/[/profiles/] + /// /agent`, which `PI_CODING_AGENT_DIR` replaces unless a profile is + /// selected; and an XDG data directory that already exists on disk + /// redirects the still-default agent directory, flattening the `agent/` + /// level away. Only `OMP_PROFILE` is read by presence — omp lets an empty + /// `OMP_PROFILE` suppress `PI_PROFILE`. Every other variable is read the + /// way JavaScript truthiness reads it: empty means unset. + fn resolve_home(&self, env: &dyn Fn(&str) -> Option) -> Option { + let set = |key: &str| env(key).filter(|p| !p.as_os_str().is_empty()); + if let Some(sessions) = set(self.home_env_var()) { + return Some(sessions); + } + + // Presence of `OMP_PROFILE` decides; an empty value selects no profile + // and still shadows `PI_PROFILE`. + let profile = match env("OMP_PROFILE") { + Some(p) => p, + None => env("PI_PROFILE").unwrap_or_default(), + }; + let profile = Some(profile).filter(|p| !p.as_os_str().is_empty()); + + // `PI_CONFIG_DIR` is a directory *name*, so the relative case is the + // only one omp documents, and there the two agree. An absolute value + // diverges: node's `path.join` concatenates it under `$HOME`, while + // `Path::join` lets it replace `$HOME` outright. Correlation then + // reads a store omp never wrote and finds nothing, which is the safe + // direction — resume falls back to the launch command rather than + // reopening some other conversation. + let config = env("HOME")?.join(set("PI_CONFIG_DIR").unwrap_or_else(|| ".omp".into())); + let root = match &profile { + Some(p) => config.join("profiles").join(p), + None => config, + }; + + // A named profile ignores `PI_CODING_AGENT_DIR`, and an agent + // directory named that way is never redirected by XDG. + if let (None, Some(agent)) = (&profile, set("PI_CODING_AGENT_DIR")) { + return Some(agent.join("sessions")); + } + + // The XDG redirect is conditional on the directory already existing: + // omp checks the filesystem, whatever its own doc comment claims. + if let Some(xdg) = set("XDG_DATA_HOME") { + let data = match &profile { + Some(p) => xdg.join("omp").join("profiles").join(p), + None => xdg.join("omp"), + }; + if data.exists() { + return Some(data.join("sessions")); + } + } + Some(root.join("agent").join("sessions")) } fn shape(&self) -> (&'static str, &'static str) { @@ -58,22 +140,164 @@ impl Harness for Omp { last_hint(text, &["omp --resume "]) } - fn correlate_fs( - &self, - _cwd: &Path, - _spawned: SystemTime, - _home: Option<&Path>, - ) -> Option { - // The `/sessions//` scan lands in a later - // phase; refusing beats guessing until it exists. - None + /// Scan every cwd bucket for the one in-window session whose header names + /// `cwd`. The id is the filename text after the last `_`: omp parses it + /// that way, and neither the leading timestamp nor the id contains `_`. + /// + /// A just-launched session may legitimately have no file at all — omp + /// keeps a session in memory until it holds an assistant message, so + /// nothing is written before the model replies. A session with no reply + /// has nothing worth resuming, so an empty bucket is not an error. + fn correlate_fs(&self, cwd: &Path, spawned: SystemTime, home: Option<&Path>) -> Option { + let sessions = self.home_root(home)?; + let spawn_ms = spawned + .duration_since(SystemTime::UNIX_EPOCH) + .ok()? + .as_millis(); + // The bucket is named from the canonical cwd while the header records + // the resolved-but-uncanonicalised one, so both forms must match. + let canon = cwd.canonicalize().ok(); + let mut survivors: Vec = Vec::new(); + for bucket in fs::read_dir(sessions).ok()?.flatten() { + let Ok(entries) = fs::read_dir(bucket.path()) else { + continue; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(id) = name + .to_str() + .and_then(|n| n.strip_suffix(".jsonl")) + .and_then(|stem| stem.rsplit_once('_')) + .map(|(_, id)| id) + .filter(|id| is_uuid(id)) + else { + continue; + }; + // An id omp did not mint carries no creation instant. Skip it + // rather than guess one from the filename or the metadata. + let Some(ms) = v7_millis(id) else { continue }; + if !within_window_ms(u128::from(ms), spawn_ms) { + continue; + } + if !header_cwd_matches(&entry.path(), cwd, canon.as_deref()) { + continue; + } + survivors.push(id.to_string()); + } + } + match survivors.as_slice() { + [only] => Some(only.clone()), + _ => None, + } + } +} + +/// Milliseconds embedded in the first 48 bits of a UUIDv7: the session's +/// creation instant. `None` when `id` is not v7. `id` must already satisfy +/// [`is_uuid`], which fixes its length and alphabet. +/// +/// `codex.rs` carries the same six lines. The duplication is deliberate — the +/// two stores are unrelated and neither module should own the other's +/// helper — and is marked here so a later reconciliation pass can find both. +fn v7_millis(id: &str) -> Option { + if id.as_bytes()[14] != b'7' { + return None; + } + u64::from_str_radix(&format!("{}{}", &id[..8], &id[9..13]), 16).ok() +} + +/// Whether the session header names `cwd`, in either the given or the +/// canonical form. Line 1 is a fixed-width mutable title slot in current omp +/// and the header itself in older files, so both lines are tried and nothing +/// past them is read: transcripts grow to megabytes and only the header +/// participates. +fn header_cwd_matches(path: &Path, cwd: &Path, canon: Option<&Path>) -> bool { + let Ok(file) = fs::File::open(path) else { + return false; + }; + let mut reader = BufReader::new(file.take(64 * 1024)); + let mut line = String::new(); + for _ in 0..2 { + line.clear(); + if !matches!(reader.read_line(&mut line), Ok(n) if n > 0) { + return false; + } + let Ok(record) = jzon::parse(&line) else { + continue; + }; + if record["type"].as_str() != Some("session") { + continue; + } + return record["cwd"].as_str().is_some_and(|c| { + let header = Path::new(c); + header == cwd || Some(header) == canon + }); } + false } #[cfg(test)] mod tests { + use std::time::Duration; + use super::*; - use crate::harness::fixtures::{ID, OTHER, assert_all_opaque, assert_corpus_scrape, paths}; + use crate::{ + harness::fixtures::{ID, OTHER, assert_all_opaque, assert_corpus_scrape, paths}, + testutil::{temp, v7_at}, + }; + + /// Spawn instant shared by the correlation tests: 2026-08-15T22:13:20Z. + const SPAWN_MS: u64 = 1_786_000_000_000; + /// Working directory recorded in the generated headers. + const CWD: &str = "/work/proj"; + + fn spawned() -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_millis(SPAWN_MS) + } + + /// omp's line 1: one 256-byte record whose `pad` field absorbs the slack, + /// so a retitle rewrites the line in place without moving the header. + fn title_slot() -> String { + let head = concat!( + r#"{"type":"title","v":1,"title":"Run ls -la","source":"auto","#, + r#""updatedAt":"2026-08-15T22:19:51.048Z","pad":""# + ); + let tail = r#""}"#; + format!("{head}{}{tail}", " ".repeat(256 - head.len() - tail.len())) + } + + /// Write a session file under `//`, optionally + /// behind the title slot. + fn write_named(sessions: &Path, bucket: &str, file: &str, id: &str, cwd: &str, slot: bool) { + let dir = sessions.join(bucket); + fs::create_dir_all(&dir).unwrap(); + let mut body = String::new(); + if slot { + body.push_str(&title_slot()); + body.push('\n'); + } + body.push_str(&format!( + r#"{{"type":"session","version":3,"id":"{id}","timestamp":"2026-08-15T22:19:51.048Z","cwd":"{cwd}","title":"Run ls -la"}}"# + )); + // A real transcript continues past the header; correlation must not. + body.push_str("\n{\"type\":\"message\",\"role\":\"assistant\"}\n"); + fs::write(dir.join(file), body).unwrap(); + } + + /// Write a session under omp's own `_.jsonl` naming. + fn write_session(sessions: &Path, bucket: &str, id: &str, cwd: &str, slot: bool) { + let file = format!("2026-08-15T22-19-51-048Z_{id}.jsonl"); + write_named(sessions, bucket, &file, id, cwd, slot); + } + + /// Resolve omp's sessions root against a synthetic launch environment. + fn home(env: &[(&str, &str)]) -> Option { + Omp.resolve_home(&|key| { + env.iter() + .find(|(name, _)| *name == key) + .map(|(_, value)| PathBuf::from(value)) + }) + } /// omp-specific opaque shapes: the `-r`/`--session` resume aliases, the /// `-c`/`--continue` most-recent shortcut, a truncated ID, a prompt flag, @@ -137,12 +361,223 @@ mod tests { assert_eq!(Omp.scrape_exit(&format!("omp --resume {ID}ff")), None); } + /// One in-window session whose header names the task's cwd correlates; + /// another directory, another window, and a second candidate do not. #[test] - fn correlate_fs_is_unconditionally_none() { + fn correlate_fs_requires_a_unique_in_window_session_for_the_cwd() { + let sessions = temp("omp_correlate"); + let id = v7_at(SPAWN_MS + 4_000, 1); + write_session(&sessions, "bucket", &id, CWD, true); + assert_eq!( + Omp.correlate_fs(Path::new(CWD), spawned(), Some(&sessions)) + .as_deref(), + Some(id.as_str()) + ); + + // The header decides the directory, so another cwd matches nothing. assert_eq!( - Omp.correlate_fs(Path::new("/work"), SystemTime::now(), None), + Omp.correlate_fs(Path::new("/elsewhere"), spawned(), Some(&sessions)), None ); + + // A session minted 90 s later falls outside the window. + let late = v7_at(SPAWN_MS + 90_000, 2); + write_session(&sessions, "late", &late, "/late/proj", true); + assert_eq!( + Omp.correlate_fs(Path::new("/late/proj"), spawned(), Some(&sessions)), + None + ); + + // Two in-window sessions for one directory cannot be told apart. + write_session(&sessions, "bucket", &v7_at(SPAWN_MS + 8_000, 3), CWD, true); + assert_eq!( + Omp.correlate_fs(Path::new(CWD), spawned(), Some(&sessions)), + None + ); + } + + /// Files written before the title slot existed start at the header. + #[test] + fn correlate_fs_reads_a_legacy_file_whose_header_is_line_one() { + let sessions = temp("omp_legacy"); + let id = v7_at(SPAWN_MS + 1_000, 5); + write_session(&sessions, "bucket", &id, CWD, false); + assert_eq!( + Omp.correlate_fs(Path::new(CWD), spawned(), Some(&sessions)) + .as_deref(), + Some(id.as_str()) + ); + } + + /// Names omp did not write contribute nothing, and neither does a bucket + /// whose session has not been persisted yet. + #[test] + fn correlate_fs_refuses_names_and_buckets_it_cannot_read() { + let sessions = temp("omp_names"); + // A v4 id: omp never minted it, so it embeds no creation instant. + let v4 = format!("2026-08-15T22-19-51-048Z_{ID}.jsonl"); + write_named(&sessions, "v4", &v4, ID, CWD, true); + // Without a `_` nothing marks where the id starts. + let good = v7_at(SPAWN_MS + 1_000, 6); + write_named( + &sessions, + "nosep", + &format!("{good}.jsonl"), + &good, + CWD, + true, + ); + // The bucket exists from launch; the file appears only once the model + // replies, so an empty bucket is ordinary. + fs::create_dir_all(sessions.join("empty")).unwrap(); + assert_eq!( + Omp.correlate_fs(Path::new(CWD), spawned(), Some(&sessions)), + None + ); + + // The same records under omp's naming correlate, and stay unique: + // neither skipped file counts as a second candidate. + write_session(&sessions, "good", &good, CWD, true); + assert_eq!( + Omp.correlate_fs(Path::new(CWD), spawned(), Some(&sessions)) + .as_deref(), + Some(good.as_str()) + ); + } + + /// The bucket is named from the canonical cwd while the header keeps the + /// resolved one, so a task launched through a symlink still matches. + #[test] + fn correlate_fs_matches_a_symlinked_cwd_through_its_canonical_form() { + let tmp = temp("omp_canon"); + let (real, link) = (tmp.join("real"), tmp.join("link")); + fs::create_dir_all(&real).unwrap(); + std::os::unix::fs::symlink(&real, &link).unwrap(); + let canonical = real.canonicalize().unwrap(); + let sessions = tmp.join("sessions"); + let id = v7_at(SPAWN_MS + 2_000, 4); + write_session(&sessions, "bucket", &id, canonical.to_str().unwrap(), true); + assert_eq!( + Omp.correlate_fs(&link, spawned(), Some(&sessions)) + .as_deref(), + Some(id.as_str()) + ); + } + + /// Every branch of the chain resolves to a sessions root. + #[test] + fn resolve_home_walks_the_store_root_chain() { + assert_eq!( + home(&[("HOME", "/h")]), + Some("/h/.omp/agent/sessions".into()) + ); + + // The session dir names the store outright and bypasses the rest. + assert_eq!( + home(&[ + ("HOME", "/h"), + ("PI_CONFIG_DIR", ".alt"), + ("PI_CODING_AGENT_DIR", "/a"), + ("PI_CODING_AGENT_SESSION_DIR", "/s"), + ]), + Some("/s".into()) + ); + // Set but empty is unset. + assert_eq!( + home(&[("HOME", "/h"), ("PI_CODING_AGENT_SESSION_DIR", "")]), + Some("/h/.omp/agent/sessions".into()) + ); + + // The agent dir replaces `/agent` whole. + assert_eq!( + home(&[("HOME", "/h"), ("PI_CODING_AGENT_DIR", "/a")]), + Some("/a/sessions".into()) + ); + // A selected profile ignores it. + assert_eq!( + home(&[ + ("HOME", "/h"), + ("PI_CODING_AGENT_DIR", "/a"), + ("PI_PROFILE", "work"), + ]), + Some("/h/.omp/profiles/work/agent/sessions".into()) + ); + // `OMP_PROFILE` wins by presence: it selects when non-empty and + // suppresses `PI_PROFILE` when empty. + assert_eq!( + home(&[("HOME", "/h"), ("OMP_PROFILE", "a"), ("PI_PROFILE", "b")]), + Some("/h/.omp/profiles/a/agent/sessions".into()) + ); + assert_eq!( + home(&[ + ("HOME", "/h"), + ("OMP_PROFILE", ""), + ("PI_PROFILE", "b"), + ("PI_CODING_AGENT_DIR", "/a"), + ]), + Some("/a/sessions".into()) + ); + + // `PI_CONFIG_DIR` renames the config root. omp documents it as a + // directory name, and the relative case is where the two agree. + assert_eq!( + home(&[("HOME", "/h"), ("PI_CONFIG_DIR", ".alt")]), + Some("/h/.alt/agent/sessions".into()) + ); + // An absolute value diverges by design: omp would concatenate it under + // `$HOME` (`/h/abs`), `Path::join` lets it replace `$HOME`. Pinned so + // the divergence is deliberate rather than discovered later — it + // misses the store and correlation returns nothing, never the wrong + // session. + assert_eq!( + home(&[("HOME", "/h"), ("PI_CONFIG_DIR", "/abs")]), + Some("/abs/agent/sessions".into()) + ); + + // Without `HOME` or an override there is nothing to build from, and + // `home_root` applies its platform fallback instead. + assert_eq!(home(&[]), None); + } + + /// The XDG redirect is conditional on the directory already existing, and + /// it drops the `agent/` level. + #[test] + fn resolve_home_redirects_to_xdg_only_when_that_directory_exists() { + let dir = temp("omp_xdg"); + let xdg = dir.to_str().unwrap(); + + assert_eq!( + home(&[("HOME", "/h"), ("XDG_DATA_HOME", xdg)]), + Some("/h/.omp/agent/sessions".into()) + ); + fs::create_dir_all(dir.join("omp")).unwrap(); + assert_eq!( + home(&[("HOME", "/h"), ("XDG_DATA_HOME", xdg)]), + Some(dir.join("omp/sessions")) + ); + + // With a profile the profile directory is what must exist. + let profile = [ + ("HOME", "/h"), + ("XDG_DATA_HOME", xdg), + ("OMP_PROFILE", "work"), + ]; + assert_eq!( + home(&profile), + Some("/h/.omp/profiles/work/agent/sessions".into()) + ); + fs::create_dir_all(dir.join("omp/profiles/work")).unwrap(); + assert_eq!(home(&profile), Some(dir.join("omp/profiles/work/sessions"))); + + // An agent dir named outright is never redirected. + assert_eq!( + home(&[ + ("HOME", "/h"), + ("XDG_DATA_HOME", xdg), + ("PI_CODING_AGENT_DIR", "/a"), + ]), + Some("/a/sessions".into()) + ); } /// The scraper recovers the exit-hint ID from the corpus terminal bytes. diff --git a/src/supervisor.rs b/src/supervisor.rs index 1ac22eb..7f25ed3 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -168,12 +168,11 @@ fn scrape_now(t: &mut Task) { t.scrape_exit_hint(); } -/// Resolve the harness home from the launch environment. The tool-specific -/// override wins, followed by `$HOME/`; neither yields -/// `None` so the harness can apply its platform-home fallback. +/// Resolve the harness home from the launch environment. The harness owns the +/// rule and reads whichever variables it needs; the default is the +/// tool-specific override followed by `$HOME/`. fn harness_home(env: &[(OsString, OsString)], h: &dyn harness::Harness) -> Option { - let val = |key: &str| env_get(env, key).map(PathBuf::from); - val(h.home_env_var()).or_else(|| Some(val("HOME")?.join(h.home_dot_dir()))) + h.resolve_home(&|key| env_get(env, key).map(PathBuf::from)) } /// State for automatic recovery snapshots. Write failures do not interrupt From e864b95c20f30744942b48c1845149983a5857a6 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 15 Aug 2026 16:02:46 -0700 Subject: [PATCH 03/11] feat(harness): read omp's live status from its inline chrome --- src/harness/summary.rs | 106 ++++++++++++-- src/harness/summary_tests.rs | 182 +++++++++++++++++++++++++ tests/corpus/README.md | 15 +- tests/corpus/preview_omp_approval.bin | 40 ++++++ tests/corpus/preview_omp_body_hint.bin | 39 ++++++ tests/corpus/preview_omp_idle.bin | 39 ++++++ tests/corpus/preview_omp_working.bin | 39 ++++++ 7 files changed, 446 insertions(+), 14 deletions(-) create mode 100644 tests/corpus/preview_omp_approval.bin create mode 100644 tests/corpus/preview_omp_body_hint.bin create mode 100644 tests/corpus/preview_omp_idle.bin create mode 100644 tests/corpus/preview_omp_working.bin diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 077479e..41d0be3 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -13,17 +13,17 @@ //! To avoid treating it as live status, every matcher: //! //! 1. locates the chrome region structurally (claude's separator-pair input -//! box, codex's composer, grok's bordered input box) and limits status -//! candidates relative to it; +//! box, codex's composer, grok's bordered input box, omp's two-row input +//! box) and limits status candidates relative to it; //! 2. returns `None` when the expected structure is absent or inconsistent; //! 3. matches row prefixes so status rows truncated with an ellipsis at narrow //! widths remain recognizable. A wrapped row fails the structural check. //! //! Normalization removes spinner glyphs, elapsed counters, throughput data, //! and key hints while preserving the CLI's status text. The only synthesized -//! status is `awaiting approval`, for approval menus: claude's dialog and -//! codex's modal. Corpus fixtures in `tests/corpus` pin the supported screen -//! structures. +//! status is `awaiting approval`, for approval menus: claude's dialog, +//! codex's modal, and omp's selector. Corpus fixtures in `tests/corpus` pin +//! the supported screen structures. use std::path::Path; @@ -678,21 +678,107 @@ fn grok_border_label(row: &str) -> Option { // -------------------------------------------------------------------- omp -- -/// omp placeholder. The row matchers land in a later phase; returning `None` -/// from both probes keeps [`select`] resolving for `omp` while its tasks fall -/// through to the existing preview tiers. +/// Accepted omp spinner frames: the default unicode/nerd braille cycle +/// (`⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`, taken as the whole braille block) and the ascii preset's +/// `-\|/`. The set is themeable and the ascii frames are ordinary +/// punctuation, so a frame alone never makes a row status; the bracketed +/// interrupt hint on the same row does. +fn omp_frame(c: char) -> bool { + ('\u{2800}'..='\u{28FF}').contains(&c) || matches!(c, '-' | '\\' | '|' | '/') +} + +/// The interrupt hint closing omp's status row, one spelling per bracket +/// theme: unicode, nerd, ascii. The inner word is always `esc`. +const OMP_HINTS: &[&str] = &["⟦esc⟧", "⟨esc⟩", "[esc]"]; + +/// omp (inline UI, primary screen). The pin is its two-row input box: a +/// `╭…╮` status border directly above the `╰…╯` row the user types on. The +/// status row is the first painted row above that pair. +/// The approval selector replaces the box outright, so the box's absence — +/// not matcher order — is what separates a blocked task from a busy one: +/// omp keeps animating the status row underneath the selector. pub struct OmpSummary; impl SummaryAdapter for OmpSummary { - fn live_preview(&self, _rows: &[String]) -> Option<(String, &'static str)> { - None + fn live_preview(&self, rows: &[String]) -> Option<(String, &'static str)> { + match omp_input_box(rows) { + Some(top) => omp_spinner_status(rows, top), + // Consider the approval selector only with the input box gone. + None => omp_approval(rows), + } } + /// No label. omp's model text lives in its status line, a + /// user-configurable segment list — the same reason [`ClaudeSummary`] + /// reads the welcome box instead of the statusline. Observed values are + /// absolute paths to local model files, long enough to swamp the + /// preview on their own. fn model_label(&self, _rows: &[String]) -> Option { None } } +/// omp's input box: the bottom-most `╰…╯` row whose immediately preceding +/// row is a `╭…╮` border. Returns that border's index. Adjacency is the +/// whole check, and it is what rejects the tool-call preview box: that box +/// draws the same corners but fences a `│`-headed command row between them. +fn omp_input_box(rows: &[String]) -> Option { + let bottom = rows.iter().rposition(|r| { + let t = r.trim(); + t.starts_with('╰') && t.ends_with('╯') + })?; + let t = rows[..bottom].last()?.trim(); + (t.starts_with('╭') && t.ends_with('╮')).then(|| bottom - 1) +} + +/// The status row: the first painted row above the input box, shaped +/// `{frame} {phrase} {hint}` one column in. Everything between the frame and +/// the hint is the model's own streamed intent phrase (`Listing directory +/// contents`; `Working…` when the model streams nothing) and is returned +/// verbatim, the CLI's own truncating `…` included. A wrapped row left its +/// hint on the next line and fails the suffix check rather than yielding half +/// a phrase. +fn omp_spinner_status(rows: &[String], top: usize) -> Option<(String, &'static str)> { + let probe = rows[..top].iter().rev().find(|r| !r.is_empty())?; + let mut chars = probe.trim_start().chars(); + if !omp_frame(chars.next()?) || chars.next()? != ' ' { + return None; + } + let rest = chars.as_str(); + let text = OMP_HINTS + .iter() + .find_map(|h| rest.strip_suffix(h))? + .strip_suffix(' ')?; + text.chars() + .next()? + .is_alphanumeric() + .then(|| (text.to_string(), "omp:spinner")) +} + +/// omp's approval selector, reached only with the input box gone: an +/// `Allow tool: {name}` head within six rows above a `❯ Approve` row, and +/// `Deny` as the next painted row below it, the selection pinned to the last +/// nine painted rows. Prose quoting those words keeps the live input box +/// below it and never reaches here. +fn omp_approval(rows: &[String]) -> Option<(String, &'static str)> { + let last = rows.iter().rposition(|r| !r.is_empty())?; + let i = (last.saturating_sub(8)..=last).find(|&i| rows[i].trim() == "❯ Approve")?; + if rows[i + 1..].iter().find(|r| !r.is_empty())?.trim() != "Deny" { + return None; + } + rows[i.saturating_sub(6)..i] + .iter() + .any(|r| omp_allow_head(r)) + .then(|| ("awaiting approval".to_string(), "omp:approval-menu")) +} + +/// The selector's head row: `Allow tool: {name}`. The prefix's trailing +/// space carries the name requirement — a trimmed row cannot end in one — so +/// a bare `Allow tool:` fails. +fn omp_allow_head(row: &str) -> bool { + row.trim().starts_with("Allow tool: ") +} + #[cfg(test)] #[path = "summary_tests.rs"] mod tests; diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index 1df184a..38a66e1 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -1473,3 +1473,185 @@ fn corpus_non_agent_tuis_keep_their_tiers() { assert_eq!(with.source, PreviewSource::Marker, "{name}"); } } + +// -------------------------------------------------------------------- omp -- + +/// Place transcript rows above an omp input box: the status border directly +/// over the row the user types on, with no row between them. +fn omp_screen>(above: &[S]) -> Vec { + let mut rows: Vec = above.iter().map(|s| s.as_ref().to_string()).collect(); + rows.push("╭── π > ⬢ model · ◒ high > ◫ 12.2%/131K ▶──╮".to_string()); + rows.push("╰─ ─╯".to_string()); + rows +} + +/// omp's approval screen: the selector replaces the input box while the +/// status row keeps animating above it. `head` is the row that names the +/// tool. +fn omp_selector(head: &str) -> Vec { + let rule = "─".repeat(120); + rs(&[ + " ⠴ Listing directory contents ⟦esc⟧", + "", + &rule, + "", + head, + " Command: ls -la", + "", + " ❯ Approve", + " Deny", + "", + " up/down navigate enter select esc cancel", + "", + &rule, + ]) +} + +/// The intent phrase survives verbatim across every bracket theme and both +/// spinner presets, the CLI's own truncating ellipsis included. +#[test] +fn omp_status_row_extracts_the_intent_phrase() { + let probe = |row: &str| OmpSummary.live_preview(&omp_screen(&[row, ""])); + for hint in ["⟦esc⟧", "⟨esc⟩", "[esc]"] { + assert_eq!( + probe(&format!(" ⠴ Listing directory contents {hint}")), + Some(("Listing directory contents".to_string(), "omp:spinner")), + "{hint:?}" + ); + } + // The ascii spinner preset, and omp's default phrase when the model + // streams no intent of its own. + for frame in ['-', '\\', '|', '/'] { + assert_eq!( + probe(&format!(" {frame} Working… [esc]")), + Some(("Working…".to_string(), "omp:spinner")), + "{frame:?}" + ); + } + assert_eq!( + probe(" ⠋ Reading the fixture corpus rea… ⟦esc⟧"), + Some(("Reading the fixture corpus rea…".to_string(), "omp:spinner")), + "a phrase the CLI truncated keeps its own ellipsis" + ); + // The model text is a user-configured status-line segment: never read. + assert_eq!( + OmpSummary.model_label(&omp_screen(&[" ⠴ Listing directory contents ⟦esc⟧", ""])), + None + ); +} + +/// The status row needs its frame, its separating space, a phrase, and the +/// interrupt hint. A row whose hint wrapped onto the next line fails here +/// rather than surfacing half a phrase. +#[test] +fn omp_status_row_requires_the_whole_skeleton() { + let probe = |row: &str| OmpSummary.live_preview(&omp_screen(&[row, ""])); + for row in [ + " ⠴ Listing directory contents", + " ⠴Listing directory contents ⟦esc⟧", + " ⠴ ⟦esc⟧", + " ⠴ · queued ⟦esc⟧", + " Listing directory contents ⟦esc⟧", + " Press ⟦esc⟧ to interrupt", + ] { + assert_eq!(probe(row), None, "{row:?}"); + } +} + +/// The pin is the row above the input box, not a substring search: a +/// status-shaped row parked in the transcript never anchors, and the +/// tool-call preview box's matching corners are not the input box. +#[test] +fn omp_pins_the_status_row_to_the_input_box() { + let quoted = omp_screen(&[ + " ⠴ Listing directory contents ⟦esc⟧", + "", + " That row is chrome, not transcript.", + "", + ]); + assert_eq!(OmpSummary.live_preview("ed), None); + + // The preview box fences a `│`-headed command row between the same + // corners, so the pair is not adjacent and the pin fails. + let preview_box = rs(&[ + " ⠴ Listing directory contents ⟦esc⟧", + "", + "╭──────────────────╮", + "│ $ ls -la │", + "╰──────────────────╯", + ]); + assert_eq!(OmpSummary.live_preview(&preview_box), None); +} + +/// The selector synthesizes its label from the `Allow tool:` head, the +/// selection, and the `Deny` sibling below it. The status row keeps +/// painting throughout and never wins. +#[test] +fn omp_approval_requires_the_selector_shape() { + assert_eq!( + OmpSummary.live_preview(&omp_selector(" Allow tool: bash")), + Some(("awaiting approval".to_string(), "omp:approval-menu")) + ); + for head in [" Allow tool: ", " Reviewing the plan"] { + assert_eq!( + OmpSummary.live_preview(&omp_selector(head)), + None, + "{head:?}" + ); + } + + // `Deny` must be the next painted row below the selection. + let lone = rs(&[" Allow tool: bash", "", " ❯ Approve", "", " esc cancel"]); + assert_eq!(OmpSummary.live_preview(&lone), None); + + // The selection must sit in the bottom of the painted rows. + let mut buried = omp_selector(" Allow tool: bash"); + buried.extend(std::iter::repeat_n(" tool output".to_string(), 6)); + assert_eq!(OmpSummary.live_preview(&buried), None); + + // The same block quoted in the transcript keeps the live input box + // below it; the box routes to the status probe, which sees prose. + let mut quoted = omp_selector(" Allow tool: bash"); + quoted.push(String::new()); + assert_eq!(OmpSummary.live_preview(&omp_screen("ed)), None); +} + +/// omp corpus replay at capture geometry (40×120): exact status text, +/// Anchor provenance, and the matcher id. Kept in its own table so the omp +/// and codex fixture sets can land independently. +#[test] +fn corpus_omp_states_anchor_exactly() { + for (name, bytes, want) in [ + ( + "preview_omp_working", + &include_bytes!("../../tests/corpus/preview_omp_working.bin")[..], + anchor("Listing directory contents", "omp:spinner"), + ), + ( + "preview_omp_approval", + &include_bytes!("../../tests/corpus/preview_omp_approval.bin")[..], + anchor("awaiting approval", "omp:approval-menu"), + ), + ] { + assert_eq!(corpus(bytes, &OmpSummary, 120), want, "{name}"); + } +} + +/// omp is an inline UI, so a screen with no anchor falls through to the +/// floor tier — its input row — never the alternate-screen marker. +#[test] +fn corpus_omp_states_fall_through_to_the_floor() { + let input_row = floor(&format!("╰─{}─╯", " ".repeat(116))); + for (name, bytes) in [ + ( + "preview_omp_idle", + &include_bytes!("../../tests/corpus/preview_omp_idle.bin")[..], + ), + ( + "preview_omp_body_hint", + &include_bytes!("../../tests/corpus/preview_omp_body_hint.bin")[..], + ), + ] { + assert_eq!(corpus(bytes, &OmpSummary, 120), input_row, "{name}"); + } +} diff --git a/tests/corpus/README.md b/tests/corpus/README.md index dbccc83..168608e 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -34,9 +34,9 @@ feed the bytes to the emulator verbatim. The `preview_*.bin` fixtures pin summary-adapter extraction, normalization, and fallback behavior. Each fixture is a constructed repaint stream: optional alternate-screen entry, clear, home, then sanitized screen rows joined with -CRLF. Claude and Grok use the alternate screen; Codex is inline. Identifying -and user-configured text is replaced with alignment-preserving synthetic -values. Geometry is 40×120 unless noted. +CRLF. Claude and Grok use the alternate screen; Codex and omp are inline. +Identifying and user-configured text is replaced with alignment-preserving +synthetic values. Geometry is 40×120 unless noted. `preview_codex_reasoning.bin` uses 40×80 geometry, and `preview_codex_queued.bin` uses 40×36. Both are bottom-anchored on a 40-row @@ -48,7 +48,10 @@ omits the welcome box and includes agent-roster rows below the input box. The Claude task-list fixtures use generic phase names in a task-list layout; both omit the welcome box. The Claude workflow-wait fixture uses generic wording, omits the welcome box, and includes a long blank gap above the input box and a -roster below it. +roster below it. The omp fixtures replace the local model path and the working +directory in the status line with same-length synthetic values, and their +status rows carry a streamed intent phrase rather than omp's default +`Working…`. | Fixture | Scenario | Coverage | | --- | --- | --- | @@ -79,6 +82,10 @@ roster below it. | `preview_grok_subagent_scrollback.bin` | grok idle with `Subagent running:` in the body, no `◎` row | fall-through; body-shaped text is not status | | `preview_grok_idle.bin` | grok idle session | fall-through to the marker | | `preview_grok_splash.bin` | grok launch splash with resume hint above the box | fall-through; distinct views never anchor | +| `preview_omp_working.bin` | omp status row carrying the model's streamed intent phrase above the input box | `omp:spinner`; padding, spinner frame, and interrupt hint stripped | +| `preview_omp_approval.bin` | omp approval selector, input box replaced, tool-call preview box and a live status row still above it | `omp:approval-menu` synthesizes `awaiting approval` while the status row keeps animating | +| `preview_omp_idle.bin` | omp idle with the welcome box and tip above the input box | fall-through to the floor tier; an inline UI reaches no marker | +| `preview_omp_body_hint.bin` | status-shaped row quoted in the transcript, prose between it and an idle input box | negative: the pin is the row above the box, not a substring search | | `preview_trunc_claude.bin` | synthetic 40×80: spinner row truncated inside its parenthetical | head match still extracts `Hashing…` | | `preview_trunc_codex.bin` | synthetic 40×80: working row truncated inside the `/ps` hint | the head still matches and the key-hint suffix is omitted | | `preview_trunc_grok.bin` | synthetic 40×80: spinner label truncated with the CLI's ellipsis | extraction keeps the CLI's own `…` verbatim | diff --git a/tests/corpus/preview_omp_approval.bin b/tests/corpus/preview_omp_approval.bin new file mode 100644 index 0000000..7a89106 --- /dev/null +++ b/tests/corpus/preview_omp_approval.bin @@ -0,0 +1,40 @@ +│ │ +│ Welcome back! │ +│ │ +│ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │ +│ │ +│ /Users/dev/Projects/fixtures/qwen3/Qwen3.8-27B-UD-Q8_K_XL.gguf │ +│ llama.cpp │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + Tip: Log in to several accounts of the same provider — `/login` again — and omp load-balances + across them automatically + + xdev: xd://: mounted mcp__fixture_js, mcp__fixture_js_add_module_dir, mcp__fixture_js_reset + + + Use the bash tool to run: ls -la + + + I'll use the bash tool to run the requested command. + +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ $ ls -la │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + ⠴ Listing directory contents ⟦esc⟧ + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + Allow tool: bash + Command: ls -la + + ❯ Approve + Deny + + up/down navigate enter select esc cancel + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/tests/corpus/preview_omp_body_hint.bin b/tests/corpus/preview_omp_body_hint.bin new file mode 100644 index 0000000..db37c18 --- /dev/null +++ b/tests/corpus/preview_omp_body_hint.bin @@ -0,0 +1,39 @@ + +╭─── omp v17.3.4 ──────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Welcome back! │ +│ │ +│ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │ +│ │ +│ /Users/dev/Projects/fixtures/qwen3/Qwen3.8-27B-UD-Q8_K_XL.gguf │ +│ llama.cpp │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + Tip: Run `omp auth-broker serve` once and every machine pulls live tokens over the wire — refresh + keys never leave the host; `omp auth-gateway` fronts it as a drop-in proxy any + OpenAI-compatible client can hit + + Use the bash tool to run: ls -la + + While a turn runs, omp paints its status row as: + + ⠴ Listing directory contents ⟦esc⟧ + + That row is chrome, not transcript: quoting it must not anchor. + +╭── π > ⬢ /Users/dev/Projects/fixtures/qwen3/Qwen3.8-27B-UD-Q8_K_XL.gguf · ◒ high > 🗑 …tures/proj > ◫ 12.2%/131K ⟲ ▶──╮ +╰─ ─╯ + + + + + + + + + + + diff --git a/tests/corpus/preview_omp_idle.bin b/tests/corpus/preview_omp_idle.bin new file mode 100644 index 0000000..0264947 --- /dev/null +++ b/tests/corpus/preview_omp_idle.bin @@ -0,0 +1,39 @@ + +╭─── omp v17.3.4 ──────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Welcome back! │ +│ │ +│ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │ +│ │ +│ /Users/dev/Projects/fixtures/qwen3/Qwen3.8-27B-UD-Q8_K_XL.gguf │ +│ llama.cpp │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + Tip: Run `omp auth-broker serve` once and every machine pulls live tokens over the wire — refresh + keys never leave the host; `omp auth-gateway` fronts it as a drop-in proxy any + OpenAI-compatible client can hit + + + +╭── π > ⬢ /Users/dev/Projects/fixtures/qwen3/Qwen3.8-27B-UD-Q8_K_XL.gguf · ◒ high > 🗑 …tures/proj > ◫ 12.2%/131K ⟲ ▶──╮ +╰─ ─╯ + + + + + + + + + + + + + + + + + diff --git a/tests/corpus/preview_omp_working.bin b/tests/corpus/preview_omp_working.bin new file mode 100644 index 0000000..c156ab0 --- /dev/null +++ b/tests/corpus/preview_omp_working.bin @@ -0,0 +1,39 @@ + +╭─── omp v17.3.4 ──────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Welcome back! │ +│ │ +│ ▀██████████▀ │ +│ ╘██ ██ │ +│ ██ ██ │ +│ ██ ██ │ +│ ▄██▄ ▄██▄ │ +│ │ +│ /Users/dev/Projects/fixtures/qwen3/Qwen3.8-27B-UD-Q8_K_XL.gguf │ +│ llama.cpp │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + Tip: Drop the word `ultrathink` in your message for harder multi-step reasoning — watch it glow + rainbow as you type + + xdev: xd://: mounted mcp__fixture_js, mcp__fixture_js_add_module_dir, mcp__fixture_js_reset + + + Use the bash tool to run: ls -la + + + ⠴ Listing directory contents ⟦esc⟧ + +╭── π > ⬢ /Users/dev/Projects/fixtures/qwen3/Qwen3.8-27B-UD-Q8_K_XL.gguf · ◒ high > 🗑 …tures/proj > ◫ 12.2%/131K ⟲ ▶──╮ +╰─ ─╯ + + + + + + + + + + + + From c3a0b19cd63c4af5aa22532c76dee222fce61d81 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 15 Aug 2026 16:08:16 -0700 Subject: [PATCH 04/11] feat(harness): capture omp's session ID from inside the agent --- src/harness/assets.rs | 81 ++++++++++++++++++++++++- src/harness/codex.rs | 1 + src/harness/mod.rs | 8 ++- src/harness/omp.rs | 102 ++++++++++++++++++++++++-------- src/supervisor_capture_tests.rs | 42 +++++++++++++ 5 files changed, 205 insertions(+), 29 deletions(-) diff --git a/src/harness/assets.rs b/src/harness/assets.rs index bd37513..9807653 100644 --- a/src/harness/assets.rs +++ b/src/harness/assets.rs @@ -21,6 +21,11 @@ //! newline-joined argv with the payload appended, so the displaced notifier //! receives the same final argument `codex` would have passed; otherwise //! exit 0. +//! - `omp`: `-e ` loads an extension module inside the agent's +//! own process, appending to the user's extensions rather than replacing +//! them. Its `session_start` and `session_switch` handlers write the session +//! id as JSON over `$FLEETCOM_CAPTURE_FILE`, skip the write when that +//! variable is unset or empty, and swallow every error. use std::{ fs, io, @@ -53,6 +58,38 @@ set -- $FLEETCOM_NOTIFY_CHAIN "$1" exec "$@" "#; +/// Extension module injected into `omp`. omp resolves the factory as +/// `typeof module === "function" ? module : module.default`, so the default +/// export is the subscription point. The module is imported, never executed, +/// so it needs no executable bit. `session_switch` matters as much as +/// `session_start`: the user can change session inside the TUI with `/resume`, +/// and the capture must follow. +const OMP_CAPTURE_MODULE: &str = r#"import * as fs from "node:fs"; + +function write(ctx, reason) { + const path = process.env.FLEETCOM_CAPTURE_FILE; + if (!path) return; + try { + fs.writeFileSync( + path, + JSON.stringify({ + reason, + sessionId: ctx.sessionManager.getSessionId(), + sessionFile: ctx.sessionManager.getSessionFile(), + cwd: ctx.cwd, + }), + ); + } catch { + // Best effort: a capture failure must never take the session down. + } +} + +export default function (pi) { + pi.on("session_start", (_e, ctx) => write(ctx, "session_start")); + pi.on("session_switch", (_e, ctx) => write(ctx, "session_switch")); +} +"#; + /// Build the `claude` settings overlay containing the `SessionStart` hook. fn claude_settings_json() -> String { jzon::object! { @@ -130,13 +167,15 @@ pub struct CaptureAssets { dir: PathBuf, claude_settings: PathBuf, codex_notify: PathBuf, + omp_capture: PathBuf, } impl CaptureAssets { /// Create `root` and a private `/-` namespace. The - /// namespace uses mode `0700`; its Claude settings use `0600`, and its - /// executable Codex notifier uses `0700`. Dead-owner namespaces are reaped - /// before the new namespace is created; other root entries remain. + /// namespace uses mode `0700`; its Claude settings and omp module use + /// `0600`, and its executable Codex notifier uses `0700`. Dead-owner + /// namespaces are reaped before the new namespace is created; other root + /// entries remain. pub fn install(root: &Path, pid: u32) -> io::Result { fs::DirBuilder::new() .recursive(true) @@ -168,10 +207,16 @@ impl CaptureAssets { fs::write(&codex_notify, CODEX_NOTIFY_SCRIPT)?; fs::set_permissions(&codex_notify, fs::Permissions::from_mode(0o700))?; + // omp imports this module; it never execs it, so it stays 0600. + let omp_capture = dir.join("omp-capture.js"); + fs::write(&omp_capture, OMP_CAPTURE_MODULE)?; + fs::set_permissions(&omp_capture, fs::Permissions::from_mode(0o600))?; + Ok(Self { dir, claude_settings, codex_notify, + omp_capture, }) } @@ -182,6 +227,7 @@ impl CaptureAssets { capture_file: self.dir.join(format!("task-{task_id}-{run}.json")), claude_settings: self.claude_settings.clone(), codex_notify: self.codex_notify.clone(), + omp_capture: self.omp_capture.clone(), } } } @@ -252,8 +298,11 @@ mod tests { assert_eq!(mode(&ns), 0o700); assert_eq!(assets.claude_settings, ns.join("claude-settings.json")); assert_eq!(assets.codex_notify, ns.join("codex-notify.sh")); + assert_eq!(assets.omp_capture, ns.join("omp-capture.js")); assert_eq!(mode(&assets.claude_settings), 0o600); assert_eq!(mode(&assets.codex_notify), 0o700); + // omp imports the module rather than executing it. + assert_eq!(mode(&assets.omp_capture), 0o600); } /// Installation creates a distinct namespace, reapplies the root mode, and @@ -597,5 +646,31 @@ mod tests { ); assert_eq!(paths.claude_settings, ns.join("claude-settings.json")); assert_eq!(paths.codex_notify, ns.join("codex-notify.sh")); + assert_eq!(paths.omp_capture, ns.join("omp-capture.js")); + } + + /// The installed module carries every piece omp's loader and the capture + /// contract depend on. The assertion is deliberately static: omp ships as + /// a self-contained binary, so neither `bun` nor `node` is guaranteed on + /// `PATH`, and a runtime-gated test would skip silently and read as a + /// pass. The module itself was run against omp 17.3.4 on 2026-08-15 and + /// wrote the capture file on both accepted command shapes. + #[test] + fn omp_module_carries_its_load_bearing_pieces() { + let root = temp("assets_omp_module"); + let assets = CaptureAssets::install(&root, std::process::id()).unwrap(); + let text = fs::read_to_string(&assets.omp_capture).unwrap(); + for piece in [ + // omp resolves the factory through `module.default`. + "export default", + // A launch subscribes once; `/resume` inside the TUI fires again. + "session_start", + "session_switch", + CAPTURE_ENV, + // The guard that keeps a capture failure off the user's session. + "catch", + ] { + assert!(text.contains(piece), "the module must carry {piece:?}"); + } } } diff --git a/src/harness/codex.rs b/src/harness/codex.rs index da8d6f8..0566f82 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -527,6 +527,7 @@ mod tests { capture_file: PathBuf::from("/c"), claude_settings: PathBuf::from("/s"), codex_notify: PathBuf::from(r#"/Odd Path/it's "here"\now"#), + omp_capture: PathBuf::from("/e.js"), }; let inv = Codex.detect("codex").unwrap(); let plan = Codex.instrument(&inv, &paths, Some(&no_config_home())); diff --git a/src/harness/mod.rs b/src/harness/mod.rs index cbecd59..d8460fc 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -204,6 +204,9 @@ pub struct CapturePaths { pub claude_settings: PathBuf, /// Program installed through `codex`'s `notify` config override. pub codex_notify: PathBuf, + /// Extension module loaded by `omp -e`, which appends to the user's own + /// extensions rather than replacing them. + pub omp_capture: PathBuf, } /// Spawn-time additions for one instrumented launch. @@ -371,13 +374,14 @@ pub(crate) mod fixtures { /// A second distinct ID for last-hint, requote, and ambiguity cases. pub(crate) const OTHER: &str = "11111111-2222-4333-8444-555555555555"; - /// Capture-path fixture. The spaced `claude_settings` and `codex_notify` - /// paths keep the shell- and TOML-quoting assertions honest. + /// Capture-path fixture. The spaced asset paths keep the shell- and + /// TOML-quoting assertions honest. pub(super) fn paths() -> CapturePaths { CapturePaths { capture_file: PathBuf::from("/tmp/cap/session.json"), claude_settings: PathBuf::from("/tmp/Application Support/fleetcom.json"), codex_notify: PathBuf::from("/tmp/Application Support/notify.sh"), + omp_capture: PathBuf::from("/tmp/Application Support/omp-capture.js"), } } diff --git a/src/harness/omp.rs b/src/harness/omp.rs index c1433ff..4f937a3 100644 --- a/src/harness/omp.rs +++ b/src/harness/omp.rs @@ -1,11 +1,12 @@ //! omp cannot pin a session ID at launch: it has no `--session-id` flag, and //! `--resume` rejects an ID that does not already exist, so a pinned UUID would //! name a session the resume command could never reach. Capture therefore has -//! to come from omp itself — instrumentation in a later phase, and meanwhile -//! the hint it prints to stderr on exit, `Resume this session with omp -//! --resume `. A crash repeats the same command inside a `[Recovery]` -//! block as `Main: omp --resume `; both carry the command substring, so -//! one matcher reads both. +//! to come from omp itself, through two channels. Live: `-e` loads an +//! extension module in omp's own process, and its `session_start` and +//! `session_switch` handlers write the ID to the capture file. At exit: the +//! hint omp prints, `Resume this session with omp --resume `. A crash +//! repeats the same command inside a `[Recovery]` block as `Main: omp --resume +//! `; both carry the command substring, so one matcher reads both. //! //! omp's IDs are UUIDv7. [`is_uuid`](super::is_uuid) validates the 8-4-4-4-12 //! lowercase-hex shape and not the version field, so they pass unchanged. @@ -34,7 +35,10 @@ use std::{ time::SystemTime, }; -use super::{CapturePaths, Harness, Invocation, SpawnPlan, is_uuid, last_hint, within_window_ms}; +use super::{ + CAPTURE_ENV, CapturePaths, Harness, Invocation, SpawnPlan, is_uuid, last_hint, shell_quote, + within_window_ms, +}; pub struct Omp; @@ -116,22 +120,42 @@ impl Harness for Omp { ("omp", "--resume") } + /// Load the capture extension with `-e`, which omp accepts on both shapes + /// and applies silently: no trust prompt, and the module is *appended* to + /// the user's own extensions. `--trusted-extension` would fit the same + /// slot and must never be used — it is mutually exclusive with `-e` and + /// replaces the user's entire extension discovery, omp's own bridges + /// included. fn instrument( &self, - // Nothing distinguishes the two accepted shapes yet: omp cannot pin an - // ID at launch, and no capture channel is injected. + // Both accepted shapes take the same injection: omp has no + // `--session-id`, so neither can pin an ID and only the extension + // reports one. _inv: &Invocation, - _capture: &CapturePaths, + capture: &CapturePaths, + // The module is self-contained and reads nothing from the store. _home: Option<&Path>, ) -> SpawnPlan { - // Capture injection lands in a later phase. Until then the command - // runs unmodified and `scrape_exit` is the only channel. - SpawnPlan::default() + SpawnPlan { + args_suffix: format!( + " -e {}", + shell_quote(&capture.omp_capture.to_string_lossy()) + ), + env: vec![( + CAPTURE_ENV.into(), + capture.capture_file.clone().into_os_string(), + )], + injected_id: None, + } } - /// No capture channel is injected yet, so no payload is ever trusted. - fn parse_capture(&self, _payload: &str) -> Option { - None + /// Read the ID out of the extension's payload. The module writes one JSON + /// object per event; anything else on that path came from somewhere else + /// and is discarded. + fn parse_capture(&self, payload: &str) -> Option { + let v = jzon::parse(payload).ok()?; + let id = v["sessionId"].as_str()?; + is_uuid(id).then(|| id.to_string()) } fn scrape_exit(&self, text: &str) -> Option { @@ -250,6 +274,8 @@ mod tests { const SPAWN_MS: u64 = 1_786_000_000_000; /// Working directory recorded in the generated headers. const CWD: &str = "/work/proj"; + /// The ID omp 17.3.4 reported through the extension on 2026-08-15. + const CAPTURED: &str = "01a0077c-e18e-7000-ae0b-016f4834b6e9"; fn spawned() -> SystemTime { SystemTime::UNIX_EPOCH + Duration::from_millis(SPAWN_MS) @@ -318,23 +344,51 @@ mod tests { assert_all_opaque(&Omp, ID, &opaque); } - /// Neither accepted shape gains an ID: omp has no `--session-id`, so a - /// pinned UUID would name a session `--resume` cannot reach. + /// Both accepted shapes load the extension and name the capture file, and + /// neither gains an ID: omp has no `--session-id`, so a pinned UUID would + /// name a session `--resume` cannot reach. The fixture's asset path + /// carries a space, so the quoting has to hold it to one word. #[test] - fn instrument_pins_no_id_for_either_accepted_shape() { + fn instrument_loads_the_extension_for_either_accepted_shape() { for cmd in ["omp".to_string(), format!("omp --resume {ID}")] { let inv = Omp.detect(&cmd).unwrap(); let plan = Omp.instrument(&inv, &paths(), None); - assert!(plan.injected_id.is_none(), "{cmd}"); - assert_eq!(plan, SpawnPlan::default(), "{cmd}"); + assert_eq!(plan.injected_id, None, "{cmd}"); + assert_eq!( + plan.args_suffix, " -e '/tmp/Application Support/omp-capture.js'", + "{cmd}" + ); + assert_eq!( + plan.env, + vec![( + CAPTURE_ENV.into(), + PathBuf::from("/tmp/cap/session.json").into_os_string() + )], + "{cmd}" + ); } } + /// The extension's payload yields an ID only when it validates; every + /// other payload on that path came from somewhere else. #[test] - fn parse_capture_is_unconditionally_none() { - // No injected channel exists, so no payload is ever trusted. - let payload = format!(r#"{{"session_id":"{ID}"}}"#); - assert_eq!(Omp.parse_capture(&payload), None); + fn parse_capture_returns_only_strict_ids() { + for reason in ["session_start", "session_switch"] { + let payload = format!( + r#"{{"reason":"{reason}","sessionId":"{CAPTURED}","sessionFile":"/s/2026-08-15T22-13-39-854Z_{CAPTURED}.jsonl","cwd":"/work/proj"}}"# + ); + assert_eq!(Omp.parse_capture(&payload).as_deref(), Some(CAPTURED)); + } + + assert_eq!(Omp.parse_capture("not json"), None); + assert_eq!(Omp.parse_capture("{}"), None); + assert_eq!(Omp.parse_capture(r#"{"sessionId":"my session"}"#), None); + assert_eq!(Omp.parse_capture(r#"{"sessionId":"x'; rm -rf ~'"}"#), None); + // Uppercase hex is not the canonical form omp writes. + assert_eq!( + Omp.parse_capture(&format!(r#"{{"sessionId":"{}"}}"#, CAPTURED.to_uppercase())), + None + ); assert_eq!(Omp.parse_capture(""), None); } diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs index 7212cd8..490a3a5 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -1444,6 +1444,48 @@ fn recovery_cadence_rewrites_on_capture_drift_and_skips_when_static() { assert_eq!(names.len(), 1, "one incarnation owns one snapshot file"); } +/// An `omp` spawn receives `-e ` and the capture environment. omp +/// cannot pin an ID at launch, so the task carries none. +#[test] +fn spawn_omp_loads_the_capture_extension() { + let dir = scratch("cap_omp"); + let (bin, runtime) = (dir.join("bin"), dir.join("run")); + install_stub(&bin, "omp", &dir); + let mut s = sup_ctx(agent_ctx(&bin, &runtime, dir.to_path_buf())); + spawn(&mut s, "omp", dir.to_path_buf()); + + let argv = wait_argv(&mut s, &dir.join("argv")); + let ei = argv + .iter() + .position(|a| a == "-e") + .expect("the stub must receive -e"); + let module = PathBuf::from(&argv[ei + 1]); + assert!(module.is_file(), "the extension module must exist"); + let text = std::fs::read_to_string(&module).unwrap(); + assert!( + text.contains("FLEETCOM_CAPTURE_FILE"), + "the module must write to the capture env: {text:?}" + ); + let t = &s.tasks[0]; + let cap = t.capture_file.clone().expect("capture file set"); + assert_eq!( + module.parent(), + cap.parent(), + "assets and captures must share the namespace" + ); + assert_eq!( + std::fs::read_to_string(dir.join("capenv")).unwrap(), + cap.display().to_string(), + "the child env must name this run's capture file" + ); + assert_eq!( + t.command, "omp", + "instrumentation must never leak into the stored command" + ); + assert!(t.harness.is_some()); + assert!(t.resume_id.is_none(), "omp cannot pin an id at launch"); +} + // --- live registry blocked status -------------------------------------- /// Tick until the sole task's preview satisfies `pred` or the budget expires, From 3efd51ecd6f6a786218b58396921446fc9ad40d9 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 15 Aug 2026 16:18:50 -0700 Subject: [PATCH 05/11] docs: describe omp capture, correlation, and its in-process asset --- README.md | 6 +++--- docs/README.md | 2 +- docs/agent-resume.md | 44 ++++++++++++++++++++++++++++++++++++-------- docs/commands.md | 2 +- docs/sessions.md | 2 +- 5 files changed, 42 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6a3a677..db38370 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Organize related tasks into named groups, even when they run in different direct ### Resume agent sessions -Start `claude`, `codex`, or `grok` normally. When you rerun the task or reload a saved session, `fleetcom` resumes the same conversation automatically. +Start `claude`, `codex`, `grok`, or `omp` normally. When you rerun the task or reload a saved session, `fleetcom` resumes the same conversation automatically. ## Operational model @@ -40,7 +40,7 @@ Running several long-lived commands is pesky once they span terminal panes or ne - Delegates tasks to a daemon, so a disconnecting client stops nothing. - Saves and reloads task recipes: directories, commands, group assignments, and display names. - Reruns a completed task in place, keeping its identity, group, and name. -- Preserves `claude`, `codex`, and `grok` conversations, so saved or rerun tasks resume instead of starting fresh. +- Preserves `claude`, `codex`, `grok`, and `omp` conversations, so saved or rerun tasks resume instead of starting fresh. - Automatically snapshots the current task set for recovery. ## Documentation @@ -102,7 +102,7 @@ Every task runs in its own pseudo-terminal, emulated with `alacritty_terminal`. - Several long-lived commands need one place for observation, tagging, and attachment. - Jobs must survive a terminal closing and remain available for reattachment. - The same command set is launched often enough to justify a saved session. -- Agent sessions (`claude`, `codex`, `grok`) must resume their conversations on rerun rather than start new ones. +- Agent sessions (`claude`, `codex`, `grok`, `omp`) must resume their conversations on rerun rather than start new ones. ### When to avoid `fleetcom` diff --git a/docs/README.md b/docs/README.md index 001feae..d7adb1f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,7 +7,7 @@ - [Commands](commands.md): every key and launch flag, including the routing mechanics - [How it works](how-it-works.md): the PTY emulation, input routing, and activity grouping - [Sessions](sessions.md): the task recipe format and where it lives -- [Agent session resume](agent-resume.md): how `fleetcom` captures and resumes supported `claude`, `codex`, and `grok` sessions +- [Agent session resume](agent-resume.md): how `fleetcom` captures and resumes supported `claude`, `codex`, `grok`, and `omp` sessions - [Storage paths](#storage-paths): runtime and session paths - [First-run walkthrough](#first-run-walkthrough): a first run, start to finish - [Security](#security): the trust boundary, on-disk state, and what is not protected diff --git a/docs/agent-resume.md b/docs/agent-resume.md index dd614d1..013730d 100644 --- a/docs/agent-resume.md +++ b/docs/agent-resume.md @@ -1,13 +1,13 @@ # Agent session resume -Session files preserve launch commands, not process state. Relaunching a bare `claude`, `codex`, or `grok` command ordinarily starts another conversation. For accepted commands, `fleetcom` captures a validated conversation ID when available and builds a canonical resume command when saving a session or rerunning a finished task (`r`). +Session files preserve launch commands, not process state. Relaunching a bare `claude`, `codex`, `grok`, or `omp` command ordinarily starts another conversation. For accepted commands, `fleetcom` captures a validated conversation ID when available and builds a canonical resume command when saving a session or rerunning a finished task (`r`). ## Workflow Start a supported agent without flags: -1. Press `n` and run `claude`, `codex`, or `grok`. The task appears in the dashboard under the command you typed. Instrumentation changes only the string executed through `$SHELL -c`, so a direct spawn still displays the requested command. -2. Work in it. `Enter` attaches; `Ctrl-\` returns to the dashboard. Depending on the agent, `fleetcom` pins an ID at launch and may update it from a hook or notifier while the task runs or from terminal output after it exits. +1. Press `n` and run `claude`, `codex`, `grok`, or `omp`. The task appears in the dashboard under the command you typed. Instrumentation changes only the string executed through `$SHELL -c`, so a direct spawn still displays the requested command. +2. Work in it. `Enter` attaches; `Ctrl-\` returns to the dashboard. Depending on the agent, `fleetcom` pins an ID at launch and may update it from a hook, notifier, or extension while the task runs or from terminal output after it exits. 3. Press `w`, enter a session name, and press `Enter`. If the earlier sources produced no ID, the save also checks the agent's on-disk session store. A captured bare command becomes its canonical resume form, such as `claude --resume ''`. 4. Run `fleetcom `, or press `o` in the dashboard, to start new processes from the saved commands. A stored resume command reopens its captured conversation. @@ -19,10 +19,11 @@ Capture is best-effort and narrow by design. A command carrying a prompt, extra The capture boundary is intentionally narrow. Only these forms participate: -- `claude`, `codex`, or `grok` +- `claude`, `codex`, `grok`, or `omp` - `claude --resume ` - `codex resume ` - `grok --resume ` +- `omp --resume ` The program word may be a path such as `/usr/local/bin/claude` when its basename matches and the token contains no shell syntax. A resume UUID may be bare or single-quoted, but it must be the final argument. @@ -30,12 +31,13 @@ Everything else remains opaque and runs, displays, and saves verbatim. This incl ## Capture state and isolation -Hooks and notifiers run outside the supervisor, so they need stable paths. The supervisor installs those assets once for each runtime root. An explicit `FLEETCOM_RUNTIME_DIR` becomes that root. Otherwise, `fleetcom` uses the platform runtime or cache directory and partitions it by session directory. +Hooks, notifiers, and extension modules are loaded by the agent rather than the supervisor, so they need stable paths. The supervisor installs those assets once for each runtime root. An explicit `FLEETCOM_RUNTIME_DIR` becomes that root. Otherwise, `fleetcom` uses the platform runtime or cache directory and partitions it by session directory. Each supervisor installation creates a private mode-`0700` `/-` namespace containing: - `claude-settings.json`, mode `0600` - `codex-notify.sh`, mode `0700` +- `omp-capture.js`, mode `0600`: omp imports the module rather than executing it, so it needs no executable bit - `task--.json` capture paths The random nonce separates concurrent supervisors and prevents PID reuse from selecting an existing namespace. The run number gives each rerun a distinct capture file, so a displaced process cannot overwrite the replacement run's session state. Installation leaves every other root entry unchanged. @@ -78,6 +80,24 @@ Grok accepts a launch-time ID but exposes no injectable live-capture channel. A After exit, the harness scans retained terminal text for the last `grok -r ` or `grok --resume ` hint. Save-time filesystem correlation checks `/sessions///`, percent-encoding the canonical working directory, falling back to a group whose `.cwd` file names that path when the encoded name is too long, and ignoring `session_kind: subagent` directories. +### `omp` + +omp can pin no ID at launch: it ships no `--session-id`, and `--resume` rejects an ID that does not already exist, so a generated UUID would name a session the resume command could never reach. Both accepted forms therefore take the same injection, and neither carries a pinned ID: + +```text +-e '/omp-capture.js' +``` + +That asset is a JavaScript module, and `-e` loads it into the agent's own process at startup, appending to the user's own extensions rather than replacing them. Its `session_start` and `session_switch` handlers write the session ID as JSON to `FLEETCOM_CAPTURE_FILE`; the harness reads `sessionId`. `session_switch` is what covers omp's in-TUI `/resume`, which changes the session ID of a process `fleetcom` has already launched. + +No other harness runs code inside the agent: Claude's asset is a settings file and Codex's is a shell script the agent execs after a turn. Two things bound that. The module no-ops when `FLEETCOM_CAPTURE_FILE` is unset or empty, and it swallows every error it raises; omp's own extension runner then calls each handler under a timeout inside a `catch`, reporting a throw to its extension error channel instead of propagating it. `--trusted-extension` fills the same slot and is never used: it is mutually exclusive with `-e` and replaces the user's entire extension discovery, omp's own bridges included. + +After exit, the harness scans retained terminal text for the last `omp --resume ` hint. omp prints it as `Resume this session with omp --resume `, and a crash prints the same command inside a `[Recovery]` block, so one matcher reads both. omp also resumes through `-r`, `--session`, and `-c`; those spellings stay opaque, because a command `fleetcom` cannot rewrite exactly is left verbatim. + +Save-time filesystem correlation reads `//_.jsonl`, where the sessions root comes from omp's own variable chain rather than one home override; the [environment-variable table](#environment-variables) lists it. Correlation does not reproduce the bucket name. omp encodes a working directory through three scopes — under `$HOME`, under the temporary directory, otherwise absolute — after realpath-canonicalizing the working directory, `$HOME`, and `$TMPDIR`, and it changed that scheme three times inside the 17.2.x line, each change shipping an on-disk migration. The scan enumerates the buckets instead and confirms the directory from the session header's own `cwd` field, which records the resolved path while the bucket is named from the canonical one, so both forms are compared. A candidate must be the sole file whose UUIDv7 creation instant falls within the 30-second spawn window; an ID that is not v7 is skipped rather than dated from metadata omp did not write. + +A session with no assistant message leaves no file at all, because omp holds it in memory until the model replies. Correlation therefore cannot find a just-launched session, and an empty bucket is ordinary rather than an error: a session with no reply has nothing worth resuming. + ## ID precedence Several channels can identify different conversations during one task. To make the result deterministic, `fleetcom` chooses the first available ID in this order: @@ -96,6 +116,7 @@ Saving and rerunning rewrite accepted commands to one of these forms: claude --resume '' codex resume '' grok --resume '' +omp --resume '' ``` The program word is preserved as typed. If no valid ID is available, the original command remains unchanged. A rerun increments the run number before spawning its replacement, so capture data from the displaced run cannot affect the new run. @@ -110,21 +131,28 @@ Each tool implements the `Harness` trait in [`src/harness/mod.rs`](../src/harnes - `shape` supplies the program word and resume selector. The default `detect` and `resume_command` methods derive the accepted and canonical forms from that pair. - `instrument` returns spawn-time arguments, environment entries, and an optional pinned ID. -- `parse_capture` reads an ID from hook or notify JSON. +- `parse_capture` reads an ID from hook, notify, or extension JSON. - `scrape_exit` reads an ID from retained terminal text. - `live_session_id` reads the ID a live session publishes on disk. It defaults to `None` for tools that publish no registry. - `live_blocked_status` reads that same registry for one display fact: whether the tool says it is blocked on the user. It returns preview text, never an ID, and defaults to `None`. - `correlate_fs` finds one matching on-disk session. +- `resolve_home` turns the task's launch environment into the tool's store root. -The supervisor resolves each harness home from the task's launch environment: the tool-specific variable first, then `$HOME` plus the tool's dot directory. That resolved path remains attached to the task for later filesystem correlation. +The supervisor supplies the launch environment and delegates the decision to `resolve_home`. Its default is the two-step rule three of the four tools follow: the tool-specific variable first, then `$HOME` plus the tool's dot directory. omp overrides it, because its store root comes from a chain of variables and a filesystem-conditional XDG branch that no single override can express. Either way, the resolved path remains attached to the task for later filesystem correlation. ## Environment variables | Variable | Meaning | | -- | -- | | `FLEETCOM_RUNTIME_DIR` | Explicit capture-asset root as well as the daemon runtime override. | -| `FLEETCOM_CAPTURE_FILE` | Per-run capture file used by the injected hook or notifier. | +| `FLEETCOM_CAPTURE_FILE` | Per-run capture file used by the injected hook, notifier, or extension module. | | `FLEETCOM_NOTIFY_CHAIN` | Newline-joined argv for the configured Codex notifier; empty when none is active. | | `CLAUDE_CONFIG_DIR` | Claude home holding the `sessions/.json` registry and the transcripts used for correlation; defaults to `$HOME/.claude`. | | `CODEX_HOME` | Codex home used for notify routing and rollout correlation; defaults to `$HOME/.codex`. | | `GROK_HOME` | Grok home used for session-directory correlation; defaults to `$HOME/.grok`. | +| `PI_CODING_AGENT_SESSION_DIR` | omp sessions root, used verbatim for correlation. The rest of omp's chain builds that path instead of naming it. | +| `PI_CODING_AGENT_DIR` | omp agent directory, whose `sessions` subdirectory is the store. A selected profile ignores it. | +| `PI_CONFIG_DIR` | omp config directory name under `$HOME`; defaults to `.omp`. An absolute value diverges from omp's own joining and correlation then finds nothing rather than the wrong session. | +| `OMP_PROFILE` | omp profile, read by presence: it selects a profile when non-empty and suppresses `PI_PROFILE` when empty. | +| `PI_PROFILE` | omp profile used only when `OMP_PROFILE` is absent. A profile inserts `profiles/` under the config directory. | +| `XDG_DATA_HOME` | Redirects the still-default omp agent directory to `/omp`, flattening the `agent/` level, and only when that directory already exists. | diff --git a/docs/commands.md b/docs/commands.md index 0131cee..e30d40a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -104,7 +104,7 @@ Destroy is Shift-gated: only uppercase `X` acts. It kills a running task or remo `r` acts only on a finished task. A running task remains untouched because rerunning it would first require a destructive kill. -The replacement starts in the same directory using the requesting client's environment. Most tasks reuse their stored command. A supported `claude`, `codex`, or `grok` task instead uses its captured resume command when a valid conversation ID is available. +The replacement starts in the same directory using the requesting client's environment. Most tasks reuse their stored command. A supported `claude`, `codex`, `grok`, or `omp` task instead uses its captured resume command when a valid conversation ID is available. Rerunning preserves the task's ID, `◆` tag, group, name, and spawn order; its clock and screen reset. Since lifecycle affects sorting, the task may move to another section when it starts. The same key works inside peek, which remains open while the replacement starts. diff --git a/docs/sessions.md b/docs/sessions.md index 6fa467d..0e4e4b9 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -51,7 +51,7 @@ The file is plain JSON and practical to edit by hand. Editing the `name` field c Commands with neither a group nor a name use the string form. String and object entries can appear in the same directory array. -A bare agent command does not identify its conversation, so saving it verbatim would start another one on load. When `fleetcom` captures an ID for `claude`, `codex`, or `grok`, it stores the resume form instead. The result remains an ordinary command string that can run directly in a shell: +A bare agent command does not identify its conversation, so saving it verbatim would start another one on load. When `fleetcom` captures an ID for `claude`, `codex`, `grok`, or `omp`, it stores the resume form instead. The result remains an ordinary command string that can run directly in a shell: ```json { From 8c6c7b60bfe617ee20431e810b4b48da6b077d77 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 15 Aug 2026 22:25:05 -0700 Subject: [PATCH 06/11] fix(summary): accept omp's themed approval cursor --- src/harness/summary.rs | 43 ++++++++++++++++++++++---- src/harness/summary_tests.rs | 59 ++++++++++++++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 41d0be3..0bcd55a 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -683,20 +683,40 @@ fn grok_border_label(row: &str) -> Option { /// `-\|/`. The set is themeable and the ascii frames are ordinary /// punctuation, so a frame alone never makes a row status; the bracketed /// interrupt hint on the same row does. +/// +/// The ascii frames reach this matcher only through a custom theme, which +/// overrides `symbols.spinnerFrames` and each symbol key independently of the +/// preset: the ascii preset's own box glyphs never anchor (see [`OmpSummary`]), +/// so ascii frames can only arrive on a screen whose box glyphs stayed unicode. fn omp_frame(c: char) -> bool { ('\u{2800}'..='\u{28FF}').contains(&c) || matches!(c, '-' | '\\' | '|' | '/') } /// The interrupt hint closing omp's status row, one spelling per bracket -/// theme: unicode, nerd, ascii. The inner word is always `esc`. +/// theme: unicode, nerd, ascii. The inner word is always `esc`. `[esc]` is +/// reachable on an anchoring screen only through the same per-key custom +/// override that reaches the ascii frames. const OMP_HINTS: &[&str] = &["⟦esc⟧", "⟨esc⟩", "[esc]"]; +/// The marker on the selector's chosen row, one spelling per symbol preset: +/// unicode, nerd (a private-use nerd-font codepoint), ascii. omp renders the +/// row as `{nav.cursor} {label}`. +const OMP_CURSORS: &[&str] = &["❯", "\u{f054}", ">"]; + /// omp (inline UI, primary screen). The pin is its two-row input box: a /// `╭…╮` status border directly above the `╰…╯` row the user types on. The /// status row is the first painted row above that pair. /// The approval selector replaces the box outright, so the box's absence — /// not matcher order — is what separates a blocked task from a busy one: /// omp keeps animating the status row underneath the selector. +/// +/// Preset coverage: those corners are the unicode and nerd spellings, and +/// nothing else anchors. The ascii preset draws every corner as `+` and the +/// horizontal as `-`, which no test can tell from a table, rule, or diagram in +/// the transcript, so the status row degrades to the floor tier there by +/// design — a stale scrollback row reported as live status is the worse +/// outcome. The selector still matches under ascii: its shape is plain text +/// and needs no box. pub struct OmpSummary; impl SummaryAdapter for OmpSummary { @@ -756,13 +776,13 @@ fn omp_spinner_status(rows: &[String], top: usize) -> Option<(String, &'static s } /// omp's approval selector, reached only with the input box gone: an -/// `Allow tool: {name}` head within six rows above a `❯ Approve` row, and -/// `Deny` as the next painted row below it, the selection pinned to the last -/// nine painted rows. Prose quoting those words keeps the live input box -/// below it and never reaches here. +/// `Allow tool: {name}` head within six rows above the selected `Approve` +/// row, and `Deny` as the next painted row below it, the selection pinned to +/// the last nine painted rows. Prose quoting those words keeps the live input +/// box below it and never reaches here. fn omp_approval(rows: &[String]) -> Option<(String, &'static str)> { let last = rows.iter().rposition(|r| !r.is_empty())?; - let i = (last.saturating_sub(8)..=last).find(|&i| rows[i].trim() == "❯ Approve")?; + let i = (last.saturating_sub(8)..=last).find(|&i| omp_approve_row(&rows[i]))?; if rows[i + 1..].iter().find(|r| !r.is_empty())?.trim() != "Deny" { return None; } @@ -772,6 +792,17 @@ fn omp_approval(rows: &[String]) -> Option<(String, &'static str)> { .then(|| ("awaiting approval".to_string(), "omp:approval-menu")) } +/// The selector's chosen row: a cursor spelling, a space, then `Approve` and +/// nothing more. Equality after the cursor is the whole check — the ascii +/// cursor `>` also opens a quoted line, so the row's remainder has to be +/// exact. +fn omp_approve_row(row: &str) -> bool { + let t = row.trim(); + OMP_CURSORS + .iter() + .any(|c| t.strip_prefix(c) == Some(" Approve")) +} + /// The selector's head row: `Allow tool: {name}`. The prefix's trailing /// space carries the name requirement — a trimmed row cannot end in one — so /// a bare `Allow tool:` fails. diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index 38a66e1..eb1a7d2 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -1489,6 +1489,12 @@ fn omp_screen>(above: &[S]) -> Vec { /// status row keeps animating above it. `head` is the row that names the /// tool. fn omp_selector(head: &str) -> Vec { + omp_selector_row(" ❯ Approve", head) +} + +/// The same screen with the selected row written out, for the themed +/// `nav.cursor` spellings and the near misses around them. +fn omp_selector_row(approve: &str, head: &str) -> Vec { let rule = "─".repeat(120); rs(&[ " ⠴ Listing directory contents ⟦esc⟧", @@ -1498,7 +1504,7 @@ fn omp_selector(head: &str) -> Vec { head, " Command: ls -la", "", - " ❯ Approve", + approve, " Deny", "", " up/down navigate enter select esc cancel", @@ -1519,8 +1525,10 @@ fn omp_status_row_extracts_the_intent_phrase() { "{hint:?}" ); } - // The ascii spinner preset, and omp's default phrase when the model - // streams no intent of its own. + // The ascii frames and hint, on the only screen that can carry them: a + // custom theme overriding those keys over unicode box glyphs, since the + // ascii preset's own box never anchors. Plus omp's default phrase, used + // when the model streams no intent of its own. for frame in ['-', '\\', '|', '/'] { assert_eq!( probe(&format!(" {frame} Working… [esc]")), @@ -1616,6 +1624,51 @@ fn omp_approval_requires_the_selector_shape() { assert_eq!(OmpSummary.live_preview(&omp_screen("ed)), None); } +/// The marker on the selected row is omp's themed `nav.cursor`: `❯` under +/// unicode, a private-use glyph under nerd, `>` under ascii. Every spelling +/// has to read as blocked — a missed one leaves the task advertising a working +/// status while it sits waiting on the user. +#[test] +fn omp_approval_accepts_every_cursor_preset() { + for cursor in ['❯', '\u{f054}', '>'] { + assert_eq!( + OmpSummary.live_preview(&omp_selector_row( + &format!(" {cursor} Approve"), + " Allow tool: bash" + )), + Some(("awaiting approval".to_string(), "omp:approval-menu")), + "{cursor:?}" + ); + } + // The row after the cursor must be `Approve` exactly: `>` also opens a + // quoted line, and the selector is the one place a bare `>` is trusted. + for approve in [" > Approve now", " >Approve", " > approve", " * Approve"] { + assert_eq!( + OmpSummary.live_preview(&omp_selector_row(approve, " Allow tool: bash")), + None, + "{approve:?}" + ); + } +} + +/// The ascii preset cannot anchor and is not meant to: its box corners are `+` +/// and its horizontal `-`, which no test tells apart from a table or a rule, +/// so the status row degrades to the floor tier rather than risk reporting a +/// scrollback row as live. The ascii frames and the `[esc]` hint the matcher +/// carries stay reachable through a custom theme, which overrides +/// `symbols.spinnerFrames` and each symbol key independently of the preset — +/// the mixed screen the phrase test builds. +#[test] +fn omp_ascii_box_glyphs_do_not_anchor() { + let ascii_box = rs(&[ + " - Listing directory contents [esc]", + "", + "+-- pi > model . high > 12.2%/131K --+", + "+- -+", + ]); + assert_eq!(OmpSummary.live_preview(&ascii_box), None); +} + /// omp corpus replay at capture geometry (40×120): exact status text, /// Anchor provenance, and the matcher id. Kept in its own table so the omp /// and codex fixture sets can land independently. From a2f9930d944dbfd6f6d7ff59b23d07a1cfbd4e85 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 15 Aug 2026 22:27:29 -0700 Subject: [PATCH 07/11] fix(harness): make omp correlation reach the store omp actually wrote --- src/harness/omp.rs | 290 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 257 insertions(+), 33 deletions(-) diff --git a/src/harness/omp.rs b/src/harness/omp.rs index 4f937a3..496e015 100644 --- a/src/harness/omp.rs +++ b/src/harness/omp.rs @@ -19,6 +19,9 @@ //! Sessions live at `//_.jsonl`. //! The harness home *is* the sessions root: `PI_CODING_AGENT_SESSION_DIR` //! names a sessions directory outright, so no agent-dir value can express it. +//! That override also flattens the store — it is passed straight through as the +//! session file's parent and the bucket level is never computed — so +//! correlation scans the root and one level below it. //! //! The bucket name is deliberately not reproduced. omp encodes a cwd through //! three scopes — under `$HOME`, under `os.tmpdir()`, otherwise absolute — @@ -36,10 +39,19 @@ use std::{ }; use super::{ - CAPTURE_ENV, CapturePaths, Harness, Invocation, SpawnPlan, is_uuid, last_hint, shell_quote, + CAPTURE_ENV, CapturePaths, Harness, Invocation, SpawnPlan, is_uuid, leading_uuid, shell_quote, within_window_ms, }; +/// The command substring both hint channels print. Detection stays on the +/// command rather than the prose around it: omp is free to reword the exit +/// line, and every rewording still has to print the command it recommends. +const RESUME_HINT: &str = "omp --resume "; + +/// The label `formatFatalRecoveryHints` writes for the main session; every +/// other label is an agent id. +const MAIN_LABEL: &str = "Main"; + pub struct Omp; impl Harness for Omp { @@ -81,20 +93,15 @@ impl Harness for Omp { Some(p) => p, None => env("PI_PROFILE").unwrap_or_default(), }; - let profile = Some(profile).filter(|p| !p.as_os_str().is_empty()); - - // `PI_CONFIG_DIR` is a directory *name*, so the relative case is the - // only one omp documents, and there the two agree. An absolute value - // diverges: node's `path.join` concatenates it under `$HOME`, while - // `Path::join` lets it replace `$HOME` outright. Correlation then - // reads a store omp never wrote and finds nothing, which is the safe - // direction — resume falls back to the launch command rather than - // reopening some other conversation. - let config = env("HOME")?.join(set("PI_CONFIG_DIR").unwrap_or_else(|| ".omp".into())); - let root = match &profile { - Some(p) => config.join("profiles").join(p), - None => config, - }; + // `normalizeProfileName` trims the value and maps the `default` + // sentinel back to no profile, so `OMP_PROFILE=default` writes to the + // unprofiled store. Every other value it rejects makes omp refuse to + // start, non-UTF-8 included: the name must match `[a-z0-9][a-z0-9._-]*`. + let profile = profile + .to_str() + .map(str::trim) + .filter(|p| !p.is_empty() && *p != "default") + .map(PathBuf::from); // A named profile ignores `PI_CODING_AGENT_DIR`, and an agent // directory named that way is never redirected by XDG. @@ -113,6 +120,23 @@ impl Harness for Omp { return Some(data.join("sessions")); } } + + // Only this last branch builds on `$HOME`; the two above name absolute + // paths outright, and demanding a home directory for them would drop an + // override omp itself honours. + // + // `PI_CONFIG_DIR` is a directory *name*, so the relative case is the + // only one omp documents, and there the two agree. An absolute value + // diverges: node's `path.join` concatenates it under `$HOME`, while + // `Path::join` lets it replace `$HOME` outright. Correlation then + // reads a store omp never wrote and finds nothing, which is the safe + // direction — resume falls back to the launch command rather than + // reopening some other conversation. + let config = env("HOME")?.join(set("PI_CONFIG_DIR").unwrap_or_else(|| ".omp".into())); + let root = match &profile { + Some(p) => config.join("profiles").join(p), + None => config, + }; Some(root.join("agent").join("sessions")) } @@ -158,15 +182,48 @@ impl Harness for Omp { is_uuid(id).then(|| id.to_string()) } + /// The last trusted hint names the session at exit. Two channels print the + /// same command, and each occurrence is judged on its own: a hint carrying + /// a `