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/agent-resume.md b/docs/agent-resume.md index 4fb8a7c..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, 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. 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,6 +52,10 @@ 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 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` 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 `-`. ### `codex` @@ -80,8 +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 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, 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 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: @@ -95,7 +102,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 +112,8 @@ 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. +- `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. @@ -116,6 +125,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/docs/commands.md b/docs/commands.md index cb101f7..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 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 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 cc4230c..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 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 f04950d..c30e07b 100644 --- a/src/harness/claude.rs +++ b/src/harness/claude.rs @@ -1,14 +1,18 @@ -//! 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 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::{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, within_window, within_window_ms, }; pub struct Claude; @@ -55,6 +59,29 @@ 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(pid, cwd, spawned, home)?.id) + } + + fn live_blocked_status( + &self, + pid: u32, + cwd: &Path, + spawned: SystemTime, + home: Option<&Path>, + ) -> Option<(String, &'static str)> { + let rec = record_for_pid(pid, cwd, spawned, home)?; + // Only `waiting` overrides the screen-derived preview. + rec.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| { @@ -68,6 +95,99 @@ impl Harness for Claude { } } +/// Validated fields used to correlate a registry record with a task and render +/// its blocked status. +struct SessionRecord { + /// `sessionId`, validated by [`is_uuid`]. + id: String, + pid: i32, + cwd: PathBuf, + /// `startedAt`, in epoch milliseconds. + started_at: u128, + /// Whether `status` is `waiting`. + waiting: bool, + /// Optional `waitingFor` text. + waiting_for: Option, +} + +/// 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"), + Some(other) => (other.to_string(), "claude:registry-waiting"), + None => ("awaiting input".to_string(), "claude:registry-waiting"), + } +} + +/// 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" { + 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()?), + waiting: v["status"].as_str() == Some("waiting"), + waiting_for: v["waitingFor"].as_str().map(str::to_string), + }) +} + +/// 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, + spawned: SystemTime, + home: Option<&Path>, +) -> Option { + let pid = i32::try_from(pid).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(); + // 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 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, + 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. @@ -90,6 +210,40 @@ mod tests { testutil::temp, }; + /// 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,"#, + 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}"#, + ); + /// 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; + + /// 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}}}"# + ) + } + + /// 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(); + } + + /// Convert epoch milliseconds to [`SystemTime`]. + 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 +372,255 @@ mod tests { ); } + /// A complete matching record exposes its validated session ID. + #[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(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`"); + 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 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"); + 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(4242, cwd, spawned, Some(&home)).is_some()); + + // The filename and embedded PID must agree. + install_record( + &home, + 4242, + &record(99, ID, "/w", LIVE_STARTED, "interactive", ""), + ); + 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(4242, cwd, spawned, Some(&home)).is_none()); + } + + /// 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"); + 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(7, &link, spawned, Some(&home)).is_some()); + // 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()); + } + + /// 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"); + let cwd = Path::new("/w"); + install_record( + &home, + 4242, + &record(4242, ID, "/w", LIVE_STARTED, "interactive", ""), + ); + + // 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()); + // 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 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"); + 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(7, cwd, spawned, Some(&home)).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(7, cwd, spawned, Some(&home)).is_none(), + "{id:?}" + ); + } + // `kind` is required. + install_record( + &home, + 7, + &format!(r#"{{"pid":7,"sessionId":"{ID}","cwd":"/w","startedAt":{LIVE_STARTED}}}"#), + ); + assert!(record_for_pid(7, cwd, spawned, Some(&home)).is_none()); + } + + /// 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"); + 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(LIVE_PID, cwd, spawned, Some(&home)).is_none(), + "{body:?}" + ); + } + // 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()); + } + + /// 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"); + let cwd = Path::new("/w"); + let spawned = at_ms(LIVE_STARTED); + for tail in ["", r#","status":"hibernating""#, r#","status":"busy""#] { + install_record( + &home, + 7, + &record(7, ID, "/w", LIVE_STARTED, "interactive", tail), + ); + 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:?}"); + } + } + + /// 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"); + 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!( + probe(r#","status":"waiting","waitingFor":"permission prompt""#), + Some(("awaiting approval".to_string(), "claude:registry-approval")) + ); + for reason in [ + "input needed", + "dialog open", + "sandbox request", + "worker request", + "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` produces a blocked-status preview. + #[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); + 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""#, + r#","status":"idle""#, + r#","status":"hibernating""#, + "", + // A reason without `status: waiting` is not blocked. + r#","waitingFor":"permission prompt""#, + ] { + assert_eq!(probe(tail), None, "{tail:?}"); + } + // A missing record contributes no status. + assert_eq!( + Claude.live_blocked_status(9, cwd, spawned, Some(&home)), + 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..a6689e1 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -9,11 +9,10 @@ //! //! # 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 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; @@ -23,7 +22,7 @@ pub mod summary; use std::{ ffi::OsString, - fs::{self, File}, + fs::File, io::Read, path::{Path, PathBuf}, time::{Duration, SystemTime}, @@ -92,6 +91,32 @@ pub trait Harness: Sync { /// Extract a session ID from final terminal text, including scrollback. fn scrape_exit(&self, text: &str) -> Option; + /// 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, + _cwd: &Path, + _spawned: SystemTime, + _home: Option<&Path>, + ) -> Option { + None + } + + /// 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, + _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; @@ -306,40 +331,11 @@ fn within_window(a: SystemTime, b: SystemTime) -> bool { } } -/// Millisecond form of [`within_window`] for UUID-embedded timestamps. +/// Epoch-millisecond form of [`within_window`]. 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.rs b/src/harness/summary.rs index ddc20bd..2be7d7e 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -118,13 +118,16 @@ 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); + // 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); (framed && chars.next()? == ' ').then(|| format!("✻ {}", chars.as_str())) } } @@ -267,6 +270,9 @@ fn claude_approval(rows: &[String]) -> Option<(String, &'static str)> { .then(|| ("awaiting approval".to_string(), "claude:approval-menu")) } +/// 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 /// welcome box is the stable source; user-configurable statusline rows are not /// parsed. When the box scrolls away, the label is unavailable. @@ -284,17 +290,27 @@ 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 } +/// 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 ")?, + 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..1df184a 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) } @@ -284,6 +284,19 @@ fn claude_title_frames_canonicalize_to_constant_text() { let b = ClaudeSummary.normalize_title("✽ Claude Code"); assert_eq!(a, b, "two frames must normalize identically"); + // Every spinner frame must normalize to the same title. + 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"), @@ -299,6 +312,54 @@ fn claude_title_frames_canonicalize_to_constant_text() { assert_eq!(ClaudeSummary.normalize_title("✻"), None, "frame alone"); } +/// 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); + 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. @@ -308,7 +369,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), @@ -316,12 +377,30 @@ 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), "no adapter: verbatim" ); + + // 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", + ); + let mut st = PreviewState::new(); + let p = st + .resolve(Instant::now(), &quadrant, Some(&ClaudeSummary), None) + .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: @@ -460,19 +539,41 @@ 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})`; 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 = 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, + "╰──────────────────────────────────────╯", + ]) + }; + 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 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)")), + // Partial and empty effort values are invalid. + ("│ Opus 5 (1M context) with hi… │ notes │", None), + ("│ Opus 5 (1M context) with … │ notes │", None), + // Neither accepted suffix is present. + ("│ 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); } @@ -1364,10 +1465,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..e76eaf4 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -88,17 +88,28 @@ 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: 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. 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() { + // The screen status and model label share one viewport snapshot. let rows = screen.live_rows(); - if let Some((text, rule)) = a.live_preview(&rows) { - let text = match a.model_label(&rows) { + // A registry status outranks a screen-derived status. + 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 +156,14 @@ fn cascade(screen: &impl ScreenFacts, adapter: Option<&dyn SummaryAdapter>) -> P }) } -/// State that invalidates the cached preview candidate. -type ResolveKey = (u64, u64, bool, Option); +/// Screen and registry state that invalidates the cached preview candidate. +type ResolveKey = ( + u64, + u64, + bool, + Option, + Option<(String, &'static str)>, +); /// Per-task preview resolution state. A rerun replaces the `Task` and resets /// this state. @@ -203,13 +220,14 @@ 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. + /// 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, screen: &impl ScreenFacts, adapter: Option<&dyn SummaryAdapter>, + blocked: Option<(&str, &'static str)>, ) -> &Preview { if self.finalized { return &self.rendered; @@ -219,9 +237,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 +335,8 @@ impl PreviewState { self.rendered.frozen = true; return; } - let mut fin = cascade(screen, adapter); + // Finalization excludes registry state because the process has exited. + let mut fin = cascade(screen, adapter, None); fin.frozen = true; self.rendered = fin; } @@ -451,7 +471,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 +487,73 @@ 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"); } + /// 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(); + 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") + ) + ); + } + + /// 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(); + 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" + ); + + // 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 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!( + (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 +567,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 +575,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 +596,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 +614,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 +624,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 +645,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 +665,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 +692,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 +724,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 +755,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 +774,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 +787,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 +805,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 +822,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 +842,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 +855,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 +870,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 +882,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 +898,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 +914,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 +931,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 +958,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 +987,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 +1000,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 +1016,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 +1030,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 +1047,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 +1073,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 +1090,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/protocol.rs b/src/protocol.rs index 0a04227..376bdec 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -276,7 +276,7 @@ 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 normalized screen or registry status. Anchor, } @@ -297,8 +297,8 @@ 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 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 e35cf92..1ac22eb 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -134,9 +134,9 @@ fn fnv1a_hex(bytes: &[u8]) -> String { format!("{h:016x}") } -/// 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. +/// 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()); @@ -147,6 +147,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..7212cd8 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 ------------------------------------------- @@ -70,6 +73,21 @@ fn install_script(bin: &Path, name: &str, body: &str) { write_executable(&bin.join(name), body); } +/// 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(); + 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() }); @@ -916,6 +934,66 @@ fn resume_id_precedence_scrape_over_capture_over_spawn() { ); } +/// 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"); + 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); + // 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""#); + 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}" + ); + + // A capture-file ID outranks the registry ID. + 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] @@ -1365,3 +1443,114 @@ 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 -------------------------------------- + +/// 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, + 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") +} + +/// 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"); + 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()); + // Key the record to the task's leader PID. + let pid = s.tasks[0].pid().expect("a live task has a pid"); + + // 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 + }); + assert_ne!( + p.source, + PreviewSource::Anchor, + "a non-waiting record must not anchor the preview: {p:?}" + ); + + // A waiting status becomes an Anchor preview. + 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" + ); + + // 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] + .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 6d47ca3..9ffdb07 100644 --- a/src/task.rs +++ b/src/task.rs @@ -35,6 +35,11 @@ use crate::{ /// input when a child stops reading. const MAX_PENDING_WRITE: usize = 16 * 1024 * 1024; +/// 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. #[derive(Debug)] pub struct WriteRefused { @@ -100,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, @@ -113,7 +118,11 @@ pub struct Task { /// Dashboard-preview resolution state; resets with the task on rerun /// because a rerun replaces the whole `Task`. preview: PreviewState, - /// Wall-clock spawn time used for filesystem correlation. + /// Cached blocked-on-user status from the harness registry. + blocked: Option<(String, &'static str)>, + /// Last registry probe time; `None` before the first probe. + blocked_probed: Option, + /// Wall-clock spawn time used for registry and transcript correlation. pub spawned_at: SystemTime, exit_code: Option, pub started: Instant, @@ -346,6 +355,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(), @@ -356,6 +367,11 @@ impl Task { }) } + /// Return the task's session-leader PID. + 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 @@ -507,12 +523,37 @@ 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() } + /// 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; + 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) {