From 12dab0b6ac4b02a9bd057568f49e2883dd592338 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 15 Aug 2026 16:24:29 -0700 Subject: [PATCH 1/8] fix(summary): canonicalize claude's quadrant title frames and its truncated model cell --- src/harness/summary.rs | 51 +++++++++++++++++----- src/harness/summary_tests.rs | 84 +++++++++++++++++++++++++++++++----- 2 files changed, 115 insertions(+), 20 deletions(-) diff --git a/src/harness/summary.rs b/src/harness/summary.rs index ddc20bd..7381e32 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -118,13 +118,20 @@ impl SummaryAdapter for ClaudeSummary { claude_welcome_label(rows) } - /// Canonicalize a leading claude spinner or braille frame to `✻` so title - /// animation does not change the rendered text. Other titles pass through - /// unchanged. + /// Canonicalize a leading claude spinner, braille, or quadrant-circle + /// frame to `✻` so title animation does not change the rendered text. + /// Other titles pass through unchanged. fn normalize_title(&self, title: &str) -> Option { let mut chars = title.chars(); let frame = chars.next()?; - let framed = CLAUDE_SPINNER.contains(&frame) || ('\u{2800}'..='\u{28FF}').contains(&frame); + // An animation frame set is only neutralized when every member + // collapses to one rendered string; a frame left out reanimates the + // title. The quadrant circles are taken as the whole contiguous + // block for that reason: `◐` and `◑` are the observed pair, and the + // other two cost nothing to cover ahead of a four-phase cycle. + let framed = CLAUDE_SPINNER.contains(&frame) + || ('\u{2800}'..='\u{28FF}').contains(&frame) + || ('\u{25D0}'..='\u{25D3}').contains(&frame); (framed && chars.next()? == ' ').then(|| format!("✻ {}", chars.as_str())) } } @@ -267,6 +274,12 @@ fn claude_approval(rows: &[String]) -> Option<(String, &'static str)> { .then(|| ("awaiting approval".to_string(), "claude:approval-menu")) } +/// The levels claude documents for `--effort`. A truncated cell is only +/// trusted when its effort token is a complete member: `with hi…` is a cut +/// landing inside the word, and rendering `(hi)` would state an effort the +/// session is not running at. +const CLAUDE_EFFORT: &[&str] = &["low", "medium", "high", "xhigh", "max"]; + /// `Fable 5 with high effort` from the welcome box → `Fable 5 (high)`. The /// welcome box is the stable source; user-configurable statusline rows are not /// parsed. When the box scrolls away, the label is unavailable. @@ -284,17 +297,35 @@ fn claude_welcome_label(rows: &[String]) -> Option { continue; }; let head = cell.trim().split(" · ").next().unwrap_or(""); - if let Some(model_effort) = head.strip_suffix(" effort") - && let Some((model, effort)) = model_effort.rsplit_once(" with ") - && !model.is_empty() - && !effort.is_empty() - { - return Some(format!("{model} ({effort})")); + if let Some(label) = claude_model_effort(head) { + return Some(label); } } None } +/// `Fable 5 with high effort` → `Fable 5 (high)`, and the same for the +/// spelling the CLI truncates itself: `Opus 5 (1M context) with high…`. The +/// welcome box's left pane is fixed near 50 columns whatever the terminal +/// width, so a model name that overruns the pane loses its trailing ` effort` +/// to the CLI's own ellipsis and no terminal is wide enough to bring it back. +/// The full spelling needs no vocabulary check — the trailing word proves the +/// token is whole — while the truncated one is refused unless the token is a +/// complete [`CLAUDE_EFFORT`] level. A model name carrying its own +/// parentheses reads as `Opus 5 (1M context) (high)`; the +/// `{model} ({effort})` contract is applied as written rather than +/// special-cased. +fn claude_model_effort(head: &str) -> Option { + let (model, effort) = match head.strip_suffix(" effort") { + Some(full) => full.rsplit_once(" with ")?, + None => { + let (model, effort) = head.strip_suffix('…')?.rsplit_once(" with ")?; + CLAUDE_EFFORT.contains(&effort).then_some((model, effort))? + } + }; + (!model.is_empty() && !effort.is_empty()).then(|| format!("{model} ({effort})")) +} + // ----------------------------------------------------------------- codex -- /// Column-0 glyphs accepted as the Codex composer prompt. diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index e6cda74..318472d 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -284,6 +284,20 @@ fn claude_title_frames_canonicalize_to_constant_text() { let b = ClaudeSummary.normalize_title("✽ Claude Code"); assert_eq!(a, b, "two frames must normalize identically"); + // Spinner and quadrant-circle frames animate the same title, so the whole + // vocabulary must land on one rendered string. + let rendered: std::collections::BTreeSet> = CLAUDE_SPINNER + .iter() + .copied() + .chain('\u{25D0}'..='\u{25D3}') + .map(|frame| ClaudeSummary.normalize_title(&format!("{frame} Run sleep command"))) + .collect(); + assert_eq!( + rendered, + std::collections::BTreeSet::from([Some("✻ Run sleep command".to_string())]), + "spinner and quadrant frames must render one string" + ); + // A braille frame plus the session summary. assert_eq!( ClaudeSummary.normalize_title("⠐ Review fleetcom preview design document"), @@ -322,6 +336,25 @@ fn title_tier_renders_the_normalized_title() { ("✢ Claude Code", PreviewSource::Title), "no adapter: verbatim" ); + + // The quadrant frames animate a title that carries the task summary, so + // the tier renders the summary once rather than alternating with it. + let mut quadrant = Emulator::new(24, 80, 100); + quadrant.process( + b"\x1b[?1049h\x1b]0;\xe2\x97\x90 Run sleep command for 25 seconds\x07conversation body", + ); + let mut st = PreviewState::new(); + let p = st + .resolve(Instant::now(), &quadrant, Some(&ClaudeSummary)) + .clone(); + assert_eq!( + (p.text.as_str(), p.source, p.rule), + ( + "✻ Run sleep command for 25 seconds", + PreviewSource::Title, + None + ) + ); } /// A column-0 row in the chrome window that is not spinner-shaped aborts: @@ -461,18 +494,49 @@ fn claude_approval_requires_the_dialog_shape() { } /// The model label comes from the welcome box and reads as -/// `{model} ({effort})`; no box, no label. +/// `{model} ({effort})`. Both cell spellings are live: the box's left pane is +/// fixed near 50 columns, so a short model name keeps its trailing `effort` +/// and a long one loses it to the CLI's own ellipsis. A cut landing inside +/// the effort word refuses instead of guessing. No box, no label. #[test] fn claude_label_reads_the_welcome_box() { - let boxed = rs(&[ - "╭─── Claude Code v2.1.215 ────────────╮", - "│ Fable 5 with high effort · Claude Max · │ notes │", - "╰──────────────────────────────────────╯", - ]); - assert_eq!( - ClaudeSummary.model_label(&boxed), - Some("Fable 5 (high)".to_string()) - ); + let boxed = |cell: &str| { + rs(&[ + "╭─── Claude Code v2.1.233 ────────────╮", + cell, + "╰──────────────────────────────────────╯", + ]) + }; + // Verbatim from a live session, and byte-identical at 100 and 160 + // columns: the left pane is fixed near 50 columns, so a model name that + // overruns it truncates at every terminal width. + let fixed_pane = "│ Opus 5 (1M context) with high… · Claude Max · │ Added opt-in memory cgroup support for Bas… │"; + for (cell, want) in [ + ( + "│ Fable 5 with high effort · Claude Max · │ notes │", + Some("Fable 5 (high)"), + ), + // Parentheses in the model name double up under the + // `{model} ({effort})` contract. Deliberate: the contract is applied + // as written. + ( + "│ Opus 5 (1M context) with high effort · Claude Max · │ notes │", + Some("Opus 5 (1M context) (high)"), + ), + (fixed_pane, Some("Opus 5 (1M context) (high)")), + // `hi` is a cut through the effort word, not a level; `(hi)` would + // name an effort the session is not running at. + ("│ Opus 5 (1M context) with hi… │ notes │", None), + ("│ Opus 5 (1M context) with … │ notes │", None), + // Neither spelling: no trailing `effort`, no ellipsis. + ("│ Some Model with high │ notes │", None), + ] { + assert_eq!( + ClaudeSummary.model_label(&boxed(cell)), + want.map(str::to_string), + "{cell:?}" + ); + } assert_eq!(ClaudeSummary.model_label(&rs(&["no box here"])), None); } From faa3165574c7a76fc32ce5e5c5b3beb67180563a Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 15 Aug 2026 16:35:34 -0700 Subject: [PATCH 2/8] feat(harness): read claude's live session registry as an evidence source --- docs/agent-resume.md | 16 +- src/harness/claude.rs | 395 +++++++++++++++++++++++++++++++- src/harness/mod.rs | 26 ++- src/supervisor.rs | 19 +- src/supervisor_capture_tests.rs | 74 ++++++ src/task.rs | 7 + 6 files changed, 517 insertions(+), 20 deletions(-) diff --git a/docs/agent-resume.md b/docs/agent-resume.md index 4fb8a7c..15d2710 100644 --- a/docs/agent-resume.md +++ b/docs/agent-resume.md @@ -11,7 +11,7 @@ Start a supported agent without flags: 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. -On a finished agent task, `r` uses the captured launch, hook, notifier, or exit ID without performing save-time filesystem correlation. The replacement keeps the task's ID, tag, group, and name. After a successful rewrite, the row shows the resume command because it has become the task's launch recipe; a [saved session](sessions.md) records the same string. +On a finished agent task, `r` uses the captured launch, hook, notifier, registry, or exit ID without performing save-time filesystem correlation. Because the registry counts among those sources, a rerun can also recover an ID for a session whose `SessionStart` hook never fired, such as one launched with hooks disabled. The replacement keeps the task's ID, tag, group, and name. After a successful rewrite, the row shows the resume command because it has become the task's launch recipe; a [saved session](sessions.md) records the same string. Capture is best-effort and narrow by design. A command carrying a prompt, extra flags, or shell syntax stays opaque and saves verbatim. An accepted command with no available ID also saves unchanged. In both cases, loading the recipe reruns the original command. @@ -52,6 +52,8 @@ A bare Claude command can accept an ID at launch. `fleetcom` therefore generates A canonical resume command already supplies its conversation ID, so adding a second ID would be incorrect; it receives only `--settings`. The overlay installs a `SessionStart` hook that copies its JSON payload into `FLEETCOM_CAPTURE_FILE`, from which the harness reads `session_id`. +Claude also publishes a live session registry: one `/sessions/.json` record per session, written and rewritten by the CLI itself with no instrumentation. `$SHELL -c` execs an accepted command in place, so a task's own PID names its record; the lookup is a direct path, not a search. A record counts only when its `kind` is `interactive` and its `pid`, `cwd`, and `startedAt` all match the task. Those guards are load-bearing: the CLI removes the record on a clean exit but leaves it behind when the process dies on a signal, and only the next `claude` launch sweeps it, so a recycled PID can otherwise find a stranger's record filed under its own name. `startedAt` names the process start, which `/clear` leaves untouched, so the 30-second match does not decay as a session ages. `/cd` inside claude moves the session's directory and loses the record. The CLI rewrites the file in place rather than renaming a temporary, so a torn read yields no evidence rather than bad evidence. + After the process exits and the PTY reader reaches EOF, the harness scans the retained terminal text for the last `claude --resume ` hint. Save-time filesystem correlation checks `/projects//.jsonl`, where the slug replaces `/` and `.` in the absolute working directory with `-`. ### `codex` @@ -80,8 +82,11 @@ Several channels can identify different conversations during one task. To make t 1. The exit hint scraped after process exit and PTY-reader EOF. 2. The current capture-file payload. -3. The ID pinned or targeted at spawn. -4. Save-time filesystem correlation, when exactly one store entry matches the task and the 30-second spawn window. +3. The live session registry, currently `claude` only. +4. The ID pinned or targeted at spawn. +5. Save-time filesystem correlation, when exactly one store entry matches the task and the 30-second spawn window. + +The registry outranks the spawn pin because the pin records what `fleetcom` asked for while the registry records what the CLI is running, and those diverge the moment a user runs `/clear`, which mints a fresh ID mid-session. It ranks below the capture file only because that file is `fleetcom`'s own hook output, and the two agree whenever both exist. Saving and rerunning rewrite accepted commands to one of these forms: @@ -95,7 +100,7 @@ The program word is preserved as typed. If no valid ID is available, the origina ## Validation boundary -Every captured value eventually enters a shell command, which makes validation the security boundary. Accepted IDs contain exactly lowercase hexadecimal characters in the `8-4-4-4-12` UUID shape. Capture payloads, terminal hints, store names, and the final command builder all apply the same check. Malformed values are ignored rather than interpolated. +Every captured value eventually enters a shell command, which makes validation the security boundary. Accepted IDs contain exactly lowercase hexadecimal characters in the `8-4-4-4-12` UUID shape. Capture payloads, terminal hints, registry records, store names, and the final command builder all apply the same check. Malformed values are ignored rather than interpolated. ## Extending capture @@ -105,6 +110,7 @@ Each tool implements the `Harness` trait in [`src/harness/mod.rs`](../src/harnes - `instrument` returns spawn-time arguments, environment entries, and an optional pinned ID. - `parse_capture` reads an ID from hook or notify 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. - `correlate_fs` finds one matching on-disk session. 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. @@ -116,6 +122,6 @@ The supervisor resolves each harness home from the task's launch environment: th | `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_NOTIFY_CHAIN` | Newline-joined argv for the configured Codex notifier; empty when none is active. | -| `CLAUDE_CONFIG_DIR` | Claude home used for transcript correlation; defaults to `$HOME/.claude`. | +| `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`. | diff --git a/src/harness/claude.rs b/src/harness/claude.rs index f04950d..16ed1fc 100644 --- a/src/harness/claude.rs +++ b/src/harness/claude.rs @@ -1,15 +1,21 @@ -//! Claude exposes three useful session signals: a launch-time `--session-id`, a -//! `SessionStart` hook, and an exit-time resume hint. Bare launches pin a v4 -//! UUID; every accepted launch receives the hook through `--settings`. The -//! filesystem fallback correlates -//! `/projects//.jsonl` transcripts. +//! Claude exposes four useful session signals: a launch-time `--session-id`, a +//! `SessionStart` hook, a live session registry, and an exit-time resume hint. +//! Bare launches pin a v4 UUID; every accepted launch receives the hook through +//! `--settings`. The CLI itself publishes one `/sessions/.json` +//! record per live session, with no instrumentation. The filesystem fallback +//! correlates `/projects//.jsonl` transcripts. -use std::{path::Path, time::SystemTime}; +use std::{ + fs, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; use super::{ CAPTURE_ENV, CapturePaths, Harness, Invocation, SpawnPlan, is_uuid, last_hint, pin_plan, - shell_quote, unique_in_window, + shell_quote, unique_in_window, within_window_ms, }; +use crate::task::pid_is_dead; pub struct Claude; @@ -55,6 +61,16 @@ impl Harness for Claude { last_hint(text, &["claude --resume "]) } + fn live_session_id( + &self, + pid: u32, + cwd: &Path, + spawned: SystemTime, + home: Option<&Path>, + ) -> Option { + Some(record_for_pid(home, pid, cwd, spawned)?.id) + } + fn correlate_fs(&self, cwd: &Path, spawned: SystemTime, home: Option<&Path>) -> Option { let dir = self.home_root(home)?.join("projects").join(slug(cwd)?); unique_in_window(dir, spawned, |entry| { @@ -68,6 +84,123 @@ impl Harness for Claude { } } +/// One record from the live session registry. The CLI writes it on launch and +/// rewrites it in place as the session changes; it removes it on a clean exit +/// but leaves it behind when the process dies on a signal, so a record on disk +/// is a claim about a pid, not proof of a live session. +/// +/// `status` and `waiting_for` have no reader outside tests yet: the phase that +/// surfaces live status in the dashboard consumes them. The expectation breaks +/// the build once that reader lands, which is what removes this attribute. +#[cfg_attr(not(test), expect(dead_code, reason = "status pair awaits its reader"))] +struct SessionRecord { + /// `sessionId`, already through [`is_uuid`]. + id: String, + /// `pid`, which also names the record's file. + pid: i32, + /// `cwd` the session runs in. + cwd: PathBuf, + /// `startedAt`: the process's start in epoch milliseconds. `procStart` + /// names the same instant in human-readable form. + started_at: u128, + /// `status`, absent from records written by non-interactive entrypoints. + status: Option, + /// `waitingFor`: why a `Waiting` session waits. Present only while the CLI + /// holds a dialog open. + waiting_for: Option, +} + +/// The `status` vocabulary the CLI validates its own records against. +#[derive(Debug, PartialEq, Eq)] +enum SessionStatus { + Busy, + Shell, + Idle, + Waiting, +} + +/// Map one `status` string. An unrecognized value yields `None` instead of +/// rejecting the record: a later CLI version can extend the vocabulary, and the +/// session ID stays valid either way. +fn status_of(status: &str) -> Option { + Some(match status { + "busy" => SessionStatus::Busy, + "shell" => SessionStatus::Shell, + "idle" => SessionStatus::Idle, + "waiting" => SessionStatus::Waiting, + _ => return None, + }) +} + +/// The registry directory: one `.json` record per live session. +fn sessions_dir(home: Option<&Path>) -> Option { + Some(Claude.home_root(home)?.join("sessions")) +} + +/// Parse one registry record. The CLI rewrites the file in place with a plain +/// write rather than a temp-and-rename, so a reader can catch it truncated: +/// unparseable text yields `None` and the caller simply has no evidence this +/// time. `bg`, `daemon`, and `daemon-worker` records name conversations no user +/// is driving, so only `interactive` survives. +fn parse_record(text: &str) -> Option { + let v = jzon::parse(text).ok()?; + if v["kind"].as_str()? != "interactive" { + return None; + } + let id = v["sessionId"].as_str().filter(|id| is_uuid(id))?; + Some(SessionRecord { + id: id.to_string(), + pid: v["pid"].as_i32().filter(|p| *p > 0)?, + cwd: PathBuf::from(v["cwd"].as_str()?), + started_at: u128::from(v["startedAt"].as_u64()?), + status: v["status"].as_str().and_then(status_of), + waiting_for: v["waitingFor"].as_str().map(str::to_string), + }) +} + +/// Read the record `pid` publishes, requiring it to name that pid, that `cwd`, +/// and a process started within [`super::CORRELATE_WINDOW`] of `spawned`. +/// +/// The two extra guards close a stale-record hazard: a `claude` killed by a +/// signal leaves its record behind, and only the next `claude` launch sweeps +/// it, so a recycled pid can find a stranger's record filed under its own name. +/// `cwd` separates two directories; `startedAt` separates two processes in one +/// directory. That window does not decay with session age, because `startedAt` +/// records the process start: `/clear` mints a fresh `sessionId` in place and +/// leaves `startedAt` untouched, so a session running for hours still matches +/// its original spawn instant. +/// +/// Call-site details: `/cd` inside claude moves the session's `cwd` and fails +/// this check, which loses the record. Failing closed there is deliberate. +fn record_for_pid( + home: Option<&Path>, + pid: u32, + cwd: &Path, + spawned: SystemTime, +) -> Option { + let pid = i32::try_from(pid).ok()?; + let text = fs::read_to_string(sessions_dir(home)?.join(format!("{pid}.json"))).ok()?; + let rec = parse_record(&text)?; + let spawned_ms = spawned.duration_since(UNIX_EPOCH).ok()?.as_millis(); + (rec.pid == pid && rec.cwd == cwd && within_window_ms(rec.started_at, spawned_ms)) + .then_some(rec) +} + +/// Find the live record naming `id`. A record whose process is gone is skipped +/// because signal deaths leave records behind. No `cwd` guard: the caller +/// already holds the ID, and the ID is itself the pin. A non-UUID `id` cannot +/// match, since [`parse_record`] validates every ID it returns. +#[cfg_attr(not(test), expect(dead_code, reason = "awaits its dashboard caller"))] +fn record_for_session(home: Option<&Path>, id: &str) -> Option { + fs::read_dir(sessions_dir(home)?) + .ok()? + .flatten() + .find_map(|entry| { + let rec = parse_record(&fs::read_to_string(entry.path()).ok()?)?; + (rec.id == id && !pid_is_dead(rec.pid)).then_some(rec) + }) +} + /// Convert an absolute working directory to Claude's project slug by replacing /// `/` and `.` with `-` (`/a/b.c` becomes `-a-b-c`). Non-UTF-8 paths have no /// representable slug. @@ -87,9 +220,46 @@ mod tests { use super::*; use crate::{ harness::fixtures::{ID, OTHER, assert_all_opaque, assert_corpus_scrape, paths}, - testutil::temp, + testutil::{dead_pid, temp}, }; + /// One record a live `claude` 2.1.233 published. Field order and spelling + /// are as written; its `sessionId` is [`OTHER`], and the middle of `cwd` is + /// elided, which the reader never inspects. + const LIVE_RECORD: &str = concat!( + r#"{"pid":83849,"sessionId":"11111111-2222-4333-8444-555555555555","#, + r#""cwd":"/private/tmp/.../scratchpad/live-claude","startedAt":1786834960302,"#, + r#""procStart":"Sat Aug 15 23:02:39 2026","version":"2.1.233","peerProtocol":1,"#, + r#""kind":"interactive","entrypoint":"cli","#, + r#""messagingSocketPath":"/tmp/cc-socks/83849.sock","#, + r#""name":"live-claude-66","nameSource":"derived","nameSince":1786834960303,"#, + r#""status":"idle","updatedAt":1786834960352,"statusUpdatedAt":1786834960352}"#, + ); + /// The pid, directory, and process start [`LIVE_RECORD`] names. + const LIVE_PID: u32 = 83849; + const LIVE_CWD: &str = "/private/tmp/.../scratchpad/live-claude"; + const LIVE_STARTED: u64 = 1_786_834_960_302; + + /// A registry record carrying every field the reader validates. `tail` + /// appends raw JSON for the optional status pair. + fn record(pid: i32, id: &str, cwd: &str, started: u64, kind: &str, tail: &str) -> String { + format!( + r#"{{"pid":{pid},"sessionId":"{id}","cwd":"{cwd}","startedAt":{started},"version":"2.1.233","kind":"{kind}","entrypoint":"cli"{tail}}}"# + ) + } + + /// File `body` as the registry record for `pid`, creating the store. + fn install_record(home: &Path, pid: i32, body: &str) { + let dir = home.join("sessions"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join(format!("{pid}.json")), body).unwrap(); + } + + /// The instant `ms` epoch milliseconds names. + fn at_ms(ms: u64) -> SystemTime { + UNIX_EPOCH + std::time::Duration::from_millis(ms) + } + /// Claude-specific opaque shapes: flags, `--continue`/`-c`, subcommands, /// the short/`=` resume spellings, and `--session-id`. The syntax shared /// by every harness is covered by the table test in `harness::tests`. @@ -218,6 +388,215 @@ mod tests { ); } + /// The record a live session published parses whole, and the harness + /// surfaces its ID through the trait. + #[test] + fn record_for_pid_reads_a_live_record() { + let home = temp("claude_registry"); + install_record(&home, LIVE_PID as i32, LIVE_RECORD); + let cwd = Path::new(LIVE_CWD); + let rec = record_for_pid(Some(&home), LIVE_PID, cwd, at_ms(LIVE_STARTED)) + .expect("the live record must parse"); + assert_eq!(rec.id, OTHER); + assert_eq!(rec.status, Some(SessionStatus::Idle)); + assert_eq!(rec.waiting_for, None); + assert_eq!( + Claude + .live_session_id(LIVE_PID, cwd, at_ms(LIVE_STARTED), Some(&home)) + .as_deref(), + Some(OTHER) + ); + } + + /// The record must claim the pid whose file it sits in and the directory + /// the task runs in. + #[test] + fn record_for_pid_requires_the_records_own_pid_and_cwd() { + let home = temp("claude_registry_ident"); + let cwd = Path::new("/w"); + let spawned = at_ms(LIVE_STARTED); + + install_record( + &home, + 4242, + &record(4242, ID, "/w", LIVE_STARTED, "interactive", ""), + ); + assert!(record_for_pid(Some(&home), 4242, cwd, spawned).is_some()); + + // A record filed under one pid while naming another is not this task's. + install_record( + &home, + 4242, + &record(99, ID, "/w", LIVE_STARTED, "interactive", ""), + ); + assert!(record_for_pid(Some(&home), 4242, cwd, spawned).is_none()); + + install_record( + &home, + 4242, + &record(4242, ID, "/elsewhere", LIVE_STARTED, "interactive", ""), + ); + assert!(record_for_pid(Some(&home), 4242, cwd, spawned).is_none()); + } + + /// A `claude` killed by a signal leaves its record behind until the next + /// launch sweeps it. A task later assigned that pid in the same directory + /// satisfies both identity guards, so the process start is what rejects it. + #[test] + fn record_for_pid_rejects_a_recycled_pids_stale_record() { + let home = temp("claude_registry_recycled"); + let cwd = Path::new("/w"); + install_record( + &home, + 4242, + &record(4242, ID, "/w", LIVE_STARTED, "interactive", ""), + ); + + // The same process: its start is inside the correlation window. + assert!(record_for_pid(Some(&home), 4242, cwd, at_ms(LIVE_STARTED + 30_000)).is_some()); + assert!(record_for_pid(Some(&home), 4242, cwd, at_ms(LIVE_STARTED - 30_000)).is_some()); + // A later process under the recycled pid: minutes apart, or one + // millisecond outside the window. + assert!(record_for_pid(Some(&home), 4242, cwd, at_ms(LIVE_STARTED + 30_001)).is_none()); + assert!(record_for_pid(Some(&home), 4242, cwd, at_ms(LIVE_STARTED + 600_000)).is_none()); + } + + /// Only an `interactive` record names a conversation a user is driving, + /// and only a strict UUID may leave the reader. + #[test] + fn record_for_pid_requires_an_interactive_kind_and_a_strict_id() { + let home = temp("claude_registry_kind"); + let cwd = Path::new("/w"); + let spawned = at_ms(LIVE_STARTED); + for kind in ["bg", "daemon", "daemon-worker"] { + install_record(&home, 7, &record(7, ID, "/w", LIVE_STARTED, kind, "")); + assert!( + record_for_pid(Some(&home), 7, cwd, spawned).is_none(), + "{kind}" + ); + } + for id in ["NOT-A-UUID", "", "c8c4a5cc-0b32-4ba0-a6b4-6ed08c218e0dff"] { + install_record( + &home, + 7, + &record(7, id, "/w", LIVE_STARTED, "interactive", ""), + ); + assert!( + record_for_pid(Some(&home), 7, cwd, spawned).is_none(), + "{id:?}" + ); + } + // A record missing `kind` is unclassifiable. + install_record( + &home, + 7, + &format!(r#"{{"pid":7,"sessionId":"{ID}","cwd":"/w","startedAt":{LIVE_STARTED}}}"#), + ); + assert!(record_for_pid(Some(&home), 7, cwd, spawned).is_none()); + } + + /// The CLI rewrites the record in place rather than renaming a temporary, + /// so a reader can catch it truncated. That, an absent record, and an + /// absent store all mean no evidence this time. + #[test] + fn record_for_pid_tolerates_a_torn_file_and_a_missing_store() { + let home = temp("claude_registry_torn"); + let cwd = Path::new(LIVE_CWD); + let spawned = at_ms(LIVE_STARTED); + for body in [&LIVE_RECORD[..LIVE_RECORD.len() / 2], "", "\0"] { + install_record(&home, LIVE_PID as i32, body); + assert!( + record_for_pid(Some(&home), LIVE_PID, cwd, spawned).is_none(), + "{body:?}" + ); + } + // No record for this pid, and no store at all. + assert!(record_for_pid(Some(&home), 1, cwd, spawned).is_none()); + let bare = temp("claude_registry_bare"); + assert!(record_for_pid(Some(&bare), LIVE_PID, cwd, spawned).is_none()); + } + + /// The status vocabulary the CLI validates its own records against, the + /// absent status a non-interactive entrypoint writes, and the reason a + /// waiting session carries. + #[test] + fn record_for_pid_reads_the_status_vocabulary() { + let home = temp("claude_registry_status"); + let cwd = Path::new("/w"); + let spawned = at_ms(LIVE_STARTED); + let read = || record_for_pid(Some(&home), 7, cwd, spawned).expect("the record must parse"); + let install = |tail: &str| { + install_record( + &home, + 7, + &record(7, ID, "/w", LIVE_STARTED, "interactive", tail), + ); + }; + + for (status, want) in [ + ("busy", SessionStatus::Busy), + ("shell", SessionStatus::Shell), + ("idle", SessionStatus::Idle), + ("waiting", SessionStatus::Waiting), + ] { + install(&format!(r#","status":"{status}""#)); + assert_eq!(read().status, Some(want), "{status}"); + } + // A status absent, or from a vocabulary this reader predates, still + // yields the ID. + for tail in ["", r#","status":"hibernating""#] { + install(tail); + let rec = read(); + assert_eq!(rec.status, None, "{tail:?}"); + assert_eq!(rec.id, ID); + } + for reason in [ + "permission prompt", + "input needed", + "dialog open", + "sandbox request", + "worker request", + ] { + install(&format!(r#","status":"waiting","waitingFor":"{reason}""#)); + let rec = read(); + assert_eq!(rec.status, Some(SessionStatus::Waiting)); + assert_eq!(rec.waiting_for.as_deref(), Some(reason)); + } + } + + /// Lookup by ID needs no directory: the caller already holds the ID and the + /// ID is the pin. Liveness still comes from the pid, because a signal death + /// leaves the record behind. + #[test] + fn record_for_session_finds_a_live_record_and_skips_a_dead_pid() { + let home = temp("claude_registry_session"); + let live = std::process::id() as i32; + let dead = dead_pid() as i32; + install_record( + &home, + live, + &record(live, ID, "/w", LIVE_STARTED, "interactive", ""), + ); + install_record( + &home, + dead, + &record(dead, OTHER, "/elsewhere", LIVE_STARTED, "interactive", ""), + ); + + assert_eq!( + record_for_session(Some(&home), ID).map(|r| r.id).as_deref(), + Some(ID) + ); + assert!( + record_for_session(Some(&home), OTHER).is_none(), + "a dead pid's record is stale" + ); + assert!(record_for_session(Some(&home), "00000000-0000-4000-8000-000000000000").is_none()); + assert!(record_for_session(Some(&home), "not-a-uuid").is_none()); + let bare = temp("claude_registry_session_bare"); + assert!(record_for_session(Some(&bare), ID).is_none()); + } + /// The scraper recovers the exit-hint ID from the corpus terminal bytes. #[test] fn corpus_scrape_recovers_the_exit_hint_id() { diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 7588d1b..8c15723 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -9,11 +9,11 @@ //! //! # Security invariant //! -//! Every ID returned by `parse_capture`, `scrape_exit`, or `correlate_fs` -//! eventually enters a shell command. These methods must therefore return only -//! strings accepted by [`is_uuid`]. Free-text names, paths, and malformed IDs -//! yield `None`. Summary adapters are display-only and do not return session -//! IDs. +//! Every ID returned by `parse_capture`, `scrape_exit`, `live_session_id`, or +//! `correlate_fs` eventually enters a shell command. These methods must +//! therefore return only strings accepted by [`is_uuid`]. Free-text names, +//! paths, and malformed IDs yield `None`. Summary adapters are display-only and +//! do not return session IDs. pub mod assets; mod claude; @@ -92,6 +92,22 @@ pub trait Harness: Sync { /// Extract a session ID from final terminal text, including scrollback. fn scrape_exit(&self, text: &str) -> Option; + /// Read the ID the tool is running right now from the live registry it + /// publishes on disk. `pid` is the task's session leader, which for every + /// accepted command shape is the tool's own process. `cwd` and `spawned` + /// identify that process, since a registry record can outlive its writer. + /// Defaults to `None`: a tool that publishes no registry has nothing to + /// read. + fn live_session_id( + &self, + _pid: u32, + _cwd: &Path, + _spawned: SystemTime, + _home: Option<&Path>, + ) -> Option { + None + } + /// Find one session ID in the tool's on-disk store. Missing or ambiguous /// matches return `None`. `home` follows the `instrument` contract. fn correlate_fs(&self, cwd: &Path, spawned: SystemTime, home: Option<&Path>) -> Option; diff --git a/src/supervisor.rs b/src/supervisor.rs index e35cf92..c2dc9f6 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -135,8 +135,13 @@ fn fnv1a_hex(bytes: &[u8]) -> String { } /// Resolve the best session ID in precedence order: exit scrape, capture file, -/// then spawn-time ID. Exit and capture data outrank the launch value because -/// either can reflect a conversation selected later. +/// live session registry, then spawn-time ID. Exit and capture data outrank the +/// launch value because either can reflect a conversation selected later. The +/// registry outranks the launch value for the same reason and by a stronger +/// one: the pin records what fleetcom asked for, while the registry records +/// what the tool is running, and `/clear` mints a fresh ID mid-session. It +/// ranks under the capture file only because that file is fleetcom's own hook +/// output, and the two agree whenever both exist. fn current_resume_id(task: &Task) -> Option { if let Some(id) = &task.scraped_id { return Some(id.clone()); @@ -147,6 +152,16 @@ fn current_resume_id(task: &Task) -> Option { { return Some(id); } + if let (Some(h), Some(pid)) = (task.harness, task.pid()) + && let Some(id) = h.live_session_id( + pid, + &task.cwd, + task.spawned_at, + task.harness_home.as_deref(), + ) + { + return Some(id); + } task.resume_id.clone() } diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs index e195997..57ef397 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -916,6 +916,80 @@ fn resume_id_precedence_scrape_over_capture_over_spawn() { ); } +/// Claude's live session registry outranks the ID pinned at spawn: the pin +/// records what fleetcom asked for, the registry what the CLI is running, and +/// `/clear` moves the conversation on the same process. Fleetcom's own hook +/// output still outranks the registry. +#[test] +fn resume_id_precedence_registry_over_spawn_under_capture() { + let dir = scratch("registry_precedence"); + let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); + let (claude_home, done) = (dir.join("claude_home"), dir.join("done")); + install_script( + &bin, + "claude", + &format!( + "until [ -e '{d}' ]; do sleep 0.05; done", + d = done.display() + ), + ); + let mut s = sup_ctx(agent_ctx_plus( + &bin, + &runtime, + dir.to_path_buf(), + &[ + ("FLEETCOM_CONFIG_DIR", &config), + ("CLAUDE_CONFIG_DIR", &claude_home), + ], + )); + spawn(&mut s, "claude", dir.to_path_buf()); + let injected = s.tasks[0] + .resume_id + .clone() + .expect("a fresh claude launch pins an id"); + assert_ne!(injected.as_str(), CAP_ID); + // `$SHELL -c` execs the accepted command in place, so the task's pid names + // the registry record. + let pid = s.tasks[0].pid().expect("a live task has a pid"); + + let sessions = claude_home.join("sessions"); + std::fs::create_dir_all(&sessions).unwrap(); + std::fs::write( + sessions.join(format!("{pid}.json")), + format!( + r#"{{"pid":{pid},"sessionId":"{CAP_ID}","cwd":"{cwd}","startedAt":{started},"kind":"interactive","status":"idle"}}"#, + cwd = dir.display(), + started = now_ms() + ), + ) + .unwrap(); + let text = save_and_read(&mut s, &config, "registry"); + assert!( + text.contains(&format!("claude --resume '{CAP_ID}'")), + "the registry must beat the injected id; got {text}" + ); + assert!( + !text.contains(&injected), + "the injected id must not survive the registry; got {text}" + ); + + // The hook fired: fleetcom's own capture channel wins. + let cap = s.tasks[0].capture_file.clone().expect("capture file set"); + std::fs::write( + &cap, + format!( + r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"clear"}}"# + ), + ) + .unwrap(); + let text = save_and_read(&mut s, &config, "capture"); + assert!( + text.contains(&format!("claude --resume '{CAP_OTHER}'")), + "the capture file must beat the registry; got {text}" + ); + std::fs::write(&done, b"").unwrap(); +} + /// A silent Codex task falls back to one matching rollout under /// `CODEX_HOME` when live channels produce no ID. #[test] diff --git a/src/task.rs b/src/task.rs index 6d47ca3..0b84047 100644 --- a/src/task.rs +++ b/src/task.rs @@ -356,6 +356,13 @@ impl Task { }) } + /// The session leader's PID. `$SHELL -c` execs an accepted agent command in + /// place, so for those tasks this is the agent process itself: the pid its + /// live session registry is keyed by. + pub fn pid(&self) -> Option { + self.pid + } + /// Latch the exit code and finish time if the leader has exited, without /// reaping it. `WNOWAIT` leaves the zombie in place, which is what keeps /// the pid (and therefore the pgid) reserved so the group stays signalable From 5dabdf9fc72503238c1c1899c5a9e6809ea3182c Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 15 Aug 2026 16:49:51 -0700 Subject: [PATCH 3/8] feat(preview): surface claude's blocked-on-user state from its registry --- docs/agent-resume.md | 3 +- docs/commands.md | 2 +- docs/how-it-works.md | 2 +- src/harness/claude.rs | 162 ++++++++++++++------ src/harness/mod.rs | 30 +++- src/harness/summary_tests.rs | 63 +++++++- src/preview.rs | 277 ++++++++++++++++++++++++----------- src/task.rs | 48 +++++- 8 files changed, 446 insertions(+), 141 deletions(-) diff --git a/docs/agent-resume.md b/docs/agent-resume.md index 15d2710..9ca1b8a 100644 --- a/docs/agent-resume.md +++ b/docs/agent-resume.md @@ -52,7 +52,7 @@ A bare Claude command can accept an ID at launch. `fleetcom` therefore generates A canonical resume command already supplies its conversation ID, so adding a second ID would be incorrect; it receives only `--settings`. The overlay installs a `SessionStart` hook that copies its JSON payload into `FLEETCOM_CAPTURE_FILE`, from which the harness reads `session_id`. -Claude also publishes a live session registry: one `/sessions/.json` record per session, written and rewritten by the CLI itself with no instrumentation. `$SHELL -c` execs an accepted command in place, so a task's own PID names its record; the lookup is a direct path, not a search. A record counts only when its `kind` is `interactive` and its `pid`, `cwd`, and `startedAt` all match the task. Those guards are load-bearing: the CLI removes the record on a clean exit but leaves it behind when the process dies on a signal, and only the next `claude` launch sweeps it, so a recycled PID can otherwise find a stranger's record filed under its own name. `startedAt` names the process start, which `/clear` leaves untouched, so the 30-second match does not decay as a session ages. `/cd` inside claude moves the session's directory and loses the record. The CLI rewrites the file in place rather than renaming a temporary, so a torn read yields no evidence rather than bad evidence. +Claude also publishes a live session registry: one `/sessions/.json` record per session, written and rewritten by the CLI itself with no instrumentation. `$SHELL -c` execs an accepted command in place, so a task's own PID names its record; the lookup is a direct path, not a search. A record counts only when its `kind` is `interactive` and its `pid`, `cwd`, and `startedAt` all match the task. Those guards are load-bearing: the CLI removes the record on a clean exit but leaves it behind when the process dies on a signal, and only the next `claude` launch sweeps it, so a recycled PID can otherwise find a stranger's record filed under its own name. `startedAt` names the process start, which `/clear` leaves untouched, so the 30-second match does not decay as a session ages. `/cd` inside claude moves the session's directory and loses the record. The CLI rewrites the file in place rather than renaming a temporary, so a torn read yields no evidence rather than bad evidence. The same record also carries the session's live status, from which the dashboard reads one state — `waiting`, the CLI blocked on the user — as the top tier of its [preview cascade](commands.md#peek). After the process exits and the PTY reader reaches EOF, the harness scans the retained terminal text for the last `claude --resume ` hint. Save-time filesystem correlation checks `/projects//.jsonl`, where the slug replaces `/` and `.` in the absolute working directory with `-`. @@ -111,6 +111,7 @@ Each tool implements the `Harness` trait in [`src/harness/mod.rs`](../src/harnes - `parse_capture` reads an ID from hook or notify 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. 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. diff --git a/docs/commands.md b/docs/commands.md index cb101f7..25475a0 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -202,7 +202,7 @@ The box uses a two-column layout. When height is limited, group headers drop fir A centered box over the dashboard showing the selected task's live screen (the last screenful). `↑`/`↓` (or `k`/`j`) switch which task you're peeking at; `Enter` attaches to it; `r` reruns it if it has finished; `Space`, `Esc`, or `q` closes. -The footer's `preview:` segment names the source of the row's dashboard preview: `floor` (the last non-blank row of the live screen), `marker` (a full-screen program with no usable title), `title` (the child's window title), or `anchor/` (a recognized agent status line, tagged with the matcher that extracted it). +The footer's `preview:` segment names the source of the row's dashboard preview: `floor` (the last non-blank row of the live screen), `marker` (a full-screen program with no usable title), `title` (the child's window title), or `anchor/` (a recognized agent status, tagged with the matcher that produced it). Most rules name a screen matcher, such as `claude:spinner` or `codex:approval-menu`. The `claude:registry-approval` and `claude:registry-waiting` rules name the other kind: the status came from the CLI's own live session record on disk, which reports a session blocked on the user without waiting for its dialog to paint. ## Attached diff --git a/docs/how-it-works.md b/docs/how-it-works.md index cc4230c..985d318 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -10,4 +10,4 @@ Attached input follows the terminal modes reported by the child. Modified Enter ## Grouping follows one activity window -The dashboard groups tasks by state (In use / Running / Idle / Completed), working directory, or names assigned with `g`. One 10-second window drives both idle signals: after 10 s without output, the row glyph changes from `✻` to `∙` and, under state grouping, the task moves to the Idle section in the same refresh. Idle state does not affect row order within directory or custom sections. A tool such as `top`, which prints every 1–2 s, never crosses the window, so it stays `✻` under Running. Preview text is independent: a status from a weaker source must persist for 600 ms before it replaces a stronger one, which absorbs repaint flicker without affecting grouping. +The dashboard groups tasks by state (In use / Running / Idle / Completed), working directory, or names assigned with `g`. One 10-second window drives both idle signals: after 10 s without output, the row glyph changes from `✻` to `∙` and, under state grouping, the task moves to the Idle section in the same refresh. Idle state does not affect row order within directory or custom sections. A tool such as `top`, which prints every 1–2 s, never crosses the window, so it stays `✻` under Running. Preview text is independent: a status from a weaker source must persist for 600 ms before it replaces a stronger one, which absorbs repaint flicker without affecting grouping. The strongest source is not the screen at all. When a supported agent CLI publishes a live session record saying it is blocked on the user — `claude` does, and only that state is read — the preview takes the CLI's own word for it, above anything scraped from the terminal: the record changes before the dialog finishes painting and says the same thing at every terminal width. diff --git a/src/harness/claude.rs b/src/harness/claude.rs index 16ed1fc..ee15fdb 100644 --- a/src/harness/claude.rs +++ b/src/harness/claude.rs @@ -15,7 +15,6 @@ use super::{ CAPTURE_ENV, CapturePaths, Harness, Invocation, SpawnPlan, is_uuid, last_hint, pin_plan, shell_quote, unique_in_window, within_window_ms, }; -use crate::task::pid_is_dead; pub struct Claude; @@ -71,6 +70,22 @@ impl Harness for Claude { Some(record_for_pid(home, pid, cwd, spawned)?.id) } + fn live_blocked_status( + &self, + pid: u32, + cwd: &Path, + spawned: SystemTime, + home: Option<&Path>, + ) -> Option<(String, &'static str)> { + let rec = record_for_pid(home, pid, cwd, spawned)?; + // `Waiting` alone: see the trait doc. The registry beats the screen to + // this one state by about a second and reports it at any terminal + // width and for every dialog shape, including the ones + // `ClaudeSummary`'s `❯ 1. `/`2. ` selector match does not cover. + (rec.status == Some(SessionStatus::Waiting)) + .then(|| waiting_preview(rec.waiting_for.as_deref())) + } + fn correlate_fs(&self, cwd: &Path, spawned: SystemTime, home: Option<&Path>) -> Option { let dir = self.home_root(home)?.join("projects").join(slug(cwd)?); unique_in_window(dir, spawned, |entry| { @@ -88,11 +103,6 @@ impl Harness for Claude { /// rewrites it in place as the session changes; it removes it on a clean exit /// but leaves it behind when the process dies on a signal, so a record on disk /// is a claim about a pid, not proof of a live session. -/// -/// `status` and `waiting_for` have no reader outside tests yet: the phase that -/// surfaces live status in the dashboard consumes them. The expectation breaks -/// the build once that reader lands, which is what removes this attribute. -#[cfg_attr(not(test), expect(dead_code, reason = "status pair awaits its reader"))] struct SessionRecord { /// `sessionId`, already through [`is_uuid`]. id: String, @@ -132,6 +142,22 @@ fn status_of(status: &str) -> Option { }) } +/// Preview text and matcher ID for a `waiting` record's `waitingFor` reason. +/// The CLI's dialog-label map spells five reasons: `permission prompt` (its +/// default for any dialog), `input needed`, `dialog open`, `sandbox request`, +/// and `worker request`. Only the first is rewritten, to the string +/// `ClaudeSummary::claude_approval` already synthesizes for the same +/// condition; the rest are claude's own words and are kept verbatim, as is any +/// reason a later version adds. A record that reports `waiting` without a +/// reason still names a user-blocking state, so it renders as one. +fn waiting_preview(reason: Option<&str>) -> (String, &'static str) { + match reason.filter(|r| !r.is_empty()) { + Some("permission prompt") => ("awaiting approval".to_string(), "claude:registry-approval"), + Some(other) => (other.to_string(), "claude:registry-waiting"), + None => ("awaiting input".to_string(), "claude:registry-waiting"), + } +} + /// The registry directory: one `.json` record per live session. fn sessions_dir(home: Option<&Path>) -> Option { Some(Claude.home_root(home)?.join("sessions")) @@ -186,21 +212,6 @@ fn record_for_pid( .then_some(rec) } -/// Find the live record naming `id`. A record whose process is gone is skipped -/// because signal deaths leave records behind. No `cwd` guard: the caller -/// already holds the ID, and the ID is itself the pin. A non-UUID `id` cannot -/// match, since [`parse_record`] validates every ID it returns. -#[cfg_attr(not(test), expect(dead_code, reason = "awaits its dashboard caller"))] -fn record_for_session(home: Option<&Path>, id: &str) -> Option { - fs::read_dir(sessions_dir(home)?) - .ok()? - .flatten() - .find_map(|entry| { - let rec = parse_record(&fs::read_to_string(entry.path()).ok()?)?; - (rec.id == id && !pid_is_dead(rec.pid)).then_some(rec) - }) -} - /// Convert an absolute working directory to Claude's project slug by replacing /// `/` and `.` with `-` (`/a/b.c` becomes `-a-b-c`). Non-UTF-8 paths have no /// representable slug. @@ -220,7 +231,7 @@ mod tests { use super::*; use crate::{ harness::fixtures::{ID, OTHER, assert_all_opaque, assert_corpus_scrape, paths}, - testutil::{dead_pid, temp}, + testutil::temp, }; /// One record a live `claude` 2.1.233 published. Field order and spelling @@ -564,37 +575,92 @@ mod tests { } } - /// Lookup by ID needs no directory: the caller already holds the ID and the - /// ID is the pin. Liveness still comes from the pid, because a signal death - /// leaves the record behind. + /// The blocked-status probe speaks the `waitingFor` vocabulary the CLI's + /// own dialog-label map defines. `permission prompt` is its default for + /// any dialog and is the one value rewritten, to the string the screen + /// scraper synthesizes for the same condition; every other reason is + /// claude's wording and survives verbatim, a reason this reader predates + /// included. A `waiting` record with no reason still blocks the user. #[test] - fn record_for_session_finds_a_live_record_and_skips_a_dead_pid() { - let home = temp("claude_registry_session"); - let live = std::process::id() as i32; - let dead = dead_pid() as i32; - install_record( - &home, - live, - &record(live, ID, "/w", LIVE_STARTED, "interactive", ""), - ); - install_record( - &home, - dead, - &record(dead, OTHER, "/elsewhere", LIVE_STARTED, "interactive", ""), - ); + fn live_blocked_status_maps_every_waiting_reason() { + let home = temp("claude_blocked_reasons"); + let cwd = Path::new("/w"); + let spawned = at_ms(LIVE_STARTED); + let probe = |tail: &str| { + install_record( + &home, + 7, + &record(7, ID, "/w", LIVE_STARTED, "interactive", tail), + ); + Claude.live_blocked_status(7, cwd, spawned, Some(&home)) + }; assert_eq!( - record_for_session(Some(&home), ID).map(|r| r.id).as_deref(), - Some(ID) + probe(r#","status":"waiting","waitingFor":"permission prompt""#), + Some(("awaiting approval".to_string(), "claude:registry-approval")) ); - assert!( - record_for_session(Some(&home), OTHER).is_none(), - "a dead pid's record is stale" + for reason in [ + "input needed", + "dialog open", + "sandbox request", + "worker request", + // Not in today's map: a later CLI version's wording is still + // claude's own and reads better than a synthesized stand-in. + "quantum entanglement request", + ] { + assert_eq!( + probe(&format!(r#","status":"waiting","waitingFor":"{reason}""#)), + Some((reason.to_string(), "claude:registry-waiting")), + "{reason}" + ); + } + for tail in [ + r#","status":"waiting""#, + r#","status":"waiting","waitingFor":"""#, + ] { + assert_eq!( + probe(tail), + Some(("awaiting input".to_string(), "claude:registry-waiting")), + "{tail:?}" + ); + } + } + + /// Only `waiting` answers. `busy` and `shell` resolve to a title carrying + /// claude's own per-turn summary, and `idle` to whatever the screen shows; + /// replacing either with the bare status word would lose information. An + /// absent status, an unreadable one, and a record that fails the identity + /// guards are all no evidence. + #[test] + fn live_blocked_status_answers_for_waiting_alone() { + let home = temp("claude_blocked_states"); + let cwd = Path::new("/w"); + let spawned = at_ms(LIVE_STARTED); + for tail in [ + r#","status":"busy""#, + r#","status":"shell""#, + r#","status":"idle""#, + r#","status":"hibernating""#, + "", + // A reason without the status it belongs to is not a claim. + r#","waitingFor":"permission prompt""#, + ] { + install_record( + &home, + 7, + &record(7, ID, "/w", LIVE_STARTED, "interactive", tail), + ); + assert_eq!( + Claude.live_blocked_status(7, cwd, spawned, Some(&home)), + None, + "{tail:?}" + ); + } + // No record for this pid at all. + assert_eq!( + Claude.live_blocked_status(9, cwd, spawned, Some(&home)), + None ); - assert!(record_for_session(Some(&home), "00000000-0000-4000-8000-000000000000").is_none()); - assert!(record_for_session(Some(&home), "not-a-uuid").is_none()); - let bare = temp("claude_registry_session_bare"); - assert!(record_for_session(Some(&bare), ID).is_none()); } /// The scraper recovers the exit-hint ID from the corpus terminal bytes. diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 8c15723..804bb3c 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -13,7 +13,8 @@ //! `correlate_fs` eventually enters a shell command. These methods must //! therefore return only strings accepted by [`is_uuid`]. Free-text names, //! paths, and malformed IDs yield `None`. Summary adapters are display-only and -//! do not return session IDs. +//! do not return session IDs, and so is `live_blocked_status`: its text reaches +//! the dashboard, never a command. pub mod assets; mod claude; @@ -108,6 +109,33 @@ pub trait Harness: Sync { None } + /// Read the tool's own claim that it is blocked on the user, as preview + /// text and the matcher ID naming the claim: the same + /// `(text, rule)` shape [`crate::preview::SummaryAdapter::live_preview`] + /// returns, so the cascade treats a registry-derived anchor and a + /// screen-derived one alike. Parameters identify the live process exactly + /// as [`Harness::live_session_id`] does. + /// + /// Only a blocked state answers `Some`. A tool's working and idle states + /// already resolve to a title carrying the CLI's own per-turn summary + /// (claude's OSC title is model-generated text such as + /// `✻ Run sleep command for 25 seconds`), and replacing that with the bare + /// word `busy` or `idle` would remove information rather than add it. + /// Being blocked on the user is the one state the screen cascade cannot + /// see reliably. + /// + /// Defaults to `None`: a tool that publishes no live status has nothing to + /// read. + fn live_blocked_status( + &self, + _pid: u32, + _cwd: &Path, + _spawned: SystemTime, + _home: Option<&Path>, + ) -> Option<(String, &'static str)> { + None + } + /// Find one session ID in the tool's on-disk store. Missing or ambiguous /// matches return `None`. `home` follows the `instrument` contract. fn correlate_fs(&self, cwd: &Path, spawned: SystemTime, home: Option<&Path>) -> Option; diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index 318472d..14343b6 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -29,7 +29,7 @@ fn corpus( let mut emu = Emulator::new(40, cols, 2000); emu.process(bytes); let mut st = PreviewState::new(); - let p = st.resolve(Instant::now(), &emu, Some(adapter)); + let p = st.resolve(Instant::now(), &emu, Some(adapter), None); (p.text.clone(), p.source, p.rule) } @@ -313,6 +313,57 @@ fn claude_title_frames_canonicalize_to_constant_text() { assert_eq!(ClaudeSummary.normalize_title("✻"), None, "frame alone"); } +/// Cascade-level: the harness's blocked-status probe outranks the adapter's +/// screen scrape on the very screen the scrape would anchor on, keeps its own +/// rule, and takes the welcome box's model label like any other anchor. The +/// registry reports `waiting` about a second before the dialog finishes +/// painting, so the two disagree exactly while that repaint is in flight. +#[test] +fn a_registry_anchor_outranks_the_claude_spinner() { + let rule = "─".repeat(60); + let screen = [ + "╭─── Claude Code v2.1.233 ────────────╮", + "│ Fable 5 with high effort · Claude Max · │ notes │", + "╰──────────────────────────────────────╯", + "", + "✻ Hashing… (6s · ↓ 87 tokens)", + &rule, + "❯", + &rule, + ] + .join("\r\n"); + let mut emu = Emulator::new(24, 80, 100); + emu.process(screen.as_bytes()); + + let mut st = PreviewState::new(); + let p = st + .resolve(Instant::now(), &emu, Some(&ClaudeSummary), None) + .clone(); + assert_eq!( + (p.text.as_str(), p.rule), + ("Fable 5 (high) · Hashing…", Some("claude:spinner")), + "premise: this screen anchors on the spinner" + ); + + let mut st = PreviewState::new(); + let p = st + .resolve( + Instant::now(), + &emu, + Some(&ClaudeSummary), + Some(("awaiting approval", "claude:registry-approval")), + ) + .clone(); + assert_eq!( + (p.text.as_str(), p.source, p.rule), + ( + "Fable 5 (high) · awaiting approval", + PreviewSource::Anchor, + Some("claude:registry-approval") + ) + ); +} + /// Cascade-level: with the claude adapter installed and no anchor on /// the screen, a frame-led title renders canonicalized under the Title /// tier; without an adapter it renders verbatim. @@ -322,7 +373,7 @@ fn title_tier_renders_the_normalized_title() { emu.process(b"\x1b[?1049h\x1b]0;\xe2\x9c\xa2 Claude Code\x07conversation body"); let mut st = PreviewState::new(); let p = st - .resolve(Instant::now(), &emu, Some(&ClaudeSummary)) + .resolve(Instant::now(), &emu, Some(&ClaudeSummary), None) .clone(); assert_eq!( (p.text.as_str(), p.source, p.rule), @@ -330,7 +381,7 @@ fn title_tier_renders_the_normalized_title() { ); let mut st = PreviewState::new(); - let p = st.resolve(Instant::now(), &emu, None).clone(); + let p = st.resolve(Instant::now(), &emu, None, None).clone(); assert_eq!( (p.text.as_str(), p.source), ("✢ Claude Code", PreviewSource::Title), @@ -345,7 +396,7 @@ fn title_tier_renders_the_normalized_title() { ); let mut st = PreviewState::new(); let p = st - .resolve(Instant::now(), &quadrant, Some(&ClaudeSummary)) + .resolve(Instant::now(), &quadrant, Some(&ClaudeSummary), None) .clone(); assert_eq!( (p.text.as_str(), p.source, p.rule), @@ -1428,10 +1479,10 @@ fn corpus_non_agent_tuis_keep_their_tiers() { assert!(emu.alternate_screen(), "{name}: alt screen active at cut"); let mut st = PreviewState::new(); let with = st - .resolve(Instant::now(), &emu, Some(&ClaudeSummary)) + .resolve(Instant::now(), &emu, Some(&ClaudeSummary), None) .clone(); let mut st = PreviewState::new(); - let without = st.resolve(Instant::now(), &emu, None).clone(); + let without = st.resolve(Instant::now(), &emu, None, None).clone(); assert_eq!(with, without, "{name}: the adapter must change nothing"); assert_eq!(with.source, PreviewSource::Marker, "{name}"); } diff --git a/src/preview.rs b/src/preview.rs index c17d97b..f3d3375 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -88,17 +88,32 @@ pub trait SummaryAdapter: Sync { } /// Resolve the instantaneous candidate in descending priority: -/// 1. summary adapter: the normalized live status when the CLI's working -/// structure is present, `{model label} · `-prefixed when the adapter -/// reads one from stable chrome -/// 2. alternate screen: the title while its epoch is current, else the marker -/// 3. primary screen: the live floor -fn cascade(screen: &impl ScreenFacts, adapter: Option<&dyn SummaryAdapter>) -> Preview { - if let Some(a) = adapter { - // Both probes use the same viewport snapshot. +/// 1. harness registry: `blocked`, the CLI's own claim that it is blocked on +/// the user, as passed by the caller +/// 2. summary adapter: the normalized live status when the CLI's working +/// structure is present +/// 3. alternate screen: the title while its epoch is current, else the marker +/// 4. primary screen: the live floor +/// +/// Tiers 1 and 2 both produce an Anchor and are both `{model label} · `- +/// prefixed when the adapter reads a label from stable chrome. +fn cascade( + screen: &impl ScreenFacts, + adapter: Option<&dyn SummaryAdapter>, + blocked: Option<(&str, &'static str)>, +) -> Preview { + if adapter.is_some() || blocked.is_some() { + // Both probes and the label read the same viewport snapshot. let rows = screen.live_rows(); - if let Some((text, rule)) = a.live_preview(&rows) { - let text = match a.model_label(&rows) { + // The registry claim is consulted first: what the CLI says about + // itself outranks a structural guess at its screen. It also lands + // about a second before the dialog finishes painting and holds at any + // terminal width, for every dialog shape the CLI draws. + let hit = blocked + .map(|(text, rule)| (text.to_string(), rule)) + .or_else(|| adapter?.live_preview(&rows)); + if let Some((text, rule)) = hit { + let text = match adapter.and_then(|a| a.model_label(&rows)) { Some(label) => format!("{label} · {text}"), None => text, }; @@ -145,8 +160,19 @@ fn cascade(screen: &impl ScreenFacts, adapter: Option<&dyn SummaryAdapter>) -> P }) } -/// State that invalidates the cached preview candidate. -type ResolveKey = (u64, u64, bool, Option); +/// State that invalidates the cached preview candidate: the screen facts the +/// cascade reads, plus the harness blocked-status probe. The probe belongs +/// here because it moves independently of the screen — a session enters and +/// leaves `waiting` with no repaint, and a repaint changes no status — so a +/// key built from screen facts alone would strand a probe result that appeared +/// or cleared while the grid stood still. +type ResolveKey = ( + u64, + u64, + bool, + Option, + Option<(String, &'static str)>, +); /// Per-task preview resolution state. A rerun replaces the `Task` and resets /// this state. @@ -204,12 +230,14 @@ impl PreviewState { /// synthetic instants. The candidate is recomputed only when the /// resolution key changed; hold expiries commit the carried value /// without a rescan. `adapter` is the task's summary adapter, fixed for - /// the task's life, so it needs no slot in the resolution key. + /// the task's life, so it needs no slot in the resolution key; `blocked` + /// is the caller's latest harness blocked-status probe, which does. pub fn resolve( &mut self, now: Instant, screen: &impl ScreenFacts, adapter: Option<&dyn SummaryAdapter>, + blocked: Option<(&str, &'static str)>, ) -> &Preview { if self.finalized { return &self.rendered; @@ -219,9 +247,10 @@ impl PreviewState { screen.alt_epoch(), screen.alternate_screen(), screen.title().map(str::to_owned), + blocked.map(|(text, rule)| (text.to_string(), rule)), ); if self.last_key.as_ref() != Some(&key) { - self.candidate = cascade(screen, adapter); + self.candidate = cascade(screen, adapter, blocked); self.last_key = Some(key); } self.step(now, screen.alternate_screen()); @@ -316,7 +345,10 @@ impl PreviewState { self.rendered.frozen = true; return; } - let mut fin = cascade(screen, adapter); + // No blocked probe: finalization runs once output is complete, and a + // record the exited process left behind claims a state it can no + // longer be in. + let mut fin = cascade(screen, adapter, None); fin.frozen = true; self.rendered = fin; } @@ -451,7 +483,7 @@ mod tests { live: Some(("Working", "stub:working")), label: Some("model-x"), }; - let p = st.resolve(now, &s, Some(&adapter)).clone(); + let p = st.resolve(now, &s, Some(&adapter), None).clone(); assert_eq!( (p.text.as_str(), p.source, p.rule), ( @@ -467,10 +499,79 @@ mod tests { label: None, }; let mut st = PreviewState::new(); - let p = st.resolve(now, &s, Some(&bare)).clone(); + let p = st.resolve(now, &s, Some(&bare), None).clone(); assert_eq!(p.text, "Working"); } + /// The harness probe is the top tier: it outranks the adapter's screen + /// anchor and takes the model label exactly as a screen anchor does. + #[test] + fn a_registry_anchor_outranks_the_adapters_anchor() { + let now = Instant::now(); + let mut st = PreviewState::new(); + let mut s = FakeScreen::primary("shell"); + s.enter_alt(); + s.set_title("app"); + let adapter = StubAdapter { + live: Some(("Working", "stub:working")), + label: Some("model-x"), + }; + let p = st + .resolve( + now, + &s, + Some(&adapter), + Some(("awaiting approval", "stub:registry")), + ) + .clone(); + assert_eq!( + (p.text.as_str(), p.source, p.rule), + ( + "model-x · awaiting approval", + PreviewSource::Anchor, + Some("stub:registry") + ) + ); + } + + /// The probe belongs in the resolution key. A task can enter and leave the + /// blocked state with no repaint, so a key built from screen facts alone + /// carries the stale candidate and the probe never reaches the cascade: + /// both halves of this test fail without it. + #[test] + fn a_probe_that_changes_on_a_static_screen_reaches_the_cascade() { + let t0 = Instant::now(); + let mut st = PreviewState::new(); + let s = FakeScreen::primary("last row"); + assert_eq!( + st.resolve(t0, &s, None, None).source, + PreviewSource::Floor, + "premise: no probe, no anchor" + ); + + // Same screen, same revision: only the probe changed. A rank increase + // renders on the resolution that observes it. + let probe = Some(("awaiting approval", "claude:registry-approval")); + let p = st.resolve(t0, &s, None, probe).clone(); + assert_eq!( + (p.text.as_str(), p.source, p.rule), + ( + "awaiting approval", + PreviewSource::Anchor, + Some("claude:registry-approval") + ) + ); + + // Clearing it is a rank drop like any other, so the floor returns at + // the hold's expiry rather than instantly. + assert_eq!(st.resolve(t0, &s, None, None).source, PreviewSource::Anchor); + let p = st.resolve(t0 + DEMOTION_HOLD, &s, None, None).clone(); + assert_eq!( + (p.text.as_str(), p.source), + ("last row", PreviewSource::Floor) + ); + } + /// A lost anchor is a demotion: the title returns only after the hold, /// exactly like any other rank drop. #[test] @@ -484,7 +585,7 @@ mod tests { live: Some(("Working", "stub:working")), label: None, }; - st.resolve(t0, &s, Some(&working)); + st.resolve(t0, &s, Some(&working), None); let idle = StubAdapter { live: None, @@ -492,11 +593,13 @@ mod tests { }; s.advance(); assert_eq!( - st.resolve(t0, &s, Some(&idle)).source, + st.resolve(t0, &s, Some(&idle), None).source, PreviewSource::Anchor, "a lost anchor must not demote instantly" ); - let p = st.resolve(t0 + DEMOTION_HOLD, &s, Some(&idle)).clone(); + let p = st + .resolve(t0 + DEMOTION_HOLD, &s, Some(&idle), None) + .clone(); assert_eq!((p.text.as_str(), p.source), ("app", PreviewSource::Title)); } @@ -511,11 +614,11 @@ mod tests { live: Some(("Ran echo ok", "stub:ran")), label: None, }; - st.resolve(t0, &s, None); + st.resolve(t0, &s, None, None); s.advance(); st.finalize(&s, Some(&adapter)); - let p = st.resolve(t0, &s, Some(&adapter)).clone(); + let p = st.resolve(t0, &s, Some(&adapter), None).clone(); assert_eq!( (p.text.as_str(), p.source, p.rule, p.frozen), ("Ran echo ok", PreviewSource::Anchor, Some("stub:ran"), true) @@ -529,7 +632,7 @@ mod tests { let mut st = PreviewState::new(); let s = FakeScreen::primary("last row"); - let p = st.resolve(now, &s, None).clone(); + let p = st.resolve(now, &s, None, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("last row", PreviewSource::Floor, false) @@ -539,18 +642,18 @@ mod tests { let mut s = FakeScreen::primary("shell"); s.enter_alt(); s.set_title("app"); - let p = st.resolve(now, &s, None).clone(); + let p = st.resolve(now, &s, None, None).clone(); assert_eq!((p.text.as_str(), p.source), ("app", PreviewSource::Title)); let mut st = PreviewState::new(); let mut s = FakeScreen::primary("shell"); s.enter_alt(); - let p = st.resolve(now, &s, None).clone(); + let p = st.resolve(now, &s, None, None).clone(); assert_eq!((p.text.as_str(), p.source), (MARKER, PreviewSource::Marker)); let mut st = PreviewState::new(); let s = FakeScreen::primary(""); - let p = st.resolve(now, &s, None).clone(); + let p = st.resolve(now, &s, None, None).clone(); assert_eq!((p.text.as_str(), p.source), ("", PreviewSource::Floor)); } @@ -560,14 +663,14 @@ mod tests { let now = Instant::now(); let mut st = PreviewState::new(); let mut s = FakeScreen::primary("building"); - assert_eq!(st.resolve(now, &s, None).source, PreviewSource::Floor); + assert_eq!(st.resolve(now, &s, None, None).source, PreviewSource::Floor); s.enter_alt(); - let p = st.resolve(now, &s, None).clone(); + let p = st.resolve(now, &s, None, None).clone(); assert_eq!((p.text.as_str(), p.source), (MARKER, PreviewSource::Marker)); s.set_title("app"); - let p = st.resolve(now, &s, None).clone(); + let p = st.resolve(now, &s, None, None).clone(); assert_eq!((p.text.as_str(), p.source), ("app", PreviewSource::Title)); } @@ -580,17 +683,20 @@ mod tests { let mut s = FakeScreen::primary("shell"); s.enter_alt(); s.set_title("app"); - st.resolve(t0, &s, None); + st.resolve(t0, &s, None, None); s.clear_title(); assert_eq!( - st.resolve(t0, &s, None).source, + st.resolve(t0, &s, None, None).source, PreviewSource::Title, "a demotion must not render instantly" ); let inside = t0 + DEMOTION_HOLD - Duration::from_millis(1); - assert_eq!(st.resolve(inside, &s, None).source, PreviewSource::Title); - let p = st.resolve(t0 + DEMOTION_HOLD, &s, None).clone(); + assert_eq!( + st.resolve(inside, &s, None, None).source, + PreviewSource::Title + ); + let p = st.resolve(t0 + DEMOTION_HOLD, &s, None, None).clone(); assert_eq!((p.text.as_str(), p.source), (MARKER, PreviewSource::Marker)); } @@ -604,25 +710,25 @@ mod tests { let mut s = FakeScreen::primary("shell"); s.enter_alt(); s.set_title("app"); - st.resolve(t0, &s, None); + st.resolve(t0, &s, None, None); s.clear_title(); - st.resolve(t0, &s, None); + st.resolve(t0, &s, None, None); // The title returns inside the hold: cancel, no visible change. s.set_title("app"); - let p = st.resolve(t0 + ms(300), &s, None).clone(); + let p = st.resolve(t0 + ms(300), &s, None, None).clone(); assert_eq!((p.text.as_str(), p.source), ("app", PreviewSource::Title)); // The next demotion starts a new hold interval. s.clear_title(); - st.resolve(t0 + ms(400), &s, None); + st.resolve(t0 + ms(400), &s, None, None); assert_eq!( - st.resolve(t0 + ms(900), &s, None).source, + st.resolve(t0 + ms(900), &s, None, None).source, PreviewSource::Title, "the canceled hold must not shorten the fresh one" ); assert_eq!( - st.resolve(t0 + ms(1_000), &s, None).source, + st.resolve(t0 + ms(1_000), &s, None, None).source, PreviewSource::Marker ); } @@ -636,19 +742,20 @@ mod tests { let mut s = FakeScreen::primary("shell"); s.enter_alt(); s.set_title("app"); - st.resolve(t0, &s, None); + st.resolve(t0, &s, None, None); // First demoted candidate: the marker. s.clear_title(); - st.resolve(t0, &s, None); + st.resolve(t0, &s, None, None); // The pending candidate flaps to a floor; the timer keeps t0. s.leave_alt(); s.set_floor("done 3 tests"); assert_eq!( - st.resolve(t0 + Duration::from_millis(300), &s, None).source, + st.resolve(t0 + Duration::from_millis(300), &s, None, None) + .source, PreviewSource::Title ); - let p = st.resolve(t0 + DEMOTION_HOLD, &s, None).clone(); + let p = st.resolve(t0 + DEMOTION_HOLD, &s, None, None).clone(); assert_eq!( (p.text.as_str(), p.source), ("done 3 tests", PreviewSource::Floor), @@ -666,14 +773,14 @@ mod tests { let mut s = FakeScreen::primary("shell"); s.enter_alt(); s.set_title("one"); - assert_eq!(st.resolve(t0, &s, None).text, "one"); + assert_eq!(st.resolve(t0, &s, None, None).text, "one"); s.set_title("two"); - assert_eq!(st.resolve(t0 + ms(200), &s, None).text, "one"); + assert_eq!(st.resolve(t0 + ms(200), &s, None, None).text, "one"); s.set_title("three"); - assert_eq!(st.resolve(t0 + ms(300), &s, None).text, "one"); + assert_eq!(st.resolve(t0 + ms(300), &s, None, None).text, "one"); assert_eq!( - st.resolve(t0 + TITLE_MIN_HOLD, &s, None).text, + st.resolve(t0 + TITLE_MIN_HOLD, &s, None, None).text, "three", "the newest candidate wins at the deadline" ); @@ -685,9 +792,9 @@ mod tests { let t0 = Instant::now(); let mut st = PreviewState::new(); let mut s = FakeScreen::primary("compiling foo"); - assert_eq!(st.resolve(t0, &s, None).text, "compiling foo"); + assert_eq!(st.resolve(t0, &s, None, None).text, "compiling foo"); s.set_floor("compiling bar"); - assert_eq!(st.resolve(t0, &s, None).text, "compiling bar"); + assert_eq!(st.resolve(t0, &s, None, None).text, "compiling bar"); } /// An unchanged resolution key carries the candidate without re-reading @@ -698,14 +805,14 @@ mod tests { let ms = Duration::from_millis; let mut st = PreviewState::new(); let mut s = FakeScreen::primary("steady"); - st.resolve(t0, &s, None); + st.resolve(t0, &s, None, None); assert_eq!(s.floor_calls.get(), 1); - st.resolve(t0 + ms(200), &s, None); + st.resolve(t0 + ms(200), &s, None, None); assert_eq!(s.floor_calls.get(), 1, "unchanged key must not re-read"); s.advance(); - st.resolve(t0 + ms(400), &s, None); + st.resolve(t0 + ms(400), &s, None, None); assert_eq!(s.floor_calls.get(), 2, "a revision bump must recompute"); } @@ -716,11 +823,11 @@ mod tests { let t0 = Instant::now(); let mut st = PreviewState::new(); let mut s = FakeScreen::primary("a long row that fit"); - assert_eq!(st.resolve(t0, &s, None).text, "a long row that fit"); + assert_eq!(st.resolve(t0, &s, None, None).text, "a long row that fit"); s.set_floor("a long row"); assert_eq!( - st.resolve(t0, &s, None).text, + st.resolve(t0, &s, None, None).text, "a long row", "the reflowed floor must render, not the carried candidate" ); @@ -733,11 +840,11 @@ mod tests { let t0 = Instant::now(); let mut st = PreviewState::new(); let mut s = FakeScreen::primary("running"); - st.resolve(t0, &s, None); + st.resolve(t0, &s, None, None); s.set_floor("test result: ok"); st.finalize(&s, None); - let p = st.resolve(t0, &s, None).clone(); + let p = st.resolve(t0, &s, None, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("test result: ok", PreviewSource::Floor, true) @@ -753,12 +860,12 @@ mod tests { let mut s = FakeScreen::primary("prelaunch junk"); s.enter_alt(); s.set_title("agent: working"); - st.resolve(t0, &s, None); + st.resolve(t0, &s, None, None); // The exit's 1049l lands with no live resolution in between. s.leave_alt(); st.finalize(&s, None); - let p = st.resolve(t0, &s, None).clone(); + let p = st.resolve(t0, &s, None, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("agent: working", PreviewSource::Title, true) @@ -766,7 +873,7 @@ mod tests { s.set_floor("stray"); assert_eq!( - st.resolve(t0 + Duration::from_secs(5), &s, None).text, + st.resolve(t0 + Duration::from_secs(5), &s, None, None).text, "agent: working", "resolution must short-circuit to the frozen value" ); @@ -781,11 +888,11 @@ mod tests { let mut s = FakeScreen::primary("prelaunch junk"); s.enter_alt(); s.set_title("agent: working"); - st.resolve(t0, &s, None); + st.resolve(t0, &s, None, None); // Teardown lands and a tick resolves before output completes. s.leave_alt(); - let p = st.resolve(t0, &s, None).clone(); + let p = st.resolve(t0, &s, None, None).clone(); assert_eq!( p.source, PreviewSource::Title, @@ -793,7 +900,7 @@ mod tests { ); st.finalize(&s, None); - let p = st.resolve(t0, &s, None).clone(); + let p = st.resolve(t0, &s, None, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("agent: working", PreviewSource::Title, true) @@ -809,14 +916,14 @@ mod tests { let mut s = FakeScreen::primary("prelaunch junk"); s.enter_alt(); s.set_title("agent: working"); - st.resolve(t0, &s, None); + st.resolve(t0, &s, None, None); // The child returns to the primary screen and keeps printing; the // hold expires and commits the floor, stamped primary. s.leave_alt(); s.set_floor("wrote 12 files"); - st.resolve(t0, &s, None); - let p = st.resolve(t0 + DEMOTION_HOLD, &s, None).clone(); + st.resolve(t0, &s, None, None); + let p = st.resolve(t0 + DEMOTION_HOLD, &s, None, None).clone(); assert_eq!( (p.text.as_str(), p.source), ("wrote 12 files", PreviewSource::Floor), @@ -825,7 +932,7 @@ mod tests { s.set_floor("exit summary"); st.finalize(&s, None); - let p = st.resolve(t0 + DEMOTION_HOLD, &s, None).clone(); + let p = st.resolve(t0 + DEMOTION_HOLD, &s, None, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("exit summary", PreviewSource::Floor, true), @@ -842,16 +949,16 @@ mod tests { let mut s = FakeScreen::primary("prelaunch junk"); s.enter_alt(); s.set_title("agent: working"); - st.resolve(t0, &s, None); + st.resolve(t0, &s, None, None); // Teardown observed (snapshot: "prelaunch junk"), title still held. s.leave_alt(); - assert_eq!(st.resolve(t0, &s, None).source, PreviewSource::Title); + assert_eq!(st.resolve(t0, &s, None, None).source, PreviewSource::Title); // A real final line lands before exit, inside the hold. s.set_floor("done"); st.finalize(&s, None); - let p = st.resolve(t0, &s, None).clone(); + let p = st.resolve(t0, &s, None, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("done", PreviewSource::Floor, true), @@ -869,18 +976,18 @@ mod tests { let mut s = FakeScreen::primary("prelaunch junk"); s.enter_alt(); s.set_title("agent: working"); - st.resolve(t0, &s, None); + st.resolve(t0, &s, None, None); s.leave_alt(); - assert_eq!(st.resolve(t0, &s, None).source, PreviewSource::Title); + assert_eq!(st.resolve(t0, &s, None, None).source, PreviewSource::Title); // A later advance changes the revision (and drops the title) but // leaves the floor untouched: nothing visible moved. s.clear_title(); - assert_eq!(st.resolve(t0, &s, None).source, PreviewSource::Title); + assert_eq!(st.resolve(t0, &s, None, None).source, PreviewSource::Title); st.finalize(&s, None); - let p = st.resolve(t0, &s, None).clone(); + let p = st.resolve(t0, &s, None, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("agent: working", PreviewSource::Title, true), @@ -898,7 +1005,7 @@ mod tests { emu.process(b"prelaunch junk\r\n"); emu.process(b"\x1b[?1049h\x1b]0;working\x07app body"); assert_eq!( - st.resolve(t0, &emu, None).source, + st.resolve(t0, &emu, None, None).source, PreviewSource::Title, "premise: the title rendered under the alt screen" ); @@ -911,7 +1018,7 @@ mod tests { "premise: the snapshot is the restore, not the successor line" ); st.finalize(&emu, None); - let p = st.resolve(t0, &emu, None).clone(); + let p = st.resolve(t0, &emu, None, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("done", PreviewSource::Floor, true) @@ -927,7 +1034,10 @@ mod tests { let mut emu = Emulator::new(24, 40, 100); emu.process(b"prelaunch junk that will wrap\r\n"); emu.process(b"\x1b[?1049h\x1b]0;working\x07app body"); - assert_eq!(st.resolve(t0, &emu, None).source, PreviewSource::Title); + assert_eq!( + st.resolve(t0, &emu, None, None).source, + PreviewSource::Title + ); emu.process(b"\x1b[?1049l"); let before = emu.live_floor(); @@ -938,7 +1048,7 @@ mod tests { "premise: the reflow moved the floor" ); st.finalize(&emu, None); - let p = st.resolve(t0, &emu, None).clone(); + let p = st.resolve(t0, &emu, None, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("working", PreviewSource::Title, true), @@ -955,13 +1065,16 @@ mod tests { let mut emu = Emulator::new(24, 40, 100); emu.process(b"prelaunch junk that will wrap\r\n"); emu.process(b"\x1b[?1049h\x1b]0;working\x07app body"); - assert_eq!(st.resolve(t0, &emu, None).source, PreviewSource::Title); + assert_eq!( + st.resolve(t0, &emu, None, None).source, + PreviewSource::Title + ); emu.process(b"\x1b[?1049l"); emu.process(b"done\r\n"); emu.resize(24, 20); st.finalize(&emu, None); - let p = st.resolve(t0, &emu, None).clone(); + let p = st.resolve(t0, &emu, None, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("done", PreviewSource::Floor, true), @@ -978,11 +1091,11 @@ mod tests { let mut s = FakeScreen::primary("shell"); s.enter_alt(); s.set_title("step 1"); - st.resolve(t0, &s, None); + st.resolve(t0, &s, None, None); s.set_title("step 2: done"); st.finalize(&s, None); - let p = st.resolve(t0, &s, None).clone(); + let p = st.resolve(t0, &s, None, None).clone(); assert_eq!( (p.text.as_str(), p.source, p.frozen), ("step 2: done", PreviewSource::Title, true) @@ -995,7 +1108,7 @@ mod tests { let mut st = PreviewState::new(); let t0 = Instant::now(); let s = FakeScreen::primary(" gpt-5.6-sol high · fleetcom · 89.9K used"); - let p = st.resolve(t0, &s, None).clone(); + let p = st.resolve(t0, &s, None, None).clone(); assert_eq!( (p.text.as_str(), p.source), ( diff --git a/src/task.rs b/src/task.rs index 0b84047..25df637 100644 --- a/src/task.rs +++ b/src/task.rs @@ -35,6 +35,15 @@ use crate::{ /// input when a child stops reading. const MAX_PENDING_WRITE: usize = 16 * 1024 * 1024; +/// Minimum interval between harness blocked-status probes for one task. The +/// probe reads the CLI's registry off disk, and `resolve_preview` runs for +/// every task on every snapshot tick, which range from the 8 ms frame minimum +/// to the 200 ms idle backstop: unthrottled, that is a syscall per task per +/// frame. 250 ms buys nothing back in exchange, because the state is +/// human-facing and already sits far below the 500 ms title hold and the +/// 600 ms demotion hold the preview passes through afterward. +const BLOCKED_PROBE_INTERVAL: Duration = Duration::from_millis(250); + /// A whole-message refusal from the bounded writer queue. #[derive(Debug)] pub struct WriteRefused { @@ -113,6 +122,12 @@ pub struct Task { /// Dashboard-preview resolution state; resets with the task on rerun /// because a rerun replaces the whole `Task`. preview: PreviewState, + /// Latest harness blocked-on-user probe, held between refreshes so the + /// preview cascade sees it on every tick without a filesystem read. + blocked: Option<(String, &'static str)>, + /// When `blocked` was last read: the [`BLOCKED_PROBE_INTERVAL`] deadline + /// base. `None` until the first probe. + blocked_probed: Option, /// Wall-clock spawn time used for filesystem correlation. pub spawned_at: SystemTime, exit_code: Option, @@ -346,6 +361,8 @@ impl Task { scraped_id: None, scraped: false, preview: PreviewState::new(), + blocked: None, + blocked_probed: None, spawned_at: SystemTime::now(), exit_code: None, started: Instant::now(), @@ -514,12 +531,41 @@ impl Task { /// the grid lock (see [`crate::preview`]). `now` is the caller's tick /// instant so every task in one snapshot resolves against the same clock. pub fn resolve_preview(&mut self, now: Instant) -> Preview { + self.refresh_blocked(now); let emu = grid(&self.parser); + let blocked = self.blocked.as_ref().map(|(text, rule)| (&**text, *rule)); self.preview - .resolve(now, &*emu, self.summary_adapter) + .resolve(now, &*emu, self.summary_adapter, blocked) .clone() } + /// Re-read the harness's blocked-on-user claim, at most once per + /// [`BLOCKED_PROBE_INTERVAL`]. Three states never probe: no harness (the + /// command is opaque, or its tool publishes no status), no pid, and an + /// exited leader, whose record — if the CLI left one behind at all — + /// claims a state the process can no longer be in. The last of those also + /// drops the cached claim, so the ticks between exit and freeze do not + /// render a dead session as blocked. + fn refresh_blocked(&mut self, now: Instant) { + let (Some(h), Some(pid), None) = (self.harness, self.pid, self.finished) else { + self.blocked = None; + return; + }; + if self + .blocked_probed + .is_some_and(|t| now.duration_since(t) < BLOCKED_PROBE_INTERVAL) + { + return; + } + self.blocked_probed = Some(now); + self.blocked = h.live_blocked_status( + pid, + &self.cwd, + self.spawned_at, + self.harness_home.as_deref(), + ); + } + /// Freeze the preview once output is complete. Any open `?2026` frame is /// landed first. pub fn finalize_preview(&mut self) { From e6b7f2d20cec9cdcfa39f4e2911bd4c944e8a2ac Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 15 Aug 2026 22:17:05 -0700 Subject: [PATCH 4/8] fix(harness): match the registry cwd through symlink aliases and drop the unread status vocabulary --- docs/agent-resume.md | 6 +- src/harness/claude.rs | 163 +++++++++++++++++++++--------------------- src/harness/mod.rs | 15 ++-- 3 files changed, 94 insertions(+), 90 deletions(-) diff --git a/docs/agent-resume.md b/docs/agent-resume.md index 9ca1b8a..6730003 100644 --- a/docs/agent-resume.md +++ b/docs/agent-resume.md @@ -11,7 +11,7 @@ Start a supported agent without flags: 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. -On a finished agent task, `r` uses the captured launch, hook, notifier, registry, or exit ID without performing save-time filesystem correlation. Because the registry counts among those sources, a rerun can also recover an ID for a session whose `SessionStart` hook never fired, such as one launched with hooks disabled. The replacement keeps the task's ID, tag, group, and name. After a successful rewrite, the row shows the resume command because it has become the task's launch recipe; a [saved session](sessions.md) records the same string. +On a finished agent task, `r` uses the captured launch, hook, notifier, registry, or exit ID without performing save-time filesystem correlation. The registry earns its place at save (`w`) rather than rerun: it names the conversation a *live* session is running even when the `SessionStart` hook never fired, as with hooks disabled. A rerun reads it only after an unclean exit, since a clean exit removes the record. The replacement keeps the task's ID, tag, group, and name. After a successful rewrite, the row shows the resume command because it has become the task's launch recipe; a [saved session](sessions.md) records the same string. Capture is best-effort and narrow by design. A command carrying a prompt, extra flags, or shell syntax stays opaque and saves verbatim. An accepted command with no available ID also saves unchanged. In both cases, loading the recipe reruns the original command. @@ -52,7 +52,9 @@ A bare Claude command can accept an ID at launch. `fleetcom` therefore generates A canonical resume command already supplies its conversation ID, so adding a second ID would be incorrect; it receives only `--settings`. The overlay installs a `SessionStart` hook that copies its JSON payload into `FLEETCOM_CAPTURE_FILE`, from which the harness reads `session_id`. -Claude also publishes a live session registry: one `/sessions/.json` record per session, written and rewritten by the CLI itself with no instrumentation. `$SHELL -c` execs an accepted command in place, so a task's own PID names its record; the lookup is a direct path, not a search. A record counts only when its `kind` is `interactive` and its `pid`, `cwd`, and `startedAt` all match the task. Those guards are load-bearing: the CLI removes the record on a clean exit but leaves it behind when the process dies on a signal, and only the next `claude` launch sweeps it, so a recycled PID can otherwise find a stranger's record filed under its own name. `startedAt` names the process start, which `/clear` leaves untouched, so the 30-second match does not decay as a session ages. `/cd` inside claude moves the session's directory and loses the record. The CLI rewrites the file in place rather than renaming a temporary, so a torn read yields no evidence rather than bad evidence. The same record also carries the session's live status, from which the dashboard reads one state — `waiting`, the CLI blocked on the user — as the top tier of its [preview cascade](commands.md#peek). +Claude also publishes a live session registry: one `/sessions/.json` record per session, written and rewritten by the CLI itself with no instrumentation. `sh`, `bash`, `zsh`, and `dash` each exec a single simple `-c` command in place rather than forking, so a task's own PID names its record and the lookup is a direct path, not a search. That exec is a shell optimization, not a guarantee: under a `$SHELL` that forks and waits instead, the task's leader is the shell, no record is filed under its PID, and the registry simply goes unused rather than wrong. + +A record counts only when its `kind` is `interactive` and its `pid`, `cwd`, and `startedAt` all match the task. A live task's PID cannot be reissued — `fleetcom` reaps with `WNOWAIT`, leaving the exited leader a zombie that holds the PID for the task's whole life — so the file is that task's own record or nothing. The `cwd` and `startedAt` guards close what the reservation cannot: the CLI removes its record on a clean exit but leaves it behind when the process dies on a signal, and only the next `claude` launch sweeps it, so a record from an *earlier* process at that PID can otherwise be read as this task's. `cwd` matches through symlink aliases, because claude records the `getcwd(3)` form while a task carries the path it was spawned with. `startedAt` names the process start, which `/clear` leaves untouched, so the 30-second match does not decay as a session ages. `/cd` inside claude moves the session's directory and loses the record. The CLI rewrites the file in place rather than renaming a temporary, so a torn read yields no evidence rather than bad evidence. The same record also carries the session's live status, from which the dashboard reads one state — `waiting`, the CLI blocked on the user — as the top tier of its [preview cascade](commands.md#peek). After the process exits and the PTY reader reaches EOF, the harness scans the retained terminal text for the last `claude --resume ` hint. Save-time filesystem correlation checks `/projects//.jsonl`, where the slug replaces `/` and `.` in the absolute working directory with `-`. diff --git a/src/harness/claude.rs b/src/harness/claude.rs index ee15fdb..b8b912b 100644 --- a/src/harness/claude.rs +++ b/src/harness/claude.rs @@ -78,11 +78,11 @@ impl Harness for Claude { home: Option<&Path>, ) -> Option<(String, &'static str)> { let rec = record_for_pid(home, pid, cwd, spawned)?; - // `Waiting` alone: see the trait doc. The registry beats the screen to - // this one state by about a second and reports it at any terminal - // width and for every dialog shape, including the ones + // The waiting state alone: see the trait doc. The registry beats the + // screen to this one state by about a second and reports it at any + // terminal width and for every dialog shape, including the ones // `ClaudeSummary`'s `❯ 1. `/`2. ` selector match does not cover. - (rec.status == Some(SessionStatus::Waiting)) + rec.waiting .then(|| waiting_preview(rec.waiting_for.as_deref())) } @@ -113,35 +113,16 @@ struct SessionRecord { /// `startedAt`: the process's start in epoch milliseconds. `procStart` /// names the same instant in human-readable form. started_at: u128, - /// `status`, absent from records written by non-interactive entrypoints. - status: Option, - /// `waitingFor`: why a `Waiting` session waits. Present only while the CLI + /// `status` reading `waiting`: the CLI blocked on the user. That is the + /// only value any caller acts on, so the rest of the vocabulary, a value + /// this reader predates, and the absent field non-interactive entrypoints + /// write all collapse to `false` without invalidating the record. + waiting: bool, + /// `waitingFor`: why a waiting session waits. Present only while the CLI /// holds a dialog open. waiting_for: Option, } -/// The `status` vocabulary the CLI validates its own records against. -#[derive(Debug, PartialEq, Eq)] -enum SessionStatus { - Busy, - Shell, - Idle, - Waiting, -} - -/// Map one `status` string. An unrecognized value yields `None` instead of -/// rejecting the record: a later CLI version can extend the vocabulary, and the -/// session ID stays valid either way. -fn status_of(status: &str) -> Option { - Some(match status { - "busy" => SessionStatus::Busy, - "shell" => SessionStatus::Shell, - "idle" => SessionStatus::Idle, - "waiting" => SessionStatus::Waiting, - _ => return None, - }) -} - /// Preview text and matcher ID for a `waiting` record's `waitingFor` reason. /// The CLI's dialog-label map spells five reasons: `permission prompt` (its /// default for any dialog), `input needed`, `dialog open`, `sandbox request`, @@ -158,11 +139,6 @@ fn waiting_preview(reason: Option<&str>) -> (String, &'static str) { } } -/// The registry directory: one `.json` record per live session. -fn sessions_dir(home: Option<&Path>) -> Option { - Some(Claude.home_root(home)?.join("sessions")) -} - /// Parse one registry record. The CLI rewrites the file in place with a plain /// write rather than a temp-and-rename, so a reader can catch it truncated: /// unparseable text yields `None` and the caller simply has no evidence this @@ -179,7 +155,7 @@ fn parse_record(text: &str) -> Option { pid: v["pid"].as_i32().filter(|p| *p > 0)?, cwd: PathBuf::from(v["cwd"].as_str()?), started_at: u128::from(v["startedAt"].as_u64()?), - status: v["status"].as_str().and_then(status_of), + waiting: v["status"].as_str() == Some("waiting"), waiting_for: v["waitingFor"].as_str().map(str::to_string), }) } @@ -187,14 +163,22 @@ fn parse_record(text: &str) -> Option { /// Read the record `pid` publishes, requiring it to name that pid, that `cwd`, /// and a process started within [`super::CORRELATE_WINDOW`] of `spawned`. /// -/// The two extra guards close a stale-record hazard: a `claude` killed by a -/// signal leaves its record behind, and only the next `claude` launch sweeps -/// it, so a recycled pid can find a stranger's record filed under its own name. -/// `cwd` separates two directories; `startedAt` separates two processes in one -/// directory. That window does not decay with session age, because `startedAt` -/// records the process start: `/clear` mints a fresh `sessionId` in place and -/// leaves `startedAt` untouched, so a session running for hours still matches -/// its original spawn instant. +/// A live task's pid cannot be reissued to a foreign `claude`: +/// [`crate::task::Task::poll_exit`] reaps with `WNOWAIT` and leaves the exited +/// leader a zombie, which holds the pid for the task's whole life. So +/// `sessions/.json` is this task's own record or nothing — that, not the +/// field checks, is what keeps a stranger out. +/// +/// The `cwd` and `startedAt` guards close what the reservation cannot: a record +/// an *earlier* process at that pid left behind, before this task existed. The +/// CLI removes its record on a clean exit, but a signal-killed `claude` leaves +/// it and only the next `claude` launch sweeps it. `cwd` separates two +/// directories; `startedAt` separates two processes in one directory. Both cost +/// less than the read that produced the record and sit at a shell-command +/// boundary, so they stay. That window does not decay with session age, because +/// `startedAt` records the process start: `/clear` mints a fresh `sessionId` in +/// place and leaves `startedAt` untouched, so a session running for hours still +/// matches its original spawn instant. /// /// Call-site details: `/cd` inside claude moves the session's `cwd` and fails /// this check, which loses the record. Failing closed there is deliberate. @@ -205,11 +189,17 @@ fn record_for_pid( spawned: SystemTime, ) -> Option { let pid = i32::try_from(pid).ok()?; - let text = fs::read_to_string(sessions_dir(home)?.join(format!("{pid}.json"))).ok()?; + let dir = Claude.home_root(home)?.join("sessions"); + let text = fs::read_to_string(dir.join(format!("{pid}.json"))).ok()?; let rec = parse_record(&text)?; let spawned_ms = spawned.duration_since(UNIX_EPOCH).ok()?.as_millis(); - (rec.pid == pid && rec.cwd == cwd && within_window_ms(rec.started_at, spawned_ms)) - .then_some(rec) + // Same path through two aliases is one directory: claude records + // `process.cwd()`, which is `getcwd(3)` and so symlink-resolved, while a + // task carries the path it was spawned with. `canonicalize` is IO and fails + // on a vanished directory, which leaves the verbatim comparison standing — + // a cwd matching neither form is still refused. + let same_cwd = rec.cwd == cwd || cwd.canonicalize().is_ok_and(|c| rec.cwd == c); + (rec.pid == pid && same_cwd && within_window_ms(rec.started_at, spawned_ms)).then_some(rec) } /// Convert an absolute working directory to Claude's project slug by replacing @@ -409,7 +399,7 @@ mod tests { let rec = record_for_pid(Some(&home), LIVE_PID, cwd, at_ms(LIVE_STARTED)) .expect("the live record must parse"); assert_eq!(rec.id, OTHER); - assert_eq!(rec.status, Some(SessionStatus::Idle)); + assert!(!rec.waiting, "the record's status is `idle`"); assert_eq!(rec.waiting_for, None); assert_eq!( Claude @@ -450,6 +440,42 @@ mod tests { assert!(record_for_pid(Some(&home), 4242, cwd, spawned).is_none()); } + /// Claude records `process.cwd()`, which `getcwd(3)` already resolved + /// through every symlink; the task carries the path it was spawned with. + /// One directory reached two ways still matches. + #[test] + fn record_for_pid_accepts_a_symlinked_cwd_alias() { + let tmp = temp("claude_registry_alias"); + let home = tmp.join("home"); + let real = tmp.join("real"); + let link = tmp.join("link"); + let other = tmp.join("other"); + for d in [&home, &real, &other] { + fs::create_dir_all(d).unwrap(); + } + std::os::unix::fs::symlink(&real, &link).unwrap(); + let canonical = real.canonicalize().unwrap(); + install_record( + &home, + 7, + &record( + 7, + ID, + canonical.to_str().unwrap(), + LIVE_STARTED, + "interactive", + "", + ), + ); + + let spawned = at_ms(LIVE_STARTED); + assert!(record_for_pid(Some(&home), 7, &link, spawned).is_some()); + // A real directory that is not an alias of the record's is still + // refused, as is one that no longer exists to canonicalize. + assert!(record_for_pid(Some(&home), 7, &other, spawned).is_none()); + assert!(record_for_pid(Some(&home), 7, &tmp.join("gone"), spawned).is_none()); + } + /// A `claude` killed by a signal leaves its record behind until the next /// launch sweeps it. A task later assigned that pid in the same directory /// satisfies both identity guards, so the process start is what rejects it. @@ -527,51 +553,22 @@ mod tests { assert!(record_for_pid(Some(&bare), LIVE_PID, cwd, spawned).is_none()); } - /// The status vocabulary the CLI validates its own records against, the - /// absent status a non-interactive entrypoint writes, and the reason a - /// waiting session carries. + /// Only `waiting` is read, so a status absent or from a vocabulary this + /// reader predates leaves the record valid and its ID usable. #[test] - fn record_for_pid_reads_the_status_vocabulary() { + fn record_for_pid_keeps_the_id_under_an_unread_status() { let home = temp("claude_registry_status"); let cwd = Path::new("/w"); let spawned = at_ms(LIVE_STARTED); - let read = || record_for_pid(Some(&home), 7, cwd, spawned).expect("the record must parse"); - let install = |tail: &str| { + for tail in ["", r#","status":"hibernating""#, r#","status":"busy""#] { install_record( &home, 7, &record(7, ID, "/w", LIVE_STARTED, "interactive", tail), ); - }; - - for (status, want) in [ - ("busy", SessionStatus::Busy), - ("shell", SessionStatus::Shell), - ("idle", SessionStatus::Idle), - ("waiting", SessionStatus::Waiting), - ] { - install(&format!(r#","status":"{status}""#)); - assert_eq!(read().status, Some(want), "{status}"); - } - // A status absent, or from a vocabulary this reader predates, still - // yields the ID. - for tail in ["", r#","status":"hibernating""#] { - install(tail); - let rec = read(); - assert_eq!(rec.status, None, "{tail:?}"); - assert_eq!(rec.id, ID); - } - for reason in [ - "permission prompt", - "input needed", - "dialog open", - "sandbox request", - "worker request", - ] { - install(&format!(r#","status":"waiting","waitingFor":"{reason}""#)); - let rec = read(); - assert_eq!(rec.status, Some(SessionStatus::Waiting)); - assert_eq!(rec.waiting_for.as_deref(), Some(reason)); + let rec = record_for_pid(Some(&home), 7, cwd, spawned).expect("the record must parse"); + assert_eq!(rec.id, ID, "{tail:?}"); + assert!(!rec.waiting, "{tail:?}"); } } diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 804bb3c..fd274eb 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -95,10 +95,15 @@ pub trait Harness: Sync { /// Read the ID the tool is running right now from the live registry it /// publishes on disk. `pid` is the task's session leader, which for every - /// accepted command shape is the tool's own process. `cwd` and `spawned` - /// identify that process, since a registry record can outlive its writer. - /// Defaults to `None`: a tool that publishes no registry has nothing to - /// read. + /// accepted command shape is the tool's own process: `sh`, `bash`, `zsh`, + /// and `dash` each exec a single simple `-c` command in place rather than + /// forking, so `$$` names the tool. That exec is a shell optimization, not + /// a guarantee — under a `$SHELL` that forks and waits instead, the leader + /// is the shell, no record is filed under its pid, and this returns + /// `None`. The registry then goes unused rather than wrong. `cwd` and + /// `spawned` identify that process, since a registry record can outlive its + /// writer. Defaults to `None`: a tool that publishes no registry has + /// nothing to read. fn live_session_id( &self, _pid: u32, @@ -114,7 +119,7 @@ pub trait Harness: Sync { /// `(text, rule)` shape [`crate::preview::SummaryAdapter::live_preview`] /// returns, so the cascade treats a registry-derived anchor and a /// screen-derived one alike. Parameters identify the live process exactly - /// as [`Harness::live_session_id`] does. + /// as [`Harness::live_session_id`] does, its exec-in-place caveat included. /// /// Only a blocked state answers `Some`. A tool's working and idle states /// already resolve to a title carrying the CLI's own per-turn summary From fb91c376ae6bacad64ab5a0f26561a625d520da7 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sat, 15 Aug 2026 22:32:44 -0700 Subject: [PATCH 5/8] test(supervisor): pin the blocked-status probe through the real dashboard path --- src/supervisor_capture_tests.rs | 144 +++++++++++++++++++++++++++++++- src/task.rs | 20 +++-- 2 files changed, 156 insertions(+), 8 deletions(-) diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs index 57ef397..c448dbf 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -1,5 +1,8 @@ use super::*; -use crate::harness::fixtures::{ID as CAP_ID, OTHER as CAP_OTHER}; +use crate::{ + harness::fixtures::{ID as CAP_ID, OTHER as CAP_OTHER}, + protocol::{Preview, PreviewSource}, +}; // --- session-capture wiring ------------------------------------------- @@ -1439,3 +1442,142 @@ fn recovery_cadence_rewrites_on_capture_drift_and_skips_when_static() { let names: Vec<_> = std::fs::read_dir(&rec).unwrap().flatten().collect(); assert_eq!(names.len(), 1, "one incarnation owns one snapshot file"); } + +// --- live registry blocked status -------------------------------------- + +/// File the registry record `pid` publishes, carrying the raw `status` JSON +/// pair. Every field `record_for_pid` validates has to agree with the task: +/// the file name and `pid`, the `cwd`, and a process start inside the +/// correlation window of the spawn. +fn install_status_record(home: &Path, pid: u32, cwd: &Path, status: &str) { + let sessions = home.join("sessions"); + std::fs::create_dir_all(&sessions).unwrap(); + std::fs::write( + sessions.join(format!("{pid}.json")), + format!( + r#"{{"pid":{pid},"sessionId":"{CAP_ID}","cwd":"{cwd}","startedAt":{started},"kind":"interactive",{status}}}"#, + cwd = cwd.display(), + started = now_ms() + ), + ) + .unwrap(); +} + +/// Tick until the sole task's emitted preview satisfies `pred`, then return +/// the last preview seen. Only a tick resolves a preview and only a +/// resolution probes the registry, so the 250 ms probe throttle expires on +/// ticks, not on sleeps. A negative assertion reads the return value: `pred` +/// stops on the first violation, so the preview returned is the violating one. +fn tick_until_preview( + s: &mut Supervisor, + budget: Duration, + mut pred: impl FnMut(&Preview) -> bool, +) -> Preview { + let mut last = None; + wait_until(budget, || { + s.tick(); + for e in s.drain() { + if let Event::Tasks(v) = e + && let Some(t) = v.into_iter().next() + { + last = Some(t.preview); + } + } + last.as_ref().is_some_and(&mut pred) + }); + last.expect("a Tasks snapshot must carry the task's preview") +} + +/// The only test that drives `Task::refresh_blocked`: the harness tests call +/// `live_blocked_status` directly and the preview tests hand `resolve` a +/// literal claim, so gutting the probe leaves both green. A `waiting` record +/// the CLI publishes for the task's own pid reaches the dashboard as the +/// anchor tier, a non-waiting record does not, and an exited leader stops +/// claiming to be blocked even though its record outlives it. +#[test] +fn registry_waiting_status_reaches_the_dashboard_preview() { + let dir = scratch("registry_blocked"); + let (bin, runtime) = (dir.join("bin"), dir.join("run")); + let (claude_home, done) = (dir.join("claude_home"), dir.join("done")); + install_script( + &bin, + "claude", + &format!( + "until [ -e '{d}' ]; do sleep 0.05; done", + d = done.display() + ), + ); + let mut s = sup_ctx(agent_ctx_plus( + &bin, + &runtime, + dir.to_path_buf(), + &[("CLAUDE_CONFIG_DIR", &claude_home)], + )); + spawn(&mut s, "claude", dir.to_path_buf()); + // The record is keyed by the leader's pid, which only the spawn can name. + let pid = s.tasks[0].pid().expect("a live task has a pid"); + + // `idle` is a live session no user is blocking on: three probe intervals + // of ticks must never anchor the preview. + install_status_record(&claude_home, pid, &dir, r#""status":"idle""#); + let p = tick_until_preview(&mut s, Duration::from_millis(750), |p| { + p.source == PreviewSource::Anchor + }); + assert_ne!( + p.source, + PreviewSource::Anchor, + "a non-waiting record must not anchor the preview: {p:?}" + ); + + // The CLI rewrites the record in place when it blocks on a dialog. + install_status_record( + &claude_home, + pid, + &dir, + r#""status":"waiting","waitingFor":"permission prompt""#, + ); + let p = tick_until_preview(&mut s, Duration::from_secs(5), |p| { + p.source == PreviewSource::Anchor + }); + assert_eq!( + (p.text.as_str(), p.source, p.rule), + ( + "awaiting approval", + PreviewSource::Anchor, + Some("claude:registry-approval") + ), + "a waiting record must reach the dashboard as the anchor tier" + ); + + // The leader exits and its record survives, as one the CLI never got to + // remove does. The reason is rewritten afterward, to text the live task + // never saw: adopting it could only come from probing a dead leader, + // which the demotion hold holding the old text cannot be mistaken for. + std::fs::write(&done, b"").unwrap(); + assert!( + reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] + .finished + .is_some()), + "the leader never exited" + ); + install_status_record( + &claude_home, + pid, + &dir, + r#""status":"waiting","waitingFor":"dialog open""#, + ); + let p = tick_until_preview(&mut s, Duration::from_secs(5), |p| { + p.text != "awaiting approval" + }); + assert!( + p.text != "awaiting approval" && p.text != "dialog open", + "an exited leader must not render as blocked: {p:?}" + ); + assert!( + claude_home + .join("sessions") + .join(format!("{pid}.json")) + .is_file(), + "the surviving record is the whole point of the case" + ); +} diff --git a/src/task.rs b/src/task.rs index 25df637..9e98afa 100644 --- a/src/task.rs +++ b/src/task.rs @@ -38,10 +38,12 @@ const MAX_PENDING_WRITE: usize = 16 * 1024 * 1024; /// Minimum interval between harness blocked-status probes for one task. The /// probe reads the CLI's registry off disk, and `resolve_preview` runs for /// every task on every snapshot tick, which range from the 8 ms frame minimum -/// to the 200 ms idle backstop: unthrottled, that is a syscall per task per -/// frame. 250 ms buys nothing back in exchange, because the state is -/// human-facing and already sits far below the 500 ms title hold and the -/// 600 ms demotion hold the preview passes through afterward. +/// to the 200 ms idle backstop: unthrottled, that is a filesystem read per +/// claude task per frame. Nothing downstream absorbs what the throttle costs. +/// A blocked status appearing is a rank increase, which cancels any pending +/// demotion and renders on the tick that observes it, so the interval plus one +/// tick is the whole visible latency of a newly blocked session. 250 ms of it +/// is a delay no human reading a status line can distinguish from immediate. const BLOCKED_PROBE_INTERVAL: Duration = Duration::from_millis(250); /// A whole-message refusal from the bounded writer queue. @@ -373,9 +375,13 @@ impl Task { }) } - /// The session leader's PID. `$SHELL -c` execs an accepted agent command in - /// place, so for those tasks this is the agent process itself: the pid its - /// live session registry is keyed by. + /// The session leader's PID. `sh`, `bash`, `zsh`, and `dash` each exec a + /// single simple `-c` command in place rather than forking, so for an + /// accepted agent command this is the agent process itself: the pid its + /// live session registry is keyed by. That exec is a shell optimization, + /// not a guarantee — under a `$SHELL` that forks and waits, the leader is + /// the shell and the registry lookups find nothing rather than the wrong + /// thing. pub fn pid(&self) -> Option { self.pid } From 527c5b55ab26ca33e40c76b87ce371f53c743668 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sun, 16 Aug 2026 12:18:36 -0700 Subject: [PATCH 6/8] refactor(harness): single-source the registry test record and the exec-in-place caveat --- src/harness/claude.rs | 62 ++++++++++++++++----------------- src/supervisor_capture_tests.rs | 48 ++++++++++--------------- src/task.rs | 11 +++--- 3 files changed, 54 insertions(+), 67 deletions(-) diff --git a/src/harness/claude.rs b/src/harness/claude.rs index b8b912b..4a67db1 100644 --- a/src/harness/claude.rs +++ b/src/harness/claude.rs @@ -67,7 +67,7 @@ impl Harness for Claude { spawned: SystemTime, home: Option<&Path>, ) -> Option { - Some(record_for_pid(home, pid, cwd, spawned)?.id) + Some(record_for_pid(pid, cwd, spawned, home)?.id) } fn live_blocked_status( @@ -77,7 +77,7 @@ impl Harness for Claude { spawned: SystemTime, home: Option<&Path>, ) -> Option<(String, &'static str)> { - let rec = record_for_pid(home, pid, cwd, spawned)?; + let rec = record_for_pid(pid, cwd, spawned, home)?; // The waiting state alone: see the trait doc. The registry beats the // screen to this one state by about a second and reports it at any // terminal width and for every dialog shape, including the ones @@ -183,10 +183,10 @@ fn parse_record(text: &str) -> Option { /// Call-site details: `/cd` inside claude moves the session's `cwd` and fails /// this check, which loses the record. Failing closed there is deliberate. fn record_for_pid( - home: Option<&Path>, pid: u32, cwd: &Path, spawned: SystemTime, + home: Option<&Path>, ) -> Option { let pid = i32::try_from(pid).ok()?; let dir = Claude.home_root(home)?.join("sessions"); @@ -396,7 +396,7 @@ mod tests { let home = temp("claude_registry"); install_record(&home, LIVE_PID as i32, LIVE_RECORD); let cwd = Path::new(LIVE_CWD); - let rec = record_for_pid(Some(&home), LIVE_PID, cwd, at_ms(LIVE_STARTED)) + let rec = record_for_pid(LIVE_PID, cwd, at_ms(LIVE_STARTED), Some(&home)) .expect("the live record must parse"); assert_eq!(rec.id, OTHER); assert!(!rec.waiting, "the record's status is `idle`"); @@ -422,7 +422,7 @@ mod tests { 4242, &record(4242, ID, "/w", LIVE_STARTED, "interactive", ""), ); - assert!(record_for_pid(Some(&home), 4242, cwd, spawned).is_some()); + assert!(record_for_pid(4242, cwd, spawned, Some(&home)).is_some()); // A record filed under one pid while naming another is not this task's. install_record( @@ -430,14 +430,14 @@ mod tests { 4242, &record(99, ID, "/w", LIVE_STARTED, "interactive", ""), ); - assert!(record_for_pid(Some(&home), 4242, cwd, spawned).is_none()); + assert!(record_for_pid(4242, cwd, spawned, Some(&home)).is_none()); install_record( &home, 4242, &record(4242, ID, "/elsewhere", LIVE_STARTED, "interactive", ""), ); - assert!(record_for_pid(Some(&home), 4242, cwd, spawned).is_none()); + assert!(record_for_pid(4242, cwd, spawned, Some(&home)).is_none()); } /// Claude records `process.cwd()`, which `getcwd(3)` already resolved @@ -469,11 +469,11 @@ mod tests { ); let spawned = at_ms(LIVE_STARTED); - assert!(record_for_pid(Some(&home), 7, &link, spawned).is_some()); + assert!(record_for_pid(7, &link, spawned, Some(&home)).is_some()); // A real directory that is not an alias of the record's is still // refused, as is one that no longer exists to canonicalize. - assert!(record_for_pid(Some(&home), 7, &other, spawned).is_none()); - assert!(record_for_pid(Some(&home), 7, &tmp.join("gone"), spawned).is_none()); + assert!(record_for_pid(7, &other, spawned, Some(&home)).is_none()); + assert!(record_for_pid(7, &tmp.join("gone"), spawned, Some(&home)).is_none()); } /// A `claude` killed by a signal leaves its record behind until the next @@ -490,12 +490,12 @@ mod tests { ); // The same process: its start is inside the correlation window. - assert!(record_for_pid(Some(&home), 4242, cwd, at_ms(LIVE_STARTED + 30_000)).is_some()); - assert!(record_for_pid(Some(&home), 4242, cwd, at_ms(LIVE_STARTED - 30_000)).is_some()); + assert!(record_for_pid(4242, cwd, at_ms(LIVE_STARTED + 30_000), Some(&home)).is_some()); + assert!(record_for_pid(4242, cwd, at_ms(LIVE_STARTED - 30_000), Some(&home)).is_some()); // A later process under the recycled pid: minutes apart, or one // millisecond outside the window. - assert!(record_for_pid(Some(&home), 4242, cwd, at_ms(LIVE_STARTED + 30_001)).is_none()); - assert!(record_for_pid(Some(&home), 4242, cwd, at_ms(LIVE_STARTED + 600_000)).is_none()); + assert!(record_for_pid(4242, cwd, at_ms(LIVE_STARTED + 30_001), Some(&home)).is_none()); + assert!(record_for_pid(4242, cwd, at_ms(LIVE_STARTED + 600_000), Some(&home)).is_none()); } /// Only an `interactive` record names a conversation a user is driving, @@ -508,7 +508,7 @@ mod tests { for kind in ["bg", "daemon", "daemon-worker"] { install_record(&home, 7, &record(7, ID, "/w", LIVE_STARTED, kind, "")); assert!( - record_for_pid(Some(&home), 7, cwd, spawned).is_none(), + record_for_pid(7, cwd, spawned, Some(&home)).is_none(), "{kind}" ); } @@ -519,7 +519,7 @@ mod tests { &record(7, id, "/w", LIVE_STARTED, "interactive", ""), ); assert!( - record_for_pid(Some(&home), 7, cwd, spawned).is_none(), + record_for_pid(7, cwd, spawned, Some(&home)).is_none(), "{id:?}" ); } @@ -529,7 +529,7 @@ mod tests { 7, &format!(r#"{{"pid":7,"sessionId":"{ID}","cwd":"/w","startedAt":{LIVE_STARTED}}}"#), ); - assert!(record_for_pid(Some(&home), 7, cwd, spawned).is_none()); + assert!(record_for_pid(7, cwd, spawned, Some(&home)).is_none()); } /// The CLI rewrites the record in place rather than renaming a temporary, @@ -543,14 +543,14 @@ mod tests { for body in [&LIVE_RECORD[..LIVE_RECORD.len() / 2], "", "\0"] { install_record(&home, LIVE_PID as i32, body); assert!( - record_for_pid(Some(&home), LIVE_PID, cwd, spawned).is_none(), + record_for_pid(LIVE_PID, cwd, spawned, Some(&home)).is_none(), "{body:?}" ); } // No record for this pid, and no store at all. - assert!(record_for_pid(Some(&home), 1, cwd, spawned).is_none()); + assert!(record_for_pid(1, cwd, spawned, Some(&home)).is_none()); let bare = temp("claude_registry_bare"); - assert!(record_for_pid(Some(&bare), LIVE_PID, cwd, spawned).is_none()); + assert!(record_for_pid(LIVE_PID, cwd, spawned, Some(&bare)).is_none()); } /// Only `waiting` is read, so a status absent or from a vocabulary this @@ -566,7 +566,7 @@ mod tests { 7, &record(7, ID, "/w", LIVE_STARTED, "interactive", tail), ); - let rec = record_for_pid(Some(&home), 7, cwd, spawned).expect("the record must parse"); + let rec = record_for_pid(7, cwd, spawned, Some(&home)).expect("the record must parse"); assert_eq!(rec.id, ID, "{tail:?}"); assert!(!rec.waiting, "{tail:?}"); } @@ -633,6 +633,15 @@ mod tests { let home = temp("claude_blocked_states"); let cwd = Path::new("/w"); let spawned = at_ms(LIVE_STARTED); + let probe = |tail: &str| { + install_record( + &home, + 7, + &record(7, ID, "/w", LIVE_STARTED, "interactive", tail), + ); + Claude.live_blocked_status(7, cwd, spawned, Some(&home)) + }; + for tail in [ r#","status":"busy""#, r#","status":"shell""#, @@ -642,16 +651,7 @@ mod tests { // A reason without the status it belongs to is not a claim. r#","waitingFor":"permission prompt""#, ] { - install_record( - &home, - 7, - &record(7, ID, "/w", LIVE_STARTED, "interactive", tail), - ); - assert_eq!( - Claude.live_blocked_status(7, cwd, spawned, Some(&home)), - None, - "{tail:?}" - ); + assert_eq!(probe(tail), None, "{tail:?}"); } // No record for this pid at all. assert_eq!( diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs index c448dbf..9af3336 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -73,6 +73,24 @@ fn install_script(bin: &Path, name: &str, body: &str) { write_executable(&bin.join(name), body); } +/// File the registry record `pid` publishes, carrying the raw `status` JSON +/// pair. Every field `record_for_pid` validates has to agree with the task: +/// the file name and `pid`, the `cwd`, and a process start inside the +/// correlation window of the spawn. +fn install_status_record(home: &Path, pid: u32, cwd: &Path, status: &str) { + let sessions = home.join("sessions"); + std::fs::create_dir_all(&sessions).unwrap(); + std::fs::write( + sessions.join(format!("{pid}.json")), + format!( + r#"{{"pid":{pid},"sessionId":"{CAP_ID}","cwd":"{cwd}","startedAt":{started},"kind":"interactive",{status}}}"#, + cwd = cwd.display(), + started = now_ms() + ), + ) + .unwrap(); +} + /// Save a recipe and return its persisted JSON. fn save_and_read(s: &mut Supervisor, config: &Path, name: &str) -> String { s.apply(Command::SaveSession { name: name.into() }); @@ -955,17 +973,7 @@ fn resume_id_precedence_registry_over_spawn_under_capture() { // the registry record. let pid = s.tasks[0].pid().expect("a live task has a pid"); - let sessions = claude_home.join("sessions"); - std::fs::create_dir_all(&sessions).unwrap(); - std::fs::write( - sessions.join(format!("{pid}.json")), - format!( - r#"{{"pid":{pid},"sessionId":"{CAP_ID}","cwd":"{cwd}","startedAt":{started},"kind":"interactive","status":"idle"}}"#, - cwd = dir.display(), - started = now_ms() - ), - ) - .unwrap(); + install_status_record(&claude_home, pid, &dir, r#""status":"idle""#); let text = save_and_read(&mut s, &config, "registry"); assert!( text.contains(&format!("claude --resume '{CAP_ID}'")), @@ -1445,24 +1453,6 @@ fn recovery_cadence_rewrites_on_capture_drift_and_skips_when_static() { // --- live registry blocked status -------------------------------------- -/// File the registry record `pid` publishes, carrying the raw `status` JSON -/// pair. Every field `record_for_pid` validates has to agree with the task: -/// the file name and `pid`, the `cwd`, and a process start inside the -/// correlation window of the spawn. -fn install_status_record(home: &Path, pid: u32, cwd: &Path, status: &str) { - let sessions = home.join("sessions"); - std::fs::create_dir_all(&sessions).unwrap(); - std::fs::write( - sessions.join(format!("{pid}.json")), - format!( - r#"{{"pid":{pid},"sessionId":"{CAP_ID}","cwd":"{cwd}","startedAt":{started},"kind":"interactive",{status}}}"#, - cwd = cwd.display(), - started = now_ms() - ), - ) - .unwrap(); -} - /// Tick until the sole task's emitted preview satisfies `pred`, then return /// the last preview seen. Only a tick resolves a preview and only a /// resolution probes the registry, so the 250 ms probe throttle expires on diff --git a/src/task.rs b/src/task.rs index 9e98afa..6782feb 100644 --- a/src/task.rs +++ b/src/task.rs @@ -375,13 +375,10 @@ impl Task { }) } - /// The session leader's PID. `sh`, `bash`, `zsh`, and `dash` each exec a - /// single simple `-c` command in place rather than forking, so for an - /// accepted agent command this is the agent process itself: the pid its - /// live session registry is keyed by. That exec is a shell optimization, - /// not a guarantee — under a `$SHELL` that forks and waits, the leader is - /// the shell and the registry lookups find nothing rather than the wrong - /// thing. + /// The session leader's PID, which for an accepted agent command is the + /// agent process itself: the pid its live session registry is keyed by. + /// [`crate::harness::Harness::live_session_id`] carries the exec-in-place + /// mechanism that makes that true and the `$SHELL` shape that breaks it. pub fn pid(&self) -> Option { self.pid } From f369f6ecb8613ef125ffa85eb3a001c652f66d41 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sun, 16 Aug 2026 13:29:14 -0700 Subject: [PATCH 7/8] refactor(harness): localize unique_in_window and correct docs the registry tier outdated --- docs/README.md | 2 +- docs/how-it-works.md | 2 +- src/harness/claude.rs | 35 ++++++++++++++++++++++++++++++++++- src/harness/mod.rs | 34 +++------------------------------- src/harness/summary_tests.rs | 2 +- src/protocol.rs | 9 ++++++--- 6 files changed, 46 insertions(+), 38 deletions(-) diff --git a/docs/README.md b/docs/README.md index 8f3abe1..001feae 100644 --- a/docs/README.md +++ b/docs/README.md @@ -169,7 +169,7 @@ Recipes persist full command lines, which can embed secrets. A token passed as a ### Captured IDs cross a shell boundary -Agent resume writes a captured conversation ID into a command run through `$SHELL -c`, so validation is a security boundary. Accepted IDs contain only lowercase hexadecimal in the `8-4-4-4-12` UUID shape. Hook payloads, terminal scrapes, filesystem correlation, and the command builder all apply that check. Instrumentation applies only to a bare program word or its canonical resume form, never arbitrary shell text. [Agent session resume](agent-resume.md#validation-boundary) documents both boundaries. +Agent resume writes a captured conversation ID into a command run through `$SHELL -c`, so validation is a security boundary. Accepted IDs contain only lowercase hexadecimal in the `8-4-4-4-12` UUID shape. Hook payloads, terminal scrapes, live session records, filesystem correlation, and the command builder all apply that check. Instrumentation applies only to a bare program word or its canonical resume form, never arbitrary shell text. [Agent session resume](agent-resume.md#validation-boundary) documents both boundaries. ### Copied text leaves through the terminal diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 985d318..76ecbdb 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -10,4 +10,4 @@ Attached input follows the terminal modes reported by the child. Modified Enter ## Grouping follows one activity window -The dashboard groups tasks by state (In use / Running / Idle / Completed), working directory, or names assigned with `g`. One 10-second window drives both idle signals: after 10 s without output, the row glyph changes from `✻` to `∙` and, under state grouping, the task moves to the Idle section in the same refresh. Idle state does not affect row order within directory or custom sections. A tool such as `top`, which prints every 1–2 s, never crosses the window, so it stays `✻` under Running. Preview text is independent: a status from a weaker source must persist for 600 ms before it replaces a stronger one, which absorbs repaint flicker without affecting grouping. The strongest source is not the screen at all. When a supported agent CLI publishes a live session record saying it is blocked on the user — `claude` does, and only that state is read — the preview takes the CLI's own word for it, above anything scraped from the terminal: the record changes before the dialog finishes painting and says the same thing at every terminal width. +The dashboard groups tasks by state (In use / Running / Idle / Completed), working directory, or names assigned with `g`. One 10-second window drives both idle signals: after 10 s without output, the row glyph changes from `✻` to `∙` and, under state grouping, the task moves to the Idle section in the same refresh. Idle state does not affect row order within directory or custom sections. A tool such as `top`, which prints every 1–2 s, never crosses the window, so it stays `✻` under Running. Preview text is independent: a status from a weaker source must persist for 600 ms before it replaces a stronger one, which absorbs repaint flicker without affecting grouping. The top tier is not read from the screen alone. When a supported agent CLI publishes a live session record saying it is blocked on the user — `claude` does, and only that state is read — the preview takes the CLI's own word for it: the record changes before the dialog finishes painting and says the same thing at every terminal width. That record is consulted before the terminal scrape rather than ranking above it, so both land on the same tier, and handing back to a screen-derived status renders at once instead of waiting out the 600 ms above. diff --git a/src/harness/claude.rs b/src/harness/claude.rs index 4a67db1..b665df2 100644 --- a/src/harness/claude.rs +++ b/src/harness/claude.rs @@ -13,7 +13,7 @@ use std::{ use super::{ CAPTURE_ENV, CapturePaths, Harness, Invocation, SpawnPlan, is_uuid, last_hint, pin_plan, - shell_quote, unique_in_window, within_window_ms, + shell_quote, within_window, within_window_ms, }; pub struct Claude; @@ -202,6 +202,39 @@ fn record_for_pid( (rec.pid == pid && same_cwd && within_window_ms(rec.started_at, spawned_ms)).then_some(rec) } +/// Return the sole `candidate` in `dir` created within [`super::CORRELATE_WINDOW`] +/// of `spawned`. `candidate` names an entry or skips it; entries without +/// creation times cannot be correlated by window and are skipped too. Several +/// in-window candidates cannot be told apart, and a stray non-uuid candidate +/// still counts against uniqueness: both return `None`. +/// +/// Local to this harness because creation time is the only correlator claude's +/// transcript store offers: every other harness reads an instant the tool +/// recorded itself, out of a rollout header or a v7 UUID. +fn unique_in_window( + dir: PathBuf, + spawned: SystemTime, + candidate: impl Fn(&fs::DirEntry) -> Option, +) -> Option { + let mut candidates: Vec = Vec::new(); + for entry in fs::read_dir(dir).ok()?.flatten() { + let Some(name) = candidate(&entry) else { + continue; + }; + let Ok(created) = entry.metadata().and_then(|m| m.created()) else { + continue; + }; + if !within_window(created, spawned) { + continue; + } + candidates.push(name); + } + match candidates.as_slice() { + [only] if is_uuid(only) => Some(only.clone()), + _ => None, + } +} + /// Convert an absolute working directory to Claude's project slug by replacing /// `/` and `.` with `-` (`/a/b.c` becomes `-a-b-c`). Non-UTF-8 paths have no /// representable slug. diff --git a/src/harness/mod.rs b/src/harness/mod.rs index fd274eb..06f75be 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -24,7 +24,7 @@ pub mod summary; use std::{ ffi::OsString, - fs::{self, File}, + fs::File, io::Read, path::{Path, PathBuf}, time::{Duration, SystemTime}, @@ -355,40 +355,12 @@ fn within_window(a: SystemTime, b: SystemTime) -> bool { } } -/// Millisecond form of [`within_window`] for UUID-embedded timestamps. +/// Millisecond form of [`within_window`] for epoch-millisecond timestamps, +/// whether a UUID embeds them or a session record states them outright. fn within_window_ms(a: u128, b: u128) -> bool { a.abs_diff(b) <= CORRELATE_WINDOW.as_millis() } -/// Return the sole `candidate` in `dir` created within [`CORRELATE_WINDOW`] -/// of `spawned`. `candidate` names an entry or skips it; entries without -/// creation times cannot be correlated by window and are skipped too. Several -/// in-window candidates cannot be told apart, and a stray non-uuid candidate -/// still counts against uniqueness: both return `None`. -fn unique_in_window( - dir: PathBuf, - spawned: SystemTime, - candidate: impl Fn(&fs::DirEntry) -> Option, -) -> Option { - let mut candidates: Vec = Vec::new(); - for entry in fs::read_dir(dir).ok()?.flatten() { - let Some(name) = candidate(&entry) else { - continue; - }; - let Ok(created) = entry.metadata().and_then(|m| m.created()) else { - continue; - }; - if !within_window(created, spawned) { - continue; - } - candidates.push(name); - } - match candidates.as_slice() { - [only] if is_uuid(only) => Some(only.clone()), - _ => None, - } -} - /// Single-quote `s` for `$SHELL -c`, encoding embedded `'` as `'\''`. fn shell_quote(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index 14343b6..3352740 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -319,7 +319,7 @@ fn claude_title_frames_canonicalize_to_constant_text() { /// registry reports `waiting` about a second before the dialog finishes /// painting, so the two disagree exactly while that repaint is in flight. #[test] -fn a_registry_anchor_outranks_the_claude_spinner() { +fn registry_anchor_outranks_the_claude_spinner() { let rule = "─".repeat(60); let screen = [ "╭─── Claude Code v2.1.233 ────────────╮", diff --git a/src/protocol.rs b/src/protocol.rs index 0a04227..2d6c99f 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -276,7 +276,9 @@ pub enum PreviewSource { Marker, /// The child's window title, honored only on the alternate screen. Title, - /// Normalized adapter output: the cascade's top tier. + /// The cascade's top tier: a summary adapter's normalized reading of the + /// child's chrome, or a harness's reading of the state the tool publishes + /// about itself off-screen. Anchor, } @@ -297,8 +299,9 @@ impl PreviewSource { pub struct Preview { pub text: String, pub source: PreviewSource, - /// Summary-adapter matcher ID for an Anchor preview; `None` for other - /// sources. Never encoded, so a wire-decoded view always carries `None`. + /// Matcher ID naming what produced an Anchor preview, from either the + /// summary adapter or the harness; `None` for other sources. Never + /// encoded, so a wire-decoded view always carries `None`. pub rule: Option<&'static str>, /// Whether the preview froze at output-complete and can no longer change. pub frozen: bool, From 8dee747f83d3987b60cadda28a2b5327f744f941 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Sun, 16 Aug 2026 14:29:31 -0700 Subject: [PATCH 8/8] refactor: streamline session handling and improve documentation --- docs/agent-resume.md | 10 +- docs/commands.md | 2 +- docs/how-it-works.md | 2 +- src/harness/claude.rs | 172 ++++++++++---------------------- src/harness/mod.rs | 45 ++------- src/harness/summary.rs | 25 +---- src/harness/summary_tests.rs | 32 ++---- src/preview.rs | 44 +++----- src/protocol.rs | 9 +- src/supervisor.rs | 11 +- src/supervisor_capture_tests.rs | 43 +++----- src/task.rs | 39 +++----- 12 files changed, 125 insertions(+), 309 deletions(-) diff --git a/docs/agent-resume.md b/docs/agent-resume.md index 6730003..dd614d1 100644 --- a/docs/agent-resume.md +++ b/docs/agent-resume.md @@ -11,7 +11,7 @@ Start a supported agent without flags: 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. -On a finished agent task, `r` uses the captured launch, hook, notifier, registry, or exit ID without performing save-time filesystem correlation. The registry earns its place at save (`w`) rather than rerun: it names the conversation a *live* session is running even when the `SessionStart` hook never fired, as with hooks disabled. A rerun reads it only after an unclean exit, since a clean exit removes the record. The replacement keeps the task's ID, tag, group, and name. After a successful rewrite, the row shows the resume command because it has become the task's launch recipe; a [saved session](sessions.md) records the same string. +On a finished agent task, `r` uses the captured launch, hook, notifier, registry, or exit ID without performing save-time filesystem correlation. A registry record remains eligible after exit if it is still present. The replacement keeps the task's ID, tag, group, and name. After a successful rewrite, the row shows the resume command because it has become the task's launch recipe; a [saved session](sessions.md) records the same string. Capture is best-effort and narrow by design. A command carrying a prompt, extra flags, or shell syntax stays opaque and saves verbatim. An accepted command with no available ID also saves unchanged. In both cases, loading the recipe reruns the original command. @@ -52,9 +52,9 @@ A bare Claude command can accept an ID at launch. `fleetcom` therefore generates A canonical resume command already supplies its conversation ID, so adding a second ID would be incorrect; it receives only `--settings`. The overlay installs a `SessionStart` hook that copies its JSON payload into `FLEETCOM_CAPTURE_FILE`, from which the harness reads `session_id`. -Claude also publishes a live session registry: one `/sessions/.json` record per session, written and rewritten by the CLI itself with no instrumentation. `sh`, `bash`, `zsh`, and `dash` each exec a single simple `-c` command in place rather than forking, so a task's own PID names its record and the lookup is a direct path, not a search. That exec is a shell optimization, not a guarantee: under a `$SHELL` that forks and waits instead, the task's leader is the shell, no record is filed under its PID, and the registry simply goes unused rather than wrong. +Claude also publishes one `/sessions/.json` record per session. `fleetcom` reads the direct path for the task leader's PID. When `$SHELL -c` leaves the shell as the task leader instead of replacing it with Claude, no matching record exists and the registry contributes no ID. -A record counts only when its `kind` is `interactive` and its `pid`, `cwd`, and `startedAt` all match the task. A live task's PID cannot be reissued — `fleetcom` reaps with `WNOWAIT`, leaving the exited leader a zombie that holds the PID for the task's whole life — so the file is that task's own record or nothing. The `cwd` and `startedAt` guards close what the reservation cannot: the CLI removes its record on a clean exit but leaves it behind when the process dies on a signal, and only the next `claude` launch sweeps it, so a record from an *earlier* process at that PID can otherwise be read as this task's. `cwd` matches through symlink aliases, because claude records the `getcwd(3)` form while a task carries the path it was spawned with. `startedAt` names the process start, which `/clear` leaves untouched, so the 30-second match does not decay as a session ages. `/cd` inside claude moves the session's directory and loses the record. The CLI rewrites the file in place rather than renaming a temporary, so a torn read yields no evidence rather than bad evidence. The same record also carries the session's live status, from which the dashboard reads one state — `waiting`, the CLI blocked on the user — as the top tier of its [preview cascade](commands.md#peek). +A record counts only when its `kind` is `interactive` and its `pid`, `cwd`, and `startedAt` match the task. The PID must match the filename, the working directories must be identical or resolve to the same path, and the process start must fall within 30 seconds of the task spawn. Missing, malformed, or mismatched records contribute no evidence. The dashboard also maps a matching record's `waiting` status to the top tier of its [preview cascade](commands.md#peek); other statuses do not affect the preview. After the process exits and the PTY reader reaches EOF, the harness scans the retained terminal text for the last `claude --resume ` hint. Save-time filesystem correlation checks `/projects//.jsonl`, where the slug replaces `/` and `.` in the absolute working directory with `-`. @@ -84,11 +84,11 @@ Several channels can identify different conversations during one task. To make t 1. The exit hint scraped after process exit and PTY-reader EOF. 2. The current capture-file payload. -3. The live session registry, currently `claude` only. +3. The live session registry, implemented by `claude`. 4. The ID pinned or targeted at spawn. 5. Save-time filesystem correlation, when exactly one store entry matches the task and the 30-second spawn window. -The registry outranks the spawn pin because the pin records what `fleetcom` asked for while the registry records what the CLI is running, and those diverge the moment a user runs `/clear`, which mints a fresh ID mid-session. It ranks below the capture file only because that file is `fleetcom`'s own hook output, and the two agree whenever both exist. +The registry outranks the spawn pin because it can contain a session ID selected after launch, including one created by `/clear`. The capture file outranks the registry. Saving and rerunning rewrite accepted commands to one of these forms: diff --git a/docs/commands.md b/docs/commands.md index 25475a0..0131cee 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -202,7 +202,7 @@ The box uses a two-column layout. When height is limited, group headers drop fir A centered box over the dashboard showing the selected task's live screen (the last screenful). `↑`/`↓` (or `k`/`j`) switch which task you're peeking at; `Enter` attaches to it; `r` reruns it if it has finished; `Space`, `Esc`, or `q` closes. -The footer's `preview:` segment names the source of the row's dashboard preview: `floor` (the last non-blank row of the live screen), `marker` (a full-screen program with no usable title), `title` (the child's window title), or `anchor/` (a recognized agent status, tagged with the matcher that produced it). Most rules name a screen matcher, such as `claude:spinner` or `codex:approval-menu`. The `claude:registry-approval` and `claude:registry-waiting` rules name the other kind: the status came from the CLI's own live session record on disk, which reports a session blocked on the user without waiting for its dialog to paint. +The footer's `preview:` segment names the source of the row's dashboard preview: `floor` (the last non-blank row of the live screen), `marker` (a full-screen program with no usable title), `title` (the child's window title), or `anchor/` (a recognized agent status, tagged with the matcher that produced it). Most rules name a screen matcher, such as `claude:spinner` or `codex:approval-menu`. The `claude:registry-approval` and `claude:registry-waiting` rules instead come from Claude's on-disk session status. ## Attached diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 76ecbdb..2a59a52 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -10,4 +10,4 @@ Attached input follows the terminal modes reported by the child. Modified Enter ## Grouping follows one activity window -The dashboard groups tasks by state (In use / Running / Idle / Completed), working directory, or names assigned with `g`. One 10-second window drives both idle signals: after 10 s without output, the row glyph changes from `✻` to `∙` and, under state grouping, the task moves to the Idle section in the same refresh. Idle state does not affect row order within directory or custom sections. A tool such as `top`, which prints every 1–2 s, never crosses the window, so it stays `✻` under Running. Preview text is independent: a status from a weaker source must persist for 600 ms before it replaces a stronger one, which absorbs repaint flicker without affecting grouping. The top tier is not read from the screen alone. When a supported agent CLI publishes a live session record saying it is blocked on the user — `claude` does, and only that state is read — the preview takes the CLI's own word for it: the record changes before the dialog finishes painting and says the same thing at every terminal width. That record is consulted before the terminal scrape rather than ranking above it, so both land on the same tier, and handing back to a screen-derived status renders at once instead of waiting out the 600 ms above. +The dashboard groups tasks by state (In use / Running / Idle / Completed), working directory, or names assigned with `g`. One 10-second window drives both idle signals: after 10 s without output, the row glyph changes from `✻` to `∙` and, under state grouping, the task moves to the Idle section in the same refresh. Idle state does not affect row order within directory or custom sections. A tool such as `top`, which prints every 1–2 s, never crosses the window, so it stays `✻` under Running. Preview text is independent: a status from a weaker source must persist for 600 ms before it replaces a stronger one, which absorbs repaint flicker without affecting grouping. Claude's on-disk `waiting` status and screen-derived agent statuses both produce the top-tier `anchor` source. The on-disk status wins when both are present; returning to the screen-derived status does not cross a tier and therefore renders immediately. diff --git a/src/harness/claude.rs b/src/harness/claude.rs index b665df2..c30e07b 100644 --- a/src/harness/claude.rs +++ b/src/harness/claude.rs @@ -1,9 +1,8 @@ -//! Claude exposes four useful session signals: a launch-time `--session-id`, a -//! `SessionStart` hook, a live session registry, and an exit-time resume hint. -//! Bare launches pin a v4 UUID; every accepted launch receives the hook through -//! `--settings`. The CLI itself publishes one `/sessions/.json` -//! record per live session, with no instrumentation. The filesystem fallback -//! correlates `/projects//.jsonl` transcripts. +//! Claude session capture uses a launch-time `--session-id`, a `SessionStart` +//! hook, the live session registry, and the exit-time resume hint. Bare launches +//! pin a v4 UUID; accepted launches install the hook through `--settings`. +//! Live lookup reads `/sessions/.json`; fallback correlation +//! reads project transcripts. use std::{ fs, @@ -78,10 +77,7 @@ impl Harness for Claude { home: Option<&Path>, ) -> Option<(String, &'static str)> { let rec = record_for_pid(pid, cwd, spawned, home)?; - // The waiting state alone: see the trait doc. The registry beats the - // screen to this one state by about a second and reports it at any - // terminal width and for every dialog shape, including the ones - // `ClaudeSummary`'s `❯ 1. `/`2. ` selector match does not cover. + // Only `waiting` overrides the screen-derived preview. rec.waiting .then(|| waiting_preview(rec.waiting_for.as_deref())) } @@ -99,38 +95,24 @@ impl Harness for Claude { } } -/// One record from the live session registry. The CLI writes it on launch and -/// rewrites it in place as the session changes; it removes it on a clean exit -/// but leaves it behind when the process dies on a signal, so a record on disk -/// is a claim about a pid, not proof of a live session. +/// Validated fields used to correlate a registry record with a task and render +/// its blocked status. struct SessionRecord { - /// `sessionId`, already through [`is_uuid`]. + /// `sessionId`, validated by [`is_uuid`]. id: String, - /// `pid`, which also names the record's file. pid: i32, - /// `cwd` the session runs in. cwd: PathBuf, - /// `startedAt`: the process's start in epoch milliseconds. `procStart` - /// names the same instant in human-readable form. + /// `startedAt`, in epoch milliseconds. started_at: u128, - /// `status` reading `waiting`: the CLI blocked on the user. That is the - /// only value any caller acts on, so the rest of the vocabulary, a value - /// this reader predates, and the absent field non-interactive entrypoints - /// write all collapse to `false` without invalidating the record. + /// Whether `status` is `waiting`. waiting: bool, - /// `waitingFor`: why a waiting session waits. Present only while the CLI - /// holds a dialog open. + /// Optional `waitingFor` text. waiting_for: Option, } -/// Preview text and matcher ID for a `waiting` record's `waitingFor` reason. -/// The CLI's dialog-label map spells five reasons: `permission prompt` (its -/// default for any dialog), `input needed`, `dialog open`, `sandbox request`, -/// and `worker request`. Only the first is rewritten, to the string -/// `ClaudeSummary::claude_approval` already synthesizes for the same -/// condition; the rest are claude's own words and are kept verbatim, as is any -/// reason a later version adds. A record that reports `waiting` without a -/// reason still names a user-blocking state, so it renders as one. +/// Map a `waitingFor` reason to preview text and its matcher ID. Permission +/// prompts use the same text as the screen matcher; other non-empty reasons +/// remain verbatim. A missing reason falls back to `awaiting input`. fn waiting_preview(reason: Option<&str>) -> (String, &'static str) { match reason.filter(|r| !r.is_empty()) { Some("permission prompt") => ("awaiting approval".to_string(), "claude:registry-approval"), @@ -139,11 +121,9 @@ fn waiting_preview(reason: Option<&str>) -> (String, &'static str) { } } -/// Parse one registry record. The CLI rewrites the file in place with a plain -/// write rather than a temp-and-rename, so a reader can catch it truncated: -/// unparseable text yields `None` and the caller simply has no evidence this -/// time. `bg`, `daemon`, and `daemon-worker` records name conversations no user -/// is driving, so only `interactive` survives. +/// Parse one interactive registry record. Malformed records and other `kind` +/// values return `None`; a missing or unrecognized status remains valid but +/// does not set `waiting`. fn parse_record(text: &str) -> Option { let v = jzon::parse(text).ok()?; if v["kind"].as_str()? != "interactive" { @@ -160,28 +140,10 @@ fn parse_record(text: &str) -> Option { }) } -/// Read the record `pid` publishes, requiring it to name that pid, that `cwd`, -/// and a process started within [`super::CORRELATE_WINDOW`] of `spawned`. -/// -/// A live task's pid cannot be reissued to a foreign `claude`: -/// [`crate::task::Task::poll_exit`] reaps with `WNOWAIT` and leaves the exited -/// leader a zombie, which holds the pid for the task's whole life. So -/// `sessions/.json` is this task's own record or nothing — that, not the -/// field checks, is what keeps a stranger out. -/// -/// The `cwd` and `startedAt` guards close what the reservation cannot: a record -/// an *earlier* process at that pid left behind, before this task existed. The -/// CLI removes its record on a clean exit, but a signal-killed `claude` leaves -/// it and only the next `claude` launch sweeps it. `cwd` separates two -/// directories; `startedAt` separates two processes in one directory. Both cost -/// less than the read that produced the record and sit at a shell-command -/// boundary, so they stay. That window does not decay with session age, because -/// `startedAt` records the process start: `/clear` mints a fresh `sessionId` in -/// place and leaves `startedAt` untouched, so a session running for hours still -/// matches its original spawn instant. -/// -/// Call-site details: `/cd` inside claude moves the session's `cwd` and fails -/// this check, which loses the record. Failing closed there is deliberate. +/// Read `sessions/.json` and require its PID, working directory, and +/// process start to match the task. The unreaped task leader reserves its PID; +/// the directory and start-time checks reject stale records already present at +/// that path. A mismatch returns `None` because the ID may enter a shell command. fn record_for_pid( pid: u32, cwd: &Path, @@ -193,24 +155,15 @@ fn record_for_pid( let text = fs::read_to_string(dir.join(format!("{pid}.json"))).ok()?; let rec = parse_record(&text)?; let spawned_ms = spawned.duration_since(UNIX_EPOCH).ok()?.as_millis(); - // Same path through two aliases is one directory: claude records - // `process.cwd()`, which is `getcwd(3)` and so symlink-resolved, while a - // task carries the path it was spawned with. `canonicalize` is IO and fails - // on a vanished directory, which leaves the verbatim comparison standing — - // a cwd matching neither form is still refused. + // Accept the task's literal path or its canonical form; reject a failed + // canonicalization unless the literal paths already match. let same_cwd = rec.cwd == cwd || cwd.canonicalize().is_ok_and(|c| rec.cwd == c); (rec.pid == pid && same_cwd && within_window_ms(rec.started_at, spawned_ms)).then_some(rec) } -/// Return the sole `candidate` in `dir` created within [`super::CORRELATE_WINDOW`] -/// of `spawned`. `candidate` names an entry or skips it; entries without -/// creation times cannot be correlated by window and are skipped too. Several -/// in-window candidates cannot be told apart, and a stray non-uuid candidate -/// still counts against uniqueness: both return `None`. -/// -/// Local to this harness because creation time is the only correlator claude's -/// transcript store offers: every other harness reads an instant the tool -/// recorded itself, out of a rollout header or a v7 UUID. +/// Return the sole candidate created within [`super::CORRELATE_WINDOW`] of +/// `spawned`. Missing creation times, multiple candidates, and a sole invalid +/// UUID return `None`. fn unique_in_window( dir: PathBuf, spawned: SystemTime, @@ -257,9 +210,7 @@ mod tests { testutil::temp, }; - /// One record a live `claude` 2.1.233 published. Field order and spelling - /// are as written; its `sessionId` is [`OTHER`], and the middle of `cwd` is - /// elided, which the reader never inspects. + /// Complete registry fixture with [`OTHER`] as its session ID. const LIVE_RECORD: &str = concat!( r#"{"pid":83849,"sessionId":"11111111-2222-4333-8444-555555555555","#, r#""cwd":"/private/tmp/.../scratchpad/live-claude","startedAt":1786834960302,"#, @@ -269,27 +220,26 @@ mod tests { r#""name":"live-claude-66","nameSource":"derived","nameSince":1786834960303,"#, r#""status":"idle","updatedAt":1786834960352,"statusUpdatedAt":1786834960352}"#, ); - /// The pid, directory, and process start [`LIVE_RECORD`] names. + /// Identity fields in [`LIVE_RECORD`]. const LIVE_PID: u32 = 83849; const LIVE_CWD: &str = "/private/tmp/.../scratchpad/live-claude"; const LIVE_STARTED: u64 = 1_786_834_960_302; - /// A registry record carrying every field the reader validates. `tail` - /// appends raw JSON for the optional status pair. + /// Build a registry record with optional raw JSON fields in `tail`. fn record(pid: i32, id: &str, cwd: &str, started: u64, kind: &str, tail: &str) -> String { format!( r#"{{"pid":{pid},"sessionId":"{id}","cwd":"{cwd}","startedAt":{started},"version":"2.1.233","kind":"{kind}","entrypoint":"cli"{tail}}}"# ) } - /// File `body` as the registry record for `pid`, creating the store. + /// Write `body` to the registry path for `pid`. fn install_record(home: &Path, pid: i32, body: &str) { let dir = home.join("sessions"); fs::create_dir_all(&dir).unwrap(); fs::write(dir.join(format!("{pid}.json")), body).unwrap(); } - /// The instant `ms` epoch milliseconds names. + /// Convert epoch milliseconds to [`SystemTime`]. fn at_ms(ms: u64) -> SystemTime { UNIX_EPOCH + std::time::Duration::from_millis(ms) } @@ -422,8 +372,7 @@ mod tests { ); } - /// The record a live session published parses whole, and the harness - /// surfaces its ID through the trait. + /// A complete matching record exposes its validated session ID. #[test] fn record_for_pid_reads_a_live_record() { let home = temp("claude_registry"); @@ -442,8 +391,7 @@ mod tests { ); } - /// The record must claim the pid whose file it sits in and the directory - /// the task runs in. + /// The record must match its filename PID and the task directory. #[test] fn record_for_pid_requires_the_records_own_pid_and_cwd() { let home = temp("claude_registry_ident"); @@ -457,7 +405,7 @@ mod tests { ); assert!(record_for_pid(4242, cwd, spawned, Some(&home)).is_some()); - // A record filed under one pid while naming another is not this task's. + // The filename and embedded PID must agree. install_record( &home, 4242, @@ -473,9 +421,7 @@ mod tests { assert!(record_for_pid(4242, cwd, spawned, Some(&home)).is_none()); } - /// Claude records `process.cwd()`, which `getcwd(3)` already resolved - /// through every symlink; the task carries the path it was spawned with. - /// One directory reached two ways still matches. + /// Literal and canonical paths to the same directory both match. #[test] fn record_for_pid_accepts_a_symlinked_cwd_alias() { let tmp = temp("claude_registry_alias"); @@ -503,15 +449,12 @@ mod tests { let spawned = at_ms(LIVE_STARTED); assert!(record_for_pid(7, &link, spawned, Some(&home)).is_some()); - // A real directory that is not an alias of the record's is still - // refused, as is one that no longer exists to canonicalize. + // Different and nonexistent paths remain mismatches. assert!(record_for_pid(7, &other, spawned, Some(&home)).is_none()); assert!(record_for_pid(7, &tmp.join("gone"), spawned, Some(&home)).is_none()); } - /// A `claude` killed by a signal leaves its record behind until the next - /// launch sweeps it. A task later assigned that pid in the same directory - /// satisfies both identity guards, so the process start is what rejects it. + /// The process start rejects a stale record with a matching PID and CWD. #[test] fn record_for_pid_rejects_a_recycled_pids_stale_record() { let home = temp("claude_registry_recycled"); @@ -522,17 +465,15 @@ mod tests { &record(4242, ID, "/w", LIVE_STARTED, "interactive", ""), ); - // The same process: its start is inside the correlation window. + // The correlation window includes both endpoints. assert!(record_for_pid(4242, cwd, at_ms(LIVE_STARTED + 30_000), Some(&home)).is_some()); assert!(record_for_pid(4242, cwd, at_ms(LIVE_STARTED - 30_000), Some(&home)).is_some()); - // A later process under the recycled pid: minutes apart, or one - // millisecond outside the window. + // One millisecond outside the window is stale. assert!(record_for_pid(4242, cwd, at_ms(LIVE_STARTED + 30_001), Some(&home)).is_none()); assert!(record_for_pid(4242, cwd, at_ms(LIVE_STARTED + 600_000), Some(&home)).is_none()); } - /// Only an `interactive` record names a conversation a user is driving, - /// and only a strict UUID may leave the reader. + /// Only interactive records with strict UUIDs are eligible. #[test] fn record_for_pid_requires_an_interactive_kind_and_a_strict_id() { let home = temp("claude_registry_kind"); @@ -556,7 +497,7 @@ mod tests { "{id:?}" ); } - // A record missing `kind` is unclassifiable. + // `kind` is required. install_record( &home, 7, @@ -565,9 +506,7 @@ mod tests { assert!(record_for_pid(7, cwd, spawned, Some(&home)).is_none()); } - /// The CLI rewrites the record in place rather than renaming a temporary, - /// so a reader can catch it truncated. That, an absent record, and an - /// absent store all mean no evidence this time. + /// Malformed and absent records contribute no evidence. #[test] fn record_for_pid_tolerates_a_torn_file_and_a_missing_store() { let home = temp("claude_registry_torn"); @@ -580,14 +519,13 @@ mod tests { "{body:?}" ); } - // No record for this pid, and no store at all. + // Missing file and missing directory follow the same path. assert!(record_for_pid(1, cwd, spawned, Some(&home)).is_none()); let bare = temp("claude_registry_bare"); assert!(record_for_pid(LIVE_PID, cwd, spawned, Some(&bare)).is_none()); } - /// Only `waiting` is read, so a status absent or from a vocabulary this - /// reader predates leaves the record valid and its ID usable. + /// Status values other than `waiting` do not invalidate the session ID. #[test] fn record_for_pid_keeps_the_id_under_an_unread_status() { let home = temp("claude_registry_status"); @@ -605,12 +543,8 @@ mod tests { } } - /// The blocked-status probe speaks the `waitingFor` vocabulary the CLI's - /// own dialog-label map defines. `permission prompt` is its default for - /// any dialog and is the one value rewritten, to the string the screen - /// scraper synthesizes for the same condition; every other reason is - /// claude's wording and survives verbatim, a reason this reader predates - /// included. A `waiting` record with no reason still blocks the user. + /// Permission prompts use the approval label, other reasons remain + /// verbatim, and an absent reason falls back to `awaiting input`. #[test] fn live_blocked_status_maps_every_waiting_reason() { let home = temp("claude_blocked_reasons"); @@ -634,8 +568,6 @@ mod tests { "dialog open", "sandbox request", "worker request", - // Not in today's map: a later CLI version's wording is still - // claude's own and reads better than a synthesized stand-in. "quantum entanglement request", ] { assert_eq!( @@ -656,11 +588,7 @@ mod tests { } } - /// Only `waiting` answers. `busy` and `shell` resolve to a title carrying - /// claude's own per-turn summary, and `idle` to whatever the screen shows; - /// replacing either with the bare status word would lose information. An - /// absent status, an unreadable one, and a record that fails the identity - /// guards are all no evidence. + /// Only `waiting` produces a blocked-status preview. #[test] fn live_blocked_status_answers_for_waiting_alone() { let home = temp("claude_blocked_states"); @@ -681,12 +609,12 @@ mod tests { r#","status":"idle""#, r#","status":"hibernating""#, "", - // A reason without the status it belongs to is not a claim. + // A reason without `status: waiting` is not blocked. r#","waitingFor":"permission prompt""#, ] { assert_eq!(probe(tail), None, "{tail:?}"); } - // No record for this pid at all. + // A missing record contributes no status. assert_eq!( Claude.live_blocked_status(9, cwd, spawned, Some(&home)), None diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 06f75be..a6689e1 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -10,11 +10,9 @@ //! # Security invariant //! //! Every ID returned by `parse_capture`, `scrape_exit`, `live_session_id`, or -//! `correlate_fs` eventually enters a shell command. These methods must -//! therefore return only strings accepted by [`is_uuid`]. Free-text names, -//! paths, and malformed IDs yield `None`. Summary adapters are display-only and -//! do not return session IDs, and so is `live_blocked_status`: its text reaches -//! the dashboard, never a command. +//! `correlate_fs` eventually enters a shell command. These methods return only +//! strings accepted by [`is_uuid`]; free text, paths, and malformed IDs yield +//! `None`. Summary adapters and `live_blocked_status` are display-only. pub mod assets; mod claude; @@ -93,17 +91,9 @@ pub trait Harness: Sync { /// Extract a session ID from final terminal text, including scrollback. fn scrape_exit(&self, text: &str) -> Option; - /// Read the ID the tool is running right now from the live registry it - /// publishes on disk. `pid` is the task's session leader, which for every - /// accepted command shape is the tool's own process: `sh`, `bash`, `zsh`, - /// and `dash` each exec a single simple `-c` command in place rather than - /// forking, so `$$` names the tool. That exec is a shell optimization, not - /// a guarantee — under a `$SHELL` that forks and waits instead, the leader - /// is the shell, no record is filed under its pid, and this returns - /// `None`. The registry then goes unused rather than wrong. `cwd` and - /// `spawned` identify that process, since a registry record can outlive its - /// writer. Defaults to `None`: a tool that publishes no registry has - /// nothing to read. + /// Read the current session ID from the tool's on-disk registry. `pid`, + /// `cwd`, and `spawned` identify the task; implementations must reject a + /// record that does not match all three. Defaults to `None`. fn live_session_id( &self, _pid: u32, @@ -114,23 +104,9 @@ pub trait Harness: Sync { None } - /// Read the tool's own claim that it is blocked on the user, as preview - /// text and the matcher ID naming the claim: the same - /// `(text, rule)` shape [`crate::preview::SummaryAdapter::live_preview`] - /// returns, so the cascade treats a registry-derived anchor and a - /// screen-derived one alike. Parameters identify the live process exactly - /// as [`Harness::live_session_id`] does, its exec-in-place caveat included. - /// - /// Only a blocked state answers `Some`. A tool's working and idle states - /// already resolve to a title carrying the CLI's own per-turn summary - /// (claude's OSC title is model-generated text such as - /// `✻ Run sleep command for 25 seconds`), and replacing that with the bare - /// word `busy` or `idle` would remove information rather than add it. - /// Being blocked on the user is the one state the screen cascade cannot - /// see reliably. - /// - /// Defaults to `None`: a tool that publishes no live status has nothing to - /// read. + /// Read a matching registry record's blocked-on-user status as preview + /// text and a matcher ID. Return `None` for every non-blocked state and for + /// tools without a live status registry. fn live_blocked_status( &self, _pid: u32, @@ -355,8 +331,7 @@ fn within_window(a: SystemTime, b: SystemTime) -> bool { } } -/// Millisecond form of [`within_window`] for epoch-millisecond timestamps, -/// whether a UUID embeds them or a session record states them outright. +/// Epoch-millisecond form of [`within_window`]. fn within_window_ms(a: u128, b: u128) -> bool { a.abs_diff(b) <= CORRELATE_WINDOW.as_millis() } diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 7381e32..2be7d7e 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -124,11 +124,7 @@ impl SummaryAdapter for ClaudeSummary { fn normalize_title(&self, title: &str) -> Option { let mut chars = title.chars(); let frame = chars.next()?; - // An animation frame set is only neutralized when every member - // collapses to one rendered string; a frame left out reanimates the - // title. The quadrant circles are taken as the whole contiguous - // block for that reason: `◐` and `◑` are the observed pair, and the - // other two cost nothing to cover ahead of a four-phase cycle. + // Normalize the entire quadrant-circle block as one animation set. let framed = CLAUDE_SPINNER.contains(&frame) || ('\u{2800}'..='\u{28FF}').contains(&frame) || ('\u{25D0}'..='\u{25D3}').contains(&frame); @@ -274,10 +270,7 @@ fn claude_approval(rows: &[String]) -> Option<(String, &'static str)> { .then(|| ("awaiting approval".to_string(), "claude:approval-menu")) } -/// The levels claude documents for `--effort`. A truncated cell is only -/// trusted when its effort token is a complete member: `with hi…` is a cut -/// landing inside the word, and rendering `(hi)` would state an effort the -/// session is not running at. +/// Complete effort values accepted before a welcome-box ellipsis. const CLAUDE_EFFORT: &[&str] = &["low", "medium", "high", "xhigh", "max"]; /// `Fable 5 with high effort` from the welcome box → `Fable 5 (high)`. The @@ -304,17 +297,9 @@ fn claude_welcome_label(rows: &[String]) -> Option { None } -/// `Fable 5 with high effort` → `Fable 5 (high)`, and the same for the -/// spelling the CLI truncates itself: `Opus 5 (1M context) with high…`. The -/// welcome box's left pane is fixed near 50 columns whatever the terminal -/// width, so a model name that overruns the pane loses its trailing ` effort` -/// to the CLI's own ellipsis and no terminal is wide enough to bring it back. -/// The full spelling needs no vocabulary check — the trailing word proves the -/// token is whole — while the truncated one is refused unless the token is a -/// complete [`CLAUDE_EFFORT`] level. A model name carrying its own -/// parentheses reads as `Opus 5 (1M context) (high)`; the -/// `{model} ({effort})` contract is applied as written rather than -/// special-cased. +/// Normalize ` with effort` and its ellipsis form to +/// ` ()`. The ellipsis form requires a complete +/// [`CLAUDE_EFFORT`] value; a partial token returns `None`. fn claude_model_effort(head: &str) -> Option { let (model, effort) = match head.strip_suffix(" effort") { Some(full) => full.rsplit_once(" with ")?, diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index 3352740..1df184a 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -284,8 +284,7 @@ fn claude_title_frames_canonicalize_to_constant_text() { let b = ClaudeSummary.normalize_title("✽ Claude Code"); assert_eq!(a, b, "two frames must normalize identically"); - // Spinner and quadrant-circle frames animate the same title, so the whole - // vocabulary must land on one rendered string. + // Every spinner frame must normalize to the same title. let rendered: std::collections::BTreeSet> = CLAUDE_SPINNER .iter() .copied() @@ -313,11 +312,8 @@ fn claude_title_frames_canonicalize_to_constant_text() { assert_eq!(ClaudeSummary.normalize_title("✻"), None, "frame alone"); } -/// Cascade-level: the harness's blocked-status probe outranks the adapter's -/// screen scrape on the very screen the scrape would anchor on, keeps its own -/// rule, and takes the welcome box's model label like any other anchor. The -/// registry reports `waiting` about a second before the dialog finishes -/// painting, so the two disagree exactly while that repaint is in flight. +/// A registry status outranks a screen-derived status while retaining the +/// model label and its own matcher ID. #[test] fn registry_anchor_outranks_the_claude_spinner() { let rule = "─".repeat(60); @@ -388,8 +384,7 @@ fn title_tier_renders_the_normalized_title() { "no adapter: verbatim" ); - // The quadrant frames animate a title that carries the task summary, so - // the tier renders the summary once rather than alternating with it. + // Quadrant frames use the same canonical title as other spinner frames. let mut quadrant = Emulator::new(24, 80, 100); quadrant.process( b"\x1b[?1049h\x1b]0;\xe2\x97\x90 Run sleep command for 25 seconds\x07conversation body", @@ -544,11 +539,8 @@ fn claude_approval_requires_the_dialog_shape() { assert_eq!(ClaudeSummary.live_preview(&lone), None); } -/// The model label comes from the welcome box and reads as -/// `{model} ({effort})`. Both cell spellings are live: the box's left pane is -/// fixed near 50 columns, so a short model name keeps its trailing `effort` -/// and a long one loses it to the CLI's own ellipsis. A cut landing inside -/// the effort word refuses instead of guessing. No box, no label. +/// The welcome-box label accepts complete and ellipsis forms but rejects a +/// partial effort value. No box means no label. #[test] fn claude_label_reads_the_welcome_box() { let boxed = |cell: &str| { @@ -558,28 +550,22 @@ fn claude_label_reads_the_welcome_box() { "╰──────────────────────────────────────╯", ]) }; - // Verbatim from a live session, and byte-identical at 100 and 160 - // columns: the left pane is fixed near 50 columns, so a model name that - // overruns it truncates at every terminal width. let fixed_pane = "│ Opus 5 (1M context) with high… · Claude Max · │ Added opt-in memory cgroup support for Bas… │"; for (cell, want) in [ ( "│ Fable 5 with high effort · Claude Max · │ notes │", Some("Fable 5 (high)"), ), - // Parentheses in the model name double up under the - // `{model} ({effort})` contract. Deliberate: the contract is applied - // as written. + // Parentheses in the model name do not change the output shape. ( "│ Opus 5 (1M context) with high effort · Claude Max · │ notes │", Some("Opus 5 (1M context) (high)"), ), (fixed_pane, Some("Opus 5 (1M context) (high)")), - // `hi` is a cut through the effort word, not a level; `(hi)` would - // name an effort the session is not running at. + // Partial and empty effort values are invalid. ("│ Opus 5 (1M context) with hi… │ notes │", None), ("│ Opus 5 (1M context) with … │ notes │", None), - // Neither spelling: no trailing `effort`, no ellipsis. + // Neither accepted suffix is present. ("│ Some Model with high │ notes │", None), ] { assert_eq!( diff --git a/src/preview.rs b/src/preview.rs index f3d3375..e76eaf4 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -88,27 +88,23 @@ pub trait SummaryAdapter: Sync { } /// Resolve the instantaneous candidate in descending priority: -/// 1. harness registry: `blocked`, the CLI's own claim that it is blocked on -/// the user, as passed by the caller +/// 1. harness registry: the caller-provided blocked status /// 2. summary adapter: the normalized live status when the CLI's working /// structure is present /// 3. alternate screen: the title while its epoch is current, else the marker /// 4. primary screen: the live floor /// -/// Tiers 1 and 2 both produce an Anchor and are both `{model label} · `- -/// prefixed when the adapter reads a label from stable chrome. +/// Tiers 1 and 2 both produce an Anchor. The adapter's model label prefixes +/// either status when available. fn cascade( screen: &impl ScreenFacts, adapter: Option<&dyn SummaryAdapter>, blocked: Option<(&str, &'static str)>, ) -> Preview { if adapter.is_some() || blocked.is_some() { - // Both probes and the label read the same viewport snapshot. + // The screen status and model label share one viewport snapshot. let rows = screen.live_rows(); - // The registry claim is consulted first: what the CLI says about - // itself outranks a structural guess at its screen. It also lands - // about a second before the dialog finishes painting and holds at any - // terminal width, for every dialog shape the CLI draws. + // A registry status outranks a screen-derived status. let hit = blocked .map(|(text, rule)| (text.to_string(), rule)) .or_else(|| adapter?.live_preview(&rows)); @@ -160,12 +156,7 @@ fn cascade( }) } -/// State that invalidates the cached preview candidate: the screen facts the -/// cascade reads, plus the harness blocked-status probe. The probe belongs -/// here because it moves independently of the screen — a session enters and -/// leaves `waiting` with no repaint, and a repaint changes no status — so a -/// key built from screen facts alone would strand a probe result that appeared -/// or cleared while the grid stood still. +/// Screen and registry state that invalidates the cached preview candidate. type ResolveKey = ( u64, u64, @@ -229,9 +220,8 @@ impl PreviewState { /// parameter, never read internally, so tests drive the holds with /// synthetic instants. The candidate is recomputed only when the /// resolution key changed; hold expiries commit the carried value - /// without a rescan. `adapter` is the task's summary adapter, fixed for - /// the task's life, so it needs no slot in the resolution key; `blocked` - /// is the caller's latest harness blocked-status probe, which does. + /// without a rescan. `adapter` is fixed for the task's lifetime; `blocked` + /// changes independently and is part of the resolution key. pub fn resolve( &mut self, now: Instant, @@ -345,9 +335,7 @@ impl PreviewState { self.rendered.frozen = true; return; } - // No blocked probe: finalization runs once output is complete, and a - // record the exited process left behind claims a state it can no - // longer be in. + // Finalization excludes registry state because the process has exited. let mut fin = cascade(screen, adapter, None); fin.frozen = true; self.rendered = fin; @@ -503,8 +491,7 @@ mod tests { assert_eq!(p.text, "Working"); } - /// The harness probe is the top tier: it outranks the adapter's screen - /// anchor and takes the model label exactly as a screen anchor does. + /// A registry status outranks a screen status and retains the model label. #[test] fn a_registry_anchor_outranks_the_adapters_anchor() { let now = Instant::now(); @@ -534,10 +521,7 @@ mod tests { ); } - /// The probe belongs in the resolution key. A task can enter and leave the - /// blocked state with no repaint, so a key built from screen facts alone - /// carries the stale candidate and the probe never reaches the cascade: - /// both halves of this test fail without it. + /// Registry changes invalidate the candidate even when the screen is static. #[test] fn a_probe_that_changes_on_a_static_screen_reaches_the_cascade() { let t0 = Instant::now(); @@ -549,8 +533,7 @@ mod tests { "premise: no probe, no anchor" ); - // Same screen, same revision: only the probe changed. A rank increase - // renders on the resolution that observes it. + // A rank increase renders on the resolution that observes it. let probe = Some(("awaiting approval", "claude:registry-approval")); let p = st.resolve(t0, &s, None, probe).clone(); assert_eq!( @@ -562,8 +545,7 @@ mod tests { ) ); - // Clearing it is a rank drop like any other, so the floor returns at - // the hold's expiry rather than instantly. + // Clearing the status demotes through the standard hold. assert_eq!(st.resolve(t0, &s, None, None).source, PreviewSource::Anchor); let p = st.resolve(t0 + DEMOTION_HOLD, &s, None, None).clone(); assert_eq!( diff --git a/src/protocol.rs b/src/protocol.rs index 2d6c99f..376bdec 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -276,9 +276,7 @@ pub enum PreviewSource { Marker, /// The child's window title, honored only on the alternate screen. Title, - /// The cascade's top tier: a summary adapter's normalized reading of the - /// child's chrome, or a harness's reading of the state the tool publishes - /// about itself off-screen. + /// The cascade's top tier: a normalized screen or registry status. Anchor, } @@ -299,9 +297,8 @@ impl PreviewSource { pub struct Preview { pub text: String, pub source: PreviewSource, - /// Matcher ID naming what produced an Anchor preview, from either the - /// summary adapter or the harness; `None` for other sources. Never - /// encoded, so a wire-decoded view always carries `None`. + /// Matcher ID for an Anchor preview; `None` for other sources. This field + /// is not encoded, so a wire-decoded view always carries `None`. pub rule: Option<&'static str>, /// Whether the preview froze at output-complete and can no longer change. pub frozen: bool, diff --git a/src/supervisor.rs b/src/supervisor.rs index c2dc9f6..1ac22eb 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -134,14 +134,9 @@ fn fnv1a_hex(bytes: &[u8]) -> String { format!("{h:016x}") } -/// Resolve the best session ID in precedence order: exit scrape, capture file, -/// live session registry, then spawn-time ID. Exit and capture data outrank the -/// launch value because either can reflect a conversation selected later. The -/// registry outranks the launch value for the same reason and by a stronger -/// one: the pin records what fleetcom asked for, while the registry records -/// what the tool is running, and `/clear` mints a fresh ID mid-session. It -/// ranks under the capture file only because that file is fleetcom's own hook -/// output, and the two agree whenever both exist. +/// Resolve the session ID in precedence order: exit scrape, capture file, live +/// registry, then spawn-time ID. The first three can reflect a session selected +/// after launch and therefore outrank the spawn-time value. fn current_resume_id(task: &Task) -> Option { if let Some(id) = &task.scraped_id { return Some(id.clone()); diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs index 9af3336..7212cd8 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -73,10 +73,7 @@ fn install_script(bin: &Path, name: &str, body: &str) { write_executable(&bin.join(name), body); } -/// File the registry record `pid` publishes, carrying the raw `status` JSON -/// pair. Every field `record_for_pid` validates has to agree with the task: -/// the file name and `pid`, the `cwd`, and a process start inside the -/// correlation window of the spawn. +/// Write a matching interactive registry record with raw status fields. fn install_status_record(home: &Path, pid: u32, cwd: &Path, status: &str) { let sessions = home.join("sessions"); std::fs::create_dir_all(&sessions).unwrap(); @@ -937,10 +934,7 @@ fn resume_id_precedence_scrape_over_capture_over_spawn() { ); } -/// Claude's live session registry outranks the ID pinned at spawn: the pin -/// records what fleetcom asked for, the registry what the CLI is running, and -/// `/clear` moves the conversation on the same process. Fleetcom's own hook -/// output still outranks the registry. +/// Session ID precedence is capture file, live registry, then spawn-time pin. #[test] fn resume_id_precedence_registry_over_spawn_under_capture() { let dir = scratch("registry_precedence"); @@ -969,8 +963,7 @@ fn resume_id_precedence_registry_over_spawn_under_capture() { .clone() .expect("a fresh claude launch pins an id"); assert_ne!(injected.as_str(), CAP_ID); - // `$SHELL -c` execs the accepted command in place, so the task's pid names - // the registry record. + // Key the registry fixture to the spawned task. let pid = s.tasks[0].pid().expect("a live task has a pid"); install_status_record(&claude_home, pid, &dir, r#""status":"idle""#); @@ -984,7 +977,7 @@ fn resume_id_precedence_registry_over_spawn_under_capture() { "the injected id must not survive the registry; got {text}" ); - // The hook fired: fleetcom's own capture channel wins. + // A capture-file ID outranks the registry ID. let cap = s.tasks[0].capture_file.clone().expect("capture file set"); std::fs::write( &cap, @@ -1453,11 +1446,8 @@ fn recovery_cadence_rewrites_on_capture_drift_and_skips_when_static() { // --- live registry blocked status -------------------------------------- -/// Tick until the sole task's emitted preview satisfies `pred`, then return -/// the last preview seen. Only a tick resolves a preview and only a -/// resolution probes the registry, so the 250 ms probe throttle expires on -/// ticks, not on sleeps. A negative assertion reads the return value: `pred` -/// stops on the first violation, so the preview returned is the violating one. +/// Tick until the sole task's preview satisfies `pred` or the budget expires, +/// then return the last preview. fn tick_until_preview( s: &mut Supervisor, budget: Duration, @@ -1478,12 +1468,8 @@ fn tick_until_preview( last.expect("a Tasks snapshot must carry the task's preview") } -/// The only test that drives `Task::refresh_blocked`: the harness tests call -/// `live_blocked_status` directly and the preview tests hand `resolve` a -/// literal claim, so gutting the probe leaves both green. A `waiting` record -/// the CLI publishes for the task's own pid reaches the dashboard as the -/// anchor tier, a non-waiting record does not, and an exited leader stops -/// claiming to be blocked even though its record outlives it. +/// A matching `waiting` record reaches the dashboard; non-waiting and post-exit +/// records do not. #[test] fn registry_waiting_status_reaches_the_dashboard_preview() { let dir = scratch("registry_blocked"); @@ -1504,11 +1490,10 @@ fn registry_waiting_status_reaches_the_dashboard_preview() { &[("CLAUDE_CONFIG_DIR", &claude_home)], )); spawn(&mut s, "claude", dir.to_path_buf()); - // The record is keyed by the leader's pid, which only the spawn can name. + // Key the record to the task's leader PID. let pid = s.tasks[0].pid().expect("a live task has a pid"); - // `idle` is a live session no user is blocking on: three probe intervals - // of ticks must never anchor the preview. + // A non-waiting status must not anchor the preview. install_status_record(&claude_home, pid, &dir, r#""status":"idle""#); let p = tick_until_preview(&mut s, Duration::from_millis(750), |p| { p.source == PreviewSource::Anchor @@ -1519,7 +1504,7 @@ fn registry_waiting_status_reaches_the_dashboard_preview() { "a non-waiting record must not anchor the preview: {p:?}" ); - // The CLI rewrites the record in place when it blocks on a dialog. + // A waiting status becomes an Anchor preview. install_status_record( &claude_home, pid, @@ -1539,10 +1524,8 @@ fn registry_waiting_status_reaches_the_dashboard_preview() { "a waiting record must reach the dashboard as the anchor tier" ); - // The leader exits and its record survives, as one the CLI never got to - // remove does. The reason is rewritten afterward, to text the live task - // never saw: adopting it could only come from probing a dead leader, - // which the demotion hold holding the old text cannot be mistaken for. + // After exit, a changed record must neither retain nor replace the cached + // blocked preview. std::fs::write(&done, b"").unwrap(); assert!( reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] diff --git a/src/task.rs b/src/task.rs index 6782feb..9ffdb07 100644 --- a/src/task.rs +++ b/src/task.rs @@ -35,15 +35,9 @@ use crate::{ /// input when a child stops reading. const MAX_PENDING_WRITE: usize = 16 * 1024 * 1024; -/// Minimum interval between harness blocked-status probes for one task. The -/// probe reads the CLI's registry off disk, and `resolve_preview` runs for -/// every task on every snapshot tick, which range from the 8 ms frame minimum -/// to the 200 ms idle backstop: unthrottled, that is a filesystem read per -/// claude task per frame. Nothing downstream absorbs what the throttle costs. -/// A blocked status appearing is a rank increase, which cancels any pending -/// demotion and renders on the tick that observes it, so the interval plus one -/// tick is the whole visible latency of a newly blocked session. 250 ms of it -/// is a delay no human reading a status line can distinguish from immediate. +/// Minimum interval between on-disk blocked-status probes for one task. Preview +/// resolution runs every 8–200 ms; this caps each task at four registry probes +/// per second. const BLOCKED_PROBE_INTERVAL: Duration = Duration::from_millis(250); /// A whole-message refusal from the bounded writer queue. @@ -111,8 +105,8 @@ pub struct Task { pub summary_adapter: Option<&'static dyn crate::preview::SummaryAdapter>, /// Run number used to give each rerun a distinct capture path. pub run: u32, - /// Session ID injected or recognized at spawn. Later capture data or an - /// exit hint can supersede it. + /// Session ID injected or recognized at spawn. Capture data, a live + /// registry record, or an exit hint can supersede it. pub resume_id: Option, /// Capture path allocated for this task run. pub capture_file: Option, @@ -124,13 +118,11 @@ pub struct Task { /// Dashboard-preview resolution state; resets with the task on rerun /// because a rerun replaces the whole `Task`. preview: PreviewState, - /// Latest harness blocked-on-user probe, held between refreshes so the - /// preview cascade sees it on every tick without a filesystem read. + /// Cached blocked-on-user status from the harness registry. blocked: Option<(String, &'static str)>, - /// When `blocked` was last read: the [`BLOCKED_PROBE_INTERVAL`] deadline - /// base. `None` until the first probe. + /// Last registry probe time; `None` before the first probe. blocked_probed: Option, - /// Wall-clock spawn time used for filesystem correlation. + /// Wall-clock spawn time used for registry and transcript correlation. pub spawned_at: SystemTime, exit_code: Option, pub started: Instant, @@ -375,10 +367,7 @@ impl Task { }) } - /// The session leader's PID, which for an accepted agent command is the - /// agent process itself: the pid its live session registry is keyed by. - /// [`crate::harness::Harness::live_session_id`] carries the exec-in-place - /// mechanism that makes that true and the `$SHELL` shape that breaks it. + /// Return the task's session-leader PID. pub fn pid(&self) -> Option { self.pid } @@ -542,13 +531,9 @@ impl Task { .clone() } - /// Re-read the harness's blocked-on-user claim, at most once per - /// [`BLOCKED_PROBE_INTERVAL`]. Three states never probe: no harness (the - /// command is opaque, or its tool publishes no status), no pid, and an - /// exited leader, whose record — if the CLI left one behind at all — - /// claims a state the process can no longer be in. The last of those also - /// drops the cached claim, so the ticks between exit and freeze do not - /// render a dead session as blocked. + /// Refresh the harness's blocked-on-user status at most once per + /// [`BLOCKED_PROBE_INTERVAL`]. Tasks without a harness or PID do not probe; + /// finished tasks clear the cached status. fn refresh_blocked(&mut self, now: Instant) { let (Some(h), Some(pid), None) = (self.harness, self.pid, self.finished) else { self.blocked = None;