diff --git a/docs/commands.md b/docs/commands.md index b4481d3..089dea8 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -48,7 +48,8 @@ Choose the operating mode with launch arguments. Use keys to control the dashboa | Glyph | Meaning | | -- | -- | -| `✻` | Running: active, producing output | +| `●` | Working: the agent reports an active turn through its CLI | +| `✻` | Running: producing output, no agent signal | | `∙` | Idle: running, but quiet | | `✓` | Completed, exit 0 | | `✗` | Completed, non-zero exit | @@ -57,7 +58,9 @@ Choose the operating mode with launch arguments. Use keys to control the dashboa Look for marks in the row's two leading columns, aligned left with `◆` before `⚑`. -After 10 seconds without output, a task is marked `∙` and grouped under Idle. +When you run an agent, Fleetcom shows `●` while the CLI reports an active turn. Launch from the Agent page to add session resume on rerun and blocked-state detection through the agent's registry. + +After 10 seconds without output, Fleetcom moves the task under Idle. It shows `∙` unless the agent's CLI reports an active turn, in which case it shows `●`. ### Input and task lifecycle diff --git a/docs/how-it-works.md b/docs/how-it-works.md index fb90be4..22c140f 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -12,6 +12,8 @@ Each task retains 2,000 lines of scrollback by default; you can configure the de ## One activity window for grouping -Group tasks by state (In use / Running / Idle / Completed), working directory, or names assigned with `g`. After more than 10 s without output, a running task becomes Idle: its glyph changes from `✻` to `∙` and, under state grouping, its row moves to Idle in the same refresh. Row order stays unchanged within directory or custom sections. A task producing output every 1–2 s, such as `top`, stays under Running with the `✻` glyph. +Group tasks by state (In use / Running / Idle / Completed), working directory, or names assigned with `g`. After 10 s without PTY output, Fleetcom marks a running task Idle, changes its glyph from `✻` to `∙`, and moves its row under Idle on the same state-grouped refresh. Fleetcom preserves row order within directory and custom sections. When Fleetcom receives output every 1–2 s from a task such as `top`, it keeps that task under Running with `✻`. + +An agent may keep working while its PTY is quiet. Fleetcom reads the agent CLI's spinner row or title frame to identify an active turn, then shows `●` regardless of output timing. Fleetcom applies the same signal to hand-typed commands and Agent-page launches. Preview text is resolved independently of activity grouping. When a stronger source disappears, the resolver retains the previous preview for 600 ms before displaying a weaker source. This avoids repaint flicker without changing grouping. Claude's on-disk `waiting` status and screen-derived agent statuses both belong to the top `anchor` tier, with the on-disk status taking precedence when both are present. If only the screen-derived status remains, the resolver displays it immediately because the tier has not changed. diff --git a/src/app_readme_tests.rs b/src/app_readme_tests.rs index 7fb0580..afe5d04 100644 --- a/src/app_readme_tests.rs +++ b/src/app_readme_tests.rs @@ -44,6 +44,7 @@ fn anchor(text: &str, rule: &'static str) -> Preview { source: PreviewSource::Anchor, rule: Some(rule), frozen: false, + working: false, } } @@ -54,6 +55,7 @@ fn title(text: &str) -> Preview { source: PreviewSource::Title, rule: None, frozen: false, + working: false, } } @@ -64,6 +66,7 @@ fn floor(text: &str) -> Preview { source: PreviewSource::Floor, rule: None, frozen: false, + working: false, } } @@ -74,6 +77,7 @@ fn frozen(text: &str) -> Preview { source: PreviewSource::Floor, rule: None, frozen: true, + working: false, } } diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 2355e02..e2ab5d1 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -13,6 +13,9 @@ //! screen, use the sanitized captured title when normalization is unsuccessful. On the //! primary screen, render a retained title only when its shape is recognized by the //! adapter: the terminal title may have been replaced by another inline program. +//! For each recognized title, return normalized text and set `working` for active +//! turns. If no adapter recognizes a title on the alternate screen, keep the captured +//! title verbatim and set `working` to `false`. //! //! # Anchor discipline //! @@ -39,6 +42,23 @@ use crate::preview::SummaryAdapter; /// permission prompt. pub const AWAITING_APPROVAL: &str = "awaiting approval"; +/// Classify each emitted anchor rule ID as working or idle. Return `None` for IDs no +/// adapter emits. List every emitted ID so the corpus test covers each classification. +/// Treat `grok:still-running` as idle because background subagents may remain active +/// after the turn ends. +pub fn rule_reports_working(rule: &str) -> Option { + match rule { + "claude:spinner" | "claude:action-row" | "codex:working" | "grok:spinner" => Some(true), + "claude:approval-menu" + | "claude:waiting" + | "codex:approval-menu" + | "codex:ran" + | "grok:worked" + | "grok:still-running" => Some(false), + _ => None, + } +} + /// Whether `c` is a Unicode Braille Patterns code point used as a spinner /// frame by the supported CLIs. fn braille_frame(c: char) -> bool { @@ -121,16 +141,17 @@ impl SummaryAdapter for ClaudeSummary { claude_welcome_label(rows) } - /// Strip a recognized claude spinner, braille, or quadrant-circle frame from a - /// nonempty title. Return `None` for other title shapes. - fn normalize_title(&self, title: &str) -> Option { + /// Set `working` for braille and quadrant-circle title frames. Treat a + /// [`CLAUDE_SPINNER`] glyph as idle. We observed only `✳` from this family in titles, + /// and only while idle. Return `None` for other shapes or empty payloads. + fn normalize_title(&self, title: &str) -> Option<(String, bool)> { let rest = after_frame(title, |c| { CLAUDE_SPINNER.contains(&c) || braille_frame(c) || ('\u{25D0}'..='\u{25D3}').contains(&c) })?; // An empty payload cannot produce a usable preview. - (!rest.is_empty()).then(|| rest.to_string()) + (!rest.is_empty()).then(|| (rest.to_string(), !title.starts_with(CLAUDE_SPINNER))) } } @@ -361,13 +382,17 @@ impl SummaryAdapter for CodexSummary { codex_model_label(rows) } - /// Fold braille frames to `⠋` and `[ . ] ` to `[ ! ] `. Return `None` for other - /// title shapes. - fn normalize_title(&self, title: &str) -> Option { + /// Set `working` for a title led by a braille frame. Replace `[ . ] ` with `[ ! ] ` + /// to show the blocked blink's canonical phase. Return `None` for other title shapes + /// and for an empty frame payload. + fn normalize_title(&self, title: &str) -> Option<(String, bool)> { if let Some(rest) = title.strip_prefix("[ . ] ") { - return Some(format!("[ ! ] {rest}")); + return Some((format!("[ ! ] {rest}"), false)); } - after_frame(title, braille_frame).map(|rest| format!("⠋ {rest}")) + // Do not normalize a frame with an empty payload; Claude and OMP use the same rule. + after_frame(title, braille_frame) + .filter(|rest| !rest.is_empty()) + .map(|rest| (rest.to_string(), true)) } } @@ -693,43 +718,43 @@ impl SummaryAdapter for GrokSummary { grok_border_label(&rows[bottom]) } - /// Parse grok 1.0.50's raw `SetTitle` announcements, unchanged by theme - /// processing. Observed forms: + /// Parse the raw `SetTitle` announcements from grok 1.0.50 before theme + /// processing. Captured forms: /// - Splash: `grok`. /// - Idle: `{summary} - grok`. /// - Working before the session is named: `{frame} - {status} - grok`. /// - Working after the session is named: `{frame} - {status} - {summary} - grok`. /// - Permission prompt: `⚠ Action Required - {frame} - {status}… - grok`. /// - /// Use the single braille character as the frame. Treat the status as grok's - /// live phrase, truncated with its own `…` at roughly 40 columns, and the - /// summary as the session name. + /// Use the single braille character as the frame. Keep grok's live status phrase, + /// including its own `…` truncation at roughly 40 columns. Read the session name + /// from the summary segment. /// - /// Map the three blocked states to [`AWAITING_APPROVAL`]: match - /// `⚠ Action Required` for permission prompts, `Running: Plan: Exit` for plan - /// approval, and the `Running: Ask: ` prefix for questions. For other working - /// titles, replace the frame with `⠋` and keep the first status segment. For - /// idle titles, return the summary. Treat ` - ` as the status/session - /// delimiter. Keep only the first segment after the frame as the status and - /// discard the remainder, even if the status contains that delimiter. + /// For the three blocked states, return [`AWAITING_APPROVAL`] and set `working` to + /// false. Match `⚠ Action Required` for permission prompts, `Running: Plan: Exit` + /// for plan approval, and the `Running: Ask: ` prefix for questions. For other + /// working titles, remove the frame and return the first status segment. For idle + /// titles, return the summary. Split on ` - ` between status and session name; keep + /// only the first segment after the frame, even when grok puts ` - ` inside its + /// status. /// /// Return `None` for a title without the ` - grok` suffix, bare `grok`, or an - /// empty status or summary. On grok's alternate screen, render the captured - /// title verbatim when normalization returns `None`. - fn normalize_title(&self, title: &str) -> Option { + /// empty status or summary. If normalization fails on grok's alternate screen, + /// preserve the captured title verbatim. + fn normalize_title(&self, title: &str) -> Option<(String, bool)> { let rest = title.strip_suffix(" - grok")?; if rest.starts_with("⚠ Action Required") { - return Some(AWAITING_APPROVAL.to_string()); + return Some((AWAITING_APPROVAL.to_string(), false)); } let Some(working) = after_frame(rest, braille_frame).and_then(|r| r.strip_prefix("- ")) else { - return (!rest.is_empty()).then(|| rest.to_string()); + return (!rest.is_empty()).then(|| (rest.to_string(), false)); }; let status = working.find(" - ").map_or(working, |i| &working[..i]); if status == "Running: Plan: Exit" || status.starts_with("Running: Ask: ") { - return Some(AWAITING_APPROVAL.to_string()); + return Some((AWAITING_APPROVAL.to_string(), false)); } - (!status.is_empty()).then(|| format!("⠋ {status}")) + (!status.is_empty()).then(|| (status.to_string(), true)) } } @@ -798,28 +823,27 @@ fn grok_border_label(row: &str) -> Option { // ------------------------------------------------------------------- omp -- -/// omp title-only adapter. omp's state reaches the preview through its OSC 0 -/// title, never through its screen: the chrome is user-themeable along three -/// axes (composer shape, symbol preset, status-line segments), so no row -/// structure is stable enough to pin. The title emitter is app-level and -/// unthemed: `π >` idle, `π > label` idle with a session name, `π ⠋…⠏ label` -/// working, `π ! label` blocked. +/// omp title-only adapter. Read omp state from its OSC 0 title; do not parse its screen. +/// Users can change the composer shape, symbol preset, and status-line segments, so +/// avoid row matchers tied to one theme. Parse these unthemed, app-level title forms: +/// `π >` when idle, `π > label` when idle with a session name, `π ⠋…⠏ label` while +/// working, and `π ! label` while blocked. pub struct OmpSummary; impl SummaryAdapter for OmpSummary { - /// Always `None`: omp has no structural tier. A matcher pinned to one - /// theme stops working silently on the next, so none is kept. + /// Return `None`: omp has no stable structural anchor. Users can change the theme, + /// so avoid row matchers that depend on one theme. fn live_preview(&self, _rows: &[String]) -> Option<(String, &'static str)> { None } /// Normalize omp's `π {separator} {label}` and `π: {label}` titles. Extract a - /// nonempty label after `>` or `π:`, normalize braille frames to `⠋`, and map - /// `!` to [`AWAITING_APPROVAL`] with or without a label. Return `None` for - /// unsupported shapes and empty idle or disabled labels. - fn normalize_title(&self, title: &str) -> Option { + /// nonempty label after `>` or `π:`. Strip the braille frame and set `working` to + /// true; map `!` to [`AWAITING_APPROVAL`] with or without a label. Return `None` for + /// unsupported shapes, empty idle or disabled labels, and frames without a label. + fn normalize_title(&self, title: &str) -> Option<(String, bool)> { if let Some(label) = title.strip_prefix("π: ") { - return (!label.is_empty()).then(|| label.to_string()); + return (!label.is_empty()).then(|| (label.to_string(), false)); } let mut chars = title.strip_prefix("π ")?.chars(); let sep = chars.next()?; @@ -829,13 +853,12 @@ impl SummaryAdapter for OmpSummary { Some(_) => return None, }; match sep { - '>' => (!label.is_empty()).then(|| label.to_string()), + '>' => (!label.is_empty()).then(|| (label.to_string(), false)), // `!` is omp's blocked state; the label is the task, not the // status, so it is dropped for parity with the other harnesses. - '!' => Some(AWAITING_APPROVAL.to_string()), - // The frame stays: without a label it is the state. - f if braille_frame(f) && label.is_empty() => Some("⠋".to_string()), - f if braille_frame(f) => Some(format!("⠋ {label}")), + '!' => Some((AWAITING_APPROVAL.to_string(), false)), + // Return no normalized label when a frame has no task text. + f if braille_frame(f) => (!label.is_empty()).then(|| (label.to_string(), true)), _ => None, } } diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index feb9da3..fc5ae7c 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -31,37 +31,40 @@ fn grok_screen>(above: &[S]) -> Vec { rows } +/// A resolved preview's observable fields: text, source, rule, working. +type Resolved = (String, PreviewSource, Option<&'static str>, bool); + /// Resolve a corpus fixture at the corpus row count and `cols` columns; -/// return its text, source, and rule. -fn corpus( - bytes: &[u8], - adapter: &dyn SummaryAdapter, - cols: u16, -) -> (String, PreviewSource, Option<&'static str>) { +/// return its text, source, rule, and working flag. +fn corpus(bytes: &[u8], adapter: &dyn SummaryAdapter, cols: u16) -> Resolved { let mut emu = Emulator::new(CORPUS_LINES as u16, cols, 2000); emu.process(bytes); let mut st = PreviewState::new(); let p = st.resolve(Instant::now(), &emu, Some(adapter), None); - (p.text.clone(), p.source, p.rule) + (p.text.clone(), p.source, p.rule, p.working) } -fn anchor(text: &str, rule: &'static str) -> (String, PreviewSource, Option<&'static str>) { - (text.to_string(), PreviewSource::Anchor, Some(rule)) +/// Use the rule table to build expected anchor tuples, including each rule's working +/// classification. +fn anchor(text: &str, rule: &'static str) -> Resolved { + let working = rule_reports_working(rule) + .unwrap_or_else(|| panic!("{rule}: classify the rule id in rule_reports_working")); + (text.to_string(), PreviewSource::Anchor, Some(rule), working) } /// Expected alternate-screen marker preview. -fn marker() -> (String, PreviewSource, Option<&'static str>) { - (MARKER.to_string(), PreviewSource::Marker, None) +fn marker() -> Resolved { + (MARKER.to_string(), PreviewSource::Marker, None, false) } /// Expected floor-preview tuple. -fn floor(text: &str) -> (String, PreviewSource, Option<&'static str>) { - (text.to_string(), PreviewSource::Floor, None) +fn floor(text: &str) -> Resolved { + (text.to_string(), PreviewSource::Floor, None, false) } /// Expected title-preview tuple. -fn titled(text: &str) -> (String, PreviewSource, Option<&'static str>) { - (text.to_string(), PreviewSource::Title, None) +fn titled(text: &str, working: bool) -> Resolved { + (text.to_string(), PreviewSource::Title, None, working) } /// Selection is a basename match on the first word only: arguments and a @@ -129,7 +132,7 @@ fn select_routes_to_the_matching_adapter() { assert_eq!(omp.live_preview(&working), None); assert_eq!( omp.normalize_title("π ⠇ List files in current directory"), - Some("⠋ List files in current directory".to_string()) + Some(("List files in current directory".to_string(), true)) ); } @@ -294,23 +297,21 @@ fn claude_waiting_family_matches_the_skeleton_and_never_probes() { ); } -/// Every recognized spinner frame strips to the same bare title text; -/// unsupported and empty shapes return `None`. +/// Normalize every recognized frame to the same bare title text. Classify the resting +/// family as idle and braille and quadrant frames as working; reject unsupported shapes +/// and empty payloads. #[test] fn claude_title_frames_canonicalize_to_constant_text() { for frame in CLAUDE_SPINNER { assert_eq!( ClaudeSummary.normalize_title(&format!("{frame} Claude Code")), - Some("Claude Code".to_string()), + Some(("Claude Code".to_string(), false)), "{frame:?}" ); } - let a = ClaudeSummary.normalize_title("✢ Claude Code"); - 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 + // Normalize every working frame to the same title and flag. + let rendered: std::collections::BTreeSet> = BRAILLE_FRAMES .iter() .copied() .chain('\u{25D0}'..='\u{25D3}') @@ -318,18 +319,18 @@ fn claude_title_frames_canonicalize_to_constant_text() { .collect(); assert_eq!( rendered, - std::collections::BTreeSet::from([Some("Run sleep command".to_string())]), - "spinner and quadrant frames must render one string" + std::collections::BTreeSet::from([Some(("Run sleep command".to_string(), true))]), + "braille and quadrant frames must render one string and report working" ); // A braille frame plus the session summary. assert_eq!( ClaudeSummary.normalize_title("⠐ Review fleetcom preview design document"), - Some("Review fleetcom preview design document".to_string()) + Some(("Review fleetcom preview design document".to_string(), true)) ); assert_eq!( ClaudeSummary.normalize_title("⠴ Review fleetcom preview design document"), - Some("Review fleetcom preview design document".to_string()), + Some(("Review fleetcom preview design document".to_string(), true)), "mid-block braille frame" ); @@ -354,19 +355,19 @@ fn title_tier_renders_the_normalized_title() { .resolve(Instant::now(), &emu, Some(&ClaudeSummary), None) .clone(); assert_eq!( - (p.text.as_str(), p.source, p.rule), - ("Claude Code", PreviewSource::Title, None) + (p.text.as_str(), p.source, p.rule, p.working), + ("Claude Code", PreviewSource::Title, None, false) ); let mut st = PreviewState::new(); let p = st.resolve(Instant::now(), &emu, None, None).clone(); assert_eq!( - (p.text.as_str(), p.source), - ("✢ Claude Code", PreviewSource::Title), + (p.text.as_str(), p.source, p.working), + ("✢ Claude Code", PreviewSource::Title, false), "no adapter: verbatim" ); - // Quadrant frames strip to the same bare text as other spinner frames. + // Give quadrant frames the same bare text and working flag 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", @@ -376,11 +377,12 @@ fn title_tier_renders_the_normalized_title() { .resolve(Instant::now(), &quadrant, Some(&ClaudeSummary), None) .clone(); assert_eq!( - (p.text.as_str(), p.source, p.rule), + (p.text.as_str(), p.source, p.rule, p.working), ( "Run sleep command for 25 seconds", PreviewSource::Title, - None + None, + true ) ); } @@ -736,14 +738,14 @@ fn codex_label_rejects_warning_prose_and_incomplete_footers() { assert_eq!(CodexSummary.model_label(&rows), None); } -/// The braille spinner and the blocked-on-user blink each canonicalize to a -/// single string; idle and foreign titles pass through. +/// Normalize braille frames to the bare title and mark them working. Canonicalize the +/// blocked-on-user blink to one phase; pass idle and foreign titles through. #[test] fn codex_title_animations_canonicalize_to_constant_text() { for frame in BRAILLE_FRAMES { assert_eq!( CodexSummary.normalize_title(&format!("{frame} fleetcom")), - Some("⠋ fleetcom".to_string()), + Some(("fleetcom".to_string(), true)), "{frame:?}" ); } @@ -751,7 +753,7 @@ fn codex_title_animations_canonicalize_to_constant_text() { // `[ . ]` folds to `[ ! ]`; `[ ! ]` already has the canonical text. assert_eq!( CodexSummary.normalize_title("[ . ] Action Required | fleetcom"), - Some("[ ! ] Action Required | fleetcom".to_string()) + Some(("[ ! ] Action Required | fleetcom".to_string(), false)) ); assert_eq!( CodexSummary.normalize_title("[ ! ] Action Required | fleetcom"), @@ -759,17 +761,16 @@ fn codex_title_animations_canonicalize_to_constant_text() { "the frozen phase needs no rewrite" ); - // Each adapter uses a distinct canonical frame. - assert_ne!( - CodexSummary.normalize_title("⠹ fleetcom"), - ClaudeSummary.normalize_title("⠹ fleetcom") - ); - // Bare, foreign, frame-only, and empty titles do not match Codex's title // grammar. assert_eq!(CodexSummary.normalize_title("fleetcom"), None); assert_eq!(CodexSummary.normalize_title("zellij: main"), None); assert_eq!(CodexSummary.normalize_title("⠹"), None, "frame alone"); + assert_eq!( + CodexSummary.normalize_title("⠹ "), + None, + "frame with an empty rest" + ); assert_eq!(CodexSummary.normalize_title(""), None, "empty title"); } @@ -1206,9 +1207,9 @@ fn grok_still_running_shapes() { ); } -/// Test each observed ` - `-joined title form from grok 1.0.50. Expect the -/// session name for idle titles, a normalized frame and first status segment for -/// working titles, and the blocked status for the three blocked forms. Return +/// Check each observed ` - `-joined title form from grok 1.0.50. Expect the session +/// name for idle titles, the first status segment with its frame removed and `working` +/// set for active titles, and the blocked status for all three blocked forms. Require /// `None` for bare `grok` and titles without the suffix. #[test] fn grok_title_segments_decode_state_and_status() { @@ -1216,7 +1217,7 @@ fn grok_title_segments_decode_state_and_status() { assert_eq!( t("Run ls -la list directory file names - grok"), - Some("Run ls -la list directory file names".to_string()) + Some(("Run ls -la list directory file names".to_string(), false)) ); // Exercise every working frame with and without a session name; expect the @@ -1224,14 +1225,14 @@ fn grok_title_segments_decode_state_and_status() { for frame in BRAILLE_FRAMES { assert_eq!( t(&format!("{frame} - Waiting for response… - grok")), - Some("⠋ Waiting for response…".to_string()), + Some(("Waiting for response…".to_string(), true)), "{frame:?}" ); assert_eq!( t(&format!( "{frame} - Thinking - Run ls -la list directory file names - grok" )), - Some("⠋ Thinking".to_string()), + Some(("Thinking".to_string(), true)), "{frame:?}" ); } @@ -1239,27 +1240,32 @@ fn grok_title_segments_decode_state_and_status() { // as ordinary working states. assert_eq!( t("⠼ - Running: verbose|clap|arg\\( - Add verbose flag to main.rs - grok"), - Some("⠋ Running: verbose|clap|arg\\(".to_string()) + Some(("Running: verbose|clap|arg\\(".to_string(), true)) ); assert_eq!( t("⠋ - Not responding - grok"), - Some("⠋ Not responding".to_string()) + Some(("Not responding".to_string(), true)) ); // For statuses containing ` - `, keep only the first segment. assert_eq!( t("⠙ - Running: Edit a - b.rs - Add verbose flag to main.rs - grok"), - Some("⠋ Running: Edit a".to_string()) + Some(("Running: Edit a".to_string(), true)) ); // Identify permission prompts by the prefix; identify plan approval and - // questions from their status strings. + // questions from their status strings. A blocked agent is not working, + // whatever frame its title carries. for title in [ "⚠ Action Required - ⠙ - Create junk/a.txt then remove the direct… - grok", "⚠ Action Required - grok", "⠹ - Running: Plan: Exit - Add verbose flag to main.rs - grok", "⠼ - Running: Ask: Which greeting do you pre… - Ask preferred greeting hello versus hi - grok", ] { - assert_eq!(t(title), Some(AWAITING_APPROVAL.to_string()), "{title:?}"); + assert_eq!( + t(title), + Some((AWAITING_APPROVAL.to_string(), false)), + "{title:?}" + ); } // Return `None` for bare, foreign, suffix-less, and empty-status titles. @@ -1275,36 +1281,31 @@ fn grok_title_segments_decode_state_and_status() { } } -/// omp title normalization strips idle and disabled-state prefixes, folds -/// working frames to `⠋`, maps `!` to the blocked status, and rejects +/// Verify that omp titles strip idle and disabled-state prefixes, remove working +/// frames while setting `working`, map `!` to the blocked status, and reject /// unsupported shapes. #[test] fn omp_title_separators_decode_state_and_label() { assert_eq!( OmpSummary.normalize_title("π > Fix the flaky test"), - Some("Fix the flaky test".to_string()) + Some(("Fix the flaky test".to_string(), false)) ); - // Every working frame must normalize to the same title. + // Normalize every working frame to the same title and flag. for frame in BRAILLE_FRAMES { assert_eq!( OmpSummary.normalize_title(&format!("π {frame} Fix the flaky test")), - Some("⠋ Fix the flaky test".to_string()), + Some(("Fix the flaky test".to_string(), true)), "{frame:?}" ); } - assert_eq!( - OmpSummary.normalize_title("π ⠴"), - Some("⠋".to_string()), - "a label-less frame is still the working state" - ); // `!` is the blocked state, with or without a label: a blocked omp reads // like a blocked claude, codex, or grok. for title in ["π ! List files in current directory", "π !"] { assert_eq!( OmpSummary.normalize_title(title), - Some(AWAITING_APPROVAL.to_string()), + Some((AWAITING_APPROVAL.to_string(), false)), "{title:?}" ); } @@ -1312,14 +1313,16 @@ fn omp_title_separators_decode_state_and_label() { // The disabled-state form yields its label. assert_eq!( OmpSummary.normalize_title("π: Fix the flaky test"), - Some("Fix the flaky test".to_string()) + Some(("Fix the flaky test".to_string(), false)) ); - // Empty idle/disabled labels and unsupported shapes return `None`. + // Require `None` for empty idle or disabled labels, frames without labels, and + // unsupported shapes. for title in [ "π", "π >", "π: ", + "π ⠴", "π >> quoted", "custom extension title", "zellij: main", @@ -1617,45 +1620,58 @@ fn corpus_positive_states_resolve_exactly() { // Exercise both title paths against screens without structural anchors: // retained OSC titles on the primary screen and live announces on the // alternate screen. - let titles: [(&str, &[u8], &dyn SummaryAdapter, &str); 6] = [ - ( + struct Titled( + &'static str, + &'static [u8], + &'static dyn SummaryAdapter, + &'static str, + bool, + ); + let titles = [ + Titled( "preview_omp_idle_titled", include_bytes!("../../tests/corpus/preview_omp_idle_titled.bin"), &OmpSummary, "fix the parser", + false, ), - ( + Titled( "preview_omp_working_titled", include_bytes!("../../tests/corpus/preview_omp_working_titled.bin"), &OmpSummary, - "⠋ List files in current directory", + "List files in current directory", + true, ), - ( + Titled( "preview_grok_idle_titled", include_bytes!("../../tests/corpus/preview_grok_idle_titled.bin"), &GrokSummary, "Run ls -la list directory file names", + false, ), // Expect a `┃`-fenced option list instead of the input box. - ( + Titled( "preview_grok_permission", include_bytes!("../../tests/corpus/preview_grok_permission.bin"), &GrokSummary, AWAITING_APPROVAL, + false, ), // Exercise the title tier with the input box present and // `◆ Waiting on plan approval` in the probe row. - ( + Titled( "preview_grok_plan_approval", include_bytes!("../../tests/corpus/preview_grok_plan_approval.bin"), &GrokSummary, AWAITING_APPROVAL, + false, ), - ( + Titled( "preview_grok_question", include_bytes!("../../tests/corpus/preview_grok_question.bin"), &GrokSummary, AWAITING_APPROVAL, + false, ), ]; // Fixture names start with the program word: require a positive case for @@ -1665,17 +1681,55 @@ fn corpus_positive_states_resolve_exactly() { let prefix = format!("preview_{program}_"); assert!( cases.iter().any(|Case(name, ..)| name.starts_with(&prefix)) - || titles.iter().any(|(name, ..)| name.starts_with(&prefix)), + || titles + .iter() + .any(|Titled(name, ..)| name.starts_with(&prefix)), "a new adapter needs a positive corpus fixture named {prefix}*" ); } for Case(name, bytes, adapter, text, rule) in cases { + assert!( + rule_reports_working(rule).is_some(), + "{rule}: a new rule id must be classified in rule_reports_working" + ); let got = corpus(bytes, adapter, 120); assert_eq!(got, anchor(text, rule), "{name}"); } - for (name, bytes, adapter, text) in titles { + for Titled(name, bytes, adapter, text, working) in titles { let got = corpus(bytes, adapter, 120); - assert_eq!(got, titled(text), "{name}"); + assert_eq!(got, titled(text, working), "{name}"); + } +} + +/// Check every emitted rule ID against its working classification. Require `None` +/// for unregistered IDs such as registry rules, typos, and IDs from future adapters. +/// Fail if an adapter adds a matcher without adding its classification here. +#[test] +fn rule_reports_working_classifies_every_emitted_id() { + let table = [ + ("claude:spinner", true), + ("claude:action-row", true), + ("codex:working", true), + ("grok:spinner", true), + ("claude:approval-menu", false), + ("claude:waiting", false), + ("codex:approval-menu", false), + ("codex:ran", false), + ("grok:worked", false), + // Background subagents are live, but the turn itself has ended. + ("grok:still-running", false), + ]; + for (rule, working) in table { + assert_eq!(rule_reports_working(rule), Some(working), "{rule}"); + } + for rule in [ + "claude:registry-approval", + "claude:registry-waiting", + "claude:spinne", + "claude:", + "", + ] { + assert_eq!(rule_reports_working(rule), None, "{rule:?}"); } } diff --git a/src/preview.rs b/src/preview.rs index 8dd781f..d977514 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -4,12 +4,16 @@ use std::time::{Duration, Instant}; -use crate::protocol::{Preview, PreviewSource}; +use crate::{ + harness::summary::rule_reports_working, + protocol::{Preview, PreviewSource}, +}; // Holds use elapsed time because tick intervals range from the 8 ms frame // minimum to the 200 ms idle backstop. -/// Minimum interval between rendered title changes. +/// Minimum interval between title renders. Hold a title when only `working` changes; +/// compare the full `Preview` value. const TITLE_MIN_HOLD: Duration = Duration::from_millis(500); /// Hold duration before rendering a lower-ranked preview source. @@ -54,10 +58,11 @@ pub trait SummaryAdapter: Sync { None } - /// Normalize a title recognized as this CLI's output. On the alternate screen, - /// preserve the captured title verbatim for `None`. On the primary screen, reject - /// the retained title for `None` and continue resolution. - fn normalize_title(&self, _title: &str) -> Option { + /// Return normalized title text and a flag indicating whether the agent is working + /// on a turn. If no adapter recognizes a title on the alternate screen, keep the + /// captured title verbatim and set `working` to `false`. On the primary screen, + /// ignore an unrecognized retained title and continue the cascade. + fn normalize_title(&self, _title: &str) -> Option<(String, bool)> { None } } @@ -96,6 +101,9 @@ fn cascade( source: PreviewSource::Anchor, rule: Some(rule), frozen: false, + // Registry statuses mark agents as waiting or blocked. Set `working` to + // false when the rule table has no classification. + working: rule_reports_working(rule).unwrap_or(false), }; } } @@ -105,19 +113,24 @@ fn cascade( // rotating spinner prefix, so animation does not keep changing the // preview. Capture itself remains program-agnostic. Keep Title and // no rule ID: normalization does not make a title an anchor match. - Some(text) => Preview { - text: adapter + Some(text) => { + let (text, working) = adapter .and_then(|a| a.normalize_title(text)) - .unwrap_or_else(|| text.to_string()), - source: PreviewSource::Title, - rule: None, - frozen: false, - }, + .unwrap_or_else(|| (text.to_string(), false)); + Preview { + text, + source: PreviewSource::Title, + rule: None, + frozen: false, + working, + } + } None => Preview { text: MARKER.to_string(), source: PreviewSource::Marker, rule: None, frozen: false, + working: false, }, }; } @@ -125,13 +138,14 @@ fn cascade( // it. Require adapter recognition instead of rendering it verbatim. if let Some(a) = adapter && let Some(title) = screen.primary_title() - && let Some(text) = a.normalize_title(title) + && let Some((text, working)) = a.normalize_title(title) { return Preview { text, source: PreviewSource::Title, rule: None, frozen: false, + working, }; } // An indented status line can remain as the idle floor. Trim only the display @@ -287,6 +301,8 @@ impl PreviewState { .is_some_and(|snapshot| screen.live_floor() == snapshot); if alt_torn_down_at_exit { self.rendered.frozen = true; + // The child has exited, so clear `working`. + self.rendered.working = false; return; } // Finalization excludes registry state because the process has exited. @@ -297,6 +313,8 @@ impl PreviewState { fin = cascade(screen, None, None); } fin.frozen = true; + // The child has exited, so clear `working`. + fin.working = false; self.rendered = fin; } } @@ -424,6 +442,8 @@ mod tests { struct StubAdapter { live: Option<(&'static str, &'static str)>, label: Option<&'static str>, + /// The working flag returned for every accepted title. + title_working: bool, } impl StubAdapter { @@ -432,6 +452,17 @@ mod tests { Self { live: Some(("Working", "stub:working")), label, + title_working: false, + } + } + + /// Configure a stub adapter without an anchor and give each title the supplied + /// working flag. + fn titling(working: bool) -> Self { + Self { + live: None, + label: None, + title_working: working, } } } @@ -447,8 +478,8 @@ mod tests { /// Accept every title so rank tests can exercise the primary-title /// tier without a CLI-specific grammar. - fn normalize_title(&self, title: &str) -> Option { - Some(title.to_string()) + fn normalize_title(&self, title: &str) -> Option<(String, bool)> { + Some((title.to_string(), self.title_working)) } } @@ -545,10 +576,7 @@ mod tests { let working = StubAdapter::working(None); st.resolve(t0, &s, Some(&working), None); - let idle = StubAdapter { - live: None, - label: None, - }; + let idle = StubAdapter::titling(false); s.advance(); assert_eq!( st.resolve(t0, &s, Some(&idle), None).source, @@ -571,6 +599,7 @@ mod tests { let adapter = StubAdapter { live: Some(("Ran echo ok", "stub:ran")), label: None, + title_working: false, }; st.resolve(t0, &s, None, None); @@ -583,6 +612,108 @@ mod tests { ); } + /// Check `working` at each winning tier: read anchors from the rule table and titles + /// from the adapter; expect `false` for registry hits, verbatim titles, markers, and + /// floors. + #[test] + fn working_follows_the_winning_tier() { + let now = Instant::now(); + let alt = FakeScreen::alt_titled("shell", "app"); + let working = |s: &FakeScreen, a: Option<&dyn SummaryAdapter>, b| { + PreviewState::new().resolve(now, s, a, b).working + }; + + // Require working anchor rules to set the flag and completion rules to clear it. + let spinner = StubAdapter { + live: Some(("Hashing…", "claude:spinner")), + label: None, + title_working: false, + }; + let ran = StubAdapter { + live: Some(("Ran ls", "codex:ran")), + label: None, + title_working: false, + }; + assert!(working(&alt, Some(&spinner), None), "anchor: working rule"); + assert!(!working(&alt, Some(&ran), None), "anchor: completion rule"); + assert!( + !working( + &alt, + None, + Some(("awaiting approval", "claude:registry-approval")) + ), + "a registry hit is blocked, never working" + ); + assert!( + !working( + &alt, + Some(&spinner), + Some(("awaiting approval", "claude:registry-approval")) + ), + "the registry outranks the spinner on screen" + ); + + // Use the adapter's flag for normalized titles; leave verbatim titles false. + assert!( + working(&alt, Some(&StubAdapter::titling(true)), None), + "title: true" + ); + assert!( + !working(&alt, Some(&StubAdapter::titling(false)), None), + "title: false" + ); + assert!(!working(&alt, None, None), "verbatim title"); + let mut retained = FakeScreen::primary("shell"); + retained.set_primary_title("app"); + assert!( + working(&retained, Some(&StubAdapter::titling(true)), None), + "retained primary title" + ); + + // Keep the flag false for markers and floors, which carry no agent-state evidence. + let mut bare = FakeScreen::primary("shell"); + bare.enter_alt(); + assert!(!working(&bare, None, None), "marker"); + assert!(!working(&FakeScreen::primary("shell"), None, None), "floor"); + } + + /// Check that both finalization paths clear `working`: retaining an alternate-screen + /// render and resolving a final screen whose anchor carries a working rule. + #[test] + fn finalize_clears_working() { + let t0 = Instant::now(); + let mut st = PreviewState::new(); + let mut s = FakeScreen::alt_titled("prelaunch junk", "agent: working"); + let titled = StubAdapter::titling(true); + assert!( + st.resolve(t0, &s, Some(&titled), None).working, + "premise: the title reported working" + ); + s.leave_alt(); + st.finalize(&s, Some(&titled)); + let p = st.resolve(t0, &s, Some(&titled), None).clone(); + assert_eq!( + (p.text.as_str(), p.frozen, p.working), + ("agent: working", true, false), + "the retained render must drop the flag" + ); + + let mut st = PreviewState::new(); + let s = FakeScreen::primary("✻ Hashing…"); + let spinner = StubAdapter { + live: Some(("Hashing…", "claude:spinner")), + label: None, + title_working: false, + }; + st.finalize(&s, Some(&spinner)); + let p = st.resolve(t0, &s, Some(&spinner), None).clone(); + assert_eq!( + (p.rule, p.frozen, p.working), + (Some("claude:spinner"), true, false), + "a frozen anchor reports nothing even under a working rule" + ); + } + /// Each cascade tier maps one emulator fact to one source. #[test] fn cascade_maps_screen_facts_to_sources() { @@ -1120,8 +1251,8 @@ mod tests { s.set_primary_title("\u{3c0} \u{2819} fix parser"); let p = st.resolve(t0, &s, Some(&OmpSummary), None).clone(); assert_eq!( - (p.text.as_str(), p.source), - ("\u{280b} fix parser", PreviewSource::Title), + (p.text.as_str(), p.source, p.working), + ("fix parser", PreviewSource::Title, true), "premise: the title tier renders while the task lives" ); diff --git a/src/protocol.rs b/src/protocol.rs index d265a8d..612d481 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -12,7 +12,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; use crate::frame::{KIND_CONTROL, KIND_HELLO, KIND_SCREEN}; /// Wire-protocol version; mismatched peers are rejected during the handshake. -pub const PROTOCOL_VERSION: u32 = 14; +pub const PROTOCOL_VERSION: u32 = 15; /// Reserved dashboard label for tasks without a custom group. pub const UNASSIGNED: &str = "Unassigned"; @@ -349,6 +349,9 @@ pub struct Preview { pub rule: Option<&'static str>, /// Whether the preview froze at output-complete and can no longer change. pub frozen: bool, + /// Use `true` for an active-turn spinner rule or title frame. Set to `false` for + /// blocked registry statuses, floors, markers, verbatim titles, and frozen previews. + pub working: bool, } impl Preview { @@ -359,6 +362,7 @@ impl Preview { source: PreviewSource::Floor, rule: None, frozen: false, + working: false, } } } @@ -890,6 +894,9 @@ pub fn encode_event(ev: &Event) -> (u8, Vec) { // Matcher rules are process-local and omitted from the wire. let _ = o.insert("src", tv.preview.source.label()); let _ = o.insert("frozen", tv.preview.frozen); + // Include `working` on the wire. The dashboard has no `rule` field from + // which to derive it. + let _ = o.insert("working", tv.preview.working); let _ = o.insert("started_ms", tv.started_ago.as_millis() as u64); // Each age exists in exactly one phase: `quiet_ms` while // live, `finished_ms` once finished. @@ -976,6 +983,7 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { source: PreviewSource::from_label(tv["src"].as_str()?)?, rule: None, frozen: tv["frozen"].as_bool()?, + working: tv["working"].as_bool()?, }, started_ago: Duration::from_millis(tv["started_ms"].as_u64()?), quiet_ago: opt_ms(&tv["quiet_ms"])?, diff --git a/src/protocol_tests.rs b/src/protocol_tests.rs index 2394530..15e0de4 100644 --- a/src/protocol_tests.rs +++ b/src/protocol_tests.rs @@ -399,11 +399,11 @@ fn mistyped_event_members_are_rejected() { for json in [ r#"{"t":"tasks","tasks":[{"id":"nope"}]}"#, // The cwd must be a base64 string. - r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"/x","tagged":true,"flagship":false,"managed":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#, + r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"/x","tagged":true,"flagship":false,"managed":false,"life":"ok","preview":"","src":"floor","frozen":false,"working":false,"started_ms":0}]}"#, // A present group must be a string; only missing/null means unassigned. - r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":true,"flagship":false,"managed":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0,"group":5}]}"#, + r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":true,"flagship":false,"managed":false,"life":"ok","preview":"","src":"floor","frozen":false,"working":false,"started_ms":0,"group":5}]}"#, // A present name must be a string. - r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":true,"flagship":false,"managed":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0,"name":5}]}"#, + r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":true,"flagship":false,"managed":false,"life":"ok","preview":"","src":"floor","frozen":false,"working":false,"started_ms":0,"name":5}]}"#, r#"{"t":"tasks","tasks":["flat"]}"#, // Numeric member in `names`. r#"{"t":"sessions","names":["ok",5]}"#, @@ -443,6 +443,7 @@ fn tasks_round_trip() { source: PreviewSource::Title, rule: None, frozen: false, + working: false, }, started_ago: Duration::from_millis(4200), quiet_ago: Some(Duration::from_millis(700)), @@ -515,7 +516,7 @@ fn set_group_and_set_name_wire_forms() { fn tasks_frame_label_keys_are_optional() { let labels = |v: &[TaskView]| (v[0].group.clone(), v[0].name.clone()); // "Lw==" is the base64 encoding of "/". - let unlabelled = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"managed":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#; + let unlabelled = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"managed":false,"life":"ok","preview":"","src":"floor","frozen":false,"working":false,"started_ms":0}]}"#; match decode_event(KIND_CONTROL, unlabelled.as_bytes()) { Some(Event::Tasks(v)) => assert_eq!(labels(&v), (None, None)), other => panic!("expected tasks event, got {other:?}"), @@ -526,11 +527,11 @@ fn tasks_frame_label_keys_are_optional() { for (labelled, expected) in [ ( - r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"managed":false,"group":"infra","life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#, + r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"managed":false,"group":"infra","life":"ok","preview":"","src":"floor","frozen":false,"working":false,"started_ms":0}]}"#, (Some("infra".to_string()), None), ), ( - r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"managed":false,"name":"build","life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0}]}"#, + r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"flagship":false,"managed":false,"name":"build","life":"ok","preview":"","src":"floor","frozen":false,"working":false,"started_ms":0}]}"#, (None, Some("build".to_string())), ), ] { @@ -589,8 +590,8 @@ fn tasks_frame_null_ages_decode_as_unknown() { assert_eq!(decode_event(k, s.as_bytes()), Some(tasks)); } -/// Every preview source round-trips with its frozen flag, and `rule` -/// never crosses the wire: an encoded `Some` decodes as `None`. +/// Round-trip the `frozen` and `working` flags for every preview source. Keep `rule` +/// local to the daemon: `encode_event` omits it, so decoding yields `None`. #[test] fn preview_source_and_frozen_round_trip() { let base = TaskView { @@ -626,8 +627,23 @@ fn preview_source_and_frozen_round_trip() { }, ..base.clone() }, + TaskView { + id: 5, + preview: Preview { + source: PreviewSource::Anchor, + working: true, + ..base.preview.clone() + }, + ..base.clone() + }, ]); let (k, p) = encode_event(&tasks); + assert!( + std::str::from_utf8(&p) + .unwrap() + .contains("\"working\":true"), + "the working flag must be its own wire field" + ); assert_eq!(decode_event(k, &p), Some(tasks)); // Encoding omits the process-local matcher rule. @@ -654,8 +670,8 @@ fn preview_source_and_frozen_round_trip() { } } -/// Require a known source and boolean `frozen` and `managed` values in same-version task -/// frames. Round-trip the managed flag in both states. +/// Reject task frames with unknown preview sources or non-boolean `frozen`, `working`, +/// or `managed` fields. Round-trip the managed flag in both states. #[test] fn tasks_frame_requires_row_fields() { for managed in [false, true] { @@ -681,6 +697,11 @@ fn tasks_frame_requires_row_fields() { "false", &["null", "0", "\"false\"", "[]", "{}"][..], ), + ( + "working", + "false", + &["null", "0", "\"false\"", "[]", "{}"][..], + ), ("managed", "false", &["null", "0", "\"true\"", "[]"][..]), ] { let member = format!("\"{field}\":{value}"); diff --git a/src/ui.rs b/src/ui.rs index 0b48f3f..177e922 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -327,8 +327,12 @@ fn row_age(v: &TaskView) -> Duration { } /// A task's lifecycle glyph, shared by the dashboard row and the `/` palette. +/// Use the agent-reported turn status to choose the glyph: an agent can keep thinking +/// past the idle window without writing output. Select adapters by command word so +/// hand-typed agent commands get the same glyph as Agent-page launches. fn status_glyph(v: &TaskView) -> &'static str { match v.lifecycle { + Lifecycle::Active | Lifecycle::Idle if v.preview.working => "●", Lifecycle::Active => "✻", Lifecycle::Idle => "∙", Lifecycle::Ok => "✓", @@ -1320,6 +1324,30 @@ mod tests { } } + /// Show `●` for a working agent while its task is active or idle. Without the flag, + /// keep the lifecycle glyphs; show completion glyphs after the task exits. + #[test] + fn task_row_glyph_follows_the_working_flag() { + let cases = [ + (Lifecycle::Active, true, "●"), + (Lifecycle::Idle, true, "●"), + (Lifecycle::Active, false, "✻"), + (Lifecycle::Idle, false, "∙"), + (Lifecycle::Ok, true, "✓"), + (Lifecycle::Failed, true, "✗"), + ]; + for (lifecycle, working, glyph) in cases { + let mut v = timed_view(lifecycle, None, None); + v.preview.working = working; + let row = task_row(&v, 80); + let body: String = row.chars().skip(2).collect(); + assert!( + body.starts_with(&format!("{glyph} ")), + "{lifecycle:?} working={working}: {row:?}" + ); + } + } + /// Title a dashboard row with the task's custom name when set. #[test] fn task_row_prefers_the_custom_name() { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 8c21cc1..eb39d5e 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -19,7 +19,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; /// Protocol version used by this test suite; must match /// `protocol::PROTOCOL_VERSION`. -pub const PROTOCOL_VERSION: u32 = 14; +pub const PROTOCOL_VERSION: u32 = 15; /// One frame of the given kind: `[u32 len][kind][payload]`. pub fn frame(kind: u8, payload: &[u8]) -> Vec {